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.
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.
| Phase | Value | What has happened | Records carried |
|---|---|---|---|
FrameStart | 0 | The 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 |
AfterGBuffer | 1 | Albedo, normal, PBR and emissive are written and readable, and nothing is lit. The phase for surface work. | GBufferTextures, ViewTextures |
AfterLighting | 2 | Opaque 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 |
AfterTransparency | 3 | The 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 |
BeforePresent | 4 | The 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 |
Overlay | 5 | After 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.
| Member | Default | What 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. |
phase | AfterTransparency | Which 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-op | Called 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.
| Record | Fields | Carried at | Writable at |
|---|---|---|---|
SceneColor | .color - the frame’s colour, HDR | AfterLighting, AfterTransparency, BeforePresent | wherever carried |
GBufferTextures | .albedo (ambient occlusion in alpha), .normal, .pbr, .emissive | AfterGBuffer through BeforePresent | AfterGBuffer only |
ViewTextures | .depth, .depth_previous, .visibility_mesh, .visibility_triangle | AfterGBuffer through BeforePresent | wherever carried |
PresentTarget | .canvas | Overlay only | Overlay |
Three of those rows are the whole reason phases exist.
SceneColoris not carried atOverlay. A texture may not be a render attachment and a sampled source in the same pass, so atOverlaythe 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 atBeforePresent. You find that out fromframe.get(SceneColor), which fails with a sentence naming the phases that do carry it, rather than from a-1failing inside a binding builder.GBufferTexturesis in/out atAfterGBufferand 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 toBeforePresentbecause 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.PresentTargethas one field and is not calledSceneColoron purpose. There is no colour there to be tempted by. Keep the existing contents with aloadop, 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;
}
| Member | What 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
nameorder. That is reproducible rather than correct, and it is chosen over registration order precisely so that adding anawaitto a system’s startup cannot change the picture. - The order is resolved at registration, not per frame, so a cycle throws at the
add_extensioncall 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
| Member | What it is |
|---|---|
frame.graph | The 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.view | The GPUViewContext being drawn: camera (whose .buffer is what a draw pass binds), scene, resolution, frame_index. |
frame.phase | Which 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_open | Records 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.
| Class | Module | name | phase | Ordering |
|---|---|---|---|---|
GPUParticleAtlasExtension (internal) | GPUParticleEmitterSystem.js | "gpu particle atlas" | FrameStart | - |
TerrainExtension | TerrainSystem.js | "terrain" | AfterGBuffer | - |
DecalExtension | DecalSystem.js | "decals" | AfterGBuffer | after = [TerrainExtension] |
WaterExtension | WaterSystem.js | "water" | AfterLighting | - |
HighlightExtension | HighlightOutlineSystem.js | "highlight" | AfterTransparency | - |
ParticleExtension | ParticleEmitterSystem.js | "particles" | AfterTransparency | - |
PathDisplayExtension | PathDisplaySystem.js | "path display" | AfterTransparency | - |
TrailExtension | Trail3DSystem.js | "trails" | AfterTransparency | - |
FogOfWarExtension | FogOfWarSystem.js | "fog of war" | AfterTransparency | after = [ParticleExtension, PathDisplayExtension, TrailExtension, HighlightExtension] |
DebugDrawExtension | DebugDrawSystem.js | "debug draw" | AfterTransparency | after = [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.