@displayxr/inline3d 1.10.0 → 1.10.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 +27 -0
- package/js/inline3d-splat-playcanvas.js +78 -14
- package/js/inline3d-viewer.js +57 -6
- package/package.json +1 -1
- package/splat.d.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.10.1 — 2026-09-23
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), and `boundsFromPositions` in
|
|
11
|
+
`./viewer` (same results, less time). Nothing changes for Spark callers' pixels.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **No grey sky box behind the splat.** The engine draws a sky whenever the scene has something to
|
|
16
|
+
draw it from, and `scene.envAtlas` counts. A page that set one to light its meshes under
|
|
17
|
+
`handle.engine.root` got a grey gradient box the instant the splat was hidden (mid-`setSource`,
|
|
18
|
+
or a page showing only its own meshes). The eye camera now renders without the Skybox layer, so
|
|
19
|
+
the canvas stays transparent. Image-based lighting still lights the meshes. The new **`sky: true`**
|
|
20
|
+
brings the engine's sky back.
|
|
21
|
+
- Verified headless with `envAtlas` set and the splat hidden: the corner pixel reads
|
|
22
|
+
`[140,140,140,255]` in 1.10.0 and `[0,0,0,0]` now (and `[140,140,140,255]` again with
|
|
23
|
+
`sky: true`).
|
|
24
|
+
- **`setSource` no longer blocks input for its cloud passes.** The SDK's framing, rest-space sample
|
|
25
|
+
and pick-set passes ran in one main-thread task stacked on the engine's end-of-load work. Now
|
|
26
|
+
each runs in its own task (with `scheduler.yield()` where available).
|
|
27
|
+
- `boundsFromPositions` takes its percentiles with a linear-time select instead of full sorts.
|
|
28
|
+
The values are bit-identical, pinned by a test.
|
|
29
|
+
- On a 1.18M-gaussian swap (headless, M1 Pro), the longest main-thread task went from
|
|
30
|
+
**60–64 ms to under 50 ms** (none reported), and the longest frame gap from 65–77 to
|
|
31
|
+
35–44 ms. At 4× CPU throttling it went from 218–238 ms to 60–65 ms, which is the engine's own
|
|
32
|
+
remainder, documented per 1M gaussians with the recommended pre-load pattern.
|
|
33
|
+
- The load stages are visible as `performance.measure` entries named `inline3d:*`.
|
|
34
|
+
|
|
8
35
|
## 1.10.0 — 2026-09-23
|
|
9
36
|
|
|
10
37
|
Touches the **core tier** (`.`), additively. The frozen 1.x surface gains one handle member and
|
|
@@ -535,8 +535,15 @@ export class PlayCanvasSplatViewer {
|
|
|
535
535
|
captureFit = 'height',
|
|
536
536
|
nearClip,
|
|
537
537
|
farClip,
|
|
538
|
+
sky = false,
|
|
538
539
|
} = opts;
|
|
539
540
|
this.canvas = canvas;
|
|
541
|
+
// The engine draws a sky box whenever the scene has something to draw it from — and
|
|
542
|
+
// `scene.envAtlas` counts: a page that sets one to light its own meshes under
|
|
543
|
+
// handle.engine.root got a grey gradient box behind the splat the instant the splat was
|
|
544
|
+
// hidden (mid-setSource, or a page showing only its meshes). The SDK's contract is a
|
|
545
|
+
// transparent canvas the page shows through, so the sky layer is off unless asked for.
|
|
546
|
+
this.sky = sky === true;
|
|
540
547
|
// Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
|
|
541
548
|
// other; splats only test against them). The projections' own near/far stay the adapter's —
|
|
542
549
|
// these only raise the near (floor) and lower the far (cap). Unset: untouched.
|
|
@@ -1044,6 +1051,11 @@ export class PlayCanvasSplatViewer {
|
|
|
1044
1051
|
fov: MONO_FOV,
|
|
1045
1052
|
...(rect ? { rect } : {}),
|
|
1046
1053
|
});
|
|
1054
|
+
// No sky unless the page asked (see the constructor): the image-based lighting a page sets
|
|
1055
|
+
// still lights its meshes — only the BACKGROUND the sky layer would draw is dropped.
|
|
1056
|
+
if (!this.sky && Array.isArray(e.camera.layers)) {
|
|
1057
|
+
e.camera.layers = e.camera.layers.filter((id) => id !== pc.LAYERID_SKYBOX);
|
|
1058
|
+
}
|
|
1047
1059
|
// The engine's default camera tonemap is LINEAR, which routes every splat colour through
|
|
1048
1060
|
// decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
|
|
1049
1061
|
// NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
|
|
@@ -1456,6 +1468,28 @@ function placeNode(node, m) {
|
|
|
1456
1468
|
* @returns {Promise<{xyz:Float32Array, opacity:Float32Array|null, total:number, stride:number,
|
|
1457
1469
|
* sourceTotal:number}|null>} `total` is the number of splats IN the sample.
|
|
1458
1470
|
*/
|
|
1471
|
+
/**
|
|
1472
|
+
* A `performance.measure` named `inline3d:<name>` from `t0` to now — so a load's stages show up in
|
|
1473
|
+
* DevTools' Performance panel and in `performance.getEntriesByType('measure')`. Never throws.
|
|
1474
|
+
*/
|
|
1475
|
+
/**
|
|
1476
|
+
* Give the main thread back for one turn — input, rAF, the engine's own tick — before the next
|
|
1477
|
+
* chunk of cloud work. `scheduler.yield()` where the browser has it (keeps our task's priority),
|
|
1478
|
+
* else a macrotask.
|
|
1479
|
+
*/
|
|
1480
|
+
function yieldToMain() {
|
|
1481
|
+
if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') return scheduler.yield();
|
|
1482
|
+
return new Promise((r) => setTimeout(r, 0));
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function perfSpan(name, t0) {
|
|
1486
|
+
try {
|
|
1487
|
+
performance.measure?.(`inline3d:${name}`, { start: t0, end: performance.now() });
|
|
1488
|
+
} catch {
|
|
1489
|
+
/* no User Timing L3 here */
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1459
1493
|
export async function readCloud(resource) {
|
|
1460
1494
|
if (!resource) return null;
|
|
1461
1495
|
const centers = resource.centers;
|
|
@@ -1464,19 +1498,29 @@ export async function readCloud(resource) {
|
|
|
1464
1498
|
if (!centers || !sourceTotal) return null;
|
|
1465
1499
|
const stride = Math.max(1, Math.ceil(sourceTotal / FRAME_SAMPLE_CAP));
|
|
1466
1500
|
const total = Math.ceil(sourceTotal / stride);
|
|
1501
|
+
// Not in the task that delivered the asset: the engine's own end-of-load work (its centre
|
|
1502
|
+
// readback unpack) runs there, and stacking ours on it made one long task.
|
|
1503
|
+
await yieldToMain();
|
|
1504
|
+
let t0 = performance.now();
|
|
1467
1505
|
const xyz = new Float32Array(total * 3);
|
|
1468
1506
|
for (let j = 0, i = 0; j < total; j++, i += stride) {
|
|
1469
1507
|
xyz[j * 3] = centers[i * 3];
|
|
1470
1508
|
xyz[j * 3 + 1] = centers[i * 3 + 1];
|
|
1471
1509
|
xyz[j * 3 + 2] = centers[i * 3 + 2];
|
|
1472
1510
|
}
|
|
1511
|
+
perfSpan('readCloud:copy', t0);
|
|
1512
|
+
await yieldToMain();
|
|
1473
1513
|
let opacity = null;
|
|
1474
1514
|
// Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
|
|
1475
1515
|
// engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
|
|
1476
1516
|
let alpha8 = null;
|
|
1477
1517
|
try {
|
|
1478
1518
|
if (data?.isSog && data.sh0?.read) {
|
|
1519
|
+
t0 = performance.now();
|
|
1479
1520
|
const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
|
|
1521
|
+
perfSpan('readCloud:sh0-readback(async)', t0);
|
|
1522
|
+
await yieldToMain();
|
|
1523
|
+
t0 = performance.now();
|
|
1480
1524
|
if (px && px.length >= sourceTotal * 4) {
|
|
1481
1525
|
const v2 = data.meta?.version === 2;
|
|
1482
1526
|
const mn = data.meta?.sh0?.mins?.[3];
|
|
@@ -1491,6 +1535,7 @@ export async function readCloud(resource) {
|
|
|
1491
1535
|
if (v2 || mn === undefined) for (let i = 0; i < sourceTotal; i++) alpha8[i] = px[i * 4 + 3];
|
|
1492
1536
|
else for (let i = 0; i < sourceTotal; i++) alpha8[i] = Math.round(op(i) * 255);
|
|
1493
1537
|
}
|
|
1538
|
+
perfSpan('readCloud:opacity', t0);
|
|
1494
1539
|
} else if (typeof data?.getProp === 'function') {
|
|
1495
1540
|
const o = data.getProp('opacity');
|
|
1496
1541
|
if (o && o.length >= sourceTotal) {
|
|
@@ -1708,6 +1753,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1708
1753
|
captureFit,
|
|
1709
1754
|
nearClip: opts.nearClip,
|
|
1710
1755
|
farClip: opts.farClip,
|
|
1756
|
+
sky: opts.sky,
|
|
1711
1757
|
});
|
|
1712
1758
|
|
|
1713
1759
|
let handle = null;
|
|
@@ -1946,7 +1992,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1946
1992
|
}
|
|
1947
1993
|
// The camera block off the SAME bytes, before the engine takes them (as the Spark path).
|
|
1948
1994
|
let camera = null;
|
|
1995
|
+
let t0 = performance.now();
|
|
1949
1996
|
if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
|
|
1997
|
+
perfSpan('readSogMeta(async)', t0);
|
|
1950
1998
|
|
|
1951
1999
|
const url = bytes
|
|
1952
2000
|
? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}`
|
|
@@ -1958,18 +2006,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1958
2006
|
: { url, filename: pathOf(url).split('/').pop() || url };
|
|
1959
2007
|
const asset = new pc.Asset(url, 'gsplat', file);
|
|
1960
2008
|
app.assets.add(asset);
|
|
2009
|
+
t0 = performance.now();
|
|
1961
2010
|
await new Promise((resolve, reject) => {
|
|
1962
2011
|
asset.ready(resolve);
|
|
1963
2012
|
asset.once('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
1964
2013
|
app.assets.load(asset);
|
|
1965
2014
|
});
|
|
2015
|
+
perfSpan('engine-load(async)', t0);
|
|
1966
2016
|
const res = asset.resource;
|
|
1967
2017
|
const desc = describeResource(res);
|
|
1968
2018
|
// A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
|
|
1969
2019
|
// SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
|
|
1970
2020
|
if (!bytes && rig !== 'display') camera = sogCameraFromMeta(desc.meta);
|
|
1971
2021
|
const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
|
|
1972
|
-
|
|
2022
|
+
// The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
|
|
2023
|
+
// were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
|
|
2024
|
+
// Split with a yield between them (and a linear-time percentile in boundsFromPositions),
|
|
2025
|
+
// no single step is a long task any more. Same numbers, same order.
|
|
2026
|
+
const pre = { local: null, rest: null, pickCentres: null };
|
|
2027
|
+
if (cloud) {
|
|
2028
|
+
const walk = centresVisitor(cloud.xyz, cloud.opacity, cloud.total);
|
|
2029
|
+
await yieldToMain();
|
|
2030
|
+
let t = performance.now();
|
|
2031
|
+
pre.local = boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []);
|
|
2032
|
+
perfSpan('cloud:bounds', t);
|
|
2033
|
+
await yieldToMain();
|
|
2034
|
+
t = performance.now();
|
|
2035
|
+
if (rigNeedsCloud(camera)) pre.rest = sampleCloudRestSpace(cloud.total, walk, camera?.rest);
|
|
2036
|
+
perfSpan('cloud:rest-sample', t);
|
|
2037
|
+
await yieldToMain();
|
|
2038
|
+
t = performance.now();
|
|
2039
|
+
const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
|
|
2040
|
+
pre.pickCentres = s ? s.slice() : null;
|
|
2041
|
+
perfSpan('cloud:pick-set', t);
|
|
2042
|
+
await yieldToMain();
|
|
2043
|
+
}
|
|
2044
|
+
return { asset, res, desc, camera, cloud, pre };
|
|
1973
2045
|
}
|
|
1974
2046
|
|
|
1975
2047
|
/**
|
|
@@ -1978,8 +2050,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1978
2050
|
*/
|
|
1979
2051
|
function applyLoaded(loaded) {
|
|
1980
2052
|
const { cloud, desc } = loaded;
|
|
1981
|
-
const
|
|
1982
|
-
const local =
|
|
2053
|
+
const tB = performance.now();
|
|
2054
|
+
const local = loaded.pre?.local || null;
|
|
1983
2055
|
const lift = (b) => ({ center: modelToContent(b.center), extent: b.extent.slice(0, 3) });
|
|
1984
2056
|
// Measured first — the Spark path's order (a supplied frame is only a fallback there too).
|
|
1985
2057
|
// A Streamed SOG has no cloud: there a caller's `frame` beats the octree-derived bounds
|
|
@@ -1995,12 +2067,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1995
2067
|
? lift(frame)
|
|
1996
2068
|
: null;
|
|
1997
2069
|
|
|
1998
|
-
const sample =
|
|
1999
|
-
!rigNeedsCloud(loaded.camera)
|
|
2000
|
-
? null
|
|
2001
|
-
: walk
|
|
2002
|
-
? sampleCloudRestSpace(cloud.total, walk, loaded.camera?.rest)
|
|
2003
|
-
: null;
|
|
2070
|
+
const sample = loaded.pre?.rest || null;
|
|
2004
2071
|
const box = canvas.getBoundingClientRect();
|
|
2005
2072
|
const resolved = resolveRig({
|
|
2006
2073
|
camera: loaded.camera,
|
|
@@ -2010,6 +2077,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2010
2077
|
});
|
|
2011
2078
|
resolved.focusDefault = resolved.focus.slice();
|
|
2012
2079
|
resolved.focusDefaultSource = resolved.focusSource;
|
|
2080
|
+
perfSpan('applyLoaded:rig', tB);
|
|
2013
2081
|
out.camera = loaded.camera;
|
|
2014
2082
|
out.rig = resolved;
|
|
2015
2083
|
out.frame = bounds;
|
|
@@ -2022,11 +2090,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2022
2090
|
}
|
|
2023
2091
|
|
|
2024
2092
|
// The strided pick fallback (used only if the engine releases its full centre set).
|
|
2025
|
-
|
|
2026
|
-
if (walk) {
|
|
2027
|
-
const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
|
|
2028
|
-
pickCentres = s ? s.slice() : null;
|
|
2029
|
-
}
|
|
2093
|
+
const pickCentres = loaded.pre?.pickCentres || null;
|
|
2030
2094
|
|
|
2031
2095
|
if (resolved.type === 'camera') {
|
|
2032
2096
|
if (!('idleSpin' in opts)) viewer.idleSpin = 0;
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -70,6 +70,56 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
|
70
70
|
// matrix and every vertex lands undefined. Reject at the setter instead.
|
|
71
71
|
const finite = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The k-th smallest of the first `n` entries of `a` — exactly what `a.subarray(0, n).sort()[k]`
|
|
75
|
+
* returns, NaN placement included (a TypedArray sort puts NaN last) — by quickselect, reordering
|
|
76
|
+
* `a` in place. O(n) on average.
|
|
77
|
+
*/
|
|
78
|
+
export function selectKth(a, n, k) {
|
|
79
|
+
// NaN to the end first, as the numeric TypedArray sort does; select among the rest.
|
|
80
|
+
let m = n;
|
|
81
|
+
for (let i = 0; i < m; ) {
|
|
82
|
+
if (a[i] !== a[i]) {
|
|
83
|
+
m--;
|
|
84
|
+
const t = a[i];
|
|
85
|
+
a[i] = a[m];
|
|
86
|
+
a[m] = t;
|
|
87
|
+
} else i++;
|
|
88
|
+
}
|
|
89
|
+
if (k >= m) return NaN;
|
|
90
|
+
let left = 0;
|
|
91
|
+
let right = m - 1;
|
|
92
|
+
while (right > left) {
|
|
93
|
+
// Median-of-three pivot keeps sorted / reverse-sorted input O(n).
|
|
94
|
+
const mid = (left + right) >> 1;
|
|
95
|
+
if (a[mid] < a[left]) swap(a, mid, left);
|
|
96
|
+
if (a[right] < a[left]) swap(a, right, left);
|
|
97
|
+
if (a[right] < a[mid]) swap(a, right, mid);
|
|
98
|
+
const pivot = a[mid];
|
|
99
|
+
let i = left;
|
|
100
|
+
let j = right;
|
|
101
|
+
while (i <= j) {
|
|
102
|
+
while (a[i] < pivot) i++;
|
|
103
|
+
while (a[j] > pivot) j--;
|
|
104
|
+
if (i <= j) {
|
|
105
|
+
swap(a, i, j);
|
|
106
|
+
i++;
|
|
107
|
+
j--;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (k <= j) right = j;
|
|
111
|
+
else if (k >= i) left = i;
|
|
112
|
+
else return a[k];
|
|
113
|
+
}
|
|
114
|
+
return a[k];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function swap(a, i, j) {
|
|
118
|
+
const t = a[i];
|
|
119
|
+
a[i] = a[j];
|
|
120
|
+
a[j] = t;
|
|
121
|
+
}
|
|
122
|
+
|
|
73
123
|
/**
|
|
74
124
|
* Robust model-space bounds from a flat array of splat/vertex centres.
|
|
75
125
|
*
|
|
@@ -110,14 +160,15 @@ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 }
|
|
|
110
160
|
const center = [0, 0, 0];
|
|
111
161
|
const extent = [0, 0, 0];
|
|
112
162
|
const axisVals = new Float64Array(n);
|
|
163
|
+
const kLo = trim ? Math.floor(lo * (n - 1)) : 0;
|
|
164
|
+
const kHi = trim ? Math.floor(hi * (n - 1)) : n - 1;
|
|
113
165
|
for (let axis = 0; axis < 3; axis++) {
|
|
114
166
|
for (let i = 0; i < n; i++) axisVals[i] = xyz[i * 3 + axis];
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const hiV = trim ? sorted[Math.floor(hi * (n - 1))] : sorted[n - 1];
|
|
167
|
+
// Only two ORDER STATISTICS are needed, not a sorted array: selecting them is O(n) where the
|
|
168
|
+
// sort was O(n log n) — the same two values, bit for bit (a sort's k-th element IS the k-th
|
|
169
|
+
// order statistic), at a fraction of the main-thread time on a 200k-point sample (1.10.1).
|
|
170
|
+
const loV = selectKth(axisVals, n, kLo);
|
|
171
|
+
const hiV = selectKth(axisVals, n, kHi);
|
|
121
172
|
center[axis] = 0.5 * (loV + hiV);
|
|
122
173
|
extent[axis] = Math.max(hiV - loV, 1e-6);
|
|
123
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.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",
|
package/splat.d.ts
CHANGED
|
@@ -266,6 +266,11 @@ export interface SplatOptions {
|
|
|
266
266
|
* `handle.engine.root`). Anything nearer is clipped, splats included. Unset: untouched.
|
|
267
267
|
*/
|
|
268
268
|
nearClip?: number;
|
|
269
|
+
/**
|
|
270
|
+
* PlayCanvas: draw the engine's sky box (default false). Off, nothing is drawn behind the splat
|
|
271
|
+
* even when a page sets `scene.envAtlas` to light its own meshes; the canvas stays transparent.
|
|
272
|
+
*/
|
|
273
|
+
sky?: boolean;
|
|
269
274
|
/** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
|
|
270
275
|
farClip?: number;
|
|
271
276
|
/** Camera rig only: the distance in world metres that sits ON the glass. */
|