@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.
- 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 +12 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +100 -14
- 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 +118 -15
- 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/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,
|
package/src/oidnPass.ts
ADDED
|
@@ -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
|
+
}
|
package/src/pathTracePass.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/presentPass.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
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.
|
|
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
|
}
|