Recommended setup for separate gltf models and animations?

I have a bunch of characters that all share the same set of animations - walk, run, etc. Each character is in a separate .glb file, exported from blender. The animations and the armature are in a separate .blend file which each character links to. I’m exporting each blender file to an individual .glb file, so that means I have N+1 .glb files, one for each character and then an additional one for the shared animations.

Also, to make things more interesting, the characters use multiple materials, so what gets exported is a group with a bunch of individual skinned meshes as its children.

Is there anything special I need to do to get these characters to animate in three.js? Right now when I load the character I just see a pile of random polygons, which I’m guessing is because the bones are missing. Loading in the animation into a mixer and calling play seems to have no effect.

1 Like

I know a few users have done this workflow, but I haven’t seen a real writeup of how it’s done unfortunately. I’d suggest starting by trying to import the animation .glb — which will need to contain the armature and some sort of mesh attached, even if it’s just a cube or a single vertex — and try to get the animation to display with THREE.SkeletonHelper.

Once that is working it should be easier to transfer the animation or the armature to your character files. Note that it’s important for both the animation and character files to contain identical armatures.

OK I’ve got that bit working. Here’s what I had to do:

  1. Two .blend files, one with the armature + actions, the other with the character mesh.
  2. Very important: Make sure that the armature is in a blender collection - it is much easier to link to a collection than to the raw resource nodes.
  3. Also, make sure to add an NLA track for each action you want to export, even if you are not using NLAs, it’s needed to ensure that the exporter will actually include the action. Otherwise, your GLB file only gets the actions that are visible in the current scene. (I tried using fake users but it was insufficient.)
  4. The blender file with the character should “link” to the collection inside the blender armature file.
  5. Click on the linked armature, and go to Object -> relations -> create library override.
  6. Now you can parent the skinned mesh to the armature, and run various actions to see how well they look.
  7. Export both blender files to GLB.
  8. Now, in the three.js program, load the two .glb files using the GLTFLoader. Grab the skinned mesh, the armature, and the animation clip from the gltf object. Note that the animation clips will be named based on the name of the NLA track, not on the name of the action (if you don’t use NLAs then the name of the action is used).
  9. Re-parent the skinned mesh as a child of the armature.
  10. Create an animation mixer for the armature and feed the clip into it.
  11. Add the armature to the scene.

OK, so all that being done, there are a few more things I need to figure out. What I have described is more of a proof of concept than it is a production-ready solution. In an environment where you have dozens of characters running around on screen, I’m not sure that cloning the armature for each individual character is going to be efficient, but I don’t know whether or not that can be avoided.

Also, all of the objects in my scene have outlines - people think of them as “toon outlines” but actually this is more of an Alphonse Mucha / Art Nouveau look. The outlines are done by drawing the back-facing polygons with a shader that displaces along the normal direction. (This requires that every model be a seamless mesh, or at least any seams in the mesh must be concave - that’s easy to do by adding a bevel modifier with a bevel width of 0 to every model, so that when the vertices are displaced outwards the bevels inflate).

For non-skinned objects this is pretty simple, just clone the mesh (the buffer geometry is shared between them) and overwrite the material slot with the outline material. You can make the outline mesh a child of the original mesh if you wipe the local transform.

However, this doesn’t work for skinned meshes and I am not sure exactly what the right recipe is yet. For performance reasons I’d like to combine all of the various bits and pieces of the character into a single geometry buffer for the outlines, since the outlines always draw the same material. But that means understanding the structure of a skinned mesh well enough to take one apart and re-assemble it, with all the vertex weights intact.

Got the outlines working, I just needed to add the skinning chunks in my outline shader.

BTW, the way I am doing shader code is using a custom TypeScript template tag function:

