@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,45 @@
1
+ /** How many splats a device should be asked to draw, before anybody has measured that device. */
2
+ import { isWeakGpuFamily } from '@driftengine/core';
3
+ /**
4
+ * The count a part this engine has no evidence against is asked to draw.
5
+ *
6
+ * **This is an estimate and it is not measured, which is the first thing to know about it.**
7
+ * `AGENTS.md` says fidelity is cut on a measurement and not on a fear, and the measurement that
8
+ * settles this one is a frame time from a real mid-range Android phone drawing a real capture —
9
+ * which this repository does not have. What is here instead is the arithmetic: a splat pass is
10
+ * fill-bound, a splat at ±2σ covers a few dozen blended fragments, and a mid-range mobile part has
11
+ * something under half a gigapixel a frame of blended fill at 60 Hz. Four hundred thousand splats
12
+ * is what that budget divides into, and it is the range published mobile viewers of this technique
13
+ * report.
14
+ *
15
+ * **What it costs if it is too low** is a capture thinner than the device could have drawn, which
16
+ * is visible as a sparser cloud and nothing worse. **What would make it wrong** is a measurement,
17
+ * in either direction, and a consumer holding one should pass its own number rather than wait for
18
+ * this line to be corrected. `docs/IMPROVEMENTS.md` carries it as an open measurement.
19
+ */
20
+ export const SPLAT_BUDGET_DEFAULT = 400_000;
21
+ /**
22
+ * The count a part measured to struggle with the good profile is asked to draw.
23
+ *
24
+ * The same estimate against the family `isWeakGpuFamily` names — an Adreno 619 measured at 156 ms
25
+ * a frame on this engine's desktop profile, an Adreno 710 at 112 ms, a UHD 630 unplayable
26
+ * fullscreen. Those are three to ten times the frame time the default is reasoned against, so this
27
+ * is the default cut by that factor rather than a second guess.
28
+ */
29
+ export const SPLAT_BUDGET_WEAK = 120_000;
30
+ /**
31
+ * The budget to hand `SplatSorter` when the consumer has no number of its own.
32
+ *
33
+ * Takes the renderer's own name — `renderer.rendererName`, which every backend answers — rather
34
+ * than reaching for an adapter itself, because `AGENTS.md`'s rule is that a package takes a
35
+ * platform capability as a parameter and never queries one.
36
+ *
37
+ * **An unknown part is not weak**, which is `isWeakGpuFamily`'s own decision and the reason it is
38
+ * reused rather than re-tabulated here: a device table is wrong about every GPU nobody has tested,
39
+ * and guessing weak on the unknown thins a capture for every part released after this line.
40
+ */
41
+ export function defaultSplatBudget(rendererName) {
42
+ if (rendererName !== undefined && isWeakGpuFamily(rendererName))
43
+ return SPLAT_BUDGET_WEAK;
44
+ return SPLAT_BUDGET_DEFAULT;
45
+ }
@@ -0,0 +1,76 @@
1
+ /** A capture that fills up as its blocks arrive, sized once from the count the first one names. */
2
+ import type { SplatData } from './splatData.ts';
3
+ export interface SplatCaptureOptions {
4
+ /** Splats in the whole capture. Every array is allocated for this and never grows. */
5
+ readonly total: number;
6
+ /** The whole capture's extent, which a `.drft` block carries in its own header. */
7
+ readonly boundsMin: ArrayLike<number>;
8
+ readonly boundsMax: ArrayLike<number>;
9
+ /** Coefficients the source carried, of which the shader reads the first band. Recorded. */
10
+ readonly sphericalHarmonics?: number;
11
+ /**
12
+ * Words in one record, from the block's own header.
13
+ *
14
+ * **Taken from the file rather than assumed**, which is what `FORMAT.md` §4.7 designed the field
15
+ * for: a capture with degree-1 view-dependent colour writes twelve where one without writes
16
+ * eight, and a streaming reader that hard-coded the width would read the second block's records
17
+ * at the wrong offset and draw a cloud of noise. Defaults to `SPLAT_WORDS` so a caller with no
18
+ * container behind it is unaffected.
19
+ */
20
+ readonly wordsPerSplat?: number;
21
+ }
22
+ /** Where a block landed, so a caller uploads what arrived rather than the whole texture. */
23
+ export interface SplatAppend {
24
+ readonly from: number;
25
+ readonly count: number;
26
+ }
27
+ /**
28
+ * A capture assembled from blocks, one `.drft` chunk at a time.
29
+ *
30
+ * **Allocated once at the final count and filled progressively**, which is what makes a streaming
31
+ * load cost nothing extra: the GPU textures are sized for the whole capture at registration, each
32
+ * block is a sub-upload into storage that already exists, and the drawn count rises. Growing an
33
+ * array as blocks land would mean reallocating and re-uploading a capture's worth of texels
34
+ * several times during a load, which is more work than the load itself.
35
+ *
36
+ * **Every block is a sparse version of the whole capture**, because the container writes them
37
+ * that way — see `coarseFirstOrder`. So a prefix of the arrived splats is a recognisable place at
38
+ * lower density rather than one finished corner, and the load opens on something worth looking
39
+ * at. That property lives in the writer; what lives here is that nothing is re-ordered or
40
+ * re-allocated on the way in.
41
+ *
42
+ * The unarrived tail is zeroed, which puts it at the origin with no size. Nothing draws it: the
43
+ * caller sorts and draws `ready` splats and the sorter is told the same number.
44
+ */
45
+ export declare class SplatCapture {
46
+ readonly data: SplatData;
47
+ private arrived;
48
+ private readonly words;
49
+ constructor(options: SplatCaptureOptions);
50
+ /** How many splats have landed. The count to sort, to draw and to upload up to. */
51
+ get ready(): number;
52
+ /**
53
+ * Copy one block's records in, deriving what the CPU needs from what the file carried.
54
+ *
55
+ * **Positions and extents come back out of the records rather than being stored beside them**,
56
+ * which is the whole reason a `.drft` splat block is thirty-two bytes a splat rather than
57
+ * forty-eight: the first three words are the position's own float bits and the eighth is the
58
+ * extent's. See `packSplats`, which is what puts them there.
59
+ */
60
+ append(records: Uint32Array, count: number): SplatAppend;
61
+ }
62
+ /**
63
+ * A whole capture in one go, for a caller that read the file rather than streamed it.
64
+ *
65
+ * The same assembly `SplatCapture` does per block, without the object — `readDrft` hands back
66
+ * every block already joined, so there is nothing left to append to.
67
+ */
68
+ export declare function splatsFromRecords(source: {
69
+ readonly count: number;
70
+ readonly records: Uint32Array;
71
+ readonly boundsMin: ArrayLike<number>;
72
+ readonly boundsMax: ArrayLike<number>;
73
+ readonly sphericalHarmonics?: number;
74
+ /** The block's own record width. See `SplatCaptureOptions.wordsPerSplat`. */
75
+ readonly wordsPerSplat?: number;
76
+ }): SplatData;
@@ -0,0 +1,108 @@
1
+ /** A capture that fills up as its blocks arrive, sized once from the count the first one names. */
2
+ import { SPLAT_WORDS, SPLAT_WORDS_SH1 } from './splatData.js';
3
+ /**
4
+ * A capture assembled from blocks, one `.drft` chunk at a time.
5
+ *
6
+ * **Allocated once at the final count and filled progressively**, which is what makes a streaming
7
+ * load cost nothing extra: the GPU textures are sized for the whole capture at registration, each
8
+ * block is a sub-upload into storage that already exists, and the drawn count rises. Growing an
9
+ * array as blocks land would mean reallocating and re-uploading a capture's worth of texels
10
+ * several times during a load, which is more work than the load itself.
11
+ *
12
+ * **Every block is a sparse version of the whole capture**, because the container writes them
13
+ * that way — see `coarseFirstOrder`. So a prefix of the arrived splats is a recognisable place at
14
+ * lower density rather than one finished corner, and the load opens on something worth looking
15
+ * at. That property lives in the writer; what lives here is that nothing is re-ordered or
16
+ * re-allocated on the way in.
17
+ *
18
+ * The unarrived tail is zeroed, which puts it at the origin with no size. Nothing draws it: the
19
+ * caller sorts and draws `ready` splats and the sorter is told the same number.
20
+ */
21
+ export class SplatCapture {
22
+ data;
23
+ arrived = 0;
24
+ words;
25
+ constructor(options) {
26
+ const total = Math.max(0, options.total);
27
+ this.words = options.wordsPerSplat ?? SPLAT_WORDS;
28
+ this.data = {
29
+ count: total,
30
+ positions: new Float32Array(total * 3),
31
+ packed: new Uint32Array(total * this.words),
32
+ wordsPerSplat: this.words,
33
+ shDegree: this.words >= SPLAT_WORDS_SH1 ? 1 : 0,
34
+ extents: new Float32Array(total),
35
+ boundsMin: new Float32Array([
36
+ options.boundsMin[0] ?? 0,
37
+ options.boundsMin[1] ?? 0,
38
+ options.boundsMin[2] ?? 0,
39
+ ]),
40
+ boundsMax: new Float32Array([
41
+ options.boundsMax[0] ?? 0,
42
+ options.boundsMax[1] ?? 0,
43
+ options.boundsMax[2] ?? 0,
44
+ ]),
45
+ sphericalHarmonics: options.sphericalHarmonics ?? 0,
46
+ };
47
+ }
48
+ /** How many splats have landed. The count to sort, to draw and to upload up to. */
49
+ get ready() {
50
+ return this.arrived;
51
+ }
52
+ /**
53
+ * Copy one block's records in, deriving what the CPU needs from what the file carried.
54
+ *
55
+ * **Positions and extents come back out of the records rather than being stored beside them**,
56
+ * which is the whole reason a `.drft` splat block is thirty-two bytes a splat rather than
57
+ * forty-eight: the first three words are the position's own float bits and the eighth is the
58
+ * extent's. See `packSplats`, which is what puts them there.
59
+ */
60
+ append(records, count) {
61
+ if (count < 0 || this.arrived + count > this.data.count) {
62
+ throw new Error(`splats: a block of ${count} splats lands at ${this.arrived} in a capture of ` +
63
+ `${this.data.count} splats, which does not fit`);
64
+ }
65
+ if (records.length < count * this.words) {
66
+ throw new Error(`splats: ${count} splats need ${count * this.words} entries and the block has ` +
67
+ `${records.length}`);
68
+ }
69
+ const from = this.arrived;
70
+ this.data.packed.set(records.subarray(0, count * this.words), from * this.words);
71
+ /*
72
+ * Reinterpreting, not converting — the same inverse the shader applies. Going through a
73
+ * `Float32Array` view of the destination rather than of the source, because the source may be
74
+ * a view over the file at any four-byte offset and the destination is this capture's own
75
+ * buffer at a known one.
76
+ */
77
+ const asFloat = new Float32Array(this.data.packed.buffer);
78
+ for (let slot = 0; slot < count; slot++) {
79
+ const splat = from + slot;
80
+ const at = splat * this.words;
81
+ this.data.positions[splat * 3] = asFloat[at] ?? 0;
82
+ this.data.positions[splat * 3 + 1] = asFloat[at + 1] ?? 0;
83
+ this.data.positions[splat * 3 + 2] = asFloat[at + 2] ?? 0;
84
+ this.data.extents[splat] = asFloat[at + 7] ?? 0;
85
+ }
86
+ this.arrived += count;
87
+ return { from, count };
88
+ }
89
+ }
90
+ /**
91
+ * A whole capture in one go, for a caller that read the file rather than streamed it.
92
+ *
93
+ * The same assembly `SplatCapture` does per block, without the object — `readDrft` hands back
94
+ * every block already joined, so there is nothing left to append to.
95
+ */
96
+ export function splatsFromRecords(source) {
97
+ const capture = new SplatCapture({
98
+ total: source.count,
99
+ boundsMin: source.boundsMin,
100
+ boundsMax: source.boundsMax,
101
+ ...(source.wordsPerSplat === undefined ? {} : { wordsPerSplat: source.wordsPerSplat }),
102
+ ...(source.sphericalHarmonics === undefined
103
+ ? {}
104
+ : { sphericalHarmonics: source.sphericalHarmonics }),
105
+ });
106
+ capture.append(source.records, source.count);
107
+ return capture.data;
108
+ }
@@ -0,0 +1,25 @@
1
+ /** Whether a capture's own bounding box reaches the frame at all, decided before anything sorts. */
2
+ /**
3
+ * Is any part of the box `[boundsMin, boundsMax]` inside the frustum?
4
+ *
5
+ * The box is in the **capture's own space**, which is why this takes the model matrix rather than
6
+ * a world-space box: `SplatData` computes its bounds while the packing loop is open, in whatever
7
+ * frame the capture was authored in, and moving the box every frame would be work to reach the
8
+ * same answer as moving the planes.
9
+ *
10
+ * **Answered before the sort, not after, and that is where the saving is.** Drawing a capture that
11
+ * is out of frame costs one draw call the rasteriser throws away; *sorting* it costs a linear pass
12
+ * over every splat it has, in a worker, for a picture nobody sees. Culling after the sort would
13
+ * save the cheap half.
14
+ *
15
+ * **Conservative: a box that touches the frustum is visible.** The test is whether some plane has
16
+ * the whole box behind it, which is exact for rejection and admits a few boxes near a corner that
17
+ * are outside every plane pairwise but inside none singly. What that costs is a sort for a capture
18
+ * just off the corner of the frame; what would make it wrong is the opposite error, which loses a
19
+ * picture — so the asymmetry is deliberate.
20
+ *
21
+ * `projection` is the caller's own, **before** the backend's clip correction: the planes below are
22
+ * the OpenGL convention that `mat4.perspective` produces, and a corrected matrix has moved z into
23
+ * [0, 1] and flipped y, so the near plane extracted from one would be wrong.
24
+ */
25
+ export declare function splatBoundsVisible(view: ArrayLike<number>, projection: ArrayLike<number>, model: ArrayLike<number>, boundsMin: ArrayLike<number>, boundsMax: ArrayLike<number>): boolean;
@@ -0,0 +1,80 @@
1
+ /** Whether a capture's own bounding box reaches the frame at all, decided before anything sorts. */
2
+ import { multiplyMat4 } from './splatMatrix.js';
3
+ /* Scratch at module scope, so a per-frame visibility test allocates nothing. Safe to share
4
+ between two batches because this is synchronous: no second call runs inside one. */
5
+ const viewModel = new Float32Array(16);
6
+ const clip = new Float32Array(16);
7
+ /** Six planes, four numbers each: a, b, c, d with `a x + b y + c z + d >= 0` inside. */
8
+ const planes = new Float32Array(24);
9
+ /**
10
+ * Is any part of the box `[boundsMin, boundsMax]` inside the frustum?
11
+ *
12
+ * The box is in the **capture's own space**, which is why this takes the model matrix rather than
13
+ * a world-space box: `SplatData` computes its bounds while the packing loop is open, in whatever
14
+ * frame the capture was authored in, and moving the box every frame would be work to reach the
15
+ * same answer as moving the planes.
16
+ *
17
+ * **Answered before the sort, not after, and that is where the saving is.** Drawing a capture that
18
+ * is out of frame costs one draw call the rasteriser throws away; *sorting* it costs a linear pass
19
+ * over every splat it has, in a worker, for a picture nobody sees. Culling after the sort would
20
+ * save the cheap half.
21
+ *
22
+ * **Conservative: a box that touches the frustum is visible.** The test is whether some plane has
23
+ * the whole box behind it, which is exact for rejection and admits a few boxes near a corner that
24
+ * are outside every plane pairwise but inside none singly. What that costs is a sort for a capture
25
+ * just off the corner of the frame; what would make it wrong is the opposite error, which loses a
26
+ * picture — so the asymmetry is deliberate.
27
+ *
28
+ * `projection` is the caller's own, **before** the backend's clip correction: the planes below are
29
+ * the OpenGL convention that `mat4.perspective` produces, and a corrected matrix has moved z into
30
+ * [0, 1] and flipped y, so the near plane extracted from one would be wrong.
31
+ */
32
+ export function splatBoundsVisible(view, projection, model, boundsMin, boundsMax) {
33
+ multiplyMat4(viewModel, view, model);
34
+ multiplyMat4(clip, projection, viewModel);
35
+ /*
36
+ * Gribb and Hartmann: the clip-space inequalities `-w <= x <= w` and so on are linear in the
37
+ * object-space point, so each is a plane whose coefficients are a sum or difference of two rows
38
+ * of the matrix. A row of a column-major matrix is every fourth entry, which is why the strides
39
+ * below look transposed.
40
+ */
41
+ for (let axis = 0; axis < 3; axis++) {
42
+ for (let component = 0; component < 4; component++) {
43
+ const w = clip[component * 4 + 3] ?? 0;
44
+ const a = clip[component * 4 + axis] ?? 0;
45
+ /* Two planes per axis: w + a is the low side, w - a the high one. */
46
+ planes[axis * 8 + component] = w + a;
47
+ planes[axis * 8 + 4 + component] = w - a;
48
+ }
49
+ }
50
+ const minX = boundsMin[0] ?? 0;
51
+ const minY = boundsMin[1] ?? 0;
52
+ const minZ = boundsMin[2] ?? 0;
53
+ const maxX = boundsMax[0] ?? 0;
54
+ const maxY = boundsMax[1] ?? 0;
55
+ const maxZ = boundsMax[2] ?? 0;
56
+ for (let plane = 0; plane < 6; plane++) {
57
+ const at = plane * 4;
58
+ const a = planes[at] ?? 0;
59
+ const b = planes[at + 1] ?? 0;
60
+ const c = planes[at + 2] ?? 0;
61
+ const d = planes[at + 3] ?? 0;
62
+ /*
63
+ * The box's corner **furthest along this plane's normal**, picked component by component.
64
+ * Testing that one decides the whole box: if even the corner most in front of the plane is
65
+ * behind it, all eight are — six dot products rather than forty-eight.
66
+ *
67
+ * **The opposite corner is the trap, and it looks equally plausible.** Taking the corner
68
+ * furthest *behind* the plane rejects every box that is partly behind one, which throws away
69
+ * any capture straddling the near plane and any capture the camera is standing inside — the
70
+ * two cases a room-scale capture is made of. Both are asserted in `splatCull.test.ts` for
71
+ * that reason, and both failed on the first writing of this line.
72
+ */
73
+ const x = a >= 0 ? maxX : minX;
74
+ const y = b >= 0 ? maxY : minY;
75
+ const z = c >= 0 ? maxZ : minZ;
76
+ if (a * x + b * y + c * z + d < 0)
77
+ return false;
78
+ }
79
+ return true;
80
+ }
@@ -0,0 +1,177 @@
1
+ /** The shape every splat reader produces, and the packing that turns it into two GPU texels. */
2
+ /** How many `uint32`s a splat occupies with no view-dependent colour: two `RGBA32UI` texels. */
3
+ export declare const SPLAT_WORDS = 8;
4
+ /**
5
+ * The same with degree-1 view-dependent colour: **one more texel**, and no more than one.
6
+ *
7
+ * The costing in `sphericalHarmonics` below is what picked degree 1 and it is worth reading before
8
+ * anybody raises this. Nine coefficients fit a texel as bytes with a per-splat scale beside them,
9
+ * which is +50% on the record and on the read; degree 2 is three more texels and degree 3 is six,
10
+ * and degree 3 alone would put 307 MB a frame through a device already carrying 389.
11
+ */
12
+ export declare const SPLAT_WORDS_SH1 = 12;
13
+ /** Three basis functions at l=1, three channels each. */
14
+ export declare const SPLAT_SH1_COEFFICIENTS = 9;
15
+ /**
16
+ * A capture as a reader hands it over: linear values, one array per attribute.
17
+ *
18
+ * **Linear, and that boundary is load-bearing.** A `.ply` from a training run stores scale as its
19
+ * logarithm and opacity as its logit, and undoing both is the *reader's* job rather than this
20
+ * one's. Getting the boundary wrong produces a capture that is either invisible or a solid block,
21
+ * and both have been reported against other viewers — because both look like a rendering fault.
22
+ */
23
+ export interface SplatSource {
24
+ readonly count: number;
25
+ /** Three per splat, in the capture's own space. */
26
+ readonly positions: Float32Array;
27
+ /** Three per splat: the Gaussian's standard deviation along each of its own axes, in metres. */
28
+ readonly scales: Float32Array;
29
+ /**
30
+ * Four per splat, **xyzw**, normalised here rather than trusted.
31
+ *
32
+ * **Both file formats store wxyz and this does not**, which is a deliberate seam: `xyzw` is what
33
+ * `gl-matrix` uses and what every other quaternion in this engine is, so a consumer building a
34
+ * source by hand is not asked to learn a second convention for one type. Each reader reorders on
35
+ * the way in, in one line, at the point where the file's order is already on screen.
36
+ */
37
+ readonly rotations: Float32Array;
38
+ /**
39
+ * Three per splat, 0 to 1, **linear**.
40
+ *
41
+ * Not sRGB-decoded, which is what every reference viewer does and is also what is correct here:
42
+ * a capture's colour comes from a spherical-harmonic DC term, which is linear radiance, and this
43
+ * engine shades and composites in linear and grades once at the end. What would make it wrong is
44
+ * a capture authored by a tool that baked a display transform into the DC term — which produces
45
+ * a washed-out cloud, not a subtly wrong one.
46
+ */
47
+ readonly colors: Float32Array;
48
+ /** One per splat, 0 to 1, already through the logistic if the format stored a logit. */
49
+ readonly opacities: Float32Array;
50
+ /**
51
+ * The l=1 spherical-harmonic band, nine per splat, or absent for a capture that has none.
52
+ *
53
+ * **Interleaved by basis function and then by channel** — `Y(1,−1).rgb`, `Y(1,0).rgb`,
54
+ * `Y(1,1).rgb` — which is *not* how a `.ply` stores them. That file is channel-major, all
55
+ * fifteen of red before all fifteen of green, and the reader transposes on the way in for the
56
+ * reason every other reordering in this package happens there: the file's own order is on
57
+ * screen at exactly one place, and a shader that had to know it would be a second copy of a
58
+ * convention nothing checks.
59
+ *
60
+ * Raw coefficients, signed, in the same units the training run wrote — the band constant is the
61
+ * shader's to apply, like `SH_C0` is the reader's for the DC term.
62
+ */
63
+ readonly sh1?: Float32Array;
64
+ }
65
+ /**
66
+ * A capture packed for the GPU, plus the two things the CPU still needs.
67
+ *
68
+ * **`positions` survives packing because the sorter reads it every time the view turns**, and it is
69
+ * the only per-splat attribute that does. Everything else is in `packed` and is never looked at
70
+ * again by JavaScript.
71
+ *
72
+ * **This takes ownership of the arrays handed to `packSplats`.** They are twelve and four bytes a
73
+ * splat, so copying a million-splat capture would be sixteen megabytes of duplicate to no purpose;
74
+ * the cost is that a caller must not go on mutating a source after packing it, which is stated
75
+ * here because nothing enforces it.
76
+ */
77
+ export interface SplatData {
78
+ readonly count: number;
79
+ /** Three per splat. See above: the sorter's input, and the reason this is not packed away. */
80
+ readonly positions: Float32Array;
81
+ /**
82
+ * `wordsPerSplat` per splat — texel 0 is position and colour, texel 1 is the covariance, and
83
+ * texel 2 is the l=1 band where the capture carries one.
84
+ */
85
+ readonly packed: Uint32Array;
86
+ /**
87
+ * `SPLAT_WORDS` or `SPLAT_WORDS_SH1`. Everything that indexes `packed` reads this rather than a
88
+ * constant, and `FORMAT.md` §4.7 anticipated exactly this: a `SPLT` block already carries its
89
+ * own `wordsPerSplat` and says "a capture that carries spherical-harmonic coefficients later
90
+ * fits by raising it and nothing else".
91
+ */
92
+ readonly wordsPerSplat: number;
93
+ /** The capture's axis-aligned extent, for the frustum test that precedes a sort. */
94
+ readonly boundsMin: Float32Array;
95
+ readonly boundsMax: Float32Array;
96
+ /**
97
+ * One per splat: the largest of its three standard deviations, in the capture's own units.
98
+ *
99
+ * **What the budget ranks by, and the second attribute that survives packing.** A splat's
100
+ * contribution to the picture is roughly how many pixels it covers, which is its world extent
101
+ * over its distance to the camera; the distance is per-frame and this is the half that is not.
102
+ * The largest sigma bounds the projected radius from above for every orientation, so it ranks
103
+ * correctly without a per-splat projection.
104
+ *
105
+ * What it costs is **four bytes a splat** — four megabytes at a million, on top of the twelve
106
+ * `positions` already keeps — and it is paid whether or not a capture is ever budgeted, because
107
+ * packing cannot know. What would make it wrong is a capture of extremely flat splats seen
108
+ * edge-on, where the largest sigma is a poor estimate of the pixels covered and this
109
+ * over-ranks them; the honest fix there is the projected radius, which is per-frame work this
110
+ * deliberately avoids.
111
+ */
112
+ readonly extents: Float32Array;
113
+ /**
114
+ * How many `f_rest_*` coefficients the source carried, of which this kept the first band.
115
+ *
116
+ * Zero for a format that has none. **Nine are read as of 2026-08-27 and the rest are not**, and
117
+ * the costing that decided which is below, kept because it is the argument against raising it.
118
+ *
119
+ * ## What view-dependent colour costs, costed 2026-08-27 and built the same day
120
+ *
121
+ * A splat with no harmonics is `SPLAT_WORDS` uint32s — two `RGBA32UI` texels, **32 bytes** — and
122
+ * the vertex stage runs **six times a splat**, because the quad is two unindexed triangles and
123
+ * `SPLAT_VERT` says why. Every invocation fetches every texel, so the data texture is read at
124
+ * **192 bytes a splat a frame** before any texture cache.
125
+ *
126
+ * A degree-`n` expansion carries `((n+1)^2 − 1) * 3` coefficients:
127
+ *
128
+ * | Degree | Coefficients | Extra texels | Bytes a splat | Read a splat a frame |
129
+ * |---|---|---|---|---|
130
+ * | 0 | 0 | 0 | 32 | 192 |
131
+ * | 1 **(built)** | 9 | 1 | 48 | 288 |
132
+ * | 2 | 24 | 3 | 80 | 480 |
133
+ * | 3 | 45 | 6 | 128 | 768 |
134
+ *
135
+ * **Against the mobile budget of `SPLAT_BUDGET_DEFAULT`, 400,000 splats**: 76.8 MB a frame at
136
+ * degree 0, 115.2 at degree 1, 192 at degree 2 and **307.2 at degree 3**. `docs/CAPABILITIES.md`
137
+ * records a mid-range phone already moving **388.7 MB a frame of attachment traffic**, so degree 3
138
+ * would very nearly double the memory traffic of a frame on the device that is already the
139
+ * constraint — and the texture itself would grow from 12.8 MB to 51 MB, which is the mistake
140
+ * `lightBudget.ts`'s shadow pool records making once already.
141
+ *
142
+ * **Degree 1 is the band that earns its texel.** The l=1 lobe is broad and directional — it is
143
+ * what makes glass, a wet surface and a polished floor read as themselves — where degrees 2 and 3
144
+ * are the sharp specular detail that costs four and six times as much. Nine coefficients fit one
145
+ * texel as **bytes with a per-splat scale beside them**, rather than halves: nine halves is 18
146
+ * bytes and would not fit, and a per-splat scale spends the sixteenth byte on the accuracy that
147
+ * quantising to a capture-wide range would have thrown away.
148
+ *
149
+ * **What is still unmeasured**, and it is the number that would refine this: how much of the 6x
150
+ * amplification a real texture cache absorbs, since six consecutive invocations read the same
151
+ * texels. Answering it needs a GPU timing of the splat pass against a capture, and this checkout
152
+ * has none — `demo/dev/public/` being uncommitted.
153
+ */
154
+ readonly sphericalHarmonics: number;
155
+ /**
156
+ * How many bands of that expansion this capture actually carries: 0 or 1.
157
+ *
158
+ * Separate from `sphericalHarmonics`, which counts what the *source* had — a `.ply` written at
159
+ * degree 3 reports 45 there and 1 here, because forty-five coefficients arrived and nine were
160
+ * kept. The two disagreeing is the honest state rather than a discrepancy.
161
+ */
162
+ readonly shDegree: 0 | 1;
163
+ }
164
+ /**
165
+ * Turn a source into two texels per splat, and compute the capture's bounds while the loop is open.
166
+ *
167
+ * Texel 0 is the three position floats reinterpreted as `uint32` plus the colour as `RGBA8`.
168
+ * Texel 1 is the six unique terms of the 3x3 covariance as three half pairs, with the fourth
169
+ * component reserved and written zero.
170
+ *
171
+ * **The covariance is computed here and never again.** It is fixed for the lifetime of a capture,
172
+ * so evaluating it per splat per frame would cost a quaternion-to-matrix and two 3x3 multiplies
173
+ * for a value that cannot change. What that gives up is six halves of precision against the
174
+ * source's floats; what would make it wrong is a capture whose splats are animated, which is not a
175
+ * thing this format can express.
176
+ */
177
+ export declare function packSplats(source: SplatSource): SplatData;