diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 2be2333f5..c1b164b70 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -113,6 +113,20 @@ public class GaussianComponent: Component { var gaussianVisibleCount: [MTLBuffer?] = Array(repeating: nil, count: maxInFlightCommandBuffers) var visibleSplatCountForRendering: UInt = 0 var splatCount: UInt = 0 + /// A chunked entity's most recent stale readback (two or three frames old, like + /// `visibleSplatCountForRendering`) of which chunk indices passed `gaussianChunkCull`'s + /// frustum/HZB test this frame. Debug-view only: populated only while + /// `SpatialDebugVisualization.shared.showGaussianChunkBounds` is on, and read by + /// `SpatialDebugBoundsCollector` to color each chunk's drawn wireframe box green (in this + /// set) or red (not). + var visibleChunkIndicesForRendering: Set = [] + /// A chunked entity with coarse levels' most recent stale readback of every chunk's drawn + /// level (0 fine, 1, 2 — `GaussianChunkLevelState.level`), indexed by chunk index, one entry + /// per chunk in the whole entity (not just the currently-visible subset). Debug-view only: + /// populated only while `SpatialDebugVisualization.shared.showGaussianChunkBounds` is on and + /// its `gaussianChunkColorMode` is `.level`. Empty for an entity with no coarse levels at all + /// (`chunkTable.coarse == nil`) — every chunk is trivially fine then. + var chunkLevelsForRendering: [UInt8] = [] /// The `.untoldgs` chunk table (decode constants on the GPU, index on the CPU), kept from /// the load so the frame can cull whole chunks before it looks at their splats. nil for a /// `.ply` or a CPU-decoded asset, which keep the per-splat cull over the whole buffer. diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 83f60f2db..ef5844136 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -4405,8 +4405,10 @@ public enum RenderPasses { let shouldDrawStaticBatchCells = settings.showStaticBatchCellBounds let shouldDrawTileBounds = settings.showTileBounds let shouldDrawOccludedBounds = isOcclusionDebugMode + let shouldDrawGaussianChunkBounds = settings.showGaussianChunkBounds guard settings.enabled || isOcclusionDebugMode, shouldDrawOctreeBounds || shouldDrawStaticBatchCells || shouldDrawTileBounds || shouldDrawOccludedBounds + || shouldDrawGaussianChunkBounds else { return } @@ -4436,6 +4438,7 @@ public enum RenderPasses { let staticBatchCellBounds = shouldDrawStaticBatchCells ? snapshot.staticBatchCellBounds : [] let tileBounds = shouldDrawTileBounds ? snapshot.tileBounds : [] let occludedBounds = shouldDrawOccludedBounds ? snapshot.occludedEntityBounds : [] + let gaussianChunkBounds = shouldDrawGaussianChunkBounds ? snapshot.gaussianChunkBounds : [] let maxLeafNodeCount = settings.maxLeafNodeCount let drawLeafCount = maxLeafNodeCount > 0 ? min(maxLeafNodeCount, leafBounds.count) : leafBounds.count @@ -4450,8 +4453,12 @@ public enum RenderPasses { let maxTileNodeCount = settings.maxTileNodeCount let drawTileCount = maxTileNodeCount > 0 ? min(maxTileNodeCount, tileBounds.count) : tileBounds.count let drawOccludedCount = occludedBounds.count + // Already capped to settings.maxGaussianChunkCount by the collector. + let drawGaussianChunkCount = gaussianChunkBounds.count - guard drawLeafCount > 0 || drawStaticBatchCellCount > 0 || drawTileCount > 0 || drawOccludedCount > 0 else { + guard drawLeafCount > 0 || drawStaticBatchCellCount > 0 || drawTileCount > 0 || drawOccludedCount > 0 + || drawGaussianChunkCount > 0 + else { return } @@ -4489,6 +4496,20 @@ public enum RenderPasses { groupedBounds[key]?.bounds.append(item.bounds) } + // Regular depth-tested group, like octree/tile/batch-cell bounds above: a chunk box is + // co-located with the splats it bounds, so it should respect depth like they do, unlike + // the occluded-entity case below where the whole point is showing something otherwise + // hidden. + for i in 0 ..< drawGaussianChunkCount { + let item = gaussianChunkBounds[i] + let key = spatialDebugColorKey(item.color) + if groupedBounds[key] == nil { + groupedBounds[key] = (color: item.color, bounds: []) + groupOrder.append(key) + } + groupedBounds[key]?.bounds.append(item.bounds) + } + // Occluded entity bounds are kept separate — they need an always-pass depth // state so the lines are visible even though the mesh is behind an occluder. var occludedGroupedBounds: [SpatialDebugColorKey: (color: simd_float4, bounds: [AABB])] = [:] @@ -4504,7 +4525,7 @@ public enum RenderPasses { } var lineVertices: [SIMD4] = [] - let drawBoundsCount = drawLeafCount + drawStaticBatchCellCount + drawTileCount + drawOccludedCount + let drawBoundsCount = drawLeafCount + drawStaticBatchCellCount + drawTileCount + drawOccludedCount + drawGaussianChunkCount lineVertices.reserveCapacity(drawBoundsCount * 24) var batches: [SpatialDebugLineBatch] = [] batches.reserveCapacity(groupOrder.count) diff --git a/Sources/UntoldEngine/Systems/GaussianSystem.swift b/Sources/UntoldEngine/Systems/GaussianSystem.swift index 65af3f7b7..f80679111 100644 --- a/Sources/UntoldEngine/Systems/GaussianSystem.swift +++ b/Sources/UntoldEngine/Systems/GaussianSystem.swift @@ -90,6 +90,12 @@ private struct GaussianVisibleCountUpdate: @unchecked Sendable { let component: GaussianComponent let visibleCount: MTLBuffer let splatCount: UInt + /// A chunked entity's per-slot `GaussianVisibleChunk[]` list (`chunkTable.visibleChunks`); + /// nil for a whole-buffer entity, which has no chunks to report. Read back into + /// `GaussianComponent.visibleChunkIndicesForRendering` only while + /// `SpatialDebugVisualization.shared.showGaussianChunkBounds` is on, so the debug view's + /// cost is paid only while something is actually looking at it. + let visibleChunkList: MTLBuffer? } /// The whole-buffer per-splat cull's inputs (`gaussianFrustumCull`). @@ -511,7 +517,8 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) { entityId: entityId, component: gaussianComponent, visibleCount: chunkSet, - splatCount: gaussianComponent.splatCount + splatCount: gaussianComponent.splatCount, + visibleChunkList: visibleChunks ) ) continue @@ -606,7 +613,8 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) { entityId: entityId, component: gaussianComponent, visibleCount: visibleCount, - splatCount: gaussianComponent.splatCount + splatCount: gaussianComponent.splatCount, + visibleChunkList: nil ) ) } @@ -658,6 +666,9 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) { computeEncoder.endEncoding() let updates = visibleCountUpdates + let chunkBoundsSettings = SpatialDebugVisualization.shared + let collectChunkBounds = chunkBoundsSettings.showGaussianChunkBounds + let collectChunkLevels = collectChunkBounds && chunkBoundsSettings.gaussianChunkColorMode == .level commandBuffer.addCompletedHandler { _ in for update in updates { let count = update.visibleCount.contents().load(as: UInt32.self) @@ -672,6 +683,41 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) { if update.component.visibleSplatCountForRendering > 0 { MemoryBudgetManager.shared.markUsed(entityId: update.entityId) } + + // Chunked only, and only while the chunk-bounds debug view is on: byte offset 4 of + // the same GaussianVisibleSet record is threadgroupCount, the number of chunks the + // cull actually appended to visibleChunkList this frame (see ShaderTypes.h) — the + // rest of that buffer is stale from whenever it last held that many entries. Each + // entry's chunkIndex word carries level/outgoing tag bits for a coarse-level entity + // (GaussianChunkCullMath.decodeVisibleChunkTag masks them off); the Set naturally + // collapses a fading chunk's separate incoming/outgoing entries into one index. + if collectChunkBounds, let chunkList = update.visibleChunkList { + let visibleChunkCount = Int(update.visibleCount.contents().load(fromByteOffset: 4, as: UInt32.self)) + var indices = Set() + indices.reserveCapacity(visibleChunkCount) + let entries = chunkList.contents().assumingMemoryBound(to: UInt32.self) + for i in 0 ..< visibleChunkCount { + let tagWord = entries[i * 4] // GaussianVisibleChunk: 4 UInt32-sized words/entry + indices.insert(GaussianChunkCullMath.decodeVisibleChunkTag(tagWord).chunkIndex) + } + update.component.visibleChunkIndicesForRendering = indices + } + + // Chunked, has coarse levels, and only while the level color mode is on: the + // persistent per-chunk level state (GaussianChunkLevelState, one entry per chunk in + // the whole entity, not just the visible subset) that gaussianComputeChunkQuotas + // last wrote. Unlike visibleChunkList this buffer isn't per-in-flight-slot — it's + // the same read pattern as visibleSplatCountForRendering above, just over the whole + // chunk range instead of one scalar. + if collectChunkLevels, let coarse = update.component.chunkTable?.coarse { + let chunkCount = update.component.chunkTable?.chunkCount ?? 0 + var levels = [UInt8](repeating: 0, count: chunkCount) + let states = coarse.levelStateBuffer.contents().assumingMemoryBound(to: GaussianChunkLevelState.self) + for i in 0 ..< chunkCount { + levels[i] = UInt8(states[i].level) + } + update.component.chunkLevelsForRendering = levels + } } } diff --git a/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift b/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift index bb7c2b742..8bc89c3da 100644 --- a/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift +++ b/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift @@ -32,17 +32,24 @@ public struct SpatialDebugBoundsSnapshot { /// Per-entity AABBs for entities that passed frustum culling but were occluded /// by the HZB pyramid. Populated only when renderDebugViewMode == .occlusionDebug. public var occludedEntityBounds: [SpatialDebugBound] + /// Per-chunk world-space bounds of every chunked (`.untoldgs`) Gaussian entity, green if the + /// chunk cull kept the chunk this frame (per `GaussianComponent.visibleChunkIndicesForRendering`, + /// a stale readback) or red if not. Populated only when + /// `SpatialDebugVisualization.showGaussianChunkBounds` is on. + public var gaussianChunkBounds: [SpatialDebugBound] public init( octreeLeafBounds: [SpatialDebugBound] = [], staticBatchCellBounds: [SpatialDebugBound] = [], tileBounds: [SpatialDebugBound] = [], - occludedEntityBounds: [SpatialDebugBound] = [] + occludedEntityBounds: [SpatialDebugBound] = [], + gaussianChunkBounds: [SpatialDebugBound] = [] ) { self.octreeLeafBounds = octreeLeafBounds self.staticBatchCellBounds = staticBatchCellBounds self.tileBounds = tileBounds self.occludedEntityBounds = occludedEntityBounds + self.gaussianChunkBounds = gaussianChunkBounds } } @@ -69,6 +76,14 @@ public final class SpatialDebugBoundsCollector: @unchecked Sendable { private let staticBatchCellLOD1Color = simd_float4(0.0, 1.0, 0.0, 1.0) private let staticBatchCellLOD2Color = simd_float4(0.0, 0.0, 1.0, 1.0) private let staticBatchCellLODMixedColor = simd_float4(1.00, 0.55, 0.15, 1.0) + private let gaussianChunkPassedColor = simd_float4(0.20, 0.95, 0.20, 1.0) + private let gaussianChunkFailedColor = simd_float4(0.95, 0.20, 0.20, 1.0) + // Chosen for maximum contrast against a splat scene's own colours (and against each + // other), not matched to the editor's white/yellow/red "Tint Splats by Level" palette — + // thin overlapping wireframe boxes wash out at white/yellow against a bright background. + private let gaussianChunkLevelFineColor = simd_float4(1.0, 0.0, 1.0, 1.0) // magenta + private let gaussianChunkLevel1Color = simd_float4(0.15, 0.95, 0.25, 1.0) // green + private let gaussianChunkLevel2Color = simd_float4(0.95, 0.15, 0.15, 1.0) // red private init() {} @@ -174,9 +189,70 @@ public final class SpatialDebugBoundsCollector: @unchecked Sendable { } } + if settings.showGaussianChunkBounds { + snapshot.gaussianChunkBounds = collectGaussianChunkBounds( + maxCount: settings.maxGaussianChunkCount, colorMode: settings.gaussianChunkColorMode + ) + } + return snapshot } + /// World-space bounds of every chunk of every chunked (`.untoldgs`) Gaussian entity. Each + /// chunk's local-space AABB (`chunk.aabbMin`/`aabbMax`, the same box the tree/per-chunk cull + /// pads and tests) is transformed by the same `worldTransform.space × splatToEntity` product + /// the cull itself uses (`GaussianEntityFrameMatrices.modelMatrix`), so a box drawn here + /// lines up with what the cull actually tested. `colorMode` picks between two stale (a + /// couple of frames old) GPU readbacks: `.cullPassFail` — green if the entity's + /// `visibleChunkIndicesForRendering` lists the chunk, red if not — or `.level` — magenta fine, + /// green level 1, red level 2, from `chunkLevelsForRendering` (empty, so every chunk reads + /// as fine, on an entity with no coarse levels baked in at all). + private func collectGaussianChunkBounds( + maxCount: Int, colorMode: SpatialDebugGaussianChunkColorMode + ) -> [SpatialDebugBound] { + let transformId = getComponentId(for: WorldTransformComponent.self) + let gaussianId = getComponentId(for: GaussianComponent.self) + let entities = queryEntitiesWithComponentIds([transformId, gaussianId], in: scene) + + var bounds: [SpatialDebugBound] = [] + for entityId in entities { + guard let gaussianComponent = scene.get(component: GaussianComponent.self, for: entityId), + gaussianComponent.isChunked, + let chunkTable = gaussianComponent.chunkTable, + let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId) + else { continue } + + let modelMatrix = simd_mul(worldTransform.space, gaussianComponent.splatToEntity) + let passed = gaussianComponent.visibleChunkIndicesForRendering + let levels = gaussianComponent.chunkLevelsForRendering + + for (index, chunk) in chunkTable.index.chunks.enumerated() { + if maxCount > 0, bounds.count >= maxCount { return bounds } + let (worldMin, worldMax) = worldAABB_MinMax( + localMin: chunk.aabbMin, localMax: chunk.aabbMax, worldMatrix: modelMatrix + ) + let color: simd_float4 + switch colorMode { + case .cullPassFail: + color = passed.contains(UInt32(index)) ? gaussianChunkPassedColor : gaussianChunkFailedColor + case .level: + let level = index < levels.count ? Int(levels[index]) : 0 + color = gaussianChunkLevelColor(level) + } + bounds.append(SpatialDebugBound(bounds: AABB(min: worldMin, max: worldMax), color: color)) + } + } + return bounds + } + + private func gaussianChunkLevelColor(_ level: Int) -> simd_float4 { + switch level { + case 1: gaussianChunkLevel1Color + case 2...: gaussianChunkLevel2Color + default: gaussianChunkLevelFineColor + } + } + private func residencyColor(for entityIds: [EntityID]) -> simd_float4 { // Tile stubs take priority — if any entity in this leaf is a tile stub, // color the leaf by the tile's streaming + HLOD state. diff --git a/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift b/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift index 76e6ec853..671bda8b0 100644 --- a/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift +++ b/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift @@ -23,6 +23,14 @@ public enum SpatialDebugBatchCellColorMode: String, Sendable { case cell } +public enum SpatialDebugGaussianChunkColorMode: String, Sendable { + /// Green passed the chunk cull this frame (drawing), red did not. + case cullPassFail + /// White fine, yellow level 1, red level 2 — the same palette as the editor's + /// "Tint Splats by Level" splat-color debug option, so the two agree visually. + case level +} + /// Runtime toggles for spatial debug visualization. public final class SpatialDebugVisualization: @unchecked Sendable { public static let shared = SpatialDebugVisualization() @@ -224,6 +232,59 @@ public final class SpatialDebugVisualization: @unchecked Sendable { } } + /// Draw per-chunk bounds of chunked (`.untoldgs`) Gaussian entities, colored by whether the + /// chunk cull kept the chunk this frame: green passed (drawing), red did not (pruned by the + /// tree pre-filter or rejected by the per-chunk frustum/HZB test — this view doesn't + /// distinguish the two). Reads `GaussianComponent.visibleChunkIndicesForRendering`, a stale + /// GPU readback like `visibleChunkCountForRendering`, gated on this flag so the readback + /// itself only runs while the view is on. + private var _showGaussianChunkBounds: Bool = false + public var showGaussianChunkBounds: Bool { + get { + lock.lock() + let value = _showGaussianChunkBounds + lock.unlock() + return value + } + set { + lock.lock() + _showGaussianChunkBounds = newValue + lock.unlock() + } + } + + /// Color mode for Gaussian chunk bounds. + private var _gaussianChunkColorMode: SpatialDebugGaussianChunkColorMode = .cullPassFail + public var gaussianChunkColorMode: SpatialDebugGaussianChunkColorMode { + get { + lock.lock() + let value = _gaussianChunkColorMode + lock.unlock() + return value + } + set { + lock.lock() + _gaussianChunkColorMode = newValue + lock.unlock() + } + } + + /// Max number of chunk bounds rendered per frame, across every chunked entity (0 = unlimited). + private var _maxGaussianChunkCount: Int = 2000 + public var maxGaussianChunkCount: Int { + get { + lock.lock() + let value = _maxGaussianChunkCount + lock.unlock() + return value + } + set { + lock.lock() + _maxGaussianChunkCount = newValue + lock.unlock() + } + } + private init() {} public func configureOctreeLeafBounds( @@ -233,7 +294,7 @@ public final class SpatialDebugVisualization: @unchecked Sendable { colorMode: SpatialDebugLeafColorMode = .plain ) { lock.lock() - _enabled = enabled || _showTileBounds || _showStaticBatchCellBounds || _colorRenderablesByLOD || _colorRenderablesByStreamingTier + _enabled = enabled || _showTileBounds || _showStaticBatchCellBounds || _colorRenderablesByLOD || _colorRenderablesByStreamingTier || _showGaussianChunkBounds _showOctreeLeafBounds = enabled _maxLeafNodeCount = max(0, maxLeafNodeCount) _octreeLeafOccupiedOnly = occupiedOnly @@ -245,7 +306,7 @@ public final class SpatialDebugVisualization: @unchecked Sendable { lock.lock() _showTileBounds = enabled _maxTileNodeCount = max(0, maxTileNodeCount) - _enabled = _showOctreeLeafBounds || enabled || _showStaticBatchCellBounds || _colorRenderablesByLOD || _colorRenderablesByStreamingTier + _enabled = _showOctreeLeafBounds || enabled || _showStaticBatchCellBounds || _colorRenderablesByLOD || _colorRenderablesByStreamingTier || _showGaussianChunkBounds lock.unlock() } @@ -255,7 +316,7 @@ public final class SpatialDebugVisualization: @unchecked Sendable { colorMode: SpatialDebugBatchCellColorMode = .plain ) { lock.lock() - _enabled = _showOctreeLeafBounds || _showTileBounds || enabled || _colorRenderablesByLOD || _colorRenderablesByStreamingTier + _enabled = _showOctreeLeafBounds || _showTileBounds || enabled || _colorRenderablesByLOD || _colorRenderablesByStreamingTier || _showGaussianChunkBounds _showStaticBatchCellBounds = enabled _maxStaticBatchCellCount = max(0, maxCellCount) _staticBatchCellColorMode = colorMode @@ -265,14 +326,28 @@ public final class SpatialDebugVisualization: @unchecked Sendable { public func configureLODLevelColoring(enabled: Bool) { lock.lock() _colorRenderablesByLOD = enabled - _enabled = _showOctreeLeafBounds || _showStaticBatchCellBounds || _colorRenderablesByLOD || _colorRenderablesByStreamingTier + _enabled = _showOctreeLeafBounds || _showStaticBatchCellBounds || _colorRenderablesByLOD || _colorRenderablesByStreamingTier || _showGaussianChunkBounds lock.unlock() } public func configureTextureStreamingTierColoring(enabled: Bool) { lock.lock() _colorRenderablesByStreamingTier = enabled - _enabled = _showOctreeLeafBounds || _showStaticBatchCellBounds || _colorRenderablesByLOD || enabled + _enabled = _showOctreeLeafBounds || _showStaticBatchCellBounds || _colorRenderablesByLOD || enabled || _showGaussianChunkBounds + lock.unlock() + } + + public func configureGaussianChunkBounds( + enabled: Bool, + maxChunkCount: Int = 2000, + colorMode: SpatialDebugGaussianChunkColorMode = .cullPassFail + ) { + lock.lock() + _showGaussianChunkBounds = enabled + _maxGaussianChunkCount = max(0, maxChunkCount) + _gaussianChunkColorMode = colorMode + _enabled = _showOctreeLeafBounds || _showTileBounds || _showStaticBatchCellBounds + || _colorRenderablesByLOD || _colorRenderablesByStreamingTier || enabled lock.unlock() } @@ -282,6 +357,7 @@ public final class SpatialDebugVisualization: @unchecked Sendable { _showOctreeLeafBounds = false _showTileBounds = false _showStaticBatchCellBounds = false + _showGaussianChunkBounds = false _colorRenderablesByLOD = false _colorRenderablesByStreamingTier = false lock.unlock() @@ -308,6 +384,18 @@ public func setTileBoundsDebug(enabled: Bool, maxTileNodeCount: Int = 500) { SpatialDebugVisualization.shared.configureTileBounds(enabled: enabled, maxTileNodeCount: maxTileNodeCount) } +/// Enable/disable per-chunk bounds visualization for chunked (`.untoldgs`) Gaussian entities. +/// `colorMode` picks green/red cull pass-fail (default) or white/yellow/red fine/level-1/level-2. +public func setGaussianChunkBoundsDebug( + enabled: Bool, + maxChunkCount: Int = 2000, + colorMode: SpatialDebugGaussianChunkColorMode = .cullPassFail +) { + SpatialDebugVisualization.shared.configureGaussianChunkBounds( + enabled: enabled, maxChunkCount: maxChunkCount, colorMode: colorMode + ) +} + /// Enable/disable static batch cell bounds visualization. public func setStaticBatchCellBoundsDebug( enabled: Bool,