@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 +57 -0
- package/README.md +9 -0
- package/js/inline3d-splat-perf.js +107 -2
- package/js/inline3d-splat-playcanvas.js +292 -19
- package/js/inline3d-splat-shared.js +51 -2
- package/js/inline3d-splat.js +8 -0
- package/package.json +2 -1
- package/splat.d.ts +63 -1
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
|
|
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,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
|
-
|
|
760
|
-
//
|
|
761
|
-
opts.
|
|
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 || {}))
|
|
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
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
* (
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
1722
|
-
//
|
|
1723
|
-
|
|
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:
|
|
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.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).
|
|
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;
|