mujoco-react 12.0.0 → 12.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/dist/chunk-U2Y2BODT.js +137 -0
- package/dist/chunk-U2Y2BODT.js.map +1 -0
- package/dist/{chunk-7BCIHJMW.js → chunk-XD3AY7XZ.js} +47 -9
- package/dist/chunk-XD3AY7XZ.js.map +1 -0
- package/dist/index.d.ts +10 -15
- package/dist/index.js +1447 -1090
- package/dist/index.js.map +1 -1
- package/dist/onnx.d.ts +1 -1
- package/dist/physics.worker.d.ts +2 -0
- package/dist/physics.worker.js +78 -0
- package/dist/physics.worker.js.map +1 -0
- package/dist/spark.d.ts +1 -1
- package/dist/spark.js +1 -1
- package/dist/{types-BjHt_Urb.d.ts → types-CSi3fIVs.d.ts} +70 -1
- package/dist/vite.d.ts +5 -2
- package/dist/vite.js +6 -2
- package/dist/vite.js.map +1 -1
- package/package.json +5 -2
- package/src/components/CameraView.tsx +9 -7
- package/src/core/MujocoCanvas.tsx +2 -0
- package/src/core/MujocoPhysics.tsx +1 -0
- package/src/core/MujocoSimProvider.tsx +103 -81
- package/src/core/sceneIdentity.ts +15 -0
- package/src/core/stepBudget.ts +18 -0
- package/src/hooks/useCameraStream.ts +14 -17
- package/src/hooks/usePolicy.ts +16 -7
- package/src/index.ts +3 -0
- package/src/physics.worker.ts +13 -0
- package/src/rendering/cameraFrameCapture.ts +33 -7
- package/src/runtime/client.ts +196 -0
- package/src/runtime/engine.ts +93 -0
- package/src/runtime/protocol.ts +59 -0
- package/src/runtime/types.ts +51 -0
- package/src/types.ts +11 -0
- package/src/vite.ts +7 -2
- package/dist/chunk-7BCIHJMW.js.map +0 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { MainModule, MjModel } from '@mujoco/mujoco';
|
|
2
|
+
import type { MujocoModule, MujocoModel, MujocoSimAPI, PolicyCameraFrameCaptureOptions } from '../types';
|
|
3
|
+
import { capturePolicyCameraFrames } from '../policyCameraFrames';
|
|
4
|
+
import { displayFields, type DisplayState, type EngineInitialization } from './engine';
|
|
5
|
+
import type { Request, Response } from './protocol';
|
|
6
|
+
import type { RuntimeOptions, RuntimeState, RuntimeSnapshot, RuntimeMetrics, SimulationRuntime, WorkerPort, AdvanceOptions } from './types';
|
|
7
|
+
|
|
8
|
+
interface Owner { close: () => void; contacts: () => RuntimeState['contacts'] }
|
|
9
|
+
const owners = new WeakMap<MujocoModel, Owner>();
|
|
10
|
+
export const isWorkerOwned = (model: MujocoModel | null) => Boolean(model && owners.has(model));
|
|
11
|
+
export function closeModelRuntime(model: MujocoModel | null) { if (model) owners.get(model)?.close(); }
|
|
12
|
+
export function runtimeContacts(model: MujocoModel) { return owners.get(model)?.contacts(); }
|
|
13
|
+
export function assertMainThreadOwnership(model: MujocoModel | null) {
|
|
14
|
+
if (isWorkerOwned(model)) throw new Error('Worker runtime owns this simulation. Use runtime operations or dispose it before using viewer physics operations.');
|
|
15
|
+
}
|
|
16
|
+
const abortError = () => new DOMException('Simulation operation cancelled', 'AbortError');
|
|
17
|
+
|
|
18
|
+
/** Client owns backpressure and cancellation; protocol owns authoritative engine state. */
|
|
19
|
+
export class RuntimeClient implements SimulationRuntime {
|
|
20
|
+
private pending: { id: number; resolve: (r: Response) => void; reject: (e: Error) => void } | null = null;
|
|
21
|
+
private nextId = 0;
|
|
22
|
+
private closed = false;
|
|
23
|
+
private busy = false;
|
|
24
|
+
private flight: Promise<unknown> = Promise.resolve();
|
|
25
|
+
private generation = 0;
|
|
26
|
+
private latest: RuntimeState = { time: 0, qpos: new Float64Array(), qvel: new Float64Array(), ctrl: new Float64Array(), sensors: new Float64Array(), contacts: [] };
|
|
27
|
+
private statistics: RuntimeMetrics = { steps: 0, physicsMs: 0, elapsedMs: 0, realTimeFactor: 0, captureMs: 0, busy: false };
|
|
28
|
+
private readonly modelId = crypto.randomUUID();
|
|
29
|
+
constructor(
|
|
30
|
+
private worker: WorkerPort,
|
|
31
|
+
private apply: (display: DisplayState) => void,
|
|
32
|
+
private release: (restoreViewer: boolean) => void,
|
|
33
|
+
private capture?: (options: PolicyCameraFrameCaptureOptions) => ReturnType<typeof capturePolicyCameraFrames>,
|
|
34
|
+
) {
|
|
35
|
+
worker.onmessage = event => {
|
|
36
|
+
const response = event.data as Response;
|
|
37
|
+
if (this.closed || response.id !== this.pending?.id) return;
|
|
38
|
+
if (response.display) {
|
|
39
|
+
try {
|
|
40
|
+
this.apply(response.display);
|
|
41
|
+
const f = response.display.fields;
|
|
42
|
+
this.latest = { time: response.display.time, qpos: f.qpos, qvel: f.qvel, ctrl: f.ctrl, sensors: f.sensordata,
|
|
43
|
+
contacts: response.done ? response.display.contacts : this.latest.contacts };
|
|
44
|
+
} catch (error) { this.close(error instanceof Error ? error : new Error(String(error))); return; }
|
|
45
|
+
}
|
|
46
|
+
if (!response.done) this.worker.postMessage({ kind: 'ack', id: response.id });
|
|
47
|
+
if (response.done) {
|
|
48
|
+
const pending = this.pending; this.pending = null;
|
|
49
|
+
if (response.error) pending.reject(new Error(response.error));
|
|
50
|
+
else pending.resolve(response);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
worker.onerror = event => this.close(new Error(event.message || 'Simulation worker failed'));
|
|
54
|
+
worker.onmessageerror = () => this.close(new Error('Cannot read simulation worker response'));
|
|
55
|
+
}
|
|
56
|
+
get state(): RuntimeState {
|
|
57
|
+
return { ...this.latest, qpos: this.latest.qpos.slice(), qvel: this.latest.qvel.slice(), ctrl: this.latest.ctrl.slice(), sensors: this.latest.sensors.slice(),
|
|
58
|
+
contacts: this.latest.contacts.map(c => ({ ...c, pos: [...c.pos] as [number, number, number] })) };
|
|
59
|
+
}
|
|
60
|
+
get metrics() { return { ...this.statistics, busy: this.busy }; }
|
|
61
|
+
private request(request: Omit<Request, 'id'>): Promise<Response> {
|
|
62
|
+
if (this.closed) return Promise.reject(new Error('Simulation runtime is disposed'));
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const id = ++this.nextId;
|
|
65
|
+
this.pending = { id, resolve, reject };
|
|
66
|
+
try { this.worker.postMessage({ ...request, id }); }
|
|
67
|
+
catch (error) { this.pending = null; reject(error instanceof Error ? error : new Error(String(error))); }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
private exclusive<T>(operation: () => Promise<T>): Promise<T> {
|
|
71
|
+
if (this.closed) return Promise.reject(new Error('Simulation runtime is disposed'));
|
|
72
|
+
if (this.busy) return Promise.reject(new Error('Runtime is busy; await the current operation (work is not queued)'));
|
|
73
|
+
this.busy = true;
|
|
74
|
+
const result = operation().finally(() => { this.busy = false; });
|
|
75
|
+
this.flight = result.catch(() => {});
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
initialize(initial: EngineInitialization) {
|
|
79
|
+
return this.exclusive(async () => { await this.request({ kind: 'initialize', initial } as Request); });
|
|
80
|
+
}
|
|
81
|
+
advance(options: AdvanceOptions) {
|
|
82
|
+
return this.exclusive(async () => {
|
|
83
|
+
if (options.signal?.aborted) throw abortError();
|
|
84
|
+
const started = performance.now(), simStart = this.latest.time;
|
|
85
|
+
const abort = () => { this.worker.postMessage({ kind: 'cancel', id: ++this.nextId }); };
|
|
86
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
87
|
+
try {
|
|
88
|
+
const result = await this.request({ kind: 'advance', steps: options.steps,
|
|
89
|
+
controls: options.controls ? Float64Array.from(options.controls) : undefined } as Request);
|
|
90
|
+
const elapsedMs = performance.now() - started;
|
|
91
|
+
this.statistics = { ...this.statistics, steps: result.steps ?? 0, physicsMs: result.physicsMs ?? 0, elapsedMs,
|
|
92
|
+
realTimeFactor: (this.latest.time - simStart) * 1000 / Math.max(elapsedMs, .001) };
|
|
93
|
+
if (result.cancelled || options.signal?.aborted) throw abortError();
|
|
94
|
+
return this.state;
|
|
95
|
+
} finally { options.signal?.removeEventListener('abort', abort); }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
observe(cameras?: PolicyCameraFrameCaptureOptions) {
|
|
99
|
+
return this.exclusive(async () => {
|
|
100
|
+
const generation = this.generation;
|
|
101
|
+
await this.request({ kind: 'snapshot' });
|
|
102
|
+
const state = this.state, started = performance.now();
|
|
103
|
+
const frames = cameras ? await this.capture?.(cameras) : undefined;
|
|
104
|
+
if (cameras && !frames) throw new Error('Camera observations require an attached canvas');
|
|
105
|
+
this.statistics.captureMs = performance.now() - started;
|
|
106
|
+
if (generation !== this.generation || this.closed) throw abortError();
|
|
107
|
+
return { ...state, ...(frames ? { cameras: frames } : {}) };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
saveState() {
|
|
111
|
+
return this.exclusive(async () => {
|
|
112
|
+
const result = await this.request({ kind: 'snapshot' });
|
|
113
|
+
const state = result.display!.integration!;
|
|
114
|
+
return { modelId: this.modelId, signature: state.signature, values: state.values.slice() };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
restoreState(snapshot: RuntimeSnapshot) {
|
|
118
|
+
return this.exclusive(async () => {
|
|
119
|
+
if (snapshot.modelId !== this.modelId) throw new Error('Snapshot belongs to a different runtime');
|
|
120
|
+
await this.request({ kind: 'restore', state: { signature: snapshot.signature, values: snapshot.values } } as Request);
|
|
121
|
+
return this.state;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async cancel() {
|
|
125
|
+
if (this.closed) return;
|
|
126
|
+
this.generation++;
|
|
127
|
+
this.worker.postMessage({ kind: 'cancel', id: ++this.nextId });
|
|
128
|
+
await this.flight;
|
|
129
|
+
}
|
|
130
|
+
async reset() {
|
|
131
|
+
await this.cancel();
|
|
132
|
+
return this.exclusive(async () => { await this.request({ kind: 'reset' }); return this.state; });
|
|
133
|
+
}
|
|
134
|
+
async dispose() { await this.cancel(); this.close(); }
|
|
135
|
+
close(error = new Error('Simulation runtime disposed'), restoreViewer = true) {
|
|
136
|
+
if (this.closed) return;
|
|
137
|
+
this.closed = true; this.generation++;
|
|
138
|
+
this.worker.terminate();
|
|
139
|
+
this.pending?.reject(error); this.pending = null;
|
|
140
|
+
this.release(restoreViewer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function attachSimulationRuntime(
|
|
145
|
+
module: MujocoModule, api: MujocoSimAPI, waitForDisplay: () => Promise<void>, invalidate: () => void,
|
|
146
|
+
options: RuntimeOptions = {},
|
|
147
|
+
): Promise<SimulationRuntime> {
|
|
148
|
+
const model = api.mjModelRef.current, data = api.mjDataRef.current;
|
|
149
|
+
if (!model || !data) throw new Error('Scene must be ready before creating a runtime');
|
|
150
|
+
assertMainThreadOwnership(model);
|
|
151
|
+
api.setPaused(true);
|
|
152
|
+
const state = api.saveState().integration!;
|
|
153
|
+
const initialState = { signature: state.signature, values: state.values };
|
|
154
|
+
const mj = module as unknown as MainModule;
|
|
155
|
+
const path = `/runtime-${crypto.randomUUID()}.mjb`;
|
|
156
|
+
let binary: Uint8Array;
|
|
157
|
+
try { mj.mj_saveModel(model as unknown as MjModel, path, null); binary = mj.FS.readFile(path); }
|
|
158
|
+
finally { try { mj.FS.unlink(path); } catch { /* save may fail before file creation */ } }
|
|
159
|
+
const worker = options.createWorker?.() ?? new Worker(new URL('./physics.worker.js', import.meta.url), { type: 'module' });
|
|
160
|
+
let complete: DisplayState | undefined;
|
|
161
|
+
const apply = (display: DisplayState) => {
|
|
162
|
+
if (api.mjModelRef.current !== model) throw new Error('Simulation model changed');
|
|
163
|
+
if (display.integration) {
|
|
164
|
+
module.mj_setState(model, data, Array.from(display.integration.values), display.integration.signature);
|
|
165
|
+
complete = display;
|
|
166
|
+
}
|
|
167
|
+
for (const field of displayFields) {
|
|
168
|
+
const target = data[field];
|
|
169
|
+
if (!(target instanceof Float64Array || target instanceof Int32Array) || target.length !== display.fields[field].length) throw new Error(`Invalid display field ${field}`);
|
|
170
|
+
target.set(display.fields[field]);
|
|
171
|
+
}
|
|
172
|
+
data.time = display.time;
|
|
173
|
+
invalidate();
|
|
174
|
+
};
|
|
175
|
+
const client = new RuntimeClient(worker, apply, restoreViewer => {
|
|
176
|
+
// Revert partial display frames to the last acknowledged complete state.
|
|
177
|
+
try {
|
|
178
|
+
if (api.mjModelRef.current === model && complete) {
|
|
179
|
+
apply(complete);
|
|
180
|
+
// Native contact/constraint structures cannot be mirrored as typed arrays.
|
|
181
|
+
// Rebuild once when returning ownership, never on progress frames or unmount.
|
|
182
|
+
if (restoreViewer) {
|
|
183
|
+
module.mj_forward(model, data);
|
|
184
|
+
const state = complete.integration!;
|
|
185
|
+
module.mj_setState(model, data, Array.from(state.values), state.signature);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} finally { owners.delete(model); }
|
|
189
|
+
}, async cameras => {
|
|
190
|
+
await waitForDisplay();
|
|
191
|
+
return capturePolicyCameraFrames(api, cameras);
|
|
192
|
+
});
|
|
193
|
+
owners.set(model, { close: () => client.close(undefined, false), contacts: () => client.state.contacts });
|
|
194
|
+
try { await client.initialize({ model: binary, state: initialState }); return client; }
|
|
195
|
+
catch (error) { client.close(); throw error; }
|
|
196
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { MainModule, MjModel, MjData } from '@mujoco/mujoco';
|
|
2
|
+
import type { ContactInfo } from '../types';
|
|
3
|
+
|
|
4
|
+
export const displayFields = ['qpos', 'qvel', 'ctrl', 'act', 'plastic', 'sensordata', 'xpos', 'xquat', 'xmat',
|
|
5
|
+
'geom_xpos', 'geom_xmat', 'site_xpos', 'site_xmat', 'cam_xpos', 'cam_xmat',
|
|
6
|
+
'flexvert_xpos', 'ten_length', 'wrap_xpos', 'cvel', 'cfrc_ext', 'qfrc_applied', 'xfrc_applied', 'qfrc_bias', 'ten_wrapadr', 'ten_wrapnum'] as const;
|
|
7
|
+
export interface EngineState { signature: number; values: Float64Array }
|
|
8
|
+
export interface EngineInitialization { model: Uint8Array; state: EngineState }
|
|
9
|
+
export interface DisplayState {
|
|
10
|
+
time: number;
|
|
11
|
+
fields: Record<typeof displayFields[number], Float64Array>;
|
|
12
|
+
contacts: ContactInfo[];
|
|
13
|
+
integration?: EngineState;
|
|
14
|
+
}
|
|
15
|
+
export class SimulationEngine {
|
|
16
|
+
readonly model: MjModel;
|
|
17
|
+
readonly data: MjData;
|
|
18
|
+
constructor(private mj: MainModule, private initial: EngineInitialization) {
|
|
19
|
+
mj.FS.writeFile('/runtime.mjb', initial.model);
|
|
20
|
+
const vfs = new mj.MjVFS();
|
|
21
|
+
try { this.model = mj.MjModel.from_binary_path('/runtime.mjb', vfs); }
|
|
22
|
+
finally { vfs.delete(); mj.FS.unlink('/runtime.mjb'); }
|
|
23
|
+
this.data = new mj.MjData(this.model);
|
|
24
|
+
try { this.reset(); } catch (error) { this.dispose(); throw error; }
|
|
25
|
+
}
|
|
26
|
+
reset() {
|
|
27
|
+
this.mj.mj_resetData(this.model, this.data);
|
|
28
|
+
this.restore(this.initial.state);
|
|
29
|
+
}
|
|
30
|
+
restore(state: EngineState) {
|
|
31
|
+
if (state.signature !== this.mj.mjtState.mjSTATE_INTEGRATION.value ||
|
|
32
|
+
state.values.length !== this.mj.mj_stateSize(this.model, state.signature) ||
|
|
33
|
+
!state.values.every(Number.isFinite)) throw new Error('Invalid runtime snapshot');
|
|
34
|
+
this.mj.mj_setState(this.model, this.data, Array.from(state.values), state.signature);
|
|
35
|
+
this.mj.mj_forward(this.model, this.data);
|
|
36
|
+
}
|
|
37
|
+
setControls(controls?: Float64Array) {
|
|
38
|
+
if (controls) {
|
|
39
|
+
if (controls.length !== this.data.ctrl.length || !controls.every(Number.isFinite)) throw new Error('Invalid runtime controls');
|
|
40
|
+
this.data.ctrl.set(controls);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
step() {
|
|
44
|
+
const expected = this.data.time + this.model.opt.timestep;
|
|
45
|
+
this.mj.mj_step(this.model, this.data);
|
|
46
|
+
if (Math.abs(this.data.time - expected) > 1e-7 || !this.data.qpos.every(Number.isFinite)) {
|
|
47
|
+
throw new Error('Physics became unstable or reset unexpectedly');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
save(): EngineState {
|
|
51
|
+
const signature = this.mj.mjtState.mjSTATE_INTEGRATION.value;
|
|
52
|
+
const buffer = new this.mj.DoubleBuffer(this.mj.mj_stateSize(this.model, signature));
|
|
53
|
+
try {
|
|
54
|
+
this.mj.mj_getState(this.model, this.data, buffer, signature);
|
|
55
|
+
return { signature, values: new Float64Array(buffer.GetView()) };
|
|
56
|
+
} finally { buffer.delete(); }
|
|
57
|
+
}
|
|
58
|
+
snapshot(complete = true): DisplayState {
|
|
59
|
+
const integration = complete ? this.save() : undefined;
|
|
60
|
+
// mj_step integrates qpos after computing derived fields. Refresh completed
|
|
61
|
+
// observations at their reported time, preserving solver warmstart exactly.
|
|
62
|
+
if (integration) this.mj.mj_forward(this.model, this.data);
|
|
63
|
+
try { return {
|
|
64
|
+
time: this.data.time,
|
|
65
|
+
fields: Object.fromEntries(displayFields.map(key => [key, new Float64Array(this.data[key])])) as DisplayState['fields'],
|
|
66
|
+
contacts: complete ? this.contacts() : [],
|
|
67
|
+
...(integration ? { integration } : {}),
|
|
68
|
+
}; } finally {
|
|
69
|
+
if (integration) this.mj.mj_setState(this.model, this.data, Array.from(integration.values), integration.signature);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
private contacts(): ContactInfo[] {
|
|
73
|
+
const result: ContactInfo[] = [], vector = this.data.contact;
|
|
74
|
+
const names = this.model.names, addresses = this.model.name_geomadr;
|
|
75
|
+
const name = (id: number) => {
|
|
76
|
+
if (id < 0) return 'flex';
|
|
77
|
+
const start = addresses[id]; let end = start;
|
|
78
|
+
while (end < names.length && names[end]) end++;
|
|
79
|
+
return new TextDecoder().decode(names.subarray(start, end));
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
for (let i = 0; i < this.data.ncon; i++) {
|
|
83
|
+
const c = vector.get(i);
|
|
84
|
+
if (!c) continue;
|
|
85
|
+
try { result.push({ geom1: c.geom1, geom2: c.geom2, geom1Name: name(c.geom1), geom2Name: name(c.geom2),
|
|
86
|
+
pos: Array.from(c.pos) as [number, number, number], depth: c.dist }); }
|
|
87
|
+
finally { c.delete(); }
|
|
88
|
+
}
|
|
89
|
+
} finally { vector.delete(); }
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
dispose() { this.data.delete(); this.model.delete(); }
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { MainModule } from '@mujoco/mujoco';
|
|
2
|
+
import { SimulationEngine, type DisplayState, type EngineInitialization, type EngineState } from './engine';
|
|
3
|
+
export type Request = { id: number } & (
|
|
4
|
+
{ kind: 'initialize'; initial: EngineInitialization } |
|
|
5
|
+
{ kind: 'advance'; steps: number; controls?: Float64Array } |
|
|
6
|
+
{ kind: 'restore'; state: EngineState } |
|
|
7
|
+
{ kind: 'reset' | 'snapshot' | 'cancel' | 'ack' }
|
|
8
|
+
);
|
|
9
|
+
export interface Response {
|
|
10
|
+
id: number; done: boolean; display?: DisplayState; error?: string;
|
|
11
|
+
cancelled?: boolean; steps?: number; physicsMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Same protocol is exercised with a real engine in worker-thread tests. */
|
|
14
|
+
export function createRuntimeHandler(load: () => Promise<MainModule>, send: (response: Response) => void) {
|
|
15
|
+
let engine: SimulationEngine | undefined;
|
|
16
|
+
let generation = 0;
|
|
17
|
+
let busy = false;
|
|
18
|
+
let displayPending = false;
|
|
19
|
+
return async (request: Request) => {
|
|
20
|
+
if (request.kind === 'ack') { displayPending = false; return; }
|
|
21
|
+
if (request.kind === 'cancel') { generation++; return; }
|
|
22
|
+
if (busy) { send({ id: request.id, done: true, error: 'Runtime is busy; await the current operation' }); return; }
|
|
23
|
+
busy = true;
|
|
24
|
+
const current = generation;
|
|
25
|
+
let steps = 0, physicsMs = 0;
|
|
26
|
+
try {
|
|
27
|
+
if (request.kind === 'initialize') {
|
|
28
|
+
if (engine) throw new Error('Runtime is already initialized');
|
|
29
|
+
engine = new SimulationEngine(await load(), request.initial);
|
|
30
|
+
} else if (!engine) throw new Error('Runtime is not initialized');
|
|
31
|
+
else if (request.kind === 'reset') engine.reset();
|
|
32
|
+
else if (request.kind === 'restore') engine.restore(request.state);
|
|
33
|
+
else if (request.kind === 'advance') {
|
|
34
|
+
if (!Number.isSafeInteger(request.steps) || request.steps <= 0) throw new Error('Steps must be a positive safe integer');
|
|
35
|
+
engine.setControls(request.controls);
|
|
36
|
+
let lastDisplay = performance.now();
|
|
37
|
+
while (steps < request.steps && generation === current) {
|
|
38
|
+
const sliceStart = performance.now();
|
|
39
|
+
do {
|
|
40
|
+
const start = performance.now();
|
|
41
|
+
engine.step();
|
|
42
|
+
physicsMs += performance.now() - start;
|
|
43
|
+
steps++;
|
|
44
|
+
} while (steps < request.steps && performance.now() - sliceStart < 8);
|
|
45
|
+
if (!displayPending && performance.now() - lastDisplay >= 32) {
|
|
46
|
+
displayPending = true;
|
|
47
|
+
send({ id: request.id, done: false, display: engine.snapshot(false) });
|
|
48
|
+
lastDisplay = performance.now();
|
|
49
|
+
}
|
|
50
|
+
// A task yield is required: a microtask alone starves cancel messages.
|
|
51
|
+
if (steps < request.steps) await new Promise(resolve => setTimeout(resolve, 0));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
send({ id: request.id, done: true, display: engine!.snapshot(), cancelled: generation !== current, steps, physicsMs });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
send({ id: request.id, done: true, error: error instanceof Error ? error.message : String(error) });
|
|
57
|
+
} finally { busy = false; displayPending = false; }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ContactInfo, PolicyCameraFrameCaptureOptions, PolicyCameraFrameCaptureResult } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface RuntimeMetrics {
|
|
4
|
+
steps: number;
|
|
5
|
+
physicsMs: number;
|
|
6
|
+
elapsedMs: number;
|
|
7
|
+
realTimeFactor: number;
|
|
8
|
+
captureMs: number;
|
|
9
|
+
busy: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface RuntimeState {
|
|
12
|
+
time: number;
|
|
13
|
+
qpos: Float64Array;
|
|
14
|
+
qvel: Float64Array;
|
|
15
|
+
ctrl: Float64Array;
|
|
16
|
+
sensors: Float64Array;
|
|
17
|
+
contacts: ContactInfo[];
|
|
18
|
+
}
|
|
19
|
+
export interface RuntimeSnapshot {
|
|
20
|
+
readonly modelId: string;
|
|
21
|
+
readonly signature: number;
|
|
22
|
+
readonly values: Float64Array;
|
|
23
|
+
}
|
|
24
|
+
export interface AdvanceOptions {
|
|
25
|
+
steps: number;
|
|
26
|
+
controls?: ArrayLike<number>;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
/** One operation at a time. Await advance/observe rather than queueing work. */
|
|
30
|
+
export interface SimulationRuntime {
|
|
31
|
+
readonly state: RuntimeState;
|
|
32
|
+
readonly metrics: Readonly<RuntimeMetrics>;
|
|
33
|
+
advance(options: AdvanceOptions): Promise<RuntimeState>;
|
|
34
|
+
observe(cameras?: PolicyCameraFrameCaptureOptions): Promise<RuntimeState & { cameras?: PolicyCameraFrameCaptureResult }>;
|
|
35
|
+
saveState(): Promise<RuntimeSnapshot>;
|
|
36
|
+
restoreState(snapshot: RuntimeSnapshot): Promise<RuntimeState>;
|
|
37
|
+
cancel(): Promise<void>;
|
|
38
|
+
reset(): Promise<RuntimeState>;
|
|
39
|
+
dispose(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export interface WorkerPort {
|
|
42
|
+
postMessage(message: unknown): void;
|
|
43
|
+
terminate(): void;
|
|
44
|
+
onmessage: ((event: MessageEvent) => void) | null;
|
|
45
|
+
onerror: ((event: ErrorEvent) => void) | null;
|
|
46
|
+
onmessageerror: ((event: MessageEvent) => void) | null;
|
|
47
|
+
}
|
|
48
|
+
export interface RuntimeOptions {
|
|
49
|
+
/** Override worker construction for custom bundlers or runtime tests. */
|
|
50
|
+
createWorker?: () => WorkerPort;
|
|
51
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { SimulationRuntime, RuntimeOptions } from './runtime/types';
|
|
7
|
+
import type { PhysicsPerformance } from './core/stepBudget';
|
|
6
8
|
import type React from 'react';
|
|
7
9
|
import type { ReactNode } from 'react';
|
|
8
10
|
import type { CanvasProps, ThreeElements } from '@react-three/fiber';
|
|
@@ -618,6 +620,8 @@ export interface PhysicsConfig {
|
|
|
618
620
|
timestep?: number;
|
|
619
621
|
/** Override the MJCF integrator. Omit to preserve model settings. */
|
|
620
622
|
integrator?: MujocoIntegrator;
|
|
623
|
+
/** Main-thread physics time budget per frame. Default 8 ms; at most 100 steps. */
|
|
624
|
+
physicsBudgetMs?: number;
|
|
621
625
|
substeps?: number;
|
|
622
626
|
paused?: boolean;
|
|
623
627
|
speed?: number;
|
|
@@ -956,6 +960,8 @@ export interface PolicyConfig {
|
|
|
956
960
|
* - replace is useful for receding-horizon policies where a fresh chunk should supersede stale queued actions.
|
|
957
961
|
*/
|
|
958
962
|
queueStrategy?: 'append' | 'replace';
|
|
963
|
+
/** Maximum buffered actions; overflow fails explicitly. Default: 256. */
|
|
964
|
+
maxQueuedActions?: number;
|
|
959
965
|
/**
|
|
960
966
|
* Clear queued actions and ignore in-flight async results when `stop()` is called.
|
|
961
967
|
* Defaults to false so callers can choose pause/resume behavior explicitly.
|
|
@@ -1463,6 +1469,9 @@ export interface BodyProps {
|
|
|
1463
1469
|
// ---- Public API (spec: full surface) ----
|
|
1464
1470
|
|
|
1465
1471
|
export interface MujocoSimAPI {
|
|
1472
|
+
/** Dedicated worker owner for exact stepping and policy observations. */
|
|
1473
|
+
createRuntime(options?: RuntimeOptions): Promise<SimulationRuntime>;
|
|
1474
|
+
getPerformance(): PhysicsPerformance;
|
|
1466
1475
|
// State
|
|
1467
1476
|
readonly status: 'loading' | 'ready' | 'error';
|
|
1468
1477
|
readonly config: SceneConfig;
|
|
@@ -1887,6 +1896,8 @@ export type MujocoCanvasProps = Omit<CanvasProps, 'onError'> & {
|
|
|
1887
1896
|
timestep?: number;
|
|
1888
1897
|
/** Override the MJCF integrator. Omit to preserve model settings. */
|
|
1889
1898
|
integrator?: MujocoIntegrator;
|
|
1899
|
+
/** Main-thread physics time budget per frame. Default 8 ms; at most 100 steps. */
|
|
1900
|
+
physicsBudgetMs?: number;
|
|
1890
1901
|
substeps?: number;
|
|
1891
1902
|
paused?: boolean;
|
|
1892
1903
|
speed?: number;
|
package/src/vite.ts
CHANGED
|
@@ -92,8 +92,13 @@ export function mujocoReact(options: MujocoReactPluginOptions) {
|
|
|
92
92
|
// warning is expected, not a failure. Raise the limit so consumers don't
|
|
93
93
|
// see it. Vite merges plugin config on top of user config, so only set a
|
|
94
94
|
// default when the consumer hasn't specified their own limit.
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
return {
|
|
96
|
+
// Keep the worker URL relative to its real package file in dev. Vite's
|
|
97
|
+
// dependency prebundler otherwise relocates it into .vite/deps.
|
|
98
|
+
optimizeDeps: { exclude: ['mujoco-react'] },
|
|
99
|
+
...(userConfig.build?.chunkSizeWarningLimit === undefined
|
|
100
|
+
? { build: { chunkSizeWarningLimit: 4000 } } : {}),
|
|
101
|
+
};
|
|
97
102
|
},
|
|
98
103
|
configResolved(config: ViteConfig) {
|
|
99
104
|
root = config.root;
|