SceneNode Polymorphic System Architecture in LuisaRender: A Deep Dive into Extensible Scene Graphs

LuisaRender implements a type-safe, extensible scene graph using a tag-based polymorphic hierarchy centered on the SceneNode abstract base class, enabling runtime plugin loading without core code modification.

The luisagroup/luisarender repository employs a sophisticated polymorphic architecture to manage diverse rendering components—from cameras and shapes to integrators and textures—through a unified interface. This system balances compile-time type safety with runtime extensibility, allowing third-party developers to inject new node types via dynamic shared libraries while maintaining strict validation at the scene description language (SDL) boundary.

Core Design Principles of the SceneNode Polymorphic System

The architecture rests on three orthogonal pillars that collectively enable safe, plugin-based extension of the scene graph.

Tag-Based Type Identification

Every node category in LuisaRender is identified by a strongly-typed enumeration defined in src/sdl/scene_node_tag.h. The SceneNodeTag enum encompasses all concrete types including CAMERA, SHAPE, SURFACE, INTEGRATOR, and SPECTRUM.

The base class stores this tag in a bit-packed field alongside a back-pointer to the owning Scene:

// src/base/scene_node.h
class SceneNode {
private:
    intptr_t _scene : 56u;          // back-pointer to the owning Scene
    Tag      _tag   : 8u;           // SceneNodeTag stored in 8-bit field
public:
    using Tag = SceneNodeTag;
    // ...
    [[nodiscard]] auto tag() const noexcept { return _tag; }
};

This tag enables runtime validation during node construction, ensuring that a Camera description cannot instantiate a Shape node.

Descriptor-Driven Construction

The SceneNodeDesc class (src/sdl/scene_node_desc.h) serves as the sole data structure bridging the SDL file format and the C++ runtime. It encapsulates:

  • Identifier: The node's name in the scene graph
  • Tag: The expected SceneNodeTag
  • Implementation type: The concrete class name (impl_type)
  • Properties: Key-value pairs parsed from the SDL file

The descriptor distinguishes between internal nodes (engine-generated, never user-facing) and declarations (user-defined entities). This separation allows the engine to auto-generate auxiliary nodes while preventing identifier collisions with user content.

Plugin-Based Runtime Polymorphism

Concrete SceneNode implementations reside in dynamic shared libraries following the naming convention luisa-render-<tag>-<impl_type>.so (or .dll/.dylib). Each plugin exposes a standard C API through the LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN macro defined in src/base/scene_node.h:

#define LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN(cls)            \
    LUISA_EXPORT_API luisa::render::SceneNode *create(      \
        luisa::render::Scene *scene,                       \
        const luisa::render::SceneNodeDesc *desc)          \
        LUISA_NOEXCEPT {                                   \
        return luisa::new_with_allocator<cls>(scene, desc);\
    }                                                      \
    LUISA_EXPORT_API void destroy(luisa::render::SceneNode *node) \
        LUISA_NOEXCEPT { luisa::delete_with_allocator(node); }

This macro generates create and destroy functions using LuisaRender's internal allocator, ensuring memory consistency across the plugin boundary.

The SceneNode Base Class and Type Safety

The SceneNode abstract base class enforces type safety through constructor validation and pure virtual interfaces. Located in src/base/scene_node.h, the class requires all derived types to implement impl_type(), returning a string view identifying the concrete implementation:

// src/base/scene_node.h
class SceneNode {
public:
    SceneNode(const Scene *scene, const SceneNodeDesc *desc, Tag tag) noexcept;
    virtual ~SceneNode() noexcept = default;
    [[nodiscard]] virtual luisa::string_view impl_type() const noexcept = 0;
    
    // Accessors
    [[nodiscard]] auto scene() const noexcept { return reinterpret_cast<const Scene *>(_scene); }
    [[nodiscard]] auto tag() const noexcept { return _tag; }
};

The constructor implementation in src/base/scene_node.cpp performs critical validation:

// src/base/scene_node.cpp
SceneNode::SceneNode(const Scene *scene, const SceneNodeDesc *desc, Tag tag) noexcept
    : _scene{reinterpret_cast<intptr_t>(scene)}, _tag{tag} {
    
    // Validate that the descriptor's tag matches the expected tag
    if (!desc->is_internal() && desc->tag() != tag) [[unlikely]] {
        LUISA_ERROR("Scene node tag mismatch: expected {}, got {}.",
                    scene_node_tag_description(tag),
                    scene_node_tag_description(desc->tag()));
    }
    
    // Ensure the descriptor is fully defined
    if (!desc->is_defined()) [[unlikely]] {
        LUISA_ERROR("Undefined scene node description.");
    }
}

This validation prevents type confusion attacks where a malicious or malformed SDL file might attempt to instantiate a Light node using Shape parameters.

Implementing Custom Scene Nodes

Third-party developers extend LuisaRender by implementing the SceneNode interface and exposing it through the plugin macro. The following example demonstrates a custom surface shader:

