Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

Chapter 15: User Interface

Crafty uses a hybrid UI approach: HTML/DOM for the menus and HUD, in-game overlays for crosshair and block interactions.

15.1 DOM-Based UI vs. In-Game UI#

Exploded layers showing the WebGPU canvas at the bottom (3D scene + crosshair/highlight overlays) with the HTML/DOM layer (HUD, hotbar, chat, menus) stacked on top — pointer-events let clicks pass through transparent regions

DOM-based UI is used for:

  • Start screen (world selection, network connect)
  • Settings panel (graphics, audio, controls)
  • Chat window (multiplayer)

In-game GPU-rendered overlays are used for:

  • Crosshair (rendered as a fullscreen overlay pass)
  • Block highlight outline (BlockHighlightPass)
  • Debug text overlay (FPS, position, chunk count)

The DOM approach gives us accessibility (screen readers, browser zoom) and familiar styling via CSS. The in-game approach ensures the crosshair and block highlight are correctly aligned with the 3D scene without latency.

15.2 The HUD#

Screen mockup showing the HUD's anchored regions: crosshair centered, hotbar bottom-center, stats bottom-left, debug overlay top-left, chat bottom-left — each region is a DOM element positioned with CSS, transparent to mouse events

The HUD (crafty/ui/hud.ts) is a DOM overlay with:

  • Crosshair — a small CSS-rendered cross at screen center.
  • Hotbar — 9 slots showing the player's held blocks, with a selected-slot highlight.
  • Stats — health, hunger, armor (survival mode).
  • Debug overlay — FPS, coordinates, chunk count, render mode.
  • Chat area — multiplayer chat messages.
// ── from crafty/ui/hud.ts ──
export interface HudElements {
  fps: HTMLDivElement;
  stats: HTMLDivElement;
  biome: HTMLDivElement;
  pos: HTMLDivElement;
  weather: HTMLDivElement;
  reticle: HTMLDivElement;
  target: HTMLDivElement;
  /** Time-of-day clock — always visible, not part of the debug overlay. */
  time: HTMLDivElement;
}

export function createHud(): HudElements {
  // ... assemble each div with inline cssText, append to document.body ...
}

The reticle and time-of-day clock are visible by default; the debug overlay (fps, stats, biome, pos, weather, target) is display: none until the X key toggles it. Per-frame the game just writes textContent on these elements — no virtual DOM, no reconciliation, no allocation.

15.3 The Start Screen#

The start screen (crafty/ui/start_screen.ts) appears before the game loads. It provides:

  • Single player — create or load a local world.
  • Multiplayer — enter a server address and player name to connect.
  • Settings — graphics quality, audio volume, controls.
// ── from crafty/ui/start_screen.ts ──
export type StartChoice =
  | { mode: 'local'; world: SavedWorld; storage: WorldStorage | null; playerName: string }
  | {
      mode: 'network';
      playerName: string;
      serverUrl: string;
      network: NetworkClient;
      welcome: ConnectResult;
      world: WorldSummary;
    };

export async function showStartScreen(): Promise<StartChoice>

The launcher resolves once the user picks either a local world (newly-created worlds are persisted to IndexedDB before the promise resolves so they appear in the saved list even if the user immediately quits) or a successful network connection (the open WebSocket and welcome payload are handed off to main, so the bootstrap never re-handshakes).

15.4 The Settings Panel#

The control panel (crafty/ui/control_panel.ts) builds the in-game settings UI from generic row helpers; the persisted state lives in crafty/config/game_settings.ts:

// ── from crafty/config/game_settings.ts ──
export interface GameSettings {
  effects: Record<EffectKey, boolean>;
  aoMethod: AOMethod;
  /** Master volume, 0–1. */
  masterVolume: number;
  /** Global mute. */
  muted: boolean;
  /** Day-night cycle frozen (time-of-day paused). */
  timePaused: boolean;
}

Settings are JSON-encoded into localStorage under the crafty.game.settings key; every field is validated on load so a stale or hand-edited entry cannot break the game.

15.5 The Block Manager#

Categorized grid of block thumbnails (Natural, Stone, Wood, etc.) with the selected block flowing down into a hotbar slot — clicking a tile assigns that block type to the active slot

The block manager UI (crafty/ui/block_manager.ts) allows the player to select which block type to place. It shows a grid of available blocks with their textures, organized by category.

In creative mode, all block types are available. In survival mode, only blocks the player has collected are shown. The selected slot determines which block is placed on right-click.

