@tinypoly/gpu-renderer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/ARCHITECTURE.md +31 -3
  2. package/README.md +13 -4
  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 +6 -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/lightTree.d.ts +10 -3
  26. package/dist/lightTree.d.ts.map +1 -1
  27. package/dist/lightTree.js +56 -17
  28. package/dist/lightTree.js.map +1 -1
  29. package/dist/lights.d.ts +20 -0
  30. package/dist/lights.d.ts.map +1 -1
  31. package/dist/lights.js +48 -1
  32. package/dist/lights.js.map +1 -1
  33. package/dist/oidnPass.d.ts +48 -0
  34. package/dist/oidnPass.d.ts.map +1 -0
  35. package/dist/oidnPass.js +255 -0
  36. package/dist/oidnPass.js.map +1 -0
  37. package/dist/pathTracePass.d.ts +2 -2
  38. package/dist/pathTracePass.d.ts.map +1 -1
  39. package/dist/pathTracePass.js +9 -9
  40. package/dist/pathTracePass.js.map +1 -1
  41. package/dist/presentPass.d.ts +2 -0
  42. package/dist/presentPass.d.ts.map +1 -1
  43. package/dist/presentPass.js +1 -0
  44. package/dist/presentPass.js.map +1 -1
  45. package/dist/renderer.d.ts +20 -0
  46. package/dist/renderer.d.ts.map +1 -1
  47. package/dist/renderer.js +138 -19
  48. package/dist/renderer.js.map +1 -1
  49. package/dist/sceneUpload.d.ts +5 -0
  50. package/dist/sceneUpload.d.ts.map +1 -1
  51. package/dist/sceneUpload.js +4 -0
  52. package/dist/sceneUpload.js.map +1 -1
  53. package/dist/settings.d.ts +29 -1
  54. package/dist/settings.d.ts.map +1 -1
  55. package/dist/settings.js +15 -6
  56. package/dist/settings.js.map +1 -1
  57. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  58. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/lens.wgsl.js +110 -17
  60. package/dist/wgsl/lens.wgsl.js.map +1 -1
  61. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  62. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/lights.wgsl.js +197 -33
  64. package/dist/wgsl/lights.wgsl.js.map +1 -1
  65. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  66. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  67. package/dist/wgsl/pathTrace.wgsl.js +173 -131
  68. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  69. package/dist/wgsl/present.wgsl.d.ts +1 -1
  70. package/dist/wgsl/present.wgsl.d.ts.map +1 -1
  71. package/dist/wgsl/present.wgsl.js +11 -1
  72. package/dist/wgsl/present.wgsl.js.map +1 -1
  73. package/dist/wgsl/random.wgsl.d.ts +1 -1
  74. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  75. package/dist/wgsl/random.wgsl.js +28 -0
  76. package/dist/wgsl/random.wgsl.js.map +1 -1
  77. package/package.json +4 -2
  78. package/src/denoise.ts +15 -1
  79. package/src/device.ts +9 -0
  80. package/src/frameLayout.ts +6 -1
  81. package/src/imageResolve.ts +2 -0
  82. package/src/index.ts +2 -1
  83. package/src/lensPass.ts +17 -1
  84. package/src/lightTree.ts +67 -21
  85. package/src/lights.ts +65 -1
  86. package/src/oidnPass.ts +284 -0
  87. package/src/oidnWebgpu.d.ts +5 -0
  88. package/src/pathTracePass.ts +11 -9
  89. package/src/presentPass.ts +3 -0
  90. package/src/renderer.ts +161 -20
  91. package/src/sceneUpload.ts +9 -0
  92. package/src/settings.ts +40 -6
  93. package/src/wgsl/lens.wgsl.ts +110 -17
  94. package/src/wgsl/lights.wgsl.ts +197 -33
  95. package/src/wgsl/pathTrace.wgsl.ts +173 -131
  96. package/src/wgsl/present.wgsl.ts +11 -1
  97. package/src/wgsl/random.wgsl.ts +28 -0
  98. package/weights/LICENSE.txt +202 -0
  99. package/weights/rt_hdr_alb_nrm.tza +0 -0
