Raytracer docs 🌐 FR

Home / Plugin system

Plugin system

Rendering and the graphical interface are not compiled into the executable: they are shared libraries (.so) loaded at runtime from ./plugins. The Core discovers them, opens them with dlopen and talks to them through a minimal C ABI contract. This page describes that contract, the type enumeration, the full lifecycle and how to write your own renderer.

Why plugins?

The goal is to decouple three responsibilities that evolve at different paces:

  • The core (srcs/core/*) — scene loading, BVH construction, orchestration, export. It depends on no concrete rendering or interface implementation.
  • Rendering (srcs/plugins/renderer/*) — the algorithm that turns a scene into pixels. Several strategies coexist (full render, progressive render for the viewport).
  • The interface (srcs/plugins/user_interface/*) — the SFML window, the panels, the gizmos. Entirely optional: the engine runs without it.

By loading these modules at runtime, a renderer can be added, removed or replaced without recompiling the core, and the engine can run headless (rendering to a PNG) simply by not shipping the interface plugin. The Core only ever knows the interfaces (IPlugin, ISceneRenderer, IUserInterface), never the concrete classes.

The C ABI contract

A plugin must export exactly two symbols, both declared extern "C":

extern "C" IPlugin *create_plugin();
extern "C" void      destroy_plugin(IPlugin *plugin);

In the shipped plugins their bodies are trivial — the factory allocates the concrete implementation, the destroy function frees it:

extern "C" IPlugin *create_plugin()
{
    return new DefaultRenderer();
}

extern "C" void destroy_plugin(IPlugin *plugin)
{
    delete plugin;
}

Why a C ABI? C++ symbol names are mangled in a way specific to each compiler, so dlsym could not reliably find them. extern "C" disables that mangling and guarantees stable symbol names (create_plugin, destroy_plugin) that the loader can resolve with certainty. The two functions only exchange an IPlugin* pointer — a purely virtual interface — which keeps the binary boundary simple.

The loader opens each library with dlopen(path, RTLD_NOW): the RTLD_NOW flag forces immediate resolution of all symbols at load time (rather than on first use). A plugin with a missing dependency therefore fails right at opening, not later in the middle of a render.

The PluginType enumeration

Every plugin describes itself via getType(), which returns a value of the PluginType enumeration (srcs/common/IPlugin.hpp). Two types are currently active:

ValueExpected interfaceRole
RENDERERISceneRendererTurns a scene into an image.
USER_INTERFACEIUserInterfaceWindow and editing interface.

The enumeration reserves further entries as comments (PRIMITIVE, LIGHT, SCENE_LOADER, OPTICAL_EFFECT): they document envisioned extensions but are not active today. getType() is what lets the Core sort the loaded plugins by category without knowing their real C++ type.

The IPlugin contract and lifecycle

The whole plugin hierarchy derives from the minimal IPlugin interface:

class IPlugin
{
    public:
        virtual ~IPlugin() = default;
        virtual PluginType getType() const = 0;
};

Specialised interfaces add their own contract on top. A renderer implements ISceneRenderer (see Writing a renderer). The graphical interface implements IUserInterface, which adds two lifecycle methods:

class IUserInterface : public IPlugin
{
    public:
        virtual void create(ICoreAccess &core_access) = 0;
        virtual void destroy() = 0;
        PluginType getType() const override = 0;
};

create(ICoreAccess&) receives a reference to the Core (through the ICoreAccess interface): this is the channel through which the interface queries and drives the engine. The bootstrap sequence performed by the Core is:

  1. loadPlugins() walks the ./plugins folder and loads every .so file it finds (through the PluginLoader).
  2. loadRenderers() fetches the RENDERER plugins, casts them to ISceneRenderer* and files them by name: the one whose getRendererName() contains "Default" becomes the full renderer, the one containing "Viewport" becomes the progressive renderer. If no "Default" renderer is found, startup fails.
  3. loadUserInterface() takes the first USER_INTERFACE plugin, casts it to IUserInterface* and calls create(*this). With no interface, the message "Could not find any user interface.. Skipping" is printed and the engine keeps running without a UI.
  4. On shutdown, unloadUserInterface() calls destroy() on the interface, then the PluginLoader destroys and closes every .so (see below).

The loader: IPluginLoader / PluginLoader

The loader (srcs/core/PluginLoader.cpp, contract srcs/common/IPluginLoader.hpp) encapsulates all use of dlopen / dlsym / dlclose. It exposes:

MethodRole
bool load(const std::string &path)Loads a single .so. Returns false on failure.
const std::vector<PluginHandle> &getPlugins() constReturns every loaded plugin.
const std::vector<PluginHandle> getPlugins(PluginType type) constReturns the plugins of a given type (filtered via getType()).
void unloadAll()Destroys each instance then closes each library.

Each opened library is recorded in a PluginHandle:

struct PluginHandle
{
    void*    handle;                 // value returned by dlopen
    IPlugin* instance;               // object created by create_plugin()
    void   (*destroy)(IPlugin*);     // pointer to destroy_plugin
};

The flow of load(path) is:

  1. dlopen(path.c_str(), RTLD_NOW); if the handle is null, the library is skipped (returns false).
  2. dlsym(handle, "create_plugin") and dlsym(handle, "destroy_plugin"). If either symbol is missing, it immediately dlclose(handle) and returns false — an incomplete .so is never kept.
  3. Otherwise create() is called and the triplet {handle, instance, destroy} is appended to the internal list.

unloadAll() walks that list and, for each entry, calls destroy(instance) (which runs the plugin's destructor — the UI's fonts, panels, render textures, etc.) then dlclose(handle), before clearing the list.

The destructor of PluginLoader calls unloadAll(). Without it, the plugin objects would live until process exit and AddressSanitizer would report them as leaks on shutdown. Cleanup is therefore automatic and ordered.

The shipped plugins

The project builds three libraries dropped into ./plugins:

FileTypeRoleDependencies
renderer_default.soRENDERER"Default" renderer — full image render, exported to a PNG.ISceneRenderer, internal math.
renderer_viewport.soRENDERER"Viewport" renderer — progressive/interactive render for the interface (refinement, selection, hover).ISceneRenderer + ISelectionAwareRenderer.
user_interface.soUSER_INTERFACESFML graphical interface: viewport, panels, gizmos, windows.IUserInterface, ICoreAccess, SFML.

The two renderers are told apart solely by the string returned by getRendererName() ("Default" and "Viewport"), which loadRenderers() matches as a substring.

Writing a renderer

A renderer implements the ISceneRenderer contract (srcs/common/ISceneRenderer.hpp), which derives from IPlugin. Its methods:

MethodRole
void renderScene(const IScene &scene)Starts rendering the scene (usually in a thread).
void setPixel(int x, int y, Color color)Writes a pixel into the output buffer.
void markSceneDirty()Signals that the scene changed (default empty implementation).
void stopRendering()Interrupts an ongoing render.
bool isRendering() constReports whether a render is active.
int getCurrentSample() constCurrent sample (progressive render progress).
Render getRender() constReturns the produced image.
std::string getRendererName() constName identifying the renderer (used for sorting in the Core).
PluginType getType() constReturns PluginType::RENDERER.

The image is carried by the Render struct defined in the same header:

struct Render
{
    int                size_x;
    int                size_y;
    std::vector<Color> pixels;
};

Here is a complete minimal skeleton of a renderer plugin, with the two exported functions:

#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
            {
                // Loop over pixels, cast rays, call 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); }
    };

    // --- C ABI contract: the two mandatory symbols ---
    extern "C" IPlugin *create_plugin()
    {
        return (new MyRenderer());
    }

    extern "C" void destroy_plugin(IPlugin *plugin)
    {
        delete (plugin);
    }
}

For the Core to recognise your renderer as the full renderer, make sure getRendererName() contains "Default"; for the progressive viewport renderer, include "Viewport". Otherwise the plugin is loaded but selected for neither role.

Primitives and lights are not plugins

Important point: primitives (sphere, plane, cube, cone…) and lights (point, directional) are not .so libraries. Even though they live under srcs/plugins/primitive/* and srcs/plugins/light/*, they are compiled straight into the main executable and instantiated via PrimitiveFactory and LightFactory (as well as the builders during scene parsing).

Only the renderers and the interface are loaded dynamically at runtime. This matches the PluginType enumeration, whose PRIMITIVE and LIGHT entries stay commented out: turning geometry and lights into dynamic plugins is an envisioned extension, not something implemented today.

Limits and practical notes

  • Relative path: the scanned folder is ./plugins, resolved relative to the program's launch directory (directory_iterator("./plugins")). Launching the binary from another folder requires a plugins/ directory to be present there.
  • Recompilation: adding or changing a plugin goes through a rebuild with make, which regenerates the .so files in ./plugins. There is no hot-reload of plugins (unlike the hot-reload of the scene file).
  • One active type at a time on the Core side: among the loaded renderers the Core keeps only a "Default" and a "Viewport" renderer; for the interface, only the first valid USER_INTERFACE plugin is used.