| name | vrma-animation-retargeting |
| description | How to correctly retarget external animations (VRMA, Mixamo GLB) onto VIVERSE avatars in PlayCanvas or Three.js, and how to drive procedural bone poses directly for game characters. |
| prerequisites | ["PlayCanvas or Three.js engine","VIVERSE Avatar SDK","animation clip files"] |
| tags | ["playcanvas","threejs","vrma","mixamo","animation","retargeting","skeleton","viverse","avatar"] |
VRMA Animation Retargeting for VIVERSE Avatars
Retarget VRMA to VIVERSE avatars using manual sampling and per-bone frame correction.
When To Use This Skill
Use when:
- VRMA / Mixamo GLB clips do not animate VIVERSE avatars correctly via default binder
- Limbs/axes are wrong despite valid curve bindings
- You need to retarget animation clips from a different character onto a VIVERSE VRM avatar
- You need deterministic animation retargeting in PlayCanvas or Three.js
- You want procedural bone poses (idle, aim, shoot) without a VRMA file
Read Order
- This file
skills/viverse-avatar-sdk/patterns/avatar-animation.md
skills/viverse-avatar-sdk/patterns/avatar-animation-troubleshooting.md
VRM Loading Checklist (Three.js)
Before touching any animation, get the loading right:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
const loader = new GLTFLoader();
loader.register(parser => new VRMLoaderPlugin(parser));
loader.load(avatarUrl, gltf => {
const vrm = gltf.userData.vrm;
vrm.scene.traverse(child => {
child.visible = true;
child.layers.set(0);
if (child.isMesh) {
child.castShadow = true;
child.frustumCulled = false;
const convertMat = m => {
const std = new THREE.MeshStandardMaterial();
if (m.map) std.map = m.map;
if (m.normalMap) std.normalMap = m.normalMap;
if (m.color) std.color.copy(m.color);
if (m.emissive) std.emissive.copy(m.emissive);
if (m.emissiveMap) std.emissiveMap = m.emissiveMap;
std.transparent = m.transparent ?? false;
std.opacity = m.opacity ?? 1.0;
std.side = THREE.DoubleSide;
std.roughness = 0.7;
std.metalness = 0.0;
return std;
};
child.material = Array.isArray(child.material)
? child.material.map(convertMat) : convertMat(child.material);
}
});
vrm.scene.rotation.y = 0;
const leftFoot = vrm.humanoid.getRawBoneNode('leftFoot');
const rightFoot = vrm.humanoid.getRawBoneNode('rightFoot');
let footWorldY = null;
for (const foot of [leftFoot, rightFoot]) {
if (!foot) continue;
foot.updateWorldMatrix(true, false);
const wp = new THREE.Vector3().setFromMatrixPosition(foot.matrixWorld);
if (footWorldY === null || wp.y < footWorldY) footWorldY = wp.y;
}
const box = new THREE.Box3().setFromObject(vrm.scene);
const scale = TARGET_HEIGHT / Math.max(box.max.y - box.min.y, 0.1);
vrm.scene.scale.setScalar(scale);
vrm.scene.position.y = footWorldY !== null
? PLATFORM_Y - footWorldY * scale
: PLATFORM_Y - new THREE.Box3().setFromObject(vrm.scene).min.y;
});
Procedural Pose Animation (No VRMA file needed)
For game characters needing specific poses (idle, aim, shoot) without a VRMA clip.
Lighter than loading VRMA, works well for action/tower defense games.
Step 0 — Always log available bones first
Bone availability varies by avatar. Build poses only from confirmed bones:
const testBones = ['leftUpperArm','rightUpperArm','leftLowerArm','rightLowerArm',
'leftHand','rightHand','spine','chest','upperChest','neck','head'];
const found = testBones.filter(b => vrm.humanoid.getNormalizedBoneNode(b));
const missing = testBones.filter(b => !vrm.humanoid.getNormalizedBoneNode(b));
console.log('[Anim] Found:', found.join(', '));
console.log('[Anim] Missing:', missing.join(', '));
[!CAUTION]
Missing bones silently skip — no error. Build pose tables only from confirmed-found bones.
Step 1 — Understand VRM normalized bone axes
Verified by screenshot testing on a real VIVERSE avatar (rotation.y = 0, facing -Z):
Upper arm axes
| Bone | rx | ry | rz |
|---|
leftUpperArm | twist | ry- = FORWARD (-Z) / ry+ = backward | rz- = RAISES / rz+ = lowers |
rightUpperArm | twist | ry+ = BACKWARD (+Z) / ry- = forward | rz+ = RAISES / rz- = lowers |
[!CAUTION]
ry sign is OPPOSITE between left and right arms. ry+ on leftUpperArm goes BACKWARD (toward camera), not forward. This is the single most common axis mistake.
[!CAUTION]
rz sign is also OPPOSITE between left and right. rz+ raises the right arm, but rz+ LOWERS the left arm.
[!CAUTION]
ry delta must be ≥ 1.2 radians to be visually distinct. When rz (vertical) and ry (horizontal) change simultaneously, the vertical component dominates. A ry delta of 0.6 rad is invisible next to a simultaneous rz change of 0.8 rad.
[!CAUTION]
Bow arm ry must be identical across all poses. Any ry delta on the bow arm creates visible forward/back oscillation during animation. Only rz and lowerArm should differ between poses for the bow arm.
Lower arm and hand axes
| Bone | rx | rz |
|---|
leftLowerArm | forearm twist (NOT elbow bend) | rz+ = elbow bends |
rightLowerArm | forearm twist (NOT elbow bend) | rz- OR rz+ = elbow bends (test both) |
hand | wrist flex/extension | wrist deviation |
[!CAUTION]
lowerArm rx is NOT elbow bend. It only pronates/supinates the forearm. The elbow bend axis is rz.
Summary table (rotation.y = 0, character faces -Z)
leftUpperArm: ry- = forward, ry+ = back, rz- = UP, rz+ = DOWN
rightUpperArm: ry+ = back, ry- = forward, rz+ = UP, rz- = DOWN
leftLowerArm: rz+ = elbow bend, rx = forearm twist
rightLowerArm: rz±= elbow bend (test direction), rx = forearm twist
hand: rx = wrist flex, ry = wrist deviation
⚠️ If rotation.y = Math.PI (character faces +Z), ALL ry signs flip.
Step 2 — Define named pose tables
Working bow-archer pose example (verified on VIVERSE avatar, rotation.y = 0):
const POSE_IDLE = {
leftUpperArm: [ 0.0, -1.57, -0.3],
leftLowerArm: [ 0.0, 0.0, 0.4],
leftHand: [ 0.0, 0.0, 0.0],
rightUpperArm: [ 0.0, 0.2, 0.8],
rightLowerArm: [ 0.0, 0.0, -0.6],
rightHand: [ 0.3, 0.0, 0.0],
spine: [ 0.10, 0.0, 0.0],
chest: [ 0.06, 0.0, 0.0],
upperChest: [ 0.04, 0.0, 0.0],
neck: [-0.05, 0.0, 0.0],
head: [-0.04, 0.0, 0.0],
};
const POSE_AIM = {
leftUpperArm: [ 0.0, -1.57, -0.5],
leftLowerArm: [ 0.0, 0.0, 0.6],
leftHand: [ 0.0, 0.0, 0.0],
rightUpperArm: [ 0.0, 1.5, 1.4],
rightLowerArm: [ 0.0, 0.0, 1.6],
rightHand: [ 0.5, 0.0, 0.0],
spine: [ 0.20, 0.0, 0.0],
chest: [ 0.14, 0.0, 0.0],
upperChest: [ 0.09, 0.0, 0.0],
neck: [-0.14, 0.0, 0.0],
head: [-0.12, 0.0, 0.0],
};
const POSE_RELEASE = {
leftUpperArm: [ 0.0, -1.57, -0.6],
leftLowerArm: [ 0.0, 0.0, 0.3],
leftHand: [ 0.0, 0.0, 0.0],
rightUpperArm: [ 0.0, 0.2, 0.5],
rightLowerArm: [ 0.0, 0.0, -0.4],
rightHand: [ 0.2, 0.0, 0.0],
spine: [ 0.12, 0.0, 0.0],
chest: [ 0.08, 0.0, 0.0],
upperChest: [ 0.05, 0.0, 0.0],
neck: [-0.07, 0.0, 0.0],
head: [-0.05, 0.0, 0.0],
};
Step 3 — Drive bones in render loop
function updateAnim(dt, vrm, shootPhase, shootT) {
const DRAW_DUR = 0.18;
const RELEASE_DUR = 0.07;
const RECOVER_DUR = 0.40;
let fromPose, toPose, t, done = false;
if (shootPhase === 0) {
t = Math.min(shootT / DRAW_DUR, 1);
fromPose = POSE_IDLE; toPose = POSE_AIM;
if (t >= 1) { shootPhase = 1; shootT = 0; }
} else if (shootPhase === 1) {
t = Math.min(shootT / RELEASE_DUR, 1);
fromPose = POSE_AIM; toPose = POSE_RELEASE;
if (t >= 1) { shootPhase = 2; shootT = 0; }
} else if (shootPhase === 2) {
t = Math.min(shootT / RECOVER_DUR, 1);
fromPose = POSE_RELEASE; toPose = POSE_IDLE;
if (t >= 1) done = true;
} else {
fromPose = POSE_IDLE; toPose = POSE_IDLE; t = 1;
}
const ease = t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
const h = vrm.humanoid;
for (const bone of Object.keys(toPose)) {
const node = h.getNormalizedBoneNode(bone);
if (!node) continue;
const from = fromPose[bone] || [0,0,0];
const to = toPose[bone];
node.quaternion.setFromEuler(new THREE.Euler(
from[0] + (to[0]-from[0])*ease,
from[1] + (to[1]-from[1])*ease,
from[2] + (to[2]-from[2])*ease, 'XYZ'
));
}
vrm.update(dt);
if (done) shootPhase = undefined;
}
[!CAUTION]
Always drive bones every frame in idle state. If _updateShootAnim returns early when shootPhase === undefined, the VRM reverts to T-pose. Add an else branch that applies POSE_IDLE at t=1.
[!CAUTION]
Do not call _updateShootAnim from inside renderer.render(). Call it from the game loop with real dt so a pause flag can skip it independently of the render.
Facing direction and aimAt formula
vrm.scene.rotation.y = 0;
model.rotation.y = Math.atan2(dir.x, -dir.z);
DashRunner Cross-Reference (avatar loading patterns)
From the DashRunner source, confirmed patterns for VIVERSE VRM:
DashRunner uses rotation.y = Math.PI (character faces +Z, camera behind at -Z).
Bastion Archer uses rotation.y = 0 (character faces -Z, enemies at -Z).
If you change rotation.y by Math.PI, all ry signs flip.
Core Problem (VRMA retargeting)
- VIVERSE avatar rig and VRMA reference frames differ per bone.
- Default binder/path mutation approach is insufficient for robust retargeting.
- Raw VRMA rotation keys include local rest transform; must convert to delta first.
Implementation Workflow (VRMA retargeting)
- Build bone map from avatar render rig (
Avatar_* target bones).
- Capture avatar parent-world rest quaternions at bind pose.
- Parse VRMA hierarchy and compute local rests + parent-world rests.
- Build manual sampler from raw track curves.
- Per frame:
- sample raw local rotation
- strip VRMA local rest:
delta = inv(vrmaLocalRest) * anim
- compute per-bone frame correction:
frameCorrect = inv(avatarParentWorld) * vrmaParentWorld
- conjugate delta into avatar frame and apply local rotation
- Drive sampler in update loop.
Key Equations
delta = inv(vrmaLocalRest) * animQuat
frameCorrect = inv(avatarParentWorld) * vrmaParentWorld
avatarLocal = frameCorrect * delta * inv(frameCorrect)
Critical Gotchas
[!CAUTION]
NEVER apply Mixamo FBX/GLB animations directly to VRM Normalized_Avatar_* bones without rig map math. VRM 1.0 Normalized bones expect an identity rest pose. Mixamo animations bake rotations relative to Mixamo's unique rest pose — renaming tracks without inverse matrix calculations causes instant deformation.
[!CAUTION]
NEVER rename skeleton bones at runtime in Three.js. SkinnedMesh.skeleton stores bones by index reference, not name. Renaming breaks skinning immediately.
- Vite/SPA Routing Bug:
.vrma files may return the SPA wildcard fallback. Rename to .glb to force correct MIME type.
- VRMAnimationLoaderPlugin TypeError: Import
VRMAnimationLoaderPlugin as a class and instantiate with new. createVRMAnimationLoaderPlugin does not exist.
Native VRMA Loading (Three.js — Recommended)
import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation';
const animLoader = new GLTFLoader();
animLoader.register(parser => new VRMAnimationLoaderPlugin(parser));
animLoader.load('assets/Walk_vrma.glb', animGltf => {
const vrmAnimation = animGltf.userData.vrmAnimations?.[0];
if (!vrmAnimation) return;
const clip = createVRMAnimationClip(vrmAnimation, vrm);
const mixer = new THREE.AnimationMixer(vrm.scene);
const action = mixer.clipAction(clip);
action.play();
});
Verification Checklist
References
skills/viverse-avatar-sdk/SKILL.md
skills/viverse-avatar-sdk/patterns/avatar-animation.md
skills/viverse-avatar-sdk/patterns/avatar-animation-troubleshooting.md