Skip to content

Rendering

Most games draw through components attached to scene nodes. For example, this adds a sprite using a path from the game's assets/ directory:

auto* player = createNode("player");
auto* sprite = player->addComponent<snowpulse::SpriteRenderer>();
sprite->sprite(appContext()->assetManager()->loadSprite("sprites/player.png"));
addChild(player);

Use Components for built-in renderers and the sections below when you need custom geometry, shaders, batching behavior, or precise draw ordering.

Internally, Snowpulse gathers one RenderPlan while preserving two distinct rendering paths. Legacy 2D components submit RenderItems that are grouped into RenderBatch and RenderSortLayer objects. Opt-in ModelRenderer components submit ModelRenderItems to dedicated forward-rendered 3D passes. Effekseer may submit a native 2D command or a depth-aware native 3D command.

Render Flow

  1. Scene gathers active Drawable components.
  2. Components call submit, submitModel, submitNative, or submitNative3D on RenderQueue.
  3. RenderQueue::buildBatches() batches compatible 2D items, preserves the existing 2D sort/compositing plan, frustum-culls eligible meshes, and sorts opaque and transparent 3D submissions independently.
  4. When 3D content exists, the renderer executes the sun-shadow pass, opaque and masked 3D front-to-back, transparent 3D back-to-front, then legacy 2D and pixel-space UI/overlay. Opaque draws write depth; transparent draws do not.

Depth is cleared once for the 3D world. With no 3D submissions, the established 2D render plan, ordering, compositing, and coordinate behavior are unchanged. See Forward-rendered 3D for the material, lighting, shadow, custom shader, and debug-draw contracts.

Sorting and Filtering

  • RenderItem::sortOrder controls draw order. Larger values render first; smaller values render later and therefore appear in the foreground.
  • TextureFiltering can be Nearest, Linear, Anisotropic, or Inherit.
  • BlendMode can be Normal, Additive, Multiply, or Screen.
  • RenderItem::indices enables custom triangle geometry when submitting RenderItem directly.
  • RenderItem::premultipliedAlpha selects PMA blending when needed.
  • RenderItem::scissorEnabled/scissorRect provide clip rectangles (used by UI).

Screen is a lightening blend useful for glows and highlights; black leaves the destination unchanged while brighter source colors move it toward white.

TextureFiltering::Inherit resolves to the queue default. The default is Linear; the normal application render loop copies AppContext::textureFiltering() to the frame queue before components draw:

scene->appContext()->textureFiltering(snowpulse::TextureFiltering::Nearest);

// Useful for a manually managed queue outside the normal application loop.
snowpulse::RenderQueue queue;
queue.defaultTextureFiltering(snowpulse::TextureFiltering::Anisotropic);

Set an individual component or RenderItem to Nearest, Linear, or Anisotropic to override that default. The RenderQueue* supplied to Drawable::draw() is the scene's queue for the current frame; submit to it and let the engine clear it before gathering and call buildBatches() afterward.

RenderQueue::batches() exposes the 2D batches for statistics and diagnostics. sortLayers() is the complete render input and is what IRenderer::render() consumes.

RenderItem and Custom Drawable

Implement both Component and Drawable to add a renderable component. A RenderItem with no explicit vertices submits a quad; the queue generates its vertices from the transform, size, sprite-frame metadata, and UV rectangle. This example shows every public RenderItem field:

class CustomDrawable final : public snowpulse::Component,
                             public snowpulse::Drawable {
public:
    explicit CustomDrawable(snowpulse::SpriteRegion region)
        : _region(region) {}

    void draw(snowpulse::RenderQueue* queue) override {
        if (!isActive || !queue || !owner()) {
            return;
        }

        snowpulse::RenderItem item;
        item.worldMatrix = owner()->transform().worldMatrix;
        item.color = { 0.75f, 0.9f, 1.0f, 0.85f };
        item.size = _region.hasSize ? _region.size : glm::vec2 { 64.0f, 64.0f };
        item.uvRect = _region.uvRect;
        item.sourceSize = _region.sourceSize;
        item.frameSize = _region.frameSize;
        item.frameOffset = _region.frameOffset;
        item.rotated = _region.rotated;
        item.primitive = snowpulse::RenderPrimitive::Triangles;
        item.vertices = {};  // Empty selects generated-quad geometry.
        item.indices = {};
        item.shaderId = snowpulse::kShaderDefault;
        item.shaderParams = { 0.0f, 0.0f, 0.0f, 0.0f };
        item.sortOrder = sortOrder;
        item.texture = _region.texture;
        item.textureFiltering = snowpulse::TextureFiltering::Inherit;
        item.blendMode = snowpulse::BlendMode::Screen;
        item.premultipliedAlpha = true;
        item.scissorEnabled = false;
        item.scissorRect = { 0.0f, 0.0f, 0.0f, 0.0f };
        queue->submit(std::move(item));
    }

private:
    snowpulse::SpriteRegion _region;
};

