@driftengine/core 4.8.7 → 4.10.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 (263) hide show
  1. package/CHANGELOG.json +123 -11
  2. package/README.md +43 -38
  3. package/dist/index.d.ts +8 -1
  4. package/dist/index.js +11 -3
  5. package/dist/render/ambientHarmonics.d.ts +1 -1
  6. package/dist/render/ambientHarmonics.js +1 -1
  7. package/dist/render/areaLights.d.ts +4 -4
  8. package/dist/render/areaLights.js +1 -1
  9. package/dist/render/atmosphere.d.ts +13 -0
  10. package/dist/render/atmosphere.js +9 -1
  11. package/dist/render/backend/api.d.ts +19 -4
  12. package/dist/render/backend/createRenderer.d.ts +6 -0
  13. package/dist/render/backend/createRenderer.js +4 -1
  14. package/dist/render/backend/select.js +11 -5
  15. package/dist/render/backend/webgl2/boneAnimations.d.ts +29 -0
  16. package/dist/render/backend/webgl2/boneAnimations.js +80 -0
  17. package/dist/render/backend/webgl2/instanced.d.ts +5 -1
  18. package/dist/render/backend/webgl2/instanced.js +13 -1
  19. package/dist/render/backend/webgl2/renderer.d.ts +180 -4
  20. package/dist/render/backend/webgl2/renderer.js +550 -40
  21. package/dist/render/backend/webgl2/skinPaletteTexture.d.ts +5 -3
  22. package/dist/render/backend/webgl2/skinPaletteTexture.js +6 -4
  23. package/dist/render/backend/webgpu/boneAnimations.d.ts +34 -0
  24. package/dist/render/backend/webgpu/boneAnimations.js +76 -0
  25. package/dist/render/backend/webgpu/buffers.d.ts +19 -4
  26. package/dist/render/backend/webgpu/buffers.js +18 -11
  27. package/dist/render/backend/webgpu/compressedUpload.d.ts +0 -1
  28. package/dist/render/backend/webgpu/compressedUpload.js +53 -8
  29. package/dist/render/backend/webgpu/depthPass.d.ts +59 -3
  30. package/dist/render/backend/webgpu/depthPass.js +49 -14
  31. package/dist/render/backend/webgpu/device.d.ts +7 -1
  32. package/dist/render/backend/webgpu/device.js +31 -3
  33. package/dist/render/backend/webgpu/drawCommand.d.ts +18 -4
  34. package/dist/render/backend/webgpu/drawCommand.js +10 -3
  35. package/dist/render/backend/webgpu/flatPass.d.ts +100 -42
  36. package/dist/render/backend/webgpu/flatPass.js +191 -43
  37. package/dist/render/backend/webgpu/instanced.d.ts +14 -1
  38. package/dist/render/backend/webgpu/instanced.js +24 -1
  39. package/dist/render/backend/webgpu/materialStore.d.ts +101 -0
  40. package/dist/render/backend/webgpu/materialStore.js +265 -0
  41. package/dist/render/backend/webgpu/pipelineCache.d.ts +6 -0
  42. package/dist/render/backend/webgpu/pipelineCache.js +7 -0
  43. package/dist/render/backend/webgpu/postPass.d.ts +51 -9
  44. package/dist/render/backend/webgpu/postPass.js +16 -2
  45. package/dist/render/backend/webgpu/reflectionSurfacePass.d.ts +30 -0
  46. package/dist/render/backend/webgpu/reflectionSurfacePass.js +100 -0
  47. package/dist/render/backend/webgpu/renderer.d.ts +283 -37
  48. package/dist/render/backend/webgpu/renderer.js +1363 -202
  49. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +7 -3
  50. package/dist/render/backend/webgpu/skinPaletteRing.js +13 -7
  51. package/dist/render/backend/webgpu/skinPaletteTexture.d.ts +6 -0
  52. package/dist/render/backend/webgpu/skinPaletteTexture.js +7 -1
  53. package/dist/render/backend/webgpu/staticBundles.d.ts +125 -0
  54. package/dist/render/backend/webgpu/staticBundles.js +241 -0
  55. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +10 -3
  56. package/dist/render/backend/webgpu/surfaceTexturePass.js +20 -2
  57. package/dist/render/backend/webgpu/uniformRing.d.ts +21 -0
  58. package/dist/render/backend/webgpu/uniformRing.js +9 -1
  59. package/dist/render/bloomChain.d.ts +3 -3
  60. package/dist/render/boneAnimation.d.ts +92 -0
  61. package/dist/render/boneAnimation.js +133 -0
  62. package/dist/render/compressedSource.d.ts +33 -13
  63. package/dist/render/compressedSource.js +70 -25
  64. package/dist/render/displayRange.d.ts +31 -0
  65. package/dist/render/displayRange.js +47 -0
  66. package/dist/render/filmicCurve.d.ts +74 -0
  67. package/dist/render/filmicCurve.js +203 -0
  68. package/dist/render/fog.d.ts +4 -1
  69. package/dist/render/fog.js +12 -2
  70. package/dist/render/frame/resources.d.ts +2 -2
  71. package/dist/render/frame/resources.js +6 -0
  72. package/dist/render/frameReflections.d.ts +40 -0
  73. package/dist/render/frameReflections.js +48 -0
  74. package/dist/render/glCompressed.js +83 -0
  75. package/dist/render/instances.d.ts +26 -0
  76. package/dist/render/lightBudget.d.ts +10 -0
  77. package/dist/render/lightBudget.js +10 -0
  78. package/dist/render/lightmap.d.ts +1 -1
  79. package/dist/render/mesh.d.ts +5 -0
  80. package/dist/render/mesh.js +6 -0
  81. package/dist/render/pass.d.ts +49 -0
  82. package/dist/render/pointLightSelection.d.ts +4 -4
  83. package/dist/render/reflectionSurfaceTargets.d.ts +28 -0
  84. package/dist/render/reflectionSurfaceTargets.js +99 -0
  85. package/dist/render/renderQuality.d.ts +39 -8
  86. package/dist/render/renderQuality.js +4 -0
  87. package/dist/render/sceneTarget.d.ts +5 -0
  88. package/dist/render/sceneTarget.js +24 -1
  89. package/dist/render/screenSpaceReflection.d.ts +7 -1
  90. package/dist/render/screenSpaceReflection.js +5 -0
  91. package/dist/render/shaders/arcane.d.ts +1 -1
  92. package/dist/render/shaders/bolt.d.ts +1 -1
  93. package/dist/render/shaders/boneAnimation.d.ts +19 -0
  94. package/dist/render/shaders/boneAnimation.js +63 -0
  95. package/dist/render/shaders/caustics.d.ts +1 -1
  96. package/dist/render/shaders/depth.d.ts +7 -0
  97. package/dist/render/shaders/depth.js +35 -10
  98. package/dist/render/shaders/film.d.ts +1 -1
  99. package/dist/render/shaders/filmLook.js +2 -1
  100. package/dist/render/shaders/fire.d.ts +1 -1
  101. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  102. package/dist/render/shaders/flat/glassTint.js +2 -0
  103. package/dist/render/shaders/flat/index.d.ts +16 -0
  104. package/dist/render/shaders/flat/index.js +33 -5
  105. package/dist/render/shaders/flat/main.d.ts +1 -1
  106. package/dist/render/shaders/flat/main.js +43 -5
  107. package/dist/render/shaders/flat/models.js +1 -1
  108. package/dist/render/shaders/flat/overlay.d.ts +16 -0
  109. package/dist/render/shaders/flat/overlay.js +117 -0
  110. package/dist/render/shaders/flat/pointShadow.js +3 -3
  111. package/dist/render/shaders/flat/preamble.js +38 -24
  112. package/dist/render/shaders/flat/reflectionSurface.d.ts +21 -0
  113. package/dist/render/shaders/flat/reflectionSurface.js +61 -0
  114. package/dist/render/shaders/fog.d.ts +1 -1
  115. package/dist/render/shaders/fog.js +9 -2
  116. package/dist/render/shaders/fringe.d.ts +29 -0
  117. package/dist/render/shaders/fringe.js +55 -0
  118. package/dist/render/shaders/generated/arcane.wgsl.d.ts +1 -1
  119. package/dist/render/shaders/generated/arcane.wgsl.js +1 -1
  120. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  121. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  122. package/dist/render/shaders/generated/caustics.wgsl.d.ts +1 -1
  123. package/dist/render/shaders/generated/caustics.wgsl.js +1 -1
  124. package/dist/render/shaders/generated/depth.wgsl.d.ts +93 -0
  125. package/dist/render/shaders/generated/depth.wgsl.js +93 -0
  126. package/dist/render/shaders/generated/film.wgsl.d.ts +1 -1
  127. package/dist/render/shaders/generated/film.wgsl.js +1 -1
  128. package/dist/render/shaders/generated/fire.wgsl.d.ts +1 -1
  129. package/dist/render/shaders/generated/fire.wgsl.js +1 -1
  130. package/dist/render/shaders/generated/flat.wgsl.d.ts +3254 -2920
  131. package/dist/render/shaders/generated/flat.wgsl.js +3292 -2957
  132. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  133. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  134. package/dist/render/shaders/generated/particle.wgsl.d.ts +4 -4
  135. package/dist/render/shaders/generated/particle.wgsl.js +4 -4
  136. package/dist/render/shaders/generated/rush.wgsl.d.ts +56 -26
  137. package/dist/render/shaders/generated/rush.wgsl.js +56 -26
  138. package/dist/render/shaders/generated/scatter.wgsl.d.ts +1 -1
  139. package/dist/render/shaders/generated/scatter.wgsl.js +1 -1
  140. package/dist/render/shaders/generated/smoke.wgsl.d.ts +1 -1
  141. package/dist/render/shaders/generated/smoke.wgsl.js +1 -1
  142. package/dist/render/shaders/generated/ssrResolve.wgsl.d.ts +29 -0
  143. package/dist/render/shaders/generated/ssrResolve.wgsl.js +29 -0
  144. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +22 -2
  145. package/dist/render/shaders/generated/ssrTrace.wgsl.js +22 -2
  146. package/dist/render/shaders/generated/water.wgsl.d.ts +1 -1
  147. package/dist/render/shaders/generated/water.wgsl.js +1 -1
  148. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +2 -2
  149. package/dist/render/shaders/gpudriven/fog.wgsl.js +11 -3
  150. package/dist/render/shaders/line.d.ts +1 -1
  151. package/dist/render/shaders/outputTransform.d.ts +7 -0
  152. package/dist/render/shaders/outputTransform.js +28 -14
  153. package/dist/render/shaders/particle.d.ts +3 -3
  154. package/dist/render/shaders/rush.js +54 -11
  155. package/dist/render/shaders/scatter.d.ts +1 -1
  156. package/dist/render/shaders/smoke.d.ts +1 -1
  157. package/dist/render/shaders/ssrResolve.d.ts +17 -0
  158. package/dist/render/shaders/ssrResolve.js +51 -0
  159. package/dist/render/shaders/ssrTrace.js +26 -0
  160. package/dist/render/shaders/vertexChannel.d.ts +7 -0
  161. package/dist/render/shaders/vertexChannel.js +26 -0
  162. package/dist/render/shaders/water.d.ts +1 -1
  163. package/dist/render/ssrPass.d.ts +16 -0
  164. package/dist/render/ssrPass.js +106 -1
  165. package/dist/render/staticDraws.d.ts +47 -0
  166. package/dist/render/staticDraws.js +43 -0
  167. package/dist/render/surfaceOverlay.d.ts +132 -0
  168. package/dist/render/surfaceOverlay.js +85 -0
  169. package/dist/render/surfaceTexture.d.ts +57 -7
  170. package/dist/render/surfaceTexture.js +125 -17
  171. package/dist/render/temporalAa.d.ts +14 -0
  172. package/dist/render/temporalAa.js +20 -0
  173. package/dist/render/vertexDefaults.d.ts +17 -0
  174. package/dist/render/vertexDefaults.js +24 -0
  175. package/package.json +6 -6
  176. package/src/index.ts +24 -2
  177. package/src/render/ambientHarmonics.ts +1 -1
  178. package/src/render/areaLights.ts +4 -4
  179. package/src/render/atmosphere.ts +22 -1
  180. package/src/render/backend/api.ts +22 -3
  181. package/src/render/backend/createRenderer.ts +18 -1
  182. package/src/render/backend/select.ts +10 -5
  183. package/src/render/backend/webgl2/boneAnimations.ts +105 -0
  184. package/src/render/backend/webgl2/instanced.ts +20 -1
  185. package/src/render/backend/webgl2/renderer.ts +672 -37
  186. package/src/render/backend/webgl2/skinPaletteTexture.ts +6 -4
  187. package/src/render/backend/webgpu/boneAnimations.ts +97 -0
  188. package/src/render/backend/webgpu/buffers.ts +42 -16
  189. package/src/render/backend/webgpu/compressedUpload.ts +59 -9
  190. package/src/render/backend/webgpu/depthPass.ts +51 -10
  191. package/src/render/backend/webgpu/device.ts +42 -3
  192. package/src/render/backend/webgpu/drawCommand.ts +22 -4
  193. package/src/render/backend/webgpu/flatPass.ts +250 -40
  194. package/src/render/backend/webgpu/instanced.ts +32 -1
  195. package/src/render/backend/webgpu/materialStore.ts +276 -0
  196. package/src/render/backend/webgpu/pipelineCache.ts +7 -0
  197. package/src/render/backend/webgpu/postPass.ts +18 -0
  198. package/src/render/backend/webgpu/reflectionSurfacePass.ts +114 -0
  199. package/src/render/backend/webgpu/renderer.ts +1655 -230
  200. package/src/render/backend/webgpu/skinPaletteRing.ts +13 -7
  201. package/src/render/backend/webgpu/skinPaletteTexture.ts +7 -1
  202. package/src/render/backend/webgpu/staticBundles.ts +363 -0
  203. package/src/render/backend/webgpu/surfaceTexturePass.ts +29 -3
  204. package/src/render/backend/webgpu/uniformRing.ts +10 -1
  205. package/src/render/bloomChain.ts +3 -3
  206. package/src/render/boneAnimation.ts +223 -0
  207. package/src/render/compressedSource.ts +121 -29
  208. package/src/render/displayRange.ts +61 -0
  209. package/src/render/filmicCurve.ts +229 -0
  210. package/src/render/fog.ts +19 -3
  211. package/src/render/frame/resources.ts +6 -0
  212. package/src/render/frameReflections.ts +72 -0
  213. package/src/render/glCompressed.ts +83 -0
  214. package/src/render/instances.ts +27 -0
  215. package/src/render/lightBudget.ts +11 -0
  216. package/src/render/lightmap.ts +1 -1
  217. package/src/render/mesh.ts +6 -0
  218. package/src/render/pass.ts +53 -1
  219. package/src/render/pointLightSelection.ts +4 -4
  220. package/src/render/reflectionSurfaceTargets.ts +105 -0
  221. package/src/render/renderQuality.ts +44 -8
  222. package/src/render/sceneTarget.ts +30 -2
  223. package/src/render/screenSpaceReflection.ts +7 -1
  224. package/src/render/shaders/boneAnimation.ts +63 -0
  225. package/src/render/shaders/depth.ts +45 -10
  226. package/src/render/shaders/filmLook.ts +2 -1
  227. package/src/render/shaders/flat/glassTint.ts +5 -1
  228. package/src/render/shaders/flat/index.ts +49 -5
  229. package/src/render/shaders/flat/main.ts +43 -5
  230. package/src/render/shaders/flat/models.ts +1 -1
  231. package/src/render/shaders/flat/overlay.ts +117 -0
  232. package/src/render/shaders/flat/pointShadow.ts +3 -3
  233. package/src/render/shaders/flat/preamble.ts +38 -24
  234. package/src/render/shaders/flat/reflectionSurface.ts +61 -0
  235. package/src/render/shaders/fog.ts +9 -2
  236. package/src/render/shaders/fringe.ts +59 -0
  237. package/src/render/shaders/generated/arcane.wgsl.ts +1 -1
  238. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  239. package/src/render/shaders/generated/caustics.wgsl.ts +1 -1
  240. package/src/render/shaders/generated/depth.wgsl.ts +95 -0
  241. package/src/render/shaders/generated/film.wgsl.ts +1 -1
  242. package/src/render/shaders/generated/fire.wgsl.ts +1 -1
  243. package/src/render/shaders/generated/flat.wgsl.ts +3292 -2957
  244. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  245. package/src/render/shaders/generated/particle.wgsl.ts +4 -4
  246. package/src/render/shaders/generated/rush.wgsl.ts +56 -26
  247. package/src/render/shaders/generated/scatter.wgsl.ts +1 -1
  248. package/src/render/shaders/generated/smoke.wgsl.ts +1 -1
  249. package/src/render/shaders/generated/ssrResolve.wgsl.ts +30 -0
  250. package/src/render/shaders/generated/ssrTrace.wgsl.ts +22 -2
  251. package/src/render/shaders/generated/water.wgsl.ts +1 -1
  252. package/src/render/shaders/gpudriven/fog.wgsl.ts +11 -3
  253. package/src/render/shaders/outputTransform.ts +30 -14
  254. package/src/render/shaders/rush.ts +54 -11
  255. package/src/render/shaders/ssrResolve.ts +52 -0
  256. package/src/render/shaders/ssrTrace.ts +26 -0
  257. package/src/render/shaders/vertexChannel.ts +27 -0
  258. package/src/render/ssrPass.ts +138 -1
  259. package/src/render/staticDraws.ts +95 -0
  260. package/src/render/surfaceOverlay.ts +195 -0
  261. package/src/render/surfaceTexture.ts +161 -27
  262. package/src/render/temporalAa.ts +20 -0
  263. package/src/render/vertexDefaults.ts +28 -0