// my_surface.cpp
#include <base/surface.h>

class MyFancySurface final : public luisa::render::Surface {
public:
    MyFancySurface(const luisa::render::Scene *scene,
                   const luisa::render::SceneNodeDesc *desc) noexcept
        : luisa::render::SceneNode{scene, desc,
          luisa::render::SceneNodeTag::SURFACE} {}

    luisa::string_view impl_type() const noexcept override { return "MyFancy"; }

    // Implement required Surface interface methods...
    void prepare(luisa::render::Pipeline &pipeline) noexcept override;
    void evaluate(...) const noexcept override;
};

LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN(MyFancySurface)

Compilation requires linking against the LuisaRender base headers and producing a shared library with the correct naming convention:


# Compile to shared library

c++ -shared -fPIC -std=c++20 \
    -I/path/to/luisarender/include \
    my_surface.cpp \
    -o luisa-render-surface-myfancy.so

# Install to runtime directory

mv luisa-render-surface-myfancy.so ./plugins/

The SDL file references the implementation by the string returned from impl_type():

surface MySurface:
    impl: MyFancy
    albedo: [1.0, 0.5, 0.2]
    roughness: 0.3

Dynamic Loading and Node Instantiation

The Scene class in src/base/scene.cpp orchestrates the dynamic loading process through the load_node method (lines 79-131). This method implements a caching mechanism to ensure that nodes referenced multiple times in the SDL file are instantiated only once.

The loading workflow follows these steps:

  1. Descriptor validation: Verify the node is defined and the tag matches the expected category.

  2. Cache lookup: Check if the node identifier already exists in _config->nodes to prevent duplicate instantiation.

  3. Plugin resolution: Build the module name using the pattern luisa-render-<tag>-<impl_type>, convert to lowercase, and query the registry:

    auto &&plugin = detail::scene_plugin_load(
        _context.runtime_directory(), tag, desc->impl_type());
  4. Function binding: Retrieve the create and destroy function pointers from the dynamic module using the standard C API.

  5. Instantiation: Invoke the create function with the scene pointer and descriptor, producing a SceneNode* that is cached and returned.

  6. Type casting: The caller (e.g., load_camera, load_shape) performs a dynamic_cast to the specific interface type (e.g., Camera*, Shape*).

The plugin registry maintains a map of loaded modules to avoid reloading shared libraries for subsequent node instantiations of the same type, significantly improving scene load performance for complex scenes with thousands of nodes.

Summary

  • Tag-based architecture: The SceneNodeTag enum provides compile-time type categories while enabling runtime validation through the _tag bit-field in the base class.

  • Descriptor bridge: SceneNodeDesc isolates SDL parsing from C++ implementation, carrying identifiers, implementation types, and properties across the language boundary.

  • Plugin extensibility: The LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN macro generates standard C entry points, allowing third-party nodes to reside in dynamically loaded shared libraries following the luisa-render-<tag>-<impl> naming convention.

  • Type safety: Constructor validation in src/base/scene_node.cpp ensures descriptor tags match expected node types, preventing category confusion during scene loading.

  • Caching loader: The Scene::load_node implementation in src/base/scene.cpp maintains a registry of loaded plugins and instantiated nodes to optimize multi-reference scenarios.

Frequently Asked Questions

How does the SceneNode polymorphic system handle thread safety during plugin loading?

The plugin registry in src/base/scene.cpp uses a global unordered_map protected by the scene loading context. While the dynamic loading itself occurs during the single-threaded scene parsing phase, instantiated SceneNode objects are immutable after construction and safe for concurrent access during rendering. The create and destroy functions exported by plugins are marked LUISA_NOEXCEPT and must be thread-safe for allocation operations.

What is the performance overhead of the dynamic plugin system compared to static linking?

The primary overhead occurs at scene load time when resolving plugin symbols and loading shared libraries. The Scene::load_node implementation mitigates this through a caching registry that prevents duplicate loads of the same module. At render time, virtual dispatch through SceneNode vtables incurs no additional overhead compared to statically linked polymorphism, as the plugin's create function returns a standard C++ object with virtual functions.

Can a single plugin library implement multiple SceneNode types?

No, the current architecture enforces a one-to-one mapping between shared library files and node implementations. The naming convention luisa-render-<tag>-<impl_type> and the registry lookup logic in detail::scene_plugin_load expect a specific module name derived from the tag and implementation type. To provide multiple node types, developers must compile separate shared libraries for each distinct SceneNode subclass.

How does the system validate that a plugin implements the correct interface for its declared tag?

Validation occurs at two levels. First, the SceneNode constructor in src/base/scene_node.cpp verifies that the descriptor's tag matches the tag passed to the base class constructor. Second, the loading code in src/base/scene.cpp uses dynamic_cast after plugin instantiation to convert the SceneNode* to the specific interface type (e.g., Camera*, Shape*). If the plugin returns an object of the wrong type, the dynamic_cast fails, triggering a runtime error before the scene graph is fully constructed.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →