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:
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:
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:
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:
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.5is inside the glyph and takes the fill colour (TextColor3); - a fragment where
sdlies between0.5 - stroke_widthand0.5is outside the fill edge, in the outline band, and takes the stroke colour (TextStrokeColor3);stroke_widthis in the same normalized distance units assd, not in screen pixels, soTextStrokeWidthhas to be scaled into those units before the comparison; - below
0.5 - stroke_widththe 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 subtreesshows up in the log. - Setting
Textto a string the place has used before adds no entry to the intern table;TextIdis what you see in the property panel, and it is read-only. TextSizestays 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.