@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
@@ -142,7 +142,12 @@ export function createSdfTextBindGroupLayout(device: GPUDevice): GPUBindGroupLay
142
142
  visibility: VISIBILITY_FRAGMENT,
143
143
  buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: SDF_TEXT_FRAG_SIZE },
144
144
  },
145
- { binding: ATLAS.texture, visibility: VISIBILITY_FRAGMENT, texture: {} },
145
+ /* The atlas is a surface texture, and every surface texture is a `2d-array` view. */
146
+ {
147
+ binding: ATLAS.texture,
148
+ visibility: VISIBILITY_FRAGMENT,
149
+ texture: { viewDimension: '2d-array' },
150
+ },
146
151
  { binding: ATLAS.sampler, visibility: VISIBILITY_FRAGMENT, sampler: {} },
147
152
  ],
148
153
  });
@@ -0,0 +1,61 @@
1
+ import { INSTANCE_FLOATS } from '../../../instances.ts';
2
+
3
+ /**
4
+ * The instance cull, in WGSL, hand-authored under the compute exception `AGENTS.md` records
5
+ * (2026-08-24): a compute shader has no WebGL2 twin to generate from, and `npm run wgsl` neither
6
+ * writes nor polices this file.
7
+ *
8
+ * **It keeps exactly what `cullInstances` keeps**, and `gpu-parity.mjs` runs both on a device: an
9
+ * instance's world sphere is the mesh's sphere with its centre through the model matrix and its
10
+ * radius scaled by the largest column, and it is kept unless wholly beyond one of the six planes.
11
+ * The one difference is order — survivors are compacted by `atomicAdd`, so they arrive in whatever
12
+ * order the invocations reached it. That is invisible to an opaque draw and is why a blended draw
13
+ * is never culled here.
14
+ *
15
+ * `args` is a `drawIndexedIndirect` record, `(indexCount, instanceCount, firstIndex, baseVertex,
16
+ * firstInstance)`; the renderer writes it with a zero instance count before each dispatch and this
17
+ * counts the survivors into it.
18
+ */
19
+ export const INSTANCE_CULL_WORKGROUP = 64;
20
+
21
+ export const INSTANCE_CULL_WGSL = /* wgsl */ `
22
+ struct Params {
23
+ planes: array<vec4<f32>, 6>,
24
+ sphere: vec4<f32>,
25
+ count: u32,
26
+ pad0: u32,
27
+ pad1: u32,
28
+ pad2: u32,
29
+ }
30
+
31
+ @group(0) @binding(0) var<uniform> params: Params;
32
+ @group(0) @binding(1) var<storage, read> source: array<f32>;
33
+ @group(0) @binding(2) var<storage, read_write> kept: array<f32>;
34
+ @group(0) @binding(3) var<storage, read_write> args: array<atomic<u32>, 5>;
35
+
36
+ const STRIDE: u32 = ${INSTANCE_FLOATS}u;
37
+
38
+ @compute @workgroup_size(${INSTANCE_CULL_WORKGROUP})
39
+ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
40
+ let i = id.x;
41
+ if (i >= params.count) { return; }
42
+ let at = i * STRIDE;
43
+ let c0 = vec3<f32>(source[at], source[at + 1u], source[at + 2u]);
44
+ let c1 = vec3<f32>(source[at + 4u], source[at + 5u], source[at + 6u]);
45
+ let c2 = vec3<f32>(source[at + 8u], source[at + 9u], source[at + 10u]);
46
+ let t = vec3<f32>(source[at + 12u], source[at + 13u], source[at + 14u]);
47
+ let local = params.sphere;
48
+ let centre = c0 * local.x + c1 * local.y + c2 * local.z + t;
49
+ let scale = sqrt(max(dot(c0, c0), max(dot(c1, c1), dot(c2, c2))));
50
+ let radius = local.w * scale;
51
+ for (var p = 0u; p < 6u; p = p + 1u) {
52
+ let plane = params.planes[p];
53
+ if (dot(plane.xyz, centre) + plane.w < -radius) { return; }
54
+ }
55
+ let slot = atomicAdd(&args[1], 1u);
56
+ let to = slot * STRIDE;
57
+ for (var k = 0u; k < STRIDE; k = k + 1u) {
58
+ kept[to + k] = source[at + k];
59
+ }
60
+ }
61
+ `;
@@ -1,4 +1,7 @@
1
1
  import type { SurfaceTextureOptions } from '../../surfaceTexture.ts';
