@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
package/src/splatGl.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** The WebGL2 half of the splat pass: two integer textures, one program, no vertex state at all. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SPLAT_STRIDE,
|
|
5
|
+
checkSplatCapacity,
|
|
6
|
+
splatRowSource,
|
|
7
|
+
splatRows,
|
|
8
|
+
splatTexels,
|
|
9
|
+
} from './splatLayout.ts';
|
|
10
|
+
import { SPLAT_FRAG, SPLAT_VERT } from './shaders/splat.ts';
|
|
11
|
+
import type { SplatData } from './splatData.ts';
|
|
12
|
+
|
|
13
|
+
/** Texture units this pass borrows. High, so they cannot collide with the lit pass's sixteen. */
|
|
14
|
+
const DATA_UNIT = 12;
|
|
15
|
+
const ORDER_UNIT = 13;
|
|
16
|
+
|
|
17
|
+
export interface Webgl2Splats {
|
|
18
|
+
readonly program: WebGLProgram;
|
|
19
|
+
readonly vao: WebGLVertexArrayObject;
|
|
20
|
+
readonly data: WebGLTexture;
|
|
21
|
+
readonly order: WebGLTexture;
|
|
22
|
+
readonly uniforms: Readonly<Record<string, WebGLUniformLocation | null>>;
|
|
23
|
+
readonly rows: number;
|
|
24
|
+
/** Two, or three for a capture with view-dependent colour. See `splatTexels`. */
|
|
25
|
+
readonly texels: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compile(
|
|
29
|
+
gl: WebGL2RenderingContext,
|
|
30
|
+
kind: number,
|
|
31
|
+
source: string,
|
|
32
|
+
label: string,
|
|
33
|
+
): WebGLShader {
|
|
34
|
+
const shader = gl.createShader(kind);
|
|
35
|
+
if (shader === null) throw new Error(`${label}: createShader failed`);
|
|
36
|
+
gl.shaderSource(shader, source);
|
|
37
|
+
gl.compileShader(shader);
|
|
38
|
+
/*
|
|
39
|
+
* Checked at init and never in the frame, which is the reliability rule: fail fast and loud at
|
|
40
|
+
* construction with a message somebody can act on, and never throw once the loop is running.
|
|
41
|
+
*/
|
|
42
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
43
|
+
const log = gl.getShaderInfoLog(shader) ?? '(no log)';
|
|
44
|
+
gl.deleteShader(shader);
|
|
45
|
+
throw new Error(`${label}: ${log}`);
|
|
46
|
+
}
|
|
47
|
+
return shader;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the program, the two textures and the empty vertex array.
|
|
52
|
+
*
|
|
53
|
+
* **The vertex array holds nothing and still has to exist.** The geometry is `gl_VertexID`
|
|
54
|
+
* arithmetic, so there is no buffer to describe — but a draw with the default vertex array bound
|
|
55
|
+
* is invalid in a core WebGL2 context, which `demo/contributedPass.ts` already records.
|
|
56
|
+
*/
|
|
57
|
+
export function createWebgl2Splats(
|
|
58
|
+
gl: WebGL2RenderingContext,
|
|
59
|
+
splats: SplatData,
|
|
60
|
+
label: string,
|
|
61
|
+
): Webgl2Splats {
|
|
62
|
+
checkSplatCapacity(
|
|
63
|
+
splats.count,
|
|
64
|
+
gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
|
|
65
|
+
splats.wordsPerSplat,
|
|
66
|
+
);
|
|
67
|
+
const texels = splatTexels(splats.wordsPerSplat);
|
|
68
|
+
|
|
69
|
+
const program = gl.createProgram();
|
|
70
|
+
if (program === null) throw new Error(`${label}: createProgram failed`);
|
|
71
|
+
const vert = compile(gl, gl.VERTEX_SHADER, SPLAT_VERT, `${label} vertex`);
|
|
72
|
+
const frag = compile(gl, gl.FRAGMENT_SHADER, SPLAT_FRAG, `${label} fragment`);
|
|
73
|
+
gl.attachShader(program, vert);
|
|
74
|
+
gl.attachShader(program, frag);
|
|
75
|
+
gl.linkProgram(program);
|
|
76
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
77
|
+
const log = gl.getProgramInfoLog(program) ?? '(no log)';
|
|
78
|
+
throw new Error(`${label}: link failed — ${log}`);
|
|
79
|
+
}
|
|
80
|
+
/* Attached shaders are reference-counted by the program; deleting the objects frees the source
|
|
81
|
+
without touching the linked binary. */
|
|
82
|
+
gl.deleteShader(vert);
|
|
83
|
+
gl.deleteShader(frag);
|
|
84
|
+
|
|
85
|
+
const names = [
|
|
86
|
+
'uSplatData',
|
|
87
|
+
'uSplatOrder',
|
|
88
|
+
'uSplatCount',
|
|
89
|
+
'uSplatStride',
|
|
90
|
+
'uView',
|
|
91
|
+
'uProjection',
|
|
92
|
+
'uViewport',
|
|
93
|
+
'uModel',
|
|
94
|
+
'uOutputTransform',
|
|
95
|
+
'uOutputExposure',
|
|
96
|
+
'uSplatTexels',
|
|
97
|
+
'uSplatShDegree',
|
|
98
|
+
'uSplatCameraLocal',
|
|
99
|
+
];
|
|
100
|
+
const uniforms: Record<string, WebGLUniformLocation | null> = {};
|
|
101
|
+
for (const name of names) uniforms[name] = gl.getUniformLocation(program, name);
|
|
102
|
+
|
|
103
|
+
const rows = splatRows(splats.count);
|
|
104
|
+
|
|
105
|
+
const data = gl.createTexture();
|
|
106
|
+
if (data === null) throw new Error(`${label}: createTexture failed`);
|
|
107
|
+
gl.bindTexture(gl.TEXTURE_2D, data);
|
|
108
|
+
/*
|
|
109
|
+
* `RGBA32UI` with nearest filtering and no mips: the contents are bit patterns rather than
|
|
110
|
+
* colours, so there is nothing meaningful to interpolate between two splats' packed floats. An
|
|
111
|
+
* integer format cannot be filtered at all in core WebGL2, which makes this required rather
|
|
112
|
+
* than merely correct.
|
|
113
|
+
*/
|
|
114
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32UI, SPLAT_STRIDE * texels, rows);
|
|
115
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
116
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
117
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
118
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
119
|
+
/*
|
|
120
|
+
* Padded to the full rectangle, because `texSubImage2D` of a partial last row is a second call
|
|
121
|
+
* with different arithmetic and the padding is at most one row of 1024 splats — 32 KB.
|
|
122
|
+
*/
|
|
123
|
+
const padded = new Uint32Array(SPLAT_STRIDE * texels * rows * 4);
|
|
124
|
+
padded.set(splats.packed.subarray(0, Math.min(splats.packed.length, padded.length)));
|
|
125
|
+
gl.texSubImage2D(
|
|
126
|
+
gl.TEXTURE_2D,
|
|
127
|
+
0,
|
|
128
|
+
0,
|
|
129
|
+
0,
|
|
130
|
+
SPLAT_STRIDE * texels,
|
|
131
|
+
rows,
|
|
132
|
+
gl.RGBA_INTEGER,
|
|
133
|
+
gl.UNSIGNED_INT,
|
|
134
|
+
padded,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const order = gl.createTexture();
|
|
138
|
+
if (order === null) throw new Error(`${label}: createTexture failed`);
|
|
139
|
+
gl.bindTexture(gl.TEXTURE_2D, order);
|
|
140
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R32UI, SPLAT_STRIDE, rows);
|
|
141
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
142
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
143
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
144
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
145
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
146
|
+
|
|
147
|
+
const vao = gl.createVertexArray();
|
|
148
|
+
if (vao === null) throw new Error(`${label}: createVertexArray failed`);
|
|
149
|
+
|
|
150
|
+
return { program, vao, data, order, uniforms, rows, texels };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Upload a new draw order. Four bytes a splat and nothing else moves.
|
|
155
|
+
*
|
|
156
|
+
* **`texSubImage2D` into storage allocated once**, never a reallocation: this runs whenever the
|
|
157
|
+
* view has turned enough to want a re-sort, and allocating a texture per sort is the per-frame
|
|
158
|
+
* allocation the house rules are about.
|
|
159
|
+
*/
|
|
160
|
+
export function uploadWebgl2Order(
|
|
161
|
+
gl: WebGL2RenderingContext,
|
|
162
|
+
splats: Webgl2Splats,
|
|
163
|
+
order: Uint32Array,
|
|
164
|
+
padded: Uint32Array,
|
|
165
|
+
): void {
|
|
166
|
+
padded.set(order.subarray(0, Math.min(order.length, padded.length)));
|
|
167
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.order);
|
|
168
|
+
gl.texSubImage2D(
|
|
169
|
+
gl.TEXTURE_2D,
|
|
170
|
+
0,
|
|
171
|
+
0,
|
|
172
|
+
0,
|
|
173
|
+
SPLAT_STRIDE,
|
|
174
|
+
splats.rows,
|
|
175
|
+
gl.RED_INTEGER,
|
|
176
|
+
gl.UNSIGNED_INT,
|
|
177
|
+
padded,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Upload a run of splats that has just arrived, and nothing else.
|
|
183
|
+
*
|
|
184
|
+
* **Whole rows at a time**, because `texSubImage2D` takes a rectangle and a partial row is a
|
|
185
|
+
* second call with different arithmetic for no gain: a row is 1,024 splats, so rounding a block
|
|
186
|
+
* out to row boundaries re-sends at most 32 KB and the row a block ends in is re-sent once when
|
|
187
|
+
* the next block completes it. A capture streams in a handful of blocks, so that is a few
|
|
188
|
+
* kilobytes over a load rather than a per-frame cost.
|
|
189
|
+
*/
|
|
190
|
+
export function uploadWebgl2SplatRange(
|
|
191
|
+
gl: WebGL2RenderingContext,
|
|
192
|
+
splats: Webgl2Splats,
|
|
193
|
+
packed: Uint32Array,
|
|
194
|
+
from: number,
|
|
195
|
+
count: number,
|
|
196
|
+
): void {
|
|
197
|
+
if (count <= 0) return;
|
|
198
|
+
const firstRow = Math.floor(from / SPLAT_STRIDE);
|
|
199
|
+
const lastRow = Math.floor((from + count - 1) / SPLAT_STRIDE);
|
|
200
|
+
const rows = lastRow - firstRow + 1;
|
|
201
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.data);
|
|
202
|
+
gl.texSubImage2D(
|
|
203
|
+
gl.TEXTURE_2D,
|
|
204
|
+
0,
|
|
205
|
+
0,
|
|
206
|
+
firstRow,
|
|
207
|
+
SPLAT_STRIDE * splats.texels,
|
|
208
|
+
rows,
|
|
209
|
+
gl.RGBA_INTEGER,
|
|
210
|
+
gl.UNSIGNED_INT,
|
|
211
|
+
splatRowSource(packed, firstRow, rows, splats.texels * 4),
|
|
212
|
+
);
|
|
213
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Draw, and put back every piece of state this borrowed.
|
|
218
|
+
*
|
|
219
|
+
* **The contract in `PassDefinition.draw`**: the renderer's own verbs assume what they left, and a
|
|
220
|
+
* leaked blend function is the class of bug that shows up three scenes away. Depth *test* on and
|
|
221
|
+
* depth *write* off is the composition claim — a wall in front of a capture occludes it, and a
|
|
222
|
+
* Gaussian has no surface to occlude anything with, so a cloud that wrote depth would cull its
|
|
223
|
+
* own tail.
|
|
224
|
+
*/
|
|
225
|
+
export function drawWebgl2Splats(
|
|
226
|
+
gl: WebGL2RenderingContext,
|
|
227
|
+
splats: Webgl2Splats,
|
|
228
|
+
count: number,
|
|
229
|
+
): void {
|
|
230
|
+
if (count <= 0) return;
|
|
231
|
+
gl.useProgram(splats.program);
|
|
232
|
+
gl.bindVertexArray(splats.vao);
|
|
233
|
+
|
|
234
|
+
gl.activeTexture(gl.TEXTURE0 + DATA_UNIT);
|
|
235
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.data);
|
|
236
|
+
gl.activeTexture(gl.TEXTURE0 + ORDER_UNIT);
|
|
237
|
+
gl.bindTexture(gl.TEXTURE_2D, splats.order);
|
|
238
|
+
gl.uniform1i(splats.uniforms['uSplatData'] ?? null, DATA_UNIT);
|
|
239
|
+
gl.uniform1i(splats.uniforms['uSplatOrder'] ?? null, ORDER_UNIT);
|
|
240
|
+
|
|
241
|
+
const blendWas = gl.getParameter(gl.BLEND) as boolean;
|
|
242
|
+
const depthMaskWas = gl.getParameter(gl.DEPTH_WRITEMASK) as boolean;
|
|
243
|
+
gl.enable(gl.BLEND);
|
|
244
|
+
/* Premultiplied `over`, per the plan's decision 1: the fragment stage folds alpha in, so this
|
|
245
|
+
composes correctly onto a target that already holds opaque geometry. */
|
|
246
|
+
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
247
|
+
gl.depthMask(false);
|
|
248
|
+
|
|
249
|
+
gl.drawArrays(gl.TRIANGLES, 0, count * 6);
|
|
250
|
+
|
|
251
|
+
gl.depthMask(depthMaskWas);
|
|
252
|
+
if (!blendWas) gl.disable(gl.BLEND);
|
|
253
|
+
gl.bindVertexArray(null);
|
|
254
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function disposeWebgl2Splats(gl: WebGL2RenderingContext, splats: Webgl2Splats): void {
|
|
258
|
+
gl.deleteProgram(splats.program);
|
|
259
|
+
gl.deleteVertexArray(splats.vao);
|
|
260
|
+
gl.deleteTexture(splats.data);
|
|
261
|
+
gl.deleteTexture(splats.order);
|
|
262
|
+
}
|
package/src/splatGpu.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/** The WebGPU half of the splat pass: the same two integer textures, one pipeline, no vertex state. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SPLAT_BINDINGS,
|
|
5
|
+
SPLAT_FRAG_WGSL,
|
|
6
|
+
SPLAT_VERT_WGSL,
|
|
7
|
+
} from './shaders/generated/splat.wgsl.ts';
|
|
8
|
+
import { SPLAT_STRIDE, splatRowSource, splatRows, splatTexels } from './splatLayout.ts';
|
|
9
|
+
import type { SplatData } from './splatData.ts';
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The usage and stage bits as literals, which is the convention every WebGPU pass in core follows:
|
|
13
|
+
* `GPUTextureUsage` and friends are runtime globals and this package typechecks against a lib that
|
|
14
|
+
* does not declare them, so naming the numbers is the honest form and the comment is the decoder.
|
|
15
|
+
*/
|
|
16
|
+
const TEXTURE_BINDING_COPY_DST = 0x04 | 0x02;
|
|
17
|
+
const UNIFORM_COPY_DST = 0x0040 | 0x0008;
|
|
18
|
+
const STAGE_VERTEX = 0x1;
|
|
19
|
+
const STAGE_FRAGMENT = 0x2;
|
|
20
|
+
|
|
21
|
+
const VERT = SPLAT_BINDINGS.SPLAT_VERT;
|
|
22
|
+
const FRAG = SPLAT_BINDINGS.SPLAT_FRAG;
|
|
23
|
+
|
|
24
|
+
export interface GpuSplats {
|
|
25
|
+
readonly pipeline: GPURenderPipeline;
|
|
26
|
+
readonly bindGroup: GPUBindGroup;
|
|
27
|
+
readonly vertexUniforms: GPUBuffer;
|
|
28
|
+
readonly fragmentUniforms: GPUBuffer;
|
|
29
|
+
readonly data: GPUTexture;
|
|
30
|
+
readonly order: GPUTexture;
|
|
31
|
+
readonly rows: number;
|
|
32
|
+
/** Two, or three for a capture with view-dependent colour. See `splatTexels`. */
|
|
33
|
+
readonly texels: number;
|
|
34
|
+
/**
|
|
35
|
+
* Scratch for one uniform write, so a frame allocates nothing — **including the views**.
|
|
36
|
+
*
|
|
37
|
+
* A `new Float32Array(buffer)` is a view rather than a copy and reads as free, and it is not: it
|
|
38
|
+
* is an object allocated in the frame loop, once per uniform block per frame, which is exactly
|
|
39
|
+
* what the house rule forbids. Both views of each block are made here, at construction, and the
|
|
40
|
+
* pass writes through them.
|
|
41
|
+
*/
|
|
42
|
+
readonly vertexScratch: ArrayBuffer;
|
|
43
|
+
readonly vertexFloats: Float32Array;
|
|
44
|
+
readonly vertexInts: Int32Array;
|
|
45
|
+
readonly fragmentScratch: ArrayBuffer;
|
|
46
|
+
readonly fragmentFloats: Float32Array;
|
|
47
|
+
readonly fragmentInts: Int32Array;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the pipeline, the two textures and the bind group.
|
|
52
|
+
*
|
|
53
|
+
* **An explicit bind group layout rather than `layout: 'auto'`, and the reason is the samplers.**
|
|
54
|
+
* The GLSL declares `usampler2D`, so the transform splits each into a texture and a sampler and
|
|
55
|
+
* naga emits both — even though the shader only ever calls `textureLoad`. An unused sampler still
|
|
56
|
+
* has to be bound, and `auto` would infer a **filtering** sampler for it. A `u32` texture is not
|
|
57
|
+
* filterable in core WebGPU, and binding a filtering sampler beside one is a validation failure
|
|
58
|
+
* that takes the whole bind group with it — the same trap `AGENTS.md` records against `r32float`.
|
|
59
|
+
* Declaring the layout says `non-filtering` and `uint`, which is what these are.
|
|
60
|
+
*/
|
|
61
|
+
export function createGpuSplats(
|
|
62
|
+
device: GPUDevice,
|
|
63
|
+
format: GPUTextureFormat,
|
|
64
|
+
depthFormat: GPUTextureFormat,
|
|
65
|
+
samples: number,
|
|
66
|
+
splats: SplatData,
|
|
67
|
+
label: string,
|
|
68
|
+
): GpuSplats {
|
|
69
|
+
const rows = splatRows(splats.count);
|
|
70
|
+
const texels = splatTexels(splats.wordsPerSplat);
|
|
71
|
+
|
|
72
|
+
const data = device.createTexture({
|
|
73
|
+
label: `${label}.data`,
|
|
74
|
+
size: { width: SPLAT_STRIDE * texels, height: rows },
|
|
75
|
+
format: 'rgba32uint',
|
|
76
|
+
usage: TEXTURE_BINDING_COPY_DST,
|
|
77
|
+
});
|
|
78
|
+
const order = device.createTexture({
|
|
79
|
+
label: `${label}.order`,
|
|
80
|
+
size: { width: SPLAT_STRIDE, height: rows },
|
|
81
|
+
format: 'r32uint',
|
|
82
|
+
usage: TEXTURE_BINDING_COPY_DST,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* `bytesPerRow` is the **source** stride, and `writeTexture` does not convert: handing it data
|
|
87
|
+
* of a different width than the row it is filling is a legal layout that reinterprets bytes.
|
|
88
|
+
* `AGENTS.md` records that costing a session, so the upload is padded to the full rectangle and
|
|
89
|
+
* the stride is stated from the format's own texel size.
|
|
90
|
+
*/
|
|
91
|
+
const padded = new Uint32Array(SPLAT_STRIDE * texels * rows * 4);
|
|
92
|
+
padded.set(splats.packed.subarray(0, Math.min(splats.packed.length, padded.length)));
|
|
93
|
+
device.queue.writeTexture(
|
|
94
|
+
{ texture: data },
|
|
95
|
+
padded,
|
|
96
|
+
{ bytesPerRow: SPLAT_STRIDE * texels * 16, rowsPerImage: rows },
|
|
97
|
+
{ width: SPLAT_STRIDE * texels, height: rows },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const vertexUniforms = device.createBuffer({
|
|
101
|
+
label: `${label}.vertexUniforms`,
|
|
102
|
+
size: VERT.uniformSize,
|
|
103
|
+
usage: UNIFORM_COPY_DST,
|
|
104
|
+
});
|
|
105
|
+
const fragmentUniforms = device.createBuffer({
|
|
106
|
+
label: `${label}.fragmentUniforms`,
|
|
107
|
+
size: FRAG.uniformSize,
|
|
108
|
+
usage: UNIFORM_COPY_DST,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const sampler = device.createSampler({
|
|
112
|
+
label: `${label}.sampler`,
|
|
113
|
+
/* Never sampled — declared because the GLSL said `usampler2D`. Non-filtering because a `u32`
|
|
114
|
+
texture cannot be filtered, and saying otherwise is the validation failure above. */
|
|
115
|
+
magFilter: 'nearest',
|
|
116
|
+
minFilter: 'nearest',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const layout = device.createBindGroupLayout({
|
|
120
|
+
label: `${label}.layout`,
|
|
121
|
+
entries: [
|
|
122
|
+
{ binding: VERT.uniforms, visibility: STAGE_VERTEX, buffer: { type: 'uniform' } },
|
|
123
|
+
{ binding: FRAG.uniforms, visibility: STAGE_FRAGMENT, buffer: { type: 'uniform' } },
|
|
124
|
+
{
|
|
125
|
+
binding: VERT.textures.uSplatData.texture,
|
|
126
|
+
visibility: STAGE_VERTEX,
|
|
127
|
+
texture: { sampleType: 'uint' },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
binding: VERT.textures.uSplatData.sampler,
|
|
131
|
+
visibility: STAGE_VERTEX,
|
|
132
|
+
sampler: { type: 'non-filtering' },
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
binding: VERT.textures.uSplatOrder.texture,
|
|
136
|
+
visibility: STAGE_VERTEX,
|
|
137
|
+
texture: { sampleType: 'uint' },
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
binding: VERT.textures.uSplatOrder.sampler,
|
|
141
|
+
visibility: STAGE_VERTEX,
|
|
142
|
+
sampler: { type: 'non-filtering' },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const bindGroup = device.createBindGroup({
|
|
148
|
+
label: `${label}.bindGroup`,
|
|
149
|
+
layout,
|
|
150
|
+
entries: [
|
|
151
|
+
{ binding: VERT.uniforms, resource: { buffer: vertexUniforms } },
|
|
152
|
+
{ binding: FRAG.uniforms, resource: { buffer: fragmentUniforms } },
|
|
153
|
+
{ binding: VERT.textures.uSplatData.texture, resource: data.createView() },
|
|
154
|
+
{ binding: VERT.textures.uSplatData.sampler, resource: sampler },
|
|
155
|
+
{ binding: VERT.textures.uSplatOrder.texture, resource: order.createView() },
|
|
156
|
+
{ binding: VERT.textures.uSplatOrder.sampler, resource: sampler },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const pipeline = device.createRenderPipeline({
|
|
161
|
+
label,
|
|
162
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
163
|
+
vertex: {
|
|
164
|
+
module: device.createShaderModule({ label: `${label}.vert`, code: SPLAT_VERT_WGSL }),
|
|
165
|
+
entryPoint: 'main',
|
|
166
|
+
},
|
|
167
|
+
fragment: {
|
|
168
|
+
module: device.createShaderModule({ label: `${label}.frag`, code: SPLAT_FRAG_WGSL }),
|
|
169
|
+
entryPoint: 'main',
|
|
170
|
+
targets: [
|
|
171
|
+
{
|
|
172
|
+
format,
|
|
173
|
+
/* Premultiplied `over`, matching the WebGL2 half exactly. */
|
|
174
|
+
blend: {
|
|
175
|
+
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
176
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
primitive: { topology: 'triangle-list' },
|
|
182
|
+
multisample: { count: samples },
|
|
183
|
+
/* Tested, never written: a wall in front occludes the capture, and a Gaussian has no surface
|
|
184
|
+
to occlude with, so a cloud that wrote depth would cull its own tail. */
|
|
185
|
+
depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'less' },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const vertexScratch = new ArrayBuffer(VERT.uniformSize);
|
|
189
|
+
const fragmentScratch = new ArrayBuffer(FRAG.uniformSize);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
pipeline,
|
|
193
|
+
bindGroup,
|
|
194
|
+
vertexUniforms,
|
|
195
|
+
fragmentUniforms,
|
|
196
|
+
data,
|
|
197
|
+
order,
|
|
198
|
+
rows,
|
|
199
|
+
texels,
|
|
200
|
+
vertexScratch,
|
|
201
|
+
vertexFloats: new Float32Array(vertexScratch),
|
|
202
|
+
vertexInts: new Int32Array(vertexScratch),
|
|
203
|
+
fragmentScratch,
|
|
204
|
+
fragmentFloats: new Float32Array(fragmentScratch),
|
|
205
|
+
fragmentInts: new Int32Array(fragmentScratch),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Upload a run of splats that has just arrived, and nothing else.
|
|
211
|
+
*
|
|
212
|
+
* Whole rows, for the reason `uploadWebgl2SplatRange` gives at length: `writeTexture` takes a
|
|
213
|
+
* rectangle, a row is 1,024 splats, and rounding out to row boundaries costs at most 32 KB
|
|
214
|
+
* re-sent per block against a second code path for partial rows.
|
|
215
|
+
*/
|
|
216
|
+
export function uploadGpuSplatRange(
|
|
217
|
+
device: GPUDevice,
|
|
218
|
+
splats: GpuSplats,
|
|
219
|
+
packed: Uint32Array,
|
|
220
|
+
from: number,
|
|
221
|
+
count: number,
|
|
222
|
+
): void {
|
|
223
|
+
if (count <= 0) return;
|
|
224
|
+
const firstRow = Math.floor(from / SPLAT_STRIDE);
|
|
225
|
+
const lastRow = Math.floor((from + count - 1) / SPLAT_STRIDE);
|
|
226
|
+
const rows = lastRow - firstRow + 1;
|
|
227
|
+
/* Shared with the WebGL2 half rather than written twice: a short tail is a validation failure
|
|
228
|
+
on this backend and a silently skipped upload on that one, and one of them had the guard. */
|
|
229
|
+
const source = splatRowSource(packed, firstRow, rows, splats.texels * 4);
|
|
230
|
+
device.queue.writeTexture(
|
|
231
|
+
{ texture: splats.data, origin: { x: 0, y: firstRow } },
|
|
232
|
+
source,
|
|
233
|
+
{ bytesPerRow: SPLAT_STRIDE * splats.texels * 16, rowsPerImage: rows },
|
|
234
|
+
{ width: SPLAT_STRIDE * splats.texels, height: rows },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Upload a new draw order. Four bytes a splat, into storage allocated once. */
|
|
239
|
+
export function uploadGpuOrder(
|
|
240
|
+
device: GPUDevice,
|
|
241
|
+
splats: GpuSplats,
|
|
242
|
+
order: Uint32Array,
|
|
243
|
+
padded: Uint32Array,
|
|
244
|
+
): void {
|
|
245
|
+
padded.set(order.subarray(0, Math.min(order.length, padded.length)));
|
|
246
|
+
device.queue.writeTexture(
|
|
247
|
+
{ texture: splats.order },
|
|
248
|
+
padded,
|
|
249
|
+
{ bytesPerRow: SPLAT_STRIDE * 4, rowsPerImage: splats.rows },
|
|
250
|
+
{ width: SPLAT_STRIDE, height: splats.rows },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function disposeGpuSplats(splats: GpuSplats): void {
|
|
255
|
+
splats.data.destroy();
|
|
256
|
+
splats.order.destroy();
|
|
257
|
+
splats.vertexUniforms.destroy();
|
|
258
|
+
splats.fragmentUniforms.destroy();
|
|
259
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** How a capture is laid out in its two textures, shared by both backends so they cannot disagree. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Splats per row.
|
|
5
|
+
*
|
|
6
|
+
* **A power of two, so the shader's divide is a shift** and the two textures index alike: the data
|
|
7
|
+
* texture is twice this wide because a splat is two texels, and the order texture is exactly this
|
|
8
|
+
* wide because a slot is one.
|
|
9
|
+
*
|
|
10
|
+
* 1024 puts a million splats in 977 rows, comfortably inside WebGL2's guaranteed 2048 — see
|
|
11
|
+
* `splatRows`, which is where that limit is checked rather than assumed.
|
|
12
|
+
*/
|
|
13
|
+
export const SPLAT_STRIDE = 1024;
|
|
14
|
+
|
|
15
|
+
/** How many rows a capture of `count` splats needs. Both textures share it. */
|
|
16
|
+
export function splatRows(count: number): number {
|
|
17
|
+
return Math.max(1, Math.ceil(count / SPLAT_STRIDE));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Texels one splat occupies in the data texture, from its record width.
|
|
22
|
+
*
|
|
23
|
+
* Two for a capture with no view-dependent colour and three for one with the l=1 band. **A
|
|
24
|
+
* function of the record rather than a constant**, because the width is the *file's* — a `SPLT`
|
|
25
|
+
* block carries its own `wordsPerSplat` and `FORMAT.md` §4.7 designed it to grow exactly this way.
|
|
26
|
+
*/
|
|
27
|
+
export function splatTexels(wordsPerSplat: number): number {
|
|
28
|
+
return wordsPerSplat / 4;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Refuse a capture that will not fit, naming both numbers.
|
|
33
|
+
*
|
|
34
|
+
* **WebGL2 guarantees `MAX_TEXTURE_SIZE` of 2048**, which at two texels a splat is 2,097,152
|
|
35
|
+
* splats — so the ceiling is stated here rather than re-derived by whoever hits it. A device
|
|
36
|
+
* usually offers far more; the guaranteed floor is what a refusal has to be written against,
|
|
37
|
+
* because a capture that loads on a workstation and refuses on a phone is worse than one that
|
|
38
|
+
* refuses on both. **A capture with view-dependent colour is three texels a splat**, so its width
|
|
39
|
+
* is 3,072 and a device at the guaranteed floor refuses it — which is the honest answer rather
|
|
40
|
+
* than a silently narrower row.
|
|
41
|
+
*/
|
|
42
|
+
export function checkSplatCapacity(count: number, maxTextureSize: number, wordsPerSplat = 8): void {
|
|
43
|
+
const rows = splatRows(count);
|
|
44
|
+
const width = SPLAT_STRIDE * splatTexels(wordsPerSplat);
|
|
45
|
+
if (rows <= maxTextureSize && width <= maxTextureSize) return;
|
|
46
|
+
throw new Error(
|
|
47
|
+
`a capture of ${count} splats needs ${width} by ${rows} texels and this device ` +
|
|
48
|
+
`allows ${maxTextureSize}. At ${SPLAT_STRIDE} splats a row the ceiling is ` +
|
|
49
|
+
`${SPLAT_STRIDE * maxTextureSize} splats.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Words per row of the data texture: `SPLAT_STRIDE` splats of `wordsPerSplat` each. */
|
|
54
|
+
export function splatRowWords(wordsPerSplat: number): number {
|
|
55
|
+
return SPLAT_STRIDE * wordsPerSplat;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A source covering exactly `rows` whole rows of packed data, starting at `firstRow`.
|
|
60
|
+
*
|
|
61
|
+
* **Both backends need this and only one of them had it, which is how it was found.** A texture
|
|
62
|
+
* upload takes a *rectangle*, and a capture whose splat count is not a multiple of
|
|
63
|
+
* `SPLAT_STRIDE` ends in a partial row — so a plain `subarray` to the end of the packed data is
|
|
64
|
+
* shorter than the rectangle it is being handed to. WebGPU refuses that at `submit` and takes the
|
|
65
|
+
* command buffer with it; WebGL2 raises `INVALID_OPERATION` and *skips the upload*, which is
|
|
66
|
+
* worse: the last row keeps whatever it held and the capture draws with up to a thousand splats
|
|
67
|
+
* missing. Measured as 59,585 pixels between the two backends on a streaming load, at the last
|
|
68
|
+
* block and no earlier.
|
|
69
|
+
*
|
|
70
|
+
* Copies only when the tail is short, which is at most once per capture and at most one row —
|
|
71
|
+
* 32 KB. The whole-row case is the common one and is a view.
|
|
72
|
+
*/
|
|
73
|
+
export function splatRowSource(
|
|
74
|
+
packed: Uint32Array,
|
|
75
|
+
firstRow: number,
|
|
76
|
+
rows: number,
|
|
77
|
+
wordsPerSplat = 8,
|
|
78
|
+
): Uint32Array {
|
|
79
|
+
const rowWords = splatRowWords(wordsPerSplat);
|
|
80
|
+
const from = firstRow * rowWords;
|
|
81
|
+
const needed = rows * rowWords;
|
|
82
|
+
if (from + needed <= packed.length) return packed.subarray(from, from + needed);
|
|
83
|
+
const padded = new Uint32Array(needed);
|
|
84
|
+
padded.set(packed.subarray(from, Math.min(packed.length, from + needed)));
|
|
85
|
+
return padded;
|
|
86
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** The two matrix operations this package needs, written once so no two callers disagree. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `out = a * b`, column-major, sixteen multiply-adds and no dependency.
|
|
5
|
+
*
|
|
6
|
+
* **Written here rather than pulling in `gl-matrix`**: this package needs one matrix product, a
|
|
7
|
+
* few times a frame, and a runtime dependency is the thing `AGENTS.md` sets a high bar for. It is
|
|
8
|
+
* in its own module rather than beside either caller because there are two of them — the pass
|
|
9
|
+
* folding the clip correction into a projection, and the cull building a clip matrix — and two
|
|
10
|
+
* copies of one decision is the 2026-08-17 rule however short the decision is.
|
|
11
|
+
*/
|
|
12
|
+
export function multiplyMat4(out: Float32Array, a: ArrayLike<number>, b: ArrayLike<number>): void {
|
|
13
|
+
for (let column = 0; column < 4; column++) {
|
|
14
|
+
const b0 = b[column * 4] ?? 0;
|
|
15
|
+
const b1 = b[column * 4 + 1] ?? 0;
|
|
16
|
+
const b2 = b[column * 4 + 2] ?? 0;
|
|
17
|
+
const b3 = b[column * 4 + 3] ?? 0;
|
|
18
|
+
for (let row = 0; row < 4; row++) {
|
|
19
|
+
out[column * 4 + row] =
|
|
20
|
+
(a[row] ?? 0) * b0 +
|
|
21
|
+
(a[4 + row] ?? 0) * b1 +
|
|
22
|
+
(a[8 + row] ?? 0) * b2 +
|
|
23
|
+
(a[12 + row] ?? 0) * b3;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where the camera sits in a capture's own space, from the view and the model matrices.
|
|
30
|
+
*
|
|
31
|
+
* **View-dependent colour needs the direction in the frame the coefficients were trained in**, not
|
|
32
|
+
* in world space: a capture turned, moved or scaled into a scene by `model` has had its whole
|
|
33
|
+
* lighting rotated with it, and evaluating the harmonics against a world-space direction produces
|
|
34
|
+
* a sheen that stays put while the capture turns underneath it.
|
|
35
|
+
*
|
|
36
|
+
* The camera is the origin of view space, so the point wanted is `inverse(view * model)` applied
|
|
37
|
+
* to the origin — which is `−inverse(M3) · t` for the product's 3x3 part and its translation
|
|
38
|
+
* column, and needs no full 4x4 inverse. A singular 3x3 means a model matrix that collapses the
|
|
39
|
+
* capture to a plane, and the answer is then the origin rather than a division by zero: a capture
|
|
40
|
+
* with no volume draws nothing whose colour anybody sees.
|
|
41
|
+
*
|
|
42
|
+
* Called once a frame per capture, which is what keeps it off the vertex stage where it would be
|
|
43
|
+
* once per splat times six.
|
|
44
|
+
*/
|
|
45
|
+
export function cameraInCaptureSpace(
|
|
46
|
+
out: Float32Array,
|
|
47
|
+
view: ArrayLike<number>,
|
|
48
|
+
model: ArrayLike<number>,
|
|
49
|
+
): void {
|
|
50
|
+
multiplyMat4(PRODUCT, view, model);
|
|
51
|
+
const m = PRODUCT;
|
|
52
|
+
const a = m[0] ?? 0,
|
|
53
|
+
b = m[4] ?? 0,
|
|
54
|
+
c = m[8] ?? 0;
|
|
55
|
+
const d = m[1] ?? 0,
|
|
56
|
+
e = m[5] ?? 0,
|
|
57
|
+
f = m[9] ?? 0;
|
|
58
|
+
const g = m[2] ?? 0,
|
|
59
|
+
h = m[6] ?? 0,
|
|
60
|
+
i = m[10] ?? 0;
|
|
61
|
+
const cofactor0 = e * i - f * h;
|
|
62
|
+
const cofactor1 = f * g - d * i;
|
|
63
|
+
const cofactor2 = d * h - e * g;
|
|
64
|
+
const determinant = a * cofactor0 + b * cofactor1 + c * cofactor2;
|
|
65
|
+
if (determinant === 0 || !Number.isFinite(determinant)) {
|
|
66
|
+
out[0] = 0;
|
|
67
|
+
out[1] = 0;
|
|
68
|
+
out[2] = 0;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const inverse = 1 / determinant;
|
|
72
|
+
const tx = m[12] ?? 0;
|
|
73
|
+
const ty = m[13] ?? 0;
|
|
74
|
+
const tz = m[14] ?? 0;
|
|
75
|
+
out[0] = -(cofactor0 * tx + (c * h - b * i) * ty + (b * f - c * e) * tz) * inverse;
|
|
76
|
+
out[1] = -(cofactor1 * tx + (a * i - c * g) * ty + (c * d - a * f) * tz) * inverse;
|
|
77
|
+
out[2] = -(cofactor2 * tx + (b * g - a * h) * ty + (a * e - b * d) * tz) * inverse;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Scratch for the product above, so a frame allocates nothing. */
|
|
81
|
+
const PRODUCT = new Float32Array(16);
|