Skip to content

SkinningEvidence.kt

src/jvmMain/kotlin/afield/graphics/scene/SkinningEvidence.kt · 270 lines

@file:Suppress("unused")

The evidence run behind Field's CPU glTF skinning: ./gradlew --no-daemon runSkinningEvidence.

It writes the rigged fixture out as glTF, loads it back through the real Assimp path, and prints what came back with the population each number was measured over: the skeleton and its clips, the influence weights after the four-wide cap, whether the bind pose is bit-identical to the unskinned mesh, and the displacement through the bend. A second, harder fixture covers the cases that are easy to get wrong: a translated node that glTF says to ignore for a skinned mesh, a shear whose correct vertex normal is known in closed form, and a vertex with six influences for the cap to discard from. The run ends with a throughput figure in nanoseconds per vertex, with and without exact inverse-transpose normals.

Frames go to dev_docs/agents/evidence/skinning/ for a person to look at, named by SkinningEvidence.frames so the sequence reads in sorted order, and the numbers are the ones dev_docs/SKINNED_CHARACTERS.md quotes. GpuSkinningEvidence is the same exercise for the compute-shader path.

package afield.graphics.scene

import afield.graphics.render.before
import afield.graphics.render.main

import afield.geometry.mesh.MeshView
import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.TriMeshBuilder
import afield.math.linalg.Vec3
import afield.geometry.mesh.bounds
import afield.geometry.mesh.renderPng
import java.io.File

object SkinningEvidence

Regenerate the rigged fixture, measure the skinning, and write frames a person can look at.

./gradlew --no-daemon runSkinningEvidence

This exists because "it compiles" is not verification for anything visual. It writes dev_docs/agents/fixtures/skinning/bending_cylinder.gltf from RiggedFixture, loads it back through the real Assimp path, prints every number the dev_docs page quotes with its population, and renders the deformation at several times into dev_docs/agents/evidence/skinning/.

The camera is pinned across frames by merging a pair of tiny triangles at fixed corners into every mesh before rendering: MeshView.distance is in multiples of the mesh's own bounding-sphere radius and VisionGeometryPreview frames the mesh's own bounds, so without an anchor each frame would re-frame itself and a sequence would show a cylinder that never appears to move.

object SkinningEvidence {

const val FIXTURE: String = RiggedFixture.COMMITTED_PATH

const val EVIDENCE_DIR: String = "dev_docs/agents/evidence/skinning"

fun anchor

Two 2cm triangles at opposite corners of a fixed box, so every frame in a sequence is framed identically. Invisible at the sizes here, and outside the cylinder so they never occlude it.

    fun anchor(
        minX: Float = -3f, minY: Float = -0.5f, minZ: Float = -2f,
        maxX: Float = 1.5f, maxY: Float = 4.5f, maxZ: Float = 2f,
    ): TriMesh {
        val b = TriMeshBuilder()
        val s = 0.02f
        for ((x, y, z) in listOf(Triple(minX, minY, minZ), Triple(maxX, maxY, maxZ))) {
            val i = b.vertexCount
            b.vertex(x, y, z, normal = Vec3(0f, 1f, 0f))
            b.vertex(x + s, y, z, normal = Vec3(0f, 1f, 0f))
            b.vertex(x, y, z + s, normal = Vec3(0f, 1f, 0f))
            b.triangle(i, i + 1, i + 2)
        }
        return b.build()
    }

fun main(args: Array<String>)

body — 182 lines, from line 74
    @JvmStatic
    fun main(args: Array<String>) {
        val fixture = File(FIXTURE)
        fixture.parentFile?.mkdirs()
        fixture.writeText(RiggedFixture.bendingCylinderGltf())
        println("fixture: ${fixture.path} (${fixture.length()} bytes)")

        // ---------------------------------------------------------------- what Assimp read back
        val model = GltfSupport.loadGltfBasic(FIXTURE, GltfSupport.GltfLoadConfig(loadTextures = false))
        val instance = model.meshes.first { it.skin != null }
        val skin = instance.skin!!
        val sk = skin.skeleton
        println()
        println("=== loader ===")
        println("meshes in file            : ${model.meshes.size}")
        println("skinned mesh              : ${instance.name}, ${instance.buffers.vertexCount} vertices, " +
            "${instance.buffers.indexCount / 3} triangles")
        println("scene nodes               : ${sk.nodes.size} -> ${sk.nodes.map { it.name }}")
        println("node parents              : ${sk.nodes.map { it.parent }}")
        println("bones                     : ${sk.bones.size} -> ${sk.bones.map { "${it.name}@node${it.node}" }}")
        println("clips                     : ${sk.clips.map { "${it.name} ${"%.3f".format(it.durationSeconds)}s " +
            "${it.channels.size}ch ${it.channels.first().samples.size} samples" }}")
        for (b in sk.bones) println("inverseBind ${b.name.padEnd(10)}: ${b.inverseBind}")

