@driftengine/core 4.8.6 → 4.9.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 (253) hide show
  1. package/CHANGELOG.json +132 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +7 -2
  4. package/dist/index.js +8 -1
  5. package/dist/render/anisotropicLobe.d.ts +10 -0
  6. package/dist/render/anisotropicLobe.js +19 -0
  7. package/dist/render/areaLights.d.ts +35 -0
  8. package/dist/render/areaLights.js +35 -0
  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 +41 -3
  12. package/dist/render/backend/createRenderer.d.ts +6 -0
  13. package/dist/render/backend/createRenderer.js +4 -1
  14. package/dist/render/backend/webgl2/renderer.d.ts +140 -16
  15. package/dist/render/backend/webgl2/renderer.js +436 -44
  16. package/dist/render/backend/webgl2/sceneCaptureTarget.d.ts +29 -0
  17. package/dist/render/backend/webgl2/sceneCaptureTarget.js +96 -0
  18. package/dist/render/backend/webgpu/device.d.ts +7 -1
  19. package/dist/render/backend/webgpu/device.js +31 -3
  20. package/dist/render/backend/webgpu/flatPass.d.ts +16 -1
  21. package/dist/render/backend/webgpu/flatPass.js +58 -26
  22. package/dist/render/backend/webgpu/particlePass.js +2 -1
  23. package/dist/render/backend/webgpu/postPass.d.ts +51 -9
  24. package/dist/render/backend/webgpu/postPass.js +16 -2
  25. package/dist/render/backend/webgpu/reflectionSurfacePass.d.ts +30 -0
  26. package/dist/render/backend/webgpu/reflectionSurfacePass.js +100 -0
  27. package/dist/render/backend/webgpu/renderer.d.ts +166 -3
  28. package/dist/render/backend/webgpu/renderer.js +738 -83
  29. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  30. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  31. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  32. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  33. package/dist/render/backend/webgpu/surfaceTexturePass.js +34 -8
  34. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  35. package/dist/render/clusteredLights.d.ts +23 -5
  36. package/dist/render/clusteredLights.js +35 -8
  37. package/dist/render/displayRange.d.ts +31 -0
  38. package/dist/render/displayRange.js +47 -0
  39. package/dist/render/driftLight/bake.d.ts +2 -0
  40. package/dist/render/driftLight/bake.js +1 -1
  41. package/dist/render/driftLight/falloff.d.ts +4 -2
  42. package/dist/render/driftLight/falloff.js +8 -2
  43. package/dist/render/filmicCurve.d.ts +74 -0
  44. package/dist/render/filmicCurve.js +203 -0
  45. package/dist/render/fog.d.ts +4 -1
  46. package/dist/render/fog.js +12 -2
  47. package/dist/render/frame/resources.d.ts +2 -2
  48. package/dist/render/frame/resources.js +6 -0
  49. package/dist/render/frameReflections.d.ts +40 -0
  50. package/dist/render/frameReflections.js +48 -0
  51. package/dist/render/gi/bounceLights.d.ts +3 -2
  52. package/dist/render/gi/bounceLights.js +8 -3
  53. package/dist/render/instanceCull.js +5 -0
  54. package/dist/render/instances.d.ts +19 -5
  55. package/dist/render/instances.js +9 -3
  56. package/dist/render/lightBudget.d.ts +12 -1
  57. package/dist/render/lightBudget.js +23 -3
  58. package/dist/render/lightmap.d.ts +23 -9
  59. package/dist/render/lightmap.js +40 -18
  60. package/dist/render/particlePool.d.ts +21 -0
  61. package/dist/render/particlePool.js +7 -0
  62. package/dist/render/pass.d.ts +49 -0
  63. package/dist/render/pointLightSelection.d.ts +13 -0
  64. package/dist/render/pointLightSelection.js +3 -1
  65. package/dist/render/reflectionSurfaceTargets.d.ts +28 -0
  66. package/dist/render/reflectionSurfaceTargets.js +99 -0
  67. package/dist/render/renderQuality.d.ts +47 -15
  68. package/dist/render/renderQuality.js +4 -0
  69. package/dist/render/rendererHarness.js +3 -0
  70. package/dist/render/rgb9e5.d.ts +22 -0
  71. package/dist/render/rgb9e5.js +47 -0
  72. package/dist/render/sceneCapture.d.ts +29 -0
  73. package/dist/render/sceneCapture.js +36 -0
  74. package/dist/render/sceneTarget.d.ts +5 -0
  75. package/dist/render/sceneTarget.js +24 -1
  76. package/dist/render/screenSpaceReflection.d.ts +32 -1
  77. package/dist/render/screenSpaceReflection.js +14 -0
  78. package/dist/render/shaders/arcane.d.ts +1 -1
  79. package/dist/render/shaders/bolt.d.ts +1 -1
  80. package/dist/render/shaders/bolt.js +7 -1
  81. package/dist/render/shaders/caustics.d.ts +1 -1
  82. package/dist/render/shaders/depth.js +11 -0
  83. package/dist/render/shaders/film.d.ts +1 -1
  84. package/dist/render/shaders/filmLook.js +2 -1
  85. package/dist/render/shaders/fire.d.ts +1 -1
  86. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  87. package/dist/render/shaders/flat/anisotropic.js +37 -3
  88. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  89. package/dist/render/shaders/flat/areaLight.js +46 -0
  90. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  91. package/dist/render/shaders/flat/glassTint.js +5 -1
  92. package/dist/render/shaders/flat/index.d.ts +15 -0
  93. package/dist/render/shaders/flat/index.js +22 -2
  94. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  95. package/dist/render/shaders/flat/lobes.js +49 -43
  96. package/dist/render/shaders/flat/main.d.ts +1 -1
  97. package/dist/render/shaders/flat/main.js +85 -22
  98. package/dist/render/shaders/flat/models.d.ts +5 -2
  99. package/dist/render/shaders/flat/models.js +100 -16
  100. package/dist/render/shaders/flat/overlay.d.ts +16 -0
  101. package/dist/render/shaders/flat/overlay.js +117 -0
  102. package/dist/render/shaders/flat/pointShadow.js +1 -1
  103. package/dist/render/shaders/flat/preamble.js +10 -3
  104. package/dist/render/shaders/flat/reflectionSurface.d.ts +21 -0
  105. package/dist/render/shaders/flat/reflectionSurface.js +61 -0
  106. package/dist/render/shaders/fog.d.ts +1 -1
  107. package/dist/render/shaders/fog.js +9 -2
  108. package/dist/render/shaders/fringe.d.ts +29 -0
  109. package/dist/render/shaders/fringe.js +55 -0
  110. package/dist/render/shaders/generated/arcane.wgsl.d.ts +1 -1
  111. package/dist/render/shaders/generated/arcane.wgsl.js +1 -1
  112. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  113. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  114. package/dist/render/shaders/generated/caustics.wgsl.d.ts +1 -1
  115. package/dist/render/shaders/generated/caustics.wgsl.js +1 -1
  116. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  117. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  118. package/dist/render/shaders/generated/film.wgsl.d.ts +1 -1
  119. package/dist/render/shaders/generated/film.wgsl.js +1 -1
  120. package/dist/render/shaders/generated/fire.wgsl.d.ts +1 -1
  121. package/dist/render/shaders/generated/fire.wgsl.js +1 -1
  122. package/dist/render/shaders/generated/flat.wgsl.d.ts +192 -16
  123. package/dist/render/shaders/generated/flat.wgsl.js +230 -54
  124. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  125. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  126. package/dist/render/shaders/generated/particle.wgsl.d.ts +4 -4
  127. package/dist/render/shaders/generated/particle.wgsl.js +4 -4
  128. package/dist/render/shaders/generated/rush.wgsl.d.ts +56 -26
  129. package/dist/render/shaders/generated/rush.wgsl.js +56 -26
  130. package/dist/render/shaders/generated/scatter.wgsl.d.ts +1 -1
  131. package/dist/render/shaders/generated/scatter.wgsl.js +1 -1
  132. package/dist/render/shaders/generated/smoke.wgsl.d.ts +1 -1
  133. package/dist/render/shaders/generated/smoke.wgsl.js +1 -1
  134. package/dist/render/shaders/generated/ssrResolve.wgsl.d.ts +29 -0
  135. package/dist/render/shaders/generated/ssrResolve.wgsl.js +29 -0
  136. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +32 -7
  137. package/dist/render/shaders/generated/ssrTrace.wgsl.js +32 -7
  138. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  139. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  140. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  141. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +2 -2
  142. package/dist/render/shaders/gpudriven/fog.wgsl.js +11 -3
  143. package/dist/render/shaders/line.d.ts +1 -1
  144. package/dist/render/shaders/particle.d.ts +3 -3
  145. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  146. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  147. package/dist/render/shaders/rush.js +54 -11
  148. package/dist/render/shaders/scatter.d.ts +1 -1
  149. package/dist/render/shaders/smoke.d.ts +1 -1
  150. package/dist/render/shaders/ssrResolve.d.ts +17 -0
  151. package/dist/render/shaders/ssrResolve.js +51 -0
  152. package/dist/render/shaders/ssrTrace.js +46 -1
  153. package/dist/render/shaders/water.d.ts +1 -1
  154. package/dist/render/shaders/water.js +12 -3
  155. package/dist/render/skinScatter.d.ts +3 -1
  156. package/dist/render/skinScatter.js +3 -1
  157. package/dist/render/ssrPass.d.ts +16 -0
  158. package/dist/render/ssrPass.js +107 -1
  159. package/dist/render/surfaceModel.d.ts +29 -8
  160. package/dist/render/surfaceModel.js +33 -9
  161. package/dist/render/surfaceOverlay.d.ts +132 -0
  162. package/dist/render/surfaceOverlay.js +85 -0
  163. package/dist/render/surfaceTexture.d.ts +44 -1
  164. package/dist/render/surfaceTexture.js +96 -5
  165. package/dist/render/temporalAa.d.ts +14 -0
  166. package/dist/render/temporalAa.js +20 -0
  167. package/dist/render/vertexDefaults.d.ts +17 -0
  168. package/dist/render/vertexDefaults.js +24 -0
  169. package/package.json +6 -6
  170. package/src/index.ts +22 -2
  171. package/src/render/anisotropicLobe.ts +29 -0
  172. package/src/render/areaLights.ts +58 -0
  173. package/src/render/atmosphere.ts +22 -1
  174. package/src/render/backend/api.ts +58 -2
  175. package/src/render/backend/createRenderer.ts +18 -1
  176. package/src/render/backend/webgl2/renderer.ts +548 -50
  177. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  178. package/src/render/backend/webgpu/device.ts +42 -3
  179. package/src/render/backend/webgpu/flatPass.ts +63 -26
  180. package/src/render/backend/webgpu/particlePass.ts +2 -1
  181. package/src/render/backend/webgpu/postPass.ts +18 -0
  182. package/src/render/backend/webgpu/reflectionSurfacePass.ts +114 -0
  183. package/src/render/backend/webgpu/renderer.ts +927 -100
  184. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  185. package/src/render/backend/webgpu/surfaceTexturePass.ts +44 -14
  186. package/src/render/clusteredLights.ts +41 -8
  187. package/src/render/displayRange.ts +61 -0
  188. package/src/render/driftLight/bake.ts +3 -1
  189. package/src/render/driftLight/falloff.ts +13 -2
  190. package/src/render/filmicCurve.ts +229 -0
  191. package/src/render/fog.ts +19 -3
  192. package/src/render/frame/resources.ts +6 -0
  193. package/src/render/frameReflections.ts +72 -0
  194. package/src/render/gi/bounceLights.ts +12 -3
  195. package/src/render/instanceCull.ts +4 -0
  196. package/src/render/instances.ts +26 -6
  197. package/src/render/lightBudget.ts +40 -2
  198. package/src/render/lightmap.ts +64 -23
  199. package/src/render/particlePool.ts +23 -0
  200. package/src/render/pass.ts +53 -1
  201. package/src/render/pointLightSelection.ts +20 -1
  202. package/src/render/reflectionSurfaceTargets.ts +105 -0
  203. package/src/render/renderQuality.ts +52 -15
  204. package/src/render/rendererHarness.ts +3 -0
  205. package/src/render/rgb9e5.ts +52 -0
  206. package/src/render/sceneCapture.ts +56 -0
  207. package/src/render/sceneTarget.ts +30 -2
  208. package/src/render/screenSpaceReflection.ts +38 -1
  209. package/src/render/shaders/bolt.ts +7 -1
  210. package/src/render/shaders/depth.ts +11 -0
  211. package/src/render/shaders/filmLook.ts +2 -1
  212. package/src/render/shaders/flat/anisotropic.ts +37 -3
  213. package/src/render/shaders/flat/areaLight.ts +46 -0
  214. package/src/render/shaders/flat/glassTint.ts +13 -2
  215. package/src/render/shaders/flat/index.ts +37 -2
  216. package/src/render/shaders/flat/lobes.ts +50 -43
  217. package/src/render/shaders/flat/main.ts +85 -22
  218. package/src/render/shaders/flat/models.ts +100 -16
  219. package/src/render/shaders/flat/overlay.ts +117 -0
  220. package/src/render/shaders/flat/pointShadow.ts +1 -1
  221. package/src/render/shaders/flat/preamble.ts +10 -3
  222. package/src/render/shaders/flat/reflectionSurface.ts +61 -0
  223. package/src/render/shaders/fog.ts +9 -2
  224. package/src/render/shaders/fringe.ts +59 -0
  225. package/src/render/shaders/generated/arcane.wgsl.ts +1 -1
  226. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  227. package/src/render/shaders/generated/caustics.wgsl.ts +1 -1
  228. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  229. package/src/render/shaders/generated/film.wgsl.ts +1 -1
  230. package/src/render/shaders/generated/fire.wgsl.ts +1 -1
  231. package/src/render/shaders/generated/flat.wgsl.ts +230 -54
  232. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  233. package/src/render/shaders/generated/particle.wgsl.ts +4 -4
  234. package/src/render/shaders/generated/rush.wgsl.ts +56 -26
  235. package/src/render/shaders/generated/scatter.wgsl.ts +1 -1
  236. package/src/render/shaders/generated/smoke.wgsl.ts +1 -1
  237. package/src/render/shaders/generated/ssrResolve.wgsl.ts +30 -0
  238. package/src/render/shaders/generated/ssrTrace.wgsl.ts +32 -7
  239. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  240. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  241. package/src/render/shaders/gpudriven/fog.wgsl.ts +11 -3
  242. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  243. package/src/render/shaders/rush.ts +54 -11
  244. package/src/render/shaders/ssrResolve.ts +52 -0
  245. package/src/render/shaders/ssrTrace.ts +46 -1
  246. package/src/render/shaders/water.ts +12 -3
  247. package/src/render/skinScatter.ts +3 -1
  248. package/src/render/ssrPass.ts +139 -1
  249. package/src/render/surfaceModel.ts +47 -10
  250. package/src/render/surfaceOverlay.ts +195 -0
  251. package/src/render/surfaceTexture.ts +136 -9
  252. package/src/render/temporalAa.ts +20 -0
  253. package/src/render/vertexDefaults.ts +28 -0
