Linear blend skinning is the standard way to deform a character mesh with a skeleton: every vertex stores up to four bone indices and four weights, and its animated position is the weighted sum of where each of those bones would carry it, which is cheap, easy to author for and supported by the common exchange formats, the glTF 2.0 skin definition included. When the skinned vertices have to persist, because a ray tracing acceleration structure is built from them, the work moves out of the vertex shader and into a compute pass that writes animated vertex buffers, and our 2023 post on ray tracing animated crowds describes how Breda batches that pass across every skinned mesh in the scene with a single dispatch.
That compute pass, apply_vertex_bones, has a second job which turned out to dominate it: it also computes each animated mesh's bounding box, which the rest of the frame reads as the mesh's bounds, and until July 2026 it did that by growing six shared words per mesh with global atomic min and max. Pull request #10478 removed those atomics entirely, in favour of a per-group reduction followed by a small second pass, and in the same change cut what the shader reads from its per-vertex buffers from 564 bytes to 220, by storing bone matrices as 3×4, deriving the normal transform from the cofactor of the blended matrix, and packing four weights into one 32-bit word.
This post is about those three costs, in the order they mattered: why the bounds were the expensive part, what the options were for each cost and which we took, and what each choice gives up. It is written for people who own a skinning pass of their own. We profiled the change, and the cofactor normal transform came out ahead of the per-bone inverse transpose it replaced.
Skinning is cheap, the bounds were not
The arithmetic in skinning is small: four matrix loads, a weighted sum and a transform per vertex, all independent of every other vertex, which is about as friendly a workload as a GPU gets. The bounding box is the opposite, because every vertex of a mesh contributes to the same six numbers, a minimum and a maximum on each axis, and a compute pass that writes those numbers from many threadgroups at once has to resolve the conflicts somewhere.
The obvious way, and the one Breda used, is to encode each float so that its unsigned integer ordering matches the float ordering (a trick known as float flipping), reset the box to an empty state in one pass, let every vertex do InterlockedMin and InterlockedMax on the six words, and flip the result back into floats in a third pass. It is correct and short, and its cost scales with the number of read-modify-writes landing on the same handful of addresses, which the hardware has to serialize.
The first measurement of this pass is the evidence that this was the expensive part: in December 2025, pull request #10011 reduced the atomics so that each wave combined its vertices with WaveActiveMin and WaveActiveMax and only the first lane wrote to memory, and the timing it recorded for apply_vertex_bones on a retail Android phone running Evolve went from 7000 μs to 1700 μs, with nothing else in the shader changed.
| Date | Change | Global atomic writes to one mesh's six words | Measured |
|---|---|---|---|
| 2024-04 | #7600: scalar-address fast path | 6 per vertex | no |
| 2025-12 | #10011: reduce per wave first | 6 per wave | 7000 μs to 1700 μs |
| 2025-12 | #10041: reduce per group in groupshared memory | 6 per group of 64 | screenshots only |
| 2026-07 | #10478: per-group partials and a reduce pass | none | profiled |
Each of the first three steps divided the number of colliding writes without removing the collisions, so a large mesh still had hundreds of groups queueing on the same six words, and the pull request description for #10478 says as much: the earlier attempts touched the count of atomics, not the contention.
Global atomic read-modify-writes landing on one mesh's six bounding box words, counted from each version of the shader for a 20,000 vertex mesh with 32-wide waves. The counts are arithmetic on the source, not a profile.
Three ways to bound a skinned mesh
There are three reasonable ways to produce that box, and they trade accuracy against work differently. The first is the atomic approach above, exact and simple, with a cost that depends on how badly writes collide, which is worst exactly where it hurts: large meshes, many groups, one box.
The second skips the vertices: precompute a box per bone in the bind pose, transform the eight corners by each bone's animated matrix, and take the union, which costs work per bone rather than per vertex and needs no reduction across the mesh at all. It is also conservative rather than exact, sometimes loosely so for long bones that rotate, and a looser box means more false positives for anything that culls or traverses by it, which is a cost paid elsewhere in the frame and is hard to see from inside the skinning pass.
The third keeps the exact box and removes the collisions by making every write have a single owner: each threadgroup reduces its own vertices to one partial box and stores it to a slot nobody else touches, and a second pass combines each mesh's partials into the final box. That costs an extra dispatch and a scratch buffer, and it changes the scaling from "contention grows with the mesh" to "a fixed amount of work per group", and that is the one we chose, because the skinning pass was already reading every vertex and the exact box comes with that read for little more than a reduction.
One group belongs to one mesh
A per-group partial box only means something if a group's 64 vertices all belong to the same mesh, and in the old dispatch they did not: the pass laid every skinned mesh end to end and gave each thread the next vertex, so a group could straddle the boundary between two meshes, which is what the scalar-address fast path in 2024 had been checking for.
Pull request #10478 pads each mesh to a whole number of groups instead. The CPU side builds an exclusive prefix sum of groups per mesh rather than of vertices, each group binary-searches that array once to find its mesh, and lanes past the end of the mesh's vertices skip the skinning and contribute an empty box, so the price is at most 63 idle lanes per skinned mesh per frame, which is nothing for a 20,000 vertex character and a real fraction of the work for a crowd made of very small meshes.
Inside a group the reduction is two steps, chosen to need a single barrier: each wave reduces with WaveActiveMin and WaveActiveMax, the first lane of each wave writes its result to a small groupshared array, and after one GroupMemoryBarrierWithGroupSync lane zero folds the per-wave results together and does a plain store of the partial box. Then reduce_aabbs runs one group per mesh, walks that mesh's partials with a grid-stride loop, reduces them the same way and writes the final box once, as ordinary floats.
Because nothing is ever combined with integer atomics, the float flipping disappears with them, and so do the two passes that existed only to set it up and undo it, so the sequence goes from reset, apply and resolve to apply and reduce, one dispatch fewer.
Bone matrices are affine, so store three rows
The second cost is what each vertex gathers. Before #10478, update_bones wrote two matrices per bone, the skinning matrix and its inverse transpose for normals, both as full float4x4, and each vertex loaded four of each, which is 512 bytes of loads per vertex, indexed by bone and therefore scattered.
The bottom row of a bone matrix is always 0, 0, 0, 1, because bones only rotate, scale and translate, so storing it carries no information, and the homogeneous divide the old shader did after the transform divided by exactly one. Storing the matrix transposed as a float3x4, 48 bytes, removes both, and mul(skinMatrix, float4(position, 1.0)) gives the position directly.
The second matrix is harder to drop, because transforming a normal by the same matrix as the position is wrong as soon as a bone scales non-uniformly: the surface stretches one way, and its normal has to tilt the other way to stay perpendicular, which is why the standard answer is the inverse transpose. The options are to keep a second matrix per bone, which is what we had, to ignore non-uniform scale and use the same 3×3 for both, which is wrong in exactly the cases where an animator squashes and stretches, or to compute the inverse transpose per vertex from the blended matrix, which costs an inverse per vertex, and there is a cheaper fourth option that keeps the correctness of the first.
The cofactor matrix is the inverse transpose without the division
For a 3×3 matrix with rows , and , the cofactor matrix is the matrix whose rows are the cross products of the other two rows, and it is the inverse transpose scaled by the determinant:
A normal is normalized after it is transformed, so the scale factor disappears, and what is left is the inverse transpose's direction for the price of three cross products and no division. The sign is the one subtlety: for a mirrored bone is negative and the cofactor points the opposite way to the inverse transpose, which is the answer that agrees with the mesh's triangle winding, because the cofactor satisfies
so it gives the same normal you would get by transforming two edges of a triangle and crossing them, mirrored winding included. In the shader that is the whole change, run on the blended matrix rather than on each bone:
// Transform normals/tangents by the cofactor of the skinning matrix's 3x3. This equals its
// inverse-transpose up to a (post-normalize, sign-correct) scalar, so it handles
// non-uniform bone scale without storing or gathering a separate normal matrix.
float3 r0 = skinMatrix.xyz;
float3 r1 = skinMatrix.xyz;
float3 r2 = skinMatrix.xyz;
float3x3 cofactor = ;
float3 transformedTangent = ;
float3 transformedNormal = ;

