@tinypoly/gpu-renderer 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/ARCHITECTURE.md +81 -10
  2. package/README.md +53 -5
  3. package/dist/atmosphere.d.ts +21 -0
  4. package/dist/atmosphere.d.ts.map +1 -0
  5. package/dist/atmosphere.js +72 -0
  6. package/dist/atmosphere.js.map +1 -0
  7. package/dist/bvh.d.ts +11 -6
  8. package/dist/bvh.d.ts.map +1 -1
  9. package/dist/bvh.js +64 -11
  10. package/dist/bvh.js.map +1 -1
  11. package/dist/denoise.d.ts +17 -2
  12. package/dist/denoise.d.ts.map +1 -1
  13. package/dist/denoise.js +95 -27
  14. package/dist/denoise.js.map +1 -1
  15. package/dist/dispatchScheduler.d.ts +11 -4
  16. package/dist/dispatchScheduler.d.ts.map +1 -1
  17. package/dist/dispatchScheduler.js +14 -7
  18. package/dist/dispatchScheduler.js.map +1 -1
  19. package/dist/frameLayout.d.ts.map +1 -1
  20. package/dist/frameLayout.js +18 -0
  21. package/dist/frameLayout.js.map +1 -1
  22. package/dist/geometry.d.ts.map +1 -1
  23. package/dist/geometry.js +6 -3
  24. package/dist/geometry.js.map +1 -1
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/lensPass.d.ts +60 -0
  30. package/dist/lensPass.d.ts.map +1 -0
  31. package/dist/lensPass.js +235 -0
  32. package/dist/lensPass.js.map +1 -0
  33. package/dist/lightTree.d.ts +43 -0
  34. package/dist/lightTree.d.ts.map +1 -0
  35. package/dist/lightTree.js +107 -0
  36. package/dist/lightTree.js.map +1 -0
  37. package/dist/lights.d.ts +19 -1
  38. package/dist/lights.d.ts.map +1 -1
  39. package/dist/lights.js +63 -1
  40. package/dist/lights.js.map +1 -1
  41. package/dist/materialLayout.d.ts +24 -0
  42. package/dist/materialLayout.d.ts.map +1 -1
  43. package/dist/materialLayout.js +36 -1
  44. package/dist/materialLayout.js.map +1 -1
  45. package/dist/pathTracePass.d.ts +6 -3
  46. package/dist/pathTracePass.d.ts.map +1 -1
  47. package/dist/pathTracePass.js +16 -10
  48. package/dist/pathTracePass.js.map +1 -1
  49. package/dist/renderer.d.ts +19 -7
  50. package/dist/renderer.d.ts.map +1 -1
  51. package/dist/renderer.js +198 -36
  52. package/dist/renderer.js.map +1 -1
  53. package/dist/sceneUpload.d.ts +19 -1
  54. package/dist/sceneUpload.d.ts.map +1 -1
  55. package/dist/sceneUpload.js +25 -3
  56. package/dist/sceneUpload.js.map +1 -1
  57. package/dist/settings.d.ts +136 -0
  58. package/dist/settings.d.ts.map +1 -0
  59. package/dist/settings.js +100 -0
  60. package/dist/settings.js.map +1 -0
  61. package/dist/subsurface.d.ts +76 -0
  62. package/dist/subsurface.d.ts.map +1 -0
  63. package/dist/subsurface.js +70 -0
  64. package/dist/subsurface.js.map +1 -0
  65. package/dist/texturePack.d.ts +16 -4
  66. package/dist/texturePack.d.ts.map +1 -1
  67. package/dist/texturePack.js +61 -8
  68. package/dist/texturePack.js.map +1 -1
  69. package/dist/volumeContainment.d.ts +17 -0
  70. package/dist/volumeContainment.d.ts.map +1 -0
  71. package/dist/volumeContainment.js +61 -0
  72. package/dist/volumeContainment.js.map +1 -0
  73. package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
  74. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
  75. package/dist/wgsl/bsdf.wgsl.js +35 -3
  76. package/dist/wgsl/bsdf.wgsl.js.map +1 -1
  77. package/dist/wgsl/bvh.wgsl.d.ts +8 -2
  78. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
  79. package/dist/wgsl/bvh.wgsl.js +167 -96
  80. package/dist/wgsl/bvh.wgsl.js.map +1 -1
  81. package/dist/wgsl/common.wgsl.d.ts.map +1 -1
  82. package/dist/wgsl/common.wgsl.js +24 -7
  83. package/dist/wgsl/common.wgsl.js.map +1 -1
  84. package/dist/wgsl/lens.wgsl.d.ts +17 -0
  85. package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
  86. package/dist/wgsl/lens.wgsl.js +315 -0
  87. package/dist/wgsl/lens.wgsl.js.map +1 -0
  88. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  89. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  90. package/dist/wgsl/lights.wgsl.js +206 -33
  91. package/dist/wgsl/lights.wgsl.js.map +1 -1
  92. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  93. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  94. package/dist/wgsl/pathTrace.wgsl.js +410 -25
  95. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  96. package/dist/wgsl/random.wgsl.d.ts +1 -1
  97. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  98. package/dist/wgsl/random.wgsl.js +9 -17
  99. package/dist/wgsl/random.wgsl.js.map +1 -1
  100. package/dist/wgsl/sampling.wgsl.d.ts +1 -1
  101. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
  102. package/dist/wgsl/sampling.wgsl.js +25 -0
  103. package/dist/wgsl/sampling.wgsl.js.map +1 -1
  104. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  105. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  106. package/dist/wgsl/surface.wgsl.js +47 -4
  107. package/dist/wgsl/surface.wgsl.js.map +1 -1
  108. package/dist/wgsl/texture.wgsl.d.ts +1 -1
  109. package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
  110. package/dist/wgsl/texture.wgsl.js +10 -11
  111. package/dist/wgsl/texture.wgsl.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/atmosphere.ts +95 -0
  114. package/src/bvh.ts +81 -12
  115. package/src/denoise.ts +110 -27
  116. package/src/dispatchScheduler.ts +16 -7
  117. package/src/frameLayout.ts +18 -0
  118. package/src/geometry.ts +6 -2
  119. package/src/index.ts +13 -0
  120. package/src/lensPass.ts +299 -0
  121. package/src/lightTree.ts +172 -0
  122. package/src/lights.ts +90 -2
  123. package/src/materialLayout.ts +52 -1
  124. package/src/pathTracePass.ts +19 -10
  125. package/src/renderer.ts +258 -43
  126. package/src/sceneUpload.ts +41 -4
  127. package/src/settings.ts +234 -0
  128. package/src/subsurface.ts +153 -0
  129. package/src/texturePack.ts +80 -13
  130. package/src/volumeContainment.ts +81 -0
  131. package/src/wgsl/bsdf.wgsl.ts +35 -3
  132. package/src/wgsl/bvh.wgsl.ts +167 -96
  133. package/src/wgsl/common.wgsl.ts +24 -7
  134. package/src/wgsl/lens.wgsl.ts +314 -0
  135. package/src/wgsl/lights.wgsl.ts +206 -33
  136. package/src/wgsl/pathTrace.wgsl.ts +410 -25
  137. package/src/wgsl/random.wgsl.ts +9 -17
  138. package/src/wgsl/sampling.wgsl.ts +25 -0
  139. package/src/wgsl/surface.wgsl.ts +47 -4
  140. package/src/wgsl/texture.wgsl.ts +10 -11
