Skip to content
Open
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
41 changes: 35 additions & 6 deletions MetalSplatter/Sources/SplatSorter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -115,6 +116,19 @@ class SplatSorter: @unchecked Sendable {
struct CameraPose: Equatable {
var position: SIMD3<Float>
var forward: SIMD3<Float>

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
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -322,11 +340,18 @@ class SplatSorter: @unchecked Sendable {

/// Updates the camera pose, triggering a new sort if needed.
func updateCameraPose(position: SIMD3<Float>, forward: SIMD3<Float>) {
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)
Expand Down Expand Up @@ -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..<state.indexBuffers.count {
state.indexBuffers[i].isValid = false
}
state.mostRecentValidBufferIndex = nil
state.needsSort = true
state.needsSort = !state.chunks.isEmpty
return state.needsSort
}
if shouldSort {
ensureSortLoopRunning()
}
}

Expand Down Expand Up @@ -555,7 +584,7 @@ class SplatSorter: @unchecked Sendable {
guard let params = sortParams else {
// Nothing to sort or no buffer available, check if we should exit or wait
let shouldExit = state.withLock { state -> Bool in
!state.needsSort && state.chunks.isEmpty
!state.hasExclusiveAccess && !state.needsSort
}

if shouldExit {
Expand Down
76 changes: 76 additions & 0 deletions MetalSplatter/Tests/SplatSorterTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import XCTest
import Dispatch
import Synchronization
import Metal
import simd
@testable import MetalSplatter
Expand Down Expand Up @@ -325,6 +327,68 @@ final class SplatSorterTests: XCTestCase {
}
}

func testSortLoopStopsAfterSettledSortWithChunksLoaded() async throws {
let sorter = try SplatSorter(device: device)
let chunk = try makeChunkReference(positions: [
SIMD3<Float>(0, 0, -5),
SIMD3<Float>(0, 0, -2),
SIMD3<Float>(0, 0, -8),
], chunkIndex: 0)

sorter.setChunks([chunk])
sorter.updateCameraPose(position: SIMD3<Float>(0, 0, 0),
forward: SIMD3<Float>(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<Float>(0, 0, -5),
SIMD3<Float>(0, 0, -2),
SIMD3<Float>(0, 0, -8),
], chunkIndex: 0)

sorter.setChunks([chunk])
sorter.updateCameraPose(position: SIMD3<Float>(0, 0, 0),
forward: SIMD3<Float>(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<Float>(0.0001, 0, 0),
forward: SIMD3<Float>(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 {
Expand Down Expand Up @@ -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<T: Sendable>(seconds: TimeInterval, operation: @escaping @Sendable () async -> T?) async -> T? {
await withTaskGroup(of: T?.self) { group in
group.addTask {
Expand Down