@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,52 @@
|
|
|
1
|
+
/** How a capture is laid out in its two textures, shared by both backends so they cannot disagree. */
|
|
2
|
+
/**
|
|
3
|
+
* Splats per row.
|
|
4
|
+
*
|
|
5
|
+
* **A power of two, so the shader's divide is a shift** and the two textures index alike: the data
|
|
6
|
+
* texture is twice this wide because a splat is two texels, and the order texture is exactly this
|
|
7
|
+
* wide because a slot is one.
|
|
8
|
+
*
|
|
9
|
+
* 1024 puts a million splats in 977 rows, comfortably inside WebGL2's guaranteed 2048 — see
|
|
10
|
+
* `splatRows`, which is where that limit is checked rather than assumed.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SPLAT_STRIDE = 1024;
|
|
13
|
+
/** How many rows a capture of `count` splats needs. Both textures share it. */
|
|
14
|
+
export declare function splatRows(count: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Texels one splat occupies in the data texture, from its record width.
|
|
17
|
+
*
|
|
18
|
+
* Two for a capture with no view-dependent colour and three for one with the l=1 band. **A
|
|
19
|
+
* function of the record rather than a constant**, because the width is the *file's* — a `SPLT`
|
|
20
|
+
* block carries its own `wordsPerSplat` and `FORMAT.md` §4.7 designed it to grow exactly this way.
|
|
21
|
+
*/
|
|
22
|
+
export declare function splatTexels(wordsPerSplat: number): number;
|
|
23
|
+
/**
|
|
24
|
+
* Refuse a capture that will not fit, naming both numbers.
|
|
25
|
+
*
|
|
26
|
+
* **WebGL2 guarantees `MAX_TEXTURE_SIZE` of 2048**, which at two texels a splat is 2,097,152
|
|
27
|
+
* splats — so the ceiling is stated here rather than re-derived by whoever hits it. A device
|
|
28
|
+
* usually offers far more; the guaranteed floor is what a refusal has to be written against,
|
|
29
|
+
* because a capture that loads on a workstation and refuses on a phone is worse than one that
|
|
30
|
+
* refuses on both. **A capture with view-dependent colour is three texels a splat**, so its width
|
|
31
|
+
* is 3,072 and a device at the guaranteed floor refuses it — which is the honest answer rather
|
|
32
|
+
* than a silently narrower row.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkSplatCapacity(count: number, maxTextureSize: number, wordsPerSplat?: number): void;
|
|
35
|
+
/** Words per row of the data texture: `SPLAT_STRIDE` splats of `wordsPerSplat` each. */
|
|
36
|
+
export declare function splatRowWords(wordsPerSplat: number): number;
|
|
37
|
+
/**
|
|
38
|
+
* A source covering exactly `rows` whole rows of packed data, starting at `firstRow`.
|
|
39
|
+
*
|
|
40
|
+
* **Both backends need this and only one of them had it, which is how it was found.** A texture
|
|
41
|
+
* upload takes a *rectangle*, and a capture whose splat count is not a multiple of
|
|
42
|
+
* `SPLAT_STRIDE` ends in a partial row — so a plain `subarray` to the end of the packed data is
|
|
43
|
+
* shorter than the rectangle it is being handed to. WebGPU refuses that at `submit` and takes the
|
|
44
|
+
* command buffer with it; WebGL2 raises `INVALID_OPERATION` and *skips the upload*, which is
|
|
45
|
+
* worse: the last row keeps whatever it held and the capture draws with up to a thousand splats
|
|
46
|
+
* missing. Measured as 59,585 pixels between the two backends on a streaming load, at the last
|
|
47
|
+
* block and no earlier.
|
|
48
|
+
*
|
|
49
|
+
* Copies only when the tail is short, which is at most once per capture and at most one row —
|
|
50
|
+
* 32 KB. The whole-row case is the common one and is a view.
|
|
51
|
+
*/
|
|
52
|
+
export declare function splatRowSource(packed: Uint32Array, firstRow: number, rows: number, wordsPerSplat?: number): Uint32Array;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** How a capture is laid out in its two textures, shared by both backends so they cannot disagree. */
|
|
2
|
+
/**
|
|
3
|
+
* Splats per row.
|
|
4
|
+
*
|
|
5
|
+
* **A power of two, so the shader's divide is a shift** and the two textures index alike: the data
|
|
6
|
+
* texture is twice this wide because a splat is two texels, and the order texture is exactly this
|
|
7
|
+
* wide because a slot is one.
|
|
8
|
+
*
|
|
9
|
+
* 1024 puts a million splats in 977 rows, comfortably inside WebGL2's guaranteed 2048 — see
|
|
10
|
+
* `splatRows`, which is where that limit is checked rather than assumed.
|
|
11
|
+
*/
|
|
12
|
+
export const SPLAT_STRIDE = 1024;
|
|
13
|
+
/** How many rows a capture of `count` splats needs. Both textures share it. */
|
|
14
|
+
export function splatRows(count) {
|
|
15
|
+
return Math.max(1, Math.ceil(count / SPLAT_STRIDE));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Texels one splat occupies in the data texture, from its record width.
|
|
19
|
+
*
|
|
20
|
+
* Two for a capture with no view-dependent colour and three for one with the l=1 band. **A
|
|
21
|
+
* function of the record rather than a constant**, because the width is the *file's* — a `SPLT`
|
|
22
|
+
* block carries its own `wordsPerSplat` and `FORMAT.md` §4.7 designed it to grow exactly this way.
|
|
23
|
+
*/
|
|
24
|
+
export function splatTexels(wordsPerSplat) {
|
|
25
|
+
return wordsPerSplat / 4;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Refuse a capture that will not fit, naming both numbers.
|
|
29
|
+
*
|
|
30
|
+
* **WebGL2 guarantees `MAX_TEXTURE_SIZE` of 2048**, which at two texels a splat is 2,097,152
|
|
31
|
+
* splats — so the ceiling is stated here rather than re-derived by whoever hits it. A device
|
|
32
|
+
* usually offers far more; the guaranteed floor is what a refusal has to be written against,
|
|
33
|
+
* because a capture that loads on a workstation and refuses on a phone is worse than one that
|
|
34
|
+
* refuses on both. **A capture with view-dependent colour is three texels a splat**, so its width
|
|
35
|
+
* is 3,072 and a device at the guaranteed floor refuses it — which is the honest answer rather
|
|
36
|
+
* than a silently narrower row.
|
|
37
|
+
*/
|
|
38
|
+
export function checkSplatCapacity(count, maxTextureSize, wordsPerSplat = 8) {
|
|
39
|
+
const rows = splatRows(count);
|
|
40
|
+
const width = SPLAT_STRIDE * splatTexels(wordsPerSplat);
|
|
41
|
+
if (rows <= maxTextureSize && width <= maxTextureSize)
|
|
42
|
+
return;
|
|
43
|
+
throw new Error(`a capture of ${count} splats needs ${width} by ${rows} texels and this device ` +
|
|
44
|
+
`allows ${maxTextureSize}. At ${SPLAT_STRIDE} splats a row the ceiling is ` +
|
|
45
|
+
`${SPLAT_STRIDE * maxTextureSize} splats.`);
|
|
46
|
+
}
|
|
47
|
+
/** Words per row of the data texture: `SPLAT_STRIDE` splats of `wordsPerSplat` each. */
|
|
48
|
+
export function splatRowWords(wordsPerSplat) {
|
|
49
|
+
return SPLAT_STRIDE * wordsPerSplat;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A source covering exactly `rows` whole rows of packed data, starting at `firstRow`.
|
|
53
|
+
*
|
|
54
|
+
* **Both backends need this and only one of them had it, which is how it was found.** A texture
|
|
55
|
+
* upload takes a *rectangle*, and a capture whose splat count is not a multiple of
|
|
56
|
+
* `SPLAT_STRIDE` ends in a partial row — so a plain `subarray` to the end of the packed data is
|
|
57
|
+
* shorter than the rectangle it is being handed to. WebGPU refuses that at `submit` and takes the
|
|
58
|
+
* command buffer with it; WebGL2 raises `INVALID_OPERATION` and *skips the upload*, which is
|
|
59
|
+
* worse: the last row keeps whatever it held and the capture draws with up to a thousand splats
|
|
60
|
+
* missing. Measured as 59,585 pixels between the two backends on a streaming load, at the last
|
|
61
|
+
* block and no earlier.
|
|
62
|
+
*
|
|
63
|
+
* Copies only when the tail is short, which is at most once per capture and at most one row —
|
|
64
|
+
* 32 KB. The whole-row case is the common one and is a view.
|
|
65
|
+
*/
|
|
66
|
+
export function splatRowSource(packed, firstRow, rows, wordsPerSplat = 8) {
|
|
67
|
+
const rowWords = splatRowWords(wordsPerSplat);
|
|
68
|
+
const from = firstRow * rowWords;
|
|
69
|
+
const needed = rows * rowWords;
|
|
70
|
+
if (from + needed <= packed.length)
|
|
71
|
+
return packed.subarray(from, from + needed);
|
|
72
|
+
const padded = new Uint32Array(needed);
|
|
73
|
+
padded.set(packed.subarray(from, Math.min(packed.length, from + needed)));
|
|
74
|
+
return padded;
|
|
75
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The two matrix operations this package needs, written once so no two callers disagree. */
|
|
2
|
+
/**
|
|
3
|
+
* `out = a * b`, column-major, sixteen multiply-adds and no dependency.
|
|
4
|
+
*
|
|
5
|
+
* **Written here rather than pulling in `gl-matrix`**: this package needs one matrix product, a
|
|
6
|
+
* few times a frame, and a runtime dependency is the thing `AGENTS.md` sets a high bar for. It is
|
|
7
|
+
* in its own module rather than beside either caller because there are two of them — the pass
|
|
8
|
+
* folding the clip correction into a projection, and the cull building a clip matrix — and two
|
|
9
|
+
* copies of one decision is the 2026-08-17 rule however short the decision is.
|
|
10
|
+
*/
|
|
11
|
+
export declare function multiplyMat4(out: Float32Array, a: ArrayLike<number>, b: ArrayLike<number>): void;
|
|
12
|
+
/**
|
|
13
|
+
* Where the camera sits in a capture's own space, from the view and the model matrices.
|
|
14
|
+
*
|
|
15
|
+
* **View-dependent colour needs the direction in the frame the coefficients were trained in**, not
|
|
16
|
+
* in world space: a capture turned, moved or scaled into a scene by `model` has had its whole
|
|
17
|
+
* lighting rotated with it, and evaluating the harmonics against a world-space direction produces
|
|
18
|
+
* a sheen that stays put while the capture turns underneath it.
|
|
19
|
+
*
|
|
20
|
+
* The camera is the origin of view space, so the point wanted is `inverse(view * model)` applied
|
|
21
|
+
* to the origin — which is `−inverse(M3) · t` for the product's 3x3 part and its translation
|
|
22
|
+
* column, and needs no full 4x4 inverse. A singular 3x3 means a model matrix that collapses the
|
|
23
|
+
* capture to a plane, and the answer is then the origin rather than a division by zero: a capture
|
|
24
|
+
* with no volume draws nothing whose colour anybody sees.
|
|
25
|
+
*
|
|
26
|
+
* Called once a frame per capture, which is what keeps it off the vertex stage where it would be
|
|
27
|
+
* once per splat times six.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cameraInCaptureSpace(out: Float32Array, view: ArrayLike<number>, model: ArrayLike<number>): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The two matrix operations this package needs, written once so no two callers disagree. */
|
|
2
|
+
/**
|
|
3
|
+
* `out = a * b`, column-major, sixteen multiply-adds and no dependency.
|
|
4
|
+
*
|
|
5
|
+
* **Written here rather than pulling in `gl-matrix`**: this package needs one matrix product, a
|
|
6
|
+
* few times a frame, and a runtime dependency is the thing `AGENTS.md` sets a high bar for. It is
|
|
7
|
+
* in its own module rather than beside either caller because there are two of them — the pass
|
|
8
|
+
* folding the clip correction into a projection, and the cull building a clip matrix — and two
|
|
9
|
+
* copies of one decision is the 2026-08-17 rule however short the decision is.
|
|
10
|
+
*/
|
|
11
|
+
export function multiplyMat4(out, a, b) {
|
|
12
|
+
for (let column = 0; column < 4; column++) {
|
|
13
|
+
const b0 = b[column * 4] ?? 0;
|
|
14
|
+
const b1 = b[column * 4 + 1] ?? 0;
|
|
15
|
+
const b2 = b[column * 4 + 2] ?? 0;
|
|
16
|
+
const b3 = b[column * 4 + 3] ?? 0;
|
|
17
|
+
for (let row = 0; row < 4; row++) {
|
|
18
|
+
out[column * 4 + row] =
|
|
19
|
+
(a[row] ?? 0) * b0 +
|
|
20
|
+
(a[4 + row] ?? 0) * b1 +
|
|
21
|
+
(a[8 + row] ?? 0) * b2 +
|
|
22
|
+
(a[12 + row] ?? 0) * b3;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Where the camera sits in a capture's own space, from the view and the model matrices.
|
|
28
|
+
*
|
|
29
|
+
* **View-dependent colour needs the direction in the frame the coefficients were trained in**, not
|
|
30
|
+
* in world space: a capture turned, moved or scaled into a scene by `model` has had its whole
|
|
31
|
+
* lighting rotated with it, and evaluating the harmonics against a world-space direction produces
|
|
32
|
+
* a sheen that stays put while the capture turns underneath it.
|
|
33
|
+
*
|
|
34
|
+
* The camera is the origin of view space, so the point wanted is `inverse(view * model)` applied
|
|
35
|
+
* to the origin — which is `−inverse(M3) · t` for the product's 3x3 part and its translation
|
|
36
|
+
* column, and needs no full 4x4 inverse. A singular 3x3 means a model matrix that collapses the
|
|
37
|
+
* capture to a plane, and the answer is then the origin rather than a division by zero: a capture
|
|
38
|
+
* with no volume draws nothing whose colour anybody sees.
|
|
39
|
+
*
|
|
40
|
+
* Called once a frame per capture, which is what keeps it off the vertex stage where it would be
|
|
41
|
+
* once per splat times six.
|
|
42
|
+
*/
|
|
43
|
+
export function cameraInCaptureSpace(out, view, model) {
|
|
44
|
+
multiplyMat4(PRODUCT, view, model);
|
|
45
|
+
const m = PRODUCT;
|
|
46
|
+
const a = m[0] ?? 0, b = m[4] ?? 0, c = m[8] ?? 0;
|
|
47
|
+
const d = m[1] ?? 0, e = m[5] ?? 0, f = m[9] ?? 0;
|
|
48
|
+
const g = m[2] ?? 0, h = m[6] ?? 0, i = m[10] ?? 0;
|
|
49
|
+
const cofactor0 = e * i - f * h;
|
|
50
|
+
const cofactor1 = f * g - d * i;
|
|
51
|
+
const cofactor2 = d * h - e * g;
|
|
52
|
+
const determinant = a * cofactor0 + b * cofactor1 + c * cofactor2;
|
|
53
|
+
if (determinant === 0 || !Number.isFinite(determinant)) {
|
|
54
|
+
out[0] = 0;
|
|
55
|
+
out[1] = 0;
|
|
56
|
+
out[2] = 0;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const inverse = 1 / determinant;
|
|
60
|
+
const tx = m[12] ?? 0;
|
|
61
|
+
const ty = m[13] ?? 0;
|
|
62
|
+
const tz = m[14] ?? 0;
|
|
63
|
+
out[0] = -(cofactor0 * tx + (c * h - b * i) * ty + (b * f - c * e) * tz) * inverse;
|
|
64
|
+
out[1] = -(cofactor1 * tx + (a * i - c * g) * ty + (c * d - a * f) * tz) * inverse;
|
|
65
|
+
out[2] = -(cofactor2 * tx + (b * g - a * h) * ty + (a * e - b * d) * tz) * inverse;
|
|
66
|
+
}
|
|
67
|
+
/** Scratch for the product above, so a frame allocates nothing. */
|
|
68
|
+
const PRODUCT = new Float32Array(16);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** A splat capture as a pass a consumer registers, drawn by whichever backend it is handed. */
|
|
2
|
+
import type { SplatData } from './splatData.ts';
|
|
3
|
+
import type { PassDefinition } from '@driftengine/core';
|
|
4
|
+
/**
|
|
5
|
+
* What a consumer hands the pass each frame.
|
|
6
|
+
*
|
|
7
|
+
* **The camera comes from the caller rather than from the renderer**, because `PassContext`
|
|
8
|
+
* carries what the *frame* is and not what is being looked at — and a contributed pass has no
|
|
9
|
+
* business reaching into the renderer for a matrix the caller already has. It is the same shape
|
|
10
|
+
* every draw verb takes: the caller says where it is looking.
|
|
11
|
+
*/
|
|
12
|
+
export interface SplatView {
|
|
13
|
+
/**
|
|
14
|
+
* Column-major, as `Camera` publishes them.
|
|
15
|
+
*
|
|
16
|
+
* `ArrayLike<number>` rather than `Float32Array` because that is what `gl-matrix`'s `mat4` is,
|
|
17
|
+
* and a consumer handing over `camera.view` should not have to cast a matrix the engine gave it.
|
|
18
|
+
*/
|
|
19
|
+
readonly view: ArrayLike<number>;
|
|
20
|
+
readonly projection: ArrayLike<number>;
|
|
21
|
+
/** In pixels, because the ellipse is built in pixel space. */
|
|
22
|
+
readonly widthPx: number;
|
|
23
|
+
readonly heightPx: number;
|
|
24
|
+
}
|
|
25
|
+
export interface SplatPass extends PassDefinition {
|
|
26
|
+
/** Called once a frame, before `drawPass`. Allocates nothing. */
|
|
27
|
+
setView(view: SplatView): void;
|
|
28
|
+
/**
|
|
29
|
+
* A new draw order, far to near. Four bytes a splat, into storage allocated once.
|
|
30
|
+
*
|
|
31
|
+
* Until one is set the pass draws nothing: an unsorted capture composited back to front is
|
|
32
|
+
* wrong at every silhouette, and drawing it anyway would look like a working feature.
|
|
33
|
+
*/
|
|
34
|
+
setOrder(order: Uint32Array, count: number): void;
|
|
35
|
+
/**
|
|
36
|
+
* The capture's own transform, so two captures compose in one scene.
|
|
37
|
+
*
|
|
38
|
+
* **Two batches are two orders and there is no order between them**, and that is a real limit
|
|
39
|
+
* rather than an omission. Each sorter ranks its own splats along the view direction expressed
|
|
40
|
+
* in that batch's space, so the two are each internally correct and the renderer draws one
|
|
41
|
+
* batch's whole cloud before the other's. Where they occupy different volumes — a statue and the
|
|
42
|
+
* room behind it — nothing shows. Where they interpenetrate, the seam is visible as a plane at
|
|
43
|
+
* which one capture starts winning every blend.
|
|
44
|
+
*
|
|
45
|
+
* What that buys is that a batch's positions never move: one transform on the camera instead of
|
|
46
|
+
* a million on the splats, every frame. What would make it wrong is a scene built from
|
|
47
|
+
* overlapping parts, where the answer is one batch with one order rather than a merge — merging
|
|
48
|
+
* two sorted orders is cheap, but the splats would still be drawn from two textures with two
|
|
49
|
+
* draw calls, so the merge has nowhere to go.
|
|
50
|
+
*/
|
|
51
|
+
setModel(model: ArrayLike<number>): void;
|
|
52
|
+
/**
|
|
53
|
+
* Push a run of splats that has just arrived into the data texture.
|
|
54
|
+
*
|
|
55
|
+
* **For a capture that is still streaming**, where the textures were sized for the final count
|
|
56
|
+
* at registration and are filled block by block — see `SplatCapture`. A caller that packed its
|
|
57
|
+
* whole capture before registering the pass never needs this: `init` uploads everything.
|
|
58
|
+
*
|
|
59
|
+
* Whole rows are re-sent, so calling this with overlapping ranges is correct and merely costs
|
|
60
|
+
* a few kilobytes; calling it every frame with the whole capture is not, and is the per-frame
|
|
61
|
+
* upload the scheduler exists to avoid.
|
|
62
|
+
*/
|
|
63
|
+
uploadSplats(from: number, count: number): void;
|
|
64
|
+
/**
|
|
65
|
+
* Whether the capture reaches the frame at all, as of the last `setView`.
|
|
66
|
+
*
|
|
67
|
+
* **Read this before asking a sorter for a new order.** A capture out of frame costs one
|
|
68
|
+
* discarded draw call and a whole linear sort over every splat it has, and the sort is the
|
|
69
|
+
* expensive half — so the caller's frame should skip `SplatSorter.frame` when this is false.
|
|
70
|
+
* `draw` checks it too, but by then the sort has already happened.
|
|
71
|
+
*/
|
|
72
|
+
readonly visible: boolean;
|
|
73
|
+
readonly count: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build a pass for one capture.
|
|
77
|
+
*
|
|
78
|
+
* **The consumer decides where it lands in the frame**, by calling `drawPass` at that point. The
|
|
79
|
+
* documented slot is **after opaque and translucent meshes, before particles and light volumes**:
|
|
80
|
+
* splats are scene content that particles and beams are drawn *through*, and the depth test that
|
|
81
|
+
* makes the composition work only sees geometry drawn before it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createSplatPass(splats: SplatData, label?: string): SplatPass;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** A splat capture as a pass a consumer registers, drawn by whichever backend it is handed. */
|
|
2
|
+
import { SPLAT_BINDINGS } from './shaders/generated/splat.wgsl.js';
|
|
3
|
+
import { SPLAT_STRIDE, splatRows, splatTexels } from './splatLayout.js';
|
|
4
|
+
import { cameraInCaptureSpace, multiplyMat4 } from './splatMatrix.js';
|
|
5
|
+
import { splatBoundsVisible } from './splatCull.js';
|
|
6
|
+
import { createWebgl2Splats, disposeWebgl2Splats, drawWebgl2Splats, uploadWebgl2Order, uploadWebgl2SplatRange, } from './splatGl.js';
|
|
7
|
+
import { createGpuSplats, disposeGpuSplats, uploadGpuOrder, uploadGpuSplatRange, } from './splatGpu.js';
|
|
8
|
+
const VERT = SPLAT_BINDINGS.SPLAT_VERT;
|
|
9
|
+
const FRAG = SPLAT_BINDINGS.SPLAT_FRAG;
|
|
10
|
+
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
11
|
+
/**
|
|
12
|
+
* Build a pass for one capture.
|
|
13
|
+
*
|
|
14
|
+
* **The consumer decides where it lands in the frame**, by calling `drawPass` at that point. The
|
|
15
|
+
* documented slot is **after opaque and translucent meshes, before particles and light volumes**:
|
|
16
|
+
* splats are scene content that particles and beams are drawn *through*, and the depth test that
|
|
17
|
+
* makes the composition work only sees geometry drawn before it.
|
|
18
|
+
*/
|
|
19
|
+
export function createSplatPass(splats, label = 'splats') {
|
|
20
|
+
let gl = null;
|
|
21
|
+
let gpu = null;
|
|
22
|
+
let device = null;
|
|
23
|
+
const rows = splatRows(splats.count);
|
|
24
|
+
/* One padded row buffer, reused by every order upload so a re-sort allocates nothing. */
|
|
25
|
+
const paddedOrder = new Uint32Array(SPLAT_STRIDE * rows);
|
|
26
|
+
const model = new Float32Array(IDENTITY);
|
|
27
|
+
let drawCount = 0;
|
|
28
|
+
let pendingOrder = null;
|
|
29
|
+
/** A span of newly arrived splats waiting for a WebGL2 context. See `uploadSplats`. */
|
|
30
|
+
let pendingRange = null;
|
|
31
|
+
const view = new Float32Array(16);
|
|
32
|
+
/** The caller's projection, pre-multiplied by the backend's clip correction. */
|
|
33
|
+
const projection = new Float32Array(16);
|
|
34
|
+
/**
|
|
35
|
+
* Where the camera is in the capture's own space, which is where the harmonics were trained.
|
|
36
|
+
*
|
|
37
|
+
* Recomputed whenever the view or the model changes rather than every frame, because both are
|
|
38
|
+
* setters and neither is called more than once a frame. Zero for a capture with no harmonics,
|
|
39
|
+
* where nothing reads it.
|
|
40
|
+
*/
|
|
41
|
+
const cameraLocal = new Float32Array(3);
|
|
42
|
+
const shDegree = splats.shDegree;
|
|
43
|
+
const texels = splatTexels(splats.wordsPerSplat);
|
|
44
|
+
/* Identity until `init`, so a `setView` before registration is not silently zeroed. */
|
|
45
|
+
let clipCorrection = IDENTITY;
|
|
46
|
+
let viewportX = 1;
|
|
47
|
+
let viewportY = 1;
|
|
48
|
+
/* True until the first `setView`, so a caller that never sets one still draws. */
|
|
49
|
+
let visible = true;
|
|
50
|
+
const writeVertexUniforms = (target) => {
|
|
51
|
+
const f = target.vertexFloats;
|
|
52
|
+
const i = target.vertexInts;
|
|
53
|
+
i[VERT.fields.uSplatCount.offset / 4] = drawCount;
|
|
54
|
+
i[VERT.fields.uSplatStride.offset / 4] = SPLAT_STRIDE;
|
|
55
|
+
i[VERT.fields.uSplatTexels.offset / 4] = texels;
|
|
56
|
+
i[VERT.fields.uSplatShDegree.offset / 4] = shDegree;
|
|
57
|
+
f.set(cameraLocal, VERT.fields.uSplatCameraLocal.offset / 4);
|
|
58
|
+
f.set(view, VERT.fields.uView.offset / 4);
|
|
59
|
+
f.set(projection, VERT.fields.uProjection.offset / 4);
|
|
60
|
+
f[VERT.fields.uViewport.offset / 4] = viewportX;
|
|
61
|
+
f[VERT.fields.uViewport.offset / 4 + 1] = viewportY;
|
|
62
|
+
f.set(model, VERT.fields.uModel.offset / 4);
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
label,
|
|
66
|
+
init(passDevice) {
|
|
67
|
+
/*
|
|
68
|
+
* **The clip correction the renderer's own verbs use, not a copy of it.** WebGPU's
|
|
69
|
+
* framebuffer origin is the top-left and OpenGL's is the bottom-left, and the engine settles
|
|
70
|
+
* that in the matrix rather than in the shaders. This pass takes its camera from its caller,
|
|
71
|
+
* so it never sees the corrected matrix — without this it drew the world upside down on
|
|
72
|
+
* WebGPU, which is exactly the first frame that backend ever produced.
|
|
73
|
+
*/
|
|
74
|
+
clipCorrection = passDevice.clipCorrection;
|
|
75
|
+
if (passDevice.backend === 'webgl2') {
|
|
76
|
+
gl = createWebgl2Splats(passDevice.gl, splats, label);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
device = passDevice.device;
|
|
80
|
+
gpu = createGpuSplats(passDevice.device, passDevice.format, passDevice.depthFormat, passDevice.samples, splats, label);
|
|
81
|
+
},
|
|
82
|
+
setView(next) {
|
|
83
|
+
view.set(next.view);
|
|
84
|
+
multiplyMat4(projection, clipCorrection, next.projection);
|
|
85
|
+
viewportX = next.widthPx;
|
|
86
|
+
viewportY = next.heightPx;
|
|
87
|
+
/*
|
|
88
|
+
* Culled against the caller's **uncorrected** projection, because the frustum planes come
|
|
89
|
+
* out of it in the convention `mat4.perspective` writes; the corrected one has moved z into
|
|
90
|
+
* [0, 1] and flipped y, and its near plane would be a different plane.
|
|
91
|
+
*/
|
|
92
|
+
visible = splatBoundsVisible(next.view, next.projection, model, splats.boundsMin, splats.boundsMax);
|
|
93
|
+
if (shDegree > 0)
|
|
94
|
+
cameraInCaptureSpace(cameraLocal, view, model);
|
|
95
|
+
},
|
|
96
|
+
setModel(next) {
|
|
97
|
+
model.set(next);
|
|
98
|
+
/* The camera's place in the capture's space moves when either matrix does, and a model set
|
|
99
|
+
after a view would otherwise evaluate the harmonics against the previous placement. */
|
|
100
|
+
if (shDegree > 0)
|
|
101
|
+
cameraInCaptureSpace(cameraLocal, view, model);
|
|
102
|
+
},
|
|
103
|
+
uploadSplats(from, count) {
|
|
104
|
+
const first = Math.max(0, Math.min(from, splats.count));
|
|
105
|
+
const howMany = Math.max(0, Math.min(count, splats.count - first));
|
|
106
|
+
if (howMany <= 0)
|
|
107
|
+
return;
|
|
108
|
+
if (gpu !== null && device !== null) {
|
|
109
|
+
uploadGpuSplatRange(device, gpu, splats.packed, first, howMany);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
/*
|
|
113
|
+
* WebGL2 has no context outside `draw`, so the range is remembered and pushed there — the
|
|
114
|
+
* same reason `setOrder` holds a pending order. Ranges are merged rather than queued
|
|
115
|
+
* because they arrive in file order and a merged span re-sends whole rows anyway.
|
|
116
|
+
*/
|
|
117
|
+
if (pendingRange === null)
|
|
118
|
+
pendingRange = { from: first, to: first + howMany };
|
|
119
|
+
else {
|
|
120
|
+
pendingRange.from = Math.min(pendingRange.from, first);
|
|
121
|
+
pendingRange.to = Math.max(pendingRange.to, first + howMany);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
setOrder(order, count) {
|
|
125
|
+
drawCount = Math.max(0, Math.min(count, splats.count));
|
|
126
|
+
/*
|
|
127
|
+
* Held rather than uploaded here, because a consumer may sort before the pass has a device:
|
|
128
|
+
* `registerPass` runs `init` at registration, but a sorter finishing first is ordinary and
|
|
129
|
+
* dropping its result would leave the capture blank until the view turned again.
|
|
130
|
+
*/
|
|
131
|
+
pendingOrder = order;
|
|
132
|
+
if (gl !== null) {
|
|
133
|
+
// The upload needs a context, which only `draw` is handed on this backend.
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (gpu !== null && device !== null) {
|
|
137
|
+
uploadGpuOrder(device, gpu, order, paddedOrder);
|
|
138
|
+
pendingOrder = null;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
draw(ctx) {
|
|
142
|
+
if (drawCount <= 0 || !visible)
|
|
143
|
+
return;
|
|
144
|
+
if (ctx.backend === 'webgl2') {
|
|
145
|
+
if (gl === null)
|
|
146
|
+
return;
|
|
147
|
+
if (pendingRange !== null) {
|
|
148
|
+
uploadWebgl2SplatRange(ctx.gl, gl, splats.packed, pendingRange.from, pendingRange.to - pendingRange.from);
|
|
149
|
+
pendingRange = null;
|
|
150
|
+
}
|
|
151
|
+
if (pendingOrder !== null) {
|
|
152
|
+
uploadWebgl2Order(ctx.gl, gl, pendingOrder, paddedOrder);
|
|
153
|
+
pendingOrder = null;
|
|
154
|
+
}
|
|
155
|
+
const { uniforms } = gl;
|
|
156
|
+
ctx.gl.useProgram(gl.program);
|
|
157
|
+
ctx.gl.uniform1i(uniforms['uSplatCount'] ?? null, drawCount);
|
|
158
|
+
ctx.gl.uniform1i(uniforms['uSplatStride'] ?? null, SPLAT_STRIDE);
|
|
159
|
+
ctx.gl.uniform1i(uniforms['uSplatTexels'] ?? null, texels);
|
|
160
|
+
ctx.gl.uniform1i(uniforms['uSplatShDegree'] ?? null, shDegree);
|
|
161
|
+
ctx.gl.uniform3fv(uniforms['uSplatCameraLocal'] ?? null, cameraLocal);
|
|
162
|
+
ctx.gl.uniformMatrix4fv(uniforms['uView'] ?? null, false, view);
|
|
163
|
+
ctx.gl.uniformMatrix4fv(uniforms['uProjection'] ?? null, false, projection);
|
|
164
|
+
ctx.gl.uniform2f(uniforms['uViewport'] ?? null, viewportX, viewportY);
|
|
165
|
+
ctx.gl.uniformMatrix4fv(uniforms['uModel'] ?? null, false, model);
|
|
166
|
+
/* The frame's own grade, per `PassContext`: this is a forward pass and may be last. */
|
|
167
|
+
ctx.gl.uniform1i(uniforms['uOutputTransform'] ?? null, ctx.outputTransform);
|
|
168
|
+
ctx.gl.uniform1f(uniforms['uOutputExposure'] ?? null, ctx.outputExposure);
|
|
169
|
+
drawWebgl2Splats(ctx.gl, gl, drawCount);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (gpu === null || device === null)
|
|
173
|
+
return;
|
|
174
|
+
if (pendingOrder !== null) {
|
|
175
|
+
uploadGpuOrder(device, gpu, pendingOrder, paddedOrder);
|
|
176
|
+
pendingOrder = null;
|
|
177
|
+
}
|
|
178
|
+
writeVertexUniforms(gpu);
|
|
179
|
+
device.queue.writeBuffer(gpu.vertexUniforms, 0, gpu.vertexScratch);
|
|
180
|
+
gpu.fragmentInts[FRAG.fields.uOutputTransform.offset / 4] = ctx.outputTransform;
|
|
181
|
+
gpu.fragmentFloats[FRAG.fields.uOutputExposure.offset / 4] = ctx.outputExposure;
|
|
182
|
+
device.queue.writeBuffer(gpu.fragmentUniforms, 0, gpu.fragmentScratch);
|
|
183
|
+
ctx.pass.setPipeline(gpu.pipeline);
|
|
184
|
+
ctx.pass.setBindGroup(0, gpu.bindGroup);
|
|
185
|
+
ctx.pass.draw(drawCount * 6);
|
|
186
|
+
},
|
|
187
|
+
dispose(passDevice) {
|
|
188
|
+
if (passDevice.backend === 'webgl2') {
|
|
189
|
+
if (gl !== null)
|
|
190
|
+
disposeWebgl2Splats(passDevice.gl, gl);
|
|
191
|
+
gl = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (gpu !== null)
|
|
195
|
+
disposeGpuSplats(gpu);
|
|
196
|
+
gpu = null;
|
|
197
|
+
device = null;
|
|
198
|
+
},
|
|
199
|
+
get visible() {
|
|
200
|
+
return visible;
|
|
201
|
+
},
|
|
202
|
+
get count() {
|
|
203
|
+
return splats.count;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The `.ply` reader: an ASCII header describing a binary body, as a training run writes it. */
|
|
2
|
+
import type { SplatData } from './splatData.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Read a `.ply` Gaussian capture.
|
|
5
|
+
*
|
|
6
|
+
* **The encodings are the whole of this function's risk.** Scale is stored as its *logarithm* and
|
|
7
|
+
* opacity as its *logit*, so both are undone here — and here only, because `packSplats` documents
|
|
8
|
+
* that it takes linear values. Getting the boundary wrong produces a capture that is either
|
|
9
|
+
* invisible or a solid block, and both have been reported against other viewers as rendering bugs.
|
|
10
|
+
*
|
|
11
|
+
* **The file stores rotation as wxyz** — `rot_0` is the real part — and `SplatSource` takes xyzw,
|
|
12
|
+
* so the reorder happens here for the same reason it happens in the `.splat` reader.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readSplatPly(buffer: ArrayBuffer): SplatData;
|