One dance, two characters
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.
.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.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.
- Go to mixamo.com and sign in with your Adobe ID (free).
- Open the Characters tab and pick The Boss.
- 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.
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.
- Drop
boss.fbxon the Character zone, andmocap-33s.bvhon Animation tracks. Both appear as chips (the motion reports its joint count and duration). - 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.
- Press Download GLB to save
boss_mocap-33s.glb. - Swap the character: click the × on its chip, drop
bruce.glbinstead (leave the same motion loaded), and downloadbruce_mocap-33s.glbthe same way.
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.
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.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));
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.
$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();
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
- 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. - 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.
- 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
- Tejaswi Gowda (script)
Assets
- The Boss (Mixamo character, .fbx): Mixamo, Adobe Licence: Adobe Mixamo terms (free with an Adobe ID).
- Bruce (MetaHuman sample .glb): creategamecharacters, metahuman-to-glb; MetaHuman by Epic Games Licence: not stated for the character (the pipeline code is MIT); a MetaHuman is an Epic Games asset.
- mocap-33s.bvh (bodyrig-webCLI sample motion): bodyrig-webCLI sample motion (the page does not say who captured it) Licence: not stated (bodyrig-webCLI is GPL-3.0; the sample motion has no terms of its own).
Tools
- bodyrig-webCLI (GPL-3.0)
- metahuman-to-glb (MIT)
- meshoptimizer (MIT)
- Mixamo (Adobe Mixamo terms (free with an Adobe ID))