@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,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;
|