@displayxr/inline3d 1.16.0 → 1.18.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 CHANGED
@@ -5,6 +5,70 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.18.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: a page that never
11
+ calls `setVideo` renders the same pixels.
12
+
13
+ ### Added
14
+
15
+ - **`handle.setVideo(src, options?)`** (#36): a stereo video on the persistent PlayCanvas splat
16
+ handle, with no second canvas, layer or session. This is for pages that keep one woven canvas for
17
+ the whole app, where a fresh `addVideo` canvas after a navigation is the side-by-side flash.
18
+ - `src` is a URL (an SDK-owned `<video>`, autoplaying) or your `HTMLVideoElement`. Options:
19
+ `format: 'sbs' | 'tb' | 'mono'`, `fit: 'contain' | 'cover'`, `rig: 'display'`,
20
+ `virtualDisplayHeight`, `loop`, `muted`, `autoplay`.
21
+ - It resolves on the video's first frame to `{ video, format, fit, remove(), stats() }`. The page
22
+ drives transport through `video`.
23
+ - The splat is hidden and a screen-locked plane on the display rig shows the video. Each eye
24
+ samples only its own half. Flat (mono), it shows the left half at full resolution.
25
+ - Upload is gated on `requestVideoFrameCallback`. The colour is `addVideo`'s own.
26
+ - `setVideo(null)` restores the splat, the pose, the lens and the declared view rig exactly.
27
+ - It throws during an in-flight `setSource`. While a video is on, `setSource` rejects and
28
+ `setRig` throws. `controls:'page'` throws.
29
+ - Gates, on a real GPU: each eye shows 0 px of the other half, exit renders **MAE 0.0000**
30
+ against the pre-video frame with the same declared rig, and colour matches `addVideo`'s paint
31
+ at MAE 0.003. At 3840×1080 the upload runs 29.9/s with `texImage2D` at p50 0.1 ms, and 0 video
32
+ frames drop on a 120 Hz panel.
33
+ - See [`docs/playcanvas-adapter.md` § setVideo](docs/playcanvas-adapter.md#setvideo--a-stereo-video-on-the-persistent-handle-36).
34
+
35
+ ## 1.17.0 — 2026-09-24
36
+
37
+ Touches the **preview tier** (`./model` and `./splat`, `engine: 'playcanvas'` only). The default look
38
+ does not move except on glTFs that use `KHR_materials_transmission`.
39
+
40
+ ### Changed
41
+
42
+ - **`environment: 'room'` is now three's room on the PlayCanvas backend** (#36). `addModel(…, {
43
+ environment: 'room' })` and `setRig('display', { environment: 'room' })` light with three.js's
44
+ procedural RoomEnvironment, regenerated in memory (ray cast, no asset, ~16 ms once per page), and
45
+ default to tone mapping `'none'`, three's. Through 1.16 `'room'` was an alias of `'neutral'` on
46
+ this engine, so a page that passed it to keep its three look got the Sample-Viewer studio with PBR
47
+ Neutral. **The default is now spelled `'neutral'` and renders exactly as before** (Khronos gate
48
+ re-run: 16.7 / 6.4 / 2.7 / 9.4, unchanged). On a four-item product catalogue, object MAE against three r180
49
+ `room` goes from 20.5 / 54.4 / 29.5 / 23.3 (the default) to **10.2 / 11.0 / 7.1 / 6.3** (storm
50
+ lantern / handbag / boot / compass). Tone mapping and the environment explain the gap, and what is
51
+ left is where three departs from the Sample Viewer (PlayCanvas `room` is closer to the Sample
52
+ Viewer lit by the same room than three is: handbag 4.8 vs 13.4). `setRig('display')` with
53
+ `'room'` renders pixel-identical to `addModel` with it. See
54
+ [`docs/playcanvas-model-backend.md` § Environments](docs/playcanvas-model-backend.md#environments--neutral-default-and-room-three).
55
+ - `setRig('display')`'s `toneMapping` now defaults to the environment's (`'none'` for `'room'`,
56
+ `'neutral'` otherwise). A second `setRig('display')` with another environment swaps the IBL it
57
+ installed.
58
+
59
+ ### Fixed
60
+
61
+ - **`KHR_materials_transmission` / `_volume` on PlayCanvas** (#36), for `addModel` and for meshes
62
+ under root on `setRig('display')`:
63
+ - The scene-colour grab pass the material samples is now requested. Without it the storm lantern's
64
+ burner rendered as a magenta blob inside the globe.
65
+ - Transmissive draws sort ahead of blended ones, the pass order of the Sample Viewer and three.
66
+ The lantern's opaque-alpha globe no longer shows the burner through it.
67
+ - The grab UV is taken per view. The engine mapped each view's NDC over the whole target, so in
68
+ stereo each eye sampled across both. Mono renders are identical.
69
+ - Lantern object MAE vs the Sample Viewer (default look): 14.4 → 12.4.
70
+ - See [§ Transmission](docs/playcanvas-model-backend.md#transmission-khr_materials_transmission--_volume).
71
+
8
72
  ## 1.16.0 — 2026-09-24
9
73
 
10
74
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive. A page that never
package/README.md CHANGED
@@ -48,7 +48,9 @@ engine's knobs) — and adds `setSource(src, { fadeMs })` (crossfading asset swa
48
48
  tilt-and-relax orbit, `handle.engine` (the engine objects, for advanced pages) and
49
49
  `handle.setRig('display' | 'camera' | 'auto')`: a live, reversible rig switch, so one persistent tile
50
50
  can show a glTF under `handle.engine.root` exactly as `addModel` would and then go back to the photo's
51
- capture rig ([§setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36)). It needs the
51
+ capture rig ([§setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36)), and
52
+ `handle.setVideo(src, { format: 'sbs' | 'tb' | 'mono' })`: a stereo video on that same tile, each eye
53
+ its own half, with no second canvas ([§setVideo](docs/playcanvas-adapter.md#setvideo--a-stereo-video-on-the-persistent-handle-36)). It needs the
52
54
  optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
53
55
  `lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