@@ -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. */
@@ -456,55 +459,57 @@ fn traceRadiance(
456
459
  for (var step = 0u; step < MAX_STEPS + MAX_WALK; step = step + 1u) {
457
460
  let hit = traceScene(origin, direction, limit);
458
461
 
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);
467
-
468
- if (VOLUMES) {
469
- mediumSample = sample2D(rng);
470
- }
471
-
472
- if (BEVEL) {
473
- bevelSample = vec4<f32>(sample2D(rng), sample2D(rng));
474
- }
475
-
476
- // The walk left the material here, and the surface below is its way out rather than what the material shows.
477
- var exiting = false;
462
+ // A step of the walk inside a material that ends in the medium: the light scatters there, keeping each colour's
463
+ // share of it. It needs three numbers, which a plain hash gives for a fraction of what the sequence costs.
464
+ if (SUBSURFACE_WALK && inMedium && hit.triangle == NO_HIT) {
465
+ let travelled = exp(-sigma * limit);
466
+ let density = dot(walkOdds, sigma * travelled);
467
+ // A new direction, the next distance (a colour and how far), and the roulette's chance.
468
+ let turn = hashSample2D(rng);
469
+ let next = hashSample2D(rng);
470
+ let chance = hashSample2D(rng).x;
478
471
 
479
- 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);
472
+ throughput = throughput * sigma * walkAlbedo * travelled / max(density, 1e-20);
473
+ walkSteps = walkSteps + 1u;
484
474
 
485
- throughput = throughput * sigma * walkAlbedo * travelled / max(density, 1e-20);
486
- walkSteps = walkSteps + 1u;
475
+ let survival = max3(throughput);
487
476
 
488
- let survival = max3(throughput);
477
+ if (walkSteps >= MAX_WALK || survival <= 0.0) {
478
+ break;
479
+ }
489
480
 
490
- if (walkSteps >= MAX_WALK || survival <= 0.0) {
481
+ if (survival < 0.2) {
482
+ if (chance > survival) {
491
483
  break;
492
484
  }
493
485
 
494
- if (survival < 0.2) {
495
- if (coverage.x > survival) {
496
- break;
497
- }
486
+ throughput = throughput / survival;
487
+ }
498
488
 
499
- throughput = throughput / survival;
500
- }
489
+ origin = origin + direction * limit;
490
+ direction = sampleHenyeyGreenstein(direction, walkG, turn);
491
+ limit = walkDistance(throughput, sigma, next, &walkOdds);
492
+ continue;
493
+ }
501
494
 
502
- origin = origin + direction * limit;
503
- direction = sampleHenyeyGreenstein(direction, walkG, lobe);
504
- limit = walkDistance(throughput, sigma, pick, &walkOdds);
505
- continue;
506
- }
495
+ // Every vertex draws the same dimensions in the same order, whatever the path then does with them: from the
496
+ // sequence for the first vertices, from a hash past SEQUENCE_DEPTH bounces.
497
+ let deep = bounce >= SEQUENCE_DEPTH;
498
+ let coverage = pathSample2D(rng, deep);
499
+ let pick = pathSample2D(rng, deep);
500
+ let lightSample = pathSample2D(rng, deep);
501
+ let lobe = pathSample2D(rng, deep);
502
+ let bounceSample = pathSample2D(rng, deep);
503
+ var mediumSample = vec2<f32>(0.0);
504
+
505
+ if (VOLUMES) {
506
+ mediumSample = pathSample2D(rng, deep);
507
+ }
508
+
509
+ // The walk left the material here, and the surface below is its way out rather than what the material shows.
510
+ var exiting = false;
507
511
 
512
+ if (SUBSURFACE_WALK && inMedium) {
508
513
  // The walk reached the surface before scattering again: the chance of travelling that far, per colour.
509
514
  let travelled = exp(-sigma * hit.distance);
510
515
 
@@ -546,7 +551,7 @@ fn traceRadiance(
546
551
  if (escapeMap != NO_TEXTURE) {
547
552
  sky = sky + sampleEquirect(escapeMap, direction).rgb * escapeIntensity;
548
553
  } else {
549
- sky = sky + environmentRadiance(direction) * escapeWeight(direction, bsdfPdf, lightsOwnDirect);
554
+ sky = sky + environmentRadiance(direction) * escapeWeight(direction, bsdfPdf, lightsOwnDirect, lastPosition);
550
555
  }
551
556
 
552
557
  let contribution = throughput * sky;
@@ -631,8 +636,16 @@ fn traceRadiance(
631
636
 
632
637
  surface = resolveSurfaceFrom(hit.triangle, bary, direction, base);
633
638
 
639
+ // Deep in the path, light reflects off a material that scatters inside as off a diffuse one of the same colour.
640
+ if (SUBSURFACE_WALK && bounce >= frame.subsurfaceDepth) {
641
+ surface.subsurface = 0.0;
642
+ }
643
+
634
644
  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);
645
+ let disk = hashSample2D(rng);
646
+ let axis = hashSample2D(rng);
647
+
648
+ surface.normal = bevelNormal(m, point, surface, -direction, materials[m].bevel, disk, axis);
636
649
  }
637
650
  }
638
651
 
@@ -693,8 +706,14 @@ fn traceRadiance(
693
706
  depth = length(point - rayOrigin);
694
707
  }
695
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
+
696
715
  if (straight && !guided) {
697
- guided = true;
716
+ guided = !window;
698
717
  diffuseAlbedo = diffuseGuide(surface);
699
718
  specularAlbedoGuide = select(specularAlbedo(surface, -direction), vec3<f32>(1.0), (surface.flags & MATERIAL_LIT) == 0u);
700
719
  guideNormal = surface.normal;
@@ -735,7 +754,7 @@ fn traceRadiance(
735
754
  }
736
755
  }
737
756
 
738
- if (!hazed && frame.atmosphereColor.w > 0.0) {
757
+ if (!hazed && !window && frame.atmosphereColor.w > 0.0) {
739
758
  hazed = true;
740
759
  // From the eye, where the camera ray only starts on the near plane; an orthographic view has no eye.
741
760
  hazeDistance = length(point - select(frame.cameraToWorld[3].xyz, rayOrigin, frame.cameraKind == 1u));
@@ -755,7 +774,11 @@ fn traceRadiance(
755
774
  }
756
775
 
757
776
  // Three does not light a basic, points, line or sprite material, so nothing carries on from one.
758
- 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)) {
759
782
  break;
760
783
  }
761
784
 
