@tinypoly/gpu-renderer 0.1.1 → 0.2.1

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 (89) hide show
  1. package/ARCHITECTURE.md +31 -3
  2. package/README.md +11 -3
  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 +4 -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/lights.d.ts +18 -0
  26. package/dist/lights.d.ts.map +1 -1
  27. package/dist/lights.js +45 -0
  28. package/dist/lights.js.map +1 -1
  29. package/dist/oidnPass.d.ts +48 -0
  30. package/dist/oidnPass.d.ts.map +1 -0
  31. package/dist/oidnPass.js +255 -0
  32. package/dist/oidnPass.js.map +1 -0
  33. package/dist/pathTracePass.d.ts +2 -2
  34. package/dist/pathTracePass.d.ts.map +1 -1
  35. package/dist/pathTracePass.js +9 -9
  36. package/dist/pathTracePass.js.map +1 -1
  37. package/dist/presentPass.d.ts +2 -0
  38. package/dist/presentPass.d.ts.map +1 -1
  39. package/dist/presentPass.js +1 -0
  40. package/dist/presentPass.js.map +1 -1
  41. package/dist/renderer.d.ts +16 -1
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +111 -15
  44. package/dist/renderer.js.map +1 -1
  45. package/dist/sceneUpload.d.ts +4 -0
  46. package/dist/sceneUpload.d.ts.map +1 -1
  47. package/dist/sceneUpload.js +3 -0
  48. package/dist/sceneUpload.js.map +1 -1
  49. package/dist/settings.d.ts +23 -1
  50. package/dist/settings.d.ts.map +1 -1
  51. package/dist/settings.js +13 -4
  52. package/dist/settings.js.map +1 -1
  53. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  54. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  55. package/dist/wgsl/lens.wgsl.js +108 -1
  56. package/dist/wgsl/lens.wgsl.js.map +1 -1
  57. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  58. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/lights.wgsl.js +147 -17
  60. package/dist/wgsl/lights.wgsl.js.map +1 -1
  61. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  62. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/pathTrace.wgsl.js +123 -91
  64. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  65. package/dist/wgsl/present.wgsl.d.ts +1 -1
  66. package/dist/wgsl/present.wgsl.d.ts.map +1 -1
  67. package/dist/wgsl/present.wgsl.js +11 -1
  68. package/dist/wgsl/present.wgsl.js.map +1 -1
  69. package/package.json +4 -2
  70. package/src/denoise.ts +15 -1
  71. package/src/device.ts +9 -0
  72. package/src/frameLayout.ts +4 -1
  73. package/src/imageResolve.ts +2 -0
  74. package/src/index.ts +2 -1
  75. package/src/lensPass.ts +17 -1
  76. package/src/lights.ts +60 -0
  77. package/src/oidnPass.ts +284 -0
  78. package/src/oidnWebgpu.d.ts +5 -0
  79. package/src/pathTracePass.ts +11 -9
  80. package/src/presentPass.ts +3 -0
  81. package/src/renderer.ts +133 -16
  82. package/src/sceneUpload.ts +7 -0
  83. package/src/settings.ts +32 -4
  84. package/src/wgsl/lens.wgsl.ts +108 -1
  85. package/src/wgsl/lights.wgsl.ts +147 -17
  86. package/src/wgsl/pathTrace.wgsl.ts +123 -91
  87. package/src/wgsl/present.wgsl.ts +11 -1
  88. package/weights/LICENSE.txt +202 -0
  89. package/weights/rt_hdr_alb_nrm.tza +0 -0
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
  }
@@ -291,6 +299,14 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
291
299
  private planar = false;
292
300
  private resolve: CaptureResolvePass | null = null;
293
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;
294
310
  private lensPass: LensPass | null = null;
295
311
  // What the lens pass last graded, and when: its input only changes when the denoiser runs or the accumulation grows.
296
312
  private lensKey = "";
@@ -351,6 +367,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
351
367
  */
352
368
  setDenoiser(input: Partial<DenoiserSettings>) {
353
369
  this.denoiserSettings = mergeDenoiser(this.denoiserSettings, input);
370
+ this.oidnFailed = false;
354
371
  this.refilter();
355
372
  }
356
373
 
@@ -364,16 +381,18 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
364
381
  const denoise = this.current.globalIllumination.denoise;
365
382
 
