@tinypoly/gpu-renderer 0.1.1 → 0.2.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 (89) hide show
  1. package/ARCHITECTURE.md +31 -3
  2. package/README.md +11 -3
  3. package/dist/denoise.d.ts +10 -1
  4. package/dist/denoise.d.ts.map +1 -1
  5. package/dist/denoise.js +4 -0
  6. package/dist/denoise.js.map +1 -1
  7. package/dist/device.d.ts.map +1 -1
  8. package/dist/device.js +8 -0
  9. package/dist/device.js.map +1 -1
  10. package/dist/frameLayout.d.ts.map +1 -1
  11. package/dist/frameLayout.js +4 -1
  12. package/dist/frameLayout.js.map +1 -1
  13. package/dist/imageResolve.d.ts +2 -1
  14. package/dist/imageResolve.d.ts.map +1 -1
  15. package/dist/imageResolve.js +1 -0
  16. package/dist/imageResolve.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lensPass.d.ts +4 -0
  22. package/dist/lensPass.d.ts.map +1 -1
  23. package/dist/lensPass.js +14 -1
  24. package/dist/lensPass.js.map +1 -1
  25. package/dist/lights.d.ts +18 -0
  26. package/dist/lights.d.ts.map +1 -1
  27. package/dist/lights.js +45 -0
  28. package/dist/lights.js.map +1 -1
  29. package/dist/oidnPass.d.ts +48 -0
  30. package/dist/oidnPass.d.ts.map +1 -0
  31. package/dist/oidnPass.js +255 -0
  32. package/dist/oidnPass.js.map +1 -0
  33. package/dist/pathTracePass.d.ts +2 -2
  34. package/dist/pathTracePass.d.ts.map +1 -1
  35. package/dist/pathTracePass.js +9 -9
  36. package/dist/pathTracePass.js.map +1 -1
  37. package/dist/presentPass.d.ts +2 -0
  38. package/dist/presentPass.d.ts.map +1 -1
  39. package/dist/presentPass.js +1 -0
  40. package/dist/presentPass.js.map +1 -1
  41. package/dist/renderer.d.ts +16 -1
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +111 -15
  44. package/dist/renderer.js.map +1 -1
  45. package/dist/sceneUpload.d.ts +4 -0
  46. package/dist/sceneUpload.d.ts.map +1 -1
  47. package/dist/sceneUpload.js +3 -0
  48. package/dist/sceneUpload.js.map +1 -1
  49. package/dist/settings.d.ts +23 -1
  50. package/dist/settings.d.ts.map +1 -1
  51. package/dist/settings.js +13 -4
  52. package/dist/settings.js.map +1 -1
  53. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  54. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  55. package/dist/wgsl/lens.wgsl.js +108 -1
  56. package/dist/wgsl/lens.wgsl.js.map +1 -1
  57. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  58. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/lights.wgsl.js +147 -17
  60. package/dist/wgsl/lights.wgsl.js.map +1 -1
  61. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  62. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/pathTrace.wgsl.js +123 -91
  64. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  65. package/dist/wgsl/present.wgsl.d.ts +1 -1
  66. package/dist/wgsl/present.wgsl.d.ts.map +1 -1
  67. package/dist/wgsl/present.wgsl.js +11 -1
  68. package/dist/wgsl/present.wgsl.js.map +1 -1
  69. package/package.json +4 -2
  70. package/src/denoise.ts +15 -1
  71. package/src/device.ts +9 -0
  72. package/src/frameLayout.ts +4 -1
  73. package/src/imageResolve.ts +2 -0
  74. package/src/index.ts +2 -1
  75. package/src/lensPass.ts +17 -1
  76. package/src/lights.ts +60 -0
  77. package/src/oidnPass.ts +284 -0
  78. package/src/oidnWebgpu.d.ts +5 -0
  79. package/src/pathTracePass.ts +11 -9
  80. package/src/presentPass.ts +3 -0
  81. package/src/renderer.ts +133 -16
  82. package/src/sceneUpload.ts +7 -0
  83. package/src/settings.ts +32 -4
  84. package/src/wgsl/lens.wgsl.ts +108 -1
  85. package/src/wgsl/lights.wgsl.ts +147 -17
  86. package/src/wgsl/pathTrace.wgsl.ts +123 -91
  87. package/src/wgsl/present.wgsl.ts +11 -1
  88. package/weights/LICENSE.txt +202 -0
  89. package/weights/rt_hdr_alb_nrm.tza +0 -0