        // ---------------------------------------------------------------- weights
        val infl = skin.influences
        println()
        println("=== weights (population: all ${infl.vertexCount} vertices of the skinned mesh) ===")
        println("worst |sum(w) - 1|        : ${infl.worstWeightSumError()}")
        println("vertices with no influence: ${infl.unweightedVertices()}")
        println("influences discarded by the 4-wide cap: ${infl.discardedInfluences} " +
            "(largest discarded weight ${infl.largestDiscardedWeight})")
        val distinct = (0 until infl.vertexCount)
            .map { v -> (0 until GltfSkinning.MAX_INFLUENCES).map { infl.weight[v * 4 + it] } }
            .distinct()
        println("distinct weight tuples    : ${distinct.size} -> $distinct")

        // ---------------------------------------------------------------- bind-pose identity
        val rigid = assimpToTriMesh(FIXTURE)
        val bind = skinnedGltfToTriMesh(FIXTURE, clip = "bend", seconds = 0f)
        println()
        println("=== bind pose (population: all ${rigid.vertexCount} vertices) ===")
        println("unskinned vertices        : ${rigid.vertexCount}")
        println("skinned-at-t0 vertices    : ${bind.vertexCount}")
        var exact = 0
        var worst = 0f
        for (v in 0 until minOf(rigid.vertexCount, bind.vertexCount)) {
            val a = rigid.position(v); val b = bind.position(v)
            if (a.x == b.x && a.y == b.y && a.z == b.z) exact++
            worst = maxOf(worst, maxOf(kotlin.math.abs(a.x - b.x), kotlin.math.abs(a.y - b.y), kotlin.math.abs(a.z - b.z)))
        }
        println("bit-identical vertices    : $exact / ${rigid.vertexCount}")
        println("worst component difference: $worst")

