Quality tiers, render target cost and loaded content
The device's quality tier: phones, desktops and the defaults between them
Every device is assigned a quality tier at boot ("low", "medium" or
"high", from its GPU, memory and display), and every renderer default a
world's settings leave unstated comes from it: the render scale, the point
and spot shadow budgets, the texture budget and the anisotropy. A desktop is "high", the renderer as
it has always run there; a phone renders the scene below a dense display's
resolution with the UI at full resolution. renderer.qualityTier() reads it and
renderer.setQualityTier() chooses another, which is a whole graphics setting
on every platform. The graphics-quality guide
(guides { path = "topics/graphics-quality" }) covers the tiers, their
defaults and how each level of override stands over the one before.
What the render targets cost
renderer.renderTargets() measures every target the renderer owns from the
texture that is allocated: the scene depth, the G-buffer, each shadow map, the
reflection-probe cubes, the transparency buffers, the post-process chain's
ping-pong pair, the game viewport's own set, and the scratch the draws into a
render target have needed — those rows are named camera[<handle>] after the
texture drawn into: depth and motion vectors under any rasterized pass, and the
occlusion, reflection and colour-history channels, the scene copies and the
G-buffer under a camera's scene render. That scratch is built by the draw that
needs it, or taken from a released target of the same size. It is released to
the next target of its size when no live camera names the target and when the
target is destroyed or resized, and freed once sixty frames pass without a
draw, so a target nothing draws into holds its colour texture and nothing else.
Released scratch waits as
released_scratch[<width>x<height>] rows for the next target of that size
(newest first, within 64 MiB, for sixty frames), so a burst of captures, each
into a target of its own, builds its scratch once per size. The colour texture
a destroyed or resized target drew into waits the same way, as a
released_scratch[<width>x<height>].color row, for the next target made at
its size and format, which starts from it cleared to transparent black.
allocations counts the render-target allocations made since the renderer was
built, the colour textures of the live targets included, each on the first frame
whose report holds it. A target taken back at a
size it held before is the same allocation and adds nothing, so two readings
around a piece of work say how many targets that work allocated.
local rt = renderer.renderTargets()
print(("%.1f MiB over %d targets"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
print(t.name, t.resident, t.bytes, t.width .. "x" .. t.height, t.layers)
end
A row marked onDemand exists only while something needs it, and reads
resident = false, bytes = 0 the rest of the time. The weighted-blended
transparency buffers hold nothing at all until a pass accumulates into them —
the clear and composite that bracket the accumulation are skipped for those
frames too, since a composite over a buffer nothing wrote reproduces the image
underneath it. The reflection-probe cube array holds one cube slot until probes
need more of it, so a scene with no probe pays 2.10 MB rather than the 18.87 MB
of eight probe slots plus the sky fallback's. The shadow page pool is held
while any light casts — the sun, or a spot, area or point light — and released
while none does, and a frame drawn with shadows or lighting off has no light
cast. A
camera rendering into a texture brings its own depth, motion vectors, occlusion
and G-buffer with it, and takes them away again with the camera.
So the total moves with what the scene does, and comparing two scenes' totals says what the second one's content costs in render-target memory. The colour image a render-texture camera draws into is the one thing absent from the report: it lives in the shared texture cache and is sampled by guid like any other texture, so the camera is one of its holders rather than its owner.
What the engine holds for content, and why an asset will not load
renderer.renderTargets() above measures the targets the renderer owns.
asset.observe() answers the other question — every texture and mesh the
device is holding for content, with the bytes each costs:
local r = asset.observe()
for _, t in r.textures do print(t.identity or t.key, t.bytes, t.width .. "x" .. t.height) end
for _, m in r.meshes do print(m.identity or m.guid, m.bytes) end
print(r.totals.textureBytes, r.totals.meshBytes, r.totals.cpuCount)
The pools are named because they are different pools. textures and
meshes are the device's; cpu is the set a live script-component context
holds, which is what warms an asset's bytes into memory; offDevice is every
mesh nothing displays, whose GPU buffers were given back. An asset can be in one
and not the others. A row carrying an identity reached the device through that
asset; a row without one is held under a guid no asset claims: a camera's own
render target, a glyph atlas a script built.
An offDevice row says what the engine keeps to put that mesh back on the
device the next time something displays it, and what it costs. held is
"source" for a .mesh asset whose stored file holds exactly the definition
the engine had: the engine keeps only that file's path (source), costs no CPU
memory for the mesh (bytes is 0), and reads the file again on the next bind.
held is "definition" for every other mesh: a mesh renderer.mesh.create
built, an asset mesh edited live away from its file, and the engine's built-in
primitive shapes (@builtin::meshes.cube, sphere, plane, cylinder,
capsule, cone and octahedron), whose definitions the engine generates
rather than reads from their files. The engine keeps that whole CPU
definition, and bytes is what it costs. totals.offDeviceBytes sums them.
for _, m in asset.observe().offDevice do print(m.identity or m.guid, m.held, m.bytes) end
totals carries the aggregates the rows sum to, so the listing reconciles
against renderer.textureMemory() and the meshes category of
renderer.gpuMemory() exactly. renderer.texture.list() and
renderer.mesh.list() report the same resources from the renderer's side, with
the script registry's origin and owner alongside.
renderer.references(kind, guid) answers the other question about one of those
rows — what is still holding it, named consumer by consumer — and
renderer.collect() releases every runtime texture, material, mesh and render
feature that nothing holds, which is also what a root scene load runs once the
new scene stands. The core/resource-model guide states the ownership rule both
of them read. devicePublished and
cpuPublished say whether the engine can answer at all, which reads differently
from an engine answering with nothing resident.
The same reading is served at /zero/runtime/residency for a reader outside
Luau, with textures, meshes, cpu, offDevice and totals readable on
their own.
Compute's own GPU resources are a separate pool again, and the compute
category of renderer.gpuMemory() is what they cost. compute.observe() lists
them one row per resource — every storage and uniform buffer, every 3D texture,
storage target, history pair and sampler — each with its bytes, the shader that
made it and the frame it was created on, summing to that category exactly.
compute.diagnose(key) answers the other half: when a resource key names
nothing, which of the states the inventory distinguishes it is in. Both are
covered at guides { path: "types/computeShader" }, and the reading is served
at /zero/runtime/compute.
Why one asset will not load — which file its type's declared primary
resolved to, whether the bytes decode, and the reason when it cannot be used —
is asset.diagnose(ref), covered in the asset-system guide.