54
56
  `playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
@@ -69,7 +71,8 @@ Splat and model rendering on the PlayCanvas backend uses the PlayCanvas engine (
69
71
  **Models render with PlayCanvas by default (1.12).** `addModel(wall, canvas, 'chair.glb')` loads
70
72
  the glTF with the PlayCanvas engine (optional peer `playcanvas`), lit to match the Khronos glTF
71
73
  Sample Viewer: an in-memory neutral studio environment, Khronos PBR Neutral tone mapping,
72
- exposure 1. `engine: 'three'` is the 1.11 three.js renderer, unchanged (also importable as
74
+ exposure 1. `environment: 'room'` gives three's RoomEnvironment look instead (a page tuned on the
75
+ three path). `engine: 'three'` is the 1.11 three.js renderer, unchanged (also importable as
73
76
  `@displayxr/inline3d/model/three`). Each engine is imported only when a tile asks for it. Draco
74
77
  and KTX2 use the same served decoder folders on both engines. Design, the Sample-Viewer MAE table
75
78
  and what throws: [`docs/playcanvas-model-backend.md`](docs/playcanvas-model-backend.md).
@@ -6,7 +6,7 @@ it are adapted from, or run on, the MIT-licensed projects below. Their notices f
6
6
  | Project | Version | How it is used |
7
7
  |---|---|---|
8
8
  | [PlayCanvas engine](https://github.com/playcanvas/engine) | 2.22.3 | Optional peer (`playcanvas`): the default splat backend and the `./model` backend. Shader code in this package is **adapted** from it: the non-square-pixel footprint rewrite of `src/scene/shader-lib/glsl/chunks/gsplat/vert/gsplatCorner.js` (`js/inline3d-splat-playcanvas.js`), the quad-extent cap in `gsplatCommon.js` (`js/inline3d-splat-perf.js`), and the dissolve effect and its hash/noise/fbm helpers from `scripts/esm/gsplat/shader-effect-dissolve.mjs` (`js/inline3d-splat-effects.js`). Each adapted site carries a provenance comment. |
9
- | [three.js](https://github.com/mrdoob/three.js) | ≥ 0.150 | Optional peer (`three`): the `./three` helpers, the Spark splat path, `./model`'s `engine: 'three'`. |
9
+ | [three.js](https://github.com/mrdoob/three.js) | ≥ 0.150 | Optional peer (`three`): the `./three` helpers, the Spark splat path, `./model`'s `engine: 'three'`. The PlayCanvas backend's `environment: 'room'` is **adapted** from `examples/jsm/environments/RoomEnvironment.js` (its scene numbers, re-rendered by ray casting in `js/inline3d-model-playcanvas.js`, `ROOM_ENVIRONMENT`). |
10
10
  | [Spark](https://github.com/sparkjsdev/spark) | ≥ 2.0 | Optional peer (`@sparkjsdev/spark`): the Spark splat backend. |
11
11
 
12
12
  ## PlayCanvas engine — MIT
@@ -24,6 +24,8 @@
24
24
  // and was fitted to the Sample Viewer's "Studio Neutral". three's path keeps RoomEnvironment
25
25
  // and no tone mapping (unchanged since 1.11); the two backends therefore do not look
26
26
  // identical, by design — docs/playcanvas-model-backend.md §Lighting has the MAE table.
27
+ // `environment: 'room'` is three's room on this engine (§ the room environment), untonemapped,
28
+ // for a page tuned on the three path (§Environments).
27
29
  // · Decoders are the ENGINE's: Draco and Basis through the engine's own worker readers (fed the
28
30
  // SAME files three's `libs/draco/` and `libs/basis/` folders hold), meshopt through the
29
31
  // container's bufferView hook with meshoptimizer's `MeshoptDecoder` (the engine has no
@@ -347,10 +349,13 @@ export const ENV_YAW_DEG = 0;
347
349
 
348
350
  /** Build the default environment on this app and set it as the scene's envAtlas. */
349
351
  export function useNeutralStudio(pc, app, yawDeg) {
350
- const W = 256;
351
- const H = 128;
352
+ return useRgbeEquirect(pc, app, 'inline3d-neutral-studio', neutralStudioRGBE(256, 128), 256, 128, yawDeg);
353
+ }
354
+
355
+ /** A grey RGBE equirect (row 0 = up) → prefiltered envAtlas on the scene. */
356
+ function useRgbeEquirect(pc, app, name, bytes, W, H, yawDeg) {
352
357
  const tex = new pc.Texture(app.graphicsDevice, {
353
- name: 'inline3d-neutral-studio',
358
+ name,
354
359
  width: W,
355
360
  height: H,
356
361
  format: pc.PIXELFORMAT_RGBA8,
@@ -359,7 +364,7 @@ export function useNeutralStudio(pc, app, yawDeg) {
359
364
  mipmaps: false,
360
365
  addressU: pc.ADDRESS_REPEAT,
361
366
  addressV: pc.ADDRESS_CLAMP_TO_EDGE,
362
- levels: [neutralStudioRGBE(W, H)],
367
+ levels: [bytes],
363
368
  });
364
369
  return useLightingSource(pc, app, tex, true, yawDeg);
365
370
  }
@@ -373,7 +378,9 @@ function useLightingSource(pc, app, source, own, yawDeg = 0) {
373
378
  app.scene.envAtlas = atlas;
374
379
  app.scene.skyboxIntensity = 1;
375
380
  app.scene.exposure = 1;
376
- if (pc.Quat && yawDeg) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg, 0);
381
+ // Always written (identity at 0): a later environment on the same scene must not inherit the
382
+ // previous one's yaw (setRig switches room ↔ neutral on one app).
383
+ if (pc.Quat) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg || 0, 0);
377
384
  return atlas;
378
385
  }
379
386
 
@@ -388,6 +395,244 @@ function loadTexture(pc, app, url) {
388
395
  });
389
396
  }
390
397
 
398
+ // ── the room environment (environment: 'room') ─────────────────────────────────────────────
399
+
400
+ /**
401
+ * `environment: 'room'` — three.js's procedural RoomEnvironment, regenerated here without three:
402
+ * the same scene (a white room lit by one point light, six grey boxes, six emissive panels), shaded
403
+ * the way three shades it when `PMREMGenerator.fromScene(new RoomEnvironment(), 0.04)` bakes it —
404
+ * which is exactly what `addModel(…, { engine: 'three', environment: 'room' })` lights with.
405
+ *
406
+ * It exists so a page that chose `'room'` on the three path gets that look on this backend. It is
407
+ * NOT the default: the default (`'neutral'`) is matched to the Khronos Sample Viewer, and three's
408
+ * room is a different, brighter room (mean radiance ~0.97 vs ~0.89, most of it from a 100-nit
409
+ * ceiling panel and two 50-nit wall panels).
410
+ *
411
+ * The scene's numbers (positions, rotations, scales, intensities) are three.js's
412
+ * examples/jsm/environments/RoomEnvironment.js (MIT, © three.js authors), itself after
413
+ * model-viewer's EnvironmentScene. No image is shipped: `roomRadiance` ray-casts the boxes per
414
+ * texel. Checked against three r180's own bake (its PMREM, read back through `textureCubeUV`):
415
+ * texel-to-texel ratio median 1.000 (p10 0.989, p90 1.006), solid-angle-weighted MAE 0.03 unblurred
416
+ * and 0.04 with the 0.04-rad blur. Solids are [cx, cy, cz, angleY, sx, sy, sz] (boxes) and
417
+ * [cx, cy, cz, sx, sy, sz, emissive] (panels), BoxGeometry(1) scaled — i.e. full extents.
418
+ */
419
+ export const ROOM_ENVIRONMENT = Object.freeze({
420
+ light: [0.418, 16.199, 0.3, 900, 28, 2], // point light: position, intensity (cd), cutoff distance, decay
421
+ room: [-0.757, 13.219, 0.717, 31.713, 28.305, 28.591], // seen from inside
422
+ boxes: [
423
+ [-10.906, 2.009, 1.846, -0.195, 2.328, 7.905, 4.651],
424
+ [-5.607, -0.754, -0.758, 0.994, 1.97, 1.534, 3.955],
425
+ [6.167, 0.857, 7.803, 0.561, 3.927, 6.285, 3.687],
426
+ [-2.017, 0.018, 6.124, 0.333, 2.002, 4.566, 2.064],
427
+ [2.291, -0.756, -2.621, -0.286, 1.546, 1.552, 1.496],
428
+ [-2.193, -0.369, -5.547, 0.516, 3.875, 3.487, 2.986],
429
+ ],
430
+ panels: [
431
+ [-16.116, 14.37, 8.208, 0.1, 2.428, 2.739, 50],
432
+ [-16.109, 18.021, -8.207, 0.1, 2.425, 2.751, 50],
433
+ [14.904, 12.198, -1.832, 0.15, 4.265, 6.331, 17],
434
+ [-0.462, 8.89, 14.52, 4.38, 5.441, 0.088, 43],
435
+ [3.235, 11.486, -12.541, 2.5, 2.0, 0.1, 20],
436
+ [0.0, 20.0, 0.0, 1.0, 0.1, 1.0, 100],
437
+ ],
438
+ blur: 0.04, // radians — the sigma addModel's three path passes to fromScene
439
+ });
440
+
441
+ /** Every solid as [cx, cy, cz, cosY, sinY, hx, hy, hz, kind (0 room, 1 lit box, 2 panel), emissive]. */
442
+ const _roomSolids = new WeakMap();
443
+ function roomSolids(env) {
444
+ let out = _roomSolids.get(env);
445
+ if (out) return out;
446
+ const [rx, ry, rz, sx, sy, sz] = env.room;
447
+ out = [[rx, ry, rz, 1, 0, sx / 2, sy / 2, sz / 2, 0, 0]];
448
+ 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]);
449
+ 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]);
450
+ _roomSolids.set(env, out);
451
+ return out;
452
+ }
453
+
454
+ /**
455
+ * Radiance of the room seen from the origin toward unit direction (x, y, z): the nearest solid
456
+ * along the ray. A panel is its emissive value; a wall or box (white, roughness 1, non-metal) is
457
+ * three's direct physical shading from the one point light, unshadowed as in three (the scene casts
458
+ * no shadows) — Lambert plus GGX at alpha 1 (D = 1/π, V = 0.5/(NL+NV), Schlick F0 0.04), with
459
+ * three's inverse-square falloff and its (1 − (d/cutoff)⁴)² window.
460
+ */
461
+ export function roomRadiance(x, y, z, env = ROOM_ENVIRONMENT) {
462
+ let best = Infinity;
463
+ let nx = 0;
464
+ let ny = 0;
465
+ let nz = 0;
466
+ let emit = -1;
467
+ let found = false;
468
+ for (const b of roomSolids(env)) {
469
+ const c = b[3];
470
+ const s = b[4];
471
+ // The ray in the solid's frame (a rotation about Y): local = Rᵀ(p − centre).
472
+ const ox = -b[0];
473
+ const oy = -b[1];
474
+ const oz = -b[2];
475
+ const o = [c * ox - s * oz, oy, s * ox + c * oz];
476
+ const d = [c * x - s * z, y, s * x + c * z];
477
+ let t0 = -Infinity;
478
+ let t1 = Infinity;
479
+ let a0 = 0;
480
+ let a1 = 0;
481
+ let miss = false;
482
+ for (let i = 0; i < 3; i++) {
483
+ const h = b[5 + i];
484
+ if (Math.abs(d[i]) < 1e-12) {
485
+ if (o[i] < -h || o[i] > h) {
486
+ miss = true;
487
+ break;
488
+ }
489
+ continue;
490
+ }
491
+ let n = (-h - o[i]) / d[i];
492
+ let f = (h - o[i]) / d[i];
493
+ let sn = -1; // the entry face's outward normal is −axis when the ray runs +axis
494
+ if (n > f) {
495
+ const t = n;
496
+ n = f;
497
+ f = t;
498
+ sn = 1;
499
+ }
500
+ if (n > t0) {
501
+ t0 = n;
502
+ a0 = (i + 1) * sn;
503
+ }
504
+ if (f < t1) {
505
+ t1 = f;
506
+ a1 = -(i + 1) * sn;
507
+ }
508
+ }
509
+ if (miss || t0 > t1 || t1 < 0) continue;
510
+ const inside = b[8] === 0;
511
+ const t = inside ? t1 : t0;
512
+ if (!(t > 0) || t >= best) continue;
513
+ best = t;
514
+ found = true;
515
+ if (b[8] === 2) {
516
+ emit = b[9];
517
+ continue;
518
+ }
519
+ emit = -1;
520
+ const a = inside ? a1 : a0;
521
+ const sg = (inside ? -1 : 1) * Math.sign(a); // the room is seen from inside: its normals point in
522
+ const ax = Math.abs(a) - 1;
523
+ const l0 = ax === 0 ? sg : 0;
524
+ const l2 = ax === 2 ? sg : 0;
525
+ nx = c * l0 + s * l2;
526
+ ny = ax === 1 ? sg : 0;
527
+ nz = -s * l0 + c * l2;
528
+ }
529
+ if (!found) return 0;
530
+ if (emit >= 0) return emit;
531
+ const [lx, ly, lz, I, cutoff, decay] = env.light;
532
+ let tx = lx - x * best;
533
+ let ty = ly - y * best;
534
+ let tz = lz - z * best;
535
+ const dist = Math.hypot(tx, ty, tz);
536
+ tx /= dist;
537
+ ty /= dist;
538
+ tz /= dist;
539
+ const nl = nx * tx + ny * ty + nz * tz;
540
+ if (nl <= 0) return 0;
541
+ const win = Math.max(0, Math.min(1, 1 - Math.pow(dist / cutoff, 4)));
542
+ const E = (I / Math.max(Math.pow(dist, decay), 0.01)) * win * win * nl;
543
+ const nv = Math.max(1e-4, -(nx * x + ny * y + nz * z));
544
+ const hx = tx - x;
545
+ const hy = ty - y;
546
+ const hz = tz - z;
547
+ const vh = Math.max(0, (tx * hx + ty * hy + tz * hz) / Math.hypot(hx, hy, hz));
548
+ const F = 0.04 + 0.96 * Math.pow(1 - vh, 5);
549
+ return (E / Math.PI) * (1 + F * (0.5 / (nl + nv)));
550
+ }
551
+
552
+ /**
553
+ * The room as a float equirect (row 0 = up, the same convention as neutralStudioRGBE), blurred by a
554
+ * Gaussian of `sigma` radians — separable on the sphere: σ in rows, σ/sinθ in columns (wrapping).
555
+ */
556
+ export function roomEquirect(W = 256, H = 128, sigma = ROOM_ENVIRONMENT.blur, env = ROOM_ENVIRONMENT) {
557
+ const out = new Float32Array(W * H);
558
+ for (let r = 0; r < H; r++) {
559
+ const th = ((r + 0.5) / H) * Math.PI;
560
+ const st = Math.sin(th);
561
+ const y = Math.cos(th);
562
+ for (let c = 0; c < W; c++) {
563
+ const ph = ((c + 0.5) / W) * 2 * Math.PI;
564
+ out[r * W + c] = roomRadiance(st * Math.cos(ph), y, st * Math.sin(ph), env);
565
+ }
566
+ }
567
+ if (!(sigma > 0)) return out;
568
+ const perRow = Math.PI / H; // radians per row, and per column at the equator
569
+ const tmp = new Float32Array(W * H);
570
+ for (let r = 0; r < H; r++) {
571
+ const st = Math.max(Math.sin(((r + 0.5) / H) * Math.PI), 1e-3);
572
+ const sg = Math.min(sigma / perRow / st, W / 4);
573
+ const R = Math.ceil(3 * sg);
574
+ const k = [];
575
+ let ks = 0;
576
+ for (let i = -R; i <= R; i++) {
577
+ const v = Math.exp(-(i * i) / (2 * sg * sg));
578
+ k.push(v);
579
+ ks += v;
580
+ }
581
+ for (let c = 0; c < W; c++) {
582
+ let a = 0;
583
+ for (let i = -R; i <= R; i++) a += k[i + R] * out[r * W + (((c + i) % W) + W) % W];
584
+ tmp[r * W + c] = a / ks;
585
+ }
586
+ }
587
+ const sg = sigma / perRow;
588
+ const R = Math.ceil(3 * sg);
589
+ for (let r = 0; r < H; r++) {
590
+ for (let c = 0; c < W; c++) {
591
+ let a = 0;
592
+ let ks = 0;
593
+ for (let i = -R; i <= R; i++) {
594
+ const rr = r + i;
595
+ if (rr < 0 || rr >= H) continue;
596
+ const v = Math.exp(-(i * i) / (2 * sg * sg));
597
+ a += v * tmp[rr * W + c];
598
+ ks += v;
599
+ }
600
+ out[r * W + c] = a / ks;
601
+ }
602
+ }
603
+ return out;
604
+ }
605
+
606
+ /** The room as RGBE bytes (cached per size: every tile on a page gets the same image). */
607
+ const _roomCache = new Map();
608
+ export function roomRGBE(W = 256, H = 128) {
609
+ const key = `${W}x${H}`;
610
+ let bytes = _roomCache.get(key);
611
+ if (!bytes) {
612
+ const f = roomEquirect(W, H);
613
+ bytes = new Uint8Array(W * H * 4);
614
+ for (let i = 0; i < f.length; i++) rgbe(f[i], bytes, i * 4);
615
+ _roomCache.set(key, bytes);
616
+ }
617
+ return bytes.slice();
618
+ }
619
+
620
+ /**
621
+ * Yaw that lines the generated room up with three's world (its +X panel on +X, the ceiling panel
622
+ * overhead). Calibrated headless against three r180's own `environment: 'room'` render on two
623
+ * catalogue meshes: a sweep over 0/90/180/270 × mirrored has a clean minimum at 90, unmirrored, on
624
+ * both. (The neutral studio's 0 is the Sample Viewer's orientation, a different frame.)
625
+ */
626
+ export const ROOM_YAW_DEG = 90;
627
+
628
+ /** Build the room environment on this app and set it as the scene's envAtlas. */
629
+ export function useRoomEnvironment(pc, app, yawDeg = ROOM_YAW_DEG) {
630
+ return useRgbeEquirect(pc, app, 'inline3d-room', roomRGBE(256, 128), 256, 128, yawDeg);
631
+ }
632
+
633
+ /** Each environment's tone mapping: the look it was matched to. */
634
+ export const ENVIRONMENT_TONE_MAPPING = Object.freeze({ neutral: 'neutral', room: 'none', studio: 'neutral', none: 'neutral' });
635
+
391
636
  /**
392
637
  * `environment: 'studio'` — three's three-point rig, same directions and ratios. Engine lights
393
638
  * shine along their local −Y, so each is aimed by rotation (never lookAt). three's punctual
@@ -430,6 +675,100 @@ function addStudioLights(pc, app, root) {
430
675
  app.scene.ambientLight = new pc.Color(amb, amb, amb);
431
676
  }
432
677
 
678
+ // ── KHR_materials_transmission / volume on this engine ─────────────────────────────────────
679
+
680
+ /**
681
+ * The engine reads KHR_materials_transmission (and _volume) into `useDynamicRefraction` +
682
+ * `BLEND_NORMAL`, and a dynamic-refraction material samples the camera's SCENE COLOUR MAP. Three
683
+ * things the engine leaves to the app, all needed for a glass or transmissive glTF to look like
684
+ * it does in the Khronos Sample Viewer (and three):
685
+ *
686
+ * 1. THE GRAB PASS. Nothing renders the scene colour map unless a camera asks for it; without it
687
+ * the material samples an unbound texture — the storm lantern's burner rendered as a MAGENTA
688
+ * blob inside its globe (measured). `viewer.useSceneColor()` asks, on every eye camera.
689
+ * 2. PASS ORDER. Both references draw opaque → transmissive → blended. The engine puts a
690
+ * transmissive material in the ONE back-to-front transparent list with the blended ones, so a
691
+ * blended globe (depthWrite off) can be drawn before the transmissive body behind it, which
692
+ * then paints over it — the burner showed THROUGH the lantern's opaque-alpha globe. A sort
693
+ * distance that puts transmissive draws first (back-to-front among themselves) restores the
694
+ * order: object MAE 10.7 → 10.2 vs three, 11.2 → 10.8 vs the Sample Viewer.
695
+ * 3. STEREO. The engine turns the refracted point's clip position into a grab-texture UV by
696
+ * mapping the VIEW's NDC over the WHOLE target (`getGrabScreenPos`). A stereo tile draws two
697
+ * views side by side into one target, so each eye would sample across both. The patched chunk
698
+ * measures the offset from THIS fragment instead (NDC delta ÷ NDC-per-pixel, from the
699
+ * derivatives), which is exact for any viewport and identical in mono.
700
+ */
701
+ const GRAB_UV_FN = `
702
+ vec2 inline3dGrabUV(vec4 clipPos) {
703
+ vec4 p0 = matrix_viewProjection * vec4(vPositionW, 1.0);
704
+ vec2 n0 = p0.xy / p0.w;
705
+ vec2 dn = clipPos.xy / clipPos.w - n0;
706
+ vec2 perPx = vec2(dFdx(n0.x), dFdy(n0.y));
707
+ 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);
708
+ return (gl_FragCoord.xy + off) * uScreenSize.zw;
709
+ }
710
+ `;
711
+ const _grabChunk = new WeakMap();
712
+ let _warnedGrabChunk = false;
713
+
714
+ /** The engine's refractionDynamicPS with the per-view grab UV (null if its shape is not the expected one). */
715
+ function stereoRefractionChunk(pc, device) {
716
+ if (_grabChunk.has(device)) return _grabChunk.get(device);
717
+ let out = null;
718
+ try {
719
+ const src = pc.ShaderChunks?.get?.(device, pc.SHADERLANGUAGE_GLSL)?.get?.('refractionDynamicPS');
720
+ const call = /getGrabScreenPos\(\s*projectionPoint\s*\)/;
721
+ const def = /vec3\s+evalRefractionColor\s*\(/;
722
+ if (typeof src === 'string' && call.test(src) && def.test(src)) {
723
+ out = src.replace(def, (m) => `${GRAB_UV_FN}\n${m}`).replace(call, 'inline3dGrabUV(projectionPoint)');
724
+ }
725
+ } catch {
726
+ out = null;
727
+ }
728
+ if (!out && !_warnedGrabChunk) {
729
+ _warnedGrabChunk = true;
730
+ 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).`);
731
+ }
732
+ _grabChunk.set(device, out);
733
+ return out;
734
+ }
735
+
736
+ /** Transmissive draws first, back-to-front among themselves (the engine's own key + an offset). */
737
+ function transmissiveFirst(mi, camPos, camFwd) {
738
+ const c = mi.aabb.center;
739
+ return 1e6 + (c.x - camPos.x) * camFwd.x + (c.y - camPos.y) * camFwd.y + (c.z - camPos.z) * camFwd.z;
740
+ }
741
+
742
+ /**
743
+ * Apply 1–3 above to every dynamic-refraction mesh under `entity` not already handled (`seen`).
744
+ * Returns how many transmissive mesh instances there are under it (handled now or before) —
745
+ * the caller turns the grab pass on when that is > 0. Cheap to call again: handled ones are skipped.
746
+ */
747
+ export function prepareTransmission(pc, entity, device, seen = new WeakSet()) {
748
+ const renders = entity?.findComponents ? entity.findComponents('render') : [];
749
+ let n = 0;
750
+ for (const r of renders) {
751
+ for (const mi of r.meshInstances || []) {
752
+ const m = mi.material;
753
+ if (!m?.useDynamicRefraction) continue;
754
+ n++;
755
+ if (seen.has(mi)) continue;
756
+ seen.add(mi);
757
+ mi.calculateSortDistance = transmissiveFirst;
758
+ if (!seen.has(m)) {
759
+ seen.add(m);
760
+ const chunk = device ? stereoRefractionChunk(pc, device) : null;
761
+ if (chunk && typeof m.getShaderChunks === 'function') {
762
+ m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('refractionDynamicPS', chunk);
763
+ m.shaderChunksVersion = pc.CHUNKAPI_2_8 ?? '2.8';
764
+ m.update?.();
765
+ }
766
+ }
767
+ }
768
+ }
769
+ return n;
770
+ }
771
+
433
772
  // ── framing ─────────────────────────────────────────────────────────────────────────────────
