@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,186 @@
1
+ import {
2
+ EXPOSURE_ADAPT_FRAG,
3
+ EXPOSURE_METER_FRAG,
4
+ METER_GRID,
5
+ adaptBlend,
6
+ } from './shaders/exposure.ts';
7
+ import { EXPOSURE_LOCAL_FRAG, LOCAL_GRID_WIDTH } from './shaders/localExposure.ts';
8
+ import { FULLSCREEN_VERT } from './shaders/fullscreen.ts';
9
+ import { compileProgram, uniformLocations } from './shader.ts';
10
+
11
+ /**
12
+ * Eye adaptation on WebGL2: meter the finished scene, and follow it with a held brightness the
13
+ * composite reads. See `shaders/exposure.ts` for the design; this owns the targets and the draws.
14
+ *
15
+ * **Two one-texel targets that swap roles each frame**, because the adaptation reads what it held
16
+ * and a texture cannot be read and written in one draw. The composite is handed whichever was
17
+ * written last. `R16F`, because the held value is in stops and eight bits of a range twenty stops
18
+ * wide is an exposure that steps visibly as it moves.
19
+ *
20
+ * **And local exposure's grid, when that is asked for**: `RG16F`, a sum and a count per tile per
21
+ * band, filtered linearly because the composite reads it between tiles. See `localExposure.ts`.
22
+ */
23
+ export class ExposurePass {
24
+ private meter: WebGLTexture | null = null;
25
+ private meterFramebuffer: WebGLFramebuffer | null = null;
26
+ private localGrid: WebGLTexture | null = null;
27
+ private localFramebuffer: WebGLFramebuffer | null = null;
28
+ private readonly held: (WebGLTexture | null)[] = [null, null];
29
+ private readonly heldFramebuffers: (WebGLFramebuffer | null)[] = [null, null];
30
+ /** Which of the two held texels the next adaptation writes. */
31
+ private writing = 0;
32
+ /** Whether anything is held yet: the first frame, and the first after a cut, snaps. */
33
+ private holding = false;
34
+ private allocated = false;
35
+ /** Set once if a half-float target will not complete; the pass is then off and says so once. */
36
+ private unavailable = false;
37
+
38
+ private readonly meterProgram: WebGLProgram;
39
+ private readonly meterUniforms: Record<string, WebGLUniformLocation | null>;
40
+ private readonly adaptProgram: WebGLProgram;
41
+ private readonly adaptUniforms: Record<string, WebGLUniformLocation | null>;
42
+ private readonly localProgram: WebGLProgram;
43
+ private readonly localUniforms: Record<string, WebGLUniformLocation | null>;
44
+ private readonly vao: WebGLVertexArrayObject;
45
+
46
+ constructor(private readonly gl: WebGL2RenderingContext) {
47
+ this.meterProgram = compileProgram(gl, FULLSCREEN_VERT, EXPOSURE_METER_FRAG, 'exposureMeter');
48
+ this.meterUniforms = uniformLocations(gl, this.meterProgram, 'exposureMeter');
49
+ this.adaptProgram = compileProgram(gl, FULLSCREEN_VERT, EXPOSURE_ADAPT_FRAG, 'exposureAdapt');
50
+ this.adaptUniforms = uniformLocations(gl, this.adaptProgram, 'exposureAdapt');
51
+ this.localProgram = compileProgram(gl, FULLSCREEN_VERT, EXPOSURE_LOCAL_FRAG, 'exposureLocal');
52
+ this.localUniforms = uniformLocations(gl, this.localProgram, 'exposureLocal');
53
+ const vao = gl.createVertexArray();
54
+ if (vao === null) throw new Error('ExposurePass: createVertexArray failed');
55
+ this.vao = vao;
56
+ }
57
+
58
+ /** Forget what is held, so the next frame meters afresh rather than easing from another shot. */
59
+ cut(): void {
60
+ this.holding = false;
61
+ }
62
+
63
+ private ensure(): boolean {
64
+ if (this.unavailable) return false;
65
+ if (this.allocated) return true;
66
+ const { gl } = this;
67
+ const target = (
68
+ size: number,
69
+ height = size,
70
+ pair = false,
71
+ ): { texture: WebGLTexture | null; framebuffer: WebGLFramebuffer | null; ok: boolean } => {
72
+ const texture = gl.createTexture();
73
+ gl.bindTexture(gl.TEXTURE_2D, texture);
74
+ if (pair)
75
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG16F, size, height, 0, gl.RG, gl.HALF_FLOAT, null);
76
+ else gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, size, height, 0, gl.RED, gl.HALF_FLOAT, null);
77
+ const filter = pair ? gl.LINEAR : gl.NEAREST;
78
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
79
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
80
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
81
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
82
+ const framebuffer = gl.createFramebuffer();
83
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
84
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
85
+ const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
86
+ return { texture, framebuffer, ok };
87
+ };
88
+ const meter = target(METER_GRID);
89
+ const first = target(1);
90
+ const second = target(1);
91
+ const local = target(LOCAL_GRID_WIDTH, METER_GRID, true);
92
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
93
+ gl.bindTexture(gl.TEXTURE_2D, null);
94
+ this.meter = meter.texture;
95
+ this.meterFramebuffer = meter.framebuffer;
96
+ this.held[0] = first.texture;
97
+ this.held[1] = second.texture;
98
+ this.heldFramebuffers[0] = first.framebuffer;
99
+ this.heldFramebuffers[1] = second.framebuffer;
100
+ this.localGrid = local.texture;
101
+ this.localFramebuffer = local.framebuffer;
102
+ this.allocated = true;
103
+ if (!meter.ok || !first.ok || !second.ok || !local.ok) {
104
+ this.unavailable = true;
105
+ console.warn(
106
+ 'ExposurePass: this driver will not render to a half-float target, so eye adaptation is ' +
107
+ 'off and the exposure is the one set. The frame itself is unaffected.',
108
+ );
109
+ return false;
110
+ }
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Meter `scene` and move the held brightness toward it by `dtSec` of adaptation, and with `local`
116
+ * draw local exposure's grid too. Returns the texel the composite reads, or null where this pass
117
+ * could not run, which the caller reads as off.
118
+ */
119
+ run(scene: WebGLTexture, dtSec: number, local = false): WebGLTexture | null {
120
+ if (!this.ensure()) return null;
121
+ const { gl } = this;
122
+ gl.disable(gl.DEPTH_TEST);
123
+ gl.disable(gl.BLEND);
124
+ gl.disable(gl.CULL_FACE);
125
+ gl.depthMask(false);
126
+ gl.bindVertexArray(this.vao);
127
+
128
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.meterFramebuffer);
129
+ gl.viewport(0, 0, METER_GRID, METER_GRID);
130
+ gl.useProgram(this.meterProgram);
131
+ gl.activeTexture(gl.TEXTURE0);
132
+ gl.bindTexture(gl.TEXTURE_2D, scene);
133
+ gl.uniform1i(this.meterUniforms['uScene'] ?? null, 0);
134
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
135
+
136
+ if (local) {
137
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.localFramebuffer);
138
+ gl.viewport(0, 0, LOCAL_GRID_WIDTH, METER_GRID);
139
+ gl.useProgram(this.localProgram);
140
+ gl.uniform1i(this.localUniforms['uScene'] ?? null, 0);
141
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
142
+ }
143
+
144
+ const into = this.writing;
145
+ const from = 1 - into;
146
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.heldFramebuffers[into] ?? null);
147
+ gl.viewport(0, 0, 1, 1);
148
+ gl.useProgram(this.adaptProgram);
149
+ gl.activeTexture(gl.TEXTURE0);
150
+ gl.bindTexture(gl.TEXTURE_2D, this.meter);
151
+ gl.uniform1i(this.adaptUniforms['uMeter'] ?? null, 0);
152
+ gl.activeTexture(gl.TEXTURE1);
153
+ gl.bindTexture(gl.TEXTURE_2D, this.held[from] ?? null);
154
+ gl.uniform1i(this.adaptUniforms['uHeld'] ?? null, 1);
155
+ gl.uniform1f(this.adaptUniforms['uBlend'] ?? null, adaptBlend(dtSec, !this.holding));
156
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
157
+
158
+ this.holding = true;
159
+ this.writing = from;
160
+ gl.bindVertexArray(null);
161
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
162
+ return this.held[into] ?? null;
163
+ }
164
+
165
+ /** Local exposure's grid as the last `run` drew it, or null before one has. */
166
+ get localTexture(): WebGLTexture | null {
167
+ return this.localGrid;
168
+ }
169
+
170
+ dispose(): void {
171
+ const { gl } = this;
172
+ for (const framebuffer of [
173
+ this.meterFramebuffer,
174
+ this.localFramebuffer,
175
+ ...this.heldFramebuffers,
176
+ ]) {
177
+ if (framebuffer !== null) gl.deleteFramebuffer(framebuffer);
178
+ }
179
+ for (const texture of [this.meter, this.localGrid, ...this.held])
180
+ if (texture !== null) gl.deleteTexture(texture);
181
+ gl.deleteProgram(this.meterProgram);
182
+ gl.deleteProgram(this.localProgram);
183
+ gl.deleteProgram(this.adaptProgram);
184
+ gl.deleteVertexArray(this.vao);
185
+ }
186
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * A flame: one point light and one plume from a single description, flickering at the rate a flame
3
+ * of that size flickers.
4
+ *
5
+ * **The rate is physics, not taste.** A buoyant flame puffs at about 1.5 / √D times a second for a
6
+ * burning area D metres across (Cetegen and Ahmed, 1993). That is 15 Hz for a candle's centimetre,
7
+ * which is why a candle trembles, and 2.4 Hz for a 40 cm brazier, which is why a fire breathes.
8
+ * Before this, a scene wired its plume, its light and a flicker amplitude by hand. The selection's
9
+ * shared wobble ran every light at the same two rates, so a candle and a bonfire pulsed together.
10
+ *
11
+ * **What the description fixes.** The flame stands 2.5 D tall, which is a candle's 2.5 cm and a
12
+ * brazier's metre. The light sits a third of the way up it, where a flame is brightest, and its
13
+ * physical radius is D / 2, so its shadows soften as a fire's do. It sways by up to 6 per cent of
14
+ * the flame's height on each axis, a few centimetres for a brazier, so a shadow it casts travels
15
+ * rather than only dimming.
16
+ *
17
+ * **What it gives up.** The waveform is two sines, the pulse and a harmonic, which never gust. A
18
+ * fire in wind wants its sway driven by the scene's `WindField`, and nothing here reads one. What
19
+ * would make the rate wrong is a flame far outside the law's range: a jet, or a pool fire of
20
+ * several metres, which puffs slower than the formula says.
21
+ *
22
+ * Plain data and two functions. `updateFlame` writes into the light it made and allocates nothing,
23
+ * so a scene updates every flame in its frame and hands the lights to `selectPointLights`.
24
+ */
25
+ import type { Vec3 } from '../math/color.ts';
26
+ import type { PlumePlacement } from './plumeRenderer.ts';
27
+ import type { PointLightSource } from './pointLightSelection.ts';
28
+
29
+ export interface FlameOptions {
30
+ /** Where the fire burns from, world space: the wick's tip, the top of the coals. */
31
+ readonly x: number;
32
+ readonly y: number;
33
+ readonly z: number;
34
+ /** How wide the burning area is, metres: a candle 0.01, a lantern 0.03, a brazier's bowl 0.4. */
35
+ readonly diameterM: number;
36
+ /** The light's colour at its mean brightness, in the caller's light units. */
37
+ readonly color: Vec3;
38
+ /** Metres at which the light ends. */
39
+ readonly radius: number;
40
+ /** How far the brightness swings either side of its mean, 0 to 1. Defaults to 0.18. */
41
+ readonly flicker?: number;
42
+ /** Keeps two flames of one size out of step. Defaults to 0. */
43
+ readonly seed?: number;
44
+ /** Whether the light may take a shadow slot. Defaults to true, as `PointLightSource` does. */
45
+ readonly castsShadow?: boolean;
46
+ /**
47
+ * How far the light wanders on each axis, as a share of the flame's height. Defaults to 0.06.
48
+ *
49
+ * 0 holds it still. A point light's static shadow map is baked from where the light stood, so a
50
+ * light that wanders reads that map from a few centimetres off. What would make the default wrong
51
+ * is a flame near a surface it shadows, where that offset shows as a band.
52
+ */
53
+ readonly sway?: number;
54
+ }
55
+
56
+ export interface Flame {
57
+ /** Rewritten by `updateFlame`. Offer it to `selectPointLights` with the scene's other lights. */
58
+ readonly light: PointLightSource;
59
+ /** Where the plume stands and how big it is, for `createPlumes`. */
60
+ readonly plume: PlumePlacement;
61
+ /** Pulses a second: 1.5 / √D. */
62
+ readonly frequencyHz: number;
63
+ readonly centre: Vec3;
64
+ readonly color: Vec3;
65
+ readonly flicker: number;
66
+ readonly phase: number;
67
+ readonly swayM: number;
68
+ }
69
+
70
+ /** How tall a flame stands, as a multiple of the width it burns from. */
71
+ const HEIGHT_PER_DIAMETER = 2.5;
72
+ /** How far the light sways on each axis, as a share of the flame's height. */
73
+ const SWAY_PER_HEIGHT = 0.06;
74
+ /** The harmonic's rate against the pulse's, chosen irrational so the two never lock. */
75
+ const HARMONIC = 2.37;
76
+
77
+ /** The puffing rate of a flame burning from `diameterM` metres across. */
78
+ export function flameFrequencyHz(diameterM: number): number {
79
+ return 1.5 / Math.sqrt(Math.max(diameterM, 1e-3));
80
+ }
81
+
82
+ export function createFlame(options: FlameOptions): Flame {
83
+ const height = HEIGHT_PER_DIAMETER * options.diameterM;
84
+ const centre: Vec3 = [options.x, options.y + height / 3, options.z];
85
+ /* A seed spread over the whole turn, so seeds 1 and 2 are not neighbours in phase. */
86
+ const phase = ((options.seed ?? 0) * 2.399963) % (Math.PI * 2);
87
+ const light: PointLightSource = {
88
+ x: centre[0],
89
+ y: centre[1],
90
+ z: centre[2],
91
+ r: options.color[0],
92
+ g: options.color[1],
93
+ b: options.color[2],
94
+ radius: options.radius,
95
+ /* Off, because this helper is the flicker; the selection's own would apply a second one. */
96
+ flicker: 0,
97
+ shadowNear: Math.max(0.05, options.diameterM),
98
+ sourceRadius: options.diameterM / 2,
99
+ ...(options.castsShadow === false ? { castsShadow: false } : {}),
100
+ };
101
+ return {
102
+ light,
103
+ plume: {
104
+ x: options.x,
105
+ y: options.y,
106
+ z: options.z,
107
+ width: options.diameterM / 2,
108
+ height,
109
+ },
110
+ frequencyHz: flameFrequencyHz(options.diameterM),
111
+ centre,
112
+ color: [options.color[0], options.color[1], options.color[2]],
113
+ flicker: options.flicker ?? 0.18,
114
+ phase,
115
+ swayM: (options.sway ?? SWAY_PER_HEIGHT) * height,
116
+ };
117
+ }
118
+
119
+ /** Where the flame's light is and how bright, at `timeSeconds`. Writes `flame.light` in place. */
120
+ export function updateFlame(flame: Flame, timeSeconds: number): void {
121
+ const w = 2 * Math.PI * flame.frequencyHz * timeSeconds;
122
+ const p = flame.phase;
123
+ const pulse = 0.65 * Math.sin(w + p) + 0.35 * Math.sin(HARMONIC * w + 1.9 * p);
124
+ const scale = 1 + flame.flicker * pulse;
125
+ const light = flame.light;
126
+ light.r = flame.color[0] * scale;
127
+ light.g = flame.color[1] * scale;
128
+ light.b = flame.color[2] * scale;
129
+ /* The sway runs slower than the pulse and on its own rates, so the light wanders rather than
130
+ tracing one loop. Up when the flame puffs up. */
131
+ const sway = flame.swayM;
132
+ light.x = flame.centre[0] + sway * Math.sin(0.83 * w + 2.1 * p);
133
+ light.y = flame.centre[1] + sway * Math.sin(w + p);
134
+ light.z = flame.centre[2] + sway * Math.cos(0.71 * w + 3.3 * p);
135
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The frame's exact lights as DriftRay's probe bake reads them: what a surface a probe's ray
3
+ * strikes is lit by, besides the sun, the bounce already in the grid and a DriftLight field.
4
+ *
5
+ * **Why the trace needs them at all.** A probe ray shades what it hits itself rather than reading
6
+ * a capture, which is what lets the bounce follow a light that moves; but it shaded with the sun
7
+ * alone, so a courtyard at night bounced no firelight into the corners the fires could not see. A
8
+ * field carries the many fixed lights; these are the rest, the lights the frame shades one by one,
9
+ * flickering and moving as they do in the frame.
10
+ *
11
+ * **Decided here and bound there**, by the 2026-08-13 rule: which lights, in what order and at what
12
+ * weight is one decision for any backend that traces. A light a field sums carries a negative
13
+ * weight and is left out, because the field already brings it to the bounce; a fading light goes in
14
+ * at its weight, as the lit shader scales it.
15
+ *
16
+ * **At most `MAX_BOUNCE_LIGHTS`, the first the selection chose**, which is nearest the camera: each
17
+ * one is a march per ray that reaches it, and a probe far from the camera is lit by lights further
18
+ * down the list that this leaves out. What would change that is a scene whose bounce is dominated
19
+ * by lights far from where it is looked at, which would want the selection run again from the grid.
20
+ *
21
+ * What it gives up against the frame: a photometric profile and a cookie, which the bounce reads as
22
+ * a plain cone, and a light's specular, which a diffuse bounce has no use for.
23
+ */
24
+
25
+ import type { PointLightSet } from '../lightBudget.ts';
26
+ import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from '../clusteredLights.ts';
27
+
28
+ /** Lights the probe bake shades a hit with. */
29
+ export const MAX_BOUNCE_LIGHTS = 32;
30
+ /**
31
+ * Floats a light takes: where it is and how far it reaches; its colour at its weight and the cosine
32
+ * of its inner cone; the way it points and the cosine of its outer cone.
33
+ */
34
+ export const BOUNCE_LIGHT_FLOATS = 12;
35
+
36
+ export function createBounceLights(): Float32Array {
37
+ return new Float32Array(MAX_BOUNCE_LIGHTS * BOUNCE_LIGHT_FLOATS);
38
+ }
39
+
40
+ /** Pack the lights of `lights` the bounce reads into `out`, and answer how many. */
41
+ export function resolveBounceLights(lights: PointLightSet | null, out: Float32Array): number {
42
+ if (lights === null) return 0;
43
+ let count = 0;
44
+ for (let i = 0; i < lights.lightCount && count < MAX_BOUNCE_LIGHTS; i++) {
45
+ const weight = lights.lightWeights[i] ?? 0;
46
+ const radius = lights.lightRadii[i] ?? 0;
47
+ if (!(weight > 0) || !(radius > 0)) continue;
48
+ const r = lights.lightColors[i * 3] ?? 0;
49
+ const g = lights.lightColors[i * 3 + 1] ?? 0;
50
+ const b = lights.lightColors[i * 3 + 2] ?? 0;
51
+ if (!(r + g + b > 0)) continue;
52
+ const at = count * BOUNCE_LIGHT_FLOATS;
53
+ out[at] = lights.lightPositions[i * 3] ?? 0;
54
+ out[at + 1] = lights.lightPositions[i * 3 + 1] ?? 0;
55
+ out[at + 2] = lights.lightPositions[i * 3 + 2] ?? 0;
56
+ out[at + 3] = radius;
57
+ out[at + 4] = r * weight;
58
+ out[at + 5] = g * weight;
59
+ out[at + 6] = b * weight;
60
+ out[at + 7] = lights.lightConeCos?.[i * 2] ?? POINT_LIGHT_COS_INNER;
61
+ out[at + 8] = lights.lightDirections?.[i * 3] ?? 0;
62
+ out[at + 9] = lights.lightDirections?.[i * 3 + 1] ?? 0;
63
+ out[at + 10] = lights.lightDirections?.[i * 3 + 2] ?? 0;
64
+ out[at + 11] = lights.lightConeCos?.[i * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
65
+ count++;
66
+ }
67
+ return count;
68
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Whether the traced field reaches the probes it lights: how many cascades a grid needs, and which
3
+ * scheduled probes the field reaches this frame.
4
+ *
5
+ * **A probe traced where the field is not learns that the world is sky.** Every ray it casts finds
6
+ * nothing and falls to the sky colour, the floor under it and the wall beside it included, so it
7
+ * reports the open sky from every side: a covered arcade lit like a field at noon. The field is
8
+ * composed around the camera, and three cascades from a 4 m inner one reach 16 m, so a courtyard 36
9
+ * m long had its far end lit exactly that way from wherever the viewer stood, a milky wash over
10
+ * everything past the outermost cascade that the rasterised grid did not have.
11
+ *
12
+ * Two answers, one for each way it happens. A grid is given the cascades that reach all of it from
13
+ * anywhere inside it; and a probe the field still does not reach, because the camera has left the
14
+ * grid, is left out of the schedule and keeps the light it has, rather than being overwritten with
15
+ * a sky it never saw. What it gives up is that probe following a change while the camera is away.
16
+ */
17
+ import { GLOBAL_FIELD_BLEND } from './globalField.ts';
18
+ import type { ProbeGrid } from '../probeGrid.ts';
19
+
20
+ /** The share of the outermost cascade's half-extent a probe can trust, inside its fading faces. */
21
+ const TRUSTED = 1 - 2 * GLOBAL_FIELD_BLEND;
22
+
23
+ /**
24
+ * Cascades enough for the outermost to hold the whole grid, and one probe step past it, within the
25
+ * part of it that is trusted, wherever inside the grid the camera stands. At least `least` and at
26
+ * most `most`: each cascade is the same number of samples, so the bound is on memory, not reach.
27
+ */
28
+ export function cascadesForGrid(
29
+ grid: ProbeGrid,
30
+ radius: number,
31
+ least: number,
32
+ most: number,
33
+ ): number {
34
+ let span = 0;
35
+ let step = 0;
36
+ for (let axis = 0; axis < 3; axis++) {
37
+ const spacing = grid.spacing[axis] as number;
38
+ span = Math.max(span, spacing * ((grid.counts[axis] as number) - 1));
39
+ step = Math.max(step, spacing);
40
+ }
41
+ const needed = (span + step) / TRUSTED;
42
+ let cascades = 1;
43
+ while (radius * 2 ** (cascades - 1) < needed && cascades < most) cascades++;
44
+ return Math.min(most, Math.max(least, cascades));
45
+ }
46
+
47
+ const at: [number, number, number] = [0, 0, 0];
48
+
49
+ /**
50
+ * Keeps, in place and in order, the scheduled probes inside the trusted part of the outermost
51
+ * cascade, `bounds` as `[minX, minY, minZ, maxX, maxY, maxZ]`. Returns how many are kept.
52
+ */
53
+ export function probesTheFieldReaches(
54
+ grid: ProbeGrid,
55
+ scheduled: Int32Array,
56
+ count: number,
57
+ bounds: ArrayLike<number>,
58
+ ): number {
59
+ let kept = 0;
60
+ for (let i = 0; i < count; i++) {
61
+ const layer = scheduled[i] as number;
62
+ grid.positionOf(layer, at);
63
+ let inside = true;
64
+ for (let axis = 0; axis < 3; axis++) {
65
+ const low = bounds[axis] as number;
66
+ const high = bounds[axis + 3] as number;
67
+ const band = GLOBAL_FIELD_BLEND * (high - low);
68
+ const p = at[axis] as number;
69
+ if (p < low + band || p > high - band) inside = false;
70
+ }
71
+ if (inside) scheduled[kept++] = layer;
72
+ }
73
+ return kept;
74
+ }
@@ -308,6 +308,45 @@ function insideBy(cascade: GlobalFieldCascade, x: number, y: number, z: number):
308
308
  return least;
309
309
  }
310
310
 
311
+ /**
312
+ * The world distance to the nearest of `instances` at any point, rather than composed onto a grid
313
+ * around the camera: for a caller that traces through the scene once instead of every frame, such
314
+ * as DriftLight's occlusion bake. The same sampling `composeGlobalField` does, under the same
315
+ * refusals of a non-uniform scale and non-cubic voxels, so the two cannot disagree about a surface.
316
+ * Nothing placed answers a very large distance: open space everywhere.
317
+ */
318
+ export function distanceToInstances(
319
+ instances: readonly GlobalFieldInstance[],
320
+ ): (x: number, y: number, z: number) => number {
321
+ const inverses: Float32Array[] = [];
322
+ const scales: number[] = [];
323
+ const kept: GlobalFieldInstance[] = [];
324
+ for (const instance of instances) {
325
+ assertUniformScale(instance.transform);
326
+ assertCubicVoxels(instance.source);
327
+ const inverse = new Float32Array(16);
328
+ if (mat4.invert(inverse, instance.transform) === null) continue;
329
+ inverses.push(inverse);
330
+ scales.push(uniformScaleOf(instance.transform));
331
+ kept.push(instance);
332
+ }
333
+ return (x, y, z) => {
334
+ let nearest = 1e9;
335
+ for (let at = 0; at < kept.length; at++) {
336
+ const distance = sampleInstance(
337
+ kept[at] as GlobalFieldInstance,
338
+ inverses[at] as Float32Array,
339
+ scales[at] as number,
340
+ x,
341
+ y,
342
+ z,
343
+ );
344
+ if (distance < nearest) nearest = distance;
345
+ }
346
+ return nearest;
347
+ };
348
+ }
349
+
311
350
  /**
312
351
  * The distance one instance reports at a world point.
313
352
  *
@@ -62,8 +62,10 @@ export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
62
62
  /**
63
63
  * How far the set turns between one refresh and the next, in turns.
64
64
  *
65
- * The golden ratio's fractional part: successive frames land their offsets as far from each other
65
+ * The golden ratio's fractional part: successive turns land their offsets as far from each other
66
66
  * as the sequence allows, for the same reason the spiral's own points do, and it never repeats.
67
+ * The WebGPU bake holds the turn at 0, because a set that turns makes every refresh a new estimate
68
+ * and a still scene flicker; see `ProbeBaker.bake`.
67
69
  */
68
70
  export const FRAME_TURN = 0.618033988749895;
69
71
 
@@ -194,11 +194,24 @@ export async function describeGpu(options: DescribeGpuOptions = {}): Promise<Gpu
194
194
  source,
195
195
  });
