@displayxr/inline3d 1.19.0 → 1.19.2

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,497 @@
1
+ // inline3d-pc-look.js — the PlayCanvas LOOK helpers shared by the model and splat adapters: the two
2
+ // generated image-based environments (the neutral studio and three's room), their yaw constants,
3
+ // and the KHR_materials_transmission fix-ups (prepareTransmission).
4
+ //
5
+ // EXPERIMENTAL and internal, like the two adapters that use it (docs/sdk-stability.md).
6
+ //
7
+ // WHY A MODULE OF ITS OWN. These lived in inline3d-model-playcanvas.js until 1.19. The splat
8
+ // adapter needs them for setRig('display') and imported that module lazily to get them — but that
9
+ // module also holds the meshopt decoder, a literal `import('meshoptimizer/decoder')` for the
10
+ // OPTIONAL `meshoptimizer` peer. Bundlers follow every import() in the graph, so every bundled app
11
+ // using only `./splat` failed to build unless it installed meshoptimizer. This module must never
12
+ // import anything that reaches `meshoptimizer` (test/splat-no-meshopt.test.mjs walks the graph).
13
+ // inline3d-model-playcanvas.js imports from here and re-exports the names it always exported.
14
+ //
15
+ // Takes the engine namespace as an argument (`pc`) — no static import of playcanvas.
16
+
17
+ const TAG = '[inline3d/model]';
18
+
19
+ // ── lighting ────────────────────────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * THE DEFAULT ENVIRONMENT: a neutral photo studio, generated in memory as an equirect radiance
23
+ * map — a floor-to-ceiling brightness profile plus a handful of soft area lights (spherical
24
+ * Gaussians). The parameters were FITTED (least squares, solid-angle weighted) to the glTF Sample
25
+ * Viewer's "Studio Neutral" environment, so that this backend's default lighting matches the
26
+ * Khronos reference; the image itself is ours (a dozen numbers, not a copy of the HDRI), so the
27
+ * SDK ships no asset and carries no attribution requirement. Grey by construction (r = g = b).
28
+ *
29
+ * `profile` is the radiance at evenly spaced polar angles from straight up (0) to straight down
30
+ * (π); `lobes` are [amplitude, sharpness, polar, azimuth] with the direction
31
+ * (sinθ·cosφ, cosθ, sinθ·sinφ) — Y up, the engine's equirect convention. A lobe's amplitude may
32
+ * be negative (a dark backdrop); the result is clamped at 0.
33
+ */
34
+ export const NEUTRAL_STUDIO = Object.freeze({
35
+ profile: [5.003, 5.049, 3.917, 1.968, 1.378, 0.893, 0.8074, 0.7525, 0.6427, -0.1814],
36
+ lobes: [
37
+ [11.31, 4.869, 0.9712, 6.105],
38
+ [1.759, 3.839, 1.404, 3.558],
39
+ [0.9139, 1.621, 1.719, 6.239],
40
+ [0.572, 7.846, 1.497, 6.066],
41
+ [0.6379, 7.687, 1.42, 6.385],
42
+ [-0.7782, 6.701, 1.13, 6.133],
43
+ [-1.263, 8.179, 1.671, 6.188],
44
+ [-1.456, 7.734, 1.256, 6.568],
45
+ [-6.7, 0.8014, 0.449, 6.171],
46
+ [0.4015, 3.915, 1.621, 5.688],
47
+ [-0.5387, 6.494, 2.828, 5.614],
48
+ [6.623, 1.864, 0.7199, 6.135],
49
+ [0.2623, 6.922, 1.755, 5.903],
50
+ [0.2597, 6.906, 1.566, 5.934],
51
+ [0.1584, 6.954, 1.514, 6.179],
52
+ [0.2272, 6.945, 1.422, 5.891],
53
+ [0.03496, 6.974, 1.414, 6.205],
54
+ [0.3804, 6.979, 1.393, 6.08],
55
+ ],
56
+ });
57
+
58
+ /** Lobes as [amplitude, sharpness, mx, my, mz] — the trig done once per env, not per texel. */
59
+ const _lobeCache = new WeakMap();
60
+ function lobesOf(env) {
61
+ let l = _lobeCache.get(env);
62
+ if (!l) {
63
+ l = env.lobes.map(([a, s, th, ph]) => [a, Math.exp(s), Math.sin(th) * Math.cos(ph), Math.cos(th), Math.sin(th) * Math.sin(ph)]);
64
+ _lobeCache.set(env, l);
65
+ }
66
+ return l;
67
+ }
68
+
69
+ /** Radiance of NEUTRAL_STUDIO (or `env`) toward unit direction (x, y, z). */
70
+ export function neutralStudioRadiance(x, y, z, env = NEUTRAL_STUDIO) {
71
+ const prof = env.profile;
72
+ const n = prof.length - 1;
73
+ const theta = Math.acos(Math.max(-1, Math.min(1, y)));
74
+ const t = (theta / Math.PI) * n;
75
+ const i = Math.min(n - 1, Math.floor(t));
76
+ const f = t - i;
77
+ let v = prof[i] * (1 - f) + prof[i + 1] * f;
78
+ for (const [a, k, mx, my, mz] of lobesOf(env)) v += a * Math.exp(k * (x * mx + y * my + z * mz - 1));
79
+ return v > 0 ? v : 0;
80
+ }
81
+
82
+ /** RGBE-encode a grey radiance. */
83
+ function rgbe(v, out, o) {
84
+ if (!(v > 1e-32)) {
85
+ out[o] = out[o + 1] = out[o + 2] = out[o + 3] = 0;
86
+ return;
87
+ }
88
+ let e = Math.ceil(Math.log2(v));
89
+ let m = v / Math.pow(2, e);
90
+ if (m >= 1) {
91
+ m /= 2;
92
+ e += 1;
93
+ }
94
+ const b = Math.min(255, Math.floor(m * 256));
95
+ out[o] = out[o + 1] = out[o + 2] = b;
96
+ out[o + 3] = e + 128;
97
+ }
98
+
99
+ /**
100
+ * The equirect as RGBE bytes, row 0 = straight up. `u` runs with the azimuth φ = 2π·u, which is
101
+ * the convention the fit used; the engine's own equirect orientation is applied through
102
+ * `scene.skyboxRotation` (§ ENV_YAW_DEG), not baked in here.
103
+ */
104
+ const _rgbeCache = new Map();
105
+ export function neutralStudioRGBE(width = 256, height = 128, env = NEUTRAL_STUDIO) {
106
+ // Every tile on a page gets the same image: build it once (a copy per call, since the engine
107
+ // may keep the array it is handed).
108
+ const key = env === NEUTRAL_STUDIO ? `${width}x${height}` : null;
109
+ if (key && _rgbeCache.has(key)) return _rgbeCache.get(key).slice();
110
+ const out = new Uint8Array(width * height * 4);
111
+ for (let r = 0; r < height; r++) {
112
+ const theta = ((r + 0.5) / height) * Math.PI;
113
+ const st = Math.sin(theta);
114
+ const y = Math.cos(theta);
115
+ for (let c = 0; c < width; c++) {
116
+ const phi = ((c + 0.5) / width) * 2 * Math.PI;
117
+ rgbe(neutralStudioRadiance(st * Math.cos(phi), y, st * Math.sin(phi), env), out, (r * width + c) * 4);
118
+ }
119
+ }
120
+ if (key) _rgbeCache.set(key, out.slice());
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Yaw (degrees about +Y) applied to the generated studio, as `scene.skyboxRotation`. Calibrated
126
+ * headless against the Sample Viewer at its default `environmentRotation` (90°): a sweep over
127
+ * 0/90/180/270 × mirrored, then ±15/±30, has a clean minimum at 0 on all four reference models
128
+ * (the engine's equirect convention already lines up with the layout the fit used). Kept as a
129
+ * named constant so a future engine that flips its convention is a one-line fix.
130
+ */
131
+ export const ENV_YAW_DEG = 0;
132
+
133
+ /** Build the default environment on this app and set it as the scene's envAtlas. */
134
+ export function useNeutralStudio(pc, app, yawDeg) {
135
+ return useRgbeEquirect(pc, app, 'inline3d-neutral-studio', neutralStudioRGBE(256, 128), 256, 128, yawDeg);
136
+ }
137
+
138
+ /** A grey RGBE equirect (row 0 = up) → prefiltered envAtlas on the scene. */
139
+ function useRgbeEquirect(pc, app, name, bytes, W, H, yawDeg) {
140
+ const tex = new pc.Texture(app.graphicsDevice, {
141
+ name,
142
+ width: W,
143
+ height: H,
144
+ format: pc.PIXELFORMAT_RGBA8,
145
+ type: pc.TEXTURETYPE_RGBE,
146
+ projection: pc.TEXTUREPROJECTION_EQUIRECT,
147
+ mipmaps: false,
148
+ addressU: pc.ADDRESS_REPEAT,
149
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
150
+ levels: [bytes],
151
+ });
152
+ return useLightingSource(pc, app, tex, true, yawDeg);
153
+ }
154
+
155
+ /** Any equirect/cube source → prefiltered envAtlas on the scene. */
156
+ export function useLightingSource(pc, app, source, own, yawDeg = 0) {
157
+ const lighting = pc.EnvLighting.generateLightingSource(source);
158
+ const atlas = pc.EnvLighting.generateAtlas(lighting);
159
+ lighting.destroy?.();
160
+ if (own) source.destroy?.();
161
+ app.scene.envAtlas = atlas;
162
+ app.scene.skyboxIntensity = 1;
163
+ app.scene.exposure = 1;
164
+ // Always written (identity at 0): a later environment on the same scene must not inherit the
165
+ // previous one's yaw (setRig switches room ↔ neutral on one app).
166
+ if (pc.Quat) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg || 0, 0);
167
+ return atlas;
168
+ }
169
+
170
+ // ── the room environment (environment: 'room') ─────────────────────────────────────────────
171
+
172
+ /**
173
+ * `environment: 'room'` — three.js's procedural RoomEnvironment, regenerated here without three:
174
+ * the same scene (a white room lit by one point light, six grey boxes, six emissive panels), shaded
175
+ * the way three shades it when `PMREMGenerator.fromScene(new RoomEnvironment(), 0.04)` bakes it —
176
+ * which is exactly what `addModel(…, { engine: 'three', environment: 'room' })` lights with.
177
+ *
178
+ * It exists so a page that chose `'room'` on the three path gets that look on this backend. It is
179
+ * NOT the default: the default (`'neutral'`) is matched to the Khronos Sample Viewer, and three's
180
+ * room is a different, brighter room (mean radiance ~0.97 vs ~0.89, most of it from a 100-nit
181
+ * ceiling panel and two 50-nit wall panels).
182
+ *
183
+ * The scene's numbers (positions, rotations, scales, intensities) are three.js's
184
+ * examples/jsm/environments/RoomEnvironment.js (MIT, © three.js authors), itself after
185
+ * model-viewer's EnvironmentScene. No image is shipped: `roomRadiance` ray-casts the boxes per
186
+ * texel. Checked against three r180's own bake (its PMREM, read back through `textureCubeUV`):
187
+ * texel-to-texel ratio median 1.000 (p10 0.989, p90 1.006), solid-angle-weighted MAE 0.03 unblurred
188
+ * and 0.04 with the 0.04-rad blur. Solids are [cx, cy, cz, angleY, sx, sy, sz] (boxes) and
189
+ * [cx, cy, cz, sx, sy, sz, emissive] (panels), BoxGeometry(1) scaled — i.e. full extents.
190
+ */
191
+ export const ROOM_ENVIRONMENT = Object.freeze({
192
+ light: [0.418, 16.199, 0.3, 900, 28, 2], // point light: position, intensity (cd), cutoff distance, decay
193
+ room: [-0.757, 13.219, 0.717, 31.713, 28.305, 28.591], // seen from inside
194
+ boxes: [
195
+ [-10.906, 2.009, 1.846, -0.195, 2.328, 7.905, 4.651],
196
+ [-5.607, -0.754, -0.758, 0.994, 1.97, 1.534, 3.955],
197
+ [6.167, 0.857, 7.803, 0.561, 3.927, 6.285, 3.687],
198
+ [-2.017, 0.018, 6.124, 0.333, 2.002, 4.566, 2.064],
199
+ [2.291, -0.756, -2.621, -0.286, 1.546, 1.552, 1.496],
200
+ [-2.193, -0.369, -5.547, 0.516, 3.875, 3.487, 2.986],
201
+ ],
202
+ panels: [
203
+ [-16.116, 14.37, 8.208, 0.1, 2.428, 2.739, 50],
204
+ [-16.109, 18.021, -8.207, 0.1, 2.425, 2.751, 50],
205
+ [14.904, 12.198, -1.832, 0.15, 4.265, 6.331, 17],
206
+ [-0.462, 8.89, 14.52, 4.38, 5.441, 0.088, 43],
207
+ [3.235, 11.486, -12.541, 2.5, 2.0, 0.1, 20],
208
+ [0.0, 20.0, 0.0, 1.0, 0.1, 1.0, 100],
209
+ ],
210
+ blur: 0.04, // radians — the sigma addModel's three path passes to fromScene
211
+ });
212
+
213
+ /** Every solid as [cx, cy, cz, cosY, sinY, hx, hy, hz, kind (0 room, 1 lit box, 2 panel), emissive]. */
214
+ const _roomSolids = new WeakMap();
215
+ function roomSolids(env) {
216
+ let out = _roomSolids.get(env);
217
+ if (out) return out;
218
+ const [rx, ry, rz, sx, sy, sz] = env.room;
219
+ out = [[rx, ry, rz, 1, 0, sx / 2, sy / 2, sz / 2, 0, 0]];
220
+ for (const [px, py, pz, a, bx, by, bz] of env.boxes) out.push([px, py, pz, Math.cos(a), Math.sin(a), bx / 2, by / 2, bz / 2, 1, 0]);
221
+ for (const [px, py, pz, bx, by, bz, e] of env.panels) out.push([px, py, pz, 1, 0, bx / 2, by / 2, bz / 2, 2, e]);
222
+ _roomSolids.set(env, out);
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * Radiance of the room seen from the origin toward unit direction (x, y, z): the nearest solid
228
+ * along the ray. A panel is its emissive value; a wall or box (white, roughness 1, non-metal) is
229
+ * three's direct physical shading from the one point light, unshadowed as in three (the scene casts
230
+ * no shadows) — Lambert plus GGX at alpha 1 (D = 1/π, V = 0.5/(NL+NV), Schlick F0 0.04), with
231
+ * three's inverse-square falloff and its (1 − (d/cutoff)⁴)² window.
232
+ */
233
+ export function roomRadiance(x, y, z, env = ROOM_ENVIRONMENT) {
234
+ let best = Infinity;
235
+ let nx = 0;
236
+ let ny = 0;
237
+ let nz = 0;
238
+ let emit = -1;
239
+ let found = false;
240
+ for (const b of roomSolids(env)) {
241
+ const c = b[3];
242
+ const s = b[4];
243
+ // The ray in the solid's frame (a rotation about Y): local = Rᵀ(p − centre).
244
+ const ox = -b[0];
245
+ const oy = -b[1];
246
+ const oz = -b[2];
247
+ const o = [c * ox - s * oz, oy, s * ox + c * oz];
248
+ const d = [c * x - s * z, y, s * x + c * z];
249
+ let t0 = -Infinity;
250
+ let t1 = Infinity;
251
+ let a0 = 0;
252
+ let a1 = 0;
253
+ let miss = false;
254
+ for (let i = 0; i < 3; i++) {
255
+ const h = b[5 + i];
256
+ if (Math.abs(d[i]) < 1e-12) {
257
+ if (o[i] < -h || o[i] > h) {
258
+ miss = true;
259
+ break;
260
+ }
261
+ continue;
262
+ }
263
+ let n = (-h - o[i]) / d[i];
264
+ let f = (h - o[i]) / d[i];
265
+ let sn = -1; // the entry face's outward normal is −axis when the ray runs +axis
266
+ if (n > f) {
267
+ const t = n;
268
+ n = f;
269
+ f = t;
270
+ sn = 1;
271
+ }
272
+ if (n > t0) {
273
+ t0 = n;
274
+ a0 = (i + 1) * sn;
275
+ }
276
+ if (f < t1) {
277
+ t1 = f;
278
+ a1 = -(i + 1) * sn;
279
+ }
280
+ }
281
+ if (miss || t0 > t1 || t1 < 0) continue;
282
+ const inside = b[8] === 0;
283
+ const t = inside ? t1 : t0;
284
+ if (!(t > 0) || t >= best) continue;
285
+ best = t;
286
+ found = true;
287
+ if (b[8] === 2) {
288
+ emit = b[9];
289
+ continue;
290
+ }
291
+ emit = -1;
292
+ const a = inside ? a1 : a0;
293
+ const sg = (inside ? -1 : 1) * Math.sign(a); // the room is seen from inside: its normals point in
294
+ const ax = Math.abs(a) - 1;
295
+ const l0 = ax === 0 ? sg : 0;
296
+ const l2 = ax === 2 ? sg : 0;
297
+ nx = c * l0 + s * l2;
298
+ ny = ax === 1 ? sg : 0;
299
+ nz = -s * l0 + c * l2;
300
+ }
301
+ if (!found) return 0;
302
+ if (emit >= 0) return emit;
303
+ const [lx, ly, lz, I, cutoff, decay] = env.light;
304
+ let tx = lx - x * best;
305
+ let ty = ly - y * best;
306
+ let tz = lz - z * best;
307
+ const dist = Math.hypot(tx, ty, tz);
308
+ tx /= dist;
309
+ ty /= dist;
310
+ tz /= dist;
311
+ const nl = nx * tx + ny * ty + nz * tz;
312
+ if (nl <= 0) return 0;
313
+ const win = Math.max(0, Math.min(1, 1 - Math.pow(dist / cutoff, 4)));
314
+ const E = (I / Math.max(Math.pow(dist, decay), 0.01)) * win * win * nl;
315
+ const nv = Math.max(1e-4, -(nx * x + ny * y + nz * z));
316
+ const hx = tx - x;
317
+ const hy = ty - y;
318
+ const hz = tz - z;
319
+ const vh = Math.max(0, (tx * hx + ty * hy + tz * hz) / Math.hypot(hx, hy, hz));
320
+ const F = 0.04 + 0.96 * Math.pow(1 - vh, 5);
321
+ return (E / Math.PI) * (1 + F * (0.5 / (nl + nv)));
322
+ }
323
+
324
+ /**
325
+ * The room as a float equirect (row 0 = up, the same convention as neutralStudioRGBE), blurred by a
326
+ * Gaussian of `sigma` radians — separable on the sphere: σ in rows, σ/sinθ in columns (wrapping).
327
+ */
328
+ export function roomEquirect(W = 256, H = 128, sigma = ROOM_ENVIRONMENT.blur, env = ROOM_ENVIRONMENT) {
329
+ const out = new Float32Array(W * H);
330
+ for (let r = 0; r < H; r++) {
331
+ const th = ((r + 0.5) / H) * Math.PI;
332
+ const st = Math.sin(th);
333
+ const y = Math.cos(th);
334
+ for (let c = 0; c < W; c++) {
335
+ const ph = ((c + 0.5) / W) * 2 * Math.PI;
336
+ out[r * W + c] = roomRadiance(st * Math.cos(ph), y, st * Math.sin(ph), env);
337
+ }
338
+ }
339
+ if (!(sigma > 0)) return out;
340
+ const perRow = Math.PI / H; // radians per row, and per column at the equator
341
+ const tmp = new Float32Array(W * H);
342
+ for (let r = 0; r < H; r++) {
343
+ const st = Math.max(Math.sin(((r + 0.5) / H) * Math.PI), 1e-3);
344
+ const sg = Math.min(sigma / perRow / st, W / 4);
345
+ const R = Math.ceil(3 * sg);
346
+ const k = [];
347
+ let ks = 0;
348
+ for (let i = -R; i <= R; i++) {
349
+ const v = Math.exp(-(i * i) / (2 * sg * sg));
350
+ k.push(v);
351
+ ks += v;
352
+ }
353
+ for (let c = 0; c < W; c++) {
354
+ let a = 0;
355
+ for (let i = -R; i <= R; i++) a += k[i + R] * out[r * W + (((c + i) % W) + W) % W];
356
+ tmp[r * W + c] = a / ks;
357
+ }
358
+ }
359
+ const sg = sigma / perRow;
360
+ const R = Math.ceil(3 * sg);
361
+ for (let r = 0; r < H; r++) {
362
+ for (let c = 0; c < W; c++) {
363
+ let a = 0;
364
+ let ks = 0;
365
+ for (let i = -R; i <= R; i++) {
366
+ const rr = r + i;
367
+ if (rr < 0 || rr >= H) continue;
368
+ const v = Math.exp(-(i * i) / (2 * sg * sg));
369
+ a += v * tmp[rr * W + c];
370
+ ks += v;
371
+ }
372
+ out[r * W + c] = a / ks;
373
+ }
374
+ }
375
+ return out;
376
+ }
377
+
378
+ /** The room as RGBE bytes (cached per size: every tile on a page gets the same image). */
379
+ const _roomCache = new Map();
380
+ export function roomRGBE(W = 256, H = 128) {
381
+ const key = `${W}x${H}`;
382
+ let bytes = _roomCache.get(key);
383
+ if (!bytes) {
384
+ const f = roomEquirect(W, H);
385
+ bytes = new Uint8Array(W * H * 4);
386
+ for (let i = 0; i < f.length; i++) rgbe(f[i], bytes, i * 4);
387
+ _roomCache.set(key, bytes);
388
+ }
389
+ return bytes.slice();
390
+ }
391
+
392
+ /**
393
+ * Yaw that lines the generated room up with three's world (its +X panel on +X, the ceiling panel
394
+ * overhead). Calibrated headless against three r180's own `environment: 'room'` render on two
395
+ * catalogue meshes: a sweep over 0/90/180/270 × mirrored has a clean minimum at 90, unmirrored, on
396
+ * both. (The neutral studio's 0 is the Sample Viewer's orientation, a different frame.)
397
+ */
398
+ export const ROOM_YAW_DEG = 90;
399
+
400
+ /** Build the room environment on this app and set it as the scene's envAtlas. */
401
+ export function useRoomEnvironment(pc, app, yawDeg = ROOM_YAW_DEG) {
402
+ return useRgbeEquirect(pc, app, 'inline3d-room', roomRGBE(256, 128), 256, 128, yawDeg);
403
+ }
404
+
405
+ // ── KHR_materials_transmission / volume on this engine ─────────────────────────────────────
406
+
407
+ /**
408
+ * The engine reads KHR_materials_transmission (and _volume) into `useDynamicRefraction` +
409
+ * `BLEND_NORMAL`, and a dynamic-refraction material samples the camera's SCENE COLOUR MAP. Three
410
+ * things the engine leaves to the app, all needed for a glass or transmissive glTF to look like
411
+ * it does in the Khronos Sample Viewer (and three):
412
+ *
413
+ * 1. THE GRAB PASS. Nothing renders the scene colour map unless a camera asks for it; without it
414
+ * the material samples an unbound texture — the storm lantern's burner rendered as a MAGENTA
415
+ * blob inside its globe (measured). `viewer.useSceneColor()` asks, on every eye camera.
416
+ * 2. PASS ORDER. Both references draw opaque → transmissive → blended. The engine puts a
417
+ * transmissive material in the ONE back-to-front transparent list with the blended ones, so a
418
+ * blended globe (depthWrite off) can be drawn before the transmissive body behind it, which
419
+ * then paints over it — the burner showed THROUGH the lantern's opaque-alpha globe. A sort
420
+ * distance that puts transmissive draws first (back-to-front among themselves) restores the
421
+ * order: object MAE 10.7 → 10.2 vs three, 11.2 → 10.8 vs the Sample Viewer.
422
+ * 3. STEREO. The engine turns the refracted point's clip position into a grab-texture UV by
423
+ * mapping the VIEW's NDC over the WHOLE target (`getGrabScreenPos`). A stereo tile draws two
424
+ * views side by side into one target, so each eye would sample across both. The patched chunk
425
+ * measures the offset from THIS fragment instead (NDC delta ÷ NDC-per-pixel, from the
426
+ * derivatives), which is exact for any viewport and identical in mono.
427
+ */
428
+ const GRAB_UV_FN = `
429
+ vec2 inline3dGrabUV(vec4 clipPos) {
430
+ vec4 p0 = matrix_viewProjection * vec4(vPositionW, 1.0);
431
+ vec2 n0 = p0.xy / p0.w;
432
+ vec2 dn = clipPos.xy / clipPos.w - n0;
433
+ vec2 perPx = vec2(dFdx(n0.x), dFdy(n0.y));
434
+ vec2 off = vec2(abs(perPx.x) > 1e-9 ? dn.x / perPx.x : 0.0, abs(perPx.y) > 1e-9 ? dn.y / perPx.y : 0.0);
435
+ return (gl_FragCoord.xy + off) * uScreenSize.zw;
436
+ }
437
+ `;
438
+ const _grabChunk = new WeakMap();
439
+ let _warnedGrabChunk = false;
440
+
441
+ /** The engine's refractionDynamicPS with the per-view grab UV (null if its shape is not the expected one). */
442
+ function stereoRefractionChunk(pc, device) {
443
+ if (_grabChunk.has(device)) return _grabChunk.get(device);
444
+ let out = null;
445
+ try {
446
+ const src = pc.ShaderChunks?.get?.(device, pc.SHADERLANGUAGE_GLSL)?.get?.('refractionDynamicPS');
447
+ const call = /getGrabScreenPos\(\s*projectionPoint\s*\)/;
448
+ const def = /vec3\s+evalRefractionColor\s*\(/;
449
+ if (typeof src === 'string' && call.test(src) && def.test(src)) {
450
+ out = src.replace(def, (m) => `${GRAB_UV_FN}\n${m}`).replace(call, 'inline3dGrabUV(projectionPoint)');
451
+ }
452
+ } catch {
453
+ out = null;
454
+ }
455
+ if (!out && !_warnedGrabChunk) {
456
+ _warnedGrabChunk = true;
457
+ console.warn(`${TAG} this engine's refraction shader has an unexpected shape; transmissive materials keep the engine's grab UV (correct in mono, not per eye in stereo).`);
458
+ }
459
+ _grabChunk.set(device, out);
460
+ return out;
461
+ }
462
+
463
+ /** Transmissive draws first, back-to-front among themselves (the engine's own key + an offset). */
464
+ function transmissiveFirst(mi, camPos, camFwd) {
465
+ const c = mi.aabb.center;
466
+ return 1e6 + (c.x - camPos.x) * camFwd.x + (c.y - camPos.y) * camFwd.y + (c.z - camPos.z) * camFwd.z;
467
+ }
468
+
469
+ /**
470
+ * Apply 1–3 above to every dynamic-refraction mesh under `entity` not already handled (`seen`).
471
+ * Returns how many transmissive mesh instances there are under it (handled now or before) —
472
+ * the caller turns the grab pass on when that is > 0. Cheap to call again: handled ones are skipped.
473
+ */
474
+ export function prepareTransmission(pc, entity, device, seen = new WeakSet()) {
475
+ const renders = entity?.findComponents ? entity.findComponents('render') : [];
476
+ let n = 0;
477
+ for (const r of renders) {
478
+ for (const mi of r.meshInstances || []) {
479
+ const m = mi.material;
480
+ if (!m?.useDynamicRefraction) continue;
481
+ n++;
482
+ if (seen.has(mi)) continue;
483
+ seen.add(mi);
484
+ mi.calculateSortDistance = transmissiveFirst;
485
+ if (!seen.has(m)) {
486
+ seen.add(m);
487
+ const chunk = device ? stereoRefractionChunk(pc, device) : null;
488
+ if (chunk && typeof m.getShaderChunks === 'function') {
489
+ m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('refractionDynamicPS', chunk);
490
+ m.shaderChunksVersion = pc.CHUNKAPI_2_8 ?? '2.8';
491
+ m.update?.();
492
+ }
493
+ }
494
+ }
495
+ }
496
+ return n;
497
+ }
@@ -35,7 +35,8 @@
35
35
  // same splats; a decision keyed on a screen position would differ between the eyes and read as
36
36
  // rivalry. So no effect here reads gl_FragCoord or a view matrix, and the custom hook exposes no
37
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.)
38
+ // adapter and samples each eye's own half at the zero-disparity plane.) The one exception,
39
+ // `wipecull`, is not an effect: it only skips gaussians no shown pixel can receive.
39
40
  //
40
41
  // ── Sort caveat ───────────────────────────────────────────────────────────────────────────────
41
42
  //
@@ -46,7 +47,12 @@
46
47
  import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
47
48
 
48
49
  /** The fixed composition order of a generated chunk. */
49
- export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom']);
50
+ export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'cull']);
51
+
52
+ /** The most eye views setSource's wavefront culls for (more: every gaussian is drawn). */
53
+ export const WIPE_CULL_MAX_VIEWS = 4;
54
+ const IDENTITY16 = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
55
+ const ZERO4 = new Float32Array(4);
50
56
 
