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
1 change: 1 addition & 0 deletions Sources/DemoGame/DemoHUD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@
Text("SMAA Edges").tag(RenderDebugViewMode.smaaEdges)
Text("SMAA Blend").tag(RenderDebugViewMode.smaaBlend)
Text("SMAA Difference").tag(RenderDebugViewMode.smaaDifference)
Text("Occlusion Debug").tag(RenderDebugViewMode.occlusionDebug)
}
.pickerStyle(.menu)

Expand Down
5 changes: 5 additions & 0 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,11 @@ public class TileComponent: Component {
/// Human-readable tile identifier from the manifest (e.g. "tile_3_2").
public var tileId: String = ""

/// Quadtree node identifier from the manifest (e.g. "F02Q100").
/// Present only in v4 quadtree_floor manifests; nil for v3 uniform-grid tiles.
/// Used by the hierarchy-aware tile culling gate in GeometryStreamingSystem.
public var quadtreeNodeId: String?

/// When true this tile contains interior-only geometry (StructuralInterior,
/// RoomContents, FineProps). The streaming system gates loading of these
/// tiles on the camera being inside the scene's interior_zone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private func expandedEngineStatsString(_ snapshot: EngineStatsSnapshot) -> Strin
Render: draws \(snapshot.render.drawCallsTotal) (opaque \(snapshot.render.drawCallsOpaque), transparent \(snapshot.render.drawCallsTransparent), shadow \(snapshot.render.drawCallsShadow), batched \(snapshot.render.drawCallsBatched)) | triangles \(snapshot.render.trianglesTotal) | visible \(snapshot.render.visibleInstances)
Culling: frustum \(snapshot.culling.frustumPassed)/\(snapshot.culling.frustumTested) failed \(snapshot.culling.frustumFailed) | occlusion \(snapshot.culling.occlusionPassed)/\(snapshot.culling.occlusionTested) failed \(snapshot.culling.occlusionFailed) | usedHZB \(snapshot.culling.usedHZB) validHZB \(snapshot.culling.hzbIsValid)
Streaming: loaded \(snapshot.streaming.loadedStreamingEntities) loading \(snapshot.streaming.loadingStreamingEntities) unloaded \(snapshot.streaming.unloadedStreamingEntities) | active \(snapshot.streaming.activeLoads) | nearby \(snapshot.streaming.nearbyEntitiesQueried) candidates \(snapshot.streaming.loadCandidates) slots \(snapshot.streaming.availableLoadSlots) | backlog \(snapshot.streaming.pendingLoadBacklog) | pendingUploads \(snapshot.streaming.pendingUploadCount) | gateMs \(formatMs(snapshot.streaming.blockedByGateMs))
Streaming: tick=\(snapshot.streaming.updateTriggered) workMs \(formatMs(snapshot.streaming.updateWorkMs)) | evictions \(snapshot.streaming.evictionsPerformed) | avgLoadMs \(formatMs(snapshot.streaming.averageAsyncLoadMs)) | applyMs \(formatMs(snapshot.streaming.lastApplyLoadedMeshMs)) | tileSwapWarn \(snapshot.streaming.tileSwapWarnings)
Streaming: tick=\(snapshot.streaming.updateTriggered) workMs \(formatMs(snapshot.streaming.updateWorkMs)) | evictions \(snapshot.streaming.evictionsPerformed) | avgLoadMs \(formatMs(snapshot.streaming.averageAsyncLoadMs)) | applyMs \(formatMs(snapshot.streaming.lastApplyLoadedMeshMs)) | tileSwapWarn \(snapshot.streaming.tileSwapWarnings) | hierGateSkip \(snapshot.streaming.tilesSkippedByHierarchyGate)
Batching: groups \(snapshot.batching.batchGroupCount) | batchedMeshes \(snapshot.batching.batchedMeshCount) | dirty \(snapshot.batching.dirtyCellsBeforePrune)→\(snapshot.batching.dirtyCellsAfterPrune) | defWork \(snapshot.batching.deferredByWorkBudget) skipComplex \(snapshot.batching.skippedByComplexityGuard) | dispatched \(snapshot.batching.dispatchedBuilds)→\(snapshot.batching.lastRebuildOutputBatchCount) groups | rebuilds/s \(snapshot.batching.rebuildsThisSecond) | rebuildMs \(formatMs(snapshot.batching.lastRebuildCostMs))
Memory: mesh \(meshMB)/\(meshBudgetMB)mb | tex \(texMB)/\(texBudgetMB)mb | total \(memPct) | entities \(snapshot.memory.trackedEntityCount)\(pressure)
"""
Expand Down
1 change: 1 addition & 0 deletions Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ public struct EngineStreamingStats {
public var averageAsyncLoadMs: Double = 0.0
public var lastApplyLoadedMeshMs: Double = 0.0
public var tileSwapWarnings: Int = 0
public var tilesSkippedByHierarchyGate: Int = 0

public init(
activeLoads: Int = 0,
Expand Down
129 changes: 124 additions & 5 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public enum RenderPasses {
let lock = NSLock()
var transparencyXRDepthWriteState: MTLDepthStencilState?
var wireframeXRDepthWriteState: MTLDepthStencilState?
var alwaysDepthState: MTLDepthStencilState?
var spatialDebugLineBuffer: MTLBuffer?
var spatialDebugLineBufferCapacityVertices: Int = 0
var spatialDebugLastLogTime: TimeInterval = 0
Expand Down Expand Up @@ -140,6 +141,29 @@ public enum RenderPasses {
return result
}

@inline(__always)
private static func getOrCreateAlwaysDepthState(device: MTLDevice) -> MTLDepthStencilState? {
runtimeState.lock.lock()
if let cached = runtimeState.alwaysDepthState {
runtimeState.lock.unlock()
return cached
}
runtimeState.lock.unlock()

let descriptor = MTLDepthStencilDescriptor()
descriptor.depthCompareFunction = .always
descriptor.isDepthWriteEnabled = false
let created = device.makeDepthStencilState(descriptor: descriptor)

runtimeState.lock.lock()
if runtimeState.alwaysDepthState == nil {
runtimeState.alwaysDepthState = created
}
let result = runtimeState.alwaysDepthState
runtimeState.lock.unlock()
return result
}

@inline(__always)
private static func ensureSpatialDebugLineBuffer(
device: MTLDevice,
Expand Down Expand Up @@ -503,10 +527,20 @@ public enum RenderPasses {
localMax: localTransformComponent.boundingBox.max,
worldMatrix: worldTransformComponent.space
)
// Per-cascade distance limit: cap at the cascade's own split distance so
// objects beyond this cascade's far plane are not rendered into it.
// This prevents the near cascade from receiving shadow casters that are
// only relevant to farther cascades, cutting draw calls significantly for
// the near (most expensive) cascade.
let cascadeMaxDistance = shadowCascadeMaxDistance(
cascadeIdx: cascadeIdx,
splitDistances: shadowSystem.cascadeSplitDistances,
globalMax: RenderPasses.maxShadowCastingDistance
)
if shadowEntityBeyondMaxDistance(
worldMin: worldMin, worldMax: worldMax,
cameraPosition: cameraPosition,
maxDistance: RenderPasses.maxShadowCastingDistance
maxDistance: cascadeMaxDistance
) { continue }
if isAABBInFrustum(frustum, min: worldMin, max: worldMax) {
result.append(entityId)
Expand Down Expand Up @@ -3273,10 +3307,14 @@ public enum RenderPasses {

public static let spatialDebugBoundsExecution: RenderPassExecution = { commandBuffer in
let settings = SpatialDebugVisualization.shared
let isOcclusionDebugMode = renderDebugViewMode == .occlusionDebug
let shouldDrawOctreeBounds = settings.showOctreeLeafBounds
let shouldDrawStaticBatchCells = settings.showStaticBatchCellBounds
let shouldDrawTileBounds = settings.showTileBounds
guard settings.enabled, shouldDrawOctreeBounds || shouldDrawStaticBatchCells || shouldDrawTileBounds else {
let shouldDrawOccludedBounds = isOcclusionDebugMode
guard settings.enabled || isOcclusionDebugMode,
shouldDrawOctreeBounds || shouldDrawStaticBatchCells || shouldDrawTileBounds || shouldDrawOccludedBounds
else {
return
}

Expand Down Expand Up @@ -3304,6 +3342,7 @@ public enum RenderPasses {
let leafBounds = shouldDrawOctreeBounds ? snapshot.octreeLeafBounds : []
let staticBatchCellBounds = shouldDrawStaticBatchCells ? snapshot.staticBatchCellBounds : []
let tileBounds = shouldDrawTileBounds ? snapshot.tileBounds : []
let occludedBounds = shouldDrawOccludedBounds ? snapshot.occludedEntityBounds : []

let maxLeafNodeCount = settings.maxLeafNodeCount
let drawLeafCount = maxLeafNodeCount > 0 ? min(maxLeafNodeCount, leafBounds.count) : leafBounds.count
Expand All @@ -3317,8 +3356,9 @@ public enum RenderPasses {
: staticBatchCellBounds.count
let maxTileNodeCount = settings.maxTileNodeCount
let drawTileCount = maxTileNodeCount > 0 ? min(maxTileNodeCount, tileBounds.count) : tileBounds.count
let drawOccludedCount = occludedBounds.count

guard drawLeafCount > 0 || drawStaticBatchCellCount > 0 || drawTileCount > 0 else {
guard drawLeafCount > 0 || drawStaticBatchCellCount > 0 || drawTileCount > 0 || drawOccludedCount > 0 else {
return
}

Expand Down Expand Up @@ -3356,8 +3396,22 @@ public enum RenderPasses {
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])] = [:]
var occludedGroupOrder: [SpatialDebugColorKey] = []
for i in 0 ..< drawOccludedCount {
let item = occludedBounds[i]
let key = spatialDebugColorKey(item.color)
if occludedGroupedBounds[key] == nil {
occludedGroupedBounds[key] = (color: item.color, bounds: [])
occludedGroupOrder.append(key)
}
occludedGroupedBounds[key]?.bounds.append(item.bounds)
}

var lineVertices: [SIMD4<Float>] = []
let drawBoundsCount = drawLeafCount + drawStaticBatchCellCount + drawTileCount
let drawBoundsCount = drawLeafCount + drawStaticBatchCellCount + drawTileCount + drawOccludedCount
lineVertices.reserveCapacity(drawBoundsCount * 24)
var batches: [SpatialDebugLineBatch] = []
batches.reserveCapacity(groupOrder.count)
Expand All @@ -3382,6 +3436,28 @@ public enum RenderPasses {
}
}

var occludedBatches: [SpatialDebugLineBatch] = []
occludedBatches.reserveCapacity(occludedGroupOrder.count)
for key in occludedGroupOrder {
guard let group = occludedGroupedBounds[key] else { continue }
let vertexStart = lineVertices.count

for bounds in group.bounds {
appendAABBLineVertices(bounds, to: &lineVertices)
}

let vertexCount = lineVertices.count - vertexStart
if vertexCount > 0 {
occludedBatches.append(
SpatialDebugLineBatch(
color: group.color,
vertexStart: vertexStart,
vertexCount: vertexCount
)
)
}
}

let requiredVertexCount = lineVertices.count
guard requiredVertexCount > 0 else {
return
Expand Down Expand Up @@ -3456,6 +3532,29 @@ public enum RenderPasses {
)
}

// Occluded entity bounds must draw on top of occluding geometry, so switch to
// an always-pass depth state before drawing them.
if !occludedBatches.isEmpty {
let alwaysState = getOrCreateAlwaysDepthState(device: renderInfo.device)
if let alwaysState {
renderEncoder.setDepthStencilState(alwaysState)
}
for batch in occludedBatches {
var debugColor = batch.color
renderEncoder.setFragmentBytes(
&debugColor,
length: MemoryLayout<simd_float4>.stride,
index: 0
)
renderEncoder.drawPrimitivesTracked(
type: .line,
vertexStart: batch.vertexStart,
vertexCount: batch.vertexCount,
category: .other
)
}
}

renderEncoder.updateFence(renderInfo.fence, after: .fragment)
}

Expand Down Expand Up @@ -3689,7 +3788,27 @@ private func uploadAndBindLights<T>(
return true
}

// MARK: - Shadow distance culling helper (internal — exposed for testing via @testable import)
// MARK: - Shadow cascade distance helpers (internal — exposed for testing via @testable import)

/// Returns the effective maximum shadow-casting distance for a single CSM cascade.
///
/// Each cascade only needs shadow casters within its own split range. Capping at the
/// cascade's split distance prevents the near cascade from receiving distant casters
/// that are only relevant to farther cascades, reducing shadow draw calls on cascade 0.
///
/// - Parameters:
/// - cascadeIdx: Index of the cascade (0 = nearest).
/// - splitDistances: Per-cascade far-plane distances from the camera, as computed by ShadowSystem.
/// - globalMax: The scene-wide shadow distance cap (RenderPasses.maxShadowCastingDistance).
/// - Returns: The tighter of globalMax and the cascade's own split distance.
func shadowCascadeMaxDistance(
cascadeIdx: Int,
splitDistances: [Float],
globalMax: Float
) -> Float {
guard cascadeIdx < splitDistances.count else { return globalMax }
return min(globalMax, splitDistances[cascadeIdx])
}

/// Returns true when the entity's AABB is farther than maxDistance from the camera.
/// Uses closest-point-on-AABB distance so large meshes near the camera are never wrongly excluded.
Expand Down
1 change: 1 addition & 0 deletions Sources/UntoldEngine/Renderer/UntoldEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {
snapshot.streaming.averageAsyncLoadMs = streamingDiag.averageAsyncLoadMs
snapshot.streaming.lastApplyLoadedMeshMs = streamingDiag.lastApplyLoadedMeshMs
snapshot.streaming.tileSwapWarnings = streamingDiag.tileSwapWarnings
snapshot.streaming.tilesSkippedByHierarchyGate = streamingDiag.tilesSkippedByHierarchyGate

snapshot.batching.batchGroupCount = batchGroups.count
snapshot.batching.batchedMeshCount = batchedMeshCount
Expand Down
Loading
Loading