@driftengine/core 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/CHANGELOG.json +282 -0
  2. package/README.md +1 -1
  3. package/dist/cinematic/cameraPath.d.ts +53 -0
  4. package/dist/cinematic/cameraPath.js +118 -0
  5. package/dist/environment/daylight.d.ts +77 -0
  6. package/dist/environment/daylight.js +77 -0
  7. package/dist/geometry/meshConcat.d.ts +9 -0
  8. package/dist/geometry/meshConcat.js +101 -0
  9. package/dist/index.d.ts +12 -2
  10. package/dist/index.js +14 -1
  11. package/dist/render/ambientOcclusionPass.js +5 -1
  12. package/dist/render/backend/createRenderer.d.ts +13 -1
  13. package/dist/render/backend/createRenderer.js +6 -5
  14. package/dist/render/backend/webgl2/renderer.d.ts +147 -1
  15. package/dist/render/backend/webgl2/renderer.js +402 -23
  16. package/dist/render/backend/webgpu/buffers.js +46 -8
  17. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  18. package/dist/render/backend/webgpu/depthPass.js +75 -14
  19. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  20. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  21. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  22. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  23. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  24. package/dist/render/backend/webgpu/flatPass.js +12 -5
  25. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  26. package/dist/render/backend/webgpu/particlePass.js +1 -0
  27. package/dist/render/backend/webgpu/postPass.d.ts +13 -3
  28. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  29. package/dist/render/backend/webgpu/probeBake.js +97 -7
  30. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  31. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  32. package/dist/render/backend/webgpu/renderer.d.ts +174 -11
  33. package/dist/render/backend/webgpu/renderer.js +682 -252
  34. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  35. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  36. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  37. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  38. package/dist/render/backend/webgpu/skyPass.js +5 -1
  39. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  40. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  41. package/dist/render/bloomChain.d.ts +6 -0
  42. package/dist/render/bloomChain.js +8 -0
  43. package/dist/render/clusteredLights.d.ts +19 -11
  44. package/dist/render/clusteredLights.js +96 -45
  45. package/dist/render/cutoutCaster.d.ts +23 -0
  46. package/dist/render/cutoutCaster.js +20 -0
  47. package/dist/render/driftLight/bake.d.ts +34 -0
  48. package/dist/render/driftLight/bake.js +95 -0
  49. package/dist/render/driftLight/falloff.d.ts +16 -0
  50. package/dist/render/driftLight/falloff.js +23 -0
  51. package/dist/render/driftLight/layout.d.ts +59 -0
  52. package/dist/render/driftLight/layout.js +138 -0
  53. package/dist/render/driftLight/lightField.d.ts +108 -0
  54. package/dist/render/driftLight/lightField.js +179 -0
  55. package/dist/render/driftLight/uniforms.d.ts +28 -0
  56. package/dist/render/driftLight/uniforms.js +41 -0
  57. package/dist/render/driftLight/visibility.d.ts +18 -0
  58. package/dist/render/driftLight/visibility.js +47 -0
  59. package/dist/render/emptyTexture.d.ts +6 -0
  60. package/dist/render/emptyTexture.js +19 -0
  61. package/dist/render/exposurePass.d.ts +48 -0
  62. package/dist/render/exposurePass.js +172 -0
  63. package/dist/render/flameLight.d.ts +71 -0
  64. package/dist/render/flameLight.js +63 -0
  65. package/dist/render/gi/bounceLights.d.ts +34 -0
  66. package/dist/render/gi/bounceLights.js +66 -0
  67. package/dist/render/gi/fieldReach.d.ts +12 -0
  68. package/dist/render/gi/fieldReach.js +62 -0
  69. package/dist/render/gi/globalField.d.ts +8 -0
  70. package/dist/render/gi/globalField.js +31 -0
  71. package/dist/render/gi/probeTrace.d.ts +3 -1
  72. package/dist/render/gi/probeTrace.js +3 -1
  73. package/dist/render/gpuCapability.js +19 -4
  74. package/dist/render/lightBudget.d.ts +11 -0
  75. package/dist/render/lightBudget.js +11 -0
  76. package/dist/render/pointLightSelection.d.ts +16 -0
  77. package/dist/render/pointLightSelection.js +178 -83
  78. package/dist/render/pointShadowBudget.d.ts +12 -2
  79. package/dist/render/pointShadowBudget.js +16 -10
  80. package/dist/render/probeGrid.d.ts +11 -1
  81. package/dist/render/probeGrid.js +5 -2
  82. package/dist/render/probeSweeps.d.ts +45 -0
  83. package/dist/render/probeSweeps.js +77 -0
  84. package/dist/render/reflectionProbe.d.ts +43 -1
  85. package/dist/render/reflectionProbe.js +17 -3
  86. package/dist/render/renderQuality.d.ts +3 -1
  87. package/dist/render/rendererHarness.d.ts +9 -0
  88. package/dist/render/rendererHarness.js +18 -3
  89. package/dist/render/sceneTarget.d.ts +20 -0
  90. package/dist/render/sceneTarget.js +62 -1
  91. package/dist/render/shaders/ambientOcclusion.d.ts +63 -6
  92. package/dist/render/shaders/ambientOcclusion.js +188 -71
  93. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  94. package/dist/render/shaders/cutoutCoverage.js +40 -0
  95. package/dist/render/shaders/depth.d.ts +25 -0
  96. package/dist/render/shaders/depth.js +69 -3
  97. package/dist/render/shaders/exposure.d.ts +52 -0
  98. package/dist/render/shaders/exposure.js +124 -0
  99. package/dist/render/shaders/filmLook.d.ts +47 -0
  100. package/dist/render/shaders/filmLook.js +98 -0
  101. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  102. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  103. package/dist/render/shaders/flat/driftLight.js +75 -0
  104. package/dist/render/shaders/flat/index.js +5 -0
  105. package/dist/render/shaders/flat/lobes.js +13 -0
  106. package/dist/render/shaders/flat/main.d.ts +1 -1
  107. package/dist/render/shaders/flat/main.js +68 -9
  108. package/dist/render/shaders/flat/preamble.js +23 -5
  109. package/dist/render/shaders/flat/probeGrid.js +46 -3
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +8 -3
  111. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +8 -3
  112. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  113. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  115. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  116. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  117. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  118. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  119. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  120. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  121. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  123. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  124. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  125. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  126. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  127. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  128. package/dist/render/shaders/globalMedium.js +4 -0
  129. package/dist/render/shaders/localExposure.d.ts +35 -0
  130. package/dist/render/shaders/localExposure.js +138 -0
  131. package/dist/render/shaders/prefilter.d.ts +1 -1
  132. package/dist/render/shaders/prefilter.js +6 -0
  133. package/dist/render/shaders/rush.js +33 -5
  134. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  135. package/dist/render/shaders/tangentFrame.js +15 -3
  136. package/dist/render/shaders/temporalResolve.js +39 -1
  137. package/dist/render/shadowCasters.d.ts +5 -3
  138. package/dist/render/shadowMap.js +13 -0
  139. package/dist/render/surfaceTexture.d.ts +5 -0
  140. package/package.json +6 -6
  141. package/scripts/gpuCompute.mjs +24 -3
  142. package/scripts/png.mjs +23 -12
  143. package/src/cinematic/cameraPath.ts +178 -0
  144. package/src/environment/daylight.ts +152 -0
  145. package/src/geometry/meshConcat.ts +100 -0
  146. package/src/index.ts +32 -0
  147. package/src/render/ambientOcclusionPass.ts +5 -1
  148. package/src/render/backend/createRenderer.ts +22 -6
  149. package/src/render/backend/webgl2/renderer.ts +439 -20
  150. package/src/render/backend/webgpu/buffers.ts +51 -8
  151. package/src/render/backend/webgpu/depthPass.ts +78 -10
  152. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  153. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  154. package/src/render/backend/webgpu/flatPass.ts +10 -2
  155. package/src/render/backend/webgpu/particlePass.ts +9 -0
  156. package/src/render/backend/webgpu/probeBake.ts +143 -9
  157. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  158. package/src/render/backend/webgpu/renderer.ts +808 -273
  159. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  160. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  161. package/src/render/backend/webgpu/skyPass.ts +5 -1
  162. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  163. package/src/render/bloomChain.ts +9 -0
  164. package/src/render/clusteredLights.ts +108 -63
  165. package/src/render/cutoutCaster.ts +44 -0
  166. package/src/render/driftLight/bake.ts +125 -0
  167. package/src/render/driftLight/falloff.ts +26 -0
  168. package/src/render/driftLight/layout.ts +177 -0
  169. package/src/render/driftLight/lightField.ts +227 -0
  170. package/src/render/driftLight/uniforms.ts +61 -0
  171. package/src/render/driftLight/visibility.ts +58 -0
  172. package/src/render/emptyTexture.ts +41 -0
  173. package/src/render/exposurePass.ts +186 -0
  174. package/src/render/flameLight.ts +135 -0
  175. package/src/render/gi/bounceLights.ts +68 -0
  176. package/src/render/gi/fieldReach.ts +74 -0
  177. package/src/render/gi/globalField.ts +39 -0
  178. package/src/render/gi/probeTrace.ts +3 -1
  179. package/src/render/gpuCapability.ts +18 -5
  180. package/src/render/lightBudget.ts +12 -0
  181. package/src/render/pointLightSelection.ts +191 -79
  182. package/src/render/pointShadowBudget.ts +16 -8
  183. package/src/render/probeGrid.ts +13 -2
  184. package/src/render/probeSweeps.ts +80 -0
  185. package/src/render/reflectionProbe.ts +54 -2
  186. package/src/render/renderQuality.ts +3 -1
  187. package/src/render/rendererHarness.ts +25 -3
  188. package/src/render/sceneTarget.ts +75 -1
  189. package/src/render/shaders/ambientOcclusion.ts +205 -71
  190. package/src/render/shaders/cutoutCoverage.ts +43 -0
  191. package/src/render/shaders/depth.ts +80 -3
  192. package/src/render/shaders/exposure.ts +128 -0
  193. package/src/render/shaders/filmLook.ts +111 -0
  194. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  195. package/src/render/shaders/flat/driftLight.ts +75 -0
  196. package/src/render/shaders/flat/index.ts +5 -0
  197. package/src/render/shaders/flat/lobes.ts +13 -0
  198. package/src/render/shaders/flat/main.ts +68 -9
  199. package/src/render/shaders/flat/preamble.ts +23 -5
  200. package/src/render/shaders/flat/probeGrid.ts +46 -3
  201. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +8 -3
  202. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  203. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  204. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  205. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  206. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  207. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  208. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  209. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  210. package/src/render/shaders/globalMedium.ts +4 -0
  211. package/src/render/shaders/localExposure.ts +154 -0
  212. package/src/render/shaders/prefilter.ts +6 -0
  213. package/src/render/shaders/rush.ts +33 -5
  214. package/src/render/shaders/tangentFrame.ts +15 -3
  215. package/src/render/shaders/temporalResolve.ts +39 -1
  216. package/src/render/shadowCasters.ts +5 -3
  217. package/src/render/shadowMap.ts +14 -0
  218. package/src/render/surfaceTexture.ts +5 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * DriftLight's lookup in hand-written WGSL, for the traced probe bake: the summed light arriving on