const outlineVert = glsl`
#define STANDARD
uniform float outlineThickness;

${ShaderChunk.skinning_pars_vertex}

void main() {
  ${ShaderChunk.beginnormal_vertex}
  ${ShaderChunk.skinbase_vertex}
  ${ShaderChunk.skinnormal_vertex}
  ${ShaderChunk.begin_vertex}
  transformed += objectNormal * outlineThickness;
  ${ShaderChunk.skinning_vertex}
  ${ShaderChunk.project_vertex}
}`;

The glsl tag function is just an identity function, but the VSCode syntax highlighter for GLSL code can be set up to recognize this. It means that I can embed my GLSL code directly in JS strings and get full syntax highlighting, without having to make separate .glsl files.

2 Likes

It would take a lot of work to avoid this, and probably requires changing the default vertex shaders. I think cloning the armature is reasonable unless you have quite a lot of characters onscreen. If you decide to try it, see Suggestion: Support animating multiple SkinnedMeshes using a single Skeleton · Issue #9606 · mrdoob/three.js · GitHub.

Need advice on how to use 2 separate files GLB 3D model and GLB animation in a-frame (threejs)
It is necessary to explain to us how we can use animations from the following resources
https://www.mixamo.com

in the following example

Where it says the following:
Upload your own animations be replacing the animated-f.glb & animated-m.glb files with any file you have of baked skeleton animations. You can download baked skeleton animations from Mixamo. Make sure to convert the .FBX file from Mixamo into a .GLB or .GLTF before uploading to 8th Wall. Different Ready Player Me body types have differenct sizes when creating animations create rigs for each possible Ready Player Me body type.

But these recommendations don’t work. Therefore, we need instructions on how we can use animations from mixamo, readyplayer.me, and the babylon,js example.

// ////////////////////////////
// DYNAMICALLY RIGGING AVATAR MESH
// ////////////////////////////
const LoopMode = {
once: THREE.LoopOnce,
repeat: THREE.LoopRepeat,
pingpong: THREE.LoopPingPong,
}
function wildcardToRegExp(s) {
return new RegExp(^${s.split(/\*+/).map(regExpEscape).join('.*')}$)
}
function regExpEscape(s) {
return s.replace(/[|\{}()^$+*?.]/g, ‘\$&’)
}

