@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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +56 -0
  4. package/dist/half.d.ts +32 -0
  5. package/dist/half.js +88 -0
  6. package/dist/index.d.ts +32 -0
  7. package/dist/index.js +38 -0
  8. package/dist/shaders/generated/splat.wgsl.d.ts +89 -0
  9. package/dist/shaders/generated/splat.wgsl.js +95 -0
  10. package/dist/shaders/splat.d.ts +25 -0
  11. package/dist/shaders/splat.js +337 -0
  12. package/dist/splat.d.ts +26 -0
  13. package/dist/splat.js +63 -0
  14. package/dist/splatBudget.d.ts +40 -0
  15. package/dist/splatBudget.js +45 -0
  16. package/dist/splatCapture.d.ts +76 -0
  17. package/dist/splatCapture.js +108 -0
  18. package/dist/splatCull.d.ts +25 -0
  19. package/dist/splatCull.js +80 -0
  20. package/dist/splatData.d.ts +177 -0
  21. package/dist/splatData.js +223 -0
  22. package/dist/splatGl.d.ts +49 -0
  23. package/dist/splatGl.js +176 -0
  24. package/dist/splatGpu.d.ts +50 -0
  25. package/dist/splatGpu.js +180 -0
  26. package/dist/splatLayout.d.ts +52 -0
  27. package/dist/splatLayout.js +75 -0
  28. package/dist/splatMatrix.d.ts +29 -0
  29. package/dist/splatMatrix.js +68 -0
  30. package/dist/splatPass.d.ts +83 -0
  31. package/dist/splatPass.js +206 -0
  32. package/dist/splatPly.d.ts +14 -0
  33. package/dist/splatPly.js +242 -0
  34. package/dist/splatSog.d.ts +110 -0
  35. package/dist/splatSog.js +285 -0
  36. package/dist/splatSogDecoder.d.ts +26 -0
  37. package/dist/splatSogDecoder.js +29 -0
  38. package/dist/splatSort.d.ts +137 -0
  39. package/dist/splatSort.js +199 -0
  40. package/dist/splatSortWorker.d.ts +14 -0
  41. package/dist/splatSortWorker.js +137 -0
  42. package/dist/splatSorter.d.ts +112 -0
  43. package/dist/splatSorter.js +231 -0
  44. package/dist/splatView.d.ts +52 -0
  45. package/dist/splatView.js +115 -0
  46. package/package.json +56 -0
  47. package/src/fixtures/README.md +36 -0
  48. package/src/fixtures/cloud.sog +0 -0
  49. package/src/fixtures/cloud.texels.json +27 -0
  50. package/src/fixtures/cloud.truth.json +582 -0
  51. package/src/half.ts +92 -0
  52. package/src/index.ts +55 -0
  53. package/src/shaders/generated/splat.wgsl.ts +98 -0
  54. package/src/shaders/splat.ts +344 -0
  55. package/src/splat.ts +75 -0
  56. package/src/splatBudget.ts +48 -0
  57. package/src/splatCapture.ts +154 -0
  58. package/src/splatCull.ts +91 -0
  59. package/src/splatData.ts +398 -0
  60. package/src/splatGl.ts +262 -0
  61. package/src/splatGpu.ts +259 -0
  62. package/src/splatLayout.ts +86 -0
  63. package/src/splatMatrix.ts +81 -0
  64. package/src/splatPass.ts +324 -0
  65. package/src/splatPly.ts +283 -0
  66. package/src/splatSog.ts +375 -0
  67. package/src/splatSogDecoder.ts +33 -0
  68. package/src/splatSort.ts +296 -0
  69. package/src/splatSortWorker.ts +155 -0
  70. package/src/splatSorter.ts +285 -0
  71. 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
+ }