@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
@@ -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.
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAy3CzC,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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinypoly/gpu-renderer",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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
 
@@ -35,6 +35,9 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
35
35
  { name: "localLightCount", type: "u32", doc: "How many leading lights sit in the scene, which the light tree picks." },
36
36
  { name: "localLightPick", type: "f32", doc: "The chance of picking one of them, the last one's cumulative probability." },
37
37
  { name: "lightTree", type: "u32", doc: "Where the light tree's nodes start in the texture buffer; zero picks by power alone." },
38
+ { name: "portals", type: "u32", doc: "Where the tree of the windows the sky is sampled through starts in the texture buffer." },
39
+ { name: "portalCount", type: "u32", doc: "How many window triangles there are; zero samples the sky by its brightness alone." },
40
+ { name: "portalList", type: "u32", doc: "Where the windows of that tree's leaves are listed in the texture buffer." },
38
41
  { name: "lightList", type: "u32", doc: "Where the lights of the tree's leaves are listed in the texture buffer." },
39
42
  { name: "lightLeaves", type: "u32", doc: "Where the leaf of every local light starts in the texture buffer." },
40
43
  { name: "clampIndirect", type: "f32", doc: "Maximum luminance one indirect contribution may add; zero leaves it." },
@@ -55,7 +58,7 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
55
58
  { name: "skyProgram", type: "u32", doc: "A procedural sky's foreground program, or NO_PROGRAM." },
56
59
  { name: "skyData", type: "u32" },
57
60
  { name: "skyBackground", type: "u32", doc: "Whether a procedural sky paints the background (1) or leaves it black (0)." },
58
- { name: "skyIntensity", type: "f32", doc: "The strength a procedural sky lights with, which its background ignores." },
61
+ { name: "skyIntensity", type: "f32", doc: "The strength a procedural sky lights with, and shows with as the background." },
59
62
  { name: "pixelCount", type: "u32", doc: "Where the alpha half of the accumulation buffer starts." },
60
63
  { name: "volumetricCount", type: "u32", doc: "Lights that scatter in the air, and where their records start." },
61
64
  { name: "volumetricData", type: "u32" },
@@ -62,6 +62,7 @@ export interface ResolveSettings {
62
62
  exposure: number;
63
63
  toneMapping: RenderToneMapping;
64
64
  vignette: number;
65
+ contrast: number;
65
66
  transparent: boolean;
66
67
  }
67
68
 
