@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 +25 -0
- package/README.md +9 -0
- package/js/inline3d-splat-perf.js +107 -2
- package/js/inline3d-splat-playcanvas.js +208 -15
- package/js/inline3d-splat-shared.js +51 -2
- package/js/inline3d-splat.js +8 -0
- package/package.json +1 -1
- package/splat.d.ts +55 -1
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
|
|
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 {
|
|
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 || {}))
|
|
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
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
* (
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
1722
|
-
//
|
|
1723
|
-
|
|
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:
|
|
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(
|
|
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;
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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.
|
|
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).
|
|
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;
|