366
383
  void (async() => {
367
- const encoder = gpu.device.createCommandEncoder();
368
-
369
- if (denoise) this.encodeDenoise(gpu, encoder, finished.width, finished.height);
384
+ if (denoise) await this.denoiseNow(gpu, finished.width, finished.height);
370
385
  else this.denoised = false;
371
386
 
387
+ if (revision !== this.revision || this.rendering) return;
388
+
389
+ const encoder = gpu.device.createCommandEncoder();
390
+
372
391
  this.encodePresent(encoder, finished.width, finished.height);
373
392
  gpu.device.queue.submit([encoder.finish()]);
374
393
 
375
394
  const image = await this.readImage(gpu, finished.width, finished.height,
376
- denoise ? this.denoisePass?.output ?? null : null);
395
+ denoise && this.denoised ? this.denoiseOutput : null);
377
396
 
378
397
  // A frame started meanwhile owns the image now.
379
398
  if (revision !== this.revision || this.rendering) return;
@@ -472,10 +491,24 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
472
491
  this.sceneDirty = true;
473
492
  }
474
493
 
475
- /** Used by `attachCanvas`; pass `null` to detach. */
494
+ /**
495
+ * Used by `attachCanvas`; pass `null` to detach. A canvas attached after a frame started, or after it finished,
496
+ * shows what the accumulation already holds at once, instead of staying empty until the next frame.
497
+ */
476
498
  setOutputCanvas(canvas: HTMLCanvasElement | null) {
477
499
  this.canvas = canvas;
478
500
  this.context = null;
501
+
502
+ const gpu = this.gpu, accumulator = this.accumulator;
503
+
504
+ if (!canvas || !gpu || !accumulator || this.disposed) return;
505
+
506
+ this.configureCanvas(gpu, accumulator.width, accumulator.height);
507
+
508
+ const encoder = gpu.device.createCommandEncoder();
509
+
510
+ this.encodePresent(encoder, accumulator.width, accumulator.height);
511
+ gpu.device.queue.submit([encoder.finish()]);
479
512
  }
480
513
 
