@displayxr/inline3d 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,63 @@ 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.9.1 — 2026-09-23
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), plus the package manifest.
11
+ Nothing changes for Spark callers or for splat-only pages. The fixes affect only what a page can
12
+ do with `handle.engine`.
13
+
14
+ ### Fixed
15
+
16
+ - **`handle.engine.root` is usable as documented.** The tile's `AppBase` now registers `Render`,
17
+ `Light` and `Anim` component systems and a `Container` resource handler, next to `Camera`,
18
+ `GSplat` and `Texture`/`GSplat`. That is exactly what a glTF needs, skinned and animated
19
+ included. Before, a page had to register them itself or `instantiateRenderEntity()` produced
20
+ nothing.
21
+ - A page that still registers them now gets the existing system back instead of an engine
22
+ "already registered" throw.
23
+ - Cost: +0.1 ms boot (`AppBase.init` 0.4 → 0.5 ms). +41.8 KB gzip only for a hand-tree-shaken
24
+ engine build; nothing for the SDK as shipped.
25
+ - Verified with a skinned, animated `.glb` over a splat: composited, depth-tested against nearer
26
+ splats, animation advancing.
27
+ - **`"./package.json"` is exported**, so `import pkg from '@displayxr/inline3d/package.json'` and
28
+ `require.resolve('@displayxr/inline3d/package.json')` work instead of
29
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`.
30
+
31
+ ### Added
32
+
33
+ - **`nearClip` / `farClip`** (PlayCanvas): a floor on the projection's near plane and a cap on its
34
+ far plane, for depth precision in a mixed mesh + splat scene. The adapter still owns the
35
+ projections, and unset leaves them untouched.
36
+ - Docs: `handle.engine` in [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md). Covers what
37
+ is registered, the cost, and two engine gotchas (lights shine along local −Y; use `AnimTrack.name`
38
+ with `assignAnimation`).
39
+
40
+ ## 1.9.0 — 2026-09-23
41
+
42
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: nothing changes for
43
+ Spark callers or for flat `.sog` files.
44
+
45
+ ### Added
46
+
47
+ - **Streamed SOG on the PlayCanvas backend** (epic #36 P2).
48
+ - Pass the URL of the `lod-meta.json`, or of the directory holding it. Bytes of a
49
+ `lod-meta.json`, or a streamed URL with `engine: 'spark'`, throw at call time with a message
50
+ saying what to pass instead.
51
+ - `splatBudget` is **per tile, all views included**: one engine camera renders every view, so
52
+ both eyes share one budget. It defaults to **600k** on a Streamed SOG when unset, and
53
+ `perf: false` keeps the engine's 1M.
54
+ - New `perf` keys pass through: `lodMode`, `lodUpdateDistance`, `lodUpdateAngle`,
55
+ `lodUnderfillLimit` (unset = engine default).
56
+ - New **`handle.stats()`**: `resident`, `peakResident`, `budget`, `numSplats`, `views`,
57
+ `lodLevels`, `files`, `filesLoaded`, `firstFrameMs`.
58
+ - Framed from its octree leaf boxes, not the root bound. The `camera` block is read from the
59
+ top level of `lod-meta.json`.
60
+ - Measured on a 5.88M-gaussian captured scene (M1 Pro): first frame 2.9 s against 20.8 s flat
61
+ at 100 Mbit/s, and 9.6 ms against 117 ms for two 1080p views. It is not a byte saving at a
62
+ close framing. Recipe and tables: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md)
63
+ §Streamed SOG.
64
+
8
65
  ## 1.8.0 — 2026-09-22
9
66
 
10
67
  Touches the **preview tier** (`./splat`, plus one additive `./three` export). **Existing callers get
package/README.md CHANGED
@@ -51,6 +51,15 @@ optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Stream
51
51
  Bundlers: the engine is a literal `import('playcanvas')`; its sort workers are Blob URLs (CSP
52
52
  `worker-src blob:`), and esbuild needs `node:worker_threads` marked external.
53
53
 
54
+ **Large scenes stream (preview).** On `engine: 'playcanvas'`, a Streamed SOG streams: pass the URL
55
+ of its `lod-meta.json` (or of its directory), not its bytes. The engine fetches only the chunks
56
+ the view needs and draws at most `perf.splatBudget` of them. That budget is per tile and shared
57
+ by every view of the tile; it defaults to 600k on a Streamed SOG. `handle.stats()` reports what
58
+ is resident. This is for big captured scenes, several million gaussians and up. A photo lift is
59
+ one dense sheet that is fully on screen, so it gains nothing from streaming. How to produce, serve
60
+ and budget one, with measurements:
61
+ [`docs/playcanvas-adapter.md` §Streamed SOG](docs/playcanvas-adapter.md#streamed-sog-p2).
62
+
54
63
  Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
55
64
  that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
56
65
 
@@ -269,8 +269,9 @@ export function splatPerfMeshOptions(perf) {
269
269
  // | `lod`, `lodSplat*`, `maxPixelRadius`, `falloff`, `alphaFloor` | none | Spark-only; ignored with one warning. The engine's splat-count lever is `splatBudget` (below) |
270
270
  //
271
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).
272
+ // `alphaClipForward`, `minPixelSize`, `splatBudget` (a splat count per tile, all views included —
273
+ // SPLAT_BUDGET_MODEL), `antiAlias` (only for AA-trained assets), and the Streamed-SOG knobs
274
+ // `lodMode`, `lodUpdateDistance`, `lodUpdateAngle`, `lodUnderfillLimit` (PC_LOD_NUMERIC below).
274
275
  //
275
276
  // `minPixelSize` IS THE ONE DEFAULT CHANGED. The engine drops every splat whose quad is under
276
277
  // 2 px by default; Spark keeps them (its `minPixelRadius` default is 0). On a lifted photograph
@@ -284,6 +285,100 @@ export const PLAYCANVAS_QUAD_SIGMA = Math.sqrt(8);
284
285
  /** Knobs that are engine-native and pass straight through to `app.scene.gsplat`. */
285
286
  const PC_NATIVE = ['alphaClipForward', 'minPixelSize', 'splatBudget'];
286
287
 
288
+ /**
289
+ * The Streamed-SOG (`lod-meta.json`) knobs, engine-native, passed straight through. Each one is
290
+ * its own kill switch: UNSET means the engine's default, never a value of ours. Numbers only,
291
+ * except `lodMode`, which the engine accepts as `'distance'` or `'error'` and silently ignores
292
+ * otherwise (so it is validated here, loudly).
293
+ *
294
+ * | key | engine default (2.22.3) | what it does |
295
+ * |---|---|---|
296
+ * | `lodMode` | `'distance'` | how a node's LOD is chosen: camera distance, or the per-chunk error table |
297
+ * | `lodUpdateDistance` | `1` (world units = the file's own units) | camera travel before LOD is re-evaluated |
298
+ * | `lodUpdateAngle` | `0` (off) | camera rotation, in degrees, before LOD is re-evaluated |
299
+ * | `lodUnderfillLimit` | `0` (off) | how many coarser levels may stand in while a finer one streams |
300
+ *
301
+ * None of them does anything on a flat `.sog`/`.ply` — there is no octree to choose in.
302
+ */
303
+ const PC_LOD_NUMERIC = ['lodUpdateDistance', 'lodUpdateAngle', 'lodUnderfillLimit'];
304
+ const PC_LOD_MODES = ['distance', 'error'];
305
+
306
+ /**
307
+ * The splat budget a Streamed SOG gets when the caller did not name one: 600k splats **per tile,
308
+ * all views included** (see SPLAT_BUDGET_MODEL). The engine's own default is 1M.
309
+ *
310
+ * Why 600k and not the engine's 1M: the budget is ONE count for every view of a tile, and a 3D
311
+ * tile draws that set TWICE (two eyes, two passes over the same work buffer). Measured on an M1
312
+ * Pro with no other GPU client, two views at 3840×1080 (docs/playcanvas-adapter.md §Streamed
313
+ * SOG): a 5.88M-gaussian captured castle ran 9.6 ms median / 12.9 ms p90 at 600k and
314
+ * 10.9 / 13.3 at 1M; Trogir (45.7M) 8.3 / 11.1 and 10.3 / 11.9. So on that GPU 1M fits a 60 Hz
315
+ * stereo frame too. 600k is chosen for HEADROOM, not because 1M failed: it keeps the M1 at ~60 %
316
+ * of a 16.7 ms frame for the weaker GPUs a 3D display ships with (Windows iGPUs, Android
317
+ * tablets — not measured yet). Only 600k and 1M were measured, so it is a safe default, not a
318
+ * tuned optimum. A caller-set `splatBudget` always wins, and `perf: false` leaves the engine's
319
+ * 1M untouched.
320
+ *
321
+ * The budget has a FLOOR the engine cannot go under: a chunk is never drawn coarser than its
322
+ * coarsest level, so the resident count bottoms out at the sum of every visible chunk's coarsest
323
+ * level (367k on the castle's 5-level pyramid, above a 300k budget).
324
+ */
325
+ export const STREAMED_SPLAT_BUDGET = 600000;
326
+
327
+ /**
328
+ * THE BUDGET MODEL, pinned (a test holds these values):
329
+ *
330
+ * - `scope: 'tile'` — every tile is its own `AppBase`, so its own scene, its own
331
+ * `scene.gsplat.splatBudget`, its own gsplat manager. Two tiles on a page = two budgets.
332
+ * - `views: 'shared'` — the engine keys its gsplat managers by CAMERA (`GSplatDirector.camerasMap`
333
+ * → one `GSplatManager` per camera × layer), and this adapter renders every view of a tile
334
+ * through ONE camera with N `RenderView`s. So one manager, one LOD pass, one budget, one work
335
+ * buffer for all N views — a 600k budget is 600k splats per tile whether the tile is mono or
336
+ * two-view. (Measured on the castle at 600k: 1 camera, 1 manager, 588,879 resident in mono
337
+ * and 590,331 with two views; docs/playcanvas-adapter.md §Streamed SOG.)
338
+ * - `fallbackDivides: true` — the N-camera fallback path (`playcanvasViewPath: 'cameras'`) makes
339
+ * N managers, each reading the SAME scene budget, which would be N× the budget per tile. The
340
+ * adapter divides the budget by N on that path to keep the per-tile contract — subject to the
341
+ * per-manager coarsest-level floor above (measured: 2 managers × 300k on the castle resident
342
+ * 734,764 = 2 × its 367,382 coarsest level).
343
+ * - LOD distance is measured from the camera NODE, which sits on the first view's eye; every
344
+ * view uses the LOD it picked (one pass). The eyes are ~63 mm apart, so the difference is
345
+ * below `lodUpdateDistance` by construction.
346
+ */
347
+ export const SPLAT_BUDGET_MODEL = Object.freeze({
348
+ scope: 'tile',
349
+ views: 'shared',
350
+ fallbackDivides: true,
351
+ lodCamera: 'first-view',
352
+ });
353
+
354
+ /**
355
+ * The scene-level budget one engine manager must be given so that a TILE spends `tileBudget`.
356
+ * RenderView path: one manager for all views, so the tile budget as is. N-camera fallback: one
357
+ * manager per view, so the tile budget split N ways (floored, never below 1).
358
+ */
359
+ export function budgetPerManager(tileBudget, viewPath, views) {
360
+ if (!(tileBudget > 0)) return tileBudget;
361
+ if (viewPath !== 'cameras') return tileBudget;
362
+ const n = Math.max(1, Math.floor(views) || 1);
363
+ return Math.max(1, Math.floor(tileBudget / n));
364
+ }
365
+
366
+ /**
367
+ * The tile's budget for a resolved perf profile on a source of this kind: the caller's
368
+ * `splatBudget` if set, else STREAMED_SPLAT_BUDGET on a Streamed SOG, else unset (engine default).
369
+ * `perf: false` resolves to `settings: {}`, which is why it never gets the streamed default.
370
+ *
371
+ * @param {{settings:object, applied:object|null}} resolved playcanvasPerfSettings()'s result.
372
+ * @param {'flat'|'streamed'|null} kind
373
+ * @returns {number|undefined}
374
+ */
375
+ export function tileSplatBudget(resolved, kind) {
376
+ const s = resolved?.settings || {};
377
+ if (isNum(s.splatBudget)) return s.splatBudget;
378
+ if (kind === 'streamed' && resolved?.applied) return STREAMED_SPLAT_BUDGET;
379
+ return undefined;
380
+ }
381
+
287
382
  /** Spark knobs with no engine equivalent — named once in a warning, then ignored. */
288
383
  const PC_UNMAPPED = ['lod', 'lodSplatCount', 'lodSplatScale', 'lodRenderScale', 'maxPixelRadius', 'falloff', 'alphaFloor'];
289
384
 
@@ -333,6 +428,16 @@ export function playcanvasPerfSettings(perf) {
333
428
  }
334
429
  if (profile.alphaRadius !== undefined) applied.alphaRadius = 'native';
335
430
  for (const key of PC_NATIVE) if (isNum(profile[key])) s[key] = profile[key];
431
+ for (const key of PC_LOD_NUMERIC) if (isNum(profile[key]) && profile[key] >= 0) s[key] = profile[key];
432
+ if (profile.lodMode !== undefined) {
433
+ if (PC_LOD_MODES.includes(profile.lodMode)) s.lodMode = profile.lodMode;
434
+ else {
435
+ console.warn(
436
+ `[inline3d/splat] perf.lodMode "${profile.lodMode}" is not one of ${PC_LOD_MODES.join(', ')} — ` +
437
+ "ignored (the engine's default, 'distance', stays).",
438
+ );
439
+ }
440
+ }
336
441
  if (typeof profile.antiAlias === 'boolean') s.antiAlias = profile.antiAlias;
337
442
  for (const key of PC_UNMAPPED) if (profile[key] !== undefined) out.ignored.push(key);
338
443
  if (out.ignored.length) {
@@ -43,7 +43,12 @@ import {
43
43
  FRAME_SAMPLE_CAP,
44
44
  } from './inline3d-splat-rig.js';
45
45
  import { readSogMeta, sogCameraFromMeta } from './inline3d-sog.js';
46
- import { playcanvasPerfSettings, patchPlayCanvasQuadExtent } from './inline3d-splat-perf.js';
46
+ import {
47
+ playcanvasPerfSettings,
48
+ patchPlayCanvasQuadExtent,
49
+ tileSplatBudget,
50
+ budgetPerManager,
51
+ } from './inline3d-splat-perf.js';
47
52
  import { boundsFromPositions } from './inline3d-viewer.js';
48
53
  import { cameraRigFromPose } from './inline3d-three.js';
49
54
  import {
@@ -76,8 +81,57 @@ import {
76
81
  captureVerticalFovDeg,
77
82
  engineFormatFor,
78
83
  pathOf,
84
+ streamedBytesError,
79
85
  } from './inline3d-splat-shared.js';
80
86
 
87
+ /**
88
+ * The component systems the tile's `AppBase` registers. Camera + GSplat draw the splat; Render,
89
+ * Light and Anim are what a page needs to put a glTF — skinned and animated included — and its
90
+ * lights under `handle.engine.root` (1.9.1; before that the page had to register them itself).
91
+ * Nothing else from the engine's full `Application` list (physics, UI, audio, particles, scripts
92
+ * and so on are the page's own business if it wants them).
93
+ */
94
+ export const PLAYCANVAS_SYSTEMS = Object.freeze([
95
+ 'CameraComponentSystem',
96
+ 'GSplatComponentSystem',
97
+ 'RenderComponentSystem',
98
+ 'LightComponentSystem',
99
+ 'AnimComponentSystem',
100
+ ]);
101
+
102
+ /**
103
+ * The resource handlers the tile's loader registers. Texture + GSplat load a splat (a bundled
104
+ * .sog is a zip of webp planes the loader registers as textures); Container loads a .glb/.gltf.
105
+ * The container's sub-assets (render, material, animation) arrive already loaded, so they need no
106
+ * handler of their own.
107
+ */
108
+ export const PLAYCANVAS_HANDLERS = Object.freeze(['TextureHandler', 'GSplatHandler', 'ContainerHandler']);
109
+
110
+ /**
111
+ * Registering a component system the app already has THROWS in the engine ("already
112
+ * registered"). Pages written before 1.9.1 add Render/Light/Anim themselves, so a second `add` of
113
+ * an id that exists is made a no-op that returns the registered system (the duplicate the page
114
+ * constructed is destroyed, so it leaves no listeners behind).
115
+ */
116
+ function guardDuplicateSystems(app) {
117
+ const reg = app.systems;
118
+ if (!reg || typeof reg.add !== 'function' || reg._dxrGuarded) return;
119
+ const add = reg.add.bind(reg);
120
+ reg.add = (system) => {
121
+ const existing = system?.id ? reg[system.id] : null;
122
+ if (existing && existing !== system) {
123
+ try {
124
+ system.destroy?.();
125
+ } catch {
126
+ /* a half-built duplicate: nothing to release */
127
+ }
128
+ return existing;
129
+ }
130
+ return add(system);
131
+ };
132
+ reg._dxrGuarded = true;
133
+ }
134
+
81
135
  /** The engine release this adapter was built and measured against (npm peer floor). */
82
136
  export const PLAYCANVAS_TESTED = '2.22.3';
83
137
 
@@ -132,6 +186,24 @@ export function frustumFromProjection(P) {
132
186
  };
133
187
  }
134
188
 
189
+ /**
190
+ * Raise a perspective projection's near plane to at least `nearFloor` and lower its far plane to
191
+ * at most `farCap`, IN PLACE, leaving the frustum's shape (fov, skew, principal point) untouched —
192
+ * only the depth mapping (P[10], P[14]) is rewritten. Idempotent. A null bound is left alone.
193
+ */
194
+ export function clampProjectionDepth(P, nearFloor, farCap) {
195
+ const f0 = frustumFromProjection(P);
196
+ let n = f0.nearClip;
197
+ let f = f0.farClip;
198
+ if (nearFloor !== null && nearFloor !== undefined && nearFloor > n) n = nearFloor;
199
+ if (farCap !== null && farCap !== undefined && farCap < f) f = farCap;
200
+ if (!(f > n)) f = n * 1.0001 + 1e-6;
201
+ if (n === f0.nearClip && f === f0.farClip) return P;
202
+ P[10] = -(f + n) / (f - n);
203
+ P[14] = (-2 * f * n) / (f - n);
204
+ return P;
205
+ }
206
+
135
207
  /** Rigid pose (position + unit quaternion xyzw) as a column-major 4×4. */
136
208
  export function poseMatrix(p, q, out = new Float64Array(16)) {
137
209
  const [x, y, z, w] = q;
@@ -385,7 +457,20 @@ export function pickViewPath(pc, forced) {
385
457
 
386
458
  // Source routing lives in ./inline3d-splat-shared.js (./splat also needs it, synchronously, to
387
459
  // refuse a format this engine cannot read at call time); re-exported for the tests.
388
- export { engineFormatFor } from './inline3d-splat-shared.js';
460
+ export { engineFormatFor, isStreamedUrl, streamedBytesError } from './inline3d-splat-shared.js';
461
+
462
+ /**
463
+ * The URL the engine loads for a streamed source: a directory URL gets `lod-meta.json` appended
464
+ * (before any query/hash, which are kept); a `lod-meta.json` URL is returned as is. The engine
465
+ * resolves every chunk relative to this URL's directory, so it must name the file itself.
466
+ */
467
+ export function streamedEntryUrl(src) {
468
+ if (typeof src !== 'string') return src;
469
+ const m = /^([^?#]*)(.*)$/.exec(src);
470
+ const path = m[1];
471
+ const tail = m[2];
472
+ return path.endsWith('/') ? `${path}lod-meta.json${tail}` : src;
473
+ }
389
474
 
390
475
  /**
391
476
  * The pick fallback on flat arrays: the gaussian whose CENTRE is nearest the ray — by angle,
@@ -448,8 +533,15 @@ export class PlayCanvasSplatViewer {
448
533
  orbitEase = {},
449
534
  feather = 0,
450
535
  captureFit = 'height',
536
+ nearClip,
537
+ farClip,
451
538
  } = opts;
452
539
  this.canvas = canvas;
540
+ // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
541
+ // other; splats only test against them). The projections' own near/far stay the adapter's —
542
+ // these only raise the near (floor) and lower the far (cap). Unset: untouched.
543
+ this.nearClip = Number.isFinite(nearClip) && nearClip > 0 ? nearClip : null;
544
+ this.farClip = Number.isFinite(farClip) && farClip > 0 ? farClip : null;
453
545
  // The tilt-and-relax orbit (./inline3d-splat-shared.js §ORBIT): drag tilts up to ±orbitMaxDeg
454
546
  // from where the press started, easing with τ = orbitEase.drag; release relaxes back with
455
547
  // τ = orbitEase.rest. `_orbitMode` is 'drag' | 'rest' | null (null = ordinary damping).
@@ -521,6 +613,16 @@ export class PlayCanvasSplatViewer {
521
613
  this._viewPath = null;
522
614
  this._frustumKey = '';
523
615
 
616
+ // Per-frame splat accounting (handle.stats()). `resident` is what the engine put in the
617
+ // tile's work buffer on the LAST tick — the budget-enforced, LOD-selected set every view of
618
+ // the tile draws from (before per-view frustum culling). `firstFrameAt` is performance.now()
619
+ // (ms since navigation start) of the first tick that drew a non-empty set.
620
+ this.resident = 0;
621
+ this.peakResident = 0;
622
+ this.firstFrameAt = null;
623
+ this._tileBudget = undefined; // undefined = the engine's own default
624
+ this._budgetViews = 1;
625
+
524
626
  this._onResize = () => this._scheduleResize();
525
627
  this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
526
628
  if (this._ro) this._ro.observe(canvas);
@@ -755,12 +857,14 @@ export class PlayCanvasSplatViewer {
755
857
  const opts = new pc.AppOptions();
756
858
  opts.graphicsDevice = device;
757
859
  // No xr (AppBase constructs XrManager only when asked, and XrManager is what probes and
758
- // can request immersive sessions), no mouse/keyboard/touch: the SDK owns input.
759
- opts.componentSystems = [pc.CameraComponentSystem, pc.GSplatComponentSystem];
760
- // TextureHandler: a bundled .sog is a zip of webp planes the loader registers as textures.
761
- opts.resourceHandlers = [pc.TextureHandler, pc.GSplatHandler];
860
+ // can request immersive sessions), no mouse/keyboard/touch: the SDK owns input. Beyond the
861
+ // splat itself, exactly what a glTF-with-animation under `handle.engine.root` needs — see
862
+ // PLAYCANVAS_SYSTEMS / PLAYCANVAS_HANDLERS.
863
+ opts.componentSystems = PLAYCANVAS_SYSTEMS.map((n) => pc[n]).filter(Boolean);
864
+ opts.resourceHandlers = PLAYCANVAS_HANDLERS.map((n) => pc[n]).filter(Boolean);
762
865
  const app = new pc.AppBase(this.canvas);
763
866
  app.init(opts);
867
+ guardDuplicateSystems(app);
764
868
  // The SDK sizes the buffer (double-width in 3D, 1:1 in mono), so the engine must never
765
869
  // resize it. RESOLUTION_FIXED is AppBase's DEFAULT, and with it `updateCanvasSize()` is a
766
870
  // no-op. Deliberately NOT calling setCanvasResolution/setCanvasFillMode: without explicit
@@ -810,7 +914,12 @@ export class PlayCanvasSplatViewer {
810
914
  );
811
915
  }
812
916
  }
813
- for (const [k, v] of Object.entries(perf?.settings || {})) app.scene.gsplat[k] = v;
917
+ for (const [k, v] of Object.entries(perf?.settings || {})) {
918
+ if (k !== 'splatBudget') app.scene.gsplat[k] = v;
919
+ }
920
+ // The budget goes through setTileBudget: it is a PER-TILE contract, and the N-camera
921
+ // fallback has to split it (SPLAT_BUDGET_MODEL).
922
+ this.setTileBudget(perf?.settings?.splatBudget);
814
923
 
815
924
  // EVERY Entity gets its app EXPLICITLY. The constructor's default is the engine's global
816
925
  // "current app" (`getApplication()`), which is whichever AppBase last init'ed or ticked — so
@@ -895,6 +1004,35 @@ export class PlayCanvasSplatViewer {
895
1004
  this._feather.mat.setParameter('dxrFeatherFy', Math.min(0.5, this.featherPx / Math.max(1, h)));
896
1005
  }
897
1006
 
1007
+ /**
1008
+ * The tile's splat budget, all views included (SPLAT_BUDGET_MODEL). `undefined` leaves the
1009
+ * engine's own default alone. On the RenderView path it is the scene budget as is (one manager
1010
+ * for all views); on the N-camera fallback it is split across the N per-camera managers.
1011
+ */
1012
+ setTileBudget(budget) {
1013
+ this._tileBudget = Number.isFinite(budget) && budget > 0 ? Math.floor(budget) : undefined;
1014
+ this._applyBudget();
1015
+ }
1016
+
1017
+ get tileBudget() {
1018
+ if (this._tileBudget !== undefined) return this._tileBudget;
1019
+ return this.app ? this.app.scene.gsplat.splatBudget : undefined;
1020
+ }
1021
+
1022
+ _applyBudget() {
1023
+ if (!this.app || this._tileBudget === undefined) return;
1024
+ const per = budgetPerManager(this._tileBudget, this._viewPath, this._budgetViews);
1025
+ if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
1026
+ }
1027
+
1028
+ /** Read back what the tick just did. Cheap: two numbers off the renderer. */
1029
+ _afterTick() {
1030
+ const n = this.app?.renderer?._gsplatCount ?? 0;
1031
+ this.resident = n;
1032
+ if (n > this.peakResident) this.peakResident = n;
1033
+ if (n > 0 && this.firstFrameAt === null) this.firstFrameAt = now();
1034
+ }
1035
+
898
1036
  /** A camera entity under the rig node. Tonemapping OFF: splat colours are already display-referred. */
899
1037
  _makeCamera(name, rect) {
900
1038
  const pc = this.pc;
@@ -984,6 +1122,9 @@ export class PlayCanvasSplatViewer {
984
1122
  const el = this.canvas;
985
1123
  const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
986
1124
  const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1125
+ if (this.nearClip !== null || this.farClip !== null) {
1126
+ for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
1127
+ }
987
1128
  const rect = (e) =>
988
1129
  sx !== 1 || sy !== 1
989
1130
  ? [Math.round(e.x * sx), Math.round(e.y * sy), Math.max(1, Math.round(e.width * sx)), Math.max(1, Math.round(e.height * sy))]
@@ -1025,6 +1166,11 @@ export class PlayCanvasSplatViewer {
1025
1166
  cams.push(cam);
1026
1167
  }
1027
1168
  for (let i = 0; i < cams.length; i++) cams[i].enabled = i < entries.length;
1169
+ if (this._budgetViews !== entries.length) {
1170
+ // One manager per enabled camera here, each reading the scene budget: split it.
1171
+ this._budgetViews = entries.length;
1172
+ this._applyBudget();
1173
+ }
1028
1174
  const W = el.width || 1;
1029
1175
  const H = el.height || 1;
1030
1176
  for (let i = 0; i < entries.length; i++) {
@@ -1042,6 +1188,7 @@ export class PlayCanvasSplatViewer {
1042
1188
  }
1043
1189
  this._updateFeather(entries[0].width * sx, entries[0].height * sy);
1044
1190
  app.tick(now());
1191
+ this._afterTick();
1045
1192
  return true;
1046
1193
  }
1047
1194
 
@@ -1361,18 +1508,86 @@ export async function readCloud(resource) {
1361
1508
  return { xyz, opacity, alpha8, total, stride, sourceTotal };
1362
1509
  }
1363
1510
 
1511
+ /**
1512
+ * A point sample standing in for a Streamed SOG's cloud, from its octree alone: every leaf node
1513
+ * contributes points in proportion to its finest-level splat count, spread deterministically
1514
+ * through its box. That is enough for the SAME percentile framing a flat source gets
1515
+ * (boundsFromPositions), which the octree's root bound cannot give — the root is the raw
1516
+ * container of every chunk, sky shells and floaters included (a captured castle: 391×821×390 m
1517
+ * root against 65×25×76 m measured on the flat file; a museum room: ±240 m around a 2 m statue).
1518
+ *
1519
+ * Coarse by construction (a node is a box, not its splats), so framing lands within a node size
1520
+ * of the flat measurement, not on it. Nothing is downloaded: lod-meta.json already lists every
1521
+ * node's box and count.
1522
+ *
1523
+ * @param {Array<{min:number[], max:number[], count:number}>} nodes
1524
+ * @param {number} [cap] points to emit in total (default 20k).
1525
+ * @returns {Float32Array|null} xyz, model space.
1526
+ */
1527
+ export function octreeSample(nodes, cap = 20000) {
1528
+ if (!Array.isArray(nodes) || !nodes.length) return null;
1529
+ let total = 0;
1530
+ for (const n of nodes) total += n.count > 0 ? n.count : 0;
1531
+ if (!(total > 0)) return null;
1532
+ const pts = [];
1533
+ let seed = 0x9e3779b9;
1534
+ const rnd = () => {
1535
+ // xorshift32: deterministic, so a test (and a reload) frames the same way every time.
1536
+ seed ^= seed << 13; seed >>>= 0;
1537
+ seed ^= seed >>> 17;
1538
+ seed ^= seed << 5; seed >>>= 0;
1539
+ return seed / 4294967296;
1540
+ };
1541
+ let carry = 0;
1542
+ for (const n of nodes) {
1543
+ if (!(n.count > 0)) continue;
1544
+ carry += (n.count / total) * cap;
1545
+ const k = Math.floor(carry);
1546
+ carry -= k;
1547
+ for (let i = 0; i < k; i++) {
1548
+ pts.push(
1549
+ n.min[0] + (n.max[0] - n.min[0]) * rnd(),
1550
+ n.min[1] + (n.max[1] - n.min[1]) * rnd(),
1551
+ n.min[2] + (n.max[2] - n.min[2]) * rnd(),
1552
+ );
1553
+ }
1554
+ }
1555
+ return pts.length ? Float32Array.from(pts) : null;
1556
+ }
1557
+
1558
+ /** The engine octree's leaf nodes as octreeSample() input. */
1559
+ function octreeNodes(res) {
1560
+ const nodes = res?.octree?.nodes;
1561
+ if (!Array.isArray(nodes)) return [];
1562
+ const out = [];
1563
+ for (const n of nodes) {
1564
+ const b = n?.bounds;
1565
+ const c = n?.lods?.[0]?.count ?? 0;
1566
+ if (!b || !b.center || !b.halfExtents || !(c > 0)) continue;
1567
+ out.push({
1568
+ min: [b.center.x - b.halfExtents.x, b.center.y - b.halfExtents.y, b.center.z - b.halfExtents.z],
1569
+ max: [b.center.x + b.halfExtents.x, b.center.y + b.halfExtents.y, b.center.z + b.halfExtents.z],
1570
+ count: c,
1571
+ });
1572
+ }
1573
+ return out;
1574
+ }
1575
+
1364
1576
  /**
1365
1577
  * What kind of resource the engine handed back, and what can be known about it without a cloud.
1366
1578
  *
1367
1579
  * A Streamed SOG (`lod-meta.json`) loads as a `GSplatOctreeResource`: no `centers`, no
1368
- * `gsplatData`, splats arriving by LOD as the camera asks. Its framing falls back to the octree's
1369
- * root bound (`resource.aabb`, the lod-meta `tree.bound` the engine nulls `data.tree` after
1370
- * reading it), its camera block to the top level of lod-meta.json, and its count to the octree's
1371
- * finest level (`numSplats`; lod-meta's `count` sums every level). Full streaming behaviour
1372
- * (budget per tile, pick on resident LODs) is P2.
1580
+ * `gsplatData`, splats arriving by LOD as the camera asks. Its framing comes from the octree's
1581
+ * leaf boxes (octreeSample the same percentile bounds a flat cloud gets), else from the root
1582
+ * bound (`resource.aabb`, the lod-meta `tree.bound` the engine nulls `data.tree` after
1583
+ * reading it); its camera block from the top level of lod-meta.json; its count from the octree's
1584
+ * finest level (`numSplats`; lod-meta's `count` sums every level). What is on screen in a given
1585
+ * frame is `handle.stats().resident`, capped by the tile budget (SPLAT_BUDGET_MODEL); `pick`
1586
+ * searches the chunks currently resident.
1373
1587
  *
1374
1588
  * @returns {{kind:'flat'|'streamed'|null, numSplats:number, meta:object|null,
1375
- * bounds:{center:number[],extent:number[]}|null}} bounds in MODEL space.
1589
+ * bounds:{center:number[],extent:number[]}|null, boundsSource?:string,
1590
+ * rootBounds?:object|null}} bounds in MODEL space.
1376
1591
  */
1377
1592
  export function describeResource(res) {
1378
1593
  if (!res) return { kind: null, numSplats: 0, meta: null, bounds: null };
@@ -1391,7 +1606,16 @@ export function describeResource(res) {
1391
1606
  } catch {
1392
1607
  n = 0;
1393
1608
  }
1394
- return { kind: 'streamed', numSplats: n || res.data?.count || 0, meta: res.data || null, bounds };
1609
+ const sample = octreeSample(octreeNodes(res));
1610
+ const sampled = sample ? boundsFromPositions(sample) : null;
1611
+ return {
1612
+ kind: 'streamed',
1613
+ numSplats: n || res.data?.count || 0,
1614
+ meta: res.data || null,
1615
+ bounds: sampled || bounds,
1616
+ boundsSource: sampled ? 'octree-sample' : bounds ? 'octree-root' : null,
1617
+ rootBounds: bounds,
1618
+ };
1395
1619
  }
1396
1620
  return { kind: 'flat', numSplats: res.gsplatData?.numSplats ?? 0, meta: res.gsplatData?.meta || null, bounds: null };
1397
1621
  }
@@ -1481,6 +1705,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1481
1705
  orbitEase: opts.orbitEase,
1482
1706
  feather,
1483
1707
  captureFit,
1708
+ nearClip: opts.nearClip,
1709
+ farClip: opts.farClip,
1484
1710
  });
1485
1711
 
1486
1712
  let handle = null;
@@ -1528,6 +1754,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1528
1754
  },
1529
1755
  exclude: (el) => handle?.exclude(el),
1530
1756
  unexclude: (el) => handle?.unexclude(el),
1757
+ stats: () => splatStats(),
1531
1758
  });
1532
1759
  // `onFocusChange` is a plain data property of the handle, read at CALL time — so a callback
1533
1760
  // assigned before this module loaded (on the stub ./splat returned) is the one that fires.
@@ -1598,6 +1825,25 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1598
1825
  // captureFit 'cover' re-crops on resize, which changes the rig's vertical FOV: re-declare it.
1599
1826
  viewer.onCaptureFov = () => pushViewRig(true);
1600
1827
 
1828
+ // ── stats ──
1829
+ function splatStats() {
1830
+ const c = current;
1831
+ const oct = c?.kind === 'streamed' ? c.res?.octree : null;
1832
+ const budget = viewer.tileBudget;
1833
+ return {
1834
+ kind: c?.kind ?? null,
1835
+ resident: viewer.resident,
1836
+ peakResident: viewer.peakResident,
1837
+ budget: Number.isFinite(budget) ? budget : null,
1838
+ numSplats: out.mesh?.numSplats ?? 0,
1839
+ views: viewer._lastGood && viewer.is3D ? viewer._lastGood.entries.length : 1,
1840
+ lodLevels: oct ? oct.lodLevels : null,
1841
+ files: oct ? oct.files.length : null,
1842
+ filesLoaded: oct ? oct.fileResources.size : null,
1843
+ firstFrameMs: viewer.firstFrameAt,
1844
+ };
1845
+ }
1846
+
1601
1847
  // ── pick ──
1602
1848
  /** The ray under a client point, in MODEL space (through the inverse pivot and the flip). */
1603
1849
  function modelRay(clientX, clientY) {
@@ -1678,6 +1924,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1678
1924
  const buf = source instanceof Blob ? await source.arrayBuffer() : source;
1679
1925
  bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
1680
1926
  }
1927
+ if (bytes) {
1928
+ // Also caught synchronously by ./splat for Uint8Array/ArrayBuffer; a Blob, and setSource,
1929
+ // only arrive here.
1930
+ const streamedErr = streamedBytesError(bytes, opts.fileName);
1931
+ if (streamedErr) throw new Error(`[inline3d/splat] ${streamedErr}`);
1932
+ }
1681
1933
  const fmt = engineFormatFor(source, bytes, opts.fileName, opts.fileType);
1682
1934
  if (!fmt) {
1683
1935
  throw new Error(
@@ -1689,10 +1941,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1689
1941
  let camera = null;
1690
1942
  if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
1691
1943
 
1692
- const url = bytes ? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}` : source;
1944
+ const url = bytes
1945
+ ? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}`
1946
+ : fmt.streamed
1947
+ ? streamedEntryUrl(source)
1948
+ : source;
1693
1949
  const file = bytes
1694
1950
  ? { url, filename: url, contents: new Response(bytes) }
1695
- : { url, filename: pathOf(source).split('/').pop() || url };
1951
+ : { url, filename: pathOf(url).split('/').pop() || url };
1696
1952
  const asset = new pc.Asset(url, 'gsplat', file);
1697
1953
  app.assets.add(asset);
1698
1954
  await new Promise((resolve, reject) => {
@@ -1718,9 +1974,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1718
1974
  const walk = cloud ? centresVisitor(cloud.xyz, cloud.opacity, cloud.total) : null;
1719
1975
  const local = walk ? boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []) : null;
1720
1976
  const lift = (b) => ({ center: modelToContent(b.center), extent: b.extent.slice(0, 3) });
1721
- // Measured first; a Streamed SOG's octree bound next (raw min/max); the caller's `frame`
1722
- // last the Spark path's order (a supplied frame is only a fallback there too).
1723
- const bounds = local ? lift(local) : desc.bounds ? lift(desc.bounds) : frame ? lift(frame) : null;
1977
+ // Measured first the Spark path's order (a supplied frame is only a fallback there too).
1978
+ // A Streamed SOG has no cloud: there a caller's `frame` beats the octree-derived bounds
1979
+ // (describeResource: a count-weighted sample of the leaf boxes, else the raw root bound),
1980
+ // because both are coarser than a real measurement.
1981
+ const bounds = local
1982
+ ? lift(local)
1983
+ : desc.kind === 'streamed' && frame
1984
+ ? lift(frame)
1985
+ : desc.bounds
1986
+ ? lift(desc.bounds)
1987
+ : frame
1988
+ ? lift(frame)
1989
+ : null;
1724
1990
 
1725
1991
  const sample =
1726
1992
  !rigNeedsCloud(loaded.camera)
@@ -1740,6 +2006,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1740
2006
  out.camera = loaded.camera;
1741
2007
  out.rig = resolved;
1742
2008
  out.frame = bounds;
2009
+ // The tile budget, now that the kind is known: the caller's, else the streamed default on a
2010
+ // Streamed SOG, else the engine's (STREAMED_SPLAT_BUDGET, SPLAT_BUDGET_MODEL).
2011
+ const budget = tileSplatBudget(perfResolved, desc.kind);
2012
+ viewer.setTileBudget(budget);
2013
+ if (perfResolved.applied) {
2014
+ out.perf = budget !== undefined ? { ...perfResolved.applied, splatBudget: budget } : perfResolved.applied;
2015
+ }
1743
2016
 
1744
2017
  // The strided pick fallback (used only if the engine releases its full centre set).
1745
2018
  let pickCentres = null;
@@ -175,12 +175,57 @@ export function engineFormatFor(src, bytes, fileName, fileType) {
175
175
  const e = extOf(fileName);
176
176
  return e === 'sog' || e === 'ply' ? { ext: e, streamed: false } : null;
177
177
  }
178
+ if (isStreamedUrl(src)) return { ext: 'json', streamed: true };
178
179
  const e = extOf(src);
179
180
  if (e === 'sog' || e === 'ply') return { ext: e, streamed: false };
180
- if (e === 'json') return { ext: 'json', streamed: /lod-meta\.json$/i.test(pathOf(src)) };
181
+ if (e === 'json') return { ext: 'json', streamed: false };
181
182
  return null;
182
183
  }
183
184
 
185
+ /**
186
+ * Is this URL a Streamed SOG? Either its `lod-meta.json` or the DIRECTORY that holds one (a path
187
+ * ending in `/`), with or without a query or hash. Here, not in the adapter, because both engines
188
+ * need the answer synchronously: the PlayCanvas backend streams it, and the Spark path must refuse
189
+ * it by name (Spark has no reader for it).
190
+ */
191
+ export function isStreamedUrl(src) {
192
+ if (typeof src !== 'string') return false;
193
+ const p = pathOf(src);
194
+ return /(^|\/)lod-meta\.json$/i.test(p) || (p.length > 0 && p.endsWith('/'));
195
+ }
196
+
197
+ /** The error a Spark page gets for a Streamed SOG URL. */
198
+ export const STREAMED_NEEDS_PLAYCANVAS =
199
+ "a Streamed SOG (lod-meta.json) is read only by engine:'playcanvas' (the default); Spark has no " +
200
+ 'reader for it. Drop engine:\'spark\', or load a flat .sog.';
201
+
202
+ /**
203
+ * A Streamed SOG handed over as BYTES — refuse it, with the reason. A Streamed SOG is a
204
+ * directory: `lod-meta.json` names hundreds of chunk files by RELATIVE path, and bytes carry no
205
+ * base URL to resolve them against. Detected by the file name the caller gave (`fileName`), or by
206
+ * sniffing a JSON object that has the lod-meta keys (`lodLevels` + `filenames`) in its first 4 KB.
207
+ *
208
+ * @returns {string|null} the error message, or null when the bytes are not a lod-meta.
209
+ */
210
+ export function streamedBytesError(bytes, fileName) {
211
+ let hit = typeof fileName === 'string' && /(^|\/)lod-meta\.json$/i.test(pathOf(fileName));
212
+ if (!hit && bytes && bytes.length) {
213
+ let i = 0;
214
+ while (i < bytes.length && i < 64 && (bytes[i] === 0x20 || bytes[i] === 0x0a || bytes[i] === 0x0d || bytes[i] === 0x09 || bytes[i] === 0xef || bytes[i] === 0xbb || bytes[i] === 0xbf)) i++;
215
+ if (bytes[i] === 0x7b) {
216
+ const head = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 4096)));
217
+ hit = /"lodLevels"\s*:/.test(head) && /"filenames"\s*:/.test(head);
218
+ }
219
+ }
220
+ if (!hit) return null;
221
+ return (
222
+ 'a Streamed SOG (lod-meta.json) cannot be passed as bytes: it is a directory of chunk files ' +
223
+ 'that lod-meta.json names by relative path, and bytes have no base URL to resolve them ' +
224
+ "against. Pass its URL instead — addSplat(wall, canvas, 'https://…/scene/lod-meta.json') " +
225
+ "(a URL ending in '/' works too)."
226
+ );
227
+ }
228
+
184
229
  // ── ORBIT: the PlayCanvas backend's built-in drag (tilt-and-relax) ──────────────────────────
185
230
  //
186
231
  // SceneViewer (the Spark path) still turns the subject cumulatively (DRAG_DEG_PER_TILE); switching
@@ -257,7 +302,11 @@ export function captureVerticalFovDeg(K, aspect, near, captureFit = 'height') {
257
302
  export function playcanvasCannotRead(src, { fileType, fileName } = {}) {
258
303
  let bytes = null;
259
304
  if (src instanceof Uint8Array) bytes = src;
260
- else if (src instanceof ArrayBuffer) bytes = new Uint8Array(src, 0, Math.min(8, src.byteLength));
305
+ else if (src instanceof ArrayBuffer) bytes = new Uint8Array(src, 0, Math.min(4096, src.byteLength));
306
+ if (bytes) {
307
+ const streamed = streamedBytesError(bytes, fileName);
308
+ if (streamed) return streamed;
309
+ }
261
310
  if (typeof src !== 'string' && !bytes && fileType === undefined) return null; // a Blob: known at load
262
311
  if (typeof src === 'string' && fileType === undefined && !extOf(src)) return null;
263
312
  if (engineFormatFor(typeof src === 'string' ? src : null, bytes, fileName, fileType)) return null;
@@ -32,6 +32,8 @@ import {
32
32
  captureVerticalFovDeg,
33
33
  CAPTURE_FITS,
34
34
  playcanvasCannotRead,
35
+ isStreamedUrl,
36
+ STREAMED_NEEDS_PLAYCANVAS,
35
37
  } from './inline3d-splat-shared.js';
36
38
  import {
37
39
  resolveRig,
@@ -172,6 +174,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
172
174
  return addSplatDeferred(wall, canvas, src, opts);
173
175
  }
174
176
 
177
+ // A Streamed SOG on Spark is a page bug (Spark has no lod-meta.json reader): say so now, by
178
+ // name, rather than let Spark fail on an "unknown file type" that reads like a corrupt asset.
179
+ if (isStreamedUrl(src)) throw new Error(`@displayxr/inline3d/splat: ${STREAMED_NEEDS_PLAYCANVAS}`);
180
+
175
181
  // Fail here, synchronously, and not through `ready`: a peer too old is an install-time mistake
176
182
  // in the page's dependencies, not a condition of this asset, and it will be true of every call.
177
183
  // Surfacing it as a load rejection would let a caller render an "asset unavailable" placeholder
@@ -632,6 +638,8 @@ function addSplatDeferred(wall, canvas, src, opts) {
632
638
  // line after addSplat — before the module has loaded — is the one that fires.
633
639
  onFocusChange: null,
634
640
  pick: () => null,
641
+ // Splat accounting (resident / budget / first frame); null until the adapter has loaded.
642
+ stats: () => null,
635
643
  remove: queue('remove'),
636
644
  exclude: queue('exclude'),
637
645
  unexclude: queue('unexclude'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -27,6 +27,7 @@
27
27
  "types": "./model.d.ts",
28
28
  "import": "./js/inline3d-model.js"
29
29
  },
30
+ "./package.json": "./package.json",
30
31
  "./undock": {
31
32
  "types": "./index.d.ts",
32
33
  "import": "./js/inline3d-undock.js"
package/splat.d.ts CHANGED
@@ -36,9 +36,25 @@ export interface SplatPerfOptions {
36
36
  lodRenderScale?: number;
37
37
  /**
38
38
  * `engine: 'playcanvas'` only — engine-native knobs, passed straight to `app.scene.gsplat`
39
- * (and winning over the Spark-knob mapping). `splatBudget` is a global splat count per tile.
39
+ * (and winning over the Spark-knob mapping).
40
+ *
41
+ * `splatBudget` is a splat count **per tile, all views included**: every view of a tile is
42
+ * drawn through one engine camera, so one budget covers both eyes of a 3D tile. It only acts
43
+ * on a Streamed SOG (a flat `.sog` draws every splat). Unset on a Streamed SOG = 600k
44
+ * (`STREAMED_SPLAT_BUDGET`); unset on anything else, or `perf: false` = the engine's 1M.
40
45
  */
41
46
  splatBudget?: number;
47
+ /**
48
+ * `engine: 'playcanvas'`, Streamed SOG only: how a chunk's LOD is chosen. Unset = the engine's
49
+ * `'distance'`.
50
+ */
51
+ lodMode?: 'distance' | 'error';
52
+ /** Streamed SOG only: camera travel (in the file's own units) before LOD re-evaluates. Engine default 1. */
53
+ lodUpdateDistance?: number;
54
+ /** Streamed SOG only: camera rotation in degrees before LOD re-evaluates. Engine default 0 (off). */
55
+ lodUpdateAngle?: number;
56
+ /** Streamed SOG only: how many coarser levels may stand in while a finer one streams. Engine default 0. */
57
+ lodUnderfillLimit?: number;
42
58
  /** `engine: 'playcanvas'` only: cull splats whose quad DIAMETER is under this many px. */
43
59
  minPixelSize?: number;
44
60
  /** `engine: 'playcanvas'` only: the forward-pass alpha floor (engine default 1/255). */
@@ -158,6 +174,11 @@ export interface SplatOptions {
158
174
  * Which renderer. `'spark'` (the default) is three.js + Spark. `'playcanvas'` is the PlayCanvas
159
175
  * engine (optional peer `playcanvas >=2.22.3 <3`, loaded by dynamic import only when asked):
160
176
  * same handle, reads `.sog` / `.ply` / a Streamed-SOG `lod-meta.json`. Anything else throws.
177
+ *
178
+ * A Streamed SOG is loaded BY URL only — its `lod-meta.json`, or the directory holding it (a
179
+ * URL ending in `/`). It is a directory of chunk files named by relative path, so bytes of a
180
+ * `lod-meta.json` throw at call time with a message giving the URL form, and so does a streamed
181
+ * URL with `engine: 'spark'`.
161
182
  */
162
183
  engine?: 'spark' | 'playcanvas';
163
184
  /**
@@ -238,6 +259,14 @@ export interface SplatOptions {
238
259
  * `'height'`. Both backends; the 3D rig's vertical FOV follows the crop. Anything else throws.
239
260
  */
240
261
  captureFit?: 'height' | 'cover';
262
+ /**
263
+ * PlayCanvas: a FLOOR on the projection's near plane, in world units (the adapter owns the
264
+ * projections; this only raises near, for depth precision when meshes share the scene under
265
+ * `handle.engine.root`). Anything nearer is clipped, splats included. Unset: untouched.
266
+ */
267
+ nearClip?: number;
268
+ /** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
269
+ farClip?: number;
241
270
  /** Camera rig only: the distance in world metres that sits ON the glass. */
242
271
  convergence?: number;
243
272
  /**
@@ -269,6 +298,34 @@ export interface SplatOptions {
269
298
  observe?: Element;
270
299
  }
271
300
 
301
+ /** `handle.stats()` on `engine: 'playcanvas'`. */
302
+ export interface SplatStats {
303
+ /** `'streamed'` for a `lod-meta.json`, `'flat'` for `.sog`/`.ply`, null before load. */
304
+ kind: 'flat' | 'streamed' | null;
305
+ /**
306
+ * Splats the engine placed in this tile's work buffer on the LAST frame: after LOD selection
307
+ * and the budget, before per-view frustum culling. Every view of the tile draws from this set.
308
+ */
309
+ resident: number;
310
+ /** The largest `resident` seen so far. */
311
+ peakResident: number;
312
+ /** The tile's splat budget (all views included), or null. */
313
+ budget: number | null;
314
+ /** The asset's own count: every splat of a flat source, the finest level of a Streamed SOG. */
315
+ numSplats: number;
316
+ /** Views drawn last frame (1 in mono, the runtime's view count in 3D). */
317
+ views: number;
318
+ /** Streamed SOG only (null otherwise): LOD levels, chunk files, chunk files currently loaded. */
319
+ lodLevels: number | null;
320
+ files: number | null;
321
+ filesLoaded: number | null;
322
+ /**
323
+ * `performance.now()` (ms since navigation start) of the first frame that drew a non-empty
324
+ * set — the page's time to first splat. Null until then.
325
+ */
326
+ firstFrameMs: number | null;
327
+ }
328
+
272
329
  /** What {@link addSplat} returns: a TileHandle plus the objects behind it. */
273
330
  export interface SplatHandle {
274
331
  /**
@@ -354,6 +411,11 @@ export interface SplatHandle {
354
411
  * raycast, falling back to the nearest centre.
355
412
  */
356
413
  pick(clientX: number, clientY: number): number[] | null;
414
+ /**
415
+ * `engine: 'playcanvas'` only: splat accounting for this tile. Null until the backend module
416
+ * has loaded.
417
+ */
418
+ stats?(): SplatStats | null;
357
419
 
358
420
  /** Close this window and release its GPU resources. */
359
421
  remove(): void;