package/src/bvh.ts CHANGED
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Binned SAH bounding volume hierarchy, built straight into the layout the shader reads: eight words per node, the
3
- * first four a minimum corner and a left child or leaf start, the last four a maximum corner and a right child or
4
- * triangle count, matching `struct BvhNode` in `wgsl/bvh.wgsl.ts`.
2
+ * Binned SAH bounding volume hierarchy. It is built as a binary tree of boxes, eight words a node, and then laid out
3
+ * the way the shader reads it: each node holds the boxes of its two children, so one read tells the traversal both
4
+ * what it may enter and where to go, and a leaf is tested without being read at all. That is sixteen words a node,
5
+ * matching `struct BvhNode` in `wgsl/common.wgsl.ts`: per child a minimum corner and a link, then a maximum corner and
6
+ * a triangle count. A link of zero or more is the node to go to; a negative one is a leaf, `-1 - start` into `order`.
5
7
  *
6
8
  * The bounds are computed from the same `Float32Array` the shader traverses, so every corner is already a float and
7
9
  * needs no widening. Building from double precision positions, as the CPU renderer does, would need each minimum
8
10
  * rounded down and each maximum rounded up before they reach the GPU.
9
11
  */
10
12
 
11
- /** Words per node in the packed buffer. */
12
- export const NODE_WORDS = 8;
13
+ /** Words per node in the packed buffer: both children's corners, links and counts. */
14
+ export const NODE_WORDS = 16;
15
+
16
+ /** Words per node of the binary tree the builder works on: its own corners, and its children or its leaf. */
17
+ const BINARY_WORDS = 8;
13
18
 
14
19
  /** Triangles a leaf may hold. */
15
20
  const MAX_LEAF_SIZE = 4;
@@ -27,7 +32,10 @@ const TRAVERSAL_COST = 0.125;
27
32
  export const MAX_BVH_DEPTH = 32;
28
33
 
