motion / src /controller.js
harshthedev's picture
SuperHQ Motion experiment: locomotion and action checkpoints, runtime, clips, evaluation
6adbbb1 verified
Raw History Blame Contribute Delete
9.45 kB
import { DenseNetwork } from './network.js';
const DT = 1 / 60;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const wrap = a => Math.atan2(Math.sin(a), Math.cos(a));
const finiteVector = (v, n) => (Array.isArray(v) || ArrayBuffer.isView(v)) && v.length === n && Array.from(v).every(Number.isFinite);
const yaw = (v, a) => [Math.cos(a)*v[0]+Math.sin(a)*v[2], v[1], -Math.sin(a)*v[0]+Math.cos(a)*v[2]];
function quaternion(columns) {
const [a,b,c] = columns, m00=a[0],m11=b[1],m22=c[2], trace=m00+m11+m22;
let q;
if (trace>0) { const s=2*Math.sqrt(trace+1);q=[(b[2]-c[1])/s,(c[0]-a[2])/s,(a[1]-b[0])/s,s/4]; }
else if(m00>m11&&m00>m22) {const s=2*Math.sqrt(1+m00-m11-m22);q=[s/4,(b[0]+a[1])/s,(c[0]+a[2])/s,(b[2]-c[1])/s];}
else if(m11>m22) {const s=2*Math.sqrt(1+m11-m00-m22);q=[(b[0]+a[1])/s,s/4,(c[1]+b[2])/s,(c[0]-a[2])/s];}
else {const s=2*Math.sqrt(1+m22-m00-m11);q=[(c[0]+a[2])/s,(c[1]+b[2])/s,s/4,(a[1]-b[0])/s];}
const length=Math.hypot(...q);return q.map(v=>v/length);
}
function rotation(value, offset) {
const x=Array.from(value.subarray(offset,offset+3)), b=Array.from(value.subarray(offset+3,offset+6));
const nx=Math.hypot(...x);if(nx<1e-6)throw Error('Degenerate predicted rotation.');
for(let i=0;i<3;i++)x[i]/=nx;
const dot=x.reduce((s,v,i)=>s+v*b[i],0),y=b.map((v,i)=>v-dot*x[i]),ny=Math.hypot(...y);
if(ny<1e-6)throw Error('Degenerate predicted rotation.');
for(let i=0;i<3;i++)y[i]/=ny;
return [x,y,[x[1]*y[2]-x[2]*y[1],x[2]*y[0]-x[0]*y[2],x[0]*y[1]-x[1]*y[0]]];
}
/** Decode an absolute 143-value pose using the canonical rig. */
export function decodeMotionPose(output,rig,position=[0,0,0],facing=0) {
if(!finiteVector(output,143)||!finiteVector(position,3)||!Number.isFinite(facing))throw Error('Invalid predicted motion pose.');
const localQuaternions=[];let articulated=0;
for(const end of rig.end_sites) {
if(end){localQuaternions.push([0,0,0,1]);continue;}
let columns=rotation(output,articulated*6);
if(articulated===0)columns=columns.map(v=>yaw(v,facing));
localQuaternions.push(quaternion(columns));articulated++;
}
const offset=yaw(Array.from(output.subarray(138,141)),facing);
return {rootPosition:position.map((v,i)=>v+offset[i]),localQuaternions,
contacts:Array.from(output.subarray(141),v=>1/(1+Math.exp(-v)))};
}
/** Fixed-step, engine-independent locomotion. Units: metres, seconds, radians. */
export class MotionController {
constructor(manifest, weights, config) {
if(config.format!=='superhq-motion-controller-v1'||config.dt!==DT||
manifest.input_contract!=='trajectory22_causal_phase3_v1'||
manifest.layers[0]?.inputs!==25||manifest.layers.at(-1)?.outputs!==143||
manifest.original_feature_indices?.some((v,i)=>v!==843+i)||manifest.original_feature_indices?.length!==25||
!finiteVector(config.cadence,2)||!Array.isArray(config.rig?.end_sites)||
config.rig.end_sites.filter(v=>!v).length!==23||config.rig.names?.length!==config.rig.end_sites.length||
![config.max_speed_mps,config.acceleration_mps2,config.yaw_rate_radps].every(v=>Number.isFinite(v)&&v>0)) {
throw Error('Incompatible Motion controller contract.');
}
this.network=new DenseNetwork(manifest,weights);this.config=config;this.rig=config.rig;
this.reset();
}
static async load(baseURL, {fetch: fetcher=globalThis.fetch}={}) {
const base=baseURL instanceof URL?baseURL:new URL(baseURL,globalThis.location?.href);
async function request(path, binary=false) {
const r=await fetcher(new URL(path,base));if(!r.ok)throw Error(`Cannot load ${path}: HTTP ${r.status}`);
return binary?r.arrayBuffer():r.json();
}
const [manifest,config]=await Promise.all([request('network.json'),request('controller.json')]);
if(manifest.weights!=='weights.bin')throw Error('Unexpected weight filename.');
const weights=await request(manifest.weights,true);
if(!globalThis.crypto?.subtle)throw Error('Weight verification requires Web Crypto (HTTPS or localhost).');
const digest=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',weights)),v=>v.toString(16).padStart(2,'0')).join('');
if(digest!==manifest.sha256)throw Error('Motion weight checksum mismatch.');
return new MotionController(manifest,weights,config);
}
reset({position=[0,0,0],facing=0,velocity=[0,0],phase=0}={}) {
if(!finiteVector(position,3)||Math.abs(position[1])>1e-8||!finiteVector(velocity,2)||
!Number.isFinite(facing)||!Number.isFinite(phase)||Math.hypot(...velocity)>this.config.max_speed_mps+1e-6) {
throw Error('Expected a finite flat-ground reset transform and bounded velocity.');
}
this.position=Array.from(position);this.velocity=[velocity[0],0,velocity[1]];
this.facing=facing;this.phase=phase;this.frame=0;
}
_intent(intent) {
if(!finiteVector(intent?.velocity,2)||!Number.isFinite(intent.facing??this.facing))throw Error('Expected velocity [x,z] and finite facing radians.');
const speed=Math.hypot(...intent.velocity),scale=Math.min(1,this.config.max_speed_mps/Math.max(speed,1e-8));
return {desired:[intent.velocity[0]*scale,0,intent.velocity[1]*scale],facing:intent.facing??this.facing,clamped:speed>this.config.max_speed_mps};
}
/** Call once per 1/60 s. Optional resolver supplies a collision-resolved root. */
step(intent, {resolveMovement}={}) {
const {desired,facing,clamped}=this._intent(intent),oldPosition=this.position.slice(),oldFacing=this.facing;
const change=desired.map((v,i)=>v-this.velocity[i]),distance=Math.hypot(...change),increment=this.config.acceleration_mps2*DT;
const direction=change.map(v=>v/Math.max(distance,1e-8)),turn=wrap(facing-oldFacing);
const points=[],velocities=[],angles=[];
for(const n of [12,24,36]) {
const accelerating=Math.min(n,Math.trunc(distance/increment));
const sum=increment*accelerating*(accelerating+1)/2+(n-accelerating)*distance;
points.push(oldPosition.map((v,i)=>v+DT*(n*this.velocity[i]+direction[i]*sum)));
velocities.push(this.velocity.map((v,i)=>v+direction[i]*Math.min(n*increment,distance)));
angles.push(oldFacing+clamp(turn,-n*this.config.yaw_rate_radps*DT,n*this.config.yaw_rate_radps*DT));
}
let nextVelocity=this.velocity.map((v,i)=>v+change[i]*Math.min(1,increment/Math.max(distance,1e-8)));
let nextPosition=oldPosition.map((v,i)=>v+nextVelocity[i]*DT);
let nextFacing=oldFacing+clamp(turn,-this.config.yaw_rate_radps*DT,this.config.yaw_rate_radps*DT);
if(resolveMovement!==undefined) {
if(typeof resolveMovement!=='function')throw Error('resolveMovement must be a function.');
const resolved=resolveMovement({position:oldPosition.slice(),facing:oldFacing},{position:nextPosition.slice(),facing:nextFacing});
if(!finiteVector(resolved?.position,3)||Math.abs(resolved.position[1])>1e-8||!Number.isFinite(resolved.facing??nextFacing))throw Error('Invalid resolved flat-ground transform.');
nextPosition=Array.from(resolved.position);nextFacing=resolved.facing??nextFacing;
nextVelocity=nextPosition.map((v,i)=>(v-oldPosition[i])/DT);
if(Math.hypot(...nextVelocity)>this.config.max_speed_mps+1e-5)throw Error('Collision resolution exceeds bounded movement; use reset for teleports.');
// Forecast actual movement when a host resolves collisions. No contact or
// physics prediction is implied by this constant-velocity preview.
for(let j=0;j<3;j++){const n=[12,24,36][j];points[j]=oldPosition.map((v,i)=>v+nextVelocity[i]*n*DT);velocities[j]=nextVelocity.slice();angles[j]=nextFacing;}
}
const speed=Math.hypot(...(resolveMovement?nextVelocity:this.velocity));
const activity=clamp((speed-.06)/.19,0,1),frequency=clamp(this.config.cadence[0]+this.config.cadence[1]*speed,.4,1.8);
const nextPhase=(this.phase+2*Math.PI*frequency*activity*DT)%(2*Math.PI);
const input=new Float32Array(25),firstVelocity=yaw(velocities[0],-oldFacing),firstAngle=angles[0]-oldFacing;
input.set([firstVelocity[0],firstVelocity[2],Math.sin(firstAngle),Math.cos(firstAngle)]);
for(let j=0;j<3;j++) {
const p=yaw(points[j].map((v,i)=>v-oldPosition[i]),-oldFacing),v=yaw(velocities[j],-oldFacing),a=angles[j]-oldFacing;
input.set([p[0],p[2],v[0],v[2],Math.sin(a),Math.cos(a)],4+j*6);
}
input.set([Math.sin(nextPhase)*activity,Math.cos(nextPhase)*activity,frequency*activity],22);
const output=this.network.forward(input),localQuaternions=[];let articulated=0;
for(const end of this.rig.end_sites) {
if(end){localQuaternions.push([0,0,0,1]);continue;}
let columns=rotation(output,articulated*6);
if(articulated===0)columns=columns.map(v=>yaw(v,nextFacing));
localQuaternions.push(quaternion(columns));articulated++;
}
const offset=yaw(Array.from(output.subarray(138,141)),nextFacing),rootPosition=nextPosition.map((v,i)=>v+offset[i]);
if(!rootPosition.every(Number.isFinite)||rootPosition[1]<.25||rootPosition[1]>1.8)throw Error('Predicted pelvis outside valid range.');
this.position=nextPosition;this.velocity=nextVelocity;this.facing=nextFacing;this.phase=nextPhase;this.frame++;
return {rootPosition,localQuaternions,contacts:Array.from(output.subarray(141),v=>1/(1+Math.exp(-v))),
simulationPosition:nextPosition.slice(),facing:nextFacing,phase:nextPhase,frame:this.frame,
speed:Math.hypot(...nextVelocity),inputClamped:clamped};
}
}