Skip to content

A twisted limb, weighed and traced

Twist a skinned limb about its own bone and linear blend skinning (each vertex follows a weighted average of its bones' transforms) squeezes the joint into a pinched waist that animators call a candy wrapper. How much volume does the limb lose? Let's pose a rigged glTF, turn its mesh into a Manifold solid, measure it, and send the same solid to the path tracer.

Field can also measure the narrowest ring with DualQuaternionSkinning.candyWrapperRadius. Here, afield.graphics.scene skins the file on the CPU, afield.geometry.solid.manifold makes and measures the solid, and afield.graphics.scene.fabricated traces it.

The render below shows the same limb skinned both ways.

Two twelve-sided tubes lying side by side on a pale floor with the Venice lagoon behind. The nearer cyan tube, posed by linear blend skinning at a half turn, narrows to a point at its middle like a twisted sweet wrapper; the tan tube behind it, posed by dual quaternion skinning, keeps its full width along its whole length

Both tubes are the same file at the same pose: the upper bone turned 180° about its own axis. The nearer, cyan tube was skinned by linear blending and the tan one behind it by dual quaternions. The cyan tube's middle ring has shrunk to a single point.

Environment: Venice Sunset by Greg Zaal / Poly Haven (CC0), shared with the Fabricate demo assets.

A rig that encloses something

RiggedFixture, the rig the skinning tests use, is an open tube with no caps, so it encloses no volume and Manifold will not take it as a solid. CappedTwistFixture is the same two-bone rig with both ends closed. Like RiggedFixture, it is written out by Kotlin source (CappedTwistFixture.kt) rather than downloaded:

  • 12 sides, radius 0.5, height 4, with rings every 0.25 along the bone and a centre vertex on each cap. That makes 206 vertices and 408 triangles, and every edge is shared by exactly two of them.
  • boneLower at the origin, boneUpper at y = 2. The weight to boneUpper is clamp((y − 1) / 2, 0, 1), so the joint zone runs from y = 1 to y = 3 and the ring at y = 2 is an even 50/50 blend.
  • One clip, twist, turns boneUpper from 0° to 180° about +y. The sampler slerps, so the angle is linear in time and secondsFor(degrees) converts.

At rest the tube is a straight prism over a regular 12-gon, and the volume has a closed form: area \( \tfrac{1}{2} \cdot 12 \cdot 0.5^2 \cdot \sin 30^\circ = 0.75 \), times height 4, is 3.0.

Volume against twist

This box poses the limb every fifteen degrees from rest to a half turn, under both skinning methods, and plots the enclosed volume Manifold reports for each pose.

:::static
import afield.geometry.solid.manifold.toSolid
import afield.graphics.scene.GltfSkinning.SkinningMethod
import afield.graphics.scene.GltfSkinning.SkinningOptions

val rig = CappedTwistFixture.locate()
val twists = (0..12).map { it * 15f }

// Pose the file, hand the triangles to Manifold, read back the enclosed volume.
fun volumeAt(degrees: Float, method: SkinningMethod): Double =
    skinnedGltfToTriMesh(
            rig,
            clip = "twist",
            seconds = CappedTwistFixture.secondsFor(degrees),
            options = SkinningOptions(method = method),
        )
        .toSolid()
        .use { it.volume }

:::fold sweep both methods and plot them against the rest volume
val lbs = twists.map { volumeAt(it, SkinningMethod.LINEAR_BLEND) }
val dqs = twists.map { volumeAt(it, SkinningMethod.DUAL_QUATERNION) }
showSignals(
    "linear blend" to lbs,
    "dual quaternion" to dqs,
    "rest, closed form" to twists.map { CappedTwistFixture.REST_VOLUME },
    x = twists,
    channel = "main",
    title = "Enclosed volume at 180°: linear blend %.4f, dual quaternion %.4f".format(lbs.last(), dqs.last()),
    xTitle = "twist of boneUpper about its own axis (degrees)",
    yTitle = "Manifold volume",
)
:::endfold

A line plot of enclosed volume against twist angle from 0 to 180 degrees. The linear-blend trace falls smoothly from 3.0 to 2.0; the dual-quaternion trace rises very slightly above the flat rest line at 3.0 and eases back

Under linear blending the volume falls at every step. Under dual quaternions it goes slightly up. These are the two familiar artefacts of the methods, a pinch and a bulge, seen as volume.

Four poses, weighed twice

This box measures four poses with two independent volume calculations, Manifold's and Field's own signedVolume, alongside the waist radius candyWrapperRadius reports.

:::static
import afield.geometry.solid.manifold.toSolid
import afield.graphics.scene.GltfSkinning.SkinningMethod
import afield.graphics.scene.GltfSkinning.SkinningOptions

val rig = CappedTwistFixture.locate()

:::fold measure rest and a half turn, both methods, both volume implementations
fun row(label: String, degrees: Float, method: SkinningMethod): String {
    val mesh = skinnedGltfToTriMesh(
        rig,
        clip = "twist",
        seconds = CappedTwistFixture.secondsFor(degrees),
        options = SkinningOptions(method = method),
    )
    val waist = DualQuaternionSkinning.candyWrapperRadius(
        mesh.positions,
        CappedTwistFixture.UPPER_JOINT_Y,
        tolerance = 0.01f,
    )
    return mesh.toSolid().use { solid ->
        val field = mesh.signedVolume()
        "| $label | ${mesh.isWatertight()} | ${solid.genus} | %.6f | %.6f | %.1e | %.4f | %.4f |"
            .format(
                solid.volume,
                field,
                kotlin.math.abs(solid.volume - field),
                solid.volume / CappedTwistFixture.REST_VOLUME,
                waist,
            )
    }
}

markdown(
    "chain",
    """
| pose | watertight | genus | Manifold volume | `signedVolume` | gap | of rest | waist radius |
|---|---|---|---|---|---|---|---|
${row("rest", 0f, SkinningMethod.LINEAR_BLEND)}
${row("linear blend, 179.9°", 179.9f, SkinningMethod.LINEAR_BLEND)}
${row("linear blend, 180°", 180f, SkinningMethod.LINEAR_BLEND)}
${row("dual quaternion, 180°", 180f, SkinningMethod.DUAL_QUATERNION)}

Closed forms: rest ${CappedTwistFixture.REST_VOLUME}; linear blend at 180°, 0.75 × (2 + 2/3) = ${(0.75 * (2.0 + 2.0 / 3.0)).f6}.
""",
    width = 1000,
)
Unit
:::endfold

chain-skinned-volume-readout

Manifold's volume and Field's signedVolume share no code. One integrates Manifold's own half-edge solid in double precision, the other sums signed tetrahedra over the float triangles. Where they agree, the skinned mesh reached Manifold intact.

The two thirds that linear blending keeps at 180° can be worked out by hand. At the 50/50 ring the blend averages the identity with its own half turn, \( \tfrac{1}{2}\operatorname{diag}(1,1,1) + \tfrac{1}{2}\operatorname{diag}(-1,1,-1) = \operatorname{diag}(0,1,0) \), so every vertex of that ring lands on the axis. In general, a ring whose weight is w gets \( \operatorname{diag}(1 - 2w, 1, 1 - 2w) \): a pure scale with no rotation left in it. Its scale \( 1 - 2w = 2 - y \) is linear in y, so each band of the joint zone is a frustum of two similar 12-gons. The zone then integrates exactly to \( 0.75 \cdot \int_1^3 (2 - y)^2\,dy = 0.75 \cdot \tfrac{2}{3} \), and the two unblended units keep their 0.75 each. The total is 2.0, so a third of the limb is gone, and none of that loss comes from how the tube happens to be triangulated.

Why the 180° row has a gap the others do not

At 180° exactly, the 50/50 ring does not quite shrink to a point. Float rounding in the half-turn quaternion leaves it about 2·10⁻⁷ across, which is under the tolerance Manifold gives a mesh of this size (about 5·10⁻⁷). Manifold keeps every vertex and every triangle it was handed, but it swaps edges in that collar, so the volume it reports is the volume of its triangulation. At 179.9° the ring is well above tolerance and the gap disappears. If a measured volume matters at a degenerate pose, compare it with signedVolume on the mesh you handed over.

What crosses each hop

hop the call what crosses what stops here
file to posed triangles skinnedGltfToTriMesh with SkinningOptions(method = …) positions and normals, scene space, one vertex per file vertex the skeleton, the weights and the clip. What comes out is a plain TriMesh
triangles to a solid toSolid positions and indices the normals. Manifold gets three floats per vertex and nothing else
a solid back to triangles toTriMesh(normals = true) positions, indices, normals split at a crease angle shared vertices along the creases. Measure the solid, not this mesh
triangles to a scene importTriMesh positions, normals, one material, a transform zero-area triangles, which are counted and skipped

Linear blending and dual quaternions are the same call with a different SkinningOptions, so the comparison above runs both on one file. The only difference between the two tubes in the render is that method argument.

Where each piece is documented

Why the fixture has so many rings

RiggedFixture's tube has five rings, and only the middle one is blended. At a half turn, dual quaternions rotate that ring 90° against each neighbour, and a straight triangulated band between two 12-gons 90° apart passes within \( r/\sqrt{2} \) of the axis, so the triangulation alone would lose about a third of those bands' volume. Seventeen rings spread the joint's rotation over eight bands, 22.5° each at the worst pose, so the volume measures the skinning and not the mesh.