@@ -778,8 +801,105 @@ fn traceRadiance(
778
801
  var nextOrigin = point + direction * RAY_EPSILON;
779
802
 
780
803
  // Transmission replaces part of the diffuse layer, so that share of the time the surface is a dielectric instead.
781
- // Choosing it in proportion to its weight leaves both halves unweighted, and only the opaque half aims at a light.
782
- 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) {
783
903
  var ior = surface.ior;
784
904
 
785
905
  // Three's dispersion: each colour refracts with its own index, spread around the material's. The path picks one
@@ -799,7 +919,8 @@ fn traceRadiance(
799
919
 
800
920
  let crossing = sampleDielectric(surface, view, ior, lobe.y / surface.transmission, bounceSample);
801
921
 
802
- 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)) {
803
924
  break;
804
925
  }
805
926
 
@@ -834,85 +955,6 @@ fn traceRadiance(
834
955
  if (!primary) {
835
956
  bounce = bounce + 1u;
836
957
  }
837
- } else {
838
- // Light that enters the material: a random walk inside it from here, in place of the share of the diffuse
839
- // layer that scatters inside. Chosen by chance, so neither branch is weighted against the other.
840
- var chance = 0.0;
841
-
842
- if (SUBSURFACE_WALK) {
843
- chance = subsurfaceChance(surface, view);
844
- }
845
-
846
- if (SUBSURFACE_WALK && lobe.x < chance) {
847
- let medium = materials[surface.material].subsurface;
848
-
849
- throughput = throughput
850
- * ((1.0 - surface.metalness) * surface.subsurface * coatTransmittance(surface, view) / chance);
851
- sigma = vec3<f32>(1.0) / max(medium.rgb, vec3<f32>(1e-6));
852
- walkAlbedo = subsurfaceAlbedo(surface.albedo);
853
- walkG = materials[surface.material].subsurfaceAnisotropy;
854
- walkSteps = 0u;
855
-
856
- // Light that scattered inside is diffuse light, which is the signal it feeds.
857
- if (!split) {
858
- split = true;
859
- splitBase = radiance;
860
- splitDiffuse = true;
861
- }
862
-
863
- inMedium = true;
864
- primary = false;
865
- straight = false;
866
- bsdfPdf = 0.0;
867
- lightsOwnDirect = false;
868
- escapeMap = NO_TEXTURE;
869
- bounce = bounce + 1u;
870
- direction = cosineDirection(-surface.facing, bounceSample);
871
- origin = point - surface.facing * RAY_EPSILON;
872
- limit = walkDistance(throughput, sigma, pick, &walkOdds);
873
- continue;
874
- }
875
-
876
- throughput = throughput / (1.0 - chance);
877
-
878
- let position = point + surface.normal * RAY_EPSILON;
879
- var directDiffuse: vec3<f32>;
880
- let direct = throughput * directLight(surface, position, view, pick.x, lightSample, rng, &directDiffuse,
881
- select(0.0, volumeDensity, inVolume));
882
- let added = select(direct, clampIndirect(direct), bounce > 0u);
883
- let before = radiance;
884
-
885
- radiance = radiance + added;
886
-
887
- let scattered = sampleBsdf(surface, view, (lobe.x - chance) / (1.0 - chance), bounceSample);
888
-
889
- // The first scatter splits the path: its direct light by lobe, and everything after it by the lobe it chose.
890
- if (!split) {
891
- split = true;
892
- splitBase = before;
893
- splitDirect = added;
894
- splitDirectDiffuse = throughput * directDiffuse * select(1.0, luminanceOf(added) / max(luminanceOf(direct), 1e-20),
895
- bounce > 0u);
896
- splitDiffuse = !scattered.valid || scattered.diffuse;
897
- waitingForSpecular = scattered.valid && !scattered.diffuse;
898
- }
899
-
900
- if (!scattered.valid) {
901
- break;
902
- }
903
-
904
- throughput = throughput * scattered.weight;
905
- bsdfPdf = scattered.pdf;
906
- lastPosition = position;
907
- lastNormal = treeNormal(surface);
908
- lightsOwnDirect = surface.kind == KIND_TOON;
909
- escapeMap = surface.envMap;
910
- escapeIntensity = surface.envMapIntensity;
911
- next = scattered.direction;
912
- nextOrigin = position;
913
- primary = false;
914
- straight = false;
915
- bounce = bounce + 1u;
916
958
  }
917
959
 
918
960
  // A path that carries almost nothing is ended by chance rather than by a cut, which keeps the estimate unbiased.
@@ -1 +1 @@
1
- {"version":3,"file":"pathTrace.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/pathTrace.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+2CzC,CAAC"}
1
+ {"version":3,"file":"pathTrace.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/pathTrace.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAy5CzC,CAAC"}
@@ -3,5 +3,5 @@
3
3
  * vignette and grain. The operators are ports of `color.ts` in the CPU renderer, which are in turn Three's ACES, AgX
4
4
  * and Khronos neutral curves, so the two renderers agree on the display transform even when they disagree on the light.
5
5
  */