3
+ * a surface a probe's ray struck, which is how candles past the frame's choice reach the bounce.
4
+ *
5
+ * **A transcription of `flat/driftLight.ts`, line for line**, because a compute shader cannot
6
+ * include the generated WGSL of a fragment stage. Two spellings of one lookup is the hazard the
7
+ * 2026-08-13 rule is about, and `driftLight.test.ts` beside this holds them together by reading
8
+ * both: change one and it names the line of the other.
9
+ *
10
+ * **Integer arithmetic for the brick's corner**, by the 2026-09-20 rule: a device divides a float by
11
+ * multiplying by its reciprocal, and a brick number off by one is a brick from somewhere else.
12
+ *
13
+ * The including module declares `driftIndex: texture_3d<u32>`, `driftAtlas: texture_3d<f32>` and a
14
+ * filtering `driftSampler`; `origin` is the field's first sample and, in `w`, the spacing.
15
+ */
16
+ export const DRIFT_LIGHT_WGSL = /* wgsl */ `
17
+ fn driftLightIrradiance(world: vec3<f32>, n: vec3<f32>, origin: vec4<f32>) -> vec3<f32> {
18
+ let spacing = origin.w;
19
+ let at = (world + n * (0.5 * spacing) - origin.xyz) / (3.0 * spacing);
20
+ let cell = vec3<i32>(floor(at));
21
+ if (any(cell < vec3<i32>(0)) || any(cell >= vec3<i32>(textureDimensions(driftIndex, 0)))) {
22
+ return vec3<f32>(0.0);
23
+ }
24
+ let id = textureLoad(driftIndex, cell, 0).r;
25
+ if (id == 0u) {
26
+ return vec3<f32>(0.0);
27
+ }
28
+ let size = vec3<i32>(textureDimensions(driftAtlas, 0));
29
+ let brick = i32(id) - 1;
30
+ let across = size.x / 8;
31
+ let down = size.y / 4;
32
+ let corner = vec3<f32>(
33
+ f32((brick % across) * 8),
34
+ f32(((brick / across) % down) * 4),
35
+ f32((brick / (across * down)) * 4)
36
+ );
37
+ let texel = 1.0 / vec3<f32>(size);
38
+ let inside = (at - vec3<f32>(cell)) * 3.0 + 0.5;
39
+ let light = textureSampleLevel(driftAtlas, driftSampler, (corner + inside) * texel, 0.0);
40
+ if (light.a < 1e-3) {
41
+ return vec3<f32>(0.0);
42
+ }
43
+ let toward = textureSampleLevel(driftAtlas, driftSampler, (corner + vec3<f32>(4.0, 0.0, 0.0) + inside) * texel, 0.0);
44
+ let arriving = light.rgb / light.a;
45
+ let towardLight = toward.xyz / light.a;
46
+ let facing = max(dot(n, towardLight), 0.0) + (1.0 - min(length(towardLight), 1.0)) * 0.25;
47
+ return arriving * facing;
48
+ }
49
+ `;
@@ -41,20 +41,45 @@ export declare const BAKE_COUNTS = 8;
41
41
  export declare const BAKE_EDGE = 11;
42
42
  export declare const BAKE_FRAME = 12;
43
43
  export declare const BAKE_SCHEDULED = 13;
44
+ /** How much of what a layer held a refresh keeps. See `PROBE_HISTORY` and `convolveMain`. */
45
+ export declare const BAKE_HISTORY = 14;
44
46
  /** The sun, which the trace evaluates itself so a light that moves changes the bounce. */
45
47
  export declare const BAKE_SUN_DIR = 16;
46
48
  export declare const BAKE_SUN_COLOUR = 20;
47
- /** What a ray that left the world finds. The frame's ambient, so nothing rasterised is read. */
49
+ /**
50
+ * What a ray that left the world finds: the sky's horizon colour where the frame drew a sky, and the
51
+ * whole of the sky, flat, where it drew none. The flag says which, in the fourth float of the row,
52
+ * which is where an f32 declared after a vec3 lands. The rest of the sky the frame drew follows.
53
+ */
48
54
  export declare const BAKE_SKY_COLOUR = 24;
49
- /** Rounded up to the struct's own sixteen-byte alignment, with two vec3 rows for the sun. */
50
- export declare const PROBE_BAKE_FLOATS = 28;
55
+ export declare const BAKE_SKY_DRAWN = 27;
56
+ export declare const BAKE_SKY_TOP = 28;
57
+ export declare const BAKE_SKY_DEEP = 32;
58
+ /** The sky's own sun, which at night is not the key light: the moon is. The sunset reads this. */
59
+ export declare const BAKE_SKY_SUN = 36;
60
+ /**
61
+ * DriftLight's field, as `driftLight/uniforms.ts` resolves it for a probe: how much is in and its
62
+ * scale in `x` and `w`, and where its grid starts with the spacing in `w`. Zero switches it off.
63
+ */
64
+ export declare const BAKE_DRIFT_LIGHT = 40;
65
+ export declare const BAKE_DRIFT_ORIGIN = 44;
66
+ /**
67
+ * How many of the frame's exact lights `bounceLights` holds, and how the renderer shades a point
68
+ * light: 1 inverse-square, 0 the shaped falloff. See `gi/bounceLights.ts`.
69
+ */
70
+ export declare const BAKE_LIGHT_COUNT = 48;
71
+ export declare const BAKE_FALLOFF = 49;
72
+ /** Rounded up to the struct's own sixteen-byte alignment: the lattice, five vec3 rows, three vec4. */
73
+ export declare const PROBE_BAKE_FLOATS = 52;
74
+ /** Rows of `bounceLights`: three a light. */
75
+ export declare const BOUNCE_LIGHT_ROWS: number;
51
76
  /** Invocations a workgroup, for both entry points. 64 is this backend's default everywhere. */
52
77
  export declare const PROBE_BAKE_WORKGROUP = 64;
53
78
  /** Floats a ray's radiance takes in the intermediate buffer. */
54
79
  export declare const PROBE_RAY_FLOATS = 3;
55
80
  /** Floats a baked texel takes. Three and not four: the alpha of an irradiance map means nothing. */
56
81
  export declare const PROBE_TEXEL_FLOATS = 3;
