@driftengine/core 4.5.0 → 4.6.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 (207) hide show
  1. package/CHANGELOG.json +51 -0
  2. package/README.md +1 -1
  3. package/dist/geometry/boxCsg.d.ts +28 -0
  4. package/dist/geometry/boxCsg.js +139 -0
  5. package/dist/geometry/csg.d.ts +36 -0
  6. package/dist/geometry/csg.js +329 -0
  7. package/dist/geometry/meshBuilder.js +13 -0
  8. package/dist/geometry/meshConcat.js +1 -0
  9. package/dist/geometry/pixelFont.js +2 -0
  10. package/dist/geometry/solid.d.ts +61 -0
  11. package/dist/geometry/solid.js +152 -0
  12. package/dist/geometry/solidFlat.d.ts +35 -0
  13. package/dist/geometry/solidFlat.js +428 -0
  14. package/dist/geometry/solidHarness.d.ts +19 -0
  15. package/dist/geometry/solidHarness.js +66 -0
  16. package/dist/geometry/solidNormals.d.ts +17 -0
  17. package/dist/geometry/solidNormals.js +86 -0
  18. package/dist/geometry/solidRound.d.ts +41 -0
  19. package/dist/geometry/solidRound.js +323 -0
  20. package/dist/geometry/solidWriter.d.ts +16 -0
  21. package/dist/geometry/solidWriter.js +42 -0
  22. package/dist/index.d.ts +23 -2
  23. package/dist/index.js +13 -1
  24. package/dist/render/backend/api.d.ts +17 -3
  25. package/dist/render/backend/drawFog.d.ts +26 -0
  26. package/dist/render/backend/drawFog.js +30 -0
  27. package/dist/render/backend/materialChanges.d.ts +2 -1
  28. package/dist/render/backend/materialChanges.js +6 -1
  29. package/dist/render/backend/webgl2/instanced.d.ts +24 -5
  30. package/dist/render/backend/webgl2/instanced.js +72 -15
  31. package/dist/render/backend/webgl2/renderer.d.ts +122 -8
  32. package/dist/render/backend/webgl2/renderer.js +238 -121
  33. package/dist/render/backend/webgpu/buffers.d.ts +10 -0
  34. package/dist/render/backend/webgpu/buffers.js +34 -3
  35. package/dist/render/backend/webgpu/depthPass.js +5 -1
  36. package/dist/render/backend/webgpu/drawCommand.d.ts +5 -0
  37. package/dist/render/backend/webgpu/drawCommand.js +1 -0
  38. package/dist/render/backend/webgpu/flatPass.d.ts +4 -1
  39. package/dist/render/backend/webgpu/flatPass.js +38 -9
  40. package/dist/render/backend/webgpu/glassTintPass.js +2 -1
  41. package/dist/render/backend/webgpu/instanceCullPass.d.ts +34 -0
  42. package/dist/render/backend/webgpu/instanceCullPass.js +144 -0
  43. package/dist/render/backend/webgpu/instanced.d.ts +11 -1
  44. package/dist/render/backend/webgpu/instanced.js +21 -3
  45. package/dist/render/backend/webgpu/latePass.d.ts +6 -2
  46. package/dist/render/backend/webgpu/latePass.js +11 -4
  47. package/dist/render/backend/webgpu/postPass.d.ts +1 -1
  48. package/dist/render/backend/webgpu/renderer.d.ts +100 -10
  49. package/dist/render/backend/webgpu/renderer.js +462 -201
  50. package/dist/render/backend/webgpu/sdfTextPass.js +6 -1
  51. package/dist/render/backend/webgpu/shaders/instanceCull.wgsl.d.ts +18 -0
  52. package/dist/render/backend/webgpu/shaders/instanceCull.wgsl.js +59 -0
  53. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +17 -3
  54. package/dist/render/backend/webgpu/surfaceTexturePass.js +77 -38
  55. package/dist/render/driftLight/bake.d.ts +7 -0
  56. package/dist/render/driftLight/bake.js +61 -56
  57. package/dist/render/driftLight/denseField.d.ts +46 -0
  58. package/dist/render/driftLight/denseField.js +109 -0
  59. package/dist/render/driftLight/lightField.d.ts +10 -5
  60. package/dist/render/driftLight/lightField.js +25 -19
  61. package/dist/render/driftLight/presence.d.ts +50 -0
  62. package/dist/render/driftLight/presence.js +43 -0
  63. package/dist/render/driftLight/uniforms.d.ts +3 -3
  64. package/dist/render/driftLight/uniforms.js +5 -6
  65. package/dist/render/driftLight/worldLightField.d.ts +41 -0
  66. package/dist/render/driftLight/worldLightField.js +66 -0
  67. package/dist/render/fixtureAtlas.d.ts +20 -0
  68. package/dist/render/fixtureAtlas.js +49 -0
  69. package/dist/render/instanceCull.d.ts +63 -0
  70. package/dist/render/instanceCull.js +160 -0
  71. package/dist/render/instances.d.ts +15 -0
  72. package/dist/render/lightBudget.d.ts +22 -33
  73. package/dist/render/lightBudget.js +24 -35
  74. package/dist/render/lightGrid.d.ts +42 -0
  75. package/dist/render/lightGrid.js +87 -0
  76. package/dist/render/mesh.d.ts +18 -24
  77. package/dist/render/mesh.js +51 -68
  78. package/dist/render/occlusion.d.ts +10 -0
  79. package/dist/render/occlusion.js +36 -7
  80. package/dist/render/pointLightSelection.d.ts +7 -0
  81. package/dist/render/pointLightSelection.js +29 -4
  82. package/dist/render/rainField.d.ts +13 -0
  83. package/dist/render/rainField.js +27 -5
  84. package/dist/render/recon/lateDepth.d.ts +11 -0
  85. package/dist/render/recon/lateDepth.js +70 -0
  86. package/dist/render/recon/resolve.d.ts +34 -4
  87. package/dist/render/recon/resolve.js +161 -20
  88. package/dist/render/rendererHarness.js +4 -0
  89. package/dist/render/sceneTarget.d.ts +2 -0
  90. package/dist/render/sceneTarget.js +2 -0
  91. package/dist/render/shaders/depth.js +12 -9
  92. package/dist/render/shaders/flat/driftLight.d.ts +1 -1
  93. package/dist/render/shaders/flat/driftLight.js +32 -7
  94. package/dist/render/shaders/flat/index.js +5 -3
  95. package/dist/render/shaders/flat/main.d.ts +1 -1
  96. package/dist/render/shaders/flat/main.js +75 -23
  97. package/dist/render/shaders/flat/preamble.js +75 -20
  98. package/dist/render/shaders/flat/surfaceEffects.d.ts +29 -0
  99. package/dist/render/shaders/flat/surfaceEffects.js +152 -0
  100. package/dist/render/shaders/generated/depth.wgsl.d.ts +8 -8
  101. package/dist/render/shaders/generated/depth.wgsl.js +8 -8
  102. package/dist/render/shaders/generated/flat.wgsl.d.ts +1320 -1320
  103. package/dist/render/shaders/generated/flat.wgsl.js +1359 -1345
  104. package/dist/render/shaders/generated/lightVolume.wgsl.js +18 -4
  105. package/dist/render/shaders/generated/rush.wgsl.d.ts +21 -16
  106. package/dist/render/shaders/generated/rush.wgsl.js +21 -16
  107. package/dist/render/shaders/generated/sdfText.wgsl.d.ts +2 -2
  108. package/dist/render/shaders/generated/sdfText.wgsl.js +2 -2
  109. package/dist/render/shaders/generated/text.wgsl.d.ts +1 -1
  110. package/dist/render/shaders/generated/text.wgsl.js +1 -1
  111. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +1 -1
  112. package/dist/render/shaders/gi/driftLight.wgsl.js +27 -4
  113. package/dist/render/shaders/recon/lateDepth.wgsl.js +38 -13
  114. package/dist/render/shaders/recon/resolve.wgsl.d.ts +8 -7
  115. package/dist/render/shaders/recon/resolve.wgsl.js +161 -27
  116. package/dist/render/shaders/rush.js +11 -1
  117. package/dist/render/shaders/sdfText.d.ts +1 -1
  118. package/dist/render/shaders/sdfText.js +3 -2
  119. package/dist/render/shaders/text.d.ts +1 -1
  120. package/dist/render/shaders/text.js +18 -9
  121. package/dist/render/surfaceEffects.d.ts +83 -0
  122. package/dist/render/surfaceEffects.js +75 -0
  123. package/dist/render/surfaceScene.d.ts +8 -0
  124. package/dist/render/surfaceScene.js +8 -0
  125. package/dist/render/surfaceTexture.d.ts +18 -2
  126. package/dist/render/surfaceTexture.js +68 -15
  127. package/dist/render/temporalAa.d.ts +25 -6
  128. package/dist/render/temporalAa.js +45 -15
  129. package/dist/render/textureSource.d.ts +30 -0
  130. package/dist/render/textureSource.js +53 -0
  131. package/dist/render/vertexDefaults.js +2 -0
  132. package/dist/world/hlod.d.ts +104 -0
  133. package/dist/world/hlod.js +214 -0
  134. package/package.json +6 -6
  135. package/src/geometry/boxCsg.ts +186 -0
  136. package/src/geometry/csg.ts +384 -0
  137. package/src/geometry/meshBuilder.ts +15 -0
  138. package/src/geometry/meshConcat.ts +1 -0
  139. package/src/geometry/pixelFont.ts +2 -0
  140. package/src/geometry/solid.ts +189 -0
  141. package/src/geometry/solidFlat.ts +522 -0
  142. package/src/geometry/solidHarness.ts +83 -0
  143. package/src/geometry/solidNormals.ts +103 -0
  144. package/src/geometry/solidRound.ts +443 -0
  145. package/src/geometry/solidWriter.ts +58 -0
  146. package/src/index.ts +51 -1
  147. package/src/render/backend/api.ts +21 -2
  148. package/src/render/backend/drawFog.ts +31 -0
  149. package/src/render/backend/materialChanges.ts +9 -2
  150. package/src/render/backend/webgl2/instanced.ts +80 -14
  151. package/src/render/backend/webgl2/renderer.ts +300 -143
  152. package/src/render/backend/webgpu/buffers.ts +42 -3
  153. package/src/render/backend/webgpu/depthPass.ts +5 -1
  154. package/src/render/backend/webgpu/drawCommand.ts +6 -0
  155. package/src/render/backend/webgpu/flatPass.ts +41 -11
  156. package/src/render/backend/webgpu/glassTintPass.ts +2 -1
  157. package/src/render/backend/webgpu/instanceCullPass.ts +165 -0
  158. package/src/render/backend/webgpu/instanced.ts +21 -3
  159. package/src/render/backend/webgpu/latePass.ts +12 -3
  160. package/src/render/backend/webgpu/renderer.ts +540 -226
  161. package/src/render/backend/webgpu/sdfTextPass.ts +6 -1
  162. package/src/render/backend/webgpu/shaders/instanceCull.wgsl.ts +61 -0
  163. package/src/render/backend/webgpu/surfaceTexturePass.ts +90 -50
  164. package/src/render/driftLight/bake.ts +85 -62
  165. package/src/render/driftLight/denseField.ts +149 -0
  166. package/src/render/driftLight/lightField.ts +33 -18
  167. package/src/render/driftLight/presence.ts +69 -0
  168. package/src/render/driftLight/uniforms.ts +8 -9
  169. package/src/render/driftLight/worldLightField.ts +83 -0
  170. package/src/render/fixtureAtlas.ts +63 -0
  171. package/src/render/instanceCull.ts +205 -0
  172. package/src/render/instances.ts +16 -0
  173. package/src/render/lightBudget.ts +25 -36
  174. package/src/render/lightGrid.ts +130 -0
  175. package/src/render/mesh.ts +59 -72
  176. package/src/render/occlusion.ts +51 -7
  177. package/src/render/pointLightSelection.ts +72 -4
  178. package/src/render/rainField.ts +39 -5
  179. package/src/render/recon/lateDepth.ts +83 -0
  180. package/src/render/recon/resolve.ts +206 -20
  181. package/src/render/rendererHarness.ts +4 -0
  182. package/src/render/sceneTarget.ts +4 -0
  183. package/src/render/shaders/depth.ts +12 -9
  184. package/src/render/shaders/flat/driftLight.ts +32 -7
  185. package/src/render/shaders/flat/index.ts +5 -3
  186. package/src/render/shaders/flat/main.ts +75 -23
  187. package/src/render/shaders/flat/preamble.ts +75 -20
  188. package/src/render/shaders/flat/surfaceEffects.ts +152 -0
  189. package/src/render/shaders/generated/depth.wgsl.ts +8 -8
  190. package/src/render/shaders/generated/flat.wgsl.ts +1361 -1345
  191. package/src/render/shaders/generated/lightVolume.wgsl.ts +20 -4
  192. package/src/render/shaders/generated/rush.wgsl.ts +21 -16
  193. package/src/render/shaders/generated/sdfText.wgsl.ts +2 -2
  194. package/src/render/shaders/generated/text.wgsl.ts +1 -1
  195. package/src/render/shaders/gi/driftLight.wgsl.ts +27 -4
  196. package/src/render/shaders/recon/lateDepth.wgsl.ts +38 -13
  197. package/src/render/shaders/recon/resolve.wgsl.ts +161 -27
  198. package/src/render/shaders/rush.ts +11 -1
  199. package/src/render/shaders/sdfText.ts +3 -2
  200. package/src/render/shaders/text.ts +18 -9
  201. package/src/render/surfaceEffects.ts +132 -0
  202. package/src/render/surfaceScene.ts +16 -0
  203. package/src/render/surfaceTexture.ts +110 -19
  204. package/src/render/temporalAa.ts +78 -14
  205. package/src/render/textureSource.ts +72 -0
  206. package/src/render/vertexDefaults.ts +2 -0
  207. package/src/world/hlod.ts +290 -0
