Log inGet started

Quality tiers, render target cost and loaded content

Updated October 16, 2026

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.

  • documentation
  • guide
  • rendering