51
57
  /** Named easings (a function `(x) => y` on [0, 1] is accepted too). */
52
58
  export const EASINGS = Object.freeze({
@@ -529,6 +535,74 @@ void ${P}color(vec3 c, inout vec4 col) {}
529
535
  }),
530
536
  },
531
537
 
538
+ // Internal: setSource's wavefront, both photos — draw each photo only on ITS side of the image
539
+ // front. NOT an effect: nothing it removes could have reached a pixel the overlay shows (the
540
+ // frame is bit-exact with it on or off), it only saves the fragments of the half nobody sees.
541
+ // Each gaussian whose on-screen footprint lies wholly on the far side of the front in EVERY eye
542
+ // gets alpha 0, which the engine's own alpha clip culls in the vertex stage. The footprint is
543
+ // bounded the way the engine sizes its quad (gsplatCorner): λ₁ ≤ ‖J‖²·s²·σ²max + 0.3 with
544
+ // ‖J‖² = (f/z)²·(1 + (x² + y²)/z²), l₁ = 2·√(2λ₁), and a corner offset ≤ l₁ + l₂ ≤ 2·l₁, plus
545
+ // 4 px. The front is the overlay's own (setSnapshotAlpha's wipe), in each eye's viewport-
546
+ // relative NDC. The eye views are the camera's (its parent's world transform · each view's
547
+ // pose), set by the adapter just before the engine renders — the live outgoing camera has its
548
+ // own. It is the LAST stage, so it sees the final centre and scale of every stage before it.
549
+ // Per mesh instance: `side` +1 keeps the right of `edge` (the outgoing photo), −1 the left
550
+ // (the incoming one); the material default is off.
551
+ wipecull: {
552
+ stage: 'cull',
553
+ kind: 'transition',
554
+ internal: true,
555
+ defaults: { durationMs: 0, easing: 'linear', holdMs: 0, direction: 'in' },
556
+ glsl: (P) => {
557
+ const views = [0, 1, 2, 3];
558
+ return `
559
+ uniform float ${P}on;
560
+ uniform float ${P}side;
561
+ uniform float ${P}edge;
562
+ uniform float ${P}n;
563
+ ${views.map((i) => `uniform mat4 ${P}V${i};\nuniform vec4 ${P}X${i};\nuniform vec4 ${P}W${i};\nuniform vec4 ${P}K${i};`).join('\n')}
564
+ bool ${P}cut;
565
+ // true when some of the footprint may reach the kept side of the front in this view
566
+ bool ${P}reach(mat4 V, vec4 X, vec4 Wr, vec4 K, vec3 c, float s) {
567
+ vec4 p = vec4(c, 1.0);
568
+ vec4 v = V * p;
569
+ float z = -v.z;
570
+ float w = dot(Wr, p);
571
+ if (z <= 1e-6 || w <= 1e-6) return true; // at or behind the eye: the engine decides
572
+ float xn = dot(X, p) / w;
573
+ float jz = K.x / z;
574
+ float j2 = jz * jz * (1.0 + dot(v.xy, v.xy) / (z * z));
575
+ float l1 = 2.0 * sqrt(2.0 * (j2 * K.z * s * s + 0.3));
576
+ return ${P}side * (xn - ${P}edge) + (2.0 * l1 + 8.0) * K.y > 0.0;
577
+ }
578
+ void ${P}center(inout vec3 c) {}
579
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
580
+ ${P}cut = false;
581
+ if (${P}on < 0.5) return;
582
+ float s = max(sc.x, max(sc.y, sc.z));
583
+ ${views.map((i) => ` if (${i > 0 ? `${P}n > ${i}.5 && ` : ''}${P}reach(${P}V${i}, ${P}X${i}, ${P}W${i}, ${P}K${i}, mc, s)) return;`).join('\n')}
584
+ ${P}cut = true;
585
+ }
586
+ void ${P}color(vec3 c, inout vec4 col) { if (${P}cut) col.a = 0.0; }
587
+ `;
588
+ },
589
+ // opts.cull() → { side, edge, views: [{ V, X, W, K }] } (at most WIPE_CULL_MAX_VIEWS), or null
590
+ // (off: every gaussian drawn). No `cull` (the material default): off.
591
+ uniforms: (ctx, inst) => {
592
+ const c = typeof inst.opts.cull === 'function' ? inst.opts.cull() : null;
593
+ const on = !!(c && c.views.length && c.views.length <= WIPE_CULL_MAX_VIEWS);
594
+ const out = { on: on ? 1 : 0, side: on ? c.side : 1, edge: on ? c.edge : 0, n: on ? c.views.length : 0 };
595
+ for (let i = 0; i < WIPE_CULL_MAX_VIEWS; i++) {
596
+ const v = on ? c.views[i] : null;
597
+ out['V' + i] = v ? v.V : IDENTITY16;
598
+ out['X' + i] = v ? v.X : ZERO4;
599
+ out['W' + i] = v ? v.W : ZERO4;
600
+ out['K' + i] = v ? v.K : ZERO4;
601
+ }
602
+ return out;
603
+ },
604
+ },
605
+
532
606
  // Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
533
607
  // entity, driven by the adapter.
534
608
  xfade: {
@@ -1528,9 +1602,18 @@ export class SplatEffects {
1528
1602
  * setSource's shader pre-warm compiles ahead of the transition.
1529
1603
  */
1530
1604
  sharedChunkCode(name, effect, opts) {
1531
- const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
1532
- const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => i.name !== name);
1533
- return composeModifier([...insts, { name, def: EFFECTS[effect], opts: o }]).code;
1605
+ return this.sharedChunkCodeFor([[name, effect, opts]]);
1606
+ }
1607
+
1608
+ /** sharedChunkCode for several driveShared bodies at once: [[name, effect, opts], …]. */
1609
+ sharedChunkCodeFor(list) {
1610
+ const names = new Set(list.map((x) => x[0]));
1611
+ const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => !names.has(i.name));
1612
+ for (const [name, effect, opts] of list) {
1613
+ const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
1614
+ insts.push({ name, def: EFFECTS[effect], opts: o });
1615
+ }
1616
+ return composeModifier(insts).code;
1534
1617
  }
1535
1618
 
1536
1619
  _setupInstance(inst) {