Overview

Avaturn is a service that generates a 3D avatar from a photo. The resulting avatar can be downloaded as a GLB file, and a model with an Idle animation is available as well.

However, no walk animation is included. If you want to walk the avatar through a VR space, you need to supply one separately.

This article documents the process of applying an existing Mixamo walk animation to an Avaturn avatar (retargeting). Three approaches were explored:

  1. Blender 5.0 CLI — offline conversion
  2. Node.js — comparison and verification
  3. Three.js runtime — fully browser-based retargeting

Background Knowledge

Bone Name Mapping

Avaturn and Mixamo share the same skeletal structure (Humanoid), but use different bone names:

MixamoAvaturn
mixamorig:HipsHips
mixamorig:LeftArmLeftArm
mixamorig:HeadHead

Stripping the mixamorig: prefix is all it takes to make the names match.

Rest Pose Differences

Even with matching bone names, the bone rotations in the rest pose (bind pose) can differ subtly between the two rigs. Because animation quaternion values are expressed relative to the rest pose, applying them directly can distort the character's pose.


Approach 1: Blender 5.0 CLI

Blender 5.0 Layered Action API

Blender 5.0 introduced a major overhaul to the animation API. The legacy action.fcurves accessor no longer works; the data now lives in a layer structure:

# Blender 4.x (old)
fcurves = action.fcurves

# Blender 5.0 (new)
fcurves = action.layers[0].strips[0].channelbags[0].fcurves

Bulk keyframe insertion also changed from insert() to foreach_set() + update():

fc = channelbag.fcurves.new(data_path, index=0)
fc.keyframe_points.add(num_keys)
fc.keyframe_points.foreach_set('co', [frame0, val0, frame1, val1, ...])
fc.update()  # required: sorts and removes duplicates

Script Flow

/Applications/Blender.app/Contents/MacOS/Blender --background --python scripts/retarget-anim.py
# 1. Import the Mixamo model and extract fcurve data from its animation
src_fcs = mixamo_action.layers[0].strips[0].channelbags[0].fcurves
for fc in src_fcs:
    # Save bone name, property, and keyframe data
    fc.keyframe_points.foreach_get('co', co_array)

# 2. Import the Avaturn model and retrieve its rest pose
src_rest = bone.matrix_local.to_quaternion()  # armature space

# 3. Rename bones and apply rest-pose correction
avaturn_bone = mixamo_bone.replace("mixamorig:", "")

# Torso bones skip correction (Hips, Spine chain, Neck, Head)
if skip_correction:
    q_corrected = q_src  # copy as-is
else:
    # Limbs: apply rest-pose delta correction
    src_rest_local = src_parent_world.inverted() @ src_world
    tgt_rest_local = tgt_parent_world.inverted() @ tgt_world
    q_corrected = tgt_rest_local.inverted() @ src_rest_local @ q_src

# 4. Place Idle + Walk on NLA tracks and export

Lessons from Trial and Error

ProblemCauseFix
Limbs don't movekeyframe_points.insert() only inserts 2 keyframesSwitch to foreach_set('co', ...) + update()
Avatar walks lying on its backHips rest-pose correction flipped the whole model's orientationSkip correction for Hips
Avatar walks hunched forwardSpine-chain rest-pose correction tilted the torso forwardSkip correction for Spine chain
Chin points upwardHead/Neck rest pose differs slightly between rigsAccepted as within tolerance

Final correction skip list: Hips, Spine, Spine1, Spine2, Neck, Head (the entire torso chain)

Failure Log

The following is a chronological record of failures encountered along the way, for reference if you hit the same issues.

Failure 1: Rename bones only → avatar disappears

The simplest approach was to strip the mixamorig: prefix and rename the track.

// Simplest approach (failed)
const cloned = track.clone();
cloned.name = track.name.replace(/^mixamorig:?/, "");

Symptom: The avatar completely disappears. It flickers briefly on each movement.

Cause: The Mixamo source model and the Avaturn model have different rest poses. Applying raw quaternion values directly causes bones to rotate to extreme angles, turning the mesh inside out or collapsing it. The Hips rotation in Mixamo is [-0.71, -0.01, -0.04, 0.70] (roughly 90° rotated), while Avaturn's rest pose is [0, 0, 0, 1] (nearly no rotation). This ~90° gap destroys the entire skeleton.

Failure 2: Apply rest-pose correction to all bones → walks lying on its back

Next, rest-pose delta correction was implemented for every bone.

