@tinypoly/gpu-renderer 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/ARCHITECTURE.md +81 -10
  2. package/README.md +53 -5
  3. package/dist/atmosphere.d.ts +21 -0
  4. package/dist/atmosphere.d.ts.map +1 -0
  5. package/dist/atmosphere.js +72 -0
  6. package/dist/atmosphere.js.map +1 -0
  7. package/dist/bvh.d.ts +11 -6
  8. package/dist/bvh.d.ts.map +1 -1
  9. package/dist/bvh.js +64 -11
  10. package/dist/bvh.js.map +1 -1
  11. package/dist/denoise.d.ts +17 -2
  12. package/dist/denoise.d.ts.map +1 -1
  13. package/dist/denoise.js +95 -27
  14. package/dist/denoise.js.map +1 -1
  15. package/dist/dispatchScheduler.d.ts +11 -4
  16. package/dist/dispatchScheduler.d.ts.map +1 -1
  17. package/dist/dispatchScheduler.js +14 -7
  18. package/dist/dispatchScheduler.js.map +1 -1
  19. package/dist/frameLayout.d.ts.map +1 -1
  20. package/dist/frameLayout.js +18 -0
  21. package/dist/frameLayout.js.map +1 -1
  22. package/dist/geometry.d.ts.map +1 -1
  23. package/dist/geometry.js +6 -3
  24. package/dist/geometry.js.map +1 -1
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/lensPass.d.ts +60 -0
  30. package/dist/lensPass.d.ts.map +1 -0
  31. package/dist/lensPass.js +235 -0
  32. package/dist/lensPass.js.map +1 -0
  33. package/dist/lightTree.d.ts +43 -0
  34. package/dist/lightTree.d.ts.map +1 -0
  35. package/dist/lightTree.js +107 -0
  36. package/dist/lightTree.js.map +1 -0
  37. package/dist/lights.d.ts +19 -1
  38. package/dist/lights.d.ts.map +1 -1
  39. package/dist/lights.js +63 -1
  40. package/dist/lights.js.map +1 -1
  41. package/dist/materialLayout.d.ts +24 -0
  42. package/dist/materialLayout.d.ts.map +1 -1
  43. package/dist/materialLayout.js +36 -1
  44. package/dist/materialLayout.js.map +1 -1
  45. package/dist/pathTracePass.d.ts +6 -3
  46. package/dist/pathTracePass.d.ts.map +1 -1
  47. package/dist/pathTracePass.js +16 -10
  48. package/dist/pathTracePass.js.map +1 -1
  49. package/dist/renderer.d.ts +19 -7
  50. package/dist/renderer.d.ts.map +1 -1
  51. package/dist/renderer.js +198 -36
  52. package/dist/renderer.js.map +1 -1
  53. package/dist/sceneUpload.d.ts +19 -1
  54. package/dist/sceneUpload.d.ts.map +1 -1
  55. package/dist/sceneUpload.js +25 -3
  56. package/dist/sceneUpload.js.map +1 -1
  57. package/dist/settings.d.ts +136 -0
  58. package/dist/settings.d.ts.map +1 -0
  59. package/dist/settings.js +100 -0
  60. package/dist/settings.js.map +1 -0
  61. package/dist/subsurface.d.ts +76 -0
  62. package/dist/subsurface.d.ts.map +1 -0
  63. package/dist/subsurface.js +70 -0
  64. package/dist/subsurface.js.map +1 -0
  65. package/dist/texturePack.d.ts +16 -4
  66. package/dist/texturePack.d.ts.map +1 -1
  67. package/dist/texturePack.js +61 -8
  68. package/dist/texturePack.js.map +1 -1
  69. package/dist/volumeContainment.d.ts +17 -0
  70. package/dist/volumeContainment.d.ts.map +1 -0
  71. package/dist/volumeContainment.js +61 -0
  72. package/dist/volumeContainment.js.map +1 -0
  73. package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
  74. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
  75. package/dist/wgsl/bsdf.wgsl.js +35 -3
  76. package/dist/wgsl/bsdf.wgsl.js.map +1 -1
  77. package/dist/wgsl/bvh.wgsl.d.ts +8 -2
  78. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
  79. package/dist/wgsl/bvh.wgsl.js +167 -96
  80. package/dist/wgsl/bvh.wgsl.js.map +1 -1
  81. package/dist/wgsl/common.wgsl.d.ts.map +1 -1
  82. package/dist/wgsl/common.wgsl.js +24 -7
  83. package/dist/wgsl/common.wgsl.js.map +1 -1
  84. package/dist/wgsl/lens.wgsl.d.ts +17 -0
  85. package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
  86. package/dist/wgsl/lens.wgsl.js +315 -0
  87. package/dist/wgsl/lens.wgsl.js.map +1 -0
  88. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  89. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  90. package/dist/wgsl/lights.wgsl.js +206 -33
  91. package/dist/wgsl/lights.wgsl.js.map +1 -1
  92. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  93. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  94. package/dist/wgsl/pathTrace.wgsl.js +410 -25
  95. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  96. package/dist/wgsl/random.wgsl.d.ts +1 -1
  97. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  98. package/dist/wgsl/random.wgsl.js +9 -17
  99. package/dist/wgsl/random.wgsl.js.map +1 -1
  100. package/dist/wgsl/sampling.wgsl.d.ts +1 -1
  101. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
  102. package/dist/wgsl/sampling.wgsl.js +25 -0
  103. package/dist/wgsl/sampling.wgsl.js.map +1 -1
  104. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  105. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  106. package/dist/wgsl/surface.wgsl.js +47 -4
  107. package/dist/wgsl/surface.wgsl.js.map +1 -1
  108. package/dist/wgsl/texture.wgsl.d.ts +1 -1
  109. package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
  110. package/dist/wgsl/texture.wgsl.js +10 -11
  111. package/dist/wgsl/texture.wgsl.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/atmosphere.ts +95 -0
  114. package/src/bvh.ts +81 -12
  115. package/src/denoise.ts +110 -27
  116. package/src/dispatchScheduler.ts +16 -7
  117. package/src/frameLayout.ts +18 -0
  118. package/src/geometry.ts +6 -2
  119. package/src/index.ts +13 -0
  120. package/src/lensPass.ts +299 -0
  121. package/src/lightTree.ts +172 -0
  122. package/src/lights.ts +90 -2
  123. package/src/materialLayout.ts +52 -1
  124. package/src/pathTracePass.ts +19 -10
  125. package/src/renderer.ts +258 -43
  126. package/src/sceneUpload.ts +41 -4
  127. package/src/settings.ts +234 -0
  128. package/src/subsurface.ts +153 -0
  129. package/src/texturePack.ts +80 -13
  130. package/src/volumeContainment.ts +81 -0
  131. package/src/wgsl/bsdf.wgsl.ts +35 -3
  132. package/src/wgsl/bvh.wgsl.ts +167 -96
  133. package/src/wgsl/common.wgsl.ts +24 -7
  134. package/src/wgsl/lens.wgsl.ts +314 -0
  135. package/src/wgsl/lights.wgsl.ts +206 -33
  136. package/src/wgsl/pathTrace.wgsl.ts +410 -25
  137. package/src/wgsl/random.wgsl.ts +9 -17
  138. package/src/wgsl/sampling.wgsl.ts +25 -0
  139. package/src/wgsl/surface.wgsl.ts +47 -4
  140. package/src/wgsl/texture.wgsl.ts +10 -11
