Rendering

Render extensions

How an application records its own work into Shade's frame - the six FramePhase injection points, RenderExtension, the typed frame records, declared ordering, and registration through the graphics engine.

Shade’s frame is a closed sequence with declared injection points. You do not insert a pass wherever you like; you write a RenderExtension, declare which phase it belongs to, and it records into that frame’s graph when the phase comes round. This is the whole extensibility surface: there is no plugin stack and no framebuffer registry. The engine’s own extensions use this mechanism; the table at the end of this page lists worked examples.

The extension types themselves - RenderExtension, FramePhase, FrameContext, FrameRecord and the four built-in records - all live in @woosh/meep-engine/src/shade/renderer/extension/, and every export there is named.

The six phases

FramePhase is a plain enum of six numbers, and the order below is the order they occur in.

FrameStartvisibility buffer and G-bufferAfterGBufferclustered lighting, deferred shading, volumetricsAfterLightingtransparencyAfterTransparencyTAA or NSS upscale - resolution changes hereBeforePresenttonemap to canvasOverlay

Each phase names a moment where a specific set of frame records exists and is stable, and an extension registered against one is handed exactly those.

PhaseValueWhat has happenedRecords carried
FrameStart0The view is updated and nothing has been rasterized. The phase for per-frame GPU compute later phases depend on: a visibility grid update, a custom cull, a procedural buffer build.none
AfterGBuffer1Albedo, normal, PBR and emissive are written and readable, and nothing is lit. The phase for surface work.GBufferTextures, ViewTextures
AfterLighting2Opaque surfaces are lit and composited and volumetrics are over them. Transparency has not run, so anything drawn here is something transparent surfaces should blend over.SceneColor, GBufferTextures, ViewTextures
AfterTransparency3The complete scene, still HDR and still at the internal render resolution. Where an extension drawing into the scene belongs unless it has a reason to be past the upscale.SceneColor, GBufferTextures, ViewTextures
BeforePresent4The end of the post chain, immediately before tonemapping and after the upscale: still HDR, at output resolution. The last phase whose output goes through the tonemapper.SceneColor, GBufferTextures, ViewTextures
Overlay5After tonemapping, drawing onto the canvas in display space. Nothing further processes this, which makes it the phase for interface: overlays, gizmos, a minimap blit.PresentTarget

frame_phase_name(phase) from the same module turns a value back into its name for a diagnostic.

The upscale is a phase boundary, and it is the one thing about the frame you cannot assume away. AfterGBuffer, AfterLighting and AfterTransparency run at the internal render resolution; TAA or the NSS upscaler resolves to the output resolution, so BeforePresent and Overlay are on the far side of it. With feature_taa_enabled === false there is no upscale and both sides are the same size. Never state a resolution as a constant - ask frame.resolution, which is derived from the live handle and is right in every configuration, dynamic resolution scaling included.

Before the upscale is the common case and the design leans on it: work at AfterTransparency is temporally resolved, upscaled and sharpened along with everything else, and it draws at internal resolution. BeforePresent is the deliberate other choice, and a trade rather than an upgrade - work there gets no temporal history and pays for full-resolution pixels.

A minimal extension

This one draws a batch of dynamic meshes into the scene. It is the shape every shipped extension has, and it is close to what Trail3DSystem and DebugDrawSystem actually do.

import { RenderExtension } from "@woosh/meep-engine/src/shade/renderer/extension/RenderExtension.js";
import { FramePhase } from "@woosh/meep-engine/src/shade/renderer/extension/FramePhase.js";
import { SceneColor } from "@woosh/meep-engine/src/shade/renderer/extension/SceneColor.js";
import { ViewTextures } from "@woosh/meep-engine/src/shade/renderer/extension/ViewTextures.js";
import { GPUDynamicMeshRenderer } from "@woosh/meep-engine/src/shade/renderer/dynamic/GPUDynamicMeshRenderer.js";
import { DynamicMesh } from "@woosh/meep-engine/src/shade/renderer/scene/DynamicMesh.js";
import { DynamicMeshBatch } from "@woosh/meep-engine/src/shade/renderer/scene/DynamicMeshBatch.js";

export class MarkerExtension extends RenderExtension {
    name = "markers";

    phase = FramePhase.AfterTransparency;

    #renderer = new GPUDynamicMeshRenderer();

    #batch = new DynamicMeshBatch();

