mujoco-react 11.0.0 → 12.1.0

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.
@@ -0,0 +1,186 @@
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: () => 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')) {
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();
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, () => {
176
+ // Revert partial display frames to the last acknowledged complete state.
177
+ if (api.mjModelRef.current === model && complete) apply(complete);
178
+ owners.delete(model);
179
+ }, async cameras => {
180
+ await waitForDisplay();
181
+ return capturePolicyCameraFrames(api, cameras);
182
+ });
183
+ owners.set(model, { close: () => client.close(), contacts: () => client.state.contacts });
184
+ try { await client.initialize({ model: binary, state: initialState }); return client; }
185
+ catch (error) { client.close(); throw error; }
186
+ }
@@ -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';
@@ -364,11 +366,16 @@ export interface MujocoModel {
364
366
  flex_shell: Int32Array;
365
367
  flex_rgba: Float32Array;
366
368
 
369
+ /** Number of stateful plastic deformation coordinates. */
370
+ nplastic: number;
371
+ jnt_plasticadr: Int32Array;
372
+
367
373
  // Model options
368
374
  opt: {
369
375
  timestep: number;
370
376
  gravity: Float64Array;
371
377
  integrator: number;
378
+ enableflags: number;
372
379
  [key: string]: unknown;
373
380
  };
374
381
 
@@ -383,6 +390,8 @@ export interface MujocoData {
383
390
  time: number;
384
391
  qpos: Float64Array;
385
392
  qvel: Float64Array;
393
+ /** Plastic rest offsets, included in full integration snapshots. */
394
+ plastic: Float64Array;
386
395
  ctrl: Float64Array;
387
396
  act: Float64Array;
388
397
  xpos: Float64Array;
@@ -419,6 +428,7 @@ export interface MujocoBuffer {
419
428
  }
420
429
 
421
430
  export interface MujocoModule {
431
+ mjtEnableBit: { mjENBL_SPECULATIVE: { value: number } };
422
432
  DoubleBuffer: new (size: number) => MujocoBuffer;
423
433
  mj_jacSite(model: MujocoModel, data: MujocoData, jacp: MujocoBuffer | null, jacr: MujocoBuffer | null, site: number): void;
424
434
  mj_integratePos(model: MujocoModel, qpos: MujocoBuffer, velocity: number[], dt: number): void;
@@ -610,6 +620,8 @@ export interface PhysicsConfig {
610
620
  timestep?: number;
611
621
  /** Override the MJCF integrator. Omit to preserve model settings. */
612
622
  integrator?: MujocoIntegrator;
623
+ /** Main-thread physics time budget per frame. Default 8 ms; at most 100 steps. */
624
+ physicsBudgetMs?: number;
613
625
  substeps?: number;
614
626
  paused?: boolean;
615
627
  speed?: number;
@@ -948,6 +960,8 @@ export interface PolicyConfig {
948
960
  * - replace is useful for receding-horizon policies where a fresh chunk should supersede stale queued actions.
949
961
  */
950
962
  queueStrategy?: 'append' | 'replace';
963
+ /** Maximum buffered actions; overflow fails explicitly. Default: 256. */
964
+ maxQueuedActions?: number;
951
965
  /**
952
966
  * Clear queued actions and ignore in-flight async results when `stop()` is called.
953
967
  * Defaults to false so callers can choose pause/resume behavior explicitly.
@@ -1455,6 +1469,9 @@ export interface BodyProps {
1455
1469
  // ---- Public API (spec: full surface) ----
1456
1470
 
1457
1471
  export interface MujocoSimAPI {
1472
+ /** Dedicated worker owner for exact stepping and policy observations. */
1473
+ createRuntime(options?: RuntimeOptions): Promise<SimulationRuntime>;
1474
+ getPerformance(): PhysicsPerformance;
1458
1475
  // State
1459
1476
  readonly status: 'loading' | 'ready' | 'error';
1460
1477
  readonly config: SceneConfig;
@@ -1879,6 +1896,8 @@ export type MujocoCanvasProps = Omit<CanvasProps, 'onError'> & {
1879
1896
  timestep?: number;
1880
1897
  /** Override the MJCF integrator. Omit to preserve model settings. */
1881
1898
  integrator?: MujocoIntegrator;
1899
+ /** Main-thread physics time budget per frame. Default 8 ms; at most 100 steps. */
1900
+ physicsBudgetMs?: number;
1882
1901
  substeps?: number;
1883
1902
  paused?: boolean;
1884
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
- if (userConfig.build?.chunkSizeWarningLimit !== undefined) return;
96
- return { build: { chunkSizeWarningLimit: 4000 } };
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;