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,744 @@
1
+ import { BasePass } from "./BasePass.js"
2
+ import { vec3 } from "../../math.js"
3
+
4
+ import hizReduceWGSL from "../shaders/hiz_reduce.wgsl"
5
+
6
+ /**
7
+ * HiZPass - Hierarchical Z-Buffer for occlusion culling
8
+ *
9
+ * Reduces the depth buffer to maximum depth per 64x64 tile.
10
+ * The CPU can then use this data to cull objects that are behind
11
+ * the geometry from the previous frame.
12
+ *
13
+ * Features:
14
+ * - 64x64 pixel tiles for coarse occlusion testing
15
+ * - Camera movement detection to invalidate stale data
16
+ * - Async GPU->CPU readback for non-blocking culling
17
+ */
18
+
19
+ // Fixed tile size - must match compute shader (hiz_reduce.wgsl)
20
+ const TILE_SIZE = 64
21
+
22
+ // Maximum tiles before disabling occlusion culling (too slow to readback)
23
+ const MAX_TILES_FOR_OCCLUSION = 2500
24
+
25
+ // Thresholds for invalidating HiZ data
26
+ const POSITION_THRESHOLD = 0.5 // Units of movement before invalidation
27
+ const ROTATION_THRESHOLD = 0.02 // Radians of rotation before invalidation (~1 degree)
28
+
29
+ // Maximum frames of readback latency before invalidating occlusion data
30
+ const MAX_READBACK_LATENCY = 3
31
+
32
+ class HiZPass extends BasePass {
33
+ constructor(engine = null) {
34
+ super('HiZ', engine)
35
+
36
+ // Compute pipeline
37
+ this.pipeline = null
38
+ this.bindGroupLayout = null
39
+
40
+ // Buffers
41
+ this.hizBuffer = null // GPU storage buffer (max Z per tile)
42
+ this.stagingBuffers = [null, null] // Double-buffered staging for CPU readback
43
+ this.stagingBufferInUse = [false, false] // Track which buffers are currently mapped
44
+ this.currentStagingIndex = 0 // Which staging buffer to use next
45
+ this.uniformBuffer = null
46
+
47
+ // Tile grid dimensions
48
+ this.tileCountX = 0
49
+ this.tileCountY = 0
50
+ this.totalTiles = 0
51
+ this._tooManyTiles = false // True if resolution is too high for efficient occlusion
52
+
53
+ // Frame tracking for readback latency
54
+ this._frameCounter = 0 // Current frame number
55
+ this._lastReadbackFrame = 0 // Frame when last readback completed
56
+
57
+ // CPU-side HiZ data (double-buffered to prevent race conditions)
58
+ this.hizDataBuffers = [null, null] // Two Float32Array buffers for double-buffering
59
+ this.readHizIndex = 0 // Which buffer is currently used for reading
60
+ this.writeHizIndex = 1 // Which buffer is currently used for writing
61
+ this.hizDataReady = false // Whether CPU data is valid for this frame
62
+ this.pendingReadback = null // Promise for pending readback
63
+
64
+ // Debug stats for occlusion testing
65
+ this.debugStats = {
66
+ tested: 0,
67
+ occluded: 0,
68
+ skippedTileSpan: 0,
69
+ visibleSkyGap: 0,
70
+ visibleInFront: 0,
71
+ }
72
+
73
+ // Camera tracking for invalidation
74
+ this.lastCameraPosition = vec3.create()
75
+ this.lastCameraDirection = vec3.create()
76
+ this.hasValidHistory = false // Whether we have valid previous frame data
77
+
78
+ // Depth texture reference
79
+ this.depthTexture = null
80
+
81
+ // Screen dimensions
82
+ this.screenWidth = 0
83
+ this.screenHeight = 0
84
+
85
+ // Flag to prevent operations during destruction
86
+ this._destroyed = false
87
+
88
+ // Warmup frames - occlusion is disabled until scene has rendered for a few frames
89
+ // This prevents false occlusion on engine creation when depth buffer is not yet populated
90
+ this._warmupFramesRemaining = 5
91
+ }
92
+
93
+ /**
94
+ * Set the depth texture to read from (from GBuffer)
95
+ * @param {Object} depth - Depth texture object with .texture and .view
96
+ */
97
+ setDepthTexture(depth) {
98
+ this.depthTexture = depth
99
+ }
100
+
101
+ /**
102
+ * Invalidate occlusion culling data and reset warmup period.
103
+ * Call this after engine creation, scene loading, or major camera changes
104
+ * to prevent incorrect occlusion culling with stale data.
105
+ */
106
+ invalidate() {
107
+ this.hasValidHistory = false
108
+ this.hizDataReady = false
109
+ this._warmupFramesRemaining = 5 // Wait 5 frames before enabling occlusion
110
+ // Reset camera tracking to avoid false invalidations
111
+ vec3.set(this.lastCameraPosition, 0, 0, 0)
112
+ vec3.set(this.lastCameraDirection, 0, 0, 0)
113
+ }
114
+
115
+ async _init() {
116
+ const { device, canvas } = this.engine
117
+ await this._createResources(canvas.width, canvas.height)
118
+ }
119
+
120
+ async _createResources(width, height) {
121
+ // Skip if dimensions haven't changed (avoid double init)
122
+ if (this.screenWidth === width && this.screenHeight === height && this.hizBuffer) {
123
+ return
124
+ }
125
+
126
+ const { device } = this.engine
127
+
128
+ // Mark as destroyed to cancel any pending readback
129
+ this._destroyed = true
130
+
131
+ // Wait for any pending readback to complete
132
+ if (this.pendingReadback) {
133
+ try {
134
+ await this.pendingReadback
135
+ } catch (e) {
136
+ // Ignore errors from cancelled readback
137
+ }
138
+ this.pendingReadback = null
139
+ }
140
+
141
+ this.screenWidth = width
142
+ this.screenHeight = height
143
+
144
+ // Calculate tile grid dimensions (fixed 64px tiles to match compute shader)
145
+ this.tileCountX = Math.ceil(width / TILE_SIZE)
146
+ this.tileCountY = Math.ceil(height / TILE_SIZE)
147
+ this.totalTiles = this.tileCountX * this.tileCountY
148
+
149
+ // Check if we have too many tiles for efficient occlusion culling
150
+ this._tooManyTiles = this.totalTiles > MAX_TILES_FOR_OCCLUSION
151
+
152
+ console.log(`HiZ: ${width}x${height} -> ${TILE_SIZE}px tiles, ${this.tileCountX}x${this.tileCountY} = ${this.totalTiles} tiles${this._tooManyTiles ? ' (occlusion disabled - too many tiles)' : ''}`)
153
+
154
+ // Destroy old buffers
155
+ if (this.hizBuffer) this.hizBuffer.destroy()
156
+ for (let i = 0; i < 2; i++) {
157
+ if (this.stagingBuffers[i]) {
158
+ this.stagingBuffers[i].destroy()
159
+ }
160
+ }
161
+ if (this.uniformBuffer) this.uniformBuffer.destroy()
162
+
163
+ // Create HiZ buffer (2 floats per tile: minZ, maxZ)
164
+ const bufferSize = this.totalTiles * 2 * 4 // 2 floats * 4 bytes per float
165
+ this.hizBuffer = device.createBuffer({
166
+ label: 'HiZ Buffer',
167
+ size: bufferSize,
168
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
169
+ })
170
+
171
+ // Create double-buffered staging buffers for CPU readback
172
+ for (let i = 0; i < 2; i++) {
173
+ this.stagingBuffers[i] = device.createBuffer({
174
+ label: `HiZ Staging Buffer ${i}`,
175
+ size: bufferSize,
176
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
177
+ })
178
+ this.stagingBufferInUse[i] = false
179
+ }
180
+ this.currentStagingIndex = 0
181
+
182
+ // Create uniform buffer
183
+ this.uniformBuffer = device.createBuffer({
184
+ label: 'HiZ Uniforms',
185
+ size: 32, // 8 floats
186
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
187
+ })
188
+
189
+ // Create CPU-side data arrays (double-buffered, 2 floats per tile: minZ, maxZ)
190
+ this.hizDataBuffers[0] = new Float32Array(this.totalTiles * 2)
191
+ this.hizDataBuffers[1] = new Float32Array(this.totalTiles * 2)
192
+ // Initialize: minZ=1.0, maxZ=1.0 (sky) - everything passes occlusion test
193
+ for (let i = 0; i < this.totalTiles; i++) {
194
+ this.hizDataBuffers[0][i * 2] = 1.0 // minZ
195
+ this.hizDataBuffers[0][i * 2 + 1] = 1.0 // maxZ
196
+ this.hizDataBuffers[1][i * 2] = 1.0
197
+ this.hizDataBuffers[1][i * 2 + 1] = 1.0
198
+ }
199
+ this.readHizIndex = 0
200
+ this.writeHizIndex = 1
201
+
202
+ // Create compute pipeline
203
+ const shaderModule = device.createShaderModule({
204
+ label: 'HiZ Reduce Shader',
205
+ code: hizReduceWGSL,
206
+ })
207
+
208
+ this.bindGroupLayout = device.createBindGroupLayout({
209
+ label: 'HiZ Reduce BGL',
210
+ entries: [
211
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
212
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth' } },
213
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
214
+ ],
215
+ })
216
+
217
+ this.pipeline = await device.createComputePipelineAsync({
218
+ label: 'HiZ Reduce Pipeline',
219
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
220
+ compute: { module: shaderModule, entryPoint: 'main' },
221
+ })
222
+
223
+ // Reset state
224
+ this.hasValidHistory = false
225
+ this._destroyed = false
226
+ this.hizDataReady = false
227
+ this.pendingReadback = null
228
+ this._warmupFramesRemaining = 5 // Wait a few frames after resize before enabling occlusion
229
+ }
230
+
231
+ /**
232
+ * Check if camera has moved significantly, requiring HiZ invalidation
233
+ * @param {Camera} camera - Current camera
234
+ * @returns {boolean} True if camera moved too much
235
+ */
236
+ _checkCameraMovement(camera) {
237
+ const position = camera.position
238
+ const direction = camera.direction
239
+
240
+ // Calculate position delta
241
+ const dx = position[0] - this.lastCameraPosition[0]
242
+ const dy = position[1] - this.lastCameraPosition[1]
243
+ const dz = position[2] - this.lastCameraPosition[2]
244
+ const positionDelta = Math.sqrt(dx * dx + dy * dy + dz * dz)
245
+
246
+ // Calculate rotation delta (dot product of directions)
247
+ const dot = direction[0] * this.lastCameraDirection[0] +
248
+ direction[1] * this.lastCameraDirection[1] +
249
+ direction[2] * this.lastCameraDirection[2]
250
+ // Clamp to avoid NaN from acos
251
+ const clampedDot = Math.max(-1, Math.min(1, dot))
252
+ const rotationDelta = Math.acos(clampedDot)
253
+
254
+ // Update last camera state
255
+ vec3.copy(this.lastCameraPosition, position)
256
+ vec3.copy(this.lastCameraDirection, direction)
257
+
258
+ // Check thresholds
259
+ const positionThreshold = this.settings?.occlusionCulling?.positionThreshold ?? POSITION_THRESHOLD
260
+ const rotationThreshold = this.settings?.occlusionCulling?.rotationThreshold ?? ROTATION_THRESHOLD
261
+
262
+ return positionDelta > positionThreshold || rotationDelta > rotationThreshold
263
+ }
264
+
265
+ /**
266
+ * Prepare HiZ data for occlusion tests - call BEFORE any culling
267
+ */
268
+ prepareForOcclusionTests(camera) {
269
+ if (!camera) return
270
+
271
+ // Reset debug stats at start of frame
272
+ this.resetDebugStats()
273
+
274
+ // No camera movement invalidation - the 100% depth gap requirement
275
+ // handles stale data gracefully, and next frame will be correct
276
+ }
277
+
278
+ async _execute(context) {
279
+ const { device } = this.engine
280
+ const { camera } = context
281
+
282
+ // Increment frame counter
283
+ this._frameCounter++
284
+
285
+ // Decrement warmup counter - occlusion is disabled until this reaches 0
286
+ if (this._warmupFramesRemaining > 0) {
287
+ this._warmupFramesRemaining--
288
+ }
289
+
290
+ // Check if occlusion culling is enabled
291
+ if (!this.settings?.occlusionCulling?.enabled) {
292
+ return
293
+ }
294
+
295
+ if (!this.depthTexture || !this.pipeline) {
296
+ return
297
+ }
298
+
299
+ // Note: Camera movement check is now done in prepareForOcclusionTests()
300
+ // which is called before light culling
301
+
302
+ // Determine if we should clear or accumulate
303
+ const shouldClear = !this.hasValidHistory
304
+
305
+ // Update uniforms
306
+ const near = camera.near || 0.05
307
+ const far = camera.far || 1000
308
+ device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([
309
+ this.screenWidth,
310
+ this.screenHeight,
311
+ this.tileCountX,
312
+ this.tileCountY,
313
+ TILE_SIZE,
314
+ near,
315
+ far,
316
+ shouldClear ? 1.0 : 0.0, // clearValue
317
+ ]))
318
+
319
+ // Create bind group
320
+ const bindGroup = device.createBindGroup({
321
+ label: 'HiZ Reduce Bind Group',
322
+ layout: this.bindGroupLayout,
323
+ entries: [
324
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
325
+ { binding: 1, resource: this.depthTexture.view },
326
+ { binding: 2, resource: { buffer: this.hizBuffer } },
327
+ ],
328
+ })
329
+
330
+ // Execute compute shader
331
+ const commandEncoder = device.createCommandEncoder({ label: 'HiZ Reduce' })
332
+
333
+ const computePass = commandEncoder.beginComputePass({ label: 'HiZ Reduce Pass' })
334
+ computePass.setPipeline(this.pipeline)
335
+ computePass.setBindGroup(0, bindGroup)
336
+ computePass.dispatchWorkgroups(this.tileCountX, this.tileCountY, 1)
337
+ computePass.end()
338
+
339
+ // Use double-buffered staging: find a buffer that's not currently in use
340
+ let stagingIndex = this.currentStagingIndex
341
+ let stagingBuffer = this.stagingBuffers[stagingIndex]
342
+
343
+ // If current buffer is in use, try the other one
344
+ if (this.stagingBufferInUse[stagingIndex]) {
345
+ stagingIndex = (stagingIndex + 1) % 2
346
+ stagingBuffer = this.stagingBuffers[stagingIndex]
347
+ }
348
+
349
+ // If both buffers are in use, skip this frame's copy (use stale data)
350
+ if (this.stagingBufferInUse[stagingIndex]) {
351
+ device.queue.submit([commandEncoder.finish()])
352
+ this.hasValidHistory = true
353
+ return
354
+ }
355
+
356
+ // Update for next frame
357
+ this.currentStagingIndex = (stagingIndex + 1) % 2
358
+
359
+ // Copy to staging buffer for CPU readback (2 floats per tile)
360
+ commandEncoder.copyBufferToBuffer(
361
+ this.hizBuffer, 0,
362
+ stagingBuffer, 0,
363
+ this.totalTiles * 2 * 4
364
+ )
365
+
366
+ device.queue.submit([commandEncoder.finish()])
367
+
368
+ // Mark that we now have valid history
369
+ this.hasValidHistory = true
370
+
371
+ // Start async readback (non-blocking)
372
+ this._startReadback(stagingBuffer, stagingIndex)
373
+ }
374
+
375
+ /**
376
+ * Start async GPU->CPU readback
377
+ * @private
378
+ * @param {GPUBuffer} stagingBuffer - The staging buffer to read from
379
+ * @param {number} stagingIndex - Which buffer index this is
380
+ */
381
+ async _startReadback(stagingBuffer, stagingIndex) {
382
+ // Don't start new readback if destroyed
383
+ if (this._destroyed) return
384
+
385
+ // Mark buffer as in use
386
+ this.stagingBufferInUse[stagingIndex] = true
387
+
388
+ // Capture which CPU buffer to write to and current frame
389
+ const writeIndex = this.writeHizIndex
390
+ const requestedFrame = this._frameCounter
391
+
392
+ // Create the readback promise
393
+ const readbackPromise = (async () => {
394
+ try {
395
+ await stagingBuffer.mapAsync(GPUMapMode.READ)
396
+
397
+ // Check if we were destroyed while waiting
398
+ if (this._destroyed) {
399
+ try { stagingBuffer.unmap() } catch (e) {}
400
+ return
401
+ }
402
+
403
+ // Write to the write buffer (not the read buffer)
404
+ const data = new Float32Array(stagingBuffer.getMappedRange())
405
+ this.hizDataBuffers[writeIndex].set(data)
406
+ stagingBuffer.unmap()
407
+
408
+ // Now atomically swap: make the write buffer the new read buffer
409
+ // This ensures readers never see partial data
410
+ this.readHizIndex = writeIndex
411
+ this.writeHizIndex = writeIndex === 0 ? 1 : 0
412
+
413
+ // Track when this readback completed
414
+ this._lastReadbackFrame = this._frameCounter
415
+
416
+ this.hizDataReady = true
417
+ } catch (e) {
418
+ // Buffer might be destroyed during resize - this is expected
419
+ if (!this._destroyed) {
420
+ console.warn('HiZ readback failed:', e)
421
+ }
422
+ } finally {
423
+ // Mark buffer as no longer in use
424
+ this.stagingBufferInUse[stagingIndex] = false
425
+ }
426
+ })()
427
+
428
+ // Store the promise so we can wait for it during resize
429
+ this.pendingReadback = readbackPromise
430
+
431
+ // Don't await here - let it run in background
432
+ readbackPromise.then(() => {
433
+ // Only clear if this is still the pending readback
434
+ if (this.pendingReadback === readbackPromise) {
435
+ this.pendingReadback = null
436
+ }
437
+ })
438
+ }
439
+
440
+ /**
441
+ * Get min and max depth for a tile
442
+ * minDepth = closest geometry (occluder surface)
443
+ * maxDepth = farthest geometry (if < 1.0, tile is fully covered)
444
+ * @param {number} tileX - Tile X coordinate
445
+ * @param {number} tileY - Tile Y coordinate
446
+ * @returns {{ minDepth: number, maxDepth: number }} Depth values (0-1, 0=near, 1=far/sky)
447
+ */
448
+ getTileMinMaxDepth(tileX, tileY) {
449
+ const hizData = this.hizDataBuffers[this.readHizIndex]
450
+ if (!this.hizDataReady || !hizData) {
451
+ return { minDepth: 1.0, maxDepth: 1.0 } // No data - assume sky (no occlusion)
452
+ }
453
+
454
+ if (tileX < 0 || tileX >= this.tileCountX ||
455
+ tileY < 0 || tileY >= this.tileCountY) {
456
+ return { minDepth: 1.0, maxDepth: 1.0 } // Out of bounds - assume sky
457
+ }
458
+
459
+ const index = (tileY * this.tileCountX + tileX) * 2
460
+ return {
461
+ minDepth: hizData[index],
462
+ maxDepth: hizData[index + 1]
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Get the maximum depth for a tile (backward compatibility)
468
+ * @param {number} tileX - Tile X coordinate
469
+ * @param {number} tileY - Tile Y coordinate
470
+ * @returns {number} Maximum depth (0-1, 0=near, 1=far/sky)
471
+ */
472
+ getTileMaxDepth(tileX, tileY) {
473
+ return this.getTileMinMaxDepth(tileX, tileY).maxDepth
474
+ }
475
+
476
+ /**
477
+ * Test if a bounding sphere is occluded
478
+ *
479
+ * Uses MIN/MAX depth per tile to calculate adaptive occlusion threshold.
480
+ * Tile thickness = maxDepth - minDepth (thin for walls, thick for angled ground)
481
+ *
482
+ * Occlusion threshold = max(1m, tileThickness, 2*sphereRadius) in linear depth
483
+ * Object is occluded if sphereFrontDepth > tileMinDepth + threshold for ALL tiles
484
+ *
485
+ * @param {Object} bsphere - Bounding sphere with center[3] and radius
486
+ * @param {mat4} viewProj - View-projection matrix
487
+ * @param {number} near - Near plane distance
488
+ * @param {number} far - Far plane distance
489
+ * @param {Array} cameraPos - Camera position [x, y, z]
490
+ * @returns {boolean} True if definitely occluded, false if potentially visible
491
+ */
492
+ testSphereOcclusion(bsphere, viewProj, near, far, cameraPos) {
493
+ this.debugStats.tested++
494
+
495
+ // Warmup period - disable occlusion for first few frames after creation/reset
496
+ // This ensures the depth buffer has valid geometry before we use it for culling
497
+ if (this._warmupFramesRemaining > 0) {
498
+ return false // Still warming up - assume visible
499
+ }
500
+
501
+ // Safety checks
502
+ if (!this.hizDataReady || !this.hasValidHistory) {
503
+ return false // No valid data - assume visible
504
+ }
505
+
506
+ // Skip occlusion at very high resolutions (too many tiles = slow readback)
507
+ if (this._tooManyTiles) {
508
+ return false // Disabled at this resolution
509
+ }
510
+
511
+ // Check for stale data - if readback is lagging too far behind, skip occlusion
512
+ // This prevents objects from being incorrectly culled when GPU is slow
513
+ const readbackLatency = this._frameCounter - this._lastReadbackFrame
514
+ if (readbackLatency > MAX_READBACK_LATENCY) {
515
+ return false // Data too stale - assume visible
516
+ }
517
+
518
+ if (!cameraPos || !viewProj || !bsphere?.center) {
519
+ return false // Missing required data - assume visible
520
+ }
521
+
522
+ if (this.screenWidth <= 0 || this.screenHeight <= 0 || this.tileCountX <= 0) {
523
+ return false // Invalid screen size - assume visible
524
+ }
525
+
526
+ // Use provided near/far or defaults
527
+ near = near || 0.05
528
+ far = far || 1000
529
+
530
+ const cx = bsphere.center[0]
531
+ const cy = bsphere.center[1]
532
+ const cz = bsphere.center[2]
533
+ const radius = bsphere.radius
534
+
535
+ // Calculate distance to sphere center
536
+ const dx = cx - cameraPos[0]
537
+ const dy = cy - cameraPos[1]
538
+ const dz = cz - cameraPos[2]
539
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
540
+
541
+ // Skip if sphere intersects near plane
542
+ if (distance - radius < near) {
543
+ return false
544
+ }
545
+
546
+ // Project sphere CENTER only (more stable than projecting 8 corners)
547
+ const clipX = viewProj[0] * cx + viewProj[4] * cy + viewProj[8] * cz + viewProj[12]
548
+ const clipY = viewProj[1] * cx + viewProj[5] * cy + viewProj[9] * cz + viewProj[13]
549
+ const clipW = viewProj[3] * cx + viewProj[7] * cy + viewProj[11] * cz + viewProj[15]
550
+
551
+ // Behind camera
552
+ if (clipW <= near) {
553
+ return false
554
+ }
555
+
556
+ // Perspective divide to NDC
557
+ const ndcX = clipX / clipW
558
+ const ndcY = clipY / clipW
559
+
560
+ // Skip if center is way off screen (frustum culling handles this)
561
+ if (ndcX < -1.5 || ndcX > 1.5 || ndcY < -1.5 || ndcY > 1.5) {
562
+ return false
563
+ }
564
+
565
+ // Convert center to screen coordinates
566
+ const screenCenterX = (ndcX * 0.5 + 0.5) * this.screenWidth
567
+ const screenCenterY = (1.0 - (ndcY * 0.5 + 0.5)) * this.screenHeight // Flip Y
568
+
569
+ // Calculate screen-space radius using clip W for proper perspective
570
+ const ndcRadius = radius / clipW
571
+ const screenRadius = ndcRadius * (this.screenHeight * 0.5)
572
+
573
+ // Calculate screen bounds from center and radius
574
+ const minScreenX = screenCenterX - screenRadius
575
+ const maxScreenX = screenCenterX + screenRadius
576
+ const minScreenY = screenCenterY - screenRadius
577
+ const maxScreenY = screenCenterY + screenRadius
578
+
579
+ // Calculate tile range from screen bounds
580
+ const rawMinTileX = Math.floor(minScreenX / TILE_SIZE)
581
+ const rawMaxTileX = Math.floor(maxScreenX / TILE_SIZE)
582
+ const rawMinTileY = Math.floor(minScreenY / TILE_SIZE)
583
+ const rawMaxTileY = Math.floor(maxScreenY / TILE_SIZE)
584
+
585
+ // If bounding box extends outside screen, object is partially visible
586
+ if (rawMinTileX < 0 || rawMaxTileX >= this.tileCountX ||
587
+ rawMinTileY < 0 || rawMaxTileY >= this.tileCountY) {
588
+ return false // Partially off-screen - assume visible
589
+ }
590
+
591
+ const minTileX = rawMinTileX
592
+ const maxTileX = rawMaxTileX
593
+ const minTileY = rawMinTileY
594
+ const maxTileY = rawMaxTileY
595
+
596
+ // If too many tiles, skip (let frustum culling handle large objects)
597
+ const tileSpanX = maxTileX - minTileX + 1
598
+ const tileSpanY = maxTileY - minTileY + 1
599
+ if (tileSpanX * tileSpanY > 25) {
600
+ this.debugStats.skippedTileSpan++
601
+ return false // Too many tiles to check
602
+ }
603
+
604
+ // Calculate LINEAR depth for front of sphere
605
+ const depthRange = far - near
606
+ const frontDistance = Math.max(near, distance - radius)
607
+ const sphereFrontDepth = (frontDistance - near) / depthRange
608
+
609
+ // Convert 1 meter to linear depth units (minimum safety margin)
610
+ const minMarginLinear = 1.0 / depthRange
611
+
612
+ // Get configurable threshold multiplier (default 1.0 = 100% of maxZ)
613
+ const cullingThreshold = this.settings?.occlusionCulling?.threshold ?? 1.0
614
+
615
+ // Check all tiles - if ANY tile shows object might be visible, exit early
616
+ for (let ty = minTileY; ty <= maxTileY; ty++) {
617
+ for (let tx = minTileX; tx <= maxTileX; tx++) {
618
+ const { minDepth, maxDepth } = this.getTileMinMaxDepth(tx, ty)
619
+
620
+ // Sky gap - no occlusion possible
621
+ if (maxDepth >= 0.999) {
622
+ this.debugStats.visibleSkyGap++
623
+ return false
624
+ }
625
+
626
+ // Tile thickness in linear depth
627
+ const tileThickness = maxDepth - minDepth
628
+
629
+ // Adaptive occlusion threshold = max(1m, tileThickness, maxZ * cullingThreshold)
630
+ // - 1m: minimum safety margin
631
+ // - tileThickness: thick tiles (angled ground) need more margin
632
+ // - maxZ * threshold: configurable depth-based margin to prevent self-occlusion
633
+ const depthBasedMargin = maxDepth * cullingThreshold
634
+ const threshold = Math.max(minMarginLinear, tileThickness, depthBasedMargin)
635
+
636
+ // Object is visible if its front is within threshold of tile's farthest surface
637
+ // Only occlude if sphere front is beyond maxDepth + threshold
638
+ if (sphereFrontDepth <= maxDepth + threshold) {
639
+ this.debugStats.visibleInFront++
640
+ return false // Visible - exit early
641
+ }
642
+ }
643
+ }
644
+
645
+ // Sphere is behind all tiles by more than the threshold → occluded
646
+ this.debugStats.occluded++
647
+ return true
648
+ }
649
+
650
+ /**
651
+ * Reset debug stats (call at start of each frame)
652
+ */
653
+ resetDebugStats() {
654
+ this.debugStats.tested = 0
655
+ this.debugStats.occluded = 0
656
+ this.debugStats.skippedTileSpan = 0
657
+ this.debugStats.visibleSkyGap = 0
658
+ this.debugStats.visibleInFront = 0
659
+ }
660
+
661
+ /**
662
+ * Get debug stats for occlusion testing
663
+ */
664
+ getDebugStats() {
665
+ return this.debugStats
666
+ }
667
+
668
+ /**
669
+ * Get tile information for debugging
670
+ */
671
+ getTileInfo() {
672
+ const hizData = this.hizDataBuffers[this.readHizIndex]
673
+ // Calculate stats about tile coverage
674
+ let globalMinDepth = 1.0, globalMaxDepth = 0.0, coveredTiles = 0
675
+ let avgThickness = 0
676
+ if (hizData && this.hizDataReady) {
677
+ for (let i = 0; i < this.totalTiles; i++) {
678
+ const minD = hizData[i * 2]
679
+ const maxD = hizData[i * 2 + 1]
680
+ globalMinDepth = Math.min(globalMinDepth, minD)
681
+ globalMaxDepth = Math.max(globalMaxDepth, maxD)
682
+ if (maxD < 0.999) {
683
+ coveredTiles++ // Tile has geometry (not just sky)
684
+ avgThickness += maxD - minD
685
+ }
686
+ }
687
+ if (coveredTiles > 0) {
688
+ avgThickness /= coveredTiles
689
+ }
690
+ }
691
+
692
+ return {
693
+ tileCountX: this.tileCountX,
694
+ tileCountY: this.tileCountY,
695
+ tileSize: TILE_SIZE,
696
+ totalTiles: this.totalTiles,
697
+ hasValidData: this.hizDataReady && this.hasValidHistory,
698
+ hizDataReady: this.hizDataReady,
699
+ hasValidHistory: this.hasValidHistory,
700
+ readbackLatency: this._frameCounter - this._lastReadbackFrame,
701
+ coveredTiles,
702
+ globalMinDepth: globalMinDepth.toFixed(4),
703
+ globalMaxDepth: globalMaxDepth.toFixed(4),
704
+ avgTileThickness: avgThickness.toFixed(4),
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Get HiZ data for debugging visualization
710
+ */
711
+ getHiZData() {
712
+ return this.hizDataBuffers[this.readHizIndex]
713
+ }
714
+
715
+ async _resize(width, height) {
716
+ await this._createResources(width, height)
717
+ }
718
+
719
+ _destroy() {
720
+ this._destroyed = true
721
+
722
+ if (this.hizBuffer) {
723
+ this.hizBuffer.destroy()
724
+ this.hizBuffer = null
725
+ }
726
+ for (let i = 0; i < 2; i++) {
727
+ if (this.stagingBuffers[i]) {
728
+ this.stagingBuffers[i].destroy()
729
+ this.stagingBuffers[i] = null
730
+ }
731
+ }
732
+ if (this.uniformBuffer) {
733
+ this.uniformBuffer.destroy()
734
+ this.uniformBuffer = null
735
+ }
736
+ this.pipeline = null
737
+ this.hizDataBuffers = [null, null]
738
+ this.hizDataReady = false
739
+ this.hasValidHistory = false
740
+ this.pendingReadback = null
741
+ }
742
+ }
743
+
744
+ export { HiZPass, TILE_SIZE }