@plasius/gpu-camera 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/LICENSE +203 -0
- package/README.md +101 -0
- package/dist/index.cjs +685 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +654 -0
- package/dist/index.js.map +1 -0
- package/legal/CLA-REGISTRY.csv +2 -0
- package/legal/CLA.md +22 -0
- package/legal/CORPORATE_CLA.md +57 -0
- package/legal/INDIVIDUAL_CLA.md +91 -0
- package/package.json +71 -0
- package/src/index.d.ts +224 -0
- package/src/index.js +759 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
const EPSILON = 1e-6;
|
|
2
|
+
const DEFAULT_VIEWPORT = Object.freeze({
|
|
3
|
+
x: 0,
|
|
4
|
+
y: 0,
|
|
5
|
+
width: 1,
|
|
6
|
+
height: 1,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const DEFAULT_UP = Object.freeze([0, 1, 0]);
|
|
10
|
+
|
|
11
|
+
export const cameraProjectionKinds = Object.freeze([
|
|
12
|
+
"perspective",
|
|
13
|
+
"orthographic",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const cameraControlKinds = Object.freeze([
|
|
17
|
+
"set-look-at",
|
|
18
|
+
"orbit",
|
|
19
|
+
"pan",
|
|
20
|
+
"truck",
|
|
21
|
+
"dolly",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function nowMs(timeSource) {
|
|
25
|
+
if (typeof timeSource === "function") {
|
|
26
|
+
return Number(timeSource()) || Date.now();
|
|
27
|
+
}
|
|
28
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
29
|
+
return performance.now();
|
|
30
|
+
}
|
|
31
|
+
return Date.now();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clamp(value, min, max) {
|
|
35
|
+
return Math.min(max, Math.max(min, value));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function finiteNumber(value, fallback = 0) {
|
|
39
|
+
const number = Number(value);
|
|
40
|
+
return Number.isFinite(number) ? number : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cloneVec3(value, fallback = [0, 0, 0]) {
|
|
44
|
+
if (!Array.isArray(value) || value.length < 3) {
|
|
45
|
+
return [...fallback];
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
finiteNumber(value[0], fallback[0]),
|
|
49
|
+
finiteNumber(value[1], fallback[1]),
|
|
50
|
+
finiteNumber(value[2], fallback[2]),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function addVec3(a, b) {
|
|
55
|
+
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function subVec3(a, b) {
|
|
59
|
+
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scaleVec3(vector, scalar) {
|
|
63
|
+
return [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dotVec3(a, b) {
|
|
67
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function crossVec3(a, b) {
|
|
71
|
+
return [
|
|
72
|
+
a[1] * b[2] - a[2] * b[1],
|
|
73
|
+
a[2] * b[0] - a[0] * b[2],
|
|
74
|
+
a[0] * b[1] - a[1] * b[0],
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function lengthVec3(vector) {
|
|
79
|
+
return Math.hypot(vector[0], vector[1], vector[2]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeVec3(vector, fallback = [0, 0, 1]) {
|
|
83
|
+
const length = lengthVec3(vector);
|
|
84
|
+
if (length <= EPSILON) {
|
|
85
|
+
return [...fallback];
|
|
86
|
+
}
|
|
87
|
+
return [vector[0] / length, vector[1] / length, vector[2] / length];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cloneViewport(viewport = DEFAULT_VIEWPORT) {
|
|
91
|
+
return {
|
|
92
|
+
x: clamp(finiteNumber(viewport.x, DEFAULT_VIEWPORT.x), 0, 1),
|
|
93
|
+
y: clamp(finiteNumber(viewport.y, DEFAULT_VIEWPORT.y), 0, 1),
|
|
94
|
+
width: clamp(finiteNumber(viewport.width, DEFAULT_VIEWPORT.width), EPSILON, 1),
|
|
95
|
+
height: clamp(finiteNumber(viewport.height, DEFAULT_VIEWPORT.height), EPSILON, 1),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeProjection(projection = {}) {
|
|
100
|
+
const kind = cameraProjectionKinds.includes(projection.kind)
|
|
101
|
+
? projection.kind
|
|
102
|
+
: "perspective";
|
|
103
|
+
|
|
104
|
+
if (kind === "orthographic") {
|
|
105
|
+
const left = finiteNumber(projection.left, -1);
|
|
106
|
+
const right = finiteNumber(projection.right, 1);
|
|
107
|
+
const bottom = finiteNumber(projection.bottom, -1);
|
|
108
|
+
const top = finiteNumber(projection.top, 1);
|
|
109
|
+
const near = Math.max(EPSILON, finiteNumber(projection.near, 0.1));
|
|
110
|
+
const far = Math.max(near + EPSILON, finiteNumber(projection.far, 2000));
|
|
111
|
+
return {
|
|
112
|
+
kind,
|
|
113
|
+
left,
|
|
114
|
+
right,
|
|
115
|
+
bottom,
|
|
116
|
+
top,
|
|
117
|
+
near,
|
|
118
|
+
far,
|
|
119
|
+
aspect: finiteNumber(projection.aspect, 1),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fovY = clamp(finiteNumber(projection.fovY, 60), 1, 179);
|
|
124
|
+
const near = Math.max(EPSILON, finiteNumber(projection.near, 0.1));
|
|
125
|
+
const far = Math.max(near + EPSILON, finiteNumber(projection.far, 2000));
|
|
126
|
+
const aspect = Math.max(EPSILON, finiteNumber(projection.aspect, 1));
|
|
127
|
+
return {
|
|
128
|
+
kind,
|
|
129
|
+
fovY,
|
|
130
|
+
near,
|
|
131
|
+
far,
|
|
132
|
+
aspect,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeTransform(transform = {}) {
|
|
137
|
+
const position = cloneVec3(transform.position, [0, 0, 5]);
|
|
138
|
+
const target = cloneVec3(transform.target, [0, 0, 0]);
|
|
139
|
+
const up = normalizeVec3(cloneVec3(transform.up, DEFAULT_UP), DEFAULT_UP);
|
|
140
|
+
return {
|
|
141
|
+
position,
|
|
142
|
+
target,
|
|
143
|
+
up,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cloneCamera(camera) {
|
|
148
|
+
return {
|
|
149
|
+
id: camera.id,
|
|
150
|
+
enabled: camera.enabled,
|
|
151
|
+
priority: camera.priority,
|
|
152
|
+
revision: camera.revision,
|
|
153
|
+
touchedAt: camera.touchedAt,
|
|
154
|
+
viewport: cloneViewport(camera.viewport),
|
|
155
|
+
transform: normalizeTransform(camera.transform),
|
|
156
|
+
projection: normalizeProjection(camera.projection),
|
|
157
|
+
metadata: camera.metadata ? { ...camera.metadata } : undefined,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function assertCameraId(cameras, cameraId) {
|
|
162
|
+
const camera = cameras.get(cameraId);
|
|
163
|
+
if (!camera) {
|
|
164
|
+
throw new Error(`Unknown camera "${cameraId}".`);
|
|
165
|
+
}
|
|
166
|
+
return camera;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sortedCameras(cameras) {
|
|
170
|
+
return [...cameras].sort((a, b) => {
|
|
171
|
+
if (b.priority !== a.priority) {
|
|
172
|
+
return b.priority - a.priority;
|
|
173
|
+
}
|
|
174
|
+
return a.id.localeCompare(b.id);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function promoteActive(cameras, activeCameraId) {
|
|
179
|
+
if (!activeCameraId) {
|
|
180
|
+
return cameras;
|
|
181
|
+
}
|
|
182
|
+
const index = cameras.findIndex((camera) => camera.id === activeCameraId);
|
|
183
|
+
if (index <= 0) {
|
|
184
|
+
return cameras;
|
|
185
|
+
}
|
|
186
|
+
const promoted = [...cameras];
|
|
187
|
+
const [active] = promoted.splice(index, 1);
|
|
188
|
+
promoted.unshift(active);
|
|
189
|
+
return promoted;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ensureArray(value) {
|
|
193
|
+
return Array.isArray(value) ? value : [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildPerspectiveMatrix(projection, overrideAspect) {
|
|
197
|
+
const aspect = Math.max(EPSILON, finiteNumber(overrideAspect, projection.aspect));
|
|
198
|
+
const fovRad = (projection.fovY * Math.PI) / 180;
|
|
199
|
+
const f = 1 / Math.tan(fovRad / 2);
|
|
200
|
+
const near = projection.near;
|
|
201
|
+
const far = projection.far;
|
|
202
|
+
|
|
203
|
+
return new Float32Array([
|
|
204
|
+
f / aspect,
|
|
205
|
+
0,
|
|
206
|
+
0,
|
|
207
|
+
0,
|
|
208
|
+
0,
|
|
209
|
+
f,
|
|
210
|
+
0,
|
|
211
|
+
0,
|
|
212
|
+
0,
|
|
213
|
+
0,
|
|
214
|
+
(far + near) / (near - far),
|
|
215
|
+
-1,
|
|
216
|
+
0,
|
|
217
|
+
0,
|
|
218
|
+
(2 * far * near) / (near - far),
|
|
219
|
+
0,
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildOrthographicMatrix(projection, overrideAspect) {
|
|
224
|
+
const near = projection.near;
|
|
225
|
+
const far = projection.far;
|
|
226
|
+
const aspect = Math.max(EPSILON, finiteNumber(overrideAspect, projection.aspect || 1));
|
|
227
|
+
|
|
228
|
+
let left = projection.left;
|
|
229
|
+
let right = projection.right;
|
|
230
|
+
let top = projection.top;
|
|
231
|
+
let bottom = projection.bottom;
|
|
232
|
+
|
|
233
|
+
if (finiteNumber(projection.aspect, 0) > EPSILON) {
|
|
234
|
+
const scale = aspect / projection.aspect;
|
|
235
|
+
left *= scale;
|
|
236
|
+
right *= scale;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const width = Math.max(EPSILON, right - left);
|
|
240
|
+
const height = Math.max(EPSILON, top - bottom);
|
|
241
|
+
const depth = Math.max(EPSILON, far - near);
|
|
242
|
+
|
|
243
|
+
return new Float32Array([
|
|
244
|
+
2 / width,
|
|
245
|
+
0,
|
|
246
|
+
0,
|
|
247
|
+
0,
|
|
248
|
+
0,
|
|
249
|
+
2 / height,
|
|
250
|
+
0,
|
|
251
|
+
0,
|
|
252
|
+
0,
|
|
253
|
+
0,
|
|
254
|
+
-2 / depth,
|
|
255
|
+
0,
|
|
256
|
+
-(right + left) / width,
|
|
257
|
+
-(top + bottom) / height,
|
|
258
|
+
-(far + near) / depth,
|
|
259
|
+
1,
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function buildProjectionMatrix(camera, overrideAspect) {
|
|
264
|
+
const projection = normalizeProjection(camera?.projection);
|
|
265
|
+
if (projection.kind === "orthographic") {
|
|
266
|
+
return buildOrthographicMatrix(projection, overrideAspect);
|
|
267
|
+
}
|
|
268
|
+
return buildPerspectiveMatrix(projection, overrideAspect);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function buildViewMatrix(camera) {
|
|
272
|
+
const transform = normalizeTransform(camera?.transform);
|
|
273
|
+
const eye = transform.position;
|
|
274
|
+
const target = transform.target;
|
|
275
|
+
const up = normalizeVec3(transform.up, DEFAULT_UP);
|
|
276
|
+
|
|
277
|
+
let zAxis = normalizeVec3(subVec3(eye, target), [0, 0, 1]);
|
|
278
|
+
let xAxis = normalizeVec3(crossVec3(up, zAxis), [1, 0, 0]);
|
|
279
|
+
let yAxis = crossVec3(zAxis, xAxis);
|
|
280
|
+
|
|
281
|
+
if (lengthVec3(xAxis) <= EPSILON) {
|
|
282
|
+
zAxis = [0, 0, 1];
|
|
283
|
+
xAxis = [1, 0, 0];
|
|
284
|
+
yAxis = [0, 1, 0];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return new Float32Array([
|
|
288
|
+
xAxis[0],
|
|
289
|
+
yAxis[0],
|
|
290
|
+
zAxis[0],
|
|
291
|
+
0,
|
|
292
|
+
xAxis[1],
|
|
293
|
+
yAxis[1],
|
|
294
|
+
zAxis[1],
|
|
295
|
+
0,
|
|
296
|
+
xAxis[2],
|
|
297
|
+
yAxis[2],
|
|
298
|
+
zAxis[2],
|
|
299
|
+
0,
|
|
300
|
+
-dotVec3(xAxis, eye),
|
|
301
|
+
-dotVec3(yAxis, eye),
|
|
302
|
+
-dotVec3(zAxis, eye),
|
|
303
|
+
1,
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function toCameraUniform(camera, overrideAspect) {
|
|
308
|
+
const normalized = {
|
|
309
|
+
transform: normalizeTransform(camera?.transform),
|
|
310
|
+
projection: normalizeProjection(camera?.projection),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
id: String(camera?.id ?? ""),
|
|
315
|
+
viewMatrix: buildViewMatrix(normalized),
|
|
316
|
+
projectionMatrix: buildProjectionMatrix(normalized, overrideAspect),
|
|
317
|
+
position: new Float32Array(normalized.transform.position),
|
|
318
|
+
target: new Float32Array(normalized.transform.target),
|
|
319
|
+
near: normalized.projection.near,
|
|
320
|
+
far: normalized.projection.far,
|
|
321
|
+
projectionKind: normalized.projection.kind,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function normalizeCameraDefinition(definition, generatedId) {
|
|
326
|
+
const id = String(definition?.id ?? generatedId ?? "camera").trim();
|
|
327
|
+
if (!id) {
|
|
328
|
+
throw new Error("Camera id cannot be empty.");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
id,
|
|
333
|
+
enabled: definition?.enabled !== false,
|
|
334
|
+
priority: finiteNumber(definition?.priority, 0),
|
|
335
|
+
revision: Math.max(0, Math.floor(finiteNumber(definition?.revision, 0))),
|
|
336
|
+
touchedAt: finiteNumber(definition?.touchedAt, 0),
|
|
337
|
+
viewport: cloneViewport(definition?.viewport),
|
|
338
|
+
transform: normalizeTransform(definition?.transform),
|
|
339
|
+
projection: normalizeProjection(definition?.projection),
|
|
340
|
+
metadata: definition?.metadata
|
|
341
|
+
? { ...definition.metadata }
|
|
342
|
+
: undefined,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function applyCameraControl(camera, control, options = {}) {
|
|
347
|
+
const base = normalizeCameraDefinition(camera, camera?.id ?? "camera");
|
|
348
|
+
if (!control || typeof control !== "object") {
|
|
349
|
+
return base;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const kind = String(control.type || "").trim();
|
|
353
|
+
if (!cameraControlKinds.includes(kind)) {
|
|
354
|
+
throw new Error(`Unknown camera control "${kind}".`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const minDistance = Math.max(EPSILON, finiteNumber(options.minDistance, 0.05));
|
|
358
|
+
const maxDistance = Math.max(minDistance + EPSILON, finiteNumber(options.maxDistance, 100000));
|
|
359
|
+
const minPolarAngle = clamp(
|
|
360
|
+
finiteNumber(options.minPolarAngle, EPSILON),
|
|
361
|
+
EPSILON,
|
|
362
|
+
Math.PI - EPSILON
|
|
363
|
+
);
|
|
364
|
+
const maxPolarAngle = clamp(
|
|
365
|
+
finiteNumber(options.maxPolarAngle, Math.PI - EPSILON),
|
|
366
|
+
minPolarAngle,
|
|
367
|
+
Math.PI - EPSILON
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
let next = cloneCamera(base);
|
|
371
|
+
|
|
372
|
+
if (kind === "set-look-at") {
|
|
373
|
+
next.transform.position = cloneVec3(control.position, next.transform.position);
|
|
374
|
+
next.transform.target = cloneVec3(control.target, next.transform.target);
|
|
375
|
+
next.transform.up = normalizeVec3(cloneVec3(control.up, next.transform.up), DEFAULT_UP);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (kind === "pan" || kind === "truck") {
|
|
379
|
+
const delta = cloneVec3(control.delta, [0, 0, 0]);
|
|
380
|
+
next.transform.position = addVec3(next.transform.position, delta);
|
|
381
|
+
next.transform.target = addVec3(next.transform.target, delta);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (kind === "dolly") {
|
|
385
|
+
const distance = finiteNumber(control.distance, 0);
|
|
386
|
+
const eye = next.transform.position;
|
|
387
|
+
const target = next.transform.target;
|
|
388
|
+
const direction = normalizeVec3(subVec3(target, eye), [0, 0, -1]);
|
|
389
|
+
|
|
390
|
+
const offset = subVec3(eye, target);
|
|
391
|
+
const currentRadius = Math.max(EPSILON, lengthVec3(offset));
|
|
392
|
+
const nextRadius = clamp(currentRadius - distance, minDistance, maxDistance);
|
|
393
|
+
const moved = subVec3(target, scaleVec3(direction, nextRadius));
|
|
394
|
+
next.transform.position = moved;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (kind === "orbit") {
|
|
398
|
+
const deltaAzimuth = finiteNumber(control.deltaAzimuth, 0);
|
|
399
|
+
const deltaPolar = finiteNumber(control.deltaPolar, 0);
|
|
400
|
+
const radiusDelta = finiteNumber(control.radiusDelta, 0);
|
|
401
|
+
|
|
402
|
+
const eye = next.transform.position;
|
|
403
|
+
const target = next.transform.target;
|
|
404
|
+
const offset = subVec3(eye, target);
|
|
405
|
+
|
|
406
|
+
const radius = Math.max(EPSILON, lengthVec3(offset));
|
|
407
|
+
const nextRadius = clamp(radius + radiusDelta, minDistance, maxDistance);
|
|
408
|
+
|
|
409
|
+
const azimuth = Math.atan2(offset[0], offset[2]) + deltaAzimuth;
|
|
410
|
+
const polarCurrent = Math.acos(clamp(offset[1] / radius, -1, 1));
|
|
411
|
+
const polar = clamp(
|
|
412
|
+
polarCurrent + deltaPolar,
|
|
413
|
+
minPolarAngle,
|
|
414
|
+
maxPolarAngle
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const sinPolar = Math.sin(polar);
|
|
418
|
+
const position = [
|
|
419
|
+
target[0] + nextRadius * sinPolar * Math.sin(azimuth),
|
|
420
|
+
target[1] + nextRadius * Math.cos(polar),
|
|
421
|
+
target[2] + nextRadius * sinPolar * Math.cos(azimuth),
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
next.transform.position = position;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
next.revision = Math.max(base.revision + 1, next.revision);
|
|
428
|
+
next.touchedAt = finiteNumber(options.touchedAt, base.touchedAt);
|
|
429
|
+
return next;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function createRenderPlan(snapshot, options = {}) {
|
|
433
|
+
const mode = options.mode === "multiview" ? "multiview" : "single";
|
|
434
|
+
const enabledOnly = options.enabledOnly !== false;
|
|
435
|
+
const includeMatrices = options.includeMatrices !== false;
|
|
436
|
+
const maxParallelViews = Math.max(
|
|
437
|
+
1,
|
|
438
|
+
Math.floor(
|
|
439
|
+
finiteNumber(
|
|
440
|
+
options.maxParallelViews,
|
|
441
|
+
finiteNumber(snapshot?.maxParallelViews, 1)
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const byId = new Map();
|
|
447
|
+
const inputCameras = ensureArray(snapshot?.cameras).map((camera) =>
|
|
448
|
+
normalizeCameraDefinition(camera, camera?.id)
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
for (const camera of inputCameras) {
|
|
452
|
+
byId.set(camera.id, camera);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let selected;
|
|
456
|
+
if (Array.isArray(options.cameraIds) && options.cameraIds.length > 0) {
|
|
457
|
+
selected = options.cameraIds
|
|
458
|
+
.map((cameraId) => byId.get(String(cameraId)))
|
|
459
|
+
.filter(Boolean);
|
|
460
|
+
} else {
|
|
461
|
+
selected = sortedCameras(inputCameras);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (enabledOnly) {
|
|
465
|
+
selected = selected.filter((camera) => camera.enabled);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
selected = promoteActive(selected, snapshot?.activeCameraId ?? null);
|
|
469
|
+
|
|
470
|
+
if (mode === "single") {
|
|
471
|
+
selected = selected.slice(0, 1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const hotSet = new Set(ensureArray(snapshot?.hotCameraIds).map((id) => String(id)));
|
|
475
|
+
|
|
476
|
+
const batches = [];
|
|
477
|
+
for (let index = 0; index < selected.length; index += maxParallelViews) {
|
|
478
|
+
const chunk = selected.slice(index, index + maxParallelViews);
|
|
479
|
+
batches.push({
|
|
480
|
+
index: batches.length,
|
|
481
|
+
parallel: chunk.length > 1,
|
|
482
|
+
views: chunk.map((camera, order) => {
|
|
483
|
+
const aspect = camera.viewport.width / camera.viewport.height;
|
|
484
|
+
const view = {
|
|
485
|
+
cameraId: camera.id,
|
|
486
|
+
order,
|
|
487
|
+
priority: camera.priority,
|
|
488
|
+
revision: camera.revision,
|
|
489
|
+
hot: hotSet.has(camera.id),
|
|
490
|
+
viewport: cloneViewport(camera.viewport),
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
if (includeMatrices) {
|
|
494
|
+
view.viewMatrix = buildViewMatrix(camera);
|
|
495
|
+
view.projectionMatrix = buildProjectionMatrix(camera, aspect);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return view;
|
|
499
|
+
}),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
mode,
|
|
505
|
+
generatedAt: finiteNumber(options.generatedAt, Date.now()),
|
|
506
|
+
activeCameraId: snapshot?.activeCameraId ?? null,
|
|
507
|
+
hotCameraIds: [...hotSet],
|
|
508
|
+
maxParallelViews,
|
|
509
|
+
totalViews: selected.length,
|
|
510
|
+
canRenderInParallel: mode === "multiview" && batches.some((batch) => batch.parallel),
|
|
511
|
+
batches,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function createCameraManager(options = {}) {
|
|
516
|
+
const listeners = new Set();
|
|
517
|
+
const cameras = new Map();
|
|
518
|
+
|
|
519
|
+
const maxParallelViews = Math.max(1, Math.floor(finiteNumber(options.maxParallelViews, 2)));
|
|
520
|
+
const maxHotCameras = Math.max(1, Math.floor(finiteNumber(options.maxHotCameras, 3)));
|
|
521
|
+
const timeSource = options.timeSource;
|
|
522
|
+
|
|
523
|
+
let sequence = 0;
|
|
524
|
+
let activeCameraId = null;
|
|
525
|
+
let version = 0;
|
|
526
|
+
let hotCameraIds = [];
|
|
527
|
+
let updatedAt = nowMs(timeSource);
|
|
528
|
+
|
|
529
|
+
function bumpVersion() {
|
|
530
|
+
version += 1;
|
|
531
|
+
updatedAt = nowMs(timeSource);
|
|
532
|
+
const snapshot = getSnapshot();
|
|
533
|
+
for (const listener of listeners) {
|
|
534
|
+
listener(snapshot);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function markHot(cameraId) {
|
|
539
|
+
hotCameraIds = [cameraId, ...hotCameraIds.filter((id) => id !== cameraId)].slice(
|
|
540
|
+
0,
|
|
541
|
+
maxHotCameras
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function selectFallbackActive() {
|
|
546
|
+
const enabled = sortedCameras([...cameras.values()]).filter((camera) => camera.enabled);
|
|
547
|
+
if (enabled.length > 0) {
|
|
548
|
+
return enabled[0].id;
|
|
549
|
+
}
|
|
550
|
+
const sorted = sortedCameras([...cameras.values()]);
|
|
551
|
+
return sorted.length > 0 ? sorted[0].id : null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function getSnapshot() {
|
|
555
|
+
const snapshot = {
|
|
556
|
+
activeCameraId,
|
|
557
|
+
version,
|
|
558
|
+
updatedAt,
|
|
559
|
+
maxParallelViews,
|
|
560
|
+
maxHotCameras,
|
|
561
|
+
hotCameraIds: [...hotCameraIds],
|
|
562
|
+
cameras: sortedCameras([...cameras.values()]).map((camera) => cloneCamera(camera)),
|
|
563
|
+
};
|
|
564
|
+
return snapshot;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function registerCamera(definition = {}) {
|
|
568
|
+
sequence += 1;
|
|
569
|
+
const generatedId = `camera-${sequence}`;
|
|
570
|
+
const camera = normalizeCameraDefinition(definition, generatedId);
|
|
571
|
+
|
|
572
|
+
if (cameras.has(camera.id)) {
|
|
573
|
+
throw new Error(`Camera "${camera.id}" is already registered.`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
camera.touchedAt = nowMs(timeSource);
|
|
577
|
+
cameras.set(camera.id, camera);
|
|
578
|
+
|
|
579
|
+
if (!activeCameraId) {
|
|
580
|
+
activeCameraId = camera.id;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
markHot(camera.id);
|
|
584
|
+
bumpVersion();
|
|
585
|
+
return cloneCamera(camera);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function updateCamera(cameraId, patch = {}) {
|
|
589
|
+
const current = assertCameraId(cameras, cameraId);
|
|
590
|
+
const next = normalizeCameraDefinition(
|
|
591
|
+
{
|
|
592
|
+
...current,
|
|
593
|
+
...patch,
|
|
594
|
+
id: current.id,
|
|
595
|
+
transform: {
|
|
596
|
+
...current.transform,
|
|
597
|
+
...patch.transform,
|
|
598
|
+
},
|
|
599
|
+
projection: {
|
|
600
|
+
...current.projection,
|
|
601
|
+
...patch.projection,
|
|
602
|
+
},
|
|
603
|
+
viewport: {
|
|
604
|
+
...current.viewport,
|
|
605
|
+
...patch.viewport,
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
current.id
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
next.revision = current.revision + 1;
|
|
612
|
+
next.touchedAt = nowMs(timeSource);
|
|
613
|
+
|
|
614
|
+
cameras.set(current.id, next);
|
|
615
|
+
markHot(current.id);
|
|
616
|
+
|
|
617
|
+
if (patch.makeActive === true) {
|
|
618
|
+
activeCameraId = current.id;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
bumpVersion();
|
|
622
|
+
return cloneCamera(next);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function upsertCamera(definition = {}) {
|
|
626
|
+
if (definition?.id && cameras.has(definition.id)) {
|
|
627
|
+
return updateCamera(definition.id, definition);
|
|
628
|
+
}
|
|
629
|
+
return registerCamera(definition);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function removeCamera(cameraId) {
|
|
633
|
+
if (!cameras.has(cameraId)) {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
cameras.delete(cameraId);
|
|
638
|
+
hotCameraIds = hotCameraIds.filter((id) => id !== cameraId);
|
|
639
|
+
|
|
640
|
+
if (activeCameraId === cameraId) {
|
|
641
|
+
activeCameraId = selectFallbackActive();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
bumpVersion();
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function activateCamera(cameraId) {
|
|
649
|
+
const current = assertCameraId(cameras, cameraId);
|
|
650
|
+
activeCameraId = current.id;
|
|
651
|
+
|
|
652
|
+
const updated = cloneCamera(current);
|
|
653
|
+
updated.touchedAt = nowMs(timeSource);
|
|
654
|
+
cameras.set(current.id, updated);
|
|
655
|
+
|
|
656
|
+
markHot(current.id);
|
|
657
|
+
bumpVersion();
|
|
658
|
+
return cloneCamera(updated);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function listCameras(listOptions = {}) {
|
|
662
|
+
let next = sortedCameras([...cameras.values()]);
|
|
663
|
+
if (listOptions.enabledOnly) {
|
|
664
|
+
next = next.filter((camera) => camera.enabled);
|
|
665
|
+
}
|
|
666
|
+
return next.map((camera) => cloneCamera(camera));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function switchCamera(direction = 1, switchOptions = {}) {
|
|
670
|
+
let list = sortedCameras([...cameras.values()]);
|
|
671
|
+
if (switchOptions.enabledOnly !== false) {
|
|
672
|
+
list = list.filter((camera) => camera.enabled);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (list.length === 0) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const currentIndex = list.findIndex((camera) => camera.id === activeCameraId);
|
|
680
|
+
const step = direction >= 0 ? 1 : -1;
|
|
681
|
+
const nextIndex =
|
|
682
|
+
currentIndex < 0
|
|
683
|
+
? 0
|
|
684
|
+
: (currentIndex + step + list.length) % list.length;
|
|
685
|
+
|
|
686
|
+
return activateCamera(list[nextIndex].id);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function applyControl(cameraId, control, controlOptions = {}) {
|
|
690
|
+
const current = assertCameraId(cameras, cameraId);
|
|
691
|
+
const touchedAt = nowMs(timeSource);
|
|
692
|
+
const next = applyCameraControl(current, control, {
|
|
693
|
+
...controlOptions,
|
|
694
|
+
touchedAt,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
cameras.set(cameraId, next);
|
|
698
|
+
markHot(cameraId);
|
|
699
|
+
|
|
700
|
+
if (controlOptions.makeActive === true) {
|
|
701
|
+
activeCameraId = cameraId;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
bumpVersion();
|
|
705
|
+
return cloneCamera(next);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function getCamera(cameraId) {
|
|
709
|
+
const camera = cameras.get(cameraId);
|
|
710
|
+
return camera ? cloneCamera(camera) : null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function hasCamera(cameraId) {
|
|
714
|
+
return cameras.has(cameraId);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function createPlan(planOptions = {}) {
|
|
718
|
+
return createRenderPlan(getSnapshot(), {
|
|
719
|
+
...planOptions,
|
|
720
|
+
maxParallelViews:
|
|
721
|
+
planOptions.maxParallelViews ?? maxParallelViews,
|
|
722
|
+
generatedAt: nowMs(timeSource),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function subscribe(listener) {
|
|
727
|
+
if (typeof listener !== "function") {
|
|
728
|
+
throw new Error("Listener must be a function.");
|
|
729
|
+
}
|
|
730
|
+
listeners.add(listener);
|
|
731
|
+
return () => {
|
|
732
|
+
listeners.delete(listener);
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function clear() {
|
|
737
|
+
cameras.clear();
|
|
738
|
+
activeCameraId = null;
|
|
739
|
+
hotCameraIds = [];
|
|
740
|
+
bumpVersion();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
registerCamera,
|
|
745
|
+
updateCamera,
|
|
746
|
+
upsertCamera,
|
|
747
|
+
removeCamera,
|
|
748
|
+
activateCamera,
|
|
749
|
+
switchCamera,
|
|
750
|
+
applyControl,
|
|
751
|
+
hasCamera,
|
|
752
|
+
getCamera,
|
|
753
|
+
listCameras,
|
|
754
|
+
getSnapshot,
|
|
755
|
+
createRenderPlan: createPlan,
|
|
756
|
+
subscribe,
|
|
757
|
+
clear,
|
|
758
|
+
};
|
|
759
|
+
}
|