const animationRigComponent = {
schema: {
remoteId: {
default: ‘animated’,
type: ‘string’,
},
clip: {
default: ‘*’,
type: ‘string’,
},
duration: {
default: 0,
type: ‘number’,
},
clampWhenFinished: {
default: !1,
type: ‘boolean’,
},
crossFadeDuration: {
default: 0,
type: ‘number’,
},
loop: {
default: ‘repeat’,
oneOf: Object.keys(LoopMode),
},
repetitions: {
default: 1 / 0,
min: 0,
},
timeScale: {
default: 1,
},
},
init() {
this.model = null,
this.remoteModel = null,
this.mixer = null,
this.activeActions =
let {remoteId} = this.data
remoteId = remoteId.charAt(0) === ‘#’ ? remoteId.slice(1) : remoteId
const remoteEl = document.getElementById(remoteId)
remoteEl || console.error(‘ramx: Remote entity not found. Pass the ID of the entity, not the model.’),
this.model = this.el.getObject3D(‘mesh’),
this.remoteModel = remoteEl.getObject3D(‘mesh’)

const tryToLoad = () => {
  this.model && this.remoteModel && this.load()
}
this.model ? tryToLoad() : this.el.addEventListener('model-loaded', (e) => {
  this.model = e.detail.model,
  tryToLoad(),
  console.log(this.model.animations)
}),
this.remoteModel ? tryToLoad() : remoteEl.addEventListener('model-loaded', (e) => {
  this.remoteModel = e.detail.model,
  tryToLoad()
})

},
load() {
const {el} = this
console.log(this.remoteModel)
this.model.animations = […this.remoteModel.animations],
this.mixer = new THREE.AnimationMixer(this.model),
this.mixer.addEventListener(‘loop’, (e) => {
el.emit(‘animation-loop’, {
action: e.action,
loopDelta: e.loopDelta,
})
}),
this.mixer.addEventListener(‘finished’, (e) => {
el.emit(‘animation-finished’, {
action: e.action,
direction: e.direction,
})
}),
this.data.clip && this.update({})
console.log * (this.data)
},
remove() {
this.mixer && this.mixer.stopAllAction()
},
update(prevData) {
if (!prevData) return
const {data} = this
const changes = AFRAME.utils.diff(data, prevData)
if (‘clip’ in changes) {
return this.stopAction(),
void (data.clip && this.playAction())
}
this.activeActions.forEach((action) => {
‘duration’ in changes && data.duration && action.setDuration(data.duration),
‘clampWhenFinished’ in changes && (action.clampWhenFinished = data.clampWhenFinished),
(‘loop’ in changes || ‘repetitions’ in changes) && action.setLoop(LoopMode[data.loop], data.repetitions),
‘timeScale’ in changes && action.setEffectiveTimeScale(data.timeScale)
})
},
stopAction() {
const {data} = this
for (let i = 0; i < this.activeActions.length; i++) data.crossFadeDuration ? this.activeActions[i].fadeOut(data.crossFadeDuration) : this.activeActions[i].stop()
this.activeActions =
},
playAction() {
console.log()
if (!this.mixer) return
const {model} = this
const {data} = this
const clips = model.animations || (model.geometry || {}).animations ||
if (!clips.length) return
const re = wildcardToRegExp(data.clip)
for (let clip, i = 0; clip = clips[i]; i++) {
if (clip.name.match(re)) {
const action = this.mixer.clipAction(clip, model)
action.enabled = !0,
action.clampWhenFinished = data.clampWhenFinished,
data.duration && action.setDuration(data.duration),
data.timeScale !== 1 && action.setEffectiveTimeScale(data.timeScale),
action.setLoop(LoopMode[data.loop], data.repetitions).fadeIn(data.crossFadeDuration).play(),
this.activeActions.push(action)
}
}
},
tick(t, dt) {
this.mixer && !Number.isNaN(dt) && this.mixer.update(dt / 1e3)
},
}
export {animationRigComponent}

How do we import these files using Blender into a file similar to animated-m.glb
animated-m.glb (632.3 KB)
(see photo for structure)


. This way we can apply the animation glb file to the Character glb file 661fdefedb35794cf234d7db.glb
661fdefedb35794cf234d7db.glb (1.5 MB)

hey did you find a way?

hey i am facing some issue i have 2 glb file i wanted to transfer the animation into our model
but animation file dosent contain any bones

Make sure to replace ‘shared_animations.glb’, ‘character1.glb’, etc., with the paths to your actual GLB files. Also, adjust the logic for loading and handling animations based on the naming conventions and structure of your files.

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';

// Assuming you have a scene, camera, and renderer set up already

// Load the shared animations GLB file
const loader = new GLTFLoader();
loader.load('shared_animations.glb', (gltf) => {
    // Here, you can extract and store the animations for later use
    const animations = gltf.animations;

    // Load individual character GLB files
    const characters = ['character1.glb', 'character2.glb', 'character3.glb'];
    characters.forEach((characterFile) => {
        loader.load(characterFile, (charGltf) => {
            const character = charGltf.scene;
            scene.add(character);

            // Assuming your character has a unique name or identifier
            const mixer = new THREE.AnimationMixer(character);
            // Associate the appropriate animations with the mixer
            const characterAnimations = animations.filter(anim => anim.name.includes(character.name));
            characterAnimations.forEach((anim) => {
                mixer.clipAction(anim).play();
            });
        });
    });
});

// Render loop
function animate() {
    requestAnimationFrame(animate);
    // Update animation mixers
    const delta = clock.getDelta();
    mixers.forEach((mixer) => {
        mixer.update(delta);
    });
    renderer.render(scene, camera);
}
animate();