src_rest_local = src_parent_world.inverted() @ src_world
tgt_rest_local = tgt_parent_world.inverted() @ tgt_world
q_corrected = tgt_rest_local.inverted() @ src_rest_local @ q_src

Symptom: The avatar is supine (lying face-up), sliding across the floor.

Cause: The Hips (root bone) rest-pose correction flipped the entire model's orientation by 90°. The value returned by Blender's bone.matrix_local.to_quaternion() includes Blender's coordinate-system conversion (Y-up → Z-up), which leaked into the Hips correction.

Failure 3: Skip correction for Hips only → avatar walks hunched forward

Hips was excluded from correction.

Symptom: Avatar walks with a forward lean (hunched posture).

Cause: The rest-pose correction for Spine, Spine1, and Spine2 tilted the spine forward. The coordinate-system conversion effect propagated through the entire torso chain, not just Hips.

Failure 4: Skip correction for the entire torso → chin points upward

Hips, Spine, Spine1, Spine2, Neck, and Head were all skipped.

Symptom: Walking looks nearly correct, but the chin tilts slightly upward.

Cause: Head and Neck rest poses differ by approximately 5–14° between Mixamo and Avaturn — a subtle difference in skeletal design. Skipping correction doesn't fully resolve it, but it's within an acceptable range for practical use.

Failure 5: Instant switch between Idle and Walk → flickering

Idle animation (bundled with Avaturn) and Walk animation (retargeted) were toggled with a walking flag.

// Instant switch (failed)
walkAction.setEffectiveWeight(store.walking ? 1 : 0);
idleAction.setEffectiveWeight(store.walking ? 0 : 1);

Symptom: The avatar briefly disappears (flickers) each time walking starts or stops.

Cause: Torso bone quaternion values differ significantly between Idle and Walk (due to rest-pose differences and coordinate-system conversion). An instantaneous weight change from 0 to 1 snaps the pose with no interpolation, momentarily corrupting the mesh.

Fix: Use THREE.MathUtils.lerp to blend 10% per frame for a smooth transition.

Failure 6: Avatar sinks into the floor or floats above it

After applying the Walk animation, the avatar bounces vertically.

Symptom: The avatar repeatedly sinks and floats during walking.

Cause: The Walk animation's Hips bone contains Y-axis position keyframes (natural vertical bob during walking), but the root-motion removal code only locked X and Z.

Fix: Add Y locking to the root-motion removal.

// Before: X/Z only
hipsNode.position.x = restX;
hipsNode.position.z = restZ;

// After: lock X/Y/Z
hipsNode.position.x = restX;
hipsNode.position.y = restY;  // added
hipsNode.position.z = restZ;

Approach 2: Node.js Comparison and Verification

To identify the difference between the working Blender version and the non-working Three.js runtime version, a script was written to parse and compare GLB files directly.

GLB Parser

function parseGlb(path) {
  const buf = readFileSync(path);
  const jsonLen = buf.readUInt32LE(12);
  const json = JSON.parse(buf.slice(20, 20 + jsonLen).toString());
  const binOffset = 20 + jsonLen + 8;
  const bin = buf.slice(binOffset);
  return { json, bin };
}

What the Comparison Revealed

Hips (keys: blender=25, mixamo=31):
  Blender frame0: [-0.0222, 0.0178, -0.0353, 0.9990]
  Mixamo  frame0: [-0.7147, -0.0120, -0.0377, 0.6983]
  Max diff: 0.692513 ✗ DIFFERENT

LeftUpLeg (keys: blender=25, mixamo=31):
  Blender frame0: [-0.0033, -0.3042, -0.9521, 0.0316]
  Mixamo  frame0: [-0.0033, -0.3042, -0.9521, 0.0316]
  Max diff: 0.000000 ✓ SAME

Limb quaternion values are identical between Mixamo and Blender. A simple rename is all that's needed.

Hips, however, differs by about 90°. This is because Blender's GLB importer applies a Y-up → Z-up coordinate conversion on import and reverses it on export. This round-trip changes the Hips rotation values.

Summary of Findings

Bone typeBlender vs. MixamoRequired treatment
Limbs (Arm, Leg, Hand, Foot)IdenticalRename only
Hips (root)~90° differenceCoordinate-system correction needed
Spine, Neck, HeadSmall differenceMostly fine as-is

Approach 3: Three.js Runtime (Fully Browser-Based)

Final retarget.ts

The comparison findings led to a straightforward implementation:

