@displayxr/inline3d 1.8.0 → 1.9.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,31 @@ 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.0 — 2026-09-23
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: nothing changes for
11
+ Spark callers or for flat `.sog` files.
12
+
13
+ ### Added
14
+
15
+ - **Streamed SOG on the PlayCanvas backend** (epic #36 P2).
16
+ - Pass the URL of the `lod-meta.json`, or of the directory holding it. Bytes of a
17
+ `lod-meta.json`, or a streamed URL with `engine: 'spark'`, throw at call time with a message
18
+ saying what to pass instead.
19
+ - `splatBudget` is **per tile, all views included**: one engine camera renders every view, so
20
+ both eyes share one budget. It defaults to **600k** on a Streamed SOG when unset, and
21
+ `perf: false` keeps the engine's 1M.
22
+ - New `perf` keys pass through: `lodMode`, `lodUpdateDistance`, `lodUpdateAngle`,
23
+ `lodUnderfillLimit` (unset = engine default).
24
+ - New **`handle.stats()`**: `resident`, `peakResident`, `budget`, `numSplats`, `views`,
25
+ `lodLevels`, `files`, `filesLoaded`, `firstFrameMs`.
26
+ - Framed from its octree leaf boxes, not the root bound. The `camera` block is read from the
27
+ top level of `lod-meta.json`.
28
+ - Measured on a 5.88M-gaussian captured scene (M1 Pro): first frame 2.9 s against 20.8 s flat
29
+ at 100 Mbit/s, and 9.6 ms against 117 ms for two 1080p views. It is not a byte saving at a
30
+ close framing. Recipe and tables: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md)
31
+ §Streamed SOG.
32
+
8
33
  ## 1.8.0 — 2026-09-22
9
34
 
10
35
  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,6 +81,7 @@ import {
76
81
  captureVerticalFovDeg,
77
82
  engineFormatFor,
78
83
  pathOf,
84
+ streamedBytesError,
79
85
  } from './inline3d-splat-shared.js';
80
86
 
81
87
  /** The engine release this adapter was built and measured against (npm peer floor). */