const auto region = assetManager()->loadSprite("visuals/region.png");
auto* visual = node->addComponent<CustomDrawable>(region);
visual->sortOrder = -20;

For custom triangles, populate vertices and optionally indices. Unlike the generated-quad path, custom vertex positions and colors are copied as supplied, so transform local positions before submitting them:

snowpulse::RenderItem item;
item.primitive = snowpulse::RenderPrimitive::Triangles;
item.sortOrder = sortOrder;
item.blendMode = snowpulse::BlendMode::Screen;

const auto& world = owner()->transform().worldMatrix;
const auto vertex = [&world](const glm::vec2& local) {
    const glm::vec3 position(world * glm::vec4(local, 0.0f, 1.0f));
    return snowpulse::RenderVertex(
        position, glm::vec2 { 0.0f, 0.0f }, glm::vec4 { 1.0f });
};
item.vertices = {
    vertex({ -24.0f, -16.0f }),
    vertex({ 24.0f, -16.0f }),
    vertex({ 0.0f, 28.0f }),
};
item.indices = { 0, 1, 2 };
queue->submit(std::move(item));

Models, Native Commands, and Lighting

submitModel() accepts persistent mesh data, a model matrix, optional GPU bone palette, and a Material3D snapshot. ModelRenderer is the normal component interface; FbxRenderer is a deprecated compatibility wrapper. Built-in materials use metallic-roughness PBR or unlit shading and support opaque, mask, and blend alpha modes.

submitNative(sortOrder, callback) inserts a depth-disabled callback into the legacy 2D sequence. submitNative3D(sortOrder, callback, depthTest, depthWrite) inserts a callback into the perspective world pass. Effekseer selects between these paths with its renderIn3D setting; the native particle system remains 2D.

Each scene supplies a SceneLighting value to the renderer:

  • ambientColor and ambientIntensity
  • a DirectionalLight3D snapshot
  • all active PointLight3D and SpotLight3D candidates

The forward renderer selects one directional, up to eight point, and up to four spot lights for each draw by priority and bounds influence. One directional light may cast a stabilized orthographic shadow. The legacy directional fields remain as a source-compatible fallback when no DirectionalLight3D component is present. Built-in sprite, primitive, text, and UI shaders remain unlit.

Primitive Rendering

Use QuadRenderer, TriangleRenderer, and CircleRenderer for texture-free solid-color shapes. TriangleRenderer submits a three-vertex RenderPrimitive::Triangles item from local-space points, so it can represent arrows, wedges, simple markers, and custom one-triangle debug geometry without building a full RenderItem manually.

Shaders

The renderer supports registering custom 2D shaders:

renderer->registerShader(myShaderId, vertexSrc, fragmentSrc, /*usesParams=*/true);

RenderItem::shaderId and RenderItem::shaderParams select the shader and any custom parameters.

With RENDERING3D ON, use registerShader3D(Shader3DDesc) for portable mesh shaders. Custom 3D shaders follow the same frame, object, material, light, and skinning bindings as the built-in PBR, unlit, and shadow variants; the complete binding table is in Forward-rendered 3D.

Android uses the shared GLES3 renderer core with a thin EGL presentation binding. It requests RGBA8/depth24/stencil8 and presents with Android Frame Pacing (SwappyGL_swap) when vsync is enabled. Render-command execution is separate from presentation, leaving a Vulkan seam without promising a Vulkan backend in v1. Registered shaders, cached/dynamic textures, ImGui, and Effekseer device objects are restored after EGL_CONTEXT_LOST.

Desktop Recording

Opt-in macOS marketing builds include an engine-level recorder shortcut (Control + R) that captures the application scene to MP4. The capture pass renders into an offscreen framebuffer sized exactly to targetResolution, so the video resolution is independent of the window size. The capture view is centered when the window and target widths differ. ImGui/debug UI is drawn after this capture pass and is not included in the recording.

See Platforms for application-level configuration.

Sample Usage

auto* sprite = node->addComponent<snowpulse::SpriteRenderer>();
sprite->sortOrder = 10;
sprite->textureFiltering = snowpulse::TextureFiltering::Nearest;
sprite->sprite(assetManager()->loadSprite("visuals/sprite.png"));