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:
| Value | Expected interface | Role |
|---|---|---|
| RENDERER | ISceneRenderer | Turns a scene into an image. |
| USER_INTERFACE | IUserInterface | Window 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:
loadPlugins()walks the./pluginsfolder and loads every.sofile it finds (through thePluginLoader).loadRenderers()fetches theRENDERERplugins, casts them toISceneRenderer*and files them by name: the one whosegetRendererName()contains"Default"becomes the full renderer, the one containing"Viewport"becomes the progressive renderer. If no "Default" renderer is found, startup fails.loadUserInterface()takes the firstUSER_INTERFACEplugin, casts it toIUserInterface*and callscreate(*this). With no interface, the message "Could not find any user interface.. Skipping" is printed and the engine keeps running without a UI.- On shutdown,
unloadUserInterface()callsdestroy()on the interface, then thePluginLoaderdestroys 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:
| Method | Role |
|---|---|
bool load(const std::string &path) | Loads a single .so. Returns false on failure. |
const std::vector<PluginHandle> &getPlugins() const | Returns every loaded plugin. |
const std::vector<PluginHandle> getPlugins(PluginType type) const | Returns 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:
dlopen(path.c_str(), RTLD_NOW); if the handle is null, the library is skipped (returnsfalse).dlsym(handle, "create_plugin")anddlsym(handle, "destroy_plugin"). If either symbol is missing, it immediatelydlclose(handle)and returnsfalse— an incomplete.sois never kept.- 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:
| File | Type | Role | Dependencies |
|---|---|---|---|
renderer_default.so | RENDERER | "Default" renderer — full image render, exported to a PNG. | ISceneRenderer, internal math. |
renderer_viewport.so | RENDERER | "Viewport" renderer — progressive/interactive render for the interface (refinement, selection, hover). | ISceneRenderer + ISelectionAwareRenderer. |
user_interface.so | USER_INTERFACE | SFML 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:
| Method | Role |
|---|---|
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() const | Reports whether a render is active. |
int getCurrentSample() const | Current sample (progressive render progress). |
Render getRender() const | Returns the produced image. |
std::string getRendererName() const | Name identifying the renderer (used for sorting in the Core). |
PluginType getType() const | Returns 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 aplugins/directory to be present there. - Recompilation: adding or changing a plugin goes through a rebuild with
make, which regenerates the.sofiles 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_INTERFACEplugin is used.