@driftengine/core 4.2.1 → 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 +272 -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 +166 -1
  14. package/dist/render/backend/webgl2/renderer.js +424 -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 +176 -11
  32. package/dist/render/backend/webgpu/renderer.js +680 -250
  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 +462 -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 +807 -271
  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,177 @@
1
+ /**
2
+ * Where DriftLight's volume exists: the bricks some light reaches, the grid that finds them, and
3
+ * which lights reach each one.
4
+ *
5
+ * **Sparse, because light is.** A candle reaches a metre and a courtyard is forty, so a dense grid
6
+ * would be almost entirely zeros: a brick is kept only where a light's reach touches it, by the
7
+ * exact sphere-against-box test rather than the box-against-box one, which takes the corner bricks a
8
+ * sphere's box reaches and the sphere does not.
9
+ *
10
+ * **A brick is four samples a side and shares its faces with its neighbours**, so it spans three
11
+ * spacings and two bricks that meet agree on the samples between them. That is what lets the shader
12
+ * filter inside one brick with the hardware's trilinear lookup and never see a seam where it crosses
13
+ * into the next.
14
+ *
15
+ * **Numbered in the index's own order**, x fastest, so a layout depends on where the lights are and
16
+ * not on the order a scene listed them in: two loads of one scene produce one atlas.
17
+ *
18
+ * What it gives up: the index is dense over the lights' bounding box, so lights spread thinly over a
19
+ * kilometre would make a large, mostly empty grid. It refuses one past `MAX_INDEX_AXIS` bricks on
20
+ * any axis and says to make one field per region instead; what would change that is a hashed
21
+ * index, which costs the shader a probe loop.
22
+ */
23
+
24
+ /** Samples on each axis of a brick. The first and last are shared with the neighbour. */
25
+ export const BRICK_SAMPLES = 4;
26
+
27
+ /**
28
+ * Bricks on each axis of the index, which is a 3D texture: 256 is the least `MAX_3D_TEXTURE_SIZE`
29
+ * WebGL2 promises, and a limit on the cell count alone would pass a grid 300 bricks long and one
30
+ * wide that a device is free to refuse. At a metre a brick, a field 256 m on a side.
31
+ */
32
+ export const MAX_INDEX_AXIS = 256;
33
+
34
+ /** What the layout reads of a light: where it is and how far it reaches. */
35
+ export interface LightReach {
36
+ readonly x: number;
37
+ readonly y: number;
38
+ readonly z: number;
39
+ readonly radius: number;
40
+ }
41
+
42
+ export interface LightFieldLayout {
43
+ /** Metres between two samples. */
44
+ readonly spacing: number;
45
+ /** Metres a brick spans: three spacings. */
46
+ readonly span: number;
47
+ /** The world position of brick (0, 0, 0)'s first sample. */
48
+ readonly origin: readonly [number, number, number];
49
+ /** Bricks on each axis of the index. */
50
+ readonly dims: readonly [number, number, number];
51
+ /** Per index cell, x fastest: the brick's number plus one, or 0 where there is no brick. */
52
+ readonly index: Uint32Array;
53
+ /** How many bricks there are. */
54
+ readonly count: number;
55
+ /** Each brick's cell in the index, three integers a brick. */
56
+ readonly brickCoords: Int32Array;
57
+ /** Where brick `b`'s lights start in `lights`; `lightStart[b + 1]` is where they end. */
58
+ readonly lightStart: Uint32Array;
59
+ /** The lights reaching each brick, in the order the scene listed them. */
60
+ readonly lights: Uint32Array;
61
+ }
62
+
63
+ const EMPTY: LightFieldLayout = {
64
+ spacing: 0,
65
+ span: 0,
66
+ origin: [0, 0, 0],
67
+ dims: [0, 0, 0],
68
+ index: new Uint32Array(0),
69
+ count: 0,
70
+ brickCoords: new Int32Array(0),
71
+ lightStart: new Uint32Array(1),
72
+ lights: new Uint32Array(0),
73
+ };
74
+
75
+ /** The bricks `lights` reach at `spacing` metres between samples. Built once; not a frame path. */
76
+ export function layoutLightField(lights: readonly LightReach[], spacing: number): LightFieldLayout {
77
+ if (!(spacing > 0)) {
78
+ throw new Error(`layoutLightField: spacing is ${String(spacing)} and must be positive.`);
79
+ }
80
+ const span = spacing * (BRICK_SAMPLES - 1);
81
+ const min = [Infinity, Infinity, Infinity];
82
+ const max = [-Infinity, -Infinity, -Infinity];
83
+ let reaching = 0;
84
+ for (const light of lights) {
85
+ if (!(light.radius > 0)) continue;
86
+ reaching += 1;
87
+ const at = [light.x, light.y, light.z];
88
+ for (let axis = 0; axis < 3; axis++) {
89
+ min[axis] = Math.min(min[axis] as number, (at[axis] as number) - light.radius);
90
+ max[axis] = Math.max(max[axis] as number, (at[axis] as number) + light.radius);
91
+ }
92
+ }
93
+ if (reaching === 0) return EMPTY;
94
+
95
+ const origin = min.map((value) => Math.floor(value / span) * span) as [number, number, number];
96
+ const dims = max.map(
97
+ (value, axis) => Math.floor((value - (origin[axis] as number)) / span) + 1,
98
+ ) as [number, number, number];
99
+ if (dims.some((across) => across > MAX_INDEX_AXIS)) {
100
+ throw new Error(
101
+ `layoutLightField: these lights span ${dims.join(' x ')} bricks against a limit of ` +
102
+ `${MAX_INDEX_AXIS} on each axis. Give each region of the world a field of its own, or a ` +
103
+ 'coarser spacing.',
104
+ );
105
+ }
106
+ const cells = dims[0] * dims[1] * dims[2];
107
+
108
+ /* Which cells a light reaches, visited twice: once to mark and count, once to list. */
109
+ const visit = (each: (light: number, cell: number) => void): void => {
110
+ lights.forEach((light, number) => {
111
+ if (!(light.radius > 0)) return;
112
+ const at = [light.x, light.y, light.z];
113
+ const low = [0, 0, 0];
114
+ const high = [0, 0, 0];
115
+ for (let axis = 0; axis < 3; axis++) {
116
+ const o = origin[axis] as number;
117
+ const p = at[axis] as number;
118
+ low[axis] = Math.max(0, Math.floor((p - light.radius - o) / span));
119
+ high[axis] = Math.min(
120
+ (dims[axis] as number) - 1,
121
+ Math.floor((p + light.radius - o) / span),
122
+ );
123
+ }
124
+ const reach2 = light.radius * light.radius;
125
+ for (let k = low[2] as number; k <= (high[2] as number); k++) {
126
+ for (let j = low[1] as number; j <= (high[1] as number); j++) {
127
+ for (let i = low[0] as number; i <= (high[0] as number); i++) {
128
+ const cell = [i, j, k];
129
+ let d2 = 0;
130
+ for (let axis = 0; axis < 3; axis++) {
131
+ const lo = (origin[axis] as number) + (cell[axis] as number) * span;
132
+ const p = at[axis] as number;
133
+ const out = Math.max(lo - p, 0, p - (lo + span));
134
+ d2 += out * out;
135
+ }
136
+ if (d2 <= reach2) each(number, i + dims[0] * (j + dims[1] * k));
137
+ }
138
+ }
139
+ }
140
+ });
141
+ };
142
+
143
+ const index = new Uint32Array(cells);
144
+ let pairs = 0;
145
+ visit((_, cell) => {
146
+ index[cell] = 1;
147
+ pairs += 1;
148
+ });
149
+ let count = 0;
150
+ for (let cell = 0; cell < cells; cell++) if (index[cell] !== 0) index[cell] = ++count;
151
+
152
+ const brickCoords = new Int32Array(count * 3);
153
+ for (let cell = 0; cell < cells; cell++) {
154
+ const brick = (index[cell] as number) - 1;
155
+ if (brick < 0) continue;
156
+ brickCoords[brick * 3] = cell % dims[0];
157
+ brickCoords[brick * 3 + 1] = Math.floor(cell / dims[0]) % dims[1];
158
+ brickCoords[brick * 3 + 2] = Math.floor(cell / (dims[0] * dims[1]));
159
+ }
160
+
161
+ const lightStart = new Uint32Array(count + 1);
162
+ visit((_, cell) => {
163
+ lightStart[index[cell] as number] = (lightStart[index[cell] as number] as number) + 1;
164
+ });
165
+ for (let brick = 0; brick < count; brick++) {
166
+ lightStart[brick + 1] = (lightStart[brick + 1] as number) + (lightStart[brick] as number);
167
+ }
168
+ const filled = lightStart.slice(0, count);
169
+ const listed = new Uint32Array(pairs);
170
+ visit((light, cell) => {
171
+ const brick = (index[cell] as number) - 1;
172
+ listed[filled[brick] as number] = light;
173
+ filled[brick] = (filled[brick] as number) + 1;
174
+ });
175
+
176
+ return { spacing, span, origin, dims, index, count, brickCoords, lightStart, lights: listed };
177
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * DriftLight: a scene's many fixed lights summed into one volume, which stands in for them wherever
3
+ * they are not shaded one by one.
4
+ *
5
+ * **The problem it answers.** A frame shades a few hundred lights at most, chosen nearest the
6
+ * camera, and a scene of thousands of candles left every one past that choice dark: a wall with a
7
+ * dozen candles beside it lit nothing until the camera walked up to it. This sums every fixed light
8
+ * once, occluded by the scene's distance field, into sparse bricks of light and direction; the lit
9
+ * shader shades exactly where the frame's choice is complete (`PointLightBuffer.complete`) and reads
10
+ * the volume past it, crossfading across a band so nothing pops. A light is never counted twice:
11
+ * the split is per pixel, and the field's own lights carry a mark (`inLightField`) the selection
12
+ * turns into the sign of their weight.
13
+ *
14
+ * **Baked a few bricks a step, by the scene.** `bake(bricks)` does the number asked, so a scene paces
15
+ * it as it paces anything else it builds behind a loading screen, and the build is deterministic:
16
+ * the engine reads no clock. The volume is not summed into the frame until every brick has landed,
17
+ * then fades in over `fadeSec`, so a field completing in view does not pop; a field baked behind a
18
+ * loading screen asks for no fade.
19
+ *
20
+ * **The lights are summed as they stood when the field was made**, from a copy: a scene dimming its
21
+ * lamps at dawn rewrites the very objects it gave the field, and a bake paced into the morning
22
+ * would otherwise sum lamps already out. `scale` dims what was summed.
23
+ *
24
+ * What it gives up is stated where each part does it: specular from summed lights (`bake.ts`),
25
+ * flicker past the choice (the volume holds each light's steady colour), and occlusion by anything
26
+ * that moves (only the fields given at construction shadow it).
27
+ */
28
+
29
+ import type { GlobalFieldInstance } from '../gi/globalField.ts';
30
+ import { distanceToInstances } from '../gi/globalField.ts';
31
+ import { halfBits } from '../halfFloat.ts';
32
+ import { BRICK_TEXELS, bakeBrick } from './bake.ts';
33
+ import type { FieldLight } from './bake.ts';
34
+ import type { PointLightFalloff } from './falloff.ts';
35
+ import { BRICK_SAMPLES, layoutLightField } from './layout.ts';
36
+ import type { LightFieldLayout } from './layout.ts';
37
+
38
+ /** Seconds the summed light takes to fade in once the field is whole. */
39
+ export const FIELD_FADE_SEC = 0.5;
40
+ /** How fast the shading radius grows back, per second, when the choice reaches further again. */
41
+ const RADIUS_GROWTH_PER_SEC = 2;
42
+ /** Bricks along each axis of the atlas: 256 texels a side, the least 3D texture WebGL2 promises. */
43
+ const ATLAS_BRICKS: readonly [number, number, number] = [32, 64, 64];
44
+ /** A brick's footprint in the atlas: its light block and its direction block side by side. */
45
+ const BRICK_WIDTH = BRICK_SAMPLES * 2;
46
+
47
+ /** A light a field sums: a `PointLightSource`, whose `inLightField` the field sets. */
48
+ export interface LightFieldSource extends FieldLight {
49
+ inLightField?: boolean;
50
+ }
51
+
52
+ export interface LightFieldOptions {
53
+ /** How the renderer shades a point light, which the sum has to match. */
54
+ readonly falloff: PointLightFalloff;
55
+ /** Metres between samples; a third of a metre by default, so a brick spans a metre. */
56
+ readonly spacing?: number;
57
+ /** The scene's placed distance fields, which occlude the light. None leaves it unoccluded. */
58
+ readonly fields?: readonly GlobalFieldInstance[];
59
+ /** Metres over which exact and summed light crossfade at the shading radius. Two by default. */
60
+ readonly band?: number;
61
+ /**
62
+ * Seconds the summed light fades in over once the field is whole, `FIELD_FADE_SEC` by default.
63
+ * Zero for a field baked behind a loading screen, which has nothing on screen to pop and may be
64
+ * stepped by a clock that has stopped.
65
+ */
66
+ readonly fadeSec?: number;
67
+ }
68
+
69
+ export class LightField {
70
+ readonly layout: LightFieldLayout;
71
+ /** Texels on each axis of the atlas. */
72
+ readonly atlasSize: readonly [number, number, number];
73
+ /** Bricks on each axis of the atlas, which the shader needs to find a brick's corner. */
74
+ readonly atlasBricks: readonly [number, number, number];
75
+ /** The atlas as half floats, four channels a texel, x fastest. */
76
+ readonly atlas: Uint16Array;
77
+ /** Metres over which exact and summed light crossfade. */
78
+ readonly band: number;
79
+ /** Whether a distance field occludes this field's light. */
80
+ readonly occluded: boolean;
81
+ /** Seconds the summed light fades in over once whole; zero is at once. */
82
+ readonly fadeSec: number;
83
+ /** The summed light's brightness, for a scene that dims all its lamps at once: dawn, a switch. */
84
+ scale = 1;
85
+ /** Where the frame's choice was centred, which the shader measures the radius from. */
86
+ readonly centre = new Float32Array(3);
87
+
88
+ private readonly lights: readonly FieldLight[];
89
+ private readonly falloff: PointLightFalloff;
90
+ private readonly distance: ((x: number, y: number, z: number) => number) | null;
91
+ private readonly lightBlock = new Float32Array(BRICK_TEXELS * 4);
92
+ private readonly directionBlock = new Float32Array(BRICK_TEXELS * 4);
93
+ private baked = 0;
94
+ private taken = 0;
95
+ private eased = 0;
96
+ private shown = 0;
97
+
98
+ constructor(lights: readonly LightFieldSource[], options: LightFieldOptions) {
99
+ for (const light of lights) light.inLightField = true;
100
+ this.lights = lights.map(({ x, y, z, radius, r, g, b, sourceRadius }) => ({
101
+ x,
102
+ y,
103
+ z,
104
+ radius,
105
+ r,
106
+ g,
107
+ b,
108
+ sourceRadius,
109
+ }));
110
+ this.falloff = options.falloff;
111
+ this.band = options.band ?? 2;
112
+ this.fadeSec = Math.max(0, options.fadeSec ?? FIELD_FADE_SEC);
113
+ const fields = options.fields ?? [];
114
+ this.occluded = fields.length > 0;
115
+ this.distance = this.occluded ? distanceToInstances(fields) : null;
116
+ this.layout = layoutLightField(lights, options.spacing ?? 1 / 3);
117
+
118
+ const count = this.layout.count;
119
+ const capacity = ATLAS_BRICKS[0] * ATLAS_BRICKS[1] * ATLAS_BRICKS[2];
120
+ if (count > capacity) {
121
+ throw new Error(
122
+ `LightField: these lights reach ${count} bricks and one field holds ${capacity}. ` +
123
+ 'Give each region of the world a field of its own, or a coarser spacing.',
124
+ );
125
+ }
126
+ const ax = Math.max(1, Math.min(count, ATLAS_BRICKS[0]));
127
+ const ay = Math.max(1, Math.min(Math.ceil(count / ax), ATLAS_BRICKS[1]));
128
+ const az = Math.max(1, Math.ceil(count / (ax * ay)));
129
+ this.atlasBricks = [ax, ay, az];
130
+ this.atlasSize = [ax * BRICK_WIDTH, ay * BRICK_SAMPLES, az * BRICK_SAMPLES];
131
+ this.atlas = new Uint16Array(this.atlasSize[0] * this.atlasSize[1] * this.atlasSize[2] * 4);
132
+ }
133
+
134
+ /** Whether every brick has been baked. */
135
+ get ready(): boolean {
136
+ return this.baked >= this.layout.count;
137
+ }
138
+
139
+ /** How much of the bake is done, 0 to 1, for a loading screen. */
140
+ get progress(): number {
141
+ return this.layout.count === 0 ? 1 : this.baked / this.layout.count;
142
+ }
143
+
144
+ /** The radius, in metres from `centre`, inside which lights are shaded exactly. */
145
+ get radius(): number {
146
+ return this.eased;
147
+ }
148
+
149
+ /** How much of the summed light is in the frame, 0 until the field is whole, then up to 1. */
150
+ get presence(): number {
151
+ return this.shown;
152
+ }
153
+
154
+ /** The texel at which brick `brick`'s light block starts; its direction block is 4 along x. */
155
+ atlasCorner(brick: number): [number, number, number] {
156
+ const [ax, ay] = this.atlasBricks;
157
+ return [
158
+ (brick % ax) * BRICK_WIDTH,
159
+ (Math.floor(brick / ax) % ay) * BRICK_SAMPLES,
160
+ Math.floor(brick / (ax * ay)) * BRICK_SAMPLES,
161
+ ];
162
+ }
163
+
164
+ /** Bake up to `bricks` more bricks into the atlas, and answer how many were baked. */
165
+ bake(bricks: number): number {
166
+ const end = Math.min(this.layout.count, this.baked + Math.max(0, Math.floor(bricks)));
167
+ const done = end - this.baked;
168
+ for (let brick = this.baked; brick < end; brick++) {
169
+ bakeBrick(
170
+ this.layout,
171
+ brick,
172
+ this.lights,
173
+ this.falloff,
174
+ this.distance,
175
+ this.lightBlock,
176
+ this.directionBlock,
177
+ );
178
+ this.place(brick);
179
+ }
180
+ this.baked = end;
181
+ return done;
182
+ }
183
+
184
+ /** The bricks baked since the last call, for a backend to upload. */
185
+ takeBaked(): { from: number; to: number } {
186
+ const range = { from: this.taken, to: this.baked };
187
+ this.taken = this.baked;
188
+ return range;
189
+ }
190
+
191
+ /**
192
+ * Follow the frame's choice: `complete` from the selection, centred at (x, y, z). The radius
193
+ * shrinks at once, because a pixel inside it that a left-out light reaches would be shaded
194
+ * short, and grows back slowly, because a pixel it has not reached yet takes the summed light,
195
+ * which is only less exact. While nothing is summed the radius simply follows.
196
+ */
197
+ follow(complete: number, x: number, y: number, z: number, dtSec: number): void {
198
+ this.centre[0] = x;
199
+ this.centre[1] = y;
200
+ this.centre[2] = z;
201
+ const showing = this.shown > 0;
202
+ if (!showing || complete < this.eased) this.eased = complete;
203
+ else this.eased += (complete - this.eased) * (1 - Math.exp(-RADIUS_GROWTH_PER_SEC * dtSec));
204
+ if (this.ready) {
205
+ this.shown = this.fadeSec > 0 ? Math.min(1, this.shown + dtSec / this.fadeSec) : 1;
206
+ }
207
+ }
208
+
209
+ /** Write the brick just baked into its place in the atlas, as half floats. */
210
+ private place(brick: number): void {
211
+ const [x0, y0, z0] = this.atlasCorner(brick);
212
+ const [w, h] = this.atlasSize;
213
+ for (let k = 0; k < BRICK_SAMPLES; k++) {
214
+ for (let j = 0; j < BRICK_SAMPLES; j++) {
215
+ for (let i = 0; i < BRICK_SAMPLES; i++) {
216
+ const from = (i + BRICK_SAMPLES * (j + BRICK_SAMPLES * k)) * 4;
217
+ const light = (((z0 + k) * h + (y0 + j)) * w + (x0 + i)) * 4;
218
+ const direction = light + BRICK_SAMPLES * 4;
219
+ for (let c = 0; c < 4; c++) {
220
+ this.atlas[light + c] = halfBits(this.lightBlock[from + c] as number);
221
+ this.atlas[direction + c] = halfBits(this.directionBlock[from + c] as number);
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What the lit shader is told about a DriftLight field this pass, decided once for both backends.
3
+ *
4
+ * **Decided here and bound there**, by the 2026-08-13 rule: each backend only uploads these numbers
5
+ * to the names `driftLight.ts` declares, so the two cannot come to disagree about when the field is
6
+ * on or what radius a pass uses. Off, which the shader reads as `light.x` of zero, until a field is
7
+ * whole; and during a probe bake the radius is zero, because the exact lights were chosen for the
8
+ * camera and a probe stands somewhere else: a probe sees every fixed light through the volume.
9
+ *
10
+ * **The radius is handed over measured from the eye**, because the shader measures from the
11
+ * `uCameraPos` it already has rather than spending a uniform on the choice's centre. A point within
12
+ * `r − |eye − centre|` of the eye is within `r` of the centre, so taking the offset off keeps every
13
+ * pixel the shader shades exactly inside the radius the selection promised. A mirror's camera, far
14
+ * from the centre, gets a smaller radius and more of the volume, which is the safe direction.
15
+ */
16
+
17
+ import type { LightField } from './lightField.ts';
18
+
19
+ export interface DriftLightUniforms {
20
+ /** How much of the summed light is in, the radius, the band, the scale. */
21
+ readonly light: Float32Array;
22
+ /** The first brick's first sample, and the metres between samples. */
23
+ readonly origin: Float32Array;
24
+ }
25
+
26
+ export function createDriftLightUniforms(): DriftLightUniforms {
27
+ return {
28
+ light: new Float32Array(4),
29
+ origin: new Float32Array(4),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Fill `out` for `field`, or for no field, seen from `eye`; `probePass` while a probe's faces are
35
+ * being drawn.
36
+ */
37
+ export function resolveDriftLight(
38
+ field: LightField | null,
39
+ probePass: boolean,
40
+ eye: ArrayLike<number>,
41
+ out: DriftLightUniforms,
42
+ ): void {
43
+ if (field === null || !field.ready || field.presence <= 0 || field.layout.count === 0) {
44
+ out.light.fill(0);
45
+ return;
46
+ }
47
+ const { layout } = field;
48
+ const offset = Math.hypot(
49
+ (eye[0] ?? 0) - (field.centre[0] as number),
50
+ (eye[1] ?? 0) - (field.centre[1] as number),
51
+ (eye[2] ?? 0) - (field.centre[2] as number),
52
+ );
53
+ out.light[0] = field.presence;
54
+ out.light[1] = probePass ? 0 : Math.max(0, field.radius - offset);
55
+ out.light[2] = field.band;
56
+ out.light[3] = field.scale;
57
+ out.origin[0] = layout.origin[0];
58
+ out.origin[1] = layout.origin[1];
59
+ out.origin[2] = layout.origin[2];
60
+ out.origin[3] = layout.spacing;
61
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * How much of a light a point sees, through a signed distance field: DriftLight's occlusion.
3
+ *
4
+ * **Sphere-traced, with a penumbra from the light's own size.** A spherical light of radius R seen
5
+ * from a distance L fills a cone that is R * t / L across at a distance t along the way, so a surface
6
+ * a clearance h from the ray covers the fraction of it that h falls short of that radius. The least
7
+ * such ratio along the ray is the visibility: 1 in open air, 0 behind a wall, and a soft ramp at an
8
+ * edge that widens with the light, which is what `AGENTS.md` asks of a light with a physical size.
9
+ *
10
+ * The trace stops before the light by its own radius, or 5 cm where that is smaller, so a candle's
11
+ * wax does not shadow the flame sitting on it. What it gives up is exactness at a grazing corner,
12
+ * where one ratio stands for an area the cone only partly crosses; what would make it wrong is a
13
+ * field coarser than the lights it shadows, which blurs every occluder smaller than a cell.
14
+ */
15
+
16
+ /** A signed distance: metres to the nearest surface, negative inside. */
17
+ export type DistanceAt = (x: number, y: number, z: number) => number;
18
+
19
+ /** Steps before the trace gives up and answers what it has; a light a metre off needs a dozen. */
20
+ const MAX_STEPS = 64;
21
+ /** The least a step advances, so a ray grazing a surface still reaches the light. */
22
+ const MIN_STEP_M = 0.01;
23
+ /** Where the trace starts, clear of the surface the point may be lying on. */
24
+ const START_M = 0.02;
25
+
26
+ /** The visibility, 0 to 1, of a light of radius `sourceRadius` at `to` from the point `from`. */
27
+ export function softVisibility(
28
+ distance: DistanceAt,
29
+ fx: number,
30
+ fy: number,
31
+ fz: number,
32
+ tx: number,
33
+ ty: number,
34
+ tz: number,
35
+ sourceRadius: number,
36
+ ): number {
37
+ const dx = tx - fx;
38
+ const dy = ty - fy;
39
+ const dz = tz - fz;
40
+ const length = Math.hypot(dx, dy, dz);
41
+ if (length < 1e-6) return 1;
42
+ const ux = dx / length;
43
+ const uy = dy / length;
44
+ const uz = dz / length;
45
+ const radius = Math.max(sourceRadius, 1e-3);
46
+ const end = length - Math.max(radius, 0.05);
47
+ let seen = 1;
48
+ let t = START_M;
49
+ for (let step = 0; step < MAX_STEPS && t < end; step++) {
50
+ const h = distance(fx + ux * t, fy + uy * t, fz + uz * t);
51
+ if (h <= 0) return 0;
52
+ const cone = (radius * t) / length;
53
+ seen = Math.min(seen, h / cone);
54
+ if (seen <= 0) return 0;
55
+ t += Math.max(h, MIN_STEP_M);
56
+ }
57
+ return Math.min(1, seen);
58
+ }
@@ -128,3 +128,44 @@ export function createEmptyTextureCube(gl: WebGL2RenderingContext): WebGLTexture
128
128
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
129
129
  return texture;
130
130
  }
131
+
132
+ /**
133
+ * The same for DriftLight's two volumes while a scene has no field: one texel of an unsigned
134
+ * integer, reading "no brick here", or of zero light. GLSL ES gives a 3D sampler no default and a
135
+ * lookup through an unbound one is the undefined read this file exists to prevent.
136
+ */
137
+ export function createEmptyTexture3D(gl: WebGL2RenderingContext, integer: boolean): WebGLTexture {
138
+ const texture = allocate(gl, 'emptyTexture3D');
139
+ gl.bindTexture(gl.TEXTURE_3D, texture);
140
+ if (integer) {
141
+ gl.texImage3D(
142
+ gl.TEXTURE_3D,
143
+ 0,
144
+ gl.R32UI,
145
+ 1,
146
+ 1,
147
+ 1,
148
+ 0,
149
+ gl.RED_INTEGER,
150
+ gl.UNSIGNED_INT,
151
+ new Uint32Array(1),
152
+ );
153
+ } else {
154
+ gl.texImage3D(
155
+ gl.TEXTURE_3D,
156
+ 0,
157
+ gl.RGBA8,
158
+ 1,
159
+ 1,
160
+ 1,
161
+ 0,
162
+ gl.RGBA,
163
+ gl.UNSIGNED_BYTE,
164
+ new Uint8Array(4),
165
+ );
166
+ }
167
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
168
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
169
+ gl.bindTexture(gl.TEXTURE_3D, null);
170
+ return texture;
171
+ }