196
196
 
197
- /* WebGL2 first: it is the API that gives a model number. See the header for the measurement. */
198
- const canvas =
199
- options.createCanvas === undefined
200
- ? (globalThis.document?.createElement('canvas') ?? null)
201
- : options.createCanvas();
197
+ /*
198
+ * WebGL2 first: it is the API that gives a model number. See the header for the measurement.
199
+ *
200
+ * **The canvas is made inside the guard as well as the context.** A host can have a `document`
201
+ * and still refuse to make one: the native host installs a page so a game finds what it expects,
202
+ * and its `createElement` throws, because there is no DOM behind it. Made outside the guard, that
203
+ * took a scene's mount down on the second host; inside it, such a host is simply one without
204
+ * WebGL2, and the adapter below names the part.
205
+ */
206
+ let canvas: HTMLCanvasElement | null = null;
207
+ try {
208
+ canvas =
209
+ options.createCanvas === undefined
210
+ ? (globalThis.document?.createElement('canvas') ?? null)
211
+ : options.createCanvas();
212
+ } catch {
213
+ canvas = null;
214
+ }
202
215
  if (canvas !== null) {
203
216
  let gl: WebGL2RenderingContext | null = null;
204
217
  try {
@@ -218,6 +218,18 @@ export const COOKIE_ATLAS_TEXTURE_UNIT = MORPH_DELTA_TEXTURE_UNIT + 1;
218
218
  */
219
219
  export const REFRACT_SCENE_TEXTURE_UNIT = COOKIE_ATLAS_TEXTURE_UNIT + 1;
220
220
 
221
+ /**
222
+ * DriftLight's brick index and atlas, the lit program's fourteenth and fifteenth fragment samplers.
223
+ *
224
+ * Units past fifteen are legal: `MAX_TEXTURE_IMAGE_UNITS` caps how many samplers a stage *uses*,
225
+ * sixteen at the least, while a unit's number only has to be under the combined limit, which WebGL2
226
+ * guarantees is at least thirty-two. The skin palette and morph deltas below them are vertex
227
+ * textures and count against the vertex stage instead. What would make this wrong is a sixteenth
228
+ * fragment sampler, which the lit program would then be one short of on the guaranteed minimum.
229
+ */
230
+ export const DRIFT_LIGHT_INDEX_TEXTURE_UNIT = REFRACT_SCENE_TEXTURE_UNIT + 1;
231
+ export const DRIFT_LIGHT_ATLAS_TEXTURE_UNIT = DRIFT_LIGHT_INDEX_TEXTURE_UNIT + 1;
232
+
221
233
  /**
222
234
  * A cookie's tile, in texels a side.
223
235
  *