@tinypoly/gpu-renderer 0.0.3 → 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.
Files changed (81) hide show
  1. package/ARCHITECTURE.md +22 -7
  2. package/README.md +1 -1
  3. package/dist/bvh.d.ts +11 -6
  4. package/dist/bvh.d.ts.map +1 -1
  5. package/dist/bvh.js +64 -11
  6. package/dist/bvh.js.map +1 -1
  7. package/dist/denoise.d.ts +7 -2
  8. package/dist/denoise.d.ts.map +1 -1
  9. package/dist/denoise.js +61 -21
  10. package/dist/denoise.js.map +1 -1
  11. package/dist/dispatchScheduler.d.ts +11 -4
  12. package/dist/dispatchScheduler.d.ts.map +1 -1
  13. package/dist/dispatchScheduler.js +14 -7
  14. package/dist/dispatchScheduler.js.map +1 -1
  15. package/dist/frameLayout.d.ts.map +1 -1
  16. package/dist/frameLayout.js +3 -0
  17. package/dist/frameLayout.js.map +1 -1
  18. package/dist/imageResolve.d.ts +26 -0
  19. package/dist/imageResolve.d.ts.map +1 -0
  20. package/dist/imageResolve.js +114 -0
  21. package/dist/imageResolve.js.map +1 -0
  22. package/dist/lights.d.ts +9 -1
  23. package/dist/lights.d.ts.map +1 -1
  24. package/dist/lights.js +9 -0
  25. package/dist/lights.js.map +1 -1
  26. package/dist/renderer.d.ts +1 -0
  27. package/dist/renderer.d.ts.map +1 -1
  28. package/dist/renderer.js +97 -62
  29. package/dist/renderer.js.map +1 -1
  30. package/dist/sceneUpload.d.ts +14 -1
  31. package/dist/sceneUpload.d.ts.map +1 -1
  32. package/dist/sceneUpload.js +21 -3
  33. package/dist/sceneUpload.js.map +1 -1
  34. package/dist/texturePack.d.ts +16 -4
  35. package/dist/texturePack.d.ts.map +1 -1
  36. package/dist/texturePack.js +61 -8
  37. package/dist/texturePack.js.map +1 -1
  38. package/dist/wgsl/bvh.wgsl.d.ts +8 -2
  39. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
  40. package/dist/wgsl/bvh.wgsl.js +167 -96
  41. package/dist/wgsl/bvh.wgsl.js.map +1 -1
  42. package/dist/wgsl/common.wgsl.d.ts.map +1 -1
  43. package/dist/wgsl/common.wgsl.js +20 -7
  44. package/dist/wgsl/common.wgsl.js.map +1 -1
  45. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  46. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  47. package/dist/wgsl/lights.wgsl.js +3 -2
  48. package/dist/wgsl/lights.wgsl.js.map +1 -1
  49. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  50. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  51. package/dist/wgsl/pathTrace.wgsl.js +15 -1
  52. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  53. package/dist/wgsl/random.wgsl.d.ts +1 -1
  54. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  55. package/dist/wgsl/random.wgsl.js +9 -17
  56. package/dist/wgsl/random.wgsl.js.map +1 -1
  57. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  58. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/surface.wgsl.js +26 -3
  60. package/dist/wgsl/surface.wgsl.js.map +1 -1
  61. package/dist/wgsl/texture.wgsl.d.ts +1 -1
  62. package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/texture.wgsl.js +10 -11
  64. package/dist/wgsl/texture.wgsl.js.map +1 -1
  65. package/package.json +1 -1
  66. package/src/bvh.ts +81 -12
  67. package/src/denoise.ts +66 -21
  68. package/src/dispatchScheduler.ts +16 -7
  69. package/src/frameLayout.ts +3 -0
  70. package/src/imageResolve.ts +136 -0
  71. package/src/lights.ts +19 -1
  72. package/src/renderer.ts +123 -77
  73. package/src/sceneUpload.ts +32 -4
  74. package/src/texturePack.ts +80 -13
  75. package/src/wgsl/bvh.wgsl.ts +167 -96
  76. package/src/wgsl/common.wgsl.ts +20 -7
  77. package/src/wgsl/lights.wgsl.ts +3 -2
  78. package/src/wgsl/pathTrace.wgsl.ts +15 -1
  79. package/src/wgsl/random.wgsl.ts +9 -17
  80. package/src/wgsl/surface.wgsl.ts +26 -3
  81. package/src/wgsl/texture.wgsl.ts +10 -11