57
- export declare const PROBE_BAKE_STRUCT_WGSL = "\nstruct ProbeBake {\n /* Where the grid's first probe stands, and how far apart they are. */\n origin: vec3<f32>,\n spacing: vec3<f32>,\n /* How many probes along each axis. Whole numbers carried as floats, as every block here does. */\n counts: vec3<f32>,\n /* Texels across one probe's map, gutter included. */\n edge: f32,\n /* Which frame this is, which turns the direction set so a probe converges rather than repeats. */\n frame: f32,\n /* How many probes this dispatch bakes. The schedule says which. */\n scheduled: f32,\n pad: f32,\n /* Towards the sun, and what colour it is. The trace shades what it hits rather than reading a\n capture, which is what lets a bounce follow a light that moved. */\n sunDir: vec3<f32>,\n sunColour: vec3<f32>,\n /* And what is outside, for a ray that left. See probeDistantRadiance. */\n skyColour: vec3<f32>,\n}\n";
82
+ export declare const PROBE_BAKE_STRUCT_WGSL = "\nstruct ProbeBake {\n /* Where the grid's first probe stands, and how far apart they are. */\n origin: vec3<f32>,\n spacing: vec3<f32>,\n /* How many probes along each axis. Whole numbers carried as floats, as every block here does. */\n counts: vec3<f32>,\n /* Texels across one probe's map, gutter included. */\n edge: f32,\n /* How far the direction set is turned. The bake holds it at 0; the parity check turns it. */\n frame: f32,\n /* How many probes this dispatch bakes. The schedule says which. */\n scheduled: f32,\n /* How much of what a layer held each refresh keeps, 0 for a grid not yet traced whole. */\n history: f32,\n /* Towards the sun, and what colour it is. The trace shades what it hits rather than reading a\n capture, which is what lets a bounce follow a light that moved. */\n sunDir: vec3<f32>,\n sunColour: vec3<f32>,\n /* And what is outside, for a ray that left. See probeDistantRadiance and probeSkyAlong. */\n skyColour: vec3<f32>,\n skyDrawn: f32,\n skyTop: vec3<f32>,\n skyDeep: vec3<f32>,\n skySun: vec3<f32>,\n /* The summed light of a scene's many fixed lights. See driftLight.wgsl.ts. */\n driftLight: vec4<f32>,\n driftOrigin: vec4<f32>,\n /* How many of the frame's exact lights bounceLights holds, and 1 for inverse-square falloff. */\n lightCount: f32,\n falloff: f32,\n}\n";
58
83
  /**
59
84
  * The octahedral mapping and the probe lattice, as text.
60
85
  *
@@ -27,6 +27,8 @@ import { FRAME_TURN, GOLDEN_ANGLE, PROBE_DIRECTIONS } from '../../gi/probeTrace.
27
27
  import { PROBE_MARCH_WGSL, PROBE_TRACE_CORE_WGSL } from './probeTrace.wgsl.js';
28
28
  import { PROBE_VOLUME_CORE_WGSL, PROBE_VOLUME_STRUCT_WGSL } from './probeVolume.wgsl.js';
29
29
  import { SAMPLE_FIELD_WGSL } from './sampleField.wgsl.js';
30
+ import { DRIFT_LIGHT_WGSL } from './driftLight.wgsl.js';
31
+ import { BOUNCE_LIGHT_FLOATS, MAX_BOUNCE_LIGHTS } from '../../gi/bounceLights.js';
30
32
  /**
31
33
  * Floats in the bake block, and where each field of it starts.
32
34
  *
@@ -45,13 +47,38 @@ export const BAKE_COUNTS = 8;
45
47
  export const BAKE_EDGE = 11;
46
48
  export const BAKE_FRAME = 12;
47
49
  export const BAKE_SCHEDULED = 13;
50
+ /** How much of what a layer held a refresh keeps. See `PROBE_HISTORY` and `convolveMain`. */
51
+ export const BAKE_HISTORY = 14;
48
52
  /** The sun, which the trace evaluates itself so a light that moves changes the bounce. */
49
53
  export const BAKE_SUN_DIR = 16;
50
54
  export const BAKE_SUN_COLOUR = 20;
51
- /** What a ray that left the world finds. The frame's ambient, so nothing rasterised is read. */
55
+ /**
56
+ * What a ray that left the world finds: the sky's horizon colour where the frame drew a sky, and the
57
+ * whole of the sky, flat, where it drew none. The flag says which, in the fourth float of the row,
58
+ * which is where an f32 declared after a vec3 lands. The rest of the sky the frame drew follows.
59
+ */
52
60
  export const BAKE_SKY_COLOUR = 24;
53
- /** Rounded up to the struct's own sixteen-byte alignment, with two vec3 rows for the sun. */
54
- export const PROBE_BAKE_FLOATS = 28;
61
+ export const BAKE_SKY_DRAWN = 27;
62
+ export const BAKE_SKY_TOP = 28;
63
+ export const BAKE_SKY_DEEP = 32;
64
+ /** The sky's own sun, which at night is not the key light: the moon is. The sunset reads this. */
65
+ export const BAKE_SKY_SUN = 36;
66
+ /**
67
+ * DriftLight's field, as `driftLight/uniforms.ts` resolves it for a probe: how much is in and its
68
+ * scale in `x` and `w`, and where its grid starts with the spacing in `w`. Zero switches it off.
69
+ */
70
+ export const BAKE_DRIFT_LIGHT = 40;
71
+ export const BAKE_DRIFT_ORIGIN = 44;
72
+ /**
73
+ * How many of the frame's exact lights `bounceLights` holds, and how the renderer shades a point
74
+ * light: 1 inverse-square, 0 the shaped falloff. See `gi/bounceLights.ts`.
75
+ */
76
+ export const BAKE_LIGHT_COUNT = 48;
77
+ export const BAKE_FALLOFF = 49;
78
+ /** Rounded up to the struct's own sixteen-byte alignment: the lattice, five vec3 rows, three vec4. */
79
+ export const PROBE_BAKE_FLOATS = 52;
80
+ /** Rows of `bounceLights`: three a light. */
81
+ export const BOUNCE_LIGHT_ROWS = (MAX_BOUNCE_LIGHTS * BOUNCE_LIGHT_FLOATS) / 4;
55
82
  /** Invocations a workgroup, for both entry points. 64 is this backend's default everywhere. */
56
83
  export const PROBE_BAKE_WORKGROUP = 64;
57
84
  /** Floats a ray's radiance takes in the intermediate buffer. */
@@ -67,17 +94,28 @@ struct ProbeBake {
67
94
  counts: vec3<f32>,
68
95
  /* Texels across one probe's map, gutter included. */
69
96
  edge: f32,
70
- /* Which frame this is, which turns the direction set so a probe converges rather than repeats. */
97
+ /* How far the direction set is turned. The bake holds it at 0; the parity check turns it. */
71
98
  frame: f32,
72
99
  /* How many probes this dispatch bakes. The schedule says which. */
73
100
  scheduled: f32,
74
- pad: f32,
101
+ /* How much of what a layer held each refresh keeps, 0 for a grid not yet traced whole. */
102
+ history: f32,
75
103
  /* Towards the sun, and what colour it is. The trace shades what it hits rather than reading a
76
104
  capture, which is what lets a bounce follow a light that moved. */
77
105
  sunDir: vec3<f32>,
78
106
  sunColour: vec3<f32>,
79
- /* And what is outside, for a ray that left. See probeDistantRadiance. */
107
+ /* And what is outside, for a ray that left. See probeDistantRadiance and probeSkyAlong. */
80
108
  skyColour: vec3<f32>,
109
+ skyDrawn: f32,
110
+ skyTop: vec3<f32>,
111
+ skyDeep: vec3<f32>,
112
+ skySun: vec3<f32>,
113
+ /* The summed light of a scene's many fixed lights. See driftLight.wgsl.ts. */
114
+ driftLight: vec4<f32>,
115
+ driftOrigin: vec4<f32>,
116
+ /* How many of the frame's exact lights bounceLights holds, and 1 for inverse-square falloff. */
117
+ lightCount: f32,
118
+ falloff: f32,
81
119
  }
82
120
  `;
83
121
  /**
@@ -228,11 +266,18 @@ fn convolveMain(@builtin(global_invocation_id) id: vec3<u32>) {
228
266
  zero rather than dividing is what keeps it from being a hole if it ever does. */
229
267
  let scale = select(0.0, 1.0 / weights, weights > 0.0);
230
268
  let value = max(vec3<f32>(0.0), sum * scale);
269
+ /*
270
+ * **Blended into what the layer held, because one refresh is one estimate.** 256 rays over a set
271
+ * that turns every frame put two refreshes of a probe a few per cent apart, and written as they
272
+ * landed they were a shimmer about nine times a second. "bake.history" is 0 until the grid has
273
+ * been traced whole, so a first fill lands at once.
274
+ */
275
+ let kept = mix(value, probeHistory(schedule[slot], u, v), bake.history);
231
276
 
232
277
  let out = id.x * ${String(PROBE_TEXEL_FLOATS)}u;
233
- irradiance[out] = value.x;
234
- irradiance[out + 1u] = value.y;
235
- irradiance[out + 2u] = value.z;
278
+ irradiance[out] = kept.x;
279
+ irradiance[out + 1u] = kept.y;
280
+ irradiance[out + 2u] = kept.z;
236
281
  }
237
282
  `;
238
283
  /**
@@ -281,6 +326,11 @@ fn probeLayerSample(layer: u32, uv: vec2<f32>) -> vec3<f32> {
281
326
  return vec3<f32>(probeValues[at], probeValues[at + 1u], probeValues[at + 2u]);
282
327
  }
283
328
 
329
+ /* No history: this module's refreshes are checked as single estimates, and "bake.history" is 0. */
330
+ fn probeHistory(layer: u32, u: u32, v: u32) -> vec3<f32> {
331
+ return vec3<f32>(0.0);
332
+ }
333
+
284
334
  /* The same flat value: this module checks the blend, and what a production texture holds at its
285
335
  two levels is what makes them different. */
