Text and labels
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. Text3D builds one per label and draws it on a quad. text.* is…
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
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.
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:
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 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:
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"
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:
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:
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.
Registering a font does not by itself make its name selectable.
font.list() reports registration keys, and whether the shaper selects on a key
is a separate fact — the name the shaper matches on is the one the font FILE
reports, which is often not the asset's name. font.reconcile() is the reading
that holds one against the other:
for _, f in ipairs(font.reconcile()) do
if f.selectable then
print(f.family .. (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:
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:
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:
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:
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:
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.fontasset type and how a font is registeredtopics/ui/styling— fonts on the UI sidetopics/rendering— what the renderer holds and what it costs