Spaces:
Running
Running
| import * as THREE from 'three'; | |
| /* RL gait/strike synthesis directly on a model's OWN skeleton. | |
| * | |
| * The failure of cross-skeleton retargeting is that we must guess each | |
| * bone's flexion axis. Here we DON'T guess: each driven joint moves as a | |
| * rotation about a LEARNABLE axis, and CEM (cross-entropy method, a simple | |
| * evolution strategy) optimizes all joint axes/amplitudes/phases/biases | |
| * against a physics rollout reward. The optimizer discovers whatever joint | |
| * motion actually produces forward, balanced walking in the rig's native | |
| * coordinates — so the result maps perfectly, no retarget. | |
| * | |
| * A joint's per-frame local quaternion: | |
| * q = rest * axisAngle(axis, bias + amp*sin(2*pi*phase + phi)) | |
| * Params per joint: [ax, ay, az, amp, phi, bias] (axis auto-normalized) | |
| */ | |
| const _v = new THREE.Vector3(), _q = new THREE.Quaternion(); | |
| export class RigController { | |
| // joints: [{key, bone}] ; feet: {L:boneL, R:boneR}; hips, head bones | |
| constructor(rig, jointKeys, refs) { | |
| this.rig = rig; | |
| this.joints = jointKeys.map(k => ({ key: k, bone: rig.bones[refs.map[k]], rest: null })); | |
| this.joints.forEach(j => { if (!j.bone) console.warn('RL: missing bone', refs.map[j.key], 'for', j.key); }); | |
| this.joints = this.joints.filter(j => j.bone); | |
| this.joints.forEach(j => j.rest = j.bone.quaternion.clone()); | |
| this.refs = refs; | |
| this.footL = rig.bones[refs.footL]; this.footR = rig.bones[refs.footR]; | |
| this.hips = rig.bones[refs.hips]; this.head = rig.bones[refs.head]; | |
| this.skinned = refs.skinnedRoot; | |
| this.nJ = this.joints.length; | |
| this.P = 6; // params per joint | |
| } | |
| randParams(rng) { | |
| const p = new Float32Array(this.nJ * this.P); | |
| for (let j = 0; j < this.nJ; j++) { | |
| const o = j * this.P; | |
| p[o] = rng() * 2 - 1; p[o + 1] = rng() * 2 - 1; p[o + 2] = rng() * 2 - 1; // axis | |
| p[o + 3] = rng() * 0.6; // amp | |
| p[o + 4] = rng() * Math.PI * 2; // phase | |
| p[o + 5] = (rng() * 2 - 1) * 0.4; // bias | |
| } | |
| return p; | |
| } | |
| // set all joints to the pose at a given cycle phase (0..1) | |
| pose(params, phase) { | |
| const w = 2 * Math.PI * phase; | |
| for (let j = 0; j < this.nJ; j++) { | |
| const o = j * this.P; | |
| let ax = params[o], ay = params[o + 1], az = params[o + 2]; | |
| const n = Math.hypot(ax, ay, az) || 1; ax /= n; ay /= n; az /= n; | |
| const ang = params[o + 5] + params[o + 3] * Math.sin(w + params[o + 4]); | |
| _v.set(ax, ay, az); _q.setFromAxisAngle(_v, ang); | |
| this.joints[j].bone.quaternion.copy(this.joints[j].rest).multiply(_q); | |
| } | |
| } | |
| restPose() { this.joints.forEach(j => j.bone.quaternion.copy(j.rest)); } | |
| // physics rollout reward for WALK. Inverted-pendulum locomotion: the lower | |
| // (stance) foot pins to the ground and its backward slide advances the COM. | |
| walkReward(params, opt = {}) { | |
| const T = opt.T || 46, dt = opt.dt || 1 / 30, f = opt.freq || 1.4; | |
| const skinned = this.skinned; | |
| let comZ = 0, comX = 0, prevStanceZ = null, prevStance = -1; | |
| let upPen = 0, latPen = 0, clearL = 0, clearR = 0, fall = 0, footYmin = 1e9; | |
| let energy = 0; | |
| for (let j = 0; j < this.nJ; j++) energy += params[j * this.P + 3] ** 2; | |
| // measure rest torso length | |
| this.restPose(); skinned.updateWorldMatrix(true, true); | |
| const restHead = this.head.getWorldPosition(new THREE.Vector3()); | |
| const restHip = this.hips.getWorldPosition(new THREE.Vector3()); | |
| const torso0 = restHead.y - restHip.y; | |
| for (let t = 0; t < T; t++) { | |
| const phase = (f * t * dt) % 1; | |
| this.pose(params, phase); | |
| skinned.updateWorldMatrix(true, true); | |
| const fl = this.footL.getWorldPosition(new THREE.Vector3()); | |
| const fr = this.footR.getWorldPosition(new THREE.Vector3()); | |
| const hp = this.hips.getWorldPosition(new THREE.Vector3()); | |
| const hd = this.head.getWorldPosition(new THREE.Vector3()); | |
| // stance = lower foot (in world y) | |
| const stanceL = fl.y <= fr.y; | |
| const stanceZ = stanceL ? fl.z : fr.z; | |
| const stance = stanceL ? 0 : 1; | |
| if (prevStanceZ !== null && stance === prevStance) { | |
| comZ += -(stanceZ - prevStanceZ); // foot slides back -> body forward | |
| } | |
| prevStanceZ = stanceZ; prevStance = stance; | |
| // uprightness: torso should keep length & be vertical | |
| const torso = hd.y - hp.y; | |
| upPen += (torso0 - torso) ** 2 + (hd.x - hp.x) ** 2 * 0.5; | |
| latPen += hp.x * hp.x; | |
| // swing foot clearance | |
| clearL += Math.max(0, fl.y); clearR += Math.max(0, fr.y); | |
| footYmin = Math.min(footYmin, fl.y, fr.y); | |
| if (torso < 0.45 * torso0) fall += 1; | |
| } | |
| this.restPose(); | |
| const speed = comZ / (T * dt); | |
| // reward forward walking speed, balance, foot alternation clearance | |
| const clearance = Math.min(clearL, clearR) / T; // both feet must lift | |
| return 1.6 * speed | |
| - 3.0 * upPen / T | |
| - 2.0 * latPen / T | |
| + 1.2 * clearance | |
| - 0.15 * energy | |
| - 2.5 * fall / T | |
| - 4.0 * Math.max(0, footYmin); // feet shouldn't float above ground | |
| } | |
| cem(rewardFn, opt = {}) { | |
| const rng = mulberry32(opt.seed || 1); | |
| const dim = this.nJ * this.P; | |
| const pop = opt.pop || 64, elite = opt.elite || 10, iters = opt.iters || 40; | |
| let mu = this.randParams(() => rng()); | |
| let sig = new Float32Array(dim).fill(0.5); | |
| const hist = []; | |
| let best = null, bestR = -1e9; | |
| for (let it = 0; it < iters; it++) { | |
| const cands = [], rewards = []; | |
| for (let p = 0; p < pop; p++) { | |
| const c = new Float32Array(dim); | |
| for (let d = 0; d < dim; d++) c[d] = mu[d] + sig[d] * gauss(rng); | |
| const r = rewardFn(c); | |
| cands.push(c); rewards.push(r); | |
| if (r > bestR) { bestR = r; best = c.slice(); } | |
| } | |
| const idx = rewards.map((r, i) => [r, i]).sort((a, b) => b[0] - a[0]).slice(0, elite).map(x => x[1]); | |
| const nmu = new Float32Array(dim), nsig = new Float32Array(dim); | |
| for (const i of idx) for (let d = 0; d < dim; d++) nmu[d] += cands[i][d] / elite; | |
| for (const i of idx) for (let d = 0; d < dim; d++) nsig[d] += (cands[i][d] - nmu[d]) ** 2 / elite; | |
| for (let d = 0; d < dim; d++) { mu[d] = nmu[d]; sig[d] = Math.sqrt(nsig[d]) + 0.02; } | |
| hist.push(bestR); | |
| if (opt.onIter) opt.onIter(it, bestR); | |
| } | |
| return { params: best, reward: bestR, hist }; | |
| } | |
| } | |
| /* ================= PHYSICS-BASED TRAINER ================= | |
| * | |
| * DeepMimic-lite: the PhysRig's joint motors track the Mixamo clip | |
| * (sampled via sampleClipTargets), and CEM learns a per-joint RESIDUAL | |
| * rotation + motor gain that keeps the clip upright under real gravity, | |
| * mass and contact. The trained weights ARE the animation controller: | |
| * theta_j = bias + amp*sin(2πphase+phi) + K·s (s = feedback state) | |
| * target_j(phase) = clip_j(phase) * axisAngle(axis_j, theta_j) | |
| * Feedback state s (yaw-relative, so it generalizes across headings): | |
| * [pelvis-up x, pelvis-up z, com-vel x, com-vel z - targetSpeed] | |
| * Params per joint: [ax,ay,az, amp, phi, bias, gain, k0,k1,k2,k3] (P = 11) | |
| * | |
| * Cyclic clips (walk) loop phase; opt.oneshot clips (attack, roll) play | |
| * phase 0..1 once and hold the final pose. | |
| */ | |
| const _tq = new THREE.Quaternion(), _tv = new THREE.Vector3(); | |
| const _com = new THREE.Vector3(), _cv = new THREE.Vector3(); | |
| const _aq = new THREE.Quaternion(); // actual qRel — MUST be separate from _tq | |
| // (clipTarget returns _tq; aliasing zeroed imErr) | |
| export class PhysTrainer { | |
| /* rig: PhysRig ; ref: result of sampleClipTargets(walkClip, ...) */ | |
| constructor(rig, ref, opt = {}) { | |
| this.rig = rig; this.ref = ref; | |
| this.P = 11; | |
| this.oneshot = !!opt.oneshot; | |
| // joints the policy modulates (arms ride along on pure clip tracking) | |
| this.driven = opt.driven || | |
| ['thighL', 'shinL', 'footL', 'thighR', 'shinR', 'footR', 'torso']; | |
| this.trackOnly = rig.joints.map(j => j.name).filter(n => !this.driven.includes(n)); | |
| this.nJ = this.driven.length; | |
| this.dim = this.nJ * this.P; | |
| this.freq = opt.freq || ref.freq; | |
| this.dt = opt.dt || 1 / 30; | |
| this.substeps = opt.substeps || 5; | |
| this.T = opt.T || (this.oneshot | |
| ? Math.round(ref.duration / this.dt) + 8 // clip once + recovery | |
| : Math.round(2.2 / this.dt)); // ~2 gait cycles | |
| this.targetSpeed = opt.targetSpeed ?? (this.oneshot ? 0 : ref.speed); | |
| this.assistScale = opt.assistScale ?? 1; // weaken balance assists (roll) | |
| this.imWeight = opt.imWeight ?? 0.35; // imitation (Mixamo-match) reward weight | |
| this.speedWeight = opt.speedWeight ?? 2.0; // forward-speed reward weight | |
| this.distWeight = opt.distWeight ?? 0; // net forward-distance reward (traversal) | |
| this.vertWeight = opt.vertWeight ?? 0; // peak-height reward (jumps leave the ground) | |
| this.dirZ = opt.dirZ ?? 1; // clip forward sign (+1 fwd, -1 backward) | |
| this.perturbMag = opt.perturbMag ?? 0; // combat knockback (m/s) injected mid-move | |
| this.perturbK = opt.perturbK ?? 3; // fight scenarios averaged per candidate | |
| this.contactWeight = opt.contactWeight ?? 0; // reward matching the clip's foot-contact timing | |
| this._s = new Float32Array(4); // feedback state buffer | |
| } | |
| /* reference pelvis height at a phase (the clip's authored root bob). Falls | |
| * back to rest height if the reference lacks it. */ | |
| refHeight(phase) { | |
| const p = this.ref.pelvisY; if (!p) return this.rig.restPelvisY; | |
| const n = p.length, x = (((phase % 1) + 1) % 1) * n; | |
| const i0 = Math.floor(x) % n, i1 = (i0 + 1) % n; | |
| return p[i0] + (p[i1] - p[i0]) * (x - i0); | |
| } | |
| /* yaw-relative balance state: pelvis tilt + com velocity deviation */ | |
| fbState() { | |
| const s = this._s, p = this.rig.byName.pelvis; | |
| _tv.set(0, 0, 1).applyQuaternion(p.quat); | |
| const yaw = Math.atan2(_tv.x, _tv.z), sy = Math.sin(yaw), cy = Math.cos(yaw); | |
| _tv.set(0, 1, 0).applyQuaternion(p.quat); // pelvis up | |
| s[0] = cy * _tv.x - sy * _tv.z; | |
| s[1] = sy * _tv.x + cy * _tv.z; | |
| this.rig.comVel(_cv); | |
| s[2] = cy * _cv.x - sy * _cv.z; | |
| s[3] = (sy * _cv.x + cy * _cv.z) - this.targetSpeed; // forward vel error | |
| return s; | |
| } | |
| zeroParams() { | |
| const p = new Float32Array(this.dim); | |
| for (let j = 0; j < this.nJ; j++) { | |
| p[j * this.P] = 1; // axis x (sagittal flexion default) | |
| p[j * this.P + 6] = 1; // gain | |
| } | |
| return p; | |
| } | |
| clipTarget(name, phase) { // interpolated clip target quat for a joint | |
| const f = this.ref.frames, n = this.ref.nPhase; | |
| if (this.oneshot) { // clamp: play once, hold last frame | |
| const x = Math.min(Math.max(phase, 0), 1) * (n - 1); | |
| const i0 = Math.floor(x), i1 = Math.min(i0 + 1, n - 1); | |
| return _tq.copy(f[i0][name]).slerp(f[i1][name], x - i0); | |
| } | |
| const x = ((phase % 1) + 1) % 1 * n; | |
| const i0 = Math.floor(x) % n, i1 = (i0 + 1) % n; | |
| return _tq.copy(f[i0][name]).slerp(f[i1][name], x - i0); | |
| } | |
| /* Compute this gait's motor target per joint at `phase` WITHOUT applying | |
| * them, so an AnimController can blend targets across several gaits. Writes | |
| * {q, gain} into `out[jointName]` (out reused across calls). Reads the live | |
| * rig state for the feedback term, so all blended gaits see the same pose. */ | |
| computeTargets(params, phase, out = {}) { | |
| const s = this.fbState(); | |
| const w = 2 * Math.PI * phase; | |
| for (const name of this.trackOnly) { | |
| const e = out[name] || (out[name] = { q: new THREE.Quaternion(), gain: 1 }); | |
| e.q.copy(this.clipTarget(name, phase)); e.gain = 1; | |
| } | |
| for (let j = 0; j < this.nJ; j++) { | |
| const o = j * this.P, name = this.driven[j]; | |
| let ax = params[o], ay = params[o + 1], az = params[o + 2]; | |
| const n = Math.hypot(ax, ay, az) || 1; ax /= n; ay /= n; az /= n; | |
| let ang = params[o + 5] + params[o + 3] * Math.sin(w + params[o + 4]) | |
| + params[o + 7] * s[0] + params[o + 8] * s[1] | |
| + params[o + 9] * s[2] + params[o + 10] * s[3]; | |
| ang = Math.max(-1.2, Math.min(1.2, ang)); // residual safety clamp | |
| const gain = Math.max(0.2, Math.min(4, Math.abs(params[o + 6]))); | |
| _tv.set(ax, ay, az); | |
| const e = out[name] || (out[name] = { q: new THREE.Quaternion(), gain: 1 }); | |
| e.q.copy(this.clipTarget(name, phase)).multiply(_aq.setFromAxisAngle(_tv, ang)); | |
| e.gain = gain; | |
| } | |
| return out; | |
| } | |
| /* set all motor targets for a phase given params (single-gait playback) */ | |
| drive(params, phase) { | |
| const rig = this.rig; | |
| rig.hoverTarget = this.vertWeight > 0 ? null : this.refHeight(phase); // track clip bob | |
| for (const name of this.trackOnly) | |
| rig.setTarget(name, this.clipTarget(name, phase).clone(), 1); | |
| const s = this.fbState(); | |
| const w = 2 * Math.PI * phase; | |
| for (let j = 0; j < this.nJ; j++) { | |
| const o = j * this.P, name = this.driven[j]; | |
| let ax = params[o], ay = params[o + 1], az = params[o + 2]; | |
| const n = Math.hypot(ax, ay, az) || 1; ax /= n; ay /= n; az /= n; | |
| let ang = params[o + 5] + params[o + 3] * Math.sin(w + params[o + 4]) | |
| + params[o + 7] * s[0] + params[o + 8] * s[1] | |
| + params[o + 9] * s[2] + params[o + 10] * s[3]; | |
| ang = Math.max(-1.2, Math.min(1.2, ang)); // residual safety clamp | |
| const gain = Math.max(0.2, Math.min(4, Math.abs(params[o + 6]))); | |
| _tv.set(ax, ay, az); | |
| const t = this.clipTarget(name, phase).clone() | |
| .multiply(new THREE.Quaternion().setFromAxisAngle(_tv, ang)); | |
| rig.setTarget(name, t, gain); | |
| } | |
| } | |
| /* physics rollout; returns reward. opt.onFrame(rig, t) for playback/recording */ | |
| rollout(params, opt = {}) { | |
| const rig = this.rig, T = opt.T || this.T; | |
| rig.reset(); | |
| const up0 = rig.uprightK, hv0 = rig.hoverK; | |
| rig.uprightK *= this.assistScale; rig.hoverK *= this.assistScale; | |
| try { return this._rollout(params, T, opt); } | |
| finally { rig.uprightK = up0; rig.hoverK = hv0; } | |
| } | |
| /* a fight scenario = list of knockback pokes {step, v:[x,y,z]} sampled to | |
| * mimic getting hit mid-move (magnitude ~ combat knockback). Fixed per | |
| * training so CEM compares candidates on identical disturbances. */ | |
| makeScenarios(rng) { | |
| const K = this.perturbK, mag = this.perturbMag, out = []; | |
| for (let k = 0; k < K; k++) { | |
| const pokes = []; | |
| const nP = 1 + Math.floor(rng() * 2); | |
| for (let p = 0; p < nP; p++) { | |
| const a = rng() * 2 * Math.PI, s = mag * (0.5 + rng()); | |
| pokes.push({ step: Math.floor(rng() * this.T * 0.7) + 2, | |
| v: [Math.cos(a) * s, (rng() - 0.3) * s * 0.4, Math.sin(a) * s] }); | |
| } | |
| out.push(pokes); | |
| } | |
| out[0] = []; // one clean scenario (no hit) always | |
| return out; | |
| } | |
| _rollout(params, T, opt) { | |
| const rig = this.rig; | |
| const pelvis = rig.byName.pelvis; | |
| const y0 = rig.restPelvisY; | |
| const schedule = opt.schedule; // fight-context knockback pokes | |
| let imErr = 0, upPen = 0, latPen = 0, fell = 0, contactPen = 0; | |
| let energy = 0; | |
| for (let j = 0; j < this.nJ; j++) energy += params[j * this.P + 3] ** 2; | |
| const start = rig.comPos(new THREE.Vector3()); | |
| const vert = this.vertWeight > 0; // jump: reward leaving ground | |
| let peakY = -1e9, steps = 0; | |
| for (let t = 0; t < T; t++) { | |
| let phase = this.freq * t * this.dt; | |
| if (!this.oneshot) phase %= 1; | |
| this.drive(params, phase); | |
| rig.step(this.dt, this.substeps); | |
| steps++; | |
| if (schedule) for (const pk of schedule) if (pk.step === t) | |
| for (const b of rig.bodies) { b.vel.x += pk.v[0]; b.vel.y += pk.v[1]; b.vel.z += pk.v[2]; } | |
| // imitation error on driven joints (match the Mixamo clip) | |
| for (const name of this.driven) { | |
| const j = rig.jointByName[name]; | |
| _aq.copy(j.p.quat).invert().multiply(j.c.quat); // actual qRel | |
| const tgt = this.clipTarget(name, phase); // returns _tq — keep separate | |
| const d = Math.abs(_aq.dot(tgt)); | |
| imErr += 2 * Math.acos(Math.min(1, d)); | |
| } | |
| // upright: body-up near world-up; height tracks the clip's authored root | |
| // trajectory (refHeight) rather than a constant — this is the root-pose | |
| // imitation term that was missing and caused the bounce. | |
| _tv.set(0, 1, 0).applyQuaternion(pelvis.quat); | |
| upPen += (1 - _tv.y) + (vert ? 0 : Math.abs(pelvis.pos.y - this.refHeight(phase)) * 3); | |
| latPen += Math.abs(pelvis.pos.x - start.x); | |
| peakY = Math.max(peakY, pelvis.pos.y); | |
| // contact matching: foot should be planted (low, still) when the clip says | |
| if (this.contactWeight > 0) { | |
| const fc = this.ref.footContact, ci = Math.floor(((phase % 1) + 1) % 1 * fc.L.length) % fc.L.length; | |
| // timing only: foot should be DOWN when the clip says down, UP when up. | |
| // Do NOT penalize its horizontal velocity — in in-place tracking the | |
| // stance foot slides backward and that slide IS the propulsion. | |
| for (const [name, want] of [['footL', fc.L[ci]], ['footR', fc.R[ci]]]) { | |
| const fb = rig.byName[name]; | |
| const grounded = fb.pos.y < fb.r * 1.7 ? 1 : 0; | |
| contactPen += want ? (1 - grounded) : grounded * 0.25; | |
| } | |
| } | |
| if (opt.onFrame) opt.onFrame(rig, t); | |
| if (pelvis.pos.y < y0 * 0.55) { // fallen? | |
| if (!this.oneshot || this.assistScale >= 1) { fell = 1; break; } | |
| else if (t > T - 10) fell += 0.25; // tumbling is fine; ending down is not | |
| } | |
| } | |
| const com = rig.comPos(new THREE.Vector3()); | |
| const dist = this.dirZ * (com.z - start.z); // signed by clip's forward dir | |
| const dur = steps * this.dt; | |
| const speed = dist / Math.max(dur, 1e-3); | |
| const speedR = 1 - Math.min(1.5, Math.abs(speed - this.targetSpeed) / Math.max(this.targetSpeed, 0.3)); | |
| return this.speedWeight * speedR | |
| + this.distWeight * Math.max(0, Math.min(4, dist)) // net forward traversal | |
| + this.vertWeight * Math.max(0, peakY - y0) // jump apex above rest | |
| + 2.5 * (steps / T) // survival | |
| - this.imWeight * imErr / (steps * this.nJ) | |
| - 1.2 * upPen / steps | |
| - 0.6 * latPen / steps | |
| - this.contactWeight * contactPen / steps | |
| - 0.08 * energy | |
| - 2.0 * fell; | |
| } | |
| /* Imitation fidelity: fraction of the Mixamo clip's joint motion actually | |
| * reproduced by the physics rollout. 1.0 = tracks the clip exactly; ~0 = | |
| * ignores it (stands at rest). meanErr = actual-vs-clip angle; meanMotion = | |
| * clip-vs-neutral angle (how much the clip itself moves). */ | |
| fidelity(params) { | |
| const rig = this.rig, T = this.T; | |
| rig.reset(); | |
| const up0 = rig.uprightK, hv0 = rig.hoverK; | |
| rig.uprightK *= this.assistScale; rig.hoverK *= this.assistScale; | |
| let err = 0, motion = 0, cnt = 0; | |
| try { | |
| for (let t = 0; t < T; t++) { | |
| let phase = this.freq * t * this.dt; | |
| if (!this.oneshot) phase %= 1; | |
| this.drive(params, phase); | |
| rig.step(this.dt, this.substeps); | |
| for (const name of this.driven) { | |
| const j = rig.jointByName[name]; | |
| _aq.copy(j.p.quat).invert().multiply(j.c.quat); // actual qRel | |
| const tgt = this.clipTarget(name, phase); // clip target (_tq) | |
| err += 2 * Math.acos(Math.min(1, Math.abs(_aq.dot(tgt)))); | |
| motion += 2 * Math.acos(Math.min(1, Math.abs(tgt.w))); // clip vs neutral | |
| cnt++; | |
| } | |
| } | |
| } finally { rig.uprightK = up0; rig.hoverK = hv0; rig.reset(); } | |
| const meanErr = err / cnt, meanMotion = motion / cnt; | |
| const fid = Math.max(0, Math.min(1, 1 - meanErr / Math.max(meanMotion, 1e-3))); | |
| return { fidelity: fid, meanErrDeg: +(meanErr * 180 / Math.PI).toFixed(1), | |
| meanMotionDeg: +(meanMotion * 180 / Math.PI).toFixed(1) }; | |
| } | |
| /* CEM over residual params, seeded at zero-residual (= pure clip tracking). | |
| * Async: yields to the event loop each iteration so a HUD can repaint. */ | |
| async cem(opt = {}) { | |
| const rng = mulberry32(opt.seed || 1); | |
| const pop = opt.pop || 40, elite = opt.elite || 8, iters = opt.iters || 30; | |
| let mu = this.zeroParams(); | |
| const sig = new Float32Array(this.dim); | |
| for (let j = 0; j < this.nJ; j++) { | |
| const o = j * this.P; | |
| sig[o] = sig[o + 1] = sig[o + 2] = 0.4; // axis | |
| sig[o + 3] = 0.25; // amp | |
| sig[o + 4] = 1.5; // phase | |
| sig[o + 5] = 0.15; // bias | |
| sig[o + 6] = 0.4; // gain | |
| sig[o + 7] = sig[o + 8] = sig[o + 9] = sig[o + 10] = 0.12; // feedback gains | |
| } | |
| // fight-context: average each candidate over fixed knockback scenarios so | |
| // the learned feedback gains reproduce the animation even while being hit | |
| const scen = this.perturbMag > 0 ? this.makeScenarios(rng) : [null]; | |
| const evalR = c => { let s = 0; for (const sch of scen) s += this.rollout(c, { schedule: sch }); return s / scen.length; }; | |
| let best = mu.slice(), bestR = evalR(mu); | |
| const hist = [bestR]; | |
| for (let it = 0; it < iters; it++) { | |
| const cands = [], rewards = []; | |
| for (let p = 0; p < pop; p++) { | |
| const c = new Float32Array(this.dim); | |
| for (let d = 0; d < this.dim; d++) c[d] = mu[d] + sig[d] * gauss(rng); | |
| const r = evalR(c); | |
| cands.push(c); rewards.push(r); | |
| if (r > bestR) { bestR = r; best = c.slice(); } | |
| } | |
| const idx = rewards.map((r, i) => [r, i]).sort((a, b) => b[0] - a[0]).slice(0, elite).map(x => x[1]); | |
| const nmu = new Float32Array(this.dim), nsig = new Float32Array(this.dim); | |
| for (const i of idx) for (let d = 0; d < this.dim; d++) nmu[d] += cands[i][d] / elite; | |
| for (const i of idx) for (let d = 0; d < this.dim; d++) nsig[d] += (cands[i][d] - nmu[d]) ** 2 / elite; | |
| for (let d = 0; d < this.dim; d++) { mu[d] = nmu[d]; sig[d] = Math.sqrt(nsig[d]) + 0.01; } | |
| hist.push(bestR); | |
| if (opt.onIter) opt.onIter(it, bestR); | |
| // MessageChannel yield: repaints HUD but isn't throttled in background tabs | |
| await new Promise(r => { const c = new MessageChannel(); c.port1.onmessage = r; c.port2.postMessage(0); }); | |
| } | |
| return { params: best, reward: bestR, hist }; | |
| } | |
| /* portable trained-weights blob */ | |
| exportWeights(params, meta = {}) { | |
| return { | |
| kind: 'physgait-v1', | |
| driven: this.driven, P: this.P, oneshot: this.oneshot, assistScale: this.assistScale, | |
| params: Array.from(params).map(x => +x.toFixed(5)), | |
| freq: this.freq, dt: this.dt, substeps: this.substeps, | |
| targetSpeed: this.targetSpeed, ...meta, | |
| }; | |
| } | |
| } | |
| function mulberry32(a) { | |
| return function () { | |
| a |= 0; a = a + 0x6D2B79F5 | 0; | |
| let t = Math.imul(a ^ a >>> 15, 1 | a); | |
| t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; | |
| return ((t ^ t >>> 14) >>> 0) / 4294967296; | |
| }; | |
| } | |
| function gauss(rng) { | |
| let u = 0, v = 0; while (u === 0) u = rng(); while (v === 0) v = rng(); | |
| return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); | |
| } | |