Luduvo Contents
Theme
On this page

UI Layout and Text

A screen of health bars, hotbar slots and chat lines is almost no geometry, and most of the work is bookkeeping. For every visible element, on every frame, the client resolves the rectangle’s screen position, whether an ancestor clips it, whether the pointer is inside it, and which atlas supplies its text, before it hands a single quad to the GPU.

This page is about how the client stores that state and how it walks it. It covers why the UI lives in the same component tables as the 3D world, what the flattened columns look like in memory, what each frame pass reads and writes, and how a glyph gets from a font outline to a crisp edge at any size.

The components themselves, their properties and the Lua that drives them are in the guide, User Interface. Nothing here changes how you write UI; it explains what happens after you do.

Why columns instead of widgets#

The conventional shape is an object tree. Widget toolkits and the HTML DOM give every screen element its own heap allocation, its own virtual table, and its own array of child pointers. An element is an object, and the tree is those objects pointing at each other.

That shape is simple to write against and expensive to traverse. Layout visits the whole tree once per frame, and every step dereferences a pointer to an address chosen by a separate allocation. Each jump is a chance to miss the CPU cache, and a miss costs far more than the arithmetic we came to do, which is a multiply and an add.

Luduvo keeps user interface state in the same Entity Component System tables that hold the 3D world. An element is an integer entity id, and its state is partitioned into contiguous component arrays. There is no UI object to dereference: a pass that operates on positions streams the position array and follows no per-element pointer.

This contiguous memory layout also allows zero-overhead serialization: UI state replicates with no heap serialization, like a gameplay component such as Position or Health. A frame row and a rigid body row replicate through the same code path.

For a script author this is why a button is an entity with components on it, and why the same API that moves a part moves a panel.

The columns#

Everything that turns component rows into pixels runs on the client. The server replicates rows; it never resolves a rectangle.

The rebuild pass walks the ECS Parent chains from each root overlay entity (UIRoot) and flattens every visible UI entity into parallel Structure of Arrays columns. The split is not by concept, it is by which pass touches which field:

C
Rect     resolved_rects[8192];Rect     clip_rects[8192];uint16_t parent_indices[8192];uint8_t  depths[8192];uint8_t  flags[8192];uint32_t entity_ids[8192];

The column widths determine the data density and optimal cache utilization. Assuming the arrays are 64-byte aligned, a single cache line fits exactly four contiguous 16-byte resolved rects without straddling boundaries. A parent index is two bytes, meaning one cache line packs 32 parent indices. Node depth (0 to 63) and flag bits (hidden, clips, and visual component presence) consume one byte each per node. Finally, entity_ids uses four bytes per element to map back to the originating ECS entity.

The two rect columns are split by that same rule. Layout only ever writes resolved_rects; scissor writes clip_rects and draw reads it, and no other pass touches that column. Had we packed a node as one 32-byte struct holding both rectangles, every 64-byte line the layout pass fetched would have carried 32 bytes of clipping data it does not read. Kept in separate arrays, and again assuming each array starts on a 64-byte boundary, the lines layout fetches hold resolved rects only; clip data reaches L1 when a later pass dereferences it.

Flattening order determines the access pattern. A child is always written after its parent, so a parent’s index is always lower than its child’s, and every read of a parent’s rect dereferences backwards into memory the same pass has already written this frame. The pass itself advances through the columns in one monotonically increasing stream, the pattern a hardware stride prefetcher can follow; whether a given parent rect is still resident when the child reads it depends on how far apart the two indices fall.

The columns are fixed length, and that is where the limits in the guide come from: 8,192 nodes across all overlays, of which 68 are reserved for engine core menus, leaving 8,124 for place UI, and a nesting depth capped at 64 levels, which the one-byte depth field encodes as 0 to 63. A subtree that exceeds either limit is dropped, and the client logs ui sync cut N subtrees.

The frame passes#

With the columns in place, a frame is a run of linear walks over them. Each pass completes a column before the next pass reads it, so no pass revisits a node. Each pass reads and writes a fixed set of columns:

1 Hierarchy flatteningWrites parents, depths, flags2 Sequential layoutWrites resolved rects3 Scissor and hit-testReads rects, writes clips4 Batch draw generationReads both rect columns

Hierarchy flattening is the walk described above: it visits the Parent chains once and fills parent_indices, depths and flags.

Sequential layout resolves relative positions and sizes and writes the result into resolved_rects. This is the pass that reads scale and offset numbers and turns them into pixels, and the flattening order reduces it to a single forward sweep over the columns with no tree traversal in it.

Scissor and hit-test read resolved_rects and write clip_rects, propagating a clipping boundary down from each element that clips its descendants, and testing the pointer against contiguous slices of rectangles rather than against a tree.

Batch draw generation is the only pass that reads both columns of 16-byte rects, and it writes UI quad vertices into GPU memory in the same sequential order.

What an author observes here is ordering: a parent’s size is always resolved before the children that depend on it, and a clip set on an ancestor is already in effect by the time its descendants are drawn.

Text: interning and styles#

Text is the one part of a UI element whose length varies per element, so the fixed-width columns cannot store it inline. The engine splits it in two.

UITextSource stores the string as an interned 32-bit TextId. Every distinct string is stored once in a global intern table, so a hundred labels reading "Ready" or "Health" each hold the same four-byte id and reference one copy of the characters, and the component column stays as narrow and as regular as any other. Studio and scripts set Text as an ordinary string and the engine interns it on write; TextId itself is read-only in the property panel.

