@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.
- package/ARCHITECTURE.md +31 -3
- package/README.md +11 -3
- package/dist/denoise.d.ts +10 -1
- package/dist/denoise.d.ts.map +1 -1
- package/dist/denoise.js +4 -0
- package/dist/denoise.js.map +1 -1
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +8 -0
- package/dist/device.js.map +1 -1
- package/dist/frameLayout.d.ts.map +1 -1
- package/dist/frameLayout.js +4 -1
- package/dist/frameLayout.js.map +1 -1
- package/dist/imageResolve.d.ts +2 -1
- package/dist/imageResolve.d.ts.map +1 -1
- package/dist/imageResolve.js +1 -0
- package/dist/imageResolve.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lensPass.d.ts +4 -0
- package/dist/lensPass.d.ts.map +1 -1
- package/dist/lensPass.js +14 -1
- package/dist/lensPass.js.map +1 -1
- package/dist/lights.d.ts +18 -0
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +45 -0
- package/dist/lights.js.map +1 -1
- package/dist/oidnPass.d.ts +48 -0
- package/dist/oidnPass.d.ts.map +1 -0
- package/dist/oidnPass.js +255 -0
- package/dist/oidnPass.js.map +1 -0
- package/dist/pathTracePass.d.ts +2 -2
- package/dist/pathTracePass.d.ts.map +1 -1
- package/dist/pathTracePass.js +9 -9
- package/dist/pathTracePass.js.map +1 -1
- package/dist/presentPass.d.ts +2 -0
- package/dist/presentPass.d.ts.map +1 -1
- package/dist/presentPass.js +1 -0
- package/dist/presentPass.js.map +1 -1
- package/dist/renderer.d.ts +16 -1
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +111 -15
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +4 -0
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +3 -0
- package/dist/sceneUpload.js.map +1 -1
- package/dist/settings.d.ts +23 -1
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +13 -4
- package/dist/settings.js.map +1 -1
- package/dist/wgsl/lens.wgsl.d.ts +1 -1
- package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
- package/dist/wgsl/lens.wgsl.js +108 -1
- package/dist/wgsl/lens.wgsl.js.map +1 -1
- package/dist/wgsl/lights.wgsl.d.ts +1 -1
- package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
- package/dist/wgsl/lights.wgsl.js +147 -17
- package/dist/wgsl/lights.wgsl.js.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.js +123 -91
- package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
- package/dist/wgsl/present.wgsl.d.ts +1 -1
- package/dist/wgsl/present.wgsl.d.ts.map +1 -1
- package/dist/wgsl/present.wgsl.js +11 -1
- package/dist/wgsl/present.wgsl.js.map +1 -1
- package/package.json +4 -2
- package/src/denoise.ts +15 -1
- package/src/device.ts +9 -0
- package/src/frameLayout.ts +4 -1
- package/src/imageResolve.ts +2 -0
- package/src/index.ts +2 -1
- package/src/lensPass.ts +17 -1
- package/src/lights.ts +60 -0
- package/src/oidnPass.ts +284 -0
- package/src/oidnWebgpu.d.ts +5 -0
- package/src/pathTracePass.ts +11 -9
- package/src/presentPass.ts +3 -0
- package/src/renderer.ts +133 -16
- package/src/sceneUpload.ts +7 -0
- package/src/settings.ts +32 -4
- package/src/wgsl/lens.wgsl.ts +108 -1
- package/src/wgsl/lights.wgsl.ts +147 -17
- package/src/wgsl/pathTrace.wgsl.ts +123 -91
- package/src/wgsl/present.wgsl.ts +11 -1
- package/weights/LICENSE.txt +202 -0
- 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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
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.
|
|
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
|
}
|
package/src/sceneUpload.ts
CHANGED
|
@@ -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.
|
|
137
|
-
camera: {
|
|
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
|
-
/**
|
|
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;
|
package/src/wgsl/lens.wgsl.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|