Skip to content

SkinnedMeshFromDisk.kt

src/jvmMain/kotlin/afield/graphics/scene/SkinnedMeshFromDisk.kt · 294 lines

@file:Suppress("unused")

package afield.graphics.scene

import afield.graphics.render.before

import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.TriMeshBuilder
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Matrix4f
import org.lwjgl.system.MemoryUtil.memFree

Posing a rigged glTF on the CPU: into a fresh TriMesh, or back into the vertex buffer a loaded model already owns.

Which of the two you want depends on what happens next. skinnedGltfToTriMesh loads the file, deforms it at one time and hands back geometry, which suits measuring a pose, feeding the software rasteriser, or treating a frame as data. CpuSkinner wraps a mesh that is already loaded for drawing and overwrites its positions and normals in place, so the stock glTF shaders draw a moving character without knowing that anything is being skinned.

posedGltfToTriMesh is skinnedGltfToTriMesh with the joint palette supplied by the caller, for a clip blend or a pose an IK solver produced, and gltfClipNames reads the clip names out of a file without deforming anything.

The blend itself is GltfSkinning's; what this file adds is the plumbing on either side of it. GpuSkinner moves the same work into the vertex shader, which is the path to take when the character has to animate every frame.

fun skinnedGltfToTriMesh

A rigged file, deformed at a time, as a TriMesh you can render, measure and look at.

assimpToTriMesh is the rigid sibling of this: it applies each node's own world matrix and hands back the bind pose. This one ignores the mesh node's transform — glTF 2.0 §3.7.3.2 says a skinned mesh node's transform is not applied — and instead runs GltfSkinning.skin with the joint palette at seconds, so what comes back is the deformed surface in scene space.

A mesh in the file with no skin is not dropped: it goes through the rigid path, so a scene that is part character and part prop arrives whole.

val bent = skinnedGltfToTriMesh(RiggedFixture.locate(), clip = "bend", seconds = 1.0f)
showMesh(bent)

@param clip which animation to evaluate, by name. null is the rest pose — which, for a file whose first keys are the bind transforms, is the bind pose and should be identical to assimpToTriMesh's output vertex for vertex. @param seconds the time within the clip. Clamped at both ends; this does not loop, because a caller that wants looping wants to choose the phase itself.

fun skinnedGltfToTriMesh(
    path: String,
    clip: String? = null,
    seconds: Float = 0f,
    keepColors: Boolean = true,
    options: GltfSkinning.SkinningOptions = GltfSkinning.SkinningOptions(),
): TriMesh = posedGltfToTriMesh(path, keepColors, options) { skeleton ->
    skeleton.palette(clip?.let { skeleton.clip(it) }, seconds)
}

fun posedGltfToTriMesh

The same, with the palette chosen by the caller — for a clip blend, a pose driven by an IK solver, or anything else that is not "clip x at time t".

A different name rather than an overload of skinnedGltfToTriMesh, deliberately. Box code is compiled by the K2 REPL against a different import set from jvmMain, and an overload pair that differs only in a trailing lambda is exactly the shape that resolves in the library and goes ambiguous in a box — where no Gradle gate can see it.

@param palette called once per skinned mesh with that mesh's skeleton; must return one matrix per entry of GltfSkinning.Skeleton.bones, in that order. GltfSkinning.Skeleton.palette and GltfSkinning.Skeleton.blendedPalette are the two ready-made ones.

