@tinypoly/gpu-renderer 0.1.0 → 0.2.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 (99) hide show
  1. package/ARCHITECTURE.md +31 -3
  2. package/README.md +13 -4
  3. package/dist/denoise.d.ts +10 -1
  4. package/dist/denoise.d.ts.map +1 -1
  5. package/dist/denoise.js +4 -0
  6. package/dist/denoise.js.map +1 -1
  7. package/dist/device.d.ts.map +1 -1
  8. package/dist/device.js +8 -0
  9. package/dist/device.js.map +1 -1
  10. package/dist/frameLayout.d.ts.map +1 -1
  11. package/dist/frameLayout.js +6 -1
  12. package/dist/frameLayout.js.map +1 -1
  13. package/dist/imageResolve.d.ts +2 -1
  14. package/dist/imageResolve.d.ts.map +1 -1
  15. package/dist/imageResolve.js +1 -0
  16. package/dist/imageResolve.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lensPass.d.ts +4 -0
  22. package/dist/lensPass.d.ts.map +1 -1
  23. package/dist/lensPass.js +14 -1
  24. package/dist/lensPass.js.map +1 -1
  25. package/dist/lightTree.d.ts +10 -3
  26. package/dist/lightTree.d.ts.map +1 -1
  27. package/dist/lightTree.js +56 -17
  28. package/dist/lightTree.js.map +1 -1
  29. package/dist/lights.d.ts +20 -0
  30. package/dist/lights.d.ts.map +1 -1
  31. package/dist/lights.js +48 -1
  32. package/dist/lights.js.map +1 -1
  33. package/dist/oidnPass.d.ts +48 -0
  34. package/dist/oidnPass.d.ts.map +1 -0
  35. package/dist/oidnPass.js +255 -0
  36. package/dist/oidnPass.js.map +1 -0
  37. package/dist/pathTracePass.d.ts +2 -2
  38. package/dist/pathTracePass.d.ts.map +1 -1
  39. package/dist/pathTracePass.js +9 -9
  40. package/dist/pathTracePass.js.map +1 -1
  41. package/dist/presentPass.d.ts +2 -0
  42. package/dist/presentPass.d.ts.map +1 -1
  43. package/dist/presentPass.js +1 -0
  44. package/dist/presentPass.js.map +1 -1
  45. package/dist/renderer.d.ts +20 -0
  46. package/dist/renderer.d.ts.map +1 -1
  47. package/dist/renderer.js +138 -19
  48. package/dist/renderer.js.map +1 -1
  49. package/dist/sceneUpload.d.ts +5 -0
  50. package/dist/sceneUpload.d.ts.map +1 -1
  51. package/dist/sceneUpload.js +4 -0
  52. package/dist/sceneUpload.js.map +1 -1
  53. package/dist/settings.d.ts +29 -1
  54. package/dist/settings.d.ts.map +1 -1
  55. package/dist/settings.js +15 -6
  56. package/dist/settings.js.map +1 -1
  57. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  58. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/lens.wgsl.js +110 -17
  60. package/dist/wgsl/lens.wgsl.js.map +1 -1
  61. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  62. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/lights.wgsl.js +197 -33
  64. package/dist/wgsl/lights.wgsl.js.map +1 -1
  65. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  66. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  67. package/dist/wgsl/pathTrace.wgsl.js +173 -131
  68. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  69. package/dist/wgsl/present.wgsl.d.ts +1 -1
  70. package/dist/wgsl/present.wgsl.d.ts.map +1 -1
  71. package/dist/wgsl/present.wgsl.js +11 -1
  72. package/dist/wgsl/present.wgsl.js.map +1 -1
  73. package/dist/wgsl/random.wgsl.d.ts +1 -1
  74. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  75. package/dist/wgsl/random.wgsl.js +28 -0
  76. package/dist/wgsl/random.wgsl.js.map +1 -1
  77. package/package.json +4 -2
  78. package/src/denoise.ts +15 -1
  79. package/src/device.ts +9 -0
  80. package/src/frameLayout.ts +6 -1
  81. package/src/imageResolve.ts +2 -0
  82. package/src/index.ts +2 -1
  83. package/src/lensPass.ts +17 -1
  84. package/src/lightTree.ts +67 -21
  85. package/src/lights.ts +65 -1
  86. package/src/oidnPass.ts +284 -0
  87. package/src/oidnWebgpu.d.ts +5 -0
  88. package/src/pathTracePass.ts +11 -9
  89. package/src/presentPass.ts +3 -0
  90. package/src/renderer.ts +161 -20
  91. package/src/sceneUpload.ts +9 -0
  92. package/src/settings.ts +40 -6
  93. package/src/wgsl/lens.wgsl.ts +110 -17
  94. package/src/wgsl/lights.wgsl.ts +197 -33
  95. package/src/wgsl/pathTrace.wgsl.ts +173 -131
  96. package/src/wgsl/present.wgsl.ts +11 -1
  97. package/src/wgsl/random.wgsl.ts +28 -0
  98. package/weights/LICENSE.txt +202 -0
  99. package/weights/rt_hdr_alb_nrm.tza +0 -0
@@ -24,11 +24,12 @@ export type FrameUniforms = Record<keyof typeof FRAME_LAYOUT.offsets & string, n
24
24
  * them.
