@driftengine/core 4.9.0 → 4.11.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 (231) hide show
  1. package/CHANGELOG.json +133 -11
  2. package/README.md +43 -38
  3. package/dist/index.d.ts +7 -4
  4. package/dist/index.js +5 -4
  5. package/dist/render/ambientHarmonics.d.ts +1 -1
  6. package/dist/render/ambientHarmonics.js +1 -1
  7. package/dist/render/areaLights.d.ts +4 -4
  8. package/dist/render/areaLights.js +1 -1
  9. package/dist/render/backend/api.d.ts +10 -3
  10. package/dist/render/backend/select.js +11 -5
  11. package/dist/render/backend/webgl2/boneAnimations.d.ts +29 -0
  12. package/dist/render/backend/webgl2/boneAnimations.js +80 -0
  13. package/dist/render/backend/webgl2/instanced.d.ts +5 -1
  14. package/dist/render/backend/webgl2/instanced.js +13 -1
  15. package/dist/render/backend/webgl2/renderer.d.ts +189 -4
  16. package/dist/render/backend/webgl2/renderer.js +614 -66
  17. package/dist/render/backend/webgl2/skinPaletteTexture.d.ts +5 -3
  18. package/dist/render/backend/webgl2/skinPaletteTexture.js +6 -4
  19. package/dist/render/backend/webgpu/boneAnimations.d.ts +34 -0
  20. package/dist/render/backend/webgpu/boneAnimations.js +76 -0
  21. package/dist/render/backend/webgpu/buffers.d.ts +24 -4
  22. package/dist/render/backend/webgpu/buffers.js +116 -59
  23. package/dist/render/backend/webgpu/clothKernels.d.ts +8 -0
  24. package/dist/render/backend/webgpu/clothKernels.js +76 -0
  25. package/dist/render/backend/webgpu/clothOps.d.ts +39 -0
  26. package/dist/render/backend/webgpu/clothOps.js +78 -0
  27. package/dist/render/backend/webgpu/clothSetLayout.d.ts +25 -0
  28. package/dist/render/backend/webgpu/clothSetLayout.js +84 -0
  29. package/dist/render/backend/webgpu/clothSetPack.d.ts +33 -0
  30. package/dist/render/backend/webgpu/clothSetPack.js +144 -0
  31. package/dist/render/backend/webgpu/compressedUpload.d.ts +0 -1
  32. package/dist/render/backend/webgpu/compressedUpload.js +53 -8
  33. package/dist/render/backend/webgpu/depthPass.d.ts +59 -3
  34. package/dist/render/backend/webgpu/depthPass.js +49 -14
  35. package/dist/render/backend/webgpu/drawCommand.d.ts +20 -4
  36. package/dist/render/backend/webgpu/drawCommand.js +11 -3
  37. package/dist/render/backend/webgpu/flatPass.d.ts +85 -42
  38. package/dist/render/backend/webgpu/flatPass.js +137 -20
  39. package/dist/render/backend/webgpu/gpuClothSet.d.ts +80 -0
  40. package/dist/render/backend/webgpu/gpuClothSet.js +462 -0
  41. package/dist/render/backend/webgpu/instanced.d.ts +14 -1
  42. package/dist/render/backend/webgpu/instanced.js +24 -1
  43. package/dist/render/backend/webgpu/latePass.js +1 -0
  44. package/dist/render/backend/webgpu/materialStore.d.ts +101 -0
  45. package/dist/render/backend/webgpu/materialStore.js +265 -0
  46. package/dist/render/backend/webgpu/motionPass.js +15 -6
  47. package/dist/render/backend/webgpu/pipelineCache.d.ts +28 -1
  48. package/dist/render/backend/webgpu/pipelineCache.js +46 -1
  49. package/dist/render/backend/webgpu/postPass.d.ts +1 -1
  50. package/dist/render/backend/webgpu/renderer.d.ts +200 -35
  51. package/dist/render/backend/webgpu/renderer.js +993 -168
  52. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +9 -2
  53. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +121 -47
  54. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +7 -3
  55. package/dist/render/backend/webgpu/skinPaletteRing.js +13 -7
  56. package/dist/render/backend/webgpu/skinPaletteTexture.d.ts +6 -0
  57. package/dist/render/backend/webgpu/skinPaletteTexture.js +7 -1
  58. package/dist/render/backend/webgpu/staticBundles.d.ts +125 -0
  59. package/dist/render/backend/webgpu/staticBundles.js +242 -0
  60. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +10 -3
  61. package/dist/render/backend/webgpu/surfaceTexturePass.js +20 -2
  62. package/dist/render/backend/webgpu/uniformRing.d.ts +21 -0
  63. package/dist/render/backend/webgpu/uniformRing.js +9 -1
  64. package/dist/render/backend/webgpu/vertexFormats.d.ts +22 -0
  65. package/dist/render/backend/webgpu/vertexFormats.js +97 -0
  66. package/dist/render/bloomChain.d.ts +3 -3
  67. package/dist/render/boneAnimation.d.ts +92 -0
  68. package/dist/render/boneAnimation.js +133 -0
  69. package/dist/render/compressedSource.d.ts +33 -13
  70. package/dist/render/compressedSource.js +70 -25
  71. package/dist/render/glCompressed.js +83 -0
  72. package/dist/render/globalMediumPass.d.ts +2 -0
  73. package/dist/render/globalMediumPass.js +1 -0
  74. package/dist/render/indexWidth.d.ts +17 -0
  75. package/dist/render/indexWidth.js +23 -0
  76. package/dist/render/instances.d.ts +26 -0
  77. package/dist/render/lightBudget.d.ts +10 -0
  78. package/dist/render/lightBudget.js +10 -0
  79. package/dist/render/lightMatrix.d.ts +11 -0
  80. package/dist/render/lightMatrix.js +14 -0
  81. package/dist/render/lightmap.d.ts +1 -1
  82. package/dist/render/mesh.d.ts +14 -0
  83. package/dist/render/mesh.js +44 -14
  84. package/dist/render/pointLightSelection.d.ts +4 -4
  85. package/dist/render/renderQuality.d.ts +30 -0
  86. package/dist/render/renderQuality.js +5 -0
  87. package/dist/render/rendererHarness.d.ts +7 -0
  88. package/dist/render/rendererHarness.js +12 -0
  89. package/dist/render/screenSpaceReflection.d.ts +2 -1
  90. package/dist/render/shader.d.ts +15 -0
  91. package/dist/render/shader.js +41 -0
  92. package/dist/render/shaders/boneAnimation.d.ts +19 -0
  93. package/dist/render/shaders/boneAnimation.js +63 -0
  94. package/dist/render/shaders/depth.d.ts +7 -0
  95. package/dist/render/shaders/depth.js +35 -10
  96. package/dist/render/shaders/flat/directionalShadow.js +84 -10
  97. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  98. package/dist/render/shaders/flat/glassTint.js +3 -0
  99. package/dist/render/shaders/flat/index.d.ts +21 -0
  100. package/dist/render/shaders/flat/index.js +42 -7
  101. package/dist/render/shaders/flat/layered.d.ts +19 -0
  102. package/dist/render/shaders/flat/layered.js +115 -0
  103. package/dist/render/shaders/flat/main.d.ts +1 -1
  104. package/dist/render/shaders/flat/main.js +58 -21
  105. package/dist/render/shaders/flat/mapReads.d.ts +10 -0
  106. package/dist/render/shaders/flat/mapReads.js +38 -0
  107. package/dist/render/shaders/flat/models.js +1 -1
  108. package/dist/render/shaders/flat/movingSun.d.ts +21 -0
  109. package/dist/render/shaders/flat/movingSun.js +46 -0
  110. package/dist/render/shaders/flat/normalMap.d.ts +6 -2
  111. package/dist/render/shaders/flat/normalMap.js +14 -3
  112. package/dist/render/shaders/flat/overlay.d.ts +3 -2
  113. package/dist/render/shaders/flat/overlay.js +42 -20
  114. package/dist/render/shaders/flat/pointShadow.js +2 -2
  115. package/dist/render/shaders/flat/preamble.js +59 -27
  116. package/dist/render/shaders/flat/worldUv.d.ts +22 -0
  117. package/dist/render/shaders/flat/worldUv.js +113 -0
  118. package/dist/render/shaders/generated/depth.wgsl.d.ts +93 -0
  119. package/dist/render/shaders/generated/depth.wgsl.js +93 -0
  120. package/dist/render/shaders/generated/flat.wgsl.d.ts +3616 -3042
  121. package/dist/render/shaders/generated/flat.wgsl.js +3688 -3113
  122. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +8 -3
  123. package/dist/render/shaders/generated/globalMedium.wgsl.js +8 -3
  124. package/dist/render/shaders/generated/lightVolume.wgsl.d.ts +15 -10
  125. package/dist/render/shaders/generated/lightVolume.wgsl.js +16 -11
  126. package/dist/render/shaders/globalMedium.js +9 -1
  127. package/dist/render/shaders/lightVolume.js +9 -1
  128. package/dist/render/shaders/outputTransform.d.ts +7 -0
  129. package/dist/render/shaders/outputTransform.js +28 -14
  130. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  131. package/dist/render/shaders/tangentFrame.js +16 -3
  132. package/dist/render/shaders/vertexChannel.d.ts +7 -0
  133. package/dist/render/shaders/vertexChannel.js +26 -0
  134. package/dist/render/skinnedClothRun.d.ts +28 -0
  135. package/dist/render/skinnedClothRun.js +103 -35
  136. package/dist/render/staticDraws.d.ts +47 -0
  137. package/dist/render/staticDraws.js +43 -0
  138. package/dist/render/surfaceLayers.d.ts +39 -0
  139. package/dist/render/surfaceLayers.js +26 -0
  140. package/dist/render/surfaceOverlay.d.ts +16 -3
  141. package/dist/render/surfaceOverlay.js +11 -6
  142. package/dist/render/surfaceProjection.d.ts +34 -0
  143. package/dist/render/surfaceProjection.js +30 -0
  144. package/dist/render/surfaceTexture.d.ts +68 -7
  145. package/dist/render/surfaceTexture.js +56 -17
  146. package/dist/render/vertexPacking.d.ts +48 -0
  147. package/dist/render/vertexPacking.js +68 -0
  148. package/package.json +6 -6
  149. package/src/index.ts +12 -4
  150. package/src/render/ambientHarmonics.ts +1 -1
  151. package/src/render/areaLights.ts +4 -4
  152. package/src/render/backend/api.ts +13 -3
  153. package/src/render/backend/select.ts +10 -5
  154. package/src/render/backend/webgl2/boneAnimations.ts +105 -0
  155. package/src/render/backend/webgl2/instanced.ts +20 -1
  156. package/src/render/backend/webgl2/renderer.ts +723 -79
  157. package/src/render/backend/webgl2/skinPaletteTexture.ts +6 -4
  158. package/src/render/backend/webgpu/boneAnimations.ts +97 -0
  159. package/src/render/backend/webgpu/buffers.ts +195 -70
  160. package/src/render/backend/webgpu/clothKernels.ts +86 -0
  161. package/src/render/backend/webgpu/clothOps.ts +86 -0
  162. package/src/render/backend/webgpu/clothSetLayout.ts +102 -0
  163. package/src/render/backend/webgpu/clothSetPack.ts +197 -0
  164. package/src/render/backend/webgpu/compressedUpload.ts +59 -9
  165. package/src/render/backend/webgpu/depthPass.ts +51 -10
  166. package/src/render/backend/webgpu/drawCommand.ts +25 -4
  167. package/src/render/backend/webgpu/flatPass.ts +212 -41
  168. package/src/render/backend/webgpu/gpuClothSet.ts +564 -0
  169. package/src/render/backend/webgpu/instanced.ts +32 -1
  170. package/src/render/backend/webgpu/latePass.ts +1 -0
  171. package/src/render/backend/webgpu/materialStore.ts +276 -0
  172. package/src/render/backend/webgpu/motionPass.ts +18 -7
  173. package/src/render/backend/webgpu/pipelineCache.ts +53 -7
  174. package/src/render/backend/webgpu/renderer.ts +1201 -202
  175. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +121 -47
  176. package/src/render/backend/webgpu/skinPaletteRing.ts +13 -7
  177. package/src/render/backend/webgpu/skinPaletteTexture.ts +7 -1
  178. package/src/render/backend/webgpu/staticBundles.ts +364 -0
  179. package/src/render/backend/webgpu/surfaceTexturePass.ts +29 -3
  180. package/src/render/backend/webgpu/uniformRing.ts +10 -1
  181. package/src/render/backend/webgpu/vertexFormats.ts +113 -0
  182. package/src/render/bloomChain.ts +3 -3
  183. package/src/render/boneAnimation.ts +223 -0
  184. package/src/render/compressedSource.ts +121 -29
  185. package/src/render/glCompressed.ts +83 -0
  186. package/src/render/globalMediumPass.ts +7 -0
  187. package/src/render/indexWidth.ts +27 -0
  188. package/src/render/instances.ts +27 -0
  189. package/src/render/lightBudget.ts +11 -0
  190. package/src/render/lightMatrix.ts +15 -0
  191. package/src/render/lightmap.ts +1 -1
  192. package/src/render/mesh.ts +72 -14
  193. package/src/render/pointLightSelection.ts +4 -4
  194. package/src/render/renderQuality.ts +38 -0
  195. package/src/render/rendererHarness.ts +15 -0
  196. package/src/render/screenSpaceReflection.ts +2 -1
  197. package/src/render/shader.ts +59 -0
  198. package/src/render/shaders/boneAnimation.ts +63 -0
  199. package/src/render/shaders/depth.ts +45 -10
  200. package/src/render/shaders/flat/directionalShadow.ts +84 -10
  201. package/src/render/shaders/flat/glassTint.ts +7 -1
  202. package/src/render/shaders/flat/index.ts +63 -7
  203. package/src/render/shaders/flat/layered.ts +115 -0
  204. package/src/render/shaders/flat/main.ts +58 -21
  205. package/src/render/shaders/flat/mapReads.ts +38 -0
  206. package/src/render/shaders/flat/models.ts +1 -1
  207. package/src/render/shaders/flat/movingSun.ts +46 -0
  208. package/src/render/shaders/flat/normalMap.ts +14 -3
  209. package/src/render/shaders/flat/overlay.ts +42 -20
  210. package/src/render/shaders/flat/pointShadow.ts +2 -2
  211. package/src/render/shaders/flat/preamble.ts +59 -27
  212. package/src/render/shaders/flat/worldUv.ts +113 -0
  213. package/src/render/shaders/generated/depth.wgsl.ts +95 -0
  214. package/src/render/shaders/generated/flat.wgsl.ts +3688 -3113
  215. package/src/render/shaders/generated/globalMedium.wgsl.ts +8 -3
  216. package/src/render/shaders/generated/lightVolume.wgsl.ts +16 -11
  217. package/src/render/shaders/globalMedium.ts +9 -1
  218. package/src/render/shaders/lightVolume.ts +9 -1
  219. package/src/render/shaders/outputTransform.ts +30 -14
  220. package/src/render/shaders/tangentFrame.ts +16 -3
  221. package/src/render/shaders/vertexChannel.ts +27 -0
  222. package/src/render/skinnedClothRun.ts +138 -38
  223. package/src/render/staticDraws.ts +95 -0
  224. package/src/render/surfaceLayers.ts +57 -0
  225. package/src/render/surfaceOverlay.ts +24 -7
  226. package/src/render/surfaceProjection.ts +57 -0
  227. package/src/render/surfaceTexture.ts +124 -27
  228. package/src/render/vertexPacking.ts +109 -0
  229. package/dist/render/backend/webgpu/gpuSkinnedCloth.d.ts +0 -74
  230. package/dist/render/backend/webgpu/gpuSkinnedCloth.js +0 -452
  231. package/src/render/backend/webgpu/gpuSkinnedCloth.ts +0 -502
@@ -1,2 +1,2 @@
1
1
  /** The fragment entry point, which assembles everything above into a colour. */