@@ -385,7 +391,20 @@ export function pickViewPath(pc, forced) {
385
391
 
386
392
  // Source routing lives in ./inline3d-splat-shared.js (./splat also needs it, synchronously, to
387
393
  // refuse a format this engine cannot read at call time); re-exported for the tests.
388
- export { engineFormatFor } from './inline3d-splat-shared.js';
394
+ export { engineFormatFor, isStreamedUrl, streamedBytesError } from './inline3d-splat-shared.js';
395
+
396
+ /**
397
+ * The URL the engine loads for a streamed source: a directory URL gets `lod-meta.json` appended
398
+ * (before any query/hash, which are kept); a `lod-meta.json` URL is returned as is. The engine
399
+ * resolves every chunk relative to this URL's directory, so it must name the file itself.
400
+ */
401
+ export function streamedEntryUrl(src) {
402
+ if (typeof src !== 'string') return src;
403
+ const m = /^([^?#]*)(.*)$/.exec(src);
404
+ const path = m[1];
405
+ const tail = m[2];
406
+ return path.endsWith('/') ? `${path}lod-meta.json${tail}` : src;
407
+ }
389
408
 
390
409
  /**
391
410
  * The pick fallback on flat arrays: the gaussian whose CENTRE is nearest the ray — by angle,
@@ -521,6 +540,16 @@ export class PlayCanvasSplatViewer {
521
540
  this._viewPath = null;
522
541
  this._frustumKey = '';
523
542
 
543
+ // Per-frame splat accounting (handle.stats()). `resident` is what the engine put in the
544
+ // tile's work buffer on the LAST tick — the budget-enforced, LOD-selected set every view of
545
+ // the tile draws from (before per-view frustum culling). `firstFrameAt` is performance.now()
546
+ // (ms since navigation start) of the first tick that drew a non-empty set.
547
+ this.resident = 0;
548
+ this.peakResident = 0;
549
+ this.firstFrameAt = null;
550
+ this._tileBudget = undefined; // undefined = the engine's own default
551
+ this._budgetViews = 1;
552
+
524
553
  this._onResize = () => this._scheduleResize();
525
554
  this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
526
555
  if (this._ro) this._ro.observe(canvas);
@@ -810,7 +839,12 @@ export class PlayCanvasSplatViewer {
810
839
  );
811
840
  }
812
841
  }
813
- for (const [k, v] of Object.entries(perf?.settings || {})) app.scene.gsplat[k] = v;
842
+ for (const [k, v] of Object.entries(perf?.settings || {})) {
843
+ if (k !== 'splatBudget') app.scene.gsplat[k] = v;
844
+ }
845
+ // The budget goes through setTileBudget: it is a PER-TILE contract, and the N-camera
846
+ // fallback has to split it (SPLAT_BUDGET_MODEL).
847
+ this.setTileBudget(perf?.settings?.splatBudget);
814
848
 
815
849
  // EVERY Entity gets its app EXPLICITLY. The constructor's default is the engine's global
816
850
  // "current app" (`getApplication()`), which is whichever AppBase last init'ed or ticked — so
@@ -895,6 +929,35 @@ export class PlayCanvasSplatViewer {
895
929
  this._feather.mat.setParameter('dxrFeatherFy', Math.min(0.5, this.featherPx / Math.max(1, h)));
896
930
  }
897
931
 
932
+ /**
933
+ * The tile's splat budget, all views included (SPLAT_BUDGET_MODEL). `undefined` leaves the
934
+ * engine's own default alone. On the RenderView path it is the scene budget as is (one manager
935
+ * for all views); on the N-camera fallback it is split across the N per-camera managers.
936
+ */
937
+ setTileBudget(budget) {
938
+ this._tileBudget = Number.isFinite(budget) && budget > 0 ? Math.floor(budget) : undefined;
939
+ this._applyBudget();
940
+ }
941
+
942
+ get tileBudget() {
943
+ if (this._tileBudget !== undefined) return this._tileBudget;
944
+ return this.app ? this.app.scene.gsplat.splatBudget : undefined;
945
+ }
946
+
947
+ _applyBudget() {
948
+ if (!this.app || this._tileBudget === undefined) return;
949
+ const per = budgetPerManager(this._tileBudget, this._viewPath, this._budgetViews);
950
+ if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
951
+ }
952
+
953
+ /** Read back what the tick just did. Cheap: two numbers off the renderer. */
954
+ _afterTick() {
955
+ const n = this.app?.renderer?._gsplatCount ?? 0;
956
+ this.resident = n;
957
+ if (n > this.peakResident) this.peakResident = n;
958
+ if (n > 0 && this.firstFrameAt === null) this.firstFrameAt = now();
959
+ }
960
+
898
961
  /** A camera entity under the rig node. Tonemapping OFF: splat colours are already display-referred. */
899
962
  _makeCamera(name, rect) {
900
963
  const pc = this.pc;
@@ -1025,6 +1088,11 @@ export class PlayCanvasSplatViewer {
1025
1088
  cams.push(cam);
1026
1089
  }
1027
1090
  for (let i = 0; i < cams.length; i++) cams[i].enabled = i < entries.length;
1091
+ if (this._budgetViews !== entries.length) {
1092
+ // One manager per enabled camera here, each reading the scene budget: split it.
1093
+ this._budgetViews = entries.length;
1094
+ this._applyBudget();
1095
+ }
1028
1096
  const W = el.width || 1;
1029
1097
  const H = el.height || 1;
1030
1098
  for (let i = 0; i < entries.length; i++) {
@@ -1042,6 +1110,7 @@ export class PlayCanvasSplatViewer {
1042
1110
  }
1043
1111
  this._updateFeather(entries[0].width * sx, entries[0].height * sy);
1044
1112
  app.tick(now());
1113
+ this._afterTick();
1045
1114
  return true;
1046
1115
  }
1047
1116
 
@@ -1361,18 +1430,86 @@ export async function readCloud(resource) {
1361
1430
  return { xyz, opacity, alpha8, total, stride, sourceTotal };
1362
1431
  }
1363
1432
 
1433
+ /**
1434
+ * A point sample standing in for a Streamed SOG's cloud, from its octree alone: every leaf node
1435
+ * contributes points in proportion to its finest-level splat count, spread deterministically
1436
+ * through its box. That is enough for the SAME percentile framing a flat source gets
1437
+ * (boundsFromPositions), which the octree's root bound cannot give — the root is the raw
1438
+ * container of every chunk, sky shells and floaters included (a captured castle: 391×821×390 m
1439
+ * root against 65×25×76 m measured on the flat file; a museum room: ±240 m around a 2 m statue).
1440
+ *
1441
+ * Coarse by construction (a node is a box, not its splats), so framing lands within a node size
1442
+ * of the flat measurement, not on it. Nothing is downloaded: lod-meta.json already lists every
1443
+ * node's box and count.
1444
+ *
1445
+ * @param {Array<{min:number[], max:number[], count:number}>} nodes
1446
+ * @param {number} [cap] points to emit in total (default 20k).
1447
+ * @returns {Float32Array|null} xyz, model space.
1448
+ */
1449
+ export function octreeSample(nodes, cap = 20000) {
1450
+ if (!Array.isArray(nodes) || !nodes.length) return null;
1451
+ let total = 0;
1452
+ for (const n of nodes) total += n.count > 0 ? n.count : 0;
1453
+ if (!(total > 0)) return null;
1454
+ const pts = [];
1455
+ let seed = 0x9e3779b9;
1456
+ const rnd = () => {
1457
+ // xorshift32: deterministic, so a test (and a reload) frames the same way every time.
1458
+ seed ^= seed << 13; seed >>>= 0;
1459
+ seed ^= seed >>> 17;
1460
+ seed ^= seed << 5; seed >>>= 0;
1461
+ return seed / 4294967296;
1462
+ };
1463
+ let carry = 0;
1464
+ for (const n of nodes) {
1465
+ if (!(n.count > 0)) continue;
1466
+ carry += (n.count / total) * cap;
1467
+ const k = Math.floor(carry);
1468
+ carry -= k;
1469
+ for (let i = 0; i < k; i++) {
1470
+ pts.push(
1471
+ n.min[0] + (n.max[0] - n.min[0]) * rnd(),
1472
+ n.min[1] + (n.max[1] - n.min[1]) * rnd(),
1473
+ n.min[2] + (n.max[2] - n.min[2]) * rnd(),
1474
+ );
1475
+ }
1476
+ }
1477
+ return pts.length ? Float32Array.from(pts) : null;
1478
+ }
1479
+
1480
+ /** The engine octree's leaf nodes as octreeSample() input. */
1481
+ function octreeNodes(res) {
1482
+ const nodes = res?.octree?.nodes;
1483
+ if (!Array.isArray(nodes)) return [];
1484
+ const out = [];
1485
+ for (const n of nodes) {
1486
+ const b = n?.bounds;
1487
+ const c = n?.lods?.[0]?.count ?? 0;
1488
+ if (!b || !b.center || !b.halfExtents || !(c > 0)) continue;
1489
+ out.push({
1490
+ min: [b.center.x - b.halfExtents.x, b.center.y - b.halfExtents.y, b.center.z - b.halfExtents.z],
1491
+ max: [b.center.x + b.halfExtents.x, b.center.y + b.halfExtents.y, b.center.z + b.halfExtents.z],
1492
+ count: c,
1493
+ });
1494
+ }
1495
+ return out;
1496
+ }
1497
+
1364
1498
  /**
1365
1499
  * What kind of resource the engine handed back, and what can be known about it without a cloud.
1366
1500
  *
1367
1501
  * 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.
1502
+ * `gsplatData`, splats arriving by LOD as the camera asks. Its framing comes from the octree's
1503
+ * leaf boxes (octreeSample the same percentile bounds a flat cloud gets), else from the root
1504
+ * bound (`resource.aabb`, the lod-meta `tree.bound` the engine nulls `data.tree` after
1505
+ * reading it); its camera block from the top level of lod-meta.json; its count from the octree's
1506
+ * finest level (`numSplats`; lod-meta's `count` sums every level). What is on screen in a given
1507
+ * frame is `handle.stats().resident`, capped by the tile budget (SPLAT_BUDGET_MODEL); `pick`
1508
+ * searches the chunks currently resident.
1373
1509
  *
1374
1510
  * @returns {{kind:'flat'|'streamed'|null, numSplats:number, meta:object|null,
1375
- * bounds:{center:number[],extent:number[]}|null}} bounds in MODEL space.
1511
+ * bounds:{center:number[],extent:number[]}|null, boundsSource?:string,
1512
+ * rootBounds?:object|null}} bounds in MODEL space.
1376
1513
  */
1377
1514
  export function describeResource(res) {
1378
1515
  if (!res) return { kind: null, numSplats: 0, meta: null, bounds: null };
@@ -1391,7 +1528,16 @@ export function describeResource(res) {
1391
1528
  } catch {
1392
1529
  n = 0;
1393
1530
  }
1394
- return { kind: 'streamed', numSplats: n || res.data?.count || 0, meta: res.data || null, bounds };
1531
+ const sample = octreeSample(octreeNodes(res));
1532
+ const sampled = sample ? boundsFromPositions(sample) : null;
1533
+ return {
1534
+ kind: 'streamed',
1535
+ numSplats: n || res.data?.count || 0,
1536
+ meta: res.data || null,
1537
+ bounds: sampled || bounds,
1538
+ boundsSource: sampled ? 'octree-sample' : bounds ? 'octree-root' : null,
1539
+ rootBounds: bounds,
1540
+ };
1395
1541
  }
1396
1542
  return { kind: 'flat', numSplats: res.gsplatData?.numSplats ?? 0, meta: res.gsplatData?.meta || null, bounds: null };
1397
1543
  }
@@ -1528,6 +1674,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1528
1674
  },
1529
1675
  exclude: (el) => handle?.exclude(el),
1530
1676
  unexclude: (el) => handle?.unexclude(el),
1677
+ stats: () => splatStats(),
1531
1678
  });