        // ---------------------------------------------------------------- deformation
        println()
        println("=== deformation (clip 'bend') ===")
        for (t in listOf(0f, 0.25f, 0.5f, 0.75f, 1f)) {
            val m = skinnedGltfToTriMesh(FIXTURE, clip = "bend", seconds = t)
            var maxD = 0f
            var tipX = 0f
            var tipCount = 0
            for (v in 0 until m.vertexCount) {
                val a = rigid.position(v); val b = m.position(v)
                val d = kotlin.math.sqrt(
                    (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + (a.z - b.z) * (a.z - b.z)
                )
                if (d > maxD) maxD = d
                // The tip ring: y = 4 in the bind pose, weight 1 to boneUpper.
                if (kotlin.math.abs(a.y - 4f) < 1e-4f) { tipX += b.x; tipCount++ }
            }
            println("t=%.2f  max |displacement| = %.6f   mean tip x = %.6f  (over %d tip vertices of %d)"
                .format(t, maxD, tipX / tipCount, tipCount, m.vertexCount))
        }

        // ---------------------------------------------------------------- frames
        val out = File(EVIDENCE_DIR)
        out.mkdirs()
        println()
        println("=== frames ===")
        for ((name, spec) in frames()) {
            val mesh = spec()
            val png = (mesh + anchor()).renderPng(
                MeshView(yawDegrees = 0.0, pitchDegrees = 8.0, distance = 2.4, width = 560, height = 560),
            )
            val f = File(out, name)
            f.writeBytes(png)
            val b = mesh.bounds()
            println("${f.path}  ${png.size} bytes  x in [%.3f, %.3f]  y in [%.3f, %.3f]"
                .format(b.min.x, b.max.x, b.min.y, b.max.y))
        }
        // ---------------------------------------------------------------- the adversarial fixture
        val stress = java.io.File(StressFixture.COMMITTED_PATH)
        stress.parentFile?.mkdirs()
        stress.writeText(StressFixture.shearAndCapGltf())
        println()
        println("=== stress fixture: ${stress.path} (${stress.length()} bytes) ===")
        val sModel = GltfSupport.loadGltfBasic(StressFixture.COMMITTED_PATH, GltfSupport.GltfLoadConfig(loadTextures = false))
        val sSkin = sModel.meshes.firstNotNullOf { it.skin }
        println("nodes  ${sSkin.skeleton.nodes.map { it.name }}")
        println("bones  ${sSkin.skeleton.bones.map { it.name }}")
        val si = sSkin.influences
        println("population: all ${si.vertexCount} vertices")
        println("influences discarded by the 4-wide cap: ${si.discardedInfluences}, " +
            "largest discarded weight ${si.largestDiscardedWeight}")
        println("worst |sum(w) - 1| after the cap and renormalisation: ${si.worstWeightSumError()}")
        for (v in 0 until si.vertexCount) {
            println("  vertex $v joints ${(0..3).map { si.jointIndex[v * 4 + it] }} " +
                "weights ${(0..3).map { si.weight[v * 4 + it] }}")
        }

        val sRest = posedGltfToTriMesh(StressFixture.COMMITTED_PATH) { it.palette(null, 0f) }
        val sShear = skinnedGltfToTriMesh(StressFixture.COMMITTED_PATH, clip = "shear", seconds = 1f)
        val sApprox = skinnedGltfToTriMesh(
            StressFixture.COMMITTED_PATH, clip = "shear", seconds = 1f,
            options = GltfSkinning.SkinningOptions(exactNormalMatrix = false),
        )
        val sRigid = assimpToTriMesh(StressFixture.COMMITTED_PATH)
        println()
        println("plate node is translated (0, 10, 0), which glTF says to IGNORE for a skinned mesh:")
        println("  skinned at rest, plate y in [%.4f, %.4f]".format(
            (0..3).minOf { sRest.position(it).y }, (0..3).maxOf { sRest.position(it).y }))
        println("  assimpToTriMesh (rigid path, applies it) y in [%.4f, %.4f]".format(
            (0 until sRigid.vertexCount).minOf { sRigid.position(it).y },
            (0 until sRigid.vertexCount).maxOf { sRigid.position(it).y }))
        println()
        val face = sShear.faceNormal(0)
        fun dot(a: Vec3, b: Vec3) = a.x * b.x + a.y * b.y + a.z * b.z
        println("sheared plate face normal        ${face}  (predicted ${StressFixture.shearedNormal.toList()})")
        println("exact inverse-transpose vertex normal dot face = %.6f".format(dot(sShear.normal(0)!!, face)))
        println("plain blended-3x3 vertex normal  dot face = %.6f  (predicted 0.8)".format(dot(sApprox.normal(0)!!, face)))
        println()
        val dx = sShear.position(4).x - sRest.position(4).x
        println("six-influence triangle x displacement = %.6f".format(dx))
        println("  capped prediction   %.6f".format(StressFixture.CAPPED_DISPLACEMENT))
        println("  uncapped prediction %.6f".format(StressFixture.UNCAPPED_DISPLACEMENT))

        // Two more frames: the shear is a thing you can see, not only a dot product.
        val stressAnchor = anchor(minX = -1.6f, minY = -0.4f, minZ = -1.4f, maxX = 6.6f, maxY = 3.4f, maxZ = 1.4f)
        val stressView = MeshView(yawDegrees = 16.0, pitchDegrees = 14.0, distance = 2.1, width = 700, height = 420)
        for ((name, mesh) in listOf("shear_rest.png" to sRest, "shear_t100.png" to sShear)) {
            val f = File(out, name)
            f.writeBytes((mesh + stressAnchor).renderPng(stressView))
            println("${f.path}  ${f.length()} bytes")
        }

        // ---------------------------------------------------------------- throughput
        // Not a benchmark -- one JVM, one warm-up, wall clock -- but a number is better than a
        // shrug when someone asks whether CPU skinning is fast enough to drive from a slider.
        run {
            val big = GltfSupport.loadGltfBasic(FIXTURE, GltfSupport.GltfLoadConfig(loadTextures = false))
            val inst = big.meshes.first { it.skin != null }
            val sk2 = inst.skin!!
            val n = sk2.influences.vertexCount
            val pos = FloatArray(n * 3); val nrm = FloatArray(n * 3)
            val fb = inst.buffers.vertexData.asFloatBuffer()
            for (v in 0 until n) {
                for (k in 0..2) { pos[v * 3 + k] = fb.get(v * 12 + k); nrm[v * 3 + k] = fb.get(v * 12 + 3 + k) }
            }
            val pal = sk2.skeleton.palette(sk2.skeleton.clip("bend"), 1f)
            repeat(2000) { GltfSkinning.skin(pos, nrm, sk2.influences, pal) }
            val reps = 20000
            val t0 = System.nanoTime()
            repeat(reps) { GltfSkinning.skin(pos, nrm, sk2.influences, pal) }
            val ns = (System.nanoTime() - t0).toDouble()
            println()
            println("=== throughput (one JVM, 2000 warm-up + $reps timed calls of $n vertices) ===")
            println("%.1f ns per vertex, %.2f M vertices/s, exact inverse-transpose normals on"
                .format(ns / (reps.toDouble() * n), reps.toDouble() * n / ns * 1000.0))
            val t1 = System.nanoTime()
            val plain = SkinningOptionsPlain
            repeat(reps) { GltfSkinning.skin(pos, nrm, sk2.influences, pal, plain) }
            val ns1 = (System.nanoTime() - t1).toDouble()
            println("%.1f ns per vertex, %.2f M vertices/s, with exactNormalMatrix = false"
                .format(ns1 / (reps.toDouble() * n), reps.toDouble() * n / ns1 * 1000.0))
        }

        println()
        println("done")
    }

    private val SkinningOptionsPlain = GltfSkinning.SkinningOptions(exactNormalMatrix = false)

fun frames(): List<Pair<String, () -> TriMesh>> = listOf

The frames written by main, named so the sequence reads in sorted order.

    fun frames(): List<Pair<String, () -> TriMesh>> = listOf(
        "bend_t000.png" to { skinnedGltfToTriMesh(FIXTURE, clip = "bend", seconds = 0f) },
        "bend_t050.png" to { skinnedGltfToTriMesh(FIXTURE, clip = "bend", seconds = 0.5f) },
        "bend_t100.png" to { skinnedGltfToTriMesh(FIXTURE, clip = "bend", seconds = 1f) },
        "curl_t100.png" to { skinnedGltfToTriMesh(FIXTURE, clip = "curl", seconds = 1f) },
        "blend_bend_curl.png" to {
            posedGltfToTriMesh(FIXTURE) { s ->
                s.blendedPalette(s.clip("bend"), 1f, s.clip("curl"), 1f, 0.5f)
            }
        },
    )
}