topazcube 0.1.31 → 0.1.35

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 (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. package/src/utils.ts +0 -403
@@ -0,0 +1,2072 @@
1
+ import { BasePass } from "./BasePass.js"
2
+ import { mat4, vec3 } from "../../math.js"
3
+ import { Frustum } from "../../utils/Frustum.js"
4
+ import { calculateShadowBoundingSphere, sphereInCascade, transformBoundingSphere } from "../../utils/BoundingSphere.js"
5
+
6
+ /**
7
+ * ShadowPass - Shadow map generation for directional and spot lights
8
+ *
9
+ * Pass 1 in the 7-pass pipeline.
10
+ * Generates depth maps from light perspectives.
11
+ */
12
+ class ShadowPass extends BasePass {
13
+ constructor(engine = null) {
14
+ super('Shadow', engine)
15
+
16
+ // Shadow textures - use texture array for cascades
17
+ this.directionalShadowMap = null // Depth texture array for cascades
18
+ this.spotShadowMaps = [] // Array of depth textures for spot lights
19
+
20
+ // Spotlight shadow atlas
21
+ this.spotShadowAtlas = null
22
+ this.spotShadowAtlasView = null
23
+
24
+ // Light matrices
25
+ this.directionalLightMatrix = mat4.create() // For backward compatibility
26
+ this.cascadeMatrices = [] // Array of mat4 for each cascade
27
+ this.cascadeViews = [] // Texture views for each cascade layer
28
+ this.spotLightMatrices = [] // Array of mat4 for each shadow slot
29
+
30
+ // Shadow slot assignments (lightIndex -> slotIndex, -1 if no shadow)
31
+ this.spotShadowSlots = new Int32Array(128) // Max 128 lights
32
+ this.spotShadowMatrices = [] // Matrices for lights with shadows
33
+
34
+ // Pipeline and bind groups
35
+ this.pipeline = null
36
+ this.uniformBuffer = null
37
+ this.bindGroup = null
38
+
39
+ // Scene bounds for directional light
40
+ this.sceneBounds = {
41
+ min: [-50, -10, -50],
42
+ max: [50, 50, 50]
43
+ }
44
+
45
+ // Noise texture for alpha hashing
46
+ this.noiseTexture = null
47
+ this.noiseSize = 64
48
+ this.noiseAnimated = true
49
+
50
+ // HiZ pass reference for occlusion culling of static meshes
51
+ this.hizPass = null
52
+
53
+ // Per-mesh bind group cache (for alpha hashing with different albedo textures)
54
+ this._meshBindGroups = new WeakMap()
55
+
56
+ // Camera shadow detection state
57
+ this._cameraShadowBuffer = null
58
+ this._cameraShadowReadBuffer = null
59
+ this._cameraShadowPipeline = null
60
+ this._cameraShadowBindGroup = null
61
+ this._cameraShadowUniformBuffer = null
62
+ this._cameraInShadow = false
63
+ this._cameraShadowPending = false
64
+ }
65
+
66
+ /**
67
+ * Set the HiZ pass for occlusion culling of static meshes
68
+ * @param {HiZPass} hizPass - HiZ pass instance
69
+ */
70
+ setHiZPass(hizPass) {
71
+ this.hizPass = hizPass
72
+ }
73
+
74
+ /**
75
+ * Set the noise texture for alpha hashing in shadows
76
+ * @param {Texture} noise - Noise texture (blue noise or bayer dither)
77
+ * @param {number} size - Texture size
78
+ * @param {boolean} animated - Whether to animate noise offset each frame
79
+ */
80
+ setNoise(noise, size = 64, animated = true) {
81
+ this.noiseTexture = noise
82
+ this.noiseSize = size
83
+ this.noiseAnimated = animated
84
+ // Clear bind group cache since noise texture changed
85
+ this._meshBindGroups = new WeakMap()
86
+ this._skinBindGroups = new WeakMap()
87
+ }
88
+
89
+ // Convenience getters for shadow settings (with defaults for backward compatibility)
90
+ get shadowMapSize() { return this.settings?.shadow?.mapSize ?? 2048 }
91
+ get cascadeCount() { return this.settings?.shadow?.cascadeCount ?? 3 }
92
+ get cascadeSizes() { return this.settings?.shadow?.cascadeSizes ?? [20, 60, 300] }
93
+ get maxSpotShadows() { return this.settings?.shadow?.maxSpotShadows ?? 16 }
94
+ get spotTileSize() { return this.settings?.shadow?.spotTileSize ?? 512 }
95
+ get spotAtlasSize() { return this.settings?.shadow?.spotAtlasSize ?? 2048 }
96
+ get spotTilesPerRow() { return this.spotAtlasSize / this.spotTileSize }
97
+ get shadowMaxDistance() { return this.settings?.shadow?.spotMaxDistance ?? 60 }
98
+ get shadowFadeStart() { return this.settings?.shadow?.spotFadeStart ?? 55 }
99
+
100
+ async _init() {
101
+ const { device } = this.engine
102
+
103
+ // Create directional shadow map as 2D texture array (one layer per cascade)
104
+ this.directionalShadowMap = device.createTexture({
105
+ size: [this.shadowMapSize, this.shadowMapSize, this.cascadeCount],
106
+ format: 'depth32float',
107
+ dimension: '2d',
108
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
109
+ label: 'Cascaded Shadow Map'
110
+ })
111
+
112
+ // Create view for entire array (for sampling in shader)
113
+ this.directionalShadowMapView = this.directionalShadowMap.createView({
114
+ dimension: '2d-array',
115
+ arrayLayerCount: this.cascadeCount,
116
+ })
117
+
118
+ // Create individual views for each cascade layer (for rendering)
119
+ this.cascadeViews = []
120
+ for (let i = 0; i < this.cascadeCount; i++) {
121
+ this.cascadeViews.push(this.directionalShadowMap.createView({
122
+ dimension: '2d',
123
+ baseArrayLayer: i,
124
+ arrayLayerCount: 1,
125
+ }))
126
+ }
127
+
128
+ // Initialize cascade matrices
129
+ this.cascadeMatrices = []
130
+ for (let i = 0; i < this.cascadeCount; i++) {
131
+ this.cascadeMatrices.push(mat4.create())
132
+ }
133
+
134
+ // Create storage buffer for cascade matrices (3 matrices * 64 bytes = 192 bytes)
135
+ this.cascadeMatricesBuffer = device.createBuffer({
136
+ label: 'Cascade Shadow Matrices',
137
+ size: this.cascadeCount * 16 * 4,
138
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
139
+ })
140
+ this.cascadeMatricesData = new Float32Array(this.cascadeCount * 16)
141
+
142
+ // Create spotlight shadow atlas
143
+ // spotAtlasSize, spotTileSize, spotTilesPerRow, maxSpotShadows come from settings via getters
144
+ const atlasSize = this.spotAtlasSize
145
+ this.spotAtlasHeight = atlasSize
146
+
147
+ this.spotShadowAtlas = device.createTexture({
148
+ size: [this.spotAtlasSize, this.spotAtlasHeight, 1],
149
+ format: 'depth32float',
150
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
151
+ label: 'Spot Shadow Atlas'
152
+ })
153
+
154
+ this.spotShadowAtlasView = this.spotShadowAtlas.createView()
155
+
156
+ // Initialize spot shadow slot data
157
+ this.spotShadowSlots.fill(-1)
158
+ for (let i = 0; i < this.maxSpotShadows; i++) {
159
+ this.spotLightMatrices.push(mat4.create())
160
+ }
161
+
162
+ // Create storage buffer for spot shadow matrices (8 matrices * 64 bytes = 512 bytes)
163
+ this.spotMatricesBuffer = device.createBuffer({
164
+ label: 'Spot Shadow Matrices',
165
+ size: this.maxSpotShadows * 16 * 4, // 8 mat4x4 * 16 floats * 4 bytes
166
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
167
+ })
168
+ this.spotMatricesData = new Float32Array(this.maxSpotShadows * 16)
169
+
170
+ // Create sampler for shadow map
171
+ this.shadowSampler = device.createSampler({
172
+ compare: 'less',
173
+ magFilter: 'linear',
174
+ minFilter: 'linear',
175
+ })
176
+
177
+ // Create regular sampler for reading depth
178
+ this.depthSampler = device.createSampler({
179
+ magFilter: 'nearest',
180
+ minFilter: 'nearest',
181
+ })
182
+
183
+ // Create uniform buffer for light matrix + alpha hash params + surface bias
184
+ // mat4 (64) + vec3+f32 (16) + alpha hash (16) + surfaceBias+padding (16) + lightDir+padding (16) = 128 bytes
185
+ this.uniformBuffer = device.createBuffer({
186
+ size: 128,
187
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
188
+ label: 'Shadow Uniforms'
189
+ })
190
+
191
+ // Create placeholder textures
192
+ this._createPlaceholderTextures()
193
+
194
+ // Create shadow pipeline
195
+ await this._createPipeline()
196
+
197
+ // Create camera shadow detection resources
198
+ await this._createCameraShadowDetection()
199
+ }
200
+
201
+ async _createPipeline() {
202
+ const { device } = this.engine
203
+
204
+ const shaderModule = device.createShaderModule({
205
+ label: 'Shadow Shader',
206
+ code: `
207
+ struct Uniforms {
208
+ lightViewProjection: mat4x4f,
209
+ lightPosition: vec3f,
210
+ lightType: f32,
211
+ // Alpha hash params
212
+ alphaHashEnabled: f32,
213
+ alphaHashScale: f32,
214
+ luminanceToAlpha: f32,
215
+ noiseSize: f32,
216
+ noiseOffsetX: f32,
217
+ surfaceBias: f32, // Expand triangles along normals (meters)
218
+ _padding: vec2f,
219
+ lightDirection: vec3f, // Light direction (for surface bias)
220
+ _padding2: f32,
221
+ }
222
+
223
+ struct VertexInput {
224
+ @location(0) position: vec3f,
225
+ @location(1) uv: vec2f,
226
+ @location(2) normal: vec3f,
227
+ @location(3) color: vec4f,
228
+ @location(4) weights: vec4f,
229
+ @location(5) joints: vec4u,
230
+ @location(6) model0: vec4f,
231
+ @location(7) model1: vec4f,
232
+ @location(8) model2: vec4f,
233
+ @location(9) model3: vec4f,
234
+ @location(10) instancePosRadius: vec4f,
235
+ }
236
+
237
+ struct VertexOutput {
238
+ @builtin(position) position: vec4f,
239
+ @location(0) uv: vec2f,
240
+ }
241
+
242
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
243
+ @group(0) @binding(1) var jointTexture: texture_2d<f32>;
244
+ @group(0) @binding(2) var jointSampler: sampler;
245
+ @group(0) @binding(3) var albedoTexture: texture_2d<f32>;
246
+ @group(0) @binding(4) var albedoSampler: sampler;
247
+ @group(0) @binding(5) var noiseTexture: texture_2d<f32>;
248
+
249
+ // Get a 4x4 matrix from the joint texture
250
+ fn getJointMatrix(jointIndex: u32) -> mat4x4f {
251
+ let row = i32(jointIndex);
252
+ let col0 = textureLoad(jointTexture, vec2i(0, row), 0);
253
+ let col1 = textureLoad(jointTexture, vec2i(1, row), 0);
254
+ let col2 = textureLoad(jointTexture, vec2i(2, row), 0);
255
+ let col3 = textureLoad(jointTexture, vec2i(3, row), 0);
256
+ return mat4x4f(col0, col1, col2, col3);
257
+ }
258
+
259
+ // Apply skinning to a position
260
+ fn applySkinning(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {
261
+ // Check if skinning is active (weights sum > 0)
262
+ let weightSum = weights.x + weights.y + weights.z + weights.w;
263
+ if (weightSum < 0.001) {
264
+ return position;
265
+ }
266
+
267
+ var skinnedPos = vec3f(0.0);
268
+ let m0 = getJointMatrix(joints.x);
269
+ let m1 = getJointMatrix(joints.y);
270
+ let m2 = getJointMatrix(joints.z);
271
+ let m3 = getJointMatrix(joints.w);
272
+
273
+ skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;
274
+ skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;
275
+ skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;
276
+ skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;
277
+
278
+ return skinnedPos;
279
+ }
280
+
281
+ // Sample noise at screen position (tiled, no animation for shadows)
282
+ fn sampleNoise(screenPos: vec2f) -> f32 {
283
+ let noiseSize = i32(uniforms.noiseSize);
284
+ let noiseOffsetX = i32(uniforms.noiseOffsetX * f32(noiseSize));
285
+
286
+ let texCoord = vec2i(
287
+ (i32(screenPos.x) + noiseOffsetX) % noiseSize,
288
+ i32(screenPos.y) % noiseSize
289
+ );
290
+ return textureLoad(noiseTexture, texCoord, 0).r;
291
+ }
292
+
293
+ @vertex
294
+ fn vertexMain(input: VertexInput) -> VertexOutput {
295
+ var output: VertexOutput;
296
+
297
+ let modelMatrix = mat4x4f(
298
+ input.model0,
299
+ input.model1,
300
+ input.model2,
301
+ input.model3
302
+ );
303
+
304
+ // Apply skinning
305
+ let skinnedPos = applySkinning(input.position, input.joints, input.weights);
306
+ let worldPos = modelMatrix * vec4f(skinnedPos, 1.0);
307
+
308
+ var clipPos = uniforms.lightViewProjection * worldPos;
309
+
310
+ // Apply surface bias - scale shadow projection to make shadows larger
311
+ // surfaceBias is treated as a percentage (0.01 = 1% larger shadows)
312
+ if (uniforms.surfaceBias > 0.0) {
313
+ let scale = 1.0 + uniforms.surfaceBias;
314
+ clipPos = vec4f(clipPos.xy * scale, clipPos.z, clipPos.w);
315
+ }
316
+
317
+ output.position = clipPos;
318
+ output.uv = input.uv;
319
+
320
+ return output;
321
+ }
322
+
323
+ @fragment
324
+ fn fragmentMain(input: VertexOutput) {
325
+ // Luminance to alpha: hard discard for pure black (no noise)
326
+ if (uniforms.luminanceToAlpha > 0.5) {
327
+ let albedo = textureSample(albedoTexture, albedoSampler, input.uv);
328
+ let luminance = dot(albedo.rgb, vec3f(0.299, 0.587, 0.114));
329
+ if (luminance < 0.004) {
330
+ discard;
331
+ }
332
+ }
333
+ // Simple alpha cutoff for shadows (no hashing - too noisy at shadow resolution)
334
+ else if (uniforms.alphaHashEnabled > 0.5) {
335
+ let albedo = textureSample(albedoTexture, albedoSampler, input.uv);
336
+ let alpha = albedo.a * uniforms.alphaHashScale;
337
+ if (alpha < 0.5) {
338
+ discard;
339
+ }
340
+ }
341
+ // Depth-only pass, no color output needed
342
+ }
343
+ `
344
+ })
345
+
346
+ // Vertex buffer layout (must match geometry)
347
+ const vertexBufferLayout = {
348
+ arrayStride: 80,
349
+ attributes: [
350
+ { format: "float32x3", offset: 0, shaderLocation: 0 },
351
+ { format: "float32x2", offset: 12, shaderLocation: 1 },
352
+ { format: "float32x3", offset: 20, shaderLocation: 2 },
353
+ { format: "float32x4", offset: 32, shaderLocation: 3 },
354
+ { format: "float32x4", offset: 48, shaderLocation: 4 },
355
+ { format: "uint32x4", offset: 64, shaderLocation: 5 },
356
+ ],
357
+ stepMode: 'vertex'
358
+ }
359
+
360
+ const instanceBufferLayout = {
361
+ arrayStride: 112, // 28 floats: matrix(16) + posRadius(4) + uvTransform(4) + color(4)
362
+ stepMode: 'instance',
363
+ attributes: [
364
+ { format: "float32x4", offset: 0, shaderLocation: 6 },
365
+ { format: "float32x4", offset: 16, shaderLocation: 7 },
366
+ { format: "float32x4", offset: 32, shaderLocation: 8 },
367
+ { format: "float32x4", offset: 48, shaderLocation: 9 },
368
+ { format: "float32x4", offset: 64, shaderLocation: 10 },
369
+ { format: "float32x4", offset: 80, shaderLocation: 11 }, // uvTransform
370
+ { format: "float32x4", offset: 96, shaderLocation: 12 }, // color
371
+ ]
372
+ }
373
+
374
+ this.bindGroupLayout = device.createBindGroupLayout({
375
+ entries: [
376
+ {
377
+ binding: 0,
378
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
379
+ buffer: { type: 'uniform' }
380
+ },
381
+ {
382
+ binding: 1,
383
+ visibility: GPUShaderStage.VERTEX,
384
+ texture: { sampleType: 'unfilterable-float' }
385
+ },
386
+ {
387
+ binding: 2,
388
+ visibility: GPUShaderStage.VERTEX,
389
+ sampler: { type: 'non-filtering' }
390
+ },
391
+ // Albedo texture for alpha hashing
392
+ {
393
+ binding: 3,
394
+ visibility: GPUShaderStage.FRAGMENT,
395
+ texture: { sampleType: 'float' }
396
+ },
397
+ {
398
+ binding: 4,
399
+ visibility: GPUShaderStage.FRAGMENT,
400
+ sampler: { type: 'filtering' }
401
+ },
402
+ // Noise texture for alpha hashing
403
+ {
404
+ binding: 5,
405
+ visibility: GPUShaderStage.FRAGMENT,
406
+ texture: { sampleType: 'float' }
407
+ }
408
+ ]
409
+ })
410
+
411
+ const pipelineLayout = device.createPipelineLayout({
412
+ bindGroupLayouts: [this.bindGroupLayout]
413
+ })
414
+
415
+ // Use async pipeline creation for non-blocking initialization
416
+ this.pipeline = await device.createRenderPipelineAsync({
417
+ label: 'Shadow Pipeline',
418
+ layout: pipelineLayout,
419
+ vertex: {
420
+ module: shaderModule,
421
+ entryPoint: 'vertexMain',
422
+ buffers: [vertexBufferLayout, instanceBufferLayout]
423
+ },
424
+ fragment: {
425
+ module: shaderModule,
426
+ entryPoint: 'fragmentMain',
427
+ targets: [] // No color attachments
428
+ },
429
+ depthStencil: {
430
+ format: 'depth32float',
431
+ depthWriteEnabled: true,
432
+ depthCompare: 'less',
433
+ },
434
+ primitive: {
435
+ topology: 'triangle-list',
436
+ cullMode: 'none', // No culling for shadow map (debug)
437
+ }
438
+ })
439
+
440
+ // Create placeholder joint texture for non-skinned meshes
441
+ this._createPlaceholderJointTexture()
442
+
443
+ // Default bind group with placeholder textures
444
+ this.bindGroup = device.createBindGroup({
445
+ layout: this.bindGroupLayout,
446
+ entries: [
447
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
448
+ { binding: 1, resource: this.placeholderJointTextureView },
449
+ { binding: 2, resource: this.placeholderJointSampler },
450
+ { binding: 3, resource: this.placeholderAlbedoTextureView },
451
+ { binding: 4, resource: this.placeholderAlbedoSampler },
452
+ { binding: 5, resource: this.placeholderNoiseTextureView }
453
+ ]
454
+ })
455
+ }
456
+
457
+ _createPlaceholderJointTexture() {
458
+ const { device } = this.engine
459
+
460
+ // Create a 4x1 rgba32float texture (one identity matrix)
461
+ this.placeholderJointTexture = device.createTexture({
462
+ size: [4, 1, 1],
463
+ format: 'rgba32float',
464
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
465
+ })
466
+
467
+ // Write identity matrix
468
+ const identityData = new Float32Array([
469
+ 1, 0, 0, 0, // column 0
470
+ 0, 1, 0, 0, // column 1
471
+ 0, 0, 1, 0, // column 2
472
+ 0, 0, 0, 1, // column 3
473
+ ])
474
+ device.queue.writeTexture(
475
+ { texture: this.placeholderJointTexture },
476
+ identityData,
477
+ { bytesPerRow: 4 * 4 * 4, rowsPerImage: 1 },
478
+ [4, 1, 1]
479
+ )
480
+
481
+ this.placeholderJointTextureView = this.placeholderJointTexture.createView()
482
+ this.placeholderJointSampler = device.createSampler({
483
+ magFilter: 'nearest',
484
+ minFilter: 'nearest',
485
+ })
486
+ }
487
+
488
+ _createPlaceholderTextures() {
489
+ const { device } = this.engine
490
+
491
+ // Create placeholder albedo texture (1x1 white with alpha=1)
492
+ // Used for meshes without alpha hashing - alpha=1 means no discard
493
+ this.placeholderAlbedoTexture = device.createTexture({
494
+ size: [1, 1, 1],
495
+ format: 'rgba8unorm',
496
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
497
+ })
498
+ device.queue.writeTexture(
499
+ { texture: this.placeholderAlbedoTexture },
500
+ new Uint8Array([255, 255, 255, 255]),
501
+ { bytesPerRow: 4, rowsPerImage: 1 },
502
+ [1, 1, 1]
503
+ )
504
+ this.placeholderAlbedoTextureView = this.placeholderAlbedoTexture.createView()
505
+ this.placeholderAlbedoSampler = device.createSampler({
506
+ magFilter: 'linear',
507
+ minFilter: 'linear',
508
+ })
509
+
510
+ // Create placeholder noise texture (1x1 gray = 0.5)
511
+ // Used when no noise texture is configured
512
+ this.placeholderNoiseTexture = device.createTexture({
513
+ size: [1, 1, 1],
514
+ format: 'rgba8unorm',
515
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
516
+ })
517
+ device.queue.writeTexture(
518
+ { texture: this.placeholderNoiseTexture },
519
+ new Uint8Array([128, 128, 128, 255]),
520
+ { bytesPerRow: 4, rowsPerImage: 1 },
521
+ [1, 1, 1]
522
+ )
523
+ this.placeholderNoiseTextureView = this.placeholderNoiseTexture.createView()
524
+ }
525
+
526
+ /**
527
+ * Get or create a bind group for a mesh (handles skin and albedo for alpha hashing)
528
+ * @param {Mesh} mesh - The mesh to get bind group for
529
+ * @returns {GPUBindGroup} The bind group for this mesh
530
+ */
531
+ getBindGroupForMesh(mesh) {
532
+ const { device } = this.engine
533
+
534
+ const skin = mesh?.skin
535
+ const material = mesh?.material
536
+ const hasAlphaHash = material?.alphaHash || mesh?.alphaHash
537
+ const hasLuminanceToAlpha = material?.luminanceToAlpha
538
+ const needsAlbedo = hasAlphaHash || hasLuminanceToAlpha
539
+
540
+ // Get albedo texture (first texture in material) or placeholder
541
+ let albedoView = this.placeholderAlbedoTextureView
542
+ let albedoSampler = this.placeholderAlbedoSampler
543
+ if (needsAlbedo && material?.textures?.[0]) {
544
+ albedoView = material.textures[0].view
545
+ albedoSampler = material.textures[0].sampler
546
+ }
547
+
548
+ // Get noise texture or placeholder
549
+ const noiseView = this.noiseTexture?.view || this.placeholderNoiseTextureView
550
+
551
+ // Get joint texture (from skin or placeholder)
552
+ let jointView = this.placeholderJointTextureView
553
+ let jointSampler = this.placeholderJointSampler
554
+ if (skin?.jointTexture) {
555
+ jointView = skin.jointTextureView
556
+ jointSampler = skin.jointSampler
557
+ }
558
+
559
+ // For meshes without alpha hash, luminanceToAlpha, and without skin, use default bind group
560
+ if (!needsAlbedo && !skin?.jointTexture) {
561
+ return this.bindGroup
562
+ }
563
+
564
+ // Cache bind groups by mesh
565
+ let bindGroup = this._meshBindGroups.get(mesh)
566
+ if (!bindGroup) {
567
+ bindGroup = device.createBindGroup({
568
+ layout: this.bindGroupLayout,
569
+ entries: [
570
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
571
+ { binding: 1, resource: jointView },
572
+ { binding: 2, resource: jointSampler },
573
+ { binding: 3, resource: albedoView },
574
+ { binding: 4, resource: albedoSampler },
575
+ { binding: 5, resource: noiseView }
576
+ ]
577
+ })
578
+ this._meshBindGroups.set(mesh, bindGroup)
579
+ }
580
+
581
+ return bindGroup
582
+ }
583
+
584
+ /**
585
+ * Get or create a bind group for a specific joint texture (legacy, for backward compatibility)
586
+ */
587
+ getBindGroupForSkin(skin) {
588
+ // For backward compatibility, create a minimal bind group for skinned meshes
589
+ // without alpha hashing
590
+ const { device } = this.engine
591
+
592
+ if (!skin || !skin.jointTexture) {
593
+ return this.bindGroup
594
+ }
595
+
596
+ // Cache bind groups by skin
597
+ if (!this._skinBindGroups) {
598
+ this._skinBindGroups = new WeakMap()
599
+ }
600
+
601
+ let bindGroup = this._skinBindGroups.get(skin)
602
+ if (!bindGroup) {
603
+ bindGroup = device.createBindGroup({
604
+ layout: this.bindGroupLayout,
605
+ entries: [
606
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
607
+ { binding: 1, resource: skin.jointTextureView },
608
+ { binding: 2, resource: skin.jointSampler },
609
+ { binding: 3, resource: this.placeholderAlbedoTextureView },
610
+ { binding: 4, resource: this.placeholderAlbedoSampler },
611
+ { binding: 5, resource: this.noiseTexture?.view || this.placeholderNoiseTextureView }
612
+ ]
613
+ })
614
+ this._skinBindGroups.set(skin, bindGroup)
615
+ }
616
+
617
+ return bindGroup
618
+ }
619
+
620
+ /**
621
+ * Create a frustum from a view-projection matrix for culling
622
+ */
623
+ _createFrustumFromMatrix(viewProj) {
624
+ const frustum = new Frustum()
625
+ frustum._extractPlanes(viewProj)
626
+ return frustum
627
+ }
628
+
629
+ /**
630
+ * Test if an instance's bounding sphere is visible to a spotlight using cone culling
631
+ * @param {Object} bsphere - Bounding sphere { center: [x,y,z], radius: r }
632
+ * @param {Array} lightPos - Light position [x, y, z]
633
+ * @param {Array} lightDir - Normalized light direction
634
+ * @param {number} maxDistance - Max shadow distance
635
+ * @param {number} coneAngle - Half-angle of spotlight cone in radians
636
+ * @returns {boolean} True if instance should be rendered
637
+ */
638
+ _isInstanceVisibleToSpotlight(bsphere, lightPos, lightDir, maxDistance, coneAngle) {
639
+ // Vector from light to sphere center
640
+ const dx = bsphere.center[0] - lightPos[0]
641
+ const dy = bsphere.center[1] - lightPos[1]
642
+ const dz = bsphere.center[2] - lightPos[2]
643
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
644
+
645
+ // Distance test: closest surface must be within max shadow distance
646
+ if (dist - bsphere.radius > maxDistance) {
647
+ return false
648
+ }
649
+
650
+ // Skip objects too close (behind light or at light position)
651
+ if (dist < 0.1) {
652
+ return true // Include objects at light position
653
+ }
654
+
655
+ // Cone test: check if sphere intersects the spotlight cone
656
+ // Normalize direction to sphere
657
+ const invDist = 1.0 / dist
658
+ const toDirX = dx * invDist
659
+ const toDirY = dy * invDist
660
+ const toDirZ = dz * invDist
661
+
662
+ // Dot product with light direction = cos(angle to sphere center)
663
+ const cosAngle = toDirX * lightDir[0] + toDirY * lightDir[1] + toDirZ * lightDir[2]
664
+
665
+ // Angular radius of sphere as seen from light (sin approximation for small angles)
666
+ // For larger spheres, use proper asin
667
+ const sinAngularRadius = Math.min(bsphere.radius / dist, 1.0)
668
+ const angularRadius = Math.asin(sinAngularRadius)
669
+
670
+ // Sphere is visible if: angle to center - angular radius < cone angle
671
+ // cos(angle) > cos(coneAngle + angularRadius)
672
+ // For efficiency, compare cosines (reversed inequality since cos is decreasing)
673
+ const expandedConeAngle = coneAngle + angularRadius
674
+ const cosExpandedCone = Math.cos(Math.min(expandedConeAngle, Math.PI))
675
+
676
+ if (cosAngle < cosExpandedCone) {
677
+ return false // Sphere is outside the expanded cone
678
+ }
679
+
680
+ return true
681
+ }
682
+
683
+ /**
684
+ * Build filtered instance data for a cascade
685
+ * Returns a Float32Array with only the instances visible to this cascade
686
+ * @param {Object} geometry - Geometry with instanceData
687
+ * @param {mat4} cascadeMatrix - Cascade's view-projection matrix
688
+ * @param {Array} lightDir - Normalized light direction (pointing to light)
689
+ * @param {number} groundLevel - Ground plane Y coordinate
690
+ * @param {Object|null} combinedBsphere - Combined bsphere for skinned models (optional)
691
+ * @returns {{ data: Float32Array, count: number }}
692
+ */
693
+ _buildCascadeFilteredInstances(geometry, cascadeMatrix, lightDir, groundLevel, combinedBsphere = null) {
694
+ const instanceStride = 28 // floats per instance (matrix + posRadius + uvTransform + color)
695
+ const visibleIndices = []
696
+
697
+ // Use combined bsphere for skinned models, otherwise fall back to geometry's sphere
698
+ const localBsphere = combinedBsphere || geometry.getBoundingSphere?.()
699
+
700
+ for (let i = 0; i < geometry.instanceCount; i++) {
701
+ const offset = i * instanceStride
702
+ let bsphere = {
703
+ center: [
704
+ geometry.instanceData[offset + 16],
705
+ geometry.instanceData[offset + 17],
706
+ geometry.instanceData[offset + 18]
707
+ ],
708
+ radius: Math.abs(geometry.instanceData[offset + 19])
709
+ }
710
+
711
+ // If no valid bsphere in instance data, use geometry's local bsphere + transform
712
+ if (bsphere.radius <= 0 && localBsphere && localBsphere.radius > 0) {
713
+ // Extract transform matrix from instance data
714
+ const matrix = geometry.instanceData.subarray(offset, offset + 16)
715
+ // Transform local bsphere by instance matrix
716
+ bsphere = transformBoundingSphere(localBsphere, matrix)
717
+ }
718
+
719
+ // Still no valid bsphere - include by default
720
+ if (!bsphere || bsphere.radius <= 0) {
721
+ visibleIndices.push(i)
722
+ continue
723
+ }
724
+
725
+ // Calculate shadow bounding sphere for this instance
726
+ const shadowBsphere = calculateShadowBoundingSphere(bsphere, lightDir, groundLevel)
727
+
728
+ // Test if shadow bounding sphere intersects this cascade's box
729
+ if (sphereInCascade(shadowBsphere, cascadeMatrix)) {
730
+ visibleIndices.push(i)
731
+ }
732
+ }
733
+
734
+ if (visibleIndices.length === 0) {
735
+ return { data: null, count: 0 }
736
+ }
737
+
738
+ // If all instances are visible, no need to copy data
739
+ if (visibleIndices.length === geometry.instanceCount) {
740
+ return { data: null, count: geometry.instanceCount, useOriginal: true }
741
+ }
742
+
743
+ // Build filtered instance data
744
+ const filteredData = new Float32Array(visibleIndices.length * instanceStride)
745
+ for (let i = 0; i < visibleIndices.length; i++) {
746
+ const srcOffset = visibleIndices[i] * instanceStride
747
+ const dstOffset = i * instanceStride
748
+ for (let j = 0; j < instanceStride; j++) {
749
+ filteredData[dstOffset + j] = geometry.instanceData[srcOffset + j]
750
+ }
751
+ }
752
+
753
+ return { data: filteredData, count: visibleIndices.length }
754
+ }
755
+
756
+ /**
757
+ * Build filtered instance data for a spotlight using cone culling
758
+ * Returns a Float32Array with only the instances visible to this light
759
+ * @param {Object} geometry - Geometry with instanceData
760
+ * @param {Array} lightPos - Light position
761
+ * @param {Array} lightDir - Normalized light direction
762
+ * @param {number} maxDistance - Max shadow distance (min of light radius and spotMaxDistance)
763
+ * @param {number} coneAngle - Half-angle of spotlight cone in radians
764
+ * @param {Object|null} combinedBsphere - Combined bsphere for skinned models (optional)
765
+ * @returns {{ data: Float32Array, count: number }}
766
+ */
767
+ _buildFilteredInstances(geometry, lightPos, lightDir, maxDistance, coneAngle, combinedBsphere = null) {
768
+ const instanceStride = 28 // floats per instance (matrix + posRadius + uvTransform + color)
769
+ const visibleIndices = []
770
+
771
+ // Use combined bsphere for skinned models, otherwise fall back to geometry's sphere
772
+ const localBsphere = combinedBsphere || geometry.getBoundingSphere?.()
773
+
774
+ for (let i = 0; i < geometry.instanceCount; i++) {
775
+ const offset = i * instanceStride
776
+ let bsphere = {
777
+ center: [
778
+ geometry.instanceData[offset + 16],
779
+ geometry.instanceData[offset + 17],
780
+ geometry.instanceData[offset + 18]
781
+ ],
782
+ radius: Math.abs(geometry.instanceData[offset + 19])
783
+ }
784
+
785
+ // If no valid bsphere in instance data, use geometry's local bsphere + transform
786
+ if (bsphere.radius <= 0 && localBsphere && localBsphere.radius > 0) {
787
+ // Extract transform matrix from instance data
788
+ const matrix = geometry.instanceData.subarray(offset, offset + 16)
789
+ // Transform local bsphere by instance matrix
790
+ bsphere = transformBoundingSphere(localBsphere, matrix)
791
+ }
792
+
793
+ // Still no valid bsphere - include by default
794
+ if (!bsphere || bsphere.radius <= 0) {
795
+ visibleIndices.push(i)
796
+ continue
797
+ }
798
+
799
+ if (this._isInstanceVisibleToSpotlight(bsphere, lightPos, lightDir, maxDistance, coneAngle)) {
800
+ visibleIndices.push(i)
801
+ }
802
+ }
803
+
804
+ if (visibleIndices.length === 0) {
805
+ return { data: null, count: 0 }
806
+ }
807
+
808
+ // If all instances are visible, no need to copy data
809
+ if (visibleIndices.length === geometry.instanceCount) {
810
+ return { data: null, count: geometry.instanceCount, useOriginal: true }
811
+ }
812
+
813
+ // Build filtered instance data
814
+ const filteredData = new Float32Array(visibleIndices.length * instanceStride)
815
+ for (let i = 0; i < visibleIndices.length; i++) {
816
+ const srcOffset = visibleIndices[i] * instanceStride
817
+ const dstOffset = i * instanceStride
818
+ for (let j = 0; j < instanceStride; j++) {
819
+ filteredData[dstOffset + j] = geometry.instanceData[srcOffset + j]
820
+ }
821
+ }
822
+
823
+ return { data: filteredData, count: visibleIndices.length }
824
+ }
825
+
826
+ /**
827
+ * Create perspective projection matrix for WebGPU (0-1 depth range)
828
+ */
829
+ perspectiveZO(out, fovy, aspect, near, far) {
830
+ const f = 1.0 / Math.tan(fovy / 2)
831
+ const nf = 1 / (near - far)
832
+
833
+ out[0] = f / aspect
834
+ out[1] = 0
835
+ out[2] = 0
836
+ out[3] = 0
837
+ out[4] = 0
838
+ out[5] = f
839
+ out[6] = 0
840
+ out[7] = 0
841
+ out[8] = 0
842
+ out[9] = 0
843
+ out[10] = far * nf // WebGPU: f/(n-f)
844
+ out[11] = -1
845
+ out[12] = 0
846
+ out[13] = 0
847
+ out[14] = near * far * nf // WebGPU: n*f/(n-f)
848
+ out[15] = 0
849
+
850
+ return out
851
+ }
852
+
853
+ /**
854
+ * Create orthographic projection matrix for WebGPU (0-1 depth range)
855
+ * gl-matrix uses OpenGL convention (-1 to 1), so we need a custom version
856
+ */
857
+ orthoZO(out, left, right, bottom, top, near, far) {
858
+ const lr = 1 / (left - right)
859
+ const bt = 1 / (bottom - top)
860
+ const nf = 1 / (near - far)
861
+
862
+ out[0] = -2 * lr
863
+ out[1] = 0
864
+ out[2] = 0
865
+ out[3] = 0
866
+ out[4] = 0
867
+ out[5] = -2 * bt
868
+ out[6] = 0
869
+ out[7] = 0
870
+ out[8] = 0
871
+ out[9] = 0
872
+ out[10] = nf // WebGPU: -1/(f-n) = 1/(n-f) = nf
873
+ out[11] = 0
874
+ out[12] = (left + right) * lr
875
+ out[13] = (top + bottom) * bt
876
+ out[14] = near * nf // WebGPU: -n/(f-n) = n/(n-f) = near*nf
877
+ out[15] = 1
878
+
879
+ return out
880
+ }
881
+
882
+ /**
883
+ * Calculate light view-projection matrices for all cascades
884
+ * Each cascade is centered on camera's XZ position for best shadow utilization
885
+ */
886
+ calculateCascadeMatrices(lightDir, camera) {
887
+ const dir = vec3.create()
888
+ vec3.normalize(dir, lightDir)
889
+
890
+ // Fixed up vector
891
+ const up = Math.abs(dir[1]) > 0.99
892
+ ? vec3.fromValues(0, 0, 1)
893
+ : vec3.fromValues(0, 1, 0)
894
+
895
+ // Camera's XZ position (center cascades here)
896
+ const cameraXZ = vec3.fromValues(camera.position[0], 0, camera.position[2])
897
+
898
+ for (let i = 0; i < this.cascadeCount; i++) {
899
+ const lightView = mat4.create()
900
+ const lightProj = mat4.create()
901
+
902
+ const frustumSize = this.cascadeSizes[i]
903
+ // Light needs to be far enough to avoid near-plane clipping
904
+ const lightDistance = frustumSize * 2 + 50
905
+ const nearPlane = 1
906
+ const farPlane = lightDistance * 2 + frustumSize
907
+
908
+ // Light position: camera XZ + light direction * distance
909
+ const lightPos = vec3.fromValues(
910
+ cameraXZ[0] + dir[0] * lightDistance,
911
+ dir[1] * lightDistance,
912
+ cameraXZ[2] + dir[2] * lightDistance
913
+ )
914
+
915
+ // Target is camera's XZ position
916
+ const target = vec3.clone(cameraXZ)
917
+
918
+ mat4.lookAt(lightView, lightPos, target, up)
919
+ this.orthoZO(lightProj, -frustumSize, frustumSize, -frustumSize, frustumSize, nearPlane, farPlane)
920
+ mat4.multiply(this.cascadeMatrices[i], lightProj, lightView)
921
+ }
922
+
923
+ // For backward compatibility, copy cascade 0 to directionalLightMatrix
924
+ mat4.copy(this.directionalLightMatrix, this.cascadeMatrices[0])
925
+
926
+ return this.cascadeMatrices
927
+ }
928
+
929
+ /**
930
+ * Calculate spotlight view-projection matrix
931
+ * @param {Object} light - Light with position, direction, geom (radius, innerCone, outerCone)
932
+ * @param {number} slotIndex - Which slot this light is assigned to
933
+ * @returns {mat4} Light view-projection matrix
934
+ */
935
+ calculateSpotLightMatrix(light, slotIndex) {
936
+ const lightView = mat4.create()
937
+ const lightProj = mat4.create()
938
+
939
+ const pos = vec3.fromValues(light.position[0], light.position[1], light.position[2])
940
+ const dir = vec3.create()
941
+ vec3.normalize(dir, light.direction)
942
+
943
+ // Target = position + direction
944
+ const target = vec3.create()
945
+ vec3.add(target, pos, dir)
946
+
947
+ // Up vector - avoid parallel with direction
948
+ const up = Math.abs(dir[1]) > 0.9
949
+ ? vec3.fromValues(1, 0, 0)
950
+ : vec3.fromValues(0, 1, 0)
951
+
952
+ mat4.lookAt(lightView, pos, target, up)
953
+
954
+ // FOV based on outer cone angle (geom.z is cosine of angle)
955
+ // Convert from cosine to angle, double it for full cone
956
+ // Cap at 120 degrees (60 degree half-angle) for shadow quality
957
+ const outerCone = light.geom[2] || 0.7
958
+ const coneAngle = Math.acos(outerCone)
959
+ const maxShadowAngle = Math.PI / 3 // 60 degrees = 120 degree total FOV
960
+ const shadowAngle = Math.min(coneAngle, maxShadowAngle)
961
+ const fov = shadowAngle * 2.0 + 0.05 // Small margin
962
+
963
+ const near = 0.5 // Close enough to capture nearby shadows
964
+ const far = light.geom[0] || 10 // radius
965
+
966
+ this.perspectiveZO(lightProj, fov, 1.0, near, far)
967
+
968
+ const matrix = this.spotLightMatrices[slotIndex]
969
+ mat4.multiply(matrix, lightProj, lightView)
970
+
971
+ return matrix
972
+ }
973
+
974
+ /**
975
+ * Assign shadow slots to spotlights based on distance to camera and frustum visibility
976
+ * @param {Array} lights - Array of light objects
977
+ * @param {vec3} cameraPosition - Camera position
978
+ * @param {Frustum} cameraFrustum - Camera frustum for culling
979
+ * @returns {Object} Mapping info for shader
980
+ */
981
+ assignSpotShadowSlots(lights, cameraPosition, cameraFrustum) {
982
+ // Reset all slots
983
+ this.spotShadowSlots.fill(-1)
984
+
985
+ // Filter to spotlights (lightType == 2) that affect the visible area
986
+ const spotLights = []
987
+ let culledByFrustum = 0
988
+ let culledByDistance = 0
989
+
990
+ for (let i = 0; i < lights.length; i++) {
991
+ const light = lights[i]
992
+ if (!light || !light.enabled) continue
993
+ if (light.lightType !== 2) continue // Only spotlights
994
+
995
+ const lightRadius = light.geom?.[0] || 10
996
+
997
+ // Distance from light to camera
998
+ const dx = light.position[0] - cameraPosition[0]
999
+ const dy = light.position[1] - cameraPosition[1]
1000
+ const dz = light.position[2] - cameraPosition[2]
1001
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
1002
+
1003
+ // Skip lights too far from camera (shadow max distance + light radius)
1004
+ // The light could still affect visible geometry even if the light itself is far
1005
+ if (distance - lightRadius > this.shadowMaxDistance) {
1006
+ culledByDistance++
1007
+ continue
1008
+ }
1009
+
1010
+ // Frustum cull: check if light's bounding sphere intersects camera frustum
1011
+ // The bounding sphere is the light's area of effect
1012
+ if (cameraFrustum) {
1013
+ const lightBsphere = {
1014
+ center: light.position,
1015
+ radius: lightRadius
1016
+ }
1017
+ if (!cameraFrustum.testSpherePlanes(lightBsphere)) {
1018
+ culledByFrustum++
1019
+ continue
1020
+ }
1021
+ }
1022
+
1023
+ spotLights.push({
1024
+ index: i,
1025
+ light: light,
1026
+ distance: distance,
1027
+ radius: lightRadius
1028
+ })
1029
+ }
1030
+
1031
+ // Sort by distance (closest first)
1032
+ spotLights.sort((a, b) => a.distance - b.distance)
1033
+
1034
+ // Assign closest visible lights to shadow slots (up to maxSpotShadows)
1035
+ const assignments = []
1036
+ for (let slot = 0; slot < Math.min(spotLights.length, this.maxSpotShadows); slot++) {
1037
+ const spotLight = spotLights[slot]
1038
+
1039
+ this.spotShadowSlots[spotLight.index] = slot
1040
+
1041
+ // Calculate shadow fade factor (1.0 at shadowFadeStart, 0.0 at shadowMaxDistance)
1042
+ let fadeFactor = 1.0
1043
+ if (spotLight.distance > this.shadowFadeStart) {
1044
+ fadeFactor = 1.0 - (spotLight.distance - this.shadowFadeStart) /
1045
+ (this.shadowMaxDistance - this.shadowFadeStart)
1046
+ fadeFactor = Math.max(0, fadeFactor)
1047
+ }
1048
+
1049
+ assignments.push({
1050
+ slot: slot,
1051
+ lightIndex: spotLight.index,
1052
+ light: spotLight.light,
1053
+ distance: spotLight.distance,
1054
+ fadeFactor: fadeFactor
1055
+ })
1056
+ }
1057
+
1058
+ return {
1059
+ assignments: assignments,
1060
+ totalSpotLights: spotLights.length,
1061
+ culledByFrustum: culledByFrustum,
1062
+ culledByDistance: culledByDistance
1063
+ }
1064
+ }
1065
+
1066
+ async _execute(context) {
1067
+ const { device, stats } = this.engine
1068
+ const { camera, meshes, mainLight, lights } = context
1069
+
1070
+ if (!this.pipeline || !meshes) {
1071
+ console.warn('ShadowPass: No pipeline or meshes', { pipeline: !!this.pipeline, meshes: !!meshes })
1072
+ return
1073
+ }
1074
+
1075
+ // Clear bind group caches for skinned meshes to ensure fresh joint textures are bound
1076
+ // This prevents stale bind groups from causing shadow artifacts on animated meshes
1077
+ this._meshBindGroups = new WeakMap()
1078
+ if (this._skinBindGroups) {
1079
+ this._skinBindGroups = new WeakMap()
1080
+ }
1081
+
1082
+ // Track shadow pass stats
1083
+ let shadowDrawCalls = 0
1084
+ let shadowTriangles = 0
1085
+ let shadowCulledInstances = 0
1086
+
1087
+ // Check if main directional light is enabled
1088
+ const mainLightEnabled = !mainLight || mainLight.enabled !== false
1089
+
1090
+ // Calculate cascade matrices (centered on camera XZ) - even if disabled, for consistent state
1091
+ const dir = vec3.fromValues(
1092
+ mainLight?.direction?.[0] ?? -1,
1093
+ mainLight?.direction?.[1] ?? 1,
1094
+ mainLight?.direction?.[2] ?? -0.5
1095
+ )
1096
+ this.calculateCascadeMatrices(dir, camera)
1097
+
1098
+ // ===================
1099
+ // CASCADED DIRECTIONAL SHADOWS
1100
+ // ===================
1101
+
1102
+ // Uniform buffer layout: mat4 (16) + vec3+f32 (4) + alpha hash params (5) + padding (3) = 28 floats (112 bytes)
1103
+ const uniformData = new Float32Array(28)
1104
+ let totalInstances = 0
1105
+ let totalTriangles = 0
1106
+
1107
+ // Noise offset for alpha hashing - always static to avoid shimmer on cutout edges
1108
+ const noiseOffsetX = 0
1109
+ const noiseOffsetY = 0
1110
+
1111
+ // Get light direction for shadow bounding sphere calculation
1112
+ const lightDir = vec3.fromValues(
1113
+ mainLight?.direction?.[0] ?? -1,
1114
+ mainLight?.direction?.[1] ?? 1,
1115
+ mainLight?.direction?.[2] ?? -0.5
1116
+ )
1117
+ vec3.normalize(lightDir, lightDir)
1118
+
1119
+ // Ground level for shadow projection
1120
+ const groundLevel = this.settings?.planarReflection?.groundLevel ?? 0
1121
+
1122
+ // Create camera frustum for culling static meshes
1123
+ const cameraFrustum = this._createFrustumFromMatrix(camera.viewProj)
1124
+ const shadowConfig = this.settings?.culling?.shadow
1125
+ const shadowFrustumCullingEnabled = shadowConfig?.frustum !== false
1126
+ const shadowHiZEnabled = shadowConfig?.hiZ !== false && this.hizPass
1127
+ const shadowMaxDistance = shadowConfig?.maxDistance ?? 100
1128
+
1129
+ // Pre-filter static meshes by shadow bounding sphere visibility
1130
+ // This is used for BOTH cascade and spotlight shadows
1131
+ // Entity-managed meshes are already filtered in RenderGraph, but static meshes aren't
1132
+ const visibleMeshes = {}
1133
+ let meshFrustumCulled = 0
1134
+ let meshDistanceCulled = 0
1135
+ let meshOcclusionCulled = 0
1136
+ let meshNoBsphere = 0
1137
+
1138
+ for (const name in meshes) {
1139
+ const mesh = meshes[name]
1140
+ const geometry = mesh.geometry
1141
+ if (!geometry || geometry.instanceCount === 0) continue
1142
+
1143
+ // Entity-managed meshes (not static) are already culled - include them
1144
+ if (!mesh.static) {
1145
+ visibleMeshes[name] = mesh
1146
+ continue
1147
+ }
1148
+
1149
+ // For static meshes, apply shadow bounding sphere culling
1150
+ // For skinned meshes with multiple submeshes, use combined bsphere if available
1151
+ // This ensures all submeshes are culled together as a unit
1152
+ const localBsphere = mesh.combinedBsphere || geometry.getBoundingSphere?.()
1153
+ if (!localBsphere || localBsphere.radius <= 0) {
1154
+ // No bsphere - include but track it
1155
+ meshNoBsphere++
1156
+ visibleMeshes[name] = mesh
1157
+ continue
1158
+ }
1159
+
1160
+ // Has valid bsphere - apply culling
1161
+ // Get world bounding sphere (transform by first instance matrix)
1162
+ const matrix = geometry.instanceData?.subarray(0, 16)
1163
+ const worldBsphere = matrix ?
1164
+ transformBoundingSphere(localBsphere, matrix) :
1165
+ localBsphere
1166
+
1167
+ // Calculate shadow bounding sphere (only if main light enabled)
1168
+ // For spotlights only, use object's own bsphere for culling
1169
+ const shadowBsphere = mainLightEnabled
1170
+ ? calculateShadowBoundingSphere(worldBsphere, lightDir, groundLevel)
1171
+ : worldBsphere
1172
+
1173
+ // For skinned meshes, expand the shadow bsphere to account for animation
1174
+ // Animated poses can extend beyond the rest pose bounding sphere
1175
+ const skinnedExpansion = this.engine?.settings?.shadow?.skinnedBsphereExpansion ?? 2.0
1176
+ const cullBsphere = mesh.hasSkin ? {
1177
+ center: shadowBsphere.center,
1178
+ radius: shadowBsphere.radius * skinnedExpansion
1179
+ } : shadowBsphere
1180
+
1181
+ // Distance culling - skip if shadow sphere is too far from camera
1182
+ const dx = cullBsphere.center[0] - camera.position[0]
1183
+ const dy = cullBsphere.center[1] - camera.position[1]
1184
+ const dz = cullBsphere.center[2] - camera.position[2]
1185
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) - cullBsphere.radius
1186
+ if (distance > shadowMaxDistance) {
1187
+ meshDistanceCulled++
1188
+ continue
1189
+ }
1190
+
1191
+ // Frustum culling - skip if shadow not visible to camera
1192
+ if (shadowFrustumCullingEnabled && cameraFrustum) {
1193
+ if (!cameraFrustum.testSpherePlanes(cullBsphere)) {
1194
+ meshFrustumCulled++
1195
+ continue
1196
+ }
1197
+ }
1198
+
1199
+ // HiZ occlusion culling - skip if shadow sphere is fully occluded
1200
+ if (shadowHiZEnabled && this.hizPass) {
1201
+ const occluded = this.hizPass.testSphereOcclusion(
1202
+ cullBsphere,
1203
+ camera.viewProj,
1204
+ camera.near,
1205
+ camera.far,
1206
+ camera.position
1207
+ )
1208
+ if (occluded) {
1209
+ meshOcclusionCulled++
1210
+ continue
1211
+ }
1212
+ }
1213
+
1214
+ visibleMeshes[name] = mesh
1215
+ }
1216
+
1217
+ // Store mesh culling stats for reporting
1218
+ this._lastMeshFrustumCulled = meshFrustumCulled
1219
+ this._lastMeshDistanceCulled = meshDistanceCulled
1220
+ this._lastMeshOcclusionCulled = meshOcclusionCulled
1221
+ this._lastMeshNoBsphere = meshNoBsphere
1222
+
1223
+ // Only render cascade shadows if main light is enabled
1224
+ if (mainLightEnabled) {
1225
+ // Check if per-cascade filtering is enabled
1226
+ const cascadeFilterEnabled = this.settings?.culling?.shadow?.cascadeFilter !== false
1227
+
1228
+ // Per-cascade culling stats
1229
+ let cascadeCulledInstances = 0
1230
+
1231
+ // Render each cascade - submit separately to ensure correct matrix
1232
+ for (let cascade = 0; cascade < this.cascadeCount; cascade++) {
1233
+ // Update uniform buffer with this cascade's matrix
1234
+ uniformData.set(this.cascadeMatrices[cascade], 0) // 0-15
1235
+ uniformData.set([0, 100, 0], 16) // 16-18 (lightPosition)
1236
+ uniformData[19] = 0 // lightType: directional
1237
+ // Alpha hash params (enabled globally - per-mesh control via albedo texture)
1238
+ const globalLuminanceToAlpha = this.settings?.rendering?.luminanceToAlpha ? 1.0 : 0.0
1239
+ uniformData[20] = 1.0 // alphaHashEnabled
1240
+ uniformData[21] = 1.0 // alphaHashScale
1241
+ uniformData[22] = globalLuminanceToAlpha // luminanceToAlpha
1242
+ uniformData[23] = this.noiseSize // noiseSize
1243
+ uniformData[24] = noiseOffsetX // noiseOffsetX
1244
+ uniformData[25] = this.settings?.shadow?.surfaceBias ?? 0 // surfaceBias
1245
+ // 26-27 are padding
1246
+ uniformData[28] = lightDir[0] // lightDirection.x
1247
+ uniformData[29] = lightDir[1] // lightDirection.y
1248
+ uniformData[30] = lightDir[2] // lightDirection.z
1249
+ // 31 is padding
1250
+ device.queue.writeBuffer(this.uniformBuffer, 0, uniformData)
1251
+
1252
+ // Create command encoder for this cascade
1253
+ const cascadeEncoder = device.createCommandEncoder({
1254
+ label: `Shadow Cascade ${cascade}`
1255
+ })
1256
+
1257
+ // Render to this cascade's layer
1258
+ const cascadePass = cascadeEncoder.beginRenderPass({
1259
+ colorAttachments: [],
1260
+ depthStencilAttachment: {
1261
+ view: this.cascadeViews[cascade],
1262
+ depthClearValue: 1.0,
1263
+ depthLoadOp: 'clear',
1264
+ depthStoreOp: 'store',
1265
+ }
1266
+ })
1267
+
1268
+ cascadePass.setPipeline(this.pipeline)
1269
+
1270
+ // Collect filtered instances for this cascade
1271
+ const meshFilters = []
1272
+ let totalFilteredFloats = 0
1273
+ const instanceStride = 28 // floats per instance (matrix + posRadius + uvTransform + color)
1274
+
1275
+ for (const name in visibleMeshes) {
1276
+ const mesh = visibleMeshes[name]
1277
+ const geometry = mesh.geometry
1278
+ if (geometry.instanceCount === 0) continue
1279
+ if (cascade === 0) geometry.update() // Only update geometry once
1280
+
1281
+ // Apply per-cascade filtering if enabled
1282
+ let filtered = null
1283
+ if (cascadeFilterEnabled) {
1284
+ filtered = this._buildCascadeFilteredInstances(
1285
+ geometry,
1286
+ this.cascadeMatrices[cascade],
1287
+ lightDir,
1288
+ groundLevel,
1289
+ mesh.combinedBsphere // Use combined bsphere for skinned models
1290
+ )
1291
+
1292
+ if (filtered.count === 0) {
1293
+ cascadeCulledInstances += geometry.instanceCount
1294
+ continue
1295
+ }
1296
+
1297
+ cascadeCulledInstances += geometry.instanceCount - filtered.count
1298
+ }
1299
+
1300
+ // If filtering returned useOriginal, or filtering disabled, use original buffer
1301
+ if (!cascadeFilterEnabled || filtered?.useOriginal) {
1302
+ meshFilters.push({
1303
+ mesh,
1304
+ geometry,
1305
+ useOriginal: true,
1306
+ count: geometry.instanceCount
1307
+ })
1308
+ } else {
1309
+ // Need to use filtered data
1310
+ meshFilters.push({
1311
+ mesh,
1312
+ geometry,
1313
+ filtered,
1314
+ byteOffset: totalFilteredFloats * 4
1315
+ })
1316
+ totalFilteredFloats += filtered.count * instanceStride
1317
+ }
1318
+ }
1319
+
1320
+ // Create/resize cascade temp buffer if needed
1321
+ const totalBufferSize = totalFilteredFloats * 4
1322
+ if (totalBufferSize > 0) {
1323
+ if (!this._cascadeTempBuffer || this._cascadeTempBufferSize < totalBufferSize) {
1324
+ if (this._cascadeTempBuffer) {
1325
+ this._cascadeTempBuffer.destroy()
1326
+ }
1327
+ const allocSize = Math.max(totalBufferSize, 65536) // Min 64KB
1328
+ this._cascadeTempBuffer = device.createBuffer({
1329
+ size: allocSize,
1330
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1331
+ label: 'Cascade Shadow Temp Instance Buffer'
1332
+ })
1333
+ this._cascadeTempBufferSize = allocSize
1334
+ }
1335
+
1336
+ // Write filtered instance data at their respective offsets
1337
+ for (const mf of meshFilters) {
1338
+ if (!mf.useOriginal && mf.filtered?.data) {
1339
+ device.queue.writeBuffer(this._cascadeTempBuffer, mf.byteOffset, mf.filtered.data)
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ // Separate meshes by luminanceToAlpha flag for proper uniform handling
1345
+ const regularMeshes = meshFilters.filter(mf => !mf.mesh.material?.luminanceToAlpha)
1346
+ const luminanceMeshes = meshFilters.filter(mf => mf.mesh.material?.luminanceToAlpha)
1347
+
1348
+ // Render regular meshes (luminanceToAlpha = 0)
1349
+ for (const mf of regularMeshes) {
1350
+ const bindGroup = this.getBindGroupForMesh(mf.mesh)
1351
+ cascadePass.setBindGroup(0, bindGroup)
1352
+
1353
+ cascadePass.setVertexBuffer(0, mf.geometry.vertexBuffer)
1354
+
1355
+ if (mf.useOriginal) {
1356
+ cascadePass.setVertexBuffer(1, mf.geometry.instanceBuffer)
1357
+ cascadePass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1358
+ cascadePass.drawIndexed(mf.geometry.indexArray.length, mf.count)
1359
+
1360
+ shadowDrawCalls++
1361
+ shadowTriangles += (mf.geometry.indexArray.length / 3) * mf.count
1362
+ } else {
1363
+ cascadePass.setVertexBuffer(1, this._cascadeTempBuffer, mf.byteOffset)
1364
+ cascadePass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1365
+ cascadePass.drawIndexed(mf.geometry.indexArray.length, mf.filtered.count)
1366
+
1367
+ shadowDrawCalls++
1368
+ shadowTriangles += (mf.geometry.indexArray.length / 3) * mf.filtered.count
1369
+ }
1370
+
1371
+ if (cascade === 0) {
1372
+ const count = mf.useOriginal ? mf.count : mf.filtered.count
1373
+ totalInstances += count
1374
+ totalTriangles += (mf.geometry.indexArray.length / 3) * count
1375
+ }
1376
+ }
1377
+
1378
+ cascadePass.end()
1379
+ device.queue.submit([cascadeEncoder.finish()])
1380
+
1381
+ // Render luminanceToAlpha meshes in separate pass with updated uniform
1382
+ if (luminanceMeshes.length > 0) {
1383
+ uniformData[22] = 1.0 // Enable luminanceToAlpha
1384
+ device.queue.writeBuffer(this.uniformBuffer, 0, uniformData)
1385
+
1386
+ const lumEncoder = device.createCommandEncoder({ label: `Shadow Cascade ${cascade} LumAlpha` })
1387
+ const lumPass = lumEncoder.beginRenderPass({
1388
+ colorAttachments: [],
1389
+ depthStencilAttachment: {
1390
+ view: this.cascadeViews[cascade],
1391
+ depthClearValue: 1.0,
1392
+ depthLoadOp: 'load', // Keep existing depth
1393
+ depthStoreOp: 'store',
1394
+ }
1395
+ })
1396
+
1397
+ lumPass.setPipeline(this.pipeline)
1398
+
1399
+ for (const mf of luminanceMeshes) {
1400
+ const bindGroup = this.getBindGroupForMesh(mf.mesh)
1401
+ lumPass.setBindGroup(0, bindGroup)
1402
+
1403
+ lumPass.setVertexBuffer(0, mf.geometry.vertexBuffer)
1404
+
1405
+ if (mf.useOriginal) {
1406
+ lumPass.setVertexBuffer(1, mf.geometry.instanceBuffer)
1407
+ lumPass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1408
+ lumPass.drawIndexed(mf.geometry.indexArray.length, mf.count)
1409
+
1410
+ shadowDrawCalls++
1411
+ shadowTriangles += (mf.geometry.indexArray.length / 3) * mf.count
1412
+ } else {
1413
+ lumPass.setVertexBuffer(1, this._cascadeTempBuffer, mf.byteOffset)
1414
+ lumPass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1415
+ lumPass.drawIndexed(mf.geometry.indexArray.length, mf.filtered.count)
1416
+
1417
+ shadowDrawCalls++
1418
+ shadowTriangles += (mf.geometry.indexArray.length / 3) * mf.filtered.count
1419
+ }
1420
+
1421
+ if (cascade === 0) {
1422
+ const count = mf.useOriginal ? mf.count : mf.filtered.count
1423
+ totalInstances += count
1424
+ totalTriangles += (mf.geometry.indexArray.length / 3) * count
1425
+ }
1426
+ }
1427
+
1428
+ lumPass.end()
1429
+ device.queue.submit([lumEncoder.finish()])
1430
+
1431
+ // Reset luminanceToAlpha for next cascade
1432
+ uniformData[22] = 0.0
1433
+ }
1434
+ }
1435
+
1436
+ // Store cascade culling stats
1437
+ shadowCulledInstances += cascadeCulledInstances
1438
+
1439
+ } // End if (mainLightEnabled)
1440
+
1441
+ // Update cascade matrices storage buffer (always, for consistent state)
1442
+ for (let i = 0; i < this.cascadeCount; i++) {
1443
+ this.cascadeMatricesData.set(this.cascadeMatrices[i], i * 16)
1444
+ }
1445
+ device.queue.writeBuffer(this.cascadeMatricesBuffer, 0, this.cascadeMatricesData)
1446
+
1447
+ // Update camera shadow detection (for adaptive volumetric fog)
1448
+ if (mainLightEnabled) {
1449
+ this._updateCameraShadowDetection(camera)
1450
+ }
1451
+
1452
+ // ===================
1453
+ // SPOTLIGHT SHADOWS (always runs, even when main light is disabled)
1454
+ // ===================
1455
+
1456
+ // If main light was disabled, we need to update geometry buffers here
1457
+ // (normally done in cascade loop, but that was skipped)
1458
+ if (!mainLightEnabled) {
1459
+ for (const name in meshes) {
1460
+ const mesh = meshes[name]
1461
+ const geometry = mesh.geometry
1462
+ if (geometry.instanceCount > 0) {
1463
+ geometry.update()
1464
+ }
1465
+ }
1466
+ }
1467
+
1468
+ // Reset slot info
1469
+ this.lastSlotInfo = { assignments: [], totalSpotLights: 0, culledByFrustum: 0, culledByDistance: 0 }
1470
+ this.spotShadowSlots.fill(-1)
1471
+
1472
+ // Assign shadow slots to closest spotlights that affect visible area
1473
+ if (lights && lights.length > 0 && this.spotShadowAtlas) {
1474
+ // Create camera frustum for culling spotlights
1475
+ const cameraFrustum = this._createFrustumFromMatrix(camera.viewProj)
1476
+ const slotInfo = this.assignSpotShadowSlots(lights, camera.position, cameraFrustum)
1477
+ this.lastSlotInfo = slotInfo
1478
+
1479
+ // Clear the atlas first - use separate encoder and submit immediately
1480
+ const clearEncoder = device.createCommandEncoder({ label: 'Spot Shadow Clear' })
1481
+ const clearPass = clearEncoder.beginRenderPass({
1482
+ colorAttachments: [],
1483
+ depthStencilAttachment: {
1484
+ view: this.spotShadowAtlasView,
1485
+ depthClearValue: 1.0,
1486
+ depthLoadOp: 'clear',
1487
+ depthStoreOp: 'store',
1488
+ }
1489
+ })
1490
+ clearPass.end()
1491
+ device.queue.submit([clearEncoder.finish()])
1492
+
1493
+ // Render each spotlight shadow - IMPORTANT: Submit each one separately
1494
+ // because writeBuffer calls are queued and would all execute before any render pass
1495
+ for (const assignment of slotInfo.assignments) {
1496
+ // Calculate spotlight matrix
1497
+ this.calculateSpotLightMatrix(assignment.light, assignment.slot)
1498
+ const spotMatrix = this.spotLightMatrices[assignment.slot]
1499
+
1500
+ // Extract spotlight parameters for cone culling
1501
+ const lightPos = assignment.light.position
1502
+ const lightRadius = assignment.light.geom[0] || 10
1503
+
1504
+ // Normalize light direction
1505
+ const spotLightDir = vec3.create()
1506
+ vec3.normalize(spotLightDir, assignment.light.direction)
1507
+
1508
+ // Max shadow distance is minimum of light radius and spotMaxDistance setting
1509
+ const spotShadowMaxDist = Math.min(lightRadius, this.shadowMaxDistance)
1510
+
1511
+ // Cone angle from outer cone (geom[2] is cosine of half-angle)
1512
+ const outerConeCos = assignment.light.geom[2] || 0.7
1513
+ const coneAngle = Math.acos(outerConeCos)
1514
+
1515
+ // Update uniform buffer with spotlight matrix and alpha hash params
1516
+ uniformData.set(spotMatrix, 0) // 0-15
1517
+ uniformData.set(lightPos, 16) // 16-18 (lightPosition)
1518
+ uniformData[19] = 2 // lightType: spotlight
1519
+ // Alpha hash params (same as cascaded shadows)
1520
+ const spotLuminanceToAlpha = this.settings?.rendering?.luminanceToAlpha ? 1.0 : 0.0
1521
+ uniformData[20] = 1.0 // alphaHashEnabled
1522
+ uniformData[21] = 1.0 // alphaHashScale
1523
+ uniformData[22] = spotLuminanceToAlpha // luminanceToAlpha
1524
+ uniformData[23] = this.noiseSize // noiseSize
1525
+ uniformData[24] = noiseOffsetX // noiseOffsetX
1526
+ uniformData[25] = this.settings?.shadow?.surfaceBias ?? 0 // surfaceBias
1527
+ // 26-27 are padding
1528
+ uniformData[28] = spotLightDir[0] // lightDirection.x
1529
+ uniformData[29] = spotLightDir[1] // lightDirection.y
1530
+ uniformData[30] = spotLightDir[2] // lightDirection.z
1531
+ // 31 is padding
1532
+ device.queue.writeBuffer(this.uniformBuffer, 0, uniformData)
1533
+
1534
+ // Calculate viewport for this slot in atlas
1535
+ const col = assignment.slot % this.spotTilesPerRow
1536
+ const row = Math.floor(assignment.slot / this.spotTilesPerRow)
1537
+ const x = col * this.spotTileSize
1538
+ const y = row * this.spotTileSize
1539
+
1540
+ // Create a separate command encoder for each spotlight to ensure
1541
+ // the writeBuffer takes effect before rendering
1542
+ const spotEncoder = device.createCommandEncoder({
1543
+ label: `Spot Shadow ${assignment.slot}`
1544
+ })
1545
+
1546
+ // Render to this tile using viewport
1547
+ const spotPass = spotEncoder.beginRenderPass({
1548
+ colorAttachments: [],
1549
+ depthStencilAttachment: {
1550
+ view: this.spotShadowAtlasView,
1551
+ depthClearValue: 1.0,
1552
+ depthLoadOp: 'load', // Don't clear, we already did
1553
+ depthStoreOp: 'store',
1554
+ }
1555
+ })
1556
+
1557
+ spotPass.setPipeline(this.pipeline)
1558
+ spotPass.setViewport(x, y, this.spotTileSize, this.spotTileSize, 0, 1)
1559
+ spotPass.setScissorRect(x, y, this.spotTileSize, this.spotTileSize)
1560
+
1561
+ // First pass: collect all filtered instances and calculate offsets
1562
+ const meshFilters = []
1563
+ let totalFilteredFloats = 0
1564
+ let spotCulledInstances = 0
1565
+ const instanceStride = 28 // floats per instance (matrix + posRadius + uvTransform + color)
1566
+
1567
+ // Use visibleMeshes for spotlight shadows - applies same static mesh
1568
+ // culling (frustum, distance, HiZ) as cascade shadows
1569
+ for (const name in visibleMeshes) {
1570
+ const mesh = visibleMeshes[name]
1571
+ const geometry = mesh.geometry
1572
+ if (geometry.instanceCount === 0) continue
1573
+
1574
+ // Build filtered instances using cone culling
1575
+ const filtered = this._buildFilteredInstances(
1576
+ geometry, lightPos, spotLightDir, spotShadowMaxDist, coneAngle,
1577
+ mesh.combinedBsphere // Use combined bsphere for skinned models
1578
+ )
1579
+
1580
+ if (filtered.count === 0) {
1581
+ spotCulledInstances += geometry.instanceCount
1582
+ continue
1583
+ }
1584
+
1585
+ spotCulledInstances += geometry.instanceCount - filtered.count
1586
+
1587
+ // Handle useOriginal optimization (all instances visible)
1588
+ if (filtered.useOriginal) {
1589
+ meshFilters.push({
1590
+ mesh,
1591
+ geometry,
1592
+ useOriginal: true,
1593
+ count: filtered.count
1594
+ })
1595
+ } else {
1596
+ meshFilters.push({
1597
+ mesh,
1598
+ geometry,
1599
+ filtered,
1600
+ byteOffset: totalFilteredFloats * 4 // offset in bytes
1601
+ })
1602
+ totalFilteredFloats += filtered.count * instanceStride
1603
+ }
1604
+ }
1605
+
1606
+ // Create/resize buffer if needed for filtered instances
1607
+ const totalBufferSize = totalFilteredFloats * 4
1608
+ if (totalBufferSize > 0) {
1609
+ if (!this._tempInstanceBuffer || this._tempInstanceBufferSize < totalBufferSize) {
1610
+ if (this._tempInstanceBuffer) {
1611
+ this._tempInstanceBuffer.destroy()
1612
+ }
1613
+ const allocSize = Math.max(totalBufferSize, 16384) // Min 16KB
1614
+ this._tempInstanceBuffer = device.createBuffer({
1615
+ size: allocSize,
1616
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1617
+ label: 'Spot Shadow Temp Instance Buffer'
1618
+ })
1619
+ this._tempInstanceBufferSize = allocSize
1620
+ }
1621
+
1622
+ // Write filtered instance data at their respective offsets
1623
+ for (const mf of meshFilters) {
1624
+ if (!mf.useOriginal && mf.filtered?.data) {
1625
+ device.queue.writeBuffer(this._tempInstanceBuffer, mf.byteOffset, mf.filtered.data)
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ // Separate meshes by luminanceToAlpha flag
1631
+ const regularMeshes = meshFilters.filter(mf => !mf.mesh.material?.luminanceToAlpha)
1632
+ const luminanceMeshes = meshFilters.filter(mf => mf.mesh.material?.luminanceToAlpha)
1633
+
1634
+ // Render regular meshes (luminanceToAlpha = 0)
1635
+ for (const mf of regularMeshes) {
1636
+ const bindGroup = this.getBindGroupForMesh(mf.mesh)
1637
+ spotPass.setBindGroup(0, bindGroup)
1638
+ spotPass.setVertexBuffer(0, mf.geometry.vertexBuffer)
1639
+
1640
+ if (mf.useOriginal) {
1641
+ spotPass.setVertexBuffer(1, mf.geometry.instanceBuffer)
1642
+ spotPass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1643
+ spotPass.drawIndexed(mf.geometry.indexArray.length, mf.count)
1644
+ } else {
1645
+ spotPass.setVertexBuffer(1, this._tempInstanceBuffer, mf.byteOffset)
1646
+ spotPass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1647
+ spotPass.drawIndexed(mf.geometry.indexArray.length, mf.filtered.count)
1648
+ }
1649
+ }
1650
+
1651
+ spotPass.end()
1652
+ device.queue.submit([spotEncoder.finish()])
1653
+
1654
+ // Render luminanceToAlpha meshes in separate pass
1655
+ if (luminanceMeshes.length > 0) {
1656
+ uniformData[22] = 1.0 // Enable luminanceToAlpha
1657
+ device.queue.writeBuffer(this.uniformBuffer, 0, uniformData)
1658
+
1659
+ const lumEncoder = device.createCommandEncoder({ label: `Spot Shadow ${assignment.slot} LumAlpha` })
1660
+ const lumPass = lumEncoder.beginRenderPass({
1661
+ colorAttachments: [],
1662
+ depthStencilAttachment: {
1663
+ view: this.spotShadowAtlasView,
1664
+ depthClearValue: 1.0,
1665
+ depthLoadOp: 'load', // Keep existing depth
1666
+ depthStoreOp: 'store',
1667
+ }
1668
+ })
1669
+
1670
+ lumPass.setPipeline(this.pipeline)
1671
+ lumPass.setViewport(x, y, this.spotTileSize, this.spotTileSize, 0, 1)
1672
+ lumPass.setScissorRect(x, y, this.spotTileSize, this.spotTileSize)
1673
+
1674
+ for (const mf of luminanceMeshes) {
1675
+ const bindGroup = this.getBindGroupForMesh(mf.mesh)
1676
+ lumPass.setBindGroup(0, bindGroup)
1677
+ lumPass.setVertexBuffer(0, mf.geometry.vertexBuffer)
1678
+
1679
+ if (mf.useOriginal) {
1680
+ lumPass.setVertexBuffer(1, mf.geometry.instanceBuffer)
1681
+ lumPass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1682
+ lumPass.drawIndexed(mf.geometry.indexArray.length, mf.count)
1683
+ } else {
1684
+ lumPass.setVertexBuffer(1, this._tempInstanceBuffer, mf.byteOffset)
1685
+ lumPass.setIndexBuffer(mf.geometry.indexBuffer, 'uint32')
1686
+ lumPass.drawIndexed(mf.geometry.indexArray.length, mf.filtered.count)
1687
+ }
1688
+ }
1689
+
1690
+ lumPass.end()
1691
+ device.queue.submit([lumEncoder.finish()])
1692
+
1693
+ // Reset luminanceToAlpha
1694
+ uniformData[22] = 0.0
1695
+ }
1696
+
1697
+ // Track stats for spotlight shadows
1698
+ for (const mf of meshFilters) {
1699
+ shadowDrawCalls++
1700
+ const count = mf.useOriginal ? mf.count : mf.filtered.count
1701
+ shadowTriangles += (mf.geometry.indexArray.length / 3) * count
1702
+ }
1703
+ shadowCulledInstances += spotCulledInstances
1704
+ }
1705
+
1706
+ // Update spot matrices storage buffer with all calculated matrices
1707
+ for (let i = 0; i < this.maxSpotShadows; i++) {
1708
+ this.spotMatricesData.set(this.spotLightMatrices[i], i * 16)
1709
+ }
1710
+ device.queue.writeBuffer(this.spotMatricesBuffer, 0, this.spotMatricesData)
1711
+
1712
+
1713
+ // IMPORTANT: Restore directional light matrix to uniform buffer
1714
+ // This prevents the last spotlight matrix from corrupting subsequent passes
1715
+ uniformData.set(this.directionalLightMatrix, 0)
1716
+ uniformData.set([0, 100, 0], 16)
1717
+ uniformData[19] = 0 // Light type: directional
1718
+ device.queue.writeBuffer(this.uniformBuffer, 0, uniformData)
1719
+ }
1720
+
1721
+ // Add shadow stats to global stats
1722
+ stats.shadowDrawCalls = shadowDrawCalls
1723
+ stats.shadowTriangles = shadowTriangles
1724
+ stats.shadowCulledInstances = shadowCulledInstances
1725
+
1726
+ // Add mesh culling stats (stored from mainLightEnabled block)
1727
+ stats.shadowMeshFrustumCulled = this._lastMeshFrustumCulled || 0
1728
+ stats.shadowMeshDistanceCulled = this._lastMeshDistanceCulled || 0
1729
+ stats.shadowMeshOcclusionCulled = this._lastMeshOcclusionCulled || 0
1730
+ stats.shadowMeshNoBsphere = this._lastMeshNoBsphere || 0
1731
+ }
1732
+
1733
+ async _resize(width, height) {
1734
+ // Shadow maps don't resize with screen
1735
+ }
1736
+
1737
+ _destroy() {
1738
+ if (this.directionalShadowMap) {
1739
+ this.directionalShadowMap.destroy()
1740
+ }
1741
+ if (this.spotShadowAtlas) {
1742
+ this.spotShadowAtlas.destroy()
1743
+ }
1744
+ }
1745
+
1746
+ /**
1747
+ * Get shadow map texture for lighting pass (texture array)
1748
+ */
1749
+ getShadowMap() {
1750
+ return this.directionalShadowMap
1751
+ }
1752
+
1753
+ /**
1754
+ * Get shadow map view for binding (2d-array view)
1755
+ */
1756
+ getShadowMapView() {
1757
+ return this.directionalShadowMapView
1758
+ }
1759
+
1760
+ /**
1761
+ * Get cascade matrices storage buffer
1762
+ */
1763
+ getCascadeMatricesBuffer() {
1764
+ return this.cascadeMatricesBuffer
1765
+ }
1766
+
1767
+ /**
1768
+ * Get cascade sizes array
1769
+ */
1770
+ getCascadeSizes() {
1771
+ return this.cascadeSizes
1772
+ }
1773
+
1774
+ /**
1775
+ * Get number of cascades
1776
+ */
1777
+ getCascadeCount() {
1778
+ return this.cascadeCount
1779
+ }
1780
+
1781
+ /**
1782
+ * Get shadow sampler (comparison sampler)
1783
+ */
1784
+ getShadowSampler() {
1785
+ return this.shadowSampler
1786
+ }
1787
+
1788
+ /**
1789
+ * Get depth sampler (regular sampler)
1790
+ */
1791
+ getDepthSampler() {
1792
+ return this.depthSampler
1793
+ }
1794
+
1795
+ /**
1796
+ * Get light matrix for shader
1797
+ */
1798
+ getLightMatrix() {
1799
+ return this.directionalLightMatrix
1800
+ }
1801
+
1802
+ /**
1803
+ * Get spot shadow atlas texture
1804
+ */
1805
+ getSpotShadowAtlas() {
1806
+ return this.spotShadowAtlas
1807
+ }
1808
+
1809
+ /**
1810
+ * Get spot shadow atlas view
1811
+ */
1812
+ getSpotShadowAtlasView() {
1813
+ return this.spotShadowAtlasView
1814
+ }
1815
+
1816
+ /**
1817
+ * Get spotlight shadow matrices (array of mat4)
1818
+ */
1819
+ getSpotLightMatrices() {
1820
+ return this.spotLightMatrices
1821
+ }
1822
+
1823
+ /**
1824
+ * Get the storage buffer containing spot shadow matrices
1825
+ */
1826
+ getSpotMatricesBuffer() {
1827
+ return this.spotMatricesBuffer
1828
+ }
1829
+
1830
+ /**
1831
+ * Get slot assignments for each light (-1 if no shadow)
1832
+ */
1833
+ getSpotShadowSlots() {
1834
+ return this.spotShadowSlots
1835
+ }
1836
+
1837
+ /**
1838
+ * Get shadow parameters for shader
1839
+ */
1840
+ getSpotShadowParams() {
1841
+ return {
1842
+ atlasSize: [this.spotAtlasSize, this.spotAtlasHeight],
1843
+ tileSize: this.spotTileSize,
1844
+ tilesPerRow: this.spotTilesPerRow,
1845
+ maxSlots: this.maxSpotShadows,
1846
+ fadeStart: this.shadowFadeStart,
1847
+ maxDistance: this.shadowMaxDistance
1848
+ }
1849
+ }
1850
+
1851
+ /**
1852
+ * Get last frame's slot assignment info (for debugging)
1853
+ */
1854
+ getLastSlotInfo() {
1855
+ return this.lastSlotInfo
1856
+ }
1857
+
1858
+ /**
1859
+ * Get cascade matrices as JavaScript arrays (for CPU-side calculations)
1860
+ */
1861
+ getCascadeMatrices() {
1862
+ return this.cascadeMatrices
1863
+ }
1864
+
1865
+ /**
1866
+ * Create resources for camera shadow detection
1867
+ * Uses a compute shader to sample shadow at camera position
1868
+ */
1869
+ async _createCameraShadowDetection() {
1870
+ const { device } = this.engine
1871
+
1872
+ // Create uniform buffer for camera position and cascade matrices
1873
+ // vec3 cameraPos + pad + 3 x mat4 cascadeMatrices = 4 + 48 = 52 floats = 208 bytes
1874
+ this._cameraShadowUniformBuffer = device.createBuffer({
1875
+ label: 'Camera Shadow Detection Uniforms',
1876
+ size: 256, // Aligned
1877
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1878
+ })
1879
+
1880
+ // Create output buffer (1 float for shadow result)
1881
+ this._cameraShadowBuffer = device.createBuffer({
1882
+ label: 'Camera Shadow Result',
1883
+ size: 4,
1884
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
1885
+ })
1886
+
1887
+ // Create readback buffer
1888
+ this._cameraShadowReadBuffer = device.createBuffer({
1889
+ label: 'Camera Shadow Readback',
1890
+ size: 4,
1891
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1892
+ })
1893
+
1894
+ // Create compute shader
1895
+ const shaderModule = device.createShaderModule({
1896
+ label: 'Camera Shadow Detection Shader',
1897
+ code: `
1898
+ struct Uniforms {
1899
+ cameraPosition: vec3f,
1900
+ _pad0: f32,
1901
+ cascadeMatrix0: mat4x4f,
1902
+ cascadeMatrix1: mat4x4f,
1903
+ cascadeMatrix2: mat4x4f,
1904
+ }
1905
+
1906
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
1907
+ @group(0) @binding(1) var shadowMap: texture_depth_2d_array;
1908
+ @group(0) @binding(2) var shadowSampler: sampler_comparison;
1909
+ @group(0) @binding(3) var<storage, read_write> result: f32;
1910
+
1911
+ fn sampleShadowCascade(worldPos: vec3f, cascadeMatrix: mat4x4f, cascadeIndex: i32) -> f32 {
1912
+ let lightSpacePos = cascadeMatrix * vec4f(worldPos, 1.0);
1913
+ let projCoords = lightSpacePos.xyz / lightSpacePos.w;
1914
+
1915
+ // Convert to UV space
1916
+ let uv = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
1917
+
1918
+ // Check bounds
1919
+ if (uv.x < 0.01 || uv.x > 0.99 || uv.y < 0.01 || uv.y > 0.99 ||
1920
+ projCoords.z < 0.0 || projCoords.z > 1.0) {
1921
+ return -1.0; // Out of bounds, try next cascade
1922
+ }
1923
+
1924
+ let bias = 0.005;
1925
+ let depth = projCoords.z - bias;
1926
+ return textureSampleCompareLevel(shadowMap, shadowSampler, uv, cascadeIndex, depth);
1927
+ }
1928
+
1929
+ @compute @workgroup_size(1)
1930
+ fn main() {
1931
+ let pos = uniforms.cameraPosition;
1932
+
1933
+ // Sample multiple points around camera (5m sphere)
1934
+ var totalShadow = 0.0;
1935
+ var sampleCount = 0.0;
1936
+
1937
+ let offsets = array<vec3f, 7>(
1938
+ vec3f(0.0, 0.0, 0.0), // Center
1939
+ vec3f(0.0, 3.0, 0.0), // Above
1940
+ vec3f(0.0, -2.0, 0.0), // Below
1941
+ vec3f(4.0, 0.0, 0.0), // Right
1942
+ vec3f(-4.0, 0.0, 0.0), // Left
1943
+ vec3f(0.0, 0.0, 4.0), // Front
1944
+ vec3f(0.0, 0.0, -4.0), // Back
1945
+ );
1946
+
1947
+ for (var i = 0; i < 7; i++) {
1948
+ let samplePos = pos + offsets[i];
1949
+
1950
+ // Try cascade 0 first (closest)
1951
+ var shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix0, 0);
1952
+ if (shadow < 0.0) {
1953
+ // Try cascade 1
1954
+ shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix1, 1);
1955
+ }
1956
+ if (shadow < 0.0) {
1957
+ // Try cascade 2
1958
+ shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix2, 2);
1959
+ }
1960
+
1961
+ if (shadow >= 0.0) {
1962
+ totalShadow += shadow;
1963
+ sampleCount += 1.0;
1964
+ }
1965
+ }
1966
+
1967
+ // Average shadow (0 = all in shadow, 1 = all lit)
1968
+ // If no valid samples, assume lit
1969
+ if (sampleCount > 0.0) {
1970
+ result = totalShadow / sampleCount;
1971
+ } else {
1972
+ result = 1.0;
1973
+ }
1974
+ }
1975
+ `
1976
+ })
1977
+
1978
+ // Create bind group layout
1979
+ this._cameraShadowBGL = device.createBindGroupLayout({
1980
+ label: 'Camera Shadow Detection BGL',
1981
+ entries: [
1982
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
1983
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth', viewDimension: '2d-array' } },
1984
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, sampler: { type: 'comparison' } },
1985
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
1986
+ ]
1987
+ })
1988
+
1989
+ // Create pipeline
1990
+ this._cameraShadowPipeline = await device.createComputePipelineAsync({
1991
+ label: 'Camera Shadow Detection Pipeline',
1992
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this._cameraShadowBGL] }),
1993
+ compute: { module: shaderModule, entryPoint: 'main' },
1994
+ })
1995
+ }
1996
+
1997
+ /**
1998
+ * Update camera shadow detection (called during execute)
1999
+ * Dispatches compute shader and starts async readback
2000
+ */
2001
+ _updateCameraShadowDetection(camera) {
2002
+ if (!this._cameraShadowPipeline || !this.directionalShadowMap) return
2003
+
2004
+ // Skip if a readback is already pending (buffer is mapped)
2005
+ if (this._cameraShadowPending) return
2006
+
2007
+ const { device } = this.engine
2008
+ const cameraPos = camera.position || [0, 0, 0]
2009
+
2010
+ // Update uniform buffer
2011
+ const data = new Float32Array(64) // 256 bytes / 4
2012
+ data[0] = cameraPos[0]
2013
+ data[1] = cameraPos[1]
2014
+ data[2] = cameraPos[2]
2015
+ data[3] = 0 // padding
2016
+
2017
+ // Copy cascade matrices
2018
+ if (this.cascadeMatrices[0]) data.set(this.cascadeMatrices[0], 4)
2019
+ if (this.cascadeMatrices[1]) data.set(this.cascadeMatrices[1], 20)
2020
+ if (this.cascadeMatrices[2]) data.set(this.cascadeMatrices[2], 36)
2021
+
2022
+ device.queue.writeBuffer(this._cameraShadowUniformBuffer, 0, data)
2023
+
2024
+ // Create bind group (recreated each frame as shadow map view might change)
2025
+ const bindGroup = device.createBindGroup({
2026
+ layout: this._cameraShadowBGL,
2027
+ entries: [
2028
+ { binding: 0, resource: { buffer: this._cameraShadowUniformBuffer } },
2029
+ { binding: 1, resource: this.directionalShadowMapView },
2030
+ { binding: 2, resource: this.shadowSampler },
2031
+ { binding: 3, resource: { buffer: this._cameraShadowBuffer } },
2032
+ ]
2033
+ })
2034
+
2035
+ // Dispatch compute shader
2036
+ const encoder = device.createCommandEncoder({ label: 'Camera Shadow Detection' })
2037
+ const pass = encoder.beginComputePass()
2038
+ pass.setPipeline(this._cameraShadowPipeline)
2039
+ pass.setBindGroup(0, bindGroup)
2040
+ pass.dispatchWorkgroups(1)
2041
+ pass.end()
2042
+
2043
+ // Copy result to readback buffer
2044
+ encoder.copyBufferToBuffer(this._cameraShadowBuffer, 0, this._cameraShadowReadBuffer, 0, 4)
2045
+ device.queue.submit([encoder.finish()])
2046
+
2047
+ // Start async readback
2048
+ this._cameraShadowPending = true
2049
+ this._cameraShadowReadBuffer.mapAsync(GPUMapMode.READ).then(() => {
2050
+ const data = new Float32Array(this._cameraShadowReadBuffer.getMappedRange())
2051
+ const shadowValue = data[0]
2052
+ this._cameraShadowReadBuffer.unmap()
2053
+ this._cameraShadowPending = false
2054
+
2055
+ // Camera is "in shadow" if average shadow value is low
2056
+ // Threshold of 0.3 means mostly in shadow
2057
+ this._cameraInShadow = shadowValue < 0.3
2058
+ }).catch(() => {
2059
+ this._cameraShadowPending = false
2060
+ })
2061
+ }
2062
+
2063
+ /**
2064
+ * Check if camera is in shadow (uses async readback result from previous frames)
2065
+ * @returns {boolean} True if camera is mostly in shadow
2066
+ */
2067
+ isCameraInShadow() {
2068
+ return this._cameraInShadow
2069
+ }
2070
+ }
2071
+
2072
+ export { ShadowPass }