25
25
  */
26
26
  export function pathTraceSource(
27
- programs: ShaderPrograms | null = null, subsurface = true, volumes = true, bevel = true): string {
27
+ programs: ShaderPrograms | null = null, subsurface = true, volumes = true, bevel = true,
28
+ portals = true): string {
28
29
  // The random walk inside materials and the media inside volumes are compiled in only for a scene that has them: the
29
30
  // kernel is bound by its size, and either would cost every other scene a few percent for code that never runs.
30
31
  return `const SUBSURFACE_WALK: bool = ${subsurface};\nconst VOLUMES: bool = ${volumes};\nconst BEVEL: bool = ${bevel};\n`
31
- + COMMON_WGSL + RANDOM_WGSL + BVH_WGSL + SAMPLING_WGSL + TEXTURE_WGSL + SHADER_SUPPORT_WGSL
32
+ + `const PORTALS: bool = ${portals};\n` + COMMON_WGSL + RANDOM_WGSL + BVH_WGSL + SAMPLING_WGSL + TEXTURE_WGSL + SHADER_SUPPORT_WGSL
32
33
  + programsWgsl(programs) + ENVIRONMENT_WGSL + SURFACE_WGSL + BSDF_WGSL + LIGHTS_WGSL + PATH_TRACE_WGSL;
33
34
  }
34
35
 