body — 97 lines, from line 78
fun posedGltfToTriMesh(
    path: String,
    keepColors: Boolean = true,
    options: GltfSkinning.SkinningOptions = GltfSkinning.SkinningOptions(),
    palette: (GltfSkinning.Skeleton) -> List<Matrix4f>,
): TriMesh {
    val model = GltfSupport.loadGltfBasic(path, GltfSupport.GltfLoadConfig(loadTextures = false))
    try {
        var out: TriMesh? = null
        for (instance in model.meshes) {
            val b = instance.buffers
            if (b.indexCount < 3) continue
            val floats = b.vertexData.asFloatBuffer()

            // The packed layout the loader writes: [pos.xyz][normal.xyz][uv.xy][color.rgba].
            val positions = FloatArray(b.vertexCount * 3)
            val normals = FloatArray(b.vertexCount * 3)
            var whiteEverywhere = true
            var anyUv = false
            val rows = ArrayList<FloatArray>(b.vertexCount)
            for (v in 0 until b.vertexCount) {
                val o = v * 12
                val row = FloatArray(12) { floats.get(o + it) }
                positions[v * 3] = row[0]; positions[v * 3 + 1] = row[1]; positions[v * 3 + 2] = row[2]
                normals[v * 3] = row[3]; normals[v * 3 + 1] = row[4]; normals[v * 3 + 2] = row[5]
                if (row[8] != 1f || row[9] != 1f || row[10] != 1f) whiteEverywhere = false
                if (row[6] != 0f || row[7] != 0f) anyUv = true
                rows.add(row)
            }

            val skin = instance.skin
            val deformedP: FloatArray
            val deformedN: FloatArray
            val mirrors: Boolean
            if (skin != null) {
                val skinned = GltfSkinning.skin(positions, normals, skin.influences, palette(skin.skeleton), options)
                deformedP = skinned.position
                deformedN = skinned.normal ?: normals
                // A skinned mesh's winding is decided per triangle by the blended matrix, not by one
                // scene determinant, and a joint palette of rigid transforms never flips it. Left
                // alone rather than guessed at; a mirroring *joint* would need per-triangle work.
                mirrors = false
            } else {
                // Rigid fallback, matching assimpToTriMesh exactly: the node's true world matrix, and
                // the inverse transpose for the normal.
                val m = instance.bindMatrix ?: instance.bindPose.toMatrix()
                val nm = m.get3x3(afield.math.linalg.Matrix3f()).invert().transpose()
                deformedP = FloatArray(b.vertexCount * 3)
                deformedN = FloatArray(b.vertexCount * 3)
                val p = afield.math.linalg.Vector3f()
                val n = afield.math.linalg.Vector3f()
                for (v in 0 until b.vertexCount) {
                    p.set(positions[v * 3], positions[v * 3 + 1], positions[v * 3 + 2]); m.transformPosition(p)
                    n.set(normals[v * 3], normals[v * 3 + 1], normals[v * 3 + 2]); nm.transform(n)
                    val len = kotlin.math.sqrt(n.x * n.x + n.y * n.y + n.z * n.z)
                    deformedP[v * 3] = p.x; deformedP[v * 3 + 1] = p.y; deformedP[v * 3 + 2] = p.z
                    if (len > 1e-12f) {
                        deformedN[v * 3] = n.x / len; deformedN[v * 3 + 1] = n.y / len; deformedN[v * 3 + 2] = n.z / len
                    } else {
                        deformedN[v * 3 + 1] = 1f
                    }
                }
                mirrors = m.determinant() < 0f
            }

            val emitColors = keepColors && !whiteEverywhere
            val builder = TriMeshBuilder()
            for (v in 0 until b.vertexCount) {
                val row = rows[v]
                builder.vertex(
                    deformedP[v * 3], deformedP[v * 3 + 1], deformedP[v * 3 + 2],
                    normal = Vec3(deformedN[v * 3], deformedN[v * 3 + 1], deformedN[v * 3 + 2]),
                    uv = if (anyUv) Vec2(row[6], row[7]) else null,
                    color = if (emitColors) Vec3(row[8], row[9], row[10]) else null,
                )
            }
            val indices = b.indexData
            var t = 0
            while (t + 2 < b.indexCount) {
                val a = indices.get(t); val bb = indices.get(t + 1); val c = indices.get(t + 2)
                if (a < b.vertexCount && bb < b.vertexCount && c < b.vertexCount) {
                    if (mirrors) builder.triangle(a, c, bb) else builder.triangle(a, bb, c)
                }
                t += 3
            }
            val part = builder.build()
            out = out?.plus(part) ?: part
        }
        return out ?: TriMesh.empty
    } finally {
        for (instance in model.meshes) {
            memFree(instance.buffers.vertexData)
            memFree(instance.buffers.indexData)
            instance.buffers.tangentData?.let { memFree(it) }
        }
    }
}

fun gltfClipNames(path: String): List<String>

The clip names in a rigged file, in file order. Empty for a file with no animations.

fun gltfClipNames(path: String): List<String> {
    val model = GltfSupport.loadGltfBasic(path, GltfSupport.GltfLoadConfig(loadTextures = false))
    try {
        return model.meshes.firstNotNullOfOrNull { it.skin?.skeleton?.clips?.map { c -> c.name } } ?: emptyList()
    } finally {
        for (instance in model.meshes) {
            memFree(instance.buffers.vertexData)
            memFree(instance.buffers.indexData)
            instance.buffers.tangentData?.let { memFree(it) }
        }
    }
}

class CpuSkinner

CPU skinning straight into the GPU vertex buffer a loaded MeshInstance already owns — so the bundled glTF shaders draw a deforming character with no shader change at all.