@@ -143,22 +143,20 @@ export const ENVIRONMENT_TEXTURE_UNIT = EMISSIVE_TEXTURE_UNIT + 1;
143
143
  export const CLUSTER_TABLE_TEXTURE_UNIT = ENVIRONMENT_TEXTURE_UNIT + 1;
144
144
 
145
145
  /**
146
- * Where the photometric atlas binds: one row per IES profile, any number of them.
147
- *
148
- * **One unit for every profile a world holds, which is the whole reason it is an atlas.** A
149
- * fixture's distribution is a curve rather than an image, so a row of 128 floats holds one and a
150
- * texture holds as many as a consumer loads. Per-profile textures would have spent the remaining
151
- * units on the third fixture.
152
- *
153
- * **Spent unconditionally, for the same reason the froxel table is**, and the argument is at that
154
- * declaration: the lookup is a branch on a value rather than a permutation, so the sampler is
155
- * declared in every profile and a declared sampler needs a complete texture bound whether or not
156
- * the branch reads it. With no profile loaded that texture is a single row of ones, which is the
157
- * multiplicative identity — a row of zeros would switch off every light that reached it.
158
- *
159
- * The tenth of sixteen. `CAPABILITIES.md` §3 keeps the running count.
146
+ * Where the fixture atlas binds: a fixture's spot cookies and its photometric profiles, one texture.
147
+ *
148
+ * **Two tables in one binding since 2026-09-30**, which is the fold `textureUnitBudget.test.ts` asks
149
+ * the next sampler for: the widest lit shader bound fifteen of the sixteen fragment samplers WebGL2
150
+ * guarantees, and the cookie atlas and the photometric atlas were two small float tables read with
151
+ * `textureLod`, both 128 texels across. See `fixtureAtlas.ts` for the layout and why cookies sit on
152
+ * top.
153
+ *
154
+ * **Spent unconditionally, for the reason the froxel table is**: both lookups are branches on values
155
+ * rather than permutations, so the sampler is declared in every profile and a declared sampler needs
156
+ * a complete texture. With nothing loaded that is a single row of ones — the multiplicative
157
+ * identity, since a row of zeros would switch off every light that reached it.
160
158
  */
