@displayxr/inline3d 1.7.0 → 1.8.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,111 @@ 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.8.0 — 2026-09-22
9
+
10
+ Touches the **preview tier** (`./splat`, plus one additive `./three` export). **Existing callers get
11
+ the same engine and the same code path**: with no `engine` option, or `engine: 'spark'`, `addSplat`
12
+ renders with three.js + Spark as in 1.7.1. The PlayCanvas engine is **opt-in** with
13
+ `engine: 'playcanvas'`.
14
+
15
+ **One behaviour change, for one class of file.** The camera-rig focus waterfall gains a rung (see
16
+ *Changed*). A `.sog` whose `camera` block has **no `focus`**, or a `focus` its converter computed
17
+ as the whole-cloud median (`focus.source: "cloud-median"`), now converges on the **nearest
18
+ substantial clump** in the middle of the frame instead of the whole-scene median. On an open
19
+ landscape that is 2.4 m (the tree trunk), not 46.9 m, so that file's 3D will look different, as
20
+ intended. Blocks with a considered focus (`convergence`, `manual`, …, or no `source`), assets with
21
+ no block, and a caller's `focus` / `convergence` are unaffected. Both engines.
22
+
23
+ ### Added
24
+
25
+ - **`addSplat(…, { engine: 'playcanvas' })`: the PlayCanvas engine as a second splat backend**
26
+ (epic #36).
27
+ - Same handle and the same rig/lens/focus waterfall as Spark. One camera renders every view
28
+ through the engine's `RenderView` list, so one sort serves all eyes.
29
+ - A shader-chunk fix for the engine's square-pixel footprint assumption, which a side-by-side
30
+ buffer breaks.
31
+ - `perf` presets map onto engine knobs, and `perf: false` leaves the engine alone.
32
+ - A **URL** `.sog` yields its `camera` block too.
33
+ - Needs the new **optional** peer `playcanvas >=2.22.3 <3`, loaded by a dynamic `import()` only
34
+ when asked for.
35
+ - Reads `.sog`, `.ply` and a Streamed-SOG `lod-meta.json`. With `engine: 'playcanvas'`, a
36
+ `.spz` / `.splat` / `.ksplat` source (or a Spark-only `fileType`) **throws at call time**.
37
+ Those stay Spark formats.
38
+ - Differences from the Spark path: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
39
+ - On the PlayCanvas backend:
40
+ - **`handle.setSource(src, { fadeMs, resetPose })`**: a crossfading in-place asset swap that
41
+ re-runs the rig waterfall for the new file. The pose is kept unless `resetPose`. It throws on
42
+ Spark.
43
+ - **`feather`**: the per-eye edge fade, matching `EdgeFeather`.
44
+ - **Tilt-and-relax `orbit`**: the drag is a fraction of the tile, capped at `orbitMaxDeg` (15°),
45
+ and relaxes back on release (`orbitEase` `{ drag: 0.2, rest: 0.6 }` s). Spark keeps its
46
+ turntable drag.
47
+ - **Exact `pick`**: the nearest gaussian centre over the full centre set, with haze skipped.
48
+ - **`captureFit: 'height' | 'cover'`** on the camera rig, both backends. `'height'` (default) is
49
+ the 1.7 window. `'cover'` fills the tile with photograph (a 4:3 capture in a 16:9 tile crops
50
+ top/bottom).
51
+ - **`handle.engine`** (advanced, not semver-covered): `{ app, root, camera }` on PlayCanvas, and
52
+ `{ renderer, scene, camera }` on Spark. **`handle.backend`**: `'playcanvas'` or `'spark'`.
53
+ - **`handle.getFocus()`** and **`handle.onFocusChange(point, { focusSource })`**, in the splat's
54
+ own space, on both backends. `handle.rig` gains `blockFocusSource` and `clumpMassFrac`.
55
+ - **`cameraRigFromPose(pose, opts)`** in `./three`. It builds the camera-rig descriptor from a
56
+ plain `{position, orientation, fov}`, bit-identical to `cameraRigFromCamera`, which is unchanged.
57
+
58
+ ### Changed
59
+
60
+ - **Focus waterfall: `nearest-clump`** (the behaviour change above). The order is now: caller ›
61
+ caller convergence › block focus (any source except `cloud-median` / `median-disparity`) ›
62
+ **nearest-clump** (needs the block's or caller's lens) › `block-cloud-median` ›
63
+ `median-disparity` › 2 m.
64
+ - **Viewer tuning constants are shared** (`inline3d-splat-shared.js`). SceneViewer reads the same
65
+ damping, idle, focus-ease, wheel and zoom numbers as before, now from one module the PlayCanvas
66
+ viewer also reads. The values are unchanged, and a side-by-side trace test pins the two viewers
67
+ bit-identical.
68
+
69
+ ## 1.7.1 — 2026-09-20
70
+
71
+ Touches the **preview tier** (`./model`) only, and fixes exactly one thing: under a bundler, a
72
+ compressed glTF never loaded at all. Uncompressed assets are unaffected, and so is every page that
73
+ loads compressed ones through a bare importmap — same pixels, same timing. If your page builds with
74
+ webpack / Turbopack / Vite / rollup and loads a Draco-, KTX2- or meshopt-compressed model, the tile
75
+ that used to stay empty with a rejected `handle.ready` now renders the product.
76
+
77
+ ### Fixed
78
+
79
+ - **`addModel` could not load ANY compressed asset under a bundler — all three decoders**
80
+ (preview tier). The decoders were resolved with `await import(spec.module)`, the specifier read
81
+ out of the `DECODERS` table — an *expression*, which no bundler can follow. The build printed
82
+
83
+ ```
84
+ Critical dependency: the request of a dependency is an expression
85
+ ```
86
+
87
+ and shipped a stub that throws `Cannot find module 'three/addons/…'` at runtime, which `addModel`
88
+ then reported honestly as a module-resolution failure in its own decoder error. Draco, KTX2/Basis
89
+ **and** meshopt all went through that one call, so the blast radius was the whole compressed path
90
+ — and a catalogue GLB out of a real pipeline is nearly always compressed, which is precisely the
91
+ case `/model` exists for. Each decoder now has a literal `import()` of its own
92
+ (`load: () => import('three/addons/loaders/DRACOLoader.js')`, and so on): that is what a build
93
+ tool can analyse, and it resolves unchanged under the `"three/addons/"` importmap prefix the
94
+ samples use.
95
+
96
+ It survived four releases because neither path that was exercised has the fault — `samples/` runs
97
+ on a bare importmap, which resolves specifiers at runtime and does not care, and the test suite is
98
+ deliberately dependency-free, so it never imported a decoder at all. The guard added with this fix
99
+ is therefore a source-level one: it reads `js/` the way a bundler does and fails if any `import()`
100
+ is handed a computed specifier again.
101
+
102
+ Nothing else moved. Injection (`{ DRACOLoader }`, `{ KTX2Loader }`, `{ meshoptDecoder }`, a class
103
+ or a ready instance you configured yourself), the shared ref-counted decoder cache, `decoderPath`
104
+ and the serve-the-decoder-files-yourself requirement are all as they were, and a decoder is still
105
+ imported only for an asset that declares its extension — bundlers now code-split each one into its
106
+ own chunk, so an uncompressed GLB downloads none of them.
107
+
108
+ Verified in Chrome against a Next.js 15 / webpack app loading an `EXT_meshopt_compression` GLB:
109
+ before, the build warning above plus `Cannot find module
110
+ 'three/addons/libs/meshopt_decoder.module.js'` and an empty stage; after, no warning, the meshopt
111
+ decoder arriving as its own chunk, and the model on screen.
112
+
8
113
  ## 1.7.0 — 2026-09-19
9
114
 
10
115
  Touches the **preview tier** (`./splat`) only, and additively: `addSplat` with no new options
package/README.md CHANGED
@@ -40,6 +40,17 @@ and only the `/three`, `/viewer` and `/splat` subpaths need them. The two viewer
40
40
  call (auto-framing on the zero-disparity plane, orbit, idle turntable, mono fallback), but their
41
41
  API is not yet covered by the semver promise below.
42
42
 
43
+ **A second splat engine (preview).** `addSplat(wall, canvas, src, { engine: 'playcanvas' })`
44
+ renders the same splat window with the [PlayCanvas](https://playcanvas.com/) engine instead of
45
+ Spark — same handle, same rig/focus/camera-block behaviour, same `perf` presets (mapped onto the
46
+ engine's knobs) — and adds `setSource(src, { fadeMs })` (crossfading asset swaps), `feather`, a
47
+ tilt-and-relax orbit and `handle.engine` (the engine objects, for advanced pages). It needs the
48
+ optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
49
+ `lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
50
+ `playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
51
+ Bundlers: the engine is a literal `import('playcanvas')`; its sort workers are Blob URLs (CSP
52
+ `worker-src blob:`), and esbuild needs `node:worker_threads` marked external.
53
+
43
54
  Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
44
55
  that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
45
56
 
@@ -114,6 +125,8 @@ js/
114
125
  and the placement readback (getSubjectBounds / getPose / depthOffset)
115
126
  inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
116
127
  (`perf` cuts overdraw; a `.sog`'s `camera` block picks the view rig)
128
+ inline3d-splat-playcanvas.js
129
+ preview: the `engine: 'playcanvas'` backend of addSplat(), loaded on demand
117
130
  inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
118
131
  from what the asset declares (you serve the decoder files — see the guide)
119
132
  docs/
@@ -63,12 +63,23 @@ async function resolveLoader(injected) {
63
63
  * One entry per decoder: the glTF extension that demands it, where its class lives, where its
64
64
  * runtime files live, and which option overrides each. The error messages are generated from
65
65
  * this table, so a message can never name an option that does not exist.
66
+ *
67
+ * `load` is a thunk around a **literal** `import()` and `module` is the same specifier as a
68
+ * string, and the duplication is deliberate. A bundler can only follow an import whose specifier
69
+ * is written out at the call site: `import(spec.module)` — reading the string out of this table —
70
+ * is an *expression*, which webpack/Turbopack/rollup cannot resolve, so they emit
71
+ * "Critical dependency: the request of a dependency is an expression" at build time and a stub
72
+ * that throws `Cannot find module …` at runtime. That made EVERY compressed asset unloadable for
73
+ * every bundler consumer, while the bare-importmap path (which resolves at runtime and does not
74
+ * care) kept working — so it survived the samples. The string stays because the error messages
75
+ * quote it; the thunk is what actually loads.
66
76
  */
67
77
  const DECODERS = {
68
78
  draco: {
69
79
  ext: 'KHR_draco_mesh_compression',
70
80
  label: 'Draco mesh compression',
71
81
  module: 'three/addons/loaders/DRACOLoader.js',
82
+ load: () => import('three/addons/loaders/DRACOLoader.js'),
72
83
  exportName: 'DRACOLoader',
73
84
  option: 'DRACOLoader',
74
85
  pathKey: 'draco',
@@ -79,6 +90,7 @@ const DECODERS = {
79
90
  ext: 'KHR_texture_basisu',
80
91
  label: 'KTX2 / Basis Universal textures',
81
92
  module: 'three/addons/loaders/KTX2Loader.js',
93
+ load: () => import('three/addons/loaders/KTX2Loader.js'),
82
94
  exportName: 'KTX2Loader',
83
95
  option: 'KTX2Loader',
84
96
  pathKey: 'basis',
@@ -89,6 +101,7 @@ const DECODERS = {
89
101
  ext: 'EXT_meshopt_compression',
90
102
  label: 'meshopt compression',
91
103
  module: 'three/addons/libs/meshopt_decoder.module.js',
104
+ load: () => import('three/addons/libs/meshopt_decoder.module.js'),
92
105
  exportName: 'MeshoptDecoder',
93
106
  option: 'meshoptDecoder',
94
107
  pathKey: null, // pure JS + inlined wasm; nothing for the page to serve
@@ -159,7 +172,10 @@ async function buildDecoder(kind, paths, injected) {
159
172
  const spec = DECODERS[kind];
160
173
  let thing = injected;
161
174
  if (!thing) {
162
- const mod = await import(spec.module);
175
+ // spec.load(), never `import(spec.module)` — see the note on DECODERS. One literal specifier
176
+ // per kind is what makes this analysable, and a bundler then code-splits each decoder into
177
+ // its own chunk, still fetched only for an asset that declares the extension.
178
+ const mod = await spec.load();
163
179
  thing = mod[spec.exportName];
164
180
  if (!thing) throw new Error(`${spec.module} has no export "${spec.exportName}"`);
165
181
  }
@@ -253,3 +253,129 @@ export function splatPerfMeshOptions(perf) {
253
253
  if (!profile || !profile.lod) return {};
254
254
  return { lod: profile.lod === 'quality' ? 'quality' : true };
255
255
  }
256
+
257
+ // ── the same presets on the PlayCanvas engine (`addSplat(…, { engine: 'playcanvas' })`) ──────
258
+ //
259
+ // Same contract as the Spark half above: nothing moves unless asked, and every knob can be
260
+ // switched off. The presets are Spark-shaped (they were measured on Spark), so this maps each
261
+ // knob onto the engine's nearest equivalent rather than inventing engine presets:
262
+ //
263
+ // | Spark knob | engine (2.22.3) | note |
264
+ // |---|---|---|
265
+ // | `alphaRadius` | always on | the engine ALREADY shrinks every quad to its own alpha radius (`clipCorner` in `gsplatCommonVS`: `min(1, sqrt(ln(a / alphaClip)) / 2)` of the √8σ quad — the same cut `patchAlphaRadius` puts into Spark). It cannot be turned off, so `alphaRadius: false` is reported as `'native'`. |
266
+ // | `minAlpha` | `scene.gsplat.alphaClipForward` | engine default is already 1/255 |
267
+ // | `maxStdDev` | chunk override of `gsplatCommonVS` | the engine's quad is a fixed √8σ; the override caps `clipCorner`'s scale at `maxStdDev/√8`, which truncates both the quad AND the gaussian's uv, i.e. Spark's semantics. Only ever SHRINKS (≤ √8σ). |
268
+ // | `minPixelRadius` | `scene.gsplat.minPixelSize` = 2 × radius | the engine compares a quad DIAMETER in px (`max(l1,l2)`) |
269
+ // | `lod`, `lodSplat*`, `maxPixelRadius`, `falloff`, `alphaFloor` | none | Spark-only; ignored with one warning. The engine's splat-count lever is `splatBudget` (below) |
270
+ //
271
+ // Engine-native keys may be passed in an options object as well and win over the mapping:
272
+ // `alphaClipForward`, `minPixelSize`, `splatBudget` (a global splat count, per app = per tile),
273
+ // `antiAlias` (only for AA-trained assets).
274
+ //
275
+ // `minPixelSize` IS THE ONE DEFAULT CHANGED. The engine drops every splat whose quad is under
276
+ // 2 px by default; Spark keeps them (its `minPixelRadius` default is 0). On a lifted photograph
277
+ // the sub-2px grain is real texture, so every `perf` value except `false` starts from 0 — the
278
+ // Spark-parity baseline — and only a preset or option that states a size moves it. `perf: false`
279
+ // is the kill switch: the engine's own defaults, untouched, 2 px included.
280
+
281
+ /** The engine's quad half-extent, in σ: `l = 2·sqrt(2λ)` over cornerUV ∈ [-1,1] (gsplatCorner). */
282
+ export const PLAYCANVAS_QUAD_SIGMA = Math.sqrt(8);
283
+
284
+ /** Knobs that are engine-native and pass straight through to `app.scene.gsplat`. */
285
+ const PC_NATIVE = ['alphaClipForward', 'minPixelSize', 'splatBudget'];
286
+
287
+ /** Spark knobs with no engine equivalent — named once in a warning, then ignored. */
288
+ const PC_UNMAPPED = ['lod', 'lodSplatCount', 'lodSplatScale', 'lodRenderScale', 'maxPixelRadius', 'falloff', 'alphaFloor'];
289
+
290
+ /**
291
+ * Resolve `perf` into engine settings. Pure: no engine, no GPU — the adapter applies the result.
292
+ *
293
+ * @param {undefined|null|false|true|string|object} perf the `addSplat` option, as given.
294
+ * @returns {{settings:object, quadExtent:number|null, applied:object|null, ignored:string[]}}
295
+ * `settings` go onto `app.scene.gsplat`; `quadExtent` (a fraction of the √8σ quad, or
296
+ * null for untouched) goes into the `gsplatCommonVS` override; `applied` is what the
297
+ * handle reports (null for `perf: false`).
298
+ */
299
+ export function playcanvasPerfSettings(perf) {
300
+ const out = { settings: {}, quadExtent: null, applied: null, ignored: [] };
301
+ if (perf === false) return out; // kill switch: engine defaults, untouched
302
+
303
+ let profile = null;
304
+ let name = null;
305
+ if (perf === undefined || perf === null) name = 'default';
306
+ else if (perf === true) {
307
+ profile = SPLAT_PERF_PRESETS.balanced;
308
+ name = 'balanced';
309
+ } else if (typeof perf === 'string') {
310
+ profile = SPLAT_PERF_PRESETS[perf] || null;
311
+ name = profile ? perf : 'default';
312
+ if (!profile) {
313
+ console.warn(
314
+ `[inline3d/splat] unknown perf preset "${perf}" — ignored. ` +
315
+ `Known: ${Object.keys(SPLAT_PERF_PRESETS).join(', ')}, or an options object.`,
316
+ );
317
+ }
318
+ } else if (typeof perf === 'object') {
319
+ profile = perf;
320
+ name = 'custom';
321
+ }
322
+
323
+ const s = out.settings;
324
+ const applied = { preset: name };
325
+ // The Spark-parity baseline. See the block comment above.
326
+ s.minPixelSize = 0;
327
+ if (profile) {
328
+ if (isNum(profile.minAlpha)) s.alphaClipForward = profile.minAlpha;
329
+ if (isNum(profile.minPixelRadius)) s.minPixelSize = 2 * Math.max(0, profile.minPixelRadius);
330
+ if (isNum(profile.maxStdDev) && profile.maxStdDev > 0) {
331
+ const k = Math.min(1, profile.maxStdDev / PLAYCANVAS_QUAD_SIGMA);
332
+ if (k < 1) out.quadExtent = k;
333
+ }
334
+ if (profile.alphaRadius !== undefined) applied.alphaRadius = 'native';
335
+ for (const key of PC_NATIVE) if (isNum(profile[key])) s[key] = profile[key];
336
+ if (typeof profile.antiAlias === 'boolean') s.antiAlias = profile.antiAlias;
337
+ for (const key of PC_UNMAPPED) if (profile[key] !== undefined) out.ignored.push(key);
338
+ if (out.ignored.length) {
339
+ console.warn(
340
+ `[inline3d/splat] perf: ${out.ignored.join(', ')} ${out.ignored.length > 1 ? 'have' : 'has'} ` +
341
+ 'no PlayCanvas equivalent and ' +
342
+ `${out.ignored.length > 1 ? 'are' : 'is'} ignored on engine:'playcanvas' — the engine's ` +
343
+ 'splat-count lever is `splatBudget` (see js/inline3d-splat-perf.js).',
344
+ );
345
+ }
346
+ }
347
+ Object.assign(applied, s);
348
+ if (out.quadExtent !== null) applied.maxStdDev = out.quadExtent * PLAYCANVAS_QUAD_SIGMA;
349
+ out.applied = applied;
350
+ return out;
351
+ }
352
+
353
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
354
+
355
+ // The anchor the quad-extent override rewrites, from the engine's `gsplatCommonVS` (2.22.3). The
356
+ // npm build re-indents chunks with tabs and a later release may re-space them, so it is a
357
+ // whitespace-agnostic regex, not a string.
358
+ const PC_CLIP_ANCHOR = /float\s+clip\s*=\s*min\(\s*1\.0\s*,/;
359
+
360
+ /**
361
+ * Cap the engine's per-splat quad at `k` of its √8σ extent — Spark's `maxStdDev`, on PlayCanvas.
362
+ *
363
+ * Rewrites `clipCorner`'s `min(1.0, …)` to `min(k, …)`, which scales the corner offset AND the uv
364
+ * the fragment shader evaluates the gaussian at, so the tail is truncated rather than the
365
+ * profile squashed. Returns the source unchanged (and `ok:false`) when the anchor is missing;
366
+ * the caller warns once and renders at the engine's own extent.
367
+ *
368
+ * @param {string} src the current `gsplatCommonVS` chunk.
369
+ * @param {number} k in (0, 1).
370
+ * @returns {{src:string, ok:boolean}}
371
+ */
372
+ export function patchPlayCanvasQuadExtent(src, k) {
373
+ if (typeof src !== 'string' || !(k > 0 && k < 1)) return { src, ok: false };
374
+ if (src.includes('dxrQuadExtent')) return { src, ok: true }; // idempotent
375
+ if (!PC_CLIP_ANCHOR.test(src)) return { src, ok: false };
376
+ const lit = k.toFixed(7);
377
+ return {
378
+ src: src.replace(PC_CLIP_ANCHOR, `float clip = min(${lit} /* dxrQuadExtent */,`),
379
+ ok: true,
380
+ };
381
+ }