package/src/renderer.ts CHANGED
@@ -8,10 +8,30 @@ 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";
15
+ import { LensPass, lensEffects } from "./lensPass.js";
16
+ import {
17
+ DEFAULT_GPU_RENDER_SETTINGS,
18
+ effectiveExposure,
19
+ mergeGpuSettings,
20
+ type GpuRenderSettings,
21
+ type GpuRenderSettingsInput,
22
+ } from "./settings.js";
23
+ import {
24
+ bevelOf,
25
+ bevelRadius,
26
+ type BevelMaterial,
27
+ subsurfaceMedium,
28
+ subsurfaceOf,
29
+ volumeMedium,
30
+ volumeOf,
31
+ type SubsurfaceMaterial,
32
+ type VolumeMaterial,
33
+ } from "./subsurface.js";
34
+ import { containingVolume, NO_MATERIAL, volumeTriangles, type VolumeTriangles } from "./volumeContainment.js";
15
35
  import {
16
36
  CONVERGED_ERROR,
17
37
  DEFAULT_DENOISER,
@@ -24,11 +44,10 @@ import { CaptureResolvePass, planarCaptures, type PlanarCaptures } from "./plana
24
44
  import { prepareSceneAssets, type SceneAssets } from "./sceneAssets.js";
25
45
  import { packVolumetricLights, VOLUMETRIC_WORDS } from "./volumetric.js";
26
46
  import { environmentPower, uploadScene, type SceneBuffers } from "./sceneUpload.js";
47
+ import { atmosphereExtinction, averageRadiance, brightestSun, gradientAverage } from "./atmosphere.js";
27
48
  import {
28
49
  DEFAULT_RENDER_ENVIRONMENT,
29
- DEFAULT_RENDER_SETTINGS,
30
50
  EventEmitter,
31
- mergeSettings,
32
51
  serializeCamera,
33
52
  serializeScene,
34
53
  srgbToLinear,
@@ -41,8 +60,6 @@ import {
41
60
  type RenderPhase,
42
61
  type RenderPixels,
43
62
  type RenderScene,
44
- type RenderSettings,
45
- type RenderSettingsInput,
46
63
  type SerializedScene,
47
64
  } from "./upstream.js";
48
65
 
@@ -74,8 +91,11 @@ export interface GpuRendererOptions {
74
91
  scene?: RenderScene | SerializedScene;
75
92
  /** Defaults to a perspective camera at (5, 5, 5) looking at the origin. */
76
93
  camera?: RenderCamera;
77
- /** Merged over `DEFAULT_RENDER_SETTINGS`, group by group. */
78
- settings?: RenderSettingsInput;
94
+ /**
95
+ * Merged over `DEFAULT_GPU_RENDER_SETTINGS`, group by group: the CPU renderer's settings, and the groups only this
96
+ * renderer reads (`bloom`, `lens`, `camera`, `subsurface`, the roulette under `globalIllumination`).
97
+ */
98
+ settings?: GpuRenderSettingsInput;
79
99
  /** Defaults to `DEFAULT_RENDER_ENVIRONMENT`. */
80
100
  environment?: RenderEnvironment;
81
101
  /** Image size in CSS pixels; the render size also applies `pixelRatio` and `settings.renderScale`. */
@@ -130,6 +150,34 @@ const yieldToPage = () => new Promise<void>(resolve => setTimeout(resolve, 0));
130
150
  */
131
151
  const SHOW_EVERY_MS = 16;
132
152
 
153
+ /**
154
+ * Submits on the device at once. Two keep it busy: the next band is queued while the last one runs, and each submit
155
+ * still stays short enough for the watchdog on its own.
156
+ */
157
+ const BANDS_IN_FLIGHT = 2;
158
+
159
+ /** Throughput under which Russian roulette may end a path, with the chance of carrying on equal to the throughput. */
160
+ const ROULETTE_THRESHOLD = 0.2;
161
+
162
+ /** How often the canvas takes a freshly denoised image while a frame traces. */
163
+ const DENOISE_EVERY_MS = 200;
164
+
165
+ /** The most paths per pixel one dispatch takes, while no pixel can stop yet. */
166
+ const BATCH_PATHS = 4;
167
+
168
+ /**
169
+ * Paths per pixel the next pass takes. The first takes one, so the canvas shows something at once. Until adaptive
170
+ * sampling may stop a pixel, every pixel takes every path anyway, so a pass takes several and pays for reading and
171
+ * writing the accumulation once; from then on one at a time, so a pixel stops as soon as it has converged.
172
+ */
173
+ function pathsPerDispatch(taken: number, until: number, adaptive: boolean): number {
174
+ if (taken === 0) return 1;
175
+
176
+ if (adaptive && taken >= until) return 1;
177
+
178
+ return Math.max(1, Math.min(BATCH_PATHS, until - taken));
179
+ }
180
+
133
181
  const EMPTY_SCENE: SerializedScene = { meshes: [], lights: [], textures: [] };
134
182
 
135
183
  /** Paths every pixel takes before adaptive sampling may stop it: below this its variance says too little. */
@@ -176,6 +224,8 @@ interface PackedTarget {
176
224
  skyData: number;
177
225
  volumetricData: number;
178
226
  volumetricCount: number;
227
+ /** The boundaries of the scene's volumes, which say whether the camera stands inside one. */
228
+ volumes: VolumeTriangles | null;
179
229
  }
180
230
 
181
231
  /**
@@ -183,7 +233,7 @@ interface PackedTarget {
183
233
  * `render()` opens a frame with whatever is current and accumulates samples until it reaches `settings.samples`.
184
234
  */
185
235
  export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
186
- private current: RenderSettings;
236
+ private current: GpuRenderSettings;
187
237
  private environment: RenderEnvironment;
188
238
  // Serializing walks the whole scene, so it is started by the setter and awaited by the frame that needs it.
189
239
  private scene: Promise<SerializedScene> = Promise.resolve(EMPTY_SCENE);
@@ -223,6 +273,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
223
273
  private planar = false;
224
274
  private resolve: CaptureResolvePass | null = null;
225
275
  private denoisePass: DenoisePass | null = null;
276
+ private lensPass: LensPass | null = null;
226
277
  private denoiserSettings: DenoiserSettings = { ...DEFAULT_DENOISER };
227
278
  // The size of the last frame that finished, which setDenoiser filters again; null while a frame traces.
228
279
  private finished: { width: number; height: number } | null = null;
@@ -232,6 +283,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
232
283
  private denoiseView: DenoiseView = { pixelScale: 1, orthographic: false };
233
284
  // Reading an environment image is a fetch, so it is started once and awaited by the frame that needs it.
234
285
  private environmentSource = "";
286
+ // The surroundings' radiance averaged over the sphere, which the air glows with; null for the gradient.
287
+ private environmentAverage: [number, number, number] | null = null;
235
288
  private environmentImage: Promise<PackedEnvironment | null> = Promise.resolve(null);
236
289
  private canvas: HTMLCanvasElement | null = null;
237
290
  private context: GPUCanvasContext | null = null;
@@ -249,10 +302,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
249
302
  this.width = Math.max(1, Math.floor(options.width));
250
303
  this.height = Math.max(1, Math.floor(options.height));
251
304
  this.pixelRatio = checkedPixelRatio(options.pixelRatio ?? 1);
252
- this.current = mergeSettings(DEFAULT_RENDER_SETTINGS, options.settings);
305
+ this.current = mergeGpuSettings(DEFAULT_GPU_RENDER_SETTINGS, options.settings);
253
306
  this.denoiserSettings = mergeDenoiser(DEFAULT_DENOISER, options.denoiser);
254
307
  this.environment = DEFAULT_RENDER_ENVIRONMENT;
255
- this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight });
308
+ this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight, maxSamples: BATCH_PATHS });
256
309
  this.setEnvironment(options.environment ?? DEFAULT_RENDER_ENVIRONMENT);
