---
title: "Text and labels"
description: "Text in a world comes from one place: a text object the engine holds, laid out against a font database and rasterised into a GPU texture."
section: "Topics"
slug: "topics-text"
canonical: "https://origozero.ai/docs/topics-text"
updated: "2026-10-15T11:08:40.318529723+00:00"
---

# Text and labels

This guide covers the parts that are easy to get wrong: what the engine is
holding for you, which font a label is actually in, and where a label that is
not showing has gone.

## The shape of it

```lua
local h = text.create({ content = "Hello", fontSize = 48 })   -- a text object
local tex = renderer.texture.create({ width = 1, height = 1, rgba = string.char(0,0,0,0) })
text.rasterize(h, tex)                                        -- glyphs into that texture
-- bind tex.guid as a material's base_color_texture to show it
text.destroy(h)                                               -- releases the layout
```

A handle is live between `create` and the `destroy` that released it, and
`text.alive(h)` is the answer to whether it still is. `setText`, `setStyle` and
`destroy` each return `true` when the text system held the handle and `false`
when it did not, so a caller whose handle went away learns it from the call
rather than from the label going blank.

## "My label is not showing"

Ask the engine what it is holding.

```lua
local obs = text.observe()
print(obs.count .. " text objects live")
for _, row in ipairs(obs.objects) do
    print(row.handle, row.content, row.width .. "x" .. row.height,
          row.owner.entityId, row.raster.textureGuid)
end
```

Each row is one live text object: its content, the style it was laid out with,
the extent it measures, whether it is `dirty` (changed since its last raster),
the entity whose component created it, and the runtime texture its last raster
landed in with the bytes that texture costs.

Read the row against the thing you expected:

| The row says | What it means |
|---|---|
| the label is not in `objects` at all | nothing created a text object — the component never got to `text.create` |
| `width` or `height` is 0 | the content shaped to nothing; check the string and `fontSize` |
| `raster.textureGuid` is absent | it was laid out but never rasterised, so no texture carries it |
| `raster.resident` is false | the raster's texture is not on the device |
| `dirty` is true | the layout has moved since the last rasterisation, so the picture on screen is the one before the change |
| `owner.alive` is false | the entity that owns this label is gone, and the object is still here |

### "The reading says the text changed and the screen disagrees"

`dirty` is the field that separates those two. A layout is what `setText` and
`setStyle` change; a raster is what `rasterize` writes, and the picture on screen
is the last raster. Between the two, `dirty` is true and the reading and the
pixels are describing different moments — which is not the reading being wrong,
and is exactly the state to look for when a label keeps showing its old string
or its old face:

```lua
for _, row in ipairs(text.observe().objects) do
    if row.dirty then
        print(row.content .. " has changed since it was last drawn")
    end
end
```

