@displayxr/inline3d 1.19.2 → 1.20.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 +48 -0
- package/README.md +3 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/js/inline3d-model-playcanvas.js +2 -2
- package/js/inline3d-splat-diag.js +599 -0
- package/js/inline3d-splat-live.js +81 -3
- package/js/inline3d-splat-playcanvas.js +142 -9
- package/js/inline3d-splat-shared.js +32 -0
- package/js/inline3d-splat.js +2 -1
- package/package.json +2 -1
- package/splat.d.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,54 @@ 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
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel change at rest; the
|
|
11
|
+
first frames of a live `crossfade` / `wavefront` / particle transition change (see Fixed).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Transition diagnostics: `diag` / `?dxrdiag`** (#36). Off by default. Records every woven
|
|
16
|
+
frame (interval, whether its eye poses are bit-identical to the previous frame's, what the
|
|
17
|
+
transition overlay shows), every `setViewRig` push with its values, long tasks and the
|
|
18
|
+
`setSource` phases. Shows a small overlay excluded from the weave, logs one
|
|
19
|
+
`[dxr-diag] transition #N <verdict>` line per transition, and exposes `window.__dxrDiag`
|
|
20
|
+
(`copy(__dxrDiag.dump())`). Kill switches for A/B: `norig`, `frozen`, `nowarm`, `cold`,
|
|
21
|
+
`nooverlay`. See `docs/playcanvas-adapter.md` § Diagnosing transition stalls.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **The live outgoing photo no longer starts as a still** (#36). A live transition put the old
|
|
26
|
+
photo on a FRESH gsplat manager at the swap, and until that manager's first sort came back the
|
|
27
|
+
overlay showed the frozen capture at full weight: the whole picture stood still for 2–7 frames
|
|
28
|
+
right as the transition started. The live camera now pre-sorts the current photo BEFORE the swap
|
|
29
|
+
(it stays on screen, untouched, meanwhile), so the overlay samples the live image from the swap
|
|
30
|
+
frame on: 0 frozen frames. The transition starts later by about the length of that sort instead
|
|
31
|
+
(bounded at 1.5 s, then the old bridge). `?dxrdiag=cold` restores the 1.19.2 path.
|
|
32
|
+
|
|
33
|
+
## 1.19.3 — 2026-09-24
|
|
34
|
+
|
|
35
|
+
Touches the **preview tier** (`./splat`, `./model`). No API change and no pixel change on a core that
|
|
36
|
+
has view rigs.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **A view rig the wall cannot take is no longer dropped silently** (#36).
|
|
41
|
+
- Every scene backend (`./splat` Spark and PlayCanvas, `./model` PlayCanvas) declared its rig
|
|
42
|
+
with `handle?.setViewRig?.(rig)`. On a handle from a core that predates view rigs, that call
|
|
43
|
+
does nothing.
|
|
44
|
+
- A page that vendors an older `inline3d.js` and hands that wall to `./splat` therefore had its
|
|
45
|
+
CAMERA rig thrown away. The wall wove the metric photo scene on the `addScene` display-rig
|
|
46
|
+
shorthand (`virtualDisplayHeight` 0.24): way off in convergence and scale in 3D, correct in 2D.
|
|
47
|
+
That is how the gallery's Spatial View wall regressed; the rig this SDK declared was the same
|
|
48
|
+
from 1.8.0 to 1.19.2.
|
|
49
|
+
- Now it logs one `console.warn` per page ("the view rig was dropped: your inline3d core
|
|
50
|
+
predates view rigs …") carrying the dropped rig.
|
|
51
|
+
- The Spark path used `handle?.setViewRig(…)` without the second `?.`, so it would have THROWN
|
|
52
|
+
there. It now takes the same warning path.
|
|
53
|
+
- Regression tests pin the declared rig for a camera-rig asset with `zoom {1, 2, relax}` and the
|
|
54
|
+
`assemble` reveal, at rest and after the reveal, on the fake engine.
|
|
55
|
+
|
|
8
56
|
## 1.19.2 — 2026-09-24
|
|
9
57
|
|
|
10
58
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change and no pixel
|
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ its own half, with no second canvas ([§setVideo](docs/playcanvas-adapter.md#set
|
|
|
55
55
|
optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
|
|
56
56
|
`lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
|
|
57
57
|
`playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
|
|
58
|
+
The repo's splat sample (`samples/splat/`) renders with PlayCanvas and keeps `?engine=spark` as
|
|
59
|
+
its A/B switch.
|
|
58
60
|
Bundlers: the engine is reached through a named-import module (`js/inline3d-playcanvas-engine.js`,
|
|
59
61
|
so the unused ~40% of the engine tree-shakes away); its sort workers are Blob URLs (CSP
|
|
60
62
|
`worker-src blob:`), and esbuild needs `node:worker_threads` marked external.
|
|
@@ -160,7 +162,7 @@ samples/
|
|
|
160
162
|
convergence + comfort, the attach pattern, C to A/B a display rig
|
|
161
163
|
windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
|
|
162
164
|
each woven with one SDK call, all on one session
|
|
163
|
-
splat/ a 3D Gaussian splat in a tile
|
|
165
|
+
splat/ a 3D Gaussian splat in a tile (PlayCanvas; ?engine=spark), with a 2D plate over it
|
|
164
166
|
model/ a glTF mesh, a mesh+splat scene, and a Draco-COMPRESSED glTF (PlayCanvas; ?engine=three)
|
|
165
167
|
composition/ the 14-case 2D/3D overlap matrix — demo AND standing hardware regression
|
|
166
168
|
surface; red cases ship red (see samples/README.md)
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -74,3 +74,15 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
74
74
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
75
75
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
76
76
|
THE SOFTWARE.
|
|
77
|
+
|
|
78
|
+
## Sample assets (repo samples only, not in the npm package)
|
|
79
|
+
|
|
80
|
+
The pages under `samples/` show these assets. None is in the published `@displayxr/inline3d`
|
|
81
|
+
package.
|
|
82
|
+
|
|
83
|
+
| Asset | Used by | Source | Credit and licence |
|
|
84
|
+
|---|---|---|---|
|
|
85
|
+
| Damaged Helmet (`DamagedHelmet.glb`) | `samples/model/` (tiles A and B), loaded at run time, not vendored | [KhronosGroup/glTF-Sample-Assets](https://github.com/KhronosGroup/glTF-Sample-Assets/tree/c6a6bd13ab2b3c685c7903d03561b8a9392f38b8/Models/DamagedHelmet) at commit `c6a6bd13ab2b3c685c7903d03561b8a9392f38b8`, via jsDelivr, unmodified | © 2018 ctxwing (rebuild and conversion to glTF 2.0), [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/legalcode). Earlier version of the model ("Battle Damaged Sci-fi Helmet") © 2016 theblueturtle_, [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/legalcode). As stated in the asset's `README.md` and `LICENSE.md`. |
|
|
86
|
+
| Duck, Draco variant (`samples/model/assets/glTF-Draco/`) | `samples/model/` (tile C) | Khronos glTF-Sample-Assets | © Sony, [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/). |
|
|
87
|
+
| Butterfly splat (`samples/splat/assets/butterfly.sog`) | `samples/splat/`, `samples/model/` (tile B) | [Spark](https://sparkjs.dev/) `assets/splats/butterfly.spz`, converted to SOG with PlayCanvas splat-transform (177,132 gaussians, 3 SH bands kept) | Butterfly splat © World Labs (from Spark, sparkjs.dev), used with permission; converted to SOG. |
|
|
88
|
+
| Draco decoder (`vendor/draco/`) | `samples/model/` | three.js `examples/jsm/libs/draco/` | © The Draco Authors, Apache-2.0. |
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
// └── eye camera, N RenderViews, TONEMAP_NEUTRAL, no sky layer
|
|
39
39
|
|
|
40
40
|
import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
|
|
41
|
-
import { resolveControls, normalizeCameraPose, now, MAX_DT_S } from './inline3d-splat-shared.js';
|
|
41
|
+
import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig } from './inline3d-splat-shared.js';
|
|
42
42
|
import {
|
|
43
43
|
NEUTRAL_STUDIO,
|
|
44
44
|
neutralStudioRadiance,
|
|
@@ -551,7 +551,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
551
551
|
if (dm > 0) d = dm / (ax.scale || 1);
|
|
552
552
|
}
|
|
553
553
|
out.viewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth }, out.viewRig || {});
|
|
554
|
-
handle
|
|
554
|
+
declareViewRig(handle, out.viewRig);
|
|
555
555
|
};
|
|
556
556
|
if (typeof opts.onBeforeFrame === 'function') {
|
|
557
557
|
const cb = opts.onBeforeFrame;
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// inline3d-splat-diag.js — transition diagnostics for the PlayCanvas splat handle (#36).
|
|
2
|
+
//
|
|
3
|
+
// Internal. Used by ./inline3d-splat-playcanvas.js only. Off unless the page asks for it:
|
|
4
|
+
// `addSplat(..., { engine: 'playcanvas', diag: true })`, or `?dxrdiag=1` on the page URL.
|
|
5
|
+
//
|
|
6
|
+
// WHY. "Head tracking stops for a moment at every photo transition" has four candidate causes
|
|
7
|
+
// that look identical on the panel, and only one of them is visible from a headless browser:
|
|
8
|
+
// - a MAIN-THREAD stall: the page misses session frames, so nothing new is drawn. Shows here as
|
|
9
|
+
// a gap in the session-frame intervals (and in the window rAF), usually with a long task.
|
|
10
|
+
// - a TRACKING hold: frames keep coming, but the eye poses in them do not move. The browser
|
|
11
|
+
// keeps the last good views when a locate reply comes back empty, and reuses the previous
|
|
12
|
+
// reply while its UI thread is busy. Shows here as frames whose XRView transforms are
|
|
13
|
+
// BIT-IDENTICAL to the previous frame's (a live eye tracker never repeats itself exactly).
|
|
14
|
+
// - a FROZEN IMAGE: frames come, poses move, but what is on screen is a still capture (the
|
|
15
|
+
// crossfade's frozen bridge, shown until the live outgoing camera has sorted). Shows here as
|
|
16
|
+
// frames where the transition overlay samples the frozen capture at a high weight.
|
|
17
|
+
// - a RIG change applied late or badly: every setViewRig push is logged with its values, so a
|
|
18
|
+
// pose jump or a hold can be lined up against the rig it followed.
|
|
19
|
+
// Each frame of the session is recorded with all four, so one run on the panel says which.
|
|
20
|
+
//
|
|
21
|
+
// KILL SWITCHES, for A/B on the panel (comma-separated, in `diag` or in `?dxrdiag=`):
|
|
22
|
+
// norig — the rig declared before the first setSource stays declared; every later
|
|
23
|
+
// re-declaration is DROPPED (logged as dropped). Tests "the rig change freezes it".
|
|
24
|
+
// frozen — force `outgoing: 'frozen'` (the 1.12.1 frozen outgoing photo) on every setSource.
|
|
25
|
+
// nowarm — skip the transition shader pre-warm (prepareSource / setSource compile nothing).
|
|
26
|
+
// cold — skip the live outgoing PRE-SORT (the 1.19.2 behaviour: the frozen capture bridges
|
|
27
|
+
// until a fresh manager has sorted).
|
|
28
|
+
// nooverlay — record + console + window.__dxrDiag, but no on-screen overlay.
|
|
29
|
+
|
|
30
|
+
/** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
|
|
31
|
+
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay']);
|
|
32
|
+
const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `diag` option (true | string | string[]) and/or the page query, into { on, switches }.
|
|
36
|
+
* The option wins when it is given (false turns diag off even with `?dxrdiag` on the URL).
|
|
37
|
+
* Unknown tokens are returned in `unknown` (warned once by the caller), never thrown on: a
|
|
38
|
+
* diagnostic flag must not be able to break a page.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveDiag(opt, search = pageSearch()) {
|
|
41
|
+
let raw = null;
|
|
42
|
+
if (opt === false) return { on: false, switches: new Set(), unknown: [] };
|
|
43
|
+
if (opt === true) raw = '1';
|
|
44
|
+
else if (typeof opt === 'string') raw = opt;
|
|
45
|
+
else if (Array.isArray(opt)) raw = opt.join(',');
|
|
46
|
+
else if (opt === undefined || opt === null) raw = queryValue(search);
|
|
47
|
+
if (raw === null) return { on: false, switches: new Set(), unknown: [] };
|
|
48
|
+
const switches = new Set();
|
|
49
|
+
const unknown = [];
|
|
50
|
+
for (const tok of String(raw).split(/[,\s+]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)) {
|
|
51
|
+
if (ON_TOKENS.has(tok)) continue;
|
|
52
|
+
if (tok === '0' || tok === 'off' || tok === 'false') return { on: false, switches: new Set(), unknown: [] };
|
|
53
|
+
if (DIAG_SWITCHES.includes(tok)) switches.add(tok);
|
|
54
|
+
else unknown.push(tok);
|
|
55
|
+
}
|
|
56
|
+
return { on: true, switches, unknown };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pageSearch() {
|
|
60
|
+
try {
|
|
61
|
+
return typeof location !== 'undefined' && typeof location.search === 'string' ? location.search : '';
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `?dxrdiag=...` (every occurrence, joined), or null when absent. `?dxrdiag` alone = on. */
|
|
68
|
+
function queryValue(search) {
|
|
69
|
+
if (!search) return null;
|
|
70
|
+
let q;
|
|
71
|
+
try {
|
|
72
|
+
q = new URLSearchParams(search);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!q.has('dxrdiag')) return null;
|
|
77
|
+
const all = q.getAll('dxrdiag').map((v) => v || '1');
|
|
78
|
+
return all.join(',');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One frame's eye-pose metrics against the previous frame's views.
|
|
83
|
+
* held — every view's transform AND projection bit-identical to the previous frame (a hold:
|
|
84
|
+
* a live tracker never repeats itself to the last bit).
|
|
85
|
+
* delta — the largest eye-position move, in the views' world units.
|
|
86
|
+
* ipd — the eye separation this frame, same units (it scales with the rig).
|
|
87
|
+
* rel — delta / ipd: head motion in eye separations, comparable across rigs.
|
|
88
|
+
* `prev` is the previous frame's packed state (or null); returns { m, state }, `state` being
|
|
89
|
+
* this frame's packed copy for the next call.
|
|
90
|
+
*/
|
|
91
|
+
export function poseDelta(views, prev) {
|
|
92
|
+
const n = views ? views.length : 0;
|
|
93
|
+
const state = new Float64Array(n * 32);
|
|
94
|
+
for (let i = 0; i < n; i++) {
|
|
95
|
+
const t = views[i]?.transform?.matrix;
|
|
96
|
+
const p = views[i]?.projectionMatrix;
|
|
97
|
+
for (let k = 0; k < 16; k++) {
|
|
98
|
+
state[i * 32 + k] = t ? t[k] : NaN;
|
|
99
|
+
state[i * 32 + 16 + k] = p ? p[k] : NaN;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const m = { views: n, held: false, delta: NaN, ipd: NaN, rel: NaN };
|
|
103
|
+
if (n >= 2) {
|
|
104
|
+
const dx = state[12] - state[32 + 12];
|
|
105
|
+
const dy = state[13] - state[32 + 13];
|
|
106
|
+
const dz = state[14] - state[32 + 14];
|
|
107
|
+
m.ipd = Math.hypot(dx, dy, dz);
|
|
108
|
+
}
|
|
109
|
+
if (prev && prev.length === state.length && n > 0) {
|
|
110
|
+
let same = true;
|
|
111
|
+
for (let k = 0; k < state.length; k++) {
|
|
112
|
+
if (!Object.is(state[k], prev[k])) {
|
|
113
|
+
same = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
m.held = same;
|
|
118
|
+
let d = 0;
|
|
119
|
+
for (let i = 0; i < n; i++) {
|
|
120
|
+
const o = i * 32;
|
|
121
|
+
d = Math.max(d, Math.hypot(state[o + 12] - prev[o + 12], state[o + 13] - prev[o + 13], state[o + 14] - prev[o + 14]));
|
|
122
|
+
}
|
|
123
|
+
m.delta = d;
|
|
124
|
+
m.rel = m.ipd > 0 ? d / m.ipd : NaN;
|
|
125
|
+
}
|
|
126
|
+
return { m, state };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A view rig descriptor, summarised for the log (numbers rounded; nothing else kept). */
|
|
130
|
+
export function summarizeRig(rig) {
|
|
131
|
+
if (!rig || typeof rig !== 'object') return { type: rig === null ? 'none' : typeof rig };
|
|
132
|
+
const r4 = (x) => (Number.isFinite(x) ? Math.round(x * 1e4) / 1e4 : x);
|
|
133
|
+
const out = {};
|
|
134
|
+
const walk = (o, prefix, depth) => {
|
|
135
|
+
for (const [k, v] of Object.entries(o)) {
|
|
136
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
137
|
+
if (typeof v === 'number') out[key] = r4(v);
|
|
138
|
+
else if (typeof v === 'string' || typeof v === 'boolean') out[key] = v;
|
|
139
|
+
else if (v && typeof v === 'object' && depth < 2 && !ArrayBuffer.isView(v)) walk(v, key, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(rig, '', 0);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Phase names, in order: prepare (prepareSource), swap (setSource → adopt), window, settle. */
|
|
147
|
+
export const DIAG_PHASES = Object.freeze(['idle', 'prepare', 'swap', 'window', 'settle']);
|
|
148
|
+
|
|
149
|
+
const FRAME_CAP = 1800; // ~30 s at 60 Hz
|
|
150
|
+
const EVENT_CAP = 600;
|
|
151
|
+
const SETTLE_MS = 1000; // a transition's summary covers its window + this much after it
|
|
152
|
+
const PRE_MS = 300; // ... and this much before the setSource call
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The recorder. `now` is injectable (tests); everything else is plain data, so the recorder runs
|
|
156
|
+
* (and is tested) without a DOM. The overlay (attachOverlay) is optional on top.
|
|
157
|
+
*/
|
|
158
|
+
export class DiagRecorder {
|
|
159
|
+
constructor({ switches = new Set(), now = () => performance.now(), log = defaultLog, label = 'splat' } = {}) {
|
|
160
|
+
this.switches = switches;
|
|
161
|
+
this.now = now;
|
|
162
|
+
this.log = log;
|
|
163
|
+
this.label = label;
|
|
164
|
+
this.frames = [];
|
|
165
|
+
this.events = [];
|
|
166
|
+
this.transitions = [];
|
|
167
|
+
this.phase = 'idle';
|
|
168
|
+
this.phaseAt = 0;
|
|
169
|
+
this.callAt = NaN; // the current / last setSource call
|
|
170
|
+
this._prevState = null;
|
|
171
|
+
this._lastFrameAt = NaN;
|
|
172
|
+
this._lastRafAt = NaN;
|
|
173
|
+
this._rigAt = -Infinity;
|
|
174
|
+
this._rigFrame = -Infinity;
|
|
175
|
+
this._frameNo = 0;
|
|
176
|
+
this._open = null; // the transition being recorded
|
|
177
|
+
this.imageState = null; // () => { overlay: 'none' | 'frozen' | 'live', w }
|
|
178
|
+
this.longTasks = [];
|
|
179
|
+
this._lto = null;
|
|
180
|
+
this.rigLocked = null; // norig: the summary of the rig kept
|
|
181
|
+
this._listeners = new Set();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
has(sw) {
|
|
185
|
+
return this.switches.has(sw);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Observe long tasks (where the browser has the API). */
|
|
189
|
+
observeLongTasks() {
|
|
190
|
+
try {
|
|
191
|
+
if (typeof PerformanceObserver !== 'function') return;
|
|
192
|
+
this._lto = new PerformanceObserver((list) => {
|
|
193
|
+
for (const e of list.getEntries()) this.longTask(e.startTime, e.duration);
|
|
194
|
+
});
|
|
195
|
+
this._lto.observe({ type: 'longtask', buffered: false });
|
|
196
|
+
} catch {
|
|
197
|
+
this._lto = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
longTask(start, duration) {
|
|
202
|
+
const lt = { s: round1(start), d: round1(duration) };
|
|
203
|
+
this.longTasks.push(lt);
|
|
204
|
+
if (this.longTasks.length > EVENT_CAP) this.longTasks.splice(0, this.longTasks.length - EVENT_CAP);
|
|
205
|
+
this._event('longtask', lt, start);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A window rAF tick (the diag's own loop): main-thread cadence independent of the session. */
|
|
209
|
+
raf(t = this.now()) {
|
|
210
|
+
const gap = Number.isFinite(this._lastRafAt) ? t - this._lastRafAt : NaN;
|
|
211
|
+
this._lastRafAt = t;
|
|
212
|
+
if (this._open && Number.isFinite(gap)) this._open.maxRafGap = Math.max(this._open.maxRafGap, gap);
|
|
213
|
+
return gap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One session frame (the woven path's onFrame), BEFORE the draw. */
|
|
217
|
+
frame(views, t = this.now()) {
|
|
218
|
+
const { m, state } = poseDelta(views, this._prevState);
|
|
219
|
+
this._prevState = state;
|
|
220
|
+
const dt = Number.isFinite(this._lastFrameAt) ? t - this._lastFrameAt : NaN;
|
|
221
|
+
this._lastFrameAt = t;
|
|
222
|
+
this._frameNo++;
|
|
223
|
+
const img = this.imageState ? safe(this.imageState) : null;
|
|
224
|
+
const f = {
|
|
225
|
+
n: this._frameNo,
|
|
226
|
+
t: round1(t),
|
|
227
|
+
dt: round1(dt),
|
|
228
|
+
phase: this.phase,
|
|
229
|
+
sinceCall: Number.isFinite(this.callAt) ? round1(t - this.callAt) : null,
|
|
230
|
+
views: m.views,
|
|
231
|
+
held: m.held,
|
|
232
|
+
delta: round6(m.delta),
|
|
233
|
+
rel: round4(m.rel),
|
|
234
|
+
ipd: round6(m.ipd),
|
|
235
|
+
// A rig push in the last 3 frames: a pose jump here is the rig, not the head.
|
|
236
|
+
afterRig: this._frameNo - this._rigFrame <= 3,
|
|
237
|
+
img: img ? img.overlay : 'none',
|
|
238
|
+
imgW: img ? round4(img.w) : 0,
|
|
239
|
+
};
|
|
240
|
+
this.frames.push(f);
|
|
241
|
+
if (this.frames.length > FRAME_CAP) this.frames.splice(0, this.frames.length - FRAME_CAP);
|
|
242
|
+
if (this._open) this._accumulate(this._open, f);
|
|
243
|
+
this._maybeClose(t);
|
|
244
|
+
this._emit();
|
|
245
|
+
return f;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A setViewRig push (or, under `norig`, a dropped one). */
|
|
249
|
+
rig(rig, { dropped = false } = {}) {
|
|
250
|
+
const t = this.now();
|
|
251
|
+
const s = summarizeRig(rig);
|
|
252
|
+
// controls:'page' re-declares every frame: only a CHANGED rig is an event.
|
|
253
|
+
const key = (dropped ? 'd' : 'p') + JSON.stringify(s);
|
|
254
|
+
if (key === this._lastRigKey) {
|
|
255
|
+
this.rigRepeats = (this.rigRepeats || 0) + 1;
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
this._lastRigKey = key;
|
|
259
|
+
this._rigAt = t;
|
|
260
|
+
if (!dropped) this._rigFrame = this._frameNo;
|
|
261
|
+
const ev = this._event(dropped ? 'rig-dropped' : 'rig', { rig: s }, t);
|
|
262
|
+
if (this._open) this._open.rigs.push({ at: round1(t - this._open.callAt), dropped, rig: s });
|
|
263
|
+
this.log(`${dropped ? 'rig DROPPED (norig)' : 'rig'} ${fmtT(this, t)} ${JSON.stringify(s)}`);
|
|
264
|
+
return ev;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Enter a phase ('prepare', 'swap', 'window', 'settle', 'idle'); `detail` is logged with it. */
|
|
268
|
+
setPhase(phase, detail) {
|
|
269
|
+
const t = this.now();
|
|
270
|
+
if (phase === 'swap') this._openTransition(t, detail);
|
|
271
|
+
this.phase = phase;
|
|
272
|
+
this.phaseAt = t;
|
|
273
|
+
if (this._open) this._open.phases.push({ phase, at: round1(t - this._open.callAt), ...(detail ? { detail } : {}) });
|
|
274
|
+
this._event('phase', { phase, ...(detail ? { detail } : {}) }, t);
|
|
275
|
+
this.log(`phase ${phase} ${fmtT(this, t)}${detail ? ' ' + JSON.stringify(detail) : ''}`);
|
|
276
|
+
if (phase === 'settle' && this._open) this._open.settleAt = t;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A named instant inside the current transition (e.g. 'presorted', 'live-shown'). */
|
|
280
|
+
mark(name, detail) {
|
|
281
|
+
const t = this.now();
|
|
282
|
+
if (this._open) this._open.marks.push({ name, at: round1(t - this._open.callAt), ...(detail ? { detail } : {}) });
|
|
283
|
+
this._event('mark', { name, ...(detail ? { detail } : {}) }, t);
|
|
284
|
+
this.log(`mark ${name} ${fmtT(this, t)}${detail ? ' ' + JSON.stringify(detail) : ''}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_openTransition(t, detail) {
|
|
288
|
+
if (this._open) this._close(this._open, t, 'superseded');
|
|
289
|
+
this.callAt = t;
|
|
290
|
+
this._open = {
|
|
291
|
+
id: this.transitions.length + 1,
|
|
292
|
+
callAt: t,
|
|
293
|
+
detail: detail || null,
|
|
294
|
+
settleAt: NaN,
|
|
295
|
+
phases: [],
|
|
296
|
+
marks: [],
|
|
297
|
+
rigs: [],
|
|
298
|
+
frames: 0,
|
|
299
|
+
maxFrameGap: 0,
|
|
300
|
+
maxRafGap: 0,
|
|
301
|
+
held: 0,
|
|
302
|
+
heldRun: 0,
|
|
303
|
+
heldRunMax: 0,
|
|
304
|
+
heldRunMaxMs: 0,
|
|
305
|
+
_heldRunStart: NaN,
|
|
306
|
+
frozenImg: 0,
|
|
307
|
+
frozenImgMs: 0,
|
|
308
|
+
_lastT: NaN,
|
|
309
|
+
firstFrameAt: NaN,
|
|
310
|
+
lastFrameAt: NaN,
|
|
311
|
+
window: [], // the frame records kept for the frozen overlay strip
|
|
312
|
+
};
|
|
313
|
+
// Frames already recorded in the lead-in (PRE_MS before the call) belong to it too.
|
|
314
|
+
for (const f of this.frames) if (f.t >= t - PRE_MS) this._open.window.push(f);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_accumulate(o, f) {
|
|
318
|
+
o.frames++;
|
|
319
|
+
o.window.push(f);
|
|
320
|
+
if (!Number.isFinite(o.firstFrameAt)) o.firstFrameAt = f.t;
|
|
321
|
+
o.lastFrameAt = f.t;
|
|
322
|
+
if (Number.isFinite(f.dt)) o.maxFrameGap = Math.max(o.maxFrameGap, f.dt);
|
|
323
|
+
if (f.held) {
|
|
324
|
+
o.held++;
|
|
325
|
+
if (o.heldRun === 0) o._heldRunStart = Number.isFinite(f.dt) ? f.t - f.dt : f.t;
|
|
326
|
+
o.heldRun++;
|
|
327
|
+
if (o.heldRun > o.heldRunMax) {
|
|
328
|
+
o.heldRunMax = o.heldRun;
|
|
329
|
+
o.heldRunMaxMs = round1(f.t - o._heldRunStart);
|
|
330
|
+
}
|
|
331
|
+
} else o.heldRun = 0;
|
|
332
|
+
if (f.img === 'frozen' && f.imgW >= 0.5) {
|
|
333
|
+
o.frozenImg++;
|
|
334
|
+
if (Number.isFinite(f.dt)) o.frozenImgMs = round1(o.frozenImgMs + f.dt);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_maybeClose(t) {
|
|
339
|
+
const o = this._open;
|
|
340
|
+
if (o && Number.isFinite(o.settleAt) && t - o.settleAt >= SETTLE_MS) this._close(o, t, 'settled');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Force the open transition's summary now (tests, or a page that wants it early). */
|
|
344
|
+
flush() {
|
|
345
|
+
if (this._open) this._close(this._open, this.now(), 'flushed');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_close(o, t, how) {
|
|
349
|
+
this._open = null;
|
|
350
|
+
if (how === 'settled' && this.phase === 'settle') {
|
|
351
|
+
this.phase = 'idle';
|
|
352
|
+
this.phaseAt = t;
|
|
353
|
+
}
|
|
354
|
+
const lts = this.longTasks.filter((e) => e.s + e.d > o.callAt - PRE_MS && e.s < t);
|
|
355
|
+
const s = {
|
|
356
|
+
id: o.id,
|
|
357
|
+
how,
|
|
358
|
+
detail: o.detail,
|
|
359
|
+
at: round1(o.callAt),
|
|
360
|
+
windowMs: Number.isFinite(o.settleAt) ? round1(o.settleAt - o.callAt) : null,
|
|
361
|
+
phases: o.phases,
|
|
362
|
+
marks: o.marks,
|
|
363
|
+
rigs: o.rigs,
|
|
364
|
+
frames: o.frames,
|
|
365
|
+
maxFrameGapMs: round1(o.maxFrameGap),
|
|
366
|
+
maxRafGapMs: round1(o.maxRafGap),
|
|
367
|
+
longTasks: lts.map((e) => ({ at: round1(e.s - o.callAt), d: e.d })),
|
|
368
|
+
longestTaskMs: lts.length ? Math.max(...lts.map((e) => e.d)) : 0,
|
|
369
|
+
heldFrames: o.held,
|
|
370
|
+
heldRunMax: o.heldRunMax,
|
|
371
|
+
heldRunMaxMs: o.heldRunMaxMs,
|
|
372
|
+
frozenImageFrames: o.frozenImg,
|
|
373
|
+
frozenImageMs: o.frozenImgMs,
|
|
374
|
+
};
|
|
375
|
+
s.verdict = verdict(s);
|
|
376
|
+
s.window = o.window.map((f) => ({ ...f, t: round1(f.t - o.callAt) }));
|
|
377
|
+
this.transitions.push(s);
|
|
378
|
+
if (this.transitions.length > 50) this.transitions.shift();
|
|
379
|
+
const { window: _w, ...brief } = s;
|
|
380
|
+
this.log(`transition #${s.id} ${s.verdict} ${JSON.stringify(brief)}`);
|
|
381
|
+
this._emit(s);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_event(type, data, t = this.now()) {
|
|
385
|
+
const ev = { ...data, type, t: round1(t), sinceCall: Number.isFinite(this.callAt) ? round1(t - this.callAt) : null };
|
|
386
|
+
this.events.push(ev);
|
|
387
|
+
if (this.events.length > EVENT_CAP) this.events.splice(0, this.events.length - EVENT_CAP);
|
|
388
|
+
return ev;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
onUpdate(cb) {
|
|
392
|
+
this._listeners.add(cb);
|
|
393
|
+
return () => this._listeners.delete(cb);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_emit(summary) {
|
|
397
|
+
for (const cb of this._listeners) safe(() => cb(summary));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Everything, as plain JSON (what David pastes back). */
|
|
401
|
+
toJSON() {
|
|
402
|
+
return {
|
|
403
|
+
label: this.label,
|
|
404
|
+
switches: [...this.switches],
|
|
405
|
+
rigLocked: this.rigLocked,
|
|
406
|
+
transitions: this.transitions,
|
|
407
|
+
events: this.events,
|
|
408
|
+
frames: this.frames,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
dump() {
|
|
413
|
+
return JSON.stringify(this.toJSON());
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
dispose() {
|
|
417
|
+
try {
|
|
418
|
+
this._lto?.disconnect();
|
|
419
|
+
} catch {
|
|
420
|
+
/* already gone */
|
|
421
|
+
}
|
|
422
|
+
this._lto = null;
|
|
423
|
+
this._listeners.clear();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The one-line verdict. Thresholds: a session-frame gap > 50 ms (three missed frames at 60 Hz) is
|
|
429
|
+
* a stall; 2+ consecutive bit-identical frames is a hold; 2+ frames of the frozen capture at
|
|
430
|
+
* weight >= 0.5 is a frozen image.
|
|
431
|
+
*/
|
|
432
|
+
export function verdict(s) {
|
|
433
|
+
const out = [];
|
|
434
|
+
if (s.heldRunMax >= 2) out.push(`TRACKING-HELD ${s.heldRunMax} frames (${s.heldRunMaxMs} ms) with frames still arriving`);
|
|
435
|
+
if (s.maxFrameGapMs > 50) out.push(`MAIN-THREAD gap ${s.maxFrameGapMs} ms (longest task ${s.longestTaskMs} ms)`);
|
|
436
|
+
if (s.frozenImageFrames >= 2) out.push(`IMAGE-FROZEN ${s.frozenImageFrames} frames (${s.frozenImageMs} ms) on the frozen capture`);
|
|
437
|
+
return out.length ? out.join(' + ') : 'CLEAN (no hold, no gap > 50 ms, no frozen image)';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function defaultLog(msg) {
|
|
441
|
+
if (typeof console !== 'undefined') console.info(`[dxr-diag] ${msg}`);
|
|
442
|
+
}
|
|
443
|
+
function fmtT(rec, t) {
|
|
444
|
+
return Number.isFinite(rec.callAt) ? `t=${round1(t - rec.callAt)}ms` : `t=${round1(t)}`;
|
|
445
|
+
}
|
|
446
|
+
function safe(fn) {
|
|
447
|
+
try {
|
|
448
|
+
return fn();
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const round1 = (x) => (Number.isFinite(x) ? Math.round(x * 10) / 10 : x);
|
|
454
|
+
const round4 = (x) => (Number.isFinite(x) ? Math.round(x * 1e4) / 1e4 : x);
|
|
455
|
+
const round6 = (x) => (Number.isFinite(x) ? Math.round(x * 1e6) / 1e6 : x);
|
|
456
|
+
|
|
457
|
+
// ── the on-screen overlay (2D DOM; the caller excludes it from the weave) ─────────────────────
|
|
458
|
+
|
|
459
|
+
const W = 360;
|
|
460
|
+
const STRIP_H = 44;
|
|
461
|
+
const WINDOW_MS = 3000;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A compact overlay, fixed to the viewport's bottom-left: two strips (the rolling last 3 s, and
|
|
465
|
+
* the last transition, frozen at its settle) plus a text line. Per session frame, one bar: its
|
|
466
|
+
* height is the frame interval (0–100 ms), its colour what the frame was — blue normal, RED held
|
|
467
|
+
* (bit-identical poses), ORANGE the frozen capture on screen, GREY a rig push just before; the
|
|
468
|
+
* white line is the head motion (delta / eye separation). Repainted at ~10 Hz by the diag's own
|
|
469
|
+
* window rAF, which also measures the main thread's cadence (also without the overlay).
|
|
470
|
+
* Returns { el, remove } (el null without the overlay).
|
|
471
|
+
*/
|
|
472
|
+
export function startDiagLoop(rec, { overlay = true, doc = typeof document !== 'undefined' ? document : null } = {}) {
|
|
473
|
+
const hasRaf = typeof requestAnimationFrame === 'function';
|
|
474
|
+
if (!overlay || !doc?.createElement || !doc.body) {
|
|
475
|
+
// No overlay: the window rAF cadence is still measured (the main thread's own clock).
|
|
476
|
+
let raf = 0;
|
|
477
|
+
const tick = (t) => {
|
|
478
|
+
rec.raf(t);
|
|
479
|
+
raf = requestAnimationFrame(tick);
|
|
480
|
+
};
|
|
481
|
+
if (hasRaf) raf = requestAnimationFrame(tick);
|
|
482
|
+
return { el: null, remove: () => raf && typeof cancelAnimationFrame === 'function' && cancelAnimationFrame(raf) };
|
|
483
|
+
}
|
|
484
|
+
const el = doc.createElement('div');
|
|
485
|
+
el.setAttribute('data-dxr-diag', '');
|
|
486
|
+
el.style.cssText =
|
|
487
|
+
'position:fixed;left:8px;bottom:8px;z-index:2147483647;width:' +
|
|
488
|
+
W +
|
|
489
|
+
'px;padding:6px;background:rgba(0,0,0,0.78);color:#e8e8e8;font:11px/1.3 ui-monospace,Menlo,Consolas,monospace;' +
|
|
490
|
+
'border-radius:6px;pointer-events:none;white-space:pre-wrap;';
|
|
491
|
+
const head = doc.createElement('div');
|
|
492
|
+
const cv = doc.createElement('canvas');
|
|
493
|
+
cv.width = W;
|
|
494
|
+
cv.height = STRIP_H * 2 + 18;
|
|
495
|
+
cv.style.cssText = `display:block;width:${W}px;height:${STRIP_H * 2 + 18}px;margin:4px 0`;
|
|
496
|
+
const foot = doc.createElement('div');
|
|
497
|
+
el.append(head, cv, foot);
|
|
498
|
+
doc.body.appendChild(el);
|
|
499
|
+
const g = cv.getContext && cv.getContext('2d');
|
|
500
|
+
let frozen = null; // the last transition's summary
|
|
501
|
+
let raf = 0;
|
|
502
|
+
let lastPaint = 0;
|
|
503
|
+
const off = rec.onUpdate((s) => {
|
|
504
|
+
if (s) frozen = s;
|
|
505
|
+
});
|
|
506
|
+
const strip = (frames, y0, t0, t1, callT) => {
|
|
507
|
+
if (!g) return;
|
|
508
|
+
g.fillStyle = '#111';
|
|
509
|
+
g.fillRect(0, y0, W, STRIP_H);
|
|
510
|
+
const x = (t) => ((t - t0) / (t1 - t0)) * W;
|
|
511
|
+
if (Number.isFinite(callT)) {
|
|
512
|
+
g.fillStyle = '#555';
|
|
513
|
+
g.fillRect(Math.round(x(callT)), y0, 1, STRIP_H);
|
|
514
|
+
}
|
|
515
|
+
let prev = null;
|
|
516
|
+
for (const f of frames) {
|
|
517
|
+
if (f.t < t0 || f.t > t1) continue;
|
|
518
|
+
const h = Math.min(STRIP_H, (Number.isFinite(f.dt) ? f.dt : 0) * (STRIP_H / 100));
|
|
519
|
+
g.fillStyle = f.held ? '#ff3b30' : f.img === 'frozen' && f.imgW >= 0.5 ? '#ff9f0a' : f.afterRig ? '#8e8e93' : '#0a84ff';
|
|
520
|
+
g.fillRect(Math.round(x(f.t)), y0 + STRIP_H - h, 2, h);
|
|
521
|
+
if (Number.isFinite(f.rel)) {
|
|
522
|
+
const yy = y0 + STRIP_H - Math.min(STRIP_H, f.rel * STRIP_H * 20);
|
|
523
|
+
if (prev) {
|
|
524
|
+
g.strokeStyle = '#fff';
|
|
525
|
+
g.beginPath();
|
|
526
|
+
g.moveTo(prev[0], prev[1]);
|
|
527
|
+
g.lineTo(x(f.t), yy);
|
|
528
|
+
g.stroke();
|
|
529
|
+
}
|
|
530
|
+
prev = [x(f.t), yy];
|
|
531
|
+
} else prev = null;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const paint = (t) => {
|
|
535
|
+
rec.raf(t);
|
|
536
|
+
raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(paint) : 0;
|
|
537
|
+
if (t - lastPaint < 100) return;
|
|
538
|
+
lastPaint = t;
|
|
539
|
+
const last = rec.frames[rec.frames.length - 1];
|
|
540
|
+
const now = last ? last.t : t;
|
|
541
|
+
head.textContent =
|
|
542
|
+
`dxr-diag ${rec.label} phase ${rec.phase}` +
|
|
543
|
+
(Number.isFinite(rec.callAt) ? ` +${Math.round(now - rec.callAt)} ms since setSource` : '') +
|
|
544
|
+
(rec.switches.size ? ` [${[...rec.switches].join(',')}]` : '') +
|
|
545
|
+
(last ? `\nframe dt ${last.dt} ms held ${last.held ? 'YES' : 'no'} img ${last.img}${last.img !== 'none' ? ' ' + last.imgW : ''}` : '');
|
|
546
|
+
strip(rec.frames, 0, now - WINDOW_MS, now, rec.callAt);
|
|
547
|
+
if (frozen && g) {
|
|
548
|
+
strip(frozen.window.map((f) => ({ ...f })), STRIP_H + 18, -PRE_MS, WINDOW_MS - PRE_MS, 0);
|
|
549
|
+
}
|
|
550
|
+
if (g) {
|
|
551
|
+
g.clearRect(0, STRIP_H, W, 18); // the label band: repainted, never accumulated
|
|
552
|
+
g.fillStyle = '#e8e8e8';
|
|
553
|
+
g.font = '10px ui-monospace, monospace';
|
|
554
|
+
g.fillText(frozen ? `last transition #${frozen.id} (frozen)` : 'last transition: none yet', 4, STRIP_H + 13);
|
|
555
|
+
}
|
|
556
|
+
foot.textContent = frozen ? `#${frozen.id}: ${frozen.verdict}` : 'red = held poses · orange = frozen image · bar = frame interval';
|
|
557
|
+
};
|
|
558
|
+
raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(paint) : 0;
|
|
559
|
+
return {
|
|
560
|
+
el,
|
|
561
|
+
remove() {
|
|
562
|
+
off();
|
|
563
|
+
if (raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf);
|
|
564
|
+
el.remove?.();
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* `window.__dxrDiag`: every diag tile on the page. `dump()` is the JSON to paste back;
|
|
571
|
+
* `last` the most recent tile's recorder; `transitions` every tile's transition summaries.
|
|
572
|
+
*/
|
|
573
|
+
export function registerDiag(rec) {
|
|
574
|
+
if (typeof window === 'undefined' || !window) return null;
|
|
575
|
+
let g = window.__dxrDiag;
|
|
576
|
+
if (!g || !Array.isArray(g.tiles)) {
|
|
577
|
+
g = window.__dxrDiag = {
|
|
578
|
+
tiles: [],
|
|
579
|
+
get last() {
|
|
580
|
+
return this.tiles[this.tiles.length - 1] || null;
|
|
581
|
+
},
|
|
582
|
+
get transitions() {
|
|
583
|
+
return this.tiles.flatMap((r) => r.transitions.map((s) => ({ tile: r.label, ...s })));
|
|
584
|
+
},
|
|
585
|
+
dump() {
|
|
586
|
+
return JSON.stringify({ ua: typeof navigator !== 'undefined' ? navigator.userAgent : null, at: new Date().toISOString(), tiles: this.tiles.map((r) => r.toJSON()) });
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
rec.label = `splat#${g.tiles.length + 1}`;
|
|
591
|
+
g.tiles.push(rec);
|
|
592
|
+
const dispose = rec.dispose.bind(rec);
|
|
593
|
+
rec.dispose = () => {
|
|
594
|
+
dispose();
|
|
595
|
+
const i = g.tiles.indexOf(rec);
|
|
596
|
+
if (i >= 0) g.tiles.splice(i, 1);
|
|
597
|
+
};
|
|
598
|
+
return g;
|
|
599
|
+
}
|
|
@@ -148,6 +148,9 @@ export class LiveOutgoing {
|
|
|
148
148
|
this.frames = 0;
|
|
149
149
|
this.views = [];
|
|
150
150
|
this._frustumKey = '';
|
|
151
|
+
/** PRE-SORT (warm): the current asset is ALSO on the live layer, before the swap. */
|
|
152
|
+
this.warming = false;
|
|
153
|
+
this._warmLayers = null;
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
/** Engine support for this path: RenderViews on one camera, render targets, layers. */
|
|
@@ -212,21 +215,94 @@ export class LiveOutgoing {
|
|
|
212
215
|
this.w = this.h = 0;
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* PRE-SORT the live camera BEFORE the swap (the fix for the frozen bridge, #36). The current
|
|
220
|
+
* asset goes on the live layer IN ADDITION to its own (the eye keeps drawing it, untouched: its
|
|
221
|
+
* World placement set is unchanged, so its manager neither rebuilds nor re-sorts), and the live
|
|
222
|
+
* camera starts rendering it, through the same views, into its target. Its manager builds its
|
|
223
|
+
* work buffer and runs its first sort NOW, while the photo is still on screen and live. At the
|
|
224
|
+
* swap, start() takes the asset OFF World only: the live layer's placement set does not change,
|
|
225
|
+
* so its manager keeps its sorted state and the overlay can sample it on the very first frame.
|
|
226
|
+
*
|
|
227
|
+
* Without it, start() moved the asset onto a FRESH manager at the swap, and the overlay showed
|
|
228
|
+
* the frozen capture until that manager's first sort came back from the sort worker — a still
|
|
229
|
+
* image under a moving head for as long as that takes (the "tracking stops for a moment at
|
|
230
|
+
* every transition" report). Returns false if this engine build cannot (nothing changed).
|
|
231
|
+
*/
|
|
232
|
+
warm(entity) {
|
|
233
|
+
const v = this.viewer;
|
|
234
|
+
if (this.active || !LiveOutgoing.supported(v) || !entity?.gsplat) return false;
|
|
235
|
+
if (this.warming) {
|
|
236
|
+
if (this.entity === entity) return true;
|
|
237
|
+
this.cancelWarm();
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
this._ensureEngineObjects();
|
|
241
|
+
this._ensureTarget(v.canvas.width, v.canvas.height);
|
|
242
|
+
const own = Array.isArray(entity.gsplat.layers) ? entity.gsplat.layers.slice() : [];
|
|
243
|
+
this._warmLayers = own;
|
|
244
|
+
entity.gsplat.layers = [...own.filter((id) => id !== this.layer.id), this.layer.id];
|
|
245
|
+
this.entity = entity;
|
|
246
|
+
this.oldFrame = null; // same asset, same rig: the chain is the rig node itself
|
|
247
|
+
this.frames = 0;
|
|
248
|
+
this.cam.enabled = true;
|
|
249
|
+
this.warming = true;
|
|
250
|
+
return true;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.warn('[inline3d/splat] live outgoing pre-sort unavailable; the transition keeps the frozen bridge', err);
|
|
253
|
+
this.cancelWarm();
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The pre-sorted manager has drawn a sorted frame (what start() will then need no bridge for). */
|
|
259
|
+
get warmReady() {
|
|
260
|
+
if (!this.warming) return false;
|
|
261
|
+
const mgr = this.manager;
|
|
262
|
+
if (mgr === undefined) return this.frames >= 3;
|
|
263
|
+
const w = mgr?.world;
|
|
264
|
+
const st = w?.getState?.(w.lastWorldStateVersion);
|
|
265
|
+
return !!st?.sortedBefore && this.frames >= 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Drop a pre-sort that did not become a live window: the asset back on only its own layers. */
|
|
269
|
+
cancelWarm() {
|
|
270
|
+
if (!this.warming) return;
|
|
271
|
+
const e = this.entity;
|
|
272
|
+
const own = this._warmLayers;
|
|
273
|
+
this.warming = false;
|
|
274
|
+
this._warmLayers = null;
|
|
275
|
+
this.entity = null;
|
|
276
|
+
if (this.cam) this.cam.enabled = false;
|
|
277
|
+
this._destroyTarget();
|
|
278
|
+
try {
|
|
279
|
+
if (e?.gsplat && own) e.gsplat.layers = own;
|
|
280
|
+
} catch {
|
|
281
|
+
/* entity already gone */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
215
285
|
/**
|
|
216
286
|
* Take `entity` (the outgoing splat, still enabled) onto the live layer. `oldFrame` is
|
|
217
287
|
* viewer.lensFrame() read BEFORE the incoming asset's rig was adopted. Returns false if this
|
|
218
|
-
* engine build cannot (the caller keeps the frozen frame).
|
|
288
|
+
* engine build cannot (the caller keeps the frozen frame). After warm(entity) the live
|
|
289
|
+
* manager is kept (only the World placement goes), so `ready` holds from the first frame.
|
|
219
290
|
*/
|
|
220
291
|
start(entity, oldFrame) {
|
|
221
292
|
const v = this.viewer;
|
|
222
293
|
if (!LiveOutgoing.supported(v) || !entity?.gsplat) return false;
|
|
294
|
+
const warmed = this.warming && this.entity === entity;
|
|
295
|
+
if (this.warming && !warmed) this.cancelWarm();
|
|
223
296
|
try {
|
|
224
297
|
this._ensureEngineObjects();
|
|
225
298
|
this._ensureTarget(v.canvas.width, v.canvas.height);
|
|
226
299
|
entity.gsplat.layers = [this.layer.id];
|
|
227
300
|
this.entity = entity;
|
|
228
301
|
this.oldFrame = oldFrame;
|
|
229
|
-
this.frames = 0;
|
|
302
|
+
if (!warmed) this.frames = 0;
|
|
303
|
+
this.warming = false;
|
|
304
|
+
this._warmLayers = null;
|
|
305
|
+
this.warmed = warmed;
|
|
230
306
|
this.cam.enabled = true;
|
|
231
307
|
this.active = true;
|
|
232
308
|
return true;
|
|
@@ -275,7 +351,7 @@ export class LiveOutgoing {
|
|
|
275
351
|
* viewport), the eye's node pose, and the rig chain.
|
|
276
352
|
*/
|
|
277
353
|
sync(entries, rect, frustum) {
|
|
278
|
-
if (!this.active) return;
|
|
354
|
+
if (!this.active && !this.warming) return;
|
|
279
355
|
const v = this.viewer;
|
|
280
356
|
const pc = v.pc;
|
|
281
357
|
const rvs = this.views;
|
|
@@ -322,9 +398,11 @@ export class LiveOutgoing {
|
|
|
322
398
|
stop() {
|
|
323
399
|
// The outgoing entity is NOT put back on its old layer: the caller releases it right after,
|
|
324
400
|
// and a round trip through World would dirty the eye's manager (a work-buffer rebuild).
|
|
401
|
+
if (this.warming) return this.cancelWarm();
|
|
325
402
|
this.entity = null;
|
|
326
403
|
this.oldFrame = null;
|
|
327
404
|
this.active = false;
|
|
405
|
+
this.warmed = false;
|
|
328
406
|
if (this.cam) this.cam.enabled = false;
|
|
329
407
|
this._destroyTarget();
|
|
330
408
|
}
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
} from './inline3d-splat-effects.js';
|
|
65
65
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
66
66
|
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
|
|
67
|
+
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag } from './inline3d-splat-diag.js';
|
|
67
68
|
import {
|
|
68
69
|
clamp,
|
|
69
70
|
finite,
|
|
@@ -99,6 +100,7 @@ import {
|
|
|
99
100
|
resolveControls,
|
|
100
101
|
normalizeCameraPose,
|
|
101
102
|
coverageExponent,
|
|
103
|
+
declareViewRig,
|
|
102
104
|
} from './inline3d-splat-shared.js';
|
|
103
105
|
|
|
104
106
|
/**
|
|
@@ -1405,6 +1407,11 @@ export class PlayCanvasSplatViewer {
|
|
|
1405
1407
|
if (!s?.parts) return false;
|
|
1406
1408
|
const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1407
1409
|
const show = alpha > 0 && fits;
|
|
1410
|
+
// What the overlay shows, for diagnostics (diagImageState): its weight on screen.
|
|
1411
|
+
const st = (this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false });
|
|
1412
|
+
st.alpha = show ? Math.min(1, alpha) : 0;
|
|
1413
|
+
st.wipe = wipe ? wipe.t : -2;
|
|
1414
|
+
st.over = !!mix?.over;
|
|
1408
1415
|
for (const p of s.parts) {
|
|
1409
1416
|
p.mi.visible = show;
|
|
1410
1417
|
if (show) {
|
|
@@ -1428,6 +1435,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1428
1435
|
setSnapshotSource(tex) {
|
|
1429
1436
|
const s = this._snap;
|
|
1430
1437
|
if (!s?.parts) return;
|
|
1438
|
+
(this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false }).live = !!tex;
|
|
1431
1439
|
for (const p of s.parts) {
|
|
1432
1440
|
p.mat.setParameter('dxrSnap', tex || s.tex);
|
|
1433
1441
|
p.mat.update();
|
|
@@ -1488,6 +1496,55 @@ export class PlayCanvasSplatViewer {
|
|
|
1488
1496
|
return this._live.start(entity, oldFrame) ? this._live : null;
|
|
1489
1497
|
}
|
|
1490
1498
|
|
|
1499
|
+
/**
|
|
1500
|
+
* Diagnostics: what the transition overlay puts on screen this frame — 'none', or the 'frozen'
|
|
1501
|
+
* capture / the 'live' outgoing target with `w`, the share of the picture it covers (the wipe's
|
|
1502
|
+
* uncommitted part times the lerp weight).
|
|
1503
|
+
*/
|
|
1504
|
+
diagImageState() {
|
|
1505
|
+
const st = this._snapState;
|
|
1506
|
+
if (!st || !(st.alpha > 0)) return { overlay: 'none', w: 0 };
|
|
1507
|
+
const left = st.wipe < -1 ? 1 : Math.min(1, Math.max(0, 1 - st.wipe));
|
|
1508
|
+
return { overlay: st.live ? 'live' : 'frozen', w: st.alpha * left };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** Pre-sort the live outgoing camera on `entity` before a swap (see LiveOutgoing.warm). */
|
|
1512
|
+
warmLiveOutgoing(entity) {
|
|
1513
|
+
this._live ||= new LiveOutgoing(this);
|
|
1514
|
+
return this._live.warm(entity);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Resolves true once the pre-sorted live manager has drawn a sorted frame, false after
|
|
1519
|
+
* `timeoutMs` (a hidden tab, a stalled sorter) or once the pre-sort was dropped.
|
|
1520
|
+
*/
|
|
1521
|
+
liveWarmed(timeoutMs = 1500) {
|
|
1522
|
+
const live = this._live;
|
|
1523
|
+
if (!live?.warming) return Promise.resolve(false);
|
|
1524
|
+
if (live.warmReady) return Promise.resolve(true);
|
|
1525
|
+
return new Promise((resolve) => {
|
|
1526
|
+
let done = false;
|
|
1527
|
+
const end = (v) => {
|
|
1528
|
+
if (done) return;
|
|
1529
|
+
done = true;
|
|
1530
|
+
clearTimeout(timer);
|
|
1531
|
+
resolve(v);
|
|
1532
|
+
};
|
|
1533
|
+
const timer = setTimeout(() => end(false), timeoutMs);
|
|
1534
|
+
this._hooks.push(() => {
|
|
1535
|
+
if (done) return false;
|
|
1536
|
+
if (this._disposed || !live.warming) return end(false), false;
|
|
1537
|
+
if (live.warmReady) return end(true), false;
|
|
1538
|
+
return true;
|
|
1539
|
+
});
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Drop a pre-sort that is not going to become a live window. */
|
|
1544
|
+
cancelLiveWarm() {
|
|
1545
|
+
this._live?.cancelWarm();
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1491
1548
|
/** End the live window: overlay back on the frozen capture, camera off, target freed. */
|
|
1492
1549
|
stopLiveOutgoing() {
|
|
1493
1550
|
if (!this._live?.active) return;
|
|
@@ -1672,7 +1729,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1672
1729
|
// compose the node's PARENT with their own pose). Park it on the first eye.
|
|
1673
1730
|
placeNode(this.eye, entries[0].pose);
|
|
1674
1731
|
// setSource's live outgoing: the same views on its own camera, into its own target.
|
|
1675
|
-
if (this._live?.active) this._live.sync(entries, rect, f);
|
|
1732
|
+
if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
|
|
1676
1733
|
// setSource's wavefront: this frame's eye views, as the engine is about to compose them.
|
|
1677
1734
|
this.onBeforeRender?.(entries, rect);
|
|
1678
1735
|
} else {
|
|
@@ -2348,7 +2405,6 @@ const SET_RIG_ENV_TONE_MAPPING = Object.freeze({ neutral: 'neutral', room: 'none
|
|
|
2348
2405
|
export const SET_RIG_TYPES = Object.freeze(['display', 'camera', 'auto']);
|
|
2349
2406
|
const SET_RIG_FITS = ['contain', 'cover', 'height', 'none'];
|
|
2350
2407
|
let warnedSetRigKeys = false;
|
|
2351
|
-
|
|
2352
2408
|
const VIDEO_BUSY = (what) =>
|
|
2353
2409
|
`@displayxr/inline3d/splat: ${what}() while a video is on — call handle.setVideo(null) first ` +
|
|
2354
2410
|
'(the video holds the display rig and hides the splat until it exits).';
|
|
@@ -2561,6 +2617,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2561
2617
|
|
|
2562
2618
|
let handle = null;
|
|
2563
2619
|
let unbindFocusInput = null;
|
|
2620
|
+
|
|
2621
|
+
// ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
|
|
2622
|
+
const diagCfg = resolveDiag(opts.diag);
|
|
2623
|
+
if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: norig, frozen, nowarm, cold, nooverlay.`);
|
|
2624
|
+
const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
|
|
2625
|
+
/** norig: armed by the first setSource — from then on the declared rig is kept. */
|
|
2626
|
+
let rigLockArmed = false;
|
|
2627
|
+
if (diag) {
|
|
2628
|
+
diag.imageState = () => viewer.diagImageState();
|
|
2629
|
+
diag.observeLongTasks();
|
|
2630
|
+
diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
|
|
2631
|
+
}
|
|
2564
2632
|
let removed = false;
|
|
2565
2633
|
/** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
|
|
2566
2634
|
const preparedAssets = new WeakMap();
|
|
@@ -2675,6 +2743,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2675
2743
|
fx = null;
|
|
2676
2744
|
unbindFocusInput?.();
|
|
2677
2745
|
viewer.onFocusChange = null;
|
|
2746
|
+
diagLoop?.remove();
|
|
2747
|
+
diag?.dispose();
|
|
2678
2748
|
handle?.remove();
|
|
2679
2749
|
viewer.dispose(); // app.destroy(): every entity — ours and any a page added — goes with it
|
|
2680
2750
|
},
|
|
@@ -2749,7 +2819,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2749
2819
|
const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
|
|
2750
2820
|
|
|
2751
2821
|
if (wall && wall.supported) {
|
|
2752
|
-
|
|
2822
|
+
const onFrame = diag
|
|
2823
|
+
? (views, layer) => {
|
|
2824
|
+
const t = performance.now();
|
|
2825
|
+
viewer.onFrame(views, layer);
|
|
2826
|
+
diag.frame(views, t); // after the draw: the overlay state is what this frame showed
|
|
2827
|
+
}
|
|
2828
|
+
: viewer.onFrame;
|
|
2829
|
+
handle = wall.addScene(canvas, onFrame, {
|
|
2753
2830
|
// controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
|
|
2754
2831
|
// and the waterfall say otherwise); the display rig's height means nothing there.
|
|
2755
2832
|
...(pageMode
|
|
@@ -2762,6 +2839,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2762
2839
|
} else {
|
|
2763
2840
|
viewer.startMono();
|
|
2764
2841
|
}
|
|
2842
|
+
let diagLoop = null;
|
|
2843
|
+
if (diag) {
|
|
2844
|
+
// Every rig push, logged with its values; under `norig`, every push after the first
|
|
2845
|
+
// setSource is DROPPED (the rig declared before it stays in force).
|
|
2846
|
+
if (handle && typeof handle.setViewRig === 'function') {
|
|
2847
|
+
const push = handle.setViewRig;
|
|
2848
|
+
handle.setViewRig = (rig) => {
|
|
2849
|
+
if (diag.has('norig') && rigLockArmed) {
|
|
2850
|
+
diag.rig(rig, { dropped: true });
|
|
2851
|
+
return false;
|
|
2852
|
+
}
|
|
2853
|
+
diag.rig(rig);
|
|
2854
|
+
return push(rig);
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
diagLoop = startDiagLoop(diag, { overlay: !diag.has('nooverlay') && !!handle });
|
|
2858
|
+
// 2D DOM over the woven canvas: out of the weave (a no-op where occlusion is automatic).
|
|
2859
|
+
if (diagLoop?.el) handle?.exclude?.(diagLoop.el);
|
|
2860
|
+
registerDiag(diag);
|
|
2861
|
+
}
|
|
2765
2862
|
// Settle the stub's `firstWoven` (addSplatDeferred) with the core handle's own.
|
|
2766
2863
|
if (typeof out._resolveFirstWoven === 'function') {
|
|
2767
2864
|
out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
|
|
@@ -2832,7 +2929,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2832
2929
|
},
|
|
2833
2930
|
out.viewRig || {},
|
|
2834
2931
|
);
|
|
2835
|
-
handle
|
|
2932
|
+
declareViewRig(handle, out.viewRig);
|
|
2836
2933
|
if (!out.rig) return;
|
|
2837
2934
|
out.rig.convergence = d;
|
|
2838
2935
|
if (Math.abs(d - pageFocus.fired) > 1e-3 || source !== pageFocus.source) {
|
|
@@ -2905,7 +3002,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2905
3002
|
out: camRigObj,
|
|
2906
3003
|
},
|
|
2907
3004
|
);
|
|
2908
|
-
handle
|
|
3005
|
+
declareViewRig(handle, out.viewRig);
|
|
2909
3006
|
}
|
|
2910
3007
|
viewer.onFocusChange = (f) => {
|
|
2911
3008
|
pushViewRig(false);
|
|
@@ -3195,7 +3292,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3195
3292
|
parallaxFactor: f.parallaxFactor ?? 1,
|
|
3196
3293
|
perspectiveFactor: f.perspectiveFactor ?? 1,
|
|
3197
3294
|
});
|
|
3198
|
-
handle
|
|
3295
|
+
declareViewRig(handle, out.viewRig);
|
|
3199
3296
|
}
|
|
3200
3297
|
|
|
3201
3298
|
/**
|
|
@@ -3601,7 +3698,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3601
3698
|
} else {
|
|
3602
3699
|
declaredDisplay = s.declaredDisplay;
|
|
3603
3700
|
out.viewRig = s.viewRig;
|
|
3604
|
-
handle
|
|
3701
|
+
declareViewRig(handle, out.viewRig);
|
|
3605
3702
|
}
|
|
3606
3703
|
} else out.viewRig = s.viewRig;
|
|
3607
3704
|
lastConvergence = s.lastConvergence;
|
|
@@ -3709,8 +3806,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3709
3806
|
await first.catch(() => null);
|
|
3710
3807
|
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
3711
3808
|
const t0 = performance.now();
|
|
3712
|
-
|
|
3809
|
+
if (diag && diag.phase === 'idle') diag.setPhase('prepare');
|
|
3810
|
+
let loaded;
|
|
3811
|
+
try {
|
|
3812
|
+
loaded = await loadOne(pcModule, app, src, { background: true });
|
|
3813
|
+
} finally {
|
|
3814
|
+
if (diag?.phase === 'prepare') diag.setPhase('idle');
|
|
3815
|
+
}
|
|
3713
3816
|
perfSpan('prepareSource', t0);
|
|
3817
|
+
diag?.mark('prepared', { ms: Math.round(performance.now() - t0) });
|
|
3714
3818
|
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
3715
3819
|
await prewarmTransition(warm); // null (no transition declared): the overlay only
|
|
3716
3820
|
const entry = { loaded, state: 'ready', dispose: null };
|
|
@@ -3753,6 +3857,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3753
3857
|
* engine whose internals differ just compiles on the transition's first frame, as before.
|
|
3754
3858
|
*/
|
|
3755
3859
|
async function prewarmTransition(plan) {
|
|
3860
|
+
if (diag?.has('nowarm')) return;
|
|
3756
3861
|
// The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
|
|
3757
3862
|
// (crossfade, wavefront and the particles all composite through them; created on the first
|
|
3758
3863
|
// capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
|
|
@@ -3877,8 +3982,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3877
3982
|
}
|
|
3878
3983
|
async function setSourceNow(next, o = {}) {
|
|
3879
3984
|
const plan = resolveSwap(o);
|
|
3985
|
+
if (diag?.has('frozen')) plan.outgoing = 'frozen'; // kill switch: the 1.12.1 frozen outgoing
|
|
3880
3986
|
const { resetPose = false } = o;
|
|
3881
3987
|
const gen = ++sourceGen;
|
|
3988
|
+
if (diag) {
|
|
3989
|
+
rigLockArmed = true;
|
|
3990
|
+
diag.setPhase('swap', { transition: plan.transition, outgoing: plan.outgoing || 'default', prepared: preparedAssets.has(next) });
|
|
3991
|
+
}
|
|
3882
3992
|
// A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
|
|
3883
3993
|
const prep = preparedAssets.get(next) || null;
|
|
3884
3994
|
if (prep) {
|
|
@@ -3910,6 +4020,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3910
4020
|
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
3911
4021
|
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
3912
4022
|
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
|
|
4023
|
+
const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
|
|
4024
|
+
// PRE-SORT the live outgoing camera on the current asset BEFORE the swap (LiveOutgoing.warm):
|
|
4025
|
+
// its first sort runs while the photo is still on screen and live, so the frozen capture has
|
|
4026
|
+
// nothing to bridge. The transition starts once it has sorted (bounded; a hidden tab or a
|
|
4027
|
+
// stalled sorter falls back to the bridge). `?dxrdiag=cold` skips it (the 1.19.2 path).
|
|
4028
|
+
if (wantsSnapshot && outgoingMode === 'live' && viewer.canLiveOutgoing && !diag?.has('cold') && viewer.warmLiveOutgoing(prev.entity)) {
|
|
4029
|
+
const tw = performance.now();
|
|
4030
|
+
const ok = await viewer.liveWarmed();
|
|
4031
|
+
perfSpan('setSource:presort(async)', tw);
|
|
4032
|
+
diag?.mark('presorted', { ok, ms: Math.round(performance.now() - tw) });
|
|
4033
|
+
if (removed || gen !== sourceGen) {
|
|
4034
|
+
// A newer setSource owns the pre-sort now (it warms the same asset, or drops it).
|
|
4035
|
+
app.assets.remove(loaded.asset);
|
|
4036
|
+
loaded.asset.unload?.();
|
|
4037
|
+
return out;
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
3913
4040
|
const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
|
|
3914
4041
|
if (removed || gen !== sourceGen) {
|
|
3915
4042
|
app.assets.remove(loaded.asset);
|
|
@@ -3922,16 +4049,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3922
4049
|
// its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
|
|
3923
4050
|
// A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
|
|
3924
4051
|
// the RenderView path); 'frozen' there = its snapshot fades out while the new one plays in.
|
|
3925
|
-
const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
|
|
3926
4052
|
const live =
|
|
3927
4053
|
snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
|
|
4054
|
+
if (!live) viewer.cancelLiveWarm(); // a pre-sort that is not becoming a live window
|
|
4055
|
+
diag?.mark('outgoing', { mode: live ? (live.warmed ? 'live (pre-sorted)' : 'live (cold: frozen bridge until sorted)') : snapped ? 'frozen' : 'none' });
|
|
3928
4056
|
/** Each frame of the window: once the live camera is ready, the overlay samples it. */
|
|
3929
4057
|
let liveShown = false;
|
|
3930
4058
|
const pumpLive = () => {
|
|
3931
4059
|
if (!live || liveShown || !live.active || !live.ready) return;
|
|
3932
4060
|
viewer.setSnapshotSource(live.texture);
|
|
3933
4061
|
liveShown = true;
|
|
4062
|
+
diag?.mark('live-shown');
|
|
3934
4063
|
};
|
|
4064
|
+
// Pre-sorted: the live target is already sorted, so the overlay samples it from the swap on.
|
|
4065
|
+
if (live?.warmed) pumpLive();
|
|
3935
4066
|
|
|
3936
4067
|
const release = (p) => {
|
|
3937
4068
|
if (!p) return;
|
|
@@ -3960,6 +4091,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3960
4091
|
const kept = applyLoaded(loaded);
|
|
3961
4092
|
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
3962
4093
|
if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
|
|
4094
|
+
diag?.setPhase('window');
|
|
3963
4095
|
};
|
|
3964
4096
|
|
|
3965
4097
|
const entity = viewer.addSplatAsset(loaded.asset);
|
|
@@ -3971,6 +4103,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3971
4103
|
if (finished) return;
|
|
3972
4104
|
finished = true;
|
|
3973
4105
|
for (const f of finishers) f();
|
|
4106
|
+
if (gen === sourceGen) diag?.setPhase('settle');
|
|
3974
4107
|
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
3975
4108
|
settle();
|
|
3976
4109
|
};
|
|
@@ -514,3 +514,35 @@ export function coverageExponent(c, L = FADE_OPTICAL_DEPTH) {
|
|
|
514
514
|
if (c >= 1) return 1;
|
|
515
515
|
return -Math.log(1 - c * (1 - Math.exp(-L))) / L;
|
|
516
516
|
}
|
|
517
|
+
|
|
518
|
+
// ── view-rig declaration (all three backends) ────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
let warnedNoViewRig = false;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Declare a view rig on the scene handle. A handle without `setViewRig` is a core that predates
|
|
524
|
+
* view rigs (inline3d.js before the view-rig commit — e.g. a page that vendors an older core and
|
|
525
|
+
* hands its wall to this adapter): the rig cannot reach the browser, and the wall weaves on the
|
|
526
|
+
* addScene display-rig shorthand instead. For a CAMERA rig that is a metric photo scene on a
|
|
527
|
+
* 0.24 m display rig — convergence and scale way off while 2D looks right — so it is said out
|
|
528
|
+
* loud, once per page, rather than dropped the way `handle?.setViewRig?.()` used to drop it.
|
|
529
|
+
* Returns whether the handle took the rig.
|
|
530
|
+
*/
|
|
531
|
+
export function declareViewRig(handle, rig) {
|
|
532
|
+
if (!handle) return false; // no wall (mono): nothing to declare to
|
|
533
|
+
if (typeof handle.setViewRig === 'function') {
|
|
534
|
+
handle.setViewRig(rig);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
if (rig?.type === 'camera' && !warnedNoViewRig) {
|
|
538
|
+
warnedNoViewRig = true;
|
|
539
|
+
console.warn(
|
|
540
|
+
'[inline3d/splat] the view rig was dropped: your inline3d core predates view rigs (its ' +
|
|
541
|
+
'addScene handle has no setViewRig), so the wall will use the addScene display-rig ' +
|
|
542
|
+
'shorthand, not this camera rig — the scene will be mis-converged and mis-scaled in 3D. ' +
|
|
543
|
+
'Update the core (js/inline3d.js) that created the wall to the same SDK version as ./splat.',
|
|
544
|
+
rig,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
STREAMED_NEEDS_PLAYCANVAS,
|
|
38
38
|
resolveControls,
|
|
39
39
|
normalizeCameraPose,
|
|
40
|
+
declareViewRig,
|
|
40
41
|
} from './inline3d-splat-shared.js';
|
|
41
42
|
import {
|
|
42
43
|
resolveRig,
|
|
@@ -486,7 +487,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
486
487
|
parallaxFactor: out.rig.parallaxFactor,
|
|
487
488
|
out: out.viewRig || {},
|
|
488
489
|
});
|
|
489
|
-
handle
|
|
490
|
+
declareViewRig(handle, out.viewRig);
|
|
490
491
|
}
|
|
491
492
|
viewer.onFocusChange = () => {
|
|
492
493
|
pushViewRig(false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"js/inline3d-splat-shared.js",
|
|
53
53
|
"js/inline3d-splat-effects.js",
|
|
54
54
|
"js/inline3d-splat-live.js",
|
|
55
|
+
"js/inline3d-splat-diag.js",
|
|
55
56
|
"js/inline3d-splat-video.js",
|
|
56
57
|
"js/inline3d-model.js",
|
|
57
58
|
"js/inline3d-model-entry.js",
|
package/splat.d.ts
CHANGED
|
@@ -219,6 +219,23 @@ export interface SplatOptions {
|
|
|
219
219
|
* URL with `engine: 'spark'`.
|
|
220
220
|
*/
|
|
221
221
|
engine?: 'spark' | 'playcanvas';
|
|
222
|
+
/**
|
|
223
|
+
* `engine: 'playcanvas'` only: TRANSITION DIAGNOSTICS (#36). Off by default; `true` (or the page
|
|
224
|
+
* URL's `?dxrdiag=1`) records every woven frame — its interval, whether its eye poses are
|
|
225
|
+
* bit-identical to the previous frame's (a tracking HOLD), whether the transition overlay shows
|
|
226
|
+
* the frozen capture — plus every `setViewRig` push with its values, long tasks, and the
|
|
227
|
+
* setSource phases (`prepare`, `swap`, `window`, `settle`). It shows a small overlay (excluded
|
|
228
|
+
* from the weave), logs `[dxr-diag]` console lines (one summary with a verdict per transition),
|
|
229
|
+
* and exposes `window.__dxrDiag` (`copy(__dxrDiag.dump())` for the JSON).
|
|
230
|
+
*
|
|
231
|
+
* A string / array adds A/B kill switches (also accepted as `?dxrdiag=norig,frozen`): `'norig'`
|
|
232
|
+
* keeps the rig declared before the first setSource (drops every re-declaration), `'frozen'`
|
|
233
|
+
* forces `outgoing: 'frozen'`, `'nowarm'` skips the transition shader pre-warm, `'cold'` skips
|
|
234
|
+
* the live outgoing pre-sort, `'nooverlay'` records without the overlay. The option wins over the
|
|
235
|
+
* URL; `false` turns it off even with `?dxrdiag` present. See docs/playcanvas-adapter.md
|
|
236
|
+
* § Diagnosing transition stalls.
|
|
237
|
+
*/
|
|
238
|
+
diag?: boolean | string | ReadonlyArray<'norig' | 'frozen' | 'nowarm' | 'cold' | 'nooverlay' | '1'>;
|
|
222
239
|
/**
|
|
223
240
|
* `engine: 'playcanvas'` only: the WebGL context's `preserveDrawingBuffer` (default false) —
|
|
224
241
|
* the knob for the weave's zero-copy read race on large canvases.
|