286
336
  fn probeLayerIrradiance(layer: u32, uv: vec2<f32>) -> vec3<f32> {
@@ -364,6 +414,10 @@ fn echoMain(@builtin(global_invocation_id) id: vec3<u32>) {
364
414
  irradiance[9] = bake.edge;
365
415
  irradiance[10] = bake.frame;
366
416
  irradiance[11] = bake.scheduled;
417
+ irradiance[12] = bake.skyDrawn;
418
+ irradiance[13] = bake.skyTop.x;
419
+ irradiance[14] = bake.skyDeep.x;
420
+ irradiance[15] = bake.skySun.x;
367
421
  }
368
422
 
369
423
  /* The encode, which is a different function from the fold "texelMain" checks and needs its own. */
@@ -440,7 +494,19 @@ ${PROBE_VOLUME_STRUCT_WGSL}
440
494
  @group(0) @binding(9) var probeSampler: sampler;
441
495
  /* The colour of whatever won the union at each sample, three floats, same layout as the field. */
442
496
  @group(0) @binding(10) var<storage, read> albedoField: array<f32>;
497
+ /* DriftLight's brick index and atlas, or one-texel stand-ins while driftLight.x is zero. */
498
+ @group(0) @binding(11) var driftIndex: texture_3d<u32>;
499
+ @group(0) @binding(12) var driftAtlas: texture_3d<f32>;
500
+ @group(0) @binding(13) var driftSampler: sampler;
501
+ /* The frame's exact lights, three rows each: see gi/bounceLights.ts for what each row holds. */
502
+ @group(0) @binding(14) var<uniform> bounceLights: array<vec4<f32>, ${String(BOUNCE_LIGHT_ROWS)}>;
443
503
  ${SAMPLE_FIELD_WGSL}
504
+ ${DRIFT_LIGHT_WGSL}
505
+
506
+ /* What a layer's irradiance level holds at a texel, which the convolution blends into. */
507
+ fn probeHistory(layer: u32, u: u32, v: u32) -> vec3<f32> {
508
+ return textureLoad(probeArray, vec2<i32>(i32(u), i32(v)), i32(layer), i32(volume.level)).rgb;
509
+ }
444
510
 
445
511
  /*
446
512
  * The albedo at a world point, from the finest cascade that holds it, by nearest sample.
@@ -553,13 +619,111 @@ fn probeSurfaceRadiance(p: vec3<f32>, towards: vec3<f32>) -> vec3<f32> {
553
619
  /*
554
620
  * And what has already bounced, which is the level this bake writes — so the loop closes.
555
621
  *
556
- * **Divided by pi once and not twice.** "convolveProbe" normalises by the cosine weights it
557
- * actually used, so what a probe stores is the cosine-weighted *mean radiance*, which is already
558
- * the irradiance over pi — the convention a Lambertian's outgoing radiance multiplies directly
559
- * by its albedo. Only the sun's own irradiance needs the division.
622
+ * **Neither term is divided by pi, because the frame divides neither.** "convolveProbe"
623
+ * normalises by the cosine weights it actually used, so a probe stores the cosine-weighted
624
+ * *mean radiance*, which a Lambertian's outgoing radiance multiplies directly by its albedo. And
625
+ * the flat shader lights a surface with "albedo * (ambient + directionalColor * cosine)", so
626
+ * "directionalColor" is the radiance a white surface facing the sun sends back rather than an
627
+ * irradiance. This divided the sun by pi until 2026-09-27, and every sunlit wall a probe struck
628
+ * came back a third as bright as the frame drew it: a room lit through its one open side read
629
+ * 66 of 255 on its far wall against a path-traced 200 ("scripts/bounce-check.mjs"). What would
630
+ * make it wrong is a flat shader that divides its own sun by pi; "probeBake.test.ts" reads both.
560
631
  */
561
632
  let bounced = probeVolumeIrradianceAt(p, n);
562
- return albedo * (direct * ${String(1 / Math.PI)} + bounced);
633
+ /*
634
+ * And the summed light of the scene's many fixed lights, which is how a candle nobody is near
635
+ * reaches the bounce: the trace shades a hit with the sun alone otherwise. At the presence and
636
+ * scale the frame reads it with, and with no share, because a probe sees every fixed light through
637
+ * the volume (driftLight/uniforms.ts).
638
+ */
639
+ let summed = driftLightIrradiance(p, n, bake.driftOrigin) * (bake.driftLight.x * bake.driftLight.w);
640
+ return albedo * (direct + bounced + summed + probeLampsAt(p, n));
641
+ }
642
+
643
+ /*
644
+ * Whether a lamp reaches a point, by marching the field towards it and stopping short.
645
+ *
646
+ * **Short of the lamp by its fixture.** A flame stands inside a lantern and a bulb inside a shade,
647
+ * and a field a quarter of a metre fine draws those as solid: a march that ran all the way would
648
+ * find the lantern round every lantern and shade every one of them out. So it stops a fixture's
649
+ * width away, the larger of 30 cm and two of the finest voxels, and what it gives up is an occluder
650
+ * that close to a lamp.
651
+ */
652
+ fn probeLampVisible(p: vec3<f32>, n: vec3<f32>, toward: vec3<f32>, span: f32) -> f32 {
653
+ let lifted = p + n * (PROBE_FIELD_START_VOXELS * march.finestStep);
654
+ var travelled = 0.0;
655
+ for (var step = 0u; step < march.steps; step = step + 1u) {
656
+ if (travelled >= span) { return 1.0; }
657
+ let at = lifted + toward * travelled;
658
+ if (probeFieldInside(at) <= 0.0) { return 1.0; }
659
+ let distance = probeFieldAt(at);
660
+ if (distance <= march.hitEpsilonM) { return 0.0; }
661
+ travelled = travelled + max(distance, march.finestStep * 0.25);
662
+ }
663
+ return 1.0;
664
+ }
665
+
666
+ /*
667
+ * The frame's exact lights on a surface a ray struck: the lit shader's lamp term, line for line
668
+ * (probeBake.test.ts holds the two together), with the field's own shadow in place of a lamp's
669
+ * shadow map. Not a photometric profile or a cookie, which the bounce reads as a plain cone.
670
+ */
671
+ fn probeLampsAt(p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
672
+ var sum = vec3<f32>(0.0);
673
+ let count = u32(bake.lightCount);
674
+ for (var i = 0u; i < count; i = i + 1u) {
675
+ let at = bounceLights[i * 3u];
676
+ let colour = bounceLights[i * 3u + 1u];
677
+ let aim = bounceLights[i * 3u + 2u];
678
+ let lightRadius = at.w;
679
+ let toLight = at.xyz - p;
680
+ let dist = length(toLight);
681
+ let coneFalloff = smoothstep(aim.w, colour.w, dot(-toLight / max(dist, 1e-4), aim.xyz));
682
+ var falloff: f32;
683
+ if (bake.falloff == 1.0) {
684
+ let window = clamp(1.0 - pow(dist / max(lightRadius, 1e-4), 4.0), 0.0, 1.0);
685
+ falloff = window * window / max(dist * dist, 0.01);
686
+ } else {
687
+ falloff = clamp(1.0 - dist / lightRadius, 0.0, 1.0);
688
+ }
689
+ if (falloff <= 0.0) { continue; }
690
+ let ndl = max(dot(n, toLight / max(dist, 1e-4)), 0.0);
691
+ if (ndl <= 0.0) { continue; }
692
+ let shape = select(falloff * falloff, falloff, bake.falloff == 1.0) * coneFalloff;
693
+ if (shape <= 0.0) { continue; }
694
+ let fixture = max(0.3, 2.0 * march.finestStep);
695
+ let seen = probeLampVisible(p, n, toLight / max(dist, 1e-4), dist - fixture);
696
+ sum = sum + colour.rgb * ndl * shape * seen;
697
+ }
698
+ return sum;
699
+ }
700
+
701
+ /*
702
+ * The sky the frame drew, along a direction: the sky shader's gradient and its two sunset bands,
703
+ * transcribed line for line (probeBake.test.ts holds the two together). Not its sun, moon, stars or
704
+ * clouds: the sun reaches a probe as direct light at what the ray strikes, and the rest is a few
705
+ * per cent of a sky's light. Where the frame drew no sky, the environment's ambient, flat.
706
+ */
707
+ fn probeSkyAlong(d: vec3<f32>) -> vec3<f32> {
708
+ if (bake.skyDrawn < 0.5) { return bake.skyColour; }
709
+ let t = d.y;
710
+ var col: vec3<f32>;
711
+ if (t >= 0.0) {
712
+ col = mix(bake.skyColour, bake.skyTop, pow(min(t, 1.0), 0.55));
713
+ } else {
714
+ col = mix(bake.skyColour, bake.skyDeep, min(-t * 1.8, 1.0));
715
+ }
716
+ let sunset = (1.0 - smoothstep(-0.03, 0.30, bake.skySun.y)) * smoothstep(-0.35, -0.05, bake.skySun.y);
717
+ let ember = vec3<f32>(1.0, 0.36, 0.13);
718
+ let sunBearing = normalize(bake.skySun.xz + vec2<f32>(1e-5));
719
+ let viewBearing = normalize(d.xz + vec2<f32>(1e-5));
720
+ let toward = max(dot(viewBearing, sunBearing), 0.0);
721
+ let fromHorizon = select(-t * 3.0, t, t >= 0.0);
722
+ let band = pow(1.0 - min(fromHorizon * 2.2, 1.0), 2.6);
723
+ col += ember * sunset * band * (0.18 + 0.75 * pow(toward, 2.2));
724
+ let away = max(-dot(viewBearing, sunBearing), 0.0);
725
+ col += vec3<f32>(0.42, 0.28, 0.45) * sunset * band * away * 0.22;
726
+ return col;
563
727
  }
