@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.
Files changed (51) hide show
  1. package/README.md +2 -1
  2. package/dist/frameLayout.d.ts.map +1 -1
  3. package/dist/frameLayout.js +2 -0
  4. package/dist/frameLayout.js.map +1 -1
  5. package/dist/lightTree.d.ts +10 -3
  6. package/dist/lightTree.d.ts.map +1 -1
  7. package/dist/lightTree.js +56 -17
  8. package/dist/lightTree.js.map +1 -1
  9. package/dist/lights.d.ts +2 -0
  10. package/dist/lights.d.ts.map +1 -1
  11. package/dist/lights.js +3 -1
  12. package/dist/lights.js.map +1 -1
  13. package/dist/renderer.d.ts +8 -0
  14. package/dist/renderer.d.ts.map +1 -1
  15. package/dist/renderer.js +39 -6
  16. package/dist/renderer.js.map +1 -1
  17. package/dist/sceneUpload.d.ts +1 -0
  18. package/dist/sceneUpload.d.ts.map +1 -1
  19. package/dist/sceneUpload.js +1 -0
  20. package/dist/sceneUpload.js.map +1 -1
  21. package/dist/settings.d.ts +6 -0
  22. package/dist/settings.d.ts.map +1 -1
  23. package/dist/settings.js +2 -2
  24. package/dist/settings.js.map +1 -1
  25. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  26. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  27. package/dist/wgsl/lens.wgsl.js +2 -16
  28. package/dist/wgsl/lens.wgsl.js.map +1 -1
  29. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  30. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  31. package/dist/wgsl/lights.wgsl.js +51 -17
  32. package/dist/wgsl/lights.wgsl.js.map +1 -1
  33. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  34. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  35. package/dist/wgsl/pathTrace.wgsl.js +51 -41
  36. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  37. package/dist/wgsl/random.wgsl.d.ts +1 -1
  38. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  39. package/dist/wgsl/random.wgsl.js +28 -0
  40. package/dist/wgsl/random.wgsl.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/frameLayout.ts +2 -0
  43. package/src/lightTree.ts +67 -21
  44. package/src/lights.ts +5 -1
  45. package/src/renderer.ts +44 -6
  46. package/src/sceneUpload.ts +2 -0
  47. package/src/settings.ts +8 -2
  48. package/src/wgsl/lens.wgsl.ts +2 -16
  49. package/src/wgsl/lights.wgsl.ts +51 -17
  50. package/src/wgsl/pathTrace.wgsl.ts +51 -41
  51. package/src/wgsl/random.wgsl.ts +28 -0
@@ -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
- /** The high bit of a node's first link marks a leaf, whose low bits are its light's index. */
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 light of a leaf, or -1. */
81
- light: number;
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, and the leaf each light ended in. Split at the median
87
- * of the longest axis of the centres, which needs no cost model and keeps the depth near log2 of the count.
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[]): { words: Uint32Array; leafOf: Uint32Array } {
103
+ export function buildLightTree(lights: readonly TreeLight[]): {
104
+ words: Uint32Array; leafOf: Uint32Array; listAt: number;
105
+ } {
90
106
  const nodes: Node[] = [];
91
- const leafOf = new Uint32Array(lights.length);
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, light: -1, density: 1 };
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
- if (indices.length === 1) {
119
- node.light = indices[0];
120
- node.density = lights[indices[0]].density;
121
- leafOf[indices[0]] = index;
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 extent = [0, 1, 2].map(k => {
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 = extent.indexOf(Math.max(...extent));
154
+ const axis = spread.indexOf(Math.max(...spread));
135
155
  const sorted = [...indices].sort((a, b) => centre(a, axis) - centre(b, axis));
136
- const half = sorted.length >> 1;
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 words = new Uint32Array(nodes.length * TREE_NODE_WORDS);
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.light >= 0) {
161
- words[at + 12] = (TREE_LEAF | node.light) >>> 0;
162
- floats[at + 13] = node.density;
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
- /** Throughput under which Russian roulette may end a path, with the chance of carrying on equal to the throughput. */
160
- const ROULETTE_THRESHOLD = 0.2;
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 >= DENOISE_EVERY_MS) {
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
 
@@ -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: 3,
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 },
@@ -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
- let halo = tentHalo(position * vec2<f32>(lens.haloSize) / size);
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
  }
@@ -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 radius = length(high.xyz - low.xyz) * 0.5;
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 <= radius * radius) {
82
- return low.w / max(radius * radius, 1e-12);
81
+ if (distance2 <= radius2) {
82
+ return low.w / max(radius2, 1e-12);
83
83
  }
84
84
 
85
- let distance = sqrt(distance2);
86
- let spread = asin(clamp(radius / distance, 0.0, 1.0));
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 theta = acos(clamp(dot(cone.xyz, offset / distance), -1.0, 1.0));
92
- let past = max(0.0, theta - acos(high.w) - spread);
93
-
94
- orientation = select(0.0, cos(past), past <= acos(clamp(cone.w, -1.0, 1.0)));
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 past = max(0.0, acos(clamp(dot(normal, -offset / distance), -1.0, 1.0)) - spread);
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
- facing = select(0.0, cos(past), past < 0.5 * PI);
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
- *pdf = chance;
135
- *density = extraFloat(at + 13u);
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 first & ~TREE_LEAF;
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
- var chance = extraFloat(frame.lightTree + node * TREE_NODE_WORDS + 13u);
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
- // Every vertex draws the same dimensions in the same order, whatever the path then does with them.
460
- let coverage = sample2D(rng);
461
- let pick = sample2D(rng);
462
- let lightSample = sample2D(rng);
463
- let lobe = sample2D(rng);
464
- let bounceSample = sample2D(rng);
465
- var mediumSample = vec2<f32>(0.0);
466
- var bevelSample = vec4<f32>(0.0);
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
- if (VOLUMES) {
469
- mediumSample = sample2D(rng);
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
- if (BEVEL) {
473
- bevelSample = vec4<f32>(sample2D(rng), sample2D(rng));
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
- surface.normal = bevelNormal(m, point, surface, -direction, materials[m].bevel, bevelSample.xy, bevelSample.zw);
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
 
@@ -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.