@@ -92,6 +93,7 @@ export class ImageResolvePass {
92
93
  floats[4] = settings.vignette;
93
94
  integers[7] = settings.transparent ? 1 : 0;
94
95
  integers[8] = denoised ? 1 : 0;
96
+ floats[9] = settings.contrast;
95
97
  this.device.queue.writeBuffer(this.uniforms, 0, this.values);
96
98
 
97
99
  const bytes = width * height * 4;
package/src/index.ts CHANGED
@@ -19,7 +19,8 @@ export type {
19
19
  export { attachCanvas } from "./canvas.js";
20
20
 
21
21
  export { DEFAULT_DENOISER } from "./denoise.js";
22
- export type { DenoiserSettings } from "./denoise.js";
22
+ export { DEFAULT_OIDN_WEIGHTS } from "./oidnPass.js";
23
+ export type { DenoiserAlgorithm, DenoiserSettings } from "./denoise.js";
23
24
 
24
25
  // The settings only this renderer reads, beside the CPU renderer's, and the defaults it renders with.
25
26
  export {
package/src/lensPass.ts CHANGED
@@ -27,6 +27,8 @@ export interface LensEffects {
27
27
  glareAngle: number;
28
28
  /** A linear sRGB matrix, row by row, or `null` for a neutral white balance. */
29
29
  whiteBalance: number[] | null;
30
+ /** Whether the image is metered and exposed to middle grey before the display transform. */
31
+ autoExposure: boolean;
30
32
  }
31
33
 
32
34
  /** The lens effects these settings ask for, or `null` when they ask for none and the pass can be skipped. */
@@ -38,7 +40,9 @@ export function lensEffects(settings: GpuRenderSettings): LensEffects | null {
38
40
  const neutral = !Number.isFinite(camera.whiteBalance) || camera.whiteBalance === NEUTRAL_WHITE_BALANCE;
39
41
  const blades = Math.min(16, Math.max(3, Math.round(Number(glare.blades) || 6)));
40
42
 
41
- if (intensity <= 0 && streaks <= 0 && aberration <= 0 && neutral) return null;
43
+ const autoExposure = camera.autoExposure === true;
44
+
45
+ if (intensity <= 0 && streaks <= 0 && aberration <= 0 && neutral && !autoExposure) return null;
42
46
 
43
47
  return {
44
48
  bloom: intensity,
@@ -50,6 +54,7 @@ export function lensEffects(settings: GpuRenderSettings): LensEffects | null {
50
54
  glareLength: Math.min(1, Math.max(0, Number(glare.length) || 0)),
51
55
  glareAngle: (Number(glare.rotation) || 0) * Math.PI / 180,
52
56
  whiteBalance: neutral ? null : whiteBalanceMatrix(camera.whiteBalance),
57
+ autoExposure,
53
58
  };
54
59
  }
55
60
 
@@ -93,6 +98,8 @@ export class LensPass {
93
98
  private readonly upsample: GPUComputePipeline;
94
99
  private readonly composite: GPUComputePipeline;
95
100
  private readonly streaks: GPUComputePipeline;
101
+ private readonly meter: GPUComputePipeline;
102
+ private readonly metered: GPUBuffer;
96
103
  /** One uniform buffer per dispatch, so every one of them keeps its own values until the queue runs it. */
97
104
  private readonly uniforms: GPUBuffer[] = [];
98
105
  private readonly values = new ArrayBuffer(UNIFORM_BYTES);
@@ -116,6 +123,7 @@ export class LensPass {
116
123
  { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
117
124
  { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
118
125
  { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
126
+ { binding: 7, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
119
127
  ],
120
128
  });
121
129
 
@@ -126,6 +134,8 @@ export class LensPass {
126
134
  this.upsample = pipeline("upsample");
127
135
  this.composite = pipeline("composite");
128
136
  this.streaks = pipeline("streaks");
137
+ this.meter = pipeline("meter");
138
+ this.metered = device.createBuffer({ label: "lens meter", size: 16, usage: GPUBufferUsage.STORAGE });
129
139
  }
130
140
 
131
141
  /** The graded image of the last `encode`, one vec4 of linear colour per pixel. */
@@ -201,6 +211,7 @@ export class LensPass {
201
211
  integers[32] = streak.width;
202
212
  integers[33] = streak.height;
203
213
  floats[34] = effects.glareAngle;
214
+ floats[35] = effects.autoExposure ? 1 : 0;
204
215
  for (let row = 0; row < 3; row++) for (let column = 0; column < 3; column++)
205
216
  floats[20 + row * 4 + column] = white[row * 3 + column];
206
217
  fill();
@@ -222,6 +233,7 @@ export class LensPass {
222
233
  { binding: 4, resource: { buffer: this.up as GPUBuffer } },
223
234
  { binding: 5, resource: { buffer: this.graded as GPUBuffer } },
224
235
  { binding: 6, resource: { buffer: this.streaked as GPUBuffer } },
236
+ { binding: 7, resource: { buffer: this.metered } },
225
237
  ],
226
238
  }));
227
239
  pass.dispatchWorkgroups(Math.ceil(target.width / WORKGROUP), Math.ceil(target.height / WORKGROUP));
@@ -278,6 +290,9 @@ export class LensPass {
278
290
  }
279
291
  }
280
292
 
293
+ // The meter is one workgroup, whatever the size: dispatched as a single tile of the grid it reads.
294
+ if (effects.autoExposure) dispatch(this.meter, { width: 1, height: 1 }, () => {});
295
+
281
296
  dispatch(this.composite, { width, height }, () => {});
282
297
 
283
298
  return this.graded as GPUBuffer;
@@ -293,6 +308,7 @@ export class LensPass {
293
308
 
294
309
  destroy() {
295
310
  this.destroyBuffers();
311
+ this.metered.destroy();
296
312
  for (const buffer of this.uniforms) buffer.destroy();
297
313
  this.uniforms.length = 0;
298
314
  }