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