mujoco-react 10.2.1 → 10.4.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.
@@ -9,7 +9,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
10
  import { GeomBuilder } from '../rendering/GeomBuilder';
11
11
  import { CAMERA_FRAME_CAPTURE_PRE_RENDER_USER_DATA_KEY } from '../rendering/cameraFrameCapture';
12
- import { MujocoModel } from '../types';
12
+ import { MujocoModel, MujocoRenderOptions } from '../types';
13
13
  import { getName } from '../core/SceneLoader';
14
14
  import { useMujocoContext } from '../core/MujocoSimProvider';
15
15
 
@@ -17,7 +17,18 @@ import { useMujocoContext } from '../core/MujocoSimProvider';
17
17
  * SceneRenderer — creates and syncs MuJoCo body meshes every frame.
18
18
  * Accepts standard R3F group props (position, rotation, scale, visible, etc.).
19
19
  */
20
- export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
20
+ export interface SceneRendererProps extends Omit<ThreeElements['group'], 'ref'> {
21
+ renderOptions?: MujocoRenderOptions;
22
+ }
23
+
24
+ function getRenderOptionsKey(renderOptions: MujocoRenderOptions | undefined) {
25
+ const smoothing = renderOptions?.meshNormalSmoothing;
26
+ if (!smoothing) return 'default';
27
+ if (smoothing === true) return 'meshNormalSmoothing:true';
28
+ return `meshNormalSmoothing:${smoothing.tolerance ?? 'default'}`;
29
+ }
30
+
31
+ export function SceneRenderer({ renderOptions, ...props }: SceneRendererProps) {
21
32
  const {
22
33
  mjModelRef,
23
34
  mjDataRef,
@@ -31,11 +42,13 @@ export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
31
42
  const groupRef = useRef<THREE.Group>(null);
32
43
  const bodyRefs = useRef<(THREE.Group | null)[]>([]);
33
44
  const prevModelRef = useRef<MujocoModel | null>(null);
45
+ const prevRenderOptionsKeyRef = useRef<string | null>(null);
46
+ const renderOptionsKey = getRenderOptionsKey(renderOptions);
34
47
 
35
48
  const geomBuilder = useMemo(() => {
36
49
  if (status !== 'ready') return null;
37
- return new GeomBuilder(mujocoRef.current);
38
- }, [status, mujocoRef]);
50
+ return new GeomBuilder(mujocoRef.current, renderOptions);
51
+ }, [status, mujocoRef, renderOptionsKey]);
39
52
 
40
53
  // Build body groups when model loads
41
54
  useEffect(() => {
@@ -45,8 +58,14 @@ export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
45
58
  if (!model || !group) return;
46
59
 
47
60
  // Skip if model hasn't changed
48
- if (prevModelRef.current === model) return;
61
+ if (
62
+ prevModelRef.current === model &&
63
+ prevRenderOptionsKeyRef.current === renderOptionsKey
64
+ ) {
65
+ return;
66
+ }
49
67
  prevModelRef.current = model;
68
+ prevRenderOptionsKeyRef.current = renderOptionsKey;
50
69
 
51
70
  // Clear previous bodies
52
71
  while (group.children.length > 0) {
@@ -73,7 +92,7 @@ export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
73
92
  refs.push(bodyGroup);
74
93
  }
75
94
  bodyRefs.current = refs;
76
- }, [status, geomBuilder, mjModelRef]);
95
+ }, [status, geomBuilder, mjModelRef, renderOptionsKey]);
77
96
 
