---
title: "Quality tiers, render target cost and loaded content"
description: "Part of the rendering guide (guides { path = \"topics/rendering\" }), which lists every part."
section: "Topics"
slug: "topics-rendering-budgets"
canonical: "https://origozero.ai/docs/topics-rendering-budgets"
updated: "2026-10-16T13:27:29.351550982+00:00"
tags: ["documentation", "guide", "rendering"]
---

# 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.

```lua
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:

```lua
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.

```lua
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.
