@driftengine/core 4.2.1 → 4.4.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 (215) hide show
  1. package/CHANGELOG.json +272 -0
  2. package/README.md +1 -1
  3. package/dist/cinematic/cameraPath.d.ts +53 -0
  4. package/dist/cinematic/cameraPath.js +118 -0
  5. package/dist/environment/daylight.d.ts +77 -0
  6. package/dist/environment/daylight.js +77 -0
  7. package/dist/geometry/meshConcat.d.ts +9 -0
  8. package/dist/geometry/meshConcat.js +101 -0
  9. package/dist/index.d.ts +12 -2
  10. package/dist/index.js +14 -1
  11. package/dist/render/backend/createRenderer.d.ts +13 -1
  12. package/dist/render/backend/createRenderer.js +6 -5
  13. package/dist/render/backend/webgl2/renderer.d.ts +166 -1
  14. package/dist/render/backend/webgl2/renderer.js +424 -23
  15. package/dist/render/backend/webgpu/buffers.js +46 -8
  16. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  17. package/dist/render/backend/webgpu/depthPass.js +75 -14
  18. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  19. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  20. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  21. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  22. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  23. package/dist/render/backend/webgpu/flatPass.js +12 -5
  24. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  25. package/dist/render/backend/webgpu/particlePass.js +1 -0
  26. package/dist/render/backend/webgpu/postPass.d.ts +12 -2
  27. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  28. package/dist/render/backend/webgpu/probeBake.js +97 -7
  29. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  30. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  31. package/dist/render/backend/webgpu/renderer.d.ts +176 -11
  32. package/dist/render/backend/webgpu/renderer.js +680 -250
  33. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  34. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  35. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  36. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  37. package/dist/render/backend/webgpu/skyPass.js +5 -1
  38. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  39. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  40. package/dist/render/bloomChain.d.ts +6 -0
  41. package/dist/render/bloomChain.js +8 -0
  42. package/dist/render/clusteredLights.d.ts +19 -11
  43. package/dist/render/clusteredLights.js +96 -45
  44. package/dist/render/cutoutCaster.d.ts +23 -0
  45. package/dist/render/cutoutCaster.js +20 -0
  46. package/dist/render/driftLight/bake.d.ts +34 -0
  47. package/dist/render/driftLight/bake.js +95 -0
  48. package/dist/render/driftLight/falloff.d.ts +16 -0
  49. package/dist/render/driftLight/falloff.js +23 -0
  50. package/dist/render/driftLight/layout.d.ts +59 -0
  51. package/dist/render/driftLight/layout.js +138 -0
  52. package/dist/render/driftLight/lightField.d.ts +108 -0
  53. package/dist/render/driftLight/lightField.js +179 -0
  54. package/dist/render/driftLight/uniforms.d.ts +28 -0
  55. package/dist/render/driftLight/uniforms.js +41 -0
  56. package/dist/render/driftLight/visibility.d.ts +18 -0
  57. package/dist/render/driftLight/visibility.js +47 -0
  58. package/dist/render/emptyTexture.d.ts +6 -0
  59. package/dist/render/emptyTexture.js +19 -0
  60. package/dist/render/exposurePass.d.ts +48 -0
  61. package/dist/render/exposurePass.js +172 -0
  62. package/dist/render/flameLight.d.ts +71 -0
  63. package/dist/render/flameLight.js +63 -0
  64. package/dist/render/gi/bounceLights.d.ts +34 -0
  65. package/dist/render/gi/bounceLights.js +66 -0
  66. package/dist/render/gi/fieldReach.d.ts +12 -0
  67. package/dist/render/gi/fieldReach.js +62 -0
  68. package/dist/render/gi/globalField.d.ts +8 -0
  69. package/dist/render/gi/globalField.js +31 -0
  70. package/dist/render/gi/probeTrace.d.ts +3 -1
  71. package/dist/render/gi/probeTrace.js +3 -1
  72. package/dist/render/gpuCapability.js +19 -4
  73. package/dist/render/lightBudget.d.ts +11 -0
  74. package/dist/render/lightBudget.js +11 -0
  75. package/dist/render/pointLightSelection.d.ts +16 -0
  76. package/dist/render/pointLightSelection.js +178 -83
  77. package/dist/render/probeGrid.d.ts +11 -1
  78. package/dist/render/probeGrid.js +5 -2
  79. package/dist/render/probeSweeps.d.ts +45 -0
  80. package/dist/render/probeSweeps.js +77 -0
  81. package/dist/render/reflectionProbe.d.ts +43 -1
  82. package/dist/render/reflectionProbe.js +17 -3
  83. package/dist/render/renderQuality.d.ts +3 -1
  84. package/dist/render/rendererHarness.d.ts +9 -0
  85. package/dist/render/rendererHarness.js +18 -3
  86. package/dist/render/sceneTarget.d.ts +20 -0
  87. package/dist/render/sceneTarget.js +62 -1
  88. package/dist/render/shaders/agx.d.ts +31 -0
  89. package/dist/render/shaders/agx.js +81 -0
  90. package/dist/render/shaders/ambientOcclusion.d.ts +24 -5
  91. package/dist/render/shaders/ambientOcclusion.js +120 -63
  92. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  93. package/dist/render/shaders/cutoutCoverage.js +40 -0
  94. package/dist/render/shaders/depth.d.ts +25 -0
  95. package/dist/render/shaders/depth.js +69 -3
  96. package/dist/render/shaders/exposure.d.ts +52 -0
  97. package/dist/render/shaders/exposure.js +124 -0
  98. package/dist/render/shaders/filmLook.d.ts +47 -0
  99. package/dist/render/shaders/filmLook.js +98 -0
  100. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  101. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  102. package/dist/render/shaders/flat/driftLight.js +75 -0
  103. package/dist/render/shaders/flat/index.js +5 -0
  104. package/dist/render/shaders/flat/lobes.js +13 -0
  105. package/dist/render/shaders/flat/main.d.ts +1 -1
  106. package/dist/render/shaders/flat/main.js +68 -9
  107. package/dist/render/shaders/flat/preamble.js +23 -5
  108. package/dist/render/shaders/flat/probeGrid.js +46 -3
  109. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +1 -1
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +1 -1
  111. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  112. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  113. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  115. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  116. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  117. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  118. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  119. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  120. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  121. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  123. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  124. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  125. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  126. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  127. package/dist/render/shaders/globalMedium.js +4 -0
  128. package/dist/render/shaders/localExposure.d.ts +35 -0
  129. package/dist/render/shaders/localExposure.js +138 -0
  130. package/dist/render/shaders/prefilter.d.ts +1 -1
  131. package/dist/render/shaders/prefilter.js +6 -0
  132. package/dist/render/shaders/rush.js +33 -5
  133. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  134. package/dist/render/shaders/tangentFrame.js +15 -3
  135. package/dist/render/shaders/temporalResolve.js +39 -1
  136. package/dist/render/shadowCasters.d.ts +5 -3
  137. package/dist/render/shadowMap.js +13 -0
  138. package/dist/render/surfaceTexture.d.ts +5 -0
  139. package/package.json +6 -6
  140. package/scripts/gpuCompute.mjs +24 -3
  141. package/scripts/png.mjs +23 -12
  142. package/src/cinematic/cameraPath.ts +178 -0
  143. package/src/environment/daylight.ts +152 -0
  144. package/src/geometry/meshConcat.ts +100 -0
  145. package/src/index.ts +32 -0
  146. package/src/render/backend/createRenderer.ts +22 -6
  147. package/src/render/backend/webgl2/renderer.ts +462 -20
  148. package/src/render/backend/webgpu/buffers.ts +51 -8
  149. package/src/render/backend/webgpu/depthPass.ts +78 -10
  150. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  151. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  152. package/src/render/backend/webgpu/flatPass.ts +10 -2
  153. package/src/render/backend/webgpu/particlePass.ts +9 -0
  154. package/src/render/backend/webgpu/probeBake.ts +143 -9
  155. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  156. package/src/render/backend/webgpu/renderer.ts +807 -271
  157. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  158. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  159. package/src/render/backend/webgpu/skyPass.ts +5 -1
  160. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  161. package/src/render/bloomChain.ts +9 -0
  162. package/src/render/clusteredLights.ts +108 -63
  163. package/src/render/cutoutCaster.ts +44 -0
  164. package/src/render/driftLight/bake.ts +125 -0
  165. package/src/render/driftLight/falloff.ts +26 -0
  166. package/src/render/driftLight/layout.ts +177 -0
  167. package/src/render/driftLight/lightField.ts +227 -0
  168. package/src/render/driftLight/uniforms.ts +61 -0
  169. package/src/render/driftLight/visibility.ts +58 -0
  170. package/src/render/emptyTexture.ts +41 -0
  171. package/src/render/exposurePass.ts +186 -0
  172. package/src/render/flameLight.ts +135 -0
  173. package/src/render/gi/bounceLights.ts +68 -0
  174. package/src/render/gi/fieldReach.ts +74 -0
  175. package/src/render/gi/globalField.ts +39 -0
  176. package/src/render/gi/probeTrace.ts +3 -1
  177. package/src/render/gpuCapability.ts +18 -5
  178. package/src/render/lightBudget.ts +12 -0
  179. package/src/render/pointLightSelection.ts +191 -79
  180. package/src/render/probeGrid.ts +13 -2
  181. package/src/render/probeSweeps.ts +80 -0
  182. package/src/render/reflectionProbe.ts +54 -2
  183. package/src/render/renderQuality.ts +3 -1
  184. package/src/render/rendererHarness.ts +25 -3
  185. package/src/render/sceneTarget.ts +75 -1
  186. package/src/render/shaders/ambientOcclusion.ts +125 -63
  187. package/src/render/shaders/cutoutCoverage.ts +43 -0
  188. package/src/render/shaders/depth.ts +80 -3
  189. package/src/render/shaders/exposure.ts +128 -0
  190. package/src/render/shaders/filmLook.ts +111 -0
  191. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  192. package/src/render/shaders/flat/driftLight.ts +75 -0
  193. package/src/render/shaders/flat/index.ts +5 -0
  194. package/src/render/shaders/flat/lobes.ts +13 -0
  195. package/src/render/shaders/flat/main.ts +68 -9
  196. package/src/render/shaders/flat/preamble.ts +23 -5
  197. package/src/render/shaders/flat/probeGrid.ts +46 -3
  198. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +1 -1
  199. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  200. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  201. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  202. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  203. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  204. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  205. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  206. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  207. package/src/render/shaders/globalMedium.ts +4 -0
  208. package/src/render/shaders/localExposure.ts +154 -0
  209. package/src/render/shaders/prefilter.ts +6 -0
  210. package/src/render/shaders/rush.ts +33 -5
  211. package/src/render/shaders/tangentFrame.ts +15 -3
  212. package/src/render/shaders/temporalResolve.ts +39 -1
  213. package/src/render/shadowCasters.ts +5 -3
  214. package/src/render/shadowMap.ts +14 -0
  215. package/src/render/surfaceTexture.ts +5 -0
