@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 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
+ }