---
title: "Shadows: budgets, casters and what each light costs"
description: "Part of the rendering guide (guides { path = \"topics/rendering\" }), which lists every part."
section: "Topics"
slug: "topics-rendering-shadows"
canonical: "https://origozero.ai/docs/topics-rendering-shadows"
updated: "2026-10-16T13:27:29.458832592+00:00"
tags: ["documentation", "guide", "rendering"]
---

# Shadows: budgets, casters and what each light costs

The shadow pipeline switches and caster thresholds are `[render]` settings in the world's `.world_settings`, listed in `guides { path = "topics/rendering-settings" }`.

**Tune distant detail and its memory budget together.** Read
`renderer.shadowConfig()`, then use `renderer.setShadowConfig` to adjust
`detailBias` and `budgetMegabytes`:

```lua
renderer.setShadowConfig({ detailBias = 0, budgetMegabytes = 32 })
```

A lower detail bias asks for finer shadows; each level coarser asks for a
quarter as many pages. The bias ranges from -8 to 8. The budget is a depth
allocation cap in MiB, at least 1. After the renderer has drawn with the new
settings, `renderer.shadowSizing()` reports the effective `detailBias`,
`poolSlots`, `backupSlots`, `depthMegabytes` and `overflowBias`.
Actual allocation can be below the cap. A positive overflow bias means
page pressure made the shadow map coarser. Increase the budget and compare
repeated camera turns, checking `renderer.shadowPageStats()` for pending
and starved pages as well as pages drawn. Changing either setting resizes
the pool and draws its pages again; subsequent cached frames measure the
steady cost. `distance` caps the sun's shadow reach in world units.

```lua
renderer.setShadowConfig({ detailBias = "default", budgetMegabytes = "default" })
```

This restores the device's detail bias and the quality tier's budget.
Use a scene's load hooks to apply its chosen runtime settings when it loads.
The API manuals `man renderer.setShadowConfig` and
`man renderer.shadowSizing` describe the full controls and sizing report.

**Every shadow-casting light shares one page pool.** The sun, and every spot,
area and point light with `castsShadows`, casts into a virtual shadow map: a
spot or area light's map is 4096 texels a side, each face of a point light's
cube 2048, both with a chain of coarser mips, cut into 128-texel pages that are
drawn only where a receiver on screen asks for them, at the mip whose texel
matches the receiver's pixel. Every casting light up to the 64 punctual lights a
scene holds keeps its shadow; what the pool holds bounds how many pages are
resident at once, and a page the pool has no room for is sampled from a coarser
mip instead of going unshadowed. `renderer.shadowMemory()` reports what the pool
holds, and `renderer.shadowViews()` what each light's pages did.

**A caster can stop casting before it stops drawing.** Every shadow page a
caster reaches rasterizes its whole triangle count, however little shadow that
ends up covering. The caster cutoff is the two thresholds that say when it is
not worth it, and both are measured against the camera the frame draws from
rather than against each light, so one setting covers every light's pages:

```lua
renderer.shadowCasterCutoff()                              --> { minRadiusPx = 0, maxDistance = 0 }
renderer.setShadowCasterCutoff({ minRadiusPx = 2 })        -- drop casters under 2 px on screen
renderer.setShadowCasterCutoff({ maxDistance = 120 })      -- and casters past 120 units
```

`minRadiusPx` is the on-screen radius a caster must reach: an object the viewer
resolves a couple of pixels of casts a shadow of about that size, so dropping it
removes depth work for something nobody was going to read as a shadow.
`maxDistance` is measured to the near side of the caster's bounding sphere, so a
building keeps casting while any part of it is inside the range while the props
around it stop. Both are 0 — released — by default, which draws the casters the
frame drew before either was set, and setting one leaves the other alone.

The saving reads in `renderer.shadowViews()`, whose rows count the casters each
light's page cull cut off. Casters that stop casting keep drawing in
the camera's own passes, so the scene looks the same everywhere except in the
shadows the cutoff removed — walk the thresholds up until one of those shows.

**A caster can rasterize cheaper geometry than it draws.** A shadow is a
silhouette laid on other surfaces at the resolution of a shadow map, so the
triangles carrying a mesh's close-up detail write depth no reader can resolve. A
shadow proxy is the cheaper stand-in it rasterizes there instead — a decimated
version of the shape, a level of its own LOD chain, or a hand-built hull:

```lua
renderer.setShadowProxy(statueMesh, statueHullMesh)
local p = renderer.shadowProxies()
print(p.triangles, "shadow triangles, from", p.sourceTriangles)
```

The registration is keyed by mesh, so one call covers every instance of it —
entities and GPU-driven populations alike — and a crowd sharing that mesh stays
one draw. The proxy is placed by whatever places the caster, its instance's own
transforms, so it stands where the caster stands. Nothing else in the scene
draws a proxy, so the call is what brings it onto the GPU.
`triangles` and `sourceTriangles` are what the main camera's newest counted run
submitted through proxies and what those same (caster, page) pairs would have
cost from the source meshes, so the pair is the before/after of every
registration and they are equal while nothing is proxied. They count what was
rasterized, so a run that drew no page at all — every one served from the depth
it already held — reads 0 for both.

A caster keeps its own geometry where a stand-in could not be placed or deformed
correctly: its entity is skinned (it rasterizes the post-skinned vertices written
for its own mesh), it blends morph targets (whose deltas describe its own mesh
and are read by vertex id), its proxy would be placed by a different node of its
model than the source mesh is, or the renderer holds no geometry under the
proxy's guid. Each of those is counted as `skinned` / `morphed` /
`nodeMismatch` / `unresolved`, once per caster per frame, so a registration that
is doing nothing says which of the four it is. On a settled scene under shadow
caching no page is drawn and `triangles` reads 0;
`renderer.setShadowCaching(false)` draws every requested page on every frame,
which is how the before/after triangle counts are read.
`renderer.clearShadowProxy(mesh)` puts one caster back on its own geometry, and
the bare call drops every registration.