481
514
  /**
@@ -558,6 +591,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
558
591
  this.buffers?.destroy();
559
592
  this.accumulator?.destroy();
560
593
  this.denoisePass?.destroy();
594
+ this.oidnPass?.destroy();
561
595
  this.lensPass?.destroy();
562
596
  this.lensPass = null;
563
597
  this.imageResolve?.dispose();
@@ -738,14 +772,30 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
738
772
 
739
773
  // The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
740
774
  // filtering the whole image after every pass would cost a cheap scene as much as tracing it.
741
- if (denoise && this.canvas && this.context && performance.now() - denoisedAt
775
+ if (denoise && this.canvas && this.context && !this.oidnBusy && performance.now() - denoisedAt
742
776
  >= denoiseInterval(width, height, this.denoiserSettings.passes)) {
743
- const encoder = gpu.device.createCommandEncoder();
777
+ if (this.usesOidn()) {
778
+ // The network takes longer than a pass: it runs beside the tracing, and the canvas takes its image when done.
779
+ this.oidnBusy = true;
780
+ void this.denoiseNow(gpu, width, height).then(() => {
781
+ if (revision !== this.revision || !this.denoised) return;
782
+
783
+ const encoder = gpu.device.createCommandEncoder();
784
+
785
+ this.encodePresent(encoder, width, height);
786
+ gpu.device.queue.submit([encoder.finish()]);
787
+ }).finally(() => {
788
+ this.oidnBusy = false;
789
+ denoisedAt = performance.now();
790
+ });
791
+ } else {
792
+ const encoder = gpu.device.createCommandEncoder();
744
793
 
745
- this.encodeDenoise(gpu, encoder, width, height);
746
- this.encodePresent(encoder, width, height);
747
- gpu.device.queue.submit([encoder.finish()]);
748
- denoisedAt = performance.now();
794
+ this.encodeDenoise(gpu, encoder, width, height);
795
+ this.encodePresent(encoder, width, height);
796
+ gpu.device.queue.submit([encoder.finish()]);
797
+ denoisedAt = performance.now();
798
+ }
749
799
  }
750
800
 
751
801
  this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
@@ -766,14 +816,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
766
816
  this.check(revision);
767
817
 
768
818
  if (denoise) {
819
+ await this.denoiseNow(gpu, width, height);
820
+ this.check(revision);
821
+
769
822
  const encoder = gpu.device.createCommandEncoder();
770
823
 
771
- this.encodeDenoise(gpu, encoder, width, height);
772
824
  this.encodePresent(encoder, width, height);
773
825
  gpu.device.queue.submit([encoder.finish()]);
774
826
  }
775
827
 
776
- const image = await this.readImage(gpu, width, height, denoise ? this.denoisePass?.output ?? null : null);
828
+ const image = await this.readImage(gpu, width, height, denoise && this.denoised ? this.denoiseOutput : null);
777
829
 
778
830
  this.check(revision);
779
831
  this.output = image;
@@ -831,7 +883,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
831
883
  && subsurfaceOf(material) !== null),
832
884
  // Even turned off, a volume's boundary has to stay invisible, which takes the same code.
833
885
  materials.some(material => volumeOf(material) !== null),
834
- this.current.bevel.enabled && materials.some(material => bevelOf(material) > 0));
886
+ this.current.bevel.enabled && materials.some(material => bevelOf(material) > 0),
887
+ // Portals need windows: thin glass, which is what a physical material with transmission and no thickness is.
888
+ this.current.globalIllumination.portals !== false && materials.some(material =>
889
+ material.type === "MeshPhysicalMaterial" && Number(material.values.transmission) > 0
890
+ && !(Number(material.values.thickness) > 0)));
835
891
 
836
892
  // A procedural sky stands in for the renderer's own environment, as it does in the CPU renderer.
837
893
  const environment = assets.sky?.environment ?? image;
@@ -1071,10 +1127,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1071
1127
  toneMapping: this.current.toneMapping,
1072
1128
  vignette: this.current.vignette,
1073
1129
  grain: this.current.grain,
1130
+ contrast: lookContrast(this.current),
1074
1131
  frameIndex: this.frameIndex,
1075
1132
  transparent: this.current.background.mode === "transparent",
1076
1133
  denoised: this.encodeLens(this.gpu, encoder, width, height,
1077
- this.denoised ? this.denoisePass?.output ?? null : null),
1134
+ this.denoised ? this.denoiseOutput : null),
1078
1135
  });
1079
1136
  }
1080
1137
 
@@ -1110,10 +1167,67 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1110
1167
  this.denoisePass ??= new DenoisePass(gpu.device);
1111
1168
  this.denoisePass.encode(encoder, (this.accumulator as Accumulator).buffer, width, height, this.denoiserSettings,
1112
1169
  this.denoiseView);
1170
+ this.denoiseOutput = this.denoisePass.output;
1113
1171
  this.denoised = true;
1114
1172
  this.denoiseVersion++;
1115
1173
  }
1116
1174
 
1175
+ private usesOidn(): boolean {
1176
+ return this.denoiserSettings.algorithm === "oidn" && !this.oidnFailed;
1177
+ }
1178
+
1179
+ /**
1180
+ * Denoises the accumulation as it stands with the chosen denoiser, and resolves once the output holds it. SVGF is
1181
+ * submitted at once; OIDN runs after any run still in flight, and is dropped when a newer frame starts meanwhile.
1182
+ */
1183
+ private async denoiseNow(gpu: RenderDevice, width: number, height: number): Promise<void> {
1184
+ if (this.usesOidn()) {
1185
+ const revision = this.revision;
1186
+ const run = this.oidnRun.then(() => this.runOidn(gpu, width, height, revision));
1187
+
1188
+ this.oidnRun = run.catch(() => undefined);
1189
+
1190
+ // Denoised, or replaced by a newer frame: either way nothing is left to do here.
1191
+ if (await run || !this.oidnFailed) return;
1192
+ }
1193
+
1194
+ const encoder = gpu.device.createCommandEncoder();
1195
+
1196
+ this.encodeDenoise(gpu, encoder, width, height);
1197
+ gpu.device.queue.submit([encoder.finish()]);
1198
+ }
1199
+
1200
+ private async runOidn(gpu: RenderDevice, width: number, height: number, revision: number): Promise<boolean> {
1201
+ if (revision !== this.revision || !this.accumulator) return false;
1202
+
1203
+ const signal = { cancelled: false };
1204
+
1205
+ const watch = () => { signal.cancelled = revision !== this.revision; };
1206
+
1207
+ this.oidnPass ??= new OidnPass(gpu.device);
1208
+
1209
+ try {
1210
+ const { strength, oidnWeights } = this.denoiserSettings;
1211
+ const timer = setInterval(watch, 50);
1212
+
1213
+ const done = await this.oidnPass.run(this.accumulator.buffer, width, height, strength,
1214
+ oidnWeights || DEFAULT_OIDN_WEIGHTS, signal).finally(() => clearInterval(timer));
1215
+
1216
+ if (!done || revision !== this.revision) return false;
1217
+
1218
+ this.denoiseOutput = this.oidnPass.output;
1219
+ this.denoised = true;
1220
+ this.denoiseVersion++;
1221
+
1222
+ return true;
1223
+ } catch(error) {
1224
+ this.oidnFailed = true;
1225
+ console.warn("GPU renderer: OIDN could not denoise, so SVGF does.", error);
1226
+
1227
+ return false;
1228
+ }
1229
+ }
1230
+
1117
1231
  private frameUniforms(
1118
1232
  camera: CameraState,
1119
1233
  width: number,
@@ -1201,6 +1315,9 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1201
1315
  lightTree: this.current.globalIllumination.lightTree === false ? 0 : buffers?.lightTree ?? 0,
1202
1316
  lightLeaves: buffers?.lightLeaves ?? 0,
1203
1317
  lightList: buffers?.lightList ?? 0,
1318
+ portals: buffers?.portals ?? 0,
1319
+ portalCount: this.current.globalIllumination.portals === false ? 0 : buffers?.portalCount ?? 0,
1320
+ portalList: buffers?.portalList ?? 0,
1204
1321
  clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
1205
1322
  // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
1206
1323
  shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
@@ -1249,7 +1366,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1249
1366
  gpu.device.queue.submit([encoder.finish()]);
1250
1367
 
1251
1368
  const data = await this.imageResolve.read(accumulator.buffer, graded, width, height,
1252
- { exposure, toneMapping, vignette, transparent });
1369
+ { exposure, toneMapping, vignette, contrast: lookContrast(this.current), transparent });
1253
1370
 
