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,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 }
|