    /**
     * @param {Geometry} geometry a plain Geometry - position attribute and an index
     * @param {number[]} color RGBA, multiplied with any per-vertex colour
     * @returns {DynamicMesh}
     */
    add(geometry, color) {
        const mesh = DynamicMesh.from(geometry, color);

        // a node's world transform is derived and stays unset until it is asked for
        mesh.updateMatrices();

        this.#batch.add(mesh);

        return mesh;
    }

    record(frame) {
        if (this.#batch.count === 0) {
            // nothing to draw, and the frame's colour is left exactly as it was
            return;
        }

        const scene = frame.get(SceneColor);

        scene.color = this.#renderer.graph_draw({
            graph: frame.graph,
            batch: this.#batch,
            color: scene.color,
            depth: frame.get(ViewTextures).depth,
            camera: frame.view.camera.buffer
        });
    }

    destroy() {
        this.#renderer.destroy();
        this.#batch.clear();
    }
}
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";

const markers = engine.graphics.add_extension(new MarkerExtension());

const geometry = make_box_geometry(1, 1, 1);

markers.add(geometry, [1, 0.2, 0, 0.8]);

// the vertices are yours to rewrite; nothing watches the arrays
geometry.needsUpdate = true;

// ... later
engine.graphics.remove_extension(markers);
markers.destroy();

GPUDynamicMeshRenderer is the path for content whose vertices change every frame - trails, ribbons, path tubes, debug lines. It takes a plain Geometry, not a MeshletGeometry, and is covered on effects. An extension is not obliged to use it: frame.graph is the frame’s own graph and anything you can record into a frame graph is fair game - meep’s own ParticleExtension is the shipped example, drawing through a billboard pipeline of its own rather than through this one.

The declarations

RenderExtension is a named object with four declarative fields and one method.

MemberDefaultWhat it is
name""Stable identity. It names the graph scope this extension’s passes land in, it is what an ordering diagnostic and a decorated error say, and it is the ordering tie-break.
phaseAfterTransparencyWhich phase it records at. Read at registration to pick the bucket, and read again by remove_extension to find it - so do not change it once the extension is registered.
after[]Extension classes this one records after.
before[]Extension classes this one records before.
record(frame)no-opCalled once per frame when the phase comes round, inside a graph scope named after the extension. Returns nothing.

An extension with nothing to draw this frame returns from record without recording, and that is the only “off” switch there is - no enabled flag, no null pass.

If record throws, the registry rethrows it wrapped as Render extension '<name>' failed to record at <phase> with the original on cause, and closes the graph scope so the real error is not buried behind an unbalanced-scope failure.

Records are the channel

record(frame) returns nothing. Everything an extension exchanges with the frame goes through typed records: you read a frame-graph handle out of one and write a replacement back, and writing a field is publication - everything after you carries what you left. There is no privileged resource. The scene colour is published exactly the way a G-buffer target is, and exactly the way a record of your own is.

RecordFieldsCarried atWritable at
SceneColor.color - the frame’s colour, HDRAfterLighting, AfterTransparency, BeforePresentwherever carried
GBufferTextures.albedo (ambient occlusion in alpha), .normal, .pbr, .emissiveAfterGBuffer through BeforePresentAfterGBuffer only
ViewTextures.depth, .depth_previous, .visibility_mesh, .visibility_triangleAfterGBuffer through BeforePresentwherever carried
PresentTarget.canvasOverlay onlyOverlay

Three of those rows are the whole reason phases exist.

  • SceneColor is not carried at Overlay. A texture may not be a render attachment and a sampled source in the same pass, so at Overlay the canvas is written and cannot be read. An effect that needs the finished image as input - a colour grade, a full-screen re-composite, fog over the whole frame - belongs at BeforePresent. You find that out from frame.get(SceneColor), which fails with a sentence naming the phases that do carry it, rather than from a -1 failing inside a binding builder.
  • GBufferTextures is in/out at AfterGBuffer and frozen afterwards. Changing surface properties means reading the targets, writing replacements and publishing the new handles: two of the four are integer formats and cannot be blended, and nothing can be attachment and source at once. It stays readable to BeforePresent because outline and selection effects legitimately read it late - and at that phase it is on the far side of the upscale and is therefore a different size from the colour beside it. frame.describe(handle) is how a pass working across that boundary learns the ratio.
  • PresentTarget has one field and is not called SceneColor on purpose. There is no colour there to be tempted by. Keep the existing contents with a load op, write only your own pixels, and publish the result back - that is what lets two overlay extensions compose, the second drawing over what the first left.