@@ -37,6 +37,32 @@ uniform float uWindTime;
37
37
  /* Spatial frequency of the travelling gust, per metre. */
38
38
  uniform vec2 uWindSpatialPhase;
39
39
  `;
40
+ /**
41
+ * The same, for the lit stage, whose wind lives in its view block: the frame's and not the draw's,
42
+ * so a draw's own block does not change when the wind does. See `hoistUniformBlock` in
43
+ * `scripts/wgsl/transform.mjs`. Text otherwise identical, so WebGL2 runs the same program it always
44
+ * has; the depth stage keeps the unmarked chunk, having no view block.
45
+ */
46
+ export const LIT_CHANNEL_ATTRIBUTE = `
47
+ layout(location = 13) in vec4 aChannel;
48
+
49
+ /*
50
+ * The frame's wind, already converted by \`resolveScatterDeform\` — a normalised direction, metres
51
+ * of bend, a gust amplitude and a scaled clock.
52
+ *
53
+ * **The same numbers the scatter batch reads, and that is the rule rather than an economy.** A
54
+ * second conversion here would be a second wind however identical its inputs, and the failure is
55
+ * not localised: it shows up as a scene that does not cohere, a canopy leaning one way while the
56
+ * grass beneath it leans another, with no single element looking wrong. See "One wind, sampled
57
+ * once" in AGENTS.md.
58
+ */
59
+ uniform vec2 uWindDirection; // wgsl:view
60
+ uniform float uWindSpeed; // wgsl:view
61
+ uniform float uWindGust; // wgsl:view
62
+ uniform float uWindTime; // wgsl:view
63
+ /* Spatial frequency of the travelling gust, per metre. */
64
+ uniform vec2 uWindSpatialPhase; // wgsl:view
65
+ `;
40
66
  /**
41
67
  * Where the wind puts a vertex, given how much of it that vertex takes.
42
68
  *
@@ -15,4 +15,4 @@
15
15
  * ocean from a fixed vertex budget without the surface sliding underfoot.
16
16
  */
17
17
  export declare const WATER_VERT = "#version 300 es\nlayout(location = 0) in vec2 aGrid;\n\nuniform mat4 uViewProj;\nuniform mat4 uReflectionViewProj;\nuniform vec3 uCameraPos;\nuniform float uTime;\n/**\n * Where the sheet is laid, in world XZ.\n *\n * The ocean passes its own camera position snapped to whole cells, which is what\n * gives an endless sea from a fixed vertex budget without the surface sliding\n * underfoot. A *bounded* sheet \u2014 a pool in a courtyard \u2014 passes the middle of the\n * thing it is filling instead, and is world-fixed by construction.\n *\n * Snapped on the CPU rather than here so that one number is the only difference\n * between an ocean and a basin, and the shader has no idea which it is drawing.\n */\nuniform vec2 uGridOrigin;\n/**\n * Metres per unit of the sheet's own grid, along each of the body's own axes.\n *\n * One and one for the ocean, whose grid is authored in metres and is two meshes wide.\n * A bounded body \u2014 a pool, a tank, a puddle, a channel \u2014 is drawn from a unit sheet\n * and scaled by its own half-extents, which is what lets one mesh and one material\n * serve every body of water in the world.\n *\n * **Two numbers and not one since 2026-08-28.** A single span makes every bounded\n * body square, and a square that contains a channel floods the ground either side\n * of it. The ocean passes the same number twice and is bit-identical to what it was.\n */\nuniform vec2 uGridSpan;\nuniform vec2 uGridHalf;\nuniform vec2 uNearHalf;\n/**\n * The body's own +z, in world XZ, unit length. (0, 1) leaves the sheet unturned.\n *\n * A direction rather than an angle, so nothing here needs a trig call and a consumer\n * bounding a channel can hand over the direction its run already takes. The across\n * axis is world up crossed with this, `(fz, -fx)`, which is the basis\n * `MeshBuilder.addOrientedBox` builds from a forward vector \u2014 one convention for an\n * oriented thing, so a reader who has met one has met both.\n */\nuniform vec2 uGridForward;\n/** Prevailing wind bearing, normalised. Waves are steered toward it. */\nuniform vec2 uWindDir;\n/** Multiplier on authored steepness \u2014 how built-up the sea is. */\nuniform float uWaveGain;\nuniform float uWaterLevel;\n\nout vec3 vWorldPos;\nout vec3 vNormal;\nout float vCrest;\nout vec4 vReflectionClip;\n/** Grid-local offset, so the sheet can fade out its own boundary. */\nout vec2 vGrid;\n\n\nconst int GERSTNER_COUNT = 4;\n/*\n * direction.xy, steepness, wavelength. Amplitude is steepness/k, so these sum to\n * roughly a 1m swell \u2014 enough to read as a living surface, low enough that crests\n * never wash over an island whose deck sits ~1.8m above sea level.\n */\nconst vec4 GERSTNER_WAVES[4] = vec4[4](\n vec4( 1.0, 0.0, 0.115, 34.0),\n vec4( 0.6, 0.8, 0.100, 18.0),\n vec4(-0.7, 0.7, 0.080, 9.0),\n vec4( 0.2, -0.98, 0.060, 5.0)\n);\n\n/*\n * Steer each wave toward the wind, but only partway. A real sea is a dominant\n * swell plus cross-chop; rotating all four onto one bearing gives corduroy,\n * which reads as a material rather than as water.\n */\nvec2 gerstnerDirection(int i, vec2 windDir) {\n return normalize(mix(normalize(GERSTNER_WAVES[i].xy), windDir, 0.62));\n}\n\nfloat gerstnerWavenumber(int i) {\n return 6.28318530718 / GERSTNER_WAVES[i].w;\n}\n\n/** Deep-water phase in radians at a point on the resting plane. */\nfloat gerstnerPhase(float k, vec2 dir, vec2 p, float time) {\n float c = sqrt(9.81 / k); // deep-water phase speed\n return k * (dot(dir, p) - c * time);\n}\n\nstruct GerstnerSurface {\n /** Displacement from the resting plane \u2014 horizontal as well as vertical. */\n vec3 offset;\n vec3 normal;\n /** Height in units of steepness; positive near a crest. */\n float crest;\n};\n\nGerstnerSurface gerstnerSurface(vec2 p, float time, vec2 windDir, float gain) {\n GerstnerSurface s;\n s.offset = vec3(0.0);\n s.normal = vec3(0.0, 1.0, 0.0);\n s.crest = 0.0;\n for (int i = 0; i < GERSTNER_COUNT; i++) {\n vec2 dir = gerstnerDirection(i, windDir);\n float steepness = GERSTNER_WAVES[i].z * gain;\n float k = gerstnerWavenumber(i);\n float f = gerstnerPhase(k, dir, p, time);\n float a = steepness / k; // amplitude from steepness\n\n s.offset.x += dir.x * a * cos(f);\n s.offset.y += a * sin(f);\n s.offset.z += dir.y * a * cos(f);\n\n // Analytic normal: the partial derivatives of the displacement above.\n s.normal.x -= dir.x * steepness * cos(f);\n s.normal.z -= dir.y * steepness * cos(f);\n s.normal.y -= steepness * sin(f);\n\n s.crest += sin(f) * steepness;\n }\n return s;\n}\n\n/**\n * Second derivatives of the height field: (d2h/dx2, d2h/dz2, d2h/dxdz).\n *\n * How the surface *bends*, which is what decides where reflected light\n * converges. Analytic from the same sum, so it cannot disagree with the shape\n * the vertex stage displaced.\n */\nvec3 gerstnerCurvature(vec2 p, float time, vec2 windDir, float gain) {\n vec3 h = vec3(0.0);\n for (int i = 0; i < GERSTNER_COUNT; i++) {\n vec2 dir = gerstnerDirection(i, windDir);\n float steepness = GERSTNER_WAVES[i].z * gain;\n float k = gerstnerWavenumber(i);\n float f = gerstnerPhase(k, dir, p, time);\n // Second derivative of (steepness/k)*sin(f) along the wave's own bearing.\n float second = -steepness * k * sin(f);\n h.x += second * dir.x * dir.x;\n h.y += second * dir.y * dir.y;\n h.z += second * dir.x * dir.y;\n }\n return h;\n}\n\n\nvoid main() {\n /*\n * Waves must reach nothing by the near sheet's edge, because beyond it lies\n * the flat skirt. A displaced rim meeting an undisplaced skirt would tear the\n * surface open along a visible seam; fading first makes the two meshes agree\n * exactly where they meet. Chebyshev distance, so the fade band follows the\n * rectangular boundary rather than cutting an ellipse inside it.\n *\n * **Measured as a fraction of each axis' own extent**, which is what lets a\n * channel three metres across and twenty-one long fade evenly at both its ends\n * and both its sides. Against one shared extent the narrow axis would be inside\n * the fade band along its whole length and the water would never reach its own\n * bank. For the ocean, whose two extents are equal, this is the same arithmetic\n * with the division moved one step earlier.\n *\n * It is also the honest thing to draw. At a few hundred metres a 1 m swell is\n * well under a pixel, and out on the skirt a float32 phase argument has lost\n * most of its meaningful bits \u2014 a calm horizon is what an ocean actually looks\n * like from that far away.\n */\n vec2 grid = aGrid * uGridSpan;\n /* The same offset the fragment stage fades the rim on, so the two cannot disagree. */\n vGrid = grid;\n vec2 rimFraction = abs(grid) / max(uNearHalf, vec2(1e-4));\n float rimDistance = max(rimFraction.x, rimFraction.y);\n float waveFade = 1.0 - smoothstep(0.35, 0.97, rimDistance);\n\n /*\n * Into the world: across the body, then along it. `right = up x forward` is\n * `(fz, -fx)`, and getting that pair the wrong way round is a sheet mirrored\n * about its own axis, which on a square is invisible and on a channel is a\n * channel across the road instead of along it.\n */\n vec2 laid = vec2(uGridForward.y, -uGridForward.x) * grid.x + uGridForward * grid.y;\n\n vec3 base = vec3(uGridOrigin.x + laid.x, uWaterLevel, uGridOrigin.y + laid.y);\n\n GerstnerSurface sea = gerstnerSurface(base.xz, uTime, uWindDir, waveFade * uWaveGain);\n vec3 pos = base + sea.offset;\n\n vWorldPos = pos;\n vNormal = normalize(sea.normal);\n vCrest = sea.crest;\n vReflectionClip = uReflectionViewProj * vec4(pos, 1.0);\n gl_Position = uViewProj * vec4(pos, 1.0);\n}\n";
18
- export declare const WATER_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec3 vNormal;\nin float vCrest;\nin vec4 vReflectionClip;\nin vec2 vGrid;\n\nuniform vec2 uGridHalf;\nuniform float uFoamGain;\n/**\n * How much of what is beneath it a body of water hides, looked straight down.\n *\n * The *density* of the water rather than a rendering trick: a sea hides its own\n * floor, a fountain shows the tiles at the bottom of it, and a puddle is nearly\n * clear. Only the nadir end is configurable \u2014 at a grazing angle every water\n * surface goes reflective, which is Fresnel and not a property of the liquid.\n */\nuniform float uNadirOpacity;\n/** The whole surface's visibility, 0 to 1. Zero draws nothing at all. */\nuniform float uVisibility;\n/**\n * How much this body mirrors regardless of the angle it is seen at, 0 to 1.\n *\n * Zero is physics: reflection is Fresnel alone, which is right for an ocean seen\n * across its own surface and which means *almost nothing* when you stand over a\n * basin and look down into it \u2014 the angle is steep, the Fresnel term is a few\n * percent, and a reflection nobody can see is the same as no reflection: the\n * character's reflection is technically present and far too faint to perceive.\n *\n * So a body may declare itself more mirror than water. It is a lie about optics\n * and an honest one about what a small pool is *for*: you look into it to see\n * something. The sea keeps zero and behaves exactly as it always has.\n */\nuniform float uMirror;\nuniform vec3 uDeepColor;\nuniform vec3 uShallowColor;\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\n/**\n * The scene's point lights, which water is entitled to as much as stone is.\n *\n * The surface used to answer to the sun alone, on the reasonable assumption that water\n * is a thing that sits under the sky. It is not: a flooded corridor, a cistern, an\n * indoor pool and a harbour at night are all water lit entirely by lamps, and under\n * those this shaded to flat ambient \u2014 a coloured plane, with the specular glitter that\n * is the single strongest cue that a surface *is* water switched off because no\n * directional source existed to make it. A lantern on a jetty threw nothing.\n *\n * Same slots, same weights and the surface shader's falloffs, a light's own exponent among\n * them, so a lamp reaches as far over water as over the ground beside it. **What it gives up**:\n * a spot's cone, which water does not read, so a spot lights the water around it as a lamp\n * would; and the smooth falloff's square, which the surface applies and this has never, so under\n * that falloff a lamp's pool on water is the wider of the two.\n */\n#define MAX_LIGHTS 16\nuniform int uLightCount;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightRadius[MAX_LIGHTS];\nuniform float uLightWeight[MAX_LIGHTS];\nuniform int uLightFalloff;\n/* The surface shader's cone record, of which water reads z alone: a light's own falloff exponent. */\nuniform vec4 uLightCone[MAX_LIGHTS];\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\nuniform vec3 uCameraPos;\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform vec2 uReflectionTexelSize;\nuniform int uReflectionFilterTaps;\n\nconst vec2 REFLECTION_FILTER[9] = vec2[9](\n vec2( 0.00, 0.00), vec2(-0.62, -0.31), vec2( 0.54, 0.43),\n vec2(-0.34, 0.76), vec2( 0.78, -0.57), vec2(-0.88, 0.28),\n vec2( 0.22, -0.91), vec2( 0.91, 0.08), vec2(-0.18, 0.94)\n);\n// Preserve small moving silhouettes such as a character while still spreading\n// the surrounding taps enough to make the target read as rough water.\nconst float REFLECTION_FILTER_WEIGHT[9] = float[9](\n 0.28, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09\n);\n\nout vec4 outColor;\n\nvoid main() {\n vec3 n = normalize(vNormal);\n vec3 view = normalize(uCameraPos - vWorldPos);\n\n // Fresnel: water is nearly a mirror at grazing angles and clear straight down.\n // abs keeps the underside physically continuous with the top instead of\n // turning the entire surface into an opaque mirror below the waterline.\n float facing = clamp(abs(dot(n, view)), 0.0, 1.0);\n float fresnel = 0.02 + 0.98 * pow(1.0 - facing, 5.0);\n\n vec3 atmosphereColor = mediumColor();\n\n vec3 col = mix(uDeepColor, uShallowColor, clamp(vCrest * 1.4 + 0.4, 0.0, 1.0));\n vec3 incident = uAmbient +\n uDirectionalColor * max(dot(n, uDirectionalDir), 0.0) * 0.6;\n\n // Specular glitter along the crests \u2014 the strongest single cue that a surface\n // is water rather than a coloured plane.\n vec3 halfVec = normalize(uDirectionalDir + view);\n float spec = pow(max(dot(n, halfVec), 0.0), 220.0);\n vec3 highlight = uDirectionalColor * spec * 1.6;\n\n /*\n * And the same from every lamp in range: a light over water lights it, and glitters\n * on it. Indoors this is the whole of the surface's lighting.\n *\n * The exponent is the sun's, so a crest picks a lamp out as tightly as it picks out\n * the sky \u2014 which is what makes a strip light read as a *streak* across a pool rather\n * than a broad sheen. Weighted by presence like every other consumer of these slots,\n * so a light entering the set fades in instead of appearing.\n */\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uLightCount) break;\n vec3 toLight = uLightPos[i] - vWorldPos;\n float lightDist = length(toLight);\n float falloff;\n float exponent = uLightCone[i].z;\n if (exponent > 0.0) {\n float reach = lightDist / max(uLightRadius[i], 1e-4);\n falloff = pow(clamp(1.0 - reach * reach, 0.0, 1.0), exponent);\n } else if (uLightFalloff == 1) {\n float window = clamp(1.0 - pow(lightDist / max(uLightRadius[i], 1e-4), 4.0), 0.0, 1.0);\n falloff = window * window / max(lightDist * lightDist, 0.01);\n } else {\n falloff = clamp(1.0 - lightDist / max(uLightRadius[i], 1e-4), 0.0, 1.0);\n }\n if (falloff <= 0.0) continue;\n vec3 lightDir = toLight / max(lightDist, 1e-4);\n float shaped = falloff * uLightWeight[i];\n incident += uLightColor[i] * max(dot(n, lightDir), 0.0) * shaped * 0.6;\n vec3 lampHalf = normalize(lightDir + view);\n highlight += uLightColor[i] * pow(max(dot(n, lampHalf), 0.0), 220.0) * shaped * 1.6;\n }\n\n col *= incident;\n col += highlight;\n\n // Foam where crests peak \u2014 and only when the wind is actually breaking them.\n float foam = smoothstep(0.55, 0.85, vCrest) * uFoamGain;\n col = mix(col, vec3(0.92, 0.96, 1.0), foam * 0.5);\n\n float dist = distance(vWorldPos, uCameraPos);\n float fog = mediumFog(dist, vWorldPos.y);\n col = mix(col, atmosphereColor, fog);\n\n // Transparency is angle-dependent, like real water: looking down you see\n // straight through to the seabed, at grazing angles it turns reflective.\n // This is computed before reflection because straight-alpha compositing must\n // not attenuate reflected radiance a second time below.\n float airOpacity = mix(uNadirOpacity, 0.96, fresnel);\n float underwaterOpacity = mix(uNadirOpacity * 0.55, 0.88, fresnel);\n float opacity = mix(airOpacity, underwaterOpacity, uUnderwaterFactor);\n opacity = mix(opacity, 0.96, foam * 0.7);\n\n // Project the displaced surface into the mirrored camera. The Gerstner\n // displacement supplies broad distortion; the analytic normal adds the\n // smaller ripples that make reflected silhouettes move with each crest.\n vec3 reflected = atmosphereColor;\n float reflectionValid = 0.0;\n if (uReflectionEnabled != 0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n reflectionUv += n.xz * mix(0.008, 0.022, fresnel);\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n reflectionValid = smoothstep(0.0, 0.025, min(border.x, border.y));\n // A planar target is optically sharp, which is correct for a mirror but\n // wrong for a moving water surface. Broaden it over a Poisson footprint\n // that grows gently with viewing distance; normal displacement above\n // still makes every crest steer that softened image independently.\n float blurPixels = 1.35 + min(dist * 0.022, 3.5);\n vec3 sceneReflection = vec3(0.0);\n float filterWeight = 0.0;\n for (int i = 0; i < 9; i++) {\n if (i >= uReflectionFilterTaps) break;\n vec2 sampleUv = reflectionUv +\n REFLECTION_FILTER[i] * uReflectionTexelSize * blurPixels;\n float weight = REFLECTION_FILTER_WEIGHT[i];\n /*\n * `textureLod` rather than `texture`, by the rule `AGENTS.md` records on 2026-08-07:\n * this loop is reached through a branch on `vReflectionClip`, which is not uniform, and\n * an implicit derivative there is undefined. **WGSL does not merely leave it undefined,\n * it refuses to compile the module**: *\"'textureSample' must only be called from uniform\n * control flow\"*, with the note that reading `vReflectionClip` may be non-uniform.\n *\n * Bit-identical on WebGL2: `planarReflection.ts` allocates one level and filters\n * `LINEAR`, so there is no mip to select and level zero is the only thing `texture`\n * could have read.\n */\n sceneReflection +=\n textureLod(uReflectionMap, clamp(sampleUv, 0.0, 1.0), 0.0).rgb * weight;\n filterWeight += weight;\n }\n sceneReflection /= max(filterWeight, 0.001);\n reflected = mix(atmosphereColor, sceneReflection, reflectionValid);\n }\n // The atmosphere-colour fallback preserves the old sky reflection when a\n // quality profile disables the extra scene pass. Foam scatters rather than\n // mirroring, so it masks the reflection at breaking crests.\n float sceneWeight = float(uReflectionEnabled) * reflectionValid;\n float reflectionGain = mix(0.75, 0.94, sceneWeight);\n // Angle-honest by default, and a mirror where a body says so. See uMirror above.\n float reflectivity = mix(fresnel, 1.0, uMirror);\n float reflectedRadiance = min(\n reflectivity * reflectionGain * (1.0 - foam * 0.8),\n opacity\n );\n // With SRC_ALPHA blending, mixing by Fresnel directly would multiply the\n // reflection by opacity again. Divide it out here so the framebuffer gets\n // the intended reflected fraction while the remainder stays transmissive.\n float reflectionWeight = reflectedRadiance / max(opacity, 0.001);\n col = mix(col, reflected, reflectionWeight);\n\n /*\n * Dissolve the sheet at its own boundary.\n *\n * Fading on fog alone made the horizon a function of the daily palette: a\n * clear day never reached the threshold, so the grid's rim \u2014 corner and all \u2014\n * became the skyline. Fading on the grid coordinate cannot fail that way,\n * because alpha reaches zero at the last row of vertices by construction.\n * Chebyshev distance because the sheet is a rectangle, so the fade band\n * follows its four edges instead of cutting an ellipse out of it \u2014 and it is\n * measured per axis, or a channel's narrow sides would be one continuous fade\n * and it would have no visible edge at all.\n */\n vec2 rimFraction = abs(vGrid) / max(uGridHalf, vec2(0.001));\n float rimT = max(rimFraction.x, rimFraction.y);\n float rim = 1.0 - smoothstep(0.9, 1.0, rimT);\n // Fog still helps where it is thick, it just is no longer load-bearing.\n float edge = 1.0 - smoothstep(0.82, 1.0, fog);\n outColor = vec4(col, opacity * edge * rim * uVisibility);\n}\n";
18
+ export declare const WATER_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec3 vNormal;\nin float vCrest;\nin vec4 vReflectionClip;\nin vec2 vGrid;\n\nuniform vec2 uGridHalf;\nuniform float uFoamGain;\n/**\n * How much of what is beneath it a body of water hides, looked straight down.\n *\n * The *density* of the water rather than a rendering trick: a sea hides its own\n * floor, a fountain shows the tiles at the bottom of it, and a puddle is nearly\n * clear. Only the nadir end is configurable \u2014 at a grazing angle every water\n * surface goes reflective, which is Fresnel and not a property of the liquid.\n */\nuniform float uNadirOpacity;\n/** The whole surface's visibility, 0 to 1. Zero draws nothing at all. */\nuniform float uVisibility;\n/**\n * How much this body mirrors regardless of the angle it is seen at, 0 to 1.\n *\n * Zero is physics: reflection is Fresnel alone, which is right for an ocean seen\n * across its own surface and which means *almost nothing* when you stand over a\n * basin and look down into it \u2014 the angle is steep, the Fresnel term is a few\n * percent, and a reflection nobody can see is the same as no reflection: the\n * character's reflection is technically present and far too faint to perceive.\n *\n * So a body may declare itself more mirror than water. It is a lie about optics\n * and an honest one about what a small pool is *for*: you look into it to see\n * something. The sea keeps zero and behaves exactly as it always has.\n */\nuniform float uMirror;\nuniform vec3 uDeepColor;\nuniform vec3 uShallowColor;\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\n/**\n * The scene's point lights, which water is entitled to as much as stone is.\n *\n * The surface used to answer to the sun alone, on the reasonable assumption that water\n * is a thing that sits under the sky. It is not: a flooded corridor, a cistern, an\n * indoor pool and a harbour at night are all water lit entirely by lamps, and under\n * those this shaded to flat ambient \u2014 a coloured plane, with the specular glitter that\n * is the single strongest cue that a surface *is* water switched off because no\n * directional source existed to make it. A lantern on a jetty threw nothing.\n *\n * Same slots, same weights and the surface shader's falloffs, a light's own exponent among\n * them, so a lamp reaches as far over water as over the ground beside it. **What it gives up**:\n * a spot's cone, which water does not read, so a spot lights the water around it as a lamp\n * would; and the smooth falloff's square, which the surface applies and this has never, so under\n * that falloff a lamp's pool on water is the wider of the two.\n */\n#define MAX_LIGHTS 16\nuniform int uLightCount;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightRadius[MAX_LIGHTS];\nuniform float uLightWeight[MAX_LIGHTS];\nuniform int uLightFalloff;\n/* The surface shader's cone record, of which water reads z alone: a light's own falloff exponent. */\nuniform vec4 uLightCone[MAX_LIGHTS];\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\nuniform vec3 uCameraPos;\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform vec2 uReflectionTexelSize;\nuniform int uReflectionFilterTaps;\n\nconst vec2 REFLECTION_FILTER[9] = vec2[9](\n vec2( 0.00, 0.00), vec2(-0.62, -0.31), vec2( 0.54, 0.43),\n vec2(-0.34, 0.76), vec2( 0.78, -0.57), vec2(-0.88, 0.28),\n vec2( 0.22, -0.91), vec2( 0.91, 0.08), vec2(-0.18, 0.94)\n);\n// Preserve small moving silhouettes such as a character while still spreading\n// the surrounding taps enough to make the target read as rough water.\nconst float REFLECTION_FILTER_WEIGHT[9] = float[9](\n 0.28, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09\n);\n\nout vec4 outColor;\n\nvoid main() {\n vec3 n = normalize(vNormal);\n vec3 view = normalize(uCameraPos - vWorldPos);\n\n // Fresnel: water is nearly a mirror at grazing angles and clear straight down.\n // abs keeps the underside physically continuous with the top instead of\n // turning the entire surface into an opaque mirror below the waterline.\n float facing = clamp(abs(dot(n, view)), 0.0, 1.0);\n float fresnel = 0.02 + 0.98 * pow(1.0 - facing, 5.0);\n\n vec3 atmosphereColor = mediumColor();\n\n vec3 col = mix(uDeepColor, uShallowColor, clamp(vCrest * 1.4 + 0.4, 0.0, 1.0));\n vec3 incident = uAmbient +\n uDirectionalColor * max(dot(n, uDirectionalDir), 0.0) * 0.6;\n\n // Specular glitter along the crests \u2014 the strongest single cue that a surface\n // is water rather than a coloured plane.\n vec3 halfVec = normalize(uDirectionalDir + view);\n float spec = pow(max(dot(n, halfVec), 0.0), 220.0);\n vec3 highlight = uDirectionalColor * spec * 1.6;\n\n /*\n * And the same from every lamp in range: a light over water lights it, and glitters\n * on it. Indoors this is the whole of the surface's lighting.\n *\n * The exponent is the sun's, so a crest picks a lamp out as tightly as it picks out\n * the sky \u2014 which is what makes a strip light read as a *streak* across a pool rather\n * than a broad sheen. Weighted by presence like every other consumer of these slots,\n * so a light entering the set fades in instead of appearing.\n */\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uLightCount) break;\n vec3 toLight = uLightPos[i] - vWorldPos;\n float lightDist = length(toLight);\n float falloff;\n float exponent = uLightCone[i].z;\n if (exponent > 0.0) {\n float reach = lightDist / max(uLightRadius[i], 1e-4);\n falloff = pow(clamp(1.0 - reach * reach, 0.0, 1.0), exponent);\n } else if (uLightFalloff == 1) {\n float window = clamp(1.0 - pow(lightDist / max(uLightRadius[i], 1e-4), 4.0), 0.0, 1.0);\n falloff = window * window / max(lightDist * lightDist, 0.01);\n } else {\n falloff = clamp(1.0 - lightDist / max(uLightRadius[i], 1e-4), 0.0, 1.0);\n }\n if (falloff <= 0.0) continue;\n vec3 lightDir = toLight / max(lightDist, 1e-4);\n float shaped = falloff * uLightWeight[i];\n incident += uLightColor[i] * max(dot(n, lightDir), 0.0) * shaped * 0.6;\n vec3 lampHalf = normalize(lightDir + view);\n highlight += uLightColor[i] * pow(max(dot(n, lampHalf), 0.0), 220.0) * shaped * 1.6;\n }\n\n col *= incident;\n col += highlight;\n\n // Foam where crests peak \u2014 and only when the wind is actually breaking them.\n float foam = smoothstep(0.55, 0.85, vCrest) * uFoamGain;\n col = mix(col, vec3(0.92, 0.96, 1.0), foam * 0.5);\n\n float dist = distance(vWorldPos, uCameraPos);\n float fog = mediumFog(dist, vWorldPos.y);\n col = mix(col, atmosphereColor, fog);\n\n // Transparency is angle-dependent, like real water: looking down you see\n // straight through to the seabed, at grazing angles it turns reflective.\n // This is computed before reflection because straight-alpha compositing must\n // not attenuate reflected radiance a second time below.\n float airOpacity = mix(uNadirOpacity, 0.96, fresnel);\n float underwaterOpacity = mix(uNadirOpacity * 0.55, 0.88, fresnel);\n float opacity = mix(airOpacity, underwaterOpacity, uUnderwaterFactor);\n opacity = mix(opacity, 0.96, foam * 0.7);\n\n // Project the displaced surface into the mirrored camera. The Gerstner\n // displacement supplies broad distortion; the analytic normal adds the\n // smaller ripples that make reflected silhouettes move with each crest.\n vec3 reflected = atmosphereColor;\n float reflectionValid = 0.0;\n if (uReflectionEnabled != 0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n reflectionUv += n.xz * mix(0.008, 0.022, fresnel);\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n reflectionValid = smoothstep(0.0, 0.025, min(border.x, border.y));\n // A planar target is optically sharp, which is correct for a mirror but\n // wrong for a moving water surface. Broaden it over a Poisson footprint\n // that grows gently with viewing distance; normal displacement above\n // still makes every crest steer that softened image independently.\n float blurPixels = 1.35 + min(dist * 0.022, 3.5);\n vec3 sceneReflection = vec3(0.0);\n float filterWeight = 0.0;\n for (int i = 0; i < 9; i++) {\n if (i >= uReflectionFilterTaps) break;\n vec2 sampleUv = reflectionUv +\n REFLECTION_FILTER[i] * uReflectionTexelSize * blurPixels;\n float weight = REFLECTION_FILTER_WEIGHT[i];\n /*\n * `textureLod` rather than `texture`, by the rule `AGENTS.md` records on 2026-08-07:\n * this loop is reached through a branch on `vReflectionClip`, which is not uniform, and\n * an implicit derivative there is undefined. **WGSL does not merely leave it undefined,\n * it refuses to compile the module**: *\"'textureSample' must only be called from uniform\n * control flow\"*, with the note that reading `vReflectionClip` may be non-uniform.\n *\n * Bit-identical on WebGL2: `planarReflection.ts` allocates one level and filters\n * `LINEAR`, so there is no mip to select and level zero is the only thing `texture`\n * could have read.\n */\n sceneReflection +=\n textureLod(uReflectionMap, clamp(sampleUv, 0.0, 1.0), 0.0).rgb * weight;\n filterWeight += weight;\n }\n sceneReflection /= max(filterWeight, 0.001);\n reflected = mix(atmosphereColor, sceneReflection, reflectionValid);\n }\n // The atmosphere-colour fallback preserves the old sky reflection when a\n // quality profile disables the extra scene pass. Foam scatters rather than\n // mirroring, so it masks the reflection at breaking crests.\n float sceneWeight = float(uReflectionEnabled) * reflectionValid;\n float reflectionGain = mix(0.75, 0.94, sceneWeight);\n // Angle-honest by default, and a mirror where a body says so. See uMirror above.\n float reflectivity = mix(fresnel, 1.0, uMirror);\n float reflectedRadiance = min(\n reflectivity * reflectionGain * (1.0 - foam * 0.8),\n opacity\n );\n // With SRC_ALPHA blending, mixing by Fresnel directly would multiply the\n // reflection by opacity again. Divide it out here so the framebuffer gets\n // the intended reflected fraction while the remainder stays transmissive.\n float reflectionWeight = reflectedRadiance / max(opacity, 0.001);\n col = mix(col, reflected, reflectionWeight);\n\n /*\n * Dissolve the sheet at its own boundary.\n *\n * Fading on fog alone made the horizon a function of the daily palette: a\n * clear day never reached the threshold, so the grid's rim \u2014 corner and all \u2014\n * became the skyline. Fading on the grid coordinate cannot fail that way,\n * because alpha reaches zero at the last row of vertices by construction.\n * Chebyshev distance because the sheet is a rectangle, so the fade band\n * follows its four edges instead of cutting an ellipse out of it \u2014 and it is\n * measured per axis, or a channel's narrow sides would be one continuous fade\n * and it would have no visible edge at all.\n */\n vec2 rimFraction = abs(vGrid) / max(uGridHalf, vec2(0.001));\n float rimT = max(rimFraction.x, rimFraction.y);\n float rim = 1.0 - smoothstep(0.9, 1.0, rimT);\n // Fog still helps where it is thick, it just is no longer load-bearing.\n float edge = 1.0 - smoothstep(0.82, 1.0, fog);\n outColor = vec4(col, opacity * edge * rim * uVisibility);\n}\n";
@@ -1,5 +1,6 @@
1
1
  import type { ReadonlyMat4 } from 'gl-matrix';
2
2
  import type { ReflectionQueue } from './screenSpaceReflection.ts';
3
+ import type { ResolvedFrameReflections } from './frameReflections.ts';
3
4
  /**
4
5
  * Screen-space reflection on WebGL2: a scissored trace per surface, then one composite.
5
6
  *
@@ -33,6 +34,9 @@ export declare class SsrPass {
33
34
  private width;
34
35
  private height;
35
36
  /** `inverse(viewProjection) * DEPTH_01_TO_CLIP`, rebuilt once a frame rather than per surface. */
37
+ /** The frame's materials' resolve, compiled the first time a frame traces them. */
38
+ private materialResolve;
39
+ private materialUniforms;
36
40
  private readonly depthToWorld;
37
41
  private readonly scissor;
38
42
  constructor(gl: WebGL2RenderingContext,
@@ -54,6 +58,18 @@ export declare class SsrPass {
54
58
  */
55
59
  traceReflections(queue: ReflectionQueue, scene: WebGLTexture, depth: WebGLTexture, viewProjection: ReadonlyMat4, eye: ArrayLike<number>, width: number, height: number, edgeFade: number): void;
56
60
  /** Composite what the trace found over whatever framebuffer is bound. */
61
+ /**
62
+ * Every pixel the lit stage left a reflection for, marched against the frame's depth by the
63
+ * trace's material mode, into this pass's own target; `resolveMaterials` then swaps the probe's
64
+ * share for it. See `frameReflections.ts`. Ends on the default framebuffer, as the surfaces' does.
65
+ */
66
+ traceMaterials(scene: WebGLTexture, depth: WebGLTexture, probe: WebGLTexture, tint: WebGLTexture, viewProjection: ReadonlyMat4, eye: ArrayLike<number>, width: number, height: number, edgeFade: number, reflections: ResolvedFrameReflections): void;
67
+ /**
68
+ * The probe's share of each pixel swapped for what `traceMaterials` found, blurred by the
69
+ * surface's roughness, added onto whatever framebuffer is bound — the scene's, with the two maps
70
+ * already detached from it. See `SSR_MATERIAL_RESOLVE_FRAG`.
71
+ */
72
+ resolveMaterials(probe: WebGLTexture, tint: WebGLTexture, width: number, height: number, reflections: ResolvedFrameReflections): void;
57
73
  resolveReflections(): void;
58
74
  dispose(): void;
59
75
  }
@@ -3,7 +3,7 @@ import { decalScissor } from './decalProjector.js';
3
3
  import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.js';
4
4
  import { compileProgram, uniformLocations } from './shader.js';
5
5
  import { FULLSCREEN_VERT } from './shaders/fullscreen.js';
6
- import { SSR_RESOLVE_FRAG } from './shaders/ssrResolve.js';
6
+ import { SSR_MATERIAL_RESOLVE_FRAG, SSR_RESOLVE_FRAG } from './shaders/ssrResolve.js';
7
7
  import { SSR_TRACE_FRAG } from './shaders/ssrTrace.js';
8
8
  import { blendKeeping } from './sceneCoverage.js';
9
9
  /**
@@ -32,6 +32,9 @@ export class SsrPass {
32
32
  width = 0;
33
33
  height = 0;
34
34
  /** `inverse(viewProjection) * DEPTH_01_TO_CLIP`, rebuilt once a frame rather than per surface. */
35
+ /** The frame's materials' resolve, compiled the first time a frame traces them. */
36
+ materialResolve = null;
37
+ materialUniforms = {};
35
38
  depthToWorld = new Float32Array(16);
36
39
  scissor = new Int32Array(4);
37
40
  constructor(gl,
@@ -118,6 +121,11 @@ export class SsrPass {
118
121
  gl.activeTexture(gl.TEXTURE1);
119
122
  gl.bindTexture(gl.TEXTURE_2D, scene);
120
123
  gl.uniform1i(u['uSsrScene'] ?? null, 1);
124
+ /* The materials' maps unread in this mode, and pointed at a bound texture all the same: a
125
+ driver may fetch a sampler's descriptor before the branch that skips it. */
126
+ gl.uniform1i(u['uSsrProbeMap'] ?? null, 0);
127
+ gl.uniform1i(u['uSsrTintMap'] ?? null, 0);
128
+ gl.uniform1f(u['uSsrMaterial'] ?? null, 0);
121
129
  gl.uniformMatrix4fv(u['uSsrDepthToWorld'] ?? null, false, this.depthToWorld);
122
130
  /* No Y flip on this backend: `gl.scissor` and the framebuffer both count rows from the bottom,
123
131
  which is the space the raw matrix already emits. See `CLIP_Y_FLIP`. */
@@ -153,6 +161,100 @@ export class SsrPass {
153
161
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
154
162
  }
155
163
  /** Composite what the trace found over whatever framebuffer is bound. */
164
+ /**
165
+ * Every pixel the lit stage left a reflection for, marched against the frame's depth by the
166
+ * trace's material mode, into this pass's own target; `resolveMaterials` then swaps the probe's
167
+ * share for it. See `frameReflections.ts`. Ends on the default framebuffer, as the surfaces' does.
168
+ */
169
+ traceMaterials(scene, depth, probe, tint, viewProjection, eye, width, height, edgeFade, reflections) {
170
+ const { gl } = this;
171
+ const u = this.traceUniforms;
172
+ this.ensureSize(width, height);
173
+ mat4.invert(this.depthToWorld, viewProjection);
174
+ mat4.multiply(this.depthToWorld, this.depthToWorld, DEPTH_01_TO_CLIP);
175
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
176
+ gl.viewport(0, 0, width, height);
177
+ gl.disable(gl.DEPTH_TEST);
178
+ gl.depthMask(false);
179
+ gl.disable(gl.CULL_FACE);
180
+ gl.disable(gl.SCISSOR_TEST);
181
+ gl.disable(gl.BLEND);
182
+ gl.clearBufferfv(gl.COLOR, 0, [0, 0, 0, 0]);
183
+ gl.useProgram(this.trace);
184
+ gl.bindVertexArray(this.vao);
185
+ const textures = [depth, scene, probe, tint];
186
+ const names = ['uSsrDepth', 'uSsrScene', 'uSsrProbeMap', 'uSsrTintMap'];
187
+ for (let unit = 0; unit < 4; unit++) {
188
+ gl.activeTexture(gl.TEXTURE0 + unit);
189
+ gl.bindTexture(gl.TEXTURE_2D, textures[unit] ?? null);
190
+ gl.uniform1i(u[names[unit] ?? ''] ?? null, unit);
191
+ }
192
+ gl.uniformMatrix4fv(u['uSsrDepthToWorld'] ?? null, false, this.depthToWorld);
193
+ gl.uniformMatrix4fv(u['uSsrViewProj'] ?? null, false, viewProjection);
194
+ gl.uniform3f(u['uSsrEye'] ?? null, eye[0] ?? 0, eye[1] ?? 0, eye[2] ?? 0);
195
+ gl.uniform1f(u['uSsrEdgeFade'] ?? null, edgeFade);
196
+ gl.uniform3f(u['uSsrAxis'] ?? null, 0, 0, 0);
197
+ gl.uniform3f(u['uSsrTint'] ?? null, 1, 1, 1);
198
+ gl.uniform1f(u['uSsrStrength'] ?? null, 1);
199
+ gl.uniform2f(u['uSsrFresnel'] ?? null, 0, 0);
200
+ gl.uniform1f(u['uSsrFacingCos'] ?? null, -1);
201
+ gl.uniform1f(u['uSsrReach'] ?? null, reflections.reachM);
202
+ gl.uniform1f(u['uSsrThickness'] ?? null, reflections.thicknessM);
203
+ gl.uniform1f(u['uSsrSteps'] ?? null, reflections.steps);
204
+ gl.uniform1f(u['uSsrMaterial'] ?? null, 1);
205
+ gl.uniform1f(u['uSsrMaxRoughness'] ?? null, reflections.maxRoughness);
206
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
207
+ /* Back to the surfaces' mode, which every other trace in the frame is. */
208
+ gl.uniform1f(u['uSsrMaterial'] ?? null, 0);
209
+ gl.bindVertexArray(null);
210
+ for (let unit = 3; unit >= 0; unit--) {
211
+ gl.activeTexture(gl.TEXTURE0 + unit);
212
+ gl.bindTexture(gl.TEXTURE_2D, null);
213
+ }
214
+ gl.depthMask(true);
215
+ gl.enable(gl.DEPTH_TEST);
216
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
217
+ }
218
+ /**
219
+ * The probe's share of each pixel swapped for what `traceMaterials` found, blurred by the
220
+ * surface's roughness, added onto whatever framebuffer is bound — the scene's, with the two maps
221
+ * already detached from it. See `SSR_MATERIAL_RESOLVE_FRAG`.
222
+ */
223
+ resolveMaterials(probe, tint, width, height, reflections) {
224
+ const { gl } = this;
225
+ if (this.reflection === null)
226
+ return;
227
+ if (this.materialResolve === null) {
228
+ this.materialResolve = compileProgram(gl, FULLSCREEN_VERT, SSR_MATERIAL_RESOLVE_FRAG, 'ssrMaterialResolve');
229
+ this.materialUniforms = uniformLocations(gl, this.materialResolve, 'ssrMaterialResolve');
230
+ }
231
+ const u = this.materialUniforms;
232
+ gl.disable(gl.DEPTH_TEST);
233
+ gl.depthMask(false);
234
+ gl.disable(gl.CULL_FACE);
235
+ gl.enable(gl.BLEND);
236
+ /* Added, a negative addend subtracting, and the scene's alpha kept. */
237
+ blendKeeping(gl, gl.ONE, gl.ONE);
238
+ gl.useProgram(this.materialResolve);
239
+ const textures = [this.reflection, probe, tint];
240
+ const names = ['uSsrReflection', 'uSsrProbeMap', 'uSsrTintMap'];
241
+ for (let unit = 0; unit < 3; unit++) {
242
+ gl.activeTexture(gl.TEXTURE0 + unit);
243
+ gl.bindTexture(gl.TEXTURE_2D, textures[unit] ?? null);
244
+ gl.uniform1i(u[names[unit] ?? ''] ?? null, unit);
245
+ }
246
+ gl.uniform3f(u['uSsrBlur'] ?? null, 1 / Math.max(width, 1), 1 / Math.max(height, 1), reflections.blur * height);
247
+ gl.bindVertexArray(this.vao);
248
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
249
+ gl.bindVertexArray(null);
250
+ for (let unit = 2; unit >= 0; unit--) {
251
+ gl.activeTexture(gl.TEXTURE0 + unit);
252
+ gl.bindTexture(gl.TEXTURE_2D, null);
253
+ }
254
+ gl.disable(gl.BLEND);
255
+ gl.depthMask(true);
256
+ gl.enable(gl.DEPTH_TEST);
257
+ }
156
258
  resolveReflections() {
157
259
  const { gl } = this;
158
260
  if (this.reflection === null)
@@ -189,6 +291,9 @@ export class SsrPass {
189
291
  gl.deleteFramebuffer(this.framebuffer);
190
292
  gl.deleteProgram(this.trace);
191
293
  gl.deleteProgram(this.resolve);
294
+ if (this.materialResolve !== null)
295
+ gl.deleteProgram(this.materialResolve);
296
+ this.materialResolve = null;
192
297
  gl.deleteVertexArray(this.vao);
193
298
  this.reflection = null;
194
299
  this.framebuffer = null;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Draws that do not change between frames, captured once from an enumeration and replayed into any
3
+ * colour pass: what `createStaticDraws` keeps, and the one rule both backends replay it by.
4
+ *
5
+ * **Captured, not referenced.** An enumeration is a closure over the caller's state, written to
6
+ * allocate nothing, so it hands a sink its scratch: the matrix it fills for every entry and the
7
+ * count it rewrites for the next batch. A list kept across frames copies both when it is made, so
8
+ * what it draws is what the enumeration said then. Meshes, batches and materials are held by
9
+ * reference: they are the caller's, and disposing one a list still holds is the caller's error.
10
+ *
11
+ * **Rigid meshes and instanced batches only.** A skinned mesh is posed every frame and a scatter
12
+ * batch bends with every frame's wind, so neither can be kept, and both are refused by name rather
13
+ * than captured in the pose they happened to have. A caller draws them each frame as it always has.
14
+ *
15
+ * What a backend does with the list is its own: WebGL2 replays the entries every frame through
16
+ * the scene-caster path, and WebGPU records them once into a render bundle per view. See
17
+ * `RendererApi.createStaticDraws` for what a caller is promised, which is the same on both.
18
+ */
19
+ import type { InstancedHandle, MeshHandle } from './backend/api.ts';
20
+ import type { MeshInstances } from './instances.ts';
21
+ import type { SceneCasterMaterial, ShadowCasterSink, ShadowCasters } from './shadowCasters.ts';
22
+ /** A list from `createStaticDraws`. Opaque but for its size. */
23
+ export interface StaticDrawsHandle {
24
+ /** How many entries it holds: meshes and batches, a batch counting once. */
25
+ readonly draws: number;
26
+ }
27
+ /** One captured entry. */
28
+ export type StaticEntry = {
29
+ readonly kind: 'mesh';
30
+ readonly mesh: MeshHandle;
31
+ readonly model: Float32Array;
32
+ readonly material: SceneCasterMaterial;
33
+ } | {
34
+ readonly kind: 'instanced';
35
+ readonly batch: InstancedHandle;
36
+ readonly data: MeshInstances;
37
+ readonly material: SceneCasterMaterial;
38
+ };
39
+ /** What both backends hold: the entries, and whether the list has been disposed. */
40
+ export interface StaticDrawsList extends StaticDrawsHandle {
41
+ readonly entries: readonly StaticEntry[];
42
+ disposed: boolean;
43
+ }
44
+ /** Enumerate `casters` once into a list. Allocates: it runs when the list is made, not per frame. */
45
+ export declare function captureStaticDraws(casters: ShadowCasters): StaticDrawsList;
46
+ /** Hand every entry to `sink`, in the order it was captured. */
47
+ export declare function replayStaticDraws(list: StaticDrawsList, sink: ShadowCasterSink): void;
@@ -0,0 +1,43 @@
1
+ /** Enumerate `casters` once into a list. Allocates: it runs when the list is made, not per frame. */
2
+ export function captureStaticDraws(casters) {
3
+ const entries = [];
4
+ const sink = {
5
+ mesh: (mesh, model, material) => {
6
+ entries.push({
7
+ kind: 'mesh',
8
+ mesh,
9
+ model: new Float32Array(model),
10
+ material: material ?? null,
11
+ });
12
+ },
13
+ instanced: (batch, data, material) => {
14
+ /* The batch's placements are on the device already; the count is the caller's scratch. */
15
+ entries.push({ kind: 'instanced', batch, data: { ...data }, material: material ?? null });
16
+ },
17
+ skinnedMesh: () => {
18
+ throw new Error('createStaticDraws: a skinned mesh is posed every frame, so a list kept across frames ' +
19
+ 'cannot hold one. Draw it each frame, with drawMesh or drawSceneCasters.');
20
+ },
21
+ scatter: () => {
22
+ throw new Error('createStaticDraws: a scatter batch bends with every frame’s wind, so a list kept across ' +
23
+ 'frames cannot hold one. Draw it each frame with drawScatter.');
24
+ },
25
+ };
26
+ casters(sink);
27
+ return {
28
+ entries,
29
+ get draws() {
30
+ return entries.length;
31
+ },
32
+ disposed: false,
33
+ };
34
+ }
35
+ /** Hand every entry to `sink`, in the order it was captured. */
36
+ export function replayStaticDraws(list, sink) {
37
+ for (const entry of list.entries) {
38
+ if (entry.kind === 'mesh')
39
+ sink.mesh(entry.mesh, entry.model, entry.material);
40
+ else
41
+ sink.instanced?.(entry.batch, entry.data, entry.material);
42
+ }
43
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * What a draw lays over its surface beyond its material: a rim of light, an emissive overlay that
3
+ * dissolves the surface away, and a second normal map blended in by region. Set per draw with
4
+ * `RendererApi.setSurfaceOverlay`, so one character's whole set of materials changes together
5
+ * while another's does not.
6
+ *
7
+ * **Per draw and held, like `setEnvironmentGain`**: set before a character's draws, cleared after,
8
+ * and every draw between them wears it. **Images come from one atlas** (`maps`), each named by the
9
+ * rectangle it occupies — a scale and an offset in the atlas's uv — because the lit stage binds every
10
+ * sampler a device is guaranteed and has none left for a texture of its own: the atlas is bound in
11
+ * the slot the frame's refraction copy takes, which an opaque surface never reads. A draw that both
12
+ * refracts and wears an overlay's images keeps its refraction and loses the images, said once.
13
+ *
14
+ * **Fifteen vectors of numbers**, in the material's uniform block. WebGPU has the room; WebGL2 has
15
+ * room where its device reports more than the lit stage's 254 vectors, and refuses the overlay in
16
+ * words where it does not. See `packSurfaceOverlay` for the layout.
17
+ */
18
+ import type { Vec3 } from '../math/color.ts';
19
+ import type { SurfaceTextureHandle } from './backend/api.ts';
20
+ /** Where an image sits in the atlas: its size and its corner, each in the atlas's uv. */
21
+ export interface OverlayRegion {
22
+ readonly scale: readonly [number, number];
23
+ readonly offset: readonly [number, number];
24
+ }
25
+ /**
26
+ * Light along the silhouette: the edge `(1 - N·V)^falloff`, leaning toward normals that face up,
27
+ * raised to a contrast, times a noise laid in screen space, a slow pulse and a mask, in a colour.
28
+ * Added to what the surface emits, and not gated on the night as emission is: it is a glow the
29
+ * surface wears, not light it gives off.
30
+ */
31
+ export interface SurfaceRim {
32
+ readonly colour: Vec3;
33
+ /** How bright, 0 for none. Multiplied with `alpha`. */
34
+ readonly intensity: number;
35
+ readonly alpha?: number;
36
+ /** The edge's exponent: larger keeps the rim nearer the silhouette. 1.5 unless given. */
37
+ readonly falloff?: number;
38
+ /**
39
+ * How far the rim leans toward normals that face up: the weight is `(N·up · ½ + ½)^upward`, so
40
+ * 0 is no lean. 0.4 unless given.
41
+ */
42
+ readonly upward?: number;
43
+ /** The exponent the whole edge is raised to. 1 unless given. */
44
+ readonly contrast?: number;
45
+ /** A noise image laid in screen space and scrolled, the rim taken times it, clamped to 1. */
46
+ readonly noise?: {
47
+ readonly region: OverlayRegion;
48
+ /** Screens a second, along x and y. */
49
+ readonly scroll?: readonly [number, number];
50
+ /** Repeats across the screen's height. 1 unless given. */
51
+ readonly tiling?: number;
52
+ };
53
+ /** A pulse `½ + ½ sin(rate · t)`, held between `low` and `high`, on `Environment.surfaceTime`. */
54
+ readonly pulse?: {
55
+ readonly rate: number;
56
+ readonly low: number;
57
+ readonly high: number;
58
+ };
59
+ /** A mask in the mesh's uv where the rim is erased: the rim times `1 - weight · mask`. */
60
+ readonly mask?: {
61
+ readonly region: OverlayRegion;
62
+ readonly weight: number;
63
+ };
64
+ }
65
+ /**
66
+ * The surface cut away where a noise in its uv falls under `threshold`, with a glowing band just
67
+ * above it, and a colour laid over the surface through the same noise.
68
+ */
69
+ export interface SurfaceDissolve {
70
+ readonly noise: {
71
+ readonly region: OverlayRegion;
72
+ readonly tiling?: readonly [number, number];
73
+ };
74
+ /** 0 keeps everything; 1 cuts everything. */
75
+ readonly threshold: number;
76
+ /** The band's width, in the noise's own units. 0.05 unless given. */
77
+ readonly edge?: number;
78
+ /** The band's light. */
79
+ readonly edgeColour?: Vec3;
80
+ readonly edgeIntensity?: number;
81
+ /** A colour laid over the surface through the noise, as light, and how much of it. */
82
+ readonly overlay?: {
83
+ readonly colour: Vec3;
84
+ readonly amount: number;
85
+ };
86
+ }
87
+ /**
88
+ * A second normal map blended into the surface's by six region weights, read through two masks:
89
+ * the first mask's red, green and blue carry regions 1 to 3, the second's regions 4 to 6. A face's
90
+ * expressions, or veins brought up all over.
91
+ */
92
+ export interface SurfaceWrinkle {
93
+ readonly normal: OverlayRegion;
94
+ readonly masks: readonly [OverlayRegion, OverlayRegion];
95
+ readonly weights: readonly [number, number, number, number, number, number];
96
+ }
97
+ /**
98
+ * What a draw lays over its surface beyond its material, for `setSurfaceOverlay`: a rim, a dissolve
99
+ * and wrinkles, each optional, and the atlas their images are regions of.
100
+ */
101
+ export interface SurfaceOverlay {
102
+ /**
103
+ * The atlas every image above is a region of. Absent: no image is read whatever regions are
104
+ * named — rims are smooth, nothing dissolves and nothing wrinkles — because the slot then holds a
105
+ * stand-in whose colour would read as a noise and a mask.
106
+ */
107
+ readonly maps?: SurfaceTextureHandle;
108
+ readonly rim?: SurfaceRim;
109
+ readonly dissolve?: SurfaceDissolve;
110
+ readonly wrinkle?: SurfaceWrinkle;
111
+ }
112
+ /** Vectors the overlay takes in the material's block: `uOverlay[OVERLAY_VECTORS]`. */
113
+ export declare const OVERLAY_VECTORS = 15;
114
+ /** Floats `packSurfaceOverlay` fills. */
115
+ export declare const OVERLAY_FLOATS: number;
116
+ /**
117
+ * An overlay as the shader reads it, into `out` (`OVERLAY_FLOATS`); all zeros for `null`, which is
118
+ * no overlay. A region with a zero scale is an image not given, and its term reads as none — a
119
+ * noise of 1, a mask of 0, no wrinkle — and every region is one without `maps`. Fifteen vectors:
120
+ *
121
+ * - 0: rim colour, rim strength (intensity times alpha; 0 is no rim)
122
+ * - 1: falloff, upward lean, contrast, mask weight
123
+ * - 2: the rim's noise region; 3: its scroll along x and y, its tiling, the pulse's rate
124
+ * - 4: the pulse's low and high (1 and 1 where none)
125
+ * - 5: the rim's mask region
126
+ * - 6: the dissolve's noise region (a zero scale is no dissolve); 7: its tiling, threshold, edge
127
+ * - 8: the edge's light and its strength; 9: the laid-over colour and its amount
128
+ * - 10: the wrinkle normal's region; 11 and 12: the two masks'; 13 and 14: the six weights
129
+ */
130
+ export declare function packSurfaceOverlay(overlay: SurfaceOverlay | null, out: Float32Array): void;
131
+ /** Whether an overlay lays anything at all, so a renderer can leave the lit switch off. */
132
+ export declare function overlayLays(overlay: SurfaceOverlay | null): boolean;
@@ -0,0 +1,85 @@
1
+ /** Vectors the overlay takes in the material's block: `uOverlay[OVERLAY_VECTORS]`. */
2
+ export const OVERLAY_VECTORS = 15;
3
+ /** Floats `packSurfaceOverlay` fills. */
4
+ export const OVERLAY_FLOATS = OVERLAY_VECTORS * 4;
5
+ const finite = (value, fallback) => value !== undefined && Number.isFinite(value) ? value : fallback;
6
+ function region(out, at, r) {
7
+ out[at] = finite(r?.scale[0], 0);
8
+ out[at + 1] = finite(r?.scale[1], 0);
9
+ out[at + 2] = finite(r?.offset[0], 0);
10
+ out[at + 3] = finite(r?.offset[1], 0);
11
+ }
12
+ /**
13
+ * An overlay as the shader reads it, into `out` (`OVERLAY_FLOATS`); all zeros for `null`, which is
14
+ * no overlay. A region with a zero scale is an image not given, and its term reads as none — a
15
+ * noise of 1, a mask of 0, no wrinkle — and every region is one without `maps`. Fifteen vectors:
16
+ *
17
+ * - 0: rim colour, rim strength (intensity times alpha; 0 is no rim)
18
+ * - 1: falloff, upward lean, contrast, mask weight
19
+ * - 2: the rim's noise region; 3: its scroll along x and y, its tiling, the pulse's rate
20
+ * - 4: the pulse's low and high (1 and 1 where none)
21
+ * - 5: the rim's mask region
22
+ * - 6: the dissolve's noise region (a zero scale is no dissolve); 7: its tiling, threshold, edge
23
+ * - 8: the edge's light and its strength; 9: the laid-over colour and its amount
24
+ * - 10: the wrinkle normal's region; 11 and 12: the two masks'; 13 and 14: the six weights
25
+ */
26
+ export function packSurfaceOverlay(overlay, out) {
27
+ out.fill(0);
28
+ if (overlay === null)
29
+ return;
30
+ /* No atlas, no region: see `SurfaceOverlay.maps`. */
31
+ const images = overlay.maps !== undefined;
32
+ const rim = overlay.rim;
33
+ if (rim !== undefined) {
34
+ out[0] = finite(rim.colour[0], 0);
35
+ out[1] = finite(rim.colour[1], 0);
36
+ out[2] = finite(rim.colour[2], 0);
37
+ out[3] = Math.max(0, finite(rim.intensity, 0) * finite(rim.alpha, 1));
38
+ out[4] = Math.max(0, finite(rim.falloff, 1.5));
39
+ out[5] = Math.max(0, finite(rim.upward, 0.4));
40
+ out[6] = Math.max(0, finite(rim.contrast, 1));
41
+ out[7] = rim.mask === undefined ? 0 : Math.min(Math.max(finite(rim.mask.weight, 0), 0), 1);
42
+ region(out, 8, images ? rim.noise?.region : null);
43
+ out[12] = finite(rim.noise?.scroll?.[0], 0);
44
+ out[13] = finite(rim.noise?.scroll?.[1], 0);
45
+ out[14] = Math.max(0, finite(rim.noise?.tiling, 1));
46
+ out[15] = finite(rim.pulse?.rate, 0);
47
+ out[16] = rim.pulse === undefined ? 1 : finite(rim.pulse.low, 1);
48
+ out[17] = rim.pulse === undefined ? 1 : finite(rim.pulse.high, 1);
49
+ region(out, 20, images ? rim.mask?.region : null);
50
+ }
51
+ const dissolve = overlay.dissolve;
52
+ if (dissolve !== undefined) {
53
+ region(out, 24, images ? dissolve.noise.region : null);
54
+ out[28] = finite(dissolve.noise.tiling?.[0], 1);
55
+ out[29] = finite(dissolve.noise.tiling?.[1], 1);
56
+ out[30] = Math.min(Math.max(finite(dissolve.threshold, 0), 0), 1);
57
+ out[31] = Math.max(1e-4, finite(dissolve.edge, 0.05));
58
+ const edge = dissolve.edgeColour ?? [0, 0, 0];
59
+ out[32] = finite(edge[0], 0);
60
+ out[33] = finite(edge[1], 0);
61
+ out[34] = finite(edge[2], 0);
62
+ out[35] = Math.max(0, finite(dissolve.edgeIntensity, 1));
63
+ const laid = dissolve.overlay;
64
+ if (laid !== undefined) {
65
+ out[36] = finite(laid.colour[0], 0);
66
+ out[37] = finite(laid.colour[1], 0);
67
+ out[38] = finite(laid.colour[2], 0);
68
+ out[39] = Math.max(0, finite(laid.amount, 0));
69
+ }
70
+ }
71
+ const wrinkle = overlay.wrinkle;
72
+ if (wrinkle !== undefined) {
73
+ region(out, 40, images ? wrinkle.normal : null);
74
+ region(out, 44, images ? wrinkle.masks[0] : null);
75
+ region(out, 48, images ? wrinkle.masks[1] : null);
76
+ for (let i = 0; i < 6; i++) {
77
+ out[52 + i] = Math.min(Math.max(finite(wrinkle.weights[i], 0), 0), 1);
78
+ }
79
+ }
80
+ }
81
+ /** Whether an overlay lays anything at all, so a renderer can leave the lit switch off. */
82
+ export function overlayLays(overlay) {
83
+ return (overlay !== null &&
84
+ (overlay.rim !== undefined || overlay.dissolve !== undefined || overlay.wrinkle !== undefined));
85
+ }