78
97
  const syncBodiesToData = useCallback(() => {
79
98
  const data = mjDataRef.current;
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { Canvas } from '@react-three/fiber';
7
- import { forwardRef, useEffect } from 'react';
7
+ import { forwardRef, useEffect, useRef } from 'react';
8
8
  import { useMujocoWasm } from './MujocoProvider';
9
9
  import { MujocoSimProvider } from './MujocoSimProvider';
10
10
  import { MujocoCanvasProps, MujocoSimAPI } from '../types';
@@ -31,6 +31,7 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
31
31
  paused,
32
32
  speed,
33
33
  interpolate,
34
+ renderOptions,
34
35
  loadingFallback,
35
36
  children,
36
37
  ...canvasProps
@@ -38,12 +39,14 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
38
39
  ref
39
40
  ) {
40
41
  const { mujoco, status: wasmStatus, error: wasmError } = useMujocoWasm();
42
+ const onErrorRef = useRef(onError);
43
+ onErrorRef.current = onError;
41
44
 
42
45
  useEffect(() => {
43
- if (wasmStatus === 'error' && onError) {
44
- onError(new Error(wasmError ?? 'WASM load failed'));
46
+ if (wasmStatus === 'error') {
47
+ onErrorRef.current?.(new Error(wasmError ?? 'WASM load failed'));
45
48
  }
46
- }, [wasmStatus, wasmError, onError]);
49
+ }, [wasmStatus, wasmError]);
47
50
 
48
51
  if (wasmStatus === 'loading' || !mujoco) {
49
52
  return loadingFallback ? (
@@ -71,6 +74,7 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
71
74
  paused={paused}
72
75
  speed={speed}
73
76
  interpolate={interpolate}
77
+ renderOptions={renderOptions}
74
78
  >
75
79
  {children}
76
80
  </MujocoSimProvider>
@@ -3,7 +3,7 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { forwardRef, useEffect } from 'react';
6
+ import { forwardRef, useEffect, useRef } from 'react';
7
7
  import { useMujocoWasm } from './MujocoProvider';
8
8
  import { MujocoSimProvider } from './MujocoSimProvider';
9
9
  import type {
@@ -62,12 +62,14 @@ export interface MujocoPhysicsProps {
62
62
  export const MujocoPhysics = forwardRef<MujocoSimAPI, MujocoPhysicsProps>(
63
63
  function MujocoPhysics({ onError, children, ...props }, ref) {
64
64
  const { mujoco, status: wasmStatus, error: wasmError } = useMujocoWasm();
65
+ const onErrorRef = useRef(onError);
66
+ onErrorRef.current = onError;
65
67
 
66
68
  useEffect(() => {
67
- if (wasmStatus === 'error' && onError) {
68
- onError(new Error(wasmError ?? 'WASM load failed'));
69
+ if (wasmStatus === 'error') {
70
+ onErrorRef.current?.(new Error(wasmError ?? 'WASM load failed'));
69
71
  }
70
- }, [wasmStatus, wasmError, onError]);
72
+ }, [wasmStatus, wasmError]);
71
73
 
72
74
  if (wasmStatus === 'error' || wasmStatus === 'loading' || !mujoco) {
73
75
  return null;
@@ -96,6 +96,8 @@ export function MujocoProvider({
96
96
  const [error, setError] = useState<string | null>(null);
97
97
  const moduleRef = useRef<MujocoModule | null>(null);
98
98
  const isMounted = useRef(true);
99
+ const onErrorRef = useRef(onError);
100
+ onErrorRef.current = onError;
99
101
 
100
102
  useEffect(() => {
101
103
  isMounted.current = true;
@@ -105,7 +107,7 @@ export function MujocoProvider({
105
107
  const err = new Error('MujocoProvider wasmVariant="threaded" requires a threadedLoader from @mujoco/mujoco/mt');
106
108
  setError(err.message);
107
109
  setStatus('error');
108
- onError?.(err);
110
+ onErrorRef.current?.(err);
109
111
  return;
110
112
  }
111
113
  let selectedWasmUrl = wasmUrl ?? defaultMujocoWasmUrl;
@@ -115,7 +117,7 @@ export function MujocoProvider({
115
117
  const err = new Error('MujocoProvider wasmVariant="threaded" requires mtWasmUrl from @mujoco/mujoco/mt/mujoco.wasm?url');
116
118
  setError(err.message);
117
119
  setStatus('error');
118
- onError?.(err);
120
+ onErrorRef.current?.(err);
119
121
  return;
120
122
  }
121
123
  selectedWasmUrl = mtWasmUrl;
@@ -152,14 +154,14 @@ export function MujocoProvider({
152
154
  const msg = err.message || 'Failed to init spatial simulation';
153
155
  setError(msg);
154
156
  setStatus('error');
155
- onError?.(new Error(msg));
157
+ onErrorRef.current?.(new Error(msg));
156
158
  }
157
159
  });
158
160
 
159
161
  return () => {
160
162
  isMounted.current = false;
161
163
  };
162
- }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, timeout, onError]);
164
+ }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, timeout]);
163
165
 
164
166
  return (
165
167
  <MujocoContext.Provider
@@ -37,6 +37,7 @@ import {
37
37
  LoadFromFilesOptions,
38
38
  LocalMujocoFile,
39
39
  ModelOptions,
40
+ MujocoRenderOptions,
40
41
  MujocoSimAPI,
41
42
  PhysicsStepCallback,
42
43
  RayHit,
@@ -241,6 +242,143 @@ function applyMountedCameraPoseOffsets(
241
242
  };
242
243
  }
243
244
 
245
+ function resolveMujocoCameraCompatibilityOptions(
246
+ options: CameraFrameCaptureOptions
247
+ ) {
248
+ const compatibility = options.mujocoCameraCompatibility;
249
+ if (!compatibility) return null;
250
+ if (compatibility === true) {
251
+ return {
252
+ useResolution: true,
253
+ useIntrinsics: true,
254
+ useClipping: true,
255
+ preserveAspect: true,
256
+ preferResolution: false,
257
+ };
258
+ }
259
+ return {
260
+ useResolution: compatibility.useResolution ?? true,
261
+ useIntrinsics: compatibility.useIntrinsics ?? true,
262
+ useClipping: compatibility.useClipping ?? true,
263
+ preserveAspect: compatibility.preserveAspect ?? true,
264
+ preferResolution: compatibility.preferResolution ?? false,
265
+ };
266
+ }
267
+
268
+ function mujocoVisualClip(model: MujocoModel) {
269
+ const map = (model as unknown as {
270
+ vis?: { map?: { znear?: number; zfar?: number } };
271
+ }).vis?.map;
272
+ const near = typeof map?.znear === 'number' && map.znear > 0
273
+ ? map.znear
274
+ : undefined;
275
+ const far = typeof map?.zfar === 'number' && map.zfar > 0
276
+ ? map.zfar
277
+ : undefined;
278
+ return { near, far };
279
+ }
280
+
281
+ function mujocoCameraResolution(
282
+ model: MujocoModel,
283
+ cameraId: number
284
+ ): { width?: number; height?: number } {
285
+ const resolution = model.cam_resolution;
286
+ if (!resolution) return {};
287
+ const width = Number(resolution[cameraId * 2]);
288
+ const height = Number(resolution[cameraId * 2 + 1]);
289
+ return {
290
+ width: Number.isFinite(width) && width > 0 ? width : undefined,
291
+ height: Number.isFinite(height) && height > 0 ? height : undefined,
292
+ };
293
+ }
294
+
295
+ function mujocoCameraProjectionMatrix(
296
+ model: MujocoModel,
297
+ cameraId: number,
298
+ width: number | undefined,
299
+ height: number | undefined,
300
+ near: number | undefined,
301
+ far: number | undefined
302
+ ): THREE.Matrix4 | undefined {
303
+ const intrinsic = model.cam_intrinsic;
304
+ const sensorSize = model.cam_sensorsize;
305
+ if (!intrinsic || !sensorSize || !width || !height) return undefined;
306
+
307
+ const intrinsicOffset = cameraId * 4;
308
+ const sensorOffset = cameraId * 2;
309
+ const focalX = Number(intrinsic[intrinsicOffset]);
310
+ const focalY = Number(intrinsic[intrinsicOffset + 1]);
311
+ const principalX = Number(intrinsic[intrinsicOffset + 2]);
312
+ const principalY = Number(intrinsic[intrinsicOffset + 3]);
313
+ const sensorWidth = Number(sensorSize[sensorOffset]);
314
+ const sensorHeight = Number(sensorSize[sensorOffset + 1]);
315
+ if (
316
+ !Number.isFinite(focalX) ||
317
+ !Number.isFinite(focalY) ||
318
+ !Number.isFinite(principalX) ||
319
+ !Number.isFinite(principalY) ||
320
+ !Number.isFinite(sensorWidth) ||
321
+ !Number.isFinite(sensorHeight) ||
322
+ focalX <= 0 ||
323
+ focalY <= 0 ||
324
+ sensorWidth <= 0 ||
325
+ sensorHeight <= 0
326
+ ) {
327
+ return undefined;
328
+ }
329
+
330
+ const fx = focalX / sensorWidth * width;
331
+ const fy = focalY / sensorHeight * height;
332
+ const cx = width * (0.5 + principalX / sensorWidth);
333
+ const cy = height * (0.5 + principalY / sensorHeight);
334
+ const znear = near ?? 0.01;
335
+ const zfar = far ?? 100;
336
+
337
+ return new THREE.Matrix4().set(
338
+ 2 * fx / width, 0, 1 - 2 * cx / width, 0,
339
+ 0, 2 * fy / height, 2 * cy / height - 1, 0,
340
+ 0, 0, -(zfar + znear) / (zfar - znear), -2 * zfar * znear / (zfar - znear),
341
+ 0, 0, -1, 0
342
+ );
343
+ }
344
+
345
+ function resolveMujocoCameraCaptureDimensions(
346
+ requested: CameraFrameCaptureOptions,
347
+ cameraResolution: { width?: number; height?: number },
348
+ compatibility: NonNullable<ReturnType<typeof resolveMujocoCameraCompatibilityOptions>>
349
+ ) {
350
+ if (!compatibility.useResolution) {
351
+ return {
352
+ width: requested.width,
353
+ height: requested.height,
354
+ };
355
+ }
356
+
357
+ if (compatibility.preferResolution) {
358
+ return {
359
+ width: cameraResolution.width ?? requested.width,
360
+ height: cameraResolution.height ?? requested.height,
361
+ };
362
+ }
363
+
364
+ let width = requested.width ?? cameraResolution.width;
365
+ let height = requested.height ?? cameraResolution.height;
366
+
367
+ if (
368
+ compatibility.preserveAspect &&
369
+ cameraResolution.width &&
370
+ cameraResolution.height
371
+ ) {
372
+ if (requested.width !== undefined && requested.height === undefined) {
373
+ height = requested.width * cameraResolution.height / cameraResolution.width;
374
+ } else if (requested.height !== undefined && requested.width === undefined) {
375
+ width = requested.height * cameraResolution.width / cameraResolution.height;
376
+ }
377
+ }
378
+
379
+ return { width, height };
380
+ }
381
+
244
382
  function countMountedCameraSelectors(options: CameraFrameCaptureOptions) {
245
383
  return Number(Boolean(options.cameraName)) +
246
384
  Number(Boolean(options.siteName)) +
@@ -406,6 +544,7 @@ interface MujocoSimProviderProps {
406
544
  paused?: boolean;
407
545
  speed?: number;
408
546
  interpolate?: boolean;
547
+ renderOptions?: MujocoRenderOptions;
409
548
  children: React.ReactNode;
410
549
  }
411
550
 
@@ -423,6 +562,7 @@ export function MujocoSimProvider({
423
562
  paused,
424
563
  speed,
425
564
  interpolate,
565
+ renderOptions,
426
566
  children,
427
567
  }: MujocoSimProviderProps) {
428
568
  const { gl, camera, scene } = useThree();
@@ -462,14 +602,12 @@ export function MujocoSimProvider({
462
602
  const hiddenBodiesRef = useRef(new Set<string>());
463
603
  const bodyReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
464
604
 
465
- useEffect(() => { configRef.current = config; }, [config]);
466
- useEffect(() => { mujocoRef.current = mujoco; }, [mujoco]);
467
-
468
- // Sync declarative props to refs
469
- useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
470
- useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
471
- useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
472
- useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
605
+ configRef.current = config;
606
+ mujocoRef.current = mujoco;
607
+ pausedRef.current = paused ?? false;
608
+ speedRef.current = speed ?? 1;
609
+ substepsRef.current = substeps ?? 1;
610
+ interpolateRef.current = interpolate ?? false;
473
611
 
474
612
  // Sync gravity prop
475
613
  useEffect(() => {
@@ -1037,11 +1175,33 @@ export function MujocoSimProvider({
1037
1175
  for (let i = 0; i < ncam; i += 1) {
1038
1176
  const posOffset = i * 3;
1039
1177
  const quatOffset = i * 4;
1178
+ const intrinsicOffset = i * 4;
1179
+ const resolutionOffset = i * 2;
1040
1180
  result.push({
1041
1181
  id: i,
1042
1182
  name: getName(model, nameAddresses[i]),
1043
1183
  bodyId: model.cam_bodyid?.[i] ?? -1,
1044
1184
  fov: model.cam_fovy?.[i] ?? null,
1185
+ resolution: model.cam_resolution
1186
+ ? [
1187
+ model.cam_resolution[resolutionOffset],
1188
+ model.cam_resolution[resolutionOffset + 1],
1189
+ ]
1190
+ : null,
1191
+ sensorSize: model.cam_sensorsize
1192
+ ? [
1193
+ model.cam_sensorsize[resolutionOffset],
1194
+ model.cam_sensorsize[resolutionOffset + 1],
1195
+ ]
1196
+ : null,
1197
+ intrinsic: model.cam_intrinsic
1198
+ ? [
1199
+ model.cam_intrinsic[intrinsicOffset],
1200
+ model.cam_intrinsic[intrinsicOffset + 1],
1201
+ model.cam_intrinsic[intrinsicOffset + 2],
1202
+ model.cam_intrinsic[intrinsicOffset + 3],
1203
+ ]
1204
+ : null,
1045
1205
  position: model.cam_pos
1046
1206
  ? vector3FromArray(model.cam_pos, posOffset)
1047
1207
  : null,
@@ -1087,11 +1247,31 @@ export function MujocoSimProvider({
1087
1247
  }
1088
1248
 
1089
1249
  const pose = applyMountedCameraPoseOffsets(options, position, quaternion);
1250
+ const compatibility = resolveMujocoCameraCompatibilityOptions(options);
1251
+ const cameraResolution = compatibility?.useResolution
1252
+ ? mujocoCameraResolution(model, cameraId)
1253
+ : { width: undefined, height: undefined };
1254
+ const clip = compatibility?.useClipping
1255
+ ? mujocoVisualClip(model)
1256
+ : { near: undefined, far: undefined };
1257
+ const { width, height } = compatibility
1258
+ ? resolveMujocoCameraCaptureDimensions(options, cameraResolution, compatibility)
1259
+ : { width: options.width, height: options.height };
1260
+ const near = options.near ?? clip.near;
1261
+ const far = options.far ?? clip.far;
1262
+ const projectionMatrix = compatibility?.useIntrinsics
1263
+ ? mujocoCameraProjectionMatrix(model, cameraId, width, height, near, far)
1264
+ : undefined;
1090
1265
 
1091
1266
  return {
1092
1267
  ...baseOptions,
1268
+ width,
1269
+ height,
1093
1270
  ...pose,
1094
1271
  fov: options.fov ?? model.cam_fovy?.[cameraId],
1272
+ near,
1273
+ far,
1274
+ projectionMatrix: options.projectionMatrix ?? projectionMatrix,
1095
1275
  source: { kind: 'mujoco-camera', cameraName: options.cameraName },
1096
1276
  };
1097
1277
  }
@@ -1759,7 +1939,7 @@ export function MujocoSimProvider({
1759
1939
 
1760
1940
  return (
1761
1941
  <MujocoSimContext.Provider value={contextValue}>
1762
- <SceneRenderer />
1942
+ <SceneRenderer renderOptions={renderOptions} />
1763
1943
  {children}
1764
1944
  </MujocoSimContext.Provider>
1765
1945
  );
package/src/index.ts CHANGED
@@ -320,6 +320,7 @@ export type {
320
320
  IkGizmoProps,
321
321
  IkGizmoDragInput,
322
322
  DragInteractionProps,
323
+ DebugVirtualCamera,
323
324
  DebugProps,
324
325
  SceneLightsProps,
325
326
  ScenarioLightingPreset,
@@ -5,9 +5,12 @@
5
5
 
6
6
 
7
7
  import * as THREE from 'three';
8
+ import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
8
9
  import { CapsuleGeometry } from './CapsuleGeometry';
9
10
  import { getName } from '../core/SceneLoader';
10
- import { MujocoModel, MujocoModule } from '../types';
11
+ import { MujocoModel, MujocoModule, MujocoRenderOptions } from '../types';
12
+
13
+ const DEFAULT_MESH_NORMAL_SMOOTHING_TOLERANCE = 1e-4;
11
14
 
12
15
  /**
13
16
  * GeomBuilder
@@ -20,9 +23,18 @@ import { MujocoModel, MujocoModule } from '../types';
20
23
  export class GeomBuilder {
21
24
  private mujoco: MujocoModule;
22
25
  private textureCache = new Map<number, THREE.Texture>();
26
+ private renderOptions?: MujocoRenderOptions;
23
27
 
24
- constructor(mujoco: MujocoModule) {
28
+ constructor(mujoco: MujocoModule, renderOptions?: MujocoRenderOptions) {
25
29
  this.mujoco = mujoco;
30
+ this.renderOptions = renderOptions;
31
+ }
32
+
33
+ private getMeshNormalSmoothingTolerance(): number | null {
34
+ const smoothing = this.renderOptions?.meshNormalSmoothing;
35
+ if (!smoothing) return null;
36
+ if (smoothing === true) return DEFAULT_MESH_NORMAL_SMOOTHING_TOLERANCE;
37
+ return smoothing.tolerance ?? DEFAULT_MESH_NORMAL_SMOOTHING_TOLERANCE;
26
38
  }
27
39
 
28
40
  private getMaterialTexture(mjModel: MujocoModel, matId: number): THREE.Texture | null {
@@ -148,6 +160,10 @@ export class GeomBuilder {
148
160
  geo.setAttribute('position', new THREE.Float32BufferAttribute(mjModel.mesh_vert.subarray(vAdr * 3, (vAdr + vNum) * 3), 3));
149
161
  // 'index' = faces (triangles connecting vertices)
150
162
  geo.setIndex(Array.from(mjModel.mesh_face.subarray(fAdr * 3, (fAdr + fNum) * 3)));
163
+ const smoothingTolerance = this.getMeshNormalSmoothingTolerance();
164
+ if (smoothingTolerance !== null) {
165
+ geo = mergeVertices(geo, smoothingTolerance);
166
+ }
151
167
  geo.computeVertexNormals(); // Auto-calculate smooth lighting normals
152
168
  }
153
169