Skip to content

Instantly share code, notes, and snippets.

@seantai
Created March 26, 2026 20:53
Show Gist options
  • Select an option

  • Save seantai/6930d150d52c45ee9425dc45271ba6d6 to your computer and use it in GitHub Desktop.

Select an option

Save seantai/6930d150d52c45ee9425dc45271ba6d6 to your computer and use it in GitHub Desktop.
GPUSkinningCompute.js
import { StorageInstancedBufferAttribute, StorageTexture, FloatType, NearestFilter, Matrix4, Vector3, Quaternion } from 'three/webgpu';
import { Fn, If, storage, storageTexture, textureStore, instanceIndex, attribute, uniform, float, int, vec4, mat4, mod, floor, clamp, add, ivec2, NodeAccess, positionLocal, normalLocal } from 'three/tsl';
/**
* Sorts bones hierarchically so parents always come before children.
* This is required for proper bone matrix propagation in GPU compute.
*/
function sortBonesHierarchically(skeleton) {
const bones = skeleton.bones;
const result = [];
const visited = new Set();
const originalToSorted = new Map();
function visit(boneIndex) {
if (visited.has(boneIndex))
return;
const bone = bones[boneIndex];
// Visit parent first if it exists and hasn't been visited
if (bone.parent && bone.parent.isBone) {
const parentIndex = bones.indexOf(bone.parent);
if (parentIndex >= 0 && !visited.has(parentIndex)) {
visit(parentIndex);
}
}
visited.add(boneIndex);
const sortedIndex = result.length;
originalToSorted.set(boneIndex, sortedIndex);
let parentSortedIndex = -1;
if (bone.parent && bone.parent.isBone) {
const parentOriginalIndex = bones.indexOf(bone.parent);
if (parentOriginalIndex >= 0) {
parentSortedIndex = originalToSorted.get(parentOriginalIndex);
}
}
result.push({
originalIndex: boneIndex,
parentSortedIndex
});
}
for (let i = 0; i < bones.length; i++) {
visit(i);
}
return result;
}
/**
* Parses animation track name to extract bone name and property.
*/
function parseTrackName(name) {
// Try bracket notation: ".bones[BoneName].property"
const bracketMatch = name.match(/\.bones\[(.+?)\]\.(\w+)/);
if (bracketMatch) {
return {
bone: bracketMatch[1],
property: bracketMatch[2]
};
}
// Try dot notation: "BoneName.property"
const dotMatch = name.match(/^(.+)\.(\w+)$/);
if (dotMatch) {
return {
bone: dotMatch[1],
property: dotMatch[2]
};
}
return null;
}
/**
* Packs animation data into a single GPU-friendly typed array.
* Layout: [metadata] [posTimes] [quatTimes] [scaleTimes] [posValues] [quatValues] [scaleValues]
* Metadata per bone stores GLOBAL offsets into the packed buffer.
*/
function packAnimationData(clip, skeleton, sortedBones) {
const bones = skeleton.bones;
const boneNameToIndex = new Map();
const boneToIndex = new Map();
bones.forEach((bone, i) => {
boneNameToIndex.set(bone.name, i);
boneToIndex.set(bone, i);
});
// Initialize per-bone track data with bind pose defaults
const boneTrackData = [];
// Prefer bind pose derived from skeleton.boneInverses to avoid using a potentially animated
// runtime pose as the "default" for bones without tracks (can cause popping on some models).
const canDeriveBindPose = Array.isArray(skeleton.boneInverses) && skeleton.boneInverses.length === bones.length;
const bindPositions = new Array(bones.length);
const bindQuaternions = new Array(bones.length);
const bindScales = new Array(bones.length);
if (canDeriveBindPose) {
const worldBind = new Matrix4();
const localBind = new Matrix4();
// Compute inverse of skeleton root to factor it out from root bone bind poses
// This prevents double-application (bind pose + compute shader both applying skeleton root)
let skeletonRootInverse = null;
for (let i = 0; i < bones.length; i++) {
const parent = bones[i].parent;
if (parent && !parent.isBone && parent.matrixWorld) {
skeletonRootInverse = parent.matrixWorld.clone().invert();
break;
}
}
for (let i = 0; i < bones.length; i++) {
// worldBind = inverse( boneInverse ) = bone's world matrix at bind time
worldBind.copy(skeleton.boneInverses[i]).invert();
const parent = bones[i].parent;
const parentIndex = (parent && parent.isBone) ? boneToIndex.get(parent) : undefined;
if (parentIndex !== undefined) {
// Child bone: localBind = inverse(parentWorld) * childWorld
localBind.copy(skeleton.boneInverses[parentIndex]).multiply(worldBind);
}
else {
// Root bone: factor out skeleton root to get bone-local transform
// worldBind = skeletonRoot * boneLocal, so boneLocal = inv(skeletonRoot) * worldBind
if (skeletonRootInverse) {
localBind.copy(skeletonRootInverse).multiply(worldBind);
}
else {
localBind.copy(worldBind);
}
}
const pos = new Vector3();
const quat = new Quaternion();
const scale = new Vector3();
localBind.decompose(pos, quat, scale);
bindPositions[i] = pos;
bindQuaternions[i] = quat;
bindScales[i] = scale;
}
}
for (let i = 0; i < bones.length; i++) {
const bone = bones[i];
const position = canDeriveBindPose ? bindPositions[i] : bone.position;
const quaternion = canDeriveBindPose ? bindQuaternions[i] : bone.quaternion;
const scale = canDeriveBindPose ? bindScales[i] : bone.scale;
boneTrackData.push({
position: {
times: [0],
values: [position.x, position.y, position.z]
},
quaternion: {
times: [0],
values: [quaternion.x, quaternion.y, quaternion.z, quaternion.w]
},
scale: {
times: [0],
values: [scale.x, scale.y, scale.z]
}
});
}
// Parse animation tracks and populate bone data
for (const track of clip.tracks) {
const parsed = parseTrackName(track.name);
if (!parsed)
continue;
const boneIndex = boneNameToIndex.get(parsed.bone);
if (boneIndex === undefined)
continue;
if (parsed.property === 'position' || parsed.property === 'quaternion' || parsed.property === 'scale') {
boneTrackData[boneIndex][parsed.property] = {
times: Array.from(track.times),
values: Array.from(track.values)
};
}
}
// Count total keyframes
let totalPositionKeyframes = 0;
let totalQuaternionKeyframes = 0;
let totalScaleKeyframes = 0;
for (const data of boneTrackData) {
totalPositionKeyframes += data.position.times.length;
totalQuaternionKeyframes += data.quaternion.times.length;
totalScaleKeyframes += data.scale.times.length;
}
// Calculate section offsets in packed buffer
const metadataSize = bones.length * 8;
const metadataOffset = 0;
const positionTimesOffset = metadataSize;
const quaternionTimesOffset = positionTimesOffset + totalPositionKeyframes;
const scaleTimesOffset = quaternionTimesOffset + totalQuaternionKeyframes;
const positionValuesOffset = scaleTimesOffset + totalScaleKeyframes;
const quaternionValuesOffset = positionValuesOffset + totalPositionKeyframes * 3;
const scaleValuesOffset = quaternionValuesOffset + totalQuaternionKeyframes * 4;
const totalSize = scaleValuesOffset + totalScaleKeyframes * 3;
// Allocate single packed buffer
const packedData = new Float32Array(totalSize);
let posTimeIdx = 0;
let quatTimeIdx = 0;
let scaleTimeIdx = 0;
let posValIdx = 0;
let quatValIdx = 0;
let scaleValIdx = 0;
// Detect frame time from first track with multiple keyframes
let detectedFrameTime = 1 / 30;
// Pack data in hierarchically sorted order
for (let sortedIdx = 0; sortedIdx < sortedBones.length; sortedIdx++) {
const originalIdx = sortedBones[sortedIdx].originalIndex;
const parentSortedIdx = sortedBones[sortedIdx].parentSortedIndex;
const data = boneTrackData[originalIdx];
const metaBase = metadataOffset + sortedIdx * 8;
// Position track - store GLOBAL offset into packed buffer
packedData[metaBase + 0] = positionTimesOffset + posTimeIdx; // global time offset
packedData[metaBase + 1] = data.position.times.length;
for (let i = 0; i < data.position.times.length; i++) {
packedData[positionTimesOffset + posTimeIdx + i] = data.position.times[i];
}
const posValBase = positionValuesOffset + posValIdx * 3;
for (let i = 0; i < data.position.times.length; i++) {
packedData[posValBase + i * 3 + 0] = data.position.values[i * 3 + 0];
packedData[posValBase + i * 3 + 1] = data.position.values[i * 3 + 1];
packedData[posValBase + i * 3 + 2] = data.position.values[i * 3 + 2];
}
// Values offset computed by shader from time offset + section offsets
posTimeIdx += data.position.times.length;
posValIdx += data.position.times.length;
// Quaternion track
packedData[metaBase + 2] = quaternionTimesOffset + quatTimeIdx;
packedData[metaBase + 3] = data.quaternion.times.length;
for (let i = 0; i < data.quaternion.times.length; i++) {
packedData[quaternionTimesOffset + quatTimeIdx + i] = data.quaternion.times[i];
// Detect frame time
if (i > 0 && detectedFrameTime === 1 / 30) {
const delta = data.quaternion.times[i] - data.quaternion.times[i - 1];
if (delta > 0.001) {
detectedFrameTime = delta;
}
}
}
const quatValBase = quaternionValuesOffset + quatValIdx * 4;
for (let i = 0; i < data.quaternion.times.length; i++) {
packedData[quatValBase + i * 4 + 0] = data.quaternion.values[i * 4 + 0];
packedData[quatValBase + i * 4 + 1] = data.quaternion.values[i * 4 + 1];
packedData[quatValBase + i * 4 + 2] = data.quaternion.values[i * 4 + 2];
packedData[quatValBase + i * 4 + 3] = data.quaternion.values[i * 4 + 3];
}
quatTimeIdx += data.quaternion.times.length;
quatValIdx += data.quaternion.times.length;
// Scale track
packedData[metaBase + 4] = scaleTimesOffset + scaleTimeIdx;
packedData[metaBase + 5] = data.scale.times.length;
for (let i = 0; i < data.scale.times.length; i++) {
packedData[scaleTimesOffset + scaleTimeIdx + i] = data.scale.times[i];
}
const scaleValBase = scaleValuesOffset + scaleValIdx * 3;
for (let i = 0; i < data.scale.times.length; i++) {
packedData[scaleValBase + i * 3 + 0] = data.scale.values[i * 3 + 0];
packedData[scaleValBase + i * 3 + 1] = data.scale.values[i * 3 + 1];
packedData[scaleValBase + i * 3 + 2] = data.scale.values[i * 3 + 2];
}
scaleTimeIdx += data.scale.times.length;
scaleValIdx += data.scale.times.length;
// Parent and original indices
packedData[metaBase + 6] = parentSortedIdx;
packedData[metaBase + 7] = originalIdx;
}
return {
packedData,
metadataOffset,
positionTimesOffset,
quaternionTimesOffset,
scaleTimesOffset,
positionValuesOffset,
quaternionValuesOffset,
scaleValuesOffset,
totalPositionKeyframes,
totalQuaternionKeyframes,
totalScaleKeyframes,
detectedFrameTime
};
}
/**
* Bakes an animation clip into pre-computed final bone matrices at a fixed sample rate.
* For each frame, evaluates all bones: interpolates keyframes on CPU, builds world
* matrices via hierarchy traversal, applies boneInverse, and remaps to original order.
*
* Returns a Float32Array of size (boneCount * 4) * frameCount * 4, laid out as
* RGBA texels: 4 texels per mat4, boneCount * 4 texels wide, frameCount rows tall.
*/
function bakeAnimation( clip, skeleton, sortedBones, sampleRate = 30 ) {
const boneCount = skeleton.bones.length;
const duration = clip.duration;
const frameCount = Math.max( 1, Math.ceil( duration * sampleRate ) + 1 );
// Parse tracks into per-bone arrays for fast CPU interpolation
const boneNameToIndex = new Map();
skeleton.bones.forEach( ( bone, i ) => boneNameToIndex.set( bone.name, i ) );
// Derive bind pose from boneInverses (same logic as packAnimationData)
const canDeriveBindPose = Array.isArray( skeleton.boneInverses ) && skeleton.boneInverses.length === boneCount;
const bindPos = new Array( boneCount );
const bindQuat = new Array( boneCount );
const bindScale = new Array( boneCount );
const boneToIndex = new Map();
skeleton.bones.forEach( ( bone, i ) => boneToIndex.set( bone, i ) );
if ( canDeriveBindPose ) {
const worldBind = new Matrix4();
const localBind = new Matrix4();
let skeletonRootInverse = null;
for ( let i = 0; i < boneCount; i ++ ) {
const parent = skeleton.bones[ i ].parent;
if ( parent && ! parent.isBone && parent.matrixWorld ) {
skeletonRootInverse = parent.matrixWorld.clone().invert();
break;
}
}
for ( let i = 0; i < boneCount; i ++ ) {
worldBind.copy( skeleton.boneInverses[ i ] ).invert();
const parent = skeleton.bones[ i ].parent;
const parentIndex = ( parent && parent.isBone ) ? boneToIndex.get( parent ) : undefined;
if ( parentIndex !== undefined ) {
localBind.copy( skeleton.boneInverses[ parentIndex ] ).multiply( worldBind );
} else {
if ( skeletonRootInverse ) {
localBind.copy( skeletonRootInverse ).multiply( worldBind );
} else {
localBind.copy( worldBind );
}
}
const p = new Vector3();
const q = new Quaternion();
const s = new Vector3();
localBind.decompose( p, q, s );
bindPos[ i ] = [ p.x, p.y, p.z ];
bindQuat[ i ] = [ q.x, q.y, q.z, q.w ];
bindScale[ i ] = [ s.x, s.y, s.z ];
}
} else {
for ( let i = 0; i < boneCount; i ++ ) {
const bone = skeleton.bones[ i ];
bindPos[ i ] = [ bone.position.x, bone.position.y, bone.position.z ];
bindQuat[ i ] = [ bone.quaternion.x, bone.quaternion.y, bone.quaternion.z, bone.quaternion.w ];
bindScale[ i ] = [ bone.scale.x, bone.scale.y, bone.scale.z ];
}
}
// Per-bone track data: { posTimes, posValues, quatTimes, quatValues, scaleTimes, scaleValues }
const tracks = [];
for ( let i = 0; i < boneCount; i ++ ) {
tracks.push( {
posTimes: [ 0 ], posValues: bindPos[ i ].slice(),
quatTimes: [ 0 ], quatValues: bindQuat[ i ].slice(),
scaleTimes: [ 0 ], scaleValues: bindScale[ i ].slice()
} );
}
for ( const track of clip.tracks ) {
const parsed = parseTrackName( track.name );
if ( ! parsed ) continue;
const idx = boneNameToIndex.get( parsed.bone );
if ( idx === undefined ) continue;
if ( parsed.property === 'position' ) {
tracks[ idx ].posTimes = Array.from( track.times );
tracks[ idx ].posValues = Array.from( track.values );
} else if ( parsed.property === 'quaternion' ) {
tracks[ idx ].quatTimes = Array.from( track.times );
tracks[ idx ].quatValues = Array.from( track.values );
} else if ( parsed.property === 'scale' ) {
tracks[ idx ].scaleTimes = Array.from( track.times );
tracks[ idx ].scaleValues = Array.from( track.values );
}
}
// CPU interpolation helpers
function lerpScalar( a, b, t ) {
return a + ( b - a ) * t;
}
function findKeyframeIndex( times, time ) {
if ( times.length <= 1 ) return { i0: 0, i1: 0, t: 0 };
if ( time <= times[ 0 ] ) return { i0: 0, i1: 0, t: 0 };
if ( time >= times[ times.length - 1 ] ) return { i0: times.length - 1, i1: times.length - 1, t: 0 };
for ( let i = 0; i < times.length - 1; i ++ ) {
if ( time >= times[ i ] && time < times[ i + 1 ] ) {
const span = times[ i + 1 ] - times[ i ];
return { i0: i, i1: i + 1, t: span > 0 ? ( time - times[ i ] ) / span : 0 };
}
}
return { i0: times.length - 1, i1: times.length - 1, t: 0 };
}
function sampleVec3( times, values, time ) {
const { i0, i1, t } = findKeyframeIndex( times, time );
return [
lerpScalar( values[ i0 * 3 ], values[ i1 * 3 ], t ),
lerpScalar( values[ i0 * 3 + 1 ], values[ i1 * 3 + 1 ], t ),
lerpScalar( values[ i0 * 3 + 2 ], values[ i1 * 3 + 2 ], t )
];
}
function sampleQuat( times, values, time ) {
const { i0, i1, t } = findKeyframeIndex( times, time );
let x0 = values[ i0 * 4 ], y0 = values[ i0 * 4 + 1 ], z0 = values[ i0 * 4 + 2 ], w0 = values[ i0 * 4 + 3 ];
let x1 = values[ i1 * 4 ], y1 = values[ i1 * 4 + 1 ], z1 = values[ i1 * 4 + 2 ], w1 = values[ i1 * 4 + 3 ];
// Shortest path
if ( x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1 < 0 ) {
x1 = - x1; y1 = - y1; z1 = - z1; w1 = - w1;
}
let rx = lerpScalar( x0, x1, t );
let ry = lerpScalar( y0, y1, t );
let rz = lerpScalar( z0, z1, t );
let rw = lerpScalar( w0, w1, t );
// Normalize
const len = Math.sqrt( rx * rx + ry * ry + rz * rz + rw * rw ) || 1;
rx /= len; ry /= len; rz /= len; rw /= len;
return [ rx, ry, rz, rw ];
}
// Compute skeleton root transform
let skeletonRootMatrix = null;
const roots = sortedBones.filter( s => s.parentSortedIndex === - 1 );
if ( roots.length > 0 ) {
const rootBone = skeleton.bones[ roots[ 0 ].originalIndex ];
const parent = rootBone.parent;
if ( parent && ! parent.isBone && parent.matrixWorld ) {
skeletonRootMatrix = parent.matrixWorld.clone();
}
}
// Allocate output: frameCount × boneCount mat4s (16 floats each)
// Layout: [frame0_bone0, frame0_bone1, ..., frame1_bone0, ...]
const output = new Float32Array( frameCount * boneCount * 16 );
const localMat = new Matrix4();
const worldMat = new Matrix4();
const tmpPos = new Vector3();
const tmpQuat = new Quaternion();
const tmpScale = new Vector3();
const worldMatrices = new Array( boneCount );
for ( let i = 0; i < boneCount; i ++ ) {
worldMatrices[ i ] = new Matrix4();
}
for ( let frame = 0; frame < frameCount; frame ++ ) {
const time = Math.min( ( frame / sampleRate ), duration );
// Pass 1: compute world matrices in hierarchical (sorted) order
for ( let sortedIdx = 0; sortedIdx < sortedBones.length; sortedIdx ++ ) {
const originalIdx = sortedBones[ sortedIdx ].originalIndex;
const parentSortedIdx = sortedBones[ sortedIdx ].parentSortedIndex;
const tr = tracks[ originalIdx ];
const pos = sampleVec3( tr.posTimes, tr.posValues, time );
const quat = sampleQuat( tr.quatTimes, tr.quatValues, time );
const scale = sampleVec3( tr.scaleTimes, tr.scaleValues, time );
tmpPos.set( pos[ 0 ], pos[ 1 ], pos[ 2 ] );
tmpQuat.set( quat[ 0 ], quat[ 1 ], quat[ 2 ], quat[ 3 ] );
tmpScale.set( scale[ 0 ], scale[ 1 ], scale[ 2 ] );
localMat.compose( tmpPos, tmpQuat, tmpScale );
if ( parentSortedIdx >= 0 ) {
worldMatrices[ sortedIdx ].multiplyMatrices( worldMatrices[ parentSortedIdx ], localMat );
} else if ( skeletonRootMatrix ) {
worldMatrices[ sortedIdx ].multiplyMatrices( skeletonRootMatrix, localMat );
} else {
worldMatrices[ sortedIdx ].copy( localMat );
}
}
// Pass 2: apply boneInverse, remap to original order, write as mat4
for ( let sortedIdx = 0; sortedIdx < sortedBones.length; sortedIdx ++ ) {
const originalIdx = sortedBones[ sortedIdx ].originalIndex;
worldMat.multiplyMatrices( worldMatrices[ sortedIdx ], skeleton.boneInverses[ originalIdx ] );
// Write mat4 at [frame * boneCount + originalIdx]
worldMat.toArray( output, ( frame * boneCount + originalIdx ) * 16 );
}
}
return { data: output, frameCount, sampleRate };
}
/**
* GPU-driven skeletal animation class for WebGPU compute shaders.
* Supports animation blending between two clips and instanced rendering.
*
* @example
* const gpuAnim = new GPUSkinningCompute(skinnedMesh, clips, { instanceCount: 100 });
* gpuAnim.setAnimationA(0); // walk
* gpuAnim.setAnimationB(1); // run
* gpuAnim.blendWeightUniform.value = 0.5; // 50% blend
*
* const compute = gpuAnim.createComputeNode();
* // In render loop:
* renderer.compute(compute);
*/
class GPUSkinningCompute {
/**
* Creates a GPU skinned animation controller.
* Pre-bakes animation data into a storage buffer for fast GPU lookup.
*/
constructor(skinnedMesh, clips, options = {}) {
this.skinnedMesh = skinnedMesh;
this.skeleton = skinnedMesh.skeleton;
this.clips = clips;
this.instanceCount = options.instanceCount || 1;
this.boneCount = this.skeleton.bones.length;
this.animationNames = clips.map((c, i) => c.name || `Animation ${i}`);
// Sort bones hierarchically (needed by bakeAnimation)
this._sortedBones = sortBonesHierarchically(this.skeleton);
// Bake all clips into pre-computed bone matrices
const bakedClips = [];
let maxFrameCount = 0;
for (const clip of clips) {
const baked = bakeAnimation(clip, this.skeleton, this._sortedBones);
bakedClips.push(baked);
maxFrameCount = Math.max(maxFrameCount, baked.frameCount);
}
this._bakedClips = bakedClips;
this._maxFrameCount = maxFrameCount;
// Pack all clips into a single storage buffer
// Layout: [clip0_frame0_bone0..boneN, clip0_frame1_bone0..boneN, ..., clip1_frame0_bone0..boneN, ...]
const boneCount = this.boneCount;
const totalMat4s = clips.length * maxFrameCount * boneCount;
const bakedData = new Float32Array(totalMat4s * 16);
for (let layer = 0; layer < clips.length; layer++) {
const baked = bakedClips[layer];
const layerOffset = layer * maxFrameCount * boneCount * 16;
// Copy baked frames
bakedData.set(baked.data, layerOffset);
// If this clip has fewer frames than max, repeat last frame to fill
if (baked.frameCount < maxFrameCount) {
const lastFrameData = baked.data.subarray(
(baked.frameCount - 1) * boneCount * 16,
baked.frameCount * boneCount * 16
);
for (let f = baked.frameCount; f < maxFrameCount; f++) {
bakedData.set(lastFrameData, layerOffset + f * boneCount * 16);
}
}
}
this._bakedBuffer = new StorageInstancedBufferAttribute(bakedData, 16);
this._totalBakedMat4s = totalMat4s;
// Current animation indices
this._animationAIndex = 0;
this._animationBIndex = clips.length > 1 ? 1 : 0;
// Uniforms
this.timeUniform = uniform(0);
this.blendWeightUniform = uniform(0);
this._durationAUniform = uniform(clips[this._animationAIndex].duration);
this._durationBUniform = uniform(clips[this._animationBIndex].duration);
this._clipOffsetAUniform = uniform(this._animationAIndex * maxFrameCount * boneCount);
this._clipOffsetBUniform = uniform(this._animationBIndex * maxFrameCount * boneCount);
this._frameCountAUniform = uniform(bakedClips[this._animationAIndex].frameCount);
this._frameCountBUniform = uniform(bakedClips[this._animationBIndex].frameCount);
this._sampleRateUniform = uniform(bakedClips[0].sampleRate);
// Storage buffers: instanceOffsets + output boneMatrices
this._createStorageBuffers();
this._computeNode = null;
this._readBoneTex = null;
}
/**
* Creates GPU storage buffers for instance offsets and output bone matrices.
*/
_createStorageBuffers() {
const boneCount = this.boneCount;
const instanceCount = this.instanceCount;
// Instance time offsets
const instanceOffsetsArray = new Float32Array(instanceCount);
const duration = this.clips[this._animationAIndex].duration;
for (let i = 0; i < instanceCount; i++) {
instanceOffsetsArray[i] = (i / instanceCount) * duration;
}
this._instanceOffsetsBuffer = new StorageInstancedBufferAttribute(instanceOffsetsArray, 1);
// Output bone matrices as StorageTexture for texture cache benefits on tile-based GPUs.
// Each mat4 = 4 vec4 columns = 4 RGBA texels.
const texWidth = boneCount * 4;
const texHeight = instanceCount;
this._boneMatricesTex = new StorageTexture( texWidth, texHeight );
this._boneMatricesTex.type = FloatType;
this._boneMatricesTex.minFilter = NearestFilter;
this._boneMatricesTex.magFilter = NearestFilter;
}
/**
* Sets the first animation (A) by index.
*/
setAnimationA(index) {
this._setAnimation('A', index);
}
/**
* Sets the second animation (B) by index for blending.
*/
setAnimationB(index) {
this._setAnimation('B', index);
}
_setAnimation(slot, index) {
if (index < 0 || index >= this.clips.length)
return;
const isA = slot === 'A';
const clipOffset = index * this._maxFrameCount * this.boneCount;
if (isA) {
this._animationAIndex = index;
this._durationAUniform.value = this.clips[index].duration;
this._clipOffsetAUniform.value = clipOffset;
this._frameCountAUniform.value = this._bakedClips[index].frameCount;
} else {
this._animationBIndex = index;
this._durationBUniform.value = this.clips[index].duration;
this._clipOffsetBUniform.value = clipOffset;
this._frameCountBUniform.value = this._bakedClips[index].frameCount;
}
}
/**
* Sets per-instance time offsets for animation staggering.
*/
setInstanceOffsets(offsets) {
if (offsets.length !== this.instanceCount) {
console.warn('GPUSkinningCompute: Instance offsets length mismatch');
return;
}
this._instanceOffsetsBuffer.array.set(offsets);
this._instanceOffsetsBuffer.needsUpdate = true;
}
/**
* Creates the compute node for GPU animation.
* Uses baked DataArrayTexture for trivial frame lookup — no branching, no hierarchy traversal.
* Dispatches instanceCount × boneCount threads for maximum GPU occupancy.
*/
createComputeNode(instanceOffsetsBuffer) {
const boneCount = this.boneCount;
const instanceCount = this.instanceCount;
const totalThreads = instanceCount * boneCount;
// Storage nodes
const instanceOffsets = storage(instanceOffsetsBuffer || this._instanceOffsetsBuffer, 'float', instanceCount);
const bakedMatrices = storage(this._bakedBuffer, 'mat4', this._totalBakedMat4s);
// StorageTexture nodes for bone matrix output (texture cache path)
const writeBoneTex = storageTexture( this._boneMatricesTex ).setAccess( NodeAccess.WRITE_ONLY );
this._readBoneTex = storageTexture( this._boneMatricesTex ).setAccess( NodeAccess.READ_ONLY );
// Helper: store a mat4 as 4 RGBA texels (one per column)
const storeMat4ToTex = ( mat, baseX, y ) => {
const v = mat.toVar();
textureStore( writeBoneTex, ivec2( baseX, y ), v.element( 0 ) );
textureStore( writeBoneTex, ivec2( baseX.add( 1 ), y ), v.element( 1 ) );
textureStore( writeBoneTex, ivec2( baseX.add( 2 ), y ), v.element( 2 ) );
textureStore( writeBoneTex, ivec2( baseX.add( 3 ), y ), v.element( 3 ) );
};
// Main compute: one thread per instance × bone
this._computeNode = Fn(() => {
const threadId = instanceIndex;
const instId = int(threadId).div(int(boneCount));
const boneIdx = int(threadId).mod(int(boneCount));
const baseX = boneIdx.mul( 4 );
const instanceOffset = instanceOffsets.element(instId);
// Compute time for animation A (looping)
const timeBase = this.timeUniform.add(instanceOffset);
const timeA = mod(timeBase, this._durationAUniform);
// Convert time to fractional frame index
const rawFrameA = timeA.mul(this._sampleRateUniform);
const frame0A = clamp(int(floor(rawFrameA)), 0, int(this._frameCountAUniform).sub(1));
const frame1A = clamp(frame0A.add(1), 0, int(this._frameCountAUniform).sub(1));
const fracA = rawFrameA.sub(float(frame0A));
// Read two adjacent frames from baked storage: clipOffset + frame * boneCount + boneIdx
const idxA0 = int(this._clipOffsetAUniform).add(frame0A.mul(int(boneCount))).add(boneIdx);
const idxA1 = int(this._clipOffsetAUniform).add(frame1A.mul(int(boneCount))).add(boneIdx);
// Manual lerp: WGSL mix() doesn't support mat4, but mat4 arithmetic (+, -, * scalar) is valid
const m0A = bakedMatrices.element(idxA0);
const m1A = bakedMatrices.element(idxA1);
const matA = m0A.add(m1A.sub(m0A).mul(fracA));
// Early-out: skip animation B reads entirely when blendWeight is 0
// Since blendWeight is uniform across all threads, no warp divergence occurs
If(this.blendWeightUniform.greaterThan(0), () => {
// Compute time for animation B (phase-aligned to A)
const phase = timeA.div(this._durationAUniform.max(0.0001));
const timeB = phase.mul(this._durationBUniform);
const rawFrameB = timeB.mul(this._sampleRateUniform);
const frame0B = clamp(int(floor(rawFrameB)), 0, int(this._frameCountBUniform).sub(1));
const frame1B = clamp(frame0B.add(1), 0, int(this._frameCountBUniform).sub(1));
const fracB = rawFrameB.sub(float(frame0B));
const idxB0 = int(this._clipOffsetBUniform).add(frame0B.mul(int(boneCount))).add(boneIdx);
const idxB1 = int(this._clipOffsetBUniform).add(frame1B.mul(int(boneCount))).add(boneIdx);
const m0B = bakedMatrices.element(idxB0);
const m1B = bakedMatrices.element(idxB1);
const matB = m0B.add(m1B.sub(m0B).mul(fracB));
// Blend A and B, store to texture
storeMat4ToTex( matA.add( matB.sub( matA ).mul( this.blendWeightUniform ) ), baseX, instId );
}).Else(() => {
// No blending needed — use animation A directly
storeMat4ToTex( matA, baseX, instId );
});
})().compute(totalThreads);
return this._computeNode;
}
/**
* Returns shared TSL skinning nodes (skinIndex, skinWeight, getBoneMatrix).
* Used by applyToMaterial, getSkinnedPosition, and getSkinnedNormal.
*/
_getSkinningContext() {
const boneCount = this.boneCount;
const readTex = this._readBoneTex;
const skinIndexNode = attribute('skinIndex', 'uvec4');
const skinWeightNode = attribute('skinWeight', 'vec4');
const getBoneMatrix = ( boneIndex ) => {
const idx = clamp( int( boneIndex ), 0, int( boneCount - 1 ) );
const baseX = idx.mul( 4 );
const y = instanceIndex;
const col0 = readTex.load( ivec2( baseX, y ) );
const col1 = readTex.load( ivec2( baseX.add( 1 ), y ) );
const col2 = readTex.load( ivec2( baseX.add( 2 ), y ) );
const col3 = readTex.load( ivec2( baseX.add( 3 ), y ) );
return mat4( col0, col1, col2, col3 );
};
return { skinIndexNode, skinWeightNode, getBoneMatrix };
}
/**
* Applies GPU skinning to a NodeMaterial.
*/
applyToMaterial(material) {
if (!this._readBoneTex) {
console.warn('GPUSkinningCompute: Call createComputeNode() first');
return;
}
// Compute bone matrices once, reuse for both position and normal skinning.
// Single skinMatrix avoids redundant texture reads and mat4 arithmetic.
const { skinIndexNode, skinWeightNode, getBoneMatrix } = this._getSkinningContext();
const boneMatX = getBoneMatrix(skinIndexNode.x);
const boneMatY = getBoneMatrix(skinIndexNode.y);
const boneMatZ = getBoneMatrix(skinIndexNode.z);
const boneMatW = getBoneMatrix(skinIndexNode.w);
// Blend bone matrices into a single skinMatrix, then transform position and normal
const skinMatrix = add(
skinWeightNode.x.mul(boneMatX),
skinWeightNode.y.mul(boneMatY),
skinWeightNode.z.mul(boneMatZ),
skinWeightNode.w.mul(boneMatW)
);
const skinnedPosition = skinMatrix.mul(vec4(positionLocal, 1)).xyz;
const skinnedNormal = skinMatrix.transformDirection(normalLocal).xyz;
const origSetupPosition = material.setupPosition;
// Apply skinning BEFORE origSetupPosition so that the InstanceNode
// (inside origSetupPosition) transforms already-skinned vertices.
// This matches the standard SkinningNode order: skin → instance.
material.setupPosition = function ( builder ) {
positionLocal.assign( skinnedPosition );
normalLocal.assign( skinnedNormal );
return origSetupPosition.call( this, builder );
};
}
/**
* Returns the skinned position node for manual material setup.
*/
getSkinnedPosition() {
if (!this._readBoneTex) {
console.warn('GPUSkinningCompute: Call createComputeNode() first');
return positionLocal;
}
const { skinIndexNode, skinWeightNode, getBoneMatrix } = this._getSkinningContext();
const boneMatX = getBoneMatrix(skinIndexNode.x);
const boneMatY = getBoneMatrix(skinIndexNode.y);
const boneMatZ = getBoneMatrix(skinIndexNode.z);
const boneMatW = getBoneMatrix(skinIndexNode.w);
const skinVertex = vec4(positionLocal, 1.0);
const skinned = add(boneMatX.mul(skinWeightNode.x).mul(skinVertex), boneMatY.mul(skinWeightNode.y).mul(skinVertex), boneMatZ.mul(skinWeightNode.z).mul(skinVertex), boneMatW.mul(skinWeightNode.w).mul(skinVertex));
return skinned.xyz;
}
/**
* Returns the skinned normal node for manual material setup.
*/
getSkinnedNormal() {
if (!this._readBoneTex) {
console.warn('GPUSkinningCompute: Call createComputeNode() first');
return normalLocal;
}
const { skinIndexNode, skinWeightNode, getBoneMatrix } = this._getSkinningContext();
const boneMatX = getBoneMatrix(skinIndexNode.x);
const boneMatY = getBoneMatrix(skinIndexNode.y);
const boneMatZ = getBoneMatrix(skinIndexNode.z);
const boneMatW = getBoneMatrix(skinIndexNode.w);
const skinMatrix = add(skinWeightNode.x.mul(boneMatX), skinWeightNode.y.mul(boneMatY), skinWeightNode.z.mul(boneMatZ), skinWeightNode.w.mul(boneMatW));
return skinMatrix.transformDirection(normalLocal).xyz;
}
/**
* Disposes of GPU resources.
*/
dispose() {
if ( this._computeNode ) {
this._computeNode.dispose();
}
this._bakedBuffer = null;
this._bakedClips = null;
this._instanceOffsetsBuffer = null;
if ( this._boneMatricesTex ) this._boneMatricesTex.dispose();
this._boneMatricesTex = null;
this._readBoneTex = null;
this._computeNode = null;
}
}
export { GPUSkinningCompute, sortBonesHierarchically, parseTrackName, packAnimationData, bakeAnimation };
export default GPUSkinningCompute;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment