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,753 @@
|
|
|
1
|
+
import { BasePass } from "./BasePass.js"
|
|
2
|
+
import { Pipeline } from "../../Pipeline.js"
|
|
3
|
+
import { Texture } from "../../Texture.js"
|
|
4
|
+
import { mat4, vec3 } from "../../math.js"
|
|
5
|
+
import { Frustum } from "../../utils/Frustum.js"
|
|
6
|
+
|
|
7
|
+
import lightingWGSL from "../shaders/lighting.wgsl"
|
|
8
|
+
import lightCullingWGSL from "../shaders/light_culling.wgsl"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LightingPass - Tiled deferred lighting calculation
|
|
12
|
+
*
|
|
13
|
+
* Pass 6 in the 7-pass pipeline.
|
|
14
|
+
* Uses compute shader to cull lights per tile, then fragment shader for lighting.
|
|
15
|
+
*
|
|
16
|
+
* Inputs: GBuffer (albedo, normal, ARM, emission, depth), environment map
|
|
17
|
+
* Output: HDR lit image (rgba16float)
|
|
18
|
+
*/
|
|
19
|
+
class LightingPass extends BasePass {
|
|
20
|
+
constructor(engine = null) {
|
|
21
|
+
super('Lighting', engine)
|
|
22
|
+
|
|
23
|
+
this.pipeline = null
|
|
24
|
+
this.computePipeline = null
|
|
25
|
+
this.outputTexture = null
|
|
26
|
+
this.environmentMap = null
|
|
27
|
+
this.gbuffer = null
|
|
28
|
+
this.shadowPass = null
|
|
29
|
+
|
|
30
|
+
// Light data
|
|
31
|
+
this.lights = []
|
|
32
|
+
|
|
33
|
+
// Noise texture for shadow jittering (blue noise or bayer dither)
|
|
34
|
+
this.noiseTexture = null
|
|
35
|
+
this.noiseSize = 64
|
|
36
|
+
this.noiseAnimated = true
|
|
37
|
+
|
|
38
|
+
// AO texture from AOPass
|
|
39
|
+
this.aoTexture = null
|
|
40
|
+
|
|
41
|
+
// Environment encoding: 0 = equirectangular (default), 1 = octahedral (for captured probes)
|
|
42
|
+
this.envEncoding = 0
|
|
43
|
+
|
|
44
|
+
// Exposure override for probe rendering (null = use settings, number = override)
|
|
45
|
+
// When capturing probes, we want raw HDR values without exposure
|
|
46
|
+
this.exposureOverride = null
|
|
47
|
+
|
|
48
|
+
// Reflection mode: flips environment sampling Y for planar reflections
|
|
49
|
+
this.reflectionMode = false
|
|
50
|
+
|
|
51
|
+
// Ambient capture mode: disables IBL on geometry, only direct lights + emissive + skybox background
|
|
52
|
+
this.ambientCaptureMode = false
|
|
53
|
+
|
|
54
|
+
// Tiled lighting buffers
|
|
55
|
+
this.lightBuffer = null // Storage buffer for light data
|
|
56
|
+
this.tileLightBuffer = null // Storage buffer for per-tile light indices
|
|
57
|
+
this.tileCountBuffer = null // For debug: light counts per tile
|
|
58
|
+
|
|
59
|
+
// Stats
|
|
60
|
+
this.stats = {
|
|
61
|
+
totalLights: 0,
|
|
62
|
+
visibleLights: 0,
|
|
63
|
+
pointLights: 0,
|
|
64
|
+
spotLights: 0,
|
|
65
|
+
culledByFrustum: 0,
|
|
66
|
+
culledByDistance: 0,
|
|
67
|
+
culledByOcclusion: 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// HiZ pass reference for occlusion culling
|
|
71
|
+
this.hizPass = null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set the HiZ pass for occlusion culling of lights
|
|
76
|
+
* @param {HiZPass} hizPass - The HiZ pass instance
|
|
77
|
+
*/
|
|
78
|
+
setHiZPass(hizPass) {
|
|
79
|
+
this.hizPass = hizPass
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Convenience getters for lighting settings (with defaults for backward compatibility)
|
|
83
|
+
get maxLights() { return this.settings?.lighting?.maxLights ?? 768 }
|
|
84
|
+
get tileSize() { return this.settings?.lighting?.tileSize ?? 16 }
|
|
85
|
+
get maxLightsPerTile() { return this.settings?.lighting?.maxLightsPerTile ?? 256 }
|
|
86
|
+
get lightMaxDistance() { return this.settings?.lighting?.maxDistance ?? 240 }
|
|
87
|
+
get lightCullingEnabled() { return this.settings?.lighting?.cullingEnabled ?? true }
|
|
88
|
+
get shadowBias() { return this.settings?.shadow?.bias ?? 0.0005 }
|
|
89
|
+
get shadowNormalBias() { return this.settings?.shadow?.normalBias ?? 0.015 }
|
|
90
|
+
get shadowStrength() { return this.settings?.shadow?.strength ?? 1.0 }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Set the environment map for IBL
|
|
94
|
+
* @param {Texture} envMap - HDR environment map
|
|
95
|
+
* @param {number} encoding - 0 = equirectangular (default), 1 = octahedral (for captured probes)
|
|
96
|
+
*/
|
|
97
|
+
setEnvironmentMap(envMap, encoding = 0) {
|
|
98
|
+
this.environmentMap = envMap
|
|
99
|
+
this.envEncoding = encoding
|
|
100
|
+
this._needsRebuild = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Set the environment encoding type
|
|
105
|
+
* @param {number} encoding - 0 = equirectangular (default), 1 = octahedral (for captured probes)
|
|
106
|
+
*/
|
|
107
|
+
setEnvironmentEncoding(encoding) {
|
|
108
|
+
this.envEncoding = encoding
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Set the GBuffer from GBufferPass
|
|
113
|
+
* @param {GBuffer} gbuffer - GBuffer textures
|
|
114
|
+
*/
|
|
115
|
+
async setGBuffer(gbuffer) {
|
|
116
|
+
this.gbuffer = gbuffer
|
|
117
|
+
this._needsRebuild = true
|
|
118
|
+
this._computeBindGroupDirty = true
|
|
119
|
+
// Create/recreate compute pipeline now that we have gbuffer
|
|
120
|
+
if (!this.computePipeline && this.lightBuffer) {
|
|
121
|
+
await this._createComputePipeline()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Set the shadow pass for shadow mapping
|
|
127
|
+
* @param {ShadowPass} shadowPass - Shadow pass instance
|
|
128
|
+
*/
|
|
129
|
+
setShadowPass(shadowPass) {
|
|
130
|
+
this.shadowPass = shadowPass
|
|
131
|
+
this._needsRebuild = true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Set the noise texture for shadow jittering
|
|
136
|
+
* @param {Texture} noise - Noise texture (blue noise or bayer dither)
|
|
137
|
+
* @param {number} size - Texture size (assumed square)
|
|
138
|
+
* @param {boolean} animated - Whether to animate noise offset each frame
|
|
139
|
+
*/
|
|
140
|
+
setNoise(noise, size = 64, animated = true) {
|
|
141
|
+
this.noiseTexture = noise
|
|
142
|
+
this.noiseSize = size
|
|
143
|
+
this.noiseAnimated = animated
|
|
144
|
+
this._needsRebuild = true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Set the AO texture from AOPass
|
|
149
|
+
* @param {Texture} aoTexture - AO texture (r8unorm)
|
|
150
|
+
*/
|
|
151
|
+
setAOTexture(aoTexture) {
|
|
152
|
+
this.aoTexture = aoTexture
|
|
153
|
+
this._needsRebuild = true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async _init() {
|
|
157
|
+
const { canvas, device } = this.engine
|
|
158
|
+
|
|
159
|
+
// Create output texture (HDR)
|
|
160
|
+
this.outputTexture = await Texture.renderTarget(this.engine, 'rgba16float')
|
|
161
|
+
|
|
162
|
+
// Initialize tiled lighting resources
|
|
163
|
+
await this._initTiledLighting(canvas.width, canvas.height)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Initialize tiled lighting compute resources
|
|
168
|
+
*/
|
|
169
|
+
async _initTiledLighting(width, height) {
|
|
170
|
+
const { device } = this.engine
|
|
171
|
+
|
|
172
|
+
// Calculate tile counts
|
|
173
|
+
this.tileCountX = Math.ceil(width / this.tileSize)
|
|
174
|
+
this.tileCountY = Math.ceil(height / this.tileSize)
|
|
175
|
+
const totalTiles = this.tileCountX * this.tileCountY
|
|
176
|
+
|
|
177
|
+
// Light buffer: store all light data for GPU access
|
|
178
|
+
// Each light: 96 bytes to match WGSL storage buffer alignment
|
|
179
|
+
const lightBufferSize = this.maxLights * 96
|
|
180
|
+
this.lightBuffer = device.createBuffer({
|
|
181
|
+
label: 'Light Buffer',
|
|
182
|
+
size: lightBufferSize,
|
|
183
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
184
|
+
})
|
|
185
|
+
this.lightBufferData = new ArrayBuffer(lightBufferSize)
|
|
186
|
+
|
|
187
|
+
// Tile light indices buffer: for each tile, store count + up to maxLightsPerTile indices
|
|
188
|
+
// Each entry is a u32 (4 bytes)
|
|
189
|
+
const tileLightBufferSize = totalTiles * (this.maxLightsPerTile + 1) * 4
|
|
190
|
+
this.tileLightBuffer = device.createBuffer({
|
|
191
|
+
label: 'Tile Light Indices',
|
|
192
|
+
size: tileLightBufferSize,
|
|
193
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Uniform buffer for compute shader
|
|
197
|
+
// viewMatrix(64) + projectionMatrix(64) + inverseProjection(64) + screenSize(8) + tileCount(8) + lightCount(4) + near(4) + far(4) + padding(4) = 224 bytes
|
|
198
|
+
this.cullUniformBuffer = device.createBuffer({
|
|
199
|
+
label: 'Light Cull Uniforms',
|
|
200
|
+
size: 224,
|
|
201
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Create compute pipeline for light culling
|
|
205
|
+
await this._createComputePipeline()
|
|
206
|
+
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create compute pipeline for light culling
|
|
211
|
+
*/
|
|
212
|
+
async _createComputePipeline() {
|
|
213
|
+
const { device } = this.engine
|
|
214
|
+
|
|
215
|
+
if (!this.gbuffer) return
|
|
216
|
+
|
|
217
|
+
const computeModule = device.createShaderModule({
|
|
218
|
+
label: 'Light Culling Compute',
|
|
219
|
+
code: lightCullingWGSL,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const computeBindGroupLayout = device.createBindGroupLayout({
|
|
223
|
+
label: 'Light Culling Bind Group Layout',
|
|
224
|
+
entries: [
|
|
225
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
|
226
|
+
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
|
227
|
+
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
228
|
+
{ binding: 3, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth' } },
|
|
229
|
+
],
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Use async pipeline creation for non-blocking initialization
|
|
233
|
+
this.computePipeline = await device.createComputePipelineAsync({
|
|
234
|
+
label: 'Light Culling Pipeline',
|
|
235
|
+
layout: device.createPipelineLayout({
|
|
236
|
+
bindGroupLayouts: [computeBindGroupLayout],
|
|
237
|
+
}),
|
|
238
|
+
compute: {
|
|
239
|
+
module: computeModule,
|
|
240
|
+
entryPoint: 'main',
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
this.computeBindGroupLayout = computeBindGroupLayout
|
|
245
|
+
this._computeBindGroupDirty = true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Create or update compute bind group
|
|
250
|
+
*/
|
|
251
|
+
_updateComputeBindGroup() {
|
|
252
|
+
const { device } = this.engine
|
|
253
|
+
|
|
254
|
+
if (!this.gbuffer || !this.computePipeline) return
|
|
255
|
+
|
|
256
|
+
this.computeBindGroup = device.createBindGroup({
|
|
257
|
+
label: 'Light Culling Bind Group',
|
|
258
|
+
layout: this.computeBindGroupLayout,
|
|
259
|
+
entries: [
|
|
260
|
+
{ binding: 0, resource: { buffer: this.cullUniformBuffer } },
|
|
261
|
+
{ binding: 1, resource: { buffer: this.lightBuffer } },
|
|
262
|
+
{ binding: 2, resource: { buffer: this.tileLightBuffer } },
|
|
263
|
+
{ binding: 3, resource: this.gbuffer.depth.view },
|
|
264
|
+
],
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
this._computeBindGroupDirty = false
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build or rebuild the fragment pipeline
|
|
272
|
+
*/
|
|
273
|
+
async _buildPipeline() {
|
|
274
|
+
if (!this.gbuffer || !this.environmentMap) {
|
|
275
|
+
console.warn('LightingPass: Missing gbuffer or environmentMap')
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check all required textures
|
|
280
|
+
if (!this.noiseTexture) {
|
|
281
|
+
console.warn('LightingPass: Missing noiseTexture')
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
if (!this.aoTexture) {
|
|
285
|
+
console.warn('LightingPass: Missing aoTexture')
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
this.pipeline = await Pipeline.create(this.engine, {
|
|
290
|
+
label: 'lighting',
|
|
291
|
+
wgslSource: lightingWGSL,
|
|
292
|
+
isPostProcessing: true,
|
|
293
|
+
textures: [this.gbuffer, this.environmentMap, this.noiseTexture, this.aoTexture],
|
|
294
|
+
renderTarget: this.outputTexture,
|
|
295
|
+
shadowPass: this.shadowPass,
|
|
296
|
+
tileLightBuffer: this.tileLightBuffer,
|
|
297
|
+
lightBuffer: this.lightBuffer,
|
|
298
|
+
tileSize: this.tileSize,
|
|
299
|
+
tileCountX: this.tileCountX,
|
|
300
|
+
maxLightsPerTile: this.maxLightsPerTile,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
this._needsRebuild = false
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Add a light to the scene
|
|
308
|
+
* @param {Object} light - Light data
|
|
309
|
+
*/
|
|
310
|
+
addLight(light) {
|
|
311
|
+
if (this.lights.length < this.maxLights) {
|
|
312
|
+
this.lights.push({
|
|
313
|
+
enabled: light.enabled !== false ? 1 : 0,
|
|
314
|
+
position: light.position || [0, 0, 0],
|
|
315
|
+
color: light.color || [1, 1, 1, 1],
|
|
316
|
+
direction: light.direction || [0, -1, 0],
|
|
317
|
+
geom: light.geom || [10, 0.3, 0.5, 0], // radius, innerCone, outerCone
|
|
318
|
+
lightType: light.lightType || 0, // 0=dir, 1=point, 2=spot
|
|
319
|
+
shadowIndex: light.shadowIndex || -1
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Clear all lights
|
|
326
|
+
*/
|
|
327
|
+
clearLights() {
|
|
328
|
+
this.lights = []
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Update lights from entity system with frustum culling and distance ordering
|
|
333
|
+
* @param {Array} lightEntities - Array of entities with lights
|
|
334
|
+
* @param {Camera} camera - Camera for frustum culling and distance calculation
|
|
335
|
+
*/
|
|
336
|
+
updateLightsFromEntities(lightEntities, camera) {
|
|
337
|
+
this.clearLights()
|
|
338
|
+
|
|
339
|
+
// Reset stats
|
|
340
|
+
this.stats.totalLights = 0
|
|
341
|
+
this.stats.visibleLights = 0
|
|
342
|
+
this.stats.culledByFrustum = 0
|
|
343
|
+
this.stats.culledByDistance = 0
|
|
344
|
+
this.stats.culledByOcclusion = 0
|
|
345
|
+
|
|
346
|
+
// Separate lights by type for different handling
|
|
347
|
+
const spotlights = []
|
|
348
|
+
const pointLights = []
|
|
349
|
+
|
|
350
|
+
// Create camera frustum for culling
|
|
351
|
+
let cameraFrustum = null
|
|
352
|
+
if (camera && this.lightCullingEnabled) {
|
|
353
|
+
cameraFrustum = new Frustum()
|
|
354
|
+
cameraFrustum.update(
|
|
355
|
+
camera.view,
|
|
356
|
+
camera.proj,
|
|
357
|
+
camera.position,
|
|
358
|
+
camera.direction,
|
|
359
|
+
camera.fov || Math.PI / 4,
|
|
360
|
+
camera.aspect || 1.0,
|
|
361
|
+
camera.near || 0.1,
|
|
362
|
+
camera.far || 1000
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const { id, entity } of lightEntities) {
|
|
367
|
+
if (!entity.light || !entity.light.enabled) continue
|
|
368
|
+
|
|
369
|
+
const light = entity.light
|
|
370
|
+
this.stats.totalLights++
|
|
371
|
+
|
|
372
|
+
// Transform light position to world space
|
|
373
|
+
const worldPos = [
|
|
374
|
+
entity.position[0] + (light.position?.[0] || 0),
|
|
375
|
+
entity.position[1] + (light.position?.[1] || 0),
|
|
376
|
+
entity.position[2] + (light.position?.[2] || 0)
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
const lightData = {
|
|
380
|
+
enabled: true,
|
|
381
|
+
position: worldPos,
|
|
382
|
+
direction: light.direction || [0, -1, 0],
|
|
383
|
+
color: light.color || [1, 1, 1, 1],
|
|
384
|
+
geom: [...(light.geom || [10, 0.3, 0.5, 0])], // Copy array to avoid modifying original
|
|
385
|
+
lightType: light.lightType || 1
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const lightRadius = lightData.geom[0] || 10
|
|
389
|
+
|
|
390
|
+
// Calculate distance and fade for ALL light types (including spotlights)
|
|
391
|
+
let distance = 0
|
|
392
|
+
let distanceFade = 1.0
|
|
393
|
+
if (camera) {
|
|
394
|
+
const dx = worldPos[0] - camera.position[0]
|
|
395
|
+
const dy = worldPos[1] - camera.position[1]
|
|
396
|
+
const dz = worldPos[2] - camera.position[2]
|
|
397
|
+
distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
398
|
+
|
|
399
|
+
let maxDist = lightData.geom[0] < 5 ? this.lightMaxDistance * 0.25 : this.lightMaxDistance
|
|
400
|
+
// Distance cull: skip if light is too far (accounting for light radius)
|
|
401
|
+
const effectiveDistance = distance - lightRadius
|
|
402
|
+
if (effectiveDistance > maxDist) {
|
|
403
|
+
this.stats.culledByDistance++
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Calculate fade: 1.0 at 80% distance, 0.0 at 100% distance (smooth fade to avoid popping)
|
|
408
|
+
const fadeStart = maxDist * 0.8
|
|
409
|
+
if (effectiveDistance > fadeStart) {
|
|
410
|
+
distanceFade = 1.0 - (effectiveDistance - fadeStart) / (maxDist - fadeStart)
|
|
411
|
+
distanceFade = Math.max(0, Math.min(1, distanceFade))
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Store distance fade in geom.w (will be applied in shader)
|
|
416
|
+
lightData.geom[3] = distanceFade
|
|
417
|
+
|
|
418
|
+
// Skip lights that are nearly invisible (faded out)
|
|
419
|
+
if (distanceFade <= 0.01) {
|
|
420
|
+
this.stats.culledByDistance++
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Create bounding sphere for culling tests
|
|
425
|
+
const lightBsphere = { center: worldPos, radius: lightRadius }
|
|
426
|
+
|
|
427
|
+
// Spotlights
|
|
428
|
+
if (lightData.lightType === 2) {
|
|
429
|
+
// Frustum cull spotlights
|
|
430
|
+
if (cameraFrustum && this.lightCullingEnabled) {
|
|
431
|
+
if (!cameraFrustum.testSpherePlanes(lightBsphere)) {
|
|
432
|
+
this.stats.culledByFrustum++
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// HiZ occlusion cull spotlights
|
|
438
|
+
if (this.hizPass && camera && this.settings?.occlusionCulling?.enabled) {
|
|
439
|
+
if (this.hizPass.testSphereOcclusion(lightBsphere, camera.viewProj, camera.near, camera.far, camera.position)) {
|
|
440
|
+
this.stats.culledByOcclusion++
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
lightData._distance = distance
|
|
446
|
+
spotlights.push(lightData)
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Point lights: apply frustum culling
|
|
451
|
+
if (cameraFrustum && this.lightCullingEnabled) {
|
|
452
|
+
if (!cameraFrustum.testSpherePlanes(lightBsphere)) {
|
|
453
|
+
this.stats.culledByFrustum++
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// HiZ occlusion cull point lights
|
|
459
|
+
if (this.hizPass && camera && this.settings?.occlusionCulling?.enabled) {
|
|
460
|
+
if (this.hizPass.testSphereOcclusion(lightBsphere, camera.viewProj, camera.near, camera.far, camera.position)) {
|
|
461
|
+
this.stats.culledByOcclusion++
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Store distance for sorting
|
|
467
|
+
lightData._distance = distance
|
|
468
|
+
pointLights.push(lightData)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Sort point lights by distance (closest first)
|
|
472
|
+
pointLights.sort((a, b) => a._distance - b._distance)
|
|
473
|
+
|
|
474
|
+
// Add spotlights first (they need consistent indices for shadow mapping)
|
|
475
|
+
for (const light of spotlights) {
|
|
476
|
+
this.addLight(light)
|
|
477
|
+
}
|
|
478
|
+
this.stats.spotLights = spotlights.length
|
|
479
|
+
|
|
480
|
+
// Add point lights (closest first, up to remaining capacity)
|
|
481
|
+
let addedPointLights = 0
|
|
482
|
+
for (const light of pointLights) {
|
|
483
|
+
if (this.lights.length >= this.maxLights) break
|
|
484
|
+
this.addLight(light)
|
|
485
|
+
addedPointLights++
|
|
486
|
+
}
|
|
487
|
+
this.stats.pointLights = addedPointLights
|
|
488
|
+
|
|
489
|
+
this.stats.visibleLights = this.lights.length
|
|
490
|
+
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async _execute(context) {
|
|
494
|
+
const { device, canvas } = this.engine
|
|
495
|
+
const { camera, lights: contextLights, mainLight } = context
|
|
496
|
+
|
|
497
|
+
// Use output texture size (not canvas size) for proper probe rendering
|
|
498
|
+
const targetWidth = this.outputTexture?.texture?.width || canvas.width
|
|
499
|
+
const targetHeight = this.outputTexture?.texture?.height || canvas.height
|
|
500
|
+
|
|
501
|
+
// Get environment settings from engine (with fallbacks)
|
|
502
|
+
// In ambient capture mode (emissive only): disable ALL lighting, only emissive + skybox
|
|
503
|
+
const ambientColor = this.ambientCaptureMode
|
|
504
|
+
? [0, 0, 0, 0] // No ambient in emissive-only mode
|
|
505
|
+
: (this.settings?.environment?.ambientColor ?? [0.7, 0.75, 0.9, 0.2])
|
|
506
|
+
const environmentDiffuse = this.ambientCaptureMode ? 0.0 : (this.settings?.environment?.diffuse ?? 4.0)
|
|
507
|
+
const environmentSpecular = this.ambientCaptureMode ? 0.0 : (this.settings?.environment?.specular ?? 4.0)
|
|
508
|
+
// Use override if set (for probe capture), otherwise use settings
|
|
509
|
+
const exposure = this.exposureOverride ?? this.settings?.environment?.exposure ?? 1.6
|
|
510
|
+
|
|
511
|
+
// Rebuild pipeline if needed
|
|
512
|
+
if (this._needsRebuild) {
|
|
513
|
+
await this._buildPipeline()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// If rebuild was attempted but failed, don't use stale pipeline with old bind groups
|
|
517
|
+
if (!this.pipeline || this._needsRebuild) {
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Update compute bind group if needed
|
|
522
|
+
if (this._computeBindGroupDirty && this.gbuffer) {
|
|
523
|
+
this._updateComputeBindGroup()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ===================
|
|
527
|
+
// LIGHT CULLING COMPUTE PASS
|
|
528
|
+
// ===================
|
|
529
|
+
// Skip light culling if:
|
|
530
|
+
// - In ambient capture mode (emissive only - no point/spot lights)
|
|
531
|
+
// - Light culling is disabled in settings
|
|
532
|
+
// - No lights to process
|
|
533
|
+
const shouldRunLightCulling = this.computePipeline &&
|
|
534
|
+
this.computeBindGroup &&
|
|
535
|
+
this.lights.length > 0 &&
|
|
536
|
+
!this.ambientCaptureMode &&
|
|
537
|
+
this.lightCullingEnabled
|
|
538
|
+
|
|
539
|
+
if (shouldRunLightCulling) {
|
|
540
|
+
// Update light buffer with current lights data
|
|
541
|
+
this._updateLightBuffer()
|
|
542
|
+
|
|
543
|
+
// Update cull uniforms
|
|
544
|
+
const cullUniformData = new Float32Array(56) // 224 bytes / 4
|
|
545
|
+
cullUniformData.set(camera.view, 0) // viewMatrix (16 floats)
|
|
546
|
+
cullUniformData.set(camera.proj, 16) // projectionMatrix (16 floats)
|
|
547
|
+
cullUniformData.set(camera.iProj || mat4.create(), 32) // inverseProjection (16 floats)
|
|
548
|
+
cullUniformData[48] = targetWidth // screenSize.x
|
|
549
|
+
cullUniformData[49] = targetHeight // screenSize.y
|
|
550
|
+
const cullUniformDataU32 = new Uint32Array(cullUniformData.buffer)
|
|
551
|
+
cullUniformDataU32[50] = this.tileCountX // tileCount.x
|
|
552
|
+
cullUniformDataU32[51] = this.tileCountY // tileCount.y
|
|
553
|
+
cullUniformDataU32[52] = this.lights.length // lightCount
|
|
554
|
+
cullUniformData[53] = camera.near || 0.1 // nearPlane from camera
|
|
555
|
+
cullUniformData[54] = camera.far || 10000 // farPlane from camera (use large default)
|
|
556
|
+
cullUniformData[55] = 0 // padding
|
|
557
|
+
|
|
558
|
+
device.queue.writeBuffer(this.cullUniformBuffer, 0, cullUniformData)
|
|
559
|
+
|
|
560
|
+
// Run compute pass
|
|
561
|
+
const computeEncoder = device.createCommandEncoder({ label: 'Light Culling' })
|
|
562
|
+
const computePass = computeEncoder.beginComputePass({ label: 'Light Culling Pass' })
|
|
563
|
+
|
|
564
|
+
computePass.setPipeline(this.computePipeline)
|
|
565
|
+
computePass.setBindGroup(0, this.computeBindGroup)
|
|
566
|
+
computePass.dispatchWorkgroups(this.tileCountX, this.tileCountY, 1)
|
|
567
|
+
computePass.end()
|
|
568
|
+
|
|
569
|
+
device.queue.submit([computeEncoder.finish()])
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ===================
|
|
573
|
+
// LIGHTING FRAGMENT PASS
|
|
574
|
+
// ===================
|
|
575
|
+
|
|
576
|
+
// Get main light settings (use defaults if not provided)
|
|
577
|
+
// In ambient capture mode (emissive only): disable main light
|
|
578
|
+
const mainLightEnabled = this.ambientCaptureMode ? false : (mainLight?.enabled !== false)
|
|
579
|
+
const mainLightIntensity = mainLight?.intensity ?? 1.0
|
|
580
|
+
const mainLightColor = mainLight?.color || [1.0, 0.95, 0.9]
|
|
581
|
+
const lightDir = vec3.fromValues(
|
|
582
|
+
mainLight?.direction?.[0] ?? -1.0,
|
|
583
|
+
mainLight?.direction?.[1] ?? 1.0,
|
|
584
|
+
mainLight?.direction?.[2] ?? -0.5
|
|
585
|
+
)
|
|
586
|
+
vec3.normalize(lightDir, lightDir)
|
|
587
|
+
|
|
588
|
+
// Get shadow info
|
|
589
|
+
let shadowMapSize = 2048
|
|
590
|
+
if (this.shadowPass) {
|
|
591
|
+
shadowMapSize = this.shadowPass.shadowMapSize
|
|
592
|
+
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Get cascade sizes from shadow pass
|
|
596
|
+
const cascadeSizes = this.shadowPass ? this.shadowPass.getCascadeSizes() : [50, 200, 1000]
|
|
597
|
+
|
|
598
|
+
// Noise offset (0..1) - random each frame if animated, 0 if static
|
|
599
|
+
const noiseOffsetX = this.noiseAnimated ? Math.random() : 0
|
|
600
|
+
const noiseOffsetY = this.noiseAnimated ? Math.random() : 0
|
|
601
|
+
|
|
602
|
+
// Set uniforms
|
|
603
|
+
this.pipeline.uniformValues.set({
|
|
604
|
+
inverseViewProjection: camera.iViewProj,
|
|
605
|
+
inverseProjection: camera.iProj,
|
|
606
|
+
inverseView: camera.iView,
|
|
607
|
+
cameraPosition: camera.position,
|
|
608
|
+
canvasSize: [targetWidth, targetHeight],
|
|
609
|
+
lightDir: lightDir,
|
|
610
|
+
lightColor: [
|
|
611
|
+
mainLightColor[0],
|
|
612
|
+
mainLightColor[1],
|
|
613
|
+
mainLightColor[2],
|
|
614
|
+
mainLightEnabled ? mainLightIntensity * 12.0 : 0.0
|
|
615
|
+
],
|
|
616
|
+
ambientColor: ambientColor,
|
|
617
|
+
environmentParams: [
|
|
618
|
+
environmentDiffuse,
|
|
619
|
+
environmentSpecular,
|
|
620
|
+
this.environmentMap?.mipCount || 1,
|
|
621
|
+
exposure
|
|
622
|
+
],
|
|
623
|
+
shadowParams: [
|
|
624
|
+
this.shadowBias,
|
|
625
|
+
this.shadowNormalBias,
|
|
626
|
+
this.shadowStrength,
|
|
627
|
+
shadowMapSize
|
|
628
|
+
],
|
|
629
|
+
cascadeSizes: [cascadeSizes[0], cascadeSizes[1], cascadeSizes[2], 0],
|
|
630
|
+
tileParams: [this.tileSize, this.tileCountX, this.maxLightsPerTile, this.ambientCaptureMode ? 0 : this.lights.length],
|
|
631
|
+
noiseParams: [this.noiseSize, noiseOffsetX, noiseOffsetY, this.envEncoding],
|
|
632
|
+
cameraParams: [camera.near || 0.05, camera.far || 1000, this.reflectionMode ? 1.0 : 0.0, this.settings?.lighting?.directSpecularMultiplier ?? 3.0],
|
|
633
|
+
specularBoost: [
|
|
634
|
+
this.settings?.lighting?.specularBoost ?? 0.0,
|
|
635
|
+
this.settings?.lighting?.specularBoostRoughnessCutoff ?? 0.5,
|
|
636
|
+
0.0,
|
|
637
|
+
0.0
|
|
638
|
+
],
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
// Render lighting pass
|
|
642
|
+
this.pipeline.render()
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Update light buffer with current lights data
|
|
647
|
+
* Must match WGSL struct Light alignment in storage buffer:
|
|
648
|
+
* - enabled: u32 at offset 0
|
|
649
|
+
* - position: vec3f at offset 16 (aligned to 16)
|
|
650
|
+
* - color: vec4f at offset 32
|
|
651
|
+
* - direction: vec3f at offset 48
|
|
652
|
+
* - geom: vec4f at offset 64
|
|
653
|
+
* - shadowIndex: i32 at offset 80
|
|
654
|
+
* - struct size: 96 bytes (24 floats)
|
|
655
|
+
*/
|
|
656
|
+
_updateLightBuffer() {
|
|
657
|
+
const { device } = this.engine
|
|
658
|
+
|
|
659
|
+
// Get spotlight shadow slot assignments
|
|
660
|
+
let spotShadowSlots = null
|
|
661
|
+
if (this.shadowPass) {
|
|
662
|
+
spotShadowSlots = this.shadowPass.getSpotShadowSlots()
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Each light is 96 bytes (24 floats) to match WGSL storage buffer alignment
|
|
666
|
+
const lightData = new Float32Array(this.maxLights * 24)
|
|
667
|
+
const lightDataU32 = new Uint32Array(lightData.buffer)
|
|
668
|
+
const lightDataI32 = new Int32Array(lightData.buffer)
|
|
669
|
+
|
|
670
|
+
for (let i = 0; i < this.maxLights; i++) {
|
|
671
|
+
const light = this.lights[i]
|
|
672
|
+
const offset = i * 24
|
|
673
|
+
|
|
674
|
+
if (light) {
|
|
675
|
+
// enabled: u32 at offset 0
|
|
676
|
+
lightDataU32[offset + 0] = light.enabled ? 1 : 0
|
|
677
|
+
// padding: 12 bytes (3 floats)
|
|
678
|
+
|
|
679
|
+
// position: vec3f at offset 16 (4 floats)
|
|
680
|
+
lightData[offset + 4] = light.position[0]
|
|
681
|
+
lightData[offset + 5] = light.position[1]
|
|
682
|
+
lightData[offset + 6] = light.position[2]
|
|
683
|
+
// padding: 4 bytes
|
|
684
|
+
|
|
685
|
+
// color: vec4f at offset 32 (8 floats)
|
|
686
|
+
lightData[offset + 8] = light.color[0]
|
|
687
|
+
lightData[offset + 9] = light.color[1]
|
|
688
|
+
lightData[offset + 10] = light.color[2]
|
|
689
|
+
lightData[offset + 11] = light.color[3]
|
|
690
|
+
|
|
691
|
+
// direction: vec3f at offset 48 (12 floats)
|
|
692
|
+
lightData[offset + 12] = light.direction[0]
|
|
693
|
+
lightData[offset + 13] = light.direction[1]
|
|
694
|
+
lightData[offset + 14] = light.direction[2]
|
|
695
|
+
// padding: 4 bytes
|
|
696
|
+
|
|
697
|
+
// geom: vec4f at offset 64 (16 floats)
|
|
698
|
+
lightData[offset + 16] = light.geom[0]
|
|
699
|
+
lightData[offset + 17] = light.geom[1]
|
|
700
|
+
lightData[offset + 18] = light.geom[2]
|
|
701
|
+
lightData[offset + 19] = light.geom[3]
|
|
702
|
+
|
|
703
|
+
// shadowIndex: i32 at offset 80 (20 floats)
|
|
704
|
+
const shadowIndex = spotShadowSlots ? (spotShadowSlots[i] !== undefined ? spotShadowSlots[i] : -1) : -1
|
|
705
|
+
lightDataI32[offset + 20] = shadowIndex
|
|
706
|
+
// padding: 12 bytes to reach 96
|
|
707
|
+
} else {
|
|
708
|
+
lightDataU32[offset] = 0 // disabled
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
device.queue.writeBuffer(this.lightBuffer, 0, lightData)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async _resize(width, height) {
|
|
716
|
+
// Recreate output texture at new size
|
|
717
|
+
this.outputTexture = await Texture.renderTarget(this.engine, 'rgba16float', width, height)
|
|
718
|
+
|
|
719
|
+
// Recreate tiled lighting buffers for new size
|
|
720
|
+
await this._initTiledLighting(width, height)
|
|
721
|
+
|
|
722
|
+
this._needsRebuild = true
|
|
723
|
+
this._computeBindGroupDirty = true
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
_destroy() {
|
|
727
|
+
this.pipeline = null
|
|
728
|
+
this.outputTexture = null
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Get the output texture for use by subsequent passes
|
|
733
|
+
*/
|
|
734
|
+
getOutputTexture() {
|
|
735
|
+
return this.outputTexture
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Get the light buffer for volumetric fog
|
|
740
|
+
*/
|
|
741
|
+
getLightBuffer() {
|
|
742
|
+
return this.lightBuffer
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Get the current light count
|
|
747
|
+
*/
|
|
748
|
+
getLightCount() {
|
|
749
|
+
return this.lights?.length ?? 0
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export { LightingPass }
|