@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
@@ -1,4 +1,5 @@
1
1
  import { resolveConditionals } from './conditionals.ts';
2
+ import { CUTOUT_COVERAGE_GLSL } from './cutoutCoverage.ts';
2
3
  import { SKINNING_GLSL } from './skinning.ts';
3
4
  import { CHANNEL_ATTRIBUTE, CHANNEL_BEND } from './vertexChannel.ts';
4
5
 
@@ -56,7 +57,22 @@ ${CHANNEL_ATTRIBUTE}
56
57
  ${CHANNEL_BEND}
57
58
  #endif
58
59
 
60
+ #if CUTOUT
61
+ /*
62
+ * **What a cutout caster adds, and where.** The UV at location 5, where every mesh carries it; two
63
+ * uniforms declared after every other, so no field of the plain variant moves and a binder written
64
+ * against it stays right for this one; and two outputs after vLightPosition, which keeps location 0.
65
+ */
66
+ layout(location = 5) in vec2 aUv;
67
+ uniform vec2 uUvScale;
68
+ uniform float uAlphaCutout;
69
+ #endif
70
+
59
71
  out vec4 vLightPosition;
72
+ #if CUTOUT
73
+ out vec2 vUv;
74
+ flat out float vAlphaCutout;
75
+ #endif
60
76
 
61
77
  void main() {
62
78
  #if SKINNED
@@ -80,20 +96,24 @@ void main() {
80
96
  #endif
81
97
  vLightPosition = uLightViewProj * vec4(bent, world.w);
82
98
  gl_Position = vLightPosition;
99
+ #if CUTOUT
100
+ vUv = aUv * uUvScale;
101
+ vAlphaCutout = uAlphaCutout;
102
+ #endif
83
103
  }
84
104
  `;
85
105
 
86
106
  /** The rigid variant: the world, a prop, a mesh with no rig behind it. */
87
107
  export const DEPTH_VERT = resolveConditionals(
88
108
  DEPTH_VERT_SOURCE,
89
- { SKINNED: false, INSTANCED: false },
109
+ { SKINNED: false, INSTANCED: false, CUTOUT: false },
90
110
  'depth',
91
111
  );
92
112
 
93
113
  /** The skinned variant, which reads a joint palette and moves the vertex by it. */
94
114
  export const DEPTH_SKINNED_VERT = resolveConditionals(
95
115
  DEPTH_VERT_SOURCE,
96
- { SKINNED: true, INSTANCED: false },
116
+ { SKINNED: true, INSTANCED: false, CUTOUT: false },
97
117
  'depth-skinned',
98
118
  );
99
119
 
@@ -107,10 +127,32 @@ export const DEPTH_SKINNED_VERT = resolveConditionals(
107
127
  */
108
128
  export const DEPTH_INSTANCED_VERT = resolveConditionals(
109
129
  DEPTH_VERT_SOURCE,
110
- { SKINNED: false, INSTANCED: true },
130
+ { SKINNED: false, INSTANCED: true, CUTOUT: false },
111
131
  'depth-instanced',
112
132
  );
113
133
 
134
+ /**
135
+ * The variants a cutout caster casts through: a leaf card, a chain link, a fence, whose shape is in
136
+ * its texture's alpha rather than in its geometry.
137
+ *
138
+ * **Variants, not a uniform gate on the plain program**, for this file's own reason: a gate would
139
+ * need the UV, a varying and a bound texture on every opaque caster, and a branch on a varying is
140
+ * not provably uniform, which forbids the implicit derivative the sample below wants. A variant
141
+ * leaves every opaque caster byte for byte as it was. What it costs is one more program each, and
142
+ * a skinned cutout, which nothing has asked for, is not among them.
143
+ */
144
+ export const DEPTH_CUTOUT_VERT = resolveConditionals(
145
+ DEPTH_VERT_SOURCE,
146
+ { SKINNED: false, INSTANCED: false, CUTOUT: true },
147
+ 'depth-cutout',
148
+ );
149
+
150
+ export const DEPTH_INSTANCED_CUTOUT_VERT = resolveConditionals(
151
+ DEPTH_VERT_SOURCE,
152
+ { SKINNED: false, INSTANCED: true, CUTOUT: true },
153
+ 'depth-instanced-cutout',
154
+ );
155
+
114
156
  export const DEPTH_FRAG = `#version 300 es
115
157
  precision highp float;
116
158
  in vec4 vLightPosition;
@@ -129,3 +171,38 @@ void main() {
129
171
  }
130
172
  }
131
173
  `;
174
+
175
+ /**
176
+ * The cutout caster's depth: the plain pass, and a texel of alpha under the cutoff writes nothing.
177
+ *
178
+ * **The sample is the first statement, in uniform control flow**, so its implicit derivative picks
179
+ * the mip the shadow map's texel footprint wants; inside a branch it would be undefined (AGENTS.md,
180
+ * 2026-08-07). The peel's own sample becomes an explicit-level fetch for the same reason, and is
181
+ * bit for bit what it was: that map has one level and a nearest sampler. The discard is last, so
182
+ * nothing samples after it.
183
+ *
184
+ * `uCutoutMap` is declared after `uPreviousShadowMap`, so the peel keeps its bindings and the cutout
185
+ * map takes the next.
186
+ */
187
+ export const DEPTH_CUTOUT_FRAG = `#version 300 es
188
+ precision highp float;
189
+ in vec4 vLightPosition;
190
+ in vec2 vUv;
191
+ flat in float vAlphaCutout;
192
+
193
+ uniform highp sampler2D uPreviousShadowMap;
194
+ uniform int uPeelShadowLayer;
195
+ uniform highp sampler2D uCutoutMap;
196
+ ${CUTOUT_COVERAGE_GLSL}
197
+ void main() {
198
+ /* Credited for its mip level as the surface's own test is, so a leaf casts the shape it draws. */
199
+ float alpha = cutoutAlpha(texture(uCutoutMap, vUv).a, vUv * vec2(textureSize(uCutoutMap, 0)));
200
+ if (uPeelShadowLayer != 0) {
201
+ vec3 p = vLightPosition.xyz / vLightPosition.w;
202
+ vec2 uv = p.xy * 0.5 + 0.5;
203
+ float previousDepth = textureLod(uPreviousShadowMap, uv, 0.0).r;
204
+ if (gl_FragCoord.z <= previousDepth + 0.00001) discard;
205
+ }
206
+ if (alpha < vAlphaCutout) discard;
207
+ }
208
+ `;
@@ -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
  *