topazcube 0.1.31 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. package/src/utils.ts +0 -403
@@ -0,0 +1,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 }