Accueil / Système de plugins
Système de plugins
Le rendu et l'interface graphique ne sont pas compilés dans l'exécutable : ce sont des
bibliothèques partagées .so chargées à l'exécution depuis ./plugins.
Le Core les découvre, les ouvre avec dlopen et communique avec elles à travers un
contrat minimal en ABI C. Cette page décrit ce contrat, l'énumération des types, le cycle de
vie complet et la marche à suivre pour écrire son propre renderer.
Pourquoi des plugins ?
L'objectif est de découpler trois responsabilités qui évoluent à des rythmes différents :
- Le cœur (
srcs/core/*) — chargement de scène, construction du BVH, orchestration, export. Il ne dépend d'aucune implémentation concrète de rendu ou d'interface. - Le rendu (
srcs/plugins/renderer/*) — l'algorithme qui transforme une scène en pixels. Plusieurs stratégies coexistent (rendu complet, rendu progressif pour le viewport). - L'interface (
srcs/plugins/user_interface/*) — la fenêtre SFML, les panneaux, les gizmos. Entièrement optionnelle : le moteur fonctionne sans elle.
En chargeant ces modules à l'exécution, on peut ajouter, retirer ou remplacer un renderer
sans recompiler le cœur, et lancer le moteur en mode « sans tête » (rendu vers un PNG) simplement en ne
fournissant pas le plugin d'interface. Le Core ne connaît que les interfaces
(IPlugin, ISceneRenderer, IUserInterface), jamais les classes concrètes.
Le contrat ABI C
Un plugin doit exporter exactement deux symboles, tous deux en extern "C" :
extern "C" IPlugin *create_plugin();
extern "C" void destroy_plugin(IPlugin *plugin);
Dans les plugins livrés, leur corps est trivial — la fabrique alloue l'implémentation concrète, la fonction de destruction la libère :
extern "C" IPlugin *create_plugin()
{
return new DefaultRenderer();
}
extern "C" void destroy_plugin(IPlugin *plugin)
{
delete plugin;
}
Pourquoi une ABI C ? Les noms des symboles C++ sont décorés (name
mangling) d'une manière propre à chaque compilateur : dlsym ne pourrait pas les
retrouver de façon fiable. extern "C" désactive cette décoration et garantit des noms de
symboles stables (create_plugin, destroy_plugin) que le chargeur peut résoudre à
coup sûr. Les deux fonctions ne s'échangent qu'un pointeur IPlugin* — une interface purement
virtuelle — ce qui garde la frontière binaire simple.
Le chargeur ouvre chaque bibliothèque avec dlopen(path, RTLD_NOW) :
le drapeau RTLD_NOW force la résolution immédiate de tous les
symboles au moment du chargement (plutôt qu'à la première utilisation). Un plugin dont une dépendance
manque échoue donc dès l'ouverture, et non plus tard au milieu d'un rendu.
L'énumération PluginType
Chaque plugin s'auto-décrit via getType(), qui renvoie une valeur de l'énumération
PluginType (srcs/common/IPlugin.hpp). Deux types sont actuellement actifs :
| Valeur | Interface attendue | Rôle |
|---|---|---|
| RENDERER | ISceneRenderer | Transforme une scène en image. |
| USER_INTERFACE | IUserInterface | Fenêtre et interface d'édition. |
L'énumération réserve d'autres entrées en commentaire (PRIMITIVE,
LIGHT, SCENE_LOADER, OPTICAL_EFFECT) : elles documentent des
extensions envisagées mais ne sont pas actives aujourd'hui. C'est getType() qui permet au
Core de trier les plugins chargés par catégorie sans connaître leur type C++ réel.
Le contrat IPlugin et le cycle de vie
Toute la hiérarchie des plugins dérive de l'interface minimale IPlugin :
class IPlugin
{
public:
virtual ~IPlugin() = default;
virtual PluginType getType() const = 0;
};
Les interfaces spécialisées y ajoutent leur propre contrat. Un renderer implémente
ISceneRenderer (voir Écrire un renderer). L'interface
graphique implémente IUserInterface, qui ajoute deux méthodes de cycle de vie :
class IUserInterface : public IPlugin
{
public:
virtual void create(ICoreAccess &core_access) = 0;
virtual void destroy() = 0;
PluginType getType() const override = 0;
};
create(ICoreAccess&) reçoit une référence vers le Core (via l'interface
ICoreAccess) : c'est par ce canal que l'interface interroge et pilote le moteur.
La séquence d'amorçage effectuée par le Core est la suivante :
loadPlugins()parcourt le dossier./pluginset charge chaque fichier.sorencontré (via lePluginLoader).loadRenderers()récupère les plugins de typeRENDERER, les convertit enISceneRenderer*et les range selon leur nom : celui dontgetRendererName()contient"Default"devient le renderer complet, celui qui contient"Viewport"devient le renderer progressif. Si aucun renderer « Default » n'est trouvé, le démarrage échoue.loadUserInterface()prend le premier plugin de typeUSER_INTERFACE, le convertit enIUserInterface*et appellecreate(*this). En l'absence d'interface, le message « Could not find any user interface.. Skipping » est affiché et le moteur poursuit sans UI.- À l'arrêt,
unloadUserInterface()appelledestroy()sur l'interface, puis lePluginLoaderdétruit et referme tous les.so(voir ci-dessous).
Le chargeur : IPluginLoader / PluginLoader
Le chargeur (srcs/core/PluginLoader.cpp, contrat srcs/common/IPluginLoader.hpp)
encapsule tout l'usage de dlopen / dlsym / dlclose. Il expose :
| Méthode | Rôle |
|---|---|
bool load(const std::string &path) | Charge un unique .so. Renvoie false en cas d'échec. |
const std::vector<PluginHandle> &getPlugins() const | Renvoie tous les plugins chargés. |
const std::vector<PluginHandle> getPlugins(PluginType type) const | Renvoie les plugins d'un type donné (filtrés via getType()). |
void unloadAll() | Détruit chaque instance puis referme chaque bibliothèque. |
Chaque bibliothèque ouverte est mémorisée dans un PluginHandle :
struct PluginHandle
{
void* handle; // valeur renvoyée par dlopen
IPlugin* instance; // objet créé par create_plugin()
void (*destroy)(IPlugin*); // pointeur vers destroy_plugin
};
Le déroulé de load(path) est :
dlopen(path.c_str(), RTLD_NOW); si le handle est nul, la bibliothèque est ignorée (retourfalse).dlsym(handle, "create_plugin")etdlsym(handle, "destroy_plugin"). Si l'un des deux symboles manque, ondlclose(handle)immédiatement et on renvoiefalse— un.soincomplet n'est jamais conservé.- Sinon
create()est appelé et le triplet{handle, instance, destroy}est ajouté à la liste interne.
unloadAll() parcourt cette liste et, pour chaque entrée, appelle
destroy(instance) (ce qui exécute le destructeur du plugin — polices, panneaux,
render-textures de l'UI, etc.) puis dlclose(handle), avant de vider la liste.
Le destructeur de PluginLoader appelle
unloadAll(). Sans cela, les objets plugins survivraient jusqu'à la fin du processus et
AddressSanitizer les signalerait comme des fuites à l'arrêt. La libération est donc automatique et
ordonnée.
Les plugins livrés
Le projet construit trois bibliothèques déposées dans ./plugins :
| Fichier | Type | Rôle | Dépendances |
|---|---|---|---|
renderer_default.so | RENDERER | Renderer « Default » — rendu complet de l'image, exporté vers un PNG. | ISceneRenderer, maths internes. |
renderer_viewport.so | RENDERER | Renderer « Viewport » — rendu progressif/interactif pour l'interface (raffinement, sélection, survol). | ISceneRenderer + ISelectionAwareRenderer. |
user_interface.so | USER_INTERFACE | Interface graphique SFML : viewport, panneaux, gizmos, fenêtres. | IUserInterface, ICoreAccess, SFML. |
Les deux renderers sont distingués uniquement par la chaîne renvoyée par getRendererName()
("Default" et "Viewport"), que loadRenderers() recherche par
sous-chaîne.
Écrire un renderer
Un renderer implémente le contrat ISceneRenderer (srcs/common/ISceneRenderer.hpp),
qui dérive de IPlugin. Ses méthodes :
| Méthode | Rôle |
|---|---|
void renderScene(const IScene &scene) | Lance le rendu de la scène (généralement dans un thread). |
void setPixel(int x, int y, Color color) | Écrit un pixel dans le tampon de sortie. |
void markSceneDirty() | Signale que la scène a changé (implémentation par défaut vide). |
void stopRendering() | Interrompt un rendu en cours. |
bool isRendering() const | Indique si un rendu est actif. |
int getCurrentSample() const | Échantillon courant (progression du rendu progressif). |
Render getRender() const | Renvoie l'image produite. |
std::string getRendererName() const | Nom identifiant le renderer (sert au tri dans le Core). |
PluginType getType() const | Renvoie PluginType::RENDERER. |
L'image est transportée par la structure Render définie dans le même en-tête :
struct Render
{
int size_x;
int size_y;
std::vector<Color> pixels;
};
Voici le squelette minimal complet d'un plugin renderer, avec les deux fonctions exportées :
#include "../../common/ISceneRenderer.hpp"
#include <atomic>
namespace rc
{
class MyRenderer : public ISceneRenderer
{
private:
Render _render = {0, 0, std::vector<Color>()};
std::atomic<bool> _rendering = false;
public:
void renderScene(const IScene &scene) override
{
// Boucle sur les pixels, lance des rayons, appelle setPixel(...)
}
void setPixel(int x, int y, Color color) override
{
_render.pixels[y * _render.size_x + x] = color;
}
void stopRendering() override { _rendering = false; }
bool isRendering() const override { return (_rendering); }
int getCurrentSample() const override { return (0); }
Render getRender() const override { return (_render); }
std::string getRendererName() const override { return ("MyRenderer"); }
PluginType getType() const override { return (PluginType::RENDERER); }
};
// --- Contrat ABI C : les deux symboles obligatoires ---
extern "C" IPlugin *create_plugin()
{
return (new MyRenderer());
}
extern "C" void destroy_plugin(IPlugin *plugin)
{
delete (plugin);
}
}
Pour que le Core reconnaisse votre renderer comme renderer complet,
faites en sorte que getRendererName() contienne "Default" ; pour le
renderer progressif du viewport, incluez "Viewport". Sinon le plugin est chargé
mais n'est sélectionné par aucun des deux rôles.
Primitives et lumières ne sont pas des plugins
Point important : les primitives (sphère, plan, cube, cône…) et
les lumières (ponctuelle, directionnelle) ne sont pas des
bibliothèques .so. Bien qu'elles vivent dans srcs/plugins/primitive/* et
srcs/plugins/light/*, elles sont compilées directement dans l'exécutable
principal et instanciées via PrimitiveFactory et LightFactory (ainsi
que les builders lors de l'analyse de scène).
Seuls les renderers et l'interface sont chargés dynamiquement à
l'exécution. C'est cohérent avec l'énumération PluginType, dont les entrées
PRIMITIVE et LIGHT restent en commentaire : le passage de la géométrie et
des lumières en plugins dynamiques est une évolution envisagée mais non réalisée à ce jour.
Limites et remarques pratiques
- Chemin relatif : le dossier scanné est
./plugins, résolu par rapport au répertoire de lancement du programme (directory_iterator("./plugins")). Lancer le binaire depuis un autre dossier suppose qu'un dossierplugins/y soit présent. - Recompilation : ajouter ou modifier un plugin passe par une recompilation avec
make, qui régénère les.sodans./plugins. Il n'y a pas de rechargement à chaud des plugins (contrairement au rechargement à chaud du fichier de scène). - Un seul type actif à la fois côté Core : parmi les renderers chargés, le Core ne
retient qu'un renderer « Default » et un renderer « Viewport » ; pour l'interface, seul le premier
plugin
USER_INTERFACEvalide est utilisé.