Log inGet started

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 saysWhat it means
the label is not in objects at allnothing created a text object — the component never got to text.create
width or height is 0the content shaped to nothing; check the string and fontSize
raster.textureGuid is absentit was laid out but never rasterised, so no texture carries it
raster.resident is falsethe raster's texture is not on the device
dirty is truethe layout has moved since the last rasterisation, so the picture on screen is the one before the change
owner.alive is falsethe 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:

ReasonMeaning
resolvedthe face the shaper used IS the family requested
noFamilyRequestedthe style named no family, so the shaper took the default
notShapedthe object produced no glyphs, so there is no face either way
familyUnknownthe requested family is in no registry entry, so there was nothing to match
familyNotSelectablethe family IS registered, and the shaper still answered with a different face
familyCoveredNoGlyphthe 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:

labelscount()face(h)orphans()observe()
100.00060.220.5028.7
500.00050.181.2125.1
2000.00050.204.9431.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 — what the renderer holds and what it costs