@driftengine/splats 3.61.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +56 -0
- package/dist/half.d.ts +32 -0
- package/dist/half.js +88 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +38 -0
- package/dist/shaders/generated/splat.wgsl.d.ts +89 -0
- package/dist/shaders/generated/splat.wgsl.js +95 -0
- package/dist/shaders/splat.d.ts +25 -0
- package/dist/shaders/splat.js +337 -0
- package/dist/splat.d.ts +26 -0
- package/dist/splat.js +63 -0
- package/dist/splatBudget.d.ts +40 -0
- package/dist/splatBudget.js +45 -0
- package/dist/splatCapture.d.ts +76 -0
- package/dist/splatCapture.js +108 -0
- package/dist/splatCull.d.ts +25 -0
- package/dist/splatCull.js +80 -0
- package/dist/splatData.d.ts +177 -0
- package/dist/splatData.js +223 -0
- package/dist/splatGl.d.ts +49 -0
- package/dist/splatGl.js +176 -0
- package/dist/splatGpu.d.ts +50 -0
- package/dist/splatGpu.js +180 -0
- package/dist/splatLayout.d.ts +52 -0
- package/dist/splatLayout.js +75 -0
- package/dist/splatMatrix.d.ts +29 -0
- package/dist/splatMatrix.js +68 -0
- package/dist/splatPass.d.ts +83 -0
- package/dist/splatPass.js +206 -0
- package/dist/splatPly.d.ts +14 -0
- package/dist/splatPly.js +242 -0
- package/dist/splatSog.d.ts +110 -0
- package/dist/splatSog.js +285 -0
- package/dist/splatSogDecoder.d.ts +26 -0
- package/dist/splatSogDecoder.js +29 -0
- package/dist/splatSort.d.ts +137 -0
- package/dist/splatSort.js +199 -0
- package/dist/splatSortWorker.d.ts +14 -0
- package/dist/splatSortWorker.js +137 -0
- package/dist/splatSorter.d.ts +112 -0
- package/dist/splatSorter.js +231 -0
- package/dist/splatView.d.ts +52 -0
- package/dist/splatView.js +115 -0
- package/package.json +56 -0
- package/src/fixtures/README.md +36 -0
- package/src/fixtures/cloud.sog +0 -0
- package/src/fixtures/cloud.texels.json +27 -0
- package/src/fixtures/cloud.truth.json +582 -0
- package/src/half.ts +92 -0
- package/src/index.ts +55 -0
- package/src/shaders/generated/splat.wgsl.ts +98 -0
- package/src/shaders/splat.ts +344 -0
- package/src/splat.ts +75 -0
- package/src/splatBudget.ts +48 -0
- package/src/splatCapture.ts +154 -0
- package/src/splatCull.ts +91 -0
- package/src/splatData.ts +398 -0
- package/src/splatGl.ts +262 -0
- package/src/splatGpu.ts +259 -0
- package/src/splatLayout.ts +86 -0
- package/src/splatMatrix.ts +81 -0
- package/src/splatPass.ts +324 -0
- package/src/splatPly.ts +283 -0
- package/src/splatSog.ts +375 -0
- package/src/splatSogDecoder.ts +33 -0
- package/src/splatSort.ts +296 -0
- package/src/splatSortWorker.ts +155 -0
- package/src/splatSorter.ts +285 -0
- package/src/splatView.ts +147 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordinary WebP decoder for `.sog` captures, which lives here and not beside the reader.
|
|
3
|
+
*
|
|
4
|
+
* **Its own module because it is the reader's only reason to name `@driftengine/core`**, and that
|
|
5
|
+
* import has two costs worth avoiding: a consumer supplying its own decoder should not pull core
|
|
6
|
+
* through this path, and `scripts/sog-reader.test.mjs` runs under Node's strip-only mode, where an
|
|
7
|
+
* import that resolves through a `node_modules` symlink is refused outright — which is the trap
|
|
8
|
+
* `docs/IMPROVEMENTS.md` records under proving a package from its tarball. The reader itself
|
|
9
|
+
* imports nothing but its own package, so the test loads it and this file stays out of the way.
|
|
10
|
+
*/
|
|
11
|
+
import { createImageTexelDecoder } from '@driftengine/core';
|
|
12
|
+
/**
|
|
13
|
+
* The ordinary decoder: `@driftengine/core`'s exact texel read.
|
|
14
|
+
*
|
|
15
|
+
* **A thin forward, and the reason it is not implemented here is worth reading before anybody
|
|
16
|
+
* inlines it.** Every browser API that hands back an image's pixels without a GPU premultiplies
|
|
17
|
+
* on the way through — a 2D canvas by storing premultiplied, WebCodecs by decoding a WebP with
|
|
18
|
+
* alpha to a premultiplied `BGRA` frame — so every RGB value comes back through
|
|
19
|
+
* `round(round(c * a / 255) * 255 / a)`. On the committed fixture that is 3 of 255 out on
|
|
20
|
+
* `sh0.webp`, whose RGB are *codebook indices* and whose alpha is the opacity: an index several
|
|
21
|
+
* entries away wherever a Gaussian is transparent. `createImageTexelDecoder` reads the same file
|
|
22
|
+
* exactly, and it lives in core because it is raw WebGL and `AGENTS.md` allows that only there.
|
|
23
|
+
*
|
|
24
|
+
* A consumer with its own decoder passes it instead; that is what the parameter is for.
|
|
25
|
+
*/
|
|
26
|
+
export function browserWebpDecoder() {
|
|
27
|
+
const decode = createImageTexelDecoder();
|
|
28
|
+
return async (bytes) => decode(bytes, 'image/webp');
|
|
29
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** A counting sort over a sixteen-bit depth key: no comparisons, one pass, far to near, budgeted. */
|
|
2
|
+
/** How many buckets the depth key has. A sixteen-bit key, so 65,536 of them. */
|
|
3
|
+
export declare const SPLAT_SORT_BUCKETS = 65536;
|
|
4
|
+
/**
|
|
5
|
+
* How many buckets the screen-space size key has.
|
|
6
|
+
*
|
|
7
|
+
* The key is the top sixteen bits of a positive float's own bit pattern, which reaches 32,640 for
|
|
8
|
+
* an infinite ratio — so this is the next power of two above that rather than a number with a
|
|
9
|
+
* meaning of its own. See `sortSplatsByDepth` for why those bits are a usable key at all.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SPLAT_SIZE_BUCKETS = 32768;
|
|
12
|
+
/**
|
|
13
|
+
* One sort, as the worker protocol and as the function's own parameter list.
|
|
14
|
+
*
|
|
15
|
+
* **One object rather than eleven arguments, and the worker is the reason.** `createSplatSortWorker`
|
|
16
|
+
* posts this and hands `event.data` straight to the sort, so the message shape and the call shape
|
|
17
|
+
* cannot disagree — which is the failure the 2026-08-17 rule is about, in the one place here where
|
|
18
|
+
* a decision genuinely does cross a thread boundary.
|
|
19
|
+
*/
|
|
20
|
+
export interface SplatSortRequest {
|
|
21
|
+
/** Three per splat, in the capture's own space. */
|
|
22
|
+
readonly positions: Float32Array;
|
|
23
|
+
/** One per splat: `SplatData.extents`, the largest sigma. Only read when there is a budget. */
|
|
24
|
+
readonly extents: Float32Array;
|
|
25
|
+
readonly count: number;
|
|
26
|
+
/**
|
|
27
|
+
* The camera's forward **in the capture's own space**, unit length.
|
|
28
|
+
*
|
|
29
|
+
* Not the world-space forward: a batch with a model matrix is looked at from a different
|
|
30
|
+
* direction in its own frame, and `resolveSplatView` is what converts one to the other. One
|
|
31
|
+
* sorter therefore serves one batch and two batches need no merged order.
|
|
32
|
+
*/
|
|
33
|
+
readonly dirX: number;
|
|
34
|
+
readonly dirY: number;
|
|
35
|
+
readonly dirZ: number;
|
|
36
|
+
/**
|
|
37
|
+
* The camera's position in the capture's own space.
|
|
38
|
+
*
|
|
39
|
+
* **Invisible in the order and load-bearing in the budget.** Shifting every splat by the same
|
|
40
|
+
* amount cannot reorder them, so depth alone never needed this; the size key is `extent` over
|
|
41
|
+
* the *distance*, and a distance needs a point to measure from.
|
|
42
|
+
*/
|
|
43
|
+
readonly originX: number;
|
|
44
|
+
readonly originY: number;
|
|
45
|
+
readonly originZ: number;
|
|
46
|
+
/** The most splats to keep. Zero, or anything at or above `count`, keeps all of them. */
|
|
47
|
+
readonly budget: number;
|
|
48
|
+
/** The buffer to fill and hand back, so the pair can ping-pong rather than allocate. */
|
|
49
|
+
readonly out: Uint32Array;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What a sort answers with: the filled buffer, and how many entries of it are real.
|
|
53
|
+
*
|
|
54
|
+
* **The count is not the request's count once a budget bites**, and it comes back rather than
|
|
55
|
+
* being recomputed, because only the sort knows how its ration of the straddling size bucket
|
|
56
|
+
* fell out. A caller that assumed the capture's own count would walk off the end of a valid order
|
|
57
|
+
* into whatever the buffer held before — stale indices drawn as splats, which reads as a corrupt
|
|
58
|
+
* capture rather than as an off-by-one.
|
|
59
|
+
*/
|
|
60
|
+
export interface SplatSortResult {
|
|
61
|
+
/** The buffer that was handed in, or the same storage after a worker transferred it twice. */
|
|
62
|
+
readonly order: Uint32Array;
|
|
63
|
+
readonly count: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* How a sort actually happens.
|
|
67
|
+
*
|
|
68
|
+
* **A capability the caller may replace, which is `AGENTS.md`'s rule about platform APIs and not a
|
|
69
|
+
* convenience.** The shipped default is a worker; a consumer under a strict CSP, in a test, or on
|
|
70
|
+
* a runtime with no `Worker` at all supplies its own and nothing here has to know.
|
|
71
|
+
*/
|
|
72
|
+
export type SplatSortFn = (request: SplatSortRequest) => Promise<SplatSortResult>;
|
|
73
|
+
/**
|
|
74
|
+
* The arrays a sort reuses, allocated once by the caller.
|
|
75
|
+
*
|
|
76
|
+
* **A sort allocates nothing**, which is the whole reason this is a parameter rather than a local:
|
|
77
|
+
* the two histograms are 384 KB between them, and a capture re-sorts whenever the view turns. Doing
|
|
78
|
+
* that in a worker does not make an allocation free — it makes it somebody else's garbage.
|
|
79
|
+
*/
|
|
80
|
+
export interface SplatSortScratch {
|
|
81
|
+
/** The depth bucket of each splat, carried from the counting pass to the scatter. */
|
|
82
|
+
readonly keys: Uint16Array;
|
|
83
|
+
/** The depth histogram, then the prefix sum over it. */
|
|
84
|
+
readonly counts: Uint32Array;
|
|
85
|
+
/** The size bucket of each splat, overwritten by the counting pass with whether it survived. */
|
|
86
|
+
readonly sizes: Uint16Array;
|
|
87
|
+
/** The size histogram, walked from the top to find the budget's threshold. */
|
|
88
|
+
readonly sizeCounts: Uint32Array;
|
|
89
|
+
/**
|
|
90
|
+
* Two views of one four-byte buffer, for reading a float's bits without allocating a view.
|
|
91
|
+
*
|
|
92
|
+
* A pair rather than a `DataView` because this is read once per splat per sort and the typed
|
|
93
|
+
* pair is one store and one load. It is in the scratch rather than at module scope for the same
|
|
94
|
+
* reason everything else here is: the sort is stringified into a worker and closes over nothing.
|
|
95
|
+
*/
|
|
96
|
+
readonly bits: Float32Array;
|
|
97
|
+
readonly bitsAsUint: Uint32Array;
|
|
98
|
+
}
|
|
99
|
+
export declare function createSplatSortScratch(count: number): SplatSortScratch;
|
|
100
|
+
/**
|
|
101
|
+
* Order the splats worth drawing far to near, writing indices into `request.out`.
|
|
102
|
+
*
|
|
103
|
+
* Returns how many were written, which is `count` unless a budget cut it.
|
|
104
|
+
*
|
|
105
|
+
* **Far to near, and the direction is the one thing a test can catch that a screenshot cannot.**
|
|
106
|
+
* A near-to-far order looks *plausible* — the cloud is still a cloud — and is wrong at every
|
|
107
|
+
* silhouette, because `over` compositing is not commutative. `dir` is the camera's forward in the
|
|
108
|
+
* capture's own space, so a larger projection along it is farther away and this sorts descending.
|
|
109
|
+
*
|
|
110
|
+
* **A counting sort rather than a comparison sort**, which is what makes it affordable at a
|
|
111
|
+
* million splats: one pass for the range, one to bucket, one prefix sum, one to scatter, all
|
|
112
|
+
* linear. `Array.prototype.sort` on a million indices is tens of milliseconds and allocates.
|
|
113
|
+
*
|
|
114
|
+
* **Sixteen bits is 65,536 distinct depths across the capture's whole extent.** At a capture ten
|
|
115
|
+
* metres deep that is 0.15 mm a bucket, far below what any ordering error could show. What it
|
|
116
|
+
* gives up is exactness: two splats inside one bucket keep their input order rather than their
|
|
117
|
+
* true order, which is a tie broken arbitrarily and invisible. What would make it wrong is a
|
|
118
|
+
* capture whose depth range is dominated by one distant outlier, which squeezes everything else
|
|
119
|
+
* into a handful of buckets — the same failure a histogram always has, and the reason the range
|
|
120
|
+
* is measured rather than assumed.
|
|
121
|
+
*
|
|
122
|
+
* **A budget selects by screen-space size, and taking a prefix of the order instead would be
|
|
123
|
+
* wrong in a way that looks deliberate.** The order is far to near, so its front is the far end:
|
|
124
|
+
* `splatBudget.test.ts` measures that, rather than leaving it to be recalled. A prefix therefore
|
|
125
|
+
* keeps the distant half of a capture and throws away everything close to the camera — the splats
|
|
126
|
+
* covering the most pixels, and most of what a viewer is looking at. A suffix inverts the mistake
|
|
127
|
+
* and drops the backdrop. Neither is a budget; both are a capture with a piece missing.
|
|
128
|
+
*
|
|
129
|
+
* So the splats kept are the ones largest **on screen**, which is the extent over the distance,
|
|
130
|
+
* and the ones dropped are by construction smaller than every one kept. What that gives up is
|
|
131
|
+
* stability: as the camera closes on a capture, splats cross the threshold and appear, which is
|
|
132
|
+
* a pop bounded by the size of the smallest splat still being drawn — around a pixel at any
|
|
133
|
+
* budget worth setting, and unmissable at a budget of a few thousand. What would make it wrong is
|
|
134
|
+
* a capture of very flat splats seen edge-on, where the largest sigma over-estimates the pixels
|
|
135
|
+
* covered; `SplatData.extents` carries that same caveat, because it is the same approximation.
|
|
136
|
+
*/
|
|
137
|
+
export declare function sortSplatsByDepth(request: SplatSortRequest, scratch: SplatSortScratch): number;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** A counting sort over a sixteen-bit depth key: no comparisons, one pass, far to near, budgeted. */
|
|
2
|
+
/** How many buckets the depth key has. A sixteen-bit key, so 65,536 of them. */
|
|
3
|
+
export const SPLAT_SORT_BUCKETS = 65536;
|
|
4
|
+
/**
|
|
5
|
+
* How many buckets the screen-space size key has.
|
|
6
|
+
*
|
|
7
|
+
* The key is the top sixteen bits of a positive float's own bit pattern, which reaches 32,640 for
|
|
8
|
+
* an infinite ratio — so this is the next power of two above that rather than a number with a
|
|
9
|
+
* meaning of its own. See `sortSplatsByDepth` for why those bits are a usable key at all.
|
|
10
|
+
*/
|
|
11
|
+
export const SPLAT_SIZE_BUCKETS = 32768;
|
|
12
|
+
export function createSplatSortScratch(count) {
|
|
13
|
+
const bits = new ArrayBuffer(4);
|
|
14
|
+
return {
|
|
15
|
+
keys: new Uint16Array(Math.max(1, count)),
|
|
16
|
+
counts: new Uint32Array(SPLAT_SORT_BUCKETS),
|
|
17
|
+
sizes: new Uint16Array(Math.max(1, count)),
|
|
18
|
+
sizeCounts: new Uint32Array(SPLAT_SIZE_BUCKETS),
|
|
19
|
+
bits: new Float32Array(bits),
|
|
20
|
+
bitsAsUint: new Uint32Array(bits),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Order the splats worth drawing far to near, writing indices into `request.out`.
|
|
25
|
+
*
|
|
26
|
+
* Returns how many were written, which is `count` unless a budget cut it.
|
|
27
|
+
*
|
|
28
|
+
* **Far to near, and the direction is the one thing a test can catch that a screenshot cannot.**
|
|
29
|
+
* A near-to-far order looks *plausible* — the cloud is still a cloud — and is wrong at every
|
|
30
|
+
* silhouette, because `over` compositing is not commutative. `dir` is the camera's forward in the
|
|
31
|
+
* capture's own space, so a larger projection along it is farther away and this sorts descending.
|
|
32
|
+
*
|
|
33
|
+
* **A counting sort rather than a comparison sort**, which is what makes it affordable at a
|
|
34
|
+
* million splats: one pass for the range, one to bucket, one prefix sum, one to scatter, all
|
|
35
|
+
* linear. `Array.prototype.sort` on a million indices is tens of milliseconds and allocates.
|
|
36
|
+
*
|
|
37
|
+
* **Sixteen bits is 65,536 distinct depths across the capture's whole extent.** At a capture ten
|
|
38
|
+
* metres deep that is 0.15 mm a bucket, far below what any ordering error could show. What it
|
|
39
|
+
* gives up is exactness: two splats inside one bucket keep their input order rather than their
|
|
40
|
+
* true order, which is a tie broken arbitrarily and invisible. What would make it wrong is a
|
|
41
|
+
* capture whose depth range is dominated by one distant outlier, which squeezes everything else
|
|
42
|
+
* into a handful of buckets — the same failure a histogram always has, and the reason the range
|
|
43
|
+
* is measured rather than assumed.
|
|
44
|
+
*
|
|
45
|
+
* **A budget selects by screen-space size, and taking a prefix of the order instead would be
|
|
46
|
+
* wrong in a way that looks deliberate.** The order is far to near, so its front is the far end:
|
|
47
|
+
* `splatBudget.test.ts` measures that, rather than leaving it to be recalled. A prefix therefore
|
|
48
|
+
* keeps the distant half of a capture and throws away everything close to the camera — the splats
|
|
49
|
+
* covering the most pixels, and most of what a viewer is looking at. A suffix inverts the mistake
|
|
50
|
+
* and drops the backdrop. Neither is a budget; both are a capture with a piece missing.
|
|
51
|
+
*
|
|
52
|
+
* So the splats kept are the ones largest **on screen**, which is the extent over the distance,
|
|
53
|
+
* and the ones dropped are by construction smaller than every one kept. What that gives up is
|
|
54
|
+
* stability: as the camera closes on a capture, splats cross the threshold and appear, which is
|
|
55
|
+
* a pop bounded by the size of the smallest splat still being drawn — around a pixel at any
|
|
56
|
+
* budget worth setting, and unmissable at a budget of a few thousand. What would make it wrong is
|
|
57
|
+
* a capture of very flat splats seen edge-on, where the largest sigma over-estimates the pixels
|
|
58
|
+
* covered; `SplatData.extents` carries that same caveat, because it is the same approximation.
|
|
59
|
+
*/
|
|
60
|
+
export function sortSplatsByDepth(request, scratch) {
|
|
61
|
+
const { positions, extents, count, dirX, dirY, dirZ, originX, originY, originZ, out } = request;
|
|
62
|
+
if (count <= 0)
|
|
63
|
+
return 0;
|
|
64
|
+
const { keys, counts, sizes, sizeCounts, bits, bitsAsUint } = scratch;
|
|
65
|
+
/*
|
|
66
|
+
* **The bucket counts come from the scratch, not from the module constants, and that is what
|
|
67
|
+
* lets this function be stringified into a worker.** `createSplatSortWorker` ships
|
|
68
|
+
* `sortSplatsByDepth.toString()` so there is one implementation of the ordering rather than two
|
|
69
|
+
* — and a stringified function loses its scope, so any module-scope binding it referenced would
|
|
70
|
+
* be an undefined identifier the moment a bundler renamed or dropped it. Reading `.length`
|
|
71
|
+
* closes over nothing. `splatSort.test.ts` asserts the source is free of such references.
|
|
72
|
+
*/
|
|
73
|
+
const buckets = counts.length;
|
|
74
|
+
/* Normalised to zero, so every test below is against one number rather than against two. */
|
|
75
|
+
const budget = request.budget > 0 && request.budget < count ? request.budget : 0;
|
|
76
|
+
let min = Infinity;
|
|
77
|
+
let max = -Infinity;
|
|
78
|
+
if (budget > 0)
|
|
79
|
+
sizeCounts.fill(0);
|
|
80
|
+
for (let index = 0; index < count; index++) {
|
|
81
|
+
const p = index * 3;
|
|
82
|
+
const depth = ((positions[p] ?? 0) - originX) * dirX +
|
|
83
|
+
((positions[p + 1] ?? 0) - originY) * dirY +
|
|
84
|
+
((positions[p + 2] ?? 0) - originZ) * dirZ;
|
|
85
|
+
if (depth < min)
|
|
86
|
+
min = depth;
|
|
87
|
+
if (depth > max)
|
|
88
|
+
max = depth;
|
|
89
|
+
if (budget > 0) {
|
|
90
|
+
/*
|
|
91
|
+
* The screen-space radius is the focal length times `extent / depth`, and the focal length
|
|
92
|
+
* is the same for every splat in a frame — so it drops out of a ranking and this is the
|
|
93
|
+
* whole key.
|
|
94
|
+
*
|
|
95
|
+
* **The key is the top sixteen bits of the ratio's own float bits**, which for a positive
|
|
96
|
+
* float is exactly monotone: IEEE 754 lays out sign, then exponent, then mantissa, so a
|
|
97
|
+
* larger positive float has a larger bit pattern, and taking the high half keeps the sign,
|
|
98
|
+
* all eight exponent bits and seven of the mantissa. That is a logarithmic quantisation —
|
|
99
|
+
* 128 buckets an octave, so a threshold lands within half a percent of the ideal size cut —
|
|
100
|
+
* at the cost of one store and one load rather than a `Math.log2` per splat per sort. What
|
|
101
|
+
* would make it wrong is a negative key, which cannot happen: the guard below sends
|
|
102
|
+
* everything that is not a positive ratio to bucket zero.
|
|
103
|
+
*
|
|
104
|
+
* A splat at or behind the camera has no screen-space size, so `depth <= 0` fails
|
|
105
|
+
* `ratio > 0` and lands in bucket zero, where the budget drops it first. So does a `NaN`,
|
|
106
|
+
* because every comparison against one is false — which is the honest answer for a splat
|
|
107
|
+
* whose size cannot be computed.
|
|
108
|
+
*/
|
|
109
|
+
const ratio = (extents[index] ?? 0) / depth;
|
|
110
|
+
let key = 0;
|
|
111
|
+
if (ratio > 0) {
|
|
112
|
+
bits[0] = ratio;
|
|
113
|
+
key = (bitsAsUint[0] ?? 0) >>> 16;
|
|
114
|
+
}
|
|
115
|
+
sizes[index] = key;
|
|
116
|
+
sizeCounts[key] = (sizeCounts[key] ?? 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/*
|
|
120
|
+
* The threshold, and the quota that trims the bucket straddling it.
|
|
121
|
+
*
|
|
122
|
+
* Walking down from the largest size, `above` is everything strictly bigger than `threshold`
|
|
123
|
+
* and fits inside the budget by construction. The bucket that would overflow it is *included*
|
|
124
|
+
* and then rationed: `quota` splats from it are admitted in index order and the rest are not.
|
|
125
|
+
* Without that ration a capture whose splats are all one size — which is most synthetic ones,
|
|
126
|
+
* and any capture from a fixed-scale exporter — would put every splat in one bucket and the
|
|
127
|
+
* threshold alone would answer either everything or nothing. Nothing is a blank screen on
|
|
128
|
+
* exactly the device the budget exists for.
|
|
129
|
+
*/
|
|
130
|
+
let threshold = 0;
|
|
131
|
+
let quota = 0;
|
|
132
|
+
if (budget > 0) {
|
|
133
|
+
let above = 0;
|
|
134
|
+
for (let bucket = sizeCounts.length - 1; bucket >= 0; bucket--) {
|
|
135
|
+
const howMany = sizeCounts[bucket] ?? 0;
|
|
136
|
+
threshold = bucket;
|
|
137
|
+
if (above + howMany > budget)
|
|
138
|
+
break;
|
|
139
|
+
above += howMany;
|
|
140
|
+
}
|
|
141
|
+
quota = budget - above;
|
|
142
|
+
}
|
|
143
|
+
/*
|
|
144
|
+
* A capture with no depth range at all — one splat, or a plane exactly side-on. Every key would
|
|
145
|
+
* be a division by zero, so the scale is zero and every splat lands in bucket 0 in input order.
|
|
146
|
+
* That is a legal answer: with no range there is no ordering to get wrong.
|
|
147
|
+
*
|
|
148
|
+
* The range spans every splat rather than only the ones inside the budget, because it is
|
|
149
|
+
* measured before the threshold is known. What that costs is depth resolution when a budget
|
|
150
|
+
* discards a distant tail; at 65,536 buckets there is a great deal to spare.
|
|
151
|
+
*/
|
|
152
|
+
const range = max - min;
|
|
153
|
+
const scale = range > 0 ? (buckets - 1) / range : 0;
|
|
154
|
+
counts.fill(0);
|
|
155
|
+
let admitted = 0;
|
|
156
|
+
let remaining = quota;
|
|
157
|
+
for (let index = 0; index < count; index++) {
|
|
158
|
+
if (budget > 0) {
|
|
159
|
+
const size = sizes[index] ?? 0;
|
|
160
|
+
const keep = size > threshold || (size === threshold && remaining > 0);
|
|
161
|
+
if (keep && size === threshold)
|
|
162
|
+
remaining--;
|
|
163
|
+
/* Overwritten with the verdict, so the scatter admits exactly this set without re-running
|
|
164
|
+
the ration. The size itself is not wanted again. */
|
|
165
|
+
sizes[index] = keep ? 1 : 0;
|
|
166
|
+
if (!keep)
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const p = index * 3;
|
|
170
|
+
const depth = ((positions[p] ?? 0) - originX) * dirX +
|
|
171
|
+
((positions[p + 1] ?? 0) - originY) * dirY +
|
|
172
|
+
((positions[p + 2] ?? 0) - originZ) * dirZ;
|
|
173
|
+
/*
|
|
174
|
+
* Inverted, so that an *ascending* bucket walk comes out far to near. Doing it here rather
|
|
175
|
+
* than by walking the histogram backwards keeps the scatter a plain forward loop, and a
|
|
176
|
+
* backwards scatter is where a stable sort quietly stops being stable.
|
|
177
|
+
*/
|
|
178
|
+
const bucket = buckets - 1 - Math.round((depth - min) * scale);
|
|
179
|
+
keys[index] = bucket;
|
|
180
|
+
counts[bucket] = (counts[bucket] ?? 0) + 1;
|
|
181
|
+
admitted++;
|
|
182
|
+
}
|
|
183
|
+
/* Prefix sum in place: each bucket becomes where its first splat goes. */
|
|
184
|
+
let running = 0;
|
|
185
|
+
for (let bucket = 0; bucket < buckets; bucket++) {
|
|
186
|
+
const howMany = counts[bucket] ?? 0;
|
|
187
|
+
counts[bucket] = running;
|
|
188
|
+
running += howMany;
|
|
189
|
+
}
|
|
190
|
+
for (let index = 0; index < count; index++) {
|
|
191
|
+
if (budget > 0 && sizes[index] === 0)
|
|
192
|
+
continue;
|
|
193
|
+
const bucket = keys[index] ?? 0;
|
|
194
|
+
const at = counts[bucket] ?? 0;
|
|
195
|
+
out[at] = index;
|
|
196
|
+
counts[bucket] = at + 1;
|
|
197
|
+
}
|
|
198
|
+
return admitted;
|
|
199
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The shipped sort capability: a worker built from a Blob, falling back to the main thread aloud. */
|
|
2
|
+
import type { SplatSortFn } from './splatSort.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Build the default sort capability.
|
|
5
|
+
*
|
|
6
|
+
* **Falls back to the main thread aloud, once.** A strict CSP without `worker-src blob:` refuses
|
|
7
|
+
* the construction, and so does any runtime with no `Worker` at all. A capture that stutters is
|
|
8
|
+
* better than one that does not draw — and a *silent* fallback is what `capabilityClamp` exists to
|
|
9
|
+
* prevent, because the symptom is then a frame rate nobody can attribute.
|
|
10
|
+
*
|
|
11
|
+
* The sort's source is passed in rather than imported here so this module has no opinion about
|
|
12
|
+
* where the function lives; `createDefaultSplatSort` supplies it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createSplatSortWorker(sortSource: string): SplatSortFn;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** The shipped sort capability: a worker built from a Blob, falling back to the main thread aloud. */
|
|
2
|
+
import { sortOnMainThread } from './splatSorter.js';
|
|
3
|
+
import { SPLAT_SIZE_BUCKETS, SPLAT_SORT_BUCKETS } from './splatSort.js';
|
|
4
|
+
/**
|
|
5
|
+
* The sort, as source, for a worker that has no module graph to import from.
|
|
6
|
+
*
|
|
7
|
+
* **Stringified rather than a second file, and that is the no-build-step contract.** This engine
|
|
8
|
+
* ships TypeScript source and consumers bundle it; `new Worker(new URL('./x.ts', import.meta.url))`
|
|
9
|
+
* asks every consumer's bundler to know about a worker entry point, which is a build step by
|
|
10
|
+
* another name. A `Blob` needs nothing of anybody.
|
|
11
|
+
*
|
|
12
|
+
* **What it costs is a copy of the sort that no test can reach**, so the two could drift — which is
|
|
13
|
+
* exactly what the 2026-08-17 rule is about. It does not drift here because it is not written
|
|
14
|
+
* twice: `sortSplatsByDepth.toString()` is interpolated below, so the worker runs the same
|
|
15
|
+
* function the main thread does, and a change to that function changes both. What would make it
|
|
16
|
+
* wrong is that function closing over a module-scope binding, since a stringified closure loses
|
|
17
|
+
* its scope — which is why it takes its scratch as a parameter and its bucket counts as part of it.
|
|
18
|
+
*
|
|
19
|
+
* **The capture is cached in the worker and re-sent only when it changes.** Positions are twelve
|
|
20
|
+
* bytes a splat and extents four, so posting them with every sort would structured-clone sixteen
|
|
21
|
+
* megabytes for a million-splat capture — several times a second while a viewer turns, on the
|
|
22
|
+
* device this whole arrangement exists for. The sender omits them when the capture is the one the
|
|
23
|
+
* worker already holds, and `null` is what says so.
|
|
24
|
+
*/
|
|
25
|
+
function workerSource(sortSource) {
|
|
26
|
+
return `
|
|
27
|
+
const SPLAT_SORT_BUCKETS = ${SPLAT_SORT_BUCKETS};
|
|
28
|
+
const SPLAT_SIZE_BUCKETS = ${SPLAT_SIZE_BUCKETS};
|
|
29
|
+
const sortSplatsByDepth = ${sortSource};
|
|
30
|
+
let scratch = null;
|
|
31
|
+
let positions = null;
|
|
32
|
+
let extents = null;
|
|
33
|
+
self.onmessage = (event) => {
|
|
34
|
+
const request = event.data;
|
|
35
|
+
if (request.positions !== null) positions = request.positions;
|
|
36
|
+
if (request.extents !== null) extents = request.extents;
|
|
37
|
+
const count = request.count;
|
|
38
|
+
if (scratch === null || scratch.keys.length < count) {
|
|
39
|
+
const bits = new ArrayBuffer(4);
|
|
40
|
+
scratch = {
|
|
41
|
+
keys: new Uint16Array(Math.max(1, count)),
|
|
42
|
+
counts: new Uint32Array(SPLAT_SORT_BUCKETS),
|
|
43
|
+
sizes: new Uint16Array(Math.max(1, count)),
|
|
44
|
+
sizeCounts: new Uint32Array(SPLAT_SIZE_BUCKETS),
|
|
45
|
+
bits: new Float32Array(bits),
|
|
46
|
+
bitsAsUint: new Uint32Array(bits),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
request.positions = positions;
|
|
50
|
+
request.extents = extents;
|
|
51
|
+
const kept = sortSplatsByDepth(request, scratch);
|
|
52
|
+
/* Transferred back, so the pair ping-pongs rather than allocating a result a frame. */
|
|
53
|
+
self.postMessage({ order: request.out, count: kept }, [request.out.buffer]);
|
|
54
|
+
};
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the default sort capability.
|
|
59
|
+
*
|
|
60
|
+
* **Falls back to the main thread aloud, once.** A strict CSP without `worker-src blob:` refuses
|
|
61
|
+
* the construction, and so does any runtime with no `Worker` at all. A capture that stutters is
|
|
62
|
+
* better than one that does not draw — and a *silent* fallback is what `capabilityClamp` exists to
|
|
63
|
+
* prevent, because the symptom is then a frame rate nobody can attribute.
|
|
64
|
+
*
|
|
65
|
+
* The sort's source is passed in rather than imported here so this module has no opinion about
|
|
66
|
+
* where the function lives; `createDefaultSplatSort` supplies it.
|
|
67
|
+
*/
|
|
68
|
+
export function createSplatSortWorker(sortSource) {
|
|
69
|
+
let worker = null;
|
|
70
|
+
let warned = false;
|
|
71
|
+
const fallback = (reason) => {
|
|
72
|
+
if (!warned) {
|
|
73
|
+
warned = true;
|
|
74
|
+
console.warn(`splats: sorting on the main thread — ${reason}. A capture will stutter while the view ` +
|
|
75
|
+
'turns. Supply your own `sort` capability to put it somewhere else.');
|
|
76
|
+
}
|
|
77
|
+
return sortOnMainThread;
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
if (typeof Worker === 'undefined' ||
|
|
81
|
+
typeof Blob === 'undefined' ||
|
|
82
|
+
typeof URL === 'undefined') {
|
|
83
|
+
return fallback('this runtime has no Worker');
|
|
84
|
+
}
|
|
85
|
+
const url = URL.createObjectURL(new Blob([workerSource(sortSource)], { type: 'text/javascript' }));
|
|
86
|
+
worker = new Worker(url);
|
|
87
|
+
/* The blob is referenced by the live worker; the URL is only the handle. */
|
|
88
|
+
URL.revokeObjectURL(url);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return fallback(String(error.message ?? error));
|
|
92
|
+
}
|
|
93
|
+
const live = worker;
|
|
94
|
+
/* Identity, not a copy: `SplatData` owns these arrays for its lifetime, so the same reference
|
|
95
|
+
arriving twice is exactly the statement that the worker's copy is still current. */
|
|
96
|
+
let sentPositions = null;
|
|
97
|
+
let sentExtents = null;
|
|
98
|
+
return (request) => new Promise((resolve, reject) => {
|
|
99
|
+
const onMessage = (event) => {
|
|
100
|
+
live.removeEventListener('message', onMessage);
|
|
101
|
+
live.removeEventListener('error', onError);
|
|
102
|
+
resolve(event.data);
|
|
103
|
+
};
|
|
104
|
+
const onError = (event) => {
|
|
105
|
+
live.removeEventListener('message', onMessage);
|
|
106
|
+
live.removeEventListener('error', onError);
|
|
107
|
+
/* The worker never learned this capture, so the next attempt must send it again. */
|
|
108
|
+
sentPositions = null;
|
|
109
|
+
sentExtents = null;
|
|
110
|
+
reject(new Error(`splats: the sort worker failed — ${event.message}`));
|
|
111
|
+
};
|
|
112
|
+
live.addEventListener('message', onMessage);
|
|
113
|
+
live.addEventListener('error', onError);
|
|
114
|
+
const positions = request.positions === sentPositions ? null : request.positions;
|
|
115
|
+
const extents = request.extents === sentExtents ? null : request.extents;
|
|
116
|
+
sentPositions = request.positions;
|
|
117
|
+
sentExtents = request.extents;
|
|
118
|
+
/*
|
|
119
|
+
* `out` is transferred and the capture is copied at most once. Copying rather than
|
|
120
|
+
* transferring the capture is the price of not owning it: the positions belong to the
|
|
121
|
+
* `SplatData` and the main thread reads them for a fallback sort.
|
|
122
|
+
*/
|
|
123
|
+
live.postMessage({
|
|
124
|
+
positions,
|
|
125
|
+
extents,
|
|
126
|
+
count: request.count,
|
|
127
|
+
dirX: request.dirX,
|
|
128
|
+
dirY: request.dirY,
|
|
129
|
+
dirZ: request.dirZ,
|
|
130
|
+
originX: request.originX,
|
|
131
|
+
originY: request.originY,
|
|
132
|
+
originZ: request.originZ,
|
|
133
|
+
budget: request.budget,
|
|
134
|
+
out: request.out,
|
|
135
|
+
}, [request.out.buffer]);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** The scheduler: one sort in flight, newest view wins, and the worker is a default not a mechanism. */
|
|
2
|
+
import type { SplatSortFn, SplatSortRequest, SplatSortResult } from './splatSort.ts';
|
|
3
|
+
import type { SplatData } from './splatData.ts';
|
|
4
|
+
import type { SplatViewLocal } from './splatView.ts';
|
|
5
|
+
export interface SplatSorterOptions {
|
|
6
|
+
/** The capture. Its positions, extents and bounds are all read; its packed texels are not. */
|
|
7
|
+
readonly splats: SplatData;
|
|
8
|
+
/** Defaults to a `Blob` worker, falling back to the main thread. See `createDefaultSplatSort`. */
|
|
9
|
+
readonly sort?: SplatSortFn;
|
|
10
|
+
/**
|
|
11
|
+
* The most splats to draw. Zero, or anything at or above the capture's count, draws all of them.
|
|
12
|
+
*
|
|
13
|
+
* `defaultSplatBudget` is what a consumer that has no number of its own should pass.
|
|
14
|
+
*/
|
|
15
|
+
readonly budget?: number;
|
|
16
|
+
/** Overrides `RESORT_MOVE_FRACTION`. Only consulted when there is a budget. */
|
|
17
|
+
readonly moveFraction?: number;
|
|
18
|
+
/**
|
|
19
|
+
* How many of the capture's splats have actually arrived. Defaults to all of them.
|
|
20
|
+
*
|
|
21
|
+
* **A capability rather than a number, because it changes while a capture streams.** A
|
|
22
|
+
* `SplatCapture` is allocated at its final count and filled block by block, so its unarrived
|
|
23
|
+
* tail is zeroed — every phantom splat sitting at the origin with no size. Sorting those would
|
|
24
|
+
* order a cloud of them into the middle of the capture and draw them, which reads as a corrupt
|
|
25
|
+
* file rather than as a load in progress. Pass `() => capture.ready` and this follows the load.
|
|
26
|
+
*/
|
|
27
|
+
readonly ready?: () => number;
|
|
28
|
+
}
|
|
29
|
+
export declare function sortOnMainThread(request: SplatSortRequest): Promise<SplatSortResult>;
|
|
30
|
+
/**
|
|
31
|
+
* The shipped capability: a worker over the same sort this module runs on the main thread.
|
|
32
|
+
*
|
|
33
|
+
* `sortSplatsByDepth.toString()` is what goes into the worker, so there is one implementation of
|
|
34
|
+
* the decision and not two — the 2026-08-17 rule, which is about exactly this. It works because
|
|
35
|
+
* that function closes over nothing: its scratch and its two bucket counts all arrive as
|
|
36
|
+
* arguments.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createDefaultSplatSort(): SplatSortFn;
|
|
39
|
+
/**
|
|
40
|
+
* Hold the newest order, ask for a new one when the view has changed enough, and never queue.
|
|
41
|
+
*
|
|
42
|
+
* **At most one sort in flight, and a request while one is running is dropped rather than
|
|
43
|
+
* queued.** The newest view is the only one worth sorting for: a queue would sort for a camera
|
|
44
|
+
* position the player has already left, and then sort again, so it converts a busy moment into a
|
|
45
|
+
* backlog that never catches up.
|
|
46
|
+
*
|
|
47
|
+
* **Two buffers, ping-ponged.** One is being filled by the sorter and the other is what the pass
|
|
48
|
+
* draws from, so a sort landing mid-frame never rewrites the order under a draw call.
|
|
49
|
+
*/
|
|
50
|
+
export declare class SplatSorter {
|
|
51
|
+
private readonly splats;
|
|
52
|
+
private readonly sort;
|
|
53
|
+
private readonly budget;
|
|
54
|
+
private readonly moveFraction;
|
|
55
|
+
private readonly ready;
|
|
56
|
+
/** How many splats the standing order was computed over, so an arrival is a reason to re-sort. */
|
|
57
|
+
private sortedReady;
|
|
58
|
+
/** Half the diagonal of the capture's own bounds. The scale the move gate is a fraction of. */
|
|
59
|
+
private readonly radius;
|
|
60
|
+
private readonly buffers;
|
|
61
|
+
/** Which buffer the sorter owns. The other is the one a caller may read. */
|
|
62
|
+
private filling;
|
|
63
|
+
private inFlight;
|
|
64
|
+
/** The view the standing order was computed for. Meaningless until `hasSorted`. */
|
|
65
|
+
private sortedDirX;
|
|
66
|
+
private sortedDirY;
|
|
67
|
+
private sortedDirZ;
|
|
68
|
+
private sortedOriginX;
|
|
69
|
+
private sortedOriginY;
|
|
70
|
+
private sortedOriginZ;
|
|
71
|
+
private hasSorted;
|
|
72
|
+
/** How many indices the standing order actually holds, which a budget makes smaller. */
|
|
73
|
+
private kept;
|
|
74
|
+
/** Bumped by every sort that lands, so a caller can tell a new order from the one it has. */
|
|
75
|
+
private generation;
|
|
76
|
+
constructor(options: SplatSorterOptions);
|
|
77
|
+
/** The standing order, or null until the first sort lands. */
|
|
78
|
+
get order(): Uint32Array | null;
|
|
79
|
+
/** How many entries of `order` are real. Below the capture's count when a budget bit. */
|
|
80
|
+
get drawCount(): number;
|
|
81
|
+
/** How many orders have landed. A caller uploads when this changes and not otherwise. */
|
|
82
|
+
get version(): number;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a sort is in flight.
|
|
85
|
+
*
|
|
86
|
+
* **What a measuring page waits on, and it has to wait on more than `version`.** With a budget
|
|
87
|
+
* the sort chooses *which* splats are drawn and not merely their order, so a capture
|
|
88
|
+
* photographed at the first landed sort is a capture photographed at whichever sort the worker
|
|
89
|
+
* happened to finish — and two backends that reach the held frame at different wall-clock times
|
|
90
|
+
* then draw different splats. A held camera is settled when this is false and the standing
|
|
91
|
+
* `version` has been uploaded: at a fixed direction `frame` asks for nothing further, so that
|
|
92
|
+
* state is reached and then keeps.
|
|
93
|
+
*/
|
|
94
|
+
get sorting(): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Offer the current view, in the capture's own space. Starts a sort if one is wanted.
|
|
97
|
+
*
|
|
98
|
+
* `resolveSplatView` is what turns a camera and a model matrix into this. `force` is for the
|
|
99
|
+
* first frame and for a capture whose transform moved, where the view in its own space may be
|
|
100
|
+
* unchanged and the order is stale anyway.
|
|
101
|
+
*
|
|
102
|
+
* **Turning always matters; moving only matters when there is a budget.** Depth is measured
|
|
103
|
+
* along the view axis, so translating the camera shifts every splat's depth by the same amount
|
|
104
|
+
* and leaves the ordering exactly as it was — which is why this gate did not exist until a
|
|
105
|
+
* budget did. A budget keeps the splats largest on screen, which is the extent over the
|
|
106
|
+
* *distance*, so a camera that walks across a capture without turning changes which splats are
|
|
107
|
+
* drawn. What that costs is a sort every tenth of a capture-radius while a viewer moves; what
|
|
108
|
+
* would make it wrong is a budget so generous that nothing is ever dropped, where the gate spends
|
|
109
|
+
* sorts to reach the same answer.
|
|
110
|
+
*/
|
|
111
|
+
frame(local: SplatViewLocal, force?: boolean): void;
|
|
112
|
+
}
|