@displayxr/inline3d 1.12.0 → 1.13.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 +55 -0
- package/README.md +11 -0
- package/THIRD_PARTY_NOTICES.md +76 -0
- package/js/inline3d-playcanvas-engine.js +5 -0
- package/js/inline3d-splat-effects.js +1116 -0
- package/js/inline3d-splat-perf.js +3 -0
- package/js/inline3d-splat-playcanvas.js +517 -82
- package/js/inline3d-splat-shared.js +33 -0
- package/js/inline3d-splat.js +28 -0
- package/package.json +4 -2
- package/splat.d.ts +152 -5
|
@@ -0,0 +1,1116 @@
|
|
|
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) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ONCE ?? 1;
|
|
1056
|
+
this.scopes.delete(key);
|
|
1057
|
+
this._installed.delete(key);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Remove everything (restores the engine's chunks). */
|
|
1062
|
+
dispose() {
|
|
1063
|
+
for (const [, , inst] of this._all()) inst.resolve({ finished: false });
|
|
1064
|
+
for (const key of [...this.scopes.keys()]) {
|
|
1065
|
+
this.scopes.get(key).clear();
|
|
1066
|
+
try {
|
|
1067
|
+
this._install(key);
|
|
1068
|
+
} catch {
|
|
1069
|
+
// the app may already be destroyed
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
this.scopes.clear();
|
|
1073
|
+
this._disposed = true;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ── option entry points for ./splat (validated at the page's own call) ───────────────────────
|
|
1078
|
+
|
|
1079
|
+
/** The error Spark's handle throws for every effect entry point. */
|
|
1080
|
+
export function effectsNotOnSpark(what) {
|
|
1081
|
+
return new Error(
|
|
1082
|
+
`@displayxr/inline3d/splat: ${what} — splat effects are PlayCanvas-only in this version. Pass ` +
|
|
1083
|
+
"engine:'playcanvas' (docs/splat-effects.md); Spark ports are a follow-up.",
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Validate a playEffect/setEffect call (throws), without running it. */
|
|
1088
|
+
export function validateEffectCall(name, opts, mode) {
|
|
1089
|
+
if (mode === 'set' && opts === null) {
|
|
1090
|
+
if (!defOf(name) || defOf(name).internal) resolveEffectOptions(name, {}, 'set');
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
resolveEffectOptions(name, opts ?? {}, mode);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* addSplat's / setSource's `reveal`: false/undefined → null; a name → that transition with its
|
|
1098
|
+
* defaults; { type, durationMs, holdMs, easing, origin, … } → the same, overridden. Throws on
|
|
1099
|
+
* anything else.
|
|
1100
|
+
*/
|
|
1101
|
+
export function resolveRevealOption(reveal) {
|
|
1102
|
+
if (reveal === undefined || reveal === false || reveal === null) return null;
|
|
1103
|
+
const spec = typeof reveal === 'string' ? { type: reveal } : reveal;
|
|
1104
|
+
if (!spec || typeof spec !== 'object' || typeof spec.type !== 'string') {
|
|
1105
|
+
throw new TypeError(
|
|
1106
|
+
`@displayxr/inline3d/splat: reveal must be false, one of ${REVEAL_EFFECTS.map((r) => `'${r}'`).join(', ')}, or { type, durationMs, holdMs, easing, origin }.`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
if (!REVEAL_EFFECTS.includes(spec.type)) {
|
|
1110
|
+
throw new Error(`@displayxr/inline3d/splat: reveal type '${spec.type}' — expected one of ${REVEAL_EFFECTS.join(', ')}.`);
|
|
1111
|
+
}
|
|
1112
|
+
const { type, ...rest } = spec;
|
|
1113
|
+
if (rest.direction === 'out') throw new Error('@displayxr/inline3d/splat: a reveal plays in (direction \'in\').');
|
|
1114
|
+
const opts = resolveEffectOptions(type, { ...rest, direction: 'in' }, 'play');
|
|
1115
|
+
return { type, raw: { ...rest, direction: 'in' }, opts };
|
|
1116
|
+
}
|