@displayxr/inline3d 1.42.0 → 1.43.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 +49 -0
- package/dist/call.js +4 -4
- package/js/camera/converge.js +6 -2
- package/js/camera/disparity.js +12 -3
- package/js/camera/pair-cursor.js +107 -12
- package/js/inline3d-model-entry.js +3 -0
- package/js/inline3d-model-playcanvas.js +8 -0
- package/js/inline3d-playcanvas-engine.js +2 -0
- package/js/inline3d-splat-deferred.js +7 -0
- package/js/inline3d-splat-effects.js +16 -0
- package/js/inline3d-splat-playcanvas.js +182 -27
- package/js/inline3d-splat.js +5 -0
- package/model.d.ts +8 -0
- package/package.json +1 -1
- package/splat.d.ts +28 -0
package/js/camera/converge.js
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
/** Low-pass factor for the convergence shift (RFC §3): next = prev + α·(target − prev). */
|
|
8
8
|
export const CONVERGENCE_ALPHA = 0.2;
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The convergence shift never exceeds this fraction of the per-eye width, either way. 0.2: a
|
|
12
|
+
* 120 mm-baseline camera (the SR laptop's) sees a face at 0.6 m at ~22% of the eye width, so it
|
|
13
|
+
* needs ~11% per eye — 0.12 left no room to lean in.
|
|
14
|
+
*/
|
|
15
|
+
export const CONVERGENCE_MAX_FRACTION = 0.2;
|
|
12
16
|
|
|
13
17
|
/** `setDepth(v)`: v in [-1, 1] adds v times this fraction of the per-eye width to the shift. */
|
|
14
18
|
export const DEPTH_RANGE_FRACTION = 0.05;
|
package/js/camera/disparity.js
CHANGED
|
@@ -144,7 +144,11 @@ export function matchBlock(img, W, H, x0, y0, bw, bh, { dMin, dMax, dyMax = 2, u
|
|
|
144
144
|
export function blockDisparities(img, W, H, o = {}) {
|
|
145
145
|
const E = W / 2;
|
|
146
146
|
const b = o.block || Math.max(8, Math.round(E / 20));
|
|
147
|
-
|
|
147
|
+
// One block per block width. A sparser grid (1.5 b) left a dim, grayscale SR-camera frame with
|
|
148
|
+
// ~9 matched blocks, 3-4 of them on the face — too few to form a mode — so the background won
|
|
149
|
+
// and auto-convergence flipped between the two. At 1 b: ~36 blocks, the face every frame, and a
|
|
150
|
+
// steadier value (±1 px instead of ±4); ~13 ms per full search at 240 px per eye.
|
|
151
|
+
const step = o.step || b;
|
|
148
152
|
const dMax = o.dMax ?? Math.round(E * 0.3);
|
|
149
153
|
const dMin = o.dMin ?? -Math.round(E * 0.05);
|
|
150
154
|
const x0 = Math.round(E * (o.roiX0 ?? 0.15));
|
|
@@ -165,12 +169,17 @@ export function blockDisparities(img, W, H, o = {}) {
|
|
|
165
169
|
* The subject = the NEAREST strong disparity mode: the largest `d` whose ±`tol` bin holds at least
|
|
166
170
|
* `minFrac` of the matched blocks (and `minBlocks`). Returns its members, or null.
|
|
167
171
|
*/
|
|
168
|
-
export function nearestMode(blocks, { tol = 2, minFrac = 0.12, minBlocks = 3 } = {}) {
|
|
172
|
+
export function nearestMode(blocks, { tol = 2, relTol = 0.1, minFrac = 0.12, minBlocks = 3 } = {}) {
|
|
169
173
|
if (!blocks.length) return null;
|
|
170
174
|
const need = Math.max(minBlocks, Math.ceil(blocks.length * minFrac));
|
|
171
175
|
const ds = blocks.map((b) => b.d).sort((a, b) => b - a); // descending: nearest first
|
|
172
176
|
for (const d of ds) {
|
|
173
|
-
|
|
177
|
+
// The bin widens with the disparity: a near subject's own relief spreads its blocks over
|
|
178
|
+
// ~relTol of its disparity (the spread scales with baseline / distance, as d does), so a
|
|
179
|
+
// fixed +-2 px split a face from a wide-baseline (120 mm) camera into groups too small to
|
|
180
|
+
// count, and the flat background won.
|
|
181
|
+
const t = Math.max(tol, relTol * Math.abs(d));
|
|
182
|
+
const members = blocks.filter((b) => Math.abs(b.d - d) <= t);
|
|
174
183
|
if (members.length >= need) {
|
|
175
184
|
const md = members.map((b) => b.d).sort((a, b) => a - b);
|
|
176
185
|
return { d: md[md.length >> 1], members };
|
package/js/camera/pair-cursor.js
CHANGED
|
@@ -72,21 +72,116 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
|
|
|
72
72
|
const dMin = -Math.round(E * 0.05);
|
|
73
73
|
const { rL, rR } = geo;
|
|
74
74
|
const k = geo.outW / rL.sw; // output px per source px
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const swp = swappedHalves(luma);
|
|
76
|
+
const found = []; // { d, x, y, eye }: a consistent match and WHERE it was taken
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const sample = (su, pv) => {
|
|
79
|
+
const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
|
|
80
|
+
if (y < 0 || y + b > h) return;
|
|
81
|
+
const xl = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
|
|
82
|
+
const xr = Math.round((rR.sx + su * rR.sw) * scale - b / 2);
|
|
83
|
+
// The arrow is far smaller than a block (~6 vs 12 copy px at the OS arrow's size), so
|
|
84
|
+
// neighbouring footprint points land on the SAME block: match each block once.
|
|
85
|
+
if (!seen.has(`L${xl},${y}`)) {
|
|
86
|
+
seen.add(`L${xl},${y}`);
|
|
87
|
+
const dl = consistentMatch(img, swp, w, h, xl, y, b, dMin, dMax);
|
|
88
|
+
if (dl !== null) found.push({ d: dl, x: xl, y, eye: 0 });
|
|
89
|
+
}
|
|
90
|
+
if (!seen.has(`R${xr},${y}`)) {
|
|
91
|
+
seen.add(`R${xr},${y}`);
|
|
92
|
+
const dr = consistentMatch(swp, img, w, h, xr, y, b, -dMax, -dMin); // right-anchored: d' = -d
|
|
93
|
+
if (dr !== null) found.push({ d: -dr, x: xr, y, eye: 1 });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
// The tip, then a ring centred on the arrow's body: that is what content must not overlap. Each
|
|
97
|
+
// point is sampled from BOTH eyes' images (the pointer's spot in the left eye matched into the
|
|
98
|
+
// right, and the same spot in the right eye matched into the left), so neither eye is favoured:
|
|
99
|
+
// at a foreground edge one eye's block can sit on the subject and the other's on the background.
|
|
100
|
+
// Every match must also pass the left-right consistency check (consistentMatch).
|
|
101
|
+
let cu = 0, cv = 0, n = 0;
|
|
77
102
|
for (const [pu, pv] of cursorArrowFootprint(u, v, height, aspect)) {
|
|
78
103
|
if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
sample(geo.mirror ? 1 - pu : pu, pv);
|
|
105
|
+
cu += pu; cv += pv; n++;
|
|
106
|
+
}
|
|
107
|
+
if (!n) return null;
|
|
108
|
+
// ...plus four blocks half a block around the footprint's centre, so that real content under
|
|
109
|
+
// the arrow is seen by more than one distinct block (pairNearestSupported needs that).
|
|
110
|
+
const du = (b / 2 / scale) / rL.sw, dv = (b / 2 / scale) / rL.sh;
|
|
111
|
+
for (const [ou, ov] of [[-du, 0], [du, 0], [0, -dv], [0, dv]]) {
|
|
112
|
+
const pu = cu / n + ou, pv = cv / n + ov;
|
|
113
|
+
if (pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1) sample(geo.mirror ? 1 - pu : pu, pv);
|
|
114
|
+
}
|
|
115
|
+
// Source disparity → displayed: each eye's crop moved its half by its own offset.
|
|
116
|
+
for (const f of found) f.d = (f.d / scale - (rL.sx - rR.sx)) * k;
|
|
117
|
+
return pairNearestSupported(found, (PAIR_SUPPORT_COPY_PX / scale) * k, b / 2);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Two consistent footprint matches must agree (within this many px of the grayscale copy) before
|
|
122
|
+
* the nearest is believed: content that comes out of the screen covers several blocks, a lone
|
|
123
|
+
* block claiming something nearer than the rest is noise.
|
|
124
|
+
*/
|
|
125
|
+
export const PAIR_SUPPORT_COPY_PX = 1.5;
|
|
126
|
+
|
|
127
|
+
/** The SBS copy with its halves swapped (cached per copy): matching it is matching right → left. */
|
|
128
|
+
const _swapped = new WeakMap();
|
|
129
|
+
function swappedHalves(luma) {
|
|
130
|
+
let s = _swapped.get(luma);
|
|
131
|
+
if (s) return s;
|
|
132
|
+
const { img, w, h } = luma;
|
|
133
|
+
const E = w / 2;
|
|
134
|
+
s = new Uint8Array(w * h);
|
|
135
|
+
for (let y = 0; y < h; y++) {
|
|
136
|
+
const o = y * w;
|
|
137
|
+
for (let x = 0; x < E; x++) {
|
|
138
|
+
s[o + x] = img[o + E + x];
|
|
139
|
+
s[o + E + x] = img[o + x];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
_swapped.set(luma, s);
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A block of image A's left half matched into its right half, kept only if the matched block,
|
|
148
|
+
* matched BACK (in B = A with its halves swapped), lands within 1 px of where it started — the
|
|
149
|
+
* classic left-right consistency check. A block straddling a depth edge (half subject, half
|
|
150
|
+
* background, each eye seeing a different half) fails it instead of returning a random depth.
|
|
151
|
+
* @returns {number|null} disparity in A's convention (left x − right x), copy px
|
|
152
|
+
*/
|
|
153
|
+
export function consistentMatch(A, B, w, h, x, y, b, dMin, dMax) {
|
|
154
|
+
const E = w / 2;
|
|
155
|
+
if (x < 0 || x + b > E) return null;
|
|
156
|
+
const m = matchBlock(A, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
|
|
157
|
+
if (!m || m.c < MIN_NCC) return null;
|
|
158
|
+
const xb = Math.round(x - m.d);
|
|
159
|
+
const yb = y + m.dy;
|
|
160
|
+
if (xb < 0 || xb + b > E || yb < 0 || yb + b > h) return null;
|
|
161
|
+
const back = matchBlock(B, w, h, xb, yb, b, b, { dMin: -Math.ceil(m.d) - 2, dMax: -Math.floor(m.d) + 2, dyMax: 1 });
|
|
162
|
+
if (!back || back.c < MIN_NCC || Math.abs(-back.d - m.d) > 1) return null;
|
|
163
|
+
return m.d;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The nearest disparity that an INDEPENDENT second sample corroborates (within `tol`), else null.
|
|
168
|
+
* Independent = taken from the other eye's image, or from a block at least `minSep` copy px away:
|
|
169
|
+
* two footprint points inside one block are one witness, not two. (A block straddling a face edge
|
|
170
|
+
* and a bright window matched at a bogus +49 px, consistently, and its twin counted as support.)
|
|
171
|
+
* @param {Array<{d:number, x?:number, y?:number, eye?:number}|number>} samples output px, + = in front
|
|
172
|
+
* @returns {number|null}
|
|
173
|
+
*/
|
|
174
|
+
export function pairNearestSupported(samples, tol, minSep = 0) {
|
|
175
|
+
const S = samples.map((s) => (typeof s === 'number' ? { d: s, x: 0, y: 0, eye: -1 } : s));
|
|
176
|
+
if (S.length < 2) return null;
|
|
177
|
+
const sorted = [...S].sort((p, q) => q.d - p.d); // nearest first
|
|
178
|
+
const independent = (p, q) => p.eye === -1 || p.eye !== q.eye || Math.abs(p.x - q.x) + Math.abs(p.y - q.y) >= minSep;
|
|
179
|
+
for (const p of sorted) {
|
|
180
|
+
for (const q of sorted) {
|
|
181
|
+
if (q !== p && Math.abs(p.d - q.d) <= tol && independent(p, q)) return p.d;
|
|
182
|
+
}
|
|
88
183
|
}
|
|
89
|
-
return
|
|
184
|
+
return null;
|
|
90
185
|
}
|
|
91
186
|
|
|
92
187
|
/**
|
|
@@ -141,7 +141,10 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
141
141
|
remove: queue('remove'),
|
|
142
142
|
exclude: queue('exclude'),
|
|
143
143
|
unexclude: queue('unexclude'),
|
|
144
|
+
// Before the backend lands: once it has, the backend's own (PlayCanvas); three has none (0).
|
|
145
|
+
warmEntity: (entity, o) => out.ready.then(() => (out.warmEntity !== stubWarm && typeof out.warmEntity === 'function' ? out.warmEntity(entity, o) : 0)),
|
|
144
146
|
};
|
|
147
|
+
const stubWarm = out.warmEntity;
|
|
145
148
|
out.firstWoven = new Promise((resolve) => {
|
|
146
149
|
out._resolveFirstWoven = resolve;
|
|
147
150
|
});
|
|
@@ -515,6 +515,14 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
515
515
|
},
|
|
516
516
|
exclude: (el) => handle?.exclude(el),
|
|
517
517
|
unexclude: (el) => handle?.unexclude(el),
|
|
518
|
+
/**
|
|
519
|
+
* Compile `entity`'s mesh programs off the frame before it is shown (default: the model) —
|
|
520
|
+
* PlayCanvasSplatViewer.warmEntity. Resolves the number of programs warmed; 0 before `ready`.
|
|
521
|
+
*/
|
|
522
|
+
async warmEntity(entity, o) {
|
|
523
|
+
await out.ready?.catch?.(() => null);
|
|
524
|
+
return removed ? 0 : viewer.warmEntity(entity ?? out.model, o);
|
|
525
|
+
},
|
|
518
526
|
});
|
|
519
527
|
|
|
520
528
|
// The per-eye buffer scale in force (the request times the device-limit clamp,
|
|
@@ -37,6 +37,7 @@ export {
|
|
|
37
37
|
Vec4,
|
|
38
38
|
Quat,
|
|
39
39
|
LAYERID_SKYBOX,
|
|
40
|
+
LAYERID_WORLD,
|
|
40
41
|
LAYERID_UI,
|
|
41
42
|
LAYERID_IMMEDIATE,
|
|
42
43
|
// cursor: 'depth' (./inline3d-cursor-depth-playcanvas.js, loaded only on opt-in)
|
|
@@ -57,6 +58,7 @@ export {
|
|
|
57
58
|
SEMANTIC_COLOR,
|
|
58
59
|
ShaderChunks,
|
|
59
60
|
SHADERLANGUAGE_GLSL,
|
|
61
|
+
SHADER_FORWARD,
|
|
60
62
|
CHUNKAPI_2_8,
|
|
61
63
|
// setSource's frame snapshot
|
|
62
64
|
RenderTarget,
|
|
@@ -117,6 +117,13 @@ export function addSplatDeferred(wall, canvas, src, opts) {
|
|
|
117
117
|
validateEffectCall(name, o, 'play');
|
|
118
118
|
return out.ready.then(() => out.playEffect(name, o));
|
|
119
119
|
},
|
|
120
|
+
// Warm before the adapter has loaded: validated now, warmed once the first asset is on screen.
|
|
121
|
+
warmEffect: (name, o) => {
|
|
122
|
+
validateEffectCall(name, o, 'play');
|
|
123
|
+
return out.ready.then(() => out.warmEffect(name, o));
|
|
124
|
+
},
|
|
125
|
+
// Mesh-program warm before the adapter has loaded: runs once it has (it needs the engine).
|
|
126
|
+
warmEntity: (entity, o) => out.ready.then(() => out.warmEntity(entity, o)),
|
|
120
127
|
setEffect: (name, params) => {
|
|
121
128
|
validateEffectCall(name, params, 'set');
|
|
122
129
|
pending.push(['setEffect', [name, params]]);
|
|
@@ -1788,6 +1788,22 @@ export class SplatEffects {
|
|
|
1788
1788
|
};
|
|
1789
1789
|
}
|
|
1790
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* The tile chunk play(name, opts) WOULD install right now — the tile effects already on, with
|
|
1793
|
+
* this one added (or replacing its namesake, in place, as _replace does) — without installing
|
|
1794
|
+
* it: what handle.warmEffect compiles ahead of the play. Null for an entity-scope effect (its
|
|
1795
|
+
* program is the asset's work-buffer modifier, not the tile chunk) or a custom fragment stage.
|
|
1796
|
+
*/
|
|
1797
|
+
playChunkCode(name, opts = {}) {
|
|
1798
|
+
const o = resolveEffectOptions(name, opts, 'play');
|
|
1799
|
+
if ((o.scope ?? 'tile') !== 'tile' || o.fragmentGlsl) return null;
|
|
1800
|
+
const inst = { name, def: defOf(name), opts: o };
|
|
1801
|
+
const m = this.scopes.get('tile');
|
|
1802
|
+
const list = m ? [...m.values()].map((i) => (i.name === name ? inst : i)) : [];
|
|
1803
|
+
if (!m?.has(name)) list.push(inst);
|
|
1804
|
+
return composeModifier(list).code;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1791
1807
|
/**
|
|
1792
1808
|
* The tile chunk driveShared(name, effect, opts) WOULD install, without installing it — what
|
|
1793
1809
|
* setSource's shader pre-warm compiles ahead of the transition.
|
|
@@ -1190,6 +1190,86 @@ export class PlayCanvasSplatViewer {
|
|
|
1190
1190
|
* engine's `renderSceneColorMap` setter counts one request per camera. Cameras made later (a
|
|
1191
1191
|
* view-path switch) inherit it via `_makeCamera`.
|
|
1192
1192
|
*/
|
|
1193
|
+
/**
|
|
1194
|
+
* Compile the forward programs `entity`'s meshes will draw with — through the RENDERER's own
|
|
1195
|
+
* call (MeshInstance.getShaderInstance with the pass, layer light hash, sorted lights, view
|
|
1196
|
+
* uniform format and each eye camera's live shaderParams it will pass), so the variant is the
|
|
1197
|
+
* very one the first draw asks for, cached on the mesh instance — then finish each link in idle
|
|
1198
|
+
* periods (KHR_parallel_shader_compile) instead of on that draw. PlayCanvas's forward renderer
|
|
1199
|
+
* links synchronously on first use (asyncCompile = false): a glTF with ~13 lit materials froze
|
|
1200
|
+
* 0.8–0.95 s on a panel. Calling material.getShaderVariant with hand-built params instead
|
|
1201
|
+
* produces broken variants (`#include "litMainVS"` unresolved) — do not.
|
|
1202
|
+
*
|
|
1203
|
+
* Warm in the state you will draw in: lights, fog, the layer and the camera's render target
|
|
1204
|
+
* key the variant. Needs one frame rendered (the cameras' shaderParams). Resolves the number of
|
|
1205
|
+
* programs warmed (0: nothing to do, or an engine whose internals differ).
|
|
1206
|
+
*/
|
|
1207
|
+
async warmEntity(entity, { timeoutMs = 4000 } = {}) {
|
|
1208
|
+
const pc = this.pc;
|
|
1209
|
+
const app = this.app;
|
|
1210
|
+
if (!pc || !app || this._disposed || !entity) return 0;
|
|
1211
|
+
const device = app.graphicsDevice;
|
|
1212
|
+
const scene = app.scene;
|
|
1213
|
+
const renderer = app.renderer;
|
|
1214
|
+
if (!renderer || typeof renderer.viewUniformFormat === 'undefined') return 0;
|
|
1215
|
+
// [meshInstance, the layer ids it draws in] — a render/model component's own `layers`
|
|
1216
|
+
const groups = [];
|
|
1217
|
+
for (const c of [...(entity.findComponents?.('render') || []), ...(entity.findComponents?.('model') || [])]) {
|
|
1218
|
+
if (c.meshInstances?.length) groups.push([c.meshInstances, c.layers?.length ? c.layers : [pc.LAYERID_WORLD]]);
|
|
1219
|
+
}
|
|
1220
|
+
if (!groups.length) return 0;
|
|
1221
|
+
const cams = [this.eye, ...(this._viewPath === 'cameras' ? this._views : [])]
|
|
1222
|
+
.map((c) => c?.camera)
|
|
1223
|
+
.filter((c) => c?.camera?.shaderParams);
|
|
1224
|
+
const forward = pc.SHADER_FORWARD ?? 0;
|
|
1225
|
+
const shaders = new Set();
|
|
1226
|
+
// Issuing a program compiles its two shaders on THIS thread: 13 lit programs at once were one
|
|
1227
|
+
// 65 ms frame. Time-slice — yield to idle whenever a slice passes SLICE_MS.
|
|
1228
|
+
const SLICE_MS = 8;
|
|
1229
|
+
let slice = now();
|
|
1230
|
+
try {
|
|
1231
|
+
for (const cc of cams) {
|
|
1232
|
+
const shaderParams = cc.camera.shaderParams;
|
|
1233
|
+
// the renderer's own pick (renderForwardLayer): the camera's shader pass, else forward
|
|
1234
|
+
const pass = cc.camera.shaderPassInfo?.index ?? forward;
|
|
1235
|
+
for (const [mis, layerIds] of groups) {
|
|
1236
|
+
for (const layerId of layerIds) {
|
|
1237
|
+
if (cc.layers && !cc.layers.includes(layerId)) continue; // this camera never draws it
|
|
1238
|
+
const layer = scene.layers.getLayerById(layerId);
|
|
1239
|
+
if (!layer) continue;
|
|
1240
|
+
const lightHash = layer.getLightHash?.(scene.clusteredLightingEnabled) ?? 0;
|
|
1241
|
+
for (const mi of mis) {
|
|
1242
|
+
if (!mi?.mesh || !mi.material) continue;
|
|
1243
|
+
if (((mi.shaderPassMask ?? ~0) & (1 << pass)) === 0) continue;
|
|
1244
|
+
mi.ensureMaterial?.(device);
|
|
1245
|
+
mi.material._scene = scene;
|
|
1246
|
+
mi.material.prepareForRender?.(device, scene);
|
|
1247
|
+
const si = mi.getShaderInstance(pass, lightHash, scene, shaderParams, renderer.viewUniformFormat, layer.splitLights);
|
|
1248
|
+
if (si?.shader) shaders.add(si.shader);
|
|
1249
|
+
if (now() - slice > SLICE_MS) {
|
|
1250
|
+
await yieldIdle(50);
|
|
1251
|
+
if (this._disposed) return 0;
|
|
1252
|
+
slice = now();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
console.info(`${this.logTag} warmEntity: could not issue the programs (engine internals differ?)`, err);
|
|
1260
|
+
return 0;
|
|
1261
|
+
}
|
|
1262
|
+
const pending = () => [...shaders].filter((x) => !x.ready && !x.failed && x.impl?.finalize);
|
|
1263
|
+
const t0 = now();
|
|
1264
|
+
while (pending().length && now() - t0 < timeoutMs && !this._disposed) {
|
|
1265
|
+
for (const x of pending()) if (x.impl.isLinked?.(device)) x.impl.finalize(device, x) || (x.failed = true);
|
|
1266
|
+
if (pending().length) await yieldIdle(50);
|
|
1267
|
+
}
|
|
1268
|
+
// still linking (or no completion query): finalize now — a block here, not on the first draw
|
|
1269
|
+
for (const x of pending()) x.impl.finalize(device, x) || (x.failed = true);
|
|
1270
|
+
return shaders.size;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1193
1273
|
useSceneColor(on = true) {
|
|
1194
1274
|
this.sceneColor = !!on;
|
|
1195
1275
|
const cams = [this.eye, ...(this._viewPath === 'cameras' ? this._views : [])];
|
|
@@ -3024,6 +3104,20 @@ export function validateDepthEnvelope(o, prev) {
|
|
|
3024
3104
|
return next;
|
|
3025
3105
|
}
|
|
3026
3106
|
|
|
3107
|
+
/**
|
|
3108
|
+
* prepareSource's `warmEffects`: an array of effect names, or of { name, opts } (validated as a
|
|
3109
|
+
* playEffect call, at prepareSource's own line). Returns [{ name, opts }].
|
|
3110
|
+
*/
|
|
3111
|
+
export function validateWarmEffects(list) {
|
|
3112
|
+
if (list === undefined) return [];
|
|
3113
|
+
if (!Array.isArray(list)) throw new TypeError('@displayxr/inline3d/splat: prepareSource warmEffects must be an array of effect names or { name, opts }.');
|
|
3114
|
+
return list.map((w) => {
|
|
3115
|
+
const e = typeof w === 'string' ? { name: w, opts: {} } : { name: w?.name, opts: w?.opts ?? {} };
|
|
3116
|
+
validateEffectCall(e.name, e.opts, 'play');
|
|
3117
|
+
return e;
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3027
3121
|
/**
|
|
3028
3122
|
* handle.setStereo's argument, validated and merged onto the current override (throws at the
|
|
3029
3123
|
* call). Returns the new override, or null when nothing is overridden.
|
|
@@ -3477,6 +3571,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3477
3571
|
},
|
|
3478
3572
|
prepareSource,
|
|
3479
3573
|
setRig,
|
|
3574
|
+
/**
|
|
3575
|
+
* Compile `entity`'s mesh programs (content under handle.engine.root) off the frame, before
|
|
3576
|
+
* it is shown — viewer.warmEntity. Resolves the number of programs warmed.
|
|
3577
|
+
*/
|
|
3578
|
+
async warmEntity(entity, o) {
|
|
3579
|
+
await booted;
|
|
3580
|
+
await first.catch(() => null);
|
|
3581
|
+
return removed ? 0 : viewer.warmEntity(entity, o);
|
|
3582
|
+
},
|
|
3480
3583
|
setStereo,
|
|
3481
3584
|
setDepthEnvelope,
|
|
3482
3585
|
/**
|
|
@@ -3577,6 +3680,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3577
3680
|
validateEffectCall(name, o, 'play');
|
|
3578
3681
|
return first.then(() => (fx && !removed ? fx.play(name, o) : { finished: false }));
|
|
3579
3682
|
},
|
|
3683
|
+
warmEffect,
|
|
3580
3684
|
/** Set a persistent effect (grade, clip, custom), hold a transition at `progress`, or null to remove. */
|
|
3581
3685
|
setEffect(name, params) {
|
|
3582
3686
|
validateEffectCall(name, params, 'set');
|
|
@@ -4966,7 +5070,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4966
5070
|
*/
|
|
4967
5071
|
async function prepareSource(src, po = {}) {
|
|
4968
5072
|
if (po === null || typeof po !== 'object') throw new TypeError('@displayxr/inline3d/splat: prepareSource options must be an object.');
|
|
4969
|
-
const
|
|
5073
|
+
const warmList = validateWarmEffects(po.warmEffects);
|
|
5074
|
+
const swapOpts = po.warmEffects === undefined ? po : Object.fromEntries(Object.entries(po).filter(([k]) => k !== 'warmEffects'));
|
|
5075
|
+
const warm = po.transition !== undefined ? resolveSwap(swapOpts) : null; // the setSource options it will get; throws on a bad one
|
|
4970
5076
|
const app = await booted;
|
|
4971
5077
|
await first.catch(() => null);
|
|
4972
5078
|
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
@@ -4995,6 +5101,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4995
5101
|
diag?.mark('prepared', { ms: Math.round(performance.now() - t0), fetchOnly });
|
|
4996
5102
|
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
4997
5103
|
await prewarmTransition(warm); // null (no transition declared): the overlay only
|
|
5104
|
+
// And the effects the page will play once this asset is in (a reveal after the swap).
|
|
5105
|
+
for (const w of warmList) await warmEffect(w.name, w.opts);
|
|
4998
5106
|
const entry = { loaded, source, state: 'ready', dispose: null };
|
|
4999
5107
|
const prepared = {
|
|
5000
5108
|
[PREPARED_TAG]: true,
|
|
@@ -5062,32 +5170,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
5062
5170
|
const pc = pcModule;
|
|
5063
5171
|
const made = [];
|
|
5064
5172
|
for (const [chunkKey, codeOf] of fx ? wantChunks : []) {
|
|
5065
|
-
const
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
for (const cam of cams) {
|
|
5069
|
-
const mi = managerMi(cam, null);
|
|
5070
|
-
const src = mi?.material;
|
|
5071
|
-
const camera = cam.camera?.camera;
|
|
5072
|
-
if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
|
|
5073
|
-
const m = new pc.ShaderMaterial(src.shaderDesc);
|
|
5074
|
-
src.defines.forEach((v, k) => m.setDefine(k, v));
|
|
5075
|
-
m.shaderChunks.copy(src.shaderChunks);
|
|
5076
|
-
m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
|
|
5077
|
-
m.blendState = src.blendState;
|
|
5078
|
-
made.push(m.getShaderVariant({
|
|
5079
|
-
device: viewer.app.graphicsDevice,
|
|
5080
|
-
scene: viewer.app.scene,
|
|
5081
|
-
objDefs: mi._shaderDefs,
|
|
5082
|
-
cameraShaderParams: camera.shaderParams,
|
|
5083
|
-
pass: 0, // SHADER_FORWARD
|
|
5084
|
-
sortedLights: [],
|
|
5085
|
-
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
5086
|
-
vertexFormat: mi.mesh?.vertexBuffer?.format,
|
|
5087
|
-
}));
|
|
5088
|
-
issued++;
|
|
5089
|
-
}
|
|
5090
|
-
if (issued) prewarmed.add(chunkKey);
|
|
5173
|
+
const got = issueChunkVariants(pc, codeOf());
|
|
5174
|
+
made.push(...got);
|
|
5175
|
+
if (got.length) prewarmed.add(chunkKey);
|
|
5091
5176
|
}
|
|
5092
5177
|
const cam = viewer.eye?.camera?.camera;
|
|
5093
5178
|
if (wantOverlay && cam?.shaderParams) {
|
|
@@ -5115,6 +5200,76 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
5115
5200
|
}
|
|
5116
5201
|
|
|
5117
5202
|
|
|
5203
|
+
/**
|
|
5204
|
+
* Issue (compile + link, not wait for) the gsplat render variant with `code` as its tile
|
|
5205
|
+
* `gsplatModifyVS` chunk, on every eye camera's splat material: a throwaway material with the
|
|
5206
|
+
* renderer's own description, defines and chunks asks the engine's program library for the
|
|
5207
|
+
* variant the renderer will ask for (the library keys on the generated source + processing
|
|
5208
|
+
* options, not on the material). Returns the issued shaders, for finalizeWhenLinked.
|
|
5209
|
+
*/
|
|
5210
|
+
function issueChunkVariants(pc, code) {
|
|
5211
|
+
const made = [];
|
|
5212
|
+
for (const cam of [viewer.eye, viewer._live?.cam].filter(Boolean)) {
|
|
5213
|
+
const mi = managerMi(cam, null);
|
|
5214
|
+
const src = mi?.material;
|
|
5215
|
+
const camera = cam.camera?.camera;
|
|
5216
|
+
if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
|
|
5217
|
+
const m = new pc.ShaderMaterial(src.shaderDesc);
|
|
5218
|
+
src.defines.forEach((v, k) => m.setDefine(k, v));
|
|
5219
|
+
m.shaderChunks.copy(src.shaderChunks);
|
|
5220
|
+
m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
|
|
5221
|
+
m.blendState = src.blendState;
|
|
5222
|
+
const v = m.getShaderVariant({
|
|
5223
|
+
device: viewer.app.graphicsDevice,
|
|
5224
|
+
scene: viewer.app.scene,
|
|
5225
|
+
objDefs: mi._shaderDefs,
|
|
5226
|
+
cameraShaderParams: camera.shaderParams,
|
|
5227
|
+
pass: 0, // SHADER_FORWARD
|
|
5228
|
+
sortedLights: [],
|
|
5229
|
+
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
5230
|
+
vertexFormat: mi.mesh?.vertexBuffer?.format,
|
|
5231
|
+
});
|
|
5232
|
+
if (v) made.push(v);
|
|
5233
|
+
}
|
|
5234
|
+
return made;
|
|
5235
|
+
}
|
|
5236
|
+
|
|
5237
|
+
/**
|
|
5238
|
+
* handle.warmEffect(name, opts) — compile the program playEffect(name, opts) will need, now,
|
|
5239
|
+
* finishing the link in idle periods (KHR_parallel_shader_compile), so the effect's first frame
|
|
5240
|
+
* does not block on it (a particle reveal's link: 250–300 ms on a panel). Keyed on the chunk
|
|
5241
|
+
* the play would install with the tile effects on NOW — warm in the state you will play from.
|
|
5242
|
+
* Resolves `true` once warm (or already warm), `false` where nothing could be warmed (an
|
|
5243
|
+
* entity-scope effect, a custom fragment stage, no engine yet, an engine whose internals
|
|
5244
|
+
* differ: it then compiles on its first frame, as before). Never throws after validation.
|
|
5245
|
+
*/
|
|
5246
|
+
async function warmEffect(name, o = {}) {
|
|
5247
|
+
validateEffectCall(name, o, 'play');
|
|
5248
|
+
await first.catch(() => null);
|
|
5249
|
+
if (removed || !fx || !pcModule || diag?.has('nowarm')) return false;
|
|
5250
|
+
let code;
|
|
5251
|
+
try {
|
|
5252
|
+
code = fx.playChunkCode(name, o);
|
|
5253
|
+
} catch {
|
|
5254
|
+
return false;
|
|
5255
|
+
}
|
|
5256
|
+
if (!code) return false;
|
|
5257
|
+
const key = `play|${code}`;
|
|
5258
|
+
if (prewarmed.has(key)) return true;
|
|
5259
|
+
await yieldIdle();
|
|
5260
|
+
if (removed) return false;
|
|
5261
|
+
try {
|
|
5262
|
+
const made = issueChunkVariants(pcModule, code);
|
|
5263
|
+
if (!made.length) return false;
|
|
5264
|
+
await finalizeWhenLinked(made);
|
|
5265
|
+
prewarmed.add(key);
|
|
5266
|
+
return true;
|
|
5267
|
+
} catch (err) {
|
|
5268
|
+
console.info('[inline3d/splat] warmEffect skipped', err);
|
|
5269
|
+
return false;
|
|
5270
|
+
}
|
|
5271
|
+
}
|
|
5272
|
+
|
|
5118
5273
|
/**
|
|
5119
5274
|
* Creating a program only ISSUES its compile + link; the browser resolves the link when the
|
|
5120
5275
|
* program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
|
package/js/inline3d-splat.js
CHANGED
|
@@ -434,6 +434,11 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
434
434
|
setEffect() {
|
|
435
435
|
throw effectsNotOnSpark('setEffect()');
|
|
436
436
|
},
|
|
437
|
+
warmEffect() {
|
|
438
|
+
throw effectsNotOnSpark('warmEffect()');
|
|
439
|
+
},
|
|
440
|
+
/** PlayCanvas-backend feature (its renderer links synchronously); nothing to warm here. */
|
|
441
|
+
warmEntity: () => Promise.resolve(0),
|
|
437
442
|
stopEffect() {
|
|
438
443
|
throw effectsNotOnSpark('stopEffect()');
|
|
439
444
|
},
|
package/model.d.ts
CHANGED
|
@@ -222,6 +222,14 @@ export interface ModelHandle {
|
|
|
222
222
|
|
|
223
223
|
remove(): void;
|
|
224
224
|
exclude(el: Element): void;
|
|
225
|
+
/**
|
|
226
|
+
* PlayCanvas only (resolves 0 on three). Compile the lit programs `entity` (default: the model)
|
|
227
|
+
* will draw with, in idle time, through the renderer's own shader lookup, and finish their links
|
|
228
|
+
* off the frame (KHR_parallel_shader_compile), so its first draw does not freeze. Warm while it
|
|
229
|
+
* is hidden, in the lighting it will be shown in. Resolves the number of programs warmed.
|
|
230
|
+
* docs/playcanvas-adapter.md §warmEntity.
|
|
231
|
+
*/
|
|
232
|
+
warmEntity(entity?: unknown, opts?: { timeoutMs?: number }): Promise<number>;
|
|
225
233
|
unexclude(el: Element): void;
|
|
226
234
|
/**
|
|
227
235
|
* The core window's `TileHandle.firstWoven`: when it is safe to reveal the canvas. Resolves
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
package/splat.d.ts
CHANGED
|
@@ -870,6 +870,12 @@ export type SplatPrepareOptions = SplatSourceOptions & {
|
|
|
870
870
|
* resident during the dwell.
|
|
871
871
|
*/
|
|
872
872
|
resident?: boolean;
|
|
873
|
+
/**
|
|
874
|
+
* Effects to compile during this prepare (after the asset and the transition), for a
|
|
875
|
+
* `playEffect` you will run once this asset is in — e.g. a reveal after the swap. Names, or
|
|
876
|
+
* `{ name, opts }` with the opts you will play with. See {@link SplatHandle.warmEffect}.
|
|
877
|
+
*/
|
|
878
|
+
warmEffects?: Array<Exclude<SplatEffectName, 'grade' | 'clip'> | { name: Exclude<SplatEffectName, 'grade' | 'clip'>; opts?: SplatEffectTiming & Record<string, unknown> }>;
|
|
873
879
|
};
|
|
874
880
|
|
|
875
881
|
/** setSource's particle transitions (docs/splat-effects.md §Particle transitions). */
|
|
@@ -1091,6 +1097,17 @@ export interface SplatHandle {
|
|
|
1091
1097
|
* display rig; a value set meanwhile applies when the video exits. `handle.rig.ipdFactor` /
|
|
1092
1098
|
* `parallaxFactor` read what is in force. The 2D↔3D mode-switch ramp still multiplies on top.
|
|
1093
1099
|
*/
|
|
1100
|
+
/**
|
|
1101
|
+
* `engine: 'playcanvas'` only (resolves 0 on Spark). Compile the lit programs of `entity` — your
|
|
1102
|
+
* content under `handle.engine.root`, e.g. a glTF — in idle time, through the renderer's own
|
|
1103
|
+
* shader lookup (MeshInstance.getShaderInstance with its exact pass, light hash, lights, view
|
|
1104
|
+
* format and the eye cameras' live shader params), and finish each link off the frame
|
|
1105
|
+
* (KHR_parallel_shader_compile). PlayCanvas's forward renderer otherwise links every new program
|
|
1106
|
+
* synchronously on its first draw: a 13-material car froze 0.8–0.95 s on a panel. Warm it while
|
|
1107
|
+
* it is hidden (`entity.enabled = false` is fine), with the lights it will be shown under; show
|
|
1108
|
+
* it after this resolves. Resolves the number of programs warmed. docs/playcanvas-adapter.md §warmEntity.
|
|
1109
|
+
*/
|
|
1110
|
+
warmEntity(entity: unknown, opts?: { timeoutMs?: number }): Promise<number>;
|
|
1094
1111
|
setStereo(opts: SplatStereoOptions | null): SplatHandle;
|
|
1095
1112
|
/** This tile in physical units — the core `TileHandle.displayMetrics()`, on the splat handle. Never rejects. */
|
|
1096
1113
|
displayMetrics(): Promise<DisplayMetrics>;
|
|
@@ -1202,6 +1219,17 @@ export interface SplatHandle {
|
|
|
1202
1219
|
name: Exclude<SplatEffectName, 'grade' | 'clip'>,
|
|
1203
1220
|
opts?: SplatEffectTiming & Record<string, unknown>,
|
|
1204
1221
|
): Promise<{ finished: boolean }>;
|
|
1222
|
+
/**
|
|
1223
|
+
* `engine: 'playcanvas'` only (throws on Spark). Compile the program `playEffect(name, opts)`
|
|
1224
|
+
* will need NOW, finishing the link in idle periods (KHR_parallel_shader_compile), so the
|
|
1225
|
+
* effect's first frame does not stall on it — a particle reveal (`'assemble'`) links for
|
|
1226
|
+
* 250–300 ms on a panel otherwise. Keyed on the chunk the play would install with the tile
|
|
1227
|
+
* effects on at the time: warm in the state you will play from. Resolves `true` once warm,
|
|
1228
|
+
* `false` when nothing could be warmed (an entity-scope effect, a custom fragment stage); the
|
|
1229
|
+
* effect then compiles on its first frame, as before. Validated at the call.
|
|
1230
|
+
* docs/splat-effects.md §Warming an effect.
|
|
1231
|
+
*/
|
|
1232
|
+
warmEffect(name: Exclude<SplatEffectName, 'grade' | 'clip'>, opts?: SplatEffectTiming & Record<string, unknown>): Promise<boolean>;
|
|
1205
1233
|
/**
|
|
1206
1234
|
* `engine: 'playcanvas'` only. Set a persistent effect (`grade`, `clip`, `custom`), hold a
|
|
1207
1235
|
* transition at `{ progress }`, or pass `null` to remove it (the exact baseline).
|