@tinypoly/gpu-renderer 0.1.1 → 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 (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 +12 -0
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +100 -14
  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 +118 -15
  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/lights.ts CHANGED
@@ -73,6 +73,49 @@ export interface PackedLights {
73
73
  leavesAt: number;
74
74
  /** Where the lights of the tree's leaves are listed in the texture buffer. */
75
75
  listAt: number;
76
+ /**
77
+ * The windows the sky is also sampled through, from inside: the triangles of every thin transmissive material, in a
78
+ * tree of their own, as the lights have, so a point aims at the windows near it rather than at every one in the
79
+ * building alike. Where its nodes start, where its leaves' list starts, and how many windows there are.
80
+ */
81
+ portalTreeAt: number;
82
+ portalListAt: number;
83
+ portalCount: number;
84
+ }
85
+
86
+ /**
87
+ * The triangles of thin glass, which is where daylight enters a room, as the tree sees them: their bounds, their area
88
+ * as power, so a window weighs by the solid angle it takes from a point, and no orientation, since a pane lets the sky
89
+ * through from either side.
90
+ */
91
+ export function portalTriangles(geometry: PackedGeometry): { triangles: number[]; lights: TreeLight[] } {
92
+ const windows = geometry.materialProperties.map(p => p.transmission > 0 && p.thickness <= 0);
93
+ const triangles: number[] = [], lights: TreeLight[] = [];
94
+
95
+ for (let triangle = 0; triangle < geometry.triangleCount; triangle++) {
96
+ if (!windows[geometry.materials[triangle]]) continue;
97
+
98
+ const at = triangle * 9;
99
+ const area = doubleArea(geometry.positions, at) / 2;
100
+
101
+ if (!(area > 0)) continue;
102
+
103
+ const corner = (k: number) => [0, 1, 2].map(c => geometry.positions[at + k * 3 + c]);
104
+ const points = [corner(0), corner(1), corner(2)];
105
+
106
+ triangles.push(triangle);
107
+ lights.push({
108
+ low: [0, 1, 2].map(c => Math.min(points[0][c], points[1][c], points[2][c])),
109
+ high: [0, 1, 2].map(c => Math.max(points[0][c], points[1][c], points[2][c])),
110
+ power: area,
111
+ axis: [0, 1, 0],
112
+ thetaO: Math.PI,
113
+ thetaE: Math.PI / 2,
114
+ density: 1 / area,
115
+ });
116
+ }
117
+
118
+ return { triangles, lights };
76
119
  }
77
120
 
78
121
  const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
@@ -439,7 +482,24 @@ export function packLights(
439
482
  for (const leaf of tree.leafOf) extra.push(leaf);
440
483
  }
441
484
 
485
+ const portals = environment && environment.power > 0 ? portalTriangles(geometry) : { triangles: [], lights: [] };
486
+ let portalTreeAt = 0, portalListAt = 0;
487
+
488
+ if (portals.triangles.length) {
489
+ const tree = buildLightTree(portals.lights);
490
+
491
+ portalTreeAt = base + extra.length;
492
+ portalListAt = portalTreeAt + tree.listAt;
493
+
494
+ // The leaves list window numbers; the kernel wants the triangles they stand for.
495
+ for (let k = tree.listAt; k < tree.words.length; k += 4) tree.words[k] = portals.triangles[tree.words[k]];
496
+ for (const word of tree.words) extra.push(word);
497
+ }
498
+
442
499
  return {
500
+ portalTreeAt,
501
+ portalListAt,
502
+ portalCount: portals.triangles.length,
443
503
  localCount,
444
504
  localPick: localCount > 0 ? floats[(localCount - 1) * LIGHT_WORDS + 1] : 0,
445
505
  treeAt,
@@ -0,0 +1,284 @@
1
+ import type { UNet } from "oidn-webgpu";
2
+ import { initUNetFromURL } from "oidn-webgpu/dist/oidn.js";
3
+ import { createShaderModule } from "./shaderModule.js";
4
+
5
+ /**
6
+ * The weights the OIDN denoiser loads by default: the HDR ray tracing network that takes the albedo and the normal as
7
+ * well as the colour (RenderKit's oidn-weights, Apache-2.0), shipped with the package. A bundler that moves the package
8
+ * elsewhere, or an app that serves its own copy, passes `denoiser.oidnWeights` instead.
9
+ */
10
+ export const DEFAULT_OIDN_WEIGHTS = new URL("../weights/rt_hdr_alb_nrm.tza", import.meta.url).href;
11
+
12
+ const WORKGROUP = 8;
13
+ const METER_GRID = [64, 36] as const;
14
+
15
+ /**
16
+ * Reads the accumulation into the three images OIDN takes, and back.
17
+ *
18
+ * The network was trained on images whose average sits near middle grey, which its own CPU path reaches by an
19
+ * automatic exposure; on the device that scale is left to the caller. `meter` takes the mean of the logarithms of the
20
+ * image's luminance over a grid, as the lens pass's meter does, `prepare` writes the colour times the scale that brings
21
+ * it to middle grey, the albedo (diffuse and specular, so a mirror is not taken for black) and the normal, and `finish`
22
+ * divides the network's output by the same scale and mixes it with the traced image by the strength.
23
+ */
24
+ export const OIDN_WGSL = /* wgsl */ `
25
+ struct Params {
26
+ size: vec2<u32>,
27
+ count: u32,
28
+ strength: f32,
29
+ };
30
+
31
+ @group(0) @binding(0) var<uniform> params: Params;
32
+ @group(0) @binding(1) var<storage, read> accumulation: array<vec4<f32>>;
33
+ @group(0) @binding(2) var<storage, read_write> color: array<vec4<f32>>;
34
+ @group(0) @binding(3) var<storage, read_write> albedo: array<vec4<f32>>;
35
+ @group(0) @binding(4) var<storage, read_write> normal: array<vec4<f32>>;
36
+ @group(0) @binding(5) var<storage, read_write> scale: array<f32>;
37
+
38
+ const MIDDLE_GREY: f32 = 0.18;
39
+ const GRID: vec2<u32> = vec2<u32>(${METER_GRID[0]}u, ${METER_GRID[1]}u);
40
+
41
+ var<workgroup> sums: array<vec2<f32>, 256>;
42
+
43
+ fn luminance(value: vec3<f32>) -> f32 {
44
+ return dot(value, vec3<f32>(0.2126, 0.7152, 0.0722));
45
+ }
46
+
47
+ fn mean(index: u32) -> vec3<f32> {
48
+ let stored = accumulation[index];
49
+
50
+ return select(vec3<f32>(0.0), stored.rgb / stored.w, stored.w > 0.0);
51
+ }
52
+
53
+ @compute @workgroup_size(256, 1, 1)
54
+ fn meter(@builtin(local_invocation_index) thread: u32) {
55
+ var sum = vec2<f32>(0.0);
56
+
57
+ for (var k = thread; k < GRID.x * GRID.y; k = k + 256u) {
58
+ let uv = (vec2<f32>(f32(k % GRID.x), f32(k / GRID.x)) + 0.5) / vec2<f32>(GRID);
59
+ let pixel = min(vec2<u32>(uv * vec2<f32>(params.size)), params.size - 1u);
60
+ let value = luminance(mean(pixel.y * params.size.x + pixel.x));
61
+
62
+ if (value > 0.0) {
63
+ sum = sum + vec2<f32>(log(value), 1.0);
64
+ }
65
+ }
66
+
67
+ sums[thread] = sum;
68
+ workgroupBarrier();
69
+
70
+ for (var stride = 128u; stride > 0u; stride = stride / 2u) {
71
+ if (thread < stride) {
72
+ sums[thread] = sums[thread] + sums[thread + stride];
73
+ }
74
+
75
+ workgroupBarrier();
76
+ }
77
+
78
+ if (thread == 0u) {
79
+ let total = sums[0];
80
+
81
+ scale[0] = select(1.0, clamp(MIDDLE_GREY / exp(total.x / total.y), 1e-6, 1e6), total.y > 0.0);
82
+ }
83
+ }
84
+
85
+ @compute @workgroup_size(${WORKGROUP}, ${WORKGROUP})
86
+ fn prepare(@builtin(global_invocation_id) id: vec3<u32>) {
87
+ if (id.x >= params.size.x || id.y >= params.size.y) {
88
+ return;
89
+ }
90
+
91
+ let index = id.y * params.size.x + id.x;
92
+ let samples = max(accumulation[index].w, 1.0);
93
+ let traced = mean(index) * scale[0];
94
+ let normalSum = accumulation[3u * params.count + index].xyz;
95
+ let size = length(normalSum);
96
+ let surface = size > 1e-6;
97
+ let reflectance = (accumulation[2u * params.count + index].rgb + accumulation[5u * params.count + index].rgb)
98
+ / samples;
99
+
100
+ color[index] = vec4<f32>(max(traced, vec3<f32>(0.0)), 1.0);
101
+ // Where the camera sees no surface, the sky, the albedo is the colour itself, as OIDN asks of the background.
102
+ albedo[index] = vec4<f32>(select(clamp(traced, vec3<f32>(0.0), vec3<f32>(1.0)),
103
+ clamp(reflectance, vec3<f32>(0.0), vec3<f32>(1.0)), surface), 1.0);
104
+ normal[index] = vec4<f32>(select(vec3<f32>(0.0), normalSum / size, surface), 0.0);
105
+ }
106
+
107
+ // The network's output arrives in the colour binding; the albedo binding takes the result.
108
+ @compute @workgroup_size(${WORKGROUP}, ${WORKGROUP})
109
+ fn finish(@builtin(global_invocation_id) id: vec3<u32>) {
110
+ if (id.x >= params.size.x || id.y >= params.size.y) {
111
+ return;
112
+ }
113
+
114
+ let index = id.y * params.size.x + id.x;
115
+ let traced = mean(index);
116
+ let denoised = max(color[index].rgb, vec3<f32>(0.0)) / scale[0];
117
+
118
+ albedo[index] = vec4<f32>(mix(traced, denoised, clamp(params.strength, 0.0, 1.0)), 1.0);
119
+ }
120
+ `;
121
+
122
+ const networks = new WeakMap<GPUDevice, Map<string, Promise<UNet>>>();
123
+
124
+ /** One network per device and weights file, kept for as long as the device lives: loading it compiles its kernels. */
125
+ function network(device: GPUDevice, weights: string): Promise<UNet> {
126
+ let byUrl = networks.get(device);
127
+
128
+ if (!byUrl) networks.set(device, byUrl = new Map());
129
+
130
+ let unet = byUrl.get(weights);
131
+
132
+ if (!unet) {
133
+ unet = initUNetFromURL(weights, { device }, { aux: true, hdr: true });
134
+ // A file that failed to load may load on a later attempt.
135
+ unet.catch(() => byUrl.delete(weights));
136
+ byUrl.set(weights, unet);
137
+ }
138
+
139
+ return unet;
140
+ }
141
+
142
+ /**
143
+ * Open Image Denoise (Intel's neural denoiser, through oidn-webgpu): the image, its albedo and its normal through a
144
+ * U-Net trained on path traced images, entirely on the device. It keeps texture and fine detail that a hand-made
145
+ * filter blurs, at the cost of a fraction of a second for a large image.
146
+ */
147
+ export class OidnPass {
148
+ private readonly layout: GPUBindGroupLayout;
149
+ private readonly meter: GPUComputePipeline;
150
+ private readonly prepare: GPUComputePipeline;
151
+ private readonly finish: GPUComputePipeline;
152
+ private readonly params: GPUBuffer;
153
+ private readonly scale: GPUBuffer;
154
+ private buffers: GPUBuffer[] = [];
155
+ private width = 0;
156
+ private height = 0;
157
+ /** The denoised radiance, one vec4 per pixel, once `run` has resolved. */
158
+ output: GPUBuffer | null = null;
159
+
160
+ constructor(private readonly device: GPUDevice) {
161
+ const module = createShaderModule(device, "oidn", OIDN_WGSL);
162
+ const visibility = GPUShaderStage.COMPUTE;
163
+ const storage = (type: GPUBufferBindingType) => ({ binding: 0, visibility, buffer: { type } });
164
+
165
+ this.layout = device.createBindGroupLayout({
166
+ entries: [
167
+ { binding: 0, visibility, buffer: { type: "uniform" } },
168
+ { ...storage("read-only-storage"), binding: 1 },
169
+ { ...storage("storage"), binding: 2 },
170
+ { ...storage("storage"), binding: 3 },
171
+ { ...storage("storage"), binding: 4 },
172
+ { ...storage("storage"), binding: 5 },
173
+ ],
174
+ });
175
+
176
+ const layout = device.createPipelineLayout({ bindGroupLayouts: [this.layout] });
177
+ const pipeline = (entryPoint: string) => device.createComputePipeline({ layout, compute: { module, entryPoint } });
178
+
179
+ this.meter = pipeline("meter");
180
+ this.prepare = pipeline("prepare");
181
+ this.finish = pipeline("finish");
182
+ this.params = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
183
+ this.scale = device.createBuffer({ size: 16, usage: GPUBufferUsage.STORAGE });
184
+ }
185
+
186
+ private resize(width: number, height: number) {
187
+ if (width === this.width && height === this.height && this.buffers.length) return;
188
+
189
+ for (const buffer of this.buffers) buffer.destroy();
190
+
191
+ const size = Math.max(16, width * height * 16);
192
+ const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST;
193
+
194
+ // Colour, albedo and normal for the network, then the result.
195
+ this.buffers = Array.from({ length: 4 }, () => this.device.createBuffer({ size, usage }));
196
+ this.output = this.buffers[3];
197
+ this.width = width;
198
+ this.height = height;
199
+ }
200
+
201
+ private group(accumulation: GPUBuffer, color: GPUBuffer, albedo: GPUBuffer, normal: GPUBuffer): GPUBindGroup {
202
+ return this.device.createBindGroup({
203
+ layout: this.layout,
204
+ entries: [
205
+ { binding: 0, resource: { buffer: this.params } },
206
+ { binding: 1, resource: { buffer: accumulation } },
207
+ { binding: 2, resource: { buffer: color } },
208
+ { binding: 3, resource: { buffer: albedo } },
209
+ { binding: 4, resource: { buffer: normal } },
210
+ { binding: 5, resource: { buffer: this.scale } },
211
+ ],
212
+ });
213
+ }
214
+
215
+ private dispatch(encoder: GPUCommandEncoder, pipeline: GPUComputePipeline, group: GPUBindGroup, x: number,
216
+ y: number) {
217
+ const pass = encoder.beginComputePass({ label: "oidn" });
218
+
219
+ pass.setPipeline(pipeline);
220
+ pass.setBindGroup(0, group);
221
+ pass.dispatchWorkgroups(x, y);
222
+ pass.end();
223
+ }
224
+
225
+ /**
226
+ * Denoises what `accumulation` holds into `output`. The images are read from it as it stands on the queue when this
227
+ * is called; the promise resolves once the result has been written, and rejects when the network cannot load.
228
+ */
229
+ async run(accumulation: GPUBuffer, width: number, height: number, strength: number, weights: string,
230
+ signal?: { cancelled: boolean }): Promise<boolean> {
231
+ const unet = await network(this.device, weights);
232
+
233
+ if (signal?.cancelled) return false;
234
+
235
+ this.resize(width, height);
236
+
237
+ const [color, albedo, normal, output] = this.buffers;
238
+ const values = new ArrayBuffer(16);
239
+
240
+ new Uint32Array(values).set([width, height, width * height]);
241
+ new Float32Array(values)[3] = strength;
242
+ this.device.queue.writeBuffer(this.params, 0, values);
243
+
244
+ const groups = [Math.ceil(width / WORKGROUP), Math.ceil(height / WORKGROUP)] as const;
245
+ const inputs = this.group(accumulation, color, albedo, normal);
246
+ const encoder = this.device.createCommandEncoder({ label: "oidn prepare" });
247
+
248
+ this.dispatch(encoder, this.meter, inputs, 1, 1);
249
+ this.dispatch(encoder, this.prepare, inputs, ...groups);
250
+ this.device.queue.submit([encoder.finish()]);
251
+
252
+ const denoised = await new Promise<GPUBuffer | null>((resolve, reject) => {
253
+ const abort = unet.tileExecute({
254
+ color: { data: color, width, height },
255
+ albedo: { data: albedo, width, height },
256
+ normal: { data: normal, width, height },
257
+ done: result => resolve(result.data),
258
+ error: reject,
259
+ progress: () => {
260
+ if (signal?.cancelled) {
261
+ abort();
262
+ resolve(null);
263
+ }
264
+ },
265
+ });
266
+ });
267
+
268
+ if (!denoised || signal?.cancelled) return false;
269
+
270
+ const finishing = this.device.createCommandEncoder({ label: "oidn finish" });
271
+
272
+ this.dispatch(finishing, this.finish, this.group(accumulation, denoised, output, normal), ...groups);
273
+ this.device.queue.submit([finishing.finish()]);
274
+
275
+ return true;
276
+ }
277
+
278
+ destroy() {
279
+ for (const buffer of [...this.buffers, this.params, this.scale]) buffer.destroy();
280
+
281
+ this.buffers = [];
282
+ this.output = null;
283
+ }
284
+ }
@@ -0,0 +1,5 @@
1
+ // The package's lib/ imports its modules without extensions, which only a bundler resolves; its dist/ is one ES module
2
+ // that a browser and Node both load, with the same exports as lib/main.
3
+ declare module "oidn-webgpu/dist/oidn.js" {
4
+ export * from "oidn-webgpu";
5
+ }
@@ -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
  }
@@ -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;
@@ -558,6 +577,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
558
577
  this.buffers?.destroy();
559
578
  this.accumulator?.destroy();
560
579
  this.denoisePass?.destroy();
580
+ this.oidnPass?.destroy();
561
581
  this.lensPass?.destroy();
562
582
  this.lensPass = null;
563
583
  this.imageResolve?.dispose();
@@ -738,14 +758,30 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
738
758
 
739
759
  // The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
740
760
  // 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
761
+ if (denoise && this.canvas && this.context && !this.oidnBusy && performance.now() - denoisedAt
742
762
  >= denoiseInterval(width, height, this.denoiserSettings.passes)) {
743
- const encoder = gpu.device.createCommandEncoder();
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();
744
779
 
745
- this.encodeDenoise(gpu, encoder, width, height);
746
- this.encodePresent(encoder, width, height);
747
- gpu.device.queue.submit([encoder.finish()]);
748
- 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
+ }
749
785
  }
