├── .gitignore ├── Package.swift ├── README.md ├── Sources ├── SimulationTools │ ├── CollisionDetection │ │ └── BroadPhase │ │ │ ├── BitonicSort │ │ │ ├── BitonicSort.metal │ │ │ ├── BitonicSort.swift │ │ │ ├── BitonicSortFinalPass.swift │ │ │ ├── BitonicSortFirstPass.swift │ │ │ └── BitonicSortGeneralPass.swift │ │ │ ├── CollisionType.swift │ │ │ ├── SpatialHashing.metal │ │ │ ├── SpatialHasing.swift │ │ │ ├── TriangleSpatialHashing.metal │ │ │ └── TriangleSpatialHashing.swift │ ├── Common │ │ ├── BroadPhaseCommon.h │ │ ├── Definitions.h │ │ ├── DistanceFunctions.h │ │ ├── Extensions.swift │ │ ├── MTLBufferAllocator.swift │ │ └── MTLTypedBuffer.swift │ └── SimulationTools.swift └── SimulationToolsSharedTypes │ ├── SimulationToolsSharedTypes.c │ └── SimulationToolsSharedTypes.h └── Tests └── SimulationToolsTests ├── SpatialHashingTests.swift └── TriangleSpatialHashingTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .swiftpm 92 | *.DS_Store 93 | Package.resolved 94 | *.swiftformat 95 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "simulation-tools", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macOS(.v11), 11 | .macCatalyst(.v14) 12 | ], 13 | products: [ 14 | .library( 15 | name: "SimulationTools", 16 | targets: ["SimulationTools"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package( 21 | url: "https://github.com/eugenebokhan/metal-tools", 22 | .upToNextMajor(from: "1.2.0") 23 | ) 24 | ], 25 | targets: [ 26 | .target( 27 | name: "SimulationToolsSharedTypes", 28 | publicHeadersPath: "." 29 | ), 30 | .target( 31 | name: "SimulationTools", 32 | dependencies: [ 33 | .product(name: "MetalTools", package: "metal-tools"), 34 | .target(name: "SimulationToolsSharedTypes") 35 | ], 36 | resources: [ 37 | .process("CollisionDetection/BroadPhase/BitonicSort/BitonicSort.metal"), 38 | .process("CollisionDetection/BroadPhase/SpatialHashing.metal"), 39 | ] 40 | ), 41 | .testTarget( 42 | name: "SimulationToolsTests", 43 | dependencies: ["SimulationTools"] 44 | ), 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimulationTools 2 | 3 | ## Description 4 | 5 | SimulationTools is a GPU-accelerated library using Metal for various high-performance algorithms in 3D simulations and computational geometry. 6 | 7 | ## Key Components 8 | 9 | ### Spatial Hashing 10 | 11 | Spatial hashing divides 3D space into a grid, enabling efficient neighbor searches and collision detection. 12 | 13 | #### Algorithm Details: 14 | 1. Hash Computation: Each vertex is assigned to a cell based on its position. 15 | 2. Bitonic Sort: Hash-index pairs are sorted to resolve collisions and build cell buckets. 16 | 3. Cell Boundary Identification: Start and end indices for each cell are determined. 17 | 4. Collision Candidate Search: Potential colliders are identified within the same and adjacent cells. 18 | 5. Insertion Sort Storage: Collision candidates are stored using an insertion sort mechanism, maintaining a sorted list of the closest candidates. 19 | 20 | #### Key Features: 21 | - Efficient for both self-collision and external collision detection 22 | - Insertion sort storage allows for temporal and structural reuse of candidates, improving performance in scenarios with coherent motion or stable structures 23 | 24 | ### Triangle Spatial Hashing 25 | 26 | Optimized for triangle meshes, this variant enables efficient point-triangle collision detection. 27 | 28 | #### Algorithm Details: 29 | 1. Triangle Hashing: Triangles are hashed into multiple cells they overlap. 30 | 2. Bucket Storage: Each cell maintains a fixed-size bucket of triangle indices. 31 | 3. Collision Candidate Search: For each query point, nearby triangles are identified through cell lookups. 32 | 4. Candidate Refinement: Distance computations are performed to sort and store the closest triangle candidates. 33 | 34 | #### Key Features: 35 | - Specialized for triangle mesh collision detection 36 | - Supports both self-collision and external collision queries 37 | - Efficient spatial reuse of collision information for improved performance in temporally coherent scenarios 38 | 39 | ## Performance Considerations 40 | 41 | - GPU-optimized using Metal for scalability with large numbers of elements 42 | - Insertion sort storage of candidates enables efficient updates in scenarios with small position changes 43 | - Triangle spatial hashing optimizes memory usage through fixed-size buckets per cell 44 | - Performance is sensitive to cell size: smaller cells increase precision but may reduce efficiency for large objects 45 | 46 | ## Usage Examples 47 | 48 | ### Spatial Hashing 49 | 50 | Here are a few examples demonstrating the use of SpatialHashing for both self-collision and external collision scenarios: 51 | 52 | ```swift 53 | import Metal 54 | import SimulationTools 55 | 56 | // MARK: - Basic Spatial Hashing Example 57 | 58 | // Initialize Metal device and command queue 59 | guard let device = MTLCreateSystemDefaultDevice() else { return } 60 | guard let commandQueue = device.makeCommandQueue() else { return } 61 | 62 | // Create a set of test positions 63 | let positions: [SIMD3] = [ 64 | [0.0, 0.0, 0.0], 65 | [0.5, 0.0, 0.0], 66 | [0.0, 0.5, 0.0], 67 | [1.0, 1.0, 1.0], 68 | [1.1, 1.1, 1.1] 69 | ] 70 | 71 | // Configure spatial hashing 72 | let config = SpatialHashing.Configuration(cellSize: 1.0, radius: 0.5) 73 | 74 | // Create SpatialHashing instance 75 | let spatialHashing = try SpatialHashing( 76 | device: device, 77 | configuration: config, 78 | maxPositionsCount: positions.count 79 | ) 80 | 81 | // Create buffers 82 | let positionsBuffer = try device.buffer(with: positions) 83 | let typedPositionsBuffer = try device.typedBuffer(with: positions, valueType: .float3) 84 | let collisionCandidatesBuffer = try device.typedBuffer( 85 | with: Array(repeating: UInt32.max, count: positions.count * 8), 86 | valueType: .uint 87 | ) 88 | 89 | // Create and execute command buffer 90 | guard let commandBuffer = commandQueue.makeCommandBuffer() else { return } 91 | spatialHashing.build(positions: typedPositionsBuffer, in: commandBuffer) 92 | spatialHashing.find( 93 | collidablePositions: nil, 94 | collisionCandidates: collisionCandidatesBuffer, 95 | connectedVertices: nil, 96 | in: commandBuffer 97 | ) 98 | commandBuffer.commit() 99 | commandBuffer.waitUntilCompleted() 100 | 101 | // Process results 102 | guard let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()?.chunked(into: 8) else { return } 103 | for (index, candidates) in collisionCandidates.enumerated() { 104 | print("Collision candidates for position \(index): \(candidates.filter { $0 != .max })") 105 | } 106 | 107 | // MARK: - External Collision Example 108 | 109 | // Create a set of mesh positions and external query positions 110 | let meshPositions: [SIMD3] = [ 111 | [0.0, 0.0, 0.0], 112 | [1.0, 0.0, 0.0], 113 | [0.0, 1.0, 0.0], 114 | [1.0, 1.0, 0.0] 115 | ] 116 | let queryPositions: [SIMD3] = [ 117 | [0.5, 0.5, 0.0], 118 | [1.5, 1.5, 0.0] 119 | ] 120 | 121 | // Configure spatial hashing 122 | let externalConfig = SpatialHashing.Configuration(cellSize: 1.0, radius: 0.5) 123 | 124 | // Create SpatialHashing instance 125 | let externalSpatialHashing = try SpatialHashing( 126 | device: device, 127 | configuration: externalConfig, 128 | maxPositionsCount: max(meshPositions.count, queryPositions.count) 129 | ) 130 | 131 | // Create buffers 132 | let meshPositionsBuffer = try device.typedBuffer(with: meshPositions, valueType: .float3) 133 | let queryPositionsBuffer = try device.typedBuffer(with: queryPositions, valueType: .float3) 134 | let externalCollisionCandidatesBuffer = try device.typedBuffer( 135 | with: Array(repeating: UInt32.max, count: queryPositions.count * 8), 136 | valueType: .uint 137 | ) 138 | 139 | // Create and execute command buffer 140 | guard let externalCommandBuffer = commandQueue.makeCommandBuffer() else { return } 141 | externalSpatialHashing.build(positions: meshPositionsBuffer, in: externalCommandBuffer) 142 | externalSpatialHashing.find( 143 | collidablePositions: queryPositionsBuffer, 144 | collisionCandidates: externalCollisionCandidatesBuffer, 145 | connectedVertices: nil, 146 | in: externalCommandBuffer 147 | ) 148 | externalCommandBuffer.commit() 149 | externalCommandBuffer.waitUntilCompleted() 150 | 151 | // Process results 152 | guard let externalCollisionCandidates: [[UInt32]] = externalCollisionCandidatesBuffer.values()?.chunked(into: 8) else { return } 153 | for (index, candidates) in externalCollisionCandidates.enumerated() { 154 | print("Collision candidates for query position \(index): \(candidates.filter { $0 != .max })") 155 | } 156 | 157 | // MARK: - Triangle Spatial Hashing Example 158 | 159 | // Create a simple triangle mesh 160 | let triangleMeshPositions: [SIMD3] = [ 161 | [0.0, 0.0, 0.0], 162 | [1.0, 0.0, 0.0], 163 | [0.0, 1.0, 0.0], 164 | [1.0, 1.0, 0.0] 165 | ] 166 | let triangles: [SIMD3] = [ 167 | [0, 1, 2], 168 | [1, 3, 2] 169 | ] 170 | 171 | // Create query positions 172 | let triangleQueryPositions: [SIMD3] = [ 173 | [0.5, 0.5, 0.0], 174 | [1.5, 1.5, 0.0] 175 | ] 176 | 177 | // Configure triangle spatial hashing 178 | let triangleConfig = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 179 | 180 | // Create TriangleSpatialHashing instance 181 | let triangleSpatialHashing = try TriangleSpatialHashing( 182 | device: device, 183 | configuration: triangleConfig, 184 | maxTrianglesCount: triangles.count 185 | ) 186 | 187 | // Create buffers 188 | let triangleMeshPositionsBuffer = try device.typedBuffer(with: triangleMeshPositions, valueType: .float3) 189 | let trianglesBuffer = try device.typedBuffer(with: triangles, valueType: .uint3) 190 | let triangleQueryPositionsBuffer = try device.typedBuffer(with: triangleQueryPositions, valueType: .float3) 191 | let triangleCollisionCandidatesBuffer = try device.typedBuffer( 192 | with: Array(repeating: UInt32.max, count: triangleQueryPositions.count * 8), 193 | valueType: .uint 194 | ) 195 | 196 | // Create and execute command buffer 197 | guard let triangleCommandBuffer = commandQueue.makeCommandBuffer() else { return } 198 | triangleSpatialHashing.build( 199 | colliderPositions: triangleMeshPositionsBuffer, 200 | indices: trianglesBuffer, 201 | in: triangleCommandBuffer 202 | ) 203 | triangleSpatialHashing.find( 204 | collidablePositions: triangleQueryPositionsBuffer, 205 | colliderPositions: triangleMeshPositionsBuffer, 206 | indices: trianglesBuffer, 207 | collisionCandidates: triangleCollisionCandidatesBuffer, 208 | in: triangleCommandBuffer 209 | ) 210 | triangleCommandBuffer.commit() 211 | triangleCommandBuffer.waitUntilCompleted() 212 | 213 | // Process results 214 | guard let triangleCollisionCandidates: [[UInt32]] = triangleCollisionCandidatesBuffer.values()?.chunked(into: 8) else { return } 215 | for (index, candidates) in triangleCollisionCandidates.enumerated() { 216 | print("Collision candidates for triangle query position \(index): \(candidates.filter { $0 != .max })") 217 | } 218 | ``` 219 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/BitonicSort/BitonicSort.metal: -------------------------------------------------------------------------------- 1 | #include 2 | using namespace metal; 3 | 4 | #include "../../../Common/Definitions.h" 5 | 6 | #define SORT(F,L,R) { \ 7 | const auto v = sort(F,L,R); \ 8 | (L) = uint2(v.x, v.y); \ 9 | (R) = uint2(v.z, v.w); \ 10 | } \ 11 | 12 | static constexpr int genLeftIndex( 13 | const uint position, 14 | const uint blockSize 15 | ) { 16 | const uint32_t blockMask = blockSize - 1; 17 | const auto no = position & blockMask; // comparator No. in block 18 | return ((position & ~blockMask) << 1) | no; 19 | } 20 | 21 | static uint4 sort( 22 | const bool reverse, 23 | uint2 left, 24 | uint2 right 25 | ) { 26 | const bool lt = left.x < right.x; 27 | const bool swap = !lt ^ reverse; 28 | const bool4 dir = bool4(swap, swap, !swap, !swap); // (lt, gte) or (gte, lt) 29 | const uint4 v = select(uint4(left.x, left.y, left.x, left.y), 30 | uint4(right.x, right.y, right.x, right.y), 31 | dir); 32 | return v; 33 | } 34 | 35 | static void loadShared( 36 | const uint threadGroupSize, 37 | const uint indexInThreadgroup, 38 | const uint position, 39 | device uint2* data, 40 | threadgroup uint2* shared 41 | ) { 42 | const auto index = genLeftIndex(position, threadGroupSize); 43 | shared[indexInThreadgroup] = data[index]; 44 | shared[indexInThreadgroup | threadGroupSize] = data[index | threadGroupSize]; 45 | } 46 | 47 | static void storeShared( 48 | const uint threadGroupSize, 49 | const uint indexInThreadgroup, 50 | const uint position, 51 | device uint2* data, 52 | threadgroup uint2* shared 53 | ) { 54 | const auto index = genLeftIndex(position, threadGroupSize); 55 | data[index] = shared[indexInThreadgroup]; 56 | data[index | threadGroupSize] = shared[indexInThreadgroup | threadGroupSize]; 57 | } 58 | 59 | kernel void bitonicSortFirstPass( 60 | device uint2* data [[ buffer(0) ]], 61 | constant uint& gridSize [[ buffer(1) ]], 62 | threadgroup uint2* shared [[ threadgroup(0) ]], 63 | const uint threadgroupSize [[ threads_per_threadgroup ]], 64 | const uint indexInThreadgroup [[ thread_index_in_threadgroup ]], 65 | const uint position [[ thread_position_in_grid ]] 66 | ) { 67 | if (deviceDoesntSupportNonuniformThreadgroups && position >= gridSize) { return; } 68 | loadShared(threadgroupSize, indexInThreadgroup, position, data, shared); 69 | threadgroup_barrier(mem_flags::mem_threadgroup); 70 | for (uint unitSize = 1; unitSize <= threadgroupSize; unitSize <<= 1) { 71 | const bool reverse = (position & (unitSize)) != 0; // to toggle direction 72 | for (uint blockSize = unitSize; 0 < blockSize; blockSize >>= 1) { 73 | const auto left = genLeftIndex(indexInThreadgroup, blockSize); 74 | SORT(reverse, shared[left], shared[left | blockSize]); 75 | threadgroup_barrier(mem_flags::mem_threadgroup); 76 | } 77 | } 78 | storeShared(threadgroupSize, indexInThreadgroup, position, data, shared); 79 | } 80 | 81 | kernel void bitonicSortGeneralPass( 82 | device uint2* data [[ buffer(0) ]], 83 | constant uint& gridSize [[ buffer(1) ]], 84 | constant uint2& params [[ buffer(2) ]], 85 | const uint position [[ thread_position_in_grid ]] 86 | ) { 87 | if (deviceDoesntSupportNonuniformThreadgroups && position >= gridSize) { return; } 88 | const bool reverse = (position & (params.x >> 1)) != 0; // to toggle direction 89 | const uint blockSize = params.y; // size of comparison sets 90 | const auto left = genLeftIndex(position, blockSize); 91 | SORT(reverse, data[left], data[left | blockSize]); 92 | } 93 | 94 | kernel void bitonicSortFinalPass( 95 | device uint2* data, 96 | constant uint& gridSize [[ buffer(1) ]], 97 | constant uint2& params [[ buffer(2) ]], 98 | threadgroup uint2* shared [[ threadgroup(0) ]], 99 | const uint threadgroupSize [[ threads_per_threadgroup ]], 100 | const uint indexInThreadgroup [[ thread_index_in_threadgroup ]], 101 | const uint position [[ thread_position_in_grid ]] 102 | ) { 103 | if (deviceDoesntSupportNonuniformThreadgroups && position >= gridSize) { return; } 104 | loadShared(threadgroupSize, indexInThreadgroup, position, data, shared); 105 | const auto unitSize = params.x; 106 | const auto blockSize = params.y; 107 | const auto num = 10 + 1; 108 | // Toggle direction. 109 | const bool reverse = (position & (unitSize >> 1)) != 0; 110 | for (uint i = 0; i < num; ++i) { 111 | const auto width = blockSize >> i; 112 | const auto left = genLeftIndex(indexInThreadgroup, width); 113 | SORT(reverse, shared[left], shared[left | width]); 114 | threadgroup_barrier(mem_flags::mem_threadgroup); 115 | } 116 | storeShared(threadgroupSize, indexInThreadgroup, position, data, shared); 117 | } 118 | 119 | #undef SORT 120 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/BitonicSort/BitonicSort.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | 3 | final class BitonicSort { 4 | // MARK: - Properties 5 | 6 | private let firstPass: FirstPass 7 | private let generalPass: GeneralPass 8 | private let finalPass: FinalPass 9 | 10 | init( 11 | library: MTLLibrary 12 | ) throws { 13 | self.firstPass = try .init( 14 | library: library 15 | ) 16 | self.generalPass = try .init( 17 | library: library 18 | ) 19 | self.finalPass = try .init( 20 | library: library 21 | ) 22 | } 23 | 24 | func encode( 25 | data: MTLBuffer, 26 | count: Int, 27 | in commandBuffer: MTLCommandBuffer 28 | ) { 29 | let elementStride = data.length / count 30 | let gridSize = count >> 1 31 | let unitSize = min( 32 | gridSize, 33 | self.generalPass 34 | .pipelineState 35 | .maxTotalThreadsPerThreadgroup 36 | ) 37 | 38 | var params = SIMD2(repeating: 1) 39 | 40 | self.firstPass.encode( 41 | data: data, 42 | elementStride: elementStride, 43 | gridSize: gridSize, 44 | unitSize: unitSize, 45 | in: commandBuffer 46 | ) 47 | params.x = .init(unitSize << 1) 48 | 49 | while params.x < count { 50 | params.y = params.x 51 | params.x <<= 1 52 | repeat { 53 | if unitSize < params.y { 54 | self.generalPass.encode( 55 | data: data, 56 | params: params, 57 | gridSize: gridSize, 58 | unitSize: unitSize, 59 | in: commandBuffer 60 | ) 61 | params.y >>= 1 62 | } else { 63 | self.finalPass.encode( 64 | data: data, 65 | elementStride: elementStride, 66 | params: params, 67 | gridSize: gridSize, 68 | unitSize: unitSize, 69 | in: commandBuffer 70 | ) 71 | params.y = .zero 72 | } 73 | } while params.y > .zero 74 | } 75 | } 76 | 77 | static func buffer( 78 | count: Int, 79 | options: MTLResourceOptions = .cpuCacheModeWriteCombined, 80 | bufferAllocator: MTLBufferAllocator 81 | ) throws -> (buffer: MTLBuffer, paddedCount: Int) { 82 | return try Self.buffer( 83 | count: count, 84 | paddingValue: SIMD2(UInt32.max, UInt32.max), 85 | bufferAllocator: bufferAllocator 86 | ) 87 | } 88 | 89 | private static func buffer( 90 | count: Int, 91 | paddingValue: T, 92 | bufferAllocator: MTLBufferAllocator 93 | ) throws -> (buffer: MTLBuffer, paddedCount: Int) { 94 | let paddedCount = 1 << UInt(ceil(log2f(.init(count)))) 95 | var count = count 96 | if paddedCount > count { 97 | count += paddedCount - count 98 | } 99 | return try ( 100 | buffer: bufferAllocator.buffer(for: T.self, count: count), 101 | paddedCount: paddedCount 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/BitonicSort/BitonicSortFinalPass.swift: -------------------------------------------------------------------------------- 1 | import MetalTools 2 | 3 | extension BitonicSort { 4 | final class FinalPass { 5 | // MARK: - Properties 6 | 7 | let pipelineState: MTLComputePipelineState 8 | private let deviceSupportsNonuniformThreadgroups: Bool 9 | 10 | // MARK: - Init 11 | 12 | init( 13 | library: MTLLibrary 14 | ) throws { 15 | self.deviceSupportsNonuniformThreadgroups = library.device 16 | .supports(feature: .nonUniformThreadgroups) 17 | 18 | let constantValues = MTLFunctionConstantValues() 19 | constantValues.set( 20 | self.deviceSupportsNonuniformThreadgroups, 21 | at: 0 22 | ) 23 | 24 | self.pipelineState = try library.computePipelineState( 25 | function: "bitonicSortFinalPass", 26 | constants: constantValues 27 | ) 28 | } 29 | 30 | // MARK: - Encode 31 | 32 | func encode( 33 | data: MTLBuffer, 34 | elementStride: Int, 35 | params: SIMD2, 36 | gridSize: Int, 37 | unitSize: Int, 38 | in commandBuffer: MTLCommandBuffer 39 | ) { 40 | commandBuffer.compute { encoder in 41 | encoder.label = "Bitonic Sort Final Pass" 42 | self.encode( 43 | data: data, 44 | elementStride: elementStride, 45 | params: params, 46 | gridSize: gridSize, 47 | unitSize: unitSize, 48 | using: encoder 49 | ) 50 | } 51 | } 52 | 53 | func encode( 54 | data: MTLBuffer, 55 | elementStride: Int, 56 | params: SIMD2, 57 | gridSize: Int, 58 | unitSize: Int, 59 | using encoder: MTLComputeCommandEncoder 60 | ) { 61 | encoder.setBuffers(data) 62 | encoder.setValue(UInt32(gridSize), at: 1) 63 | encoder.setValue(params, at: 2) 64 | 65 | encoder.setThreadgroupMemoryLength( 66 | (elementStride * unitSize) << 1, 67 | index: 0 68 | ) 69 | 70 | if self.deviceSupportsNonuniformThreadgroups { 71 | encoder.dispatch1d( 72 | state: self.pipelineState, 73 | exactly: gridSize, 74 | threadgroupWidth: unitSize 75 | ) 76 | } else { 77 | encoder.dispatch1d( 78 | state: self.pipelineState, 79 | covering: gridSize, 80 | threadgroupWidth: unitSize 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/BitonicSort/BitonicSortFirstPass.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | 3 | extension BitonicSort { 4 | final class FirstPass { 5 | // MARK: - Properties 6 | 7 | let pipelineState: MTLComputePipelineState 8 | private let deviceSupportsNonuniformThreadgroups: Bool 9 | 10 | // MARK: - Init 11 | 12 | init( 13 | library: MTLLibrary 14 | ) throws { 15 | self.deviceSupportsNonuniformThreadgroups = library.device 16 | .supports(feature: .nonUniformThreadgroups) 17 | 18 | let constantValues = MTLFunctionConstantValues() 19 | constantValues.set( 20 | self.deviceSupportsNonuniformThreadgroups, 21 | at: 0 22 | ) 23 | 24 | self.pipelineState = try library.computePipelineState( 25 | function: "bitonicSortFirstPass", 26 | constants: constantValues 27 | ) 28 | } 29 | 30 | // MARK: - Encode 31 | 32 | func encode( 33 | data: MTLBuffer, 34 | elementStride: Int, 35 | gridSize: Int, 36 | unitSize: Int, 37 | in commandBuffer: MTLCommandBuffer 38 | ) { 39 | commandBuffer.compute { encoder in 40 | encoder.label = "Bitonic Sort First Pass" 41 | self.encode( 42 | data: data, 43 | elementStride: elementStride, 44 | gridSize: gridSize, 45 | unitSize: unitSize, 46 | using: encoder 47 | ) 48 | } 49 | } 50 | 51 | func encode( 52 | data: MTLBuffer, 53 | elementStride: Int, 54 | gridSize: Int, 55 | unitSize: Int, 56 | using encoder: MTLComputeCommandEncoder 57 | ) { 58 | encoder.setBuffers(data) 59 | encoder.setValue(UInt32(gridSize), at: 1) 60 | encoder.setThreadgroupMemoryLength( 61 | (elementStride * unitSize) << 1, 62 | index: 0 63 | ) 64 | 65 | if self.deviceSupportsNonuniformThreadgroups { 66 | encoder.dispatch1d( 67 | state: self.pipelineState, 68 | exactly: gridSize, 69 | threadgroupWidth: unitSize 70 | ) 71 | } else { 72 | encoder.dispatch1d( 73 | state: self.pipelineState, 74 | covering: gridSize, 75 | threadgroupWidth: unitSize 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/BitonicSort/BitonicSortGeneralPass.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | 3 | extension BitonicSort { 4 | final class GeneralPass { 5 | // MARK: - Properties 6 | 7 | let pipelineState: MTLComputePipelineState 8 | private let deviceSupportsNonuniformThreadgroups: Bool 9 | 10 | // MARK: - Init 11 | 12 | init( 13 | library: MTLLibrary 14 | ) throws { 15 | self.deviceSupportsNonuniformThreadgroups = library.device.supports(feature: .nonUniformThreadgroups) 16 | 17 | let constantValues = MTLFunctionConstantValues() 18 | constantValues.set( 19 | self.deviceSupportsNonuniformThreadgroups, 20 | at: 0 21 | ) 22 | 23 | self.pipelineState = try library.computePipelineState( 24 | function: "bitonicSortGeneralPass", 25 | constants: constantValues 26 | ) 27 | } 28 | 29 | // MARK: - Encode 30 | 31 | func encode( 32 | data: MTLBuffer, 33 | params: SIMD2, 34 | gridSize: Int, 35 | unitSize: Int, 36 | in commandBuffer: MTLCommandBuffer 37 | ) { 38 | commandBuffer.compute { encoder in 39 | encoder.label = "Bitonic Sort General Pass" 40 | self.encode( 41 | data: data, 42 | params: params, 43 | gridSize: gridSize, 44 | unitSize: unitSize, 45 | using: encoder 46 | ) 47 | } 48 | } 49 | 50 | func encode( 51 | data: MTLBuffer, 52 | params: SIMD2, 53 | gridSize: Int, 54 | unitSize: Int, 55 | using encoder: MTLComputeCommandEncoder 56 | ) { 57 | encoder.setBuffers(data) 58 | encoder.setValue(UInt32(gridSize), at: 1) 59 | encoder.setValue(params, at: 2) 60 | 61 | if self.deviceSupportsNonuniformThreadgroups { 62 | encoder.dispatch1d( 63 | state: self.pipelineState, 64 | exactly: gridSize, 65 | threadgroupWidth: unitSize 66 | ) 67 | } else { 68 | encoder.dispatch1d( 69 | state: self.pipelineState, 70 | covering: gridSize, 71 | threadgroupWidth: unitSize 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/CollisionType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum SelfCollisionType: String, Hashable, CaseIterable { 4 | case vertexToVertex 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/SpatialHashing.metal: -------------------------------------------------------------------------------- 1 | #include "../../Common/BroadPhaseCommon.h" 2 | #include "../../Common/Definitions.h" 3 | #include "../../Common/DistanceFunctions.h" 4 | 5 | kernel void computeHashAndIndexState( 6 | device const half4* positions [[ buffer(0) ]], 7 | device uint2* hashTable [[ buffer(1) ]], 8 | constant uint& hashTableCapacity [[ buffer(2) ]], 9 | constant float& cellSize [[ buffer(3) ]], 10 | constant uint& gridSize [[ buffer(4) ]], 11 | uint gid [[ thread_position_in_grid ]] 12 | ) { 13 | if (deviceDoesntSupportNonuniformThreadgroups && gid >= gridSize) { return; } 14 | float3 position = float3(positions[gid].xyz); 15 | uint hash = getHash(hashCoord(position, cellSize), hashTableCapacity); 16 | hashTable[gid] = uint2(hash, gid); 17 | } 18 | 19 | kernel void computeCellBoundaries( 20 | device uint* cellStart [[ buffer(0) ]], 21 | device uint* cellEnd [[ buffer(1) ]], 22 | device const uint2* hashTable [[ buffer(2) ]], 23 | constant uint& hashTableSize [[ buffer(3) ]], 24 | uint gid [[ thread_position_in_grid ]], 25 | uint threadIdx [[ thread_position_in_threadgroup ]], 26 | threadgroup uint* sharedHash [[ threadgroup(0) ]] 27 | ) { 28 | if (deviceDoesntSupportNonuniformThreadgroups && gid >= hashTableSize) { return; } 29 | uint2 hashIndex = hashTable[gid]; 30 | uint hash = hashIndex.x; 31 | sharedHash[threadIdx + 1] = hash; 32 | if (gid > 0 && threadIdx == 0) { 33 | sharedHash[0] = hashTable[gid - 1].x; 34 | } 35 | threadgroup_barrier(mem_flags::mem_threadgroup); 36 | 37 | if (gid == 0 || hash != sharedHash[threadIdx]) { 38 | cellStart[hash] = gid; 39 | if (gid > 0) { 40 | cellEnd[sharedHash[threadIdx]] = gid; 41 | } 42 | } 43 | 44 | if (gid == hashTableSize - 1) { 45 | cellEnd[hash] = gid + 1; 46 | } 47 | } 48 | 49 | kernel void convertToHalf( 50 | constant void* positions [[ buffer(0) ]], 51 | device half3* halfPositions [[ buffer(1) ]], 52 | constant uint& gridSize [[ buffer(2) ]], 53 | constant bool& usePackedPositions [[ buffer(3) ]], 54 | uint gid [[ thread_position_in_grid ]] 55 | ) { 56 | if (deviceDoesntSupportNonuniformThreadgroups && gid >= gridSize) { return; } 57 | GetPositionFunc getCollidablePosition = usePackedPositions ? getPackedPosition : getPosition; 58 | 59 | float3 position = getCollidablePosition(gid, positions); 60 | halfPositions[gid] = half3(position); 61 | } 62 | 63 | kernel void reorderHalfPrecision( 64 | device const half3* halfPositions [[ buffer(0) ]], 65 | device half3* sortedHalfPositions [[ buffer(1) ]], 66 | device const uint2* hashTable [[ buffer(2) ]], 67 | constant uint& gridSize [[ buffer(3) ]], 68 | uint gid [[ thread_position_in_grid ]] 69 | ) { 70 | if (deviceDoesntSupportNonuniformThreadgroups && gid >= gridSize) { return; } 71 | uint2 hashAndIndex = hashTable[gid]; 72 | sortedHalfPositions[gid] = halfPositions[hashAndIndex.y]; 73 | } 74 | 75 | kernel void findCollisionCandidates( 76 | device uint* collisionCandidates [[ buffer(0) ]], 77 | constant uint2* hashTable [[ buffer(1) ]], 78 | constant uint* cellStart [[ buffer(2) ]], 79 | constant uint* cellEnd [[ buffer(3) ]], 80 | constant half3* colliderPositions [[ buffer(4) ]], 81 | constant void* collidablePositions [[ buffer(5) ]], 82 | constant uint* connectedVertices [[buffer(6)]], 83 | constant uint& hashTableCapacity [[ buffer(7) ]], 84 | constant float& radius [[ buffer(8) ]], 85 | constant float& cellSize [[ buffer(9) ]], 86 | constant uint& collisionCandidatesCount [[ buffer(10) ]], 87 | constant uint& connectedVerticesCount [[ buffer(11) ]], 88 | constant uint& collidableCount [[ buffer(12) ]], 89 | constant uint& gridSize [[ buffer(13) ]], 90 | constant bool& usePackedPositions [[ buffer(14) ]], 91 | uint gid [[ thread_position_in_grid ]] 92 | ) { 93 | if (deviceDoesntSupportNonuniformThreadgroups && gid >= gridSize) { return; } 94 | const bool handlingSelfCollision = collidableCount == 0; 95 | const uint index = handlingSelfCollision ? hashTable[gid].y : gid; 96 | if (index == UINT_MAX) { return; } 97 | GetPositionFunc getCollidablePosition = usePackedPositions ? getPackedPosition : getPosition; 98 | 99 | const float3 position = handlingSelfCollision ? float3(colliderPositions[gid].xyz) : getCollidablePosition(gid, collidablePositions); 100 | const int3 hashPosition = hashCoord(position, cellSize); 101 | 102 | SortedCollisionCandidates sortedCollisionCandidates; 103 | initializeCollisionCandidates( 104 | collisionCandidates, 105 | colliderPositions, 106 | sortedCollisionCandidates, 107 | index, 108 | half3(position), 109 | collisionCandidatesCount 110 | ); 111 | 112 | const float squaredDiameter = pow(radius * 2, 2); 113 | for (int x = hashPosition.x - 1; x <= hashPosition.x + 1; x++) { 114 | for (int y = hashPosition.y - 1; y <= hashPosition.y + 1; y++) { 115 | for (int z = hashPosition.z - 1; z <= hashPosition.z + 1; z++) { 116 | const float3 cellCenter = float3(x, y, z) * cellSize + cellSize * 0.5; 117 | if (sdsBox(cellCenter - float3(position), float3(cellSize * 0.5)) > squaredDiameter) { 118 | continue; 119 | } 120 | 121 | const uint hash = getHash(int3(x, y, z), hashTableCapacity); 122 | const uint start = cellStart[hash]; 123 | if (start == UINT_MAX) { continue; } 124 | const uint end = min(cellEnd[hash], start + MAX_COLLISION_CANDIDATES); 125 | 126 | for (uint i = start; i < end; i++) { 127 | uint collisionCandidate = hashTable[i].y; 128 | if (collisionCandidate == UINT_MAX) { break; } 129 | if (handlingSelfCollision && collisionCandidate == index) { continue; } 130 | 131 | bool isConnected = false; 132 | for (uint j = 0; j < connectedVerticesCount; j++) { 133 | isConnected = connectedVertices[index * connectedVerticesCount + j] == collisionCandidate; 134 | if (isConnected) { break; } 135 | } 136 | if (isConnected) { continue; } 137 | 138 | const half3 candidatePosition = colliderPositions[i].xyz; 139 | float distanceSq = length_squared(position - float3(candidatePosition)); 140 | if (distanceSq > sortedCollisionCandidates.candidates[collisionCandidatesCount - 1].distance) { continue; } 141 | if (distanceSq > squaredDiameter) { continue; } 142 | 143 | insertSeed(sortedCollisionCandidates, collisionCandidate, distanceSq, collisionCandidatesCount); 144 | } 145 | } 146 | } 147 | } 148 | 149 | for (int i = 0; i < int(collisionCandidatesCount); i++) { 150 | collisionCandidates[index * collisionCandidatesCount + i] = sortedCollisionCandidates.candidates[i].index; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/SpatialHasing.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | import MetalTools 3 | 4 | public final class SpatialHashing { 5 | /// Configuration for the SpatialHashing algorithm. 6 | public struct Configuration { 7 | /// The size of each cell in the spatial grid. 8 | let cellSize: Float 9 | /// The radius used for collision detection. 10 | let radius: Float 11 | 12 | /// Initializes a new Configuration instance. 13 | /// - Parameters: 14 | /// - cellSize: The size of each cell in the spatial grid. 15 | /// - radius: The radius used for collision detection. 16 | public init(cellSize: Float, radius: Float) { 17 | self.cellSize = cellSize 18 | self.radius = radius 19 | } 20 | } 21 | 22 | public let configuration: Configuration 23 | 24 | private let computeHashAndIndexState: MTLComputePipelineState 25 | private let computeCellBoundariesState: MTLComputePipelineState 26 | private let convertToHalfState: MTLComputePipelineState 27 | private let reorderHalfPrecisionState: MTLComputePipelineState 28 | private let findCollisionCandidatesState: MTLComputePipelineState 29 | private let bitonicSort: BitonicSort 30 | 31 | private let cellStart: MTLBuffer 32 | private let cellEnd: MTLBuffer 33 | private let hashTable: (buffer: MTLBuffer, paddedCount: Int) 34 | private let halfPositions: MTLBuffer 35 | private let sortedHalfPositions: MTLTypedBuffer 36 | private let hashTableCapacity: Int 37 | 38 | /// Initializes a new instance of SpatialHashing using the specified Metal device. 39 | /// 40 | /// - Parameters: 41 | /// - device: The Metal device to use for computations. 42 | /// - configuration: The configuration for spatial hashing. 43 | /// - maxPositionsCount: The maximum number of positions that can be handled. 44 | /// - Throws: An error if initialization fails. 45 | public convenience init( 46 | heap: MTLHeap, 47 | configuration: Configuration, 48 | maxPositionsCount: Int 49 | ) throws { 50 | try self.init( 51 | bufferAllocator: .init(type: .heap(heap)), 52 | configuration: configuration, 53 | maxPositionsCount: maxPositionsCount 54 | ) 55 | } 56 | 57 | /// Initializes a new instance of SpatialHashing using the specified Metal heap. 58 | /// 59 | /// - Parameters: 60 | /// - heap: The Metal heap to allocate resources from. 61 | /// - configuration: The configuration for spatial hashing. 62 | /// - maxPositionsCount: The maximum number of positions that can be handled. 63 | /// - Throws: An error if initialization fails. 64 | public convenience init( 65 | device: MTLDevice, 66 | configuration: Configuration, 67 | maxPositionsCount: Int 68 | ) throws { 69 | try self.init( 70 | bufferAllocator: .init(type: .device(device)), 71 | configuration: configuration, 72 | maxPositionsCount: maxPositionsCount 73 | ) 74 | } 75 | 76 | private init( 77 | bufferAllocator: MTLBufferAllocator, 78 | configuration: Configuration, 79 | maxPositionsCount: Int 80 | ) throws { 81 | let library = try bufferAllocator.device.makeDefaultLibrary(bundle: .module) 82 | let deviceSupportsNonuniformThreadgroups = bufferAllocator.device.supports(feature: .nonUniformThreadgroups) 83 | 84 | let constantValues = MTLFunctionConstantValues() 85 | constantValues.set(deviceSupportsNonuniformThreadgroups, at: 0) 86 | 87 | self.configuration = configuration 88 | self.computeHashAndIndexState = try library.computePipelineState(function: "computeHashAndIndexState", constants: constantValues) 89 | self.computeCellBoundariesState = try library.computePipelineState(function: "computeCellBoundaries", constants: constantValues) 90 | self.convertToHalfState = try library.computePipelineState(function: "convertToHalf", constants: constantValues) 91 | self.reorderHalfPrecisionState = try library.computePipelineState(function: "reorderHalfPrecision", constants: constantValues) 92 | self.findCollisionCandidatesState = try library.computePipelineState(function: "findCollisionCandidates", constants: constantValues) 93 | self.bitonicSort = try .init(library: library) 94 | 95 | self.hashTableCapacity = maxPositionsCount * 2 96 | self.hashTable = try BitonicSort.buffer(count: maxPositionsCount, bufferAllocator: bufferAllocator) 97 | self.cellStart = try bufferAllocator.buffer(for: UInt32.self, count: self.hashTableCapacity) 98 | self.cellEnd = try bufferAllocator.buffer(for: UInt32.self, count: self.hashTableCapacity) 99 | self.halfPositions = try bufferAllocator.buffer(for: SIMD3.self, count: maxPositionsCount) 100 | self.sortedHalfPositions = try bufferAllocator.typedBuffer(descriptor: .init(valueType: .half3, count: maxPositionsCount)) 101 | } 102 | 103 | /// Builds the spatial hash structure for the given positions. 104 | /// 105 | /// - Parameters: 106 | /// - positions: A buffer containing the positions. 107 | /// - commandBuffer: The command buffer to encode the operation into. 108 | public func build( 109 | positions: MTLTypedBuffer, 110 | in commandBuffer: MTLCommandBuffer 111 | ) { 112 | let positionsPacked = positions.descriptor.valueType.isPacked 113 | 114 | commandBuffer.blit { encoder in 115 | encoder.fill(buffer: self.hashTable.buffer, range: 0...size, index: 0) 154 | encoder.dispatch1d(state: self.computeCellBoundariesState, exactlyOrCovering: positions.descriptor.count, threadgroupWidth: threadgroupWidth) 155 | } 156 | commandBuffer.popDebugGroup() 157 | } 158 | 159 | /// Finds collision candidates for the given positions. 160 | /// 161 | /// - Parameters: 162 | /// - collidablePositions: Optional buffer containing collidable positions. If nil, uses the positions from the build step. 163 | /// - collisionCandidates: Buffer to store the found collision candidates. 164 | /// - connectedVertices: Optional buffer containing connected vertices to exclude from collision checks. 165 | /// - commandBuffer: The command buffer to encode the operation into. 166 | public func find( 167 | collidablePositions: MTLTypedBuffer?, 168 | collisionCandidates: MTLTypedBuffer, 169 | connectedVertices: MTLTypedBuffer?, 170 | in commandBuffer: MTLCommandBuffer 171 | ) { 172 | let collidablePositionsCount = collidablePositions?.descriptor.count ?? 0 173 | let collidablePositions = collidablePositions ?? self.sortedHalfPositions 174 | let maxCandidatesCount = collisionCandidates.descriptor.count / collidablePositions.descriptor.count 175 | let positionsPacked = collidablePositions.descriptor.valueType.isPacked 176 | 177 | commandBuffer.pushDebugGroup("Find Collision Candidates") 178 | commandBuffer.compute { encoder in 179 | encoder.setBuffer(collisionCandidates.buffer, offset: 0, index: 0) 180 | encoder.setBuffer(self.hashTable.buffer, offset: 0, index: 1) 181 | encoder.setBuffer(self.cellStart, offset: 0, index: 2) 182 | encoder.setBuffer(self.cellEnd, offset: 0, index: 3) 183 | encoder.setBuffer(self.sortedHalfPositions.buffer, offset: 0, index: 4) 184 | encoder.setBuffer(collidablePositions.buffer, offset: 0, index: 5) 185 | if let connectedVertices { 186 | encoder.setBuffer(connectedVertices.buffer, offset: 0, index: 6) 187 | } else { 188 | encoder.setValue([UInt32.zero], at: 6) 189 | } 190 | encoder.setValue(UInt32(self.hashTableCapacity), at: 7) 191 | encoder.setValue(self.configuration.radius, at: 8) 192 | encoder.setValue(self.configuration.cellSize, at: 9) 193 | encoder.setValue(UInt32(maxCandidatesCount), at: 10) 194 | encoder.setValue(UInt32((connectedVertices?.descriptor.count ?? 0) / collidablePositions.descriptor.count), at: 11) 195 | encoder.setValue(UInt32(collidablePositionsCount), at: 12) 196 | encoder.setValue(UInt32(collidablePositions.descriptor.count), at: 13) 197 | encoder.setValue(positionsPacked, at: 14) 198 | encoder.dispatch1d(state: self.findCollisionCandidatesState, exactlyOrCovering: collidablePositions.descriptor.count) 199 | } 200 | commandBuffer.popDebugGroup() 201 | } 202 | } 203 | 204 | public extension SpatialHashing { 205 | static func totalBuffersSize(maxPositionsCount: Int) -> Int { 206 | let cellStartSize = maxPositionsCount * MemoryLayout.stride * 2 207 | let cellEndSize = maxPositionsCount * MemoryLayout.stride * 2 208 | let hashTableSize = maxPositionsCount * MemoryLayout>.stride * 2 209 | let halfPositionsSize = maxPositionsCount * MemoryLayout>.stride * 2 210 | 211 | return cellStartSize + cellEndSize + hashTableSize + halfPositionsSize 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/TriangleSpatialHashing.metal: -------------------------------------------------------------------------------- 1 | #include "../../Common/BroadPhaseCommon.h" 2 | #include "../../Common/Definitions.h" 3 | #include "../../../SimulationToolsSharedTypes/SimulationToolsSharedTypes.h" 4 | 5 | kernel void hashTriangles( 6 | constant const void* positions [[ buffer(0) ]], 7 | device uint* hashTable [[ buffer(1) ]], 8 | device atomic_uint* hashTableCounter [[ buffer(2) ]], 9 | constant float& cellSize [[ buffer(3) ]], 10 | constant void* triangles [[ buffer(4) ]], 11 | constant uint& trianglesCount [[ buffer(5) ]], 12 | constant uint& bucketSize [[ buffer(6) ]], 13 | constant uint& step [[ buffer(7) ]], 14 | constant bool& usePackedColliderPositions [[ buffer(8) ]], 15 | constant bool& usePackedIndices [[ buffer(9) ]], 16 | uint id [[ thread_position_in_grid ]] 17 | ) { 18 | if (id >= trianglesCount) { return; } 19 | 20 | GetTriangleFunc getTriangle = usePackedIndices ? getPackedIndex : getIndex; 21 | GetPositionFunc getColliderPosition = usePackedColliderPositions ? getPackedPosition : getPosition; 22 | 23 | uint gid = (step + id) % trianglesCount; 24 | 25 | uint3 triangle = getTriangle(gid, triangles); 26 | Triangle trianglePositions = createTriangle(triangle, getColliderPosition, positions); 27 | 28 | float3 minPos = min(min(trianglePositions.a, trianglePositions.b), trianglePositions.c); 29 | float3 maxPos = max(max(trianglePositions.a, trianglePositions.b), trianglePositions.c); 30 | 31 | int3 minCell = int3(floor(minPos / cellSize)); 32 | int3 maxCell = int3(ceil(maxPos / cellSize)); 33 | 34 | for (int x = minCell.x; x <= maxCell.x; x++) { 35 | for (int y = minCell.y; y <= maxCell.y; y++) { 36 | for (int z = minCell.z; z <= maxCell.z; z++) { 37 | uint hash = getHash(int3(x, y, z), trianglesCount); 38 | uint index = atomic_fetch_add_explicit(&hashTableCounter[hash], 1, memory_order_relaxed); 39 | if (index < bucketSize) { 40 | hashTable[hash * bucketSize + index] = gid; 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | kernel void findTriangleCandidates( 48 | device uint* collisionCandidates [[ buffer(0) ]], 49 | constant void* positions [[ buffer(1) ]], 50 | constant void* colliderPositions [[ buffer(2) ]], 51 | constant void* triangles [[ buffer(3) ]], 52 | constant uint* triangleHashTable [[ buffer(4) ]], 53 | constant uint* connectedVertices [[buffer(5)]], 54 | constant TriangleSHParameters& params [[ buffer(6) ]], 55 | uint gid [[ thread_position_in_grid ]] 56 | ) { 57 | if (gid >= params.gridSize) { return; } 58 | 59 | GetTriangleFunc getTriangle = params.usePackedIndices ? getPackedIndex : getIndex; 60 | GetPositionFunc getCollidablePosition = params.usePackedCollidablePositions ? getPackedPosition : getPosition; 61 | GetPositionFunc getColliderPosition = params.usePackedColliderPositions ? getPackedPosition : getPosition; 62 | 63 | const bool handlingSelfCollision = !params.useExternalCollidable; 64 | const float3 vertexPosition = getCollidablePosition(gid, positions); 65 | const int3 hashPosition = int3(floor(vertexPosition / params.cellSize)); 66 | 67 | SortedCollisionCandidates sortedCollisionCandidates; 68 | initializeTriangleCollisionCandidates( 69 | collisionCandidates, 70 | getColliderPosition, 71 | colliderPositions, 72 | getTriangle, 73 | triangles, 74 | gid, 75 | vertexPosition, 76 | sortedCollisionCandidates, 77 | params.maxCollisionCandidatesCount 78 | ); 79 | 80 | uint hash = getHash(hashPosition, params.hashTableCapacity); 81 | for (uint i = 0; i < params.bucketSize; i++) { 82 | uint triangleIndex = triangleHashTable[hash * params.bucketSize + i]; 83 | if (triangleIndex == UINT_MAX) { continue; } 84 | 85 | const uint3 triangle = getTriangle(triangleIndex, triangles); 86 | if (handlingSelfCollision && any(triangle == gid)) { continue; } 87 | 88 | bool isConnected = false; 89 | for (uint j = 0; j < params.connectedVerticesCount; j++) { 90 | isConnected = any(triangle == connectedVertices[gid * params.connectedVerticesCount + j]); 91 | if (isConnected) { break; } 92 | } 93 | if (isConnected) { continue; } 94 | 95 | const Triangle trianglePositions = createTriangle(triangle, getColliderPosition, colliderPositions); 96 | const float distanceSQ = usdTriangle(vertexPosition, trianglePositions.a, trianglePositions.b, trianglePositions.c); 97 | if (distanceSQ > sortedCollisionCandidates.candidates[params.maxCollisionCandidatesCount - 1].distance) { continue; } 98 | 99 | insertSeed(sortedCollisionCandidates, triangleIndex, distanceSQ, params.maxCollisionCandidatesCount); 100 | } 101 | 102 | for (int i = 0; i < int(params.maxCollisionCandidatesCount); i++) { 103 | collisionCandidates[gid * params.maxCollisionCandidatesCount + i] = sortedCollisionCandidates.candidates[i].index; 104 | } 105 | } 106 | 107 | kernel void reuseTrianglesCache( 108 | device uint* collisionCandidates [[ buffer(0) ]], 109 | constant void* positions [[ buffer(1) ]], 110 | constant void* colliderPositions [[ buffer(2) ]], 111 | constant void* triangles [[ buffer(3) ]], 112 | constant uint* vertexNeighbors [[ buffer(4) ]], 113 | constant uint* connectedVertices [[buffer(5)]], 114 | constant uint3* triangleNeighbors [[ buffer(6) ]], 115 | constant uint& vertexNeighborsCount [[ buffer(7) ]], 116 | constant TriangleSHParameters& params [[ buffer(8) ]], 117 | constant bool& enableTriangleReuse [[ buffer(9) ]], 118 | uint gid [[ thread_position_in_grid ]] 119 | ) { 120 | if (gid >= params.gridSize) { return; } 121 | 122 | GetTriangleFunc getTriangle = params.usePackedIndices ? getPackedIndex : getIndex; 123 | GetPositionFunc getCollidablePosition = params.usePackedCollidablePositions ? getPackedPosition : getPosition; 124 | GetPositionFunc getColliderPosition = params.usePackedColliderPositions ? getPackedPosition : getPosition; 125 | 126 | const bool handlingSelfCollision = !params.useExternalCollidable; 127 | const float3 vertexPosition = getCollidablePosition(gid, positions); 128 | 129 | SortedCollisionCandidates sortedCollisionCandidates; 130 | initializeTriangleCollisionCandidates( 131 | collisionCandidates, 132 | getColliderPosition, 133 | colliderPositions, 134 | getTriangle, 135 | triangles, 136 | gid, 137 | vertexPosition, 138 | sortedCollisionCandidates, 139 | params.maxCollisionCandidatesCount 140 | ); 141 | 142 | const int neighborsReuseCount = 4; 143 | for (int i = 0; i < min(neighborsReuseCount, int(vertexNeighborsCount)); i++) { 144 | uint neighborIndex = vertexNeighbors[gid * vertexNeighborsCount + i]; 145 | if (neighborIndex == UINT_MAX) { continue; } 146 | for (int j = 0; j < 1; j++) { 147 | uint triangleIndex = collisionCandidates[neighborIndex * params.maxCollisionCandidatesCount + j]; 148 | if (triangleIndex == UINT_MAX) { continue; } 149 | 150 | uint3 triangle = getTriangle(triangleIndex, triangles); 151 | if (handlingSelfCollision && any(triangle == gid)) { continue; } 152 | 153 | bool isConnected = false; 154 | for (uint j = 0; j < params.connectedVerticesCount; j++) { 155 | isConnected = any(triangle == connectedVertices[gid * params.connectedVerticesCount + j]); 156 | if (isConnected) { break; } 157 | } 158 | if (isConnected) { continue; } 159 | 160 | Triangle trianglePositions = createTriangle(triangle, getColliderPosition, colliderPositions); 161 | float distanceSQ = usdTriangle(vertexPosition, trianglePositions.a, trianglePositions.b, trianglePositions.c); 162 | if (distanceSQ > sortedCollisionCandidates.candidates[params.maxCollisionCandidatesCount - 1].distance) { continue; } 163 | 164 | insertSeed(sortedCollisionCandidates, triangleIndex, distanceSQ, params.maxCollisionCandidatesCount); 165 | } 166 | } 167 | 168 | if (enableTriangleReuse) { 169 | for (int j = 0; j < 1; j++) { 170 | uint closestIndex = collisionCandidates[gid * params.maxCollisionCandidatesCount + j]; 171 | if (closestIndex == UINT_MAX) { continue; } 172 | for (int i = 0; i < 3; i++) { 173 | uint triangleIndex = triangleNeighbors[closestIndex][i]; 174 | if (triangleIndex == UINT_MAX) { continue; } 175 | uint3 triangle = getTriangle(triangleIndex, triangles); 176 | Triangle trianglePositions = createTriangle(triangle, getColliderPosition, colliderPositions); 177 | float distanceSQ = usdTriangle(vertexPosition, trianglePositions.a, trianglePositions.b, trianglePositions.c); 178 | if (distanceSQ > sortedCollisionCandidates.candidates[params.maxCollisionCandidatesCount - 1].distance) { continue; } 179 | 180 | insertSeed(sortedCollisionCandidates, triangleIndex, distanceSQ, params.maxCollisionCandidatesCount); 181 | } 182 | } 183 | } 184 | 185 | for (int i = 0; i < int(params.maxCollisionCandidatesCount); i++) { 186 | collisionCandidates[gid * params.maxCollisionCandidatesCount + i] = sortedCollisionCandidates.candidates[i].index; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Sources/SimulationTools/CollisionDetection/BroadPhase/TriangleSpatialHashing.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | import SimulationToolsSharedTypes 3 | 4 | public final class TriangleSpatialHashing { 5 | /// Configuration for the TriangleSpatialHashing algorithm. 6 | public struct Configuration { 7 | /// The size of each cell in the spatial grid. 8 | let cellSize: Float 9 | /// The size of each bucket in the hash table. 10 | let bucketSize: Int 11 | 12 | /// Initializes a new Configuration instance. 13 | /// - Parameters: 14 | /// - cellSize: The size of each cell in the spatial grid. 15 | /// - bucketSize: The size of each bucket in the hash table. 16 | public init(cellSize: Float, bucketSize: Int = 8) { 17 | self.cellSize = cellSize 18 | self.bucketSize = bucketSize 19 | } 20 | } 21 | 22 | private let hashTrianglesState: MTLComputePipelineState 23 | private let findTriangleCandidatesState: MTLComputePipelineState 24 | private let reuseTrianglesCacheState: MTLComputePipelineState 25 | 26 | private let configuration: Configuration 27 | private let hashTable: MTLBuffer 28 | private let hashTableCounter: MTLBuffer 29 | private var counter = 0 30 | 31 | /// Initializes a new instance of TriangleSpatialHashing using the specified Metal device. 32 | /// 33 | /// - Parameters: 34 | /// - device: The Metal device to use for computations. 35 | /// - configuration: The configuration for triangle spatial hashing. 36 | /// - maxTrianglesCount: The maximum number of triangles that can be handled. 37 | /// - Throws: An error if initialization fails. 38 | public convenience init( 39 | heap: MTLHeap, 40 | configuration: Configuration, 41 | maxTrianglesCount: Int 42 | ) throws { 43 | try self.init( 44 | bufferAllocator: .init(type: .heap(heap)), 45 | configuration: configuration, 46 | maxTrianglesCount: maxTrianglesCount 47 | ) 48 | } 49 | 50 | /// Initializes a new instance of TriangleSpatialHashing using the specified Metal heap. 51 | /// 52 | /// - Parameters: 53 | /// - heap: The Metal heap to allocate resources from. 54 | /// - configuration: The configuration for triangle spatial hashing. 55 | /// - maxTrianglesCount: The maximum number of triangles that can be handled. 56 | /// - Throws: An error if initialization fails. 57 | public convenience init( 58 | device: MTLDevice, 59 | configuration: Configuration, 60 | maxTrianglesCount: Int 61 | ) throws { 62 | try self.init( 63 | bufferAllocator: .init(type: .device(device)), 64 | configuration: configuration, 65 | maxTrianglesCount: maxTrianglesCount 66 | ) 67 | } 68 | 69 | private init( 70 | bufferAllocator: MTLBufferAllocator, 71 | configuration: Configuration, 72 | maxTrianglesCount: Int 73 | ) throws { 74 | let library = try bufferAllocator.device.makeDefaultLibrary(bundle: .module) 75 | 76 | self.hashTrianglesState = try library.computePipelineState(function: "hashTriangles") 77 | self.findTriangleCandidatesState = try library.computePipelineState(function: "findTriangleCandidates") 78 | self.reuseTrianglesCacheState = try library.computePipelineState(function: "reuseTrianglesCache") 79 | 80 | self.configuration = configuration 81 | self.hashTable = try bufferAllocator.buffer(for: UInt32.self, count: maxTrianglesCount * configuration.bucketSize, options: .storageModePrivate) 82 | self.hashTableCounter = try bufferAllocator.buffer(for: UInt32.self, count: maxTrianglesCount, options: .storageModePrivate) 83 | } 84 | 85 | /// Builds the spatial hash structure for the given triangle mesh. 86 | /// 87 | /// - Parameters: 88 | /// - colliderPositions: A buffer containing the vertex positions of the mesh. 89 | /// - indices: A buffer containing the triangle indices of the mesh. 90 | /// - commandBuffer: The command buffer to encode the operation into. 91 | public func build( 92 | colliderPositions: MTLTypedBuffer, 93 | indices: MTLTypedBuffer, 94 | in commandBuffer: MTLCommandBuffer 95 | ) { 96 | let colliderPositionsPacked = colliderPositions.descriptor.valueType.isPacked 97 | let trianglesPacked = indices.descriptor.valueType.isPacked 98 | 99 | commandBuffer.blit { encoder in 100 | encoder.fill(buffer: self.hashTableCounter, range: 0.. Int { 237 | let triangleHashTableSize = maxTrianglesCount * MemoryLayout.stride * configuration.bucketSize 238 | let triangleHashTableCounterSize = maxTrianglesCount * MemoryLayout.stride 239 | 240 | return triangleHashTableSize + triangleHashTableCounterSize 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /Sources/SimulationTools/Common/BroadPhaseCommon.h: -------------------------------------------------------------------------------- 1 | #ifndef BroadPhaseCommon_h 2 | #define BroadPhaseCommon_h 3 | 4 | #include 5 | using namespace metal; 6 | 7 | #include "DistanceFunctions.h" 8 | #define MAX_CONNECTED_VERTICES 32 9 | #define MAX_COLLISION_CANDIDATES 32 10 | 11 | typedef float3 (*GetPositionFunc)(uint, constant void*); 12 | typedef uint3 (*GetTriangleFunc)(uint, constant void*); 13 | 14 | METAL_FUNC float3 getPosition(uint index, constant void* data) { 15 | constant float3* positions = (constant float3*)data; 16 | return positions[index]; 17 | } 18 | 19 | METAL_FUNC uint3 getIndex(uint index, constant void* data) { 20 | constant uint3* positions = (constant uint3*)data; 21 | return positions[index]; 22 | } 23 | 24 | METAL_FUNC float3 getPackedPosition(uint index, constant void* data) { 25 | constant packed_float3* positions = (constant packed_float3*)data; 26 | return positions[index]; 27 | } 28 | 29 | METAL_FUNC uint3 getPackedIndex(uint index, constant void* data) { 30 | constant packed_uint3* positions = (constant packed_uint3*)data; 31 | return positions[index]; 32 | } 33 | 34 | struct Triangle { 35 | float3 a; 36 | float3 b; 37 | float3 c; 38 | }; 39 | 40 | METAL_FUNC int3 hashCoord(float3 position, float gridSpacing) { 41 | int x = floor(position.x / gridSpacing); 42 | int y = floor(position.y / gridSpacing); 43 | int z = floor(position.z / gridSpacing); 44 | 45 | return int3(x, y, z); 46 | } 47 | 48 | METAL_FUNC int computeHash(int3 position) { 49 | int x = position.x; 50 | int y = position.y; 51 | int z = position.z; 52 | 53 | int hash = (x * 92837111) ^ (y * 689287499) ^ (z * 283923481); 54 | 55 | return hash; 56 | } 57 | 58 | METAL_FUNC uint getHash(int3 position, uint hashTableCapacity) { 59 | int hash = computeHash(position); 60 | return uint(abs(hash % hashTableCapacity)); 61 | } 62 | 63 | struct CollisionCandidate { 64 | uint index; 65 | float distance; 66 | }; 67 | 68 | struct SortedCollisionCandidates { 69 | CollisionCandidate candidates[MAX_COLLISION_CANDIDATES]; 70 | }; 71 | 72 | METAL_FUNC Triangle createTriangle(uint3 triangleVertices, 73 | GetPositionFunc getPosition, 74 | constant void* positionData 75 | ) { 76 | return Triangle { 77 | getPosition(triangleVertices.x, positionData), 78 | getPosition(triangleVertices.y, positionData), 79 | getPosition(triangleVertices.z, positionData) 80 | }; 81 | } 82 | 83 | template 84 | enable_if_t, void> 85 | METAL_FUNC initializeCollisionCandidates( 86 | device uint* candidates, 87 | constant const vec* positions, 88 | thread SortedCollisionCandidates &sortedCandidates, 89 | uint index, 90 | const vec position, 91 | uint count 92 | ) { 93 | for (int i = 0; i < int(count); i++) { 94 | uint colliderIndex = candidates[index * count + i]; 95 | sortedCandidates.candidates[i].index = colliderIndex; 96 | if (colliderIndex != UINT_MAX) { 97 | vec collider = positions[colliderIndex].xyz; 98 | sortedCandidates.candidates[i].distance = float(length_squared(position - collider)); 99 | } else { 100 | sortedCandidates.candidates[i].distance = FLT_MAX; 101 | } 102 | } 103 | } 104 | 105 | METAL_FUNC void initializeCollisionCandidates( 106 | device uint* candidates, 107 | GetPositionFunc getPosition, 108 | constant void* positions, 109 | thread SortedCollisionCandidates &sortedCandidates, 110 | uint index, 111 | const float3 position, 112 | uint count 113 | ) { 114 | for (int i = 0; i < int(count); i++) { 115 | uint colliderIndex = candidates[index * count + i]; 116 | sortedCandidates.candidates[i].index = colliderIndex; 117 | if (colliderIndex != UINT_MAX) { 118 | float3 collider = getPosition(colliderIndex, positions); 119 | sortedCandidates.candidates[i].distance = length_squared(position - collider); 120 | } else { 121 | sortedCandidates.candidates[i].distance = FLT_MAX; 122 | } 123 | } 124 | } 125 | 126 | void METAL_FUNC initializeTriangleCollisionCandidates( 127 | device uint* candidates, 128 | GetPositionFunc getPosition, 129 | constant void* positions, 130 | GetTriangleFunc getTriangle, 131 | constant void* triangleData, 132 | uint index, 133 | float3 position, 134 | thread SortedCollisionCandidates &collisionCandidates, 135 | uint count 136 | ) { 137 | for (int i = 0; i < int(count); i++) { 138 | uint colliderIndex = candidates[index * count + i]; 139 | collisionCandidates.candidates[i].index = colliderIndex; 140 | if (colliderIndex != UINT_MAX) { 141 | uint3 triangleIndices = getTriangle(colliderIndex, triangleData); 142 | float3 a = getPosition(triangleIndices.x, positions); 143 | float3 b = getPosition(triangleIndices.y, positions); 144 | float3 c = getPosition(triangleIndices.z, positions); 145 | collisionCandidates.candidates[i].distance = usdTriangle(position, a, b, c); 146 | } else { 147 | collisionCandidates.candidates[i].distance = FLT_MAX; 148 | } 149 | } 150 | } 151 | 152 | template 153 | enable_if_t, void> 154 | METAL_FUNC insertSeed( 155 | thread SortedCollisionCandidates &candidates, 156 | uint index, 157 | T distance, 158 | uint count 159 | ) { 160 | int insertPosition = -1; 161 | int duplicateIndex = -1; 162 | 163 | for (int i = 0; i < int(count); i++) { 164 | if (distance <= candidates.candidates[i].distance && insertPosition == -1) { 165 | insertPosition = i; 166 | } 167 | 168 | if (index == candidates.candidates[i].index) { 169 | duplicateIndex = i; 170 | break; 171 | } 172 | } 173 | 174 | if (insertPosition != -1) { 175 | int start = duplicateIndex == -1 ? count - 1 : duplicateIndex; 176 | for (int j = start; j > insertPosition; j--) { 177 | candidates.candidates[j] = candidates.candidates[j - 1]; 178 | } 179 | 180 | candidates.candidates[insertPosition] = { .index = index, .distance = distance }; 181 | } 182 | } 183 | 184 | #endif /* BroadPhaseCommon_h */ 185 | -------------------------------------------------------------------------------- /Sources/SimulationTools/Common/Definitions.h: -------------------------------------------------------------------------------- 1 | #ifndef Definitions_h 2 | #define Definitions_h 3 | 4 | constant bool deviceSupportsNonuniformThreadgroups [[ function_constant(0) ]]; 5 | constant bool deviceDoesntSupportNonuniformThreadgroups = !deviceSupportsNonuniformThreadgroups; 6 | 7 | #endif /* Definitions_h */ 8 | 9 | -------------------------------------------------------------------------------- /Sources/SimulationTools/Common/DistanceFunctions.h: -------------------------------------------------------------------------------- 1 | #ifndef DistanceFunctions_h 2 | #define DistanceFunctions_h 3 | 4 | template 5 | enable_if_t, float> 6 | METAL_FUNC usdTriangle(vec p, vec a, vec b, vec c) { 7 | vec ba = b - a; 8 | vec pa = p - a; 9 | vec cb = c - b; 10 | vec pb = p - b; 11 | vec ac = a - c; 12 | vec pc = p - c; 13 | vec nor = cross(ba, ac); 14 | 15 | return 16 | (sign(dot(cross(ba, nor), pa)) + sign(dot(cross(cb, nor), pb)) + sign(dot(cross(ac, nor), pc)) < 2.0) 17 | ? 18 | min(min( 19 | length_squared(ba * saturate(dot(ba, pa) / length_squared(ba)) - pa), 20 | length_squared(cb * saturate(dot(cb, pb) / length_squared(cb)) - pb)), 21 | length_squared(ac * saturate(dot(ac, pc) / length_squared(ac)) - pc)) 22 | : 23 | dot(nor, pa) * dot(nor, pa) / length_squared(nor) 24 | ; 25 | } 26 | 27 | METAL_FUNC float sdsBox(float3 p, float3 b) { 28 | float3 q = abs(p) - b; 29 | return length_squared(max(q,0.0)) + min(max(q.x,max(q.y,q.z)),0.0); 30 | } 31 | 32 | #endif /* DistanceFunctions_h */ 33 | -------------------------------------------------------------------------------- /Sources/SimulationTools/Common/Extensions.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | 3 | extension Array { 4 | var dataLength: Int { self.count * MemoryLayout.stride } 5 | } 6 | 7 | extension Array { 8 | func chunked(into size: Int) -> [[Element]] { 9 | var chunks: [[Element]] = [] 10 | var index = 0 11 | 12 | while index < self.count { 13 | let chunk = Array(self[index.. Element { 24 | return self[Int(index)] 25 | } 26 | } 27 | 28 | extension Collection { 29 | subscript(safe index: Index) -> Element? { 30 | self.indices.contains(index) ? self[index] : nil 31 | } 32 | 33 | var isNotEmpty: Bool { !isEmpty } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SimulationTools/Common/MTLBufferAllocator.swift: -------------------------------------------------------------------------------- 1 | import MetalTools 2 | 3 | final class MTLBufferAllocator { 4 | enum `Type` { 5 | case device(MTLDevice) 6 | case heap(MTLHeap) 7 | } 8 | 9 | var device: MTLDevice { 10 | switch type { 11 | case let .device(device): return device 12 | case let .heap(heap): return heap.device 13 | } 14 | } 15 | 16 | let type: `Type` 17 | 18 | init(type: Type) { 19 | self.type = type 20 | } 21 | 22 | func buffer( 23 | for type: T.Type, 24 | count: Int = 1, 25 | options: MTLResourceOptions = .cpuCacheModeWriteCombined 26 | ) throws -> MTLBuffer { 27 | switch self.type { 28 | case let .device(device): 29 | return try device.buffer( 30 | for: type, 31 | count: count, 32 | options: options 33 | ) 34 | case let .heap(heap): 35 | return try heap.buffer( 36 | for: type, 37 | count: count, 38 | options: heap.resourceOptions 39 | ) 40 | } 41 | } 42 | 43 | func buffer( 44 | with value: T, 45 | options: MTLResourceOptions = .cpuCacheModeWriteCombined 46 | ) throws -> MTLBuffer { 47 | switch type { 48 | case let .device(device): 49 | return try device.buffer( 50 | with: value, 51 | options: options 52 | ) 53 | case let .heap(heap): 54 | return try heap.buffer( 55 | with: value, 56 | options: heap.resourceOptions 57 | ) 58 | } 59 | } 60 | 61 | func buffer( 62 | with values: [T], 63 | options: MTLResourceOptions = .cpuCacheModeWriteCombined 64 | ) throws -> MTLBuffer { 65 | switch type { 66 | case let .device(device): 67 | return try device.buffer( 68 | with: values, 69 | options: options 70 | ) 71 | case let .heap(heap): 72 | return try heap.buffer( 73 | with: values, 74 | options: heap.resourceOptions 75 | ) 76 | } 77 | } 78 | 79 | func buffer( 80 | length: Int, 81 | options: MTLResourceOptions = .cpuCacheModeWriteCombined 82 | ) throws -> MTLBuffer { 83 | switch type { 84 | case let .device(device): 85 | guard let buffer = device.makeBuffer(length: length, options: options) 86 | else { throw MetalError.MTLDeviceError.bufferCreationFailed } 87 | return buffer 88 | case let .heap(heap): 89 | guard let buffer = heap.makeBuffer(length: length, options: heap.resourceOptions) 90 | else { throw MetalError.MTLDeviceError.bufferCreationFailed } 91 | return buffer 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/SimulationTools/Common/MTLTypedBuffer.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | import MetalTools 3 | 4 | public enum MTLBufferValueType { 5 | case float, float2, float3, float4 6 | case half, half2, half3, half4 7 | case packedFloat3 8 | case uint, uint2, uint3, uint4 9 | case packedUInt3 10 | 11 | var stride: Int { 12 | switch self { 13 | case .float: return MemoryLayout.stride 14 | case .float2: return MemoryLayout>.stride 15 | case .float3: return MemoryLayout>.stride 16 | case .float4: return MemoryLayout>.stride 17 | case .half: return MemoryLayout.stride 18 | case .half2: return MemoryLayout>.stride 19 | case .half3: return MemoryLayout>.stride 20 | case .half4: return MemoryLayout>.stride 21 | case .packedFloat3: return MemoryLayout.stride * 3 22 | case .uint: return MemoryLayout.stride 23 | case .uint2: return MemoryLayout>.stride 24 | case .uint3: return MemoryLayout>.stride 25 | case .uint4: return MemoryLayout>.stride 26 | case .packedUInt3: return MemoryLayout.stride * 3 27 | } 28 | } 29 | 30 | var isPacked: Bool { 31 | self == .packedFloat3 || self == .packedUInt3 32 | } 33 | } 34 | 35 | public struct MTLTypedBufferDescriptor { 36 | public var valueType: MTLBufferValueType 37 | public var count: Int 38 | 39 | public init(valueType: MTLBufferValueType, count: Int) { 40 | self.valueType = valueType 41 | self.count = count 42 | } 43 | } 44 | 45 | public class MTLTypedBuffer { 46 | public let buffer: MTLBuffer 47 | public let descriptor: MTLTypedBufferDescriptor 48 | 49 | init(descriptor: MTLTypedBufferDescriptor, options: MTLResourceOptions = .cpuCacheModeWriteCombined, bufferAllocator: MTLBufferAllocator) throws { 50 | self.descriptor = descriptor 51 | self.buffer = try bufferAllocator.buffer(length: descriptor.count * descriptor.valueType.stride, options: options) 52 | } 53 | 54 | init(values: [T], valueType: MTLBufferValueType, options: MTLResourceOptions = .cpuCacheModeWriteCombined, bufferAllocator: MTLBufferAllocator) throws { 55 | self.descriptor = MTLTypedBufferDescriptor(valueType: valueType, count: values.count) 56 | self.buffer = try bufferAllocator.buffer(with: values) 57 | } 58 | 59 | init(buffer: MTLBuffer, descriptor: MTLTypedBufferDescriptor) { 60 | assert(buffer.length >= descriptor.count * descriptor.valueType.stride, "Buffer is too small for the specified count and value type") 61 | self.buffer = buffer 62 | self.descriptor = descriptor 63 | } 64 | 65 | public func values() -> [T]? { 66 | return buffer.array(of: T.self, count: descriptor.count) 67 | } 68 | } 69 | 70 | public extension MTLDevice { 71 | func typedBuffer(with array: [T], valueType: MTLBufferValueType, options: MTLResourceOptions = .cpuCacheModeWriteCombined) throws -> MTLTypedBuffer { 72 | try MTLTypedBuffer(values: array, valueType: valueType, bufferAllocator: .init(type: .device(self))) 73 | } 74 | 75 | func typedBuffer(descriptor: MTLTypedBufferDescriptor, options: MTLResourceOptions = .cpuCacheModeWriteCombined) throws -> MTLTypedBuffer { 76 | try MTLTypedBuffer(descriptor: descriptor, bufferAllocator: .init(type: .device(self))) 77 | } 78 | 79 | func typedBuffer(with buffer: MTLBuffer, valueType: MTLBufferValueType) -> MTLTypedBuffer { 80 | let descriptor = MTLTypedBufferDescriptor(valueType: valueType, count: buffer.length / valueType.stride) 81 | return MTLTypedBuffer(buffer: buffer, descriptor: descriptor) 82 | } 83 | } 84 | 85 | public extension MTLHeap { 86 | func typedBuffer(with array: [T], valueType: MTLBufferValueType, options: MTLResourceOptions = .cpuCacheModeWriteCombined) throws -> MTLTypedBuffer { 87 | try MTLTypedBuffer(values: array, valueType: valueType, bufferAllocator: .init(type: .heap(self))) 88 | } 89 | 90 | func typedBuffer(descriptor: MTLTypedBufferDescriptor, options: MTLResourceOptions = .cpuCacheModeWriteCombined) throws -> MTLTypedBuffer { 91 | try MTLTypedBuffer(descriptor: descriptor, bufferAllocator: .init(type: .heap(self))) 92 | } 93 | 94 | func typedBuffer(with buffer: MTLBuffer, valueType: MTLBufferValueType) throws -> MTLTypedBuffer { 95 | guard buffer.heap === self else { 96 | throw MTLTypedBufferError.bufferNotInHeap 97 | } 98 | let descriptor = MTLTypedBufferDescriptor(valueType: valueType, count: buffer.length / valueType.stride) 99 | return MTLTypedBuffer(buffer: buffer, descriptor: descriptor) 100 | } 101 | } 102 | 103 | extension MTLBufferAllocator { 104 | func typedBuffer( 105 | descriptor: MTLTypedBufferDescriptor, 106 | options: MTLResourceOptions = .cpuCacheModeWriteCombined 107 | ) throws -> MTLTypedBuffer { 108 | switch self.type { 109 | case let .device(device): 110 | return try device.typedBuffer(descriptor: descriptor, options: options) 111 | case let .heap(heap): 112 | return try heap.typedBuffer(descriptor: descriptor, options: heap.resourceOptions) 113 | } 114 | } 115 | 116 | func typedBuffer( 117 | with array: [T], 118 | valueType: MTLBufferValueType, 119 | options: MTLResourceOptions = .cpuCacheModeWriteCombined 120 | ) throws -> MTLTypedBuffer { 121 | switch self.type { 122 | case let .device(device): 123 | return try device.typedBuffer(with: array, valueType: valueType, options: options) 124 | case let .heap(heap): 125 | return try heap.typedBuffer(with: array, valueType: valueType, options: options) 126 | } 127 | } 128 | } 129 | 130 | public enum MTLTypedBufferError: Error { 131 | case bufferNotInHeap 132 | } 133 | -------------------------------------------------------------------------------- /Sources/SimulationTools/SimulationTools.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Sources/SimulationToolsSharedTypes/SimulationToolsSharedTypes.c: -------------------------------------------------------------------------------- 1 | void simulationToolsSharedTypes() {} 2 | -------------------------------------------------------------------------------- /Sources/SimulationToolsSharedTypes/SimulationToolsSharedTypes.h: -------------------------------------------------------------------------------- 1 | #ifndef SimulationToolsSharedTypes_h 2 | #define SimulationToolsSharedTypes_h 3 | 4 | #if __METAL_VERSION__ 5 | 6 | #include 7 | 8 | using namespace metal; 9 | #define NS_ENUM(_type, _name) enum _name : _type _name; enum _name : _type 10 | #define metal_enum_t metal::int32_t 11 | 12 | #else 13 | 14 | #include 15 | #import 16 | #import 17 | typedef int32_t metal_enum_t; 18 | 19 | #endif 20 | 21 | typedef struct { 22 | uint hashTableCapacity; 23 | float cellSize; 24 | uint maxCollisionCandidatesCount; 25 | uint connectedVerticesCount; 26 | uint bucketSize; 27 | uint gridSize; 28 | bool useExternalCollidable; 29 | bool usePackedCollidablePositions; 30 | bool usePackedColliderPositions; 31 | bool usePackedIndices; 32 | } TriangleSHParameters; 33 | 34 | #endif /* SimulationToolsSharedTypes_h */ 35 | -------------------------------------------------------------------------------- /Tests/SimulationToolsTests/SpatialHashingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Metal 3 | @testable import SimulationTools 4 | 5 | final class SpatialHashingTests: XCTestCase { 6 | var device: MTLDevice! 7 | var commandQueue: MTLCommandQueue! 8 | 9 | override func setUp() { 10 | super.setUp() 11 | self.device = MTLCreateSystemDefaultDevice() 12 | self.commandQueue = self.device.makeCommandQueue() 13 | } 14 | 15 | override func tearDown() { 16 | self.device = nil 17 | self.commandQueue = nil 18 | super.tearDown() 19 | } 20 | 21 | func testSpatialHashingInitialization() throws { 22 | let config = SpatialHashing.Configuration( 23 | cellSize: 1.0, 24 | radius: 0.5 25 | ) 26 | 27 | XCTAssertNoThrow( 28 | try SpatialHashing( 29 | device: self.device, 30 | configuration: config, 31 | maxPositionsCount: 100 32 | ) 33 | ) 34 | } 35 | 36 | func generateMockData(count: Int = 100) -> [SIMD3] { 37 | return (0..(cos(angle) * 10.0, sin(angle) * 10.0, 0.0) 40 | } 41 | } 42 | 43 | func collisionCandidates(positions: [SIMD3], candidatesCount: Int = 8, cellSize: Float) throws -> MTLTypedBuffer { 44 | let config = SpatialHashing.Configuration( 45 | cellSize: cellSize, 46 | radius: cellSize / 2.0 47 | ) 48 | 49 | let spatialHashing = try SpatialHashing( 50 | device: self.device, 51 | configuration: config, 52 | maxPositionsCount: positions.count 53 | ) 54 | 55 | let positionsBuffer = try device.typedBuffer(with: positions, valueType: .float3) 56 | let collisionCandidatesBuffer = try device.typedBuffer( 57 | with: Array(repeating: UInt32.max, count: positions.count * candidatesCount), 58 | valueType: .uint 59 | ) 60 | 61 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 62 | XCTFail("Failed to create command buffer") 63 | throw NSError(domain: "SpatialHashingTests", code: 1, userInfo: nil) 64 | } 65 | 66 | spatialHashing.build(positions: positionsBuffer, in: commandBuffer) 67 | 68 | spatialHashing.find( 69 | collidablePositions: nil, 70 | collisionCandidates: collisionCandidatesBuffer, 71 | connectedVertices: nil, 72 | in: commandBuffer 73 | ) 74 | 75 | commandBuffer.commit() 76 | commandBuffer.waitUntilCompleted() 77 | 78 | return collisionCandidatesBuffer 79 | } 80 | 81 | func testBuildMethodInitialization() throws { 82 | let positions = generateMockData() 83 | let collisionCandidatesBuffer = try collisionCandidates(positions: positions, cellSize: 0.5) 84 | XCTAssertNotNil(collisionCandidatesBuffer.values()! as [UInt32], "Collision candidates buffer should not be nil") 85 | } 86 | 87 | func testCollisionCandidatesContainClosestProximity() throws { 88 | let positions: [SIMD3] = [ 89 | [-0.5, 0.0, 0.0], 90 | [0.0, 0.0, 0.0], 91 | [1.0, 0.0, 0.0], 92 | [1.5, 0.0, 0.0] 93 | ] 94 | let candidatesCount = 4 95 | let collisionCandidatesBuffer = try collisionCandidates(positions: positions, candidatesCount: candidatesCount, cellSize: 1.0) 96 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: candidatesCount) 97 | 98 | XCTAssertTrue(collisionCandidates[0].contains(1)) 99 | XCTAssertTrue(collisionCandidates[1].contains(0)) 100 | XCTAssertTrue(collisionCandidates[2].contains(3)) 101 | XCTAssertTrue(collisionCandidates[3].contains(2)) 102 | } 103 | 104 | func testCollisionCandidatesDoNotContainSelf() throws { 105 | let positions = generateMockData() 106 | let candidatesCount = 4 107 | let collisionCandidatesBuffer = try collisionCandidates(positions: positions, candidatesCount: candidatesCount, cellSize: 1.0) 108 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: candidatesCount) 109 | 110 | XCTAssertFalse(collisionCandidates[0].contains(0)) 111 | XCTAssertFalse(collisionCandidates[1].contains(1)) 112 | XCTAssertFalse(collisionCandidates[2].contains(2)) 113 | XCTAssertFalse(collisionCandidates[3].contains(3)) 114 | } 115 | 116 | func testCollisionCandidatesSymmetry() throws { 117 | let positions = generateMockData(count: 10) 118 | 119 | let candidatesCount = 8 120 | let collisionCandidatesBuffer = try collisionCandidates(positions: positions, candidatesCount: candidatesCount, cellSize: 1.0) 121 | let collisionCandidates: [Set] = collisionCandidatesBuffer.values()!.chunked(into: candidatesCount).map { Set($0) } 122 | 123 | print(collisionCandidates.enumerated().map { $0 }) 124 | for (i, candidates) in collisionCandidates.enumerated() { 125 | for candidate in candidates where candidate != UInt32.max { 126 | XCTAssertTrue(collisionCandidates[Int(candidate)].contains(UInt32(i)), "Symmetry check failed: \(i) and \(candidate)") 127 | } 128 | } 129 | } 130 | 131 | func testConnectedVerticesExclusion() throws { 132 | let positions: [SIMD3] = [ 133 | [0.0, 0.0, 0.0], 134 | [0.1, 0.0, 0.0], 135 | [0.5, 0.0, 0.0], 136 | [1.501, 0.0, 0.0] 137 | ] 138 | 139 | let cellSize: Float = 1.0 140 | let candidatesCount = 8 141 | let config = SpatialHashing.Configuration( 142 | cellSize: cellSize, 143 | radius: cellSize / 2.0 144 | ) 145 | 146 | let spatialHashing = try SpatialHashing( 147 | device: self.device, 148 | configuration: config, 149 | maxPositionsCount: positions.count 150 | ) 151 | 152 | let positionsBuffer = try device.typedBuffer(with: positions, valueType: .float3) 153 | let collisionCandidatesBuffer = try device.typedBuffer( 154 | with: Array(repeating: UInt32.max, count: positions.count * candidatesCount), 155 | valueType: .uint 156 | ) 157 | 158 | // Specify connected vertices: 0 and 1 are connected 159 | let connectedVertices: [UInt32] = [ 160 | 1, UInt32.max, UInt32.max, UInt32.max, // For vertex 0 161 | 0, UInt32.max, UInt32.max, UInt32.max, // For vertex 1 162 | UInt32.max, UInt32.max, UInt32.max, UInt32.max, // For vertex 2 163 | UInt32.max, UInt32.max, UInt32.max, UInt32.max // For vertex 3 164 | ] 165 | let connectedVerticesBuffer = try device.typedBuffer(with: connectedVertices, valueType: .uint) 166 | 167 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 168 | XCTFail("Failed to create command buffer") 169 | throw NSError(domain: "SpatialHashingTests", code: 1, userInfo: nil) 170 | } 171 | 172 | spatialHashing.build(positions: positionsBuffer, in: commandBuffer) 173 | 174 | spatialHashing.find( 175 | collidablePositions: nil, 176 | collisionCandidates: collisionCandidatesBuffer, 177 | connectedVertices: connectedVerticesBuffer, 178 | in: commandBuffer 179 | ) 180 | 181 | commandBuffer.commit() 182 | commandBuffer.waitUntilCompleted() 183 | 184 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: candidatesCount) 185 | 186 | // Verify that vertex 0 does not have vertex 1 as a collision candidate 187 | XCTAssertFalse(collisionCandidates[0].contains(1), "Vertex 0 should not have connected vertex 1 as a collision candidate") 188 | XCTAssertTrue(collisionCandidates[0].contains(2), "Vertex 0 should have vertex 2 as a collision candidate") 189 | XCTAssertFalse(collisionCandidates[0].contains(3), "Vertex 0 should not have vertex 3 as a collision candidate (outside cell size)") 190 | 191 | // Verify that vertex 1 does not have vertex 0 as a collision candidate 192 | XCTAssertFalse(collisionCandidates[1].contains(0), "Vertex 1 should not have connected vertex 0 as a collision candidate") 193 | XCTAssertTrue(collisionCandidates[1].contains(2), "Vertex 1 should have vertex 2 as a collision candidate") 194 | XCTAssertFalse(collisionCandidates[1].contains(3), "Vertex 1 should not have vertex 3 as a collision candidate (outside cell size)") 195 | 196 | // Verify that vertex 2 has both vertex 0 and 1 as collision candidates 197 | XCTAssertTrue(collisionCandidates[2].contains(0), "Vertex 2 should have vertex 0 as a collision candidate") 198 | XCTAssertTrue(collisionCandidates[2].contains(1), "Vertex 2 should have vertex 1 as a collision candidate") 199 | XCTAssertFalse(collisionCandidates[2].contains(3), "Vertex 2 should not have vertex 3 as a collision candidate (outside cell size)") 200 | 201 | // Verify that vertex 3 has no collision candidates (all others are outside its cell) 202 | XCTAssertFalse(collisionCandidates[3].contains(0), "Vertex 3 should not have vertex 0 as a collision candidate") 203 | XCTAssertFalse(collisionCandidates[3].contains(1), "Vertex 3 should not have vertex 1 as a collision candidate") 204 | XCTAssertFalse(collisionCandidates[3].contains(2), "Vertex 3 should not have vertex 2 as a collision candidate") 205 | } 206 | 207 | func testPerformanceForPositions(_ count: Int) throws { 208 | let positions: [SIMD3] = (0..( 210 | Float.random(in: -100...100), 211 | Float.random(in: -100...100), 212 | Float.random(in: -100...100) 213 | ) 214 | } 215 | let config = SpatialHashing.Configuration( 216 | cellSize: 1.0, 217 | radius: 0.5 218 | ) 219 | 220 | do { 221 | let heap = try self.device.heap(size: SpatialHashing.totalBuffersSize(maxPositionsCount: count), storageMode: .shared) 222 | let spatialHashing = try SpatialHashing( 223 | heap: heap, 224 | configuration: config, 225 | maxPositionsCount: positions.count 226 | ) 227 | 228 | let candidatesCount = 8 229 | let positionsBuffer = try device.typedBuffer(with: positions, valueType: .float3) 230 | let collisionCandidatesBuffer = try device.typedBuffer( 231 | with: Array(repeating: UInt32.max, count: positions.count * candidatesCount), 232 | valueType: .uint 233 | ) 234 | 235 | measure { 236 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 237 | XCTFail("Failed to create command buffer") 238 | return 239 | } 240 | 241 | spatialHashing.build(positions: positionsBuffer, in: commandBuffer) 242 | 243 | spatialHashing.find( 244 | collidablePositions: nil, 245 | collisionCandidates: collisionCandidatesBuffer, 246 | connectedVertices: nil, 247 | in: commandBuffer 248 | ) 249 | 250 | let startTime = CFAbsoluteTimeGetCurrent() 251 | commandBuffer.addCompletedHandler { _ in 252 | let endTime = CFAbsoluteTimeGetCurrent() 253 | let duration = (endTime - startTime) * 1000 254 | print("Performance test for \(count) positions took \(duration) ms") 255 | } 256 | 257 | commandBuffer.commit() 258 | commandBuffer.waitUntilCompleted() 259 | } 260 | } catch { 261 | XCTFail("Performance test failed with error: \(error)") 262 | } 263 | } 264 | 265 | func testPackedFloat3Format() throws { 266 | let positions: [SIMD3] = [ 267 | [0.0, 0.0, 0.0], 268 | [0.5, 0.0, 0.0], 269 | [1.0, 0.0, 0.0], 270 | [1.5, 0.0, 0.0] 271 | ] 272 | 273 | let packedPositions = positions.map { packed_float3($0) } 274 | 275 | let cellSize: Float = 1.0 276 | let config = SpatialHashing.Configuration(cellSize: cellSize, radius: 0.5) 277 | 278 | let spatialHashing = try SpatialHashing( 279 | device: self.device, 280 | configuration: config, 281 | maxPositionsCount: positions.count 282 | ) 283 | 284 | let positionsBuffer = try device.typedBuffer(with: packedPositions, valueType: .packedFloat3) 285 | let collisionCandidatesBuffer = try device.typedBuffer( 286 | with: Array(repeating: UInt32.max, count: positions.count * 8), 287 | valueType: .uint 288 | ) 289 | 290 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 291 | XCTFail("Failed to create command buffer") 292 | throw NSError(domain: "SpatialHashingTests", code: 1, userInfo: nil) 293 | } 294 | 295 | spatialHashing.build(positions: positionsBuffer, in: commandBuffer) 296 | 297 | spatialHashing.find( 298 | collidablePositions: nil, 299 | collisionCandidates: collisionCandidatesBuffer, 300 | connectedVertices: nil, 301 | in: commandBuffer 302 | ) 303 | 304 | commandBuffer.commit() 305 | commandBuffer.waitUntilCompleted() 306 | 307 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: 8) 308 | 309 | XCTAssertTrue(collisionCandidates[0].contains(1)) 310 | XCTAssertTrue(collisionCandidates[1].contains(0)) 311 | XCTAssertTrue(collisionCandidates[1].contains(2)) 312 | XCTAssertTrue(collisionCandidates[2].contains(1)) 313 | XCTAssertTrue(collisionCandidates[2].contains(3)) 314 | XCTAssertTrue(collisionCandidates[3].contains(2)) 315 | } 316 | 317 | func testExternalCollidablePositions() throws { 318 | let colliderPositions: [SIMD3] = [ 319 | [0.0, 0.0, 0.0], 320 | [1.0, 0.0, 0.0], 321 | [2.0, 0.0, 0.0], 322 | [3.0, 0.0, 0.0] 323 | ] 324 | 325 | let externalPositions: [SIMD3] = [ 326 | [0.5, 0.0, 0.0], 327 | [1.5, 0.0, 0.0], 328 | [2.5, 0.0, 0.0] 329 | ] 330 | 331 | let cellSize: Float = 1.0 332 | let config = SpatialHashing.Configuration(cellSize: cellSize, radius: 0.5) 333 | 334 | let spatialHashing = try SpatialHashing( 335 | device: self.device, 336 | configuration: config, 337 | maxPositionsCount: colliderPositions.count 338 | ) 339 | 340 | let colliderBuffer = try device.typedBuffer(with: colliderPositions, valueType: .float3) 341 | let externalBuffer = try device.typedBuffer(with: externalPositions, valueType: .float3) 342 | let collisionCandidatesBuffer = try device.typedBuffer( 343 | with: Array(repeating: UInt32.max, count: externalPositions.count * 8), 344 | valueType: .uint 345 | ) 346 | 347 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 348 | XCTFail("Failed to create command buffer") 349 | throw NSError(domain: "SpatialHashingTests", code: 1, userInfo: nil) 350 | } 351 | 352 | spatialHashing.build(positions: colliderBuffer, in: commandBuffer) 353 | 354 | spatialHashing.find( 355 | collidablePositions: externalBuffer, 356 | collisionCandidates: collisionCandidatesBuffer, 357 | connectedVertices: nil, 358 | in: commandBuffer 359 | ) 360 | 361 | commandBuffer.commit() 362 | commandBuffer.waitUntilCompleted() 363 | 364 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: 8) 365 | 366 | XCTAssertTrue(collisionCandidates[0].contains(0)) 367 | XCTAssertTrue(collisionCandidates[0].contains(1)) 368 | XCTAssertTrue(collisionCandidates[1].contains(1)) 369 | XCTAssertTrue(collisionCandidates[1].contains(2)) 370 | XCTAssertTrue(collisionCandidates[2].contains(2)) 371 | XCTAssertTrue(collisionCandidates[2].contains(3)) 372 | } 373 | 374 | func testMixedFormatPositions() throws { 375 | let colliderPositions: [SIMD3] = [ 376 | [0.0, 0.0, 0.0], 377 | [1.0, 0.0, 0.0], 378 | [2.0, 0.0, 0.0], 379 | [3.0, 0.0, 0.0] 380 | ] 381 | 382 | let externalPositions: [SIMD3] = [ 383 | [0.5, 0.0, 0.0], 384 | [1.5, 0.0, 0.0], 385 | [2.5, 0.0, 0.0] 386 | ] 387 | 388 | let cellSize: Float = 1.0 389 | let config = SpatialHashing.Configuration(cellSize: cellSize, radius: 0.5) 390 | 391 | let spatialHashing = try SpatialHashing( 392 | device: self.device, 393 | configuration: config, 394 | maxPositionsCount: colliderPositions.count 395 | ) 396 | 397 | let packedColliderPositions = colliderPositions.map { packed_float3($0) } 398 | let colliderBuffer = try device.typedBuffer(with: packedColliderPositions, valueType: .packedFloat3) 399 | let externalBuffer = try device.typedBuffer(with: externalPositions, valueType: .float3) 400 | let collisionCandidatesBuffer = try device.typedBuffer( 401 | with: Array(repeating: UInt32.max, count: externalPositions.count * 8), 402 | valueType: .uint 403 | ) 404 | 405 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 406 | XCTFail("Failed to create command buffer") 407 | throw NSError(domain: "SpatialHashingTests", code: 1, userInfo: nil) 408 | } 409 | 410 | spatialHashing.build(positions: colliderBuffer, in: commandBuffer) 411 | 412 | spatialHashing.find( 413 | collidablePositions: externalBuffer, 414 | collisionCandidates: collisionCandidatesBuffer, 415 | connectedVertices: nil, 416 | in: commandBuffer 417 | ) 418 | 419 | commandBuffer.commit() 420 | commandBuffer.waitUntilCompleted() 421 | 422 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: 8) 423 | 424 | XCTAssertTrue(collisionCandidates[0].contains(0)) 425 | XCTAssertTrue(collisionCandidates[0].contains(1)) 426 | XCTAssertTrue(collisionCandidates[1].contains(1)) 427 | XCTAssertTrue(collisionCandidates[1].contains(2)) 428 | XCTAssertTrue(collisionCandidates[2].contains(2)) 429 | XCTAssertTrue(collisionCandidates[2].contains(3)) 430 | } 431 | 432 | func testEdgeCases() throws { 433 | let positions: [SIMD3] = [ 434 | [0.0, 0.0, 0.0], 435 | [1.01, 0.0, 0.0], 436 | [0.99, 0.0, 0.0], 437 | [1.1, 0.0, 0.0], 438 | [10.0, 10.0, 10.0] 439 | ] 440 | 441 | let cellSize: Float = 1.0 442 | let radius: Float = 0.5 443 | let config = SpatialHashing.Configuration(cellSize: cellSize, radius: radius) 444 | 445 | let spatialHashing = try SpatialHashing( 446 | device: self.device, 447 | configuration: config, 448 | maxPositionsCount: positions.count 449 | ) 450 | 451 | let positionsBuffer = try device.typedBuffer(with: positions, valueType: .float3) 452 | let collisionCandidatesBuffer = try device.typedBuffer( 453 | with: Array(repeating: UInt32.max, count: positions.count * 8), 454 | valueType: .uint 455 | ) 456 | 457 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 458 | XCTFail("Failed to create command buffer") 459 | throw NSError(domain: "SpatialHashingTests", code: 1, userInfo: nil) 460 | } 461 | 462 | spatialHashing.build(positions: positionsBuffer, in: commandBuffer) 463 | 464 | spatialHashing.find( 465 | collidablePositions: nil, 466 | collisionCandidates: collisionCandidatesBuffer, 467 | connectedVertices: nil, 468 | in: commandBuffer 469 | ) 470 | 471 | commandBuffer.commit() 472 | commandBuffer.waitUntilCompleted() 473 | 474 | let collisionCandidates: [[UInt32]] = collisionCandidatesBuffer.values()!.chunked(into: 8) 475 | 476 | // Adjusted assertions based on the spatial hashing behavior 477 | XCTAssertTrue(collisionCandidates[0].contains(2), "Position 0 should detect position 2 as a neighbor") 478 | XCTAssertFalse(collisionCandidates[0].contains(1), "Position 0 should not detect position 1 as a neighbor") 479 | 480 | XCTAssertTrue(collisionCandidates[1].contains(2), "Position 1 should detect position 2 as a neighbor") 481 | XCTAssertTrue(collisionCandidates[1].contains(3), "Position 1 should detect position 3 as a neighbor") 482 | 483 | XCTAssertTrue(collisionCandidates[2].contains(0), "Position 2 should detect position 0 as a neighbor") 484 | XCTAssertTrue(collisionCandidates[2].contains(1), "Position 2 should detect position 1 as a neighbor") 485 | XCTAssertTrue(collisionCandidates[2].contains(3), "Position 2 should detect position 3 as a neighbor") 486 | 487 | XCTAssertTrue(collisionCandidates[3].contains(1), "Position 3 should detect position 1 as a neighbor") 488 | XCTAssertTrue(collisionCandidates[3].contains(2), "Position 3 should detect position 2 as a neighbor") 489 | 490 | XCTAssertFalse(collisionCandidates[4].contains(0), "Position 4 should not detect any neighbors") 491 | XCTAssertFalse(collisionCandidates[4].contains(1), "Position 4 should not detect any neighbors") 492 | XCTAssertFalse(collisionCandidates[4].contains(2), "Position 4 should not detect any neighbors") 493 | XCTAssertFalse(collisionCandidates[4].contains(3), "Position 4 should not detect any neighbors") 494 | } 495 | 496 | 497 | func testPerformanceFor1kPositions() throws { 498 | try testPerformanceForPositions(1_000) 499 | } 500 | 501 | func testPerformanceFor10kPositions() throws { 502 | try testPerformanceForPositions(10_000) 503 | } 504 | 505 | func testPerformanceFor100kPositions() throws { 506 | try testPerformanceForPositions(100_000) 507 | } 508 | 509 | func testPerformanceFor1mPositions() throws { 510 | try testPerformanceForPositions(1_000_000) 511 | } 512 | } 513 | 514 | private extension Array { 515 | func chunked(into size: Int) -> [[Element]] { 516 | return stride(from: 0, to: count, by: size).map { 517 | Array(self[$0 ..< Swift.min($0 + size, count)]) 518 | } 519 | } 520 | } 521 | 522 | private struct packed_float3 { 523 | let x: Float 524 | let y: Float 525 | let z: Float 526 | 527 | init(x: Float, y: Float, z: Float) { 528 | self.x = x 529 | self.y = y 530 | self.z = z 531 | } 532 | 533 | 534 | init(_ simd: SIMD3) { 535 | self.x = simd.x 536 | self.y = simd.y 537 | self.z = simd.z 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /Tests/SimulationToolsTests/TriangleSpatialHashingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Metal 3 | @testable import SimulationTools 4 | 5 | final class TriangleSpatialHashingTests: XCTestCase { 6 | var device: MTLDevice! 7 | var commandQueue: MTLCommandQueue! 8 | 9 | override func setUp() { 10 | super.setUp() 11 | self.device = MTLCreateSystemDefaultDevice() 12 | self.commandQueue = self.device.makeCommandQueue() 13 | } 14 | 15 | override func tearDown() { 16 | self.device = nil 17 | self.commandQueue = nil 18 | super.tearDown() 19 | } 20 | 21 | func testTriangleSpatialHashingInitialization() throws { 22 | let config = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 23 | 24 | XCTAssertNoThrow( 25 | try TriangleSpatialHashing( 26 | device: self.device, 27 | configuration: config, 28 | maxTrianglesCount: 100 29 | ) 30 | ) 31 | } 32 | 33 | func testBuildAndFindExternalCollision() throws { 34 | let config = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 35 | let spatialHashing = try TriangleSpatialHashing(device: self.device, configuration: config, maxTrianglesCount: 2) 36 | 37 | let colliderPositions = [ 38 | SIMD3(0, 0, 0), SIMD3(1, 0, 0), SIMD3(0, 1, 0), // Triangle 1 39 | SIMD3(2, 0, 0), SIMD3(3, 0, 0), SIMD3(2, 1, 0) // Triangle 2 40 | ] 41 | let colliderTriangles = [SIMD3(0, 1, 2), SIMD3(3, 4, 5)] 42 | let positions = [SIMD3(0.5, 0.5, 0), SIMD3(2.5, 0.5, 0)] 43 | 44 | let positionsBuffer = try createTypedBuffer(from: positions, type: .float3) 45 | let colliderPositionsBuffer = try createTypedBuffer(from: colliderPositions, type: .float3) 46 | let colliderTrianglesBuffer = try createTypedBuffer(from: colliderTriangles, type: .uint3) 47 | 48 | let collisionCandidates = try findTriangleCollisionCandidates( 49 | spatialHashing: spatialHashing, 50 | positions: positionsBuffer, 51 | colliderPositions: colliderPositionsBuffer, 52 | colliderTriangles: colliderTrianglesBuffer 53 | ) 54 | 55 | let chunks = collisionCandidates.chunked(into: 8) 56 | XCTAssertEqual(chunks[0][0], 0) // First position should collide with first triangle 57 | XCTAssertEqual(chunks[1][0], 1) // Second position should collide with second triangle 58 | } 59 | 60 | func testBuildAndFindSelfCollision() throws { 61 | let config = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 62 | let spatialHashing = try TriangleSpatialHashing(device: self.device, configuration: config, maxTrianglesCount: 3) 63 | 64 | let positions = [ 65 | SIMD3(0, 0, 0), SIMD3(1, 0, 0), SIMD3(0, 1, 0), // Triangle 1 66 | SIMD3(0.5, 0.5, 0), SIMD3(1.5, 0.5, 0), SIMD3(0.5, 1.5, 0), // Triangle 2 67 | SIMD3(2, 0, 0), SIMD3(3, 0, 0), SIMD3(2, 1, 0) // Triangle 3 68 | ] 69 | let triangles = [SIMD3(0, 1, 2), SIMD3(3, 4, 5), SIMD3(6, 7, 8)] 70 | 71 | let positionsBuffer = try createTypedBuffer(from: positions, type: .float3) 72 | let trianglesBuffer = try createTypedBuffer(from: triangles, type: .uint3) 73 | 74 | let collisionCandidates = try findTriangleCollisionCandidates( 75 | spatialHashing: spatialHashing, 76 | positions: nil, 77 | colliderPositions: positionsBuffer, 78 | colliderTriangles: trianglesBuffer 79 | ) 80 | 81 | let chunks = collisionCandidates.chunked(into: 8) 82 | XCTAssertEqual(chunks[3][0], 0) // Vertex of triangle 2 should collide with triangle 1 83 | XCTAssertEqual(chunks[0][0], 1) // Vertex of triangle 1 should collide with triangle 2 84 | } 85 | 86 | func testPackedFormatExternalCollision() throws { 87 | let config = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 88 | let spatialHashing = try TriangleSpatialHashing(device: self.device, configuration: config, maxTrianglesCount: 2) 89 | 90 | let colliderPositions = [ 91 | packed_float3(0, 0, 0), packed_float3(1, 0, 0), packed_float3(0, 1, 0), // Triangle 1 92 | packed_float3(2, 0, 0), packed_float3(3, 0, 0), packed_float3(2, 1, 0) // Triangle 2 93 | ] 94 | let colliderTriangles = [packed_uint3(0, 1, 2), packed_uint3(3, 4, 5)] 95 | let positions = [SIMD3(0.5, 0.5, 0), SIMD3(2.5, 0.5, 0)] 96 | 97 | let positionsBuffer = try createTypedBuffer(from: positions, type: .float3) 98 | let colliderPositionsBuffer = try createTypedBuffer(from: colliderPositions, type: .packedFloat3) 99 | let colliderTrianglesBuffer = try createTypedBuffer(from: colliderTriangles, type: .packedUInt3) 100 | 101 | let collisionCandidates = try findTriangleCollisionCandidates( 102 | spatialHashing: spatialHashing, 103 | positions: positionsBuffer, 104 | colliderPositions: colliderPositionsBuffer, 105 | colliderTriangles: colliderTrianglesBuffer 106 | ) 107 | 108 | let chunks = collisionCandidates.chunked(into: 8) 109 | XCTAssertEqual(chunks[0][0], 0) // First position should collide with first triangle 110 | XCTAssertEqual(chunks[1][0], 1) // Second position should collide with second triangle 111 | } 112 | 113 | func testMixedFormatSelfCollision() throws { 114 | let config = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 115 | let spatialHashing = try TriangleSpatialHashing(device: self.device, configuration: config, maxTrianglesCount: 3) 116 | 117 | let positions = [ 118 | packed_float3(0, 0, 0), packed_float3(1, 0, 0), packed_float3(0, 1, 0), // Triangle 1 119 | packed_float3(0.5, 0.5, 0), packed_float3(1.5, 0.5, 0), packed_float3(0.5, 1.5, 0), // Triangle 2 120 | packed_float3(2, 0, 0), packed_float3(3, 0, 0), packed_float3(2, 1, 0) // Triangle 3 121 | ] 122 | let triangles = [SIMD3(0, 1, 2), SIMD3(3, 4, 5), SIMD3(6, 7, 8)] 123 | 124 | let positionsBuffer = try createTypedBuffer(from: positions, type: .packedFloat3) 125 | let trianglesBuffer = try createTypedBuffer(from: triangles, type: .uint3) 126 | 127 | let collisionCandidates = try findTriangleCollisionCandidates( 128 | spatialHashing: spatialHashing, 129 | positions: nil, 130 | colliderPositions: positionsBuffer, 131 | colliderTriangles: trianglesBuffer 132 | ) 133 | 134 | let chunks = collisionCandidates.chunked(into: 8) 135 | XCTAssertTrue(chunks[3].contains(0)) // Vertex of triangle 2 should collide with triangle 1 136 | XCTAssertTrue (chunks[0].contains(1)) // Vertex of triangle 1 should collide with triangle 2 137 | XCTAssertTrue(!chunks[0].contains(0)) // Vertex of triangle 1 should not collide with triangle 1 138 | XCTAssertTrue(!chunks[3].contains(1)) // Vertex of triangle 2 should not collide with triangle 2 139 | } 140 | 141 | func testPerformanceFor10kTriangles() throws { 142 | try testPerformanceForTriangles(10_000) 143 | } 144 | 145 | func testPerformanceFor100kTriangles() throws { 146 | try testPerformanceForTriangles(100_000) 147 | } 148 | 149 | func testPerformanceForTriangles(_ count: Int) throws { 150 | let config = TriangleSpatialHashing.Configuration(cellSize: 1.0, bucketSize: 8) 151 | let spatialHashing = try TriangleSpatialHashing(device: self.device, configuration: config, maxTrianglesCount: count) 152 | 153 | let (meshPositions, triangles, meshDimensions) = generateUniformMesh(triangleCount: count) 154 | let randomPositions = generateRandomPositions(count: count, meshDimensions: meshDimensions) 155 | 156 | let meshPositionsBuffer = try createTypedBuffer(from: meshPositions, type: .float3) 157 | let trianglesBuffer = try createTypedBuffer(from: triangles, type: .uint3) 158 | let randomPositionsBuffer = try createTypedBuffer(from: randomPositions, type: .float3) 159 | let collisionCandidatesBuffer = try device.typedBuffer( 160 | with: Array(repeating: UInt32.max, count: count * 8), 161 | valueType: .uint 162 | ) 163 | 164 | measure { 165 | guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { 166 | XCTFail("Failed to create command buffer") 167 | return 168 | } 169 | 170 | spatialHashing.build( 171 | colliderPositions: meshPositionsBuffer, 172 | indices: trianglesBuffer, 173 | in: commandBuffer 174 | ) 175 | 176 | spatialHashing.find( 177 | collidablePositions: randomPositionsBuffer, 178 | colliderPositions: meshPositionsBuffer, 179 | indices: trianglesBuffer, 180 | collisionCandidates: collisionCandidatesBuffer, 181 | in: commandBuffer 182 | ) 183 | let startTime = CFAbsoluteTimeGetCurrent() 184 | commandBuffer.addCompletedHandler { _ in 185 | let endTime = CFAbsoluteTimeGetCurrent() 186 | let duration = (endTime - startTime) * 1000 187 | print("Performance test for \(count) triangles and \(count) random positions took \(duration) ms") 188 | } 189 | 190 | commandBuffer.commit() 191 | commandBuffer.waitUntilCompleted() 192 | } 193 | } 194 | 195 | func generateUniformMesh(triangleCount: Int) -> (positions: [SIMD3], triangles: [SIMD3], dimensions: SIMD2) { 196 | let gridSize = Int(ceil(sqrt(Float(triangleCount)))) 197 | let cellSize: Float = 1.0 198 | var positions: [SIMD3] = [] 199 | var triangles: [SIMD3] = [] 200 | 201 | for i in 0..(x, 0, z)) 208 | positions.append(SIMD3(x + cellSize, 0, z)) 209 | positions.append(SIMD3(x, 0, z + cellSize)) 210 | positions.append(SIMD3(x + cellSize, 0, z + cellSize)) 211 | 212 | triangles.append(SIMD3(baseIndex, baseIndex + 1, baseIndex + 2)) 213 | triangles.append(SIMD3(baseIndex + 1, baseIndex + 3, baseIndex + 2)) 214 | 215 | if triangles.count >= triangleCount { 216 | break 217 | } 218 | } 219 | if triangles.count >= triangleCount { 220 | break 221 | } 222 | } 223 | 224 | triangles = Array(triangles.prefix(triangleCount)) 225 | 226 | let dimensions = SIMD2(Float(gridSize) * cellSize, Float(gridSize) * cellSize) 227 | 228 | return (positions, triangles, dimensions) 229 | } 230 | 231 | func generateRandomPositions(count: Int, meshDimensions: SIMD2) -> [SIMD3] { 232 | return (0..( 234 | Float.random(in: 0...meshDimensions.x), 235 | Float.random(in: 0...2.0), // Random height up to 2 units above the plane 236 | Float.random(in: 0...meshDimensions.y) 237 | ) 238 | } 239 | } 240 | 241 | private func findTriangleCollisionCandidates( 242 | spatialHashing: TriangleSpatialHashing, 243 | positions: MTLTypedBuffer?, 244 | colliderPositions: MTLTypedBuffer, 245 | colliderTriangles: MTLTypedBuffer 246 | ) throws -> [UInt32] { 247 | let count = positions?.descriptor.count ?? colliderPositions.descriptor.count 248 | let collisionCandidatesBuffer = try device.typedBuffer( 249 | with: Array(repeating: UInt32.max, count: count * 8), 250 | valueType: .uint 251 | ) 252 | 253 | let commandBuffer = commandQueue.makeCommandBuffer()! 254 | 255 | spatialHashing.build( 256 | colliderPositions: colliderPositions, 257 | indices: colliderTriangles, 258 | in: commandBuffer 259 | ) 260 | 261 | spatialHashing.find( 262 | collidablePositions: positions, 263 | colliderPositions: colliderPositions, 264 | indices: colliderTriangles, 265 | collisionCandidates: collisionCandidatesBuffer, 266 | in: commandBuffer 267 | ) 268 | 269 | commandBuffer.commit() 270 | commandBuffer.waitUntilCompleted() 271 | 272 | return collisionCandidatesBuffer.values()! 273 | } 274 | 275 | private func createTriangles(count: Int) -> [SIMD3] { 276 | var triangles: [SIMD3] = [] 277 | triangles.reserveCapacity(count) 278 | 279 | for i in stride(from: 0, to: count * 3, by: 3) { 280 | let triangle = SIMD3(UInt32(i), UInt32(i + 1), UInt32(i + 2)) 281 | triangles.append(triangle) 282 | } 283 | 284 | return triangles 285 | } 286 | 287 | private func createTypedBuffer(from array: [T], type: MTLBufferValueType) throws -> MTLTypedBuffer { 288 | try device.typedBuffer(with: array, valueType: type, options: []) 289 | } 290 | 291 | private func createTypedBuffer(count: Int, type: MTLBufferValueType) throws -> MTLTypedBuffer { 292 | try device.typedBuffer(descriptor: .init(valueType: type, count: count)) 293 | } 294 | } 295 | 296 | private extension Array { 297 | func chunked(into size: Int) -> [[Element]] { 298 | return stride(from: 0, to: count, by: size).map { 299 | Array(self[$0 ..< Swift.min($0 + size, count)]) 300 | } 301 | } 302 | } 303 | 304 | private struct packed_float3 { 305 | let x: Float 306 | let y: Float 307 | let z: Float 308 | 309 | init(_ x: Float, _ y: Float, _ z: Float) { 310 | self.x = x 311 | self.y = y 312 | self.z = z 313 | } 314 | } 315 | 316 | private struct packed_uint3 { 317 | let x: UInt32 318 | let y: UInt32 319 | let z: UInt32 320 | 321 | init(_ x: UInt32, _ y: UInt32, _ z: UInt32) { 322 | self.x = x 323 | self.y = y 324 | self.z = z 325 | } 326 | } 327 | 328 | 329 | func run() throws { 330 | 331 | } 332 | --------------------------------------------------------------------------------