SceneColor.copy (a readable snapshot of the colour as the phase began) and ViewTextures.velocity are deliberately absent, not missing. Both are optional frame products, and there is no delivery mechanism for those yet.

Every field getter and setter goes through a guard, so the failures are named rather than mysterious: reading a field the frame has not filled in, publishing into a record that is frozen at this phase, publishing a superseded handle, or touching a record after its frame has closed each assert at the line that caused it. Those guards are assertions, and a production build strips them - develop against a build that keeps them.

Publishing a record of your own

The vocabulary is open. An extension can publish a record for another extension to read, and doing so costs no edit to Shade.

import { FrameRecord } from "@woosh/meep-engine/src/shade/renderer/extension/FrameRecord.js";

export class VisibilityGrid extends FrameRecord {
    #grid = -1;

    static is_present_at(phase) {
        return true;
    }

    get grid() {
        return this.read(this.#grid);
    }

    set grid(handle) {
        this.#grid = this.publish(handle);
    }
}
// in an extension at FrameStart
frame.create(VisibilityGrid).grid = record_the_grid_update(frame.graph);

// in another extension, at a later phase
if (frame.has(VisibilityGrid)) {
    const grid = frame.get(VisibilityGrid).grid;
}
MemberWhat it does
static is_present_at(phase)Phases the frame carries this record at. Answered before an instance exists, which is what lets get say where a record is carried rather than only that it is not here. Defaults to every phase.
static is_writable_at(phase)Phases a field may be published into. Never wider than is_present_at. Defaults to it. A record that is carried but not writable is frozen - GBufferTextures after shading is the case that matters.
read(handle)The guard a field’s getter goes through.
publish(handle)The guard a field’s setter goes through, and the reason publication is a plain assignment.

frame.has(T) / frame.get(T) / frame.create(T) are three calls rather than one get_or_create because absent has to stay distinguishable from empty. create throws if the frame already carries that type; get throws with the phases the type is carried at. The renderer publishes the four built-in records through the same create, so there is one mechanism, not two.

Publication is the naming step, not the dependency edge: FramePassBuilder.write renames a written resource into a versioned clone and records the read, so “this pass consumed the colour as it stood and produced a new one” is already a graph fact. Publishing decides which handle currently means “the scene colour”.

Ordering

Order is declared, never emergent.

class DecalExtension extends RenderExtension {
    phase = FramePhase.AfterGBuffer;

    after = [TerrainExtension];   // classes, not instances
    before = [];
}
  • Constraints name classes, so you do not need a reference to another system’s object to state an order, and a class nobody registered is simply not a constraint.
  • Constraints only order extensions within one phase. The registry resolves each phase separately, and the phases already order everything across them.
  • Two extensions on one phase with no path between them record in ascending name order. That is reproducible rather than correct, and it is chosen over registration order precisely so that adding an await to a system’s startup cannot change the picture.
  • The order is resolved at registration, not per frame, so a cycle throws at the add_extension call that closed it, names the extensions on it, and leaves the registry exactly as it was.
  • An extension cannot be registered or removed from inside record.

Worked out for the six extensions meep ships at AfterTransparency: name order is debug draw, fog of war, highlight, particles, path display, trails; the declarations push fog of war behind the four it names and debug draw behind fog of war; the resolved order is highlight, particles, path display, trails, fog of war, debug draw. At AfterGBuffer there are two, and decals sorts first by name but declares after = [TerrainExtension], so the order is terrain, decals - which is what puts scorch marks on top of the textured ground rather than under it.

What keeps your work alive

The frame graph elides work whose output nothing consumes, and that applies to an extension exactly as it does to a built-in pass.