15.6 Text Rendering: SDF Glyphs#

DOM textContent is perfect for the HUD — it sits in screen space, never moves with the camera, and we can let the browser do the typesetting. But the moment text has to live in the 3D scene — a street name draped over a map, a label tracking a moving object, text that follows a curved road — DOM falls apart. A <div> can only be a screen-aligned axis box; it can't rotate per-glyph along a path, it won't compose with HDR and tonemapping, and pumping thousands of nodes through layout every frame thrashes the browser. The remote-player name tags (crafty/game/name_label.ts, one <div> per player projected to screen) are the most a DOM label can be, and that's the ceiling.

So the engine carries its own GPU text renderer in src/text/ — engine-general, not tied to the map that first needed it. The hard part of drawing text on a GPU is that a glyph is a tiny bitmap, and a tiny bitmap scaled up turns blurry or blocky. The fix is a signed distance field (SDF): instead of storing per-texel coverage, store each texel's distance to the nearest glyph edge. Distance interpolates smoothly under bilinear filtering, so one modestly-sized atlas stays razor-sharp from tiny labels to huge ones, and the fragment shader can reconstruct a crisp antialiased edge — plus outlines and halos — for free.

SDF text pipeline: a CSS font is rasterized on a Canvas, distance-transformed into an SDF atlas with per-glyph metrics, then drawn per frame as instanced quads blended onto the HDR target, with the fragment shader reconstructing a sharp edge at any scale via screen-space derivatives

15.6.1 Building the atlas#

createSdfFontAtlas (src/text/text_atlas.ts) is the one browser-only step: it rasterizes a CSS font into a single-channel SDF texture plus a FontMetrics table. This is the MapLibre/TinySDF recipe — Canvas 2D coverage → Euclidean distance transform → SDF:

// ── from src/text/text_atlas.ts ──
// Cell sized to the font's full ascent + descent, not a square `size` box — a square cell clips the
// tails of descenders ('g', 'y', 'p') for fonts whose fontBoundingBox extends past the em.
const cellW = Math.ceil(size) + buffer * 2;
const cellH = ascent + descent + buffer * 2;
for (const ch of charset) {
  ctx.clearRect(0, 0, cellW, cellH);
  ctx.fillText(ch, buffer, buffer + ascent);              // draw the glyph, padded
  const img = ctx.getImageData(0, 0, cellW, cellH).data;  // read its alpha coverage
  const alpha = new Float32Array(cellW * cellH);
  for (let i = 0; i < cellW * cellH; i++) {
    alpha[i] = img[i * 4 + 3] / 255;
  }
  const sdf = computeSdf(alpha, cellW, cellH, radius);     // coverage → distance field
  const pos = packer.add(cellW, cellH);                    // shelf-pack into the atlas
  glyphs.set(cp, { x: pos.x, y: pos.y, w: cellW, h: cellH,
    xOffset: -buffer, yOffset: -(buffer + ascent), advance: adv });
}

computeSdf runs a binary version of the glyph through a Euclidean distance transform twice — once seeding the covered texels (the inside field) and once seeding the empty ones (the outside field) — and subtracts them, so the result is negative inside the glyph and positive outside. The transform itself (edt2d) is the classic Felzenszwalb & Huttenlocher algorithm: a 1D lower-envelope-of-parabolas pass run over every column, then every row. It's pure, exact, and O(n) per pass — and it's unit-tested independently of any canvas. The signed distance is then encoded to 0..255 with 128 sitting on the glyph edge:

// ── from src/text/text_atlas.ts ──
const d = Math.sqrt(outer[i]) - Math.sqrt(inner[i]); // <0 inside, >0 outside
const val = 0.5 - d / radius;                         // inside → >0.5
out[i] = Math.max(0, Math.min(255, Math.round(255 * val)));

