Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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…)
Expand Down
6 changes: 3 additions & 3 deletions Sources/UntoldEngine/Mesh/Mesh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 18 additions & 9 deletions Sources/UntoldEngine/Renderer/RenderInitializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/UntoldEngine/Renderer/UntoldEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions Sources/UntoldEngine/Systems/BatchingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 18 additions & 13 deletions Sources/UntoldEngine/Systems/CullingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

Expand Down
49 changes: 21 additions & 28 deletions Sources/UntoldEngine/Systems/TextureStreamingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ public class TextureStreamingSystem: @unchecked Sendable {
/// Entities that currently hold textures above `minimumTextureDimension`.
private var upgradedEntities: Set<EntityID> = []
private var activeOps: Set<EntityID> = []
private var pendingBatchRefreshEntities: Set<EntityID> = []

private let lock = NSLock()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -614,7 +608,6 @@ public class TextureStreamingSystem: @unchecked Sendable {
lock.lock()
upgradedEntities.removeAll()
activeOps.removeAll()
pendingBatchRefreshEntities.removeAll()
lock.unlock()
timeSinceLastUpdate = 0
totalUpgrades = 0
Expand Down
20 changes: 10 additions & 10 deletions Tests/UntoldEngineTests/EntityAABBTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading