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
111 changes: 82 additions & 29 deletions Sources/UntoldEngine/RuntimeAssets/NativeTextureLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,32 @@ public final class NativeTextureLoader: @unchecked Sendable {
private let device: MTLDevice
private let commandQueue: MTLCommandQueue

private struct CacheKey: Hashable {
let path: String
let targetMaxDimension: Int // 0 means "full resolution"
let deviceId: ObjectIdentifier
}

/// Guarded by cacheCondition (acts as both mutex and condition variable).
private nonisolated(unsafe) static var sharedCache: [CacheKey: MTLTexture] = [:]
// Keys currently being uploaded by exactly one thread; others wait on cacheCondition.
private nonisolated(unsafe) static var inFlight: Set<CacheKey> = []
private static let cacheCondition = NSCondition()

public init?(device: MTLDevice) {
guard let queue = device.makeCommandQueue() else { return nil }
self.device = device
commandQueue = queue
commandQueue.label = "NativeTextureLoader"
}

/// Evict all cached textures (call when a scene is unloaded or under memory pressure).
public static func purgeSharedCache() {
cacheCondition.lock()
defer { cacheCondition.unlock() }
sharedCache.removeAll()
}

// MARK: - Public entry points

/// Load a .utex file at `url` and return a GPU-private MTLTexture containing all mip levels.
Expand Down Expand Up @@ -63,40 +82,74 @@ public final class NativeTextureLoader: @unchecked Sendable {
// MARK: - Internal load

private func loadTextureOrThrow(from url: URL, targetMaxDimension: Int?, label: String?) throws -> MTLTexture {
// Memory-map the file — zero-copy read for the header and mip table.
let fileData = try Data(contentsOf: url, options: .mappedIfSafe)

let reader = NativeTexReader()
let (header, allMips) = try reader.read(from: fileData)
let key = CacheKey(path: url.standardizedFileURL.path, targetMaxDimension: targetMaxDimension ?? 0, deviceId: ObjectIdentifier(device))
let cond = NativeTextureLoader.cacheCondition

guard let pixelFormat = MTLPixelFormat(rawValue: UInt(header.pixelFormat)) else {
throw NativeTexLoadError.unsupportedPixelFormat(header.pixelFormat)
// Single-flight gate: if another thread is already uploading this key, wait for it
// rather than starting a duplicate upload. This prevents the startup burst where N
// tiles request the same shared texture concurrently.
cond.lock()
while NativeTextureLoader.inFlight.contains(key) {
cond.wait()
}
guard device.supportsPixelFormat(pixelFormat) else {
throw NativeTexLoadError.pixelFormatNotSupported(pixelFormat)
if let cached = NativeTextureLoader.sharedCache[key] {
cond.unlock()
return cached
}
NativeTextureLoader.inFlight.insert(key)
cond.unlock()

// Select the subset of mips to upload.
// Walk from mip 0 (largest) toward the end; use the first mip whose
// width fits within targetMaxDimension as the starting level.
// If targetMaxDimension is nil, start from mip 0 (full resolution).
let startMip: Int
if let maxDim = targetMaxDimension, maxDim > 0 {
startMip = allMips.firstIndex(where: { Int($0.widthPx) <= maxDim }) ?? (allMips.count - 1)
} else {
startMip = 0
}
let mips = Array(allMips[startMip...])
// Only one thread reaches here for a given key at any time.
do {
// Memory-map the file — zero-copy read for the header and mip table.
let fileData = try Data(contentsOf: url, options: .mappedIfSafe)

let texture = try makeTexture(
pixelFormat: pixelFormat,
width: Int(mips[0].widthPx),
height: Int(mips[0].heightPx),
mipCount: mips.count,
label: label
)
try upload(mips: mips, from: fileData, header: header, to: texture)
return texture
let reader = NativeTexReader()
let (header, allMips) = try reader.read(from: fileData)

guard let pixelFormat = MTLPixelFormat(rawValue: UInt(header.pixelFormat)) else {
throw NativeTexLoadError.unsupportedPixelFormat(header.pixelFormat)
}
guard device.supportsPixelFormat(pixelFormat) else {
throw NativeTexLoadError.pixelFormatNotSupported(pixelFormat)
}

// Select the subset of mips to upload.
// Walk from mip 0 (largest) toward the end; use the first mip whose
// width fits within targetMaxDimension as the starting level.
// If targetMaxDimension is nil, start from mip 0 (full resolution).
let startMip: Int
if let maxDim = targetMaxDimension, maxDim > 0 {
startMip = allMips.firstIndex(where: { Int($0.widthPx) <= maxDim }) ?? (allMips.count - 1)
} else {
startMip = 0
}
let mips = Array(allMips[startMip...])

let texture = try makeTexture(
pixelFormat: pixelFormat,
width: Int(mips[0].widthPx),
height: Int(mips[0].heightPx),
mipCount: mips.count,
label: label
)
try upload(mips: mips, from: fileData, header: header, to: texture)

cond.lock()
NativeTextureLoader.sharedCache[key] = texture
NativeTextureLoader.inFlight.remove(key)
cond.broadcast()
cond.unlock()

return texture
} catch {
// On failure, unblock any waiters so they don't hang indefinitely.
cond.lock()
NativeTextureLoader.inFlight.remove(key)
cond.broadcast()
cond.unlock()
throw error
}
}

// MARK: - Texture creation
Expand Down
2 changes: 2 additions & 0 deletions Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ public class GeometryStreamingSystem: @unchecked Sendable {
}
MemoryBudgetManager.shared.onMemoryPressureCritical = { [weak self] in
guard let self else { return }
NativeTextureLoader.purgeSharedCache()
withStateLock {
self.pendingPressureRelief = true
self.pressureIsAggressive = true
Expand Down Expand Up @@ -1532,6 +1533,7 @@ public class GeometryStreamingSystem: @unchecked Sendable {
firstRangeTimestamps.removeAll()
interiorZone = nil
}
NativeTextureLoader.purgeSharedCache()
}

/// Get streaming statistics
Expand Down
235 changes: 235 additions & 0 deletions Tests/UntoldEngineRenderTests/NativeTextureLoaderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//
// NativeTextureLoaderTests.swift
// UntoldEngine
//
// Tests for NativeTextureLoader: shared cache deduplication, single-flight
// concurrency, mip-tier separation, purge, error recovery, and key normalization.
//
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

@preconcurrency import Metal
@testable import UntoldEngine
import XCTest

// MARK: - Test class

@MainActor
final class NativeTextureLoaderTests: BaseRenderSetup {
private var loader: NativeTextureLoader!
private var tempDir: URL!

override func setUp() async throws {
try await super.setUp()
loader = NativeTextureLoader(device: renderInfo.device)
tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("NativeTextureLoaderTests_\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
NativeTextureLoader.purgeSharedCache()
}

override func tearDown() async throws {
NativeTextureLoader.purgeSharedCache()
try? FileManager.default.removeItem(at: tempDir)
loader = nil
try await super.tearDown()
}

override func initializeAssets() {}

// MARK: - Cache deduplication

func testCacheReturnsSameInstanceForSameURL() throws {
let url = try writeMinimalUtex(named: "dedup_test")
let t1 = loader.loadTexture(from: url)
let t2 = loader.loadTexture(from: url)
let tex1 = try XCTUnwrap(t1, "First load should succeed")
let tex2 = try XCTUnwrap(t2, "Second load should succeed")
XCTAssertIdentical(tex1, tex2, "Same URL must return the same MTLTexture instance from cache")
}

func testCacheSeparatesTiersForSameURL() throws {
let url = try writeMinimalUtex(named: "tier_test", mipSizes: [128, 64, 32])
let full = loader.loadTexture(from: url, targetMaxDimension: nil)
let small = loader.loadTexture(from: url, targetMaxDimension: 64)
let texFull = try XCTUnwrap(full)
let texSmall = try XCTUnwrap(small)
XCTAssertNotIdentical(texFull, texSmall, "Different mip tiers must produce separate MTLTexture instances")
XCTAssertGreaterThan(texFull.width, texSmall.width, "Full-res texture must be wider than the capped tier")
}

// MARK: - Single-flight concurrency

func testConcurrentRequestsForSameKeyReturnSameInstance() async throws {
let url = try writeMinimalUtex(named: "concurrent_test")
let device = try XCTUnwrap(renderInfo.device)

// Spin up 8 concurrent tasks that all request the same URL.
// withTaskGroup collects results without any shared mutable state.
let textures: [MTLTexture] = try await withThrowingTaskGroup(of: MTLTexture.self) { group in
for _ in 0 ..< 8 {
group.addTask {
let loader = try XCTUnwrap(NativeTextureLoader(device: device))
return try XCTUnwrap(loader.loadTexture(from: url), "Concurrent load must not return nil")
}
}
var collected: [MTLTexture] = []
for try await tex in group {
collected.append(tex)
}
return collected
}

XCTAssertEqual(textures.count, 8, "All 8 concurrent loads must succeed")
let first = try XCTUnwrap(textures.first)
for (i, tex) in textures.enumerated() {
XCTAssertIdentical(tex, first, "Load \(i) must return the same cached MTLTexture instance")
}
}

// MARK: - Purge

func testPurgeAllowsFreshLoad() throws {
let url = try writeMinimalUtex(named: "purge_test")
let before = try XCTUnwrap(loader.loadTexture(from: url))
NativeTextureLoader.purgeSharedCache()
let after = try XCTUnwrap(loader.loadTexture(from: url))
XCTAssertNotIdentical(before, after, "After purge, the loader must allocate a new MTLTexture")
}

func testPurgeDoesNotHangWithNoCache() {
// Should complete immediately without deadlock even on an empty cache.
NativeTextureLoader.purgeSharedCache()
NativeTextureLoader.purgeSharedCache()
}

// MARK: - Missing / bad file

func testMissingFileReturnsNil() {
let missing = tempDir.appendingPathComponent("does_not_exist.utex")
let result = loader.loadTexture(from: missing)
XCTAssertNil(result, "Loading a non-existent file must return nil, not crash")
}

func testCorruptFileReturnsNil() throws {
let url = tempDir.appendingPathComponent("corrupt.utex")
try Data([0xDE, 0xAD, 0xBE, 0xEF]).write(to: url)
let result = loader.loadTexture(from: url)
XCTAssertNil(result, "Loading a corrupt file must return nil, not crash")
}

func testFailedLoadReleasesInFlightSoSubsequentLoadSucceeds() throws {
// Write a corrupt file at a URL, attempt to load it (clears in-flight on error),
// then overwrite with a valid file and verify the second load succeeds.
let url = tempDir.appendingPathComponent("recovery.utex")
try Data([0x00]).write(to: url)
_ = loader.loadTexture(from: url) // expected nil — also clears in-flight entry

let valid = try makeMinimalUtexData(mipSizes: [4])
try valid.write(to: url)
NativeTextureLoader.purgeSharedCache() // clear the nil result (if any) from cache

let result = loader.loadTexture(from: url)
XCTAssertNotNil(result, "After a failed load clears in-flight, a subsequent valid load must succeed")
}

// MARK: - Key normalization

func testStandardizedPathDeduplicatesEquivalentURLs() throws {
let url = try writeMinimalUtex(named: "normalize_test")
// Construct an equivalent URL with a redundant path component.
var components = url.pathComponents
components.insert(".", at: components.count - 1)
let dotURL = try XCTUnwrap(NSURL.fileURL(withPathComponents: components))

let t1 = loader.loadTexture(from: url)
let t2 = loader.loadTexture(from: dotURL)
let tex1 = try XCTUnwrap(t1)
let tex2 = try XCTUnwrap(t2)
XCTAssertIdentical(tex1, tex2, "Equivalent paths (with redundant components) must hit the same cache entry")
}

// MARK: - Texture properties

func testLoadedTextureHasExpectedDimensions() throws {
let url = try writeMinimalUtex(named: "dims_test", mipSizes: [64])
let tex = try XCTUnwrap(loader.loadTexture(from: url))
XCTAssertEqual(tex.width, 64)
XCTAssertEqual(tex.height, 64)
}

func testTargetMaxDimensionSelectsCorrectMip() throws {
// Build a 3-mip chain: 128, 64, 32. Requesting cap=64 must pick the 64-wide mip.
let url = try writeMinimalUtex(named: "mip_select_test", mipSizes: [128, 64, 32])
let tex = try XCTUnwrap(loader.loadTexture(from: url, targetMaxDimension: 64))
XCTAssertLessThanOrEqual(tex.width, 64, "Requested cap=64 must not return a texture wider than 64px")
}

// MARK: - Fixture helpers

/// Writes a minimal valid single-mip `.utex` file to the temp directory and returns its URL.
private func writeMinimalUtex(named name: String, mipSizes: [Int] = [4]) throws -> URL {
let data = try makeMinimalUtexData(mipSizes: mipSizes)
let url = tempDir.appendingPathComponent("\(name).utex")
try data.write(to: url)
return url
}

/// Builds a minimal valid `.utex` container in memory.
///
/// Each entry in `mipSizes` produces one mip level of that width (square).
/// Payload bytes are zeroed ASTC blocks — valid as-is since ASTC is decoded
/// on-GPU; the CPU only sees opaque 16-byte blocks.
private func makeMinimalUtexData(mipSizes: [Int]) throws -> Data {
// ASTC 4×4 sRGB — raw value 186 (MTLPixelFormat.astc_4x4_sRGB)
let pixelFormat: UInt32 = 186
let blockW: UInt8 = 4
let blockH: UInt8 = 4

// Build the mip table and compute payload sizes.
struct MipInfo { let widthPx: UInt32; let heightPx: UInt32; let byteSize: UInt32 }
let mipInfos: [MipInfo] = mipSizes.map { side in
let blocks = max(1, (side + Int(blockW) - 1) / Int(blockW))
let bytes = UInt32(blocks * blocks * 16)
return MipInfo(widthPx: UInt32(side), heightPx: UInt32(side), byteSize: bytes)
}

let mipCount = UInt32(mipInfos.count)
let payloadOffset = NativeTexFormat.payloadOffset(mipCount: Int(mipCount))
let totalPayload = mipInfos.reduce(0) { $0 + $1.byteSize }

let header = NativeTexHeader(
flags: 0,
width: mipInfos[0].widthPx,
height: mipInfos[0].heightPx,
mipCount: mipCount,
pixelFormat: pixelFormat,
blockWidth: blockW,
blockHeight: blockH,
payloadOffset: payloadOffset,
totalPayloadSize: totalPayload
)

let writer = UntoldBinaryWriter()
header.encode(to: writer)

var byteOffset: UInt32 = 0
for mip in mipInfos {
NativeTexMipEntry(
byteOffset: byteOffset,
byteSize: mip.byteSize,
widthPx: mip.widthPx,
heightPx: mip.heightPx
).encode(to: writer)
byteOffset += mip.byteSize
}

// Zero-filled ASTC blocks (valid: GPU decodes them as opaque black).
writer.writeData(Data(count: Int(totalPayload)))
return writer.data
}
}
Loading