6
- export declare const PRESENT_WGSL = "\nstruct Present {\n resolution: vec2<u32>,\n exposure: f32,\n toneMapping: u32,\n vignette: f32,\n grain: f32,\n frameIndex: u32,\n // A transparent background: the canvas gets the coverage as alpha, premultiplied as it is configured.\n transparent: u32,\n // Whether the colour comes from the denoiser's output rather than straight from the accumulation.\n denoised: u32,\n};\n\n@group(0) @binding(0) var<uniform> present: Present;\n@group(0) @binding(1) var<storage, read> accumulation: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read> denoised: array<vec4<f32>>;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {\n // One oversized triangle covers the viewport with no vertex buffer.\n let x = f32(index / 2u) * 4.0 - 1.0;\n let y = f32(index & 1u) * 4.0 - 1.0;\n\n return vec4<f32>(x, y, 0.0, 1.0);\n}\n\nfn toneMapAces(color: vec3<f32>) -> vec3<f32> {\n let toAces = mat3x3<f32>(\n vec3<f32>(0.59719, 0.076, 0.0284),\n vec3<f32>(0.35458, 0.90834, 0.13383),\n vec3<f32>(0.04823, 0.01566, 0.83777));\n let fromAces = mat3x3<f32>(\n vec3<f32>(1.60475, -0.10208, -0.00327),\n vec3<f32>(-0.53108, 1.10813, -0.07276),\n vec3<f32>(-0.07367, -0.00605, 1.07602));\n let v = toAces * (color / 0.6);\n\n return fromAces * ((v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081));\n}\n\nfn toneMapAgx(color: vec3<f32>) -> vec3<f32> {\n let toAgx = mat3x3<f32>(\n vec3<f32>(0.6274, 0.0691, 0.0164),\n vec3<f32>(0.3293, 0.9195, 0.088),\n vec3<f32>(0.0433, 0.0113, 0.8956));\n let inset = mat3x3<f32>(\n vec3<f32>(0.856627153315983, 0.137318972929847, 0.11189821299995),\n vec3<f32>(0.0951212405381588, 0.761241990602591, 0.0767994186031903),\n vec3<f32>(0.0482516061458583, 0.101439036467562, 0.811302368396859));\n let outset = mat3x3<f32>(\n vec3<f32>(1.1271005818144368, -0.1413297634984383, -0.14132976349843826),\n vec3<f32>(-0.11060664309660323, 1.157823702216272, -0.11060664309660294),\n vec3<f32>(-0.016493938717834573, -0.016493938717834257, 1.2519364065950405));\n let fromAgx = mat3x3<f32>(\n vec3<f32>(1.6605, -0.1246, -0.0182),\n vec3<f32>(-0.5876, 1.1329, -0.1006),\n vec3<f32>(-0.0728, -0.0083, 1.1187));\n let logged = inset * (toAgx * color);\n let x = clamp((log2(max(logged, vec3<f32>(1e-10))) + 12.47393) / 16.499999, vec3<f32>(0.0), vec3<f32>(1.0));\n 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))\n - 6.868 * pow(x, vec3<f32>(3.0)) + 0.4298 * x * x + 0.1191 * x - 0.00232;\n\n return fromAgx * pow(max(outset * curved, vec3<f32>(0.0)), vec3<f32>(2.2));\n}\n\nfn toneMapNeutral(color: vec3<f32>) -> vec3<f32> {\n let low = min(color.r, min(color.g, color.b));\n let offset = select(0.04, low - 6.25 * low * low, low < 0.08);\n let shifted = color - offset;\n let peak = max(shifted.r, max(shifted.g, shifted.b));\n\n if (peak < 0.76) {\n return shifted;\n }\n\n let newPeak = 1.0 - 0.24 * 0.24 / (peak + 0.24 - 0.76);\n let g = 1.0 - 1.0 / (0.15 * (peak - newPeak) + 1.0);\n\n return shifted * (newPeak / peak) * (1.0 - g) + newPeak * g;\n}\n\nfn toneMap(color: vec3<f32>) -> vec3<f32> {\n let exposed = max(color * present.exposure, vec3<f32>(0.0));\n var mapped = exposed;\n\n if (present.toneMapping == 0u) {\n mapped = toneMapAces(exposed);\n } else if (present.toneMapping == 1u) {\n mapped = toneMapAgx(exposed);\n } else if (present.toneMapping == 2u) {\n mapped = toneMapNeutral(exposed);\n }\n\n return clamp(mapped, vec3<f32>(0.0), vec3<f32>(1.0));\n}\n\nfn linearToSrgb(color: vec3<f32>) -> vec3<f32> {\n let low = color * 12.92;\n let high = 1.055 * pow(max(color, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;\n\n return select(high, low, color <= vec3<f32>(0.0031308));\n}\n\nfn hash(value: u32) -> f32 {\n var state = value * 747796405u + 2891336453u;\n state = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;\n\n return f32((state >> 22u) ^ state) * 2.3283064365386963e-10;\n}\n\n@fragment\nfn fragmentMain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {\n let pixel = vec2<u32>(position.xy);\n let index = pixel.y * present.resolution.x + pixel.x;\n let stored = accumulation[index];\n\n // Nothing has been traced into this pixel yet: it stays see-through, so whatever is under the canvas shows.\n if (stored.w <= 0.0) {\n return vec4<f32>(0.0);\n }\n\n var color = select(stored.rgb / stored.w, vec3<f32>(0.0), stored.w <= 0.0);\n\n if (present.denoised != 0u) {\n color = denoised[index].rgb;\n }\n var alpha = 1.0;\n\n if (present.transparent != 0u) {\n let coverage = accumulation[present.resolution.x * present.resolution.y + index].x;\n alpha = select(clamp(coverage / stored.w, 0.0, 1.0), 0.0, stored.w <= 0.0);\n // The radiance is premultiplied by the coverage; the tone curve applies to the colour itself.\n color = select(color / alpha, vec3<f32>(0.0), alpha <= 0.0);\n }\n\n let centred = position.xy / vec2<f32>(present.resolution) - 0.5;\n let falloff = 1.0 - present.vignette * dot(centred, centred) * 2.0;\n var output = linearToSrgb(toneMap(color * max(falloff, 0.0)));\n\n if (present.grain > 0.0) {\n let noise = hash(index * 9781u + present.frameIndex * 6151u) - 0.5;\n output = clamp(output + noise * present.grain * 0.1, vec3<f32>(0.0), vec3<f32>(1.0));\n }\n\n return vec4<f32>(output * alpha, alpha);\n}\n";
6
+ export declare const PRESENT_WGSL = "\nstruct Present {\n resolution: vec2<u32>,\n exposure: f32,\n toneMapping: u32,\n vignette: f32,\n grain: f32,\n frameIndex: u32,\n // A transparent background: the canvas gets the coverage as alpha, premultiplied as it is configured.\n transparent: u32,\n // Whether the colour comes from the denoiser's output rather than straight from the accumulation.\n denoised: u32,\n // How much of AgX's Punchy look goes over its base curve.\n contrast: f32,\n};\n\n@group(0) @binding(0) var<uniform> present: Present;\n@group(0) @binding(1) var<storage, read> accumulation: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read> denoised: array<vec4<f32>>;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {\n // One oversized triangle covers the viewport with no vertex buffer.\n let x = f32(index / 2u) * 4.0 - 1.0;\n let y = f32(index & 1u) * 4.0 - 1.0;\n\n return vec4<f32>(x, y, 0.0, 1.0);\n}\n\nfn toneMapAces(color: vec3<f32>) -> vec3<f32> {\n let toAces = mat3x3<f32>(\n vec3<f32>(0.59719, 0.076, 0.0284),\n vec3<f32>(0.35458, 0.90834, 0.13383),\n vec3<f32>(0.04823, 0.01566, 0.83777));\n let fromAces = mat3x3<f32>(\n vec3<f32>(1.60475, -0.10208, -0.00327),\n vec3<f32>(-0.53108, 1.10813, -0.07276),\n vec3<f32>(-0.07367, -0.00605, 1.07602));\n let v = toAces * (color / 0.6);\n\n return fromAces * ((v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081));\n}\n\nfn toneMapAgx(color: vec3<f32>) -> vec3<f32> {\n let toAgx = mat3x3<f32>(\n vec3<f32>(0.6274, 0.0691, 0.0164),\n vec3<f32>(0.3293, 0.9195, 0.088),\n vec3<f32>(0.0433, 0.0113, 0.8956));\n let inset = mat3x3<f32>(\n vec3<f32>(0.856627153315983, 0.137318972929847, 0.11189821299995),\n vec3<f32>(0.0951212405381588, 0.761241990602591, 0.0767994186031903),\n vec3<f32>(0.0482516061458583, 0.101439036467562, 0.811302368396859));\n let outset = mat3x3<f32>(\n vec3<f32>(1.1271005818144368, -0.1413297634984383, -0.14132976349843826),\n vec3<f32>(-0.11060664309660323, 1.157823702216272, -0.11060664309660294),\n vec3<f32>(-0.016493938717834573, -0.016493938717834257, 1.2519364065950405));\n let fromAgx = mat3x3<f32>(\n vec3<f32>(1.6605, -0.1246, -0.0182),\n vec3<f32>(-0.5876, 1.1329, -0.1006),\n vec3<f32>(-0.0728, -0.0083, 1.1187));\n let logged = inset * (toAgx * color);\n let x = clamp((log2(max(logged, vec3<f32>(1e-10))) + 12.47393) / 16.499999, vec3<f32>(0.0), vec3<f32>(1.0));\n 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))\n - 6.868 * pow(x, vec3<f32>(3.0)) + 0.4298 * x * x + 0.1191 * x - 0.00232;\n // A look in the spirit of Blender's Punchy, a power of 1.35 and more saturation, but pivoted on middle grey: Punchy\n // raises the power around zero, which takes middle grey from half the display down to a third and darkens the whole\n // image. Here the highlights rise and the shadows fall while grey, which the automatic exposure aims at, stands.\n let pivot = 0.49673;\n let contrasted = pivot * pow(max(base, vec3<f32>(0.0)) / pivot, vec3<f32>(1.35));\n let luma = dot(contrasted, vec3<f32>(0.2126, 0.7152, 0.0722));\n let punchy = luma + 1.2 * (contrasted - luma);\n let curved = mix(base, punchy, clamp(present.contrast, 0.0, 1.0));\n\n return fromAgx * pow(max(outset * curved, vec3<f32>(0.0)), vec3<f32>(2.2));\n}\n\nfn toneMapNeutral(color: vec3<f32>) -> vec3<f32> {\n let low = min(color.r, min(color.g, color.b));\n let offset = select(0.04, low - 6.25 * low * low, low < 0.08);\n let shifted = color - offset;\n let peak = max(shifted.r, max(shifted.g, shifted.b));\n\n if (peak < 0.76) {\n return shifted;\n }\n\n let newPeak = 1.0 - 0.24 * 0.24 / (peak + 0.24 - 0.76);\n let g = 1.0 - 1.0 / (0.15 * (peak - newPeak) + 1.0);\n\n return shifted * (newPeak / peak) * (1.0 - g) + newPeak * g;\n}\n\nfn toneMap(color: vec3<f32>) -> vec3<f32> {\n let exposed = max(color * present.exposure, vec3<f32>(0.0));\n var mapped = exposed;\n\n if (present.toneMapping == 0u) {\n mapped = toneMapAces(exposed);\n } else if (present.toneMapping == 1u) {\n mapped = toneMapAgx(exposed);\n } else if (present.toneMapping == 2u) {\n mapped = toneMapNeutral(exposed);\n }\n\n return clamp(mapped, vec3<f32>(0.0), vec3<f32>(1.0));\n}\n\nfn linearToSrgb(color: vec3<f32>) -> vec3<f32> {\n let low = color * 12.92;\n let high = 1.055 * pow(max(color, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;\n\n return select(high, low, color <= vec3<f32>(0.0031308));\n}\n\nfn hash(value: u32) -> f32 {\n var state = value * 747796405u + 2891336453u;\n state = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;\n\n return f32((state >> 22u) ^ state) * 2.3283064365386963e-10;\n}\n\n@fragment\nfn fragmentMain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {\n let pixel = vec2<u32>(position.xy);\n let index = pixel.y * present.resolution.x + pixel.x;\n let stored = accumulation[index];\n\n // Nothing has been traced into this pixel yet: it stays see-through, so whatever is under the canvas shows.\n if (stored.w <= 0.0) {\n return vec4<f32>(0.0);\n }\n\n var color = select(stored.rgb / stored.w, vec3<f32>(0.0), stored.w <= 0.0);\n\n if (present.denoised != 0u) {\n color = denoised[index].rgb;\n }\n var alpha = 1.0;\n\n if (present.transparent != 0u) {\n let coverage = accumulation[present.resolution.x * present.resolution.y + index].x;\n alpha = select(clamp(coverage / stored.w, 0.0, 1.0), 0.0, stored.w <= 0.0);\n // The radiance is premultiplied by the coverage; the tone curve applies to the colour itself.\n color = select(color / alpha, vec3<f32>(0.0), alpha <= 0.0);\n }\n\n let centred = position.xy / vec2<f32>(present.resolution) - 0.5;\n let falloff = 1.0 - present.vignette * dot(centred, centred) * 2.0;\n var output = linearToSrgb(toneMap(color * max(falloff, 0.0)));\n\n if (present.grain > 0.0) {\n let noise = hash(index * 9781u + present.frameIndex * 6151u) - 0.5;\n output = clamp(output + noise * present.grain * 0.1, vec3<f32>(0.0), vec3<f32>(1.0));\n }\n\n return vec4<f32>(output * alpha, alpha);\n}\n";
7
7
  //# sourceMappingURL=present.wgsl.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"present.wgsl.d.ts","sourceRoot":"","sources":["../../src/wgsl/present.wgsl.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,YAAY,63KAmJxB,CAAC"}