@@ -1,5 +1,9 @@
1
1
  import { glSceneDepthFormat } from './depthConvention.ts';
2
+ import { clampGrain, clampVignette, grainSeed } from './shaders/filmLook.ts';
2
3
  import { AmbientOcclusionPass } from './ambientOcclusionPass.ts';
4
+ import { ExposurePass } from './exposurePass.ts';
5
+ import { clampAutoExposure } from './shaders/exposure.ts';
6
+ import { clampLocalExposure } from './shaders/localExposure.ts';
3
7
  import { BloomPass } from './bloomPass.ts';
4
8
  import {
5
9
  type ColourGradeLut,
@@ -91,6 +95,16 @@ export class SceneTarget {
91
95
  private gradeSource: ColourGradeLut | null = null;
92
96
  private gradeSize = GRADE_PLACEHOLDER_SIZE;
93
97
  private gradeStrength = 0;
98
+ /** The lens and the print, held like the grade. See `filmLook.ts`. */
99
+ private vignette = 0;
100
+ private grain = 0;
101
+ private grainSeed = 0;
102
+ /** Eye adaptation: how far, and this frame's time step. See `Renderer.setAutoExposure`. */
103
+ private autoExposure = 0;
104
+ private autoExposureDt = 0;
105
+ /** How far each region is brought toward the frame. See `Renderer.setLocalExposure`. */
106
+ private localExposure = 0;
107
+ private exposure: ExposurePass | null = null;
94
108
  private width = 0;
95
109
  private height = 0;
96
110
 
@@ -606,6 +620,33 @@ export class SceneTarget {
606
620
  * `null`, or a strength at or below zero, is exactly the frame that existed before the effect
607
621
  * did: the shader's branch is on a uniform, so no fetch happens at all.
608
622
  */
623
+ /** The lens's corner falloff, held until changed. See `Renderer.setVignette`. */
624
+ setVignette(strength: number): void {
625
+ this.vignette = clampVignette(strength);
626
+ }
627
+
628
+ /** The print's grain and this frame's seed, held until changed. See `Renderer.setFilmGrain`. */
629
+ setFilmGrain(strength: number, seed: number): void {
630
+ this.grain = clampGrain(strength);
631
+ this.grainSeed = grainSeed(seed);
632
+ }
633
+
634
+ /** How far the eye adapts, and the time since the last frame. See `Renderer.setAutoExposure`. */
635
+ setAutoExposure(strength: number, dtSec: number): void {
636
+ this.autoExposure = clampAutoExposure(strength);
637
+ this.autoExposureDt = dtSec;
638
+ }
639
+
640
+ /** How far each region is brought toward the frame. See `Renderer.setLocalExposure`. */
641
+ setLocalExposure(strength: number): void {
642
+ this.localExposure = clampLocalExposure(strength);
643
+ }
644
+
645
+ /** A new shot: the eye meters it afresh rather than easing in from the last one. */
646
+ cutExposure(): void {
647
+ this.exposure?.cut();
648
+ }
649
+
609
650
  setColourGrade(lut: ColourGradeLut | null, strength: number): void {
610
651
  this.gradeStrength = lut === null ? 0 : Math.max(0, Math.min(1, strength));
611
652
  if (lut === null || lut === this.gradeSource) return;
@@ -851,6 +892,17 @@ export class SceneTarget {
851
892
  bloomTexture = this.bloom.run(scene, this.width, this.height, bloom.threshold);
852
893
  }
853
894
 
895
+ /*
896
+ * Eye adaptation, metered from the same resolved scene bloom reads, and after it for the same
897
+ * reason: the composite consumes it. Off at zero strength, where nothing is allocated or drawn.
898
+ */
899
+ let heldExposure: WebGLTexture | null = null;
900
+ const local = this.localExposure > 0;
901
+ if ((this.autoExposure > 0 || local) && scene !== null) {
902
+ this.exposure ??= new ExposurePass(gl);
903
+ heldExposure = this.exposure.run(scene, this.autoExposureDt, local);
904
+ }
905
+
854
906
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
855
907
  gl.viewport(0, 0, this.width, this.height);
856
908
  gl.disable(gl.DEPTH_TEST);
@@ -946,6 +998,25 @@ export class SceneTarget {
946
998
  gl.uniform1i(this.uniforms['uGradeLut'] ?? null, 4);
947
999
  gl.uniform1f(this.uniforms['uGradeStrength'] ?? null, this.gradeStrength);
948
1000
  gl.uniform1f(this.uniforms['uGradeSize'] ?? null, this.gradeSize);
1001
+
1002
+ /* The held brightness on unit 5, and the scene as the placeholder where there is none, for
1003
+ the reason the occlusion's placeholder gives. Zero strength is the whole of the off path. */
1004
+ gl.activeTexture(gl.TEXTURE5);
1005
+ gl.bindTexture(gl.TEXTURE_2D, heldExposure ?? scene);
1006
+ gl.uniform1i(this.uniforms['uExposureHeld'] ?? null, 5);
1007
+ gl.uniform1f(
1008
+ this.uniforms['uAutoExposure'] ?? null,
1009
+ heldExposure === null ? 0 : this.autoExposure,
1010
+ );
1011
+ /* Local exposure's grid on unit 6, with the same placeholder and the same off path. */
1012
+ const localGrid = local && heldExposure !== null ? (this.exposure?.localTexture ?? null) : null;
1013
+ gl.activeTexture(gl.TEXTURE6);
1014
+ gl.bindTexture(gl.TEXTURE_2D, localGrid ?? scene);
1015
+ gl.uniform1i(this.uniforms['uExposureLocal'] ?? null, 6);
1016
+ gl.uniform1f(
1017
+ this.uniforms['uLocalExposure'] ?? null,
1018
+ localGrid === null ? 0 : this.localExposure,
1019
+ );
949
1020
  gl.activeTexture(gl.TEXTURE0);
950
1021
 
951
1022
  /* No unit and no texture: a flat colour, not a sample. Uploaded unconditionally — two
@@ -954,6 +1025,8 @@ export class SceneTarget {
954
1025
  mix it does not need. */
955
1026
  gl.uniform3fv(this.uniforms['uVeilColor'] ?? null, veilColor);
956
1027
  gl.uniform1f(this.uniforms['uVeilAlpha'] ?? null, veilAlpha);
1028
+ gl.uniform1f(this.uniforms['uVignette'] ?? null, this.vignette);
1029
+ gl.uniform2f(this.uniforms['uGrain'] ?? null, this.grain, this.grainSeed);
957
1030
 
958
1031
  gl.bindVertexArray(this.vao);
959
1032
  gl.drawArrays(gl.TRIANGLES, 0, 3);
@@ -970,7 +1043,7 @@ export class SceneTarget {
970
1043
  * own framebuffers on the next frame, and that is how this was found: 120 pixels of a frame
971
1044
  * that should have been identical, and a warning per draw explaining why.
972
1045
  */
973
- for (let unit = 0; unit <= 4; unit++) {
1046
+ for (let unit = 0; unit <= 5; unit++) {
974
1047
  gl.activeTexture(gl.TEXTURE0 + unit);
975
1048
  gl.bindTexture(gl.TEXTURE_2D, null);
976
1049
  }
@@ -999,6 +1072,7 @@ export class SceneTarget {
999
1072
  dispose(): void {
1000
1073
  const { gl } = this;
1001
1074
  this.ao?.dispose();
1075
+ this.exposure?.dispose();
1002
1076
  this.ao = null;
1003
1077
  this.bloom?.dispose();
1004
1078
  this.bloom = null;
@@ -28,11 +28,20 @@ import { glslIsFarDepth, glslSceneDepthToNdc } from '../depthConvention.ts';
28
28
  /**
29
29
  * The estimate. Writes one channel: 1 is open sky, 0 is fully enclosed.
30
30
  *
31
- * The estimator is the Alchemy/scalable-AO one — for each neighbour, the vector v from this
32
- * surface to it, weighted max(0, v·n) / (v·v). Two properties earn it its place over a
33
- * hemisphere of offset points: nothing has to be oriented, so there is no tangent frame and
34
- * no rotation matrix per pixel, and the 1 / v·v term falls away on its own, so a neighbour
35
- * further off contributes less without a second range test doing it by hand.
31
+ * **Horizons, not neighbours: ground-truth ambient occlusion (Jimenez et al. 2016).** Each pixel
32
+ * walks two lines across the screen, both ways, and keeps the highest thing it can see along each
33
+ * half: the horizon. The sky left between the two horizons of a line, weighted by the cosine to the
34
+ * surface's own normal, is that line's visibility in closed form, and two lines a pixel with the
35
+ * rotation tile below turning them is the hemisphere. It replaced a sum over twelve neighbours that
36
+ * counted anything within the radius as a blocker, however thin: cloth hanging a hand in front of a
37
+ * column darkened the column behind it, which read as a halo round every curtain in a courtyard.
38
+ *
39
+ * **What stops the halo is two rules about the horizon**, both the paper's. A sample fades out of
40
+ * the horizon as it approaches the radius, so a far wall does not shade a floor; and once the walk
41
+ * has passed the highest sample, the horizon sinks back toward what it sees next, so a thin thing
42
+ * in front raises it only while the walk is behind that thing. What that gives up is the shade under
43
+ * a thick object seen edge on, which reads as a thin one; the thickness is a constant because depth
44
+ * alone cannot say how deep anything is.
36
45
  */
37
46
  export const AO_FRAG = `#version 300 es
38
47
  precision highp float;
@@ -69,16 +78,18 @@ uniform float uRadius;
69
78
 
70
79
  out float fragColor;
71
80
 
72
- /** How many depths each pixel compares itself against. Fixed, so the cost is fixed. */
73
- const int AO_TAPS = 12;
81
+ /** Lines across the screen a pixel walks, each both ways. Fixed, so the cost is fixed. */
82
+ const int AO_SLICES = 2;
83
+ /** Depth samples each way along a line: two lines, two ways, six steps is 24 a pixel. */
84
+ const int AO_STEPS = 6;
74
85
  /**
75
- * The golden angle, in radians.
86
+ * How far the horizon sinks back toward each later sample once the walk has passed the highest one.
76
87
  *
77
- * Successive taps turned by it never line up into spokes, which a rational fraction of a
78
- * turn does: a kernel that repeats a direction is blind along it, and it is blind the same
79
- * way in every pixel, so it reads as a pattern in the surface rather than as noise.
88
+ * The paper's thickness heuristic. At zero anything within the radius shades for good, which is the
89
+ * halo; at one a horizon lasts one sample and a real corner loses its shade. A fifth keeps a corner,
90
+ * whose samples keep rising, and lets a curtain's shade drop within the few samples past its edge.
80
91
  */
81
- const float AO_GOLDEN_ANGLE = 2.39996323;
92
+ const float AO_THIN = 0.2;
82
93
  /**
83
94
  * How far the sampling may reach across the frame, whatever the depth says.
84
95
  *
@@ -87,25 +98,6 @@ const float AO_GOLDEN_ANGLE = 2.39996323;
87
98
  * being contact shading and becomes an expensive wide darkening. 6% of the frame.
88
99
  */
89
100
  const float AO_MAX_REACH_UV = 0.06;
90
- /**
91
- * Self-occlusion guard, as a fraction of the pixel's own view depth.
92
- *
93
- * Depth is quantised, so a flat surface samples itself very slightly in front of itself and
94
- * every flat surface in the world comes back faintly dirty. The error grows with distance,
95
- * which is why the guard is proportional to depth rather than a constant.
96
- */
97
- const float AO_BIAS = 0.004;
98
- /**
99
- * The floor under the 1 / (v·v) weighting, in square metres.
100
- *
101
- * Not a guard against dividing by zero, which is what a value near the float epsilon would
102
- * be. The nearest taps land about a centimetre away, where v·v is 1e-4 and the weight is ten
103
- * thousand — so a depth quantised to a millimetre yields several units of occlusion out of
104
- * nothing at all. Measured that way first, with 1e-4 here: a flat floor came back as static.
105
- * A hundredth of a square metre is a tenth of a metre of separation, which is the scale
106
- * below which two samples are the same surface rather than one occluding the other.
107
- */
108
- const float AO_EPSILON = 0.01;
109
101
  /**
110
102
  * How many times further one neighbour may be than the other before the pair straddles an edge.
111
103
  *
@@ -134,6 +126,17 @@ vec3 viewPosition(vec2 uv, float depth) {
134
126
  return view.xyz / view.w;
135
127
  }
136
128
 
129
+ /**
130
+ * The cosine-weighted sky between two horizon angles h0 < 0 < h1, measured from the eye, for a
131
+ * normal at the angle given in the same plane: the paper's closed form. 1 for an open plane facing
132
+ * the eye.
133
+ */
134
+ float gtaoArc(float h0, float h1, float normal) {
135
+ float c = cos(normal);
136
+ float s2 = 2.0 * sin(normal);
137
+ return 0.25 * (-cos(2.0 * h0 - normal) + c + h0 * s2) + 0.25 * (-cos(2.0 * h1 - normal) + c + h1 * s2);
138
+ }
139
+
137
140
  void main() {
138
141
  /*
139
142
  * One output pixel, in UV, along each axis.
@@ -215,15 +218,16 @@ void main() {
215
218
  vec3 n = normalize(cross(dpdx, dpdy));
216
219
  /*
217
220
  * Turned to face the eye. Which sign the cross product comes out with is a fact about the
218
- * screen-space winding and the handedness of the projection, and a normal pointing away
219
- * from the camera makes every v·n come back zero, which is a frame with no occlusion in it
220
- * anywhere. In view space the eye is the origin, so a normal facing it satisfies n·p < 0.
221
+ * screen-space winding and the handedness of the projection, and a normal pointing away from the
222
+ * camera puts every horizon behind the surface. In view space the eye is the origin, so a normal
223
+ * facing it satisfies n·p < 0.
221
224
  */
222
225
  if (dot(n, p) > 0.0) n = -n;
226
+ vec3 toEye = normalize(-p);
223
227
 
224
228
  /*
225
- * The radius as a fraction of the frame. A metre subtends less of the screen the further
226
- * away it is, which is the entire reason this is computed per pixel rather than handed in.
229
+ * The radius as a fraction of the frame. A metre subtends less of the screen the further away it
230
+ * is, which is the entire reason this is computed per pixel rather than handed in.
227
231
  */
228
232
  vec2 reach = min(
229
233
  uRadius * uProjScale * 0.5 / max(-p.z, 1e-3),
@@ -231,41 +235,77 @@ void main() {
231
235
  );
232
236
 
233
237
  /*
234
- * A rotation that repeats every four pixels: sixteen distinct turns laid out over a 4x4
235
- * tile. Paired with the four-wide blur that follows, every blurred pixel averages all
236
- * sixteen, which is what makes twelve taps enough. A per-pixel hash would decorrelate
237
- * neighbours better in principle and is worse here, because the blur can then only soften
238
- * the noise rather than complete the kernel.
238
+ * A rotation that repeats every four pixels: sixteen turns laid out over a 4x4 tile, spanning the
239
+ * quarter turn between one line and the next, so the eight-wide blur that follows averages every
240
+ * turn of both lines. The first step's distance is staggered by the same tile, so neighbouring
241
+ * pixels do not all sample the same rings either.
239
242
  */
240
243
  float tile = mod(gl_FragCoord.x, 4.0) + 4.0 * mod(gl_FragCoord.y, 4.0);
241
- float turn = tile * (6.2831853 / 16.0);
242
-
243
- float sum = 0.0;
244
- for (int i = 0; i < AO_TAPS; i++) {
245
- float t = (float(i) + 0.5) / float(AO_TAPS);
246
- float angle = turn + float(i) * AO_GOLDEN_ANGLE;
247
- /* The square root spreads the taps evenly over the disk; without it they crowd the
248
- centre, where they are measuring the pixel's own depth. */
249
- vec2 uv = vUv + vec2(cos(angle), sin(angle)) * sqrt(t) * reach;
244
+ float turn = tile * (1.5707963 / 16.0);
245
+ float stagger = fract(tile * 0.618034);
246
+ /* One output pixel along each axis, in UV. */
247
+ vec2 pixel = vec2(length(stepX), length(stepY));
250
248
 
249
+ float visible = 0.0;
250
+ for (int slice = 0; slice < AO_SLICES; slice++) {
251
+ float angle = turn + float(slice) * (3.1415927 / float(AO_SLICES));
252
+ /*
253
+ * **Even in pixels, not in UV.** A UV step across a wide frame is longer than one down it, so
254
+ * turns spread evenly in UV crowd toward the horizontal once they reach the screen, and on a
255
+ * floor the horizontal lines are the ones that see least of its normal: every flat floor and
256
+ * ceiling came back at 0.82 open, measured, where the construction gives 0.97. A pixel is as
257
+ * wide as it is tall in view space, so a direction even in pixels is even in the world. It is
258
+ * scaled here into UV, and the reach is taken down the frame, whose UV is the same pixels.
259
+ */
260
+ vec2 screen = vec2(cos(angle) * pixel.x / pixel.y, sin(angle));
251
261
  /*
252
- * textureLod, not texture, and it is the rule rather than a preference: this sample sits
253
- * inside a loop whose iterations a compiler cannot prove uniform, and an implicit
254
- * derivative there is what lets one flatten the body and pay for every arm. The fetch is
255
- * identical — the depth texture has one storage level and NEAREST filters, so there is no
256
- * mip for a derivative to select. See AGENTS.md, 2026-08-07.
262
+ * The line in view space, taken through the same reconstruction the samples use: a step along
263
+ * the screen at this pixel's own depth, so the samples lie in the plane the arc is measured in
264
+ * whatever the frame's aspect and whichever way a backend's clip space counts up.
257
265
  */
258
- float sampled = textureLod(uDepth, uv, 0.0).r;
259
- if (${glslIsFarDepth('sampled')}) continue;
266
+ vec3 along = normalize(viewPosition(vUv + screen * 1e-3, depth) - p);
267
+ vec3 across = normalize(along - toEye * dot(along, toEye));
268
+ vec3 axis = cross(across, toEye);
269
+ /* The normal projected into the line's plane, and its angle from the eye toward +across. */
270
+ vec3 projected = n - axis * dot(n, axis);
271
+ float projectedLength = length(projected);
272
+ if (projectedLength < 1e-4) continue;
273
+ float normalAngle = atan(dot(projected, across), dot(projected, toEye));
260
274
 
261
- vec3 v = viewPosition(uv, sampled) - p;
262
- float vv = dot(v, v);
263
- /* Past the radius it is a different surface rather than a neighbour of this one. */
264
- if (vv > uRadius * uRadius) continue;
265
- sum += max(0.0, dot(v, n) - AO_BIAS * (-p.z)) / (vv + AO_EPSILON);
275
+ /* The highest cosine seen each way. */
276
+ float horizon0 = -1.0;
277
+ float horizon1 = -1.0;
278
+ for (int k = 0; k < AO_STEPS; k++) {
279
+ float t = (float(k) + stagger) / float(AO_STEPS);
280
+ /* Squared, so the steps crowd the pixel, where contact shade lives. */
281
+ vec2 stride = screen * reach.y * (t * t + 0.5 / float(AO_STEPS * AO_STEPS));
282
+ for (int side = 0; side < 2; side++) {
283
+ vec2 uv = side == 0 ? vUv - stride : vUv + stride;
284
+ /*
285
+ * textureLod, not texture, and it is the rule rather than a preference: this sits inside a
286
+ * loop whose iterations a compiler cannot prove uniform. The depth texture has one level and
287
+ * NEAREST filters, so the fetch is identical. See AGENTS.md, 2026-08-07.
288
+ */
289
+ float sampled = textureLod(uDepth, uv, 0.0).r;
290
+ if (${glslIsFarDepth('sampled')}) continue;
291
+ vec3 v = viewPosition(uv, sampled) - p;
292
+ float distance2 = dot(v, v);
293
+ if (distance2 < 1e-8) continue;
294
+ float cosine = dot(v, toEye) * inversesqrt(distance2);
295
+ /* Faded to nothing at the radius: a far wall is not an occluder of this surface. */
296
+ float fade = clamp(1.0 - distance2 / (uRadius * uRadius), 0.0, 1.0);
297
+ cosine = mix(-1.0, cosine, fade);
298
+ if (side == 0) horizon0 = cosine > horizon0 ? cosine : mix(horizon0, cosine, AO_THIN);
299
+ else horizon1 = cosine > horizon1 ? cosine : mix(horizon1, cosine, AO_THIN);
300
+ }
301
+ }
302
+ /* Angles from the eye, negative toward -across, and never below the surface's own tangent. */
303
+ float h0 = max(-acos(clamp(horizon0, -1.0, 1.0)), normalAngle - 1.5707963);
304
+ float h1 = min(acos(clamp(horizon1, -1.0, 1.0)), normalAngle + 1.5707963);
305
+ visible += projectedLength * gtaoArc(h0, h1, normalAngle);
266
306
  }
267
307
 
268
- fragColor = clamp(1.0 - 2.0 * uRadius * sum / float(AO_TAPS), 0.0, 1.0);
308
+ fragColor = clamp(visible / float(AO_SLICES), 0.0, 1.0);
269
309
  }
270
310
  `;
271
311
 
@@ -352,3 +392,25 @@ void main() {
352
392
  fragColor = weight > 0.0 ? sum / weight : textureLod(uAo, vUv, 0.0).r;
353
393
  }
354
394
  `;
395
+
396
+ /**
397
+ * The estimate's closed form, written out once more so a test can pin it to hand-derived values:
398
+ * the cosine-weighted sky between horizon angles `h0 < 0 < h1` for a normal at `normal`, all in
399
+ * radians from the eye within one line's plane.
400
+ */
401
+ export function gtaoArc(h0: number, h1: number, normal: number): number {
402
+ const c = Math.cos(normal);
403
+ const s2 = 2 * Math.sin(normal);
404
+ return (
405
+ 0.25 * (-Math.cos(2 * h0 - normal) + c + h0 * s2) +
406
+ 0.25 * (-Math.cos(2 * h1 - normal) + c + h1 * s2)
407
+ );
408
+ }
409
+
410
+ /** One step of a horizon: up to a higher sample, or a fifth of the way down toward a lower one. */
411
+ export function nextHorizon(horizon: number, cosine: number): number {
412
+ return cosine > horizon ? cosine : horizon + (cosine - horizon) * AO_THIN;
413
+ }
414
+
415
+ /** The shader's thickness heuristic. See `AO_THIN` inside `AO_FRAG`. */
416
+ export const AO_THIN = 0.2;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A cutout that keeps its coverage down the mip chain.
3
+ *
4
+ * **Averaging alpha thins everything that is thinner than a texel.** Each level of a mip chain is
5
+ * the mean of four texels of the one above, so a needle one texel wide on a clear background is 0.5
6
+ * at the next level, 0.25 at the one after, and gone against a cutoff of 0.5 two levels down. A
7
+ * tree that is dense up close then stands bare a few metres off, which on a bought courtyard was a
8
+ * cypress drawn as bare branches at 1280 pixels across and in full leaf at 2000.
9
+ *
10
+ * **So the test scales alpha up by the level it is sampling**: a quarter more per level, the figure
11
+ * Ben Golus measured on foliage in "Anti-aliased Alpha Test" (2017). Only the test sees the scaled
12
+ * value; what a translucent draw blends with is the texture's own alpha, and a material with no
13
+ * cutoff is untouched. What it gives up is exactness: coverage is held roughly rather than measured
14
+ * per image, which Ignacio Castaño's method would do at upload from a readback this engine does not
15
+ * make. What would make it wrong is an image whose mips were built to keep coverage already, which
16
+ * this would then thicken.
17
+ *
18
+ * The level is estimated from the texture coordinate's screen derivatives, the isotropic formula;
19
+ * a sampler with anisotropic filtering reads a lower level at a grazing angle, so there a cutout
20
+ * comes out slightly fuller than its own mip would have made it.
21
+ */
22
+
23
+ /** How much more alpha each mip level down is credited with in the test. */
24
+ export const CUTOUT_MIP_SCALE = 0.25;
25
+
26
+ /**
27
+ * `cutoutAlpha(alpha, texels)`: the alpha the cutout test should see, for a sample at `texels`, the
28
+ * texture coordinate times the texture's size. Called in uniform control flow, since it takes
29
+ * derivatives.
30
+ */
31
+ export const CUTOUT_COVERAGE_GLSL = `
32
+ float cutoutAlpha(float alpha, vec2 texels) {
33
+ vec2 dx = dFdx(texels);
34
+ vec2 dy = dFdy(texels);
35
+ float level = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy))));
36
+ return alpha * (1.0 + level * ${CUTOUT_MIP_SCALE.toFixed(2)});
37
+ }
38
+ `;
39
+
40
+ /** The same, given the level, for a test to pin: what the shader compares against the cutoff. */
41
+ export function cutoutAlpha(alpha: number, level: number): number {
42
+ return alpha * (1 + Math.max(0, level) * CUTOUT_MIP_SCALE);
43
+ }
@@ -1,4 +1,5 @@
1
1
  import { resolveConditionals } from './conditionals.ts';
2
+ import { CUTOUT_COVERAGE_GLSL } from './cutoutCoverage.ts';
2
3
  import { SKINNING_GLSL } from './skinning.ts';
3
4
  import { CHANNEL_ATTRIBUTE, CHANNEL_BEND } from './vertexChannel.ts';
4
5
 
@@ -56,7 +57,22 @@ ${CHANNEL_ATTRIBUTE}
56
57
  ${CHANNEL_BEND}
57
58
  #endif
58
59
 
60
+ #if CUTOUT
61
+ /*
62
+ * **What a cutout caster adds, and where.** The UV at location 5, where every mesh carries it; two
63
+ * uniforms declared after every other, so no field of the plain variant moves and a binder written
64
+ * against it stays right for this one; and two outputs after vLightPosition, which keeps location 0.
65
+ */
66
+ layout(location = 5) in vec2 aUv;
67
+ uniform vec2 uUvScale;
68
+ uniform float uAlphaCutout;
69
+ #endif
70
+
59
71
  out vec4 vLightPosition;
72
+ #if CUTOUT
73
+ out vec2 vUv;
74
+ flat out float vAlphaCutout;
75
+ #endif
60
76
 
61
77
  void main() {
62
78
  #if SKINNED
@@ -80,20 +96,24 @@ void main() {
80
96
  #endif
81
97
  vLightPosition = uLightViewProj * vec4(bent, world.w);
82
98
  gl_Position = vLightPosition;
99
+ #if CUTOUT
100
+ vUv = aUv * uUvScale;
101
+ vAlphaCutout = uAlphaCutout;
102
+ #endif
83
103
  }
84
104
  `;
85
105
 
86
106
  /** The rigid variant: the world, a prop, a mesh with no rig behind it. */
87
107
  export const DEPTH_VERT = resolveConditionals(
88
108
  DEPTH_VERT_SOURCE,
89
- { SKINNED: false, INSTANCED: false },
109
+ { SKINNED: false, INSTANCED: false, CUTOUT: false },
90
110
  'depth',
91
111
  );
92
112
 
93
113
  /** The skinned variant, which reads a joint palette and moves the vertex by it. */
94
114
  export const DEPTH_SKINNED_VERT = resolveConditionals(
95
115
  DEPTH_VERT_SOURCE,
96
- { SKINNED: true, INSTANCED: false },
116
+ { SKINNED: true, INSTANCED: false, CUTOUT: false },
97
117
  'depth-skinned',
98
118
  );
99
119
 
@@ -107,10 +127,32 @@ export const DEPTH_SKINNED_VERT = resolveConditionals(
107
127
  */
108
128
  export const DEPTH_INSTANCED_VERT = resolveConditionals(
109
129
  DEPTH_VERT_SOURCE,
110
- { SKINNED: false, INSTANCED: true },
130
+ { SKINNED: false, INSTANCED: true, CUTOUT: false },
111
131
  'depth-instanced',
112
132
  );
113
133
 
134
+ /**
135
+ * The variants a cutout caster casts through: a leaf card, a chain link, a fence, whose shape is in
136
+ * its texture's alpha rather than in its geometry.
137
+ *
138
+ * **Variants, not a uniform gate on the plain program**, for this file's own reason: a gate would
139
+ * need the UV, a varying and a bound texture on every opaque caster, and a branch on a varying is
140
+ * not provably uniform, which forbids the implicit derivative the sample below wants. A variant
141
+ * leaves every opaque caster byte for byte as it was. What it costs is one more program each, and
142
+ * a skinned cutout, which nothing has asked for, is not among them.
143
+ */
144
+ export const DEPTH_CUTOUT_VERT = resolveConditionals(
145
+ DEPTH_VERT_SOURCE,
146
+ { SKINNED: false, INSTANCED: false, CUTOUT: true },
147
+ 'depth-cutout',
148
+ );
149
+
150
+ export const DEPTH_INSTANCED_CUTOUT_VERT = resolveConditionals(
151
+ DEPTH_VERT_SOURCE,
152
+ { SKINNED: false, INSTANCED: true, CUTOUT: true },
153
+ 'depth-instanced-cutout',
154
+ );
155
+
114
156
  export const DEPTH_FRAG = `#version 300 es
115
157
  precision highp float;
116
158
  in vec4 vLightPosition;
@@ -129,3 +171,38 @@ void main() {
129
171
  }
130
172
  }
131
173
  `;
174
+
175
+ /**
176
+ * The cutout caster's depth: the plain pass, and a texel of alpha under the cutoff writes nothing.
177
+ *
178
+ * **The sample is the first statement, in uniform control flow**, so its implicit derivative picks
179
+ * the mip the shadow map's texel footprint wants; inside a branch it would be undefined (AGENTS.md,
180
+ * 2026-08-07). The peel's own sample becomes an explicit-level fetch for the same reason, and is
181
+ * bit for bit what it was: that map has one level and a nearest sampler. The discard is last, so
182
+ * nothing samples after it.
183
+ *
184
+ * `uCutoutMap` is declared after `uPreviousShadowMap`, so the peel keeps its bindings and the cutout
185
+ * map takes the next.
186
+ */
187
+ export const DEPTH_CUTOUT_FRAG = `#version 300 es
188
+ precision highp float;
189
+ in vec4 vLightPosition;
190
+ in vec2 vUv;
191
+ flat in float vAlphaCutout;
192
+
193
+ uniform highp sampler2D uPreviousShadowMap;
194
+ uniform int uPeelShadowLayer;
195
+ uniform highp sampler2D uCutoutMap;
196
+ ${CUTOUT_COVERAGE_GLSL}
197
+ void main() {
198
+ /* Credited for its mip level as the surface's own test is, so a leaf casts the shape it draws. */
199
+ float alpha = cutoutAlpha(texture(uCutoutMap, vUv).a, vUv * vec2(textureSize(uCutoutMap, 0)));
200
+ if (uPeelShadowLayer != 0) {
201
+ vec3 p = vLightPosition.xyz / vLightPosition.w;
202
+ vec2 uv = p.xy * 0.5 + 0.5;
203
+ float previousDepth = textureLod(uPreviousShadowMap, uv, 0.0).r;
204
+ if (gl_FragCoord.z <= previousDepth + 0.00001) discard;
205
+ }
206
+ if (alpha < vAlphaCutout) discard;
207
+ }
208
+ `;