What drawing costs: culling, materials and crowds
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")