1
+ {"version":3,"file":"present.wgsl.d.ts","sourceRoot":"","sources":["../../src/wgsl/present.wgsl.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,YAAY,ulMA6JxB,CAAC"}
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"present.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/present.wgsl.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmJtC,CAAC"}
1
+ {"version":3,"file":"present.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/present.wgsl.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6JtC,CAAC"}
@@ -5,5 +5,5 @@
5
5
  * random numbers. Only the sample index, the pixel and a dimension counter feed the generator, so a pixel draws the same
6
6
  * numbers whichever dispatch happens to cover it and the image does not depend on the tiling.
7
7
  */
8
- export declare const RANDOM_WGSL = "\nfn pcgHash(value: u32) -> u32 {\n let state = value * 747796405u + 2891336453u;\n let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;\n\n return (word >> 22u) ^ word;\n}\n\nstruct PathSampler {\n seed: u32,\n index: u32,\n dimension: u32,\n};\n\nfn makeSampler(pixel: vec2<u32>, index: u32) -> PathSampler {\n var rng: PathSampler;\n rng.seed = pcgHash(pixel.x * 1973u + pixel.y * 9277u + 1u);\n rng.index = index;\n rng.dimension = 0u;\n\n return rng;\n}\n\n/**\n * The Laine-Karras hash, which permutes the high bits in a way that nests: what Owen scrambling asks for. Every\n * multiplier has to be even, or a bit stops depending on itself and the map is no longer a permutation.\n */\nfn laineKarras(value: u32, seed: u32) -> u32 {\n var x = value + seed;\n x = x ^ (x * 0x6c50b47cu);\n x = x ^ (x * 0xb82f1e52u);\n x = x ^ (x * 0xc7afe784u);\n x = x ^ (x * 0x8d22f6e6u);\n\n return x;\n}\n\nfn nestedScramble(value: u32, seed: u32) -> u32 {\n return reverseBits(laineKarras(reverseBits(value), seed));\n}\n\n/**\n * The first two Sobol dimensions, as fixed point fractions. The second is the index times Pascal's triangle mod 2,\n * which is linear in the bits of the index, so it is read one byte at a time from the tables texturePack.ts writes\n * instead of folded in one bit at a time: four reads where the scrambled index made it thirty two steps.\n */\nfn sobol2(index: u32) -> vec2<u32> {\n let second = textures[SOBOL_TABLE + (index & 0xffu)]\n ^ textures[SOBOL_TABLE + 256u + ((index >> 8u) & 0xffu)]\n ^ textures[SOBOL_TABLE + 512u + ((index >> 16u) & 0xffu)]\n ^ textures[SOBOL_TABLE + 768u + (index >> 24u)];\n\n return vec2<u32>(reverseBits(index), second);\n}\n\nfn toUnit(value: u32) -> f32 {\n return min(f32(value) * 2.3283064365386963e-10, 0.99999994);\n}\n\n/**\n * The next pair of dimensions, in [0, 1). The sample index is shuffled per pair as well, which pads the two dimensional\n * sequence out to as many dimensions as a path needs without correlating them.\n */\nfn sample2D(rng: ptr<function, PathSampler>) -> vec2<f32> {\n let pair = (*rng).seed + (*rng).dimension * 2654435761u;\n (*rng).dimension = (*rng).dimension + 1u;\n\n let shuffled = nestedScramble((*rng).index, pcgHash(pair));\n let point = sobol2(shuffled);\n\n return vec2<f32>(\n toUnit(nestedScramble(point.x, pcgHash(pair + 0x9e3779b9u))),\n toUnit(nestedScramble(point.y, pcgHash(pair + 0x3c6ef372u))));\n}\n";
8
+ export declare const RANDOM_WGSL = "\nfn pcgHash(value: u32) -> u32 {\n let state = value * 747796405u + 2891336453u;\n let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;\n\n return (word >> 22u) ^ word;\n}\n\nstruct PathSampler {\n seed: u32,\n index: u32,\n dimension: u32,\n};\n\nfn makeSampler(pixel: vec2<u32>, index: u32) -> PathSampler {\n var rng: PathSampler;\n rng.seed = pcgHash(pixel.x * 1973u + pixel.y * 9277u + 1u);\n rng.index = index;\n rng.dimension = 0u;\n\n return rng;\n}\n\n/**\n * The Laine-Karras hash, which permutes the high bits in a way that nests: what Owen scrambling asks for. Every\n * multiplier has to be even, or a bit stops depending on itself and the map is no longer a permutation.\n */\nfn laineKarras(value: u32, seed: u32) -> u32 {\n var x = value + seed;\n x = x ^ (x * 0x6c50b47cu);\n x = x ^ (x * 0xb82f1e52u);\n x = x ^ (x * 0xc7afe784u);\n x = x ^ (x * 0x8d22f6e6u);\n\n return x;\n}\n\nfn nestedScramble(value: u32, seed: u32) -> u32 {\n return reverseBits(laineKarras(reverseBits(value), seed));\n}\n\n/**\n * The first two Sobol dimensions, as fixed point fractions. The second is the index times Pascal's triangle mod 2,\n * which is linear in the bits of the index, so it is read one byte at a time from the tables texturePack.ts writes\n * instead of folded in one bit at a time: four reads where the scrambled index made it thirty two steps.\n */\nfn sobol2(index: u32) -> vec2<u32> {\n let second = textures[SOBOL_TABLE + (index & 0xffu)]\n ^ textures[SOBOL_TABLE + 256u + ((index >> 8u) & 0xffu)]\n ^ textures[SOBOL_TABLE + 512u + ((index >> 16u) & 0xffu)]\n ^ textures[SOBOL_TABLE + 768u + (index >> 24u)];\n\n return vec2<u32>(reverseBits(index), second);\n}\n\nfn toUnit(value: u32) -> f32 {\n return min(f32(value) * 2.3283064365386963e-10, 0.99999994);\n}\n\n/**\n * The next pair of dimensions from a plain hash instead of the scrambled sequence: for the deep dimensions of a random\n * walk, where stratification buys nothing and the sequence's table reads and scrambles cost a step more than its ray.\n * It takes a dimension of its own, so the sequence's draws after it stay where they would have been.\n */\nfn hashSample2D(rng: ptr<function, PathSampler>) -> vec2<f32> {\n let first = pcgHash((*rng).seed ^ pcgHash((*rng).index * 2654435761u + (*rng).dimension * 0x9e3779b9u));\n (*rng).dimension = (*rng).dimension + 1u;\n\n return vec2<f32>(toUnit(first), toUnit(pcgHash(first)));\n}\n\n/**\n * How many vertices of a path draw from the scrambled sequence. Its stratification shows on the camera ray and the\n * first bounces, where the light a pixel sees is decided; past them the light has been averaged by every bounce\n * before, and a plain hash gives numbers as good for a fraction of the cost.\n */\nconst SEQUENCE_DEPTH: u32 = 3u;\n\n/** The next pair of dimensions of a path's vertex: from the sequence while the path is shallow, from a hash after. */\nfn pathSample2D(rng: ptr<function, PathSampler>, deep: bool) -> vec2<f32> {\n if (deep) {\n return hashSample2D(rng);\n }\n\n return sample2D(rng);\n}\n\n/**\n * The next pair of dimensions, in [0, 1). The sample index is shuffled per pair as well, which pads the two dimensional\n * sequence out to as many dimensions as a path needs without correlating them.\n */\nfn sample2D(rng: ptr<function, PathSampler>) -> vec2<f32> {\n let pair = (*rng).seed + (*rng).dimension * 2654435761u;\n (*rng).dimension = (*rng).dimension + 1u;\n\n let shuffled = nestedScramble((*rng).index, pcgHash(pair));\n let point = sobol2(shuffled);\n\n return vec2<f32>(\n toUnit(nestedScramble(point.x, pcgHash(pair + 0x9e3779b9u))),\n toUnit(nestedScramble(point.y, pcgHash(pair + 0x3c6ef372u))));\n}\n";
9
9
  //# sourceMappingURL=random.wgsl.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"random.wgsl.d.ts","sourceRoot":"","sources":["../../src/wgsl/random.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,y6EA0EvB,CAAC"}
