motion / src /action-controller.js
harshthedev's picture
SuperHQ Motion experiment: locomotion and action checkpoints, runtime, clips, evaluation
6adbbb1 verified
Raw History Blame Contribute Delete
8.13 kB
import { decodeMotionPose } from './controller.js';
import { worldPositions } from './pose.js';
import { ActionStabilizer,plantToes } from './action-stabilizer.js';
const DT=1/60,ANTICIPATION=.24,EXTENSION=.50,LANDING=.55;
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const vector=(x,n)=>(Array.isArray(x)||ArrayBuffer.isView(x))&&x.length===n&&Array.from(x).every(Number.isFinite);
/** Stationary action pose controller. The host owns gravity and collisions.
* Features require superhq-motion-actions-v2 weights. No custom skills or clips
* are loaded by this runtime. MotionGameController composes these canonical
* poses with locomotion and horizontal game physics.
*/
export class MotionActions {
constructor(network,rig,{contract,phase=0,runtimeCorrections=true}={}) {
if(contract!=='superhq-motion-actions-v2'||!Number.isFinite(phase))throw Error('Expected v2 action weights.');
this.network=network;this.rig=rig;this.runtimeCorrections=runtimeCorrections;this.reset({phase});
}
reset({phase=0}={}) {
if(!Number.isFinite(phase))throw Error('Invalid phase.');
this.phase=((phase%1)+1)%1;this.mode='stance';this.elapsed=0;this.airElapsed=0;
this.depth=0;this.depthVelocity=0;this.goal=0;this.response=.65;
this.wasGrounded=true;this.lastVerticalVelocity=0;this.takeoffSent=false;this.pendingJump=false;
this.stabilizer=new ActionStabilizer();this.plantWeight=1;this.previousMode=null;this.goalChanged=false;
const x=this.features();x[34]=0;
const pose=decodeMotionPose(this.network.forward(x),this.rig);
const world=worldPositions(pose,this.rig);
this.anchors=[world[22].slice(),world[27].slice()];this.jumpAnchorMean=this.anchors[0].map((v,i)=>(v+this.anchors[1][i])/2);
}
setGoal({crouchDepth,responseSeconds=.65}) {
if(!Number.isFinite(crouchDepth)||crouchDepth<0||crouchDepth>1||
!Number.isFinite(responseSeconds)||responseSeconds<.5||responseSeconds>.9)throw Error('Invalid crouch goal.');
this.goalChanged ||= crouchDepth!==this.goal||responseSeconds!==this.response;
this.goal=crouchDepth;this.response=responseSeconds;
}
requestJump({horizontalSpeed=0}={}) {
if(!Number.isFinite(horizontalSpeed)||horizontalSpeed<0)throw Error('Invalid movement speed.');
if(this.mode!=='stance'||!this.wasGrounded||this.depth>.05||this.goal>.05||horizontalSpeed>.05||this.pendingJump)return false;
this.pendingJump=true;return true;
}
features() {
const x=new Float32Array(48);x[3]=1;for(const j of [9,15,21])x[j]=1;
x.set([Math.sin(this.phase*2*Math.PI),Math.cos(this.phase*2*Math.PI),.4],22);x[25]=1;x[26]=1;x[41]=1;
return x;
}
step({position=[0,0,0],verticalVelocity=0,grounded=true}={}) {
if(!vector(position,3)||!Number.isFinite(verticalVelocity)||typeof grounded!=='boolean')throw Error('Invalid host physics state.');
if(this.pendingJump){this.mode='anticipation';this.elapsed=0;this.takeoffSent=false;this.pendingJump=false;}
if(!grounded&&this.wasGrounded) {
this.mode=this.mode==='anticipation'?'extension':'air';this.airElapsed=0;this.elapsed=0;
}
if(grounded&&!this.wasGrounded) {
this.mode='landing';this.elapsed=0;this.landingEntry=clamp(this.airElapsed/EXTENSION,0,1);this.impactVelocity=this.lastVerticalVelocity;
}
if(this.mode==='extension'&&this.airElapsed>=EXTENSION)this.mode='air';
if(this.mode==='landing'&&this.elapsed>=LANDING){this.mode='stance';this.depth=0;this.depthVelocity=0;}
const x=this.features();let takeoffRequested=false;
if(this.mode==='stance') {
const omega=3.89/this.response,error=this.depth-this.goal,cross=this.depthVelocity+omega*error,decay=Math.exp(-omega*DT);
this.depth=this.goal+(error+cross*DT)*decay;this.depthVelocity=(this.depthVelocity-omega*cross*DT)*decay;
x[30]=this.depth;x[34]=1;x[38]=this.depthVelocity;x[39]=this.depth;x.set(this.anchors.flat(),42);
} else {
x.set(this.jumpAnchorMean,45);
if(this.mode==='anticipation') {
x[35]=1;x[38]=Math.min(this.elapsed,ANTICIPATION)/(ANTICIPATION+EXTENSION);
if(this.elapsed>=ANTICIPATION&&!this.takeoffSent){takeoffRequested=true;this.takeoffSent=true;}
} else if(this.mode==='extension') {
x[35]=1;x[38]=(ANTICIPATION+this.airElapsed)/(ANTICIPATION+EXTENSION);
} else if(this.mode==='air') {
x[36]=1;x[38]=clamp((this.airElapsed-EXTENSION)/.25,0,1);
} else {
x[37]=1;x[38]=clamp(this.elapsed/LANDING,0,1);x[42]=this.landingEntry;x[43]=.38;x[44]=this.impactVelocity;
}
}
x[40]=verticalVelocity;x[41]=Number(grounded);
const output=this.network.forward(x),raw=decodeMotionPose(output,this.rig);
let canonical=raw,correction={transition:null,feet:[]};
if(this.runtimeCorrections){
const changed=this.previousMode!==null&&(this.previousMode!==this.mode||this.wasGrounded!==grounded||this.goalChanged);
let next=raw;
if(changed){
const preview=x.slice(),phase=this.phase+.4*DT;preview[22]=Math.sin(phase*2*Math.PI);preview[23]=Math.cos(phase*2*Math.PI);
if(this.mode==='stance'){
const omega=3.89/this.response,error=this.depth-this.goal,cross=this.depthVelocity+omega*error,decay=Math.exp(-omega*DT);
preview[30]=preview[39]=this.goal+(error+cross*DT)*decay;preview[38]=(this.depthVelocity-omega*cross*DT)*decay;
}else if(this.mode==='anticipation'||this.mode==='extension')preview[38]=Math.min(1,x[38]+DT/(ANTICIPATION+EXTENSION));
else if(this.mode==='air')preview[38]=Math.min(1,x[38]+DT/.25);
else if(this.mode==='landing')preview[38]=Math.min(1,x[38]+DT/LANDING);
next=decodeMotionPose(this.network.forward(preview),this.rig);
}
const filtered=this.stabilizer.apply(raw,next,changed);canonical=filtered.pose;correction.transition=filtered.correction;
this.plantWeight+=(Number(this.mode==='stance'&&grounded)-this.plantWeight)*(1-Math.exp(-DT/.08));
if(!grounded)this.plantWeight=0;
if(this.plantWeight>.001){const planted=plantToes(canonical,this.rig,this.anchors,this.plantWeight);canonical=planted.pose;correction.feet=planted.details;}
}
const pose={...canonical,rootPosition:canonical.rootPosition.map((v,i)=>v+position[i])};
const rawPose={...raw,rootPosition:raw.rootPosition.map((v,i)=>v+position[i])};
const result={...pose,rawPose,correction,input:x,mode:this.mode,takeoffRequested,depth:this.depth,phase:this.phase,hostPosition:Array.from(position)};
this.phase=(this.phase+.4*DT)%1;this.elapsed+=DT;if(!grounded)this.airElapsed+=DT;
this.previousMode=this.mode;this.goalChanged=false;this.wasGrounded=grounded;this.lastVerticalVelocity=verticalVelocity;
return result;
}
}
/** Example host physics only. Games should supply their own collision state. */
export class TestPhysics {
constructor({impulse=3.5,gravity=9.81,platformHeight=0}={}) {
if(![impulse,gravity,platformHeight].every(Number.isFinite)||impulse<=0||gravity<=0||platformHeight<0)throw Error('Invalid test physics.');
this.impulse=impulse;this.gravity=gravity;this.initialPlatform=platformHeight;this.reset();
}
reset(){this.platformHeight=this.initialPlatform;this.position=[0,0,0];this.velocity=0;this.grounded=true;this.takeoffs=0;this.landings=0;}
state(){return {position:this.position.slice(),verticalVelocity:this.velocity,grounded:this.grounded};}
/** Stand the host on a surface at height (used after a ledge climb). */
place(height){if(!Number.isFinite(height)||height<0)throw Error('Invalid ground height.');this.position=[0,height,0];this.velocity=0;this.grounded=true;this.platformHeight=height;}
launch(){if(!this.grounded)return false;this.velocity=this.impulse;this.grounded=false;this.takeoffs++;return true;}
step(){if(this.grounded)return;const previous=this.position[1];this.position[1]+=this.velocity*DT-.5*this.gravity*DT*DT;this.velocity-=this.gravity*DT;
const floor=this.velocity<0&&previous>=this.platformHeight?this.platformHeight:0;
if(this.position[1]<=floor&&this.velocity<0){this.position[1]=floor;this.velocity=0;this.grounded=true;this.landings++;}}
}