Drawing your first text
Class: drive. No recompile — use a running render-capable engine and the
bytes of a TTF. Reach for the MCP harness (send_mail) or a component's ctx;
the mail contract is the same either way.
The renderer draws text. You register a font once from its bytes, then send a
text draw to aether.render every frame you want it on screen. The first draw
shows: nothing has to be created by a round trip first.
1. Register a font
Mail aether.render.create_font to the aether.render mailbox. Its one field,
bytes, is the whole font file as a blob. The renderer reads no file, so
getting the bytes is the caller's job, and there are two usual ways.
From a session, pass the file's bytes as hex. The vendored
RobotoMono.ttf is about 184 kB, so about 370 kB of hex text:
// send_mail → aether.render (kind: aether.render.create_font)
{ "bytes": { "$hex": "0001000000120100000400204753554249…" } }
From a component, read the file through aether.fs and hand the blob on.
A blob a guest received is sent on by reference, so the font is never copied
through the guest's memory. A throwaway component is enough for a session
that would sooner not paste hex:
#![allow(unused)] fn main() { #[actor(root, depends(FsCapability, RenderCapability))] impl WasmActor for FontLoader { const NAMESPACE: &'static str = "demo.font_loader"; fn init(_ctx: &mut WasmInitCtx<'_>) -> Result<Self, ActorInitError> { Ok(FontLoader { font_id: None }) } // Mail is allowed from `wire`: ask `aether.fs` for the file. fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> { ctx.send::<FsCapability>(&Read { addr: NamespaceAddr::new("assets", "fonts/RobotoMono.ttf") }); Ok(()) } // The file's bytes arrive as a blob; pass it straight to the renderer. #[handler::response] fn on_read_result(&mut self, ctx: &mut WasmCtx<'_>, read: ReadResult) { match read { ReadResult::Ok { bytes, .. } => { ctx.send::<RenderCapability>(&CreateFont { bytes }); } ReadResult::Err { error, .. } => tracing::warn!(?error, "the font file did not read"), } } // Keep the id: it names the font in every draw. #[handler::response] fn on_create_font_result(&mut self, _ctx: &mut WasmCtx<'_>, created: CreateFontResult) { match created { CreateFontResult::Ok { font_id } => self.font_id = Some(font_id), CreateFontResult::Err { error } => tracing::warn!(%error, "the bytes are not a font"), } } } }
A component that carries its font with it takes it from its module's assets, or
builds the blob from embedded bytes with Blob::from, and skips the read.
Either way the renderer parses the font off its own turn and replies
aether.render.create_font_result:
{ "Ok": { "font_id": 0 } }
Bytes that are not a font reply { "Err": { "error": "…" } } instead. Hold
onto font_id — it names the font for every draw, and it is valid until the
engine restarts. Registering the same bytes again gives a second font with a
new id.
2. Draw a string
Mail aether.render.draw_text every frame the text should be visible — the
same immediate-mode contract as aether.draw_triangle. Send it once and the
string shows for one frame; stop sending it and it vanishes.
// send_mail → aether.render (no application reply; keep the tool's settled default)
{
"clip": null,
"space": "Screen",
"runs": [
{
"font_id": 0,
"text": "hello aether",
"size_pixels": 32.0,
"color": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 }, // RGBA, linear
"origin": [24.0, 24.0]
}
]
}
A draw_text is a batch: clip and space apply to every run in runs, and
each run is one string in one font at one size. Screen lays a run out in
window pixels starting at its origin, flowing left to right. The baseline
sits one ascent below the origin.
color is a linear RGBA multiplier over the glyph coverage: the alpha channel
scales the blend, so { "r": 1, "g": 0, "b": 0, "a": 1 } draws solid red
text and { "r": 1, "g": 1, "b": 1, "a": 0.5 } draws half-transparent
white.
Text is drawn in the order you send it among your other overlay draws. Send a
draw_shapes plate and then the label, and the label lies on the plate; send
the plate second and it covers the label.
3. See it
From the MCP harness, capture_frame with the draw in mails renders the
string into the returned PNG. One capture is enough, even for the first draw
of the session.
// 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.draw_text",
"params": { "clip": null, "space": "Screen",
"runs": [ { "font_id": 0, "text": "hello aether", "size_pixels": 32.0,
"color": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 },
"origin": [24.0, 24.0] } ] } }
]
}
4. Float a label above a character
To draw a label at a world-space position — above a character's head, for
instance — use World { anchor, scale } instead of Screen.
// send_mail → aether.render (no application reply; keep the tool's settled default)
{
"clip": null,
"space": {
"World": {
"anchor": [0.0, 2.0, 0.0],
"scale": { "Distance": { "reference_distance": 10.0 } }
}
},
"runs": [
{
"font_id": 0,
"text": "Player",
"size_pixels": 18.0,
"color": { "r": 1.0, "g": 1.0, "b": 0.8, "a": 1.0 },
"origin": [0.0, 0.0]
}
]
}
anchor is the world-space point the label floats above. Each run is
centered horizontally on the anchor, with the baseline sitting at the anchor's
projected screen position and glyphs extending upward. A run's origin is
ignored in world space.
scale controls how the label's apparent size relates to camera distance:
{ "Distance": { "reference_distance": 10.0 } }— the label holds itssize_pixelsexactly when the anchor is 10 units from the camera and shrinks proportionally as it recedes. This is the above-the-head mode: the label looks natural from any distance."Pixels"— the label keeps a fixed on-screen pixel size regardless of distance. Useful for HUD-style labels that must stay readable at any range.
Both modes use the current aether.view_projection view-projection matrix, so the label
always faces the camera and never skews as the camera orbits. Send the draw
every frame the label should appear, the same as Screen text.
Related text operations
aether.render.font_metricsreturns a size-independent metrics table for afont_id, so callers can measure, fit, and place text locally.clipapplies an optional framebuffer-pixel scissor to the whole batch. Runs that need different clips go in differentdraw_textmails.
What it does not do yet
- One font, one size, one line per run. No shaping, bidirectional text, or emoji — the layout is fontdue's horizontal advance metrics.
- The atlas has no LRU or multi-atlas spill. Glyphs that do not fit are skipped in the draw that fills it. The next text draw clears the atlas and places its glyphs afresh; drawing recovers when the working set fits.
- Fonts are never released. There is no destroy request for a font.
All of these sit behind the three aether.render text kinds, so the internals
can grow without changing the mail you send.