1
+ {"version":3,"file":"random.wgsl.d.ts","sourceRoot":"","sources":["../../src/wgsl/random.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,mqHAsGvB,CAAC"}
@@ -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.
@@ -1 +1 @@
1
- {"version":3,"file":"random.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/random.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0ErC,CAAC"}
1
+ {"version":3,"file":"random.wgsl.js","sourceRoot":"","sources":["../../src/wgsl/random.wgsl.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsGrC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinypoly/gpu-renderer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "WebGPU path tracer for Three.js scenes: BVH traversal, progressive accumulation and tone mapping in WGSL compute shaders.",
5
5
  "keywords": [
6
6
  "three",
@@ -38,6 +38,7 @@
38
38
  "!dist/**/*.test.*",
39
39
  "src",
40
40
  "!src/**/*.test.ts",
41
+ "weights",
41
42
  "ARCHITECTURE.md"
42
43
  ],
43
44
  "engines": {
@@ -45,7 +46,8 @@
45
46
  },
46
47
  "dependencies": {
47
48
  "@tinypoly/cpu-renderer": "^0.3.1",
48
- "naga-wasm": "30.2.0"
49
+ "naga-wasm": "30.2.0",
50
+ "oidn-webgpu": "0.6.0"
49
51
  },
50
52
  "peerDependencies": {
51
53
  "three": ">=0.185.0"
package/src/denoise.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  import { createShaderModule } from "./shaderModule.js";
2
2
 
3
+ /**
4
+ * Which denoiser filters the image: `"oidn"`, Intel's Open Image Denoise, a neural network that keeps texture and fine
5
+ * detail, or `"svgf"`, the edge-aware wavelet filter, faster and with the controls below.
6
+ */
7
+ export type DenoiserAlgorithm = "oidn" | "svgf";
8
+
3
9
  /** How the denoiser filters. Every field is optional where it is taken, and falls back to `DEFAULT_DENOISER`. */
4
10
  export interface DenoiserSettings {
11
+ /** The denoiser that runs; OIDN falls back to SVGF where its network cannot load. */
12
+ algorithm: DenoiserAlgorithm;
13
+ /** Where OIDN's weights are fetched from; empty takes the ones shipped with the package. */
14
+ oidnWeights: string;
5
15
  /** How much of the filtered image replaces the traced one, from 0 (none) to 1. */
6
16
  strength: number;
7
- /** Wavelet passes, 1 to 5: how far the filter reaches, from 2 to 62 pixels. */
17
+ /** SVGF only from here: wavelet passes, 1 to 5, how far the filter reaches, from 2 to 62 pixels. */
8
18
  passes: number;
9
19
  /**
10
20
  * How large a luminance difference between two pixels still counts as noise, in standard deviations of the pixel's
@@ -28,6 +38,8 @@ export interface DenoiserSettings {
28
38
  }
29
39
 
30
40
  export const DEFAULT_DENOISER: Readonly<DenoiserSettings> = Object.freeze({
41
+ algorithm: "oidn",
42
+ oidnWeights: "",
31
43
  strength: 1,
32
44
  passes: 3,
33
45
  edge: 2,
@@ -42,6 +54,8 @@ export function mergeDenoiser(base: DenoiserSettings, input: Partial<DenoiserSet
42
54
  const merged = { ...base, ...Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) };
43
55
 
44
56
  return {
57
+ algorithm: merged.algorithm === "svgf" ? "svgf" : "oidn",
58
+ oidnWeights: typeof merged.oidnWeights === "string" ? merged.oidnWeights : "",
45
59
  strength: Math.min(1, Math.max(0, Number(merged.strength) || 0)),
46
60
  passes: Math.min(5, Math.max(1, Math.round(Number(merged.passes) || 1))),
47
61
  edge: Math.max(0.01, Number(merged.edge) || DEFAULT_DENOISER.edge),
package/src/device.ts CHANGED
@@ -63,10 +63,19 @@ export async function requestRenderDevice(options: RenderDeviceOptions = {}): Pr
63
63
  if (!adapter)
64
64
  throw new Error("GPU renderer: no WebGPU adapter was available.");
65
65
 
66
+ // Half floats and the adapter's widest workgroups are what the OIDN denoiser runs fastest with; the path tracer
67
+ // needs neither, and an adapter without them still gets a device.
68
+ const requiredFeatures: GPUFeatureName[] = adapter.features.has("shader-f16") ? ["shader-f16"] : [];
69
+
66
70
  const device = await adapter.requestDevice({
71
+ requiredFeatures,
67
72
  requiredLimits: {
68
73
  maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
69
74
  maxBufferSize: adapter.limits.maxBufferSize,
75
+ maxComputeWorkgroupStorageSize: adapter.limits.maxComputeWorkgroupStorageSize,
76
+ maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
77
+ maxComputeWorkgroupSizeX: adapter.limits.maxComputeWorkgroupSizeX,
78
+ maxComputeInvocationsPerWorkgroup: adapter.limits.maxComputeInvocationsPerWorkgroup,
70
79
  },
71
80
  });
72
81