mujoco-react 9.4.0 → 9.5.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.
@@ -11,6 +11,7 @@ import type { ThreeElements } from '@react-three/fiber';
11
11
  import * as THREE from 'three';
12
12
  import { useMujocoContext } from '../core/MujocoSimProvider';
13
13
  import { getName } from '../core/SceneLoader';
14
+ import { CAPTURE_EXCLUDE_KEY } from '../rendering/cameraFrameCapture';
14
15
  import { getContact, withContacts } from '../types';
15
16
  import type { DebugProps } from '../types';
16
17
 
@@ -25,9 +26,20 @@ const JOINT_COLORS: Record<number, number> = {
25
26
  const _v3a = new THREE.Vector3();
26
27
  const _v3b = new THREE.Vector3();
27
28
  const _quat = new THREE.Quaternion();
29
+ const _cameraMatrix = new THREE.Matrix4();
28
30
  const _contactPos = new THREE.Vector3();
29
31
  const _contactNormal = new THREE.Vector3();
30
32
  const MAX_CONTACT_ARROWS = 50;
33
+ const CAMERA_DEBUG_LENGTH = 0.12;
34
+ const CAMERA_DEBUG_FRUSTUM_DEPTH = 0.08;
35
+
36
+ type CameraDebugObject = THREE.Group & {
37
+ userData: {
38
+ cameraId: number;
39
+ frustum: THREE.LineSegments;
40
+ label?: THREE.Sprite;
41
+ };
42
+ };
31
43
 
32
44
  /**
33
45
  * Declarative debug visualization component.
@@ -37,6 +49,7 @@ export function Debug({
37
49
  showGeoms = false,
38
50
  showSites = false,
39
51
  showJoints = false,
52
+ showCameras = false,
40
53
  showContacts = false,
41
54
  showCOM = false,
42
55
  showInertia = false,
@@ -55,6 +68,7 @@ export function Debug({
55
68
  const geoms: THREE.Object3D[] = [];
56
69
  const sites: THREE.Object3D[] = [];
57
70
  const joints: THREE.Object3D[] = [];
71
+ const cameras: CameraDebugObject[] = [];
58
72
  const comMarkers: THREE.Object3D[] = [];
59
73
 
60
74
  // Wireframe geoms
@@ -179,6 +193,83 @@ export function Debug({
179
193
  }
180
194
  }
181
195
 
196
+ if (showCameras && model.ncam && model.name_camadr) {
197
+ for (let i = 0; i < model.ncam; i++) {
198
+ const group = new THREE.Group() as CameraDebugObject;
199
+ group.userData.cameraId = i;
200
+ group.renderOrder = 999;
201
+ group.frustumCulled = false;
202
+
203
+ const marker = new THREE.Mesh(
204
+ new THREE.BoxGeometry(0.014, 0.009, 0.006),
205
+ new THREE.MeshBasicMaterial({ color: 0x38bdf8, depthTest: false })
206
+ );
207
+ marker.renderOrder = 999;
208
+ marker.frustumCulled = false;
209
+ group.add(marker);
210
+
211
+ const forward = new THREE.ArrowHelper(
212
+ new THREE.Vector3(0, 0, -1),
213
+ new THREE.Vector3(),
214
+ CAMERA_DEBUG_LENGTH,
215
+ 0x38bdf8,
216
+ CAMERA_DEBUG_LENGTH * 0.24,
217
+ CAMERA_DEBUG_LENGTH * 0.11
218
+ );
219
+ forward.renderOrder = 999;
220
+ forward.frustumCulled = false;
221
+ forward.line.material = new THREE.LineBasicMaterial({
222
+ color: 0x38bdf8,
223
+ depthTest: false,
224
+ });
225
+ (forward.cone.material as THREE.MeshBasicMaterial).depthTest = false;
226
+ group.add(forward);
227
+
228
+ const frustumGeometry = new THREE.BufferGeometry();
229
+ frustumGeometry.setAttribute(
230
+ 'position',
231
+ new THREE.Float32BufferAttribute(new Float32Array(8 * 2 * 3), 3)
232
+ );
233
+ const frustum = new THREE.LineSegments(
234
+ frustumGeometry,
235
+ new THREE.LineBasicMaterial({
236
+ color: 0x38bdf8,
237
+ transparent: true,
238
+ opacity: 0.8,
239
+ depthTest: false,
240
+ })
241
+ );
242
+ frustum.renderOrder = 999;
243
+ frustum.frustumCulled = false;
244
+ group.userData.frustum = frustum;
245
+ group.add(frustum);
246
+
247
+ const canvas = document.createElement('canvas');
248
+ canvas.width = 256;
249
+ canvas.height = 64;
250
+ const ctx = canvas.getContext('2d')!;
251
+ ctx.fillStyle = '#38bdf8';
252
+ ctx.font = 'bold 32px monospace';
253
+ ctx.textAlign = 'center';
254
+ ctx.fillText(getName(model, model.name_camadr[i]), 128, 42);
255
+ const texture = new THREE.CanvasTexture(canvas);
256
+ const sprite = new THREE.Sprite(
257
+ new THREE.SpriteMaterial({
258
+ map: texture,
259
+ depthTest: false,
260
+ transparent: true,
261
+ })
262
+ );
263
+ sprite.position.set(0, 0.014, 0.01);
264
+ sprite.scale.set(0.04, 0.01, 1);
265
+ sprite.renderOrder = 999;
266
+ group.userData.label = sprite;
267
+ group.add(sprite);
268
+
269
+ cameras.push(group);
270
+ }
271
+ }
272
+
182
273
  // COM markers
183
274
  if (showCOM) {
184
275
  for (let i = 1; i < model.nbody; i++) {
@@ -190,8 +281,8 @@ export function Debug({
190
281
  }
191
282
  }
192
283
 
193
- return { geoms, sites, joints, comMarkers };
194
- }, [status, mjModelRef, showGeoms, showSites, showJoints, showCOM]);
284
+ return { geoms, sites, joints, cameras, comMarkers };
285
+ }, [status, mjModelRef, showGeoms, showSites, showJoints, showCameras, showCOM]);
195
286
 
196
287
  // Add/remove debug objects from scene
197
288
  useEffect(() => {
@@ -202,6 +293,7 @@ export function Debug({
202
293
  ...debugGeometry.geoms,
203
294
  ...debugGeometry.sites,
204
295
  ...debugGeometry.joints,
296
+ ...debugGeometry.cameras,
205
297
  ...debugGeometry.comMarkers,
206
298
  ];
207
299
  for (const obj of allObjects) group.add(obj);
@@ -281,6 +373,59 @@ export function Debug({
281
373
  }
282
374
  }
283
375
 
376
+ const camXpos = data.cam_xpos;
377
+ const camXmat = data.cam_xmat;
378
+ if (camXpos && camXmat) {
379
+ for (const group of debugGeometry.cameras) {
380
+ const cameraId = group.userData.cameraId;
381
+ const i3 = cameraId * 3;
382
+ const i9 = cameraId * 9;
383
+ group.position.set(
384
+ camXpos[i3],
385
+ camXpos[i3 + 1],
386
+ camXpos[i3 + 2]
387
+ );
388
+ _cameraMatrix.set(
389
+ camXmat[i9], camXmat[i9 + 1], camXmat[i9 + 2], 0,
390
+ camXmat[i9 + 3], camXmat[i9 + 4], camXmat[i9 + 5], 0,
391
+ camXmat[i9 + 6], camXmat[i9 + 7], camXmat[i9 + 8], 0,
392
+ 0, 0, 0, 1
393
+ );
394
+ group.quaternion.setFromRotationMatrix(_cameraMatrix);
395
+
396
+ const fovy = model.cam_fovy?.[cameraId] ?? 45;
397
+ const halfHeight = Math.tan(THREE.MathUtils.degToRad(fovy) / 2) *
398
+ CAMERA_DEBUG_FRUSTUM_DEPTH;
399
+ const halfWidth = halfHeight * 4 / 3;
400
+ const positions = group.userData.frustum.geometry.attributes.position;
401
+ const array = positions.array as Float32Array;
402
+ const points = [
403
+ [0, 0, 0],
404
+ [-halfWidth, halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
405
+ [0, 0, 0],
406
+ [halfWidth, halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
407
+ [0, 0, 0],
408
+ [halfWidth, -halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
409
+ [0, 0, 0],
410
+ [-halfWidth, -halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
411
+ [-halfWidth, halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
412
+ [halfWidth, halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
413
+ [halfWidth, halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
414
+ [halfWidth, -halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
415
+ [halfWidth, -halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
416
+ [-halfWidth, -halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
417
+ [-halfWidth, -halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
418
+ [-halfWidth, halfHeight, -CAMERA_DEBUG_FRUSTUM_DEPTH],
419
+ ];
420
+ for (let i = 0; i < points.length; i += 1) {
421
+ array[i * 3] = points[i][0];
422
+ array[i * 3 + 1] = points[i][1];
423
+ array[i * 3 + 2] = points[i][2];
424
+ }
425
+ positions.needsUpdate = true;
426
+ }
427
+ }
428
+
284
429
  // Update COM markers
285
430
  for (const mesh of debugGeometry.comMarkers) {
286
431
  const bid = mesh.userData.bodyId;
@@ -358,7 +503,13 @@ export function Debug({
358
503
  if (status !== 'ready') return null;
359
504
 
360
505
  return (
361
- <group {...groupProps}>
506
+ <group
507
+ {...groupProps}
508
+ userData={{
509
+ ...groupProps.userData,
510
+ [CAPTURE_EXCLUDE_KEY]: true,
511
+ }}
512
+ >
362
513
  <group ref={groupRef} />
363
514
  {showContacts && <group ref={contactGroupRef} />}
364
515
  </group>
@@ -8,6 +8,7 @@ import type { ThreeElements } from '@react-three/fiber';
8
8
  import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
10
  import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
11
+ import { CAPTURE_EXCLUDE_KEY } from '../rendering/cameraFrameCapture';
11
12
  import type { DragInteractionProps } from '../types';
12
13
 
13
14
  // Preallocated temps to avoid GC pressure
@@ -60,6 +61,7 @@ export function DragInteraction({
60
61
  0.1,
61
62
  0xff4444,
62
63
  );
64
+ arrow.userData[CAPTURE_EXCLUDE_KEY] = true;
63
65
  arrow.visible = false;
64
66
  // Make arrow semi-transparent
65
67
  (arrow.line.material as THREE.LineBasicMaterial).transparent = true;
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
10
  import { useMujocoContext } from '../core/MujocoSimProvider';
11
11
  import { findSiteByName } from '../core/SceneLoader';
12
+ import { CAPTURE_EXCLUDE_KEY } from '../rendering/cameraFrameCapture';
12
13
  import type { IkGizmoProps } from '../types';
13
14
 
14
15
  // Preallocated temps to avoid GC pressure in useFrame
@@ -83,7 +84,10 @@ export function IkGizmo({ controller, siteName, scale = 0.18, onDrag }: IkGizmoP
83
84
  if (status !== 'ready') return null;
84
85
 
85
86
  return (
86
- <group ref={wrapperRef}>
87
+ <group
88
+ ref={wrapperRef}
89
+ userData={{ [CAPTURE_EXCLUDE_KEY]: true }}
90
+ >
87
91
  <PivotControls
88
92
  ref={pivotRef}
89
93
  autoTransform
@@ -148,12 +148,12 @@ function vector3FromArray(values: ArrayLike<number>, offset: number): [number, n
148
148
  return [values[offset], values[offset + 1], values[offset + 2]];
149
149
  }
150
150
 
151
- function quaternionFromArray(values: ArrayLike<number>, offset: number): [number, number, number, number] {
151
+ function quaternionFromMujocoQuat(values: ArrayLike<number>, offset: number): [number, number, number, number] {
152
152
  return [
153
- values[offset],
154
153
  values[offset + 1],
155
154
  values[offset + 2],
156
155
  values[offset + 3],
156
+ values[offset],
157
157
  ];
158
158
  }
159
159
 
@@ -993,7 +993,7 @@ export function MujocoSimProvider({
993
993
  ? vector3FromArray(model.cam_pos, posOffset)
994
994
  : null,
995
995
  quaternion: model.cam_quat
996
- ? quaternionFromArray(model.cam_quat, quatOffset)
996
+ ? quaternionFromMujocoQuat(model.cam_quat, quatOffset)
997
997
  : null,
998
998
  });
999
999
  }
@@ -1024,7 +1024,7 @@ export function MujocoSimProvider({
1024
1024
  const quaternion = data.cam_xmat
1025
1025
  ? quaternionFromXmat(data.cam_xmat, cameraId * 9)
1026
1026
  : model.cam_quat
1027
- ? quaternionFromArray(model.cam_quat, cameraId * 4)
1027
+ ? quaternionFromMujocoQuat(model.cam_quat, cameraId * 4)
1028
1028
  : undefined;
1029
1029
 
1030
1030
  if (!position || !quaternion) {
@@ -1442,7 +1442,7 @@ export function MujocoSimProvider({
1442
1442
  const cameraFrames: Record<string, CameraFrameCaptureResult> = {};
1443
1443
  for (const { key, captureOptions, mountedSource, session } of captureSessions) {
1444
1444
  const resolvedCaptureOptions = resolveCameraCaptureOptions(captureOptions);
1445
- const cameraFrame = session.captureDataUrl({
1445
+ const cameraFrame = await session.captureDataUrlAsync({
1446
1446
  ...resolvedCaptureOptions,
1447
1447
  source: mountedSource ?? resolvedCaptureOptions.source,
1448
1448
  });
package/src/index.ts CHANGED
@@ -113,6 +113,7 @@ export type {
113
113
  MountedCameraSequenceRecordResult,
114
114
  } from './hooks/useMountedCameraSequenceRecorder';
115
115
  export {
116
+ CAPTURE_EXCLUDE_KEY,
116
117
  captureCameraFrame,
117
118
  captureCameraFrameBlob,
118
119
  createCameraFrameCaptureSession,
@@ -24,11 +24,66 @@ export interface CameraFrameCaptureSession {
24
24
  height: number;
25
25
  source: CameraFrameCaptureSource;
26
26
  };
27
+ captureAsync(options?: CameraFrameCaptureOptions): Promise<{
28
+ canvas: HTMLCanvasElement;
29
+ camera: THREE.Camera;
30
+ width: number;
31
+ height: number;
32
+ source: CameraFrameCaptureSource;
33
+ }>;
27
34
  captureDataUrl(options?: CameraFrameCaptureOptions): CameraFrameCaptureResult;
35
+ captureDataUrlAsync(
36
+ options?: CameraFrameCaptureOptions
37
+ ): Promise<CameraFrameCaptureResult>;
28
38
  captureBlob(options?: CameraFrameCaptureOptions): Promise<CameraFrameCaptureBlobResult>;
29
39
  dispose(): void;
30
40
  }
31
41
 
42
+ export const CAMERA_FRAME_CAPTURE_RENDER_USER_DATA_KEY =
43
+ 'mujocoReactCameraFrameCaptureRender';
44
+ export const CAPTURE_EXCLUDE_KEY =
45
+ 'mujoco.capture.exclude';
46
+
47
+ export type CameraFrameCaptureRenderInput = {
48
+ renderer: THREE.WebGLRenderer;
49
+ scene: THREE.Scene;
50
+ camera: THREE.Camera;
51
+ target: THREE.WebGLRenderTarget;
52
+ width: number;
53
+ height: number;
54
+ };
55
+
56
+ export type CameraFrameCaptureRenderResult = {
57
+ pixels: Uint8Array;
58
+ width?: number;
59
+ height?: number;
60
+ flipY?: boolean;
61
+ };
62
+
63
+ type CameraFrameCaptureRender = (
64
+ input: CameraFrameCaptureRenderInput
65
+ ) =>
66
+ | CameraFrameCaptureRenderResult
67
+ | null
68
+ | undefined
69
+ | Promise<CameraFrameCaptureRenderResult | null | undefined>;
70
+
71
+ type RendererState = {
72
+ target: THREE.WebGLRenderTarget | null;
73
+ xrEnabled: boolean;
74
+ viewport: THREE.Vector4;
75
+ scissor: THREE.Vector4;
76
+ scissorTest: boolean;
77
+ clearColor: THREE.Color;
78
+ clearAlpha: number;
79
+ autoClear: boolean;
80
+ };
81
+
82
+ type VisibilityState = {
83
+ object: THREE.Object3D;
84
+ visible: boolean;
85
+ };
86
+
32
87
  function toVector3(
33
88
  value: CameraFrameCaptureVector3 | undefined,
34
89
  fallback: THREE.Vector3
@@ -136,21 +191,79 @@ function readRenderTargetToCanvas(
136
191
  pixels: Uint8Array,
137
192
  imageData: ImageData,
138
193
  width: number,
139
- height: number
194
+ height: number,
195
+ outputColorSpace: string
140
196
  ) {
141
197
  renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
142
198
 
143
199
  const rowBytes = width * 4;
200
+ const encodeSrgb = outputColorSpace === THREE.SRGBColorSpace;
144
201
  for (let y = 0; y < height; y += 1) {
145
202
  const sourceStart = (height - y - 1) * rowBytes;
146
203
  const targetStart = y * rowBytes;
204
+ const row = pixels.subarray(sourceStart, sourceStart + rowBytes);
205
+ if (!encodeSrgb) {
206
+ imageData.data.set(row, targetStart);
207
+ continue;
208
+ }
209
+
210
+ for (let x = 0; x < rowBytes; x += 4) {
211
+ const pixelOffset = targetStart + x;
212
+ imageData.data[pixelOffset] = linearByteToSrgbByte(row[x]);
213
+ imageData.data[pixelOffset + 1] = linearByteToSrgbByte(row[x + 1]);
214
+ imageData.data[pixelOffset + 2] = linearByteToSrgbByte(row[x + 2]);
215
+ imageData.data[pixelOffset + 3] = row[x + 3];
216
+ }
217
+ }
218
+ context.putImageData(imageData, 0, 0);
219
+ return canvas;
220
+ }
221
+
222
+ function linearByteToSrgbByte(value: number) {
223
+ const normalized = value / 255;
224
+ const encoded =
225
+ normalized <= 0.0031308
226
+ ? normalized * 12.92
227
+ : 1.055 * Math.pow(normalized, 1 / 2.4) - 0.055;
228
+ return Math.min(255, Math.max(0, Math.round(encoded * 255)));
229
+ }
230
+
231
+ function readPixelsToCanvas(
232
+ pixels: Uint8Array,
233
+ context: CanvasRenderingContext2D,
234
+ imageData: ImageData,
235
+ width: number,
236
+ height: number,
237
+ flipY = true
238
+ ) {
239
+ const rowBytes = width * 4;
240
+ for (let y = 0; y < height; y += 1) {
241
+ const sourceY = flipY ? height - y - 1 : y;
242
+ const sourceStart = sourceY * rowBytes;
243
+ const targetStart = y * rowBytes;
147
244
  imageData.data.set(
148
245
  pixels.subarray(sourceStart, sourceStart + rowBytes),
149
246
  targetStart
150
247
  );
151
248
  }
152
249
  context.putImageData(imageData, 0, 0);
153
- return canvas;
250
+ }
251
+
252
+ function hideExcludedCaptureObjects(scene: THREE.Scene): VisibilityState[] {
253
+ const hidden: VisibilityState[] = [];
254
+ scene.traverse((object) => {
255
+ if (!object.visible) return;
256
+ if (!object.userData[CAPTURE_EXCLUDE_KEY]) return;
257
+ hidden.push({ object, visible: object.visible });
258
+ object.visible = false;
259
+ });
260
+ return hidden;
261
+ }
262
+
263
+ function restoreObjectVisibility(hidden: VisibilityState[]) {
264
+ for (const { object, visible } of hidden) {
265
+ object.visible = visible;
266
+ }
154
267
  }
155
268
 
156
269
  function getCameraFrameCaptureSource(
@@ -173,6 +286,52 @@ function getCameraFrameCaptureSource(
173
286
  return { kind: 'fallback-camera' };
174
287
  }
175
288
 
289
+ function saveRendererState(renderer: THREE.WebGLRenderer): RendererState {
290
+ const viewport = new THREE.Vector4();
291
+ const scissor = new THREE.Vector4();
292
+ const clearColor = new THREE.Color();
293
+ renderer.getViewport(viewport);
294
+ renderer.getScissor(scissor);
295
+ renderer.getClearColor(clearColor);
296
+ return {
297
+ target: renderer.getRenderTarget(),
298
+ xrEnabled: renderer.xr.enabled,
299
+ viewport,
300
+ scissor,
301
+ scissorTest: renderer.getScissorTest(),
302
+ clearColor,
303
+ clearAlpha: renderer.getClearAlpha(),
304
+ autoClear: renderer.autoClear,
305
+ };
306
+ }
307
+
308
+ function restoreRendererState(
309
+ renderer: THREE.WebGLRenderer,
310
+ state: RendererState
311
+ ) {
312
+ renderer.setRenderTarget(state.target);
313
+ renderer.xr.enabled = state.xrEnabled;
314
+ renderer.setViewport(state.viewport);
315
+ renderer.setScissor(state.scissor);
316
+ renderer.setScissorTest(state.scissorTest);
317
+ renderer.setClearColor(state.clearColor, state.clearAlpha);
318
+ renderer.autoClear = state.autoClear;
319
+ }
320
+
321
+ function getCaptureRenderer(
322
+ scene: THREE.Scene
323
+ ): CameraFrameCaptureRender | null {
324
+ const renderers: CameraFrameCaptureRender[] = [];
325
+ scene.traverse((object) => {
326
+ if (renderers.length) return;
327
+ const render = object.userData[
328
+ CAMERA_FRAME_CAPTURE_RENDER_USER_DATA_KEY
329
+ ] as CameraFrameCaptureRender | undefined;
330
+ if (typeof render === 'function') renderers.push(render);
331
+ });
332
+ return renderers[0] ?? null;
333
+ }
334
+
176
335
  export function createCameraFrameCaptureSession(
177
336
  renderer: THREE.WebGLRenderer,
178
337
  scene: THREE.Scene,
@@ -198,7 +357,7 @@ export function createCameraFrameCaptureSession(
198
357
  const pixels = new Uint8Array(width * height * 4);
199
358
  const imageData = drawContext.createImageData(width, height);
200
359
 
201
- function capture(nextOptions: CameraFrameCaptureOptions = {}) {
360
+ function resolveCaptureOptions(nextOptions: CameraFrameCaptureOptions = {}) {
202
361
  const captureOptions = { ...options, ...nextOptions };
203
362
  const nextDimensions = getCaptureDimensions(renderer, captureOptions);
204
363
  if (
@@ -218,13 +377,20 @@ export function createCameraFrameCaptureSession(
218
377
  height
219
378
  );
220
379
 
221
- const previousTarget = renderer.getRenderTarget();
222
- const previousXrEnabled = renderer.xr.enabled;
380
+ return captureOptions;
381
+ }
382
+
383
+ function renderPreparedCapture(captureOptions: CameraFrameCaptureOptions) {
384
+ const previousState = saveRendererState(renderer);
385
+ const hidden = hideExcludedCaptureObjects(scene);
223
386
 
224
387
  scene.updateMatrixWorld(true);
225
388
  try {
226
389
  renderer.xr.enabled = false;
227
390
  renderer.setRenderTarget(target);
391
+ renderer.setViewport(0, 0, width, height);
392
+ renderer.setScissor(0, 0, width, height);
393
+ renderer.setScissorTest(false);
228
394
  renderer.clear();
229
395
  renderer.render(scene, camera);
230
396
  readRenderTargetToCanvas(
@@ -235,7 +401,8 @@ export function createCameraFrameCaptureSession(
235
401
  pixels,
236
402
  imageData,
237
403
  width,
238
- height
404
+ height,
405
+ renderer.outputColorSpace
239
406
  );
240
407
  return {
241
408
  canvas,
@@ -245,15 +412,69 @@ export function createCameraFrameCaptureSession(
245
412
  source: getCameraFrameCaptureSource(captureOptions),
246
413
  };
247
414
  } finally {
248
- renderer.setRenderTarget(previousTarget);
249
- renderer.xr.enabled = previousXrEnabled;
415
+ restoreObjectVisibility(hidden);
416
+ restoreRendererState(renderer, previousState);
417
+ }
418
+ }
419
+
420
+ function capture(nextOptions: CameraFrameCaptureOptions = {}) {
421
+ return renderPreparedCapture(resolveCaptureOptions(nextOptions));
422
+ }
423
+
424
+ async function captureAsync(nextOptions: CameraFrameCaptureOptions = {}) {
425
+ const captureOptions = resolveCaptureOptions(nextOptions);
426
+ scene.updateMatrixWorld(true);
427
+ const captureRenderer = getCaptureRenderer(scene);
428
+ if (captureRenderer) {
429
+ const previousState = saveRendererState(renderer);
430
+ const hidden = hideExcludedCaptureObjects(scene);
431
+ try {
432
+ renderer.xr.enabled = false;
433
+ const captureResult = await captureRenderer({
434
+ renderer,
435
+ scene,
436
+ camera,
437
+ target,
438
+ width,
439
+ height,
440
+ });
441
+ if (captureResult) {
442
+ const captureWidth = captureResult.width ?? width;
443
+ const captureHeight = captureResult.height ?? height;
444
+ if (captureWidth !== width || captureHeight !== height) {
445
+ throw new Error(
446
+ 'Camera frame capture renderer returned unexpected dimensions.'
447
+ );
448
+ }
449
+ readPixelsToCanvas(
450
+ captureResult.pixels,
451
+ drawContext,
452
+ imageData,
453
+ width,
454
+ height,
455
+ captureResult.flipY ?? true
456
+ );
457
+ return {
458
+ canvas,
459
+ camera,
460
+ width,
461
+ height,
462
+ source: getCameraFrameCaptureSource(captureOptions),
463
+ };
464
+ }
465
+ } finally {
466
+ restoreObjectVisibility(hidden);
467
+ restoreRendererState(renderer, previousState);
468
+ }
250
469
  }
470
+ return renderPreparedCapture(captureOptions);
251
471
  }
252
472
 
253
473
  return {
254
474
  width,
255
475
  height,
256
476
  capture,
477
+ captureAsync,
257
478
  captureDataUrl(nextOptions = {}) {
258
479
  const type = nextOptions.type ?? options.type ?? 'image/png';
259
480
  const result = capture(nextOptions);
@@ -266,9 +487,21 @@ export function createCameraFrameCaptureSession(
266
487
  type,
267
488
  };
268
489
  },
490
+ async captureDataUrlAsync(nextOptions = {}) {
491
+ const type = nextOptions.type ?? options.type ?? 'image/png';
492
+ const result = await captureAsync(nextOptions);
493
+ return {
494
+ ...result,
495
+ dataUrl: result.canvas.toDataURL(
496
+ type,
497
+ nextOptions.quality ?? options.quality
498
+ ),
499
+ type,
500
+ };
501
+ },
269
502
  async captureBlob(nextOptions = {}) {
270
503
  const type = nextOptions.type ?? options.type ?? 'image/png';
271
- const result = capture(nextOptions);
504
+ const result = await captureAsync(nextOptions);
272
505
  const blob = await new Promise<Blob>((resolve, reject) => {
273
506
  result.canvas.toBlob(
274
507
  (nextBlob) => {
@@ -313,17 +546,22 @@ export async function captureCameraFrame(
313
546
  options: CameraFrameCaptureOptions = {}
314
547
  ): Promise<CameraFrameCaptureResult> {
315
548
  const type = options.type ?? 'image/png';
316
- const result = renderCameraFrameToCanvas(
549
+ const session = createCameraFrameCaptureSession(
317
550
  renderer,
318
551
  scene,
319
552
  fallbackCamera,
320
553
  options
321
554
  );
322
- return {
323
- ...result,
324
- dataUrl: result.canvas.toDataURL(type, options.quality),
325
- type,
326
- };
555
+ try {
556
+ const result = await session.captureAsync();
557
+ return {
558
+ ...result,
559
+ dataUrl: result.canvas.toDataURL(type, options.quality),
560
+ type,
561
+ };
562
+ } finally {
563
+ session.dispose();
564
+ }
327
565
  }
328
566
 
329
567
  export async function captureCameraFrameBlob(
@@ -332,22 +570,15 @@ export async function captureCameraFrameBlob(
332
570
  fallbackCamera: THREE.Camera,
333
571
  options: CameraFrameCaptureOptions = {}
334
572
  ): Promise<CameraFrameCaptureBlobResult> {
335
- const type = options.type ?? 'image/png';
336
- const result = renderCameraFrameToCanvas(
573
+ const session = createCameraFrameCaptureSession(
337
574
  renderer,
338
575
  scene,
339
576
  fallbackCamera,
340
577
  options
341
578
  );
342
- const blob = await new Promise<Blob>((resolve, reject) => {
343
- result.canvas.toBlob(
344
- (nextBlob) => {
345
- if (nextBlob) resolve(nextBlob);
346
- else reject(new Error('Camera frame capture did not produce a Blob.'));
347
- },
348
- type,
349
- options.quality
350
- );
351
- });
352
- return { ...result, blob, type };
579
+ try {
580
+ return await session.captureBlob();
581
+ } finally {
582
+ session.dispose();
583
+ }
353
584
  }