happynood's picture
Fix the projected-gravity vector: read the IMU site frame, not the upvector sensor
b5df5d2 verified
Raw
History Blame Contribute Delete
12.5 kB
// MuJoCo WASM wrapper: one independent simulation per precision variant.
//
// Everything that describes the model or the policy interface comes from
// assets/scene/scene.json, which `qcb export-scene` generates from the trained
// environment. Nothing here is hardcoded, because every one of those numbers is a
// silent-failure candidate: a wrong sensor address feeds the policy a different
// robot's velocity and it still walks, just worse.
// MuJoCo exposes some fields as typed-array views into WASM memory and others as
// embind vectors. The two have incompatible `set`: `TypedArray.set(array, offset)`
// copies a sequence, while `vector.set(index, value)` writes one element. Calling
// the wrong one is not a type error — it throws `offset is out of bounds`, or
// worse, silently writes the wrong thing. Views are detected first, explicitly.
const isView = (vec) => ArrayBuffer.isView(vec);
/** Read element `i` from a MuJoCo array. */
export function at(vec, i) {
return isView(vec) ? vec[i] : vec.get(i);
}
/** Write element `i` of a MuJoCo array. */
export function put(vec, i, value) {
if (isView(vec)) vec[i] = value;
else vec.set(i, value);
}
export function readBlock(vec, adr, dim) {
const out = new Float64Array(dim);
for (let i = 0; i < dim; i++) out[i] = at(vec, adr + i);
return out;
}
/**
* A deterministic pseudo-random source for observation noise.
*
* The training environment draws its observation noise from JAX's counter-based
* PRNG, which cannot be reproduced here. This is therefore *not* an attempt to
* match Python bit-for-bit — with noise on, no browser rollout can. It exists so
* the demo shows the deployed behaviour (the policy was trained with this noise)
* while staying reproducible across reloads within the browser. The parity test
* runs with `level = 0`, which removes the term entirely and makes the comparison
* meaningful.
*/
export function makeRng(seed) {
let state = seed >>> 0;
return () => {
// xorshift32: cheap, deterministic, adequate for a visual demo.
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
state >>>= 0;
return state / 4294967296;
};
}
export class Sim {
/**
* @param {object} mujoco loaded WASM module
* @param {object} scene parsed scene.json
* @param {string} label which precision this instance represents
*/
constructor(mujoco, scene, label) {
this.mujoco = mujoco;
this.scene = scene;
this.label = label;
this.iface = scene.policy_interface;
this.model = mujoco.MjModel.loadFromXML(`/working/${scene.top_xml}`);
this.#assertShape();
this.#applyOverrides();
this.data = new mujoco.MjData(this.model);
this.nu = scene.nu;
this.lastAction = new Float32Array(this.nu);
this.prevAction = new Float32Array(this.nu);
this.obs = new Float32Array(48);
this.command = new Float32Array([1.0, 0.0, 0.0]);
this.noiseLevel = this.iface.noise.level;
this.rng = makeRng(0x9e3779b9);
// Perturbations, driven by the UI. Model-level ones are applied to the
// compiled model; loop-level ones act between policy and plant.
this.actuatorDelay = 0;
this.delayQueue = [];
this.obsNoiseSigma = 0;
this.reset();
}
#assertShape() {
const m = this.model;
const expected = [this.scene.nq, this.scene.nv, this.scene.nu];
const actual = [m.nq, m.nv, m.nu];
for (let i = 0; i < 3; i++) {
if (expected[i] !== actual[i]) {
throw new Error(
`${this.label}: loaded model has nq/nv/nu ${actual.join('/')}, ` +
`scene.json says ${expected.join('/')} — wrong scene or wrong assets`
);
}
}
}
/**
* Reapply the edits the training environment makes after parsing the XML.
*
* `Go1Env.__init__` sets the timestep, raises ccd_iterations, and rewrites joint
* damping and every actuator gain and bias from its PD constants. None of it is
* in the XML. Skipping this step produces a robot that walks but is not the one
* that was benchmarked.
*/
#applyOverrides() {
const m = this.model;
const s = this.scene;
m.opt.timestep = s.timestep;
m.opt.ccd_iterations = s.ccd_iterations;
for (let i = 0; i < s.dof_damping.length; i++) put(m.dof_damping, i, s.dof_damping[i]);
// Row widths come from scene.json. Both matrices are (nu, 10) in MuJoCo 3.x;
// assuming 3 for biasprm — which its name and the mjNBIAS constant invite —
// scatters each actuator's bias into another actuator's row.
const gainStride = s.actuator_gainprm_stride;
const biasStride = s.actuator_biasprm_stride;
for (let i = 0; i < s.actuator_gainprm_col0.length; i++) {
put(m.actuator_gainprm, i * gainStride + 0, s.actuator_gainprm_col0[i]);
put(m.actuator_biasprm, i * biasStride + 1, s.actuator_biasprm_col1[i]);
}
}
/**
* Release the WASM-side model and data.
*
* Each Sim holds a compiled 10 MB model in the module's heap. Rebuilding the
* variant list without this leaks one per rebuild, and a visitor toggling
* checkboxes exhausts the heap in a couple of dozen clicks.
*/
dispose() {
for (const handle of [this.data, this.model]) {
if (handle && typeof handle.delete === 'function' && !handle.isDeleted?.()) {
handle.delete();
}
}
this.data = null;
this.model = null;
}
/** Scale torso mass; 1.0 restores the exported value. */
setMassScale(scale) {
const bodyId = 1; // first non-world body: the trunk
if (this._baseMass === undefined) this._baseMass = at(this.model.body_mass, bodyId);
put(this.model.body_mass, bodyId, this._baseMass * scale);
}
/** Scale the sliding friction coefficient of every geom. */
setFrictionScale(scale) {
const n = this.model.ngeom;
if (this._baseFriction === undefined) {
this._baseFriction = new Float64Array(n);
for (let i = 0; i < n; i++) this._baseFriction[i] = at(this.model.geom_friction, i * 3);
}
for (let i = 0; i < n; i++) put(this.model.geom_friction, i * 3, this._baseFriction[i] * scale);
}
setActuatorDelay(steps) {
this.actuatorDelay = Math.max(0, Math.round(steps));
this.delayQueue = [];
}
setObsNoise(sigma) {
this.obsNoiseSigma = sigma;
}
/** Apply an impulse to the torso's linear velocity, as the eval harness does. */
push(impulseNs) {
const mass = at(this.model.body_mass, 1);
const dv = impulseNs / Math.max(mass, 1e-9);
// Random direction in the horizontal plane plus a little vertical, unit-normalised.
let v = [this.rng() * 2 - 1, this.rng() * 2 - 1, this.rng() * 2 - 1];
const norm = Math.hypot(v[0], v[1], v[2]) || 1;
for (let i = 0; i < 3; i++) {
put(this.data.qvel, i, at(this.data.qvel, i) + (v[i] / norm) * dv);
}
}
reset() {
this.mujoco.mj_resetData(this.model, this.data);
// Start from the keyframe the environment uses (`home`), if present.
if (this.model.nkey > 0) {
for (let i = 0; i < this.scene.nq; i++) put(this.data.qpos, i, at(this.model.key_qpos, i));
}
this.lastAction.fill(0);
this.prevAction.fill(0);
this.delayQueue = [];
this.totalReturn = 0;
this.steps = 0;
this.jitterSum = 0;
this.jitterCount = 0;
this.fallen = false;
this.mujoco.mj_forward(this.model, this.data);
}
/**
* Projected gravity: the world down-vector in the IMU site's frame.
*
* Matches `Go1Env.get_gravity` exactly —
* `data.site_xmat[imu].T @ [0, 0, -1]`, which is the negated third row of that
* row-major 3x3. It is **not** the `upvector` sensor; reading that instead
* gives roughly the negation, in a frame that need not be the IMU's.
*
* These three numbers are the policy's orientation signal. Inverted, the policy
* is told the robot is upside down and does the sensible thing: it stops
* walking and tries to hold still. Measured with the wrong vector, the Go1
* travelled 0.02 m in five seconds against a 1.0 m/s command.
*/
gravity() {
const base = this.iface.imu_site_id * 9 + 6;
return [
-at(this.data.site_xmat, base + 0),
-at(this.data.site_xmat, base + 1),
-at(this.data.site_xmat, base + 2),
];
}
#noise(scaleName) {
const scale = this.iface.noise.scales[scaleName] ?? 0;
return (2 * this.rng() - 1) * this.noiseLevel * scale;
}
/** Build the 48-dim observation exactly as `_get_obs` does. */
observe() {
const d = this.data;
const s = this.iface.sensors;
const o = this.obs;
let k = 0;
const linvel = readBlock(d.sensordata, s.local_linvel.adr, 3);
for (let i = 0; i < 3; i++) o[k++] = linvel[i] + this.#noise('linvel');
const gyro = readBlock(d.sensordata, s.gyro.adr, 3);
for (let i = 0; i < 3; i++) o[k++] = gyro[i] + this.#noise('gyro');
const g = this.gravity();
for (let i = 0; i < 3; i++) o[k++] = g[i] + this.#noise('gravity');
const q0 = this.iface.qpos_joint_start;
for (let i = 0; i < 12; i++) {
o[k++] = at(d.qpos, q0 + i) - this.iface.default_pose[i] + this.#noise('joint_pos');
}
const v0 = this.iface.qvel_joint_start;
for (let i = 0; i < 12; i++) o[k++] = at(d.qvel, v0 + i) + this.#noise('joint_vel');
for (let i = 0; i < 12; i++) o[k++] = this.lastAction[i];
for (let i = 0; i < 3; i++) o[k++] = this.command[i];
// The perturbation axis adds its own noise on top of the env's, matching
// `perturbed_rollout`, which noises the observation the policy receives.
if (this.obsNoiseSigma > 0) {
for (let i = 0; i < o.length; i++) o[i] += this.obsNoiseSigma * gaussian(this.rng);
}
return o;
}
/**
* Advance one control step: apply `action`, run `n_substeps` physics steps.
* Returns the wall-clock spent inside physics, for the HUD.
*/
step(action) {
let applied = action;
if (this.actuatorDelay > 0) {
this.delayQueue.push(Float32Array.from(action));
applied =
this.delayQueue.length > this.actuatorDelay
? this.delayQueue.shift()
: new Float32Array(this.nu);
}
if (this.steps > 0) {
let delta = 0;
for (let i = 0; i < this.nu; i++) delta += (applied[i] - this.prevAction[i]) ** 2;
this.jitterSum += Math.sqrt(delta);
this.jitterCount += 1;
}
this.prevAction = Float32Array.from(applied);
this.lastAction = Float32Array.from(applied);
const pose = this.iface.default_pose;
const scale = this.iface.action_scale;
for (let i = 0; i < this.nu; i++) put(this.data.ctrl, i, pose[i] + applied[i] * scale);
const t0 = performance.now();
for (let i = 0; i < this.scene.n_substeps; i++) {
this.mujoco.mj_step(this.model, this.data);
}
const physicsMs = performance.now() - t0;
this.steps += 1;
// Uprightness, from the same projected-gravity vector the policy sees: its z
// component is about -1 standing and turns positive once the robot is over.
if (this.gravity()[2] > 0.0) this.fallen = true;
// Command-tracking error: how far the achieved body velocity is from what the
// joystick asked for. Exact, and directly meaningful to a viewer.
//
// The episodic *return* is deliberately not computed here. It is a sum of 16
// environment-specific terms involving contact state, feet air time, swing
// peaks and actuator forces; a JavaScript reimplementation would produce a
// number that looks like the benchmark's return and is not it, which is worse
// than not showing one. Measured returns live in the results panel, where
// they come from the recorded runs.
const linvel = readBlock(this.data.sensordata, this.iface.sensors.local_linvel.adr, 3);
const gyro = readBlock(this.data.sensordata, this.iface.sensors.gyro.adr, 3);
this.trackingError = Math.hypot(
linvel[0] - this.command[0],
linvel[1] - this.command[1],
gyro[2] - this.command[2]
);
this.trackingSum = (this.trackingSum ?? 0) + this.trackingError;
return physicsMs;
}
get meanTrackingError() {
return this.steps > 0 ? (this.trackingSum ?? 0) / this.steps : 0;
}
get meanJitter() {
return this.jitterCount > 0 ? this.jitterSum / this.jitterCount : 0;
}
}
/** Box–Muller, for the observation-noise perturbation axis. */
export function gaussian(rng) {
let u = 0;
while (u === 0) u = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rng());
}