@displayxr/inline3d 1.22.0 → 1.24.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 +38 -0
- package/js/inline3d-splat-diag.js +17 -1
- package/js/inline3d-splat-layer-rig.js +425 -0
- package/js/inline3d-splat-live.js +87 -7
- package/js/inline3d-splat-playcanvas.js +188 -7
- package/js/inline3d-splat-rig-map.js +369 -0
- package/js/inline3d-splat-video.js +97 -0
- package/js/inline3d-splat.js +18 -0
- package/package.json +3 -1
- package/splat.d.ts +70 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
|
|
|
5
5
|
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
6
|
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
7
|
|
|
8
|
+
## 1.24.0 — 2026-09-25
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change. Pixels change
|
|
11
|
+
during a live transition between two camera-rig photos (they were wrong), and for the frames in
|
|
12
|
+
which a newly declared rig has not yet reached the runtime's views.
|
|
13
|
+
|
|
14
|
+
- **No camera jump at the start of a live transition.** Crossfade, wavefront and the particle
|
|
15
|
+
transitions keep the outgoing photo alive next to the incoming one. Between two photos with
|
|
16
|
+
camera rigs (SOG camera blocks: each its own convergence and vertical FOV), the outgoing photo
|
|
17
|
+
was drawn from eyes scaled by the two rigs' window ratio from the swap on, a jump in its
|
|
18
|
+
disparity and head parallax. For the frame(s) before the declaration reached the views, both
|
|
19
|
+
photos were drawn through the wrong rig. Each photo is now drawn through its own rig for the whole
|
|
20
|
+
window. The SDK reads off the views which declared rig they were located for, and remaps them
|
|
21
|
+
exactly (window onto window, eye onto eye; the runtime's projection verbatim, no Kooima) where
|
|
22
|
+
they are not the photo's own. `reassemble` was never affected. Headless: 102–208 px of
|
|
23
|
+
reference-point error → < 0.005 px, and the live outgoing image equals the frame before the swap
|
|
24
|
+
(MAE 0.000). Docs: `docs/playcanvas-adapter.md` § Each photo through its own rig.
|
|
25
|
+
- **Kill switch `?dxrdiag=oldrig`** (the 1.23 behaviour). The diag's per-frame record gains
|
|
26
|
+
`rigAt` / `rigIn` / `rigOut`: the rig the views were located for, the rig the current photo was
|
|
27
|
+
drawn through (`+` = remapped), and how the live outgoing photo was drawn.
|
|
28
|
+
- `tools/rig-swap-capture`: the headless harness behind the numbers.
|
|
29
|
+
|
|
30
|
+
## 1.23.0 — 2026-09-25
|
|
31
|
+
|
|
32
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New API; no pixel change for a
|
|
33
|
+
page that does not call it.
|
|
34
|
+
|
|
35
|
+
- **`handle.setLayerRig(layer, 'display' | 'camera', { viewerDistance?, gain? })`** (and
|
|
36
|
+
`addSplat(…, { displayRigLayers: [...] })`): draw chosen engine layers through the display rig —
|
|
37
|
+
round objects with physical pop-out — while the splat and the declared view rig stay on the
|
|
38
|
+
photo's camera rig. The display-rig views are the runtime's own views times an exact shear that
|
|
39
|
+
fixes the convergence plane (a contact point never moves); the runtime's projection matrices are
|
|
40
|
+
used verbatim — no Kooima (docs/proposals/layer-display-rig.md). Mono / 2D is byte-identical to
|
|
41
|
+
today. Kill switch `?dxrdiag=nolayerrig`. `handle.layerRigState()` reports what it did.
|
|
42
|
+
- **`handle.makeSbsMaterial(texture, { format })`**: per-eye side-by-side (or top/bottom) sampling
|
|
43
|
+
on any quad — the left half to left views, the right half to right views, the left half in mono.
|
|
44
|
+
The eye split is a scene-wide uniform, `dxr_eye_split`, set every draw.
|
|
45
|
+
|
|
8
46
|
## 1.22.0 — 2026-09-25
|
|
9
47
|
|
|
10
48
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; no pixel change.
|
|
@@ -28,6 +28,17 @@
|
|
|
28
28
|
// nooverlay — record + console + window.__dxrDiag, but no on-screen overlay.
|
|
29
29
|
// oldpick — handle.pick() always runs the full scan over every centre (the 1.21.1 path), no
|
|
30
30
|
// pick index. Tests "the page's picks are what blocks the main thread".
|
|
31
|
+
// nolayerrig — handle.setLayerRig is recorded but never applied: every layer stays on the eye
|
|
32
|
+
// camera and the photo's camera rig (the pre-1.23 path, byte for byte).
|
|
33
|
+
// oldrig — no rig tracking (./inline3d-splat-rig-map.js): the views are drawn as the runtime
|
|
34
|
+
// located them, and a live outgoing photo goes back on the pre-1.24 node chain (which
|
|
35
|
+
// scales its disparity by the two rigs' window ratio at the swap). A/B for "the
|
|
36
|
+
// outgoing photo jumps when the incoming rig is declared".
|
|
37
|
+
//
|
|
38
|
+
// Per frame, `rigAt` / `rigIn` / `rigOut` say which declared rig the views were located for
|
|
39
|
+
// (the tracker's id; null = none matched), which rig the current photo was drawn through
|
|
40
|
+
// (`+` = remapped to it) and how the live outgoing photo was drawn ('own' = its rig as located,
|
|
41
|
+
// 'remapped', 'eye', 'chain'; null = no live outgoing).
|
|
31
42
|
//
|
|
32
43
|
// WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
|
|
33
44
|
// that is not the SDK's transition at all):
|
|
@@ -42,7 +53,7 @@
|
|
|
42
53
|
// after it (the page's continuation of `await setSource`).
|
|
43
54
|
|
|
44
55
|
/** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
|
|
45
|
-
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick']);
|
|
56
|
+
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig', 'oldrig']);
|
|
46
57
|
const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
|
|
47
58
|
|
|
48
59
|
/**
|
|
@@ -189,6 +200,7 @@ export class DiagRecorder {
|
|
|
189
200
|
this._frameNo = 0;
|
|
190
201
|
this._open = null; // the transition being recorded
|
|
191
202
|
this.imageState = null; // () => { overlay: 'none' | 'frozen' | 'live', w }
|
|
203
|
+
this.rigState = null; // () => { at, in, out } (see the header)
|
|
192
204
|
this.longTasks = [];
|
|
193
205
|
this._lto = null;
|
|
194
206
|
this.rigLocked = null; // norig: the summary of the rig kept
|
|
@@ -371,6 +383,7 @@ export class DiagRecorder {
|
|
|
371
383
|
this._lastFrameAt = t;
|
|
372
384
|
this._frameNo++;
|
|
373
385
|
const img = this.imageState ? safe(this.imageState) : null;
|
|
386
|
+
const rs = this.rigState ? safe(this.rigState) : null;
|
|
374
387
|
const f = {
|
|
375
388
|
n: this._frameNo,
|
|
376
389
|
t: round1(t),
|
|
@@ -386,6 +399,9 @@ export class DiagRecorder {
|
|
|
386
399
|
afterRig: this._frameNo - this._rigFrame <= 3,
|
|
387
400
|
img: img ? img.overlay : 'none',
|
|
388
401
|
imgW: img ? round4(img.w) : 0,
|
|
402
|
+
rigAt: rs ? rs.at : null,
|
|
403
|
+
rigIn: rs ? rs.in : null,
|
|
404
|
+
rigOut: rs ? rs.out : null,
|
|
389
405
|
};
|
|
390
406
|
this.frames.push(f);
|
|
391
407
|
if (this.frames.length > FRAME_CAP) this.frames.splice(0, this.frames.length - FRAME_CAP);
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// inline3d-splat-layer-rig.js — handle.setLayerRig(): draw chosen PlayCanvas layers through the
|
|
2
|
+
// DISPLAY rig while the splat (and the runtime's declared rig) stay on the photo's CAMERA rig.
|
|
3
|
+
// Internal to ./inline3d-splat-playcanvas.js. Design: docs/proposals/layer-display-rig.md.
|
|
4
|
+
//
|
|
5
|
+
// NO KOOIMA HERE. The runtime's projection matrices are used verbatim; nothing below builds a
|
|
6
|
+
// frustum, a tangent or an off-axis matrix. What it does instead is an exact identity:
|
|
7
|
+
//
|
|
8
|
+
// A camera rig is a portal: every view looks through ONE window rectangle on the convergence
|
|
9
|
+
// plane (centre Wc = N0 + D·fwd), from its own eye E_i. The round (display-rig) views look
|
|
10
|
+
// through the SAME window from E'_i = N0 + k·(E_i − N0) — the runtime's ipd/parallax factors
|
|
11
|
+
// are linear about the nominal viewpoint, and the photo's camera rig is the display rig with
|
|
12
|
+
// both factors at n/D (displayxr-common's rig conversion). Two portals through one window
|
|
13
|
+
// differ only by the eye, and the affine shear M_i that fixes the window plane pointwise and
|
|
14
|
+
// sends E'_i to E_i turns one picture into the other:
|
|
15
|
+
//
|
|
16
|
+
// view_round_i = view_photo_i · M_i, proj_round_i = proj_photo_i
|
|
17
|
+
//
|
|
18
|
+
// k = D / (m·n): D the declared convergence (world units), m the declared metersToVirtual, n the
|
|
19
|
+
// nominal viewer distance in metres — the one quantity the browser does not expose (see the
|
|
20
|
+
// design note §5). Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
|
|
21
|
+
|
|
22
|
+
import { invertAffine } from './inline3d-splat-rig-map.js';
|
|
23
|
+
|
|
24
|
+
/** handle.setLayerRig's rigs. 'camera' is the default every layer starts on. */
|
|
25
|
+
export const LAYER_RIGS = Object.freeze(['display', 'camera']);
|
|
26
|
+
|
|
27
|
+
/** Nominal viewer distance (m) when neither the page nor the platform says: the browser's own. */
|
|
28
|
+
export const DEFAULT_VIEWER_DISTANCE_M = 0.6;
|
|
29
|
+
|
|
30
|
+
/** A round eye may not reach the window plane: h(E') is floored at this fraction of D. */
|
|
31
|
+
const MIN_EYE_HEIGHT_FRAC = 1e-3;
|
|
32
|
+
|
|
33
|
+
/** Rotate v by unit quaternion q (xyzw). */
|
|
34
|
+
function rotate(q, v) {
|
|
35
|
+
const [x, y, z, w] = q;
|
|
36
|
+
const tx = 2 * (y * v[2] - z * v[1]);
|
|
37
|
+
const ty = 2 * (z * v[0] - x * v[2]);
|
|
38
|
+
const tz = 2 * (x * v[1] - y * v[0]);
|
|
39
|
+
return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* setLayerRig's arguments, validated (throws at the call).
|
|
44
|
+
* @returns {{ rig: 'display'|'camera', viewerDistance: number|null, gain: number|null }}
|
|
45
|
+
*/
|
|
46
|
+
export function validateLayerRig(layer, rig, o = {}) {
|
|
47
|
+
if (!(typeof layer === 'string' && layer.length) && !(Number.isInteger(layer) && layer >= 0) && !(layer && typeof layer === 'object' && Number.isInteger(layer.id))) {
|
|
48
|
+
throw new TypeError('@displayxr/inline3d/splat: setLayerRig(layer, …) — expected a layer name, a layer id, or a pc.Layer.');
|
|
49
|
+
}
|
|
50
|
+
if (!LAYER_RIGS.includes(rig)) {
|
|
51
|
+
throw new Error(`@displayxr/inline3d/splat: setLayerRig — rig "${rig}", expected ${LAYER_RIGS.map((r) => `'${r}'`).join(' or ')}.`);
|
|
52
|
+
}
|
|
53
|
+
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setLayerRig options must be an object.');
|
|
54
|
+
const num = (k) => {
|
|
55
|
+
if (o[k] === undefined) return null;
|
|
56
|
+
if (!Number.isFinite(o[k]) || !(o[k] > 0)) throw new Error(`@displayxr/inline3d/splat: setLayerRig — bad ${k}: ${o[k]}.`);
|
|
57
|
+
return o[k];
|
|
58
|
+
};
|
|
59
|
+
return { rig, viewerDistance: num('viewerDistance'), gain: num('gain') };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The frame of a declared CAMERA-rig descriptor (XRViewRigInit shape), or null for anything else
|
|
64
|
+
* (a display rig, no rig, a convergence at infinity — no finite window to hold fixed).
|
|
65
|
+
* @returns {{ N0: number[], fwd: number[], D: number, m: number }|null}
|
|
66
|
+
*/
|
|
67
|
+
export function cameraRigFrame(rig) {
|
|
68
|
+
if (!rig || rig.type !== 'camera') return null;
|
|
69
|
+
const inv = rig.convergenceDiopters;
|
|
70
|
+
if (!Number.isFinite(inv) || !(inv > 0)) return null;
|
|
71
|
+
const p = rig.position || { x: 0, y: 0, z: 0 };
|
|
72
|
+
const o = rig.orientation || { x: 0, y: 0, z: 0, w: 1 };
|
|
73
|
+
const m = Number.isFinite(rig.metersToVirtual) && rig.metersToVirtual > 0 ? rig.metersToVirtual : 1;
|
|
74
|
+
const fwd = rotate([o.x || 0, o.y || 0, o.z || 0, o.w === undefined ? 1 : o.w], [0, 0, -1]);
|
|
75
|
+
return { N0: [p.x || 0, p.y || 0, p.z || 0], fwd, D: 1 / inv, m };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** k = D / (m·n), or the caller's explicit gain. */
|
|
79
|
+
export function layerRigGain(frame, { viewerDistance = null, gain = null } = {}) {
|
|
80
|
+
if (gain !== null) return gain;
|
|
81
|
+
const n = viewerDistance !== null ? viewerDistance : DEFAULT_VIEWER_DISTANCE_M;
|
|
82
|
+
return frame.D / (frame.m * n);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The window-fixing shear for one eye, as a column-major 4×4 (and its inverse), in the space the
|
|
87
|
+
* declared rig and the views share.
|
|
88
|
+
*
|
|
89
|
+
* M(X) = X + v · (ẑ·X − c), ẑ = −fwd, c = ẑ·Wc, v = (E − E') / h(E'), h(X) = ẑ·X − c
|
|
90
|
+
* M⁻¹ = the same form with v' = −v / (1 + ẑ·v)
|
|
91
|
+
*
|
|
92
|
+
* @param {number[]} E the runtime's eye position (the view pose's translation).
|
|
93
|
+
* @returns {{ M: Float64Array, Minv: Float64Array, Eround: number[] }}
|
|
94
|
+
*/
|
|
95
|
+
export function windowShear(E, frame, k, M = new Float64Array(16), Minv = new Float64Array(16)) {
|
|
96
|
+
const { N0, fwd, D } = frame;
|
|
97
|
+
const z = [-fwd[0], -fwd[1], -fwd[2]];
|
|
98
|
+
const Wc = [N0[0] + D * fwd[0], N0[1] + D * fwd[1], N0[2] + D * fwd[2]];
|
|
99
|
+
const c = z[0] * Wc[0] + z[1] * Wc[1] + z[2] * Wc[2];
|
|
100
|
+
const Er = [N0[0] + k * (E[0] - N0[0]), N0[1] + k * (E[1] - N0[1]), N0[2] + k * (E[2] - N0[2])];
|
|
101
|
+
let h = z[0] * Er[0] + z[1] * Er[1] + z[2] * Er[2] - c;
|
|
102
|
+
const hMin = MIN_EYE_HEIGHT_FRAC * D;
|
|
103
|
+
if (!(h > hMin)) {
|
|
104
|
+
// Leaning into the glass with a large gain: keep the round eye in front of the window.
|
|
105
|
+
const dh = hMin - h;
|
|
106
|
+
Er[0] += dh * z[0];
|
|
107
|
+
Er[1] += dh * z[1];
|
|
108
|
+
Er[2] += dh * z[2];
|
|
109
|
+
h = hMin;
|
|
110
|
+
}
|
|
111
|
+
const v = [(E[0] - Er[0]) / h, (E[1] - Er[1]) / h, (E[2] - Er[2]) / h];
|
|
112
|
+
shearMatrix(v, z, c, M);
|
|
113
|
+
const s = 1 + z[0] * v[0] + z[1] * v[1] + z[2] * v[2];
|
|
114
|
+
shearMatrix([-v[0] / s, -v[1] / s, -v[2] / s], z, c, Minv);
|
|
115
|
+
return { M, Minv, Eround: Er };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** X ↦ X + v·(z·X − c), column-major. */
|
|
119
|
+
function shearMatrix(v, z, c, out) {
|
|
120
|
+
for (let col = 0; col < 3; col++) {
|
|
121
|
+
for (let row = 0; row < 3; row++) out[col * 4 + row] = (row === col ? 1 : 0) + v[row] * z[col];
|
|
122
|
+
out[col * 4 + 3] = 0;
|
|
123
|
+
}
|
|
124
|
+
out[12] = -v[0] * c;
|
|
125
|
+
out[13] = -v[1] * c;
|
|
126
|
+
out[14] = -v[2] * c;
|
|
127
|
+
out[15] = 1;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Unit quaternion (xyzw) of a rigid column-major 4×4's rotation. */
|
|
132
|
+
function quatOf(m) {
|
|
133
|
+
const m11 = m[0], m12 = m[4], m13 = m[8], m21 = m[1], m22 = m[5], m23 = m[9], m31 = m[2], m32 = m[6], m33 = m[10];
|
|
134
|
+
const tr = m11 + m22 + m33;
|
|
135
|
+
if (tr > 0) {
|
|
136
|
+
const s = 0.5 / Math.sqrt(tr + 1);
|
|
137
|
+
return [(m32 - m23) * s, (m13 - m31) * s, (m21 - m12) * s, 0.25 / s];
|
|
138
|
+
}
|
|
139
|
+
if (m11 > m22 && m11 > m33) {
|
|
140
|
+
const s = 2 * Math.sqrt(1 + m11 - m22 - m33);
|
|
141
|
+
return [0.25 * s, (m12 + m21) / s, (m13 + m31) / s, (m32 - m23) / s];
|
|
142
|
+
}
|
|
143
|
+
if (m22 > m33) {
|
|
144
|
+
const s = 2 * Math.sqrt(1 + m22 - m11 - m33);
|
|
145
|
+
return [(m12 + m21) / s, 0.25 * s, (m23 + m32) / s, (m13 - m31) / s];
|
|
146
|
+
}
|
|
147
|
+
const s = 2 * Math.sqrt(1 + m33 - m11 - m22);
|
|
148
|
+
return [(m13 + m31) / s, (m23 + m32) / s, 0.25 * s, (m21 - m12) / s];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** a × b, column-major (local, so this module stays standalone). */
|
|
152
|
+
export function mul4(a, b, out = new Float64Array(16)) {
|
|
153
|
+
const r = new Float64Array(16);
|
|
154
|
+
for (let col = 0; col < 4; col++) {
|
|
155
|
+
for (let row = 0; row < 4; row++) {
|
|
156
|
+
r[col * 4 + row] = a[row] * b[col * 4] + a[4 + row] * b[col * 4 + 1] + a[8 + row] * b[col * 4 + 2] + a[12 + row] * b[col * 4 + 3];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
out.set(r);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Inverse of a rigid (rotation + translation) column-major 4×4. */
|
|
164
|
+
export function invertRigid(m, out = new Float64Array(16)) {
|
|
165
|
+
const r = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
|
|
166
|
+
out[0] = r[0]; out[1] = r[3]; out[2] = r[6]; out[3] = 0;
|
|
167
|
+
out[4] = r[1]; out[5] = r[4]; out[6] = r[7]; out[7] = 0;
|
|
168
|
+
out[8] = r[2]; out[9] = r[5]; out[10] = r[8]; out[11] = 0;
|
|
169
|
+
const tx = m[12], ty = m[13], tz = m[14];
|
|
170
|
+
out[12] = -(out[0] * tx + out[4] * ty + out[8] * tz);
|
|
171
|
+
out[13] = -(out[1] * tx + out[5] * ty + out[9] * tz);
|
|
172
|
+
out[14] = -(out[2] * tx + out[6] * ty + out[10] * tz);
|
|
173
|
+
out[15] = 1;
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The round views for one frame: for each runtime view pose P_i (camera → rig space, rigid),
|
|
179
|
+
* `viewInv = M_i⁻¹ · P_i` and `view = P_i⁻¹ · M_i` — what RenderView.setView takes. Null (the
|
|
180
|
+
* caller keeps the photo views) when there is nothing to round: no camera rig, fewer than two
|
|
181
|
+
* views, or a gain of exactly 1.
|
|
182
|
+
*/
|
|
183
|
+
export function roundViews(entries, frame, k, into = []) {
|
|
184
|
+
if (!frame || !entries || entries.length < 2 || !(k > 0) || k === 1) return null;
|
|
185
|
+
into.length = entries.length;
|
|
186
|
+
for (let i = 0; i < entries.length; i++) {
|
|
187
|
+
const P = entries[i].pose;
|
|
188
|
+
const o = (into[i] ||= { viewInv: new Float64Array(16), view: new Float64Array(16), M: new Float64Array(16), Minv: new Float64Array(16), eye: [0, 0, 0] });
|
|
189
|
+
const { Eround } = windowShear([P[12], P[13], P[14]], frame, k, o.M, o.Minv);
|
|
190
|
+
o.eye[0] = Eround[0];
|
|
191
|
+
o.eye[1] = Eround[1];
|
|
192
|
+
o.eye[2] = Eround[2];
|
|
193
|
+
mul4(o.Minv, P, o.viewInv);
|
|
194
|
+
mul4(invertAffine(P), o.M, o.view); // P is rigid, or affine once remapped to the declared rig (./inline3d-splat-rig-map.js)
|
|
195
|
+
}
|
|
196
|
+
return into;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Split a layer composition into contiguous RUNS for per-rig cameras. `entries` is the
|
|
201
|
+
* composition's sublayer list as layer ids, in draw order (opaque and transparent sublayers both
|
|
202
|
+
* appear); `display` the set of display-rig layer ids; `eyeLayers` the ids the eye camera draws.
|
|
203
|
+
* PlayCanvas orders render actions by CAMERA, then layer — so keeping today's order with a second
|
|
204
|
+
* rig needs one camera per run:
|
|
205
|
+
* pre — eye-camera layers with any sublayer before the first display sublayer (stay put);
|
|
206
|
+
* display — the display layers the eye camera drew;
|
|
207
|
+
* post — eye-camera layers whose every sublayer comes after the last display sublayer.
|
|
208
|
+
* A non-display layer INTERLEAVED between display sublayers stays in `pre` (it would otherwise
|
|
209
|
+
* split a run) and is reported in `interleaved`.
|
|
210
|
+
*/
|
|
211
|
+
export function layerRuns(entries, display, eyeLayers) {
|
|
212
|
+
const eye = new Set(eyeLayers);
|
|
213
|
+
let first = -1;
|
|
214
|
+
let last = -1;
|
|
215
|
+
for (let i = 0; i < entries.length; i++) {
|
|
216
|
+
if (display.has(entries[i]) && eye.has(entries[i])) {
|
|
217
|
+
if (first < 0) first = i;
|
|
218
|
+
last = i;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const out = { pre: [], display: [], post: [], interleaved: [] };
|
|
222
|
+
if (first < 0) {
|
|
223
|
+
out.pre = [...eye];
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
for (const id of eyeLayers) {
|
|
228
|
+
if (seen.has(id)) continue;
|
|
229
|
+
seen.add(id);
|
|
230
|
+
if (display.has(id)) {
|
|
231
|
+
out.display.push(id);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const at = [];
|
|
235
|
+
for (let i = 0; i < entries.length; i++) if (entries[i] === id) at.push(i);
|
|
236
|
+
if (at.length && at.every((i) => i > last)) out.post.push(id);
|
|
237
|
+
else {
|
|
238
|
+
out.pre.push(id);
|
|
239
|
+
if (at.some((i) => i > first && i < last)) out.interleaved.push(id);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Keep the run cameras' layer lists in draw order (the composition's, not the eye's list).
|
|
243
|
+
const order = (ids) => ids.sort((a, b) => entries.indexOf(a) - entries.indexOf(b));
|
|
244
|
+
order(out.display);
|
|
245
|
+
order(out.post);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── the engine side ──────────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The per-rig cameras of one tile. Owned by the viewer; `sync` + `frame` are called by its draw.
|
|
253
|
+
* The eye camera keeps its `layers` minus what moved; `restore()` puts them back.
|
|
254
|
+
*/
|
|
255
|
+
export class LayerRigCameras {
|
|
256
|
+
constructor(viewer) {
|
|
257
|
+
this.viewer = viewer;
|
|
258
|
+
/** What the page asked for: name or id → { viewerDistance, gain }. */
|
|
259
|
+
this.requests = new Map();
|
|
260
|
+
/** Those, resolved: layer id → options (the ones present in the composition right now). */
|
|
261
|
+
this.layers = new Map();
|
|
262
|
+
/** The gain options in force (the last setLayerRig call's — one gain per tile). */
|
|
263
|
+
this.opts = {};
|
|
264
|
+
this.disabled = false; // the kill switch (diag 'nolayerrig')
|
|
265
|
+
this.cams = { display: null, post: null };
|
|
266
|
+
this.moved = { display: [], post: [] };
|
|
267
|
+
this._key = '';
|
|
268
|
+
this._rvs = { display: [], post: [] };
|
|
269
|
+
this._round = [];
|
|
270
|
+
/** Diagnostics: what the last frame did. */
|
|
271
|
+
this.last = { rounded: false, gain: null, views: 0 };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
get active() {
|
|
275
|
+
return !this.disabled && this.requests.size > 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Resolve a name / id / pc.Layer to an id in this tile's composition (null if unknown). */
|
|
279
|
+
resolve(layer) {
|
|
280
|
+
const comp = this.viewer.app?.scene?.layers;
|
|
281
|
+
if (!comp) return null;
|
|
282
|
+
if (layer && typeof layer === 'object') return layer.id;
|
|
283
|
+
if (Number.isInteger(layer)) return layer;
|
|
284
|
+
const l = comp.getLayerByName?.(layer);
|
|
285
|
+
return l ? l.id : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Record a request by the caller's key (name, id): resolved against the composition on every
|
|
290
|
+
* sync, so a layer the page adds AFTER calling setLayerRig is picked up when it appears.
|
|
291
|
+
*/
|
|
292
|
+
set(key, rig, o) {
|
|
293
|
+
const k = key && typeof key === 'object' ? key.id : key;
|
|
294
|
+
if (rig === 'display') this.requests.set(k, o);
|
|
295
|
+
else this.requests.delete(k);
|
|
296
|
+
this.opts = o;
|
|
297
|
+
this._key = ''; // re-split on the next draw
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_resolveAll() {
|
|
301
|
+
this.layers.clear();
|
|
302
|
+
for (const [k, o] of this.requests) {
|
|
303
|
+
const id = this.resolve(k);
|
|
304
|
+
if (id !== null && id !== undefined) this.layers.set(id, o);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_makeCam(name, priority) {
|
|
309
|
+
const v = this.viewer;
|
|
310
|
+
const cam = v._makeCamera(name, null);
|
|
311
|
+
cam.camera.priority = priority;
|
|
312
|
+
cam.camera.clearColorBuffer = false;
|
|
313
|
+
cam.camera.clearDepthBuffer = false;
|
|
314
|
+
cam.camera.clearStencilBuffer = false;
|
|
315
|
+
cam.camera.frustumCulling = false;
|
|
316
|
+
cam.camera.layers = [];
|
|
317
|
+
return cam;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Re-split the composition when the display set, the eye's layers or the composition moved. */
|
|
321
|
+
sync() {
|
|
322
|
+
const v = this.viewer;
|
|
323
|
+
const eyeCam = v.eye?.camera;
|
|
324
|
+
const comp = v.app?.scene?.layers;
|
|
325
|
+
if (!eyeCam || !comp) return false;
|
|
326
|
+
if (!this.active) {
|
|
327
|
+
if (this.moved.display.length || this.moved.post.length) this.restore();
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
this._resolveAll();
|
|
331
|
+
const entries = (comp.layerList || []).map((l) => l.id);
|
|
332
|
+
const eyeLayers = [...(eyeCam.layers || []), ...this.moved.display, ...this.moved.post];
|
|
333
|
+
const key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${(eyeCam.layers || []).join(',')}`;
|
|
334
|
+
if (key === this._key) return this.moved.display.length > 0;
|
|
335
|
+
const runs = layerRuns(entries, new Set(this.layers.keys()), eyeLayers);
|
|
336
|
+
if (runs.interleaved.length && !this._warnedInterleaved) {
|
|
337
|
+
this._warnedInterleaved = true;
|
|
338
|
+
console.warn(
|
|
339
|
+
`[inline3d/splat] setLayerRig: layer(s) ${runs.interleaved.join(', ')} sit BETWEEN display-rig ` +
|
|
340
|
+
'sublayers; they are drawn before them (with the eye camera). Move them before or after the display layers.',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
this.moved.display = runs.display;
|
|
344
|
+
this.moved.post = runs.post;
|
|
345
|
+
eyeCam.layers = runs.pre;
|
|
346
|
+
if (runs.display.length) {
|
|
347
|
+
this.cams.display ||= this._makeCam('inline3d-eye-display', 1);
|
|
348
|
+
this.cams.display.camera.layers = runs.display;
|
|
349
|
+
}
|
|
350
|
+
if (runs.post.length) {
|
|
351
|
+
this.cams.post ||= this._makeCam('inline3d-eye-post', 2);
|
|
352
|
+
this.cams.post.camera.layers = runs.post;
|
|
353
|
+
}
|
|
354
|
+
if (this.cams.display) this.cams.display.enabled = runs.display.length > 0;
|
|
355
|
+
if (this.cams.post) this.cams.post.enabled = runs.post.length > 0;
|
|
356
|
+
this._key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${eyeCam.layers.join(',')}`;
|
|
357
|
+
return runs.display.length > 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Every moved layer back on the eye camera, run cameras off (the kill switch / last layer off). */
|
|
361
|
+
restore() {
|
|
362
|
+
const eyeCam = this.viewer.eye?.camera;
|
|
363
|
+
if (eyeCam) {
|
|
364
|
+
const back = [...this.moved.display, ...this.moved.post];
|
|
365
|
+
eyeCam.layers = [...(eyeCam.layers || []), ...back.filter((id) => !(eyeCam.layers || []).includes(id))];
|
|
366
|
+
}
|
|
367
|
+
this.moved = { display: [], post: [] };
|
|
368
|
+
if (this.cams.display) this.cams.display.enabled = false;
|
|
369
|
+
if (this.cams.post) this.cams.post.enabled = false;
|
|
370
|
+
this._key = '';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Per drawn frame, after the eye camera's RenderViews are set. `rig` is the descriptor the
|
|
375
|
+
* runtime located these views with (the viewer snapshots it before the tick can declare anew).
|
|
376
|
+
*/
|
|
377
|
+
frame(entries, rect, f, rig) {
|
|
378
|
+
const pc = this.viewer.pc;
|
|
379
|
+
this.last.rounded = false;
|
|
380
|
+
this.last.views = entries.length;
|
|
381
|
+
const frame = cameraRigFrame(rig);
|
|
382
|
+
const k = frame ? layerRigGain(frame, this.opts || {}) : null;
|
|
383
|
+
const round = frame ? roundViews(entries, frame, k, this._round) : null;
|
|
384
|
+
this.last.gain = k;
|
|
385
|
+
for (const run of ['display', 'post']) {
|
|
386
|
+
const cam = this.cams[run];
|
|
387
|
+
if (!cam || !cam.enabled) continue;
|
|
388
|
+
const rvs = this._rvs[run];
|
|
389
|
+
if (rvs.length !== entries.length) {
|
|
390
|
+
rvs.length = 0;
|
|
391
|
+
for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
|
|
392
|
+
cam.camera.camera.xrViews = rvs.slice();
|
|
393
|
+
}
|
|
394
|
+
const r = run === 'display' ? round : null;
|
|
395
|
+
for (let i = 0; i < entries.length; i++) {
|
|
396
|
+
const e = entries[i];
|
|
397
|
+
if (r) rvs[i].setView(e.proj, r[i].viewInv, r[i].view);
|
|
398
|
+
else rvs[i].setView(e.proj, e.pose);
|
|
399
|
+
const [x, y, w, h] = rect(e);
|
|
400
|
+
rvs[i].setViewport(x, y, w, h);
|
|
401
|
+
}
|
|
402
|
+
if (cam._frustumKey !== this.viewer._frustumKey) {
|
|
403
|
+
cam._frustumKey = this.viewer._frustumKey;
|
|
404
|
+
cam.camera.camera.setXrProperties({ ...f, horizontalFov: false });
|
|
405
|
+
}
|
|
406
|
+
// The node drives transparent sorting (the views ignore it): on the (round) first eye.
|
|
407
|
+
const p = entries[0].node || entries[0].pose; // `node`: a rigid pose when the views are remapped
|
|
408
|
+
const q = quatOf(p);
|
|
409
|
+
cam.setLocalRotation(q[0], q[1], q[2], q[3]);
|
|
410
|
+
if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
|
|
411
|
+
else cam.setLocalPosition(p[12], p[13], p[14]);
|
|
412
|
+
if (run === 'display') {
|
|
413
|
+
// Two camera spaces never share a depth test; in mono (and at gain 1) they are one space.
|
|
414
|
+
cam.camera.clearDepthBuffer = !!r;
|
|
415
|
+
this.last.rounded = !!r;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
destroy() {
|
|
421
|
+
this.restore();
|
|
422
|
+
for (const c of Object.values(this.cams)) c?.destroy?.();
|
|
423
|
+
this.cams = { display: null, post: null };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
@@ -25,14 +25,29 @@
|
|
|
25
25
|
//
|
|
26
26
|
// THE RIG. The incoming asset's rig (camera rig: its capture pose, lens and convergence; display
|
|
27
27
|
// rig: its fit and focus) is adopted at once, as in the frozen path, so the views the runtime
|
|
28
|
-
// hands us
|
|
29
|
-
//
|
|
28
|
+
// hands us — once the declaration has reached them, a frame or more later — are the INCOMING
|
|
29
|
+
// photo's. The outgoing photo must keep being drawn through ITS OWN rig for the whole window.
|
|
30
|
+
//
|
|
31
|
+
// Two camera rigs (a photo slideshow: every photo a SOG with a camera block): the views are
|
|
32
|
+
// remapped, per frame, from the rig they were LOCATED for to the outgoing photo's rig, by the
|
|
33
|
+
// exact portal map of ./inline3d-splat-rig-map.js — window onto window, eye onto eye, the
|
|
34
|
+
// runtime's projection verbatim — under the outgoing photo's rig node R_o. Before the
|
|
35
|
+
// declaration arrives the located rig IS the outgoing one and the views pass through untouched.
|
|
36
|
+
//
|
|
37
|
+
// Otherwise (a display rig on either side, views no declared rig explains, the kill switch
|
|
38
|
+
// `?dxrdiag=oldrig`): the pre-1.24 node chain, which maps the incoming views back to the outgoing
|
|
39
|
+
// photo's framing:
|
|
30
40
|
// N = R_o · K_o · D_o · D_n⁻¹ · K_n⁻¹
|
|
31
41
|
// with R the rig node (inverse pivot), K the mono/capture pose in rig space and
|
|
32
42
|
// D = diag(c·t, c·t, c) (c = convergence distance, t = tan(vfov/2)). The eye poses (head
|
|
33
43
|
// motion) pass through untouched; the outgoing photo keeps its own window and zero-disparity
|
|
34
44
|
// plane. Two display-rig assets cancel to N = R_o (the old fit and focus); in controls:'page' the
|
|
35
|
-
// page owns the camera and N is the rig node itself.
|
|
45
|
+
// page owns the camera and N is the rig node itself. For two CAMERA rigs this chain is not exact:
|
|
46
|
+
// it scales the eyes with the window, by (c_o·t_o)/(c_n·t_n) — the outgoing photo's disparity and
|
|
47
|
+
// head parallax jumped by that factor at the swap (the "camera change right before the
|
|
48
|
+
// transition" report) — and it assumed the views were already the incoming rig's.
|
|
49
|
+
|
|
50
|
+
import { remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
|
|
36
51
|
|
|
37
52
|
/** setSource's `outgoing` option. */
|
|
38
53
|
export const OUTGOING_MODES = Object.freeze(['live', 'frozen']);
|
|
@@ -151,6 +166,12 @@ export class LiveOutgoing {
|
|
|
151
166
|
/** PRE-SORT (warm): the current asset is ALSO on the live layer, before the swap. */
|
|
152
167
|
this.warming = false;
|
|
153
168
|
this._warmLayers = null;
|
|
169
|
+
/** The outgoing photo's own declared rig (a RigTracker entry), taken at start(). */
|
|
170
|
+
this.ownRig = null;
|
|
171
|
+
this._map = [];
|
|
172
|
+
this._node = new Float64Array(16);
|
|
173
|
+
/** What the last sync drew through: 'eye' (the eye's views), 'own' (the photo's rig, as located), 'remapped', 'chain'. */
|
|
174
|
+
this.path = null;
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
/** Engine support for this path: RenderViews on one camera, render targets, layers. */
|
|
@@ -299,6 +320,8 @@ export class LiveOutgoing {
|
|
|
299
320
|
entity.gsplat.layers = [this.layer.id];
|
|
300
321
|
this.entity = entity;
|
|
301
322
|
this.oldFrame = oldFrame;
|
|
323
|
+
// Read BEFORE the incoming asset's rig is declared: the last declared rig is the outgoing's.
|
|
324
|
+
this.ownRig = v.rigTrack?.latest ?? null;
|
|
302
325
|
if (!warmed) this.frames = 0;
|
|
303
326
|
this.warming = false;
|
|
304
327
|
this._warmLayers = null;
|
|
@@ -347,10 +370,11 @@ export class LiveOutgoing {
|
|
|
347
370
|
}
|
|
348
371
|
|
|
349
372
|
/**
|
|
350
|
-
* Per frame, before the engine tick: the
|
|
351
|
-
*
|
|
373
|
+
* Per frame, before the engine tick: the views on the outgoing camera — through the outgoing
|
|
374
|
+
* photo's own rig (see the header) — the viewports, and the node chain. `rf` is the viewer's rig
|
|
375
|
+
* mapping for this frame (viewer._mapViews), or undefined.
|
|
352
376
|
*/
|
|
353
|
-
sync(entries, rect, frustum) {
|
|
377
|
+
sync(entries, rect, frustum, rf) {
|
|
354
378
|
if (!this.active && !this.warming) return;
|
|
355
379
|
const v = this.viewer;
|
|
356
380
|
const pc = v.pc;
|
|
@@ -360,11 +384,50 @@ export class LiveOutgoing {
|
|
|
360
384
|
for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
|
|
361
385
|
this.cam.camera.camera.xrViews = rvs.slice();
|
|
362
386
|
}
|
|
387
|
+
// Which views: the pre-sort (and controls:'page', where both photos are the page camera's)
|
|
388
|
+
// draws what the eye draws; an active window draws the outgoing photo's own rig.
|
|
389
|
+
let mapped = null; // remapped views, or null = the entries as they are
|
|
390
|
+
let exact = false; // the rig map applies: no node chain
|
|
391
|
+
let nodeRig = null; // the portal the remapped views belong to (for the camera node)
|
|
392
|
+
const own = this.ownRig;
|
|
393
|
+
if (rf && v.rigTrack && (!this.active || v.pageCamera)) {
|
|
394
|
+
exact = true;
|
|
395
|
+
mapped = rf.remapped ? rf.entries : null;
|
|
396
|
+
nodeRig = rf.eye?.portal ?? null;
|
|
397
|
+
this.path = 'eye';
|
|
398
|
+
} else if (rf?.located && own?.portal && this.active && !v.pageCamera) {
|
|
399
|
+
if (rf.located === own || sameRig(rf.located.rig, own.rig)) {
|
|
400
|
+
exact = true;
|
|
401
|
+
this.path = 'own';
|
|
402
|
+
} else {
|
|
403
|
+
mapped = remapViews(entries, rf.located.portal, own.portal, this._map);
|
|
404
|
+
exact = !!mapped;
|
|
405
|
+
nodeRig = own.portal;
|
|
406
|
+
if (mapped) this.path = 'remapped';
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!exact) this.path = 'chain';
|
|
363
410
|
for (let i = 0; i < entries.length; i++) {
|
|
364
|
-
rvs[i].setView(
|
|
411
|
+
if (mapped) rvs[i].setView(mapped[i].proj, mapped[i].viewInv, mapped[i].view);
|
|
412
|
+
else rvs[i].setView(entries[i].proj, entries[i].pose);
|
|
365
413
|
const [x, y, w, h] = rect(entries[i]);
|
|
366
414
|
rvs[i].setViewport(x, y, w, h);
|
|
367
415
|
}
|
|
416
|
+
// The wavefront's cull reads the views this camera really draws (viewer.cullViews).
|
|
417
|
+
if (mapped) {
|
|
418
|
+
const cull = (this._cull ||= []);
|
|
419
|
+
cull.length = mapped.length;
|
|
420
|
+
for (let i = 0; i < mapped.length; i++) {
|
|
421
|
+
const c = (cull[i] ||= {});
|
|
422
|
+
c.proj = mapped[i].proj;
|
|
423
|
+
c.pose = mapped[i].viewInv;
|
|
424
|
+
c.x = mapped[i].x;
|
|
425
|
+
c.y = mapped[i].y;
|
|
426
|
+
c.width = mapped[i].width;
|
|
427
|
+
c.height = mapped[i].height;
|
|
428
|
+
}
|
|
429
|
+
this.cam._dxrViews = cull;
|
|
430
|
+
} else this.cam._dxrViews = null;
|
|
368
431
|
if (frustum) {
|
|
369
432
|
const key = `${frustum.fov.toFixed(4)}|${frustum.aspectRatio.toFixed(4)}|${frustum.nearClip}|${frustum.farClip}`;
|
|
370
433
|
if (key !== this._frustumKey) {
|
|
@@ -374,6 +437,20 @@ export class LiveOutgoing {
|
|
|
374
437
|
}
|
|
375
438
|
// The node chain (see the header). controls:'page': the page's camera, for both assets.
|
|
376
439
|
const { n1, n2, n3 } = this.nodes;
|
|
440
|
+
if (exact) {
|
|
441
|
+
// The views are in rig space already: only the photo's rig node above them — the one the
|
|
442
|
+
// outgoing photo had at the swap (the pre-sort and controls:'page': the current one).
|
|
443
|
+
setTRS(n1, similarityTRS(this.active && !v.pageCamera && this.oldFrame ? this.oldFrame.rig : v.rigMatrix()));
|
|
444
|
+
n2.setLocalScale(1, 1, 1);
|
|
445
|
+
setTRS(n3, { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: 1 });
|
|
446
|
+
// The camera NODE (sort direction, LOD distance) on the first eye this camera draws from.
|
|
447
|
+
const pose = mapped && nodeRig ? nodePose(nodeRig, mapped[0].eye, this._node) : entries[0].pose;
|
|
448
|
+
const q = similarityTRS(pose).rotation;
|
|
449
|
+
this.cam.setLocalPosition(pose[12], pose[13], pose[14]);
|
|
450
|
+
this.cam.setLocalRotation(q[0], q[1], q[2], q[3]);
|
|
451
|
+
this.frames++;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
377
454
|
if (v.pageCamera || !this.oldFrame) {
|
|
378
455
|
const r = similarityTRS(v.rigMatrix());
|
|
379
456
|
setTRS(n1, r);
|
|
@@ -401,6 +478,9 @@ export class LiveOutgoing {
|
|
|
401
478
|
if (this.warming) return this.cancelWarm();
|
|
402
479
|
this.entity = null;
|
|
403
480
|
this.oldFrame = null;
|
|
481
|
+
this.ownRig = null;
|
|
482
|
+
this.path = null;
|
|
483
|
+
if (this.cam) this.cam._dxrViews = null;
|
|
404
484
|
this.active = false;
|
|
405
485
|
this.warmed = false;
|
|
406
486
|
if (this.cam) this.cam.enabled = false;
|