2
+ import { SURFACE_EFFECT_TEXELS, packSurfaceEffects } from '../../surfaceEffects.ts';
3
+ import { toHalfFloats } from '../../halfFloat.ts';
4
+ import { isSourceList, layerSize, refuseArrayUpdate, sourceSize } from '../../textureSource.ts';
2
5
  import type { PipelineCache } from './pipelineCache.ts';
3
6
  import { shaderModule } from './shaderModules.ts';
4
7
 
@@ -89,6 +92,8 @@ export function generateMipChain(
89
92
  texture: GPUTexture,
90
93
  format: GPUTextureFormat,
91
94
  levels: number,
95
+ /** How many array layers to fill, each its own chain. One for a plain texture. */
96
+ layers = 1,
92
97
  ): void {
93
98
  if (levels < 2) return;
94
99
  const pipeline = mipBlitPipeline(device, pipelines, format);
@@ -98,29 +103,33 @@ export function generateMipChain(
98
103
  minFilter: 'linear',
99
104
  });
100
105
  const encoder = device.createCommandEncoder({ label: 'mip.chain' });
101
- for (let level = 1; level < levels; level++) {
102
- const source = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
103
- const target = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
104
- const pass = encoder.beginRenderPass({
105
- label: `mip.level${level}`,
106
- colorAttachments: [
107
- { view: target, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
108
- ],
109
- });
110
- pass.setPipeline(pipeline);
111
- pass.setBindGroup(
112
- 0,
113
- device.createBindGroup({
114
- layout: pipeline.getBindGroupLayout(0),
115
- entries: [
116
- { binding: 0, resource: source },
117
- { binding: 1, resource: sampler },
106
+ /* One level of one layer at a time: a render target is a single 2D subresource, and the source
107
+ view is one level of the same layer, so no pass reads what it writes. */
108
+ for (let layer = 0; layer < layers; layer++)
109
+ for (let level = 1; level < levels; level++) {
110
+ const one = { dimension: '2d' as const, baseArrayLayer: layer, arrayLayerCount: 1 };
111
+ const source = texture.createView({ ...one, baseMipLevel: level - 1, mipLevelCount: 1 });
112
+ const target = texture.createView({ ...one, baseMipLevel: level, mipLevelCount: 1 });
113
+ const pass = encoder.beginRenderPass({
114
+ label: `mip.level${level}`,
115
+ colorAttachments: [
116
+ { view: target, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 0] },
118
117
  ],
119
- }),
120
- );
121
- pass.draw(3);
122
- pass.end();
123
- }
118
+ });
119
+ pass.setPipeline(pipeline);
120
+ pass.setBindGroup(
121
+ 0,
122
+ device.createBindGroup({
123
+ layout: pipeline.getBindGroupLayout(0),
124
+ entries: [
125
+ { binding: 0, resource: source },
126
+ { binding: 1, resource: sampler },
127
+ ],
128
+ }),
129
+ );
130
+ pass.draw(3);
131
+ pass.end();
132
+ }
124
133
  device.queue.submit([encoder.finish()]);
125
134
  }
126
135
 
@@ -149,19 +158,11 @@ export function mipBlitPipeline(
149
158
  }));
150
159
  }
151
160
 
