Raytracer docs 🌐 EN

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 :

ValeurInterface attendueRôle
RENDERERISceneRendererTransforme une scène en image.
USER_INTERFACEIUserInterfaceFenê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 :

  1. loadPlugins() parcourt le dossier ./plugins et charge chaque fichier .so rencontré (via le PluginLoader).
  2. loadRenderers() récupère les plugins de type RENDERER, les convertit en ISceneRenderer* et les range selon leur nom : celui dont getRendererName() 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.
  3. loadUserInterface() prend le premier plugin de type USER_INTERFACE, le convertit en IUserInterface* et appelle create(*this). En l'absence d'interface, le message « Could not find any user interface.. Skipping » est affiché et le moteur poursuit sans UI.
  4. À l'arrêt, unloadUserInterface() appelle destroy() sur l'interface, puis le PluginLoader dé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éthodeRôle
bool load(const std::string &path)Charge un unique .so. Renvoie false en cas d'échec.
const std::vector<PluginHandle> &getPlugins() constRenvoie tous les plugins chargés.
const std::vector<PluginHandle> getPlugins(PluginType type) constRenvoie 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 :

  1. dlopen(path.c_str(), RTLD_NOW) ; si le handle est nul, la bibliothèque est ignorée (retour false).
  2. dlsym(handle, "create_plugin") et dlsym(handle, "destroy_plugin"). Si l'un des deux symboles manque, on dlclose(handle) immédiatement et on renvoie false — un .so incomplet n'est jamais conservé.
  3. 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 :

FichierTypeRôleDépendances
renderer_default.soRENDERERRenderer « Default » — rendu complet de l'image, exporté vers un PNG.ISceneRenderer, maths internes.
renderer_viewport.soRENDERERRenderer « Viewport » — rendu progressif/interactif pour l'interface (raffinement, sélection, survol).ISceneRenderer + ISelectionAwareRenderer.
user_interface.soUSER_INTERFACEInterface 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éthodeRô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() constIndique si un rendu est actif.
int getCurrentSample() constÉchantillon courant (progression du rendu progressif).
Render getRender() constRenvoie l'image produite.
std::string getRendererName() constNom identifiant le renderer (sert au tri dans le Core).
PluginType getType() constRenvoie 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 dossier plugins/ y soit présent.
  • Recompilation : ajouter ou modifier un plugin passe par une recompilation avec make, qui régénère les .so dans ./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_INTERFACE valide est utilisé.