// Coordinate-system correction for Hips only (90° X rotation)
const BLENDER_COORD_FIX = new THREE.Quaternion()
  .setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);

export function retargetMixamoToAvaturn(
  mixamoClip: THREE.AnimationClip,
  _mixamoScene: THREE.Object3D,
  targetScene: THREE.Object3D,
): THREE.AnimationClip | null {
  const targetBones = new Set<string>();
  targetScene.traverse((node) => {
    if ((node as THREE.Bone).isBone) targetBones.add(node.name);
  });

  if (!targetBones.has("Hips")) return null;

  const newTracks: THREE.KeyframeTrack[] = [];

  for (const track of mixamoClip.tracks) {
    const dotIdx = track.name.indexOf(".");
    const srcBone = track.name.slice(0, dotIdx);
    const prop = track.name.slice(dotIdx + 1);
    const tgtBone = srcBone.replace(/^mixamorig:?/, "");

    if (!targetBones.has(tgtBone)) continue;

    if (prop === "quaternion" && tgtBone === "Hips") {
      // Hips: apply coordinate-system correction
      const values = new Float32Array(track.values.length);
      const q = new THREE.Quaternion();
      for (let i = 0; i < track.values.length; i += 4) {
        q.set(track.values[i], track.values[i+1],
              track.values[i+2], track.values[i+3]);
        q.premultiply(BLENDER_COORD_FIX);
        values[i] = q.x; values[i+1] = q.y;
        values[i+2] = q.z; values[i+3] = q.w;
      }
      newTracks.push(new THREE.QuaternionKeyframeTrack(
        `${tgtBone}.${prop}`, Array.from(track.times), Array.from(values)
      ));
    } else if (prop === "quaternion") {
      // Limbs: rename only
      const cloned = track.clone();
      cloned.name = `${tgtBone}.${prop}`;
      newTracks.push(cloned);
    }
    // position/scale tracks are excluded
    // (Mixamo uses cm units, Avaturn uses m units — a 100x difference)
  }

  return new THREE.AnimationClip("walk-retargeted",
    mixamoClip.duration, newTracks);
}

Why Position Tracks Are Excluded

The comparison revealed a critical scale mismatch:

Mixamo Hips translation:  Y=83.6  (cm units)
Avaturn Hips rest pos:    Y=0.97  (m units)

Approximately 100×. Applying the position track as-is would cause the avatar to balloon to enormous size. Since the Avatar component's root-motion removal code manages position, excluding it from the animation is safe.

Idle ↔ Walk Crossfade

Instant switching caused flickering, so lerp is used for smooth blending:

const targetWalk = store.walking ? 1 : 0;
const currentWalk = walkAction.getEffectiveWeight();
const blend = THREE.MathUtils.lerp(currentWalk, targetWalk, 0.1);
walkAction.setEffectiveWeight(blend);
idleAction.setEffectiveWeight(1 - blend);

Advancing the weight by 10% each frame prevents abrupt pose changes.


Web Upload Implementation

The final result allows users to upload an Avaturn GLB and immediately use it as a walking avatar.

// Settings.tsx — upload button
<input type="file" accept=".glb" onChange={(e) => {
  const file = e.target.files?.[0];
  if (!file) return;
  const url = URL.createObjectURL(file);
  setCustomAvatar(url);  // store the Blob URL
}} />
// Avatar.tsx — runtime retargeting
if (!hasWalkAnimation) {
  // Load the Mixamo source
  loader.load("/models/avatar.glb", (mixamoGltf) => {
    const walkClip = retargetMixamoToAvaturn(
      mixamoData.clip, mixamoGltf.scene, scene
    );
    if (walkClip) {
      const action = mixer.clipAction(walkClip);
      action.play();
      action.setEffectiveWeight(0);
    }
  });
}

No Blender, no Node.js. Everything runs in the browser.

Runtime Failure Log

The browser-based retargeting reproduced the same failures as the Blender version, and also introduced runtime-specific issues.

Failure R1: Rest-pose correction applied → bizarre posture

The rest-pose correction formula from the Blender version was ported directly to Three.js.

// Rest-pose correction in Three.js (failed)
const srcRestLocal = new THREE.Quaternion();
srcRestLocal.copy(srcParentWorld).invert().multiply(srcWorld);
q.premultiply(srcRestLocal).premultiply(tgtRestLocalInv);

Symptom: The body stretches wildly and ends up supine. Worse than the Blender version.

