← All posts

The HUD is DOM, and that was the right call

Every 3D engine has to answer the UI question. Unity built uGUI, then UI Toolkit. Unreal built Slate and UMG. Both are large subsystems, and both exist mostly because a native engine has no other option: there is no text layout engine sitting next to your renderer, so you write one.

A browser engine is in a different position, and I think the right answer is different. Awaken's HUD is a DOM overlay over the canvas. Text, panels, buttons and images are elements, positioned over your game.

The argument, and it is mostly text

Draw a HUD on the GPU and you sign up for the entire text stack. Glyph atlases, kerning, shaping, bidirectional text, fallback fonts for the scripts you did not think about, subpixel positioning, and a signed-distance-field pipeline if you want it to scale. That work is enormous, it is nobody's differentiator, and it is sitting in the browser already, finished, at a quality no game engine's text renderer reaches.

The rest of it follows. Buttons get focus, hover and keyboard activation for free. Screen readers work. Selection works. @font-face means an imported pack's typeface renders in its own typeface with no atlas baking step.

The cost is real and worth stating: UI elements cannot be composited inside the 3D scene, cannot be sampled by a shader, and cannot be part of a post-processing pass. A HUD that needs to warp with a lens distortion is not this system.

For a HUD, a menu, a dialogue box and an inventory screen, which is what most games need, the trade is lopsided in favour of DOM.

One component, reconciled

A UINode is an ordinary ECS component, so it serialises with the scene and ships in the export like anything else. No separate UI document, no second asset type.

Each frame the overlay reconciles the DOM against the UINode entities: create, update, remove. It is the same idea as a virtual DOM diff, with a fixed component shape instead of an arbitrary tree, which makes it about thirty lines rather than a framework.

The layout model is deliberately small: an anchor (one of nine screen positions) plus pixel x/y offsets from it, and w/h where 0 means fit the content.

Nine anchors and two offsets is not a layout engine. It is enough to pin a health bar bottom-left, a score top-right and a crosshair centred, and have all of them stay where they belong at any window size. Anything more elaborate, in my experience, ends up being an argument with a layout engine rather than a use of one.

Kind changes the chrome, and one rule matters: only button captures clicks. text and image are click-through, so a full-screen HUD does not eat the pointer input your game needs.

Scaling, and why it is off by default

Raw pixels mean a HUD tuned on a large canvas is oversized on a small one. The fix is a reference height: pick the height you designed at, and every UINode scales by viewportHeight / referenceHeight. Offsets, sizes and font sizes scale together while each element stays pinned to its anchor. It is Unity's CanvasScaler "scale with height".

It defaults to off, which surprises people. A HUD tuned to one resolution and scaled anyway looks worse, not better, and a maker who has not thought about it yet is best served by pixels meaning pixels. Imported HUD packs set 1080, because that is what they were authored at.

Importing uGUI, and what it refuses

Unity UI packs were the interesting import problem. A uGUI widget prefab is a RectTransform hierarchy of Image and Text elements with anchors, pivots and offsets at every level. Awaken UI is a flat set of screen-anchored nodes.

Converting means resolving each element's rect through the whole anchor chain (the standard uGUI layout maths) and emitting it as one centre-anchored node, so the widget reconstructs on screen without the hierarchy that produced it.

The scope line is drawn at elements whose Image or Text live inline. Deeply nested prefab-instance composites and dynamic layout groups are not resolved, and those elements are skipped rather than guessed at, so a complex screen imports its resolvable pieces instead of failing entirely. Y is flipped, because Unity's origin is bottom-left and a screen's is top-left.

One honest gap: navigation labels set by the pack's own runtime scripts stay as their placeholder text. Only strings authored inline, or as a prefab-instance override, come across, because the others do not exist until Unity runs code Awaken does not run.

The sprite art imports as textures, the .anim clips import as UI clips, and the .ttf/.otf fonts register as @font-face, so a pack's screens come across looking like themselves.

Animation without a second system

UI clips are keyframed numbers on named properties, played by a UIAnimator. That is enough for the things UI actually does: fade in, slide, pulse, spin a reticle. rotation, scaleX and scaleY exist on UINode specifically so animation has something to drive.

It is a separate clip type from skeletal animation and shares no code with it, which was a deliberate choice. A UI clip has no bones, no skeleton, no retargeting, no blend tree. Forcing them through one system would have meant a skeletal animator that knows about opacity, and neither half would be simpler.

The 2D route out

Sprites and a 2D camera came later, and cost almost nothing, which is a sign the underlying model was right.

An orthographic projection was already there for cameras. A quad primitive was already a builtin mesh. So a 2D sprite is a MeshRenderer on a quad with transparent: true and castShadow: false, paired with an orthographic camera, and the Add menu makes that one click.

No new GPU pass, no 2D renderer, no separate scene graph. A 2D game in Awaken is a 3D scene viewed flat, which means it gets shaders, particles, physics and everything else without any of them being ported.

That is the payoff of a small component model. The features that cost nothing to add are the ones you did not have to design a second system for.

← All posts