One dance, two characters

Two characters dancing on a white stage

Two dancers on a blank white stage — a construct, if you like. Bruce starts a dance; The Boss picks it up three seconds later and copies it move for move, while a camera swings around so you see their faces for most of it. The characters are rigged and animated outside Strata with bodyrig-webCLI — it retargets one motion onto any skeleton and writes a single GLB with the animation baked in — then Strata stages and renders them. Open the editor, open the Shell tab, and paste each block in order.

Two different sources, one pipeline. The two dancers come from completely different places: The Boss is a Mixamo .fbx (a mixamorig skeleton in centimetres, T-pose), and Bruce is a MetaHuman .glb (a ~1,200-bone rig in metres, A-pose, with 51 face blendshapes). bodyrig-webCLI maps one motion file onto both skeletons and writes a plain GLB for each, so Strata imports them natively — through the ordinary Import dialog, with no plug-in, converter or per-character setup — and they arrive with the same clip name and length, ready for the same $S() timeline grammar.
What you'll need. A free Mixamo account (an Adobe ID) for The Boss, a browser for bodyrig-webCLI (it runs entirely in the page — nothing is uploaded), and Bruce, a MetaHuman exported by creategamecharacters/metahuman-to-glb (see Make your own MetaHuman for what that project is). The pieces are free, but the characters keep their own terms: Mixamo's characters belong to Adobe and are not meant to be re-hosted, so this tutorial never asks you to commit the Boss file anywhere public.

1 Download The Boss from Mixamo

The Boss is a stock Mixamo character, so he comes with the standard mixamorig skeleton that bodyrig-webCLI already knows how to map.

  1. Go to mixamo.com and sign in with your Adobe ID (free).
  2. Open the Characters tab and pick The Boss.
  3. Press Download. Choose format FBX Binary (.fbx) and pose T-pose, then download and save the file as boss.fbx.

Mixamo's menus change from time to time, so the labels above may read a little differently — what matters is the character as a rigged .fbx in a T-pose.

Want a different dance? You don't have to use Mixamo's motions. bodyrig-webCLI accepts BVH, FBX or GLB for the animation, and it ships a 33-second sample, mocap-33s.bvh, that this tutorial uses. If you'd rather pick one from Mixamo, open its Animations tab, choose a dance, and download it as FBX with skin set to Without Skin; the tool reads animation-only FBX files too.

2 Get Bruce

Bruce is already a GLB, hosted by the metahuman-to-glb demo. Save it from your browser or with curl (it is about 80 MB):

curl -L -o bruce.glb https://creategamecharacters.github.io/metahuman-to-glb/5.7/characters/bruce/bruce.glb

3 Bake the dance onto both with bodyrig-webCLI