@@ -41,10 +42,10 @@ export class PathTracePass {
41
42
  private readonly uniforms: GPUBuffer;
42
43
  private readonly values = new ArrayBuffer(FRAME_BYTES);
43
44
  private bindGroup: GPUBindGroup | null = null;
44
- private programKey = "|false|false|false";
45
+ private programKey = "|false|false|false|false|false";
45
46
 
46
47
  constructor(private readonly device: GPUDevice) {
47
- this.pipeline = this.build(null, false, false, false);
48
+ this.pipeline = this.build(null, false, false, false, false);
48
49
  this.uniforms = device.createBuffer({ size: FRAME_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
49
50
  }
50
51
 
@@ -52,20 +53,21 @@ export class PathTracePass {
52
53
  * Recompiles the kernel when the scene's shaders changed, or whether it has a material that scatters light inside;
53
54
  * the same set keeps the pipeline it has.
54
55
  */
55
- setPrograms(programs: ShaderPrograms | null, subsurface = false, volumes = false, bevel = false) {
56
- const key = `${programs?.key ?? ""}|${subsurface}|${volumes}|${bevel}`;
56
+ setPrograms(programs: ShaderPrograms | null, subsurface = false, volumes = false, bevel = false,
57
+ portals = false) {
58
+ const key = `${programs?.key ?? ""}|${subsurface}|${volumes}|${bevel}|${portals}`;
57
59
 
58
60
  if (key === this.programKey) return;
59
61
 
60
62
  this.programKey = key;
61
- this.pipeline = this.build(programs, subsurface, volumes, bevel);
63
+ this.pipeline = this.build(programs, subsurface, volumes, bevel, portals);
62
64
  this.bindGroup = null;
63
65
  }
64
66
 
65
67
  private build(programs: ShaderPrograms | null, subsurface: boolean, volumes: boolean,
66
- bevel: boolean): GPUComputePipeline {
68
+ bevel: boolean, portals: boolean): GPUComputePipeline {
67
69
  const module = createShaderModule(this.device, "path trace",
68
- pathTraceSource(programs, subsurface, volumes, bevel));
70
+ pathTraceSource(programs, subsurface, volumes, bevel, portals));
69
71
 
70
72
  return this.device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
71
73
  }
@@ -9,6 +9,8 @@ export interface PresentUniforms {
9
9
  toneMapping: RenderToneMapping;
10
10
  vignette: number;
11
11
  grain: number;
12
+ /** AgX's look, from its base curve to Punchy. */
13
+ contrast: number;
12
14
  frameIndex: number;
13
15
  transparent: boolean;
14
16
  /** The denoiser's output, which the colour is read from instead of the accumulation when given. */
@@ -84,6 +86,7 @@ export class PresentPass {
84
86
  this.integers[6] = present.frameIndex;
85
87
  this.integers[7] = present.transparent ? 1 : 0;
86
88
  this.integers[8] = present.denoised ? 1 : 0;
89
+ this.floats[9] = present.contrast;
87
90
  this.device.queue.writeBuffer(this.uniforms, 0, this.values);
88
91
 
89
92
  const pass = encoder.beginRenderPass({
package/src/renderer.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  type DenoiserSettings,
41
41
  type DenoiseView,
42
42
  } from "./denoise.js";
43
+ import { DEFAULT_OIDN_WEIGHTS, OidnPass } from "./oidnPass.js";
43
44
  import { CaptureResolvePass, planarCaptures, type PlanarCaptures } from "./planarCapture.js";
44
45
  import { prepareSceneAssets, type SceneAssets } from "./sceneAssets.js";
45
46
  import { packVolumetricLights, VOLUMETRIC_WORDS } from "./volumetric.js";
@@ -123,6 +124,13 @@ interface PendingFrame {
123
124
  reject: (reason: Error) => void;
124
125
  }
125
126
 
127
+ /** AgX's look, which a missing or broken setting leaves at the base curve's own. */
128
+ function lookContrast(settings: GpuRenderSettings): number {
129
+ const contrast = Number(settings.camera.contrast);
130
+
131
+ return Number.isFinite(contrast) ? Math.min(1, Math.max(0, contrast)) : 0;
132
+ }
133
+
126
134
  function abortError(message: string): Error {
127
135
  return Object.assign(new Error(message), { name: "AbortError" });
128
136
  }
@@ -156,12 +164,30 @@ const SHOW_EVERY_MS = 16;
156
164
  */
157
165
  const BANDS_IN_FLIGHT = 2;
158
166
 
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;
167
+ /**
168
+ * Throughput under which Russian roulette may end a path, with the chance of carrying on equal to the throughput. One
169
+ * plays it on every path past the first bounces, as PBRT does: a path off a light wall carries on as long as it
170
+ * carries light, instead of seven bounces before the roulette ever looks at it. The mean stays exact.
171
+ */
172
+ const ROULETTE_THRESHOLD = 1;
173
+
174
+ /** How often the lens pass grades the canvas again while a frame traces without the denoiser. */
175
+ const LENS_EVERY_MS = 100;
161
176
 
162
- /** How often the canvas takes a freshly denoised image while a frame traces. */
177
+ /** How often the canvas takes a freshly denoised image while a frame traces, for an image of DENOISE_REFERENCE work. */
163
178
  const DENOISE_EVERY_MS = 200;
164
179
 
180
+ /** The filtering of a 1080p image with three passes, which the interval above was set for. */
181
+ const DENOISE_REFERENCE = 1920 * 1080 * 3;
182
+
183
+ /**
184
+ * How long the canvas waits between denoised images: longer for a larger image or more passes, in proportion, so the
185
+ * filter takes the same share of the device from tracing whatever the size. The finished image is filtered regardless.
186
+ */
187
+ export function denoiseInterval(width: number, height: number, passes: number): number {
188
+ return DENOISE_EVERY_MS * Math.max(1, width * height * Math.max(1, passes) / DENOISE_REFERENCE);
189
+ }
190
+
165
191
  /** The most paths per pixel one dispatch takes, while no pixel can stop yet. */
166
192
  const BATCH_PATHS = 4;
167
193
 
@@ -273,7 +299,19 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
273
299
  private planar = false;
274
300
  private resolve: CaptureResolvePass | null = null;
275
301
  private denoisePass: DenoisePass | null = null;
302
+ private oidnPass: OidnPass | null = null;
303
+ // The buffer the last denoise wrote, whichever denoiser ran it.
304
+ private denoiseOutput: GPUBuffer | null = null;
305
+ // The network denoises one image at a time: each run waits for the one before it.
306
+ private oidnRun: Promise<unknown> = Promise.resolve();
307
+ private oidnBusy = false;
308
+ // Set once the network failed to load or run; SVGF stands in until the denoiser settings change.
309
+ private oidnFailed = false;
276
310
  private lensPass: LensPass | null = null;
311
+ // What the lens pass last graded, and when: its input only changes when the denoiser runs or the accumulation grows.
312
+ private lensKey = "";
313
+ private lensAt = -Infinity;
314
+ private denoiseVersion = 0;
277
315
  private denoiserSettings: DenoiserSettings = { ...DEFAULT_DENOISER };
278
316
  // The size of the last frame that finished, which setDenoiser filters again; null while a frame traces.
279
317
  private finished: { width: number; height: number } | null = null;
@@ -329,6 +367,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
329
367
  */
330
368
  setDenoiser(input: Partial<DenoiserSettings>) {
331
369
  this.denoiserSettings = mergeDenoiser(this.denoiserSettings, input);
370
+ this.oidnFailed = false;
332
371
  this.refilter();
333
372
  }
334
373
 
@@ -342,16 +381,18 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
342
381
  const denoise = this.current.globalIllumination.denoise;
343
382
 
344
383
  void (async() => {
345
- const encoder = gpu.device.createCommandEncoder();
346
-
347
- if (denoise) this.encodeDenoise(gpu, encoder, finished.width, finished.height);
384
+ if (denoise) await this.denoiseNow(gpu, finished.width, finished.height);
348
385
  else this.denoised = false;
349
386
 
387
+ if (revision !== this.revision || this.rendering) return;
388
+
389
+ const encoder = gpu.device.createCommandEncoder();
390
+
350
391
  this.encodePresent(encoder, finished.width, finished.height);
351
392
  gpu.device.queue.submit([encoder.finish()]);
352
393
 
353
394
  const image = await this.readImage(gpu, finished.width, finished.height,
354
- denoise ? this.denoisePass?.output ?? null : null);
395
+ denoise && this.denoised ? this.denoiseOutput : null);
355
396
 
356
397
  // A frame started meanwhile owns the image now.
357
398
  if (revision !== this.revision || this.rendering) return;
@@ -536,6 +577,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
536
577
  this.buffers?.destroy();
537
578
  this.accumulator?.destroy();
538
579
  this.denoisePass?.destroy();
580
+ this.oidnPass?.destroy();
539
581
  this.lensPass?.destroy();
540
582
  this.lensPass = null;
541
583
  this.imageResolve?.dispose();
@@ -716,13 +758,30 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
716
758
 
717
759
  // The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
718
760
  // 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) {
720
- const encoder = gpu.device.createCommandEncoder();
761
+ if (denoise && this.canvas && this.context && !this.oidnBusy && performance.now() - denoisedAt
762
+ >= denoiseInterval(width, height, this.denoiserSettings.passes)) {
763
+ if (this.usesOidn()) {
764
+ // The network takes longer than a pass: it runs beside the tracing, and the canvas takes its image when done.
765
+ this.oidnBusy = true;
766
+ void this.denoiseNow(gpu, width, height).then(() => {
767
+ if (revision !== this.revision || !this.denoised) return;
768
+
769
+ const encoder = gpu.device.createCommandEncoder();
770
+
771
+ this.encodePresent(encoder, width, height);
772
+ gpu.device.queue.submit([encoder.finish()]);
773
+ }).finally(() => {
774
+ this.oidnBusy = false;
775
+ denoisedAt = performance.now();
776
+ });
777
+ } else {
778
+ const encoder = gpu.device.createCommandEncoder();
721
779
 
722
- this.encodeDenoise(gpu, encoder, width, height);
723
- this.encodePresent(encoder, width, height);
724
- gpu.device.queue.submit([encoder.finish()]);
725
- denoisedAt = performance.now();
780
+ this.encodeDenoise(gpu, encoder, width, height);
781
+ this.encodePresent(encoder, width, height);
782
+ gpu.device.queue.submit([encoder.finish()]);
783
+ denoisedAt = performance.now();
784
+ }
726
785
  }
727
786
 
728
787
  this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
@@ -743,14 +802,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
743
802
  this.check(revision);
744
803
 
745
804
  if (denoise) {
805
+ await this.denoiseNow(gpu, width, height);
806
+ this.check(revision);
807
+
746
808
  const encoder = gpu.device.createCommandEncoder();
747
809
 
748
- this.encodeDenoise(gpu, encoder, width, height);
749
810
  this.encodePresent(encoder, width, height);
750
811
  gpu.device.queue.submit([encoder.finish()]);
751
812
  }
752
813
 
753
- const image = await this.readImage(gpu, width, height, denoise ? this.denoisePass?.output ?? null : null);
814
+ const image = await this.readImage(gpu, width, height, denoise && this.denoised ? this.denoiseOutput : null);
754
815
 
755
816
  this.check(revision);
756
817
  this.output = image;
@@ -808,7 +869,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
808
869
  && subsurfaceOf(material) !== null),
809
870
  // Even turned off, a volume's boundary has to stay invisible, which takes the same code.
810
871
  materials.some(material => volumeOf(material) !== null),
811
- this.current.bevel.enabled && materials.some(material => bevelOf(material) > 0));
872
+ this.current.bevel.enabled && materials.some(material => bevelOf(material) > 0),
873
+ // Portals need windows: thin glass, which is what a physical material with transmission and no thickness is.
874
+ this.current.globalIllumination.portals !== false && materials.some(material =>
875
+ material.type === "MeshPhysicalMaterial" && Number(material.values.transmission) > 0
876
+ && !(Number(material.values.thickness) > 0)));
812
877
 
813
878
  // A procedural sky stands in for the renderer's own environment, as it does in the CPU renderer.
814
879
  const environment = assets.sky?.environment ?? image;
@@ -1048,10 +1113,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1048
1113
  toneMapping: this.current.toneMapping,
1049
1114
  vignette: this.current.vignette,
1050
1115
  grain: this.current.grain,
1116
+ contrast: lookContrast(this.current),
1051
1117
  frameIndex: this.frameIndex,
1052
1118
  transparent: this.current.background.mode === "transparent",
1053
1119
  denoised: this.encodeLens(this.gpu, encoder, width, height,
1054
- this.denoised ? this.denoisePass?.output ?? null : null),
1120
+ this.denoised ? this.denoiseOutput : null),
1055
1121
  });
1056
1122
  }
1057
1123
 
@@ -1060,13 +1126,25 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1060
1126
  * buffer of graded colour the display transform reads instead, or the input as it was.
1061
1127
  */
1062
1128
  private encodeLens(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number,
1063
- denoised: GPUBuffer | null): GPUBuffer | null {
1129
+ denoised: GPUBuffer | null, force = false): GPUBuffer | null {
1064
1130
  const effects = lensEffects(this.current);
1065
1131
 
1066
1132
  if (!effects) return denoised;
1067
1133
 
1068
1134
  this.lensPass ??= new LensPass(gpu.device);
1069
1135
 
1136
+ // The canvas is drawn about once a display frame, but a denoised image only changes a few times a second: grading
1137
+ // the same one again would take the device from tracing for nothing. Straight from the accumulation, the image
1138
+ // grows with every pass, and is graded again at most every LENS_EVERY_MS.
1139
+ const key = JSON.stringify([this.revision, width, height, denoised ? this.denoiseVersion : -1, effects]);
1140
+ const now = performance.now();
1141
+ const fresh = key === this.lensKey && (denoised !== null || now - this.lensAt < LENS_EVERY_MS);
1142
+
1143
+ if (!force && fresh && this.lensPass.output) return this.lensPass.output;
1144
+
1145
+ this.lensKey = key;
1146
+ this.lensAt = now;
1147
+
1070
1148
  return this.lensPass.encode(encoder, (this.accumulator as Accumulator).buffer, denoised, width, height, effects);
1071
1149
  }
1072
1150
 
@@ -1075,7 +1153,65 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1075
1153
  this.denoisePass ??= new DenoisePass(gpu.device);
1076
1154
  this.denoisePass.encode(encoder, (this.accumulator as Accumulator).buffer, width, height, this.denoiserSettings,
1077
1155
  this.denoiseView);
1156
+ this.denoiseOutput = this.denoisePass.output;
1078
1157
  this.denoised = true;
1158
+ this.denoiseVersion++;
1159
+ }
1160
+
1161
+ private usesOidn(): boolean {
1162
+ return this.denoiserSettings.algorithm === "oidn" && !this.oidnFailed;
1163
+ }
1164
+
1165
+ /**
1166
+ * Denoises the accumulation as it stands with the chosen denoiser, and resolves once the output holds it. SVGF is
1167
+ * submitted at once; OIDN runs after any run still in flight, and is dropped when a newer frame starts meanwhile.
1168
+ */
1169
+ private async denoiseNow(gpu: RenderDevice, width: number, height: number): Promise<void> {
1170
+ if (this.usesOidn()) {
1171
+ const revision = this.revision;
1172
+ const run = this.oidnRun.then(() => this.runOidn(gpu, width, height, revision));
1173
+
1174
+ this.oidnRun = run.catch(() => undefined);
1175
+
1176
+ // Denoised, or replaced by a newer frame: either way nothing is left to do here.
1177
+ if (await run || !this.oidnFailed) return;
1178
+ }
1179
+
1180
+ const encoder = gpu.device.createCommandEncoder();
1181
+
1182
+ this.encodeDenoise(gpu, encoder, width, height);
1183
+ gpu.device.queue.submit([encoder.finish()]);
1184
+ }
1185
+
1186
+ private async runOidn(gpu: RenderDevice, width: number, height: number, revision: number): Promise<boolean> {
1187
+ if (revision !== this.revision || !this.accumulator) return false;
1188
+
1189
+ const signal = { cancelled: false };
1190
+
1191
+ const watch = () => { signal.cancelled = revision !== this.revision; };
1192
+
1193
+ this.oidnPass ??= new OidnPass(gpu.device);
1194
+
1195
+ try {
1196
+ const { strength, oidnWeights } = this.denoiserSettings;
1197
+ const timer = setInterval(watch, 50);
1198
+
1199
+ const done = await this.oidnPass.run(this.accumulator.buffer, width, height, strength,
1200
+ oidnWeights || DEFAULT_OIDN_WEIGHTS, signal).finally(() => clearInterval(timer));
1201
+
1202
+ if (!done || revision !== this.revision) return false;
1203
+
1204
+ this.denoiseOutput = this.oidnPass.output;
1205
+ this.denoised = true;
1206
+ this.denoiseVersion++;
1207
+
1208
+ return true;
1209
+ } catch(error) {
1210
+ this.oidnFailed = true;
1211
+ console.warn("GPU renderer: OIDN could not denoise, so SVGF does.", error);
1212
+
1213
+ return false;
1214
+ }
1079
1215
  }
