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