diff --git a/Sources/CShaderTypes/ShaderTypes.h b/Sources/CShaderTypes/ShaderTypes.h index 318fa3c3..50bba695 100644 --- a/Sources/CShaderTypes/ShaderTypes.h +++ b/Sources/CShaderTypes/ShaderTypes.h @@ -315,6 +315,7 @@ typedef struct{ float alphaCutoff; float passthroughAlpha; // mixed passthrough color alpha; depth remains opaque int alphaMode; // 0=opaque, 1=mask, 2=blend + simd_float4 lodDither; // x=threshold, y=mode: 0 off, 1 keep below, 2 keep at/above bool interactWithLight; }MaterialParametersUniform; diff --git a/Sources/DemoGame/AppDelegate.swift b/Sources/DemoGame/AppDelegate.swift index 540dadd7..6fef673f 100644 --- a/Sources/DemoGame/AppDelegate.swift +++ b/Sources/DemoGame/AppDelegate.swift @@ -186,15 +186,16 @@ demoState.onRenderDebugViewChanged = { [weak self] mode in self?.gameScene.setRenderDebugView(mode) } - demoState.onSpatialDebugChanged = { [weak self] enabled, occupiedOnly, colorMode in + demoState.onSpatialDebugChanged = { [weak self] enabled, octreeCellsEnabled, occupiedOnly, colorMode in self?.gameScene.setSpatialDebug( enabled: enabled, + octreeCellsEnabled: octreeCellsEnabled, occupiedOnly: occupiedOnly, colorMode: colorMode ) } - demoState.onTileBoundsChanged = { enabled in - setTileBoundsDebug(enabled: enabled) + demoState.onTileBoundsChanged = { [weak self] enabled in + self?.gameScene.setTileBoundsDebug(enabled) } demoState.onMouseOverControlPanelChanged = { [weak self] isOver in self?.gameScene.suppressCameraInput = isOver diff --git a/Sources/DemoGame/DemoHUD.swift b/Sources/DemoGame/DemoHUD.swift index 196c78bb..501c656e 100644 --- a/Sources/DemoGame/DemoHUD.swift +++ b/Sources/DemoGame/DemoHUD.swift @@ -351,7 +351,15 @@ Toggle("Spatial Debug", isOn: $state.spatialDebugEnabled) .toggleStyle(.checkbox) if state.spatialDebugEnabled { - Toggle("Occupied Only", isOn: $state.spatialOccupiedOnly) + Toggle("Octree Cells", isOn: $state.octreeCellsEnabled) + .toggleStyle(.checkbox) + .padding(.leading, 12) + if state.octreeCellsEnabled { + Toggle("Occupied Only", isOn: $state.spatialOccupiedOnly) + .toggleStyle(.checkbox) + .padding(.leading, 24) + } + Toggle("Tile Bounds", isOn: $state.tileBoundsEnabled) .toggleStyle(.checkbox) .padding(.leading, 12) Picker("Mode", selection: $state.spatialColorMode) { @@ -361,9 +369,7 @@ } .pickerStyle(.segmented) .frame(minWidth: 180) - Toggle("Tile Bounds", isOn: $state.tileBoundsEnabled) - .toggleStyle(.checkbox) - .padding(.leading, 12) + .padding(.top, 2) } Divider() diff --git a/Sources/DemoGame/DemoState.swift b/Sources/DemoGame/DemoState.swift index 646d0030..d01e1326 100644 --- a/Sources/DemoGame/DemoState.swift +++ b/Sources/DemoGame/DemoState.swift @@ -180,19 +180,40 @@ } var spatialDebugEnabled: Bool = false { - didSet { onSpatialDebugChanged?(spatialDebugEnabled, spatialOccupiedOnly, spatialColorMode) } + didSet { + onSpatialDebugChanged?(spatialDebugEnabled, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode) + // Tile Bounds is a child of Spatial Debug; propagate the master gate. + onTileBoundsChanged?(spatialDebugEnabled ? tileBoundsEnabled : false) + } + } + + /// Whether octree leaf cells are shown within the Spatial Debug overlay. + var octreeCellsEnabled: Bool = true { + didSet { + if spatialDebugEnabled { + onSpatialDebugChanged?(true, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode) + } + } } var spatialColorMode: SpatialDebugLeafColorMode = .plain { - didSet { if spatialDebugEnabled { onSpatialDebugChanged?(true, spatialOccupiedOnly, spatialColorMode) } } + didSet { + if spatialDebugEnabled { + onSpatialDebugChanged?(true, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode) + } + } } var spatialOccupiedOnly: Bool = true { - didSet { if spatialDebugEnabled { onSpatialDebugChanged?(true, spatialOccupiedOnly, spatialColorMode) } } + didSet { + if spatialDebugEnabled { + onSpatialDebugChanged?(true, octreeCellsEnabled, spatialOccupiedOnly, spatialColorMode) + } + } } var tileBoundsEnabled: Bool = false { - didSet { onTileBoundsChanged?(tileBoundsEnabled) } + didSet { if spatialDebugEnabled { onTileBoundsChanged?(tileBoundsEnabled) } } } // MARK: - Stats @@ -216,7 +237,7 @@ var onAntiAliasingChanged: ((AntiAliasingMode) -> Void)? var onTextureStreamingTierDebugChanged: ((Bool) -> Void)? var onRenderDebugViewChanged: ((RenderDebugViewMode) -> Void)? - var onSpatialDebugChanged: ((Bool, Bool, SpatialDebugLeafColorMode) -> Void)? + var onSpatialDebugChanged: ((Bool, Bool, Bool, SpatialDebugLeafColorMode) -> Void)? var onTileBoundsChanged: ((Bool) -> Void)? var onMouseOverControlPanelChanged: ((Bool) -> Void)? @@ -233,7 +254,7 @@ ssaoBias = Double(preset.ssaoBias) ssaoIntensity = Double(preset.ssaoIntensity) isApplyingPostFXPreset = false - PostFX.apply(preset) + setPostFX(.preset(preset)) } private func notifyColorGradingChanged(force: Bool = false) { diff --git a/Sources/DemoGame/GameScene.swift b/Sources/DemoGame/GameScene.swift index 5ad64811..4254043f 100644 --- a/Sources/DemoGame/GameScene.swift +++ b/Sources/DemoGame/GameScene.swift @@ -15,8 +15,8 @@ /// - Entity lifecycle: `createEntity`, `setEntityName`, `destroyAllEntities` /// - Camera/input: `createGameCamera`, `findGameCamera`, `moveCameraWithInput`, `orbitCameraAround` /// - Asset loading: `setEntityMeshAsync` (always-resident), `setEntityStreamScene` (streamable scene) - /// - Performance features: `setEntityStaticBatchComponent`, `enableBatching`, `generateBatches`, `enableStreaming` - /// - Debug overlays: `setLODLevelDebug`, `setTextureStreamingTierDebug`, `setOctreeLeafBoundsDebug` + /// - Performance features: `setEntityStaticBatchComponent`, `setBatching`, `generateBatches`, `setGeometryStreaming` + /// - Debug overlays: `setSpatialDebug` final class GameScene: @unchecked Sendable { private enum LoadedContent { case none @@ -55,7 +55,7 @@ init() { InputSystem.shared.registerKeyboardEvents() InputSystem.shared.registerMouseEvents() - bypassPostProcessing = false + setRendering(.postProcessing(.enabled)) setupDefaultSceneObjects() } } @@ -71,12 +71,10 @@ let light = createEntity() setEntityName(entityId: light, name: "Directional Light") createDirLight(entityId: light) + setCamera(.active(gameCamera)) - CameraSystem.shared.activeCamera = gameCamera - - applyIBL = true - renderEnvironment = false - + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) // setEngineStatsLogging(enabled: true, profile: .verbose, intervalSeconds: 1.0) } } @@ -98,10 +96,10 @@ loadedEntity = success ? entity : nil loadedContent = success ? .mesh(entity) : .none let camera = findGameCamera() - CameraSystem.shared.activeCamera = camera + setCamera(.active(camera)) cameraBehavior = .flyOrbit setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) - renderEnvironment = false + setRendering(.environment(.visible(false))) completion(success) } } @@ -116,7 +114,7 @@ } clearSceneBatches() - GeometryStreamingSystem.shared.enabled = true + setGeometryStreaming(.enabled(true)) let sceneRoot = createEntity() setEntityName(entityId: sceneRoot, name: sceneID) @@ -128,7 +126,7 @@ loadedContent = .tiledScene(sceneRoot) cameraBehavior = Self.cameraBehavior(for: sceneID) Self.applyCameraEye(for: sceneID) - renderEnvironment = Self.shouldRenderEnvironment(for: sceneID) + setRendering(.environment(.visible(Self.shouldRenderEnvironment(for: sceneID)))) } completion(success) } @@ -136,7 +134,7 @@ private func prepareForMeshLoad(completion: @escaping () -> Void) { clearSceneBatches() - GeometryStreamingSystem.shared.enabled = false + setGeometryStreaming(.enabled(false)) switch loadedContent { case let .mesh(entity): @@ -158,7 +156,7 @@ private static func applyCameraEye(for sceneID: String) { let camera = findGameCamera() let eye: simd_float3 - let target: simd_float3 + var target: simd_float3 switch cameraBehavior(for: sceneID) { case .originOrbit: @@ -170,7 +168,7 @@ } cameraLookAt(entityId: camera, eye: eye, target: target, up: cameraUpDefault) - CameraSystem.shared.activeCamera = camera + setCamera(.active(camera)) if cameraBehavior(for: sceneID) == .originOrbit { setOriginOrbitTarget(entityId: camera) } else { @@ -221,17 +219,17 @@ guard let entity = loadedEntity else { return } if enabled { setEntityStaticBatchComponent(entityId: entity) - enableBatching(true) + UntoldEngine.setBatching(.enabled(true)) generateBatches() } else { - enableBatching(false) + UntoldEngine.setBatching(.enabled(false)) } } /// Enables or disables the geometry streaming system. /// Streaming radii are declared in the scene manifest; this is a runtime on/off toggle only. func setStreaming(_ enabled: Bool, streamingRadius _: Float, unloadRadius _: Float) { - GeometryStreamingSystem.shared.enabled = enabled + setGeometryStreaming(.enabled(enabled)) } } @@ -239,58 +237,70 @@ extension GameScene { func setColorGrading(enabled: Bool, exposure: Float, brightness: Float, contrast: Float, saturation: Float) { - PostFX.enableColorGrading(enabled) - ColorGradingParams.shared.exposure = exposure - ColorGradingParams.shared.brightness = brightness - ColorGradingParams.shared.contrast = contrast - ColorGradingParams.shared.saturation = saturation + setPostFX(.colorGrading(.enabled(enabled))) + setPostFX(.colorGrading(.exposure(exposure))) + setPostFX(.colorGrading(.brightness(brightness))) + setPostFX(.colorGrading(.contrast(contrast))) + setPostFX(.colorGrading(.saturation(saturation))) } func setSSAO(enabled: Bool, radius: Float, bias: Float, intensity: Float) { - SSAO.setEnabled(enabled) - SSAO.setRadius(radius) - SSAO.setBias(bias) - SSAO.setIntensity(intensity) + setPostFX(.ssao(.enabled(enabled))) + setPostFX(.ssao(.radius(radius))) + setPostFX(.ssao(.bias(bias))) + setPostFX(.ssao(.intensity(intensity))) } /// Selects the active anti-aliasing pass used by the render graph. func setAntiAliasing(_ mode: AntiAliasingMode) { - antiAliasingMode = mode + setRendering(.antiAliasing(mode)) } /// Toggles the per-entity LOD level colour overlay. func setLodDebug(_ enabled: Bool) { - setLODLevelDebug(enabled: enabled) + UntoldEngine.setSpatialDebug(.lodLevels(enabled)) } /// Toggles the texture streaming tier colour overlay. func setStreamingTierDebug(_ enabled: Bool) { - setTextureStreamingTierDebug(enabled: enabled) + UntoldEngine.setSpatialDebug(.textureStreamingTiers(enabled)) + } + + /// Toggles streamed tile bounds in the Spatial Debug overlay. + func setTileBoundsDebug(_ enabled: Bool) { + UntoldEngine.setSpatialDebug(.tileBounds(enabled: enabled)) } /// Selects the renderer debug output. func setRenderDebugView(_ mode: RenderDebugViewMode) { if mode == .ssaoBlurred, SSAO.isEnabled() == false { - SSAO.setEnabled(true) + setPostFX(.ssao(.enabled(true))) } - renderDebugViewMode = mode + setRendering(.debugView(mode)) } /// Draws (or hides) the octree leaf-node bounds debug overlay. func setSpatialDebug( enabled: Bool, + octreeCellsEnabled: Bool, occupiedOnly: Bool, colorMode: SpatialDebugLeafColorMode ) { if enabled { - setOctreeLeafBoundsDebug( - enabled: true, - maxLeafNodeCount: 0, - occupiedOnly: occupiedOnly, - colorMode: colorMode - ) + // Always apply the color mode and occupiedOnly so tile bounds (which read + // these settings directly from SpatialDebugVisualization) stay in sync even + // when octree leaf cells are toggled off. + if octreeCellsEnabled { + UntoldEngine.setSpatialDebug(.octreeLeafBounds(.enabled( + maxLeafNodeCount: 0, + occupiedOnly: occupiedOnly, + colorMode: colorMode + ))) + } else { + UntoldEngine.setSpatialDebug(.octreeLeafBounds(.disabled)) + } } else { - disableSpatialDebugVisualization() + UntoldEngine.setSpatialDebug(.disabled) } } } diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 73675bd8..f3949f7c 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -400,6 +400,22 @@ public class TileLODTagComponent: Component { } } +/// Per-render-descendant dither state used while tile LOD/HLOD/full-tile +/// representations hand off visibility. +public class TileRepresentationFadeComponent: Component { + /// 0...1 cross-fade progress. + public var progress: Float = 0 + /// Matches MaterialParametersUniform.lodDither.y. + /// 1 = incoming representation, 2 = outgoing representation. + public var mode: Float = 0 + + public required init() {} + public init(progress: Float, mode: Float) { + self.progress = progress + self.mode = mode + } +} + // MARK: Static Batching Component public class StaticBatchComponent: Component { @@ -659,6 +675,18 @@ public class TileComponent: Component { /// Tracks representation churn for diagnostics and dwell logic. public var lastLoadedLODIndex: Int? + /// Partition-cell AABB from the manifest (key: "cell_bounds"). + /// Tighter than the mesh content AABB for spanning tiles whose geometry + /// overflows the cell boundary. Used by the debug tile-bounds visualizer + /// so the drawn box matches the Blender overlay partition cells. + /// nil for manifests that predate the cell_bounds field and for the shared bucket. + public var cellBounds: AABB? + + /// True when this entity represents the shared-bucket tile (the monolithic asset + /// holding world-spanning objects). The shared bucket is not a partition cell so + /// it is excluded from the "Tile Bounds" debug overlay. + public var isSharedBucket: Bool = false + public required init() {} } diff --git a/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift b/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift index 68d6a515..fea12154 100644 --- a/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift +++ b/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift @@ -62,7 +62,9 @@ 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) | hierGateSkip \(snapshot.streaming.tilesSkippedByHierarchyGate) + 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) | repGap \(snapshot.streaming.tileRepresentationGapWarnings) | lod0VisWarn \(snapshot.streaming.lod0VisibilityWarnings) covered \(snapshot.streaming.lod0VisibilityWarningsWithFallback) open \(snapshot.streaming.lod0VisibilityWarningsNoFallback) | hierGateSkip \(snapshot.streaming.tilesSkippedByHierarchyGate) + TileReps: resident full/lod/hlod \(snapshot.streaming.residentFullTileRepresentations)/\(snapshot.streaming.residentLODRepresentations)/\(snapshot.streaming.residentHLODRepresentations) | visible full/lod/hlod \(snapshot.streaming.visibleFullTileRepresentations)/\(snapshot.streaming.visibleLODRepresentations)/\(snapshot.streaming.visibleHLODRepresentations) | overlap visible full+lod/full+hlod/lod+hlod \(snapshot.streaming.fullAndLODVisibleOverlapTiles)/\(snapshot.streaming.fullAndHLODVisibleOverlapTiles)/\(snapshot.streaming.lodAndHLODVisibleOverlapTiles) residentFull+fallback \(snapshot.streaming.fullAndFallbackResidentOverlapTiles) | fades \(snapshot.streaming.activeTileRepresentationFades) waiting \(snapshot.streaming.waitingTileRepresentationFades) + TileRenderCost: visible full/lod/hlod \(snapshot.render.tileFullVisibleInstances)/\(snapshot.render.tileLODVisibleInstances)/\(snapshot.render.tileHLODVisibleInstances) | draws full/lod/hlod \(snapshot.render.tileFullDrawsEstimate)/\(snapshot.render.tileLODDrawsEstimate)/\(snapshot.render.tileHLODDrawsEstimate) | tris full/lod/hlod \(snapshot.render.tileFullTrianglesEstimate)/\(snapshot.render.tileLODTrianglesEstimate)/\(snapshot.render.tileHLODTrianglesEstimate) 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) """ diff --git a/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift b/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift index 861fa44b..338f8f86 100644 --- a/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift +++ b/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift @@ -71,6 +71,15 @@ public struct EngineRenderStats { public var drawCallsBatched: Int = 0 public var trianglesTotal: Int = 0 public var visibleInstances: Int = 0 + public var tileFullVisibleInstances: Int = 0 + public var tileLODVisibleInstances: Int = 0 + public var tileHLODVisibleInstances: Int = 0 + public var tileFullDrawsEstimate: Int = 0 + public var tileLODDrawsEstimate: Int = 0 + public var tileHLODDrawsEstimate: Int = 0 + public var tileFullTrianglesEstimate: Int = 0 + public var tileLODTrianglesEstimate: Int = 0 + public var tileHLODTrianglesEstimate: Int = 0 public init( drawCallsTotal: Int = 0, @@ -79,7 +88,16 @@ public struct EngineRenderStats { drawCallsShadow: Int = 0, drawCallsBatched: Int = 0, trianglesTotal: Int = 0, - visibleInstances: Int = 0 + visibleInstances: Int = 0, + tileFullVisibleInstances: Int = 0, + tileLODVisibleInstances: Int = 0, + tileHLODVisibleInstances: Int = 0, + tileFullDrawsEstimate: Int = 0, + tileLODDrawsEstimate: Int = 0, + tileHLODDrawsEstimate: Int = 0, + tileFullTrianglesEstimate: Int = 0, + tileLODTrianglesEstimate: Int = 0, + tileHLODTrianglesEstimate: Int = 0 ) { self.drawCallsTotal = drawCallsTotal self.drawCallsOpaque = drawCallsOpaque @@ -88,6 +106,15 @@ public struct EngineRenderStats { self.drawCallsBatched = drawCallsBatched self.trianglesTotal = trianglesTotal self.visibleInstances = visibleInstances + self.tileFullVisibleInstances = tileFullVisibleInstances + self.tileLODVisibleInstances = tileLODVisibleInstances + self.tileHLODVisibleInstances = tileHLODVisibleInstances + self.tileFullDrawsEstimate = tileFullDrawsEstimate + self.tileLODDrawsEstimate = tileLODDrawsEstimate + self.tileHLODDrawsEstimate = tileHLODDrawsEstimate + self.tileFullTrianglesEstimate = tileFullTrianglesEstimate + self.tileLODTrianglesEstimate = tileLODTrianglesEstimate + self.tileHLODTrianglesEstimate = tileHLODTrianglesEstimate } } @@ -155,6 +182,22 @@ public struct EngineStreamingStats { public var lastApplyLoadedMeshMs: Double = 0.0 public var tileSwapWarnings: Int = 0 public var tilesSkippedByHierarchyGate: Int = 0 + public var tileRepresentationGapWarnings: Int = 0 + public var lod0VisibilityWarnings: Int = 0 + public var lod0VisibilityWarningsWithFallback: Int = 0 + public var lod0VisibilityWarningsNoFallback: Int = 0 + public var residentFullTileRepresentations: Int = 0 + public var residentLODRepresentations: Int = 0 + public var residentHLODRepresentations: Int = 0 + public var visibleFullTileRepresentations: Int = 0 + public var visibleLODRepresentations: Int = 0 + public var visibleHLODRepresentations: Int = 0 + public var fullAndLODVisibleOverlapTiles: Int = 0 + public var fullAndHLODVisibleOverlapTiles: Int = 0 + public var lodAndHLODVisibleOverlapTiles: Int = 0 + public var fullAndFallbackResidentOverlapTiles: Int = 0 + public var activeTileRepresentationFades: Int = 0 + public var waitingTileRepresentationFades: Int = 0 public init( activeLoads: Int = 0, @@ -174,7 +217,24 @@ public struct EngineStreamingStats { evictionsPerformed: Int = 0, averageAsyncLoadMs: Double = 0.0, lastApplyLoadedMeshMs: Double = 0.0, - tileSwapWarnings: Int = 0 + tileSwapWarnings: Int = 0, + tilesSkippedByHierarchyGate: Int = 0, + tileRepresentationGapWarnings: Int = 0, + lod0VisibilityWarnings: Int = 0, + lod0VisibilityWarningsWithFallback: Int = 0, + lod0VisibilityWarningsNoFallback: Int = 0, + residentFullTileRepresentations: Int = 0, + residentLODRepresentations: Int = 0, + residentHLODRepresentations: Int = 0, + visibleFullTileRepresentations: Int = 0, + visibleLODRepresentations: Int = 0, + visibleHLODRepresentations: Int = 0, + fullAndLODVisibleOverlapTiles: Int = 0, + fullAndHLODVisibleOverlapTiles: Int = 0, + lodAndHLODVisibleOverlapTiles: Int = 0, + fullAndFallbackResidentOverlapTiles: Int = 0, + activeTileRepresentationFades: Int = 0, + waitingTileRepresentationFades: Int = 0 ) { self.activeLoads = activeLoads self.loadCandidates = loadCandidates @@ -194,6 +254,23 @@ public struct EngineStreamingStats { self.averageAsyncLoadMs = averageAsyncLoadMs self.lastApplyLoadedMeshMs = lastApplyLoadedMeshMs self.tileSwapWarnings = tileSwapWarnings + self.tilesSkippedByHierarchyGate = tilesSkippedByHierarchyGate + self.tileRepresentationGapWarnings = tileRepresentationGapWarnings + self.lod0VisibilityWarnings = lod0VisibilityWarnings + self.lod0VisibilityWarningsWithFallback = lod0VisibilityWarningsWithFallback + self.lod0VisibilityWarningsNoFallback = lod0VisibilityWarningsNoFallback + self.residentFullTileRepresentations = residentFullTileRepresentations + self.residentLODRepresentations = residentLODRepresentations + self.residentHLODRepresentations = residentHLODRepresentations + self.visibleFullTileRepresentations = visibleFullTileRepresentations + self.visibleLODRepresentations = visibleLODRepresentations + self.visibleHLODRepresentations = visibleHLODRepresentations + self.fullAndLODVisibleOverlapTiles = fullAndLODVisibleOverlapTiles + self.fullAndHLODVisibleOverlapTiles = fullAndHLODVisibleOverlapTiles + self.lodAndHLODVisibleOverlapTiles = lodAndHLODVisibleOverlapTiles + self.fullAndFallbackResidentOverlapTiles = fullAndFallbackResidentOverlapTiles + self.activeTileRepresentationFades = activeTileRepresentationFades + self.waitingTileRepresentationFades = waitingTileRepresentationFades } } diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index b00d5d83..282c03db 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -57,6 +57,12 @@ public enum RenderPasses { let vertexCount: Int } + private struct LODDitherDraw { + let meshes: [Mesh] + let threshold: Float + let mode: Float + } + private final class RuntimeState: @unchecked Sendable { let lock = NSLock() var transparencyXRDepthWriteState: MTLDepthStencilState? @@ -279,6 +285,63 @@ public enum RenderPasses { return opacity } + @inline(__always) + private static func isEntityInActiveLODFade(_ entityId: EntityID) -> Bool { + guard LODConfig.shared.enableFadeTransitions, + let lod = scene.get(component: LODComponent.self, for: entityId) + else { return false } + return lod.previousLOD != nil + } + + @inline(__always) + private static func isEntityInActiveTileRepresentationFade(_ entityId: EntityID) -> Bool { + scene.get(component: TileRepresentationFadeComponent.self, for: entityId) != nil + } + + private static func opaqueLODDraws(entityId: EntityID, renderComponent: RenderComponent) -> [LODDitherDraw] { + guard LODConfig.shared.enableFadeTransitions, + let lod = scene.get(component: LODComponent.self, for: entityId), + let previousLOD = lod.previousLOD, + previousLOD >= 0, + previousLOD < lod.lodLevels.count + else { + return [LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0)] + } + + let previousMeshes = lod.lodLevels[previousLOD].mesh + guard !previousMeshes.isEmpty else { + return [LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0)] + } + + let threshold = simd_clamp(lod.transitionProgress, 0.0, 1.0) + return [ + LODDitherDraw(meshes: previousMeshes, threshold: threshold, mode: 2.0), + LODDitherDraw(meshes: renderComponent.mesh, threshold: threshold, mode: 1.0), + ] + } + + @inline(__always) + private static func applyLODDither( + draw: LODDitherDraw, + materialParameters: inout MaterialParametersUniform + ) { + materialParameters.lodDither = simd_float4(draw.threshold, draw.mode, 0.0, 0.0) + } + + @inline(__always) + private static func applyTileRepresentationDither( + entityId: EntityID, + materialParameters: inout MaterialParametersUniform + ) { + guard let fade = scene.get(component: TileRepresentationFadeComponent.self, for: entityId) else { return } + materialParameters.lodDither = simd_float4( + simd_clamp(fade.progress, 0.0, 1.0), + fade.mode, + 0.0, + 0.0 + ) + } + @inline(__always) private static func extractLODIndex(from batchKey: String) -> Int? { guard let markerRange = batchKey.range(of: "_LOD", options: .backwards) else { @@ -1167,7 +1230,11 @@ public enum RenderPasses { if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } // Skip batched entities if batching is enabled - if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { + if BatchingSystem.shared.isEnabled(), + BatchingSystem.shared.isBatched(entityId: entityId), + !isEntityInActiveLODFade(entityId), + !isEntityInActiveTileRepresentationFade(entityId) + { continue } @@ -1197,173 +1264,177 @@ public enum RenderPasses { continue } - for mesh in renderComponent.mesh { - // update uniforms - var modelUniforms = Uniforms() - - let rootMatrix = worldTransformComponent.space - var modelMatrix = simd_mul(rootMatrix, mesh.localSpace) - - let viewMatrix: simd_float4x4 = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - - let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) + for lodDraw in opaqueLODDraws(entityId: entityId, renderComponent: renderComponent) { + for mesh in lodDraw.meshes { + // update uniforms + var modelUniforms = Uniforms() - let upperModelMatrix: matrix_float3x3 = matrix3x3_upper_left(modelMatrix) + let rootMatrix = worldTransformComponent.space + var modelMatrix = simd_mul(rootMatrix, mesh.localSpace) - let inverseUpperModelMatrix: matrix_float3x3 = upperModelMatrix.inverse + let viewMatrix: simd_float4x4 = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let normalMatrix: matrix_float3x3 = inverseUpperModelMatrix.transpose + let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) - modelUniforms.modelViewMatrix = modelViewMatrix + let upperModelMatrix: matrix_float3x3 = matrix3x3_upper_left(modelMatrix) - modelUniforms.normalMatrix = normalMatrix + let inverseUpperModelMatrix: matrix_float3x3 = upperModelMatrix.inverse - modelUniforms.viewMatrix = viewMatrix + let normalMatrix: matrix_float3x3 = inverseUpperModelMatrix.transpose - modelUniforms.modelMatrix = modelMatrix + modelUniforms.modelViewMatrix = modelViewMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + modelUniforms.normalMatrix = normalMatrix - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.viewMatrix = viewMatrix - renderEncoder.setVertexBytes( - &modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue) - ) + modelUniforms.modelMatrix = modelMatrix - // Only enable armature path when a valid joint transform buffer exists. - let jointTransformBuffer = mesh.skin?.jointTransformsBuffer - var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil + modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) - renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue)) + modelUniforms.projectionMatrix = renderInfo.perspectiveSpace - renderEncoder.setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, - offset: 0, index: Int(modelPassVerticesIndex.rawValue) - ) + renderEncoder.setVertexBytes( + &modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue) + ) - renderEncoder.setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, - offset: 0, index: Int(modelPassNormalIndex.rawValue) - ) + // Only enable armature path when a valid joint transform buffer exists. + let jointTransformBuffer = mesh.skin?.jointTransformsBuffer + var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil - renderEncoder.setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, - index: Int(modelPassUVIndex.rawValue) - ) + renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue)) - renderEncoder.setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, - offset: 0, index: Int(modelPassTangentIndex.rawValue) - ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, + offset: 0, index: Int(modelPassVerticesIndex.rawValue) + ) - renderEncoder.setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, - offset: 0, index: Int(modelPassJointIdIndex.rawValue) - ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, + offset: 0, index: Int(modelPassNormalIndex.rawValue) + ) - renderEncoder.setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, - offset: 0, index: Int(modelPassJointWeightsIndex.rawValue) - ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, + index: Int(modelPassUVIndex.rawValue) + ) - if let jointTransformBuffer { - renderEncoder.setVertexBuffer(jointTransformBuffer, offset: 0, index: Int(modelPassJointTransformIndex.rawValue)) - } else { - var identityMatrix = matrix_identity_float4x4 - renderEncoder.setVertexBytes(&identityMatrix, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue)) - } + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, + offset: 0, index: Int(modelPassTangentIndex.rawValue) + ) - renderEncoder.setFragmentBytes( - &modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue) - ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, + offset: 0, index: Int(modelPassJointIdIndex.rawValue) + ) - for subMesh in mesh.submeshes { - guard let material = subMesh.material else { continue } + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, + offset: 0, index: Int(modelPassJointWeightsIndex.rawValue) + ) - // Blend-mode submeshes are rendered in the transparency pass. - if material.alphaMode == .blend { - continue + if let jointTransformBuffer { + renderEncoder.setVertexBuffer(jointTransformBuffer, offset: 0, index: Int(modelPassJointTransformIndex.rawValue)) + } else { + var identityMatrix = matrix_identity_float4x4 + renderEncoder.setVertexBytes(&identityMatrix, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue)) } - var stScale: Float = material.stScale + renderEncoder.setFragmentBytes( + &modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue) + ) - renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) + for subMesh in mesh.submeshes { + guard let material = subMesh.material else { continue } - // set base texture - renderEncoder.setFragmentTexture( - material.baseColor.texture, index: Int(modelPassBaseTextureIndex.rawValue) - ) + // Blend-mode submeshes are rendered in the transparency pass. + if material.alphaMode == .blend { + continue + } - renderEncoder.setFragmentSamplerState(material.baseColor.sampler, index: Int(modelPassBaseSamplerIndex.rawValue)) + var stScale: Float = material.stScale - // set roughness - renderEncoder.setFragmentTexture( - material.roughness.texture, index: Int(modelPassRoughnessTextureIndex.rawValue) - ) + renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) - renderEncoder.setFragmentSamplerState(material.roughness.sampler, index: Int(modelPassMaterialSamplerIndex.rawValue)) + // set base texture + renderEncoder.setFragmentTexture( + material.baseColor.texture, index: Int(modelPassBaseTextureIndex.rawValue) + ) - // set metallic - renderEncoder.setFragmentTexture( - material.metallic.texture, index: Int(modelPassMetallicTextureIndex.rawValue) - ) + renderEncoder.setFragmentSamplerState(material.baseColor.sampler, index: Int(modelPassBaseSamplerIndex.rawValue)) - // set normal - // set normal - var hasNormal: Bool = (material.normal.texture != nil) - renderEncoder.setFragmentBytes( - &hasNormal, length: MemoryLayout.stride, - index: Int(modelPassFragmentHasNormalTextureIndex.rawValue) - ) + // set roughness + renderEncoder.setFragmentTexture( + material.roughness.texture, index: Int(modelPassRoughnessTextureIndex.rawValue) + ) - var materialParameters = MaterialParametersUniform() - materialParameters.specular = material.specular - materialParameters.specularTint = material.specularTint - materialParameters.subsurface = material.subsurface - materialParameters.anisotropic = material.anisotropic - materialParameters.sheen = material.sheen - materialParameters.sheenTint = material.sheenTint - materialParameters.clearCoat = material.clearCoat - materialParameters.clearCoatGloss = material.clearCoatGloss - materialParameters.baseColor = material.baseColorValue - materialParameters.roughness = material.roughnessValue - materialParameters.metallic = material.metallicValue - materialParameters.ior = material.ior - materialParameters.edgeTint = material.edgeTint - materialParameters.alphaCutoff = material.alphaCutoff - materialParameters.passthroughAlpha = passthroughGhostAlpha(for: entityId) - materialParameters.alphaMode = Int32(material.alphaMode.rawValue) - materialParameters.interactWithLight = material.interactWithLight - materialParameters.emmissive = material.emissiveValue + renderEncoder.setFragmentSamplerState(material.roughness.sampler, index: Int(modelPassMaterialSamplerIndex.rawValue)) - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) - applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) - applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + // set metallic + renderEncoder.setFragmentTexture( + material.metallic.texture, index: Int(modelPassMetallicTextureIndex.rawValue) + ) - renderEncoder.setFragmentBytes( - &materialParameters, length: MemoryLayout.stride, - index: Int(modelPassFragmentMaterialParameterIndex.rawValue) - ) + // set normal + // set normal + var hasNormal: Bool = (material.normal.texture != nil) + renderEncoder.setFragmentBytes( + &hasNormal, length: MemoryLayout.stride, + index: Int(modelPassFragmentHasNormalTextureIndex.rawValue) + ) - renderEncoder.setFragmentTexture( - material.normal.texture, index: Int(modelPassNormalTextureIndex.rawValue) - ) + var materialParameters = MaterialParametersUniform() + materialParameters.specular = material.specular + materialParameters.specularTint = material.specularTint + materialParameters.subsurface = material.subsurface + materialParameters.anisotropic = material.anisotropic + materialParameters.sheen = material.sheen + materialParameters.sheenTint = material.sheenTint + materialParameters.clearCoat = material.clearCoat + materialParameters.clearCoatGloss = material.clearCoatGloss + materialParameters.baseColor = material.baseColorValue + materialParameters.roughness = material.roughnessValue + materialParameters.metallic = material.metallicValue + materialParameters.ior = material.ior + materialParameters.edgeTint = material.edgeTint + materialParameters.alphaCutoff = material.alphaCutoff + materialParameters.passthroughAlpha = passthroughGhostAlpha(for: entityId) + materialParameters.alphaMode = Int32(material.alphaMode.rawValue) + materialParameters.interactWithLight = material.interactWithLight + materialParameters.emmissive = material.emissiveValue + + materialParameters.hasTexture = simd_int4( + Int32(material.hasBaseMap ? 1 : 0), + Int32(material.hasRoughMap ? 1 : 0), + Int32(material.hasMetalMap ? 1 : 0), + 0 + ) + applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + applyLODDither(draw: lodDraw, materialParameters: &materialParameters) + applyTileRepresentationDither(entityId: entityId, materialParameters: &materialParameters) + + renderEncoder.setFragmentBytes( + &materialParameters, length: MemoryLayout.stride, + index: Int(modelPassFragmentMaterialParameterIndex.rawValue) + ) - renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture( + material.normal.texture, index: Int(modelPassNormalTextureIndex.rawValue) + ) - renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, - category: .opaque - ) + renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) + + renderEncoder.drawIndexedPrimitivesTracked( + type: subMesh.metalKitSubmesh.primitiveType, + indexCount: subMesh.metalKitSubmesh.indexCount, + indexType: subMesh.metalKitSubmesh.indexType, + indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, + indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + category: .opaque + ) + } } } } @@ -1661,7 +1732,11 @@ public enum RenderPasses { if scene.mask(for: entityId) == nil { continue } if shouldHideSceneEntity(entityId: entityId) { continue } if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } - if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { continue } + if BatchingSystem.shared.isEnabled(), + BatchingSystem.shared.isBatched(entityId: entityId), + !isEntityInActiveLODFade(entityId), + !isEntityInActiveTileRepresentationFade(entityId) + { continue } if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { continue } @@ -1671,96 +1746,100 @@ public enum RenderPasses { guard let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { continue } guard scene.get(component: LocalTransformComponent.self, for: entityId) != nil else { continue } - for mesh in renderComponent.mesh { - var modelUniforms = Uniforms() - let modelMatrix = simd_mul(worldTransformComponent.space, mesh.localSpace) - let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) - let normalMatrix = matrix3x3_upper_left(modelMatrix).inverse.transpose - - modelUniforms.modelViewMatrix = modelViewMatrix - modelUniforms.normalMatrix = normalMatrix - modelUniforms.viewMatrix = viewMatrix - modelUniforms.modelMatrix = modelMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace - - renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue)) - - let jointTransformBuffer = mesh.skin?.jointTransformsBuffer - var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil - renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue)) + for lodDraw in opaqueLODDraws(entityId: entityId, renderComponent: renderComponent) { + for mesh in lodDraw.meshes { + var modelUniforms = Uniforms() + let modelMatrix = simd_mul(worldTransformComponent.space, mesh.localSpace) + let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) + let normalMatrix = matrix3x3_upper_left(modelMatrix).inverse.transpose - renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, offset: 0, index: Int(modelPassVerticesIndex.rawValue)) - renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, offset: 0, index: Int(modelPassNormalIndex.rawValue)) - renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, index: Int(modelPassUVIndex.rawValue)) - renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, offset: 0, index: Int(modelPassTangentIndex.rawValue)) - renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointIdIndex.rawValue)) - renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointWeightsIndex.rawValue)) + modelUniforms.modelViewMatrix = modelViewMatrix + modelUniforms.normalMatrix = normalMatrix + modelUniforms.viewMatrix = viewMatrix + modelUniforms.modelMatrix = modelMatrix + modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + modelUniforms.projectionMatrix = renderInfo.perspectiveSpace - if let jtb = jointTransformBuffer { - renderEncoder.setVertexBuffer(jtb, offset: 0, index: Int(modelPassJointTransformIndex.rawValue)) - } else { - var identity = matrix_identity_float4x4 - renderEncoder.setVertexBytes(&identity, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue)) - } + renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue)) - renderEncoder.setFragmentBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue)) + let jointTransformBuffer = mesh.skin?.jointTransformsBuffer + var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil + renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue)) - for subMesh in mesh.submeshes { - guard let material = subMesh.material else { continue } - if material.alphaMode == .blend { continue } + renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, offset: 0, index: Int(modelPassVerticesIndex.rawValue)) + renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, offset: 0, index: Int(modelPassNormalIndex.rawValue)) + renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, index: Int(modelPassUVIndex.rawValue)) + renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, offset: 0, index: Int(modelPassTangentIndex.rawValue)) + renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointIdIndex.rawValue)) + renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointWeightsIndex.rawValue)) - var stScale: Float = material.stScale - renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) - renderEncoder.setFragmentTexture(material.baseColor.texture, index: Int(modelPassBaseTextureIndex.rawValue)) - renderEncoder.setFragmentSamplerState(material.baseColor.sampler, index: Int(modelPassBaseSamplerIndex.rawValue)) - renderEncoder.setFragmentTexture(material.roughness.texture, index: Int(modelPassRoughnessTextureIndex.rawValue)) - renderEncoder.setFragmentSamplerState(material.roughness.sampler, index: Int(modelPassMaterialSamplerIndex.rawValue)) - renderEncoder.setFragmentTexture(material.metallic.texture, index: Int(modelPassMetallicTextureIndex.rawValue)) + if let jtb = jointTransformBuffer { + renderEncoder.setVertexBuffer(jtb, offset: 0, index: Int(modelPassJointTransformIndex.rawValue)) + } else { + var identity = matrix_identity_float4x4 + renderEncoder.setVertexBytes(&identity, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue)) + } - var hasNormal = (material.normal.texture != nil) - renderEncoder.setFragmentBytes(&hasNormal, length: MemoryLayout.stride, index: Int(modelPassFragmentHasNormalTextureIndex.rawValue)) + renderEncoder.setFragmentBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue)) - var materialParameters = MaterialParametersUniform() - materialParameters.specular = material.specular - materialParameters.specularTint = material.specularTint - materialParameters.subsurface = material.subsurface - materialParameters.anisotropic = material.anisotropic - materialParameters.sheen = material.sheen - materialParameters.sheenTint = material.sheenTint - materialParameters.clearCoat = material.clearCoat - materialParameters.clearCoatGloss = material.clearCoatGloss - materialParameters.baseColor = material.baseColorValue - materialParameters.roughness = material.roughnessValue - materialParameters.metallic = material.metallicValue - materialParameters.ior = material.ior - materialParameters.edgeTint = material.edgeTint - materialParameters.alphaCutoff = material.alphaCutoff - materialParameters.passthroughAlpha = passthroughGhostAlpha(for: entityId) - materialParameters.alphaMode = Int32(material.alphaMode.rawValue) - materialParameters.interactWithLight = material.interactWithLight - materialParameters.emmissive = material.emissiveValue - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) - applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) - applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + for subMesh in mesh.submeshes { + guard let material = subMesh.material else { continue } + if material.alphaMode == .blend { continue } + + var stScale: Float = material.stScale + renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) + renderEncoder.setFragmentTexture(material.baseColor.texture, index: Int(modelPassBaseTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(material.baseColor.sampler, index: Int(modelPassBaseSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(material.roughness.texture, index: Int(modelPassRoughnessTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(material.roughness.sampler, index: Int(modelPassMaterialSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(material.metallic.texture, index: Int(modelPassMetallicTextureIndex.rawValue)) + + var hasNormal = (material.normal.texture != nil) + renderEncoder.setFragmentBytes(&hasNormal, length: MemoryLayout.stride, index: Int(modelPassFragmentHasNormalTextureIndex.rawValue)) + + var materialParameters = MaterialParametersUniform() + materialParameters.specular = material.specular + materialParameters.specularTint = material.specularTint + materialParameters.subsurface = material.subsurface + materialParameters.anisotropic = material.anisotropic + materialParameters.sheen = material.sheen + materialParameters.sheenTint = material.sheenTint + materialParameters.clearCoat = material.clearCoat + materialParameters.clearCoatGloss = material.clearCoatGloss + materialParameters.baseColor = material.baseColorValue + materialParameters.roughness = material.roughnessValue + materialParameters.metallic = material.metallicValue + materialParameters.ior = material.ior + materialParameters.edgeTint = material.edgeTint + materialParameters.alphaCutoff = material.alphaCutoff + materialParameters.passthroughAlpha = passthroughGhostAlpha(for: entityId) + materialParameters.alphaMode = Int32(material.alphaMode.rawValue) + materialParameters.interactWithLight = material.interactWithLight + materialParameters.emmissive = material.emissiveValue + materialParameters.hasTexture = simd_int4( + Int32(material.hasBaseMap ? 1 : 0), + Int32(material.hasRoughMap ? 1 : 0), + Int32(material.hasMetalMap ? 1 : 0), + 0 + ) + applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + applyLODDither(draw: lodDraw, materialParameters: &materialParameters) + applyTileRepresentationDither(entityId: entityId, materialParameters: &materialParameters) - renderEncoder.setFragmentBytes(&materialParameters, length: MemoryLayout.stride, index: Int(modelPassFragmentMaterialParameterIndex.rawValue)) - renderEncoder.setFragmentTexture(material.normal.texture, index: Int(modelPassNormalTextureIndex.rawValue)) - renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) + renderEncoder.setFragmentBytes(&materialParameters, length: MemoryLayout.stride, index: Int(modelPassFragmentMaterialParameterIndex.rawValue)) + renderEncoder.setFragmentTexture(material.normal.texture, index: Int(modelPassNormalTextureIndex.rawValue)) + renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) - renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, - category: .opaque - ) + renderEncoder.drawIndexedPrimitivesTracked( + type: subMesh.metalKitSubmesh.primitiveType, + indexCount: subMesh.metalKitSubmesh.indexCount, + indexType: subMesh.metalKitSubmesh.indexType, + indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, + indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + category: .opaque + ) + } } } } diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 8d1437dd..77050f1e 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -242,6 +242,18 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } #if ENGINE_STATS_ENABLED + private struct TileRenderCostSummary { + var fullVisibleInstances: Int = 0 + var lodVisibleInstances: Int = 0 + var hlodVisibleInstances: Int = 0 + var fullDrawsEstimate: Int = 0 + var lodDrawsEstimate: Int = 0 + var hlodDrawsEstimate: Int = 0 + var fullTrianglesEstimate: Int = 0 + var lodTrianglesEstimate: Int = 0 + var hlodTrianglesEstimate: Int = 0 + } + private func publishEngineStats(frameStartTime: Double) { let frameTotalMs: Double if let dt = timeSinceLastUpdate as Float?, dt > 0 { @@ -260,6 +272,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { let gateBlockedMs = AssetLoadingGate.shared.consumeBlockedMsSinceLastSample() let gateActiveLoads = AssetLoadingGate.shared.activeLoadCount let drawStats = RenderStatsCollector.shared.snapshot() + let tileRenderCosts = auditVisibleTileRenderCosts() let memStats = MemoryBudgetManager.shared.getStats() EngineStatsMonitor.shared.update { snapshot in @@ -272,6 +285,15 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { snapshot.render.drawCallsBatched = drawStats.drawCallsBatched snapshot.render.trianglesTotal = drawStats.trianglesTotal snapshot.render.visibleInstances = max(visibleEntityIds.count, hzbStats.visibleAfterOcclusionCount) + snapshot.render.tileFullVisibleInstances = tileRenderCosts.fullVisibleInstances + snapshot.render.tileLODVisibleInstances = tileRenderCosts.lodVisibleInstances + snapshot.render.tileHLODVisibleInstances = tileRenderCosts.hlodVisibleInstances + snapshot.render.tileFullDrawsEstimate = tileRenderCosts.fullDrawsEstimate + snapshot.render.tileLODDrawsEstimate = tileRenderCosts.lodDrawsEstimate + snapshot.render.tileHLODDrawsEstimate = tileRenderCosts.hlodDrawsEstimate + snapshot.render.tileFullTrianglesEstimate = tileRenderCosts.fullTrianglesEstimate + snapshot.render.tileLODTrianglesEstimate = tileRenderCosts.lodTrianglesEstimate + snapshot.render.tileHLODTrianglesEstimate = tileRenderCosts.hlodTrianglesEstimate snapshot.culling.frustumTested = hzbStats.frustumTestedCount snapshot.culling.frustumPassed = hzbStats.frustumCandidateCount @@ -311,6 +333,22 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { snapshot.streaming.lastApplyLoadedMeshMs = streamingDiag.lastApplyLoadedMeshMs snapshot.streaming.tileSwapWarnings = streamingDiag.tileSwapWarnings snapshot.streaming.tilesSkippedByHierarchyGate = streamingDiag.tilesSkippedByHierarchyGate + snapshot.streaming.tileRepresentationGapWarnings = streamingDiag.tileRepresentationGapWarnings + snapshot.streaming.lod0VisibilityWarnings = streamingDiag.lod0VisibilityWarnings + snapshot.streaming.lod0VisibilityWarningsWithFallback = streamingDiag.lod0VisibilityWarningsWithFallback + snapshot.streaming.lod0VisibilityWarningsNoFallback = streamingDiag.lod0VisibilityWarningsNoFallback + snapshot.streaming.residentFullTileRepresentations = streamingDiag.residentFullTileRepresentations + snapshot.streaming.residentLODRepresentations = streamingDiag.residentLODRepresentations + snapshot.streaming.residentHLODRepresentations = streamingDiag.residentHLODRepresentations + snapshot.streaming.visibleFullTileRepresentations = streamingDiag.visibleFullTileRepresentations + snapshot.streaming.visibleLODRepresentations = streamingDiag.visibleLODRepresentations + snapshot.streaming.visibleHLODRepresentations = streamingDiag.visibleHLODRepresentations + snapshot.streaming.fullAndLODVisibleOverlapTiles = streamingDiag.fullAndLODVisibleOverlapTiles + snapshot.streaming.fullAndHLODVisibleOverlapTiles = streamingDiag.fullAndHLODVisibleOverlapTiles + snapshot.streaming.lodAndHLODVisibleOverlapTiles = streamingDiag.lodAndHLODVisibleOverlapTiles + snapshot.streaming.fullAndFallbackResidentOverlapTiles = streamingDiag.fullAndFallbackResidentOverlapTiles + snapshot.streaming.activeTileRepresentationFades = streamingDiag.activeTileRepresentationFades + snapshot.streaming.waitingTileRepresentationFades = streamingDiag.waitingTileRepresentationFades snapshot.batching.batchGroupCount = batchGroups.count snapshot.batching.batchedMeshCount = batchedMeshCount @@ -333,6 +371,62 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } EngineStatsMonitor.shared.completeFrame() } + + private func auditVisibleTileRenderCosts() -> TileRenderCostSummary { + var summary = TileRenderCostSummary() + let loadedFullTiles = Set(GeometryStreamingSystem.shared.loadedTileEntitiesSnapshot()) + + for entityId in visibleEntityIds { + guard scene.exists(entityId), + let render = scene.get(component: RenderComponent.self, for: entityId) + else { continue } + + let cost = tileRenderCost(for: render) + if let tag = scene.get(component: TileLODTagComponent.self, for: entityId) { + if tag.levelIndex == 5 { + summary.hlodVisibleInstances += 1 + summary.hlodDrawsEstimate += cost.draws + summary.hlodTrianglesEstimate += cost.triangles + } else { + summary.lodVisibleInstances += 1 + summary.lodDrawsEstimate += cost.draws + summary.lodTrianglesEstimate += cost.triangles + } + } else if visibleEntityHasLoadedFullTileAncestor(entityId, loadedFullTiles: loadedFullTiles) { + summary.fullVisibleInstances += 1 + summary.fullDrawsEstimate += cost.draws + summary.fullTrianglesEstimate += cost.triangles + } + } + + return summary + } + + private func visibleEntityHasLoadedFullTileAncestor(_ entityId: EntityID, loadedFullTiles: Set) -> Bool { + var current = getEntityParent(entityId: entityId) + while let parent = current { + if loadedFullTiles.contains(parent) { return true } + current = getEntityParent(entityId: parent) + } + return false + } + + private func tileRenderCost(for render: RenderComponent) -> (draws: Int, triangles: Int) { + var draws = 0 + var triangles = 0 + + for mesh in render.mesh { + for submesh in mesh.submeshes { + guard let material = submesh.material else { continue } + if material.alphaMode == .blend { continue } + + draws += 1 + triangles += max(0, submesh.metalKitSubmesh.indexCount / 3) + } + } + + return (draws, triangles) + } #endif private func tickFrameMonitors() { diff --git a/Sources/UntoldEngine/Shaders/modelShader.metal b/Sources/UntoldEngine/Shaders/modelShader.metal index 8d2054f4..1d0a82e6 100644 --- a/Sources/UntoldEngine/Shaders/modelShader.metal +++ b/Sources/UntoldEngine/Shaders/modelShader.metal @@ -15,6 +15,23 @@ using namespace metal; +constant ushort lodBayer8x8[64] = { + 0, 48, 12, 60, 3, 51, 15, 63, + 32, 16, 44, 28, 35, 19, 47, 31, + 8, 56, 4, 52, 11, 59, 7, 55, + 40, 24, 36, 20, 43, 27, 39, 23, + 2, 50, 14, 62, 1, 49, 13, 61, + 34, 18, 46, 30, 33, 17, 45, 29, + 10, 58, 6, 54, 9, 57, 5, 53, + 42, 26, 38, 22, 41, 25, 37, 21, +}; + +static inline float lodBayerThreshold(float2 position) { + uint2 pixel = uint2(floor(position)) & uint2(7); + uint index = pixel.y * 8u + pixel.x; + return (float(lodBayer8x8[index]) + 0.5) / 64.0; +} + vertex VertexOutModel vertexModelShader( VertexInModel in [[stage_in]], constant Uniforms &uniforms [[buffer(modelPassUniformIndex)]], @@ -123,6 +140,20 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], discard_fragment(); } + float lodDitherMode = materialParameter.lodDither.y; + if (lodDitherMode > 0.5) { + float threshold = clamp(materialParameter.lodDither.x, 0.0, 1.0); + float dither = lodBayerThreshold(in.position.xy); + + if (lodDitherMode < 1.5) { + if (dither >= threshold) { + discard_fragment(); + } + } else if (dither < threshold) { + discard_fragment(); + } + } + float passthroughAlpha = clamp(materialParameter.passthroughAlpha, 0.0, 1.0); //normal map is in Tangent space diff --git a/Sources/UntoldEngine/Systems/AssetLoadingState.swift b/Sources/UntoldEngine/Systems/AssetLoadingState.swift index 7cb9dfb5..8ec5cab4 100644 --- a/Sources/UntoldEngine/Systems/AssetLoadingState.swift +++ b/Sources/UntoldEngine/Systems/AssetLoadingState.swift @@ -144,6 +144,7 @@ public struct LoadingProgress { public let currentMesh: Int public let totalMeshes: Int public var phase: LoadingPhase + public let blocksRenderLoop: Bool public var percentage: Float { guard totalMeshes > 0 else { return 0 } @@ -167,8 +168,15 @@ public actor AssetLoadingState { private init() {} /// Start tracking loading for an entity - public func startLoading(entityId: EntityID, filename: String, totalMeshes: Int = 0) { - if loadingEntities[entityId] == nil { + public func startLoading(entityId: EntityID, filename: String, totalMeshes: Int = 0, blockRenderLoop: Bool = true) { + let existing = loadingEntities[entityId] + let effectiveBlockRenderLoop = existing?.blocksRenderLoop == true || blockRenderLoop + + if existing == nil { + if blockRenderLoop { + AssetLoadingGate.shared.beginLoading() + } + } else if existing?.blocksRenderLoop == false, blockRenderLoop { AssetLoadingGate.shared.beginLoading() } @@ -177,7 +185,8 @@ public actor AssetLoadingState { filename: filename, currentMesh: 0, totalMeshes: totalMeshes, - phase: .loading + phase: .loading, + blocksRenderLoop: effectiveBlockRenderLoop ) } @@ -189,13 +198,16 @@ public actor AssetLoadingState { filename: existing.filename, currentMesh: currentMesh, totalMeshes: totalMeshes, - phase: phase ?? existing.phase + phase: phase ?? existing.phase, + blocksRenderLoop: existing.blocksRenderLoop ) } /// Mark entity as finished loading public func finishLoading(entityId: EntityID) { - if loadingEntities.removeValue(forKey: entityId) != nil { + if let progress = loadingEntities.removeValue(forKey: entityId), + progress.blocksRenderLoop + { AssetLoadingGate.shared.finishLoading() } } diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index ef49797f..880b5aab 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -711,6 +711,37 @@ public class BatchingSystem: @unchecked Sendable { pendingEntityRemovals.formUnion(entityIds) } + /// Removes fading tile representation entities from active/pending batches so + /// the renderer can draw them with per-entity dither uniforms. + public func notifyTileEntitiesFading(_ entityIds: Set) { + guard !entityIds.isEmpty else { return } + + pendingEntityAdditions.subtract(entityIds) + newlyResidentEntities.subtract(entityIds) + tileParsedEntityIds.subtract(entityIds) + if pendingTileResidentQueueHead < pendingTileResidentQueue.count { + let tail = pendingTileResidentQueue[pendingTileResidentQueueHead...] + pendingTileResidentQueue = tail.filter { !entityIds.contains($0) } + } else { + pendingTileResidentQueue.removeAll(keepingCapacity: true) + } + pendingTileResidentQueueHead = 0 + + var affectedCells: Set = [] + for entityId in entityIds { + if let cellId = entityToCellMembership[entityId] ?? resolveCellIdForEntity(entityId: entityId) { + affectedCells.insert(cellId) + } + pendingEntityRemovals.insert(entityId) + } + + for cellId in affectedCells { + _ = removeBatchesForCell(cellId, queueForRetirement: false) + dirtyCells.insert(cellId) + setCellState(cellId, .renderableUnbatched) + } + } + /// Compact one-line summary for periodic heartbeat logging. /// Reports the fields most likely to reveal accumulation bugs: /// registered entity count, dirty cells, and last rebuild cost. @@ -732,12 +763,19 @@ public class BatchingSystem: @unchecked Sendable { // The premature dirtyCells.insert is also omitted: removeEntityFromBatchingTracking // calls markCellDirtyForFallback during the tick which inserts the same cell — // the early insert only caused a redundant estimateCellWork() call on the same tick. - if entityToCellMembership[event.entityId] != nil { + let isActiveLODFade = scene.get(component: LODComponent.self, for: event.entityId)?.previousLOD != nil + if let cellId = entityToCellMembership[event.entityId] { + if isActiveLODFade { + _ = removeBatchesForCell(cellId, queueForRetirement: false) + } pendingEntityRemovals.insert(event.entityId) } - // Always re-queue for addition so the entity rebatches under its new LOD key. - pendingEntityAdditions.insert(event.entityId) + // Active entity LOD fades need per-entity uniforms. LODSystem emits a + // same-LOD completion event after the fade clears, which requeues batching. + if !isActiveLODFade { + pendingEntityAdditions.insert(event.entityId) + } } private func handleResidencyChange(_ event: AssetResidencyChangedEvent) { @@ -1597,6 +1635,14 @@ public class BatchingSystem: @unchecked Sendable { // Skip entities with empty meshes (not yet loaded by streaming) if renderComponent.mesh.isEmpty { return nil } + if scene.get(component: LODComponent.self, for: entityId)?.previousLOD != nil { + return nil + } + + if scene.get(component: TileRepresentationFadeComponent.self, for: entityId) != nil { + return nil + } + // Identity-preserved streamed objects must stay individually renderable/selectable. if shouldPreserveSceneEntityIdentity(entityId: entityId) { return nil } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift index e799bd90..46036959 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift @@ -25,6 +25,192 @@ extension GeometryStreamingSystem { /// // MARK: - HLOD Load / Unload + func advanceTileRepresentationFades(deltaTime: Float) { + guard !activeTileRepresentationFades.isEmpty else { return } + + var remaining: [ActiveTileRepresentationFade] = [] + var completed: [ActiveTileRepresentationFade] = [] + remaining.reserveCapacity(activeTileRepresentationFades.count) + + withWorldMutationGate { + for var fade in activeTileRepresentationFades { + if fade.waitsForIncomingVisibility { + let visibleSet = Set(visibleEntityIds) + let incomingVisible = fade.incomingRenderIds.contains { visibleSet.contains($0) } + if !incomingVisible { + for entityId in fade.allRenderIds where scene.exists(entityId) { + scene.get(component: TileRepresentationFadeComponent.self, for: entityId)?.progress = 0 + } + remaining.append(fade) + continue + } + fade.waitsForIncomingVisibility = false + } + + fade.elapsed += deltaTime + let progress = simd_clamp(fade.elapsed / max(fade.duration, 0.001), 0.0, 1.0) + + for entityId in fade.allRenderIds where scene.exists(entityId) { + scene.get(component: TileRepresentationFadeComponent.self, for: entityId)?.progress = progress + } + + if progress >= 1.0 { + for entityId in fade.allRenderIds where scene.exists(entityId) { + scene.remove(component: TileRepresentationFadeComponent.self, from: entityId) + } + completed.append(fade) + } else { + remaining.append(fade) + } + } + + activeTileRepresentationFades = remaining + } + + for fade in completed { + if !fade.incomingRenderIds.isEmpty { + BatchingSystem.shared.notifyTileEntitiesResident(fade.incomingRenderIds) + TextureStreamingSystem.shared.notifyEntitiesReady(fade.incomingRenderIds) + } + + switch fade.completion { + case .unloadHLOD: + unloadHLOD(entityId: fade.tileEntityId) + case let .unloadLODLevel(levelIndex): + unloadLODLevel(entityId: fade.tileEntityId, levelIndex: levelIndex) + } + } + } + + func hasActiveTileRepresentationFade(entityId: EntityID, completion: TileFadeCompletion? = nil) -> Bool { + activeTileRepresentationFades.contains { fade in + guard fade.tileEntityId == entityId else { return false } + guard let completion else { return true } + return tileFadeCompletion(fade.completion, matches: completion) + } + } + + private func tileFadeCompletion(_ lhs: TileFadeCompletion, matches rhs: TileFadeCompletion) -> Bool { + switch (lhs, rhs) { + case (.unloadHLOD, .unloadHLOD): + return true + case let (.unloadLODLevel(a), .unloadLODLevel(b)): + return a == b + default: + return false + } + } + + @discardableResult + func beginTileRepresentationFade( + tileEntityId: EntityID, + incomingRenderIds: Set, + outgoingRenderIds: Set, + completion: TileFadeCompletion + ) -> Bool { + guard LODConfig.shared.enableFadeTransitions else { return false } + guard !incomingRenderIds.isEmpty, !outgoingRenderIds.isEmpty else { return false } + guard !hasActiveTileRepresentationFade(entityId: tileEntityId, completion: completion) else { return true } + + let duration = max(LODConfig.shared.fadeTransitionTime, 0.001) + + withWorldMutationGate { + for entityId in incomingRenderIds where scene.exists(entityId) { + if scene.get(component: TileRepresentationFadeComponent.self, for: entityId) == nil { + registerComponent(entityId: entityId, componentType: TileRepresentationFadeComponent.self) + } + if let fade = scene.get(component: TileRepresentationFadeComponent.self, for: entityId) { + fade.progress = 0 + fade.mode = 1.0 + } + } + + for entityId in outgoingRenderIds where scene.exists(entityId) { + if scene.get(component: TileRepresentationFadeComponent.self, for: entityId) == nil { + registerComponent(entityId: entityId, componentType: TileRepresentationFadeComponent.self) + } + if let fade = scene.get(component: TileRepresentationFadeComponent.self, for: entityId) { + fade.progress = 0 + fade.mode = 2.0 + } + } + + activeTileRepresentationFades.append(ActiveTileRepresentationFade( + tileEntityId: tileEntityId, + completion: completion, + elapsed: 0, + duration: duration, + waitsForIncomingVisibility: true, + incomingRenderIds: incomingRenderIds, + outgoingRenderIds: outgoingRenderIds + )) + } + + BatchingSystem.shared.notifyTileEntitiesFading(incomingRenderIds.union(outgoingRenderIds)) + return true + } + + func fullTileRenderDescendantIds(tileEntityId: EntityID) -> Set { + collectRenderDescendantIds(tileEntityId).filter { + scene.get(component: TileLODTagComponent.self, for: $0) == nil + } + } + + func lodRenderDescendantIds(_ tileComp: TileComponent, levelIndex: Int) -> Set { + guard tileComp.lodLevels.indices.contains(levelIndex) else { return [] } + let entityId = tileComp.lodLevels[levelIndex].entityId + guard entityId != .invalid, scene.exists(entityId) else { return [] } + return collectRenderDescendantIds(entityId) + } + + func hlodRenderDescendantIds(_ tileComp: TileComponent) -> Set { + guard let entityId = tileComp.hlodEntityId, scene.exists(entityId) else { return [] } + return collectRenderDescendantIds(entityId) + } + + @discardableResult + func beginFadeFromTileFallbacksToFullTile(entityId: EntityID, tileComp: TileComponent) -> Bool { + let incoming = fullTileRenderDescendantIds(tileEntityId: entityId) + var started = false + + if tileComp.hlodState == .loaded { + started = beginTileRepresentationFade( + tileEntityId: entityId, + incomingRenderIds: incoming, + outgoingRenderIds: hlodRenderDescendantIds(tileComp), + completion: .unloadHLOD + ) || started + } + + for i in tileComp.lodLevels.indices where tileComp.lodLevels[i].state == .loaded { + started = beginTileRepresentationFade( + tileEntityId: entityId, + incomingRenderIds: incoming, + outgoingRenderIds: lodRenderDescendantIds(tileComp, levelIndex: i), + completion: .unloadLODLevel(i) + ) || started + } + + return started + } + + func retireLODLevelsCoveredByHLOD(entityId: EntityID, tileComp: TileComponent) { + guard tileComp.hlodState == .loaded else { return } + + let incoming = hlodRenderDescendantIds(tileComp) + for i in tileComp.lodLevels.indices where tileComp.lodLevels[i].state != .unloaded { + let startedFade = beginTileRepresentationFade( + tileEntityId: entityId, + incomingRenderIds: incoming, + outgoingRenderIds: lodRenderDescendantIds(tileComp, levelIndex: i), + completion: .unloadLODLevel(i) + ) + if !startedFade { + unloadLODLevel(entityId: entityId, levelIndex: i) + } + } + } + /// Loads the coarse HLOD mesh for a tile stub as a child entity. /// Called when the camera is beyond `hlodSwitchDistance` and the tile is unloaded. /// HLOD entities are rendered through the standard model pass (no batching) and @@ -159,6 +345,7 @@ extension GeometryStreamingSystem { func unloadHLOD(entityId: EntityID) { guard let tileComp = scene.get(component: TileComponent.self, for: entityId), tileComp.hlodState != .unloaded else { return } + guard canUnloadTileFallback(entityId: entityId, tileComp: tileComp, removingHLOD: true) else { return } // Set .unloading BEFORE cancel() so any in-flight completion callback // that checks hlodState sees .unloading (not .loading) and discards its result. @@ -191,11 +378,9 @@ extension GeometryStreamingSystem { tileComp.lastHLODTransitionTime = CFAbsoluteTimeGetCurrent() } - // Force-release the AssetLoadingGate that setEntityMeshAsync opened via - // startLoading(entityId: capturedHlodEntityId). Task.cancel() is cooperative — - // the inner Task may still be running after we destroy the entity, and its - // completion callback will find the entity gone and return early without calling - // finishLoading, leaving the gate permanently elevated and the render loop frozen. + // Clear async loading progress that setEntityMeshAsync registered. Task.cancel() + // is cooperative; the inner Task may still be running after we destroy the entity, + // and its completion callback can return early without calling finishLoading. // finishLoading is idempotent: if the Task already called it, this is a no-op. if let hlodId = capturedHlodEntityId { Task { await AssetLoadingState.shared.finishLoading(entityId: hlodId) } @@ -347,6 +532,7 @@ extension GeometryStreamingSystem { guard let tileComp = scene.get(component: TileComponent.self, for: entityId), levelIndex < tileComp.lodLevels.count, tileComp.lodLevels[levelIndex].state != .unloaded else { return } + guard canUnloadTileFallback(entityId: entityId, tileComp: tileComp, removingLODLevel: levelIndex) else { return } // Set .unloading BEFORE cancel() so an in-flight completion sees it and discards. // If the level was still .loading, the completion callback will not decrement the @@ -386,7 +572,7 @@ extension GeometryStreamingSystem { } } - // Same gate-release fix as unloadHLOD — see comment there for full rationale. + // Same async loading-state cleanup as unloadHLOD — see comment there for rationale. if capturedLodEntityId != .invalid { Task { await AssetLoadingState.shared.finishLoading(entityId: capturedLodEntityId) } } @@ -565,13 +751,6 @@ extension GeometryStreamingSystem { self.markLoadedTileEntity(entityId) self.recordTileRepresentationSwap(entityId: entityId, tileId: tileId, representation: "tile:parsed") - // Only drop fallback coverage once full geometry is renderable. - // OCC tiles may be parsed before enough child stubs have uploaded. - if self.tileHasUsableFullGeometry(tc) { - self.unloadHLOD(entityId: entityId) - self.unloadAllLODLevels(entityId: entityId) - } - // Tag the tile's mesh hierarchy for cell-based static batching. // setEntityStaticBatchComponent walks the full child tree and // attaches StaticBatchComponent to every entity that has a @@ -584,6 +763,11 @@ extension GeometryStreamingSystem { let tileRenderIds = self.collectRenderDescendantIds(capturedMeshEntityId) let selectableRenderIds = tileRenderIds.filter { hasEntitySceneChannel(entityId: $0, channel: .selectableGeometry) } + let hasFallbackCoverage = tc.hlodState == .loaded || tc.lodLevels.contains { $0.state == .loaded } + let canReleaseFallback = self.canReleaseLOD0Fallback(entityId: entityId, tileComp: tc, renderEntityIds: tileRenderIds) + let fullTileFadeStarted = hasFallbackCoverage + ? self.beginFadeFromTileFallbacksToFullTile(entityId: entityId, tileComp: tc) + : false // For fullLoad tiles (occCount == 0) the RenderComponent is // already present on capturedMeshEntityId and its children — @@ -596,12 +780,33 @@ extension GeometryStreamingSystem { // Also enqueue into the texture streaming burst queue so // freshly loaded tile geometry gets its first texture upgrade // before the regular visible-entity pass. - if !tileRenderIds.isEmpty { + if !tileRenderIds.isEmpty, !fullTileFadeStarted { BatchingSystem.shared.notifyTileEntitiesResident(tileRenderIds) TextureStreamingSystem.shared.notifyEntitiesReady(tileRenderIds) } } + self.recordLOD0Promotion( + entityId: entityId, + tileId: tileId, + renderEntityIds: tileRenderIds, + fallbackSummary: self.tileFallbackSummary(tc) + ) + + // Drop fallback coverage only after LOD0 is present in the + // render-visible set. A freshly parsed full tile can have + // RenderComponent.isVisible=true while visibleEntityIds is + // still frozen behind the loading gate/triple-buffer handoff. + // Keeping LOD/HLOD alive through that window avoids a visible + // hole during navigation. + if canReleaseFallback { + if fullTileFadeStarted { + self.clearLOD0FallbackBookkeeping(entityId: entityId) + } else { + self.releaseLOD0FallbackCoverage(entityId: entityId) + } + } + let budgetStats = MemoryBudgetManager.shared.getStats() let geomPct = Int((budgetStats.geometryUtilization * 100).rounded()) let selectableNames = selectableRenderIds diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index 7929ee70..bf1fcd63 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -18,6 +18,55 @@ private struct TileRepresentationSwapWindow { var toggleCountsByTarget: [String: Int] } +private struct TileLOD0VisibilityProbe { + let tileId: String + let parsedFrame: Int + let renderEntityIds: Set + let fallbackSummaryAtParse: String + var warnedMissingVisibleSet: Bool = false +} + +private struct TileRepresentationGapAuditSummary { + var unloadedNoRepresentation: Int = 0 + var parsingNoRepresentation: Int = 0 + var failedNoRepresentation: Int = 0 + var parsedNoRepresentation: Int = 0 +} + +private struct TileRepresentationOverlapAuditSummary { + var residentFullTiles: Int = 0 + var residentLODTiles: Int = 0 + var residentHLODTiles: Int = 0 + var visibleFullTiles: Int = 0 + var visibleLODTiles: Int = 0 + var visibleHLODTiles: Int = 0 + var fullAndLODVisibleTiles: Int = 0 + var fullAndHLODVisibleTiles: Int = 0 + var lodAndHLODVisibleTiles: Int = 0 + var fullAndFallbackResidentTiles: Int = 0 + var activeFadeTiles: Int = 0 + var waitingFadeTiles: Int = 0 +} + +enum TileFadeCompletion { + case unloadHLOD + case unloadLODLevel(Int) +} + +struct ActiveTileRepresentationFade { + let tileEntityId: EntityID + let completion: TileFadeCompletion + var elapsed: Float + let duration: Float + var waitsForIncomingVisibility: Bool + let incomingRenderIds: Set + let outgoingRenderIds: Set + + var allRenderIds: Set { + incomingRenderIds.union(outgoingRenderIds) + } +} + public class GeometryStreamingSystem: @unchecked Sendable { public static let shared = GeometryStreamingSystem() @@ -141,6 +190,22 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// traversal by preventing rapid HLOD/LOD/full-tile churn in a narrow distance band. public var secondaryRepresentationMinDwellSeconds: Double = 1.0 + /// Emits focused diagnostics for tile representation gaps and LOD0 handoffs. + /// This is intentionally runtime-enabled by default because the logs are throttled + /// and the data is only collected for active tile entities. + public var tileRepresentationDiagnosticsEnabled: Bool = true + + /// Minimum time a tile may spend parsing with no loaded fallback before the + /// representation-gap diagnostic warns. This filters expected first-frame + /// startup hydration while still catching LOD0 stalls. + public var tileRepresentationGapDwellSeconds: Double = 0.5 + + /// Number of streaming-system frames LOD0 may remain absent from the published + /// visible set before warning. Culling/visible-set publication can legitimately + /// lag parse completion by several frames during tile bursts, so this warns only + /// on persistent handoff gaps. + public var lod0VisibilityWarningFrameDelay: Int = 12 + /// Maximum tile unload operations processed per streaming update tick. /// Capping unloads prevents a single-frame blank on fast camera movement or /// teleports: when many tiles leave range at once, GPU buffer releases are @@ -222,6 +287,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// mass dispatch of 100+ HLOD parses that would OOM-kill the process. var hlodLoadingCount: Int = 0 + var activeTileRepresentationFades: [ActiveTileRepresentationFade] = [] + // MARK: - Camera Velocity (4.5 predictive loading) /// Exponential smoothing factor for camera velocity (0 = no smoothing, 1 = frozen). @@ -320,6 +387,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { let entityId: EntityID let distance: Float let priority: Int + let urgency: Int let solidAngle: Float let viewAlignment: Float let occlusionScore: Float @@ -372,6 +440,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { var cumulativeAsyncLoadMs: Double = 0.0 var completedAsyncLoads: Int = 0 fileprivate var tileSwapWindow: [EntityID: TileRepresentationSwapWindow] = [:] + private var tileRepresentationGapLastLogTime: [EntityID: Double] = [:] + private var lod0VisibilityProbes: [EntityID: TileLOD0VisibilityProbe] = [:] + private var tileLOD0HandoffPending: Set = [] + private var lastTileGapSummaryLogTime: Double = 0 /// First-detection timestamps (CFAbsoluteTime) keyed by entity ID. /// Records when each entity first appeared as a load candidate so we can measure @@ -612,6 +684,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { currentFrame += 1 MeshResourceManager.shared.currentFrame = currentFrame // Keep cache LRU updated + advanceTileRepresentationFades(deltaTime: deltaTime) let activeLoadsAtStart = activeLoadCountSnapshot() @@ -801,12 +874,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { tc.loadTask = nil tc.parseStartTime = 0 - // Force-release the AssetLoadingGate for the hung inner Task. - // setEntityMeshAsync opens the gate via AssetLoadingState.shared.startLoading(entityId:) - // using capturedMeshEntityId. Since loadTextures() ignores Swift cooperative - // cancellation, the gate never closes on its own — isLoadingAny stays permanently - // true and the render loop freezes. Calling finishLoading here closes the gate - // so the render loop resumes on the next frame. + // Clear async loading progress for the hung inner Task. setEntityMeshAsync + // tracks capturedMeshEntityId in AssetLoadingState; if ModelIO or texture + // decoding ignores cooperative cancellation, the normal finish path may never + // run and loadingCount() would stay elevated. let hungMeshId = tc.meshEntityId tc.meshEntityId = .invalid if hungMeshId != .invalid { @@ -1090,18 +1161,15 @@ public class GeometryStreamingSystem: @unchecked Sendable { let dist = calculateDistance(entityId: entityId, cameraPosition: effectiveCameraPosition) - // LOD levels are only cleared for HLOD when the HLOD is actually renderable. - // If an HLOD is merely loading, keep the previous LOD as coverage until the - // replacement is resident. + // Keep loaded LOD coverage while HLOD is loading or resident. The renderer can + // cull/select the appropriate representation, but destroying the old LOD on the + // same tick the HLOD becomes loaded creates visible secondary-representation pops. if tileComp.hlodSwitchDistance > 0 { let hlodLoaded = tileComp.hlodState == .loaded let hlodLoading = tileComp.hlodState == .loading let beyondHLOD = dist >= tileComp.hlodSwitchDistance let inHLODHysteresisBand = dist >= tileComp.hlodSwitchDistance * hlodHysteresisFactor if beyondHLOD || hlodLoading || (hlodLoaded && inHLODHysteresisBand) { - if hlodLoaded { - unloadAllLODLevels(entityId: entityId) - } continue } } @@ -1126,7 +1194,11 @@ public class GeometryStreamingSystem: @unchecked Sendable { let canTransitionLOD = tileComp.lastLODTransitionTime == 0 || timeoutNow - tileComp.lastLODTransitionTime >= secondaryRepresentationMinDwellSeconds - let fallbackTargetIndex = targetIndex ?? (!tileHasUsableFullGeometry(tileComp) ? tileComp.lodLevels.indices.first : nil) + let hasLoadedLOD = tileComp.lodLevels.contains { $0.state == .loaded } + let hasVisibleFallback = hasLoadedLOD || tileComp.hlodState == .loaded + let needsLOD0HandoffFallback = tileLOD0HandoffPending.contains(entityId) && !hasVisibleFallback + let fallbackTargetIndex = targetIndex ?? ((!tileHasUsableFullGeometry(tileComp) || needsLOD0HandoffFallback) ? tileComp.lodLevels.indices.first : nil) + let fallbackUrgency = (tileComp.state == .parsing && !hasVisibleFallback) || needsLOD0HandoffFallback ? 100 : 0 switch tileComp.state { case .unloaded, .failed, .parsing: @@ -1137,14 +1209,17 @@ public class GeometryStreamingSystem: @unchecked Sendable { priority: tileComp.priority, levelIndex: target, cameraPosition: effectiveCameraPosition, - tileOccluders: tileOccluders + tileOccluders: tileOccluders, + urgency: fallbackUrgency )) } else if canTransitionLOD { unloadAllLODLevels(entityId: entityId) } case .parsed: - if tileHasUsableFullGeometry(tileComp) { - unloadAllLODLevels(entityId: entityId) + if tileHasUsableFullGeometry(tileComp), !tileLOD0HandoffPending.contains(entityId) { + if !beginFadeFromTileFallbacksToFullTile(entityId: entityId, tileComp: tileComp) { + unloadAllLODLevels(entityId: entityId) + } } else if let target = fallbackTargetIndex { lodLoadCandidates.append(makeTileRepresentationCandidate( entityId: entityId, @@ -1152,7 +1227,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { priority: tileComp.priority, levelIndex: target, cameraPosition: effectiveCameraPosition, - tileOccluders: tileOccluders + tileOccluders: tileOccluders, + urgency: fallbackUrgency )) } case .unloading: @@ -1186,7 +1262,15 @@ public class GeometryStreamingSystem: @unchecked Sendable { tileComp.lodLevels[candidate.levelIndex].state == .loaded, tileComp.lodLevels[i].state != .unloaded { - unloadLODLevel(entityId: candidate.entityId, levelIndex: i) + let startedFade = beginTileRepresentationFade( + tileEntityId: candidate.entityId, + incomingRenderIds: lodRenderDescendantIds(tileComp, levelIndex: candidate.levelIndex), + outgoingRenderIds: lodRenderDescendantIds(tileComp, levelIndex: i), + completion: .unloadLODLevel(i) + ) + if !startedFade { + unloadLODLevel(entityId: candidate.entityId, levelIndex: i) + } } } } @@ -1206,6 +1290,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { let canTransitionHLOD = tileComp.lastHLODTransitionTime == 0 || timeoutNow - tileComp.lastHLODTransitionTime >= secondaryRepresentationMinDwellSeconds + if dist > tileComp.hlodSwitchDistance, tileComp.hlodState == .loaded { + retireLODLevelsCoveredByHLOD(entityId: entityId, tileComp: tileComp) + } + switch tileComp.state { case .unloaded, .failed: if dist > tileComp.hlodSwitchDistance { @@ -1228,14 +1316,39 @@ public class GeometryStreamingSystem: @unchecked Sendable { tileComp.hlodState != .unloaded, tileHasLoadedLOD(tileComp) || tileHasUsableFullGeometry(tileComp) { - unloadHLOD(entityId: entityId) + var incoming: Set = [] + if tileHasUsableFullGeometry(tileComp) { + incoming = fullTileRenderDescendantIds(tileEntityId: entityId) + } else if let loadedLOD = tileComp.lodLevels.indices.first(where: { tileComp.lodLevels[$0].state == .loaded }) { + incoming = lodRenderDescendantIds(tileComp, levelIndex: loadedLOD) + } + let startedFade = beginTileRepresentationFade( + tileEntityId: entityId, + incomingRenderIds: incoming, + outgoingRenderIds: hlodRenderDescendantIds(tileComp), + completion: .unloadHLOD + ) + if !startedFade { + unloadHLOD(entityId: entityId) + } } } // else: inside hysteresis band — keep current HLOD state. case .parsed: // Full geometry must be renderable before HLOD coverage is dropped. - if tileHasUsableFullGeometry(tileComp), tileComp.hlodState != .unloaded { - unloadHLOD(entityId: entityId) + if tileHasUsableFullGeometry(tileComp), + !tileLOD0HandoffPending.contains(entityId), + tileComp.hlodState != .unloaded + { + let startedFade = beginTileRepresentationFade( + tileEntityId: entityId, + incomingRenderIds: fullTileRenderDescendantIds(tileEntityId: entityId), + outgoingRenderIds: hlodRenderDescendantIds(tileComp), + completion: .unloadHLOD + ) + if !startedFade { + unloadHLOD(entityId: entityId) + } } case .parsing, .unloading: // Keep HLOD visible during full-tile load for a seamless transition. @@ -1597,6 +1710,16 @@ public class GeometryStreamingSystem: @unchecked Sendable { } } + auditLOD0FallbackHandoffs(tileFrustum: tileStreamingFrustum) + + if tileRepresentationDiagnosticsEnabled { + auditTileRepresentationDiagnostics( + nearbyEntities: nearbyEntities, + cameraPosition: effectiveCameraPosition, + tileFrustum: tileStreamingFrustum + ) + } + let updateWorkMs = (CFAbsoluteTimeGetCurrent() - updateStart) * 1000.0 peakTickMs = max(peakTickMs, updateWorkMs) let activeLoadsAtEnd = activeLoadCountSnapshot() @@ -1855,7 +1978,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { priority: Int, levelIndex: Int, cameraPosition: simd_float3, - tileOccluders: [TileOccluder] + tileOccluders: [TileOccluder], + urgency: Int = 0 ) -> TileRepresentationCandidate { let (solidAngle, viewAlignment) = tileImportanceComponents( entityId: entityId, @@ -1885,6 +2009,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { entityId: entityId, distance: distance, priority: priority, + urgency: urgency, solidAngle: solidAngle, viewAlignment: viewAlignment, occlusionScore: occlusionScore, @@ -1897,6 +2022,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { let saFloor = max(maxSA, 1e-6) candidates.sort { lhs, rhs in if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.urgency != rhs.urgency { return lhs.urgency > rhs.urgency } if enableImportanceSort { let lScore = (lhs.solidAngle / saFloor) * lhs.viewAlignment * lhs.occlusionScore let rScore = (rhs.solidAngle / saFloor) * rhs.viewAlignment * rhs.occlusionScore @@ -2094,6 +2220,15 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// This API frees GPU memory immediately so the next full-scale session can /// start loading tiles with a clean memory budget. public func forceUnloadAllParsedTiles() { + withWorldMutationGate { + let tileFadeComponentId = getComponentId(for: TileRepresentationFadeComponent.self) + let fadingEntities = queryEntitiesWithComponentIds([tileFadeComponentId], in: scene) + for entityId in fadingEntities { + scene.remove(component: TileRepresentationFadeComponent.self, from: entityId) + } + activeTileRepresentationFades.removeAll(keepingCapacity: true) + } + // Cancel in-flight (.parsing) tiles first so their Tasks cannot complete // through the success path after this call returns. let parsingSnapshot = loadingTileEntitiesSnapshot() @@ -2160,6 +2295,12 @@ public class GeometryStreamingSystem: @unchecked Sendable { } } } + let tileFadeComponentId = getComponentId(for: TileRepresentationFadeComponent.self) + let fadingEntities = queryEntitiesWithComponentIds([tileFadeComponentId], in: scene) + for entityId in fadingEntities { + scene.remove(component: TileRepresentationFadeComponent.self, from: entityId) + } + activeTileRepresentationFades.removeAll(keepingCapacity: true) withStateLock { loadedHLODEntities.removeAll() loadedLODEntities.removeAll() @@ -2188,6 +2329,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { cumulativeAsyncLoadMs = 0 completedAsyncLoads = 0 tileSwapWindow.removeAll() + tileRepresentationGapLastLogTime.removeAll() + lod0VisibilityProbes.removeAll() + tileLOD0HandoffPending.removeAll() + lastTileGapSummaryLogTime = 0 lastCameraPosition = nil cameraVelocity = .zero firstRangeTimestamps.removeAll() @@ -2239,6 +2384,377 @@ public class GeometryStreamingSystem: @unchecked Sendable { public func getDiagnosticsSnapshot() -> GeometryStreamingDiagnosticsSnapshot { withStateLock { diagnostics } } + + func tileFallbackSummary(_ tileComp: TileComponent) -> String { + let lodSummary = tileComp.lodLevels.enumerated() + .filter { _, level in level.state != .unloaded } + .map { index, level in "lod\(index + 1)=\(level.state)" } + .joined(separator: ",") + let lodText = lodSummary.isEmpty ? "lod=none" : lodSummary + return "hlod=\(tileComp.hlodState) \(lodText)" + } + + func canReleaseLOD0Fallback( + entityId _: EntityID, + tileComp: TileComponent, + renderEntityIds: Set + ) -> Bool { + guard tileHasUsableFullGeometry(tileComp) else { return false } + guard !renderEntityIds.isEmpty else { return true } + + let visibleSet = Set(visibleEntityIds) + return renderEntityIds.contains { visibleSet.contains($0) } + } + + func fullTileHasVisibleCoverage(entityId: EntityID, tileComp: TileComponent) -> Bool { + guard tileHasUsableFullGeometry(tileComp) else { return false } + let renderIds = fullTileRenderDescendantIds(tileEntityId: entityId) + return canReleaseLOD0Fallback(entityId: entityId, tileComp: tileComp, renderEntityIds: renderIds) + } + + func canUnloadTileFallback(entityId: EntityID, tileComp: TileComponent, removingLODLevel levelIndex: Int? = nil, removingHLOD: Bool = false) -> Bool { + let visibleSet = Set(visibleEntityIds) + + if removingHLOD { + let removingIds = hlodRenderDescendantIds(tileComp) + if !removingIds.contains(where: { visibleSet.contains($0) }) { + return true + } + } + + if let levelIndex { + let removingIds = lodRenderDescendantIds(tileComp, levelIndex: levelIndex) + if !removingIds.contains(where: { visibleSet.contains($0) }) { + return true + } + } + + if fullTileHasVisibleCoverage(entityId: entityId, tileComp: tileComp) { + return true + } + + if tileComp.hlodState == .loaded, !removingHLOD { + let renderIds = hlodRenderDescendantIds(tileComp) + if renderIds.contains(where: { visibleSet.contains($0) }) { + return true + } + } + + for (index, level) in tileComp.lodLevels.enumerated() + where level.state == .loaded && index != levelIndex + { + let renderIds = lodRenderDescendantIds(tileComp, levelIndex: index) + if renderIds.contains(where: { visibleSet.contains($0) }) { + return true + } + } + + return false + } + + func releaseLOD0FallbackCoverage(entityId: EntityID) { + var fadeStarted = false + if let tileComp = scene.get(component: TileComponent.self, for: entityId) { + fadeStarted = beginFadeFromTileFallbacksToFullTile(entityId: entityId, tileComp: tileComp) + } + + clearLOD0FallbackBookkeeping(entityId: entityId) + + if !fadeStarted { + unloadHLOD(entityId: entityId) + unloadAllLODLevels(entityId: entityId) + } + } + + func clearLOD0FallbackBookkeeping(entityId: EntityID) { + tileLOD0HandoffPending.remove(entityId) + if !tileRepresentationDiagnosticsEnabled { + lod0VisibilityProbes.removeValue(forKey: entityId) + } + } + + func recordLOD0Promotion( + entityId: EntityID, + tileId: String, + renderEntityIds: Set, + fallbackSummary: String + ) { + let visibleSet = Set(visibleEntityIds) + let visibleSetCount = renderEntityIds.filter { visibleSet.contains($0) }.count + let renderVisibleCount = renderEntityIds.filter { + scene.get(component: RenderComponent.self, for: $0)?.isVisible == true + }.count + + if !renderEntityIds.isEmpty, visibleSetCount == 0 { + tileLOD0HandoffPending.insert(entityId) + } else { + tileLOD0HandoffPending.remove(entityId) + } + + lod0VisibilityProbes[entityId] = TileLOD0VisibilityProbe( + tileId: tileId, + parsedFrame: currentFrame, + renderEntityIds: renderEntityIds, + fallbackSummaryAtParse: fallbackSummary + ) + + if tileRepresentationDiagnosticsEnabled { + Logger.log( + message: "[TileStreaming][LOD0] Tile '\(tileId)' promoted parsedFrame=\(currentFrame) render=\(renderEntityIds.count) renderVisible=\(renderVisibleCount) visibleSet=\(visibleSetCount) fallbackAtParse={\(fallbackSummary)}", + category: LogCategory.tileStreaming.rawValue + ) + } + } + + private func auditTileRepresentationDiagnostics( + nearbyEntities: [EntityID], + cameraPosition: simd_float3, + tileFrustum: Frustum? + ) { + auditRepresentationGaps( + nearbyEntities: nearbyEntities, + cameraPosition: cameraPosition, + tileFrustum: tileFrustum + ) + auditRepresentationOverlaps() + auditLOD0VisibilityProbes(tileFrustum: tileFrustum) + } + + private func auditRepresentationOverlaps() { + let visibleSet = Set(visibleEntityIds) + var summary = TileRepresentationOverlapAuditSummary() + let fadingTiles = Set(activeTileRepresentationFades.map(\.tileEntityId)) + let waitingFadeTiles = Set(activeTileRepresentationFades.filter(\.waitsForIncomingVisibility).map(\.tileEntityId)) + let fullTileEntities = Set(loadedTileEntitiesSnapshot()) + let lodTileEntities = Set(loadedLODEntitiesSnapshot()) + let hlodTileEntities = Set(loadedHLODEntitiesSnapshot()) + let tileEntities = fullTileEntities + .union(lodTileEntities) + .union(hlodTileEntities) + .union(fadingTiles) + .union(waitingFadeTiles) + + for entityId in tileEntities { + guard scene.exists(entityId), + let tileComp = scene.get(component: TileComponent.self, for: entityId) + else { continue } + + let fullIds = fullTileEntities.contains(entityId) + ? fullTileRenderDescendantIds(tileEntityId: entityId) + : [] + let lodIds: Set = lodTileEntities.contains(entityId) + ? tileComp.lodLevels.indices.reduce(into: Set()) { result, index in + guard tileComp.lodLevels[index].state == .loaded else { return } + result.formUnion(lodRenderDescendantIds(tileComp, levelIndex: index)) + } + : [] + let hlodIds = hlodTileEntities.contains(entityId) && tileComp.hlodState == .loaded + ? hlodRenderDescendantIds(tileComp) + : [] + + let hasFullResident = !fullIds.isEmpty && tileHasUsableFullGeometry(tileComp) + let hasLODResident = !lodIds.isEmpty + let hasHLODResident = !hlodIds.isEmpty + let fullVisible = hasFullResident && fullIds.contains { visibleSet.contains($0) } + let lodVisible = hasLODResident && lodIds.contains { visibleSet.contains($0) } + let hlodVisible = hasHLODResident && hlodIds.contains { visibleSet.contains($0) } + + if hasFullResident { summary.residentFullTiles += 1 } + if hasLODResident { summary.residentLODTiles += 1 } + if hasHLODResident { summary.residentHLODTiles += 1 } + if fullVisible { summary.visibleFullTiles += 1 } + if lodVisible { summary.visibleLODTiles += 1 } + if hlodVisible { summary.visibleHLODTiles += 1 } + if fullVisible, lodVisible { summary.fullAndLODVisibleTiles += 1 } + if fullVisible, hlodVisible { summary.fullAndHLODVisibleTiles += 1 } + if lodVisible, hlodVisible { summary.lodAndHLODVisibleTiles += 1 } + if hasFullResident, hasLODResident || hasHLODResident { + summary.fullAndFallbackResidentTiles += 1 + } + if fadingTiles.contains(entityId) { summary.activeFadeTiles += 1 } + if waitingFadeTiles.contains(entityId) { summary.waitingFadeTiles += 1 } + } + + withStateLock { + diagnostics.residentFullTileRepresentations = summary.residentFullTiles + diagnostics.residentLODRepresentations = summary.residentLODTiles + diagnostics.residentHLODRepresentations = summary.residentHLODTiles + diagnostics.visibleFullTileRepresentations = summary.visibleFullTiles + diagnostics.visibleLODRepresentations = summary.visibleLODTiles + diagnostics.visibleHLODRepresentations = summary.visibleHLODTiles + diagnostics.fullAndLODVisibleOverlapTiles = summary.fullAndLODVisibleTiles + diagnostics.fullAndHLODVisibleOverlapTiles = summary.fullAndHLODVisibleTiles + diagnostics.lodAndHLODVisibleOverlapTiles = summary.lodAndHLODVisibleTiles + diagnostics.fullAndFallbackResidentOverlapTiles = summary.fullAndFallbackResidentTiles + diagnostics.activeTileRepresentationFades = summary.activeFadeTiles + diagnostics.waitingTileRepresentationFades = summary.waitingFadeTiles + } + } + + private func auditLOD0FallbackHandoffs(tileFrustum _: Frustum?) { + guard !tileLOD0HandoffPending.isEmpty else { return } + + let visibleSet = Set(visibleEntityIds) + var releases: [EntityID] = [] + + for entityId in tileLOD0HandoffPending { + guard scene.exists(entityId), + let tileComp = scene.get(component: TileComponent.self, for: entityId), + tileComp.state == .parsed, + let probe = lod0VisibilityProbes[entityId] + else { + releases.append(entityId) + continue + } + + let visibleSetCount = probe.renderEntityIds.filter { visibleSet.contains($0) }.count + if visibleSetCount > 0 { + releases.append(entityId) + continue + } + + // Do not release fallback coverage on a timeout or frustum miss. The + // RenderComponent can already be marked visible while the published + // visibleEntityIds set is still lagging; releasing here creates an + // uncovered LOD0 handoff window. Out-of-range cleanup will tear down + // stale representations when the tile genuinely leaves streaming range. + } + + for entityId in releases { + releaseLOD0FallbackCoverage(entityId: entityId) + } + } + + private func auditRepresentationGaps( + nearbyEntities: [EntityID], + cameraPosition: simd_float3, + tileFrustum: Frustum? + ) { + let now = CFAbsoluteTimeGetCurrent() + var summary = TileRepresentationGapAuditSummary() + + for entityId in nearbyEntities { + guard scene.exists(entityId), + let tileComp = scene.get(component: TileComponent.self, for: entityId) + else { continue } + + let distance = calculateDistance(entityId: entityId, cameraPosition: cameraPosition) + guard distance <= tileComp.streamingRadius + 1.0, + tilePassesStreamingFrustum(entityId: entityId, frustum: tileFrustum) + else { continue } + + let hasFull = tileHasUsableFullGeometry(tileComp) + let loadedLODCount = tileComp.lodLevels.filter { $0.state == .loaded }.count + let loadingLODCount = tileComp.lodLevels.filter { $0.state == .loading }.count + let hasLoadedHLOD = tileComp.hlodState == .loaded + let hasVisibleFallback = loadedLODCount > 0 || hasLoadedHLOD + + guard !hasFull, !hasVisibleFallback else { + tileRepresentationGapLastLogTime.removeValue(forKey: entityId) + continue + } + + switch tileComp.state { + case .unloaded: + summary.unloadedNoRepresentation += 1 + continue + case .parsing: + summary.parsingNoRepresentation += 1 + guard tileComp.parseStartTime > 0, + now - tileComp.parseStartTime >= tileRepresentationGapDwellSeconds + else { continue } + case .failed: + summary.failedNoRepresentation += 1 + case .parsed: + summary.parsedNoRepresentation += 1 + case .unloading: + continue + } + + let lastLog = tileRepresentationGapLastLogTime[entityId] ?? 0 + guard now - lastLog >= 2.0 else { continue } + tileRepresentationGapLastLogTime[entityId] = now + + withStateLock { + diagnostics.tileRepresentationGapWarnings += 1 + } + + Logger.logWarning( + message: "[TileStreaming][Gap] Tile '\(tileComp.tileId)' has no visible representation in display range. dist=\(String(format: "%.1f", distance))m state=\(tileComp.state) visual=\(tileComp.visualState) fullUsable=\(hasFull) hlod=\(tileComp.hlodState) loadedLOD=\(loadedLODCount) loadingLOD=\(loadingLODCount) activeFullLoads=\(activeTileLoadCount())", + category: LogCategory.tileStreaming.rawValue + ) + } + + if summary.unloadedNoRepresentation > 0, + now - lastTileGapSummaryLogTime >= 2.0 + { + lastTileGapSummaryLogTime = now + Logger.log( + message: "[TileStreaming][GapSummary] unloadedNoRep=\(summary.unloadedNoRepresentation) parsingNoRep=\(summary.parsingNoRepresentation) failedNoRep=\(summary.failedNoRepresentation) parsedNoRep=\(summary.parsedNoRepresentation) activeFullLoads=\(activeTileLoadCount()) maxFullLoads=\(maxConcurrentTileLoads) activeLODLoads=\(activeLODLoadCount()) maxLODLoads=\(maxConcurrentLODLoads) activeHLODLoads=\(activeHLODLoadCount()) maxHLODLoads=\(maxConcurrentHLODLoads)", + category: LogCategory.tileStreaming.rawValue + ) + } + } + + private func auditLOD0VisibilityProbes(tileFrustum: Frustum?) { + guard !lod0VisibilityProbes.isEmpty else { return } + + let visibleSet = Set(visibleEntityIds) + var removals: [EntityID] = [] + + for (entityId, var probe) in lod0VisibilityProbes { + guard scene.exists(entityId), + let tileComp = scene.get(component: TileComponent.self, for: entityId), + tileComp.state == .parsed + else { + removals.append(entityId) + continue + } + + let visibleSetCount = probe.renderEntityIds.filter { visibleSet.contains($0) }.count + let renderVisibleCount = probe.renderEntityIds.filter { + scene.get(component: RenderComponent.self, for: $0)?.isVisible == true + }.count + let ageFrames = currentFrame - probe.parsedFrame + + if visibleSetCount > 0 { + Logger.log( + message: "[TileStreaming][LOD0] Tile '\(probe.tileId)' first visible after \(ageFrames) frame(s). visibleSet=\(visibleSetCount)/\(probe.renderEntityIds.count) renderVisible=\(renderVisibleCount) fallbackAtParse={\(probe.fallbackSummaryAtParse)}", + category: LogCategory.tileStreaming.rawValue + ) + releaseLOD0FallbackCoverage(entityId: entityId) + removals.append(entityId) + continue + } + + if ageFrames >= lod0VisibilityWarningFrameDelay, + !probe.warnedMissingVisibleSet, + tilePassesStreamingFrustum(entityId: entityId, frustum: tileFrustum) + { + probe.warnedMissingVisibleSet = true + lod0VisibilityProbes[entityId] = probe + withStateLock { + diagnostics.lod0VisibilityWarnings += 1 + if tileComp.hlodState == .loaded || tileComp.lodLevels.contains(where: { $0.state == .loaded }) { + diagnostics.lod0VisibilityWarningsWithFallback += 1 + } else { + diagnostics.lod0VisibilityWarningsNoFallback += 1 + } + } + Logger.logWarning( + message: "[TileStreaming][LOD0] Tile '\(probe.tileId)' parsed but LOD0 render entities have not entered the visible set after \(ageFrames) frame(s). render=\(probe.renderEntityIds.count) renderVisible=\(renderVisibleCount) visual=\(tileComp.visualState) fallbackNow={\(tileFallbackSummary(tileComp))} fallbackAtParse={\(probe.fallbackSummaryAtParse)}", + category: LogCategory.tileStreaming.rawValue + ) + } + + // Keep the probe alive until LOD0 enters visibleEntityIds. A high age is + // diagnostic signal, not proof that the fallback can be dropped safely. + } + + for entityId in removals { + lod0VisibilityProbes.removeValue(forKey: entityId) + } + } } public struct GeometryStreamingDiagnosticsSnapshot: Sendable { @@ -2263,6 +2779,22 @@ public struct GeometryStreamingDiagnosticsSnapshot: Sendable { public var lastFailedAsyncLoadMs: Double = 0.0 public var tileSwapWarnings: Int = 0 public var tilesSkippedByHierarchyGate: Int = 0 + public var tileRepresentationGapWarnings: Int = 0 + public var lod0VisibilityWarnings: Int = 0 + public var lod0VisibilityWarningsWithFallback: Int = 0 + public var lod0VisibilityWarningsNoFallback: Int = 0 + public var residentFullTileRepresentations: Int = 0 + public var residentLODRepresentations: Int = 0 + public var residentHLODRepresentations: Int = 0 + public var visibleFullTileRepresentations: Int = 0 + public var visibleLODRepresentations: Int = 0 + public var visibleHLODRepresentations: Int = 0 + public var fullAndLODVisibleOverlapTiles: Int = 0 + public var fullAndHLODVisibleOverlapTiles: Int = 0 + public var lodAndHLODVisibleOverlapTiles: Int = 0 + public var fullAndFallbackResidentOverlapTiles: Int = 0 + public var activeTileRepresentationFades: Int = 0 + public var waitingTileRepresentationFades: Int = 0 public init() {} } diff --git a/Sources/UntoldEngine/Systems/LODConfig.swift b/Sources/UntoldEngine/Systems/LODConfig.swift index a5cb3c26..6ec151fe 100644 --- a/Sources/UntoldEngine/Systems/LODConfig.swift +++ b/Sources/UntoldEngine/Systems/LODConfig.swift @@ -52,7 +52,8 @@ public struct LODConfig { /// Hysteresis to prevent flickering ( add to distance when switching up) public var hysteresis: Float = 5.0 - // Enable smooth transitions - Not yet implemented + /// Enable dithered cross-fade transitions for entity-level LOD switches and + /// tile representation handoffs. public var enableFadeTransitions: Bool = false public var fadeTransitionTime: Float = 0.3 diff --git a/Sources/UntoldEngine/Systems/LODSystem.swift b/Sources/UntoldEngine/Systems/LODSystem.swift index b8ce2355..1591a73f 100644 --- a/Sources/UntoldEngine/Systems/LODSystem.swift +++ b/Sources/UntoldEngine/Systems/LODSystem.swift @@ -29,6 +29,10 @@ public class LODSystem: @unchecked Sendable { public func update(deltaTime: Float) { frameCounter &+= 1 + if LODConfig.shared.enableFadeTransitions { + advanceActiveTransitions(deltaTime: deltaTime) + } + // Get active camera guard let camera = CameraSystem.shared.activeCamera, let cameraComponent = scene.get(component: CameraComponent.self, for: camera) @@ -58,12 +62,58 @@ public class LODSystem: @unchecked Sendable { } } + private func advanceActiveTransitions(deltaTime: Float) { + let lodId = getComponentId(for: LODComponent.self) + let entities = queryEntitiesWithComponentIds([lodId], in: scene) + let transitionDuration = max(LODConfig.shared.fadeTransitionTime, 0.001) + + for entityId in entities { + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId), + lodComponent.previousLOD != nil + else { continue } + + withWorldMutationGate { + lodComponent.transitionProgress += deltaTime / transitionDuration + + if lodComponent.transitionProgress >= 1.0 { + lodComponent.previousLOD = nil + lodComponent.transitionProgress = 0.0 + + let meshAssetID: String + if lodComponent.currentLOD >= 0, lodComponent.currentLOD < lodComponent.lodLevels.count { + meshAssetID = generateMeshAssetID( + lodLevel: lodComponent.lodLevels[lodComponent.currentLOD], + lodIndex: lodComponent.currentLOD + ) + } else { + meshAssetID = lodComponent.activeMeshAssetID + } + + let event = EntityLODChangedEvent( + entityId: entityId, + previousLODIndex: lodComponent.currentLOD, + newLODIndex: lodComponent.currentLOD, + meshAssetID: meshAssetID + ) + SystemEventBus.shared.queueLODChange(event) + } + } + } + } + private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, deltaTime: Float) { guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { return } // Skip if no LOD levels loaded yet (async loading may still be in progress) guard !lodComponent.lodLevels.isEmpty else { return } + if shouldDeferLODSelectionDuringTransition( + fadeTransitionsEnabled: LODConfig.shared.enableFadeTransitions, + previousLOD: lodComponent.previousLOD + ) { + return + } + // Calculate distance let distance = calculateDistance(entityId: entityId, cameraPosition: cameraPosition) @@ -165,7 +215,7 @@ public class LODSystem: @unchecked Sendable { return lodComponent.lodLevels.count - 1 } - private func applyLOD(entityId: EntityID, newLOD: Int, deltaTime: Float) { + private func applyLOD(entityId: EntityID, newLOD: Int, deltaTime _: Float) { guard let lodComponent = scene.get(component: LODComponent.self, for: entityId), let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { return } @@ -187,16 +237,6 @@ public class LODSystem: @unchecked Sendable { lodComponent.transitionProgress = 0.0 } - // Update transition - if lodComponent.previousLOD != nil { - lodComponent.transitionProgress += deltaTime / LODConfig.shared.fadeTransitionTime - - if lodComponent.transitionProgress >= 1.0 { - // Transition complete - lodComponent.previousLOD = nil - lodComponent.transitionProgress = 0.0 - } - } } else { // Instant switch lodComponent.currentLOD = newLOD @@ -257,3 +297,10 @@ func lodShouldRunThisFrame( // Fast-path: camera jumped far enough since last update — run immediately. return simd_distance(cameraPosition, lastCameraPosition) > displacementThreshold } + +func shouldDeferLODSelectionDuringTransition( + fadeTransitionsEnabled: Bool, + previousLOD: Int? +) -> Bool { + fadeTransitionsEnabled && previousLOD != nil +} diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index eed57b33..48204713 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -209,6 +209,9 @@ private func registerComponentCleanupHandlers() { ComponentRegistry.register(componentType: TileLODTagComponent.self, handlerId: "tileLODTag", priority: 30) { entityId in scene.remove(component: TileLODTagComponent.self, from: entityId) } + ComponentRegistry.register(componentType: TileRepresentationFadeComponent.self, handlerId: "tileRepresentationFade", priority: 30) { entityId in + scene.remove(component: TileRepresentationFadeComponent.self, from: entityId) + } ComponentRegistry.register(componentType: GizmoComponent.self, handlerId: "gizmo", priority: 30) { entityId in removeEntityGizmo(entityId: entityId) @@ -1065,14 +1068,10 @@ public func setEntityMeshAsync( let completionBox = completion.map { BoolCompletionBox(callback: $0) } Task { - // Mark as loading. Secondary assets (LOD levels, HLODs) pass blockRenderLoop:false — - // the gate is opened and immediately closed so the render loop is never stalled - // waiting for supplementary geometry. All downstream finishLoading calls are - // idempotent no-ops once the entity is already removed from the loading set. - await AssetLoadingState.shared.startLoading(entityId: entityId, filename: filename) - if !blockRenderLoop { - await AssetLoadingState.shared.finishLoading(entityId: entityId) - } + // Track progress for the whole async load. Tile streaming passes + // blockRenderLoop:false so parsing does not freeze culling; only the short + // withWorldMutationGate registration sections pause render traversal. + await AssetLoadingState.shared.startLoading(entityId: entityId, filename: filename, blockRenderLoop: blockRenderLoop) // Get URL guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { @@ -1379,6 +1378,11 @@ private struct TileEntry: Decodable { /// Absent in older (v3 uniform_grid) manifests — treated as false. let isInterior: Bool? + /// Partition-cell AABB written by the exporter. Tighter than `bounds` for + /// spanning tiles because it reflects the KD/quad-tree cell, not the mesh + /// content footprint. Used for the "Tile Bounds" debug overlay. + let cellBounds: TileBounds? + enum CodingKeys: String, CodingKey { case tileId = "tile_id" case pathRelativeToManifest = "path_relative_to_manifest" @@ -1395,6 +1399,7 @@ private struct TileEntry: Decodable { case quadtreeNodeId = "quadtree_node_id" case semanticTier = "semantic_tier" case isInterior = "interior" + case cellBounds = "cell_bounds" } } @@ -1471,7 +1476,10 @@ public func setEntityStreamScene( return } - Logger.log(message: "[setEntityStreamScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).") + Logger.log( + message: "[setEntityStreamScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).", + category: LogCategory.tileStreaming.rawValue + ) registerTiledScene( rootEntityId: rootEntityId, @@ -1513,7 +1521,10 @@ public func loadTiledScene( return } - Logger.log(message: "[loadTiledScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).") + Logger.log( + message: "[loadTiledScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).", + category: LogCategory.tileStreaming.rawValue + ) let rootEntityId = createEntity() setEntityName(entityId: rootEntityId, name: "\(manifest).root") @@ -1567,7 +1578,10 @@ public func setEntityStreamScene( return } - Logger.log(message: "[setEntityStreamScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).") + Logger.log( + message: "[setEntityStreamScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).", + category: LogCategory.tileStreaming.rawValue + ) registerTiledScene( rootEntityId: rootEntityId, @@ -1619,7 +1633,10 @@ public func loadTiledScene( return } - Logger.log(message: "[loadTiledScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).") + Logger.log( + message: "[loadTiledScene] Manifest v\(tileManifest.version) decoded — \(tileManifest.tiles.count) tile(s).", + category: LogCategory.tileStreaming.rawValue + ) let rootEntityId = createEntity() setEntityName(entityId: rootEntityId, name: "\(manifestURL.deletingPathExtension().lastPathComponent).root") @@ -1745,9 +1762,15 @@ private func registerTiledScene( if let shared = tileManifest.sharedBucket { let sharedURL = manifestDir.appendingPathComponent(shared.pathRelativeToManifest) if !FileManager.default.fileExists(atPath: sharedURL.path) { - Logger.logWarning(message: "[loadTiledScene] Shared bucket file missing: '\(shared.pathRelativeToManifest)' — skipping.") + Logger.logWarning( + message: "[loadTiledScene] Shared bucket file missing: '\(shared.pathRelativeToManifest)' — skipping.", + category: LogCategory.tileStreaming.rawValue + ) } else if shared.bounds.min.count < 3 || shared.bounds.max.count < 3 || shared.center.count < 3 { - Logger.logWarning(message: "[loadTiledScene] Shared bucket has malformed bounds — skipping.") + Logger.logWarning( + message: "[loadTiledScene] Shared bucket has malformed bounds — skipping.", + category: LogCategory.tileStreaming.rawValue + ) } else { withWorldMutationGate { let entityId = createEntity() @@ -1769,13 +1792,17 @@ private func registerTiledScene( tileComp.priority = shared.priority ?? defaults.priority tileComp.prefetchRadius = shared.prefetchRadius ?? defaults.prefetchRadius ?? 0 tileComp.tileId = shared.tileId + tileComp.isSharedBucket = true tileComp.state = .unloaded } setParent(childId: entityId, parentId: rootEntityId) OctreeSystem.shared.registerEntity(entityId) } hasSharedBucket = true - Logger.log(message: "[loadTiledScene] Shared bucket stub registered: '\(shared.tileId)'.") + Logger.log( + message: "[loadTiledScene] Shared bucket stub registered: '\(shared.tileId)'.", + category: LogCategory.tileStreaming.rawValue + ) } } @@ -1785,12 +1812,18 @@ private func registerTiledScene( max: simd_float3(iz.max[0], iz.max[1], iz.max[2]) ) GeometryStreamingSystem.shared.interiorZone = zone - Logger.log(message: "[loadTiledScene] Interior zone set: \(zone.min) → \(zone.max)") + Logger.log( + message: "[loadTiledScene] Interior zone set: \(zone.min) → \(zone.max)", + category: LogCategory.tileStreaming.rawValue + ) } let skipMsg = regState.skippedCount > 0 ? " (\(regState.skippedCount) skipped)" : "" let bucketMsg = hasSharedBucket ? " + shared bucket" : "" - Logger.log(message: "[loadTiledScene] '\(label)': \(regState.registeredCount) tile stubs registered\(skipMsg)\(bucketMsg).") + Logger.log( + message: "[loadTiledScene] '\(label)': \(regState.registeredCount) tile stubs registered\(skipMsg)\(bucketMsg).", + category: LogCategory.tileStreaming.rawValue + ) GeometryStreamingSystem.shared.buildTileHierarchyIndex() regState.completion?(true) } @@ -1807,7 +1840,10 @@ private func registerTiledScene( guard tile.bounds.min.count >= 3, tile.bounds.max.count >= 3, tile.center.count >= 3 else { - Logger.logWarning(message: "[loadTiledScene] Tile '\(tile.tileId)' has malformed bounds or center — skipping.") + Logger.logWarning( + message: "[loadTiledScene] Tile '\(tile.tileId)' has malformed bounds or center — skipping.", + category: LogCategory.tileStreaming.rawValue + ) regState.skippedCount += 1 continue } @@ -1828,6 +1864,12 @@ private func registerTiledScene( registerSceneGraphComponent(entityId: entityId) registerComponent(entityId: entityId, componentType: TileComponent.self) if let tileComp = scene.get(component: TileComponent.self, for: entityId) { + if let cb = tile.cellBounds, cb.min.count >= 3, cb.max.count >= 3 { + tileComp.cellBounds = AABB( + min: simd_float3(cb.min[0], cb.min[1], cb.min[2]), + max: simd_float3(cb.max[0], cb.max[1], cb.max[2]) + ) + } let configuredStreamingRadius = tile.streamingRadius ?? defaults.streamingRadius let configuredUnloadRadius = tile.unloadRadius ?? defaults.unloadRadius let configuredPrefetch = tile.prefetchRadius ?? defaults.prefetchRadius ?? 0 @@ -1858,7 +1900,10 @@ private func registerTiledScene( tileComp.state = .unloaded if let tier = tile.semanticTier { let floorTag = tile.floorId.map { "floor=\($0) " } ?? "" - Logger.log(message: "[loadTiledScene] \(tile.tileId): \(floorTag)tier=\(tier) stream=\(String(format: "%.1f", configuredStreamingRadius))m") + Logger.log( + message: "[loadTiledScene] \(tile.tileId): \(floorTag)tier=\(tier) stream=\(String(format: "%.1f", configuredStreamingRadius))m", + category: LogCategory.tileStreaming.rawValue + ) } if let hlodLevels = tile.hlodLevels, let first = hlodLevels.first, let normalizedHLOD = normalizedBands.hlodSwitchDistance @@ -1929,7 +1974,8 @@ private func normalizeTileStreamingBands( if normalizedHLOD != hlodSwitchDistance || normalizedLODs != lodSwitchDistances || normalizedPrefetch != prefetchRadius { Logger.logWarning( - message: "[loadTiledScene] Normalized streaming bands for tile '\(tileId)' — prefetch=\(String(format: "%.2f", normalizedPrefetch)) hlod=\(normalizedHLOD.map { String(format: "%.2f", $0) } ?? "nil") lods=\(normalizedLODs.map { String(format: "%.2f", $0) })" + message: "[loadTiledScene] Normalized streaming bands for tile '\(tileId)' — prefetch=\(String(format: "%.2f", normalizedPrefetch)) hlod=\(normalizedHLOD.map { String(format: "%.2f", $0) } ?? "nil") lods=\(normalizedLODs.map { String(format: "%.2f", $0) })", + category: LogCategory.tileStreaming.rawValue ) } diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air index 8f5efbf8..791b2468 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index ae0c6153..e00e451b 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air index 4839145a..d79c06ba 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib index 75f2de1a..62d52efc 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air index 3cbf05e6..9aecfe59 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib index 1d3981fb..4dee1b95 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air index 8fb5c654..9df020af 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index 7aca9adb..85330803 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air index f8247509..c55c4273 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index 96a4cf23..c7a4047d 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib index e331c479..987d6ebb 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift new file mode 100644 index 00000000..1276e2ff --- /dev/null +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -0,0 +1,549 @@ +// +// EngineSettingsAPI.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +public enum LODFadeTransitionSetting: Sendable { + case enabled(duration: Float? = nil) + case disabled +} + +public enum LODProperty: Sendable { + case fadeTransitions(LODFadeTransitionSetting) + case distanceBias(Float) + case hysteresis(Float) + case updateFrameInterval(Int) + case minimumCameraDisplacement(Float) + case distanceThresholds([Float]) +} + +public func setLOD(_ property: LODProperty) { + var config = LODConfig.shared + + switch property { + case let .fadeTransitions(.enabled(duration)): + config.enableFadeTransitions = true + if let duration { + config.fadeTransitionTime = max(duration, 0.001) + } + case .fadeTransitions(.disabled): + config.enableFadeTransitions = false + case let .distanceBias(value): + config.lodBias = max(value, 0.001) + case let .hysteresis(value): + config.hysteresis = max(value, 0) + case let .updateFrameInterval(value): + config.lodUpdateFrameInterval = max(value, 1) + case let .minimumCameraDisplacement(value): + config.minimumCameraDisplacementForLODUpdate = max(value, 0) + case let .distanceThresholds(values): + config.lodDistances = values.map { max($0, 0) } + } + + LODConfig.shared = config +} + +public enum RenderingProperty: Sendable { + case antiAliasing(AntiAliasingMode) + case debugView(RenderDebugViewMode) + case postProcessing(RenderingToggle) + case wireframe(WireframeProperty) + case environment(RenderingEnvironmentProperty) +} + +public enum RenderingToggle: Sendable { + case enabled + case disabled +} + +public enum RenderingEnvironmentProperty: Sendable { + case ibl(Bool) + case visible(Bool) +} + +public enum WireframeProperty: Sendable { + case color(simd_float4) + case distanceFade(enabled: Bool, start: Float = 8.0, end: Float = 40.0, minimumAlpha: Float = 0.08) + case params(color: simd_float4, fadeEnabled: Bool = false, fadeStart: Float = 8.0, fadeEnd: Float = 40.0, minimumAlpha: Float = 0.08) +} + +public func setRendering(_ property: RenderingProperty) { + switch property { + case let .antiAliasing(mode): + antiAliasingMode = mode + case let .debugView(mode): + renderDebugViewMode = mode + case .postProcessing(.enabled): + bypassPostProcessing = false + case .postProcessing(.disabled): + bypassPostProcessing = true + case let .wireframe(property): + applyWireframeProperty(property) + case let .environment(property): + applyRenderingEnvironmentProperty(property) + } +} + +private func applyRenderingEnvironmentProperty(_ property: RenderingEnvironmentProperty) { + switch property { + case let .ibl(value): + applyIBL = value + case let .visible(value): + renderEnvironment = value + } +} + +private func applyWireframeProperty(_ property: WireframeProperty) { + wireframeRenderStateLock.lock() + let current = wireframeRenderState + wireframeRenderStateLock.unlock() + + switch property { + case let .color(color): + setWireframeParams( + color: color, + fadeEnabled: current.distanceFadeEnabled, + fadeStart: current.fadeStartDistance, + fadeEnd: current.fadeEndDistance, + minimumAlpha: current.minimumAlpha + ) + case let .distanceFade(enabled, start, end, minimumAlpha): + setWireframeParams( + color: current.color, + fadeEnabled: enabled, + fadeStart: start, + fadeEnd: end, + minimumAlpha: minimumAlpha + ) + case let .params(color, fadeEnabled, fadeStart, fadeEnd, minimumAlpha): + setWireframeParams( + color: color, + fadeEnabled: fadeEnabled, + fadeStart: fadeStart, + fadeEnd: fadeEnd, + minimumAlpha: minimumAlpha + ) + } +} + +public enum EngineProperty: Sendable { + case assetBasePath(URL?) + case metrics(EngineMetricsSetting) +} + +public enum EngineMetricsSetting: Sendable { + case enabled + case disabled +} + +public func setEngine(_ property: EngineProperty) { + switch property { + case let .assetBasePath(url): + assetBasePath = url + case .metrics(.enabled): + enableEngineMetrics = true + case .metrics(.disabled): + enableEngineMetrics = false + } +} + +public enum PostFXProperty: Sendable { + case preset(PostFXPreset) + case colorGrading(ColorGradingProperty) + case colorCorrection(ColorCorrectionProperty) + case bloomThreshold(BloomThresholdProperty) + case bloomComposite(BloomCompositeProperty) + case vignette(VignetteProperty) + case chromaticAberration(ChromaticAberrationProperty) + case depthOfField(DepthOfFieldProperty) + case ssao(SSAOProperty) +} + +public enum ColorGradingProperty: Sendable { + case enabled(Bool) + case exposure(Float) + case brightness(Float) + case contrast(Float) + case saturation(Float) + case temperature(Float) + case tint(Float) +} + +public enum ColorCorrectionProperty: Sendable { + case enabled(Bool) + case lift(simd_float3) + case gamma(simd_float3) + case gain(simd_float3) +} + +public enum BloomThresholdProperty: Sendable { + case enabled(Bool) + case threshold(Float) + case intensity(Float) +} + +public enum BloomCompositeProperty: Sendable { + case enabled(Bool) + case intensity(Float) +} + +public enum VignetteProperty: Sendable { + case enabled(Bool) + case intensity(Float) + case radius(Float) + case softness(Float) + case center(simd_float2) +} + +public enum ChromaticAberrationProperty: Sendable { + case enabled(Bool) + case intensity(Float) + case center(simd_float2) +} + +public enum DepthOfFieldProperty: Sendable { + case enabled(Bool) + case focusDistance(Float) + case focusRange(Float) + case maxBlur(Float) +} + +public enum SSAOProperty: Sendable { + case enabled(Bool) + case radius(Float) + case bias(Float) + case intensity(Float) + case quality(SSAOQuality) +} + +public func setPostFX(_ property: PostFXProperty) { + switch property { + case let .preset(preset): + PostFX.apply(preset) + case let .colorGrading(property): + applyColorGradingProperty(property) + case let .colorCorrection(property): + applyColorCorrectionProperty(property) + case let .bloomThreshold(property): + applyBloomThresholdProperty(property) + case let .bloomComposite(property): + applyBloomCompositeProperty(property) + case let .vignette(property): + applyVignetteProperty(property) + case let .chromaticAberration(property): + applyChromaticAberrationProperty(property) + case let .depthOfField(property): + applyDepthOfFieldProperty(property) + case let .ssao(property): + applySSAOProperty(property) + } +} + +public enum GeometryStreamingProperty: Sendable { + case enabled(Bool) + case tileConcurrency(Int) + case meshConcurrency(Int) + case lodConcurrency(Int) + case hlodConcurrency(Int) + case queryRadius(Float) + case floorProximityGateY(Float) + case interiorZone(AABB?) + case frustumGate(GeometryStreamingFrustumGateSetting) + case velocityLookAhead(time: Float, minSpeed: Float) + case candidateSorting(importance: Bool, occlusion: Bool) + case minimumParsedTileResidentSeconds(Double) + case timeouts(tileParse: Double, meshLoad: Double) +} + +public enum GeometryStreamingFrustumGateSetting: Sendable { + case enabled(meshPadding: Float = 5.0, tilePadding: Float = 20.0) + case disabled +} + +public func setGeometryStreaming(_ property: GeometryStreamingProperty) { + let streaming = GeometryStreamingSystem.shared + + switch property { + case let .enabled(value): + streaming.enabled = value + case let .tileConcurrency(value): + streaming.maxConcurrentTileLoads = max(value, 1) + case let .meshConcurrency(value): + streaming.maxConcurrentLoads = max(value, 1) + case let .lodConcurrency(value): + streaming.maxConcurrentLODLoads = max(value, 1) + case let .hlodConcurrency(value): + streaming.maxConcurrentHLODLoads = max(value, 1) + case let .queryRadius(value): + streaming.maxQueryRadius = max(value, 0) + case let .floorProximityGateY(value): + streaming.floorProximityGateY = max(value, 0) + case let .interiorZone(value): + streaming.interiorZone = value + case let .frustumGate(.enabled(meshPadding, tilePadding)): + streaming.enableFrustumGate = true + streaming.frustumGatePadding = max(meshPadding, 0) + streaming.tileFrustumGatePadding = max(tilePadding, 0) + case .frustumGate(.disabled): + streaming.enableFrustumGate = false + case let .velocityLookAhead(time, minSpeed): + streaming.velocityLookAheadTime = max(time, 0) + streaming.velocityLookAheadMinSpeed = max(minSpeed, 0) + case let .candidateSorting(importance, occlusion): + streaming.enableImportanceSort = importance + streaming.enableOcclusionSort = occlusion + case let .minimumParsedTileResidentSeconds(value): + streaming.minimumParsedTileResidentSeconds = max(value, 0) + case let .timeouts(tileParse, meshLoad): + streaming.tileParseTimeoutSeconds = max(tileParse, 0) + streaming.meshLoadTimeoutSeconds = max(meshLoad, 0) + } +} + +public enum BatchingProperty: Sendable { + case enabled(Bool) + case cellSize(Float) + case runtimeTuning(RuntimeBatchingTuning) + case maxDirtyCellsPerTick(Int) + case retireDelayFrames(Int) + case maxRetirementsPerTick(Int) + case backgroundArtifactBuild(Bool) + case visibilityGatedBuild(Bool) + case maxBuildDispatchesPerTick(Int) + case maxArtifactAppliesPerTick(Int) + case rebuildBudgets(vertices: Int, indices: Int, bytes: Int) + case runtimeCellLimits(vertices: Int, indices: Int, bytes: Int) + case quiescenceFramesBeforeBuild(Int) + case recentVisibilityWindowFrames(Int) +} + +public func setBatching(_ property: BatchingProperty) { + let batching = BatchingSystem.shared + + switch property { + case let .enabled(value): + batching.setEnabled(value) + case let .cellSize(value): + batching.setBatchCellSize(value) + case let .runtimeTuning(value): + batching.applyRuntimeBatchingTuning(value) + case let .maxDirtyCellsPerTick(value): + batching.setMaxDirtyCellsPerTick(value) + case let .retireDelayFrames(value): + batching.setBatchRetireDelayFrames(value) + case let .maxRetirementsPerTick(value): + batching.setMaxRetirementsPerTick(value) + case let .backgroundArtifactBuild(value): + batching.setBackgroundArtifactBuildEnabled(value) + case let .visibilityGatedBuild(value): + batching.setVisibilityGatedBatchBuildEnabled(value) + case let .maxBuildDispatchesPerTick(value): + batching.setMaxBuildDispatchesPerTick(value) + case let .maxArtifactAppliesPerTick(value): + batching.setMaxArtifactAppliesPerTick(value) + case let .rebuildBudgets(vertices, indices, bytes): + batching.setMaxRebuildVerticesPerTick(vertices) + batching.setMaxRebuildIndicesPerTick(indices) + batching.setMaxRebuildBufferBytesPerTick(bytes) + case let .runtimeCellLimits(vertices, indices, bytes): + batching.setMaxRuntimeCellVertices(vertices) + batching.setMaxRuntimeCellIndices(indices) + batching.setMaxRuntimeCellBufferBytes(bytes) + case let .quiescenceFramesBeforeBuild(value): + batching.setQuiescenceFramesBeforeBatchBuild(value) + case let .recentVisibilityWindowFrames(value): + batching.setRecentVisibilityWindowFrames(value) + } +} + +public enum SpatialDebugProperty: Sendable { + case disabled + case octreeLeafBounds(SpatialDebugOctreeLeafBoundsSetting) + case tileBounds(enabled: Bool, maxTileNodeCount: Int = 500) + case staticBatchCellBounds(enabled: Bool, maxCellCount: Int = 2000, colorMode: SpatialDebugBatchCellColorMode = .plain) + case lodLevels(Bool) + case textureStreamingTiers(Bool) +} + +public enum SpatialDebugOctreeLeafBoundsSetting: Sendable { + case enabled(maxLeafNodeCount: Int = 2000, occupiedOnly: Bool = true, colorMode: SpatialDebugLeafColorMode = .plain) + case disabled +} + +public func setSpatialDebug(_ property: SpatialDebugProperty) { + switch property { + case .disabled: + disableSpatialDebugVisualization() + case let .octreeLeafBounds(.enabled(maxLeafNodeCount, occupiedOnly, colorMode)): + setOctreeLeafBoundsDebug( + enabled: true, + maxLeafNodeCount: maxLeafNodeCount, + occupiedOnly: occupiedOnly, + colorMode: colorMode + ) + case .octreeLeafBounds(.disabled): + setOctreeLeafBoundsDebug(enabled: false) + case let .tileBounds(enabled, maxTileNodeCount): + setTileBoundsDebug(enabled: enabled, maxTileNodeCount: maxTileNodeCount) + case let .staticBatchCellBounds(enabled, maxCellCount, colorMode): + setStaticBatchCellBoundsDebug(enabled: enabled, maxCellCount: maxCellCount, colorMode: colorMode) + case let .lodLevels(value): + setLODLevelDebug(enabled: value) + case let .textureStreamingTiers(value): + setTextureStreamingTierDebug(enabled: value) + } +} + +public enum LoggerProperty: Sendable { + case level(LogLevel) + case category(LogCategory, Bool) + case categories([LogCategory], Bool) + case resetCategories +} + +public func setLogger(_ property: LoggerProperty) { + switch property { + case let .level(value): + Logger.logLevel = value + case let .category(category, enabled): + Logger.set(category: category, enabled: enabled) + case let .categories(categories, enabled): + for category in categories { + Logger.set(category: category, enabled: enabled) + } + case .resetCategories: + Logger.resetCategoryToggles() + } +} + +public enum CameraProperty: Sendable { + case active(EntityID?) + case defaultFOV(Float) + case clipPlanes(near: Float, far: Float) +} + +public func setCamera(_ property: CameraProperty) { + switch property { + case let .active(entityId): + CameraSystem.shared.activeCamera = entityId + case let .defaultFOV(value): + fov = value + case let .clipPlanes(near: nearPlane, far: farPlane): + near = nearPlane + far = farPlane + } +} + +private func applyColorGradingProperty(_ property: ColorGradingProperty) { + switch property { + case let .enabled(value): + ColorGradingParams.shared.enabled = value + case let .exposure(value): + ColorGradingParams.shared.exposure = value + case let .brightness(value): + ColorGradingParams.shared.brightness = value + case let .contrast(value): + ColorGradingParams.shared.contrast = value + case let .saturation(value): + ColorGradingParams.shared.saturation = value + case let .temperature(value): + ColorGradingParams.shared.temperature = value + case let .tint(value): + ColorGradingParams.shared.tint = value + } +} + +private func applyColorCorrectionProperty(_ property: ColorCorrectionProperty) { + switch property { + case let .enabled(value): + ColorCorrectionParams.shared.enabled = value + case let .lift(value): + ColorCorrectionParams.shared.lift = value + case let .gamma(value): + ColorCorrectionParams.shared.gamma = value + case let .gain(value): + ColorCorrectionParams.shared.gain = value + } +} + +private func applyBloomThresholdProperty(_ property: BloomThresholdProperty) { + switch property { + case let .enabled(value): + BloomThresholdParams.shared.enabled = value + case let .threshold(value): + BloomThresholdParams.shared.threshold = value + case let .intensity(value): + BloomThresholdParams.shared.intensity = value + } +} + +private func applyBloomCompositeProperty(_ property: BloomCompositeProperty) { + switch property { + case let .enabled(value): + BloomCompositeParams.shared.enabled = value + case let .intensity(value): + BloomCompositeParams.shared.intensity = value + } +} + +private func applyVignetteProperty(_ property: VignetteProperty) { + switch property { + case let .enabled(value): + VignetteParams.shared.enabled = value + case let .intensity(value): + VignetteParams.shared.intensity = value + case let .radius(value): + VignetteParams.shared.radius = value + case let .softness(value): + VignetteParams.shared.softness = value + case let .center(value): + VignetteParams.shared.center = value + } +} + +private func applyChromaticAberrationProperty(_ property: ChromaticAberrationProperty) { + switch property { + case let .enabled(value): + ChromaticAberrationParams.shared.enabled = value + case let .intensity(value): + ChromaticAberrationParams.shared.intensity = value + case let .center(value): + ChromaticAberrationParams.shared.center = value + } +} + +private func applyDepthOfFieldProperty(_ property: DepthOfFieldProperty) { + switch property { + case let .enabled(value): + DepthOfFieldParams.shared.enabled = value + case let .focusDistance(value): + DepthOfFieldParams.shared.focusDistance = value + case let .focusRange(value): + DepthOfFieldParams.shared.focusRange = value + case let .maxBlur(value): + DepthOfFieldParams.shared.maxBlur = value + } +} + +private func applySSAOProperty(_ property: SSAOProperty) { + switch property { + case let .enabled(value): + SSAOParams.shared.enabled = value + case let .radius(value): + SSAOParams.shared.radius = value + case let .bias(value): + SSAOParams.shared.bias = value + case let .intensity(value): + SSAOParams.shared.intensity = value + case let .quality(value): + SSAOParams.shared.quality = value + } +} diff --git a/Sources/UntoldEngine/Utils/Globals.swift b/Sources/UntoldEngine/Utils/Globals.swift index a9ff3be3..2da5c6a9 100644 --- a/Sources/UntoldEngine/Utils/Globals.swift +++ b/Sources/UntoldEngine/Utils/Globals.swift @@ -253,10 +253,21 @@ var timePassedSinceLastFrame: Float { set { RuntimeGlobalsStore.shared.timePassedSinceLastFrame = newValue } } -// Frustum info -public let far: Float = 500 -public let near: Float = 0.1 -public let fov: Float = 65.0 +/// Frustum info +public var far: Float { + get { RuntimeGlobalsStore.shared.cameraFarPlane } + set { RuntimeGlobalsStore.shared.cameraFarPlane = max(newValue, RuntimeGlobalsStore.shared.cameraNearPlane + 0.001) } +} + +public var near: Float { + get { RuntimeGlobalsStore.shared.cameraNearPlane } + set { RuntimeGlobalsStore.shared.cameraNearPlane = max(newValue, 0.0001) } +} + +public var fov: Float { + get { RuntimeGlobalsStore.shared.cameraDefaultFOV } + set { RuntimeGlobalsStore.shared.cameraDefaultFOV = min(max(newValue, 1.0), 179.0) } +} // Shadow max parameters (legacy single-cascade — kept for reference) let shadowMaxWidth: Float = 300.0 @@ -730,6 +741,9 @@ private final class RuntimeGlobalsStore: @unchecked Sendable { private var bypassPostProcessingValue: Bool = false private var antiAliasingModeValue: AntiAliasingMode = .fxaa private var renderDebugViewModeValue: RenderDebugViewMode = .lit + private var cameraDefaultFOVValue: Float = 65.0 + private var cameraNearPlaneValue: Float = 0.1 + private var cameraFarPlaneValue: Float = 500.0 private var entityMeshMapValue: [EntityID: [Mesh]] = [:] private var entityNameMapValue: [EntityID: String] = [:] private var reverseEntityNameMapValue: [String: [EntityID]] = [:] @@ -1254,6 +1268,51 @@ private final class RuntimeGlobalsStore: @unchecked Sendable { lock.unlock() } } + + var cameraDefaultFOV: Float { + get { + lock.lock() + let value = cameraDefaultFOVValue + lock.unlock() + return value + } + set { + lock.lock() + cameraDefaultFOVValue = newValue + lock.unlock() + } + } + + var cameraNearPlane: Float { + get { + lock.lock() + let value = cameraNearPlaneValue + lock.unlock() + return value + } + set { + lock.lock() + cameraNearPlaneValue = newValue + if cameraFarPlaneValue <= cameraNearPlaneValue { + cameraFarPlaneValue = cameraNearPlaneValue + 0.001 + } + lock.unlock() + } + } + + var cameraFarPlane: Float { + get { + lock.lock() + let value = cameraFarPlaneValue + lock.unlock() + return value + } + set { + lock.lock() + cameraFarPlaneValue = max(newValue, cameraNearPlaneValue + 0.001) + lock.unlock() + } + } } /// ibl @@ -1693,7 +1752,7 @@ public final class SMAAParams: ObservableObject, @unchecked Sendable { } /// SSAO Quality Settings -public enum SSAOQuality: Int, CaseIterable { +public enum SSAOQuality: Int, CaseIterable, Sendable { case fast = 0 case balanced = 1 case high = 2 diff --git a/Sources/UntoldEngine/Utils/Logger.swift b/Sources/UntoldEngine/Utils/Logger.swift index c28d0ff9..87e1f369 100644 --- a/Sources/UntoldEngine/Utils/Logger.swift +++ b/Sources/UntoldEngine/Utils/Logger.swift @@ -147,6 +147,7 @@ public enum Logger { line: Int = #line) { guard logLevel.rawValue >= LogLevel.warning.rawValue else { return } + guard state.isCategoryEnabled(category) else { return } let renderedMessage = message() print("Warning: \(renderedMessage)") emit(level: .warning, message: renderedMessage, category: category, file: file, function: function, line: line) diff --git a/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift b/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift index df21ea5e..bb7c2b74 100644 --- a/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift +++ b/Sources/UntoldEngine/Utils/SpatialDebugBoundsCollector.swift @@ -117,9 +117,28 @@ public final class SpatialDebugBoundsCollector: @unchecked Sendable { guard let tc = scene.get(component: TileComponent.self, for: entityId), let local = scene.get(component: LocalTransformComponent.self, for: entityId) else { continue } - - let color = tileResidencyColor(for: tc) - let bounds = AABB(min: local.boundingBox.min, max: local.boundingBox.max) + // The shared bucket is a global asset, not a partition cell — skip it + // so it doesn't draw a scene-wide AABB over everything. + guard !tc.isSharedBucket else { continue } + // "Occupied Only": skip tiles that have nothing resident at all. + if settings.octreeLeafOccupiedOnly, + tc.state == .unloaded, tc.hlodState == .unloaded { continue } + + // Prefer cell_bounds (partition cell) over mesh content AABB so the + // drawn box matches what the Blender tile overlay shows. + let bounds = tc.cellBounds ?? AABB(min: local.boundingBox.min, max: local.boundingBox.max) + + // Respect the active color mode — tile bounds follow the same Mode picker + // that governs octree leaf cells so both visualizations stay consistent. + let color: simd_float4 + switch settings.octreeLeafColorMode { + case .residency: + color = tileResidencyColor(for: tc) + case .plain, .culling: + // Culling is octree-cell specific; tile stubs have no RenderComponent. + // Both plain and culling modes show a neutral wireframe for tile cells. + color = defaultOctreeColor + } snapshot.tileBounds.append(SpatialDebugBound(bounds: bounds, color: color)) } } diff --git a/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift b/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift index ac75ad3d..76e6ec85 100644 --- a/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift +++ b/Sources/UntoldEngine/Utils/SpatialDebugVisualization.swift @@ -10,13 +10,13 @@ import Foundation -public enum SpatialDebugLeafColorMode: String { +public enum SpatialDebugLeafColorMode: String, Sendable { case plain case residency case culling } -public enum SpatialDebugBatchCellColorMode: String { +public enum SpatialDebugBatchCellColorMode: String, Sendable { case plain case culling case lod diff --git a/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift index 3b6ee418..9cdeca6c 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift @@ -474,7 +474,7 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { XCTAssertTrue(childrenClear, "No HLOD child entity should survive after cancellation") } - func testHLOD_unloadedWhenFullTileParsed() async throws { + func testHLOD_unloadedAfterFullTileIsRenderVisible() async throws { let fixture = try makeUntoldTileSceneFixture(includeHLOD: true, includeLOD: false) try loadSceneManifest(at: fixture.manifestURL) @@ -486,16 +486,23 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { } XCTAssertTrue(hlodLoaded, "HLOD should be resident before full tile parse") - // Parsing the full tile calls unloadHLOD internally on success. + // Parsing the full tile keeps HLOD resident until LOD0 appears in the + // render-visible set. GeometryStreamingSystem.shared.loadTile(entityId: tileEntityId) let tileParsed = await waitUntil(timeout: 5.0) { scene.get(component: TileComponent.self, for: tileEntityId)?.state == .parsed } XCTAssertTrue(tileParsed, "Full tile should reach .parsed state") - let tc = try XCTUnwrap(scene.get(component: TileComponent.self, for: tileEntityId)) - XCTAssertEqual(tc.hlodState, .unloaded, "HLOD must be unloaded automatically when full tile parses") - XCTAssertNil(tc.hlodEntityId, "HLOD entity reference must be nil after full tile parses") + let parsedTC = try XCTUnwrap(scene.get(component: TileComponent.self, for: tileEntityId)) + XCTAssertEqual(parsedTC.hlodState, .loaded, "HLOD should remain until LOD0 is visible") + + visibleEntityIds = Array(GeometryStreamingSystem.shared.collectRenderDescendantIds(tileEntityId)) + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let releasedTC = try XCTUnwrap(scene.get(component: TileComponent.self, for: tileEntityId)) + XCTAssertEqual(releasedTC.hlodState, .unloaded, "HLOD must unload after LOD0 enters the visible set") + XCTAssertNil(releasedTC.hlodEntityId, "HLOD entity reference must be nil after LOD0 handoff") } func testHLOD_doubleUnloadIsNoOp() throws { diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index e8d0794e..f90010ee 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -86,6 +86,7 @@ final class StaticBatchingTest: BaseRenderSetup { } private func setVisibleEntitiesForSpatialDebug(_ entities: [EntityID]) { + cullFrameIndex &+= 1 visibleEntityIds = entities for frame in 0 ..< 3 { tripleVisibleEntities.setWrite(frame: frame, with: entities) diff --git a/Tests/UntoldEngineRenderTests/StreamingGateTests.swift b/Tests/UntoldEngineRenderTests/StreamingGateTests.swift index ba4f181e..2c419018 100644 --- a/Tests/UntoldEngineRenderTests/StreamingGateTests.swift +++ b/Tests/UntoldEngineRenderTests/StreamingGateTests.swift @@ -23,13 +23,29 @@ import XCTest final class StreamingGateTests: BaseRenderSetup { override func setUp() async throws { try await super.setUp() + destroyAllEntities() GeometryStreamingSystem.shared.reset() GeometryStreamingSystem.shared.enabled = true + GeometryStreamingSystem.shared.maxConcurrentLoads = 3 GeometryStreamingSystem.shared.updateInterval = 0.0 + GeometryStreamingSystem.shared.maxQueryRadius = 500.0 + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2 + GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 + GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4 + GeometryStreamingSystem.shared.tileParseMemoryBudgetMB = 200.0 + GeometryStreamingSystem.shared.velocitySmoothing = 0.85 + GeometryStreamingSystem.shared.velocityLookAheadTime = 0.5 + GeometryStreamingSystem.shared.velocityLookAheadMinSpeed = 1.5 + GeometryStreamingSystem.shared.floorProximityGateY = 5.0 + GeometryStreamingSystem.shared.enableImportanceSort = true GeometryStreamingSystem.shared.enableFrustumGate = false // disabled by default; re-enabled per test GeometryStreamingSystem.shared.tileParseTimeoutSeconds = 60.0 + LODConfig.shared = LODConfig() + OctreeSystem.shared.clear() MemoryBudgetManager.shared.clear() MemoryBudgetManager.shared.enabled = true + MemoryBudgetManager.shared.highWaterMark = 0.85 + MemoryBudgetManager.shared.lowWaterMark = 0.70 MemoryBudgetManager.shared.geometryBudget = 512 * 1024 * 1024 MemoryBudgetManager.shared.textureBudget = 256 * 1024 * 1024 } @@ -37,10 +53,25 @@ final class StreamingGateTests: BaseRenderSetup { override func tearDown() async throws { GeometryStreamingSystem.shared.reset() GeometryStreamingSystem.shared.enabled = false + GeometryStreamingSystem.shared.maxConcurrentLoads = 3 + GeometryStreamingSystem.shared.maxQueryRadius = 500.0 + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2 + GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 + GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4 + GeometryStreamingSystem.shared.tileParseMemoryBudgetMB = 200.0 + GeometryStreamingSystem.shared.velocitySmoothing = 0.85 + GeometryStreamingSystem.shared.velocityLookAheadTime = 0.5 + GeometryStreamingSystem.shared.velocityLookAheadMinSpeed = 1.5 + GeometryStreamingSystem.shared.floorProximityGateY = 5.0 + GeometryStreamingSystem.shared.enableImportanceSort = true GeometryStreamingSystem.shared.enableFrustumGate = true // restore default GeometryStreamingSystem.shared.interiorZone = nil + LODConfig.shared = LODConfig() CameraSystem.shared.activeCamera = nil + OctreeSystem.shared.clear() MemoryBudgetManager.shared.clear() + MemoryBudgetManager.shared.highWaterMark = 0.85 + MemoryBudgetManager.shared.lowWaterMark = 0.70 LoadingSystem.shared.resourceURLFn = getResourceURL destroyAllEntities() try await super.tearDown() diff --git a/Tests/UntoldEngineRenderTests/TileStreamingTests.swift b/Tests/UntoldEngineRenderTests/TileStreamingTests.swift index 45b2eecf..47a886ed 100644 --- a/Tests/UntoldEngineRenderTests/TileStreamingTests.swift +++ b/Tests/UntoldEngineRenderTests/TileStreamingTests.swift @@ -667,11 +667,15 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { override func setUp() async throws { try await super.setUp() + destroyAllEntities() GeometryStreamingSystem.shared.reset() GeometryStreamingSystem.shared.enabled = true GeometryStreamingSystem.shared.updateInterval = 0.0 // no throttle + LODConfig.shared = LODConfig() GeometryStreamingSystem.shared.lodHysteresisFactor = hysteresisFactor + GeometryStreamingSystem.shared.hlodHysteresisFactor = hysteresisFactor GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 + GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4 GeometryStreamingSystem.shared.maxConcurrentTileLoads = 0 GeometryStreamingSystem.shared.maxQueryRadius = 500.0 @@ -686,7 +690,12 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { GeometryStreamingSystem.shared.reset() GeometryStreamingSystem.shared.enabled = false GeometryStreamingSystem.shared.updateInterval = 0.1 + LODConfig.shared = LODConfig() + GeometryStreamingSystem.shared.lodHysteresisFactor = 0.90 + GeometryStreamingSystem.shared.hlodHysteresisFactor = 0.90 GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2 + GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 + GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4 OctreeSystem.shared.clear() MemoryBudgetManager.shared.clear() try await super.tearDown() @@ -758,6 +767,20 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { return (entityId, tileComp) } + private func attachVisibleRenderRepresentation(parentId: EntityID) -> EntityID { + let rootId = createEntity() + registerSceneGraphComponent(entityId: rootId) + setParent(childId: rootId, parentId: parentId) + + let renderId = createEntity() + registerSceneGraphComponent(entityId: renderId) + _ = scene.assign(to: renderId, component: RenderComponent.self) + setParent(childId: renderId, parentId: rootId) + + visibleEntityIds.append(renderId) + return rootId + } + // MARK: Tests /// Camera within hysteresis band [hysteresisThreshold, switchDistance): @@ -1029,6 +1052,27 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { XCTAssertNotEqual(hlodTileComp.hlodState, .unloaded, "Far HLOD coverage should still be admitted when HLOD capacity is free") } + func testHLODReplacement_unloadsVisibleLODWhenHLODIsVisible() { + let (tileEntityId, tileComp) = makeTileEntity( + distance: 150, + lodSwitchDistance: 50, + initialLODState: .loaded, + hlodSwitchDistance: 100, + initialHLODState: .loaded + ) + + tileComp.lodLevels[0].entityId = attachVisibleRenderRepresentation(parentId: tileEntityId) + tileComp.hlodEntityId = attachVisibleRenderRepresentation(parentId: tileEntityId) + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertEqual(tileComp.hlodState, .loaded, "HLOD should remain loaded as far-field coverage") + XCTAssertEqual(tileComp.lodLevels[0].state, .unloaded, "Visible LOD should unload once visible HLOD coverage exists") + } + /// Inactive LOD level (state = .unloaded) at dist inside the hysteresis band: /// should NOT be loaded (the band is only for keeping an active level, not for /// loading a new one; a fresh activation requires dist >= raw switchDistance). diff --git a/Tests/UntoldEngineTests/EngineSettingsAPITests.swift b/Tests/UntoldEngineTests/EngineSettingsAPITests.swift new file mode 100644 index 00000000..648afe0e --- /dev/null +++ b/Tests/UntoldEngineTests/EngineSettingsAPITests.swift @@ -0,0 +1,306 @@ +// +// EngineSettingsAPITests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd +@testable import UntoldEngine +import XCTest + +@MainActor +final class EngineSettingsAPITests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + LODConfig.shared = LODConfig() + bypassPostProcessing = false + antiAliasingMode = .fxaa + renderDebugViewMode = .lit + applyIBL = false + renderEnvironment = false + setCamera(.defaultFOV(65.0)) + setCamera(.clipPlanes(near: 0.1, far: 500.0)) + assetBasePath = nil + enableEngineMetrics = false + Logger.logLevel = .debug + Logger.resetCategoryToggles() + GeometryStreamingSystem.shared.enabled = true + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2 + GeometryStreamingSystem.shared.maxConcurrentLoads = 3 + GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 + GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4 + GeometryStreamingSystem.shared.enableFrustumGate = true + GeometryStreamingSystem.shared.frustumGatePadding = 5.0 + GeometryStreamingSystem.shared.tileFrustumGatePadding = 20.0 + GeometryStreamingSystem.shared.maxQueryRadius = 500.0 + GeometryStreamingSystem.shared.floorProximityGateY = 5.0 + GeometryStreamingSystem.shared.interiorZone = nil + GeometryStreamingSystem.shared.velocityLookAheadTime = 0.5 + GeometryStreamingSystem.shared.velocityLookAheadMinSpeed = 1.5 + GeometryStreamingSystem.shared.enableImportanceSort = true + GeometryStreamingSystem.shared.enableOcclusionSort = true + GeometryStreamingSystem.shared.minimumParsedTileResidentSeconds = 8.0 + GeometryStreamingSystem.shared.tileParseTimeoutSeconds = 60.0 + GeometryStreamingSystem.shared.meshLoadTimeoutSeconds = 60.0 + SpatialDebugVisualization.shared.disableAll() + PostFX.apply(.neutral) + PostFX.setEnabled(.vignette, false) + PostFX.setEnabled(.bloomThreshold, false) + PostFX.setEnabled(.bloomComposite, false) + PostFX.setEnabled(.chromaticAberration, false) + PostFX.setEnabled(.depthOfField, false) + PostFX.setEnabled(.colorCorrection, false) + } + + func testSetLODUpdatesSharedConfig() { + setLOD(.fadeTransitions(.enabled(duration: 0.42))) + setLOD(.distanceBias(1.5)) + setLOD(.hysteresis(3.0)) + setLOD(.updateFrameInterval(0)) + setLOD(.minimumCameraDisplacement(-1)) + setLOD(.distanceThresholds([25, -10, 100])) + + let config = LODConfig.shared + XCTAssertTrue(config.enableFadeTransitions) + XCTAssertEqual(config.fadeTransitionTime, 0.42, accuracy: 0.001) + XCTAssertEqual(config.lodBias, 1.5, accuracy: 0.001) + XCTAssertEqual(config.hysteresis, 3.0, accuracy: 0.001) + XCTAssertEqual(config.lodUpdateFrameInterval, 1) + XCTAssertEqual(config.minimumCameraDisplacementForLODUpdate, 0, accuracy: 0.001) + XCTAssertEqual(config.lodDistances, [25, 0, 100]) + + setLOD(.fadeTransitions(.disabled)) + XCTAssertFalse(LODConfig.shared.enableFadeTransitions) + } + + func testSetRenderingUpdatesGlobals() { + setRendering(.antiAliasing(.smaa)) + if case .smaa = antiAliasingMode {} else { + XCTFail("Expected SMAA anti-aliasing mode") + } + + setRendering(.debugView(.depth)) + if case .depth = renderDebugViewMode {} else { + XCTFail("Expected depth debug view") + } + + setRendering(.postProcessing(.disabled)) + XCTAssertTrue(bypassPostProcessing) + + setRendering(.postProcessing(.enabled)) + XCTAssertFalse(bypassPostProcessing) + + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(true))) + XCTAssertTrue(applyIBL) + XCTAssertTrue(renderEnvironment) + + setRendering(.environment(.ibl(false))) + setRendering(.environment(.visible(false))) + XCTAssertFalse(applyIBL) + XCTAssertFalse(renderEnvironment) + } + + func testSetEngineUpdatesGlobals() { + let url = URL(fileURLWithPath: "/tmp/GameData") + + setEngine(.assetBasePath(url)) + setEngine(.metrics(.enabled)) + + XCTAssertEqual(assetBasePath, url) + XCTAssertTrue(enableEngineMetrics) + + setEngine(.metrics(.disabled)) + XCTAssertFalse(enableEngineMetrics) + } + + func testSetPostFXUpdatesEffectParams() { + setPostFX(.ssao(.enabled(true))) + setPostFX(.ssao(.radius(0.8))) + setPostFX(.ssao(.bias(0.04))) + setPostFX(.ssao(.intensity(0.7))) + + XCTAssertTrue(SSAOParams.shared.enabled) + XCTAssertEqual(SSAOParams.shared.radius, 0.8, accuracy: 0.001) + XCTAssertEqual(SSAOParams.shared.bias, 0.04, accuracy: 0.001) + XCTAssertEqual(SSAOParams.shared.intensity, 0.7, accuracy: 0.001) + + setPostFX(.colorGrading(.enabled(true))) + setPostFX(.colorGrading(.exposure(-0.2))) + setPostFX(.colorGrading(.saturation(0.9))) + + XCTAssertTrue(ColorGradingParams.shared.enabled) + XCTAssertEqual(ColorGradingParams.shared.exposure, -0.2, accuracy: 0.001) + XCTAssertEqual(ColorGradingParams.shared.saturation, 0.9, accuracy: 0.001) + + setPostFX(.vignette(.enabled(true))) + setPostFX(.vignette(.intensity(0.5))) + setPostFX(.vignette(.center(simd_float2(0.4, 0.6)))) + + XCTAssertTrue(VignetteParams.shared.enabled) + XCTAssertEqual(VignetteParams.shared.intensity, 0.5, accuracy: 0.001) + XCTAssertEqual(VignetteParams.shared.center.x, 0.4, accuracy: 0.001) + XCTAssertEqual(VignetteParams.shared.center.y, 0.6, accuracy: 0.001) + } + + func testSetPostFXPresetAppliesPreset() { + setPostFX(.preset(.cinematic)) + + XCTAssertTrue(ColorGradingParams.shared.enabled) + XCTAssertEqual(ColorGradingParams.shared.exposure, -0.2, accuracy: 0.001) + XCTAssertTrue(SSAOParams.shared.enabled) + XCTAssertEqual(SSAOParams.shared.intensity, 0.5, accuracy: 0.001) + } + + func testSetGeometryStreamingUpdatesStreamingSystem() { + let zone = AABB(min: simd_float3(-1, -2, -3), max: simd_float3(1, 2, 3)) + + setGeometryStreaming(.enabled(false)) + setGeometryStreaming(.tileConcurrency(0)) + setGeometryStreaming(.meshConcurrency(0)) + setGeometryStreaming(.lodConcurrency(0)) + setGeometryStreaming(.hlodConcurrency(0)) + setGeometryStreaming(.queryRadius(-10)) + setGeometryStreaming(.floorProximityGateY(-4)) + setGeometryStreaming(.interiorZone(zone)) + setGeometryStreaming(.frustumGate(.enabled(meshPadding: -2, tilePadding: 12))) + setGeometryStreaming(.velocityLookAhead(time: -1, minSpeed: 3)) + setGeometryStreaming(.candidateSorting(importance: false, occlusion: false)) + setGeometryStreaming(.minimumParsedTileResidentSeconds(-1)) + setGeometryStreaming(.timeouts(tileParse: -5, meshLoad: 9)) + + let streaming = GeometryStreamingSystem.shared + XCTAssertFalse(streaming.enabled) + XCTAssertEqual(streaming.maxConcurrentTileLoads, 1) + XCTAssertEqual(streaming.maxConcurrentLoads, 1) + XCTAssertEqual(streaming.maxConcurrentLODLoads, 1) + XCTAssertEqual(streaming.maxConcurrentHLODLoads, 1) + XCTAssertEqual(streaming.maxQueryRadius, 0, accuracy: 0.001) + XCTAssertEqual(streaming.floorProximityGateY, 0, accuracy: 0.001) + XCTAssertEqual(streaming.interiorZone?.min.x ?? 0, -1, accuracy: 0.001) + XCTAssertTrue(streaming.enableFrustumGate) + XCTAssertEqual(streaming.frustumGatePadding, 0, accuracy: 0.001) + XCTAssertEqual(streaming.tileFrustumGatePadding, 12, accuracy: 0.001) + XCTAssertEqual(streaming.velocityLookAheadTime, 0, accuracy: 0.001) + XCTAssertEqual(streaming.velocityLookAheadMinSpeed, 3, accuracy: 0.001) + XCTAssertFalse(streaming.enableImportanceSort) + XCTAssertFalse(streaming.enableOcclusionSort) + XCTAssertEqual(streaming.minimumParsedTileResidentSeconds, 0, accuracy: 0.001) + XCTAssertEqual(streaming.tileParseTimeoutSeconds, 0, accuracy: 0.001) + XCTAssertEqual(streaming.meshLoadTimeoutSeconds, 9, accuracy: 0.001) + + setGeometryStreaming(.frustumGate(.disabled)) + XCTAssertFalse(streaming.enableFrustumGate) + } + + func testSetBatchingUpdatesRuntimeTuning() { + setBatching(.enabled(true)) + setBatching(.cellSize(24)) + setBatching(.maxDirtyCellsPerTick(0)) + setBatching(.retireDelayFrames(0)) + setBatching(.maxRetirementsPerTick(0)) + setBatching(.backgroundArtifactBuild(false)) + setBatching(.visibilityGatedBuild(false)) + setBatching(.maxBuildDispatchesPerTick(0)) + setBatching(.maxArtifactAppliesPerTick(0)) + setBatching(.rebuildBudgets(vertices: 0, indices: 0, bytes: 0)) + setBatching(.runtimeCellLimits(vertices: 0, indices: 0, bytes: 0)) + setBatching(.quiescenceFramesBeforeBuild(-1)) + setBatching(.recentVisibilityWindowFrames(-1)) + + let batching = BatchingSystem.shared + XCTAssertTrue(batching.isEnabled()) + XCTAssertEqual(batching.getBatchCellSize(), 24, accuracy: 0.001) + XCTAssertEqual(batching.getMaxDirtyCellsPerTick(), 1) + XCTAssertEqual(batching.getBatchRetireDelayFrames(), 1) + XCTAssertEqual(batching.getMaxRetirementsPerTick(), 1) + XCTAssertFalse(batching.isBackgroundArtifactBuildEnabled()) + XCTAssertFalse(batching.isVisibilityGatedBatchBuildEnabled()) + + let tuning = batching.getRuntimeBatchingTuning() + XCTAssertEqual(tuning.maxBuildDispatchesPerTick, 1) + XCTAssertEqual(tuning.maxArtifactAppliesPerTick, 1) + XCTAssertEqual(tuning.maxRebuildVerticesPerTick, 1) + XCTAssertEqual(tuning.maxRebuildIndicesPerTick, 1) + XCTAssertEqual(tuning.maxRebuildBufferBytesPerTick, 1) + XCTAssertEqual(tuning.maxRuntimeCellVertices, 1) + XCTAssertEqual(tuning.maxRuntimeCellIndices, 1) + XCTAssertEqual(tuning.maxRuntimeCellBufferBytes, 1) + XCTAssertEqual(tuning.quiescenceFramesBeforeBatchBuild, 0) + XCTAssertEqual(tuning.recentVisibilityWindowFrames, 0) + } + + func testSetSpatialDebugUpdatesVisualization() { + setSpatialDebug(.octreeLeafBounds(.enabled(maxLeafNodeCount: -1, occupiedOnly: false, colorMode: .residency))) + setSpatialDebug(.tileBounds(enabled: true, maxTileNodeCount: -1)) + setSpatialDebug(.staticBatchCellBounds(enabled: true, maxCellCount: -1, colorMode: .lod)) + setSpatialDebug(.lodLevels(true)) + setSpatialDebug(.textureStreamingTiers(true)) + + let debug = SpatialDebugVisualization.shared + XCTAssertTrue(debug.enabled) + XCTAssertTrue(debug.showOctreeLeafBounds) + XCTAssertEqual(debug.maxLeafNodeCount, 0) + XCTAssertFalse(debug.octreeLeafOccupiedOnly) + XCTAssertEqual(debug.octreeLeafColorMode, .residency) + XCTAssertTrue(debug.showTileBounds) + XCTAssertEqual(debug.maxTileNodeCount, 0) + XCTAssertTrue(debug.showStaticBatchCellBounds) + XCTAssertEqual(debug.maxStaticBatchCellCount, 0) + XCTAssertEqual(debug.staticBatchCellColorMode, .lod) + XCTAssertTrue(debug.colorRenderablesByLOD) + XCTAssertTrue(debug.colorRenderablesByStreamingTier) + + setSpatialDebug(.disabled) + XCTAssertFalse(debug.enabled) + XCTAssertFalse(debug.showOctreeLeafBounds) + XCTAssertFalse(debug.showTileBounds) + XCTAssertFalse(debug.showStaticBatchCellBounds) + XCTAssertFalse(debug.colorRenderablesByLOD) + XCTAssertFalse(debug.colorRenderablesByStreamingTier) + } + + func testSetLoggerUpdatesLoggerState() { + setLogger(.level(.warning)) + setLogger(.category(.tileStreaming, true)) + setLogger(.categories([.batching, .textureStreaming], true)) + + XCTAssertEqual(Logger.logLevel, .warning) + XCTAssertTrue(Logger.isEnabled(category: .tileStreaming)) + XCTAssertTrue(Logger.isEnabled(category: .batching)) + XCTAssertTrue(Logger.isEnabled(category: .textureStreaming)) + + setLogger(.resetCategories) + XCTAssertFalse(Logger.isEnabled(category: .tileStreaming)) + XCTAssertFalse(Logger.isEnabled(category: .batching)) + XCTAssertFalse(Logger.isEnabled(category: .textureStreaming)) + } + + func testSetCameraUpdatesCameraGlobals() { + let camera = createEntity() + + setCamera(.active(camera)) + setCamera(.defaultFOV(70.0)) + setCamera(.clipPlanes(near: 0.05, far: 1000.0)) + + XCTAssertEqual(CameraSystem.shared.activeCamera, camera) + XCTAssertEqual(fov, 70.0, accuracy: 0.001) + XCTAssertEqual(near, 0.05, accuracy: 0.001) + XCTAssertEqual(far, 1000.0, accuracy: 0.001) + + setCamera(.defaultFOV(200.0)) + setCamera(.clipPlanes(near: -1.0, far: 0.0)) + + XCTAssertEqual(fov, 179.0, accuracy: 0.001) + XCTAssertEqual(near, 0.0001, accuracy: 0.00001) + XCTAssertGreaterThan(far, near) + + setCamera(.active(nil)) + XCTAssertNil(CameraSystem.shared.activeCamera) + } +} diff --git a/Tests/UntoldEngineTests/LODSystemTests.swift b/Tests/UntoldEngineTests/LODSystemTests.swift index c4c2dde7..8b4a148a 100644 --- a/Tests/UntoldEngineTests/LODSystemTests.swift +++ b/Tests/UntoldEngineTests/LODSystemTests.swift @@ -199,6 +199,21 @@ final class LODSystemTests: XCTestCase { XCTAssertEqual(lodComp.transitionProgress, 0.0, "Transition progress should reset to 0") } + func testLODSelectionDefersDuringFadeTransition() { + XCTAssertTrue( + shouldDeferLODSelectionDuringTransition(fadeTransitionsEnabled: true, previousLOD: 1), + "Active fades should finish before LOD selection can retarget the entity" + ) + XCTAssertFalse( + shouldDeferLODSelectionDuringTransition(fadeTransitionsEnabled: true, previousLOD: nil), + "LOD selection should run normally when no fade is active" + ) + XCTAssertFalse( + shouldDeferLODSelectionDuringTransition(fadeTransitionsEnabled: false, previousLOD: 1), + "Instant-switch mode should not use fade-transition deferral" + ) + } + // MARK: - Array Extension Tests func testSafeArraySubscript() { diff --git a/Tests/UntoldEngineTests/LoggerCategoryTests.swift b/Tests/UntoldEngineTests/LoggerCategoryTests.swift index bd383cf3..100c2a24 100644 --- a/Tests/UntoldEngineTests/LoggerCategoryTests.swift +++ b/Tests/UntoldEngineTests/LoggerCategoryTests.swift @@ -12,8 +12,18 @@ import XCTest final class LoggerCategoryTests: XCTestCase { + private var previousLogLevel: LogLevel = .debug + + override func setUp() { + super.setUp() + previousLogLevel = Logger.logLevel + Logger.logLevel = .debug + Logger.resetCategoryToggles() + } + override func tearDown() { Logger.resetCategoryToggles() + Logger.logLevel = previousLogLevel super.tearDown() } @@ -36,4 +46,30 @@ final class LoggerCategoryTests: XCTestCase { XCTAssertFalse(Logger.isEnabled(category: .textureLoading)) XCTAssertFalse(Logger.isEnabled(category: .streamingHeartbeat)) } + + func testWarningsRespectCategoryToggles() { + Logger.resetCategoryToggles() + + var disabledWarningEvaluated = false + Logger.logWarning( + message: { + disabledWarningEvaluated = true + return "disabled tile streaming warning" + }(), + category: LogCategory.tileStreaming.rawValue + ) + XCTAssertFalse(disabledWarningEvaluated) + + Logger.enable(category: .tileStreaming) + + var enabledWarningEvaluated = false + Logger.logWarning( + message: { + enabledWarningEvaluated = true + return "enabled tile streaming warning" + }(), + category: LogCategory.tileStreaming.rawValue + ) + XCTAssertTrue(enabledWarningEvaluated) + } } diff --git a/Tests/UntoldEngineTests/TestEngineReset.swift b/Tests/UntoldEngineTests/TestEngineReset.swift index 5e76980a..cd61a3a8 100644 --- a/Tests/UntoldEngineTests/TestEngineReset.swift +++ b/Tests/UntoldEngineTests/TestEngineReset.swift @@ -17,6 +17,8 @@ @MainActor func resetEngineTestState() { scene = Scene() CameraSystem.shared.activeCamera = nil + setCamera(.defaultFOV(65.0)) + setCamera(.clipPlanes(near: 0.1, far: 500.0)) visibleEntityIds.removeAll() entityMeshMap.removeAll() entityNameMap.removeAll() diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index a514687a..aa586883 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -294,7 +294,7 @@ the camera after assets load: let gameCamera = createEntity() setEntityName(entityId: gameCamera, name: "Main Camera") createGameCamera(entityId: gameCamera) -CameraSystem.shared.activeCamera = gameCamera +setCamera(.active(gameCamera)) let light = createEntity() setEntityName(entityId: light, name: "Directional Light") @@ -322,7 +322,7 @@ final class GameScene { let gameCamera = createEntity() setEntityName(entityId: gameCamera, name: "Main Camera") createGameCamera(entityId: gameCamera) - CameraSystem.shared.activeCamera = gameCamera + setCamera(.active(gameCamera)) let light = createEntity() setEntityName(entityId: light, name: "Directional Light") diff --git a/docs/API/SpatialDebugger.md b/docs/API/SpatialDebugger.md index 7132f6fb..88b4a0ee 100644 --- a/docs/API/SpatialDebugger.md +++ b/docs/API/SpatialDebugger.md @@ -49,18 +49,17 @@ OctreeSystem.shared.worldBounds = AABB( ) // Enable octree debug rendering. -setOctreeLeafBoundsDebug( - enabled: true, +setSpatialDebug(.octreeLeafBounds(.enabled( maxLeafNodeCount: 0, // 0 = unlimited occupiedOnly: true, // draw only leaves containing entries colorMode: .culling -) +))) ``` Disable the spatial debugger: ``` swift -disableSpatialDebugVisualization() +setSpatialDebug(.disabled) ``` ------------------------------------------------------------------------ @@ -78,12 +77,11 @@ Useful for verifying: - entity placement inside the tree ``` swift -setOctreeLeafBoundsDebug( - enabled: true, +setSpatialDebug(.octreeLeafBounds(.enabled( maxLeafNodeCount: 0, occupiedOnly: true, colorMode: .plain -) +))) ``` ------------------------------------------------------------------------ @@ -99,12 +97,11 @@ Useful for diagnosing: - streaming thrashing ``` swift -setOctreeLeafBoundsDebug( - enabled: true, +setSpatialDebug(.octreeLeafBounds(.enabled( maxLeafNodeCount: 0, occupiedOnly: true, colorMode: .residency -) +))) ``` Color meanings: @@ -137,12 +134,11 @@ Useful for diagnosing: - visibility system behavior ``` swift -setOctreeLeafBoundsDebug( - enabled: true, +setSpatialDebug(.octreeLeafBounds(.enabled( maxLeafNodeCount: 0, occupiedOnly: true, colorMode: .culling -) +))) ``` Color meanings: @@ -170,12 +166,11 @@ moves. To visualize the full octree structure including empty regions: ``` swift -setOctreeLeafBoundsDebug( - enabled: true, +setSpatialDebug(.octreeLeafBounds(.enabled( maxLeafNodeCount: 0, occupiedOnly: false, colorMode: .residency -) +))) ``` This can help diagnose: @@ -188,14 +183,19 @@ This can help diagnose: ## API -### setOctreeLeafBoundsDebug +### setSpatialDebug - setOctreeLeafBoundsDebug( - enabled: Bool, + setSpatialDebug(.octreeLeafBounds(.enabled( maxLeafNodeCount: Int, occupiedOnly: Bool, - colorMode: SpatialDebugColorMode - ) + colorMode: SpatialDebugLeafColorMode + ))) + + setSpatialDebug(.tileBounds(enabled: Bool, maxTileNodeCount: Int)) + setSpatialDebug(.staticBatchCellBounds(enabled: Bool, maxCellCount: Int)) + setSpatialDebug(.lodLevels(Bool)) + setSpatialDebug(.textureStreamingTiers(Bool)) + setSpatialDebug(.disabled) Parameters: @@ -214,12 +214,12 @@ Available color modes: ------------------------------------------------------------------------ -### disableSpatialDebugVisualization +### Disable All Disables all spatial debugging overlays. ``` swift -disableSpatialDebugVisualization() +setSpatialDebug(.disabled) ``` ------------------------------------------------------------------------ @@ -264,7 +264,7 @@ level each renderable is currently using. Enable it with: ``` swift -setLODLevelDebug(enabled: true) +setSpatialDebug(.lodLevels(true)) ``` This mode colors renderables by their active LOD level to help diagnose: diff --git a/docs/API/UsageExamples.md b/docs/API/UsageExamples.md index 2143999b..b4ff86f0 100644 --- a/docs/API/UsageExamples.md +++ b/docs/API/UsageExamples.md @@ -115,7 +115,7 @@ Most examples need a game camera and at least one light. let camera = createEntity() setEntityName(entityId: camera, name: "Main Camera") createGameCamera(entityId: camera) -CameraSystem.shared.activeCamera = camera +setCamera(.active(camera)) moveCameraTo(entityId: camera, 0.0, 3.0, 10.0) let sun = createEntity() @@ -264,7 +264,7 @@ final class GameScene { let camera = createEntity() setEntityName(entityId: camera, name: "Main Camera") createGameCamera(entityId: camera) - CameraSystem.shared.activeCamera = camera + setCamera(.active(camera)) moveCameraTo(entityId: camera, 0.0, 3.0, 10.0) let sun = createEntity() diff --git a/docs/API/UsingBlenderAddon.md b/docs/API/UsingBlenderAddon.md index 57647e7f..4dc8e6f8 100644 --- a/docs/API/UsingBlenderAddon.md +++ b/docs/API/UsingBlenderAddon.md @@ -94,6 +94,97 @@ CityBlender/ The manifest lives beside `tile_exports`, and all paths in the manifest are relative to the manifest location. +### Visualize Tiles + +Use the viewport tile preview before exporting to check how the scene will be +partitioned, which objects become shared assets, and which LOD/HLOD +representation would be active at runtime. + +In Blender's 3D viewport, press `N` to open the sidebar, then select the +`Untold Tiles` tab. The `Tile & LOD Setup` panel exposes the same high-level +partitioning choices used by `File > Export > Untold Tiled Scene`: + +- `Uniform Grid`: a regular X/Z grid. Use this for simple outdoor scenes or + scenes where evenly sized tiles are enough. `Auto Tile Size` lets the exporter + choose a grid size from scene complexity; disabling it enables manual + `Tile Size X` and `Tile Size Z (Depth)` values. `Spanning Threshold` controls + when very large objects are placed in the shared bucket instead of a single + local tile. +- `Quadtree`: a floor-aware hierarchy that recursively subdivides dense areas. + This is usually the best starting point for multi-floor buildings. +- `KD-Tree`: a floor-aware hierarchy that splits along scene density. This can + produce better balance when objects are clustered unevenly. + +Enable `Visible Objects Only` when you want the preview to match the default +export scope. Disable it when hidden scene meshes should still participate in +the partition. + +Press `Preview Tiles` to draw the tile overlay in the viewport. In density mode, +the overlay colors mean: + +- `Green`: low object density. +- `Yellow`: medium object density. +- `Red`: high object density. +- `Blue`: shared bucket geometry, usually objects too large to belong cleanly to + one tile. + +![Tile Preview](../images/TilePreview.png) + +Use `Tile Floor Fill` to toggle translucent tile floors. Leave it off when you +want wireframe-only boxes for inspecting dense or stacked geometry. + +The panel also previews runtime LOD state. Choose a `Scene Profile` (`auto`, +`indoor`, or `outdoor`) and optionally enable `Custom Tier Radii` to override +the stream, unload, and priority values for `ExteriorShell`, +`StructuralInterior`, `RoomContents`, and `FineProps`. Enable `Generate HLOD` +or `Generate LOD` when you want the preview and export to include those payloads; +`Preview LOD Plan` reports how many payloads would be generated before any files +are written. + +To simulate runtime streaming, choose the `Distance Source`: + +- `Active Camera`: measure tile distance from the active scene camera. +- `3D Cursor`: measure from the cursor position. +- `Selected Object`: measure from the active selected object. + +Move the chosen source, then press `Preview Runtime States`. The overlay changes +from density colors to runtime colors: + +- `White`: full-detail tile, also called LOD0. +- `Cyan`: LOD1. +- `Yellow`: LOD2. +- `Orange`: HLOD. +- `Red`: unloaded. +- `Blue`: shared bucket geometry. + +![Tile Runtime Preview](../images/PreviewRuntimeLOD.png) + +For heavy scenes, use `Set Meshes To Bounds` to draw meshes as bounding boxes +while keeping them exportable. `Hide Meshes` hides scene meshes after a preview +so the tile overlay is easier to inspect, and `Restore` returns the saved +viewport display state. If `Visible Objects Only` is enabled, restore hidden +meshes before running another preview or export so they are included. + +The `Object Override` area lets you toggle `Force Local` on the active mesh. Use +this when an object is being classified into the shared bucket but should instead +belong to a regular tile and receive its own LOD/HLOD ladder. + +### Object Annotations + +Select a mesh object and open `Object Properties > Untold` to author +mesh-level hints used by tiled scene export. + +- `Object Semantic`: choose `Auto`, `ExteriorShell`, `StructuralInterior`, + `RoomContents`, or `FineProps`. These are mesh semantics; the exporter groups + meshes by spatial node and semantic tier, then writes the generated tile's + `semantic_tier` in the manifest. +- `Priority Hint`: choose `Auto`, `Low`, `Normal`, `High`, or `Critical`. This + is aggregated into the generated tile's manifest `priority`; when all objects + are `Auto`, the semantic tier default priority is used. + +Tiled scene export supports static mesh geometry only. Armatures and meshes +bound to armatures should be exported through the animation workflow instead. + ### Tiled Scene Options - `Visible Objects Only`: export only visible mesh objects. @@ -108,6 +199,25 @@ relative to the manifest location. - `Dry Run`: analyze the partition without writing tile payloads. - `Write Manifest In Dry Run`: write the manifest JSON even during a dry run. +The `Tile Preview` panel in the 3D viewport also includes LOD planning controls. +Use `Preview LOD Plan` to report how many HLOD/LOD payloads the current tiled +export settings will generate before writing files. For quadtree and KD-tree +exports, LOD/HLOD generation is limited to eligible semantic tiers such as +`ExteriorShell` and `StructuralInterior`; `RoomContents` and `FineProps` +normally stream at close range and are skipped. + +Use `Preview Runtime Bands` to color the current tile overlay by camera, +3D cursor, or selected-object distance. Runtime colors show the representation +that would be active inside each tile's distance band: full tile, LOD, HLOD, +unloaded, or shared bucket. + +For very large scenes, use the Tile Preview panel's viewport utilities to reduce +Blender viewport load while keeping mesh data available for export. `Set Meshes +To Bounds` draws mesh objects as bounding boxes. `Hide Meshes` hides them in the +viewport after a preview has been generated, and `Restore` returns the saved +display state. Restore the meshes before rerunning preview/export when `Visible +Objects Only` is enabled. + ### Uniform Grid Use `Partitioning > Uniform Grid` for regular X/Y/Z grid tiles. diff --git a/docs/API/UsingCameraSystem.md b/docs/API/UsingCameraSystem.md index cb18fdbb..7f12b5d0 100644 --- a/docs/API/UsingCameraSystem.md +++ b/docs/API/UsingCameraSystem.md @@ -8,11 +8,18 @@ For gameplay, always use the game camera (not the editor/scene camera). Call `fi ```swift let camera = findGameCamera() -CameraSystem.shared.activeCamera = camera +setCamera(.active(camera)) ``` If no game camera exists, `findGameCamera()` creates one and sets it up with default values. +Configure global projection defaults at startup: + +```swift +setCamera(.defaultFOV(70.0)) +setCamera(.clipPlanes(near: 0.1, far: 1000.0)) +``` + ## Translate (Move) the Camera Use absolute or relative movement: @@ -133,6 +140,6 @@ startCameraPath(waypoints: waypoints, mode: .once, settings: settings) ## Notes -- `startCameraPath` and `updateCameraPath` operate on `CameraSystem.shared.activeCamera`. +- `startCameraPath` and `updateCameraPath` operate on the active camera set with `setCamera(.active(...))`. - `segmentDuration` is the time to move from the current waypoint to the next. - For gameplay, always acquire the camera with `findGameCamera()` and set it active before path playback or follow logic. diff --git a/docs/API/UsingEngineSettings.md b/docs/API/UsingEngineSettings.md new file mode 100644 index 00000000..d1f62a50 --- /dev/null +++ b/docs/API/UsingEngineSettings.md @@ -0,0 +1,206 @@ +# Engine Settings API + +The Untold Engine uses a consistent style for its API: + +```swift +setDomain(.property(value)) +setDomain(.group(.property(value))) +``` + +This keeps user-facing setup code predictable and avoids requiring developers to remember which singleton or global variable owns each value. + +Existing direct APIs such as `LODConfig.shared`, `SSAOParams.shared`, `antiAliasingMode`, and `assetBasePath` are still available for compatibility and advanced tuning. + + +## Rendering + +```swift +setRendering(.antiAliasing(.fxaa)) +setRendering(.antiAliasing(.smaa)) +setRendering(.antiAliasing(.msaa)) +setRendering(.antiAliasing(.none)) + +setRendering(.debugView(.lit)) +setRendering(.debugView(.depth)) +setRendering(.debugView(.ssaoBlurred)) + +setRendering(.postProcessing(.enabled)) +setRendering(.postProcessing(.disabled)) +``` + +Wireframe parameters can also be configured through the same domain: + +```swift +setRendering(.wireframe(.params( + color: simd_float4(0.2, 0.85, 1.0, 0.65), + fadeEnabled: true, + fadeStart: 8.0, + fadeEnd: 40.0, + minimumAlpha: 0.08 +))) +``` + +## PostFX + +Use `setPostFX` for individual post-processing and SSAO settings: + +```swift +setPostFX(.preset(.cinematic)) + +setPostFX(.ssao(.enabled(true))) +setPostFX(.ssao(.radius(0.8))) +setPostFX(.ssao(.bias(0.025))) +setPostFX(.ssao(.intensity(0.75))) +setPostFX(.ssao(.quality(.balanced))) + +setPostFX(.colorGrading(.enabled(true))) +setPostFX(.colorGrading(.exposure(-0.2))) +setPostFX(.colorGrading(.saturation(0.9))) + +setPostFX(.vignette(.enabled(true))) +setPostFX(.vignette(.intensity(0.5))) +setPostFX(.vignette(.radius(0.8))) + +setPostFX(.bloomThreshold(.enabled(true))) +setPostFX(.bloomThreshold(.threshold(0.6))) +setPostFX(.bloomThreshold(.intensity(0.8))) +setPostFX(.bloomComposite(.enabled(true))) +setPostFX(.bloomComposite(.intensity(1.0))) + +setPostFX(.chromaticAberration(.enabled(true))) +setPostFX(.chromaticAberration(.intensity(0.02))) + +setPostFX(.depthOfField(.enabled(true))) +setPostFX(.depthOfField(.focusDistance(4.7))) +setPostFX(.depthOfField(.focusRange(1.5))) +setPostFX(.depthOfField(.maxBlur(10.0))) +``` + +The nested property shape is intentional: the compiler keeps effect-specific settings grouped with the effect they belong to. + +## Engine Globals + +```swift +setEngine(.assetBasePath(gameDataURL)) +setEngine(.metrics(.enabled)) +setEngine(.metrics(.disabled)) +``` + +## Geometry Streaming + +```swift +setGeometryStreaming(.enabled(true)) +setGeometryStreaming(.tileConcurrency(2)) +setGeometryStreaming(.meshConcurrency(3)) +setGeometryStreaming(.lodConcurrency(4)) +setGeometryStreaming(.hlodConcurrency(4)) +setGeometryStreaming(.queryRadius(500.0)) +setGeometryStreaming(.frustumGate(.enabled(meshPadding: 5.0, tilePadding: 20.0))) +setGeometryStreaming(.velocityLookAhead(time: 0.5, minSpeed: 1.5)) +setGeometryStreaming(.candidateSorting(importance: true, occlusion: true)) +setGeometryStreaming(.minimumParsedTileResidentSeconds(8.0)) +setGeometryStreaming(.timeouts(tileParse: 60.0, meshLoad: 60.0)) +``` + +Keep one-shot streaming actions as commands: + +```swift +GeometryStreamingSystem.shared.forceUnloadAllParsedTiles() +``` + +## Static Batching + +```swift +setBatching(.enabled(true)) +setBatching(.cellSize(32.0)) +setBatching(.maxDirtyCellsPerTick(8)) +setBatching(.visibilityGatedBuild(true)) +setBatching(.backgroundArtifactBuild(true)) +setBatching(.runtimeTuning(.visionOSBalanced)) +``` + +Entity tagging and rebuild commands remain explicit: + +```swift +setEntityStaticBatchComponent(entityId: entity) +generateBatches() +clearSceneBatches() +``` + +## LOD + +```swift +setLOD(.fadeTransitions(.enabled(duration: 0.25))) +setLOD(.fadeTransitions(.disabled)) +setLOD(.distanceBias(1.0)) +setLOD(.hysteresis(5.0)) +setLOD(.updateFrameInterval(4)) +setLOD(.minimumCameraDisplacement(0.5)) +setLOD(.distanceThresholds([50, 100, 200, 500])) +``` + +`setLOD(.fadeTransitions(.enabled(duration:)))` replaces the older direct configuration: + +```swift +LODConfig.shared.enableFadeTransitions = true +LODConfig.shared.fadeTransitionTime = 0.25 +``` + + +## Spatial Debug + +```swift +setSpatialDebug(.octreeLeafBounds(.enabled( + maxLeafNodeCount: 0, + occupiedOnly: true, + colorMode: .culling +))) +setSpatialDebug(.tileBounds(enabled: true, maxTileNodeCount: 500)) +setSpatialDebug(.staticBatchCellBounds(enabled: true, maxCellCount: 2000, colorMode: .lod)) +setSpatialDebug(.lodLevels(true)) +setSpatialDebug(.textureStreamingTiers(true)) +setSpatialDebug(.disabled) +``` + +## Logger + +```swift +setLogger(.level(.debug)) +setLogger(.category(.tileStreaming, true)) +setLogger(.categories([.streamingHeartbeat, .oocTiming], true)) +setLogger(.resetCategories) +``` + +Logging itself stays message-oriented: + +```swift +Logger.log(message: "Scene loaded", category: LogCategory.general.rawValue) +``` + +## Camera + +```swift +let camera = findGameCamera() +setCamera(.active(camera)) +setCamera(.defaultFOV(70.0)) +setCamera(.clipPlanes(near: 0.1, far: 1000.0)) +``` + +## Style Rule + +For Contributors, when adding new public settings, prefer one of these forms: + +```swift +setLOD(.newProperty(value)) +setRendering(.newProperty(value)) +setPostFX(.effect(.newProperty(value))) +setEngine(.newProperty(value)) +setGeometryStreaming(.newProperty(value)) +setBatching(.newProperty(value)) +setSpatialDebug(.newProperty(value)) +setLogger(.newProperty(value)) +setCamera(.newProperty(value)) +setSceneChannel(.contextGeometry, .renderMode(.wireframe)) +``` + +Avoid adding new public examples that require direct mutation of shared singletons unless the setting is intentionally advanced/internal. diff --git a/docs/API/UsingGeometryStreamingSystem.md b/docs/API/UsingGeometryStreamingSystem.md index 17f7816f..86737812 100644 --- a/docs/API/UsingGeometryStreamingSystem.md +++ b/docs/API/UsingGeometryStreamingSystem.md @@ -96,45 +96,43 @@ Important defaults: ```swift // Tile concurrency -GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2 -GeometryStreamingSystem.shared.maxConcurrentLoads = 3 -GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 -GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4 +setGeometryStreaming(.tileConcurrency(2)) +setGeometryStreaming(.meshConcurrency(3)) +setGeometryStreaming(.lodConcurrency(4)) +setGeometryStreaming(.hlodConcurrency(4)) // Frustum gate -GeometryStreamingSystem.shared.enableFrustumGate = true -GeometryStreamingSystem.shared.tileFrustumGatePadding = 20.0 // m — wider pad for tiles -GeometryStreamingSystem.shared.frustumGatePadding = 5.0 // m — pad for mesh-level OCC +setGeometryStreaming(.frustumGate(.enabled( + meshPadding: 5.0, + tilePadding: 20.0 +))) // Spatial query -GeometryStreamingSystem.shared.maxQueryRadius = 500.0 // must cover farthest unload_radius +setGeometryStreaming(.queryRadius(500.0)) // must cover farthest unload_radius // Velocity predictor (predictive tile loading) -GeometryStreamingSystem.shared.velocityLookAheadTime = 0.5 // s — how far ahead to project -GeometryStreamingSystem.shared.velocityLookAheadMinSpeed = 1.5 // m/s — activation threshold +setGeometryStreaming(.velocityLookAhead(time: 0.5, minSpeed: 1.5)) // Interior zone gating (v4 quadtree-floor manifests) // Tiles tagged interior=true only load when the camera is inside this AABB. // Set automatically from the manifest; override if needed: -GeometryStreamingSystem.shared.interiorZone = AABB( +setGeometryStreaming(.interiorZone(AABB( min: simd_float3(-10, 0, -10), max: simd_float3(10, 5, 10) -) +))) // Floor-aware gating for v4 quadtree-floor manifests. // Interior tiles with floor metadata only dispatch when their Y center is near the camera. -GeometryStreamingSystem.shared.floorProximityGateY = 5.0 +setGeometryStreaming(.floorProximityGateY(5.0)) // Tile candidate ordering -GeometryStreamingSystem.shared.enableImportanceSort = true -GeometryStreamingSystem.shared.enableOcclusionSort = true +setGeometryStreaming(.candidateSorting(importance: true, occlusion: true)) // Tile unload stability -GeometryStreamingSystem.shared.minimumParsedTileResidentSeconds = 8.0 +setGeometryStreaming(.minimumParsedTileResidentSeconds(8.0)) // Parse safety -GeometryStreamingSystem.shared.tileParseTimeoutSeconds = 60.0 // watchdog deadline per tile -GeometryStreamingSystem.shared.meshLoadTimeoutSeconds = 60.0 // watchdog deadline per OCC mesh load +setGeometryStreaming(.timeouts(tileParse: 60.0, meshLoad: 60.0)) ``` Use `maxQueryRadius` large enough to cover the farthest `unload_radius` in the scene, or out-of-range tiles may not be discovered for teardown. @@ -204,8 +202,8 @@ The rule of thumb: **call it whenever you know a new tile-streaming session is a ### Tiles pop in on camera rotation -- Increase `GeometryStreamingSystem.shared.tileFrustumGatePadding` -- Keep `enableFrustumGate = true` +- Increase `setGeometryStreaming(.frustumGate(.enabled(meshPadding:tilePadding:)))` tile padding +- Keep the frustum gate enabled ### Tiles unload and reload too aggressively @@ -214,7 +212,7 @@ The rule of thumb: **call it whenever you know a new tile-streaming session is a ### Tile parse bursts spike memory -- Lower `maxConcurrentTileLoads` +- Lower `setGeometryStreaming(.tileConcurrency(...))` - Reduce per-tile file sizes in the exported manifest ### Streaming does nothing diff --git a/docs/API/UsingLOD-Batching-Streaming.md b/docs/API/UsingLOD-Batching-Streaming.md index c5a34b14..3dfd6da0 100644 --- a/docs/API/UsingLOD-Batching-Streaming.md +++ b/docs/API/UsingLOD-Batching-Streaming.md @@ -36,7 +36,7 @@ private func setupLODWithBatching() { loadedCount += 1 if loadedCount == totalTrees { - enableBatching(true) + setBatching(.enabled(true)) generateBatches() } } diff --git a/docs/API/UsingLODSystem.md b/docs/API/UsingLODSystem.md index ae8677d7..2d173895 100644 --- a/docs/API/UsingLODSystem.md +++ b/docs/API/UsingLODSystem.md @@ -263,17 +263,18 @@ Configure global LOD behavior: ```swift // Adjust LOD bias (higher = switch to lower detail sooner) -LODConfig.shared.lodBias = 1.5 // Performance mode -LODConfig.shared.lodBias = 0.75 // Quality mode +setLOD(.distanceBias(1.5)) // Performance mode +setLOD(.distanceBias(0.75)) // Quality mode // Adjust hysteresis to prevent flickering -LODConfig.shared.hysteresis = 10.0 +setLOD(.hysteresis(10.0)) -// Enable fade transitions between LODs - Not yet implemented -LODConfig.shared.enableFadeTransitions = true -LODConfig.shared.fadeTransitionTime = 0.5 // seconds +// Enable dithered cross-fade transitions between LOD representations +setLOD(.fadeTransitions(.enabled(duration: 0.5))) ``` +The older `LODConfig.shared` values remain available for compatibility and advanced tuning. New code should prefer `setLOD(...)` so LOD settings follow the same style as scene channels, rendering, and PostFX. + ### Forced LOD Override Force a specific LOD level (useful for debugging): @@ -354,8 +355,8 @@ Base distances on object importance and size: - Ensure camera has `CameraComponent` and is active ### Visual Popping Between LODs -- Increase `LODConfig.shared.hysteresis` value -- Enable fade transitions: `LODConfig.shared.enableFadeTransitions = true` - not yet implemented +- Increase hysteresis: `setLOD(.hysteresis(8.0))` +- Enable dithered cross-fade transitions: `setLOD(.fadeTransitions(.enabled(duration: 0.3)))` - Adjust LOD bias for smoother transitions ### File Not Found Errors @@ -396,8 +397,9 @@ for i in 0..<10 { } // Configure LOD system for this scene -LODConfig.shared.lodBias = 1.2 // Slightly favor performance -LODConfig.shared.hysteresis = 8.0 // Prevent flickering +setLOD(.distanceBias(1.2)) // Slightly favor performance +setLOD(.hysteresis(8.0)) // Prevent flickering +setLOD(.fadeTransitions(.enabled(duration: 0.3))) print("Created \(trees.count) trees with LOD support") ``` @@ -442,7 +444,7 @@ private func setupLODWithBatching() { loadedCount += 1 if loadedCount == totalTrees { // All trees loaded - generate batches - enableBatching(true) + setBatching(.enabled(true)) generateBatches() print("\(totalTrees) trees configured with LOD + Batching") } diff --git a/docs/API/UsingPostFX.md b/docs/API/UsingPostFX.md index b0b72f9e..c2a78795 100644 --- a/docs/API/UsingPostFX.md +++ b/docs/API/UsingPostFX.md @@ -9,7 +9,7 @@ The engine provides a post-processing system through the `PostFX` namespace. You The simplest way to set up post-effects is to apply one of the built-in presets: ```swift -PostFX.apply(.cinematic) +setPostFX(.preset(.cinematic)) ``` That single call configures color grading and SSAO together. No scene wiring or callback setup is needed — it works from anywhere in your game code. @@ -30,13 +30,13 @@ Presets can be swapped at any point during gameplay — for example when transit ```swift // Entering a dark dungeon -PostFX.apply(.cinematic) +setPostFX(.preset(.cinematic)) // Entering a bright outdoor area -PostFX.apply(.highContrast) +setPostFX(.preset(.highContrast)) // Reset everything to defaults -PostFX.apply(.neutral) +setPostFX(.preset(.neutral)) ``` --- @@ -54,11 +54,13 @@ let sunset = PostFXPreset( temperature: 0.4 ) -PostFX.apply(sunset) +setPostFX(.preset(sunset)) ``` All parameters have defaults (matching `.neutral`), so you only need to specify the values you want to change. +The older `PostFX.apply(...)` call remains supported. New code should prefer `setPostFX(.preset(...))` so settings use the same facade style as LOD, rendering, and engine globals. + ### PostFXPreset Parameters | Parameter | Type | Default | Description | @@ -80,12 +82,12 @@ All parameters have defaults (matching `.neutral`), so you only need to specify ## Anti-Aliasing -Anti-aliasing is configured through the `antiAliasingMode` global, not through the `PostFX` namespace: +Anti-aliasing is configured through the rendering settings facade, not through the `PostFX` namespace: ```swift -antiAliasingMode = .fxaa // Fast Approximate Anti-Aliasing (default) -antiAliasingMode = .smaa // Subpixel Morphological Anti-Aliasing (3-pass) -antiAliasingMode = .none // No anti-aliasing +setRendering(.antiAliasing(.fxaa)) // Fast Approximate Anti-Aliasing (default) +setRendering(.antiAliasing(.smaa)) // Subpixel Morphological Anti-Aliasing (3-pass) +setRendering(.antiAliasing(.none)) // No anti-aliasing ``` | Mode | Description | @@ -94,14 +96,14 @@ antiAliasingMode = .none // No anti-aliasing | `.smaa` | Three-pass chain (edge detection → blend weights → neighborhood blend). Sharper than FXAA, handles diagonal and corner patterns. Costs ~3× the GPU time of FXAA. | | `.none` | Anti-aliasing skipped entirely. The output transform reads directly from the look pass. | -SMAA also exposes intermediate debug views via `renderDebugViewMode`: +SMAA also exposes intermediate debug views through `setRendering(.debugView(...))`: ```swift -renderDebugViewMode = .smaaEdges // Show edge detection result -renderDebugViewMode = .smaaBlend // Show blend-weight texture -renderDebugViewMode = .smaaDifference // Show original vs. resolved difference -renderDebugViewMode = .fxaaEdgeDebug // Show FXAA luma-gradient edge map -renderDebugViewMode = .lit // Normal rendering (default) +setRendering(.debugView(.smaaEdges)) // Show edge detection result +setRendering(.debugView(.smaaBlend)) // Show blend-weight texture +setRendering(.debugView(.smaaDifference)) // Show original vs. resolved difference +setRendering(.debugView(.fxaaEdgeDebug)) // Show FXAA luma-gradient edge map +setRendering(.debugView(.lit)) // Normal rendering (default) ``` --- @@ -111,9 +113,9 @@ renderDebugViewMode = .lit // Normal rendering (default) For fine-grained control outside of presets, you can enable or disable individual effects: ```swift -PostFX.setEnabled(.colorGrading, true) -PostFX.setEnabled(.vignette, true) -PostFX.setEnabled(.chromaticAberration, false) +setPostFX(.colorGrading(.enabled(true))) +setPostFX(.vignette(.enabled(true))) +setPostFX(.chromaticAberration(.enabled(false))) ``` And read their current state: @@ -136,11 +138,9 @@ let isActive = PostFX.isEnabled(.bloomThreshold) > **SSAO is not a `PostFXEffect`** — it has its own enable API: > ```swift -> SSAO.setEnabled(true) -> // or directly: -> SSAOParams.shared.enabled = true +> setPostFX(.ssao(.enabled(true))) > ``` -> SSAO is also configured through `PostFXPreset` when you call `PostFX.apply(preset)`, but it is not accessible via `PostFX.setEnabled(...)` or `PostFX.isEnabled(...)`. +> SSAO is also configured through `PostFXPreset` when you call `setPostFX(.preset(preset))`. The current SSAO renderer is depth-only. It samples the stored opaque depth buffer, runs the blur chain internally, and applies the result during pre-composite. This keeps SSAO compatible with the engine's tile-based deferred renderer without forcing normal or position G-Buffer attachments to be stored in memory. @@ -152,23 +152,27 @@ Each effect exposes its parameters through a shared singleton. Import `UntoldEng ```swift // Color grading -ColorGradingParams.shared.exposure = -0.2 -ColorGradingParams.shared.contrast = 1.15 -ColorGradingParams.shared.saturation = 0.9 -ColorGradingParams.shared.temperature = -0.1 +setPostFX(.colorGrading(.exposure(-0.2))) +setPostFX(.colorGrading(.contrast(1.15))) +setPostFX(.colorGrading(.saturation(0.9))) +setPostFX(.colorGrading(.temperature(-0.1))) // Bloom -BloomThresholdParams.shared.threshold = 0.6 -BloomThresholdParams.shared.intensity = 0.8 -BloomThresholdParams.shared.enabled = true +setPostFX(.bloomThreshold(.threshold(0.6))) +setPostFX(.bloomThreshold(.intensity(0.8))) +setPostFX(.bloomThreshold(.enabled(true))) // Vignette -VignetteParams.shared.intensity = 0.5 -VignetteParams.shared.radius = 0.8 -VignetteParams.shared.enabled = true +setPostFX(.vignette(.intensity(0.5))) +setPostFX(.vignette(.radius(0.8))) +setPostFX(.vignette(.enabled(true))) // SSAO -SSAOParams.shared.radius = 0.8 -SSAOParams.shared.intensity = 0.75 -SSAOParams.shared.enabled = true +setPostFX(.ssao(.radius(0.8))) +setPostFX(.ssao(.intensity(0.75))) +setPostFX(.ssao(.enabled(true))) ``` + +Direct singleton access remains available for compatibility and advanced tooling. Prefer the `setPostFX(...)` facade in user-facing examples. + +For the broader settings style, see [Engine Settings API](UsingEngineSettings.md). diff --git a/docs/API/UsingProfiler.md b/docs/API/UsingProfiler.md index 36ebe3f9..3a69ab3e 100644 --- a/docs/API/UsingProfiler.md +++ b/docs/API/UsingProfiler.md @@ -12,7 +12,7 @@ Use structured metrics as the source of truth, then enable category logs only wh Enable the profiler at runtime: ```swift -enableEngineMetrics = true +setEngine(.metrics(.enabled)) ``` Or via environment variable: @@ -115,21 +115,17 @@ Enable them when diagnosing OOC/loader behavior: ```swift // Keep structured profiler metrics on -enableEngineMetrics = true +setEngine(.metrics(.enabled)) setEngineStatsLogging(enabled: true, profile: .compact, intervalSeconds: 1.0) // Add focused trace logs -Logger.enable(category: .oocStatus) // OutOfCore lifecycle/status -Logger.enable(category: .oocTiming) // OOC timing detail -Logger.enable(category: .assetLoader) // progressive loader parse/upload +setLogger(.categories([.oocStatus, .oocTiming, .assetLoader], true)) ``` Disable after capture: ```swift -Logger.disable(category: .oocTiming) -Logger.disable(category: .oocStatus) -Logger.disable(category: .assetLoader) +setLogger(.categories([.oocTiming, .oocStatus, .assetLoader], false)) ``` ## Static Batching Triage @@ -140,15 +136,15 @@ Enable the `.batching` log category to get a material-diversity report: ```swift // One-shot snapshot at any point (e.g. after the scene finishes loading) -Logger.enable(category: .batching) +setLogger(.category(.batching, true)) BatchingSystem.shared.logMaterialDiagnosticsNow() -Logger.disable(category: .batching) +setLogger(.category(.batching, false)) ``` Or arm it to fire automatically every 30 seconds during a session: ```swift -Logger.enable(category: .batching) +setLogger(.category(.batching, true)) // engine loop calls logMaterialDiagnosticsIfDue() each frame — no extra code needed ``` @@ -243,9 +239,9 @@ Output includes: loaded / loading / unloaded entity counts, active load slot usa Enable the `.tileStreaming` category for event-level traces (tile parse timeouts, eviction warnings, swap-thrash alerts): ```swift -Logger.enable(category: .tileStreaming) +setLogger(.category(.tileStreaming, true)) // ... reproduce the issue ... -Logger.disable(category: .tileStreaming) +setLogger(.category(.tileStreaming, false)) ``` --- @@ -354,7 +350,7 @@ To inspect timeline data: 1. Open Instruments 2. Choose **Points of Interest** 3. Filter subsystem to `com.untoldengine.profiling` -4. Run the app with `enableEngineMetrics = true` (or `UNTOLD_METRICS=1`) +4. Run the app with `setEngine(.metrics(.enabled))` (or `UNTOLD_METRICS=1`) ## Build Configuration Notes diff --git a/docs/API/UsingRegistrationSystem.md b/docs/API/UsingRegistrationSystem.md index 333833d4..bb05e7d2 100644 --- a/docs/API/UsingRegistrationSystem.md +++ b/docs/API/UsingRegistrationSystem.md @@ -108,7 +108,7 @@ public func playSceneAt(url: URL, completion: (() -> Void)? = nil) { } // Early camera rebind during async mesh loading window. - CameraSystem.shared.activeCamera = findGameCamera() + setCamera(.active(findGameCamera())) } } ``` diff --git a/docs/API/UsingRenderingSystem.md b/docs/API/UsingRenderingSystem.md index 21788fc8..24db0be6 100644 --- a/docs/API/UsingRenderingSystem.md +++ b/docs/API/UsingRenderingSystem.md @@ -73,9 +73,9 @@ Once everything is set up: Set the anti-aliasing mode globally before the first frame (or at any point to change it at runtime): ```swift -antiAliasingMode = .fxaa // Fast Approximate Anti-Aliasing (default) -antiAliasingMode = .smaa // Subpixel Morphological Anti-Aliasing -antiAliasingMode = .none // Disabled +setRendering(.antiAliasing(.fxaa)) // Fast Approximate Anti-Aliasing (default) +setRendering(.antiAliasing(.smaa)) // Subpixel Morphological Anti-Aliasing +setRendering(.antiAliasing(.none)) // Disabled ``` SMAA produces sharper results than FXAA and handles diagonal/corner patterns, at roughly 3× the GPU cost of FXAA. For most scenes `.fxaa` is a good default. See [UsingPostFX](UsingPostFX.md) for debug views that let you inspect the intermediate AA passes. @@ -86,13 +86,13 @@ SMAA produces sharper results than FXAA and handles diagonal/corner patterns, at Opaque geometry uses a tile-based deferred rendering (TBDR) path. The model pass writes G-Buffer data into memoryless tile attachments, then the lighting shader reads those attachments through framebuffer fetch inside the same render encoder. This keeps the high-bandwidth G-Buffer data on the GPU tile instead of round-tripping it through full-screen textures. -SSAO is still available through `SSAO.setEnabled(true)`, `SSAOParams.shared`, and `PostFX` presets, but the current implementation is **depth-only**. It samples the stored opaque depth buffer and applies the blurred occlusion during pre-composite. It no longer requires the normal or position G-Buffer textures to be stored in memory. +SSAO is still available through `setPostFX(.ssao(...))` and PostFX presets, but the current implementation is **depth-only**. It samples the stored opaque depth buffer and applies the blurred occlusion during pre-composite. It no longer requires the normal or position G-Buffer textures to be stored in memory. ```swift -SSAO.setEnabled(true) -SSAOParams.shared.radius = 0.8 -SSAOParams.shared.bias = 0.025 -SSAOParams.shared.intensity = 0.75 +setPostFX(.ssao(.enabled(true))) +setPostFX(.ssao(.radius(0.8))) +setPostFX(.ssao(.bias(0.025))) +setPostFX(.ssao(.intensity(0.75))) ``` Use `.ssaoBlurred` in the debug view to inspect the final blurred occlusion texture. @@ -104,17 +104,19 @@ Use `.ssaoBlurred` in the debug view to inspect the final blurred occlusion text The engine can visualize individual G-Buffer layers and anti-aliasing internals in place of the final lit image: ```swift -renderDebugViewMode = .lit // Normal output (default) -renderDebugViewMode = .albedo // G-Buffer base color -renderDebugViewMode = .normal // G-Buffer surface normals -renderDebugViewMode = .depth // Linearized depth buffer (grayscale) -renderDebugViewMode = .ssaoBlurred // SSAO occlusion result -renderDebugViewMode = .fxaaEdgeDebug // FXAA luma-gradient edge map -renderDebugViewMode = .smaaEdges // SMAA edge detection output -renderDebugViewMode = .smaaBlend // SMAA blend-weight texture -renderDebugViewMode = .smaaDifference // Original vs. SMAA-resolved difference +setRendering(.debugView(.lit)) // Normal output (default) +setRendering(.debugView(.albedo)) // G-Buffer base color +setRendering(.debugView(.normal)) // G-Buffer surface normals +setRendering(.debugView(.depth)) // Linearized depth buffer (grayscale) +setRendering(.debugView(.ssaoBlurred)) // SSAO occlusion result +setRendering(.debugView(.fxaaEdgeDebug)) // FXAA luma-gradient edge map +setRendering(.debugView(.smaaEdges)) // SMAA edge detection output +setRendering(.debugView(.smaaBlend)) // SMAA blend-weight texture +setRendering(.debugView(.smaaDifference)) // Original vs. SMAA-resolved difference ``` -Restore normal rendering with `renderDebugViewMode = .lit`. +Restore normal rendering with `setRendering(.debugView(.lit))`. + +For the broader settings style, see [Engine Settings API](UsingEngineSettings.md). --- diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index a768e3fb..3c613701 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -22,7 +22,7 @@ setEntityMesh(entityId: cube2, filename: "cube", withExtension: "untold") translateTo(entityId: cube2, position: simd_float3(2, 0, 0)) setEntityStaticBatchComponent(entityId: cube2) -enableBatching(true) +setBatching(.enabled(true)) generateBatches() ``` @@ -34,7 +34,7 @@ let building = createEntity() setEntityMeshAsync(entityId: building, filename: "office_building", withExtension: "untold") { success in guard success else { return } setEntityStaticBatchComponent(entityId: building) - enableBatching(true) + setBatching(.enabled(true)) generateBatches() } ``` @@ -67,23 +67,23 @@ For tiled/streamed scenes, the engine manages static batching automatically. Whe Do **not** call `generateBatches()` for streamed scenes. That function performs a full global rebuild — it queries every entity in the scene simultaneously, merges entities from different tiles into shared batch groups, and allocates all GPU buffers synchronously on the render thread. This overrides the engine's incremental system and causes a noticeable stall. -For streamed scenes, only call `enableBatching(true)` after the scene loads. The engine handles the rest: +For streamed scenes, only call `setBatching(.enabled(true))` after the scene loads. The engine handles the rest: ```swift setEntityStreamScene(entityId: sceneRoot, manifest: "city", withExtension: "json") { success in - enableBatching(true) + setBatching(.enabled(true)) setSceneReady(success) } ``` -For non-streamed scenes (single `.untold`), call `setEntityStaticBatchComponent`, `generateBatches()`, and `enableBatching(true)` as normal. The same applies to any operation that mutates material state (color, opacity) — wrap it with `enableBatching(false)` before and `generateBatches()` + `enableBatching(true)` after, but only for non-streamed scenes: +For non-streamed scenes (single `.untold`), call `setEntityStaticBatchComponent`, `generateBatches()`, and `setBatching(.enabled(true))` as normal. The same applies to any operation that mutates material state (color, opacity) — wrap it with `setBatching(.enabled(false))` before and `generateBatches()` + `setBatching(.enabled(true))` after, but only for non-streamed scenes: ```swift // Non-streamed only — do not use this pattern in tiled/streamed scenes -enableBatching(false) +setBatching(.enabled(false)) setEntityColor(entityId: prop, color: simd_float4(1, 0, 0, 1)) generateBatches() -enableBatching(true) +setBatching(.enabled(true)) ``` ## Core APIs @@ -104,12 +104,16 @@ Removes static batching tags from the entity hierarchy. removeEntityStaticBatchComponent(entityId: entity) ``` -### `enableBatching(_:)` +### `setBatching(_:)` -Globally enables or disables runtime batching. +Configures runtime batching. ```swift -enableBatching(true) +setBatching(.enabled(true)) +setBatching(.cellSize(32.0)) +setBatching(.maxDirtyCellsPerTick(8)) +setBatching(.visibilityGatedBuild(true)) +setBatching(.backgroundArtifactBuild(true)) ``` ### `generateBatches()` diff --git a/docs/API/UsingTheLogger.md b/docs/API/UsingTheLogger.md index 93cfcd85..2a805766 100644 --- a/docs/API/UsingTheLogger.md +++ b/docs/API/UsingTheLogger.md @@ -7,11 +7,11 @@ UntoldEngine includes a thread-safe logger with log-level filtering, per-categor Log level controls the minimum severity that is emitted. Set it once at startup: ```swift -Logger.logLevel = .debug // emit everything -Logger.logLevel = .info // emit info, warnings, and errors -Logger.logLevel = .warning // emit warnings and errors only -Logger.logLevel = .error // emit errors only -Logger.logLevel = .none // suppress all output +setLogger(.level(.debug)) // emit everything +setLogger(.level(.info)) // emit info, warnings, and errors +setLogger(.level(.warning)) // emit warnings and errors only +setLogger(.level(.error)) // emit errors only +setLogger(.level(.none)) // suppress all output ``` | Level | Value | What emits | @@ -94,46 +94,49 @@ High-volume categories are off by default to avoid log spam during normal operat ```swift // Enable a category -Logger.enable(category: .oocStatus) +setLogger(.category(.oocStatus, true)) // Disable a category -Logger.disable(category: .xrCamera) +setLogger(.category(.xrCamera, false)) -// Toggle with a Bool -Logger.set(category: .assetLoader, enabled: true) +// Toggle multiple categories +setLogger(.categories([.assetLoader, .tileStreaming], true)) // Check current state if Logger.isEnabled(category: .ecs) { ... } // Reset all overrides back to defaults -Logger.resetCategoryToggles() +setLogger(.resetCategories) ``` ### Typical debug session ```swift // Turn on verbose geometry streaming traces for a debug session -Logger.enable(category: .tileStreaming) -Logger.enable(category: .streamingHeartbeat) -Logger.enable(category: .oocStatus) -Logger.enable(category: .oocTiming) -Logger.enable(category: .assetLoader) +setLogger(.categories([ + .tileStreaming, + .streamingHeartbeat, + .oocStatus, + .oocTiming, + .assetLoader, +], true)) // ... reproduce the issue ... // Clean up after capture -Logger.disable(category: .tileStreaming) -Logger.disable(category: .streamingHeartbeat) -Logger.disable(category: .oocStatus) -Logger.disable(category: .oocTiming) -Logger.disable(category: .assetLoader) +setLogger(.categories([ + .tileStreaming, + .streamingHeartbeat, + .oocStatus, + .oocTiming, + .assetLoader, +], false)) ``` Texture diagnostics can be enabled separately: ```swift -Logger.enable(category: .textureStreaming) -Logger.enable(category: .textureLoading) +setLogger(.categories([.textureStreaming, .textureLoading], true)) ``` ### Static batching diagnostics @@ -143,19 +146,19 @@ The `.batching` category drives `BatchingSystem`'s material-diversity report. It **One-shot snapshot** (most common): ```swift -Logger.enable(category: .batching) +setLogger(.category(.batching, true)) BatchingSystem.shared.logMaterialDiagnosticsNow() // immediate scan and emit -Logger.disable(category: .batching) +setLogger(.category(.batching, false)) ``` **Periodic auto-logging** (fires at most once every 30 s while enabled): ```swift // Call once at startup to arm it; the engine loop calls logMaterialDiagnosticsIfDue() each frame. -Logger.enable(category: .batching) +setLogger(.category(.batching, true)) // When done: -Logger.disable(category: .batching) +setLogger(.category(.batching, false)) ``` Sample output: @@ -210,4 +213,4 @@ Sinks are held weakly — the logger will not extend their lifetime. - `Logger.log(...)` respects both `logLevel` and category state. - `Logger.logWarning(...)` and `Logger.logError(...)` respect `logLevel` only — they are never suppressed by category. -- Category overrides layer on top of the built-in defaults. Call `resetCategoryToggles()` to restore defaults without restarting. +- Category overrides layer on top of the built-in defaults. Call `setLogger(.resetCategories)` to restore defaults without restarting. diff --git a/docs/images/PreviewRuntimeLOD.png b/docs/images/PreviewRuntimeLOD.png new file mode 100644 index 00000000..9233ae68 Binary files /dev/null and b/docs/images/PreviewRuntimeLOD.png differ diff --git a/docs/images/TilePreview.png b/docs/images/TilePreview.png new file mode 100644 index 00000000..d70f4b58 Binary files /dev/null and b/docs/images/TilePreview.png differ diff --git a/scripts/tests/test_tilestreamingpartition.py b/scripts/tests/test_tilestreamingpartition.py index c2b282f2..ce41e384 100644 --- a/scripts/tests/test_tilestreamingpartition.py +++ b/scripts/tests/test_tilestreamingpartition.py @@ -39,6 +39,12 @@ import tilestreamingpartition as t # noqa: E402 +class FakeObject(dict): + def __init__(self, name: str, **props) -> None: + super().__init__(props) + self.name = name + + class TileStreamingPartitionTests(unittest.TestCase): # ------------------------------------------------------------------ @@ -162,6 +168,62 @@ def test_aabb_to_usd_space_swaps_y_and_z(self) -> None: self.assertAlmostEqual(usd["min"][1], 3.0) # Z → Y self.assertAlmostEqual(usd["min"][2], -5.0) # -Y → Z + # ------------------------------------------------------------------ + # Runtime representation helpers + # ------------------------------------------------------------------ + + def test_distance_to_aabb_is_zero_inside_large_bounds(self) -> None: + bounds = {"min": (-100.0, -10.0, -20.0), "max": (100.0, 10.0, 20.0)} + + self.assertAlmostEqual(t.distance_to_aabb((50.0, 0.0, 0.0), bounds), 0.0) + + def test_distance_to_aabb_uses_closest_point(self) -> None: + bounds = {"min": (0.0, 0.0, 0.0), "max": (10.0, 10.0, 10.0)} + + self.assertAlmostEqual(t.distance_to_aabb((13.0, 14.0, 10.0), bounds), 5.0) + + def test_object_union_aabb(self) -> None: + objs = [FakeObject("A"), FakeObject("B")] + bounds = { + "A": {"min": (0.0, 1.0, 2.0), "max": (3.0, 4.0, 5.0)}, + "B": {"min": (-2.0, 3.0, 1.0), "max": (8.0, 9.0, 7.0)}, + } + + self.assertEqual(t.object_union_aabb(objs, bounds), { + "min": (-2.0, 1.0, 1.0), + "max": (8.0, 9.0, 7.0), + }) + + def test_runtime_representation_hlod_takes_far_field_precedence(self) -> None: + state = t.classify_runtime_representation( + distance=80.0, + unload_r=60.0, + hlod_levels=[{"switch_distance": 57.0}], + lod_levels=[{"switch_distance": 25.0}], + ) + + self.assertEqual(state, "hlod") + + def test_runtime_representation_lod_precedes_unloaded_without_hlod(self) -> None: + state = t.classify_runtime_representation( + distance=80.0, + unload_r=60.0, + hlod_levels=[], + lod_levels=[{"switch_distance": 25.0}], + ) + + self.assertEqual(state, "lod") + + def test_runtime_representation_unloaded_when_no_secondary_asset(self) -> None: + state = t.classify_runtime_representation( + distance=80.0, + unload_r=60.0, + hlod_levels=[], + lod_levels=[], + ) + + self.assertEqual(state, "unloaded") + # ------------------------------------------------------------------ # Mesh classification # ------------------------------------------------------------------ @@ -219,6 +281,56 @@ def test_sanitize_name_replaces_special_characters(self) -> None: def test_sanitize_name_preserves_alphanumerics(self) -> None: self.assertEqual(t.sanitize_name("Tile_0_1"), "Tile_0_1") + # ------------------------------------------------------------------ + # Untold object metadata + # ------------------------------------------------------------------ + + def test_semantic_override_wins_over_existing_metadata(self) -> None: + obj = FakeObject( + "Wall", + untold_quadtree_node_id="F01_Q_0", + untold_floor_id=1, + untold_quadtree_depth=1, + untold_spatial_class="local", + untold_semantic_guess="StructuralInterior", + untold_semantic_confidence=0.8, + untold_semantic_override="ExteriorShell", + ) + + metadata = t.read_untold_metadata(obj) + + self.assertEqual(metadata["semantic"], "ExteriorShell") + self.assertEqual(metadata["confidence"], 1.0) + self.assertEqual(metadata["source"], "custom_property_override") + + def test_auto_semantic_override_uses_existing_metadata(self) -> None: + obj = FakeObject( + "Chair", + untold_quadtree_node_id="F01_Q_1", + untold_semantic_guess="RoomContents", + untold_semantic_confidence=0.7, + untold_semantic_override="Auto", + ) + + metadata = t.read_untold_metadata(obj) + + self.assertEqual(metadata["semantic"], "RoomContents") + self.assertEqual(metadata["source"], "custom_property") + + def test_aggregate_priority_hint_uses_highest_hint(self) -> None: + objs = [ + FakeObject("A", untold_streaming_priority_hint="Low"), + FakeObject("B", untold_streaming_priority_hint="Critical"), + FakeObject("C"), + ] + + self.assertEqual(t.aggregate_priority_hint(objs, default_priority=8), 15) + + def test_aggregate_priority_hint_keeps_default_when_higher(self) -> None: + objs = [FakeObject("A", untold_streaming_priority_hint="Low")] + + self.assertEqual(t.aggregate_priority_hint(objs, default_priority=10), 10) + def test_format_bytes_bytes(self) -> None: self.assertIn("B", t.format_bytes(500)) self.assertIn("500", t.format_bytes(500)) diff --git a/scripts/tilestreamingpartition.py b/scripts/tilestreamingpartition.py index 37570a02..5083dbe7 100755 --- a/scripts/tilestreamingpartition.py +++ b/scripts/tilestreamingpartition.py @@ -63,7 +63,7 @@ if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) -from untoldexplorer import ProgressReporter, clear_scene, export_objects_to_untold, import_usd_asset +from untoldexplorer import ProgressCallback, ProgressReporter, clear_scene, export_objects_to_untold, import_usd_asset def print_export_stage(stage, detail=""): @@ -208,12 +208,14 @@ def append_worker_progress(progress_file, event): # --- Tile LOD levels ------------------------------------------ # Per-tile discrete LOD generation. Each entry is a (decimate_ratio, -# switch_position) pair where switch_position is a normalized position in the -# representation ladder, not a direct fraction of streaming_radius. -# The exporter maps these positions through a non-linear curve so the bands are -# wider at distance and less prone to HLOD/LOD/full-detail flip-flopping near -# the streaming boundary. Sorted ascending by position (finest first). The -# full-detail tile is always LOD0; entries here define LOD1, LOD2, etc. +# switch_distance) pair. switch_distance accepts two forms: +# • 0 < value <= 1.0 — normalised position in the [streaming_r, unload_r] band +# (legacy / script-default mode; mapped through a non-linear +# curve so bands are wider at distance) +# • value > 1.0 — absolute metres; clamped to the valid range for the tile's +# tier (the mode used when the Blender addon or CLI sets values) +# Sorted ascending by switch_distance (finest first). LOD0 = full geometry; entries +# here define LOD1, LOD2, etc. GENERATE_LOD = False TILE_LOD_LEVELS = [ (0.5, 0.30), # LOD1 — 50% poly, widened near/mid-band anchor @@ -227,8 +229,8 @@ def append_worker_progress(progress_file, event): LOD_NEAR_BAND_START_FRACTION = 0.45 LOD_SWITCH_CURVE_EXPONENT = 1.25 HLOD_SWITCH_CURVE_EXPONENT = 2.0 -SWITCH_DISTANCE_MIN_GAP = 2.0 -SWITCH_DISTANCE_OUTER_MARGIN = 0.75 +SWITCH_DISTANCE_MIN_GAP = 4.0 +SWITCH_DISTANCE_OUTER_MARGIN = 4.0 # --- Quadtree / semantic-tier streaming radii ----------------- # Fractions of scene_half_diag — converted to world-space metres once at @@ -267,6 +269,15 @@ def append_worker_progress(progress_file, event): "FineProps": "FP", } +VALID_SEMANTIC_TIERS = set(TIER_SHORT_CODES.keys()) + +OBJECT_PRIORITY_HINTS = { + "Low": 3, + "Normal": 8, + "High": 12, + "Critical": 15, +} + # When semantic confidence (from the phase-1+2 script) is below this value, # the object's tier is overridden to DEFAULT_TIER instead of being trusted. TIER_CONFIDENCE_THRESHOLD = 0.50 @@ -275,6 +286,7 @@ def append_worker_progress(progress_file, event): # StructuralInterior is the safest default: loads at medium distance, # never deferred as long as FineProps, never as wide-radius as ExteriorShell. DEFAULT_SEMANTIC_TIER = "StructuralInterior" +UNTAGGED_SEMANTIC_TIER = "Auto" # Auto | ExteriorShell | StructuralInterior | RoomContents | FineProps # Fraction of objects that must carry Untold metadata before the quadtree # export path is activated. Below this threshold the grid path runs instead. @@ -302,6 +314,13 @@ def append_worker_progress(progress_file, event): # When True, the KD-tree path is always used (set via the --kdtree CLI flag). FORCE_KDTREE = False +# Runtime tiles are emitted per (spatial node, semantic tier), so an apparently +# balanced spatial leaf can still become several singleton runtime tiles after +# semantic grouping. Collapse underfilled leaf-tier groups upward until each +# group has at least this many objects or reaches the floor root. +INLINE_MIN_OBJECTS_PER_TILE_TIER = 4 +INLINE_COLLAPSE_UNDERFILLED_TILE_TIERS = True + INLINE_AUTO_FLOOR_BAND_HEIGHT = None # set to a float (metres) to override auto-detection INLINE_MIN_FLOOR_BAND_HEIGHT = 2.5 INLINE_MAX_FLOOR_BAND_HEIGHT = 5.0 @@ -347,6 +366,10 @@ def append_worker_progress(progress_file, event): SOURCE_SCENE_PATH_OVERRIDE = "" ERROR_IF_UNSAVED_SOURCE_NOT_FOUND = True +# Set by bridge.py for in-process (Blender add-on) exports so the overall +# "tile export" ProgressReporter can drive a UI progress bar. +PROGRESS_CALLBACK: ProgressCallback | None = None + # --- Auto tile sizing ----------------------------------------- AUTO_TILE_SIZE = False AUTO_TILE_TARGET_MAX_TILES = 2000 @@ -587,11 +610,9 @@ def validate_hlod_levels(): raise RuntimeError( f"HLOD_LEVELS[{idx}] has invalid 'switch_distance': {level.get('switch_distance')}" ) - if not (0.0 < switch_distance <= 1.0): + if switch_distance <= 0.0: raise RuntimeError( - f"HLOD_LEVELS[{idx}] switch_distance position must be in (0, 1], got {switch_distance}. " - f"This is a normalized position across the outer streaming band " - f"(e.g. 1.0 = near unload_radius)." + f"HLOD_LEVELS[{idx}] switch_distance must be > 0 (metres or 0–1 normalised), got {switch_distance}." ) normalized.append({ @@ -636,10 +657,9 @@ def validate_lod_levels(): raise RuntimeError( f"TILE_LOD_LEVELS[{idx}] has invalid switch_distance: {entry[1]!r}" ) - if not (0.0 < distance <= 1.0): + if distance <= 0.0: raise RuntimeError( - f"TILE_LOD_LEVELS[{idx}] switch_distance position must be in (0, 1], got {distance}. " - f"This is a normalized ladder position, not a direct fraction of streaming_radius." + f"TILE_LOD_LEVELS[{idx}] switch_distance must be > 0 (metres or 0–1 normalised), got {distance}." ) normalized.append({ @@ -666,12 +686,18 @@ def compute_hlod_switch_distances(streaming_r, unload_r, levels): resolved = [] prev = min_switch - SWITCH_DISTANCE_MIN_GAP for idx, level in enumerate(sorted(levels, key=lambda l: l["switch_distance"])): - t = clamp(level["switch_distance"], 0.0, 1.0) - eased_t = 1.0 - math.pow(1.0 - t, HLOD_SWITCH_CURVE_EXPONENT) + sd = level["switch_distance"] remaining = len(levels) - idx - 1 upper_bound = max_switch - (remaining * SWITCH_DISTANCE_MIN_GAP) - candidate = lerp(min_switch, max_switch, eased_t) - candidate = clamp(candidate, prev + SWITCH_DISTANCE_MIN_GAP, upper_bound) + if sd > 1.0: + # Absolute metres — clamp directly to the valid window. + candidate = clamp(sd, prev + SWITCH_DISTANCE_MIN_GAP, upper_bound) + else: + # Normalised 0–1 — existing lerp/ease path. + t = clamp(sd, 0.0, 1.0) + eased_t = 1.0 - math.pow(1.0 - t, HLOD_SWITCH_CURVE_EXPONENT) + candidate = lerp(min_switch, max_switch, eased_t) + candidate = clamp(candidate, prev + SWITCH_DISTANCE_MIN_GAP, upper_bound) resolved.append({ "suffix": level["suffix"], "reduction_ratio": level["reduction_ratio"], @@ -690,23 +716,32 @@ def compute_lod_switch_distances(streaming_r, unload_r, hlod_levels, lod_levels) if hlod_levels: far_limit = min(level["switch_distance"] for level in hlod_levels) - SWITCH_DISTANCE_MIN_GAP - near_limit = streaming_r * LOD_NEAR_BAND_START_FRACTION - required_span = SWITCH_DISTANCE_MIN_GAP * len(lod_levels) - max_near_limit = far_limit - required_span - near_limit = min(near_limit, max_near_limit) - near_limit = max(SWITCH_DISTANCE_MIN_GAP, near_limit) + near_limit = max( + streaming_r + SWITCH_DISTANCE_MIN_GAP, + streaming_r * LOD_NEAR_BAND_START_FRACTION, + SWITCH_DISTANCE_MIN_GAP, + ) + required_span = SWITCH_DISTANCE_MIN_GAP * max(0, len(lod_levels) - 1) + if far_limit < near_limit + required_span: + near_limit = max(SWITCH_DISTANCE_MIN_GAP, far_limit - required_span) far_limit = max(far_limit, near_limit + required_span) resolved = [] prev = near_limit - SWITCH_DISTANCE_MIN_GAP sorted_levels = sorted(lod_levels, key=lambda l: l["switch_distance"]) for idx, level in enumerate(sorted_levels): - t = clamp(level["switch_distance"], 0.0, 1.0) - eased_t = math.pow(t, LOD_SWITCH_CURVE_EXPONENT) + sd = level["switch_distance"] remaining = len(sorted_levels) - idx - 1 upper_bound = far_limit - (remaining * SWITCH_DISTANCE_MIN_GAP) - candidate = lerp(near_limit, far_limit, eased_t) - candidate = clamp(candidate, prev + SWITCH_DISTANCE_MIN_GAP, upper_bound) + if sd > 1.0: + # Absolute metres — clamp directly. + candidate = clamp(sd, prev + SWITCH_DISTANCE_MIN_GAP, upper_bound) + else: + # Normalised 0–1 — existing ease path. + t = clamp(sd, 0.0, 1.0) + eased_t = math.pow(t, LOD_SWITCH_CURVE_EXPONENT) + candidate = lerp(near_limit, far_limit, eased_t) + candidate = clamp(candidate, prev + SWITCH_DISTANCE_MIN_GAP, upper_bound) resolved.append({ "ratio": level["ratio"], "switch_distance": round(candidate, 2), @@ -716,6 +751,123 @@ def compute_lod_switch_distances(streaming_r, unload_r, hlod_levels, lod_levels) return resolved +def resolve_tile_representation_levels(streaming_r, unload_r, hlod_level_configs, lod_level_configs): + """Resolve normalized LOD/HLOD configs into world-space bands for one tile. + + Tile streaming radii can vary by semantic tier, so a single global + representation ladder is not valid for all tiles. Keep the invariant: + + streaming_radius < LOD... < HLOD < unload_radius + + with SWITCH_DISTANCE_MIN_GAP between adjacent representation bands. + """ + tile_hlod_levels = compute_hlod_switch_distances( + streaming_r, + unload_r, + hlod_level_configs, + ) + tile_lod_levels = compute_lod_switch_distances( + streaming_r, + unload_r, + tile_hlod_levels, + lod_level_configs, + ) + return tile_hlod_levels, tile_lod_levels + + +def distance_to_aabb(point, aabb): + """Return closest-point distance from point to an AABB. + + The engine's GeometryStreamingSystem uses closest-point-on-AABB distance for + tile streaming decisions. Keep Python preview/export diagnostics on the same + contract so large tiles near the camera are not misclassified as far away + just because their centers are distant. + """ + px, py, pz = point + mn = aabb["min"] + mx = aabb["max"] + closest_x = min(max(px, mn[0]), mx[0]) + closest_y = min(max(py, mn[1]), mx[1]) + closest_z = min(max(pz, mn[2]), mx[2]) + return math.sqrt( + (px - closest_x) ** 2 + + (py - closest_y) ** 2 + + (pz - closest_z) ** 2 + ) + + +def object_union_aabb(objects, object_bounds): + """Return the Blender-space union AABB for a group of objects.""" + if not objects: + return None + return { + "min": ( + min(object_bounds[o.name]["min"][0] for o in objects), + min(object_bounds[o.name]["min"][1] for o in objects), + min(object_bounds[o.name]["min"][2] for o in objects), + ), + "max": ( + max(object_bounds[o.name]["max"][0] for o in objects), + max(object_bounds[o.name]["max"][1] for o in objects), + max(object_bounds[o.name]["max"][2] for o in objects), + ), + } + + +def classify_runtime_representation(distance, unload_r, hlod_levels=None, lod_levels=None): + """Classify the active runtime representation for a tile distance. + + This mirrors GeometryStreamingSystem's representation order: + HLOD covers far-field tiles after the HLOD switch distance. + LOD covers mid-field tiles after the first LOD switch distance. + Full geometry covers the near band. + Unloaded only applies when no secondary representation is active. + + Note: in the engine, a loaded HLOD is only evicted once the tile leaves + GeometryStreamingSystem.maxQueryRadius (500m default) — far beyond + unload_radius, which only governs full-geometry eviction. So HLOD/LOD + take precedence over unload_radius here. + """ + hlod_levels = hlod_levels or [] + lod_levels = lod_levels or [] + + if hlod_levels and distance >= min(level["switch_distance"] for level in hlod_levels): + return "hlod" + + if lod_levels and distance >= min(level["switch_distance"] for level in lod_levels): + return "lod" + + if distance >= unload_r: + return "unloaded" + + return "full" + + +def classify_runtime_representation_detail(distance, unload_r, hlod_levels=None, lod_levels=None): + """Return full, lod1/lod2/..., hlod, or unloaded for preview diagnostics. + + See classify_runtime_representation for why HLOD/LOD take precedence over + unload_radius. + """ + hlod_levels = hlod_levels or [] + lod_levels = sorted(lod_levels or [], key=lambda l: l["switch_distance"]) + + if hlod_levels and distance >= min(level["switch_distance"] for level in hlod_levels): + return "hlod" + + active_lod_index = None + for idx, level in enumerate(lod_levels): + if distance >= level["switch_distance"]: + active_lod_index = idx + if active_lod_index is not None: + return f"lod{active_lod_index + 1}" + + if distance >= unload_r: + return "unloaded" + + return "full" + + def get_candidate_objects(): # Exclude objects in the "Tile Preview" collection — those are the wireframe # tile-bound boxes created by create_tile_preview() for visual debugging. @@ -740,6 +892,46 @@ def get_candidate_objects(): return objs +def _is_visible_for_tiled_export(obj, view_layer): + if not VISIBLE_ONLY: + return True + return not obj.hide_viewport and not obj.hide_get(view_layer=view_layer) + + +def _mesh_uses_armature(obj): + if obj.type != "MESH": + return False + if getattr(obj, "parent", None) is not None and obj.parent.type == "ARMATURE": + return True + if hasattr(obj, "find_armature") and obj.find_armature() is not None: + return True + return any(getattr(mod, "type", None) == "ARMATURE" for mod in obj.modifiers) + + +def validate_tiled_scene_static_only(): + view_layer = bpy.context.view_layer + armatures = [] + skinned_meshes = [] + for obj in bpy.context.scene.objects: + if not _is_visible_for_tiled_export(obj, view_layer): + continue + if obj.type == "ARMATURE": + armatures.append(obj.name) + elif _mesh_uses_armature(obj): + skinned_meshes.append(obj.name) + + if armatures or skinned_meshes: + details = [] + if armatures: + details.append(f"armatures: {', '.join(sorted(armatures)[:8])}") + if skinned_meshes: + details.append(f"skinned meshes: {', '.join(sorted(skinned_meshes)[:8])}") + raise RuntimeError( + "Tiled scene export supports static mesh geometry only; " + + "; ".join(details) + ) + + # ============================================================ # SECTION 2: WORLD BOUNDS # All world-space bound queries use the evaluated depsgraph so @@ -920,6 +1112,18 @@ def tile_bounds_aabb_usd(tile_bounds): }) +def node_cell_bounds_aabb_usd(node_bounds_xy, objects, object_bounds): + """Return a USD-space AABB from a tree node's XY cell and object Z extent.""" + if not node_bounds_xy or not objects: + return None + z_min = min(object_bounds[o.name]["min"][2] for o in objects) + z_max = max(object_bounds[o.name]["max"][2] for o in objects) + return aabb_to_usd_space({ + "min": (node_bounds_xy["min_x"], node_bounds_xy["min_y"], z_min), + "max": (node_bounds_xy["max_x"], node_bounds_xy["max_y"], z_max), + }) + + def compute_objects_aabb_usd(objects, object_bounds): """Compute the union AABB of a set of objects, returned in USD space. @@ -1035,6 +1239,44 @@ def _obj_prop(obj, key, default=None): return default +def _semantic_override(obj): + value = _obj_prop(obj, "untold_semantic_override") + if value is None: + return None + value = str(value) + if value in ("", "Auto"): + return None + if value not in VALID_SEMANTIC_TIERS: + print(f" Warning: {obj.name} has unsupported untold_semantic_override={value!r}; using inferred tier.") + return None + return value + + +def _untagged_semantic_default(): + return UNTAGGED_SEMANTIC_TIER if UNTAGGED_SEMANTIC_TIER in VALID_SEMANTIC_TIERS else None + + +def object_priority_hint(obj): + value = _obj_prop(obj, "untold_streaming_priority_hint") + if value is None: + return None + value = str(value) + if value in ("", "Auto"): + return None + priority = OBJECT_PRIORITY_HINTS.get(value) + if priority is None: + print(f" Warning: {obj.name} has unsupported untold_streaming_priority_hint={value!r}; using tier default.") + return priority + + +def aggregate_priority_hint(objects, default_priority): + priorities = [object_priority_hint(obj) for obj in objects] + priorities = [p for p in priorities if p is not None] + if not priorities: + return default_priority + return max(default_priority, max(priorities)) + + def read_untold_metadata(obj): """Read quadtree/semantic metadata from a Blender object. @@ -1045,25 +1287,45 @@ def read_untold_metadata(obj): Returns a dict or None if no source yields valid metadata. """ # --- Primary: Blender custom properties (bare or USD-namespaced) --- + override = _semantic_override(obj) + untagged_default = _untagged_semantic_default() node_id = _obj_prop(obj, "untold_quadtree_node_id") if node_id is not None: + semantic = override or untagged_default or str(_obj_prop(obj, "untold_semantic_guess", DEFAULT_SEMANTIC_TIER)) return { "floor_id": int(_obj_prop(obj, "untold_floor_id", 0)), "node_id": str(node_id), "depth": int(_obj_prop(obj, "untold_quadtree_depth", 0)), "spatial_class": str(_obj_prop(obj, "untold_spatial_class", "local")), - "semantic": str(_obj_prop(obj, "untold_semantic_guess", DEFAULT_SEMANTIC_TIER)), - "confidence": float(_obj_prop(obj, "untold_semantic_confidence", 0.0)), - "source": "custom_property", + "semantic": semantic, + "confidence": 1.0 if (override or untagged_default) else float(_obj_prop(obj, "untold_semantic_confidence", 0.0)), + "source": "custom_property_override" if override else ("custom_property_untagged_default" if untagged_default else "custom_property"), } # --- Secondary: parse suffix from the Xform prim name stored by Blender's USD importer --- xform_name = _obj_prop(obj, "blender:object_name") if xform_name: meta = _parse_name_suffix(str(xform_name)) if meta: + if override: + meta["semantic"] = override + meta["confidence"] = 1.0 + meta["source"] = "name_suffix_override" + elif untagged_default: + meta["semantic"] = untagged_default + meta["confidence"] = 1.0 + meta["source"] = "name_suffix_untagged_default" return meta # --- Fallback: name suffix on the Blender object name itself --- - return _parse_name_suffix(obj.name) + meta = _parse_name_suffix(obj.name) + if meta and override: + meta["semantic"] = override + meta["confidence"] = 1.0 + meta["source"] = "name_suffix_override" + elif meta and untagged_default: + meta["semantic"] = untagged_default + meta["confidence"] = 1.0 + meta["source"] = "name_suffix_untagged_default" + return meta def _parse_name_suffix(name): @@ -1178,7 +1440,8 @@ def build_quadtree_assignments(objects, object_bounds, inline_metadata=None): # unload with the floor's streaming radii and interior-zone gate. if meta["spatial_class"] == "spanning" and meta["depth"] == 0: tier = _resolve_tier(meta) - if tier == "ExteriorShell": + force_local = (_obj_prop(obj, "untold_tile_policy") == "force_local") + if tier == "ExteriorShell" and not force_local: shared_objects.append(obj) else: # Use the node_id already stored in metadata — it is the floor root @@ -1204,6 +1467,24 @@ def quadtree_tile_id(node_id, tier): return sanitize_name(f"{node_id}_{code}") +def group_metadata(tile_objs, metadata_map): + return [metadata_map.get(obj.name) for obj in tile_objs if metadata_map.get(obj.name) is not None] + + +def group_has_spanning_metadata(tile_objs, metadata_map): + return any(meta.get("spatial_class") == "spanning" for meta in group_metadata(tile_objs, metadata_map)) + + +def group_cell_bounds_xy(tile_objs, metadata_map): + if group_has_spanning_metadata(tile_objs, metadata_map): + return None + for meta in group_metadata(tile_objs, metadata_map): + cell = meta.get("cell_bounds_xy") + if cell: + return cell + return None + + # ============================================================ # SECTION 4.6: INLINE QUADTREE ANNOTATION # Reproduces the logic from untold_phase12_suffix-Blender.py entirely inside @@ -1263,6 +1544,175 @@ def _qt_descend(node, rect, max_depth): return _qt_descend(overlapping[0], rect, max_depth) +def _partition_parent_node_id(node_id): + if "_" not in node_id: + return None + parent = node_id.rsplit("_", 1)[0] + if parent == node_id: + return None + return parent + + +def _qt_find_or_create_node(root, node_id): + if root.node_id == node_id: + return root + suffix = node_id[len(root.node_id):] + if not suffix.startswith("_"): + return None + node = root + for part in suffix[1:].split("_"): + if not part: + continue + try: + child_index = int(part) + except ValueError: + return None + if child_index < 0 or child_index > 3: + return None + if not node.children: + node.subdivide() + node = node.children[child_index] + return node + + +def _update_meta_node(meta, node, source_suffix): + meta["node_id"] = node.node_id + meta["depth"] = node.depth + meta["cell_bounds_xy"] = { + "min_x": node.min_x, + "min_y": node.min_y, + "max_x": node.max_x, + "max_y": node.max_y, + } + source = str(meta.get("source", "")) + if source_suffix not in source: + meta["source"] = f"{source}{source_suffix}" if source else source_suffix.lstrip("_") + + +def _print_tile_tier_quality(label, metadata_dict): + groups = {} + depth_hist = {} + for meta in metadata_dict.values(): + key = (meta.get("node_id"), meta.get("semantic")) + groups[key] = groups.get(key, 0) + 1 + depth = int(meta.get("depth", 0)) + depth_hist[depth] = depth_hist.get(depth, 0) + 1 + + if not groups: + return + + counts = sorted(groups.values()) + total_groups = len(counts) + singleton_groups = sum(1 for c in counts if c == 1) + under_min_groups = sum(1 for c in counts if c < INLINE_MIN_OBJECTS_PER_TILE_TIER) + + def percentile(sorted_values, p): + if not sorted_values: + return 0 + idx = int(math.ceil((p / 100.0) * len(sorted_values))) - 1 + idx = max(0, min(idx, len(sorted_values) - 1)) + return sorted_values[idx] + + print(f" [{label}] tile-tier quality:") + print( + f" groups={total_groups} singleton={singleton_groups} " + f"under_min<{INLINE_MIN_OBJECTS_PER_TILE_TIER}={under_min_groups}" + ) + print( + f" objects/group min={counts[0]} p50={percentile(counts, 50)} " + f"p95={percentile(counts, 95)} max={counts[-1]} " + f"avg={sum(counts) / len(counts):.1f}" + ) + depth_parts = [f"d{depth}:{count}" for depth, count in sorted(depth_hist.items())] + print(f" object depth histogram: {' '.join(depth_parts)}") + + +def print_node_tier_group_quality(label, node_tier_groups): + if not node_tier_groups: + return + counts = sorted(len(objs) for objs in node_tier_groups.values()) + if not counts: + return + singleton_groups = sum(1 for c in counts if c == 1) + under_min_groups = sum(1 for c in counts if c < INLINE_MIN_OBJECTS_PER_TILE_TIER) + by_tier = {} + by_depth = {} + for (node_id, tier), objs in node_tier_groups.items(): + by_tier[tier] = by_tier.get(tier, 0) + 1 + depth = max(0, node_id.count("_") - 1) + by_depth[depth] = by_depth.get(depth, 0) + 1 + + def percentile(sorted_values, p): + idx = int(math.ceil((p / 100.0) * len(sorted_values))) - 1 + idx = max(0, min(idx, len(sorted_values) - 1)) + return sorted_values[idx] + + print(f"\n {label} tile-tier quality:") + print( + f" groups={len(counts)} singleton={singleton_groups} " + f"under_min<{INLINE_MIN_OBJECTS_PER_TILE_TIER}={under_min_groups}" + ) + print( + f" objects/group min={counts[0]} p50={percentile(counts, 50)} " + f"p95={percentile(counts, 95)} max={counts[-1]} " + f"avg={sum(counts) / len(counts):.1f}" + ) + tier_parts = [f"{tier}:{count}" for tier, count in sorted(by_tier.items())] + depth_parts = [f"d{depth}:{count}" for depth, count in sorted(by_depth.items())] + print(f" groups by tier : {' '.join(tier_parts)}") + print(f" groups by depth: {' '.join(depth_parts)}") + + +def _collapse_underfilled_tile_tiers(metadata_dict, root_for_floor, find_node, label): + if not INLINE_COLLAPSE_UNDERFILLED_TILE_TIERS or INLINE_MIN_OBJECTS_PER_TILE_TIER <= 1: + _print_tile_tier_quality(label, metadata_dict) + return + + groups = {} + for obj_name, meta in metadata_dict.items(): + key = (meta.get("node_id"), meta.get("semantic")) + groups.setdefault(key, set()).add(obj_name) + + moved = 0 + changed = True + while changed: + changed = False + for obj_name in sorted(metadata_dict.keys()): + meta = metadata_dict[obj_name] + if meta.get("spatial_class") != "local": + continue + node_id = meta.get("node_id") + tier = meta.get("semantic") + key = (node_id, tier) + if len(groups.get(key, ())) >= INLINE_MIN_OBJECTS_PER_TILE_TIER: + continue + + parent_id = _partition_parent_node_id(node_id) + if parent_id is None: + continue + + root = root_for_floor(meta.get("floor_id")) + parent = find_node(root, parent_id) if root is not None else None + if parent is None: + continue + + groups[key].discard(obj_name) + if not groups[key]: + groups.pop(key, None) + parent_key = (parent.node_id, tier) + groups.setdefault(parent_key, set()).add(obj_name) + _update_meta_node(meta, parent, "_collapsed") + moved += 1 + changed = True + + if moved: + print( + f" [{label}] collapsed {moved} object assignment(s) from underfilled " + f"tile-tier groups (<{INLINE_MIN_OBJECTS_PER_TILE_TIER} objects)" + ) + _print_tile_tier_quality(label, metadata_dict) + + # ============================================================ # SECTION 4.65: KD-TREE SPATIAL PARTITIONING # Alternative to the quadtree: at each node, splits on the @@ -1387,6 +1837,14 @@ def _kd_assign_rect(node, rect_min_x, rect_min_y, rect_max_x, rect_max_y, cx, cy return _kd_assign_rect(child, rect_min_x, rect_min_y, rect_max_x, rect_max_y, cx, cy) +def _kd_collect_nodes(node, out): + if node is None: + return + out[node.node_id] = node + _kd_collect_nodes(node.left, out) + _kd_collect_nodes(node.right, out) + + def compute_inline_kdtree_metadata(objects, object_bounds): """Run floor + KD-tree + semantic annotation inline on imported objects. @@ -1432,18 +1890,7 @@ def compute_inline_kdtree_metadata(objects, object_bounds): scene_max_z = global_max[2] scene_z_span = max(scene_max_z - scene_min_z, 0.001) - if INLINE_FLOOR_COUNT_OVERRIDE and INLINE_FLOOR_BAND_HEIGHT_OVERRIDE: - floor_count = max(1, int(INLINE_FLOOR_COUNT_OVERRIDE)) - band_height = float(INLINE_FLOOR_BAND_HEIGHT_OVERRIDE) - elif INLINE_FLOOR_COUNT_OVERRIDE: - floor_count = max(1, int(INLINE_FLOOR_COUNT_OVERRIDE)) - band_height = scene_z_span / floor_count - elif INLINE_FLOOR_BAND_HEIGHT_OVERRIDE: - band_height = float(INLINE_FLOOR_BAND_HEIGHT_OVERRIDE) - floor_count = max(1, int(_math.ceil(scene_z_span / band_height))) - else: - band_height = INLINE_AUTO_FLOOR_BAND_HEIGHT or _inline_estimate_floor_band_height(object_cache) - floor_count = max(1, int(_math.ceil(scene_z_span / band_height))) + floor_count, band_height = _resolve_inline_floor_layout(object_cache, scene_z_span) print(f" [inline kd-tree] floor band height: {band_height:.2f}m, floors: {floor_count}") @@ -1467,6 +1914,11 @@ def compute_inline_kdtree_metadata(objects, object_bounds): max_depth=INLINE_KDTREE_MAX_DEPTH, min_leaf=INLINE_KDTREE_MIN_LEAF, ) + floor_node_maps = {} + for fid, root in floor_roots.items(): + node_map = {} + _kd_collect_nodes(root, node_map) + floor_node_maps[fid + 1] = node_map # --- Pass 2: assign each object to its KD-tree node + semantic tier --- # Spanning detection: if an object's AABB crosses a KD split plane, it is @@ -1494,15 +1946,26 @@ def compute_inline_kdtree_metadata(objects, object_bounds): volume = max(dims[0], 0.0) * max(dims[1], 0.0) * max(dims[2], 0.0) materials = _inline_get_material_names(obj) semantic, confidence = _inline_semantic_guess(obj.name, materials, dims, volume) + override = _semantic_override(obj) + if override: + semantic, confidence = override, 1.0 + elif _untagged_semantic_default(): + semantic, confidence = _untagged_semantic_default(), 1.0 metadata_dict[obj.name] = { "floor_id": fid + 1, "node_id": node.node_id, "depth": node.depth, + "cell_bounds_xy": { + "min_x": node.min_x, + "min_y": node.min_y, + "max_x": node.max_x, + "max_y": node.max_y, + }, "spatial_class": spatial_class, "semantic": semantic, "confidence": confidence, - "source": "inline_kdtree", + "source": "inline_kdtree_override" if override else ("inline_kdtree_untagged_default" if _untagged_semantic_default() else "inline_kdtree"), } leaf_object_counts[node.node_id] = leaf_object_counts.get(node.node_id, 0) + 1 @@ -1511,6 +1974,13 @@ def compute_inline_kdtree_metadata(objects, object_bounds): print(f" [inline kd-tree] annotated {annotated}/{len(objects)} objects " f"({span_count} spanning → shared/floor-root routing)") + _collapse_underfilled_tile_tiers( + metadata_dict, + lambda floor_id: floor_roots.get(int(floor_id) - 1) if floor_id else None, + lambda root, node_id: floor_node_maps.get(int(root.node_id[1:3]), {}).get(node_id) if root is not None else None, + "inline kd-tree", + ) + # --- Heavy-leaf diagnostics --- if leaf_object_counts: max_objs = max(leaf_object_counts.values()) @@ -1539,6 +2009,34 @@ def _inline_assign_floor_id(center_z, scene_min_z, band_height): return int(_math.floor((center_z - scene_min_z) / max(band_height, 0.001))) +def _resolve_inline_floor_layout(object_cache, scene_z_span): + """Resolve floor bands for inline tree annotation. + + Outdoor scenes usually represent one exterior ground-plane layer, even when + buildings have large vertical extents. Auto floor slicing remains available + for indoor/auto profiles and for explicit user overrides. + """ + import math as _math + + if INLINE_FLOOR_COUNT_OVERRIDE and INLINE_FLOOR_BAND_HEIGHT_OVERRIDE: + floor_count = max(1, int(INLINE_FLOOR_COUNT_OVERRIDE)) + band_height = float(INLINE_FLOOR_BAND_HEIGHT_OVERRIDE) + elif INLINE_FLOOR_COUNT_OVERRIDE: + floor_count = max(1, int(INLINE_FLOOR_COUNT_OVERRIDE)) + band_height = scene_z_span / floor_count + elif INLINE_FLOOR_BAND_HEIGHT_OVERRIDE: + band_height = float(INLINE_FLOOR_BAND_HEIGHT_OVERRIDE) + floor_count = max(1, int(_math.ceil(scene_z_span / band_height))) + elif (SCENE_STREAMING_PROFILE or "auto").lower() == "outdoor": + floor_count = 1 + band_height = scene_z_span + else: + band_height = INLINE_AUTO_FLOOR_BAND_HEIGHT or _inline_estimate_floor_band_height(object_cache) + floor_count = max(1, int(_math.ceil(scene_z_span / band_height))) + + return floor_count, max(float(band_height), 0.001) + + def _inline_get_material_names(obj): out = [] if obj.data and hasattr(obj.data, "materials"): @@ -1632,22 +2130,7 @@ def compute_inline_quadtree_metadata(objects, object_bounds): scene_max_z = global_max[2] scene_z_span = max(scene_max_z - scene_min_z, 0.001) - if INLINE_FLOOR_COUNT_OVERRIDE and INLINE_FLOOR_BAND_HEIGHT_OVERRIDE: - # Both pinned — user knows exactly what they want. - floor_count = max(1, int(INLINE_FLOOR_COUNT_OVERRIDE)) - band_height = float(INLINE_FLOOR_BAND_HEIGHT_OVERRIDE) - elif INLINE_FLOOR_COUNT_OVERRIDE: - # Floor count pinned — derive band height from scene Z span. - floor_count = max(1, int(INLINE_FLOOR_COUNT_OVERRIDE)) - band_height = scene_z_span / floor_count - elif INLINE_FLOOR_BAND_HEIGHT_OVERRIDE: - # Band height pinned — derive floor count from scene Z span. - band_height = float(INLINE_FLOOR_BAND_HEIGHT_OVERRIDE) - floor_count = max(1, int(_math.ceil(scene_z_span / band_height))) - else: - # Fully auto: estimate band height from object Z dimensions. - band_height = INLINE_AUTO_FLOOR_BAND_HEIGHT or _inline_estimate_floor_band_height(object_cache) - floor_count = max(1, int(_math.ceil(scene_z_span / band_height))) + floor_count, band_height = _resolve_inline_floor_layout(object_cache, scene_z_span) print(f" [inline annotation] floor band height: {band_height:.2f}m, floors: {floor_count}") @@ -1678,19 +2161,36 @@ def compute_inline_quadtree_metadata(objects, object_bounds): materials = _inline_get_material_names(obj) semantic, confidence = _inline_semantic_guess(obj.name, materials, dims, volume) + override = _semantic_override(obj) + if override: + semantic, confidence = override, 1.0 + elif _untagged_semantic_default(): + semantic, confidence = _untagged_semantic_default(), 1.0 metadata_dict[obj.name] = { "floor_id": floor_id + 1, # 1-based to match annotation script "node_id": node.node_id, "depth": node.depth, + "cell_bounds_xy": { + "min_x": node.min_x, + "min_y": node.min_y, + "max_x": node.max_x, + "max_y": node.max_y, + }, "spatial_class": spatial_class, "semantic": semantic, "confidence": confidence, - "source": "inline", + "source": "inline_override" if override else ("inline_untagged_default" if _untagged_semantic_default() else "inline"), } annotated = len(metadata_dict) print(f" [inline annotation] annotated {annotated}/{len(objects)} objects") + _collapse_underfilled_tile_tiers( + metadata_dict, + lambda floor_id: floor_roots.get(int(floor_id) - 1) if floor_id else None, + _qt_find_or_create_node, + "inline quadtree", + ) return metadata_dict @@ -1741,9 +2241,12 @@ def build_assignments(objects, object_bounds, origin_x, origin_y, origin_z, xz_count = xz_tile_overlap_count( aabb, origin_x, origin_z, tile_size_x, tile_size_z, SPLIT_CLIP_EPSILON ) - classification_map[obj.name] = classify_mesh( - aabb, tile_size_x, tile_size_z, xz_count, eff_overlap_thr - ) + result = classify_mesh(aabb, tile_size_x, tile_size_z, xz_count, eff_overlap_thr) + if (_obj_prop(obj, "untold_tile_policy") == "force_local" + and result["policy"] in ("shared_bucket", "future_split_candidate")): + result = dict(result) + result["policy"] = "local_overlap" + classification_map[obj.name] = result # Build the bounding box of local-only objects. Spanning objects are # clamped to this box so their tile assignments stay within the populated @@ -3013,8 +3516,6 @@ def centroid_assignments_for_sizing(objects, object_bounds, def choose_auto_tile_size(objects, object_bounds, scene_bounds, origin_y, tile_size_y): - origin_x = scene_bounds["min"][0] - origin_z = scene_bounds["min"][1] extent_x = max(scene_bounds["max"][0] - scene_bounds["min"][0], 1e-9) extent_z = max(scene_bounds["max"][1] - scene_bounds["min"][1], 1e-9) scene_area = extent_x * extent_z @@ -3030,6 +3531,8 @@ def choose_auto_tile_size(objects, object_bounds, scene_bounds, origin_y, tile_s met_tiles = False; met_obj = False for it in range(1, max(1, int(AUTO_TILE_MAX_ITERATIONS)) + 1): + origin_x = math.floor(scene_bounds["min"][0] / tile_size) * tile_size + origin_z = math.floor(scene_bounds["min"][1] / tile_size) * tile_size asgn = centroid_assignments_for_sizing(objects, object_bounds, origin_x, origin_y, origin_z, tile_size, tile_size_y, tile_size) @@ -3301,7 +3804,12 @@ def _run_worker_mode(work_bundle_path: str, result_file_path: str) -> None: tile_results = [] tile_specs = bundle.get("tiles", []) - total_assets = sum(1 + len(active_hlod_levels) + len(active_lod_levels) for _ in tile_specs) + total_assets = sum( + 1 + + len(tile_spec.get("active_hlod_levels", active_hlod_levels)) + + len(tile_spec.get("active_lod_levels", active_lod_levels)) + for tile_spec in tile_specs + ) completed_assets = 0 append_worker_progress(progress_file, { "event": "start", @@ -3316,6 +3824,8 @@ def _run_worker_mode(work_bundle_path: str, result_file_path: str) -> None: filepath = tile_spec["filepath"] tile_bounds = tile_spec["tile_bounds"] obj_names = tile_spec["object_names"] + tile_hlod_levels = tile_spec.get("active_hlod_levels", active_hlod_levels) + tile_lod_levels = tile_spec.get("active_lod_levels", active_lod_levels) objects = [bpy.data.objects.get(n) for n in obj_names] objects = [o for o in objects if o is not None] @@ -3352,7 +3862,7 @@ def _run_worker_mode(work_bundle_path: str, result_file_path: str) -> None: hlod_results = [] if ok and not DEBUG_AABB_ONLY: - for level in active_hlod_levels: + for level in tile_hlod_levels: hlod_filepath = os.path.join( os.path.dirname(filepath), f"{tile_id}{level['suffix']}.{ext}", @@ -3388,7 +3898,7 @@ def _run_worker_mode(work_bundle_path: str, result_file_path: str) -> None: lod_results = [] if ok and not DEBUG_AABB_ONLY: - for lod_idx, lod in enumerate(active_lod_levels): + for lod_idx, lod in enumerate(tile_lod_levels): lod_n = lod_idx + 1 lod_filepath = os.path.join( os.path.dirname(filepath), @@ -3930,6 +4440,7 @@ def run(): # Gather objects and compute world bounds # ------------------------------------------------------------------ print_export_stage("Analyze scene") + validate_tiled_scene_static_only() objects = get_candidate_objects() if not objects: print("No mesh objects found.") @@ -3938,10 +4449,12 @@ def run(): object_bounds = compute_object_bounds(objects) scene_bounds = scene_world_bounds(objects) # Blender (X, Y_depth, Z_height) - # Origins for tile coordinate mapping - origin_x = scene_bounds["min"][0] # Blender X - origin_y = scene_bounds["min"][2] # Blender Z height → tile Y - origin_z = scene_bounds["min"][1] # Blender Y depth → tile Z + # Origins for tile coordinate mapping. + # Snap to the nearest world-aligned tile boundary so grid cells are always + # multiples of tile_size from the world origin. Without this, the grid + # anchors at scene_min and objects near the origin end up in the corner of + # their tile rather than inside a stable, predictable cell. + origin_y = scene_bounds["min"][2] # Blender Z height → tile Y # ------------------------------------------------------------------ # Tile sizing (manual or auto) @@ -3966,6 +4479,9 @@ def run(): tile_size_x, tile_size_y, tile_size_z = TILE_SIZE_X, TILE_SIZE_Y, TILE_SIZE_Z auto_info = None + origin_x = math.floor(scene_bounds["min"][0] / tile_size_x) * tile_size_x # Blender X + origin_z = math.floor(scene_bounds["min"][1] / tile_size_z) * tile_size_z # Blender Y depth → tile Z + base_tile = max(tile_size_x, tile_size_z) # ------------------------------------------------------------------ @@ -3976,27 +4492,44 @@ def run(): (scene_bounds["max"][1] - scene_bounds["min"][1]) ** 2 ) streaming_r, unload_r = compute_streaming_defaults(base_tile, scene_half_diag) + # When the user has explicitly configured tier radii, honour them for the + # uniform-grid representation ladder too. Without this, LOD/HLOD switch + # distances are computed against the narrow auto-computed defaults + # (e.g. 38/57 m for a 22 m tile) even though the user set 80/150 m, which + # produces ~4 m bands that are visually instantaneous. ExteriorShell is the + # dominant tier for outdoor/city scenes; fall back to StructuralInterior if + # only that override is present. + _grid_override = ( + TIER_RADIUS_OVERRIDES.get("ExteriorShell") + or TIER_RADIUS_OVERRIDES.get("StructuralInterior") + ) + if _grid_override: + _ov_s = _grid_override.get("streaming", 0.0) + _ov_u = _grid_override.get("unload", 0.0) + if _ov_s > 0.0 and _ov_u > _ov_s: + streaming_r, unload_r = _ov_s, _ov_u shared_r, shared_ur = compute_shared_streaming_radii(scene_half_diag) - # Resolve the representation ladder into world-space switch distances. - # HLOD sits near the unload edge; tile LODs are eased across the mid band. - active_hlod_levels = compute_hlod_switch_distances( + # Resolve a default representation ladder for progress accounting and logs. + # Manifest tile entries are resolved again using each tile's tier-specific + # streaming/unload radii. + default_hlod_levels = compute_hlod_switch_distances( streaming_r, unload_r, active_hlod_levels, ) - active_lod_levels = compute_lod_switch_distances( + default_lod_levels = compute_lod_switch_distances( streaming_r, unload_r, - active_hlod_levels, + default_hlod_levels, active_lod_levels, ) - if active_hlod_levels or active_lod_levels: + if default_hlod_levels or default_lod_levels: print( - "Resolved streaming ladder: " + "Resolved default streaming ladder: " f"stream={streaming_r:.2f}, unload={unload_r:.2f}, " - f"HLOD={[l['switch_distance'] for l in active_hlod_levels]}, " - f"LOD={[l['switch_distance'] for l in active_lod_levels]}" + f"HLOD={[l['switch_distance'] for l in default_hlod_levels]}, " + f"LOD={[l['switch_distance'] for l in default_lod_levels]}" ) # ------------------------------------------------------------------ @@ -4040,6 +4573,7 @@ def run(): f"{mode_str} groups: {len(node_tier_groups)} tile-tier pairs, " f"{len(shared_objects)} shared-bucket objects" ) + print_node_tier_group_quality("Final partition", node_tier_groups) else: print("No quadtree metadata detected — using uniform grid partitioning.") tile_assignments, shared_objects, classification_map = build_assignments( @@ -4177,9 +4711,18 @@ def run(): "overlap_threshold": OVERLAP_THRESHOLD, "future_split_tile_threshold": FUTURE_SPLIT_TILE_THRESHOLD, }, + "partition_quality_config": { + "collapse_underfilled_tile_tiers": ( + bool(INLINE_COLLAPSE_UNDERFILLED_TILE_TIERS) + if use_quadtree and not pre_annotated else False + ), + "min_objects_per_tile_tier": INLINE_MIN_OBJECTS_PER_TILE_TIER, + "applies_to_preannotated_metadata": False, + }, "hlod_generation": { "enabled": bool(active_hlod_levels), "levels": active_hlod_levels, + "default_resolved_levels": default_hlod_levels, }, "lod_generation": { "enabled": bool(active_lod_levels), @@ -4187,6 +4730,10 @@ def run(): {"decimate_ratio": l["ratio"], "switch_distance": l["switch_distance"]} for l in active_lod_levels ], + "default_resolved_levels": [ + {"decimate_ratio": l["ratio"], "switch_distance": l["switch_distance"]} + for l in default_lod_levels + ], }, "object_classification": { name: { @@ -4229,6 +4776,16 @@ def run(): print(f" {tier:25s}: {count:5d} objects " f"stream={radii.get('streaming','?')}m " f"unload={radii.get('unload','?')}m") + spanning_secondary_count = sum( + 1 + for (_node_id, tier), objs in node_tier_groups.items() + if objs and tier in HLOD_LOD_TIERS and group_has_spanning_metadata(objs, metadata_map) + ) + if spanning_secondary_count: + print( + f" Secondary reps : {spanning_secondary_count} spanning tile-tier pair(s) " + "will receive LOD/HLOD (same ladder as leaf tiles)" + ) if use_kdtree: # KD-tree leaf balance report — shows whether the tree is producing @@ -4258,10 +4815,29 @@ def run(): tile_id = quadtree_tile_id(node_id, tier) filepath = os.path.join(output_dir, f"{tile_id}.{ext}") aabb_usd = compute_objects_aabb_usd(tile_objs, object_bounds) + cell_aabb_usd = node_cell_bounds_aabb_usd( + group_cell_bounds_xy(tile_objs, metadata_map), + tile_objs, + object_bounds, + ) center = aabb_center(aabb_usd) if aabb_usd else [0,0,0] est_mem = sum(estimate_object_memory_bytes(o, mesh_size_cache) for o in tile_objs) tier_radii = tier_streaming_radii(tier) + tile_stream = tier_radii.get("streaming", streaming_r) + tile_unload = tier_radii.get("unload", unload_r) + tile_priority = aggregate_priority_hint( + tile_objs, + tier_radii.get("priority", DEFAULT_STREAMING_PRIORITY), + ) + is_spanning_group = group_has_spanning_metadata(tile_objs, metadata_map) + tier_wants_hlod_lod = tier in HLOD_LOD_TIERS + tile_hlod_levels, tile_lod_levels = resolve_tile_representation_levels( + tile_stream, + tile_unload, + active_hlod_levels if tier_wants_hlod_lod else [], + active_lod_levels if tier_wants_hlod_lod else [], + ) floor_id = 0 for obj in tile_objs: m = metadata_map.get(obj.name) @@ -4274,15 +4850,40 @@ def run(): "quadtree_node_id": node_id, "semantic_tier": tier, "path_relative_to_manifest": os.path.relpath(filepath, model_dir), - "streaming_radius": tier_radii.get("streaming", streaming_r), - "unload_radius": tier_radii.get("unload", unload_r), - "priority": tier_radii.get("priority", DEFAULT_STREAMING_PRIORITY), - "hlod_levels": [], "lod_levels": [], + "streaming_radius": tile_stream, + "unload_radius": tile_unload, + "priority": tile_priority, + "hlod_levels": [ + { + "path": os.path.relpath( + os.path.join(output_dir, f"{tile_id}{level['suffix']}.{ext}"), + model_dir, + ), + "switch_distance": level["switch_distance"], + } + for level in tile_hlod_levels + ], + "lod_levels": [ + { + "path": os.path.relpath( + os.path.join(output_dir, f"{tile_id}_lod{lod_idx + 1}.{ext}"), + model_dir, + ), + "switch_distance": lod["switch_distance"], + } + for lod_idx, lod in enumerate(tile_lod_levels) + ], "interior": tier != "ExteriorShell", "file_size_bytes": 0, "estimated_memory_bytes": est_mem, "bounds": {"min": list(aabb_usd["min"]), "max": list(aabb_usd["max"])} if aabb_usd else {"min": [0,0,0], "max": [0,0,0]}, + "cell_bounds": {"min": list(cell_aabb_usd["min"]), "max": list(cell_aabb_usd["max"])} + if cell_aabb_usd else ( + {"min": list(aabb_usd["min"]), "max": list(aabb_usd["max"])} + if aabb_usd else {"min": [0,0,0], "max": [0,0,0]} + ), + "secondary_representation_policy": "none_spanning_group" if is_spanning_group else "normal", "center": list(center), "object_count": len(tile_objs), }) @@ -4314,10 +4915,12 @@ def run(): aabb_usd = tile_bounds_aabb_usd(tile_bounds) center = aabb_center(aabb_usd) est_mem = estimate_tile_memory(tile_objs, tile_coverage_counts, mesh_size_cache) + tile_priority = aggregate_priority_hint(tile_objs, DEFAULT_STREAMING_PRIORITY) tile_entry = { "tile_id": tile_id, "grid_coord": [tx, ty, tz], "path_relative_to_manifest": os.path.relpath(filepath, model_dir), + "priority": tile_priority, "hlod_levels": [ { "path": os.path.relpath( @@ -4326,7 +4929,7 @@ def run(): ), "switch_distance": level["switch_distance"], } - for level in active_hlod_levels + for level in default_hlod_levels ], "file_size_bytes": 0, "estimated_memory_bytes": est_mem, @@ -4335,7 +4938,7 @@ def run(): "object_count": len(tile_objs), "objects": [o.name for o in tile_objs], } - if active_lod_levels: + if default_lod_levels: tile_entry["lod_levels"] = [ { "path": os.path.relpath( @@ -4344,7 +4947,7 @@ def run(): ), "switch_distance": lod["switch_distance"], } - for lod_idx, lod in enumerate(active_lod_levels) + for lod_idx, lod in enumerate(default_lod_levels) ] manifest["tiles"].append(tile_entry) @@ -4354,13 +4957,14 @@ def run(): shared_center = aabb_center(shared_aabb_usd) if shared_aabb_usd else [0,0,0] shared_est_mem = sum(estimate_object_memory_bytes(o, mesh_size_cache) for o in shared_objects) + shared_priority = aggregate_priority_hint(shared_objects, DEFAULT_STREAMING_PRIORITY) manifest["shared_bucket"] = { "tile_id": "shared", "path_relative_to_manifest": os.path.relpath(shared_filepath, model_dir), "export_policy": "shared_bucket", "streaming_radius": shared_r, "unload_radius": shared_ur, - "priority": DEFAULT_STREAMING_PRIORITY, + "priority": shared_priority, "file_size_bytes": 0, "estimated_memory_bytes": shared_est_mem, "bounds": ({"min": list(shared_aabb_usd["min"]), @@ -4403,7 +5007,7 @@ def run(): if not tile_objs: continue planned_local_assets += 1 - if quadtree_parallel or tier in HLOD_LOD_TIERS: + if tier in HLOD_LOD_TIERS: planned_local_assets += len(active_hlod_levels) + len(active_lod_levels) else: non_empty_tiles = sum(1 for _coord, tile_objs in tile_assignments.items() if tile_objs) @@ -4411,6 +5015,7 @@ def run(): export_progress = ProgressReporter( "tile export", (1 if shared_objects else 0) + planned_local_assets + 1, + on_progress=PROGRESS_CALLBACK, ) export_progress.stage("Start", f"{planned_local_assets} tile asset(s), {len(shared_objects)} shared object(s)") @@ -4432,13 +5037,14 @@ def run(): shared_file_sz = os.path.getsize(shared_filepath) if os.path.isfile(shared_filepath) else 0 shared_est_mem = sum(estimate_object_memory_bytes(o, mesh_size_cache) for o in shared_objects) + shared_priority = aggregate_priority_hint(shared_objects, DEFAULT_STREAMING_PRIORITY) manifest["shared_bucket"] = { "tile_id": "shared", "path_relative_to_manifest": os.path.relpath(shared_filepath, model_dir), "export_policy": "shared_bucket", "streaming_radius": shared_r, "unload_radius": shared_ur, - "priority": DEFAULT_STREAMING_PRIORITY, + "priority": shared_priority, "file_size_bytes": shared_file_sz, "estimated_memory_bytes": shared_est_mem, "bounds": ({"min": list(shared_aabb_usd["min"]), @@ -4473,14 +5079,16 @@ def run(): # Attempt parallel export; falls back to None when PARALLEL_WORKERS=1 # or there are too few tiles to justify subprocesses. - qt_parallel_results = _export_quadtree_tiles_parallel( - sorted_groups, - source_scene_path, - output_dir, - ext, - active_hlod_levels=active_hlod_levels, - active_lod_levels=active_lod_levels, - ) + qt_parallel_results = None + if not active_hlod_levels and not active_lod_levels: + qt_parallel_results = _export_quadtree_tiles_parallel( + sorted_groups, + source_scene_path, + output_dir, + ext, + active_hlod_levels=[], + active_lod_levels=[], + ) for (node_id, tier), tile_objs in sorted_groups: if not tile_objs: @@ -4489,6 +5097,11 @@ def run(): tile_id = quadtree_tile_id(node_id, tier) filepath = os.path.join(output_dir, f"{tile_id}.{ext}") aabb_usd = compute_objects_aabb_usd(tile_objs, object_bounds) + cell_aabb_usd = node_cell_bounds_aabb_usd( + group_cell_bounds_xy(tile_objs, metadata_map), + tile_objs, + object_bounds, + ) center = aabb_center(aabb_usd) if aabb_usd else [0.0, 0.0, 0.0] est_mem = sum(estimate_object_memory_bytes(o, mesh_size_cache) for o in tile_objs) @@ -4497,7 +5110,10 @@ def run(): tier_radii = tier_streaming_radii(tier) tile_stream = tier_radii.get("streaming", streaming_r) tile_unload = tier_radii.get("unload", unload_r) - tile_priority = tier_radii.get("priority", DEFAULT_STREAMING_PRIORITY) + tile_priority = aggregate_priority_hint( + tile_objs, + tier_radii.get("priority", DEFAULT_STREAMING_PRIORITY), + ) # Derive a representative floor_id from the objects in this group. floor_id = 0 @@ -4509,7 +5125,14 @@ def run(): # HLOD/LOD is only useful for tiers with radii large enough to form a # meaningful switch band (ExteriorShell, StructuralInterior). + is_spanning_group = group_has_spanning_metadata(tile_objs, metadata_map) tier_wants_hlod_lod = tier in HLOD_LOD_TIERS + tile_hlod_levels, tile_lod_levels = resolve_tile_representation_levels( + tile_stream, + tile_unload, + active_hlod_levels if tier_wants_hlod_lod else [], + active_lod_levels if tier_wants_hlod_lod else [], + ) if qt_parallel_results is not None: # --- Parallel path: tile was exported by a worker subprocess --- @@ -4520,18 +5143,20 @@ def run(): hlod_entries = [ { "path": os.path.relpath(r["filepath"], model_dir), - "switch_distance": r["switch_distance"], + "switch_distance": tile_hlod_levels[idx]["switch_distance"], } - for r in (result.get("hlod_results", []) if result else []) + for idx, r in enumerate(result.get("hlod_results", []) if result else []) if r.get("ok") + and idx < len(tile_hlod_levels) ] lod_entries = [ { "path": os.path.relpath(r["filepath"], model_dir), - "switch_distance": r["switch_distance"], + "switch_distance": tile_lod_levels[idx]["switch_distance"], } - for r in (result.get("lod_results", []) if result else []) + for idx, r in enumerate(result.get("lod_results", []) if result else []) if r.get("ok") + and idx < len(tile_lod_levels) ] else: # --- Sequential path --- @@ -4549,7 +5174,7 @@ def run(): hlod_entries = [] lod_entries = [] if ok and tier_wants_hlod_lod: - for level in active_hlod_levels: + for level in tile_hlod_levels: hlod_filename = f"{tile_id}{level['suffix']}.{ext}" hlod_filepath = os.path.join(output_dir, hlod_filename) print( @@ -4571,7 +5196,7 @@ def run(): "switch_distance": level["switch_distance"], }) - for lod_idx, lod in enumerate(active_lod_levels): + for lod_idx, lod in enumerate(tile_lod_levels): lod_n = lod_idx + 1 lod_filename = f"{tile_id}_lod{lod_n}.{ext}" lod_filepath = os.path.join(output_dir, lod_filename) @@ -4624,6 +5249,12 @@ def run(): "estimated_memory_bytes": est_mem, "bounds": {"min": list(aabb_usd["min"]), "max": list(aabb_usd["max"])} if aabb_usd else {"min": [0,0,0], "max": [0,0,0]}, + "cell_bounds": {"min": list(cell_aabb_usd["min"]), "max": list(cell_aabb_usd["max"])} + if cell_aabb_usd else ( + {"min": list(aabb_usd["min"]), "max": list(aabb_usd["max"])} + if aabb_usd else {"min": [0,0,0], "max": [0,0,0]} + ), + "secondary_representation_policy": "none_spanning_group" if is_spanning_group else "normal", "center": list(center), "object_count": len(tile_objs), } @@ -4700,8 +5331,8 @@ def run(): sorted_tiles, source_scene_path, output_dir, - active_hlod_levels, - active_lod_levels, + default_hlod_levels, + default_lod_levels, origin_x, origin_y, origin_z, tile_size_x, tile_size_y, tile_size_z, ) @@ -4719,6 +5350,7 @@ def run(): aabb_usd = tile_bounds_aabb_usd(tile_bounds) center = aabb_center(aabb_usd) est_mem = estimate_tile_memory(tile_objs, tile_coverage_counts, mesh_size_cache) + tile_priority = aggregate_priority_hint(tile_objs, DEFAULT_STREAMING_PRIORITY) if parallel_results is not None: # --- Parallel path: tile was exported by a worker subprocess --- @@ -4781,7 +5413,7 @@ def run(): continue hlod_entries = [] - for level in active_hlod_levels: + for level in default_hlod_levels: hlod_filename = f"{tile_id}{level['suffix']}.{ext}" hlod_filepath = os.path.join(output_dir, hlod_filename) print( @@ -4804,7 +5436,7 @@ def run(): }) lod_entries = [] - for lod_idx, lod in enumerate(active_lod_levels): + for lod_idx, lod in enumerate(default_lod_levels): lod_n = lod_idx + 1 lod_filename = f"{tile_id}_lod{lod_n}.{ext}" lod_filepath = os.path.join(output_dir, lod_filename) @@ -4833,6 +5465,7 @@ def run(): "tile_id": tile_id, "grid_coord": [tx, ty, tz], "path_relative_to_manifest": os.path.relpath(filepath, model_dir), + "priority": tile_priority, "hlod_levels": hlod_entries, "file_size_bytes": file_sz, "estimated_memory_bytes": est_mem, @@ -4878,6 +5511,20 @@ def parse_args(argv): parser.add_argument("--write-manifest-in-dry-run", action="store_true", help="Write the manifest JSON even when --dry-run is enabled.") parser.add_argument("--generate-hlod", action="store_true", help="Enable HLOD export regardless of the script default.") parser.add_argument("--generate-lod", action="store_true", help="Enable per-tile LOD export regardless of the script default.") + parser.add_argument( + "--lod-level", + action="append", + default=[], + metavar="DISTANCE:RATIO", + help="Override per-tile LOD levels. Repeat for LOD1, LOD2, etc. Distance in metres. Example: --lod-level 90:0.5", + ) + parser.add_argument( + "--hlod-level", + action="append", + default=[], + metavar="SUFFIX:DISTANCE:RATIO", + help="Override HLOD levels. Distance in metres. Example: --hlod-level _hlod:250:0.1", + ) parser.add_argument("--visible-only", action="store_true", help="Export only visible mesh objects.") parser.add_argument("--all-meshes", action="store_true", help="Export all mesh objects, including hidden ones.") parser.add_argument("--debug-aabb-only", action="store_true", help="Export debug AABB payloads instead of real geometry.") @@ -4912,6 +5559,17 @@ def parse_args(argv): "Produces partitioning_mode='kdtree_floor' in the manifest." ), ) + parser.add_argument( + "--min-objects-per-tile-tier", + type=int, + default=None, + help=( + "For inline quadtree/KD-tree exports, collapse underfilled " + "(node, semantic tier) groups upward until each emitted tile-tier " + "has at least this many objects or reaches the floor root. " + "Default: 4. Use 1 to preserve the old singleton-leaf behavior." + ), + ) parser.add_argument( "--scene-profile", choices=("auto", "indoor", "outdoor"), @@ -4935,6 +5593,15 @@ def parse_args(argv): "--tier-radius StructuralInterior=10,16 --tier-radius RoomContents=5,9,8" ), ) + parser.add_argument( + "--untagged-semantic-tier", + choices=("Auto", "ExteriorShell", "StructuralInterior", "RoomContents", "FineProps"), + default="Auto", + help=( + "Semantic tier assigned to meshes without an explicit untold_semantic_override. " + "Auto keeps the name/material/size classifier and falls back to StructuralInterior." + ), + ) parser.add_argument( "--floor-count", type=int, @@ -4974,6 +5641,8 @@ def apply_cli_overrides(args): global DRY_RUN_WRITE_MANIFEST global GENERATE_HLOD global GENERATE_LOD + global HLOD_LEVELS + global TILE_LOD_LEVELS global VISIBLE_ONLY global DEBUG_AABB_ONLY global AUTO_TILE_SIZE @@ -4987,8 +5656,11 @@ def apply_cli_overrides(args): global FORCE_KDTREE global SCENE_STREAMING_PROFILE global TIER_RADIUS_OVERRIDES + global UNTAGGED_SEMANTIC_TIER global INLINE_FLOOR_COUNT_OVERRIDE global INLINE_FLOOR_BAND_HEIGHT_OVERRIDE + global INLINE_MIN_OBJECTS_PER_TILE_TIER + global INLINE_COLLAPSE_UNDERFILLED_TILE_TIERS if args.input: SOURCE_SCENE_PATH_OVERRIDE = args.input @@ -5008,6 +5680,26 @@ def apply_cli_overrides(args): GENERATE_HLOD = True if args.generate_lod: GENERATE_LOD = True + if getattr(args, "lod_level", None): + parsed_lods = [] + for value in args.lod_level: + parts = str(value).split(":") + if len(parts) != 2: + raise RuntimeError(f"--lod-level must be DISTANCE:RATIO, got {value!r}") + parsed_lods.append((float(parts[1]), float(parts[0]))) # (ratio, distance_metres) + TILE_LOD_LEVELS = parsed_lods + if getattr(args, "hlod_level", None): + parsed_hlods = [] + for value in args.hlod_level: + parts = str(value).split(":") + if len(parts) != 3: + raise RuntimeError(f"--hlod-level must be SUFFIX:DISTANCE:RATIO, got {value!r}") + parsed_hlods.append({ + "suffix": parts[0], + "reduction_ratio": float(parts[2]), + "switch_distance": float(parts[1]), # metres + }) + HLOD_LEVELS = parsed_hlods if args.visible_only: VISIBLE_ONLY = True if args.all_meshes: @@ -5033,11 +5725,16 @@ def apply_cli_overrides(args): if getattr(args, "kdtree", False): FORCE_KDTREE = True FORCE_QUADTREE = True # KD-tree uses the same quadtree export pipeline + if getattr(args, "min_objects_per_tile_tier", None) is not None: + INLINE_MIN_OBJECTS_PER_TILE_TIER = max(1, int(args.min_objects_per_tile_tier)) + INLINE_COLLAPSE_UNDERFILLED_TILE_TIERS = INLINE_MIN_OBJECTS_PER_TILE_TIER > 1 if getattr(args, "scene_profile", None): SCENE_STREAMING_PROFILE = args.scene_profile if getattr(args, "tier_radius", None): for tier, override in args.tier_radius: TIER_RADIUS_OVERRIDES[tier] = override + if getattr(args, "untagged_semantic_tier", None): + UNTAGGED_SEMANTIC_TIER = args.untagged_semantic_tier if getattr(args, "floor_count", None) is not None: INLINE_FLOOR_COUNT_OVERRIDE = args.floor_count if getattr(args, "floor_band_height", None) is not None: diff --git a/scripts/untold-blender-addon/package.sh b/scripts/untold-blender-addon/package.sh index 37bda508..6ae18e1d 100755 --- a/scripts/untold-blender-addon/package.sh +++ b/scripts/untold-blender-addon/package.sh @@ -13,6 +13,8 @@ mkdir -p "${STAGE_DIR}/vendor" cp "${ADDON_DIR}/untold_exporter/__init__.py" "${STAGE_DIR}/__init__.py" cp "${ADDON_DIR}/untold_exporter/bridge.py" "${STAGE_DIR}/bridge.py" +cp "${ADDON_DIR}/untold_exporter/object_metadata.py" "${STAGE_DIR}/object_metadata.py" +cp "${ADDON_DIR}/untold_exporter/viewport_overlay.py" "${STAGE_DIR}/viewport_overlay.py" cp "${SCRIPTS_DIR}/untoldexplorer.py" "${STAGE_DIR}/vendor/untoldexplorer.py" cp "${SCRIPTS_DIR}/texbake.py" "${STAGE_DIR}/vendor/texbake.py" cp "${SCRIPTS_DIR}/tilestreamingpartition.py" "${STAGE_DIR}/vendor/tilestreamingpartition.py" diff --git a/scripts/untold-blender-addon/untold_exporter/__init__.py b/scripts/untold-blender-addon/untold_exporter/__init__.py index 6b62fefa..ff8b7fe2 100644 --- a/scripts/untold-blender-addon/untold_exporter/__init__.py +++ b/scripts/untold-blender-addon/untold_exporter/__init__.py @@ -6,12 +6,51 @@ from bpy_extras.io_utils import ExportHelper from . import bridge +from . import object_metadata +from . import viewport_overlay def exporter_bridge(): return importlib.reload(bridge) +def _tier_radius_overrides_from_settings(settings) -> list[str]: + if not getattr(settings, "use_custom_tier_radii", False): + return [] + + overrides: list[str] = [] + tier_fields = [ + ("ExteriorShell", "exterior_shell"), + ("StructuralInterior", "structural_interior"), + ("RoomContents", "room_contents"), + ("FineProps", "fine_props"), + ] + for tier, prefix in tier_fields: + streaming = float(getattr(settings, f"{prefix}_streaming_radius")) + unload = float(getattr(settings, f"{prefix}_unload_radius")) + priority = int(getattr(settings, f"{prefix}_priority")) + if streaming > 0.0 and unload > streaming: + overrides.append(f"{tier}={streaming:g},{unload:g},{priority}") + return overrides + + +def _lod_level_overrides_from_settings(settings) -> list[str]: + if not getattr(settings, "use_custom_representation_ranges", False): + return [] + return [ + f"{float(settings.lod1_switch_distance):g}:{float(settings.lod1_reduction_ratio):g}", + f"{float(settings.lod2_switch_distance):g}:{float(settings.lod2_reduction_ratio):g}", + ] + + +def _hlod_level_overrides_from_settings(settings) -> list[str]: + if not getattr(settings, "use_custom_representation_ranges", False): + return [] + return [ + f"_hlod:{float(settings.hlod_switch_distance):g}:{float(settings.hlod_reduction_ratio):g}", + ] + + bl_info = { "name": "Untold Engine Exporter", "author": "Untold Engine Studios", @@ -269,8 +308,9 @@ class UNTOLD_OT_export_tiled_scene(bpy.types.Operator): name="Partitioning", description="Tile partitioning algorithm", items=[ - ("UNIFORM", "Uniform Grid", "Use regular X/Y/Z tile dimensions"), - ("QUADTREE", "Quadtree", "Use floor/quadtree partitioning with semantic tiers"), + ("UNIFORM", "Uniform Grid", "Use regular X/Y/Z tile dimensions"), + ("QUADTREE", "Quadtree", "Use floor/quadtree partitioning with semantic tiers"), + ("KDTREE", "KD-Tree", "Use floor/KD-tree partitioning — better balance in clustered scenes"), ], default="QUADTREE", ) @@ -303,14 +343,14 @@ class UNTOLD_OT_export_tiled_scene(bpy.types.Operator): ) floor_count: IntProperty( - name="Quadtree: Floor Count", - description="Optional floor count override for quadtree partitioning. Use 0 for auto-detect", + name="Tree: Floor Count", + description="Optional floor count override for quadtree/KD-tree partitioning. Use 0 for auto-detect", default=0, min=0, ) floor_band_height: FloatProperty( - name="Quadtree: Floor Band Height", + name="Tree: Floor Band Height", description="Optional per-floor band height override. Use 0 for auto-detect", default=0.0, min=0.0, @@ -327,6 +367,37 @@ class UNTOLD_OT_export_tiled_scene(bpy.types.Operator): default="auto", ) + untagged_semantic_tier: EnumProperty( + name="Untagged Semantic", + description="Semantic tier used for meshes without an explicit Untold semantic override", + items=[ + ("Auto", "Auto", "Infer from name, material, and size"), + ("ExteriorShell", "Exterior Shell", "Treat untagged meshes as exterior shell geometry"), + ("StructuralInterior", "Structural Interior", "Treat untagged meshes as structural interior geometry"), + ("RoomContents", "Room Contents", "Treat untagged meshes as room contents"), + ("FineProps", "Fine Props", "Treat untagged meshes as fine props"), + ], + default="Auto", + ) + + use_custom_tier_radii: BoolProperty( + name="Custom Tier Radii", + description="Override profile-derived semantic tier stream/unload radii for this tiled export", + default=False, + ) + exterior_shell_streaming_radius: FloatProperty(name="Exterior Stream", default=80.0, min=0.0) + exterior_shell_unload_radius: FloatProperty(name="Exterior Unload", default=130.0, min=0.0) + exterior_shell_priority: IntProperty(name="Exterior Priority", default=15, min=0) + structural_interior_streaming_radius: FloatProperty(name="Structural Stream", default=80.0, min=0.0) + structural_interior_unload_radius: FloatProperty(name="Structural Unload", default=130.0, min=0.0) + structural_interior_priority: IntProperty(name="Structural Priority", default=15, min=0) + room_contents_streaming_radius: FloatProperty(name="Room Stream", default=35.0, min=0.0) + room_contents_unload_radius: FloatProperty(name="Room Unload", default=70.0, min=0.0) + room_contents_priority: IntProperty(name="Room Priority", default=8, min=0) + fine_props_streaming_radius: FloatProperty(name="Fine Stream", default=30.0, min=0.0) + fine_props_unload_radius: FloatProperty(name="Fine Unload", default=60.0, min=0.0) + fine_props_priority: IntProperty(name="Fine Priority", default=5, min=0) + generate_hlod: BoolProperty( name="Generate HLOD", description="Generate simplified coarse HLOD assets for eligible tiles", @@ -339,6 +410,18 @@ class UNTOLD_OT_export_tiled_scene(bpy.types.Operator): default=False, ) + use_custom_representation_ranges: BoolProperty( + name="Custom Rep Ranges", + description="Override normalized LOD/HLOD switch positions used by tiled export", + default=False, + ) + lod1_switch_distance: FloatProperty(name="LOD1 Start (m)", default=90.0, min=1.0, soft_max=2000.0) + lod1_reduction_ratio: FloatProperty(name="LOD1 Ratio", default=0.50, min=0.01, max=1.0) + lod2_switch_distance: FloatProperty(name="LOD2 Start (m)", default=150.0, min=1.0, soft_max=2000.0) + lod2_reduction_ratio: FloatProperty(name="LOD2 Ratio", default=0.20, min=0.01, max=1.0) + hlod_switch_distance: FloatProperty(name="HLOD Start (m)", default=250.0, min=1.0, soft_max=5000.0) + hlod_reduction_ratio: FloatProperty(name="HLOD Ratio", default=0.10, min=0.01, max=1.0) + compress_geometry: BoolProperty( name="Compress Geometry", description="Compress vertex and index chunks with LZ4 in tile payloads", @@ -358,6 +441,39 @@ class UNTOLD_OT_export_tiled_scene(bpy.types.Operator): ) def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + preview = getattr(context.scene, "untold_tile_preview", None) + if preview is not None: + self.visible_only = preview.visible_only + self.partitioning_mode = preview.partitioning_mode + self.auto_tile_size = preview.auto_tile_size + self.tile_size_x = preview.tile_size_x + self.tile_size_z = preview.tile_size_z + self.floor_count = preview.floor_count + self.floor_band_height = preview.floor_band_height + self.scene_profile = preview.scene_profile + self.untagged_semantic_tier = preview.untagged_semantic_tier + self.use_custom_tier_radii = preview.use_custom_tier_radii + self.exterior_shell_streaming_radius = preview.exterior_shell_streaming_radius + self.exterior_shell_unload_radius = preview.exterior_shell_unload_radius + self.exterior_shell_priority = preview.exterior_shell_priority + self.structural_interior_streaming_radius = preview.structural_interior_streaming_radius + self.structural_interior_unload_radius = preview.structural_interior_unload_radius + self.structural_interior_priority = preview.structural_interior_priority + self.room_contents_streaming_radius = preview.room_contents_streaming_radius + self.room_contents_unload_radius = preview.room_contents_unload_radius + self.room_contents_priority = preview.room_contents_priority + self.fine_props_streaming_radius = preview.fine_props_streaming_radius + self.fine_props_unload_radius = preview.fine_props_unload_radius + self.fine_props_priority = preview.fine_props_priority + self.generate_hlod = preview.generate_hlod + self.generate_lod = preview.generate_lod + self.use_custom_representation_ranges = preview.use_custom_representation_ranges + self.lod1_switch_distance = preview.lod1_switch_distance + self.lod1_reduction_ratio = preview.lod1_reduction_ratio + self.lod2_switch_distance = preview.lod2_switch_distance + self.lod2_reduction_ratio = preview.lod2_reduction_ratio + self.hlod_switch_distance = preview.hlod_switch_distance + self.hlod_reduction_ratio = preview.hlod_reduction_ratio if not self.directory: blend_path = getattr(bpy.data, "filepath", "") or "" if blend_path: @@ -369,6 +485,22 @@ def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str] def execute(self, context: bpy.types.Context) -> set[str]: scene_dir = Path(bpy.path.abspath(self.directory)).expanduser().resolve() + + wm = context.window_manager + workspace = context.workspace + wm.progress_begin(0, 100) + + def progress(stage: str, done: int, total: int, detail: str) -> None: + percent = (100.0 * done) / max(total, 1) + suffix = f" - {detail}" if detail else "" + wm.progress_update(percent) + workspace.status_text_set(f"Untold Export: {stage} {percent:5.1f}%{suffix}") + print(f"[Untold Exporter] {percent:5.1f}% {stage}{suffix}", flush=True) + # Force the status bar to redraw now; the UI does not refresh on + # its own while this blocking export operator is running. + if not bpy.app.background: + bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + try: result = exporter_bridge().export_tiled_scene( scene_dir=scene_dir, @@ -381,16 +513,24 @@ def execute(self, context: bpy.types.Context) -> set[str]: floor_count=self.floor_count, floor_band_height=self.floor_band_height, scene_profile=self.scene_profile, + untagged_semantic_tier=self.untagged_semantic_tier, + tier_radius_overrides=_tier_radius_overrides_from_settings(self), + lod_level_overrides=_lod_level_overrides_from_settings(self), + hlod_level_overrides=_hlod_level_overrides_from_settings(self), generate_hlod=self.generate_hlod, generate_lod=self.generate_lod, compress_geometry=self.compress_geometry, dry_run=self.dry_run, write_manifest_in_dry_run=self.write_manifest_in_dry_run, + progress_callback=progress, ) except Exception as exc: self.report({"ERROR"}, str(exc)) print(f"[Untold Exporter] Error: {exc}", flush=True) return {"CANCELLED"} + finally: + wm.progress_end() + workspace.status_text_set(None) mode = "planned" if result["dry_run"] else "exported" message = ( @@ -419,9 +559,13 @@ def register() -> None: for cls in classes: bpy.utils.register_class(cls) bpy.types.TOPBAR_MT_file_export.append(menu_func_export) + object_metadata.register() + viewport_overlay.register() def unregister() -> None: + viewport_overlay.unregister() + object_metadata.unregister() bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) for cls in reversed(classes): bpy.utils.unregister_class(cls) diff --git a/scripts/untold-blender-addon/untold_exporter/bridge.py b/scripts/untold-blender-addon/untold_exporter/bridge.py index 53bfb575..d4ccaa46 100644 --- a/scripts/untold-blender-addon/untold_exporter/bridge.py +++ b/scripts/untold-blender-addon/untold_exporter/bridge.py @@ -237,11 +237,16 @@ def export_tiled_scene( floor_count: int, floor_band_height: float, scene_profile: str, + untagged_semantic_tier: str, + tier_radius_overrides: list[str] | None, + lod_level_overrides: list[str] | None, + hlod_level_overrides: list[str] | None, generate_hlod: bool, generate_lod: bool, compress_geometry: bool, dry_run: bool, write_manifest_in_dry_run: bool, + progress_callback: ProgressCallback | None = None, ) -> dict[str, object]: module = tile_exporter_module() output_dir = scene_dir / "tile_exports" @@ -254,17 +259,27 @@ def export_tiled_scene( # workflow anyway. module.ERROR_IF_UNSAVED_SOURCE_NOT_FOUND = False module.SOURCE_SCENE_PATH_OVERRIDE = "" + module.TIER_RADIUS_OVERRIDES = {} + module.UNTAGGED_SEMANTIC_TIER = "Auto" module.resolve_source_scene_path = lambda: "" + module.PROGRESS_CALLBACK = progress_callback argv = [ "tilestreamingpartition.py", "--output-dir", str(output_dir), "--parallel-workers", "1", "--scene-profile", scene_profile, + "--untagged-semantic-tier", untagged_semantic_tier, "--tile-size-x", str(tile_size_x), "--tile-size-y", str(tile_size_y), "--tile-size-z", str(tile_size_z), ] + for override in tier_radius_overrides or []: + argv.extend(["--tier-radius", override]) + for override in lod_level_overrides or []: + argv.extend(["--lod-level", override]) + for override in hlod_level_overrides or []: + argv.extend(["--hlod-level", override]) argv.append("--visible-only" if visible_only else "--all-meshes") @@ -276,6 +291,12 @@ def export_tiled_scene( argv.extend(["--floor-count", str(floor_count)]) if floor_band_height > 0.0: argv.extend(["--floor-band-height", str(floor_band_height)]) + if partitioning_mode == "KDTREE": + argv.append("--kdtree") + if floor_count > 0: + argv.extend(["--floor-count", str(floor_count)]) + if floor_band_height > 0.0: + argv.extend(["--floor-band-height", str(floor_band_height)]) if generate_hlod: argv.append("--generate-hlod") if generate_lod: diff --git a/scripts/untold-blender-addon/untold_exporter/object_metadata.py b/scripts/untold-blender-addon/untold_exporter/object_metadata.py new file mode 100644 index 00000000..a68db4b6 --- /dev/null +++ b/scripts/untold-blender-addon/untold_exporter/object_metadata.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import bpy +from bpy.props import EnumProperty + + +SEMANTIC_ITEMS = ( + ("Auto", "Auto", "Let the exporter infer the mesh semantic tier"), + ("ExteriorShell", "Exterior Shell", "Outer shell, facade, roof, or always-visible exterior mesh"), + ("StructuralInterior", "Structural Interior", "Interior floors, walls, ceilings, stairs, and structural meshes"), + ("RoomContents", "Room Contents", "Furniture, fixtures, and room-scale content meshes"), + ("FineProps", "Fine Props", "Small close-range detail props"), +) + +PRIORITY_ITEMS = ( + ("Auto", "Auto", "Use the semantic tier's default tile priority"), + ("Low", "Low", "Low-priority mesh when aggregating tile load priority"), + ("Normal", "Normal", "Normal-priority mesh when aggregating tile load priority"), + ("High", "High", "High-priority mesh when aggregating tile load priority"), + ("Critical", "Critical", "Critical-priority mesh when aggregating tile load priority"), +) + + +def _set_or_clear_custom_prop(obj: bpy.types.Object, key: str, value: str, clear_value: str = "Auto") -> None: + if value == clear_value: + if key in obj: + del obj[key] + else: + obj[key] = value + + +def _semantic_updated(self, _context) -> None: + obj = self.id_data + _set_or_clear_custom_prop(obj, "untold_semantic_override", self.semantic_tag) + + +def _priority_updated(self, _context) -> None: + obj = self.id_data + _set_or_clear_custom_prop(obj, "untold_streaming_priority_hint", self.streaming_priority_hint) + + +class UntoldObjectMetadata(bpy.types.PropertyGroup): + semantic_tag: EnumProperty( + name="Object Semantic", + description="Mesh-level semantic tier used by tiled scene export", + items=SEMANTIC_ITEMS, + default="Auto", + update=_semantic_updated, + ) + + streaming_priority_hint: EnumProperty( + name="Priority Hint", + description="Object-level hint aggregated into generated tile priority", + items=PRIORITY_ITEMS, + default="Auto", + update=_priority_updated, + ) + + +class UNTOLD_PT_object_metadata(bpy.types.Panel): + bl_label = "Untold" + bl_idname = "UNTOLD_PT_object_metadata" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + obj = context.object + return obj is not None and obj.type == "MESH" + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + metadata = context.object.untold_metadata + layout.prop(metadata, "semantic_tag") + layout.prop(metadata, "streaming_priority_hint") + + +classes = ( + UntoldObjectMetadata, + UNTOLD_PT_object_metadata, +) + + +def register() -> None: + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Object.untold_metadata = bpy.props.PointerProperty( + type=UntoldObjectMetadata + ) + + +def unregister() -> None: + del bpy.types.Object.untold_metadata + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/scripts/untold-blender-addon/untold_exporter/viewport_overlay.py b/scripts/untold-blender-addon/untold_exporter/viewport_overlay.py new file mode 100644 index 00000000..153b3da7 --- /dev/null +++ b/scripts/untold-blender-addon/untold_exporter/viewport_overlay.py @@ -0,0 +1,1554 @@ +from __future__ import annotations + +import math + +import bpy +import gpu +from bpy.app.handlers import persistent +from gpu_extras.batch import batch_for_shader +from mathutils import Vector +from bpy.props import FloatProperty, BoolProperty, EnumProperty, IntProperty + + +# ── Global draw state ───────────────────────────────────────────────────────── + +_draw_handle = None +_tile_boxes: list[tuple] = [] # [(mn_xyz, mx_xyz, rgba), ...] +_draw_shader = None +_fill_batches: list[tuple] = [] # [(batch, rgba), ...] +_line_batches: list[tuple] = [] # [(batch, rgba), ...] +_preview_object_names: set[str] = set() +_preview_color_mode = "DENSITY" +_mesh_view_state: dict[str, dict] = {} + +_TIER_RADIUS_FIELDS = [ + ("ExteriorShell", "exterior_shell"), + ("StructuralInterior", "structural_interior"), + ("RoomContents", "room_contents"), + ("FineProps", "fine_props"), +] + + +# ── Color helpers ───────────────────────────────────────────────────────────── + +def _heatmap_color(t: float, alpha: float = 0.9) -> tuple: + """Map t ∈ [0,1] to green → yellow → red.""" + if t < 0.5: + r = t * 2.0 + g = 0.8 + else: + r = 1.0 + g = 0.8 * (1.0 - (t - 0.5) * 2.0) + return (r, g, 0.05, alpha) + + +def _shared_color() -> tuple: + return (0.20, 0.45, 0.95, 0.9) + + +def _runtime_color(state: str) -> tuple: + colors = { + "full": (0.92, 0.92, 0.92, 0.9), # white/light grey — neutral "fully loaded" + "lod": (0.10, 0.85, 1.00, 0.9), # cyan + "lod1": (0.10, 0.85, 1.00, 0.9), # cyan + "lod2": (1.00, 0.90, 0.10, 0.9), # yellow + "hlod": (1.00, 0.55, 0.10, 0.9), # orange + "unloaded": (0.65, 0.12, 0.08, 0.55), # dim red + "shared": _shared_color(), + } + return colors.get(state, colors["unloaded"]) + + +def _tier_radius_overrides(settings) -> dict: + if not getattr(settings, "use_custom_tier_radii", False): + return {} + + overrides = {} + for tier, prefix in _TIER_RADIUS_FIELDS: + streaming = float(getattr(settings, f"{prefix}_streaming_radius")) + unload = float(getattr(settings, f"{prefix}_unload_radius")) + priority = int(getattr(settings, f"{prefix}_priority")) + if streaming > 0.0 and unload > streaming: + overrides[tier] = { + "streaming": streaming, + "unload": unload, + "priority": priority, + } + return overrides + + +def _apply_tier_radius_overrides(module, settings) -> None: + module.TIER_RADIUS_OVERRIDES = _tier_radius_overrides(settings) + + +def _apply_semantic_policy(module, settings) -> None: + module.UNTAGGED_SEMANTIC_TIER = getattr(settings, "untagged_semantic_tier", "Auto") + + +def _apply_representation_ranges(module, settings) -> None: + if not getattr(settings, "use_custom_representation_ranges", False): + return + module.TILE_LOD_LEVELS = [ + (float(getattr(settings, "lod1_reduction_ratio", 0.5)), float(getattr(settings, "lod1_switch_distance", 90.0))), + (float(getattr(settings, "lod2_reduction_ratio", 0.2)), float(getattr(settings, "lod2_switch_distance", 110.0))), + ] + module.HLOD_LEVELS = [{ + "suffix": "_hlod", + "reduction_ratio": float(getattr(settings, "hlod_reduction_ratio", 0.10)), + "switch_distance": float(getattr(settings, "hlod_switch_distance", 130.0)), + }] + + +def _repr_summary_for_settings(settings): + """Compute resolved LOD/HLOD switch distances (metres) for ExteriorShell, for panel display. + + Returns a dict with keys: streaming_r, unload_r, lod1, lod2, hlod (all floats or None). + Returns None when Custom Tier Radii is not enabled (scene radii unknown without running the + partitioner). + """ + if not getattr(settings, "use_custom_tier_radii", False): + return None + if not getattr(settings, "use_custom_representation_ranges", False): + return None + + streaming_r = float(getattr(settings, "exterior_shell_streaming_radius", 80.0)) + unload_r = float(getattr(settings, "exterior_shell_unload_radius", 150.0)) + if unload_r <= streaming_r: + return None + + GAP = 4.0 + MARGIN = 4.0 + gap_range = max(unload_r - streaming_r, GAP * 2.0) + min_sw = streaming_r + GAP + max_sw = max(min_sw, unload_r - min(MARGIN, gap_range * 0.25)) + + generate_hlod = getattr(settings, "generate_hlod", False) + generate_lod = getattr(settings, "generate_lod", False) + + hlod = None + if generate_hlod: + raw = float(getattr(settings, "hlod_switch_distance", 130.0)) + hlod = max(min_sw, min(max_sw, raw)) + + lod1 = lod2 = None + if generate_lod: + far_limit = (hlod - GAP) if hlod is not None else (unload_r - MARGIN) + near_limit = max(streaming_r + GAP, GAP) + raw1 = float(getattr(settings, "lod1_switch_distance", 90.0)) + raw2 = float(getattr(settings, "lod2_switch_distance", 110.0)) + lod1 = max(near_limit, min(far_limit - GAP, raw1)) + lod2 = max(lod1 + GAP, min(far_limit, raw2)) + + return { + "streaming_r": streaming_r, + "unload_r": unload_r, + "lod1": lod1, + "lod2": lod2, + "hlod": hlod, + } + + +def _group_cell_bounds_xy(module, objs, metadata_map): + if module.group_has_spanning_metadata(objs, metadata_map): + return None + for obj in objs: + meta = metadata_map.get(obj.name) + if meta and meta.get("cell_bounds_xy"): + return meta["cell_bounds_xy"] + return None + + +def _cell_box_from_metadata(module, objs, aabbs, metadata_map): + cell = _group_cell_bounds_xy(module, objs, metadata_map) + if not cell: + return None + z_min = min(aabbs[obj.name][0].z for obj in objs) + z_max = max(aabbs[obj.name][1].z for obj in objs) + return ( + (cell["min_x"], cell["min_y"], z_min), + (cell["max_x"], cell["max_y"], z_max), + ) + + +# ── Geometry helpers ────────────────────────────────────────────────────────── + +def _box_line_coords(mn: tuple, mx: tuple) -> list[tuple]: + x0, y0, z0 = mn + x1, y1, z1 = mx + return [ + (x0, y0, z0), (x1, y0, z0), + (x1, y0, z0), (x1, y1, z0), + (x1, y1, z0), (x0, y1, z0), + (x0, y1, z0), (x0, y0, z0), + (x0, y0, z1), (x1, y0, z1), + (x1, y0, z1), (x1, y1, z1), + (x1, y1, z1), (x0, y1, z1), + (x0, y1, z1), (x0, y0, z1), + (x0, y0, z0), (x0, y0, z1), + (x1, y0, z0), (x1, y0, z1), + (x1, y1, z0), (x1, y1, z1), + (x0, y1, z0), (x0, y1, z1), + ] + + +def _box_floor_tris(mn: tuple, mx: tuple) -> list[tuple]: + x0, y0, z = mn[0], mn[1], mn[2] + x1, y1 = mx[0], mx[1] + return [ + (x0, y0, z), (x1, y0, z), (x1, y1, z), + (x0, y0, z), (x1, y1, z), (x0, y1, z), + ] + + +# ── Quadtree node bound reconstruction ─────────────────────────────────────── +# +# Node IDs encode the path from root as child indices separated by underscores. +# Example: "F02_Q_0_3_1" → floor 2, child path SW → NE → SE +# +# _QuadNode.subdivide() child order (tilestreamingpartition.py): +# 0 → (min_x, min_y, mid_x, mid_y) SW +# 1 → (mid_x, min_y, max_x, mid_y) SE +# 2 → (min_x, mid_y, mid_x, max_y) NW +# 3 → (mid_x, mid_y, max_x, max_y) NE +# +# All floors share the same XY root (global scene XY bounds). + +def _quadtree_node_xy_bounds( + node_id: str, + scene_min_x: float, scene_min_y: float, + scene_max_x: float, scene_max_y: float, +) -> tuple[float, float, float, float] | None: + parts = node_id.split('_') + try: + q_idx = next(i for i, p in enumerate(parts) if p == 'Q') + except StopIteration: + return None + + min_x, min_y = scene_min_x, scene_min_y + max_x, max_y = scene_max_x, scene_max_y + + for p in parts[q_idx + 1:]: + if not p.isdigit(): + break + idx = int(p) + mid_x = (min_x + max_x) * 0.5 + mid_y = (min_y + max_y) * 0.5 + if idx == 0: + max_x, max_y = mid_x, mid_y + elif idx == 1: + min_x = mid_x; max_y = mid_y + elif idx == 2: + max_x = mid_x; min_y = mid_y + elif idx == 3: + min_x = mid_x; min_y = mid_y + + return min_x, min_y, max_x, max_y + + +# ── GPU draw callback ───────────────────────────────────────────────────────── + +def _draw_callback() -> None: + if not _fill_batches and not _line_batches: + return + + shader = _ensure_draw_shader() + shader.bind() + + gpu.state.blend_set('ALPHA') + gpu.state.depth_test_set('LESS_EQUAL') + gpu.state.depth_mask_set(False) + + for batch, color in _fill_batches: + shader.uniform_float("color", color) + batch.draw(shader) + + gpu.state.depth_mask_set(True) + + for batch, color in _line_batches: + shader.uniform_float("color", color) + batch.draw(shader) + + gpu.state.blend_set('NONE') + gpu.state.depth_test_set('NONE') + + +def _ensure_draw_shader(): + global _draw_shader + if _draw_shader is None: + _draw_shader = gpu.shader.from_builtin('UNIFORM_COLOR') + return _draw_shader + + +def _rebuild_draw_batches(context: bpy.types.Context | None = None) -> None: + global _fill_batches, _line_batches + shader = _ensure_draw_shader() + _fill_batches = [] + _line_batches = [] + + context = context or bpy.context + settings = getattr(context.scene, "untold_tile_preview", None) + show_fill = getattr(settings, "show_tile_floor_fill", True) + + for mn, mx, color in _tile_boxes: + if show_fill: + fill = (color[0], color[1], color[2], color[3] * 0.20) + _fill_batches.append(( + batch_for_shader(shader, 'TRIS', {"pos": _box_floor_tris(mn, mx)}), + fill, + )) + _line_batches.append(( + batch_for_shader(shader, 'LINES', {"pos": _box_line_coords(mn, mx)}), + color, + )) + + +def _clear_preview_state() -> None: + global _tile_boxes, _fill_batches, _line_batches, _preview_object_names, _preview_color_mode + _tile_boxes = [] + _fill_batches = [] + _line_batches = [] + _preview_object_names = set() + _preview_color_mode = "DENSITY" + _unregister_draw_handler() + + +def _register_draw_handler() -> None: + global _draw_handle + if _draw_handle is None: + _draw_handle = bpy.types.SpaceView3D.draw_handler_add( + _draw_callback, (), 'WINDOW', 'POST_VIEW' + ) + + +def _unregister_draw_handler() -> None: + global _draw_handle + if _draw_handle is not None: + bpy.types.SpaceView3D.draw_handler_remove(_draw_handle, 'WINDOW') + _draw_handle = None + + +def _tag_viewports_redraw(context: bpy.types.Context) -> None: + for area in context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + +def _tag_all_viewports_redraw() -> None: + window_manager = getattr(bpy.context, "window_manager", None) + if window_manager is None: + return + for window in window_manager.windows: + screen = getattr(window, "screen", None) + if screen is None: + continue + for area in screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + +def _tile_preview_collection_objects() -> set: + preview_col = bpy.data.collections.get("Tile Preview") + return set(preview_col.objects) if preview_col else set() + + +def _viewport_utility_meshes(context: bpy.types.Context) -> list: + preview_objects = _tile_preview_collection_objects() + return [ + obj for obj in context.scene.objects + if obj.type == 'MESH' and obj not in preview_objects + ] + + +def _remember_mesh_view_state(context: bpy.types.Context, objects: list) -> None: + view_layer = context.view_layer + for obj in objects: + if obj.name in _mesh_view_state: + continue + _mesh_view_state[obj.name] = { + "display_type": obj.display_type, + "hide_viewport": obj.hide_viewport, + "hide_get": obj.hide_get(view_layer=view_layer), + } + + +@persistent +def _clear_preview_on_file_event(_dummy=None) -> None: + global _mesh_view_state + _clear_preview_state() + _mesh_view_state = {} + _tag_all_viewports_redraw() + + +@persistent +def _clear_preview_when_source_meshes_change(scene, _depsgraph=None) -> None: + if not _preview_object_names: + return + current_mesh_names = {obj.name for obj in scene.objects if obj.type == 'MESH'} + if not _preview_object_names.issubset(current_mesh_names): + _clear_preview_state() + _tag_all_viewports_redraw() + + +def _register_app_handlers() -> None: + _unregister_app_handlers() + if _clear_preview_on_file_event not in bpy.app.handlers.load_pre: + bpy.app.handlers.load_pre.append(_clear_preview_on_file_event) + if _clear_preview_on_file_event not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(_clear_preview_on_file_event) + if _clear_preview_when_source_meshes_change not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_clear_preview_when_source_meshes_change) + + +def _unregister_app_handlers() -> None: + callbacks = ( + (bpy.app.handlers.load_pre, _clear_preview_on_file_event), + (bpy.app.handlers.load_post, _clear_preview_on_file_event), + (bpy.app.handlers.depsgraph_update_post, _clear_preview_when_source_meshes_change), + ) + for handlers, callback in callbacks: + callback_names = {callback.__name__} + for handler in list(handlers): + if ( + handler is callback + or ( + getattr(handler, "__module__", None) == __name__ + and getattr(handler, "__name__", None) in callback_names + ) + ): + handlers.remove(handler) + + +# ── Scene property group ────────────────────────────────────────────────────── + +class UntoldTilePreviewSettings(bpy.types.PropertyGroup): + partitioning_mode: EnumProperty( + name="Mode", + description="Must match what you will use in File > Export > Untold Tiled Scene", + items=[ + ('UNIFORM', "Uniform Grid", "Regular XZ grid — fastest, best for open outdoor scenes"), + ('QUADTREE', "Quadtree", "Floor + quadtree hierarchy — best for multi-floor buildings"), + ('KDTREE', "KD-Tree", "Floor + KD-tree hierarchy — better balance in clustered scenes"), + ], + default='QUADTREE', + ) + + # Uniform grid + auto_tile_size: BoolProperty( + name="Auto Tile Size", + description="Let the exporter choose tile dimensions from scene complexity — matches default export behaviour", + default=True, + ) + tile_size_x: FloatProperty( + name="Tile Size X", + description="Tile width in world units (Blender X). Only used when Auto Tile Size is off", + default=25.0, min=0.1, + ) + tile_size_z: FloatProperty( + name="Tile Size Z (Depth)", + description="Tile depth in world units (Blender Y). Only used when Auto Tile Size is off", + default=25.0, min=0.1, + ) + spanning_threshold: FloatProperty( + name="Spanning Threshold", + description="Objects wider than this many tile-lengths go to the shared bucket (blue)", + default=4.0, min=1.0, max=32.0, + ) + + show_tile_floor_fill: BoolProperty( + name="Tile Floor Fill", + description="Draw a translucent floor fill for each tile box. " + "Disable to show only the wireframe outlines, which makes it " + "easier to inspect geometry inside overlapping/stacked tiles", + default=False, + update=lambda self, context: (_rebuild_draw_batches(context), _tag_viewports_redraw(context)), + ) + + # Quadtree / KD-tree + floor_count: IntProperty( + name="Floor Count", + description="Number of floors (0 = auto-detect from object Z dimensions)", + default=0, min=0, + ) + floor_band_height: FloatProperty( + name="Floor Band Height", + description="Per-floor Z band height in world units (0 = auto-detect)", + default=0.0, min=0.0, + ) + + scene_profile: EnumProperty( + name="Scene Profile", + description="Streaming radius profile used for LOD/HLOD planning and tiled export", + items=[ + ("auto", "Auto", "Infer indoor/outdoor streaming bands from the scene"), + ("indoor", "Indoor", "Use tighter room-scale streaming bands"), + ("outdoor", "Outdoor", "Use wider city/open-world streaming bands"), + ], + default="auto", + ) + + untagged_semantic_tier: EnumProperty( + name="Untagged Semantic", + description="Semantic tier used for meshes without an explicit Untold semantic override", + items=[ + ("Auto", "Auto", "Infer from name, material, and size"), + ("ExteriorShell", "Exterior Shell", "Treat untagged meshes as exterior shell geometry"), + ("StructuralInterior", "Structural Interior", "Treat untagged meshes as structural interior geometry"), + ("RoomContents", "Room Contents", "Treat untagged meshes as room contents"), + ("FineProps", "Fine Props", "Treat untagged meshes as fine props"), + ], + default="Auto", + ) + + use_custom_tier_radii: BoolProperty( + name="Custom Tier Radii", + description="Override profile-derived semantic tier stream/unload radii for preview and export", + default=False, + ) + exterior_shell_streaming_radius: FloatProperty(name="Exterior Stream", default=80.0, min=0.0) + exterior_shell_unload_radius: FloatProperty(name="Exterior Unload", default=150.0, min=0.0) + exterior_shell_priority: IntProperty(name="Exterior Priority", default=15, min=0) + structural_interior_streaming_radius: FloatProperty(name="Structural Stream", default=80.0, min=0.0) + structural_interior_unload_radius: FloatProperty(name="Structural Unload", default=150.0, min=0.0) + structural_interior_priority: IntProperty(name="Structural Priority", default=15, min=0) + room_contents_streaming_radius: FloatProperty(name="Room Stream", default=35.0, min=0.0) + room_contents_unload_radius: FloatProperty(name="Room Unload", default=70.0, min=0.0) + room_contents_priority: IntProperty(name="Room Priority", default=8, min=0) + fine_props_streaming_radius: FloatProperty(name="Fine Stream", default=30.0, min=0.0) + fine_props_unload_radius: FloatProperty(name="Fine Unload", default=60.0, min=0.0) + fine_props_priority: IntProperty(name="Fine Priority", default=5, min=0) + + generate_hlod: BoolProperty( + name="Generate HLOD", + description="Generate far-distance coarse HLOD payloads for eligible tile groups", + default=False, + ) + + generate_lod: BoolProperty( + name="Generate LOD", + description="Generate per-tile intermediate LOD payloads for eligible tile groups", + default=False, + ) + + use_custom_representation_ranges: BoolProperty( + name="Custom LOD Ranges", + description="Override normalized LOD/HLOD switch positions used by preview and export", + default=False, + ) + lod1_switch_distance: FloatProperty( + name="LOD1 Start (m)", + description="Distance in metres at which full-detail geometry switches to LOD1", + default=90.0, min=1.0, soft_max=2000.0, + ) + lod1_reduction_ratio: FloatProperty( + name="LOD1 Ratio", + description="LOD1 mesh reduction ratio", + default=0.50, min=0.01, max=1.0, + ) + lod2_switch_distance: FloatProperty( + name="LOD2 Start (m)", + description="Distance in metres at which LOD1 switches to LOD2", + default=110.0, min=1.0, soft_max=2000.0, + ) + lod2_reduction_ratio: FloatProperty( + name="LOD2 Ratio", + description="LOD2 mesh reduction ratio", + default=0.20, min=0.01, max=1.0, + ) + hlod_switch_distance: FloatProperty( + name="HLOD Start (m)", + description="Distance in metres at which the coarse HLOD representation replaces LOD geometry", + default=130.0, min=1.0, soft_max=5000.0, + ) + hlod_reduction_ratio: FloatProperty( + name="HLOD Ratio", + description="HLOD mesh reduction ratio", + default=0.10, min=0.01, max=1.0, + ) + + runtime_source: EnumProperty( + name="Distance Source", + description="Position used to preview which tile representation would be active", + items=[ + ("CAMERA", "Active Camera", "Use the active scene camera position"), + ("CURSOR", "3D Cursor", "Use the 3D cursor position"), + ("SELECTED", "Selected Object", "Use the active selected object position"), + ], + default="CAMERA", + ) + + visible_only: BoolProperty( + name="Visible Objects Only", + description="Preview only visible mesh objects, matching the default export behaviour", + default=True, + ) + + +# ── Object / AABB helpers ───────────────────────────────────────────────────── + +def _collect_objects(context: bpy.types.Context, visible_only: bool) -> list: + view_layer = context.view_layer + return [ + obj for obj in context.scene.objects + if obj.type == 'MESH' + and (not visible_only or ( + not obj.hide_viewport and not obj.hide_get(view_layer=view_layer) + )) + ] + + +def _compute_aabbs(objects: list, context: bpy.types.Context) -> dict: + depsgraph = context.evaluated_depsgraph_get() + result = {} + for obj in objects: + eval_obj = obj.evaluated_get(depsgraph) + mw = eval_obj.matrix_world + corners = [mw @ Vector(c) for c in eval_obj.bound_box] + result[obj.name] = ( + Vector((min(v.x for v in corners), min(v.y for v in corners), min(v.z for v in corners))), + Vector((max(v.x for v in corners), max(v.y for v in corners), max(v.z for v in corners))), + ) + return result + + +def _scene_z_range(aabbs: dict) -> tuple[float, float]: + return ( + min(v[0].z for v in aabbs.values()), + max(v[1].z for v in aabbs.values()), + ) + + +def _aabbs_to_module_format(aabbs: dict) -> dict: + """Convert {name: (Vector_min, Vector_max)} to {name: {"min": tuple, "max": tuple}}.""" + return { + name: {"min": (mn.x, mn.y, mn.z), "max": (mx.x, mx.y, mx.z)} + for name, (mn, mx) in aabbs.items() + } + + +def _scene_bounds_from_aabbs(aabbs: dict) -> dict: + return { + "min": ( + min(v[0].x for v in aabbs.values()), + min(v[0].y for v in aabbs.values()), + min(v[0].z for v in aabbs.values()), + ), + "max": ( + max(v[1].x for v in aabbs.values()), + max(v[1].y for v in aabbs.values()), + max(v[1].z for v in aabbs.values()), + ), + } + + +def _scene_half_diag(scene_bounds: dict) -> float: + return 0.5 * math.sqrt( + (scene_bounds["max"][0] - scene_bounds["min"][0]) ** 2 + + (scene_bounds["max"][1] - scene_bounds["min"][1]) ** 2 + ) + + +# ── Partition builders ──────────────────────────────────────────────────────── + +def _build_uniform_boxes(objects: list, aabbs: dict, settings) -> tuple[list, dict]: + """Delegate classification to tilestreamingpartition.build_assignments so all + spanning rules (OVERLAP_THRESHOLD, SPLIT_MAX_TILES, SPLIT_SPANNING_OBJECTS) + and auto tile sizing match the actual exporter.""" + from . import bridge as _bridge + module = _bridge.tile_exporter_module() + + tile_x = settings.tile_size_x + tile_z = settings.tile_size_z + + # Propagate preview settings to module globals so build_assignments + # uses the same classification thresholds as the UI exposes. + module.SPANNING_THRESHOLD_TILES = settings.spanning_threshold + module.TILE_SIZE_X = tile_x + module.TILE_SIZE_Z = tile_z + + object_bounds = _aabbs_to_module_format(aabbs) + scene_bounds = _scene_bounds_from_aabbs(aabbs) + origin_y = scene_bounds["min"][2] # Blender Z height → tile Y + + if settings.auto_tile_size: + tile_x, tile_z, _ = module.choose_auto_tile_size( + objects, object_bounds, scene_bounds, origin_y, module.TILE_SIZE_Y + ) + module.TILE_SIZE_X = tile_x + module.TILE_SIZE_Z = tile_z + + # Same world-aligned snap as tilestreamingpartition.py + origin_x = math.floor(scene_bounds["min"][0] / tile_x) * tile_x # Blender X + origin_z = math.floor(scene_bounds["min"][1] / tile_z) * tile_z # Blender Y depth → tile Z + + tile_assignments, shared_objects, _ = module.build_assignments( + objects, object_bounds, + origin_x, origin_y, origin_z, + tile_x, module.TILE_SIZE_Y, tile_z, + ) + + scene_min_z, scene_max_z = _scene_z_range(aabbs) + max_count = max((len(v) for v in tile_assignments.values()), default=1) + boxes: list[tuple] = [] + + for (tx, ty, tz), objs in tile_assignments.items(): + t = (len(objs) - 1) / max(max_count - 1, 1) + tb = module.tile_bounds_from_coord( + tx, ty, tz, origin_x, origin_y, origin_z, + tile_x, module.TILE_SIZE_Y, tile_z, + ) + # tile_bounds keys: min_x/max_x = Blender X, min_z/max_z = Blender Y depth + mn = (tb["min_x"], tb["min_z"], scene_min_z) + mx = (tb["max_x"], tb["max_z"], scene_max_z) + boxes.append((mn, mx, _heatmap_color(t))) + + for obj in shared_objects: + mn_v, mx_v = aabbs[obj.name] + boxes.append(((mn_v.x, mn_v.y, mn_v.z), (mx_v.x, mx_v.y, mx_v.z), _shared_color())) + + return boxes, { + "tiles": len(tile_assignments), + "shared": len(shared_objects), + "max": max_count, + "avg": sum(len(v) for v in tile_assignments.values()) / max(len(tile_assignments), 1), + "tile_x": tile_x, + "tile_z": tile_z, + } + + +def _build_tree_boxes(objects: list, aabbs: dict, settings, use_kdtree: bool) -> tuple[list, dict]: + """Use tilestreamingpartition's inline annotation then build_quadtree_assignments + so tier-based grouping and shared-bucket routing exactly match the exporter.""" + from . import bridge as _bridge + module = _bridge.tile_exporter_module() + + module.INLINE_FLOOR_COUNT_OVERRIDE = settings.floor_count if settings.floor_count > 0 else None + module.INLINE_FLOOR_BAND_HEIGHT_OVERRIDE = settings.floor_band_height if settings.floor_band_height > 0.0 else None + module.SCENE_STREAMING_PROFILE = settings.scene_profile + _apply_semantic_policy(module, settings) + _apply_representation_ranges(module, settings) + + object_bounds = _aabbs_to_module_format(aabbs) + + if use_kdtree: + metadata = module.compute_inline_kdtree_metadata(objects, object_bounds) + else: + metadata = module.compute_inline_quadtree_metadata(objects, object_bounds) + + if not metadata: + return [], {} + + # build_quadtree_assignments handles (node_id, tier) grouping and the + # ExteriorShell-only rule for shared-bucket routing, matching the exporter. + node_tier_groups, shared_objects, metadata_map = module.build_quadtree_assignments( + objects, object_bounds, inline_metadata=metadata + ) + + scene_min_x = min(v[0].x for v in aabbs.values()) + scene_min_y = min(v[0].y for v in aabbs.values()) + scene_max_x = max(v[1].x for v in aabbs.values()) + scene_max_y = max(v[1].y for v in aabbs.values()) + scene_min_z, scene_max_z = _scene_z_range(aabbs) + + max_count = max((len(v) for v in node_tier_groups.values()), default=1) + boxes: list[tuple] = [] + + for (node_id, _tier), objs in node_tier_groups.items(): + t = (len(objs) - 1) / max(max_count - 1, 1) + color = _heatmap_color(t) + z_min = min(aabbs[obj.name][0].z for obj in objs) + z_max = max(aabbs[obj.name][1].z for obj in objs) + cell_box = _cell_box_from_metadata(module, objs, aabbs, metadata_map) + if cell_box: + boxes.append((cell_box[0], cell_box[1], color)) + continue + + if not use_kdtree: + bounds = _quadtree_node_xy_bounds( + node_id, scene_min_x, scene_min_y, scene_max_x, scene_max_y + ) + if bounds: + nx0, ny0, nx1, ny1 = bounds + boxes.append(((nx0, ny0, z_min), (nx1, ny1, z_max), color)) + continue + + # KD-tree: use union AABB (split positions are data-driven, not reconstructable) + x_min = min(aabbs[obj.name][0].x for obj in objs) + y_min = min(aabbs[obj.name][0].y for obj in objs) + x_max = max(aabbs[obj.name][1].x for obj in objs) + y_max = max(aabbs[obj.name][1].y for obj in objs) + boxes.append(((x_min, y_min, z_min), (x_max, y_max, z_max), color)) + + for obj in shared_objects: + mn_v, mx_v = aabbs[obj.name] + boxes.append(((mn_v.x, mn_v.y, mn_v.z), (mx_v.x, mx_v.y, mx_v.z), _shared_color())) + + return boxes, { + "tiles": len(node_tier_groups), + "nodes": len({node_id for node_id, _tier in node_tier_groups}), + "shared": len(shared_objects), + "max": max_count, + "avg": sum(len(v) for v in node_tier_groups.values()) / max(len(node_tier_groups), 1), + } + + +def _build_lod_plan(objects: list, aabbs: dict, settings) -> dict: + from . import bridge as _bridge + module = _bridge.tile_exporter_module() + + if not settings.generate_hlod and not settings.generate_lod: + return {"enabled": False} + + object_bounds = _aabbs_to_module_format(aabbs) + scene_bounds = _scene_bounds_from_aabbs(aabbs) + scene_half_diag = _scene_half_diag(scene_bounds) + base_tile = max(settings.tile_size_x, settings.tile_size_z, 1.0) + _apply_representation_ranges(module, settings) + streaming_r, unload_r = module.compute_streaming_defaults(base_tile, scene_half_diag) + + hlod_levels = module.validate_hlod_levels() if settings.generate_hlod else [] + lod_levels = module.validate_lod_levels() if settings.generate_lod else [] + active_hlod = module.compute_hlod_switch_distances(streaming_r, unload_r, hlod_levels) + active_lod = module.compute_lod_switch_distances(streaming_r, unload_r, active_hlod, lod_levels) + + mode = settings.partitioning_mode + if mode == 'UNIFORM': + tile_x = settings.tile_size_x + tile_z = settings.tile_size_z + module.TILE_SIZE_X = tile_x + module.TILE_SIZE_Z = tile_z + origin_y = scene_bounds["min"][2] + if settings.auto_tile_size: + tile_x, tile_z, _ = module.choose_auto_tile_size( + objects, object_bounds, scene_bounds, origin_y, module.TILE_SIZE_Y + ) + origin_x = math.floor(scene_bounds["min"][0] / tile_x) * tile_x + origin_z = math.floor(scene_bounds["min"][1] / tile_z) * tile_z + tile_assignments, _shared_objects, _ = module.build_assignments( + objects, object_bounds, + origin_x, origin_y, origin_z, + tile_x, module.TILE_SIZE_Y, tile_z, + ) + eligible_groups = len([objs for objs in tile_assignments.values() if objs]) + skipped_groups = 0 + resolved_profile = settings.scene_profile + by_tier = {} + else: + use_kdtree = mode == 'KDTREE' + module.INLINE_FLOOR_COUNT_OVERRIDE = settings.floor_count if settings.floor_count > 0 else None + module.INLINE_FLOOR_BAND_HEIGHT_OVERRIDE = settings.floor_band_height if settings.floor_band_height > 0.0 else None + module.SCENE_STREAMING_PROFILE = settings.scene_profile + _apply_semantic_policy(module, settings) + _apply_representation_ranges(module, settings) + if use_kdtree: + metadata = module.compute_inline_kdtree_metadata(objects, object_bounds) + else: + metadata = module.compute_inline_quadtree_metadata(objects, object_bounds) + node_tier_groups, _shared_objects, metadata_map = module.build_quadtree_assignments( + objects, object_bounds, inline_metadata=metadata + ) + resolved_profile = module.infer_streaming_profile( + True, node_tier_groups, scene_half_diag, base_tile + ) if settings.scene_profile == "auto" else settings.scene_profile + _apply_tier_radius_overrides(module, settings) + module.init_tier_radii(scene_half_diag, resolved_profile) + eligible_tiers = set(module.HLOD_LOD_TIERS) + eligible_groups = sum( + 1 for (_node_id, tier), objs in node_tier_groups.items() + if objs and tier in eligible_tiers and not module.group_has_spanning_metadata(objs, metadata_map) + ) + skipped_groups = sum( + 1 for (_node_id, tier), objs in node_tier_groups.items() + if objs and (tier not in eligible_tiers or module.group_has_spanning_metadata(objs, metadata_map)) + ) + by_tier = {} + for (_node_id, tier), objs in node_tier_groups.items(): + if objs: + by_tier[tier] = by_tier.get(tier, 0) + 1 + + return { + "enabled": True, + "mode": mode, + "profile": resolved_profile, + "eligible_groups": eligible_groups, + "skipped_groups": skipped_groups, + "hlod_levels": active_hlod, + "lod_levels": active_lod, + "hlod_assets": eligible_groups * len(active_hlod), + "lod_assets": eligible_groups * len(active_lod), + "by_tier": by_tier, + } + + +def _distance_source_position(context: bpy.types.Context, settings) -> tuple[Vector | None, str]: + if settings.runtime_source == "CAMERA": + camera = context.scene.camera + if camera is None: + return None, "Active Camera" + return camera.matrix_world.translation.copy(), "Active Camera" + if settings.runtime_source == "CURSOR": + return context.scene.cursor.location.copy(), "3D Cursor" + active = context.object + if active is None: + return None, "Selected Object" + return active.matrix_world.translation.copy(), "Selected Object" + + +def _runtime_ladder(module, streaming_r: float, unload_r: float, settings, eligible: bool) -> tuple[list, list]: + if not eligible: + return [], [] + _apply_representation_ranges(module, settings) + hlod_levels = module.validate_hlod_levels() if settings.generate_hlod else [] + lod_levels = module.validate_lod_levels() if settings.generate_lod else [] + active_hlod = module.compute_hlod_switch_distances(streaming_r, unload_r, hlod_levels) + active_lod = module.compute_lod_switch_distances(streaming_r, unload_r, active_hlod, lod_levels) + return active_hlod, active_lod + + +def _runtime_distance_to_bounds(module, source_pos: Vector, aabb: dict) -> float: + return module.distance_to_aabb( + (source_pos.x, source_pos.y, source_pos.z), + aabb, + ) + + +def _build_uniform_runtime_boxes(objects: list, aabbs: dict, settings, source_pos: Vector) -> tuple[list, dict]: + from . import bridge as _bridge + module = _bridge.tile_exporter_module() + + tile_x = settings.tile_size_x + tile_z = settings.tile_size_z + module.SPANNING_THRESHOLD_TILES = settings.spanning_threshold + module.TILE_SIZE_X = tile_x + module.TILE_SIZE_Z = tile_z + + object_bounds = _aabbs_to_module_format(aabbs) + scene_bounds = _scene_bounds_from_aabbs(aabbs) + origin_y = scene_bounds["min"][2] + + if settings.auto_tile_size: + tile_x, tile_z, _ = module.choose_auto_tile_size( + objects, object_bounds, scene_bounds, origin_y, module.TILE_SIZE_Y + ) + module.TILE_SIZE_X = tile_x + module.TILE_SIZE_Z = tile_z + + origin_x = math.floor(scene_bounds["min"][0] / tile_x) * tile_x + origin_z = math.floor(scene_bounds["min"][1] / tile_z) * tile_z + + tile_assignments, shared_objects, _ = module.build_assignments( + objects, object_bounds, + origin_x, origin_y, origin_z, + tile_x, module.TILE_SIZE_Y, tile_z, + ) + + scene_min_z, scene_max_z = _scene_z_range(aabbs) + scene_half_diag = _scene_half_diag(scene_bounds) + streaming_r, unload_r = module.compute_streaming_defaults(max(tile_x, tile_z, 1.0), scene_half_diag) + + # Apply profile, semantic policy, rep ranges, and tier radius overrides so + # that custom tier radii (and the outdoor 1-floor rule) take effect in + # Uniform Grid mode — without this, only the tiny tile-multiplier defaults + # (streaming=20 m, unload=30 m for a 10 m tile) are used, leaving almost + # every tile gray/unloaded when the camera is more than ~30 m from it. + module.SCENE_STREAMING_PROFILE = settings.scene_profile + _apply_semantic_policy(module, settings) + _apply_representation_ranges(module, settings) + _apply_tier_radius_overrides(module, settings) + resolved_profile = ( + settings.scene_profile + if settings.scene_profile != "auto" + else module.infer_streaming_profile(False, {}, scene_half_diag, max(tile_x, tile_z, 1.0)) + ) + module.init_tier_radii(scene_half_diag, resolved_profile) + if getattr(settings, "use_custom_tier_radii", False): + dominant = module.tier_streaming_radii("ExteriorShell") + streaming_r = float(dominant.get("streaming", streaming_r)) + unload_r = float(dominant.get("unload", unload_r)) + + active_hlod, active_lod = _runtime_ladder( + module, streaming_r, unload_r, settings, bool(settings.generate_hlod or settings.generate_lod) + ) + stats = { + "full": 0, "lod1": 0, "lod2": 0, "hlod": 0, "unloaded": 0, "shared": len(shared_objects), + } + boxes: list[tuple] = [] + + for tx, ty, tz in tile_assignments.keys(): + tb = module.tile_bounds_from_coord( + tx, ty, tz, origin_x, origin_y, origin_z, + tile_x, module.TILE_SIZE_Y, tile_z, + ) + mn = (tb["min_x"], tb["min_z"], scene_min_z) + mx = (tb["max_x"], tb["max_z"], scene_max_z) + distance = _runtime_distance_to_bounds(module, source_pos, {"min": mn, "max": mx}) + state = module.classify_runtime_representation_detail(distance, unload_r, active_hlod, active_lod) + if state not in stats: + stats[state] = 0 + stats[state] += 1 + boxes.append((mn, mx, _runtime_color(state))) + + for obj in shared_objects: + mn_v, mx_v = aabbs[obj.name] + boxes.append(((mn_v.x, mn_v.y, mn_v.z), (mx_v.x, mx_v.y, mx_v.z), _runtime_color("shared"))) + + return boxes, stats + + +def _build_tree_runtime_boxes( + objects: list, + aabbs: dict, + settings, + source_pos: Vector, + use_kdtree: bool, +) -> tuple[list, dict]: + from . import bridge as _bridge + module = _bridge.tile_exporter_module() + + module.INLINE_FLOOR_COUNT_OVERRIDE = settings.floor_count if settings.floor_count > 0 else None + module.INLINE_FLOOR_BAND_HEIGHT_OVERRIDE = settings.floor_band_height if settings.floor_band_height > 0.0 else None + module.SCENE_STREAMING_PROFILE = settings.scene_profile + _apply_semantic_policy(module, settings) + _apply_representation_ranges(module, settings) + + object_bounds = _aabbs_to_module_format(aabbs) + metadata = ( + module.compute_inline_kdtree_metadata(objects, object_bounds) + if use_kdtree + else module.compute_inline_quadtree_metadata(objects, object_bounds) + ) + if not metadata: + return [], {} + + node_tier_groups, shared_objects, metadata_map = module.build_quadtree_assignments( + objects, object_bounds, inline_metadata=metadata + ) + + scene_bounds = _scene_bounds_from_aabbs(aabbs) + scene_half_diag = _scene_half_diag(scene_bounds) + base_tile = max(settings.tile_size_x, settings.tile_size_z, 1.0) + resolved_profile = module.infer_streaming_profile( + True, node_tier_groups, scene_half_diag, base_tile + ) if settings.scene_profile == "auto" else settings.scene_profile + _apply_tier_radius_overrides(module, settings) + module.init_tier_radii(scene_half_diag, resolved_profile) + + eligible_tiers = set(module.HLOD_LOD_TIERS) + stats = { + "full": 0, "lod1": 0, "lod2": 0, "hlod": 0, "unloaded": 0, "shared": len(shared_objects), + } + boxes: list[tuple] = [] + + for (_node_id, tier), objs in node_tier_groups.items(): + if not objs: + continue + + cell_box = _cell_box_from_metadata(module, objs, aabbs, metadata_map) + if cell_box: + distance_mn, distance_mx = cell_box + distance_aabb = {"min": distance_mn, "max": distance_mx} + else: + distance_aabb = module.object_union_aabb(objs, object_bounds) + distance_mn = distance_aabb["min"] + distance_mx = distance_aabb["max"] + mn = distance_mn + mx = distance_mx + + radii = module.tier_streaming_radii(tier) + streaming_r = float(radii.get("streaming", 1.0)) + unload_r = float(radii.get("unload", max(2.0, streaming_r * 1.5))) + is_spanning_group = module.group_has_spanning_metadata(objs, metadata_map) + active_hlod, active_lod = _runtime_ladder( + module, streaming_r, unload_r, settings, tier in eligible_tiers and not is_spanning_group + ) + distance = _runtime_distance_to_bounds(module, source_pos, distance_aabb) + state = module.classify_runtime_representation_detail(distance, unload_r, active_hlod, active_lod) + if state not in stats: + stats[state] = 0 + stats[state] += 1 + boxes.append((mn, mx, _runtime_color(state))) + + for obj in shared_objects: + mn_v, mx_v = aabbs[obj.name] + boxes.append(((mn_v.x, mn_v.y, mn_v.z), (mx_v.x, mx_v.y, mx_v.z), _runtime_color("shared"))) + + stats["profile"] = resolved_profile + return boxes, stats + + +# ── Operators ───────────────────────────────────────────────────────────────── + +class UNTOLD_OT_preview_tiles(bpy.types.Operator): + bl_idname = "untold.preview_tiles" + bl_label = "Preview Tiles" + bl_description = ( + "Draw a colour-coded tile grid in the 3D viewport. " + "Green = low density, red = high density, blue = shared bucket" + ) + bl_options = {"REGISTER"} + + def execute(self, context: bpy.types.Context) -> set[str]: + global _tile_boxes, _preview_object_names, _preview_color_mode + + # Always clear any existing overlay first so a failed or empty preview + # never leaves stale boxes from a previous run visible. + _clear_preview_state() + _tag_viewports_redraw(context) + + settings = context.scene.untold_tile_preview + objects = _collect_objects(context, settings.visible_only) + + if not objects: + self.report({'WARNING'}, "No mesh objects found for the selected scope") + return {'CANCELLED'} + + aabbs = _compute_aabbs(objects, context) + mode = settings.partitioning_mode + + try: + if mode == 'UNIFORM': + boxes, stats = _build_uniform_boxes(objects, aabbs, settings) + elif mode == 'QUADTREE': + boxes, stats = _build_tree_boxes(objects, aabbs, settings, use_kdtree=False) + else: # KDTREE + boxes, stats = _build_tree_boxes(objects, aabbs, settings, use_kdtree=True) + except Exception as exc: + self.report({'ERROR'}, f"Preview failed: {exc}") + return {'CANCELLED'} + + if not boxes: + self.report({'WARNING'}, "No tile assignments computed — check settings") + return {'CANCELLED'} + + _tile_boxes = boxes + _preview_color_mode = "DENSITY" + _preview_object_names = {obj.name for obj in objects} + _rebuild_draw_batches(context) + _register_draw_handler() + _tag_viewports_redraw(context) + + mode_label = {"UNIFORM": "uniform grid", "QUADTREE": "quadtree", "KDTREE": "KD-tree"}[mode] + extra = "" + if mode == 'UNIFORM' and settings.auto_tile_size: + extra = f" | auto tile {stats['tile_x']:.1f}×{stats['tile_z']:.1f}" + elif mode != 'UNIFORM': + extra = f" | {stats['nodes']} spatial nodes" + unit_label = "tiles" if mode == 'UNIFORM' else "tile-tier pairs" + avg_label = "obj/tile" if mode == 'UNIFORM' else "obj/pair" + self.report( + {'INFO'}, + f"Tile preview ({mode_label}): {stats['tiles']} {unit_label} | " + f"{stats['shared']} shared | avg {stats['avg']:.1f} {avg_label} | " + f"max {stats['max']}{extra}" + ) + return {'FINISHED'} + + +class UNTOLD_OT_preview_runtime_bands(bpy.types.Operator): + bl_idname = "untold.preview_runtime_bands" + bl_label = "Preview Runtime States" + bl_description = "Color each tile by the representation selected from its bounds distance and switch distances" + bl_options = {"REGISTER"} + + def execute(self, context: bpy.types.Context) -> set[str]: + global _tile_boxes, _preview_object_names, _preview_color_mode + + _clear_preview_state() + _tag_viewports_redraw(context) + + settings = context.scene.untold_tile_preview + source_pos, source_label = _distance_source_position(context, settings) + if source_pos is None: + self.report({'WARNING'}, f"No {source_label.lower()} available for runtime preview") + return {'CANCELLED'} + + objects = _collect_objects(context, settings.visible_only) + if not objects: + self.report({'WARNING'}, "No mesh objects found for the selected scope") + return {'CANCELLED'} + + aabbs = _compute_aabbs(objects, context) + mode = settings.partitioning_mode + + try: + if mode == 'UNIFORM': + boxes, stats = _build_uniform_runtime_boxes(objects, aabbs, settings, source_pos) + elif mode == 'QUADTREE': + boxes, stats = _build_tree_runtime_boxes( + objects, aabbs, settings, source_pos, use_kdtree=False + ) + else: + boxes, stats = _build_tree_runtime_boxes( + objects, aabbs, settings, source_pos, use_kdtree=True + ) + except Exception as exc: + self.report({'ERROR'}, f"Runtime preview failed: {exc}") + return {'CANCELLED'} + + if not boxes: + self.report({'WARNING'}, "No runtime states computed — check settings") + return {'CANCELLED'} + + _tile_boxes = boxes + _preview_color_mode = "RUNTIME" + _preview_object_names = {obj.name for obj in objects} + _rebuild_draw_batches(context) + _register_draw_handler() + _tag_viewports_redraw(context) + + profile = f" | profile {stats['profile']}" if stats.get("profile") else "" + self.report( + {'INFO'}, + f"Runtime states from {source_label}: full {stats.get('full', 0)} | " + f"LOD1 {stats.get('lod1', 0)} | LOD2 {stats.get('lod2', 0)} | " + f"HLOD {stats.get('hlod', 0)} | unloaded {stats.get('unloaded', 0)} | " + f"shared {stats.get('shared', 0)}{profile}" + ) + return {'FINISHED'} + + +class UNTOLD_OT_clear_tile_preview(bpy.types.Operator): + bl_idname = "untold.clear_tile_preview" + bl_label = "Clear" + bl_description = "Remove the tile grid overlay from the viewport" + bl_options = {"REGISTER"} + + def execute(self, context: bpy.types.Context) -> set[str]: + _clear_preview_state() + _tag_viewports_redraw(context) + self.report({'INFO'}, "Tile preview cleared") + return {'FINISHED'} + + +class UNTOLD_OT_preview_lod_plan(bpy.types.Operator): + bl_idname = "untold.preview_lod_plan" + bl_label = "Preview LOD Plan" + bl_description = "Report tile-level LOD/HLOD payloads that tiled export will generate" + bl_options = {"REGISTER"} + + def execute(self, context: bpy.types.Context) -> set[str]: + settings = context.scene.untold_tile_preview + objects = _collect_objects(context, settings.visible_only) + if not objects: + self.report({'WARNING'}, "No mesh objects found for the selected scope") + return {'CANCELLED'} + + aabbs = _compute_aabbs(objects, context) + try: + plan = _build_lod_plan(objects, aabbs, settings) + except Exception as exc: + self.report({'ERROR'}, f"LOD plan failed: {exc}") + return {'CANCELLED'} + + if not plan.get("enabled"): + self.report({'WARNING'}, "Enable Generate HLOD or Generate LOD first") + return {'CANCELLED'} + + total_assets = plan["hlod_assets"] + plan["lod_assets"] + hlod_switches = [l["switch_distance"] for l in plan["hlod_levels"]] + lod_switches = [l["switch_distance"] for l in plan["lod_levels"]] + tier_summary = "" + if plan["by_tier"]: + tier_summary = " | " + ", ".join( + f"{tier}:{count}" for tier, count in sorted(plan["by_tier"].items()) + ) + self.report( + {'INFO'}, + f"LOD plan: {total_assets} payloads for {plan['eligible_groups']} eligible groups " + f"({plan['skipped_groups']} skipped) | profile {plan['profile']} | " + f"HLOD {hlod_switches} | LOD {lod_switches}{tier_summary}" + ) + return {'FINISHED'} + + +class UNTOLD_OT_meshes_to_bounds(bpy.types.Operator): + bl_idname = "untold.meshes_to_bounds" + bl_label = "Set Meshes To Bounds" + bl_description = "Draw scene meshes as viewport bounding boxes while keeping them exportable" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + objects = _viewport_utility_meshes(context) + if not objects: + self.report({'WARNING'}, "No mesh objects found") + return {'CANCELLED'} + + _remember_mesh_view_state(context, objects) + for obj in objects: + obj.display_type = 'BOUNDS' + obj.hide_viewport = False + obj.hide_set(False, view_layer=context.view_layer) + + _tag_viewports_redraw(context) + self.report({'INFO'}, f"Set {len(objects)} mesh objects to bounds display") + return {'FINISHED'} + + +class UNTOLD_OT_hide_meshes_for_preview(bpy.types.Operator): + bl_idname = "untold.hide_meshes_for_preview" + bl_label = "Hide Meshes" + bl_description = "Hide scene meshes in the viewport so the tile overlay can be inspected" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + objects = _viewport_utility_meshes(context) + if not objects: + self.report({'WARNING'}, "No mesh objects found") + return {'CANCELLED'} + + _remember_mesh_view_state(context, objects) + for obj in objects: + obj.hide_set(True, view_layer=context.view_layer) + + _tag_viewports_redraw(context) + self.report( + {'INFO'}, + f"Hid {len(objects)} mesh objects in the viewport. Restore before rerunning with Visible Objects Only." + ) + return {'FINISHED'} + + +class UNTOLD_OT_restore_mesh_display(bpy.types.Operator): + bl_idname = "untold.restore_mesh_display" + bl_label = "Restore Mesh Display" + bl_description = "Restore mesh display and viewport hide states saved by the tile preview utilities" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: bpy.types.Context) -> set[str]: + global _mesh_view_state + + if not _mesh_view_state: + self.report({'WARNING'}, "No saved mesh display state to restore") + return {'CANCELLED'} + + restored = 0 + view_layer = context.view_layer + for name, state in list(_mesh_view_state.items()): + obj = bpy.data.objects.get(name) + if obj is None: + continue + obj.display_type = state["display_type"] + obj.hide_viewport = state["hide_viewport"] + obj.hide_set(state["hide_get"], view_layer=view_layer) + restored += 1 + + _mesh_view_state = {} + _tag_viewports_redraw(context) + self.report({'INFO'}, f"Restored viewport display for {restored} mesh objects") + return {'FINISHED'} + + +# ── Force-local tile policy operator ────────────────────────────────────────── + +class UNTOLD_OT_set_tile_policy(bpy.types.Operator): + """Toggle Force Local on selected objects — bypasses shared-bucket classification +so the object is assigned to a regular tile and receives its own LOD/HLOD ladder.""" + bl_idname = "untold.set_tile_policy" + bl_label = "Toggle Force Local" + bl_options = {"REGISTER", "UNDO"} + + policy: bpy.props.StringProperty(default="force_local") # type: ignore[valid-type] + + def execute(self, context: bpy.types.Context) -> set[str]: + targets = context.selected_objects or [] + if not targets: + self.report({'WARNING'}, "No objects selected") + return {'CANCELLED'} + changed = 0 + for obj in targets: + if obj.get("untold_tile_policy") == self.policy: + del obj["untold_tile_policy"] + else: + obj["untold_tile_policy"] = self.policy + changed += 1 + noun = "object" if changed == 1 else "objects" + self.report({'INFO'}, f"Tile policy updated on {changed} {noun}") + return {'FINISHED'} + + +# ── Sidebar panel ───────────────────────────────────────────────────────────── + +class UNTOLD_PT_tile_preview(bpy.types.Panel): + bl_label = "Tile & LOD Setup" + bl_idname = "UNTOLD_PT_tile_preview" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'Untold Tiles' + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + settings = context.scene.untold_tile_preview + mode = settings.partitioning_mode + + layout.prop(settings, "partitioning_mode") + layout.prop(settings, "visible_only") + layout.separator(factor=0.5) + + if mode == 'UNIFORM': + col = layout.column(align=True) + col.label(text="Grid") + col.prop(settings, "auto_tile_size") + sub = col.column(align=True) + sub.enabled = not settings.auto_tile_size + sub.prop(settings, "tile_size_x") + sub.prop(settings, "tile_size_z") + col.prop(settings, "spanning_threshold") + else: + col = layout.column(align=True) + col.label(text="Floor detection") + col.prop(settings, "floor_count") + col.prop(settings, "floor_band_height") + col.label(text="(0 = auto-detect)", icon='INFO') + + layout.separator() + col = layout.column(align=True) + col.label(text="LOD") + col.prop(settings, "scene_profile") + col.prop(settings, "untagged_semantic_tier") + col.prop(settings, "use_custom_tier_radii") + if settings.use_custom_tier_radii: + for label, prefix in [ + ("Exterior Shell", "exterior_shell"), + ("Structural Interior", "structural_interior"), + ("Room Contents", "room_contents"), + ("Fine Props", "fine_props"), + ]: + box = col.box() + box.label(text=label) + row = box.row(align=True) + row.prop(settings, f"{prefix}_streaming_radius", text="Stream") + row.prop(settings, f"{prefix}_unload_radius", text="Unload") + row.prop(settings, f"{prefix}_priority", text="Priority") + col.prop(settings, "generate_hlod") + col.prop(settings, "generate_lod") + col.prop(settings, "use_custom_representation_ranges") + if settings.use_custom_representation_ranges: + box = col.box() + row = box.row(align=True) + row.prop(settings, "lod1_switch_distance", text="LOD1 (m)") + row.prop(settings, "lod1_reduction_ratio", text="Ratio") + row = box.row(align=True) + row.prop(settings, "lod2_switch_distance", text="LOD2 (m)") + row.prop(settings, "lod2_reduction_ratio", text="Ratio") + row = box.row(align=True) + row.prop(settings, "hlod_switch_distance", text="HLOD (m)") + row.prop(settings, "hlod_reduction_ratio", text="Ratio") + summary = _repr_summary_for_settings(settings) + if summary: + sr = summary["streaming_r"] + ur = summary["unload_r"] + lod1 = summary["lod1"] + lod2 = summary["lod2"] + hlod = summary["hlod"] + parts = [f"full < {sr:.0f} m"] + if lod1 is not None: + end = f"{lod2:.0f}" if lod2 is not None else (f"{hlod:.0f}" if hlod is not None else f"{ur:.0f}") + parts.append(f"LOD1 {lod1:.0f}–{end} m") + if lod2 is not None: + end = f"{hlod:.0f}" if hlod is not None else f"{ur:.0f}" + parts.append(f"LOD2 {lod2:.0f}–{end} m") + if hlod is not None: + band = ur - hlod + warn = " (!)" if band < 20 else "" + parts.append(f"HLOD {hlod:.0f}–{ur:.0f} m{warn}") + sbox = col.box() + sbox.scale_y = 0.75 + sbox.label(text=f"ExteriorShell ({sr:.0f} / {ur:.0f} m):") + sbox.label(text=" | ".join(parts)) + if hlod is not None and (ur - hlod) < 20: + sbox.label(text="HLOD band < 20 m — raise Unload or lower HLOD start", icon='ERROR') + col.operator("untold.preview_lod_plan", icon='MOD_DECIM', text="Preview LOD Plan") + + layout.separator() + col = layout.column(align=True) + col.label(text="Runtime") + col.prop(settings, "runtime_source") + col.operator("untold.preview_runtime_bands", icon='VIEW_CAMERA', text="Preview Runtime States") + + layout.separator() + col = layout.column(align=True) + col.label(text="Viewport") + col.operator("untold.meshes_to_bounds", icon='MESH_CUBE', text="Set Meshes To Bounds") + row = col.row(align=True) + row.operator("untold.hide_meshes_for_preview", icon='HIDE_ON', text="Hide Meshes") + row.operator("untold.restore_mesh_display", icon='FILE_REFRESH', text="Restore") + + layout.separator() + col = layout.column(align=True) + col.label(text="Object Override") + obj = context.active_object + if obj and obj.type == 'MESH': + is_force_local = obj.get("untold_tile_policy") == "force_local" + box = col.box() + row = box.row(align=True) + icon = 'PINNED' if is_force_local else 'UNPINNED' + row.label(text=obj.name, icon=icon) + op = row.operator( + "untold.set_tile_policy", + text="Force Local: ON" if is_force_local else "Force Local: OFF", + icon='CHECKBOX_HLT' if is_force_local else 'CHECKBOX_DEHLT', + ) + op.policy = "force_local" + if is_force_local: + box.label(text="Excluded from shared bucket", icon='INFO') + else: + col.label(text="Select a mesh object", icon='INFO') + + layout.separator() + + row = layout.row(align=True) + row.operator("untold.preview_tiles", icon='OVERLAY', text="Preview Tiles") + row.operator("untold.clear_tile_preview", icon='X', text="") + layout.prop(settings, "show_tile_floor_fill") + + if _tile_boxes: + layout.separator(factor=0.5) + box = layout.box() + col = box.column(align=True) + col.scale_y = 0.8 + if _preview_color_mode == "RUNTIME": + col.label(text="Runtime States") + col.label(text=" White - full (LOD0)") + col.label(text=" Cyan - LOD1") + col.label(text=" Yellow - LOD2") + col.label(text=" Orange - HLOD") + col.label(text=" Red - unloaded") + col.label(text=" Blue - shared bucket") + else: + col.label(text="Density") + col.label(text=" Green - low") + col.label(text=" Yellow - medium") + col.label(text=" Red - high") + col.label(text=" Blue - shared bucket") + + +# ── Registration ────────────────────────────────────────────────────────────── + +classes = ( + UntoldTilePreviewSettings, + UNTOLD_PT_tile_preview, + UNTOLD_OT_preview_tiles, + UNTOLD_OT_clear_tile_preview, + UNTOLD_OT_preview_lod_plan, + UNTOLD_OT_preview_runtime_bands, + UNTOLD_OT_meshes_to_bounds, + UNTOLD_OT_hide_meshes_for_preview, + UNTOLD_OT_restore_mesh_display, + UNTOLD_OT_set_tile_policy, +) + + +def register() -> None: + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.untold_tile_preview = bpy.props.PointerProperty( + type=UntoldTilePreviewSettings + ) + _register_app_handlers() + + +def unregister() -> None: + _unregister_app_handlers() + _clear_preview_state() + del bpy.types.Scene.untold_tile_preview + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index f61a729e..e61b5fc6 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -95,10 +95,11 @@ class ProgressReporter: - def __init__(self, label: str, total_steps: int) -> None: + def __init__(self, label: str, total_steps: int, on_progress: Optional[ProgressCallback] = None) -> None: self.label = label self.total_steps = max(int(total_steps), 1) self.completed_steps = 0 + self.on_progress = on_progress def stage(self, stage: str, detail: str = "") -> None: self._emit(stage, detail, self.completed_steps) @@ -115,6 +116,8 @@ def _emit(self, stage: str, detail: str, completed_steps: int) -> None: f"({completed_steps}/{self.total_steps}) {stage}{suffix}", flush=True, ) + if self.on_progress is not None: + self.on_progress(stage, completed_steps, self.total_steps, detail) def align(value: int, alignment: int) -> int: