Rendering & camera
Governing ADRs: ADR-0025 (the art direction the renderer serves), ADR-0066 (where the render and camera kinds live), ADR-0074 §Decision 7 (camera folds into the render mailbox), and ADR-0173 (the internal device-loss contract). The model — world-space geometry, a single
view_projuniform, a camera that is an ordinary actor publishing the matrix — is stable.
The substrate owns the GPU. An actor that wants something drawn mails geometry
to one mailbox, aether.render, as ordinary fire-and-forget mail. The geometry
is world-space triangles; the substrate multiplies every vertex by a single 4×4
view_proj matrix to produce the on-screen frame. That matrix is the only
camera concept the renderer knows about, and it arrives the same way the
geometry does — as mail. A camera is any actor that computes a view_proj
and publishes it; the renderer applies whatever the latest one was.
Why it exists
The renderer serves the generation loop, not graphics fidelity (ADR-0025): chunky low-poly flat-shaded forms with palette-indexed per-vertex color, enough to make generated content feel alive. That target makes the caller surface small on purpose — submit triangles, set a matrix — so a drawing component or a camera is a few lines of mail rather than a pipeline to configure.
The load-bearing decision is that the camera is not a renderer feature. The
substrate applies one view_proj uniform and reads it from mail; it never owns
a camera, a projection mode, or a controller. So camera logic — orbit, top-down,
follow, whatever a game needs — lives in user space as an ordinary actor, and is
swappable by loading a different one. The renderer stays a thin matrix-applier;
everything expressive about how the world is framed is a component decision. The
alternative, a privileged camera baked into the renderer, would pull projection
policy and input handling into the substrate and make every new framing mode a
substrate change.
Geometry is world-space for the same reason: a drawing component emits where things are, not where they land on screen. The camera's matrix does the world→clip transform at draw time, so the same geometry reframes for free when the camera moves, and two components drawing into one frame share a coordinate system without coordinating.
What it does
One mailbox, a small kind set. Everything addresses aether.render, owned by
the RenderCapability actor. It handles these payload kinds:
| Kind | Shape | Semantics |
|---|---|---|
aether.draw_triangle | { verts: [Vertex; 3] }, cast-shaped | per-tick geometry; accumulates into the frame |
aether.view_projection | { view, projection, eye, near, far, extent } | a view of the world; the renderer applies projection * view, and the latest value wins |
aether.render.view_from | { source } → view_from_result | follow the view source at the path source, in place of the one followed before |
aether.render.create_texture | { width, height, format, sampling, usage, pixels } → create_texture_result | register an Rgba8, R8, R32Float, R16Float, or Rgba16Float texture; reply carries the texture_id |
aether.render.update_texture | { texture_id, x, y, width, height, pixels } | overwrite a sub-rect of a texture (atlas growth) |
aether.render.destroy_texture | { texture_id } | release a registered texture, texture array or volume texture; fire-and-forget |
aether.render.create_texture_array | { format, side, layers, mips } → create_texture_array_result | register a texture array of a fixed side and layer count; reply carries the texture_id |
aether.render.write_texture_layer | { texture_id, layer, pixels } | replace every level of one layer of a texture array, in place; fire-and-forget |
aether.render.create_texture_volume | { format, width, height, depth, pixels } → create_texture_volume_result | register a volume texture with its whole contents; reply carries the texture_id |
aether.render.draw_textured_quads | { texture_id, space, clip, blend, quads } | per-tick textured alpha-blended quads; accumulates into the frame |
aether.render.create_font | { bytes } → create_font_result | register a font from a blob of its file's bytes; reply carries the font_id (Text) |
aether.render.font_metrics | { font_id } → font_metrics_result | a registered font's size-independent metrics table |
aether.render.draw_text | { clip, space, runs } | per-tick strings laid out into one textured batch over the reserved glyph atlas; accumulates into the frame |
aether.render.draw_screen_triangles | { space, clip, triangles } | per-tick pixel-space triangles at any orientation; accumulates into the frame |
aether.render.draw_shapes | { space, clip, shapes } | per-tick rounded, stroked, shadowed, optionally textured boxes evaluated as a distance field; accumulates into the frame |
aether.render.material.textured | { texture_id, blend, rects } | per-tick depth-tested world-space textured rects |
aether.render.material.coverage | { texture_id, rects } | per-tick depth-tested world-space coverage bands from an R8 texture |
aether.render.capture_frame | { window, mails, after_mails, checks, similarity } | atomic "set state, read back a PNG, clean up"; a windowed runtime rejects an omitted window rather than guessing one |
aether.render.program.register | { wgsl, bindings, transients, geometries, depth_transients, passes } → program.register_result | register an authored render program (ADR-0170); reply carries the program_id |
aether.render.program.dispatch | { program_id, bindings, geometries, draw_sets, uniforms } | execute a registered program once at the next frame record; fire-and-forget |
aether.render.program.destroy | { program_id } | release a registered program; fire-and-forget |
A Vertex is { x, y, z, r, g, b } — a world-space position plus a per-vertex
color. One DrawTriangle is three of them; a component batches many per envelope
via send_many (each triangle is DRAW_TRIANGLE_BYTES on the wire).
Textured quads are the generic image surface (ADR-0105).
create_texture stages Rgba8 or R8 pixels under a session-scoped
texture_id (the reply hands it back); draw_textured_quads then draws a batch
of quads sampling that texture, each carrying a pixel-unit rect, a uv sub-rect,
and an RGBA tint. The pixels arrive as a Blob and are staged as received, with
JSON callers sending the same byte array as before. R8 samples contribute their scalar value in the red channel
(vec4(r, 0, 0, 1)), which is mainly a substrate for material passes; ordinary
sprite/text atlas callers use Rgba8. destroy_texture releases a registered
texture when the producer knows it is no longer used. It releases a
texture array or a
volume texture the same way:
the three share one id space, and an array or a volume is read only by a
render program.
blend picks how the sampled texel lays over what is already there, and the
choice is about what the source's colour channels already carry. Straight —
the default, and what an uploaded image or a glyph atlas wants — treats colour
and coverage as independent and weights one by the other. Premultiplied is for
a source whose colour has already been scaled by its own coverage, which is what
a texture written by a render program always is: a
fragment pass alpha-blends onto a transparent clear, so writing (colour, a)
stores (colour * a, a). Compositing that as Straight weights it a second
time and squares its coverage, so a half-covered texel arrives at a quarter
strength. material.textured carries the same field with the same meaning. An
opaque source is unaffected either way, which is why the distinction only
surfaces once a program's output is partially transparent.
The field's presence follows one rule: blend appears exactly on the verbs
that composite a caller-supplied image — draw_textured_quads and
material.textured — because only the caller that produced those texels knows
whether they were already scaled by their coverage. A verb whose colours the
substrate rasterizes itself — draw_shapes, draw_screen_triangles,
material.coverage — carries no blend: the fragment stage knows what it
wrote, so there is nothing for the caller to declare.
The create carries two role knobs (ADR-0170). sampling selects Linear
filtering for color content or Nearest for label planes whose texel values
are identities — interpolating between region labels would manufacture values
no texel holds. usage selects Sampled (CPU-staged pixels, the default role
above) or Writable — a GPU render target created without staged pixels and
cleared to transparent black at realization, the output surface authored
render programs draw into; update_texture against a writable texture
warn-drops, since it has no CPU staging. Two formats store data planes rather than
colour. R32Float keeps one f32 per texel; core WebGPU cannot linear-filter
it, so it requires Nearest sampling and binds through a non-filtering layout.
R16Float keeps one f16 — about eleven bits of mantissa — and is filterable,
which is what a pass reading between texels needs. Rgba16Float carries four
such filterable lanes when several independent quantities share one pass.
All three are read by authored-program passes; the color material and overlay
paths are not data-plane consumers.
Authored render programs (ADR-0170)
put actor-owned per-pixel work on the GPU without a substrate change.
program.register carries one WGSL module (fragment entry points only — the
substrate owns a fullscreen-triangle vertex stage) plus a declared graph:
bindings are the registry textures a dispatch supplies, transients are
executor-pooled intermediates (each an extent — full reference size or an
integer divisor — plus a format), and each pass names its fragment entry
point, input slots, output slot, byte-window into the dispatch's uniform blob,
and an optional repeat with per-iteration uniform stride. Validation happens
at register, once — naga over the WGSL, then the graph (entry points exist,
slots written before read, windows cover the shader's uniform block), then
wgpu pipeline creation under an error scope — so every failure class is a
distinguishable register_result Err { reason } and a bad program never
crashes the substrate. program.dispatch executes the passes once at the next
frame record, before the material and overlay passes, so drawing a program's
writable output texture in the same frame shows the freshly computed pixels;
runtime binding mismatches warn-drop naming the program, pass, and binding.
The full contract — slots, extents, uniform windows, repeat semantics,
validation classes, pooling, determinism conventions — is the subject of
Authored render programs.
Quads draw through a second alpha-blended pipeline in an overlay pass recorded
after the world pass, so they always land on top. The accumulate-per-frame
contract matches draw_triangle: resend the batch every frame it should appear.
The batch's space selects the projection — Screen rects are window pixels
drawn under an ortho derived from the surface size; World anchors the quad in
the scene through the camera's view_proj and reads its coordinates as pixel
offsets from the projected anchor. Every overlay verb carries the same
field with the same meaning. Sprites and HUD images compose this surface, and
so does the renderer's own text: a draw_text becomes one textured batch
over the glyph atlas (Text).
Overlay order between actors is lineage order
(ADR-0248).
The four overlay verbs (draw_textured_quads, draw_screen_triangles,
draw_shapes, draw_text) share one painter order, and it has two parts.
Inside one actor it is submission order: the batches that actor sent, in the
order it sent them. Between two actors it is the order of the actor tree: a
child's draws lie over its parent's, and a later sibling's over an earlier
one's, where "later" is creation order and the root actors count as siblings.
The order the mail reaches the renderer in plays no part, so two actors that
draw on the same Tick lie the same way on every frame. The renderer files
each batch at its sender's place, read with NativeCtx::lineage_order when
the batch arrives, and sorts once, when the frame commits. Nothing states an
order: there is no z-index, layer number, or reorder verb. An actor that must
lie over another is created after it, or beneath a parent created after it;
a parent that needs fixed places creates its layers first, as empty children
in the order it wants, and spawns content beneath the right one.
A draw that is not mail from an ordinary actor sorts by the same rule, with no special case:
| Draws from | Sender the renderer reads | Where it lies |
|---|---|---|
MCP send_mail, send_mail_traced | aether.rpc.server | At that root's place in boot order: under every boot-list component and every later load |
capture_frame pre-mails and after-mails | aether.render itself | At the render capability's place in boot order, under every component |
| SubstrateHarness sends | none | Under everything |
| A driver root push with no reply inbox | none | Under everything |
So a debug shape mailed straight to the renderer from a session lies under every component's overlay draws. A session that wants a draw in front loads or spawns an actor that draws: a root created later is last.
Screen triangles are the overlay's free-form primitive.
draw_screen_triangles takes triangles whose three corners are pixels — one
linear RGBA per corner interpolated across the face — and records them in the
same overlay pass, through the same pipeline, in the one painter order they
share with the quad batches: submission order inside one actor, lineage order
between actors. Either winding draws; the batch carries the same optional clip
scissor and the same space, so a gauge or a graph edge can hang off a
world-space anchor exactly as a label does. It exists because 2D content built from
rotated geometry had no aspect-correct path: a quad is {x, y, width, height}
with no orientation, and draw_triangle is world-space, so with no camera
loaded its identity view_proj spans -1..=1 on both axes and stretches
everything by the window's aspect ratio. Under Screen the pixel coordinates
are absolute, so a ribbon at an angle, a gauge, or a graph edge holds its
proportions on any window without a camera actor publishing a projection for
flat content.
Shapes are the overlay's distance-field primitive
(ADR-0213).
draw_shapes takes axis-aligned boxes, each with a corner_radius, an optional
fill, an optional inside stroke { width_pixels, color }, and an optional
shadow { blur_pixels, offset, color }. The substrate expands each to one quad
grown by its shadow extent and a fragment stage evaluates a rounded-box signed
distance per pixel — shadow under fill under stroke, every edge anti-aliased
over one fwidth — so a one-pixel edge at a fractional position is one soft
edge rather than two half-covered rows, and the same six numbers draw a plate,
a circle (a radius at or above half the shorter side), a ring (a stroke with no
fill), or a soft halo (a shadow with neither) at any scale. The batch takes the
same painter position and the same clip scissor as any other overlay batch,
through its own pipeline: one more overlay draw, not a pass and not a layer.
The vocabulary is fixed and substrate-owned — callers supply parameters, never
WGSL — so the overlay lane stays a closed contract a caller can reason
about. A corner_radius of 0.0 with a fill alone is a
flat rectangle, which is why there is no separate flat-quad verb: the overlay
has one verb per fragment stage — sample a texture, evaluate a distance
field, rasterize caller geometry — and draw_text, which lays strings out
into the first.
A shape's optional texture { texture_id, u0, v0, u1, v1, blend } draws an
image inside the fill's coverage, so the corner radius, the circle, and the
anti-aliased edge apply to the image exactly as they apply to a flat colour —
a rounded avatar, a thumbnail at the panel's radius, a circular icon. The uv
sub-rect stretches across the shape's box, fill multiplies the sampled texel
the way draw_textured_quads' tint does (so Rgba::WHITE draws the image
unmodified, and a shape with a texture but no fill draws no image at all), and
blend means what it means on draw_textured_quads. The record path splits a
batch into draws at each texture transition, so one batch may mix untextured
plates with images over several textures and still keep its authored painter
order.
World-space materials are textured and depth-tested (ADR-0140).
The material pass records after the triangle pass and before the screen overlay,
loading the main pass depth buffer with writes disabled. Components send typed
material draw kinds rather than shader source. aether.render.material.textured
draws sampled rects with a tint. aether.render.material.coverage
requires an R8 texture, thresholds at iso 127.5, and renders a body/rim band
from the rect parameters. Each rect carries its own right/up basis: draped
planar content passes the world axes, while content registered to a view — an
underpainting standing behind its subject — orients the rect toward the eye. Both are immediate-mode: resend the batch every frame
or it disappears on the next commit-current frame.
The view_proj uniform, latest wins. The substrate holds one column-major
4×4 matrix and uploads it verbatim to the shader each frame (column-major matches
wgpu's uniform layout, so the 64 bytes upload with no transpose). An
aether.view_projection mail carries the two halves apart, as aether-math
values: view (world → view) and projection (view → clip), with the eye in
world space, the near and far depth planes, and the viewport extent in
physical pixels the projection was built for. The renderer stores
projection * view, and each mail overwrites it wholesale; nothing blends or
stacks. Before any view arrives, the matrix is identity, so vertices render in
clip space 1:1.
The renderer follows a view source. A view source is an actor that
publishes aether.view_projection to whoever subscribed to it. It covers the
ViewSource protocol: a viewer sends it aether.render.view_subscribe, and it
sends that viewer its current view at once and each later one as the view
changes, until the viewer sends aether.render.view_unsubscribe. Both kinds are
empty; the source takes its subscriber from the mail's sender. The renderer is
one such viewer, and aether.render.view_from { source } tells it which source
to follow: it unsubscribes from the source it followed before, subscribes to the
one at the path source, and replies view_from_result. So the active camera
is the source the renderer follows, one mail switches it, and two cameras do not
overwrite each other through this path. A path that does not prove — no actor
has stood at it, the actor there does not handle both subscription kinds, or it
has closed — is answered Err naming the path and why, and the renderer keeps
the source it had. When the followed source closes, the renderer follows nothing
and keeps the last view it was sent. A component that draws through its own
render program subscribes to the same source and is sent the same views. An
actor that computes its own view may still send aether.view_projection
straight to aether.render; it overwrites whatever a followed source sent.
Depth test is on. The offscreen target carries a Depth32Float depth buffer
tested LessEqual, so larger world-z draws on top. The convention that
follows: floors and backdrops sit at z = 0, movers at z ≥ 0.1. Geometry at
the same depth draws in submission order.
Geometry is retained per tick. DrawTriangle mail accumulates into a
per-frame buffer; when the frame records, that buffer becomes the frame and the
accumulator resets. A component redraws its geometry every frame it wants it
visible — stop emitting and the geometry is gone next frame. When a frame
records with nothing freshly emitted (a capture that didn't advance a tick), the
renderer replays the last submitted geometry, so a still frame shows what the
last live frame drew.
Device loss is generation-aware and bounded. Every installed wgpu device
has an internal generation. The first frame after that generation reports loss
makes one replacement attempt; callbacks arriving late from an older device
are ignored. A complete replacement is published at once — fresh built-in
pipelines and targets plus rebuilt registry state — rather than exposing a
partly reconstructed GPU. Failure emits one structured error and makes render
terminally unusable for the session: request/reply GPU operations and captures
return Err, while fire-and-forget draw, update, dispatch, and destroy mail is
warning-dropped. It does not retry or spin.
The surfaceless SubstrateHarness replaces its fixed offscreen target. A
desktop runtime instead walks every retained window in ascending window path
order. The first canonical window selects the replacement adapter, device, and
surface format; every later window must attach to that same context and format.
Only after all surfaces succeed does the runtime replace the full target map,
preserving each window path and occlusion flag together with the shared GPU and
wireframe overlay. If any later surface fails, none of the staged surfaces or
device state becomes live and the whole render capability becomes unusable.
A window surface's present mode is chosen from what the surface offers,
never fallen back to. The window's owner tells render one of two things
about a surface (SurfacePresent), on attach_window and again on
set_window_present, and render maps it over the present modes the surface
reports:
| Asked | wgpu mode chosen | When the surface lacks it |
|---|---|---|
InStep: the present waits for the display | Fifo | Err naming the modes the surface offers |
Unsynced: the present returns at once | Immediate, else Mailbox | Err naming the modes the surface offers |
A refusal leaves the surface configured as it was. wgpu's AutoVsync and
AutoNoVsync are not used, because each is a fallback chain and the second
ends in Fifo: a window asked to run unsynced would be paced by the display
with no word to its owner. FifoRelaxed is not used either: it is Fifo
that tears when a frame is late. The chosen mode is logged at info under
aether_substrate::render, since Unsynced is served by Immediate (which
may tear) on one platform and Mailbox (which does not) on another. Render
knows nothing of window kinds or frame rates: the desktop driver maps a
window's presentation onto these two values, and a
device replacement asks each rebuilt surface for the value it last had.
Public ids do not change across a successful replacement. Sampled textures
upload again from their retained CPU pixels, texture arrays from the retained
blob of each written layer, volume textures from the blob they were created
with, and registered geometry realizes again from its
retained vertex/index bytes. The replacement puts nothing on the upload queue:
a resource that was still queued uploads on the new device when its turn
comes, and one that had already left the queue uploads at its first use
there (see When staged bytes reach the GPU).
The unwritten layers of a texture array are owed their clear again on the new
device; the queue gives it only to an array that was still queued, and wgpu
clears the rest at the first draw through the array. GPU-only writable textures keep
their ids but restart transparent; an actor that needs their contents sends its
ordinary program dispatch on the next repaint. A capture that was ready but
had not begun may cross the successful transaction and record once. Loss with
an ambiguous submission, poll, map, or readback instead returns that capture's
Err; its frame is not replayed and its after_mails are not released twice.
All of this is host policy — there is no recovery kind, generation callback, or
guest-visible wire change.
The production headless chassis composes no render actor. A chassis
composes only the capabilities it serves
(ADR-0232 §6),
so a component that declares depends(RenderCapability) is refused at load on
headless, naming aether.render as the dependency that is not live, and a
capture_frame there is answered NotPresent. The minimal hub chassis does not
install an aether.render mailbox either. SubstrateHarness composes the real
offscreen RenderCapability for render/capture tests.
When staged bytes reach the GPU
Create, update and layer-write mail only stages bytes: the handler validates, keeps the bytes and answers. The GPU buffer or texture is made later, in one of two ways (ADR-0251).
The upload queue, ahead of use. Every create, update and layer write puts
its resource on one queue, once, in arrival order; a destroy takes it off. Each
frame the renderer uploads a fixed number of pieces from the front of the
queue, before it records any pass. Resources an unanswered
aether.render.await_resident names go first (see below). A resource that is staged and then given
frames is on the GPU before anything draws it, so the frame that first draws it
uploads nothing.
| Resource | One piece |
|---|---|
| Geometry | the whole geometry |
| Instance buffer | the range written since its last upload |
| Texture | the whole texture on its first upload, then the rows updated since the last |
| Volume texture | the whole volume |
| Texture array | one layer, with all its levels |
The allowance is the boot knob AETHER_RENDER_UPLOAD_PIECES_PER_FRAME
(--render-upload-pieces-per-frame), default 32. It is a count and not bytes
or time, because the cost of an upload follows the number of GPU objects it
makes. It is read once at boot, and zero is refused there.
First use, when a draw gets there first. A resource that a recorded pass names before the queue has reached it is uploaded in that frame, as it always was. Drawing a resource is how a sender says it is needed now, and correctness never depends on the queue: the queue only moves work earlier. When the queue later reaches a resource a draw already uploaded, it leaves the queue at no cost.
A texture array goes up a layer at a time, unwritten layers included. An array is staged by many mails and can be far larger than one frame should carry, so it alone is divisible. The queue uploads its written layers first, then clears each layer no mail has written by writing zeros to it, and the array leaves the queue only when every layer is written or cleared on the GPU. The clears matter because a GPU texture's never-written layers are cleared by wgpu in the first submission that draws through the texture, all at once; an array with many unwritten layers would otherwise cost one long frame the first time it is drawn. A dispatch that binds the array early uploads its written layers in that frame and leaves the unwritten ones to the queue.
Waiting until resources are on the GPU. A resource is staged while the renderer holds its bytes and the GPU object is missing or behind them, and resident once the GPU object holds every staged byte. An actor that wants to hold content back until drawing it uploads nothing sends one request:
// send_mail → aether.render (kind: aether.render.await_resident)
{ "resources": [
{ "Geometry": { "geometry_id": 3 } },
{ "Instances": { "instances_id": 0 } },
{ "Texture": { "texture_id": 1 } } // a texture, a texture array or a volume
] }
The reply is aether.render.await_resident_result, sent exactly once:
| Reply | Meaning |
|---|---|
Ok { bytes } | every named resource was resident at some point after the request arrived; bytes is what they hold on the GPU, each counted once, as the memory report counts them |
Err(Unknown { resource }) | resource named nothing when the request arrived (never created, already destroyed, or a texture the renderer keeps for itself); nothing was waited on |
Err(Destroyed { resource }) | resource was destroyed before it became resident |
Err(DeviceUnusable { error }) | the render device failed for good, so nothing more uploads |
Err(Closed) | the renderer closed first |
- When it is answered. At once when every named resource is already
resident, when an id names nothing, or when the list is empty
(
Ok { bytes: 0 }). Otherwise in the frame whose upload step finds the last named resource resident. A resource a draw uploaded first is noticed by the next step that reaches it, not in the frame of that draw. - Order. The upload step takes the resources an unanswered request names before every other queued resource, and each of the two groups in arrival order. This is the only ordering signal: no create kind carries a priority.
- A later update does not reopen a wait. A named resource is counted the first time it is found resident. If it is updated afterwards the request is still answered when the others arrive, and an answered request is never answered again. To hear that an update reached the GPU, send the update and then ask again; mail from one sender arrives in order, so the new request covers it.
- A request that has to wait settles its own chain first. The reply is
owed without holding the sender's chain, so a request sent from a
Tickhandler does not hold the frame loop, and asend_mailover MCP returns with no reply for it. A component takes the reply in a#[handler::response]; from MCP, check that a frame has run and ask again, which is answered inside the call once everything is resident. - Duplicates. A resource named twice in one request counts once.
The white texture and the glyph atlas, which the renderer keeps for itself, are never queued: the frame that draws with one uploads it. With no device, or no frames, nothing uploads and no wait is answered.
How to use it
There are two seats: a component drawing into frames, and an agent staging a frame to read it back.
From a component — submit on the Render stage. A render-producing actor
computes its per-frame state on Tick and submits geometry on the Render
lifecycle stage, so the submission integrates the fully-settled cross-actor state
of the frame rather than racing other actors' tick handlers
(ADR-0082).
Both are frame-lifecycle stages, subscribed on aether.lifecycle from the wire
hook:
#![allow(unused)] fn main() { // In an `#[actor(depends(LifecycleCapability, RenderCapability))]` block. fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> { ctx.subscribe::<LifecycleCapability, Tick>(); ctx.subscribe::<LifecycleCapability, Render>(); Ok(()) } #[handler::event] fn on_render(&mut self, ctx: &mut WasmCtx<'_>, _render: Render) { ctx.send_many::<RenderCapability>(&self.triangles); } }
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.
Address the cap by type — ctx.send::<RenderCapability>(..) — and send
DrawTriangles. If you're a camera, be a view source: handle ViewSubscribe by
typing ctx.sender() with ctx.cast::<Subscriber<ViewProjection>>(), keep the
reference, and send each ViewProjection through it with ctx.send_to; handle
ViewUnsubscribe by dropping it. A camera then names no recipient, and the
renderer takes its view once aether.render.view_from names the camera. A
component that depends on render does not stand up on headless at all; one
that subscribes Render without depending on render gets the lifecycle's
refusal at wire time on a graph that omits the stage.
From an agent over MCP — stage, then capture. Use capture_frame: its
required window names the render target (the window's actor path as
aether.window.list reports it, or its short form aether.window/:main; the
tool never guesses a primary or focused window), its mails bundle dispatches before the readback (the state that should
appear) and after_mails after (cleanup), all around one synchronous PNG read.
So to see a
camera change, stage an aether.kit.camera.pose or .frame addressed to the
camera instance (or a DrawTriangle directly) in mails and read the frame
back inline. The renderer's retained
geometry means a capture that doesn't advance a tick still shows the last live
frame.
How to extend or reuse it
- A camera is component work, not substrate work, and
aether-kit'scameraexport is the one to use: an instanced actor, one camera per instance ataether.kit.camera:<key>, spawned with anaether.kit.camera.config({ lens, viewport, pose }) and removed by dropping the instance. It is a view source: it publishes itsViewProjectionto its subscribers when the view changes and at no other time, and the renderer is one onceaether.render.view_fromnames it, so the active camera is whichever one the renderer follows.- One pose and one lens. A pose is
{ target, yaw, pitch, distance }: the eye sitsdistancefromtargetand looks back at it. A lens isPerspective { fov }orOrthographic { extent }, whereextentis the half-height at the target per unit of distance. The view is the inverse of the eye's rigid transform, so a pitch of-PI/2(straight down) is an ordinary pose, and top-down is that pitch with an orthographic lens. The scalars are validated: a pitch outside[-PI/2, PI/2], a distance or an extent that is not above zero, or a field of view outside(0, PI)does not decode. - Depth follows distance. A perspective lens puts the planes at
distance / 100anddistance * 100, an orthographic one at∓distance * 100, andViewProjection'snearandfarcarry them. A scene a thousand units across needs no tuning; a drawer that knows better bounds can still send its ownViewProjection. - The viewport is
Fixed { width, height }orWindow(path). A window camera follows its window's size and publishes nothing until it has learned it. - Mail.
aether.kit.camera.posesets the pose;.frame { bounds }looks at a box from far enough back to see all of it;.glide { to, over_millis }eases to a pose, stepping onTickonly while it runs;.whereis answered with the current pose;.ray { pixel }is answered with the world-space ray through a pixel (ray_result), ready foraether-math'sRay::plane_hit. - A republish of the kit module keeps a camera's pose, glide and learned
viewport size and drops its viewers, which the camera logs at warn: send
aether.render.view_fromagain, and have any other viewer subscribe again.
- One pose and one lens. A pose is
- Driving a camera with the mouse and the keyboard is a peer component's
job, not the camera's.
aether-kit'scamera-controllerexport, an instance ataether.kit.camera-controller:<key>, subscribes the window's key, mouse and focus events andTick, and sends the camera onlyaether.kit.camera.pose, the message a script or an agent sends: at most one a tick, and none while nothing is held.- Controls. Left-drag orbits. The wheel zooms, multiplying the distance
per step within the config's
nearestandfarthest. Right-drag or middle-drag pans with the grabbed point staying under the cursor. WASD and the arrows pan the target across the ground at a rate that scales with the camera's distance, and Q/E turn the camera about its target. Key rates use the tick's elapsed time. - It reads before it writes. A gesture starts when input arrives while
nothing is held: the controller asks the camera
aether.kit.camera.whereand steps from the answer, and it forgets the pose when the last key and button are released. Apose,frameorglidesent from elsewhere between gestures stands, and the next gesture continues from wherever the camera is. - It is a viewer of its camera. It sends the camera
aether.render.view_subscribeand casts a drag pan's rays through the view the drag began in, so the pan is exact for either lens with no request per mouse move. - One window. Its
aether.kit.camera-controller.confignames the camera ("camera": "aether.kit.camera:main") and the window whose input it reads ("window": "aether.window/aether.window.instance:main"), and sets the rates and the zoom range. Input from any other window is ignored, and when its window loses focus it drops every held key and button, whose releases went to another window. - Load the camera first: a controller whose camera is not live fails its load.
- Controls. Left-drag orbits. The wheel zooms, multiplying the distance
per step within the config's
- A new drawing component subscribes the
Renderstage and emitsDrawTriangles in world space, withzchosen against the depth convention (backdrop atz = 0, movers above). Multiple components can draw into one frame; they share the world coordinate system and the active camera with no coordination beyond the depth ordering. - Mesh authoring is a layer above this one: a component that loads mesh files
and replays their triangles to
aether.rendereach frame. The DSL, parser, tessellation, OBJ compatibility, and viewer path are covered in Mesh authoring.
Where to read more
- The art direction the renderer serves — ADR-0025.
- Where the render and camera kinds live, and why a camera is an ordinary component — ADR-0066.
- Camera folding into the render mailbox — ADR-0074 §Decision 7.
- The textured-quad surface text and sprites compose, and the screen-vs-world projection split — ADR-0105.
- The
Tick/Renderframe stages and why submission waits for settlement — ADR-0082; thewirehook and writing handlers — Components & lifecycle. - Subscribing input and lifecycle stages from a component — Input streams.