@tinypoly/gpu-renderer 0.0.4 → 0.0.5
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/ARCHITECTURE.md +22 -7
- package/README.md +1 -1
- package/dist/bvh.d.ts +11 -6
- package/dist/bvh.d.ts.map +1 -1
- package/dist/bvh.js +64 -11
- package/dist/bvh.js.map +1 -1
- package/dist/denoise.d.ts +7 -2
- package/dist/denoise.d.ts.map +1 -1
- package/dist/denoise.js +61 -21
- package/dist/denoise.js.map +1 -1
- package/dist/dispatchScheduler.d.ts +11 -4
- package/dist/dispatchScheduler.d.ts.map +1 -1
- package/dist/dispatchScheduler.js +14 -7
- package/dist/dispatchScheduler.js.map +1 -1
- package/dist/frameLayout.d.ts.map +1 -1
- package/dist/frameLayout.js +3 -0
- package/dist/frameLayout.js.map +1 -1
- package/dist/lights.d.ts +9 -1
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +9 -0
- package/dist/lights.js.map +1 -1
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +80 -22
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +14 -1
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +21 -3
- package/dist/sceneUpload.js.map +1 -1
- package/dist/texturePack.d.ts +16 -4
- package/dist/texturePack.d.ts.map +1 -1
- package/dist/texturePack.js +61 -8
- package/dist/texturePack.js.map +1 -1
- package/dist/wgsl/bvh.wgsl.d.ts +8 -2
- package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
- package/dist/wgsl/bvh.wgsl.js +167 -96
- package/dist/wgsl/bvh.wgsl.js.map +1 -1
- package/dist/wgsl/common.wgsl.d.ts.map +1 -1
- package/dist/wgsl/common.wgsl.js +20 -7
- package/dist/wgsl/common.wgsl.js.map +1 -1
- package/dist/wgsl/lights.wgsl.d.ts +1 -1
- package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
- package/dist/wgsl/lights.wgsl.js +3 -2
- package/dist/wgsl/lights.wgsl.js.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.js +15 -1
- package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
- package/dist/wgsl/random.wgsl.d.ts +1 -1
- package/dist/wgsl/random.wgsl.d.ts.map +1 -1
- package/dist/wgsl/random.wgsl.js +9 -17
- package/dist/wgsl/random.wgsl.js.map +1 -1
- package/dist/wgsl/surface.wgsl.d.ts +1 -1
- package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
- package/dist/wgsl/surface.wgsl.js +26 -3
- package/dist/wgsl/surface.wgsl.js.map +1 -1
- package/dist/wgsl/texture.wgsl.d.ts +1 -1
- package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
- package/dist/wgsl/texture.wgsl.js +10 -11
- package/dist/wgsl/texture.wgsl.js.map +1 -1
- package/package.json +1 -1
- package/src/bvh.ts +81 -12
- package/src/denoise.ts +66 -21
- package/src/dispatchScheduler.ts +16 -7
- package/src/frameLayout.ts +3 -0
- package/src/lights.ts +19 -1
- package/src/renderer.ts +92 -21
- package/src/sceneUpload.ts +32 -4
- package/src/texturePack.ts +80 -13
- package/src/wgsl/bvh.wgsl.ts +167 -96
- package/src/wgsl/common.wgsl.ts +20 -7
- package/src/wgsl/lights.wgsl.ts +3 -2
- package/src/wgsl/pathTrace.wgsl.ts +15 -1
- package/src/wgsl/random.wgsl.ts +9 -17
- package/src/wgsl/surface.wgsl.ts +26 -3
- package/src/wgsl/texture.wgsl.ts +10 -11
package/package.json
CHANGED
package/src/bvh.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Binned SAH bounding volume hierarchy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Binned SAH bounding volume hierarchy. It is built as a binary tree of boxes, eight words a node, and then laid out
|
|
3
|
+
* the way the shader reads it: each node holds the boxes of its two children, so one read tells the traversal both
|
|
4
|
+
* what it may enter and where to go, and a leaf is tested without being read at all. That is sixteen words a node,
|
|
5
|
+
* matching `struct BvhNode` in `wgsl/common.wgsl.ts`: per child a minimum corner and a link, then a maximum corner and
|
|
6
|
+
* a triangle count. A link of zero or more is the node to go to; a negative one is a leaf, `-1 - start` into `order`.
|
|
5
7
|
*
|
|
6
8
|
* The bounds are computed from the same `Float32Array` the shader traverses, so every corner is already a float and
|
|
7
9
|
* needs no widening. Building from double precision positions, as the CPU renderer does, would need each minimum
|
|
8
10
|
* rounded down and each maximum rounded up before they reach the GPU.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
|
-
/** Words per node in the packed buffer. */
|
|
12
|
-
export const NODE_WORDS =
|
|
13
|
+
/** Words per node in the packed buffer: both children's corners, links and counts. */
|
|
14
|
+
export const NODE_WORDS = 16;
|
|
15
|
+
|
|
16
|
+
/** Words per node of the binary tree the builder works on: its own corners, and its children or its leaf. */
|
|
17
|
+
const BINARY_WORDS = 8;
|
|
13
18
|
|
|
14
19
|
/** Triangles a leaf may hold. */
|
|
15
20
|
const MAX_LEAF_SIZE = 4;
|
|
@@ -27,7 +32,10 @@ const TRAVERSAL_COST = 0.125;
|
|
|
27
32
|
export const MAX_BVH_DEPTH = 32;
|
|
28
33
|
|
|
29
34
|
export interface PackedBvh {
|
|
30
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* `nodeCount * NODE_WORDS` words, read as floats for the corners and as signed integers for the links. Node zero
|
|
37
|
+
* holds the root's two children, or the root itself beside an empty child when the whole scene is one leaf.
|
|
38
|
+
*/
|
|
31
39
|
nodes: ArrayBuffer;
|
|
32
40
|
nodeCount: number;
|
|
33
41
|
/** Triangle indices in leaf order: a leaf covers `order[start] .. order[start + count - 1]`. */
|
|
@@ -66,15 +74,15 @@ export function buildBvh(positions: Float32Array, triangleCount: number): Packed
|
|
|
66
74
|
|
|
67
75
|
const { min, max, centroid } = triangleBounds(positions, triangleCount);
|
|
68
76
|
// The heuristic may split down to one triangle per leaf, so the pool starts at the common case and grows.
|
|
69
|
-
let nodes = new ArrayBuffer(Math.max(4, Math.ceil(triangleCount / 2)) *
|
|
77
|
+
let nodes = new ArrayBuffer(Math.max(4, Math.ceil(triangleCount / 2)) * BINARY_WORDS * 4);
|
|
70
78
|
let floats = new Float32Array(nodes);
|
|
71
79
|
let ints = new Int32Array(nodes);
|
|
72
80
|
|
|
73
81
|
const reserve = (needed: number) => {
|
|
74
|
-
if (needed *
|
|
82
|
+
if (needed * BINARY_WORDS * 4 <= nodes.byteLength) return;
|
|
75
83
|
|
|
76
|
-
const grown = new ArrayBuffer(Math.max(needed, Math.ceil(nodes.byteLength / (
|
|
77
|
-
*
|
|
84
|
+
const grown = new ArrayBuffer(Math.max(needed, Math.ceil(nodes.byteLength / (BINARY_WORDS * 4) * 1.6))
|
|
85
|
+
* BINARY_WORDS * 4);
|
|
78
86
|
|
|
79
87
|
new Float32Array(grown).set(floats);
|
|
80
88
|
nodes = grown;
|
|
@@ -132,7 +140,7 @@ export function buildBvh(positions: Float32Array, triangleCount: number): Packed
|
|
|
132
140
|
if (value > cMaxZ) cMaxZ = value;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
const word = node *
|
|
143
|
+
const word = node * BINARY_WORDS;
|
|
136
144
|
|
|
137
145
|
floats[word] = minX;
|
|
138
146
|
floats[word + 1] = minY;
|
|
@@ -180,7 +188,68 @@ export function buildBvh(positions: Float32Array, triangleCount: number): Packed
|
|
|
180
188
|
top += 2;
|
|
181
189
|
}
|
|
182
190
|
|
|
183
|
-
return {
|
|
191
|
+
return { ...widen(floats, ints, nodeCount), order, maxDepth };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The binary tree laid out for the shader: one node per interior node of it, holding both children's boxes. Interior
|
|
196
|
+
* nodes are numbered depth first, left before right, so a node and the child it leads to tend to share a cache line.
|
|
197
|
+
*/
|
|
198
|
+
function widen(floats: Float32Array, ints: Int32Array, binaryCount: number): { nodes: ArrayBuffer; nodeCount: number } {
|
|
199
|
+
const leaf = (node: number) => ints[node * BINARY_WORDS + 3] < 0;
|
|
200
|
+
const rootLeaf = leaf(0);
|
|
201
|
+
// A full binary tree has one fewer interior node than leaves; a single leaf still needs a node to stand in.
|
|
202
|
+
const nodeCount = rootLeaf ? 1 : (binaryCount - 1) / 2;
|
|
203
|
+
const nodes = new ArrayBuffer(nodeCount * NODE_WORDS * 4);
|
|
204
|
+
const outFloats = new Float32Array(nodes);
|
|
205
|
+
const outInts = new Int32Array(nodes);
|
|
206
|
+
const index = new Int32Array(binaryCount);
|
|
207
|
+
|
|
208
|
+
const writeChild = (word: number, child: number) => {
|
|
209
|
+
const from = child * BINARY_WORDS;
|
|
210
|
+
|
|
211
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
212
|
+
outFloats[word + axis] = floats[from + axis];
|
|
213
|
+
outFloats[word + 4 + axis] = floats[from + 4 + axis];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
outInts[word + 3] = leaf(child) ? ints[from + 3] : index[child];
|
|
217
|
+
outInts[word + 7] = leaf(child) ? ints[from + 7] : 0;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (rootLeaf) {
|
|
221
|
+
writeChild(0, 0);
|
|
222
|
+
|
|
223
|
+
// A box at the far corner of the float range, which no ray reaches, around a leaf of nothing.
|
|
224
|
+
for (let axis = 0; axis < 3; axis++) outFloats[8 + axis] = outFloats[12 + axis] = 3.4028234663852886e38;
|
|
225
|
+
|
|
226
|
+
outInts[11] = -1;
|
|
227
|
+
outInts[15] = 0;
|
|
228
|
+
|
|
229
|
+
return { nodes, nodeCount };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const interior: number[] = [];
|
|
233
|
+
const pending = [0];
|
|
234
|
+
|
|
235
|
+
while (pending.length) {
|
|
236
|
+
const node = pending.pop() as number;
|
|
237
|
+
|
|
238
|
+
index[node] = interior.length;
|
|
239
|
+
interior.push(node);
|
|
240
|
+
|
|
241
|
+
const left = ints[node * BINARY_WORDS + 3], right = ints[node * BINARY_WORDS + 7];
|
|
242
|
+
|
|
243
|
+
if (!leaf(right)) pending.push(right);
|
|
244
|
+
if (!leaf(left)) pending.push(left);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interior.forEach((node, at) => {
|
|
248
|
+
writeChild(at * NODE_WORDS, ints[node * BINARY_WORDS + 3]);
|
|
249
|
+
writeChild(at * NODE_WORDS + 8, ints[node * BINARY_WORDS + 7]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return { nodes, nodeCount };
|
|
184
253
|
}
|
|
185
254
|
|
|
186
255
|
/** Surface area, the cost the split heuristic weighs each side by. Empty bounds cost nothing. */
|
package/src/denoise.ts
CHANGED
|
@@ -86,6 +86,10 @@ struct Params {
|
|
|
86
86
|
@group(0) @binding(3) var<storage, read> specularIn: array<vec4<f32>>;
|
|
87
87
|
@group(0) @binding(4) var<storage, read_write> diffuseOut: array<vec4<f32>>;
|
|
88
88
|
@group(0) @binding(5) var<storage, read_write> specularOut: array<vec4<f32>>;
|
|
89
|
+
// The guides each tap compares, worked out once per pixel by prepare: the normal and the depth, then the roughness, or
|
|
90
|
+
// -1 where no sample has landed. The same numbers the accumulation gives, two reads a tap instead of four.
|
|
91
|
+
@group(0) @binding(6) var<storage, read_write> guides: array<vec4<f32>>;
|
|
92
|
+
@group(0) @binding(7) var<storage, read_write> roughnesses: array<f32>;
|
|
89
93
|
|
|
90
94
|
fn luminance(color: vec3<f32>) -> f32 {
|
|
91
95
|
return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
|
|
@@ -153,10 +157,15 @@ fn prepare(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
|
153
157
|
if (count <= 0.0) {
|
|
154
158
|
diffuseOut[index] = vec4<f32>(0.0);
|
|
155
159
|
specularOut[index] = vec4<f32>(0.0);
|
|
160
|
+
guides[index] = vec4<f32>(0.0);
|
|
161
|
+
roughnesses[index] = -1.0;
|
|
156
162
|
|
|
157
163
|
return;
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
guides[index] = vec4<f32>(guideNormal(index), guideDepth(index));
|
|
167
|
+
roughnesses[index] = guideRoughness(index);
|
|
168
|
+
|
|
160
169
|
let squares = accumulation[params.count + index];
|
|
161
170
|
let diffuse = diffuseMean(index);
|
|
162
171
|
let specular = specularMean(index);
|
|
@@ -269,9 +278,10 @@ fn atrous(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
|
269
278
|
return;
|
|
270
279
|
}
|
|
271
280
|
|
|
272
|
-
let
|
|
273
|
-
let
|
|
274
|
-
let
|
|
281
|
+
let guide = guides[index];
|
|
282
|
+
let normal = guide.xyz;
|
|
283
|
+
let depth = guide.w;
|
|
284
|
+
let roughness = roughnesses[index];
|
|
275
285
|
let diffuseSigma = params.edge * sqrt(max(localVariance(pixel, false), 0.0)) + 1e-6;
|
|
276
286
|
let specularSigma = params.edge * sqrt(max(localVariance(pixel, true), 0.0)) + 1e-6;
|
|
277
287
|
let radius = specularRadius(index, depth);
|
|
@@ -299,19 +309,23 @@ fn atrous(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
|
299
309
|
var ws = base;
|
|
300
310
|
|
|
301
311
|
if (dx != 0 || dy != 0) {
|
|
302
|
-
|
|
312
|
+
let otherRoughness = roughnesses[other];
|
|
313
|
+
|
|
314
|
+
// No sample has landed there.
|
|
315
|
+
if (otherRoughness < 0.0) {
|
|
303
316
|
continue;
|
|
304
317
|
}
|
|
305
318
|
|
|
319
|
+
let otherGuide = guides[other];
|
|
306
320
|
let offset = length(vec2<f32>(f32(dx * step), f32(dy * step)));
|
|
307
|
-
let alignment = max(dot(normal,
|
|
308
|
-
let depthWeight = exp(-abs(depth -
|
|
321
|
+
let alignment = max(dot(normal, otherGuide.xyz), 0.0);
|
|
322
|
+
let depthWeight = exp(-abs(depth - otherGuide.w) / (0.02 * depth * offset + 1e-3));
|
|
309
323
|
|
|
310
324
|
wd = wd * pow(alignment, 128.0) * depthWeight
|
|
311
325
|
* exp(-abs(luminance(d.rgb) - luminance(diffuseCenter.rgb)) / diffuseSigma);
|
|
312
326
|
|
|
313
327
|
ws = ws * pow(alignment, specularPower) * depthWeight
|
|
314
|
-
* exp(-abs(
|
|
328
|
+
* exp(-abs(otherRoughness - roughness) * 10.0)
|
|
315
329
|
* exp(-abs(luminance(s.rgb) - luminance(specularCenter.rgb)) / specularSigma)
|
|
316
330
|
* select(0.0, exp(-offset * offset / (radius * radius)), radius >= 0.5);
|
|
317
331
|
}
|
|
@@ -381,8 +395,21 @@ export class DenoisePass {
|
|
|
381
395
|
private readonly finish: GPUComputePipeline;
|
|
382
396
|
/** One uniform per stage, so a whole denoise is a single submit. */
|
|
383
397
|
private readonly params: GPUBuffer[];
|
|
384
|
-
/** Two ping-pong pairs, diffuse and specular, and the
|
|
398
|
+
/** Two ping-pong pairs, diffuse and specular, the output, then the guides and the roughness. */
|
|
385
399
|
private buffers: GPUBuffer[] = [];
|
|
400
|
+
/** Bind groups by the buffers they bind, and a number for each buffer seen, which is what the key is made of. */
|
|
401
|
+
private readonly groups = new Map<string, GPUBindGroup>();
|
|
402
|
+
private readonly ids = new WeakMap<GPUBuffer, number>();
|
|
403
|
+
private nextId = 0;
|
|
404
|
+
|
|
405
|
+
private idOf(buffer: GPUBuffer): number {
|
|
406
|
+
let id = this.ids.get(buffer);
|
|
407
|
+
|
|
408
|
+
if (id === undefined) this.ids.set(buffer, id = this.nextId++);
|
|
409
|
+
|
|
410
|
+
return id;
|
|
411
|
+
}
|
|
412
|
+
|
|
386
413
|
private width = 0;
|
|
387
414
|
private height = 0;
|
|
388
415
|
|
|
@@ -402,6 +429,8 @@ export class DenoisePass {
|
|
|
402
429
|
{ binding: 3, visibility, buffer: storage("read-only-storage") },
|
|
403
430
|
{ binding: 4, visibility, buffer: storage("storage") },
|
|
404
431
|
{ binding: 5, visibility, buffer: storage("storage") },
|
|
432
|
+
{ binding: 6, visibility, buffer: storage("storage") },
|
|
433
|
+
{ binding: 7, visibility, buffer: storage("storage") },
|
|
405
434
|
],
|
|
406
435
|
});
|
|
407
436
|
|
|
@@ -423,9 +452,12 @@ export class DenoisePass {
|
|
|
423
452
|
|
|
424
453
|
const size = Math.max(16, width * height * 16);
|
|
425
454
|
|
|
426
|
-
this.buffers = Array.from({ length:
|
|
455
|
+
this.buffers = Array.from({ length: 6 }, () =>
|
|
427
456
|
this.device.createBuffer({ size, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }));
|
|
457
|
+
this.buffers.push(
|
|
458
|
+
this.device.createBuffer({ size: Math.max(16, width * height * 4), usage: GPUBufferUsage.STORAGE }));
|
|
428
459
|
this.output = this.buffers[4];
|
|
460
|
+
this.groups.clear();
|
|
429
461
|
this.width = width;
|
|
430
462
|
this.height = height;
|
|
431
463
|
}
|
|
@@ -460,22 +492,34 @@ export class DenoisePass {
|
|
|
460
492
|
this.resize(width, height);
|
|
461
493
|
this.writeParams(width, height, settings, view);
|
|
462
494
|
|
|
463
|
-
const [diffuseA, diffuseB, specularA, specularB, output] = this.buffers;
|
|
495
|
+
const [diffuseA, diffuseB, specularA, specularB, output, guides, roughnesses] = this.buffers;
|
|
464
496
|
const groups = [Math.ceil(width / 8), Math.ceil(height / 8)] as const;
|
|
465
497
|
|
|
466
498
|
const run = (pipeline: GPUComputePipeline, params: GPUBuffer,
|
|
467
499
|
sources: [GPUBuffer, GPUBuffer], targets: [GPUBuffer, GPUBuffer]) => {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
500
|
+
// The same few groups serve every denoise of an image size: they are made once and kept.
|
|
501
|
+
const key = [params, accumulation, ...sources, ...targets].map(buffer => this.idOf(buffer)).join();
|
|
502
|
+
let group = this.groups.get(key);
|
|
503
|
+
|
|
504
|
+
if (!group) {
|
|
505
|
+
group = this.device.createBindGroup({
|
|
506
|
+
layout: this.layout,
|
|
507
|
+
entries: [
|
|
508
|
+
{ binding: 0, resource: { buffer: params } },
|
|
509
|
+
{ binding: 1, resource: { buffer: accumulation } },
|
|
510
|
+
{ binding: 2, resource: { buffer: sources[0] } },
|
|
511
|
+
{ binding: 3, resource: { buffer: sources[1] } },
|
|
512
|
+
{ binding: 4, resource: { buffer: targets[0] } },
|
|
513
|
+
{ binding: 5, resource: { buffer: targets[1] } },
|
|
514
|
+
{ binding: 6, resource: { buffer: guides } },
|
|
515
|
+
{ binding: 7, resource: { buffer: roughnesses } },
|
|
516
|
+
],
|
|
517
|
+
});
|
|
518
|
+
// A new accumulation buffer comes with a new size, which clears these; the cap is only a guard.
|
|
519
|
+
if (this.groups.size > 64) this.groups.clear();
|
|
520
|
+
|
|
521
|
+
this.groups.set(key, group);
|
|
522
|
+
}
|
|
479
523
|
|
|
480
524
|
const pass = encoder.beginComputePass({ label: "denoise" });
|
|
481
525
|
|
|
@@ -509,6 +553,7 @@ export class DenoisePass {
|
|
|
509
553
|
for (const buffer of [...this.buffers, ...this.params]) buffer.destroy();
|
|
510
554
|
|
|
511
555
|
this.buffers = [];
|
|
556
|
+
this.groups.clear();
|
|
512
557
|
this.output = null;
|
|
513
558
|
}
|
|
514
559
|
}
|
package/src/dispatchScheduler.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface DispatchSchedulerOptions {
|
|
|
13
13
|
initialRows?: number;
|
|
14
14
|
/** Rows in the whole image. */
|
|
15
15
|
maxRows: number;
|
|
16
|
+
/** The most paths per pixel one submit may take, which is how far past the whole image a band may grow. */
|
|
17
|
+
maxSamples?: number;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** The most one measurement may widen the band, so an unmeasured machine meets the watchdog gradually. */
|
|
@@ -24,20 +26,26 @@ export class DispatchScheduler {
|
|
|
24
26
|
private readonly targetMs: number;
|
|
25
27
|
private readonly initialRows: number;
|
|
26
28
|
private readonly maxRows: number;
|
|
29
|
+
private readonly maxSamples: number;
|
|
30
|
+
/** The work of the next submit, in rows times paths per pixel. */
|
|
27
31
|
private rows: number;
|
|
28
|
-
/** Rows a millisecond, as far as anything has shown. Zero until the first submit says something. */
|
|
32
|
+
/** Rows a millisecond, one path each, as far as anything has shown. Zero until the first submit says something. */
|
|
29
33
|
private throughput = 0;
|
|
30
34
|
|
|
31
35
|
constructor(options: DispatchSchedulerOptions) {
|
|
32
36
|
this.targetMs = options.targetMs ?? 12;
|
|
33
37
|
this.maxRows = Math.max(1, Math.floor(options.maxRows));
|
|
38
|
+
this.maxSamples = Math.max(1, Math.floor(options.maxSamples ?? 1));
|
|
34
39
|
this.initialRows = Math.max(1, Math.min(this.maxRows, Math.floor(options.initialRows ?? 16)));
|
|
35
40
|
this.rows = this.initialRows;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Rows the next submit should cover, never more than what is left. A submit that takes several paths per pixel
|
|
45
|
+
* covers that many times fewer rows, so it still takes about the target time.
|
|
46
|
+
*/
|
|
47
|
+
next(remaining: number, samples = 1): number {
|
|
48
|
+
return Math.max(1, Math.min(Math.floor(this.rows / Math.max(1, samples)), Math.floor(remaining)));
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -52,15 +60,16 @@ export class DispatchScheduler {
|
|
|
52
60
|
* The rate is measured from the rows actually submitted rather than from the current guess, so one band clamped by
|
|
53
61
|
* what was left of the image does not stop the next one from growing.
|
|
54
62
|
*/
|
|
55
|
-
record(durationMs: number, rows: number) {
|
|
63
|
+
record(durationMs: number, rows: number, samples = 1) {
|
|
56
64
|
if (!Number.isFinite(durationMs) || durationMs <= 0 || rows <= 0) return;
|
|
57
65
|
|
|
58
|
-
const
|
|
66
|
+
const work = rows * Math.max(1, samples);
|
|
67
|
+
const observed = work / durationMs;
|
|
59
68
|
|
|
60
69
|
// Good news is believed at once, since it can only mean the band was cheaper than the clock suggested. Bad news
|
|
61
70
|
// is taken gradually, so one submit that happened to land behind something else does not collapse the band.
|
|
62
71
|
this.throughput = observed > this.throughput ? observed : this.throughput * DECAY + observed * (1 - DECAY);
|
|
63
|
-
this.rows = Math.max(1, Math.min(this.maxRows,
|
|
72
|
+
this.rows = Math.max(1, Math.min(this.maxRows * this.maxSamples, work * GROW, this.targetMs * this.throughput));
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Back to the unmeasured band, for a new scene or a new size. */
|
package/src/frameLayout.ts
CHANGED
|
@@ -30,11 +30,14 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
|
|
|
30
30
|
{ name: "triangleCount", type: "u32" },
|
|
31
31
|
{ name: "lightCount", type: "u32" },
|
|
32
32
|
{ name: "areaLightCount", type: "u32", doc: "How many leading lights are emissive triangles." },
|
|
33
|
+
{ name: "lightCdf", type: "u32", doc: "Where every light's cumulative probability starts in the texture buffer." },
|
|
34
|
+
{ name: "lightTriangles", type: "u32", doc: "Where every emissive light's triangle starts in the texture buffer." },
|
|
33
35
|
{ name: "clampIndirect", type: "f32", doc: "Maximum luminance one indirect contribution may add; zero leaves it." },
|
|
34
36
|
{ name: "shadowSpread", type: "f32", doc: "Tangent of half the angular diameter shadow rays spread over." },
|
|
35
37
|
{ name: "shadowsEnabled", type: "u32" },
|
|
36
38
|
{ name: "uvOffset", type: "u32", doc: "Where the UV sets start inside the attribute buffer." },
|
|
37
39
|
{ name: "orderOffset", type: "u32", doc: "Where the hierarchy's triangle order starts inside the index buffer." },
|
|
40
|
+
{ name: "leafOffset", type: "u32", doc: "Where each triangle's position in that order starts inside the index buffer." },
|
|
38
41
|
{ name: "colorOffset", type: "u32", doc: "Where vertex colours start inside the attribute buffer." },
|
|
39
42
|
{ name: "hasColors", type: "u32" },
|
|
40
43
|
{ name: "varyingOffset", type: "u32", doc: "Where shader varyings start inside the attribute buffer." },
|
package/src/lights.ts
CHANGED
|
@@ -50,8 +50,16 @@ export interface PackedLights {
|
|
|
50
50
|
ambient: { sky: number[]; ground: number[]; axis: number[] };
|
|
51
51
|
/** How often direct sampling picks the environment rather than a light. Zero when there is nothing to aim at. */
|
|
52
52
|
environmentPick: number;
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Words to append to the texture buffer: a spot light's map index and the matrix that projects it, then the
|
|
55
|
+
* cumulative probability of every entry and the triangle of every emissive one, packed tight so the two searches the
|
|
56
|
+
* shader makes over them read four bytes a step instead of a whole entry.
|
|
57
|
+
*/
|
|
54
58
|
extraWords: Uint32Array;
|
|
59
|
+
/** Where the cumulative probabilities start in the texture buffer, one float per entry. */
|
|
60
|
+
cdfAt: number;
|
|
61
|
+
/** Where the emissive entries' triangles start in the texture buffer, one word per emissive entry. */
|
|
62
|
+
trianglesAt: number;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
|
|
@@ -353,8 +361,18 @@ export function packLights(
|
|
|
353
361
|
});
|
|
354
362
|
|
|
355
363
|
const environmentPick = surrounding.length ? surrounding[0].weight / total : 0;
|
|
364
|
+
const base = options.extraBase ?? 0;
|
|
365
|
+
const cdfAt = base + extra.length;
|
|
366
|
+
|
|
367
|
+
for (let index = 0; index < candidates.length; index++) extra.push(words[index * LIGHT_WORDS + 1]);
|
|
368
|
+
|
|
369
|
+
const trianglesAt = base + extra.length;
|
|
370
|
+
|
|
371
|
+
for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
|
|
356
372
|
|
|
357
373
|
return {
|
|
374
|
+
cdfAt,
|
|
375
|
+
trianglesAt,
|
|
358
376
|
entries,
|
|
359
377
|
count: candidates.length,
|
|
360
378
|
areaCount: areas.length,
|
package/src/renderer.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { DispatchScheduler } from "./dispatchScheduler.js";
|
|
|
8
8
|
import { packGeometry } from "./geometry.js";
|
|
9
9
|
import { packLights } from "./lights.js";
|
|
10
10
|
import { NO_PROGRAM } from "./materialLayout.js";
|
|
11
|
-
import { NO_TEXTURE, planTextures, writeTextures } from "./texturePack.js";
|
|
11
|
+
import { NO_TEXTURE, planTextures, textureEntry, writeTextures } from "./texturePack.js";
|
|
12
12
|
import { PathTracePass, type FrameUniforms } from "./pathTracePass.js";
|
|
13
13
|
import { ImageResolvePass } from "./imageResolve.js";
|
|
14
14
|
import { PresentPass } from "./presentPass.js";
|
|
@@ -130,6 +130,31 @@ const yieldToPage = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
|
130
130
|
*/
|
|
131
131
|
const SHOW_EVERY_MS = 16;
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Submits on the device at once. Two keep it busy: the next band is queued while the last one runs, and each submit
|
|
135
|
+
* still stays short enough for the watchdog on its own.
|
|
136
|
+
*/
|
|
137
|
+
const BANDS_IN_FLIGHT = 2;
|
|
138
|
+
|
|
139
|
+
/** How often the canvas takes a freshly denoised image while a frame traces. */
|
|
140
|
+
const DENOISE_EVERY_MS = 200;
|
|
141
|
+
|
|
142
|
+
/** The most paths per pixel one dispatch takes, while no pixel can stop yet. */
|
|
143
|
+
const BATCH_PATHS = 4;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Paths per pixel the next pass takes. The first takes one, so the canvas shows something at once. Until adaptive
|
|
147
|
+
* sampling may stop a pixel, every pixel takes every path anyway, so a pass takes several and pays for reading and
|
|
148
|
+
* writing the accumulation once; from then on one at a time, so a pixel stops as soon as it has converged.
|
|
149
|
+
*/
|
|
150
|
+
function pathsPerDispatch(taken: number, until: number, adaptive: boolean): number {
|
|
151
|
+
if (taken === 0) return 1;
|
|
152
|
+
|
|
153
|
+
if (adaptive && taken >= until) return 1;
|
|
154
|
+
|
|
155
|
+
return Math.max(1, Math.min(BATCH_PATHS, until - taken));
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
const EMPTY_SCENE: SerializedScene = { meshes: [], lights: [], textures: [] };
|
|
134
159
|
|
|
135
160
|
/** Paths every pixel takes before adaptive sampling may stop it: below this its variance says too little. */
|
|
@@ -252,7 +277,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
252
277
|
this.current = mergeSettings(DEFAULT_RENDER_SETTINGS, options.settings);
|
|
253
278
|
this.denoiserSettings = mergeDenoiser(DEFAULT_DENOISER, options.denoiser);
|
|
254
279
|
this.environment = DEFAULT_RENDER_ENVIRONMENT;
|
|
255
|
-
this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight });
|
|
280
|
+
this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight, maxSamples: BATCH_PATHS });
|
|
256
281
|
this.setEnvironment(options.environment ?? DEFAULT_RENDER_ENVIRONMENT);
|
|
257
282
|
|
|
258
283
|
if (options.scene) this.setScene(options.scene);
|
|
@@ -549,29 +574,57 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
549
574
|
this.emit("start", { width, height });
|
|
550
575
|
|
|
551
576
|
const target = Math.max(1, Math.round(this.current.samples));
|
|
552
|
-
// Adaptive sampling: the frame has the work of `target`
|
|
577
|
+
// Adaptive sampling: the frame has the work of `target` paths over the whole image to spend. Pixels that have
|
|
553
578
|
// converged stop taking samples, and a row with none left stops being dispatched, so what they would have used goes
|
|
554
579
|
// to the rows that still need it, up to a few times `target` paths in the noisiest pixels.
|
|
555
580
|
const adaptive = target > ADAPTIVE_MIN;
|
|
556
|
-
const
|
|
581
|
+
const maxPaths = adaptive ? target * ADAPTIVE_REACH : target;
|
|
582
|
+
// In rows times paths, the unit the scheduler measures work in.
|
|
557
583
|
const budget = target * height;
|
|
558
584
|
const accumulator = this.accumulator as Accumulator;
|
|
559
585
|
let active: number[] = Array.from({ length: height }, (_, row) => row);
|
|
560
|
-
|
|
586
|
+
// Paths every pixel still in the running has taken so far, which is also the next sample index.
|
|
587
|
+
let taken = 0;
|
|
588
|
+
let spent = 0, band = 0;
|
|
561
589
|
let shown = performance.now();
|
|
590
|
+
let denoisedAt = -Infinity;
|
|
562
591
|
const denoise = this.current.globalIllumination.denoise;
|
|
592
|
+
// Bands on the device, oldest first. The next one is encoded and queued while the last one runs, so the device
|
|
593
|
+
// never waits for the page to hear that a band finished.
|
|
594
|
+
const flight: { done: Promise<unknown>; submitted: number; rows: number; samples: number }[] = [];
|
|
595
|
+
let settledAt = 0;
|
|
596
|
+
|
|
597
|
+
const settle = async() => {
|
|
598
|
+
const oldest = flight.shift();
|
|
599
|
+
|
|
600
|
+
if (!oldest) return;
|
|
601
|
+
|
|
602
|
+
await oldest.done;
|
|
603
|
+
|
|
604
|
+
const now = performance.now();
|
|
605
|
+
|
|
606
|
+
// A band starts on the device when the one before it ends, or when it was submitted if the device was idle. The
|
|
607
|
+
// page may hear of that end late, which would make this band look shorter than it ran; since it was submitted it
|
|
608
|
+
// has waited for at most the bands ahead of it, so its share of that wait bounds it from below.
|
|
609
|
+
const ran = Math.max(now - Math.max(oldest.submitted, settledAt), (now - oldest.submitted) / BANDS_IN_FLIGHT);
|
|
610
|
+
|
|
611
|
+
this.scheduler.record(ran, oldest.rows, oldest.samples);
|
|
612
|
+
settledAt = now;
|
|
613
|
+
};
|
|
563
614
|
|
|
564
615
|
this.denoised = false;
|
|
565
616
|
this.finished = null;
|
|
566
617
|
|
|
567
618
|
const matrices = cameraMatrices(camera, width, height);
|
|
619
|
+
const uniforms = this.frameUniforms(camera, width, height, assets);
|
|
568
620
|
|
|
569
621
|
// Two over the projection's vertical scale and the height: one pixel's extent, per unit of depth in perspective.
|
|
570
622
|
this.denoiseView = { pixelScale: 2 / (matrices.projection[5] * height), orthographic: matrices.orthographic };
|
|
571
623
|
|
|
572
|
-
while (
|
|
624
|
+
while (taken < maxPaths && spent < budget && active.length) {
|
|
573
625
|
// Only a pass whose pixels may stop needs its rows flagged; before that every row is busy anyway.
|
|
574
|
-
const flagging = adaptive &&
|
|
626
|
+
const flagging = adaptive && taken >= ADAPTIVE_MIN;
|
|
627
|
+
const samples = pathsPerDispatch(taken, adaptive ? ADAPTIVE_MIN : target, adaptive);
|
|
575
628
|
|
|
576
629
|
if (flagging) {
|
|
577
630
|
const clear = gpu.device.createCommandEncoder();
|
|
@@ -588,17 +641,17 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
588
641
|
|
|
589
642
|
this.check(revision);
|
|
590
643
|
|
|
591
|
-
const rows = this.scheduler.next(end - row);
|
|
644
|
+
const rows = this.scheduler.next(end - row, samples);
|
|
592
645
|
const started = performance.now();
|
|
593
646
|
const encoder = gpu.device.createCommandEncoder();
|
|
594
647
|
|
|
595
648
|
this.emit("bucket", { worker: 0, bucket: { x: 0, y: row, width, height: rows, index: band++ }, width, height });
|
|
596
649
|
this.pathTrace?.encode(encoder, {
|
|
597
|
-
...
|
|
650
|
+
...uniforms,
|
|
598
651
|
bandOrigin: [0, row],
|
|
599
652
|
bandSize: [width, rows],
|
|
600
|
-
sampleIndex:
|
|
601
|
-
sampleCount:
|
|
653
|
+
sampleIndex: taken,
|
|
654
|
+
sampleCount: samples,
|
|
602
655
|
adaptiveMin: adaptive ? ADAPTIVE_MIN : 0,
|
|
603
656
|
adaptiveThreshold: ADAPTIVE_THRESHOLD,
|
|
604
657
|
});
|
|
@@ -608,11 +661,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
608
661
|
if (show) this.encodePresent(encoder, width, height);
|
|
609
662
|
|
|
610
663
|
gpu.device.queue.submit([encoder.finish()]);
|
|
611
|
-
|
|
664
|
+
flight.push({ done: gpu.device.queue.onSubmittedWorkDone(), submitted: performance.now(), rows, samples });
|
|
665
|
+
|
|
666
|
+
while (flight.length >= BANDS_IN_FLIGHT) await settle();
|
|
612
667
|
|
|
613
|
-
this.scheduler.record(performance.now() - started, rows);
|
|
614
668
|
row += rows;
|
|
615
|
-
spent += rows;
|
|
669
|
+
spent += rows * samples;
|
|
616
670
|
this.emit("progress", { completed: Math.min(spent, budget), total: budget, phase: "shade" });
|
|
617
671
|
|
|
618
672
|
if (show) {
|
|
@@ -622,24 +676,35 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
622
676
|
}
|
|
623
677
|
}
|
|
624
678
|
|
|
625
|
-
|
|
679
|
+
taken += samples;
|
|
626
680
|
this.frameIndex++;
|
|
627
681
|
|
|
628
|
-
// The
|
|
629
|
-
//
|
|
630
|
-
|
|
682
|
+
// The rows this pass still traced in are copied out before anything else is queued, so they can be read while
|
|
683
|
+
// the device goes on with the denoiser.
|
|
684
|
+
const rowsRead = flagging ? this.activeRows(gpu, accumulator) : null;
|
|
685
|
+
|
|
686
|
+
// The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
|
|
687
|
+
// filtering the whole image after every pass would cost a cheap scene as much as tracing it.
|
|
688
|
+
if (denoise && this.canvas && this.context && performance.now() - denoisedAt >= DENOISE_EVERY_MS) {
|
|
631
689
|
const encoder = gpu.device.createCommandEncoder();
|
|
632
690
|
|
|
633
691
|
this.encodeDenoise(gpu, encoder, width, height);
|
|
634
692
|
this.encodePresent(encoder, width, height);
|
|
635
693
|
gpu.device.queue.submit([encoder.finish()]);
|
|
694
|
+
denoisedAt = performance.now();
|
|
636
695
|
}
|
|
637
696
|
|
|
638
697
|
this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
|
|
639
698
|
|
|
640
|
-
if (
|
|
699
|
+
if (rowsRead) {
|
|
700
|
+
while (flight.length) await settle();
|
|
701
|
+
|
|
702
|
+
active = await rowsRead;
|
|
703
|
+
}
|
|
641
704
|
}
|
|
642
705
|
|
|
706
|
+
while (flight.length) await settle();
|
|
707
|
+
|
|
643
708
|
// Converged early, or spent: either way the image is as finished as this frame makes it.
|
|
644
709
|
this.emit("progress", { completed: budget, total: budget, phase: "shade" });
|
|
645
710
|
this.emit("sample", { samples: target, target });
|
|
@@ -741,7 +806,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
741
806
|
|
|
742
807
|
this.accumulator ??= new Accumulator(gpu.device, width, height);
|
|
743
808
|
|
|
744
|
-
if (resized) this.scheduler = new DispatchScheduler({ maxRows: height });
|
|
809
|
+
if (resized) this.scheduler = new DispatchScheduler({ maxRows: height, maxSamples: BATCH_PATHS });
|
|
745
810
|
|
|
746
811
|
this.pathTrace.setBindings(this.buffers, this.accumulator.buffer);
|
|
747
812
|
this.configureCanvas(gpu, width, height);
|
|
@@ -820,7 +885,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
820
885
|
this.check(revision);
|
|
821
886
|
|
|
822
887
|
const buffers = uploadScene(gpu.device, geometry, bvh, textures, lights, environment, cdf);
|
|
823
|
-
|
|
888
|
+
|
|
889
|
+
const textureOffsets = new Map([...textures.indexOf]
|
|
890
|
+
.map(([id, index]) => [id, textures.data[textureEntry(index) + 2]]));
|
|
891
|
+
|
|
824
892
|
const packed: PackedSummary = { cameraDependent: geometry.cameraDependent, textureOffsets };
|
|
825
893
|
|
|
826
894
|
if (buffers.bytes.largest > gpu.maxStorageBufferBindingSize) {
|
|
@@ -1004,12 +1072,15 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1004
1072
|
triangleCount: buffers?.triangleCount ?? 0,
|
|
1005
1073
|
lightCount: buffers?.lightCount ?? 0,
|
|
1006
1074
|
areaLightCount: buffers?.areaLightCount ?? 0,
|
|
1075
|
+
lightCdf: buffers?.lightCdf ?? 0,
|
|
1076
|
+
lightTriangles: buffers?.lightTriangles ?? 0,
|
|
1007
1077
|
clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
|
|
1008
1078
|
// The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
|
|
1009
1079
|
shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
|
|
1010
1080
|
shadowsEnabled: shadows.enabled,
|
|
1011
1081
|
uvOffset: buffers?.uvOffset ?? 0,
|
|
1012
1082
|
orderOffset: buffers?.orderOffset ?? 0,
|
|
1083
|
+
leafOffset: buffers?.leafOffset ?? 0,
|
|
1013
1084
|
colorOffset: buffers?.colorOffset ?? 0,
|
|
1014
1085
|
hasColors: buffers?.hasColors ?? false,
|
|
1015
1086
|
varyingOffset: buffers?.varyingOffset ?? 0,
|