564
728
 
565
729
  /*
@@ -567,15 +731,21 @@ fn probeSurfaceRadiance(p: vec3<f32>, towards: vec3<f32>) -> vec3<f32> {
567
731
  *
568
732
  * **Not the probe array's own level 0, and that was measured rather than reasoned.** Level 0 is a
569
733
  * *rasterised* capture taken once, so a ray reading it gathers the light the room had when the
570
- * capture was made — and a room whose red wall was repainted blue went on bouncing red, because
571
- * most of its rays escaped through the opening and read the capture. With the sky here instead,
572
- * **nothing in the trace reads anything rasterised**, which is what lets the whole solution follow
573
- * a scene that changes and is the sentence Wave 4's criterion asks for.
734
+ * capture was made, and a room whose red wall was repainted blue went on bouncing red, because
735
+ * most of its rays escaped through the opening and read the capture. The sky is the frame's own
736
+ * numbers, so **nothing in the trace reads anything rasterised** and the solution follows a scene
737
+ * that changes.
738
+ *
739
+ * **The sky drawn, and not the environment's ambient, since 2026-09-27.** The ambient is a grade's
740
+ * fill for surfaces the probes do not reach, and a palette sets it apart from the sky: at ten
741
+ * degrees of sun a courtyard's was five times dimmer than its horizon, and blue where the horizon
742
+ * was amber, so traced shade at dusk came out cool against the rasterised grid that saw the sky.
574
743
  *
575
- * No albedo and no sun: nothing was struck, so there is no surface to shade.
744
+ * No albedo and no sun: nothing was struck, so there is no surface to shade. "towards" points back
745
+ * down the ray, so the sky is read along its opposite.
576
746
  */
577
747
  fn probeDistantRadiance(p: vec3<f32>, towards: vec3<f32>) -> vec3<f32> {
578
- return bake.skyColour;
748
+ return probeSkyAlong(-towards);
579
749
  }
580
750
  ${PROBE_TRACE_CORE_WGSL}
581
751
  ${PROBE_BAKE_CORE_WGSL}
