happynood's picture
Add artwork and tighten the card
368d239 verified
Raw
History Blame Contribute Delete
11 kB
// Demo entry point: N precisions racing the same policy in the same physics.
//
// Every simulation shares an initial state, a command and a perturbation setting.
// The only difference between them is the precision of the weights driving them,
// which is the whole point of the page.
import loadMujoco from '../vendor/mujoco/mujoco_wasm.js';
import { Sim, at } from './sim.js';
import { Policy, environmentReport } from './policy.js';
import { Viewport } from './render.js';
const MODEL_BASE = window.QCB_MODEL_BASE ?? './assets/onnx';
const SCENE_BASE = './assets/scene';
const state = {
mujoco: null,
scene: null,
variants: null,
running: false,
entries: [], // { scheme, sim, policy, viewport, hud }
lastFrame: 0,
fpsEma: 0,
degraded: false,
};
async function loadScene(mujoco) {
const scene = await (await fetch(`${SCENE_BASE}/scene.json`)).json();
mujoco.FS.mkdir('/working');
mujoco.FS.mount(mujoco.MEMFS, { root: '.' }, '/working');
for (const name of scene.files) {
const bytes = new Uint8Array(await (await fetch(`${SCENE_BASE}/${name}`)).arrayBuffer());
mujoco.FS.writeFile(`/working/${name}`, bytes);
}
return scene;
}
// Built with DOM calls rather than an HTML string. The scheme ids come from this
// project's own registry, so interpolation would be safe here, but keeping the
// habit means a future value from anywhere else cannot become markup.
const HUD_FIELDS = [
['steps', 'steps survived', '0'],
['state', 'state', 'upright'],
['track', 'tracking error', '—'],
['jitter', 'action jitter', '—'],
['ms', 'inference', '—'],
];
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function buildCard(scheme, bits) {
const card = el('div', 'card');
card.dataset.scheme = scheme;
const head = el('div', 'card-head');
head.append(el('span', 'scheme', scheme), el('span', 'bits', `${bits.toFixed(2)} bits/weight`));
const list = el('dl', 'hud');
for (const [key, label, initial] of HUD_FIELDS) {
const row = el('div');
const value = el('dd', null, initial);
value.dataset.k = key;
row.append(el('dt', null, label), value);
list.append(row);
}
card.append(head, el('canvas'), list);
return card;
}
/** Load the pieces that need awaiting. Touches no DOM and no shared state. */
async function loadVariant(scheme) {
const info = state.variants.variants[scheme];
const sim = new Sim(state.mujoco, state.scene, scheme);
const policy = await Policy.load(`${MODEL_BASE}/${info.file}`, scheme);
return { scheme, sim, policy, bits: info.bits_per_weight };
}
// Rebuilds are serialised by generation. Clicking several checkboxes in quick
// succession used to start overlapping rebuilds: each cleared the grid and each
// pushed into the same `state.entries`, so cards from one selection ended up
// alongside policies from another and the page showed variants nobody asked for.
// Loading now happens off to the side and is committed in one synchronous block,
// and a rebuild that has been superseded discards its work instead of publishing
// it.
let rebuildGeneration = 0;
async function rebuild() {
const generation = ++rebuildGeneration;
const wanted = [...document.querySelectorAll('input[name=scheme]:checked')].map((c) => c.value);
const loaded = [];
for (const scheme of wanted) {
const variant = await loadVariant(scheme);
if (generation !== rebuildGeneration) {
// A newer selection is already in flight; drop what this one built.
variant.sim.dispose();
loaded.forEach((v) => v.sim.dispose());
return;
}
loaded.push(variant);
}
if (generation !== rebuildGeneration) {
loaded.forEach((v) => v.sim.dispose());
return;
}
state.entries.forEach((e) => e.sim.dispose());
const grid = document.getElementById('grid');
const entries = loaded.map((v) => {
const card = buildCard(v.scheme, v.bits);
return {
scheme: v.scheme,
sim: v.sim,
policy: v.policy,
card,
viewport: null,
};
});
grid.replaceChildren(...entries.map((e) => e.card));
// Viewports need their canvas attached to size correctly, so they are created
// after the commit rather than before it.
for (const entry of entries) {
entry.viewport = new Viewport(entry.card.querySelector('canvas'), entry.sim, entry.scheme);
}
state.entries = entries;
applyControls();
resetAll();
status(`${entries.length} variant(s) loaded: ${entries.map((e) => e.scheme).join(', ')}`);
}
function applyControls() {
const mass = parseFloat(document.getElementById('mass').value);
const friction = parseFloat(document.getElementById('friction').value);
const delay = parseInt(document.getElementById('delay').value, 10);
const noise = parseFloat(document.getElementById('noise').value);
document.getElementById('mass-v').textContent = `${mass.toFixed(2)}x`;
document.getElementById('friction-v').textContent = `${friction.toFixed(2)}x`;
document.getElementById('delay-v').textContent = `${delay} step(s)`;
document.getElementById('noise-v').textContent = noise.toFixed(3);
const cmd = [
parseFloat(document.getElementById('vx').value),
0,
parseFloat(document.getElementById('wz').value),
];
document.getElementById('vx-v').textContent = `${cmd[0].toFixed(2)} m/s`;
document.getElementById('wz-v').textContent = `${cmd[2].toFixed(2)} rad/s`;
for (const e of state.entries) {
e.sim.setMassScale(mass);
e.sim.setFrictionScale(friction);
e.sim.setActuatorDelay(delay);
e.sim.setObsNoise(noise);
e.sim.command.set(cmd);
}
}
function resetAll() {
for (const e of state.entries) {
e.sim.reset();
e.viewport.sync();
e.viewport.draw();
}
}
function pushAll() {
const impulse = parseFloat(document.getElementById('push').value);
for (const e of state.entries) e.sim.push(impulse);
status(`pushed ${impulse.toFixed(1)} N·s`);
}
function status(text) {
document.getElementById('status').textContent = text;
}
function updateHud(entry) {
const q = (k) => entry.card.querySelector(`[data-k=${k}]`);
q('steps').textContent = entry.sim.steps;
q('state').textContent = entry.sim.fallen ? 'fallen' : 'upright';
q('state').className = entry.sim.fallen ? 'bad' : 'ok';
q('track').textContent = entry.sim.meanTrackingError.toFixed(3);
q('jitter').textContent = entry.sim.meanJitter.toFixed(4);
q('ms').textContent = `${entry.policy.emaMs.toFixed(2)} ms/step`;
}
async function frame(now) {
if (!state.running) return;
const dt = now - state.lastFrame;
state.lastFrame = now;
if (dt > 0) state.fpsEma = state.fpsEma === 0 ? 1000 / dt : 0.9 * state.fpsEma + 0.1 * (1000 / dt);
// Mobile-degraded mode: if the page cannot hold 30 fps with the selected
// variants, drop to two and say so rather than showing a slideshow.
if (!state.degraded && state.fpsEma > 0 && state.fpsEma < 30 && state.entries.length > 2) {
state.degraded = true;
const boxes = [...document.querySelectorAll('input[name=scheme]')];
boxes.forEach((b, i) => {
b.checked = i < 2 ? b.checked || i === 0 : false;
});
if (!boxes.some((b) => b.checked)) boxes[0].checked = true;
status(
`below 30 fps with ${state.entries.length} robots — reduced to 2. ` +
`Re-select variants to override.`
);
await rebuild();
requestAnimationFrame(frame);
return;
}
await Promise.all(
state.entries.map(async (e) => {
if (e.sim.fallen && document.getElementById('freeze').checked) return;
const action = await e.policy.act(e.sim.observe());
e.sim.step(action);
})
);
for (const e of state.entries) {
e.viewport.sync();
e.viewport.draw();
updateHud(e);
}
document.getElementById('fps').textContent = `${state.fpsEma.toFixed(0)} fps`;
requestAnimationFrame(frame);
}
async function runLatencyBenchmark() {
const wasRunning = state.running;
state.running = false;
status('measuring latency — 100 warmup + 1000 timed iterations per variant…');
const obs = state.entries.length
? state.entries[0].sim.observe()
: new Float32Array(state.variants.obs_dim);
// Every loaded variant, not only the visible ones, so the recommender has a
// latency number for each scheme it might select.
const rows = [];
for (const e of state.entries) {
rows.push(await e.policy.benchmark(Float32Array.from(obs)));
}
const report = { environment: environmentReport(), measurements: rows };
document.getElementById('latency').textContent = JSON.stringify(report, null, 2);
status('latency measured — copy the JSON into results/browser_latency.json');
state.running = wasRunning;
if (wasRunning) {
state.lastFrame = performance.now();
requestAnimationFrame(frame);
}
return report;
}
async function main() {
status('loading MuJoCo WebAssembly…');
state.mujoco = await loadMujoco();
state.scene = await loadScene(state.mujoco);
state.variants = await (await fetch(`${MODEL_BASE}/variants.json`)).json();
const chooser = document.getElementById('schemes');
for (const scheme of Object.keys(state.variants.variants)) {
const id = `scheme-${scheme}`;
const wrap = el('label');
const box = document.createElement('input');
box.type = 'checkbox';
box.name = 'scheme';
box.id = id;
box.value = scheme;
// fp32 and int4-channel by default: the baseline and the one scheme measured
// to lose robustness margin, which is the comparison the page is about.
box.checked = scheme === 'fp32' || scheme === 'int4-channel';
wrap.append(box, document.createTextNode(` ${scheme}`));
chooser.appendChild(wrap);
}
for (const id of ['mass', 'friction', 'delay', 'noise', 'vx', 'wz']) {
document.getElementById(id).addEventListener('input', applyControls);
}
document.getElementById('reset').addEventListener('click', resetAll);
document.getElementById('push-btn').addEventListener('click', pushAll);
document.getElementById('bench').addEventListener('click', runLatencyBenchmark);
document.getElementById('play').addEventListener('click', () => {
state.running = !state.running;
document.getElementById('play').textContent = state.running ? 'pause' : 'play';
if (state.running) {
state.lastFrame = performance.now();
requestAnimationFrame(frame);
}
});
chooser.addEventListener('change', () => {
state.degraded = false;
rebuild();
});
window.addEventListener('resize', () => state.entries.forEach((e) => e.viewport.resize()));
await rebuild();
status('ready — press play');
// Exposed for the headless parity and latency tests, which drive the page
// directly rather than reimplementing any of it.
window.QCB = { state, runLatencyBenchmark, rebuild, resetAll, updateHud, Sim, Policy, at };
}
main().catch((err) => {
status(`failed: ${err.message}`);
console.error(err);
});