Authoring a render program
Class: drive. No recompile — use a running render-capable engine (desktop,
or a SubstrateHarness scenario). The walkthrough uses the MCP harness's
send_mail and capture_frame; a wasm component sends the same kinds with
ctx.send::<RenderCapability>(..), shown at the end. The contract behind every
step is Authored render programs
(ADR-0170).
The program built here is a two-pass ping-pong: pass one thresholds a source
image's red channel against a uniform value into a transient, pass two inverts
the transient from a second uniform value into a writable output texture. It is
the same graph the canonical harness scenario drives
(program_scenario.rs),
small enough to verify by eye and complete enough to exercise every part of the
surface: bindings, a transient, two uniform windows in one blob, and drawing
the result. A second walkthrough at the end rasterizes
a triangle through a draw pass, which adds the geometry resource and an
authored vertex stage
(ADR-0171).
1. Create the source and output textures
A program reads and writes registry textures. Create a 2 × 2 source with
staged pixels (usage: Sampled) and an empty 2 × 2 output render target
(usage: Writable — pixels must be empty; the texture clears to transparent
black when it is realized).
// send_mail → aether.render (kind: aether.render.create_texture)
{
"width": 2, "height": 2,
"format": "Rgba8", "sampling": "Linear", "usage": "Sampled",
// Row-major RGBA, top-down. Red channel per texel:
// (0,0)=0.2, (1,0)=0.8, (0,1)=0.4, (1,1)=0.9.
"pixels": [51,0,0,255, 204,0,0,255, 102,0,0,255, 230,0,0,255]
}
// send_mail → aether.render (kind: aether.render.create_texture)
{
"width": 2, "height": 2,
"format": "Rgba8", "sampling": "Linear", "usage": "Writable",
"pixels": []
}
Each replies aether.render.create_texture_result with
{ "Ok": { "texture_id": … } }. Keep both ids — the walkthrough below calls
them SOURCE_ID and OUTPUT_ID.
2. Register the program
The WGSL module declares fragment entry points only — the substrate owns the
vertex stage (a fullscreen triangle handing each fragment its
@location(0) uv). Both entry points here window the same one-float uniform
block shape at @group(0) @binding(0) and sample one input at the group-1
pair @binding(0) / @binding(1):
struct WindowParams { value: f32 }
@group(0) @binding(0) var<uniform> window_params: WindowParams;
@group(1) @binding(0) var source_texture: texture_2d<f32>;
@group(1) @binding(1) var source_sampler: sampler;
@fragment
fn fs_threshold(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let lit = select(0.0, 1.0, textureSample(source_texture, source_sampler, uv).r >= window_params.value);
return vec4<f32>(lit, lit, lit, 1.0);
}
@fragment
fn fs_invert(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let level = window_params.value - textureSample(source_texture, source_sampler, uv).r;
return vec4<f32>(level, level, level, 1.0);
}
Register it with the declared graph: two bindings (source, output), one
transient for the ping-pong hop, and two passes. Each binding declares a
shape and a sampling: both here are Target(Full), sized from the output,
and Filtered, read through a clamped sampler. A source of a size of its own
would declare "shape": "Texture", and a table read with textureLoad would
declare "sampling": "Texel"
(slots and extents). The
transient declares an extent and a samples: it is always sized from the
output and always read through a clamped sampler, and "One" is one sample
per texel (samples). Each pass
declares a blend; "Alpha" composes what the pass returns over what its
output holds (blend). Pass 0 reads binding 0 and
writes the transient, windowing bytes 0..4 of the dispatch blob; pass 1 reads
pass 0's output through the PassOutput alias and writes binding 1, windowing
bytes 4..8.
// send_mail → aether.render (kind: aether.render.program.register)
{
"wgsl": "<the module above, as one string>",
"bindings": [
// 0: the source
{ "format": "Rgba8", "shape": { "Target": "Full" }, "sampling": { "Filtered": { "wrap": "Clamp", "mips": "Base" } } },
// 1: the output (written by the final pass; must be Target(Full))
{ "format": "Rgba8", "shape": { "Target": "Full" }, "sampling": { "Filtered": { "wrap": "Clamp", "mips": "Base" } } }
],
"transients": [
// 0: the ping-pong hop
{ "format": "Rgba8", "extent": "Full", "samples": "One" }
],
"geometries": [], // no draw pass in this graph
"depth_transients": [], // and so no depth targets
"passes": [
{
"stage": "Fragment",
"blend": "Alpha",
"entry_point": "fs_threshold",
"inputs": [ { "Binding": { "index": 0 } } ],
"output": { "Transient": { "index": 0 } },
"uniform_offset": 0, "uniform_length": 4,
"repeat": null
},
{
"stage": "Fragment",
"blend": "Alpha",
"entry_point": "fs_invert",
"inputs": [ { "PassOutput": { "pass": 0 } } ],
"output": { "Binding": { "index": 1 } },
"uniform_offset": 4, "uniform_length": 4,
"repeat": null
}
]
}
The reply is aether.render.program.register_result:
{ "Ok": { "program_id": 0 } }
Validation happens here, once — naga over the WGSL, the graph checks
(entry points exist, slots written before read, windows cover the shader's
uniform block, the final pass writes a Target(Full) binding), then wgpu
pipeline creation under an error scope. A failure replies
{ "Err": { "reason": … } } with the failing class named — see the
validation table —
and consumes no id.
3. Dispatch it
A dispatch names one registry texture per declared binding, in order, one
registry geometry per declared geometry slot (none here), and carries the
uniform blob the passes window into. The blob here is two little-endian f32
values packed tight: threshold 0.5 in bytes 0..4 and invert base 1.0 in
bytes 4..8. Windows need no alignment — the executor stages them aligned
itself.
// send_mail → aether.render (kind: aether.render.program.dispatch)
{
"program_id": 0,
"bindings": [SOURCE_ID, OUTPUT_ID],
"geometries": [],
"draw_sets": [],
"uniforms": [0, 0, 0, 63, 0, 0, 128, 63] // 0.5f32, 1.0f32, little-endian
}
Every field is required: the codec rejects a missing one rather than
defaulting it, so a fragment-only program still sends "geometries": [] and
"draw_sets": [].
Dispatch is fire-and-forget: the passes record at the next frame, before the
world, material, and overlay passes, and the result persists in the output
texture until the next dispatch overwrites it. A runtime mismatch — a wrong
binding count, a texture whose format or size disagrees with the graph, a
blob shorter than a window — drops the whole dispatch with a warning in the
render actor's log ring (actor_logs on "aether.render") and leaves the
frame intact.
4. See the output
The output texture samples like any registry texture. Draw it as a screen
quad and capture in one call, with the dispatch staged in the same mails
bundle so the freshly written pixels appear in the captured frame:
// capture_frame — window is required; it is the window's actor path
// as aether.window.list reports it.
{
"window": "aether.window/aether.window.instance:main",
"mails": [
{ "address": "aether.render", "kind_name": "aether.render.program.dispatch",
"params": { "program_id": 0, "bindings": [SOURCE_ID, OUTPUT_ID], "geometries": [],
"draw_sets": [],
"uniforms": [0, 0, 0, 63, 0, 0, 128, 63] } },
{ "address": "aether.render", "kind_name": "aether.render.draw_textured_quads",
"params": { "texture_id": OUTPUT_ID, "space": "Screen", "clip": null, "blend": "Straight",
"quads": [ { "x": 16.0, "y": 8.0, "width": 128.0, "height": 128.0,
"u0": 0.0, "v0": 0.0, "u1": 1.0, "v1": 1.0,
"tint": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 } } ] } }
]
}
Threshold at 0.5 maps the left texel column (red 0.2, 0.4) to 0 and
the right column (0.8, 0.9) to 1; inverting from 1.0 flips them — the
captured quad shows a white left half and a black right half. The quad draw is
immediate-mode and must ride every frame it should appear; the program's
output pixels are retained and need no re-dispatch to keep being sampled.
5. Repaint and release
To recompute — new threshold, new source content — send another dispatch with a fresh blob; the structure stays as registered. Anything that varies per run belongs in the blob (including any randomness, pre-rolled on the CPU: the GPU side is deterministic by convention). When the program is no longer needed:
// send_mail → aether.render (kind: aether.render.program.destroy)
{ "program_id": 0 }
A minimal draw pass
A draw pass rasterizes resident geometry through an authored vertex stage
instead of running the substrate's fullscreen triangle. The walkthrough below
paints one white triangle into a 16 × 16 writable texture and draws that
texture back to the screen — the same graph
draw_pass_scenario.rs
drives. It needs three things the fragment walkthrough did not: a geometry, a
geometry slot in the register, and a vertex entry point.
1. Upload the geometry
create_geometry takes a layout, the packed vertex bytes, and 32-bit indices.
The layout here is one attribute — a Float32x3 position at @location(0), so
the stride is 12 bytes and three vertices are 36 bytes. Positions are clip
space: the corners (-0.8, -0.8), (0.8, -0.8), and (0.0, 0.8), each with
z = 0.
// send_mail → aether.render (kind: aether.render.create_geometry)
{
"layout": [ { "location": 0, "format": "Float32x3" } ],
// Three vertices, little-endian f32: -0.8 is [205,204,76,191],
// 0.8 is [205,204,76,63], 0.0 is [0,0,0,0].
"vertices": [205,204,76,191, 205,204,76,191, 0,0,0,0,
205,204,76,63, 205,204,76,191, 0,0,0,0,
0,0,0,0, 205,204,76,63, 0,0,0,0],
// One triangle: indices 0, 1, 2 as little-endian u32.
"indices": [0,0,0,0, 1,0,0,0, 2,0,0,0]
}
The reply is aether.render.create_geometry_result with
{ "Ok": { "geometry_id": 0 } } — call it GEOMETRY_ID. A rejection names its
class: an empty layout, vertex bytes that do not divide by the stride, index
bytes that do not divide by four, or an index past the vertex count. The reply
means the bytes are staged on the CPU. The GPU buffers are created in a later
frame by the renderer's upload queue, or by the first draw pass that uses the
geometry if that comes first
(Rendering).
Both byte fields accept a literal array as above; for a real mesh, use the
harness blob embeds instead — { "$file": "/absolute/path/mesh.bin" } reads
the bytes on the harness host and { "$base64": "…" } decodes a base64 string.
2. Create the output texture
The program draws into a writable registry texture, exactly as the fragment walkthrough's final pass did:
// send_mail → aether.render (kind: aether.render.create_texture)
{
"width": 16, "height": 16,
"format": "Rgba8", "sampling": "Linear", "usage": "Writable",
"pixels": []
}
Keep the id as TARGET_ID.
3. Register the draw program
The module declares one vertex entry and one fragment entry. The vertex stage
reads the position attribute for x and y and takes its clip depth from the
uniform window, which binds at @group(0) @binding(0) for the vertex stage and
the fragment stage alike; the fragment stage paints the window's color.
struct DrawParams {
color: vec4<f32>,
depth: f32,
}
@group(0) @binding(0) var<uniform> draw_params: DrawParams;
@vertex
fn vs_flat(@location(0) position: vec3<f32>) -> @builtin(position) vec4<f32> {
return vec4<f32>(position.xy, draw_params.depth, 1.0);
}
@fragment
fn fs_flat() -> @location(0) vec4<f32> {
return draw_params.color;
}
DrawParams is a vec4<f32> followed by an f32, padded out to the struct's
16-byte alignment — 32 bytes, which is what the pass's uniform_length must
cover.
// send_mail → aether.render (kind: aether.render.program.register)
{
"wgsl": "<the module above, as one string>",
"bindings": [
// 0: the output (Target(Full), as the final pass writes it)
{ "format": "Rgba8", "shape": { "Target": "Full" }, "sampling": { "Filtered": { "wrap": "Clamp", "mips": "Base" } } }
],
"transients": [],
"geometries": [
{ "layout": [ { "location": 0, "format": "Float32x3" } ] } // 0: matches the created layout
],
"depth_transients": [], // this pass does not depth-test
"passes": [
{
"stage": { "Draw": { "vertex_entry_point": "vs_flat",
"geometry": 0, "depth": null, "load": "Clear" } },
"blend": "Alpha",
"entry_point": "fs_flat",
"inputs": [],
"output": { "Binding": { "index": 0 } },
"uniform_offset": 0, "uniform_length": 32,
"repeat": null
}
]
}
The geometry slot's layout must be the layout the geometry was created with,
attribute for attribute — the register builds the vertex buffer layout from the
slot and checks vs_flat's interface against it, and the dispatch checks the
supplied geometry against it again. Each mismatch replies its own named
reason — reading a location the layout does not declare is one class, reading
a declared location as the wrong WGSL type another.
4. Dispatch and see the triangle
The dispatch supplies one geometry id per declared slot, in order, alongside
the bindings and the uniform blob. The blob is the 32-byte DrawParams window:
white in the first 16 bytes, clip depth 0.5 in the next four, then padding.
Stage the dispatch and an overlay quad in one capture_frame so the freshly
drawn pixels land in the captured frame:
// capture_frame — window is required; it is the window's actor path
// as aether.window.list reports it.
{
"window": "aether.window/aether.window.instance:main",
"mails": [
{ "address": "aether.render", "kind_name": "aether.render.program.dispatch",
"params": { "program_id": 0, "bindings": [TARGET_ID], "geometries": [GEOMETRY_ID],
"draw_sets": [],
// color (1,1,1,1) then depth 0.5, padded to 32 bytes
"uniforms": [0,0,128,63, 0,0,128,63, 0,0,128,63, 0,0,128,63,
0,0,0,63, 0,0,0,0, 0,0,0,0, 0,0,0,0] } },
{ "address": "aether.render", "kind_name": "aether.render.draw_textured_quads",
"params": { "texture_id": TARGET_ID, "space": "Screen", "clip": null, "blend": "Straight",
"quads": [ { "x": 16.0, "y": 8.0, "width": 128.0, "height": 128.0,
"u0": 0.0, "v0": 0.0, "u1": 1.0, "v1": 1.0,
"tint": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 } } ] } }
]
}
The captured quad shows a white triangle pointing up — apex near the top edge,
base along the bottom — over the cleared transparent black the Clear load
left everywhere the triangle does not cover. Clip +y is up on screen and maps
to the top of the sampled texture, which is why the apex at y = 0.8 lands
there.
5. Add depth when passes must occlude each other
Depth arrives with the declaration: a pass depth-tests exactly when it names a
depth slot. Declare one depth_transients entry and name it from both passes
to make them agree on occlusion:
{
// …bindings and wgsl as above. Two meshes now, so two geometry slots,
// and the dispatch supplies two ids in this order.
"geometries": [
{ "layout": [ { "location": 0, "format": "Float32x3" } ] },
{ "layout": [ { "location": 0, "format": "Float32x3" } ] }
],
// The depth slot shares its passes' output's extent and sample count; the
// output is a binding, which has one sample. "Output" sizes the slot from
// the program's output, which is what a slot beside a color output declares.
"depth_transients": [ { "extent": { "Output": "Full" }, "samples": "One" } ],
"passes": [
{ "stage": { "Draw": { "vertex_entry_point": "vs_flat", "geometry": 0,
"depth": 0, "load": "Clear" } },
"blend": "Alpha",
"entry_point": "fs_flat", "inputs": [], "output": { "Binding": { "index": 0 } },
"uniform_offset": 0, "uniform_length": 32, "repeat": null },
{ "stage": { "Draw": { "vertex_entry_point": "vs_flat", "geometry": 1,
"depth": 0, "load": "Load" } },
"blend": "Alpha",
"entry_point": "fs_flat", "inputs": [], "output": { "Binding": { "index": 0 } },
"uniform_offset": 32, "uniform_length": 32, "repeat": null }
]
}
The first pass to name depth slot 0 clears it to the far plane; the second
loads it, so a nearer mesh drawn first survives a farther one drawn second
where they overlap. The second pass declares load: "Load" on the color output
for the same reason — Clear there would erase the first pass's work. Each
pass windows its own 32 bytes of a 64-byte blob, which is how they carry
different colors and depths. A depth slot must resolve to the same extent as
the color output of every pass naming it.
Drawing many instances
A draw pass draws one geometry once. To draw a geometry many times, or many
geometries, in one render pass, put the draws in a
draw set and draw it with a
DrawSets pass. The walkthrough
draws the triangle from the section above twice, shifted left and right by an
offset each copy reads from its instance record.
Create the instance records. A record here is one Float32x3 offset at
@location(4), so the stride is 12 bytes; the two records are (-0.4, 0, 0)
and (0.4, 0, 0):
// send_mail → aether.render (kind: aether.render.create_instances)
{
"layout": [ { "location": 4, "format": "Float32x3" } ],
"capacity": 2,
// -0.4 is [205,204,204,190], 0.4 is [205,204,204,62].
"records": [205,204,204,190, 0,0,0,0, 0,0,0,0,
205,204,204,62, 0,0,0,0, 0,0,0,0]
}
The reply is { "Ok": { "instances_id": 0 } } — call it INSTANCES_ID. Then
make the set: one draw of the triangle's three indices, once per record of both
records. The set's two layouts are the geometry's and the instance buffer's.
// send_mail → aether.render (kind: aether.render.create_draw_set)
{
"vertex_layout": [ { "location": 0, "format": "Float32x3" } ],
"instance_layout": [ { "location": 4, "format": "Float32x3" } ],
"draws": [
{ "geometry_id": GEOMETRY_ID, "indices": { "first": 0, "count": 3 },
"instances_id": INSTANCES_ID, "instances": { "first": 0, "count": 2 } }
]
}
The reply is { "Ok": { "draw_set_id": 0 } } — call it DRAW_SET_ID. Every
draw is checked here, so a refusal names the draw and its class and nothing is
left to fail at frame time.
Register a program whose pass declares the same two layouts. The vertex stage reads the corner from vertex buffer 0 and the offset from the instance buffer, each at the location its layout declares:
@vertex
fn vs_placed(@location(0) corner: vec3<f32>, @location(4) offset: vec3<f32>) -> @builtin(position) vec4<f32> {
return vec4<f32>(corner + offset, 1.0);
}
@fragment
fn fs_white() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 1.0, 1.0, 1.0);
}
// send_mail → aether.render (kind: aether.render.program.register)
{
"wgsl": "<the module above, as one string>",
"bindings": [
{ "format": "Rgba8", "shape": { "Target": "Full" }, "sampling": { "Filtered": { "wrap": "Clamp", "mips": "Base" } } }
],
"transients": [],
"geometries": [], // a DrawSets pass names no geometry slot
"depth_transients": [],
"passes": [
{ "stage": { "DrawSets": {
"vertex_entry_point": "vs_placed",
"vertex_layout": [ { "location": 0, "format": "Float32x3" } ],
"instance_layout": [ { "location": 4, "format": "Float32x3" } ],
"draw_sets": 0, // this pass draws list 0 of the dispatch
"cull": "None", "depth": null, "load": "Clear" } },
"blend": "Alpha",
"entry_point": "fs_white", "inputs": [], "output": { "Binding": { "index": 0 } },
"uniform_offset": 0, "uniform_length": 0, "repeat": null }
]
}
The dispatch lists the sets each list slot draws this frame. The program names
one list, so draw_sets holds one list:
// send_mail → aether.render (kind: aether.render.program.dispatch)
{
"program_id": PROGRAM_ID,
"bindings": [TARGET_ID],
"geometries": [],
"draw_sets": [ [DRAW_SET_ID] ],
"uniforms": []
}
Both copies of the triangle land in TARGET_ID from one render pass. To move a
copy, send aether.render.update_instances with its record; to add or drop
draws, patch the set with aether.render.update_draw_set; to hide the whole
set for a frame, leave its id out of the list. None of those re-registers the
program. A dispatch that lists an unknown set id, or a set whose layouts are
not the pass's, drops whole with a warning.
Hold a draw back until its buffers are on the GPU
A geometry and an instance buffer are staged by their creates and uploaded in a later frame. A set that draws them before then makes the renderer upload them in the frame of that first draw, which is the long frame streamed content wants to avoid. To add content only once drawing it uploads nothing, await it first and add the draw when the reply arrives:
// send_mail → aether.render (kind: aether.render.await_resident)
{ "resources": [
{ "Geometry": { "geometry_id": GEOMETRY_ID } },
{ "Instances": { "instances_id": INSTANCES_ID } }
] }
The reply is aether.render.await_resident_result. { "Ok": { "bytes": 72 } }
says both are on the GPU and how many bytes they hold there: 48 for the
triangle's vertices and indices, 24 for the two records. Then send
aether.render.create_draw_set, or patch the draw into a live set with
aether.render.update_draw_set. Awaited resources also upload ahead of
everything else in the queue, so asking is how a sender says what it needs
soonest.
A request that has to wait is answered in a later frame, after its own chain
has settled: over MCP the send_mail call returns with no reply for it, and a
second request sent once frames have run is answered inside the call. A
component takes the reply in a #[handler::response] over
AwaitResidentResult and adds the draw there. An Err names the resource that
named nothing or was destroyed while awaited
(Rendering).
From a wasm component
The same kinds flow through the flat send verbs. Registration is a
send whose reply arrives as ordinary mail, so the component keeps the id from
a ProgramRegisterResult handler and dispatches once it has one:
#![allow(unused)] fn main() { use aether_render::{ProgramDispatch, ProgramRegister, ProgramRegisterResult, RenderCapability}; // In an `#[actor(depends(RenderCapability))]` block. fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> { ctx.send::<RenderCapability>(&self.build_register()); // a ProgramRegister value Ok(()) } #[handler::response] fn on_registered(&mut self, _ctx: &mut WasmCtx<'_>, result: ProgramRegisterResult) { match result { ProgramRegisterResult::Ok { program_id } => self.program_id = Some(program_id), // Err means this session cannot run the program — disable the // feature for the session rather than re-registering. ProgramRegisterResult::Err { error } => tracing::warn!(%error, "program register refused"), } } }
A ctx that omits its actor is typed by it: the macro reads WasmCtx<'_> as
WasmCtx<'_, Self>, so the ctx reaches only the actors the component declares
with depends(R). The ctx's type arguments are receiver, sender, mode: the actor first, the
sender the handler requires second (Anyone when it states none), and the
reply mode third (WasmCtx<'_, Self, Anyone, Unchecked>); spell
WasmCtx<'_, Erased> for the untyped view.
The dispatch then rides wherever the repaint cadence lives — a Tick or
Render handler, a settle gate — as
ctx.send::<RenderCapability>(&ProgramDispatch { program_id, bindings, geometries, draw_sets, uniforms }).
A component that draws geometry sends its CreateGeometry on the same
reply-driven path as the register, keeping the geometry_id from a
CreateGeometryResult handler; the upload belongs to subject load, and every
frame after that changes only the uniform blob — an animated mesh poses through
matrices in that blob rather than through a fresh upload.
The archived consumer to study at scale is the watercolour easel's wash program
(aether-puppet/src/easel/program/):
one static graph of several hundred passes, one uniform blob encoded per
develop.
What to check when it fails
- Register replies
Err— read thereason; each validation class names the offending pass and slot. Fix the graph or the WGSL and register again (nothing was consumed). - Dispatch shows nothing — check
actor_logson"aether.render"for a warn-drop naming the program, pass, and binding; the usual causes are a binding list in the wrong order, aSampledtexture where the graph writes, a geometry list whose length or ids do not match the declared slots, or a blob shorter than a window's reach. - Output is stale — a program executes only when dispatched; confirm the
dispatch rode the same frame as the capture (stage it in
capture_frame'smails, as above). - A draw pass paints nothing where geometry should be — the triangle may be outside the clip volume the vertex stage produced, or wound so it lands off-target. Culling is off, so both faces paint; check the positions and the depth the vertex stage writes, and whether an earlier pass's depth in a shared slot is rejecting the fragments.
- A draw pass erases an earlier pass — a draw pass's
loadis authoritative on its color output; a second pass onto the same slot wants"Load", not"Clear".