Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt32> = []
/// 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.
Expand Down
25 changes: 23 additions & 2 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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])] = [:]
Expand All @@ -4504,7 +4525,7 @@ public enum RenderPasses {
}

var lineVertices: [SIMD4<Float>] = []
let drawBoundsCount = drawLeafCount + drawStaticBatchCellCount + drawTileCount + drawOccludedCount
let drawBoundsCount = drawLeafCount + drawStaticBatchCellCount + drawTileCount + drawOccludedCount + drawGaussianChunkCount
lineVertices.reserveCapacity(drawBoundsCount * 24)
var batches: [SpatialDebugLineBatch] = []
batches.reserveCapacity(groupOrder.count)
Expand Down
50 changes: 48 additions & 2 deletions Sources/UntoldEngine/Systems/GaussianSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -511,7 +517,8 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
entityId: entityId,
component: gaussianComponent,
visibleCount: chunkSet,
splatCount: gaussianComponent.splatCount
splatCount: gaussianComponent.splatCount,
visibleChunkList: visibleChunks
)
)
continue
Expand Down Expand Up @@ -606,7 +613,8 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
entityId: entityId,
component: gaussianComponent,
visibleCount: visibleCount,
splatCount: gaussianComponent.splatCount
splatCount: gaussianComponent.splatCount,
visibleChunkList: nil
)
)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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<UInt32>()
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
}
}
}

Expand Down
78 changes: 77 additions & 1 deletion Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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() {}

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading