Skinned and morphed meshes

three's skinning and morph math in a TSL compute pass, feeding the GPU refit, with nothing read back.

three skins in the vertex shader, so its posed vertices never exist in a buffer. With a renderer, the sync runs a compute pass per posed mesh (BvhPosePass) that writes them into a buffer of ours, and SceneTrees.setPose refits the tree from it on the GPU.

import type { Scene } from 'three/webgpu';

// With a renderer, every SkinnedMesh and morph-target mesh is posed on the GPU (BvhPosePass).
const sync = new SceneTreesObject3DSync(trees, scene, { renderer });
await sync.ready; // the pose pipelines are compiled: every object is posed from the first update

// Each frame, after the animation mixer:
scene.updateMatrixWorld();
sync.update(); // transforms, then each visible posed object's pass, then SceneTrees.setPose
const encoder = device.createCommandEncoder();
trees.update(encoder); // refits the posed trees on the GPU: nothing is read back
device.queue.submit([encoder.finish()]);
console.log(sync.poseCounts); // { posed, boundsMoved, waiting }

Which meshes

With a renderer, every SkinnedMesh and every mesh with position morph targets (threeDeforms(mesh)), unless deforming: false or a (mesh) => boolean says otherwise. Without a renderer they stay in their rest pose.

Not posed: BatchedMesh, and an InstancedMesh with per-instance morphs (morphTexture) and more than one instance (the sync warns once and reads its rest pose). A posed object gets its own mesh per triangle range; its instances never merge. Hidden objects aren't posed.

The pass

BvhPosePass runs three r187's math in three's order: morphs as morphReference does them, then skinning as getSkinnedPosition does it, after skeleton.update(). It writes one vec4 per vertex. On the Apple M5 the posed vertices match three's CPU pose (getVertexPosition, applyBoneTransform) to 6.4e-7.

  • Compile cost: one shader per variant (skinned, morphed, both), shared by every posed mesh of that variant. The sync compiles each variant when its first mesh is added. Until the compile settles, update() doesn't pose that variant's objects (sync.poseCounts.waiting); await sync.ready has every object posed from the first update.
  • Inputs are read once: rest positions, skin indices and weights and morph targets are packed when the pass is made. A new geometry object gets a new pass at sync(); editing those attributes in place is not followed.
  • A replaced skeleton gets a new pass at the next update().

The top-level box follows the pose

The top level keeps one box per deforming mesh. The sync sets it (SceneTrees.setDeformBounds) from pass.bounds(), a box that holds the pose, computed from the skeleton on the CPU. It moves only when the pose leaves the box, or the box grows past twice the padded pose. deformSlack (default 0.25 of the largest extent, on every side) sets the room. A walking character's root motion is followed.

Picking

GPU picking (TraceBatchKernel or BvhTraceNodes) hits the posed surface. CPU picking on a posed mesh stays on three's own raycast (acceleratedRaycast falls back for a SkinnedMesh or morph targets), which poses each vertex on the CPU. For CPU queries on the live pose with a tree, use SkinnedMeshBVH (one mesh, in its own space) or bake the meshes with StaticGeometryGenerator (below).

Tested on the three-skinned GPU page (Apple M5): 0 disagreements over 16,384 rays at 4 frames, against three's CPU pose. The skinned example shows nine characters.

A tree that follows the pose

