GG-UI — Rendering
How a published document reaches the screen: paint, the primitive format, the GPU pass, text, images, and the engine host.
From Snapshot to Pixels
UI update (on jobs) Encoder ─────────────────── ─────── Update re-run what changed UpdateBindings UpdateLayout Paint nodes → primitives, glyphs → atlas read latest snapshot Publish snapshot + primitives + atlas edits ──► upload changed ranges one draw per window, after the scene
- Published, not shared. Encoder reads a published snapshot and never touches rows the UI update is writing: the same contract as the published sim frame.
- Only what changed. An idle document paints nothing, publishes nothing, and uploads nothing.
- The GPU does the per-pixel work. Rounded corners, borders, and clipping are evaluated in the fragment shader from a few numbers per primitive. No CPU tessellation, no scissor changes between draws.
The UI Primitive
Every UI draw is one primitive type: an instanced quad. Screen UI writes primitives on the CPU; world UI and sprites will write the same structure from compute, which is why the format is fixed.
| Field | Size | Meaning |
|---|---|---|
| Rect | 4 × float | Position and size in document space (UI units) |
| UV rect | 4 × uint16 | Texture region, in 65536ths so every texel edge of a power-of-two texture is exact |
| Colour | 4 × uint8 | Fill or tint: sRGB, straight alpha, inherited opacity already applied |
| Border colour | 4 × uint8 | |
| Corner radius | 4 × uint8 | Per corner, in half UI units |
| Border width | uint8 | In quarter UI units |
| Kind | uint8 | Solid, glyph (coverage), glyph (signed distance, reserved), image, shadow, line, triangle, arc |
| Clip | uint16 | Index into the surface's clip table; 0 = unclipped |
| Texture | uint16 | Index into the engine texture table |
| Parameter | float | Kind-specific: a shadow's blur, an image's greyscale (0..1), an arc's thickness |
48 bytes (UIPrimitive, Paint.h).
A line or a triangle uses the rect for points rather than a box: a line runs from (X, Y) to (Width, Height), Parameter wide, with round ends and one pixel of anti-aliasing; a triangle has corners (X, Y), (Width, Height), and a third in Parameter and the first UV pair. Triangles aren't anti-aliased. An arc is part of a ring in the rect's inscribed circle: U0 is where it starts and U1 how far it reaches, in 65535ths of a turn clockwise from the top, and Parameter the ring's thickness (0 fills to the centre, a pie); its edges are anti-aliased across radius and angle. Radial progress fills and shape arcs are arcs.
- Draw order is a separate list. Primitives are in slots; a list says which slot is drawn when, back to front, blended in linear light. No depth buffer, no CPU sorting.
- Document space, not pixels. The vertex shader applies the surface's transform, so the same primitives serve any surface.
- No vertex buffers. The vertex shader builds each quad from the vertex index and reads the primitive through Buffer Device Address.
Clipping
A primitive names a clip by index. The clip table has one entry per clipping or transformed box (UIClip, 48 bytes): a rect in document space, and a view applied to everything drawn under it: M * position - move, with M a 2 x 2 matrix. The scrolls, zooms, and group transforms of every enclosing box are composed into it on the CPU.
- A whole document is one draw call whatever it clips.
- Scrolling writes a clip entry and no primitive. Content of a scrolling box is painted at its unscrolled position under an entry that carries the scroll of the box and its ancestors. What stays in view isn't repainted or re-uploaded.
- A group transform writes a clip entry and no primitive. An element with a transform (
BoxProps::Transform,ui.Rotation,ui.Scale, an animation track) is drawn, with its content, under an entry of its own holding it, about its pivot. Turning or scaling it each frame rewrites that entry and nothing else: no row, no primitive, no layout. Hit testing takes the point into the element's frame before testing it. - Nested clips are intersected on the CPU, so the shader tests one rect. Under a turn, a clip is the on-screen bounds of the box that clips.
Paint
| Node | Primitives |
|---|---|
| Box, component, root | Background and border if its style has them |
| Text, button label | One per glyph, from the node's shaped run |
| Progress bar, toggle, slider | Track, fill, knob |
| Image | One |
- A node keeps its slots. A visible node has a run of primitive slots (a power of two) that it keeps between snapshots (
SnapshotRun). A label whose text grows rewrites its own slots and moves nothing else. Unused slots draw nothing. A node that needs more room gets another run; one that stops showing frees its run. A clipping node keeps its clip entry the same way. - The order list (
GGSnapshot::Order) changes only when visibility, order, or a node's run changes. Snapshots share it, and the slot chunks, the way they share rows. - Overlays (scroll bars, caret and selection, tool marks, popups shown as layers) belong to no node and are rebuilt each publish (
GGSnapshot::Overlay). - Store forwarding. Primitives produced in bulk are written in the same 16 / 32-byte chunks they are later copied in. Writing them a field at a time made the copy stall on the store buffer and cost a quarter of painting a document of boxes.
| Since the previous snapshot | Paint visits |
|---|---|
| Some rows written, visibility and order unchanged | Those nodes, in place |
| Visibility, child order, or a node's slot count changed; a box scrolled | Everything visible, in draw order. Unchanged nodes keep their slots |
| Styles, scale, or the glyph atlas changed | Everything visible, repainted |
Measured: one label changed with everything else unchanged, 0.0012 ms with 10,000 primitives showing and 0.0017 ms with 40,000 (GGUI_Scale_AFullScreen). Painting 10,000 primitives from scratch, 0.20 ms (GGUI_Paint_TenThousandPrimitives). A 100,000-node list scrolled slightly with 200 showing publishes in 0.015 ms: no row written, seven nodes painted (GGUI_Scale_Scrolling).
Deltas
Every publish is a complete snapshot, so a reader never sees half of one. Each snapshot also records how it differs from the previous one: up to eight slot ranges (more are merged across the smallest gaps) and whether the clip table changed. It carries the last four such records, because each frame in flight has its own GPU buffer and each is a different number of publishes behind. The pass brings a buffer up to date by copying those ranges, and copies everything when the records don't reach back far enough, the buffer grew, or glyph pages moved to other textures.
Snapshots carry their document's identity. A buffer holding a different document is rewritten rather than patched: sequence numbers are per document, and a stale buffer once looked "one publish behind" a new one.
The UI Pass
UIPass (renderer) draws a document as one instanced draw. ui.vert.slang builds each quad; ui.frag.slang evaluates clip, rounded box, border, texture, and colour. Per window, per frame in flight, there is a primitive buffer (by slot, overlays after), an order buffer, and a clip buffer.
- The main window's document is drawn after the scene. Something else can be drawn between its nodes and its overlay (
UIPass::). Every other window with a document is cleared and shows it.Part - Blending is in linear light with an sRGB target. Coverage text gets a small gamma correction so thin strokes don't look light.
Not yet: a closed window's buffers are kept until shutdown.
The Engine Texture Table
TextureTable (renderer): the engine's bindless sampled-image table, with the UI as its first user. Materials will use the same table.
- One descriptor array of 4,096 sampled images, partially bound, updated between frames. Four samplers (linear and nearest, clamped and repeating).
- A texture is an index. Indices are recycled after the frames in flight have drained.
- Entry 0 is a white pixel, so a solid primitive is an image primitive with nothing special about it.
TextureTable::writes a rect in place: glyph pages are uploaded by changed rect.Update - Textures need host image copy, which the engine already requires.
Text
GGUIText is its own library: TextEngine puts FreeType and HarfBuzz behind the core's text interfaces. It needs no engine and no GPU, so it builds in every configuration and its tests run headless.
| Stage | Library | Notes |
|---|---|---|
| Font files and glyph rendering | FreeType | |
| Shaping | HarfBuzz | Ligatures, kerning, combining marks, complex scripts |
| Bidirectional text | SheenBidi | Per paragraph; direction from the first strong character |
| Line breaking | Built in | After spaces, hyphens, dashes, and zero-width spaces; between ideographs and kana; at line feeds |
| Fallback | A font chain | The first font that has the character is used for that run |
- Shaping happens once. Measuring shapes the text, breaks lines, and caches the result as a run. Paint draws from the same run. Runs are shared between nodes with the same text, font, size, and width, and are reference counted.
- Fallback: spaces, punctuation, digits, and combining marks stay in the font of the text before them, so a mark is never split from its letter. Line height and baseline are the style font's. The engine adds Noto Sans Hebrew and Noto Sans Arabic.
- Atlas: glyphs are rendered hinted at the pixel size they are shown at, on first use, into 1024 × 1024 one-byte pages, and sit on whole pixels. A glyph primitive names an atlas page, not a texture; the pass fills in the texture index as it uploads, which keeps the core and the text library free of the renderer.
- Scale: hinted glyphs differ by pixel size, so a UI scale change re-shapes text.
- Fonts are chosen by style class: the default UI font,
heading, andmonocome from the engine's font directory (Manrope, Space Grotesk, JetBrains Mono). - Determinism: text metrics enter fixed-point layout at one boundary and are converted there. The fonts are pinned in the build, so the same text gives the same metrics everywhere.
- Rich text (
TextRequest::Spans): parts of a paragraph in their own font and size, with padding before and after (a key cap), and inline boxes (an image: one U+FFFC, as wide as its aspect at its line's height). The paragraph is shaped and broken into lines as one; a line is as tall as the tallest font on it. A glyph says which span it is in (GlyphQuad::Span), andGlyphSource::Piecesgives each span's rect on each line, for its background and image. Spans are part of the run's identity, so differently styled text is another run. TextEngine::Inspectlists a run's glyphs as drawn;Stats::FallbackGlyphscounts fallback glyphs.
Not yet: the full Unicode line-breaking algorithm (Thai and other scripts without spaces need a dictionary); vertical text; a font for ideographs (they draw as the missing glyph); signed-distance text for world space (the primitive kind is reserved); kerning between runs of different fonts.
Images
ui.Image(handle, props); ui.Image("ui/coin.png", props); // looked up when built; a missing file warns once with file and line ui.Viewport(target, props); // a render target sized by layout
- An image is an
ImageID. The core describes one through anImageSource(texture, region, pixel size) and never sees files or GPU objects. The source has a revision; documents re-read their images only when it moves. - Size: unset, an image is its pixel size at 100 %, scaled with the UI. With one axis set, the other keeps the aspect ratio. Its bottom sits on the text baseline.
- Fit (
Contain,Cover,Fill,None) places it in a box of another shape, also within an atlas region. Props add a tint, corner radius, and an accessible name (none: decorative). - Render targets:
ui.Image(target)shows it at its own resolution.ui.Viewport(target)takes the size layout gives it: after layout each viewport's pixel size is published (UIContext::Viewports) and the owner resizes to it. Until it does, the old image is stretched. UIImages(renderer) is the engine's source: files (PNG, BMP) loaded on first use from the image roots, images from pixels, regions, and targets. Files are RGBA8 sRGB.
ui.Embed(id) is an area something else draws: the document draws nothing there and publishes where it is (GGSnapshot::Embeds, clipped, in UI units). The pointer over it isn't the document's, whatever is under it, unless a popup is over it or the document holds the pointer from a press.
Not yet: the scene shown live in a document outside the editor (the renderer draws straight to the swapchain there, so there is no scene target); images chosen by a binding; removing an atlas doesn't remove its regions.
Canvas
ui.Canvas (UICanvas.h) is drawing in an element: lines, polylines, arcs, filled circles and triangles, in the canvas's own units, for gizmos, curves, and node wires.
if (auto canvas = ui.Canvas({ .Width = GGSize::Grow(), .Height = GGSize::Grow() }, [=](GGCanvas& draw) { draw.Line({ 0, 0 }, { draw.Width(), draw.Height() }, { .Width = 2.0f, .Color = axisX }); })) { ui.OnDrag([](const PointerEvent& e) { ... }); // handlers and children inside it, as for a box }
- The drawing is recorded when the code around it runs, and again when the canvas changes size.
Width()andHeight()are the size it was last laid out at. - It is the canvas node's own paint: drawn in the node's place in the order, under its children, clipped to it. The snapshot keeps it beside the node's row, in the entry an image would use.
Not yet: anti-aliased triangle edges; dashed lines; text in a drawing (use children).
The Engine Host
UIHost owns the documents the engine shows, one per window, and runs them. Threading is described at the top of UIHost.h.
- The update (re-run, bindings, layout, paint, publish) runs once per display frame, started by Encoder at the start of its frame. The snapshot drawn is the one built for that frame, so UI adds no frame of latency. A heavy update lengthens the render frame, never the sim's.
- Unrelated documents update in parallel on jobs. Related ones (a popup and its owner, a tool and the document it inspects, a dock space's windows) update serially.
UpdateAllreturns on time. A document that overruns the budget keeps updating on its job and is drawn from its last snapshot until it finishes.- Input arrives on Sentinel, which decides from the last published snapshot whether the UI takes it and queues what it takes. Neither thread waits on the other.
- Bindings read the published sim frame through a reader the host supplies. The default reads
EngineStats. - Headless builds have the core and no host.
Stats Overlay
StatsOverlay (--stats) is a document in the main window's corner. Every number is a binding to EngineStats (one relaxed atomic each, written by the thread that knows it): thread rates, Brain's step time and debt to real time, rollbacks per second, frame time, frame age, measured input-to-photon latency, UI primitive count, and injected load. It is built once; a changed number repaints one label.
Brain's rate is counted at the fixed step against real time. An older counter divided steps by simulated time, which read the nominal rate however far behind the sim was.
Testing
| Layer | What it checks | Needs a GPU |
|---|---|---|
| Primitive goldens | A document paints to an expected primitive list | No |
| Reference rasterizer | RasterizeReference, a CPU implementation of the primitive shader, renders to an image that tests probe | No |
| Readback | The GPU pass renders offscreen, is read back, and is compared with the reference | Yes |
| Text metrics goldens | Shaping fixed strings (Latin, combining marks, right-to-left, mixed, fallback) gives recorded glyph positions | No |
| Incremental paint | After each of a series of changes, the snapshot is byte for byte what painting from scratch gives | No |
The reference rasterizer samples the atlas and images the way the GPU's sampler does, so text and images are part of the pixel comparison. --ui-test mounts a check document; RequestUICapture reads it back.
Performance
| Measure | Target | Result |
|---|---|---|
| Idle document | No paint, publish, or upload | None; readback tests count uploads over 30 idle frames |
| Draw calls | One per window | One per window |
| A changed bound value | One node repainted, one range uploaded | One node; two primitives copied per frame in flight, in a document of 252 |
| Painting 10,000 primitives from scratch | Under 0.5 ms | 0.20 ms optimised (2.8 ms unoptimised) |
| Shaping | Never in a frame that changes nothing | Runs are shared; an unchanged label isn't shaped or walked |
| Glyph atlas | No re-rendering; uploads are changed rects | About 1,200 texels for a new document's glyphs, not a whole page |
| GPU vs reference | No pixel differs by more than 2 of 255 |
Decisions
| Question | Decision |
|---|---|
| Where the UI update runs | Started by Encoder and joined before the UI pass. No added latency; an idle UI costs nothing |
| Whose texture table | The engine's. A UI-private table would be rewritten when materials arrive |
| Line breaking | Simple rules now. Break opportunities come from a function, so the full algorithm can replace it without touching layout |
| Signed-distance text | With world UI. The primitive kind is reserved |
| Colour | Blend in linear light: correct gradients and translucency |
Open Questions
- Whether paint is split by subtree when a document is large.
- Rounded clips (a scrolling box with rounded corners).
- Sub-pixel positioning for hinted glyphs: a few horizontal offsets per glyph cost atlas space and give evener spacing.
- Atlas eviction when a document uses many sizes.