UITextStyle holds appearance: font size, colour, stroke width, stroke colour, horizontal and vertical alignment, and bold, in a 44-byte struct that stores no string data and no pointer to any. Two labels with the same look and different words hold distinct TextId values and duplicate the 44 bytes; two labels with the same words and different looks hold the same TextId and one style struct each.

UITextInput is the same idea applied to an editable field. It stores only what the author set: the placeholder as an interned id, a maximum character length, the cursor colour, and the password masking flag. Caret position, active selection and focus are transient client state, and they are never serialized and never replicated.

So assigning Text a string the intern table already holds adds no entry to it, though every write still hashes the string to find the existing id. Caret, selection and focus stay on the client, so typing in a field contributes no replication traffic by itself.

Text: MSDF rendering#

MSDF rendering preserves sharp corners across scales, from 12 pixels to 512, out of one texture.

The obvious approach is a bitmap font, a pre-rendered image of each glyph. It blurs when scaled up and pixelates when scaled down, and staying sharp means baking a separate atlas for every size we intend to use.

The next approach is a signed distance field. Each texel stores one scalar: the distance to the nearest glyph outline, negative outside and positive inside. The GPU interpolates distances bilinearly instead of colours, so the reconstructed edge stays smooth under magnification, the field between texels being piecewise linear rather than exact. It has one geometric flaw: it rounds sharp corners. A single scalar field cannot encode two edges meeting at a point, so the apex of an ‘A’ or the corners of an ‘M’ and a ‘Z’ come out as small fillets.

A multi-channel signed distance field addresses that flaw. MSDF decomposes the glyph outline into separate continuous edge contours and encodes their signed distances into the red, green and blue channels of a single RGB texture. Along a flat or gently curving edge all three channels store the same distance, up to texel quantization. At a sharp corner, the two intersecting edges are assigned to different channels, so the corner is preserved as a difference between channels rather than being averaged away.

The shader recovers one distance from the three with a median:

GLSL
vec3 msd = texture(msdf_atlas, tex_coord).rgb;float sd = max(min(msd.r, msd.g), min(max(msd.r, msd.g), msd.b));

The median takes the middle of the three channels rather than their average, so the outlying channel is discarded instead of blended in. Where all three agree it returns that shared distance. At a corner they disagree, and the middle value follows the intersection of the two contours instead of rounding it off. The field stays continuous along edges and stays sharp at points.

A distance is not yet a coverage value. To decide how much of the pixel the glyph covers, the shader converts the recovered distance from texture space into screen pixels using the GPU’s screen-space derivatives, which approximate how far the texture coordinate moves across one screen pixel:

GLSL
vec2 unit_range = vec2(glyph_px_range) / vec2(textureSize(msdf_atlas, 0));vec2 screen_tex_size = vec2(1.0) / fwidth(tex_coord);float screen_px_distance = (sd - 0.5) * dot(unit_range, screen_tex_size) * 0.5;float fill_opacity = clamp(screen_px_distance + 0.5, 0.0, 1.0);

fwidth(tex_coord) is the sum of the absolute screen-space partial derivatives, a finite difference the GPU evaluates once per 2 by 2 fragment quad and holds constant within it. Its reciprocal is therefore screen pixels per unit of texture coordinate, and unit_range expresses the atlas distance range in those same units, so the dot product sums the range measured in screen pixels along both axes and the 0.5 averages the two.

Multiplying the distance, re-centred on 0.5, by that number puts it in screen pixels, and adding 0.5 before the clamp gives a ramp one screen pixel wide across the outline: fully inside is 1, fully outside is 0, and edge fragments take the intermediate fraction as an approximation of coverage. One atlas therefore anti-aliases itself across the 12 to 512 pixel range, without a mip chain per font size, assuming the mapped range stays at least one screen pixel wide; below that the ramp is narrower than a pixel and the edge steps.

Outlines come from the same field. Because the distance already encodes how far a pixel is from the edge, a stroke requires no second pass and no extra geometry, only a second threshold in the same fragment shader, assuming the band stays inside the padding of the glyph quad already being drawn:

  • a fragment where sd >= 0.5 is inside the glyph and takes the fill colour (TextColor3);
  • a fragment where sd lies between 0.5 - stroke_width and 0.5 is outside the fill edge, in the outline band, and takes the stroke colour (TextStrokeColor3); stroke_width is in the same normalized distance units as sd, not in screen pixels, so TextStrokeWidth has to be scaled into those units before the comparison;
  • below 0.5 - stroke_width the fragment contributes nothing: it is discarded or written with zero alpha.

Because this evaluates in the shader, raising TextStrokeWidth costs a comparison, not a draw call.

What an author sees#

A script cannot touch the columns, but their limits are observable.

  • A UI element is an entity, so the scripting API, replication and the property panel are the ones you already use for the world.
  • Two ceilings follow from the fixed-length columns and the one-byte depth field: 8,124 nodes for place UI, and 64 levels of nesting, encoded as 0 to 63. Cross either and the subtree does not appear and ui sync cut N subtrees shows up in the log.
  • Setting Text to a string the place has used before adds no entry to the intern table; TextId is what you see in the property panel, and it is read-only.
  • TextSize stays crisp across the atlas range, 12 pixels to 512, and a text stroke adds no draw call.
  • Caret, selection and focus never leave the client, so a server script cannot observe half-typed input.

For the components, properties and events behind all of this, see User Interface.