  • Thread a result into a record and it is consumed.
  • Write something imported - the canvas, an imported texture - and the graph treats it as a side effect automatically.
  • For work that must run regardless, say so with make_side_effect() on the pass builder. An upload that writes a GPU buffer the graph does not know about is the usual case:
const upload = {};

frame.graph.add("markers / upload", upload, (data, resources, execution) => {
    write_the_table(execution.graphics);
}).make_side_effect();

An extension whose whole recording is elided is a correct, cheaper frame, not a failure, and nothing reports it as one. It is a grey cluster with your extension’s name on it in a graph dump.

The rest of FrameContext

MemberWhat it is
frame.graphThe frame’s FrameGraph. Record into it with graph.add(name, data, execute) and the builder’s create / read / write, exactly as the built-in passes do.
frame.viewThe GPUViewContext being drawn: camera (whose .buffer is what a draw pass binds), scene, resolution, frame_index.
frame.phaseWhich phase this is.
frame.resolution[width, height] of what this phase draws into, read from the live handle - so it follows a publication, and the second extension on a phase is told the size the first one left. At FrameStart nothing is drawn and it is the view’s internal size. Read it inside record, not inside a pass callback, which runs long after the frame has moved on.
frame.describe(handle)The descriptor of any resource in the frame’s graph. For a texture that is a TextureResourceDescriptor - .resolution, .format, .usage, .mipLevelCount. The general form of resolution, and what a pass relating two differently sized resources needs.
frame.is_openRecords are per-frame. A record held past the end of the frame is a retained handle by another name, and both reading and publishing through one say so.

A pass that needs a scratch texture of its own creates one through the builder, describing it with TextureResourceDescriptor.from({ resolution, format, usage }) from @woosh/meep-engine/src/shade/device/graph/TextureResourceDescriptor.js. It is a transient graph resource: the graph decides its lifetime, and the GPU texture behind it is pooled by descriptor and handed to whoever asks for the same shape next.

Registering

Register through the graphics engine, not through the renderer.

import { FramePhase } from "@woosh/meep-engine/src/shade/renderer/extension/FramePhase.js";

const markers = engine.graphics.add_extension(new MarkerExtension());  // returns the extension

engine.graphics.extension_count(FramePhase.AfterTransparency);         // number

engine.graphics.remove_extension(markers);                             // boolean: was it registered

Registering does not require a device. GraphicsEngine holds the extensions itself and applies them to the renderer when one exists, and again after a restart - so a system can register at startup without knowing where in the lifecycle it is, and start -> stop -> start keeps its extensions. Renderer carries the same three methods, but an extension registered straight on the renderer is lost the next time one is built.

extension_count(phase) is read-only and deliberately narrow: it says how many extensions are registered against a phase, not what they are, which is enough for a system to prove it registered and enough for a test to prove teardown leaks nothing.

Every rendering system meep ships follows the same shape:

async startup(entityManager) {
    this.#extension = this.#graphics.add_extension(new MarkerExtension(this));
}

async shutdown(entityManager) {
    if (this.#extension !== null) {
        this.#graphics.remove_extension(this.#extension);

        this.#extension = null;
    }
}

What meep ships

These extensions sit beside the ECS systems that own them in @woosh/meep-engine/src/engine/graphics3/. All are named exports except GPUParticleAtlasExtension, which is internal to GPUParticleEmitterSystem; register that system to use its atlas integration.

ClassModulenamephaseOrdering
GPUParticleAtlasExtension (internal)GPUParticleEmitterSystem.js"gpu particle atlas"FrameStart-
TerrainExtensionTerrainSystem.js"terrain"AfterGBuffer-
DecalExtensionDecalSystem.js"decals"AfterGBufferafter = [TerrainExtension]
WaterExtensionWaterSystem.js"water"AfterLighting-
HighlightExtensionHighlightOutlineSystem.js"highlight"AfterTransparency-
ParticleExtensionParticleEmitterSystem.js"particles"AfterTransparency-
PathDisplayExtensionPathDisplaySystem.js"path display"AfterTransparency-
TrailExtensionTrail3DSystem.js"trails"AfterTransparency-
FogOfWarExtensionFogOfWarSystem.js"fog of war"AfterTransparencyafter = [ParticleExtension, PathDisplayExtension, TrailExtension, HighlightExtension]
DebugDrawExtensionDebugDrawSystem.js"debug draw"AfterTransparencyafter = [FogOfWarExtension]

The three ordering declarations are the readable part of the design: decals land on top of the textured terrain rather than under it, everything drawn into the scene is concealed by the fog of war because the fog says so rather than because of where it happened to be registered, and developer geometry is the single deliberate exception - a debugging tool the fog can hide stops working exactly where debugging is needed.

Named in the design doc, not built

RENDER_EXTENSION_DESIGN.md ships inside the package and names two pieces that do not exist in the code: RenderExtensionRegistry.describe() (printing the resolved pipeline as data) and FrameProduct (opting into optional frame products such as velocity or a readable copy of the colour). Do not write against them.


Related: rendering overview for the frame as a whole, effects for the dynamic-mesh path an extension usually draws with, and trails for a shipped extension read end to end.