29
34
  export interface PackedBvh {
30
- /** `nodeCount * NODE_WORDS` words, read as floats for the corners and as signed integers for the links. */
35
+ /**
36
+ * `nodeCount * NODE_WORDS` words, read as floats for the corners and as signed integers for the links. Node zero
37
+ * holds the root's two children, or the root itself beside an empty child when the whole scene is one leaf.
38
+ */
31
39
  nodes: ArrayBuffer;
32
40
  nodeCount: number;
33
41
  /** Triangle indices in leaf order: a leaf covers `order[start] .. order[start + count - 1]`. */
@@ -66,15 +74,15 @@ export function buildBvh(positions: Float32Array, triangleCount: number): Packed
66
74
 
67
75
  const { min, max, centroid } = triangleBounds(positions, triangleCount);
68
76
  // The heuristic may split down to one triangle per leaf, so the pool starts at the common case and grows.
69
- let nodes = new ArrayBuffer(Math.max(4, Math.ceil(triangleCount / 2)) * NODE_WORDS * 4);
77
+ let nodes = new ArrayBuffer(Math.max(4, Math.ceil(triangleCount / 2)) * BINARY_WORDS * 4);
70
78
  let floats = new Float32Array(nodes);
71
79
  let ints = new Int32Array(nodes);
72
80
 
73
81
  const reserve = (needed: number) => {
74
- if (needed * NODE_WORDS * 4 <= nodes.byteLength) return;
82
+ if (needed * BINARY_WORDS * 4 <= nodes.byteLength) return;
75
83
 
76
- const grown = new ArrayBuffer(Math.max(needed, Math.ceil(nodes.byteLength / (NODE_WORDS * 4) * 1.6))
77
- * NODE_WORDS * 4);
84
+ const grown = new ArrayBuffer(Math.max(needed, Math.ceil(nodes.byteLength / (BINARY_WORDS * 4) * 1.6))
85
+ * BINARY_WORDS * 4);
78
86
 
79
87
  new Float32Array(grown).set(floats);
80
88
  nodes = grown;
@@ -132,7 +140,7 @@ export function buildBvh(positions: Float32Array, triangleCount: number): Packed
132
140
  if (value > cMaxZ) cMaxZ = value;
133
141
  }
134
142
 
135
- const word = node * NODE_WORDS;
143
+ const word = node * BINARY_WORDS;
136
144
 
137
145
  floats[word] = minX;
138
146
  floats[word + 1] = minY;
@@ -180,7 +188,68 @@ export function buildBvh(positions: Float32Array, triangleCount: number): Packed
180
188
  top += 2;
181
189
  }
182
190
 
183
- return { nodes: nodes.slice(0, nodeCount * NODE_WORDS * 4), nodeCount, order, maxDepth };
191
+ return { ...widen(floats, ints, nodeCount), order, maxDepth };
192
+ }
193
+
194
+ /**
195
+ * The binary tree laid out for the shader: one node per interior node of it, holding both children's boxes. Interior
196
+ * nodes are numbered depth first, left before right, so a node and the child it leads to tend to share a cache line.
197
+ */
198
+ function widen(floats: Float32Array, ints: Int32Array, binaryCount: number): { nodes: ArrayBuffer; nodeCount: number } {
199
+ const leaf = (node: number) => ints[node * BINARY_WORDS + 3] < 0;
200
+ const rootLeaf = leaf(0);
201
+ // A full binary tree has one fewer interior node than leaves; a single leaf still needs a node to stand in.
202
+ const nodeCount = rootLeaf ? 1 : (binaryCount - 1) / 2;
203
+ const nodes = new ArrayBuffer(nodeCount * NODE_WORDS * 4);
204
+ const outFloats = new Float32Array(nodes);
205
+ const outInts = new Int32Array(nodes);
206
+ const index = new Int32Array(binaryCount);
207
+
208
+ const writeChild = (word: number, child: number) => {
209
+ const from = child * BINARY_WORDS;
210
+
211
+ for (let axis = 0; axis < 3; axis++) {
212
+ outFloats[word + axis] = floats[from + axis];
213
+ outFloats[word + 4 + axis] = floats[from + 4 + axis];
214
+ }
215
+
216
+ outInts[word + 3] = leaf(child) ? ints[from + 3] : index[child];
217
+ outInts[word + 7] = leaf(child) ? ints[from + 7] : 0;
218
+ };
219
+
220
+ if (rootLeaf) {
221
+ writeChild(0, 0);
222
+
223
+ // A box at the far corner of the float range, which no ray reaches, around a leaf of nothing.
224
+ for (let axis = 0; axis < 3; axis++) outFloats[8 + axis] = outFloats[12 + axis] = 3.4028234663852886e38;
225
+
226
+ outInts[11] = -1;
227
+ outInts[15] = 0;
228
+
229
+ return { nodes, nodeCount };
230
+ }
231
+
232
+ const interior: number[] = [];
233
+ const pending = [0];
234
+
235
+ while (pending.length) {
236
+ const node = pending.pop() as number;
237
+
238
+ index[node] = interior.length;
239
+ interior.push(node);
240
+
241
+ const left = ints[node * BINARY_WORDS + 3], right = ints[node * BINARY_WORDS + 7];
242
+
243
+ if (!leaf(right)) pending.push(right);
244
+ if (!leaf(left)) pending.push(left);
245
+ }
246
+
247
+ interior.forEach((node, at) => {
248
+ writeChild(at * NODE_WORDS, ints[node * BINARY_WORDS + 3]);
249
+ writeChild(at * NODE_WORDS + 8, ints[node * BINARY_WORDS + 7]);
250
+ });
251
+
252
+ return { nodes, nodeCount };
184
253
  }
