@displayxr/inline3d 1.12.1 → 1.13.1

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.
@@ -0,0 +1,1122 @@
1
+ // inline3d-splat-effects.js — shader effects for the PlayCanvas splat backend (`./splat`,
2
+ // `engine: 'playcanvas'`).
3
+ //
4
+ // PREVIEW TIER (see docs/sdk-stability.md). Internal module: pages reach it through the splat
5
+ // handle (`reveal`, `playEffect`, `setEffect`, `stopEffect`, `effects`, `setSource`'s
6
+ // `transition`), never by importing this file. Guide: docs/splat-effects.md.
7
+ //
8
+ // ── The two engine hooks (PlayCanvas 2.22.3) ──────────────────────────────────────────────────
9
+ //
10
+ // TILE `app.scene.gsplat.material`'s `gsplatModifyVS` chunk: the unified renderer's render-
11
+ // time vertex stage, run for EVERY splat of the tile, every view, every frame. Cheap
12
+ // (no work-buffer re-render), and what the engine's own reveal scripts use.
13
+ // ENTITY `entity.gsplat.setWorkBufferModifier` + `setParameter`: runs when the engine copies
14
+ // ONE asset into the tile's work buffer. The only hook that can tell two assets apart
15
+ // (setSource's incoming vs outgoing). While one is installed the work buffer is re-
16
+ // rendered every frame (WORKBUFFER_UPDATE_ALWAYS); measured within noise on a 1.18M
17
+ // photo. Here `splat.index` is the asset's own FILE index (entity scope only).
18
+ //
19
+ // Both take the same three functions — modifySplatCenter / modifySplatRotationScale /
20
+ // modifySplatColor — on WORLD-space centres (the engine world = this adapter's content space).
21
+ //
22
+ // ── One generated chunk per hook ──────────────────────────────────────────────────────────────
23
+ //
24
+ // The SDK owns the chunk: every active effect of a scope is ONE function body with its own
25
+ // uniform prefix, and the chunk calls them in a FIXED order, STAGE_ORDER: grade → clip → reveal →
26
+ // pulse → custom. Removing the last effect deletes the chunk (tile) or the modifier (entity),
27
+ // which restores the engine's own default — the exact baseline, not a no-op look-alike.
28
+ //
29
+ // Adding an effect = one GLSL body + one registry entry in EFFECTS below: `glsl(P)` defines
30
+ // `P##center`, `P##rs`, `P##color`, and `uniforms(ctx, inst, amount)` returns the values.
31
+ //
32
+ // ── The stereo rule ───────────────────────────────────────────────────────────────────────────
33
+ //
34
+ // Every effect is keyed on WORLD position and TIME only. Each eye of a woven tile renders the
35
+ // same splats; a decision keyed on a screen position would differ between the eyes and read as
36
+ // rivalry. So no effect here reads gl_FragCoord or a view matrix, and the custom hook exposes no
37
+ // screen-space input. (The one image-space piece, setSource's frame snapshot, lives in the
38
+ // adapter and samples each eye's own half at the zero-disparity plane.)
39
+ //
40
+ // ── Sort caveat ───────────────────────────────────────────────────────────────────────────────
41
+ //
42
+ // The engine sorts by the ORIGINAL centres. Effects that keep each splat on its own ray (inflate,
43
+ // the wavefront ridge) or only hide/show splats (sweep, fade, dissolve's reveal) keep a valid
44
+ // order; a custom effect that moves splats far will blend slightly out of order while in flight.
45
+
46
+ import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
47
+
48
+ /** The fixed composition order of a generated chunk. */
49
+ export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom']);
50
+
51
+ /** Named easings (a function `(x) => y` on [0, 1] is accepted too). */
52
+ export const EASINGS = Object.freeze({
53
+ linear: (x) => x,
54
+ easeInQuad: (x) => x * x,
55
+ easeOutQuad: (x) => 1 - (1 - x) * (1 - x),
56
+ easeInOutQuad: (x) => (x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2),
57
+ easeInCubic: (x) => x * x * x,
58
+ easeOutCubic: (x) => 1 - Math.pow(1 - x, 3),
59
+ easeInOutCubic: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
60
+ easeInOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
61
+ });
62
+
63
+ /** The optical depth the `fade` effect ramps for (docs/splat-effects.md §fade, measured). */
64
+ export const FADE_EFFECT_OPTICAL_DEPTH = 24;
65
+
66
+ // Shared GLSL, emitted once per chunk.
67
+ //
68
+ // dxrFxHash / dxrFxNoise / dxrFxFbm: adapted from PlayCanvas engine
69
+ // scripts/esm/gsplat/shader-effect-dissolve.mjs @ v2.22.3, MIT (THIRD_PARTY_NOTICES.md).
70
+ const PRELUDE = `
71
+ float dxrFxHash(vec3 p) {
72
+ p = fract(p * vec3(443.8975, 397.2973, 491.1871));
73
+ p += dot(p, p.yzx + 19.19);
74
+ return fract((p.x + p.y) * p.z);
75
+ }
76
+ float dxrFxNoise(vec3 p) {
77
+ vec3 i = floor(p);
78
+ vec3 f = fract(p);
79
+ vec3 u = f * f * (3.0 - 2.0 * f);
80
+ return mix(
81
+ mix(mix(dxrFxHash(i + vec3(0.0, 0.0, 0.0)), dxrFxHash(i + vec3(1.0, 0.0, 0.0)), u.x),
82
+ mix(dxrFxHash(i + vec3(0.0, 1.0, 0.0)), dxrFxHash(i + vec3(1.0, 1.0, 0.0)), u.x), u.y),
83
+ mix(mix(dxrFxHash(i + vec3(0.0, 0.0, 1.0)), dxrFxHash(i + vec3(1.0, 0.0, 1.0)), u.x),
84
+ mix(dxrFxHash(i + vec3(0.0, 1.0, 1.0)), dxrFxHash(i + vec3(1.0, 1.0, 1.0)), u.x), u.y),
85
+ u.z);
86
+ }
87
+ float dxrFxFbm(vec3 p) {
88
+ float sum = 0.0;
89
+ float amp = 0.5;
90
+ for (int i = 0; i < 3; i++) {
91
+ sum += amp * dxrFxNoise(p);
92
+ p *= 2.02;
93
+ amp *= 0.5;
94
+ }
95
+ return sum / 0.875;
96
+ }
97
+ `;
98
+
99
+ const vec3Of = (v, what) => {
100
+ if (!Array.isArray(v) || v.length !== 3 || !v.every(Number.isFinite)) {
101
+ throw new TypeError(`@displayxr/inline3d/splat: ${what} must be [x, y, z] (finite numbers).`);
102
+ }
103
+ return [v[0], v[1], v[2]];
104
+ };
105
+ const num = (v, what, lo = -Infinity, hi = Infinity) => {
106
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < lo || v > hi) {
107
+ throw new RangeError(`@displayxr/inline3d/splat: ${what} must be a number in [${lo}, ${hi}], got ${v}.`);
108
+ }
109
+ return v;
110
+ };
111
+
112
+ /** Distance from `c` to the farthest corner of a {center, extent} box. */
113
+ function farthestCorner(c, box) {
114
+ if (!box) return 1;
115
+ let m = 0;
116
+ for (const sx of [-0.5, 0.5]) for (const sy of [-0.5, 0.5]) for (const sz of [-0.5, 0.5]) {
117
+ const x = box.center[0] + sx * box.extent[0] - c[0];
118
+ const y = box.center[1] + sy * box.extent[1] - c[1];
119
+ const z = box.center[2] + sz * box.extent[2] - c[2];
120
+ m = Math.max(m, Math.hypot(x, y, z));
121
+ }
122
+ return m > 0 ? m : 1;
123
+ }
124
+ const maxExtent = (box) => (box ? Math.max(box.extent[0], box.extent[1], box.extent[2], 1e-6) : 1);
125
+
126
+ // ── the registry ──────────────────────────────────────────────────────────────────────────────
127
+ //
128
+ // kind 'transition': played over time (playEffect / reveal), direction 'in' (the asset arrives:
129
+ // the effect goes from its start state to the untouched asset, then is REMOVED) or 'out' (the
130
+ // asset leaves: from untouched to the end state, which is HELD until stopEffect). The GLSL
131
+ // gets `amount` = how much of the untouched asset shows (1 = untouched; every body returns
132
+ // early at 1, so amount 1 is the baseline exactly).
133
+ // kind 'persistent': setEffect only, no clock (grade, clip).
134
+ // kind 'pulse': played; a one-off overlay that is removed at the end.
135
+
136
+ /** @type {Record<string, object>} */
137
+ export const EFFECTS = {
138
+ // The gallery's Spatial View reveal: z' = D + (z − D)·s along rays from the centre of projection
139
+ // O (the eyes' midpoint by default — invisible from there, so the eyes see DISPARITY arrive, not
140
+ // a zoom), centre AND scale scaled by the same λ (footprint fixed from O). s eases from
141
+ // `residual` (0.05: a perfectly flat cloud loses its depth ORDER) to 1.
142
+ inflate: {
143
+ stage: 'reveal',
144
+ kind: 'transition',
145
+ defaults: { durationMs: 1200, easing: 'easeOutCubic', holdMs: 0, origin: 'eyes', direction: 'in', residual: 0.05 },
146
+ validate: (o) => {
147
+ if (o.residual !== undefined) num(o.residual, 'inflate residual', 0, 1);
148
+ },
149
+ glsl: (P) => `
150
+ uniform float ${P}amount;
151
+ uniform float ${P}s0;
152
+ uniform vec3 ${P}O;
153
+ uniform vec3 ${P}A;
154
+ uniform float ${P}D;
155
+ float ${P}lambda;
156
+ void ${P}center(inout vec3 c) {
157
+ ${P}lambda = 1.0;
158
+ if (${P}amount >= 1.0) return;
159
+ float s = mix(${P}s0, 1.0, ${P}amount);
160
+ vec3 v = c - ${P}O;
161
+ float d = dot(v, ${P}A);
162
+ if (d <= 1e-4) return;
163
+ ${P}lambda = (${P}D + (d - ${P}D) * s) / d;
164
+ c = ${P}O + v * ${P}lambda;
165
+ }
166
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) { sc *= ${P}lambda; }
167
+ void ${P}color(vec3 c, inout vec4 col) {}
168
+ `,
169
+ uniforms: (ctx, inst, amount) => {
170
+ const eyes = ctx.eyes();
171
+ const O = inst.originPoint(ctx, eyes);
172
+ const A = eyes.axis;
173
+ const f = ctx.focus();
174
+ let D = (f[0] - O[0]) * A[0] + (f[1] - O[1]) * A[1] + (f[2] - O[2]) * A[2];
175
+ if (!(D > 1e-4)) D = 1;
176
+ return { amount, s0: inst.opts.residual, O, A, D };
177
+ },
178
+ },
179
+
180
+ // A radial dissolve-in from a world point: a jittered sphere front whose radius reaches the
181
+ // farthest corner of the FRAMING box (the percentile box the camera frames, not the file's raw
182
+ // bound — one outlier would otherwise slow the whole sweep). Splats inside grow from dots, with
183
+ // a tinted band at the front. Hides/shows only: no splat moves.
184
+ sweep: {
185
+ stage: 'reveal',
186
+ kind: 'transition',
187
+ defaults: { durationMs: 1500, easing: 'easeInQuad', holdMs: 0, origin: 'focus', direction: 'in', band: 0.1, edgeColor: [0.2, 0.9, 1.0], edge: 0.6 },
188
+ validate: (o) => {
189
+ if (o.band !== undefined) num(o.band, 'sweep band', 1e-4, 1);
190
+ if (o.edge !== undefined) num(o.edge, 'sweep edge', 0, 4);
191
+ if (o.edgeColor !== undefined) vec3Of(o.edgeColor, 'sweep edgeColor');
192
+ },
193
+ glsl: (P) => `
194
+ uniform float ${P}amount;
195
+ uniform vec3 ${P}C;
196
+ uniform float ${P}R;
197
+ uniform float ${P}B;
198
+ uniform vec3 ${P}E;
199
+ float ${P}k(vec3 c) {
200
+ float j = (dxrFxHash(c) - 0.5) * ${P}B;
201
+ return clamp((${P}R - length(c - ${P}C) + j) / ${P}B, 0.0, 1.0);
202
+ }
203
+ void ${P}center(inout vec3 c) {}
204
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
205
+ if (${P}amount >= 1.0) return;
206
+ float k = ${P}k(oc);
207
+ if (k <= 0.0) { sc = vec3(0.0); return; }
208
+ sc *= mix(0.15, 1.0, k * k);
209
+ }
210
+ void ${P}color(vec3 c, inout vec4 col) {
211
+ if (${P}amount >= 1.0) return;
212
+ float k = ${P}k(c);
213
+ if (k <= 0.0) { col.a = 0.0; return; }
214
+ col.rgb += ${P}E * (1.0 - k);
215
+ }
216
+ `,
217
+ start: (ctx, inst) => {
218
+ const C = inst.originPoint(ctx);
219
+ const far = farthestCorner(C, ctx.framing());
220
+ inst.state.band = inst.opts.band * far;
221
+ inst.state.far = far;
222
+ inst.state.C = C;
223
+ },
224
+ // The front starts one band BEFORE the origin (amount 0 shows nothing, jitter included) and
225
+ // ends past the farthest framed corner.
226
+ uniforms: (ctx, inst, amount) => ({
227
+ amount,
228
+ C: inst.state.C,
229
+ R: -inst.state.band + amount * (inst.state.far + 2.5 * inst.state.band),
230
+ B: inst.state.band,
231
+ E: inst.opts.edgeColor.map((x) => x * inst.opts.edge),
232
+ }),
233
+ },
234
+
235
+ // Coverage-linear opacity: α' = 1 − (1 − α)^k with k = coverageExponent(amount). Plain α·t
236
+ // saturates on a dense photo (a pixel under many near-opaque splats is covered long before t
237
+ // reaches 1).
238
+ fade: {
239
+ stage: 'reveal',
240
+ kind: 'transition',
241
+ defaults: { durationMs: 800, easing: 'linear', holdMs: 0, origin: 'focus', direction: 'in' },
242
+ glsl: (P) => `
243
+ uniform float ${P}amount;
244
+ uniform float ${P}k;
245
+ void ${P}center(inout vec3 c) {}
246
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {}
247
+ void ${P}color(vec3 c, inout vec4 col) {
248
+ if (${P}amount >= 1.0) return;
249
+ col.a = ${P}k <= 0.0 ? 0.0 : 1.0 - pow(max(1.0 - col.a, ${FADE_TRANSMITTANCE_FLOOR.toFixed(4)}), ${P}k);
250
+ }
251
+ `,
252
+ uniforms: (ctx, inst, amount) => ({ amount, k: coverageExponent(amount, FADE_EFFECT_OPTICAL_DEPTH) }),
253
+ },
254
+
255
+ // The engine's dissolve, as a reveal: splats burn in along an fbm noise front — lifted along
256
+ // the camera's UP (never toward the viewer), swaying, glowing at the edge. Sizes follow the
257
+ // framing box, so the look is the same on a 2 cm object and a 40 m street.
258
+ //
259
+ // Adapted from PlayCanvas engine scripts/esm/gsplat/shader-effect-dissolve.mjs @ v2.22.3, MIT
260
+ // (THIRD_PARTY_NOTICES.md): the burn/lift/sway/edge body; the crop and AABB gates are dropped
261
+ // and the progress is inverted (amount 1 = intact).
262
+ dissolve: {
263
+ stage: 'reveal',
264
+ kind: 'transition',
265
+ defaults: { durationMs: 2000, easing: 'linear', holdMs: 0, origin: 'focus', direction: 'in', noiseScale: 3, edgeWidth: 0.12, edgeColor: [1.0, 0.45, 0.1], lift: 0.25, wave: 0.03 },
266
+ validate: (o) => {
267
+ if (o.noiseScale !== undefined) num(o.noiseScale, 'dissolve noiseScale', 1e-3, 1e3);
268
+ if (o.edgeWidth !== undefined) num(o.edgeWidth, 'dissolve edgeWidth', 1e-3, 1);
269
+ if (o.lift !== undefined) num(o.lift, 'dissolve lift', 0, 10);
270
+ if (o.wave !== undefined) num(o.wave, 'dissolve wave', 0, 1);
271
+ if (o.edgeColor !== undefined) vec3Of(o.edgeColor, 'dissolve edgeColor');
272
+ },
273
+ glsl: (P) => `
274
+ uniform float ${P}amount;
275
+ uniform float ${P}freq;
276
+ uniform float ${P}ew;
277
+ uniform vec3 ${P}ec;
278
+ uniform vec3 ${P}up;
279
+ uniform float ${P}lift;
280
+ uniform float ${P}wa;
281
+ uniform float ${P}wf;
282
+ uniform float ${P}time;
283
+ // The burn is keyed on the ORIGINAL centre and kept for the colour stage (as the engine's
284
+ // script does), so the lift cannot change which splats burn. -1 = not computed this splat (a
285
+ // colour-only work-buffer pass skips the centre stage): the colour stage then computes it.
286
+ float ${P}b = -1.0;
287
+ float ${P}burn(vec3 c, out float n) {
288
+ n = dxrFxFbm(c * ${P}freq);
289
+ return clamp(((1.0 - ${P}amount) * (1.0 + ${P}ew) - n) / ${P}ew, 0.0, 1.0);
290
+ }
291
+ void ${P}center(inout vec3 c) {
292
+ ${P}b = -1.0;
293
+ if (${P}amount >= 1.0) return;
294
+ float n;
295
+ float b = ${P}burn(c, n);
296
+ ${P}b = b;
297
+ if (b <= 0.0) return;
298
+ float travel = b * b;
299
+ vec3 off = ${P}up * (travel * ${P}lift);
300
+ float phase = n * 43.7;
301
+ off.x += sin(c.y * ${P}wf + phase + ${P}time * 2.0) * ${P}wa * travel;
302
+ off.z += cos(c.x * ${P}wf + phase + ${P}time * 1.7) * ${P}wa * travel;
303
+ c += off;
304
+ }
305
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
306
+ if (${P}amount >= 1.0) return;
307
+ float n;
308
+ float b = ${P}burn(oc, n);
309
+ if (b <= 0.0) return;
310
+ if (b >= 1.0) { sc = vec3(0.0); return; }
311
+ float size = gsplatGetSizeFromScale(sc);
312
+ sc = mix(sc, vec3(size), min(b * 3.0, 1.0));
313
+ sc *= (1.0 - b);
314
+ }
315
+ void ${P}color(vec3 c, inout vec4 col) {
316
+ if (${P}amount >= 1.0) return;
317
+ float n;
318
+ float b = ${P}b >= 0.0 ? ${P}b : ${P}burn(c, n);
319
+ if (b <= 0.0) return;
320
+ col.rgb = mix(col.rgb, ${P}ec, smoothstep(0.0, 0.4, b));
321
+ col.a *= 1.0 - smoothstep(0.5, 1.0, b);
322
+ }
323
+ `,
324
+ start: (ctx, inst) => {
325
+ const ext = maxExtent(ctx.framing());
326
+ inst.state.ext = ext;
327
+ inst.state.up = ctx.eyes().up;
328
+ },
329
+ uniforms: (ctx, inst, amount, tMs) => ({
330
+ amount,
331
+ freq: inst.opts.noiseScale / inst.state.ext,
332
+ ew: inst.opts.edgeWidth,
333
+ ec: inst.opts.edgeColor,
334
+ up: inst.state.up,
335
+ lift: inst.opts.lift * inst.state.ext,
336
+ wa: inst.opts.wave * inst.state.ext,
337
+ wf: 6 / inst.state.ext,
338
+ time: inst.elapsedS(tMs),
339
+ }),
340
+ },
341
+
342
+ // A ring of light expanding from a world point and fading out. Colour only.
343
+ pulse: {
344
+ stage: 'pulse',
345
+ kind: 'pulse',
346
+ defaults: { durationMs: 1200, easing: 'easeOutQuad', holdMs: 0, origin: 'focus', color: [1, 1, 1], strength: 0.35, band: 0.06 },
347
+ validate: (o) => {
348
+ if (o.color !== undefined) vec3Of(o.color, 'pulse color');
349
+ if (o.strength !== undefined) num(o.strength, 'pulse strength', 0, 4);
350
+ if (o.band !== undefined) num(o.band, 'pulse band', 1e-4, 1);
351
+ if (o.radius !== undefined) num(o.radius, 'pulse radius', 1e-6);
352
+ },
353
+ glsl: (P) => `
354
+ uniform vec3 ${P}C;
355
+ uniform float ${P}R;
356
+ uniform float ${P}B;
357
+ uniform vec3 ${P}K;
358
+ void ${P}center(inout vec3 c) {}
359
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {}
360
+ void ${P}color(vec3 c, inout vec4 col) {
361
+ float x = (length(c - ${P}C) - ${P}R) / ${P}B;
362
+ col.rgb += ${P}K * exp(-x * x);
363
+ }
364
+ `,
365
+ start: (ctx, inst) => {
366
+ const C = inst.originPoint(ctx);
367
+ inst.state.C = C;
368
+ // How far the ring travels, world units: `radius`, else the farthest framed corner.
369
+ inst.state.R1 = inst.opts.radius ?? farthestCorner(C, ctx.framing());
370
+ },
371
+ uniforms: (ctx, inst, e) => ({
372
+ C: inst.state.C,
373
+ R: e * inst.state.R1,
374
+ B: Math.max(1e-6, inst.opts.band * inst.state.R1),
375
+ K: inst.opts.color.map((x) => x * inst.opts.strength * (1 - e)),
376
+ }),
377
+ },
378
+
379
+ // Colour grade: exposure (stops), contrast about mid grey, saturation, a tint multiplier.
380
+ grade: {
381
+ stage: 'grade',
382
+ kind: 'persistent',
383
+ defaults: { exposure: 0, contrast: 1, saturation: 1, tint: [1, 1, 1] },
384
+ validate: (o) => {
385
+ if (o.exposure !== undefined) num(o.exposure, 'grade exposure', -10, 10);
386
+ if (o.contrast !== undefined) num(o.contrast, 'grade contrast', 0, 10);
387
+ if (o.saturation !== undefined) num(o.saturation, 'grade saturation', 0, 10);
388
+ if (o.tint !== undefined) vec3Of(o.tint, 'grade tint');
389
+ },
390
+ glsl: (P) => `
391
+ uniform float ${P}gain;
392
+ uniform float ${P}contrast;
393
+ uniform float ${P}sat;
394
+ uniform vec3 ${P}tint;
395
+ void ${P}center(inout vec3 c) {}
396
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {}
397
+ void ${P}color(vec3 c, inout vec4 col) {
398
+ vec3 rgb = col.rgb * ${P}gain;
399
+ rgb = (rgb - 0.5) * ${P}contrast + 0.5;
400
+ float l = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
401
+ col.rgb = mix(vec3(l), rgb, ${P}sat) * ${P}tint;
402
+ }
403
+ `,
404
+ uniforms: (ctx, inst) => ({
405
+ gain: Math.pow(2, inst.opts.exposure),
406
+ contrast: inst.opts.contrast,
407
+ sat: inst.opts.saturation,
408
+ tint: inst.opts.tint,
409
+ }),
410
+ },
411
+
412
+ // Clip to a box or a sphere, given in the splat's OWN (model) space like setFocus; `invert`
413
+ // keeps the outside instead.
414
+ clip: {
415
+ stage: 'clip',
416
+ kind: 'persistent',
417
+ defaults: { invert: false },
418
+ validate: (o) => {
419
+ const has = (o.box ? 1 : 0) + (o.sphere ? 1 : 0);
420
+ if (has !== 1) throw new TypeError("@displayxr/inline3d/splat: setEffect('clip') needs exactly one of { box: { min, max } } or { sphere: { center, radius } }.");
421
+ if (o.box) {
422
+ vec3Of(o.box.min, 'clip box.min');
423
+ vec3Of(o.box.max, 'clip box.max');
424
+ }
425
+ if (o.sphere) {
426
+ vec3Of(o.sphere.center, 'clip sphere.center');
427
+ num(o.sphere.radius, 'clip sphere.radius', 0);
428
+ }
429
+ },
430
+ glsl: (P) => `
431
+ uniform float ${P}mode;
432
+ uniform vec3 ${P}lo;
433
+ uniform vec3 ${P}hi;
434
+ uniform vec3 ${P}sc;
435
+ uniform float ${P}sr;
436
+ uniform float ${P}inv;
437
+ bool ${P}out(vec3 c) {
438
+ bool inside = ${P}mode < 0.5
439
+ ? all(greaterThanEqual(c, ${P}lo)) && all(lessThanEqual(c, ${P}hi))
440
+ : length(c - ${P}sc) <= ${P}sr;
441
+ return ${P}inv > 0.5 ? inside : !inside;
442
+ }
443
+ void ${P}center(inout vec3 c) {}
444
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) { if (${P}out(oc)) sc = vec3(0.0); }
445
+ void ${P}color(vec3 c, inout vec4 col) { if (${P}out(c)) col.a = 0.0; }
446
+ `,
447
+ uniforms: (ctx, inst) => {
448
+ const o = inst.opts;
449
+ if (o.box) {
450
+ const a = ctx.modelToContent(o.box.min);
451
+ const b = ctx.modelToContent(o.box.max);
452
+ return {
453
+ mode: 0,
454
+ lo: [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])],
455
+ hi: [Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])],
456
+ sc: [0, 0, 0],
457
+ sr: 0,
458
+ inv: o.invert ? 1 : 0,
459
+ };
460
+ }
461
+ return { mode: 1, lo: [0, 0, 0], hi: [0, 0, 0], sc: ctx.modelToContent(o.sphere.center), sr: o.sphere.radius, inv: o.invert ? 1 : 0 };
462
+ },
463
+ },
464
+
465
+ // setSource's wavefront transition, incoming side — the approved photo-frame transition
466
+ // (PROPOSALS.md #1; the prototype's mode 3): a soft front crosses the picture left → right over
467
+ // normalised u, `band` (0.18) of the travel wide; column u commits A → B over
468
+ // lt = clamp((t − u·(1 − band)) / band, 0, 1) with a smoothstep. The image half of the commit
469
+ // (the outgoing frame giving way) is the adapter's snapshot wipe, on the SAME lt. This half is
470
+ // the depth RIDGE riding the front: sin(π·lt) × `ridge` world units (0.03 m on a metric photo)
471
+ // toward the eyes, along each splat's own ray with its scale by the same λ — it keeps its place
472
+ // and size in the picture and only comes forward. CAPPED so the extra disparity never exceeds
473
+ // `ridgeMaxDisparity` of the eye view's width: Δ ≤ cap · 2·tan(fovX/2) · d² / eyeSeparation.
474
+ // u is the splat's angle in the transition's fixed camera frame (x/z — for a photo, its grid
475
+ // column), world position only, so both eyes agree.
476
+ wavefront: {
477
+ stage: 'reveal',
478
+ kind: 'transition',
479
+ internal: true,
480
+ defaults: { durationMs: 2000, easing: 'easeInOutSine', holdMs: 0, origin: 'eyes', direction: 'in', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
481
+ validate: (o) => {
482
+ if (o.band !== undefined) num(o.band, 'wavefront band', 0.01, 1);
483
+ if (o.ridge !== undefined) num(o.ridge, 'wavefront ridge', 0, 1);
484
+ if (o.ridgeMaxDisparity !== undefined) num(o.ridgeMaxDisparity, 'wavefront ridgeMaxDisparity', 0, 0.05);
485
+ },
486
+ glsl: (P) => `
487
+ uniform float ${P}amount;
488
+ uniform float ${P}band;
489
+ uniform float ${P}ridge;
490
+ uniform float ${P}capK;
491
+ uniform vec3 ${P}O;
492
+ uniform vec3 ${P}A;
493
+ uniform vec3 ${P}X;
494
+ uniform float ${P}tanX;
495
+ float ${P}lambda;
496
+ void ${P}center(inout vec3 c) {
497
+ ${P}lambda = 1.0;
498
+ if (${P}amount >= 1.0) return;
499
+ vec3 v = c - ${P}O;
500
+ float d = dot(v, ${P}A);
501
+ if (d <= 1e-4) return;
502
+ float u = 0.5 + 0.5 * dot(v, ${P}X) / (d * ${P}tanX);
503
+ float lt = clamp((${P}amount - u * (1.0 - ${P}band)) / ${P}band, 0.0, 1.0);
504
+ float bump = min(${P}ridge, ${P}capK * d * d) * sin(lt * 3.14159265);
505
+ ${P}lambda = (d - bump) / d;
506
+ c = ${P}O + v * ${P}lambda;
507
+ }
508
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) { sc *= ${P}lambda; }
509
+ void ${P}color(vec3 c, inout vec4 col) {}
510
+ `,
511
+ start: (ctx, inst) => {
512
+ const eyes = ctx.eyes();
513
+ inst.state.O = eyes.origin;
514
+ inst.state.A = eyes.axis;
515
+ inst.state.X = eyes.right;
516
+ inst.state.tanX = eyes.tanHalfFovX > 0 ? eyes.tanHalfFovX : 0.5;
517
+ // No eye separation (2D): no disparity to cap (the ridge moves along the camera's own rays).
518
+ inst.state.capK = eyes.separation > 0 ? (inst.opts.ridgeMaxDisparity * 2 * inst.state.tanX) / eyes.separation : 1e9;
519
+ },
520
+ uniforms: (ctx, inst, amount) => ({
521
+ amount,
522
+ band: inst.opts.band,
523
+ ridge: inst.opts.ridge,
524
+ capK: inst.state.capK,
525
+ O: inst.state.O,
526
+ A: inst.state.A,
527
+ X: inst.state.X,
528
+ tanX: inst.state.tanX,
529
+ }),
530
+ },
531
+
532
+ // Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
533
+ // entity, driven by the adapter.
534
+ xfade: {
535
+ stage: 'reveal',
536
+ kind: 'persistent',
537
+ internal: true,
538
+ defaults: { k: 1 },
539
+ glsl: (P) => `
540
+ uniform float ${P}k;
541
+ void ${P}center(inout vec3 c) {}
542
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {}
543
+ void ${P}color(vec3 c, inout vec4 col) {
544
+ if (${P}k >= 1.0) return;
545
+ col.a = ${P}k <= 0.0 ? 0.0 : 1.0 - pow(max(1.0 - col.a, ${FADE_TRANSMITTANCE_FLOOR.toFixed(4)}), ${P}k);
546
+ }
547
+ `,
548
+ uniforms: (ctx, inst) => ({ k: inst.opts.k }),
549
+ },
550
+ };
551
+ // `deflate` = inflate, out: the photo flattens onto its convergence plane (zero disparity) and
552
+ // stays flat until stopEffect.
553
+ EFFECTS.deflate = { ...EFFECTS.inflate, defaults: { ...EFFECTS.inflate.defaults, direction: 'out', easing: 'easeInOutSine' } };
554
+
555
+ /**
556
+ * The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
557
+ * prototype's formula: column u starts at t = u·(1 − band) and has committed band later, so
558
+ * u = 0 starts at t = 0 and u = 1 finishes at t = 1. The adapter's snapshot wipe and the ridge
559
+ * both use it.
560
+ */
561
+ export function wavefrontCommit(t, u, band) {
562
+ return Math.min(1, Math.max(0, (t - u * (1 - band)) / band));
563
+ }
564
+
565
+ /** Names a page may pass to playEffect / setEffect / reveal. */
566
+ export const PUBLIC_EFFECTS = Object.freeze(
567
+ Object.keys(EFFECTS)
568
+ .filter((n) => !EFFECTS[n].internal)
569
+ .concat(['custom'])
570
+ .sort(),
571
+ );
572
+ /** addSplat's `reveal` accepts these (the transition effects). */
573
+ export const REVEAL_EFFECTS = Object.freeze(['inflate', 'sweep', 'dissolve', 'fade']);
574
+
575
+ // ── custom GLSL ───────────────────────────────────────────────────────────────────────────────
576
+
577
+ const HOOK_FNS = [
578
+ ['modifySplatCenter', 'center', 'void %(inout vec3 c) {}'],
579
+ ['modifySplatRotationScale', 'rs', 'void %(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {}'],
580
+ ['modifySplatColor', 'color', 'void %(vec3 c, inout vec4 col) {}'],
581
+ ];
582
+
583
+ /** Screen-space inputs a custom body may not read (the stereo rule). */
584
+ const SCREEN_SPACE = /\b(gl_FragCoord|gl_Position|matrix_view|matrix_projection|matrix_viewProjection|viewport_size|view_position|uCameraPosition)\b/;
585
+
586
+ /**
587
+ * A custom effect's body under prefix P: the engine-shaped functions renamed to P##center/rs/
588
+ * color (missing ones stubbed), with `dxrProgress` and `dxrTime` defined for it.
589
+ */
590
+ export function customGlsl(P, glsl) {
591
+ let body = String(glsl);
592
+ let out = `uniform float ${P}p;\nuniform float ${P}t;\n#define dxrProgress ${P}p\n#define dxrTime ${P}t\n`;
593
+ for (const [engineName, suffix, stub] of HOOK_FNS) {
594
+ const re = new RegExp(`\\b${engineName}\\b`, 'g');
595
+ if (re.test(body)) body = body.replace(re, `${P}${suffix}`);
596
+ else out += stub.replace('%', `${P}${suffix}`) + '\n';
597
+ }
598
+ return out + body + '\n#undef dxrProgress\n#undef dxrTime\n';
599
+ }
600
+
601
+ function validateCustom(o) {
602
+ if (typeof o.glsl !== 'string' || !o.glsl.trim()) {
603
+ throw new TypeError("@displayxr/inline3d/splat: the custom effect needs { glsl: '…' } — engine-shaped modifySplatCenter / modifySplatRotationScale / modifySplatColor bodies (docs/splat-effects.md §custom).");
604
+ }
605
+ for (const [k, s] of [['glsl', o.glsl], ['fragmentGlsl', o.fragmentGlsl]]) {
606
+ if (s !== undefined && typeof s !== 'string') throw new TypeError(`@displayxr/inline3d/splat: custom ${k} must be a string.`);
607
+ if (typeof s === 'string' && SCREEN_SPACE.test(s)) {
608
+ throw new Error(
609
+ `@displayxr/inline3d/splat: custom ${k} reads a screen-space input (${s.match(SCREEN_SPACE)[1]}). Effects are keyed on ` +
610
+ 'world position and time only — a screen-keyed decision differs between the two eyes of a woven tile.',
611
+ );
612
+ }
613
+ }
614
+ if (o.fragmentGlsl !== undefined && o.scope === 'entity') {
615
+ throw new Error("@displayxr/inline3d/splat: custom fragmentGlsl is tile scope only (the work buffer has no fragment stage).");
616
+ }
617
+ if (o.uniforms !== undefined) {
618
+ if (!o.uniforms || typeof o.uniforms !== 'object') throw new TypeError('@displayxr/inline3d/splat: custom uniforms must be an object.');
619
+ for (const [k, v] of Object.entries(o.uniforms)) {
620
+ if (!/^[A-Za-z_]\w*$/.test(k) || k.startsWith('gl_') || k.startsWith('dxr')) {
621
+ throw new Error(`@displayxr/inline3d/splat: custom uniform name '${k}' is not allowed (a GLSL identifier, not gl_* or dxr*).`);
622
+ }
623
+ const ok = typeof v === 'function' || (typeof v === 'number' && Number.isFinite(v)) || (Array.isArray(v) && v.length >= 1 && v.length <= 4 && v.every(Number.isFinite));
624
+ if (!ok) throw new TypeError(`@displayxr/inline3d/splat: custom uniform '${k}' must be a number, a 1–4 number array, or (tMs) => value.`);
625
+ }
626
+ }
627
+ }
628
+
629
+ // ── option validation ─────────────────────────────────────────────────────────────────────────
630
+
631
+ const isCustom = (name) => name === 'custom' || (typeof name === 'string' && name.startsWith('custom:') && name.length > 7);
632
+ const defOf = (name) => (isCustom(name) ? CUSTOM_DEF : EFFECTS[name]);
633
+
634
+ const CUSTOM_DEF = {
635
+ stage: 'custom',
636
+ kind: 'custom',
637
+ defaults: { durationMs: 1000, easing: 'linear', holdMs: 0, scope: 'tile' },
638
+ };
639
+
640
+ /**
641
+ * Validate + default one effect call. `mode` is 'play' | 'set'. Throws on anything a page could
642
+ * not have meant (an unknown name, an effect in the wrong call, a bad number).
643
+ */
644
+ export function resolveEffectOptions(name, opts = {}, mode = 'play', { internal = false } = {}) {
645
+ const def = defOf(name);
646
+ if (!def || (def.internal && !internal)) {
647
+ throw new Error(`@displayxr/inline3d/splat: unknown effect '${name}'. Known: ${PUBLIC_EFFECTS.join(', ')} (and 'custom:<name>').`);
648
+ }
649
+ if (opts === null || typeof opts !== 'object') throw new TypeError(`@displayxr/inline3d/splat: ${name} options must be an object.`);
650
+ if (mode === 'play' && def.kind === 'persistent') {
651
+ throw new Error(`@displayxr/inline3d/splat: '${name}' is a persistent effect — use setEffect('${name}', params), not playEffect.`);
652
+ }
653
+ const o = { ...def.defaults, ...opts };
654
+ if (o.durationMs !== undefined) num(o.durationMs, `${name} durationMs`, 0, 600000);
655
+ if (o.holdMs !== undefined) num(o.holdMs, `${name} holdMs`, 0, 600000);
656
+ if (o.easing !== undefined && typeof o.easing !== 'function' && !EASINGS[o.easing]) {
657
+ throw new Error(`@displayxr/inline3d/splat: unknown easing '${o.easing}'. Known: ${Object.keys(EASINGS).join(', ')}, or a function.`);
658
+ }
659
+ if (o.direction !== undefined && o.direction !== 'in' && o.direction !== 'out') {
660
+ throw new Error(`@displayxr/inline3d/splat: ${name} direction must be 'in' or 'out'.`);
661
+ }
662
+ if (o.scope !== undefined && o.scope !== 'tile' && o.scope !== 'entity') {
663
+ throw new Error(`@displayxr/inline3d/splat: ${name} scope must be 'tile' or 'entity'.`);
664
+ }
665
+ if (o.progress !== undefined) num(o.progress, `${name} progress`, 0, 1);
666
+ if (o.origin !== undefined) o.origin = resolveOriginSpec(o.origin, name);
667
+ if (isCustom(name)) validateCustom(o);
668
+ else def.validate?.(o);
669
+ if (typeof o.easing === 'string') o.easingFn = EASINGS[o.easing];
670
+ else if (typeof o.easing === 'function') o.easingFn = o.easing;
671
+ return o;
672
+ }
673
+
674
+ /**
675
+ * Origins: 'focus' (the focus point, the default for sweep/dissolve/pulse), 'eyes' (the eyes'
676
+ * midpoint, the inflate default), [x, y, z] in the splat's own space, or a 2-D point on the
677
+ * canvas — [clientX, clientY] or { clientX, clientY } — turned into a world point with pick()
678
+ * when the effect starts.
679
+ */
680
+ export function resolveOriginSpec(origin, name = 'effect') {
681
+ if (origin === 'focus' || origin === 'eyes') return { kind: origin };
682
+ if (Array.isArray(origin) && origin.length === 3 && origin.every(Number.isFinite)) return { kind: 'model', point: origin.slice() };
683
+ if (Array.isArray(origin) && origin.length === 2 && origin.every(Number.isFinite)) return { kind: 'client', x: origin[0], y: origin[1] };
684
+ if (origin && typeof origin === 'object' && Number.isFinite(origin.clientX) && Number.isFinite(origin.clientY)) {
685
+ return { kind: 'client', x: origin.clientX, y: origin.clientY };
686
+ }
687
+ if (origin && typeof origin === 'object' && 'kind' in origin) return origin; // already resolved
688
+ throw new TypeError(
689
+ `@displayxr/inline3d/splat: ${name} origin must be 'focus', 'eyes', [x, y, z] (the splat's own space) or a canvas point [clientX, clientY].`,
690
+ );
691
+ }
692
+
693
+ // ── composition ───────────────────────────────────────────────────────────────────────────────
694
+
695
+ const slug = (name) => name.replace(/[^A-Za-z0-9]/g, '_');
696
+
697
+ /** Uniform prefix of an effect instance. */
698
+ export const prefixOf = (name) => `dxrFx_${slug(name)}_`;
699
+
700
+ /**
701
+ * THE generated modifier: `instances` = [{ name, def, opts }] of one scope, emitted in
702
+ * STAGE_ORDER (ties keep insertion order). Pure — test/splat-effects.test.mjs pins it.
703
+ */
704
+ export function composeModifier(instances) {
705
+ const list = instances
706
+ .map((inst, i) => ({ inst, i, s: STAGE_ORDER.indexOf(inst.def.stage) }))
707
+ .sort((a, b) => a.s - b.s || a.i - b.i)
708
+ .map((x) => x.inst);
709
+ let code = '// generated by @displayxr/inline3d/splat (splat effects)\n' + PRELUDE;
710
+ const calls = { center: [], rs: [], color: [] };
711
+ for (const inst of list) {
712
+ const P = prefixOf(inst.name);
713
+ code += `// ── ${inst.name} (${inst.def.stage})\n`;
714
+ code += isCustom(inst.name) ? customGlsl(P, inst.opts.glsl) : inst.def.glsl(P);
715
+ calls.center.push(` ${P}center(center);`);
716
+ calls.rs.push(` ${P}rs(originalCenter, modifiedCenter, rotation, scale);`);
717
+ calls.color.push(` ${P}color(center, color);`);
718
+ }
719
+ code += `void modifySplatCenter(inout vec3 center) {\n${calls.center.join('\n')}\n}\n`;
720
+ code += `void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {\n${calls.rs.join('\n')}\n}\n`;
721
+ code += `void modifySplatColor(vec3 center, inout vec4 color) {\n${calls.color.join('\n')}\n}\n`;
722
+ return { code, order: list.map((i) => i.name) };
723
+ }
724
+
725
+ // ── the runner ────────────────────────────────────────────────────────────────────────────────
726
+
727
+ /**
728
+ * One tile's effects. The adapter owns one and drives `tick(tMs)` once per frame, before the
729
+ * engine renders.
730
+ *
731
+ * `ctx` (supplied by the adapter; every point in WORLD/content space):
732
+ * pc, app the engine module and the tile's AppBase
733
+ * eyes() { origin, axis, right, up, tanHalfFovX } — the eyes' midpoint
734
+ * and the view frame of the first eye
735
+ * focus() the focus point
736
+ * framing() { center, extent } — the box the camera frames, or null
737
+ * pick(clientX, clientY) the world point under a canvas point, or null
738
+ * modelToContent(p) the splat's own space → world
739
+ * entity() the current asset's entity (entity scope)
740
+ */
741
+ export class SplatEffects {
742
+ constructor(ctx) {
743
+ this.ctx = ctx;
744
+ /** scope key ('tile' or an entity) → Map(name → instance) */
745
+ this.scopes = new Map();
746
+ /** scope key → the chunk/modifier structure last installed ('' = none) */
747
+ this._installed = new Map();
748
+ this._disposed = false;
749
+ }
750
+
751
+ _scope(key, create = true) {
752
+ let m = this.scopes.get(key);
753
+ if (!m && create) this.scopes.set(key, (m = new Map()));
754
+ return m;
755
+ }
756
+
757
+ /** Every instance, as [scopeKey, name, instance]. */
758
+ *_all() {
759
+ for (const [key, m] of this.scopes) for (const [name, inst] of m) yield [key, name, inst];
760
+ }
761
+
762
+ _target(opts) {
763
+ if ((opts.scope ?? 'tile') === 'tile') return 'tile';
764
+ const e = opts.entity ?? this.ctx.entity();
765
+ if (!e?.gsplat) throw new Error('@displayxr/inline3d/splat: entity-scope effect before the splat has loaded.');
766
+ return e;
767
+ }
768
+
769
+ _makeInstance(name, opts, mode) {
770
+ const def = defOf(name);
771
+ const inst = {
772
+ name,
773
+ def,
774
+ opts,
775
+ mode, // 'play' | 'set'
776
+ state: {},
777
+ startGate: null,
778
+ t0: null, // clock start (after the hold), ms
779
+ startedAt: null,
780
+ amount: 0,
781
+ done: false,
782
+ resolve: null,
783
+ promise: null,
784
+ originPoint: (ctx, eyes) => this._origin(inst, ctx, eyes),
785
+ elapsedS: (tMs) => (inst.startedAt === null ? 0 : Math.max(0, (tMs - inst.startedAt) / 1000)),
786
+ };
787
+ inst.promise = new Promise((r) => (inst.resolve = r));
788
+ return inst;
789
+ }
790
+
791
+ _origin(inst, ctx, eyes) {
792
+ const o = inst.opts.origin || { kind: 'focus' };
793
+ if (o.kind === 'eyes') return (eyes || ctx.eyes()).origin;
794
+ if (inst.state.origin) return inst.state.origin;
795
+ let p = null;
796
+ if (o.kind === 'model') p = ctx.modelToContent(o.point);
797
+ else if (o.kind === 'client') p = ctx.pick(o.x, o.y);
798
+ if (!p) p = ctx.focus();
799
+ inst.state.origin = p;
800
+ return p;
801
+ }
802
+
803
+ /**
804
+ * Play a transition/pulse/custom effect. Resolves { finished } — true when it ran to its end,
805
+ * false when stopped or replaced. `gate` (a promise) holds it at its START state until settled.
806
+ */
807
+ play(name, opts = {}, { gate = null, entity = null, internal = false } = {}) {
808
+ const o = resolveEffectOptions(name, opts, 'play', { internal });
809
+ if (entity) o.entity = entity;
810
+ const key = this._target(o);
811
+ const inst = this._makeInstance(name, o, 'play');
812
+ this._replace(key, name, inst);
813
+ if (gate) {
814
+ inst.startGate = gate;
815
+ inst.gated = true;
816
+ Promise.resolve(gate).then(
817
+ () => (inst.startGate = null),
818
+ () => (inst.startGate = null),
819
+ );
820
+ }
821
+ this._setupInstance(inst);
822
+ this._apply(key, inst, this.ctx.now());
823
+ this._install(key);
824
+ return inst.promise;
825
+ }
826
+
827
+ /**
828
+ * Set (or with null remove) a persistent effect, or hold a transition at a fixed `progress`.
829
+ */
830
+ set(name, params, { entity = null } = {}) {
831
+ if (params === null) {
832
+ if (!defOf(name) || defOf(name).internal) resolveEffectOptions(name, {}, 'set'); // throws
833
+ this.stop(name, { finish: false });
834
+ return;
835
+ }
836
+ const o = resolveEffectOptions(name, params, 'set');
837
+ if (entity) o.entity = entity;
838
+ const key = this._target(o);
839
+ const inst = this._makeInstance(name, o, 'set');
840
+ this._replace(key, name, inst);
841
+ this._setupInstance(inst);
842
+ this._apply(key, inst, this.ctx.now());
843
+ this._install(key);
844
+ }
845
+
846
+ /** Internal effects (xfade, wavefront) on one entity, driven by the adapter. */
847
+ setInternal(entity, name, opts) {
848
+ const def = EFFECTS[name];
849
+ const m = this._scope(entity);
850
+ let inst = m.get(name);
851
+ if (opts === null) {
852
+ if (inst) {
853
+ m.delete(name);
854
+ inst.resolve({ finished: false });
855
+ this._install(entity);
856
+ }
857
+ return;
858
+ }
859
+ if (!inst) {
860
+ inst = this._makeInstance(name, { ...def.defaults, ...opts }, 'set');
861
+ m.set(name, inst);
862
+ this._setupInstance(inst);
863
+ } else Object.assign(inst.opts, opts);
864
+ this._apply(entity, inst, this.ctx.now());
865
+ this._install(entity);
866
+ }
867
+
868
+ _setupInstance(inst) {
869
+ // Also for a gated one (its held START state needs the geometry); re-run when the gate opens,
870
+ // since the rig/framing may have changed meanwhile (setSource's flip adopts a new asset).
871
+ if (inst.def.start) inst.def.start(this.ctx, inst);
872
+ }
873
+
874
+ _replace(key, name, inst) {
875
+ const m = this._scope(key);
876
+ const old = m.get(name);
877
+ if (old) old.resolve({ finished: false });
878
+ m.set(name, inst);
879
+ }
880
+
881
+ /**
882
+ * Stop one effect (or every public one with no name). `finish: true` jumps to the end state:
883
+ * an 'in' transition is removed (the untouched asset), an 'out' one holds its end state;
884
+ * `finish: false` (default) removes it — the baseline.
885
+ */
886
+ stop(name, { finish = false, entity = null } = {}) {
887
+ for (const [key, n, inst] of [...this._all()]) {
888
+ if (name ? n !== name : inst.def.internal) continue;
889
+ if (entity && key !== entity) continue;
890
+ const m = this.scopes.get(key);
891
+ if (finish && inst.mode === 'play' && inst.opts.direction === 'out') {
892
+ inst.mode = 'set';
893
+ inst.opts.progress = 1;
894
+ this._apply(key, inst, this.ctx.now());
895
+ inst.resolve({ finished: true });
896
+ continue;
897
+ }
898
+ m.delete(n);
899
+ inst.resolve({ finished: !!finish });
900
+ this._install(key);
901
+ }
902
+ }
903
+
904
+ /** What is on, for handle.effects(). */
905
+ list() {
906
+ const out = [];
907
+ for (const [key, name, inst] of this._all()) {
908
+ if (inst.def.internal) continue;
909
+ out.push({
910
+ name,
911
+ scope: key === 'tile' ? 'tile' : 'entity',
912
+ stage: inst.def.stage,
913
+ playing: inst.mode === 'play',
914
+ waiting: !!inst.startGate,
915
+ progress: +inst.progress?.toFixed?.(4) || 0,
916
+ });
917
+ }
918
+ return out;
919
+ }
920
+
921
+ /** Forget an entity's effects (released by setSource). Its modifier goes with the entity. */
922
+ dropEntity(entity) {
923
+ const m = this.scopes.get(entity);
924
+ if (!m) return;
925
+ for (const inst of m.values()) inst.resolve({ finished: false });
926
+ this.scopes.delete(entity);
927
+ this._installed.delete(entity);
928
+ }
929
+
930
+ /** True while anything needs a per-frame tick. */
931
+ get active() {
932
+ for (const [, m] of this.scopes) if (m.size) return true;
933
+ return false;
934
+ }
935
+
936
+ /** Advance clocks and upload uniforms. Called once per frame by the adapter. */
937
+ tick(tMs) {
938
+ if (this._disposed) return;
939
+ for (const [key, name, inst] of [...this._all()]) {
940
+ if (inst.mode === 'play') {
941
+ if (inst.startGate) {
942
+ this._apply(key, inst, tMs); // hold the start state
943
+ continue;
944
+ }
945
+ if (inst.t0 === null) {
946
+ if (inst.gated && inst.def.start) {
947
+ inst.state = {};
948
+ inst.def.start(this.ctx, inst);
949
+ }
950
+ inst.startedAt = tMs;
951
+ inst.t0 = tMs + (inst.opts.holdMs || 0);
952
+ }
953
+ }
954
+ this._apply(key, inst, tMs);
955
+ if (inst.mode === 'play' && inst.rawT >= 1) this._complete(key, name, inst);
956
+ }
957
+ }
958
+
959
+ _complete(key, name, inst) {
960
+ const m = this.scopes.get(key);
961
+ if (inst.opts.direction === 'out' || inst.opts.hold === true) {
962
+ inst.mode = 'set';
963
+ inst.opts.progress = 1;
964
+ inst.resolve({ finished: true });
965
+ return;
966
+ }
967
+ m.delete(name);
968
+ inst.resolve({ finished: true });
969
+ this._install(key);
970
+ }
971
+
972
+ /** The instance's amount for time tMs, then its uniforms onto the scope's target. */
973
+ _apply(key, inst, tMs) {
974
+ const o = inst.opts;
975
+ let raw;
976
+ if (inst.mode === 'set') raw = o.progress ?? 1;
977
+ else if (inst.startGate || inst.t0 === null) raw = 0;
978
+ else raw = o.durationMs > 0 ? Math.min(1, Math.max(0, (tMs - inst.t0) / o.durationMs)) : 1;
979
+ inst.rawT = raw;
980
+ const eased = inst.mode === 'set' || !o.easingFn ? raw : o.easingFn(raw);
981
+ inst.progress = raw;
982
+ const kind = inst.def.kind;
983
+ let values;
984
+ if (kind === 'transition') {
985
+ const amount = o.direction === 'out' ? 1 - eased : eased;
986
+ inst.amount = amount;
987
+ values = inst.def.uniforms(this.ctx, inst, amount, tMs);
988
+ } else if (kind === 'pulse') {
989
+ values = inst.def.uniforms(this.ctx, inst, eased, tMs);
990
+ } else if (kind === 'custom') {
991
+ values = { p: inst.mode === 'set' ? (o.progress ?? 1) : eased, t: inst.elapsedS(tMs) };
992
+ } else {
993
+ values = inst.def.uniforms(this.ctx, inst);
994
+ }
995
+ const P = prefixOf(inst.name);
996
+ const set = this._setter(key);
997
+ for (const [k, v] of Object.entries(values)) set(P + k, v);
998
+ if (kind === 'custom' && o.uniforms) {
999
+ for (const [k, v] of Object.entries(o.uniforms)) set(k, typeof v === 'function' ? v(tMs) : v);
1000
+ }
1001
+ if (key === 'tile') this._tileDirty = true;
1002
+ }
1003
+
1004
+ _setter(key) {
1005
+ if (key === 'tile') {
1006
+ const mat = this._tileMaterial();
1007
+ return (n, v) => mat?.setParameter(n, v);
1008
+ }
1009
+ const g = key.gsplat;
1010
+ return (n, v) => g?.setParameter?.(n, v);
1011
+ }
1012
+
1013
+ _tileMaterial() {
1014
+ return this.ctx.app()?.scene?.gsplat?.material ?? null;
1015
+ }
1016
+
1017
+ /** Called after tick: the tile material's uniforms went up this frame. */
1018
+ flush() {
1019
+ if (this._tileDirty) {
1020
+ this._tileDirty = false;
1021
+ this._tileMaterial()?.update?.();
1022
+ }
1023
+ }
1024
+
1025
+ /** (Re)install the generated modifier of one scope if its STRUCTURE changed. */
1026
+ _install(key) {
1027
+ const m = this.scopes.get(key);
1028
+ const insts = m ? [...m.values()] : [];
1029
+ const composed = insts.length ? composeModifier(insts) : null;
1030
+ const fragment = key === 'tile' ? insts.find((i) => i.opts.fragmentGlsl)?.opts.fragmentGlsl ?? null : null;
1031
+ const sig = composed ? composed.code + (fragment || '') : '';
1032
+ if ((this._installed.get(key) ?? '') === sig) return;
1033
+ this._installed.set(key, sig);
1034
+ const pc = this.ctx.pc();
1035
+ if (key === 'tile') {
1036
+ const mat = this._tileMaterial();
1037
+ if (!mat) return;
1038
+ const chunks = mat.getShaderChunks(pc.SHADERLANGUAGE_GLSL);
1039
+ if (composed) chunks.set('gsplatModifyVS', composed.code);
1040
+ else chunks.delete('gsplatModifyVS');
1041
+ if (fragment) chunks.set('gsplatModifyPS', fragment);
1042
+ else chunks.delete('gsplatModifyPS');
1043
+ // re-upload every instance's uniforms onto the (possibly new) program
1044
+ if (m) for (const inst of m.values()) this._apply(key, inst, this.ctx.now());
1045
+ mat.update();
1046
+ return;
1047
+ }
1048
+ const g = key.gsplat;
1049
+ if (!g) return;
1050
+ if (composed) {
1051
+ g.setWorkBufferModifier?.({ glsl: composed.code });
1052
+ if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ALWAYS ?? 2;
1053
+ } else {
1054
+ g.setWorkBufferModifier?.(null);
1055
+ if ('workBufferUpdate' in g) {
1056
+ // AUTO first: the engine's placement setter treats ONCE as a one-shot re-render and never
1057
+ // leaves ALWAYS, so ONCE alone kept the asset re-rendering its work buffer (and re-sorting)
1058
+ // every frame after its effect had ended.
1059
+ g.workBufferUpdate = pc.WORKBUFFER_UPDATE_AUTO ?? 0;
1060
+ g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ONCE ?? 1;
1061
+ }
1062
+ this.scopes.delete(key);
1063
+ this._installed.delete(key);
1064
+ }
1065
+ }
1066
+
1067
+ /** Remove everything (restores the engine's chunks). */
1068
+ dispose() {
1069
+ for (const [, , inst] of this._all()) inst.resolve({ finished: false });
1070
+ for (const key of [...this.scopes.keys()]) {
1071
+ this.scopes.get(key).clear();
1072
+ try {
1073
+ this._install(key);
1074
+ } catch {
1075
+ // the app may already be destroyed
1076
+ }
1077
+ }
1078
+ this.scopes.clear();
1079
+ this._disposed = true;
1080
+ }
1081
+ }
1082
+
1083
+ // ── option entry points for ./splat (validated at the page's own call) ───────────────────────
1084
+
1085
+ /** The error Spark's handle throws for every effect entry point. */
1086
+ export function effectsNotOnSpark(what) {
1087
+ return new Error(
1088
+ `@displayxr/inline3d/splat: ${what} — splat effects are PlayCanvas-only in this version. Pass ` +
1089
+ "engine:'playcanvas' (docs/splat-effects.md); Spark ports are a follow-up.",
1090
+ );
1091
+ }
1092
+
1093
+ /** Validate a playEffect/setEffect call (throws), without running it. */
1094
+ export function validateEffectCall(name, opts, mode) {
1095
+ if (mode === 'set' && opts === null) {
1096
+ if (!defOf(name) || defOf(name).internal) resolveEffectOptions(name, {}, 'set');
1097
+ return;
1098
+ }
1099
+ resolveEffectOptions(name, opts ?? {}, mode);
1100
+ }
1101
+
1102
+ /**
1103
+ * addSplat's / setSource's `reveal`: false/undefined → null; a name → that transition with its
1104
+ * defaults; { type, durationMs, holdMs, easing, origin, … } → the same, overridden. Throws on
1105
+ * anything else.
1106
+ */
1107
+ export function resolveRevealOption(reveal) {
1108
+ if (reveal === undefined || reveal === false || reveal === null) return null;
1109
+ const spec = typeof reveal === 'string' ? { type: reveal } : reveal;
1110
+ if (!spec || typeof spec !== 'object' || typeof spec.type !== 'string') {
1111
+ throw new TypeError(
1112
+ `@displayxr/inline3d/splat: reveal must be false, one of ${REVEAL_EFFECTS.map((r) => `'${r}'`).join(', ')}, or { type, durationMs, holdMs, easing, origin }.`,
1113
+ );
1114
+ }
1115
+ if (!REVEAL_EFFECTS.includes(spec.type)) {
1116
+ throw new Error(`@displayxr/inline3d/splat: reveal type '${spec.type}' — expected one of ${REVEAL_EFFECTS.join(', ')}.`);
1117
+ }
1118
+ const { type, ...rest } = spec;
1119
+ if (rest.direction === 'out') throw new Error('@displayxr/inline3d/splat: a reveal plays in (direction \'in\').');
1120
+ const opts = resolveEffectOptions(type, { ...rest, direction: 'in' }, 'play');
1121
+ return { type, raw: { ...rest, direction: 'in' }, opts };
1122
+ }