Open tejaswigowda.com/bodyrig-webCLI (or clone the repo and run npm start for a local copy at http://127.0.0.1:8010). It is a static page: the badge in the header reads 100% local · zero egress, and you can confirm in DevTools' Network tab that your files never leave the machine.

  1. Drop boss.fbx on the Character zone, and mocap-33s.bvh on Animation tracks. Both appear as chips (the motion reports its joint count and duration).
  2. The page bakes as soon as it has a character and a motion; the status bar ends with Done: boss_mocap-33s.glb and a live preview plays. To re-bake after changing an option, press Bake & export GLB.
  3. Press Download GLB to save boss_mocap-33s.glb.
  4. Swap the character: click the × on its chip, drop bruce.glb instead (leave the same motion loaded), and download bruce_mocap-33s.glb the same way.
bodyrig-webCLI with bruce.glb loaded as the character and mocap-33s as the animation track, the status bar reading Done: bruce_mocap-33s.glb, and Bruce dancing in the preview.
bodyrig-webCLI after baking Bruce: character and motion chips on the left, the finished GLB playing in the preview on the right.

Because both characters were baked from the same motion file, they are two different sources mapped natively into Strata by one tool: the animation clip in each GLB has the same name, mocap-33s, and the same 33.1-second length — which is exactly what lets The Boss copy Bruce later with a plain time offset.

It's a CLI in a page. Every control is just a command in the box under Command: after dropping the files, bake --fps 30 is what the button runs. Useful flags are --foot-lock (pin planted feet with IK), --loop (close the cycle), --in-place and --trim 2:20 (seconds). Run help to see them all. For a MetaHuman, leave Align reference pose ticked — it is what makes the A-pose Bruce and the T-pose Boss follow the same motion.
Skip the dragging for Bruce. The tool also reads a deep link, so you can bake him straight from his URL: http://tejaswigowda.com/bodyrig-webCLI/?character=<bruce URL>&motion=<motion URL>&run=bake (URL-encode the two values). Add --hide Outfits to the command to leave the clothes out of the bake if you only want the bare body.

The GLBs are meshopt-compressed to keep them small (Bruce comes out near 39 MB, down from about 80 MB). Strata's loader already includes the meshopt decoder, so nothing extra is needed; tick off Meshopt compress only if you plan to open the file in a tool that lacks it.

4 Import both into Strata

Use File → Import (or drag both files onto the editor window). A progress banner tracks each file while it is read and parsed. When the Import glTF dialog appears, leave the compression options off — Strata skips mesh simplification on skinned and morph-target meshes anyway, and rigged characters don't benefit from it — and press OK.

Then check what arrived in the Shell:

scene.children.map(o => o.name);        // ['bruce_mocap-33s.glb', 'boss_mocap-33s.glb']
scene.animations.map(a => a.name);      // ['mocap-33s', 'mocap-33s']

Give them short names to address them by, and measure each character so you know whether the scale is sane. Strata normalizes each import (it centres and grounds the model, and rescales a centimetre-unit Mixamo rig by ×0.01), so both should come in close to 2 m. Each shell command runs in its own scope, so look the characters up again in every block that needs them:

const bruce = scene.children.find(o => o.name.startsWith('bruce'));
const boss  = scene.children.find(o => o.name.startsWith('boss'));
relabel(bruce, 'bruce');               // Bruce becomes #bruce
relabel(boss, 'boss');                 // The Boss becomes #boss

const height = o => new THREE.Box3().setFromObject(o).getSize(new THREE.Vector3()).y;
[height(bruce).toFixed(2), height(boss).toFixed(2)]   // about 2 m each

If one is off, scale his group so he stands about as tall as Bruce — one command, undoable:

const boss = scene.children.find(o => o.name.startsWith('boss'));
const k = 1.8 / new THREE.Box3().setFromObject(boss).getSize(new THREE.Vector3()).y;
editor.execute(new SetScaleCommand(editor, boss, boss.scale.clone().multiplyScalar(k)));

5 Stage the two dancers

Both characters were baked at the origin, so they would dance inside each other. Slide The Boss 2.8 m to the side:

const boss = scene.children.find(o => o.name.startsWith('boss'));
editor.execute(new SetPositionCommand(editor, boss, new THREE.Vector3(2.8, 0, 0)));

6 Build a blank white stage

The stage is a featureless white void: a white background that the floor fades into, light fog to soften the horizon, a glossy white floor for a faint reflection, and two lights.

scene.background = new THREE.Color(0xffffff);
scene.fog = new THREE.Fog(0xffffff, 14, 55);

const floor = new Mesh(new PlaneGeometry(200, 200),
  new MeshStandardMaterial({ color: 0xffffff, roughness: 0.12, metalness: 0.1 }));
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
floor.name = 'Floor';
editor.execute(new AddObjectCommand(editor, floor));

const sky = new THREE.HemisphereLight(0xffffff, 0xdddddd, 1.6);
sky.name = 'Sky';
editor.execute(new AddObjectCommand(editor, sky));

const key = new DirectionalLight(0xffffff, 2.2);
key.position.set(4, 8, 5);
key.castShadow = true;
key.name = 'Key';
editor.execute(new AddObjectCommand(editor, key));
The environment setting. If the void looks grey rather than white, check the Scene tab: Background and Environment can override what you set in code (see Build a night scene from scratch for the same trap).

7 Play the dance: Bruce leads, The Boss follows

Each imported clip is bound to its own character, so two clips can share a name without clashing — the selector decides whose clip plays. Put Bruce's on the Universal Timeline at 0 s and the Boss's at 3 s:

$S('#bruce').at(0).play('mocap-33s');
$S('#boss').at(3).play('mocap-33s');

Open the Animations tab and scrub: Bruce starts moving at once, The Boss begins three seconds in and repeats the same moves. The whole timeline is 36.1 seconds — the 33.1-second dance plus the Boss's 3-second head start.

Why bake the same motion twice? You could retarget live in the page, but a baked clip is plain keyframe data: it is versioned with the scene, selectable by $S, and rendered identically every time. Retargeting per character is the part that needs a tool; sequencing and staging is what Strata is for.

8 Add a camera that orbits the dancers

A static shot from the wrong side shows the back of both heads for most of the song. First, find which way the dance faces: at the playhead around 10 s, orbit the viewport until you see their faces. For the sample motion they face about 150° away from +Z, toward −Z and slightly +X. Build a camera clip that stays on that side and drifts ±35° around it with a slow push-in, keyed every half second:

const cam = new PerspectiveCamera(35, 16 / 9, 0.5, 60);   // tight near/far = more depth precision
cam.name = 'Orbit Camera';
editor.execute(new AddObjectCommand(editor, cam));
relabel(cam, 'orbit-camera');

// A camera helper that does the aiming for us. Object3D.lookAt points +Z at the
// target; a camera looks down -Z, so use a camera, not a bare Object3D.
const rig = new PerspectiveCamera();
const center = new THREE.Vector3(1.7, 1.0, 0.7);     // between the two dancers
const times = [], pos = [], quat = [];
for (let t = 0; t <= 33; t += 0.5) {
  const az = THREE.MathUtils.degToRad(150 + 35 * Math.sin(2 * Math.PI * t / 33));
  const r  = 8.5 - Math.sin(Math.PI * t / 33);        // push in, then ease out
  rig.position.set(center.x + r * Math.sin(az), 1.45, center.z + r * Math.cos(az));
  rig.lookAt(center);
  times.push(t);
  pos.push(...rig.position.toArray());
  quat.push(...rig.quaternion.toArray());
}

addClip(scene, new THREE.AnimationClip('camera-orbit', 33, [
  new THREE.VectorKeyframeTrack(cam.uuid + '.position', times, pos),
  new THREE.QuaternionKeyframeTrack(cam.uuid + '.quaternion', times, quat),
]));
$S('#orbit-camera').at(0).play('camera-orbit');

Track names must be <object uuid>.<property>, which is why the clip is built from cam.uuid. If your dancers face another way, change the 150 (degrees from +Z) and check a few frames with holdTimelineAt:

const { holdTimelineAt } = await import('./editor/js/intelligence/timelineController.js');
holdTimelineAt(editor, 16);            // jump the whole scene to t = 16 s
editor.viewportCamera = cam;           // look through the new camera
editor.signals.viewportCameraChanged.dispatch();
Flickering clothes (z-fighting). A garment that hugs the body sits almost exactly on the skin under it, and for some frames of the dance the two surfaces tie in the depth buffer, so patches of skin show through the cloth. Two fixes, both already in this tutorial: keep the camera's near/far range tight (0.5–60 above, not 0.01–1000 — depth precision is spent mostly near the camera, and the ratio far/near is what matters), and push the layer underneath slightly back with a polygon offset. Bruce's outfit sits on his body mesh the same way, so if you see flicker on him, apply the same offset to his body mesh. The Boss's clothes are separate meshes (jacket, pants, shoes…), so list his meshes first, then offset whichever one is showing through:
const boss = scene.children.find(o => o.name.startsWith('boss'));
const names = [];
boss.traverse(o => { if (o.isMesh) names.push(o.name); });
names;                                          // pick the mesh that is poking through

const mesh = boss.getObjectByName(names[0]);    // replace names[0] with that name
mesh.material = mesh.material.clone();          // never edit a material that has already rendered
Object.assign(mesh.material, { polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 4 });
If a gap remains, raise the factor; if skin starts to poke through a layer that should be under it, you pushed the wrong one.

9 Render it

  1. Open the Render tab. The Snapshot section saves the scene exactly as it is right now, with no grid or gizmos: hold the timeline at a moment you like (holdTimelineAt(editor, 16)), pick Orbit Camera and a size, and press Take Snapshot.
  2. For the video, scroll to Video Render. With no Camera Sequence shots, the render uses the viewport camera, so add one shot at 0 s pointing at Orbit Camera (+ Add Shot, then choose the camera). Pick 1280 × 720 at 30 fps and press Render & Export. The whole 36.1-second timeline renders; expect it to take about as long as the clip itself.
  3. Keep the tab in the foreground while it renders — browsers throttle hidden tabs, which makes frames stall.

To keep the scene, set your repository and token in the Git tab and use Commit Scene. The characters and their baked clips are stored as binary asset files next to a small scene JSON, so the commit is large but not unmanageable (a few dozen megabytes for this scene).

// Sanity-check what you're about to commit
$S('#bruce').count();                  // 1
editor.timeline.toJSON().tracks.map(t => t.target);   // ['#bruce', '#boss', '#orbit-camera']

Credits and licences

People

Assets

Tools