@@ -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
- /** Rows the next submit should cover, never more than what is left. */
39
- next(remaining: number): number {
40
- return Math.max(1, Math.min(Math.floor(this.rows), Math.floor(remaining)));
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 observed = rows / durationMs;
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, rows * GROW, this.targetMs * this.throughput));
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. */
@@ -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." },
@@ -0,0 +1,136 @@
1
+ import { createShaderModule } from "./shaderModule.js";
2
+ import type { RenderToneMapping } from "./upstream.js";
3
+ import { PRESENT_WGSL } from "./wgsl/present.wgsl.js";
4
+
5
+ /** The order the presentation shader's `toneMapping` switch expects. */
6
+ const TONE_MAPPING = ["aces", "agx", "neutral", "linear"] as const;
7
+
8
+ const UNIFORM_BYTES = 48;
9
+
10
+ const WORKGROUP = 8;
11
+
12
+ /**
13
+ * The finished image, resolved on the device: the presentation pass's display transform, run as a compute pass that
14
+ * writes straight-alpha sRGB bytes, four per pixel. Reading those back is a sixth of the float accumulation and needs
15
+ * no loop over the pixels on the page, which is what held it for seconds at the end of a large frame.
16
+ *
17
+ * It follows the image `render()` has always resolved: no grain, and the vignette measured from each pixel's corner.
18
+ */
19
+ export const IMAGE_RESOLVE_WGSL = /* wgsl */ `${PRESENT_WGSL}
20
+ @group(0) @binding(3) var<storage, read_write> pixels: array<u32>;
21
+
22
+ fn encodeByte(value: f32) -> u32 {
23
+ return u32(round(clamp(value, 0.0, 1.0) * 255.0));
24
+ }
25
+
26
+ @compute @workgroup_size(${WORKGROUP}, ${WORKGROUP})
27
+ fn resolveMain(@builtin(global_invocation_id) id: vec3<u32>) {
28
+ let width = present.resolution.x;
29
+ let height = present.resolution.y;
30
+
31
+ if (id.x >= width || id.y >= height) {
32
+ return;
33
+ }
34
+
35
+ let index = id.y * width + id.x;
36
+ let stored = accumulation[index];
37
+ let samples = stored.w;
38
+ var alpha = 1.0;
39
+
40
+ if (present.transparent != 0u) {
41
+ alpha = select(0.0, clamp(accumulation[width * height + index].x / samples, 0.0, 1.0), samples > 0.0);
42
+ }
43
+
44
+ var color = vec3<f32>(0.0);
45
+
46
+ if (samples > 0.0 && alpha > 0.0) {
47
+ let mean = select(stored.rgb / samples, denoised[index].rgb, present.denoised != 0u);
48
+ let centred = vec2<f32>(f32(id.x) / f32(width), f32(id.y) / f32(height)) - 0.5;
49
+ let falloff = max(0.0, 1.0 - present.vignette * dot(centred, centred) * 2.0);
50
+
51
+ color = mean / alpha * falloff;
52
+ }
53
+
54
+ let encoded = linearToSrgb(toneMap(color));
55
+
56
+ pixels[index] = encodeByte(encoded.r) | (encodeByte(encoded.g) << 8u) | (encodeByte(encoded.b) << 16u)
57
+ | (encodeByte(alpha) << 24u);
58
+ }
59
+ `;
60
+
61
+ export interface ResolveSettings {
62
+ exposure: number;
63
+ toneMapping: RenderToneMapping;
64
+ vignette: number;
65
+ transparent: boolean;
66
+ }
67
+
68
+ export class ImageResolvePass {
69
+ private readonly pipeline: GPUComputePipeline;
70
+ private readonly uniforms: GPUBuffer;
71
+ private readonly values = new ArrayBuffer(UNIFORM_BYTES);
72
+
73
+ constructor(private readonly device: GPUDevice) {
74
+ const module = createShaderModule(device, "image resolve", IMAGE_RESOLVE_WGSL);
75
+
76
+ this.pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "resolveMain" } });
77
+ this.uniforms = device.createBuffer({
78
+ size: UNIFORM_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
79
+ });
80
+ }
81
+
82
+ /** The image as RGBA bytes, straight alpha, row by row from the top. */
83
+ async read(accumulation: GPUBuffer, denoised: GPUBuffer | null, width: number, height: number,
84
+ settings: ResolveSettings): Promise<Uint8ClampedArray<ArrayBuffer>> {
85
+ const floats = new Float32Array(this.values), integers = new Uint32Array(this.values);
86
+
87
+ integers.fill(0);
88
+ integers[0] = width;
89
+ integers[1] = height;
90
+ floats[2] = settings.exposure;
91
+ integers[3] = Math.max(0, TONE_MAPPING.indexOf(settings.toneMapping));
92
+ floats[4] = settings.vignette;
93
+ integers[7] = settings.transparent ? 1 : 0;
94
+ integers[8] = denoised ? 1 : 0;
95
+ this.device.queue.writeBuffer(this.uniforms, 0, this.values);
96
+
97
+ const bytes = width * height * 4;
98
+ const output = this.device.createBuffer({ size: bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
99
+ const staging = this.device.createBuffer({ size: bytes, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
100
+
101
+ try {
102
+ const encoder = this.device.createCommandEncoder({ label: "image resolve" });
103
+ const pass = encoder.beginComputePass({ label: "image resolve" });
104
+
105
+ pass.setPipeline(this.pipeline);
106
+ pass.setBindGroup(0, this.device.createBindGroup({
107
+ layout: this.pipeline.getBindGroupLayout(0),
108
+ entries: [
109
+ { binding: 0, resource: { buffer: this.uniforms } },
110
+ { binding: 1, resource: { buffer: accumulation } },
111
+ { binding: 2, resource: { buffer: denoised ?? accumulation } },
112
+ { binding: 3, resource: { buffer: output } },
113
+ ],
114
+ }));
115
+ pass.dispatchWorkgroups(Math.ceil(width / WORKGROUP), Math.ceil(height / WORKGROUP));
116
+ pass.end();
117
+ encoder.copyBufferToBuffer(output, 0, staging, 0, bytes);
118
+ this.device.queue.submit([encoder.finish()]);
119
+
120
+ await staging.mapAsync(GPUMapMode.READ);
121
+
122
+ const data = new Uint8ClampedArray(staging.getMappedRange().slice(0));
123
+
124
+ staging.unmap();
125
+
126
+ return data;
127
+ } finally {
128
+ output.destroy();
129
+ staging.destroy();
130
+ }
131
+ }
132
+
133
+ dispose() {
134
+ this.uniforms.destroy();
135
+ }
136
+ }
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
- /** Words to append to the texture buffer: a spot light's map index and the matrix that projects it. */
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
@@ -1,16 +1,16 @@
1
1
  import { PerspectiveCamera, type LightProbe, type Material, type Object3D, type Scene } from "three";
2
2
  import { Accumulator } from "./accumulator.js";
3
- import { type PackedBvh } from "./bvh.js";
4
3
  import { BvhBuilder, createDefaultBvhWorker, type BvhWorkerFactory } from "./bvhBuilder.js";
5
4
  import { cameraMatrices, type CameraMatrices } from "./camera.js";
6
5
  import { requestRenderDevice, type RenderDevice } from "./device.js";
7
6
  import { loadEnvironment, type PackedEnvironment } from "./environment.js";
8
7
  import { DispatchScheduler } from "./dispatchScheduler.js";
9
- import { packGeometry, type PackedGeometry } from "./geometry.js";
8
+ import { packGeometry } from "./geometry.js";
10
9
  import { packLights } from "./lights.js";
11
10
  import { NO_PROGRAM } from "./materialLayout.js";
12
- import { NO_TEXTURE, planTextures, writeTextures, type PackedTextures } from "./texturePack.js";
11
+ import { NO_TEXTURE, planTextures, textureEntry, writeTextures } from "./texturePack.js";
13
12
  import { PathTracePass, type FrameUniforms } from "./pathTracePass.js";
13
+ import { ImageResolvePass } from "./imageResolve.js";
14
14
  import { PresentPass } from "./presentPass.js";
15
15
  import {
16
16
  CONVERGED_ERROR,
@@ -27,7 +27,6 @@ import { environmentPower, uploadScene, type SceneBuffers } from "./sceneUpload.
27
27
  import {
28
28
  DEFAULT_RENDER_ENVIRONMENT,
29
29
  DEFAULT_RENDER_SETTINGS,
30
- encodeColor,
31
30
  EventEmitter,
32
31
  mergeSettings,
33
32
  serializeCamera,
@@ -131,6 +130,31 @@ const yieldToPage = () => new Promise<void>(resolve => setTimeout(resolve, 0));
131
130
  */
132
131
  const SHOW_EVERY_MS = 16;
133
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
+
134
158
  const EMPTY_SCENE: SerializedScene = { meshes: [], lights: [], textures: [] };
135
159
 
136
160
  /** Paths every pixel takes before adaptive sampling may stop it: below this its variance says too little. */
@@ -161,8 +185,18 @@ function rowRanges(rows: readonly number[]): [number, number][] {
161
185
  const CAPTURE_SAMPLES = 32;
162
186
 
163
187
  /** A scene on the device, with where its extra words went. */
188
+ /**
189
+ * What is kept of a packed scene once it is on the device: whether it follows the camera, and where each texture's
190
+ * texels start, for the captures that write into them. The packed arrays themselves, hundreds of megabytes for a large
191
+ * scene, are let go as soon as they are uploaded.
192
+ */
193
+ interface PackedSummary {
194
+ cameraDependent: boolean;
195
+ textureOffsets: ReadonlyMap<string, number>;
196
+ }
197
+
164
198
  interface PackedTarget {
165
- packed: { geometry: PackedGeometry; bvh: PackedBvh; textures: PackedTextures };
199
+ packed: PackedSummary;
166
200
  buffers: SceneBuffers;
167
201
  skyData: number;
168
202
  volumetricData: number;
@@ -201,7 +235,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
201
235
  private pathTrace: PathTracePass | null = null;
202
236
  private present: PresentPass | null = null;
203
237
  private buffers: SceneBuffers | null = null;
204
- private packed: { geometry: PackedGeometry; bvh: PackedBvh; textures: PackedTextures } | null = null;
238
+ private packed: PackedSummary | null = null;
239
+ private imageResolve: ImageResolvePass | null = null;
205
240
  private sceneDirty = true;
206
241
  // Compute bakes, the procedural sky and translated shaders: prepared once per scene.
207
242
  private assets: Promise<SceneAssets> | null = null;
@@ -242,7 +277,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
242
277
  this.current = mergeSettings(DEFAULT_RENDER_SETTINGS, options.settings);
243
278
  this.denoiserSettings = mergeDenoiser(DEFAULT_DENOISER, options.denoiser);
244
279
  this.environment = DEFAULT_RENDER_ENVIRONMENT;
245
- this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight });
280
+ this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight, maxSamples: BATCH_PATHS });
246
281
  this.setEnvironment(options.environment ?? DEFAULT_RENDER_ENVIRONMENT);
247
282
 
248
283
  if (options.scene) this.setScene(options.scene);
@@ -472,6 +507,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
472
507
  this.buffers?.destroy();
473
508
  this.accumulator?.destroy();
474
509
  this.denoisePass?.destroy();
510
+ this.imageResolve?.dispose();
511
+ this.imageResolve = null;
475
512
  this.gpu?.device.destroy();
476
513
  this.buffers = null;
477
514
  this.accumulator = null;
@@ -537,29 +574,57 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
537
574
  this.emit("start", { width, height });
538
575
 
539
576
  const target = Math.max(1, Math.round(this.current.samples));
540
- // Adaptive sampling: the frame has the work of `target` passes over the whole image to spend. Pixels that have
577
+ // Adaptive sampling: the frame has the work of `target` paths over the whole image to spend. Pixels that have
541
578
  // converged stop taking samples, and a row with none left stops being dispatched, so what they would have used goes
542
579
  // to the rows that still need it, up to a few times `target` paths in the noisiest pixels.
543
580
  const adaptive = target > ADAPTIVE_MIN;
544
- const maxPasses = adaptive ? target * ADAPTIVE_REACH : target;
581
+ const maxPaths = adaptive ? target * ADAPTIVE_REACH : target;
582
+ // In rows times paths, the unit the scheduler measures work in.
545
583
  const budget = target * height;
546
584
  const accumulator = this.accumulator as Accumulator;
547
585
  let active: number[] = Array.from({ length: height }, (_, row) => row);
548
- let spent = 0, band = 0, pass = 0;
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;
549
589
  let shown = performance.now();
590
+ let denoisedAt = -Infinity;
550
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
+ };
551
614
 
552
615
  this.denoised = false;
553
616
  this.finished = null;
554
617
 
555
618
  const matrices = cameraMatrices(camera, width, height);
619
+ const uniforms = this.frameUniforms(camera, width, height, assets);
556
620
 
557
621
  // Two over the projection's vertical scale and the height: one pixel's extent, per unit of depth in perspective.
558
622
  this.denoiseView = { pixelScale: 2 / (matrices.projection[5] * height), orthographic: matrices.orthographic };
559
623
 
560
- while (pass < maxPasses && spent < budget && active.length) {
624
+ while (taken < maxPaths && spent < budget && active.length) {
561
625
  // Only a pass whose pixels may stop needs its rows flagged; before that every row is busy anyway.
562
- const flagging = adaptive && pass >= ADAPTIVE_MIN;
626
+ const flagging = adaptive && taken >= ADAPTIVE_MIN;
627
+ const samples = pathsPerDispatch(taken, adaptive ? ADAPTIVE_MIN : target, adaptive);
563
628
 
564
629
  if (flagging) {
565
630
  const clear = gpu.device.createCommandEncoder();
@@ -576,17 +641,17 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
576
641
 
577
642
  this.check(revision);
578
643
 
579
- const rows = this.scheduler.next(end - row);
644
+ const rows = this.scheduler.next(end - row, samples);
580
645
  const started = performance.now();
581
646
  const encoder = gpu.device.createCommandEncoder();
582
647
 
583
648
  this.emit("bucket", { worker: 0, bucket: { x: 0, y: row, width, height: rows, index: band++ }, width, height });
584
649
  this.pathTrace?.encode(encoder, {
585
- ...this.frameUniforms(camera, width, height, assets),
650
+ ...uniforms,
586
651
  bandOrigin: [0, row],
587
652
  bandSize: [width, rows],
588
- sampleIndex: pass,
589
- sampleCount: 1,
653
+ sampleIndex: taken,
654
+ sampleCount: samples,
590
655
  adaptiveMin: adaptive ? ADAPTIVE_MIN : 0,
591
656
  adaptiveThreshold: ADAPTIVE_THRESHOLD,
592
657
  });
@@ -596,11 +661,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
596
661
  if (show) this.encodePresent(encoder, width, height);
597
662
 
598
663
  gpu.device.queue.submit([encoder.finish()]);
599
- await gpu.device.queue.onSubmittedWorkDone();
664
+ flight.push({ done: gpu.device.queue.onSubmittedWorkDone(), submitted: performance.now(), rows, samples });
665
+
666
+ while (flight.length >= BANDS_IN_FLIGHT) await settle();
600
667
 
601
- this.scheduler.record(performance.now() - started, rows);
602
668
  row += rows;
603
- spent += rows;
669
+ spent += rows * samples;
604
670
  this.emit("progress", { completed: Math.min(spent, budget), total: budget, phase: "shade" });
605
671
 
606
672
  if (show) {
@@ -610,24 +676,35 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
610
676
  }
611
677
  }
612
678
 
613
- pass++;
679
+ taken += samples;
614
680
  this.frameIndex++;
615
681
 
616
- // The canvas shows the denoised image from the end of each pass on, and the bands of the next pass refine
617
- // underneath it until it is denoised again.
618
- if (denoise && this.canvas && this.context) {
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) {
619
689
  const encoder = gpu.device.createCommandEncoder();
620
690
 
621
691
  this.encodeDenoise(gpu, encoder, width, height);
622
692
  this.encodePresent(encoder, width, height);
623
693
  gpu.device.queue.submit([encoder.finish()]);
694
+ denoisedAt = performance.now();
624
695
  }
625
696
 
626
697
  this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
627
698
 
628
- if (flagging) active = await this.activeRows(gpu, accumulator);
699
+ if (rowsRead) {
700
+ while (flight.length) await settle();
701
+
702
+ active = await rowsRead;
703
+ }
629
704
  }
630
705
 
706
+ while (flight.length) await settle();
707
+
631
708
  // Converged early, or spent: either way the image is as finished as this frame makes it.
632
709
  this.emit("progress", { completed: budget, total: budget, phase: "shade" });
633
710
  this.emit("sample", { samples: target, target });
@@ -698,17 +775,22 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
698
775
  const environment = assets.sky?.environment ?? image;
699
776
  const matrices = cameraMatrices(camera, width, height);
700
777
  const view = `${JSON.stringify(camera)}|${width}|${height}`;
701
- const moved = (!!this.packed?.geometry.cameraDependent || this.planar) && view !== this.packedView;
778
+ const moved = (!!this.packed?.cameraDependent || this.planar) && view !== this.packedView;
702
779
 
703
780
  if (this.sceneDirty || !this.buffers || moved) {
704
781
  // Planar surfaces need their captures, from this camera, before the scene that samples them is packed.
705
782
  const planar = planarCaptures(assets.scene, camera, width, height);
706
783
 
784
+ // The scene on the device is replaced: letting it go first keeps the two from being held at once.
785
+ this.buffers?.destroy();
786
+ this.buffers = null;
787
+ this.target = null;
788
+ this.packed = null;
789
+
707
790
  const packed = await this.packScene(
708
791
  gpu, revision, planar?.scene ?? assets.scene, assets, matrices, width, height, environment);
709
792
 
710
793
  this.packed = packed.packed;
711
- this.buffers?.destroy();
712
794
  this.buffers = packed.buffers;
713
795
  this.target = packed;
714
796
  this.sceneDirty = false;
@@ -724,7 +806,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
724
806
 
725
807
  this.accumulator ??= new Accumulator(gpu.device, width, height);
726
808
 
727
- if (resized) this.scheduler = new DispatchScheduler({ maxRows: height });
809
+ if (resized) this.scheduler = new DispatchScheduler({ maxRows: height, maxSamples: BATCH_PATHS });
728
810
 
729
811
  this.pathTrace.setBindings(this.buffers, this.accumulator.buffer);
730
812
  this.configureCanvas(gpu, width, height);
@@ -802,8 +884,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
802
884
  // Nothing is on the device yet, so a frame replaced while the hierarchy was built has nothing to give back.
803
885
  this.check(revision);
804
886
 
805
- const packed = { geometry, bvh, textures };
806
- const buffers = uploadScene(gpu.device, geometry, packed.bvh, textures, lights, environment, cdf);
887
+ const buffers = uploadScene(gpu.device, geometry, bvh, textures, lights, environment, cdf);
888
+
889
+ const textureOffsets = new Map([...textures.indexOf]
890
+ .map(([id, index]) => [id, textures.data[textureEntry(index) + 2]]));
891
+
892
+ const packed: PackedSummary = { cameraDependent: geometry.cameraDependent, textureOffsets };
807
893
 
808
894
  if (buffers.bytes.largest > gpu.maxStorageBufferBindingSize) {
809
895
  buffers.destroy();
@@ -869,9 +955,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
869
955
  }
870
956
 
871
957
  const offset = (id: string | undefined) => {
872
- const index = id === undefined ? undefined : main.packed.textures.indexOf.get(id);
873
-
874
- return index === undefined ? NO_TEXTURE : main.packed.textures.data[index * 16 + 2];
958
+ return (id === undefined ? undefined : main.packed.textureOffsets.get(id)) ?? NO_TEXTURE;
875
959
  };
876
960
 
877
961
  const encoder = gpu.device.createCommandEncoder();
@@ -988,12 +1072,15 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
988
1072
  triangleCount: buffers?.triangleCount ?? 0,
989
1073
  lightCount: buffers?.lightCount ?? 0,
990
1074
  areaLightCount: buffers?.areaLightCount ?? 0,
1075
+ lightCdf: buffers?.lightCdf ?? 0,
1076
+ lightTriangles: buffers?.lightTriangles ?? 0,
991
1077
  clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
992
1078
  // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
993
1079
  shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
994
1080
  shadowsEnabled: shadows.enabled,
995
1081
  uvOffset: buffers?.uvOffset ?? 0,
996
1082
  orderOffset: buffers?.orderOffset ?? 0,
1083
+ leafOffset: buffers?.leafOffset ?? 0,
997
1084
  colorOffset: buffers?.colorOffset ?? 0,
998
1085
  hasColors: buffers?.hasColors ?? false,
999
1086
  varyingOffset: buffers?.varyingOffset ?? 0,
@@ -1021,55 +1108,14 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1021
1108
  private async readImage(
1022
1109
  gpu: RenderDevice, width: number, height: number, denoised: GPUBuffer | null): Promise<RenderImage> {
1023
1110
  const accumulator = this.accumulator as Accumulator;
1024
- // The radiance and the coverage; the guides and the row flags after them are not part of the image.
1025
- const length = width * height * 32;
1026
-
1027
- const read = async(buffer: GPUBuffer, bytes: number) => {
1028
- const usage = GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST;
1029
- const staging = gpu.device.createBuffer({ size: bytes, usage });
1030
- const encoder = gpu.device.createCommandEncoder();
1031
-
1032
- encoder.copyBufferToBuffer(buffer, 0, staging, 0, bytes);
1033
- gpu.device.queue.submit([encoder.finish()]);
1034
- await staging.mapAsync(GPUMapMode.READ);
1035
-
1036
- const floats = new Float32Array(staging.getMappedRange().slice(0));
1037
-
1038
- staging.unmap();
1039
- staging.destroy();
1040
-
1041
- return floats;
1042
- };
1043
-
1044
- const floats = await read(accumulator.buffer, length);
1045
- // The denoiser's output is the mean radiance itself, one vec4 per pixel.
1046
- const filtered = denoised ? await read(denoised, width * height * 16) : null;
1047
-
1048
- const data = new Uint8ClampedArray(width * height * 4);
1049
1111
  const { exposure, toneMapping, vignette } = this.current;
1050
1112
  const transparent = this.current.background.mode === "transparent";
1051
- const coverage = width * height * 4;
1052
-
1053
- for (let pixel = 0; pixel < width * height; pixel++) {
1054
- const samples = floats[pixel * 4 + 3];
1055
- const x = (pixel % width) / width - 0.5, y = Math.floor(pixel / width) / height - 0.5;
1056
- const falloff = Math.max(0, 1 - vignette * (x * x + y * y) * 2);
1057
-
1058
- // The radiance is premultiplied by the coverage, which a transparent background makes the alpha.
1059
- const alpha = !transparent ? 1
1060
- : samples > 0 ? Math.min(1, Math.max(0, floats[coverage + pixel * 4] / samples)) : 0;
1061
1113
 
1062
- const mean = (channel: number) =>
1063
- (filtered ? filtered[pixel * 4 + channel] : floats[pixel * 4 + channel] / samples);
1114
+ // Resolved on the device, so the page only copies the bytes of the image.
1115
+ this.imageResolve ??= new ImageResolvePass(gpu.device);
1064
1116
 
1065
- const color = samples > 0 && alpha > 0
1066
- ? [0, 1, 2].map(channel => mean(channel) / alpha * falloff)
1067
- : [0, 0, 0];
1068
-
1069
- const [r, g, b] = encodeColor(color, exposure, toneMapping);
1070
-
1071
- data.set([r, g, b, Math.round(alpha * 255)], pixel * 4);
1072
- }
1117
+ const data = await this.imageResolve.read(accumulator.buffer, denoised, width, height,
1118
+ { exposure, toneMapping, vignette, transparent });
1073
1119
 
1074
1120
  return { width, height, data };
1075
1121
  }