SkinnedMeshBVH is three-mesh-bvh's class: a CPU tree over one SkinnedMesh's (or morph-target mesh's) posed triangles, in the mesh's own space. Set it as the geometry's tree and acceleratedRaycast hits the pose; refit it after the pose moves.

import { SkinnedMeshBVH, acceleratedRaycast, prepareBoundsTrees, setBoundsTree } from '@pmndrs/bvh/three';
import { Mesh, SkinnedMesh, type Raycaster } from 'three';

await prepareBoundsTrees();
Mesh.prototype.raycast = acceleratedRaycast;
SkinnedMesh.prototype.raycast = acceleratedRaycast; // three's SkinnedMesh has a raycast of its own

const bvh = new SkinnedMeshBVH(character); // the current pose, in the mesh's own space
setBoundsTree(character.geometry, bvh); // geometry.boundsTree = bvh

// Each frame, after the animation mixer and updateMatrixWorld():
const frame = () => {
    bvh.refit(); // poses every vertex again (three's getVertexPosition) and refits the boxes
    raycaster.firstHitOnly = true;
    const [hit] = raycaster.intersectObject(character); // the live pose
    console.log(hit?.point, hit?.face?.normal);
};
  • The pose is three's own CPU pose: each vertex through mesh.getVertexPosition (morph targets, then skinning), the one three's raycast tests. matrixWorld places the tree as it places the mesh, so the object can move freely.
  • refit() poses every vertex again and refits the boxes, keeping the tree's shape (CpuMeshBvh.refit). A tree refitted many times over very different poses gets slower boxes; build a new one now and then if that matters.
  • Queries (raycast, raycastFirst, shapecast, closestPointToPoint, ...) read the posed corners. A raycast hit's face.normal comes from the pose; its uv and interpolated normal from the geometry's attributes, as three's raycast of a SkinnedMesh gives them.
  • SkinnedMesh.prototype.raycast must be set too: three's SkinnedMesh has a raycast of its own, so setting Mesh.prototype.raycast alone never reaches it (as with three-mesh-bvh).
  • The options are computeBoundsTree's. The tree belongs to one mesh: two meshes sharing a geometry need a tree each, and only one can be geometry.boundsTree.
  • Cost: refit() poses every vertex on the main thread. For many characters, pose on the GPU (above).

It isn't built on StaticGeometryGenerator, which bakes every visible mesh under an object into world space and flips a mirrored mesh's winding; this tree is one mesh in its own space, with the geometry's triangle numbering. Tested against three-mesh-bvh's SkinnedMeshBVH and three's raycast of the posed mesh (skinned, mirrored and morphed, indexed and not, over three poses), and its shapecast against three-mesh-bvh's.

On the CPU: StaticGeometryGenerator

StaticGeometryGenerator is three-mesh-bvh's class: it bakes skinned and morph-target meshes (and plain ones) into one static BufferGeometry, on the CPU, so a tree over it answers CPU raycasts, shapecasts and closest-point queries for the live pose. Use it for a character's collider or for picking on an animated mesh without a GPU readback.

import { StaticGeometryGenerator, computeBoundsTree } from '@pmndrs/bvh/three';
import { Mesh, type Raycaster, type SkinnedMesh } from 'three';

const generator = new StaticGeometryGenerator(character); // every visible mesh under it
const geometry = generator.generate(); // the current pose, in world space, a group per mesh
computeBoundsTree.call(geometry);
const collider = new Mesh(geometry, generator.getMaterials()); // identity transform: the pose is in world space

// Each frame, after the animation mixer and updateMatrixWorld():
const frame = () => {
    generator.generate(geometry); // the same arrays; the index is left alone
    geometry.boundsTree!.refit();
    raycaster.firstHitOnly = true;
    const [hit] = raycaster.intersectObject(collider); // the live pose
    console.log(hit?.point);
};
  • What it bakes: every visible Mesh under the objects you pass (traverseVisible), in that order. meshes lists them, and getMaterials() gives their materials in the order of the geometry's groups (one group per mesh with useGroups, the default).
  • three's math: each vertex is morphed as three's Mesh.getVertexPosition morphs it, then skinned with SkinnedMesh.applyBoneTransform, so the baked positions are what three's own CPU raycast tests. Normals and tangents go through the blended bone matrix. With applyWorldTransforms (default true) everything is in world space; a mirrored mesh's triangles are flipped to keep their winding.
  • Updating: pass the same geometry to generate again. Its arrays are written in place and its index is left alone, so geometry.boundsTree.refit() is enough, as with three-mesh-bvh. Meshes that didn't change (matrix, positions version, bone matrices, morph weights) are skipped.
  • attributes (default position, normal, color, tangent, uv, uv2) says what to copy. Every mesh must have the same attributes, and all must be indexed or none; generate throws otherwise, or when a mesh's attribute sizes changed since the last call.
  • Cost: every vertex of every changed mesh, every call, on the main thread. For many or large characters, pose on the GPU (above) instead.

It is a port of three-mesh-bvh 0.9.18's class (MIT). One difference: three-mesh-bvh doesn't notice a change of morph weights alone and keeps the old pose; this one bakes again. Tested bit for bit against three-mesh-bvh's output and three's getVertexPosition, and a tree over the baked geometry hits what three's raycast of the posed meshes hits, before and after the pose moves.

See also

  • Deforming meshes: what SceneTrees does with a pose, whatever made it: the refit, drift and rebuilds.
  • Refits: GpuMeshRefit on its own.