How coarse a proxy can be is a property of the subject and the shadow map's
resolution, and it is measurable: capture the shadow with the proxy released,
capture it armed, and diff. A stand-in whose silhouette lands inside the map's
own resolution changes nothing a viewer can see while removing most of the
depth work.

**The sun's shadow follows the screen.** The sun casts into a virtual shadow
map: a clipmap of levels around the camera, each covering twice the world of the
one before at the same texel count, cut into pages that are drawn only where a
receiver on screen asks for them. Each receiver asks for the level whose texel
matches the size of its own pixel, so the shadow under the player and the shadow
on the far hills are both resolved at about one texel a pixel, and a frame pays
for the pages its view needs rather than for maps covering the whole view
distance. `renderer.shadowPageStats()` reads what the newest run did: how many
pages were asked for, drawn, still pending, and how many the pool holds.
`capture { pass = "shadowPages" }` colours each pixel by the level it samples.

**One caster can have a finer shadow of its own.** A character standing in the
view gets the texel density every surface at its distance gets. A hero box makes
it more: the caster's world bounds, widened by a padding, inside which every
receiver asks for and samples a level `levelBias` levels finer than its footprint
selects — each level halving the world a shadow texel covers:

```lua
local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
local h = renderer.shadowHero()
while h.frame <= before do
    task.waitDrawnFrames(1)
    h = renderer.shadowHero()
end
print(("%d levels finer over %.1f units"):format(h.levelBias, h.worldExtent))
```

The report is the box of the drawn frame its `frame` names, so it changes only
when the renderer draws another: read straight after `setShadowHero` or
`clearShadowHero`, it describes the registration before the call. `entity` is the
exception, read from the live registration. A reading of a change is one whose
`frame` is past the `time.drawnFrameCount()` taken as the change was made.

Nothing else about the shadow changes: the same casters reach it through the
same filter, so the difference is resolution and only resolution, which is what
makes the before/after diffable. `worldExtent` is the world distance the box
covers along its longest axis, and `min` / `max` its corners. The second argument
is padding, a multiple of the caster's bounds: room for a pose that leaves the
bind-pose box and for the filter that samples just outside a silhouette.

A registered caster that gets no box says why in `decline`: `notFound` (no
renderable of it in the scene), `noBounds`, `degenerate` or
`noDirectionalShadow`. The box is made over the renderables the entity itself
carries, so a subject assembled out of several entities is boxed around whichever
one is registered. `renderer.clearShadowHero()` releases the caster.

**A shadow page that nothing changed is not drawn again.** Every light's pages —
the sun's, and each spot, area and point light's — keep the depth they hold
until something over them changes: their light moves, a caster over them moves
or appears or vanishes, the caster cutoff starts or stops dropping one of them,
a shadow proxy is registered or cleared, the geometry a caster rasterizes is
replaced under its own handle — its own mesh or the proxy standing in for it —
a caster's material changes what it lets light through, a model's animation
moves the nodes its parts are placed by, or a skinned caster changes pose. A
change dirties only the pages under the caster's old and new footprint, so a
caster moving on one side of a point light redraws pages of the faces it reaches
and leaves the others holding what they have. A scene standing still therefore
pays for its shadows on the frames that resolve them and on no frame after. The
one case that cannot be read from the scene is a mesh whose vertices a compute
pass writes — a population's per-instance transforms, or a mesh created straight
from a compute buffer — so the pages under one of those re-render every frame.
A still camera over a still scene runs nothing at all, which
`renderer.shadowPageStats().ran` reads as false.

`renderer.setShadowCaching(false)` invalidates and draws every requested page on
every pass. A shadow suspected of holding an image the scene has moved past is
compared against that: if the two agree, the pages are current and the
difference is elsewhere.

```lua
local s = renderer.shadowCacheStats()
print(s.rendered, "shadow pages drawn on the newest run")
print(s.cached, "kept the depth they already had")
print(s.pointRendered, "of", s.pointRendered + s.pointCached, "point-light pages redrawn")
```

`profiler.gpuFrame()` shows the same thing in milliseconds: the `scene.shadow`
spans are what stop being recorded on a frame that redraws nothing.

**Which light is eating the shadow budget.** The readings above are totals
across every light. `renderer.shadowViews()` is the same run one light at a
time: a row per shadow-casting spot, area and point light, naming the light and
carrying what its pages did.

```lua
local report = renderer.shadowViews()
for _, view in report.views do
    print(("%s %d — light %s: %d pages asked for, %d resident, %d drawn, %d starved"):format(
        view.role, view.index, view.light, view.pagesRequested,
        view.pagesResident, view.pagesRendered, view.pagesStarved))
end
```

`pagesInvalidated` is what the frame's caster changes dirtied for that light,
`pagesPending` what was asked for and left for a later run, and `instances` the
(caster, page) pairs the page cull admitted; `castersTested` /
`castersAdmitted` / `castersCutOff` say what the cull did with the casters it
walked for the light, where `renderer.cullStats()` answers for the main camera
alone. `report.camera` carries the main camera's own share of
`renderer.drawStats().compacted` / `compactedDrawn`.

The counts come back from the GPU a few frames after the run, and `frame` names
the frame they belong to. A light that stops casting leaves `views`. The frame
publishes its lights only while something is reading them: the first call of a
session arms that and the frame after it answers, which the
`renderer.shadowViews()` wrapper waits out for you.
