From 5e86ae9a587313fde9877f6c22ea7edaec89814f Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Tue, 3 Feb 2026 06:53:24 -0700 Subject: [PATCH 1/2] [Feature] Added LOD support --- Sources/UntoldEngine/ECS/Components.swift | 21 + .../UntoldEngine/Renderer/UntoldEngine.swift | 2 + .../UntoldEngine/Scenes/SceneSerializer.swift | 104 +++++ Sources/UntoldEngine/Systems/LODConfig.swift | 32 ++ Sources/UntoldEngine/Systems/LODSystem.swift | 142 +++++++ .../UntoldEngine/Systems/LoadingSystem.swift | 35 ++ .../Systems/RegistrationSystem.swift | 269 ++++++++++++- .../UntoldEngine/Utils/ArrayExtensions.swift | 17 + .../SceneSerializerTest.swift | 132 ++++++ Tests/UntoldEngineTests/LODSystemTests.swift | 376 ++++++++++++++++++ .../03-Engine Systems/UsingLODSystem.md | 330 +++++++++++++++ 11 files changed, 1456 insertions(+), 4 deletions(-) create mode 100644 Sources/UntoldEngine/Systems/LODConfig.swift create mode 100644 Sources/UntoldEngine/Systems/LODSystem.swift create mode 100644 Sources/UntoldEngine/Utils/ArrayExtensions.swift create mode 100644 Tests/UntoldEngineTests/LODSystemTests.swift create mode 100644 docs/04-Engine Development/03-Engine Systems/UsingLODSystem.md diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 0de6a44e..e2131312 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -278,6 +278,27 @@ public class DerivedAssetNodeComponent: Component { } } +// MARK: - LOD Component + +public struct LODLevel { + public var mesh: [Mesh] // Meshes for this lod + public var maxDistance: Float // Switch to next LOD beyond this + public var screenPercentage: Float // Optional: screen-space threshold + public var url: URL? // URL to the LOD file (for display purposes) +} + +public class LODComponent: Component { + public var lodLevels: [LODLevel] = [] // Sorted by distance (LOD0 first) + public var currentLOD: Int = 0 // Active LOD index + public var fadeTransition: Bool = false // Enable cross-fade + public var transitionDuration: Float = 0.3 // Fade time in seconds + public var transitionProgress: Float = 0.0 // Current fade (0-1) + public var previousLOD: Int? // For blending + public var forcedLOD: Int? // Manual override (-1 = auto) + + public required init() {} +} + // MARK: - USC Scripting Component public class ScriptComponent: Component, Codable { diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 0c41830f..899b7e13 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -241,6 +241,8 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { traverseSceneGraph() handleInputCallback?() + LODSystem.shared.update(deltaTime: fixedStep) + if gameMode == true { AnimationSystem.shared.update(timeSinceLastUpdate) diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 2b3c1cec..bfdc7af0 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -142,6 +142,21 @@ struct AssetInstanceData: Codable { var overrides: [AssetOverrideData] } +// MARK: - LOD Data + +struct LODLevelData: Codable { + var url: URL + var maxDistance: Float + var screenPercentage: Float +} + +struct LODData: Codable { + var lodLevels: [LODLevelData] + var currentLOD: Int + var fadeTransition: Bool + var transitionDuration: Float +} + struct EntityData: Codable { var uuid: UUID = .init() // Unique identifier for this entity var parentUUID: UUID? = nil // UUID of the parent entity, if any @@ -165,11 +180,15 @@ struct EntityData: Codable { var hasSpotLightComponent: Bool? var hasAreaLightComponent: Bool? var hasCameraComponent: Bool? + var hasLODComponent: Bool? var customComponents: [String: Data]? = nil // New Asset Instance system var assetInstance: AssetInstanceData? = nil + + // LOD system + var lodData: LODData? = nil } private func isProceduralAssetURL(_ url: URL) -> Bool { @@ -386,6 +405,38 @@ public func serializeScene() -> SceneData { entityData.cameraData?.up = getCameraUp(entityId: entityId) } + // LOD properties + let hasLOD: Bool = hasComponent(entityId: entityId, componentType: LODComponent.self) + + if hasLOD { + entityData.hasLODComponent = hasLOD + + if let lodComponent = scene.get(component: LODComponent.self, for: entityId) { + var lodLevelsData: [LODLevelData] = [] + + for lodLevel in lodComponent.lodLevels { + // Only serialize if URL is available + if let url = lodLevel.url { + let lodLevelData = LODLevelData( + url: url, + maxDistance: lodLevel.maxDistance, + screenPercentage: lodLevel.screenPercentage + ) + lodLevelsData.append(lodLevelData) + } + } + + if !lodLevelsData.isEmpty { + entityData.lodData = LODData( + lodLevels: lodLevelsData, + currentLOD: lodComponent.currentLOD, + fadeTransition: lodComponent.fadeTransition, + transitionDuration: lodComponent.transitionDuration + ) + } + } + } + // custom component var customComponents: [String: Data] = [:] @@ -987,6 +1038,59 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } } + // LOD Component + if sceneDataEntity.hasLODComponent == true { + if let lodData = sceneDataEntity.lodData { + switch meshLoadingMode { + case .sync: + // Synchronous LOD loading not yet implemented + Logger.logWarning(message: "[SceneSerializer] Synchronous LOD loading not supported, skipping LOD for '\(sceneDataEntity.name)'") + case .asyncDefault: + // Register LOD component first + setEntityLodComponent(entityId: entityId) + + // Track completion + var loadedCount = 0 + let totalLevels = lodData.lodLevels.count + + // Load each LOD level using the granular API + for (index, lodLevelData) in lodData.lodLevels.enumerated() { + let url = lodLevelData.url + let filename = url.deletingPathExtension().lastPathComponent + let ext = url.pathExtension + let maxDistance = lodLevelData.maxDistance + + addLODLevel( + entityId: entityId, + lodIndex: index, + fileName: filename, + withExtension: ext, + maxDistance: maxDistance + ) { success in + if success { + loadedCount += 1 + // When all levels are loaded, restore LOD settings + if loadedCount == totalLevels { + Logger.log(message: "✅ LOD loaded for '\(sceneDataEntity.name)' with \(totalLevels) levels") + Task { + await MainActor.run { + if let lodComponent = scene.get(component: LODComponent.self, for: entityId) { + lodComponent.currentLOD = lodData.currentLOD + lodComponent.fadeTransition = lodData.fadeTransition + lodComponent.transitionDuration = lodData.transitionDuration + } + } + } + } + } else { + Logger.logWarning(message: "⚠️ Failed to load LOD level \(index) for '\(sceneDataEntity.name)'") + } + } + } + } + } + } + // custom components if let customComponents = sceneDataEntity.customComponents { for (typeName, jsonData) in customComponents { diff --git a/Sources/UntoldEngine/Systems/LODConfig.swift b/Sources/UntoldEngine/Systems/LODConfig.swift new file mode 100644 index 00000000..e2e0135c --- /dev/null +++ b/Sources/UntoldEngine/Systems/LODConfig.swift @@ -0,0 +1,32 @@ +// +// LODConfig.swift +// +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import Foundation + +public struct LODConfig { + public static var shared = LODConfig() + + // Default distance thresholds (in world units) + public var lodDistances: [Float] = [ + 50.0, // LOD0 -> LOD1 + 100.0, // LOD1 -> LOD2 + 200.0, // LOD2 -> LOD3 + 500.0, // LOD3 -> LOD4 (or culled) + ] + + // Bias multiplier (1.0 = normal, 2.0 = switch 2x earlier) + public var lodBias: Float = 1.0 + + // Hysteresis to prevent flickering ( add to distance when switching up) + public var hysteresis: Float = 5.0 + + // Enable smooth transitions - Not yet implemented + 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 new file mode 100644 index 00000000..d1fc89ac --- /dev/null +++ b/Sources/UntoldEngine/Systems/LODSystem.swift @@ -0,0 +1,142 @@ +// +// LODSystem.swift +// +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import Foundation +import simd + +public class LODSystem { + public static let shared = LODSystem() + private init() {} + + public func update(deltaTime: Float) { + // Get active camer + guard let camera = CameraSystem.shared.activeCamera, let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { return } + + let cameraPosition = cameraComponent.localPosition + + // Query entities with LOD components + let lodId = getComponentId(for: LODComponent.self) + let transformId = getComponentId(for: WorldTransformComponent.self) + let entities = queryEntitiesWithComponentIds([lodId, transformId], in: scene) + + for entityId in entities { + updateEntityLOD(entityId: entityId, cameraPosition: cameraPosition, deltaTime: deltaTime) + } + } + + private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, deltaTime: Float) { + // Calculate distance + let distance = calculateDistance(entityId: entityId, cameraPosition: cameraPosition) + + // Get current LOD Component + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { return } + + // Select new LOD level + let newLOD = selectLODLevel(distance: distance, lodComponent: lodComponent, currentLOD: lodComponent.currentLOD) + + // Apply the LOD (handles transitions, updates render component) + applyLOD(entityId: entityId, newLOD: newLOD, deltaTime: deltaTime) + } + + private func calculateDistance(entityId: EntityID, cameraPosition: simd_float3) -> Float { + guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId), let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) else { return 0.0 } + + // Get entity center from AABB + let boundingBox = localTransform.boundingBox + let localCenter = (boundingBox.min + boundingBox.max) * 0.5 + + // Tranform to world space + let worldCenter = worldTransform.space * simd_float4(localCenter, 1.0) + + // Calculate distance from camera to entity center + let distance = simd_distance(cameraPosition, simd_float3(worldCenter.x, worldCenter.y, worldCenter.z)) + + // Optional: Adjust by object size (larger objects can have more lods) + // Uncomment below to enable size-aware LOD + // let objectSize = simd_length(boundingBox.max - boundingBox.min) + // return distance / max(objectSize * 0.5, 1.0) + + return distance + } + + private func selectLODLevel(distance: Float, lodComponent: LODComponent, currentLOD: Int) -> Int { + // Check for forced LOD override + if let forced = lodComponent.forcedLOD, forced >= 0 { + return min(forced, lodComponent.lodLevels.count - 1) + } + + // Apply LOD bias + let adjustedDistance = distance * LODConfig.shared.lodBias + + // Find appropriate LOD level + for (index, lodLevel) in lodComponent.lodLevels.enumerated() { + var threshold = lodLevel.maxDistance + + // Apply hysteresis when switching to higher detail (prevents flickering) + if index < currentLOD { + threshold -= LODConfig.shared.hysteresis + } + + if adjustedDistance <= threshold { + return index + } + } + + // Beyond all thresholds, use lowest LOD + return lodComponent.lodLevels.count - 1 + } + + 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 + } + + let currentLOD = lodComponent.currentLOD + + // No change needed + if newLOD == currentLOD, lodComponent.previousLOD == nil { + return + } + + // Handle fade transitions + if LODConfig.shared.enableFadeTransitions { + if newLOD != currentLOD { + // Start transition + lodComponent.previousLOD = currentLOD + lodComponent.currentLOD = newLOD + 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 + lodComponent.previousLOD = nil + } + + // Update render component with new LOD meshes + // Safety check: ensure LOD level exists and has valid mesh data + if newLOD >= 0, newLOD < lodComponent.lodLevels.count { + let lodLevel = lodComponent.lodLevels[newLOD] + // Skip placeholder LODs (empty mesh arrays) + if !lodLevel.mesh.isEmpty { + renderComponent.mesh = lodLevel.mesh + } + } + } +} diff --git a/Sources/UntoldEngine/Systems/LoadingSystem.swift b/Sources/UntoldEngine/Systems/LoadingSystem.swift index 25612dcf..40619ed1 100644 --- a/Sources/UntoldEngine/Systems/LoadingSystem.swift +++ b/Sources/UntoldEngine/Systems/LoadingSystem.swift @@ -229,3 +229,38 @@ public func reloadScript(named name: String) -> Bool { Logger.log(message: "🔄 Reloaded script: \(script.name)") return true } + +/// Find LOD file URLs for a given base path +/// Returns array of URLs for each LOD level found +public func findLODFiles(basePath: URL, lodCount: Int = 4) -> [URL] { + var lodURLs: [URL] = [] + + // Strip existing LOD suffix if present (e.g., tree_LOD0.usdz -> tree.usdz) + let fileNameWithoutExt = basePath.deletingPathExtension().lastPathComponent + let baseDirectory = basePath.deletingLastPathComponent() + let ext = basePath.pathExtension + + // Remove _LOD\d+ pattern if it exists + let baseFileName: String + if let range = fileNameWithoutExt.range(of: "_LOD[0-9]+$", options: .regularExpression) { + baseFileName = String(fileNameWithoutExt[.. 0 { @@ -572,6 +570,7 @@ public func loadSceneAsync( setEntityName(entityId: entityId, name: mesh.first!.assetName) setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) } + return true } await AssetLoadingState.shared.finishLoading(entityId: sceneLoadEntityId) @@ -1084,3 +1083,265 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio options: [MTLResourceOptions.storageModeShared]) } } + +// MARK: - Granular LOD Management Functions + +/// Set up LOD component for an entity +/// Call this before adding LOD levels +public func setEntityLodComponent(entityId: EntityID) { + if !hasComponent(entityId: entityId, componentType: LODComponent.self) { + registerComponent(entityId: entityId, componentType: LODComponent.self) + Logger.log(message: "✅ LODComponent registered for entity") + } else { + Logger.logWarning(message: "LODComponent already exists on entity") + } +} + +/// Add a single LOD level to an entity +/// Entity must have LODComponent set via setEntityLodComponent() first +public func addLODLevel( + entityId: EntityID, + lodIndex: Int, + fileName: String, + withExtension: String, + maxDistance: Float, + screenPercentage: Float = 0.0, + completion: ((Bool) -> Void)? = nil +) { + Task { + // Check if LODComponent exists + guard hasComponent(entityId: entityId, componentType: LODComponent.self) else { + Logger.logError(message: "Entity does not have LODComponent. Call setEntityLodComponent() first.") + await MainActor.run { + completion?(false) + } + return + } + + // Get file URL using standard resource loading + guard let url = LoadingSystem.shared.resourceURL(forResource: fileName, withExtension: withExtension) else { + Logger.logError(message: "Failed to find LOD file: \(fileName).\(withExtension)") + await MainActor.run { + completion?(false) + } + return + } + + // Load meshes for this LOD + var meshes = await Mesh.loadMeshesAsync( + url: url, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + // Assign empty skin to all meshes (required by shaders) + let skin = Skin() + for index in meshes.indices { + meshes[index].skin = skin + } + + // Create LOD level + let lodLevel = LODLevel( + mesh: meshes, + maxDistance: maxDistance, + screenPercentage: screenPercentage, + url: url + ) + + await MainActor.run { + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + handleError(.componentNotFound, "LODComponent") + completion?(false) + return + } + + // Add LOD level at the specified index + if lodIndex < 0 { + Logger.logWarning(message: "Invalid LOD index \(lodIndex), appending to end") + lodComponent.lodLevels.append(lodLevel) + } else if lodIndex >= lodComponent.lodLevels.count { + // Ensure array is large enough by padding with empty slots if needed + // This handles out-of-order additions (e.g., adding LOD2 before LOD1) + while lodComponent.lodLevels.count < lodIndex { + // Pad with placeholder (will be replaced when proper LOD is added) + let placeholder = LODLevel(mesh: [], maxDistance: 0, screenPercentage: 0, url: URL(fileURLWithPath: "")) + lodComponent.lodLevels.append(placeholder) + } + // Now append the actual LOD at the correct index + lodComponent.lodLevels.append(lodLevel) + } else { + // Replace existing LOD at this index + lodComponent.lodLevels[lodIndex] = lodLevel + } + + // If this is LOD0, create or update RenderComponent + if lodIndex == 0 { + if let renderComponent = scene.get(component: RenderComponent.self, for: entityId) { + // Update existing RenderComponent + renderComponent.mesh = meshes + renderComponent.assetURL = url + renderComponent.assetName = meshes.first?.assetName ?? url.deletingPathExtension().lastPathComponent + } else { + // Create new RenderComponent + let assetName = meshes.first?.assetName ?? url.deletingPathExtension().lastPathComponent + registerRenderComponent(entityId: entityId, meshes: meshes, url: url, assetName: assetName) + associateMeshesToEntity(entityId: entityId, meshes: meshes) + } + } + + Logger.log(message: "✅ Added LOD level \(lodIndex) to entity") + completion?(true) + } + } +} + +/// Remove a specific LOD level by index +public func removeLODLevel( + entityId: EntityID, + lodIndex: Int +) { + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + Logger.logWarning(message: "Entity does not have LODComponent") + return + } + + // Validate index + guard lodIndex >= 0, lodIndex < lodComponent.lodLevels.count else { + Logger.logWarning(message: "Invalid LOD index: \(lodIndex)") + return + } + + // Remove the LOD level + lodComponent.lodLevels.remove(at: lodIndex) + + // If we removed the current LOD, reset to LOD0 + if lodComponent.currentLOD == lodIndex { + lodComponent.currentLOD = 0 + + // Update render component to show LOD0 if available + if !lodComponent.lodLevels.isEmpty, + let renderComponent = scene.get(component: RenderComponent.self, for: entityId) + { + renderComponent.mesh = lodComponent.lodLevels[0].mesh + } + } else if lodComponent.currentLOD > lodIndex { + // Adjust current LOD index if we removed something before it + lodComponent.currentLOD -= 1 + } + + Logger.log(message: "✅ Removed LOD level \(lodIndex)") +} + +/// Replace an existing LOD level with a new mesh file +public func replaceLODLevel( + entityId: EntityID, + lodIndex: Int, + fileName: String, + withExtension: String, + maxDistance: Float, + screenPercentage: Float = 0.0, + completion: ((Bool) -> Void)? = nil +) { + Task { + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + Logger.logWarning(message: "Entity does not have LODComponent") + await MainActor.run { + completion?(false) + } + return + } + + // Validate index + guard lodIndex >= 0, lodIndex < lodComponent.lodLevels.count else { + Logger.logWarning(message: "Invalid LOD index: \(lodIndex)") + await MainActor.run { + completion?(false) + } + return + } + + // Get file URL using standard resource loading + guard let newURL = LoadingSystem.shared.resourceURL(forResource: fileName, withExtension: withExtension) else { + Logger.logError(message: "Failed to find LOD file: \(fileName).\(withExtension)") + await MainActor.run { + completion?(false) + } + return + } + + // Load new meshes + var meshes = await Mesh.loadMeshesAsync( + url: newURL, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + // Assign empty skin to all meshes + let skin = Skin() + for index in meshes.indices { + meshes[index].skin = skin + } + + // Create new LOD level + let newLodLevel = LODLevel( + mesh: meshes, + maxDistance: maxDistance, + screenPercentage: screenPercentage, + url: newURL + ) + + await MainActor.run { + // Replace the LOD level + lodComponent.lodLevels[lodIndex] = newLodLevel + + // If this is the current LOD or LOD0, update render component + if lodComponent.currentLOD == lodIndex, + let renderComponent = scene.get(component: RenderComponent.self, for: entityId) + { + renderComponent.mesh = meshes + renderComponent.assetURL = newURL + renderComponent.assetName = meshes.first?.assetName ?? newURL.deletingPathExtension().lastPathComponent + } + + Logger.log(message: "✅ Replaced LOD level \(lodIndex)") + completion?(true) + } + } +} + +/// Get the number of LOD levels for an entity +public func getLODLevelCount(entityId: EntityID) -> Int { + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + return 0 + } + return lodComponent.lodLevels.count +} + +/// Register LOD component for an existing entity with pre-loaded LOD levels +/// Useful for testing or when you've manually created LOD levels +public func registerLODComponent( + entityId: EntityID, + lodLevels: [LODLevel] +) { + guard !lodLevels.isEmpty else { + Logger.logWarning(message: "Cannot register LODComponent with empty lodLevels") + return + } + + registerComponent(entityId: entityId, componentType: LODComponent.self) + + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + handleError(.componentNotFound, "LODComponent") + return + } + + lodComponent.lodLevels = lodLevels + lodComponent.currentLOD = 0 + + // Update render component with LOD0 if it exists + if let renderComponent = scene.get(component: RenderComponent.self, for: entityId) { + renderComponent.mesh = lodLevels[0].mesh + } +} diff --git a/Sources/UntoldEngine/Utils/ArrayExtensions.swift b/Sources/UntoldEngine/Utils/ArrayExtensions.swift new file mode 100644 index 00000000..6d6f7e37 --- /dev/null +++ b/Sources/UntoldEngine/Utils/ArrayExtensions.swift @@ -0,0 +1,17 @@ +// +// ArrayExtensions.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import Foundation + +extension Array { + /// Safe subscript that returns nil instead of crashing for out-of-bounds access + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift index d391aaad..cceba2fd 100644 --- a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift +++ b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift @@ -1183,4 +1183,136 @@ final class SceneSerializerTests: BaseRenderSetup { XCTAssertEqual(renderComponent.assetName, "Cube") XCTAssertTrue(renderComponent.assetURL.path.hasPrefix("/primitive/")) } + + // MARK: - LOD Component Tests + + func testSerializeLODComponent() { + // Create entity with LOD component + let entityId = createEntity() + setEntityName(entityId: entityId, name: "LODEntity") + registerTransformComponent(entityId: entityId) + registerComponent(entityId: entityId, componentType: LODComponent.self) + + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + XCTFail("LODComponent should exist") + return + } + + // Create mock LOD levels with URLs + let lod0URL = URL(fileURLWithPath: "/GameData/Models/tree/tree_LOD0.usdz") + let lod1URL = URL(fileURLWithPath: "/GameData/Models/tree/tree_LOD1.usdz") + let lod2URL = URL(fileURLWithPath: "/GameData/Models/tree/tree_LOD2.usdz") + + // Create empty mesh arrays for testing (we only care about serialization) + let emptyMeshes: [Mesh] = [] + + lodComponent.lodLevels = [ + LODLevel(mesh: emptyMeshes, maxDistance: 10.0, screenPercentage: 0.0, url: lod0URL), + LODLevel(mesh: emptyMeshes, maxDistance: 25.0, screenPercentage: 0.0, url: lod1URL), + LODLevel(mesh: emptyMeshes, maxDistance: 50.0, screenPercentage: 0.0, url: lod2URL), + ] + lodComponent.currentLOD = 1 + lodComponent.fadeTransition = true + lodComponent.transitionDuration = 0.5 + + // Serialize + let sceneData = serializeScene() + + // Verify LOD data was serialized + XCTAssertEqual(sceneData.entities.count, 1, "Should have one entity") + XCTAssertTrue(sceneData.entities[0].hasLODComponent == true, "Should have LOD component flag") + XCTAssertNotNil(sceneData.entities[0].lodData, "LOD data should not be nil") + + let lodData = sceneData.entities[0].lodData! + XCTAssertEqual(lodData.lodLevels.count, 3, "Should have 3 LOD levels") + XCTAssertEqual(lodData.currentLOD, 1, "Current LOD should match") + XCTAssertTrue(lodData.fadeTransition, "Fade transition should be enabled") + XCTAssertEqual(lodData.transitionDuration, 0.5, "Transition duration should match") + + // Verify LOD level data + XCTAssertEqual(lodData.lodLevels[0].url, lod0URL, "LOD0 URL should match") + XCTAssertEqual(lodData.lodLevels[0].maxDistance, 10.0, "LOD0 distance should match") + + XCTAssertEqual(lodData.lodLevels[1].url, lod1URL, "LOD1 URL should match") + XCTAssertEqual(lodData.lodLevels[1].maxDistance, 25.0, "LOD1 distance should match") + + XCTAssertEqual(lodData.lodLevels[2].url, lod2URL, "LOD2 URL should match") + XCTAssertEqual(lodData.lodLevels[2].maxDistance, 50.0, "LOD2 distance should match") + } + + func testSerializeLODComponentWithoutURLs() { + // Test that LOD levels without URLs are not serialized + let entityId = createEntity() + setEntityName(entityId: entityId, name: "LODEntityNoURLs") + registerTransformComponent(entityId: entityId) + registerComponent(entityId: entityId, componentType: LODComponent.self) + + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + XCTFail("LODComponent should exist") + return + } + + // Create LOD levels without URLs (url = nil) + let emptyMeshes: [Mesh] = [] + lodComponent.lodLevels = [ + LODLevel(mesh: emptyMeshes, maxDistance: 10.0, screenPercentage: 0.0, url: nil), + LODLevel(mesh: emptyMeshes, maxDistance: 25.0, screenPercentage: 0.0, url: nil), + ] + + // Serialize + let sceneData = serializeScene() + + // Verify LOD component exists but no LOD data was serialized (because URLs are nil) + XCTAssertEqual(sceneData.entities.count, 1, "Should have one entity") + XCTAssertTrue(sceneData.entities[0].hasLODComponent == true, "Should have LOD component flag") + XCTAssertNil(sceneData.entities[0].lodData, "LOD data should be nil when no URLs are present") + } + + func testSerializeLODComponentMixedURLs() { + // Test that only LOD levels with URLs are serialized + let entityId = createEntity() + setEntityName(entityId: entityId, name: "LODEntityMixed") + registerTransformComponent(entityId: entityId) + registerComponent(entityId: entityId, componentType: LODComponent.self) + + guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { + XCTFail("LODComponent should exist") + return + } + + let lod0URL = URL(fileURLWithPath: "/GameData/Models/tree/tree_LOD0.usdz") + let lod2URL = URL(fileURLWithPath: "/GameData/Models/tree/tree_LOD2.usdz") + let emptyMeshes: [Mesh] = [] + + lodComponent.lodLevels = [ + LODLevel(mesh: emptyMeshes, maxDistance: 10.0, screenPercentage: 0.0, url: lod0URL), + LODLevel(mesh: emptyMeshes, maxDistance: 25.0, screenPercentage: 0.0, url: nil), // No URL + LODLevel(mesh: emptyMeshes, maxDistance: 50.0, screenPercentage: 0.0, url: lod2URL), + ] + + // Serialize + let sceneData = serializeScene() + + // Verify only LOD levels with URLs were serialized + XCTAssertNotNil(sceneData.entities[0].lodData, "LOD data should exist") + let lodData = sceneData.entities[0].lodData! + XCTAssertEqual(lodData.lodLevels.count, 2, "Should only serialize LOD levels with URLs") + XCTAssertEqual(lodData.lodLevels[0].url, lod0URL, "First serialized LOD should be LOD0") + XCTAssertEqual(lodData.lodLevels[1].url, lod2URL, "Second serialized LOD should be LOD2") + } + + func testSerializeEntityWithoutLODComponent() { + // Verify entities without LOD component don't have LOD data + let entityId = createEntity() + setEntityName(entityId: entityId, name: "RegularEntity") + registerTransformComponent(entityId: entityId) + + // Serialize + let sceneData = serializeScene() + + // Verify no LOD data + XCTAssertEqual(sceneData.entities.count, 1, "Should have one entity") + XCTAssertNil(sceneData.entities[0].hasLODComponent, "Should not have LOD component flag") + XCTAssertNil(sceneData.entities[0].lodData, "Should not have LOD data") + } } diff --git a/Tests/UntoldEngineTests/LODSystemTests.swift b/Tests/UntoldEngineTests/LODSystemTests.swift new file mode 100644 index 00000000..375ebe41 --- /dev/null +++ b/Tests/UntoldEngineTests/LODSystemTests.swift @@ -0,0 +1,376 @@ +import simd +@testable import UntoldEngine +import XCTest + +final class LODSystemTests: XCTestCase { + var testEntity: EntityID! + + override func setUp() { + super.setUp() + // Clean up before each test + destroyAllEntities() + testEntity = createEntity() + registerTransformComponent(entityId: testEntity) + } + + override func tearDown() { + destroyAllEntities() + super.tearDown() + } + + // MARK: - LODComponent Tests + + func testLODComponentCreation() { + let lodComponent = LODComponent() + + XCTAssertEqual(lodComponent.lodLevels.count, 0, "New LODComponent should have empty levels") + XCTAssertEqual(lodComponent.currentLOD, 0, "Default LOD should be 0") + XCTAssertNil(lodComponent.previousLOD, "Previous LOD should be nil initially") + XCTAssertNil(lodComponent.forcedLOD, "Forced LOD should be nil by default") + XCTAssertEqual(lodComponent.transitionProgress, 0.0, "Transition progress should be 0") + } + + func testLODLevelCreation() { + let meshes: [Mesh] = [] + let lodLevel = LODLevel( + mesh: meshes, + maxDistance: 100.0, + screenPercentage: 0.5 + ) + + XCTAssertEqual(lodLevel.maxDistance, 100.0, "Max distance should match") + XCTAssertEqual(lodLevel.screenPercentage, 0.5, "Screen percentage should match") + XCTAssertEqual(lodLevel.mesh.count, 0, "Mesh array should be empty") + } + + func testRegisterLODComponent() { + registerComponent(entityId: testEntity, componentType: LODComponent.self) + + XCTAssertTrue( + hasComponent(entityId: testEntity, componentType: LODComponent.self), + "Entity should have LODComponent" + ) + + let lodComp = scene.get(component: LODComponent.self, for: testEntity) + XCTAssertNotNil(lodComp, "LODComponent should be retrievable") + } + + // MARK: - LODConfig Tests + + func testLODConfigDefaults() { + let config = LODConfig.shared + + XCTAssertEqual(config.lodDistances.count, 4, "Should have 4 default distances") + XCTAssertEqual(config.lodDistances[0], 50.0, "LOD0 distance should be 50") + XCTAssertEqual(config.lodDistances[1], 100.0, "LOD1 distance should be 100") + XCTAssertEqual(config.lodDistances[2], 200.0, "LOD2 distance should be 200") + XCTAssertEqual(config.lodDistances[3], 500.0, "LOD3 distance should be 500") + + XCTAssertEqual(config.lodBias, 1.0, "Default LOD bias should be 1.0") + XCTAssertEqual(config.hysteresis, 5.0, "Default hysteresis should be 5.0") + XCTAssertFalse(config.enableFadeTransitions, "Fade transitions should be disabled by default for performance") + XCTAssertEqual(config.fadeTransitionTime, 0.3, "Fade transition time should be 0.3s") + } + + func testLODConfigCustomization() { + LODConfig.shared.lodBias = 1.5 + LODConfig.shared.hysteresis = 10.0 + + XCTAssertEqual(LODConfig.shared.lodBias, 1.5, "LOD bias should be updated") + XCTAssertEqual(LODConfig.shared.hysteresis, 10.0, "Hysteresis should be updated") + + // Reset for other tests + LODConfig.shared.lodBias = 1.0 + LODConfig.shared.hysteresis = 5.0 + } + + // MARK: - LODSystem Distance Calculation Tests + + func testCalculateDistanceBasic() { + // Test the distance calculation logic independently + let cameraPos = simd_float3(0, 0, 0) + let entityPos = simd_float3(10, 0, 0) + + let distance = simd_distance(cameraPos, entityPos) + + // Verify distance calculation + XCTAssertEqual(distance, 10.0, accuracy: 0.01, "Distance calculation should be accurate") + + // Test other positions + let pos2 = simd_float3(3, 4, 0) + let distance2 = simd_distance(cameraPos, pos2) + XCTAssertEqual(distance2, 5.0, accuracy: 0.01, "Distance should be 5 (3-4-5 triangle)") + } + + // MARK: - LOD Selection Tests + + func testLODSelectionBasedOnDistance() { + // Create LOD component with test levels + registerComponent(entityId: testEntity, componentType: LODComponent.self) + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to get LOD component") + return + } + + // Add LOD levels with increasing distances + lodComp.lodLevels = [ + LODLevel(mesh: [], maxDistance: 50.0, screenPercentage: 1.0), // LOD0 + LODLevel(mesh: [], maxDistance: 100.0, screenPercentage: 0.5), // LOD1 + LODLevel(mesh: [], maxDistance: 200.0, screenPercentage: 0.25), // LOD2 + LODLevel(mesh: [], maxDistance: 500.0, screenPercentage: 0.1), // LOD3 + ] + + XCTAssertEqual(lodComp.lodLevels.count, 4, "Should have 4 LOD levels") + + // Verify distances are correctly set + XCTAssertEqual(lodComp.lodLevels[0].maxDistance, 50.0) + XCTAssertEqual(lodComp.lodLevels[1].maxDistance, 100.0) + XCTAssertEqual(lodComp.lodLevels[2].maxDistance, 200.0) + XCTAssertEqual(lodComp.lodLevels[3].maxDistance, 500.0) + } + + func testForcedLODOverride() { + registerComponent(entityId: testEntity, componentType: LODComponent.self) + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to get LOD component") + return + } + + lodComp.lodLevels = [ + LODLevel(mesh: [], maxDistance: 50.0, screenPercentage: 1.0), + LODLevel(mesh: [], maxDistance: 100.0, screenPercentage: 0.5), + ] + + // Force LOD to specific level + lodComp.forcedLOD = 1 + + XCTAssertEqual(lodComp.forcedLOD, 1, "Forced LOD should be set to 1") + + // Clear forced LOD + lodComp.forcedLOD = nil + XCTAssertNil(lodComp.forcedLOD, "Forced LOD should be cleared") + } + + // MARK: - LOD Transition Tests + + func testLODTransitionProgress() { + registerComponent(entityId: testEntity, componentType: LODComponent.self) + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to get LOD component") + return + } + + lodComp.currentLOD = 0 + lodComp.previousLOD = 1 + lodComp.transitionProgress = 0.5 + + XCTAssertEqual(lodComp.currentLOD, 0, "Current LOD should be 0") + XCTAssertEqual(lodComp.previousLOD, 1, "Previous LOD should be 1") + XCTAssertEqual(lodComp.transitionProgress, 0.5, "Transition progress should be 0.5") + } + + func testTransitionCompletion() { + registerComponent(entityId: testEntity, componentType: LODComponent.self) + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to get LOD component") + return + } + + lodComp.currentLOD = 1 + lodComp.previousLOD = 0 + lodComp.transitionProgress = 1.0 + + // Simulate transition completion + if lodComp.transitionProgress >= 1.0 { + lodComp.previousLOD = nil + lodComp.transitionProgress = 0.0 + } + + XCTAssertNil(lodComp.previousLOD, "Previous LOD should be cleared after transition") + XCTAssertEqual(lodComp.transitionProgress, 0.0, "Transition progress should reset to 0") + } + + // MARK: - Array Extension Tests + + func testSafeArraySubscript() { + let distances: [Float] = [50.0, 100.0, 200.0] + + XCTAssertEqual(distances[safe: 0], 50.0, "Safe subscript should return first element") + XCTAssertEqual(distances[safe: 1], 100.0, "Safe subscript should return second element") + XCTAssertEqual(distances[safe: 2], 200.0, "Safe subscript should return third element") + XCTAssertNil(distances[safe: 3], "Safe subscript should return nil for out of bounds") + XCTAssertNil(distances[safe: -1], "Safe subscript should return nil for negative index") + } + + // MARK: - LOD Component Registration Tests + + func testRegisterLODComponentWithLevels() { + let lodLevels = [ + LODLevel(mesh: [], maxDistance: 50.0, screenPercentage: 1.0), + LODLevel(mesh: [], maxDistance: 100.0, screenPercentage: 0.5), + ] + + registerLODComponent(entityId: testEntity, lodLevels: lodLevels) + + XCTAssertTrue( + hasComponent(entityId: testEntity, componentType: LODComponent.self), + "Entity should have LODComponent after registration" + ) + + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to retrieve LOD component") + return + } + + XCTAssertEqual(lodComp.lodLevels.count, 2, "Should have 2 LOD levels") + XCTAssertEqual(lodComp.currentLOD, 0, "Current LOD should be 0") + XCTAssertEqual(lodComp.lodLevels[0].maxDistance, 50.0, "LOD0 distance should be 50") + XCTAssertEqual(lodComp.lodLevels[1].maxDistance, 100.0, "LOD1 distance should be 100") + } + + func testRegisterLODComponentWithEmptyLevels() { + // This should log a warning but not crash + registerLODComponent(entityId: testEntity, lodLevels: []) + + // Component should not be registered if levels are empty + XCTAssertFalse( + hasComponent(entityId: testEntity, componentType: LODComponent.self), + "Entity should not have LODComponent with empty levels" + ) + } + + // MARK: - LOD System Integration Tests + + func testMultipleEntitiesWithLOD() { + let entity1 = createEntity() + let entity2 = createEntity() + + registerTransformComponent(entityId: entity1) + registerTransformComponent(entityId: entity2) + + let lodLevels1 = [LODLevel(mesh: [], maxDistance: 50.0, screenPercentage: 1.0)] + let lodLevels2 = [LODLevel(mesh: [], maxDistance: 100.0, screenPercentage: 1.0)] + + registerLODComponent(entityId: entity1, lodLevels: lodLevels1) + registerLODComponent(entityId: entity2, lodLevels: lodLevels2) + + XCTAssertTrue(hasComponent(entityId: entity1, componentType: LODComponent.self)) + XCTAssertTrue(hasComponent(entityId: entity2, componentType: LODComponent.self)) + + let lod1 = scene.get(component: LODComponent.self, for: entity1) + let lod2 = scene.get(component: LODComponent.self, for: entity2) + + XCTAssertEqual(lod1?.lodLevels[0].maxDistance, 50.0) + XCTAssertEqual(lod2?.lodLevels[0].maxDistance, 100.0) + } + + func testLODSystemQuery() { + // Create multiple entities with LOD components + let entity1 = createEntity() + let entity2 = createEntity() + let entity3 = createEntity() // No LOD + + registerTransformComponent(entityId: entity1) + registerTransformComponent(entityId: entity2) + registerTransformComponent(entityId: entity3) + + let lodLevels = [LODLevel(mesh: [], maxDistance: 50.0, screenPercentage: 1.0)] + registerLODComponent(entityId: entity1, lodLevels: lodLevels) + registerLODComponent(entityId: entity2, lodLevels: lodLevels) + + // Query entities with LOD components + let lodId = getComponentId(for: LODComponent.self) + let transformId = getComponentId(for: WorldTransformComponent.self) + let entities = queryEntitiesWithComponentIds([lodId, transformId], in: scene) + + XCTAssertEqual(entities.count, 2, "Should find 2 entities with LOD and transform components") + XCTAssertTrue(entities.contains(entity1), "Should contain entity1") + XCTAssertTrue(entities.contains(entity2), "Should contain entity2") + XCTAssertFalse(entities.contains(entity3), "Should not contain entity3 (no LOD)") + } + + // MARK: - LOD Hysteresis Tests + + func testHysteresisValue() { + let hysteresis = LODConfig.shared.hysteresis + + XCTAssertEqual(hysteresis, 5.0, "Default hysteresis should be 5.0") + XCTAssertGreaterThan(hysteresis, 0, "Hysteresis should be positive") + + // Hysteresis prevents flickering by requiring distance to change + // by more than hysteresis value before switching LOD levels + let threshold: Float = 100.0 + let adjustedUpward = threshold + hysteresis // 105.0 + let adjustedDownward = threshold - hysteresis // 95.0 + + XCTAssertEqual(adjustedUpward, 105.0, "Upward threshold should include hysteresis") + XCTAssertEqual(adjustedDownward, 95.0, "Downward threshold should include hysteresis") + } + + // MARK: - LOD Bias Tests + + func testLODBias() { + let bias = LODConfig.shared.lodBias + XCTAssertEqual(bias, 1.0, "Default bias should be 1.0") + + // Bias > 1.0 makes LODs switch to lower detail sooner (performance mode) + // Bias < 1.0 makes LODs switch to lower detail later (quality mode) + let distance: Float = 100.0 + let performanceBias: Float = 1.5 + let qualityBias: Float = 0.75 + + let performanceDistance = distance * performanceBias + let qualityDistance = distance * qualityBias + + XCTAssertEqual(performanceDistance, 150.0, "Performance bias should increase effective distance") + XCTAssertEqual(qualityDistance, 75.0, "Quality bias should decrease effective distance") + } + + // MARK: - Edge Cases + + func testLODWithSingleLevel() { + let lodLevels = [LODLevel(mesh: [], maxDistance: 1000.0, screenPercentage: 1.0)] + registerLODComponent(entityId: testEntity, lodLevels: lodLevels) + + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to get LOD component") + return + } + + XCTAssertEqual(lodComp.lodLevels.count, 1, "Should have exactly 1 LOD level") + XCTAssertEqual(lodComp.currentLOD, 0, "Current LOD should always be 0 with single level") + } + + func testLODWithMaxLevels() { + // Test with many LOD levels + var lodLevels: [LODLevel] = [] + for i in 0 ..< 10 { + let distance = Float(i + 1) * 100.0 + lodLevels.append(LODLevel(mesh: [], maxDistance: distance, screenPercentage: 1.0)) + } + + registerLODComponent(entityId: testEntity, lodLevels: lodLevels) + + guard let lodComp = scene.get(component: LODComponent.self, for: testEntity) else { + XCTFail("Failed to get LOD component") + return + } + + XCTAssertEqual(lodComp.lodLevels.count, 10, "Should have 10 LOD levels") + XCTAssertEqual(lodComp.lodLevels[9].maxDistance, 1000.0, "Last LOD should have max distance of 1000") + } + + func testDestroyEntityWithLODComponent() { + let lodLevels = [LODLevel(mesh: [], maxDistance: 50.0, screenPercentage: 1.0)] + registerLODComponent(entityId: testEntity, lodLevels: lodLevels) + + XCTAssertTrue(hasComponent(entityId: testEntity, componentType: LODComponent.self)) + + destroyEntity(entityId: testEntity) + scene.finalizePendingDestroys() + + // Entity should be marked as freed after destruction + let entityIndex = getEntityIndex(testEntity) + XCTAssertTrue(scene.entities[Int(entityIndex)].freed, "Entity should be freed after destruction") + } +} diff --git a/docs/04-Engine Development/03-Engine Systems/UsingLODSystem.md b/docs/04-Engine Development/03-Engine Systems/UsingLODSystem.md new file mode 100644 index 00000000..3c7d0c3b --- /dev/null +++ b/docs/04-Engine Development/03-Engine Systems/UsingLODSystem.md @@ -0,0 +1,330 @@ +--- +id: lodsystem +title: LOD System +sidebar_position: 10 +--- + +# LOD (Level of Detail) System - Usage Guide + +The Untold Engine provides a flexible LOD system for optimizing rendering performance by displaying different mesh details based on camera distance. + +## Overview + +The LOD system allows you to: +- Add multiple levels of detail to any entity +- Automatically switch between LOD levels based on distance +- Customize distance thresholds for each LOD level +- Configure LOD behavior (bias, hysteresis, fade transitions) + +## Quick Start + +### Basic LOD Setup + +```swift +// Create entity +let tree = createEntity() + +// Add LOD component +setEntityLodComponent(entityId: tree) + +// Add LOD levels (from highest to lowest detail) +addLODLevel(entityId: tree, lodIndex: 0, fileName: "tree_LOD0", withExtension: "usdz", maxDistance: 50.0) +addLODLevel(entityId: tree, lodIndex: 1, fileName: "tree_LOD1", withExtension: "usdz", maxDistance: 100.0) +addLODLevel(entityId: tree, lodIndex: 2, fileName: "tree_LOD2", withExtension: "usdz", maxDistance: 200.0) +addLODLevel(entityId: tree, lodIndex: 3, fileName: "tree_LOD3", withExtension: "usdz", maxDistance: 400.0) +``` + +**How it works:** +- LOD0 (highest detail) renders when camera is < 50 units away +- LOD1 renders between 50-100 units +- LOD2 renders between 100-200 units +- LOD3 (lowest detail) renders beyond 200 units + +### With Initial Mesh Loading + +You can also load an initial mesh synchronously before adding LOD levels: + +```swift +let tree = createEntity() + +// Load initial mesh synchronously (shows immediately) +setEntityMesh(entityId: tree, filename: "tree_LOD0", withExtension: "usdz") + +// Add LOD component +setEntityLodComponent(entityId: tree) + +// Add LOD levels (will replace initial mesh when ready) +addLODLevel(entityId: tree, lodIndex: 0, fileName: "tree_LOD0", withExtension: "usdz", maxDistance: 50.0) +addLODLevel(entityId: tree, lodIndex: 1, fileName: "tree_LOD1", withExtension: "usdz", maxDistance: 100.0) +addLODLevel(entityId: tree, lodIndex: 2, fileName: "tree_LOD2", withExtension: "usdz", maxDistance: 200.0) +addLODLevel(entityId: tree, lodIndex: 3, fileName: "tree_LOD3", withExtension: "usdz", maxDistance: 400.0) +``` + +### With Async Mesh Loading + +For better performance, use async loading: + +```swift +let tree = createEntity() + +// Load initial mesh asynchronously +setEntityMeshAsync(entityId: tree, filename: "tree_LOD0", withExtension: "usdz") { success in + if success { + print("Initial mesh loaded") + } +} + +// Add LOD component +setEntityLodComponent(entityId: tree) + +// Add LOD levels +addLODLevel(entityId: tree, lodIndex: 0, fileName: "tree_LOD0", withExtension: "usdz", maxDistance: 50.0) +addLODLevel(entityId: tree, lodIndex: 1, fileName: "tree_LOD1", withExtension: "usdz", maxDistance: 100.0) +addLODLevel(entityId: tree, lodIndex: 2, fileName: "tree_LOD2", withExtension: "usdz", maxDistance: 200.0) +addLODLevel(entityId: tree, lodIndex: 3, fileName: "tree_LOD3", withExtension: "usdz", maxDistance: 400.0) +``` + +## File Organization + +LOD files should be organized in subdirectories: + +``` +GameData/ +└── Models/ + ├── tree_LOD0/ + │ └── tree_LOD0.usdz + ├── tree_LOD1/ + │ └── tree_LOD1.usdz + ├── tree_LOD2/ + │ └── tree_LOD2.usdz + └── tree_LOD3/ + └── tree_LOD3.usdz +``` + +**Note:** Each LOD file should be in its own folder with the same name as the file (without extension). + +## API Reference + +### Core Functions + +#### `setEntityLodComponent(entityId:)` +Registers an LOD component on an entity. Call this before adding LOD levels. + +```swift +setEntityLodComponent(entityId: tree) +``` + +#### `addLODLevel(entityId:lodIndex:fileName:withExtension:maxDistance:completion:)` +Adds a single LOD level to an entity. + +**Parameters:** +- `entityId`: The entity to add LOD to +- `lodIndex`: LOD level index (0 = highest detail) +- `fileName`: Name of the mesh file (without extension) +- `withExtension`: File extension (e.g., "usdz") +- `maxDistance`: Maximum camera distance for this LOD +- `completion`: Optional callback when loading completes + +```swift +addLODLevel( + entityId: tree, + lodIndex: 0, + fileName: "tree_LOD0", + withExtension: "usdz", + maxDistance: 50.0 +) { success in + if success { + print("LOD0 loaded successfully") + } +} +``` + +#### `removeLODLevel(entityId:lodIndex:)` +Removes a specific LOD level from an entity. + +```swift +removeLODLevel(entityId: tree, lodIndex: 2) +``` + +#### `replaceLODLevel(entityId:lodIndex:fileName:withExtension:maxDistance:completion:)` +Replaces an existing LOD level with a new mesh. + +```swift +replaceLODLevel( + entityId: tree, + lodIndex: 1, + fileName: "tree_LOD1_new", + withExtension: "usdz", + maxDistance: 100.0 +) +``` + +#### `getLODLevelCount(entityId:) -> Int` +Returns the number of LOD levels for an entity. + +```swift +let count = getLODLevelCount(entityId: tree) +print("Entity has \(count) LOD levels") +``` + +## Advanced Usage + +### Custom Distance Thresholds + +Adjust distances based on your scene scale: + +```swift +// Small scene (indoor environment) +addLODLevel(entityId: prop, lodIndex: 0, fileName: "prop_LOD0", withExtension: "usdz", maxDistance: 10.0) +addLODLevel(entityId: prop, lodIndex: 1, fileName: "prop_LOD1", withExtension: "usdz", maxDistance: 20.0) + +// Large scene (outdoor landscape) +addLODLevel(entityId: mountain, lodIndex: 0, fileName: "mountain_LOD0", withExtension: "usdz", maxDistance: 500.0) +addLODLevel(entityId: mountain, lodIndex: 1, fileName: "mountain_LOD1", withExtension: "usdz", maxDistance: 1000.0) +addLODLevel(entityId: mountain, lodIndex: 2, fileName: "mountain_LOD2", withExtension: "usdz", maxDistance: 2000.0) +``` + +### LOD Configuration + +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 + +// Adjust hysteresis to prevent flickering +LODConfig.shared.hysteresis = 10.0 + +// Enable fade transitions between LODs - Not yet implemented +LODConfig.shared.enableFadeTransitions = true +LODConfig.shared.fadeTransitionTime = 0.5 // seconds +``` + +### Forced LOD Override + +Force a specific LOD level (useful for debugging): + +```swift +if let lodComponent = scene.get(component: LODComponent.self, for: tree) { + lodComponent.forcedLOD = 2 // Always show LOD2 + // lodComponent.forcedLOD = nil // Resume automatic LOD selection +} +``` + +### Programmatic LOD Management + +```swift +// Create entity with LOD component +let rock = createEntity() +setEntityLodComponent(entityId: rock) + +// Add LODs dynamically based on performance +let lodFiles = ["rock_LOD0", "rock_LOD1", "rock_LOD2"] +let distances: [Float] = [30.0, 60.0, 120.0] + +for (index, fileName) in lodFiles.enumerated() { + addLODLevel( + entityId: rock, + lodIndex: index, + fileName: fileName, + withExtension: "usdz", + maxDistance: distances[index] + ) +} + +// Check LOD count +let lodCount = getLODLevelCount(entityId: rock) +print("Rock has \(lodCount) LOD levels") + +// Remove highest detail LOD on low-end hardware +if isLowEndDevice { + removeLODLevel(entityId: rock, lodIndex: 0) +} +``` + +## Best Practices + +### Recommended LOD Counts +- **Small props**: 2-3 LODs +- **Characters**: 3-4 LODs +- **Vehicles**: 3-4 LODs +- **Buildings**: 4-5 LODs +- **Terrain**: 5-8 LODs + +### Polygon Reduction Guidelines +- **LOD0** (full detail): 100% polygons +- **LOD1**: ~50% polygon reduction +- **LOD2**: ~75% polygon reduction +- **LOD3**: ~90% polygon reduction or billboard + +### Distance Thresholds +Base distances on object importance and size: +- **Hero objects**: Longer high-detail distance +- **Background objects**: Shorter high-detail distance +- **Large objects**: Visible from farther away, need more LODs + +### Performance Tips +1. Always use async loading (`setEntityMeshAsync`) for better performance +2. Keep LOD0 for objects within 50 units of camera +3. Use billboards or impostors for very distant objects (LOD3+) +4. Test LOD transitions in-game to ensure smooth visual quality +5. Use `forcedLOD` during development to preview each LOD level + +## Troubleshooting + +### LODs Not Switching +- Verify LOD component is registered: `hasComponent(entityId: tree, componentType: LODComponent.self)` +- Check distance thresholds are set correctly +- 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 +- Adjust LOD bias for smoother transitions + +### File Not Found Errors +- Verify file organization follows the subdirectory structure +- Check file names match exactly (case-sensitive) +- Ensure files are in the correct `GameData/Models/` path + +### Performance Issues +- Reduce number of LOD levels for less important objects +- Increase distance thresholds to switch LODs sooner +- Use LOD bias > 1.0 for performance mode + +## Example: Complete LOD Setup + +```swift +import UntoldEngine + +// Create multiple trees with LODs +var trees: [EntityID] = [] + +for i in 0..<10 { + let tree = createEntity() + setEntityName(entityId: tree, name: "Tree_\(i)") + + // Position trees + translateTo(entityId: tree, position: simd_float3(Float(i * 10), 0, 0)) + + // Add LOD component + setEntityLodComponent(entityId: tree) + + // Add 4 LOD levels + addLODLevel(entityId: tree, lodIndex: 0, fileName: "tree_LOD0", withExtension: "usdz", maxDistance: 50.0) + addLODLevel(entityId: tree, lodIndex: 1, fileName: "tree_LOD1", withExtension: "usdz", maxDistance: 100.0) + addLODLevel(entityId: tree, lodIndex: 2, fileName: "tree_LOD2", withExtension: "usdz", maxDistance: 200.0) + addLODLevel(entityId: tree, lodIndex: 3, fileName: "tree_LOD3", withExtension: "usdz", maxDistance: 400.0) + + trees.append(tree) +} + +// Configure LOD system for this scene +LODConfig.shared.lodBias = 1.2 // Slightly favor performance +LODConfig.shared.hysteresis = 8.0 // Prevent flickering +LODConfig.shared.enableFadeTransitions = true + +print("Created \(trees.count) trees with LOD support") +``` + From cf4c2eac0b03fcb030bb53e2e7c4f3438d6243a5 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 4 Feb 2026 00:07:21 -0700 Subject: [PATCH 2/2] [Feature] Added static batching support --- Sources/UntoldEngine/ECS/Components.swift | 10 + .../UntoldEngine/Renderer/RenderPasses.swift | 336 +++++++++++++ .../UntoldEngine/Scenes/SceneSerializer.swift | 56 +++ .../UntoldEngine/Systems/BatchingSystem.swift | 421 ++++++++++++++++ .../Systems/RegistrationSystem.swift | 92 ++++ .../Systems/RenderingSystem.swift | 23 +- Sources/UntoldEngine/Utils/FuncUtils.swift | 19 + .../RenderGraphBuilderTest.swift | 12 +- .../SceneSerializerTest.swift | 274 +++++++++++ .../StaticBatchingTest.swift | 459 ++++++++++++++++++ .../UsingStaticBatchingSystem.md | 303 ++++++++++++ 11 files changed, 1997 insertions(+), 8 deletions(-) create mode 100644 Sources/UntoldEngine/Systems/BatchingSystem.swift create mode 100644 Tests/UntoldEngineRenderTests/StaticBatchingTest.swift create mode 100644 docs/04-Engine Development/03-Engine Systems/UsingStaticBatchingSystem.md diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index e2131312..b1b04459 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -299,6 +299,16 @@ public class LODComponent: Component { public required init() {} } +// MARK: Static Batching Component + +public class StaticBatchComponent: Component { + public var isStatic: Bool = true // Object doesn't move + public var batchGroupId: UUID? // Assigned during batching + public var canBatch: Bool = true // User can disable batching per object + + public required init() {} +} + // MARK: - USC Scripting Component public class ScriptComponent: Component, Codable { diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index fb4c9d20..12b7c8f7 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -273,6 +273,11 @@ public enum RenderPasses { // Skip entities that are pending destroy if scene.mask(for: entityId) == nil { continue } + // Skip batched entities if batching is enabled + if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { + continue + } + if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } @@ -381,6 +386,122 @@ public enum RenderPasses { renderEncoder.updateFence(renderInfo.fence, after: .fragment) } + public static let batchedShadowExecution: (MTLCommandBuffer) -> Void = { commandBuffer in + // Skip if batching is disabled or no batches exist + guard BatchingSystem.shared.isEnabled(), + !BatchingSystem.shared.batchGroups.isEmpty + else { return } + + guard let shadowPipeline = PipelineManager.shared.renderPipelinesByType[.shadow] else { + handleError(.pipelineStateNulled, "shadowPipeline is nil") + return + } + + if shadowPipeline.success == false { + handleError(.pipelineStateNulled, shadowPipeline.name!) + return + } + + // Shadow system should already be updated by shadowExecution + guard let dirLight = shadowSystem.dirLightSpaceMatrix else { return } + + guard let shadowDescriptor = renderInfo.shadowRenderPassDescriptor else { + handleError(.renderPassCreationFailed, "Shadow render pass descriptor not initialized") + return + } + + // Reuse existing shadow descriptor (already configured by shadowExecution) + shadowDescriptor.depthAttachment.loadAction = .load + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: shadowDescriptor) + else { + handleError(.renderPassCreationFailed, "Batched Shadow Pass") + return + } + + defer { + renderEncoder.popDebugGroup() + renderEncoder.endEncoding() + } + + renderEncoder.label = "Batched Shadow Pass" + renderEncoder.pushDebugGroup("Batched Shadow Pass") + + renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) + renderEncoder.setDepthStencilState(shadowPipeline.depthState!) + renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + + renderEncoder.setDepthBias(0.005, slopeScale: 1.0, clamp: 1.0) + renderEncoder.setViewport( + MTLViewport(originX: 0.0, originY: 0.0, width: Double(shadowResolution.x), height: Double(shadowResolution.y), znear: 0.0, zfar: 1.0)) + + // Set light space matrix (same as shadowExecution) + renderEncoder.setVertexBytes( + &shadowSystem.dirLightSpaceMatrix, length: MemoryLayout.stride, + index: Int(shadowPassLightMatrixUniform.rawValue) + ) + + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { + handleError(.noActiveCamera) + return + } + + // Create identity uniform (batched vertices are already in world space) + var batchUniforms = Uniforms() + let viewMatrix = cameraComponent.viewSpace + let modelMatrix = matrix_identity_float4x4 + let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) + + batchUniforms.modelMatrix = modelMatrix + batchUniforms.viewMatrix = viewMatrix + batchUniforms.modelViewMatrix = modelViewMatrix + batchUniforms.normalMatrix = matrix_identity_float3x3 + batchUniforms.cameraPosition = cameraComponent.localPosition + batchUniforms.projectionMatrix = renderInfo.perspectiveSpace + + // Render each batch group (shadows only need positions) + for batchGroup in BatchingSystem.shared.batchGroups { + guard let positionBuffer = batchGroup.positionBuffer, + let indexBuffer = batchGroup.indexBuffer + else { continue } + + // Set uniforms + renderEncoder.setVertexBytes( + &batchUniforms, + length: MemoryLayout.stride, + index: Int(shadowPassModelUniform.rawValue) + ) + + // Set position buffer (shadows only need positions) + renderEncoder.setVertexBuffer(positionBuffer, offset: 0, index: Int(shadowPassModelPositionIndex.rawValue)) + + // Static batched objects don't have armature + var hasArmature = false + renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(shadowPassHasArmature.rawValue)) + + // Set dummy joint buffers (shader expects them even when hasArmature is false) + renderEncoder.setVertexBuffer(positionBuffer, offset: 0, index: Int(shadowPassJointIdIndex.rawValue)) // Dummy + renderEncoder.setVertexBuffer(positionBuffer, offset: 0, index: Int(shadowPassJointWeightsIndex.rawValue)) // Dummy + + // Dummy joint transform buffer + var identityMatrix = matrix_identity_float4x4 + renderEncoder.setVertexBytes(&identityMatrix, length: MemoryLayout.stride, index: Int(shadowPassJointTransformIndex.rawValue)) + + // SINGLE SHADOW DRAW CALL FOR ENTIRE BATCH + renderEncoder.drawIndexedPrimitives( + type: .triangle, + indexCount: batchGroup.indexCount, + indexType: .uint32, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } + + renderEncoder.updateFence(renderInfo.fence, after: .fragment) + } + public static let modelExecution: (MTLCommandBuffer) -> Void = { commandBuffer in guard let modelPipeline = PipelineManager.shared.renderPipelinesByType[.model] else { handleError(.pipelineStateNulled, "modelPipeline is nil") @@ -452,6 +573,11 @@ public enum RenderPasses { // Skip entities that are pending destroy if scene.mask(for: entityId) == nil { continue } + // Skip batched entities if batching is enabled + if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { + continue + } + if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } @@ -583,6 +709,12 @@ public enum RenderPasses { renderEncoder.setFragmentSamplerState(subMesh.material?.roughness.sampler, index: Int(modelPassMaterialSamplerIndex.rawValue)) + // set metallic + renderEncoder.setFragmentTexture( + subMesh.material?.metallic.texture, index: Int(modelPassMetallicTextureIndex.rawValue) + ) + + // set normal // set normal var hasNormal: Bool = ((subMesh.material?.normal.texture) != nil) renderEncoder.setFragmentBytes( @@ -639,6 +771,210 @@ public enum RenderPasses { renderEncoder.updateFence(renderInfo.fence, after: .fragment) } + public static let batchedModelExecution: (MTLCommandBuffer) -> Void = { commandBuffer in + // Skip if batching is disabled or no batches exist + guard BatchingSystem.shared.isEnabled(), + !BatchingSystem.shared.batchGroups.isEmpty + else { + return + } + + guard let modelPipeline = PipelineManager.shared.renderPipelinesByType[.model] else { + handleError(.pipelineStateNulled, "modelPipeline is nil") + return + } + + if modelPipeline.success == false { + handleError(.pipelineStateNulled, modelPipeline.name!) + return + } + + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { + handleError(.noActiveCamera) + return + } + + guard let encoderDescriptor = renderInfo.offscreenRenderPassDescriptor else { + handleError(.renderPassCreationFailed, "Offscreen render pass descriptor not initialized") + return + } + + // Reuse existing offscreen descriptor (already configured by modelExecution) + encoderDescriptor.colorAttachments[Int(colorTarget.rawValue)].loadAction = .load + encoderDescriptor.colorAttachments[Int(normalTarget.rawValue)].loadAction = .load + encoderDescriptor.colorAttachments[Int(positionTarget.rawValue)].loadAction = .load + encoderDescriptor.colorAttachments[Int(materialTarget.rawValue)].loadAction = .load + encoderDescriptor.colorAttachments[Int(emissiveTarget.rawValue)].loadAction = .load + encoderDescriptor.depthAttachment.loadAction = .load + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: encoderDescriptor) + else { + handleError(.renderPassCreationFailed, "Batched Model Pass") + return + } + + defer { + renderEncoder.popDebugGroup() + renderEncoder.endEncoding() + } + + renderEncoder.label = "Batched Model Pass" + renderEncoder.pushDebugGroup("Batched Model Pass") + + renderEncoder.setRenderPipelineState(modelPipeline.pipelineState!) + renderEncoder.setDepthStencilState(modelPipeline.depthState) + renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + + // Create identity uniform (batched vertices are already in world space) + var batchUniforms = Uniforms() + let viewMatrix = cameraComponent.viewSpace + let modelMatrix = matrix_identity_float4x4 + let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) // Same calculation as modelExecution + let upperModelMatrix: matrix_float3x3 = matrix3x3_upper_left(modelMatrix) + + let inverseUpperModelMatrix: matrix_float3x3 = upperModelMatrix.inverse + + let normalMatrix: matrix_float3x3 = inverseUpperModelMatrix.transpose + /* + // update uniforms + var modelUniforms = Uniforms() + + var modelMatrix = simd_mul(worldTransformComponent.space, mesh.localSpace) + + let viewMatrix: simd_float4x4 = cameraComponent.viewSpace + + let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) + + let upperModelMatrix: matrix_float3x3 = matrix3x3_upper_left(modelMatrix) + + let inverseUpperModelMatrix: matrix_float3x3 = upperModelMatrix.inverse + + let normalMatrix: matrix_float3x3 = inverseUpperModelMatrix.transpose + */ + + batchUniforms.modelMatrix = modelMatrix + batchUniforms.viewMatrix = viewMatrix + batchUniforms.modelViewMatrix = modelViewMatrix + batchUniforms.normalMatrix = normalMatrix + batchUniforms.cameraPosition = cameraComponent.localPosition + batchUniforms.projectionMatrix = renderInfo.perspectiveSpace + + // Render each batch group + for batchGroup in BatchingSystem.shared.batchGroups { + guard let positionBuffer = batchGroup.positionBuffer, + let normalBuffer = batchGroup.normalBuffer, + let uvBuffer = batchGroup.uvBuffer, + let tangentBuffer = batchGroup.tangentBuffer, + let indexBuffer = batchGroup.indexBuffer + else { continue } + + // Get material from first entity in batch + guard let firstEntityId = batchGroup.entityIds.first, + let renderComponent = scene.get(component: RenderComponent.self, for: firstEntityId), + let firstMesh = renderComponent.mesh.first, + let firstSubmesh = firstMesh.submeshes.first, + let material = firstSubmesh.material + else { continue } + + // Set uniforms + renderEncoder.setVertexBytes( + &batchUniforms, + length: MemoryLayout.stride, + index: Int(modelPassUniformIndex.rawValue) + ) + + // Set vertex buffers - now using separate buffers for each attribute + renderEncoder.setVertexBuffer(positionBuffer, offset: 0, index: Int(modelPassVerticesIndex.rawValue)) + renderEncoder.setVertexBuffer(normalBuffer, offset: 0, index: Int(modelPassNormalIndex.rawValue)) + renderEncoder.setVertexBuffer(uvBuffer, offset: 0, index: Int(modelPassUVIndex.rawValue)) + renderEncoder.setVertexBuffer(tangentBuffer, offset: 0, index: Int(modelPassTangentIndex.rawValue)) + + // Static batched objects don't have armature, but shader expects these buffers + // Set dummy buffers for joint data to avoid validation errors + renderEncoder.setVertexBuffer(positionBuffer, offset: 0, index: Int(modelPassJointIdIndex.rawValue)) // Dummy buffer + renderEncoder.setVertexBuffer(positionBuffer, offset: 0, index: Int(modelPassJointWeightsIndex.rawValue)) // Dummy buffer + + // Create a minimal dummy joint transform buffer (single identity matrix) + // This is required because Metal validation doesn't allow nil buffers + var identityMatrix = matrix_identity_float4x4 + renderEncoder.setVertexBytes(&identityMatrix, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue)) + + // No armature for static batched objects + var hasArmature = false + renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue)) + + // Set fragment uniforms + renderEncoder.setFragmentBytes( + &batchUniforms, + length: MemoryLayout.stride, + index: Int(modelPassFragmentUniformIndex.rawValue) + ) + + // Set material properties (same as normal rendering) + 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)) + + // Set metallic texture + renderEncoder.setFragmentTexture(material.metallic.texture, index: Int(modelPassMetallicTextureIndex.rawValue)) + + var hasNormal: Bool = (material.normal.texture != nil) + renderEncoder.setFragmentBytes(&hasNormal, length: MemoryLayout.stride, index: Int(modelPassFragmentHasNormalTextureIndex.rawValue)) + Logger.log(message: " 🎨 Material baseColor: \(material.baseColorValue)") + 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.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 + ) + + 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)) + + // SINGLE DRAW CALL FOR ENTIRE BATCH + Logger.log(message: "✅ Drawing batch \(batchGroup.id): \(batchGroup.indexCount) indices, \(batchGroup.vertexCount) vertices") + renderEncoder.drawIndexedPrimitives( + type: .triangle, + indexCount: batchGroup.indexCount, + indexType: .uint32, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } + + renderEncoder.updateFence(renderInfo.fence, after: .fragment) + } + static let ssaoExecution: (MTLCommandBuffer) -> Void = { commandBuffer in guard let camera = CameraSystem.shared.activeCamera, let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { handleError(.noActiveCamera) diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index bfdc7af0..68f728b7 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -181,6 +181,7 @@ struct EntityData: Codable { var hasAreaLightComponent: Bool? var hasCameraComponent: Bool? var hasLODComponent: Bool? + var hasStaticBatchComponent: Bool? var customComponents: [String: Data]? = nil @@ -437,6 +438,28 @@ public func serializeScene() -> SceneData { } } + // Static Batch properties + // Check if entity or any of its children have StaticBatchComponent + func hasStaticBatchInHierarchy(entityId: EntityID) -> Bool { + // Check self + if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { + return true + } + // Check children recursively + let children = getEntityChildren(parentId: entityId) + for child in children { + if hasStaticBatchInHierarchy(entityId: child) { + return true + } + } + return false + } + + // Only set the flag if true (leave as nil otherwise) + if hasStaticBatchInHierarchy(entityId: entityId) { + entityData.hasStaticBatchComponent = true + } + // custom component var customComponents: [String: Data] = [:] @@ -717,6 +740,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM // Apply overrides synchronously after import applyAssetInstanceOverrides(entityId: entityId, overrides: assetInstance.overrides) + // Restore Static Batch Component (sync mode - mesh already loaded) + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } + // Setup animations (skeleton is now available) if sceneDataEntity.hasAnimationComponent == true { for animations in sceneDataEntity.animations { @@ -738,6 +766,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM // Apply overrides after async import completes applyAssetInstanceOverrides(entityId: entityId, overrides: assetInstance.overrides) + // Restore Static Batch Component (meshes now loaded) + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } + // Setup animations (skeleton is now available) if sceneDataEntity.hasAnimationComponent == true { for animations in sceneDataEntity.animations { @@ -768,9 +801,19 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName) setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: sceneDataEntity.assetName) applyLocalTransform() + + // Restore Static Batch Component (procedural mesh already loaded) + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } } else { setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) applyLocalTransform() + + // Restore Static Batch Component (sync mode - mesh already loaded) + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } } // Setup animations (skeleton is now available) @@ -790,6 +833,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName) setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: sceneDataEntity.assetName) applyLocalTransform() + + // Restore Static Batch Component (procedural mesh already loaded) + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } } else { let fallbackLabel = withExtension.isEmpty ? filename : "\(filename).\(withExtension)" let meshLabel = sceneDataEntity.name.isEmpty ? fallbackLabel : sceneDataEntity.name @@ -800,6 +848,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM if success { Logger.log(message: "✅ Mesh loaded for \(meshLabel)") + // Restore Static Batch Component (mesh now loaded) + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } + // Setup animations (skeleton is now available) if sceneDataEntity.hasAnimationComponent == true { for animations in sceneDataEntity.animations { @@ -1091,6 +1144,9 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } } + // Static Batch Component is now restored inside mesh loading completion handlers + // (moved there to ensure RenderComponent exists before adding StaticBatchComponent) + // custom components if let customComponents = sceneDataEntity.customComponents { for (typeName, jsonData) in customComponents { diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift new file mode 100644 index 00000000..9ca19021 --- /dev/null +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -0,0 +1,421 @@ +// +// BatchingSystem.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import CShaderTypes +import Foundation +import Metal +import simd + +// Represents a group of meshes batched together +public struct BatchGroup { + var id: UUID + var materialHash: String // Identifier for material compatibility + + // Separate buffers for each vertex attribute (simpler than interleaved) + var positionBuffer: MTLBuffer? + var normalBuffer: MTLBuffer? + var uvBuffer: MTLBuffer? + var tangentBuffer: MTLBuffer? + var indexBuffer: MTLBuffer? + + var indexCount: Int + var vertexCount: Int + 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) +} + +// Manages all batching operations +public class BatchingSystem { + public static let shared = BatchingSystem() + + public private(set) var batchGroups: [BatchGroup] = [] + private var entityToBatch: [EntityID: UUID] = [:] // Track which batch an entity belongs to + private var batchingEnabled: Bool = false + + private init() {} + + // Generate batches for all static entities in the scene + public func generateBatches() { + Logger.log(message: "🔨 Starting static batch generation...") + + // Update all world transforms before batching + // (batching needs accurate world positions) + traverseSceneGraph() + + // Clear existing batches + clearBatches() + + let transformId = getComponentId(for: WorldTransformComponent.self) + let renderId = getComponentId(for: RenderComponent.self) + let staticBatchId = getComponentId(for: StaticBatchComponent.self) + let entities = queryEntitiesWithComponentIds([transformId, renderId, staticBatchId], in: scene) + + Logger.log(message: "📋 Found \(entities.count) entities with StaticBatchComponent") + + // Group meshes by material + var materialGroups: [String: [(entityId: EntityID, mesh: Mesh, meshIndex: Int, transform: simd_float4x4)]] = [:] + + // Iterate through all entities with StaticBatchComponent + for entityId in entities { + // Check if entity has required components + guard let staticBatch = scene.get(component: StaticBatchComponent.self, for: entityId), + staticBatch.canBatch, + let renderComponent = scene.get(component: RenderComponent.self, for: entityId), + let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId), + let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) + else { continue } + + // Skip entities with animations + if scene.get(component: SkeletonComponent.self, for: entityId) != nil { continue } + if scene.get(component: AnimationComponent.self, for: entityId) != nil { continue } + + // Skip gizmos and special entities + if scene.get(component: GizmoComponent.self, for: entityId) != nil { continue } + if scene.get(component: LightComponent.self, for: entityId) != nil { continue } + + // Process each mesh in the render component + for (meshIndex, mesh) in renderComponent.mesh.enumerated() { + for (submeshIndex, submesh) in mesh.submeshes.enumerated() { + guard let material = submesh.material else { continue } + + let matHash = getMaterialHash(material: material) + let finalTransform = simd_mul(worldTransform.space, mesh.localSpace) + + if materialGroups[matHash] == nil { + materialGroups[matHash] = [] + } + materialGroups[matHash]!.append(( + entityId: entityId, + mesh: mesh, + meshIndex: submeshIndex, // Store submesh index for later use + transform: finalTransform + )) + + Logger.log(message: " → Entity \(entityId): mesh[\(meshIndex)].submesh[\(submeshIndex)] with material hash \(matHash.prefix(8))...") + } + } + } + + Logger.log(message: "📦 Found \(materialGroups.count) material groups") + + // Log material group details + for (matHash, meshGroup) in materialGroups { + Logger.log(message: " Material \(matHash.prefix(8))... has \(meshGroup.count) submeshes") + } + + // Create batch groups + for (matHash, meshGroup) in materialGroups { + // Only batch if we have multiple meshes with same material + if meshGroup.count < 2 { + Logger.log(message: "⏭️ Skipping material \(matHash.prefix(8))... (only \(meshGroup.count) submesh, need ≥2 for batching)") + continue + } + + Logger.log(message: "🔗 Batching \(meshGroup.count) meshes with material hash: \(matHash.prefix(20))...") + + if let batchGroup = createBatchGroup(from: meshGroup, materialHash: matHash) { + batchGroups.append(batchGroup) + + // Track entity to batch mapping + for item in meshGroup { + entityToBatch[item.entityId] = batchGroup.id + } + } + } + + Logger.log(message: "✅ Created \(batchGroups.count) batch groups") + + // Print statistics + let totalBatchedMeshes = batchGroups.reduce(0) { $0 + $1.entityIds.count } + Logger.log(message: "📊 Batching Stats: \(totalBatchedMeshes) meshes → \(batchGroups.count) draw calls") + } + + private func createBatchGroup( + from meshGroup: [(entityId: EntityID, mesh: Mesh, meshIndex: Int, transform: simd_float4x4)], + materialHash: String + ) -> BatchGroup? { + var allPositions: [simd_float4] = [] // Changed to float4 to match vertex descriptor + var allNormals: [simd_float4] = [] // Changed to float4 to match vertex descriptor + var allUVs: [simd_float2] = [] + var allTangents: [simd_float4] = [] // Changed to float4 to match vertex descriptor + var allIndices: [UInt32] = [] + var entityIds: [EntityID] = [] + var meshIndices: [(EntityID, Int)] = [] + + var minBounds = simd_float3(Float.infinity, Float.infinity, Float.infinity) + var maxBounds = simd_float3(-Float.infinity, -Float.infinity, -Float.infinity) + + // Combine all meshes + for item in meshGroup { + let currentVertexOffset = UInt32(allPositions.count) + + // Extract and transform vertices (returns separate arrays) + let vertexData = extractVertices(from: item.mesh, worldTransform: item.transform) + + allPositions.append(contentsOf: vertexData.positions) + allNormals.append(contentsOf: vertexData.normals) + allUVs.append(contentsOf: vertexData.uvs) + allTangents.append(contentsOf: vertexData.tangents) + + // Update bounding box (extract xyz from float4 positions) + for position in vertexData.positions { + let pos3 = simd_float3(position.x, position.y, position.z) + minBounds = simd_min(minBounds, pos3) + maxBounds = simd_max(maxBounds, pos3) + } + + // Extract indices with offset + let indices = extractIndices(from: item.mesh, submeshIndex: item.meshIndex, indexOffset: currentVertexOffset) + allIndices.append(contentsOf: indices) + + entityIds.append(item.entityId) + meshIndices.append((item.entityId, item.meshIndex)) + } + + guard !allPositions.isEmpty, !allIndices.isEmpty else { + Logger.logWarning(message: "Failed to create batch: no vertices or indices") + return nil + } + + // Create separate Metal buffers for each attribute (using float4 for positions, normals, tangents) + let positionBufferSize = allPositions.count * MemoryLayout.stride + let normalBufferSize = allNormals.count * MemoryLayout.stride + let uvBufferSize = allUVs.count * MemoryLayout.stride + let tangentBufferSize = allTangents.count * MemoryLayout.stride + let indexBufferSize = allIndices.count * MemoryLayout.stride + + guard let positionBuffer = renderInfo.device.makeBuffer( + bytes: allPositions, + length: positionBufferSize, + options: .storageModeShared + ) else { + Logger.logError(message: "Failed to create batch position buffer") + return nil + } + + guard let normalBuffer = renderInfo.device.makeBuffer( + bytes: allNormals, + length: normalBufferSize, + options: .storageModeShared + ) else { + Logger.logError(message: "Failed to create batch normal buffer") + return nil + } + + guard let uvBuffer = renderInfo.device.makeBuffer( + bytes: allUVs, + length: uvBufferSize, + options: .storageModeShared + ) else { + Logger.logError(message: "Failed to create batch UV buffer") + return nil + } + + guard let tangentBuffer = renderInfo.device.makeBuffer( + bytes: allTangents, + length: tangentBufferSize, + options: .storageModeShared + ) else { + Logger.logError(message: "Failed to create batch tangent buffer") + return nil + } + + guard let indexBuffer = renderInfo.device.makeBuffer( + bytes: allIndices, + length: indexBufferSize, + options: .storageModeShared + ) else { + Logger.logError(message: "Failed to create batch index buffer") + return nil + } + + positionBuffer.label = "Batch Position Buffer" + normalBuffer.label = "Batch Normal Buffer" + uvBuffer.label = "Batch UV Buffer" + tangentBuffer.label = "Batch Tangent Buffer" + indexBuffer.label = "Batch Index Buffer" + + Logger.log(message: " ✅ Created batch buffers: \(allPositions.count) positions, \(allIndices.count) indices") + Logger.log(message: " Bounds: min=\(minBounds), max=\(maxBounds)") + + return BatchGroup( + id: UUID(), + materialHash: materialHash, + positionBuffer: positionBuffer, + normalBuffer: normalBuffer, + uvBuffer: uvBuffer, + tangentBuffer: tangentBuffer, + indexBuffer: indexBuffer, + indexCount: allIndices.count, + vertexCount: allPositions.count, + entityIds: entityIds, + meshIndices: meshIndices, + boundingBox: (min: minBounds, max: maxBounds) + ) + } + + // Clear all existing batches + public func clearBatches() { + batchGroups.removeAll() + entityToBatch.removeAll() + } + + // Check if an entity is part of a batch + public func isBatched(entityId: EntityID) -> Bool { + entityToBatch[entityId] != nil + } + + // Get batch group for an entity + public func getBatchGroup(for entityId: EntityID) -> BatchGroup? { + guard let batchId = entityToBatch[entityId] else { return nil } + return batchGroups.first { $0.id == batchId } + } + + public func setEnabled(_ enabled: Bool) { + batchingEnabled = enabled + } + + public func isEnabled() -> Bool { + batchingEnabled + } + + // Generate a hash representing material properties for batching compatibility + private func getMaterialHash(material: Material) -> String { + var components: [String] = [] + + // Texture URLs (or "none" if no texture) + components.append(material.baseColorURL?.absoluteString ?? "none") + components.append(material.roughnessURL?.absoluteString ?? "none") + components.append(material.metallicURL?.absoluteString ?? "none") + components.append(material.normalURL?.absoluteString ?? "none") + + // Base color value (important for meshes without textures) + components.append(String(format: "%.2f,%.2f,%.2f,%.2f", + material.baseColorValue.x, + material.baseColorValue.y, + material.baseColorValue.z, + material.baseColorValue.w)) + + // Material values (rounded to avoid tiny differences) + components.append(String(format: "%.2f", material.roughnessValue)) + components.append(String(format: "%.2f", material.metallicValue)) + components.append(String(format: "%.2f", material.specular)) + components.append(String(format: "%.2f", material.ior)) + + // Flags + components.append("\(material.interactWithLight)") + + return components.joined(separator: "|") + } + + // Check if two materials are compatible for batching + private func areMaterialsCompatible(_ mat1: Material, _ mat2: Material) -> Bool { + getMaterialHash(material: mat1) == getMaterialHash(material: mat2) + } + + // Extract vertex data from a mesh and transform to world space + // Returns separate arrays for each attribute (using float4 to match shader expectations) + private func extractVertices(from mesh: Mesh, worldTransform: simd_float4x4) -> ( + positions: [simd_float4], + normals: [simd_float4], + uvs: [simd_float2], + tangents: [simd_float4] + ) { + var positions: [simd_float4] = [] + var normals: [simd_float4] = [] + var uvs: [simd_float2] = [] + var tangents: [simd_float4] = [] + + let metalMesh = mesh.metalKitMesh + + // Get vertex buffers + guard metalMesh.vertexBuffers.count > Int(modelPassVerticesIndex.rawValue) else { + return (positions, normals, uvs, tangents) + } + + let positionBuffer = metalMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer + let normalBuffer = metalMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer + let uvBuffer = metalMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer + let tangentBuffer = metalMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer + + // Get vertex count + guard mesh.submeshes.first != nil else { return (positions, normals, uvs, tangents) } + let vertexCount = metalMesh.vertexCount + + // Extract raw data (Metal vertex buffers use float4 for positions, normals, tangents) + let posData = positionBuffer.contents().bindMemory(to: simd_float4.self, capacity: vertexCount) + let normData = normalBuffer.contents().bindMemory(to: simd_float4.self, capacity: vertexCount) + let uvData = uvBuffer.contents().bindMemory(to: simd_float2.self, capacity: vertexCount) + let tanData = tangentBuffer.contents().bindMemory(to: simd_float4.self, capacity: vertexCount) + + // Calculate normal transformation matrix (inverse transpose of upper 3x3) + let upperModelMatrix = matrix_float3x3(columns: ( + simd_float3(worldTransform.columns.0.x, worldTransform.columns.0.y, worldTransform.columns.0.z), + simd_float3(worldTransform.columns.1.x, worldTransform.columns.1.y, worldTransform.columns.1.z), + simd_float3(worldTransform.columns.2.x, worldTransform.columns.2.y, worldTransform.columns.2.z) + )) + let normalMatrix = upperModelMatrix.inverse.transpose + + // Reserve capacity + positions.reserveCapacity(vertexCount) + normals.reserveCapacity(vertexCount) + uvs.reserveCapacity(vertexCount) + tangents.reserveCapacity(vertexCount) + + // Transform and append + for i in 0 ..< vertexCount { + // Transform position to world space (keep as float4) + let localPos = posData[i] // Already float4 with w component + let worldPos = worldTransform * localPos + positions.append(worldPos) + + // Transform normal to world space (extract xyz, transform, then pack as float4 with w=0) + let localNorm = simd_float3(normData[i].x, normData[i].y, normData[i].z) + let worldNormal = simd_normalize(normalMatrix * localNorm) + normals.append(simd_float4(worldNormal.x, worldNormal.y, worldNormal.z, 0.0)) + + // UVs don't need transformation + uvs.append(uvData[i]) + + // Transform tangent to world space (extract xyz, transform, preserve w) + let localTan = simd_float3(tanData[i].x, tanData[i].y, tanData[i].z) + let worldTangent = simd_normalize(normalMatrix * localTan) + tangents.append(simd_float4(worldTangent.x, worldTangent.y, worldTangent.z, tanData[i].w)) + } + + return (positions, normals, uvs, tangents) + } + + // Extract indices from a mesh with offset applied + private func extractIndices(from mesh: Mesh, submeshIndex: Int, indexOffset: UInt32) -> [UInt32] { + var indices: [UInt32] = [] + + guard submeshIndex < mesh.submeshes.count else { return indices } + let submesh = mesh.submeshes[submeshIndex] + + let indexBuffer = submesh.metalKitSubmesh.indexBuffer.buffer + let indexCount = submesh.metalKitSubmesh.indexCount + let indexType = submesh.metalKitSubmesh.indexType + + if indexType == .uint16 { + let rawIndices = indexBuffer.contents().bindMemory(to: UInt16.self, capacity: indexCount) + for i in 0 ..< indexCount { + indices.append(UInt32(rawIndices[i]) + indexOffset) + } + } else if indexType == .uint32 { + let rawIndices = indexBuffer.contents().bindMemory(to: UInt32.self, capacity: indexCount) + for i in 0 ..< indexCount { + indices.append(rawIndices[i] + indexOffset) + } + } + + return indices + } +} diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index d0cd0ba1..a19c14eb 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -70,6 +70,12 @@ func finalizePendingDestroys() { removeEntityScenegraph(entityId: entityId) removeEntityName(entityId: entityId) removeEntityLight(entityId: entityId) + removeEntityStaticBatch(entityId: entityId) + removeEntityLOD(entityId: entityId) + removeEntityGaussian(entityId: entityId) + removeEntityCamera(entityId: entityId) + removeEntityAssetInstance(entityId: entityId) + removeEntityScript(entityId: entityId) } scene.finalizePendingDestroys() @@ -1084,6 +1090,92 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio } } +// MARK: Static Batching + +public func setEntityStaticBatchComponent(entityId: EntityID) { + // Only process entities with RenderComponent (skip empty parent entities) + if let _ = scene.get(component: RenderComponent.self, for: entityId) { + if !hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { + registerComponent(entityId: entityId, componentType: StaticBatchComponent.self) + Logger.log(message: "✅ StaticBatchComponent registered for entity \(entityId)") + } else { + Logger.logWarning(message: "StaticBatchComponent already exists on entity \(entityId)") + } + } + + // Recursively mark all children as static + let children = getEntityChildren(parentId: entityId) + for childId in children { + setEntityStaticBatchComponent(entityId: childId) + } +} + +public func removeEntityStaticBatchComponent(entityId: EntityID) { + // Remove from this entity if it has the component + if let _ = scene.get(component: StaticBatchComponent.self, for: entityId) { + scene.remove(component: StaticBatchComponent.self, from: entityId) + Logger.log(message: "✅ StaticBatchComponent removed from entity \(entityId)") + } + + // Recursively remove from all children + let children = getEntityChildren(parentId: entityId) + for childId in children { + removeEntityStaticBatchComponent(entityId: childId) + } +} + +// Internal cleanup function for entity destruction (non-recursive, called per entity) +func removeEntityStaticBatch(entityId: EntityID) { + if scene.get(component: StaticBatchComponent.self, for: entityId) != nil { + scene.remove(component: StaticBatchComponent.self, from: entityId) + } +} + +func removeEntityLOD(entityId: EntityID) { + if let lodComponent = scene.get(component: LODComponent.self, for: entityId) { + // Clear LOD levels (meshes will be cleaned up by RenderComponent cleanup) + lodComponent.lodLevels.removeAll() + scene.remove(component: LODComponent.self, from: entityId) + } +} + +func removeEntityGaussian(entityId: EntityID) { + if let gaussianComponent = scene.get(component: GaussianComponent.self, for: entityId) { + // Release Metal buffers + gaussianComponent.splatData = nil + gaussianComponent.gaussianSortedIndices = nil + gaussianComponent.spaceUniform.removeAll() + scene.remove(component: GaussianComponent.self, from: entityId) + } +} + +func removeEntityCamera(entityId: EntityID) { + if scene.get(component: CameraComponent.self, for: entityId) != nil { + scene.remove(component: CameraComponent.self, from: entityId) + } + if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { + scene.remove(component: SceneCameraComponent.self, from: entityId) + } +} + +func removeEntityAssetInstance(entityId: EntityID) { + if scene.get(component: AssetInstanceComponent.self, for: entityId) != nil { + scene.remove(component: AssetInstanceComponent.self, from: entityId) + } + if scene.get(component: DerivedAssetNodeComponent.self, for: entityId) != nil { + scene.remove(component: DerivedAssetNodeComponent.self, from: entityId) + } +} + +func removeEntityScript(entityId: EntityID) { + if let scriptComponent = scene.get(component: ScriptComponent.self, for: entityId) { + // Clean up scripts + scriptComponent.scripts.removeAll() + scriptComponent.scriptFilePaths = nil + scene.remove(component: ScriptComponent.self, from: entityId) + } +} + // MARK: - Granular LOD Management Functions /// Set up LOD component for an entity diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index 759037a6..c8921d75 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -159,7 +159,13 @@ public func buildGameModeGraph() -> RenderGraphResult { ) graph[shadowPass.id] = shadowPass - gBufferPass(graph: &graph, shadowPass: shadowPass) + // Add batched shadow pass (runs after regular shadow pass) + let batchedShadowPass = RenderPass( + id: "batchedShadow", dependencies: [shadowPass.id], execute: RenderPasses.batchedShadowExecution + ) + graph[batchedShadowPass.id] = batchedShadowPass + + gBufferPass(graph: &graph, shadowPass: batchedShadowPass) // Gaussian pass depends on model pass - needs depth buffer from 3D models let gaussianPass = RenderPass(id: "gaussian", dependencies: ["model"], execute: RenderPasses.gaussianExecution) @@ -202,15 +208,24 @@ func gBufferPass(graph: inout [String: RenderPass], shadowPass: RenderPass) { id: "model", dependencies: [shadowPass.id], execute: RenderPasses.modelExecution ) graph[modelPass.id] = modelPass + // Add batched model pass (runs after regular model pass) + let batchedModelPass = RenderPass( + id: "batchedModel", dependencies: [modelPass.id], execute: RenderPasses.batchedModelExecution + ) + graph[batchedModelPass.id] = batchedModelPass + // Update SSAO to depend on batched pass + let ssaoPass = RenderPass( + id: "ssao", + dependencies: [batchedModelPass.id], // Changed from "model" to "batchedModel" + execute: RenderPasses.ssaoOptimizedExecution + ) - // Use optimized SSAO pipeline with quality-aware execution - let ssaoPass = RenderPass(id: "ssao", dependencies: [modelPass.id], execute: RenderPasses.ssaoOptimizedExecution) graph[ssaoPass.id] = ssaoPass // Note: ssaoOptimizedExecution handles all blur/upsample internally // No need for separate ssaoBlur pass in the graph - let lightPass = RenderPass(id: "lightPass", dependencies: [modelPass.id, shadowPass.id, ssaoPass.id], execute: RenderPasses.lightExecution) + let lightPass = RenderPass(id: "lightPass", dependencies: [batchedModelPass.id, modelPass.id, shadowPass.id, ssaoPass.id], execute: RenderPasses.lightExecution) graph[lightPass.id] = lightPass } diff --git a/Sources/UntoldEngine/Utils/FuncUtils.swift b/Sources/UntoldEngine/Utils/FuncUtils.swift index a5f6c317..68571741 100644 --- a/Sources/UntoldEngine/Utils/FuncUtils.swift +++ b/Sources/UntoldEngine/Utils/FuncUtils.swift @@ -1136,3 +1136,22 @@ public func getAlphaForImmersionMode() -> Float { return 0.0 } } + +// Generate batches for all static entities +public func generateBatches() { + BatchingSystem.shared.generateBatches() +} + +// Clear all batches + +public func clearSceneBatches() { + BatchingSystem.shared.clearBatches() +} + +public func enableBatching(_ enabled: Bool) { + BatchingSystem.shared.setEnabled(enabled) +} + +public func isBatchingEnabled() -> Bool { + BatchingSystem.shared.isEnabled() +} diff --git a/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift b/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift index 31cee03c..d4ea478f 100644 --- a/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift +++ b/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift @@ -63,6 +63,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { // Verify all passes are created XCTAssertNotNil(graph["model"], "Model pass should be created") + XCTAssertNotNil(graph["batchedModel"], "Batched model pass should be created") XCTAssertNotNil(graph["ssao"], "SSAO pass should be created (handles blur internally)") XCTAssertNotNil(graph["lightPass"], "Light pass should be created") } @@ -76,11 +77,12 @@ final class RenderGraphBuilderTest: BaseRenderSetup { // Verify dependencies XCTAssertEqual(graph["model"]?.dependencies, ["shadow"], "Model pass should depend on shadow pass") - XCTAssertEqual(graph["ssao"]?.dependencies, ["model"], "SSAO pass should depend on model pass") + XCTAssertEqual(graph["batchedModel"]?.dependencies, ["model"], "Batched model pass should depend on model pass") + XCTAssertEqual(graph["ssao"]?.dependencies, ["batchedModel"], "SSAO pass should depend on batched model pass") let lightDeps = graph["lightPass"]?.dependencies.sorted() - let expectedLightDeps = ["model", "shadow", "ssao"].sorted() - XCTAssertEqual(lightDeps, expectedLightDeps, "Light pass should depend on model, shadow, and ssao (blur handled internally)") + let expectedLightDeps = ["batchedModel", "model", "shadow", "ssao"].sorted() + XCTAssertEqual(lightDeps, expectedLightDeps, "Light pass should depend on batchedModel, model, shadow, and ssao") } func testGBufferPass_TopologicalOrder() { @@ -96,9 +98,11 @@ final class RenderGraphBuilderTest: BaseRenderSetup { // Verify correct ordering constraints assertTopologicalConstraints(order: order, constraints: [ ("shadow", "model"), - ("model", "ssao"), + ("model", "batchedModel"), + ("batchedModel", "ssao"), ("shadow", "lightPass"), ("model", "lightPass"), + ("batchedModel", "lightPass"), ("ssao", "lightPass"), ]) } diff --git a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift index cceba2fd..a2361d2b 100644 --- a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift +++ b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift @@ -1315,4 +1315,278 @@ final class SceneSerializerTests: BaseRenderSetup { XCTAssertNil(sceneData.entities[0].hasLODComponent, "Should not have LOD component flag") XCTAssertNil(sceneData.entities[0].lodData, "Should not have LOD data") } + + // MARK: - Static Batch Component Tests + + func testSerializeSingleEntityWithStaticBatchComponent() { + // Create entity with mesh and static batch component + let entityId = createEntity() + setEntityName(entityId: entityId, name: "StaticCube") + registerTransformComponent(entityId: entityId) + let meshes = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: "Cube") + + // Mark as static + setEntityStaticBatchComponent(entityId: entityId) + + // Serialize + let sceneData = serializeScene() + + // Verify StaticBatchComponent was serialized + XCTAssertEqual(sceneData.entities.count, 1, "Should have one entity") + XCTAssertTrue(sceneData.entities[0].hasStaticBatchComponent == true, "Should have static batch component flag") + } + + func testDeserializeSingleEntityWithStaticBatchComponent() { + // Create entity with mesh and static batch component + let entityId = createEntity() + setEntityName(entityId: entityId, name: "StaticCube") + let meshes = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: "Cube") + setEntityStaticBatchComponent(entityId: entityId) + + // Serialize + let sceneData = serializeScene() + + // Clear scene + destroyAllEntities() + scene.finalizePendingDestroys() + entityNameMap.removeAll() + reverseEntityNameMap.removeAll() + + // Deserialize + deserializeScene(sceneData: sceneData, meshLoadingMode: .sync) + + // Verify entity was recreated with StaticBatchComponent + guard let recreatedId = findEntity(name: "StaticCube") else { + XCTFail("Expected to find StaticCube after deserialization") + return + } + + XCTAssertTrue( + hasComponent(entityId: recreatedId, componentType: StaticBatchComponent.self), + "StaticBatchComponent should be restored after deserialization" + ) + } + + func testSerializeParentWithStaticBatchChildren() { + // Create parent entity (USDZ root) + let parentId = createEntity() + setEntityName(entityId: parentId, name: "TreeRoot") + registerTransformComponent(entityId: parentId) + registerSceneGraphComponent(entityId: parentId) + + // Create child entities with meshes + let child1 = createEntity() + setEntityName(entityId: child1, name: "TreeTrunk") + registerTransformComponent(entityId: child1) + registerSceneGraphComponent(entityId: child1) + let mesh1 = BasicPrimitives.createCylinder() + setEntityMeshDirect(entityId: child1, meshes: mesh1, assetName: "Cylinder") + setParent(childId: child1, parentId: parentId) + setEntityStaticBatchComponent(entityId: child1) + + let child2 = createEntity() + setEntityName(entityId: child2, name: "TreeLeaves") + registerTransformComponent(entityId: child2) + registerSceneGraphComponent(entityId: child2) + let mesh2 = BasicPrimitives.createSphere() + setEntityMeshDirect(entityId: child2, meshes: mesh2, assetName: "Sphere") + setParent(childId: child2, parentId: parentId) + setEntityStaticBatchComponent(entityId: child2) + + // Serialize + let sceneData = serializeScene() + + // Verify parent has StaticBatchComponent flag (due to recursive check) + XCTAssertEqual(sceneData.entities.count, 3, "Should have parent + 2 children") + let parentData = sceneData.entities.first { $0.name == "TreeRoot" } + XCTAssertNotNil(parentData, "Parent entity should exist in scene data") + XCTAssertTrue( + parentData?.hasStaticBatchComponent == true, + "Parent should have static batch component flag when children have the component" + ) + } + + func testRoundTripStaticBatchComponentViaJSON() { + // Create entity with static batch component + let entityId = createEntity() + setEntityName(entityId: entityId, name: "StaticEntity") + let meshes = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: "Cube") + setEntityStaticBatchComponent(entityId: entityId) + + // Serialize to JSON + let sceneData = serializeScene() + let tempDir = FileManager.default.temporaryDirectory + let sceneURL = tempDir.appendingPathComponent("static_batch_roundtrip.json") + + do { + let jsonData = try JSONEncoder().encode(sceneData) + try jsonData.write(to: sceneURL) + } catch { + XCTFail("Failed to write static batch JSON: \(error)") + return + } + + // Clear scene + destroyAllEntities() + scene.finalizePendingDestroys() + entityNameMap.removeAll() + reverseEntityNameMap.removeAll() + + // Load and deserialize + guard let loadedSceneData = loadGameScene(from: sceneURL) else { + XCTFail("Failed to load static batch JSON") + return + } + + deserializeScene(sceneData: loadedSceneData, meshLoadingMode: .sync) + + // Verify component was restored + guard let recreatedId = findEntity(name: "StaticEntity") else { + XCTFail("Expected to find StaticEntity after deserialization") + return + } + + XCTAssertTrue( + hasComponent(entityId: recreatedId, componentType: StaticBatchComponent.self), + "StaticBatchComponent should be restored after JSON round-trip" + ) + + // Cleanup + try? FileManager.default.removeItem(at: sceneURL) + } + + func testRoundTripStaticBatchComponentWithHierarchyViaJSON() { + // Create hierarchy: Parent with two static children + let parentId = createEntity() + setEntityName(entityId: parentId, name: "StaticGroup") + registerTransformComponent(entityId: parentId) + registerSceneGraphComponent(entityId: parentId) + + let child1 = createEntity() + setEntityName(entityId: child1, name: "StaticChild1") + registerTransformComponent(entityId: child1) + registerSceneGraphComponent(entityId: child1) + let mesh1 = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: child1, meshes: mesh1, assetName: "Cube") + setParent(childId: child1, parentId: parentId) + setEntityStaticBatchComponent(entityId: child1) + + let child2 = createEntity() + setEntityName(entityId: child2, name: "StaticChild2") + registerTransformComponent(entityId: child2) + registerSceneGraphComponent(entityId: child2) + let mesh2 = BasicPrimitives.createSphere() + setEntityMeshDirect(entityId: child2, meshes: mesh2, assetName: "Sphere") + setParent(childId: child2, parentId: parentId) + setEntityStaticBatchComponent(entityId: child2) + + // Serialize to JSON + let sceneData = serializeScene() + let tempDir = FileManager.default.temporaryDirectory + let sceneURL = tempDir.appendingPathComponent("static_batch_hierarchy_roundtrip.json") + + do { + let jsonData = try JSONEncoder().encode(sceneData) + try jsonData.write(to: sceneURL) + } catch { + XCTFail("Failed to write static batch hierarchy JSON: \(error)") + return + } + + // Clear scene + destroyAllEntities() + scene.finalizePendingDestroys() + entityNameMap.removeAll() + reverseEntityNameMap.removeAll() + + // Load and deserialize + guard let loadedSceneData = loadGameScene(from: sceneURL) else { + XCTFail("Failed to load static batch hierarchy JSON") + return + } + + deserializeScene(sceneData: loadedSceneData, meshLoadingMode: .sync) + + // Verify all entities were recreated + guard let recreatedParent = findEntity(name: "StaticGroup"), + let recreatedChild1 = findEntity(name: "StaticChild1"), + let recreatedChild2 = findEntity(name: "StaticChild2") + else { + XCTFail("Expected to find all entities after deserialization") + return + } + + // Verify children have StaticBatchComponent + XCTAssertTrue( + hasComponent(entityId: recreatedChild1, componentType: StaticBatchComponent.self), + "Child1 should have StaticBatchComponent after deserialization" + ) + XCTAssertTrue( + hasComponent(entityId: recreatedChild2, componentType: StaticBatchComponent.self), + "Child2 should have StaticBatchComponent after deserialization" + ) + + // Verify hierarchy was restored + let parent = getEntityParent(entityId: recreatedChild1) + XCTAssertEqual(parent, recreatedParent, "Child1 should have correct parent") + let parent2 = getEntityParent(entityId: recreatedChild2) + XCTAssertEqual(parent2, recreatedParent, "Child2 should have correct parent") + + // Cleanup + try? FileManager.default.removeItem(at: sceneURL) + } + + func testSerializeEntityWithoutStaticBatchComponent() { + // Verify entities without StaticBatchComponent don't have the flag + let entityId = createEntity() + setEntityName(entityId: entityId, name: "DynamicEntity") + registerTransformComponent(entityId: entityId) + let meshes = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: "Cube") + + // Serialize without marking as static + let sceneData = serializeScene() + + // Verify no StaticBatchComponent flag + XCTAssertEqual(sceneData.entities.count, 1, "Should have one entity") + XCTAssertNil(sceneData.entities[0].hasStaticBatchComponent, "Should not have static batch component flag") + } + + func testSerializeRecursiveHierarchyCheck() { + // Test that hasStaticBatchInHierarchy checks deeply nested children + let root = createEntity() + setEntityName(entityId: root, name: "Root") + registerTransformComponent(entityId: root) + registerSceneGraphComponent(entityId: root) + + let level1 = createEntity() + setEntityName(entityId: level1, name: "Level1") + registerTransformComponent(entityId: level1) + registerSceneGraphComponent(entityId: level1) + setParent(childId: level1, parentId: root) + + let level2 = createEntity() + setEntityName(entityId: level2, name: "Level2") + registerTransformComponent(entityId: level2) + registerSceneGraphComponent(entityId: level2) + let meshes = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: level2, meshes: meshes, assetName: "Cube") + setParent(childId: level2, parentId: level1) + setEntityStaticBatchComponent(entityId: level2) // Only deepest child has component + + // Serialize + let sceneData = serializeScene() + + // Both root and level1 should have the flag due to recursive check + let rootData = sceneData.entities.first { $0.name == "Root" } + let level1Data = sceneData.entities.first { $0.name == "Level1" } + let level2Data = sceneData.entities.first { $0.name == "Level2" } + + XCTAssertTrue(rootData?.hasStaticBatchComponent == true, "Root should have flag (child hierarchy contains component)") + XCTAssertTrue(level1Data?.hasStaticBatchComponent == true, "Level1 should have flag (child hierarchy contains component)") + XCTAssertTrue(level2Data?.hasStaticBatchComponent == true, "Level2 should have flag (has component directly)") + } } diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift new file mode 100644 index 00000000..d9bcf4c9 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -0,0 +1,459 @@ +// +// StaticBatchingTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import CShaderTypes +import Foundation +@testable import UntoldEngine +import XCTest + +final class StaticBatchingTest: BaseRenderSetup { + override func setUp() { + super.setUp() + + // Clear any existing batches + clearSceneBatches() + enableBatching(false) + } + + override func tearDown() { + // Clean up batches after tests + clearSceneBatches() + enableBatching(false) + + super.tearDown() + } + + // MARK: - Basic Functionality Tests + + func testBatchingSystemInitialization() { + XCTAssertNotNil(BatchingSystem.shared, "❌ BatchingSystem should be initialized") + XCTAssertFalse(BatchingSystem.shared.isEnabled(), "❌ Batching should be disabled by default") + XCTAssertEqual(BatchingSystem.shared.batchGroups.count, 0, "❌ Should have no batch groups initially") + } + + func testEnableBatching() { + // When: Enable batching + enableBatching(true) + + // Then: Batching should be enabled + XCTAssertTrue(isBatchingEnabled(), "❌ Batching should be enabled") + + // When: Disable batching + enableBatching(false) + + // Then: Batching should be disabled + XCTAssertFalse(isBatchingEnabled(), "❌ Batching should be disabled") + } + + func testStaticBatchComponent() { + // Given: Create an entity with mesh (required for static batch component) + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createCube(), assetName: "TestCube") + + // When: Mark entity as static + setEntityStaticBatchComponent(entityId: entity) + + // Then: Entity should have StaticBatchComponent + let staticComponent = scene.get(component: StaticBatchComponent.self, for: entity) + XCTAssertNotNil(staticComponent, "❌ Entity should have StaticBatchComponent") + XCTAssertTrue(staticComponent?.isStatic ?? false, "❌ Entity should be marked as static") + XCTAssertTrue(staticComponent?.canBatch ?? false, "❌ Entity should be batchable") + + // When: Remove static component + removeEntityStaticBatchComponent(entityId: entity) + + // Then: Entity should not have StaticBatchComponent + let removedComponent = scene.get(component: StaticBatchComponent.self, for: entity) + XCTAssertNil(removedComponent, "❌ Entity should not have StaticBatchComponent after removal") + } + + // MARK: - Batch Generation Tests + + func testGenerateBatchesWithNoStaticEntities() { + // Given: No entities marked as static + let entity1 = createEntity() + let entity2 = createEntity() + + // When: Generate batches + generateBatches() + + // Then: No batches should be created + XCTAssertEqual(BatchingSystem.shared.batchGroups.count, 0, "❌ Should have no batches without static entities") + } + + func testGenerateBatchesWithSingleStaticEntity() { + // Given: One static entity + let entity = createEntity() + setEntityStaticBatchComponent(entityId: entity) + + // When: Generate batches + generateBatches() + + // Then: No batches should be created (need at least 2 entities with same material) + XCTAssertEqual(BatchingSystem.shared.batchGroups.count, 0, "❌ Should have no batches with only 1 static entity") + } + + func testGenerateBatchesWithMultipleStaticEntities() { + // Given: Load a simple model + guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { + XCTFail("❌ Failed to load ball.usdz") + return + } + + // Create multiple entities with same mesh (same material) + var entities: [EntityID] = [] + for i in 0 ..< 5 { + let entity = createEntity() + + // Load mesh + let meshes = Mesh.loadMeshes( + url: ballURL, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + // Add RenderComponent + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + renderComponent.assetURL = ballURL + renderComponent.assetName = "ball" + } + + // Add Transform + if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { + transform.position = simd_float3(Float(i) * 2.0, 0, 0) + } + + // Add WorldTransform + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + + // Mark as static + setEntityStaticBatchComponent(entityId: entity) + + entities.append(entity) + } + + // When: Generate batches + generateBatches() + + // Then: At least one batch should be created + XCTAssertGreaterThan(BatchingSystem.shared.batchGroups.count, 0, "❌ Should create at least 1 batch group") + + // Verify entities are batched + for entity in entities { + XCTAssertTrue(BatchingSystem.shared.isBatched(entityId: entity), "❌ Entity \(entity) should be batched") + } + + print("✅ Created \(BatchingSystem.shared.batchGroups.count) batch group(s) from \(entities.count) entities") + } + + func testBatchGroupBufferCreation() { + // Given: Load a model and create batched entities + guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { + XCTFail("❌ Failed to load ball.usdz") + return + } + + for i in 0 ..< 3 { + let entity = createEntity() + + let meshes = Mesh.loadMeshes( + url: ballURL, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + } + + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + + setEntityStaticBatchComponent(entityId: entity) + } + + // When: Generate batches + generateBatches() + + // Then: Batch buffers should be created + guard let batchGroup = BatchingSystem.shared.batchGroups.first else { + XCTFail("❌ No batch group created") + return + } + + XCTAssertNotNil(batchGroup.positionBuffer, "❌ Position buffer should be created") + XCTAssertNotNil(batchGroup.normalBuffer, "❌ Normal buffer should be created") + XCTAssertNotNil(batchGroup.uvBuffer, "❌ UV buffer should be created") + XCTAssertNotNil(batchGroup.tangentBuffer, "❌ Tangent buffer should be created") + XCTAssertNotNil(batchGroup.indexBuffer, "❌ Index buffer should be created") + + XCTAssertGreaterThan(batchGroup.vertexCount, 0, "❌ Vertex count should be > 0") + XCTAssertGreaterThan(batchGroup.indexCount, 0, "❌ Index count should be > 0") + + print("✅ Batch group created with \(batchGroup.vertexCount) vertices and \(batchGroup.indexCount) indices") + } + + func testClearBatches() { + // Given: Create some batches + guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { + XCTFail("❌ Failed to load ball.usdz") + return + } + + for i in 0 ..< 3 { + let entity = createEntity() + let meshes = Mesh.loadMeshes(url: ballURL, vertexDescriptor: vertexDescriptor.model, device: renderInfo.device, flip: true) + + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + } + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + setEntityStaticBatchComponent(entityId: entity) + } + + generateBatches() + XCTAssertGreaterThan(BatchingSystem.shared.batchGroups.count, 0, "❌ Should have batches before clearing") + + // When: Clear batches + clearSceneBatches() + + // Then: All batches should be removed + XCTAssertEqual(BatchingSystem.shared.batchGroups.count, 0, "❌ All batches should be cleared") + } + + // MARK: - Material Grouping Tests + + func testBatchesGroupByMaterial() { + // This test would verify that entities with different materials + // are placed in different batch groups + + // Note: This is a conceptual test - actual implementation depends on + // having entities with different materials available in test resources + + // For now, verify that the material hash function exists and works + let entity1 = createEntity() + setEntityStaticBatchComponent(entityId: entity1) + + // The system should group entities by material hash + // This is tested implicitly in testGenerateBatchesWithMultipleStaticEntities + XCTAssertTrue(true, "Material grouping is tested implicitly") + } + + // MARK: - Edge Cases + + func testBatchingExcludesAnimatedEntities() { + // Given: An entity with animation component + let entity = createEntity() + + // Add components + _ = scene.assign(to: entity, component: RenderComponent.self) + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + _ = scene.assign(to: entity, component: AnimationComponent.self) + + // Mark as static + setEntityStaticBatchComponent(entityId: entity) + + // When: Generate batches + generateBatches() + + // Then: Animated entity should not be batched + XCTAssertFalse(BatchingSystem.shared.isBatched(entityId: entity), "❌ Animated entities should not be batched") + } + + func testBatchingExcludesSkeletalMeshes() { + // Given: An entity with skeleton component + let entity = createEntity() + + // Add components + _ = scene.assign(to: entity, component: RenderComponent.self) + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + _ = scene.assign(to: entity, component: SkeletonComponent.self) + + // Mark as static + setEntityStaticBatchComponent(entityId: entity) + + // When: Generate batches + generateBatches() + + // Then: Skeletal entity should not be batched + XCTAssertFalse(BatchingSystem.shared.isBatched(entityId: entity), "❌ Skeletal meshes should not be batched") + } + + func testBatchingExcludesLights() { + // Given: A light entity + let entity = createEntity() + + // Add components + _ = scene.assign(to: entity, component: LightComponent.self) + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + + // Mark as static + setEntityStaticBatchComponent(entityId: entity) + + // When: Generate batches + generateBatches() + + // Then: Light should not be batched + XCTAssertFalse(BatchingSystem.shared.isBatched(entityId: entity), "❌ Lights should not be batched") + } + + // MARK: - Performance Tests + + func testBatchingPerformance() { + // Measure performance of batch generation with many entities + measure { + // Given: Create 50 static entities + guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { + return + } + + for i in 0 ..< 50 { + let entity = createEntity() + + let meshes = Mesh.loadMeshes( + url: ballURL, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + } + + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + setEntityStaticBatchComponent(entityId: entity) + } + + // When: Generate batches (this is what we're measuring) + generateBatches() + + // Clean up for next iteration + clearSceneBatches() + } + } + + // MARK: - Integration Tests + + func testBatchingIntegrationWithRendering() { + // This test verifies that batching integrates with the rendering system + + // Given: Create batched entities + guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { + XCTFail("❌ Failed to load ball.usdz") + return + } + + for i in 0 ..< 3 { + let entity = createEntity() + + let meshes = Mesh.loadMeshes( + url: ballURL, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + } + + if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { + transform.position = simd_float3(Float(i) * 2.0, 0, 0) + } + + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + setEntityStaticBatchComponent(entityId: entity) + } + + // When: Enable batching and generate batches + enableBatching(true) + generateBatches() + + // Then: Verify batching is active + XCTAssertTrue(isBatchingEnabled(), "❌ Batching should be enabled") + XCTAssertGreaterThan(BatchingSystem.shared.batchGroups.count, 0, "❌ Should have batch groups") + + // Verify renderer can access batches (integration point) + XCTAssertNotNil(renderer, "❌ Renderer should be initialized") + + // Draw frame to ensure batched rendering works + let expectation = XCTestExpectation(description: "Batched rendering test") + + renderer.draw(in: renderer.metalView) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + // If we get here without crashes, batching integrates correctly + expectation.fulfill() + } + + wait(for: [expectation], timeout: TimeInterval(timeoutFactor)) + + print("✅ Batching integrated successfully with rendering system") + } + + // MARK: - Statistics Tests + + func testBatchStatistics() { + // Given: Create entities and generate batches + guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { + XCTFail("❌ Failed to load ball.usdz") + return + } + + let entityCount = 10 + for i in 0 ..< entityCount { + let entity = createEntity() + + let meshes = Mesh.loadMeshes( + url: ballURL, + vertexDescriptor: vertexDescriptor.model, + device: renderInfo.device, + flip: true + ) + + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + } + + _ = scene.assign(to: entity, component: LocalTransformComponent.self) + _ = scene.assign(to: entity, component: WorldTransformComponent.self) + setEntityStaticBatchComponent(entityId: entity) + } + + generateBatches() + + // Then: Verify statistics + let batchCount = BatchingSystem.shared.batchGroups.count + let totalBatchedEntities = BatchingSystem.shared.batchGroups.reduce(0) { $0 + $1.entityIds.count } + + XCTAssertGreaterThan(batchCount, 0, "❌ Should have created batches") + XCTAssertEqual(totalBatchedEntities, entityCount, "❌ All entities should be batched") + + // Calculate draw call reduction + let drawCallsWithoutBatching = entityCount // One per entity + let drawCallsWithBatching = batchCount + let reduction = Float(drawCallsWithoutBatching - drawCallsWithBatching) / Float(drawCallsWithoutBatching) * 100 + + print("📊 Batching Statistics:") + print(" Entities: \(entityCount)") + print(" Batches: \(batchCount)") + print(" Draw calls reduced: \(drawCallsWithoutBatching) → \(drawCallsWithBatching)") + print(" Reduction: \(String(format: "%.1f", reduction))%") + + XCTAssertLessThan(drawCallsWithBatching, drawCallsWithoutBatching, "❌ Batching should reduce draw calls") + } +} diff --git a/docs/04-Engine Development/03-Engine Systems/UsingStaticBatchingSystem.md b/docs/04-Engine Development/03-Engine Systems/UsingStaticBatchingSystem.md new file mode 100644 index 00000000..c366cb7c --- /dev/null +++ b/docs/04-Engine Development/03-Engine Systems/UsingStaticBatchingSystem.md @@ -0,0 +1,303 @@ +--- +id: staticbatchingsystem +title: Static Batching System +sidebar_position: 11 +--- + +# Static Batching System - Usage Guide + +The Untold Engine provides a static batching system that dramatically reduces draw calls by combining static (non-moving) geometry into optimized batches. + +## Quick Start + +### Basic Static Batching Setup + +```swift +// Create entities +let cube1 = createEntity() +setEntityMesh(entityId: cube1, filename: "cube", withExtension: "usdz") +translateTo(entityId: cube1, position: simd_float3(0, 0, 0)) + +let cube2 = createEntity() +setEntityMesh(entityId: cube2, filename: "cube", withExtension: "usdz") +translateTo(entityId: cube2, position: simd_float3(2, 0, 0)) + +let cube3 = createEntity() +setEntityMesh(entityId: cube3, filename: "cube", withExtension: "usdz") +translateTo(entityId: cube3, position: simd_float3(4, 0, 0)) + +// Mark entities as static +setEntityStaticBatchComponent(entityId: cube1) +setEntityStaticBatchComponent(entityId: cube2) +setEntityStaticBatchComponent(entityId: cube3) + +// Enable batching and generate batches +enableBatching(true) +generateBatches() +``` + +**How it works:** +- Static entities are marked for batching +- `generateBatches()` combines entities with the same material into batch groups +- Rendering system uses batched draw calls instead of per-entity calls + +### With Async Mesh Loading (Recommended) + +For better performance, use async loading and enable batching in the completion handler: + +```swift +let stadium = createEntity() +setEntityMeshAsync(entityId: stadium, filename: "stadium", withExtension: "usdz") { success in + if success { + print("Scene loaded successfully") + + // Mark as static AFTER mesh is loaded + setEntityStaticBatchComponent(entityId: stadium) + + // Enable batching system + enableBatching(true) + + // Generate batches + generateBatches() + } +} +``` + +**Important:** Always call `setEntityStaticBatchComponent()` **after** the mesh loads successfully, then enable and generate batches. + +### Multi-Mesh Assets (USDZ with Multiple Objects) + +For USDZ files with multiple meshes (like a building with walls, roof, windows): + +```swift +let building = createEntity() +setEntityMeshAsync(entityId: building, filename: "office_building", withExtension: "usdz") { success in + if success { + // Mark parent entity - automatically marks all children as static + setEntityStaticBatchComponent(entityId: building) + + enableBatching(true) + generateBatches() + } +} +``` + +**How it works:** `setEntityStaticBatchComponent()` recursively marks the parent and all children, so the entire building is batched. + +## API Reference + +### Core Functions + +#### `setEntityStaticBatchComponent(entityId:)` +Marks an entity (and all its children) as static for batching. + +```swift +setEntityStaticBatchComponent(entityId: entity) +``` + +**Note:** Entity must have a `RenderComponent` (i.e., mesh must be loaded). + +#### `removeEntityStaticBatchComponent(entityId:)` +Removes static batching from an entity (and all its children). + +```swift +removeEntityStaticBatchComponent(entityId: entity) +``` + +**Use case:** If you need to move a previously static object. + +#### `enableBatching(_:)` +Globally enables or disables the batching system. + +```swift +enableBatching(true) // Enable batching +enableBatching(false) // Disable batching +``` + +#### `isBatchingEnabled() -> Bool` +Checks if batching is currently enabled. + +```swift +if isBatchingEnabled() { + print("Batching is active") +} +``` + +#### `generateBatches()` +Generates batch groups from all entities marked as static. + +```swift +generateBatches() +``` + +**Important:** Call this after marking entities as static and enabling batching. + +#### `clearSceneBatches()` +Clears all generated batches. + +```swift +clearSceneBatches() +``` + +**Use case:** When loading a new scene or reconfiguring static geometry. + +## Complete Workflow Examples + +### Example 1: Multiple Static Objects + +```swift +import UntoldEngine + +// Create multiple static props +var props: [EntityID] = [] + +for i in 0..<50 { + let rock = createEntity() + setEntityName(entityId: rock, name: "Rock_\(i)") + + // Load mesh + setEntityMesh(entityId: rock, filename: "rock", withExtension: "usdz") + + // Position randomly + let x = Float.random(in: -20...20) + let z = Float.random(in: -20...20) + translateTo(entityId: rock, position: simd_float3(x, 0, z)) + + // Mark as static + setEntityStaticBatchComponent(entityId: rock) + + props.append(rock) +} + +// Enable and generate batches +enableBatching(true) +generateBatches() + +print("Batched \(props.count) rocks") +``` + +### Example 2: Scene Loading with Batching + +```swift +// Load scene from file +if let sceneData = loadGameScene(from: sceneURL) { + deserializeScene(sceneData: sceneData) + + // Scene automatically restores StaticBatchComponent for marked entities + // Enable batching and generate + enableBatching(true) + generateBatches() + + print("Scene loaded with batching enabled") +} +``` + +### Example 3: Dynamic Scene with Mixed Objects + +```swift +// Static environment +let ground = createEntity() +setEntityMesh(entityId: ground, filename: "ground_plane", withExtension: "usdz") +setEntityStaticBatchComponent(entityId: ground) + +let walls = createEntity() +setEntityMesh(entityId: walls, filename: "walls", withExtension: "usdz") +setEntityStaticBatchComponent(entityId: walls) + +// Dynamic objects (NOT marked as static) +let player = createEntity() +setEntityMesh(entityId: player, filename: "character", withExtension: "usdz") +// Do NOT call setEntityStaticBatchComponent for moving objects + +let enemy = createEntity() +setEntityMesh(entityId: enemy, filename: "enemy", withExtension: "usdz") +// Enemies move, so no static batching + +// Enable batching (only affects static entities) +enableBatching(true) +generateBatches() +``` + +### Example 4: Large Async Scene Loading + +```swift +let cityBlock = createEntity() +setEntityMeshAsync(entityId: cityBlock, filename: "city_block", withExtension: "usdz") { success in + if success { + print("City block loaded with all buildings") + + // Mark entire hierarchy as static + setEntityStaticBatchComponent(entityId: cityBlock) + + // Enable batching system + enableBatching(true) + + // Generate batches + generateBatches() + + print("Static batching enabled - draw calls optimized") + } else { + print("Failed to load city block") + } +} +``` + +## Best Practices + +### What to Mark as Static +✅ **Good candidates:** +- Environment geometry (walls, floors, ceilings) +- Props that never move (rocks, trees, furniture) +- Buildings and structures +- Terrain meshes +- Static decorations + +❌ **Bad candidates:** +- Characters and NPCs +- Vehicles +- Projectiles +- Animated objects +- UI elements + +### Batching Requirements +For entities to batch together, they must have: +- ✅ Same material (textures, colors) +- ✅ `StaticBatchComponent` marked +- ✅ Valid `RenderComponent` (mesh loaded) + +Entities with different materials will be in separate batch groups. + +### Performance Tips + +1. **Mark entities AFTER mesh loading:** + ```swift + setEntityMeshAsync(...) { success in + setEntityStaticBatchComponent(entityId: entity) // ✅ Correct timing + } + ``` + +2. **Enable batching once per scene:** + ```swift + // Game initialization or scene load + enableBatching(true) + generateBatches() + ``` + +3. **Group entities by material:** + - Entities with the same material batch better + - Reduce material variations for better batching + +4. **Regenerate batches when needed:** + ```swift + // When adding/removing static entities + clearSceneBatches() + generateBatches() + ``` + +## Limitations + +- **No dynamic batching:** Only works for static geometry +- **Transform baked:** Entity positions are baked into batch geometry +- **Material grouping:** Different materials create separate batches +- **No skeletal meshes:** Animated/skinned meshes cannot be batched +