GpuSkinning.kt
src/jvmMain/kotlin/afield/graphics/scene/GpuSkinning.kt · 932 lines
@file:Suppress("MemberVisibilityCanBePrivate", "unused")
package afield.graphics.scene
import afield.graphics.render.before
import afield.graphics.render.main
import afield.document.context.fromContext
import afield.graphics.render.Mesh
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.FrameBufferGroup
import afield.graphics.vulkan.buffers.buffer
import afield.graphics.vulkan.util.Camera
import afield.math.linalg.Matrix4f
Skinning in the vertex shader: one mesh, one draw call, and a crowd of characters each posed differently.
The bind-pose vertices are uploaded once and never touched again. Per frame the CPU writes only
the joint palette, which is instances times bones matrices, and the GPU does the per-vertex
blend, so a hundred characters cost a hundred palettes rather than a hundred deformations.
GpuSkinner is the box-facing half: build one over a loaded GltfSupport.MeshInstance, hand it
a palette per instance with uploadPalettes, then bindTo the mesh you are drawing.
GpuSkinning holds the GLSL and the palette packing underneath it.
The CPU path in GltfSkinning is the oracle. The same GLSL that draws also runs as a compute shader writing deformed positions to a buffer the CPU reads back, so a test that compares the two is checking the shader that renders rather than a second implementation written to pass it. Linear blending and dual quaternions can be mixed inside one draw call, chosen per instance, which is the clearest way to see what separates them.
object GpuSkinning
Skinning in the vertex shader, with a joint-matrix palette on the GPU, and a crowd of characters from one mesh and one draw call.
CpuSkinner deforms on the CPU and rewrites the packed 12-float vertex buffer that
MeshInstance.applyToMesh uploads. That is correct, it is measured — 17.7 M vertices/s, so
2.8 ms for a 50,000-vertex character — and it is the oracle this path is checked against.
What it cannot do is a crowd: every character costs another full deformation and another full
re-upload, so a hundred of them is a hundred times 2.8 ms plus a hundred buffer writes.
Here the bind-pose vertices are uploaded once and never touched again. Per frame the CPU
writes only the palette — instances × bones matrices, which for the fixtures here is 7 bones
— and the vertex shader does the Σ wᵢ · palette[bᵢ] · p for every vertex of every instance.
What is on the GPU and what is not
Worth being exact about, because "GPU skinning" is often claimed for less:
- On the GPU: the per-vertex blend.
O(instances × vertices)— the term that grows. - On the CPU: evaluating the palette from the clips.
O(instances × bones), and bones are units where vertices are tens of thousands. GltfSkinning.Skeleton.buildGlobals is a sequential parent-before-child walk; moving it to a compute shader would buy a rounding error and cost the ability to read it.
So per-instance clip blending is evaluated per instance on the CPU and handed over as that instance's slice of the palette. See GpuSkinner.uploadPalettes.
The instance transform rides in the palette
There is no per-instance model-matrix buffer, and that is not an omission. A palette entry is
already global(node) · inverseBind, and placing instance i in the world is one more matrix
on the left:
palette[i][b] = model(i) · global(node(b), tᵢ) · inverseBind(b)
The CPU is building that palette anyway, so per-instance placement costs one mul per bone
and nothing at all on the GPU. It also keeps the normal matrix honest — the model transform is
inside the blended 3×3 that glsl inverts and transposes, rather than applied after it.
GpuSkinner.placedPalette does the multiply.
One GLSL, two entry points
glsl is the whole of the maths, and it is used by both skinnedVertexShader (which
draws) and verifyComputeShader (which writes deformed positions to a buffer the CPU reads
back). That is deliberate: GpuSkinningSmoke comparing the compute output against
GltfSkinning.skin is then evidence about the shader that actually renders, and not about a
second implementation written to pass a test.
Two blends, chosen per instance
Linear blend skinning averages matrices, and under a twist about the bone's own axis that
average stops being a rotation: the fixture's twist clip at 180° pinches the joint ring to
nothing, the candy wrapper. DualQuaternionSkinning is the CPU fix; this carries it to the
GPU. Each instance's palette is uploaded twice — as mat4s, and as dual quaternions plus a
residual 3×3 (see DUAL_VEC4S_PER_JOINT, packDualPalette) — and a per-instance method word
says which the shader blends. So one draw call can hold linear-blend and dual-quaternion
characters side by side, which is how the difference is best seen.
The split M = T·R·S stays on the CPU, and it is the oracle's own DualQuaternionSkinning.split:
it is O(instances × bones), like the rest of the palette, and reusing it means the GPU adds only
the blend.
Push-constant budget
The stock glTF block is 176 bytes. This adds five buffer references and three uints, for
228. Vulkan only guarantees 128, so this is a MoltenVK/desktop-class assumption, not a
portable one; the machine it was measured on reports 4096. If it ever has to shrink, the three
uints fold into one packed word, the joint and weight buffers interleave into one, and the
method words can ride in the dual palette's unused w lanes.
object GpuSkinning {
const val MAX_INFLUENCES: Int = GltfSkinning.MAX_INFLUENCES
Influences per vertex, fixed by GltfSkinning.MAX_INFLUENCES and by uvec4/vec4.
const val VERTEX_STRIDE_FLOATS: Int = 12
Floats per vertex in the packed buffer the bundled glTF shaders read.
const val LOCAL_SIZE: Int = 64
Invocations per workgroup in verifyComputeShader.
const val DUAL_VEC4S_PER_JOINT: Int = 5
vec4s per joint in the dual-quaternion palette: real.xyzw, dual.xyzw, then the
residual 3×3 S as three columns in JOML order (m00 m01 m02, …) with w unused.
80 bytes against the matrix palette's 64. Five and not four because the residual can be a full 3×3: Gram-Schmidt leaves it upper-triangular, six floats, which would fit beside the eight of the dual quaternion — but a degenerate joint (a basis axis scaled to nothing) hands the whole 3×3 over as its residual, and a packing that could not carry it would be exact only until the first collapsed bone.
const val DUAL_FLOATS_PER_JOINT: Int = DUAL_VEC4S_PER_JOINT * 4
Floats per joint in the dual-quaternion palette: DUAL_VEC4S_PER_JOINT × 4.
const val METHOD_LINEAR: Int = 0
Method word 0: linear blend skinning from the mat4 palette.
const val METHOD_DUAL_QUATERNION: Int = 1
Method word bit 0: dual quaternion blending from the dual palette.
const val METHOD_RESIDUAL_BIT: Int = 2
Method word bit 1: this instance's palette has at least one non-rigid joint, so the residual 3×3 must be blended. Only meaningful with METHOD_DUAL_QUATERNION.
This is DualQuaternionSkinning.skin's anyNonRigid, carried per instance: the oracle skips
the residual accumulation for a palette of pure rigid motions, and so does the shader, so
the two take the same arithmetic path rather than one of them blending identities.
fun packDualPalette(palette: List<Matrix4f>, into: FloatArray, offset: Int = 0): Int
Write palette into into at offset in the dual-quaternion layout — DUAL_FLOATS_PER_JOINT
floats per joint — and return the method word the shader needs for it.
The split is DualQuaternionSkinning.split, the oracle's own factorisation, called
here rather than re-derived. So the only arithmetic the GPU adds is the blend, and a
disagreement between the shader and DualQuaternionSkinning.skin can only be a
disagreement about blending. Rigid joints get an identity residual, which is what the
oracle's addScaled3 adds for them when some other joint needs the residual path.
Both GpuSkinner.uploadPalettes and GpuSkinningSmoke pack through this, so the packing
the smoke grades is the packing the renderer draws.
@return METHOD_DUAL_QUATERNION, or'd with METHOD_RESIDUAL_BIT when any joint is non-rigid.
fun packDualPalette(palette: List<Matrix4f>, into: FloatArray, offset: Int = 0): Int {
var nonRigid = false
var o = offset
for (m in palette) {
val s = DualQuaternionSkinning.split(m)
val r = s.dq.real
val d = s.dq.dual
into[o++] = r.x; into[o++] = r.y; into[o++] = r.z; into[o++] = r.w
into[o++] = d.x; into[o++] = d.y; into[o++] = d.z; into[o++] = d.w
val res = s.residual
if (res == null) {
into[o++] = 1f; into[o++] = 0f; into[o++] = 0f; into[o++] = 0f
into[o++] = 0f; into[o++] = 1f; into[o++] = 0f; into[o++] = 0f
into[o++] = 0f; into[o++] = 0f; into[o++] = 1f; into[o++] = 0f
} else {
nonRigid = true
into[o++] = res.m00; into[o++] = res.m01; into[o++] = res.m02; into[o++] = 0f
into[o++] = res.m10; into[o++] = res.m11; into[o++] = res.m12; into[o++] = 0f
into[o++] = res.m20; into[o++] = res.m21; into[o++] = res.m22; into[o++] = 0f
}
}
return METHOD_DUAL_QUATERNION or (if (nonRigid) METHOD_RESIDUAL_BIT else 0)
}
fun glsl(): String
The skinning maths, as GLSL: five buffer-reference types and six functions.
Place immediately after #version — it carries its own #extension directive. Written to
mirror GltfSkinning.skin statement for statement, including the parts that look like
fussiness and are not:
- It accumulates the weighted matrix, not the weighted transformed position. Identical for positions; it is the only way to get the blended 3×3 the normal needs.
- A vertex with no influence at all gets the identity, not the zero matrix, so an unweighted vertex stays where it is instead of collapsing to the origin.
- Weights are renormalised, matching
SkinningOptions.renormalize, so a file whose weights sum to 0.9 does not shrink. - The normal uses the exact inverse transpose when the blended 3×3 is invertible and the
plain 3×3 when it is not, matching
SkinningOptions.exactNormalMatrix. Falling back rather than producing a NaN is what keeps a degenerate pose renderable.
mat4 * float and mat4 + mat4 are component-wise in GLSL, so afSkinBlend is the
literal transcription of GltfSkinning.addScaled.
afSkinBlendDual is the same for DualQuaternionSkinning.skin, and the parts of that
which look like fussiness are not either:
- The antipodal pivot is the highest-weight influence, the first one on a tie, compared
with a strict
>from zero — so at the fixture's 0.5/0.5 ring both sides pick the same pivot, and a shader that picked the last one on a tie would flip different signs. - The residual
Sis blended without the sign flip. A shear has no antipode; flipping it with the quaternion would negate the mesh at every flipped vertex. - Normalisation is
DualQuaternionf.normalizeto the letter, including the re-orthogonalisationdual -= dot(real, dual) · real, which is what keeps a blended dual part from carrying a spurious scale into the translation. - It returns a matrix,
[R·S | t], soafSkinPositionandafSkinNormalare shared by both methods. That changes nothing about the normal:Ris orthonormal, so(R·S)⁻ᵀ = R·S⁻ᵀ, which is the oracle's residual-inverse-transpose-then-rotate.
afSkinTransform picks between the two per instance, from a method word
(METHOD_LINEAR, METHOD_DUAL_QUATERNION, METHOD_RESIDUAL_BIT). The branch is uniform
within an instance, so it only diverges in a subgroup that straddles two instances of
different methods — which a mixed crowd has, and pays for by running both blends there.
body — 135 lines, from line 227
fun glsl(): String = """
#extension GL_EXT_buffer_reference : require
layout(std430, buffer_reference) readonly buffer AfSkinJoints { uvec4 v[]; };
layout(std430, buffer_reference) readonly buffer AfSkinWeights { vec4 v[]; };
layout(std430, buffer_reference) readonly buffer AfSkinPalette { mat4 v[]; };
// $DUAL_VEC4S_PER_JOINT vec4 per (instance, bone): real, dual, then the residual 3x3 by columns.
layout(std430, buffer_reference) readonly buffer AfSkinDual { vec4 v[]; };
// One word per instance: which blend it gets. See afSkinTransform.
layout(std430, buffer_reference) readonly buffer AfSkinMethods { uint v[]; };
// The four influences of `vertex`, blended into one matrix. `paletteBase` is where this
// instance's slice of the palette starts -- instanceIndex * boneCount.
mat4 afSkinBlend(AfSkinJoints joints, AfSkinWeights weights, AfSkinPalette palette,
uint vertex, uint paletteBase) {
uvec4 j = joints.v[vertex];
vec4 w = weights.v[vertex];
float sum = w.x + w.y + w.z + w.w;
float norm = sum > 0.0 ? 1.0 / sum : 1.0;
mat4 blended = mat4(0.0);
bool contributed = false;
if (w.x != 0.0) { blended += palette.v[paletteBase + j.x] * (w.x * norm); contributed = true; }
if (w.y != 0.0) { blended += palette.v[paletteBase + j.y] * (w.y * norm); contributed = true; }
if (w.z != 0.0) { blended += palette.v[paletteBase + j.z] * (w.z * norm); contributed = true; }
if (w.w != 0.0) { blended += palette.v[paletteBase + j.w] * (w.w * norm); contributed = true; }
// An unweighted vertex keeps its bind position rather than collapsing to the origin.
return contributed ? blended : mat4(1.0);
}
// Rotate v by the unit quaternion q (xyzw), as q v q*. The same arithmetic as the generated
// `transformQuaternionVector3` the CPU oracle calls, term for term.
vec3 afQuatRotate(vec4 q, vec3 v) {
vec3 t = 2.0 * cross(q.xyz, v);
return v + q.w * t + cross(q.xyz, t);
}
// Dual quaternion linear blending, transcribed from DualQuaternionSkinning.skin, and returned as
// the equivalent affine matrix [R * S | t] so afSkinPosition and afSkinNormal serve both methods.
// That is exact rather than convenient: R is orthonormal, so (R S)^-T = R S^-T, which is what the
// oracle does to a normal -- inverse transpose of the residual, then the rotation.
//
// `residual` is the oracle's `anyNonRigid`: false means every joint of this instance's palette is a
// pure rigid motion, and S is skipped entirely rather than blended from identities.
mat4 afSkinBlendDual(AfSkinJoints joints, AfSkinWeights weights, AfSkinDual dual,
uint vertex, uint paletteBase, bool residual) {
uvec4 j = joints.v[vertex];
vec4 w = weights.v[vertex];
float sum = w.x + w.y + w.z + w.w;
float norm = sum > 0.0 ? 1.0 / sum : 1.0;
// The pivot is the HIGHEST-weight influence, first one on a tie -- strict '>' from 0, exactly as
// the oracle -- so the sign convention does not depend on the order the bones arrived in.
int pivot = -1;
float best = 0.0;
for (int i = 0; i < 4; i++) {
if (w[i] > best) { best = w[i]; pivot = i; }
}
if (pivot < 0) return mat4(1.0);
vec4 pivotReal = dual.v[(paletteBase + j[pivot]) * ${DUAL_VEC4S_PER_JOINT}u];
vec4 real = vec4(0.0);
vec4 du = vec4(0.0);
mat3 s = mat3(0.0);
for (int i = 0; i < 4; i++) {
if (w[i] == 0.0) continue;
float wi = w[i] * norm;
uint e = (paletteBase + j[i]) * ${DUAL_VEC4S_PER_JOINT}u;
vec4 r = dual.v[e];
vec4 d = dual.v[e + 1u];
// Antipodality: q and -q are one rotation and opposite dual quaternions. Flip any influence
// on the far hemisphere from the pivot, or the joint ring blends toward zero.
float sgn = dot(pivotReal, r) < 0.0 ? -1.0 : 1.0;
real += r * (wi * sgn);
du += d * (wi * sgn);
// S is blended linearly and WITHOUT the sign: a shear has no antipode.
if (residual) s += mat3(dual.v[e + 2u].xyz, dual.v[e + 3u].xyz, dual.v[e + 4u].xyz) * wi;
}
// DualQuaternionf.normalize: identity below 1e-6 length, otherwise scale both parts by 1/|real|
// and re-orthogonalise the dual part against the real one.
float lenSq = dot(real, real);
if (lenSq < 1e-12) {
real = vec4(0.0, 0.0, 0.0, 1.0);
du = vec4(0.0);
} else {
float inv = 1.0 / sqrt(lenSq);
real *= inv;
du *= inv;
du -= dot(real, du) * real;
}
if (!residual) s = mat3(1.0);
// Translation = 2 * (dual * conj(real)).xyz, Hamilton product, as DualQuaternionf.getTranslation.
vec3 t = 2.0 * (real.w * du.xyz - du.w * real.xyz + cross(real.xyz, du.xyz));
// Columns of R are the rotated basis, so R * v == afQuatRotate(real, v) by linearity.
mat3 R = mat3(afQuatRotate(real, vec3(1.0, 0.0, 0.0)),
afQuatRotate(real, vec3(0.0, 1.0, 0.0)),
afQuatRotate(real, vec3(0.0, 0.0, 1.0)));
mat3 rs = R * s;
return mat4(vec4(rs[0], 0.0), vec4(rs[1], 0.0), vec4(rs[2], 0.0), vec4(t, 1.0));
}
// Whichever blend this instance asked for. `methods.v[instance]`: bit 0 = dual quaternion,
// bit 1 = this instance's palette has a non-rigid joint (blend the residual 3x3).
mat4 afSkinTransform(AfSkinJoints joints, AfSkinWeights weights, AfSkinPalette palette,
AfSkinDual dual, AfSkinMethods methods,
uint vertex, uint instance, uint boneCount) {
uint m = methods.v[instance];
uint base = instance * boneCount;
if ((m & ${METHOD_DUAL_QUATERNION}u) != 0u) {
return afSkinBlendDual(joints, weights, dual, vertex, base, (m & ${METHOD_RESIDUAL_BIT}u) != 0u);
}
return afSkinBlend(joints, weights, palette, vertex, base);
}
vec3 afSkinPosition(mat4 blended, vec3 p) {
return (blended * vec4(p, 1.0)).xyz;
}
vec3 afSkinNormal(mat4 blended, vec3 n) {
mat3 nm = mat3(blended);
float d = determinant(nm);
// Singular, infinite or NaN determinant: use the plain 3x3. A wrong-ish normal beats a NaN,
// which would take the whole triangle out of the rasteriser.
if (abs(d) > 1e-20 && !isinf(d) && !isnan(d)) nm = transpose(inverse(nm));
vec3 t = nm * n;
float len = length(t);
return len > 1e-12 ? t / len : t;
}
""".trimIndent()
val skinnedVertexShader: String
GltfSupport.gltVertexShader with the skin in it: same packed 12-float vertex, same buffer
references, same push constants — plus a joint stream, a palette and gl_InstanceIndex.
Everything the stock shader does, it still does. The additions are:
jointBuffer/weightBuffer, indexed by the sameidxthe vertex data is, so the 12-float layout every other Field glTF path depends on is unchanged. Two extra buffers beat a fourteen-float vertex that no other shader could read.paletteBuffer, sliced bygl_InstanceIndex * boneCount.dualPaletteBufferandmethodBuffer: the same palette as dual quaternions plus residual, and one word per instance choosing which of the two it is skinned by.skinEnabled, so one module can draw a rigged and a rigid mesh. 0 is the stock path, bit for bit.instanceTint, which multiplies in a hash of the instance index. A crowd where every character is the same colour is a crowd you cannot tell is a crowd.
modelView should carry view only when skinning: a skinned mesh is already in scene
space (glTF 2.0 §3.7.3.2), and each instance's placement is inside its palette. Pushing a
model matrix as well applies it twice.
body — 109 lines, from line 384
val skinnedVertexShader: String = """
#version 450
${glsl()}
#extension GL_EXT_scalar_block_layout : require
layout(std430, buffer_reference) readonly buffer VertexBuffer {
float vertexData[];
};
layout(std430, buffer_reference) readonly buffer IndexBuffer {
uint indexData[];
};
layout(push_constant) uniform PushConstants {
mat4 modelView; // view * model; view alone when skinning
mat4 proj; // projection
vec4 materialBaseColor;
uint vertexOffset; // in floats
uint indexOffset; // in indices
uint vertexStride; // in floats
uint useVertexColor; // bool as uint
VertexBuffer vertexBuffer;
IndexBuffer indexBuffer;
AfSkinJoints jointBuffer;
AfSkinWeights weightBuffer;
AfSkinPalette paletteBuffer;
AfSkinDual dualPaletteBuffer;
AfSkinMethods methodBuffer;
uint boneCount; // palette entries per instance
uint skinEnabled; // 0 = draw the bind pose, exactly as the stock shader does
uint instanceTint; // 1 = tint by gl_InstanceIndex so a crowd reads as a crowd
} pc;
layout(location = 0) out vec3 vViewPos;
layout(location = 1) out vec3 vNormalVS;
layout(location = 2) out vec2 vUV;
layout(location = 3) out vec4 vColor;
// A cheap, stable, well-spread hue per instance. Not a random number generator -- it only has to
// make neighbours differ.
vec3 afInstanceTint(uint i) {
float h = fract(float(i) * 0.6180339887);
vec3 k = fract(vec3(h) + vec3(0.0, 0.3333333, 0.6666667));
return 0.45 + 0.55 * abs(k * 6.0 - 3.0) / 3.0;
}
void main()
{
uint idx = pc.indexBuffer.indexData[pc.indexOffset+uint(gl_VertexIndex)];
uint base = pc.vertexOffset + idx * pc.vertexStride;
vec3 pos = vec3(
pc.vertexBuffer.vertexData[base + 0],
pc.vertexBuffer.vertexData[base + 1],
pc.vertexBuffer.vertexData[base + 2]
);
vec3 normal = vec3(
pc.vertexBuffer.vertexData[base + 3],
pc.vertexBuffer.vertexData[base + 4],
pc.vertexBuffer.vertexData[base + 5]
);
vec2 uv = vec2(
pc.vertexBuffer.vertexData[base + 6],
pc.vertexBuffer.vertexData[base + 7]
);
vec4 color = vec4(
pc.vertexBuffer.vertexData[base + 8],
pc.vertexBuffer.vertexData[base + 9],
pc.vertexBuffer.vertexData[base + 10],
pc.vertexBuffer.vertexData[base + 11]
);
if (pc.useVertexColor == 0u) {
color = vec4(1.0);
}
if (pc.skinEnabled != 0u) {
mat4 blended = afSkinTransform(pc.jointBuffer, pc.weightBuffer, pc.paletteBuffer,
pc.dualPaletteBuffer, pc.methodBuffer,
idx, uint(gl_InstanceIndex), pc.boneCount);
pos = afSkinPosition(blended, pos);
normal = afSkinNormal(blended, normal);
}
if (pc.instanceTint != 0u) {
color.rgb *= afInstanceTint(uint(gl_InstanceIndex));
}
vec4 viewPos = pc.proj * pc.modelView*vec4(pos, 1.0);
mat3 mv3 = mat3(pc.modelView);
vec3 normalVS = normalize(mv3 * normal);
vViewPos = viewPos.xyz;
vNormalVS = normalVS;
vUV = uv;
vColor = color * pc.materialBaseColor;
gl_Position = viewPos;
}
""".trimIndent()
val skinnedFragmentShader: String
A fragment shader that actually uses vColor.
GltfSupport.gltfFragmentShader declares vColor and then never reads it — it outputs
vNormalVS.rgb * d, so every mesh is shaded by its own normal direction and the
materialBaseColor push constant has no effect on the picture. That is fine for one
object and useless for a crowd: with it, a hundred instances tinted a hundred ways render
a hundred identical characters, and "the crowd is varied" becomes unfalsifiable.
So this keeps the same one-directional-light look and multiplies the interpolated colour
in. Same location bindings, so it drops into the same module.
val skinnedFragmentShader: String = """
#version 450
layout(location = 0) out vec4 uFragColor;
layout(location = 1) in vec3 vNormalVS;
layout(location = 3) in vec4 vColor;
void main()
{
vec3 l = normalize(vec3(1, 0.4, -0.2));
float d = abs(dot(normalize(vNormalVS), l));
// A floor under the diffuse term, so a character facing away is dark rather than absent.
uFragColor = vec4(vColor.rgb * (0.25 + 0.75 * d), 1.0);
}
""".trimIndent()
val verifyComputeShader: String
The same glsl, run over every vertex of every instance, writing deformed positions and normals to a buffer the CPU can read back and compare with GltfSkinning.skin.
This exists because a screenshot cannot tell you that a vertex is in the right place to six
decimals, and because the alternative — transform feedback — is not in core Vulkan. One
invocation per (instance, vertex); output is two vec4 per invocation, position then
normal, so the readback is std430-simple rather than a packing puzzle.
It reads the bind pose from its own bindBuffer (two vec4 per vertex) rather than from
the packed 12-float vertex buffer, so the comparison does not depend on the draw path's
vertex layout staying put.
val verifyComputeShader: String = """
#version 450
${glsl()}
layout(local_size_x = $LOCAL_SIZE) in;
layout(std430, buffer_reference) readonly buffer AfSkinBind { vec4 v[]; };
layout(std430, buffer_reference) buffer AfSkinOut { vec4 v[]; };
layout(push_constant) uniform Push {
AfSkinJoints jointBuffer;
AfSkinWeights weightBuffer;
AfSkinPalette paletteBuffer;
AfSkinDual dualPaletteBuffer;
AfSkinMethods methodBuffer;
AfSkinBind bindBuffer;
AfSkinOut outBuffer;
uint boneCount;
uint vertexCount;
uint instanceCount;
} pc;
void main() {
uint gid = gl_GlobalInvocationID.x;
if (gid >= pc.vertexCount * pc.instanceCount) return;
uint inst = gid / pc.vertexCount;
uint v = gid - inst * pc.vertexCount;
mat4 blended = afSkinTransform(pc.jointBuffer, pc.weightBuffer, pc.paletteBuffer,
pc.dualPaletteBuffer, pc.methodBuffer,
v, inst, pc.boneCount);
vec3 p = afSkinPosition(blended, pc.bindBuffer.v[v * 2u].xyz);
vec3 n = afSkinNormal(blended, pc.bindBuffer.v[v * 2u + 1u].xyz);
pc.outBuffer.v[gid * 2u] = vec4(p, 1.0);
pc.outBuffer.v[gid * 2u + 1u] = vec4(n, 0.0);
}
""".trimIndent()
}
class GpuSkinner
The GPU half of CpuSkinner: joints and weights uploaded once, a palette written per frame, and a crowd drawn from one mesh.
val skinner = GpuSkinner(models[0].meshes[0], instances = 64)
skinner.bindTo(mesh, camera) // once
loop {
skinner.uploadPalettes((0 until 64).map { i ->
skinner.placedPalette(model(i), "bend", t + i * 0.05f)
}) // 64 x 7 matrices, not 64 x 50,000 vertices
frame()
}
Linear blend by default. For the twisted joint linear blending cannot do, set
skinner.method = GltfSkinning.SkinningMethod.DUAL_QUATERNION, or hand uploadPalettes one
method per instance to put both in one draw:
skinner.uploadPalettes(palettes, methods = List(64) { i -> if (i % 2 == 0) LINEAR_BLEND else DUAL_QUATERNION })
The three things easy to get wrong, all of which this handles and none of which announce themselves if you do them by hand:
- The model matrix must not be pushed.
MeshInstance.applyToMeshdefaultsmodelto the node's own transform, and glTF 2.0 §3.7.3.2 says a skinned mesh node's transform is ignored. updateCamera pushes the view alone; per-instance placement goes through placedPalette. - The bind pose must be uploaded, not a posed one. Sharing a
MeshInstancewith a CpuSkinner is therefore a trap:CpuSkinner.poserewritesinstance.buffersin place and the GPU would then skin already-skinned vertices. This snapshots the packed vertex buffer at construction and uploads the snapshot, so the two can coexist —GpuSkinningEvidenceruns both against the same instance and photographs each. - The vertex upload happens once. The GPU buffers belong to this object, not to the
MeshInstance, so nothing re-uploads geometry per frame. The only per-frame write is the palette.
class GpuSkinner(
val instance: GltfSupport.MeshInstance,
/** How many characters this palette is sized for. One draw call covers all of them. */
val instances: Int = 1,
val renderer: Renderer = fromContext(),
/** Distinguishes this skinner's buffers from another's. */
val name: String = "gpuSkin",
) {
val skin: GltfSkinning.MeshSkin
The mesh's skin; non-null by construction, as for CpuSkinner.
val skin: GltfSkinning.MeshSkin = requireNotNull(instance.skin) {
"MeshInstance '${instance.name}' has no skin; GpuSkinner has nothing to deform"
}
val vertexCount: Int = instance.buffers.vertexCount
val boneCount: Int = skin.skeleton.bones.size
val clips: List<String> get() = skin.skeleton.clips.map { it.name }
Clip names available on this mesh's skeleton, in file order.
init
init {
require(instances > 0) { "instances must be positive, got $instances" }
require(boneCount > 0) { "MeshInstance '${instance.name}' has a skin with no bones" }
}
val bindPositions: FloatArray = FloatArray(vertexCount * 3)
The bind-pose positions and normals, snapshotted at construction.
3 * vertexCount each. Taken here rather than read on demand because a CpuSkinner on the
same MeshInstance overwrites instance.buffers in place, and a GPU skinner that read the
buffer later would skin an already-skinned mesh.
val bindNormals: FloatArray = FloatArray(vertexCount * 3)
The whole packed 12-float vertex buffer as it was at construction — the bind pose.
private val bindVertexData: FloatArray = FloatArray(vertexCount * GpuSkinning.VERTEX_STRIDE_FLOATS)
init
init {
val f = instance.buffers.vertexData.asFloatBuffer()
for (n in 0 until vertexCount * GpuSkinning.VERTEX_STRIDE_FLOATS) bindVertexData[n] = f.get(n)
for (v in 0 until vertexCount) for (k in 0..2) {
bindPositions[v * 3 + k] = bindVertexData[v * 12 + k]
bindNormals[v * 3 + k] = bindVertexData[v * 12 + 3 + k]
}
}
val jointBuffer: FrameBufferGroup
uvec4 per vertex. Static: written once in init, never again.
val jointBuffer: FrameBufferGroup = buffer(name = "${name}Joints") {
size = vertexCount * 4L * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
val weightBuffer: FrameBufferGroup
vec4 per vertex. Static.
val weightBuffer: FrameBufferGroup = buffer(name = "${name}Weights") {
size = vertexCount * 4L * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
val paletteBuffer: FrameBufferGroup
instances × boneCount mat4. The only thing written per frame, and the reason a crowd is
nearly free: 64 characters of a 7-bone rig is 448 matrices, against 64 × vertexCount
vertices the GPU would otherwise have to be handed.
val paletteBuffer: FrameBufferGroup = buffer(name = "${name}Palette") {
size = instances.toLong() * boneCount * 16L * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
val dualPaletteBuffer: FrameBufferGroup
The same instances × boneCount palette as dual quaternions plus residual —
GpuSkinning.DUAL_FLOATS_PER_JOINT floats a joint. Written by uploadPalettes only for
the instances that are dual-quaternion skinned; the rest of it is never read.
val dualPaletteBuffer: FrameBufferGroup = buffer(name = "${name}DualPalette") {
size = instances.toLong() * boneCount * GpuSkinning.DUAL_FLOATS_PER_JOINT * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
val methodBuffer: FrameBufferGroup
One uint per instance: which blend the shader uses for it. Written by uploadPalettes.
val methodBuffer: FrameBufferGroup = buffer(name = "${name}Methods") {
size = instances.toLong() * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
var method: GltfSkinning.SkinningMethod = GltfSkinning.SkinningMethod.LINEAR_BLEND
The blend every instance gets unless uploadPalettes is handed a per-instance list. Linear blend by default, matching GltfSkinning.SkinningOptions and every other engine.
Takes effect at the next uploadPalettes, because the method word also carries whether that palette has a non-rigid joint, and only an upload knows.
Scratch for uploadPalettes; sized once.
private val dualScratch = FloatArray(instances * boneCount * GpuSkinning.DUAL_FLOATS_PER_JOINT)
private val methodScratch = IntArray(instances)
init
init {
val infl = skin.influences
jointBuffer.get(write = true).mapAndWrite_ints {
for (v in 0 until vertexCount) for (k in 0 until GpuSkinning.MAX_INFLUENCES) {
put(infl.jointIndex[v * GpuSkinning.MAX_INFLUENCES + k])
}
}
weightBuffer.get(write = true).mapAndWrite {
for (v in 0 until vertexCount) for (k in 0 until GpuSkinning.MAX_INFLUENCES) {
put(infl.weight[v * GpuSkinning.MAX_INFLUENCES + k])
}
}
// Every instance linear until told otherwise, rather than whatever the allocation held.
methodBuffer.get(write = true).mapAndWrite_ints {
for (i in 0 until instances) put(GpuSkinning.METHOD_LINEAR)
}
}
fun placedPalette(model: Matrix4f, clip: String?, seconds: Float): List<Matrix4f>
This instance's palette at seconds of clip, with model multiplied in on the left, so
one entry is model · global(node, t) · inverseBind.
@param clip a clip name, or null for the rest pose.
fun placedPalette(model: Matrix4f, clip: String?, seconds: Float): List<Matrix4f> =
place(model, skin.skeleton.palette(clip?.let { skin.skeleton.clip(it) }, seconds))
fun blendedPalette
As placedPalette, but blending two clips — the per-instance clip blend. mix is 0 for all
clipA, 1 for all clipB.
The blend is a component-wise matrix lerp, which is what
GltfSkinning.Skeleton.blendedPalette does and therefore what the CPU oracle does. It is
not a slerp, and it is worth being exact about which problem method does and does not
fix. Dual-quaternion skinning fixes the blend between a vertex's influences — the
candy wrapper at a twisted joint. It does nothing for this blend, which is between two
clips' matrices for one joint, and happens before skinning sees the palette: a lerp of
two very different rotations is a matrix that has already shrunk, and
DualQuaternionSkinning.split then faithfully carries that shrink as a residual scale. For
clips that disagree by a large rotation, blend the clips' rotations (a slerp per joint)
rather than their matrices; that is not done here.
fun blendedPalette(
model: Matrix4f, clipA: String?, aSeconds: Float, clipB: String?, bSeconds: Float, mix: Float,
): List<Matrix4f> = place(
model,
skin.skeleton.blendedPalette(
clipA?.let { skin.skeleton.clip(it) }, aSeconds,
clipB?.let { skin.skeleton.clip(it) }, bSeconds, mix,
)
)
private fun place(model: Matrix4f, palette: List<Matrix4f>): List<Matrix4f> =
palette.map { Matrix4f(model).mul(it) }
fun uploadPalettes
Write one palette per instance. palettes.size must be at most instances, and every
entry must have boneCount matrices.
The whole per-frame CPU cost of an animating crowd.
@param methods the blend for each instance, methods[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) for palettes[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240). Defaults to
method for all of them. A dual-quaternion instance additionally costs one
DualQuaternionSkinning.split per bone here — a Gram-Schmidt on a 3×3 — which is still
O(instances × bones) and still nothing next to the vertices.
fun uploadPalettes(
palettes: List<List<Matrix4f>>,
methods: List<GltfSkinning.SkinningMethod> = List(palettes.size) { method },
) {
require(palettes.size <= instances) {
"${palettes.size} palettes for a skinner sized for $instances instances"
}
require(methods.size == palettes.size) {
"${methods.size} methods for ${palettes.size} palettes; give one per instance"
}
for ((i, p) in palettes.withIndex()) require(p.size == boneCount) {
"palette $i has ${p.size} matrices, this skeleton has $boneCount bones"
}
paletteBuffer.get(write = true).mapAndWrite {
for (p in palettes) for (m in p) {
for (n in 0 until 16) put(m[n])
}
}
var anyDual = false
for ((i, p) in palettes.withIndex()) {
methodScratch[i] = if (methods[i] == GltfSkinning.SkinningMethod.DUAL_QUATERNION) {
anyDual = true
GpuSkinning.packDualPalette(p, dualScratch, i * boneCount * GpuSkinning.DUAL_FLOATS_PER_JOINT)
} else {
GpuSkinning.METHOD_LINEAR
}
}
// An all-linear crowd skips the second palette write entirely.
if (anyDual) {
val floats = palettes.size * boneCount * GpuSkinning.DUAL_FLOATS_PER_JOINT
dualPaletteBuffer.get(write = true).mapAndWrite { for (n in 0 until floats) put(dualScratch[n]) }
}
methodBuffer.get(write = true).mapAndWrite_ints {
for (i in palettes.indices) put(methodScratch[i])
}
}
fun methodWords(count: Int = instances): IntArray = methodScratch.copyOf(count)
The method word the shader will see for each of the first count instances after the last
uploadPalettes — GpuSkinning.METHOD_LINEAR, or GpuSkinning.METHOD_DUAL_QUATERNION
with GpuSkinning.METHOD_RESIDUAL_BIT when that palette had a non-rigid joint.
fun uploadPalette(palette: List<Matrix4f>) = uploadPalettes(listOf(palette))
One palette, for the common single-character case.
The bind-pose vertex data and the indices, held here rather than on the GltfSupport.MeshInstance.
MeshInstance.applyToMesh would do this, and deliberately is not used. Two reasons, and
the second is the one that matters:
- Its buffers come from
defaultBuffer { … }.buffer, whose renderer defaults tofromContext(). Inside a box that resolves; from amain— which is where the evidence renderer and every smoke live — it throwsCannot invoke "afield.document.graph.Box.getProperties()". Holding them here with an explicit renderer makes one class work in both. applyToMeshre-uploads the whole vertex buffer on every call, because the CPU path has to. This path uploads it once, which is the entire point, so the upload belongs to the object whose lifetime matches it.
private val vertexBuffer: FrameBufferGroup = buffer(name = "${name}Vertices") {
size = vertexCount.toLong() * GpuSkinning.VERTEX_STRIDE_FLOATS * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
private val indexBuffer: FrameBufferGroup = buffer(name = "${name}Indices") {
size = instance.buffers.indexCount * 4L
storage(); addressable(); hosted(); dest()
}.toBuffer(renderer)
init
init {
// The bind pose, from the snapshot rather than from `instance.buffers`: a CpuSkinner
// sharing this MeshInstance may already have posed it in place, and uploading a posed
// mesh would make the GPU skin it a second time.
vertexBuffer.get(write = true).mapAndWrite { put(bindVertexData) }
indexBuffer.get(write = true).mapAndWrite_ints {
for (n in 0 until instance.buffers.indexCount) put(instance.buffers.indexData.get(n))
}
}
fun bindTo
Point mesh at this skinner and set every push constant
GpuSkinning.skinnedVertexShader needs. Call once; after this only uploadPalettes and, if
the camera moves, updateCamera are per-frame.
@param instanceCount how many characters to draw. Sets mesh.instanceCount, which
Mesh.kt already passes to vkCmdDraw — so a crowd is one draw call and needs no change
to standardRenderer.kt or to any renderer code.
@param tint multiply in a per-instance hue, so a crowd reads as a crowd.
fun bindTo(
mesh: Mesh,
camera: Camera = fromContext(),
instanceCount: Int = instances,
tint: Boolean = instances > 1,
) {
require(instanceCount in 1..instances) { "instanceCount $instanceCount is outside 1..$instances" }
mesh.initializePushConstantsFromModule(mesh.inside)
mesh.push["vertexOffset"] = 0
mesh.push["indexOffset"] = 0
mesh.push["vertexStride"] = GpuSkinning.VERTEX_STRIDE_FLOATS
mesh.push["materialBaseColor"] = instance.material.baseColor
mesh.push["useVertexColor"] = 1
mesh.push.bind("vertexBuffer") { vertexBuffer }
mesh.push.bind("indexBuffer") { indexBuffer }
mesh.push.bind("jointBuffer") { jointBuffer }
mesh.push.bind("weightBuffer") { weightBuffer }
mesh.push.bind("paletteBuffer") { paletteBuffer }
mesh.push.bind("dualPaletteBuffer") { dualPaletteBuffer }
mesh.push.bind("methodBuffer") { methodBuffer }
mesh.push["boneCount"] = boneCount
mesh.push["skinEnabled"] = 1
mesh.push["instanceTint"] = if (tint) 1 else 0
updateCamera(mesh, camera)
mesh.resize(instance.buffers.indexCount)
mesh.instanceCount = instanceCount
}
fun updateCamera(mesh: Mesh, camera: Camera = fromContext())
Push the camera. modelView is view alone, with no model matrix: glTF 2.0 §3.7.3.2
says a skinned mesh node's own transform is ignored, and each instance's placement is
already inside its palette. Composing a model matrix in here would apply it twice.
fun updateCamera(mesh: Mesh, camera: Camera = fromContext()) {
mesh.push["modelView"] = camera.m()
mesh.push["proj"] = camera.p()
}
fun uploadPosedVertices(mesh: Mesh)
Hand mesh back to the stock, un-skinned path: the bind pose as instance.buffers holds
it now, drawn by the skinEnabled == 0 branch.
This is how the evidence renderer photographs the CPU path through the identical pipeline: pose with a CpuSkinner, call this, and the shader passes the already-deformed vertices straight through.
fun uploadPosedVertices(mesh: Mesh) {
val f = instance.buffers.vertexData.asFloatBuffer()
vertexBuffer.get(write = true).mapAndWrite {
for (n in 0 until vertexCount * GpuSkinning.VERTEX_STRIDE_FLOATS) put(f.get(n))
}
mesh.push["skinEnabled"] = 0
}
fun restoreBindPose(mesh: Mesh)
Put the bind pose back in the GPU buffer and re-enable GPU skinning.
fun restoreBindPose(mesh: Mesh) {
vertexBuffer.get(write = true).mapAndWrite { put(bindVertexData) }
mesh.push["skinEnabled"] = 1
}
}