750
786
 
751
787
  this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
@@ -766,14 +802,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
766
802
  this.check(revision);
767
803
 
768
804
  if (denoise) {
805
+ await this.denoiseNow(gpu, width, height);
806
+ this.check(revision);
807
+
769
808
  const encoder = gpu.device.createCommandEncoder();
770
809
 
771
- this.encodeDenoise(gpu, encoder, width, height);
772
810
  this.encodePresent(encoder, width, height);
773
811
  gpu.device.queue.submit([encoder.finish()]);
774
812
  }
775
813
 
776
- 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);
777
815
 
778
816
  this.check(revision);
779
817
  this.output = image;
@@ -831,7 +869,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
831
869
  && subsurfaceOf(material) !== null),
832
870
  // Even turned off, a volume's boundary has to stay invisible, which takes the same code.
833
871
  materials.some(material => volumeOf(material) !== null),
834
- 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)));
835
877
 
836
878
  // A procedural sky stands in for the renderer's own environment, as it does in the CPU renderer.
837
879
  const environment = assets.sky?.environment ?? image;
@@ -1071,10 +1113,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1071
1113
  toneMapping: this.current.toneMapping,
1072
1114
  vignette: this.current.vignette,
1073
1115
  grain: this.current.grain,
1116
+ contrast: lookContrast(this.current),
1074
1117
  frameIndex: this.frameIndex,
