tfrere's picture
tfrere HF Staff
feat: standalone Microduck RL playground (MuJoCo WASM + ONNX policies)
c8f39b4
Raw History Blame
20.4 kB
// Microduck RL playground: the REAL trained policies, not a procedural waddle.
//
// Physics runs in MuJoCo compiled to WebAssembly (the official
// @mujoco/mujoco bindings), stepping the same MJCF the policies were
// trained on (apirrone/mjlab_microduck). The controller is one of the
// exported ONNX checkpoints from apirrone/microduck_runtime, executed with
// onnxruntime-web at 50 Hz (timestep 0.005 s, decimation 4) - exactly the
// loop from mjlab_microduck/scripts/infer_policy.py.
//
// Obs layout (61D, "new-cmd-obs" flavor, from the ONNX metadata):
// [base_ang_vel(3), projected_gravity(3), joint_pos(14), joint_vel(14),
// last_action(14), command(13)]
// command = [vx, vy, wz, head_pose(4), body_pose(6)]; for the sitstand
// policy, command[0] is the posture flag (1 = sit, 0 = stand).
// Action (14) = joint position targets relative to the default pose.
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import { buildRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION } from "./duck.js";
import { VARIANTS, VARIANT_NAMES, materialHookFor, randomVariantName, applyVariant, specToHex } from "./variants.js";
import loadMujoco from "https://cdn.jsdelivr.net/npm/@mujoco/mujoco@3.11.0/mujoco.js";
import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.mjs";
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
const POLICY_DIR = "./policies";
const POLICIES = {
walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
roulade: `${POLICY_DIR}/roulade.onnx`,
};
// From the ONNX metadata (identical for all alpha policies) and the STAND
// keyframe in mjlab's scene_walk.xml. Order matches the actuators in
// the MJCF.
const JOINT_NAMES = [
"left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle",
"neck_pitch", "head_pitch", "head_yaw", "head_roll",
"right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle",
];
const DEFAULT_POSE = new Float32Array([
0, -0.08726646259971647, -0.457924, -0.004940, 0.452984,
0.3490658503988659, 0.3490658503988659, 0, 0,
0, 0.08726646259971647, 0.457924, 0.004940, -0.452984,
]);
const NUM_JOINTS = 14;
const OBS_SIZE = 61;
const CMD_SIZE = 13;
const ACTION_SCALE = 1.0;
const TIMESTEP = 0.005;
const DECIMATION = 4;
const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
// Velocity command limits, same as infer_policy.py's keyboard mapping.
const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_LAT = 0.2, VEL_ANG = 1.0;
const mount = document.getElementById("scene");
const loadingEl = document.getElementById("loading");
const hudEl = document.getElementById("hud");
const statsEl = document.getElementById("stats");
const verbEl = document.getElementById("verb");
const setLoading = (msg) => { loadingEl.textContent = msg; };
// Surface boot failures on the page itself: a rejected top-level await
// otherwise leaves the loading screen up with no visible error.
window.addEventListener("unhandledrejection", (e) => {
const msg = e.reason?.stack || e.reason?.message || String(e.reason);
setLoading(`Boot failed: ${msg}`);
console.error("[rl] unhandled rejection", e.reason);
});
window.addEventListener("error", (e) => {
setLoading(`Boot failed: ${e.message}`);
});
const bootSteps = [];
const traced = (label, p) => {
bootSteps.push(label);
return p.then(
(v) => {
bootSteps.splice(bootSteps.indexOf(label), 1);
if (bootSteps.length) setLoading(`Loading ${bootSteps.join(", ")}\u2026`);
return v;
},
(err) => { console.error(`[rl] ${label} FAILED`, err); throw err; },
);
};
// ── MJCF preparation ────────────────────────────────────────────────────
// robot_allcollisions.xml is what infer_policy.py's scene.xml includes: it
// carries body/shell collision geoms that robot_walk.xml lacks, which the
// sitstand policy needs (a sit rests the trunk on the ground).
// The visual meshes are irrelevant to the dynamics: every body carries an
// explicit <inertial>, and visual geoms have contype=0 conaffinity=0.
// Stripping them means the MuJoCo VFS only needs the ~10 meshes referenced
// by collision geoms.
async function buildPhysicsXml() {
const src = await (await fetch(`${MODEL_DIR}/robot_allcollisions.xml`)).text();
const doc = new DOMParser().parseFromString(src, "text/xml");
for (const g of [...doc.querySelectorAll('geom[class="visual"]')]) g.remove();
const usedMeshes = new Set(
[...doc.querySelectorAll("geom[mesh]")].map((g) => g.getAttribute("mesh")),
);
for (const m of [...doc.querySelectorAll("asset > mesh")]) {
const name = m.getAttribute("name") ?? m.getAttribute("file").replace(/\.stl$/i, "");
if (!usedMeshes.has(name)) m.remove();
}
const root = doc.documentElement;
const el = (tag, attrs) => {
const e = doc.createElement(tag);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
return e;
};
root.appendChild(el("option", { timestep: String(TIMESTEP) }));
doc.querySelector("worldbody").appendChild(
el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
);
// STAND keyframe from mjlab's scene_walk.xml (STAND2 pose).
const qposFree = "0 0 0.12 1 0 0 0";
const pose14 = Array.from(DEFAULT_POSE).join(" ");
const kf = doc.createElement("keyframe");
kf.appendChild(el("key", { name: "STAND", qpos: `${qposFree} ${pose14}`, ctrl: pose14 }));
root.appendChild(kf);
const meshFiles = [...doc.querySelectorAll("asset > mesh")].map((m) => m.getAttribute("file"));
return { xml: new XMLSerializer().serializeToString(doc), meshFiles };
}
// ── Boot physics + policy in parallel with the render rig ──────────────
setLoading("Loading MuJoCo WASM, policies and meshes\u2026");
const [mujoco, { xml, meshFiles }, k] = await Promise.all([
traced("mujoco wasm", loadMujoco()),
traced("physics xml", buildPhysicsXml()),
traced("kinematics", loadKinematics(`${MODEL_DIR}/kinematics.json`)),
]);
const vfs = new mujoco.MjVFS();
await Promise.all(
meshFiles.map(async (f) => {
// Same cache-busted URL as duck.js so the browser reuses the render
// meshes instead of downloading the collision subset a second time.
const buf = await (await fetch(`${MODEL_DIR}/meshes/${f}?v=${MESH_VERSION}`, { cache: "force-cache" })).arrayBuffer();
// meshdir="assets" in the MJCF, so the compiler looks up "assets/<f>".
vfs.addBuffer(`assets/${f}`, new Uint8Array(buf));
}),
);
const sessions = {};
let currentVariant = randomVariantName();
const rigPromise = (async () => {
return buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
})();
const sessionOpts = { executionProviders: ["wasm"] };
[sessions.walk, sessions.sitstand, sessions.roulade] = await Promise.all([
ort.InferenceSession.create(POLICIES.walk, sessionOpts),
ort.InferenceSession.create(POLICIES.sitstand, sessionOpts),
ort.InferenceSession.create(POLICIES.roulade, sessionOpts),
]);
setLoading("Compiling physics\u2026");
const model = mujoco.MjModel.from_xml_string(xml, vfs);
const data = new mujoco.MjData(model);
// Addresses resolved once. qpos/qvel/sensordata views are re-read at each
// use: the WASM heap can grow and detach earlier TypedArray views.
// NOTE: unlike the Python bindings, these accessor fields are plain numbers.
const qposAdr = JOINT_NAMES.map((n) => model.jnt(n).qposadr);
const dofAdr = JOINT_NAMES.map((n) => model.jnt(n).dofadr);
const gyroAdr = model.sensor("imu_ang_vel").adr;
const trunkId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, "trunk_base");
const standKeyId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND");
let uiReady = false;
const lastAction = new Float32Array(NUM_JOINTS);
const obs = new Float32Array(OBS_SIZE);
const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
const velCmd = new Float32Array(3); // twist command, driven by held keys
// Declared before the control loop starts: buildObs checks it to decide
// between the auto-run default and manual key control.
const held = new Set();
let mode = "walk"; // "walk" | "sitstand" | "roulade"
let sitFlag = 0;
function resetSim() {
mujoco.mj_resetDataKeyframe(model, data, standKeyId);
mujoco.mj_forward(model, data);
lastAction.fill(0);
sitFlag = 0;
// Buttons reflect sitFlag; keep them honest after auto-resets.
if (uiReady) syncButtons();
}
resetSim();
// ── Observation ─────────────────────────────────────────────────────────
const _q = new THREE.Quaternion();
const _g = new THREE.Vector3();
function buildObs() {
const qpos = data.qpos, qvel = data.qvel, sens = data.sensordata;
let i = 0;
// base_ang_vel: gyro sensor at the IMU site
for (let a = 0; a < 3; a++) obs[i++] = sens[gyroAdr + a];
// projected gravity: world -z rotated into the trunk frame
const xq = data.body(trunkId).xquat; // [w, x, y, z]
_q.set(xq[1], xq[2], xq[3], xq[0]).conjugate();
_g.set(0, 0, -1).applyQuaternion(_q);
obs[i++] = _g.x; obs[i++] = _g.y; obs[i++] = _g.z;
for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qpos[qposAdr[j]] - DEFAULT_POSE[j];
for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qvel[dofAdr[j]];
for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = lastAction[j];
// command: walking/roulade use the twist; sitstand uses cmd[0] as the
// posture flag. "Run" means run: with no keys held the walking policy
// gets a forward velocity by default, keys override it.
cmd.fill(0, 0, 3);
if (mode === "walk") {
cmd[0] = held.size ? velCmd[0] : VEL_FWD;
cmd[1] = velCmd[1]; cmd[2] = velCmd[2];
} else if (mode === "roulade") {
cmd[0] = velCmd[0]; cmd[1] = velCmd[1]; cmd[2] = velCmd[2];
} else {
cmd[0] = sitFlag;
}
for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
return obs;
}
// ── Control loop (50 Hz, async because ONNX inference is async) ────────
let ctrlHz = 0;
let fallenSince = null;
async function controlStep() {
const feeds = { obs: new ort.Tensor("float32", buildObs(), [1, OBS_SIZE]) };
const out = await sessions[mode].run(feeds);
const act = out.actions.data;
lastAction.set(act);
const ctrl = data.ctrl;
for (let j = 0; j < NUM_JOINTS; j++) ctrl[j] = DEFAULT_POSE[j] + act[j] * ACTION_SCALE;
for (let s = 0; s < DECIMATION; s++) mujoco.mj_step(model, data);
// Fall detection: walk/sitstand have no get-up skill, so auto-reset when
// down for good. Height alone would false-positive on a deep sit, so
// "fallen" = trunk tilted past ~60 deg (projected gravity z above -0.5)
// or sunk below the floor, sustained for over a second. The roulade rolls
// the trunk on purpose and recovers on its own, so it gets a much longer
// grace window before we call it stuck.
const z = data.qpos[2];
const now = performance.now();
const tipped = obs[5] > -0.5; // projected gravity z, from the last obs
const graceMs = mode === "roulade" ? 5000 : 1000;
if (tipped || z < 0.02) {
fallenSince ??= now;
if (now - fallenSince > graceMs) { resetSim(); fallenSince = null; }
} else {
fallenSince = null;
}
}
let running = true;
(async function controlLoop() {
let next = performance.now();
let count = 0, hzT0 = next;
while (running) {
await controlStep();
count++;
const now = performance.now();
if (now - hzT0 > 500) {
ctrlHz = (count * 1000) / (now - hzT0);
count = 0; hzT0 = now;
}
next += CTRL_DT * 1000;
const wait = next - performance.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
else next = performance.now(); // fell behind: don't spiral
}
})();
// ── Rendering (three.js rig driven by qpos) ─────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x08080c);
const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
camera.position.set(0.55, 0.35, 0.7);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
mount.appendChild(renderer.domElement);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
scene.environmentIntensity = 0.45;
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);
keyLight.position.set(2, 4, 2);
scene.add(keyLight);
const fill = new THREE.DirectionalLight(0xffffff, 0.4);
fill.position.set(-2, 2, 1.5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffb366, 0.7);
rim.position.set(0, 3, -2);
scene.add(rim);
function makeFloorTexture() {
const N = 256;
const c = document.createElement("canvas");
c.width = c.height = N;
const g = c.getContext("2d");
g.fillStyle = "#131009";
g.fillRect(0, 0, N, N);
g.strokeStyle = "rgba(255, 179, 102, 0.05)";
g.lineWidth = 2;
g.strokeRect(1, 1, N - 2, N - 2);
g.fillStyle = "rgba(255, 198, 30, 0.10)";
for (const x of [0, N]) {
for (const y of [0, N]) {
g.beginPath();
g.arc(x, y, 5, 0, Math.PI * 2);
g.fill();
}
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(48, 48);
return tex;
}
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 12),
new THREE.MeshStandardMaterial({ map: makeFloorTexture(), roughness: 0.9, metalness: 0.0 }),
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const rig = await rigPromise;
scene.add(rig.placer);
const trunkGroup = rig.bodies.get("trunk_base");
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 0.25;
controls.maxDistance = 3;
controls.maxPolarAngle = Math.PI / 2 - 0.03;
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
new ResizeObserver(resize).observe(mount);
resize();
// The rig's root already applies the MJCF Z-up -> three Y-up fix, so the
// trunk group can take the freejoint pose in raw MJCF coordinates.
const _target = new THREE.Vector3();
function syncRig() {
const qpos = data.qpos;
trunkGroup.position.set(qpos[0], qpos[1], qpos[2]);
trunkGroup.quaternion.set(qpos[4], qpos[5], qpos[6], qpos[3]);
for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
// Camera target eases toward the trunk so the duck stays framed while
// it walks away.
_target.set(qpos[0], qpos[2], -qpos[1]);
controls.target.lerp(_target, 0.06);
}
// Quack: a quick jaw flap on every mode/colour change. The jaw isn't a
// MuJoCo joint (duck.js re-creates the hinge in JS), so this is purely
// cosmetic and can't upset the policy.
const QUACK_MS = 480;
let quackAt = -Infinity;
const quack = () => { quackAt = performance.now(); };
function syncJaw() {
const t = (performance.now() - quackAt) / QUACK_MS;
setJawOpen(rig, t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0);
}
function renderStats() {
const [vx, vy, wz] = velCmd;
const posture = mode === "walk"
? `running policy`
: mode === "roulade"
? `roulade policy`
: `sitstand policy \u00b7 ${sitFlag ? "sit" : "stand"}`;
statsEl.innerHTML =
`<b>${posture}</b><br>` +
`cmd vx ${vx.toFixed(2)} \u00b7 vy ${vy.toFixed(2)} \u00b7 wz ${wz.toFixed(2)}<br>` +
`ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s`;
}
function loop() {
requestAnimationFrame(loop);
syncRig();
syncJaw();
controls.update();
renderStats();
renderer.render(scene, camera);
}
loadingEl.style.display = "none";
hudEl.hidden = false;
loop();
// ── Input: hold-to-command keys + HUD buttons ───────────────────────────
function refreshVelCmd() {
velCmd[0] = held.has("fwd") ? VEL_FWD : held.has("back") ? VEL_BACK : 0;
velCmd[1] = held.has("left") ? VEL_LAT : held.has("right") ? -VEL_LAT : 0;
velCmd[2] = held.has("turnl") ? VEL_ANG : held.has("turnr") ? -VEL_ANG : 0;
}
const KEYMAP = {
ArrowUp: "fwd", KeyW: "fwd", KeyZ: "fwd",
ArrowDown: "back", KeyS: "back",
ArrowLeft: "turnl", ArrowRight: "turnr",
KeyA: "left", KeyQ: "left", KeyE: "right", KeyD: "right",
};
window.addEventListener("keydown", (e) => {
if (e.repeat) return;
if (e.code === "KeyR") { resetSim(); return; }
const act = KEYMAP[e.code];
if (!act) return;
e.preventDefault();
held.add(act);
refreshVelCmd();
});
window.addEventListener("keyup", (e) => {
const act = KEYMAP[e.code];
if (!act) return;
held.delete(act);
refreshVelCmd();
});
window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
const btnWalk = document.getElementById("btn-walk");
const btnSit = document.getElementById("btn-sit");
const btnRoulade = document.getElementById("btn-roulade");
document.getElementById("btn-reset").addEventListener("click", resetSim);
// The headline verb tracks the active policy.
const VERBS = { walk: "run", sitstand: "sit", roulade: "roll over" };
function setMode(next) {
quack();
if (next !== "sit") {
// Leaving a sit: let the sitstand policy stand the duck back up first.
if (mode === "sitstand" && sitFlag === 1) {
sitFlag = 0;
setTimeout(() => { mode = next; lastAction.fill(0); syncButtons(); }, 2000);
syncButtons();
return;
}
mode = next;
lastAction.fill(0);
} else {
// Hand over gently: hold the stand under the sitstand policy for a
// moment before commanding the sit, or the abrupt session switch
// (walking's action history + instant flag) knocks the duck over.
mode = "sitstand";
sitFlag = 0;
lastAction.fill(0);
setTimeout(() => {
if (mode === "sitstand") { sitFlag = 1; syncButtons(); }
}, 800);
}
syncButtons();
}
function syncButtons() {
const sitting = mode === "sitstand" && sitFlag === 1;
btnWalk.classList.toggle("on", mode === "walk" || (mode === "sitstand" && !sitting));
btnSit.classList.toggle("on", sitting);
btnRoulade.classList.toggle("on", mode === "roulade");
verbEl.textContent = VERBS[mode];
}
btnWalk.addEventListener("click", () => setMode("walk"));
btnSit.addEventListener("click", () => setMode("sit"));
btnRoulade.addEventListener("click", () => setMode("roulade"));
// ── Colour swatches: re-skin the rig live, with a quack ─────────────────
// One representative colour per variant so the dots read at a glance.
const SWATCH_SLOT = { classic: "feet", charcoal: "headDome", purple: "feet", blue: "facePlate" };
const swatchesEl = document.getElementById("swatches");
const swatchBtns = new Map();
for (const name of VARIANT_NAMES) {
const v = VARIANTS[name];
const b = document.createElement("button");
b.style.background = specToHex(v[SWATCH_SLOT[name] ?? "bodyShell"]);
b.setAttribute("aria-label", `${name} colours`);
b.addEventListener("click", () => {
if (name === currentVariant) return;
currentVariant = name;
applyVariant(rig, name);
quack();
syncSwatches();
});
swatchesEl.appendChild(b);
swatchBtns.set(name, b);
}
function syncSwatches() {
for (const [name, b] of swatchBtns) b.classList.toggle("on", name === currentVariant);
}
syncSwatches();
syncButtons();
uiReady = true;
// Deterministic hooks for automated verification (rAF pauses in
// background tabs, and the control loop is async).
window.rl = {
model, data, mujoco,
get mode() { return mode; },
get sitFlag() { return sitFlag; },
buildObs, cmd,
velCmd, lastAction, resetSim,
step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
render: () => { syncRig(); renderer.render(scene, camera); },
};