From f676f75b2d43deb3825830617a8f1fcc34beb637 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Tue, 17 Mar 2026 17:01:53 -0700 Subject: [PATCH 1/3] [Patch] fixed texture streaming --- .../UntoldEngine/Systems/BatchingSystem.swift | 17 +++++++ .../Systems/TextureStreamingSystem.swift | 49 ++++++++----------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 2ae2f695..d7b0a68c 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -1779,6 +1779,23 @@ public class BatchingSystem: @unchecked Sendable { entityToBatch[entityId] } + /// Update the representative material of the batch group that contains `entityId` in-place. + /// + /// This lets texture streaming swap a new `MTLTexture` into the batch group's material + /// without tearing down or rebuilding the batch. The render pass reads `group.material` + /// fresh every frame, so the change takes effect on the very next draw call. + /// + /// - Returns: `true` if the entity was found in an active batch and the update was applied. + @discardableResult + public func updateBatchMaterialInPlace(for entityId: EntityID, update: (inout Material) -> Void) -> Bool { + guard let batchInfo = entityToBatch[entityId], + let index = batchIdToIndex[batchInfo.batchId], + batchGroups.indices.contains(index) + else { return false } + update(&batchGroups[index].material) + return true + } + public func setEnabled(_ enabled: Bool) { batchingEnabled = enabled if !enabled { diff --git a/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift b/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift index b418d38b..2e5bf9b3 100644 --- a/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift @@ -77,7 +77,6 @@ public class TextureStreamingSystem: @unchecked Sendable { /// Entities that currently hold textures above `minimumTextureDimension`. private var upgradedEntities: Set = [] private var activeOps: Set = [] - private var pendingBatchRefreshEntities: Set = [] private let lock = NSLock() @@ -169,36 +168,11 @@ public class TextureStreamingSystem: @unchecked Sendable { return active } - private func enqueueBatchRefresh(_ entityId: EntityID) { - lock.lock() - pendingBatchRefreshEntities.insert(entityId) - lock.unlock() - } - - private func drainPendingBatchRefreshes() -> [EntityID] { - lock.lock() - let pending = Array(pendingBatchRefreshEntities) - pendingBatchRefreshEntities.removeAll(keepingCapacity: true) - lock.unlock() - return pending - } - - private func flushPendingBatchRefreshes() { - let pending = drainPendingBatchRefreshes() - guard !pending.isEmpty else { return } - for entityId in pending where scene.exists(entityId) { - BatchingSystem.shared.notifyEntityMaterialChanged(entityId: entityId) - } - } - // MARK: - Update public func update(cameraPosition: simd_float3, deltaTime: Float) { guard enabled else { return } - // Always flush queued rebatch notifications from async streaming tasks. - flushPendingBatchRefreshes() - timeSinceLastUpdate += deltaTime guard timeSinceLastUpdate >= updateInterval else { return } timeSinceLastUpdate = 0 @@ -486,7 +460,27 @@ public class TextureStreamingSystem: @unchecked Sendable { guard didAnyChange else { return } - self.enqueueBatchRefresh(entityId) + // Update the batch group's representative material in-place so the + // new texture is visible on the next frame with zero batch churn. + BatchingSystem.shared.updateBatchMaterialInPlace(for: entityId) { batchMaterial in + for item in loaded { + let isFull = item.targetMaxDimension == nil + switch item.textureType { + case .baseColor: + batchMaterial.baseColor.texture = item.texture + batchMaterial.baseColorStreamingLevel = isFull ? .full : .capped + case .roughness: + batchMaterial.roughness.texture = item.texture + batchMaterial.roughnessStreamingLevel = isFull ? .full : .capped + case .metallic: + batchMaterial.metallic.texture = item.texture + batchMaterial.metallicStreamingLevel = isFull ? .full : .capped + case .normal: + batchMaterial.normal.texture = item.texture + batchMaterial.normalStreamingLevel = isFull ? .full : .capped + } + } + } let hasAboveMinimum = self.entityHasTexturesAboveMinimumTier(entityId: entityId) self.lock.lock() @@ -614,7 +608,6 @@ public class TextureStreamingSystem: @unchecked Sendable { lock.lock() upgradedEntities.removeAll() activeOps.removeAll() - pendingBatchRefreshEntities.removeAll() lock.unlock() timeSinceLastUpdate = 0 totalUpgrades = 0 From a5d68377cfc8bd0efe94bbc9fd60c258d4e80fa9 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Tue, 17 Mar 2026 17:03:13 -0700 Subject: [PATCH 2/3] [CI] Fixed formatting issues --- Sources/UntoldEngine/Mesh/Mesh.swift | 6 ++-- .../Renderer/RenderInitializer.swift | 27 ++++++++++------ .../UntoldEngine/Renderer/UntoldEngine.swift | 2 +- .../UntoldEngine/Systems/CullingSystem.swift | 31 +++++++++++-------- Tests/UntoldEngineTests/EntityAABBTest.swift | 20 ++++++------ .../MemoryBudgetManagerTests.swift | 1 + 6 files changed, 51 insertions(+), 36 deletions(-) diff --git a/Sources/UntoldEngine/Mesh/Mesh.swift b/Sources/UntoldEngine/Mesh/Mesh.swift index c228499c..3e3c87ce 100644 --- a/Sources/UntoldEngine/Mesh/Mesh.swift +++ b/Sources/UntoldEngine/Mesh/Mesh.swift @@ -966,8 +966,8 @@ private func textureLikelyHasAlphaChannel(_ texture: MTLTexture?) -> Bool { /// Tracks whether a texture slot is at full or capped resolution public enum TextureStreamingLevel { - case full // Original resolution - case capped // Downsampled to fit TextureLoader.maxTextureDimension + case full // Original resolution + case capped // Downsampled to fit TextureLoader.maxTextureDimension } public struct Material { @@ -1106,7 +1106,7 @@ public struct Material { roughnessSourceDimensions = roughnessDims metallicSourceDimensions = metallicDims - // Set texture streaming levels based on whether textures were dimension-capped. + /// Set texture streaming levels based on whether textures were dimension-capped. func isCapped(_ texture: MTLTexture?, _ sourceDims: simd_int2?) -> Bool { guard let texture, let sourceDims else { return false } return texture.width < Int(sourceDims.x) || texture.height < Int(sourceDims.y) diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift index 4512e56a..2f69a0af 100644 --- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift +++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift @@ -1130,47 +1130,56 @@ func ensurePostProcessTexturesExist() { textureResources.tonemapTexture = createTexture( device: renderInfo.device, label: "Tonemap Texture", pixelFormat: wf.sceneColor, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.blurTextureHor = createTexture( device: renderInfo.device, label: "Blur Texture Hor", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.blurTextureVer = createTexture( device: renderInfo.device, label: "Blur Texture Ver", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.colorCorrectionTexture = createTexture( device: renderInfo.device, label: "Color Correction Debug Texture", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.bloomThresholdTextuture = createTexture( device: renderInfo.device, label: "Bloom Threshold Texture", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.bloomCompositeTexture = createTexture( device: renderInfo.device, label: "Bloom Composite Texture", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.vignetteTexture = createTexture( device: renderInfo.device, label: "Vignette Texture", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.chromaticAberrationTexture = createTexture( device: renderInfo.device, label: "Chromatic Aberration Texture", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) textureResources.depthOfFieldTexture = createTexture( device: renderInfo.device, label: "Depth of Field Texture", pixelFormat: wf.postProcess, width: w, height: h, - usage: usage, storageMode: storage) + usage: usage, storageMode: storage + ) } func initSSAOResources() { diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 1d306ef2..08f669f3 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -600,7 +600,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { let effectiveVM = SceneRootTransform.shared.effectiveViewMatrix(viewMatrix) let eyeVP = simd_mul(projectionMatrix, effectiveVM) if eyeIndex == 0 { renderInfo.xrEye0ViewProjection = eyeVP } - else { renderInfo.xrEye1ViewProjection = eyeVP } + else { renderInfo.xrEye1ViewProjection = eyeVP } } configuration.updateXRRenderingSystemCallback!(.xr(commandBuffer: commandBuffer, passDescriptor: passDescriptor)) diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index 85ab02d3..ca49f6a9 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -128,18 +128,18 @@ public func makeObjectAABB(localMin: simd_float3, return EntityAABB(center: simd_float4(c.x, c.y, c.z, 0.0), halfExtent: simd_float4(e.x, e.y, e.z, 0.0), index: index, version: version, pad0: 0, pad1: 0) } -// Aspect ratio above which a world AABB is considered elongated and split into segments. +/// Aspect ratio above which a world AABB is considered elongated and split into segments. private let kSegmentAspectThreshold: Float = 3.0 -// Number of equal-length segments to split an elongated AABB into. +/// Number of equal-length segments to split an elongated AABB into. private let kSegmentCount: Int = 3 /// Returns one EntityAABB for compact meshes, or `kSegmentCount` AABBs along the dominant /// axis for elongated ones. All segments share the same (index, version) so the entity is /// considered visible when any single segment survives culling. func makeSegmentedEntityAABBs(localMin: simd_float3, - localMax: simd_float3, - worldMatrix M: simd_float4x4, - index: UInt32, version: UInt32) -> [EntityAABB] + localMax: simd_float3, + worldMatrix M: simd_float4x4, + index: UInt32, version: UInt32) -> [EntityAABB] { let (center, halfExtent) = worldAABB_CenterExtent(localMin: localMin, localMax: localMax, worldMatrix: M) @@ -163,14 +163,15 @@ func makeSegmentedEntityAABBs(localMin: simd_float3, return [EntityAABB( center: simd_float4(center.x, center.y, center.z, 0), halfExtent: simd_float4(halfExtent.x, halfExtent.y, halfExtent.z, 0), - index: index, version: version, pad0: 0, pad1: 0)] + index: index, version: version, pad0: 0, pad1: 0 + )] } // Split into kSegmentCount equal segments along the dominant axis. var result: [EntityAABB] = [] result.reserveCapacity(kSegmentCount) let segHalfLen = dominantHalf / Float(kSegmentCount) - let startOffset = -dominantHalf + segHalfLen // offset of first segment centre from entity centre + let startOffset = -dominantHalf + segHalfLen // offset of first segment centre from entity centre for s in 0 ..< kSegmentCount { var segCenter = center @@ -181,7 +182,8 @@ func makeSegmentedEntityAABBs(localMin: simd_float3, result.append(EntityAABB( center: simd_float4(segCenter.x, segCenter.y, segCenter.z, 0), halfExtent: simd_float4(segHalf.x, segHalf.y, segHalf.z, 0), - index: index, version: version, pad0: 0, pad1: 0)) + index: index, version: version, pad0: 0, pad1: 0 + )) } return result } @@ -640,7 +642,8 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space, index: getEntityIndex(entityId), - version: getEntityVersion(entityId)) + version: getEntityVersion(entityId) + ) entityAABBContainer.append(singleAABB) } else { // get object AABB — elongated meshes are split into segments so a solid @@ -650,7 +653,8 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space, index: getEntityIndex(entityId), - version: getEntityVersion(entityId)) + version: getEntityVersion(entityId) + ) entityAABBContainer.append(contentsOf: segments) } } @@ -726,9 +730,9 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { eye1VisTriple.ensureCapacity(count) let eye0CountBuf = eye0CountTriple.bufferForWrite(frame: submitFrameIndex) - let eye0VisBuf = eye0VisTriple.bufferForWrite(frame: submitFrameIndex) + let eye0VisBuf = eye0VisTriple.bufferForWrite(frame: submitFrameIndex) let eye1CountBuf = eye1CountTriple.bufferForWrite(frame: submitFrameIndex) - let eye1VisBuf = eye1VisTriple.bufferForWrite(frame: submitFrameIndex) + let eye1VisBuf = eye1VisTriple.bufferForWrite(frame: submitFrameIndex) let ran0 = executeHZBOcclusionCulling( commandBuffer, @@ -952,7 +956,8 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space, index: getEntityIndex(entityId), - version: getEntityVersion(entityId)) + version: getEntityVersion(entityId) + ) entityAABBContainer.append(contentsOf: segments) } diff --git a/Tests/UntoldEngineTests/EntityAABBTest.swift b/Tests/UntoldEngineTests/EntityAABBTest.swift index b55b2321..bb1cdf57 100644 --- a/Tests/UntoldEngineTests/EntityAABBTest.swift +++ b/Tests/UntoldEngineTests/EntityAABBTest.swift @@ -142,7 +142,7 @@ final class EntityAABBTest: XCTestCase { // MARK: - makeSegmentedEntityAABBs - // Compact mesh: aspect ratio ≤ 3 → single AABB, identical to makeObjectAABB output. + /// Compact mesh: aspect ratio ≤ 3 → single AABB, identical to makeObjectAABB output. func test_segmented_compactMesh_returnsSingleAABB() { // 2×2×2 cube — all axes equal, aspect = 1. let localMin = simd_float3(-1, -1, -1) @@ -159,7 +159,7 @@ final class EntityAABBTest: XCTestCase { approxEqual(simd_float3(result[0].halfExtent.x, result[0].halfExtent.y, result[0].halfExtent.z), simd_float3(1, 1, 1)) } - // Compact mesh: aspect ratio exactly at threshold (= 3.0) → still single AABB. + /// Compact mesh: aspect ratio exactly at threshold (= 3.0) → still single AABB. func test_segmented_aspectAtThreshold_returnsSingleAABB() { // halfExtent = (3, 1, 1) → dominant/minShort = 3.0, not > threshold. let localMin = simd_float3(-3, -1, -1) @@ -171,7 +171,7 @@ final class EntityAABBTest: XCTestCase { XCTAssertEqual(result.count, 1) } - // Elongated along X: aspect > 3 → 3 segments. + /// Elongated along X: aspect > 3 → 3 segments. func test_segmented_elongatedX_returnsThreeSegments() { // halfExtent = (6, 1, 1) → aspect 6 > 3. let localMin = simd_float3(-6, -1, -1) @@ -207,7 +207,7 @@ final class EntityAABBTest: XCTestCase { } } - // Elongated along Y: dominant axis is Y. + /// Elongated along Y: dominant axis is Y. func test_segmented_elongatedY_returnsThreeSegments() { // halfExtent = (1, 9, 1) → aspect 9 > 3. let localMin = simd_float3(-1, -9, -1) @@ -231,7 +231,7 @@ final class EntityAABBTest: XCTestCase { } } - // Elongated along Z: dominant axis is Z. + /// Elongated along Z: dominant axis is Z. func test_segmented_elongatedZ_returnsThreeSegments() { // halfExtent = (1, 1, 12) → aspect 12 > 3. let localMin = simd_float3(-1, -1, -12) @@ -253,8 +253,8 @@ final class EntityAABBTest: XCTestCase { } } - // Combined coverage: segments union exactly covers the original AABB along the dominant axis. - func test_segmented_segmentsCoverFullLength() { + /// Combined coverage: segments union exactly covers the original AABB along the dominant axis. + func test_segmented_segmentsCoverFullLength() throws { let localMin = simd_float3(-8, -1, -1) let localMax = simd_float3(8, 1, 1) let M = matrix_identity_float4x4 @@ -263,15 +263,15 @@ final class EntityAABBTest: XCTestCase { XCTAssertEqual(result.count, 3) // Leading edge of first segment and trailing edge of last segment should equal ±8. - let first = result.first! - let last = result.last! + let first = try XCTUnwrap(result.first) + let last = try XCTUnwrap(result.last) let leading = first.center.x - first.halfExtent.x let trailing = last.center.x + last.halfExtent.x XCTAssertEqual(leading, -8.0, accuracy: 1e-4) XCTAssertEqual(trailing, 8.0, accuracy: 1e-4) } - // With a non-identity world transform, segments still share the same index/version. + /// With a non-identity world transform, segments still share the same index/version. func test_segmented_withTranslation_preservesIndexVersion() { let localMin = simd_float3(-6, -1, -1) let localMax = simd_float3(6, 1, 1) diff --git a/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift b/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift index f2fe2ec2..db77f921 100644 --- a/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift +++ b/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift @@ -247,6 +247,7 @@ final class MemoryBudgetManagerTests: XCTestCase { } // MARK: - Combined Mesh + Texture Memory Tests + // These tests verify the fix that made utilizationPercent, availableMemory, // and shouldEvict() use the combined mesh + texture total rather than mesh alone. From f26b3e8076ca5cca5704168ffcb6c479a753f18d Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Tue, 17 Mar 2026 20:17:52 -0700 Subject: [PATCH 3/3] [Release] Prepare release 0.11.2 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 748fc6e8..d3d2a961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ # Changelog +## v0.11.2 - 2026-03-18 +### 🐞 Fixes +- [Patch] fixed texture streaming (f676f75…) ## v0.11.1 - 2026-03-17 ### 🐞 Fixes - [Patch] initial cell-based static batch system (7574378…)