@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.
@@ -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
- /** The convergence shift never exceeds this fraction of the per-eye width, either way. */
11
- export const CONVERGENCE_MAX_FRACTION = 0.12;
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;
@@ -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
- const step = o.step || Math.round(b * 1.5); // sparse grid: half the blocks, same modes
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
- const members = blocks.filter((b) => Math.abs(b.d - d) <= tol);
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 };
@@ -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
- let best = null;
76
- // The tip, then a ring centred on the arrow's body: that is what content must not overlap.
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
- const su = geo.mirror ? 1 - pu : pu;
80
- const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
81
- const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
82
- if (x < 0 || x + b > E || y < 0 || y + b > h) continue;
83
- const m = matchBlock(img, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
84
- if (!m || m.c < MIN_NCC) continue;
85
- // Source disparity → displayed: each eye's crop moved its half by its own offset.
86
- const d = (m.d / scale - (rL.sx - rR.sx)) * k;
87
- if (best === null || d > best) best = d;
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 best;
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 warm = po.transition !== undefined ? resolveSwap(po) : null; // the setSource options it will get; throws on a bad one
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 cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
5066
- const code = codeOf();
5067
- let issued = 0;
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
@@ -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.42.0",
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).