1532
1679
  // `onFocusChange` is a plain data property of the handle, read at CALL time — so a callback
1533
1680
  // assigned before this module loaded (on the stub ./splat returned) is the one that fires.
@@ -1598,6 +1745,25 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1598
1745
  // captureFit 'cover' re-crops on resize, which changes the rig's vertical FOV: re-declare it.
1599
1746
  viewer.onCaptureFov = () => pushViewRig(true);
1600
1747
 
1748
+ // ── stats ──
1749
+ function splatStats() {
1750
+ const c = current;
1751
+ const oct = c?.kind === 'streamed' ? c.res?.octree : null;
1752
+ const budget = viewer.tileBudget;
1753
+ return {
1754
+ kind: c?.kind ?? null,
1755
+ resident: viewer.resident,
1756
+ peakResident: viewer.peakResident,
1757
+ budget: Number.isFinite(budget) ? budget : null,
1758
+ numSplats: out.mesh?.numSplats ?? 0,
1759
+ views: viewer._lastGood && viewer.is3D ? viewer._lastGood.entries.length : 1,
1760
+ lodLevels: oct ? oct.lodLevels : null,
1761
+ files: oct ? oct.files.length : null,
1762
+ filesLoaded: oct ? oct.fileResources.size : null,
1763
+ firstFrameMs: viewer.firstFrameAt,
1764
+ };
1765
+ }
1766
+
1601
1767
  // ── pick ──