2
- export declare const MAIN_GLSL = "void main() {\n /*\n * Where every material map is read: the texture coordinate, and the layer of the array the face\n * wears, rounded here so an interpolated 2.9999 lands on layer 3 however the generated WGSL\n * turns a float into an array index. A plain texture is an array of one, so layer 0.\n */\n vec3 surfaceAt = vec3(vUv.xy, floor(vUv.z + 0.5));\n if (dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n /*\n * The layer's surface effects, zeros where the albedo array carries no table \u2014 see\n * surfaceEffects.ts. Scroll and flipbook move surfaceAt before any map reads it, so every map of\n * the material moves together. The gradients are taken here, outside every branch, for the room\n * a window samples through textureGrad further down.\n */\n /* Zero rows unless a material has carried a table: `SURFACE_EFFECTS` is off until one does, and\n every effect below reads exactly nothing from zero rows, which the device folds away. */\n fxClear();\n if (SURFACE_EFFECTS) {\n fxLoad(int(surfaceAt.z));\n surfaceAt = fxAnimate(surfaceAt);\n }\n /* An eye's iris, moved by its cornea before any map reads it: models.ts. */\n surfaceAt = modelSurfaceAt(surfaceAt);\n vec2 fxDx = dFdx(surfaceAt.xy);\n vec2 fxDy = dFdy(surfaceAt.xy);\n if (uWriteMode.y != 0.0 && !ditherKeeps(uWriteMode.y)) discard;\n /* A draw's dissolve cuts the surface away before anything is shaded: overlay.ts. */\n if (SURFACE_OVERLAY) {\n if (overlayCuts(surfaceAt.xy)) discard;\n }\n\n /*\n * Surface colour and its coverage, resolved before anything else \u2014 including before it is\n * decided whether lighting runs at all. A thrown-away fragment should not pay for shading\n * it will never contribute to, whichever of the two paths below ends up running, and the\n * cutout is what a decal's edge needs regardless.\n *\n * An implicit-derivative texture() call rather than textureLod, and that is the\n * documented exception the 2026-08-07 rule asks for rather than a lapse: the branch\n * tests a *uniform*, so a compiler can prove it uniform and skip it wholesale, which\n * is precisely what the point-light loop's per-iteration index test could not offer.\n * The derivative is also the point here \u2014 this texture is mipmapped, and a tiled\n * floor at a grazing angle without mip selection aliases and crawls.\n *\n * Sampled once into a local instead of at each use, so the untextured majority pays a\n * single uniform test and the textured case pays a single fetch.\n */\n vec3 albedo = vColor;\n /*\n * How much of this fragment the surface actually covers.\n *\n * One for anything untextured, and the texture's own alpha for the rest. It reaches the\n * output only where a draw is translucent \u2014 an opaque draw has blending off and the\n * blend stage ignores alpha entirely \u2014 so this costs the opaque world nothing, and it\n * is what lets a *decal* work: a stain, a poster, a sticker, whose image is transparent\n * around the mark and partly transparent within it.\n *\n * Without it the cutout was the only thing a texture's alpha could say, so a translucent\n * draw carrying a cutout texture painted its whole quad at the draw's opacity \u2014 a\n * rectangle of haze with a picture in it, rather than a picture.\n */\n float coverage = 1.0;\n if (uMaterialFlags.x != 0) {\n vec4 texel = texture(uAlbedo, surfaceAt);\n // Discarded before anything else is computed: a thrown-away fragment should not pay\n // for the lighting it will never contribute to. The test credits alpha for the mip level\n // it samples, so a needle averaged thin down the chain is not lost; see cutoutCoverage.ts.\n // Behind a branch on a uniform, so a material with no cutoff pays nothing for it.\n float kept = texel.a;\n if (uCutout.x > 0.0) {\n float tested = cutoutAlpha(texel.a, vUv.xy * vec2(textureSize(uAlbedo, 0).xy));\n if (uCutout.y == 0.0) {\n if (tested < uCutout.x) discard;\n } else {\n /*\n * A dithered edge: the pixel is kept by the share of it the texture covers. Under a\n * temporal resolve the test is against noise that moves every frame, and a kept pixel is\n * whole; multisampled, the share is the alpha the GPU turns into coverage. See\n * cutoutDither.ts.\n */\n float share = cutoutShare(tested, uCutout.x);\n if (uCutout.y == 1.0) {\n if (!cutoutKeeps(share, gl_FragCoord.xy, uCutout.z)) discard;\n kept = 1.0;\n } else {\n kept = share;\n }\n }\n }\n albedo *= texel.rgb;\n coverage = kept;\n }\n\n /*\n * Occlusion, roughness and metallic, resolved out here rather than inside the lighting branch.\n *\n * Occlusion applies to an unlit draw as much as to a lit one \u2014 uLightingEnabled at 0 is\n * three.js's meshBasicMaterial and a baked crevice is still a crevice \u2014 so the three values have\n * to exist on both sides of that branch.\n *\n * texture rather than textureLod, under a branch on a *uniform*, exactly as uAlbedo above: the\n * compiler can prove this branch uniform and skip it wholesale, and the derivative is the point,\n * because this image is mipmapped and a tiled floor at a grazing angle without mip selection\n * aliases and crawls. See AGENTS.md, 2026-08-07, for the case where a branch is not uniform and\n * this would be illegal.\n *\n * ormRoughness starts as the attribute and is overwritten rather than chosen by a ternary, so\n * that a draw with no map bound reads exactly what it read before this existed, with nothing to\n * get backwards. The map replaces the attribute rather than scaling it: an absent roughness\n * attribute is 0.4277 and not 1, so scaling would have read glossy on every procedural mesh.\n */\n float ormOcclusion = 1.0;\n float ormRoughness = vRoughness;\n float metal = 0.0;\n if (uMaterialFlags.y != 0) {\n vec3 t = texture(uOrmMap, surfaceAt).rgb;\n ormOcclusion = mix(1.0, t.r, uOrmScale.r);\n ormRoughness = clamp(t.g * uOrmScale.g, 0.0, 1.0);\n metal = clamp(t.b * uOrmScale.b, 0.0, 1.0);\n }\n\n /*\n * The unlit default: exactly the surface's own colour, times its own texture, and nothing\n * else touching it \u2014 three.js calls this `meshBasicMaterial`. This is the value a draw with\n * `uLightingEnabled` at 0 keeps to the end, and the value every lit draw starts from before\n * the branch below builds a shaded result on top of it.\n */\n /*\n * Wear, rain and windows, on the colour and roughness everything below shades. Each is exactly\n * nothing where its numbers are zero, which is every surface of a scene that names no effect.\n */\n fxNormal = normalize(vNormal);\n fxDistance = distance(vWorldPos, uCameraPos);\n vec4 weathered = fxWeather(vWorldPos, albedo, ormRoughness);\n albedo = weathered.rgb;\n ormRoughness = weathered.w;\n vec3 windowGlow = vec3(0.0);\n if (fx0.x > 0.0) {\n vec4 window = fxWindows(surfaceAt, fxDx, fxDy, coverage);\n albedo = mix(albedo, fxRoom * 0.35, window.w);\n windowGlow = window.rgb;\n }\n\n vec3 lit = albedo;\n /* How much of the environment the frame shows here, and how a found reflection would land:\n reflectionSurface.ts. None where nothing is reflected. */\n vec3 reflectedProbe = vec3(0.0);\n vec3 reflectedTint = vec3(0.0);\n float reflectedRoughness = 1.0;\n /* Skin's diffuse, held before emission for the screen-space blur's own pass: models.ts. */\n vec3 diffuseAlone = vec3(0.0);\n /* The light arriving at a glass pane from behind it, gathered in each light loop below and added\n over the see-through at the end. Stays zero for anything that is not glass. See glass.ts. */\n vec3 glassGlow = vec3(0.0);\n float glassTransmission = uSeeThrough.z;\n float glassFrost = uSeeThrough.w;\n\n /*\n * The normal a refracting draw bends by: the shading normal, a normal map's tilt and every relief\n * in it, as the lit block leaves it before turning a back face \u2014 the geometric normal where\n * nothing tilts it, which is what refraction bent by before 4.8.6. See the refraction below.\n */\n vec3 shadingNormal = normalize(vNormal);\n if (uLightingEnabled != 0) {\n vec3 n = normalize(vNormal);\n /*\n * **Which face of a two-sided surface this is, from the normal and not from the rasteriser.**\n * The rasteriser's own facing flag would say it exactly, and it is one more input to the stage: the flat\n * shader already carries sixteen, WebGPU's ceiling, and the seventeenth made every pipeline\n * invalid and every mesh draw nothing. A face whose own normal points away from the eye is the\n * back of the sheet; what that gives up is a sliver at a curved silhouette, where a front face's\n * interpolated normal already leans away and is lit as the back.\n */\n /*\n * **Glass is two-sided whatever its material says**: a pane is symmetric, so from behind the\n * side the eye sees is the one lit, and the light on its far side is what glows through it.\n * Taken from the stored normal alone, a pane seen from behind glowed with the light on the\n * eye's side and not with the sun shining through it.\n */\n bool backFace = (uMaterialFlags.w != 0 || glassTransmission > 0.0) && dot(n, uCameraPos - vWorldPos) < 0.0;\n /*\n * The authored normal, before either relief perturbs it.\n *\n * A map is the truth about which way the surface faces; uRelief invents structure from noise\n * and uTextureRelief reads a height off the albedo's own luminance, and both exist because\n * there was no way to author normals. So the map goes first and relief lays fine structure\n * over whatever it produced \u2014 composing rather than replacing, because a map that switched\n * relief off would silently discard a value the caller set.\n *\n * Gated on a uniform, which is what makes the derivatives inside tangentFrame legal at all \u2014\n * AGENTS.md 2026-08-07 \u2014 and what makes a scene with no normal map pay nothing.\n *\n * texture rather than textureLod, and this is the one place in this shader where that is\n * right: a mipped colour image, sampled under uniform control flow, exactly as uAlbedo is\n * below. The rule is about non-uniform branches, and this branch is on a uniform.\n */\n vec3 unmapped = n;\n if (uNormalStrength > 0.0) n = normalMapped(n, surfaceAt);\n /* A draw's wrinkles, by region, over whatever the map made: overlay.ts. */\n if (SURFACE_OVERLAY) n = overlayWrinkle(n, unmapped, surfaceAt.xy);\n\n /*\n * Microscopic relief: the surface's own texture, as a turn of the normal rather than a change\n * of colour.\n *\n * **This is what makes a road read as asphalt instead of as a grey plane.** Real surfaces are\n * covered in structure too small to model and too large to ignore, and what it does to a\n * picture is change the *direction* light leaves each point, so a coarse surface catches a lamp\n * on one side of every bump and shades on the other. Grain cannot do it: that varies how much\n * light a point takes, which mottles a flat face and washes out at exactly the shallow angles\n * where a road shows its texture most.\n *\n * The bumps are the gradient of the same value noise the grain term uses, sampled three times\n * the shading point. That costs three extra noise evaluations and is gated on the surface\n * having asked, so a world with no textured surfaces pays nothing.\n *\n * **The roughness is widened with it, and that is not optional.** A perturbed normal is a\n * normal that varies fast, and a narrow highlight on a fast-varying normal is the sub-pixel\n * sparkle this engine spent a release fixing. The amplitude here is *known* rather than\n * measured from a screen-space derivative, which is what makes this the safe version: the same\n * widening driven by fwidth was measured making that artefact three times worse, because that\n * derivative spikes at every hard crease. See docs/IMPROVEMENTS.md.\n */\n /* Never below zero: a lightmapped mesh's coordinates ride this lane below it. See lightmap.ts. */\n float reliefAmount = uRelief.x * max(vRelief, 0.0);\n /* Grown with the relief, before anything reads it. See the comment above: a normal that\n wanders cannot hold a highlight narrower than the wander. */\n /*\n * Grown with the relief *and* with the map, before anything reads it. A normal that wanders\n * cannot hold a highlight narrower than the wander, and a strongly mapped surface wanders as\n * surely as a procedurally relieved one \u2014 uTextureRelief already widens by its own amount for\n * exactly this reason, and a map left out of it sparkles.\n */\n float surfaceRoughness = clamp(\n ormRoughness + reliefAmount * RELIEF_ROUGHNESS + uNormalStrength * RELIEF_ROUGHNESS,\n 0.0,\n 1.0\n );\n /*\n * How much of the noise one pixel covers, and the fade that follows from it.\n *\n * **Point-sampled procedural detail aliases the moment a pixel is wider than a bump**, and\n * this had no defence against that at all. At the 60 cycles a metre a road asks for, a\n * carriageway seen at a grazing angle puts tens of bumps inside one fragment and takes a\n * single gradient from somewhere inside them; which one it lands on changes per pixel and\n * per frame, so the surface reads as salt-and-pepper noise rather than as aggregate. It is\n * worst exactly where a street scene spends most of its pixels: far away and near edge-on.\n *\n * The footprint is the world-space pixel size in noise cells, so the fade is stated in the\n * units the artefact is actually in \u2014 a road fades at the distance a bump stops being\n * resolvable, whatever the cycle count, the camera or the resolution.\n *\n * **Computed here rather than inside the branch below, and that is required rather than\n * tidy.** `fwidth` is a derivative and `vRelief` is a varying, so a derivative taken under\n * `if (reliefAmount > 0.0)` sits in non-uniform control flow: undefined in GLSL and refused\n * outright by WGSL. `shadowFactor` in this file carries the long version of that rule and\n * the shader that could not be translated until it was obeyed. `uLightingEnabled` above is\n * a uniform, so this is legal where it stands.\n *\n * The cost is one `fwidth` on every lit fragment, including those carrying no relief.\n * **What would make it wrong is reading this as the fix for sparkle**: it is not, and the\n * roughness widening above deliberately keeps the *unfaded* amount. Unresolved normal\n * variance is still roughness \u2014 a road that faded to smooth at distance would go glossy\n * instead of noisy, which is a worse artefact wearing better clothes.\n */\n vec3 reliefPixel = fwidth(vWorldPos) * uRelief.y;\n float reliefFootprint = max(max(reliefPixel.x, reliefPixel.y), reliefPixel.z);\n float reliefResolved = reliefAmount * (1.0 - smoothstep(0.5, 1.5, reliefFootprint));\n if (reliefResolved > 0.0) {\n vec3 at = vWorldPos * uRelief.y;\n /* One cell of the noise, which is one bump: the gradient over any smaller step is the\n interpolant's own slope rather than the shape's. */\n float here = grain(at);\n float dx = grain(at + vec3(0.5, 0.0, 0.0)) - here;\n float dy = grain(at + vec3(0.0, 0.5, 0.0)) - here;\n float dz = grain(at + vec3(0.0, 0.0, 0.5)) - here;\n vec3 slope = vec3(dx, dy, dz);\n /* Only the part across the surface tilts it; pushing along the normal would move the point\n rather than turn it, which a shading normal cannot express. */\n slope -= n * dot(slope, n);\n n = normalize(n - slope * reliefResolved * RELIEF_TILT);\n }\n\n /*\n * And the relief the bound texture itself carries, as its own luminance read as a height.\n *\n * **Both conditions are uniforms, which is what makes the derivatives below legal.** A branch\n * on a uniform is uniform control flow; `shadowFactor` above carries the long version of this\n * rule and the shader that could not be translated until it was obeyed. The four `texture`\n * calls are inside that same uniform branch, so the implicit level AGENTS.md's 2026-08-07\n * rule is about is well defined here too.\n *\n * **A basis from the screen rather than from a tangent attribute.** Turning a gradient\n * measured in UV into a tilt in world space needs to know which way U and V point on the\n * surface, and `MeshData` carries no tangents. Deriving them per fragment from how world\n * position and UV change across the quad needs nothing from the geometry, so this reaches\n * every mesh in every consumer rather than only remade ones \u2014 and it is the construction\n * three.js's own `perturbNormalArb` uses, so a surface ported from there is lit by the same\n * arithmetic instead of by an approximation of it.\n *\n * `det` carries the basis's own scale, so the normal is built as `abs(det) * n - slope`\n * and normalized: dividing the gradient by `det` instead would blow up on a fragment where\n * the surface is edge-on and the quad's footprint collapses.\n */\n if (uMaterialFlags.x != 0 && uTextureRelief != 0.0) {\n /*\n * **A centred difference, not three.js's forward one, and that is what makes the two\n * backends agree.** WebGL2's framebuffer counts y upward and WebGPU's counts it downward,\n * so `dFdy` of anything points the opposite way on the two \u2014 which the construction below\n * is built to survive, since `sign(det)` flips with it and cancels. A *forward* height\n * difference breaks that cancellation, because `h(uv + d) - h(uv)` is not the negative of\n * `h(uv - d) - h(uv)` anywhere the picture curves. Measured on the proof page: the y\n * component of the perturbed normal disagreed between the backends by a mean of 18 of 255\n * against 4 for the two components `dFdx` drives, with no dependence on pixel parity, so\n * it was the direction rather than the precision. A centred difference is exactly\n * antisymmetric, the cancellation holds, and it is the more accurate estimate besides: a\n * forward difference reports the slope half a step away from the point it is used at.\n */\n vec2 duvdx = dFdx(vUv.xy);\n vec2 duvdy = dFdy(vUv.xy);\n /* Half, because a centred difference spans two steps and three.js's number means one. */\n float reliefPerStep = uTextureRelief * 0.5;\n float dHdx = (surfaceHeight(texture(uAlbedo, vec3(vUv.xy + duvdx, surfaceAt.z)).rgb)\n - surfaceHeight(texture(uAlbedo, vec3(vUv.xy - duvdx, surfaceAt.z)).rgb)) * reliefPerStep;\n float dHdy = (surfaceHeight(texture(uAlbedo, vec3(vUv.xy + duvdy, surfaceAt.z)).rgb)\n - surfaceHeight(texture(uAlbedo, vec3(vUv.xy - duvdy, surfaceAt.z)).rgb)) * reliefPerStep;\n\n vec3 dpdx = dFdx(vWorldPos);\n vec3 dpdy = dFdy(vWorldPos);\n vec3 acrossV = cross(dpdy, n);\n vec3 acrossU = cross(n, dpdx);\n float det = dot(dpdx, acrossV);\n vec3 slope = sign(det) * (dHdx * acrossV + dHdy * acrossU);\n /*\n * **Capped at RELIEF_MAX_TILT, and that is not tuning.** This is *microscopic* relief:\n * structure too small to model, standing on geometry that is still the real shape. Turned\n * further than 45\u00B0 it stops being that and starts lighting a face from behind its own\n * surface, which no amount of texture detail is evidence for. And past that point the\n * expression is near degenerate \u2014 `slope` has grown to the size of the term it is\n * subtracted from, so the normalized result swings a long way for a small change in\n * either, which is the fast-varying normal this engine already spent a release removing\n * the sparkle from. The cap binds only where the picture is a cliff; at the amplitudes a\n * photograph of a real surface produces, it never binds at all.\n */\n float lean = length(slope);\n float limit = abs(det) * RELIEF_MAX_TILT;\n if (lean > limit) slope *= limit / lean;\n n = normalize(abs(det) * n - slope);\n /* Widened for the same reason the procedural relief above widens it, and it is no more\n optional here: a normal that wanders cannot hold a highlight narrower than the wander.\n The amplitude is the caller's own stated one rather than a screen-space measurement,\n which is the condition that made that widening safe there. */\n surfaceRoughness = clamp(\n surfaceRoughness + abs(uTextureRelief) * RELIEF_ROUGHNESS,\n 0.0,\n 1.0\n );\n }\n\n /*\n * **The back face of a two-sided surface is lit as its front**, which is glTF's rule for\n * `doubleSided`: the whole shading normal turned toward the viewer, after the map and the\n * relief have perturbed it, as three.js turns its normal, tangent and bitangent together.\n * Turning only the interpolated normal would leave a normal map's tilt pointing the wrong way\n * across the fold of every curtain.\n */\n shadingNormal = n;\n if (backFace) n = -n;\n /*\n * A model's surface, gathered once, on a branch on the switches alone (models.ts) \u2014 and whether\n * there is one, asked once: every site below is a branch on these two, which a pipeline with no\n * model folds away, and each extra call was a copy of its plumbing in all sixteen permutations.\n */\n bool modelled = modelOn();\n bool modelledBehind = modelSeesBehind();\n if (modelled) modelSurface(n, albedo, surfaceRoughness, metal, surfaceAt);\n /*\n * Skin's colour half leaves here, with the colour its spread light is multiplied by, and a whole\n * alpha a multisampled edge resolves to its coverage. A branch on the switches alone, so uniform.\n */\n if (modelAlbedoAlone()) {\n outColor = vec4(albedo, 1.0);\n return;\n }\n\n /*\n * One dot product, used twice: ndl is the lit side and its negative is the night side below.\n * Shared rather than issued twice because it is the same number and one is cheaper, and not\n * because it fixes anything. It was tried as a fix and measured: it does not. See the night\n * term for what it does not fix.\n */\n float sunDot = dot(n, uDirectionalDir);\n float ndl = max(sunDot, 0.0);\n /*\n * Kept as its own value rather than folded into direct, because the emissive\n * term below needs it too. The sun and the moon are the only sources whose shadow\n * used to stop at a glowing surface.\n */\n /*\n * **The surface's own n\u00B7l, before any map tilted it: the side of the surface the sun is on.**\n *\n * A normal map says which way each texel faces within its surface; it cannot put a texel on the\n * other side of that surface from the sun. Read from the mapped normal alone, the texels of a\n * column's face turned from a high sun that leaned into the light were lit as though the column\n * were not in the way: a field of white specks over stone in shade. So the shadow's band is\n * chosen by this, and the sun is taken away as it reaches zero, over the last two hundredths of\n * n\u00B7l, where an unmapped surface's own Lambert term is already under 0.02. Over a tenth it moved\n * every smooth column's terminator in the published scenes. What it gives up is the bump-lit\n * relief of a surface barely facing the sun, where the light was already grazing.\n */\n vec3 surfaceN = normalize(vNormal);\n if (backFace) surfaceN = -surfaceN;\n float surfaceNdl = dot(surfaceN, uDirectionalDir);\n#if DIRECTIONAL_SHADOWS\n float sunShade = shadowFactor(max(surfaceNdl, 0.0));\n /* What the sun keeps through any glass on its way here: see sunGlassLookup. */\n vec3 sunGlass = sunGlassTint(max(surfaceNdl, 0.0));\n#else\n float sunShade = 1.0;\n vec3 sunGlass = vec3(1.0);\n#endif\n vec3 sunColor = uDirectionalColor * sunGlass;\n /*\n * **How much sky this vertex can see, applied to the sun and to nothing else.**\n *\n * It multiplies sunShade rather than direct because the two are the same kind of quantity --\n * occlusion of the one directional source -- and because direct is not the only thing that\n * reads it: the sun's specular lobe below multiplies by sunShade on its own, so scaling\n * direct alone would leave an enclosed face with no sunlight and a sun highlight anyway.\n *\n * **Ambient is deliberately untouched, and that is the defect this lane exists to fix.** The\n * only per-vertex channel that reached shading before was colors, and the shader reads\n * albedo = vColor before lit = albedo * (ambient + sun), so a sky factor carried in the\n * vertex colour scales both terms: an enclosed face is darkened once for having no sky and\n * again for the ambient it should still have received. A consumer hitting this raised their\n * own floor constant to 0.45 to compensate and measured what it cost -- 55% of a chunk's\n * vertices sat between 0.10 and 0.20 -- which is contrast they could not get back.\n *\n * 1.0 for every mesh that carries no channel, so this is a multiply by one on every scene\n * that shipped before it.\n */\n sunShade *= vSkyDirect;\n /*\n * And the sun gone from a surface turned from it, whatever its map says. See `surfaceNdl`.\n * Its own factor rather than folded into sunShade, which the emissive share reads as the sun's\n * shadow: a lamp's pane turned from the moon is not in shadow, it is only unlit.\n */\n float sunFacing = smoothstep(0.0, 0.02, surfaceNdl);\n float direct = ndl * sunShade * sunFacing;\n /* Glass: the sun on the far side of a pane lights it through the glass. See glassGlow. */\n if (glassTransmission > 0.0) glassGlow += sunColor * max(-surfaceNdl, 0.0) * sunShade;\n\n /*\n * Hemispheric: sky above, ground below, mixed by which way the surface looks. An\n * upward face takes the sky colour, a downward face the ground's, and a wall the\n * average \u2014 which is what makes a floor and a ceiling in the same room read as\n * different materials rather than the same one at different brightness.\n */\n vec3 ambient = mix(uAmbientGround, uAmbient, n.y * 0.5 + 0.5);\n#if ENVIRONMENT_PROBE\n /*\n * The room's diffuse light, read from the probe array.\n *\n * **It replaces the hemispheric term where a grid exists, rather than lifting it.** What stood\n * here until 2026-08-25 was a mix toward `textureLod(uEnvironment, n, maxLod - 3)`, guarded\n * by a `max` so a *partial* probe could not darken a subject: a box-filtered level three\n * below the coarsest is a sample of the room rather than an integral over it, and a plain mix\n * toward one rendered a night courtyard black. What stood here after that was nine\n * spherical-harmonic coefficients, which is the integral and needed a readback to produce.\n *\n * This is the same integral again, convolved into a level of the array at bake time and read\n * with a fetch. **The clamp went with the fit that needed it**: a second-order projection can\n * go slightly negative where the environment is strongly directional, and a cosine convolution\n * of a non-negative environment cannot.\n *\n * `uProbeGridAmbient` is 0 when a caller declined the diffuse half, and the whole expression\n * is multiplied away there \u2014 so a scene that passed `irradiance: false` keeps the gradient it\n * set, which is the guarantee that option was added to give.\n */\n vec3 irradiance = gridIrradiance(n, vWorldPos);\n irradiance = mix(irradiance, ambient, probeGridOutside(surfaceN, vWorldPos));\n ambient = mix(ambient, irradiance, uProbeGridAmbient * uEnvironmentEnabled);\n#endif\n /* A draw's own ambient, where one is set, in place of the frame's: `setAmbientSH`. */\n if (uAmbientSH[6].w > 0.5) ambient = ambientHarmonics(n);\n /*\n * A metal keeps its ambient and loses its direct diffuse.\n *\n * **Written as one term because the honest two cancel.** The spelling this stands for is\n * albedo * (1 - metal) * (ambient + D * direct) + metal * albedo * ambient: a metal's reduced\n * diffuse, plus the ambient floor that stops a rough one going black once its diffuse is gone\n * and its reflection is scaled by (1 - roughness). Their two ambient contributions are\n * (1 - metal) * ambient and metal * ambient, which sum to ambient exactly, so all of it\n * reduces to this line.\n *\n * At metal 0 that is one multiply by 1.0 away from the expression it replaces, which is the\n * smallest change available in a shader with a documented history of shared subexpressions\n * rounding differently when their neighbours move. See the night-emissive term.\n */\n /*\n * A model answers the sun itself, and is shadowed as it says (models.ts). The standard line\n * stands as it was, so a pipeline with no model compiles to exactly the arithmetic before.\n */\n vec3 sunModelSpecular = vec3(0.0);\n if (modelled) {\n modelLight(uDirectionalDir, 0.0, 0.0);\n float sunModelFacing = modelledBehind ? 1.0 : sunFacing;\n lit = modelAmbient(ambient) + sunColor * mDiffuse * modelShade(sunShade) * sunModelFacing;\n sunModelSpecular = mSpecular * sunColor * sunShade * sunModelFacing;\n } else {\n lit = albedo * (ambient + sunColor * direct * (1.0 - metal));\n }\n /* A baked page's light, beside the sky's and the sun's: nothing but where a material has one. */\n lit += modelBaked(n, albedo, metal);\n\n /*\n * A sun highlight, for the few surfaces that ask for one.\n *\n * Blinn-Phong against the dominant source, which at night is the moon \u2014 so a polished\n * prop catches whatever is in the sky rather than only the day. It takes the same\n * sunShade the diffuse term takes, because a highlight that survives a shadow is\n * the clearest possible tell that lighting is faked.\n *\n * vSpecular is zero for every mesh that did not supply the attribute \u2014 the vast\n * majority \u2014 so for the rest of the world this is one multiply by a constant zero.\n */\n vec3 halfway = normalize(uDirectionalDir + normalize(uCameraPos - vWorldPos));\n /*\n * A metal's highlight takes its own colour; a dielectric's takes the light's.\n *\n * That is the single most visible thing metalness does, and it is why gold under a white lamp\n * reads as gold rather than as beige plastic with a white dot on it. vSpecular is this\n * engine's dielectric reflectance \u2014 a look control rather than an F0, as specularLobe's own\n * header says at length \u2014 so the mix runs from it to the albedo rather than from 0.04.\n *\n * mix(x, y, 0) is exactly x, so at metal 0 this is vec3(vSpecular) and the line below is the\n * one it replaces with the scalar broadened to three components.\n */\n vec3 specColor = mix(vec3(vSpecular), albedo, metal);\n /*\n * **A metal's highlight goes white at a grazing angle, and that is most of what reads as\n * polished.**\n *\n * Schlick says reflectance climbs to 1 at the edge whatever the material is, so a red metal\n * shows a red highlight face-on and a *white* one along every panel edge. Without this the\n * highlight is albedo-tinted at every angle, and a dark red suit gets a dark red highlight\n * that reads as matte paint however smooth the map says it is. The environment term below has\n * carried its own Fresnel since it was written; the direct term never had one.\n *\n * Scaled by metal, so at metal 0 this is mix(specColor, white, 0) \u2014 specColor exactly, and the\n * dielectric case is the expression it replaces. A dielectric's F0 is already so low that the\n * engine's vSpecular look control stands in for the whole term, which is the reading\n * specularLobe's header argues for at length.\n */\n float sunVoH = max(dot(normalize(uCameraPos - vWorldPos), halfway), 0.0);\n vec3 sunSpec = highlightTint(specColor, sunVoH, metal);\n /*\n * **Held back until after the environment blend, because that blend is a mix and would\n * otherwise erase this.**\n *\n * The reflection below is written as `mix(lit, environment, w)`, so everything already in\n * `lit` is scaled by `1 - w`. On a metal `w` reaches 1, and a highlight added here is\n * multiplied by zero: the surface with the *most* reason to show a specular streak is the one\n * guaranteed not to. That is the single most visible difference between a bought PBR model\n * here and the same model in its own reference render, where every panel edge carries a white\n * streak brighter than the paint under it.\n *\n * The lamps have always added their highlight after this block; the sun was the odd one out,\n * and only because the block moved beneath it. Both are specular reflections of a source, and\n * a reflection of the room cannot subtract one.\n *\n * Zero for the whole world that carries no specular attribute and no metalness, exactly as\n * before: `sunSpec` is `vec3(vSpecular)` at metal 0 and `vSpecular` defaults to 0.\n */\n /* GGX's own where the surface asks (`physicalSpecular`): the sun is a point, so nothing widens it. */\n float sunNdh = max(dot(n, halfway), 0.0);\n float sunLobe = physicalSpecular()\n ? physicalLobe(sunNdh, ndl, dot(n, normalize(uCameraPos - vWorldPos)), surfaceRoughness, 0.0, 1.0)\n : specularLobe(sunNdh, surfaceRoughness);\n vec3 sunHighlight = sunColor * sunLobe * sunSpec * sunShade * sunFacing;\n if (modelled) sunHighlight = sunModelSpecular;\n /*\n * The dielectric's share goes in here, where it has always gone, so the environment blend\n * below dims it by exactly the reflectance it always dimmed it by. The metal's share is added\n * after that blend instead \u2014 see the line that does it for why the two cannot be one.\n */\n lit += sunHighlight * (1.0 - metal);\n\n /*\n * Procedural grain on the surfaces that asked for it, so a face is not one flat colour.\n *\n * This is what \"texture\" means in an engine that has none: the variation is computed\n * from world position rather than sampled, so it costs no upload, no memory and no\n * sampler, and it cannot be seen to tile. Grain is the half of a realistic material\n * that the colours alone cannot supply \u2014 a mineral surface is not uniform, and at\n * these sizes the eye reads\n * uniformity as plastic before it reads anything else.\n *\n * World-space rather than object-space, so the grain sits still in the world while a\n * prop turns through it \u2014 which is how a solid looks, and what stops the pattern\n * reading as something painted on and rotating with the object.\n *\n * Inside the branch on purpose. It is pure ALU with no texture fetch, so a\n * non-uniform branch is safe here (AGENTS.md, 2026-08-07 \u2014 that rule is about\n * implicit derivatives, which this has none of), and almost nothing in the world\n * carries a grain attribute at all.\n *\n * The amount is what the surface itself declared, and the third answer to a question\n * that was guessed twice. Gating on vSpecular meant \"this is stone\" only while stone\n * was the only shiny thing in the world, so an imported car came out sanded. Weighting\n * by vRoughness was closer and still a proxy: painted plaster is rough and has no\n * grain, polished granite is smooth and has plenty, so a painted tower at roughness 0.55\n * took 55% grain and read as marble. The two properties are independent and no weighting\n * between them can be right. uGrain remains as the pass-level scale over whatever the\n * geometry said, for an imported model that declares nothing.\n */\n if (vGrain > 0.0 && uGrain > 0.0) {\n float grainMix = mix(\n grain(vWorldPos * GRAIN_SCALE),\n grain(vWorldPos * GRAIN_COARSE_SCALE),\n GRAIN_COARSE_MIX\n );\n lit *= mix(1.0, mix(GRAIN_FLOOR, GRAIN_CEIL, grainMix), uGrain * vGrain);\n }\n\n /*\n * Environment reflection, Fresnel weighted.\n *\n * The sky and ground colours the ambient term already carries, sampled along the mirror\n * direction rather than the normal, so the reflection swings across a curved panel as the\n * eye moves instead of sitting still on it. That movement is most of what reads as gloss.\n *\n * Fresnel because every dielectric reflects far more at a grazing angle than head on:\n * about four percent facing the eye and nearly all of it at the edge, which is the bright\n * rim along a wing and the reason a windscreen turns into a mirror from the side. Scaled\n * by smoothness, since a rough surface scatters the same energy into no image at all.\n *\n * **Two sources, and which one is used is the caller's decision rather than a guess.** With a\n * reflection probe baked, the mirror direction samples the room itself and a car body carries\n * the panels above it and the walls around it. Without one, it samples the sky and ground\n * colours the ambient term already holds: an approximation, stated as one, with no scene in\n * it, which restores the tonal range a mirror surface has and cannot show anything.\n */\n /*\n * **Or an ORM map, because a metal is nothing but its reflection.**\n *\n * This read uReflectivity alone, which is a per-pass control a consumer sets from the\n * container's own scalar \u2014 and that scalar is 0 for exactly the materials that carry their\n * metalness in a map, because a per-texel value cannot be expressed as a per-draw one. So the\n * whole block was skipped for a fully metallic surface and max(uReflectivity, metal) inside it\n * was never reached. A chromed subject came out near black with a baked probe sitting unread.\n *\n * **Gated on the ORM flag rather than on metal, and that is required rather than tidy.** The\n * block takes fwidth of the mirror direction, and a derivative may only be taken under uniform\n * control flow. The flag is a uniform; metal is a texture fetch, so branching on it would\n * put that derivative in non-uniform control flow and WGSL refuses the module outright. See\n * AGENTS.md, 2026-08-07. The per-texel decision still happens, one line down, in the amount.\n *\n * With the flag at 0 this is uReflectivity > 0.0 exactly, so nothing that binds no map takes a\n * branch it did not take before.\n */\n /* A model reflects its environment whatever the pass asked of the standard model: see\n modelReflectance. On the switches alone, so the derivatives below stay in uniform flow. */\n if (uEnvironmentDials.y > 0.0 || uMaterialFlags.y != 0 || modelled) {\n vec3 toEye = normalize(uCameraPos - vWorldPos);\n vec3 mirrored = reflect(-toEye, modelled ? modelReflectNormal(n) : n);\n vec3 environment = mix(uAmbientGround, uAmbient, mirrored.y * 0.5 + 0.5);\n#if ENVIRONMENT_PROBE\n /*\n * Rougher surfaces read a blurrier level, which is what separates satin from lacquer, and\n * **no level sharper than this pixel can resolve**, which is what stops a lacquered panel\n * sparkling.\n *\n * Roughness alone answers only half the question. It says how blurred the *material* makes a\n * reflection; it says nothing about how much of the cubemap lands inside one pixel. Where a\n * panel creases or a shut line runs, the mirror direction swings a long way between\n * neighbouring fragments, so at 0.025 for lacquered paint each of them reads a texel far from\n * its neighbour's at the sharpest level there is. A street of lit windows then lands in some\n * pixels and not the ones beside them: single bright dots tracing every crease, every gap and\n * every silhouette, worst on dark paint at night, and gone the moment you move close enough\n * for that curvature to span several pixels. **That is a sampling rate, not the model.**\n *\n * Two consumers reported it and it reproduces in demo/nightStreet.ts. It also looks exactly\n * like anti-aliasing being off, and is not: multisampling resolves *coverage*, so it cannot\n * touch a value that varies too fast inside one triangle.\n *\n * The footprint is fwidth of the mirror direction, turned into texels through the face size\n * the chain implies: a face spans a right angle, so texels per radian is that size over \u03C0/2.\n * The blurrier of the two levels wins. Nothing else about the term moves, deliberately \u2014\n * widening the *material's* roughness from the same derivative was tried and measured worse,\n * because it also dims the reflection at exactly the creases it was meant to settle.\n *\n * Derivatives are well defined here: uReflectivity is a uniform, so this branch is uniform\n * for every fragment in the draw. See AGENTS.md, 2026-08-07, for the case where it is not.\n */\n /*\n * **Combined in roughness, not floored on a level \u2014 repaired 2026-08-25.**\n *\n * This turned the footprint into texels and took the blurrier of that level and the\n * material's. Against the **box-filtered capture** that was right: a level was a smaller\n * picture. Against the **GGX-prefiltered cube** a level is a *lobe convolved at roughness\n * level/maxLod*, so the same floor stopped saying \"sample more coarsely\" and started saying\n * \"this surface is rough\". Measured on a 512 probe with panels swinging about 0.03 rad a\n * pixel: level 3.3, which under the new chain is an enforced **roughness 0.37 on every metal\n * whatever its map says**. Reported from a consumer as metals reading opaque.\n *\n * So the footprint is the roughness whose lobe is as wide as the pixel, combined in\n * quadrature \u2014 independent angular spreads add that way. A GGX lobe at roughness r has a\n * characteristic half-angle of about r squared, so a footprint of `swing` radians is a\n * roughness of `sqrt(swing)`.\n *\n * **Halved, because `fwidth` is an upper bound rather than the footprint.** It is\n * `abs(dFdx) + abs(dFdy)`, a sum of both screen derivatives rather than the radius of the\n * pixel's cone, and those derivatives are *coarse* on nearly every GPU: one pair per 2x2\n * quad. Together they over-state the cone by about two, and this squares back into a\n * roughness, so a half here is a quarter of the solid angle.\n *\n * **The reason this shape was refused before is gone.** The comment it replaces recorded\n * that widening the material's roughness \"was tried and measured worse, because it also dims\n * the reflection at exactly the creases it was meant to settle\" \u2014 true under the old weight,\n * where `reflectAmount` carried a `1 - roughness` factor. `envSpecularEnergy` removed\n * that: a metal's weight is exactly 1.0 at every roughness, so widening no longer dims.\n *\n * **It widens only the level this picks.** `surfaceRoughness` is untouched, because it also\n * feeds the environment BRDF and the sun's own lobe, and a crease should not grow a wider\n * *direct* highlight for spanning few pixels.\n *\n * **What it costs**: a surface still loses its mirror where it is genuinely under-sampled,\n * which is the sparkle protection doing its job. **What would make it wrong** is a chain\n * whose levels are not roughness \u2014 the capture's own box chain is exactly that.\n */\n /*\n * **The map's edge, not `exp2(uEnvironmentMaxLod)`.** Those were one number while the\n * environment was a cube whose chain ran to a single texel a face. The chain now stops one\n * level below the cosine convolution, so the top level is 4 while the map is 256 across, and\n * the old expression would say sixteen \u2014 which reads as every metal being under-sampled and\n * forced to a roughness its material does not have.\n */\n float faceTexels = uEnvironmentEdge;\n float swingRad = max(length(fwidth(mirrored)), 1e-6);\n /*\n * **Two chains, two ways to pick a level, chosen by `uEnvironmentPrefiltered`.**\n *\n * A *box* level is a smaller picture of the room, so the footprint belongs as a floor on the\n * level: read coarsely enough that a pixel covers about a texel, and lose detail, nothing\n * else. A *prefiltered* level is the room convolved at roughness level/maxLod, so the same\n * floor stops saying \"sample more coarsely\" and starts saying \"this surface is rough\" \u2014\n * measured on a 512 probe with panels swinging 0.03 rad a pixel, an enforced roughness of\n * 0.37 on every metal whatever its map says.\n *\n * So for the lobe chain the footprint is expressed as the roughness whose lobe is as wide as\n * the pixel and combined in quadrature, independent angular spreads adding that way: a GGX\n * lobe at roughness r has a characteristic half-angle of about r squared, so a footprint of\n * `swing` radians is a roughness of `sqrt(swing)`. Halved, because `fwidth` is\n * `abs(dFdx) + abs(dFdy)` over *coarse* derivatives \u2014 one pair per 2x2 quad \u2014 so it\n * over-states the pixel's cone by about two, and this squares back into a roughness.\n *\n * **What it costs** is both expressions evaluated and one selected, which is a handful of\n * multiply-adds against a uniform. **What would make it wrong** is a third kind of chain.\n */\n float boxLod = max(surfaceRoughness * uEnvironmentMaxLod, log2(max(swingRad * faceTexels * 0.6366, 1.0)));\n float footprintRough = 0.5 * sqrt(min(swingRad, 1.0));\n float lobeRough = min(1.0, sqrt(surfaceRoughness * surfaceRoughness + footprintRough * footprintRough));\n float envLod = clamp(\n mix(boxLod, lobeRough * uEnvironmentMaxLod, uEnvironmentDials.x),\n 0.0,\n uEnvironmentMaxLod\n );\n vec3 room = gridRadiance(mirrored, envLod, vWorldPos);\n /*\n * **The room wins outright where there is one, and it must.**\n *\n * This was briefly written as `max(environment, room)`, to stop a dim probe leaving a metal\n * darker than the world around it. It does stop that, and it destroys the reflection doing\n * it: the hemispheric term is a two-colour gradient with no image in it whatsoever, and a\n * world's declared ambient is brighter than most of what a probe actually holds \u2014 so the max\n * returns the *gradient* over nearly the whole sphere and a mirror shows a smooth wash. That\n * is the same flat, matte surface this was trying to fix, arrived at from the other side.\n *\n * A floor is right for irradiance, where only the level matters, and wrong for a mirror,\n * where the level is the least of what is being carried. Brightness belongs to\n * `uEnvironmentGain`, which scales what the surface reflects and keeps its image.\n */\n environment = mix(environment, room, uEnvironmentEnabled);\n#endif\n /*\n * Whatever the surface ends up reflecting, at the brightness the caller asked for \u2014 **and\n * weighted by metalness, which is the only case the gain was ever an answer to.**\n *\n * A metal has no diffuse and no ambient behind its reflection: what it reflects is all it\n * is, so when the probe under-reports the room the surface has no other way to be lit. A\n * dielectric is in no such position. Its reflection sits on top of a diffuse term that is\n * already carrying the world's light, and scaling it lifts a surface that was correct \u2014\n * measured on a car painted black, which came out washed grey with a milky haze over every\n * panel, because `fresnel * uReflectivity * (1 - roughness)` reaches most of the way to 1 at\n * a grazing angle and three times the room is what landed there.\n *\n * `mix(1, gain, 0)` is exactly 1, so a dielectric is arithmetically untouched however the\n * caller sets this.\n */\n environment *= mix(1.0, uEnvironmentDials.z, metal);\n float facing = 1.0 - max(dot(n, toEye), 0.0);\n /*\n * **A metal's Fresnel base is close to its albedo, not a dielectric's four percent.** Schlick\n * with a fixed 0.04 is right for glass, plastic and paint and badly wrong for chrome: left\n * there, a smooth metal seen head-on takes a four percent reflection and reads as dark paint.\n *\n * And a metal is nothing but its reflection, so it raises the amount as well as the base \u2014\n * uReflectivity is a per-pass control and a metal texel cannot be left mirroring nothing\n * because the pass it happens to be drawn in did not ask for reflections.\n *\n * Both collapse at metal 0: mix(0.04, 1.0, 0) is 0.04, and max(uReflectivity, 0) is\n * uReflectivity because both renderers clamp that uniform non-negative on upload.\n */\n float f0 = mix(0.04, 1.0, metal);\n float fresnel = mix(f0, 1.0, facing * facing * facing * facing * facing);\n /*\n * **A rough metal is still a metal, and `1 - roughness` said otherwise.**\n *\n * The thinning belongs to the dielectric term, where it stands for a polish that scatters\n * its reflection away into no image at all. A metal has no diffuse to fall back on: this is\n * its entire response to light, and scaling it by `1 - roughness` throws away 40% of\n * everything a surface at roughness 0.4 shows and replaces it with `albedo * ambient`,\n * which is a dark constant. That is the \"reads dark, reads like paint\" report, arithmetically.\n *\n * It is also double counting. The chain the sample comes from *is* the roughness \u2014 a rougher\n * surface reads a blurrier level a few lines above \u2014 so dimming it as well charges twice for\n * one property. A GGX prefilter loses some energy with roughness and it is nothing like this\n * steep; that belongs with the prefilter, when there is one.\n *\n * **Identical at metal 0**: `max(uReflectivity * (1 - r), 0)` is `uReflectivity * (1 - r)`,\n * which is what the old `max(uReflectivity, metal) * (1 - r)` came to, and both renderers\n * clamp that uniform non-negative on upload.\n */\n float reflectAmount = max(uEnvironmentDials.y * (1.0 - surfaceRoughness), metal);\n /*\n * **With a prefiltered environment, how much a surface returns is an integral rather than a\n * curve somebody chose**, and this is where the comment above said the thinning should go\n * once a prefilter existed.\n *\n * `envBrdfApprox` is the split-sum's second half: the convolution decides *what* is\n * reflected at a roughness and this decides *how much*. It contains the angular dependence,\n * so it replaces `fresnel` as well as `1 - roughness` \u2014 keeping the separate Fresnel\n * factor would apply it twice.\n *\n * **Gated on `uEnvironmentEnabled`, and the gate is an argument rather than a way of\n * protecting a pixel count.** The split sum approximates an integral over a *prefiltered*\n * environment. With no probe, `environment` is the two-colour sky-and-ground gradient,\n * which was never integrated over anything and has no chain behind it, so applying a term\n * that assumes one would assert an integral that does not exist. Every scene without a probe\n * therefore keeps exactly the arithmetic it had.\n *\n * **That last clause used to read \"which is every published scene\", and it was wrong.**\n * `nightStreet` bakes at 128 and `showroom` at 256, so two of the seven take this\n * branch. They move no pixels \u2014 measured, 0 of 921,600 on each of the seven \u2014 but only\n * because neither binds an ORM map, so both are dielectrics at `f0` 0.04 where this term\n * is worth hundredths of a level. A claim about a gate is worth checking against the\n * scenes rather than remembering, which is what `docs/README.md` says about every claim\n * of this shape.\n *\n * **Measured rather than assumed, and it moved less than expected.** On the roughness ladder\n * the whole change is at most 0.2 of 255, because a rough surface reads the coarsest levels\n * of the chain and those are dark in a room with one bright wall. The term is nonetheless\n * the right one: it is where a bright loaded environment will show, and it stops a fully\n * rough dielectric reflecting exactly nothing, which `1 - roughness` asserted and which is\n * false of every real surface.\n */\n float ndv = 1.0 - facing;\n /*\n * **`envSpecularEnergy` and not `f0 * dfg.x + dfg.y`, and the difference is a metal\n * looking like paint.** The bare split sum integrates one scattering event and drops every\n * ray that bounces twice inside the microsurface, so it returns `1 - 0.55 * roughness` for\n * a surface at `f0` 1 \u2014 asserting that a perfect mirror absorbs over half the light once it\n * is roughened. Reported from a consumer whose subject is forced fully metallic: measured\n * losses of 22% at roughness 0.4, 33% at 0.6 and 44% at 0.8 against the term this replaced.\n *\n * The compensation returns it, and at `f0` 1 it returns *exactly* 1.0 at every roughness \u2014\n * which is the old `fresnel * reflectAmount` weight for a metal, to the bit. So this is a\n * repair rather than a second look decision, and the arithmetic is in `lobes.ts`.\n */\n /*\n * **Compiled out where there is no probe, and that is worth 4,762 gzipped bytes.**\n *\n * With `ENVIRONMENT_PROBE` off, `prefiltered` was a literal 0 and `mix` returned the\n * left-hand side outright \u2014 so the fit, the compensation and the clamp were computed, and\n * shipped, to be multiplied by nothing in eight of the sixteen permutations. `ARCHITECTURE.md`\n * \u00A71 is why that is not a rounding error: a permutation is larger than deflate's 32 KB window,\n * so sixteen near-identical copies do not dedupe and every consumer downloads all of them.\n *\n * Measured on `core-only`, gzipped: **529,889 ungated, 525,127 gated**, against a floor of\n * 524,402 before any of this. So the energy compensation costs **725 bytes** rather than the\n * 5,487 it costs computed unconditionally \u2014 and the gate pays for more than it: `dfg` moved\n * inside it too, so eight permutations stopped carrying `envBrdfApprox` as well, which they\n * had been doing since the split sum landed. The picture is identical to the bit either way.\n *\n * The prediction written here first was 2,708. It was wrong, and it is replaced by the\n * measurement rather than kept beside it.\n *\n * **What would make this wrong** is a second consumer of `dfg` outside this branch \u2014 a\n * clearcoat lobe wanting the same fit. It would then belong above the `#if` again, and the\n * bytes would be buying something.\n */\n#if ENVIRONMENT_PROBE\n vec2 dfg = envBrdfApprox(ndv, surfaceRoughness);\n float integrated = clamp(envSpecularEnergy(f0, dfg) * max(uEnvironmentDials.y, metal), 0.0, 1.0);\n /*\n * **The split sum only where a prefiltered chain is what is being sampled.** It is the\n * correct share of a *prefiltered* environment; over a box chain it asserts an integral that\n * was never performed, and it flattens the Fresnel sweep that reads as gloss \u2014 measured, the\n * grazing-to-head-on ratio falls from about 15 to 1 down to 5 to 1 at roughness 0.5, which\n * was reported twice as a metal going opaque. With the prefilter off this is exactly the\n * expression the engine carried before the lighting track.\n */\n float weight = mix(\n clamp(fresnel * reflectAmount, 0.0, 1.0),\n integrated,\n uEnvironmentEnabled * uEnvironmentDials.x\n );\n#else\n float weight = clamp(fresnel * reflectAmount, 0.0, 1.0);\n#endif\n if (modelled) weight = modelReflectance(weight);\n if (modelled) environment = modelEnvironment(environment);\n lit = mix(\n lit,\n environment * mix(vec3(1.0), albedo, metal),\n weight\n );\n reflectedTint = mix(vec3(1.0), albedo, metal) * weight;\n reflectedProbe = environment * reflectedTint;\n reflectedRoughness = surfaceRoughness;\n }\n /* The reflection pass's surface half leaves here, before the lamps: reflectionSurface.ts. */\n if (REFLECTION_SURFACE) {\n reflectionSurfaceOut(reflectedProbe, reflectedTint, reflectedRoughness, ormOcclusion,\n reflectionAir());\n return;\n }\n /*\n * The sun's highlight, on top of whatever the surface reflects rather than underneath it \u2014\n * **and only by as much as the surface is metal.**\n *\n * The metal half is the fix: the blend above reaches 1 on a metal, so a highlight added before\n * it was multiplied by zero and the surface with the most reason to show a specular streak was\n * the one guaranteed not to.\n *\n * The `(1 - metal)` half above is the part that must not move. A dielectric's blend weight is\n * `fresnel * uReflectivity * (1 - roughness)`, which on polished paint or glass reaches most\n * of the way to 1 at a grazing angle \u2014 and adding the highlight after the blend rather than\n * into it takes a lobe that was correctly dimmed by the surface's own reflectance and puts it\n * back at full strength. On a near-black glass lens carrying `specular` 1 that is a white\n * blob where there was none. So the dielectric keeps the arithmetic it has always had, exactly:\n * at metal 0 the line below adds nothing and the line above is the original expression.\n */\n lit += sunHighlight * metal;\n\n /*\n * Self-illuminated geometry. Additive and gated on the night factor, so it is\n * exactly invisible by day and costs two ALU ops.\n *\n * Dimmed in shadow, but only partly, and the fraction is the whole argument. Strictly,\n * emission is light leaving a surface and an occluder between that surface and the sun\n * has no business touching it \u2014 which is what this used to do, adding the full term\n * after the shadow. The result is that a shadow crossing the terrace darkens the deck\n * and leaves every inlaid band at full brightness, so the bands stop belonging to the\n * floor they are cut into. It gets reported as the shadow passing *under* the inlaid\n * markings, as though it could not be cast over them at all.\n *\n * That is a real break in the picture, and the physical reading is not much\n * of a defence, because none of these surfaces is a pure emitter: a lit inlay is worked\n * stone with light coming *through* it, and the stone around the light is in shadow like\n * everything else. So the shadow takes a share and leaves the rest. At 0.55 a band in\n * shade is visibly in shade and still visibly lit, which is what it is.\n */\n /*\n * Deferred to *after* the point lights, so their shadows can reach it. Addition\n * commutes; the order is only about what is known by then.\n */\n float lightShade = 1.0;\n\n /*\n * **The lamps' light twice: once as it arrives, once as their shadows leave it.**\n *\n * Every point light's contribution used to go straight into `lit`. It is accumulated here\n * instead, in two sums that differ only by the shadow term, so that after the loop there is\n * a single number per channel saying how much of the lamps' light survived \u2014 and *that* is\n * the thing the resolve below can average across a pair of pixels without touching albedo,\n * a highlight, or anything else the frame has in it.\n *\n * **The two sums are not an approximation.** `lampOpen` multiplied by\n * `lampShadowed / lampOpen` is `lampShadowed`, per channel, whatever mixture of lamps\n * and shadows produced it. With the resolve switched off this is arithmetically the line it\n * replaces; a lamp that is shadowed and a lamp beside it that is not keep their own shares.\n *\n * **What it costs** is two vector adds per light and six floats of live state. **What would\n * make it wrong** is a term wanting to sit between the lamps and `lit` \u2014 a per-light\n * tone map, say \u2014 because these two are summed before anything can get between them.\n */\n vec3 lampOpen = vec3(0.0);\n vec3 lampShadowed = vec3(0.0);\n /* `DRIFT_LIGHT` is off until a consumer gives the renderer a DriftLight volume, and the device\n compiles the volume's reads away with it. */\n float driftShare = 0.0;\n if (DRIFT_LIGHT) {\n driftShare = driftLightShare(vWorldPos);\n }\n\n /*\n * Which froxel this fragment is in.\n *\n * Derived in view space rather than from `gl_FragCoord`, for the reason `uView`'s own\n * comment gives: the two backends disagree about which way a framebuffer's y runs, and a tile\n * row taken from a window coordinate would be mirrored on one of them.\n *\n * The arithmetic is `sliceOfViewDepth` and `tileOf` from `clusteredLights.ts`, in the\n * same order and with the same clamps, because a fragment reading a froxel the binner filled\n * differently is a fragment lit by somebody else's lights.\n */\n /*\n * **Only in a build that clusters, and there only in a frame that does.** `CLUSTERED_LIGHTS` is\n * the profile's `clusteredLights`, a pipeline constant the device compiles this whole arm away\n * on: the view transform, two logarithms and the froxel fetch below ran on every lit pixel of\n * every scene, clustered or not, and the arm's records held their registers through the loop.\n */\n int clusterBaseTexel = 0;\n int clusterLights = 0;\n bool clusteredArm = false;\n if (CLUSTERED_LIGHTS) {\n if (uClustered != 0) {\n vec3 clusterView = (uView * vec4(vWorldPos, 1.0)).xyz;\n float clusterDepth = -clusterView.z;\n float clusterHalfH = max(clusterDepth, 1e-4) * uClusterFrustum.z;\n float clusterHalfW = clusterHalfH * uClusterFrustum.w;\n int clusterTileX = clamp(\n int(floor((clusterView.x / clusterHalfW + 1.0) * 0.5 * float(CLUSTER_X))), 0, CLUSTER_X - 1);\n int clusterTileY = clamp(\n int(floor((clusterView.y / clusterHalfH + 1.0) * 0.5 * float(CLUSTER_Y))), 0, CLUSTER_Y - 1);\n int clusterSlice = 0;\n if (clusterDepth > uClusterFrustum.x) {\n clusterSlice = clamp(\n int(floor(log(clusterDepth / uClusterFrustum.x)\n / log(uClusterFrustum.y / uClusterFrustum.x) * float(CLUSTER_Z))),\n 0, CLUSTER_Z - 1);\n }\n clusterBaseTexel = LIGHT_REGION_TEXELS\n + (clusterTileX + clusterTileY * CLUSTER_X + clusterSlice * CLUSTER_X * CLUSTER_Y)\n * CLUSTER_TEXELS;\n clusterLights = int(clusterTexel(clusterBaseTexel).x);\n clusteredArm = true;\n }\n }\n\n /* The fixture atlas's size, asked once: its cookie band and its profile rows are found from it. */\n vec2 fixtureSize = vec2(textureSize(uFixtureAtlas, 0));\n /*\n * Whether any light can carry a measured profile or a cookie, decided once rather than per\n * light. `LIGHT_FIXTURES` is off until a consumer loads one, and the device compiles the\n * fixture lookups away with it, which they hold their registers for in every scene otherwise.\n */\n bool fixtureProfiles = false;\n bool fixtureShapes = false;\n if (LIGHT_FIXTURES) {\n fixtureProfiles = uFixtureShape.x > 1.0;\n fixtureShapes = uFixtureShape.y > 1.0 || uFixtureShape.z > 0.0;\n }\n\n // Point lights. Inverse-square-ish falloff clipped to a finite radius, so a\n // light can be culled without a visible seam.\n for (int slot = 0; slot < LIGHT_LOOP_MAX; slot++) {\n /*\n * **The two arms differ in where a light's numbers come from and in nothing else.**\n *\n * Everything below reads `lightPos`, `lightColor`, `lightRadius`,\n * `lightSourceRadius`, `lightWeight` and `shadowSlot`, so the clustered path is a\n * different *source* for the same six values rather than a second copy of the shading. That\n * is what makes the zero-pixel gate between the two arms mean something: if the body were\n * duplicated, the gate would only be comparing two transcriptions.\n */\n int i;\n vec3 lightPos;\n float lightRadius;\n vec3 lightColor;\n float lightSourceRadius;\n float lightWeight;\n int shadowSlot;\n vec3 lightDir;\n vec2 lightCone;\n float iesProfile;\n vec3 iesAxis;\n float cookie;\n /* The light's lighting channels, a mask: 1 unless it names others. See `uLightIesAxis`. */\n float lightChannels = 1.0;\n /* The light's own falloff exponent, or 0 for the frame's falloff. See `uLightCone`. */\n float lightExponent = 0.0;\n /* A clustered rectangle's barn doors: their cosine and length, none at 0. See `areaBarnDoors`. */\n vec2 areaDoors = vec2(0.0);\n /*\n * **A rectangle in the froxel table** \u2014 one past the fixed arm's `maxAreaLights` \u2014 which\n * its record says with a negative radius, its reach negated. Its fields come out of the\n * fixture texels: sidedness, the right axis, the half extents and the up axis.\n */\n bool clusteredArea = false;\n float areaTwoSided = 0.0;\n if (clusteredArm) {\n if (slot >= clusterLights) break;\n /*\n * Four indices to a texel. Selected with compares rather than by indexing the vector,\n * because a dynamic component index is legal in GLSL ES 3.00 and is one more thing for a\n * translator to disagree about on the one path that has no fallback beneath it.\n */\n uvec4 packedIndices = clusterTexel(clusterBaseTexel + 1 + (slot >> 2));\n int component = slot & 3;\n uint packed = component == 0\n ? packedIndices.x\n : component == 1 ? packedIndices.y : component == 2 ? packedIndices.z : packedIndices.w;\n i = int(packed);\n int record = i * LIGHT_TEXELS;\n uvec4 rec0 = clusterTexel(record);\n lightPos = vec3(\n uintBitsToFloat(rec0.x), uintBitsToFloat(rec0.y), uintBitsToFloat(rec0.z));\n lightRadius = uintBitsToFloat(rec0.w);\n clusteredArea = lightRadius < 0.0;\n lightRadius = abs(lightRadius);\n /*\n * **Out of reach is decided on the first texel, before the rest of the record is read.**\n * A froxel holds every light whose sphere touches its box, so most of the lights a fragment\n * walks never reach it, and each one used to cost all five texels before the falloff below\n * said so. Both falloffs are zero at and past the radius, so this skips only lights the\n * `continue` after the falloff would have skipped anyway: 17.5 ms of a 4K main pass with\n * 320 lamps was 12.3 with this and the profile guard below, byte for byte the same picture.\n */\n if (length(lightPos - vWorldPos) >= lightRadius) continue;\n /*\n * **The second texel, and for a plain point light the last one.** The emitter size and the\n * weight are two halves in one word, which is how nine numbers fit in two texels;\n * `unpackHalf2x16` widens them exactly, and the fixed arm reads the same two values rounded\n * the same way, so the arms stay one picture. See `packSizeAndWeight`.\n */\n uvec4 rec1 = clusterTexel(record + 1);\n lightColor = vec3(\n uintBitsToFloat(rec1.x), uintBitsToFloat(rec1.y), uintBitsToFloat(rec1.z));\n vec2 sizeAndWeight = unpackHalf2x16(rec1.w);\n lightSourceRadius = abs(sizeAndWeight.x);\n lightWeight = sizeAndWeight.y;\n /*\n * **The fixture, read only for a light that has one.** What a plain light's record holds in\n * the last four texels is exactly these values, so a light carrying no shadow slot, cone,\n * profile, cookie, channels or falloff of its own shades the same whether they are read or assumed, and at heavy overlap\n * reading them was a third of the pass. A branch on a value from a texture around\n * `texelFetch` is not the 2026-08-07 case: an integer fetch takes no derivative.\n *\n * **Inside a branch on `uClustered` first**, which is 2 only in a frame where some light\n * carries a fixture. The test on each light's flag is a branch on a value read from a\n * texture, which a compiler may run both sides of, and it cost 1.3 ms of a 4K pass whose 320\n * lamps were all plain; a uniform branch costs nothing, so such a frame never reaches it.\n */\n shadowSlot = -1;\n lightDir = vec3(0.0);\n lightCone = vec2(-1.0, -2.0);\n iesProfile = -1.0;\n iesAxis = vec3(0.0);\n cookie = -1.0;\n if (uClustered == 2) {\n if ((rec1.w & LIGHT_FIXTURE_FLAG) != 0u) {\n uvec4 rec2 = clusterTexel(record + 2);\n uvec4 rec3 = clusterTexel(record + 3);\n uvec4 rec4 = clusterTexel(record + 4);\n uvec4 rec5 = clusterTexel(record + 5);\n shadowSlot = clusteredArea ? -1 : int(uintBitsToFloat(rec2.x));\n areaTwoSided = uintBitsToFloat(rec2.x);\n lightDir = vec3(\n uintBitsToFloat(rec2.y), uintBitsToFloat(rec2.z), uintBitsToFloat(rec2.w));\n lightCone = vec2(uintBitsToFloat(rec3.x), uintBitsToFloat(rec3.y));\n iesProfile = uintBitsToFloat(rec3.z);\n cookie = uintBitsToFloat(rec3.w);\n /* The photometric azimuth. See LIGHT_RECORD in clusteredLights.ts for why it costs a\n texel rather than being derived from the direction. */\n iesAxis = vec3(\n uintBitsToFloat(rec4.x), uintBitsToFloat(rec4.y), uintBitsToFloat(rec4.z));\n lightChannels = uintBitsToFloat(rec4.w);\n lightExponent = uintBitsToFloat(rec5.x);\n areaDoors = vec2(uintBitsToFloat(rec5.y), uintBitsToFloat(rec5.z));\n }\n }\n } else {\n i = slot;\n if (i >= uLightCount || i >= MAX_LIGHTS) break;\n lightPos = uLightPos[i];\n lightRadius = uLightRadius[i];\n lightColor = uLightColor[i];\n lightSourceRadius = uLightSourceRadius[i];\n lightWeight = uLightWeight[i];\n shadowSlot = i;\n lightDir = uLightDir[i];\n lightCone = uLightCone[i].xy;\n lightExponent = uLightCone[i].z;\n iesProfile = uLightIesProfile[i];\n iesAxis = uLightIesAxis[i].xyz;\n lightChannels = uLightIesAxis[i].w;\n cookie = uLightCookie[i];\n }\n /*\n * **A light shades a surface only where their channels meet**: the light's mask against the\n * material's. Both default to channel 1, so a scene naming none takes every light as before\n * \u2014 and **zero reads as that default on both sides**, so a pass that never writes the lane\n * loses no light: an unwritten uniform is zero, and zero here would be every lamp switched\n * off. A branch on a value read from a texture in the clustered arm, as the reach test above\n * is, and it skips only what the light would add; no derivative is taken past it.\n */\n int litChannels = int(lightChannels);\n int surfaceChannels = int(uCutout.w);\n if (((litChannels == 0 ? 1 : litChannels) & (surfaceChannels == 0 ? 1 : surfaceChannels)) == 0) {\n continue;\n }\n /*\n * **A clustered rectangle is shaded here and goes no further**: by the fixed loop's own body\n * (`areaLight.ts`), unshadowed \u2014 its occlusion is the fixed arm's, which is why the first\n * `maxAreaLights` stay there \u2014 and windowed to nothing at its reach, as a lamp is, so the\n * froxel it is binned into has no edge in the picture.\n */\n if (clusteredArea) {\n vec3 seenCentre = lightPos;\n vec2 seenHalf = lightCone;\n if (!areaBarnDoors(seenCentre, lightDir, iesAxis, seenHalf, areaDoors)) continue;\n float areaForm = areaSignedForm(n, seenCentre, lightDir, iesAxis, seenHalf);\n float areaWindow = clamp(1.0 - pow(length(lightPos - vWorldPos) / max(lightRadius, 1e-4), 4.0), 0.0, 1.0);\n areaForm = mix(max(0.0, areaForm), abs(areaForm), areaTwoSided);\n if (areaForm > 0.0) {\n areaLightAdd(\n seenCentre, lightDir, iesAxis, seenHalf, lightColor * (areaWindow * areaWindow), areaForm,\n 1.0, vec3(1.0), n, albedo, metal, specColor, surfaceRoughness, modelled,\n lampOpen, lampShadowed\n );\n }\n continue;\n }\n /* A DriftLight field's own light carries a negative weight, and is shaded exactly only for the\n share of this pixel the field does not stand in for. See `driftLight.ts`. */\n if (lightWeight < 0.0) lightWeight = -lightWeight * (1.0 - driftShare);\n /*\n * **The cone, and a point light's is one that admits every direction.**\n *\n * `lightCone` is the cosine of the inner angle then of the outer, and a light that declared\n * no cone carries -1 and -2 \u2014 so the dot product, which is at least -1 for every direction\n * on the sphere, is always at or past the upper edge and the term is exactly 1. Not\n * approximately: `smoothstep` returns its upper bound outright once the argument reaches\n * it, with no arithmetic in between, which is what makes every scene with no spot light\n * bit-identical to the build before spot lights existed.\n *\n * **Not written as a branch on whether the light is a spot.** In the clustered arm that\n * would be a branch on a value read from a texture, which is not provably uniform control\n * flow, and the shadow fetches below sit inside this same loop \u2014 the 2026-08-07 rule is\n * about exactly that. Two multiply-adds are cheaper than the flattening a compiler would do\n * anyway.\n *\n * **What it costs** is that the edge is a smoothstep in the cosine rather than in the angle,\n * so the falloff is very slightly biased toward the outer edge. **What would make it wrong**\n * is a cone narrow enough for that bias to be visible, which is a few degrees, and at that\n * width the shadow map's resolution is the larger problem.\n */\n vec3 toLight = lightPos - vWorldPos;\n float dist = length(toLight);\n /* The direction the light travels, which is what a cone is measured against. */\n float coneFalloff =\n smoothstep(lightCone.y, lightCone.x, dot(-toLight / max(dist, 1e-4), lightDir));\n /*\n * Two ways for a lamp to fade, and the difference is a look rather than a detail.\n *\n * The default shapes the falloff to the radius: it reaches zero exactly at the edge, so\n * a light can be culled with no seam, and it stays gentle near the source. That is the\n * right behaviour for a world of large soft sources and it is what every world here is\n * authored against.\n *\n * Inverse-square is what a physically-based renderer does, and it is *violent* close\n * in: at a tenth of a metre it is a hundred times the value at one metre, so a lamp\n * mounted a few centimetres under a ceiling floods it and the panel around it reads as\n * lit rather than as a dark plate with a bright strip in it. Windowed by the radius the\n * same way three.js windows its own, so the light still ends where it says it does.\n */\n float falloff;\n /*\n * **A light may name its own falloff, and then it is Unreal's for a light without\n * inverse-square falloff**: `(1 - (d/R)^2)^n` with no distance term, so a fill authored to\n * light a whole hall across a hundred metres does, where the inverse square would have it\n * gone a few metres out. Zero is the frame's own rule below, which every light was before.\n */\n if (lightExponent > 0.0) {\n float reach = dist / max(lightRadius, 1e-4);\n falloff = pow(clamp(1.0 - reach * reach, 0.0, 1.0), lightExponent);\n } else if (uLightFalloff == 1) {\n float window = clamp(1.0 - pow(dist / max(lightRadius, 1e-4), 4.0), 0.0, 1.0);\n falloff = window * window / max(dist * dist, 0.01);\n } else {\n falloff = clamp(1.0 - dist / lightRadius, 0.0, 1.0);\n }\n if (falloff <= 0.0) continue;\n float ndl = max(dot(n, toLight / max(dist, 1e-4)), 0.0);\n /*\n * **A lamp behind a pane is not skipped when the surface is glass**: it lights the pane through\n * the glass, shaped and shadowed like any lamp, and is added to glassGlow once its shape is\n * known below. For everything else a lamp behind the surface is skipped here, as always.\n */\n float backNdl =\n glassTransmission > 0.0 ? max(-dot(n, toLight / max(dist, 1e-4)), 0.0) : 0.0;\n /* A model that sees light from behind keeps the lamps behind the surface. See models.ts. */\n if (ndl <= 0.0 && backNdl <= 0.0 && !modelledBehind) continue;\n\n /*\n * Normal-offset bias.\n *\n * The deck is a single enormous flat plane, so the light grazes nearly all\n * of it: one shadow texel covers a long stretch whose depth changes far\n * faster than any depth bias can absorb. Pushing the *sample position*\n * along the surface normal instead moves the receiver clear of its own\n * occluder in the direction that actually matters, and it scales with how\n * grazing the angle is. Without it the cubemap's face seams show as a\n * diagonal cross and the whole plane bands.\n */\n /* How present this light's shadow is. See `POINT_SHADOW_MIN_WEIGHT`. */\n float shadowWeight = clamp(falloff * 2.0, 0.0, 1.0);\n#if POINT_SHADOWS\n float grazing = 1.0 - ndl;\n float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;\n vec3 receiverPoint = vWorldPos + n * normalOffset;\n\n /*\n * uPointShadowWeight is how present the cubemap is, and it is the difference\n * between a shadow arriving and a shadow appearing. A pool slot changing hands\n * throws its image away, so the map behind a light can go from absent to complete\n * between two frames; sampling it at full strength the frame it lands is a shadow\n * switching on under a lamp that never moved. It ramps instead.\n */\n float occl = 1.0;\n /*\n * One lookup, where there were ten unrolled else-if arms.\n *\n * GLSL ES cannot index a sampler array with a non-constant expression, so twelve cubemaps\n * meant twelve declared samplers and a chain comparing this light against each slot's\n * owner. One array texture makes the map a *layer* \u2014 an ordinary integer \u2014 so the light\n * indexes the arrays directly and the chain is a compare against -1.\n */\n /*\n * **Indexed by the shadow slot, not by the light.** Clustering lifts how many lights shade a\n * fragment and not how many shadow maps exist \u2014 the pool is a memory budget \u2014 so a clustered\n * light either holds one of these slots or casts none, and `-1` says which. `clamp` keeps\n * the read in range for a light with no slot, whose `layer` is then forced negative anyway;\n * indexing a uniform array with -1 is undefined and would be a picture rather than an error.\n *\n * **And in range at the other end, which a shrunken budget is what made reachable.** A\n * clustered light carries its slot in the froxel record rather than in `i`, so a build with\n * `maxLights` below `MAX_LIGHTS_PER_CLUSTER` can be handed a slot past the end of these\n * arrays \u2014 undefined again, and again as a picture. Past the end is treated as *no shadow*\n * rather than clamped onto the last slot: a light wearing another light's occlusion is a\n * wrong picture, where a light that occludes nothing is the engine's own stated trade\n * everywhere else it runs out of room.\n */\n int shadowRead = clamp(shadowSlot, 0, MAX_LIGHTS - 1);\n int layer = shadowSlot >= 0 && shadowSlot < MAX_LIGHTS ? uPointShadowLayer[shadowRead] : -1;\n /* What the panes between this receiver and the lamp let through, per map: lampGlass.ts. */\n vec3 staticGlass = vec3(1.0);\n vec3 liveGlass = vec3(1.0);\n if (layer >= 0 && shadowWeight > POINT_SHADOW_MIN_WEIGHT) {\n staticGlass = pointGlassTint(\n float(layer),\n receiverPoint - uPointShadowProjection[shadowRead].xyz,\n uPointShadowProjection[shadowRead].w,\n uPointShadowNear[shadowRead],\n uPointShadowSize[shadowRead]\n );\n occl = mix(\n 1.0,\n pointShadow(\n uPointShadows,\n float(layer),\n /*\n * **From where the map was baked, not from where the light is.** The two are apart\n * by up to `pointShadowRebakeDistance` for anything that wanders, and both things\n * this asks the picture move with that gap: the distance it compares against the\n * stored one, and which texel the single-tap blocker search lands in. See\n * `uPointShadowOrigin`.\n */\n receiverPoint - uPointShadowProjection[shadowRead].xyz,\n uPointShadowProjection[shadowRead].w,\n uPointShadowNear[shadowRead],\n uPointShadowSize[shadowRead]\n ),\n uPointShadowWeight[shadowRead]\n );\n }\n\n // A live map contains movers only. Multiplication composes their occlusion\n // with the immutable world map, while the weight makes ownership changes a\n // crossfade rather than switching a whole character shadow in one frame.\n float liveOccl = 1.0;\n float liveWeight = 0.0;\n int liveLayer =\n shadowSlot >= 0 && shadowSlot < MAX_LIGHTS ? uLivePointShadowLayer[shadowRead] : -1;\n if (liveLayer >= 0 && shadowWeight > POINT_SHADOW_MIN_WEIGHT) {\n liveOccl = pointShadow(\n uPointShadows,\n float(liveLayer),\n /* Its own bake origin, for the reason the static layer above gives. */\n receiverPoint - uLivePointShadowProjection[shadowRead].xyz,\n uLivePointShadowProjection[shadowRead].w,\n uLivePointShadowNear[shadowRead],\n uLivePointShadowSize[shadowRead]\n );\n liveWeight = uLivePointShadowWeight[shadowRead];\n liveGlass = pointGlassTint(\n float(liveLayer),\n receiverPoint - uLivePointShadowProjection[shadowRead].xyz,\n uLivePointShadowProjection[shadowRead].w,\n uLivePointShadowNear[shadowRead],\n uLivePointShadowSize[shadowRead]\n );\n }\n occl *= mix(1.0, liveOccl, liveWeight);\n /* Each map's glass faded in by that map's presence, as its occlusion is. */\n vec3 lampGlass = mix(vec3(1.0), staticGlass, uPointShadowWeight[shadowRead]) * mix(vec3(1.0), liveGlass, liveWeight);\n#else\n float occl = 1.0;\n vec3 lampGlass = vec3(1.0);\n#endif\n\n // Blend occlusion out as the light fades, so a shadow can never be more\n // present than the light casting it \u2014 and slot changes at the edge of\n // range become invisible rather than a pop.\n float shaded = mix(1.0, occl, shadowWeight);\n /*\n * The cone multiplies the distance falloff rather than replacing it, which is what makes a\n * spot a point light with a direction rather than a second kind of light. It is exactly 1\n * for a light that declared no cone, so this line is arithmetically the one it replaces.\n */\n /*\n * **What a real fixture throws, rather than what a cone approximates.**\n *\n * An IES profile is a measured intensity by angle from the fixture's axis, so this is the\n * same angle the cone is measured at, read out of one row of the atlas. A light with no\n * profile carries a negative index and takes 1, which is the multiplicative identity and\n * costs a fetch nobody can avoid \u2014 see below.\n *\n * **The fetch is unconditional and `textureLod`, both deliberately.** This loop is not\n * provably uniform control flow: in the clustered arm the light index comes out of a\n * texture. A branch on the profile index would therefore be a non-uniform branch around a\n * sample, which is what the 2026-08-07 rule forbids and what lets a compiler flatten the\n * loop and cost every arm. So the row is clamped to a valid one, the sample always happens,\n * and the result is selected afterwards with a `mix` on a compare \u2014 which is arithmetic\n * rather than control flow.\n *\n * **And skipped outright where nothing loaded a profile**, behind `uFixtureShape.x`, which is\n * one while the atlas holds only its row of ones. A uniform is provably uniform control\n * flow, so this branch is not the one the rule forbids. It was paid on every scene until a\n * 4K frame of 320 lamps and no shadows measured it at 0.7 ms of the main pass, rising to\n * 2.3 ms with the lamps overlapping, which is the scene this note used to say would make\n * the fetch wrong. Every light then reads 1, which is what `step(0.0, iesProfile)` gave\n * each of them anyway.\n */\n float iesRow = max(iesProfile, 0.0);\n /* The direction the fixture throws toward this fragment, which both angles are measured on. */\n vec3 iesRay = -toLight / max(dist, 1e-4);\n /* The angle from the fixture's axis, as a fraction of the 0-to-180 arc every row spans. */\n float iesU = 0.0;\n if (fixtureProfiles) {\n float iesAngle = acos(clamp(dot(iesRay, lightDir), -1.0, 1.0));\n /* Clamped to the row's first and last texel centres, which is what the edge clamp of a\n texture exactly one row wide gave; the atlas can be wider now, with cookies beside it. */\n iesU = clamp(iesAngle / PI_IES * IES_WIDTH, 0.5, IES_WIDTH - 0.5) / fixtureSize.x;\n }\n /*\n * **The azimuth about the fixture's own axis**, behind a branch on a uniform so a scene whose\n * profiles are all axially symmetric pays none of this arithmetic. A `uniform float` is\n * provably uniform control flow \u2014 no wavefront can diverge on it \u2014 so this is not the\n * 2026-08-07 case, which is about a branch a compiler *cannot* prove uniform.\n *\n * The reference is orthogonalised against the aim rather than assumed perpendicular to it,\n * so a caller may hand the fixture's own forward; one parallel to the aim leaves nothing to\n * project and stays on the first plane rather than producing a NaN.\n */\n float iesTurns = 0.0;\n vec3 cookieTint = vec3(1.0);\n /*\n * **The fixture's own frame, built once and shared by the azimuth and the cookie**, because\n * both are asking the same question about a fixture \u2014 which way is up \u2014 and both read\n * `uLightIesAxis` to answer it. Behind a branch on two uniforms, so a scene using neither\n * builds no frame at all.\n */\n if (fixtureShapes) {\n vec3 iesRef = iesAxis - lightDir * dot(iesAxis, lightDir);\n float iesRefLen = length(iesRef);\n if (iesRefLen > 1e-5) {\n iesRef /= iesRefLen;\n vec3 iesBinormal = cross(lightDir, iesRef);\n if (uFixtureShape.y > 1.0) {\n /* `atan` answers -PI..PI and `fract` of a negative wraps forward, so this is 0..1\n across the whole turn without a branch on the sign. */\n iesTurns = fract(atan(dot(iesRay, iesBinormal), dot(iesRay, iesRef)) / TAU_IES);\n }\n if (uFixtureShape.z > 0.0) {\n /*\n * **A perspective projection onto the plane one unit down the aim**, which is what a\n * fixture actually throws: the mask grows with distance rather than being pasted on\n * at a fixed size.\n *\n * Scaled by the *outer cone's* tangent so a cookie fills whatever cone it is given \u2014\n * a mask drawn once works in a narrow spot and a wide one. A light with no cone\n * carries a cosine of -2 and the `max` below turns that into zero rather than a NaN;\n * such a light also carries no cookie, so the result is discarded either way.\n */\n float cookieAlong = max(dot(iesRay, lightDir), 1e-4);\n float cosOuter = lightCone.y;\n float tanOuter = sqrt(max(1.0 - cosOuter * cosOuter, 0.0)) / max(cosOuter, 1e-3);\n vec2 fromAxis =\n vec2(dot(iesRay, iesRef), dot(iesRay, iesBinormal)) / cookieAlong;\n vec2 cookieUv = fromAxis / max(tanOuter, 1e-3) * 0.5 + 0.5;\n /*\n * Clamped inside the tile rather than wrapped: past the cone edge the cone term is\n * already zero, so what matters is only that the sample cannot reach the neighbouring\n * cookie \u2014 which a bilinear fetch at a seam otherwise does.\n */\n float cookieTile = max(cookie, 0.0);\n vec2 cookieAt = vec2(\n (cookieTile + clamp(cookieUv.x, COOKIE_INSET, 1.0 - COOKIE_INSET)) * COOKIE_SPAN\n / fixtureSize.x,\n clamp(cookieUv.y, COOKIE_INSET, 1.0 - COOKIE_INSET) * COOKIE_SPAN / fixtureSize.y);\n /* Unconditional and `textureLod`, and selected with a `mix` afterwards: the light\n loop is not provably uniform control flow \u2014 the clustered arm reads its index from a\n texture \u2014 so a branch on the *light's* cookie index around a sample is what the\n 2026-08-07 rule forbids. */\n vec3 cookieSample = textureLod(uFixtureAtlas, cookieAt, 0.0).rgb;\n cookieTint = mix(vec3(1.0), cookieSample, step(0.0, cookie));\n }\n }\n }\n /*\n * **One fetch, and the atlas's own linear filter does the blend between planes.**\n *\n * A profile occupies `planes + 1` rows with the last repeating the first, so a fractional\n * row inside a profile is exactly the blend wanted and the wrap from the last plane back to\n * the first is a blend into that repeat rather than a bleed into the next profile. The\n * version this replaced took two explicit fetches and a `mix`, and cost **18,315 gzipped\n * bytes on `core-only`** across the sixteen fragment permutations.\n */\n float photometric = 1.0;\n if (fixtureProfiles) {\n float iesBase = iesRow * (uFixtureShape.y + 1.0);\n /* The profiles are the atlas's bottom rows, below any cookies. */\n float iesTop = fixtureSize.y - uFixtureShape.x;\n float iesV = (iesTop + iesBase + iesTurns * uFixtureShape.y + 0.5) / fixtureSize.y;\n float iesGain = textureLod(uFixtureAtlas, vec2(iesU, iesV), 0.0).r;\n photometric = mix(1.0, iesGain, step(0.0, iesProfile));\n }\n\n /* A light's own exponent already is its whole curve; the frame's smooth rule squares its own. */\n float shape =\n (lightExponent > 0.0 || uLightFalloff == 1 ? falloff : falloff * falloff) *\n coneFalloff *\n photometric;\n glassGlow += lightColor * backNdl * shape * lightWeight * shaded * lampGlass;\n if (ndl <= 0.0 && !modelledBehind) continue;\n /*\n * The cookie tints rather than scales, because a mask may be coloured \u2014 a stained window is\n * the case that makes the difference \u2014 and a greyscale one is exactly a scale. Applied to\n * the light's colour rather than to `shape`, which is a scalar and could not carry it.\n */\n lightColor *= cookieTint;\n /*\n * A model's answer to this lamp, held back and shadowed exactly as the standard terms below\n * are \u2014 the shadowed sum through `modelShade`, which is where skin's penumbra reddens.\n */\n if (modelled) {\n modelLight(toLight / max(dist, 1e-4), lightSourceRadius, dist);\n vec3 lampModelLight = lightColor * shape * lightWeight;\n lampOpen += (mDiffuse + mSpecular) * lampModelLight;\n lampShadowed += (mDiffuse * modelShade(shaded) + mSpecular * shaded) * lampModelLight * lampGlass;\n lightShade = min(lightShade, mix(1.0, shaded, lightWeight));\n continue;\n }\n /*\n * **A metal has no diffuse term, and this lamp kept giving it one.**\n *\n * The sun's diffuse has been multiplied by `(1 - metal)` since metalness arrived. The lamps'\n * never was, and it is added *after* the environment blend, so it could not even be dimmed\n * by the reflection standing in front of it: every point light in a scene laid a full\n * Lambertian wash over a surface that physically reflects none of it, on top of everything\n * else the surface showed.\n *\n * **That wash is what a metal looked like.** A broad, soft, view-independent term is the\n * definition of matte, and in a room lit by its own fixtures it is most of the pixel \u2014 which\n * is why a chromed subject read as painted plastic in a lamp-lit hall, a garage and a night\n * street alike, and why raising what it reflected by four times moved almost nothing. The\n * reflection was not missing; it was being drowned.\n *\n * The light is not lost. What a metal does with a lamp arrives through the highlight below,\n * which is already metal-aware, and through the room it mirrors, which is where a lamp's\n * energy belongs on a metal.\n *\n * This was written once before and taken back out, because removing it alone made a metal\n * darker with nothing to replace what it lost. What replaces it is `uEnvironmentGain`: a\n * consumer whose rooms are darker than the surfaces standing in them can raise what those\n * surfaces reflect, rather than lighting a metal in a way metals are not lit.\n *\n * At metal 0 this is one multiply by 1.0 away from the expression it replaces.\n */\n /*\n * Held back from `lit` until the loop is over, so the shadow can be resolved across a\n * pair of pixels first. See the block below the loop; nothing else about this term moved.\n */\n vec3 lampDiffuse = albedo * lightColor * ndl * shape * lightWeight * (1.0 - metal);\n lampOpen += lampDiffuse;\n lampShadowed += lampDiffuse * shaded * lampGlass;\n\n /*\n * A lamp reflected in a polished surface, which point lights could not do at all.\n *\n * The specular term used to answer only to the dominant directional source, so a world\n * with no sun in it had no highlights in it either. That is fine outdoors and wrong\n * everywhere else: a waxed floor under a strip light reflects the *strip*, and it is\n * most of what tells you the floor is polished rather than merely pale. An interior lit\n * entirely by its own fixtures had no way to say so.\n *\n * Gated on vSpecular, which almost nothing carries, so the rest of the world pays one\n * compare per light. Shares the sun's exponent and the same shadow and weight terms, so\n * a highlight cannot outlive the light casting it or survive a light fading out.\n */\n /*\n * The metal half is there because vSpecular defaults to 0 and every mesh meshBuilder builds\n * takes that default, so a metal gated on the attribute alone would take no highlight from\n * any lamp in the scene. The condition is the old one exactly when metal is 0, so nothing\n * that binds no map takes a different branch than it took before.\n */\n if (vSpecular > 0.0 || metal > 0.0) {\n vec3 toEyeLamp = normalize(uCameraPos - vWorldPos);\n vec3 lampHalfway = normalize(toLight / max(dist, 1e-4) + toEyeLamp);\n /* The same grazing whitening the sun's highlight takes, and zero at metal 0 for the look. */\n vec3 lampSpec = highlightTint(specColor, max(dot(toEyeLamp, lampHalfway), 0.0), metal);\n /* The look's lobe, or GGX's own where the surface asks for it: see `physicalLobe`. */\n float lampNdh = max(dot(n, lampHalfway), 0.0);\n float lampLobe = physicalSpecular()\n ? physicalLobe(lampNdh, ndl, dot(n, toEyeLamp), surfaceRoughness, lightSourceRadius, dist)\n : sphereLobe(lampNdh, surfaceRoughness, lightSourceRadius, dist);\n /* Held back with the diffuse half, and shadowed by the same term it always was. */\n vec3 lampHighlight = lightColor * lampLobe * lampSpec * shape * lightWeight;\n lampOpen += lampHighlight;\n lampShadowed += lampHighlight * shaded * lampGlass;\n }\n /*\n * The most any light in range shadows this point, for the emissive term below \u2014\n * weighted, because this one is not a colour multiply and would otherwise ignore\n * the ramp entirely. A light arriving at zero brightness would still switch a\n * shadow onto the marked line it is arriving over, which is precisely the surface\n * it was first watched happening on.\n */\n lightShade = min(lightShade, mix(1.0, shaded, lightWeight));\n }\n\n /*\n * **Rectangular area lights: an exact diffuse integral and an approximate specular.**\n *\n * The diffuse half is the closed form of the cosine-weighted integral over a polygon, which is\n * what a linearly transformed cosine reduces to when its matrix is the identity \u2014 so this is\n * not an approximation of it, it *is* it. The specular half is a representative point handed to\n * `sphereLobe`, which widens a lobe by the source's subtended angle and renormalises its\n * energy; that is the approximate half, and it diverges from a fitted LTC at grazing angles\n * where the real lobe stretches along the view. Both are stated at their definitions.\n *\n * **A loop over a count rather than a permutation**, and the arithmetic is on record: thirty\n * lines of shared shader cost 6,215 gzipped bytes across the sixteen permutations this session\n * and fifty cost 10,517, while the fifth flag clustered lighting nearly took would have cost\n * 196,910. Compiled in unconditionally this is about a twentieth of what permuting it costs,\n * and a count of zero is what makes it free at runtime.\n *\n * **It occludes, as of 2026-08-28.** It used to not, and the note here said so: the pool is\n * octahedral and centred on a point, and a rectangle is not one. What settled it is that a\n * rectangle *does* have a point \u2014 its centre \u2014 and that the only thing separating an area\n * shadow from a point one is where the filter's width comes from. So the bake is a point\n * light's bake and the filter is an ellipse the size of the emitter; see `areaShadow` for\n * what that is exact about and what it approximates. Off unless a rectangle asks, because\n * every scene that already declared one was authored against a light that passed through\n * stone.\n */\n for (int a = 0; a < MAX_AREA_LIGHTS; a++) {\n if (a >= uAreaLightCount) break;\n vec3 centre = uAreaLightPos[a];\n vec3 right = uAreaLightRight[a];\n vec3 up = uAreaLightUp[a];\n vec2 halfSize = uAreaLightSize[a].xy;\n /*\n * **The part of the rectangle this fragment sees past its barn doors** (`areaBarnDoors`),\n * which its light and highlight come from. Its shadow keeps the whole rectangle's centre and\n * size below, because that is what its map was baked from.\n */\n vec3 seenCentre = centre;\n vec2 seenHalf = halfSize;\n if (!areaBarnDoors(seenCentre, right, up, seenHalf, uAreaLightSize[a].zw)) continue;\n\n /* The form factor and its sign, which is the sidedness: see `areaSignedForm`. */\n float signedForm = areaSignedForm(n, seenCentre, right, up, seenHalf);\n float form = mix(max(0.0, signedForm), abs(signedForm), uAreaLightTwoSided[a]);\n /* The form factor is linear in the normal, so the one seen from behind is its negation. */\n float backForm = glassTransmission > 0.0\n ? mix(max(0.0, -signedForm), abs(signedForm), uAreaLightTwoSided[a])\n : 0.0;\n if (form <= 0.0 && backForm <= 0.0) continue;\n\n /*\n * **How much of the rectangle this fragment can actually see past whatever is in the way.**\n *\n * One octahedral layer baked from the rectangle's *centre*, filtered by an ellipse whose axes\n * are the rectangle's own \u2014 see `areaShadow`, which is where the emitter's shape enters.\n * Multiplies both halves below, because a surface in shadow is in shadow for its highlight\n * too: occluding only the diffuse term leaves a specular streak hanging in mid-air where the\n * caster is, which is the shape the water renderer's own notes call light passing through\n * stone.\n *\n * **Two layers, multiplied.** The static one holds the world and is baked once for a fixture\n * that does not move; the live one holds movers only and is refreshed every frame. Their\n * product is the composition, exactly as the point lights above compose theirs \u2014 a person\n * walking under a lit awning is in the live layer and the awning's posts are in the static\n * one, and neither map has to know about the other.\n *\n * **Not folded into `lightShade`**, which is what dims a self-lit surface when a lamp's\n * shadow crosses it. That term needs a per-light \"is this light arriving\" weight so a light\n * at zero brightness cannot switch a shadow onto a glowing inlay, and the point lights have\n * one in their own range ramp. An area light's `form` is an irradiance fraction rather than\n * a presence: it is 1 for a rectangle filling the sky and a few thousandths for a bright\n * panel across a room, so using it directly barely dims anything, and any threshold over it\n * is a constant nobody has measured. `docs/IMPROVEMENTS.md` carries the row.\n */\n float areaOccl = 1.0;\n vec3 areaGlass = vec3(1.0);\n#if POINT_SHADOWS\n /*\n * The sample position is pushed along the surface normal before the lookup, and it scales\n * with how grazing the light is \u2014 the same cure and the same expression the point path uses a\n * few hundred lines up. Depth bias alone cannot fix a grazing receiver without peter-panning,\n * because one shadow texel then covers a stretch of floor whose depth changes faster than any\n * constant. The direction to the rectangle's *centre* is what \"grazing\" means here, which is\n * the same centre the map was baked from.\n */\n vec3 areaCentreVec = centre - vWorldPos;\n float areaCentreDist = length(areaCentreVec);\n float areaCentreNdl = areaCentreDist > 1e-4\n ? max(dot(n, areaCentreVec / areaCentreDist), 0.0)\n : 0.0;\n float areaGrazing = 1.0 - areaCentreNdl;\n float areaNormalOffset = (0.06 + areaCentreDist * 0.05) * areaGrazing * areaGrazing + 0.02;\n vec3 areaShadowFrom = (vWorldPos + n * areaNormalOffset) - centre;\n\n /*\n * `-1` is a rectangle with no image: one that declined to cast, one that named no range,\n * or one whose first bake has not landed yet. A layer index rather than a flag, for the\n * reason the point path's is one \u2014 GLSL ES cannot index a sampler array with a non-constant\n * expression, and a layer is an ordinary integer.\n */\n int areaLayer = uAreaShadowLayer[a];\n if (areaLayer >= 0) {\n areaOccl = mix(\n 1.0,\n areaShadow(\n uPointShadows,\n float(areaLayer),\n areaShadowFrom,\n uAreaShadowFar[a],\n uAreaShadowNear[a],\n right,\n up,\n halfSize\n ),\n uAreaShadowWeight[a]\n );\n /* What the panes before the rectangle let through, faded in as its occlusion is. */\n areaGlass = mix(\n vec3(1.0),\n areaGlassTint(float(areaLayer), areaShadowFrom, uAreaShadowFar[a], uAreaShadowNear[a], right, up, halfSize),\n uAreaShadowWeight[a]\n );\n }\n int areaLiveLayer = uLiveAreaShadowLayer[a];\n if (areaLiveLayer >= 0) {\n areaOccl *= mix(\n 1.0,\n areaShadow(\n uPointShadows,\n float(areaLiveLayer),\n areaShadowFrom,\n uLiveAreaShadowFar[a],\n uLiveAreaShadowNear[a],\n right,\n up,\n halfSize\n ),\n uLiveAreaShadowWeight[a]\n );\n areaGlass *= mix(\n vec3(1.0),\n areaGlassTint(\n float(areaLiveLayer),\n areaShadowFrom,\n uLiveAreaShadowFar[a],\n uLiveAreaShadowNear[a],\n right,\n up,\n halfSize\n ),\n uLiveAreaShadowWeight[a]\n );\n }\n#endif\n\n glassGlow += uAreaLightColor[a] * backForm * areaOccl * areaGlass;\n if (form <= 0.0) continue;\n /* The diffuse, the model's answer or the lobe integrated over the rectangle: see areaLight.ts. */\n areaLightAdd(\n seenCentre, right, up, seenHalf, uAreaLightColor[a], form, areaOccl, areaGlass,\n n, albedo, metal, specColor, surfaceRoughness, modelled, lampOpen, lampShadowed\n );\n }\n\n /*\n * **The other half of the shadow filter: the two pixels of a column pair, folded together.**\n *\n * `pointShadow` turns its twelve taps by half a golden angle on every other column, so a\n * pair of neighbouring pixels holds twenty-four places on the disk between them and neither\n * holds more than twelve. This is where the pair becomes one filter. `dFdx` of a value is\n * the difference across the pair, and half of it, signed by which of the two this pixel is,\n * is exactly the average of both \u2014 so each pixel ends up shaded by all twenty-four taps\n * while paying for twelve. It is the pairing `ambientOcclusion.ts` describes between its\n * rotation tile and its blur, at the smallest size that needs no second pass.\n *\n * **After both lamp loops rather than beside the taps, and that is why this block exists at\n * all.** A derivative is only defined where every pixel of the quad reaches it, and neither\n * loop above reaches anything uniformly: the point loop `continue`s on falloff and on\n * facing, and in the clustered arm its trip count is read from a froxel, so two neighbouring\n * pixels can be shading different lights with a froxel boundary drawn across the screen\n * where they stop agreeing. A `dFdx` in there is the 2026-08-07 rule's own case, and it\n * would also invite a compiler to flatten the loop. Measured, too: with the turn taken from\n * a per-fragment hash instead of the column, the same resolve made the penumbra *worse*\n * than no resolve at all \u2014 3.14 against 3.00 \u2014 because the hardware here answers a coarse\n * derivative and the second row of a quad then borrows the first row's difference. The\n * column tile is what makes that borrowing correct, so the two halves are one mechanism.\n *\n * Both lamp kinds are in the sums, because a rectangle's shadow is filtered by the same\n * twelve taps and stippled the same way.\n *\n * **Clamped, and the clamp is a guard rather than tidying.** A fragment killed by the\n * cutout or the clip plane above leaves its lane without a value for its neighbour to\n * difference against, and GLSL ES says nothing about what comes back. What could reach the\n * frame is a shadow term outside 0..1 on the pixel beside a cutout hole, which the clamp\n * turns into a pixel that is merely fully lit or fully shadowed. **What would make this\n * wrong** is a consumer whose cutout edges are most of the frame \u2014 dense foliage drawn as\n * one alpha-tested sheet \u2014 where the honest answer is to resolve in a pass of its own,\n * against a shadow term written to a target.\n *\n * The floor under the divisor costs nothing and answers the case where no lamp reached this\n * pixel at all: both sums are zero there, so the ratio is zero and multiplying it back by\n * the floor adds nothing.\n */\n vec3 lampOpenFloor = max(lampOpen, vec3(1e-6));\n vec3 lampShade = lampShadowed / lampOpenFloor;\n#if POINT_SHADOWS\n lampShade = clamp(\n lampShade - dFdx(lampShade) * (float(int(gl_FragCoord.x) & 1) - 0.5),\n 0.0,\n 1.0\n );\n#endif\n lit += lampOpenFloor * lampShade;\n /*\n * DriftLight: every fixed light past the frame's choice, summed and occluded, for the share of\n * this pixel the exact lights above no longer cover. Diffuse only, and so no metal term: see\n * `driftLight.ts`.\n */\n if (driftShare > 0.0) {\n if (glassTransmission > 0.0) {\n glassGlow += driftLightIrradiance(vWorldPos, -n) * (driftShare * uDriftLight.w);\n }\n /* The standard expression in its own order \u2014 albedo times the field, then the share \u2014 so a\n pipeline with no model rounds exactly as it did; a model is handed the light alone. */\n vec3 driftField = driftLightIrradiance(vWorldPos, n);\n lit += modelled\n ? modelIrradiance(driftField * (driftShare * uDriftLight.w))\n : albedo * driftField * (driftShare * uDriftLight.w) * (1.0 - metal);\n }\n\n /*\n * Self-illuminated geometry, dimmed where a lamp's shadow crosses it.\n *\n * Emission used to be added straight after the sun term and touched by nothing, so a\n * shadow could not land on a glowing surface at all \u2014 a character standing on the\n * terrace threw a shadow across the deck and the inlaid bands under it stayed at full\n * brightness, as though the shadow passed beneath them. Reported as the shadow going\n * *under* the inlaid markings rather than over them \u2014 and, narrowed down, as a fault\n * in the point lights' shadows specifically: a character's or a prop's, with the sun's\n * apparently unaffected.\n *\n * The sun is not affected because emission is gated on uNightFactor and so does not\n * exist by day, when the sun's shadow is the one being cast. At night the lamps are the\n * light and their occlusion is per-light inside the loop above, which the emissive term\n * never saw. Hence lightShade: the most any lamp in range shadows this point.\n *\n * Only a share of it, because none of these surfaces is a pure emitter \u2014 a lit inlay is\n * worked stone with light coming through it, and the stone is shadowed like everything\n * else. Taking all of it would darken a lamp head because something crossed in front of\n * it, which is a worse picture than the one being fixed.\n */\n /*\n * The sun and the moon shadow a glowing surface too.\n *\n * lightShade fixed this for lamps and stopped there, on the reasoning that\n * emission is gated on uNightFactor and so does not exist by day, when the sun's\n * shadow is the one being cast. That is true at noon and false at every hour that\n * matters: at dusk the bands are already lit and the sun is at its longest, and at\n * night the moon is the directional source and casts exactly the same way.\n *\n * So an emissive deck \u2014 emissive 0.55 \u2014 took a lamp post's shadow from a lamp and\n * not from the sky, and a post that threw a long shadow across the grass by day\n * threw none across the deck at night. Reported as self-shadowing that was present\n * under the sun and gone at night.\n *\n * Same share as the lamps get, and for the same reason: these surfaces are worked\n * stone with light coming through, not pure emitters, so taking all of it would\n * black out a lamp head because something crossed in front of it.\n */\n /*\n * What a surface emits is not always what it is made of.\n *\n * A negative component means no colour was named, and the term falls back to the albedo,\n * which is this engine's original behaviour and right for anything glowing because it is\n * hot or lit from within. Where a colour is named it replaces the albedo outright rather\n * than tinting it, because the point is a glow whose hue is independent of the paint\n * underneath: a ceiling emitting a dull warm haze over a pale panel cannot be expressed\n * by scaling the panel, and scaling it gets the brightness right and the hue wrong.\n */\n diffuseAlone = lit;\n vec3 emissiveTint = vEmissiveColor.r < 0.0 ? albedo : vEmissiveColor;\n /*\n * Where the surface glows, from an image, or everywhere if it binds none.\n *\n * **A multiplier of exactly 1 when no map is bound**, so this line collapses to the arithmetic\n * it replaces bit for bit \u2014 which is what the zero-pixel gate on the published scenes is\n * measuring, none of them binding one.\n *\n * **It modulates the emission rather than creating it**, which is glTF's rule: emitted colour\n * is the factor times the texture. A mesh whose `vEmissive` is 0 stays dark however bright the\n * image, because the whole term is still multiplied by it. That is stated here as well as at\n * `SurfaceMaterial.emissive`, because this is where somebody debugging a model that will not\n * glow ends up looking.\n *\n * The branch is on a uniform, so the `texture` call sits in uniform control flow and needs no\n * explicit level \u2014 the 2026-08-07 rule is satisfied by the gate rather than by a `textureLod`.\n */\n vec3 emissiveMapped = vec3(1.0);\n if (uMaterialFlags.z != 0) {\n emissiveMapped = texture(uEmissiveMap, surfaceAt).rgb * uEmissiveScale;\n }\n /* Pulse, flicker and fade from the layer's effects; exactly 1 where it names none. */\n float fxGlow = fx4.z + fx5.x + fx5.w > 0.0 ? fxEmission(surfaceAt.z) : 1.0;\n lit += emissiveTint * emissiveMapped * uEmission.x * vEmissive * uEmission.y\n * mix(1.0, min(lightShade, sunShade), EMISSIVE_SHADOW_SHARE) * fxGlow;\n /* And a lit window's glow, which the scene's lit share already times: not gated on night. */\n lit += windowGlow * uEmission.x;\n /* And a draw's overlay: its rim, its dissolve's band and the colour laid over. Not gated on the\n night, as emission is: a glow the surface wears rather than light it gives off. overlay.ts. */\n if (SURFACE_OVERLAY) lit += overlayGlow(surfaceAt.xy, n);\n\n#if NIGHT_EMISSIVE\n /*\n * And the same emission again, weighted by how far this surface faces away from the sun.\n *\n * uDirectionalDir points from the surface toward the source, so its negative dot with the\n * normal is how far into its own night this fragment is: 0 anywhere the sun can see and 1 at\n * the antisolar point. Added rather than mixed, and outside uNightFactor, because a body that\n * turns has a night side at every hour of whatever clock the world is keeping.\n *\n * Not shadowed by lightShade: a city is not switched off by a mountain standing in front of\n * the sun, and the term is already zero everywhere the sun reaches.\n *\n * **It is not free when it is off, and that is measured rather than assumed.** Held on the\n * gilded chamber with uNightEmissive at 0, against the build before this term existed: 110\n * pixels of 750,080 move, in scattered clusters of two to four, mean delta 17. Isolated by\n * elimination: the uniform declared and never used moves 0 pixels, the branch present with an\n * empty body moves 1, which is that scene's own floor, and the arithmetic above moves 110. So\n * it is the added instructions perturbing the rounding of shared subexpressions, and what\n * lands on screen is a shadow comparison sitting exactly on its boundary flipping sides.\n * Neither result is more correct than the other. Sharing the dot product above was tried as a\n * repair and changed nothing.\n */\n if (uNightEmissive > 0.0) {\n /* The same emission, so the same map: a night side is not a different material. */\n lit += emissiveTint * emissiveMapped * uNightEmissive * vEmissive * max(-sunDot, 0.0);\n }\n#endif\n\n // Arrival glow: a world-space box lit independently of the clock, so\n // finishing a run reads the same at noon and at midnight. Costs one compare\n // per fragment and no extra draw call \u2014 the box is a uniform, not geometry.\n if (uHighlightMax.w > 0.0) {\n vec3 inside = step(uHighlightMin, vWorldPos) * step(vWorldPos, uHighlightMax.xyz);\n lit += albedo * uHighlightMax.w * inside.x * inside.y * inside.z;\n }\n }\n\n /*\n * The medium: fog and the underwater tint, both \u2014 see `uFogEnabled`'s own comment for why\n * the two are folded together and what a draw that skips them gives up. Every other pass in\n * the renderer binds `uUnderwaterFactor` unconditionally so that crossing the surface moves\n * the whole scene together; this is the one draw where that is the caller's choice.\n */\n /*\n * Baked occlusion, over everything this surface contributes.\n *\n * **Deliberately the same scope as the screen-space AO the composite pass applies**, which\n * multiplies the whole frame including direct light and emissive \u2014 see rush.ts. The narrower\n * physical reading, ambient and reflection only, was considered and not taken: consistency with\n * the effect already shipping won over it.\n *\n * **The stated cost of that is a double count with the shadow maps.** Where a baked crevice and\n * a cast shadow fall on the same fragment, both darken it, and the ambient-only reading would\n * not have. If that ever becomes visible enough to report, narrowing this is the fix.\n *\n * Outside the lighting branch, so an unlit draw \u2014 uLightingEnabled at 0, which is\n * meshBasicMaterial \u2014 is occluded too. Before the fog block, because fog is between the eye and\n * the surface, and darkening it would darken the air in front of a crevice rather than the\n * crevice.\n *\n * Exactly 1.0 with no map bound, so this is a multiply by one on every published scene.\n */\n lit *= ormOcclusion;\n\n float fog = 0.0;\n if (uFogEnabled != 0) {\n // Water is a camera medium, not a property of individual objects.\n vec3 waterTransmission = vec3(0.42) + uUnderwaterColor * 2.0;\n lit = mix(lit, lit * waterTransmission, uUnderwaterFactor * 0.55);\n diffuseAlone = mix(diffuseAlone, diffuseAlone * waterTransmission, uUnderwaterFactor * 0.55);\n fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n }\n /* The diffuse pass leaves here, with skin's diffuse and the profile it is spread by. */\n if (modelDiffuseAlone()) {\n outColor = vec4(modelDiffuseOut(diffuseAlone * ormOcclusion, fog), skinProfileCode());\n return;\n }\n\n /*\n * A surface recedes into the medium; light added to the frame fades in it (`uFogEnabled` at 2,\n * `drawFog.ts`). What is behind an additive draw is already fogged, so mixing the draw toward\n * the medium's colour and adding it puts the haze into the frame twice \u2014 a glow by day drew as\n * a grey shape of itself, greyer the thicker the air.\n */\n vec3 shaded = applyOutputTransform(\n uFogEnabled == 2 ? lit * (1.0 - fog) : mix(lit, mediumColor(), fog));\n\n /*\n * **Refraction: the scene behind this surface, bent and absorbed.**\n *\n * The offset is along the shading normal in screen space, which is the cheap approximation every\n * real-time renderer ships rather than a ray traced through two interfaces of a solid. What it\n * cannot do is show anything the frame had not already drawn, so a pane at the edge of the frame\n * refracts what is beside it -- and the clamp below is what keeps that from being worse than\n * that. Without it a sample walks off the edge and wraps, which reads as a tear rather than as\n * an approximation.\n *\n * **Beer-Lambert, with the caller stating what survives one metre** rather than an absorption\n * coefficient: pow(tint, d) is exp(d * log(tint)), so a tint of one is clear glass with no\n * special case and there is no log of zero at the API to trap on. What it costs is that a caller\n * holding coefficients from a reference converts them once.\n *\n * **The path length divides by the view angle, and that is the whole reason this is not a\n * tint.** A slab seen edge-on is more glass than the same slab seen face-on, which is what makes\n * a glass edge go green while its middle stays clear; a tint multiplied straight in gives the\n * same colour at every angle. The clamp is a floor on the grazing case rather than a tolerance:\n * dot reaches zero at the silhouette, and an unclamped divide takes the pixel to infinity and\n * then to a NaN, which takes the fragment with it. Twenty thicknesses is where it caps, so a\n * medium that should go opaque at a grazing angle stops getting darker there.\n *\n * **It replaces the surface's own shading rather than mixing with it, and the strength is the\n * offset alone.** Mixing by the strength was tried first and is wrong twice over: the offset and\n * the amount of background shown are different quantities, so one scalar cannot be both -- a\n * plausible offset of 0.025 UV then meant a 2.5% blend, and a pane of glass drew as flat paint\n * over the thing it was supposed to be showing. Found by this row's own check, where every bar\n * behind the pane vanished.\n *\n * What replacing costs is the surface's own highlight: glass here shows the scene behind it and\n * not a specular of its own, because by this line the shaded colour has the lighting, the fog and\n * the tone curve already folded into it and there is no specular left to keep. A caller wanting a\n * glint draws a second, non-refracting pass over the same geometry. What would change it is\n * splitting the specular out before the fog, which is a larger change to the one shader every\n * draw uses.\n */\n if (uSeeThrough.x > 0.0 || glassTransmission > 0.0) {\n /*\n * **Bent by the shading normal, so a normal map shimmers what is seen through it** \u2014 a heat\n * haze, a distortion sphere, a rippled pane. Until 4.8.6 this was the geometric normal and a\n * mapped surface refracted as a smooth lens. An unlit draw shaded no normal, so it reads the\n * map here, on a branch on uniforms alone, which keeps its derivatives legal.\n */\n vec3 refractN = shadingNormal;\n if (uLightingEnabled == 0 && uNormalStrength > 0.0) refractN = normalMapped(refractN, surfaceAt);\n vec3 refractV = normalize(uCameraPos - vWorldPos);\n ivec2 snapSize = textureSize(uRefractScene, 0);\n vec2 screenUv = gl_FragCoord.xy / vec2(snapSize);\n vec2 bent = clamp(screenUv + refractN.xy * uSeeThrough.x, vec2(0.0), vec2(1.0));\n /*\n * **An explicit level, and frost is what picks it** \u2014 the 2026-08-07 rule, and the reason a\n * frosted pane blurs rather than fades: the copy carries a mip chain on a frame that draws glass,\n * so a milkier pane reads a coarser level of it. Level 0 on a copy with one level is exactly what\n * `texture()` read here before, so plain refraction draws as it did.\n */\n /* Two short of the last level: a fully frosted pane still shows the large light and dark of what\n is behind it, where the chain's last level is one texel, the whole frame's average. */\n float levels = max(floor(log2(float(max(snapSize.x, snapSize.y)))) - 2.0, 0.0);\n vec3 behind = textureLod(uRefractScene, bent, glassFrost * levels).rgb;\n /* Unsigned: a pane passes the same light seen from either side, and so does its Fresnel. */\n float cosView = max(abs(dot(refractN, refractV)), 0.05);\n float pathLength = uSeeThrough.y * vThickness / cosView;\n vec3 seen = behind * pow(uRefractTint, vec3(pathLength));\n if (glassTransmission > 0.0) {\n /*\n * **Glass keeps its own surface and shows what is behind over it**, by how much it lets\n * through and how little it reflects at this angle \u2014 Schlick's Fresnel at 0.04, the arithmetic\n * `glass.ts` states. So a clear pane keeps its highlight, and a pane seen edge-on is a mirror.\n * The glow of the lights behind it is added last, by how frosted it is.\n */\n float fresnel = 0.04 + 0.96 * pow(1.0 - cosView, 5.0);\n shaded = mix(shaded, seen * uGlassTint, glassTransmission * (1.0 - fresnel)) +\n applyOutputTransform(glassGlow * uGlassTint * (glassFrost * glassTransmission) * (1.0 - fog));\n } else {\n shaded = seen;\n }\n }\n /*\n * The per-vertex alpha lane, folded in here rather than given an expression of its own so the\n * order-independent branch below inherits it through the same local. 1.0 when absent.\n *\n * **It did not need MeshData.colors widened to four floats**, which is how the consumer who\n * asked for it had costed the change -- widening colors moves every mesh producer, the\n * container layout and every baked asset. Alpha is a factor on the draw's opacity, not a\n * component of its albedo, so it rides a lane instead and nothing existing moves.\n */\n float alpha = uOpacity * coverage * vAlpha;\n\n /*\n * **Order-independent transparency, when this draw is accumulating into it.**\n *\n * The weight falls off with distance so a near layer counts for more than a far one, and it is\n * clamped at both ends: without a ceiling a fragment on the near plane takes a weight large\n * enough to swamp the others inside a 16-bit target, and without a floor a distant one rounds to\n * nothing and vanishes rather than fading. The same arithmetic is `oitWeight` in\n * `orderIndependent.ts`, where it is asserted against numbers.\n *\n * The output is premultiplied and weighted, against a blend of `(ONE, ONE)`, so the target ends\n * up holding the weighted sum of colour and the weighted sum of alpha. The revealage pass draws\n * the same geometry with this at 0, where the branch below is not taken at all.\n */\n if (uWriteMode.x != 0.0) {\n float z = distance(vWorldPos, uCameraPos) / 200.0;\n float falloff = 0.03 / (1e-5 + z * z * z * z);\n float w = alpha * clamp(falloff, 1e-2, 3e3);\n outColor = vec4(shaded * alpha * w, alpha * w);\n return;\n }\n\n /* WebGL2's frame writes the reflection maps itself, with the fog already reckoned above. */\n if (REFLECTION_MAPS) {\n reflectionSurfaceOut(reflectedProbe, reflectedTint, reflectedRoughness, ormOcclusion, fog);\n }\n outColor = vec4(shaded, alpha);\n}\n";
2
+ export declare const MAIN_GLSL = "void main() {\n /*\n * Where every material map is read: the texture coordinate, and the layer of the array the face\n * wears, rounded here so an interpolated 2.9999 lands on layer 3 however the generated WGSL\n * turns a float into an array index. A plain texture is an array of one, so layer 0.\n */\n vec3 surfaceAt = vec3(vUv.xy, floor(vUv.z + 0.5));\n /*\n * The coordinates a map's frame and its cutout's level are measured along: the mesh's, or the\n * world's where the material projects its maps (worldUv.ts) \u2014 in which case the mesh's tangents\n * no longer run along them either. Triplanar reads its own three planes below; every other\n * reader of surfaceAt takes the horizontal plane.\n */\n vec2 frameAt = vUv.xy;\n int frameTangents = vHasTangents;\n projectSurface(surfaceAt, frameAt, frameTangents);\n /* A layered material's mask, read once for every layer's share below: layered.ts. */\n if (LAYERED && layered()) layeredBegin();\n if (dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n /*\n * The layer's surface effects, zeros where the albedo array carries no table \u2014 see\n * surfaceEffects.ts. Scroll and flipbook move surfaceAt before any map reads it, so every map of\n * the material moves together. The gradients are taken here, outside every branch, for the room\n * a window samples through textureGrad further down.\n */\n /* Zero rows unless a material has carried a table: `SURFACE_EFFECTS` is off until one does, and\n every effect below reads exactly nothing from zero rows, which the device folds away. */\n fxClear();\n if (SURFACE_EFFECTS) {\n fxLoad(int(surfaceAt.z));\n surfaceAt = fxAnimate(surfaceAt);\n }\n /* An eye's iris, moved by its cornea before any map reads it: models.ts. */\n surfaceAt = modelSurfaceAt(surfaceAt);\n vec2 fxDx = dFdx(surfaceAt.xy);\n vec2 fxDy = dFdy(surfaceAt.xy);\n if (uWriteMode.y != 0.0 && !ditherKeeps(uWriteMode.y)) discard;\n /* An opacity spent on the screen door instead of the blend: preamble.ts. 0 keeps nothing. */\n if (uWriteMode.z != 0.0 && ditherThreshold() >= vAlpha) discard;\n /* A draw's dissolve cuts the surface away before anything is shaded: overlay.ts. */\n if (SURFACE_OVERLAY) {\n if (overlayCuts(surfaceAt.xy)) discard;\n }\n\n /*\n * Surface colour and its coverage, resolved before anything else \u2014 including before it is\n * decided whether lighting runs at all. A thrown-away fragment should not pay for shading\n * it will never contribute to, whichever of the two paths below ends up running, and the\n * cutout is what a decal's edge needs regardless.\n *\n * An implicit-derivative texture() call rather than textureLod, and that is the\n * documented exception the 2026-08-07 rule asks for rather than a lapse: the branch\n * tests a *uniform*, so a compiler can prove it uniform and skip it wholesale, which\n * is precisely what the point-light loop's per-iteration index test could not offer.\n * The derivative is also the point here \u2014 this texture is mipmapped, and a tiled\n * floor at a grazing angle without mip selection aliases and crawls.\n *\n * Sampled once into a local instead of at each use, so the untextured majority pays a\n * single uniform test and the textured case pays a single fetch.\n */\n vec3 albedo = vColor;\n /*\n * How much of this fragment the surface actually covers.\n *\n * One for anything untextured, and the texture's own alpha for the rest. It reaches the\n * output only where a draw is translucent \u2014 an opaque draw has blending off and the\n * blend stage ignores alpha entirely \u2014 so this costs the opaque world nothing, and it\n * is what lets a *decal* work: a stain, a poster, a sticker, whose image is transparent\n * around the mark and partly transparent within it.\n *\n * Without it the cutout was the only thing a texture's alpha could say, so a translucent\n * draw carrying a cutout texture painted its whole quad at the draw's opacity \u2014 a\n * rectangle of haze with a picture in it, rather than a picture.\n */\n float coverage = 1.0;\n if (uMaterialFlags.x != 0) {\n vec4 texel = albedoTexel(surfaceAt);\n // Discarded before anything else is computed: a thrown-away fragment should not pay\n // for the lighting it will never contribute to. The test credits alpha for the mip level\n // it samples, so a needle averaged thin down the chain is not lost; see cutoutCoverage.ts.\n // Behind a branch on a uniform, so a material with no cutoff pays nothing for it.\n float kept = texel.a;\n if (uCutout.x > 0.0) {\n float tested = cutoutAlpha(texel.a, frameAt * vec2(textureSize(uAlbedo, 0).xy));\n if (uCutout.y == 0.0) {\n if (tested < uCutout.x) discard;\n } else {\n /*\n * A dithered edge: the pixel is kept by the share of it the texture covers. Under a\n * temporal resolve the test is against noise that moves every frame, and a kept pixel is\n * whole; multisampled, the share is the alpha the GPU turns into coverage. See\n * cutoutDither.ts.\n */\n float share = cutoutShare(tested, uCutout.x);\n if (uCutout.y == 1.0) {\n if (!cutoutKeeps(share, gl_FragCoord.xy, uHighlightMin.w)) discard;\n kept = 1.0;\n } else {\n kept = share;\n }\n }\n }\n albedo *= texel.rgb;\n coverage = kept;\n }\n\n /*\n * Occlusion, roughness and metallic, resolved out here rather than inside the lighting branch.\n *\n * Occlusion applies to an unlit draw as much as to a lit one \u2014 uLightingEnabled at 0 is\n * three.js's meshBasicMaterial and a baked crevice is still a crevice \u2014 so the three values have\n * to exist on both sides of that branch.\n *\n * texture rather than textureLod, under a branch on a *uniform*, exactly as uAlbedo above: the\n * compiler can prove this branch uniform and skip it wholesale, and the derivative is the point,\n * because this image is mipmapped and a tiled floor at a grazing angle without mip selection\n * aliases and crawls. See AGENTS.md, 2026-08-07, for the case where a branch is not uniform and\n * this would be illegal.\n *\n * ormRoughness starts as the attribute and is overwritten rather than chosen by a ternary, so\n * that a draw with no map bound reads exactly what it read before this existed, with nothing to\n * get backwards. The map replaces the attribute rather than scaling it: an absent roughness\n * attribute is 0.4277 and not 1, so scaling would have read glossy on every procedural mesh.\n */\n float ormOcclusion = 1.0;\n float ormRoughness = vRoughness;\n float metal = 0.0;\n if (uMaterialFlags.y != 0) {\n vec3 t = ormTexel(surfaceAt);\n ormOcclusion = mix(1.0, t.r, uOrmScale.r);\n ormRoughness = clamp(t.g * uOrmScale.g, 0.0, 1.0);\n metal = clamp(t.b * uOrmScale.b, 0.0, 1.0);\n }\n\n /*\n * The unlit default: exactly the surface's own colour, times its own texture, and nothing\n * else touching it \u2014 three.js calls this `meshBasicMaterial`. This is the value a draw with\n * `uLightingEnabled` at 0 keeps to the end, and the value every lit draw starts from before\n * the branch below builds a shaded result on top of it.\n */\n /*\n * Wear, rain and windows, on the colour and roughness everything below shades. Each is exactly\n * nothing where its numbers are zero, which is every surface of a scene that names no effect.\n */\n fxNormal = normalize(vNormal);\n fxDistance = distance(vWorldPos, uCameraPos);\n vec4 weathered = fxWeather(vWorldPos, albedo, ormRoughness);\n albedo = weathered.rgb;\n ormRoughness = weathered.w;\n vec3 windowGlow = vec3(0.0);\n if (fx0.x > 0.0) {\n vec4 window = fxWindows(surfaceAt, fxDx, fxDy, coverage);\n albedo = mix(albedo, fxRoom * 0.35, window.w);\n windowGlow = window.rgb;\n }\n\n vec3 lit = albedo;\n /* How much of the environment the frame shows here, and how a found reflection would land:\n reflectionSurface.ts. None where nothing is reflected. */\n vec3 reflectedProbe = vec3(0.0);\n vec3 reflectedTint = vec3(0.0);\n float reflectedRoughness = 1.0;\n /* Skin's diffuse, held before emission for the screen-space blur's own pass: models.ts. */\n vec3 diffuseAlone = vec3(0.0);\n /* The light arriving at a glass pane from behind it, gathered in each light loop below and added\n over the see-through at the end. Stays zero for anything that is not glass. See glass.ts. */\n vec3 glassGlow = vec3(0.0);\n float glassTransmission = uSeeThrough.z;\n float glassFrost = uSeeThrough.w;\n /*\n * **A thin surface lets light from behind through, coloured by itself**: a banner, a leaf, a\n * lampshade, an opaque material's `diffuseTransmission`, in the glass tint's spare lane. It\n * gathers the light behind it in glassGlow, as a pane does, and adds it through its own colour.\n * None on glass, which has its own way through.\n */\n float thin = glassTransmission > 0.0 ? 0.0 : uGlassTint.w;\n bool seesBehind = glassTransmission > 0.0 || thin > 0.0;\n\n /*\n * The normal a refracting draw bends by: the shading normal, a normal map's tilt and every relief\n * in it, as the lit block leaves it before turning a back face \u2014 the geometric normal where\n * nothing tilts it, which is what refraction bent by before 4.8.6. See the refraction below.\n */\n vec3 shadingNormal = normalize(vNormal);\n if (uLightingEnabled != 0) {\n vec3 n = normalize(vNormal);\n /*\n * **Which face of a two-sided surface this is, from the normal and not from the rasteriser.**\n * The rasteriser's own facing flag would say it exactly, and it is one more input to the stage: the flat\n * shader already carries sixteen, WebGPU's ceiling, and the seventeenth made every pipeline\n * invalid and every mesh draw nothing. A face whose own normal points away from the eye is the\n * back of the sheet; what that gives up is a sliver at a curved silhouette, where a front face's\n * interpolated normal already leans away and is lit as the back.\n */\n /*\n * **Glass is two-sided whatever its material says**: a pane is symmetric, so from behind the\n * side the eye sees is the one lit, and the light on its far side is what glows through it.\n * Taken from the stored normal alone, a pane seen from behind glowed with the light on the\n * eye's side and not with the sun shining through it.\n */\n bool backFace = (uMaterialFlags.w != 0 || glassTransmission > 0.0) && dot(n, uCameraPos - vWorldPos) < 0.0;\n /*\n * The authored normal, before either relief perturbs it.\n *\n * A map is the truth about which way the surface faces; uRelief invents structure from noise\n * and uTextureRelief reads a height off the albedo's own luminance, and both exist because\n * there was no way to author normals. So the map goes first and relief lays fine structure\n * over whatever it produced \u2014 composing rather than replacing, because a map that switched\n * relief off would silently discard a value the caller set.\n *\n * Gated on a uniform, which is what makes the derivatives inside tangentFrame legal at all \u2014\n * AGENTS.md 2026-08-07 \u2014 and what makes a scene with no normal map pay nothing.\n *\n * texture rather than textureLod, and this is the one place in this shader where that is\n * right: a mipped colour image, sampled under uniform control flow, exactly as uAlbedo is\n * below. The rule is about non-uniform branches, and this branch is on a uniform.\n */\n vec3 unmapped = n;\n if (uNormalStrength > 0.0) n = normalMapped(n, surfaceAt, frameAt, frameTangents);\n /* A draw's wrinkles, by region, over whatever the map made: overlay.ts. */\n if (SURFACE_OVERLAY) n = overlayWrinkle(n, unmapped, surfaceAt.xy);\n\n /*\n * Microscopic relief: the surface's own texture, as a turn of the normal rather than a change\n * of colour.\n *\n * **This is what makes a road read as asphalt instead of as a grey plane.** Real surfaces are\n * covered in structure too small to model and too large to ignore, and what it does to a\n * picture is change the *direction* light leaves each point, so a coarse surface catches a lamp\n * on one side of every bump and shades on the other. Grain cannot do it: that varies how much\n * light a point takes, which mottles a flat face and washes out at exactly the shallow angles\n * where a road shows its texture most.\n *\n * The bumps are the gradient of the same value noise the grain term uses, sampled three times\n * the shading point. That costs three extra noise evaluations and is gated on the surface\n * having asked, so a world with no textured surfaces pays nothing.\n *\n * **The roughness is widened with it, and that is not optional.** A perturbed normal is a\n * normal that varies fast, and a narrow highlight on a fast-varying normal is the sub-pixel\n * sparkle this engine spent a release fixing. The amplitude here is *known* rather than\n * measured from a screen-space derivative, which is what makes this the safe version: the same\n * widening driven by fwidth was measured making that artefact three times worse, because that\n * derivative spikes at every hard crease. See docs/IMPROVEMENTS.md.\n */\n /* Never below zero: a lightmapped mesh's coordinates ride this lane below it. See lightmap.ts. */\n float reliefAmount = uRelief.x * max(vRelief, 0.0);\n /* Grown with the relief, before anything reads it. See the comment above: a normal that\n wanders cannot hold a highlight narrower than the wander. */\n /*\n * Grown with the relief *and* with the map, before anything reads it. A normal that wanders\n * cannot hold a highlight narrower than the wander, and a strongly mapped surface wanders as\n * surely as a procedurally relieved one \u2014 uTextureRelief already widens by its own amount for\n * exactly this reason, and a map left out of it sparkles.\n */\n float surfaceRoughness = clamp(\n ormRoughness + reliefAmount * RELIEF_ROUGHNESS + uNormalStrength * RELIEF_ROUGHNESS,\n 0.0,\n 1.0\n );\n /*\n * How much of the noise one pixel covers, and the fade that follows from it.\n *\n * **Point-sampled procedural detail aliases the moment a pixel is wider than a bump**, and\n * this had no defence against that at all. At the 60 cycles a metre a road asks for, a\n * carriageway seen at a grazing angle puts tens of bumps inside one fragment and takes a\n * single gradient from somewhere inside them; which one it lands on changes per pixel and\n * per frame, so the surface reads as salt-and-pepper noise rather than as aggregate. It is\n * worst exactly where a street scene spends most of its pixels: far away and near edge-on.\n *\n * The footprint is the world-space pixel size in noise cells, so the fade is stated in the\n * units the artefact is actually in \u2014 a road fades at the distance a bump stops being\n * resolvable, whatever the cycle count, the camera or the resolution.\n *\n * **Computed here rather than inside the branch below, and that is required rather than\n * tidy.** `fwidth` is a derivative and `vRelief` is a varying, so a derivative taken under\n * `if (reliefAmount > 0.0)` sits in non-uniform control flow: undefined in GLSL and refused\n * outright by WGSL. `shadowFactor` in this file carries the long version of that rule and\n * the shader that could not be translated until it was obeyed. `uLightingEnabled` above is\n * a uniform, so this is legal where it stands.\n *\n * The cost is one `fwidth` on every lit fragment, including those carrying no relief.\n * **What would make it wrong is reading this as the fix for sparkle**: it is not, and the\n * roughness widening above deliberately keeps the *unfaded* amount. Unresolved normal\n * variance is still roughness \u2014 a road that faded to smooth at distance would go glossy\n * instead of noisy, which is a worse artefact wearing better clothes.\n */\n vec3 reliefPixel = fwidth(vWorldPos) * uRelief.y;\n float reliefFootprint = max(max(reliefPixel.x, reliefPixel.y), reliefPixel.z);\n float reliefResolved = reliefAmount * (1.0 - smoothstep(0.5, 1.5, reliefFootprint));\n if (reliefResolved > 0.0) {\n vec3 at = vWorldPos * uRelief.y;\n /* One cell of the noise, which is one bump: the gradient over any smaller step is the\n interpolant's own slope rather than the shape's. */\n float here = grain(at);\n float dx = grain(at + vec3(0.5, 0.0, 0.0)) - here;\n float dy = grain(at + vec3(0.0, 0.5, 0.0)) - here;\n float dz = grain(at + vec3(0.0, 0.0, 0.5)) - here;\n vec3 slope = vec3(dx, dy, dz);\n /* Only the part across the surface tilts it; pushing along the normal would move the point\n rather than turn it, which a shading normal cannot express. */\n slope -= n * dot(slope, n);\n n = normalize(n - slope * reliefResolved * RELIEF_TILT);\n }\n\n /*\n * And the relief the bound texture itself carries, as its own luminance read as a height.\n *\n * **Both conditions are uniforms, which is what makes the derivatives below legal.** A branch\n * on a uniform is uniform control flow; `shadowFactor` above carries the long version of this\n * rule and the shader that could not be translated until it was obeyed. The four `texture`\n * calls are inside that same uniform branch, so the implicit level AGENTS.md's 2026-08-07\n * rule is about is well defined here too.\n *\n * **A basis from the screen rather than from a tangent attribute.** Turning a gradient\n * measured in UV into a tilt in world space needs to know which way U and V point on the\n * surface, and `MeshData` carries no tangents. Deriving them per fragment from how world\n * position and UV change across the quad needs nothing from the geometry, so this reaches\n * every mesh in every consumer rather than only remade ones \u2014 and it is the construction\n * three.js's own `perturbNormalArb` uses, so a surface ported from there is lit by the same\n * arithmetic instead of by an approximation of it.\n *\n * `det` carries the basis's own scale, so the normal is built as `abs(det) * n - slope`\n * and normalized: dividing the gradient by `det` instead would blow up on a fragment where\n * the surface is edge-on and the quad's footprint collapses.\n */\n if (uMaterialFlags.x != 0 && uTextureRelief != 0.0) {\n /*\n * **A centred difference, not three.js's forward one, and that is what makes the two\n * backends agree.** WebGL2's framebuffer counts y upward and WebGPU's counts it downward,\n * so `dFdy` of anything points the opposite way on the two \u2014 which the construction below\n * is built to survive, since `sign(det)` flips with it and cancels. A *forward* height\n * difference breaks that cancellation, because `h(uv + d) - h(uv)` is not the negative of\n * `h(uv - d) - h(uv)` anywhere the picture curves. Measured on the proof page: the y\n * component of the perturbed normal disagreed between the backends by a mean of 18 of 255\n * against 4 for the two components `dFdx` drives, with no dependence on pixel parity, so\n * it was the direction rather than the precision. A centred difference is exactly\n * antisymmetric, the cancellation holds, and it is the more accurate estimate besides: a\n * forward difference reports the slope half a step away from the point it is used at.\n */\n vec2 duvdx = dFdx(vUv.xy);\n vec2 duvdy = dFdy(vUv.xy);\n /* Half, because a centred difference spans two steps and three.js's number means one. */\n float reliefPerStep = uTextureRelief * 0.5;\n float dHdx = (surfaceHeight(texture(uAlbedo, vec3(vUv.xy + duvdx, surfaceAt.z)).rgb)\n - surfaceHeight(texture(uAlbedo, vec3(vUv.xy - duvdx, surfaceAt.z)).rgb)) * reliefPerStep;\n float dHdy = (surfaceHeight(texture(uAlbedo, vec3(vUv.xy + duvdy, surfaceAt.z)).rgb)\n - surfaceHeight(texture(uAlbedo, vec3(vUv.xy - duvdy, surfaceAt.z)).rgb)) * reliefPerStep;\n\n vec3 dpdx = dFdx(vWorldPos);\n vec3 dpdy = dFdy(vWorldPos);\n vec3 acrossV = cross(dpdy, n);\n vec3 acrossU = cross(n, dpdx);\n float det = dot(dpdx, acrossV);\n vec3 slope = sign(det) * (dHdx * acrossV + dHdy * acrossU);\n /*\n * **Capped at RELIEF_MAX_TILT, and that is not tuning.** This is *microscopic* relief:\n * structure too small to model, standing on geometry that is still the real shape. Turned\n * further than 45\u00B0 it stops being that and starts lighting a face from behind its own\n * surface, which no amount of texture detail is evidence for. And past that point the\n * expression is near degenerate \u2014 `slope` has grown to the size of the term it is\n * subtracted from, so the normalized result swings a long way for a small change in\n * either, which is the fast-varying normal this engine already spent a release removing\n * the sparkle from. The cap binds only where the picture is a cliff; at the amplitudes a\n * photograph of a real surface produces, it never binds at all.\n */\n float lean = length(slope);\n float limit = abs(det) * RELIEF_MAX_TILT;\n if (lean > limit) slope *= limit / lean;\n n = normalize(abs(det) * n - slope);\n /* Widened for the same reason the procedural relief above widens it, and it is no more\n optional here: a normal that wanders cannot hold a highlight narrower than the wander.\n The amplitude is the caller's own stated one rather than a screen-space measurement,\n which is the condition that made that widening safe there. */\n surfaceRoughness = clamp(\n surfaceRoughness + abs(uTextureRelief) * RELIEF_ROUGHNESS,\n 0.0,\n 1.0\n );\n }\n\n /*\n * **The back face of a two-sided surface is lit as its front**, which is glTF's rule for\n * `doubleSided`: the whole shading normal turned toward the viewer, after the map and the\n * relief have perturbed it, as three.js turns its normal, tangent and bitangent together.\n * Turning only the interpolated normal would leave a normal map's tilt pointing the wrong way\n * across the fold of every curtain.\n */\n shadingNormal = n;\n if (backFace) n = -n;\n /* A blending rim, on the base colour every light below reads: overlay.ts. */\n if (SURFACE_OVERLAY) albedo = overlayRimBase(albedo, surfaceAt.xy, n);\n /*\n * A model's surface, gathered once, on a branch on the switches alone (models.ts) \u2014 and whether\n * there is one, asked once: every site below is a branch on these two, which a pipeline with no\n * model folds away, and each extra call was a copy of its plumbing in all sixteen permutations.\n */\n bool modelled = modelOn();\n bool modelledBehind = modelSeesBehind();\n if (modelled) modelSurface(n, albedo, surfaceRoughness, metal, surfaceAt);\n /*\n * Skin's colour half leaves here, with the colour its spread light is multiplied by, and a whole\n * alpha a multisampled edge resolves to its coverage. A branch on the switches alone, so uniform.\n */\n if (modelAlbedoAlone()) {\n outColor = vec4(albedo, 1.0);\n return;\n }\n\n /*\n * One dot product, used twice: ndl is the lit side and its negative is the night side below.\n * Shared rather than issued twice because it is the same number and one is cheaper, and not\n * because it fixes anything. It was tried as a fix and measured: it does not. See the night\n * term for what it does not fix.\n */\n float sunDot = dot(n, uDirectionalDir);\n float ndl = max(sunDot, 0.0);\n /*\n * Kept as its own value rather than folded into direct, because the emissive\n * term below needs it too. The sun and the moon are the only sources whose shadow\n * used to stop at a glowing surface.\n */\n /*\n * **The surface's own n\u00B7l, before any map tilted it: the side of the surface the sun is on.**\n *\n * A normal map says which way each texel faces within its surface; it cannot put a texel on the\n * other side of that surface from the sun. Read from the mapped normal alone, the texels of a\n * column's face turned from a high sun that leaned into the light were lit as though the column\n * were not in the way: a field of white specks over stone in shade. So the shadow's band is\n * chosen by this, and the sun is taken away as it reaches zero, over the last two hundredths of\n * n\u00B7l, where an unmapped surface's own Lambert term is already under 0.02. Over a tenth it moved\n * every smooth column's terminator in the published scenes. What it gives up is the bump-lit\n * relief of a surface barely facing the sun, where the light was already grazing.\n */\n vec3 surfaceN = normalize(vNormal);\n if (backFace) surfaceN = -surfaceN;\n float surfaceNdl = dot(surfaceN, uDirectionalDir);\n#if DIRECTIONAL_SHADOWS\n float sunShade = shadowFactor(max(surfaceNdl, 0.0));\n /* What the sun keeps through any glass on its way here: see sunGlassLookup. */\n vec3 sunGlass = sunGlassTint(max(surfaceNdl, 0.0));\n#else\n float sunShade = 1.0;\n vec3 sunGlass = vec3(1.0);\n#endif\n vec3 sunColor = uDirectionalColor * sunGlass;\n /*\n * **How much sky this vertex can see, applied to the sun and to nothing else.**\n *\n * It multiplies sunShade rather than direct because the two are the same kind of quantity --\n * occlusion of the one directional source -- and because direct is not the only thing that\n * reads it: the sun's specular lobe below multiplies by sunShade on its own, so scaling\n * direct alone would leave an enclosed face with no sunlight and a sun highlight anyway.\n *\n * **Ambient is deliberately untouched, and that is the defect this lane exists to fix.** The\n * only per-vertex channel that reached shading before was colors, and the shader reads\n * albedo = vColor before lit = albedo * (ambient + sun), so a sky factor carried in the\n * vertex colour scales both terms: an enclosed face is darkened once for having no sky and\n * again for the ambient it should still have received. A consumer hitting this raised their\n * own floor constant to 0.45 to compensate and measured what it cost -- 55% of a chunk's\n * vertices sat between 0.10 and 0.20 -- which is contrast they could not get back.\n *\n * 1.0 for every mesh that carries no channel, so this is a multiply by one on every scene\n * that shipped before it.\n */\n sunShade *= vSkyDirect;\n /*\n * And the sun gone from a surface turned from it, whatever its map says. See `surfaceNdl`.\n * Its own factor rather than folded into sunShade, which the emissive share reads as the sun's\n * shadow: a lamp's pane turned from the moon is not in shadow, it is only unlit.\n */\n float sunFacing = smoothstep(0.0, 0.02, surfaceNdl);\n float direct = ndl * sunShade * sunFacing;\n /* Glass: the sun on the far side of a pane lights it through the glass. See glassGlow. */\n if (seesBehind) glassGlow += sunColor * max(-surfaceNdl, 0.0) * sunShade;\n\n /*\n * Hemispheric: sky above, ground below, mixed by which way the surface looks. An\n * upward face takes the sky colour, a downward face the ground's, and a wall the\n * average \u2014 which is what makes a floor and a ceiling in the same room read as\n * different materials rather than the same one at different brightness.\n */\n vec3 ambient = mix(uAmbientGround, uAmbient, n.y * 0.5 + 0.5);\n#if ENVIRONMENT_PROBE\n /*\n * The room's diffuse light, read from the probe array.\n *\n * **It replaces the hemispheric term where a grid exists, rather than lifting it.** What stood\n * here until 2026-08-25 was a mix toward `textureLod(uEnvironment, n, maxLod - 3)`, guarded\n * by a `max` so a *partial* probe could not darken a subject: a box-filtered level three\n * below the coarsest is a sample of the room rather than an integral over it, and a plain mix\n * toward one rendered a night courtyard black. What stood here after that was nine\n * spherical-harmonic coefficients, which is the integral and needed a readback to produce.\n *\n * This is the same integral again, convolved into a level of the array at bake time and read\n * with a fetch. **The clamp went with the fit that needed it**: a second-order projection can\n * go slightly negative where the environment is strongly directional, and a cosine convolution\n * of a non-negative environment cannot.\n *\n * `uProbeGridAmbient` is 0 when a caller declined the diffuse half, and the whole expression\n * is multiplied away there \u2014 so a scene that passed `irradiance: false` keeps the gradient it\n * set, which is the guarantee that option was added to give.\n */\n vec3 irradiance = gridIrradiance(n, vWorldPos);\n irradiance = mix(irradiance, ambient, probeGridOutside(surfaceN, vWorldPos));\n ambient = mix(ambient, irradiance, uProbeGridAmbient * uEnvironmentEnabled);\n#endif\n /* A draw's own ambient, where one is set, in place of the frame's: `setAmbientSH`. */\n if (uAmbientSH[6].w > 0.5) ambient = ambientHarmonics(n);\n /*\n * A metal keeps its ambient and loses its direct diffuse.\n *\n * **Written as one term because the honest two cancel.** The spelling this stands for is\n * albedo * (1 - metal) * (ambient + D * direct) + metal * albedo * ambient: a metal's reduced\n * diffuse, plus the ambient floor that stops a rough one going black once its diffuse is gone\n * and its reflection is scaled by (1 - roughness). Their two ambient contributions are\n * (1 - metal) * ambient and metal * ambient, which sum to ambient exactly, so all of it\n * reduces to this line.\n *\n * At metal 0 that is one multiply by 1.0 away from the expression it replaces, which is the\n * smallest change available in a shader with a documented history of shared subexpressions\n * rounding differently when their neighbours move. See the night-emissive term.\n */\n /*\n * A model answers the sun itself, and is shadowed as it says (models.ts). The standard line\n * stands as it was, so a pipeline with no model compiles to exactly the arithmetic before.\n */\n vec3 sunModelSpecular = vec3(0.0);\n if (modelled) {\n modelLight(uDirectionalDir, 0.0, 0.0);\n float sunModelFacing = modelledBehind ? 1.0 : sunFacing;\n lit = modelAmbient(ambient) + sunColor * mDiffuse * modelShade(sunShade) * sunModelFacing;\n sunModelSpecular = mSpecular * sunColor * sunShade * sunModelFacing;\n } else {\n lit = albedo * (ambient + sunColor * direct * (1.0 - metal));\n }\n /* A baked page's light, beside the sky's and the sun's: nothing but where a material has one. */\n lit += modelBaked(n, albedo, metal);\n\n /*\n * A sun highlight, for the few surfaces that ask for one.\n *\n * Blinn-Phong against the dominant source, which at night is the moon \u2014 so a polished\n * prop catches whatever is in the sky rather than only the day. It takes the same\n * sunShade the diffuse term takes, because a highlight that survives a shadow is\n * the clearest possible tell that lighting is faked.\n *\n * vSpecular is zero for every mesh that did not supply the attribute \u2014 the vast\n * majority \u2014 so for the rest of the world this is one multiply by a constant zero.\n */\n vec3 halfway = normalize(uDirectionalDir + normalize(uCameraPos - vWorldPos));\n /*\n * A metal's highlight takes its own colour; a dielectric's takes the light's.\n *\n * That is the single most visible thing metalness does, and it is why gold under a white lamp\n * reads as gold rather than as beige plastic with a white dot on it. vSpecular is this\n * engine's dielectric reflectance \u2014 a look control rather than an F0, as specularLobe's own\n * header says at length \u2014 so the mix runs from it to the albedo rather than from 0.04.\n *\n * mix(x, y, 0) is exactly x, so at metal 0 this is vec3(vSpecular) and the line below is the\n * one it replaces with the scalar broadened to three components.\n */\n vec3 specColor = mix(vec3(vSpecular), albedo, metal);\n /*\n * **A metal's highlight goes white at a grazing angle, and that is most of what reads as\n * polished.**\n *\n * Schlick says reflectance climbs to 1 at the edge whatever the material is, so a red metal\n * shows a red highlight face-on and a *white* one along every panel edge. Without this the\n * highlight is albedo-tinted at every angle, and a dark red suit gets a dark red highlight\n * that reads as matte paint however smooth the map says it is. The environment term below has\n * carried its own Fresnel since it was written; the direct term never had one.\n *\n * Scaled by metal, so at metal 0 this is mix(specColor, white, 0) \u2014 specColor exactly, and the\n * dielectric case is the expression it replaces. A dielectric's F0 is already so low that the\n * engine's vSpecular look control stands in for the whole term, which is the reading\n * specularLobe's header argues for at length.\n */\n float sunVoH = max(dot(normalize(uCameraPos - vWorldPos), halfway), 0.0);\n vec3 sunSpec = highlightTint(specColor, sunVoH, metal);\n /*\n * **Held back until after the environment blend, because that blend is a mix and would\n * otherwise erase this.**\n *\n * The reflection below is written as `mix(lit, environment, w)`, so everything already in\n * `lit` is scaled by `1 - w`. On a metal `w` reaches 1, and a highlight added here is\n * multiplied by zero: the surface with the *most* reason to show a specular streak is the one\n * guaranteed not to. That is the single most visible difference between a bought PBR model\n * here and the same model in its own reference render, where every panel edge carries a white\n * streak brighter than the paint under it.\n *\n * The lamps have always added their highlight after this block; the sun was the odd one out,\n * and only because the block moved beneath it. Both are specular reflections of a source, and\n * a reflection of the room cannot subtract one.\n *\n * Zero for the whole world that carries no specular attribute and no metalness, exactly as\n * before: `sunSpec` is `vec3(vSpecular)` at metal 0 and `vSpecular` defaults to 0.\n */\n /* GGX's own where the surface asks (`physicalSpecular`): the sun is a point, so nothing widens it. */\n float sunNdh = max(dot(n, halfway), 0.0);\n float sunLobe = physicalSpecular()\n ? physicalLobe(sunNdh, ndl, dot(n, normalize(uCameraPos - vWorldPos)), surfaceRoughness, 0.0, 1.0)\n : specularLobe(sunNdh, surfaceRoughness);\n vec3 sunHighlight = sunColor * sunLobe * sunSpec * sunShade * sunFacing;\n if (modelled) sunHighlight = sunModelSpecular;\n /*\n * The dielectric's share goes in here, where it has always gone, so the environment blend\n * below dims it by exactly the reflectance it always dimmed it by. The metal's share is added\n * after that blend instead \u2014 see the line that does it for why the two cannot be one.\n */\n lit += sunHighlight * (1.0 - metal);\n\n /*\n * Procedural grain on the surfaces that asked for it, so a face is not one flat colour.\n *\n * This is what \"texture\" means in an engine that has none: the variation is computed\n * from world position rather than sampled, so it costs no upload, no memory and no\n * sampler, and it cannot be seen to tile. Grain is the half of a realistic material\n * that the colours alone cannot supply \u2014 a mineral surface is not uniform, and at\n * these sizes the eye reads\n * uniformity as plastic before it reads anything else.\n *\n * World-space rather than object-space, so the grain sits still in the world while a\n * prop turns through it \u2014 which is how a solid looks, and what stops the pattern\n * reading as something painted on and rotating with the object.\n *\n * Inside the branch on purpose. It is pure ALU with no texture fetch, so a\n * non-uniform branch is safe here (AGENTS.md, 2026-08-07 \u2014 that rule is about\n * implicit derivatives, which this has none of), and almost nothing in the world\n * carries a grain attribute at all.\n *\n * The amount is what the surface itself declared, and the third answer to a question\n * that was guessed twice. Gating on vSpecular meant \"this is stone\" only while stone\n * was the only shiny thing in the world, so an imported car came out sanded. Weighting\n * by vRoughness was closer and still a proxy: painted plaster is rough and has no\n * grain, polished granite is smooth and has plenty, so a painted tower at roughness 0.55\n * took 55% grain and read as marble. The two properties are independent and no weighting\n * between them can be right. uGrain remains as the pass-level scale over whatever the\n * geometry said, for an imported model that declares nothing.\n */\n if (vGrain > 0.0 && uGrain > 0.0) {\n float grainMix = mix(\n grain(vWorldPos * GRAIN_SCALE),\n grain(vWorldPos * GRAIN_COARSE_SCALE),\n GRAIN_COARSE_MIX\n );\n lit *= mix(1.0, mix(GRAIN_FLOOR, GRAIN_CEIL, grainMix), uGrain * vGrain);\n }\n\n /*\n * Environment reflection, Fresnel weighted.\n *\n * The sky and ground colours the ambient term already carries, sampled along the mirror\n * direction rather than the normal, so the reflection swings across a curved panel as the\n * eye moves instead of sitting still on it. That movement is most of what reads as gloss.\n *\n * Fresnel because every dielectric reflects far more at a grazing angle than head on:\n * about four percent facing the eye and nearly all of it at the edge, which is the bright\n * rim along a wing and the reason a windscreen turns into a mirror from the side. Scaled\n * by smoothness, since a rough surface scatters the same energy into no image at all.\n *\n * **Two sources, and which one is used is the caller's decision rather than a guess.** With a\n * reflection probe baked, the mirror direction samples the room itself and a car body carries\n * the panels above it and the walls around it. Without one, it samples the sky and ground\n * colours the ambient term already holds: an approximation, stated as one, with no scene in\n * it, which restores the tonal range a mirror surface has and cannot show anything.\n */\n /*\n * **Or an ORM map, because a metal is nothing but its reflection.**\n *\n * This read uReflectivity alone, which is a per-pass control a consumer sets from the\n * container's own scalar \u2014 and that scalar is 0 for exactly the materials that carry their\n * metalness in a map, because a per-texel value cannot be expressed as a per-draw one. So the\n * whole block was skipped for a fully metallic surface and max(uReflectivity, metal) inside it\n * was never reached. A chromed subject came out near black with a baked probe sitting unread.\n *\n * **Gated on the ORM flag rather than on metal, and that is required rather than tidy.** The\n * block takes fwidth of the mirror direction, and a derivative may only be taken under uniform\n * control flow. The flag is a uniform; metal is a texture fetch, so branching on it would\n * put that derivative in non-uniform control flow and WGSL refuses the module outright. See\n * AGENTS.md, 2026-08-07. The per-texel decision still happens, one line down, in the amount.\n *\n * With the flag at 0 this is uReflectivity > 0.0 exactly, so nothing that binds no map takes a\n * branch it did not take before.\n */\n /* A model reflects its environment whatever the pass asked of the standard model: see\n modelReflectance. On the switches alone, so the derivatives below stay in uniform flow. */\n if (uEnvironmentDials.y > 0.0 || uMaterialFlags.y != 0 || modelled) {\n vec3 toEye = normalize(uCameraPos - vWorldPos);\n vec3 mirrored = reflect(-toEye, modelled ? modelReflectNormal(n) : n);\n vec3 environment = mix(uAmbientGround, uAmbient, mirrored.y * 0.5 + 0.5);\n#if ENVIRONMENT_PROBE\n /*\n * Rougher surfaces read a blurrier level, which is what separates satin from lacquer, and\n * **no level sharper than this pixel can resolve**, which is what stops a lacquered panel\n * sparkling.\n *\n * Roughness alone answers only half the question. It says how blurred the *material* makes a\n * reflection; it says nothing about how much of the cubemap lands inside one pixel. Where a\n * panel creases or a shut line runs, the mirror direction swings a long way between\n * neighbouring fragments, so at 0.025 for lacquered paint each of them reads a texel far from\n * its neighbour's at the sharpest level there is. A street of lit windows then lands in some\n * pixels and not the ones beside them: single bright dots tracing every crease, every gap and\n * every silhouette, worst on dark paint at night, and gone the moment you move close enough\n * for that curvature to span several pixels. **That is a sampling rate, not the model.**\n *\n * Two consumers reported it and it reproduces in demo/nightStreet.ts. It also looks exactly\n * like anti-aliasing being off, and is not: multisampling resolves *coverage*, so it cannot\n * touch a value that varies too fast inside one triangle.\n *\n * The footprint is fwidth of the mirror direction, turned into texels through the face size\n * the chain implies: a face spans a right angle, so texels per radian is that size over \u03C0/2.\n * The blurrier of the two levels wins. Nothing else about the term moves, deliberately \u2014\n * widening the *material's* roughness from the same derivative was tried and measured worse,\n * because it also dims the reflection at exactly the creases it was meant to settle.\n *\n * Derivatives are well defined here: uReflectivity is a uniform, so this branch is uniform\n * for every fragment in the draw. See AGENTS.md, 2026-08-07, for the case where it is not.\n */\n /*\n * **Combined in roughness, not floored on a level \u2014 repaired 2026-08-25.**\n *\n * This turned the footprint into texels and took the blurrier of that level and the\n * material's. Against the **box-filtered capture** that was right: a level was a smaller\n * picture. Against the **GGX-prefiltered cube** a level is a *lobe convolved at roughness\n * level/maxLod*, so the same floor stopped saying \"sample more coarsely\" and started saying\n * \"this surface is rough\". Measured on a 512 probe with panels swinging about 0.03 rad a\n * pixel: level 3.3, which under the new chain is an enforced **roughness 0.37 on every metal\n * whatever its map says**. Reported from a consumer as metals reading opaque.\n *\n * So the footprint is the roughness whose lobe is as wide as the pixel, combined in\n * quadrature \u2014 independent angular spreads add that way. A GGX lobe at roughness r has a\n * characteristic half-angle of about r squared, so a footprint of `swing` radians is a\n * roughness of `sqrt(swing)`.\n *\n * **Halved, because `fwidth` is an upper bound rather than the footprint.** It is\n * `abs(dFdx) + abs(dFdy)`, a sum of both screen derivatives rather than the radius of the\n * pixel's cone, and those derivatives are *coarse* on nearly every GPU: one pair per 2x2\n * quad. Together they over-state the cone by about two, and this squares back into a\n * roughness, so a half here is a quarter of the solid angle.\n *\n * **The reason this shape was refused before is gone.** The comment it replaces recorded\n * that widening the material's roughness \"was tried and measured worse, because it also dims\n * the reflection at exactly the creases it was meant to settle\" \u2014 true under the old weight,\n * where `reflectAmount` carried a `1 - roughness` factor. `envSpecularEnergy` removed\n * that: a metal's weight is exactly 1.0 at every roughness, so widening no longer dims.\n *\n * **It widens only the level this picks.** `surfaceRoughness` is untouched, because it also\n * feeds the environment BRDF and the sun's own lobe, and a crease should not grow a wider\n * *direct* highlight for spanning few pixels.\n *\n * **What it costs**: a surface still loses its mirror where it is genuinely under-sampled,\n * which is the sparkle protection doing its job. **What would make it wrong** is a chain\n * whose levels are not roughness \u2014 the capture's own box chain is exactly that.\n */\n /*\n * **The map's edge, not `exp2(uEnvironmentMaxLod)`.** Those were one number while the\n * environment was a cube whose chain ran to a single texel a face. The chain now stops one\n * level below the cosine convolution, so the top level is 4 while the map is 256 across, and\n * the old expression would say sixteen \u2014 which reads as every metal being under-sampled and\n * forced to a roughness its material does not have.\n */\n float faceTexels = uEnvironmentEdge;\n float swingRad = max(length(fwidth(mirrored)), 1e-6);\n /*\n * **Two chains, two ways to pick a level, chosen by `uEnvironmentPrefiltered`.**\n *\n * A *box* level is a smaller picture of the room, so the footprint belongs as a floor on the\n * level: read coarsely enough that a pixel covers about a texel, and lose detail, nothing\n * else. A *prefiltered* level is the room convolved at roughness level/maxLod, so the same\n * floor stops saying \"sample more coarsely\" and starts saying \"this surface is rough\" \u2014\n * measured on a 512 probe with panels swinging 0.03 rad a pixel, an enforced roughness of\n * 0.37 on every metal whatever its map says.\n *\n * So for the lobe chain the footprint is expressed as the roughness whose lobe is as wide as\n * the pixel and combined in quadrature, independent angular spreads adding that way: a GGX\n * lobe at roughness r has a characteristic half-angle of about r squared, so a footprint of\n * `swing` radians is a roughness of `sqrt(swing)`. Halved, because `fwidth` is\n * `abs(dFdx) + abs(dFdy)` over *coarse* derivatives \u2014 one pair per 2x2 quad \u2014 so it\n * over-states the pixel's cone by about two, and this squares back into a roughness.\n *\n * **What it costs** is both expressions evaluated and one selected, which is a handful of\n * multiply-adds against a uniform. **What would make it wrong** is a third kind of chain.\n */\n float boxLod = max(surfaceRoughness * uEnvironmentMaxLod, log2(max(swingRad * faceTexels * 0.6366, 1.0)));\n float footprintRough = 0.5 * sqrt(min(swingRad, 1.0));\n float lobeRough = min(1.0, sqrt(surfaceRoughness * surfaceRoughness + footprintRough * footprintRough));\n float envLod = clamp(\n mix(boxLod, lobeRough * uEnvironmentMaxLod, uEnvironmentDials.x),\n 0.0,\n uEnvironmentMaxLod\n );\n vec3 room = gridRadiance(mirrored, envLod, vWorldPos);\n /*\n * **The room wins outright where there is one, and it must.**\n *\n * This was briefly written as `max(environment, room)`, to stop a dim probe leaving a metal\n * darker than the world around it. It does stop that, and it destroys the reflection doing\n * it: the hemispheric term is a two-colour gradient with no image in it whatsoever, and a\n * world's declared ambient is brighter than most of what a probe actually holds \u2014 so the max\n * returns the *gradient* over nearly the whole sphere and a mirror shows a smooth wash. That\n * is the same flat, matte surface this was trying to fix, arrived at from the other side.\n *\n * A floor is right for irradiance, where only the level matters, and wrong for a mirror,\n * where the level is the least of what is being carried. Brightness belongs to\n * `uEnvironmentGain`, which scales what the surface reflects and keeps its image.\n */\n environment = mix(environment, room, uEnvironmentEnabled);\n#endif\n /*\n * Whatever the surface ends up reflecting, at the brightness the caller asked for \u2014 **and\n * weighted by metalness, which is the only case the gain was ever an answer to.**\n *\n * A metal has no diffuse and no ambient behind its reflection: what it reflects is all it\n * is, so when the probe under-reports the room the surface has no other way to be lit. A\n * dielectric is in no such position. Its reflection sits on top of a diffuse term that is\n * already carrying the world's light, and scaling it lifts a surface that was correct \u2014\n * measured on a car painted black, which came out washed grey with a milky haze over every\n * panel, because `fresnel * uReflectivity * (1 - roughness)` reaches most of the way to 1 at\n * a grazing angle and three times the room is what landed there.\n *\n * `mix(1, gain, 0)` is exactly 1, so a dielectric is arithmetically untouched however the\n * caller sets this.\n */\n environment *= mix(1.0, uEnvironmentDials.z, metal);\n float facing = 1.0 - max(dot(n, toEye), 0.0);\n /*\n * **A metal's Fresnel base is close to its albedo, not a dielectric's four percent.** Schlick\n * with a fixed 0.04 is right for glass, plastic and paint and badly wrong for chrome: left\n * there, a smooth metal seen head-on takes a four percent reflection and reads as dark paint.\n *\n * And a metal is nothing but its reflection, so it raises the amount as well as the base \u2014\n * uReflectivity is a per-pass control and a metal texel cannot be left mirroring nothing\n * because the pass it happens to be drawn in did not ask for reflections.\n *\n * Both collapse at metal 0: mix(0.04, 1.0, 0) is 0.04, and max(uReflectivity, 0) is\n * uReflectivity because both renderers clamp that uniform non-negative on upload.\n */\n float f0 = mix(0.04, 1.0, metal);\n float fresnel = mix(f0, 1.0, facing * facing * facing * facing * facing);\n /*\n * **A rough metal is still a metal, and `1 - roughness` said otherwise.**\n *\n * The thinning belongs to the dielectric term, where it stands for a polish that scatters\n * its reflection away into no image at all. A metal has no diffuse to fall back on: this is\n * its entire response to light, and scaling it by `1 - roughness` throws away 40% of\n * everything a surface at roughness 0.4 shows and replaces it with `albedo * ambient`,\n * which is a dark constant. That is the \"reads dark, reads like paint\" report, arithmetically.\n *\n * It is also double counting. The chain the sample comes from *is* the roughness \u2014 a rougher\n * surface reads a blurrier level a few lines above \u2014 so dimming it as well charges twice for\n * one property. A GGX prefilter loses some energy with roughness and it is nothing like this\n * steep; that belongs with the prefilter, when there is one.\n *\n * **Identical at metal 0**: `max(uReflectivity * (1 - r), 0)` is `uReflectivity * (1 - r)`,\n * which is what the old `max(uReflectivity, metal) * (1 - r)` came to, and both renderers\n * clamp that uniform non-negative on upload.\n */\n float reflectAmount = max(uEnvironmentDials.y * (1.0 - surfaceRoughness), metal);\n /*\n * **With a prefiltered environment, how much a surface returns is an integral rather than a\n * curve somebody chose**, and this is where the comment above said the thinning should go\n * once a prefilter existed.\n *\n * `envBrdfApprox` is the split-sum's second half: the convolution decides *what* is\n * reflected at a roughness and this decides *how much*. It contains the angular dependence,\n * so it replaces `fresnel` as well as `1 - roughness` \u2014 keeping the separate Fresnel\n * factor would apply it twice.\n *\n * **Gated on `uEnvironmentEnabled`, and the gate is an argument rather than a way of\n * protecting a pixel count.** The split sum approximates an integral over a *prefiltered*\n * environment. With no probe, `environment` is the two-colour sky-and-ground gradient,\n * which was never integrated over anything and has no chain behind it, so applying a term\n * that assumes one would assert an integral that does not exist. Every scene without a probe\n * therefore keeps exactly the arithmetic it had.\n *\n * **That last clause used to read \"which is every published scene\", and it was wrong.**\n * `nightStreet` bakes at 128 and `showroom` at 256, so two of the seven take this\n * branch. They move no pixels \u2014 measured, 0 of 921,600 on each of the seven \u2014 but only\n * because neither binds an ORM map, so both are dielectrics at `f0` 0.04 where this term\n * is worth hundredths of a level. A claim about a gate is worth checking against the\n * scenes rather than remembering, which is what `docs/README.md` says about every claim\n * of this shape.\n *\n * **Measured rather than assumed, and it moved less than expected.** On the roughness ladder\n * the whole change is at most 0.2 of 255, because a rough surface reads the coarsest levels\n * of the chain and those are dark in a room with one bright wall. The term is nonetheless\n * the right one: it is where a bright loaded environment will show, and it stops a fully\n * rough dielectric reflecting exactly nothing, which `1 - roughness` asserted and which is\n * false of every real surface.\n */\n float ndv = 1.0 - facing;\n /*\n * **`envSpecularEnergy` and not `f0 * dfg.x + dfg.y`, and the difference is a metal\n * looking like paint.** The bare split sum integrates one scattering event and drops every\n * ray that bounces twice inside the microsurface, so it returns `1 - 0.55 * roughness` for\n * a surface at `f0` 1 \u2014 asserting that a perfect mirror absorbs over half the light once it\n * is roughened. Reported from a consumer whose subject is forced fully metallic: measured\n * losses of 22% at roughness 0.4, 33% at 0.6 and 44% at 0.8 against the term this replaced.\n *\n * The compensation returns it, and at `f0` 1 it returns *exactly* 1.0 at every roughness \u2014\n * which is the old `fresnel * reflectAmount` weight for a metal, to the bit. So this is a\n * repair rather than a second look decision, and the arithmetic is in `lobes.ts`.\n */\n /*\n * **Compiled out where there is no probe, and that is worth 4,762 gzipped bytes.**\n *\n * With `ENVIRONMENT_PROBE` off, `prefiltered` was a literal 0 and `mix` returned the\n * left-hand side outright \u2014 so the fit, the compensation and the clamp were computed, and\n * shipped, to be multiplied by nothing in eight of the sixteen permutations. `ARCHITECTURE.md`\n * \u00A71 is why that is not a rounding error: a permutation is larger than deflate's 32 KB window,\n * so sixteen near-identical copies do not dedupe and every consumer downloads all of them.\n *\n * Measured on `core-only`, gzipped: **529,889 ungated, 525,127 gated**, against a floor of\n * 524,402 before any of this. So the energy compensation costs **725 bytes** rather than the\n * 5,487 it costs computed unconditionally \u2014 and the gate pays for more than it: `dfg` moved\n * inside it too, so eight permutations stopped carrying `envBrdfApprox` as well, which they\n * had been doing since the split sum landed. The picture is identical to the bit either way.\n *\n * The prediction written here first was 2,708. It was wrong, and it is replaced by the\n * measurement rather than kept beside it.\n *\n * **What would make this wrong** is a second consumer of `dfg` outside this branch \u2014 a\n * clearcoat lobe wanting the same fit. It would then belong above the `#if` again, and the\n * bytes would be buying something.\n */\n#if ENVIRONMENT_PROBE\n vec2 dfg = envBrdfApprox(ndv, surfaceRoughness);\n float integrated = clamp(envSpecularEnergy(f0, dfg) * max(uEnvironmentDials.y, metal), 0.0, 1.0);\n /*\n * **The split sum only where a prefiltered chain is what is being sampled.** It is the\n * correct share of a *prefiltered* environment; over a box chain it asserts an integral that\n * was never performed, and it flattens the Fresnel sweep that reads as gloss \u2014 measured, the\n * grazing-to-head-on ratio falls from about 15 to 1 down to 5 to 1 at roughness 0.5, which\n * was reported twice as a metal going opaque. With the prefilter off this is exactly the\n * expression the engine carried before the lighting track.\n */\n float weight = mix(\n clamp(fresnel * reflectAmount, 0.0, 1.0),\n integrated,\n uEnvironmentEnabled * uEnvironmentDials.x\n );\n#else\n float weight = clamp(fresnel * reflectAmount, 0.0, 1.0);\n#endif\n if (modelled) weight = modelReflectance(weight);\n if (modelled) environment = modelEnvironment(environment);\n lit = mix(\n lit,\n environment * mix(vec3(1.0), albedo, metal),\n weight\n );\n reflectedTint = mix(vec3(1.0), albedo, metal) * weight;\n reflectedProbe = environment * reflectedTint;\n reflectedRoughness = surfaceRoughness;\n }\n /* The reflection pass's surface half leaves here, before the lamps: reflectionSurface.ts. */\n if (REFLECTION_SURFACE) {\n reflectionSurfaceOut(reflectedProbe, reflectedTint, reflectedRoughness, ormOcclusion,\n reflectionAir());\n return;\n }\n /*\n * The sun's highlight, on top of whatever the surface reflects rather than underneath it \u2014\n * **and only by as much as the surface is metal.**\n *\n * The metal half is the fix: the blend above reaches 1 on a metal, so a highlight added before\n * it was multiplied by zero and the surface with the most reason to show a specular streak was\n * the one guaranteed not to.\n *\n * The `(1 - metal)` half above is the part that must not move. A dielectric's blend weight is\n * `fresnel * uReflectivity * (1 - roughness)`, which on polished paint or glass reaches most\n * of the way to 1 at a grazing angle \u2014 and adding the highlight after the blend rather than\n * into it takes a lobe that was correctly dimmed by the surface's own reflectance and puts it\n * back at full strength. On a near-black glass lens carrying `specular` 1 that is a white\n * blob where there was none. So the dielectric keeps the arithmetic it has always had, exactly:\n * at metal 0 the line below adds nothing and the line above is the original expression.\n */\n lit += sunHighlight * metal;\n\n /*\n * Self-illuminated geometry. Additive and gated on the night factor, so it is\n * exactly invisible by day and costs two ALU ops.\n *\n * Dimmed in shadow, but only partly, and the fraction is the whole argument. Strictly,\n * emission is light leaving a surface and an occluder between that surface and the sun\n * has no business touching it \u2014 which is what this used to do, adding the full term\n * after the shadow. The result is that a shadow crossing the terrace darkens the deck\n * and leaves every inlaid band at full brightness, so the bands stop belonging to the\n * floor they are cut into. It gets reported as the shadow passing *under* the inlaid\n * markings, as though it could not be cast over them at all.\n *\n * That is a real break in the picture, and the physical reading is not much\n * of a defence, because none of these surfaces is a pure emitter: a lit inlay is worked\n * stone with light coming *through* it, and the stone around the light is in shadow like\n * everything else. So the shadow takes a share and leaves the rest. At 0.55 a band in\n * shade is visibly in shade and still visibly lit, which is what it is.\n */\n /*\n * Deferred to *after* the point lights, so their shadows can reach it. Addition\n * commutes; the order is only about what is known by then.\n */\n float lightShade = 1.0;\n\n /*\n * **The lamps' light twice: once as it arrives, once as their shadows leave it.**\n *\n * Every point light's contribution used to go straight into `lit`. It is accumulated here\n * instead, in two sums that differ only by the shadow term, so that after the loop there is\n * a single number per channel saying how much of the lamps' light survived \u2014 and *that* is\n * the thing the resolve below can average across a pair of pixels without touching albedo,\n * a highlight, or anything else the frame has in it.\n *\n * **The two sums are not an approximation.** `lampOpen` multiplied by\n * `lampShadowed / lampOpen` is `lampShadowed`, per channel, whatever mixture of lamps\n * and shadows produced it. With the resolve switched off this is arithmetically the line it\n * replaces; a lamp that is shadowed and a lamp beside it that is not keep their own shares.\n *\n * **What it costs** is two vector adds per light and six floats of live state. **What would\n * make it wrong** is a term wanting to sit between the lamps and `lit` \u2014 a per-light\n * tone map, say \u2014 because these two are summed before anything can get between them.\n */\n vec3 lampOpen = vec3(0.0);\n vec3 lampShadowed = vec3(0.0);\n /* `DRIFT_LIGHT` is off until a consumer gives the renderer a DriftLight volume, and the device\n compiles the volume's reads away with it. */\n float driftShare = 0.0;\n if (DRIFT_LIGHT) {\n driftShare = driftLightShare(vWorldPos);\n }\n\n /*\n * Which froxel this fragment is in.\n *\n * Derived in view space rather than from `gl_FragCoord`, for the reason `uView`'s own\n * comment gives: the two backends disagree about which way a framebuffer's y runs, and a tile\n * row taken from a window coordinate would be mirrored on one of them.\n *\n * The arithmetic is `sliceOfViewDepth` and `tileOf` from `clusteredLights.ts`, in the\n * same order and with the same clamps, because a fragment reading a froxel the binner filled\n * differently is a fragment lit by somebody else's lights.\n */\n /*\n * **Only in a build that clusters, and there only in a frame that does.** `CLUSTERED_LIGHTS` is\n * the profile's `clusteredLights`, a pipeline constant the device compiles this whole arm away\n * on: the view transform, two logarithms and the froxel fetch below ran on every lit pixel of\n * every scene, clustered or not, and the arm's records held their registers through the loop.\n */\n int clusterBaseTexel = 0;\n int clusterLights = 0;\n bool clusteredArm = false;\n if (CLUSTERED_LIGHTS) {\n if (uClustered != 0) {\n vec3 clusterView = (uView * vec4(vWorldPos, 1.0)).xyz;\n float clusterDepth = -clusterView.z;\n float clusterHalfH = max(clusterDepth, 1e-4) * uClusterFrustum.z;\n float clusterHalfW = clusterHalfH * uClusterFrustum.w;\n int clusterTileX = clamp(\n int(floor((clusterView.x / clusterHalfW + 1.0) * 0.5 * float(CLUSTER_X))), 0, CLUSTER_X - 1);\n int clusterTileY = clamp(\n int(floor((clusterView.y / clusterHalfH + 1.0) * 0.5 * float(CLUSTER_Y))), 0, CLUSTER_Y - 1);\n int clusterSlice = 0;\n if (clusterDepth > uClusterFrustum.x) {\n clusterSlice = clamp(\n int(floor(log(clusterDepth / uClusterFrustum.x)\n / log(uClusterFrustum.y / uClusterFrustum.x) * float(CLUSTER_Z))),\n 0, CLUSTER_Z - 1);\n }\n clusterBaseTexel = LIGHT_REGION_TEXELS\n + (clusterTileX + clusterTileY * CLUSTER_X + clusterSlice * CLUSTER_X * CLUSTER_Y)\n * CLUSTER_TEXELS;\n clusterLights = int(clusterTexel(clusterBaseTexel).x);\n clusteredArm = true;\n }\n }\n\n /* The fixture atlas's size, asked once: its cookie band and its profile rows are found from it. */\n vec2 fixtureSize = vec2(textureSize(uFixtureAtlas, 0));\n /*\n * Whether any light can carry a measured profile or a cookie, decided once rather than per\n * light. `LIGHT_FIXTURES` is off until a consumer loads one, and the device compiles the\n * fixture lookups away with it, which they hold their registers for in every scene otherwise.\n */\n bool fixtureProfiles = false;\n bool fixtureShapes = false;\n if (LIGHT_FIXTURES) {\n fixtureProfiles = uFixtureShape.x > 1.0;\n fixtureShapes = uFixtureShape.y > 1.0 || uFixtureShape.z > 0.0;\n }\n\n // Point lights. Inverse-square-ish falloff clipped to a finite radius, so a\n // light can be culled without a visible seam.\n for (int slot = 0; slot < LIGHT_LOOP_MAX; slot++) {\n /*\n * **The two arms differ in where a light's numbers come from and in nothing else.**\n *\n * Everything below reads `lightPos`, `lightColor`, `lightRadius`,\n * `lightSourceRadius`, `lightWeight` and `shadowSlot`, so the clustered path is a\n * different *source* for the same six values rather than a second copy of the shading. That\n * is what makes the zero-pixel gate between the two arms mean something: if the body were\n * duplicated, the gate would only be comparing two transcriptions.\n */\n int i;\n vec3 lightPos;\n float lightRadius;\n vec3 lightColor;\n float lightSourceRadius;\n float lightWeight;\n int shadowSlot;\n vec3 lightDir;\n vec2 lightCone;\n float iesProfile;\n vec3 iesAxis;\n float cookie;\n /* The light's lighting channels, a mask: 1 unless it names others. See `uLightIesAxis`. */\n float lightChannels = 1.0;\n /* The light's own falloff exponent, or 0 for the frame's falloff. See `uLightCone`. */\n float lightExponent = 0.0;\n /* A clustered rectangle's barn doors: their cosine and length, none at 0. See `areaBarnDoors`. */\n vec2 areaDoors = vec2(0.0);\n /*\n * **A rectangle in the froxel table** \u2014 one past the fixed arm's `maxAreaLights` \u2014 which\n * its record says with a negative radius, its reach negated. Its fields come out of the\n * fixture texels: sidedness, the right axis, the half extents and the up axis.\n */\n bool clusteredArea = false;\n float areaTwoSided = 0.0;\n if (clusteredArm) {\n if (slot >= clusterLights) break;\n /*\n * Four indices to a texel. Selected with compares rather than by indexing the vector,\n * because a dynamic component index is legal in GLSL ES 3.00 and is one more thing for a\n * translator to disagree about on the one path that has no fallback beneath it.\n */\n uvec4 packedIndices = clusterTexel(clusterBaseTexel + 1 + (slot >> 2));\n int component = slot & 3;\n uint packed = component == 0\n ? packedIndices.x\n : component == 1 ? packedIndices.y : component == 2 ? packedIndices.z : packedIndices.w;\n i = int(packed);\n int record = i * LIGHT_TEXELS;\n uvec4 rec0 = clusterTexel(record);\n lightPos = vec3(\n uintBitsToFloat(rec0.x), uintBitsToFloat(rec0.y), uintBitsToFloat(rec0.z));\n lightRadius = uintBitsToFloat(rec0.w);\n clusteredArea = lightRadius < 0.0;\n lightRadius = abs(lightRadius);\n /*\n * **Out of reach is decided on the first texel, before the rest of the record is read.**\n * A froxel holds every light whose sphere touches its box, so most of the lights a fragment\n * walks never reach it, and each one used to cost all five texels before the falloff below\n * said so. Both falloffs are zero at and past the radius, so this skips only lights the\n * `continue` after the falloff would have skipped anyway: 17.5 ms of a 4K main pass with\n * 320 lamps was 12.3 with this and the profile guard below, byte for byte the same picture.\n */\n if (length(lightPos - vWorldPos) >= lightRadius) continue;\n /*\n * **The second texel, and for a plain point light the last one.** The emitter size and the\n * weight are two halves in one word, which is how nine numbers fit in two texels;\n * `unpackHalf2x16` widens them exactly, and the fixed arm reads the same two values rounded\n * the same way, so the arms stay one picture. See `packSizeAndWeight`.\n */\n uvec4 rec1 = clusterTexel(record + 1);\n lightColor = vec3(\n uintBitsToFloat(rec1.x), uintBitsToFloat(rec1.y), uintBitsToFloat(rec1.z));\n vec2 sizeAndWeight = unpackHalf2x16(rec1.w);\n lightSourceRadius = abs(sizeAndWeight.x);\n lightWeight = sizeAndWeight.y;\n /*\n * **The fixture, read only for a light that has one.** What a plain light's record holds in\n * the last four texels is exactly these values, so a light carrying no shadow slot, cone,\n * profile, cookie, channels or falloff of its own shades the same whether they are read or assumed, and at heavy overlap\n * reading them was a third of the pass. A branch on a value from a texture around\n * `texelFetch` is not the 2026-08-07 case: an integer fetch takes no derivative.\n *\n * **Inside a branch on `uClustered` first**, which is 2 only in a frame where some light\n * carries a fixture. The test on each light's flag is a branch on a value read from a\n * texture, which a compiler may run both sides of, and it cost 1.3 ms of a 4K pass whose 320\n * lamps were all plain; a uniform branch costs nothing, so such a frame never reaches it.\n */\n shadowSlot = -1;\n lightDir = vec3(0.0);\n lightCone = vec2(-1.0, -2.0);\n iesProfile = -1.0;\n iesAxis = vec3(0.0);\n cookie = -1.0;\n if (uClustered == 2) {\n if ((rec1.w & LIGHT_FIXTURE_FLAG) != 0u) {\n uvec4 rec2 = clusterTexel(record + 2);\n uvec4 rec3 = clusterTexel(record + 3);\n uvec4 rec4 = clusterTexel(record + 4);\n uvec4 rec5 = clusterTexel(record + 5);\n shadowSlot = clusteredArea ? -1 : int(uintBitsToFloat(rec2.x));\n areaTwoSided = uintBitsToFloat(rec2.x);\n lightDir = vec3(\n uintBitsToFloat(rec2.y), uintBitsToFloat(rec2.z), uintBitsToFloat(rec2.w));\n lightCone = vec2(uintBitsToFloat(rec3.x), uintBitsToFloat(rec3.y));\n iesProfile = uintBitsToFloat(rec3.z);\n cookie = uintBitsToFloat(rec3.w);\n /* The photometric azimuth. See LIGHT_RECORD in clusteredLights.ts for why it costs a\n texel rather than being derived from the direction. */\n iesAxis = vec3(\n uintBitsToFloat(rec4.x), uintBitsToFloat(rec4.y), uintBitsToFloat(rec4.z));\n lightChannels = uintBitsToFloat(rec4.w);\n lightExponent = uintBitsToFloat(rec5.x);\n areaDoors = vec2(uintBitsToFloat(rec5.y), uintBitsToFloat(rec5.z));\n }\n }\n } else {\n i = slot;\n if (i >= uLightCount || i >= MAX_LIGHTS) break;\n lightPos = uLightPos[i];\n lightRadius = uLightRadius[i];\n lightColor = uLightColor[i];\n lightSourceRadius = uLightSourceRadius[i];\n lightWeight = uLightWeight[i];\n shadowSlot = i;\n lightDir = uLightDir[i];\n lightCone = uLightCone[i].xy;\n lightExponent = uLightCone[i].z;\n iesProfile = uLightIesProfile[i];\n iesAxis = uLightIesAxis[i].xyz;\n lightChannels = uLightIesAxis[i].w;\n cookie = uLightCookie[i];\n }\n /*\n * **A light shades a surface only where their channels meet**: the light's mask against the\n * material's. Both default to channel 1, so a scene naming none takes every light as before\n * \u2014 and **zero reads as that default on both sides**, so a pass that never writes the lane\n * loses no light: an unwritten uniform is zero, and zero here would be every lamp switched\n * off. A branch on a value read from a texture in the clustered arm, as the reach test above\n * is, and it skips only what the light would add; no derivative is taken past it.\n */\n int litChannels = int(lightChannels);\n int surfaceChannels = int(uCutout.w);\n if (((litChannels == 0 ? 1 : litChannels) & (surfaceChannels == 0 ? 1 : surfaceChannels)) == 0) {\n continue;\n }\n /*\n * **A clustered rectangle is shaded here and goes no further**: by the fixed loop's own body\n * (`areaLight.ts`), unshadowed \u2014 its occlusion is the fixed arm's, which is why the first\n * `maxAreaLights` stay there \u2014 and windowed to nothing at its reach, as a lamp is, so the\n * froxel it is binned into has no edge in the picture.\n */\n if (clusteredArea) {\n vec3 seenCentre = lightPos;\n vec2 seenHalf = lightCone;\n if (!areaBarnDoors(seenCentre, lightDir, iesAxis, seenHalf, areaDoors)) continue;\n float areaForm = areaSignedForm(n, seenCentre, lightDir, iesAxis, seenHalf);\n float areaWindow = clamp(1.0 - pow(length(lightPos - vWorldPos) / max(lightRadius, 1e-4), 4.0), 0.0, 1.0);\n areaForm = mix(max(0.0, areaForm), abs(areaForm), areaTwoSided);\n if (areaForm > 0.0) {\n areaLightAdd(\n seenCentre, lightDir, iesAxis, seenHalf, lightColor * (areaWindow * areaWindow), areaForm,\n 1.0, vec3(1.0), n, albedo, metal, specColor, surfaceRoughness, modelled,\n lampOpen, lampShadowed\n );\n }\n continue;\n }\n /* A DriftLight field's own light carries a negative weight, and is shaded exactly only for the\n share of this pixel the field does not stand in for. See `driftLight.ts`. */\n if (lightWeight < 0.0) lightWeight = -lightWeight * (1.0 - driftShare);\n /*\n * **The cone, and a point light's is one that admits every direction.**\n *\n * `lightCone` is the cosine of the inner angle then of the outer, and a light that declared\n * no cone carries -1 and -2 \u2014 so the dot product, which is at least -1 for every direction\n * on the sphere, is always at or past the upper edge and the term is exactly 1. Not\n * approximately: `smoothstep` returns its upper bound outright once the argument reaches\n * it, with no arithmetic in between, which is what makes every scene with no spot light\n * bit-identical to the build before spot lights existed.\n *\n * **Not written as a branch on whether the light is a spot.** In the clustered arm that\n * would be a branch on a value read from a texture, which is not provably uniform control\n * flow, and the shadow fetches below sit inside this same loop \u2014 the 2026-08-07 rule is\n * about exactly that. Two multiply-adds are cheaper than the flattening a compiler would do\n * anyway.\n *\n * **What it costs** is that the edge is a smoothstep in the cosine rather than in the angle,\n * so the falloff is very slightly biased toward the outer edge. **What would make it wrong**\n * is a cone narrow enough for that bias to be visible, which is a few degrees, and at that\n * width the shadow map's resolution is the larger problem.\n */\n vec3 toLight = lightPos - vWorldPos;\n float dist = length(toLight);\n /* The direction the light travels, which is what a cone is measured against. */\n float coneFalloff =\n smoothstep(lightCone.y, lightCone.x, dot(-toLight / max(dist, 1e-4), lightDir));\n /*\n * Two ways for a lamp to fade, and the difference is a look rather than a detail.\n *\n * The default shapes the falloff to the radius: it reaches zero exactly at the edge, so\n * a light can be culled with no seam, and it stays gentle near the source. That is the\n * right behaviour for a world of large soft sources and it is what every world here is\n * authored against.\n *\n * Inverse-square is what a physically-based renderer does, and it is *violent* close\n * in: at a tenth of a metre it is a hundred times the value at one metre, so a lamp\n * mounted a few centimetres under a ceiling floods it and the panel around it reads as\n * lit rather than as a dark plate with a bright strip in it. Windowed by the radius the\n * same way three.js windows its own, so the light still ends where it says it does.\n */\n float falloff;\n /*\n * **A light may name its own falloff, and then it is the usual one for a light without\n * inverse-square falloff**: `(1 - (d/R)^2)^n` with no distance term, so a fill authored to\n * light a whole hall across a hundred metres does, where the inverse square would have it\n * gone a few metres out. Zero is the frame's own rule below, which every light was before.\n */\n if (lightExponent > 0.0) {\n float reach = dist / max(lightRadius, 1e-4);\n falloff = pow(clamp(1.0 - reach * reach, 0.0, 1.0), lightExponent);\n } else if (uLightFalloff == 1) {\n float window = clamp(1.0 - pow(dist / max(lightRadius, 1e-4), 4.0), 0.0, 1.0);\n falloff = window * window / max(dist * dist, 0.01);\n } else {\n falloff = clamp(1.0 - dist / lightRadius, 0.0, 1.0);\n }\n if (falloff <= 0.0) continue;\n float ndl = max(dot(n, toLight / max(dist, 1e-4)), 0.0);\n /*\n * **A lamp behind a pane is not skipped when the surface is glass**: it lights the pane through\n * the glass, shaped and shadowed like any lamp, and is added to glassGlow once its shape is\n * known below. For everything else a lamp behind the surface is skipped here, as always.\n */\n float backNdl = seesBehind ? max(-dot(n, toLight / max(dist, 1e-4)), 0.0) : 0.0;\n /* A model that sees light from behind keeps the lamps behind the surface. See models.ts. */\n if (ndl <= 0.0 && backNdl <= 0.0 && !modelledBehind) continue;\n\n /*\n * Normal-offset bias.\n *\n * The deck is a single enormous flat plane, so the light grazes nearly all\n * of it: one shadow texel covers a long stretch whose depth changes far\n * faster than any depth bias can absorb. Pushing the *sample position*\n * along the surface normal instead moves the receiver clear of its own\n * occluder in the direction that actually matters, and it scales with how\n * grazing the angle is. Without it the cubemap's face seams show as a\n * diagonal cross and the whole plane bands.\n */\n /* How present this light's shadow is. See `POINT_SHADOW_MIN_WEIGHT`. */\n float shadowWeight = clamp(falloff * 2.0, 0.0, 1.0);\n#if POINT_SHADOWS\n float grazing = 1.0 - ndl;\n float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;\n /* Toward a lamp behind a thin surface, which is in the map itself: away from it, the surface\n would stand in its own shadow. A pane is in no map, so glass keeps the usual side. */\n vec3 receiverPoint = vWorldPos + (thin > 0.0 && ndl <= 0.0 ? -n : n) * normalOffset;\n\n /*\n * uPointShadowWeight is how present the cubemap is, and it is the difference\n * between a shadow arriving and a shadow appearing. A pool slot changing hands\n * throws its image away, so the map behind a light can go from absent to complete\n * between two frames; sampling it at full strength the frame it lands is a shadow\n * switching on under a lamp that never moved. It ramps instead.\n */\n float occl = 1.0;\n /*\n * One lookup, where there were ten unrolled else-if arms.\n *\n * GLSL ES cannot index a sampler array with a non-constant expression, so twelve cubemaps\n * meant twelve declared samplers and a chain comparing this light against each slot's\n * owner. One array texture makes the map a *layer* \u2014 an ordinary integer \u2014 so the light\n * indexes the arrays directly and the chain is a compare against -1.\n */\n /*\n * **Indexed by the shadow slot, not by the light.** Clustering lifts how many lights shade a\n * fragment and not how many shadow maps exist \u2014 the pool is a memory budget \u2014 so a clustered\n * light either holds one of these slots or casts none, and `-1` says which. `clamp` keeps\n * the read in range for a light with no slot, whose `layer` is then forced negative anyway;\n * indexing a uniform array with -1 is undefined and would be a picture rather than an error.\n *\n * **And in range at the other end, which a shrunken budget is what made reachable.** A\n * clustered light carries its slot in the froxel record rather than in `i`, so a build with\n * `maxLights` below `MAX_LIGHTS_PER_CLUSTER` can be handed a slot past the end of these\n * arrays \u2014 undefined again, and again as a picture. Past the end is treated as *no shadow*\n * rather than clamped onto the last slot: a light wearing another light's occlusion is a\n * wrong picture, where a light that occludes nothing is the engine's own stated trade\n * everywhere else it runs out of room.\n */\n int shadowRead = clamp(shadowSlot, 0, MAX_LIGHTS - 1);\n int layer = shadowSlot >= 0 && shadowSlot < MAX_LIGHTS ? uPointShadowLayer[shadowRead] : -1;\n /* What the panes between this receiver and the lamp let through, per map: lampGlass.ts. */\n vec3 staticGlass = vec3(1.0);\n vec3 liveGlass = vec3(1.0);\n if (layer >= 0 && shadowWeight > POINT_SHADOW_MIN_WEIGHT) {\n staticGlass = pointGlassTint(\n float(layer),\n receiverPoint - uPointShadowProjection[shadowRead].xyz,\n uPointShadowProjection[shadowRead].w,\n uPointShadowNear[shadowRead],\n uPointShadowSize[shadowRead]\n );\n occl = mix(\n 1.0,\n pointShadow(\n uPointShadows,\n float(layer),\n /*\n * **From where the map was baked, not from where the light is.** The two are apart\n * by up to `pointShadowRebakeDistance` for anything that wanders, and both things\n * this asks the picture move with that gap: the distance it compares against the\n * stored one, and which texel the single-tap blocker search lands in. See\n * `uPointShadowOrigin`.\n */\n receiverPoint - uPointShadowProjection[shadowRead].xyz,\n uPointShadowProjection[shadowRead].w,\n uPointShadowNear[shadowRead],\n uPointShadowSize[shadowRead]\n ),\n uPointShadowWeight[shadowRead]\n );\n }\n\n // A live map contains movers only. Multiplication composes their occlusion\n // with the immutable world map, while the weight makes ownership changes a\n // crossfade rather than switching a whole character shadow in one frame.\n float liveOccl = 1.0;\n float liveWeight = 0.0;\n int liveLayer =\n shadowSlot >= 0 && shadowSlot < MAX_LIGHTS ? uLivePointShadowLayer[shadowRead] : -1;\n if (liveLayer >= 0 && shadowWeight > POINT_SHADOW_MIN_WEIGHT) {\n liveOccl = pointShadow(\n uPointShadows,\n float(liveLayer),\n /* Its own bake origin, for the reason the static layer above gives. */\n receiverPoint - uLivePointShadowProjection[shadowRead].xyz,\n uLivePointShadowProjection[shadowRead].w,\n uLivePointShadowNear[shadowRead],\n uLivePointShadowSize[shadowRead]\n );\n liveWeight = uLivePointShadowWeight[shadowRead];\n liveGlass = pointGlassTint(\n float(liveLayer),\n receiverPoint - uLivePointShadowProjection[shadowRead].xyz,\n uLivePointShadowProjection[shadowRead].w,\n uLivePointShadowNear[shadowRead],\n uLivePointShadowSize[shadowRead]\n );\n }\n occl *= mix(1.0, liveOccl, liveWeight);\n /* Each map's glass faded in by that map's presence, as its occlusion is. */\n vec3 lampGlass = mix(vec3(1.0), staticGlass, uPointShadowWeight[shadowRead]) * mix(vec3(1.0), liveGlass, liveWeight);\n#else\n float occl = 1.0;\n vec3 lampGlass = vec3(1.0);\n#endif\n\n // Blend occlusion out as the light fades, so a shadow can never be more\n // present than the light casting it \u2014 and slot changes at the edge of\n // range become invisible rather than a pop.\n float shaded = mix(1.0, occl, shadowWeight);\n /*\n * The cone multiplies the distance falloff rather than replacing it, which is what makes a\n * spot a point light with a direction rather than a second kind of light. It is exactly 1\n * for a light that declared no cone, so this line is arithmetically the one it replaces.\n */\n /*\n * **What a real fixture throws, rather than what a cone approximates.**\n *\n * An IES profile is a measured intensity by angle from the fixture's axis, so this is the\n * same angle the cone is measured at, read out of one row of the atlas. A light with no\n * profile carries a negative index and takes 1, which is the multiplicative identity and\n * costs a fetch nobody can avoid \u2014 see below.\n *\n * **The fetch is unconditional and `textureLod`, both deliberately.** This loop is not\n * provably uniform control flow: in the clustered arm the light index comes out of a\n * texture. A branch on the profile index would therefore be a non-uniform branch around a\n * sample, which is what the 2026-08-07 rule forbids and what lets a compiler flatten the\n * loop and cost every arm. So the row is clamped to a valid one, the sample always happens,\n * and the result is selected afterwards with a `mix` on a compare \u2014 which is arithmetic\n * rather than control flow.\n *\n * **And skipped outright where nothing loaded a profile**, behind `uFixtureShape.x`, which is\n * one while the atlas holds only its row of ones. A uniform is provably uniform control\n * flow, so this branch is not the one the rule forbids. It was paid on every scene until a\n * 4K frame of 320 lamps and no shadows measured it at 0.7 ms of the main pass, rising to\n * 2.3 ms with the lamps overlapping, which is the scene this note used to say would make\n * the fetch wrong. Every light then reads 1, which is what `step(0.0, iesProfile)` gave\n * each of them anyway.\n */\n float iesRow = max(iesProfile, 0.0);\n /* The direction the fixture throws toward this fragment, which both angles are measured on. */\n vec3 iesRay = -toLight / max(dist, 1e-4);\n /* The angle from the fixture's axis, as a fraction of the 0-to-180 arc every row spans. */\n float iesU = 0.0;\n if (fixtureProfiles) {\n float iesAngle = acos(clamp(dot(iesRay, lightDir), -1.0, 1.0));\n /* Clamped to the row's first and last texel centres, which is what the edge clamp of a\n texture exactly one row wide gave; the atlas can be wider now, with cookies beside it. */\n iesU = clamp(iesAngle / PI_IES * IES_WIDTH, 0.5, IES_WIDTH - 0.5) / fixtureSize.x;\n }\n /*\n * **The azimuth about the fixture's own axis**, behind a branch on a uniform so a scene whose\n * profiles are all axially symmetric pays none of this arithmetic. A `uniform float` is\n * provably uniform control flow \u2014 no wavefront can diverge on it \u2014 so this is not the\n * 2026-08-07 case, which is about a branch a compiler *cannot* prove uniform.\n *\n * The reference is orthogonalised against the aim rather than assumed perpendicular to it,\n * so a caller may hand the fixture's own forward; one parallel to the aim leaves nothing to\n * project and stays on the first plane rather than producing a NaN.\n */\n float iesTurns = 0.0;\n vec3 cookieTint = vec3(1.0);\n /*\n * **The fixture's own frame, built once and shared by the azimuth and the cookie**, because\n * both are asking the same question about a fixture \u2014 which way is up \u2014 and both read\n * `uLightIesAxis` to answer it. Behind a branch on two uniforms, so a scene using neither\n * builds no frame at all.\n */\n if (fixtureShapes) {\n vec3 iesRef = iesAxis - lightDir * dot(iesAxis, lightDir);\n float iesRefLen = length(iesRef);\n if (iesRefLen > 1e-5) {\n iesRef /= iesRefLen;\n vec3 iesBinormal = cross(lightDir, iesRef);\n if (uFixtureShape.y > 1.0) {\n /* `atan` answers -PI..PI and `fract` of a negative wraps forward, so this is 0..1\n across the whole turn without a branch on the sign. */\n iesTurns = fract(atan(dot(iesRay, iesBinormal), dot(iesRay, iesRef)) / TAU_IES);\n }\n if (uFixtureShape.z > 0.0) {\n /*\n * **A perspective projection onto the plane one unit down the aim**, which is what a\n * fixture actually throws: the mask grows with distance rather than being pasted on\n * at a fixed size.\n *\n * Scaled by the *outer cone's* tangent so a cookie fills whatever cone it is given \u2014\n * a mask drawn once works in a narrow spot and a wide one. A light with no cone\n * carries a cosine of -2 and the `max` below turns that into zero rather than a NaN;\n * such a light also carries no cookie, so the result is discarded either way.\n */\n float cookieAlong = max(dot(iesRay, lightDir), 1e-4);\n float cosOuter = lightCone.y;\n float tanOuter = sqrt(max(1.0 - cosOuter * cosOuter, 0.0)) / max(cosOuter, 1e-3);\n vec2 fromAxis =\n vec2(dot(iesRay, iesRef), dot(iesRay, iesBinormal)) / cookieAlong;\n vec2 cookieUv = fromAxis / max(tanOuter, 1e-3) * 0.5 + 0.5;\n /*\n * Clamped inside the tile rather than wrapped: past the cone edge the cone term is\n * already zero, so what matters is only that the sample cannot reach the neighbouring\n * cookie \u2014 which a bilinear fetch at a seam otherwise does.\n */\n float cookieTile = max(cookie, 0.0);\n vec2 cookieAt = vec2(\n (cookieTile + clamp(cookieUv.x, COOKIE_INSET, 1.0 - COOKIE_INSET)) * COOKIE_SPAN\n / fixtureSize.x,\n clamp(cookieUv.y, COOKIE_INSET, 1.0 - COOKIE_INSET) * COOKIE_SPAN / fixtureSize.y);\n /* Unconditional and `textureLod`, and selected with a `mix` afterwards: the light\n loop is not provably uniform control flow \u2014 the clustered arm reads its index from a\n texture \u2014 so a branch on the *light's* cookie index around a sample is what the\n 2026-08-07 rule forbids. */\n vec3 cookieSample = textureLod(uFixtureAtlas, cookieAt, 0.0).rgb;\n cookieTint = mix(vec3(1.0), cookieSample, step(0.0, cookie));\n }\n }\n }\n /*\n * **One fetch, and the atlas's own linear filter does the blend between planes.**\n *\n * A profile occupies `planes + 1` rows with the last repeating the first, so a fractional\n * row inside a profile is exactly the blend wanted and the wrap from the last plane back to\n * the first is a blend into that repeat rather than a bleed into the next profile. The\n * version this replaced took two explicit fetches and a `mix`, and cost **18,315 gzipped\n * bytes on `core-only`** across the sixteen fragment permutations.\n */\n float photometric = 1.0;\n if (fixtureProfiles) {\n float iesBase = iesRow * (uFixtureShape.y + 1.0);\n /* The profiles are the atlas's bottom rows, below any cookies. */\n float iesTop = fixtureSize.y - uFixtureShape.x;\n float iesV = (iesTop + iesBase + iesTurns * uFixtureShape.y + 0.5) / fixtureSize.y;\n float iesGain = textureLod(uFixtureAtlas, vec2(iesU, iesV), 0.0).r;\n photometric = mix(1.0, iesGain, step(0.0, iesProfile));\n }\n\n /* A light's own exponent already is its whole curve; the frame's smooth rule squares its own. */\n float shape =\n (lightExponent > 0.0 || uLightFalloff == 1 ? falloff : falloff * falloff) *\n coneFalloff *\n photometric;\n glassGlow += lightColor * backNdl * shape * lightWeight * shaded * lampGlass;\n if (ndl <= 0.0 && !modelledBehind) continue;\n /*\n * The cookie tints rather than scales, because a mask may be coloured \u2014 a stained window is\n * the case that makes the difference \u2014 and a greyscale one is exactly a scale. Applied to\n * the light's colour rather than to `shape`, which is a scalar and could not carry it.\n */\n lightColor *= cookieTint;\n /*\n * A model's answer to this lamp, held back and shadowed exactly as the standard terms below\n * are \u2014 the shadowed sum through `modelShade`, which is where skin's penumbra reddens.\n */\n if (modelled) {\n modelLight(toLight / max(dist, 1e-4), lightSourceRadius, dist);\n vec3 lampModelLight = lightColor * shape * lightWeight;\n lampOpen += (mDiffuse + mSpecular) * lampModelLight;\n lampShadowed += (mDiffuse * modelShade(shaded) + mSpecular * shaded) * lampModelLight * lampGlass;\n lightShade = min(lightShade, mix(1.0, shaded, lightWeight));\n continue;\n }\n /*\n * **A metal has no diffuse term, and this lamp kept giving it one.**\n *\n * The sun's diffuse has been multiplied by `(1 - metal)` since metalness arrived. The lamps'\n * never was, and it is added *after* the environment blend, so it could not even be dimmed\n * by the reflection standing in front of it: every point light in a scene laid a full\n * Lambertian wash over a surface that physically reflects none of it, on top of everything\n * else the surface showed.\n *\n * **That wash is what a metal looked like.** A broad, soft, view-independent term is the\n * definition of matte, and in a room lit by its own fixtures it is most of the pixel \u2014 which\n * is why a chromed subject read as painted plastic in a lamp-lit hall, a garage and a night\n * street alike, and why raising what it reflected by four times moved almost nothing. The\n * reflection was not missing; it was being drowned.\n *\n * The light is not lost. What a metal does with a lamp arrives through the highlight below,\n * which is already metal-aware, and through the room it mirrors, which is where a lamp's\n * energy belongs on a metal.\n *\n * This was written once before and taken back out, because removing it alone made a metal\n * darker with nothing to replace what it lost. What replaces it is `uEnvironmentGain`: a\n * consumer whose rooms are darker than the surfaces standing in them can raise what those\n * surfaces reflect, rather than lighting a metal in a way metals are not lit.\n *\n * At metal 0 this is one multiply by 1.0 away from the expression it replaces.\n */\n /*\n * Held back from `lit` until the loop is over, so the shadow can be resolved across a\n * pair of pixels first. See the block below the loop; nothing else about this term moved.\n */\n vec3 lampDiffuse = albedo * lightColor * ndl * shape * lightWeight * (1.0 - metal);\n lampOpen += lampDiffuse;\n lampShadowed += lampDiffuse * shaded * lampGlass;\n\n /*\n * A lamp reflected in a polished surface, which point lights could not do at all.\n *\n * The specular term used to answer only to the dominant directional source, so a world\n * with no sun in it had no highlights in it either. That is fine outdoors and wrong\n * everywhere else: a waxed floor under a strip light reflects the *strip*, and it is\n * most of what tells you the floor is polished rather than merely pale. An interior lit\n * entirely by its own fixtures had no way to say so.\n *\n * Gated on vSpecular, which almost nothing carries, so the rest of the world pays one\n * compare per light. Shares the sun's exponent and the same shadow and weight terms, so\n * a highlight cannot outlive the light casting it or survive a light fading out.\n */\n /*\n * The metal half is there because vSpecular defaults to 0 and every mesh meshBuilder builds\n * takes that default, so a metal gated on the attribute alone would take no highlight from\n * any lamp in the scene. The condition is the old one exactly when metal is 0, so nothing\n * that binds no map takes a different branch than it took before.\n */\n if (vSpecular > 0.0 || metal > 0.0) {\n vec3 toEyeLamp = normalize(uCameraPos - vWorldPos);\n vec3 lampHalfway = normalize(toLight / max(dist, 1e-4) + toEyeLamp);\n /* The same grazing whitening the sun's highlight takes, and zero at metal 0 for the look. */\n vec3 lampSpec = highlightTint(specColor, max(dot(toEyeLamp, lampHalfway), 0.0), metal);\n /* The look's lobe, or GGX's own where the surface asks for it: see `physicalLobe`. */\n float lampNdh = max(dot(n, lampHalfway), 0.0);\n float lampLobe = physicalSpecular()\n ? physicalLobe(lampNdh, ndl, dot(n, toEyeLamp), surfaceRoughness, lightSourceRadius, dist)\n : sphereLobe(lampNdh, surfaceRoughness, lightSourceRadius, dist);\n /* Held back with the diffuse half, and shadowed by the same term it always was. */\n vec3 lampHighlight = lightColor * lampLobe * lampSpec * shape * lightWeight;\n lampOpen += lampHighlight;\n lampShadowed += lampHighlight * shaded * lampGlass;\n }\n /*\n * The most any light in range shadows this point, for the emissive term below \u2014\n * weighted, because this one is not a colour multiply and would otherwise ignore\n * the ramp entirely. A light arriving at zero brightness would still switch a\n * shadow onto the marked line it is arriving over, which is precisely the surface\n * it was first watched happening on.\n */\n lightShade = min(lightShade, mix(1.0, shaded, lightWeight));\n }\n\n /*\n * **Rectangular area lights: an exact diffuse integral and an approximate specular.**\n *\n * The diffuse half is the closed form of the cosine-weighted integral over a polygon, which is\n * what a linearly transformed cosine reduces to when its matrix is the identity \u2014 so this is\n * not an approximation of it, it *is* it. The specular half is a representative point handed to\n * `sphereLobe`, which widens a lobe by the source's subtended angle and renormalises its\n * energy; that is the approximate half, and it diverges from a fitted LTC at grazing angles\n * where the real lobe stretches along the view. Both are stated at their definitions.\n *\n * **A loop over a count rather than a permutation**, and the arithmetic is on record: thirty\n * lines of shared shader cost 6,215 gzipped bytes across the sixteen permutations this session\n * and fifty cost 10,517, while the fifth flag clustered lighting nearly took would have cost\n * 196,910. Compiled in unconditionally this is about a twentieth of what permuting it costs,\n * and a count of zero is what makes it free at runtime.\n *\n * **It occludes, as of 2026-08-28.** It used to not, and the note here said so: the pool is\n * octahedral and centred on a point, and a rectangle is not one. What settled it is that a\n * rectangle *does* have a point \u2014 its centre \u2014 and that the only thing separating an area\n * shadow from a point one is where the filter's width comes from. So the bake is a point\n * light's bake and the filter is an ellipse the size of the emitter; see `areaShadow` for\n * what that is exact about and what it approximates. Off unless a rectangle asks, because\n * every scene that already declared one was authored against a light that passed through\n * stone.\n */\n for (int a = 0; a < MAX_AREA_LIGHTS; a++) {\n if (a >= uAreaLightCount) break;\n vec3 centre = uAreaLightPos[a];\n vec3 right = uAreaLightRight[a];\n vec3 up = uAreaLightUp[a];\n vec2 halfSize = uAreaLightSize[a].xy;\n /*\n * **The part of the rectangle this fragment sees past its barn doors** (`areaBarnDoors`),\n * which its light and highlight come from. Its shadow keeps the whole rectangle's centre and\n * size below, because that is what its map was baked from.\n */\n vec3 seenCentre = centre;\n vec2 seenHalf = halfSize;\n if (!areaBarnDoors(seenCentre, right, up, seenHalf, uAreaLightSize[a].zw)) continue;\n\n /* The form factor and its sign, which is the sidedness: see `areaSignedForm`. */\n float signedForm = areaSignedForm(n, seenCentre, right, up, seenHalf);\n float form = mix(max(0.0, signedForm), abs(signedForm), uAreaLightTwoSided[a]);\n /* The form factor is linear in the normal, so the one seen from behind is its negation. */\n float backForm = seesBehind\n ? mix(max(0.0, -signedForm), abs(signedForm), uAreaLightTwoSided[a])\n : 0.0;\n if (form <= 0.0 && backForm <= 0.0) continue;\n\n /*\n * **How much of the rectangle this fragment can actually see past whatever is in the way.**\n *\n * One octahedral layer baked from the rectangle's *centre*, filtered by an ellipse whose axes\n * are the rectangle's own \u2014 see `areaShadow`, which is where the emitter's shape enters.\n * Multiplies both halves below, because a surface in shadow is in shadow for its highlight\n * too: occluding only the diffuse term leaves a specular streak hanging in mid-air where the\n * caster is, which is the shape the water renderer's own notes call light passing through\n * stone.\n *\n * **Two layers, multiplied.** The static one holds the world and is baked once for a fixture\n * that does not move; the live one holds movers only and is refreshed every frame. Their\n * product is the composition, exactly as the point lights above compose theirs \u2014 a person\n * walking under a lit awning is in the live layer and the awning's posts are in the static\n * one, and neither map has to know about the other.\n *\n * **Not folded into `lightShade`**, which is what dims a self-lit surface when a lamp's\n * shadow crosses it. That term needs a per-light \"is this light arriving\" weight so a light\n * at zero brightness cannot switch a shadow onto a glowing inlay, and the point lights have\n * one in their own range ramp. An area light's `form` is an irradiance fraction rather than\n * a presence: it is 1 for a rectangle filling the sky and a few thousandths for a bright\n * panel across a room, so using it directly barely dims anything, and any threshold over it\n * is a constant nobody has measured. `docs/IMPROVEMENTS.md` carries the row.\n */\n float areaOccl = 1.0;\n vec3 areaGlass = vec3(1.0);\n#if POINT_SHADOWS\n /*\n * The sample position is pushed along the surface normal before the lookup, and it scales\n * with how grazing the light is \u2014 the same cure and the same expression the point path uses a\n * few hundred lines up. Depth bias alone cannot fix a grazing receiver without peter-panning,\n * because one shadow texel then covers a stretch of floor whose depth changes faster than any\n * constant. The direction to the rectangle's *centre* is what \"grazing\" means here, which is\n * the same centre the map was baked from.\n */\n vec3 areaCentreVec = centre - vWorldPos;\n float areaCentreDist = length(areaCentreVec);\n float areaCentreNdl = areaCentreDist > 1e-4\n ? max(dot(n, areaCentreVec / areaCentreDist), 0.0)\n : 0.0;\n float areaGrazing = 1.0 - areaCentreNdl;\n float areaNormalOffset = (0.06 + areaCentreDist * 0.05) * areaGrazing * areaGrazing + 0.02;\n vec3 areaShadowFrom = (vWorldPos + n * areaNormalOffset) - centre;\n\n /*\n * `-1` is a rectangle with no image: one that declined to cast, one that named no range,\n * or one whose first bake has not landed yet. A layer index rather than a flag, for the\n * reason the point path's is one \u2014 GLSL ES cannot index a sampler array with a non-constant\n * expression, and a layer is an ordinary integer.\n */\n int areaLayer = uAreaShadowLayer[a];\n if (areaLayer >= 0) {\n areaOccl = mix(\n 1.0,\n areaShadow(\n uPointShadows,\n float(areaLayer),\n areaShadowFrom,\n uAreaShadowFar[a],\n uAreaShadowNear[a],\n right,\n up,\n halfSize\n ),\n uAreaShadowWeight[a]\n );\n /* What the panes before the rectangle let through, faded in as its occlusion is. */\n areaGlass = mix(\n vec3(1.0),\n areaGlassTint(float(areaLayer), areaShadowFrom, uAreaShadowFar[a], uAreaShadowNear[a], right, up, halfSize),\n uAreaShadowWeight[a]\n );\n }\n int areaLiveLayer = uLiveAreaShadowLayer[a];\n if (areaLiveLayer >= 0) {\n areaOccl *= mix(\n 1.0,\n areaShadow(\n uPointShadows,\n float(areaLiveLayer),\n areaShadowFrom,\n uLiveAreaShadowFar[a],\n uLiveAreaShadowNear[a],\n right,\n up,\n halfSize\n ),\n uLiveAreaShadowWeight[a]\n );\n areaGlass *= mix(\n vec3(1.0),\n areaGlassTint(\n float(areaLiveLayer),\n areaShadowFrom,\n uLiveAreaShadowFar[a],\n uLiveAreaShadowNear[a],\n right,\n up,\n halfSize\n ),\n uLiveAreaShadowWeight[a]\n );\n }\n#endif\n\n glassGlow += uAreaLightColor[a] * backForm * areaOccl * areaGlass;\n if (form <= 0.0) continue;\n /* The diffuse, the model's answer or the lobe integrated over the rectangle: see areaLight.ts. */\n areaLightAdd(\n seenCentre, right, up, seenHalf, uAreaLightColor[a], form, areaOccl, areaGlass,\n n, albedo, metal, specColor, surfaceRoughness, modelled, lampOpen, lampShadowed\n );\n }\n\n /*\n * **The other half of the shadow filter: the two pixels of a column pair, folded together.**\n *\n * `pointShadow` turns its twelve taps by half a golden angle on every other column, so a\n * pair of neighbouring pixels holds twenty-four places on the disk between them and neither\n * holds more than twelve. This is where the pair becomes one filter. `dFdx` of a value is\n * the difference across the pair, and half of it, signed by which of the two this pixel is,\n * is exactly the average of both \u2014 so each pixel ends up shaded by all twenty-four taps\n * while paying for twelve. It is the pairing `ambientOcclusion.ts` describes between its\n * rotation tile and its blur, at the smallest size that needs no second pass.\n *\n * **After both lamp loops rather than beside the taps, and that is why this block exists at\n * all.** A derivative is only defined where every pixel of the quad reaches it, and neither\n * loop above reaches anything uniformly: the point loop `continue`s on falloff and on\n * facing, and in the clustered arm its trip count is read from a froxel, so two neighbouring\n * pixels can be shading different lights with a froxel boundary drawn across the screen\n * where they stop agreeing. A `dFdx` in there is the 2026-08-07 rule's own case, and it\n * would also invite a compiler to flatten the loop. Measured, too: with the turn taken from\n * a per-fragment hash instead of the column, the same resolve made the penumbra *worse*\n * than no resolve at all \u2014 3.14 against 3.00 \u2014 because the hardware here answers a coarse\n * derivative and the second row of a quad then borrows the first row's difference. The\n * column tile is what makes that borrowing correct, so the two halves are one mechanism.\n *\n * Both lamp kinds are in the sums, because a rectangle's shadow is filtered by the same\n * twelve taps and stippled the same way.\n *\n * **Clamped, and the clamp is a guard rather than tidying.** A fragment killed by the\n * cutout or the clip plane above leaves its lane without a value for its neighbour to\n * difference against, and GLSL ES says nothing about what comes back. What could reach the\n * frame is a shadow term outside 0..1 on the pixel beside a cutout hole, which the clamp\n * turns into a pixel that is merely fully lit or fully shadowed. **What would make this\n * wrong** is a consumer whose cutout edges are most of the frame \u2014 dense foliage drawn as\n * one alpha-tested sheet \u2014 where the honest answer is to resolve in a pass of its own,\n * against a shadow term written to a target.\n *\n * The floor under the divisor costs nothing and answers the case where no lamp reached this\n * pixel at all: both sums are zero there, so the ratio is zero and multiplying it back by\n * the floor adds nothing.\n */\n vec3 lampOpenFloor = max(lampOpen, vec3(1e-6));\n vec3 lampShade = lampShadowed / lampOpenFloor;\n#if POINT_SHADOWS\n lampShade = clamp(\n lampShade - dFdx(lampShade) * (float(int(gl_FragCoord.x) & 1) - 0.5),\n 0.0,\n 1.0\n );\n#endif\n lit += lampOpenFloor * lampShade;\n /*\n * DriftLight: every fixed light past the frame's choice, summed and occluded, for the share of\n * this pixel the exact lights above no longer cover. Diffuse only, and so no metal term: see\n * `driftLight.ts`.\n */\n if (driftShare > 0.0) {\n if (seesBehind) {\n glassGlow += driftLightIrradiance(vWorldPos, -n) * (driftShare * uDriftLight.w);\n }\n /* The standard expression in its own order \u2014 albedo times the field, then the share \u2014 so a\n pipeline with no model rounds exactly as it did; a model is handed the light alone. */\n vec3 driftField = driftLightIrradiance(vWorldPos, n);\n lit += modelled\n ? modelIrradiance(driftField * (driftShare * uDriftLight.w))\n : albedo * driftField * (driftShare * uDriftLight.w) * (1.0 - metal);\n }\n\n /*\n * Self-illuminated geometry, dimmed where a lamp's shadow crosses it.\n *\n * Emission used to be added straight after the sun term and touched by nothing, so a\n * shadow could not land on a glowing surface at all \u2014 a character standing on the\n * terrace threw a shadow across the deck and the inlaid bands under it stayed at full\n * brightness, as though the shadow passed beneath them. Reported as the shadow going\n * *under* the inlaid markings rather than over them \u2014 and, narrowed down, as a fault\n * in the point lights' shadows specifically: a character's or a prop's, with the sun's\n * apparently unaffected.\n *\n * The sun is not affected because emission is gated on uNightFactor and so does not\n * exist by day, when the sun's shadow is the one being cast. At night the lamps are the\n * light and their occlusion is per-light inside the loop above, which the emissive term\n * never saw. Hence lightShade: the most any lamp in range shadows this point.\n *\n * Only a share of it, because none of these surfaces is a pure emitter \u2014 a lit inlay is\n * worked stone with light coming through it, and the stone is shadowed like everything\n * else. Taking all of it would darken a lamp head because something crossed in front of\n * it, which is a worse picture than the one being fixed.\n */\n /*\n * The sun and the moon shadow a glowing surface too.\n *\n * lightShade fixed this for lamps and stopped there, on the reasoning that\n * emission is gated on uNightFactor and so does not exist by day, when the sun's\n * shadow is the one being cast. That is true at noon and false at every hour that\n * matters: at dusk the bands are already lit and the sun is at its longest, and at\n * night the moon is the directional source and casts exactly the same way.\n *\n * So an emissive deck \u2014 emissive 0.55 \u2014 took a lamp post's shadow from a lamp and\n * not from the sky, and a post that threw a long shadow across the grass by day\n * threw none across the deck at night. Reported as self-shadowing that was present\n * under the sun and gone at night.\n *\n * Same share as the lamps get, and for the same reason: these surfaces are worked\n * stone with light coming through, not pure emitters, so taking all of it would\n * black out a lamp head because something crossed in front of it.\n */\n /*\n * What a surface emits is not always what it is made of.\n *\n * A negative component means no colour was named, and the term falls back to the albedo,\n * which is this engine's original behaviour and right for anything glowing because it is\n * hot or lit from within. Where a colour is named it replaces the albedo outright rather\n * than tinting it, because the point is a glow whose hue is independent of the paint\n * underneath: a ceiling emitting a dull warm haze over a pale panel cannot be expressed\n * by scaling the panel, and scaling it gets the brightness right and the hue wrong.\n */\n /* What a thin surface lets through from behind, through its own colour; a metal lets none. */\n if (thin > 0.0) lit += albedo * glassGlow * (thin * (1.0 - metal));\n diffuseAlone = lit;\n vec3 emissiveTint = vEmissiveColor.r < 0.0 ? albedo : vEmissiveColor;\n /*\n * Where the surface glows, from an image, or everywhere if it binds none.\n *\n * **A multiplier of exactly 1 when no map is bound**, so this line collapses to the arithmetic\n * it replaces bit for bit \u2014 which is what the zero-pixel gate on the published scenes is\n * measuring, none of them binding one.\n *\n * **It modulates the emission rather than creating it**, which is glTF's rule: emitted colour\n * is the factor times the texture. A mesh whose `vEmissive` is 0 stays dark however bright the\n * image, because the whole term is still multiplied by it. That is stated here as well as at\n * `SurfaceMaterial.emissive`, because this is where somebody debugging a model that will not\n * glow ends up looking.\n *\n * The branch is on a uniform, so the `texture` call sits in uniform control flow and needs no\n * explicit level \u2014 the 2026-08-07 rule is satisfied by the gate rather than by a `textureLod`.\n */\n vec3 emissiveMapped = vec3(1.0);\n if (uMaterialFlags.z != 0) {\n emissiveMapped = emissiveTexel(surfaceAt) * uEmissiveScale;\n }\n /* Pulse, flicker and fade from the layer's effects; exactly 1 where it names none. */\n float fxGlow = fx4.z + fx5.x + fx5.w > 0.0 ? fxEmission(surfaceAt.z) : 1.0;\n lit += emissiveTint * emissiveMapped * uEmission.x * vEmissive * uEmission.y\n * mix(1.0, min(lightShade, sunShade), EMISSIVE_SHADOW_SHARE) * fxGlow;\n /* And a lit window's glow, which the scene's lit share already times: not gated on night. */\n lit += windowGlow * uEmission.x;\n /* And a draw's overlay: its rim, its dissolve's band and the colour laid over. Not gated on the\n night, as emission is: a glow the surface wears rather than light it gives off. overlay.ts. */\n if (SURFACE_OVERLAY) lit += overlayGlow(surfaceAt.xy, n);\n\n#if NIGHT_EMISSIVE\n /*\n * And the same emission again, weighted by how far this surface faces away from the sun.\n *\n * uDirectionalDir points from the surface toward the source, so its negative dot with the\n * normal is how far into its own night this fragment is: 0 anywhere the sun can see and 1 at\n * the antisolar point. Added rather than mixed, and outside uNightFactor, because a body that\n * turns has a night side at every hour of whatever clock the world is keeping.\n *\n * Not shadowed by lightShade: a city is not switched off by a mountain standing in front of\n * the sun, and the term is already zero everywhere the sun reaches.\n *\n * **It is not free when it is off, and that is measured rather than assumed.** Held on the\n * gilded chamber with uNightEmissive at 0, against the build before this term existed: 110\n * pixels of 750,080 move, in scattered clusters of two to four, mean delta 17. Isolated by\n * elimination: the uniform declared and never used moves 0 pixels, the branch present with an\n * empty body moves 1, which is that scene's own floor, and the arithmetic above moves 110. So\n * it is the added instructions perturbing the rounding of shared subexpressions, and what\n * lands on screen is a shadow comparison sitting exactly on its boundary flipping sides.\n * Neither result is more correct than the other. Sharing the dot product above was tried as a\n * repair and changed nothing.\n */\n if (uNightEmissive > 0.0) {\n /* The same emission, so the same map: a night side is not a different material. */\n lit += emissiveTint * emissiveMapped * uNightEmissive * vEmissive * max(-sunDot, 0.0);\n }\n#endif\n\n // Arrival glow: a world-space box lit independently of the clock, so\n // finishing a run reads the same at noon and at midnight. Costs one compare\n // per fragment and no extra draw call \u2014 the box is a uniform, not geometry.\n if (uHighlightMax.w > 0.0) {\n vec3 inside = step(uHighlightMin.xyz, vWorldPos) * step(vWorldPos, uHighlightMax.xyz);\n lit += albedo * uHighlightMax.w * inside.x * inside.y * inside.z;\n }\n }\n\n /*\n * The medium: fog and the underwater tint, both \u2014 see `uFogEnabled`'s own comment for why\n * the two are folded together and what a draw that skips them gives up. Every other pass in\n * the renderer binds `uUnderwaterFactor` unconditionally so that crossing the surface moves\n * the whole scene together; this is the one draw where that is the caller's choice.\n */\n /*\n * Baked occlusion, over everything this surface contributes.\n *\n * **Deliberately the same scope as the screen-space AO the composite pass applies**, which\n * multiplies the whole frame including direct light and emissive \u2014 see rush.ts. The narrower\n * physical reading, ambient and reflection only, was considered and not taken: consistency with\n * the effect already shipping won over it.\n *\n * **The stated cost of that is a double count with the shadow maps.** Where a baked crevice and\n * a cast shadow fall on the same fragment, both darken it, and the ambient-only reading would\n * not have. If that ever becomes visible enough to report, narrowing this is the fix.\n *\n * Outside the lighting branch, so an unlit draw \u2014 uLightingEnabled at 0, which is\n * meshBasicMaterial \u2014 is occluded too. Before the fog block, because fog is between the eye and\n * the surface, and darkening it would darken the air in front of a crevice rather than the\n * crevice.\n *\n * Exactly 1.0 with no map bound, so this is a multiply by one on every published scene.\n */\n lit *= ormOcclusion;\n\n float fog = 0.0;\n if (uFogEnabled != 0) {\n // Water is a camera medium, not a property of individual objects.\n vec3 waterTransmission = vec3(0.42) + uUnderwaterColor * 2.0;\n lit = mix(lit, lit * waterTransmission, uUnderwaterFactor * 0.55);\n diffuseAlone = mix(diffuseAlone, diffuseAlone * waterTransmission, uUnderwaterFactor * 0.55);\n fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n }\n /* The diffuse pass leaves here, with skin's diffuse and the profile it is spread by. */\n if (modelDiffuseAlone()) {\n outColor = vec4(modelDiffuseOut(diffuseAlone * ormOcclusion, fog), skinProfileCode());\n return;\n }\n\n /*\n * A surface recedes into the medium; light added to the frame fades in it (`uFogEnabled` at 2,\n * `drawFog.ts`). What is behind an additive draw is already fogged, so mixing the draw toward\n * the medium's colour and adding it puts the haze into the frame twice \u2014 a glow by day drew as\n * a grey shape of itself, greyer the thicker the air.\n */\n vec3 shaded = applyOutputTransform(\n uFogEnabled == 2 ? lit * (1.0 - fog) : mix(lit, mediumColor(), fog));\n\n /*\n * **Refraction: the scene behind this surface, bent and absorbed.**\n *\n * The offset is along the shading normal in screen space, which is the cheap approximation every\n * real-time renderer ships rather than a ray traced through two interfaces of a solid. What it\n * cannot do is show anything the frame had not already drawn, so a pane at the edge of the frame\n * refracts what is beside it -- and the clamp below is what keeps that from being worse than\n * that. Without it a sample walks off the edge and wraps, which reads as a tear rather than as\n * an approximation.\n *\n * **Beer-Lambert, with the caller stating what survives one metre** rather than an absorption\n * coefficient: pow(tint, d) is exp(d * log(tint)), so a tint of one is clear glass with no\n * special case and there is no log of zero at the API to trap on. What it costs is that a caller\n * holding coefficients from a reference converts them once.\n *\n * **The path length divides by the view angle, and that is the whole reason this is not a\n * tint.** A slab seen edge-on is more glass than the same slab seen face-on, which is what makes\n * a glass edge go green while its middle stays clear; a tint multiplied straight in gives the\n * same colour at every angle. The clamp is a floor on the grazing case rather than a tolerance:\n * dot reaches zero at the silhouette, and an unclamped divide takes the pixel to infinity and\n * then to a NaN, which takes the fragment with it. Twenty thicknesses is where it caps, so a\n * medium that should go opaque at a grazing angle stops getting darker there.\n *\n * **It replaces the surface's own shading rather than mixing with it, and the strength is the\n * offset alone.** Mixing by the strength was tried first and is wrong twice over: the offset and\n * the amount of background shown are different quantities, so one scalar cannot be both -- a\n * plausible offset of 0.025 UV then meant a 2.5% blend, and a pane of glass drew as flat paint\n * over the thing it was supposed to be showing. Found by this row's own check, where every bar\n * behind the pane vanished.\n *\n * What replacing costs is the surface's own highlight: glass here shows the scene behind it and\n * not a specular of its own, because by this line the shaded colour has the lighting, the fog and\n * the tone curve already folded into it and there is no specular left to keep. A caller wanting a\n * glint draws a second, non-refracting pass over the same geometry. What would change it is\n * splitting the specular out before the fog, which is a larger change to the one shader every\n * draw uses.\n */\n if (uSeeThrough.x > 0.0 || glassTransmission > 0.0) {\n /*\n * **Bent by the shading normal, so a normal map shimmers what is seen through it** \u2014 a heat\n * haze, a distortion sphere, a rippled pane. Until 4.8.6 this was the geometric normal and a\n * mapped surface refracted as a smooth lens. An unlit draw shaded no normal, so it reads the\n * map here, on a branch on uniforms alone, which keeps its derivatives legal.\n */\n vec3 refractN = shadingNormal;\n if (uLightingEnabled == 0 && uNormalStrength > 0.0) refractN = normalMapped(refractN, surfaceAt, frameAt, frameTangents);\n vec3 refractV = normalize(uCameraPos - vWorldPos);\n ivec2 snapSize = textureSize(uRefractScene, 0);\n vec2 screenUv = gl_FragCoord.xy / vec2(snapSize);\n /*\n * **The normal's sideways part as the eye sees it**: the shading normal in view space, its x\n * and y across the screen, y turned to the way this frame's rows run (w of the tint). It was the\n * normal's world x and y until 4.10.0, which on a quad facing the camera is the quad's own lean\n * against the world's axes: one offset over the whole quad, a lens, with a normal map's tilt a\n * small change on top. In view space a quad facing the eye bends by nothing but its map, which\n * is a shimmer, and a pane bends by how it leans toward the eye wherever the eye stands.\n */\n vec3 seenN = mat3(uView) * refractN;\n vec2 bend = vec2(seenN.x, seenN.y * uRefractTint.w);\n vec2 bent = clamp(screenUv + bend * uSeeThrough.x, vec2(0.0), vec2(1.0));\n /*\n * **An explicit level, and frost is what picks it** \u2014 the 2026-08-07 rule, and the reason a\n * frosted pane blurs rather than fades: the copy carries a mip chain on a frame that draws glass,\n * so a milkier pane reads a coarser level of it. Level 0 on a copy with one level is exactly what\n * `texture()` read here before, so plain refraction draws as it did.\n */\n /* Two short of the last level: a fully frosted pane still shows the large light and dark of what\n is behind it, where the chain's last level is one texel, the whole frame's average. */\n float levels = max(floor(log2(float(max(snapSize.x, snapSize.y)))) - 2.0, 0.0);\n vec3 behind = textureLod(uRefractScene, bent, glassFrost * levels).rgb;\n /* Unsigned: a pane passes the same light seen from either side, and so does its Fresnel. */\n float cosView = max(abs(dot(refractN, refractV)), 0.05);\n float pathLength = uSeeThrough.y * vThickness / cosView;\n vec3 seen = behind * pow(uRefractTint.rgb, vec3(pathLength));\n if (glassTransmission > 0.0) {\n /*\n * **Glass keeps its own surface and shows what is behind over it**, by how much it lets\n * through and how little it reflects at this angle \u2014 Schlick's Fresnel at 0.04, the arithmetic\n * `glass.ts` states. So a clear pane keeps its highlight, and a pane seen edge-on is a mirror.\n * The glow of the lights behind it is added last, by how frosted it is.\n */\n float fresnel = 0.04 + 0.96 * pow(1.0 - cosView, 5.0);\n shaded = mix(shaded, seen * uGlassTint.rgb, glassTransmission * (1.0 - fresnel)) +\n applyOutputTransform(glassGlow * uGlassTint.rgb * (glassFrost * glassTransmission) * (1.0 - fog));\n } else {\n shaded = seen;\n }\n }\n /*\n * The per-vertex alpha lane, folded in here rather than given an expression of its own so the\n * order-independent branch below inherits it through the same local. 1.0 when absent.\n *\n * **It did not need MeshData.colors widened to four floats**, which is how the consumer who\n * asked for it had costed the change -- widening colors moves every mesh producer, the\n * container layout and every baked asset. Alpha is a factor on the draw's opacity, not a\n * component of its albedo, so it rides a lane instead and nothing existing moves. Where the draw\n * spends it on the screen door instead (`uWriteMode.z`), it was spent at the top and is 1 here.\n */\n float alpha = uOpacity * coverage * (uWriteMode.z != 0.0 ? 1.0 : vAlpha);\n\n /*\n * **Order-independent transparency, when this draw is accumulating into it.**\n *\n * The weight falls off with distance so a near layer counts for more than a far one, and it is\n * clamped at both ends: without a ceiling a fragment on the near plane takes a weight large\n * enough to swamp the others inside a 16-bit target, and without a floor a distant one rounds to\n * nothing and vanishes rather than fading. The same arithmetic is `oitWeight` in\n * `orderIndependent.ts`, where it is asserted against numbers.\n *\n * The output is premultiplied and weighted, against a blend of `(ONE, ONE)`, so the target ends\n * up holding the weighted sum of colour and the weighted sum of alpha. The revealage pass draws\n * the same geometry with this at 0, where the branch below is not taken at all.\n */\n if (uWriteMode.x != 0.0) {\n float z = distance(vWorldPos, uCameraPos) / 200.0;\n float falloff = 0.03 / (1e-5 + z * z * z * z);\n float w = alpha * clamp(falloff, 1e-2, 3e3);\n outColor = vec4(shaded * alpha * w, alpha * w);\n return;\n }\n\n /* WebGL2's frame writes the reflection maps itself, with the fog already reckoned above. */\n if (REFLECTION_MAPS) {\n reflectionSurfaceOut(reflectedProbe, reflectedTint, reflectedRoughness, ormOcclusion, fog);\n }\n outColor = vec4(shaded, alpha);\n}\n";