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.
- package/README.md +22 -6
- package/dist/{chunk-VDSEPZYQ.js → chunk-6MOK6ZWB.js} +397 -4
- package/dist/chunk-6MOK6ZWB.js.map +1 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +360 -430
- package/dist/index.js.map +1 -1
- package/dist/spark.d.ts +27 -3
- package/dist/spark.js +156 -3
- package/dist/spark.js.map +1 -1
- package/dist/{types-BuJ4boaq.d.ts → types-BDB9QT6Z.d.ts} +1 -0
- package/package.json +1 -1
- package/src/components/ContactMarkers.tsx +8 -1
- package/src/components/Debug.tsx +154 -3
- package/src/components/DragInteraction.tsx +2 -0
- package/src/components/IkGizmo.tsx +5 -1
- package/src/core/MujocoSimProvider.tsx +5 -5
- package/src/index.ts +1 -0
- package/src/rendering/cameraFrameCapture.ts +259 -28
- package/src/rendering/cameraFrameSource.ts +10 -2
- package/src/spark.tsx +241 -1
- package/src/types.ts +1 -0
- package/dist/chunk-VDSEPZYQ.js.map +0 -1
package/src/components/Debug.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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.
|
|
1445
|
+
const cameraFrame = await session.captureDataUrlAsync({
|
|
1446
1446
|
...resolvedCaptureOptions,
|
|
1447
1447
|
source: mountedSource ?? resolvedCaptureOptions.source,
|
|
1448
1448
|
});
|
package/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
249
|
-
renderer
|
|
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 =
|
|
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
|
|
549
|
+
const session = createCameraFrameCaptureSession(
|
|
317
550
|
renderer,
|
|
318
551
|
scene,
|
|
319
552
|
fallbackCamera,
|
|
320
553
|
options
|
|
321
554
|
);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
336
|
-
const result = renderCameraFrameToCanvas(
|
|
573
|
+
const session = createCameraFrameCaptureSession(
|
|
337
574
|
renderer,
|
|
338
575
|
scene,
|
|
339
576
|
fallbackCamera,
|
|
340
577
|
options
|
|
341
578
|
);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
}
|