The encoded glyphs are packed into one texture by a ShelfPacker — a dead-simple left-to-right, row-by-row packer, which is all a fixed one-shot glyph set needs (no online repacking). The atlas is uploaded as r8unorm (one byte per texel — distance is scalar, we don't need color), and FontMetrics records each glyph's atlas rect, pen advance, and offset from the baseline so layout can typeset without ever touching a canvas again.

One more thing happens at upload: we generate a short, box-filtered mip chain for the atlas and sample it trilinearly. An SDF is razor-sharp under magnification — that's the whole point — but a single texel tap under heavy minification (a 13px label drawn from a 44px atlas, or a curved label whose glyphs creep sub-pixel each frame) undersamples the field and the edges crawl and shimmer. Mipmaps give the GPU a pre-averaged level to pick. The chain is capped at a few levels, deliberately: a packed atlas bleeds neighboring glyphs together at deep mips, and text never minifies that far in practice — each glyph's buffer padding keeps what bleed there is inside the empty outside-field where it's invisible.

This is a single-channel SDF, not a multi-channel MSDF. True MSDF keeps sharper corners but needs glyph vector outlines run through offline edge-coloring tooling we can't run in the browser. Because the pass and shader just read a generic distance field, a prebaked MSDF atlas (3-channel, median(r,g,b)) is a drop-in upgrade later with no pipeline change.

15.6.2 Laying out a line#

With metrics in hand, typesetting is pure arithmetic. layoutLine (src/text/text_layout.ts) walks a string, advancing a pen and emitting one GlyphQuad per visible glyph — corner positions relative to the text origin, plus atlas UVs normalized to 0..1 so the caller can swap atlas resolutions freely:

// ── from src/text/text_layout.ts ──
export function layoutLine(text: string, m: FontMetrics): LaidOutLine {
  const quads: GlyphQuad[] = [];
  let pen = 0;
  for (const ch of text) {
    const g = m.glyphs.get(ch.codePointAt(0) as number);
    if (g === undefined) {
      const space = m.glyphs.get(0x20);          // unknown glyph: advance gracefully
      pen += space ? space.advance : m.lineHeight * 0.3;
      continue;
    }
    if (g.w > 0 && g.h > 0) {
      const x0 = pen + g.xOffset, y0 = g.yOffset;
      quads.push({ x0, y0, x1: x0 + g.w, y1: y0 + g.h, /* ...UVs... */ });
    }
    pen += g.advance;
  }
  return { quads, width: pen, ascent: m.ascent, descent: m.descent };
}

This module is deliberately GPU-free and DOM-free — reusable by any 2D or 3D label system, identical whether the glyphs came from the runtime SDF atlas or a future prebaked one. It ships two more pure, unit-tested helpers the map labels lean on:

  • declutter — greedy screen-space collision. Given each candidate label's screen box and a priority rank, it places labels in importance order and drops any that overlap one already kept. O(n²), which is fine for the few hundred candidates a viewport holds after culling.
  • placeGlyphsAlongLine — distributes a line of glyphs along a road polyline, giving each glyph a center point and a tangent angle, and flipping the whole run if it would otherwise read right-to-left. This is how street names curve along their roads.

15.6.3 The draw pass#

SdfTextPass (src/text/sdf_text_pass.ts) is a generic render-graph pass that draws every glyph on screen in one instanced draw call. Each glyph is one instance of 14 floats — center, half-size, a (cos, sin) rotation (so glyphs can follow a path), the atlas UV rect, and an RGBA color:

// ── from src/text/sdf_text_pass.ts ──
/** Floats per glyph instance: center(2) + halfSize(2) + rot(2) + uvRect(4) + color(4). */
export const GLYPH_FLOATS = 14;
// ...
enc.setPipeline(this._pipeline);
enc.setBindGroup(0, this._bindGroup);   // atlas + sampler + viewport uniform
enc.setVertexBuffer(0, this._instanceBuf);
enc.draw(6, this._count);               // 6 verts × N glyphs

The caller (e.g. GeoLabelFeature) builds that instance buffer each frame; the pass just grows its GPU buffer by powers of two as the glyph count climbs and uploads. It chains onto frame.hdr with loadOp: 'load' and no depth test, so text floats as an overlay on top of the lit scene — the same insertion slot as the lens-flare pass, before tonemap — and blends with premultiplied alpha.

The vertex shader expands each instance into a quad, rotates it by (cos, sin), and maps it to NDC against the viewport size. The fragment shader is where the SDF pays off (src/shaders/sdf_text.wgsl):

// ── from src/shaders/sdf_text.wgsl ──
let d = textureSample(atlas, samp, in.uv).r;
let aa = max(fwidth(d), 0.001);                       // edge softness = on-screen texel size
let fill  = smoothstep(0.5 - aa, 0.5 + aa, d);        // glyph interior
let outer = smoothstep(0.5 - aa - u.halo, 0.5 + aa - u.halo, d); // interior + optional outline ring
let aFill = fill * in.color.a;
let a     = outer * in.color.a;
return vec4f(in.color.rgb * aFill, a);                // premultiplied: color over a black outline

fwidth(d) is the trick that makes one atlas work at every size: it measures how fast the distance field changes per screen pixel, so the smoothstep width tracks the on-screen glyph scale automatically — antialiasing stays a constant ~1px whether the label is tiny or filling the screen. The u.halo uniform adds an optional black outline: outer is a second, wider smoothstep reaching halo below the edge, so the ring between fill and outer becomes opaque-black coverage with no color. With halo = 0 the ring vanishes and you get plain antialiased text; the geo map raises it (~0.22) so labels stay legible over busy imagery.

15.6.4 Getting the edges right: fringing, counters, descenders#

The lines above are the corrected shader. The first version had a subtle but ugly bug, and chasing it down is instructive — these are the failure modes any SDF text renderer hits.

The dark fringe. The original fragment shader mixed the fill color toward black and scaled the alpha by the same edge coverage:

// WRONG — darkens every antialiased edge twice
let rgb = mix(vec3f(0.0), in.color.rgb, inside);  // color fades to black at the edge
return vec4f(rgb * (inside * in.color.a), inside * in.color.a);

At a half-covered edge texel (inside ≈ 0.5) the color is already halved toward black and the coverage is 0.5, so the premultiplied result is about a quarter of the glyph's color where it should be half. Every glyph got a dim ring one pixel wide — barely visible over a dark scene, but over a bright sky that ring tints the background gray and reads as dirty edge "fringing." The fix is to stop tying the color to the edge: composite a constant-color fill over the opaque-black outline (rgb = color * fill, a = outer), so the fill edge keeps its full color and only the genuine outline ring is dark.

Filled counters. A wide always-on halo doesn't just outline the glyph — it grows inward too. At small sizes the inward halo from both sides of a thin stroke meets in the middle and fills the small holes (the counters) of e, o, a, g with black blobs. That's why the halo is a small, caller-controlled value rather than a baked-in constant: general text passes halo = 0 and stays clean; only labels that genuinely need a legibility outline over a busy background opt in, and even then keep it modest.

An outline is not free. The outline is itself a semi-transparent dark ring, so over a bright background it darkens whatever shows through. It's a legibility tool for busy backgrounds — over a clean gradient it just looks like fringing. The crisp samples/text_test.ts showcase passes halo = 0 for exactly this reason; the map keeps its outline because map tiles are visually noisy.

Descenders and shimmer round out the list — both covered above: size the atlas cell to the font's real ascent + descent so g/y/p tails aren't clipped, and mip the atlas so minified or animated labels don't crawl. None of these are exotic; they're the standard tax on rolling your own SDF text, and the unit-tested pure core (computeSdf, layoutLine) is what let us fix the shader and atlas without re-deriving the typesetting each time.

15.6.5 Map labels, end to end#

GeoLabelFeature (src/geo/geo_label_feature.ts) wires the whole thing together for the geo map and is the reference consumer worth reading if you build your own. Each frame it: projects every label's anchor into screen space under the floating origin; runs point labels through layoutLine and street labels through placeGlyphsAlongLine; declutters the survivors by priority; ramps a per-label fade opacity so labels fade in and out instead of popping; and emits the glyph instances for SdfTextPass. It's a good template for any in-world text — health bars, damage numbers, waypoint markers — that needs to be sharp, cheap, and composited with the 3D scene rather than bolted on as DOM.

15.7 Summary#

The UI follows a hybrid approach combining DOM and GPU-rendered elements:

  • DOM-based UI: Menus, HUD, and settings — fully accessible and CSS-styled
  • GPU overlays: Crosshair and block highlight — zero latency with the 3D scene
  • HUD: Crosshair, hotbar, stats, debug info, chat
  • Start screen: Single player, multiplayer, and settings panels
  • Settings: LocalStorage persistence with quality presets
  • Block manager: Categorized grid with creative/survival mode filtering
  • SDF text: GPU-rendered glyphs that stay sharp at any scale and compose with the 3D scene — for text DOM can't do

Further reading:

  • crafty/ui/ — All UI components
  • crafty/ui/hud.ts — In-game HUD
  • crafty/ui/start_screen.ts — Start screen
  • crafty/ui/block_manager.ts — Block selection grid
  • src/text/ — SDF atlas generation, pure layout helpers, and the glyph draw pass
  • src/shaders/sdf_text.wgsl — SDF antialiasing fragment shader
  • src/geo/geo_label_feature.ts — End-to-end in-world labels (the reference consumer)