@tinypoly/gpu-renderer 0.1.0 → 0.1.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/README.md +2 -1
- package/dist/frameLayout.d.ts.map +1 -1
- package/dist/frameLayout.js +2 -0
- package/dist/frameLayout.js.map +1 -1
- package/dist/lightTree.d.ts +10 -3
- package/dist/lightTree.d.ts.map +1 -1
- package/dist/lightTree.js +56 -17
- package/dist/lightTree.js.map +1 -1
- package/dist/lights.d.ts +2 -0
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +3 -1
- package/dist/lights.js.map +1 -1
- package/dist/renderer.d.ts +8 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +39 -6
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +1 -0
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +1 -0
- package/dist/sceneUpload.js.map +1 -1
- package/dist/settings.d.ts +6 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +2 -2
- 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 +2 -16
- 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 +51 -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 +51 -41
- package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
- package/dist/wgsl/random.wgsl.d.ts +1 -1
- package/dist/wgsl/random.wgsl.d.ts.map +1 -1
- package/dist/wgsl/random.wgsl.js +28 -0
- package/dist/wgsl/random.wgsl.js.map +1 -1
- package/package.json +1 -1
- package/src/frameLayout.ts +2 -0
- package/src/lightTree.ts +67 -21
- package/src/lights.ts +5 -1
- package/src/renderer.ts +44 -6
- package/src/sceneUpload.ts +2 -0
- package/src/settings.ts +8 -2
- package/src/wgsl/lens.wgsl.ts +2 -16
- package/src/wgsl/lights.wgsl.ts +51 -17
- package/src/wgsl/pathTrace.wgsl.ts +51 -41
- package/src/wgsl/random.wgsl.ts +28 -0
package/src/frameLayout.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
|
|
|
35
35
|
{ name: "localLightCount", type: "u32", doc: "How many leading lights sit in the scene, which the light tree picks." },
|
|
36
36
|
{ name: "localLightPick", type: "f32", doc: "The chance of picking one of them, the last one's cumulative probability." },
|
|
37
37
|
{ name: "lightTree", type: "u32", doc: "Where the light tree's nodes start in the texture buffer; zero picks by power alone." },
|
|
38
|
+
{ name: "lightList", type: "u32", doc: "Where the lights of the tree's leaves are listed in the texture buffer." },
|
|
38
39
|
{ name: "lightLeaves", type: "u32", doc: "Where the leaf of every local light starts in the texture buffer." },
|
|
39
40
|
{ name: "clampIndirect", type: "f32", doc: "Maximum luminance one indirect contribution may add; zero leaves it." },
|
|
40
41
|
{ name: "shadowSpread", type: "f32", doc: "Tangent of half the angular diameter shadow rays spread over." },
|
|
@@ -63,6 +64,7 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
|
|
|
63
64
|
{ name: "lensDistortion", type: "f32", doc: "Radial distortion of the camera ray: positive is barrel, negative pincushion." },
|
|
64
65
|
{ name: "rouletteThreshold", type: "f32", doc: "Throughput under which a path may be ended by chance; zero never." },
|
|
65
66
|
{ name: "rouletteStart", type: "u32", doc: "Bounces every path takes before the roulette may end it." },
|
|
67
|
+
{ name: "subsurfaceDepth", type: "u32", doc: "Bounces at which light still walks inside a material; deeper it reflects." },
|
|
66
68
|
{ name: "subsurfaceEnabled", type: "u32", doc: "Whether materials that scatter light inside do; 0 renders them opaque." },
|
|
67
69
|
{ name: "bevelEnabled", type: "u32", doc: "Whether materials with a bevel radius round their edges." },
|
|
68
70
|
{ name: "volumesEnabled", type: "u32", doc: "Whether volumes scatter light; 0 leaves their meshes out altogether." },
|
package/src/lightTree.ts
CHANGED
|
@@ -24,9 +24,23 @@ export interface TreeLight {
|
|
|
24
24
|
/** Sixteen words a node: low and power, high and cos theta_o, axis and cos theta_e, then the links. */
|
|
25
25
|
export const TREE_NODE_WORDS = 16;
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The high bit of a node's first link marks a leaf, whose low bits say where its lights start in the list after the
|
|
29
|
+
* nodes; its second link says how many there are.
|
|
30
|
+
*/
|
|
28
31
|
export const TREE_LEAF = 0x80000000;
|
|
29
32
|
|
|
33
|
+
/** Four words a light in a leaf's list: the light, its chance within the leaf, its area density, the running chance. */
|
|
34
|
+
export const TREE_ENTRY_WORDS = 4;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A leaf holds up to this many lights, when they are so close together, against how far the lights spread, that from
|
|
38
|
+
* any point the tree could not tell them apart: the triangles of one small lamp. Lights further apart each keep a leaf,
|
|
39
|
+
* which keeps every choice between them by distance and facing.
|
|
40
|
+
*/
|
|
41
|
+
const LEAF_LIGHTS = 8;
|
|
42
|
+
const LEAF_EXTENT = 0.02;
|
|
43
|
+
|
|
30
44
|
export const TREE_ROOT_PARENT = 0xffffffff;
|
|
31
45
|
|
|
32
46
|
interface Cone {
|
|
@@ -77,18 +91,22 @@ interface Node {
|
|
|
77
91
|
left: number;
|
|
78
92
|
right: number;
|
|
79
93
|
parent: number;
|
|
80
|
-
/** The
|
|
81
|
-
|
|
82
|
-
density: number;
|
|
94
|
+
/** The lights of a leaf, or none. */
|
|
95
|
+
lights: number[];
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
/**
|
|
86
|
-
* The tree over these lights, as words for the texture buffer,
|
|
87
|
-
*
|
|
99
|
+
* The tree over these lights, as words for the texture buffer, the leaf and list slot each light ended in, and where
|
|
100
|
+
* the list of the leaves' lights starts. Split at the middle of the longest axis of the centres, which needs no cost
|
|
101
|
+
* model and keeps a tight group, such as one lamp's triangles, on one side to share a leaf.
|
|
88
102
|
*/
|
|
89
|
-
export function buildLightTree(lights: readonly TreeLight[]): {
|
|
103
|
+
export function buildLightTree(lights: readonly TreeLight[]): {
|
|
104
|
+
words: Uint32Array; leafOf: Uint32Array; listAt: number;
|
|
105
|
+
} {
|
|
90
106
|
const nodes: Node[] = [];
|
|
91
|
-
|
|
107
|
+
// Two words a light: its leaf, and where it sits in the list of lights after the nodes.
|
|
108
|
+
const leafOf = new Uint32Array(lights.length * 2);
|
|
109
|
+
let span = 0;
|
|
92
110
|
|
|
93
111
|
const build = (indices: number[], parent: number): number => {
|
|
94
112
|
const index = nodes.length;
|
|
@@ -111,29 +129,35 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
|
|
|
111
129
|
cone = cone ? unionCone(cone, own) : own;
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
const node: Node = { low, high, power, cone: cone as Cone, left: -1, right: -1, parent,
|
|
132
|
+
const node: Node = { low, high, power, cone: cone as Cone, left: -1, right: -1, parent, lights: [] };
|
|
115
133
|
|
|
116
134
|
nodes.push(node);
|
|
117
135
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
const extent = Math.hypot(high[0] - low[0], high[1] - low[1], high[2] - low[2]);
|
|
137
|
+
|
|
138
|
+
if (index === 0) span = extent;
|
|
139
|
+
|
|
140
|
+
if (indices.length === 1 || (indices.length <= LEAF_LIGHTS && extent <= span * LEAF_EXTENT)) {
|
|
141
|
+
node.lights = indices;
|
|
122
142
|
|
|
123
143
|
return index;
|
|
124
144
|
}
|
|
125
145
|
|
|
126
146
|
const centre = (i: number, k: number) => (lights[i].low[k] + lights[i].high[k]) / 2;
|
|
127
147
|
|
|
128
|
-
const
|
|
148
|
+
const spread = [0, 1, 2].map(k => {
|
|
129
149
|
const values = indices.map(i => centre(i, k));
|
|
130
150
|
|
|
131
151
|
return Math.max(...values) - Math.min(...values);
|
|
132
152
|
});
|
|
133
153
|
|
|
134
|
-
const axis =
|
|
154
|
+
const axis = spread.indexOf(Math.max(...spread));
|
|
135
155
|
const sorted = [...indices].sort((a, b) => centre(a, axis) - centre(b, axis));
|
|
136
|
-
|
|
156
|
+
// Split at the middle of the extent, which keeps a tight group of lights together on one side; the median only
|
|
157
|
+
// when that leaves a side empty, as lights all at one place do.
|
|
158
|
+
const middle = (centre(sorted[0], axis) + centre(sorted[sorted.length - 1], axis)) / 2;
|
|
159
|
+
const below = sorted.findIndex(i => centre(i, axis) > middle);
|
|
160
|
+
const half = below > 0 ? below : sorted.length >> 1;
|
|
137
161
|
|
|
138
162
|
node.left = build(sorted.slice(0, half), index);
|
|
139
163
|
node.right = build(sorted.slice(half), index);
|
|
@@ -143,8 +167,11 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
|
|
|
143
167
|
|
|
144
168
|
if (lights.length) build(lights.map((_, i) => i), TREE_ROOT_PARENT);
|
|
145
169
|
|
|
146
|
-
const
|
|
170
|
+
const listAt = nodes.length * TREE_NODE_WORDS;
|
|
171
|
+
const entries = nodes.reduce((sum, node) => sum + node.lights.length, 0);
|
|
172
|
+
const words = new Uint32Array(listAt + entries * TREE_ENTRY_WORDS);
|
|
147
173
|
const floats = new Float32Array(words.buffer);
|
|
174
|
+
let entry = 0;
|
|
148
175
|
|
|
149
176
|
nodes.forEach((node, k) => {
|
|
150
177
|
const at = k * TREE_NODE_WORDS;
|
|
@@ -157,9 +184,28 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
|
|
|
157
184
|
floats.set(node.cone.axis, at + 8);
|
|
158
185
|
floats[at + 11] = Math.cos(Math.min(node.cone.thetaE, Math.PI));
|
|
159
186
|
|
|
160
|
-
if (node.
|
|
161
|
-
words[at + 12] = (TREE_LEAF |
|
|
162
|
-
|
|
187
|
+
if (node.lights.length) {
|
|
188
|
+
words[at + 12] = (TREE_LEAF | entry) >>> 0;
|
|
189
|
+
words[at + 13] = node.lights.length;
|
|
190
|
+
|
|
191
|
+
// Within a leaf, by power: the lights are too close together for anything else to tell them apart.
|
|
192
|
+
const total = node.lights.reduce((sum, i) => sum + lights[i].power, 0);
|
|
193
|
+
let running = 0;
|
|
194
|
+
|
|
195
|
+
node.lights.forEach((light, j) => {
|
|
196
|
+
const chance = total > 0 ? lights[light].power / total : 1 / node.lights.length;
|
|
197
|
+
const slot = listAt + entry * TREE_ENTRY_WORDS;
|
|
198
|
+
|
|
199
|
+
running += chance;
|
|
200
|
+
words[slot] = light;
|
|
201
|
+
floats[slot + 1] = chance;
|
|
202
|
+
floats[slot + 2] = lights[light].density;
|
|
203
|
+
// The last closes the range exactly, so a unit sample never falls past it.
|
|
204
|
+
floats[slot + 3] = j === node.lights.length - 1 ? 1 : running;
|
|
205
|
+
leafOf[light * 2] = k;
|
|
206
|
+
leafOf[light * 2 + 1] = entry;
|
|
207
|
+
entry++;
|
|
208
|
+
});
|
|
163
209
|
} else {
|
|
164
210
|
words[at + 12] = node.left;
|
|
165
211
|
words[at + 13] = node.right;
|
|
@@ -168,5 +214,5 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
|
|
|
168
214
|
words[at + 14] = node.parent >>> 0;
|
|
169
215
|
});
|
|
170
216
|
|
|
171
|
-
return { words, leafOf };
|
|
217
|
+
return { words, leafOf, listAt };
|
|
172
218
|
}
|
package/src/lights.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface PackedLights {
|
|
|
71
71
|
/** Where the light tree's nodes start in the texture buffer, and the leaf of every local entry; zero for no tree. */
|
|
72
72
|
treeAt: number;
|
|
73
73
|
leavesAt: number;
|
|
74
|
+
/** Where the lights of the tree's leaves are listed in the texture buffer. */
|
|
75
|
+
listAt: number;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
|
|
@@ -425,12 +427,13 @@ export function packLights(
|
|
|
425
427
|
|
|
426
428
|
for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
|
|
427
429
|
|
|
428
|
-
let treeAt = 0, leavesAt = 0;
|
|
430
|
+
let treeAt = 0, leavesAt = 0, listAt = 0;
|
|
429
431
|
|
|
430
432
|
if (localCount > 0) {
|
|
431
433
|
const tree = buildLightTree(candidates.slice(0, localCount).map(candidate => candidate.tree as TreeLight));
|
|
432
434
|
|
|
433
435
|
treeAt = base + extra.length;
|
|
436
|
+
listAt = treeAt + tree.listAt;
|
|
434
437
|
for (const word of tree.words) extra.push(word);
|
|
435
438
|
leavesAt = base + extra.length;
|
|
436
439
|
for (const leaf of tree.leafOf) extra.push(leaf);
|
|
@@ -441,6 +444,7 @@ export function packLights(
|
|
|
441
444
|
localPick: localCount > 0 ? floats[(localCount - 1) * LIGHT_WORDS + 1] : 0,
|
|
442
445
|
treeAt,
|
|
443
446
|
leavesAt,
|
|
447
|
+
listAt,
|
|
444
448
|
cdfAt,
|
|
445
449
|
trianglesAt,
|
|
446
450
|
entries,
|
package/src/renderer.ts
CHANGED
|
@@ -156,12 +156,30 @@ const SHOW_EVERY_MS = 16;
|
|
|
156
156
|
*/
|
|
157
157
|
const BANDS_IN_FLIGHT = 2;
|
|
158
158
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Throughput under which Russian roulette may end a path, with the chance of carrying on equal to the throughput. One
|
|
161
|
+
* plays it on every path past the first bounces, as PBRT does: a path off a light wall carries on as long as it
|
|
162
|
+
* carries light, instead of seven bounces before the roulette ever looks at it. The mean stays exact.
|
|
163
|
+
*/
|
|
164
|
+
const ROULETTE_THRESHOLD = 1;
|
|
165
|
+
|
|
166
|
+
/** How often the lens pass grades the canvas again while a frame traces without the denoiser. */
|
|
167
|
+
const LENS_EVERY_MS = 100;
|
|
161
168
|
|
|
162
|
-
/** How often the canvas takes a freshly denoised image while a frame traces. */
|
|
169
|
+
/** How often the canvas takes a freshly denoised image while a frame traces, for an image of DENOISE_REFERENCE work. */
|
|
163
170
|
const DENOISE_EVERY_MS = 200;
|
|
164
171
|
|
|
172
|
+
/** The filtering of a 1080p image with three passes, which the interval above was set for. */
|
|
173
|
+
const DENOISE_REFERENCE = 1920 * 1080 * 3;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How long the canvas waits between denoised images: longer for a larger image or more passes, in proportion, so the
|
|
177
|
+
* filter takes the same share of the device from tracing whatever the size. The finished image is filtered regardless.
|
|
178
|
+
*/
|
|
179
|
+
export function denoiseInterval(width: number, height: number, passes: number): number {
|
|
180
|
+
return DENOISE_EVERY_MS * Math.max(1, width * height * Math.max(1, passes) / DENOISE_REFERENCE);
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
/** The most paths per pixel one dispatch takes, while no pixel can stop yet. */
|
|
166
184
|
const BATCH_PATHS = 4;
|
|
167
185
|
|
|
@@ -274,6 +292,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
274
292
|
private resolve: CaptureResolvePass | null = null;
|
|
275
293
|
private denoisePass: DenoisePass | null = null;
|
|
276
294
|
private lensPass: LensPass | null = null;
|
|
295
|
+
// What the lens pass last graded, and when: its input only changes when the denoiser runs or the accumulation grows.
|
|
296
|
+
private lensKey = "";
|
|
297
|
+
private lensAt = -Infinity;
|
|
298
|
+
private denoiseVersion = 0;
|
|
277
299
|
private denoiserSettings: DenoiserSettings = { ...DEFAULT_DENOISER };
|
|
278
300
|
// The size of the last frame that finished, which setDenoiser filters again; null while a frame traces.
|
|
279
301
|
private finished: { width: number; height: number } | null = null;
|
|
@@ -716,7 +738,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
716
738
|
|
|
717
739
|
// The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
|
|
718
740
|
// filtering the whole image after every pass would cost a cheap scene as much as tracing it.
|
|
719
|
-
if (denoise && this.canvas && this.context && performance.now() - denoisedAt
|
|
741
|
+
if (denoise && this.canvas && this.context && performance.now() - denoisedAt
|
|
742
|
+
>= denoiseInterval(width, height, this.denoiserSettings.passes)) {
|
|
720
743
|
const encoder = gpu.device.createCommandEncoder();
|
|
721
744
|
|
|
722
745
|
this.encodeDenoise(gpu, encoder, width, height);
|
|
@@ -1060,13 +1083,25 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1060
1083
|
* buffer of graded colour the display transform reads instead, or the input as it was.
|
|
1061
1084
|
*/
|
|
1062
1085
|
private encodeLens(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number,
|
|
1063
|
-
denoised: GPUBuffer | null): GPUBuffer | null {
|
|
1086
|
+
denoised: GPUBuffer | null, force = false): GPUBuffer | null {
|
|
1064
1087
|
const effects = lensEffects(this.current);
|
|
1065
1088
|
|
|
1066
1089
|
if (!effects) return denoised;
|
|
1067
1090
|
|
|
1068
1091
|
this.lensPass ??= new LensPass(gpu.device);
|
|
1069
1092
|
|
|
1093
|
+
// The canvas is drawn about once a display frame, but a denoised image only changes a few times a second: grading
|
|
1094
|
+
// the same one again would take the device from tracing for nothing. Straight from the accumulation, the image
|
|
1095
|
+
// grows with every pass, and is graded again at most every LENS_EVERY_MS.
|
|
1096
|
+
const key = JSON.stringify([this.revision, width, height, denoised ? this.denoiseVersion : -1, effects]);
|
|
1097
|
+
const now = performance.now();
|
|
1098
|
+
const fresh = key === this.lensKey && (denoised !== null || now - this.lensAt < LENS_EVERY_MS);
|
|
1099
|
+
|
|
1100
|
+
if (!force && fresh && this.lensPass.output) return this.lensPass.output;
|
|
1101
|
+
|
|
1102
|
+
this.lensKey = key;
|
|
1103
|
+
this.lensAt = now;
|
|
1104
|
+
|
|
1070
1105
|
return this.lensPass.encode(encoder, (this.accumulator as Accumulator).buffer, denoised, width, height, effects);
|
|
1071
1106
|
}
|
|
1072
1107
|
|
|
@@ -1076,6 +1111,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1076
1111
|
this.denoisePass.encode(encoder, (this.accumulator as Accumulator).buffer, width, height, this.denoiserSettings,
|
|
1077
1112
|
this.denoiseView);
|
|
1078
1113
|
this.denoised = true;
|
|
1114
|
+
this.denoiseVersion++;
|
|
1079
1115
|
}
|
|
1080
1116
|
|
|
1081
1117
|
private frameUniforms(
|
|
@@ -1145,6 +1181,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1145
1181
|
rouletteStart: Math.max(0, Math.round(this.current.globalIllumination.rouletteStart ?? 0)),
|
|
1146
1182
|
lensDistortion: Number.isFinite(this.current.lens.distortion) ? this.current.lens.distortion : 0,
|
|
1147
1183
|
subsurfaceEnabled: this.current.subsurface.enabled,
|
|
1184
|
+
subsurfaceDepth: Math.max(0, Math.round(Number(this.current.subsurface.depth ?? 2))),
|
|
1148
1185
|
bevelEnabled: this.current.bevel.enabled,
|
|
1149
1186
|
volumesEnabled: this.current.volumes.enabled,
|
|
1150
1187
|
cameraVolume: this.current.volumes.enabled
|
|
@@ -1163,6 +1200,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1163
1200
|
localLightPick: buffers?.localLightPick ?? 0,
|
|
1164
1201
|
lightTree: this.current.globalIllumination.lightTree === false ? 0 : buffers?.lightTree ?? 0,
|
|
1165
1202
|
lightLeaves: buffers?.lightLeaves ?? 0,
|
|
1203
|
+
lightList: buffers?.lightList ?? 0,
|
|
1166
1204
|
clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
|
|
1167
1205
|
// The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
|
|
1168
1206
|
shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
|
|
@@ -1206,7 +1244,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1206
1244
|
|
|
1207
1245
|
// The lens's effects are encoded on their own, ahead of the resolve, which reads the colour they leave.
|
|
1208
1246
|
const encoder = gpu.device.createCommandEncoder({ label: "lens" });
|
|
1209
|
-
const graded = this.encodeLens(gpu, encoder, width, height, denoised);
|
|
1247
|
+
const graded = this.encodeLens(gpu, encoder, width, height, denoised, true);
|
|
1210
1248
|
|
|
1211
1249
|
gpu.device.queue.submit([encoder.finish()]);
|
|
1212
1250
|
|
package/src/sceneUpload.ts
CHANGED
|
@@ -52,6 +52,7 @@ export interface SceneBuffers {
|
|
|
52
52
|
localLightPick: number;
|
|
53
53
|
lightTree: number;
|
|
54
54
|
lightLeaves: number;
|
|
55
|
+
lightList: number;
|
|
55
56
|
/** Radiance an ambient or hemisphere light adds, which no shadow ray can reach. */
|
|
56
57
|
ambient: PackedLights["ambient"];
|
|
57
58
|
/** Where the surrounding image and its distributions ended up, for the frame uniform. */
|
|
@@ -206,6 +207,7 @@ export function uploadScene(
|
|
|
206
207
|
localLightPick: lights.localPick,
|
|
207
208
|
lightTree: lights.treeAt,
|
|
208
209
|
lightLeaves: lights.leavesAt,
|
|
210
|
+
lightList: lights.listAt,
|
|
209
211
|
ambient: lights.ambient,
|
|
210
212
|
environment: {
|
|
211
213
|
texture: environment ? textures.indexOf.get(environment.texture.id) ?? NO_TEXTURE : NO_TEXTURE,
|
package/src/settings.ts
CHANGED
|
@@ -78,6 +78,12 @@ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIlluminat
|
|
|
78
78
|
subsurface: {
|
|
79
79
|
/** Materials with `userData.cpuRenderer.subsurface` scatter light inside themselves. Off renders them opaque. */
|
|
80
80
|
enabled: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Bounces at which light still walks inside such a material: past them it reflects off it as a diffuse surface of
|
|
83
|
+
* the same colour. Light that has already bounced that often hardly shows the difference, and every walk costs as
|
|
84
|
+
* much as many bounces.
|
|
85
|
+
*/
|
|
86
|
+
depth: number;
|
|
81
87
|
};
|
|
82
88
|
/**
|
|
83
89
|
* Aerial perspective: the air between the camera and what it sees, which fades distant things towards the colour of
|
|
@@ -123,13 +129,13 @@ export const DEFAULT_GPU_RENDER_SETTINGS: Readonly<GpuRenderSettings> = Object.f
|
|
|
123
129
|
...DEFAULT_RENDER_SETTINGS,
|
|
124
130
|
// AgX rolls highlights off as film does, where ACES pushes saturated colours to the gamut's edge.
|
|
125
131
|
toneMapping: "agx",
|
|
126
|
-
globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart:
|
|
132
|
+
globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart: 1,
|
|
127
133
|
lightTree: true },
|
|
128
134
|
bloom: { enabled: true, intensity: 0.04, radius: 0.65 },
|
|
129
135
|
glare: { enabled: true, intensity: 0.06, blades: 6, length: 0.25, rotation: 15 },
|
|
130
136
|
lens: { chromaticAberration: 0.2, distortion: 0 },
|
|
131
137
|
camera: { physicalExposure: false, iso: 100, shutterSpeed: 1 / 125, whiteBalance: 6500 },
|
|
132
|
-
subsurface: { enabled: true },
|
|
138
|
+
subsurface: { enabled: true, depth: 2 },
|
|
133
139
|
atmosphere: { enabled: true, visibility: 20000, haze: 0.6, color: "" },
|
|
134
140
|
volumes: { enabled: true },
|
|
135
141
|
bevel: { enabled: true },
|
package/src/wgsl/lens.wgsl.ts
CHANGED
|
@@ -260,21 +260,6 @@ fn streakBilinear(position: vec2<f32>) -> vec3<f32> {
|
|
|
260
260
|
return mix(mix(streakTexel(x, y), streakTexel(x + 1, y), b.z), mix(streakTexel(x, y + 1), streakTexel(x + 1, y + 1), b.z), b.w);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
/** The folded halo at full resolution: a tent over its largest level, which is half the image's size. */
|
|
264
|
-
fn tentHalo(position: vec2<f32>) -> vec3<f32> {
|
|
265
|
-
var sum = vec3<f32>(0.0);
|
|
266
|
-
|
|
267
|
-
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
|
268
|
-
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
|
269
|
-
let weight = f32((2 - abs(dx)) * (2 - abs(dy))) / 16.0;
|
|
270
|
-
|
|
271
|
-
sum = sum + upBilinear(lens.haloOffset, lens.haloSize, position + vec2<f32>(f32(dx), f32(dy)) * 0.5) * weight;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return sum;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
263
|
/** The image as the lens delivers it: fringed towards the corners, with its halo, balanced for the camera's white. */
|
|
279
264
|
@compute @workgroup_size(8, 8, 1)
|
|
280
265
|
fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
@@ -298,7 +283,8 @@ fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
|
298
283
|
}
|
|
299
284
|
|
|
300
285
|
if (lens.intensity > 0.0) {
|
|
301
|
-
|
|
286
|
+
// The largest folded level is already a tent over the levels below it, so one bilinear read of it is smooth.
|
|
287
|
+
let halo = upBilinear(lens.haloOffset, lens.haloSize, position * vec2<f32>(lens.haloSize) / size);
|
|
302
288
|
|
|
303
289
|
color = mix(color, halo, lens.intensity);
|
|
304
290
|
}
|
package/src/wgsl/lights.wgsl.ts
CHANGED
|
@@ -55,6 +55,7 @@ fn lightByProbability(u: f32) -> Light {
|
|
|
55
55
|
const TREE_NODE_WORDS: u32 = 16u;
|
|
56
56
|
const TREE_LEAF: u32 = 0x80000000u;
|
|
57
57
|
const TREE_ROOT_PARENT: u32 = 0xffffffffu;
|
|
58
|
+
const TREE_ENTRY_WORDS: u32 = 4u;
|
|
58
59
|
const NO_LIGHT: u32 = 0xffffffffu;
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -67,39 +68,58 @@ fn lightNodeImportance(node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32
|
|
|
67
68
|
let at = frame.lightTree + node * TREE_NODE_WORDS;
|
|
68
69
|
let low = extraVec4(at);
|
|
69
70
|
let high = extraVec4(at + 4u);
|
|
70
|
-
let cone = extraVec4(at + 8u);
|
|
71
71
|
|
|
72
72
|
if (low.w <= 0.0) {
|
|
73
73
|
return 0.0;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
let centre = (low.xyz + high.xyz) * 0.5;
|
|
77
|
-
let
|
|
77
|
+
let radius2 = dot(high.xyz - low.xyz, high.xyz - low.xyz) * 0.25;
|
|
78
78
|
let offset = position - centre;
|
|
79
79
|
let distance2 = dot(offset, offset);
|
|
80
80
|
|
|
81
|
-
if (distance2 <=
|
|
82
|
-
return low.w / max(
|
|
81
|
+
if (distance2 <= radius2) {
|
|
82
|
+
return low.w / max(radius2, 1e-12);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
let
|
|
86
|
-
|
|
85
|
+
let away = offset * inverseSqrt(distance2);
|
|
86
|
+
// The angle the box takes from the point, as its sine and cosine: the angles below are widened by it without a
|
|
87
|
+
// single inverse trigonometric function, by the sum and difference formulas.
|
|
88
|
+
let sinU2 = radius2 / distance2;
|
|
89
|
+
let cosU = sqrt(1.0 - sinU2);
|
|
90
|
+
let sinU = sqrt(sinU2);
|
|
87
91
|
var orientation = 1.0;
|
|
88
92
|
|
|
89
93
|
// A cone of minus one is the whole sphere: such lights shine towards every point alike.
|
|
90
94
|
if (high.w > -1.0) {
|
|
91
|
-
let
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
let cone = extraVec4(at + 8u);
|
|
96
|
+
let cosO = high.w;
|
|
97
|
+
let sinO = sqrt(max(0.0, 1.0 - cosO * cosO));
|
|
98
|
+
// The cone widened by the box: cos and sin of theta_o + theta_u, the whole sphere once that passes pi.
|
|
99
|
+
let cosWide = cosO * cosU - sinO * sinU;
|
|
100
|
+
let sinWide = sinO * cosU + cosO * sinU;
|
|
101
|
+
let cosTheta = dot(cone.xyz, away);
|
|
102
|
+
|
|
103
|
+
// cos(max(0, theta - theta_o - theta_u)), which is one while the point lies within the widened cone.
|
|
104
|
+
if (sinWide >= 0.0 && cosTheta < cosWide) {
|
|
105
|
+
let sinTheta = sqrt(max(0.0, 1.0 - cosTheta * cosTheta));
|
|
106
|
+
let cosPast = cosTheta * cosWide + sinTheta * sinWide;
|
|
107
|
+
|
|
108
|
+
orientation = select(0.0, cosPast, cosPast >= cone.w);
|
|
109
|
+
}
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
var facing = 1.0;
|
|
98
113
|
|
|
99
114
|
if (any(normal != vec3<f32>(0.0))) {
|
|
100
|
-
let
|
|
115
|
+
let cosN = -dot(normal, away);
|
|
116
|
+
|
|
117
|
+
// cos(max(0, theta_n - theta_u)), and nothing once that reaches a right angle.
|
|
118
|
+
if (cosN < cosU) {
|
|
119
|
+
let sinN = sqrt(max(0.0, 1.0 - cosN * cosN));
|
|
101
120
|
|
|
102
|
-
|
|
121
|
+
facing = max(0.0, cosN * cosU + sinN * sinU);
|
|
122
|
+
}
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
return low.w * orientation * facing / distance2;
|
|
@@ -130,11 +150,23 @@ fn sampleLightTree(position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<func
|
|
|
130
150
|
let at = frame.lightTree + node * TREE_NODE_WORDS;
|
|
131
151
|
let first = textures[at + 12u];
|
|
132
152
|
|
|
153
|
+
// A leaf lists its lights, too close together to tell apart from here: one is picked by its share of the power.
|
|
133
154
|
if ((first & TREE_LEAF) != 0u) {
|
|
134
|
-
|
|
135
|
-
|
|
155
|
+
let start = frame.lightList + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
|
|
156
|
+
let count = textures[at + 13u];
|
|
157
|
+
var slot = start + (count - 1u) * TREE_ENTRY_WORDS;
|
|
158
|
+
|
|
159
|
+
for (var j = 0u; j + 1u < count; j = j + 1u) {
|
|
160
|
+
if (x < extraFloat(start + j * TREE_ENTRY_WORDS + 3u)) {
|
|
161
|
+
slot = start + j * TREE_ENTRY_WORDS;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
*pdf = chance * extraFloat(slot + 1u);
|
|
167
|
+
*density = extraFloat(slot + 2u);
|
|
136
168
|
|
|
137
|
-
return
|
|
169
|
+
return textures[slot];
|
|
138
170
|
}
|
|
139
171
|
|
|
140
172
|
let second = textures[at + 13u];
|
|
@@ -162,8 +194,10 @@ fn sampleLightTree(position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<func
|
|
|
162
194
|
|
|
163
195
|
/** The chance the tree would have picked the local light at this index from this point, walked up from its leaf. */
|
|
164
196
|
fn lightTreePdf(index: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
|
|
165
|
-
var node = textures[frame.lightLeaves + index];
|
|
166
|
-
|
|
197
|
+
var node = textures[frame.lightLeaves + index * 2u];
|
|
198
|
+
let slot = frame.lightList + textures[frame.lightLeaves + index * 2u + 1u] * TREE_ENTRY_WORDS;
|
|
199
|
+
// Its share within its leaf, and the area density of a point on it.
|
|
200
|
+
var chance = extraFloat(slot + 1u) * extraFloat(slot + 2u);
|
|
167
201
|
|
|
168
202
|
for (var depth = 0u; depth < 64u; depth = depth + 1u) {
|
|
169
203
|
let parent = textures[frame.lightTree + node * TREE_NODE_WORDS + 14u];
|
|
@@ -456,55 +456,57 @@ fn traceRadiance(
|
|
|
456
456
|
for (var step = 0u; step < MAX_STEPS + MAX_WALK; step = step + 1u) {
|
|
457
457
|
let hit = traceScene(origin, direction, limit);
|
|
458
458
|
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
459
|
+
// A step of the walk inside a material that ends in the medium: the light scatters there, keeping each colour's
|
|
460
|
+
// share of it. It needs three numbers, which a plain hash gives for a fraction of what the sequence costs.
|
|
461
|
+
if (SUBSURFACE_WALK && inMedium && hit.triangle == NO_HIT) {
|
|
462
|
+
let travelled = exp(-sigma * limit);
|
|
463
|
+
let density = dot(walkOdds, sigma * travelled);
|
|
464
|
+
// A new direction, the next distance (a colour and how far), and the roulette's chance.
|
|
465
|
+
let turn = hashSample2D(rng);
|
|
466
|
+
let next = hashSample2D(rng);
|
|
467
|
+
let chance = hashSample2D(rng).x;
|
|
468
|
+
|
|
469
|
+
throughput = throughput * sigma * walkAlbedo * travelled / max(density, 1e-20);
|
|
470
|
+
walkSteps = walkSteps + 1u;
|
|
471
|
+
|
|
472
|
+
let survival = max3(throughput);
|
|
473
|
+
|
|
474
|
+
if (walkSteps >= MAX_WALK || survival <= 0.0) {
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
467
477
|
|
|
468
|
-
|
|
469
|
-
|
|
478
|
+
if (survival < 0.2) {
|
|
479
|
+
if (chance > survival) {
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
throughput = throughput / survival;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
origin = origin + direction * limit;
|
|
487
|
+
direction = sampleHenyeyGreenstein(direction, walkG, turn);
|
|
488
|
+
limit = walkDistance(throughput, sigma, next, &walkOdds);
|
|
489
|
+
continue;
|
|
470
490
|
}
|
|
471
491
|
|
|
472
|
-
|
|
473
|
-
|
|
492
|
+
// Every vertex draws the same dimensions in the same order, whatever the path then does with them: from the
|
|
493
|
+
// sequence for the first vertices, from a hash past SEQUENCE_DEPTH bounces.
|
|
494
|
+
let deep = bounce >= SEQUENCE_DEPTH;
|
|
495
|
+
let coverage = pathSample2D(rng, deep);
|
|
496
|
+
let pick = pathSample2D(rng, deep);
|
|
497
|
+
let lightSample = pathSample2D(rng, deep);
|
|
498
|
+
let lobe = pathSample2D(rng, deep);
|
|
499
|
+
let bounceSample = pathSample2D(rng, deep);
|
|
500
|
+
var mediumSample = vec2<f32>(0.0);
|
|
501
|
+
|
|
502
|
+
if (VOLUMES) {
|
|
503
|
+
mediumSample = pathSample2D(rng, deep);
|
|
474
504
|
}
|
|
475
505
|
|
|
476
506
|
// The walk left the material here, and the surface below is its way out rather than what the material shows.
|
|
477
507
|
var exiting = false;
|
|
478
508
|
|
|
479
509
|
if (SUBSURFACE_WALK && inMedium) {
|
|
480
|
-
if (hit.triangle == NO_HIT) {
|
|
481
|
-
// Nothing within the drawn distance: the light scatters there, keeping each colour's share of the medium.
|
|
482
|
-
let travelled = exp(-sigma * limit);
|
|
483
|
-
let density = dot(walkOdds, sigma * travelled);
|
|
484
|
-
|
|
485
|
-
throughput = throughput * sigma * walkAlbedo * travelled / max(density, 1e-20);
|
|
486
|
-
walkSteps = walkSteps + 1u;
|
|
487
|
-
|
|
488
|
-
let survival = max3(throughput);
|
|
489
|
-
|
|
490
|
-
if (walkSteps >= MAX_WALK || survival <= 0.0) {
|
|
491
|
-
break;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
if (survival < 0.2) {
|
|
495
|
-
if (coverage.x > survival) {
|
|
496
|
-
break;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
throughput = throughput / survival;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
origin = origin + direction * limit;
|
|
503
|
-
direction = sampleHenyeyGreenstein(direction, walkG, lobe);
|
|
504
|
-
limit = walkDistance(throughput, sigma, pick, &walkOdds);
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
510
|
// The walk reached the surface before scattering again: the chance of travelling that far, per colour.
|
|
509
511
|
let travelled = exp(-sigma * hit.distance);
|
|
510
512
|
|
|
@@ -631,8 +633,16 @@ fn traceRadiance(
|
|
|
631
633
|
|
|
632
634
|
surface = resolveSurfaceFrom(hit.triangle, bary, direction, base);
|
|
633
635
|
|
|
636
|
+
// Deep in the path, light reflects off a material that scatters inside as off a diffuse one of the same colour.
|
|
637
|
+
if (SUBSURFACE_WALK && bounce >= frame.subsurfaceDepth) {
|
|
638
|
+
surface.subsurface = 0.0;
|
|
639
|
+
}
|
|
640
|
+
|
|
634
641
|
if (BEVEL && frame.bevelEnabled != 0u && materials[m].bevel > 0.0) {
|
|
635
|
-
|
|
642
|
+
let disk = hashSample2D(rng);
|
|
643
|
+
let axis = hashSample2D(rng);
|
|
644
|
+
|
|
645
|
+
surface.normal = bevelNormal(m, point, surface, -direction, materials[m].bevel, disk, axis);
|
|
636
646
|
}
|
|
637
647
|
}
|
|
638
648
|
|
package/src/wgsl/random.wgsl.ts
CHANGED
|
@@ -64,6 +64,34 @@ fn toUnit(value: u32) -> f32 {
|
|
|
64
64
|
return min(f32(value) * 2.3283064365386963e-10, 0.99999994);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The next pair of dimensions from a plain hash instead of the scrambled sequence: for the deep dimensions of a random
|
|
69
|
+
* walk, where stratification buys nothing and the sequence's table reads and scrambles cost a step more than its ray.
|
|
70
|
+
* It takes a dimension of its own, so the sequence's draws after it stay where they would have been.
|
|
71
|
+
*/
|
|
72
|
+
fn hashSample2D(rng: ptr<function, PathSampler>) -> vec2<f32> {
|
|
73
|
+
let first = pcgHash((*rng).seed ^ pcgHash((*rng).index * 2654435761u + (*rng).dimension * 0x9e3779b9u));
|
|
74
|
+
(*rng).dimension = (*rng).dimension + 1u;
|
|
75
|
+
|
|
76
|
+
return vec2<f32>(toUnit(first), toUnit(pcgHash(first)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How many vertices of a path draw from the scrambled sequence. Its stratification shows on the camera ray and the
|
|
81
|
+
* first bounces, where the light a pixel sees is decided; past them the light has been averaged by every bounce
|
|
82
|
+
* before, and a plain hash gives numbers as good for a fraction of the cost.
|
|
83
|
+
*/
|
|
84
|
+
const SEQUENCE_DEPTH: u32 = 3u;
|
|
85
|
+
|
|
86
|
+
/** The next pair of dimensions of a path's vertex: from the sequence while the path is shallow, from a hash after. */
|
|
87
|
+
fn pathSample2D(rng: ptr<function, PathSampler>, deep: bool) -> vec2<f32> {
|
|
88
|
+
if (deep) {
|
|
89
|
+
return hashSample2D(rng);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return sample2D(rng);
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
/**
|
|
68
96
|
* The next pair of dimensions, in [0, 1). The sample index is shuffled per pair as well, which pads the two dimensional
|
|
69
97
|
* sequence out to as many dimensions as a path needs without correlating them.
|