Skinned and morphed meshes
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.readyhas 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.matrixWorldplaces 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'sface.normalcomes from the pose; itsuvand interpolatednormalfrom the geometry's attributes, as three's raycast of aSkinnedMeshgives them. SkinnedMesh.prototype.raycastmust be set too: three'sSkinnedMeshhas a raycast of its own, so settingMesh.prototype.raycastalone 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 begeometry.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
Meshunder the objects you pass (traverseVisible), in that order.mesheslists them, andgetMaterials()gives their materials in the order of the geometry's groups (one group per mesh withuseGroups, the default). - three's math: each vertex is morphed as three's
Mesh.getVertexPositionmorphs it, then skinned withSkinnedMesh.applyBoneTransform, so the baked positions are what three's own CPU raycast tests. Normals and tangents go through the blended bone matrix. WithapplyWorldTransforms(default true) everything is in world space; a mirrored mesh's triangles are flipped to keep their winding. - Updating: pass the same geometry to
generateagain. Its arrays are written in place and its index is left alone, sogeometry.boundsTree.refit()is enough, as with three-mesh-bvh. Meshes that didn't change (matrix, positions version, bone matrices, morph weights) are skipped. attributes(defaultposition,normal,color,tangent,uv,uv2) says what to copy. Every mesh must have the same attributes, and all must be indexed or none;generatethrows 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
SceneTreesdoes with a pose, whatever made it: the refit, drift and rebuilds. - Refits:
GpuMeshRefiton its own.