From 2c9217daa2c1af2de047626199eb822cf36837ab Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 26 May 2026 08:21:53 -0700 Subject: [PATCH 1/4] [Patch] Add scene channels for selectable and context geometry visibility --- Sources/UntoldEngine/ECS/Components.swift | 7 + .../UntoldEngine/Renderer/RenderPasses.swift | 11 +- .../UntoldEngine/Systems/BatchingSystem.swift | 18 +- ...eometryStreamingSystem+TileStreaming.swift | 15 +- .../Systems/RegistrationSystem.swift | 25 ++- .../Utils/SceneContextVisibility.swift | 165 ++++++++++++++++++ .../StaticBatchingTest.swift | 59 ++++++- .../SceneContextVisibilityTests.swift | 110 ++++++++++++ Tests/UntoldEngineTests/TestEngineReset.swift | 1 + docs/API/UsingSceneChannels.md | 89 ++++++++++ docs/API/UsingStaticBatchingSystem.md | 1 + docs/API/UsingTheExporter.md | 2 + docs/Architecture/batchingSystem.md | 6 +- docs/Architecture/renderingSystem.md | 4 +- docs/Architecture/sceneChannels.md | 62 +++++++ 15 files changed, 560 insertions(+), 15 deletions(-) create mode 100644 Sources/UntoldEngine/Utils/SceneContextVisibility.swift create mode 100644 Tests/UntoldEngineTests/SceneContextVisibilityTests.swift create mode 100644 docs/API/UsingSceneChannels.md create mode 100644 docs/Architecture/sceneChannels.md diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 14fbd977..e594d886 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -75,6 +75,13 @@ public class PickInteractionComponent: Component { public required init() {} } +public class EntitySceneChannelsComponent: Component { + public var channels: SceneChannel = [] + public var usesDefaultChannels: Bool = false + + public required init() {} +} + public class GaussianComponent: Component { var splatData: MTLBuffer? var gaussianSortedIndices: MTLBuffer? diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 7d3562b4..573ef8ae 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -271,15 +271,17 @@ public enum RenderPasses { private static func collectVisibleBatchGroupsDirect() -> [BatchGroup] { let groups = BatchingSystem.shared.batchGroups guard !groups.isEmpty else { return [] } + let channelVisibleGroups = groups.filter { areSceneChannelsVisible($0.sceneChannels) } + guard !channelVisibleGroups.isEmpty else { return [] } guard let frustum = currentFrameFrustum else { // Frustum not yet available — derive from visible entities (safe fallback). let ids = collectVisibleBatchIds(from: visibleEntityIds) - return groups.filter { ids.contains($0.id) } + return channelVisibleGroups.filter { ids.contains($0.id) } } // Phase 1: frustum cull — drop groups whose AABB is outside the view frustum. - let frustumPassed = groups.filter { + let frustumPassed = channelVisibleGroups.filter { isAABBInFrustum(frustum, min: $0.boundingBox.min, max: $0.boundingBox.max) } @@ -367,6 +369,7 @@ public enum RenderPasses { for entityId in entities { if shouldSkipShadowEntity(entityId) { continue } + if shouldHideSceneEntity(entityId: entityId) { continue } if BatchingSystem.shared.isEnabled() { // Batch-eligible entities (StaticBatchComponent present) are always drawn // via shadowCasterBatchGroups — whether or not the batch rebuild has landed @@ -404,7 +407,7 @@ public enum RenderPasses { } private static func shadowCasterBatchGroups(for cascadeIdx: Int) -> [BatchGroup] { - let groups = BatchingSystem.shared.batchGroups + let groups = BatchingSystem.shared.batchGroups.filter { areSceneChannelsVisible($0.sceneChannels) } guard !groups.isEmpty, let frustum = shadowFrustum(for: cascadeIdx) else { return [] } return groups.filter { @@ -994,6 +997,7 @@ public enum RenderPasses { for entityId in visibleEntityIds { // Skip entities that are pending destroy if scene.mask(for: entityId) == nil { continue } + if shouldHideSceneEntity(entityId: entityId) { continue } // Skip batched entities if batching is enabled if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { @@ -2435,6 +2439,7 @@ public enum RenderPasses { for entityId in visibleEntityIds { if scene.mask(for: entityId) == nil { continue } + if shouldHideSceneEntity(entityId: entityId) { continue } if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { continue diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 05c3c675..747c99c3 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -40,6 +40,7 @@ private struct BatchBuildKey: Hashable { let cellId: BatchCellID let materialHash: String let lodIndex: Int + let sceneChannelsRawValue: UInt64 var materialLODHash: String { "\(materialHash)_LOD\(lodIndex)" @@ -60,6 +61,7 @@ private struct BatchCandidate { let worldTransform: WorldTransformComponent let lodIndex: Int let cellId: BatchCellID + let sceneChannels: SceneChannel } private struct BatchCellLifecycleRecord { @@ -422,6 +424,7 @@ public struct BatchGroup { var entityIds: [EntityID] // Original entities in this batch var meshIndices: [(entityId: EntityID, meshIndex: Int)] // Track source meshes var boundingBox: (min: simd_float3, max: simd_float3) + var sceneChannels: SceneChannel /// Incremented each time `updateBatchMaterialInPlace` patches this group's textures. /// Used to detect whether a batch artifact is stale relative to the live streaming state. @@ -1413,7 +1416,8 @@ public class BatchingSystem: @unchecked Sendable { let key = BatchBuildKey( cellId: candidate.cellId, materialHash: matHash, - lodIndex: candidate.lodIndex + lodIndex: candidate.lodIndex, + sceneChannelsRawValue: candidate.sceneChannels.rawValue ) groupCounts[key, default: 0] += 1 groupVertices[key, default: 0] += vertexCount @@ -1504,6 +1508,9 @@ public class BatchingSystem: @unchecked Sendable { // Skip entities with empty meshes (not yet loaded by streaming) if renderComponent.mesh.isEmpty { return nil } + // Identity-preserved streamed objects must stay individually renderable/selectable. + if shouldPreserveSceneEntityIdentity(entityId: entityId) { return nil } + // Skip entities with animations if scene.get(component: SkeletonComponent.self, for: entityId) != nil { return nil } if scene.get(component: AnimationComponent.self, for: entityId) != nil { return nil } @@ -1531,7 +1538,8 @@ public class BatchingSystem: @unchecked Sendable { renderComponent: renderComponent, worldTransform: worldTransform, lodIndex: lodIndex, - cellId: cellId + cellId: cellId, + sceneChannels: getEntitySceneChannels(entityId: entityId) ) } @@ -1550,7 +1558,8 @@ public class BatchingSystem: @unchecked Sendable { let batchKey = BatchBuildKey( cellId: candidate.cellId, materialHash: matHash, - lodIndex: candidate.lodIndex + lodIndex: candidate.lodIndex, + sceneChannelsRawValue: candidate.sceneChannels.rawValue ) let finalTransform = simd_mul(candidate.worldTransform.space, mesh.localSpace) @@ -1907,6 +1916,7 @@ public class BatchingSystem: @unchecked Sendable { var allIndices: [UInt32] = [] var entityIds: [EntityID] = [] var meshIndices: [(EntityID, Int)] = [] + var sceneChannels: SceneChannel = [] var minBounds = simd_float3(Float.infinity, Float.infinity, Float.infinity) var maxBounds = simd_float3(-Float.infinity, -Float.infinity, -Float.infinity) @@ -1936,6 +1946,7 @@ public class BatchingSystem: @unchecked Sendable { entityIds.append(item.entityId) meshIndices.append((item.entityId, item.meshIndex)) + sceneChannels.formUnion(getEntitySceneChannels(entityId: item.entityId)) } guard !allPositions.isEmpty, !allIndices.isEmpty else { @@ -2022,6 +2033,7 @@ public class BatchingSystem: @unchecked Sendable { entityIds: entityIds, meshIndices: meshIndices, boundingBox: (min: minBounds, max: maxBounds), + sceneChannels: sceneChannels, isLODBatch: isLODBatch ) } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift index b20727ac..5c92be07 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift @@ -562,6 +562,9 @@ extension GeometryStreamingSystem { // The completion already holds the world-mutation gate. setEntityStaticBatchComponentUngated(entityId: capturedMeshEntityId) + let tileRenderIds = self.collectRenderDescendantIds(capturedMeshEntityId) + let selectableRenderIds = tileRenderIds.filter { hasEntitySceneChannel(entityId: $0, channel: .selectableGeometry) } + // For fullLoad tiles (occCount == 0) the RenderComponent is // already present on capturedMeshEntityId and its children — // they bypass the OCC upload path that normally queues the @@ -573,7 +576,6 @@ 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. - let tileRenderIds = self.collectRenderDescendantIds(capturedMeshEntityId) if !tileRenderIds.isEmpty { BatchingSystem.shared.notifyTileEntitiesResident(tileRenderIds) TextureStreamingSystem.shared.notifyEntitiesReady(tileRenderIds) @@ -582,7 +584,16 @@ extension GeometryStreamingSystem { let budgetStats = MemoryBudgetManager.shared.getStats() let geomPct = Int((budgetStats.geometryUtilization * 100).rounded()) - Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)") + let selectableNames = selectableRenderIds + .map { getEntityName(entityId: $0) } + .filter { !$0.isEmpty } + .sorted() + .prefix(8) + .joined(separator: ", ") + let selectableSuffix = selectableRenderIds.isEmpty + ? "" + : " selectable=[\(selectableNames)]" + Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload, render=\(tileRenderIds.count), selectable=\(selectableRenderIds.count)). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)\(selectableSuffix)") } else { // Destroy the pre-created child entity on failure so it // doesn't leak as an empty, invisible stub. diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index efe8bf5d..0266b2c7 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -218,6 +218,10 @@ private func registerComponentCleanupHandlers() { removeEntityPickInteraction(entityId: entityId) } + ComponentRegistry.register(componentType: EntitySceneChannelsComponent.self, handlerId: "sceneChannels", priority: 30) { entityId in + removeEntitySceneChannels(entityId: entityId) + } + ComponentRegistry.register(componentType: LocalTransformComponent.self, handlerId: "transforms", priority: 90) { entityId in removeEntityTransforms(entityId: entityId) } @@ -639,6 +643,7 @@ private func registerUntoldProgressiveStubEntity( sc.streamingRadius = Float.greatestFiniteMagnitude sc.unloadRadius = Float.greatestFiniteMagnitude } + setDefaultEntitySceneChannels(entityId: childEntityId, channels: defaultSceneChannels(forName: uniqueAssetName)) return childEntityId } @@ -2168,6 +2173,9 @@ func registerRenderComponent(entityId: EntityID, meshes: [Mesh], url: URL, asset renderComponent.assetName = assetName renderComponent.assetURL = url entityMeshMap[entityId] = resolvedMeshes + let entityName = getEntityName(entityId: entityId) + let channelSourceName = entityName.isEmpty ? assetName : entityName + setDefaultEntitySceneChannels(entityId: entityId, channels: defaultSceneChannels(forName: channelSourceName)) let boundingBox = Mesh.computeMeshBoundingBox(for: resolvedMeshes) @@ -2213,6 +2221,15 @@ public func setEntityName(entityId: EntityID, name: String) { list.append(entityId) } reverseEntityNameMap[name] = list + + let hasRenderableSceneComponent = scene.get(component: RenderComponent.self, for: entityId) != nil || + scene.get(component: StreamingComponent.self, for: entityId) != nil + if let component = scene.get(component: EntitySceneChannelsComponent.self, for: entityId), + component.usesDefaultChannels, + hasRenderableSceneComponent + { + component.channels = defaultSceneChannels(forName: name) + } } public func getEntityName(entityId: EntityID) -> String { @@ -2501,7 +2518,7 @@ private func setEntityStaticBatchComponentRecursive(entityId: EntityID) { // becomes GPU-resident, so it must be tagged before the RenderComponent arrives. let hasRender = scene.get(component: RenderComponent.self, for: entityId) != nil let hasStreaming = scene.get(component: StreamingComponent.self, for: entityId) != nil - if hasRender || hasStreaming { + if hasRender || hasStreaming, !shouldPreserveSceneEntityIdentity(entityId: entityId) { if !hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { registerComponent(entityId: entityId, componentType: StaticBatchComponent.self) } else { @@ -2640,6 +2657,12 @@ func removeEntityPickInteraction(entityId: EntityID) { } } +func removeEntitySceneChannels(entityId: EntityID) { + if scene.get(component: EntitySceneChannelsComponent.self, for: entityId) != nil { + scene.remove(component: EntitySceneChannelsComponent.self, from: entityId) + } +} + // MARK: - Granular LOD Management Functions /// Set up LOD component for an entity diff --git a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift new file mode 100644 index 00000000..f617100e --- /dev/null +++ b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift @@ -0,0 +1,165 @@ +// +// SceneContextVisibility.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 + +public struct SceneChannel: OptionSet, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let contextGeometry = SceneChannel(rawValue: 1 << 0) + public static let selectableGeometry = SceneChannel(rawValue: 1 << 1) + public static let preserveIdentity = SceneChannel(rawValue: 1 << 2) +} + +private final class SceneChannelVisibilityState: @unchecked Sendable { + static let shared = SceneChannelVisibilityState() + + private let lock = NSLock() + private var hiddenChannels: SceneChannel = [] + + func setVisible(_ channel: SceneChannel, visible: Bool) { + lock.lock() + if visible { + hiddenChannels.remove(channel) + } else { + hiddenChannels.insert(channel) + } + lock.unlock() + } + + func isVisible(_ channels: SceneChannel) -> Bool { + lock.lock() + let visible = hiddenChannels.intersection(channels).isEmpty + lock.unlock() + return visible + } + + func reset() { + lock.lock() + hiddenChannels = [] + lock.unlock() + } +} + +public let selectableSceneEntityNamePrefix = "NM_" + +public func defaultSceneChannels(forName name: String, isRenderable: Bool = true) -> SceneChannel { + if name.hasPrefix(selectableSceneEntityNamePrefix) { + return [.selectableGeometry, .preserveIdentity] + } + + return isRenderable ? .contextGeometry : [] +} + +public func setEntitySceneChannels(entityId: EntityID, channels: SceneChannel) { + setEntitySceneChannels(entityId: entityId, channels: channels, usesDefaultChannels: false) +} + +func setDefaultEntitySceneChannels(entityId: EntityID, channels: SceneChannel) { + setEntitySceneChannels(entityId: entityId, channels: channels, usesDefaultChannels: true) +} + +private func setEntitySceneChannels(entityId: EntityID, channels: SceneChannel, usesDefaultChannels: Bool) { + if channels.isEmpty { + if scene.get(component: EntitySceneChannelsComponent.self, for: entityId) != nil { + scene.remove(component: EntitySceneChannelsComponent.self, from: entityId) + } + return + } + + if scene.get(component: EntitySceneChannelsComponent.self, for: entityId) == nil { + registerComponent(entityId: entityId, componentType: EntitySceneChannelsComponent.self) + } + + if let component = scene.get(component: EntitySceneChannelsComponent.self, for: entityId) { + component.channels = channels + component.usesDefaultChannels = usesDefaultChannels + } +} + +public func addEntitySceneChannels(entityId: EntityID, channels: SceneChannel) { + var current = getEntitySceneChannels(entityId: entityId) + current.insert(channels) + setEntitySceneChannels(entityId: entityId, channels: current) +} + +public func removeEntitySceneChannels(entityId: EntityID, channels: SceneChannel) { + guard let component = scene.get(component: EntitySceneChannelsComponent.self, for: entityId) else { return } + component.channels.remove(channels) + if component.channels.isEmpty { + scene.remove(component: EntitySceneChannelsComponent.self, from: entityId) + } +} + +public func getEntitySceneChannels(entityId: EntityID) -> SceneChannel { + if let component = scene.get(component: EntitySceneChannelsComponent.self, for: entityId) { + return component.channels + } + + return fallbackSceneChannels(entityId: entityId) +} + +public func hasEntitySceneChannel(entityId: EntityID, channel: SceneChannel) -> Bool { + getEntitySceneChannels(entityId: entityId).intersection(channel).isEmpty == false +} + +public func setSceneChannelVisible(_ channel: SceneChannel, _ visible: Bool) { + SceneChannelVisibilityState.shared.setVisible(channel, visible: visible) +} + +public func getSceneChannelVisible(_ channel: SceneChannel) -> Bool { + SceneChannelVisibilityState.shared.isVisible(channel) +} + +public func resetSceneChannelVisibility() { + SceneChannelVisibilityState.shared.reset() +} + +public func shouldHideSceneEntity(entityId: EntityID) -> Bool { + !SceneChannelVisibilityState.shared.isVisible(getEntitySceneChannels(entityId: entityId)) +} + +public func areSceneChannelsVisible(_ channels: SceneChannel) -> Bool { + SceneChannelVisibilityState.shared.isVisible(channels) +} + +public func shouldPreserveSceneEntityIdentity(entityId: EntityID) -> Bool { + hasEntitySceneChannel(entityId: entityId, channel: .preserveIdentity) +} + +public func isSelectableSceneEntity(entityId: EntityID) -> Bool { + hasEntitySceneChannel(entityId: entityId, channel: .selectableGeometry) +} + +public func isNonSelectableSceneContextEntity(entityId: EntityID) -> Bool { + hasEntitySceneChannel(entityId: entityId, channel: .contextGeometry) +} + +/// Compatibility path for entities created outside the normal registration flow. +/// Runtime/streamed entities should receive EntitySceneChannelsComponent eagerly; +/// remove this once all entity creation paths assign scene channels explicitly. +private func fallbackSceneChannels(entityId: EntityID) -> SceneChannel { + let entityName = getEntityName(entityId: entityId) + if entityName.hasPrefix(selectableSceneEntityNamePrefix) { + return defaultSceneChannels(forName: entityName) + } + + if scene.get(component: RenderComponent.self, for: entityId) != nil || + scene.get(component: StreamingComponent.self, for: entityId) != nil + { + return defaultSceneChannels(forName: entityName) + } + + return [] +} diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index 4395ac2a..837a253d 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -60,7 +60,7 @@ final class StaticBatchingTest: BaseRenderSetup { try await super.tearDown() } - private func createStaticCubeEntity(position: simd_float3, lodIndex: Int = 0) -> EntityID { + private func createStaticCubeEntity(position: simd_float3, lodIndex: Int = 0, markStatic: Bool = true) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { @@ -73,12 +73,15 @@ final class StaticBatchingTest: BaseRenderSetup { } _ = scene.assign(to: entity, component: WorldTransformComponent.self) + _ = scene.assign(to: entity, component: ScenegraphComponent.self) if let lodComponent = scene.assign(to: entity, component: LODComponent.self) { lodComponent.currentLOD = lodIndex } - setEntityStaticBatchComponent(entityId: entity) + if markStatic { + setEntityStaticBatchComponent(entityId: entity) + } return entity } @@ -211,6 +214,58 @@ final class StaticBatchingTest: BaseRenderSetup { print("✅ Created \(BatchingSystem.shared.batchGroups.count) batch group(s) from \(entities.count) entities") } + func testNMNamedStaticEntityIsExcludedFromBatching() { + let wallA = createStaticCubeEntity(position: simd_float3(0, 0, 0)) + let wallB = createStaticCubeEntity(position: simd_float3(2, 0, 0)) + let pipe = createStaticCubeEntity(position: simd_float3(4, 0, 0), markStatic: false) + + setEntityName(entityId: wallA, name: "Wall_A") + setEntityName(entityId: wallB, name: "Wall_B") + setEntityName(entityId: pipe, name: "NM_Pipe_001") + setEntityStaticBatchComponent(entityId: pipe) + + generateBatches() + + XCTAssertTrue(BatchingSystem.shared.isBatched(entityId: wallA), "Context entity should still be batchable") + XCTAssertTrue(BatchingSystem.shared.isBatched(entityId: wallB), "Context entity should still be batchable") + XCTAssertNil(scene.get(component: StaticBatchComponent.self, for: pipe), "Selectable NM_ entity should not be tagged for static batching") + XCTAssertFalse(BatchingSystem.shared.isBatched(entityId: pipe), "Selectable NM_ entity must remain individually renderable") + } + + func testStreamedTileHierarchyHidesOnlyNonSelectableRenderChildren() { + let tileRoot = createEntity() + registerTransformComponent(entityId: tileRoot) + registerSceneGraphComponent(entityId: tileRoot) + setEntityName(entityId: tileRoot, name: "F04_Q_1_2_SI") + + let wallA = createStaticCubeEntity(position: simd_float3(0, 0, 0), markStatic: false) + let wallB = createStaticCubeEntity(position: simd_float3(2, 0, 0), markStatic: false) + let selectablePipe = createStaticCubeEntity(position: simd_float3(4, 0, 0), markStatic: false) + + setEntityName(entityId: wallA, name: "Wall_A") + setEntityName(entityId: wallB, name: "Wall_B") + setEntityName(entityId: selectablePipe, name: "NM_Pipe_001") + setParent(childId: wallA, parentId: tileRoot) + setParent(childId: wallB, parentId: tileRoot) + setParent(childId: selectablePipe, parentId: tileRoot) + + setEntityStaticBatchComponent(entityId: tileRoot) + setSceneChannelVisible(.contextGeometry, false) + generateBatches() + + XCTAssertTrue(shouldHideSceneEntity(entityId: wallA)) + XCTAssertTrue(shouldHideSceneEntity(entityId: wallB)) + XCTAssertFalse(shouldHideSceneEntity(entityId: selectablePipe)) + XCTAssertTrue(isSelectableSceneEntity(entityId: selectablePipe)) + + XCTAssertNotNil(scene.get(component: StaticBatchComponent.self, for: wallA)) + XCTAssertNotNil(scene.get(component: StaticBatchComponent.self, for: wallB)) + XCTAssertNil(scene.get(component: StaticBatchComponent.self, for: selectablePipe)) + XCTAssertTrue(BatchingSystem.shared.isBatched(entityId: wallA)) + XCTAssertTrue(BatchingSystem.shared.isBatched(entityId: wallB)) + XCTAssertFalse(BatchingSystem.shared.isBatched(entityId: selectablePipe)) + } + func testBatchGroupBufferCreation() { // Given: Load a model and create batched entities let meshes = BasicPrimitives.createSphere() diff --git a/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift b/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift new file mode 100644 index 00000000..b7a8857f --- /dev/null +++ b/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift @@ -0,0 +1,110 @@ +// +// SceneContextVisibilityTests.swift +// UntoldEngineTests +// +// 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/. + +@testable import UntoldEngine +import XCTest + +@MainActor +final class SceneContextVisibilityTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + } + + func testContextSceneChannelVisibilityDefaultsToVisible() { + XCTAssertTrue(getSceneChannelVisible(.contextGeometry)) + } + + func testContextSceneChannelVisibilityCanToggle() { + setSceneChannelVisible(.contextGeometry, false) + XCTAssertFalse(getSceneChannelVisible(.contextGeometry)) + + setSceneChannelVisible(.contextGeometry, true) + XCTAssertTrue(getSceneChannelVisible(.contextGeometry)) + } + + func testNMNamedEntityIsSelectableSceneEntity() { + let entityId = createEntity() + setEntityName(entityId: entityId, name: "NM_Pipe_001") + + XCTAssertTrue(isSelectableSceneEntity(entityId: entityId)) + XCTAssertTrue(hasEntitySceneChannel(entityId: entityId, channel: .selectableGeometry)) + XCTAssertTrue(shouldPreserveSceneEntityIdentity(entityId: entityId)) + XCTAssertFalse(isNonSelectableSceneContextEntity(entityId: entityId)) + XCTAssertFalse(shouldHideSceneEntity(entityId: entityId)) + } + + func testNonNMEntityIsHiddenWhenNonSelectableSceneIsHidden() { + let entityId = createEntity() + setEntityName(entityId: entityId, name: "Wall_North") + _ = scene.assign(to: entityId, component: RenderComponent.self) + + setSceneChannelVisible(.contextGeometry, false) + + XCTAssertFalse(isSelectableSceneEntity(entityId: entityId)) + XCTAssertTrue(isNonSelectableSceneContextEntity(entityId: entityId)) + XCTAssertTrue(shouldHideSceneEntity(entityId: entityId)) + } + + func testExplicitSceneChannelsOverrideNameFallback() { + let entityId = createEntity() + setEntityName(entityId: entityId, name: "Wall_North") + setEntitySceneChannels(entityId: entityId, channels: [.selectableGeometry, .preserveIdentity]) + + setSceneChannelVisible(.contextGeometry, false) + + XCTAssertTrue(isSelectableSceneEntity(entityId: entityId)) + XCTAssertTrue(shouldPreserveSceneEntityIdentity(entityId: entityId)) + XCTAssertFalse(isNonSelectableSceneContextEntity(entityId: entityId)) + XCTAssertFalse(shouldHideSceneEntity(entityId: entityId)) + } + + func testSceneChannelVisibilityCanHideExplicitChannel() { + let entityId = createEntity() + setEntitySceneChannels(entityId: entityId, channels: .contextGeometry) + + setSceneChannelVisible(.contextGeometry, false) + + XCTAssertTrue(hasEntitySceneChannel(entityId: entityId, channel: .contextGeometry)) + XCTAssertTrue(shouldHideSceneEntity(entityId: entityId)) + } + + func testRemovingLastSceneChannelRemovesComponent() { + let entityId = createEntity() + setEntitySceneChannels(entityId: entityId, channels: .contextGeometry) + + removeEntitySceneChannels(entityId: entityId, channels: .contextGeometry) + + XCTAssertNil(scene.get(component: EntitySceneChannelsComponent.self, for: entityId)) + } + + func testDefaultSceneChannelsRefreshWhenEntityNameChanges() { + let entityId = createEntity() + _ = scene.assign(to: entityId, component: RenderComponent.self) + setDefaultEntitySceneChannels(entityId: entityId, channels: defaultSceneChannels(forName: "Wall_North")) + + setEntityName(entityId: entityId, name: "NM_Pipe_001") + + XCTAssertTrue(isSelectableSceneEntity(entityId: entityId)) + XCTAssertTrue(shouldPreserveSceneEntityIdentity(entityId: entityId)) + XCTAssertFalse(isNonSelectableSceneContextEntity(entityId: entityId)) + } + + func testExplicitSceneChannelsDoNotRefreshWhenEntityNameChanges() { + let entityId = createEntity() + _ = scene.assign(to: entityId, component: RenderComponent.self) + setEntitySceneChannels(entityId: entityId, channels: .contextGeometry) + + setEntityName(entityId: entityId, name: "NM_Pipe_001") + + XCTAssertFalse(isSelectableSceneEntity(entityId: entityId)) + XCTAssertFalse(shouldPreserveSceneEntityIdentity(entityId: entityId)) + XCTAssertTrue(isNonSelectableSceneContextEntity(entityId: entityId)) + } +} diff --git a/Tests/UntoldEngineTests/TestEngineReset.swift b/Tests/UntoldEngineTests/TestEngineReset.swift index 564f3f90..5e76980a 100644 --- a/Tests/UntoldEngineTests/TestEngineReset.swift +++ b/Tests/UntoldEngineTests/TestEngineReset.swift @@ -33,5 +33,6 @@ scenePickingGPUAvailable = false activeEntity = .invalid OctreeSystem.shared.clear() + resetSceneChannelVisibility() setSceneReady(true) } diff --git a/docs/API/UsingSceneChannels.md b/docs/API/UsingSceneChannels.md new file mode 100644 index 00000000..ad9f63b1 --- /dev/null +++ b/docs/API/UsingSceneChannels.md @@ -0,0 +1,89 @@ +# Scene Channels + +Scene channels let you group entities with a bitmask and control behavior for the group. They are useful for large spatial scenes where the app needs to treat background/context geometry differently from interactive objects. + +The current built-in channels are: + +| Channel | Purpose | +|---|---| +| `.contextGeometry` | Non-selectable scene context such as walls, floors, ceilings, terrain, or merged background geometry | +| `.selectableGeometry` | Entities that should remain visible and selectable as individual objects | +| `.preserveIdentity` | Entities that should not be merged into static batches because their identity matters at runtime | + +## Default Behavior + +For exported tiled scenes, the engine assigns channels automatically: + +| Entity name | Default channels | +|---|---| +| `NM_Pipe_001` | `.selectableGeometry`, `.preserveIdentity` | +| `NM_LightFixture_A` | `.selectableGeometry`, `.preserveIdentity` | +| `Wall_North` | `.contextGeometry` | +| merged tile geometry | `.contextGeometry` | + +This preserves the existing `NM_` workflow. Objects whose names start with `NM_` remain selectable by default; you do not need to call a channel visibility function to make them selectable. + +## Hiding Context Geometry + +To hide non-selectable context geometry while keeping selectable objects visible: + +```swift +setSceneChannelVisible(.contextGeometry, false) +``` + +To show it again: + +```swift +setSceneChannelVisible(.contextGeometry, true) +``` + +This is a visibility feature, not transparency. Hidden entities are skipped by the renderer instead of being rendered with opacity `0.0`. + +## Explicit Entity Channels + +Apps can override the default mapping for any entity: + +```swift +setEntitySceneChannels( + entityId: pipe, + channels: [.selectableGeometry, .preserveIdentity] +) +``` + +You can add or remove channels incrementally: + +```swift +addEntitySceneChannels(entityId: entity, channels: .selectableGeometry) +removeEntitySceneChannels(entityId: entity, channels: .contextGeometry) +``` + +Query helpers: + +```swift +let channels = getEntitySceneChannels(entityId: entity) +let selectable = isSelectableSceneEntity(entityId: entity) +let context = isNonSelectableSceneContextEntity(entityId: entity) +let preserveIdentity = shouldPreserveSceneEntityIdentity(entityId: entity) +``` + +## Recommended Construction Workflow + +For large construction-site or architectural scenes: + +1. Leave walls, floors, ceilings, and other background objects without the `NM_` prefix so they can be merged and assigned `.contextGeometry`. +2. Prefix interactive objects with `NM_` in Blender so they export as separate entities and receive `.selectableGeometry` plus `.preserveIdentity`. +3. At runtime, hide context geometry when the user needs a clear real-world view: + +```swift +setSceneChannelVisible(.contextGeometry, false) +``` + +Selectable objects remain visible and pickable. + +## Notes + +- Scene channels are an engine-level grouping mechanism. The `NM_` prefix is only the current exporter convention for assigning initial channels. +- `.preserveIdentity` keeps an entity out of static batching so tap-to-select and per-object metadata lookups can still target the original entity. +- Channel visibility works for both individual render entities and batch groups. Batch groups are separated by channel mask, so hiding a channel does not require rebuilding batches. + +For exporter details, see [Using The Exporter](UsingTheExporter.md#selective-merging-with-the-nm_-prefix). For implementation details, see [Scene Channels Architecture](../Architecture/sceneChannels.md). diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index e8509d4f..a768e3fb 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -148,5 +148,6 @@ clearSceneBatches() - The batching system is now cell-based and visibility-gated. - Tile streaming and batching are tightly integrated; residency events are no longer the old per-entity event storm for full-load tiles. - `TileLODTagComponent` lets batching treat per-tile LODs and HLODs as distinct LOD groups even though they are not entity-level `LODComponent` assets. +- Scene channels separate context geometry from selectable geometry. Entities marked `.preserveIdentity` are excluded from batching, and batch groups are separated by channel mask so channel visibility can be toggled without rebuilding batches. For architectural details, see [Batching System](../Architecture/batchingSystem). diff --git a/docs/API/UsingTheExporter.md b/docs/API/UsingTheExporter.md index d2b93d66..c8e2dafd 100644 --- a/docs/API/UsingTheExporter.md +++ b/docs/API/UsingTheExporter.md @@ -154,6 +154,8 @@ This lets you keep background geometry (walls, floors, ceilings) optimized while To change the prefix or disable selective merging, edit `NO_MERGE_PREFIX` at the top of `scripts/tilestreamingpartition.py`. Set it to `""` to merge all objects regardless of name. +At runtime, `NM_` objects default to `.selectableGeometry` and `.preserveIdentity` scene channels. Regular render/streaming geometry defaults to `.contextGeometry`. This lets an app hide context geometry with `setSceneChannelVisible(.contextGeometry, false)` while keeping `NM_` objects visible and selectable. See [Scene Channels](UsingSceneChannels.md). + ## Optimization Workflows After exporting assets, use [Optimizations](Optimizations.md) for optional diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index 85a079a6..45bce213 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -22,7 +22,7 @@ cellId(x, y, z) = floor(worldCenter / cellSize) When your 100 entities load, each one that has a `StaticBatchComponent` gets registered: -- **Eligibility check** (`resolveBatchCandidate`): the entity must have a `RenderComponent`, `WorldTransformComponent`, no skeleton/animation, no transparency, no gizmo/light component, and its mesh must already be resident in memory. The LOD index is derived from `LODComponent.currentLOD` (entity-level LOD), then `TileLODTagComponent.levelIndex` (per-tile LOD/HLOD children), defaulting to 0. `isLODBatch` on the resulting `BatchGroup` is true if any member entity has either component. +- **Eligibility check** (`resolveBatchCandidate`): the entity must have a `RenderComponent`, `WorldTransformComponent`, no skeleton/animation, no transparency, no gizmo/light component, no `.preserveIdentity` scene channel, and its mesh must already be resident in memory. The LOD index is derived from `LODComponent.currentLOD` (entity-level LOD), then `TileLODTagComponent.levelIndex` (per-tile LOD/HLOD children), defaulting to 0. `isLODBatch` on the resulting `BatchGroup` is true if any member entity has either component. - If eligible → it gets assigned to a cell and added to `cellToEntities[cellId]`. - The cell is marked **dirty** and its state becomes `renderableUnbatched`. @@ -69,7 +69,7 @@ This is the core build loop: 4. **Apply per-tick budgets**: up to 8 cells, 120K verts, 220K indices, 6MB total per tick. Once budgets are exhausted, remaining cells defer to next frame. -5. **Snapshot build inputs** under a world mutation gate: for each selected cell, group its entities' meshes by `BatchBuildKey = (cellId, materialHash, lodIndex)`. This produces `CellBuildInput`. +5. **Snapshot build inputs** under a world mutation gate: for each selected cell, group its entities' meshes by `BatchBuildKey = (cellId, materialHash, lodIndex, sceneChannelsRawValue)`. This produces `CellBuildInput`. 6. **Dispatch background builds** on `artifactBuildQueue` (a `.utility` DispatchQueue). The heavy work — actually merging vertex data — happens off the main thread. @@ -115,7 +115,7 @@ Back on the main thread (next frame or same frame if sync mode): The renderer uses **cluster-level frustum culling** to determine which batch groups to submit. Each `BatchGroup` carries a precomputed world-space AABB (`boundingBox`) covering all geometry in the group. The render passes test each group's AABB directly against the current-frame frustum — **one AABB test per batch group, not one per entity**. Groups whose AABB is fully outside the frustum are skipped without any entity-level traversal. -For batch groups that survive the AABB test, each `BatchGroup` is one draw call with its merged buffer. 100 entities sharing one material = **1 draw call**, submitted only when the group's spatial bounds are within the frustum. +For batch groups that survive the AABB test and scene-channel visibility filter, each `BatchGroup` is one draw call with its merged buffer. 100 entities sharing one material and channel mask = **1 draw call**, submitted only when the group's spatial bounds are within the frustum and its channels are visible. Per-entity batching membership (`entityToBatch`) is still maintained and used by non-batched rendering paths and by tests. diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index 269d317e..c1d22b30 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -147,7 +147,9 @@ This is the core of the deferred rendering pipeline. Entities do not produce a s - Uploads the model matrix, normal matrix, and camera uniforms into the current in-flight frame slot - Issues a draw call per mesh submesh -`batchedModelExecution` uses **cluster-level frustum culling**: it calls `visibleBatchGroupsSnapshot()` which tests each `BatchGroup`'s precomputed world-space AABB against `currentFrameFrustum` using `isAABBInFrustum`. The result — groups whose AABB intersects the frustum — is cached for the frame and shared with `batchedShadowExecution`. Each surviving group is then submitted as a single draw call with its merged vertex and index buffers. +Before encoding each draw, the renderer checks scene-channel visibility. Individual entities use `shouldHideSceneEntity(entityId:)`; batch groups use their stored channel mask. Hidden channels are skipped entirely rather than rendered transparently. + +`batchedModelExecution` uses **cluster-level frustum culling**: it calls `visibleBatchGroupsSnapshot()` which tests each `BatchGroup`'s precomputed world-space AABB against `currentFrameFrustum` using `isAABBInFrustum`, then filters by scene-channel visibility. The result — groups whose AABB intersects the frustum and whose channels are visible — is cached for the frame and shared with `batchedShadowExecution`. Each surviving group is then submitted as a single draw call with its merged vertex and index buffers. `ssaoOptimizedExecution` reads the G-Buffer normals and depth and produces a screen-space ambient occlusion texture. Blurring is handled internally — no separate blur nodes appear in the graph. diff --git a/docs/Architecture/sceneChannels.md b/docs/Architecture/sceneChannels.md new file mode 100644 index 00000000..1d26bf82 --- /dev/null +++ b/docs/Architecture/sceneChannels.md @@ -0,0 +1,62 @@ +# Scene Channels + +Scene channels are a generic bitmask used to describe how runtime entities participate in scene-level behavior. They avoid hardcoding engine features around app-specific object names while still supporting exporter conventions such as the `NM_` prefix. + +## Data Model + +`SceneChannel` is an `OptionSet` backed by `UInt64`. Each entity can have an `EntitySceneChannelsComponent` containing one or more channels. + +Current built-in channels: + +| Channel | Meaning | +|---|---| +| `.contextGeometry` | Background scene geometry that can be hidden as a group | +| `.selectableGeometry` | Runtime entities intended for picking/interaction | +| `.preserveIdentity` | Entities that should remain separate and should not be static-batched | + +The bitmask design allows future channels to be added without changing the storage model. + +## Default Assignment + +Runtime and streamed entities receive `EntitySceneChannelsComponent` during registration. The default mapping is: + +| Source | Channels | +|---|---| +| entity name starts with `NM_` | `.selectableGeometry`, `.preserveIdentity` | +| renderable/streamed entity without `NM_` | `.contextGeometry` | + +Explicit calls to `setEntitySceneChannels(entityId:channels:)` override the default mapping. The component tracks whether its value came from engine defaults so later name updates can refresh default channels without overwriting app-defined channels. + +`fallbackSceneChannels` exists only as a temporary compatibility path for entities created outside the normal registration flow. It should be removed once all entity creation paths assign scene channels explicitly. + +## Rendering + +Channel visibility is controlled globally: + +```swift +setSceneChannelVisible(.contextGeometry, false) +``` + +The render passes call `shouldHideSceneEntity(entityId:)` for individual entities. Hidden entities are skipped before draw encoding. This is different from opacity: no transparent draw is submitted, so the feature avoids transparency sorting issues. + +Batched rendering filters `BatchGroup`s through `areSceneChannelsVisible(_:)`. If a group's channel mask intersects a hidden channel, the whole group is skipped. + +## Batching + +Scene channels affect batching in two ways: + +1. Entities with `.preserveIdentity` are excluded as batch candidates. +2. `BatchBuildKey` includes the channel mask, so entities in different channels do not merge into the same batch group. + +This lets the renderer hide `.contextGeometry` batches without rebuilding batch artifacts and without affecting `.selectableGeometry` entities. + +## Exporter Compatibility + +The engine does not require object names to implement channels. However, the current Blender/exporter workflow uses `NM_` as a compatibility convention: + +- `NM_*` objects are exported individually. +- Their names survive into the `.untold` file. +- At runtime they default to `.selectableGeometry` and `.preserveIdentity`. +- Regular merged geometry defaults to `.contextGeometry`. + +This keeps the existing selectable-object workflow intact while moving the engine feature itself toward generic channels. From cbade44365de20c7f0cd4a6bab2c7a1019a30d19 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 26 May 2026 08:53:45 -0700 Subject: [PATCH 2/4] [Docs] Updated documentation --- docs/API/UsingGeometryStreamingSystem.md | 17 ++++++++++- docs/Architecture/batchingSystem.md | 4 +-- docs/Architecture/geometryStreamingSystem.md | 22 +++++++++----- docs/Architecture/meshResourceManager.md | 7 +++-- docs/Architecture/streamingCacheLifecycle.md | 2 ++ docs/Architecture/textureStreamingSystem.md | 17 ++++++++--- docs/Architecture/tilebasedstreaming.md | 32 ++++++++++++-------- 7 files changed, 70 insertions(+), 31 deletions(-) diff --git a/docs/API/UsingGeometryStreamingSystem.md b/docs/API/UsingGeometryStreamingSystem.md index 728c8506..17f7816f 100644 --- a/docs/API/UsingGeometryStreamingSystem.md +++ b/docs/API/UsingGeometryStreamingSystem.md @@ -76,7 +76,7 @@ If `prefetch_radius` is omitted, the engine computes it automatically from the g Each update tick, `GeometryStreamingSystem`: 1. Queries the octree within `maxQueryRadius`. -2. Chooses tile parse candidates using predictive camera motion (velocity look-ahead), a frustum gate, and an optional interior zone gate. +2. Chooses tile parse candidates using predictive camera motion, frustum gating, floor/interior gates, screen-space importance, and loaded-tile occlusion weighting. 3. Parses up to `maxConcurrentTileLoads` tiles, subject to `tileParseMemoryBudgetMB`. 4. Streams OCC child meshes inside loaded tiles using `maxConcurrentLoads`. 5. Unloads tiles, LODs, HLODs, and OCC meshes when they leave range or memory pressure requires eviction. @@ -89,6 +89,8 @@ Important defaults: - `maxConcurrentHLODLoads = 4` - `updateInterval = 0.1` - `burstTickInterval = 0.016` +- `floorProximityGateY = 5.0` +- `minimumParsedTileResidentSeconds = 8.0` ## Useful Runtime Knobs @@ -119,8 +121,20 @@ GeometryStreamingSystem.shared.interiorZone = AABB( 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 + +// Tile candidate ordering +GeometryStreamingSystem.shared.enableImportanceSort = true +GeometryStreamingSystem.shared.enableOcclusionSort = true + +// Tile unload stability +GeometryStreamingSystem.shared.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 ``` Use `maxQueryRadius` large enough to cover the farthest `unload_radius` in the scene, or out-of-range tiles may not be discovered for teardown. @@ -184,6 +198,7 @@ The rule of thumb: **call it whenever you know a new tile-streaming session is a - **Texture streaming**: `setEntityStreamScene(...)` automatically aligns texture distance bands to the manifest radii. - **Batching**: full-load tiles, per-tile LODs, and HLODs notify `BatchingSystem` automatically. OCC sub-mesh uploads join batching incrementally through normal residency events. - **Memory pressure**: texture quality is shed first; geometry eviction follows only when geometry pressure remains high. +- **Tile geometry eviction**: if OCC eviction cannot clear geometry pressure, the system can evict full-load tiles, HLODs, and per-tile LODs through `evictTileGeometry`, while protecting tiles inside their own `streamingRadius` and respecting the parsed-tile minimum dwell. ## Common Problems diff --git a/docs/Architecture/batchingSystem.md b/docs/Architecture/batchingSystem.md index 45bce213..b444963d 100644 --- a/docs/Architecture/batchingSystem.md +++ b/docs/Architecture/batchingSystem.md @@ -6,7 +6,7 @@ The goal is simple: instead of issuing 100 separate draw calls (one per entity), ## Step 0: The World is Divided into Cells -The 3D world is partitioned into a 3D grid of **cells**. The cell size is calibrated at scene load time: when a tile manifest is present, the cell size is set to `2 × tileSize` so that cell boundaries align with tile boundaries. When no manifest is present, the default of 32 world units is used. +The 3D world is partitioned into a 3D grid of **cells**. The cell size is calibrated at scene load time: when a tile manifest is present, the cell size is set to `1 × tileSize` so that cell boundaries align with tile boundaries without producing oversized batch cells. When no manifest is present, the default of 32 world units is used. Every entity is assigned to a cell based on the world-space center of its bounding box: @@ -37,7 +37,7 @@ Any entities that changed (LOD switch, mesh evicted/streamed in) are removed fro **Tile-loaded entities bypass the quiescence delay.** When a fullLoad tile (or LOD/HLOD load) finishes, `GeometryStreamingSystem` calls `BatchingSystem.notifyTileEntitiesResident(_:)` with the set of render-ready entity IDs. This single call directly registers the entities in `pendingEntityAdditions`, marks them as tile-parsed (for quiescence bypass), and resolves their cell membership — replacing the former two-step `queueResidencyEventsForRenderDescendants` + `notifyTileParsedEntities` pairing and avoiding the per-entity event storm through `SystemEventBus`. Their cells are immediately promoted to `batchPending` in the same tick. See [Tile-Local Batch Promotion](#tile-local-batch-promotion) below. -**Stale entity purge on LOD/HLOD teardown.** When `unloadLODLevel` or `unloadHLOD` destroys child entities, it first calls `BatchingSystem.cancelPendingEntities(_:)` with the render descendant IDs. This removes them from `pendingEntityAdditions`, `pendingEntityRemovals`, `newlyResidentEntities`, and `tileParsedEntityIds` before the entities are destroyed — preventing "entity is missing" errors on the next `tick()` and avoiding wasted batch rebuilds for entities that no longer exist. +**Stale entity purge on tile/LOD/HLOD teardown.** When `unloadTile`, `unloadLODLevel`, or `unloadHLOD` destroys child entities, it first calls `BatchingSystem.notifyTileEntitiesUnloading(_:)` with the render descendant IDs. This removes pending additions/removals and committed cell membership before the entities are destroyed, preventing recycled entity IDs from remaining attached to stale batch state. ### 2b. Update Visibility History The system checks which cells currently contain visible entities and records `cellLastVisibleFrame[cellId]`. This drives **visibility gating** — the system won't waste CPU rebuilding cells you can't see. diff --git a/docs/Architecture/geometryStreamingSystem.md b/docs/Architecture/geometryStreamingSystem.md index 24571c7b..41ed129f 100644 --- a/docs/Architecture/geometryStreamingSystem.md +++ b/docs/Architecture/geometryStreamingSystem.md @@ -29,7 +29,7 @@ Instead of checking every OCC child stub in the scene, it asks the `OctreeSystem This is the key performance trick — only nearby entities are evaluated. -### 3. Classify Each Nearby Entity (lines 129–157) +### 3. Classify Each Nearby Entity For each entity the octree returns, the system calculates the **distance from camera to the entity's bounding box center**, then: @@ -40,12 +40,12 @@ For each entity the octree returns, the system calculates the **distance from ca | `.loaded` | still in range | → stamp `lastVisibleFrame` (keep alive) | | `.loading` / `.unloading` | — | skip, already in progress | -### 4. Out-of-Range Loaded Entities (lines 164–183) +### 4. Out-of-Range Loaded Entities The octree query only covers nearby space. But what if `building_Z` was loaded and the player sprinted far away — it might not be in the octree result anymore. So the system also checks its `loadedStreamingEntities` tracking set for any loaded entity **not** in the octree result, and adds those to unload candidates if they're too far. --- -## Unloading First: Free Memory Before Loading New Things (lines 191–197) +## Unloading First: Free Memory Before Loading New Things Unload candidates are **sorted farthest-first** (most wasteful memory first). Up to `maxUnloadsPerUpdate = 12` are processed per tick to avoid frame spikes. @@ -119,7 +119,7 @@ isTileOwned(entityId:) — private helper in GeometryStreamingSystem+MeshStreami --- -## LOD Path: `reloadLODEntity()` (lines 313–415) +## LOD Path: `reloadLODEntity()` For LOD entities (e.g., a skyscraper with 3 detail levels), it: 1. Loads **all LOD levels** concurrently from cache/disk @@ -171,7 +171,9 @@ if geometry pressure is high: 5. Skips entities that are both visible and within `visibleEvictionProtectionRadius` (30 m default) 6. Accepts an optional `maxEvictions` cap (default `Int.max`). The OS pressure path passes `16` per call — this bounds single-frame work during a burst. Any remaining candidates spill to subsequent ticks. -> **`evictLRU` does not reach full-load tile geometry.** `loadedStreamingEntities` tracks OCC mesh stubs (`StreamingComponent` entities). Full-load tiles use the `fullLoad` path of `setEntityMeshAsync`, which creates `RenderComponent` entities that are **not** in `loadedStreamingEntities`. `evictLRU` therefore cannot free their GPU buffers. The tile unload pass (via `unloadTile()`) is the only mechanism that frees full-load tile geometry, but it has a 3-second grace period and a 2-per-tick cap. For explicit session transitions, call `GeometryStreamingSystem.shared.forceUnloadAllParsedTiles()` instead — see [`tilebasedstreaming.md`](tilebasedstreaming.md#forceunloadallparsedtiles----explicit-session-transition). +If `evictLRU` cannot clear geometry pressure, the system runs `evictTileGeometry(...)` as a second-stage pass. This reaches representations that do not live in `loadedStreamingEntities`: full-load tiles, HLODs, and per-tile LODs. It evicts farthest first, protects tiles inside their own `streamingRadius`, and does not evict parsed full tiles until `minimumParsedTileResidentSeconds` has elapsed. + +For explicit session transitions, call `GeometryStreamingSystem.shared.forceUnloadAllParsedTiles()` instead — see [`tilebasedstreaming.md`](tilebasedstreaming.md#forceunloadallparsedtiles----explicit-session-transition). The `sizeFactor` in the eviction score is normalized against `geometryBudget` (not the combined budget), so a mesh consuming 80% of the geometry pool scores correctly rather than appearing to consume only ~48% of a combined total. @@ -240,8 +242,8 @@ The tile-level streaming layer sits **above** the mesh-level OOC streaming. For ### Per-frame passes in `update()` (tile layer, in order) -1. **Tile load pass** — dispatches `.unloaded` stubs within `effectivePrefetchRadius` (frustum-gated, budget-gated, up to `maxConcurrentTileLoads`). Tiles with LOD levels are gated: the full tile only loads when the camera is within the finest LOD switch distance. -2. **Tile unload pass** — three sub-passes (nearby beyond `unloadRadius`, `.parsed` outside query radius, `.parsing` outside query radius). +1. **Tile load pass** — dispatches `.unloaded` stubs within `effectivePrefetchRadius` (frustum-gated, floor/interior-gated, budget-gated, up to `maxConcurrentTileLoads`). Tiles with LOD levels are gated: the full tile only loads when the camera is within the finest LOD switch distance. Within each priority tier, candidates are sorted by screen-space importance and optional loaded-tile occlusion score before falling back to distance. +2. **Tile unload pass** — three sub-passes (nearby beyond `unloadRadius`, `.parsed` outside query radius, `.parsing` outside query radius). Nearby parsed/parsing tiles use the unload grace period; parsed full tiles also honor `minimumParsedTileResidentSeconds`. 3. **HLOD streaming pass** — for each tile stub, loads or unloads the HLOD proxy based on camera distance vs `hlodSwitchDistance` and `tileComp.state`. Uses `hlodHysteresisFactor` (default 0.90) to prevent thrashing at boundaries. Capped by `maxConcurrentHLODLoads` (default 4). 4. **HLOD out-of-range cleanup** — unloads HLOD entities for tiles that drifted entirely outside `maxQueryRadius`. 5. **Per-tile LOD streaming pass** — for each tile stub, finds the target LOD level for the current distance with hysteresis (`lodHysteresisFactor`, default 0.90). Skips when HLOD is resident (avoids dual representation). Loads target; unloads others; unloads all when tile is `.parsed`. Capped by `maxConcurrentLODLoads` (default 4). @@ -262,6 +264,10 @@ Tile stubs carry a `TileComponent` (no `StreamingComponent`, no `RenderComponent | `tileId` | Debug identifier matching the manifest `tile_id` | | `state` | `.unloaded → .parsing → .parsed → .unloading` | | `pendingUnloadSince` | CFAbsoluteTime when tile first exceeded `unloadRadius`; 0 = in range | +| `parsedResidentSince` | CFAbsoluteTime when the tile became `.parsed`; used by the minimum parsed-tile dwell guard | +| `totalOCCStubs` / `uploadedOCCStubs` | Visual readiness counters for out-of-core child uploads | +| `visualState` | `.empty`, `.partial`, `.usable`, or `.complete` based on OCC upload progress | +| `failureCount` / `lastFailureTime` / `retryDelaySeconds` | Exponential-backoff retry state after parse failures | | `loadTask` | The in-flight Swift `Task` (cancelled on teardown) | | `meshEntityId` | The dedicated mesh-child entity ID; stored so the parse-timeout watchdog can force-close `AssetLoadingGate` if the parse Task becomes stuck | | `hlodURL` | URL of the HLOD proxy `.untold` asset, if present in the manifest | @@ -303,7 +309,7 @@ Three sub-passes each tick, capped at `maxTileUnloadsPerUpdate` (default **2**) - **`maxConcurrentTileLoads = 2`** — two concurrent parses balance throughput for large scenes against RAM spike risk. Each parse runs `UntoldReader` on a full `.untold` file; the `tileParseMemoryBudgetMB` gate serialises naturally when a large tile saturates the budget. - **`blockRenderLoop: false` on all tile/LOD/HLOD loads** — `setEntityMeshAsync` is called with `blockRenderLoop: false` so that `AssetLoadingGate.isLoadingAny` is not held `true` during the (potentially multi-second) parse. Without this, concurrent parses freeze `visibleEntityIds` updates and stall the render loop. - **Hysteresis on LOD/HLOD transitions** — `lodHysteresisFactor` (default 0.90) and `hlodHysteresisFactor` (default 0.90) add a 10% inner band so the camera must move meaningfully past a switch boundary before the current representation is unloaded. Without hysteresis, frame-to-frame distance jitter causes rapid load/unload cycles that freeze the engine. -- **`cancelPendingEntities` before entity destruction** — when `unloadLODLevel` or `unloadHLOD` tears down child entities, it first calls `BatchingSystem.shared.cancelPendingEntities(_:)` with the render descendant IDs, purging them from all pending batching queues. This prevents "entity is missing" errors when the batching tick tries to process additions for entities that were destroyed between event queuing and tick processing. +- **`notifyTileEntitiesUnloading` before entity destruction** — when `unloadTile`, `unloadLODLevel`, or `unloadHLOD` tears down child entities, it first calls `BatchingSystem.shared.notifyTileEntitiesUnloading(_:)` with the render descendant IDs, purging pending additions and committed cell membership. This prevents destroyed or recycled entity IDs from staying in batching state. - **`notifyTileEntitiesResident` replaces the event storm** — tile/LOD/HLOD load completions call `BatchingSystem.shared.notifyTileEntitiesResident(_:)` instead of the former two-step `queueResidencyEventsForRenderDescendants` + `notifyTileParsedEntities` pairing. The single call directly registers entities in the batching system's pending additions and marks them for quiescence bypass, avoiding hundreds of individual `AssetResidencyChangedEvent` objects through `SystemEventBus`. - **Identity world transform on stubs** — tile geometry is exported in world space; no runtime coordinate conversion needed. - **`.auto` streaming policy** — tile loads use native `.untold` admission to choose immediate full-tile upload or OCC child-stub registration. diff --git a/docs/Architecture/meshResourceManager.md b/docs/Architecture/meshResourceManager.md index 546791e2..b99f2d48 100644 --- a/docs/Architecture/meshResourceManager.md +++ b/docs/Architecture/meshResourceManager.md @@ -110,6 +110,7 @@ Eviction calls `mesh.cleanUp()` on cached meshes to release Metal resources. | Tile-owned OCC stubs | `ProgressiveAssetLoader.CPURuntimeEntry` uploaded on demand | Full-load tile geometry is not tracked in `loadedStreamingEntities`, so ordinary -OCC LRU eviction does not free it. Tile unload or -`GeometryStreamingSystem.forceUnloadAllParsedTiles()` is responsible for -tearing down full-load tile geometry. +OCC LRU eviction does not free it. The streaming system now follows OCC eviction +with `evictTileGeometry(...)` when geometry pressure remains high; normal tile +unload and `GeometryStreamingSystem.forceUnloadAllParsedTiles()` are the other +paths that tear down full-load tile geometry. diff --git a/docs/Architecture/streamingCacheLifecycle.md b/docs/Architecture/streamingCacheLifecycle.md index cfcb08f0..ba4fa51b 100644 --- a/docs/Architecture/streamingCacheLifecycle.md +++ b/docs/Architecture/streamingCacheLifecycle.md @@ -70,6 +70,7 @@ When geometry leaves range or memory pressure rises: - `unloadMesh(...)` clears the entity's live mesh reference - `MeshResourceManager.release(entityId:)` decrements shared cache refs when applicable - OCC stubs keep their CPU source warm until tile/root teardown +- if OCC eviction cannot clear geometry pressure, `evictTileGeometry(...)` can unload full-load tile geometry, HLODs, and per-tile LODs outside their protected streaming band ### 6. Cache cleanup @@ -92,3 +93,4 @@ When reviewing a streamed tile today, think of residency in this order: 3. If OOC, does the child stub have a `CPURuntimeEntry`? 4. Is the GPU copy currently resident? 5. Is batching representing the entity directly or via a cell artifact? +6. If geometry pressure is high, is the candidate an OCC stub handled by `evictLRU`, or tile-owned full/LOD/HLOD geometry handled by `evictTileGeometry`? diff --git a/docs/Architecture/textureStreamingSystem.md b/docs/Architecture/textureStreamingSystem.md index da577867..aba6ee7d 100644 --- a/docs/Architecture/textureStreamingSystem.md +++ b/docs/Architecture/textureStreamingSystem.md @@ -309,6 +309,15 @@ TextureStreamingSystem.shared.apply(.detailed) TextureStreamingSystem.shared.upgradeRadius = 3.0 // widen full-res zone ``` +For tiled scenes, `setEntityStreamScene(...)` calls `TextureStreamingSystem.shared.alignToManifest(streamingRadius:unloadRadius:)` automatically after decoding the manifest. That applies the `.tiled` profile and derives: + +```swift +upgradeRadius = max(streamingRadius * 0.70, 2.5) +downgradeRadius = max(unloadRadius, upgradeRadius + 1.0, upgradeRadius * 2.0) +``` + +The floors keep small scenes usable, while large scenes still align texture tiers to their authored tile streaming bands. + | Profile | `upgradeRadius` | `downgradeRadius` | `minDim` | `maxConcurrentOps` | Best for | |---|---|---|---|---|---| | `.detailed` | 2.5 m | 6.0 m | 512 px | 6 | Vehicles, products, characters, props, interiors | @@ -345,13 +354,13 @@ TextureStreamingSystem.shared.alignToManifest( This applies the `.tiled` profile (concurrency, dimensions) then derives the texture tier radii from the manifest geometry streaming bands: ``` -upgradeRadius = streamingRadius × 0.70 -downgradeRadius = max(unloadRadius, upgradeRadius + 1.0) +upgradeRadius = max(streamingRadius × 0.70, 2.5) +downgradeRadius = max(unloadRadius, upgradeRadius + 1.0, upgradeRadius × 2.0) ``` -**Why 0.70 × streamingRadius?** The streaming radius is the distance at which tile geometry *loads*. Upgrading to full-res at 70% of that radius means the camera has already moved well inside the loaded zone before the texture upgrade fires — reducing the chance of a visible resolution pop the moment a tile appears. +**Why max(streamingRadius × 0.70, 2.5)?** The streaming radius is the distance at which tile geometry *loads*. Upgrading to full-res at 70% of that radius means the camera has already moved well inside the loaded zone before the texture upgrade fires. The 2.5 m floor keeps small scenes from requiring centimetre-level camera distances before full-resolution textures appear. -**Why downgradeRadius = unloadRadius?** Tile geometry unloads at `unloadRadius`. Degrading textures to minimum at the same distance means the GPU texture memory is already at minimum cost exactly when the geometry is about to be evicted, preventing a spike of full/medium-res textures on geometry that is about to disappear. +**Why the downgrade radius floors?** Tile geometry unloads at `unloadRadius`, so large scenes use that as the minimum-tier boundary. For small scenes, `upgradeRadius + 1.0` and `upgradeRadius × 2.0` guarantee a usable medium-quality band instead of dropping directly from full to minimum. **Example** (city.json: `streaming_radius = 38.5m`, `unload_radius = 57.8m`): - `upgradeRadius = 38.5 × 0.70 = 26.97m` — full res within ~one tile diagonal diff --git a/docs/Architecture/tilebasedstreaming.md b/docs/Architecture/tilebasedstreaming.md index 4ec6d64d..4bc85484 100644 --- a/docs/Architecture/tilebasedstreaming.md +++ b/docs/Architecture/tilebasedstreaming.md @@ -132,6 +132,8 @@ The manifest schema has evolved across two versions: **Interior zone gating** — the manifest's `interior_zone` is the union AABB of all `ExteriorShell` tiles. On each streaming tick, the engine checks whether the camera is inside this volume. Tiles with `"interior": true` are only dispatched for loading while the camera is inside. This prevents the engine from loading room-level geometry when the player is outside the building, regardless of distance. +**Floor proximity gating** — for v4 `quadtree_floor` manifests, interior tiles that carry `floor_id` are also checked against `GeometryStreamingSystem.shared.floorProximityGateY` (default 5 m). The gate compares the camera's Y position to the tile's manifest center Y and suppresses new load dispatches for vertically distant floors. It does not unload already parsed tiles; normal `unloadRadius`, grace, and dwell rules still control teardown. + > The quadtree partitioning is a content-pipeline and manifest-level concept. At runtime the engine uses an **octree** for spatial range queries (finding tile stubs near the camera). The manifest's `quadtree_node_id` is used for debug logging only and has no effect on streaming logic. --- @@ -178,7 +180,8 @@ No geometry is parsed or uploaded at this stage. The whole function completes in 2. Tests against `effectivePrefetchRadius` (see [Prefetch Radius](#prefetch-radius)). 3. Applies the **frustum gate** (padded AABB vs camera frustum, `tileFrustumGatePadding = 20 m`). Tiles fully outside the frustum are skipped this tick. 4. Eligible tiles are sorted by priority (descending) then distance (ascending). -5. Up to `maxConcurrentTileLoads` (default 2) are dispatched via `loadTile()`, subject to the **memory budget gate**: the total parse memory in flight must stay under `tileParseMemoryBudgetMB` (200 MB), with a guarantee that at least one tile always loads even if it alone exceeds the budget. +5. Within each priority tier, candidates are sorted by screen-space importance. The score uses projected tile footprint, view alignment, and optionally an occlusion weight derived from closer loaded tile AABBs. Occlusion never hard-blocks a tile; `occlusionMinWeight` leaves a nonzero floor so sparse or glassy geometry does not permanently hide work. +6. Up to `maxConcurrentTileLoads` (default 2) are dispatched via `loadTile()`, subject to the **memory budget gate**: the total parse memory in flight must stay under `tileParseMemoryBudgetMB` (200 MB), with a guarantee that at least one tile always loads even if it alone exceeds the budget. **Tile unload pass** — three sub-passes each tick. All passes use `min(actual, predictive)` distance, matching the load pass, so a tile the camera is approaching is not torn down mid-parse: @@ -186,7 +189,7 @@ No geometry is parsed or uploaded at this stage. The whole function completes in 2. **Loaded tiles** that drifted entirely outside `maxQueryRadius`. 3. **Parsing tiles** that drifted outside `maxQueryRadius` (fast movement or teleport). -Both `.parsing` and `.parsed` tiles go through the **grace period** (see [Unload Grace Period](#unload-grace-period)) before actual teardown (passes 1 and 2). Pass 3 tiles are genuinely beyond the 500 m query radius and are cancelled without a grace period — boundary oscillation cannot occur at that range. At most `maxTileUnloadsPerUpdate` (default 2) tiles are torn down per tick to spread GPU buffer releases across frames. +Both `.parsing` and `.parsed` tiles go through the **grace period** (see [Unload Grace Period](#unload-grace-period)) before actual teardown when they are still inside the octree query radius. Parsed tiles also honor `minimumParsedTileResidentSeconds` (default 8 s), so a newly visible tile cannot be immediately evicted or unloaded while the camera settles near a boundary. Pass 3 tiles are genuinely beyond the 500 m query radius and are cancelled without a grace period — boundary oscillation cannot occur at that range. At most `maxTileUnloadsPerUpdate` (default 2) tiles are torn down per tick to spread GPU buffer releases across frames. ### 3. `loadTile(entityId:)` @@ -267,24 +270,22 @@ Both `.parsing` and `.parsed` tiles honour the grace period. `.parsing` tiles ha - **`MemoryBudgetManager`** tracks geometry and texture GPU bytes against per-platform budgets (probed at startup). - Before dispatching any tile load, the system checks `shouldEvictGeometry()`. If true, it runs `TextureStreamingSystem.shedTextureMemory` and `evictLRU` (capped at 8 evictions) before attempting a tile parse. This prevents a tile's multi-MB commit from pushing RAM over budget. -- **LRU eviction** scores loaded streaming entities by camera distance × `evictionDistanceWeight` + GPU size × `evictionSizeWeight`. Entities within `visibleEvictionProtectionRadius` (30 m) are protected. +- **LRU eviction** scores loaded OCC streaming entities by camera distance × `evictionDistanceWeight` + GPU size × `evictionSizeWeight`. Entities within `visibleEvictionProtectionRadius` (30 m) are protected. +- **Tile geometry eviction** (`evictTileGeometry`) runs after OCC eviction if geometry pressure remains high. It can evict full-load tiles, HLODs, and per-tile LODs that are outside their tile `streamingRadius`; parsed full tiles are protected until `minimumParsedTileResidentSeconds` elapses. - **OS memory pressure** callbacks (`DispatchSource.makeMemoryPressureSource`) set a flag; eviction is deferred to the next `update()` tick to stay single-threaded. - **`tripleVisibleEntities.clearAll()`** — called in `finalizePendingDestroys()` to clear all triple-buffer slots so the renderer does not read stale entity IDs after a scene reload. -### `evictLRU` limitation with full-load tile geometry +### Full-load tile geometry eviction -`evictLRU` targets `loadedStreamingEntities` — the set of OCC mesh stubs (entities with `StreamingComponent`). Full-load tile geometry lives on `RenderComponent` entities created by the `fullLoad` path of `setEntityMeshAsync` and is **not** in `loadedStreamingEntities`. This means: +`evictLRU` targets `loadedStreamingEntities` — the set of OCC mesh stubs (entities with `StreamingComponent`). Full-load tile geometry lives on `RenderComponent` entities created by the `fullLoad` path of `setEntityMeshAsync` and is **not** in `loadedStreamingEntities`. -- `MemoryBudgetManager.shouldEvictGeometry()` correctly reports that memory is in use. -- `evictLRU` runs but frees nothing from those tiles. -- `shouldEvictGeometry()` stays true. -- The tile load loop's `guard !shouldEvictGeometry() else { break }` fires and **no new tiles load**. +The current runtime follows `evictLRU` with `evictTileGeometry(...)` when geometry pressure remains high. This second-stage pass reaches full-load tiles, HLODs, and per-tile LODs, sorted farthest first. It protects a tile while the camera is inside that tile's own `streamingRadius`, because that tile would otherwise be re-dispatched immediately on the next streaming tick. -The only mechanism that can free full-load tile GPU memory is the **tile unload pass** calling `unloadTile()`. That pass has a 3-second grace period and a `maxTileUnloadsPerUpdate = 2` cap. For a 200-tile scene, full cleanup takes 10+ seconds after the camera moves away. +Parsed full tiles also use `minimumParsedTileResidentSeconds` (default 8 s) as a dwell floor before memory-pressure eviction can tear them down. This prevents large floor or facade tiles from appearing briefly and disappearing again while the camera settles near a streaming boundary. ### `forceUnloadAllParsedTiles()` — explicit session transition -When an app transitions between "active" and "inspection/calibration" modes (e.g. scaling a tiled scene down to miniature for placement), the previous session's tiles must be explicitly freed before the next full-scale session loads new tiles. Without an explicit unload, the memory budget deadlock described above stalls the new session for 10+ seconds. +When an app transitions between "active" and "inspection/calibration" modes (e.g. scaling a tiled scene down to miniature for placement), explicitly freeing the previous session's tiles avoids waiting for distance-based unloads, grace periods, per-tick caps, and dwell guards before the next full-scale session loads new tiles. ```swift GeometryStreamingSystem.shared.forceUnloadAllParsedTiles() @@ -415,7 +416,7 @@ When `tileComp.state == .parsed` (full tile is resident), all LOD levels are unl ### `unloadLODLevel(entityId:levelIndex:)` 1. Sets `level.state = .unloading` **before** `level.loadTask?.cancel()` (same race fix as HLOD). -2. Calls `BatchingSystem.shared.cancelPendingEntities(_:)` with the render descendant IDs — removes them from all pending batching queues before the entities are destroyed, preventing "entity is missing" errors on the next batching tick. +2. Calls `BatchingSystem.shared.notifyTileEntitiesUnloading(_:)` with the render descendant IDs — removes pending additions and committed cell membership before the entities are destroyed, preventing stale or recycled IDs from remaining in batch state. 3. Destroys the child entity. 4. Force-releases `AssetLoadingGate` for the destroyed entity (idempotent no-op if the Task already closed it). 5. Removes from `loadedLODEntities` when all levels are clear. @@ -496,5 +497,10 @@ if CFAbsoluteTimeGetCurrent() - tc.parseStartTime > tileParseTimeoutSeconds (def | `visibleEvictionProtectionRadius` | 30 m | Distance inside which eviction is blocked | | `hlodSwitchDistance` | manifest `switch_distance` | Camera distance beyond which HLOD is shown | | LOD `switchDistance` | manifest `switch_distance` per entry | Camera distance beyond which this LOD is preferred | -| `tileParseTimeoutSeconds` | 60 s | Watchdog deadline for an entire tile parse Task; forces tile to `.failed` and frees concurrency slot | +| `tileParseTimeoutSeconds` | 60 s | Watchdog deadline for an entire tile parse Task after remote download completes; forces tile to `.failed` and frees concurrency slot | +| `meshLoadTimeoutSeconds` | 60 s | Watchdog deadline for OCC mesh uploads and cache-backed mesh loads; resets stuck mesh loads to `.unloaded` | | `secondaryRepresentationMinDwellSeconds` | 1.0 s | Minimum time a HLOD or per-tile LOD level must dwell before the next transition is allowed; prevents flip-flopping between representations faster than once per second | +| `minimumParsedTileResidentSeconds` | 8.0 s | Minimum time a parsed full tile remains resident before normal unload or tile-geometry eviction may tear it down | +| `floorProximityGateY` | 5 m | Maximum Y distance for dispatching floor-aware interior tiles; set to `Float.greatestFiniteMagnitude` to disable | +| `enableImportanceSort` | true | Sort tile candidates by screen-space importance within priority tiers | +| `enableOcclusionSort` | true | Deprioritize tile candidates whose screen footprint is covered by closer loaded tile AABBs | From 579d50e871b90885da8a1a38d12cedc81f63c265 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 26 May 2026 09:11:36 -0700 Subject: [PATCH 3/4] [Demo] Updated game demo --- Sources/DemoGame/GameScene.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DemoGame/GameScene.swift b/Sources/DemoGame/GameScene.swift index 1cdd5e20..5ad64811 100644 --- a/Sources/DemoGame/GameScene.swift +++ b/Sources/DemoGame/GameScene.swift @@ -31,7 +31,7 @@ private enum Constants { static let orbitTargetOffset: Float = 25.0 - static let cameraMoveSpeed: Float = 0.5 + static let cameraMoveSpeed: Float = 1.0 static let cameraInputDeltaTime: Float = 0.1 static let streamingPriority: Int = 10 static let citySceneID = "city" From e4ee49640bfb093d933f6e1ed101330b2f38aca5 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 26 May 2026 09:13:22 -0700 Subject: [PATCH 4/4] [Release] Bump version to 0.12.15 --- CHANGELOG.md | 5 +++++ README.md | 2 +- Sources/DemoGame/AppDelegate.swift | 2 +- Sources/Sandbox/AppDelegate.swift | 2 +- Sources/UntoldEngine/Renderer/UntoldEngine.swift | 2 +- docs/API/GettingStarted.md | 2 +- 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c33d8f4..2c9dcf66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## v0.12.15 - 2026-05-26 +### 🐞 Fixes +- [Patch] Add scene channels for selectable and context geometry visibility (2c9217d…) +### 📚 Docs +- [Docs] Updated documentation (cbade44…) ## v0.12.14 - 2026-05-22 ### 🐞 Fixes - [Patch] Fix transparency bug (6a51e40…) diff --git a/README.md b/README.md index 80b74605..ccc05212 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.12.14 +git checkout v0.12.15 swift run untolddemo ``` diff --git a/Sources/DemoGame/AppDelegate.swift b/Sources/DemoGame/AppDelegate.swift index b8ed327c..92193bde 100644 --- a/Sources/DemoGame/AppDelegate.swift +++ b/Sources/DemoGame/AppDelegate.swift @@ -11,7 +11,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.12.14" + static let appVersion = "0.12.15" static let defaultWindowSize = NSSize(width: 1920, height: 1080) static let minimumWindowSize = NSSize(width: 640, height: 480) } diff --git a/Sources/Sandbox/AppDelegate.swift b/Sources/Sandbox/AppDelegate.swift index a837967e..953d5958 100644 --- a/Sources/Sandbox/AppDelegate.swift +++ b/Sources/Sandbox/AppDelegate.swift @@ -10,7 +10,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.12.14" + static let appVersion = "0.12.15" static let windowSize = NSSize(width: 1600, height: 900) } diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 87f36979..043937b1 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -175,7 +175,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { CameraSystem.shared.activeCamera = gameCamera - Logger.log(message: "Untold Engine Starting. Version 0.12.14") + Logger.log(message: "Untold Engine Starting. Version 0.12.15") } public func initSizeableResources() { diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index e1db7339..8a819001 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -27,7 +27,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.12.14 +git checkout v0.12.15 swift run untolddemo ```