@displayxr/inline3d 1.23.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 +22 -0
- package/js/inline3d-splat-diag.js +15 -1
- package/js/inline3d-splat-layer-rig.js +4 -2
- package/js/inline3d-splat-live.js +87 -7
- package/js/inline3d-splat-playcanvas.js +95 -7
- package/js/inline3d-splat-rig-map.js +369 -0
- package/package.json +2 -1
- package/splat.d.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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
|
+
|
|
8
30
|
## 1.23.0 — 2026-09-25
|
|
9
31
|
|
|
10
32
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New API; no pixel change for a
|
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
// pick index. Tests "the page's picks are what blocks the main thread".
|
|
31
31
|
// nolayerrig — handle.setLayerRig is recorded but never applied: every layer stays on the eye
|
|
32
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).
|
|
33
42
|
//
|
|
34
43
|
// WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
|
|
35
44
|
// that is not the SDK's transition at all):
|
|
@@ -44,7 +53,7 @@
|
|
|
44
53
|
// after it (the page's continuation of `await setSource`).
|
|
45
54
|
|
|
46
55
|
/** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
|
|
47
|
-
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig']);
|
|
56
|
+
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig', 'oldrig']);
|
|
48
57
|
const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
|
|
49
58
|
|
|
50
59
|
/**
|
|
@@ -191,6 +200,7 @@ export class DiagRecorder {
|
|
|
191
200
|
this._frameNo = 0;
|
|
192
201
|
this._open = null; // the transition being recorded
|
|
193
202
|
this.imageState = null; // () => { overlay: 'none' | 'frozen' | 'live', w }
|
|
203
|
+
this.rigState = null; // () => { at, in, out } (see the header)
|
|
194
204
|
this.longTasks = [];
|
|
195
205
|
this._lto = null;
|
|
196
206
|
this.rigLocked = null; // norig: the summary of the rig kept
|
|
@@ -373,6 +383,7 @@ export class DiagRecorder {
|
|
|
373
383
|
this._lastFrameAt = t;
|
|
374
384
|
this._frameNo++;
|
|
375
385
|
const img = this.imageState ? safe(this.imageState) : null;
|
|
386
|
+
const rs = this.rigState ? safe(this.rigState) : null;
|
|
376
387
|
const f = {
|
|
377
388
|
n: this._frameNo,
|
|
378
389
|
t: round1(t),
|
|
@@ -388,6 +399,9 @@ export class DiagRecorder {
|
|
|
388
399
|
afterRig: this._frameNo - this._rigFrame <= 3,
|
|
389
400
|
img: img ? img.overlay : 'none',
|
|
390
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,
|
|
391
405
|
};
|
|
392
406
|
this.frames.push(f);
|
|
393
407
|
if (this.frames.length > FRAME_CAP) this.frames.splice(0, this.frames.length - FRAME_CAP);
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// nominal viewer distance in metres — the one quantity the browser does not expose (see the
|
|
20
20
|
// design note §5). Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
|
|
21
21
|
|
|
22
|
+
import { invertAffine } from './inline3d-splat-rig-map.js';
|
|
23
|
+
|
|
22
24
|
/** handle.setLayerRig's rigs. 'camera' is the default every layer starts on. */
|
|
23
25
|
export const LAYER_RIGS = Object.freeze(['display', 'camera']);
|
|
24
26
|
|
|
@@ -189,7 +191,7 @@ export function roundViews(entries, frame, k, into = []) {
|
|
|
189
191
|
o.eye[1] = Eround[1];
|
|
190
192
|
o.eye[2] = Eround[2];
|
|
191
193
|
mul4(o.Minv, P, o.viewInv);
|
|
192
|
-
mul4(
|
|
194
|
+
mul4(invertAffine(P), o.M, o.view); // P is rigid, or affine once remapped to the declared rig (./inline3d-splat-rig-map.js)
|
|
193
195
|
}
|
|
194
196
|
return into;
|
|
195
197
|
}
|
|
@@ -402,7 +404,7 @@ export class LayerRigCameras {
|
|
|
402
404
|
cam.camera.camera.setXrProperties({ ...f, horizontalFov: false });
|
|
403
405
|
}
|
|
404
406
|
// The node drives transparent sorting (the views ignore it): on the (round) first eye.
|
|
405
|
-
const p = entries[0].pose;
|
|
407
|
+
const p = entries[0].node || entries[0].pose; // `node`: a rigid pose when the views are remapped
|
|
406
408
|
const q = quatOf(p);
|
|
407
409
|
cam.setLocalRotation(q[0], q[1], q[2], q[3]);
|
|
408
410
|
if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
|
|
@@ -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;
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
70
70
|
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
|
|
71
71
|
import { LayerRigCameras, validateLayerRig } from './inline3d-splat-layer-rig.js';
|
|
72
|
+
import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
|
|
72
73
|
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
|
|
73
74
|
import {
|
|
74
75
|
clamp,
|
|
@@ -823,6 +824,16 @@ export class PlayCanvasSplatViewer {
|
|
|
823
824
|
this.layerRigSource = null;
|
|
824
825
|
/** That descriptor as it stood when THIS frame's views were pulled (snapshotted in onFrame). */
|
|
825
826
|
this._rigSnap = null;
|
|
827
|
+
/**
|
|
828
|
+
* The rigs this tile declared and which one each frame's views were located for
|
|
829
|
+
* (./inline3d-splat-rig-map.js). Null = the kill switch (diag 'oldrig'): views drawn as
|
|
830
|
+
* located, the live outgoing on its pre-1.24 node chain.
|
|
831
|
+
*/
|
|
832
|
+
this.rigTrack = null;
|
|
833
|
+
/** This frame's rig mapping: { located, eye, remapped, entries } (see _mapViews). */
|
|
834
|
+
this.rigFrame = null;
|
|
835
|
+
this._eyeMap = [];
|
|
836
|
+
this._rigAtPull = null;
|
|
826
837
|
this._eyeSplit = NaN;
|
|
827
838
|
this.boxAspect = 1;
|
|
828
839
|
this.featherPx = feather > 0 ? feather : 0;
|
|
@@ -1136,6 +1147,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1136
1147
|
// The rig these views were located with: Blink chained the rig declared BEFORE this callback,
|
|
1137
1148
|
// and the tick below may declare a new one (a focus ease) for the NEXT locate.
|
|
1138
1149
|
if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
|
|
1150
|
+
// Same moment, for the rig map: the rig these views were most likely located for (a tie-break).
|
|
1151
|
+
this._rigAtPull = this.rigTrack ? this.rigTrack.latest : null;
|
|
1139
1152
|
// BEFORE the tick: a pose the page sets in here is the one this very frame renders.
|
|
1140
1153
|
this._beforeFrame?.(views || null);
|
|
1141
1154
|
this._tick();
|
|
@@ -1588,6 +1601,44 @@ export class PlayCanvasSplatViewer {
|
|
|
1588
1601
|
}
|
|
1589
1602
|
}
|
|
1590
1603
|
|
|
1604
|
+
/**
|
|
1605
|
+
* This frame's rig mapping (./inline3d-splat-rig-map.js): which declared rig the views were
|
|
1606
|
+
* located for (`located`), which the current photo wants (`eye`, the last declared), and — only
|
|
1607
|
+
* when those differ and both are camera rigs — the views remapped to `eye` (`entries`: proj,
|
|
1608
|
+
* viewInv, view per view; `cull`: the same as { proj, pose } entries; `node`: the first eye's
|
|
1609
|
+
* rigid pose). Otherwise `remapped` is false and the views are drawn exactly as located.
|
|
1610
|
+
*/
|
|
1611
|
+
_mapViews(entries, prefer = null) {
|
|
1612
|
+
const rt = this.rigTrack;
|
|
1613
|
+
const rf = (this.rigFrame ||= { located: null, eye: null, remapped: false, entries: null, cull: [], node: new Float64Array(16) });
|
|
1614
|
+
rf.remapped = false;
|
|
1615
|
+
rf.located = rf.eye = null;
|
|
1616
|
+
if (!rt || this._mode !== '3d' || entries.length < 2) return rf;
|
|
1617
|
+
rf.located = rt.locate(entries, prefer);
|
|
1618
|
+
rf.eye = rt.latest;
|
|
1619
|
+
const L = rf.located;
|
|
1620
|
+
const T = rf.eye;
|
|
1621
|
+
if (!L || !T?.portal || L === T || sameRig(L.rig, T.rig)) return rf;
|
|
1622
|
+
const r = remapViews(entries, L.portal, T.portal, this._eyeMap);
|
|
1623
|
+
if (!r) return rf;
|
|
1624
|
+
rf.remapped = true;
|
|
1625
|
+
rf.entries = r;
|
|
1626
|
+
rf.cull.length = r.length;
|
|
1627
|
+
for (let i = 0; i < r.length; i++) {
|
|
1628
|
+
const c = (rf.cull[i] ||= {});
|
|
1629
|
+
c.proj = r[i].proj;
|
|
1630
|
+
c.pose = r[i].viewInv;
|
|
1631
|
+
c.x = r[i].x;
|
|
1632
|
+
c.y = r[i].y;
|
|
1633
|
+
c.width = r[i].width;
|
|
1634
|
+
c.height = r[i].height;
|
|
1635
|
+
c.node = null;
|
|
1636
|
+
}
|
|
1637
|
+
nodePose(T.portal, r[0].eye, rf.node);
|
|
1638
|
+
rf.cull[0].node = rf.node; // a rigid pose for a camera NODE (the views are affine)
|
|
1639
|
+
return rf;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1591
1642
|
/**
|
|
1592
1643
|
* The mono/capture camera as a lens frame, for the live outgoing's rig chain
|
|
1593
1644
|
* (./inline3d-splat-live.js): the rig node's matrix, the camera pose in rig space, the
|
|
@@ -1612,6 +1663,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1612
1663
|
*/
|
|
1613
1664
|
cullViews(cam, entries, rect, into = []) {
|
|
1614
1665
|
const parent = cam?.parent?.getWorldTransform?.()?.data ?? null;
|
|
1666
|
+
// A camera drawing remapped views (./inline3d-splat-rig-map.js) publishes them as _dxrViews.
|
|
1667
|
+
if (cam?._dxrViews?.length === entries.length) entries = cam._dxrViews;
|
|
1615
1668
|
into.length = entries.length;
|
|
1616
1669
|
for (let i = 0; i < entries.length; i++) {
|
|
1617
1670
|
const e = entries[i];
|
|
@@ -1625,7 +1678,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1625
1678
|
o.W[0] = C[3]; o.W[1] = C[7]; o.W[2] = C[11]; o.W[3] = C[15];
|
|
1626
1679
|
o.K[0] = w * e.proj[0];
|
|
1627
1680
|
o.K[1] = 1 / w;
|
|
1628
|
-
|
|
1681
|
+
// The view's largest column scale² (1 for a rigid view; a remapped view is affine).
|
|
1682
|
+
o.K[2] = Math.max(V[0] * V[0] + V[1] * V[1] + V[2] * V[2], V[4] * V[4] + V[5] * V[5] + V[6] * V[6], V[8] * V[8] + V[9] * V[9] + V[10] * V[10]);
|
|
1629
1683
|
o.K[3] = 0;
|
|
1630
1684
|
}
|
|
1631
1685
|
return into;
|
|
@@ -1857,12 +1911,18 @@ export class PlayCanvasSplatViewer {
|
|
|
1857
1911
|
for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
|
|
1858
1912
|
this.eye.camera.camera.xrViews = rvs.slice();
|
|
1859
1913
|
}
|
|
1914
|
+
// The current photo through ITS rig (the last declared): the views are remapped only while
|
|
1915
|
+
// they were located for another one (a declaration still in flight, ./inline3d-splat-rig-map.js).
|
|
1916
|
+
const rf = this._mapViews(entries, cache ? cache.rigAt : null);
|
|
1917
|
+
const eyeViews = rf.remapped ? rf.entries : null;
|
|
1860
1918
|
for (let i = 0; i < entries.length; i++) {
|
|
1861
1919
|
const e = entries[i];
|
|
1862
|
-
rvs[i].setView(
|
|
1920
|
+
if (eyeViews) rvs[i].setView(eyeViews[i].proj, eyeViews[i].viewInv, eyeViews[i].view);
|
|
1921
|
+
else rvs[i].setView(e.proj, e.pose);
|
|
1863
1922
|
const [x, y, w, h] = rect(e);
|
|
1864
1923
|
rvs[i].setViewport(x, y, w, h);
|
|
1865
1924
|
}
|
|
1925
|
+
this.eye._dxrViews = eyeViews ? rf.cull : null;
|
|
1866
1926
|
// LOD and FOV-compensation read camera.fov/near/far, which under xrViews come from the
|
|
1867
1927
|
// XR properties — the frustum the views actually have, as XrManager does it.
|
|
1868
1928
|
const f = frustumFromProjection(entries[0].proj);
|
|
@@ -1873,13 +1933,16 @@ export class PlayCanvasSplatViewer {
|
|
|
1873
1933
|
}
|
|
1874
1934
|
// The camera NODE drives the sort direction and LOD distance; the views ignore it (they
|
|
1875
1935
|
// compose the node's PARENT with their own pose). Park it on the first eye.
|
|
1876
|
-
placeNode(this.eye, entries[0].pose);
|
|
1877
|
-
// setSource's live outgoing: the same views on its own camera
|
|
1878
|
-
|
|
1936
|
+
placeNode(this.eye, eyeViews ? rf.node : entries[0].pose);
|
|
1937
|
+
// setSource's live outgoing: the same views on its own camera (through ITS photo's rig),
|
|
1938
|
+
// into its own target.
|
|
1939
|
+
if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f, rf);
|
|
1879
1940
|
// setSource's wavefront: this frame's eye views, as the engine is about to compose them.
|
|
1880
1941
|
this.onBeforeRender?.(entries, rect);
|
|
1881
1942
|
// handle.setLayerRig: the display / post run cameras, on the same views (display: rounded).
|
|
1882
|
-
if (this.layerRigs && this.layerRigs.sync())
|
|
1943
|
+
if (this.layerRigs && this.layerRigs.sync()) {
|
|
1944
|
+
this.layerRigs.frame(eyeViews ? rf.cull : entries, rect, f, eyeViews ? rf.eye.rig : rf.located ? rf.located.rig : cache ? cache.rig : null);
|
|
1945
|
+
}
|
|
1883
1946
|
} else {
|
|
1884
1947
|
// Fallback: one camera per view, `rect` + `calculateProjection`.
|
|
1885
1948
|
const cams = this._views;
|
|
@@ -1938,6 +2001,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1938
2001
|
g.bufW = el.width || 0;
|
|
1939
2002
|
g.bufH = el.height || 0;
|
|
1940
2003
|
g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
|
|
2004
|
+
g.rigAt = this._rigAtPull ?? null;
|
|
1941
2005
|
for (let i = 0; i < views.length; i++) {
|
|
1942
2006
|
const e = g.entries[i];
|
|
1943
2007
|
const vp = vps[i];
|
|
@@ -2869,6 +2933,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2869
2933
|
let rigLockArmed = false;
|
|
2870
2934
|
if (diag) {
|
|
2871
2935
|
diag.imageState = () => viewer.diagImageState();
|
|
2936
|
+
diag.rigState = () => {
|
|
2937
|
+
const rf = viewer.rigFrame;
|
|
2938
|
+
if (!viewer.rigTrack || !rf) return null;
|
|
2939
|
+
return {
|
|
2940
|
+
at: rf.located ? rf.located.id : null,
|
|
2941
|
+
in: rf.eye ? `${rf.eye.id}${rf.remapped ? '+' : ''}` : null,
|
|
2942
|
+
out: viewer._live?.active ? viewer._live.path : null,
|
|
2943
|
+
};
|
|
2944
|
+
};
|
|
2872
2945
|
diag.observeLongTasks();
|
|
2873
2946
|
diag.observeLongFrames();
|
|
2874
2947
|
diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
|
|
@@ -3109,6 +3182,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3109
3182
|
// toward `target` at the viewer's FOCUS_EASE unless snapped.
|
|
3110
3183
|
const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
|
|
3111
3184
|
|
|
3185
|
+
let initialViewRig = null; // the rig addScene is created with (controls:'page'), else the shorthand
|
|
3112
3186
|
if (wall && wall.supported) {
|
|
3113
3187
|
const onFrame = diag
|
|
3114
3188
|
? (views, layer) => {
|
|
@@ -3121,7 +3195,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3121
3195
|
// controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
|
|
3122
3196
|
// and the waterfall say otherwise); the display rig's height means nothing there.
|
|
3123
3197
|
...(pageMode
|
|
3124
|
-
? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
|
|
3198
|
+
? { viewRig: (initialViewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth })) }
|
|
3125
3199
|
: { virtualDisplayHeight }),
|
|
3126
3200
|
onLayerLost: viewer.onLayerLost,
|
|
3127
3201
|
...(observe ? { observe } : {}),
|
|
@@ -3130,6 +3204,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3130
3204
|
} else {
|
|
3131
3205
|
viewer.startMono();
|
|
3132
3206
|
}
|
|
3207
|
+
// Every rig this tile declares, by VALUE (./inline3d-splat-rig-map.js): which one a frame's
|
|
3208
|
+
// views were located for is read off the views, so each photo can be drawn through its own rig
|
|
3209
|
+
// while a declaration is in flight or while a live outgoing photo is still on screen.
|
|
3210
|
+
// `?dxrdiag=oldrig` turns it off (views drawn as located; the live outgoing's node chain).
|
|
3211
|
+
if (handle && typeof handle.setViewRig === 'function' && !diag?.has('oldrig')) {
|
|
3212
|
+
const rt = (viewer.rigTrack = new RigTracker());
|
|
3213
|
+
rt.note(initialViewRig || { type: 'display', virtualDisplayHeight });
|
|
3214
|
+
const push = handle.setViewRig;
|
|
3215
|
+
handle.setViewRig = (rig) => {
|
|
3216
|
+
const r = push.call(handle, rig);
|
|
3217
|
+
rt.note(rig);
|
|
3218
|
+
return r;
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
3133
3221
|
let diagLoop = null;
|
|
3134
3222
|
if (diag) {
|
|
3135
3223
|
// Every rig push, logged with its values; under `norig`, every push after the first
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// inline3d-splat-rig-map.js — each photo through ITS OWN camera rig, whatever camera rig the
|
|
2
|
+
// runtime's views were located for. Internal to ./inline3d-splat-playcanvas.js (and
|
|
3
|
+
// ./inline3d-splat-live.js).
|
|
4
|
+
//
|
|
5
|
+
// THE BUG THIS FIXES. A live transition (crossfade, wavefront, the particle transitions) keeps the
|
|
6
|
+
// outgoing photo on screen while the incoming photo's rig is declared. The runtime's views are
|
|
7
|
+
// then the INCOMING rig's, and the outgoing photo was mapped back to its own framing by a node
|
|
8
|
+
// chain N = R_o·K_o·D_o·D_n⁻¹·K_n⁻¹, D = diag(c·t, c·t, c). That maps the incoming WINDOW onto
|
|
9
|
+
// the outgoing one exactly, but it scales the EYES with it: a camera rig's eyes do not scale with
|
|
10
|
+
// its window (they are the viewer's eyes, times metersToVirtual), so the outgoing photo was drawn
|
|
11
|
+
// from eyes scaled by (c_o·t_o)/(c_n·t_n) — its disparity and head parallax jumped by that factor
|
|
12
|
+
// the moment the rig switched. And a declaration reaches the views a frame or more later, so for
|
|
13
|
+
// those frames the chain mapped views that were still the OUTGOING rig's (and the incoming photo
|
|
14
|
+
// was drawn through them).
|
|
15
|
+
//
|
|
16
|
+
// NO KOOIMA HERE, and no frustum is built. The runtime's projection matrices are used verbatim
|
|
17
|
+
// (only their depth rows are rescaled, as clampProjectionDepth already does). What this does is
|
|
18
|
+
// exact algebra on the runtime's outputs and the descriptors we declared:
|
|
19
|
+
//
|
|
20
|
+
// 1. A camera rig is a PORTAL (displayxr-common's dxr_camera3d_compute_view): every view looks,
|
|
21
|
+
// from its own eye, through ONE window rectangle on the convergence plane — centre
|
|
22
|
+
// Wc = N0 + D·fwd, half-height t·D (t = tan(vfov/2)), half-width aspect·t·D — and its eye is
|
|
23
|
+
// N0 + R·m·(π·c̄ + ι·(q − c̄)) (q the viewer's eye relative to the nominal viewer, c̄ their
|
|
24
|
+
// centroid, π/ι the parallax/ipd factors, m metersToVirtual).
|
|
25
|
+
// 2. WHICH rig a view set was located for is read off the views: under the right descriptor
|
|
26
|
+
// every view's frustum crosses the window plane exactly in the window (`viewResidual`). The
|
|
27
|
+
// candidates are the descriptors declared recently (`RigTracker`); none within tolerance ⇒
|
|
28
|
+
// unknown ⇒ nothing is remapped (the caller keeps its old path).
|
|
29
|
+
// 3. From the located rig F to a target rig T, per view: the eye maps exactly (step 1 inverted
|
|
30
|
+
// and re-applied — the eye centroid and offsets come from the views themselves), and the
|
|
31
|
+
// affine map A_i that sends F's window onto T's (in-plane scale σ = t_T·D_T / t_F·D_F, the
|
|
32
|
+
// same for x and y: both windows have the canvas's aspect) and F's eye onto T's eye maps
|
|
33
|
+
// every ray from F's eye through a window point onto the ray from T's eye through the
|
|
34
|
+
// matching window point. So
|
|
35
|
+
//
|
|
36
|
+
// viewInv_T,i = A_i · viewInv_F,i proj_T,i = proj_F,i (depth rows rescaled)
|
|
37
|
+
//
|
|
38
|
+
// renders EXACTLY what T's own view i would (same pixel for every point: both frusta are
|
|
39
|
+
// the pyramid through the window, and A_i maps one pyramid onto the other with the window
|
|
40
|
+
// rectangle onto the window rectangle). Vertical FOV, convergence, metersToVirtual and the
|
|
41
|
+
// two factors may all differ. Only the depth mapping moves (A_i scales depth by a_z), which
|
|
42
|
+
// is why the near/far planes are divided by a_z: the target keeps the runtime's near/far.
|
|
43
|
+
//
|
|
44
|
+
// A display rig is NOT mapped: its eyes are absolute (the viewer's z includes the nominal
|
|
45
|
+
// distance, which the browser does not expose), so display ↔ camera would need that one scalar.
|
|
46
|
+
// Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
|
|
47
|
+
|
|
48
|
+
/** A view set matches a rig when every residual is below this, in window HALF-HEIGHTS (≈0.2 px at 720 px). */
|
|
49
|
+
export const RIG_MATCH_TOL = 5e-4;
|
|
50
|
+
|
|
51
|
+
/** How many recently declared rigs the tracker keeps as candidates. */
|
|
52
|
+
export const RIG_HISTORY = 8;
|
|
53
|
+
|
|
54
|
+
const FIELDS = ['convergenceDiopters', 'verticalFov', 'metersToVirtual', 'ipdFactor', 'parallaxFactor', 'virtualDisplayHeight', 'perspectiveFactor'];
|
|
55
|
+
|
|
56
|
+
/** Rotate v by unit quaternion q (xyzw). */
|
|
57
|
+
function rotate(q, v) {
|
|
58
|
+
const [x, y, z, w] = q;
|
|
59
|
+
const tx = 2 * (y * v[2] - z * v[1]);
|
|
60
|
+
const ty = 2 * (z * v[0] - x * v[2]);
|
|
61
|
+
const tz = 2 * (x * v[1] - y * v[0]);
|
|
62
|
+
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)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
66
|
+
|
|
67
|
+
/** A value copy of a declared descriptor (the SDK rewrites its camera-rig object in place), or null. */
|
|
68
|
+
export function rigSnapshot(rig) {
|
|
69
|
+
if (!rig || typeof rig !== 'object') return null;
|
|
70
|
+
const p = rig.position || {};
|
|
71
|
+
const q = rig.orientation || {};
|
|
72
|
+
const o = {
|
|
73
|
+
type: rig.type,
|
|
74
|
+
position: { x: +p.x || 0, y: +p.y || 0, z: +p.z || 0 },
|
|
75
|
+
orientation: { x: +q.x || 0, y: +q.y || 0, z: +q.z || 0, w: q.w === undefined ? 1 : +q.w },
|
|
76
|
+
};
|
|
77
|
+
for (const k of FIELDS) if (rig[k] !== undefined) o[k] = rig[k];
|
|
78
|
+
return o;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Same declared values (snapshots)? */
|
|
82
|
+
export function sameRig(a, b) {
|
|
83
|
+
if (a === b) return true;
|
|
84
|
+
if (!a || !b || a.type !== b.type) return false;
|
|
85
|
+
for (const k of ['x', 'y', 'z']) if (a.position[k] !== b.position[k]) return false;
|
|
86
|
+
for (const k of ['x', 'y', 'z', 'w']) if (a.orientation[k] !== b.orientation[k]) return false;
|
|
87
|
+
for (const k of FIELDS) if (a[k] !== b[k]) return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The portal of a CAMERA-rig descriptor, or null (a display rig, convergence at infinity, a
|
|
93
|
+
* factor of 0 — nothing to invert — or a non-finite field).
|
|
94
|
+
* @returns {{ N0: number[], q: number[], right: number[], up: number[], back: number[], Wc: number[], D: number, t: number, m: number, ipd: number, par: number }|null}
|
|
95
|
+
*/
|
|
96
|
+
export function cameraPortal(rig) {
|
|
97
|
+
if (!rig || rig.type !== 'camera') return null;
|
|
98
|
+
const inv = rig.convergenceDiopters;
|
|
99
|
+
const vfov = rig.verticalFov;
|
|
100
|
+
if (!(Number.isFinite(inv) && inv > 0) || !(Number.isFinite(vfov) && vfov > 0 && vfov < Math.PI)) return null;
|
|
101
|
+
const m = Number.isFinite(rig.metersToVirtual) && rig.metersToVirtual > 0 ? rig.metersToVirtual : 1;
|
|
102
|
+
const ipd = rig.ipdFactor ?? 1;
|
|
103
|
+
const par = rig.parallaxFactor ?? 1;
|
|
104
|
+
if (!(Number.isFinite(ipd) && ipd > 0) || !(Number.isFinite(par) && par > 0)) return null;
|
|
105
|
+
const p = rig.position || {};
|
|
106
|
+
const o = rig.orientation || {};
|
|
107
|
+
let q = [+o.x || 0, +o.y || 0, +o.z || 0, o.w === undefined ? 1 : +o.w];
|
|
108
|
+
const qn = Math.hypot(q[0], q[1], q[2], q[3]);
|
|
109
|
+
if (!(qn > 0)) return null;
|
|
110
|
+
q = q.map((c) => c / qn);
|
|
111
|
+
const N0 = [+p.x || 0, +p.y || 0, +p.z || 0];
|
|
112
|
+
const right = rotate(q, [1, 0, 0]);
|
|
113
|
+
const up = rotate(q, [0, 1, 0]);
|
|
114
|
+
const back = rotate(q, [0, 0, 1]);
|
|
115
|
+
const D = 1 / inv;
|
|
116
|
+
const Wc = [N0[0] - D * back[0], N0[1] - D * back[1], N0[2] - D * back[2]];
|
|
117
|
+
return { N0, q, right, up, back, Wc, D, t: Math.tan(vfov / 2), m, ipd, par };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Window-local coordinates (right, up, toward the viewer) of a point, about the window centre. */
|
|
121
|
+
function local(P, X) {
|
|
122
|
+
const d = [X[0] - P.Wc[0], X[1] - P.Wc[1], X[2] - P.Wc[2]];
|
|
123
|
+
return [dot(P.right, d), dot(P.up, d), dot(P.back, d)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* How far one runtime view is from being a view of portal P, in window half-heights: its
|
|
128
|
+
* rotation must be P's, and its frustum must cross P's window plane exactly in the window
|
|
129
|
+
* (centred on Wc, t·D tall). Reads the projection's off-axis tangents; builds nothing.
|
|
130
|
+
*/
|
|
131
|
+
export function viewResidual(entry, P) {
|
|
132
|
+
const m = entry.pose;
|
|
133
|
+
const pr = entry.proj;
|
|
134
|
+
// rotation: the pose's columns against the rig's axes (unit vectors; 1e-7 is float32 noise)
|
|
135
|
+
const rot = Math.max(
|
|
136
|
+
Math.abs(m[0] - P.right[0]), Math.abs(m[1] - P.right[1]), Math.abs(m[2] - P.right[2]),
|
|
137
|
+
Math.abs(m[4] - P.up[0]), Math.abs(m[5] - P.up[1]), Math.abs(m[6] - P.up[2]),
|
|
138
|
+
Math.abs(m[8] - P.back[0]), Math.abs(m[9] - P.back[1]), Math.abs(m[10] - P.back[2]),
|
|
139
|
+
);
|
|
140
|
+
if (!(pr[0] > 0) || !(pr[5] > 0) || Math.abs(pr[11] + 1) > 1e-6 || Math.abs(pr[4]) > 1e-6) return Infinity;
|
|
141
|
+
const u = local(P, [m[12], m[13], m[14]]);
|
|
142
|
+
if (!(u[2] > 0)) return Infinity;
|
|
143
|
+
const tl = (pr[8] - 1) / pr[0];
|
|
144
|
+
const tr = (pr[8] + 1) / pr[0];
|
|
145
|
+
const tb = (pr[9] - 1) / pr[5];
|
|
146
|
+
const tt = (pr[9] + 1) / pr[5];
|
|
147
|
+
const h = P.t * P.D;
|
|
148
|
+
const cx = u[0] + 0.5 * (tl + tr) * u[2];
|
|
149
|
+
const cy = u[1] + 0.5 * (tb + tt) * u[2];
|
|
150
|
+
const hy = 0.5 * (tt - tb) * u[2];
|
|
151
|
+
return Math.max(rot, Math.abs(cx) / h, Math.abs(cy) / h, Math.abs(hy - h) / h);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The worst view's residual for portal P (Infinity when P is null). */
|
|
155
|
+
export function viewsResidual(entries, P) {
|
|
156
|
+
if (!P || !entries?.length) return Infinity;
|
|
157
|
+
let r = 0;
|
|
158
|
+
for (const e of entries) r = Math.max(r, viewResidual(e, P));
|
|
159
|
+
return r;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function mul4(a, b, out = new Float64Array(16)) {
|
|
163
|
+
const r = new Float64Array(16);
|
|
164
|
+
for (let c = 0; c < 4; c++) {
|
|
165
|
+
for (let row = 0; row < 4; row++) {
|
|
166
|
+
r[c * 4 + row] = a[row] * b[c * 4] + a[4 + row] * b[c * 4 + 1] + a[8 + row] * b[c * 4 + 2] + a[12 + row] * b[c * 4 + 3];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
out.set(r);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Inverse of an affine column-major 4×4 (last row 0 0 0 1). Null when singular. */
|
|
174
|
+
export function invertAffine(m, out = new Float64Array(16)) {
|
|
175
|
+
const a = m[0], b = m[4], c = m[8];
|
|
176
|
+
const d = m[1], e = m[5], f = m[9];
|
|
177
|
+
const g = m[2], h = m[6], i = m[10];
|
|
178
|
+
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
|
|
179
|
+
const det = a * A + b * B + c * C;
|
|
180
|
+
if (!(Math.abs(det) > 1e-300)) return null;
|
|
181
|
+
const k = 1 / det;
|
|
182
|
+
const r00 = A * k, r01 = -(b * i - c * h) * k, r02 = (b * f - c * e) * k;
|
|
183
|
+
const r10 = B * k, r11 = (a * i - c * g) * k, r12 = -(a * f - c * d) * k;
|
|
184
|
+
const r20 = C * k, r21 = -(a * h - b * g) * k, r22 = (a * e - b * d) * k;
|
|
185
|
+
const tx = m[12], ty = m[13], tz = m[14];
|
|
186
|
+
out[0] = r00; out[1] = r10; out[2] = r20; out[3] = 0;
|
|
187
|
+
out[4] = r01; out[5] = r11; out[6] = r21; out[7] = 0;
|
|
188
|
+
out[8] = r02; out[9] = r12; out[10] = r22; out[11] = 0;
|
|
189
|
+
out[12] = -(r00 * tx + r01 * ty + r02 * tz);
|
|
190
|
+
out[13] = -(r10 * tx + r11 * ty + r12 * tz);
|
|
191
|
+
out[14] = -(r20 * tx + r21 * ty + r22 * tz);
|
|
192
|
+
out[15] = 1;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The eyes of target portal T for the same physical eyes that produced these views of portal F
|
|
198
|
+
* (dxr_display3d_apply_eye_factors_n inverted for F, re-applied for T). Rig space.
|
|
199
|
+
*/
|
|
200
|
+
export function mapEyes(entries, F, T) {
|
|
201
|
+
const n = entries.length;
|
|
202
|
+
const l = [];
|
|
203
|
+
const c = [0, 0, 0];
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
const d = [e.pose[12] - F.N0[0], e.pose[13] - F.N0[1], e.pose[14] - F.N0[2]];
|
|
206
|
+
const v = [dot(F.right, d) / F.m, dot(F.up, d) / F.m, dot(F.back, d) / F.m];
|
|
207
|
+
l.push(v);
|
|
208
|
+
c[0] += v[0] / n; c[1] += v[1] / n; c[2] += v[2] / n;
|
|
209
|
+
}
|
|
210
|
+
const kc = T.par / F.par;
|
|
211
|
+
const ko = T.ipd / F.ipd;
|
|
212
|
+
return l.map((v) => {
|
|
213
|
+
const w = [0, 1, 2].map((k) => T.m * (kc * c[k] + ko * (v[k] - c[k])));
|
|
214
|
+
return [0, 1, 2].map((k) => T.N0[k] + T.right[k] * w[0] + T.up[k] * w[1] + T.back[k] * w[2]);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The affine map A (column-major 4×4) that sends F's window onto T's window (in-plane scale σ)
|
|
220
|
+
* and eye Ef onto eye Et. Null when either eye is not in front of its window.
|
|
221
|
+
*/
|
|
222
|
+
export function portalMap(F, T, Ef, Et, sigma = (T.t * T.D) / (F.t * F.D)) {
|
|
223
|
+
const uf = local(F, Ef);
|
|
224
|
+
const ut = local(T, Et);
|
|
225
|
+
if (!(uf[2] > 0) || !(ut[2] > 0)) return null;
|
|
226
|
+
// L (window-local, F → T): [σ 0 ax; 0 σ ay; 0 0 az], with L·uf = ut.
|
|
227
|
+
const ax = (ut[0] - sigma * uf[0]) / uf[2];
|
|
228
|
+
const ay = (ut[1] - sigma * uf[1]) / uf[2];
|
|
229
|
+
const az = ut[2] / uf[2];
|
|
230
|
+
// A(X) = Wc_T + B_T · L · B_Fᵀ · (X − Wc_F), B = [right up back] (columns).
|
|
231
|
+
const BT = [T.right, T.up, T.back];
|
|
232
|
+
const BF = [F.right, F.up, F.back];
|
|
233
|
+
const Lc = [[sigma, 0, 0], [0, sigma, 0], [ax, ay, az]]; // columns of L
|
|
234
|
+
// M = B_T · L · B_Fᵀ: column j = Σ_k (B_T · L)_col_k · BF[k][j]
|
|
235
|
+
const TL = Lc.map((col) => [0, 1, 2].map((r) => BT[0][r] * col[0] + BT[1][r] * col[1] + BT[2][r] * col[2]));
|
|
236
|
+
const A = new Float64Array(16);
|
|
237
|
+
for (let j = 0; j < 3; j++) {
|
|
238
|
+
for (let r = 0; r < 3; r++) A[j * 4 + r] = TL[0][r] * BF[0][j] + TL[1][r] * BF[1][j] + TL[2][r] * BF[2][j];
|
|
239
|
+
}
|
|
240
|
+
for (let r = 0; r < 3; r++) A[12 + r] = T.Wc[r] - (A[r] * F.Wc[0] + A[4 + r] * F.Wc[1] + A[8 + r] * F.Wc[2]);
|
|
241
|
+
A[15] = 1;
|
|
242
|
+
return { A, az };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Rewrite a projection's depth rows for near/far divided by `k` (the map's depth scale). */
|
|
246
|
+
function scaleDepth(P, k, out) {
|
|
247
|
+
out.set(P);
|
|
248
|
+
if (!(k > 0) || k === 1) return out;
|
|
249
|
+
if (Math.abs(P[11] + 1) > 1e-6 || Math.abs(P[15]) > 1e-6) return out; // not a perspective: leave it
|
|
250
|
+
const n = P[14] / (P[10] - 1);
|
|
251
|
+
if (Math.abs(P[10] + 1) < 1e-9) {
|
|
252
|
+
out[14] = -2 * (n / k); // infinite far: only the near plane moves
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
const f = P[14] / (P[10] + 1);
|
|
256
|
+
const n2 = n / k;
|
|
257
|
+
const f2 = f / k;
|
|
258
|
+
out[10] = -(f2 + n2) / (f2 - n2);
|
|
259
|
+
out[14] = (-2 * f2 * n2) / (f2 - n2);
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* These views (located for portal F) as portal T's views. Per view: { proj, viewInv, view, eye }
|
|
265
|
+
* — what RenderView.setView takes, and the target eye (rig space) for the camera node. Null when
|
|
266
|
+
* there is nothing to do (F and T the same portal) or the map does not exist (an eye at or behind
|
|
267
|
+
* a window): the caller keeps the views as they are.
|
|
268
|
+
*/
|
|
269
|
+
export function remapViews(entries, F, T, into = []) {
|
|
270
|
+
if (!F || !T || !entries?.length) return null;
|
|
271
|
+
if (F === T) return null;
|
|
272
|
+
const Et = mapEyes(entries, F, T);
|
|
273
|
+
const sigma = (T.t * T.D) / (F.t * F.D);
|
|
274
|
+
into.length = entries.length;
|
|
275
|
+
for (let i = 0; i < entries.length; i++) {
|
|
276
|
+
const e = entries[i];
|
|
277
|
+
const Ef = [e.pose[12], e.pose[13], e.pose[14]];
|
|
278
|
+
const pm = portalMap(F, T, Ef, Et[i], sigma);
|
|
279
|
+
if (!pm) return null;
|
|
280
|
+
const o = (into[i] ||= { proj: new Float64Array(16), viewInv: new Float64Array(16), view: new Float64Array(16), eye: [0, 0, 0] });
|
|
281
|
+
mul4(pm.A, e.pose, o.viewInv);
|
|
282
|
+
if (!invertAffine(o.viewInv, o.view)) return null;
|
|
283
|
+
scaleDepth(e.proj, pm.az, o.proj);
|
|
284
|
+
o.eye[0] = Et[i][0];
|
|
285
|
+
o.eye[1] = Et[i][1];
|
|
286
|
+
o.eye[2] = Et[i][2];
|
|
287
|
+
o.x = e.x;
|
|
288
|
+
o.y = e.y;
|
|
289
|
+
o.width = e.width;
|
|
290
|
+
o.height = e.height;
|
|
291
|
+
}
|
|
292
|
+
return into;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Two portals with the same window (pose, convergence, vertical FOV) — only their eyes can differ. */
|
|
296
|
+
export function sameWindow(a, b) {
|
|
297
|
+
return a.D === b.D && a.t === b.t && a.N0.every((x, k) => x === b.N0[k]) && a.q.every((x, k) => x === b.q[k]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Rigid pose matrix (column-major) from a portal's orientation at `eye` — a camera node's pose. */
|
|
301
|
+
export function nodePose(T, eye, out = new Float64Array(16)) {
|
|
302
|
+
out[0] = T.right[0]; out[1] = T.right[1]; out[2] = T.right[2]; out[3] = 0;
|
|
303
|
+
out[4] = T.up[0]; out[5] = T.up[1]; out[6] = T.up[2]; out[7] = 0;
|
|
304
|
+
out[8] = T.back[0]; out[9] = T.back[1]; out[10] = T.back[2]; out[11] = 0;
|
|
305
|
+
out[12] = eye[0]; out[13] = eye[1]; out[14] = eye[2]; out[15] = 1;
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The rigs this tile declared, newest last, and which one a view set was located for.
|
|
311
|
+
* `note(rig)` on every declaration; `locate(entries)` per drawn frame.
|
|
312
|
+
*/
|
|
313
|
+
export class RigTracker {
|
|
314
|
+
constructor({ history = RIG_HISTORY, tol = RIG_MATCH_TOL } = {}) {
|
|
315
|
+
this.history = history;
|
|
316
|
+
this.tol = tol;
|
|
317
|
+
/** [{ id, rig (snapshot), portal (or null) }], oldest first. */
|
|
318
|
+
this.rigs = [];
|
|
319
|
+
this._seq = 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A declaration (the value, not the object: the SDK rewrites its descriptor in place). */
|
|
323
|
+
note(rig) {
|
|
324
|
+
const snap = rigSnapshot(rig);
|
|
325
|
+
const last = this.rigs[this.rigs.length - 1];
|
|
326
|
+
if (last && sameRig(last.rig, snap)) return last;
|
|
327
|
+
const entry = { id: ++this._seq, rig: snap, portal: cameraPortal(snap) };
|
|
328
|
+
this.rigs.push(entry);
|
|
329
|
+
if (this.rigs.length > this.history) this.rigs.splice(0, this.rigs.length - this.history);
|
|
330
|
+
return entry;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The rig declared last (what the drawing photo wants now), or null. */
|
|
334
|
+
get latest() {
|
|
335
|
+
return this.rigs[this.rigs.length - 1] || null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The declared camera rig these views were located for, or null when none matches within
|
|
340
|
+
* tolerance (a display rig, a runtime clamp, a rig older than the history).
|
|
341
|
+
*
|
|
342
|
+
* The views pin down the WINDOW (pose, convergence, vertical FOV) exactly, but not the eye
|
|
343
|
+
* factors: any eyes are consistent with a window (metersToVirtual, ipdFactor and
|
|
344
|
+
* parallaxFactor only move the eyes). When several candidates match — rigs that differ ONLY in
|
|
345
|
+
* those — `prefer` wins if it is among them (the rig declared when these views were pulled:
|
|
346
|
+
* Blink's one frame of latency), else the newest.
|
|
347
|
+
*/
|
|
348
|
+
locate(entries, prefer = null) {
|
|
349
|
+
let best = null;
|
|
350
|
+
let bestR = Infinity;
|
|
351
|
+
const matches = [];
|
|
352
|
+
for (let i = this.rigs.length - 1; i >= 0; i--) {
|
|
353
|
+
const c = this.rigs[i];
|
|
354
|
+
if (!c.portal) continue;
|
|
355
|
+
const r = viewsResidual(entries, c.portal);
|
|
356
|
+
if (r <= this.tol) matches.push(c); // newest first
|
|
357
|
+
if (r < bestR) {
|
|
358
|
+
bestR = r;
|
|
359
|
+
best = c;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
this.lastResidual = bestR;
|
|
363
|
+
if (!best || !(bestR <= this.tol)) return null;
|
|
364
|
+
// Among the rigs sharing the best match's window (they differ only in the eye factors):
|
|
365
|
+
// `prefer` if it is one of them, else the newest.
|
|
366
|
+
const group = matches.filter((c) => sameWindow(c.portal, best.portal));
|
|
367
|
+
return group.includes(prefer) ? prefer : group[0];
|
|
368
|
+
}
|
|
369
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"js/inline3d-splat-diag.js",
|
|
56
56
|
"js/inline3d-splat-video.js",
|
|
57
57
|
"js/inline3d-splat-layer-rig.js",
|
|
58
|
+
"js/inline3d-splat-rig-map.js",
|
|
58
59
|
"js/inline3d-model.js",
|
|
59
60
|
"js/inline3d-model-entry.js",
|
|
60
61
|
"js/inline3d-model-playcanvas.js",
|
package/splat.d.ts
CHANGED
|
@@ -277,11 +277,12 @@ export interface SplatOptions {
|
|
|
277
277
|
* A string / array adds A/B kill switches (also accepted as `?dxrdiag=norig,frozen`): `'norig'`
|
|
278
278
|
* keeps the rig declared before the first setSource (drops every re-declaration), `'frozen'`
|
|
279
279
|
* forces `outgoing: 'frozen'`, `'nowarm'` skips the transition shader pre-warm, `'cold'` skips
|
|
280
|
-
* the live outgoing pre-sort, `'nooverlay'` records without the overlay
|
|
280
|
+
* the live outgoing pre-sort, `'nooverlay'` records without the overlay, `'oldrig'` draws the
|
|
281
|
+
* views as located (no rig tracking; the live outgoing photo on the pre-1.24 chain). The option wins over the
|
|
281
282
|
* URL; `false` turns it off even with `?dxrdiag` present. See docs/playcanvas-adapter.md
|
|
282
283
|
* § Diagnosing transition stalls.
|
|
283
284
|
*/
|
|
284
|
-
diag?: boolean | string | ReadonlyArray<'norig' | 'frozen' | 'nowarm' | 'cold' | 'nooverlay' | '1'>;
|
|
285
|
+
diag?: boolean | string | ReadonlyArray<'norig' | 'frozen' | 'nowarm' | 'cold' | 'nooverlay' | 'oldpick' | 'nolayerrig' | 'oldrig' | '1'>;
|
|
285
286
|
/**
|
|
286
287
|
* `engine: 'playcanvas'` only: the WebGL context's `preserveDrawingBuffer` (default false) —
|
|
287
288
|
* the knob for the weave's zero-copy read race on large canvases.
|