185
254
 
186
255
  /** Surface area, the cost the split heuristic weighs each side by. Empty bounds cost nothing. */
package/src/denoise.ts CHANGED
@@ -15,6 +15,16 @@ export interface DenoiserSettings {
15
15
  onlyNoisy: boolean;
16
16
  /** Clamp a pixel brighter than every one of its neighbours to the brightest of them, before filtering. */
17
17
  antiFirefly: boolean;
18
+ /**
19
+ * How far the filter lets go of the normal and depth tests over a material that scatters light inside, from 0 to 1.
20
+ * Light leaves such a surface after wandering under it, so its shading hardly follows the normal at each point.
21
+ */
22
+ subsurface: number;
23
+ /**
24
+ * The smallest footprint a smooth reflection is filtered over, in pixels; 0 leaves a mirror alone. Within it, taps
25
+ * also have to agree on how far away the reflected thing is, so the edge of a reflected object stays sharp.
26
+ */
27
+ mirrorRadius: number;
18
28
  }
19
29
 
20
30
  export const DEFAULT_DENOISER: Readonly<DenoiserSettings> = Object.freeze({
@@ -23,6 +33,8 @@ export const DEFAULT_DENOISER: Readonly<DenoiserSettings> = Object.freeze({
23
33
  edge: 2,
24
34
  onlyNoisy: true,
25
35
  antiFirefly: true,
36
+ subsurface: 1,
37
+ mirrorRadius: 3,
26
38
  });
27
39
 
28
40
  /** The settings with `input` applied, each field kept in range. */
@@ -35,6 +47,8 @@ export function mergeDenoiser(base: DenoiserSettings, input: Partial<DenoiserSet
35
47
  edge: Math.max(0.01, Number(merged.edge) || DEFAULT_DENOISER.edge),
36
48
  onlyNoisy: Boolean(merged.onlyNoisy),
37
49
  antiFirefly: Boolean(merged.antiFirefly),
50
+ subsurface: Math.min(1, Math.max(0, Number(merged.subsurface) || 0)),
51
+ mirrorRadius: Math.min(16, Math.max(0, Number(merged.mirrorRadius) || 0)),
38
52
  };
39
53
  }
40
54
 
@@ -77,7 +91,8 @@ struct Params {
77
91
  converged: f32,
78
92
  pixelScale: f32,
79
93
  orthographic: u32,
80
- padding: vec2<u32>,
94
+ subsurface: f32,
95
+ mirrorRadius: f32,
81
96
  };
82
97
 
83
98
  @group(0) @binding(0) var<uniform> params: Params;
@@ -86,6 +101,10 @@ struct Params {
86
101
  @group(0) @binding(3) var<storage, read> specularIn: array<vec4<f32>>;
87
102
  @group(0) @binding(4) var<storage, read_write> diffuseOut: array<vec4<f32>>;
88
103
  @group(0) @binding(5) var<storage, read_write> specularOut: array<vec4<f32>>;
104
+ // The guides each tap compares, worked out once per pixel by prepare: the normal and the depth, then the roughness, or
105
+ // -1 where no sample has landed. The same numbers the accumulation gives, two reads a tap instead of four.
106
+ @group(0) @binding(6) var<storage, read_write> guides: array<vec4<f32>>;
107
+ @group(0) @binding(7) var<storage, read_write> roughnesses: array<f32>;
89
108
 
90
109
  fn luminance(color: vec3<f32>) -> f32 {
91
110
  return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
@@ -128,6 +147,11 @@ fn guideRoughness(index: u32) -> f32 {
128
147
  return accumulation[2u * params.count + index].w / samples(index);
129
148
  }
130
149
 
150
+ /** How much of the first surface scatters light inside, averaged over the pixel's samples. */
151
+ fn guideSubsurface(index: u32) -> f32 {
152
+ return accumulation[5u * params.count + index].w / samples(index);
153
+ }
154
+
131
155
  fn specularDistance(index: u32) -> f32 {
132
156
  return accumulation[4u * params.count + index].w / samples(index);
133
157
  }
@@ -153,10 +177,15 @@ fn prepare(@builtin(global_invocation_id) id: vec3<u32>) {
153
177
  if (count <= 0.0) {
154
178
  diffuseOut[index] = vec4<f32>(0.0);
155
179
  specularOut[index] = vec4<f32>(0.0);
180
+ guides[index] = vec4<f32>(0.0);
181
+ roughnesses[index] = -1.0;
156
182
 
157
183
  return;
158
184
  }
159
185
 
186
+ guides[index] = vec4<f32>(guideNormal(index), guideDepth(index));
187
+ roughnesses[index] = guideRoughness(index);
188
+
160
189
  let squares = accumulation[params.count + index];
161
190
  let diffuse = diffuseMean(index);
162
191
  let specular = specularMean(index);
@@ -269,14 +298,26 @@ fn atrous(@builtin(global_invocation_id) id: vec3<u32>) {
269
298
  return;
270
299
  }
271
300
 
272
- let normal = guideNormal(index);
273
- let depth = guideDepth(index);
274
- let roughness = guideRoughness(index);
275
- let diffuseSigma = params.edge * sqrt(max(localVariance(pixel, false), 0.0)) + 1e-6;
301
+ let guide = guides[index];
302
+ let normal = guide.xyz;
303
+ let depth = guide.w;
304
+ let roughness = roughnesses[index];
305
+ let diffuseSigma = params.edge * sqrt(max(localVariance(pixel, false), 0.0))
306
+ * (1.0 + guideSubsurface(index) * params.subsurface) + 1e-6;
276
307
  let specularSigma = params.edge * sqrt(max(localVariance(pixel, true), 0.0)) + 1e-6;
277
- let radius = specularRadius(index, depth);
278
- // A smoother surface's reflection turns faster with its normal: the normal test sharpens with it.
279
- let specularPower = min(128.0 / max(roughness * roughness, 0.03), 4096.0);
308
+ // A smooth reflection is filtered over at least the mirror radius, by taps that see the reflected thing at the same
309
+ // distance: that test keeps the edges of what it reflects, which lets the normal test relax over a curved mirror,
310
+ // where neighbouring normals part fast and would otherwise leave the reflection unfiltered.
311
+ let mirror = params.mirrorRadius > 0.0 && roughness < 0.3;
312
+ let radius = select(specularRadius(index, depth), max(specularRadius(index, depth), params.mirrorRadius), mirror);
313
+ let reflected = specularDistance(index);
314
+ // A smoother surface's reflection turns faster with its normal: the normal test sharpens with it, short of shutting
315
+ // out the neighbours of a curved mirror altogether once the reflected distance guards it.
316
+ let specularPower = min(128.0 / max(roughness * roughness, 0.03), select(4096.0, 64.0, mirror));
317
+ // Under a surface that scatters light inside, the normal and the depth say little about the light that leaves it.
318
+ let scattering = guideSubsurface(index) * params.subsurface;
319
+ let diffusePower = mix(128.0, 1.0, scattering);
320
+ let depthSlack = mix(1.0, 4.0, scattering);
280
321
  let step = i32(params.step);
281
322
  var diffuse = vec4<f32>(0.0);
282
323
  var specular = vec4<f32>(0.0);
@@ -299,21 +340,30 @@ fn atrous(@builtin(global_invocation_id) id: vec3<u32>) {
299
340
  var ws = base;
300
341
 
301
342
  if (dx != 0 || dy != 0) {
302
- if (accumulation[other].w <= 0.0) {
343
+ let otherRoughness = roughnesses[other];
344
+
345
+ // No sample has landed there.
346
+ if (otherRoughness < 0.0) {
303
347
  continue;
304
348
  }
305
349
 
350
+ let otherGuide = guides[other];
306
351
  let offset = length(vec2<f32>(f32(dx * step), f32(dy * step)));
307
- let alignment = max(dot(normal, guideNormal(other)), 0.0);
308
- let depthWeight = exp(-abs(depth - guideDepth(other)) / (0.02 * depth * offset + 1e-3));
352
+ let alignment = max(dot(normal, otherGuide.xyz), 0.0);
353
+ let depthWeight = exp(-abs(depth - otherGuide.w) / (0.02 * depth * offset + 1e-3));
354
+ let diffuseDepth = exp(-abs(depth - otherGuide.w) / (0.02 * depthSlack * depth * offset + 1e-3));
309
355
 
310
- wd = wd * pow(alignment, 128.0) * depthWeight
356
+ wd = wd * pow(alignment, diffusePower) * diffuseDepth
311
357
  * exp(-abs(luminance(d.rgb) - luminance(diffuseCenter.rgb)) / diffuseSigma);
312
358
 
313
359
  ws = ws * pow(alignment, specularPower) * depthWeight
314
- * exp(-abs(guideRoughness(other) - roughness) * 10.0)
360
+ * exp(-abs(otherRoughness - roughness) * 10.0)
315
361
  * exp(-abs(luminance(s.rgb) - luminance(specularCenter.rgb)) / specularSigma)
316
362
  * select(0.0, exp(-offset * offset / (radius * radius)), radius >= 0.5);
363
+
364
+ if (mirror) {
365
+ ws = ws * exp(-abs(specularDistance(other) - reflected) / (0.05 * reflected + 1e-3));
366
+ }
317
367
  }
318
368
 
319
369
  diffuse = diffuse + vec4<f32>(d.rgb * wd, d.w * wd * wd);
@@ -381,8 +431,21 @@ export class DenoisePass {
381
431
  private readonly finish: GPUComputePipeline;
382
432
  /** One uniform per stage, so a whole denoise is a single submit. */
383
433
  private readonly params: GPUBuffer[];
384
- /** Two ping-pong pairs, diffuse and specular, and the output. */
434
+ /** Two ping-pong pairs, diffuse and specular, the output, then the guides and the roughness. */
385
435
  private buffers: GPUBuffer[] = [];
436
+ /** Bind groups by the buffers they bind, and a number for each buffer seen, which is what the key is made of. */
437
+ private readonly groups = new Map<string, GPUBindGroup>();
438
+ private readonly ids = new WeakMap<GPUBuffer, number>();
439
+ private nextId = 0;
440
+
441
+ private idOf(buffer: GPUBuffer): number {
442
+ let id = this.ids.get(buffer);
443
+
444
+ if (id === undefined) this.ids.set(buffer, id = this.nextId++);
445
+
446
+ return id;
447
+ }
448
+
386
449
  private width = 0;
387
450
  private height = 0;
388
451
 
@@ -402,6 +465,8 @@ export class DenoisePass {
402
465
  { binding: 3, visibility, buffer: storage("read-only-storage") },
403
466
  { binding: 4, visibility, buffer: storage("storage") },
404
467
  { binding: 5, visibility, buffer: storage("storage") },
468
+ { binding: 6, visibility, buffer: storage("storage") },
469
+ { binding: 7, visibility, buffer: storage("storage") },
405
470
  ],
406
471
  });
407
472
 
@@ -423,9 +488,12 @@ export class DenoisePass {
423
488
 
424
489
  const size = Math.max(16, width * height * 16);
425
490
 
426
- this.buffers = Array.from({ length: 5 }, () =>
491
+ this.buffers = Array.from({ length: 6 }, () =>
427
492
  this.device.createBuffer({ size, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }));
493
+ this.buffers.push(
494
+ this.device.createBuffer({ size: Math.max(16, width * height * 4), usage: GPUBufferUsage.STORAGE }));
428
495
  this.output = this.buffers[4];
496
+ this.groups.clear();
429
497
  this.width = width;
430
498
  this.height = height;
431
499
  }
@@ -445,6 +513,8 @@ export class DenoisePass {
445
513
  floats[7] = CONVERGED_ERROR;
446
514
  floats[8] = view.pixelScale;
447
515
  words[9] = view.orthographic ? 1 : 0;
516
+ floats[10] = settings.subsurface;
517
+ floats[11] = settings.mirrorRadius;
448
518
  this.device.queue.writeBuffer(this.params[k], 0, values);
449
519
  });
450
520
  }
@@ -460,22 +530,34 @@ export class DenoisePass {
460
530
  this.resize(width, height);
461
531
  this.writeParams(width, height, settings, view);
462
532
 
463
- const [diffuseA, diffuseB, specularA, specularB, output] = this.buffers;
533
+ const [diffuseA, diffuseB, specularA, specularB, output, guides, roughnesses] = this.buffers;
464
534
  const groups = [Math.ceil(width / 8), Math.ceil(height / 8)] as const;
465
535
 
466
536
  const run = (pipeline: GPUComputePipeline, params: GPUBuffer,
467
537
  sources: [GPUBuffer, GPUBuffer], targets: [GPUBuffer, GPUBuffer]) => {
468
- const group = this.device.createBindGroup({
469
- layout: this.layout,
470
- entries: [
471
- { binding: 0, resource: { buffer: params } },
472
- { binding: 1, resource: { buffer: accumulation } },
473
- { binding: 2, resource: { buffer: sources[0] } },
474
- { binding: 3, resource: { buffer: sources[1] } },
475
- { binding: 4, resource: { buffer: targets[0] } },
476
- { binding: 5, resource: { buffer: targets[1] } },
477
- ],
478
- });
538
+ // The same few groups serve every denoise of an image size: they are made once and kept.
539
+ const key = [params, accumulation, ...sources, ...targets].map(buffer => this.idOf(buffer)).join();
540
+ let group = this.groups.get(key);
541
+
542
+ if (!group) {
543
+ group = this.device.createBindGroup({
544
+ layout: this.layout,
545
+ entries: [
546
+ { binding: 0, resource: { buffer: params } },
547
+ { binding: 1, resource: { buffer: accumulation } },
548
+ { binding: 2, resource: { buffer: sources[0] } },
549
+ { binding: 3, resource: { buffer: sources[1] } },
550
+ { binding: 4, resource: { buffer: targets[0] } },
551
+ { binding: 5, resource: { buffer: targets[1] } },
552
+ { binding: 6, resource: { buffer: guides } },
553
+ { binding: 7, resource: { buffer: roughnesses } },
554
+ ],
555
+ });
556
+ // A new accumulation buffer comes with a new size, which clears these; the cap is only a guard.
557
+ if (this.groups.size > 64) this.groups.clear();
558
+
559
+ this.groups.set(key, group);
560
+ }
479
561
 
480
562
  const pass = encoder.beginComputePass({ label: "denoise" });
481
563
 
@@ -509,6 +591,7 @@ export class DenoisePass {
509
591
  for (const buffer of [...this.buffers, ...this.params]) buffer.destroy();
510
592
 
511
593
  this.buffers = [];
594
+ this.groups.clear();
512
595
  this.output = null;
513
596
  }
514
597
  }
@@ -13,6 +13,8 @@ export interface DispatchSchedulerOptions {
13
13
  initialRows?: number;
14
14
  /** Rows in the whole image. */
15
15
  maxRows: number;
16
+ /** The most paths per pixel one submit may take, which is how far past the whole image a band may grow. */
17
+ maxSamples?: number;
16
18
  }
17
19
 
18
20
  /** The most one measurement may widen the band, so an unmeasured machine meets the watchdog gradually. */
@@ -24,20 +26,26 @@ export class DispatchScheduler {
24
26
  private readonly targetMs: number;
25
27
  private readonly initialRows: number;
26
28
  private readonly maxRows: number;
29
+ private readonly maxSamples: number;
30
+ /** The work of the next submit, in rows times paths per pixel. */
27
31
  private rows: number;
28
- /** Rows a millisecond, as far as anything has shown. Zero until the first submit says something. */
32
+ /** Rows a millisecond, one path each, as far as anything has shown. Zero until the first submit says something. */
29
33
  private throughput = 0;
30
34
 
31
35
  constructor(options: DispatchSchedulerOptions) {
32
36
  this.targetMs = options.targetMs ?? 12;
33
37
  this.maxRows = Math.max(1, Math.floor(options.maxRows));
38
+ this.maxSamples = Math.max(1, Math.floor(options.maxSamples ?? 1));
34
39
  this.initialRows = Math.max(1, Math.min(this.maxRows, Math.floor(options.initialRows ?? 16)));
35
40
  this.rows = this.initialRows;
36
41
  }
37
42
 
38
- /** Rows the next submit should cover, never more than what is left. */
39
- next(remaining: number): number {
40
- return Math.max(1, Math.min(Math.floor(this.rows), Math.floor(remaining)));
43
+ /**
44
+ * Rows the next submit should cover, never more than what is left. A submit that takes several paths per pixel
45
+ * covers that many times fewer rows, so it still takes about the target time.
46
+ */
47
+ next(remaining: number, samples = 1): number {
48
+ return Math.max(1, Math.min(Math.floor(this.rows / Math.max(1, samples)), Math.floor(remaining)));
41
49
  }
42
50
 
43
51
  /**
@@ -52,15 +60,16 @@ export class DispatchScheduler {
52
60
  * The rate is measured from the rows actually submitted rather than from the current guess, so one band clamped by
53
61
  * what was left of the image does not stop the next one from growing.
54
62
  */
55
- record(durationMs: number, rows: number) {
63
+ record(durationMs: number, rows: number, samples = 1) {
56
64
  if (!Number.isFinite(durationMs) || durationMs <= 0 || rows <= 0) return;
57
65
 
58
- const observed = rows / durationMs;
66
+ const work = rows * Math.max(1, samples);
67
+ const observed = work / durationMs;
59
68
 
60
69
  // Good news is believed at once, since it can only mean the band was cheaper than the clock suggested. Bad news
61
70
  // is taken gradually, so one submit that happened to land behind something else does not collapse the band.
62
71
  this.throughput = observed > this.throughput ? observed : this.throughput * DECAY + observed * (1 - DECAY);
63
- this.rows = Math.max(1, Math.min(this.maxRows, rows * GROW, this.targetMs * this.throughput));
72
+ this.rows = Math.max(1, Math.min(this.maxRows * this.maxSamples, work * GROW, this.targetMs * this.throughput));
64
73
  }
65
74
 
66
75
  /** Back to the unmeasured band, for a new scene or a new size. */
@@ -30,11 +30,18 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
30
30
  { name: "triangleCount", type: "u32" },
31
31
  { name: "lightCount", type: "u32" },
32
32
  { name: "areaLightCount", type: "u32", doc: "How many leading lights are emissive triangles." },
33
+ { name: "lightCdf", type: "u32", doc: "Where every light's cumulative probability starts in the texture buffer." },
34
+ { name: "lightTriangles", type: "u32", doc: "Where every emissive light's triangle starts in the texture buffer." },
35
+ { name: "localLightCount", type: "u32", doc: "How many leading lights sit in the scene, which the light tree picks." },
36
+ { name: "localLightPick", type: "f32", doc: "The chance of picking one of them, the last one's cumulative probability." },
37
+ { name: "lightTree", type: "u32", doc: "Where the light tree's nodes start in the texture buffer; zero picks by power alone." },
38
+ { name: "lightLeaves", type: "u32", doc: "Where the leaf of every local light starts in the texture buffer." },
33
39
  { name: "clampIndirect", type: "f32", doc: "Maximum luminance one indirect contribution may add; zero leaves it." },
34
40
  { name: "shadowSpread", type: "f32", doc: "Tangent of half the angular diameter shadow rays spread over." },
35
41
  { name: "shadowsEnabled", type: "u32" },
36
42
  { name: "uvOffset", type: "u32", doc: "Where the UV sets start inside the attribute buffer." },
37
43
  { name: "orderOffset", type: "u32", doc: "Where the hierarchy's triangle order starts inside the index buffer." },
44
+ { name: "leafOffset", type: "u32", doc: "Where each triangle's position in that order starts inside the index buffer." },
38
45
  { name: "colorOffset", type: "u32", doc: "Where vertex colours start inside the attribute buffer." },
39
46
  { name: "hasColors", type: "u32" },
40
47
  { name: "varyingOffset", type: "u32", doc: "Where shader varyings start inside the attribute buffer." },
@@ -53,6 +60,17 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
53
60
  { name: "volumetricData", type: "u32" },
54
61
  { name: "adaptiveMin", type: "u32", doc: "Samples a pixel takes before it may stop as converged; 0 never stops." },
55
62
  { name: "adaptiveThreshold", type: "f32", doc: "The error under which a pixel and its neighbours count as converged." },
63
+ { name: "lensDistortion", type: "f32", doc: "Radial distortion of the camera ray: positive is barrel, negative pincushion." },
64
+ { name: "rouletteThreshold", type: "f32", doc: "Throughput under which a path may be ended by chance; zero never." },
65
+ { name: "rouletteStart", type: "u32", doc: "Bounces every path takes before the roulette may end it." },
66
+ { name: "subsurfaceEnabled", type: "u32", doc: "Whether materials that scatter light inside do; 0 renders them opaque." },
67
+ { name: "bevelEnabled", type: "u32", doc: "Whether materials with a bevel radius round their edges." },
68
+ { name: "volumesEnabled", type: "u32", doc: "Whether volumes scatter light; 0 leaves their meshes out altogether." },
69
+ { name: "cameraVolume", type: "u32", doc: "The volume material the camera stands inside, or NO_MATERIAL." },
70
+ { name: "atmosphereColor", type: "vec4f", doc: "rgb is the radiance the air scatters towards the camera, w 1 when it is on." },
71
+ { name: "atmosphereExtinction", type: "vec4f", doc: "rgb is how much of each colour the air takes per scene unit, w the haze." },
72
+ { name: "atmosphereSun", type: "vec4f", doc: "xyz is the direction towards the brightest directional light." },
73
+ { name: "atmosphereSunColor", type: "vec4f", doc: "rgb is that light's irradiance, which the air scatters." },
56
74
  { name: "clipPlane", type: "vec4f", doc: "A plane the camera ray drops surfaces behind, for planar captures; w = 0 and xyz = 0 is none." },
57
75
  ]);
58
76
 
package/src/geometry.ts CHANGED
@@ -2,6 +2,7 @@ import { clipToWorld, transform4, worldToClip, type CameraMatrices } from "./cam
2
2
  import {
3
3
  isMaterialVisible,
4
4
  MATERIAL_KIND,
5
+ MATERIAL_UNLIT_LIGHT,
5
6
  materialProperties,
6
7
  NO_PLACEMENT,
7
8
  NO_PROGRAM,
@@ -525,9 +526,12 @@ export function packGeometry(
525
526
  materials: new Uint32Array(triangleMaterials),
526
527
  triangleCount: triangleMaterials.length,
527
528
  materialColors: new Float32Array(properties.flatMap(m => [...m.color, m.opacity])),
528
- // Only a lit or plainly emissive surface is a light: a shader's colour is not known until it runs.
529
+ // Only a lit or plainly emissive surface is a light: a shader's colour is not known until it runs. An unlit
530
+ // material of a fixed colour emits that colour, so it is one too.
529
531
  materialEmissive: new Float32Array(properties.flatMap(m =>
530
- m.kind === MATERIAL_KIND.shader ? [0, 0, 0, 0] : [...m.emissive, m.emissiveIntensity])),
532
+ m.kind === MATERIAL_KIND.shader ? [0, 0, 0, 0]
533
+ : (m.flags & MATERIAL_UNLIT_LIGHT) !== 0 ? [...m.color, 1]
534
+ : [...m.emissive, m.emissiveIntensity])),
531
535
  materialProperties: properties,
532
536
  materialPlacements: entries.map(entry => entry.placement),
533
537
  materialCount: properties.length,
package/src/index.ts CHANGED
@@ -21,6 +21,19 @@ export { attachCanvas } from "./canvas.js";
21
21
  export { DEFAULT_DENOISER } from "./denoise.js";
22
22
  export type { DenoiserSettings } from "./denoise.js";
23
23
 
24
+ // The settings only this renderer reads, beside the CPU renderer's, and the defaults it renders with.
25
+ export {
26
+ DEFAULT_GPU_RENDER_SETTINGS,
27
+ effectiveExposure,
28
+ mergeGpuSettings,
29
+ physicalExposure,
30
+ whiteBalanceMatrix,
31
+ } from "./settings.js";
32
+
33
+ export type { GpuRenderSettings, GpuRenderSettingsInput } from "./settings.js";
34
+ export { subsurfaceMedium } from "./subsurface.js";
35
+ export type { SubsurfaceInput, SubsurfaceMaterial, SubsurfaceMedium } from "./subsurface.js";
36
+
24
37
  export type { Bucket, Listener } from "./upstream.js";
25
38
 
26
39
  // Scene packing, published because the sizes it produces are what a host checks against a device's limits.