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.
@@ -7,6 +7,10 @@ import { captureSimulationState, restoreSimulationState } from './engineState';
7
7
  import { getActuatorInfo, writeActuatorControl } from './SceneLoader';
8
8
  import type { ActuatorControlValue, MujocoIntegrator } from '../types';
9
9
 
10
+ import { sceneIdentity } from './sceneIdentity';
11
+ import { runStepBudget, type PhysicsPerformance } from './stepBudget';
12
+ import { attachSimulationRuntime, assertMainThreadOwnership, isWorkerOwned, closeModelRuntime, runtimeContacts } from '../runtime/client';
13
+ import type { RuntimeOptions } from '../runtime/types';
10
14
  import { useFrame, useThree } from '@react-three/fiber';
11
15
  import {
12
16
  createContext,
@@ -514,11 +518,12 @@ export function useMujoco(): UseMujocoResult {
514
518
  }
515
519
 
516
520
  export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
517
- const { beforeStepCallbacks } = useMujocoContext();
521
+ const { beforeStepCallbacks, mjModelRef } = useMujocoContext();
518
522
  const callbackRef = useRef(callback);
519
523
  callbackRef.current = callback;
520
524
 
521
525
  useEffect(() => {
526
+ assertMainThreadOwnership(mjModelRef.current);
522
527
  const wrapped: PhysicsStepCallback = (input) => callbackRef.current(input);
523
528
  beforeStepCallbacks.current.add(wrapped);
524
529
  return () => { beforeStepCallbacks.current.delete(wrapped); };
@@ -526,11 +531,12 @@ export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
526
531
  }
527
532
 
528
533
  export function useAfterPhysicsStep(callback: PhysicsStepCallback) {
529
- const { afterStepCallbacks } = useMujocoContext();
534
+ const { afterStepCallbacks, mjModelRef } = useMujocoContext();
530
535
  const callbackRef = useRef(callback);
531
536
  callbackRef.current = callback;
532
537
 
533
538
  useEffect(() => {
539
+ assertMainThreadOwnership(mjModelRef.current);
534
540
  const wrapped: PhysicsStepCallback = (input) => callbackRef.current(input);
535
541
  afterStepCallbacks.current.add(wrapped);
536
542
  return () => { afterStepCallbacks.current.delete(wrapped); };
@@ -550,6 +556,7 @@ interface MujocoSimProviderProps {
550
556
  timestep?: number;
551
557
  integrator?: MujocoIntegrator;
552
558
  substeps?: number;
559
+ physicsBudgetMs?: number;
553
560
  paused?: boolean;
554
561
  speed?: number;
555
562
  interpolate?: boolean;
@@ -570,6 +577,7 @@ export function MujocoSimProvider({
570
577
  timestep,
571
578
  integrator,
572
579
  substeps,
580
+ physicsBudgetMs = 8,
573
581
  paused,
574
582
  speed,
575
583
  interpolate,
@@ -577,7 +585,22 @@ export function MujocoSimProvider({
577
585
  hiddenBodies,
578
586
  children,
579
587
  }: MujocoSimProviderProps) {
580
- const { gl, camera, scene } = useThree();
588
+ const { gl, camera, scene, invalidate } = useThree();
589
+ const modelIdentity = sceneIdentity(config);
590
+ const performanceRef = useRef<PhysicsPerformance>({ steps: 0, physicsMs: 0, realTimeFactor: 0, backlogSteps: 0, budgetExceeded: false });
591
+ const displayWaiters = useRef(new Set<() => void>());
592
+ useFrame(() => {
593
+ if (displayWaiters.current.size) {
594
+ const waiters = [...displayWaiters.current];
595
+ displayWaiters.current.clear();
596
+ queueMicrotask(() => { scene.updateMatrixWorld(true); for (const ready of waiters) ready(); });
597
+ }
598
+ }, 0);
599
+ const waitForDisplay = () => new Promise<void>((resolve, reject) => {
600
+ const ready = () => { clearTimeout(timer); resolve(); };
601
+ const timer = setTimeout(() => { displayWaiters.current.delete(ready); reject(new Error('Canvas did not produce an observation frame within 5 seconds')); }, 5000);
602
+ displayWaiters.current.add(ready); invalidate();
603
+ });
581
604
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
582
605
 
583
606
  // --- Refs ---
@@ -618,8 +641,8 @@ export function MujocoSimProvider({
618
641
 
619
642
  configRef.current = config;
620
643
  mujocoRef.current = mujoco;
621
- pausedRef.current = paused ?? false;
622
- speedRef.current = speed ?? 1;
644
+ useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
645
+ useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
623
646
  substepsRef.current = substeps ?? 1;
624
647
  interpolateRef.current = interpolate ?? false;
625
648
 
@@ -628,10 +651,11 @@ export function MujocoSimProvider({
628
651
  if (!gravity) return;
629
652
  const model = mjModelRef.current;
630
653
  if (!model?.opt?.gravity) return;
654
+ assertMainThreadOwnership(model);
631
655
  model.opt.gravity[0] = gravity[0];
632
656
  model.opt.gravity[1] = gravity[1];
633
657
  model.opt.gravity[2] = gravity[2];
634
- }, [gravity]);
658
+ }, [gravity?.[0], gravity?.[1], gravity?.[2]]);
635
659
 
636
660
  const integratorRef = useRef(integrator);
637
661
  integratorRef.current = integrator;
@@ -640,7 +664,7 @@ export function MujocoSimProvider({
640
664
  if (value !== undefined) model.opt.integrator = { Euler: 0, RK4: 1, implicit: 2, implicitfast: 3, discrete: 4 }[value];
641
665
  };
642
666
  useEffect(() => {
643
- if (mjModelRef.current) applyIntegrator(mjModelRef.current);
667
+ if (mjModelRef.current) { assertMainThreadOwnership(mjModelRef.current); applyIntegrator(mjModelRef.current); }
644
668
  }, [integrator]);
645
669
 
646
670
  // Sync timestep prop
@@ -648,6 +672,7 @@ export function MujocoSimProvider({
648
672
  if (timestep === undefined) return;
649
673
  const model = mjModelRef.current;
650
674
  if (!model?.opt) return;
675
+ assertMainThreadOwnership(model);
651
676
  model.opt.timestep = timestep;
652
677
  }, [timestep]);
653
678
 
@@ -694,6 +719,7 @@ export function MujocoSimProvider({
694
719
  // --- Load scene on mount ---
695
720
  useEffect(() => {
696
721
  let disposed = false;
722
+ setStatus('loading');
697
723
 
698
724
  (async () => {
699
725
  try {
@@ -734,6 +760,7 @@ export function MujocoSimProvider({
734
760
 
735
761
  return () => {
736
762
  disposed = true;
763
+ closeModelRuntime(mjModelRef.current);
737
764
  mjDataRef.current?.delete();
738
765
  mjModelRef.current?.delete();
739
766
  mjModelRef.current = null;
@@ -742,7 +769,7 @@ export function MujocoSimProvider({
742
769
  interpolationStateRef.current.valid = false;
743
770
  try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
744
771
  };
745
- }, [mujoco, config]);
772
+ }, [mujoco, modelIdentity]);
746
773
 
747
774
  // Fire onReady and assign external ref when status changes to ready
748
775
  useEffect(() => {
@@ -766,77 +793,40 @@ export function MujocoSimProvider({
766
793
  const data = mjDataRef.current;
767
794
  if (!model || !data) return;
768
795
 
769
- // Check single-step mode
770
- const shouldStep = !pausedRef.current || stepsToRunRef.current > 0;
771
- if (!shouldStep) return;
772
-
773
- // Zero generalized applied forces
774
- for (let i = 0; i < model.nv; i++) {
775
- data.qfrc_applied[i] = 0;
776
- }
777
-
778
- // Before-step callbacks
779
- for (const cb of beforeStepCallbacks.current) {
780
- cb({ model, data });
781
- }
782
-
783
- const numSubsteps = substepsRef.current;
784
- if (!interpolateRef.current) {
785
- // Step physics with substeps
786
- if (stepsToRunRef.current > 0) {
787
- for (let s = 0; s < stepsToRunRef.current; s++) {
788
- mujocoRef.current.mj_step(model, data);
789
- }
790
- stepsToRunRef.current = 0;
791
- } else {
792
- const startSimTime = data.time;
793
- const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
794
- const frameTime = clampedDelta * speedRef.current;
795
- while (data.time - startSimTime < frameTime) {
796
- for (let s = 0; s < numSubsteps; s++) {
797
- mujocoRef.current.mj_step(model, data);
798
- }
799
- }
800
- }
801
- } else if (stepsToRunRef.current > 0) {
796
+ if (isWorkerOwned(model)) { interpolationStateRef.current.valid = false; return; }
797
+ const queued = stepsToRunRef.current > 0;
798
+ if (pausedRef.current && !queued) return;
799
+ const startTime = data.time;
800
+ const dt = model.opt.timestep;
801
+ if (!Number.isFinite(dt) || dt <= 0) return;
802
+ const substeps = Math.max(1, Math.floor(substepsRef.current));
803
+ const frameTime = Math.max(0, Math.min(delta, 1 / 15) * speedRef.current);
804
+ if (!queued && interpolateRef.current) physicsAccumulatorRef.current += frameTime;
805
+ const desired = queued ? stepsToRunRef.current : interpolateRef.current
806
+ ? Math.floor(physicsAccumulatorRef.current / (dt * substeps)) * substeps
807
+ : Math.ceil(frameTime / (dt * substeps)) * substeps;
808
+ for (let i = 0; i < model.nv; i++) data.qfrc_applied[i] = 0;
809
+ for (const cb of beforeStepCallbacks.current) cb({ model, data });
810
+ if (interpolateRef.current) {
802
811
  ensureInterpolationBuffers(model);
803
- copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
804
- for (let s = 0; s < stepsToRunRef.current; s++) {
805
- mujocoRef.current.mj_step(model, data);
806
- }
812
+ if (!interpolationStateRef.current.valid) copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
813
+ }
814
+ const measured = runStepBudget(desired, () => {
815
+ if (interpolateRef.current) copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
816
+ mujocoRef.current.mj_step(model, data);
817
+ }, physicsBudgetMs);
818
+ if (queued) stepsToRunRef.current -= measured.steps;
819
+ // Viewer overload slows simulation; it never accumulates an unbounded catch-up debt.
820
+ performanceRef.current = { ...measured, backlogSteps: Math.max(0, desired - measured.steps),
821
+ realTimeFactor: (data.time - startTime) / Math.max(delta, .000001) };
822
+ if (interpolateRef.current) {
807
823
  copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
808
- interpolationStateRef.current.alpha = 1;
824
+ if (!queued) {
825
+ physicsAccumulatorRef.current -= measured.steps * dt;
826
+ if (measured.budgetExceeded) physicsAccumulatorRef.current %= dt * substeps;
827
+ } else physicsAccumulatorRef.current = 0;
828
+ interpolationStateRef.current.alpha = queued ? 1 : Math.min(1, physicsAccumulatorRef.current / dt);
809
829
  interpolationStateRef.current.valid = true;
810
- stepsToRunRef.current = 0;
811
- } else {
812
- ensureInterpolationBuffers(model);
813
- const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
814
- physicsAccumulatorRef.current += clampedDelta * speedRef.current;
815
- const stepDt = Math.max((model.opt?.timestep ?? 0.002) * Math.max(1, numSubsteps), 1e-6);
816
- let stepped = false;
817
-
818
- while (physicsAccumulatorRef.current >= stepDt) {
819
- copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
820
- for (let s = 0; s < numSubsteps; s++) {
821
- mujocoRef.current.mj_step(model, data);
822
- }
823
- copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
824
- physicsAccumulatorRef.current -= stepDt;
825
- stepped = true;
826
- }
827
-
828
- if (!interpolationStateRef.current.valid) {
829
- copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
830
- copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
831
- }
832
-
833
- interpolationStateRef.current.alpha = Math.min(Math.max(physicsAccumulatorRef.current / stepDt, 0), 1);
834
- interpolationStateRef.current.valid = true;
835
-
836
- if (!stepped) {
837
- onStepRef.current?.({ time: data.time, model, data });
838
- return;
839
- }
840
830
  }
841
831
 
842
832
  // After-step callbacks
@@ -906,6 +896,7 @@ export function MujocoSimProvider({
906
896
  }, []);
907
897
 
908
898
  const step = useCallback((n = 1) => {
899
+ if (!Number.isSafeInteger(n) || n <= 0) throw new Error('Step count must be a positive safe integer');
909
900
  stepsToRunRef.current = n;
910
901
  }, []);
911
902
 
@@ -1384,6 +1375,7 @@ export function MujocoSimProvider({
1384
1375
  const data = mjDataRef.current;
1385
1376
  if (!model || !data) return null;
1386
1377
 
1378
+ assertMainThreadOwnership(model);
1387
1379
  _rayPnt[0] = origin.x; _rayPnt[1] = origin.y; _rayPnt[2] = origin.z;
1388
1380
  const dir = direction.clone().normalize();
1389
1381
  _rayVec[0] = dir.x; _rayVec[1] = dir.y; _rayVec[2] = dir.z;
@@ -1462,6 +1454,7 @@ export function MujocoSimProvider({
1462
1454
  const loadSceneApi = useCallback(async (newConfig: SceneConfig): Promise<void> => {
1463
1455
  const gen = ++loadGenRef.current;
1464
1456
  try {
1457
+ closeModelRuntime(mjModelRef.current);
1465
1458
  mjDataRef.current?.delete();
1466
1459
  mjModelRef.current?.delete();
1467
1460
  mjModelRef.current = null;
@@ -1640,6 +1633,7 @@ export function MujocoSimProvider({
1640
1633
  throw new Error('MuJoCo scene is not ready for camera sequence stepping.');
1641
1634
  }
1642
1635
 
1636
+ let sliceStarted = performance.now();
1643
1637
  for (let stepIndex = 0; stepIndex < steps; stepIndex += 1) {
1644
1638
  for (let i = 0; i < model.nv; i += 1) {
1645
1639
  data.qfrc_applied[i] = 0;
@@ -1659,6 +1653,11 @@ export function MujocoSimProvider({
1659
1653
  cb({ model, data });
1660
1654
  }
1661
1655
  onStepRef.current?.({ time: data.time, model, data });
1656
+ if (performance.now() - sliceStarted >= 8) {
1657
+ await new Promise(resolve => setTimeout(resolve, 0));
1658
+ throwIfCameraSequenceAborted(options.signal);
1659
+ sliceStarted = performance.now();
1660
+ }
1662
1661
  await options.onAfterStep?.({
1663
1662
  frameIndex,
1664
1663
  stepIndex,
@@ -1890,8 +1889,15 @@ export function MujocoSimProvider({
1890
1889
  }, []);
1891
1890
 
1892
1891
  // --- Assemble API ---
1893
- const api = useMemo<MujocoSimAPI>(
1894
- () => ({
1892
+ const api: MujocoSimAPI = useMemo<MujocoSimAPI>(
1893
+ () => guardRuntimeOwnership({
1894
+ createRuntime: (options?: RuntimeOptions) => {
1895
+ if (beforeStepCallbacks.current.size || afterStepCallbacks.current.size) {
1896
+ return Promise.reject(new Error('Unmount physics callbacks/controllers before creating a worker runtime; use runtime.advance({controls}) for policy actions.'));
1897
+ }
1898
+ return attachSimulationRuntime(mujocoRef.current, apiRef.current, waitForDisplay, invalidate, options);
1899
+ },
1900
+ getPerformance: () => ({ ...performanceRef.current }),
1895
1901
  get status() { return status; },
1896
1902
  get config() { return configRef.current; },
1897
1903
  reset,
@@ -1918,7 +1924,7 @@ export function MujocoSimProvider({
1918
1924
  setExternalForce,
1919
1925
  applyGeneralizedForce,
1920
1926
  getSensorData,
1921
- getContacts,
1927
+ getContacts: () => { const model = mjModelRef.current; return model && isWorkerOwned(model) ? runtimeContacts(model)! : getContacts(); },
1922
1928
  getBodies,
1923
1929
  getJoints,
1924
1930
  getGeoms,
@@ -1954,7 +1960,7 @@ export function MujocoSimProvider({
1954
1960
  setGeomSize,
1955
1961
  mjModelRef,
1956
1962
  mjDataRef,
1957
- }),
1963
+ }, () => mjModelRef.current),
1958
1964
  [
1959
1965
  status, reset, setSpeed, togglePause, setPaused, step,
1960
1966
  getTime, getTimestep, applyKeyframe, saveState, restoreState,
@@ -1975,7 +1981,7 @@ export function MujocoSimProvider({
1975
1981
  setBodyMass, setGeomFriction, setGeomSize,
1976
1982
  ]
1977
1983
  );
1978
- const apiRef = useRef(api);
1984
+ const apiRef = useRef<MujocoSimAPI>(api);
1979
1985
  apiRef.current = api;
1980
1986
 
1981
1987
  const contextValue = useMemo<MujocoSimContextValue>(
@@ -2011,3 +2017,19 @@ export function MujocoSimProvider({
2011
2017
  </MujocoSimContext.Provider>
2012
2018
  );
2013
2019
  }
2020
+
2021
+
2022
+ const runtimeMutators = new Set<PropertyKey>([
2023
+ 'reset', 'setSpeed', 'togglePause', 'setPaused', 'step', 'applyKeyframe', 'saveState', 'restoreState',
2024
+ 'setQpos', 'setQvel', 'setCtrl', 'applyForce', 'applyTorque', 'setExternalForce', 'applyGeneralizedForce',
2025
+ 'setGravity', 'setTimestep', 'setBodyMass', 'setGeomFriction', 'setGeomSize',
2026
+ 'loadScene', 'loadFromFiles', 'addBody', 'removeBody', 'recompile', 'recordCameraSequence',
2027
+ ]);
2028
+ function guardRuntimeOwnership(api: MujocoSimAPI, model: () => MujocoModel | null): MujocoSimAPI {
2029
+ for (const key of runtimeMutators) {
2030
+ const record = api as unknown as Record<PropertyKey, (...args: unknown[]) => unknown>;
2031
+ const method = record[key];
2032
+ record[key] = (...args) => { assertMainThreadOwnership(model()); return method(...args); };
2033
+ }
2034
+ return api;
2035
+ }
@@ -0,0 +1,15 @@
1
+ import type { SceneConfig } from '../types';
2
+ const fileIds = new WeakMap<object, number>();
3
+ let nextFileId = 0;
4
+ /** Model inputs define lifetime. Callback identity never recompiles physics. */
5
+ export function sceneIdentity(config: SceneConfig): string {
6
+ return JSON.stringify([
7
+ config.src, config.sceneFile, config.environmentFiles ?? [], config.sceneObjects ?? [],
8
+ config.homeJoints ?? [], config.xmlPatches ?? [], (config.files ?? []).map(file => {
9
+ if (!fileIds.has(file)) fileIds.set(file, ++nextFileId);
10
+ return fileIds.get(file);
11
+ }),
12
+ ], (_key, value: unknown) => value && typeof value === 'object' && !Array.isArray(value)
13
+ ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
14
+ : value);
15
+ }
@@ -0,0 +1,18 @@
1
+ export interface PhysicsPerformance {
2
+ steps: number;
3
+ physicsMs: number;
4
+ realTimeFactor: number;
5
+ backlogSteps: number;
6
+ budgetExceeded: boolean;
7
+ }
8
+ /** A single mj_step cannot be preempted; always finish it, then yield. */
9
+ export function runStepBudget(requested: number, step: () => void, budgetMs = 8, now = () => performance.now()) {
10
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0) throw new Error('physicsBudgetMs must be positive and finite');
11
+ const start = now();
12
+ let steps = 0;
13
+ while (steps < requested && steps < 100) {
14
+ step(); steps++;
15
+ if (now() - start >= budgetMs) break;
16
+ }
17
+ return { steps, physicsMs: now() - start, budgetExceeded: steps < requested };
18
+ }
@@ -2,16 +2,9 @@
2
2
  * @license
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  *
5
- * Stream a live MuJoCo camera into a DOM `<canvas>`. Each frame the scene is
6
- * rendered offscreen from the selected camera and blitted into the canvas, so
7
- * it composites normally in the DOM (works inside opaque panels) and does NOT
8
- * take over the render loop. Prefer this over `useCameraViewport` for camera
9
- * tiles embedded in HTML UI; use `useCameraViewport` for transparent overlays
10
- * on a full-bleed canvas.
11
- *
12
- * Uses the async capture path so Gaussian-splat environments render through
13
- * their dedicated capture renderer — streaming a splat scene at full rate does
14
- * not disturb the main view's splat sort.
5
+ * Readback stream for DOM compositing/encoding. For interactive camera previews,
6
+ * use CameraView/useCameraViewport: they render on the GPU with no readback.
7
+ * This path defaults to 10 FPS and permits only one capture in flight.
15
8
  */
16
9
 
17
10
  import { useEffect, useRef } from 'react';
@@ -23,12 +16,12 @@ import type { CameraFrameCaptureOptions } from '../types';
23
16
 
24
17
  export interface CameraStreamOptions extends CameraFrameCaptureOptions {
25
18
  /**
26
- * Optional cap on updates per second. Omit to stream as fast as captures
27
- * complete (one capture is in flight at a time regardless).
19
+ * Readback rate, default 10 FPS. Must be positive and finite.
28
20
  */
29
21
  fps?: number;
30
22
  /** Pause updates without unmounting. */
31
23
  paused?: boolean;
24
+ onError?: (error: Error) => void;
32
25
  }
33
26
 
34
27
  function streamSignature(options: CameraStreamOptions): string {
@@ -77,10 +70,10 @@ export function useCameraStream(
77
70
  const opts = optionsRef.current;
78
71
  if (opts.paused) return;
79
72
 
80
- if (opts.fps && opts.fps > 0) {
81
- elapsedRef.current += delta;
82
- if (elapsedRef.current < 1 / opts.fps) return;
83
- }
73
+ const fps = opts.fps ?? 10;
74
+ if (!Number.isFinite(fps) || fps <= 0) throw new Error('Camera stream fps must be positive and finite');
75
+ elapsedRef.current += delta;
76
+ if (elapsedRef.current < 1 / fps) return;
84
77
  // One capture in flight at a time — naturally rate-limits to capture speed.
85
78
  if (inFlightRef.current) return;
86
79
  elapsedRef.current = 0;
@@ -107,7 +100,11 @@ export function useCameraStream(
107
100
  }
108
101
  ctx.drawImage(frame.canvas, 0, 0);
109
102
  })
110
- .catch(() => {})
103
+ .catch(error => {
104
+ const failure = error instanceof Error ? error : new Error(String(error));
105
+ if (optionsRef.current.onError) optionsRef.current.onError(failure);
106
+ else console.error('Camera stream capture failed', failure);
107
+ })
111
108
  .finally(() => {
112
109
  inFlightRef.current = false;
113
110
  });
@@ -5,7 +5,7 @@
5
5
  * usePolicy — policy decimation loop hook (spec 10.1)
6
6
  */
7
7
 
8
- import { useCallback, useMemo, useRef } from 'react';
8
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
9
9
  import { useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
10
  import type { PolicyAPI, PolicyConfig, PolicyInferenceOutput, PolicyVector } from '../types';
11
11
 
@@ -39,8 +39,11 @@ function enqueuePolicyActions(
39
39
  queue: PendingPolicyAction[],
40
40
  actions: PolicyVector[],
41
41
  observation: PolicyVector,
42
- strategy: PolicyConfig['queueStrategy']
42
+ strategy: PolicyConfig['queueStrategy'],
43
+ limit = 256
43
44
  ) {
45
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('maxQueuedActions must be a positive safe integer');
46
+ if ((strategy === 'replace' ? 0 : queue.length) + actions.length > limit) throw new Error(`Policy action queue exceeds maxQueuedActions (${limit})`);
44
47
  if (strategy === 'replace') {
45
48
  queue.splice(0, queue.length);
46
49
  }
@@ -73,7 +76,6 @@ export function usePolicy(config: PolicyConfig): PolicyAPI {
73
76
  const clearQueue = useCallback(() => {
74
77
  epochRef.current += 1;
75
78
  actionQueueRef.current.splice(0, actionQueueRef.current.length);
76
- inFlightRef.current = false;
77
79
  lastErrorRef.current = null;
78
80
  }, []);
79
81
 
@@ -84,6 +86,11 @@ export function usePolicy(config: PolicyConfig): PolicyAPI {
84
86
  lastActionRef.current = null;
85
87
  }, [clearQueue]);
86
88
 
89
+ useEffect(() => {
90
+ isRunningRef.current = configRef.current.enabled ?? true;
91
+ return () => { epochRef.current++; isRunningRef.current = false; };
92
+ }, []);
93
+
87
94
  useBeforePhysicsStep(({ model, data }) => {
88
95
  if (!isRunningRef.current) return;
89
96
 
@@ -126,7 +133,8 @@ export function usePolicy(config: PolicyConfig): PolicyAPI {
126
133
  actionQueueRef.current,
127
134
  toPolicyActions(output),
128
135
  observation,
129
- cfg.queueStrategy ?? 'append'
136
+ cfg.queueStrategy ?? 'append',
137
+ cfg.maxQueuedActions
130
138
  );
131
139
  lastErrorRef.current = null;
132
140
  })
@@ -136,7 +144,6 @@ export function usePolicy(config: PolicyConfig): PolicyAPI {
136
144
  cfg.onError?.(error);
137
145
  })
138
146
  .finally(() => {
139
- if (epoch !== epochRef.current) return;
140
147
  inFlightRef.current = false;
141
148
  });
142
149
  } else {
@@ -146,7 +153,8 @@ export function usePolicy(config: PolicyConfig): PolicyAPI {
146
153
  actionQueueRef.current,
147
154
  actions,
148
155
  observation,
149
- cfg.queueStrategy ?? 'append'
156
+ cfg.queueStrategy ?? 'append',
157
+ cfg.maxQueuedActions
150
158
  );
151
159
  } else {
152
160
  const [action, ...queuedActions] = actions;
@@ -155,7 +163,8 @@ export function usePolicy(config: PolicyConfig): PolicyAPI {
155
163
  actionQueueRef.current,
156
164
  queuedActions,
157
165
  observation,
158
- cfg.queueStrategy ?? 'append'
166
+ cfg.queueStrategy ?? 'append',
167
+ cfg.maxQueuedActions
159
168
  );
160
169
  // Apply action. If `infer` is omitted, this preserves the legacy inline-controller path.
161
170
  cfg.onAction({ action, observation, model, data });
package/src/index.ts CHANGED
@@ -470,3 +470,6 @@ export {
470
470
  // Re-export MuJoCo types for convenience
471
471
  export type { MujocoModule, MujocoModel, MujocoData, MujocoContact, MujocoContactArray } from './types';
472
472
  export { getContact } from './types';
473
+
474
+ export type { SimulationRuntime, RuntimeOptions, RuntimeState, RuntimeSnapshot, RuntimeMetrics, AdvanceOptions, WorkerPort } from './runtime/types';
475
+ export type { PhysicsPerformance } from './core/stepBudget';
@@ -0,0 +1,13 @@
1
+ import loadMujoco from '@mujoco/mujoco';
2
+ import wasmUrl from '@mujoco/mujoco/mujoco.wasm?url';
3
+ import { createRuntimeHandler, type Request, type Response } from './runtime/protocol';
4
+ const scope = self as unknown as { onmessage: (event: MessageEvent<Request>) => void; postMessage: (response: Response, transfer: Transferable[]) => void };
5
+ const handle = createRuntimeHandler(
6
+ () => loadMujoco({ locateFile: (file: string) => file.endsWith('.wasm') ? wasmUrl : file }),
7
+ response => {
8
+ const transfer: Transferable[] = response.display ? Object.values(response.display.fields).map(value => value.buffer as ArrayBuffer) : [];
9
+ if (response.display?.integration) transfer.push(response.display.integration.values.buffer as ArrayBuffer);
10
+ scope.postMessage(response, transfer);
11
+ },
12
+ );
13
+ scope.onmessage = event => { void handle(event.data); };
@@ -354,9 +354,10 @@ function readRenderTargetToCanvas(
354
354
  width: number,
355
355
  height: number,
356
356
  outputColorSpace: string,
357
- flipX = false
357
+ flipX = false,
358
+ readback = true
358
359
  ) {
359
- renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
360
+ if (readback) renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
360
361
 
361
362
  const rowBytes = width * 4;
362
363
  const encodeSrgb = outputColorSpace === THREE.SRGBColorSpace;
@@ -646,6 +647,16 @@ export function createCameraFrameCaptureSession(
646
647
 
647
648
  const pixels = new Uint8Array(width * height * 4);
648
649
  const imageData = drawContext.createImageData(width, height);
650
+ let inFlight = false, disposed = false;
651
+ function assertAvailable() {
652
+ if (disposed) throw new Error('Camera capture session is disposed');
653
+ if (inFlight) throw new Error('Camera capture session is busy; await the current capture');
654
+ }
655
+ function releaseResources() {
656
+ target.dispose();
657
+ if (isolatedRenderer && !isolatedRenderer.cached) isolatedRenderer.renderer.dispose();
658
+ }
659
+
649
660
 
650
661
  function resolveCaptureOptions(nextOptions: CameraFrameCaptureOptions = {}) {
651
662
  const captureOptions = { ...options, ...nextOptions };
@@ -743,10 +754,12 @@ export function createCameraFrameCaptureSession(
743
754
  }
744
755
 
745
756
  function capture(nextOptions: CameraFrameCaptureOptions = {}) {
757
+ assertAvailable();
746
758
  return renderPreparedCapture(resolveCaptureOptions(nextOptions));
747
759
  }
748
760
 
749
761
  function capturePixels(nextOptions: CameraFrameCaptureOptions = {}): CameraFramePixelsResult {
762
+ assertAvailable();
750
763
  const captureOptions = resolveCaptureOptions(nextOptions);
751
764
  renderCaptureToTarget(captureOptions, () => {
752
765
  sessionRenderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
@@ -777,6 +790,13 @@ export function createCameraFrameCaptureSession(
777
790
  }
778
791
 
779
792
  async function captureAsync(nextOptions: CameraFrameCaptureOptions = {}) {
793
+ assertAvailable();
794
+ inFlight = true;
795
+ try { return await captureAsyncInternal(nextOptions); }
796
+ finally { inFlight = false; if (disposed) releaseResources(); }
797
+ }
798
+
799
+ async function captureAsyncInternal(nextOptions: CameraFrameCaptureOptions = {}) {
780
800
  const captureOptions = resolveCaptureOptions(nextOptions);
781
801
  runCapturePreRenderHooks(scene);
782
802
  scene.updateMatrixWorld(true);
@@ -841,7 +861,14 @@ export function createCameraFrameCaptureSession(
841
861
  restoreRendererState(sessionRenderer, previousState);
842
862
  }
843
863
  }
844
- return renderPreparedCapture(captureOptions);
864
+ let readback: Promise<unknown> | undefined;
865
+ renderCaptureToTarget(captureOptions, () => {
866
+ readback = sessionRenderer.readRenderTargetPixelsAsync(target, 0, 0, width, height, pixels);
867
+ });
868
+ await readback;
869
+ readRenderTargetToCanvas(sessionRenderer, target, canvas, drawContext, pixels, imageData,
870
+ width, height, sessionRenderer.outputColorSpace, captureOptions.flipX ?? false, false);
871
+ return { canvas, camera, width, height, source: getCameraFrameCaptureSource(captureOptions) };
845
872
  }
846
873
 
847
874
  return {
@@ -891,10 +918,9 @@ export function createCameraFrameCaptureSession(
891
918
  return { ...result, blob, type };
892
919
  },
893
920
  dispose() {
894
- target.dispose();
895
- if (isolatedRenderer && !isolatedRenderer.cached) {
896
- isolatedRenderer.renderer.dispose();
897
- }
921
+ if (disposed) return;
922
+ disposed = true;
923
+ if (!inFlight) releaseResources();
898
924
  },
899
925
  };
900
926
  }