@@ -290,6 +290,10 @@ void main() {
290
290
  */
291
291
  export const MEDIUM_UPSAMPLE_FRAG = `#version 300 es
292
292
  precision highp float;
293
+ /* highp int, by the rule every fragment stage doing bit arithmetic keeps: the footprint below is
294
+ picked with a mask and a shift. At four values it would survive sixteen bits, and a gate that
295
+ has to judge which masks are small is a gate nobody can hold. */
296
+ precision highp int;
293
297
 
294
298
  in vec2 vUv;
295
299
 
@@ -0,0 +1,35 @@
1
+ /** Bands of log luminance, and how many stops each is wide. */
2
+ export declare const LOCAL_BINS = 10;
3
+ export declare const LOCAL_BIN_STOPS: number;
4
+ /** The grid's width in texels: a block of tiles per band, and one for the tiles' means. */
5
+ export declare const LOCAL_GRID_WIDTH: number;
6
+ /** How far a pixel's band level is blended toward its tile's mean. */
7
+ export declare const LOCAL_BLURRED_BLEND = 0.4;
8
+ /** The furthest a region is moved either way, in stops. */
9
+ export declare const LOCAL_RANGE_STOPS = 3;
10
+ /**
11
+ * The grid: one texel a tile a band. Each reads its tile's 8 × 8 taps, as the meter does, and keeps
12
+ * the ones in its band; the last block keeps them all. Sum and count are both divided by the taps,
13
+ * so the filtered pair in the composite is still a weighted mean.
14
+ */
15
+ export declare const EXPOSURE_LOCAL_FRAG: string;
16
+ /**
17
+ * The composite's half. Needs `AUTO_EXPOSURE_GLSL` before it, for the held brightness.
18
+ *
19
+ * A block is read clamped half a texel inside its own tiles, so the filter never reaches into the
20
+ * neighbouring band's block. Every fetch is at level zero: the grid has one level, and the branch
21
+ * on the strength above them is uniform but the pixel's band is not.
22
+ */
23
+ export declare const LOCAL_EXPOSURE_GLSL: string;
24
+ /** The two bands a pixel at `pixelLog2` reads, and how much of the upper, as the shader finds them. */
25
+ export declare function localBin(pixelLog2: number): {
26
+ lower: number;
27
+ upper: number;
28
+ weight: number;
29
+ };
30
+ /** A pixel's local level from its two bands' sums and counts and its tile's mean, as the shader. */
31
+ export declare function sliceLocal(sumLower: number, countLower: number, sumUpper: number, countUpper: number, weight: number, tileMean: number): number;
32
+ /** A region's gain for a local level and the frame's held one at `strength`, as the composite. */
33
+ export declare function localExposureGain(localLog2: number, heldLog2: number, strength: number): number;
34
+ /** A local exposure strength as the shader takes it: 0 to 1, anything else off. */
35
+ export declare function clampLocalExposure(strength: number): number;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Local exposure: each region of the frame brought part of the way toward the frame's own
3
+ * brightness, so shade and sun can both be read in one picture.
4
+ *
5
+ * **A tone curve maps one range, and a sunlit courtyard is two.** Measured in a bought courtyard at
6
+ * noon, the shaded arcades hold a fiftieth of the light the sunlit paving does, which is what the
7
+ * place is: a canyon lit mostly by its own walls. Exposed for the paving the arcades go to black,
8
+ * exposed for the arcades the paving goes to white, and no curve fixes both, because a curve is a
9
+ * function of a pixel's brightness and the same brightness has to mean shade in one place and sun in
10
+ * another. A photographer brackets; an eye adapts by region. This is the second.
11
+ *
12
+ * **A bilateral grid over the exposure meter's tiles.** The grid pass writes, for each of the
13
+ * meter's 32 × 32 tiles and each of ten two-stop bands of log luminance, the sum and the share of
14
+ * the tile's taps that fell in that band, and in an eleventh block the tile's mean. The composite
15
+ * reads the grid at the pixel's place and in the pixel's own band, filtered in both, so a shaded
16
+ * pixel beside sunlit ones takes the shade's level rather than the tile's: that is what keeps the
17
+ * lift from haloing across the edge of a shadow. The band's level is blended 0.4 toward the tile's
18
+ * mean, which is what stops a texture's own light and dark being flattened into one another.
19
+ *
20
+ * **The gain is `strength · (held − local)` stops**, clamped to three: at 0.5 a region five stops
21
+ * under the frame is lifted two and a half, and one two over is brought down one. The held value is
22
+ * eye adaptation's, so the frame as a whole still adapts and regions are moved relative to it.
23
+ *
24
+ * What it gives up is some of the contrast between regions, which is the point, and a halo a tile
25
+ * wide where two regions share a band and differ in brightness. What would make it wrong is a scene
26
+ * whose drama is the contrast itself: a single shaft into a dark room wants the room dark, and the
27
+ * strength is the caller's for that reason.
28
+ */
29
+ import { METER_GRID, METER_MAX_LOG2, METER_MIN_LOG2, METER_TAPS } from './exposure.js';
30
+ /** Bands of log luminance, and how many stops each is wide. */
31
+ export const LOCAL_BINS = 10;
32
+ export const LOCAL_BIN_STOPS = (METER_MAX_LOG2 - METER_MIN_LOG2) / LOCAL_BINS;
33
+ /** The grid's width in texels: a block of tiles per band, and one for the tiles' means. */
34
+ export const LOCAL_GRID_WIDTH = METER_GRID * (LOCAL_BINS + 1);
35
+ /** How far a pixel's band level is blended toward its tile's mean. */
36
+ export const LOCAL_BLURRED_BLEND = 0.4;
37
+ /** The furthest a region is moved either way, in stops. */
38
+ export const LOCAL_RANGE_STOPS = 3;
39
+ const BLOCKS = (LOCAL_BINS + 1).toFixed(1);
40
+ const GRID = METER_GRID.toFixed(1);
41
+ const MIN = METER_MIN_LOG2.toFixed(1);
42
+ const MAX = METER_MAX_LOG2.toFixed(1);
43
+ const STOPS = LOCAL_BIN_STOPS.toFixed(1);
44
+ const LAST = (LOCAL_BINS - 1).toFixed(1);
45
+ /**
46
+ * The grid: one texel a tile a band. Each reads its tile's 8 × 8 taps, as the meter does, and keeps
47
+ * the ones in its band; the last block keeps them all. Sum and count are both divided by the taps,
48
+ * so the filtered pair in the composite is still a weighted mean.
49
+ */
50
+ export const EXPOSURE_LOCAL_FRAG = `#version 300 es
51
+ precision highp float;
52
+
53
+ in vec2 vUv;
54
+
55
+ /** The finished scene, in scene light, before any exposure or curve. */
56
+ uniform highp sampler2D uScene;
57
+
58
+ out vec2 fragColor;
59
+
60
+ void main() {
61
+ float uvBlock = vUv.x * ${BLOCKS};
62
+ float band = floor(uvBlock);
63
+ vec2 corner = vec2(fract(uvBlock), vUv.y) - vec2(0.5 / ${GRID});
64
+ float sum = 0.0;
65
+ float count = 0.0;
66
+ for (int y = 0; y < ${METER_TAPS}; y++) {
67
+ for (int x = 0; x < ${METER_TAPS}; x++) {
68
+ vec2 uv = corner + (vec2(float(x), float(y)) + 0.5) / ${(METER_GRID * METER_TAPS).toFixed(1)};
69
+ vec3 c = textureLod(uScene, uv, 0.0).rgb;
70
+ float level = clamp(log2(max(dot(c, vec3(0.2126, 0.7152, 0.0722)), 1e-8)), ${MIN}, ${MAX});
71
+ float tapBand = floor(clamp((level - ${MIN}) / ${STOPS}, 0.0, ${LAST}));
72
+ float kept = band > ${LAST} || tapBand == band ? 1.0 : 0.0;
73
+ sum += level * kept;
74
+ count += kept;
75
+ }
76
+ }
77
+ fragColor = vec2(sum, count) / ${(METER_TAPS * METER_TAPS).toFixed(1)};
78
+ }
79
+ `;
80
+ /**
81
+ * The composite's half. Needs `AUTO_EXPOSURE_GLSL` before it, for the held brightness.
82
+ *
83
+ * A block is read clamped half a texel inside its own tiles, so the filter never reaches into the
84
+ * neighbouring band's block. Every fetch is at level zero: the grid has one level, and the branch
85
+ * on the strength above them is uniform but the pixel's band is not.
86
+ */
87
+ export const LOCAL_EXPOSURE_GLSL = `
88
+ /** How far each region is brought toward the frame, 0 to 1. 0 is off and the whole of the off path. */
89
+ uniform float uLocalExposure;
90
+ /** The bilateral grid: a block of tiles per band of brightness, and one of the tiles' means. */
91
+ uniform highp sampler2D uExposureLocal;
92
+
93
+ vec2 localBlock(vec2 uv, float block) {
94
+ vec2 inside = clamp(uv, vec2(0.5 / ${GRID}), vec2(1.0 - 0.5 / ${GRID}));
95
+ return textureLod(uExposureLocal, vec2((block + inside.x) / ${BLOCKS}, inside.y), 0.0).rg;
96
+ }
97
+
98
+ float localExposureGain(vec2 uv, vec3 light) {
99
+ if (uLocalExposure <= 0.0) return 1.0;
100
+ float held = texelFetch(uExposureHeld, ivec2(0, 0), 0).r;
101
+ float pixel = clamp(log2(max(dot(light, vec3(0.2126, 0.7152, 0.0722)), 1e-8)), ${MIN}, ${MAX});
102
+ float b = (pixel - ${MIN}) / ${STOPS} - 0.5;
103
+ float lower = clamp(floor(b), 0.0, ${LAST});
104
+ float upper = min(lower + 1.0, ${LAST});
105
+ vec2 banded = mix(localBlock(uv, lower), localBlock(uv, upper), clamp(b - lower, 0.0, 1.0));
106
+ float tileMean = localBlock(uv, ${LOCAL_BINS.toFixed(1)}).r;
107
+ float bilateral = banded.y > 1e-3 ? banded.x / banded.y : tileMean;
108
+ float local = mix(bilateral, tileMean, ${LOCAL_BLURRED_BLEND.toFixed(1)});
109
+ float stops = uLocalExposure * (held - local);
110
+ return exp2(clamp(stops, -${LOCAL_RANGE_STOPS.toFixed(1)}, ${LOCAL_RANGE_STOPS.toFixed(1)}));
111
+ }
112
+ `;
113
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
114
+ /** The two bands a pixel at `pixelLog2` reads, and how much of the upper, as the shader finds them. */
115
+ export function localBin(pixelLog2) {
116
+ const pixel = clamp(pixelLog2, METER_MIN_LOG2, METER_MAX_LOG2);
117
+ const b = (pixel - METER_MIN_LOG2) / LOCAL_BIN_STOPS - 0.5;
118
+ const lower = clamp(Math.floor(b), 0, LOCAL_BINS - 1);
119
+ return { lower, upper: Math.min(lower + 1, LOCAL_BINS - 1), weight: clamp(b - lower, 0, 1) };
120
+ }
121
+ /** A pixel's local level from its two bands' sums and counts and its tile's mean, as the shader. */
122
+ export function sliceLocal(sumLower, countLower, sumUpper, countUpper, weight, tileMean) {
123
+ const sum = sumLower + (sumUpper - sumLower) * weight;
124
+ const count = countLower + (countUpper - countLower) * weight;
125
+ const bilateral = count > 1e-3 ? sum / count : tileMean;
126
+ return bilateral + (tileMean - bilateral) * LOCAL_BLURRED_BLEND;
127
+ }
128
+ /** A region's gain for a local level and the frame's held one at `strength`, as the composite. */
129
+ export function localExposureGain(localLog2, heldLog2, strength) {
130
+ if (!(strength > 0))
131
+ return 1;
132
+ const stops = strength * (heldLog2 - localLog2);
133
+ return 2 ** clamp(stops, -LOCAL_RANGE_STOPS, LOCAL_RANGE_STOPS);
134
+ }
135
+ /** A local exposure strength as the shader takes it: 0 to 1, anything else off. */
136
+ export function clampLocalExposure(strength) {
137
+ return Number.isFinite(strength) ? clamp(strength, 0, 1) : 0;
138
+ }
@@ -34,4 +34,4 @@
34
34
  * difference between one cube and a cube per camera. **What would make it wrong** is a scene
35
35
  * where grazing metal is the subject rather than incidental.
36
36
  */
37
- export declare const PREFILTER_FRAG = "#version 300 es\nprecision highp float;\n\nvec2 octEncode(vec3 d) {\n vec3 n = d / (abs(d.x) + abs(d.y) + abs(d.z));\n vec2 p = n.z >= 0.0\n ? n.xy\n : (1.0 - abs(vec2(n.y, n.x))) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);\n return p * 0.5 + 0.5;\n}\n\nvec3 octDecode(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n vec3 n = vec3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y));\n float t = max(-n.z, 0.0);\n n.x += n.x >= 0.0 ? -t : t;\n n.y += n.y >= 0.0 ? -t : t;\n return normalize(n);\n}\n\nvec2 octWrapUv(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n if (f.x < -1.0 || f.x > 1.0) {\n f.x = (f.x > 0.0 ? 2.0 : -2.0) - f.x;\n f.y = -f.y;\n }\n if (f.y < -1.0 || f.y > 1.0) {\n f.y = (f.y > 0.0 ? 2.0 : -2.0) - f.y;\n f.x = -f.x;\n }\n return f * 0.5 + 0.5;\n}\n\nvec2 octInsetUv(vec3 d, float edge) {\n return (octEncode(d) * (edge - 2.0) + 1.0) / edge;\n}\n\nvec3 octInsetDir(vec2 uv, float edge) {\n return octDecode(octWrapUv((uv * edge - 1.0) / (edge - 2.0)));\n}\n\nin vec2 vUv;\n\n/** The captured probe, box-filtered, which this convolves. Never the cube being written. */\nuniform highp samplerCube uPrefilterSource;\n\n/**\n * The edge of the level being written, in texels, which is what the inset is derived from.\n *\n * The level's own size rather than the array's, because a gutter is one texel at every level and\n * its share of the map therefore doubles as the chain coarsens. A base edge passed here instead\n * would inset every level by the base's fraction, which is a fifth of a texel at the top of the\n * chain and reads as the reflection sliding as roughness rises.\n */\nuniform float uPrefilterEdge;\n\n/** 1 on the level holding the cosine convolution, 0 on every level of the reflection chain. */\nuniform float uPrefilterIrradiance;\n\n/**\n * 1 when the reflection chain is a box filter rather than a GGX convolution.\n *\n * RenderQuality.environmentPrefilter is off by default, and that default is a correction rather\n * than a preference: the prefilter shipped wired straight into the lit pass and changed the\n * picture of every consumer that had a probe, which was reported as metals reading opaque. A\n * profile that has not asked for the lobe chain must get the same reflection it had before probes\n * moved into an array, so the box arm exists and is what that profile bakes.\n *\n * The array edge is twice the source cube's face, so one array level matches one cube level in\n * angular density and the box arm is a single fetch at the same index. There is no second\n * texture: the flag decides what is convolved into the layer rather than which of two chains is\n * bound, so it is read at bake time and a profile that changes it has to bake again.\n */\nuniform float uPrefilterBox;\n\n/** The level being written, for the box arm, which reads the source chain at the same index. */\nuniform float uPrefilterLevel;\n\n/** The roughness this level stands for, from roughnessForLevel. 0 is a mirror. */\nuniform float uPrefilterRoughness;\n/** Samples per texel, from PREFILTER_SAMPLE_COUNTS. A power of two; see the count's own note. */\nuniform float uPrefilterSamples;\n/** The source cube's face width in texels, for the solid-angle comparison below. */\nuniform float uPrefilterSourceTexels;\n/** The source chain's top level, so a wide sample cannot ask for a level that is not there. */\nuniform float uPrefilterSourceMaxLod;\n\nout vec4 fragColor;\n\nconst float PI = 3.14159265359;\n\n/**\n * The radical inverse of a 32-bit integer, base two: the second dimension of a Hammersley set.\n *\n * A bit reversal, written as the standard five-shift sequence rather than a loop, because a loop\n * over 32 bits per sample per texel is the one place in a bake where the arithmetic is the cost.\n * **This is why every sample count is a power of two** \u2014 the sequence's stratification is only\n * even when the count is, and an uneven one shows as a faint directional bias in a rough level\n * rather than as an error anybody would notice.\n */\nfloat radicalInverse(uint bits) {\n bits = (bits << 16u) | (bits >> 16u);\n bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);\n bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);\n bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);\n bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);\n return float(bits) * 2.3283064365386963e-10;\n}\n\n/**\n * A half vector drawn from the GGX distribution about the normal, for one Hammersley pair.\n *\n * Importance sampling rather than a uniform hemisphere: at low roughness the lobe occupies a\n * vanishing fraction of the hemisphere, so uniform samples would put almost every one of them\n * where the weight is zero and the few that land in the lobe would carry all the variance.\n */\nvec3 importanceSampleGgx(vec2 xi, vec3 n, float roughness) {\n float a = roughness * roughness;\n\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));\n float sinTheta = sqrt(1.0 - cosTheta * cosTheta);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n /*\n * Any tangent will do as long as it is not parallel to the normal, and the choice of which\n * axis to cross against has to depend on the normal or it degenerates at the poles \u2014 which is\n * one face of six coming out as noise, on the two faces the axis points at.\n */\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/**\n * A direction drawn from the cosine lobe about the normal, for the diffuse level.\n *\n * **Cosine-weighted, which is what makes the estimator a plain mean.** Irradiance is\n * integral of L cos(theta), the cosine pdf is cos(theta) / pi, so the estimate is\n * pi / N * sum(L) \u2014 and what the lit pass wants is radiance rather than irradiance, so the pi\n * divides straight back out. irradianceSh.ts folds the same division into its band factors and\n * says so, which is what makes a grid of one reproduce what the projection produced.\n */\nvec3 importanceSampleCosine(vec2 xi, vec3 n) {\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt(max(1.0 - xi.y, 0.0));\n float sinTheta = sqrt(xi.y);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/** Trowbridge-Reitz, in its own normalisation, because this is a probability rather than a look. */\nfloat ggxDistribution(float ndh, float roughness) {\n float a = roughness * roughness;\n float a2 = a * a;\n float d = ndh * ndh * (a2 - 1.0) + 1.0;\n return a2 / max(PI * d * d, 1e-8);\n}\n\nvoid main() {\n vec3 n = octInsetDir(vUv, uPrefilterEdge);\n\n float texelSolidAngle = 4.0 * PI / (6.0 * uPrefilterSourceTexels * uPrefilterSourceTexels);\n\n /*\n * The box chain, which is one fetch: the source's own level at the same index, resampled through\n * the octahedral mapping. It is what a profile that has not asked for the lobe chain gets, and\n * it is the picture every consumer with a probe already had.\n */\n if (uPrefilterBox > 0.5 && uPrefilterIrradiance <= 0.5) {\n fragColor = vec4(\n textureLod(uPrefilterSource, n, min(uPrefilterLevel, uPrefilterSourceMaxLod)).rgb,\n 1.0\n );\n return;\n }\n\n /*\n * **The diffuse level, and it is a mean rather than a weighted sum.** Every cosine sample is\n * drawn with the weight already in its density, so there is nothing to accumulate a weight for\n * and nothing to divide by but the count. A sample below the horizon cannot occur, which is the\n * other half of why this arm has no continue in it.\n */\n if (uPrefilterIrradiance > 0.5) {\n vec3 diffuse = vec3(0.0);\n int cosineSamples = int(uPrefilterSamples);\n for (int i = 0; i < cosineSamples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 l = importanceSampleCosine(xi, n);\n /*\n * The same filtered importance sampling the reflection uses, against the cosine density.\n * A diffuse sample is wide \u2014 a sixth of the sphere at the median \u2014 so reading the base level\n * would carry a bright window through as a handful of fireflies in the ambient, which is the\n * one place they cannot be blurred out later.\n */\n float pdf = max(dot(n, l) / PI, 1e-4);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n diffuse += textureLod(uPrefilterSource, l, lod).rgb;\n }\n fragColor = vec4(diffuse / max(uPrefilterSamples, 1.0), 1.0);\n return;\n }\n\n /*\n * A mirror is a fetch rather than an integral, and it is a separate arm on purpose: at\n * roughness 0 the distribution is a delta, every importance sample collapses onto the same\n * direction, and the sum is one sample's worth of noise where the answer is exactly the texel.\n */\n if (uPrefilterRoughness <= 0.0) {\n fragColor = vec4(textureLod(uPrefilterSource, n, 0.0).rgb, 1.0);\n return;\n }\n\n vec3 sum = vec3(0.0);\n float weight = 0.0;\n\n int samples = int(uPrefilterSamples);\n for (int i = 0; i < samples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 h = importanceSampleGgx(xi, n, uPrefilterRoughness);\n vec3 l = normalize(2.0 * dot(n, h) * h - n);\n\n float ndl = dot(n, l);\n if (ndl <= 0.0) continue;\n\n /*\n * Filtered importance sampling. A sample covers 1 / (count * pdf) of the sphere; when that\n * is much wider than a texel, reading the base level lets a few very bright texels land in\n * some samples and not others, and the variance survives the average as fireflies. The half\n * in front of the logarithm is a length ratio against an area one \u2014 a level step doubles a\n * texel's width and quadruples its solid angle. sourceLevelForSample holds the same\n * arithmetic on the CPU and its test carries the hand-derived numbers.\n */\n float ndh = max(dot(n, h), 0.0);\n float pdf = max(ggxDistribution(ndh, uPrefilterRoughness) * 0.25, 1e-8);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n\n /*\n * textureLod rather than texture, and the 2026-08-07 rule is why: this fetch sits inside a\n * loop with a continue in it, which no compiler can prove uniform, and an implicit derivative\n * in non-uniform control flow is what lets a driver flatten the loop and cost every arm.\n * The level is wanted here anyway, so the rule costs nothing at all.\n */\n sum += textureLod(uPrefilterSource, l, lod).rgb * ndl;\n weight += ndl;\n }\n\n /*\n * Normalised by the accumulated weight rather than by the sample count, because samples with\n * a light below the horizon were skipped and dividing by the count would darken every rough\n * level by the fraction that missed \u2014 which reads as roughness losing energy and is exactly\n * the artefact uEnvironmentGain has been compensating for.\n */\n fragColor = vec4(sum / max(weight, 1e-4), 1.0);\n}\n";
37
+ export declare const PREFILTER_FRAG = "#version 300 es\nprecision highp float;\n/*\n * highp int as well, because the radical inverse below is a 32-bit bit reversal and the fragment\n * default for int and uint is mediump. A driver that computes mediump in sixteen bits returns 0\n * for every sample, and every cosine sample of the diffuse level lands on its own normal.\n */\nprecision highp int;\n\nvec2 octEncode(vec3 d) {\n vec3 n = d / (abs(d.x) + abs(d.y) + abs(d.z));\n vec2 p = n.z >= 0.0\n ? n.xy\n : (1.0 - abs(vec2(n.y, n.x))) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);\n return p * 0.5 + 0.5;\n}\n\nvec3 octDecode(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n vec3 n = vec3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y));\n float t = max(-n.z, 0.0);\n n.x += n.x >= 0.0 ? -t : t;\n n.y += n.y >= 0.0 ? -t : t;\n return normalize(n);\n}\n\nvec2 octWrapUv(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n if (f.x < -1.0 || f.x > 1.0) {\n f.x = (f.x > 0.0 ? 2.0 : -2.0) - f.x;\n f.y = -f.y;\n }\n if (f.y < -1.0 || f.y > 1.0) {\n f.y = (f.y > 0.0 ? 2.0 : -2.0) - f.y;\n f.x = -f.x;\n }\n return f * 0.5 + 0.5;\n}\n\nvec2 octInsetUv(vec3 d, float edge) {\n return (octEncode(d) * (edge - 2.0) + 1.0) / edge;\n}\n\nvec3 octInsetDir(vec2 uv, float edge) {\n return octDecode(octWrapUv((uv * edge - 1.0) / (edge - 2.0)));\n}\n\nin vec2 vUv;\n\n/** The captured probe, box-filtered, which this convolves. Never the cube being written. */\nuniform highp samplerCube uPrefilterSource;\n\n/**\n * The edge of the level being written, in texels, which is what the inset is derived from.\n *\n * The level's own size rather than the array's, because a gutter is one texel at every level and\n * its share of the map therefore doubles as the chain coarsens. A base edge passed here instead\n * would inset every level by the base's fraction, which is a fifth of a texel at the top of the\n * chain and reads as the reflection sliding as roughness rises.\n */\nuniform float uPrefilterEdge;\n\n/** 1 on the level holding the cosine convolution, 0 on every level of the reflection chain. */\nuniform float uPrefilterIrradiance;\n\n/**\n * 1 when the reflection chain is a box filter rather than a GGX convolution.\n *\n * RenderQuality.environmentPrefilter is off by default, and that default is a correction rather\n * than a preference: the prefilter shipped wired straight into the lit pass and changed the\n * picture of every consumer that had a probe, which was reported as metals reading opaque. A\n * profile that has not asked for the lobe chain must get the same reflection it had before probes\n * moved into an array, so the box arm exists and is what that profile bakes.\n *\n * The array edge is twice the source cube's face, so one array level matches one cube level in\n * angular density and the box arm is a single fetch at the same index. There is no second\n * texture: the flag decides what is convolved into the layer rather than which of two chains is\n * bound, so it is read at bake time and a profile that changes it has to bake again.\n */\nuniform float uPrefilterBox;\n\n/** The level being written, for the box arm, which reads the source chain at the same index. */\nuniform float uPrefilterLevel;\n\n/** The roughness this level stands for, from roughnessForLevel. 0 is a mirror. */\nuniform float uPrefilterRoughness;\n/** Samples per texel, from PREFILTER_SAMPLE_COUNTS. A power of two; see the count's own note. */\nuniform float uPrefilterSamples;\n/** The source cube's face width in texels, for the solid-angle comparison below. */\nuniform float uPrefilterSourceTexels;\n/** The source chain's top level, so a wide sample cannot ask for a level that is not there. */\nuniform float uPrefilterSourceMaxLod;\n\nout vec4 fragColor;\n\nconst float PI = 3.14159265359;\n\n/**\n * The radical inverse of a 32-bit integer, base two: the second dimension of a Hammersley set.\n *\n * A bit reversal, written as the standard five-shift sequence rather than a loop, because a loop\n * over 32 bits per sample per texel is the one place in a bake where the arithmetic is the cost.\n * **This is why every sample count is a power of two** \u2014 the sequence's stratification is only\n * even when the count is, and an uneven one shows as a faint directional bias in a rough level\n * rather than as an error anybody would notice.\n */\nfloat radicalInverse(uint bits) {\n bits = (bits << 16u) | (bits >> 16u);\n bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);\n bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);\n bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);\n bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);\n return float(bits) * 2.3283064365386963e-10;\n}\n\n/**\n * A half vector drawn from the GGX distribution about the normal, for one Hammersley pair.\n *\n * Importance sampling rather than a uniform hemisphere: at low roughness the lobe occupies a\n * vanishing fraction of the hemisphere, so uniform samples would put almost every one of them\n * where the weight is zero and the few that land in the lobe would carry all the variance.\n */\nvec3 importanceSampleGgx(vec2 xi, vec3 n, float roughness) {\n float a = roughness * roughness;\n\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));\n float sinTheta = sqrt(1.0 - cosTheta * cosTheta);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n /*\n * Any tangent will do as long as it is not parallel to the normal, and the choice of which\n * axis to cross against has to depend on the normal or it degenerates at the poles \u2014 which is\n * one face of six coming out as noise, on the two faces the axis points at.\n */\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/**\n * A direction drawn from the cosine lobe about the normal, for the diffuse level.\n *\n * **Cosine-weighted, which is what makes the estimator a plain mean.** Irradiance is\n * integral of L cos(theta), the cosine pdf is cos(theta) / pi, so the estimate is\n * pi / N * sum(L) \u2014 and what the lit pass wants is radiance rather than irradiance, so the pi\n * divides straight back out. irradianceSh.ts folds the same division into its band factors and\n * says so, which is what makes a grid of one reproduce what the projection produced.\n */\nvec3 importanceSampleCosine(vec2 xi, vec3 n) {\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt(max(1.0 - xi.y, 0.0));\n float sinTheta = sqrt(xi.y);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/** Trowbridge-Reitz, in its own normalisation, because this is a probability rather than a look. */\nfloat ggxDistribution(float ndh, float roughness) {\n float a = roughness * roughness;\n float a2 = a * a;\n float d = ndh * ndh * (a2 - 1.0) + 1.0;\n return a2 / max(PI * d * d, 1e-8);\n}\n\nvoid main() {\n vec3 n = octInsetDir(vUv, uPrefilterEdge);\n\n float texelSolidAngle = 4.0 * PI / (6.0 * uPrefilterSourceTexels * uPrefilterSourceTexels);\n\n /*\n * The box chain, which is one fetch: the source's own level at the same index, resampled through\n * the octahedral mapping. It is what a profile that has not asked for the lobe chain gets, and\n * it is the picture every consumer with a probe already had.\n */\n if (uPrefilterBox > 0.5 && uPrefilterIrradiance <= 0.5) {\n fragColor = vec4(\n textureLod(uPrefilterSource, n, min(uPrefilterLevel, uPrefilterSourceMaxLod)).rgb,\n 1.0\n );\n return;\n }\n\n /*\n * **The diffuse level, and it is a mean rather than a weighted sum.** Every cosine sample is\n * drawn with the weight already in its density, so there is nothing to accumulate a weight for\n * and nothing to divide by but the count. A sample below the horizon cannot occur, which is the\n * other half of why this arm has no continue in it.\n */\n if (uPrefilterIrradiance > 0.5) {\n vec3 diffuse = vec3(0.0);\n int cosineSamples = int(uPrefilterSamples);\n for (int i = 0; i < cosineSamples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 l = importanceSampleCosine(xi, n);\n /*\n * The same filtered importance sampling the reflection uses, against the cosine density.\n * A diffuse sample is wide \u2014 a sixth of the sphere at the median \u2014 so reading the base level\n * would carry a bright window through as a handful of fireflies in the ambient, which is the\n * one place they cannot be blurred out later.\n */\n float pdf = max(dot(n, l) / PI, 1e-4);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n diffuse += textureLod(uPrefilterSource, l, lod).rgb;\n }\n fragColor = vec4(diffuse / max(uPrefilterSamples, 1.0), 1.0);\n return;\n }\n\n /*\n * A mirror is a fetch rather than an integral, and it is a separate arm on purpose: at\n * roughness 0 the distribution is a delta, every importance sample collapses onto the same\n * direction, and the sum is one sample's worth of noise where the answer is exactly the texel.\n */\n if (uPrefilterRoughness <= 0.0) {\n fragColor = vec4(textureLod(uPrefilterSource, n, 0.0).rgb, 1.0);\n return;\n }\n\n vec3 sum = vec3(0.0);\n float weight = 0.0;\n\n int samples = int(uPrefilterSamples);\n for (int i = 0; i < samples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 h = importanceSampleGgx(xi, n, uPrefilterRoughness);\n vec3 l = normalize(2.0 * dot(n, h) * h - n);\n\n float ndl = dot(n, l);\n if (ndl <= 0.0) continue;\n\n /*\n * Filtered importance sampling. A sample covers 1 / (count * pdf) of the sphere; when that\n * is much wider than a texel, reading the base level lets a few very bright texels land in\n * some samples and not others, and the variance survives the average as fireflies. The half\n * in front of the logarithm is a length ratio against an area one \u2014 a level step doubles a\n * texel's width and quadruples its solid angle. sourceLevelForSample holds the same\n * arithmetic on the CPU and its test carries the hand-derived numbers.\n */\n float ndh = max(dot(n, h), 0.0);\n float pdf = max(ggxDistribution(ndh, uPrefilterRoughness) * 0.25, 1e-8);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n\n /*\n * textureLod rather than texture, and the 2026-08-07 rule is why: this fetch sits inside a\n * loop with a continue in it, which no compiler can prove uniform, and an implicit derivative\n * in non-uniform control flow is what lets a driver flatten the loop and cost every arm.\n * The level is wanted here anyway, so the rule costs nothing at all.\n */\n sum += textureLod(uPrefilterSource, l, lod).rgb * ndl;\n weight += ndl;\n }\n\n /*\n * Normalised by the accumulated weight rather than by the sample count, because samples with\n * a light below the horizon were skipped and dividing by the count would darken every rough\n * level by the fraction that missed \u2014 which reads as roughness losing energy and is exactly\n * the artefact uEnvironmentGain has been compensating for.\n */\n fragColor = vec4(sum / max(weight, 1e-4), 1.0);\n}\n";