1080
1216
 
1081
1217
  private frameUniforms(
@@ -1145,6 +1281,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1145
1281
  rouletteStart: Math.max(0, Math.round(this.current.globalIllumination.rouletteStart ?? 0)),
1146
1282
  lensDistortion: Number.isFinite(this.current.lens.distortion) ? this.current.lens.distortion : 0,
1147
1283
  subsurfaceEnabled: this.current.subsurface.enabled,
1284
+ subsurfaceDepth: Math.max(0, Math.round(Number(this.current.subsurface.depth ?? 2))),
1148
1285
  bevelEnabled: this.current.bevel.enabled,
1149
1286
  volumesEnabled: this.current.volumes.enabled,
1150
1287
  cameraVolume: this.current.volumes.enabled
@@ -1163,6 +1300,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1163
1300
  localLightPick: buffers?.localLightPick ?? 0,
1164
1301
  lightTree: this.current.globalIllumination.lightTree === false ? 0 : buffers?.lightTree ?? 0,
1165
1302
  lightLeaves: buffers?.lightLeaves ?? 0,
1303
+ lightList: buffers?.lightList ?? 0,
1304
+ portals: buffers?.portals ?? 0,
1305
+ portalCount: this.current.globalIllumination.portals === false ? 0 : buffers?.portalCount ?? 0,
1306
+ portalList: buffers?.portalList ?? 0,
1166
1307
  clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
1167
1308
  // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
1168
1309
  shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
@@ -1206,12 +1347,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1206
1347
 
1207
1348
  // The lens's effects are encoded on their own, ahead of the resolve, which reads the colour they leave.
1208
1349
  const encoder = gpu.device.createCommandEncoder({ label: "lens" });
1209
- const graded = this.encodeLens(gpu, encoder, width, height, denoised);
1350
+ const graded = this.encodeLens(gpu, encoder, width, height, denoised, true);
1210
1351
 
1211
1352
  gpu.device.queue.submit([encoder.finish()]);
1212
1353
 
1213
1354
  const data = await this.imageResolve.read(accumulator.buffer, graded, width, height,
1214
- { exposure, toneMapping, vignette, transparent });
1355
+ { exposure, toneMapping, vignette, contrast: lookContrast(this.current), transparent });
1215
1356
 
