@driftengine/core 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.json +261 -0
  2. package/README.md +1 -1
  3. package/dist/cinematic/cameraPath.d.ts +53 -0
  4. package/dist/cinematic/cameraPath.js +118 -0
  5. package/dist/environment/daylight.d.ts +77 -0
  6. package/dist/environment/daylight.js +77 -0
  7. package/dist/geometry/meshConcat.d.ts +9 -0
  8. package/dist/geometry/meshConcat.js +101 -0
  9. package/dist/index.d.ts +12 -2
  10. package/dist/index.js +14 -1
  11. package/dist/render/backend/createRenderer.d.ts +13 -1
  12. package/dist/render/backend/createRenderer.js +6 -5
  13. package/dist/render/backend/webgl2/renderer.d.ts +147 -1
  14. package/dist/render/backend/webgl2/renderer.js +402 -23
  15. package/dist/render/backend/webgpu/buffers.js +46 -8
  16. package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
  17. package/dist/render/backend/webgpu/depthPass.js +75 -14
  18. package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
  19. package/dist/render/backend/webgpu/exposurePass.js +190 -0
  20. package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
  21. package/dist/render/backend/webgpu/fieldCompose.js +13 -0
  22. package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
  23. package/dist/render/backend/webgpu/flatPass.js +12 -5
  24. package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
  25. package/dist/render/backend/webgpu/particlePass.js +1 -0
  26. package/dist/render/backend/webgpu/postPass.d.ts +12 -2
  27. package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
  28. package/dist/render/backend/webgpu/probeBake.js +97 -7
  29. package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
  30. package/dist/render/backend/webgpu/probeTargets.js +180 -0
  31. package/dist/render/backend/webgpu/renderer.d.ts +174 -11
  32. package/dist/render/backend/webgpu/renderer.js +675 -249
  33. package/dist/render/backend/webgpu/scatterPass.js +7 -7
  34. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  35. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
  36. package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
  37. package/dist/render/backend/webgpu/skyPass.js +5 -1
  38. package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
  39. package/dist/render/backend/webgpu/uniformRing.js +53 -3
  40. package/dist/render/bloomChain.d.ts +6 -0
  41. package/dist/render/bloomChain.js +8 -0
  42. package/dist/render/clusteredLights.d.ts +19 -11
  43. package/dist/render/clusteredLights.js +96 -45
  44. package/dist/render/cutoutCaster.d.ts +23 -0
  45. package/dist/render/cutoutCaster.js +20 -0
  46. package/dist/render/driftLight/bake.d.ts +34 -0
  47. package/dist/render/driftLight/bake.js +95 -0
  48. package/dist/render/driftLight/falloff.d.ts +16 -0
  49. package/dist/render/driftLight/falloff.js +23 -0
  50. package/dist/render/driftLight/layout.d.ts +59 -0
  51. package/dist/render/driftLight/layout.js +138 -0
  52. package/dist/render/driftLight/lightField.d.ts +108 -0
  53. package/dist/render/driftLight/lightField.js +179 -0
  54. package/dist/render/driftLight/uniforms.d.ts +28 -0
  55. package/dist/render/driftLight/uniforms.js +41 -0
  56. package/dist/render/driftLight/visibility.d.ts +18 -0
  57. package/dist/render/driftLight/visibility.js +47 -0
  58. package/dist/render/emptyTexture.d.ts +6 -0
  59. package/dist/render/emptyTexture.js +19 -0
  60. package/dist/render/exposurePass.d.ts +48 -0
  61. package/dist/render/exposurePass.js +172 -0
  62. package/dist/render/flameLight.d.ts +71 -0
  63. package/dist/render/flameLight.js +63 -0
  64. package/dist/render/gi/bounceLights.d.ts +34 -0
  65. package/dist/render/gi/bounceLights.js +66 -0
  66. package/dist/render/gi/fieldReach.d.ts +12 -0
  67. package/dist/render/gi/fieldReach.js +62 -0
  68. package/dist/render/gi/globalField.d.ts +8 -0
  69. package/dist/render/gi/globalField.js +31 -0
  70. package/dist/render/gi/probeTrace.d.ts +3 -1
  71. package/dist/render/gi/probeTrace.js +3 -1
  72. package/dist/render/gpuCapability.js +19 -4
  73. package/dist/render/lightBudget.d.ts +11 -0
  74. package/dist/render/lightBudget.js +11 -0
  75. package/dist/render/pointLightSelection.d.ts +16 -0
  76. package/dist/render/pointLightSelection.js +178 -83
  77. package/dist/render/probeGrid.d.ts +11 -1
  78. package/dist/render/probeGrid.js +5 -2
  79. package/dist/render/probeSweeps.d.ts +45 -0
  80. package/dist/render/probeSweeps.js +77 -0
  81. package/dist/render/reflectionProbe.d.ts +43 -1
  82. package/dist/render/reflectionProbe.js +17 -3
  83. package/dist/render/renderQuality.d.ts +3 -1
  84. package/dist/render/rendererHarness.d.ts +9 -0
  85. package/dist/render/rendererHarness.js +18 -3
  86. package/dist/render/sceneTarget.d.ts +20 -0
  87. package/dist/render/sceneTarget.js +62 -1
  88. package/dist/render/shaders/agx.d.ts +31 -0
  89. package/dist/render/shaders/agx.js +81 -0
  90. package/dist/render/shaders/ambientOcclusion.d.ts +24 -5
  91. package/dist/render/shaders/ambientOcclusion.js +120 -63
  92. package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
  93. package/dist/render/shaders/cutoutCoverage.js +40 -0
  94. package/dist/render/shaders/depth.d.ts +25 -0
  95. package/dist/render/shaders/depth.js +69 -3
  96. package/dist/render/shaders/exposure.d.ts +52 -0
  97. package/dist/render/shaders/exposure.js +124 -0
  98. package/dist/render/shaders/filmLook.d.ts +47 -0
  99. package/dist/render/shaders/filmLook.js +98 -0
  100. package/dist/render/shaders/flat/directionalShadow.js +35 -4
  101. package/dist/render/shaders/flat/driftLight.d.ts +29 -0
  102. package/dist/render/shaders/flat/driftLight.js +75 -0
  103. package/dist/render/shaders/flat/index.js +5 -0
  104. package/dist/render/shaders/flat/lobes.js +13 -0
  105. package/dist/render/shaders/flat/main.d.ts +1 -1
  106. package/dist/render/shaders/flat/main.js +68 -9
  107. package/dist/render/shaders/flat/preamble.js +23 -5
  108. package/dist/render/shaders/flat/probeGrid.js +46 -3
  109. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +1 -1
  110. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +1 -1
  111. package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
  112. package/dist/render/shaders/generated/depth.wgsl.js +100 -0
  113. package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
  114. package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
  115. package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
  116. package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
  117. package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
  118. package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
  119. package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
  120. package/dist/render/shaders/generated/rush.wgsl.js +40 -10
  121. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  122. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  123. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
  124. package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
  125. package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
  126. package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
  127. package/dist/render/shaders/globalMedium.js +4 -0
  128. package/dist/render/shaders/localExposure.d.ts +35 -0
  129. package/dist/render/shaders/localExposure.js +138 -0
  130. package/dist/render/shaders/prefilter.d.ts +1 -1
  131. package/dist/render/shaders/prefilter.js +6 -0
  132. package/dist/render/shaders/rush.js +33 -5
  133. package/dist/render/shaders/tangentFrame.d.ts +1 -1
  134. package/dist/render/shaders/tangentFrame.js +15 -3
  135. package/dist/render/shaders/temporalResolve.js +39 -1
  136. package/dist/render/shadowCasters.d.ts +5 -3
  137. package/dist/render/shadowMap.js +13 -0
  138. package/dist/render/surfaceTexture.d.ts +5 -0
  139. package/package.json +6 -6
  140. package/scripts/gpuCompute.mjs +24 -3
  141. package/scripts/png.mjs +23 -12
  142. package/src/cinematic/cameraPath.ts +178 -0
  143. package/src/environment/daylight.ts +152 -0
  144. package/src/geometry/meshConcat.ts +100 -0
  145. package/src/index.ts +32 -0
  146. package/src/render/backend/createRenderer.ts +22 -6
  147. package/src/render/backend/webgl2/renderer.ts +439 -20
  148. package/src/render/backend/webgpu/buffers.ts +51 -8
  149. package/src/render/backend/webgpu/depthPass.ts +78 -10
  150. package/src/render/backend/webgpu/exposurePass.ts +234 -0
  151. package/src/render/backend/webgpu/fieldCompose.ts +15 -0
  152. package/src/render/backend/webgpu/flatPass.ts +10 -2
  153. package/src/render/backend/webgpu/particlePass.ts +9 -0
  154. package/src/render/backend/webgpu/probeBake.ts +143 -9
  155. package/src/render/backend/webgpu/probeTargets.ts +211 -0
  156. package/src/render/backend/webgpu/renderer.ts +801 -270
  157. package/src/render/backend/webgpu/scatterPass.ts +7 -7
  158. package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
  159. package/src/render/backend/webgpu/skyPass.ts +5 -1
  160. package/src/render/backend/webgpu/uniformRing.ts +49 -3
  161. package/src/render/bloomChain.ts +9 -0
  162. package/src/render/clusteredLights.ts +108 -63
  163. package/src/render/cutoutCaster.ts +44 -0
  164. package/src/render/driftLight/bake.ts +125 -0
  165. package/src/render/driftLight/falloff.ts +26 -0
  166. package/src/render/driftLight/layout.ts +177 -0
  167. package/src/render/driftLight/lightField.ts +227 -0
  168. package/src/render/driftLight/uniforms.ts +61 -0
  169. package/src/render/driftLight/visibility.ts +58 -0
  170. package/src/render/emptyTexture.ts +41 -0
  171. package/src/render/exposurePass.ts +186 -0
  172. package/src/render/flameLight.ts +135 -0
  173. package/src/render/gi/bounceLights.ts +68 -0
  174. package/src/render/gi/fieldReach.ts +74 -0
  175. package/src/render/gi/globalField.ts +39 -0
  176. package/src/render/gi/probeTrace.ts +3 -1
  177. package/src/render/gpuCapability.ts +18 -5
  178. package/src/render/lightBudget.ts +12 -0
  179. package/src/render/pointLightSelection.ts +191 -79
  180. package/src/render/probeGrid.ts +13 -2
  181. package/src/render/probeSweeps.ts +80 -0
  182. package/src/render/reflectionProbe.ts +54 -2
  183. package/src/render/renderQuality.ts +3 -1
  184. package/src/render/rendererHarness.ts +25 -3
  185. package/src/render/sceneTarget.ts +75 -1
  186. package/src/render/shaders/ambientOcclusion.ts +125 -63
  187. package/src/render/shaders/cutoutCoverage.ts +43 -0
  188. package/src/render/shaders/depth.ts +80 -3
  189. package/src/render/shaders/exposure.ts +128 -0
  190. package/src/render/shaders/filmLook.ts +111 -0
  191. package/src/render/shaders/flat/directionalShadow.ts +35 -4
  192. package/src/render/shaders/flat/driftLight.ts +75 -0
  193. package/src/render/shaders/flat/index.ts +5 -0
  194. package/src/render/shaders/flat/lobes.ts +13 -0
  195. package/src/render/shaders/flat/main.ts +68 -9
  196. package/src/render/shaders/flat/preamble.ts +23 -5
  197. package/src/render/shaders/flat/probeGrid.ts +46 -3
  198. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +1 -1
  199. package/src/render/shaders/generated/depth.wgsl.ts +103 -0
  200. package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
  201. package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
  202. package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
  203. package/src/render/shaders/generated/rush.wgsl.ts +40 -10
  204. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  205. package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
  206. package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
  207. package/src/render/shaders/globalMedium.ts +4 -0
  208. package/src/render/shaders/localExposure.ts +154 -0
  209. package/src/render/shaders/prefilter.ts +6 -0
  210. package/src/render/shaders/rush.ts +33 -5
  211. package/src/render/shaders/tangentFrame.ts +15 -3
  212. package/src/render/shaders/temporalResolve.ts +39 -1
  213. package/src/render/shadowCasters.ts +5 -3
  214. package/src/render/shadowMap.ts +14 -0
  215. package/src/render/surfaceTexture.ts +5 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Eye adaptation: the frame's own brightness, measured on the GPU and followed over time.