152
- function sourceSize(source: TexImageSource): { width: number; height: number } {
153
- const candidate = source as {
154
- width?: number;
155
- height?: number;
156
- videoWidth?: number;
157
- videoHeight?: number;
158
- };
159
- const width = candidate.videoWidth ?? candidate.width ?? 0;
160
- const height = candidate.videoHeight ?? candidate.height ?? 0;
161
- return { width: Math.max(1, width), height: Math.max(1, height) };
162
- }
163
-
164
- /** An image uploaded once and bound per material. */
161
+ /**
162
+ * An image uploaded once and bound per material — or several of one size, as an array's layers.
163
+ * Every surface texture is a `2d-array` view, a plain image an array of one; `surfaceTexture.ts`
164
+ * says why.
165
+ */
165
166
  export class GpuSurfaceTexture {
166
167
  readonly sampler: GPUSampler;
167
168
 
@@ -172,14 +173,22 @@ export class GpuSurfaceTexture {
172
173
  private levels: number;
173
174
  private width: number;
174
175
  private height: number;
176
+ /** How many images the array holds. */
177
+ readonly layers: number;
178
+ /** The per-layer effects table, or null for a texture given none. See `surfaceEffects.ts`. */
179
+ private effectsTable: GPUTexture | null = null;
180
+ /** Its view, which the flat group binds beside this image when it is a material's albedo. */
181
+ effectsView: GPUTextureView | null = null;
175
182
 
176
183
  constructor(
177
184
  private readonly device: GPUDevice,
178
185
  private readonly pipelines: PipelineCache,
179
- source: TexImageSource,
186
+ source: TexImageSource | readonly TexImageSource[],
180
187
  options: SurfaceTextureOptions = {},
181
188
  ) {
182
- const { width, height } = sourceSize(source);
189
+ const sources: readonly TexImageSource[] = isSourceList(source) ? source : [source];
190
+ const { width, height } = layerSize(sources);
191
+ this.layers = sources.length;
183
192
  this.mipmapped = options.mipmap ?? true;
184
193
  /* `SRGB8_ALPHA8`'s equivalent. Decoded in the sampler, before filtering, which is the only
185
194
  place it is correct — `surfaceTexture.ts` makes the argument in full. */
@@ -188,7 +197,7 @@ export class GpuSurfaceTexture {
188
197
  this.height = height;
189
198
  this.levels = this.mipmapped ? mipLevelCount(width, height) : 1;
190
199
  this.texture = this.allocate();
191
- this.current = this.texture.createView();
200
+ this.current = this.arrayView(this.texture);
192
201
 
193
202
  const wrap: GPUAddressMode =
194
203
  (options.wrap ?? 'repeat') === 'repeat' ? 'repeat' : 'clamp-to-edge';
@@ -223,7 +232,32 @@ export class GpuSurfaceTexture {
223
232
  : 1,
224
233
  });
225
234
 
226
- this.upload(source);
235
+ this.upload(sources);
236
+
237
+ /* Half floats, as `renderer.ts` uploads it: filterable, so the ordinary float layout takes it,
238
+ and read by `textureLoad` alone, so the filter never runs. */
239
+ const effects = options.effects ?? [];
240
+ if (effects.length > 0) {
241
+ const table = device.createTexture({
242
+ label: 'surface.effects',
243
+ size: [SURFACE_EFFECT_TEXELS, effects.length],
244
+ format: 'rgba16float',
245
+ usage: 0x4 | 0x2, // TEXTURE_BINDING | COPY_DST
246
+ });
247
+ device.queue.writeTexture(
248
+ { texture: table },
249
+ toHalfFloats(packSurfaceEffects(effects)),
250
+ { bytesPerRow: SURFACE_EFFECT_TEXELS * 8, rowsPerImage: effects.length },
251
+ { width: SURFACE_EFFECT_TEXELS, height: effects.length, depthOrArrayLayers: 1 },
252
+ );
253
+ this.effectsTable = table;
254
+ this.effectsView = table.createView();
255
+ }
256
+ }
257
+
258
+ /** A view of every layer, as the shaders' `texture_2d_array` bindings read it. */
259
+ private arrayView(texture: GPUTexture): GPUTextureView {
260
+ return texture.createView({ dimension: '2d-array', arrayLayerCount: this.layers });
227
261
  }
228
262
 
229
263
  /** The view every binding of this image holds. A new one when `update` changes the size. */
@@ -248,24 +282,25 @@ export class GpuSurfaceTexture {
248
282
  update(source: TexImageSource): GPUTexture | null {
249
283
  const replaced = this.texture;
250
284
  if (replaced === null) return null;
285
+ refuseArrayUpdate(this.layers);
251
286
  const { width, height } = sourceSize(source);
252
287
  if (width === this.width && height === this.height) {
253
- this.upload(source);
288
+ this.upload([source]);
254
289
  return null;
255
290
  }
256
291
  this.width = width;
257
292
  this.height = height;
258
293
  this.levels = this.mipmapped ? mipLevelCount(width, height) : 1;
259
294
  this.texture = this.allocate();
260
- this.current = this.texture.createView();
261
- this.upload(source);
295
+ this.current = this.arrayView(this.texture);
296
+ this.upload([source]);
262
297
  return replaced;
263
298
  }
264
299
 
265
300
  private allocate(): GPUTexture {
266
301
  return this.device.createTexture({
267
302
  label: 'surface.texture',
268
- size: [this.width, this.height],
303
+ size: [this.width, this.height, this.layers],
269
304
  format: this.format,
270
305
  mipLevelCount: this.levels,
271
306
  /*
@@ -290,9 +325,12 @@ export class GpuSurfaceTexture {
290
325
  if (this.texture === null) return;
291
326
  this.texture.destroy();
292
327
  this.texture = null;
328
+ this.effectsTable?.destroy();
329
+ this.effectsTable = null;
330
+ this.effectsView = null;
293
331
  }
294
332
 
295
- private upload(source: TexImageSource): void {
333
+ private upload(sources: readonly TexImageSource[]): void {
296
334
  const texture = this.texture;
297
335
  if (texture === null) return;
298
336
  /*
@@ -306,11 +344,13 @@ export class GpuSurfaceTexture {
306
344
  * The orientation check that passed this backend was run on a canvas alone, where the two
307
345
  * agreed. Measure both source types, or a texture pipeline is half tested.
308
346
  */
309
- this.device.queue.copyExternalImageToTexture(
310
- { source: source as GPUCopyExternalImageSource, flipY: false },
311
- { texture },
312
- [this.width, this.height],
313
- );
347
+ for (let layer = 0; layer < sources.length; layer++) {
348
+ this.device.queue.copyExternalImageToTexture(
349
+ { source: sources[layer] as GPUCopyExternalImageSource, flipY: false },
350
+ { texture, origin: [0, 0, layer] },
351
+ [this.width, this.height],
352
+ );
353
+ }
314
354
  if (this.levels > 1) this.generateMips(texture);
315
355
  }
316
356
 
@@ -322,6 +362,6 @@ export class GpuSurfaceTexture {
322
362
  * an attachment and a resource at once — rejected, and rejected silently enough to matter.
323
363
  */
324
364
  private generateMips(texture: GPUTexture): void {
325
- generateMipChain(this.device, this.pipelines, texture, this.format, this.levels);
365
+ generateMipChain(this.device, this.pipelines, texture, this.format, this.levels, this.layers);
326
366
  }
327
367
  }
@@ -57,69 +57,92 @@ export function bakeBrick(
57
57
  for (let k = 0; k < BRICK_SAMPLES; k++) {
58
58
  for (let j = 0; j < BRICK_SAMPLES; j++) {
59
59
  for (let i = 0; i < BRICK_SAMPLES; i++) {
60
- const texel = (i + BRICK_SAMPLES * (j + BRICK_SAMPLES * k)) * 4;
61
- const px = bx + i * spacing;
62
- const py = by + j * spacing;
63
- const pz = bz + k * spacing;
64
- light.fill(0, texel, texel + 4);
65
- direction.fill(0, texel, texel + 4);
66
- if (distance !== null && distance(px, py, pz) < 0) continue;
67
-
68
- let r = 0;
69
- let g = 0;
70
- let b = 0;
71
- let towardX = 0;
72
- let towardY = 0;
73
- let towardZ = 0;
74
- let weight = 0;
75
- for (let listed = first; listed < last; listed++) {
76
- const source = lights[layout.lights[listed] as number];
77
- if (source === undefined) continue;
78
- const dx = source.x - px;
79
- const dy = source.y - py;
80
- const dz = source.z - pz;
81
- const dist = Math.hypot(dx, dy, dz);
82
- if (dist >= source.radius) continue;
83
- const shape = pointLightShape(dist, source.radius, falloff);
84
- if (shape <= 0) continue;
85
- const seen =
86
- distance === null
87
- ? 1
88
- : softVisibility(
89
- distance,
90
- px,
91
- py,
92
- pz,
93
- source.x,
94
- source.y,
95
- source.z,
96
- source.sourceRadius,
97
- );
98
- if (seen <= 0) continue;
99
- const lr = source.r * shape * seen;
100
- const lg = source.g * shape * seen;
101
- const lb = source.b * shape * seen;
102
- r += lr;
103
- g += lg;
104
- b += lb;
105
- const bright = 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
106
- if (dist > 1e-6 && bright > 0) {
107
- towardX += (bright * dx) / dist;
108
- towardY += (bright * dy) / dist;
109
- towardZ += (bright * dz) / dist;
110
- weight += bright;
111
- }
112
- }
113
- light[texel] = r;
114
- light[texel + 1] = g;
115
- light[texel + 2] = b;
116
- light[texel + 3] = 1;
117
- if (weight > 0) {
118
- direction[texel] = towardX / weight;
119
- direction[texel + 1] = towardY / weight;
120
- direction[texel + 2] = towardZ / weight;
121
- }
60
+ sampleFieldLight(
61
+ bx + i * spacing,
62
+ by + j * spacing,
63
+ bz + k * spacing,
64
+ lights,
65
+ layout.lights,
66
+ first,
67
+ last,
68
+ falloff,
69
+ distance,
70
+ light,
71
+ direction,
72
+ (i + BRICK_SAMPLES * (j + BRICK_SAMPLES * k)) * 4,
73
+ );
122
74
  }
123
75
  }
124
76
  }
125
77
  }
78
+
79
+ /**
80
+ * One sample: the light arriving at (px, py, pz) from `lights[candidates[first..last)]`, written at
81
+ * `texel` in `light` (colour, then 1 for a sample and 0 for one inside solid geometry) and in
82
+ * `direction` (the brightness-weighted way it arrives from). Shared by every bake, so a brick and a
83
+ * dense volume cannot come to disagree about what a sample is.
84
+ */
85
+ export function sampleFieldLight(
86
+ px: number,
87
+ py: number,
88
+ pz: number,
89
+ lights: readonly FieldLight[],
90
+ candidates: ArrayLike<number>,
91
+ first: number,
92
+ last: number,
93
+ falloff: PointLightFalloff,
94
+ distance: DistanceAt | null,
95
+ light: Float32Array,
96
+ direction: Float32Array,
97
+ texel: number,
98
+ ): void {
99
+ light.fill(0, texel, texel + 4);
100
+ direction.fill(0, texel, texel + 4);
101
+ if (distance !== null && distance(px, py, pz) < 0) return;
102
+
103
+ let r = 0;
104
+ let g = 0;
105
+ let b = 0;
106
+ let towardX = 0;
107
+ let towardY = 0;
108
+ let towardZ = 0;
109
+ let weight = 0;
110
+ for (let listed = first; listed < last; listed++) {
111
+ const source = lights[candidates[listed] as number];
112
+ if (source === undefined) continue;
113
+ const dx = source.x - px;
114
+ const dy = source.y - py;
115
+ const dz = source.z - pz;
116
+ const dist = Math.hypot(dx, dy, dz);
117
+ if (dist >= source.radius) continue;
118
+ const shape = pointLightShape(dist, source.radius, falloff);
119
+ if (shape <= 0) continue;
120
+ const seen =
121
+ distance === null
122
+ ? 1
123
+ : softVisibility(distance, px, py, pz, source.x, source.y, source.z, source.sourceRadius);
124
+ if (seen <= 0) continue;
125
+ const lr = source.r * shape * seen;
126
+ const lg = source.g * shape * seen;
127
+ const lb = source.b * shape * seen;
128
+ r += lr;
129
+ g += lg;
130
+ b += lb;
131
+ const bright = 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
132
+ if (dist > 1e-6 && bright > 0) {
133
+ towardX += (bright * dx) / dist;
134
+ towardY += (bright * dy) / dist;
135
+ towardZ += (bright * dz) / dist;
136
+ weight += bright;
137
+ }
138
+ }
139
+ light[texel] = r;
140
+ light[texel + 1] = g;
141
+ light[texel + 2] = b;
142
+ light[texel + 3] = 1;
143
+ if (weight > 0) {
144
+ direction[texel] = towardX / weight;
145
+ direction[texel + 1] = towardY / weight;
146
+ direction[texel + 2] = towardZ / weight;
147
+ }
148
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * DriftLight for a whole world: every fixed light summed into one dense grid, baked offline.
3
+ *
4
+ * **Dense, where the courtyard field is sparse, because a city is lit nearly everywhere.** A brick
5
+ * earns its index and its shared faces where light is rare; street lamps reaching thirty metres
6
+ * forty metres apart leave almost no cell dark, so the sparseness buys nothing and the shared
7
+ * faces cost 2.4× the samples. At 8 m a sample, a two-kilometre city 128 m tall is 256 × 16 × 256
8
+ * samples, two half-float texels each: 16 MB on the GPU, and the renderer's lookup is one filtered
9
+ * fetch for the light and one for its direction.
10
+ *
11
+ * **The same sample as every other bake.** `sampleFieldLight` is shared with `bakeBrick`, so a dense
12
+ * volume and a brick agree bit for bit wherever both have a sample. The order a sample's lights are
13
+ * summed in does not have to match for that: the sum runs in double precision and is stored in
14
+ * single, which the last bits of a few terms in another order do not reach.
15
+ *
16
+ * **Lights are bucketed first**, four samples a bucket on every axis, so a sample sums only the
17
+ * lights whose reach touches its bucket rather than all of them: a city of ten thousand lamps is a
18
+ * few dozen candidates a sample. Offline and allocation-free per sample; it may take seconds.
19
+ *
20
+ * What it gives up is resolution: at 8 m a lamp's pool is a few samples across, which is why this
21
+ * stands in only past the exact lights, where a pool is a few dozen pixels.
22
+ */
23
+
24
+ import type { FieldLight } from './bake.ts';
25
+ import { sampleFieldLight } from './bake.ts';
26
+ import type { PointLightFalloff } from './falloff.ts';
27
+ import type { DistanceAt } from './visibility.ts';
28
+
29
+ /** The most samples along x or z: WebGL2 promises 256 on every axis of a 3D texture. */
30
+ export const DENSE_MAX_AXIS = 256;
31
+ /** The most along y, which holds the light and then the direction: two layers in 256. */
32
+ export const DENSE_MAX_HEIGHT = 128;
33
+ /** Samples a bucket spans on each axis. */
34
+ const BUCKET = 4;
35
+
36
+ export interface DenseLightVolume {
37
+ /** The world position of sample (0, 0, 0). */
38
+ readonly origin: readonly [number, number, number];
39
+ /** Metres between two samples. */
40
+ readonly spacing: number;
41
+ /** Samples on each axis. */
42
+ readonly dims: readonly [number, number, number];
43
+ /** Four floats a sample, x fastest then y then z: the colour, then 1 for a sample and 0 inside. */
44
+ readonly light: Float32Array;
45
+ /** Four floats a sample: the direction light arrives from, weighted by brightness, then 0. */
46
+ readonly direction: Float32Array;
47
+ }
48
+
49
+ /**
50
+ * Sum `lights` over the box `bounds` (min xyz, max xyz) at `spacing` metres. `distance` occludes, or
51
+ * null for none. The first sample stands on the box's min corner.
52
+ */
53
+ export function bakeDenseField(
54
+ lights: readonly FieldLight[],
55
+ falloff: PointLightFalloff,
56
+ distance: DistanceAt | null,
57
+ bounds: ArrayLike<number>,
58
+ spacing: number,
59
+ ): DenseLightVolume {
60
+ if (!(spacing > 0)) throw new Error(`bakeDenseField: spacing is ${String(spacing)}`);
61
+ const origin: [number, number, number] = [
62
+ bounds[0] as number,
63
+ bounds[1] as number,
64
+ bounds[2] as number,
65
+ ];
66
+ const dims: [number, number, number] = [0, 0, 0];
67
+ for (let axis = 0; axis < 3; axis++) {
68
+ const extent = (bounds[axis + 3] as number) - (origin[axis] as number);
69
+ dims[axis] = Math.max(1, Math.floor(extent / spacing + 1e-9) + 1);
70
+ }
71
+ if (dims[0] > DENSE_MAX_AXIS || dims[2] > DENSE_MAX_AXIS || dims[1] > DENSE_MAX_HEIGHT) {
72
+ throw new Error(
73
+ `bakeDenseField: ${dims.join(' × ')} samples, and a volume holds ${DENSE_MAX_AXIS} across, ` +
74
+ `${DENSE_MAX_HEIGHT} up and ${DENSE_MAX_AXIS} deep; a coarser spacing fits it.`,
75
+ );
76
+ }
77
+
78
+ /* Each light listed in every bucket its reach touches, in the order the scene listed them. */
79
+ const buckets = [
80
+ Math.ceil(dims[0] / BUCKET),
81
+ Math.ceil(dims[1] / BUCKET),
82
+ Math.ceil(dims[2] / BUCKET),
83
+ ] as const;
84
+ const bucketCount = buckets[0] * buckets[1] * buckets[2];
85
+ const span = spacing * BUCKET;
86
+ const range = (light: FieldLight, axis: number, out: Int32Array): boolean => {
87
+ const at = axis === 0 ? light.x : axis === 1 ? light.y : light.z;
88
+ const lo = Math.floor((at - light.radius - (origin[axis] as number)) / span);
89
+ const hi = Math.floor((at + light.radius - (origin[axis] as number)) / span);
90
+ out[axis * 2] = Math.max(0, lo);
91
+ out[axis * 2 + 1] = Math.min((buckets[axis] as number) - 1, hi);
92
+ return (out[axis * 2] as number) <= (out[axis * 2 + 1] as number);
93
+ };
94
+ const cells = new Int32Array(6);
95
+ const start = new Uint32Array(bucketCount + 1);
96
+ const each = (fill: (bucket: number, light: number) => void): void => {
97
+ for (let index = 0; index < lights.length; index++) {
98
+ const light = lights[index] as FieldLight;
99
+ if (!(light.radius > 0)) continue;
100
+ if (!range(light, 0, cells) || !range(light, 1, cells) || !range(light, 2, cells)) continue;
101
+ for (let z = cells[4] as number; z <= (cells[5] as number); z++) {
102
+ for (let y = cells[2] as number; y <= (cells[3] as number); y++) {
103
+ for (let x = cells[0] as number; x <= (cells[1] as number); x++) {
104
+ fill(x + buckets[0] * (y + buckets[1] * z), index);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ };
110
+ each((bucket) => {
111
+ start[bucket + 1] = (start[bucket + 1] as number) + 1;
112
+ });
113
+ for (let b = 0; b < bucketCount; b++)
114
+ start[b + 1] = (start[b + 1] as number) + (start[b] as number);
115
+ const listed = new Uint32Array(start[bucketCount] as number);
116
+ const cursor = start.slice(0, bucketCount);
117
+ each((bucket, light) => {
118
+ listed[cursor[bucket] as number] = light;
119
+ cursor[bucket] = (cursor[bucket] as number) + 1;
120
+ });
121
+
122
+ const count = dims[0] * dims[1] * dims[2];
123
+ const light = new Float32Array(count * 4);
124
+ const direction = new Float32Array(count * 4);
125
+ for (let k = 0; k < dims[2]; k++) {
126
+ for (let j = 0; j < dims[1]; j++) {
127
+ for (let i = 0; i < dims[0]; i++) {
128
+ const bucket =
129
+ Math.floor(i / BUCKET) +
130
+ buckets[0] * (Math.floor(j / BUCKET) + buckets[1] * Math.floor(k / BUCKET));
131
+ sampleFieldLight(
132
+ origin[0] + i * spacing,
133
+ origin[1] + j * spacing,
134
+ origin[2] + k * spacing,
135
+ lights,
136
+ listed,
137
+ start[bucket] as number,
138
+ start[bucket + 1] as number,
139
+ falloff,
140
+ distance,
141
+ light,
142
+ direction,
143
+ (i + dims[0] * (j + dims[1] * k)) * 4,
144
+ );
145
+ }
146
+ }
147
+ }
148
+ return { origin, spacing, dims, light, direction };
149
+ }