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
|
@@ -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,
|
|
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
|
-
|
|
770
|
-
const
|
|
771
|
-
if (!
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
});
|
package/src/hooks/usePolicy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
}
|
|
921
|
+
if (disposed) return;
|
|
922
|
+
disposed = true;
|
|
923
|
+
if (!inFlight) releaseResources();
|
|
898
924
|
},
|
|
899
925
|
};
|
|
900
926
|
}
|