@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,223 @@
|
|
|
1
|
+
/** The shape every splat reader produces, and the packing that turns it into two GPU texels. */
|
|
2
|
+
import { packHalf2x16 } from './half.js';
|
|
3
|
+
/** How many `uint32`s a splat occupies with no view-dependent colour: two `RGBA32UI` texels. */
|
|
4
|
+
export const SPLAT_WORDS = 8;
|
|
5
|
+
/**
|
|
6
|
+
* The same with degree-1 view-dependent colour: **one more texel**, and no more than one.
|
|
7
|
+
*
|
|
8
|
+
* The costing in `sphericalHarmonics` below is what picked degree 1 and it is worth reading before
|
|
9
|
+
* anybody raises this. Nine coefficients fit a texel as bytes with a per-splat scale beside them,
|
|
10
|
+
* which is +50% on the record and on the read; degree 2 is three more texels and degree 3 is six,
|
|
11
|
+
* and degree 3 alone would put 307 MB a frame through a device already carrying 389.
|
|
12
|
+
*/
|
|
13
|
+
export const SPLAT_WORDS_SH1 = 12;
|
|
14
|
+
/** Three basis functions at l=1, three channels each. */
|
|
15
|
+
export const SPLAT_SH1_COEFFICIENTS = 9;
|
|
16
|
+
/* Scratch for one splat's rotation matrix, so the packing loop allocates nothing. */
|
|
17
|
+
const rotation = new Float32Array(9);
|
|
18
|
+
/**
|
|
19
|
+
* Turn a source into two texels per splat, and compute the capture's bounds while the loop is open.
|
|
20
|
+
*
|
|
21
|
+
* Texel 0 is the three position floats reinterpreted as `uint32` plus the colour as `RGBA8`.
|
|
22
|
+
* Texel 1 is the six unique terms of the 3x3 covariance as three half pairs, with the fourth
|
|
23
|
+
* component reserved and written zero.
|
|
24
|
+
*
|
|
25
|
+
* **The covariance is computed here and never again.** It is fixed for the lifetime of a capture,
|
|
26
|
+
* so evaluating it per splat per frame would cost a quaternion-to-matrix and two 3x3 multiplies
|
|
27
|
+
* for a value that cannot change. What that gives up is six halves of precision against the
|
|
28
|
+
* source's floats; what would make it wrong is a capture whose splats are animated, which is not a
|
|
29
|
+
* thing this format can express.
|
|
30
|
+
*/
|
|
31
|
+
export function packSplats(source) {
|
|
32
|
+
const { count, positions, scales, rotations, colors, opacities, sh1 } = source;
|
|
33
|
+
expectLength('positions', positions, count * 3);
|
|
34
|
+
expectLength('scales', scales, count * 3);
|
|
35
|
+
expectLength('rotations', rotations, count * 4);
|
|
36
|
+
expectLength('colors', colors, count * 3);
|
|
37
|
+
expectLength('opacities', opacities, count);
|
|
38
|
+
if (sh1 !== undefined)
|
|
39
|
+
expectLength('sh1', sh1, count * SPLAT_SH1_COEFFICIENTS);
|
|
40
|
+
const wordsPerSplat = sh1 === undefined ? SPLAT_WORDS : SPLAT_WORDS_SH1;
|
|
41
|
+
const packed = new Uint32Array(count * wordsPerSplat);
|
|
42
|
+
/* Reinterpreting rather than converting: the shader reads these back with the inverse. */
|
|
43
|
+
const asFloat = new Float32Array(packed.buffer);
|
|
44
|
+
const boundsMin = new Float32Array([Infinity, Infinity, Infinity]);
|
|
45
|
+
const boundsMax = new Float32Array([-Infinity, -Infinity, -Infinity]);
|
|
46
|
+
const extents = new Float32Array(count);
|
|
47
|
+
for (let index = 0; index < count; index++) {
|
|
48
|
+
const at = index * wordsPerSplat;
|
|
49
|
+
const p = index * 3;
|
|
50
|
+
const x = positions[p] ?? 0;
|
|
51
|
+
const y = positions[p + 1] ?? 0;
|
|
52
|
+
const z = positions[p + 2] ?? 0;
|
|
53
|
+
asFloat[at] = x;
|
|
54
|
+
asFloat[at + 1] = y;
|
|
55
|
+
asFloat[at + 2] = z;
|
|
56
|
+
if (x < (boundsMin[0] ?? 0))
|
|
57
|
+
boundsMin[0] = x;
|
|
58
|
+
if (y < (boundsMin[1] ?? 0))
|
|
59
|
+
boundsMin[1] = y;
|
|
60
|
+
if (z < (boundsMin[2] ?? 0))
|
|
61
|
+
boundsMin[2] = z;
|
|
62
|
+
if (x > (boundsMax[0] ?? 0))
|
|
63
|
+
boundsMax[0] = x;
|
|
64
|
+
if (y > (boundsMax[1] ?? 0))
|
|
65
|
+
boundsMax[1] = y;
|
|
66
|
+
if (z > (boundsMax[2] ?? 0))
|
|
67
|
+
boundsMax[2] = z;
|
|
68
|
+
/* `unpackUnorm4x8` reads the lowest byte as x, so red goes in the lowest byte. */
|
|
69
|
+
packed[at + 3] =
|
|
70
|
+
(byte(colors[p]) |
|
|
71
|
+
(byte(colors[p + 1]) << 8) |
|
|
72
|
+
(byte(colors[p + 2]) << 16) |
|
|
73
|
+
(byte(opacities[index]) << 24)) >>>
|
|
74
|
+
0;
|
|
75
|
+
quaternionToMatrix(rotations, index * 4, index);
|
|
76
|
+
/*
|
|
77
|
+
* Sigma = R diag(s^2) R^T, six unique terms. Written as a sum over the three columns rather
|
|
78
|
+
* than as two matrix products, which is the same arithmetic with nothing to allocate: column
|
|
79
|
+
* j of R scaled by s_j is column j of R*S, and Sigma[i][k] is the dot of rows i and k of that.
|
|
80
|
+
*/
|
|
81
|
+
const sx = scales[p] ?? 0;
|
|
82
|
+
const sy = scales[p + 1] ?? 0;
|
|
83
|
+
const sz = scales[p + 2] ?? 0;
|
|
84
|
+
extents[index] = Math.max(sx, sy, sz);
|
|
85
|
+
const w0 = sx * sx;
|
|
86
|
+
const w1 = sy * sy;
|
|
87
|
+
const w2 = sz * sz;
|
|
88
|
+
const r00 = rotation[0] ?? 0;
|
|
89
|
+
const r01 = rotation[1] ?? 0;
|
|
90
|
+
const r02 = rotation[2] ?? 0;
|
|
91
|
+
const r10 = rotation[3] ?? 0;
|
|
92
|
+
const r11 = rotation[4] ?? 0;
|
|
93
|
+
const r12 = rotation[5] ?? 0;
|
|
94
|
+
const r20 = rotation[6] ?? 0;
|
|
95
|
+
const r21 = rotation[7] ?? 0;
|
|
96
|
+
const r22 = rotation[8] ?? 0;
|
|
97
|
+
const xx = r00 * r00 * w0 + r01 * r01 * w1 + r02 * r02 * w2;
|
|
98
|
+
const xy = r00 * r10 * w0 + r01 * r11 * w1 + r02 * r12 * w2;
|
|
99
|
+
const xz = r00 * r20 * w0 + r01 * r21 * w1 + r02 * r22 * w2;
|
|
100
|
+
const yy = r10 * r10 * w0 + r11 * r11 * w1 + r12 * r12 * w2;
|
|
101
|
+
const yz = r10 * r20 * w0 + r11 * r21 * w1 + r12 * r22 * w2;
|
|
102
|
+
const zz = r20 * r20 * w0 + r21 * r21 * w1 + r22 * r22 * w2;
|
|
103
|
+
packed[at + 4] = packHalf2x16(xx, xy);
|
|
104
|
+
packed[at + 5] = packHalf2x16(xz, yy);
|
|
105
|
+
packed[at + 6] = packHalf2x16(yz, zz);
|
|
106
|
+
/*
|
|
107
|
+
* The extent, as float bits, in what texel one's fourth component would otherwise waste.
|
|
108
|
+
*
|
|
109
|
+
* **The GPU never reads it** — the vertex stage takes three of that texel's four components
|
|
110
|
+
* for the covariance — so this costs the draw nothing and it means a `.drft` splat block can
|
|
111
|
+
* be exactly the packed record. Carrying a parallel four bytes a splat instead would be
|
|
112
|
+
* twelve percent on the one payload this engine most wants small. `extents` still exists
|
|
113
|
+
* beside it because the sort reads it in a tight loop, where reinterpreting bits per access
|
|
114
|
+
* would not be free. What would make this wrong is a later shader wanting that component for
|
|
115
|
+
* something else, at which point the block grows a ninth word and says so in its header.
|
|
116
|
+
*/
|
|
117
|
+
asFloat[at + 7] = extents[index] ?? 0;
|
|
118
|
+
if (sh1 !== undefined)
|
|
119
|
+
packSh1(sh1, index, packed, asFloat, at + 8);
|
|
120
|
+
}
|
|
121
|
+
/* A capture of nothing has no extent, and Infinity in a bound is a frustum test that never
|
|
122
|
+
answers. Collapse to the origin, which a zero-count batch never reaches anyway. */
|
|
123
|
+
if (count === 0) {
|
|
124
|
+
boundsMin.fill(0);
|
|
125
|
+
boundsMax.fill(0);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
count,
|
|
129
|
+
positions,
|
|
130
|
+
packed,
|
|
131
|
+
wordsPerSplat,
|
|
132
|
+
boundsMin,
|
|
133
|
+
boundsMax,
|
|
134
|
+
extents,
|
|
135
|
+
sphericalHarmonics: 0,
|
|
136
|
+
shDegree: sh1 === undefined ? 0 : 1,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* One splat's l=1 band into one `RGBA32UI` texel: nine bytes and a scale.
|
|
141
|
+
*
|
|
142
|
+
* **Bytes with a per-splat scale, and both halves of that are the decision.** Nine halves would be
|
|
143
|
+
* eighteen bytes and would not fit a texel at all; nine bytes against a *capture-wide* range would
|
|
144
|
+
* fit and would spend most of the range on the few splats with the largest coefficients, leaving
|
|
145
|
+
* the ordinary ones quantised to a handful of levels. A per-splat scale costs the sixteenth byte —
|
|
146
|
+
* which is four bytes as a float, filling the texel exactly — and gives every splat the whole
|
|
147
|
+
* 8-bit range for its own coefficients.
|
|
148
|
+
*
|
|
149
|
+
* A splat whose band is all zero gets a scale of zero and nine mid-range bytes, which the shader
|
|
150
|
+
* multiplies back to exactly zero. That is the common case in a capture's flat regions and it
|
|
151
|
+
* costs nothing to say so.
|
|
152
|
+
*
|
|
153
|
+
* The three spare bytes of the third word are written zero rather than left as whatever the
|
|
154
|
+
* allocation held: a `.drft` block is compared byte for byte by `drft-diff`, and a record with
|
|
155
|
+
* uninitialised padding is a file that differs from itself.
|
|
156
|
+
*/
|
|
157
|
+
function packSh1(sh1, index, packed, asFloat, at) {
|
|
158
|
+
const from = index * SPLAT_SH1_COEFFICIENTS;
|
|
159
|
+
let scale = 0;
|
|
160
|
+
for (let k = 0; k < SPLAT_SH1_COEFFICIENTS; k++) {
|
|
161
|
+
const magnitude = Math.abs(sh1[from + k] ?? 0);
|
|
162
|
+
if (magnitude > scale)
|
|
163
|
+
scale = magnitude;
|
|
164
|
+
}
|
|
165
|
+
const inverse = scale > 0 ? 1 / scale : 0;
|
|
166
|
+
/* Centre of the range, which is exactly zero once the scale multiplies it back. */
|
|
167
|
+
const quantise = (value) => Math.max(0, Math.min(255, Math.round((value ?? 0) * inverse * 127 + 128)));
|
|
168
|
+
packed[at] =
|
|
169
|
+
(quantise(sh1[from] ?? 0) |
|
|
170
|
+
(quantise(sh1[from + 1] ?? 0) << 8) |
|
|
171
|
+
(quantise(sh1[from + 2] ?? 0) << 16) |
|
|
172
|
+
(quantise(sh1[from + 3] ?? 0) << 24)) >>>
|
|
173
|
+
0;
|
|
174
|
+
packed[at + 1] =
|
|
175
|
+
(quantise(sh1[from + 4] ?? 0) |
|
|
176
|
+
(quantise(sh1[from + 5] ?? 0) << 8) |
|
|
177
|
+
(quantise(sh1[from + 6] ?? 0) << 16) |
|
|
178
|
+
(quantise(sh1[from + 7] ?? 0) << 24)) >>>
|
|
179
|
+
0;
|
|
180
|
+
packed[at + 2] = quantise(sh1[from + 8] ?? 0) >>> 0;
|
|
181
|
+
asFloat[at + 3] = scale;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Fill `rotation` from a quaternion at `offset`, normalising on the way.
|
|
185
|
+
*
|
|
186
|
+
* **A zero quaternion is a refusal naming the splat**, not a silent identity and not a `NaN` that
|
|
187
|
+
* reaches the GPU. A NaN covariance produces a splat whose ellipse fails every comparison, which
|
|
188
|
+
* on most drivers is an invisible splat and on some is one that covers the screen — and neither
|
|
189
|
+
* points at the file that caused it.
|
|
190
|
+
*/
|
|
191
|
+
function quaternionToMatrix(source, offset, index) {
|
|
192
|
+
const qx = source[offset] ?? 0;
|
|
193
|
+
const qy = source[offset + 1] ?? 0;
|
|
194
|
+
const qz = source[offset + 2] ?? 0;
|
|
195
|
+
const qw = source[offset + 3] ?? 0;
|
|
196
|
+
const length = Math.hypot(qx, qy, qz, qw);
|
|
197
|
+
if (length === 0 || !Number.isFinite(length)) {
|
|
198
|
+
throw new Error(`splat ${index} carries a rotation of length ${length}, which names no orientation. ` +
|
|
199
|
+
'A capture with a zero or non-finite quaternion is malformed at the source.');
|
|
200
|
+
}
|
|
201
|
+
const x = qx / length;
|
|
202
|
+
const y = qy / length;
|
|
203
|
+
const z = qz / length;
|
|
204
|
+
const w = qw / length;
|
|
205
|
+
rotation[0] = 1 - 2 * (y * y + z * z);
|
|
206
|
+
rotation[1] = 2 * (x * y - w * z);
|
|
207
|
+
rotation[2] = 2 * (x * z + w * y);
|
|
208
|
+
rotation[3] = 2 * (x * y + w * z);
|
|
209
|
+
rotation[4] = 1 - 2 * (x * x + z * z);
|
|
210
|
+
rotation[5] = 2 * (y * z - w * x);
|
|
211
|
+
rotation[6] = 2 * (x * z - w * y);
|
|
212
|
+
rotation[7] = 2 * (y * z + w * x);
|
|
213
|
+
rotation[8] = 1 - 2 * (x * x + y * y);
|
|
214
|
+
}
|
|
215
|
+
function byte(value) {
|
|
216
|
+
const clamped = Math.max(0, Math.min(1, value ?? 0));
|
|
217
|
+
return Math.round(clamped * 255);
|
|
218
|
+
}
|
|
219
|
+
function expectLength(name, array, expected) {
|
|
220
|
+
if (array.length === expected)
|
|
221
|
+
return;
|
|
222
|
+
throw new Error(`${name} has ${array.length} entries where a capture of this count needs ${expected}.`);
|
|
223
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** The WebGL2 half of the splat pass: two integer textures, one program, no vertex state at all. */
|
|
2
|
+
import type { SplatData } from './splatData.ts';
|
|
3
|
+
export interface Webgl2Splats {
|
|
4
|
+
readonly program: WebGLProgram;
|
|
5
|
+
readonly vao: WebGLVertexArrayObject;
|
|
6
|
+
readonly data: WebGLTexture;
|
|
7
|
+
readonly order: WebGLTexture;
|
|
8
|
+
readonly uniforms: Readonly<Record<string, WebGLUniformLocation | null>>;
|
|
9
|
+
readonly rows: number;
|
|
10
|
+
/** Two, or three for a capture with view-dependent colour. See `splatTexels`. */
|
|
11
|
+
readonly texels: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build the program, the two textures and the empty vertex array.
|
|
15
|
+
*
|
|
16
|
+
* **The vertex array holds nothing and still has to exist.** The geometry is `gl_VertexID`
|
|
17
|
+
* arithmetic, so there is no buffer to describe — but a draw with the default vertex array bound
|
|
18
|
+
* is invalid in a core WebGL2 context, which `demo/contributedPass.ts` already records.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createWebgl2Splats(gl: WebGL2RenderingContext, splats: SplatData, label: string): Webgl2Splats;
|
|
21
|
+
/**
|
|
22
|
+
* Upload a new draw order. Four bytes a splat and nothing else moves.
|
|
23
|
+
*
|
|
24
|
+
* **`texSubImage2D` into storage allocated once**, never a reallocation: this runs whenever the
|
|
25
|
+
* view has turned enough to want a re-sort, and allocating a texture per sort is the per-frame
|
|
26
|
+
* allocation the house rules are about.
|
|
27
|
+
*/
|
|
28
|
+
export declare function uploadWebgl2Order(gl: WebGL2RenderingContext, splats: Webgl2Splats, order: Uint32Array, padded: Uint32Array): void;
|
|
29
|
+
/**
|
|
30
|
+
* Upload a run of splats that has just arrived, and nothing else.
|
|
31
|
+
*
|
|
32
|
+
* **Whole rows at a time**, because `texSubImage2D` takes a rectangle and a partial row is a
|
|
33
|
+
* second call with different arithmetic for no gain: a row is 1,024 splats, so rounding a block
|
|
34
|
+
* out to row boundaries re-sends at most 32 KB and the row a block ends in is re-sent once when
|
|
35
|
+
* the next block completes it. A capture streams in a handful of blocks, so that is a few
|
|
36
|
+
* kilobytes over a load rather than a per-frame cost.
|
|
37
|
+
*/
|
|
38
|
+
export declare function uploadWebgl2SplatRange(gl: WebGL2RenderingContext, splats: Webgl2Splats, packed: Uint32Array, from: number, count: number): void;
|
|
39
|
+
/**
|
|
40
|
+
* Draw, and put back every piece of state this borrowed.
|
|
41
|
+
*
|
|
42
|
+
* **The contract in `PassDefinition.draw`**: the renderer's own verbs assume what they left, and a
|
|
43
|
+
* leaked blend function is the class of bug that shows up three scenes away. Depth *test* on and
|
|
44
|
+
* depth *write* off is the composition claim — a wall in front of a capture occludes it, and a
|
|
45
|
+
* Gaussian has no surface to occlude anything with, so a cloud that wrote depth would cull its
|
|
46
|
+
* own tail.
|
|
47
|
+
*/
|
|
48
|
+
export declare function drawWebgl2Splats(gl: WebGL2RenderingContext, splats: Webgl2Splats, count: number): void;
|
|
49
|
+
export declare function disposeWebgl2Splats(gl: WebGL2RenderingContext, splats: Webgl2Splats): void;
|
package/dist/splatGl.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** The WebGL2 half of the splat pass: two integer textures, one program, no vertex state at all. */
|
|
2
|
+
import { SPLAT_STRIDE, checkSplatCapacity, splatRowSource, splatRows, splatTexels, } from './splatLayout.js';
|
|
3
|
+
import { SPLAT_FRAG, SPLAT_VERT } from './shaders/splat.js';
|
|
4
|
+
/** Texture units this pass borrows. High, so they cannot collide with the lit pass's sixteen. */
|
|
5
|
+
const DATA_UNIT = 12;
|
|
6
|
+
const ORDER_UNIT = 13;
|
|
7
|
+
function compile(gl, kind, source, label) {
|
|
8
|
+
const shader = gl.createShader(kind);
|
|
9
|
+
if (shader === null)
|
|
10
|
+
throw new Error(`${label}: createShader failed`);
|
|
11
|
+
gl.shaderSource(shader, source);
|
|
12
|
+
gl.compileShader(shader);
|
|
13
|
+
/*
|
|
14
|
+
* Checked at init and never in the frame, which is the reliability rule: fail fast and loud at
|
|
15
|
+
* construction with a message somebody can act on, and never throw once the loop is running.
|
|
16
|
+
*/
|
|
17
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
18
|
+
const log = gl.getShaderInfoLog(shader) ?? '(no log)';
|
|
19
|
+
gl.deleteShader(shader);
|
|
20
|
+
throw new Error(`${label}: ${log}`);
|
|
21
|
+
}
|
|
22
|
+
return shader;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the program, the two textures and the empty vertex array.
|
|
26
|
+
*
|
|
27
|
+
* **The vertex array holds nothing and still has to exist.** The geometry is `gl_VertexID`
|
|
28
|
+
* arithmetic, so there is no buffer to describe — but a draw with the default vertex array bound
|
|
29
|
+
* is invalid in a core WebGL2 context, which `demo/contributedPass.ts` already records.
|
|
30
|
+
*/
|
|
31
|
+
export function createWebgl2Splats(gl, splats, label) {
|
|
32
|
+
checkSplatCapacity(splats.count, gl.getParameter(gl.MAX_TEXTURE_SIZE), splats.wordsPerSplat);
|
|
33
|
+
const texels = splatTexels(splats.wordsPerSplat);
|
|
34
|
+
const program = gl.createProgram();
|
|
35
|
+
if (program === null)
|
|
36
|
+
throw new Error(`${label}: createProgram failed`);
|
|
37
|
+
const vert = compile(gl, gl.VERTEX_SHADER, SPLAT_VERT, `${label} vertex`);
|
|
38
|
+
const frag = compile(gl, gl.FRAGMENT_SHADER, SPLAT_FRAG, `${label} fragment`);
|
|
39
|
+
gl.attachShader(program, vert);
|
|
40
|
+
gl.attachShader(program, frag);
|
|
41
|
+
gl.linkProgram(program);
|
|
42
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
43
|
+
const log = gl.getProgramInfoLog(program) ?? '(no log)';
|
|
44
|
+
throw new Error(`${label}: link failed — ${log}`);
|
|
45
|
+
}
|
|
46
|
+
/* Attached shaders are reference-counted by the program; deleting the objects frees the source
|
|
47
|
+
without touching the linked binary. */
|
|
48
|
+
gl.deleteShader(vert);
|
|
49
|
+
gl.deleteShader(frag);
|
|
50
|
+
const names = [
|
|
51
|
+
'uSplatData',
|
|
52
|
+
'uSplatOrder',
|
|
53
|
+
'uSplatCount',
|
|
54
|
+
'uSplatStride',
|
|
55
|
+
'uView',
|
|
56
|
+
'uProjection',
|
|
57
|
+
'uViewport',
|
|
58
|
+
'uModel',
|
|
59
|
+
'uOutputTransform',
|
|
60
|
+
'uOutputExposure',
|
|
61
|
+
'uSplatTexels',
|
|
62
|
+
'uSplatShDegree',
|
|
63
|
+
'uSplatCameraLocal',
|
|
64
|
+
];
|
|
65
|
+
const uniforms = {};
|
|
66
|
+
for (const name of names)
|
|
67
|
+
uniforms[name] = gl.getUniformLocation(program, name);
|
|
68
|
+
const rows = splatRows(splats.count);
|
|
69
|
+
const data = gl.createTexture();
|
|
70
|
+
if (data === null)
|
|
71
|
+
throw new Error(`${label}: createTexture failed`);
|
|
72
|
+
gl.bindTexture(gl.TEXTURE_2D, data);
|
|
73
|
+
/*
|
|
74
|
+
* `RGBA32UI` with nearest filtering and no mips: the contents are bit patterns rather than
|
|
75
|
+
* colours, so there is nothing meaningful to interpolate between two splats' packed floats. An
|
|
76
|
+
* integer format cannot be filtered at all in core WebGL2, which makes this required rather
|
|
77
|
+
* than merely correct.
|
|
78
|
+
*/
|
|
79
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32UI, SPLAT_STRIDE * texels, rows);
|
|
80
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
81
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
82
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
83
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
84
|
+
/*
|
|
85
|
+
* Padded to the full rectangle, because `texSubImage2D` of a partial last row is a second call
|
|
86
|
+
* with different arithmetic and the padding is at most one row of 1024 splats — 32 KB.
|
|
87
|
+
*/
|
|
88
|
+
const padded = new Uint32Array(SPLAT_STRIDE * texels * rows * 4);
|
|
89
|
+
padded.set(splats.packed.subarray(0, Math.min(splats.packed.length, padded.length)));
|
|
90
|
+
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, SPLAT_STRIDE * texels, rows, gl.RGBA_INTEGER, gl.UNSIGNED_INT, padded);
|
|
91
|
+
const order = gl.createTexture();
|
|
92
|
+
if (order === null)
|
|
93
|
+
throw new Error(`${label}: createTexture failed`);
|
|
94
|
+
gl.bindTexture(gl.TEXTURE_2D, order);
|
|
95
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R32UI, SPLAT_STRIDE, rows);
|
|
96
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
97
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
98
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
99
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
100
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
101
|
+
const vao = gl.createVertexArray();
|
|
102
|
+
if (vao === null)
|
|
103
|
+
throw new Error(`${label}: createVertexArray failed`);
|
|
104
|
+
return { program, vao, data, order, uniforms, rows, texels };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Upload a new draw order. Four bytes a splat and nothing else moves.
|
|
108
|
+
*
|
|
109
|
+
* **`texSubImage2D` into storage allocated once**, never a reallocation: this runs whenever the
|
|
110
|
+
* view has turned enough to want a re-sort, and allocating a texture per sort is the per-frame
|
|
111
|
+
* allocation the house rules are about.
|
|
112
|
+
*/
|
|
113
|
+
export function uploadWebgl2Order(gl, splats, order, padded) {
|
|
114
|
+
padded.set(order.subarray(0, Math.min(order.length, padded.length)));
|
|
115
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.order);
|
|
116
|
+
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, SPLAT_STRIDE, splats.rows, gl.RED_INTEGER, gl.UNSIGNED_INT, padded);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Upload a run of splats that has just arrived, and nothing else.
|
|
120
|
+
*
|
|
121
|
+
* **Whole rows at a time**, because `texSubImage2D` takes a rectangle and a partial row is a
|
|
122
|
+
* second call with different arithmetic for no gain: a row is 1,024 splats, so rounding a block
|
|
123
|
+
* out to row boundaries re-sends at most 32 KB and the row a block ends in is re-sent once when
|
|
124
|
+
* the next block completes it. A capture streams in a handful of blocks, so that is a few
|
|
125
|
+
* kilobytes over a load rather than a per-frame cost.
|
|
126
|
+
*/
|
|
127
|
+
export function uploadWebgl2SplatRange(gl, splats, packed, from, count) {
|
|
128
|
+
if (count <= 0)
|
|
129
|
+
return;
|
|
130
|
+
const firstRow = Math.floor(from / SPLAT_STRIDE);
|
|
131
|
+
const lastRow = Math.floor((from + count - 1) / SPLAT_STRIDE);
|
|
132
|
+
const rows = lastRow - firstRow + 1;
|
|
133
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.data);
|
|
134
|
+
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, firstRow, SPLAT_STRIDE * splats.texels, rows, gl.RGBA_INTEGER, gl.UNSIGNED_INT, splatRowSource(packed, firstRow, rows, splats.texels * 4));
|
|
135
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Draw, and put back every piece of state this borrowed.
|
|
139
|
+
*
|
|
140
|
+
* **The contract in `PassDefinition.draw`**: the renderer's own verbs assume what they left, and a
|
|
141
|
+
* leaked blend function is the class of bug that shows up three scenes away. Depth *test* on and
|
|
142
|
+
* depth *write* off is the composition claim — a wall in front of a capture occludes it, and a
|
|
143
|
+
* Gaussian has no surface to occlude anything with, so a cloud that wrote depth would cull its
|
|
144
|
+
* own tail.
|
|
145
|
+
*/
|
|
146
|
+
export function drawWebgl2Splats(gl, splats, count) {
|
|
147
|
+
if (count <= 0)
|
|
148
|
+
return;
|
|
149
|
+
gl.useProgram(splats.program);
|
|
150
|
+
gl.bindVertexArray(splats.vao);
|
|
151
|
+
gl.activeTexture(gl.TEXTURE0 + DATA_UNIT);
|
|
152
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.data);
|
|
153
|
+
gl.activeTexture(gl.TEXTURE0 + ORDER_UNIT);
|
|
154
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.order);
|
|
155
|
+
gl.uniform1i(splats.uniforms['uSplatData'] ?? null, DATA_UNIT);
|
|
156
|
+
gl.uniform1i(splats.uniforms['uSplatOrder'] ?? null, ORDER_UNIT);
|
|
157
|
+
const blendWas = gl.getParameter(gl.BLEND);
|
|
158
|
+
const depthMaskWas = gl.getParameter(gl.DEPTH_WRITEMASK);
|
|
159
|
+
gl.enable(gl.BLEND);
|
|
160
|
+
/* Premultiplied `over`, per the plan's decision 1: the fragment stage folds alpha in, so this
|
|
161
|
+
composes correctly onto a target that already holds opaque geometry. */
|
|
162
|
+
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
163
|
+
gl.depthMask(false);
|
|
164
|
+
gl.drawArrays(gl.TRIANGLES, 0, count * 6);
|
|
165
|
+
gl.depthMask(depthMaskWas);
|
|
166
|
+
if (!blendWas)
|
|
167
|
+
gl.disable(gl.BLEND);
|
|
168
|
+
gl.bindVertexArray(null);
|
|
169
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
170
|
+
}
|
|
171
|
+
export function disposeWebgl2Splats(gl, splats) {
|
|
172
|
+
gl.deleteProgram(splats.program);
|
|
173
|
+
gl.deleteVertexArray(splats.vao);
|
|
174
|
+
gl.deleteTexture(splats.data);
|
|
175
|
+
gl.deleteTexture(splats.order);
|
|
176
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The WebGPU half of the splat pass: the same two integer textures, one pipeline, no vertex state. */
|
|
2
|
+
import type { SplatData } from './splatData.ts';
|
|
3
|
+
export interface GpuSplats {
|
|
4
|
+
readonly pipeline: GPURenderPipeline;
|
|
5
|
+
readonly bindGroup: GPUBindGroup;
|
|
6
|
+
readonly vertexUniforms: GPUBuffer;
|
|
7
|
+
readonly fragmentUniforms: GPUBuffer;
|
|
8
|
+
readonly data: GPUTexture;
|
|
9
|
+
readonly order: GPUTexture;
|
|
10
|
+
readonly rows: number;
|
|
11
|
+
/** Two, or three for a capture with view-dependent colour. See `splatTexels`. */
|
|
12
|
+
readonly texels: number;
|
|
13
|
+
/**
|
|
14
|
+
* Scratch for one uniform write, so a frame allocates nothing — **including the views**.
|
|
15
|
+
*
|
|
16
|
+
* A `new Float32Array(buffer)` is a view rather than a copy and reads as free, and it is not: it
|
|
17
|
+
* is an object allocated in the frame loop, once per uniform block per frame, which is exactly
|
|
18
|
+
* what the house rule forbids. Both views of each block are made here, at construction, and the
|
|
19
|
+
* pass writes through them.
|
|
20
|
+
*/
|
|
21
|
+
readonly vertexScratch: ArrayBuffer;
|
|
22
|
+
readonly vertexFloats: Float32Array;
|
|
23
|
+
readonly vertexInts: Int32Array;
|
|
24
|
+
readonly fragmentScratch: ArrayBuffer;
|
|
25
|
+
readonly fragmentFloats: Float32Array;
|
|
26
|
+
readonly fragmentInts: Int32Array;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build the pipeline, the two textures and the bind group.
|
|
30
|
+
*
|
|
31
|
+
* **An explicit bind group layout rather than `layout: 'auto'`, and the reason is the samplers.**
|
|
32
|
+
* The GLSL declares `usampler2D`, so the transform splits each into a texture and a sampler and
|
|
33
|
+
* naga emits both — even though the shader only ever calls `textureLoad`. An unused sampler still
|
|
34
|
+
* has to be bound, and `auto` would infer a **filtering** sampler for it. A `u32` texture is not
|
|
35
|
+
* filterable in core WebGPU, and binding a filtering sampler beside one is a validation failure
|
|
36
|
+
* that takes the whole bind group with it — the same trap `AGENTS.md` records against `r32float`.
|
|
37
|
+
* Declaring the layout says `non-filtering` and `uint`, which is what these are.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createGpuSplats(device: GPUDevice, format: GPUTextureFormat, depthFormat: GPUTextureFormat, samples: number, splats: SplatData, label: string): GpuSplats;
|
|
40
|
+
/**
|
|
41
|
+
* Upload a run of splats that has just arrived, and nothing else.
|
|
42
|
+
*
|
|
43
|
+
* Whole rows, for the reason `uploadWebgl2SplatRange` gives at length: `writeTexture` takes a
|
|
44
|
+
* rectangle, a row is 1,024 splats, and rounding out to row boundaries costs at most 32 KB
|
|
45
|
+
* re-sent per block against a second code path for partial rows.
|
|
46
|
+
*/
|
|
47
|
+
export declare function uploadGpuSplatRange(device: GPUDevice, splats: GpuSplats, packed: Uint32Array, from: number, count: number): void;
|
|
48
|
+
/** Upload a new draw order. Four bytes a splat, into storage allocated once. */
|
|
49
|
+
export declare function uploadGpuOrder(device: GPUDevice, splats: GpuSplats, order: Uint32Array, padded: Uint32Array): void;
|
|
50
|
+
export declare function disposeGpuSplats(splats: GpuSplats): void;
|
package/dist/splatGpu.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** The WebGPU half of the splat pass: the same two integer textures, one pipeline, no vertex state. */
|
|
2
|
+
import { SPLAT_BINDINGS, SPLAT_FRAG_WGSL, SPLAT_VERT_WGSL, } from './shaders/generated/splat.wgsl.js';
|
|
3
|
+
import { SPLAT_STRIDE, splatRowSource, splatRows, splatTexels } from './splatLayout.js';
|
|
4
|
+
/*
|
|
5
|
+
* The usage and stage bits as literals, which is the convention every WebGPU pass in core follows:
|
|
6
|
+
* `GPUTextureUsage` and friends are runtime globals and this package typechecks against a lib that
|
|
7
|
+
* does not declare them, so naming the numbers is the honest form and the comment is the decoder.
|
|
8
|
+
*/
|
|
9
|
+
const TEXTURE_BINDING_COPY_DST = 0x04 | 0x02;
|
|
10
|
+
const UNIFORM_COPY_DST = 0x0040 | 0x0008;
|
|
11
|
+
const STAGE_VERTEX = 0x1;
|
|
12
|
+
const STAGE_FRAGMENT = 0x2;
|
|
13
|
+
const VERT = SPLAT_BINDINGS.SPLAT_VERT;
|
|
14
|
+
const FRAG = SPLAT_BINDINGS.SPLAT_FRAG;
|
|
15
|
+
/**
|
|
16
|
+
* Build the pipeline, the two textures and the bind group.
|
|
17
|
+
*
|
|
18
|
+
* **An explicit bind group layout rather than `layout: 'auto'`, and the reason is the samplers.**
|
|
19
|
+
* The GLSL declares `usampler2D`, so the transform splits each into a texture and a sampler and
|
|
20
|
+
* naga emits both — even though the shader only ever calls `textureLoad`. An unused sampler still
|
|
21
|
+
* has to be bound, and `auto` would infer a **filtering** sampler for it. A `u32` texture is not
|
|
22
|
+
* filterable in core WebGPU, and binding a filtering sampler beside one is a validation failure
|
|
23
|
+
* that takes the whole bind group with it — the same trap `AGENTS.md` records against `r32float`.
|
|
24
|
+
* Declaring the layout says `non-filtering` and `uint`, which is what these are.
|
|
25
|
+
*/
|
|
26
|
+
export function createGpuSplats(device, format, depthFormat, samples, splats, label) {
|
|
27
|
+
const rows = splatRows(splats.count);
|
|
28
|
+
const texels = splatTexels(splats.wordsPerSplat);
|
|
29
|
+
const data = device.createTexture({
|
|
30
|
+
label: `${label}.data`,
|
|
31
|
+
size: { width: SPLAT_STRIDE * texels, height: rows },
|
|
32
|
+
format: 'rgba32uint',
|
|
33
|
+
usage: TEXTURE_BINDING_COPY_DST,
|
|
34
|
+
});
|
|
35
|
+
const order = device.createTexture({
|
|
36
|
+
label: `${label}.order`,
|
|
37
|
+
size: { width: SPLAT_STRIDE, height: rows },
|
|
38
|
+
format: 'r32uint',
|
|
39
|
+
usage: TEXTURE_BINDING_COPY_DST,
|
|
40
|
+
});
|
|
41
|
+
/*
|
|
42
|
+
* `bytesPerRow` is the **source** stride, and `writeTexture` does not convert: handing it data
|
|
43
|
+
* of a different width than the row it is filling is a legal layout that reinterprets bytes.
|
|
44
|
+
* `AGENTS.md` records that costing a session, so the upload is padded to the full rectangle and
|
|
45
|
+
* the stride is stated from the format's own texel size.
|
|
46
|
+
*/
|
|
47
|
+
const padded = new Uint32Array(SPLAT_STRIDE * texels * rows * 4);
|
|
48
|
+
padded.set(splats.packed.subarray(0, Math.min(splats.packed.length, padded.length)));
|
|
49
|
+
device.queue.writeTexture({ texture: data }, padded, { bytesPerRow: SPLAT_STRIDE * texels * 16, rowsPerImage: rows }, { width: SPLAT_STRIDE * texels, height: rows });
|
|
50
|
+
const vertexUniforms = device.createBuffer({
|
|
51
|
+
label: `${label}.vertexUniforms`,
|
|
52
|
+
size: VERT.uniformSize,
|
|
53
|
+
usage: UNIFORM_COPY_DST,
|
|
54
|
+
});
|
|
55
|
+
const fragmentUniforms = device.createBuffer({
|
|
56
|
+
label: `${label}.fragmentUniforms`,
|
|
57
|
+
size: FRAG.uniformSize,
|
|
58
|
+
usage: UNIFORM_COPY_DST,
|
|
59
|
+
});
|
|
60
|
+
const sampler = device.createSampler({
|
|
61
|
+
label: `${label}.sampler`,
|
|
62
|
+
/* Never sampled — declared because the GLSL said `usampler2D`. Non-filtering because a `u32`
|
|
63
|
+
texture cannot be filtered, and saying otherwise is the validation failure above. */
|
|
64
|
+
magFilter: 'nearest',
|
|
65
|
+
minFilter: 'nearest',
|
|
66
|
+
});
|
|
67
|
+
const layout = device.createBindGroupLayout({
|
|
68
|
+
label: `${label}.layout`,
|
|
69
|
+
entries: [
|
|
70
|
+
{ binding: VERT.uniforms, visibility: STAGE_VERTEX, buffer: { type: 'uniform' } },
|
|
71
|
+
{ binding: FRAG.uniforms, visibility: STAGE_FRAGMENT, buffer: { type: 'uniform' } },
|
|
72
|
+
{
|
|
73
|
+
binding: VERT.textures.uSplatData.texture,
|
|
74
|
+
visibility: STAGE_VERTEX,
|
|
75
|
+
texture: { sampleType: 'uint' },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
binding: VERT.textures.uSplatData.sampler,
|
|
79
|
+
visibility: STAGE_VERTEX,
|
|
80
|
+
sampler: { type: 'non-filtering' },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
binding: VERT.textures.uSplatOrder.texture,
|
|
84
|
+
visibility: STAGE_VERTEX,
|
|
85
|
+
texture: { sampleType: 'uint' },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
binding: VERT.textures.uSplatOrder.sampler,
|
|
89
|
+
visibility: STAGE_VERTEX,
|
|
90
|
+
sampler: { type: 'non-filtering' },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
const bindGroup = device.createBindGroup({
|
|
95
|
+
label: `${label}.bindGroup`,
|
|
96
|
+
layout,
|
|
97
|
+
entries: [
|
|
98
|
+
{ binding: VERT.uniforms, resource: { buffer: vertexUniforms } },
|
|
99
|
+
{ binding: FRAG.uniforms, resource: { buffer: fragmentUniforms } },
|
|
100
|
+
{ binding: VERT.textures.uSplatData.texture, resource: data.createView() },
|
|
101
|
+
{ binding: VERT.textures.uSplatData.sampler, resource: sampler },
|
|
102
|
+
{ binding: VERT.textures.uSplatOrder.texture, resource: order.createView() },
|
|
103
|
+
{ binding: VERT.textures.uSplatOrder.sampler, resource: sampler },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
const pipeline = device.createRenderPipeline({
|
|
107
|
+
label,
|
|
108
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
109
|
+
vertex: {
|
|
110
|
+
module: device.createShaderModule({ label: `${label}.vert`, code: SPLAT_VERT_WGSL }),
|
|
111
|
+
entryPoint: 'main',
|
|
112
|
+
},
|
|
113
|
+
fragment: {
|
|
114
|
+
module: device.createShaderModule({ label: `${label}.frag`, code: SPLAT_FRAG_WGSL }),
|
|
115
|
+
entryPoint: 'main',
|
|
116
|
+
targets: [
|
|
117
|
+
{
|
|
118
|
+
format,
|
|
119
|
+
/* Premultiplied `over`, matching the WebGL2 half exactly. */
|
|
120
|
+
blend: {
|
|
121
|
+
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
122
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
primitive: { topology: 'triangle-list' },
|
|
128
|
+
multisample: { count: samples },
|
|
129
|
+
/* Tested, never written: a wall in front occludes the capture, and a Gaussian has no surface
|
|
130
|
+
to occlude with, so a cloud that wrote depth would cull its own tail. */
|
|
131
|
+
depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'less' },
|
|
132
|
+
});
|
|
133
|
+
const vertexScratch = new ArrayBuffer(VERT.uniformSize);
|
|
134
|
+
const fragmentScratch = new ArrayBuffer(FRAG.uniformSize);
|
|
135
|
+
return {
|
|
136
|
+
pipeline,
|
|
137
|
+
bindGroup,
|
|
138
|
+
vertexUniforms,
|
|
139
|
+
fragmentUniforms,
|
|
140
|
+
data,
|
|
141
|
+
order,
|
|
142
|
+
rows,
|
|
143
|
+
texels,
|
|
144
|
+
vertexScratch,
|
|
145
|
+
vertexFloats: new Float32Array(vertexScratch),
|
|
146
|
+
vertexInts: new Int32Array(vertexScratch),
|
|
147
|
+
fragmentScratch,
|
|
148
|
+
fragmentFloats: new Float32Array(fragmentScratch),
|
|
149
|
+
fragmentInts: new Int32Array(fragmentScratch),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Upload a run of splats that has just arrived, and nothing else.
|
|
154
|
+
*
|
|
155
|
+
* Whole rows, for the reason `uploadWebgl2SplatRange` gives at length: `writeTexture` takes a
|
|
156
|
+
* rectangle, a row is 1,024 splats, and rounding out to row boundaries costs at most 32 KB
|
|
157
|
+
* re-sent per block against a second code path for partial rows.
|
|
158
|
+
*/
|
|
159
|
+
export function uploadGpuSplatRange(device, splats, packed, from, count) {
|
|
160
|
+
if (count <= 0)
|
|
161
|
+
return;
|
|
162
|
+
const firstRow = Math.floor(from / SPLAT_STRIDE);
|
|
163
|
+
const lastRow = Math.floor((from + count - 1) / SPLAT_STRIDE);
|
|
164
|
+
const rows = lastRow - firstRow + 1;
|
|
165
|
+
/* Shared with the WebGL2 half rather than written twice: a short tail is a validation failure
|
|
166
|
+
on this backend and a silently skipped upload on that one, and one of them had the guard. */
|
|
167
|
+
const source = splatRowSource(packed, firstRow, rows, splats.texels * 4);
|
|
168
|
+
device.queue.writeTexture({ texture: splats.data, origin: { x: 0, y: firstRow } }, source, { bytesPerRow: SPLAT_STRIDE * splats.texels * 16, rowsPerImage: rows }, { width: SPLAT_STRIDE * splats.texels, height: rows });
|
|
169
|
+
}
|
|
170
|
+
/** Upload a new draw order. Four bytes a splat, into storage allocated once. */
|
|
171
|
+
export function uploadGpuOrder(device, splats, order, padded) {
|
|
172
|
+
padded.set(order.subarray(0, Math.min(order.length, padded.length)));
|
|
173
|
+
device.queue.writeTexture({ texture: splats.order }, padded, { bytesPerRow: SPLAT_STRIDE * 4, rowsPerImage: splats.rows }, { width: SPLAT_STRIDE, height: splats.rows });
|
|
174
|
+
}
|
|
175
|
+
export function disposeGpuSplats(splats) {
|
|
176
|
+
splats.data.destroy();
|
|
177
|
+
splats.order.destroy();
|
|
178
|
+
splats.vertexUniforms.destroy();
|
|
179
|
+
splats.fragmentUniforms.destroy();
|
|
180
|
+
}
|