161
- export const IES_ATLAS_TEXTURE_UNIT = CLUSTER_TABLE_TEXTURE_UNIT + 1;
159
+ export const FIXTURE_ATLAS_TEXTURE_UNIT = CLUSTER_TABLE_TEXTURE_UNIT + 1;
162
160
 
163
161
  /**
164
162
  * The joint palette, read by the **vertex** stage.
@@ -177,7 +175,7 @@ export const IES_ATLAS_TEXTURE_UNIT = CLUSTER_TABLE_TEXTURE_UNIT + 1;
177
175
  * What it costs is one of the five units left after the IES atlas. What would make it wrong is
178
176
  * the lit pass growing past sixteen, which `textureUnitBudget.test.ts` asserts against.
179
177
  */
180
- export const SKIN_PALETTE_TEXTURE_UNIT = IES_ATLAS_TEXTURE_UNIT + 1;
178
+ export const SKIN_PALETTE_TEXTURE_UNIT = FIXTURE_ATLAS_TEXTURE_UNIT + 1;
181
179
 
182
180
  /**
183
181
  * A mesh's morph deltas, also read by the vertex stage, and also out of this same sixteen.
@@ -188,22 +186,6 @@ export const SKIN_PALETTE_TEXTURE_UNIT = IES_ATLAS_TEXTURE_UNIT + 1;
188
186
  */
189
187
  export const MORPH_DELTA_TEXTURE_UNIT = SKIN_PALETTE_TEXTURE_UNIT + 1;
190
188
 