1254
1371
  return { width, height, data };
1255
1372
  }
@@ -53,6 +53,10 @@ export interface SceneBuffers {
53
53
  lightTree: number;
54
54
  lightLeaves: number;
55
55
  lightList: number;
56
+ /** The windows the sky is sampled through: see lights.ts. */
57
+ portals: number;
58
+ portalCount: number;
59
+ portalList: number;
56
60
  /** Radiance an ambient or hemisphere light adds, which no shadow ray can reach. */
57
61
  ambient: PackedLights["ambient"];
58
62
  /** Where the surrounding image and its distributions ended up, for the frame uniform. */
@@ -208,6 +212,9 @@ export function uploadScene(
208
212
  lightTree: lights.treeAt,
209
213
  lightLeaves: lights.leavesAt,
210
214
  lightList: lights.listAt,
215
+ portals: lights.portalTreeAt,
216
+ portalCount: lights.portalCount,
217
+ portalList: lights.portalListAt,
211
218
  ambient: lights.ambient,
212
219
  environment: {
213
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;
@@ -130,11 +149,14 @@ export const DEFAULT_GPU_RENDER_SETTINGS: Readonly<GpuRenderSettings> = Object.f
130
149
  // AgX rolls highlights off as film does, where ACES pushes saturated colours to the gamut's edge.
131
150
  toneMapping: "agx",
132
151
  globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart: 1,
133
- lightTree: true },
152
+ lightTree: true, portals: true },
134
153
  bloom: { enabled: true, intensity: 0.04, radius: 0.65 },
135
154
  glare: { enabled: true, intensity: 0.06, blades: 6, length: 0.25, rotation: 15 },
136
- lens: { chromaticAberration: 0.2, distortion: 0 },
137
- camera: { physicalExposure: false, iso: 100, shutterSpeed: 1 / 125, whiteBalance: 6500 },
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
+ },
138
160
  subsurface: { enabled: true, depth: 2 },
139
161
  atmosphere: { enabled: true, visibility: 20000, haze: 0.6, color: "" },
140
162
  volumes: { enabled: true },
@@ -160,10 +182,16 @@ export function physicalExposure(aperture: number, shutterSpeed: number, iso: nu
160
182
  return 1 / (1.2 * 2 ** ev100);
161
183
  }
162
184
 
163
- /** 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
+ */
164
189
  export function effectiveExposure(settings: GpuRenderSettings): number {
165
190
  const { camera, depthOfField, exposure } = settings;
166
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
+
167
195
  return camera.physicalExposure
168
196
  ? exposure * physicalExposure(depthOfField.aperture, camera.shutterSpeed, camera.iso)
169
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> {
@@ -293,6 +396,10 @@ fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
293
396
  color = mix(color, streakBilinear(position * vec2<f32>(lens.streakSize) / size), lens.glare);
294
397
  }
295
398
 
399
+ if (lens.autoExposure > 0.0) {
400
+ color = color * metered[0].x;
401
+ }
402
+
296
403
  let balanced = vec3<f32>(dot(lens.white0.xyz, color), dot(lens.white1.xyz, color), dot(lens.white2.xyz, color));
297
404
 
298
405
  graded[id.y * lens.resolution.x + id.x] = vec4<f32>(max(balanced, vec3<f32>(0.0)), 1.0);