Cause: Blender's bone.matrix_local.to_quaternion() returns values in armature space, while Three.js's getWorldQuaternion() returns values in scene space (including parent-object rotations). Even though both represent the "rest-pose quaternion," the coordinate systems differ, so the correction formula produces completely different results.

Failure R2: Rest pose sampled while animation is playing

// Ordering issue (failed)
const idleAction = mixer.clipAction(idleClip);
idleAction.play();  // ← Idle animation starts

// At this point, bone world quaternions are already affected by Idle
const walkClip = retargetMixamoToAvaturn(clip, mixamoScene, scene);

Symptom: Correction values vary each time, producing unpredictable poses.

Cause: When getWorldQuaternion() is called inside retargetMixamoToAvaturn, the Idle animation is already playing, so the bone rotation reflects the current Idle frame rather than the rest pose.

Fix: Take a snapshot of the rest pose using scene.clone(true) before starting any animation.

Failure R3: Position tracks applied as-is → avatar becomes enormous

// Rename only, including position tracks (failed)
const cloned = track.clone();
cloned.name = newTrackName;

Symptom: The avatar grows to fill the entire screen.

Cause: Mixamo's Hips position is Y=83.6 (cm), while Avaturn's Hips rest position is Y=0.97 (m) — roughly a 100× scale difference. Applying the position track directly places the avatar's pelvis 83 meters above the ground.

Fix: Use only quaternion (rotation) tracks; exclude position and scale tracks entirely.

Failure R4: Coordinate-system correction applied to all torso bones → double rotation below Spine

The 90° X rotation was applied not just to Hips but to all torso bones.

if (SKIP_CORRECTION.has(tgtBoneName)) {
  q.premultiply(BLENDER_COORD_FIX);  // applied to all torso bones (failed)
}

Symptom: Hips looks correct, but Spine and above rotate to abnormal angles.

Cause: The coordinate-system correction applied to Hips automatically propagates to child bones (Spine, Neck, Head). Applying the same correction individually to those children results in double application.

Fix: Verified with the Node.js comparison script — apply correction to Hips only.

Spine (center):
  Runtime:  [0.6695, -0.0123, 0.0338, 0.7420]  ← large error from double application
  Blender:  [0.0385, 0.0122, 0.0338, 0.9986]
  MaxDiff:  0.630967 ✗ DIFFERENT

↓ After switching to Hips-only correction

Spine (center):
  Runtime:  [-0.0513, 0.0152, 0.0326, 0.9980]  ← nearly matching
  Blender:  [0.0385, 0.0122, 0.0338, 0.9986]
  MaxDiff:  0.089769 ⚠ CLOSE

Accuracy Comparison

Results from verifying Blender vs. runtime with the Node.js comparison script:

BoneBlender vs. RuntimeResult
Hips0.011⚠ Nearly matching
Spine0.090⚠ Small difference
LeftUpLeg0.000✓ Identical
LeftArm0.000✓ Identical
LeftShoulder0.000✓ Identical
Head0.112✗ Slight deviation
Neck0.241✗ Slight deviation

Limbs are identical. The Head/Neck deviation (5–14°) manifests as a slightly upward-tilted chin — within acceptable range for practical use.


Comparison of the Three Approaches

Blender CLINode.js VerificationThree.js Runtime
DependenciesBlender 5.0Node.js + fsNone (browser)
Processing timeBuild timeTest timeRuntime
AccuracyHighest (includes Blender coordinate conversion)Comparison onlySufficient for practical use
User experienceDeveloper pre-convertsUpload GLB and use immediately
Use casePreset avatarsDebuggingUser-custom avatars

Summary

Key Findings

  1. Limb animations work correctly with bone renaming alone. Avaturn and Mixamo limb rest poses are identical.
  2. Only Hips (the root bone) requires coordinate-system correction. This is equivalent to a 90° X rotation corresponding to Blender's Y-up ↔ Z-up conversion.
  3. Position tracks cannot be used. Mixamo uses cm units and Avaturn uses m units — a 100× difference.
  4. Idle ↔ Walk switching must use lerp crossfading to avoid flickering.
  5. Blender 5.0's Action API was completely redesigned. action.fcurvesaction.layers[0].strips[0].channelbags[0].fcurves.

Debugging Approach

Parsing GLB files directly and comparing values numerically proved to be the most effective approach. Identifying differences by numbers is more reliable than repeated visual inspection in a browser.

node scripts/compare-anims.mjs   # Blender version vs. raw Mixamo data
node scripts/verify-retarget.mjs  # Post-correction runtime vs. Blender version