191
- /**
192
- * Where the spot-light cookie atlas binds: one row of square tiles, one tile a cookie.
193
- *
194
- * **The fourteenth of sixteen, and two are left.** `CAPABILITIES.md` §3 keeps the running count —
195
- * and had been three units stale for as long as the IES atlas, the skin palette and the morph
196
- * deltas have existed, which is why the number is derived in `textureUnitBudget.test.ts` and only
197
- * quoted in prose.
198
- *
199
- * **Spent unconditionally, for the reason the froxel table and the IES atlas are**: a declared
200
- * sampler needs a complete texture bound whether or not the branch reads it, so a one-texel white
201
- * placeholder stands in it when no consumer has loaded a cookie. White rather than black, because
202
- * it multiplies a light's colour and a black stand-in would switch off every light that reached
203
- * it — the identity `packIesAtlas` chooses for the same reason.
204
- */
205
- export const COOKIE_ATLAS_TEXTURE_UNIT = MORPH_DELTA_TEXTURE_UNIT + 1;
206
-
207
189
  /**
208
190
  * The colour the frame had already drawn, for a refracting surface to sample.
209
191
  *
@@ -217,7 +199,7 @@ export const COOKIE_ATLAS_TEXTURE_UNIT = MORPH_DELTA_TEXTURE_UNIT + 1;
217
199
  * cubemaps became one array, which is what freed the units this one is spending.
218
200
  * `textureUnitBudget.test.ts` carries the phone that budget was written for.
219
201
  */
220
- export const REFRACT_SCENE_TEXTURE_UNIT = COOKIE_ATLAS_TEXTURE_UNIT + 1;
202
+ export const REFRACT_SCENE_TEXTURE_UNIT = MORPH_DELTA_TEXTURE_UNIT + 1;
221
203
 
222
204
  /**
223
205
  * DriftLight's brick index and atlas, the lit program's fourteenth and fifteenth fragment samplers.
@@ -238,12 +220,19 @@ export const DRIFT_LIGHT_ATLAS_TEXTURE_UNIT = DRIFT_LIGHT_INDEX_TEXTURE_UNIT + 1
238
220
  export const SUN_GLASS_TINT_TEXTURE_UNIT = DRIFT_LIGHT_ATLAS_TEXTURE_UNIT + 1;
239
221
 
240
222
  /**
241
- * What each lamp keeps through glass (`pointGlassTint.ts`), the lit program's fifteenth fragment
242
- * sampler and the last one that leaves a sixteenth spare. Unit sixteen of the combined pool, which
243
- * WebGL2 guarantees thirty-two of; what a stage may declare is the sixteen, and this is fifteen.
223
+ * What each lamp keeps through glass (`pointGlassTint.ts`), a fragment sampler of the lit program.
224
+ * A unit of the combined pool, which WebGL2 guarantees thirty-two of; what a stage may declare is
225
+ * sixteen, which `textureUnitBudget.test.ts` counts from the shader source.
244
226
  */
245
227
  export const POINT_GLASS_TINT_TEXTURE_UNIT = SUN_GLASS_TINT_TEXTURE_UNIT + 1;
246
228
 