A font arriving, leaving, or changing the names it answers to (a `.font`
asset's `face.json` edited, for one) lays out again every object whose face it
decides, so those objects read `dirty` too. `text.isDirty(h)` answers the same
question for one handle; a component that owns a raster draws it again when it
answers true, the way `Text3D` does every frame.

A label that stays `dirty` is one whose re-rasterisation is not happening. On a
`Text3D`, `:refresh()` forces it; removing and re-adding the component rebuilds
the texture outright.

For whether the **quad** reached the frame — a separate question from whether
the text object exists — the renderer's own object list names the entity that
owns each draw, so it answers per object rather than as a total:

```lua
for _, o in ipairs(__rendering.renderObjects()) do
    if o.owner_entity_id == id then print("drawing", o.mesh_guid, o.visible) end
end
```

The frame's draw COUNT is not that answer: the renderer batches once a scene is
big enough, and the total then moves between `draws` and `compactedDrawn`
without saying which object left.

## "My label is in the wrong font"

This section is about the SHAPER, which serves `text.*` 2D and 3D rendering. A
`fontFamily` on a UI widget is answered by the UI text renderer, which
resolves the same names one registration made selectable: `ui.listFonts()` and
the `gui` toolbox's **fonts** tool enumerate what it holds, including the
host-OS families it has
loaded, and a family it matched nothing for is reported as an
`unknown-font-family` warning through **validate**. `topics/ui/styling`
covers it.

A style's `fontFamily` is a request. The shaper answers it out of the font
database, and the answer is not always the request — a family the database
cannot match still shapes, against whatever the fallback chain reached.

`text.face` is the engine's own answer:

```lua
local face = text.face(h)
-- { requested = "Inter", resolved = "DejaVu Sans", matched = false,
--   reason = "familyUnknown", postScriptName = "DejaVuSans", faces = { … } }
```

`matched` is true when the resolved face IS the family requested. A label whose
style named no family reports `matched = false` with `reason =
"noFamilyRequested"` — it got the default because it asked for nothing, which is
not the same as being in the wrong font, so `reason` is what an alert should
switch on.

`faces` lists every face the shaper used, most glyphs first, so a fallback that
covered part of a string is visible next to the face that covered the rest.

`reason` comes from a closed set, which `text.faceReasons()` enumerates:

| Reason | Meaning |
|---|---|
| `resolved` | the face the shaper used IS the family requested |
| `noFamilyRequested` | the style named no family, so the shaper took the default |
| `notShaped` | the object produced no glyphs, so there is no face either way |
| `familyUnknown` | the requested family is in no registry entry, so there was nothing to match |
| `familyNotSelectable` | the family IS registered, and the shaper still answered with a different face |
| `familyCoveredNoGlyph` | the family is one the shaper's font database holds, and its faces cover none of the glyphs asked for — `font.reconcile()` reports it, having laid the family out under its own weights and over several scripts |

The last two are the actionable ones:

```lua
for _, row in ipairs(text.observe().objects) do
    local r = row.face.reason
    if r == "familyUnknown" or r == "familyNotSelectable" then
        print(row.content .. " asked for " .. row.face.requested
              .. " and shaped with " .. row.face.resolved .. " (" .. r .. ")")
    end
end
```

`familyUnknown` means the font never got registered — check that the `.font` asset exists and its `onRegister` ran.
`familyNotSelectable` means it did get registered and this label did not get it;
the row's `resolved` names what you got instead. Which of the two reasons a
family is under — a name the shaper does not select on, or a name it does select
on whose faces did not cover this label — is what `font.reconcile()` answers, by
laying the family out under its own weights and over several scripts.
`familyCoveredNoGlyph` is the second of those: the name is one the shaper picks
and the family's faces have no glyph for what you asked it to draw — an emoji
face asked for Latin, say. Draw content the family covers, or use a family that
covers this content.

**A registered face answers to every name it was registered under.** One
registration hands the shaper the name the `.font` asset carries, the family
group it stands in, the web-font and generic names its descriptor declares, and
the family the font FILE declares; each of them is shaped as a request for that
last one, which is the name the database files the face under. So every name
`font.list()` reports selects the face it registered, and `font.reconcile()` is
the reading that says which family each name selects and what it takes to
arrive:

```lua
for _, f in ipairs(font.reconcile()) do
    if f.selectable then
        print(f.family .. " selects " .. f.shapedWith
              .. (f.weight and (" at weight " .. f.weight) or ""))
    else
        print(f.family .. " is a name the shaper does not pick; it shapes as " .. f.shapedWith)
    end
end
```

**`selectable` says the family is reachable; `matched` says naming it is
enough.** A style reaches a family by name, weight and coverage together, so a
row is selectable when SOME style naming the family lands on it — and `matched`
is the narrower fact that `fontFamily = family`, carrying nothing else, is
already that family over Latin text. The two split exactly where a family needs
help: `f.selectable and not f.matched` is the set whose styles need the row's
`weight`, or content the family's faces cover, to arrive:

```lua
for _, f in ipairs(font.reconcile()) do
    if f.matched then
        text.create({ content = "Sale", fontFamily = f.family })
    elseif f.weight ~= nil then
        text.create({ content = "Sale", fontFamily = f.family, weight = f.weight })
    end
end
```

A selectable row naming no `weight` is the other half: its faces answer for the
glyphs they have, so it arrives by drawing content they cover.

**A family is selected by name and weight together.** A family whose only face
is heavy resolves under that weight and falls back under every other one, so a
row's `weight` names the weight its family needs when the default is not it —
pass it as the style's `weight` alongside `fontFamily`:

```lua
text.create({ content = "Sale", fontFamily = f.family, weight = f.weight })
```

`weights` on the same row lists the numeric weights the family's faces carry.

**A family is also selected by what its faces cover.** A face answers for the
glyphs it has and a face with wider coverage takes the rest, so a family whose
faces carry no Latin — an emoji or a symbol face — would read as a name the
shaper does not pick if Latin were the whole probe. A family the shaper's
database holds is asked again over content from other scripts, and one that
answers for any of it is `selectable`. A family that answers for none of it
reads `familyCoveredNoGlyph`, which says the name is selectable and this content
is not what its faces cover.

Each family is laid out once per weight and content tried and every probe object
destroyed again, so the live count is where it was.

## "Something is still drawing after I deleted it"

A label belongs to the entity whose component created it. When that entity is
despawned and the text object is still held, the object is an **orphan** — and
it is a row rather than pixels:

```lua
for _, row in ipairs(text.orphans()) do
    print(row.content .. " belongs to " .. row.owner.entityId .. ", which is gone")
end
```

This is the reading an entity walk cannot produce: a walk sees live entities, and
an orphan's owner is not one. A component that takes a handle in `awake` releases
it in `onDestroy`; one that does not leaves a row here.

An object created outside any component call belongs to no entity, and reports
`owner.owned = false` rather than appearing as an orphan.

## What text costs

Glyph rasters are runtime GPU textures, so they sit inside the figure
`renderer.gpuMemory().textures` reports. `text.rasterMemory()` takes them out of
it:

```lua
local mem = text.rasterMemory()
print(mem.bytes .. " of " .. mem.poolBytes .. " bytes of " .. mem.pool)
-- 122680 of 20858684 bytes of runtime GPU textures
```

`bytes` is summed off the same texture map that pool total is summed from, so
`shareOfPool` is a share of that number rather than a second count of the same
memory. `textures` is how many textures carry a raster — a texture several text
objects rasterise into is one of them, counted once, the way the renderer holds
it.

## Reading it from outside Luau

`/zero/runtime/text` serves the inventory the readings above are built from, and
`count`, `objects`, `fonts` and `registrations` are readable on their own:

```lua
vfs.read("/zero/runtime/text/count")
```

It is the engine's own reading, serialised once, so a field that is in both the
node and the namespace carries the same value in each. An object row there is
the inventory before the joins: `ownerEntityId`, `textureGuid`, `rasterWidth` /
`rasterHeight`, `requestedFamily` and `faces` — the raw columns.

The joins are what `text.observe()` adds on top of them, in Luau, out of
surfaces the text system has no access to: `owner.alive` (from `entity.exists`),
`raster.bytes` and `raster.resident` (from `renderer.texture.list()`),
`face.reason`, and the `orphans` list those two produce together. Reading the
node alone, an owner's liveness is `entity.exists(row.ownerEntityId)` and a
raster's bytes are the texture map's figure for `row.textureGuid`.

## What reading it costs

Nothing here runs per frame — every reading is built when you ask, which is what
makes it as current in edit mode as in play and what keeps an engine nobody is
observing from paying for it.

Measured on one engine at three scene sizes, in milliseconds per call:

| labels | `count()` | `face(h)` | `orphans()` | `observe()` |
|---|---|---|---|---|
| 10 | 0.0006 | 0.22 | 0.50 | 28.7 |
| 50 | 0.0005 | 0.18 | 1.21 | 25.1 |
| 200 | 0.0005 | 0.20 | 4.94 | 31.1 |

`count()` and `alive(h)` are single reads off the text system and do not move
with the scene. `face(h)` reads one label's row, so it does not either.
`orphans()` walks every text object, which is where it grows.

`observe()` carries about 25 ms that is not the walk and does not follow the
label count: it joins `renderer.texture.list()` to fill in each row's
`raster.bytes`, and that listing is the whole of the fixed cost. `rasterMemory()`
joins the same listing and carries the same fixed cost. When the bytes are not
what you are after, `count()`, `face(h)` and `orphans()` answer without it.

`font.reconcile()` lays a probe object out per family, and again per weight and
per probe content for a family the first one does not reach, so it follows the
number of names rather than the number of labels: about 36 ms on an engine
carrying 79 of them.

## See also

- `types/font` — the `.font` asset type and how a font is registered
- `topics/ui/styling` — fonts on the UI side
- `topics/rendering-budgets`: what the renderer holds and what it costs