434
773
 
435
774
  /** Union of the render mesh instances' world AABBs (content space, the file's own units). */
@@ -535,7 +874,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
535
874
  fitSweep = true,
536
875
  renderScale = 1,
537
876
  feather = 0,
538
- environment = 'room',
877
+ environment = 'neutral',
539
878
  envMap = null,
540
879
  decoderPath = null,
541
880
  meshoptDecoder = null,
@@ -568,7 +907,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
568
907
  nearClip: opts.nearClip,
569
908
  farClip: opts.farClip,
570
909
  sky: false,
571
- toneMapping: 'neutral',
910
+ // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
911
+ toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
572
912
  });
573
913
 
574
914
  let handle = null;
@@ -701,7 +1041,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
701
1041
  const yaw = Number.isFinite(opts.environmentRotation) ? opts.environmentRotation : 0;
702
1042
  if (typeof envMap === 'string') useLightingSource(pc, app, await loadTexture(pc, app, envMap), true, yaw);
703
1043
  else if (envMap && typeof envMap === 'object') useLightingSource(pc, app, envMap, false, yaw);
704
- else if (environment === 'room' || environment === 'neutral') useNeutralStudio(pc, app, ENV_YAW_DEG + yaw);
1044
+ else if (environment === 'room') useRoomEnvironment(pc, app, ROOM_YAW_DEG + yaw);
1045
+ else if (environment === 'neutral') useNeutralStudio(pc, app, ENV_YAW_DEG + yaw);
705
1046
  else if (environment === 'studio') addStudioLights(pc, app, viewer.content);
706
1047
  if (removed) return out;
707
1048
 
@@ -736,6 +1077,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
736
1077
  if (removed) return out;
737
1078
 
738
1079
  const entity = asset.resource.instantiateRenderEntity();
1080
+ // KHR_materials_transmission / volume: grab pass, pass order, per-eye grab UV (§ prepareTransmission).
1081
+ if (prepareTransmission(pc, entity, app.graphicsDevice) > 0) viewer.useSceneColor(true);
739
1082
  viewer.content.addChild(entity);
740
1083
  out.model = entity;
741
1084
  out.container = asset.resource; // animations, materials, textures (engine types)
@@ -53,6 +53,7 @@ export {
53
53
  SEMANTIC_TEXCOORD0,
54
54
  ShaderChunks,
55
55
  SHADERLANGUAGE_GLSL,
56
+ CHUNKAPI_2_8,
56
57
  // setSource's frame snapshot
57
58
  RenderTarget,
58
59
  FILTER_NEAREST,