@displayxr/inline3d 1.7.1 → 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 +86 -0
- package/README.md +22 -0
- package/js/inline3d-splat-perf.js +231 -0
- package/js/inline3d-splat-playcanvas.js +2101 -0
- package/js/inline3d-splat-rig.js +268 -3
- package/js/inline3d-splat-shared.js +318 -0
- package/js/inline3d-splat.js +182 -118
- package/js/inline3d-three.js +43 -9
- package/js/inline3d-viewer.js +27 -41
- package/package.json +8 -2
- package/splat.d.ts +191 -7
- package/three.d.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,92 @@ 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
|
+
|
|
33
|
+
## 1.8.0 — 2026-09-22
|
|
34
|
+
|
|
35
|
+
Touches the **preview tier** (`./splat`, plus one additive `./three` export). **Existing callers get
|
|
36
|
+
the same engine and the same code path**: with no `engine` option, or `engine: 'spark'`, `addSplat`
|
|
37
|
+
renders with three.js + Spark as in 1.7.1. The PlayCanvas engine is **opt-in** with
|
|
38
|
+
`engine: 'playcanvas'`.
|
|
39
|
+
|
|
40
|
+
**One behaviour change, for one class of file.** The camera-rig focus waterfall gains a rung (see
|
|
41
|
+
*Changed*). A `.sog` whose `camera` block has **no `focus`**, or a `focus` its converter computed
|
|
42
|
+
as the whole-cloud median (`focus.source: "cloud-median"`), now converges on the **nearest
|
|
43
|
+
substantial clump** in the middle of the frame instead of the whole-scene median. On an open
|
|
44
|
+
landscape that is 2.4 m (the tree trunk), not 46.9 m, so that file's 3D will look different, as
|
|
45
|
+
intended. Blocks with a considered focus (`convergence`, `manual`, …, or no `source`), assets with
|
|
46
|
+
no block, and a caller's `focus` / `convergence` are unaffected. Both engines.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`addSplat(…, { engine: 'playcanvas' })`: the PlayCanvas engine as a second splat backend**
|
|
51
|
+
(epic #36).
|
|
52
|
+
- Same handle and the same rig/lens/focus waterfall as Spark. One camera renders every view
|
|
53
|
+
through the engine's `RenderView` list, so one sort serves all eyes.
|
|
54
|
+
- A shader-chunk fix for the engine's square-pixel footprint assumption, which a side-by-side
|
|
55
|
+
buffer breaks.
|
|
56
|
+
- `perf` presets map onto engine knobs, and `perf: false` leaves the engine alone.
|
|
57
|
+
- A **URL** `.sog` yields its `camera` block too.
|
|
58
|
+
- Needs the new **optional** peer `playcanvas >=2.22.3 <3`, loaded by a dynamic `import()` only
|
|
59
|
+
when asked for.
|
|
60
|
+
- Reads `.sog`, `.ply` and a Streamed-SOG `lod-meta.json`. With `engine: 'playcanvas'`, a
|
|
61
|
+
`.spz` / `.splat` / `.ksplat` source (or a Spark-only `fileType`) **throws at call time**.
|
|
62
|
+
Those stay Spark formats.
|
|
63
|
+
- Differences from the Spark path: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
|
|
64
|
+
- On the PlayCanvas backend:
|
|
65
|
+
- **`handle.setSource(src, { fadeMs, resetPose })`**: a crossfading in-place asset swap that
|
|
66
|
+
re-runs the rig waterfall for the new file. The pose is kept unless `resetPose`. It throws on
|
|
67
|
+
Spark.
|
|
68
|
+
- **`feather`**: the per-eye edge fade, matching `EdgeFeather`.
|
|
69
|
+
- **Tilt-and-relax `orbit`**: the drag is a fraction of the tile, capped at `orbitMaxDeg` (15°),
|
|
70
|
+
and relaxes back on release (`orbitEase` `{ drag: 0.2, rest: 0.6 }` s). Spark keeps its
|
|
71
|
+
turntable drag.
|
|
72
|
+
- **Exact `pick`**: the nearest gaussian centre over the full centre set, with haze skipped.
|
|
73
|
+
- **`captureFit: 'height' | 'cover'`** on the camera rig, both backends. `'height'` (default) is
|
|
74
|
+
the 1.7 window. `'cover'` fills the tile with photograph (a 4:3 capture in a 16:9 tile crops
|
|
75
|
+
top/bottom).
|
|
76
|
+
- **`handle.engine`** (advanced, not semver-covered): `{ app, root, camera }` on PlayCanvas, and
|
|
77
|
+
`{ renderer, scene, camera }` on Spark. **`handle.backend`**: `'playcanvas'` or `'spark'`.
|
|
78
|
+
- **`handle.getFocus()`** and **`handle.onFocusChange(point, { focusSource })`**, in the splat's
|
|
79
|
+
own space, on both backends. `handle.rig` gains `blockFocusSource` and `clumpMassFrac`.
|
|
80
|
+
- **`cameraRigFromPose(pose, opts)`** in `./three`. It builds the camera-rig descriptor from a
|
|
81
|
+
plain `{position, orientation, fov}`, bit-identical to `cameraRigFromCamera`, which is unchanged.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- **Focus waterfall: `nearest-clump`** (the behaviour change above). The order is now: caller ›
|
|
86
|
+
caller convergence › block focus (any source except `cloud-median` / `median-disparity`) ›
|
|
87
|
+
**nearest-clump** (needs the block's or caller's lens) › `block-cloud-median` ›
|
|
88
|
+
`median-disparity` › 2 m.
|
|
89
|
+
- **Viewer tuning constants are shared** (`inline3d-splat-shared.js`). SceneViewer reads the same
|
|
90
|
+
damping, idle, focus-ease, wheel and zoom numbers as before, now from one module the PlayCanvas
|
|
91
|
+
viewer also reads. The values are unchanged, and a side-by-side trace test pins the two viewers
|
|
92
|
+
bit-identical.
|
|
93
|
+
|
|
8
94
|
## 1.7.1 — 2026-09-20
|
|
9
95
|
|
|
10
96
|
Touches the **preview tier** (`./model`) only, and fixes exactly one thing: under a bundler, a
|
package/README.md
CHANGED
|
@@ -40,6 +40,26 @@ and only the `/three`, `/viewer` and `/splat` subpaths need them. The two viewer
|
|
|
40
40
|
call (auto-framing on the zero-disparity plane, orbit, idle turntable, mono fallback), but their
|
|
41
41
|
API is not yet covered by the semver promise below.
|
|
42
42
|
|
|
43
|
+
**A second splat engine (preview).** `addSplat(wall, canvas, src, { engine: 'playcanvas' })`
|
|
44
|
+
renders the same splat window with the [PlayCanvas](https://playcanvas.com/) engine instead of
|
|
45
|
+
Spark — same handle, same rig/focus/camera-block behaviour, same `perf` presets (mapped onto the
|
|
46
|
+
engine's knobs) — and adds `setSource(src, { fadeMs })` (crossfading asset swaps), `feather`, a
|
|
47
|
+
tilt-and-relax orbit and `handle.engine` (the engine objects, for advanced pages). It needs the
|
|
48
|
+
optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
|
|
49
|
+
`lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
|
|
50
|
+
`playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
|
|
51
|
+
Bundlers: the engine is a literal `import('playcanvas')`; its sort workers are Blob URLs (CSP
|
|
52
|
+
`worker-src blob:`), and esbuild needs `node:worker_threads` marked external.
|
|
53
|
+
|
|
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
|
+
|
|
43
63
|
Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
|
|
44
64
|
that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
|
|
45
65
|
|
|
@@ -114,6 +134,8 @@ js/
|
|
|
114
134
|
and the placement readback (getSubjectBounds / getPose / depthOffset)
|
|
115
135
|
inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
|
|
116
136
|
(`perf` cuts overdraw; a `.sog`'s `camera` block picks the view rig)
|
|
137
|
+
inline3d-splat-playcanvas.js
|
|
138
|
+
preview: the `engine: 'playcanvas'` backend of addSplat(), loaded on demand
|
|
117
139
|
inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
|
|
118
140
|
from what the asset declares (you serve the decoder files — see the guide)
|
|
119
141
|
docs/
|
|
@@ -253,3 +253,234 @@ export function splatPerfMeshOptions(perf) {
|
|
|
253
253
|
if (!profile || !profile.lod) return {};
|
|
254
254
|
return { lod: profile.lod === 'quality' ? 'quality' : true };
|
|
255
255
|
}
|
|
256
|
+
|
|
257
|
+
// ── the same presets on the PlayCanvas engine (`addSplat(…, { engine: 'playcanvas' })`) ──────
|
|
258
|
+
//
|
|
259
|
+
// Same contract as the Spark half above: nothing moves unless asked, and every knob can be
|
|
260
|
+
// switched off. The presets are Spark-shaped (they were measured on Spark), so this maps each
|
|
261
|
+
// knob onto the engine's nearest equivalent rather than inventing engine presets:
|
|
262
|
+
//
|
|
263
|
+
// | Spark knob | engine (2.22.3) | note |
|
|
264
|
+
// |---|---|---|
|
|
265
|
+
// | `alphaRadius` | always on | the engine ALREADY shrinks every quad to its own alpha radius (`clipCorner` in `gsplatCommonVS`: `min(1, sqrt(ln(a / alphaClip)) / 2)` of the √8σ quad — the same cut `patchAlphaRadius` puts into Spark). It cannot be turned off, so `alphaRadius: false` is reported as `'native'`. |
|
|
266
|
+
// | `minAlpha` | `scene.gsplat.alphaClipForward` | engine default is already 1/255 |
|
|
267
|
+
// | `maxStdDev` | chunk override of `gsplatCommonVS` | the engine's quad is a fixed √8σ; the override caps `clipCorner`'s scale at `maxStdDev/√8`, which truncates both the quad AND the gaussian's uv, i.e. Spark's semantics. Only ever SHRINKS (≤ √8σ). |
|
|
268
|
+
// | `minPixelRadius` | `scene.gsplat.minPixelSize` = 2 × radius | the engine compares a quad DIAMETER in px (`max(l1,l2)`) |
|
|
269
|
+
// | `lod`, `lodSplat*`, `maxPixelRadius`, `falloff`, `alphaFloor` | none | Spark-only; ignored with one warning. The engine's splat-count lever is `splatBudget` (below) |
|
|
270
|
+
//
|
|
271
|
+
// Engine-native keys may be passed in an options object as well and win over the mapping:
|
|
272
|
+
// `alphaClipForward`, `minPixelSize`, `splatBudget` (a 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).
|
|
275
|
+
//
|
|
276
|
+
// `minPixelSize` IS THE ONE DEFAULT CHANGED. The engine drops every splat whose quad is under
|
|
277
|
+
// 2 px by default; Spark keeps them (its `minPixelRadius` default is 0). On a lifted photograph
|
|
278
|
+
// the sub-2px grain is real texture, so every `perf` value except `false` starts from 0 — the
|
|
279
|
+
// Spark-parity baseline — and only a preset or option that states a size moves it. `perf: false`
|
|
280
|
+
// is the kill switch: the engine's own defaults, untouched, 2 px included.
|
|
281
|
+
|
|
282
|
+
/** The engine's quad half-extent, in σ: `l = 2·sqrt(2λ)` over cornerUV ∈ [-1,1] (gsplatCorner). */
|
|
283
|
+
export const PLAYCANVAS_QUAD_SIGMA = Math.sqrt(8);
|
|
284
|
+
|
|
285
|
+
/** Knobs that are engine-native and pass straight through to `app.scene.gsplat`. */
|
|
286
|
+
const PC_NATIVE = ['alphaClipForward', 'minPixelSize', 'splatBudget'];
|
|
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
|
+
|
|
382
|
+
/** Spark knobs with no engine equivalent — named once in a warning, then ignored. */
|
|
383
|
+
const PC_UNMAPPED = ['lod', 'lodSplatCount', 'lodSplatScale', 'lodRenderScale', 'maxPixelRadius', 'falloff', 'alphaFloor'];
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve `perf` into engine settings. Pure: no engine, no GPU — the adapter applies the result.
|
|
387
|
+
*
|
|
388
|
+
* @param {undefined|null|false|true|string|object} perf the `addSplat` option, as given.
|
|
389
|
+
* @returns {{settings:object, quadExtent:number|null, applied:object|null, ignored:string[]}}
|
|
390
|
+
* `settings` go onto `app.scene.gsplat`; `quadExtent` (a fraction of the √8σ quad, or
|
|
391
|
+
* null for untouched) goes into the `gsplatCommonVS` override; `applied` is what the
|
|
392
|
+
* handle reports (null for `perf: false`).
|
|
393
|
+
*/
|
|
394
|
+
export function playcanvasPerfSettings(perf) {
|
|
395
|
+
const out = { settings: {}, quadExtent: null, applied: null, ignored: [] };
|
|
396
|
+
if (perf === false) return out; // kill switch: engine defaults, untouched
|
|
397
|
+
|
|
398
|
+
let profile = null;
|
|
399
|
+
let name = null;
|
|
400
|
+
if (perf === undefined || perf === null) name = 'default';
|
|
401
|
+
else if (perf === true) {
|
|
402
|
+
profile = SPLAT_PERF_PRESETS.balanced;
|
|
403
|
+
name = 'balanced';
|
|
404
|
+
} else if (typeof perf === 'string') {
|
|
405
|
+
profile = SPLAT_PERF_PRESETS[perf] || null;
|
|
406
|
+
name = profile ? perf : 'default';
|
|
407
|
+
if (!profile) {
|
|
408
|
+
console.warn(
|
|
409
|
+
`[inline3d/splat] unknown perf preset "${perf}" — ignored. ` +
|
|
410
|
+
`Known: ${Object.keys(SPLAT_PERF_PRESETS).join(', ')}, or an options object.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
} else if (typeof perf === 'object') {
|
|
414
|
+
profile = perf;
|
|
415
|
+
name = 'custom';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const s = out.settings;
|
|
419
|
+
const applied = { preset: name };
|
|
420
|
+
// The Spark-parity baseline. See the block comment above.
|
|
421
|
+
s.minPixelSize = 0;
|
|
422
|
+
if (profile) {
|
|
423
|
+
if (isNum(profile.minAlpha)) s.alphaClipForward = profile.minAlpha;
|
|
424
|
+
if (isNum(profile.minPixelRadius)) s.minPixelSize = 2 * Math.max(0, profile.minPixelRadius);
|
|
425
|
+
if (isNum(profile.maxStdDev) && profile.maxStdDev > 0) {
|
|
426
|
+
const k = Math.min(1, profile.maxStdDev / PLAYCANVAS_QUAD_SIGMA);
|
|
427
|
+
if (k < 1) out.quadExtent = k;
|
|
428
|
+
}
|
|
429
|
+
if (profile.alphaRadius !== undefined) applied.alphaRadius = 'native';
|
|
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
|
+
}
|
|
441
|
+
if (typeof profile.antiAlias === 'boolean') s.antiAlias = profile.antiAlias;
|
|
442
|
+
for (const key of PC_UNMAPPED) if (profile[key] !== undefined) out.ignored.push(key);
|
|
443
|
+
if (out.ignored.length) {
|
|
444
|
+
console.warn(
|
|
445
|
+
`[inline3d/splat] perf: ${out.ignored.join(', ')} ${out.ignored.length > 1 ? 'have' : 'has'} ` +
|
|
446
|
+
'no PlayCanvas equivalent and ' +
|
|
447
|
+
`${out.ignored.length > 1 ? 'are' : 'is'} ignored on engine:'playcanvas' — the engine's ` +
|
|
448
|
+
'splat-count lever is `splatBudget` (see js/inline3d-splat-perf.js).',
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
Object.assign(applied, s);
|
|
453
|
+
if (out.quadExtent !== null) applied.maxStdDev = out.quadExtent * PLAYCANVAS_QUAD_SIGMA;
|
|
454
|
+
out.applied = applied;
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
459
|
+
|
|
460
|
+
// The anchor the quad-extent override rewrites, from the engine's `gsplatCommonVS` (2.22.3). The
|
|
461
|
+
// npm build re-indents chunks with tabs and a later release may re-space them, so it is a
|
|
462
|
+
// whitespace-agnostic regex, not a string.
|
|
463
|
+
const PC_CLIP_ANCHOR = /float\s+clip\s*=\s*min\(\s*1\.0\s*,/;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Cap the engine's per-splat quad at `k` of its √8σ extent — Spark's `maxStdDev`, on PlayCanvas.
|
|
467
|
+
*
|
|
468
|
+
* Rewrites `clipCorner`'s `min(1.0, …)` to `min(k, …)`, which scales the corner offset AND the uv
|
|
469
|
+
* the fragment shader evaluates the gaussian at, so the tail is truncated rather than the
|
|
470
|
+
* profile squashed. Returns the source unchanged (and `ok:false`) when the anchor is missing;
|
|
471
|
+
* the caller warns once and renders at the engine's own extent.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} src the current `gsplatCommonVS` chunk.
|
|
474
|
+
* @param {number} k in (0, 1).
|
|
475
|
+
* @returns {{src:string, ok:boolean}}
|
|
476
|
+
*/
|
|
477
|
+
export function patchPlayCanvasQuadExtent(src, k) {
|
|
478
|
+
if (typeof src !== 'string' || !(k > 0 && k < 1)) return { src, ok: false };
|
|
479
|
+
if (src.includes('dxrQuadExtent')) return { src, ok: true }; // idempotent
|
|
480
|
+
if (!PC_CLIP_ANCHOR.test(src)) return { src, ok: false };
|
|
481
|
+
const lit = k.toFixed(7);
|
|
482
|
+
return {
|
|
483
|
+
src: src.replace(PC_CLIP_ANCHOR, `float clip = min(${lit} /* dxrQuadExtent */,`),
|
|
484
|
+
ok: true,
|
|
485
|
+
};
|
|
486
|
+
}
|