257
310
 
258
311
  if (options.scene) this.setScene(options.scene);
@@ -260,7 +313,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
260
313
  }
261
314
 
262
315
  /** The settings the next frame renders with. */
263
- get settings(): Readonly<RenderSettings> {
316
+ get settings(): Readonly<GpuRenderSettings> {
264
317
  return this.current;
265
318
  }
266
319
 
@@ -371,15 +424,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
371
424
  }
372
425
 
373
426
  /**
374
- * Merges the given fields into the current settings, group by group. Turning the denoiser on or off also filters the
375
- * finished image again at once, as `setDenoiser` does, since that needs no tracing.
427
+ * Merges the given fields into the current settings, group by group. Turning the denoiser on or off, or changing
428
+ * what is done to the light after it is traced (exposure, tone mapping, vignette, bloom, chromatic aberration, white
429
+ * balance), shows the finished image again at once, as `setDenoiser` does, since none of that needs tracing.
376
430
  */
377
- setSettings(settings: RenderSettingsInput) {
378
- const denoise = this.current.globalIllumination.denoise;
431
+ setSettings(settings: GpuRenderSettingsInput) {
432
+ const before = developing(this.current);
379
433
 
380
- this.current = mergeSettings(this.current, settings);
434
+ this.current = mergeGpuSettings(this.current, settings);
381
435
 
382
- if (this.current.globalIllumination.denoise !== denoise) this.refilter();
436
+ if (developing(this.current) !== before) this.refilter();
383
437
  }
384
438
 
385
439
  setEnvironment(environment: RenderEnvironment) {
@@ -482,6 +536,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
482
536
  this.buffers?.destroy();
483
537
  this.accumulator?.destroy();
484
538
  this.denoisePass?.destroy();
539
+ this.lensPass?.destroy();
540
+ this.lensPass = null;
485
541
  this.imageResolve?.dispose();
486
542
  this.imageResolve = null;
487
543
  this.gpu?.device.destroy();
@@ -549,29 +605,57 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
549
605
  this.emit("start", { width, height });
550
606
 
551
607
  const target = Math.max(1, Math.round(this.current.samples));
552
- // Adaptive sampling: the frame has the work of `target` passes over the whole image to spend. Pixels that have
608
+ // Adaptive sampling: the frame has the work of `target` paths over the whole image to spend. Pixels that have
553
609
  // converged stop taking samples, and a row with none left stops being dispatched, so what they would have used goes
554
610
  // to the rows that still need it, up to a few times `target` paths in the noisiest pixels.
555
611
  const adaptive = target > ADAPTIVE_MIN;
556
- const maxPasses = adaptive ? target * ADAPTIVE_REACH : target;
612
+ const maxPaths = adaptive ? target * ADAPTIVE_REACH : target;
613
+ // In rows times paths, the unit the scheduler measures work in.
557
614
  const budget = target * height;
558
615
  const accumulator = this.accumulator as Accumulator;
559
616
  let active: number[] = Array.from({ length: height }, (_, row) => row);
560
- let spent = 0, band = 0, pass = 0;
617
+ // Paths every pixel still in the running has taken so far, which is also the next sample index.
618
+ let taken = 0;
619
+ let spent = 0, band = 0;
561
620
  let shown = performance.now();
621
+ let denoisedAt = -Infinity;
562
622
  const denoise = this.current.globalIllumination.denoise;
623
+ // Bands on the device, oldest first. The next one is encoded and queued while the last one runs, so the device
624
+ // never waits for the page to hear that a band finished.
625
+ const flight: { done: Promise<unknown>; submitted: number; rows: number; samples: number }[] = [];
626
+ let settledAt = 0;
627
+
628
+ const settle = async() => {
629
+ const oldest = flight.shift();
630
+
631
+ if (!oldest) return;
632
+
633
+ await oldest.done;
634
+
635
+ const now = performance.now();
636
+
637
+ // A band starts on the device when the one before it ends, or when it was submitted if the device was idle. The
638
+ // page may hear of that end late, which would make this band look shorter than it ran; since it was submitted it
639
+ // has waited for at most the bands ahead of it, so its share of that wait bounds it from below.
640
+ const ran = Math.max(now - Math.max(oldest.submitted, settledAt), (now - oldest.submitted) / BANDS_IN_FLIGHT);
641
+
642
+ this.scheduler.record(ran, oldest.rows, oldest.samples);
643
+ settledAt = now;
644
+ };
563
645
 
564
646
  this.denoised = false;
565
647
  this.finished = null;
566
648
 
567
649
  const matrices = cameraMatrices(camera, width, height);
650
+ const uniforms = this.frameUniforms(camera, width, height, assets);
568
651
 
569
652
  // Two over the projection's vertical scale and the height: one pixel's extent, per unit of depth in perspective.
570
653
  this.denoiseView = { pixelScale: 2 / (matrices.projection[5] * height), orthographic: matrices.orthographic };
571
654
 
572
- while (pass < maxPasses && spent < budget && active.length) {
655
+ while (taken < maxPaths && spent < budget && active.length) {
573
656
  // Only a pass whose pixels may stop needs its rows flagged; before that every row is busy anyway.
574
- const flagging = adaptive && pass >= ADAPTIVE_MIN;
657
+ const flagging = adaptive && taken >= ADAPTIVE_MIN;
658
+ const samples = pathsPerDispatch(taken, adaptive ? ADAPTIVE_MIN : target, adaptive);
575
659
 
576
660
  if (flagging) {
577
661
  const clear = gpu.device.createCommandEncoder();
@@ -588,17 +672,17 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
588
672
 
589
673
  this.check(revision);
590
674
 
591
- const rows = this.scheduler.next(end - row);
675
+ const rows = this.scheduler.next(end - row, samples);
592
676
  const started = performance.now();
593
677
  const encoder = gpu.device.createCommandEncoder();
594
678
 
595
679
  this.emit("bucket", { worker: 0, bucket: { x: 0, y: row, width, height: rows, index: band++ }, width, height });
596
680
  this.pathTrace?.encode(encoder, {
597
- ...this.frameUniforms(camera, width, height, assets),
681
+ ...uniforms,
598
682
  bandOrigin: [0, row],
599
683
  bandSize: [width, rows],
600
- sampleIndex: pass,
601
- sampleCount: 1,
684
+ sampleIndex: taken,
685
+ sampleCount: samples,
602
686
  adaptiveMin: adaptive ? ADAPTIVE_MIN : 0,
603
687
  adaptiveThreshold: ADAPTIVE_THRESHOLD,
604
688
  });
@@ -608,11 +692,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
608
692
  if (show) this.encodePresent(encoder, width, height);
609
693
 
610
694
  gpu.device.queue.submit([encoder.finish()]);
611
- await gpu.device.queue.onSubmittedWorkDone();
695
+ flight.push({ done: gpu.device.queue.onSubmittedWorkDone(), submitted: performance.now(), rows, samples });
696
+
697
+ while (flight.length >= BANDS_IN_FLIGHT) await settle();
612
698
 
613
- this.scheduler.record(performance.now() - started, rows);
614
699
  row += rows;
615
- spent += rows;
700
+ spent += rows * samples;
616
701
  this.emit("progress", { completed: Math.min(spent, budget), total: budget, phase: "shade" });
617
702
 
618
703
  if (show) {
@@ -622,24 +707,35 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
622
707
  }
623
708
  }
624
709
 
625
- pass++;
710
+ taken += samples;
626
711
  this.frameIndex++;
627
712
 
628
- // The canvas shows the denoised image from the end of each pass on, and the bands of the next pass refine
629
- // underneath it until it is denoised again.
630
- if (denoise && this.canvas && this.context) {
713
+ // The rows this pass still traced in are copied out before anything else is queued, so they can be read while
714
+ // the device goes on with the denoiser.
715
+ const rowsRead = flagging ? this.activeRows(gpu, accumulator) : null;
716
+
717
+ // The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
718
+ // filtering the whole image after every pass would cost a cheap scene as much as tracing it.
719
+ if (denoise && this.canvas && this.context && performance.now() - denoisedAt >= DENOISE_EVERY_MS) {
631
720
  const encoder = gpu.device.createCommandEncoder();
632
721
 
633
722
  this.encodeDenoise(gpu, encoder, width, height);
634
723
  this.encodePresent(encoder, width, height);
635
724
  gpu.device.queue.submit([encoder.finish()]);
725
+ denoisedAt = performance.now();
636
726
  }
637
727
 
638
728
  this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
639
729
 
640
- if (flagging) active = await this.activeRows(gpu, accumulator);
730
+ if (rowsRead) {
731
+ while (flight.length) await settle();
732
+
733
+ active = await rowsRead;
734
+ }
641
735
  }
642
736
 
737
+ while (flight.length) await settle();
738
+
643
739
  // Converged early, or spent: either way the image is as finished as this frame makes it.
644
740
  this.emit("progress", { completed: budget, total: budget, phase: "shade" });
645
741
  this.emit("sample", { samples: target, target });
@@ -704,10 +800,20 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
704
800
  height: number,
705
801
  image: PackedEnvironment | null) {
706
802
  this.pathTrace ??= new PathTracePass(gpu.device);
707
- this.pathTrace.setPrograms(assets.programs);
803
+ const materials = assets.scene.meshes.flatMap(mesh => mesh.materials);
804
+
805
+ this.pathTrace.setPrograms(assets.programs,
806
+ this.current.subsurface.enabled && materials.some(material =>
807
+ (material.type === "MeshStandardMaterial" || material.type === "MeshPhysicalMaterial")
808
+ && subsurfaceOf(material) !== null),
809
+ // Even turned off, a volume's boundary has to stay invisible, which takes the same code.
810
+ materials.some(material => volumeOf(material) !== null),
811
+ this.current.bevel.enabled && materials.some(material => bevelOf(material) > 0));
708
812
 
709
813
  // A procedural sky stands in for the renderer's own environment, as it does in the CPU renderer.
710
814
  const environment = assets.sky?.environment ?? image;
815
+
816
+ this.environmentAverage = environment ? averageRadiance(environment) : null;
711
817
  const matrices = cameraMatrices(camera, width, height);
712
818
  const view = `${JSON.stringify(camera)}|${width}|${height}`;
713
819
  const moved = (!!this.packed?.cameraDependent || this.planar) && view !== this.packedView;
@@ -741,7 +847,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
741
847
 
742
848
  this.accumulator ??= new Accumulator(gpu.device, width, height);
743
849
 
744
- if (resized) this.scheduler = new DispatchScheduler({ maxRows: height });
850
+ if (resized) this.scheduler = new DispatchScheduler({ maxRows: height, maxSamples: BATCH_PATHS });
745
851
 
746
852
  this.pathTrace.setBindings(this.buffers, this.accumulator.buffer);
747
853
  this.configureCanvas(gpu, width, height);
@@ -820,7 +926,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
820
926
  this.check(revision);
821
927
 
822
928
  const buffers = uploadScene(gpu.device, geometry, bvh, textures, lights, environment, cdf);
823
- const textureOffsets = new Map([...textures.indexOf].map(([id, index]) => [id, textures.data[index * 16 + 2]]));
929
+
930
+ const textureOffsets = new Map([...textures.indexOf]
931
+ .map(([id, index]) => [id, textures.data[textureEntry(index) + 2]]));
932
+
824
933
  const packed: PackedSummary = { cameraDependent: geometry.cameraDependent, textureOffsets };
825
934
 
826
935
  if (buffers.bytes.largest > gpu.maxStorageBufferBindingSize) {
@@ -834,6 +943,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
834
943
  skyData,
835
944
  volumetricData,
836
945
  volumetricCount: Math.min(volumetric.length / VOLUMETRIC_WORDS, 8),
946
+ volumes: volumeTriangles(geometry),
837
947
  };
838
948
  }
839
949
 
@@ -872,6 +982,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
872
982
  const uniforms = {
873
983
  ...this.frameUniforms(capture.camera, width, height, assets, target, capture.background),
874
984
  lens: [0, 1, 0, 0],
985
+ lensDistortion: 0,
986
+ // A capture is a texture of the scene, seen again by the camera, which lays the air over it then.
987
+ atmosphereColor: [0, 0, 0, 0],
988
+ cameraVolume: NO_MATERIAL,
875
989
  volumetricCount: 0,
876
990
  clipPlane: capture.clip ?? [0, 0, 0, 0],
877
991
  bandOrigin: [0, 0],
@@ -925,21 +1039,37 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
925
1039
  }
926
1040
 
927
1041
  private encodePresent(encoder: GPUCommandEncoder, width: number, height: number) {
928
- if (!this.context || !this.present) return;
1042
+ if (!this.context || !this.present || !this.gpu) return;
929
1043
 
930
1044
  this.present.encode(encoder, this.context.getCurrentTexture().createView(), {
931
1045
  width,
932
1046
  height,
933
- exposure: this.current.exposure,
1047
+ exposure: effectiveExposure(this.current),
934
1048
  toneMapping: this.current.toneMapping,
935
1049
  vignette: this.current.vignette,
936
1050
  grain: this.current.grain,
937
1051
  frameIndex: this.frameIndex,
938
1052
  transparent: this.current.background.mode === "transparent",
939
- denoised: this.denoised ? this.denoisePass?.output : null,
1053
+ denoised: this.encodeLens(this.gpu, encoder, width, height,
1054
+ this.denoised ? this.denoisePass?.output ?? null : null),
940
1055
  });
941
1056
  }
942
1057
 
1058
+ /**
1059
+ * The lens and the sensor over the radiance (or the denoiser's output), when the settings ask for any of them: the
1060
+ * buffer of graded colour the display transform reads instead, or the input as it was.
1061
+ */
1062
+ private encodeLens(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number,
1063
+ denoised: GPUBuffer | null): GPUBuffer | null {
1064
+ const effects = lensEffects(this.current);
1065
+
1066
+ if (!effects) return denoised;
1067
+
1068
+ this.lensPass ??= new LensPass(gpu.device);
1069
+
1070
+ return this.lensPass.encode(encoder, (this.accumulator as Accumulator).buffer, denoised, width, height, effects);
1071
+ }
1072
+
943
1073
  /** Denoises the accumulation as it stands, for the canvas and for the finished image. */
944
1074
  private encodeDenoise(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number) {
945
1075
  this.denoisePass ??= new DenoisePass(gpu.device);
@@ -973,6 +1103,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
973
1103
  : 0;
974
1104
 
975
1105
  const backgroundMode = background.mode === "color" ? 1 : background.mode === "transparent" ? 2 : 0;
1106
+ const { atmosphere } = this.current;
1107
+ const strength = sky ? sky.intensity : this.environment.intensity ?? 1;
1108
+
1109
+ const airGlow = atmosphere.color
1110
+ ? linearColor(atmosphere.color, [0, 0, 0])
1111
+ : (this.environmentAverage ?? gradientAverage(linearColor(gradient?.topColor, [1, 1, 1]),
1112
+ linearColor(gradient?.bottomColor, [0, 0, 0]), gradient?.exponent ?? 2)).map(channel => channel * strength);
1113
+
1114
+ const extinction = atmosphereExtinction(atmosphere);
1115
+ const sun = brightestSun(assets.scene.lights);
976
1116
 
977
1117
  return {
978
1118
  cameraToWorld: matrices.cameraWorld,
@@ -1001,15 +1141,35 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1001
1141
  resolution: [width, height],
1002
1142
  cameraKind: matrices.orthographic ? 1 : 0,
1003
1143
  bounces: Math.max(1, Math.round(this.current.globalIllumination.bounces)),
1144
+ rouletteThreshold: this.current.globalIllumination.russianRoulette === false ? 0 : ROULETTE_THRESHOLD,
1145
+ rouletteStart: Math.max(0, Math.round(this.current.globalIllumination.rouletteStart ?? 0)),
1146
+ lensDistortion: Number.isFinite(this.current.lens.distortion) ? this.current.lens.distortion : 0,
1147
+ subsurfaceEnabled: this.current.subsurface.enabled,
1148
+ bevelEnabled: this.current.bevel.enabled,
1149
+ volumesEnabled: this.current.volumes.enabled,
1150
+ cameraVolume: this.current.volumes.enabled
1151
+ ? containingVolume(target?.volumes ?? null, matrices.cameraWorld.slice(12, 15))
1152
+ : NO_MATERIAL,
1153
+ atmosphereColor: [...airGlow, extinction.some(value => value > 0) ? 1 : 0],
1154
+ atmosphereExtinction: [...extinction, Math.min(1, Math.max(0, Number(atmosphere.haze) || 0))],
1155
+ atmosphereSun: [...sun?.direction ?? [0, 1, 0], 0],
1156
+ atmosphereSunColor: [...sun?.irradiance ?? [0, 0, 0], 0],
1004
1157
  triangleCount: buffers?.triangleCount ?? 0,
1005
1158
  lightCount: buffers?.lightCount ?? 0,
1006
1159
  areaLightCount: buffers?.areaLightCount ?? 0,
1160
+ lightCdf: buffers?.lightCdf ?? 0,
1161
+ lightTriangles: buffers?.lightTriangles ?? 0,
1162
+ localLightCount: buffers?.localLightCount ?? 0,
1163
+ localLightPick: buffers?.localLightPick ?? 0,
1164
+ lightTree: this.current.globalIllumination.lightTree === false ? 0 : buffers?.lightTree ?? 0,
1165
+ lightLeaves: buffers?.lightLeaves ?? 0,
1007
1166
  clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
1008
1167
  // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
1009
1168
  shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
1010
1169
  shadowsEnabled: shadows.enabled,
1011
1170
  uvOffset: buffers?.uvOffset ?? 0,
1012
1171
  orderOffset: buffers?.orderOffset ?? 0,
1172
+ leafOffset: buffers?.leafOffset ?? 0,
1013
1173
  colorOffset: buffers?.colorOffset ?? 0,
1014
1174
  hasColors: buffers?.hasColors ?? false,
1015
1175
  varyingOffset: buffers?.varyingOffset ?? 0,
@@ -1037,13 +1197,20 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1037
1197
  private async readImage(
1038
1198
  gpu: RenderDevice, width: number, height: number, denoised: GPUBuffer | null): Promise<RenderImage> {
1039
1199
  const accumulator = this.accumulator as Accumulator;
1040
- const { exposure, toneMapping, vignette } = this.current;
1200
+ const { toneMapping, vignette } = this.current;
1201
+ const exposure = effectiveExposure(this.current);
1041
1202
  const transparent = this.current.background.mode === "transparent";
1042
1203
 
1043
1204
  // Resolved on the device, so the page only copies the bytes of the image.
1044
1205
  this.imageResolve ??= new ImageResolvePass(gpu.device);
1045
1206
 
1046
- const data = await this.imageResolve.read(accumulator.buffer, denoised, width, height,
1207
+ // The lens's effects are encoded on their own, ahead of the resolve, which reads the colour they leave.
1208
+ const encoder = gpu.device.createCommandEncoder({ label: "lens" });
1209
+ const graded = this.encodeLens(gpu, encoder, width, height, denoised);
1210
+
1211
+ gpu.device.queue.submit([encoder.finish()]);
1212
+
1213
+ const data = await this.imageResolve.read(accumulator.buffer, graded, width, height,
1047
1214
  { exposure, toneMapping, vignette, transparent });
1048
1215
 
1049
1216
  return { width, height, data };
@@ -1064,6 +1231,9 @@ const SERIALIZABLE = new Set([
1064
1231
  */
1065
1232
  async function serializeLive(scene: Scene): Promise<SerializedScene> {
1066
1233
  const renamed = new Map<Material, string>();
1234
+ // The snapshot keeps a material's name but not its media, so the name carries a mark to them.
1235
+ const named = new Map<Material, string>();
1236
+ const media: { subsurface?: unknown; volume?: unknown; bevel?: unknown }[] = [];
1067
1237
 
1068
1238
  scene.traverse(object => {
1069
1239
  const value = (object as Object3D & { material?: Material | Material[] }).material;
@@ -1075,15 +1245,60 @@ async function serializeLive(scene: Scene): Promise<SerializedScene> {
1075
1245
  renamed.set(material, material.type);
1076
1246
  material.type = shader.isRawShaderMaterial ? "RawShaderMaterial" : "ShaderMaterial";
1077
1247
  }
1248
+
1249
+ const data = material.userData?.cpuRenderer as
1250
+ { subsurface?: unknown; volume?: unknown; bevel?: unknown } | undefined;
1251
+
1252
+ if (!named.has(material)
1253
+ && (subsurfaceMedium(data?.subsurface) || volumeMedium(data?.volume) || bevelRadius(data?.bevel) > 0)) {
1254
+ named.set(material, material.name);
1255
+ material.name = `${material.name}${MEDIA_MARK}${media.length}`;
1256
+ media.push({ subsurface: data?.subsurface, volume: data?.volume, bevel: data?.bevel });
1257
+ }
1078
1258
  }
1079
1259
  });
1080
1260
 
1261
+ let snapshot: SerializedScene;
1262
+
1081
1263
  try {
1082
1264
  // The path tracer samples only the base level of a texture: the mip chain would be built and thrown away.
1083
- return (await serializeScene(scene, { mipmaps: false })).scene;
1265
+ snapshot = (await serializeScene(scene, { mipmaps: false })).scene;
1084
1266
  } finally {
1085
1267
  for (const [material, type] of renamed) material.type = type;
1268
+ for (const [material, name] of named) material.name = name;
1269
+ }
1270
+
1271
+ // Materials are shared between the meshes that use them, so each one is unmarked once and seen marked no more.
1272
+ for (const mesh of snapshot.meshes) {
1273
+ for (const material of mesh.materials as (SubsurfaceMaterial & VolumeMaterial & BevelMaterial)[]) {
1274
+ const at = material.name.lastIndexOf(MEDIA_MARK);
1275
+
1276
+ if (at < 0) continue;
1277
+
1278
+ const medium = media[Number(material.name.slice(at + MEDIA_MARK.length))];
1279
+
1280
+ material.name = material.name.slice(0, at) || material.type;
1281
+ if (medium.subsurface !== undefined) material.subsurface = medium.subsurface as SubsurfaceMaterial["subsurface"];
1282
+ if (medium.volume !== undefined) material.volume = medium.volume as VolumeMaterial["volume"];
1283
+ if (medium.bevel !== undefined) material.bevel = medium.bevel as BevelMaterial["bevel"];
1284
+ }
1086
1285
  }
1286
+
1287
+ return snapshot;
1288
+ }
1289
+
1290
+ /** Ends a material's name while it is serialized, followed by the index of its media. */
1291
+ const MEDIA_MARK = "\u0000media:";
1292
+
1293
+ /**
1294
+ * Everything that changes the finished image without tracing it again, as one string: the denoiser switch and what
1295
+ * happens to the light between the radiance and the screen.
1296
+ */
1297
+ function developing(settings: GpuRenderSettings): string {
1298
+ const { exposure, toneMapping, vignette, bloom, glare, lens, camera, depthOfField, globalIllumination } = settings;
1299
+
1300
+ return JSON.stringify([globalIllumination.denoise, exposure, toneMapping, vignette, bloom, glare,
1301
+ lens.chromaticAberration, camera, camera.physicalExposure ? depthOfField.aperture : 0]);
1087
1302
  }
1088
1303
 
1089
1304
  /**
@@ -11,7 +11,7 @@ export interface SceneByteLength {
11
11
  materials: number;
12
12
  /** Vertex normals, the UV sets, vertex colours and shader varyings, in one buffer. */
13
13
  attributes: number;
14
- /** One material index per triangle and then the hierarchy's triangle order, in one buffer. */
14
+ /** One material index per triangle, then the hierarchy's triangle order and its inverse, in one buffer. */
15
15
  indices: number;
16
16
  nodes: number;
17
17
  lights: number;
@@ -37,11 +37,21 @@ export interface SceneBuffers {
37
37
  varyingOffset: number;
38
38
  hasColors: boolean;
39
39
  orderOffset: number;
40
+ /** Where each triangle's position in the leaf order starts inside `indices`. */
41
+ leafOffset: number;
40
42
  triangleCount: number;
41
43
  /** Everything the integrator can aim a shadow ray at. */
42
44
  lightCount: number;
43
45
  /** How many of those are emissive triangles, which lead the buffer. */
44
46
  areaLightCount: number;
47
+ /** Where the lights' cumulative probabilities and the emissive ones' triangles sit in `textures`. */
48
+ lightCdf: number;
49
+ lightTriangles: number;
50
+ /** The light tree over the lights that sit in the scene: see lightTree.ts. */
51
+ localLightCount: number;
52
+ localLightPick: number;
53
+ lightTree: number;
54
+ lightLeaves: number;
45
55
  /** Radiance an ambient or hemisphere light adds, which no shadow ray can reach. */
46
56
  ambient: PackedLights["ambient"];
47
57
  /** Where the surrounding image and its distributions ended up, for the frame uniform. */
@@ -76,7 +86,7 @@ export function sceneByteLength(
76
86
  const lengths = {
77
87
  positions: sized(geometry.positions.byteLength),
78
88
  attributes: sized(attributeBytes(geometry)),
79
- indices: sized(geometry.materials.byteLength + bvh.order.byteLength),
89
+ indices: sized(geometry.materials.byteLength + 2 * bvh.order.byteLength),
80
90
  materials: sized(Math.max(1, geometry.materialCount) * MATERIAL_WORDS * 4),
81
91
  nodes: sized(bvh.nodes.byteLength),
82
92
  lights: sized(lights.entries.byteLength),
@@ -110,6 +120,25 @@ export function environmentPower(environment: PackedEnvironment | null): number
110
120
  return environment ? environment.total * 2 * Math.PI ** 2 / (environment.width * environment.height) : 0;
111
121
  }
112
122
 
123
+ /**
124
+ * The positions in the hierarchy's leaf order, which is the order its traversal reads them in, so a leaf is one run of
125
+ * the buffer; and where each triangle went, for everything that reads a triangle by its own number.
126
+ */
127
+ export function leafOrder(
128
+ positions: Float32Array, order: Uint32Array): { positions: Float32Array; inverse: Uint32Array } {
129
+ const ordered = new Float32Array(order.length * 9);
130
+ const inverse = new Uint32Array(order.length);
131
+
132
+ for (let at = 0; at < order.length; at++) {
133
+ const triangle = order[at];
134
+
135
+ ordered.set(positions.subarray(triangle * 9, triangle * 9 + 9), at * 9);
136
+ inverse[triangle] = at;
137
+ }
138
+
139
+ return { positions: ordered, inverse };
140
+ }
141
+
113
142
  export function uploadScene(
114
143
  device: GPUDevice,
115
144
  geometry: PackedGeometry,
@@ -147,11 +176,12 @@ export function uploadScene(
147
176
 
148
177
  const colors = geometry.colors ?? new Float32Array(0);
149
178
  const varyings = geometry.varyings ?? new Float32Array(0);
179
+ const leaves = leafOrder(geometry.positions, bvh.order);
150
180
 
151
181
  const buffers = {
152
- positions: write(geometry.positions, lengths.positions),
182
+ positions: write(leaves.positions, lengths.positions),
153
183
  attributes: join([geometry.normals, geometry.uvs, colors, varyings], lengths.attributes),
154
- indices: join([geometry.materials, bvh.order], lengths.indices),
184
+ indices: join([geometry.materials, bvh.order, leaves.inverse], lengths.indices),
155
185
  materials: write(new Uint8Array(packMaterials(geometry)), lengths.materials),
156
186
  nodes: write(new Uint8Array(bvh.nodes), lengths.nodes),
157
187
  lights: write(new Uint8Array(lights.entries), lengths.lights),
@@ -166,9 +196,16 @@ export function uploadScene(
166
196
  varyingOffset: geometry.normals.length + geometry.uvs.length + colors.length,
167
197
  hasColors: colors.length > 0,
168
198
  orderOffset: geometry.materials.length,
199
+ leafOffset: geometry.materials.length + bvh.order.length,
169
200
  triangleCount: geometry.triangleCount,
170
201
  lightCount: lights.count,
171
202
  areaLightCount: lights.areaCount,
203
+ lightCdf: lights.cdfAt,
204
+ lightTriangles: lights.trianglesAt,
205
+ localLightCount: lights.localCount,
206
+ localLightPick: lights.localPick,
207
+ lightTree: lights.treeAt,
208
+ lightLeaves: lights.leavesAt,
172
209
  ambient: lights.ambient,
173
210
  environment: {
174
211
  texture: environment ? textures.indexOf.get(environment.texture.id) ?? NO_TEXTURE : NO_TEXTURE,