1602
1768
  /** The ray under a client point, in MODEL space (through the inverse pivot and the flip). */
1603
1769
  function modelRay(clientX, clientY) {
@@ -1678,6 +1844,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1678
1844
  const buf = source instanceof Blob ? await source.arrayBuffer() : source;
1679
1845
  bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
1680
1846
  }
1847
+ if (bytes) {
1848
+ // Also caught synchronously by ./splat for Uint8Array/ArrayBuffer; a Blob, and setSource,
1849
+ // only arrive here.
1850
+ const streamedErr = streamedBytesError(bytes, opts.fileName);
1851
+ if (streamedErr) throw new Error(`[inline3d/splat] ${streamedErr}`);
1852
+ }
1681
1853
  const fmt = engineFormatFor(source, bytes, opts.fileName, opts.fileType);
1682
1854
  if (!fmt) {
1683
1855
  throw new Error(
@@ -1689,10 +1861,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1689
1861
  let camera = null;
1690
1862
  if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
1691
1863
 
1692
- const url = bytes ? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}` : source;
1864
+ const url = bytes
1865
+ ? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}`
1866
+ : fmt.streamed
1867
+ ? streamedEntryUrl(source)
1868
+ : source;
1693
1869
  const file = bytes
1694
1870
  ? { url, filename: url, contents: new Response(bytes) }
1695
- : { url, filename: pathOf(source).split('/').pop() || url };
1871
+ : { url, filename: pathOf(url).split('/').pop() || url };
1696
1872
  const asset = new pc.Asset(url, 'gsplat', file);
1697
1873
  app.assets.add(asset);
1698
1874
  await new Promise((resolve, reject) => {
@@ -1718,9 +1894,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1718
1894
  const walk = cloud ? centresVisitor(cloud.xyz, cloud.opacity, cloud.total) : null;
1719
1895
  const local = walk ? boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []) : null;
1720
1896
  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;
1897
+ // Measured first the Spark path's order (a supplied frame is only a fallback there too).
1898
+ // A Streamed SOG has no cloud: there a caller's `frame` beats the octree-derived bounds
1899
+ // (describeResource: a count-weighted sample of the leaf boxes, else the raw root bound),
1900
+ // because both are coarser than a real measurement.
1901
+ const bounds = local
1902
+ ? lift(local)
1903
+ : desc.kind === 'streamed' && frame
1904
+ ? lift(frame)
1905
+ : desc.bounds
1906
+ ? lift(desc.bounds)
1907
+ : frame
1908
+ ? lift(frame)
1909
+ : null;
1724
1910
 
1725
1911
  const sample =
1726
1912
  !rigNeedsCloud(loaded.camera)
@@ -1740,6 +1926,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1740
1926
  out.camera = loaded.camera;
1741
1927
  out.rig = resolved;
1742
1928
  out.frame = bounds;
1929
+ // The tile budget, now that the kind is known: the caller's, else the streamed default on a
1930
+ // Streamed SOG, else the engine's (STREAMED_SPLAT_BUDGET, SPLAT_BUDGET_MODEL).
1931
+ const budget = tileSplatBudget(perfResolved, desc.kind);
1932
+ viewer.setTileBudget(budget);
1933
+ if (perfResolved.applied) {
1934
+ out.perf = budget !== undefined ? { ...perfResolved.applied, splatBudget: budget } : perfResolved.applied;
1935
+ }
1743
1936
 
1744
1937
  // The strided pick fallback (used only if the engine releases its full centre set).
1745
1938
  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.0",
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",
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
  /**
@@ -269,6 +290,34 @@ export interface SplatOptions {
269
290
  observe?: Element;
270
291
  }
271
292
 
293
+ /** `handle.stats()` on `engine: 'playcanvas'`. */
294
+ export interface SplatStats {
295
+ /** `'streamed'` for a `lod-meta.json`, `'flat'` for `.sog`/`.ply`, null before load. */
296
+ kind: 'flat' | 'streamed' | null;
297
+ /**
298
+ * Splats the engine placed in this tile's work buffer on the LAST frame: after LOD selection
299
+ * and the budget, before per-view frustum culling. Every view of the tile draws from this set.
300
+ */
301
+ resident: number;
302
+ /** The largest `resident` seen so far. */
303
+ peakResident: number;
304
+ /** The tile's splat budget (all views included), or null. */
305
+ budget: number | null;
306
+ /** The asset's own count: every splat of a flat source, the finest level of a Streamed SOG. */
307
+ numSplats: number;
308
+ /** Views drawn last frame (1 in mono, the runtime's view count in 3D). */
309
+ views: number;
310
+ /** Streamed SOG only (null otherwise): LOD levels, chunk files, chunk files currently loaded. */
311
+ lodLevels: number | null;
312
+ files: number | null;
313
+ filesLoaded: number | null;
314
+ /**
315
+ * `performance.now()` (ms since navigation start) of the first frame that drew a non-empty
316
+ * set — the page's time to first splat. Null until then.
317
+ */
318
+ firstFrameMs: number | null;
319
+ }
320
+
272
321
  /** What {@link addSplat} returns: a TileHandle plus the objects behind it. */
273
322
  export interface SplatHandle {
274
323
  /**
@@ -354,6 +403,11 @@ export interface SplatHandle {
354
403
  * raycast, falling back to the nearest centre.
355
404
  */
356
405
  pick(clientX: number, clientY: number): number[] | null;
406
+ /**
407
+ * `engine: 'playcanvas'` only: splat accounting for this tile. Null until the backend module
408
+ * has loaded.
409
+ */
410
+ stats?(): SplatStats | null;
357
411
 
358
412
  /** Close this window and release its GPU resources. */
359
413
  remove(): void;