1075
1118
  transparent: this.current.background.mode === "transparent",
1076
1119
  denoised: this.encodeLens(this.gpu, encoder, width, height,
1077
- this.denoised ? this.denoisePass?.output ?? null : null),
1120
+ this.denoised ? this.denoiseOutput : null),
1078
1121
  });
1079
1122
  }
1080
1123
 
@@ -1110,10 +1153,67 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1110
1153
  this.denoisePass ??= new DenoisePass(gpu.device);
1111
1154
  this.denoisePass.encode(encoder, (this.accumulator as Accumulator).buffer, width, height, this.denoiserSettings,
1112
1155
  this.denoiseView);
1156
+ this.denoiseOutput = this.denoisePass.output;
1113
1157
  this.denoised = true;
1114
1158
  this.denoiseVersion++;
1115
1159
  }
1116
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
+ }
1215
+ }
1216
+
1117
1217
  private frameUniforms(
1118
1218
  camera: CameraState,
1119
1219
  width: number,
@@ -1201,6 +1301,9 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1201
1301
  lightTree: this.current.globalIllumination.lightTree === false ? 0 : buffers?.lightTree ?? 0,
1202
1302
  lightLeaves: buffers?.lightLeaves ?? 0,
1203
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,
1204
1307
  clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
1205
1308
  // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
1206
1309
  shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
@@ -1249,7 +1352,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1249
1352
  gpu.device.queue.submit([encoder.finish()]);
1250
1353
 
1251
1354
  const data = await this.imageResolve.read(accumulator.buffer, graded, width, height,
1252
- { exposure, toneMapping, vignette, transparent });
1355
+ { exposure, toneMapping, vignette, contrast: lookContrast(this.current), transparent });
1253
1356
 
1254
1357
  return { width, height, data };
1255
1358
  }