Three js batched mesh animations + lod system.
- Features
- Demo installation
- Quick Start
- How to import animations
- Material available methods
- Batched mesh available methods
- Usage Examples
- Limits
- Individual units independent animation while each unit is just an instance of one batched mesh
- Create custom events linked to a certain animation frame
- Create any custom logic based on distance from a unit or it's state (alive, dead, injured, etc.)
- Animation LOD - set distance threshold to make animation simpler reducing gpu work
- Geometry LOD - set distance threshold to use simpler geometry seamlessly while preserving a current animation frame
- Use texture arrays to set textures for each unit type inside a shader efficiently
- One material supports up to 10 unit types with any amount of instances for each (browser available RAM is a limit)
git clone https://github.com/GuestGD/threeSBML.git
cd threeSBML
npm install
npx vite --host-
Export a character's animations using Blender addon
-
Put BatchedMeshLod.js and SkinnedBatchMaterial.js into your project manually or install using:
npm i @guestgd/three-sbml
- Import both scripts:
import { SkinnedBatchMaterial } from "./SkinnedBatchMaterial.js";
import { BatchedMeshLod } from "./BatchedMeshLod.js";- Load every character animations from it's bin file:
export async function loadBinArray(binPath) {
const response = await fetch(binPath);
const buffer = await response.arrayBuffer();
return new Float32Array(buffer);
}-
Load skinned mesh glb
-
Load texture maps of the characters. The maps must be combined to DataArrayTexture or CompressedArrayTexture. KTX2 CompressedArrayTexture is recommended. Here is an example of KTX-Software command to create ready to use CompressedArrayTexture with diffuse maps for 3 characters:
ktx create --format R8G8B8_UNORM --layers 3 --assign-tf linear --encode basis-lz --generate-mipmap --mipmap-filter box difuseEnemy1.png difuseEnemy2.png difuseEnemy3.png enemiesDiffArray.ktx2IMPORTANT! Use 8 bit maps. WebGL extensions needed for 16/32 bit texture arrays are not widely supported by devices.
Take care of the textures order also. The shader gonna use corresponding index for each map by default.
- Now create new SkinnedBatchMaterial:
const material = new SkinnedBatchMaterial({
maps: {
mapsArray: ktxLoaded.enemiesDiffArray, // diffuseMaps CompressedArrayTexture
normalMapsArray: null, // normalMaps CompressedArrayTexture. Must be THREE.NoColorSpace
ormMapsArray: null, // ORM texture contains AOmap in R channel, roughness map in G channel and metalness map in B channel
},
unitsData, // must contain unit names with next data for each: rawMatrices, boneInverses, bonesAmount
animLodDistance, // Vector2 object with distance thresholds allowing to simplify animations on distance. Example: new THREE.Vector2(5000, 15000);
useAO: false, // this flag defines if shader gonna use R channel from ORM texture
});Example of unitData object structure:
const unitsData = {
soldier: {
rawMatrices: soldierLoadedBin,
boneInverses: soldierSkinnedMesh.skeleton.boneInverses,
bonesAmount: soldierSkinnedMesh.skeleton.bones.length,
},
mutant: {
rawMatrices: mutantLoadedBin,
boneInverses: mutantSkinnedMesh.skeleton.boneInverses,
bonesAmount: mutantSkinnedMesh.skeleton.bones.length,
},
};- Create new BatchedMeshLod:
const batchedEnemies = new BatchedMeshLod(
{
soldier: {
geometries: [
skinnedMeshesLods.soldier[0].geometry, // Prepare geometry LODs in Blender to use it here
skinnedMeshesLods.soldier[1].geometry,
skinnedMeshesLods.soldier[2].geometry,
skinnedMeshesLods.soldier[3].geometry,
skinnedMeshesLods.soldier[4].geometry,
],
distLod: [2000, 4000, 7000, 20000],
instancesAmount: 10,
},
mutant: {
geometries: [
skinnedMeshesLods.mutant[0].geometry,
skinnedMeshesLods.mutant[1].geometry,
skinnedMeshesLods.mutant[2].geometry,
skinnedMeshesLods.mutant[3].geometry,
skinnedMeshesLods.mutant[4].geometry,
],
distLod: [2000, 3000, 5000, 10000],
instancesAmount: 20,
},
},
material // or just use any THREE material if you dont need animations
);
scene.add(batchedEnemies);- Put units to desired position. There are 2 options:
- Use placeGrid() method to place units in a grid
batchedEnemies.placeGrid("soldier", instancesPerUnit, {
start: new THREE.Vector3(-12000, -100, 8000),
spacing: new THREE.Vector3(3000, 0, -3000),
columns: 10,
});- Or use method setMatrix(unitName, instanceIndex, matrix4)
const scaleValue = 200;
const position = new THREE.Vector3(100, 0, 100);
const scale = new THREE.Vector3(scaleValue, scaleValue, scaleValue);
const quaternion = new THREE.Quaternion();
const matrix4 = new THREE.Matrix4();
matrix4.compose(position, quaternion, scale);
setMatrix("soldier", 10, matrix4);- Let the material get access to LOD distance values and matrices:
material.setBatchedMesh(batchedEnemies);- Now you are ready to setup animations. The material must know what frame ranges from Float32Array contains certain animations. That's why Blender addon gonna be so useful. It exports JS helper that contains all necessary lines of code. It allows to just copy-paste all necessary data about frame ranges and transitions. You will find lines like these:
material.setAnimationFrames("soldier", "soldierRest", 0, 0, 30);
material.setAnimationFrames("soldier", "soldierFire", 1, 27, 30);
material.setAnimationFrames("soldier", "soldierIdle", 28, 71, 30);
material.setAnimationFrames("soldier", "soldierRun", 72, 79, 30);
...
...
material.setAnimationTransitions("soldier", "soldierFire", {
soldierIdle: "soldierFire_To_soldierIdle",
soldierRun: "soldierFire_To_soldierRun",
});- At this points you are ready to simply play animations.
Play animation for a range of instances:
material.playAnimationBatched("soldier", 0, 10, "soldierFire", "loop", 0.75);Play animation for a certain instance:
material.playAnimation("soldier", 5, "soldierFire", "loop", 0.75);- Then in main animate loop:
const delta = new THREE.Clock().getDelta();
batchedEnemies.update(camera);
batchedEnemies.material.update.updateAnimations(delta);Animations must be exported as a flat Float32Array of matrices. There are several ways of doing this:
- The easiest way - use my Blender addon . I really recommend using this approach to simplify your pipeline. The addon exports desired units animations allowing to pick a step, creating transition animations for all animation combinations in addition. Also it exports a tiny JS helper with lines of code you can just copy-paste into your project to set animation frames and transition animations.
- You can manually play an animation of a typical skinned mesh in any three js project and write bones matrices of desired animations for needed frames. Then export it to any format you prefer.
- Any other way you want.
The final result must be Float32Array containing bones matrices for all desired frames of unit's animations. For example:
- A character has 50 bones. Each bones has 4x4 matrix so 50*16 = 800. It means each 800 values of this Float32Array is 1 frame of the character animations. Combine all animations frames into one array this way.
setAnimationFrames(unitName, animName, startFrame, endFrame, fps, transit);setAnimationTransitions(unitName, animName, (transitions = {}));setBatchedMesh(batchedMesh);playAnimation(unitName, localInstanceId, animName, mode, speed);playAnimation(unitName, localInstanceId, animName, mode, speed);playAnimationBatched(
unitName,
startInstanceId,
endInstanceId,
animName,
mode,
speed
);stopAnimation(unitName, localInstanceId);stopAnimationBatched(unitName, startInstanceId, endInstanceId);pauseAnimation(unitName, localInstanceId);resumeAnimation(unitName, localInstanceId, newSpeed);isPaused(unitName, localInstanceId);isStopped(unitName, localInstanceId);isPlaying(unitName, localInstanceId);getInstanceAnimationData(unitName, instanceIndex);transitionToAnimation(
unitName,
localInstanceId,
targetAnimName,
targetAnimMode,
targetAnimSpeed,
transitionClipName,
transitionClipSpeed,
onComplete
);createEvent(unitName, animName, frame, callback);removeEvent(unitName, animName, frame);updateAnimations(delta);setDistanceState(unitName, config, callBack);getDistanceState(unitName, instanceId);setState(unitName, instanceId, stateName);getState(unitName, instanceId);setMatrix(unitName, instanceIndex, matrix4);getMatrix(unitName, instanceIndex);placeGrid(
unitName,
count,
(opts = {
start,
spacing,
columns,
scale,
rot,
})
);setMapIndex(unitName, value);getUnitInstancesAmount(unitName);getUnitInstanceDistance(unitName, instanceIndex);unitLookAt(unitName, instanceIndex, targetVector, rotationOffset);unitMoveTowards(
unitName,
instanceIndex,
targetVector,
lerpFactor,
stopDistance,
rotateToTarget
);- Up to 10 animated unit types per one material. Every unit type uses it's own separate Data texture to keep animation frames. Data array texture is not used due to not so wide support of Webgl extensions required for 32 bits Data array texture. One Data texture could be used to keep frames of many units animations also - this way it's possible to animate hundreds of unit types but it gonna be a nightmare for debugging and managing
- 16384 is a total limit of all units instances you can control with one material
- 16384 is also a max total amount of frames every single unit can have