1216
1357
  return { width, height, data };
1217
1358
  }
@@ -52,6 +52,11 @@ export interface SceneBuffers {
52
52
  localLightPick: number;
53
53
  lightTree: number;
54
54
  lightLeaves: number;
55
+ lightList: number;
56
+ /** The windows the sky is sampled through: see lights.ts. */
57
+ portals: number;
58
+ portalCount: number;
59
+ portalList: number;
55
60
  /** Radiance an ambient or hemisphere light adds, which no shadow ray can reach. */
56
61
  ambient: PackedLights["ambient"];
57
62
  /** Where the surrounding image and its distributions ended up, for the frame uniform. */
@@ -206,6 +211,10 @@ export function uploadScene(
206
211
  localLightPick: lights.localPick,
207
212
  lightTree: lights.treeAt,
208
213
  lightLeaves: lights.leavesAt,
214
+ lightList: lights.listAt,
215
+ portals: lights.portalTreeAt,
216
+ portalCount: lights.portalCount,
217
+ portalList: lights.portalListAt,
209
218
  ambient: lights.ambient,
210
219
  environment: {
211
220
  texture: environment ? textures.indexOf.get(environment.texture.id) ?? NO_TEXTURE : NO_TEXTURE,
package/src/settings.ts CHANGED
@@ -20,6 +20,12 @@ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIlluminat
20
20
  * gets the samples a large panel far away would have taken.
21
21
  */
22
22
  lightTree: boolean;
23
+ /**
24
+ * Inside a room, aims at the sky through its windows (every thin transmissive material) as well as by its
25
+ * brightness, as Cycles' portals do, so daylight that enters through glass is found instead of stumbled upon.
26
+ * A point counts as inside when something is above it; elsewhere the sky is sampled as before.
27
+ */
28
+ portals: boolean;
23
29
  };
24
30
  /**
25
31
  * The light a real lens and sensor scatter around anything bright: a soft halo that fades with distance. Energy
@@ -60,11 +66,24 @@ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIlluminat
60
66
  };
61
67
  /** A camera's exposure triangle and white balance. */
62
68
  camera: {
69
+ /**
70
+ * The contrast and saturation of AgX's look, `0` to `1`: `0` is its base curve, flat on purpose, and `1` a look
71
+ * like Blender's Punchy, pivoted on middle grey so the image keeps its brightness. The other tone mappings ignore it.
72
+ */
73
+ contrast: number;
63
74
  /**
64
75
  * Exposes from `iso`, `shutterSpeed` and `depthOfField.aperture` as a camera would, with `exposure` as a
65
76
  * compensation on top. The scene's lights then have to be in physical units (lux, candela, nits).
66
77
  */
67
78
  physicalExposure: boolean;
79
+ /**
80
+ * Meters the image as a camera in automatic does and exposes it to middle grey, from the mean of the logarithms of
81
+ * its luminance, weighed towards the centre: a bright window or sky cannot darken the rest. It takes the place of
82
+ * `exposure` and of the physical exposure, with `exposureCompensation` on top.
83
+ */
84
+ autoExposure: boolean;
85
+ /** Stops added to the metered exposure: positive brightens, as on a camera's dial. */
86
+ exposureCompensation: number;
68
87
  iso: number;
69
88
  /** Seconds. */
70
89
  shutterSpeed: number;
@@ -78,6 +97,12 @@ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIlluminat
78
97
  subsurface: {
79
98
  /** Materials with `userData.cpuRenderer.subsurface` scatter light inside themselves. Off renders them opaque. */
80
99
  enabled: boolean;
100
+ /**
101
+ * Bounces at which light still walks inside such a material: past them it reflects off it as a diffuse surface of
102
+ * the same colour. Light that has already bounced that often hardly shows the difference, and every walk costs as
103
+ * much as many bounces.
104
+ */
105
+ depth: number;
81
106
  };
82
107
  /**
83
108
  * Aerial perspective: the air between the camera and what it sees, which fades distant things towards the colour of
@@ -123,13 +148,16 @@ export const DEFAULT_GPU_RENDER_SETTINGS: Readonly<GpuRenderSettings> = Object.f
123
148
  ...DEFAULT_RENDER_SETTINGS,
124
149
  // AgX rolls highlights off as film does, where ACES pushes saturated colours to the gamut's edge.
125
150
  toneMapping: "agx",
126
- globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart: 3,
127
- lightTree: true },
151
+ globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart: 1,
152
+ lightTree: true, portals: true },
128
153
  bloom: { enabled: true, intensity: 0.04, radius: 0.65 },
129
154
  glare: { enabled: true, intensity: 0.06, blades: 6, length: 0.25, rotation: 15 },
130
- lens: { chromaticAberration: 0.2, distortion: 0 },
131
- camera: { physicalExposure: false, iso: 100, shutterSpeed: 1 / 125, whiteBalance: 6500 },
132
- subsurface: { enabled: true },
155
+ lens: { chromaticAberration: 0.05, distortion: 0 },
156
+ camera: {
157
+ contrast: 1, physicalExposure: false, autoExposure: false, exposureCompensation: 0, iso: 100,
158
+ shutterSpeed: 1 / 125, whiteBalance: 6500,
159
+ },
160
+ subsurface: { enabled: true, depth: 2 },
133
161
  atmosphere: { enabled: true, visibility: 20000, haze: 0.6, color: "" },
134
162
  volumes: { enabled: true },
135
163
  bevel: { enabled: true },
@@ -154,10 +182,16 @@ export function physicalExposure(aperture: number, shutterSpeed: number, iso: nu
154
182
  return 1 / (1.2 * 2 ** ev100);
155
183
  }
156
184
 
157
- /** The exposure the image is multiplied by: the setting itself, or a camera's on top of it as compensation. */
185
+ /**
186
+ * The exposure the image is multiplied by: the setting itself, or a camera's on top of it as compensation, or with
187
+ * the automatic exposure only its compensation, the meter's own exposure being applied on the device.
188
+ */
158
189
  export function effectiveExposure(settings: GpuRenderSettings): number {
159
190
  const { camera, depthOfField, exposure } = settings;
160
191
 
192
+ // The meter's exposure is applied on the device, by the lens pass; what is left here is the compensation.
193
+ if (camera.autoExposure) return 2 ** (Number(camera.exposureCompensation) || 0);
194
+
161
195
  return camera.physicalExposure
162
196
  ? exposure * physicalExposure(depthOfField.aperture, camera.shutterSpeed, camera.iso)
163
197
  : exposure;
@@ -46,7 +46,8 @@ struct Lens {
46
46
  // The size of the level the rays are gathered at, and the angle of the first ray, in radians.
47
47
  streakSize: vec2<u32>,
48
48
  glareAngle: f32,
49
- pad0: f32,
49
+ // Whether the camera meters the image and exposes it to middle grey, which the meter pass works out.
50
+ autoExposure: f32,
50
51
  };
51
52
 
52
53
  const PI: f32 = 3.14159265358979;
@@ -61,6 +62,108 @@ const STREAK_TAPS: i32 = 24;
61
62
  @group(0) @binding(4) var<storage, read_write> up: array<vec4<f32>>;
62
63
  @group(0) @binding(5) var<storage, read_write> graded: array<vec4<f32>>;
63
64
  @group(0) @binding(6) var<storage, read_write> streaked: array<vec4<f32>>;
65
+ // What the camera's meter read: the exposure that brings the image's average to middle grey, in x.
66
+ @group(0) @binding(7) var<storage, read_write> metered: array<vec4<f32>>;
67
+
68
+ /** What a light meter calibrates to: an average scene reflects about eighteen percent. */
69
+ const MIDDLE_GREY: f32 = 0.18;
70
+ const METER_GRID: vec2<u32> = vec2<u32>(64u, 36u);
71
+ const METER_THREADS: u32 = 256u;
72
+
73
+ /**
74
+ * How far above middle grey the meter lets the bright end of the image land: three stops, where AgX still shows
75
+ * detail, three and a half under where it reaches white.
76
+ */
77
+ const HIGHLIGHT_CEILING: f32 = 8.0;
78
+ /** The share of the metered points that counts as the bright end: the light through a window, not a lamp's own pixels. */
79
+ const HIGHLIGHT_SHARE: f32 = 0.03;
80
+ /** Half stops of luminance, from 2^-16 to 2^16, which the bright end is found in. */
81
+ const METER_BINS: u32 = 64u;
82
+
83
+ var<workgroup> meterSums: array<vec2<f32>, 256>;
84
+ var<workgroup> meterHistogram: array<atomic<u32>, 64>;
85
+
86
+ /**
87
+ * The camera's meter: the image's average luminance over a grid of points, taken as the mean of the logarithms so that
88
+ * a bright window or a patch of sky cannot darken everything else, weighed towards the centre as a camera's
89
+ * centre-weighted metering is. What no path has reached yet, or is black, does not count. One workgroup reads the
90
+ * grid and sums it in shared memory.
91
+ *
92
+ * Then it guards the highlights, as a camera's matrix metering does: the average alone takes a dim attic with a shaft
93
+ * of sun in it to middle grey and burns the shaft out, where a photographer keeps the room dim. The brightest few
94
+ * percent of the points, found in a histogram of half stops, may land no more than {@link HIGHLIGHT_CEILING} times
95
+ * brighter; an even scene, whose bright end is close to its average, is left as the average says.
96
+ */
97
+ @compute @workgroup_size(256, 1, 1)
98
+ fn meter(@builtin(local_invocation_index) thread: u32) {
99
+ var sum = vec2<f32>(0.0);
100
+ let count = METER_GRID.x * METER_GRID.y;
101
+
102
+ if (thread < METER_BINS) {
103
+ atomicStore(&meterHistogram[thread], 0u);
104
+ }
105
+
106
+ workgroupBarrier();
107
+
108
+ for (var k = thread; k < count; k = k + METER_THREADS) {
109
+ let cell = vec2<f32>(f32(k % METER_GRID.x), f32(k / METER_GRID.x)) + 0.5;
110
+ let uv = cell / vec2<f32>(METER_GRID);
111
+ let luminance = dot(imageTexel(i32(uv.x * f32(lens.resolution.x)), i32(uv.y * f32(lens.resolution.y))),
112
+ vec3<f32>(0.2126, 0.7152, 0.0722));
113
+ let away = (uv - 0.5) * 2.0;
114
+ let weight = select(0.0, 1.0 - 0.6 * min(dot(away, away) * 0.5, 1.0), luminance > 0.0);
115
+
116
+ sum = sum + vec2<f32>(log(max(luminance, 1e-6)) * weight, weight);
117
+
118
+ if (luminance > 0.0) {
119
+ let bin = u32(clamp((log2(luminance) + 16.0) * 2.0, 0.0, f32(METER_BINS - 1u)));
120
+
121
+ atomicAdd(&meterHistogram[bin], 1u);
122
+ }
123
+ }
124
+
125
+ meterSums[thread] = sum;
126
+ workgroupBarrier();
127
+
128
+ for (var stride = METER_THREADS / 2u; stride > 0u; stride = stride / 2u) {
129
+ if (thread < stride) {
130
+ meterSums[thread] = meterSums[thread] + meterSums[thread + stride];
131
+ }
132
+
133
+ workgroupBarrier();
134
+ }
135
+
136
+ if (thread == 0u) {
137
+ let total = meterSums[0];
138
+ let average = select(MIDDLE_GREY, exp(total.x / max(total.y, 1e-6)), total.y > 0.0);
139
+ var exposure = MIDDLE_GREY / average;
140
+
141
+ // From the top down, the first half stop by which the bright share is reached; its upper edge is that end.
142
+ var points = 0u;
143
+
144
+ for (var bin = 0u; bin < METER_BINS; bin = bin + 1u) {
145
+ points = points + atomicLoad(&meterHistogram[bin]);
146
+ }
147
+
148
+ var above = 0u;
149
+ var bright = 0.0;
150
+
151
+ for (var bin = i32(METER_BINS) - 1; bin >= 0 && points > 0u; bin = bin - 1) {
152
+ above = above + atomicLoad(&meterHistogram[u32(bin)]);
153
+
154
+ if (f32(above) >= HIGHLIGHT_SHARE * f32(points)) {
155
+ bright = exp2(f32(bin + 1) * 0.5 - 16.0);
156
+ break;
157
+ }
158
+ }
159
+
160
+ if (bright > 0.0) {
161
+ exposure = min(exposure, HIGHLIGHT_CEILING * MIDDLE_GREY / bright);
162
+ }
163
+
164
+ metered[0] = vec4<f32>(clamp(exposure, 1e-4, 1e4), average, bright, 0.0);
165
+ }
166
+ }
64
167
 
65
168
  /** The image's own texel: its mean so far, or nothing where no path has landed yet. */
66
169
  fn imageTexel(x: i32, y: i32) -> vec3<f32> {
@@ -260,21 +363,6 @@ fn streakBilinear(position: vec2<f32>) -> vec3<f32> {
260
363
  return mix(mix(streakTexel(x, y), streakTexel(x + 1, y), b.z), mix(streakTexel(x, y + 1), streakTexel(x + 1, y + 1), b.z), b.w);
261
364
  }
262
365
 
263
- /** The folded halo at full resolution: a tent over its largest level, which is half the image's size. */
264
- fn tentHalo(position: vec2<f32>) -> vec3<f32> {
265
- var sum = vec3<f32>(0.0);
266
-
267
- for (var dy = -1; dy <= 1; dy = dy + 1) {
268
- for (var dx = -1; dx <= 1; dx = dx + 1) {
269
- let weight = f32((2 - abs(dx)) * (2 - abs(dy))) / 16.0;
270
-
271
- sum = sum + upBilinear(lens.haloOffset, lens.haloSize, position + vec2<f32>(f32(dx), f32(dy)) * 0.5) * weight;
272
- }
273
- }
274
-
275
- return sum;
276
- }
277
-
278
366
  /** The image as the lens delivers it: fringed towards the corners, with its halo, balanced for the camera's white. */
279
367
  @compute @workgroup_size(8, 8, 1)
280
368
  fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
@@ -298,7 +386,8 @@ fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
298
386
  }
299
387
 
300
388
  if (lens.intensity > 0.0) {
301
- let halo = tentHalo(position * vec2<f32>(lens.haloSize) / size);
389
+ // The largest folded level is already a tent over the levels below it, so one bilinear read of it is smooth.
390
+ let halo = upBilinear(lens.haloOffset, lens.haloSize, position * vec2<f32>(lens.haloSize) / size);
302
391
 
303
392
  color = mix(color, halo, lens.intensity);
304
393
  }
@@ -307,6 +396,10 @@ fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
307
396
  color = mix(color, streakBilinear(position * vec2<f32>(lens.streakSize) / size), lens.glare);
308
397
  }
309
398
 
399
+ if (lens.autoExposure > 0.0) {
400
+ color = color * metered[0].x;
401
+ }
402
+
310
403
  let balanced = vec3<f32>(dot(lens.white0.xyz, color), dot(lens.white1.xyz, color), dot(lens.white2.xyz, color));
311
404
 
312
405
  graded[id.y * lens.resolution.x + id.x] = vec4<f32>(max(balanced, vec3<f32>(0.0)), 1.0);