229
+ /**
230
+ * Each surface layer's effects table (`surfaceEffects.ts`), bound beside the albedo array it
231
+ * describes. **Paid for by folding**: the cookie and photometric atlases became one fixture atlas
232
+ * to free the sampler this takes, so the lit stage still keeps one of its sixteen spare.
233
+ */
234
+ export const SURFACE_EFFECTS_TEXTURE_UNIT = POINT_GLASS_TINT_TEXTURE_UNIT + 1;
235
+
247
236
  /**
248
237
  * A cookie's tile, in texels a side.
249
238
  *
@@ -0,0 +1,130 @@
1
+ /**
2
+ * A world's fixed lights bucketed once, so the frame's exact choice looks only at the lights near
3
+ * the eye rather than at every one of them.
4
+ *
5
+ * **The choice must be the full scan's, exactly, and needs no sort to be.** Both of the choice's
6
+ * lists break a tie in distance by source index, so the order the lights are met in cannot reach the
7
+ * result: what reaches it is the full scan's candidates minus only lights no query could keep —
8
+ * every light whose cell lies within `viewRange + maxRadius` of the eye is gathered, and the
9
+ * selection's own `viewRange + radius` test does the rest.
10
+ *
11
+ * **Cost stated**: built once, from where the lights stood. A light that moves or changes its reach
12
+ * must be re-bucketed by the caller — this is for the city's fixed thousands, and a scene's handful
13
+ * of movers stays with the plain scan.
14
+ *
15
+ * Nothing here allocates after construction; the gather writes into the grid's own scratch.
16
+ */
17
+ import type { PointLightSource } from './pointLightSelection.ts';
18
+
19
+ /** The most cells a grid may span, which keeps a stray light far away from costing a gigabyte. */
20
+ export const LIGHT_GRID_MAX_CELLS = 1 << 22;
21
+
22
+ export interface LightGrid {
23
+ readonly sources: readonly PointLightSource[];
24
+ /** Metres a cell spans on each axis. */
25
+ readonly cell: number;
26
+ /** The world position of cell (0, 0, 0)'s corner. */
27
+ readonly origin: readonly [number, number, number];
28
+ readonly dims: readonly [number, number, number];
29
+ /** Where cell `c`'s lights start in `lights`; `start[c + 1]` is where they end. */
30
+ readonly start: Uint32Array;
31
+ /** Source indices by cell, ascending within each. */
32
+ readonly lights: Uint32Array;
33
+ /** The largest reach of any source, which widens every query. */
34
+ readonly maxRadius: number;
35
+ /** The last gather, cell by cell: `gathered[0..count)`. */
36
+ readonly gathered: Int32Array;
37
+ }
38
+
39
+ /** Bucket `sources` into cells of `cell` metres. Built once; not a frame path. */
40
+ export function createLightGrid(sources: readonly PointLightSource[], cell: number): LightGrid {
41
+ if (!(cell > 0)) throw new Error(`createLightGrid: a cell of ${String(cell)} metres`);
42
+ let minX = Infinity;
43
+ let minY = Infinity;
44
+ let minZ = Infinity;
45
+ let maxX = -Infinity;
46
+ let maxY = -Infinity;
47
+ let maxZ = -Infinity;
48
+ let maxRadius = 0;
49
+ for (const light of sources) {
50
+ minX = Math.min(minX, light.x);
51
+ minY = Math.min(minY, light.y);
52
+ minZ = Math.min(minZ, light.z);
53
+ maxX = Math.max(maxX, light.x);
54
+ maxY = Math.max(maxY, light.y);
55
+ maxZ = Math.max(maxZ, light.z);
56
+ maxRadius = Math.max(maxRadius, light.radius);
57
+ }
58
+ if (sources.length === 0) minX = minY = minZ = maxX = maxY = maxZ = 0;
59
+ const dims: [number, number, number] = [
60
+ Math.floor((maxX - minX) / cell) + 1,
61
+ Math.floor((maxY - minY) / cell) + 1,
62
+ Math.floor((maxZ - minZ) / cell) + 1,
63
+ ];
64
+ const cells = dims[0] * dims[1] * dims[2];
65
+ if (cells > LIGHT_GRID_MAX_CELLS) {
66
+ throw new Error(
67
+ `createLightGrid: these lights span ${dims.join(' × ')} cells of ${cell} m, past ` +
68
+ `${LIGHT_GRID_MAX_CELLS}; a larger cell fits them.`,
69
+ );
70
+ }
71
+ const cellOf = (light: PointLightSource): number =>
72
+ Math.floor((light.x - minX) / cell) +
73
+ dims[0] * (Math.floor((light.y - minY) / cell) + dims[1] * Math.floor((light.z - minZ) / cell));
74
+ const start = new Uint32Array(cells + 1);
75
+ for (const light of sources) {
76
+ const c = cellOf(light);
77
+ start[c + 1] = (start[c + 1] as number) + 1;
78
+ }
79
+ for (let c = 0; c < cells; c++) start[c + 1] = (start[c + 1] as number) + (start[c] as number);
80
+ const lights = new Uint32Array(sources.length);
81
+ const cursor = start.slice(0, cells);
82
+ for (let index = 0; index < sources.length; index++) {
83
+ const c = cellOf(sources[index] as PointLightSource);
84
+ lights[cursor[c] as number] = index;
85
+ cursor[c] = (cursor[c] as number) + 1;
86
+ }
87
+ return {
88
+ sources,
89
+ cell,
90
+ origin: [minX, minY, minZ],
91
+ dims,
92
+ start,
93
+ lights,
94
+ maxRadius,
95
+ gathered: new Int32Array(sources.length),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Gather into `grid.gathered`, cell by cell, every light whose cell lies within `reach` metres of
101
+ * (x, y, z) on each axis, and answer how many.
102
+ */
103
+ export function gatherLights(
104
+ grid: LightGrid,
105
+ x: number,
106
+ y: number,
107
+ z: number,
108
+ reach: number,
109
+ ): number {
110
+ const { cell, origin, dims, start, lights, gathered } = grid;
111
+ const x0 = Math.max(0, Math.floor((x - reach - origin[0]) / cell));
112
+ const x1 = Math.min(dims[0] - 1, Math.floor((x + reach - origin[0]) / cell));
113
+ const y0 = Math.max(0, Math.floor((y - reach - origin[1]) / cell));
114
+ const y1 = Math.min(dims[1] - 1, Math.floor((y + reach - origin[1]) / cell));
115
+ const z0 = Math.max(0, Math.floor((z - reach - origin[2]) / cell));
116
+ const z1 = Math.min(dims[2] - 1, Math.floor((z + reach - origin[2]) / cell));
117
+ let count = 0;
118
+ for (let cz = z0; cz <= z1; cz++) {
119
+ for (let cy = y0; cy <= y1; cy++) {
120
+ const row = dims[0] * (cy + dims[1] * cz);
121
+ for (let cx = x0; cx <= x1; cx++) {
122
+ const c = row + cx;
123
+ for (let at = start[c] as number; at < (start[c + 1] as number); at++) {
124
+ gathered[count++] = lights[at] as number;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return count;
130
+ }
@@ -58,14 +58,6 @@ const ATTR_JOINTS = 11;
58
58
  */
59
59
  const ATTR_CHANNEL = 13;
60
60
 
61
- /**
62
- * The locations `attachInstances` claims, which a plain draw through the same array must not read.
63
- *
64
- * Eleven to fourteen are the model matrix's columns and fifteen is the tint. **Thirteen is also
65
- * the channel**, and eleven and twelve are the skinning pair — which cannot collide, because
66
- * `flatVert` refuses skinning with instancing. See `draw`.
67
- */
68
- const INSTANCE_COLUMNS = [11, 12, 13, 14, 15] as const;
69
61
  const ATTR_WEIGHTS = 12;
70
62
 
71
63
  /**
@@ -95,6 +87,9 @@ function applyConstant(
95
87
  export class Mesh {
96
88
  private readonly vao: WebGLVertexArrayObject;
97
89
  private readonly buffers: WebGLBuffer[] = [];
90
+ /** Every attribute bound from a buffer, so a batch can build a vertex array of its own. */
91
+ private readonly layout: { location: number; buffer: WebGLBuffer; size: number }[] = [];
92
+ private indexBuffer: WebGLBuffer | null = null;
98
93
  /**
99
94
  * The position and normal buffers, kept so `update` can rewrite them.
100
95
  *
@@ -104,7 +99,8 @@ export class Mesh {
104
99
  private readonly deformable: { positions: WebGLBuffer; normals: WebGLBuffer } | null = null;
105
100
  /** How many floats the position buffer holds, so an update of the wrong size is refused. */
106
101
  private readonly positionFloats: number;
107
- private readonly indexCount: number;
102
+ /** Indices one draw of it issues; the batch-size rule reads it (`cullsInstances`). */
103
+ readonly indexCount: number;
108
104
  /**
109
105
  * The constant values this mesh's *absent* attributes are shaded with, re-applied on
110
106
  * every draw.
@@ -227,7 +223,13 @@ export class Mesh {
227
223
  if (data.uvs === undefined) {
228
224
  gl.disableVertexAttribArray(ATTR_UV);
229
225
  this.constants.push({ location: ATTR_UV, value: ABSENT_ATTRIBUTE['uvs'] });
226
+ } else if (data.layers !== undefined) {
227
+ /* The texture-array layer rides as the third coordinate — every vertex location is spent —
228
+ so a layered mesh uploads (u, v, layer) packed. See `MeshData.layers`. */
229
+ this.attachAttribute(gl, ATTR_UV, packUvLayers(data.uvs, data.layers), 3);
230
230
  } else {
231
+ /* Two components; the shader's vec3 reads z as 0 by the GL rule for a missing component,
232
+ which is layer 0 — the only layer a plain texture has. */
231
233
  this.attachAttribute(gl, ATTR_UV, data.uvs, 2);
232
234
  }
233
235
 
@@ -313,6 +315,7 @@ export class Mesh {
313
315
  if (indexBuffer === null) throw new Error('createBuffer failed');
314
316
  this.buffers.push(indexBuffer);
315
317
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
318
+ this.indexBuffer = indexBuffer;
316
319
  this.allocate(gl, gl.ELEMENT_ARRAY_BUFFER, indexBuffer, data.indices, gl.STATIC_DRAW);
317
320
 
318
321
  gl.bindVertexArray(null);
@@ -322,28 +325,11 @@ export class Mesh {
322
325
  draw(gl: WebGL2RenderingContext): void {
323
326
  gl.bindVertexArray(this.vao);
324
327
  /*
325
- * **A mesh with a batch has the instance columns enabled on this very array, and location 13
326
- * is the channel.** `attachInstances` claims 11 to 14 for the model matrix and 15 for the
327
- * tint; a `Mesh` owns one vertex array, so those bindings are still live for a *plain* draw
328
- * through it — and `aChannel` then reads the third column of instance zero's matrix instead
329
- * of the absent-attribute constant. For a placement with no rotation that column is
330
- * `(0, 0, 1, 0)`, so `aChannel.y` is zero, `vSkyDirect = aChannel.y` scales the directional
331
- * term to nothing, and the mesh renders lit by the hemispheric ambient alone — correct
332
- * normals, correct albedo, no error, no warning.
333
- *
334
- * Found on `demo/instancing.ts`, which exists to draw two ranks of one box the two ways and
335
- * compare them: the `drawMesh` rank's top face read (51, 30, 21) against the `drawInstanced`
336
- * rank's (218, 110, 59), and 51 is exactly the ambient. WebGPU has no array to share and drew
337
- * them alike, which is what made it visible at all.
338
- *
339
- * Disabled here and put back by `drawInstances`, rather than given the batch an array of its
340
- * own: that is the larger change this class's own note about two batches already contemplates,
341
- * and it wants one for a different reason. Three calls on a mesh that has a batch and none on
342
- * a mesh that does not.
328
+ * No instance columns to disable: a batch draws through a vertex array of its own
329
+ * (`createInstanceArray`), so this array never carries them. It used to, and location 13 — the
330
+ * channel — then read the third column of instance zero's matrix on a plain draw, which lit a
331
+ * mesh by ambient alone on this backend and not the other.
343
332
  */
344
- if (this.instanced) {
345
- for (const location of INSTANCE_COLUMNS) gl.disableVertexAttribArray(location);
346
- }
347
333
  // The VAO restores the arrays; these it cannot. See `constants`.
348
334
  for (const constant of this.constants) {
349
335
  const value = constant.value;
@@ -353,35 +339,30 @@ export class Mesh {
353
339
  }
354
340
 
355
341
  /**
356
- * Bind one buffer's per-instance attributes to this mesh's vertex array.
342
+ * A vertex array for one instanced batch: every attribute this mesh binds from a buffer, its index
343
+ * buffer, and the batch's instance columns — the model matrix at 11 to 14, the tint at 15.
357
344
  *
358
- * **One batch per mesh, and the vertex array is why.** A `Mesh` owns exactly one, so a second
359
- * batch would rebind locations 11 to 15 on the array the first still draws through — and what
360
- * comes out is one batch's instances placed by the other's matrices, which reads as a transform
361
- * bug in the consumer's own code rather than as a misuse of this.
362
- *
363
- * **What would make it wrong** is a consumer legitimately wanting two batches of one mesh,
364
- * which wants a vertex array per batch rather than a flag here.
345
+ * **A batch owns its own array**, which is what lets a mesh have more than one batch: a prop drawn
346
+ * once per region is one mesh and a batch per region. With one array a mesh had one batch, since a
347
+ * second would rebind the columns the first draws through and place one batch's instances by the
348
+ * other's matrices. The arrays share every buffer, so a batch costs its instances and nothing of
349
+ * the geometry. The caller deletes what this returns.
365
350
  */
366
- attachInstances(gl: WebGL2RenderingContext, buffer: WebGLBuffer, stride: number): void {
367
- if (this.instanced) {
368
- throw new Error(
369
- 'Mesh: this mesh already has an instanced batch — a Mesh owns one vertex array, so a ' +
370
- 'second batch would rebind the attributes the first draws through.',
371
- );
351
+ createInstanceArray(
352
+ gl: WebGL2RenderingContext,
353
+ instances: WebGLBuffer,
354
+ stride: number,
355
+ ): WebGLVertexArrayObject {
356
+ const vao = gl.createVertexArray();
357
+ if (vao === null) throw new Error('createVertexArray failed');
358
+ gl.bindVertexArray(vao);
359
+ for (const attribute of this.layout) {
360
+ gl.bindBuffer(gl.ARRAY_BUFFER, attribute.buffer);
361
+ gl.enableVertexAttribArray(attribute.location);
362
+ gl.vertexAttribPointer(attribute.location, attribute.size, gl.FLOAT, false, 0, 0);
372
363
  }
373
- this.instanced = true;
374
- this.instanceBuffer = buffer;
375
- this.instanceStride = stride;
376
- gl.bindVertexArray(this.vao);
377
- this.bindInstanceColumns(gl, buffer);
378
- gl.bindVertexArray(null);
379
- }
380
-
381
- /** The instance columns, bound. Called at attach and again after a plain draw disabled them. */
382
- private bindInstanceColumns(gl: WebGL2RenderingContext, buffer: WebGLBuffer): void {
383
- const stride = this.instanceStride;
384
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
364
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
365
+ gl.bindBuffer(gl.ARRAY_BUFFER, instances);
385
366
  /* Four vec4 columns then a vec3 tint: a mat4 is not a vertex attribute in GLSL ES 300. */
386
367
  for (let column = 0; column < 4; column += 1) {
387
368
  const location = 11 + column;
@@ -392,24 +373,17 @@ export class Mesh {
392
373
  gl.enableVertexAttribArray(15);
393
374
  gl.vertexAttribPointer(15, 3, gl.FLOAT, false, stride, 64);
394
375
  gl.vertexAttribDivisor(15, 1);
376
+ gl.bindVertexArray(null);
377
+ return vao;
395
378
  }
396
379
 
397
- private instanceBuffer: WebGLBuffer | null = null;
398
- private instanceStride = 0;
399
-
400
- /** Whether `attachInstances` has already claimed locations 11 to 15 on this mesh's array. */
401
- private instanced = false;
402
-
403
- /**
404
- * Draw `count` instances through the attributes `attachInstances` bound.
405
- *
406
- * The constants are re-applied exactly as `draw` does — a vertex array restores the arrays and
407
- * not the constant attributes, and an absent optional attribute is a constant here.
408
- */
409
- drawInstances(gl: WebGL2RenderingContext, count: number): void {
410
- gl.bindVertexArray(this.vao);
411
- /* Put back what `draw` disabled, if a plain draw of this mesh came first. See its note. */
412
- if (this.instanceBuffer !== null) this.bindInstanceColumns(gl, this.instanceBuffer);
380
+ /** Draw `count` instances through a batch's own vertex array. See `createInstanceArray`. */
381
+ drawInstancesThrough(
382
+ gl: WebGL2RenderingContext,
383
+ vao: WebGLVertexArrayObject,
384
+ count: number,
385
+ ): void {
386
+ gl.bindVertexArray(vao);
413
387
  for (const constant of this.constants) {
414
388
  const value = constant.value;
415
389
  applyConstant(gl, constant.location, value);
@@ -472,6 +446,7 @@ export class Mesh {
472
446
  this.allocate(gl, gl.ARRAY_BUFFER, buffer, data, usage);
473
447
  gl.enableVertexAttribArray(location);
474
448
  gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0);
449
+ this.layout.push({ location, buffer, size });
475
450
  return buffer;
476
451
  }
477
452
 
@@ -579,3 +554,15 @@ export function createMeshIncremental(
579
554
  const mesh = new Mesh(gl, data, dynamic, true);
580
555
  return { mesh, upload: mesh.uploads(gl) };
581
556
  }
557
+
558
+ /** Texture coordinates and layers interleaved as (u, v, layer), one row a vertex. */
559
+ export function packUvLayers(uvs: Float32Array, layers: Float32Array): Float32Array {
560
+ const count = layers.length;
561
+ const packed = new Float32Array(count * 3);
562
+ for (let v = 0; v < count; v++) {
563
+ packed[v * 3] = uvs[v * 2] as number;
564
+ packed[v * 3 + 1] = uvs[v * 2 + 1] as number;
565
+ packed[v * 3 + 2] = layers[v] as number;
566
+ }
567
+ return packed;
568
+ }
@@ -39,6 +39,9 @@ import type { Bounds } from '../math/bounds.ts';
39
39
  /** What an unwritten texel holds: the far plane, which occludes nothing. */
40
40
  const FAR = 1;
41
41
 
42
+ /** Texels a tested rectangle may span at the level it is read from. See `boxBehind`. */
43
+ const TEXELS_ACROSS = 8;
44
+
42
45
  export interface OcclusionOptions {
43
46
  /** Texels across. The height follows the aspect. */
44
47
  readonly width: number;
@@ -189,15 +192,47 @@ export class OcclusionBuffer {
189
192
  const radius = bounds.radius * Math.max(sx, sy, sz);
190
193
 
191
194
  /* The cube that contains the sphere, which contains the object. Conservative twice over. */
195
+ return this.boxBehind(x - radius, y - radius, z - radius, x + radius, y + radius, z + radius);
196
+ }
197
+
198
+ /**
199
+ * Whether a world-space box is entirely behind the declared occluders — the tighter question for
200
+ * something that is a box, where the cube around its sphere reaches far past it. **A flat region
201
+ * is the case it exists for**: a city block's sphere is half as deep underground as it is wide,
202
+ * nothing there occludes it, and the sphere test abstains for every block in the city. False
203
+ * whenever anything is uncertain, exactly as `occluded`.
204
+ */
205
+ occludedBox(min: ArrayLike<number>, max: ArrayLike<number>): boolean {
206
+ if (this.occluders === 0) return false;
207
+ if (!this.built) this.buildPyramid();
208
+ return this.boxBehind(
209
+ min[0] ?? 0,
210
+ min[1] ?? 0,
211
+ min[2] ?? 0,
212
+ max[0] ?? 0,
213
+ max[1] ?? 0,
214
+ max[2] ?? 0,
215
+ );
216
+ }
217
+
218
+ /** The shared test: a world box's corners projected, its rectangle tested against the pyramid. */
219
+ private boxBehind(
220
+ loX: number,
221
+ loY: number,
222
+ loZ: number,
223
+ hiX: number,
224
+ hiY: number,
225
+ hiZ: number,
226
+ ): boolean {
192
227
  let minX = Infinity;
193
228
  let minY = Infinity;
194
229
  let minZ = Infinity;
195
230
  let maxX = -Infinity;
196
231
  let maxY = -Infinity;
197
232
  for (let corner = 0; corner < 8; corner++) {
198
- const px = corner & 1 ? x + radius : x - radius;
199
- const py = corner & 2 ? y + radius : y - radius;
200
- const pz = corner & 4 ? z + radius : z - radius;
233
+ const px = corner & 1 ? hiX : loX;
234
+ const py = corner & 2 ? hiY : loY;
235
+ const pz = corner & 4 ? hiZ : loZ;
201
236
  if (!this.project(px, py, pz, 0)) return false;
202
237
  minX = Math.min(minX, CORNERS[0] ?? 0);
203
238
  maxX = Math.max(maxX, CORNERS[0] ?? 0);
@@ -212,13 +247,22 @@ export class OcclusionBuffer {
212
247
  if (minX < 0 || minY < 0 || maxX > width || maxY > height) return false;
213
248
 
214
249
  /*
215
- * The coarsest level whose texel is at least as wide as the rectangle, so the test reads at
216
- * most two texels each way. `Math.log2` would say the same thing and is one of the calls this
217
- * repository's determinism gate refuses elsewhere; a shift loop says it in integers.
250
+ * The coarsest level at which the rectangle spans at most `TEXELS_ACROSS` texels, so the test
251
+ * reads at most that many plus one each way. `Math.log2` would say the same thing and is one of
252
+ * the calls this repository's determinism gate refuses elsewhere; a shift loop says it in integers.
253
+ *
254
+ * **Eight, not one, since 2026-09-30.** At one the level's texel was as wide as the rectangle, and
255
+ * two such texels aligned to the grid span up to twice it — so anything whose rectangle came near
256
+ * an occluder's edge, or the buffer's own unwritten border row, read unwritten texels and
257
+ * abstained. That was every city region behind a row of buildings, whose rectangle sits just
258
+ * under the skyline; measured on a box seven metres wide behind a wall filling the view, which
259
+ * one abstained at and eight culls. Reading up to 81 texels instead of 4 costs a few dozen
260
+ * compares and keeps the answer exactly as conservative: the maximum over every texel the
261
+ * rectangle touches.
218
262
  */
219
263
  let level = 0;
220
264
  let span = Math.max(maxX - minX, maxY - minY);
221
- while (span > 1 && level + 1 < this.levels.length) {
265
+ while (span > TEXELS_ACROSS && level + 1 < this.levels.length) {
222
266
  span *= 0.5;
223
267
  level++;
224
268
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from './clusteredLights.ts';
4
4
  import { MAX_POINT_LIGHTS, POINT_SHADOW_POOL } from './lightBudget.ts';
5
+ import { gatherLights } from './lightGrid.ts';
6
+ import type { LightGrid } from './lightGrid.ts';
5
7
 
6
8
  export const DEFAULT_POINT_LIGHT_VIEW_RANGE = 180;
7
9
 
@@ -43,8 +45,15 @@ const FADE_BAND_M = 40;
43
45
  */
44
46
  const CONTENTION_BAND_M = 3;
45
47
 
46
- /** Sort keys for the shadow list, which is ordered independently of the shaded one. */
47
- const shadowRank = new Float32Array(POINT_SHADOW_POOL);
48
+ /**
49
+ * Sort keys for the shadow list, which is ordered independently of the shaded one.
50
+ *
51
+ * **Float64, and ties broken by source index**, the shaded list's rule. These were single precision
52
+ * compared against a double, so which of two lamps exactly as far away came first depended on
53
+ * which way the distance happened to round — and on the order the lamps were met, which is what a
54
+ * grid changes. Now the list is the same whatever order its candidates arrive in.
55
+ */
56
+ const shadowRank = new Float64Array(POINT_SHADOW_POOL);
48
57
 
49
58
  /**
50
59
  * Every light in range this frame, by source index and squared distance, before the nearest are
@@ -344,6 +353,58 @@ export function selectPointLights(
344
353
  castX = x,
345
354
  castY = y,
346
355
  castZ = z,
356
+ ): void {
357
+ choose(sources, null, sources.length, x, y, z, out, timeSeconds, viewRange, castX, castY, castZ);
358
+ }
359
+
360
+ /**
361
+ * `selectPointLights` over a grid's sources, looking only at the lights near the eye: the same
362
+ * choice, tie for tie, for a world of thousands of fixed lights that the plain scan would visit
363
+ * every frame. See `lightGrid.ts` for why the result is the scan's exactly.
364
+ */
365
+ export function selectGridLights(
366
+ grid: LightGrid,
367
+ x: number,
368
+ y: number,
369
+ z: number,
370
+ out: PointLightBuffer,
371
+ timeSeconds = 0,
372
+ viewRange = DEFAULT_POINT_LIGHT_VIEW_RANGE,
373
+ castX = x,
374
+ castY = y,
375
+ castZ = z,
376
+ ): void {
377
+ const count = gatherLights(grid, x, y, z, viewRange + grid.maxRadius);
378
+ choose(
379
+ grid.sources,
380
+ grid.gathered,
381
+ count,
382
+ x,
383
+ y,
384
+ z,
385
+ out,
386
+ timeSeconds,
387
+ viewRange,
388
+ castX,
389
+ castY,
390
+ castZ,
391
+ );
392
+ }
393
+
394
+ /** The choice itself, over every source or over the ones `visit` names, in source order. */
395
+ function choose(
396
+ sources: readonly PointLightSource[],
397
+ visit: Int32Array | null,
398
+ visitCount: number,
399
+ x: number,
400
+ y: number,
401
+ z: number,
402
+ out: PointLightBuffer,
403
+ timeSeconds: number,
404
+ viewRange: number,
405
+ castX: number,
406
+ castY: number,
407
+ castZ: number,
347
408
  ): void {
348
409
  out.count = 0;
349
410
  out.shadowCount = 0;
@@ -362,7 +423,8 @@ export function selectPointLights(
362
423
  let cutSq = Infinity;
363
424
  let candidateCount = 0;
364
425
 
365
- for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) {
426
+ for (let visited = 0; visited < visitCount; visited++) {
427
+ const sourceIndex = visit === null ? visited : (visit[visited] as number);
366
428
  const light = sources[sourceIndex];
367
429
  if (light === undefined) continue;
368
430
  /*
@@ -431,7 +493,13 @@ export function selectPointLights(
431
493
  const castDistanceSq = cx * cx + cy * cy + cz * cz;
432
494
  if (castDistanceSq >= light.radius * light.radius) continue;
433
495
  let shadowSlot = out.shadowCount;
434
- while (shadowSlot > 0 && (shadowRank[shadowSlot - 1] ?? 0) > castDistanceSq) shadowSlot--;
496
+ while (
497
+ shadowSlot > 0 &&
498
+ ((shadowRank[shadowSlot - 1] ?? 0) > castDistanceSq ||
499
+ ((shadowRank[shadowSlot - 1] ?? 0) === castDistanceSq &&
500
+ (out.shadowIndex[shadowSlot - 1] ?? -1) > sourceIndex))
501
+ )
502
+ shadowSlot--;
435
503
  if (shadowSlot >= POINT_SHADOW_POOL) continue;
436
504
  const lastShadow = Math.min(out.shadowCount, POINT_SHADOW_POOL - 1);
437
505
  for (let index = lastShadow; index > shadowSlot; index--) {