GltfSupport.MeshInstance.buffers.vertexData is plain host memory in the fixed 12-float layout `pos.xyzuv.xy, andapplyToMesh` uploads it. Overwriting the position and normal fields in place before each upload is therefore the whole of GPU-rendered skeletal animation, at GltfSkinning's measured 17.7 M vertices/s. A real GPU skinning path — a joint palette buffer and a blend in the vertex shader — saves the upload and is the obvious next step, but it cannot produce a picture this does not.

The bug this class exists to make impossible

Skinning in place is not idempotent if you read what you last wrote: the second frame would skin an already-skinned mesh and the character would fly apart over a few seconds — slowly enough to look like a physics problem rather than a bookkeeping one. So the bind-pose positions and normals are snapshotted at construction and every pose call reads that snapshot, never the buffer. CpuSkinnerTest.posingTwiceWithTheSamePaletteGivesTheSameAnswer is the assertion.

val (module, models) = gltfModelHere("character.glb") { }
val skinner = CpuSkinner(models[0].meshes[0])
loop {
    skinner.pose("walk", time)
    models[0].meshes[0].applyToMesh(mesh, camera)   // but see the warning below
}

One thing in the way of that loop, read off the code and not measured on a device

MeshInstance.applyToMesh calls defaultBuffer { … }.buffer for its vertex and index buffers. defaultBuffer builds a fresh BufferParameters on every call, and BufferParameters.result — the cache that makes toBuffer() return the same allocation twice — lives on that object. So a per-frame applyToMesh allocates a new FrameBufferGroup and a new VulkanBuffer every frame. Fine for the once-at-setup call the documentation shows; not fine for a sixty-times-a-second re-upload.

Making this loop right therefore wants the two BufferParameters hoisted onto the MeshInstance so their result caches across frames, and applyToMesh writing into them rather than rebuilding them. That has not been done, because it cannot be verified without a device and this branch had no GPU verification path; the deformation itself is exercised through the CPU rasterizer and the path tracer instead. Deforming into the buffer — the part this class does — is independent of that and is unit-tested by CpuSkinnerTest.

@throws IllegalArgumentException if the instance has no skin — a rigid mesh needs no skinner, and silently doing nothing would be the wrong answer to a typo in a filename.

class CpuSkinner(
    val instance: GltfSupport.MeshInstance,
    val options: GltfSkinning.SkinningOptions = GltfSkinning.SkinningOptions(),
) {

val skin: GltfSkinning.MeshSkin

The mesh's skin; non-null by construction.

    val skin: GltfSkinning.MeshSkin = requireNotNull(instance.skin) {
        "MeshInstance '${instance.name}' has no skin; CpuSkinner has nothing to deform"
    }

val clips: List<String> get() = skin.skeleton.clips.map { it.name }

Clip names available on this mesh's skeleton, in file order.

    private val vertexCount = instance.buffers.vertexCount
    private val bindPositions = FloatArray(vertexCount * 3)
    private val bindNormals = FloatArray(vertexCount * 3)

init

    init {
        val f = instance.buffers.vertexData.asFloatBuffer()
        for (v in 0 until vertexCount) {
            for (k in 0..2) {
                bindPositions[v * 3 + k] = f.get(v * 12 + k)
                bindNormals[v * 3 + k] = f.get(v * 12 + 3 + k)
            }
        }
    }

fun pose(palette: List<Matrix4f>): GltfSkinning.SkinnedVertices

Deform to palette — one matrix per entry of skin.skeleton.bones — and write the result into the instance's vertex buffer, overwriting only the position and normal fields.

    fun pose(palette: List<Matrix4f>): GltfSkinning.SkinnedVertices {
        val out = GltfSkinning.skin(bindPositions, bindNormals, skin.influences, palette, options)
        val f = instance.buffers.vertexData.asFloatBuffer()
        for (v in 0 until vertexCount) {
            for (k in 0..2) {
                f.put(v * 12 + k, out.position[v * 3 + k])
                f.put(v * 12 + 3 + k, (out.normal ?: bindNormals)[v * 3 + k])
            }
        }
        return out
    }

fun pose(clip: String?, seconds: Float): GltfSkinning.SkinnedVertices

Deform to clip clip at seconds. null is the rest pose.

    fun pose(clip: String?, seconds: Float): GltfSkinning.SkinnedVertices =
        pose(skin.skeleton.palette(clip?.let { skin.skeleton.clip(it) }, seconds))

fun reset()

Put the bind pose back, bit for bit — the snapshot is written straight through.

    fun reset() {
        val f = instance.buffers.vertexData.asFloatBuffer()
        for (v in 0 until vertexCount) {
            for (k in 0..2) {
                f.put(v * 12 + k, bindPositions[v * 3 + k])
                f.put(v * 12 + 3 + k, bindNormals[v * 3 + k])
            }
        }
    }
}