@@ -0,0 +1,22 @@
1
+ /**
2
+ * rgb9e5: three 9-bit mantissas over one shared 5-bit exponent, an unsigned float colour in four
3
+ * bytes. `EXT_texture_shared_exponent`'s encoding, the one a GPU's `rgb9e5ufloat` reads, written
4
+ * out so a lightmap page can hold its irradiance in four bytes a texel against a half float's eight.
5
+ *
6
+ * **What it gives up**: precision in the smaller channels, which share the largest one's exponent —
7
+ * a channel a thousandth of its neighbour keeps about one bit — and anything negative or past
8
+ * 65,408, which it clamps. Light is neither, and a bake's colours rarely span more than a few
9
+ * octaves within one texel, so a lightmap loses nothing visible; a value that needs each channel
10
+ * exact wants a format of its own.
11
+ *
12
+ * The bits, low to high: red in 0–8, green in 9–17, blue in 18–26, the exponent in 27–31, biased by
13
+ * 15, and a value is `mantissa * 2^(exponent - 15 - 9)`.
14
+ */
15
+ /** The largest value the format holds: 511/512 of 2^16. */
16
+ export declare const RGB9E5_MAX: number;
17
+ /** One colour packed into its 32 bits, rounding to nearest; NaN and negatives are zero. */
18
+ export declare function packRgb9e5(red: number, green: number, blue: number): number;
19
+ /** A packed colour's three channels, into `out[at…at+2]`. */
20
+ export declare function unpackRgb9e5(bits: number, out: {
21
+ [index: number]: number;
22
+ }, at?: number): void;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * rgb9e5: three 9-bit mantissas over one shared 5-bit exponent, an unsigned float colour in four
3
+ * bytes. `EXT_texture_shared_exponent`'s encoding, the one a GPU's `rgb9e5ufloat` reads, written
4
+ * out so a lightmap page can hold its irradiance in four bytes a texel against a half float's eight.
5
+ *
6
+ * **What it gives up**: precision in the smaller channels, which share the largest one's exponent —
7
+ * a channel a thousandth of its neighbour keeps about one bit — and anything negative or past
8
+ * 65,408, which it clamps. Light is neither, and a bake's colours rarely span more than a few
9
+ * octaves within one texel, so a lightmap loses nothing visible; a value that needs each channel
10
+ * exact wants a format of its own.
11
+ *
12
+ * The bits, low to high: red in 0–8, green in 9–17, blue in 18–26, the exponent in 27–31, biased by
13
+ * 15, and a value is `mantissa * 2^(exponent - 15 - 9)`.
14
+ */
15
+ const BIAS = 15;
16
+ const MANTISSA_BITS = 9;
17
+ const MANTISSA_VALUES = 1 << MANTISSA_BITS;
18
+ const MAX_EXPONENT = 31;
19
+ /** The largest value the format holds: 511/512 of 2^16. */
20
+ export const RGB9E5_MAX = ((MANTISSA_VALUES - 1) / MANTISSA_VALUES) * 2 ** (MAX_EXPONENT - BIAS);
21
+ const clampChannel = (v) => (v > 0 ? Math.min(v, RGB9E5_MAX) : 0);
22
+ /** One colour packed into its 32 bits, rounding to nearest; NaN and negatives are zero. */
23
+ export function packRgb9e5(red, green, blue) {
24
+ const r = clampChannel(red);
25
+ const g = clampChannel(green);
26
+ const b = clampChannel(blue);
27
+ const largest = Math.max(r, g, b);
28
+ let exponent = Math.max(-BIAS - 1, Math.floor(Math.log2(largest))) + 1 + BIAS;
29
+ let scale = 2 ** (exponent - BIAS - MANTISSA_BITS);
30
+ /* Rounding the largest channel up to 512 needs one more exponent, which the spec allows for. */
31
+ if (Math.floor(largest / scale + 0.5) === MANTISSA_VALUES) {
32
+ exponent += 1;
33
+ scale *= 2;
34
+ }
35
+ /* Inline rather than a helper: a page packs millions of texels, and a closure a call is one each. */
36
+ const mr = Math.min(MANTISSA_VALUES - 1, Math.floor(r / scale + 0.5));
37
+ const mg = Math.min(MANTISSA_VALUES - 1, Math.floor(g / scale + 0.5));
38
+ const mb = Math.min(MANTISSA_VALUES - 1, Math.floor(b / scale + 0.5));
39
+ return (mr | (mg << 9) | (mb << 18) | (exponent << 27)) >>> 0;
40
+ }
41
+ /** A packed colour's three channels, into `out[at…at+2]`. */
42
+ export function unpackRgb9e5(bits, out, at = 0) {
43
+ const scale = 2 ** ((bits >>> 27) - BIAS - MANTISSA_BITS);
44
+ out[at] = (bits & 511) * scale;
45
+ out[at + 1] = ((bits >>> 9) & 511) * scale;
46
+ out[at + 2] = ((bits >>> 18) & 511) * scale;
47
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A scene capture: a texture the world is drawn into from a camera of the caller's, which a material
3
+ * then shows like any image — a screen in the world showing what a camera elsewhere sees, a monitor,
4
+ * a security feed. The planar reflection's pass, for a caller's camera rather than a mirrored one.
5
+ *
6
+ * **It holds radiance, not display pixels**, as a probe does: drawn with the output transform held
7
+ * off, at the colour format a probe takes — half floats under `hdrScene`, bytes otherwise — and
8
+ * sampled linear, so a material showing it as its `emissive` emits the light the capture saw and the
9
+ * frame's grade then applies once, to the screen as to everything around it.
10
+ *
11
+ * **What it gives up**: it is the mesh pass alone, as a probe bake is — the sky, water, particles and
12
+ * anything else a scene draws after its world are in the capture only if the callback draws them;
13
+ * no temporal resolve, bloom or other effect after the world runs on it; and it has no mip chain, so
14
+ * a screen seen small and far shimmers where a mipmapped image would not.
15
+ */
16
+ /** What a capture's texture is made from, in place of an image: its size and colour format. */
17
+ export interface SceneCaptureTexels {
18
+ readonly kind: 'scene-capture';
19
+ readonly width: number;
20
+ readonly height: number;
21
+ /** Half floats, which hold light past one; bytes otherwise. */
22
+ readonly float: boolean;
23
+ }
24
+ /** The largest side a capture may have, the size every WebGL2 device must take. */
25
+ export declare const MAX_SCENE_CAPTURE_SIZE = 4096;
26
+ /** A capture's description, refused by name where a side is not a whole number of pixels it can be. */
27
+ export declare function sceneCaptureTexels(width: number, height: number, float: boolean): SceneCaptureTexels;
28
+ /** Whether a texture source is a capture's description rather than an image. */
29
+ export declare function isSceneCaptureTexels(source: unknown): source is SceneCaptureTexels;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A scene capture: a texture the world is drawn into from a camera of the caller's, which a material
3
+ * then shows like any image — a screen in the world showing what a camera elsewhere sees, a monitor,
4
+ * a security feed. The planar reflection's pass, for a caller's camera rather than a mirrored one.
5
+ *
6
+ * **It holds radiance, not display pixels**, as a probe does: drawn with the output transform held
7
+ * off, at the colour format a probe takes — half floats under `hdrScene`, bytes otherwise — and
8
+ * sampled linear, so a material showing it as its `emissive` emits the light the capture saw and the
9
+ * frame's grade then applies once, to the screen as to everything around it.
10
+ *
11
+ * **What it gives up**: it is the mesh pass alone, as a probe bake is — the sky, water, particles and
12
+ * anything else a scene draws after its world are in the capture only if the callback draws them;
13
+ * no temporal resolve, bloom or other effect after the world runs on it; and it has no mip chain, so
14
+ * a screen seen small and far shimmers where a mipmapped image would not.
15
+ */
16
+ /** The largest side a capture may have, the size every WebGL2 device must take. */
17
+ export const MAX_SCENE_CAPTURE_SIZE = 4096;
18
+ /** A capture's description, refused by name where a side is not a whole number of pixels it can be. */
19
+ export function sceneCaptureTexels(width, height, float) {
20
+ for (const [name, side] of [
21
+ ['width', width],
22
+ ['height', height],
23
+ ]) {
24
+ if (!Number.isInteger(side) || side < 1 || side > MAX_SCENE_CAPTURE_SIZE) {
25
+ throw new Error(`createSceneCapture: ${name} must be a whole number from 1 to ${MAX_SCENE_CAPTURE_SIZE}, ` +
26
+ `not ${String(side)}`);
27
+ }
28
+ }
29
+ return { kind: 'scene-capture', width, height, float };
30
+ }
31
+ /** Whether a texture source is a capture's description rather than an image. */
32
+ export function isSceneCaptureTexels(source) {
33
+ return (typeof source === 'object' &&
34
+ source !== null &&
35
+ source.kind === 'scene-capture');
36
+ }
@@ -89,6 +89,8 @@ export declare class SceneTarget {
89
89
  private gradeStrength;
90
90
  /** The lens and the print, held like the grade. See `filmLook.ts`. */
91
91
  private vignette;
92
+ /** The lens's colour fringe: red's and green's pull and where it begins. See `fringe.ts`. */
93
+ private readonly fringe;
92
94
  private grain;
93
95
  private grainSeed;
94
96
  /** Eye adaptation: how far, and this frame's time step. See `Renderer.setAutoExposure`. */
@@ -277,6 +279,8 @@ export declare class SceneTarget {
277
279
  */
278
280
  /** The lens's corner falloff, held until changed. See `Renderer.setVignette`. */
279
281
  setVignette(strength: number): void;
282
+ /** The lens's colour fringe, held until changed. See `Renderer.setChromaticAberration`. */
283
+ setChromaticAberration(intensity: number, start: number): void;
280
284
  /** The print's grain and this frame's seed, held until changed. See `Renderer.setFilmGrain`. */
281
285
  setFilmGrain(strength: number, seed: number): void;
282
286
  /** How far the eye adapts, and the time since the last frame. See `Renderer.setAutoExposure`. */
@@ -298,6 +302,7 @@ export declare class SceneTarget {
298
302
  resolve(strength: number, reachUv: number, grade?: {
299
303
  readonly transform: number;
300
304
  readonly exposure: number;
305
+ readonly film?: Float32Array;
301
306
  }, motion?: {
302
307
  readonly reprojection: Float32Array;
303
308
  readonly strength: number;
@@ -1,5 +1,6 @@
1
1
  import { glSceneDepthFormat } from './depthConvention.js';
2
2
  import { clampGrain, clampVignette, grainSeed } from './shaders/filmLook.js';
3
+ import { FRINGE_FLOATS, resolveFringe } from './shaders/fringe.js';
3
4
  import { AmbientOcclusionPass } from './ambientOcclusionPass.js';
4
5
  import { ExposurePass } from './exposurePass.js';
5
6
  import { clampAutoExposure } from './shaders/exposure.js';
@@ -93,6 +94,8 @@ export class SceneTarget {
93
94
  gradeStrength = 0;
94
95
  /** The lens and the print, held like the grade. See `filmLook.ts`. */
95
96
  vignette = 0;
97
+ /** The lens's colour fringe: red's and green's pull and where it begins. See `fringe.ts`. */
98
+ fringe = new Float32Array(FRINGE_FLOATS);
96
99
  grain = 0;
97
100
  grainSeed = 0;
98
101
  /** Eye adaptation: how far, and this frame's time step. See `Renderer.setAutoExposure`. */
@@ -605,6 +608,10 @@ export class SceneTarget {
605
608
  setVignette(strength) {
606
609
  this.vignette = clampVignette(strength);
607
610
  }
611
+ /** The lens's colour fringe, held until changed. See `Renderer.setChromaticAberration`. */
612
+ setChromaticAberration(intensity, start) {
613
+ resolveFringe(intensity, start, this.fringe);
614
+ }
608
615
  /** The print's grain and this frame's seed, held until changed. See `Renderer.setFilmGrain`. */
609
616
  setFilmGrain(strength, seed) {
610
617
  this.grain = clampGrain(strength);
@@ -654,7 +661,10 @@ export class SceneTarget {
654
661
  this.gradeSize = lut.size;
655
662
  }
656
663
  resolve(strength, reachUv,
657
- /* The grade, applied here because this is the last pass. See RUSH_FRAG's own note. */
664
+ /*
665
+ * The grade, applied here because this is the last pass. See RUSH_FRAG's own note. `film` is
666
+ * the `filmic` curve's eight constants (`resolveFilmicCurve`), read only under transform 4.
667
+ */
658
668
  grade = {
659
669
  transform: 0,
660
670
  exposure: 1,
@@ -809,6 +819,12 @@ export class SceneTarget {
809
819
  gl.uniform1f(this.uniforms['uReach'] ?? null, reachUv);
810
820
  gl.uniform1i(this.uniforms['uOutputTransform'] ?? null, grade.transform);
811
821
  gl.uniform1f(this.uniforms['uOutputExposure'] ?? null, grade.exposure);
822
+ if (grade.film !== undefined) {
823
+ gl.uniform4fv(this.uniforms['uFilmA'] ?? null, grade.film, 0, 4);
824
+ gl.uniform4fv(this.uniforms['uFilmB'] ?? null, grade.film, 4, 4);
825
+ }
826
+ /* A standard range always here: see `Renderer.displayRange`. */
827
+ gl.uniform1f(this.uniforms['uDisplayHeadroom'] ?? null, 1);
812
828
  /*
813
829
  * Depth on its own unit, and the reprojection with it. Bound every frame rather than
814
830
  * once, because a texture unit is context state and any pass between two frames is free
@@ -894,6 +910,12 @@ export class SceneTarget {
894
910
  gl.bindTexture(gl.TEXTURE_2D, localGrid ?? scene);
895
911
  gl.uniform1i(this.uniforms['uExposureLocal'] ?? null, 6);
896
912
  gl.uniform1f(this.uniforms['uLocalExposure'] ?? null, localGrid === null ? 0 : this.localExposure);
913
+ /* Each object's own motion is WebGPU's under a reconstruction; here the scene stands in on
914
+ unit 7 and the flag says not to read it, so the camera's motion alone blurs. */
915
+ gl.activeTexture(gl.TEXTURE7);
916
+ gl.bindTexture(gl.TEXTURE_2D, scene);
917
+ gl.uniform1i(this.uniforms['uMotion'] ?? null, 7);
918
+ gl.uniform1f(this.uniforms['uObjectMotion'] ?? null, 0);
897
919
  gl.activeTexture(gl.TEXTURE0);
898
920
  /* No unit and no texture: a flat colour, not a sample. Uploaded unconditionally — two
899
921
  floats and a triplet cost nothing next to the triangle this pass already draws — and it
@@ -902,6 +924,7 @@ export class SceneTarget {
902
924
  gl.uniform3fv(this.uniforms['uVeilColor'] ?? null, veilColor);
903
925
  gl.uniform1f(this.uniforms['uVeilAlpha'] ?? null, veilAlpha);
904
926
  gl.uniform1f(this.uniforms['uVignette'] ?? null, this.vignette);
927
+ gl.uniform3fv(this.uniforms['uFringe'] ?? null, this.fringe);
905
928
  gl.uniform2f(this.uniforms['uGrain'] ?? null, this.grain, this.grainSeed);
906
929
  gl.bindVertexArray(this.vao);
907
930
  gl.drawArrays(gl.TRIANGLES, 0, 3);
@@ -24,6 +24,11 @@ import type { DecalBox } from './decalProjector.ts';
24
24
  * which is the same price the temporal row declined for a velocity buffer. A caller declares a
25
25
  * `ReflectiveSurface` instead: a box, and a facing rule for which surfaces inside it reflect. It is
26
26
  * the shape `DecalProjector` already has, for the same reason.
27
+ *
28
+ * **Or every surface by its own material**, where the renderer was built with
29
+ * `screenSpaceReflections`: the lit stage leaves two maps of what it reflected — not a G-buffer, the
30
+ * environment's share and its tint, which is all a swap needs — and the same trace runs in a
31
+ * material mode over every pixel. See `frameReflections.ts`.
27
32
  */
28
33
  /**
29
34
  * Clip Y negated, for a backend whose framebuffer rows run downward.
@@ -159,9 +164,30 @@ export declare function edgeFade(margin: number, band: number): number;
159
164
  * nothing at one running directly into it.
160
165
  */
161
166
  export declare function towardCameraFade(direction: ReadonlyVec3, viewDirection: ReadonlyVec3): number;
167
+ /**
168
+ * What a `ReflectiveSurface` is made with: its box, which way the surfaces inside it face, and how
169
+ * much and how far they reflect. Every number but the box has a default; see each field.
170
+ */
162
171
  export interface ReflectiveSurfaceOptions extends DecalBox {
163
- /** How much of what the ray finds lands on the surface, 0 to 1. */
172
+ /**
173
+ * How much of what the ray finds lands on the surface, 0 to 1: one share over the box, or with
174
+ * `fresnel` the surface's reflectance head-on.
175
+ */
164
176
  readonly strength?: number;
177
+ /**
178
+ * Whether `strength` is the surface's reflectance head-on, F0, rather than one share over the
179
+ * box. Each pixel then returns `F0 · A + B` of what its ray finds, the split-sum environment BRDF
180
+ * at its own view and `roughness` — Karis's fit, the one the lit stage reflects its environment
181
+ * by — so a floor returns more toward the horizon than underfoot, and the share follows the
182
+ * camera as Unreal weighs its reflections. **False by default**, the one share as before.
183
+ *
184
+ * **What it gives up**: the pass has no material buffer, so the roughness and F0 are the box's,
185
+ * not each pixel's, and nothing occludes the reflection where the lit stage's ambient occlusion
186
+ * would. A floor of one material is what it is for.
187
+ */
188
+ readonly fresnel?: boolean;
189
+ /** The surface's roughness, 0 to 1, that `fresnel` weighs by. 0, a polished surface, if absent. */
190
+ readonly roughness?: number;
165
191
  /** How far a ray may travel, world metres. */
166
192
  readonly reachM?: number;
167
193
  /** How deep a depth sample is treated as being. See `ScreenSpaceMarch.thicknessM`. */
@@ -189,6 +215,9 @@ export declare class ReflectiveSurface {
189
215
  readonly axis: Float32Array<ArrayBuffer>;
190
216
  readonly tint: Float32Array<ArrayBuffer>;
191
217
  strength: number;
218
+ /** See `ReflectiveSurfaceOptions.fresnel`. */
219
+ fresnel: boolean;
220
+ roughness: number;
192
221
  reachM: number;
193
222
  thicknessM: number;
194
223
  steps: number;
@@ -226,6 +255,8 @@ export interface RecordedReflection {
226
255
  readonly axis: Float32Array;
227
256
  readonly tint: Float32Array;
228
257
  strength: number;
258
+ fresnel: boolean;
259
+ roughness: number;
229
260
  reachM: number;
230
261
  thicknessM: number;
231
262
  steps: number;
@@ -23,6 +23,11 @@ import { decalToWorldMatrix, worldToDecalMatrix } from './decalProjector.js';
23
23
  * which is the same price the temporal row declined for a velocity buffer. A caller declares a
24
24
  * `ReflectiveSurface` instead: a box, and a facing rule for which surfaces inside it reflect. It is
25
25
  * the shape `DecalProjector` already has, for the same reason.
26
+ *
27
+ * **Or every surface by its own material**, where the renderer was built with
28
+ * `screenSpaceReflections`: the lit stage leaves two maps of what it reflected — not a G-buffer, the
29
+ * environment's share and its tint, which is all a swap needs — and the same trace runs in a
30
+ * material mode over every pixel. See `frameReflections.ts`.
26
31
  */
27
32
  /**
28
33
  * Clip Y negated, for a backend whose framebuffer rows run downward.
@@ -292,6 +297,9 @@ export class ReflectiveSurface {
292
297
  axis = new Float32Array(3);
293
298
  tint = new Float32Array(3);
294
299
  strength;
300
+ /** See `ReflectiveSurfaceOptions.fresnel`. */
301
+ fresnel;
302
+ roughness;
295
303
  reachM;
296
304
  thicknessM;
297
305
  steps;
@@ -308,6 +316,8 @@ export class ReflectiveSurface {
308
316
  /* Half, because a mirror is the rare case: a wet floor, polished stone and a car bonnet all
309
317
  return a fraction of what lands on them, and a consumer who wants a mirror asks for one. */
310
318
  this.strength = options.strength ?? 0.5;
319
+ this.fresnel = options.fresnel ?? false;
320
+ this.roughness = options.roughness ?? 0;
311
321
  /* Eight metres covers a room and a street corner. Further costs steps rather than accuracy,
312
322
  since the step is the reach divided by them. */
313
323
  this.reachM = options.reachM ?? 8;
@@ -366,6 +376,8 @@ function newRecord() {
366
376
  axis: new Float32Array(3),
367
377
  tint: new Float32Array(3),
368
378
  strength: 0.5,
379
+ fresnel: false,
380
+ roughness: 0,
369
381
  reachM: 8,
370
382
  thicknessM: 0.25,
371
383
  steps: 24,
@@ -414,6 +426,8 @@ export class ReflectionQueue {
414
426
  record.axis.set(surface.axis);
415
427
  record.tint.set(surface.tint);
416
428
  record.strength = Math.min(1, Math.max(0, surface.strength));
429
+ record.fresnel = surface.fresnel;
430
+ record.roughness = Math.min(1, Math.max(0, surface.roughness));
417
431
  record.reachM = Math.max(0, surface.reachM);
418
432
  record.thicknessM = Math.max(0, surface.thicknessM);
419
433
  record.steps = Math.min(32, Math.max(1, Math.round(surface.steps)));
@@ -16,4 +16,4 @@
16
16
  * gets motion without direction. That is what separates a spell from a lamp on a
17
17
  * dimmer.
18
18
  */
19
- export declare const ARCANE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin float vSeed;\nin float vDistance;\nin vec3 vWorldPos;\n\nuniform float uTime;\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 int uNoiseOctaves;\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\n/** The caller's colour, so an aura shifts with the world's own palette. */\nuniform vec3 uTint;\n\nout vec4 outColor;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),\n mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n // Polar about the quad centre. This is the line that makes it an orb rather\n // than a column; everything downstream reads radius and angle, never height.\n float radius = length(vUv - 0.5) * 2.0;\n if (radius >= 1.0) discard;\n\n vec2 d = vUv - 0.5;\n float angle = atan(d.y, d.x);\n float t = uTime + vSeed * 37.0;\n\n /*\n * Two bands, turning opposite ways at different rates. Sampled in (angle, radius)\n * space so the noise wraps around the disc instead of sliding across it \u2014 noise in\n * UV space would drift off one edge and read as a texture being scrolled.\n */\n float inner = valueNoise(vec2(angle * 1.9 + t * 0.55, radius * 4.2));\n float outer = valueNoise(vec2(angle * 3.1 - t * 0.34, radius * 2.6));\n float swirl = inner * 0.62 + outer * 0.38;\n if (uNoiseOctaves > 1) {\n swirl = mix(swirl, swirl * valueNoise(vec2(angle * 6.3 + t * 0.9, radius * 8.0)) * 2.0, 0.35);\n }\n\n /*\n * Core and corona as two separate falloffs rather than one curve. A single falloff\n * gives a blob; what makes this read as *contained energy* is a small bright centre\n * with a much wider, much fainter halo, so the object inside stays legible.\n */\n float core = 1.0 - smoothstep(0.0, 0.28, radius);\n float corona = (1.0 - smoothstep(0.18, 1.0, radius)) * (0.35 + swirl * 0.65);\n float body = core * 0.9 + corona;\n if (body <= 0.02) discard;\n\n // Hot centre through the tint to a dark rim, so the core reads as white-hot\n // energy and the edge as the colour it is made of.\n vec3 col = mix(uTint * 0.35, uTint, smoothstep(0.0, 0.55, body));\n col = mix(col, vec3(1.0), smoothstep(0.82, 1.4, body));\n col *= uTint;\n\n float alpha = clamp(body * 1.6, 0.0, 1.0);\n // Additive light must lose alpha as it scatters, or mixing its colour alone\n // still makes a distant aura glow through fog and through water.\n float fog = mediumFog(vDistance, vWorldPos.y);\n col = mix(col, mediumColor(), fog);\n alpha *= 1.0 - fog;\n\n outColor = vec4(col, alpha);\n}\n";
19
+ export declare const ARCANE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin float vSeed;\nin float vDistance;\nin vec3 vWorldPos;\n\nuniform float uTime;\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 int uNoiseOctaves;\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\n/** The caller's colour, so an aura shifts with the world's own palette. */\nuniform vec3 uTint;\n\nout vec4 outColor;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),\n mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n // Polar about the quad centre. This is the line that makes it an orb rather\n // than a column; everything downstream reads radius and angle, never height.\n float radius = length(vUv - 0.5) * 2.0;\n if (radius >= 1.0) discard;\n\n vec2 d = vUv - 0.5;\n float angle = atan(d.y, d.x);\n float t = uTime + vSeed * 37.0;\n\n /*\n * Two bands, turning opposite ways at different rates. Sampled in (angle, radius)\n * space so the noise wraps around the disc instead of sliding across it \u2014 noise in\n * UV space would drift off one edge and read as a texture being scrolled.\n */\n float inner = valueNoise(vec2(angle * 1.9 + t * 0.55, radius * 4.2));\n float outer = valueNoise(vec2(angle * 3.1 - t * 0.34, radius * 2.6));\n float swirl = inner * 0.62 + outer * 0.38;\n if (uNoiseOctaves > 1) {\n swirl = mix(swirl, swirl * valueNoise(vec2(angle * 6.3 + t * 0.9, radius * 8.0)) * 2.0, 0.35);\n }\n\n /*\n * Core and corona as two separate falloffs rather than one curve. A single falloff\n * gives a blob; what makes this read as *contained energy* is a small bright centre\n * with a much wider, much fainter halo, so the object inside stays legible.\n */\n float core = 1.0 - smoothstep(0.0, 0.28, radius);\n float corona = (1.0 - smoothstep(0.18, 1.0, radius)) * (0.35 + swirl * 0.65);\n float body = core * 0.9 + corona;\n if (body <= 0.02) discard;\n\n // Hot centre through the tint to a dark rim, so the core reads as white-hot\n // energy and the edge as the colour it is made of.\n vec3 col = mix(uTint * 0.35, uTint, smoothstep(0.0, 0.55, body));\n col = mix(col, vec3(1.0), smoothstep(0.82, 1.4, body));\n col *= uTint;\n\n float alpha = clamp(body * 1.6, 0.0, 1.0);\n // Additive light must lose alpha as it scatters, or mixing its colour alone\n // still makes a distant aura glow through fog and through water.\n float fog = mediumFog(vDistance, vWorldPos.y);\n col = mix(col, mediumColor(), fog);\n alpha *= 1.0 - fog;\n\n outColor = vec4(col, alpha);\n}\n";
@@ -14,4 +14,4 @@
14
14
  * gives an arc its three-dimensionality is the path, and the path is world-space.
15
15
  */
16
16
  export declare const BOLT_VERT = "#version 300 es\nlayout(location = 0) in vec3 aFrom;\nlayout(location = 1) in vec3 aTo;\n/** Along the segment (0 or 1), and which side of it (-1 or +1). */\nlayout(location = 2) in vec2 aCorner;\n/** Where this segment sits along its arc, the arc's envelope, its seed, its gain. */\nlayout(location = 3) in vec4 aArc;\n\nuniform mat4 uViewProj;\nuniform vec3 uCameraPos;\n/** Core half-width in metres, and how much of a pixel a far arc must still cover. */\nuniform float uWidth;\nuniform float uMinWidthPerMetre;\n\nout float vSide;\nout float vAlong;\nout float vFade;\nout float vSeed;\nout float vGain;\nout vec3 vWorldPos;\n\nvoid main() {\n vec3 mid = mix(aFrom, aTo, aCorner.x);\n vec3 dir = aTo - aFrom;\n float len = length(dir);\n dir = len > 1e-6 ? dir / len : vec3(0.0, 1.0, 0.0);\n\n vec3 view = mid - uCameraPos;\n float dist = length(view);\n view = dist > 1e-6 ? view / dist : vec3(0.0, 0.0, 1.0);\n\n /*\n * Across the segment and across the line of sight. Degenerate exactly when the\n * segment points at the viewer, where any perpendicular is as good as another \u2014\n * and where the segment covers almost no screen anyway.\n */\n vec3 across = cross(dir, view);\n float span = length(across);\n across = span > 1e-4 ? across / span : normalize(cross(dir, vec3(0.0, 1.0, 0.0)) + vec3(1e-3));\n\n /*\n * A floor on the world-space width that grows with distance, so a filament\n * never thins below a pixel. Under a pixel a bright thin line does not get\n * fainter, it *flickers* \u2014 the rasteriser catches it on some frames and not\n * others \u2014 and a strobing artefact reads as a bug, not as lightning.\n */\n float halfWidth = max(uWidth, dist * uMinWidthPerMetre);\n vec3 world = mid + across * aCorner.y * halfWidth;\n\n vSide = aCorner.y;\n vAlong = aArc.x;\n vFade = aArc.y;\n vSeed = aArc.z;\n vGain = aArc.w;\n vWorldPos = world;\n gl_Position = uViewProj * vec4(world, 1.0);\n}\n";
17
- export declare const BOLT_FRAG = "#version 300 es\nprecision highp float;\n\nin float vSide;\nin float vAlong;\nin float vFade;\nin float vSeed;\nin float vGain;\nin vec3 vWorldPos;\n\nuniform float uTime;\n/** The saturating centre, and the glow it sits inside. */\nuniform vec3 uCoreColor;\nuniform vec3 uEdgeColor;\n/** How far past white the core is allowed to go. */\nuniform float uCoreGain;\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;\n\nout vec4 outColor;\n\nfloat hash11(float p) {\n return fract(sin(p * 12.9898) * 43758.5453123);\n}\n\nvoid main() {\n /*\n * Across the filament: 1 at the centre line, 0 at the quad's edge. Two powers\n * of it are the entire look \u2014 a very tight one for the core and a loose one for\n * the glow. A single falloff gives either a hard line with no atmosphere or a\n * soft smudge with no filament.\n */\n float across = 1.0 - abs(vSide);\n float core = pow(across, 10.0);\n /*\n * A *wide* halo, deliberately. This exponent is the single biggest control over\n * whether the effect reads as lightning or as a drawn line: a tight falloff gives a\n * crisp filament with nothing around it, which is a wire, while a broad one bleeds\n * light into the air the way an over-exposed discharge does on film. It is the\n * closest thing to bloom available without a second pass.\n */\n float glow = pow(across, 1.1);\n\n /*\n * Stutter. An arc is not a steady light: it is re-established many times a\n * second, and the eye reads a *constant* bright line as neon. Two frequencies\n * so the pattern does not read as a sine, and per-arc phase from the seed so\n * several arcs never blink together.\n */\n /*\n * Stutter, but shallow. An arc is re-established many times a second and a perfectly\n * steady bright line reads as neon \u2014 but too *much* flicker reads as a broken effect\n * rather than as a discharge, and at the brief lifetime these now have, deep flicker\n * mostly hides the bolt. The path itself is already being redrawn (see 'BoltPool'),\n * which carries most of the crackle; this only keeps the brightness alive.\n */\n float t = uTime * 47.0 + vSeed * 17.0;\n float stutter = 0.78 + 0.22 * (hash11(floor(t)) * 0.6 + hash11(floor(t * 2.37)) * 0.4);\n\n /*\n * The tip tapers. An arc that ends in a squared-off quad reads as a drawn\n * segment; thinning toward the far end is what makes it read as a discharge\n * dissipating into the air.\n */\n float taper = 1.0 - vAlong * 0.55;\n\n float energy = vFade * stutter * taper * vGain;\n vec3 lit = uEdgeColor * glow + uCoreColor * core * uCoreGain;\n\n /*\n * Distance dims it rather than tinting it toward the fog. A discharge emits;\n * mixing it into the medium's colour is what a *surface* does, and doing that\n * here turned a far bolt grey instead of faint.\n */\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n /*\n * Alpha is 1, and the energy is already in the colour.\n *\n * The additive blend is 'SRC_ALPHA, ONE', so what reaches the framebuffer is\n * 'rgb * a'. Putting the energy in both \u2014 which the first version did \u2014 squares it,\n * and squaring a number below one is how an effect authored as blinding arrives as\n * faint: at a typical energy of 0.5 it delivered a quarter of what it asked for.\n * Every emissive material here has to pick one channel and mean it.\n */\n outColor = vec4(lit * energy * (1.0 - fog), 1.0);\n}\n";
17
+ export declare const BOLT_FRAG = "#version 300 es\nprecision highp float;\n\nin float vSide;\nin float vAlong;\nin float vFade;\nin float vSeed;\nin float vGain;\nin vec3 vWorldPos;\n\nuniform float uTime;\n/** The saturating centre, and the glow it sits inside. */\nuniform vec3 uCoreColor;\nuniform vec3 uEdgeColor;\n/** How far past white the core is allowed to go. */\nuniform float uCoreGain;\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;\n\nout vec4 outColor;\n\nfloat hash11(float p) {\n return fract(sin(p * 12.9898) * 43758.5453123);\n}\n\nvoid main() {\n /*\n * Across the filament: 1 at the centre line, 0 at the quad's edge. Two powers\n * of it are the entire look \u2014 a very tight one for the core and a loose one for\n * the glow. A single falloff gives either a hard line with no atmosphere or a\n * soft smudge with no filament.\n */\n /*\n * Clamped, because a multisampled target shades a pixel on the quad's edge at its centre, which\n * may lie outside the quad: there 'vSide' runs past one, 'across' goes negative, and 'pow' of a\n * negative number is NaN. One NaN pixel is a dark speck; bloom then spreads it into a white disc\n * the size of the blur. Found by a fight drawing short arcs at 4 samples.\n */\n float across = clamp(1.0 - abs(vSide), 0.0, 1.0);\n float core = pow(across, 10.0);\n /*\n * A *wide* halo, deliberately. This exponent is the single biggest control over\n * whether the effect reads as lightning or as a drawn line: a tight falloff gives a\n * crisp filament with nothing around it, which is a wire, while a broad one bleeds\n * light into the air the way an over-exposed discharge does on film. It is the\n * closest thing to bloom available without a second pass.\n */\n float glow = pow(across, 1.1);\n\n /*\n * Stutter. An arc is not a steady light: it is re-established many times a\n * second, and the eye reads a *constant* bright line as neon. Two frequencies\n * so the pattern does not read as a sine, and per-arc phase from the seed so\n * several arcs never blink together.\n */\n /*\n * Stutter, but shallow. An arc is re-established many times a second and a perfectly\n * steady bright line reads as neon \u2014 but too *much* flicker reads as a broken effect\n * rather than as a discharge, and at the brief lifetime these now have, deep flicker\n * mostly hides the bolt. The path itself is already being redrawn (see 'BoltPool'),\n * which carries most of the crackle; this only keeps the brightness alive.\n */\n float t = uTime * 47.0 + vSeed * 17.0;\n float stutter = 0.78 + 0.22 * (hash11(floor(t)) * 0.6 + hash11(floor(t * 2.37)) * 0.4);\n\n /*\n * The tip tapers. An arc that ends in a squared-off quad reads as a drawn\n * segment; thinning toward the far end is what makes it read as a discharge\n * dissipating into the air.\n */\n float taper = 1.0 - vAlong * 0.55;\n\n float energy = vFade * stutter * taper * vGain;\n vec3 lit = uEdgeColor * glow + uCoreColor * core * uCoreGain;\n\n /*\n * Distance dims it rather than tinting it toward the fog. A discharge emits;\n * mixing it into the medium's colour is what a *surface* does, and doing that\n * here turned a far bolt grey instead of faint.\n */\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n /*\n * Alpha is 1, and the energy is already in the colour.\n *\n * The additive blend is 'SRC_ALPHA, ONE', so what reaches the framebuffer is\n * 'rgb * a'. Putting the energy in both \u2014 which the first version did \u2014 squares it,\n * and squaring a number below one is how an effect authored as blinding arrives as\n * faint: at a typical energy of 0.5 it delivered a quarter of what it asked for.\n * Every emissive material here has to pick one channel and mean it.\n */\n outColor = vec4(lit * energy * (1.0 - fog), 1.0);\n}\n";
@@ -104,7 +104,13 @@ void main() {
104
104
  * the glow. A single falloff gives either a hard line with no atmosphere or a
105
105
  * soft smudge with no filament.
106
106
  */
107
- float across = 1.0 - abs(vSide);
107
+ /*
108
+ * Clamped, because a multisampled target shades a pixel on the quad's edge at its centre, which
109
+ * may lie outside the quad: there 'vSide' runs past one, 'across' goes negative, and 'pow' of a
110
+ * negative number is NaN. One NaN pixel is a dark speck; bloom then spreads it into a white disc
111
+ * the size of the blur. Found by a fight drawing short arcs at 4 samples.
112
+ */
113
+ float across = clamp(1.0 - abs(vSide), 0.0, 1.0);
108
114
  float core = pow(across, 10.0);
109
115
  /*
110
116
  * A *wide* halo, deliberately. This exponent is the single biggest control over
@@ -35,4 +35,4 @@
35
35
  * ordinary one, and it wants the sheet laid just clear of the tiles.
36
36
  */
37
37
  export declare const CAUSTICS_VERT = "#version 300 es\nlayout(location = 0) in vec3 aRest;\nlayout(location = 1) in vec2 aLocal;\n/** x: resting height of the water below. y: spare. */\nlayout(location = 2) in vec2 aParams;\n\nuniform mat4 uViewProj;\n\nout vec3 vWorldPos;\nout vec2 vLocal;\nout vec2 vParams;\n\nvoid main() {\n vWorldPos = aRest;\n vLocal = aLocal;\n vParams = aParams;\n gl_Position = uViewProj * vec4(aRest, 1.0);\n}\n";
38
- export declare const CAUSTICS_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec2 vLocal;\nin vec2 vParams;\n\nuniform vec3 uCameraPos;\nuniform vec2 uWindDir;\nuniform float uWaveGain;\nuniform float uTime;\n/** Toward the dominant source, as everywhere else in the engine. */\nuniform vec3 uLightDir;\n/** Colour of the light the water is reflecting. */\nuniform vec3 uTint;\nuniform float uStrength;\n/** How far from the water, on either side, the effect has died out entirely. */\nuniform float uMaxDrop;\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\n\nout vec4 outColor;\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\n/**\n * Shallowest the source may be and still be treated as a source.\n *\n * The slant path goes as 1/sin(elevation), so a light on the horizon would send\n * the sampled water position kilometres away and the pattern would smear into\n * noise. Clamping keeps a sunset dramatic instead of broken.\n */\nconst float MIN_ELEVATION = 0.16;\n\nvoid main() {\n float waterY = vParams.x;\n float drop = vWorldPos.y - waterY;\n\n /*\n * Everything is masked rather than branched on, and fwidth() below is why:\n * screen-space derivatives are undefined in non-uniform control flow, so a\n * discard above them would make the antialiasing of the caustic lines a\n * driver-dependent guess. Nothing is saved by branching either \u2014 a masked\n * fragment adds zero under additive blending.\n *\n * Either side of the surface is lit, and the distance from it is what matters\n * rather than the direction. This began as a soffit-only effect \u2014 light thrown\n * *up* onto the underside of a bridge \u2014 on the reasoning that below the water\n * there is nothing to light. That is only true when the water is a ceiling to\n * you. A pool floor, a flooded corridor, a tank with a lamp over it: the water\n * focuses light onto the bottom exactly as it does onto a soffit, and it is by\n * far the more common of the two.\n *\n * Only the surface's own thin band is excluded, where the sheet and the water\n * are coplanar and the pattern would be meaningless.\n *\n * Too far from it and the net has spread into nothing. And the sheet dissolves\n * at its own boundary, so a lit surface does not end in a lit rectangle.\n */\n float distanceFromWater = abs(drop);\n float clear = step(0.02, distanceFromWater);\n float height = 1.0 - smoothstep(uMaxDrop * 0.45, uMaxDrop, distanceFromWater);\n float rim = min(min(vLocal.x, 1.0 - vLocal.x), min(vLocal.y, 1.0 - vLocal.y));\n float edge = smoothstep(0.0, 0.12, rim);\n float reach = clear * height * edge;\n\n /*\n * Which water lights *this* point. For a flat surface the reflected ray leaves\n * along the mirrored light direction, so walking back from the ceiling gives\n * the water position and the path length in one step. This is why the net\n * slides along the ground as the sun moves rather than sitting under itself.\n */\n float elevation = max(uLightDir.y, MIN_ELEVATION);\n float slant = 1.0 / elevation;\n float d = drop * slant;\n vec2 water = vWorldPos.xz + uLightDir.xz * d;\n\n vec3 h = gerstnerCurvature(water, uTime, uWindDir, uWaveGain);\n float a = 1.0 - 2.0 * d * h.x;\n float b = 1.0 - 2.0 * d * h.y;\n float c = 2.0 * d * h.z;\n float det = a * b - c * c;\n\n /*\n * The bright net is where the determinant crosses zero, so its width is a\n * gradient question, not a constant. Widening the core by the screen-space\n * derivative antialiases it for free \u2014 without that, a line thinner than a\n * pixel strobes as the camera moves, which is the usual reason an effect like\n * this ends up looking like noise.\n */\n float soft = max(0.05, fwidth(det) * 1.6);\n float lines = soft / max(abs(det), soft);\n /*\n * Between the lines, the faint brightening of merely convergent water \u2014 and it has to\n * stay faint. At 0.45 it lit the *whole* sheet, and since the sheet hangs a few\n * centimetres under the soffit, the result read as a pale plate floating below the\n * bridge rather than as light on its underside \u2014 reported as being able to see through\n * the bridge ceiling, which looked glassy and transparent. The plate was the sheet.\n *\n * A tighter threshold and a fifth of the weight: the net is bright, everything between\n * it is almost nothing, and a soffit with no caustics on it is simply a soffit.\n */\n float sheen = clamp(1.0 / max(abs(det), 0.18) - 0.85, 0.0, 1.0);\n float caustic = clamp(lines * 0.9 + sheen * 0.09, 0.0, 1.6);\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n\n // Additive: this is light arriving at a surface that is already shaded.\n outColor = vec4(uTint * (caustic * uStrength * reach * (1.0 - fog)), 1.0);\n}\n";
38
+ export declare const CAUSTICS_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec2 vLocal;\nin vec2 vParams;\n\nuniform vec3 uCameraPos;\nuniform vec2 uWindDir;\nuniform float uWaveGain;\nuniform float uTime;\n/** Toward the dominant source, as everywhere else in the engine. */\nuniform vec3 uLightDir;\n/** Colour of the light the water is reflecting. */\nuniform vec3 uTint;\nuniform float uStrength;\n/** How far from the water, on either side, the effect has died out entirely. */\nuniform float uMaxDrop;\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\n\nout vec4 outColor;\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\n/**\n * Shallowest the source may be and still be treated as a source.\n *\n * The slant path goes as 1/sin(elevation), so a light on the horizon would send\n * the sampled water position kilometres away and the pattern would smear into\n * noise. Clamping keeps a sunset dramatic instead of broken.\n */\nconst float MIN_ELEVATION = 0.16;\n\nvoid main() {\n float waterY = vParams.x;\n float drop = vWorldPos.y - waterY;\n\n /*\n * Everything is masked rather than branched on, and fwidth() below is why:\n * screen-space derivatives are undefined in non-uniform control flow, so a\n * discard above them would make the antialiasing of the caustic lines a\n * driver-dependent guess. Nothing is saved by branching either \u2014 a masked\n * fragment adds zero under additive blending.\n *\n * Either side of the surface is lit, and the distance from it is what matters\n * rather than the direction. This began as a soffit-only effect \u2014 light thrown\n * *up* onto the underside of a bridge \u2014 on the reasoning that below the water\n * there is nothing to light. That is only true when the water is a ceiling to\n * you. A pool floor, a flooded corridor, a tank with a lamp over it: the water\n * focuses light onto the bottom exactly as it does onto a soffit, and it is by\n * far the more common of the two.\n *\n * Only the surface's own thin band is excluded, where the sheet and the water\n * are coplanar and the pattern would be meaningless.\n *\n * Too far from it and the net has spread into nothing. And the sheet dissolves\n * at its own boundary, so a lit surface does not end in a lit rectangle.\n */\n float distanceFromWater = abs(drop);\n float clear = step(0.02, distanceFromWater);\n float height = 1.0 - smoothstep(uMaxDrop * 0.45, uMaxDrop, distanceFromWater);\n float rim = min(min(vLocal.x, 1.0 - vLocal.x), min(vLocal.y, 1.0 - vLocal.y));\n float edge = smoothstep(0.0, 0.12, rim);\n float reach = clear * height * edge;\n\n /*\n * Which water lights *this* point. For a flat surface the reflected ray leaves\n * along the mirrored light direction, so walking back from the ceiling gives\n * the water position and the path length in one step. This is why the net\n * slides along the ground as the sun moves rather than sitting under itself.\n */\n float elevation = max(uLightDir.y, MIN_ELEVATION);\n float slant = 1.0 / elevation;\n float d = drop * slant;\n vec2 water = vWorldPos.xz + uLightDir.xz * d;\n\n vec3 h = gerstnerCurvature(water, uTime, uWindDir, uWaveGain);\n float a = 1.0 - 2.0 * d * h.x;\n float b = 1.0 - 2.0 * d * h.y;\n float c = 2.0 * d * h.z;\n float det = a * b - c * c;\n\n /*\n * The bright net is where the determinant crosses zero, so its width is a\n * gradient question, not a constant. Widening the core by the screen-space\n * derivative antialiases it for free \u2014 without that, a line thinner than a\n * pixel strobes as the camera moves, which is the usual reason an effect like\n * this ends up looking like noise.\n */\n float soft = max(0.05, fwidth(det) * 1.6);\n float lines = soft / max(abs(det), soft);\n /*\n * Between the lines, the faint brightening of merely convergent water \u2014 and it has to\n * stay faint. At 0.45 it lit the *whole* sheet, and since the sheet hangs a few\n * centimetres under the soffit, the result read as a pale plate floating below the\n * bridge rather than as light on its underside \u2014 reported as being able to see through\n * the bridge ceiling, which looked glassy and transparent. The plate was the sheet.\n *\n * A tighter threshold and a fifth of the weight: the net is bright, everything between\n * it is almost nothing, and a soffit with no caustics on it is simply a soffit.\n */\n float sheen = clamp(1.0 / max(abs(det), 0.18) - 0.85, 0.0, 1.0);\n float caustic = clamp(lines * 0.9 + sheen * 0.09, 0.0, 1.6);\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n\n // Additive: this is light arriving at a surface that is already shaded.\n outColor = vec4(uTint * (caustic * uStrength * reach * (1.0 - fog)), 1.0);\n}\n";
@@ -100,6 +100,12 @@ void main() {
100
100
  #endif
101
101
  #if INSTANCED
102
102
  mat4 model = mat4(aInstanceModel0, aInstanceModel1, aInstanceModel2, aInstanceModel3);
103
+ /* The bottom row is the instance's texture cell, rebuilt as the row it stands for: see flat. */
104
+ vec4 instanceCell = vec4(model[0].w, model[1].w, model[2].w, model[3].w);
105
+ model[0].w = 0.0;
106
+ model[1].w = 0.0;
107
+ model[2].w = 0.0;
108
+ model[3].w = 1.0;
103
109
  #else
104
110
  mat4 model = uModel;
105
111
  #endif
@@ -124,7 +130,12 @@ void main() {
124
130
  vLightPosition = uLightViewProj * vec4(bent, world.w);
125
131
  gl_Position = vLightPosition;
126
132
  #if CUTOUT
133
+ #if INSTANCED
134
+ /* A cutout instance's shadow is cut by its own cell, as its picture is. */
135
+ vUv = vec3((aUv.xy * instanceCell.xy + instanceCell.zw) * uUvScale + uUvOffset, aUv.z);
136
+ #else
127
137
  vUv = vec3(aUv.xy * uUvScale + uUvOffset, aUv.z);
138
+ #endif
128
139
  vAlphaCutout = uAlphaCutout;
129
140
  #endif
130
141
  #if GLASS
@@ -12,4 +12,4 @@
12
12
  * rainbow is generated rather than sampled.
13
13
  */
14
14
  export declare const FILM_VERT = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec3 aPosition;\nlayout(location = 1) in vec3 aNormal;\nlayout(location = 2) in vec3 aColor;\n/*\n * Coverage, on the attribute the mesh format calls emissive.\n *\n * A film has no use for self-illumination, and it very much needs an edge that\n * dissolves: the first oil was flat squares, and a hard straight edge is what made\n * it read as a decal dropped on the world rather than a puddle lying in it. One\n * float per vertex, 1 in the middle of a slick and 0 at its rim.\n */\nlayout(location = 3) in float aCoverage;\n\nuniform mat4 uViewProj;\n/* The mirrored camera's projection, so a film can sample the planar target the way water does. */\nuniform mat4 uReflectionViewProj;\n\nout vec3 vWorld;\nout vec3 vNormal;\nout vec3 vTint;\nout vec4 vReflectionClip;\nout float vCoverage;\n\nvoid main() {\n vWorld = aPosition;\n vNormal = normalize(aNormal);\n vTint = aColor;\n vCoverage = aCoverage;\n vReflectionClip = uReflectionViewProj * vec4(aPosition, 1.0);\n gl_Position = uViewProj * vec4(aPosition, 1.0);\n}\n";
15
- export declare const FILM_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorld;\nin vec3 vNormal;\nin vec3 vTint;\nin vec4 vReflectionClip;\n/*\n * The scene mirrored across the plane this film lies on, and how much of it to show.\n *\n * **Sheen is iridescence and this is the image**, which is the split a consumer asked for after\n * finding that raising sheen to make a road wetter only made it oilier. A wet street at night is\n * defined by everything above it appearing twice, and no amount of interference pattern is that.\n *\n * The projection is the water shader's, unchanged and deliberately so: reproducing it outside the\n * engine is the piece most likely to be subtly wrong and least likely to be noticed, since a\n * mirror off by a small skew looks like a wet road until somebody puts a straight line across it.\n */\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform float uReflectionStrength;\nin float vCoverage;\n\nuniform vec3 uCameraPos;\nuniform float uTime;\n/*\n * The mirrored pass clips at the water plane, and it does so **in the fragment\n * shader** rather than with gl_ClipDistance.\n *\n * That is not a style choice. Clip distances in WebGL2 need\n * EXT_clip_cull_distance both enabled on the context and declared with an\n * #extension line, and without the declaration the shader fails to *compile* \u2014\n * which, since programs are built when the renderer is constructed, takes the\n * whole boot with it and leaves a black screen. This shader had never been\n * compiled when it was written, so the mistake sat harmlessly in the file until\n * the day it was wired to something. Discarding is what flat.ts does, it needs\n * no extension, and a film is a thin translucent layer where the cost of a\n * discarded fragment is nothing.\n */\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\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\n/** How strong the sheen is. Zero leaves a plain dark film. */\nuniform float uSheen;\n\n/**\n * How rough the film's own surface is, 0 to 1, and how coarse that roughness is.\n *\n * **A film had exactly one finish before this: mirror.** That is right for standing water and\n * wrong for the thing this pass is most used for, because a wet road is not a mirror \u2014 it is\n * water lying *in* aggregate, and the stone underneath breaks the reflection into a smear. With\n * no way to say so, a patch of film over a textured road met it at a hard rim: mirror on one\n * side, stone on the other, with nothing able to cross it. Feathering the coverage cannot fix\n * that, and it was tried \u2014 the alpha was never what the eye was reading.\n *\n * So the reflection is displaced by a noise field rather than blurred. **Displaced, because a\n * blur is the wrong phenomenon**: a rough surface does not soften what it reflects, it scatters\n * it, and a lamp on wet tarmac smears into a streak rather than becoming a soft disc. It is also\n * what a blur cannot afford here \u2014 the mirror has no mip chain (see the `textureLod` note\n * below), so softening it honestly would cost several taps where this costs none.\n *\n * `uFilmRoughnessCycles` is bumps to the metre, and it is the same quantity\n * `Renderer.setSurfaceRelief` takes. **Matching it to the surface underneath is the point**:\n * given the same field at the same scale, the water is broken up by the aggregate that the dry\n * road beside it is showing, and the two stop being different materials.\n *\n * Zero is the mirror this pass has always been, exactly, so nothing that does not ask for\n * roughness renders differently.\n */\nuniform float uFilmRoughness;\nuniform float uFilmRoughnessCycles;\n\nout vec4 fragColor;\n\n/** A hue wheel, without a texture. Standard 6-segment ramp. */\nvec3 hue(float h) {\n h = fract(h) * 6.0;\n vec3 c = clamp(vec3(abs(h - 3.0) - 1.0, 2.0 - abs(h - 2.0), 2.0 - abs(h - 4.0)), 0.0, 1.0);\n return c;\n}\n\n/**\n * The same value noise `flat.ts` builds its relief from, and deliberately the same.\n *\n * Copied rather than shared because these two shaders have no common include beyond the fog, and\n * a film that used a *different* field would defeat the whole purpose of taking a cycle count:\n * the water would be broken up by one pattern and the road by another, which is two materials\n * again with extra steps.\n */\nfloat filmHash(vec3 p) {\n p = fract(p * 0.1031);\n p += dot(p, p.zyx + 31.32);\n return fract((p.x + p.y) * p.z);\n}\n\nfloat filmNoise(vec3 p) {\n vec3 i = floor(p);\n vec3 f = fract(p);\n vec3 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 0.0)), filmHash(i + vec3(1.0, 0.0, 0.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 0.0)), filmHash(i + vec3(1.0, 1.0, 0.0)), u.x),\n u.y\n ),\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 1.0)), filmHash(i + vec3(1.0, 0.0, 1.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 1.0)), filmHash(i + vec3(1.0, 1.0, 1.0)), u.x),\n u.y\n ),\n u.z\n );\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorld, 1.0), uClipPlane) < 0.0) discard;\n\n vec3 view = normalize(uCameraPos - vWorld);\n float facing = max(dot(normalize(vNormal), view), 0.0);\n /*\n * Fresnel-ish: dark looking straight down at it, bright at a grazing angle.\n * This is the whole effect \u2014 it is what makes a wet patch look wet rather\n * than looking like a patch of dark paint.\n */\n float grazing = pow(1.0 - facing, 3.0);\n\n /*\n * A slow travelling interference, so the sheen crawls rather than sitting\n * still. Two frequencies that do not divide each other, or the pattern\n * repeats visibly across a long slick.\n */\n float ripple =\n sin(vWorld.x * 0.73 + vWorld.z * 0.41 + uTime * 0.55) *\n cos(vWorld.z * 0.91 - vWorld.x * 0.29 - uTime * 0.37);\n\n vec3 sheen = hue(grazing * 1.35 + ripple * 0.14 + 0.55);\n /*\n * Dark underneath, so the film always reads as *less* than the surface it lies on even where\n * the sheen is strongest \u2014 but not so dark that it disappears.\n *\n * A slick that has just launched somebody has to be findable afterwards. It was rounded,\n * feathered and iridescent \u2014 and at a fifth of an opaque tint on a pale deck at dusk,\n * invisible. A hazard you cannot find is not a hazard, it is a trap.\n *\n * So the base is darker and the sheen has a floor: even looking straight down, where the\n * Fresnel term is nearly nothing, a slick is a distinctly wet patch rather than a faint one.\n */\n vec3 base = vTint * 0.9;\n vec3 color = base + sheen * uSheen * (0.22 + grazing * 0.75);\n\n /*\n * The mirrored scene, under the sheen rather than instead of it.\n *\n * Screen-space from the mirrored clip position, exactly as the water pass does it, with the\n * same fade as the sample leaves the target: a reflection that stopped dead at the edge of its\n * own buffer would draw a line across the road where nothing is.\n *\n * Weighted by the grazing term as well as by strength, because that is what a reflection does:\n * a wet surface underfoot shows almost nothing and the same surface down the street shows the\n * whole scene. Added before the fog so the image recedes with everything else in the frame.\n */\n if (uReflectionEnabled != 0 && uReflectionStrength > 0.0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n float valid = smoothstep(0.0, 0.025, min(border.x, border.y));\n /*\n * An explicit level, not an implicit one, and the branch above is the whole reason.\n *\n * An implicit derivative inside control flow that is not provably uniform is what lets a\n * compiler flatten the branch and pay for every arm \u2014 AGENTS.md, 2026-08-07 \u2014 and WGSL\n * does not merely warn: the module fails to compile with \"textureSample must only be\n * called from uniform control flow\", which invalidates the pipeline, which invalidates\n * the command buffer, and the frame still presents. The fifth time on this branch. The\n * mirror has no mip chain, so level 0 is the only level there is; the water shader\n * reaches the same conclusion for the same lookup.\n */\n /*\n * The aggregate under the water, as a displacement of what the water shows.\n *\n * The gradient of the same field the road's relief uses, taken across the surface and used\n * to push the lookup. **Stretched along the reflection rather than isotropic**, and that is\n * the half that makes it read as a road: a reflection on a rough horizontal surface smears\n * *towards the viewer*, because a bump tilts the ray in the plane it is already travelling\n * in. Displacing in a circle gives frosted glass instead, which is a different wet thing.\n *\n * Scaled by the grazing term for the same reason the mirror is: looking straight down at a\n * puddle the reflection is compressed into almost nothing and a displacement of it would be\n * enormous, so the scatter has to fall off exactly where the image does.\n */\n if (uFilmRoughness > 0.0) {\n vec3 at = vWorld * uFilmRoughnessCycles;\n float here = filmNoise(at);\n vec2 slope = vec2(\n filmNoise(at + vec3(0.5, 0.0, 0.0)) - here,\n filmNoise(at + vec3(0.0, 0.0, 0.5)) - here\n );\n /* Along the view, which on a horizontal film is where the image runs. */\n vec2 along = normalize(vec2(view.x, view.z) + vec2(1e-5, 0.0));\n vec2 smear = along * dot(slope, along) * 3.0 + slope * 0.35;\n reflectionUv += smear * uFilmRoughness * 0.016 * (0.2 + grazing * 0.8);\n /* Re-fade at the border: a displaced sample can leave the target the original did not. */\n vec2 movedBorder = min(reflectionUv, 1.0 - reflectionUv);\n valid = min(valid, smoothstep(0.0, 0.025, min(movedBorder.x, movedBorder.y)));\n }\n vec3 mirrored = textureLod(uReflectionMap, clamp(reflectionUv, 0.0, 1.0), 0.0).rgb;\n color = mix(color, mirrored, valid * uReflectionStrength * (0.25 + grazing * 0.75));\n }\n\n // The same fog every other pass applies \u2014 and now literally the same code, which\n // it was not: this ran its own curve while claiming otherwise, so a slick receded\n // at a different rate than the deck it was lying on.\n float fog = mediumFog(length(uCameraPos - vWorld), vWorld.y);\n color = mix(color, mediumColor(), fog);\n\n /*\n * Alpha from coverage, squared.\n *\n * Squared because a linear ramp across the rim still reads as a visible band \u2014\n * the eye finds the constant-gradient edge. Squaring pushes the falloff outward\n * so the slick has a soft shoulder and no perceptible boundary. The sheen fades\n * with it, which is right: the thinnest part of a film is the least iridescent.\n */\n /*\n * Alpha from coverage, and the rim is what is squared \u2014 not the whole patch.\n *\n * Squaring the coverage outright made the *middle* translucent too, which is most of why a\n * slick could not be found. The shoulder still needs to be soft, so the square is mixed in\n * near the edge only: solid through the body, dissolving at the rim.\n */\n float coverage = clamp(vCoverage, 0.0, 1.0);\n float alpha = mix(coverage * coverage, coverage, 0.65) * 0.92;\n fragColor = vec4(color, alpha);\n}\n";
15
+ export declare const FILM_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorld;\nin vec3 vNormal;\nin vec3 vTint;\nin vec4 vReflectionClip;\n/*\n * The scene mirrored across the plane this film lies on, and how much of it to show.\n *\n * **Sheen is iridescence and this is the image**, which is the split a consumer asked for after\n * finding that raising sheen to make a road wetter only made it oilier. A wet street at night is\n * defined by everything above it appearing twice, and no amount of interference pattern is that.\n *\n * The projection is the water shader's, unchanged and deliberately so: reproducing it outside the\n * engine is the piece most likely to be subtly wrong and least likely to be noticed, since a\n * mirror off by a small skew looks like a wet road until somebody puts a straight line across it.\n */\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform float uReflectionStrength;\nin float vCoverage;\n\nuniform vec3 uCameraPos;\nuniform float uTime;\n/*\n * The mirrored pass clips at the water plane, and it does so **in the fragment\n * shader** rather than with gl_ClipDistance.\n *\n * That is not a style choice. Clip distances in WebGL2 need\n * EXT_clip_cull_distance both enabled on the context and declared with an\n * #extension line, and without the declaration the shader fails to *compile* \u2014\n * which, since programs are built when the renderer is constructed, takes the\n * whole boot with it and leaves a black screen. This shader had never been\n * compiled when it was written, so the mistake sat harmlessly in the file until\n * the day it was wired to something. Discarding is what flat.ts does, it needs\n * no extension, and a film is a thin translucent layer where the cost of a\n * discarded fragment is nothing.\n */\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\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\n/** How strong the sheen is. Zero leaves a plain dark film. */\nuniform float uSheen;\n\n/**\n * How rough the film's own surface is, 0 to 1, and how coarse that roughness is.\n *\n * **A film had exactly one finish before this: mirror.** That is right for standing water and\n * wrong for the thing this pass is most used for, because a wet road is not a mirror \u2014 it is\n * water lying *in* aggregate, and the stone underneath breaks the reflection into a smear. With\n * no way to say so, a patch of film over a textured road met it at a hard rim: mirror on one\n * side, stone on the other, with nothing able to cross it. Feathering the coverage cannot fix\n * that, and it was tried \u2014 the alpha was never what the eye was reading.\n *\n * So the reflection is displaced by a noise field rather than blurred. **Displaced, because a\n * blur is the wrong phenomenon**: a rough surface does not soften what it reflects, it scatters\n * it, and a lamp on wet tarmac smears into a streak rather than becoming a soft disc. It is also\n * what a blur cannot afford here \u2014 the mirror has no mip chain (see the `textureLod` note\n * below), so softening it honestly would cost several taps where this costs none.\n *\n * `uFilmRoughnessCycles` is bumps to the metre, and it is the same quantity\n * `Renderer.setSurfaceRelief` takes. **Matching it to the surface underneath is the point**:\n * given the same field at the same scale, the water is broken up by the aggregate that the dry\n * road beside it is showing, and the two stop being different materials.\n *\n * Zero is the mirror this pass has always been, exactly, so nothing that does not ask for\n * roughness renders differently.\n */\nuniform float uFilmRoughness;\nuniform float uFilmRoughnessCycles;\n\nout vec4 fragColor;\n\n/** A hue wheel, without a texture. Standard 6-segment ramp. */\nvec3 hue(float h) {\n h = fract(h) * 6.0;\n vec3 c = clamp(vec3(abs(h - 3.0) - 1.0, 2.0 - abs(h - 2.0), 2.0 - abs(h - 4.0)), 0.0, 1.0);\n return c;\n}\n\n/**\n * The same value noise `flat.ts` builds its relief from, and deliberately the same.\n *\n * Copied rather than shared because these two shaders have no common include beyond the fog, and\n * a film that used a *different* field would defeat the whole purpose of taking a cycle count:\n * the water would be broken up by one pattern and the road by another, which is two materials\n * again with extra steps.\n */\nfloat filmHash(vec3 p) {\n p = fract(p * 0.1031);\n p += dot(p, p.zyx + 31.32);\n return fract((p.x + p.y) * p.z);\n}\n\nfloat filmNoise(vec3 p) {\n vec3 i = floor(p);\n vec3 f = fract(p);\n vec3 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 0.0)), filmHash(i + vec3(1.0, 0.0, 0.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 0.0)), filmHash(i + vec3(1.0, 1.0, 0.0)), u.x),\n u.y\n ),\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 1.0)), filmHash(i + vec3(1.0, 0.0, 1.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 1.0)), filmHash(i + vec3(1.0, 1.0, 1.0)), u.x),\n u.y\n ),\n u.z\n );\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorld, 1.0), uClipPlane) < 0.0) discard;\n\n vec3 view = normalize(uCameraPos - vWorld);\n float facing = max(dot(normalize(vNormal), view), 0.0);\n /*\n * Fresnel-ish: dark looking straight down at it, bright at a grazing angle.\n * This is the whole effect \u2014 it is what makes a wet patch look wet rather\n * than looking like a patch of dark paint.\n */\n float grazing = pow(1.0 - facing, 3.0);\n\n /*\n * A slow travelling interference, so the sheen crawls rather than sitting\n * still. Two frequencies that do not divide each other, or the pattern\n * repeats visibly across a long slick.\n */\n float ripple =\n sin(vWorld.x * 0.73 + vWorld.z * 0.41 + uTime * 0.55) *\n cos(vWorld.z * 0.91 - vWorld.x * 0.29 - uTime * 0.37);\n\n vec3 sheen = hue(grazing * 1.35 + ripple * 0.14 + 0.55);\n /*\n * Dark underneath, so the film always reads as *less* than the surface it lies on even where\n * the sheen is strongest \u2014 but not so dark that it disappears.\n *\n * A slick that has just launched somebody has to be findable afterwards. It was rounded,\n * feathered and iridescent \u2014 and at a fifth of an opaque tint on a pale deck at dusk,\n * invisible. A hazard you cannot find is not a hazard, it is a trap.\n *\n * So the base is darker and the sheen has a floor: even looking straight down, where the\n * Fresnel term is nearly nothing, a slick is a distinctly wet patch rather than a faint one.\n */\n vec3 base = vTint * 0.9;\n vec3 color = base + sheen * uSheen * (0.22 + grazing * 0.75);\n\n /*\n * The mirrored scene, under the sheen rather than instead of it.\n *\n * Screen-space from the mirrored clip position, exactly as the water pass does it, with the\n * same fade as the sample leaves the target: a reflection that stopped dead at the edge of its\n * own buffer would draw a line across the road where nothing is.\n *\n * Weighted by the grazing term as well as by strength, because that is what a reflection does:\n * a wet surface underfoot shows almost nothing and the same surface down the street shows the\n * whole scene. Added before the fog so the image recedes with everything else in the frame.\n */\n if (uReflectionEnabled != 0 && uReflectionStrength > 0.0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n float valid = smoothstep(0.0, 0.025, min(border.x, border.y));\n /*\n * An explicit level, not an implicit one, and the branch above is the whole reason.\n *\n * An implicit derivative inside control flow that is not provably uniform is what lets a\n * compiler flatten the branch and pay for every arm \u2014 AGENTS.md, 2026-08-07 \u2014 and WGSL\n * does not merely warn: the module fails to compile with \"textureSample must only be\n * called from uniform control flow\", which invalidates the pipeline, which invalidates\n * the command buffer, and the frame still presents. The fifth time on this branch. The\n * mirror has no mip chain, so level 0 is the only level there is; the water shader\n * reaches the same conclusion for the same lookup.\n */\n /*\n * The aggregate under the water, as a displacement of what the water shows.\n *\n * The gradient of the same field the road's relief uses, taken across the surface and used\n * to push the lookup. **Stretched along the reflection rather than isotropic**, and that is\n * the half that makes it read as a road: a reflection on a rough horizontal surface smears\n * *towards the viewer*, because a bump tilts the ray in the plane it is already travelling\n * in. Displacing in a circle gives frosted glass instead, which is a different wet thing.\n *\n * Scaled by the grazing term for the same reason the mirror is: looking straight down at a\n * puddle the reflection is compressed into almost nothing and a displacement of it would be\n * enormous, so the scatter has to fall off exactly where the image does.\n */\n if (uFilmRoughness > 0.0) {\n vec3 at = vWorld * uFilmRoughnessCycles;\n float here = filmNoise(at);\n vec2 slope = vec2(\n filmNoise(at + vec3(0.5, 0.0, 0.0)) - here,\n filmNoise(at + vec3(0.0, 0.0, 0.5)) - here\n );\n /* Along the view, which on a horizontal film is where the image runs. */\n vec2 along = normalize(vec2(view.x, view.z) + vec2(1e-5, 0.0));\n vec2 smear = along * dot(slope, along) * 3.0 + slope * 0.35;\n reflectionUv += smear * uFilmRoughness * 0.016 * (0.2 + grazing * 0.8);\n /* Re-fade at the border: a displaced sample can leave the target the original did not. */\n vec2 movedBorder = min(reflectionUv, 1.0 - reflectionUv);\n valid = min(valid, smoothstep(0.0, 0.025, min(movedBorder.x, movedBorder.y)));\n }\n vec3 mirrored = textureLod(uReflectionMap, clamp(reflectionUv, 0.0, 1.0), 0.0).rgb;\n color = mix(color, mirrored, valid * uReflectionStrength * (0.25 + grazing * 0.75));\n }\n\n // The same fog every other pass applies \u2014 and now literally the same code, which\n // it was not: this ran its own curve while claiming otherwise, so a slick receded\n // at a different rate than the deck it was lying on.\n float fog = mediumFog(length(uCameraPos - vWorld), vWorld.y);\n color = mix(color, mediumColor(), fog);\n\n /*\n * Alpha from coverage, squared.\n *\n * Squared because a linear ramp across the rim still reads as a visible band \u2014\n * the eye finds the constant-gradient edge. Squaring pushes the falloff outward\n * so the slick has a soft shoulder and no perceptible boundary. The sheen fades\n * with it, which is right: the thinnest part of a film is the least iridescent.\n */\n /*\n * Alpha from coverage, and the rim is what is squared \u2014 not the whole patch.\n *\n * Squaring the coverage outright made the *middle* translucent too, which is most of why a\n * slick could not be found. The shoulder still needs to be soft, so the square is mixed in\n * near the edge only: solid through the body, dissolving at the rim.\n */\n float coverage = clamp(vCoverage, 0.0, 1.0);\n float alpha = mix(coverage * coverage, coverage, 0.65) * 0.92;\n fragColor = vec4(color, alpha);\n}\n";
@@ -78,7 +78,8 @@ export function grainWeight(luma) {
78
78
  return 0.25 + 3 * l * (1 - l);
79
79
  }
80
80
  /** Both backends' words when there is no composite to apply the look in. */
81
- export const FILM_LOOK_WITHOUT_COMPOSITE = '[driftengine] setVignette and setFilmGrain need `screenEffects`: without a composite every ' +
81
+ export const FILM_LOOK_WITHOUT_COMPOSITE = '[driftengine] setVignette, setFilmGrain and setChromaticAberration need `screenEffects`: ' +
82
+ 'without a composite every ' +
82
83
  'pass is the last thing to touch the frame, so there is no one place to apply a lens or a ' +
83
84
  'print. The look was not applied.';
84
85
  /** A vignette strength as the shader takes it: 0 to 4, anything else off. */