Files
Mujoco_WASM/wasm/web_platform/src/simulation/SimulationSession.ts
T
chenlin 1eb05132f1
build / setup (compute matrix) (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
feat: add browser-based MuJoCo simulation platform
2026-08-20 15:12:08 +08:00

175 lines
10 KiB
TypeScript

import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco';
import {meshIdFromSceneDataId} from './geometry';
export interface ActuatorInfo {id: number; name: string; value: number; min: number; max: number; limited: boolean;}
export interface JointInfo {id: number; name: string; type: number; value: number; min: number; max: number; limited: boolean; editable: boolean; bodyId: number; axis: [number, number, number];}
export interface BodyInfo {id: number; name: string;}
export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; model: {nbody: number; njnt: number; ngeom: number; nu: number; nq: number; nv: number};}
export interface FrameResult {steps: number; stepMs: number; overBudget: boolean;}
export class SimulationSession {
readonly model: MjModel;
readonly data: MjData;
readonly perturb: MjvPerturb;
paused = true;
speed = 1;
readonly frameBudgetMs = 8;
readonly maxCatchUpSteps = 100;
private accumulator = 0;
private lastNow?: number;
private forceBody = -1;
private force: [number, number, number] = [0, 0, 0];
private disposed = false;
constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) {
let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined;
try {
model = module.MjModel.mj_loadXML(modelPath) ?? undefined;
if (!model) throw new Error(`MuJoCo 无法编译模型:${modelPath}`);
data = new module.MjData(model);
perturb = new module.MjvPerturb(); module.mjv_defaultPerturb(perturb);
this.model = model; this.data = data; this.perturb = perturb;
module.mj_forward(model, data);
} catch (error) { perturb?.delete(); data?.delete(); model?.delete(); throw error; }
}
setPaused(paused: boolean): void {this.paused = paused; this.accumulator = 0; this.lastNow = undefined;}
setSpeed(speed: number): void {this.speed = Math.min(4, Math.max(0.1, speed));}
reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();}
singleStep(): void {this.applyForce(); this.module.mj_step(this.model, this.data);}
advance(now: number): FrameResult {
if (this.lastNow === undefined) {this.lastNow = now; return {steps: 0, stepMs: 0, overBudget: false};}
const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000)); this.lastNow = now;
if (this.paused) return {steps: 0, stepMs: 0, overBudget: false};
this.accumulator += elapsed * this.speed;
const dt = Number(this.model.opt.timestep) || 0.002; const started = performance.now(); let steps = 0;
while (this.accumulator >= dt && steps < this.maxCatchUpSteps && performance.now() - started < this.frameBudgetMs) {
this.applyForce(); this.module.mj_step(this.model, this.data); this.accumulator -= dt; steps++;
}
const overBudget = this.accumulator >= dt;
if (steps >= this.maxCatchUpSteps) this.accumulator = Math.min(this.accumulator, dt);
return {steps, stepMs: performance.now() - started, overBudget};
}
setActuator(id: number, value: number): void {
if (id < 0 || id >= this.model.nu) return;
const actuator = this.model.actuator(id);
try {
const limited = Boolean(actuator.ctrllimited);
const min = limited ? Number(actuator.ctrlrange[0]) : -1;
const max = limited ? Number(actuator.ctrlrange[1]) : 1;
const address = Number(this.model.actuator_ctrladr[id] ?? id);
this.data.ctrl[address] = Math.min(max, Math.max(min, value));
} finally {
actuator.delete();
}
}
setJointPosition(id: number, value: number): boolean {
if (id < 0 || id >= this.model.njnt) return false;
const joint = this.model.jnt(id);
try {
const type = Number(joint.type);
if (type !== 2 && type !== 3) return false;
const limited = Boolean(joint.limited);
const min = limited ? Number(joint.range[0]) : -Math.PI;
const max = limited ? Number(joint.range[1]) : Math.PI;
this.setPaused(true);
this.data.qpos[Number(joint.qposadr)] = Math.min(max, Math.max(min, value));
this.module.mj_forward(this.model, this.data);
return true;
} finally {
joint.delete();
}
}
setExternalForce(bodyId: number, force: [number, number, number]): void {this.forceBody = bodyId > 0 && bodyId < this.model.nbody ? bodyId : -1; this.force = force;}
clearExternalForce(): void {this.forceBody = -1; this.force = [0, 0, 0]; this.data.xfrc_applied.fill(0); this.perturb.active = 0;}
initializePerturb(scene: MjvScene, bodyId: number): void {this.perturb.select = bodyId; this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);}
applyPerturbForce(): void {if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb);}
private applyForce(): void {
this.data.xfrc_applied.fill(0); if (this.forceBody < 1) return;
this.applyPerturbForce(); const offset = this.forceBody * 6;
this.data.xfrc_applied[offset] += this.force[0]; this.data.xfrc_applied[offset + 1] += this.force[1]; this.data.xfrc_applied[offset + 2] += this.force[2];
}
/** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */
geometryBounds():{center:[number,number,number];extent:number} {
const lower=[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY];const upper=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY];
for(let geom=0;geom<this.model.ngeom;geom+=1){if(Number(this.model.geom_type[geom])===this.module.mjtGeom.mjGEOM_PLANE.value)continue;const radius=Math.max(0,Number(this.model.geom_rbound[geom]));for(let axis=0;axis<3;axis+=1){const value=Number(this.data.geom_xpos[geom*3+axis]);lower[axis]=Math.min(lower[axis],value-radius);upper[axis]=Math.max(upper[axis],value+radius);}}
if(!lower.every(Number.isFinite)||!upper.every(Number.isFinite))return {center:[0,0,0],extent:2};
return {center:[(lower[0]+upper[0])/2,(lower[1]+upper[1])/2,(lower[2]+upper[2])/2],extent:Math.max(.5,upper[0]-lower[0],upper[1]-lower[1],upper[2]-lower[2])};
}
/** 返回当前姿态全部模型几何(不含无限平面)的最低世界坐标。 */
minimumGeometryZ():number {
const option=new this.module.MjvOption();const camera=new this.module.MjvCamera();const scene=new this.module.MjvScene(this.model,32768);
this.module.mjv_defaultOption(option);this.module.mjv_defaultCamera(camera);
let minimum=Number.POSITIVE_INFINITY;
try{
this.module.mjv_updateScene(this.model,this.data,option,this.perturb,camera,this.module.mjtCatBit.mjCAT_ALL.value,scene);
const geoms=scene.geoms;
try{for(let index=0;index<geoms.size();index+=1){const geom=geoms.get(index);if(!geom)continue;try{
if(geom.type===this.module.mjtGeom.mjGEOM_PLANE.value)continue;
const z=this.geomMinimumZ(geom);if(Number.isFinite(z))minimum=Math.min(minimum,z);
}finally{geom.delete();}}}finally{geoms.delete();}
}finally{scene.delete();camera.delete();option.delete();}
return Number.isFinite(minimum)?minimum:0;
}
/** 平移所有世界根 body,使当前姿态的最低点位于 z=0。 */
alignLowestPointToGround():number {
const offset=-this.minimumGeometryZ();
if(Math.abs(offset)<1e-9)return 0;
for(let body=1;body<this.model.nbody;body+=1)if(Number(this.model.body_parentid[body])===0)this.model.body_pos[body*3+2]+=offset;
this.module.mj_forward(this.model,this.data);
return offset;
}
private geomMinimumZ(geom:import('@mujoco/mujoco').MjvGeom):number {
const m=this.module,type=geom.type,s=geom.size,r0=geom.mat[6],r1=geom.mat[7],r2=geom.mat[8],center=geom.pos[2];
if(type===m.mjtGeom.mjGEOM_SPHERE.value)return center-s[0];
if(type===m.mjtGeom.mjGEOM_BOX.value)return center-(Math.abs(r0)*s[0]+Math.abs(r1)*s[1]+Math.abs(r2)*s[2]);
if(type===m.mjtGeom.mjGEOM_ELLIPSOID.value)return center-Math.hypot(r0*s[0],r1*s[1],r2*s[2]);
if(type===m.mjtGeom.mjGEOM_CYLINDER.value)return center-(Math.hypot(r0,r1)*s[0]+Math.abs(r2)*s[2]);
if(type===m.mjtGeom.mjGEOM_CAPSULE.value)return center-(s[0]+Math.abs(r2)*s[2]);
if(type===m.mjtGeom.mjGEOM_MESH.value&&geom.dataid>=0){
const id=meshIdFromSceneDataId(geom.dataid),first=Number(this.model.mesh_vertadr[id]),count=Number(this.model.mesh_vertnum[id]);let minimum=Number.POSITIVE_INFINITY;
for(let vertex=0;vertex<count;vertex+=1){const offset=(first+vertex)*3;minimum=Math.min(minimum,center+r0*this.model.mesh_vert[offset]+r1*this.model.mesh_vert[offset+1]+r2*this.model.mesh_vert[offset+2]);}
return minimum;
}
const radius=geom.size[0]||0;return center-radius;
}
snapshot(): SimulationSnapshot {
const actuators = Array.from({length: this.model.nu}, (_, id): ActuatorInfo => {
const actuator = this.model.actuator(id);
try {
const limited = Boolean(actuator.ctrllimited);
const address = Number(this.model.actuator_ctrladr[id] ?? id);
return {id,name:actuator.name || `actuator_${id}`,value:Number(this.data.ctrl[address]),min:limited?Number(actuator.ctrlrange[0]):-1,max:limited?Number(actuator.ctrlrange[1]):1,limited};
} finally {
actuator.delete();
}
});
const joints = Array.from({length: this.model.njnt}, (_, id): JointInfo => {
const joint = this.model.jnt(id);
try {
const type=Number(joint.type); const limited=Boolean(joint.limited);
return {id,name:joint.name || `joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min:limited?Number(joint.range[0]):-Math.PI,max:limited?Number(joint.range[1]):Math.PI,limited,editable:type===2||type===3,bodyId:Number(joint.bodyid),axis:[Number(joint.axis[0]),Number(joint.axis[1]),Number(joint.axis[2])]};
} finally {
joint.delete();
}
});
const bodies = Array.from({length: this.model.nbody}, (_,id): BodyInfo => {
const body = this.model.body(id);
try { return {id,name:body.name || `body_${id}`}; }
finally { body.delete(); }
});
return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}};
}
dispose(): void {if(this.disposed)return; this.disposed=true; this.clearExternalForce(); this.perturb.delete(); this.data.delete(); this.model.delete();}
}