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.
- package/LICENSE.txt +0 -0
- package/README.md +0 -0
- package/dist/Renderer.cjs +20844 -0
- package/dist/Renderer.cjs.map +1 -0
- package/dist/Renderer.js +20827 -0
- package/dist/Renderer.js.map +1 -0
- package/dist/client.cjs +91 -260
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +68 -215
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +165 -432
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +117 -370
- package/dist/server.js.map +1 -1
- package/dist/terminal.cjs +113 -200
- package/dist/terminal.cjs.map +1 -1
- package/dist/terminal.js +50 -51
- package/dist/terminal.js.map +1 -1
- package/dist/utils-CRhi1BDa.cjs +259 -0
- package/dist/utils-CRhi1BDa.cjs.map +1 -0
- package/dist/utils-D7tXt6-2.js +260 -0
- package/dist/utils-D7tXt6-2.js.map +1 -0
- package/package.json +19 -15
- package/src/{client.ts → network/client.js} +170 -403
- package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
- package/src/{compress-node.ts → network/compress-node.js} +8 -14
- package/src/{server.ts → network/server.js} +229 -317
- package/src/{terminal.js → network/terminal.js} +0 -0
- package/src/{topazcube.ts → network/topazcube.js} +2 -2
- package/src/network/utils.js +375 -0
- package/src/renderer/Camera.js +191 -0
- package/src/renderer/DebugUI.js +703 -0
- package/src/renderer/Geometry.js +1049 -0
- package/src/renderer/Material.js +64 -0
- package/src/renderer/Mesh.js +211 -0
- package/src/renderer/Node.js +112 -0
- package/src/renderer/Pipeline.js +645 -0
- package/src/renderer/Renderer.js +1496 -0
- package/src/renderer/Skin.js +792 -0
- package/src/renderer/Texture.js +584 -0
- package/src/renderer/core/AssetManager.js +394 -0
- package/src/renderer/core/CullingSystem.js +308 -0
- package/src/renderer/core/EntityManager.js +541 -0
- package/src/renderer/core/InstanceManager.js +343 -0
- package/src/renderer/core/ParticleEmitter.js +358 -0
- package/src/renderer/core/ParticleSystem.js +564 -0
- package/src/renderer/core/SpriteSystem.js +349 -0
- package/src/renderer/gltf.js +563 -0
- package/src/renderer/math.js +161 -0
- package/src/renderer/rendering/HistoryBufferManager.js +333 -0
- package/src/renderer/rendering/ProbeCapture.js +1495 -0
- package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
- package/src/renderer/rendering/RenderGraph.js +2258 -0
- package/src/renderer/rendering/passes/AOPass.js +308 -0
- package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
- package/src/renderer/rendering/passes/BasePass.js +101 -0
- package/src/renderer/rendering/passes/BloomPass.js +420 -0
- package/src/renderer/rendering/passes/CRTPass.js +724 -0
- package/src/renderer/rendering/passes/FogPass.js +445 -0
- package/src/renderer/rendering/passes/GBufferPass.js +730 -0
- package/src/renderer/rendering/passes/HiZPass.js +744 -0
- package/src/renderer/rendering/passes/LightingPass.js +753 -0
- package/src/renderer/rendering/passes/ParticlePass.js +841 -0
- package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
- package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
- package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
- package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
- package/src/renderer/rendering/passes/SSGIPass.js +266 -0
- package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
- package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
- package/src/renderer/rendering/passes/TransparentPass.js +831 -0
- package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
- package/src/renderer/rendering/shaders/ao.wgsl +182 -0
- package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
- package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/crt.wgsl +455 -0
- package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
- package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
- package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
- package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
- package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
- package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
- package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
- package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
- package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
- package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
- package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
- package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
- package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
- package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
- package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
- package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
- package/src/renderer/utils/BoundingSphere.js +439 -0
- package/src/renderer/utils/Frustum.js +281 -0
- package/src/renderer/utils/Raycaster.js +761 -0
- package/dist/client.d.cts +0 -211
- package/dist/client.d.ts +0 -211
- package/dist/server.d.cts +0 -120
- package/dist/server.d.ts +0 -120
- package/dist/terminal.d.cts +0 -64
- package/dist/terminal.d.ts +0 -64
- 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 }
|