@@ -64,8 +64,8 @@ const NO_LIGHT: u32 = 0xffffffffu;
64
64
  * angle the node's box takes from the point (Conty Estevez and Kulla's importance). A point inside the box rules
65
65
  * nothing out. A zero normal is a receiver that takes light from everywhere, such as a point in a volume.
66
66
  */
67
- fn lightNodeImportance(node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
68
- let at = frame.lightTree + node * TREE_NODE_WORDS;
67
+ fn lightNodeImportance(tree: u32, node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
68
+ let at = tree + node * TREE_NODE_WORDS;
69
69
  let low = extraVec4(at);
70
70
  let high = extraVec4(at + 4u);
71
71
 
@@ -125,13 +125,13 @@ fn lightNodeImportance(node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32
125
125
  return low.w * orientation * facing / distance2;
126
126
  }
127
127
 
128
- /** The chance of stepping from a node of the tree to each of its children, from this point. */
129
- fn lightChildOdds(left: u32, right: u32, position: vec3<f32>, normal: vec3<f32>) -> vec2<f32> {
128
+ /** The chance of stepping from a node of a tree (the lights' or the windows') to each of its children, from here. */
129
+ fn lightChildOdds(tree: u32, left: u32, right: u32, position: vec3<f32>, normal: vec3<f32>) -> vec2<f32> {
130
130
  var importance = vec2<f32>(0.0);
131
131
 
132
132
  // One loop over the two children, so the importance is written into the kernel once per walk.
133
133
  for (var k = 0u; k < 2u; k = k + 1u) {
134
- importance[k] = lightNodeImportance(select(right, left, k == 0u), position, normal);
134
+ importance[k] = lightNodeImportance(tree, select(right, left, k == 0u), position, normal);
135
135
  }
136
136
 
137
137
  let total = importance.x + importance.y;
@@ -139,20 +139,23 @@ fn lightChildOdds(left: u32, right: u32, position: vec3<f32>, normal: vec3<f32>)
139
139
  return select(importance / total, vec2<f32>(0.0), total <= 0.0);
140
140
  }
141
141
 
142
- /** A light picked down the tree by importance from this point, with the chance of picking it; NO_LIGHT for none. */
143
- fn sampleLightTree(position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<function, f32>,
142
+ /**
143
+ * An entry picked down a tree by importance from this point, with the chance of picking it; NO_LIGHT for none. The
144
+ * tree is the lights' or the windows', given by where its nodes and its leaves' list start.
145
+ */
146
+ fn sampleLightTree(tree: u32, list: u32, position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<function, f32>,
144
147
  density: ptr<function, f32>) -> u32 {
145
148
  var node = 0u;
146
149
  var chance = 1.0;
147
150
  var x = u;
148
151
 
149
152
  for (var depth = 0u; depth < 64u; depth = depth + 1u) {
150
- let at = frame.lightTree + node * TREE_NODE_WORDS;
153
+ let at = tree + node * TREE_NODE_WORDS;
151
154
  let first = textures[at + 12u];
152
155
 
153
156
  // A leaf lists its lights, too close together to tell apart from here: one is picked by its share of the power.
154
157
  if ((first & TREE_LEAF) != 0u) {
155
- let start = frame.lightList + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
158
+ let start = list + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
156
159
  let count = textures[at + 13u];
157
160
  var slot = start + (count - 1u) * TREE_ENTRY_WORDS;
158
161
 
@@ -170,7 +173,7 @@ fn sampleLightTree(position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<func
170
173
  }
171
174
 
172
175
  let second = textures[at + 13u];
173
- let odds = lightChildOdds(first, second, position, normal);
176
+ let odds = lightChildOdds(tree, first, second, position, normal);
174
177
 
175
178
  if (odds.x + odds.y <= 0.0) {
176
179
  return NO_LIGHT;
@@ -208,7 +211,7 @@ fn lightTreePdf(index: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
208
211
 
209
212
  let at = frame.lightTree + parent * TREE_NODE_WORDS;
210
213
  let first = textures[at + 12u];
211
- let odds = lightChildOdds(first, textures[at + 13u], position, normal);
214
+ let odds = lightChildOdds(frame.lightTree, first, textures[at + 13u], position, normal);
212
215
 
213
216
  chance = chance * select(odds.y, odds.x, node == first);
214
217
  node = parent;
@@ -252,6 +255,119 @@ fn lightPdfOfTriangle(triangle: u32, position: vec3<f32>, normal: vec3<f32>) ->
252
255
  return 0.0;
253
256
  }
254
257
 
258
+ /** Share of the sky's samples aimed through the windows, from a point inside a room. */
259
+ const PORTAL_SHARE: f32 = 0.5;
260
+
261
+ /**
262
+ * How often the sky is aimed at through the windows from this point: from inside a room, where something stands above
263
+ * it, half the time; outside never, where the open sky is better found by its brightness. A function of the point alone,
264
+ * so the heuristic can ask it again when a bounce ray escapes.
265
+ */
266
+ fn portalChance(position: vec3<f32>) -> f32 {
267
+ if (!PORTALS || frame.portalCount == 0u) {
268
+ return 0.0;
269
+ }
270
+
271
+ return select(0.0, PORTAL_SHARE, traceAny(position, vec3<f32>(0.0, 1.0, 0.0), 1e30) != NO_HIT);
272
+ }
273
+
274
+ /** Window subtrees a ray's walk through the windows' tree keeps waiting. */
275
+ const PORTAL_STACK: u32 = 32u;
276
+
277
+ /**
278
+ * The solid angle density with which aiming through the windows produces this direction: over every window triangle
279
+ * it crosses, the chance the tree picks that window from here times the area density of a point on it, over the area
280
+ * it shows from here. The ray walks the tree as it would a hierarchy, by the nodes' boxes, carrying the chance of
281
+ * reaching each node, so the windows it misses cost a box test and the ones it crosses no walk back up.
282
+ */
283
+ fn portalPdf(position: vec3<f32>, direction: vec3<f32>) -> f32 {
284
+ var nodes: array<u32, PORTAL_STACK>;
285
+ var chances: array<f32, PORTAL_STACK>;
286
+ var top = 1u;
287
+ let inverse = 1.0 / direction;
288
+ var pdf = 0.0;
289
+
290
+ nodes[0] = 0u;
291
+ chances[0] = 1.0;
292
+
293
+ loop {
294
+ if (top == 0u) {
295
+ break;
296
+ }
297
+
298
+ top = top - 1u;
299
+
300
+ let node = nodes[top];
301
+ let chance = chances[top];
302
+ let at = frame.portals + node * TREE_NODE_WORDS;
303
+
304
+ if (enterBox(extraVec4(at).xyz, extraVec4(at + 4u).xyz, position, inverse, 1e30) >= 1e30) {
305
+ continue;
306
+ }
307
+
308
+ let first = textures[at + 12u];
309
+ let second = textures[at + 13u];
310
+
311
+ if ((first & TREE_LEAF) != 0u) {
312
+ let start = frame.portalList + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
313
+
314
+ for (var j = 0u; j < second; j = j + 1u) {
315
+ let slot = start + j * TREE_ENTRY_WORDS;
316
+ let triangle = textures[slot];
317
+ let t = intersectTriangle(leafOf(triangle), position, direction).x;
318
+
319
+ if (t > 0.0) {
320
+ let cosine = abs(dot(triangleNormal(triangle), direction));
321
+
322
+ pdf = pdf + chance * extraFloat(slot + 1u) * extraFloat(slot + 2u) * t * t / max(cosine, 1e-6);
323
+ }
324
+ }
325
+
326
+ continue;
327
+ }
328
+
329
+ let odds = lightChildOdds(frame.portals, first, second, position, vec3<f32>(0.0));
330
+
331
+ for (var k = 0u; k < 2u; k = k + 1u) {
332
+ if (odds[k] > 0.0 && top < PORTAL_STACK) {
333
+ nodes[top] = select(second, first, k == 0u);
334
+ chances[top] = chance * odds[k];
335
+ top = top + 1u;
336
+ }
337
+ }
338
+ }
339
+
340
+ return pdf;
341
+ }
342
+
343
+ /** A direction through a point on a window the tree picks from here, or none from a point on the window itself. */
344
+ fn samplePortal(position: vec3<f32>, u: vec2<f32>) -> vec3<f32> {
345
+ var chance = 0.0;
346
+ var density = 0.0;
347
+ let triangle = sampleLightTree(frame.portals, frame.portalList, position, vec3<f32>(0.0), u.x, &chance, &density);
348
+
349
+ if (triangle == NO_LIGHT) {
350
+ return vec3<f32>(0.0);
351
+ }
352
+
353
+ // The tree spent the sample walking down; a fresh one places the point, which the pick does not depend on.
354
+ let point = trianglePointAt(triangle, triangleBarycentric(vec2<f32>(fract(u.x * 4096.0), u.y)));
355
+ let offset = point - position;
356
+
357
+ return select(vec3<f32>(0.0), normalize(offset), dot(offset, offset) > 1e-12);
358
+ }
359
+
360
+ /** The density of the sky's sampling from this point for this direction: by brightness, through windows, or both. */
361
+ fn skyPdf(position: vec3<f32>, direction: vec3<f32>, share: f32) -> f32 {
362
+ var pdf = (1.0 - share) * environmentPdf(direction);
363
+
364
+ if (share > 0.0) {
365
+ pdf = pdf + share * portalPdf(position, direction);
366
+ }
367
+
368
+ return pdf;
369
+ }
370
+
255
371
  /** The power heuristic: how much of a contribution the strategy that produced it keeps, against the other one. */
256
372
  fn misWeight(pdf: f32, other: f32) -> f32 {
257
373
  let a = pdf * pdf;
@@ -307,7 +423,7 @@ fn sampleLight(position: vec3<f32>, normal: vec3<f32>, pick: f32, u: vec2<f32>)
307
423
  if (frame.lightTree != 0u && pick < frame.localLightPick) {
308
424
  var chance = 0.0;
309
425
  var density = 1.0;
310
- let index = sampleLightTree(position, normal, pick / frame.localLightPick, &chance, &density);
426
+ let index = sampleLightTree(frame.lightTree, frame.lightList, position, normal, pick / frame.localLightPick, &chance, &density);
311
427
 
312
428
  if (index == NO_LIGHT || chance <= 0.0) {
313
429
  return noLight();
@@ -356,17 +472,31 @@ fn sampleLightEntry(light: Light, pdf: f32, position: vec3<f32>, u: vec2<f32>) -
356
472
  sample.misPdf = 0.0;
357
473
 
358
474
  if (light.kind == LIGHT_ENVIRONMENT) {
359
- let drawn = sampleEnvironmentDirection(u);
475
+ // From inside a room, part of the time through a window: daylight comes in there or not at all.
476
+ let share = portalChance(position);
477
+ var direction = vec3<f32>(0.0);
478
+
479
+ if (u.x < share) {
480
+ direction = samplePortal(position, vec2<f32>(u.x / share, u.y));
481
+ } else {
482
+ let drawn = sampleEnvironmentDirection(vec2<f32>(min((u.x - share) / (1.0 - share), 0.99999994), u.y));
483
+
484
+ if (drawn.pdf > 0.0) {
485
+ direction = drawn.direction;
486
+ }
487
+ }
488
+
489
+ let mixed = skyPdf(position, direction, share);
360
490
 
361
- if (drawn.pdf <= 0.0) {
491
+ if (all(direction == vec3<f32>(0.0)) || mixed <= 0.0) {
362
492
  return noLight();
363
493
  }
364
494
 
365
- let density = pdf * drawn.pdf;
495
+ let density = pdf * mixed;
366
496
 
367
- sample.direction = drawn.direction;
497
+ sample.direction = direction;
368
498
  sample.distance = 1e30;
369
- sample.value = environmentRadiance(drawn.direction) / density;
499
+ sample.value = environmentRadiance(direction) / density;
370
500
  // A bounce ray that escapes finds the same light, so the two strategies share it.
371
501
  sample.misPdf = density;
372
502
 
@@ -42,7 +42,9 @@ fn backgroundRadiance(direction: vec3<f32>) -> vec3<f32> {
42
42
  return frame.background.rgb;
43
43
  }
44
44
 
45
- // A procedural sky paints the background without the strength it lights with, and its foreground over it.
45
+ // A procedural sky paints the background with the strength it lights with, and its foreground over it. The CPU
46
+ // renderer leaves that strength out of what the camera sees, which a renderer exposing for the light cannot: the sky
47
+ // that lights a wall five times brighter than itself would show as a dark band behind it.
46
48
  if (frame.skyProgram != NO_PROGRAM || frame.skyIntensity > 0.0) {
47
49
  if (frame.skyBackground == 0u) {
48
50
  return vec3<f32>(0.0);
@@ -54,10 +56,10 @@ fn backgroundRadiance(direction: vec3<f32>) -> vec3<f32> {
54
56
  input.direction = direction;
55
57
  input.diffuseColor = vec4<f32>(1.0);
56
58
 
57
- return runProgram(frame.skyProgram, input).color.rgb;
59
+ return runProgram(frame.skyProgram, input).color.rgb * frame.skyIntensity;
58
60
  }
59
61
 
60
- return environmentRadiance(direction) / frame.skyIntensity;
62
+ return environmentRadiance(direction);
61
63
  }
62
64
 
63
65
  return environmentRadiance(direction);
@@ -67,7 +69,7 @@ fn backgroundRadiance(direction: vec3<f32>) -> vec3<f32> {
67
69
  * How much of the sky a bounce ray that escaped keeps. Direct sampling aims at the bright parts of an image too, so the
68
70
  * two share it; when direct sampling took all of it, as it does from a toon surface, the bounce keeps none.
69
71
  */
70
- fn escapeWeight(direction: vec3<f32>, bsdfPdf: f32, lightsOwnDirect: bool) -> f32 {
72
+ fn escapeWeight(direction: vec3<f32>, bsdfPdf: f32, lightsOwnDirect: bool, scatteredAt: vec3<f32>) -> f32 {
71
73
  if (frame.environmentPick <= 0.0) {
72
74
  return 1.0;
73
75
  }
@@ -80,7 +82,8 @@ fn escapeWeight(direction: vec3<f32>, bsdfPdf: f32, lightsOwnDirect: bool) -> f3
80
82
  return 1.0;
81
83
  }
82
84
 
83
- return misWeight(bsdfPdf, frame.environmentPick * environmentPdf(direction));
85
+ // The sky was aimed at from the vertex the bounce left, through its windows as well when that vertex is inside.
86
+ return misWeight(bsdfPdf, frame.environmentPick * skyPdf(scatteredAt, direction, portalChance(scatteredAt)));
84
87
  }
85
88
 
86
89
  /** Scales a contribution down to the luminance ceiling, which trades a little bias for the fireflies it removes. */
@@ -548,7 +551,7 @@ fn traceRadiance(
548
551
  if (escapeMap != NO_TEXTURE) {
549
552
  sky = sky + sampleEquirect(escapeMap, direction).rgb * escapeIntensity;
550
553
  } else {
551
- sky = sky + environmentRadiance(direction) * escapeWeight(direction, bsdfPdf, lightsOwnDirect);
554
+ sky = sky + environmentRadiance(direction) * escapeWeight(direction, bsdfPdf, lightsOwnDirect, lastPosition);
552
555
  }
553
556
 
554
557
  let contribution = throughput * sky;
@@ -703,8 +706,14 @@ fn traceRadiance(
703
706
  depth = length(point - rayOrigin);
704
707
  }
705
708
 
709
+ // A window the camera ray goes straight through is not what the camera sees: what lies behind it is, so the
710
+ // denoiser takes its guides there, and the air its distance. Glass has next to no diffuse colour and one flat
711
+ // normal, which would leave everything seen through it unfiltered. The window's own guides stand until then, for
712
+ // the paths that reflect off it instead.
713
+ let window = surface.transmission > 0.0 && surface.thickness <= 0.0;
714
+
706
715
  if (straight && !guided) {
707
- guided = true;
716
+ guided = !window;
708
717
  diffuseAlbedo = diffuseGuide(surface);
709
718
  specularAlbedoGuide = select(specularAlbedo(surface, -direction), vec3<f32>(1.0), (surface.flags & MATERIAL_LIT) == 0u);
710
719
  guideNormal = surface.normal;
@@ -745,7 +754,7 @@ fn traceRadiance(
745
754
  }
746
755
  }
747
756
 
748
- if (!hazed && frame.atmosphereColor.w > 0.0) {
757
+ if (!hazed && !window && frame.atmosphereColor.w > 0.0) {
749
758
  hazed = true;
750
759
  // From the eye, where the camera ray only starts on the near plane; an orthographic view has no eye.
751
760
  hazeDistance = length(point - select(frame.cameraToWorld[3].xyz, rayOrigin, frame.cameraKind == 1u));
@@ -765,7 +774,11 @@ fn traceRadiance(
765
774
  }
766
775
 
767
776
  // Three does not light a basic, points, line or sprite material, so nothing carries on from one.
768
- if ((surface.flags & MATERIAL_LIT) == 0u || bounce >= frame.bounces) {
777
+ // Past the last bounce a path still crosses a window straight on, which scatters nothing, to find the sky behind
778
+ // it: cut there, the daylight a room gets through its glazing would go missing from every last bounce.
779
+ let spent = bounce >= frame.bounces;
780
+
781
+ if ((surface.flags & MATERIAL_LIT) == 0u || (spent && !window)) {
769
782
  break;
770
783
  }
771
784
 
@@ -788,8 +801,105 @@ fn traceRadiance(
788
801
  var nextOrigin = point + direction * RAY_EPSILON;
789
802
 
790
803
  // Transmission replaces part of the diffuse layer, so that share of the time the surface is a dielectric instead.
791
- // Choosing it in proportion to its weight leaves both halves unweighted, and only the opaque half aims at a light.
792
- if (surface.transmission > 0.0 && lobe.y < surface.transmission) {
804
+ // Choosing it in proportion to its weight leaves both halves unweighted. The opaque half aims at a light; on a
805
+ // window that lets only part of the light through, it does so on every path, weighed by its share, rather than on
806
+ // the paths that chose it: a sunlit pane seen against a dim room would otherwise be lit on one path in five and
807
+ // dark on the rest, which is the grain glass shows.
808
+ let dielectric = surface.transmission > 0.0 && lobe.y < surface.transmission;
809
+ let windowLight = window && !spent && surface.transmission < 1.0 && surface.subsurface <= 0.0;
810
+
811
+ if (!dielectric || windowLight) {
812
+ var chance = 0.0;
813
+
814
+ if (!dielectric) {
815
+ // Light that enters the material: a random walk inside it from here, in place of the share of the diffuse
816
+ // layer that scatters inside. Chosen by chance, so neither branch is weighted against the other.
817
+ // The window's opaque share is a surface like any other, which a spent path stops at.
818
+ if (spent) {
819
+ break;
820
+ }
821
+
822
+ if (SUBSURFACE_WALK) {
823
+ chance = subsurfaceChance(surface, view);
824
+ }
825
+
826
+ if (SUBSURFACE_WALK && lobe.x < chance) {
827
+ let medium = materials[surface.material].subsurface;
828
+
829
+ throughput = throughput
830
+ * ((1.0 - surface.metalness) * surface.subsurface * coatTransmittance(surface, view) / chance);
831
+ sigma = vec3<f32>(1.0) / max(medium.rgb, vec3<f32>(1e-6));
832
+ walkAlbedo = subsurfaceAlbedo(surface.albedo);
833
+ walkG = materials[surface.material].subsurfaceAnisotropy;
834
+ walkSteps = 0u;
835
+
836
+ // Light that scattered inside is diffuse light, which is the signal it feeds.
837
+ if (!split) {
838
+ split = true;
839
+ splitBase = radiance;
840
+ splitDiffuse = true;
841
+ }
842
+
843
+ inMedium = true;
844
+ primary = false;
845
+ straight = false;
846
+ bsdfPdf = 0.0;
847
+ lightsOwnDirect = false;
848
+ escapeMap = NO_TEXTURE;
849
+ bounce = bounce + 1u;
850
+ direction = cosineDirection(-surface.facing, bounceSample);
851
+ origin = point - surface.facing * RAY_EPSILON;
852
+ limit = walkDistance(throughput, sigma, pick, &walkOdds);
853
+ continue;
854
+ }
855
+
856
+ throughput = throughput / (1.0 - chance);
857
+ }
858
+
859
+ let position = point + surface.normal * RAY_EPSILON;
860
+ var directDiffuse: vec3<f32>;
861
+ let lit = select(throughput, throughput * (1.0 - surface.transmission), windowLight);
862
+ let direct = lit * directLight(surface, position, view, pick.x, lightSample, rng, &directDiffuse,
863
+ select(0.0, volumeDensity, inVolume));
864
+ let added = select(direct, clampIndirect(direct), bounce > 0u);
865
+ let before = radiance;
866
+
867
+ radiance = radiance + added;
868
+
869
+ if (!dielectric) {
870
+ let scattered = sampleBsdf(surface, view, (lobe.x - chance) / (1.0 - chance), bounceSample);
871
+
872
+ // The first scatter splits the path: its direct light by lobe, and everything after it by the lobe it chose.
873
+ if (!split) {
874
+ split = true;
875
+ splitBase = before;
876
+ splitDirect = added;
877
+ splitDirectDiffuse = lit * directDiffuse * select(1.0, luminanceOf(added) / max(luminanceOf(direct), 1e-20),
878
+ bounce > 0u);
879
+ splitDiffuse = !scattered.valid || scattered.diffuse;
880
+ waitingForSpecular = scattered.valid && !scattered.diffuse;
881
+ }
882
+
883
+ if (!scattered.valid) {
884
+ break;
885
+ }
886
+
887
+ throughput = throughput * scattered.weight;
888
+ bsdfPdf = scattered.pdf;
889
+ lastPosition = position;
890
+ lastNormal = treeNormal(surface);
891
+ lightsOwnDirect = surface.kind == KIND_TOON;
892
+ escapeMap = surface.envMap;
893
+ escapeIntensity = surface.envMapIntensity;
894
+ next = scattered.direction;
895
+ nextOrigin = position;
896
+ primary = false;
897
+ straight = false;
898
+ bounce = bounce + 1u;
899
+ }
900
+ }
901
+
902
+ if (dielectric) {
793
903
  var ior = surface.ior;
794
904
 
795
905
  // Three's dispersion: each colour refracts with its own index, spread around the material's. The path picks one
@@ -809,7 +919,8 @@ fn traceRadiance(
809
919
 
810
920
  let crossing = sampleDielectric(surface, view, ior, lobe.y / surface.transmission, bounceSample);
811
921
 
812
- if (!crossing.valid) {
922
+ // A reflection off the glass is a scattering, which a spent path no longer has.
923
+ if (!crossing.valid || (spent && !crossing.straight)) {
813
924
  break;
814
925
  }
815
926
 
@@ -844,85 +955,6 @@ fn traceRadiance(
844
955
  if (!primary) {
845
956
  bounce = bounce + 1u;
846
957
  }
847
- } else {
848
- // Light that enters the material: a random walk inside it from here, in place of the share of the diffuse
849
- // layer that scatters inside. Chosen by chance, so neither branch is weighted against the other.
850
- var chance = 0.0;
851
-
852
- if (SUBSURFACE_WALK) {
853
- chance = subsurfaceChance(surface, view);
854
- }
855
-
856
- if (SUBSURFACE_WALK && lobe.x < chance) {
857
- let medium = materials[surface.material].subsurface;
858
-
859
- throughput = throughput
860
- * ((1.0 - surface.metalness) * surface.subsurface * coatTransmittance(surface, view) / chance);
861
- sigma = vec3<f32>(1.0) / max(medium.rgb, vec3<f32>(1e-6));
862
- walkAlbedo = subsurfaceAlbedo(surface.albedo);
863
- walkG = materials[surface.material].subsurfaceAnisotropy;
864
- walkSteps = 0u;
865
-
866
- // Light that scattered inside is diffuse light, which is the signal it feeds.
867
- if (!split) {
868
- split = true;
869
- splitBase = radiance;
870
- splitDiffuse = true;
871
- }
872
-
873
- inMedium = true;
874
- primary = false;
875
- straight = false;
876
- bsdfPdf = 0.0;
877
- lightsOwnDirect = false;
878
- escapeMap = NO_TEXTURE;
879
- bounce = bounce + 1u;
880
- direction = cosineDirection(-surface.facing, bounceSample);
881
- origin = point - surface.facing * RAY_EPSILON;
882
- limit = walkDistance(throughput, sigma, pick, &walkOdds);
883
- continue;
884
- }
885
-
886
- throughput = throughput / (1.0 - chance);
887
-
888
- let position = point + surface.normal * RAY_EPSILON;
889
- var directDiffuse: vec3<f32>;
890
- let direct = throughput * directLight(surface, position, view, pick.x, lightSample, rng, &directDiffuse,
891
- select(0.0, volumeDensity, inVolume));
892
- let added = select(direct, clampIndirect(direct), bounce > 0u);
893
- let before = radiance;
894
-
895
- radiance = radiance + added;
896
-
897
- let scattered = sampleBsdf(surface, view, (lobe.x - chance) / (1.0 - chance), bounceSample);
898
-
899
- // The first scatter splits the path: its direct light by lobe, and everything after it by the lobe it chose.
900
- if (!split) {
901
- split = true;
902
- splitBase = before;
903
- splitDirect = added;
904
- splitDirectDiffuse = throughput * directDiffuse * select(1.0, luminanceOf(added) / max(luminanceOf(direct), 1e-20),
905
- bounce > 0u);
906
- splitDiffuse = !scattered.valid || scattered.diffuse;
907
- waitingForSpecular = scattered.valid && !scattered.diffuse;
908
- }
909
-
910
- if (!scattered.valid) {
911
- break;
912
- }
913
-
914
- throughput = throughput * scattered.weight;
915
- bsdfPdf = scattered.pdf;
916
- lastPosition = position;
917
- lastNormal = treeNormal(surface);
918
- lightsOwnDirect = surface.kind == KIND_TOON;
919
- escapeMap = surface.envMap;
920
- escapeIntensity = surface.envMapIntensity;
921
- next = scattered.direction;
922
- nextOrigin = position;
923
- primary = false;
924
- straight = false;
925
- bounce = bounce + 1u;
926
958
  }
927
959
 
928
960
  // A path that carries almost nothing is ended by chance rather than by a cut, which keeps the estimate unbiased.
@@ -15,6 +15,8 @@ struct Present {
15
15
  transparent: u32,
16
16
  // Whether the colour comes from the denoiser's output rather than straight from the accumulation.
17
17
  denoised: u32,
18
+ // How much of AgX's Punchy look goes over its base curve.
19
+ contrast: f32,
18
20
  };
19
21
 
20
22
  @group(0) @binding(0) var<uniform> present: Present;
@@ -63,8 +65,16 @@ fn toneMapAgx(color: vec3<f32>) -> vec3<f32> {
63
65
  vec3<f32>(-0.0728, -0.0083, 1.1187));
64
66
  let logged = inset * (toAgx * color);
65
67
  let x = clamp((log2(max(logged, vec3<f32>(1e-10))) + 12.47393) / 16.499999, vec3<f32>(0.0), vec3<f32>(1.0));
66
- let curved = 15.5 * pow(x, vec3<f32>(6.0)) - 40.14 * pow(x, vec3<f32>(5.0)) + 31.96 * pow(x, vec3<f32>(4.0))
68
+ let base = 15.5 * pow(x, vec3<f32>(6.0)) - 40.14 * pow(x, vec3<f32>(5.0)) + 31.96 * pow(x, vec3<f32>(4.0))
67
69
  - 6.868 * pow(x, vec3<f32>(3.0)) + 0.4298 * x * x + 0.1191 * x - 0.00232;
70
+ // A look in the spirit of Blender's Punchy, a power of 1.35 and more saturation, but pivoted on middle grey: Punchy
71
+ // raises the power around zero, which takes middle grey from half the display down to a third and darkens the whole
72
+ // image. Here the highlights rise and the shadows fall while grey, which the automatic exposure aims at, stands.
73
+ let pivot = 0.49673;
74
+ let contrasted = pivot * pow(max(base, vec3<f32>(0.0)) / pivot, vec3<f32>(1.35));
75
+ let luma = dot(contrasted, vec3<f32>(0.2126, 0.7152, 0.0722));
76
+ let punchy = luma + 1.2 * (contrasted - luma);
77
+ let curved = mix(base, punchy, clamp(present.contrast, 0.0, 1.0));
68
78
 
69
79
  return fromAgx * pow(max(outset * curved, vec3<f32>(0.0)), vec3<f32>(2.2));
70
80
  }