That takes the matrix gather to four 48 byte loads, 192 bytes per vertex, deletes the normal matrix buffer, and removes the per-bone inverse and transpose from update_bones. It is not free in one respect, and the pull request says so: for a vertex weighted to more than one bone, the normal is now the cofactor of the blended matrix rather than the blend of each bone's inverse transpose, which are different matrices, and neither is exact, since linear blending of matrices is itself an approximation, single-bone vertices come out the same as before, and we have not compared the two on a character that squashes hard.
Four weights that sum to one need three numbers
The third cost is the weight stream. Skinning weights are normalized to sum to one, and Breda stored all four as float4, 16 bytes per vertex, for three degrees of freedom.
The common alternative is four normalized 8-bit values, which glTF allows and which costs 4 bytes, and its problem is that four independently rounded weights no longer sum to one, so either the shader renormalizes or the mesh shifts slightly wherever the error lands. #10478 stores three weights instead, as 11, 11 and 10 bit unsigned normalized fields in one 32-bit word, and reconstructs the fourth:
The power-of-two denominators are deliberate: each weight is an exact binary fraction, so the decoder builds it by placing the integer code into the mantissa of a float between 1 and 2 and subtracting one, with no division, and the four weights sum to exactly one in floating point rather than approximately. The packer normalizes the input first and rounds each stored weight to the nearest step, so the stored three are within 1/4096, 1/4096 and 1/2048 of the authored values (a weight of exactly one is the exception, clamped to 2047/2048 with the remainder landing on the fourth), the reconstructed fourth absorbs their error, at most 1/1024, and the round-trip unit tests allow twice that.
The bone indices got the same treatment in the same change: a mesh whose largest bone index fits in a byte stores its four indices in one 32-bit word instead of 16 bytes, larger skeletons use two words, and the shader learns which layout it has from a flag stored in the top bit of the buffer's bindless handle, so the per-mesh data did not grow to carry it.
Bytes the skinning shader reads per vertex, before and after #10478, from the buffer layouts in the code. These are bytes requested by the shader, not DRAM traffic: vertices that share bones hit the same matrices in cache.
The weight format is a serialized asset change, so every skinned mesh had to be cooked again, the same way the earlier normal and tangent packing required, which is a cost to plan for if you make the same change in a pipeline with a large cooked library.
What the change does not prove
The 564 to 220 bytes per vertex and the removal of every global atomic are properties of the code: the new shader cannot contend on the bounds, whatever the mesh. The profile of #10478 bears out the normal path too, with the cofactor transform coming out ahead of the per-bone inverse transpose it replaced.
There are also two things the change made worse in principle. Padding to whole groups wastes up to 63 lanes per mesh, which matters for crowds built from many small meshes, and reduce_aabbs runs one group of 64 threads per mesh however few partials it has, so a scene with thousands of tiny skinned meshes dispatches thousands of nearly idle groups, and in that scene the per-bone boxes from earlier in this post could be the better choice, at a crossover we have not measured.
The meshlet path kept the formats
The skinning work did not stop at #10478: in August the skinning path for virtualized geometry, which skins whole clusters and writes a bounding sphere per cluster into the skinned cluster's header, moved into apply_vertex_bones as a second permutation, and it does still use atomics for its bounds, but only in groupshared memory, since one group owns one cluster and nothing outside the group ever writes the result. It reads the same float3x4 matrices and packed weights and uses the same cofactor normals, so the formats held up when a second consumer arrived.
If you own a skinning pass
The first thing to look at is whether your bounds are computed with global atomics on a shared box, because if they are, that is the first suspect for the cost of your skinning pass rather than the skinning itself, and you can test it cheaply by timing the pass with the bounds update commented out. If the difference is large, the per-group partial and reduce pass is a contained change; if your meshes are small and numerous, try per-bone boxes first.
The matrix and weight formats are independent of that and smaller wins, and the cofactor normal in particular is the one to take even if nothing else is, since it removes a buffer, a per-bone inverse and half the gathers without giving up correctness under non-uniform scale. The reference to read for why linear blend skinning behaves the way it does, and what dual quaternion skinning trades against it, is Kavan, Collins, Žára and O'Sullivan, Geometric Skinning with Approximate Dual Quaternion Blending, ACM Transactions on Graphics, 2008.
What we have not measured yet
- A many-small-meshes scene, to find where padding and one reduce group per mesh start to cost more than they save.
- A normal comparison on a hard squash, between the cofactor of the blend and the blend of inverse transposes, so the approximation is judged on a picture rather than an argument.
The design we would recommend to anyone starting from where we were is the one we ended at: give every bounding box write a single owner, store bone matrices as the three rows that carry information, and let the cofactor do the normal transform, because each of those removes work rather than making it faster. What we would do differently is the order, since three rounds of reducing atomic counts came before anyone removed the atomics, and #10011, the change that produced the first measurement, already noted that most of them could be removed.
