Log inGet started

What drawing costs: culling, materials and crowds

Updated October 15, 2026

These readings answer what a frame spends on the objects it draws, the materials they draw with and the crowds they stand in.

An object can stop drawing before it stops existing. The camera's own version of that threshold is a scene-wide screen-size cutoff: below it, an object is dropped from the frame's draws entirely rather than rasterized into the handful of pixels it covers.

renderer.setMinScreenSize(4)   -- drop anything under 4 px of on-screen radius
renderer.minScreenSize()       --> 4   what the camera draws down to now
renderer.setMinScreenSize(0)   -- released: every object draws however small

It is measured from the object's own bounds against the camera's projection, so one threshold means the same apparent size at any distance or field of view, and it is released (0) by default. Walk it up until something you meant to keep disappears, then back off — a few pixels carry no detail a viewer resolves while still costing a full vertex pass and a submission.

The cutoff is answered on the GPU, alongside the frustum and occlusion tests, before any of an object's clusters are tested. renderer.cullStats().views reports it per view:

local main = renderer.cullStats().views[1]
print(main.instancesCulled.screenSize, "objects under the cutoff")
print(main.clustersDrawn, "clusters drawn")

The counts come back from the GPU a frame or two behind, so they describe a frame that has already finished. Arm the cutoff and watch instancesCulled.screenSize rise and clustersDrawn fall.

Hardware-instanced populations take the same answers through compacted draws whose instance count the GPU decides; renderer.drawStats() reports that pair as compacted (this frame's plan) and compactedDrawn (what the GPU asked for).

What a frame spends on materials follows how many distinct materials it draws with, not how many draws it submits. A material's parameters — its uniform block, its textures, and any storage buffers its shader declares — are bound state, so the batched opaque geometry is gathered into runs: the draws naming one material are submitted next to each other, and the run costs one set of binds however many draws it holds. renderer.drawStats() reports both halves:

local d = renderer.drawStats()
print(d.materialBinds, "material binds issued")
print(d.materialBindsElided, "draws spared the bind")
print(d.materialExtraBinds, d.materialExtraBindsElided)  -- the shader's own storage group

Their sum is how many times a pass reached the bind decision — once per unit of geometry it submitted, at or below draws, since a mesh of several primitives draws once per primitive under one set of binds. materialBinds alone is what the frame paid. A scene of many meshes over a few materials reads a small materialBinds beside a large materialBindsElided; the two converging is a scene whose draws each name a material of their own, which is the shape to look for when a frame costs more than its geometry explains.

Only the batched opaque geometry is gathered into runs. Depth-sorted transparents, skinned meshes and populations carry the same bound state through the same pass but submit in their own order, so they are spared a bind only where a material happens to repeat — a transparent-heavy or population-heavy scene reads a low materialBindsElided for that reason rather than because anything is wrong.

What a transparent crowd costs is how much of its depth order repeats. Blended geometry is submitted farthest-first, because that order is what makes the blend come out right, and a stretch of neighbours in it sharing a mesh, a material, a shader and a pose is submitted as ONE instanced draw over those neighbours — the same members, in the same order, out of a single submission.

renderer.setBlendedBatching(false)   -- a draw per blended renderable
local before = renderer.drawStats().draws
renderer.setBlendedBatching(true)    -- neighbours sharing a draw key together
local after = renderer.drawStats().draws

A run stops wherever a differently-drawn renderable sorts between two of its members, so a crowd interleaved with other transparent geometry batches into several runs rather than one, and a mesh of several primitives keeps a draw per renderable. Both would otherwise move fragments through each other, which is the one thing a back-to-front order exists to prevent. The image is identical either way — the switch is there so a frame suspected of being formed by the batching can be compared against one that is not.

What a skinned crowd costs is its distinct poses, not its head count. A skinned instance is posed by a compute pass that writes its vertices into a shared pool, and instances of one mesh whose joints hold the same pose are posed once, into one slice of that pool that all of them draw from. renderer.skinningStats() reports what the last frame's skinned instances actually cost:

local s = renderer.skinningStats()
print(s.instances, "skinned instances")   --> 21
print(s.poses, "distinct poses")          --> 1    what they cost between them
print(s.dispatches, "skinning dispatches")--> 0    the pose arrived on an earlier frame
print(s.held, "poses read as they stood") --> 1
print(s.liveBytes, "of", s.unsharedBytes) --> 1351296  of  28377216

A pose is written once. The pass builds a slice out of the instance's joint matrices, the node transforms its vertices address, its blend weight and its blend model, so a slice already holding a pose holds exactly what running the pass over the same inputs would write — and a frame that binds a pose whose slice still holds it reads the slice and dispatches nothing. Skinning is paid for by the poses that CHANGED: held counts the poses of the last frame that cost nothing, dispatches the ones that were written, and the two add up to poses.

local s = renderer.skinningStats()
print(s.dispatches, "poses written,", s.held, "read as they stood")  --> 0  22

A cast standing in one pose — a paused clip, a graph holding a frame, a prop wearing a skeleton nothing drives — costs compute the frame the pose arrives and nothing after it, and the skinning span leaves profiler.gpuFrame() entirely once the window it averages over has rolled past the last dispatch. Turning a bone costs two frames of dispatch: the frame the new pose arrives, and the frame after, whose pose is the same joints reached from the new ones rather than from the old. renderer.setSkinningPoseHold(false) writes every pose a frame binds instead, which is the comparison a frame suspected of reading a slice that no longer holds its pose is made against; the image is the same either way.

A crowd moving together — one clip on one clock — reads one pose however many members it has, so the pool holds a single character's worth of vertices for all of them. Members at different animation times each hold their own pose and each take a slice, which is what keeps them posed independently: an avatar spawned into a crowd that has been idling reads as its own pose until the two clocks line up again. liveBytes against unsharedBytes is the saving as it stands this frame; poolBytes is what the pool holds, with a previous-position buffer of the same size beside it feeding skinned motion vectors.

A pose no instance holds any more gives its slice back, and the next pose of the same size is cut from it — so a crowd that changes pose every frame cycles through the slices it already has rather than walking the pool forward. reusedSlices counts the poses of the last frame that were handed one of those slices instead of pool the engine had never used, which is what a scene in motion reads while poolBytes stays where it was:

local s = renderer.skinningStats()
print(s.reusedSlices, "of", s.poses, "poses took a slice the pool already held")
  • documentation
  • guide
  • rendering