From c86cbb7dc3e58c1adc854c7b00fb35e2662a4625 Mon Sep 17 00:00:00 2001 From: StoneHub Date: Thu, 28 May 2026 12:09:19 -0400 Subject: [PATCH] Stop settled sorter loop --- MetalSplatter/Sources/SplatSorter.swift | 41 ++++++++++-- MetalSplatter/Tests/SplatSorterTests.swift | 76 ++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/MetalSplatter/Sources/SplatSorter.swift b/MetalSplatter/Sources/SplatSorter.swift index b9058c5e..970a2518 100644 --- a/MetalSplatter/Sources/SplatSorter.swift +++ b/MetalSplatter/Sources/SplatSorter.swift @@ -82,6 +82,7 @@ class SplatSorter: @unchecked Sendable { private static var bufferCount: Int { 3 } private static var pollIntervalNanoseconds: UInt64 { 1_000_000 } // 1ms + private static var cameraPoseEpsilonSquared: Float { 0.000001 } // MARK: - Types @@ -115,6 +116,19 @@ class SplatSorter: @unchecked Sendable { struct CameraPose: Equatable { var position: SIMD3 var forward: SIMD3 + + func requiresSort(comparedTo other: CameraPose) -> Bool { + if (position - other.position).lengthSquared > SplatSorter.cameraPoseEpsilonSquared { + return true + } + + if !SplatRenderer.Constants.sortByDistance, + (forward - other.forward).lengthSquared > SplatSorter.cameraPoseEpsilonSquared { + return true + } + + return false + } } // MARK: - State @@ -166,6 +180,10 @@ class SplatSorter: @unchecked Sendable { } } + var isSortLoopRunningForTesting: Bool { + state.withLock { $0.sortLoopRunning } + } + /// Sets the chunks to sort. Must be called within `withExclusiveAccess` for thread safety, /// or during initial setup before any sorting begins. func setChunks(_ chunks: [ChunkReference]) { @@ -322,11 +340,18 @@ class SplatSorter: @unchecked Sendable { /// Updates the camera pose, triggering a new sort if needed. func updateCameraPose(position: SIMD3, forward: SIMD3) { - state.withLock { state in - state.cameraPose = CameraPose(position: position, forward: forward) + let shouldSort = state.withLock { state -> Bool in + let cameraPose = CameraPose(position: position, forward: forward) + guard cameraPose.requiresSort(comparedTo: state.cameraPose) else { + return false + } + state.cameraPose = cameraPose state.needsSort = true + return true + } + if shouldSort { + ensureSortLoopRunning() } - ensureSortLoopRunning() } // MARK: - Index Buffer Access (Scoped - Preferred) @@ -399,12 +424,16 @@ class SplatSorter: @unchecked Sendable { /// Use this when chunk contents have been reordered in place. /// Any unreleased references become stale - callers should release them promptly. func invalidateAllBuffers() { - state.withLock { state in + let shouldSort = state.withLock { state -> Bool in for i in 0.. Bool in - !state.needsSort && state.chunks.isEmpty + !state.hasExclusiveAccess && !state.needsSort } if shouldExit { diff --git a/MetalSplatter/Tests/SplatSorterTests.swift b/MetalSplatter/Tests/SplatSorterTests.swift index 422c003b..7d324127 100644 --- a/MetalSplatter/Tests/SplatSorterTests.swift +++ b/MetalSplatter/Tests/SplatSorterTests.swift @@ -1,4 +1,6 @@ import XCTest +import Dispatch +import Synchronization import Metal import simd @testable import MetalSplatter @@ -325,6 +327,68 @@ final class SplatSorterTests: XCTestCase { } } + func testSortLoopStopsAfterSettledSortWithChunksLoaded() async throws { + let sorter = try SplatSorter(device: device) + let chunk = try makeChunkReference(positions: [ + SIMD3(0, 0, -5), + SIMD3(0, 0, -2), + SIMD3(0, 0, -8), + ], chunkIndex: 0) + + sorter.setChunks([chunk]) + sorter.updateCameraPose(position: SIMD3(0, 0, 0), + forward: SIMD3(0, 0, -1)) + + let buffer = await withTimeout(seconds: 2) { + await sorter.obtainSortedIndices() + } + XCTAssertNotNil(buffer, "Initial sort should complete") + if let buffer { + sorter.releaseSortedIndices(buffer) + } + + let didStop = await waitForSortLoopToStop(sorter) + + XCTAssertEqual(didStop, true, "Sort loop should stop when the settled scene has no pending sort") + } + + func testTinyCameraPoseUpdateDoesNotTriggerResort() async throws { + let sorter = try SplatSorter(device: device) + let sortStartCount = Mutex(0) + sorter.onSortStart = { + sortStartCount.withLock { $0 += 1 } + } + + let chunk = try makeChunkReference(positions: [ + SIMD3(0, 0, -5), + SIMD3(0, 0, -2), + SIMD3(0, 0, -8), + ], chunkIndex: 0) + + sorter.setChunks([chunk]) + sorter.updateCameraPose(position: SIMD3(0, 0, 0), + forward: SIMD3(0, 0, -1)) + + let buffer = await withTimeout(seconds: 2) { + await sorter.obtainSortedIndices() + } + XCTAssertNotNil(buffer, "Initial sort should complete") + if let buffer { + sorter.releaseSortedIndices(buffer) + } + + _ = await waitForSortLoopToStop(sorter) + + XCTAssertEqual(sortStartCount.withLock { $0 }, 1) + + sorter.updateCameraPose(position: SIMD3(0.0001, 0, 0), + forward: SIMD3(0, 0.0001, -1)) + + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(sortStartCount.withLock { $0 }, 1, "Tiny camera pose changes should not trigger a new sort") + } + // MARK: - Edge Cases func testEmptyChunks() async throws { @@ -409,6 +473,18 @@ final class SplatSorterTests: XCTestCase { // MARK: - Test Helpers extension SplatSorterTests { + func waitForSortLoopToStop(_ sorter: SplatSorter, + timeoutNanoseconds: UInt64 = 2_000_000_000) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while sorter.isSortLoopRunningForTesting { + if DispatchTime.now().uptimeNanoseconds >= deadline { + return false + } + try? await Task.sleep(nanoseconds: 1_000_000) + } + return true + } + func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async -> T?) async -> T? { await withTaskGroup(of: T?.self) { group in group.addTask {