microduck-ar / src /sim.js
multimodalart's picture
multimodalart HF Staff
Floor size estimation: learned play area, runtime fence walls, boundary + area readout
1bee80a verified
Raw History Blame Contribute Delete
18.6 kB
// Microduck AR sim core: the REAL trained RL policies stepping real MuJoCo
// physics, trimmed from the microduck-simulator playground (pollen-robotics)
// for AR: no arena visuals, no props, no rollers - just the walking duck, a
// kickable ball, one-shot kicks/rolls and the automatic fall recovery.
//
// Physics: MuJoCo WASM (@mujoco/mujoco), timestep 0.005 s, decimation 4.
// Controller: ONNX checkpoints from apirrone/microduck_runtime, run with
// onnxruntime-web at 50 Hz. Obs layout (61D):
// [base_ang_vel(3), projected_gravity(3), joint_pos(14), joint_vel(14),
// last_action(14), command(13)]
import * as THREE from "three";
import { MODEL_DIR, MESH_VERSION, loadGlbGeometries, geometryToBinaryStl } from "./duck.js";
import { signed } from "./signed.js";
const MUJOCO_URL = "https://cdn.jsdelivr.net/npm/@mujoco/mujoco@3.11.0/mujoco.js";
const ORT_URL = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.mjs";
const ORT_WASM_DIR = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
const POLICY_DIR = "./policies";
const POLICIES = {
walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
kickL: `${POLICY_DIR}/ball_kick_left.onnx`,
kickR: `${POLICY_DIR}/ball_kick_right.onnx`,
roll: `${POLICY_DIR}/roulade.onnx`,
groundpick: `${POLICY_DIR}/alpha_ground_pick.onnx`,
stand: `${POLICY_DIR}/BEST_alpha_stand.onnx`,
};
export 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",
];
export 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,
]);
export const NUM_JOINTS = 14;
const OBS_SIZE = 61;
const CMD_SIZE = 13;
const ACTION_SCALE = 1.0;
const TIMESTEP = 0.005;
const DECIMATION = 4;
export const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
// Velocity command limits (infer_policy.py's keyboard mapping).
export const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_ANG = 1.0;
export const BALL_RADIUS = 0.05;
const BALL_PARK_POS = "50 0 0.05";
// Invisible fence keeping duck + ball within reach of the placement spot.
// This is only the boot value: setFence() re-sizes the compiled wall geoms
// at runtime to the real floor area learned from AR hit-tests.
export const FENCE_HALF = 1.0;
const WALL_T = 0.025, WALL_H = 0.125;
const KICK_STEPS = 25; // 0.5 s window
const POST_KICK_LOCK_STEPS = 20;
// Ground-pick one-shot: phase clock encoded as [cos, sin, 0] in the
// command vel slots (runtime defaults: 4 s period, cycle exits at 0.7).
const GROUND_PICK_PERIOD_S = 4.0;
const GROUND_PICK_END_PHASE = 0.7;
// Fall recovery (mirrors the runtime's --fall-detect state machine).
const FALL_DEBOUNCE_STEPS = 10;
const FALL_SETTLE_STEPS = 15;
const RECOVER_UPRIGHT_STEPS = 50;
const RECOVER_GIVEUP_STEPS = 300;
export async function createSim({ onProgress = () => {}, getCommand }) {
const step = (msg) => onProgress(msg);
step("loading runtimes");
const [{ default: loadMujocoFactory }, ort] = await Promise.all([
import(/* @vite-ignore */ MUJOCO_URL),
import(/* @vite-ignore */ ORT_URL),
]);
ort.env.wasm.wasmPaths = ORT_WASM_DIR;
ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
// ── MJCF: strip visual geoms, add floor + fence + ball + STAND key ────
step("preparing physics model");
async function buildPhysicsXml() {
const src = await (await fetch(signed(`${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) }));
const world = doc.querySelector("worldbody");
world.appendChild(el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }));
const ht = WALL_T, hh = WALL_H;
const off = FENCE_HALF + ht, span = FENCE_HALF + 0.05;
for (const w of [
{ name: "wall_px", pos: `${off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
{ name: "wall_nx", pos: `${-off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
{ name: "wall_py", pos: `0 ${off} ${hh}`, size: `${span} ${ht} ${hh}` },
{ name: "wall_ny", pos: `0 ${-off} ${hh}`, size: `${span} ${ht} ${hh}` },
]) {
world.appendChild(el("geom", { name: w.name, type: "box", pos: w.pos, size: w.size }));
}
const ballBody = el("body", { name: "ball", pos: BALL_PARK_POS });
ballBody.appendChild(el("freejoint", { name: "ball_freejoint" }));
ballBody.appendChild(el("geom", {
name: "ball_geom", type: "sphere", size: String(BALL_RADIUS),
mass: "0.03", friction: "0.4 0.01 0.003", solref: "0.03 0.4", condim: "6",
}));
world.appendChild(ballBody);
// STAND keyframe: freejoint + every hinge in document order + ball.
const qposFree = "0 0 0.12 1 0 0 0";
const poseByName = new Map(JOINT_NAMES.map((n, i) => [n, DEFAULT_POSE[i]]));
const qposJoints = [...doc.querySelectorAll("body > joint")]
.map((j) => poseByName.get(j.getAttribute("name")) ?? 0)
.join(" ");
const kf = doc.createElement("keyframe");
kf.appendChild(el("key", {
name: "STAND",
qpos: `${qposFree} ${qposJoints} ${BALL_PARK_POS} 1 0 0 0`,
ctrl: Array.from(DEFAULT_POSE).join(" "),
}));
root.appendChild(kf);
const meshFiles = [...doc.querySelectorAll("asset > mesh")].map((m) => m.getAttribute("file"));
return { xml: new XMLSerializer().serializeToString(doc), meshFiles };
}
const [mujoco, { xml, meshFiles }] = await Promise.all([
loadMujocoFactory(),
buildPhysicsXml(),
]);
step("building collision meshes");
const vfs = new mujoco.MjVFS();
const geoms = await loadGlbGeometries();
await Promise.all(
meshFiles.map(async (f) => {
// Collision meshes rebuilt from the already-loaded visual GLB where
// possible; anything missing falls back to its STL.
const entry = geoms.get(f);
const buf = entry
? geometryToBinaryStl(entry.welded)
: await (await fetch(signed(`${MODEL_DIR}/meshes/${f}?v=${MESH_VERSION}`), { cache: "force-cache" })).arrayBuffer();
vfs.addBuffer(`assets/${f}`, new Uint8Array(buf));
}),
);
step("loading policies");
const sessionOpts = { executionProviders: ["wasm"] };
const sessions = {};
let loaded = 0;
await Promise.all(Object.entries(POLICIES).map(async ([name, url]) => {
sessions[name] = await ort.InferenceSession.create(signed(url), sessionOpts);
step(`loading policies ${++loaded}/${Object.keys(POLICIES).length}`);
}));
step("compiling physics");
const model = mujoco.MjModel.from_xml_string(xml, vfs);
const data = new mujoco.MjData(model);
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");
const ballQposAdr = model.jnt("ball_freejoint").qposadr;
const ballDofAdr = model.jnt("ball_freejoint").dofadr;
const wallIds = Object.fromEntries(["wall_px", "wall_nx", "wall_py", "wall_ny"].map(
(n) => [n, mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM.value, n)],
));
// ── Runtime fence: an axis-aligned rectangle in MJCF coordinates ──────
// Static geom pos/size live in the model and are read every step, so the
// walls can follow the floor area learned from AR scanning. Views are
// re-read on every call: WASM heap growth detaches TypedArrays.
const fence = { cx: 0, cy: 0, hx: FENCE_HALF, hy: FENCE_HALF };
function setFence(cx, cy, hx, hy) {
fence.cx = cx; fence.cy = cy; fence.hx = hx; fence.hy = hy;
const gp = model.geom_pos, gs = model.geom_size;
const set = (id, px, py, sx, sy) => {
gp[id * 3] = px; gp[id * 3 + 1] = py; gp[id * 3 + 2] = WALL_H;
gs[id * 3] = sx; gs[id * 3 + 1] = sy; gs[id * 3 + 2] = WALL_H;
};
set(wallIds.wall_px, cx + hx + WALL_T, cy, WALL_T, hy + 0.05);
set(wallIds.wall_nx, cx - hx - WALL_T, cy, WALL_T, hy + 0.05);
set(wallIds.wall_py, cx, cy + hy + WALL_T, hx + 0.05, WALL_T);
set(wallIds.wall_ny, cx, cy - hy - WALL_T, hx + 0.05, WALL_T);
}
const lastAction = new Float32Array(NUM_JOINTS);
const obs = new Float32Array(OBS_SIZE);
const cmd = new Float32Array(CMD_SIZE);
const ZERO_CMD = new Float32Array(3);
let mode = "walk"; // "walk" | "roll" | "kickL" | "kickR" | "groundpick"
let kickRun = null;
let rollRun = null;
let pickRun = null;
let postKickLock = 0;
let recovery = null; // null | { state: "fallen"|"recovering", steps, uprightSteps }
let fallDebounce = 0;
let fallenSince = null;
let ballActive = false;
let paused = true;
const isKick = () => mode === "kickL" || mode === "kickR";
const listeners = { mode: [] };
const emitMode = () => {
const label = recovery ? "recovery"
: mode === "roll" ? "roll"
: mode === "groundpick" ? "pick"
: isKick() ? "kick" : "walk";
for (const fn of listeners.mode) fn(label);
};
function effectiveCmd() {
if (mode !== "walk" || postKickLock > 0 || recovery) return ZERO_CMD;
return getCommand();
}
function parkBallPhysics() {
const qpos = data.qpos, qvel = data.qvel;
qpos[ballQposAdr] = 50; qpos[ballQposAdr + 1] = 0; qpos[ballQposAdr + 2] = BALL_RADIUS;
qpos[ballQposAdr + 3] = 1; qpos[ballQposAdr + 4] = 0; qpos[ballQposAdr + 5] = 0; qpos[ballQposAdr + 6] = 0;
for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
mujoco.mj_forward(model, data);
ballActive = false;
}
function resetSim() {
kickRun = null; rollRun = null; pickRun = null; postKickLock = 0;
recovery = null; fallDebounce = 0; fallenSince = null;
mode = "walk";
mujoco.mj_resetDataKeyframe(model, data, standKeyId);
mujoco.mj_forward(model, data);
lastAction.fill(0);
ballActive = false;
emitMode();
}
resetSim();
function spawnBall() {
const qpos = data.qpos, qvel = data.qvel;
const yaw = Math.atan2(
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
);
const heading = yaw + (Math.random() - 0.5) * 0.7;
const dist = 0.35 + (Math.random() - 0.5) * 0.1;
const m = BALL_RADIUS + 0.05;
const cl = (v, c, h) => Math.min(c + h - m, Math.max(c - h + m, v));
qpos[ballQposAdr] = cl(qpos[0] + Math.cos(heading) * dist, fence.cx, fence.hx);
qpos[ballQposAdr + 1] = cl(qpos[1] + Math.sin(heading) * dist, fence.cy, fence.hy);
qpos[ballQposAdr + 2] = BALL_RADIUS + 0.02;
qpos[ballQposAdr + 3] = 1; qpos[ballQposAdr + 4] = 0; qpos[ballQposAdr + 5] = 0; qpos[ballQposAdr + 6] = 0;
for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
mujoco.mj_forward(model, data);
ballActive = true;
}
// ── Observation ───────────────────────────────────────────────────────
const _q = new THREE.Quaternion();
const _g = new THREE.Vector3();
function projGravZ() {
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);
return _g.z;
}
function buildObs() {
const qpos = data.qpos, qvel = data.qvel, sens = data.sensordata;
let i = 0;
for (let a = 0; a < 3; a++) obs[i++] = sens[gyroAdr + a];
const xq = data.body(trunkId).xquat;
_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];
cmd.fill(0);
if (mode === "groundpick" && pickRun) {
// Phase encoding in the vel slots; head/body slots stay zero-padded
// (the runtime's zero_command_padding).
const a = 2 * Math.PI * pickRun.phase;
cmd[0] = Math.cos(a);
cmd[1] = Math.sin(a);
} else {
const c = effectiveCmd();
cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
}
for (let c2 = 0; c2 < CMD_SIZE; c2++) obs[i++] = cmd[c2];
return obs;
}
const activeSession = () =>
recovery?.state === "recovering" ? sessions.stand : sessions[mode];
function poseIsDead() {
const z = data.qpos[2];
const gz = projGravZ();
if (!Number.isFinite(z) || !Number.isFinite(gz)) return "exploded";
if (gz > -0.5 || z < 0.02) return "fallen";
return null;
}
async function controlStep() {
if (recovery?.state !== "fallen") {
const feeds = { obs: new ort.Tensor("float32", buildObs(), [1, OBS_SIZE]) };
const out = await activeSession().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);
const death = poseIsDead();
if (death === "exploded") {
resetSim();
} else if (recovery) {
recovery.steps++;
if (recovery.state === "fallen") {
if (recovery.steps >= FALL_SETTLE_STEPS) {
recovery = { state: "recovering", steps: 0, uprightSteps: 0 };
lastAction.fill(0);
}
} else {
recovery.uprightSteps = projGravZ() < -0.85 ? recovery.uprightSteps + 1 : 0;
if (recovery.uprightSteps >= RECOVER_UPRIGHT_STEPS) {
recovery = null;
mode = "walk";
lastAction.fill(0);
emitMode();
} else if (recovery.steps >= RECOVER_GIVEUP_STEPS) {
resetSim();
}
}
} else if (death === "fallen") {
if (mode === "walk" && postKickLock === 0) {
fallenSince = null;
if (++fallDebounce >= FALL_DEBOUNCE_STEPS) {
fallDebounce = 0;
recovery = { state: "fallen", steps: 0 };
emitMode();
}
} else {
fallDebounce = 0;
const now = performance.now();
const graceMs = mode === "roll" ? 5000 : 1000;
fallenSince ??= now;
if (now - fallenSince > graceMs) resetSim();
}
} else {
fallDebounce = 0;
fallenSince = null;
}
// Ball escape watchdog.
if (ballActive) {
const q = data.qpos;
if (Math.abs(q[ballQposAdr] - fence.cx) > fence.hx + 0.1 ||
Math.abs(q[ballQposAdr + 1] - fence.cy) > fence.hy + 0.1) spawnBall();
}
if (postKickLock > 0 && mode === "walk") postKickLock--;
if (isKick() && kickRun) {
kickRun.steps++;
if (kickRun.steps >= KICK_STEPS) {
kickRun = null;
mode = "walk";
postKickLock = POST_KICK_LOCK_STEPS;
emitMode();
}
}
// Ground-pick one-shot: advance the trained phase clock and hand back
// to walk at the runtime's cycle end (~2.8 s).
if (mode === "groundpick" && pickRun) {
pickRun.phase += CTRL_DT / GROUND_PICK_PERIOD_S;
if (pickRun.phase >= GROUND_PICK_END_PHASE) {
pickRun = null;
mode = "walk";
emitMode();
}
}
if (mode === "roll" && rollRun) {
rollRun.steps++;
if (obs[5] > -0.3) rollRun.tipped = true;
const upright = obs[5] < -0.85;
const done = rollRun.tipped && upright && rollRun.steps >= 40;
const expired = rollRun.steps >= 150;
if (done || expired) {
rollRun = null;
mode = "walk";
lastAction.fill(0);
if (!upright) resetSim();
emitMode();
}
}
}
// ── 50 Hz control loop ────────────────────────────────────────────────
let running = true;
let ctrlHz = 0;
(async function controlLoop() {
let next = performance.now();
let count = 0, hzT0 = next;
while (running) {
if (!paused) {
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();
}
})();
function triggerKick(foot) {
if (mode !== "walk" || recovery || postKickLock > 0) return false;
mode = foot === "left" ? "kickL" : "kickR";
kickRun = { steps: 0 };
emitMode();
return true;
}
function triggerRoll() {
if (mode !== "walk" || recovery) return false;
mode = "roll";
rollRun = { steps: 0, tipped: false };
emitMode();
return true;
}
function triggerGroundPick() {
if (mode !== "walk" || recovery || postKickLock > 0) return false;
mode = "groundpick";
pickRun = { phase: 0 };
emitMode();
return true;
}
step("ready");
return {
model, data, mujoco,
qposAdr,
ballQposAdr,
get ballActive() { return ballActive; },
get mode() { return recovery ? "recovery" : mode; },
get ctrlHz() { return ctrlHz; },
get paused() { return paused; },
set paused(v) { paused = v; },
get pickPhase() { return mode === "groundpick" ? pickRun?.phase ?? null : null; },
get fence() { return { ...fence }; },
setFence,
resetSim, spawnBall, parkBallPhysics, triggerKick, triggerRoll, triggerGroundPick,
onMode: (fn) => listeners.mode.push(fn),
destroy: () => { running = false; },
};
}