3
+ *
4
+ * **A fixed exposure is right for one view and wrong for the next.** A courtyard with the sun on
5
+ * its upper walls and its floor in the shade of a 19 m wall is several stops darker at ground level
6
+ * than from the gallery, and an exposure set for one draws the other either clipped or black. A
7
+ * camera meters and an eye adapts; this does both, from the finished scene, with no readback.
8
+ *
9
+ * **Two passes, both tiny.** The meter writes a 32×32 grid, each texel the mean of 8×8 taps of log
10
+ * luminance over its share of the frame. The adaptation reads all 1,024 of them in one texel,
11
+ * averages them into the frame's log-mean luminance, and moves the held value toward it by a blend
12
+ * the caller's frame time decides. The composite reads the held value and scales the scene toward
13
+ * middle grey. Log luminance, because an eye's response is to ratios: one sunlit pixel beside a
14
+ * dark frame should not set the exposure the way an arithmetic mean lets it.
15
+ *
16
+ * What it gives up is metering by region: every pixel counts the same, so a small bright window in
17
+ * a dark room pulls less than a photographer's spot meter would. What would change it is a scene
18
+ * that needs a subject held at a brightness, and the answer then is a centre weight in the meter.
19
+ */
20
+
21
+ /** Texels a side in the meter's grid, and taps a side within each. */
22
+ export const METER_GRID = 32;
23
+ export const METER_TAPS = 8;
24
+ /** The luminance the meter clamps to, in stops: a black pixel is not minus infinity. */
25
+ export const METER_MIN_LOG2 = -12;
26
+ export const METER_MAX_LOG2 = 8;
27
+ /** What full adaptation brings the frame's log-mean luminance to: middle grey. */
28
+ export const ADAPTED_KEY = 0.18;
29
+ /** The furthest adaptation moves the exposure either way, in stops. A black loading screen is not a
30
+ * reason to multiply the next frame by a thousand. */
31
+ export const ADAPT_RANGE_STOPS = 6;
32
+ /**
33
+ * How fast the held brightness follows the measured one, per second: 1.5 closes about 78% of the
34
+ * way in a second. An eye takes longer to open than to close, and a camera's meter is faster than
35
+ * either; one rate is what a scene that cuts between shots can predict.
36
+ */
37
+ export const ADAPT_RATE = 1.5;
38
+
39
+ export const EXPOSURE_METER_FRAG = `#version 300 es
40
+ precision highp float;
41
+
42
+ in vec2 vUv;
43
+
44
+ /** The finished scene, in scene light, before any exposure or curve. */
45
+ uniform highp sampler2D uScene;
46
+
47
+ out float fragColor;
48
+
49
+ void main() {
50
+ /* This texel's corner, and taps spread evenly over its share of the frame. */
51
+ vec2 corner = vUv - vec2(0.5 / ${METER_GRID.toFixed(1)});
52
+ float sum = 0.0;
53
+ for (int y = 0; y < ${METER_TAPS}; y++) {
54
+ for (int x = 0; x < ${METER_TAPS}; x++) {
55
+ vec2 uv = corner + (vec2(float(x), float(y)) + 0.5) / ${(METER_GRID * METER_TAPS).toFixed(1)};
56
+ vec3 c = textureLod(uScene, uv, 0.0).rgb;
57
+ float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
58
+ sum += clamp(log2(max(luma, 1e-8)), ${METER_MIN_LOG2.toFixed(1)}, ${METER_MAX_LOG2.toFixed(1)});
59
+ }
60
+ }
61
+ fragColor = sum / ${(METER_TAPS * METER_TAPS).toFixed(1)};
62
+ }
63
+ `;
64
+
65
+ export const EXPOSURE_ADAPT_FRAG = `#version 300 es
66
+ precision highp float;
67
+
68
+ /** The meter's grid of log luminances. */
69
+ uniform highp sampler2D uMeter;
70
+ /** The brightness held from the frames before, in stops, one texel. */
71
+ uniform highp sampler2D uHeld;
72
+ /** How far toward this frame's measurement to move: 1 snaps, 0 holds. */
73
+ uniform float uBlend;
74
+
75
+ out float fragColor;
76
+
77
+ void main() {
78
+ float sum = 0.0;
79
+ for (int y = 0; y < ${METER_GRID}; y++) {
80
+ for (int x = 0; x < ${METER_GRID}; x++) {
81
+ sum += texelFetch(uMeter, ivec2(x, y), 0).r;
82
+ }
83
+ }
84
+ float measured = sum / ${(METER_GRID * METER_GRID).toFixed(1)};
85
+ float held = texelFetch(uHeld, ivec2(0, 0), 0).r;
86
+ fragColor = mix(held, measured, uBlend);
87
+ }
88
+ `;
89
+
90
+ /**
91
+ * `uAutoExposure`, `uExposureHeld` and `autoExposureGain`, for the composite. The gain scales scene
92
+ * light before the tone curve, toward the key by the fraction the caller asked for.
93
+ */
94
+ export const AUTO_EXPOSURE_GLSL = `
95
+ /** How far the frame adapts toward middle grey, 0 to 1. 0 is off and the whole of the off path. */
96
+ uniform float uAutoExposure;
97
+ /** The held log-mean luminance, in stops, one texel. */
98
+ uniform highp sampler2D uExposureHeld;
99
+
100
+ float autoExposureGain() {
101
+ if (uAutoExposure <= 0.0) return 1.0;
102
+ float held = texelFetch(uExposureHeld, ivec2(0, 0), 0).r;
103
+ float stops = uAutoExposure * (${Math.log2(ADAPTED_KEY).toFixed(6)} - held);
104
+ return exp2(clamp(stops, -${ADAPT_RANGE_STOPS.toFixed(1)}, ${ADAPT_RANGE_STOPS.toFixed(1)}));
105
+ }
106
+ `;
107
+
108
+ /**
109
+ * The fraction of the way toward this frame's measurement to move, after `dtSec` seconds. A cut,
110
+ * or a first frame with nothing held, snaps.
111
+ */
112
+ export function adaptBlend(dtSec: number, snap: boolean): number {
113
+ if (snap) return 1;
114
+ if (!(dtSec > 0)) return 0;
115
+ return 1 - Math.exp(-ADAPT_RATE * dtSec);
116
+ }
117
+
118
+ /** The scene's gain for a held log-mean `heldLog2` at `strength`, as the composite computes it. */
119
+ export function autoExposureGain(heldLog2: number, strength: number): number {
120
+ if (!(strength > 0)) return 1;
121
+ const stops = strength * (Math.log2(ADAPTED_KEY) - heldLog2);
122
+ return 2 ** Math.min(Math.max(stops, -ADAPT_RANGE_STOPS), ADAPT_RANGE_STOPS);
123
+ }
124
+
125
+ /** An adaptation strength as the shader takes it: 0 to 1, anything else off. */
126
+ export function clampAutoExposure(strength: number): number {
127
+ return Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 1) : 0;
128
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The lens and the print: a vignette in scene light, and grain in display values.
3
+ *
4
+ * **The vignette is the lens's, so it acts on light rather than on the picture.** A lens loses light
5
+ * toward its corners by roughly the fourth power of the cosine of the angle off axis, which for a
6
+ * point `r` from the centre is `1 / (1 + k r^2)^2`. Applied to scene light before the tone curve,
7
+ * a bright corner rolls off through the curve's shoulder the way a photographed one does, where a
8
+ * vignette painted over the finished picture greys it. `r` is measured to the frame's corner on a
9
+ * circle, not an ellipse, so a wide window is not darker at its sides than at its top.
10
+ *
11
+ * **The grain is the print's, so it acts on display values after the grade and the veil.** It is
12
+ * one value a pixel, the same on all three channels, weighted toward the midtones where film shows
13
+ * it and kept faint in deep shadow and highlight, where adding it would lift black and grey white.
14
+ * It also dithers: a dark gradient quantised to eight bits bands, and a noise of a few levels breaks
15
+ * the bands up. **The seed is the caller's**, because the engine takes time from its caller: a held
16
+ * capture passes the same seed and is identical run to run, and a playing scene passes a new one
17
+ * each frame and the grain moves as film does.
18
+ *
19
+ * Zero strength for each is the whole of its off path: a branch on a uniform, and the frame is what
20
+ * it was byte for byte.
21
+ */
22
+
23
+ /**
24
+ * `uVignette`, `uGrain`, `withVignette` and `withGrain`. The rush composite includes it once.
25
+ *
26
+ * `withVignette` takes the pixel's coordinate and the scene's size, since a distance to the corner
27
+ * has to know the aspect. `withGrain` takes the fragment's integer position, so the grain is one
28
+ * value a pixel whatever the resolution.
29
+ */
30
+ export const FILM_LOOK_GLSL = `
31
+ /** How strongly the lens darkens its corners: 0 is none, 0.5 about 1.2 stops at the corner. */
32
+ uniform float uVignette;
33
+ /** x: the grain's amplitude in display values, 0 for none. y: this frame's seed, the caller's. */
34
+ uniform vec2 uGrain;
35
+
36
+ vec3 withVignette(vec3 c, vec2 uv, vec2 size) {
37
+ if (uVignette <= 0.0) return c;
38
+ vec2 d = (uv - 0.5) * size;
39
+ vec2 corner = 0.5 * size;
40
+ float r2 = dot(d, d) / dot(corner, corner);
41
+ float falloff = 1.0 + uVignette * r2;
42
+ return c / (falloff * falloff);
43
+ }
44
+
45
+ float grainNoise(uvec2 p, uint seed) {
46
+ uint h = p.x * 1664525u + p.y * 1013904223u + seed * 2654435769u;
47
+ h ^= h >> 16u;
48
+ h *= 2246822519u;
49
+ h ^= h >> 13u;
50
+ h *= 3266489917u;
51
+ h ^= h >> 16u;
52
+ return float(h) * (1.0 / 4294967295.0);
53
+ }
54
+
55
+ vec3 withGrain(vec3 c, vec2 fragCoord) {
56
+ if (uGrain.x <= 0.0) return c;
57
+ float luma = dot(clamp(c, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
58
+ float weight = 0.25 + 3.0 * luma * (1.0 - luma);
59
+ float n = grainNoise(uvec2(fragCoord), uint(uGrain.y)) - 0.5;
60
+ return c + vec3(2.0 * n * uGrain.x * weight);
61
+ }
62
+ `;
63
+
64
+ /**
65
+ * The vignette's factor at `uv` on a `width` by `height` frame, as the shader computes it.
66
+ * The shader's arithmetic written out once more, so a test can pin it to hand-derived values.
67
+ */
68
+ export function vignetteFactor(
69
+ u: number,
70
+ v: number,
71
+ width: number,
72
+ height: number,
73
+ strength: number,
74
+ ): number {
75
+ if (strength <= 0) return 1;
76
+ const dx = (u - 0.5) * width;
77
+ const dy = (v - 0.5) * height;
78
+ const r2 = (dx * dx + dy * dy) / (0.25 * (width * width + height * height));
79
+ const falloff = 1 + strength * r2;
80
+ return 1 / (falloff * falloff);
81
+ }
82
+
83
+ /** How much of the grain a display luminance takes: full in the midtones, a quarter at the ends. */
84
+ export function grainWeight(luma: number): number {
85
+ const l = Math.min(Math.max(luma, 0), 1);
86
+ return 0.25 + 3 * l * (1 - l);
87
+ }
88
+
89
+ /** Both backends' words when there is no composite to apply the look in. */
90
+ export const FILM_LOOK_WITHOUT_COMPOSITE =
91
+ '[driftengine] setVignette and setFilmGrain need `screenEffects`: without a composite every ' +
92
+ 'pass is the last thing to touch the frame, so there is no one place to apply a lens or a ' +
93
+ 'print. The look was not applied.';
94
+
95
+ /** A vignette strength as the shader takes it: 0 to 4, anything else off. */
96
+ export function clampVignette(strength: number): number {
97
+ return Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 4) : 0;
98
+ }
99
+
100
+ /** A grain amplitude in display values: 0 to 0.5, anything else off. */
101
+ export function clampGrain(strength: number): number {
102
+ return Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 0.5) : 0;
103
+ }
104
+
105
+ /**
106
+ * A seed as the shader reads it: a whole number below 2^24, which a 32-bit float carries exactly,
107
+ * so the same seed is the same grain on both backends. Any number a caller has is accepted.
108
+ */
109
+ export function grainSeed(seed: number): number {
110
+ return Number.isFinite(seed) ? Math.floor(Math.abs(seed)) % 16777216 : 0;
111
+ }
@@ -68,10 +68,13 @@ float shadowFactor(float directionalNdl) {
68
68
  // Near a surface's own light terminator its direct-light term is already
69
69
  // approaching zero, while the orthographic projection of that surface
70
70
  // collapses toward a line. No finite contact-safe bias can represent that
71
- // slope reliably. Fade only the shadow modulation through this narrow band;
72
- // the Lambert term still supplies the physically dominant falloff.
71
+ // slope reliably, so through this band the fine filter below hands over to the
72
+ // coarse test after it. \`directionalNdl\` is the surface's own, before any normal
73
+ // map: the band is about the receiving plane, and a map's tilt says nothing about it.
73
74
  float receiverFade = smoothstep(0.08, 0.20, directionalNdl);
74
- if (receiverFade <= 0.0) return 1.0;
75
+ /* A surface turned from the light takes none of it, so there is nothing to shadow; what reads
76
+ this there is the emissive share, which has always seen such a surface as unshadowed. */
77
+ if (directionalNdl <= 0.0) return 1.0;
75
78
 
76
79
  // The receiver stays at its real position, computed above. A normal offset changes
77
80
  // direction at a hard mesh edge and tears one continuous shadow where it crosses from
@@ -104,6 +107,34 @@ float shadowFactor(float directionalNdl) {
104
107
  );
105
108
  if (lowElevationFade <= 0.0) return 1.0;
106
109
 
110
+ /*
111
+ * **The coarse test: is there anything a metre or more between this point and the sun.**
112
+ *
113
+ * One tap at the receiver's own texel, with a metre of tolerance where the filter below allows
114
+ * fourteen centimetres. It cannot see a contact shadow, and it does not need the plane's slope:
115
+ * a grazing plane's own depth moves about a centimetre times the tangent of its angle to the
116
+ * light across a texel, which stays under a metre down to a hundredth of n·l on a map with
117
+ * centimetre texels. What it does see is a roof.
118
+ *
119
+ * **This band used to return fully lit**, on the argument that the Lambert term was already
120
+ * near zero there. Under a high sun that is every vertical wall: at 70° of elevation a wall's
121
+ * n·l is at most 0.34, and one turned from the sun's bearing sits inside the band. A gallery's
122
+ * back wall under its vault took the sun through the vault, and so did the faces of its
123
+ * columns, which read as lit stone in shade; with a normal map carrying that n·l per texel it
124
+ * was a field of white specks. What would make this wrong is a map whose texels are tens of
125
+ * centimetres, where a grazing plane's own depth moves more than the tolerance and the band
126
+ * draws the acne it exists to avoid.
127
+ */
128
+ float coarseCompare = p.z - 1.0 / uShadowDepthSpan;
129
+ float coarse =
130
+ directionalVisibility(p.z, coarseCompare, textureLod(uStaticShadowMap, p.xy, 0.0).r) *
131
+ directionalVisibility(p.z, coarseCompare, textureLod(uDynamicShadowMap, p.xy, 0.0).r);
132
+ if (uPeeledShadowEnabled != 0) {
133
+ coarse *= directionalVisibility(p.z, coarseCompare, textureLod(uPeeledShadowMap, p.xy, 0.0).r);
134
+ }
135
+ float strength = uShadowStrength * edgeFade * lowElevationFade;
136
+ if (receiverFade <= 0.0) return mix(1.0, coarse, strength);
137
+
107
138
  float texel = 1.35 / uShadowMapSize;
108
139
  /*
109
140
  * How far the plane compensation below may carry a tap's depth, in the map's own units.
@@ -182,7 +213,7 @@ float shadowFactor(float directionalNdl) {
182
213
  // conventional one-depth map would discard; movers remain independent too.
183
214
  float lit = staticLit * peeledLit * dynamicLit;
184
215
 
185
- return mix(1.0, lit, uShadowStrength * edgeFade * lowElevationFade * receiverFade);
216
+ return mix(1.0, mix(coarse, lit, receiverFade), strength);
186
217
  }
187
218
  #endif
188
219
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * DriftLight in the lit shader: how much of a pixel the summed light stands in for, and the summed
3
+ * light itself, read from the field's sparse volume.
4
+ *
5
+ * **The split is per pixel.** Inside the frame's completeness radius (`uDriftLight.y`, measured from
6
+ * the eye) every light that reaches the pixel was chosen, so it is shaded exactly; past it the
7
+ * volume stands in, and across the band before the radius the two crossfade. The choice was made
8
+ * about some centre, which need not be the eye; `uniforms.ts` takes the distance between the two
9
+ * off the radius, so a pixel inside it is inside the choice's radius too. A field light's
10
+ * exact contribution is scaled by the share left over (`main.ts`, where its weight's sign is read),
11
+ * so a light is counted once however the pixel is split.
12
+ *
13
+ * **One index fetch and two filtered ones.** The index finds the brick; the brick's four samples a
14
+ * side share their faces with the next brick's, so the hardware's trilinear lookup inside one is
15
+ * seamless across the join. The light texel carries in its fourth channel whether each sample is a
16
+ * sample at all, and dividing by the filtered value drops the samples buried in walls rather than
17
+ * blending their darkness into the surface. The lookup is nudged half a spacing along the normal,
18
+ * which keeps a surface reading the air in front of it rather than the wall it is part of.
19
+ *
20
+ * **Two vectors of uniforms, and the sizes read off the textures.** The lit stage sits two vectors
21
+ * under the 256 a reported part offers at eight lights, and the first cut of this, at five, took that
22
+ * part down a rung to four. So the grid's extent and the atlas's are what `textureSize` answers, and
23
+ * the radius is measured from `uCameraPos`, which the stage already has, rather than from a centre
24
+ * of its own. What it gives up is two integer queries a pixel past the radius.
25
+ *
26
+ * `textureLod` throughout, by the 2026-08-07 rule: this is reached through branches no compiler can
27
+ * prove uniform. Both samplers are declared `highp` because GLSL ES gives 3D samplers no default.
28
+ */
29
+ export const DRIFT_LIGHT_GLSL = /* glsl */ `
30
+ /** x how much of the summed light is in the frame (0 off), y the radius, z the band, w the scale. */
31
+ uniform vec4 uDriftLight;
32
+ /** xyz the first brick's first sample; w metres between samples, a third of a brick. */
33
+ uniform vec4 uDriftLightOrigin;
34
+ uniform highp usampler3D uDriftLightIndex; // wgsl:share shadow
35
+ uniform highp sampler3D uDriftLightAtlas;
36
+
37
+ /** The share of this pixel's field light the volume stands in for: 0 inside the radius, 1 past it. */
38
+ float driftLightShare(vec3 world) {
39
+ if (uDriftLight.x <= 0.0) return 0.0;
40
+ float away = distance(world, uCameraPos);
41
+ return smoothstep(uDriftLight.y - uDriftLight.z, uDriftLight.y, away) * uDriftLight.x;
42
+ }
43
+
44
+ /** The summed light arriving on a surface at \`world\` facing \`n\`. */
45
+ vec3 driftLightIrradiance(vec3 world, vec3 n) {
46
+ float spacing = uDriftLightOrigin.w;
47
+ vec3 at = (world + n * (0.5 * spacing) - uDriftLightOrigin.xyz) / (3.0 * spacing);
48
+ ivec3 cell = ivec3(floor(at));
49
+ if (any(lessThan(cell, ivec3(0))) || any(greaterThanEqual(cell, textureSize(uDriftLightIndex, 0)))) {
50
+ return vec3(0.0);
51
+ }
52
+ uint id = texelFetch(uDriftLightIndex, cell, 0).r;
53
+ if (id == 0u) return vec3(0.0);
54
+ /* A brick is eight texels across (its light block, then its direction block) and four up and deep. */
55
+ ivec3 size = textureSize(uDriftLightAtlas, 0);
56
+ int brick = int(id) - 1;
57
+ int across = size.x / 8;
58
+ int down = size.y / 4;
59
+ vec3 corner = vec3(
60
+ float((brick % across) * 8),
61
+ float(((brick / across) % down) * 4),
62
+ float((brick / (across * down)) * 4)
63
+ );
64
+ vec3 texel = 1.0 / vec3(size);
65
+ vec3 inside = (at - vec3(cell)) * 3.0 + 0.5;
66
+ vec4 light = textureLod(uDriftLightAtlas, (corner + inside) * texel, 0.0);
67
+ if (light.a < 1e-3) return vec3(0.0);
68
+ vec4 toward = textureLod(uDriftLightAtlas, (corner + vec3(4.0, 0.0, 0.0) + inside) * texel, 0.0);
69
+ vec3 arriving = light.rgb / light.a;
70
+ vec3 from = toward.xyz / light.a;
71
+ /* Lambert for a single light, a quarter for light arriving from everywhere: see \`bake.ts\`. */
72
+ float facing = max(dot(n, from), 0.0) + (1.0 - min(length(from), 1.0)) * 0.25;
73
+ return arriving * facing;
74
+ }
75
+ `;
@@ -2,6 +2,7 @@ import { resolveConditionals } from '../conditionals.ts';
2
2
  import { OCTAHEDRAL_GLSL } from '../octahedral.ts';
3
3
  import { PROBEGRID_GLSL } from './probeGrid.ts';
4
4
  import { TANGENT_FRAME_GLSL } from '../tangentFrame.ts';
5
+ import { CUTOUT_COVERAGE_GLSL } from '../cutoutCoverage.ts';
5
6
  import { preambleGlsl } from './preamble.ts';
6
7
  import { LOBES_GLSL } from './lobes.ts';
7
8
  import { POINTSHADOW_GLSL } from './pointShadow.ts';
@@ -10,6 +11,7 @@ import { MORPH_GLSL } from '../morph.ts';
10
11
  import { SKINNING_GLSL } from '../skinning.ts';
11
12
  import { SURFACE_GLSL } from './surface.ts';
12
13
  import { MAIN_GLSL } from './main.ts';
14
+ import { DRIFT_LIGHT_GLSL } from './driftLight.ts';
13
15
  import { CHANNEL_ATTRIBUTE, CHANNEL_BEND } from '../vertexChannel.ts';
14
16
  import { FULL_LIGHT_BUDGET, type LightBudget } from '../../uniformVectorBudget.ts';
15
17
 
@@ -489,6 +491,9 @@ export function flatFrag(options: FlatShaderOptions): string {
489
491
  * and `main` is unconditional by construction.
490
492
  */
491
493
  TANGENT_FRAME_GLSL,
494
+ /* Unconditional for the same reason, and called only where a cutoff was asked for. */
495
+ CUTOUT_COVERAGE_GLSL,
496
+ DRIFT_LIGHT_GLSL,
492
497
  MAIN_GLSL,
493
498
  ].join('\n'),
494
499
  {
@@ -74,6 +74,19 @@ const float PENUMBRA_FADE = 1.6;
74
74
  */
75
75
  const float MAX_FILTER_RADIUS = 0.25;
76
76
 
77
+ /**
78
+ * The weight below which a point light's shadow is not looked up at all.
79
+ *
80
+ * A light's shadow is blended toward none as the light falls off, \`mix(1, occl, weight)\` with the
81
+ * weight twice the falloff, so a shadow looked up under this floor changes at most three per cent
82
+ * of a light that has already all but faded: with the inverse-square falloff, past about eight
83
+ * metres from it. Measured on a lantern-lit courtyard, the lookups the floor skips were most of a
84
+ * night's frame: 14 ms of 26 at 1440p, a blocker tap and twelve filter taps for every light in every
85
+ * froxel. What it gives up is the last three per cent of a distant light's shadow, which the blend
86
+ * was already taking away; what would make it wrong is a falloff that stays strong far out.
87
+ */
88
+ const float POINT_SHADOW_MIN_WEIGHT = 0.03;
89
+
77
90
  /**
78
91
  * How much of a self-lit surface's glow a lamp's shadow may take.
79
92
  *
@@ -37,8 +37,13 @@ export const MAIN_GLSL = `void main() {
37
37
  if (uAlbedoEnabled != 0) {
38
38
  vec4 texel = texture(uAlbedo, vUv);
39
39
  // Discarded before anything else is computed: a thrown-away fragment should not pay
40
- // for the lighting it will never contribute to.
41
- if (texel.a < uAlbedoCutout) discard;
40
+ // for the lighting it will never contribute to. The test credits alpha for the mip level
41
+ // it samples, so a needle averaged thin down the chain is not lost; see cutoutCoverage.ts.
42
+ // Behind a branch on a uniform, so a material with no cutoff pays nothing for it.
43
+ if (uAlbedoCutout > 0.0) {
44
+ float tested = cutoutAlpha(texel.a, vUv * vec2(textureSize(uAlbedo, 0)));
45
+ if (tested < uAlbedoCutout) discard;
46
+ }
42
47
  albedo *= texel.rgb;
43
48
  coverage = texel.a;
44
49
  }
@@ -81,6 +86,15 @@ export const MAIN_GLSL = `void main() {
81
86
 
82
87
  if (uLightingEnabled != 0) {
83
88
  vec3 n = normalize(vNormal);
89
+ /*
90
+ * **Which face of a two-sided surface this is, from the normal and not from the rasteriser.**
91
+ * The rasteriser's own facing flag would say it exactly, and it is one more input to the stage: the flat
92
+ * shader already carries sixteen, WebGPU's ceiling, and the seventeenth made every pipeline
93
+ * invalid and every mesh draw nothing. A face whose own normal points away from the eye is the
94
+ * back of the sheet; what that gives up is a sliver at a curved silhouette, where a front face's
95
+ * interpolated normal already leans away and is lit as the back.
96
+ */
97
+ bool backFace = uDoubleSided != 0 && dot(n, uCameraPos - vWorldPos) < 0.0;
84
98
  /*
85
99
  * The authored normal, before either relief perturbs it.
86
100
  *
@@ -260,6 +274,15 @@ export const MAIN_GLSL = `void main() {
260
274
  );
261
275
  }
262
276
 
277
+ /*
278
+ * **The back face of a two-sided surface is lit as its front**, which is glTF's rule for
279
+ * \`doubleSided\`: the whole shading normal turned toward the viewer, after the map and the
280
+ * relief have perturbed it, as three.js turns its normal, tangent and bitangent together.
281
+ * Turning only the interpolated normal would leave a normal map's tilt pointing the wrong way
282
+ * across the fold of every curtain.
283
+ */
284
+ if (backFace) n = -n;
285
+
263
286
  /*
264
287
  * One dot product, used twice: ndl is the lit side and its negative is the night side below.
265
288
  * Shared rather than issued twice because it is the same number and one is cheaper, and not
@@ -273,8 +296,23 @@ export const MAIN_GLSL = `void main() {
273
296
  * term below needs it too. The sun and the moon are the only sources whose shadow
274
297
  * used to stop at a glowing surface.
275
298
  */
299
+ /*
300
+ * **The surface's own n·l, before any map tilted it: the side of the surface the sun is on.**
301
+ *
302
+ * A normal map says which way each texel faces within its surface; it cannot put a texel on the
303
+ * other side of that surface from the sun. Read from the mapped normal alone, the texels of a
304
+ * column's face turned from a high sun that leaned into the light were lit as though the column
305
+ * were not in the way: a field of white specks over stone in shade. So the shadow's band is
306
+ * chosen by this, and the sun is taken away as it reaches zero, over the last two hundredths of
307
+ * n·l, where an unmapped surface's own Lambert term is already under 0.02. Over a tenth it moved
308
+ * every smooth column's terminator in the published scenes. What it gives up is the bump-lit
309
+ * relief of a surface barely facing the sun, where the light was already grazing.
310
+ */
311
+ vec3 surfaceN = normalize(vNormal);
312
+ if (backFace) surfaceN = -surfaceN;
313
+ float surfaceNdl = dot(surfaceN, uDirectionalDir);
276
314
  #if DIRECTIONAL_SHADOWS
277
- float sunShade = shadowFactor(ndl);
315
+ float sunShade = shadowFactor(max(surfaceNdl, 0.0));
278
316
  #else
279
317
  float sunShade = 1.0;
280
318
  #endif
@@ -298,7 +336,13 @@ export const MAIN_GLSL = `void main() {
298
336
  * that shipped before it.
299
337
  */
300
338
  sunShade *= vSkyDirect;
301
- float direct = ndl * sunShade;
339
+ /*
340
+ * And the sun gone from a surface turned from it, whatever its map says. See \`surfaceNdl\`.
341
+ * Its own factor rather than folded into sunShade, which the emissive share reads as the sun's
342
+ * shadow: a lamp's pane turned from the moon is not in shadow, it is only unlit.
343
+ */
344
+ float sunFacing = smoothstep(0.0, 0.02, surfaceNdl);
345
+ float direct = ndl * sunShade * sunFacing;
302
346
 
303
347
  /*
304
348
  * Hemispheric: sky above, ground below, mixed by which way the surface looks. An
@@ -328,6 +372,7 @@ export const MAIN_GLSL = `void main() {
328
372
  * set, which is the guarantee that option was added to give.
329
373
  */
330
374
  vec3 irradiance = gridIrradiance(n, vWorldPos);
375
+ irradiance = mix(irradiance, ambient, probeGridOutside(surfaceN, vWorldPos));
331
376
  ambient = mix(ambient, irradiance, uProbeGridAmbient * uEnvironmentEnabled);
332
377
  #endif
333
378
  /*
@@ -406,7 +451,7 @@ export const MAIN_GLSL = `void main() {
406
451
  * before: \`sunSpec\` is \`vec3(vSpecular)\` at metal 0 and \`vSpecular\` defaults to 0.
407
452
  */
408
453
  vec3 sunHighlight =
409
- uDirectionalColor * specularLobe(max(dot(n, halfway), 0.0), surfaceRoughness) * sunSpec * sunShade;
454
+ uDirectionalColor * specularLobe(max(dot(n, halfway), 0.0), surfaceRoughness) * sunSpec * sunShade * sunFacing;
410
455
  /*
411
456
  * The dielectric's share goes in here, where it has always gone, so the environment blend
412
457
  * below dims it by exactly the reflectance it always dimmed it by. The metal's share is added
@@ -813,6 +858,7 @@ export const MAIN_GLSL = `void main() {
813
858
  */
814
859
  vec3 lampOpen = vec3(0.0);
815
860
  vec3 lampShadowed = vec3(0.0);
861
+ float driftShare = driftLightShare(vWorldPos);
816
862
 
817
863
  /*
818
864
  * Which froxel this fragment is in.
@@ -912,7 +958,7 @@ export const MAIN_GLSL = `void main() {
912
958
  cookie = uintBitsToFloat(rec4.w);
913
959
  } else {
914
960
  i = slot;
915
- if (i >= uLightCount) break;
961
+ if (i >= uLightCount || i >= MAX_LIGHTS) break;
916
962
  lightPos = uLightPos[i];
917
963
  lightRadius = uLightRadius[i];
918
964
  lightColor = uLightColor[i];
@@ -925,6 +971,9 @@ export const MAIN_GLSL = `void main() {
925
971
  iesAxis = uLightIesAxis[i];
926
972
  cookie = uLightCookie[i];
927
973
  }
974
+ /* A DriftLight field's own light carries a negative weight, and is shaded exactly only for the
975
+ share of this pixel the field does not stand in for. See \`driftLight.ts\`. */
976
+ if (lightWeight < 0.0) lightWeight = -lightWeight * (1.0 - driftShare);
928
977
  /*
929
978
  * **The cone, and a point light's is one that admits every direction.**
930
979
  *
@@ -987,6 +1036,8 @@ export const MAIN_GLSL = `void main() {
987
1036
  * grazing the angle is. Without it the cubemap's face seams show as a
988
1037
  * diagonal cross and the whole plane bands.
989
1038
  */
1039
+ /* How present this light's shadow is. See \`POINT_SHADOW_MIN_WEIGHT\`. */
1040
+ float shadowWeight = clamp(falloff * 2.0, 0.0, 1.0);
990
1041
  #if POINT_SHADOWS
991
1042
  float grazing = 1.0 - ndl;
992
1043
  float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;
@@ -1025,7 +1076,7 @@ export const MAIN_GLSL = `void main() {
1025
1076
  */
1026
1077
  int shadowRead = clamp(shadowSlot, 0, MAX_LIGHTS - 1);
1027
1078
  int layer = shadowSlot >= 0 && shadowSlot < MAX_LIGHTS ? uPointShadowLayer[shadowRead] : -1;
1028
- if (layer >= 0) {
1079
+ if (layer >= 0 && shadowWeight > POINT_SHADOW_MIN_WEIGHT) {
1029
1080
  occl = mix(
1030
1081
  1.0,
1031
1082
  pointShadow(
@@ -1054,7 +1105,7 @@ export const MAIN_GLSL = `void main() {
1054
1105
  float liveWeight = 0.0;
1055
1106
  int liveLayer =
1056
1107
  shadowSlot >= 0 && shadowSlot < MAX_LIGHTS ? uLivePointShadowLayer[shadowRead] : -1;
1057
- if (liveLayer >= 0) {
1108
+ if (liveLayer >= 0 && shadowWeight > POINT_SHADOW_MIN_WEIGHT) {
1058
1109
  liveOccl = pointShadow(
1059
1110
  uPointShadows,
1060
1111
  float(liveLayer),
@@ -1074,7 +1125,7 @@ export const MAIN_GLSL = `void main() {
1074
1125
  // Blend occlusion out as the light fades, so a shadow can never be more
1075
1126
  // present than the light casting it — and slot changes at the edge of
1076
1127
  // range become invisible rather than a pop.
1077
- float shaded = mix(1.0, occl, clamp(falloff * 2.0, 0.0, 1.0));
1128
+ float shaded = mix(1.0, occl, shadowWeight);
1078
1129
  /*
1079
1130
  * The cone multiplies the distance falloff rather than replacing it, which is what makes a
1080
1131
  * spot a point light with a direction rather than a second kind of light. It is exactly 1
@@ -1494,6 +1545,14 @@ export const MAIN_GLSL = `void main() {
1494
1545
  );
1495
1546
  #endif
1496
1547
  lit += lampOpenFloor * lampShade;
1548
+ /*
1549
+ * DriftLight: every fixed light past the frame's choice, summed and occluded, for the share of
1550
+ * this pixel the exact lights above no longer cover. Diffuse only, and so no metal term: see
1551
+ * \`driftLight.ts\`.
1552
+ */
1553
+ if (driftShare > 0.0) {
1554
+ lit += albedo * driftLightIrradiance(vWorldPos, n) * (driftShare * uDriftLight.w) * (1.0 - metal);
1555
+ }
1497
1556
 
1498
1557
  /*
1499
1558
  * Self-illuminated geometry, dimmed where a lamp's shadow crosses it.
@@ -38,6 +38,12 @@ import { FOG_GLSL } from '../fog.ts';
38
38
  export function preambleGlsl({ maxLights, maxAreaLights }: LightBudget): string {
39
39
  return `#version 300 es
40
40
  precision highp float;
41
+ /*
42
+ * highp int as well: a clustered light is decoded with uintBitsToFloat from texels that arrive as
43
+ * uvec4, and its texel index passes 32767 in a big world. The fragment default for int and uint is
44
+ * mediump, which a sixteen-bit driver truncates, and every clustered light then lights nothing.
45
+ */
46
+ precision highp int;
41
47
 
42
48
  in vec3 vNormal;
43
49
  in vec3 vColor;
@@ -140,6 +146,12 @@ uniform int uAlbedoEnabled;
140
146
  */
141
147
  uniform float uAlbedoCutout;
142
148
 
149
+ /**
150
+ * 1 for a surface seen from both faces: glTF's doubleSided, a curtain or a leaf card. Its draw culls
151
+ * nothing, and a back face is lit as a front, with the shading normal turned toward the viewer.
152
+ */
153
+ uniform int uDoubleSided;
154
+
143
155
  /**
144
156
  * A normal map, in the surface's own space.
145
157
  *
@@ -550,6 +562,12 @@ uniform vec3 uProbeGridCounts;
550
562
  * exactly as it did before any probe existed.
551
563
  */
552
564
  uniform float uProbeGridAmbient;
565
+ /**
566
+ * Which sets of layers the shading reads, and how far between them: the last whole sweep's layer
567
+ * offset, the newest whole sweep's, and the blend from the first toward the second. \`(0, 0, 0)\`
568
+ * for a grid that does not crossfade. See \`probeSweeps.ts\`.
569
+ */
570
+ uniform vec3 uProbeGridSets;
553
571
  #endif
554
572
  /** A world-space box that lights up regardless of time of day (run finished). */
555
573
  uniform vec3 uHighlightMin;
@@ -637,12 +655,12 @@ uvec4 clusterTexel(int texel) {
637
655
  * both numbers sit beside the budgets they come from.
638
656
  */
639
657
  /*
640
- * **Both arms have the same bound, and that equality is deliberate.** There is one loop, so a
641
- * larger clustered cap would raise the bound for the fixed path too, and a scene that never asks
642
- * for froxels would carry a loop of 28 where it carries 16 today. \`MAX_LIGHTS_PER_CLUSTER\` is
643
- * pinned equal to \`MAX_LIGHTS\` for that reason; a test in \`textureUnitBudget.test.ts\` asserts it.
658
+ * **The bound is the froxel's cap, and the fixed arm leaves long before it.** The two were equal
659
+ * until a candlelit interior needed more than sixteen lights in a froxel; see
660
+ * \`MAX_LIGHTS_PER_CLUSTER\`. The fixed arm breaks at \`uLightCount\` and at \`MAX_LIGHTS\`, so it
661
+ * iterates exactly what it did and never indexes its uniform arrays past their end.
644
662
  */
645
- #define LIGHT_LOOP_MAX MAX_LIGHTS
663
+ #define LIGHT_LOOP_MAX MAX_LIGHTS_PER_CLUSTER
646
664
  uniform int uLightCount;
647
665
  /** 0 keeps the radius-shaped falloff; 1 is physical inverse-square with a soft cutoff. */
648
666
  uniform int uLightFalloff;