├── .gitignore ├── LICENSE ├── MetalSplatter ├── Resources │ ├── MultiStageRenderPath.metal │ ├── ShaderCommon.h │ ├── SingleStageRenderPath.metal │ ├── SplatProcessing.h │ └── SplatProcessing.metal └── Sources │ ├── MetalBuffer.swift │ └── SplatRenderer.swift ├── PLYIO ├── Sources │ ├── BitPatternConvertible.swift │ ├── ByteWidthProviding.swift │ ├── DataConvertible.swift │ ├── EndianConvertible.swift │ ├── PLYElement+ascii.swift │ ├── PLYElement+binary.swift │ ├── PLYElement.swift │ ├── PLYHeader+ascii.swift │ ├── PLYHeader.swift │ ├── PLYReader.swift │ ├── PLYWriter.swift │ ├── UnsafeRawPointerConvertible.swift │ └── ZeroProviding.swift ├── TestData │ ├── beetle.ascii.ply │ └── beetle.binary.ply └── Tests │ ├── DataConvertibleTests.swift │ ├── PLYIOTests.swift │ └── UnsafeRawPointerConvertibleTests.swift ├── Package.swift ├── README.md ├── SampleApp ├── App │ ├── Constants.swift │ └── SampleApp.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ └── Contents.json ├── Info.plist ├── MetalSplatter_SampleApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── MetalSplatter SampleApp.xcscheme ├── Model │ ├── ModelIdentifier.swift │ ├── ModelRenderer.swift │ ├── SampleBoxRenderer+ModelRenderer.swift │ └── SplatRenderer+ModelRenderer.swift ├── Scene │ ├── ContentStageConfiguration.swift │ ├── ContentView.swift │ ├── MetalKitSceneRenderer.swift │ ├── MetalKitSceneView.swift │ └── VisionSceneRenderer.swift └── Util │ └── MatrixMathUtil.swift ├── SampleBoxRenderer ├── Resources │ ├── Assets.xcassets │ │ ├── ColorMap.textureset │ │ │ ├── Contents.json │ │ │ └── Universal.mipmapset │ │ │ │ ├── ColorMap.png │ │ │ │ └── Contents.json │ │ └── Contents.json │ └── Shaders.metal └── Sources │ └── SampleBoxRenderer.swift ├── SplatConverter └── Sources │ └── SplatConverter.swift └── SplatIO ├── Sources ├── AutodetectSceneReader.swift ├── Comparable+clamped.swift ├── DotSplatEncodedPoint.swift ├── DotSplatSceneReader.swift ├── DotSplatSceneWriter.swift ├── SplatFileFormat.swift ├── SplatMemoryBuffer.swift ├── SplatPLYConstants.swift ├── SplatPLYSceneReader.swift ├── SplatPLYSceneWriter.swift ├── SplatScenePoint+CustomStringConvertible.swift ├── SplatScenePoint.swift ├── SplatSceneReader.swift └── SplatSceneWriter.swift ├── TestData ├── test-splat.3-points-from-train.ply └── test-splat.3-points-from-train.splat └── Tests └── SplatIOTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | xcuserdata 3 | DerivedData/ 4 | .swiftpm 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sean Cier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MetalSplatter/Resources/MultiStageRenderPath.metal: -------------------------------------------------------------------------------- 1 | #include "SplatProcessing.h" 2 | 3 | typedef struct 4 | { 5 | half4 color [[raster_order_group(0)]]; 6 | float depth [[raster_order_group(0)]]; 7 | } FragmentValues; 8 | 9 | typedef struct 10 | { 11 | FragmentValues values [[imageblock_data]]; 12 | } FragmentStore; 13 | 14 | typedef struct 15 | { 16 | half4 color [[color(0)]]; 17 | float depth [[depth(any)]]; 18 | } FragmentOut; 19 | 20 | kernel void initializeFragmentStore(imageblock blockData, 21 | ushort2 localThreadID [[thread_position_in_threadgroup]]) { 22 | threadgroup_imageblock FragmentValues *values = blockData.data(localThreadID); 23 | values->color = { 0, 0, 0, 0 }; 24 | values->depth = 0; 25 | } 26 | 27 | vertex FragmentIn multiStageSplatVertexShader(uint vertexID [[vertex_id]], 28 | uint instanceID [[instance_id]], 29 | ushort amplificationID [[amplification_id]], 30 | constant Splat* splatArray [[ buffer(BufferIndexSplat) ]], 31 | constant UniformsArray & uniformsArray [[ buffer(BufferIndexUniforms) ]]) { 32 | Uniforms uniforms = uniformsArray.uniforms[min(int(amplificationID), kMaxViewCount)]; 33 | 34 | uint splatID = instanceID * uniforms.indexedSplatCount + (vertexID / 4); 35 | if (splatID >= uniforms.splatCount) { 36 | FragmentIn out; 37 | out.position = float4(1, 1, 0, 1); 38 | return out; 39 | } 40 | 41 | Splat splat = splatArray[splatID]; 42 | 43 | return splatVertex(splat, uniforms, vertexID % 4); 44 | } 45 | 46 | fragment FragmentStore multiStageSplatFragmentShader(FragmentIn in [[stage_in]], 47 | FragmentValues previousFragmentValues [[imageblock_data]]) { 48 | FragmentStore out; 49 | 50 | half alpha = splatFragmentAlpha(in.relativePosition, in.color.a); 51 | half4 colorWithPremultipliedAlpha = half4(in.color.rgb * alpha, alpha); 52 | 53 | half oneMinusAlpha = 1 - alpha; 54 | 55 | half4 previousColor = previousFragmentValues.color; 56 | out.values.color = previousColor * oneMinusAlpha + colorWithPremultipliedAlpha; 57 | 58 | float previousDepth = previousFragmentValues.depth; 59 | float depth = in.position.z; 60 | out.values.depth = previousDepth * oneMinusAlpha + depth * alpha; 61 | 62 | return out; 63 | } 64 | 65 | /// Generate a single triangle covering the entire screen 66 | vertex FragmentIn postprocessVertexShader(uint vertexID [[vertex_id]]) { 67 | FragmentIn out; 68 | 69 | float4 position; 70 | position.x = (vertexID == 2) ? 3.0 : -1.0; 71 | position.y = (vertexID == 0) ? -3.0 : 1.0; 72 | position.zw = 1.0; 73 | 74 | out.position = position; 75 | return out; 76 | } 77 | 78 | fragment FragmentOut postprocessFragmentShader(FragmentValues fragmentValues [[imageblock_data]]) { 79 | FragmentOut out; 80 | out.depth = (fragmentValues.color.a == 0) ? 0 : fragmentValues.depth / fragmentValues.color.a; 81 | out.color = fragmentValues.color; 82 | return out; 83 | } 84 | 85 | fragment half4 postprocessFragmentShaderNoDepth(FragmentValues fragmentValues [[imageblock_data]]) { 86 | return fragmentValues.color; 87 | } 88 | -------------------------------------------------------------------------------- /MetalSplatter/Resources/ShaderCommon.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | using namespace metal; 5 | 6 | constant const int kMaxViewCount = 2; 7 | constant static const half kBoundsRadius = 3; 8 | constant static const half kBoundsRadiusSquared = kBoundsRadius*kBoundsRadius; 9 | 10 | enum BufferIndex: int32_t 11 | { 12 | BufferIndexUniforms = 0, 13 | BufferIndexSplat = 1, 14 | }; 15 | 16 | typedef struct 17 | { 18 | matrix_float4x4 projectionMatrix; 19 | matrix_float4x4 viewMatrix; 20 | uint2 screenSize; 21 | 22 | /* 23 | The first N splats are represented as as 2N primitives and 4N vertex indices. The remained are represented 24 | as instanced of these first N. This allows us to limit the size of the indexed array (and associated memory), 25 | but also avoid the performance penalty of a very large number of instances. 26 | */ 27 | uint splatCount; 28 | uint indexedSplatCount; 29 | } Uniforms; 30 | 31 | typedef struct 32 | { 33 | Uniforms uniforms[kMaxViewCount]; 34 | } UniformsArray; 35 | 36 | typedef struct 37 | { 38 | packed_float3 position; 39 | packed_half4 color; 40 | packed_half3 covA; 41 | packed_half3 covB; 42 | } Splat; 43 | 44 | typedef struct 45 | { 46 | float4 position [[position]]; 47 | half2 relativePosition; // Ranges from -kBoundsRadius to +kBoundsRadius 48 | half4 color; 49 | } FragmentIn; 50 | -------------------------------------------------------------------------------- /MetalSplatter/Resources/SingleStageRenderPath.metal: -------------------------------------------------------------------------------- 1 | #include "SplatProcessing.h" 2 | 3 | vertex FragmentIn singleStageSplatVertexShader(uint vertexID [[vertex_id]], 4 | uint instanceID [[instance_id]], 5 | ushort amplificationID [[amplification_id]], 6 | constant Splat* splatArray [[ buffer(BufferIndexSplat) ]], 7 | constant UniformsArray & uniformsArray [[ buffer(BufferIndexUniforms) ]]) { 8 | Uniforms uniforms = uniformsArray.uniforms[min(int(amplificationID), kMaxViewCount)]; 9 | 10 | uint splatID = instanceID * uniforms.indexedSplatCount + (vertexID / 4); 11 | if (splatID >= uniforms.splatCount) { 12 | FragmentIn out; 13 | out.position = float4(1, 1, 0, 1); 14 | return out; 15 | } 16 | 17 | Splat splat = splatArray[splatID]; 18 | 19 | return splatVertex(splat, uniforms, vertexID % 4); 20 | } 21 | 22 | fragment half4 singleStageSplatFragmentShader(FragmentIn in [[stage_in]]) { 23 | half alpha = splatFragmentAlpha(in.relativePosition, in.color.a); 24 | return half4(alpha * in.color.rgb, alpha); 25 | } 26 | -------------------------------------------------------------------------------- /MetalSplatter/Resources/SplatProcessing.h: -------------------------------------------------------------------------------- 1 | #import "ShaderCommon.h" 2 | 3 | float3 calcCovariance2D(float3 viewPos, 4 | packed_half3 cov3Da, 5 | packed_half3 cov3Db, 6 | float4x4 viewMatrix, 7 | float4x4 projectionMatrix, 8 | uint2 screenSize); 9 | 10 | void decomposeCovariance(float3 cov2D, thread float2 &v1, thread float2 &v2); 11 | 12 | FragmentIn splatVertex(Splat splat, 13 | Uniforms uniforms, 14 | uint relativeVertexIndex); 15 | 16 | half splatFragmentAlpha(half2 relativePosition, half splatAlpha); 17 | -------------------------------------------------------------------------------- /MetalSplatter/Resources/SplatProcessing.metal: -------------------------------------------------------------------------------- 1 | #import "SplatProcessing.h" 2 | 3 | float3 calcCovariance2D(float3 viewPos, 4 | packed_half3 cov3Da, 5 | packed_half3 cov3Db, 6 | float4x4 viewMatrix, 7 | float4x4 projectionMatrix, 8 | uint2 screenSize) { 9 | float invViewPosZ = 1 / viewPos.z; 10 | float invViewPosZSquared = invViewPosZ * invViewPosZ; 11 | 12 | float tanHalfFovX = 1 / projectionMatrix[0][0]; 13 | float tanHalfFovY = 1 / projectionMatrix[1][1]; 14 | float limX = 1.3 * tanHalfFovX; 15 | float limY = 1.3 * tanHalfFovY; 16 | viewPos.x = clamp(viewPos.x * invViewPosZ, -limX, limX) * viewPos.z; 17 | viewPos.y = clamp(viewPos.y * invViewPosZ, -limY, limY) * viewPos.z; 18 | 19 | float focalX = screenSize.x * projectionMatrix[0][0] / 2; 20 | float focalY = screenSize.y * projectionMatrix[1][1] / 2; 21 | 22 | float3x3 J = float3x3( 23 | focalX * invViewPosZ, 0, 0, 24 | 0, focalY * invViewPosZ, 0, 25 | -(focalX * viewPos.x) * invViewPosZSquared, -(focalY * viewPos.y) * invViewPosZSquared, 0 26 | ); 27 | float3x3 W = float3x3(viewMatrix[0].xyz, viewMatrix[1].xyz, viewMatrix[2].xyz); 28 | float3x3 T = J * W; 29 | float3x3 Vrk = float3x3( 30 | cov3Da.x, cov3Da.y, cov3Da.z, 31 | cov3Da.y, cov3Db.x, cov3Db.y, 32 | cov3Da.z, cov3Db.y, cov3Db.z 33 | ); 34 | float3x3 cov = T * Vrk * transpose(T); 35 | 36 | // Apply low-pass filter: every Gaussian should be at least 37 | // one pixel wide/high. Discard 3rd row and column. 38 | cov[0][0] += 0.3; 39 | cov[1][1] += 0.3; 40 | return float3(cov[0][0], cov[0][1], cov[1][1]); 41 | } 42 | 43 | // cov2D is a flattened 2d covariance matrix. Given 44 | // covariance = | a b | 45 | // | c d | 46 | // (where b == c because the Gaussian covariance matrix is symmetric), 47 | // cov2D = ( a, b, d ) 48 | void decomposeCovariance(float3 cov2D, thread float2 &v1, thread float2 &v2) { 49 | float a = cov2D.x; 50 | float b = cov2D.y; 51 | float d = cov2D.z; 52 | float det = a * d - b * b; // matrix is symmetric, so "c" is same as "b" 53 | float trace = a + d; 54 | 55 | float mean = 0.5 * trace; 56 | float dist = max(0.1, sqrt(mean * mean - det)); // based on https://github.com/graphdeco-inria/diff-gaussian-rasterization/blob/main/cuda_rasterizer/forward.cu 57 | 58 | // Eigenvalues 59 | float lambda1 = mean + dist; 60 | float lambda2 = mean - dist; 61 | 62 | float2 eigenvector1; 63 | if (b == 0) { 64 | eigenvector1 = (a > d) ? float2(1, 0) : float2(0, 1); 65 | } else { 66 | eigenvector1 = normalize(float2(b, d - lambda2)); 67 | } 68 | 69 | // Gaussian axes are orthogonal 70 | float2 eigenvector2 = float2(eigenvector1.y, -eigenvector1.x); 71 | 72 | v1 = eigenvector1 * sqrt(lambda1); 73 | v2 = eigenvector2 * sqrt(lambda2); 74 | } 75 | 76 | FragmentIn splatVertex(Splat splat, 77 | Uniforms uniforms, 78 | uint relativeVertexIndex) { 79 | FragmentIn out; 80 | 81 | float4 viewPosition4 = uniforms.viewMatrix * float4(splat.position, 1); 82 | float3 viewPosition3 = viewPosition4.xyz; 83 | 84 | float3 cov2D = calcCovariance2D(viewPosition3, splat.covA, splat.covB, 85 | uniforms.viewMatrix, uniforms.projectionMatrix, uniforms.screenSize); 86 | 87 | float2 axis1; 88 | float2 axis2; 89 | decomposeCovariance(cov2D, axis1, axis2); 90 | 91 | float4 projectedCenter = uniforms.projectionMatrix * viewPosition4; 92 | 93 | float bounds = 1.2 * projectedCenter.w; 94 | if (projectedCenter.z < 0.0 || 95 | projectedCenter.z > projectedCenter.w || 96 | projectedCenter.x < -bounds || 97 | projectedCenter.x > bounds || 98 | projectedCenter.y < -bounds || 99 | projectedCenter.y > bounds) { 100 | out.position = float4(1, 1, 0, 1); 101 | return out; 102 | } 103 | 104 | const half2 relativeCoordinatesArray[] = { { -1, -1 }, { -1, 1 }, { 1, -1 }, { 1, 1 } }; 105 | half2 relativeCoordinates = relativeCoordinatesArray[relativeVertexIndex]; 106 | half2 screenSizeFloat = half2(uniforms.screenSize.x, uniforms.screenSize.y); 107 | half2 projectedScreenDelta = 108 | (relativeCoordinates.x * half2(axis1) + relativeCoordinates.y * half2(axis2)) 109 | * 2 110 | * kBoundsRadius 111 | / screenSizeFloat; 112 | 113 | out.position = float4(projectedCenter.x + projectedScreenDelta.x * projectedCenter.w, 114 | projectedCenter.y + projectedScreenDelta.y * projectedCenter.w, 115 | projectedCenter.z, 116 | projectedCenter.w); 117 | out.relativePosition = kBoundsRadius * relativeCoordinates; 118 | out.color = splat.color; 119 | return out; 120 | } 121 | 122 | half splatFragmentAlpha(half2 relativePosition, half splatAlpha) { 123 | half negativeMagnitudeSquared = -dot(relativePosition, relativePosition); 124 | return (negativeMagnitudeSquared < -kBoundsRadiusSquared) ? 0 : exp(0.5 * negativeMagnitudeSquared) * splatAlpha; 125 | } 126 | -------------------------------------------------------------------------------- /MetalSplatter/Sources/MetalBuffer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Metal 3 | import os 4 | 5 | fileprivate let log = 6 | Logger(subsystem: Bundle.module.bundleIdentifier!, 7 | category: "MetalBuffer") 8 | 9 | class MetalBuffer { 10 | enum Error: LocalizedError { 11 | case capacityGreatedThanMaxCapacity(requested: Int, max: Int) 12 | case bufferCreationFailed 13 | 14 | var errorDescription: String? { 15 | switch self { 16 | case .capacityGreatedThanMaxCapacity(let requested, let max): 17 | "Requested metal buffer size (\(requested)) exceeds device maximum (\(max))" 18 | case .bufferCreationFailed: 19 | "Failed to create metal buffer" 20 | } 21 | } 22 | } 23 | 24 | let device: MTLDevice 25 | 26 | var capacity: Int = 0 27 | var count: Int = 0 28 | var buffer: MTLBuffer 29 | var values: UnsafeMutablePointer 30 | 31 | init(device: MTLDevice, capacity: Int = 1) throws { 32 | let capacity = max(capacity, 1) 33 | guard capacity <= Self.maxCapacity(for: device) else { 34 | throw Error.capacityGreatedThanMaxCapacity(requested: capacity, max: Self.maxCapacity(for: device)) 35 | } 36 | 37 | self.device = device 38 | 39 | self.capacity = capacity 40 | self.count = 0 41 | guard let buffer = device.makeBuffer(length: MemoryLayout.stride * self.capacity, 42 | options: .storageModeShared) else { 43 | throw Error.bufferCreationFailed 44 | } 45 | self.buffer = buffer 46 | self.values = UnsafeMutableRawPointer(self.buffer.contents()).bindMemory(to: T.self, capacity: self.capacity) 47 | } 48 | 49 | static func maxCapacity(for device: MTLDevice) -> Int { 50 | device.maxBufferLength / MemoryLayout.stride 51 | } 52 | 53 | var maxCapacity: Int { 54 | device.maxBufferLength / MemoryLayout.stride 55 | } 56 | 57 | func setCapacity(_ newCapacity: Int) throws { 58 | let newCapacity = max(newCapacity, 1) 59 | guard newCapacity != capacity else { return } 60 | guard capacity <= maxCapacity else { 61 | throw Error.capacityGreatedThanMaxCapacity(requested: capacity, max: maxCapacity) 62 | } 63 | 64 | log.info("Allocating a new buffer of size \(MemoryLayout.stride) * \(newCapacity) = \(Float(MemoryLayout.stride * newCapacity) / (1024.0 * 1024.0))mb") 65 | guard let newBuffer = device.makeBuffer(length: MemoryLayout.stride * newCapacity, 66 | options: .storageModeShared) else { 67 | throw Error.bufferCreationFailed 68 | } 69 | let newValues = UnsafeMutableRawPointer(newBuffer.contents()).bindMemory(to: T.self, capacity: newCapacity) 70 | let newCount = min(count, newCapacity) 71 | if newCount > 0 { 72 | memcpy(newValues, values, MemoryLayout.stride * newCount) 73 | } 74 | 75 | self.capacity = newCapacity 76 | self.count = newCount 77 | self.buffer = newBuffer 78 | self.values = newValues 79 | } 80 | 81 | func ensureCapacity(_ minimumCapacity: Int) throws { 82 | guard capacity < minimumCapacity else { return } 83 | try setCapacity(minimumCapacity) 84 | } 85 | 86 | /// Assumes capacity is available 87 | /// Returns the index of the value 88 | @discardableResult 89 | func append(_ element: T) -> Int { 90 | (values + count).pointee = element 91 | defer { count += 1 } 92 | return count 93 | } 94 | 95 | /// Assumes capacity is available. 96 | /// Returns the index of the first values. 97 | @discardableResult 98 | func append(_ elements: [T]) -> Int { 99 | (values + count).update(from: elements, count: elements.count) 100 | defer { count += elements.count } 101 | return count 102 | } 103 | 104 | /// Assumes capacity is available 105 | /// Returns the index of the value 106 | @discardableResult 107 | func append(_ otherBuffer: MetalBuffer, fromIndex: Int) -> Int { 108 | (values + count).pointee = (otherBuffer.values + fromIndex).pointee 109 | defer { count += 1 } 110 | return count 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /PLYIO/Sources/BitPatternConvertible.swift: -------------------------------------------------------------------------------- 1 | public protocol BitPatternConvertible { 2 | associatedtype BitPattern 3 | var bitPattern: BitPattern { get } 4 | init(bitPattern: BitPattern) 5 | } 6 | 7 | extension Float: BitPatternConvertible {} 8 | extension Double: BitPatternConvertible {} 9 | -------------------------------------------------------------------------------- /PLYIO/Sources/ByteWidthProviding.swift: -------------------------------------------------------------------------------- 1 | public protocol ByteWidthProviding { 2 | static var byteWidth: Int { get } 3 | } 4 | 5 | public extension BinaryInteger { 6 | static var byteWidth: Int { MemoryLayout.size } 7 | } 8 | 9 | public extension BinaryFloatingPoint { 10 | static var byteWidth: Int { MemoryLayout.size } 11 | } 12 | 13 | extension Int8: ByteWidthProviding {} 14 | extension UInt8: ByteWidthProviding {} 15 | extension Int16: ByteWidthProviding {} 16 | extension UInt16: ByteWidthProviding {} 17 | extension Int32: ByteWidthProviding {} 18 | extension UInt32: ByteWidthProviding {} 19 | extension Int64: ByteWidthProviding {} 20 | extension UInt64: ByteWidthProviding {} 21 | extension Float: ByteWidthProviding {} 22 | extension Double: ByteWidthProviding {} 23 | -------------------------------------------------------------------------------- /PLYIO/Sources/DataConvertible.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DataConvertible { 4 | // MARK: Reading from DataProtocol 5 | 6 | // Assumes that data.count - offset >= byteWidth 7 | init(_ data: D, from offset: D.Index, bigEndian: Bool) 8 | // Assumes that data.count >= byteWidth 9 | init(_ data: D, bigEndian: Bool) 10 | 11 | // Assumes that data.count - offset >= count * byteWidth 12 | static func array(_ data: D, from offset: D.Index, count: Int, bigEndian: Bool) -> [Self] 13 | // Assumes that data.count >= count * byteWidth 14 | static func array(_ data: D, count: Int, bigEndian: Bool) -> [Self] 15 | 16 | // MARK: Writing to MutableDataProtocol 17 | 18 | func append(to data: inout D, bigEndian: Bool) 19 | 20 | static func append(_ values: [Self], to data: inout D, bigEndian: Bool) 21 | } 22 | 23 | fileprivate enum DataConvertibleConstants { 24 | fileprivate static let isBigEndian = 42 == 42.bigEndian 25 | } 26 | 27 | public extension BinaryInteger 28 | where Self: DataConvertible, Self: EndianConvertible { 29 | // MARK: Reading from DataProtocol 30 | 31 | init(_ data: D, from offset: D.Index, bigEndian: Bool) { 32 | var value: Self = .zero 33 | withUnsafeMutableBytes(of: &value) { 34 | let bytesCopied = data.copyBytes(to: $0, from: offset...) 35 | assert(bytesCopied == MemoryLayout.size) 36 | } 37 | self = (bigEndian == DataConvertibleConstants.isBigEndian) ? value : value.byteSwapped 38 | } 39 | 40 | init(_ data: D, bigEndian: Bool) { 41 | var value: Self = .zero 42 | withUnsafeMutableBytes(of: &value) { 43 | let bytesCopied = data.copyBytes(to: $0) 44 | assert(bytesCopied == MemoryLayout.size) 45 | } 46 | self = (bigEndian == DataConvertibleConstants.isBigEndian) ? value : value.byteSwapped 47 | } 48 | 49 | static func array(_ data: D, from offset: D.Index, count: Int, bigEndian: Bool) -> [Self] { 50 | var values: [Self] = Array(repeating: .zero, count: count) 51 | values.withUnsafeMutableBytes { 52 | let bytesCopied = data.copyBytes(to: $0, from: offset...) 53 | assert(bytesCopied == MemoryLayout.size * count) 54 | } 55 | if bigEndian != DataConvertibleConstants.isBigEndian { 56 | for i in 0..(_ data: D, count: Int, bigEndian: Bool) -> [Self] { 64 | var values: [Self] = Array(repeating: .zero, count: count) 65 | values.withUnsafeMutableBytes { 66 | let bytesCopied = data.copyBytes(to: $0) 67 | assert(bytesCopied == MemoryLayout.size * count) 68 | } 69 | if bigEndian != DataConvertibleConstants.isBigEndian { 70 | for i in 0..(to data: inout D, bigEndian: Bool) { 80 | let value = (bigEndian == DataConvertibleConstants.isBigEndian) ? self : byteSwapped 81 | withUnsafeBytes(of: value) { 82 | data.append(contentsOf: $0) 83 | } 84 | } 85 | 86 | static func append(_ values: [Self], to data: inout D, bigEndian: Bool) { 87 | if bigEndian == DataConvertibleConstants.isBigEndian { 88 | withUnsafeBytes(of: values) { 89 | data.append(contentsOf: $0) 90 | } 91 | } else { 92 | for value in values { 93 | let byteSwapped = value.byteSwapped 94 | withUnsafeBytes(of: byteSwapped) { 95 | data.append(contentsOf: $0) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | public extension BinaryFloatingPoint 103 | where Self: DataConvertible, Self: BitPatternConvertible, Self.BitPattern: ZeroProviding, Self.BitPattern: EndianConvertible { 104 | // MARK: Reading from DataProtocol 105 | 106 | init(_ data: D, from offset: D.Index, bigEndian: Bool) { 107 | if bigEndian == DataConvertibleConstants.isBigEndian { 108 | self = .zero 109 | withUnsafeMutableBytes(of: &self) { 110 | let bytesCopied = data.copyBytes(to: $0, from: offset...) 111 | assert(bytesCopied == MemoryLayout.size) 112 | } 113 | } else { 114 | var value: BitPattern = .zero 115 | withUnsafeMutableBytes(of: &value) { 116 | let bytesCopied = data.copyBytes(to: $0, from: offset...) 117 | assert(bytesCopied == MemoryLayout.size) 118 | } 119 | self = Self(bitPattern: value.byteSwapped) 120 | } 121 | } 122 | 123 | init(_ data: D, bigEndian: Bool) { 124 | if bigEndian == DataConvertibleConstants.isBigEndian { 125 | self = .zero 126 | withUnsafeMutableBytes(of: &self) { 127 | let bytesCopied = data.copyBytes(to: $0) 128 | assert(bytesCopied == MemoryLayout.size) 129 | } 130 | } else { 131 | var value: BitPattern = .zero 132 | withUnsafeMutableBytes(of: &value) { 133 | let bytesCopied = data.copyBytes(to: $0) 134 | assert(bytesCopied == MemoryLayout.size) 135 | } 136 | self = Self(bitPattern: value.byteSwapped) 137 | } 138 | } 139 | 140 | static func array(_ data: D, from offset: D.Index, count: Int, bigEndian: Bool) -> [Self] { 141 | var values: [Self] = Array(repeating: .zero, count: count) 142 | values.withUnsafeMutableBytes { 143 | let bytesCopied = data.copyBytes(to: $0, from: offset...) 144 | assert(bytesCopied == MemoryLayout.size * count) 145 | } 146 | if bigEndian != DataConvertibleConstants.isBigEndian { 147 | for i in 0..(_ data: D, count: Int, bigEndian: Bool) -> [Self] { 155 | var values: [Self] = Array(repeating: .zero, count: count) 156 | values.withUnsafeMutableBytes { 157 | let bytesCopied = data.copyBytes(to: $0) 158 | assert(bytesCopied == MemoryLayout.size * count) 159 | } 160 | if bigEndian != DataConvertibleConstants.isBigEndian { 161 | for i in 0..(to data: inout D, bigEndian: Bool) { 171 | let value = (bigEndian == DataConvertibleConstants.isBigEndian) ? bitPattern : bitPattern.byteSwapped 172 | withUnsafeBytes(of: value) { 173 | data.append(contentsOf: $0) 174 | } 175 | } 176 | 177 | static func append(_ values: [Self], to data: inout D, bigEndian: Bool) { 178 | if bigEndian == DataConvertibleConstants.isBigEndian { 179 | withUnsafeBytes(of: values) { 180 | data.append(contentsOf: $0) 181 | } 182 | } else { 183 | for value in values { 184 | let byteSwapped = value.bitPattern.byteSwapped 185 | withUnsafeBytes(of: byteSwapped) { 186 | data.append(contentsOf: $0) 187 | } 188 | } 189 | } 190 | } 191 | } 192 | 193 | extension Int8: DataConvertible {} 194 | extension UInt8: DataConvertible {} 195 | extension Int16: DataConvertible {} 196 | extension UInt16: DataConvertible {} 197 | extension Int32: DataConvertible {} 198 | extension UInt32: DataConvertible, ZeroProviding {} 199 | extension Int64: DataConvertible {} 200 | extension UInt64: DataConvertible, ZeroProviding {} 201 | extension Float: DataConvertible {} 202 | extension Double: DataConvertible {} 203 | -------------------------------------------------------------------------------- /PLYIO/Sources/EndianConvertible.swift: -------------------------------------------------------------------------------- 1 | public protocol EndianConvertible { 2 | var byteSwapped: Self { get } 3 | } 4 | 5 | extension Int8: EndianConvertible {} 6 | extension UInt8: EndianConvertible {} 7 | extension Int16: EndianConvertible {} 8 | extension UInt16: EndianConvertible {} 9 | extension Int32: EndianConvertible {} 10 | extension UInt32: EndianConvertible {} 11 | extension Int64: EndianConvertible {} 12 | extension UInt64: EndianConvertible {} 13 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYElement+ascii.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PLYElement { 4 | public enum ASCIIDecodeError: LocalizedError { 5 | case bodyInvalidStringForPropertyType(PLYHeader.Element, Int, PLYHeader.Property) 6 | case bodyMissingPropertyValuesInElement(PLYHeader.Element, Int, PLYHeader.Property) 7 | case bodyUnexpectedValuesInElement(PLYHeader.Element, Int) 8 | 9 | public var errorDescription: String? { 10 | switch self { 11 | case .bodyInvalidStringForPropertyType(let headerElement, let elementIndex, let headerProperty): 12 | "Invalid type string for property \(headerProperty.name) in element \(headerElement.name), index \(elementIndex)" 13 | case .bodyMissingPropertyValuesInElement(let headerElement, let elementIndex, let headerProperty): 14 | "Missing values for property \(headerProperty.name) in element \(headerElement.name), index \(elementIndex)" 15 | case .bodyUnexpectedValuesInElement(let headerElement, let elementIndex): 16 | "Unexpected values in element \(headerElement.name), index \(elementIndex)" 17 | } 18 | } 19 | } 20 | 21 | // Parse the given element type from the single line from the body of an ASCII PLY file. 22 | // Considers only bytes from offset..<(offset+size) 23 | // May modify the body; after this returns, the body contents are undefined. 24 | mutating func decodeASCII(type elementHeader: PLYHeader.Element, 25 | fromMutable body: UnsafeMutablePointer, 26 | at offset: Int, 27 | bodySize: Int, 28 | elementIndex: Int) throws { 29 | if properties.count != elementHeader.properties.count { 30 | properties = Array(repeating: .uint8(0), count: elementHeader.properties.count) 31 | } 32 | 33 | var stringParser = UnsafeStringParser(data: body, offset: offset, size: bodySize) 34 | 35 | for (i, propertyHeader) in elementHeader.properties.enumerated() { 36 | switch propertyHeader.type { 37 | case .primitive(let primitiveType): 38 | guard let string = stringParser.nextStringSeparatedByWhitespace() else { 39 | throw ASCIIDecodeError.bodyMissingPropertyValuesInElement(elementHeader, elementIndex, propertyHeader) 40 | } 41 | guard let value = Property.tryDecodeASCIIPrimitive(type: primitiveType, from: string) else { 42 | throw ASCIIDecodeError.bodyInvalidStringForPropertyType(elementHeader, elementIndex, propertyHeader) 43 | } 44 | properties[i] = value 45 | case .list(countType: let countType, valueType: let valueType): 46 | guard let countString = stringParser.nextStringSeparatedByWhitespace() else { 47 | throw ASCIIDecodeError.bodyMissingPropertyValuesInElement(elementHeader, elementIndex, propertyHeader) 48 | } 49 | guard let count = Property.tryDecodeASCIIPrimitive(type: countType, from: countString)?.uint64Value else { 50 | throw ASCIIDecodeError.bodyInvalidStringForPropertyType(elementHeader, elementIndex, propertyHeader) 51 | } 52 | 53 | properties[i] = try PLYElement.Property.tryDecodeASCIIList(valueType: valueType, 54 | count: Int(count), 55 | from: &stringParser, 56 | elementHeader: elementHeader, 57 | elementIndex: elementIndex, 58 | propertyHeader: propertyHeader) 59 | } 60 | } 61 | 62 | guard stringParser.nextStringSeparatedByWhitespace() == nil else { 63 | throw ASCIIDecodeError.bodyUnexpectedValuesInElement(elementHeader, elementIndex) 64 | } 65 | } 66 | } 67 | 68 | fileprivate extension PLYElement.Property { 69 | static func tryDecodeASCIIPrimitive(type: PLYHeader.PrimitivePropertyType, 70 | from string: String) -> PLYElement.Property? { 71 | switch type { 72 | case .int8 : if let value = Int8( string) { .int8( value) } else { nil } 73 | case .uint8 : if let value = UInt8( string) { .uint8( value) } else { nil } 74 | case .int16 : if let value = Int16( string) { .int16( value) } else { nil } 75 | case .uint16 : if let value = UInt16(string) { .uint16( value) } else { nil } 76 | case .int32 : if let value = Int32( string) { .int32( value) } else { nil } 77 | case .uint32 : if let value = UInt32(string) { .uint32( value) } else { nil } 78 | case .float32: if let value = Float( string) { .float32(value) } else { nil } 79 | case .float64: if let value = Double(string) { .float64(value) } else { nil } 80 | } 81 | } 82 | 83 | static func tryDecodeASCIIList(valueType: PLYHeader.PrimitivePropertyType, 84 | from strings: [String]) -> PLYElement.Property? { 85 | switch valueType { 86 | case .int8: 87 | let values = strings.compactMap { Int8($0) } 88 | return values.count == strings.count ? .listInt8(values) : nil 89 | case .uint8: 90 | let values = strings.compactMap { UInt8($0) } 91 | return values.count == strings.count ? .listUInt8(values) : nil 92 | case .int16: 93 | let values = strings.compactMap { Int16($0) } 94 | return values.count == strings.count ? .listInt16(values) : nil 95 | case .uint16: 96 | let values = strings.compactMap { UInt16($0) } 97 | return values.count == strings.count ? .listUInt16(values) : nil 98 | case .int32: 99 | let values = strings.compactMap { Int32($0) } 100 | return values.count == strings.count ? .listInt32(values) : nil 101 | case .uint32: 102 | let values = strings.compactMap { UInt32($0) } 103 | return values.count == strings.count ? .listUInt32(values) : nil 104 | case .float32: 105 | let values = strings.compactMap { Float($0) } 106 | return values.count == strings.count ? .listFloat32(values) : nil 107 | case .float64: 108 | let values = strings.compactMap { Double($0) } 109 | return values.count == strings.count ? .listFloat64(values) : nil 110 | } 111 | } 112 | 113 | static func tryDecodeASCIIList(valueType: PLYHeader.PrimitivePropertyType, 114 | count: Int, 115 | from stringParser: inout UnsafeStringParser, 116 | elementHeader: PLYHeader.Element, 117 | elementIndex: Int, 118 | propertyHeader: PLYHeader.Property) throws -> PLYElement.Property { 119 | do { 120 | switch valueType { 121 | case .int8: 122 | return .listInt8(try (0.. 185 | var offset: Int 186 | var size: Int 187 | var currentPosition = 0 188 | 189 | mutating func nextStringSeparatedByWhitespace() -> String? { 190 | var start = currentPosition 191 | var end = start 192 | while true { 193 | if end == size { 194 | guard start < end else { return nil } 195 | let s = String(data: Data(bytes: data + offset + start, count: end - start), encoding: .utf8) 196 | currentPosition = size 197 | return s 198 | } 199 | 200 | let byte = (data + offset + end).pointee 201 | if byte == PLYReader.Constants.space || byte == 0 { 202 | if start == end { 203 | // Starts with a space -- ignore it; strings may be separated by multiple spaces 204 | end += 1 205 | start = end 206 | } else { 207 | // Temporarily make this into a null-terminated string for String()'s benefit 208 | let oldEndValue = (data + offset + end).pointee 209 | (data + offset + end).pointee = 0 210 | let s = String(cString: data + offset + start) 211 | (data + offset + end).pointee = oldEndValue 212 | currentPosition = end+1 213 | return s 214 | } 215 | } else { 216 | end += 1 217 | } 218 | } 219 | } 220 | 221 | mutating func nextElementSeparatedByWhitespace() throws -> T? { 222 | guard let s = nextStringSeparatedByWhitespace() else { return nil } 223 | guard let result = T(s) else { 224 | throw Error.invalidFormat(s) 225 | } 226 | return result 227 | } 228 | 229 | mutating func assumeNextElementSeparatedByWhitespace() throws -> T { 230 | guard let result: T = try nextElementSeparatedByWhitespace() else { 231 | throw Error.unexpectedEndOfData 232 | } 233 | return result 234 | } 235 | } 236 | 237 | fileprivate func min(_ x: T?, _ y: T?) -> T? where T : Comparable { 238 | guard let x else { return y } 239 | guard let y else { return x } 240 | return min(x, y) 241 | } 242 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYElement+binary.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PLYElement { 4 | enum BinaryEncodeError: Swift.Error { 5 | case propertyCountMismatch(expected: PLYHeader.Element, actual: PLYElement) 6 | case typeMismatch(expected: PLYHeader.PropertyType, actual: PLYElement.Property) 7 | case listCountTypeOverflow(PLYHeader.PrimitivePropertyType, actualListCount: Int) 8 | case invalidListCountType(PLYHeader.PrimitivePropertyType) 9 | } 10 | 11 | // Parse the given element type from the next bytes in the body of a binary PLY file. 12 | // If sufficient bytes are available, returns the number of bytes consumed and self contains the element decoded. 13 | // Otherwise returns nil and self is left in an indeterminate state. 14 | mutating func tryDecodeBinary(type elementHeader: PLYHeader.Element, 15 | from body: UnsafeRawPointer, 16 | at offset: Int, 17 | bodySize: Int, 18 | bigEndian: Bool) throws -> Int? { 19 | if properties.count != elementHeader.properties.count { 20 | properties = Array(repeating: .uint8(0), count: elementHeader.properties.count) 21 | } 22 | 23 | var offset = offset 24 | let originalOffset = offset 25 | for (i, propertyHeader) in elementHeader.properties.enumerated() { 26 | let remainingBytes = bodySize - (offset - originalOffset) 27 | switch propertyHeader.type { 28 | case .primitive(let primitiveType): 29 | guard remainingBytes >= primitiveType.byteWidth else { 30 | return nil 31 | } 32 | let value = PLYElement.Property.decodeBinaryPrimitive(type: primitiveType, from: body, at: offset, bigEndian: bigEndian) 33 | properties[i] = value 34 | offset += primitiveType.byteWidth 35 | case .list(countType: let countType, valueType: let valueType): 36 | guard remainingBytes >= countType.byteWidth else { 37 | return nil 38 | } 39 | let count = Int(PLYElement.Property.decodeBinaryPrimitive(type: countType, from: body, at: offset, bigEndian: bigEndian).uint64Value!) 40 | guard remainingBytes >= countType.byteWidth + count * valueType.byteWidth else { 41 | return nil 42 | } 43 | 44 | offset += countType.byteWidth 45 | let value = PLYElement.Property.decodeBinaryList(valueType: valueType, from: body, at: offset, count: count, bigEndian: bigEndian) 46 | properties[i] = value 47 | offset += count * valueType.byteWidth 48 | } 49 | } 50 | 51 | return offset - originalOffset 52 | } 53 | 54 | func encodeBinary(type elementHeader: PLYHeader.Element, 55 | to data: UnsafeMutableRawPointer, 56 | at offset: Int, 57 | bigEndian: Bool) throws -> Int { 58 | guard properties.count == elementHeader.properties.count else { 59 | throw BinaryEncodeError.propertyCountMismatch(expected: elementHeader, actual: self) 60 | } 61 | var sizeSoFar = 0 62 | for (property, propertyType) in zip(properties, elementHeader.properties) { 63 | sizeSoFar += try property.encodeBinary(type: propertyType.type, to: data, at: offset + sizeSoFar, bigEndian: bigEndian) 64 | } 65 | return sizeSoFar 66 | } 67 | 68 | func encodedBinaryByteWidth(type elementHeader: PLYHeader.Element) throws -> Int { 69 | guard properties.count == elementHeader.properties.count else { 70 | throw BinaryEncodeError.propertyCountMismatch(expected: elementHeader, actual: self) 71 | } 72 | var total = 0 73 | for i in 0.. PLYElement.Property { 85 | switch type { 86 | case .int8 : .int8 (Int8 (body, from: offset, bigEndian: bigEndian)) 87 | case .uint8 : .uint8 (UInt8 (body, from: offset, bigEndian: bigEndian)) 88 | case .int16 : .int16 (Int16 (body, from: offset, bigEndian: bigEndian)) 89 | case .uint16 : .uint16 (UInt16(body, from: offset, bigEndian: bigEndian)) 90 | case .int32 : .int32 (Int32 (body, from: offset, bigEndian: bigEndian)) 91 | case .uint32 : .uint32 (UInt32(body, from: offset, bigEndian: bigEndian)) 92 | case .float32: .float32(Float (body, from: offset, bigEndian: bigEndian)) 93 | case .float64: .float64(Double(body, from: offset, bigEndian: bigEndian)) 94 | } 95 | } 96 | 97 | static func decodeBinaryList(valueType: PLYHeader.PrimitivePropertyType, 98 | from body: UnsafeRawPointer, 99 | at offset: Int, 100 | count: Int, 101 | bigEndian: Bool) -> PLYElement.Property { 102 | switch valueType { 103 | case .int8 : .listInt8 (Int8.array (body, from: offset, count: count, bigEndian: bigEndian)) 104 | case .uint8 : .listUInt8 (UInt8.array (body, from: offset, count: count, bigEndian: bigEndian)) 105 | case .int16 : .listInt16 (Int16.array (body, from: offset, count: count, bigEndian: bigEndian)) 106 | case .uint16 : .listUInt16 (UInt16.array(body, from: offset, count: count, bigEndian: bigEndian)) 107 | case .int32 : .listInt32 (Int32.array (body, from: offset, count: count, bigEndian: bigEndian)) 108 | case .uint32 : .listUInt32 (UInt32.array(body, from: offset, count: count, bigEndian: bigEndian)) 109 | case .float32: .listFloat32(Float.array (body, from: offset, count: count, bigEndian: bigEndian)) 110 | case .float64: .listFloat64(Double.array(body, from: offset, count: count, bigEndian: bigEndian)) 111 | } 112 | } 113 | 114 | func encodeBinary(type: PLYHeader.PropertyType, 115 | to data: UnsafeMutableRawPointer, 116 | at offset: Int, 117 | bigEndian: Bool) throws -> Int { 118 | switch type { 119 | case .primitive(let valueType): 120 | try encodeBinaryPrimitive(type: valueType, to: data, at: offset, bigEndian: bigEndian) 121 | case .list(countType: let countType, valueType: let valueType): 122 | try encodeBinaryList(countType: countType, 123 | valueType: valueType, 124 | to: data, 125 | at: offset, 126 | bigEndian: bigEndian) 127 | } 128 | } 129 | 130 | func encodeBinaryPrimitive(type: PLYHeader.PrimitivePropertyType, 131 | to data: UnsafeMutableRawPointer, 132 | at offset: Int, 133 | bigEndian: Bool) throws -> Int { 134 | switch (type, self) { 135 | case (.int8, .int8( let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 136 | case (.uint8, .uint8( let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 137 | case (.int16, .int16( let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 138 | case (.uint16, .uint16( let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 139 | case (.int32, .int32( let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 140 | case (.uint32, .uint32( let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 141 | case (.float32, .float32(let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 142 | case (.float64, .float64(let value )): value.store(to: data, at: offset, bigEndian: bigEndian) 143 | default: throw PLYElement.BinaryEncodeError.typeMismatch(expected: .primitive(type), actual: self) 144 | } 145 | } 146 | 147 | func encodeBinaryList(countType: PLYHeader.PrimitivePropertyType, 148 | valueType: PLYHeader.PrimitivePropertyType, 149 | to data: UnsafeMutableRawPointer, 150 | at offset: Int, 151 | bigEndian: Bool) throws -> Int { 152 | guard let listCount else { 153 | throw PLYElement.BinaryEncodeError.typeMismatch(expected: .list(countType: countType, valueType: valueType), 154 | actual: self) 155 | } 156 | 157 | guard listCount < countType.maxIntValue else { 158 | throw PLYElement.BinaryEncodeError.listCountTypeOverflow(countType, actualListCount: listCount) 159 | } 160 | guard countType.isInteger else { 161 | throw PLYElement.BinaryEncodeError.invalidListCountType(countType) 162 | } 163 | 164 | let countSize: Int 165 | switch countType { 166 | case .int8 : countSize = Int8( listCount).store(to: data, at: offset, bigEndian: bigEndian) 167 | case .uint8 : countSize = UInt8( listCount).store(to: data, at: offset, bigEndian: bigEndian) 168 | case .int16 : countSize = Int16( listCount).store(to: data, at: offset, bigEndian: bigEndian) 169 | case .uint16: countSize = UInt16(listCount).store(to: data, at: offset, bigEndian: bigEndian) 170 | case .int32 : countSize = Int32( listCount).store(to: data, at: offset, bigEndian: bigEndian) 171 | case .uint32: countSize = UInt32(listCount).store(to: data, at: offset, bigEndian: bigEndian) 172 | case .float32, .float64: 173 | fatalError("Internal error: unhandled list count type during encode: \(countType)") 174 | } 175 | 176 | let offset = offset + countSize 177 | 178 | let valuesSize: Int 179 | switch (valueType, self) { 180 | case (.int8, .listInt8( let values)): valuesSize = Int8 .store(values, to: data, at: offset, bigEndian: bigEndian) 181 | case (.uint8, .listUInt8( let values)): valuesSize = UInt8 .store(values, to: data, at: offset, bigEndian: bigEndian) 182 | case (.int16, .listInt16( let values)): valuesSize = Int16 .store(values, to: data, at: offset, bigEndian: bigEndian) 183 | case (.uint16, .listUInt16( let values)): valuesSize = UInt16 .store(values, to: data, at: offset, bigEndian: bigEndian) 184 | case (.int32, .listInt32( let values)): valuesSize = Int32 .store(values, to: data, at: offset, bigEndian: bigEndian) 185 | case (.uint32, .listUInt32( let values)): valuesSize = UInt32 .store(values, to: data, at: offset, bigEndian: bigEndian) 186 | case (.float32, .listFloat32(let values)): valuesSize = Float32.store(values, to: data, at: offset, bigEndian: bigEndian) 187 | case (.float64, .listFloat64(let values)): valuesSize = Float64.store(values, to: data, at: offset, bigEndian: bigEndian) 188 | default: 189 | throw PLYElement.BinaryEncodeError.typeMismatch(expected: .list(countType: countType, valueType: valueType), 190 | actual: self) 191 | } 192 | 193 | return countSize + valuesSize 194 | } 195 | 196 | func encodedBinaryByteWidth(type: PLYHeader.PropertyType) -> Int { 197 | switch (type, self) { 198 | case (.primitive(let primitiveType), _): 199 | primitiveType.byteWidth 200 | case (.list(countType: let countType, valueType: let valueType), _): 201 | countType.byteWidth + (listCount ?? 0) * valueType.byteWidth 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYElement.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct PLYElement { 4 | public enum Property { 5 | case int8(Int8) 6 | case uint8(UInt8) 7 | case int16(Int16) 8 | case uint16(UInt16) 9 | case int32(Int32) 10 | case uint32(UInt32) 11 | case float32(Float) 12 | case float64(Double) 13 | case listInt8([Int8]) 14 | case listUInt8([UInt8]) 15 | case listInt16([Int16]) 16 | case listUInt16([UInt16]) 17 | case listInt32([Int32]) 18 | case listUInt32([UInt32]) 19 | case listFloat32([Float]) 20 | case listFloat64([Double]) 21 | 22 | var uint64Value: UInt64? { 23 | switch self { 24 | case .int8( let value): UInt64(value) 25 | case .uint8( let value): UInt64(value) 26 | case .int16( let value): UInt64(value) 27 | case .uint16(let value): UInt64(value) 28 | case .int32( let value): UInt64(value) 29 | case .uint32(let value): UInt64(value) 30 | case .float32, .float64, .listInt8, .listUInt8, .listInt16, .listUInt16, .listInt32, .listUInt32, .listFloat32, .listFloat64: nil 31 | } 32 | } 33 | 34 | var listCount: Int? { 35 | switch self { 36 | case .listInt8( let values): values.count 37 | case .listUInt8( let values): values.count 38 | case .listInt16( let values): values.count 39 | case .listUInt16( let values): values.count 40 | case .listInt32( let values): values.count 41 | case .listUInt32( let values): values.count 42 | case .listFloat32(let values): values.count 43 | case .listFloat64(let values): values.count 44 | case .int8, .uint8, .int16, .uint16, .int32, .uint32, .float32, .float64: 45 | nil 46 | } 47 | } 48 | } 49 | 50 | public var properties: [Property] 51 | 52 | public init(properties: [Property]) { 53 | self.properties = properties 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYHeader+ascii.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PLYHeader { 4 | public enum ASCIIDecodeError: LocalizedError { 5 | case headerFormatMissing 6 | case headerInvalidCharacters 7 | case headerUnknownKeyword(String) 8 | case headerUnexpectedKeyword(String) 9 | case headerInvalidLine(String) 10 | case headerInvalidFileFormatType(String) 11 | case headerUnknownPropertyType(String) 12 | case headerInvalidListCountType(String) 13 | 14 | public var errorDescription: String? { 15 | switch self { 16 | case .headerFormatMissing: 17 | "Header format missing" 18 | case .headerInvalidCharacters: 19 | "Invalid characters in header" 20 | case .headerUnknownKeyword(let keyword): 21 | "Unknown keyword in header: \"\(keyword)\"" 22 | case .headerUnexpectedKeyword(let keyword): 23 | "Unexpected keyword in header: \"\(keyword)\"" 24 | case .headerInvalidLine(let line): 25 | "Invalid line in header: \"\(line)\"" 26 | case .headerInvalidFileFormatType(let type): 27 | "Invalid file format type in header: \(type)" 28 | case .headerUnknownPropertyType(let type): 29 | "Unknown property type: \(type)" 30 | case .headerInvalidListCountType(let type): 31 | "Invalid list count type: \(type)" 32 | } 33 | } 34 | } 35 | 36 | static func decodeASCII(from headerData: Data) throws -> PLYHeader { 37 | guard let headerString = String(data: headerData, encoding: .utf8) else { 38 | throw ASCIIDecodeError.headerInvalidCharacters 39 | } 40 | var parseError: Swift.Error? 41 | var header: PLYHeader? 42 | headerString.enumerateLines { (headerLine, stop: inout Bool) in 43 | do { 44 | guard let keywordString = headerLine.components(separatedBy: .whitespaces).filter({ !$0.isEmpty }).first else { 45 | return 46 | } 47 | guard let keyword = PLYHeader.Keyword(rawValue: keywordString) else { 48 | throw ASCIIDecodeError.headerUnknownKeyword(keywordString) 49 | } 50 | switch keyword { 51 | case .ply, .comment, .obj_info: 52 | return 53 | case .format: 54 | guard header == nil else { 55 | throw ASCIIDecodeError.headerUnexpectedKeyword(keyword.rawValue) 56 | } 57 | let regex = #/\s*format\s+(?\w+?)\s+(?\S+?)/# 58 | guard let match = try regex.wholeMatch(in: headerLine) else { 59 | throw ASCIIDecodeError.headerInvalidLine(headerLine) 60 | } 61 | guard let format = PLYHeader.Format(rawValue: String(match.format)) else { 62 | throw ASCIIDecodeError.headerInvalidFileFormatType(String(match.format)) 63 | } 64 | header = PLYHeader(format: format, version: String(match.version), elements: []) 65 | case .element: 66 | guard header != nil else { 67 | throw ASCIIDecodeError.headerUnexpectedKeyword(keyword.rawValue) 68 | } 69 | let regex = #/\s*element\s+(?\S+?)\s+(?\d+?)/# 70 | guard let match = try regex.wholeMatch(in: headerLine) else { 71 | throw ASCIIDecodeError.headerInvalidLine(headerLine) 72 | } 73 | header?.elements.append(PLYHeader.Element(name: String(match.name), 74 | count: UInt32(match.count)!, 75 | properties: [])) 76 | case .property: 77 | guard header != nil, header?.elements.isEmpty == false else { 78 | throw ASCIIDecodeError.headerUnexpectedKeyword(keyword.rawValue) 79 | } 80 | let listRegex = #/\s*property\s+list\s+(?\w+?)\s+(?\w+?)\s+(?\S+)/# 81 | let nonListRegex = #/\s*property\s+(?\w+?)\s+(?\S+)/# 82 | if let match = try listRegex.wholeMatch(in: headerLine) { 83 | guard let countType = PLYHeader.PrimitivePropertyType.fromString(String(match.countType)) else { 84 | throw ASCIIDecodeError.headerUnknownPropertyType(String(match.countType)) 85 | } 86 | guard countType.isInteger else { 87 | throw ASCIIDecodeError.headerInvalidListCountType(String(match.countType)) 88 | } 89 | guard let valueType = PLYHeader.PrimitivePropertyType.fromString(String(match.valueType)) else { 90 | throw ASCIIDecodeError.headerUnknownPropertyType(String(match.valueType)) 91 | } 92 | let property = PLYHeader.Property(name: String(match.name), 93 | type: .list(countType: countType, valueType: valueType)) 94 | header!.elements[header!.elements.count-1].properties.append(property) 95 | } else if let match = try nonListRegex.wholeMatch(in: headerLine) { 96 | guard let valueType = PLYHeader.PrimitivePropertyType.fromString(String(match.valueType)) else { 97 | throw ASCIIDecodeError.headerUnknownPropertyType(String(match.valueType)) 98 | } 99 | let property = PLYHeader.Property(name: String(match.name), 100 | type: .primitive(valueType)) 101 | header!.elements[header!.elements.count-1].properties.append(property) 102 | } else { 103 | throw ASCIIDecodeError.headerInvalidLine(headerLine) 104 | } 105 | case .endHeader: 106 | stop = true 107 | } 108 | } catch { 109 | parseError = error 110 | stop = true 111 | } 112 | } 113 | 114 | if let parseError { 115 | throw parseError 116 | } 117 | 118 | guard let header else { 119 | throw ASCIIDecodeError.headerFormatMissing 120 | } 121 | 122 | return header 123 | } 124 | 125 | } 126 | 127 | extension PLYHeader: CustomStringConvertible { 128 | public var description: String { 129 | "ply\n" + 130 | "format \(format.rawValue) \(version)\n" + 131 | elements.map(\.description).reduce("", +) 132 | } 133 | } 134 | 135 | extension PLYHeader.Element: CustomStringConvertible { 136 | public var description: String { 137 | "element \(name) \(count)\n" + 138 | properties.map(\.description).reduce("", +) 139 | } 140 | } 141 | 142 | extension PLYHeader.Property: CustomStringConvertible { 143 | public var description: String { 144 | "property \(type) \(name)\n" 145 | } 146 | } 147 | 148 | extension PLYHeader.PropertyType: CustomStringConvertible { 149 | public var description: String { 150 | switch self { 151 | case .primitive(let primitiveType): primitiveType.description 152 | case .list(let countType, let valueType): "list \(countType) \(valueType)" 153 | } 154 | } 155 | } 156 | 157 | extension PLYHeader.PrimitivePropertyType: CustomStringConvertible { 158 | static func fromString(_ string: String) -> PLYHeader.PrimitivePropertyType? { 159 | switch string { 160 | case "int8", "char" : .int8 161 | case "uint8", "uchar" : .uint8 162 | case "int16", "short" : .int16 163 | case "uint16", "ushort": .uint16 164 | case "int32", "int" : .int32 165 | case "uint32", "uint" : .uint32 166 | case "float32", "float" : .float32 167 | case "float64", "double": .float64 168 | default: nil 169 | } 170 | } 171 | 172 | public var description: String { 173 | switch self { 174 | case .int8: "char" 175 | case .uint8: "uchar" 176 | case .int16: "short" 177 | case .uint16: "ushort" 178 | case .int32: "int" 179 | case .uint32: "uint" 180 | case .float32: "float" 181 | case .float64: "double" 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYHeader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct PLYHeader: Equatable { 4 | enum Keyword: String { 5 | case ply = "ply" 6 | case format = "format" 7 | case comment = "comment" 8 | case element = "element" 9 | case property = "property" 10 | case endHeader = "end_header" 11 | case obj_info = "obj_info" 12 | } 13 | 14 | public enum Format: String, Equatable { 15 | case ascii 16 | case binaryLittleEndian = "binary_little_endian" 17 | case binaryBigEndian = "binary_big_endian" 18 | } 19 | 20 | public struct Element: Equatable { 21 | public var name: String 22 | public var count: UInt32 23 | public var properties: [Property] 24 | 25 | public init(name: String, count: UInt32, properties: [Property]) { 26 | self.name = name 27 | self.count = count 28 | self.properties = properties 29 | } 30 | 31 | public func index(forPropertyNamed name: String) -> Int? { 32 | properties.firstIndex { $0.name == name } 33 | } 34 | } 35 | 36 | public enum PropertyType: Equatable { 37 | case primitive(PrimitivePropertyType) 38 | case list(countType: PrimitivePropertyType, valueType: PrimitivePropertyType) 39 | } 40 | 41 | public enum PrimitivePropertyType: Equatable { 42 | case int8 // aka char 43 | case uint8 // aka uchar 44 | case int16 // aka short 45 | case uint16 // aka ushort 46 | case int32 // aka int 47 | case uint32 // aka uint 48 | case float32 // aka float 49 | case float64 // aka double 50 | 51 | var isInteger: Bool { 52 | switch self { 53 | case .int8, .uint8, .int16, .uint16, .int32, .uint32: true 54 | case .float32, .float64: false 55 | } 56 | } 57 | 58 | var byteWidth: Int { 59 | switch self { 60 | case .int8: Int8.byteWidth 61 | case .uint8: UInt8.byteWidth 62 | case .int16: Int16.byteWidth 63 | case .uint16: UInt16.byteWidth 64 | case .int32: Int32.byteWidth 65 | case .uint32: UInt32.byteWidth 66 | case .float32: Float.byteWidth 67 | case .float64: Double.byteWidth 68 | } 69 | } 70 | 71 | var maxIntValue: UInt32 { 72 | switch self { 73 | case .int8 : UInt32(Int8.max ) 74 | case .uint8 : UInt32(UInt8.max ) 75 | case .int16 : UInt32(Int16.max ) 76 | case .uint16 : UInt32(UInt16.max) 77 | case .int32 : UInt32(Int32.max ) 78 | case .uint32 : UInt32(UInt32.max) 79 | case .float32: 0 80 | case .float64: 0 81 | } 82 | } 83 | } 84 | 85 | public struct Property: Equatable { 86 | public var name: String 87 | public var type: PropertyType 88 | 89 | public init(name: String, type: PropertyType) { 90 | self.name = name 91 | self.type = type 92 | } 93 | } 94 | 95 | public var format: Format 96 | public var version: String 97 | public var elements: [Element] 98 | 99 | public init(format: Format, version: String, elements: [Element]) { 100 | self.format = format 101 | self.version = version 102 | self.elements = elements 103 | } 104 | 105 | public func index(forElementNamed name: String) -> Int? { 106 | elements.firstIndex { $0.name == name } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYReader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol PLYReaderDelegate { 4 | func didStartReading(withHeader header: PLYHeader) 5 | func didRead(element: PLYElement, typeIndex: Int, withHeader elementHeader: PLYHeader.Element) 6 | func didFinishReading() 7 | func didFailReading(withError error: Swift.Error?) 8 | } 9 | 10 | public class PLYReader { 11 | public enum Error: LocalizedError { 12 | case cannotOpenSource(URL) 13 | case readError 14 | case headerStartMissing 15 | case headerEndMissing 16 | case unexpectedEndOfFile 17 | case internalConsistency 18 | 19 | public var errorDescription: String? { 20 | switch self { 21 | case .cannotOpenSource(let url): 22 | "Cannot open source file at \(url)" 23 | case .readError: 24 | "Error while reading input" 25 | case .headerStartMissing: 26 | "Header start missing" 27 | case .headerEndMissing: 28 | "Header end missing" 29 | case .unexpectedEndOfFile: 30 | "Unexpected end-of-file while reading input" 31 | case .internalConsistency: 32 | "Internal error in PLYReader" 33 | } 34 | } 35 | } 36 | 37 | enum Constants { 38 | static let headerStartToken = "\(PLYHeader.Keyword.ply.rawValue)\n".data(using: .utf8)! 39 | static let headerEndToken = "\(PLYHeader.Keyword.endHeader.rawValue)\n".data(using: .utf8)! 40 | // Hold up to 16k of data at once before reclaiming. Higher numbers will use more data, but lower numbers will result in more frequent, somewhat expensive "move bytes" operations. 41 | static let bodySizeForReclaim = 16*1024 42 | 43 | static let cr = UInt8(ascii: "\r") 44 | static let lf = UInt8(ascii: "\n") 45 | static let space = UInt8(ascii: " ") 46 | } 47 | 48 | private enum Phase { 49 | case unstarted 50 | case header 51 | case body 52 | } 53 | 54 | private var inputStream: InputStream 55 | private var header: PLYHeader? = nil 56 | private var body = Data() 57 | private var bodyOffset: Int = 0 58 | private var currentElementGroup: Int = 0 59 | private var currentElementCountInGroup: Int = 0 60 | private var reusableElement = PLYElement(properties: []) 61 | 62 | public init(_ inputStream: InputStream) { 63 | self.inputStream = inputStream 64 | } 65 | 66 | public convenience init(_ url: URL) throws { 67 | guard let inputStream = InputStream(url: url) else { 68 | throw Error.cannotOpenSource(url) 69 | } 70 | self.init(inputStream) 71 | } 72 | 73 | public func read(to delegate: PLYReaderDelegate) { 74 | header = nil 75 | body = Data() 76 | bodyOffset = 0 77 | currentElementGroup = 0 78 | currentElementCountInGroup = 0 79 | 80 | let bufferSize = 8*1024 81 | let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) 82 | defer { buffer.deallocate() } 83 | var headerData = Data() 84 | 85 | inputStream.open() 86 | defer { inputStream.close() } 87 | 88 | var phase: Phase = .unstarted 89 | 90 | while true { 91 | let readResult = inputStream.read(buffer, maxLength: bufferSize) 92 | let bytesRead: Int 93 | switch readResult { 94 | case -1: 95 | delegate.didFailReading(withError: Error.readError) 96 | return 97 | case 0: 98 | switch phase { 99 | case .unstarted, .header: 100 | break 101 | case .body: 102 | // Reprocess the remaining data, now with isEOF = true, since that might mean a successful completion (e.g. ASCII data missing a final EOL) 103 | do { 104 | try processBody(delegate: delegate, isEOF: true) 105 | } catch { 106 | delegate.didFailReading(withError: error) 107 | return 108 | } 109 | if isComplete { 110 | delegate.didFinishReading() 111 | return 112 | } 113 | } 114 | delegate.didFailReading(withError: Error.unexpectedEndOfFile) 115 | return 116 | default: 117 | bytesRead = readResult 118 | } 119 | 120 | var bufferIndex = 0 121 | while bufferIndex < bytesRead { 122 | switch phase { 123 | case .unstarted: 124 | headerData.append(buffer[bufferIndex]) 125 | bufferIndex += 1 126 | if headerData.count == Constants.headerStartToken.count { 127 | if headerData == Constants.headerStartToken { 128 | // Found header start token. Continue to read actual header 129 | phase = .header 130 | } else { 131 | // Beginning of stream didn't match headerStartToken; fail 132 | delegate.didFailReading(withError: Error.headerStartMissing) 133 | return 134 | } 135 | } 136 | case .header: 137 | headerData.append(buffer[bufferIndex]) 138 | bufferIndex += 1 139 | if headerData.hasSuffix(Constants.headerEndToken) { 140 | do { 141 | let header = try PLYHeader.decodeASCII(from: headerData) 142 | self.header = header 143 | phase = .body 144 | delegate.didStartReading(withHeader: header) 145 | } catch { 146 | delegate.didFailReading(withError: error) 147 | return 148 | } 149 | } 150 | case .body: 151 | if bufferIndex == 0 { 152 | body.append(buffer, count: bytesRead) 153 | } else if bufferIndex < bytesRead { 154 | body.append(Data(bytes: buffer, count: bytesRead)[bufferIndex.. 0 && body.count >= Constants.bodySizeForReclaim else { return } 183 | body.removeSubrange(0.. Bool { 275 | count >= data.count && self[(count - data.count)...] == data 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /PLYIO/Sources/PLYWriter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | public class PLYWriter { 5 | private enum Constants { 6 | static let defaultBufferSize = 64*1024 7 | } 8 | 9 | enum Error: Swift.Error { 10 | case headerAlreadyWritten 11 | case headerNotYetWritten 12 | case cannotWriteAfterClose 13 | case unexpectedElement 14 | case unknownOutputStreamError 15 | case outputStreamFull 16 | } 17 | 18 | private static let log = Logger() 19 | 20 | private let outputStream: OutputStream 21 | private var buffer: UnsafeMutableRawPointer? 22 | private var bufferSize: Int 23 | private var header: PLYHeader? 24 | 25 | private var ascii = false 26 | private var bigEndian = false 27 | 28 | private var currentElementGroupIndex = 0 29 | private var currentElementCountInGroup = 0 30 | 31 | public init(_ outputStream: OutputStream) { 32 | self.outputStream = outputStream 33 | outputStream.open() 34 | bufferSize = Constants.defaultBufferSize 35 | buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 8) 36 | } 37 | 38 | public convenience init?(toFileAtPath path: String, append: Bool) { 39 | guard let outputStream = OutputStream(toFileAtPath: path, append: append) else { 40 | return nil 41 | } 42 | self.init(outputStream) 43 | } 44 | 45 | deinit { 46 | try? close() 47 | } 48 | 49 | public func close() throws { 50 | outputStream.close() 51 | 52 | buffer?.deallocate() 53 | buffer = nil 54 | 55 | guard let header else { 56 | throw Error.headerNotYetWritten 57 | } 58 | if currentElementGroupIndex < header.elements.count { 59 | Self.log.error("PLYWriter stream closed before all elements have been written") 60 | } 61 | } 62 | 63 | /// write(_:PLYHeader, elementCount: Int) must be callen exactly once before zero or more calls to write(_:[PLYElement]). Once called, this method should not be called again on the same PLYWriter. 64 | public func write(_ header: PLYHeader) throws { 65 | if self.header != nil { 66 | throw Error.headerAlreadyWritten 67 | } 68 | 69 | self.header = header 70 | 71 | outputStream.write("\(header.description)") 72 | outputStream.write("\(PLYHeader.Keyword.endHeader.rawValue)\n") 73 | 74 | switch header.format { 75 | case .ascii: 76 | self.ascii = true 77 | case .binaryBigEndian: 78 | self.ascii = false 79 | self.bigEndian = true 80 | case .binaryLittleEndian: 81 | self.ascii = false 82 | self.bigEndian = false 83 | } 84 | } 85 | 86 | /// write(_:PLYHeader, elementCount: Int) must be callen exactly once before any calls to write(_:[PLYElement]). This method may be called multiple times, until all have been supplied, after which close() should be called exactly once. 87 | public func write(_ elements: [PLYElement], count: Int? = nil) throws { 88 | guard let header else { 89 | throw Error.headerNotYetWritten 90 | } 91 | 92 | var remainingElements: [PLYElement] 93 | if let count { 94 | guard count > 0 else { return } 95 | remainingElements = Array(elements[0.. remainingBufferCapacity { 119 | // Not enough room in the buffer; make room 120 | try dumpBuffer(length: bufferOffset) 121 | bufferOffset = 0 122 | } 123 | if elementByteWidth > bufferSize { 124 | assert(bufferOffset == 0) 125 | // The buffer's empty and just not big enough. Expand it. 126 | if bufferOffset == 0 { 127 | buffer?.deallocate() 128 | bufferSize = elementByteWidth 129 | buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 8) 130 | } 131 | } 132 | 133 | bufferOffset += try element.encodeBinary(type: elementHeader, 134 | to: buffer!, 135 | at: bufferOffset, 136 | bigEndian: bigEndian) 137 | } 138 | 139 | try dumpBuffer(length: bufferOffset) 140 | } 141 | 142 | remainingElements = Array(remainingElements.dropFirst(countInGroup)) 143 | 144 | currentElementCountInGroup += countInGroup 145 | while (currentElementGroupIndex < header.elements.count) && 146 | (currentElementCountInGroup == header.elements[currentElementGroupIndex].count) { 147 | currentElementGroupIndex += 1 148 | currentElementCountInGroup = 0 149 | } 150 | } 151 | } 152 | 153 | private func dumpBuffer(length: Int) throws { 154 | guard length > 0 else { 155 | return 156 | } 157 | 158 | switch outputStream.write(buffer!, maxLength: length) { 159 | case length: 160 | return 161 | case 0: 162 | throw Error.outputStreamFull 163 | case -1: 164 | fallthrough 165 | default: 166 | if let error = outputStream.streamError { 167 | throw error 168 | } else { 169 | throw Error.unknownOutputStreamError 170 | } 171 | } 172 | } 173 | 174 | public func write(_ element: PLYElement) throws { 175 | try write([ element ]) 176 | } 177 | } 178 | 179 | fileprivate extension OutputStream { 180 | @discardableResult 181 | func write(_ data: Data) -> Int { 182 | data.withUnsafeBytes { 183 | if let pointer = $0.baseAddress?.assumingMemoryBound(to: UInt8.self) { 184 | return write(pointer, maxLength: data.count) 185 | } else { 186 | return 0 187 | } 188 | } 189 | } 190 | 191 | @discardableResult 192 | func write(_ string: String) -> Int { 193 | write(string.data(using: .utf8)!) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /PLYIO/Sources/UnsafeRawPointerConvertible.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol UnsafeRawPointerConvertible { 4 | // MARK: Reading from UnsafeRawPointer 5 | 6 | // Assumes that data size - offset >= byteWidth 7 | init(_ data: UnsafeRawPointer, from offset: Int, bigEndian: Bool) 8 | // Assumes that data size >= byteWidth 9 | init(_ data: UnsafeRawPointer, bigEndian: Bool) 10 | 11 | // Assumes that data size - offset >= count * byteWidth 12 | static func array(_ data: UnsafeRawPointer, from offset: Int, count: Int, bigEndian: Bool) -> [Self] 13 | // Assumes that data size >= count * byteWidth 14 | static func array(_ data: UnsafeRawPointer, count: Int, bigEndian: Bool) -> [Self] 15 | 16 | // MARK: Writing to UnsafeMutableRawPointer 17 | 18 | // Assumes that data size - offset >= byteWidth 19 | // Returns number of bytes stored 20 | @discardableResult 21 | func store(to data: UnsafeMutableRawPointer, at offset: Int, bigEndian: Bool) -> Int 22 | // Assumes that data size >= byteWidth 23 | // Returns number of bytes stored 24 | @discardableResult 25 | func store(to data: UnsafeMutableRawPointer, bigEndian: Bool) -> Int 26 | 27 | // Assumes that data size - offset >= count * byteWidth 28 | // Returns number of bytes stored 29 | @discardableResult 30 | static func store(_ values: [Self], to data: UnsafeMutableRawPointer, at offset: Int, bigEndian: Bool) -> Int 31 | // Assumes that data size >= count * byteWidth 32 | // Returns number of bytes stored 33 | @discardableResult 34 | static func store(_ values: [Self], to data: UnsafeMutableRawPointer, bigEndian: Bool) -> Int 35 | } 36 | 37 | fileprivate enum UnsafeRawPointerConvertibleConstants { 38 | fileprivate static let isBigEndian = 42 == 42.bigEndian 39 | } 40 | 41 | public extension BinaryInteger where Self: UnsafeRawPointerConvertible, Self: EndianConvertible { 42 | // MARK: Reading from UnsafeRawPointer 43 | 44 | init(_ data: UnsafeRawPointer, from offset: Int, bigEndian: Bool) { 45 | let value = (data + offset).loadUnaligned(as: Self.self) 46 | self = (bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian) ? value : value.byteSwapped 47 | } 48 | 49 | init(_ data: UnsafeRawPointer, bigEndian: Bool) { 50 | let value = data.loadUnaligned(as: Self.self) 51 | self = (bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian) ? value : value.byteSwapped 52 | } 53 | 54 | static func array(_ data: UnsafeRawPointer, from offset: Int, count: Int, bigEndian: Bool) -> [Self] { 55 | let size = MemoryLayout.size 56 | var values: [Self] = Array(repeating: .zero, count: count) 57 | if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 58 | for i in 0.. [Self] { 70 | array(data, from: 0, count: count, bigEndian: bigEndian) 71 | } 72 | 73 | // MARK: Writing to UnsafeMutableRawPointer 74 | 75 | @discardableResult 76 | func store(to data: UnsafeMutableRawPointer, at offset: Int, bigEndian: Bool) -> Int { 77 | let value = (bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian) ? self : byteSwapped 78 | data.storeBytes(of: value, toByteOffset: offset, as: Self.self) 79 | return Self.byteWidth 80 | } 81 | 82 | @discardableResult 83 | func store(to data: UnsafeMutableRawPointer, bigEndian: Bool) -> Int { 84 | let value = (bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian) ? self : byteSwapped 85 | data.storeBytes(of: value, as: Self.self) 86 | return Self.byteWidth 87 | } 88 | 89 | @discardableResult 90 | static func store(_ values: [Self], to data: UnsafeMutableRawPointer, at offset: Int, bigEndian: Bool) -> Int { 91 | if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 92 | values.withUnsafeBytes { 93 | (data + offset).copyMemory(from: $0.baseAddress!, byteCount: values.count * byteWidth) 94 | } 95 | } else { 96 | for (index, value) in values.enumerated() { 97 | let byteSwapped = value.byteSwapped 98 | data.storeBytes(of: byteSwapped, toByteOffset: offset + index * byteWidth, as: Self.self) 99 | } 100 | } 101 | return values.count * byteWidth 102 | } 103 | 104 | @discardableResult 105 | static func store(_ values: [Self], to data: UnsafeMutableRawPointer, bigEndian: Bool) -> Int { 106 | if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 107 | values.withUnsafeBytes { 108 | data.copyMemory(from: $0.baseAddress!, byteCount: values.count * byteWidth) 109 | } 110 | } else { 111 | for (index, value) in values.enumerated() { 112 | let byteSwapped = value.byteSwapped 113 | data.storeBytes(of: byteSwapped, toByteOffset: index * byteWidth, as: Self.self) 114 | } 115 | } 116 | return values.count * byteWidth 117 | } 118 | } 119 | 120 | public extension BinaryFloatingPoint where Self: UnsafeRawPointerConvertible, Self: BitPatternConvertible, Self.BitPattern: EndianConvertible { 121 | // MARK: Reading from UnsafeRawPointer 122 | 123 | init(_ data: UnsafeRawPointer, from offset: Int, bigEndian: Bool) { 124 | self = if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 125 | (data + offset).loadUnaligned(as: Self.self) 126 | } else { 127 | Self(bitPattern: (data + offset).loadUnaligned(as: BitPattern.self).byteSwapped) 128 | } 129 | } 130 | 131 | init(_ data: UnsafeRawPointer, bigEndian: Bool) { 132 | self = if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 133 | data.loadUnaligned(as: Self.self) 134 | } else { 135 | Self(bitPattern: data.loadUnaligned(as: BitPattern.self).byteSwapped) 136 | } 137 | } 138 | 139 | static func array(_ data: UnsafeRawPointer, from offset: Int, count: Int, bigEndian: Bool) -> [Self] { 140 | let size = MemoryLayout.size 141 | var values: [Self] = Array(repeating: .zero, count: count) 142 | if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 143 | for i in 0.. [Self] { 155 | array(data, from: 0, count: count, bigEndian: bigEndian) 156 | } 157 | 158 | // MARK: Writing to UnsafeMutableRawPointer 159 | 160 | @discardableResult 161 | func store(to data: UnsafeMutableRawPointer, at offset: Int, bigEndian: Bool) -> Int { 162 | let value = (bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian) ? bitPattern : bitPattern.byteSwapped 163 | data.storeBytes(of: value, toByteOffset: offset, as: BitPattern.self) 164 | return Self.byteWidth 165 | } 166 | 167 | @discardableResult 168 | func store(to data: UnsafeMutableRawPointer, bigEndian: Bool) -> Int { 169 | let value = (bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian) ? bitPattern : bitPattern.byteSwapped 170 | data.storeBytes(of: value, as: BitPattern.self) 171 | return Self.byteWidth 172 | } 173 | 174 | @discardableResult 175 | static func store(_ values: [Self], to data: UnsafeMutableRawPointer, at offset: Int, bigEndian: Bool) -> Int { 176 | if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 177 | values.withUnsafeBytes { 178 | (data + offset).copyMemory(from: $0.baseAddress!, byteCount: values.count * byteWidth) 179 | } 180 | } else { 181 | for (index, value) in values.enumerated() { 182 | let byteSwapped = value.bitPattern.byteSwapped 183 | data.storeBytes(of: byteSwapped, toByteOffset: offset + index * byteWidth, as: BitPattern.self) 184 | } 185 | } 186 | return values.count * byteWidth 187 | } 188 | 189 | @discardableResult 190 | static func store(_ values: [Self], to data: UnsafeMutableRawPointer, bigEndian: Bool) -> Int { 191 | if bigEndian == UnsafeRawPointerConvertibleConstants.isBigEndian { 192 | values.withUnsafeBytes { 193 | data.copyMemory(from: $0.baseAddress!, byteCount: values.count * byteWidth) 194 | } 195 | } else { 196 | for (index, value) in values.enumerated() { 197 | let byteSwapped = value.bitPattern.byteSwapped 198 | data.storeBytes(of: byteSwapped, toByteOffset: index * byteWidth, as: BitPattern.self) 199 | } 200 | } 201 | return values.count * byteWidth 202 | } 203 | } 204 | 205 | extension Int8: UnsafeRawPointerConvertible {} 206 | extension UInt8: UnsafeRawPointerConvertible {} 207 | extension Int16: UnsafeRawPointerConvertible {} 208 | extension UInt16: UnsafeRawPointerConvertible {} 209 | extension Int32: UnsafeRawPointerConvertible {} 210 | extension UInt32: UnsafeRawPointerConvertible {} 211 | extension Int64: UnsafeRawPointerConvertible {} 212 | extension UInt64: UnsafeRawPointerConvertible {} 213 | extension Float: UnsafeRawPointerConvertible {} 214 | extension Double: UnsafeRawPointerConvertible {} 215 | -------------------------------------------------------------------------------- /PLYIO/Sources/ZeroProviding.swift: -------------------------------------------------------------------------------- 1 | public protocol ZeroProviding { 2 | static var zero: Self { get } 3 | } 4 | -------------------------------------------------------------------------------- /PLYIO/TestData/beetle.binary.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scier/MetalSplatter/ec6d0f2cdee958c9617925eda474362869cbf3de/PLYIO/TestData/beetle.binary.ply -------------------------------------------------------------------------------- /PLYIO/Tests/DataConvertibleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PLYIO 3 | 4 | final class DataConvertibleTests: XCTestCase { 5 | static let floatValue: Float = 42.17 6 | static var floatValueLittleEndianData = Data([ 0x14, 0xae, 0x28, 0x42 ]) 7 | static var floatValueBigEndianData = Data([ 0x42, 0x28, 0xae, 0x14 ]) 8 | static let floatValuesCount = 1024 9 | static let floatValuesLittleEndianData = (0.. Bool { 177 | switch (lhs, rhs) { 178 | case let (.int8(lhsValue), .int8(rhsValue)): lhsValue == rhsValue 179 | case let (.uint8(lhsValue), .uint8(rhsValue)): lhsValue == rhsValue 180 | case let (.int16(lhsValue), .int16(rhsValue)): lhsValue == rhsValue 181 | case let (.uint16(lhsValue), .uint16(rhsValue)): lhsValue == rhsValue 182 | case let (.int32(lhsValue), .int32(rhsValue)): lhsValue == rhsValue 183 | case let (.uint32(lhsValue), .uint32(rhsValue)): lhsValue == rhsValue 184 | case let (.float32(lhsValue), .float32(rhsValue)): abs(lhsValue - rhsValue) < float32Tolerance 185 | case let (.float64(lhsValue), .float64(rhsValue)): abs(lhsValue - rhsValue) < float64Tolerance 186 | case let (.listInt8(lhsValues), .listInt8(rhsValues)): lhsValues == rhsValues 187 | case let (.listUInt8(lhsValues), .listUInt8(rhsValues)): lhsValues == rhsValues 188 | case let (.listInt16(lhsValues), .listInt16(rhsValues)): lhsValues == rhsValues 189 | case let (.listUInt16(lhsValues), .listUInt16(rhsValues)): lhsValues == rhsValues 190 | case let (.listInt32(lhsValues), .listInt32(rhsValues)): lhsValues == rhsValues 191 | case let (.listUInt32(lhsValues), .listUInt32(rhsValues)): lhsValues == rhsValues 192 | case let (.listFloat32(lhsValues), .listFloat32(rhsValues)): 193 | lhsValues.count == rhsValues.count && 194 | zip(lhsValues, rhsValues).allSatisfy { abs($0.1 - $0.0) < float32Tolerance } 195 | case let (.listFloat64(lhsValues), .listFloat64(rhsValues)): 196 | lhsValues.count == rhsValues.count && 197 | zip(lhsValues, rhsValues).allSatisfy { abs($0.1 - $0.0) < float64Tolerance } 198 | default: 199 | false 200 | } 201 | } 202 | } 203 | 204 | private class DataOutputStream: OutputStream { 205 | var data = Data() 206 | 207 | override func open() {} 208 | override func close() {} 209 | override var hasSpaceAvailable: Bool { true } 210 | 211 | override func write(_ buffer: UnsafePointer, maxLength length: Int) -> Int { 212 | data.append(buffer, count: length) 213 | return length 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /PLYIO/Tests/UnsafeRawPointerConvertibleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import PLYIO 3 | 4 | final class UnsafeRawPointerConvertibleTests: XCTestCase { 5 | static let floatValue: Float = 42.17 6 | static var floatValueLittleEndianData = Data([ 0x14, 0xae, 0x28, 0x42 ]) 7 | static var floatValueBigEndianData = Data([ 0x42, 0x28, 0xae, 0x14 ]) 8 | static let floatValuesCount = 1024 9 | static let floatValuesLittleEndianData = Data([ 0x00 ]) + (0..(0, 1, 0) 8 | #if !os(visionOS) 9 | static let fovy = Angle(degrees: 65) 10 | #endif 11 | static let modelCenterZ: Float = -8 12 | } 13 | 14 | -------------------------------------------------------------------------------- /SampleApp/App/SampleApp.swift: -------------------------------------------------------------------------------- 1 | #if os(visionOS) 2 | import CompositorServices 3 | #endif 4 | import SwiftUI 5 | 6 | @main 7 | struct SampleApp: App { 8 | var body: some Scene { 9 | WindowGroup("MetalSplatter Sample App", id: "main") { 10 | ContentView() 11 | } 12 | 13 | #if os(macOS) 14 | WindowGroup(for: ModelIdentifier.self) { modelIdentifier in 15 | MetalKitSceneView(modelIdentifier: modelIdentifier.wrappedValue) 16 | .navigationTitle(modelIdentifier.wrappedValue?.description ?? "No Model") 17 | } 18 | #endif // os(macOS) 19 | 20 | #if os(visionOS) 21 | ImmersiveSpace(for: ModelIdentifier.self) { modelIdentifier in 22 | CompositorLayer(configuration: ContentStageConfiguration()) { layerRenderer in 23 | let renderer = VisionSceneRenderer(layerRenderer) 24 | Task { 25 | do { 26 | try await renderer.load(modelIdentifier.wrappedValue) 27 | } catch { 28 | print("Error loading model: \(error.localizedDescription)") 29 | } 30 | renderer.startRenderLoop() 31 | } 32 | } 33 | } 34 | .immersionStyle(selection: .constant(immersionStyle), in: immersionStyle) 35 | #endif // os(visionOS) 36 | } 37 | 38 | #if os(visionOS) 39 | var immersionStyle: ImmersionStyle { 40 | if #available(visionOS 2, *) { 41 | .mixed 42 | } else { 43 | .full 44 | } 45 | } 46 | #endif // os(visionOS) 47 | } 48 | 49 | -------------------------------------------------------------------------------- /SampleApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SampleApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SampleApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationPreferredDefaultSceneSessionRole 8 | UIWindowSceneSessionRoleApplication 9 | UIApplicationSupportsMultipleScenes 10 | 11 | UISceneConfigurations 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /SampleApp/MetalSplatter_SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SampleApp/MetalSplatter_SampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SampleApp/MetalSplatter_SampleApp.xcodeproj/xcshareddata/xcschemes/MetalSplatter SampleApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /SampleApp/Model/ModelIdentifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ModelIdentifier: Equatable, Hashable, Codable, CustomStringConvertible { 4 | case gaussianSplat(URL) 5 | case sampleBox 6 | 7 | var description: String { 8 | switch self { 9 | case .gaussianSplat(let url): 10 | "Gaussian Splat: \(url.path)" 11 | case .sampleBox: 12 | "Sample Box" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /SampleApp/Model/ModelRenderer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Metal 3 | import simd 4 | 5 | public struct ModelRendererViewportDescriptor { 6 | var viewport: MTLViewport 7 | var projectionMatrix: simd_float4x4 8 | var viewMatrix: simd_float4x4 9 | var screenSize: SIMD2 10 | } 11 | 12 | public protocol ModelRenderer { 13 | func render(viewports: [ModelRendererViewportDescriptor], 14 | colorTexture: MTLTexture, 15 | colorStoreAction: MTLStoreAction, 16 | depthTexture: MTLTexture?, 17 | rasterizationRateMap: MTLRasterizationRateMap?, 18 | renderTargetArrayLength: Int, 19 | to commandBuffer: MTLCommandBuffer) throws 20 | } 21 | -------------------------------------------------------------------------------- /SampleApp/Model/SampleBoxRenderer+ModelRenderer.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | import SampleBoxRenderer 3 | 4 | extension SampleBoxRenderer: ModelRenderer { 5 | public func render(viewports: [ModelRendererViewportDescriptor], 6 | colorTexture: MTLTexture, 7 | colorStoreAction: MTLStoreAction, 8 | depthTexture: MTLTexture?, 9 | rasterizationRateMap: MTLRasterizationRateMap?, 10 | renderTargetArrayLength: Int, 11 | to commandBuffer: MTLCommandBuffer) throws { 12 | let remappedViewports = viewports.map { viewport -> ViewportDescriptor in 13 | ViewportDescriptor(viewport: viewport.viewport, 14 | projectionMatrix: viewport.projectionMatrix, 15 | viewMatrix: viewport.viewMatrix, 16 | screenSize: viewport.screenSize) 17 | } 18 | try render(viewports: remappedViewports, 19 | colorTexture: colorTexture, 20 | colorStoreAction: colorStoreAction, 21 | depthTexture: depthTexture, 22 | rasterizationRateMap: rasterizationRateMap, 23 | renderTargetArrayLength: renderTargetArrayLength, 24 | to: commandBuffer) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SampleApp/Model/SplatRenderer+ModelRenderer.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | import MetalSplatter 3 | 4 | extension SplatRenderer: ModelRenderer { 5 | public func render(viewports: [ModelRendererViewportDescriptor], 6 | colorTexture: MTLTexture, 7 | colorStoreAction: MTLStoreAction, 8 | depthTexture: MTLTexture?, 9 | rasterizationRateMap: MTLRasterizationRateMap?, 10 | renderTargetArrayLength: Int, 11 | to commandBuffer: MTLCommandBuffer) throws { 12 | let remappedViewports = viewports.map { viewport -> ViewportDescriptor in 13 | ViewportDescriptor(viewport: viewport.viewport, 14 | projectionMatrix: viewport.projectionMatrix, 15 | viewMatrix: viewport.viewMatrix, 16 | screenSize: viewport.screenSize) 17 | } 18 | try render(viewports: remappedViewports, 19 | colorTexture: colorTexture, 20 | colorStoreAction: colorStoreAction, 21 | depthTexture: depthTexture, 22 | rasterizationRateMap: rasterizationRateMap, 23 | renderTargetArrayLength: renderTargetArrayLength, 24 | to: commandBuffer) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SampleApp/Scene/ContentStageConfiguration.swift: -------------------------------------------------------------------------------- 1 | #if os(visionOS) 2 | 3 | import CompositorServices 4 | import Foundation 5 | import SwiftUI 6 | 7 | struct ContentStageConfiguration: CompositorLayerConfiguration { 8 | func makeConfiguration(capabilities: LayerRenderer.Capabilities, configuration: inout LayerRenderer.Configuration) { 9 | configuration.depthFormat = .depth32Float 10 | configuration.colorFormat = .bgra8Unorm_srgb 11 | 12 | let foveationEnabled = capabilities.supportsFoveation 13 | configuration.isFoveationEnabled = foveationEnabled 14 | 15 | let options: LayerRenderer.Capabilities.SupportedLayoutsOptions = foveationEnabled ? [.foveationEnabled] : [] 16 | let supportedLayouts = capabilities.supportedLayouts(options: options) 17 | 18 | configuration.layout = supportedLayouts.contains(.layered) ? .layered : .dedicated 19 | } 20 | } 21 | 22 | #endif // os(visionOS) 23 | -------------------------------------------------------------------------------- /SampleApp/Scene/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import RealityKit 3 | import UniformTypeIdentifiers 4 | 5 | struct ContentView: View { 6 | @State private var isPickingFile = false 7 | 8 | #if os(macOS) 9 | @Environment(\.openWindow) private var openWindow 10 | #elseif os(iOS) 11 | @State private var navigationPath = NavigationPath() 12 | 13 | private func openWindow(value: ModelIdentifier) { 14 | navigationPath.append(value) 15 | } 16 | #elseif os(visionOS) 17 | @Environment(\.openImmersiveSpace) var openImmersiveSpace 18 | @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace 19 | 20 | @State var immersiveSpaceIsShown = false 21 | 22 | private func openWindow(value: ModelIdentifier) { 23 | Task { 24 | switch await openImmersiveSpace(value: value) { 25 | case .opened: 26 | immersiveSpaceIsShown = true 27 | case .error, .userCancelled: 28 | break 29 | @unknown default: 30 | break 31 | } 32 | } 33 | } 34 | #endif 35 | 36 | var body: some View { 37 | #if os(macOS) || os(visionOS) 38 | mainView 39 | #elseif os(iOS) 40 | NavigationStack(path: $navigationPath) { 41 | mainView 42 | .navigationDestination(for: ModelIdentifier.self) { modelIdentifier in 43 | MetalKitSceneView(modelIdentifier: modelIdentifier) 44 | .navigationTitle(modelIdentifier.description) 45 | } 46 | } 47 | #endif // os(iOS) 48 | } 49 | 50 | @ViewBuilder 51 | var mainView: some View { 52 | VStack { 53 | Spacer() 54 | 55 | Text("MetalSplatter SampleApp") 56 | 57 | Spacer() 58 | 59 | Button("Read Scene File") { 60 | isPickingFile = true 61 | } 62 | .padding() 63 | .buttonStyle(.borderedProminent) 64 | .disabled(isPickingFile) 65 | #if os(visionOS) 66 | .disabled(immersiveSpaceIsShown) 67 | #endif 68 | .fileImporter(isPresented: $isPickingFile, 69 | allowedContentTypes: [ 70 | UTType(filenameExtension: "ply")!, 71 | UTType(filenameExtension: "splat")!, 72 | ]) { 73 | isPickingFile = false 74 | switch $0 { 75 | case .success(let url): 76 | _ = url.startAccessingSecurityScopedResource() 77 | Task { 78 | // This is a sample app. In a real app, this should be more tightly scoped, not using a silly timer. 79 | try await Task.sleep(for: .seconds(10)) 80 | url.stopAccessingSecurityScopedResource() 81 | } 82 | openWindow(value: ModelIdentifier.gaussianSplat(url)) 83 | case .failure: 84 | break 85 | } 86 | } 87 | 88 | Spacer() 89 | 90 | Button("Show Sample Box") { 91 | openWindow(value: ModelIdentifier.sampleBox) 92 | } 93 | .padding() 94 | .buttonStyle(.borderedProminent) 95 | #if os(visionOS) 96 | .disabled(immersiveSpaceIsShown) 97 | #endif 98 | 99 | Spacer() 100 | 101 | #if os(visionOS) 102 | Button("Dismiss Immersive Space") { 103 | Task { 104 | await dismissImmersiveSpace() 105 | immersiveSpaceIsShown = false 106 | } 107 | } 108 | .disabled(!immersiveSpaceIsShown) 109 | 110 | Spacer() 111 | #endif // os(visionOS) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /SampleApp/Scene/MetalKitSceneRenderer.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(macOS) 2 | 3 | import Metal 4 | import MetalKit 5 | import MetalSplatter 6 | import os 7 | import SampleBoxRenderer 8 | import simd 9 | import SwiftUI 10 | 11 | class MetalKitSceneRenderer: NSObject, MTKViewDelegate { 12 | private static let log = 13 | Logger(subsystem: Bundle.main.bundleIdentifier!, 14 | category: "MetalKitSceneRenderer") 15 | 16 | let metalKitView: MTKView 17 | let device: MTLDevice 18 | let commandQueue: MTLCommandQueue 19 | 20 | var model: ModelIdentifier? 21 | var modelRenderer: (any ModelRenderer)? 22 | 23 | let inFlightSemaphore = DispatchSemaphore(value: Constants.maxSimultaneousRenders) 24 | 25 | var lastRotationUpdateTimestamp: Date? = nil 26 | var rotation: Angle = .zero 27 | 28 | var drawableSize: CGSize = .zero 29 | 30 | init?(_ metalKitView: MTKView) { 31 | self.device = metalKitView.device! 32 | guard let queue = self.device.makeCommandQueue() else { return nil } 33 | self.commandQueue = queue 34 | self.metalKitView = metalKitView 35 | metalKitView.colorPixelFormat = MTLPixelFormat.bgra8Unorm_srgb 36 | metalKitView.depthStencilPixelFormat = MTLPixelFormat.depth32Float 37 | metalKitView.sampleCount = 1 38 | metalKitView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) 39 | } 40 | 41 | func load(_ model: ModelIdentifier?) async throws { 42 | guard model != self.model else { return } 43 | self.model = model 44 | 45 | modelRenderer = nil 46 | switch model { 47 | case .gaussianSplat(let url): 48 | let splat = try await SplatRenderer(device: device, 49 | colorFormat: metalKitView.colorPixelFormat, 50 | depthFormat: metalKitView.depthStencilPixelFormat, 51 | sampleCount: metalKitView.sampleCount, 52 | maxViewCount: 1, 53 | maxSimultaneousRenders: Constants.maxSimultaneousRenders) 54 | try await splat.read(from: url) 55 | modelRenderer = splat 56 | case .sampleBox: 57 | modelRenderer = try! await SampleBoxRenderer(device: device, 58 | colorFormat: metalKitView.colorPixelFormat, 59 | depthFormat: metalKitView.depthStencilPixelFormat, 60 | sampleCount: metalKitView.sampleCount, 61 | maxViewCount: 1, 62 | maxSimultaneousRenders: Constants.maxSimultaneousRenders) 63 | case .none: 64 | break 65 | } 66 | } 67 | 68 | private var viewport: ModelRendererViewportDescriptor { 69 | let projectionMatrix = matrix_perspective_right_hand(fovyRadians: Float(Constants.fovy.radians), 70 | aspectRatio: Float(drawableSize.width / drawableSize.height), 71 | nearZ: 0.1, 72 | farZ: 100.0) 73 | 74 | let rotationMatrix = matrix4x4_rotation(radians: Float(rotation.radians), 75 | axis: Constants.rotationAxis) 76 | let translationMatrix = matrix4x4_translation(0.0, 0.0, Constants.modelCenterZ) 77 | // Turn common 3D GS PLY files rightside-up. This isn't generally meaningful, it just 78 | // happens to be a useful default for the most common datasets at the moment. 79 | let commonUpCalibration = matrix4x4_rotation(radians: .pi, axis: SIMD3(0, 0, 1)) 80 | 81 | let viewport = MTLViewport(originX: 0, originY: 0, width: drawableSize.width, height: drawableSize.height, znear: 0, zfar: 1) 82 | 83 | return ModelRendererViewportDescriptor(viewport: viewport, 84 | projectionMatrix: projectionMatrix, 85 | viewMatrix: translationMatrix * rotationMatrix * commonUpCalibration, 86 | screenSize: SIMD2(x: Int(drawableSize.width), y: Int(drawableSize.height))) 87 | } 88 | 89 | private func updateRotation() { 90 | let now = Date() 91 | defer { 92 | lastRotationUpdateTimestamp = now 93 | } 94 | 95 | guard let lastRotationUpdateTimestamp else { return } 96 | rotation += Constants.rotationPerSecond * now.timeIntervalSince(lastRotationUpdateTimestamp) 97 | } 98 | 99 | func draw(in view: MTKView) { 100 | guard let modelRenderer else { return } 101 | guard let drawable = view.currentDrawable else { return } 102 | 103 | _ = inFlightSemaphore.wait(timeout: DispatchTime.distantFuture) 104 | 105 | guard let commandBuffer = commandQueue.makeCommandBuffer() else { 106 | inFlightSemaphore.signal() 107 | return 108 | } 109 | 110 | let semaphore = inFlightSemaphore 111 | commandBuffer.addCompletedHandler { (_ commandBuffer)-> Swift.Void in 112 | semaphore.signal() 113 | } 114 | 115 | updateRotation() 116 | 117 | do { 118 | try modelRenderer.render(viewports: [viewport], 119 | colorTexture: view.multisampleColorTexture ?? drawable.texture, 120 | colorStoreAction: view.multisampleColorTexture == nil ? .store : .multisampleResolve, 121 | depthTexture: view.depthStencilTexture, 122 | rasterizationRateMap: nil, 123 | renderTargetArrayLength: 0, 124 | to: commandBuffer) 125 | } catch { 126 | Self.log.error("Unable to render scene: \(error.localizedDescription)") 127 | } 128 | 129 | commandBuffer.present(drawable) 130 | 131 | commandBuffer.commit() 132 | } 133 | 134 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { 135 | drawableSize = size 136 | } 137 | } 138 | 139 | #endif // os(iOS) || os(macOS) 140 | -------------------------------------------------------------------------------- /SampleApp/Scene/MetalKitSceneView.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(macOS) 2 | 3 | import SwiftUI 4 | import MetalKit 5 | 6 | #if os(macOS) 7 | private typealias ViewRepresentable = NSViewRepresentable 8 | #elseif os(iOS) 9 | private typealias ViewRepresentable = UIViewRepresentable 10 | #endif 11 | 12 | struct MetalKitSceneView: ViewRepresentable { 13 | var modelIdentifier: ModelIdentifier? 14 | 15 | class Coordinator { 16 | var renderer: MetalKitSceneRenderer? 17 | } 18 | 19 | func makeCoordinator() -> Coordinator { 20 | Coordinator() 21 | } 22 | 23 | #if os(macOS) 24 | func makeNSView(context: NSViewRepresentableContext) -> MTKView { 25 | makeView(context.coordinator) 26 | } 27 | #elseif os(iOS) 28 | func makeUIView(context: UIViewRepresentableContext) -> MTKView { 29 | makeView(context.coordinator) 30 | } 31 | #endif 32 | 33 | private func makeView(_ coordinator: Coordinator) -> MTKView { 34 | let metalKitView = MTKView() 35 | 36 | if let metalDevice = MTLCreateSystemDefaultDevice() { 37 | metalKitView.device = metalDevice 38 | } 39 | 40 | let renderer = MetalKitSceneRenderer(metalKitView) 41 | coordinator.renderer = renderer 42 | metalKitView.delegate = renderer 43 | 44 | Task { 45 | do { 46 | try await renderer?.load(modelIdentifier) 47 | } catch { 48 | print("Error loading model: \(error.localizedDescription)") 49 | } 50 | } 51 | 52 | return metalKitView 53 | } 54 | 55 | #if os(macOS) 56 | func updateNSView(_ view: MTKView, context: NSViewRepresentableContext) { 57 | updateView(context.coordinator) 58 | } 59 | #elseif os(iOS) 60 | func updateUIView(_ view: MTKView, context: UIViewRepresentableContext) { 61 | updateView(context.coordinator) 62 | } 63 | #endif 64 | 65 | private func updateView(_ coordinator: Coordinator) { 66 | guard let renderer = coordinator.renderer else { return } 67 | Task { 68 | do { 69 | try await renderer.load(modelIdentifier) 70 | } catch { 71 | print("Error loading model: \(error.localizedDescription)") 72 | } 73 | } 74 | } 75 | } 76 | 77 | #endif // os(iOS) || os(macOS) 78 | -------------------------------------------------------------------------------- /SampleApp/Scene/VisionSceneRenderer.swift: -------------------------------------------------------------------------------- 1 | #if os(visionOS) 2 | 3 | import CompositorServices 4 | import Metal 5 | import MetalSplatter 6 | import os 7 | import SampleBoxRenderer 8 | import simd 9 | import Spatial 10 | import SwiftUI 11 | 12 | extension LayerRenderer.Clock.Instant.Duration { 13 | var timeInterval: TimeInterval { 14 | let nanoseconds = TimeInterval(components.attoseconds / 1_000_000_000) 15 | return TimeInterval(components.seconds) + (nanoseconds / TimeInterval(NSEC_PER_SEC)) 16 | } 17 | } 18 | 19 | class VisionSceneRenderer { 20 | private static let log = 21 | Logger(subsystem: Bundle.main.bundleIdentifier!, 22 | category: "VisionSceneRenderer") 23 | 24 | let layerRenderer: LayerRenderer 25 | let device: MTLDevice 26 | let commandQueue: MTLCommandQueue 27 | 28 | var model: ModelIdentifier? 29 | var modelRenderer: (any ModelRenderer)? 30 | 31 | let inFlightSemaphore = DispatchSemaphore(value: Constants.maxSimultaneousRenders) 32 | 33 | var lastRotationUpdateTimestamp: Date? = nil 34 | var rotation: Angle = .zero 35 | 36 | let arSession: ARKitSession 37 | let worldTracking: WorldTrackingProvider 38 | 39 | init(_ layerRenderer: LayerRenderer) { 40 | self.layerRenderer = layerRenderer 41 | self.device = layerRenderer.device 42 | self.commandQueue = self.device.makeCommandQueue()! 43 | 44 | worldTracking = WorldTrackingProvider() 45 | arSession = ARKitSession() 46 | } 47 | 48 | func load(_ model: ModelIdentifier?) async throws { 49 | guard model != self.model else { return } 50 | self.model = model 51 | 52 | modelRenderer = nil 53 | switch model { 54 | case .gaussianSplat(let url): 55 | let splat = try SplatRenderer(device: device, 56 | colorFormat: layerRenderer.configuration.colorFormat, 57 | depthFormat: layerRenderer.configuration.depthFormat, 58 | sampleCount: 1, 59 | maxViewCount: layerRenderer.properties.viewCount, 60 | maxSimultaneousRenders: Constants.maxSimultaneousRenders) 61 | try await splat.read(from: url) 62 | modelRenderer = splat 63 | case .sampleBox: 64 | modelRenderer = try! SampleBoxRenderer(device: device, 65 | colorFormat: layerRenderer.configuration.colorFormat, 66 | depthFormat: layerRenderer.configuration.depthFormat, 67 | sampleCount: 1, 68 | maxViewCount: layerRenderer.properties.viewCount, 69 | maxSimultaneousRenders: Constants.maxSimultaneousRenders) 70 | case .none: 71 | break 72 | } 73 | } 74 | 75 | func startRenderLoop() { 76 | Task { 77 | do { 78 | try await arSession.run([worldTracking]) 79 | } catch { 80 | fatalError("Failed to initialize ARSession") 81 | } 82 | 83 | let renderThread = Thread { 84 | self.renderLoop() 85 | } 86 | renderThread.name = "Render Thread" 87 | renderThread.start() 88 | } 89 | } 90 | 91 | private func viewports(drawable: LayerRenderer.Drawable, deviceAnchor: DeviceAnchor?) -> [ModelRendererViewportDescriptor] { 92 | let rotationMatrix = matrix4x4_rotation(radians: Float(rotation.radians), 93 | axis: Constants.rotationAxis) 94 | let translationMatrix = matrix4x4_translation(0.0, 0.0, Constants.modelCenterZ) 95 | // Turn common 3D GS PLY files rightside-up. This isn't generally meaningful, it just 96 | // happens to be a useful default for the most common datasets at the moment. 97 | let commonUpCalibration = matrix4x4_rotation(radians: .pi, axis: SIMD3(0, 0, 1)) 98 | 99 | let simdDeviceAnchor = deviceAnchor?.originFromAnchorTransform ?? matrix_identity_float4x4 100 | 101 | return drawable.views.map { view in 102 | let userViewpointMatrix = (simdDeviceAnchor * view.transform).inverse 103 | let projectionMatrix = ProjectiveTransform3D(leftTangent: Double(view.tangents[0]), 104 | rightTangent: Double(view.tangents[1]), 105 | topTangent: Double(view.tangents[2]), 106 | bottomTangent: Double(view.tangents[3]), 107 | nearZ: Double(drawable.depthRange.y), 108 | farZ: Double(drawable.depthRange.x), 109 | reverseZ: true) 110 | let screenSize = SIMD2(x: Int(view.textureMap.viewport.width), 111 | y: Int(view.textureMap.viewport.height)) 112 | return ModelRendererViewportDescriptor(viewport: view.textureMap.viewport, 113 | projectionMatrix: .init(projectionMatrix), 114 | viewMatrix: userViewpointMatrix * translationMatrix * rotationMatrix * commonUpCalibration, 115 | screenSize: screenSize) 116 | } 117 | } 118 | 119 | private func updateRotation() { 120 | let now = Date() 121 | defer { 122 | lastRotationUpdateTimestamp = now 123 | } 124 | 125 | guard let lastRotationUpdateTimestamp else { return } 126 | rotation += Constants.rotationPerSecond * now.timeIntervalSince(lastRotationUpdateTimestamp) 127 | } 128 | 129 | func renderFrame() { 130 | guard let frame = layerRenderer.queryNextFrame() else { return } 131 | 132 | frame.startUpdate() 133 | frame.endUpdate() 134 | 135 | guard let timing = frame.predictTiming() else { return } 136 | LayerRenderer.Clock().wait(until: timing.optimalInputTime) 137 | 138 | guard let commandBuffer = commandQueue.makeCommandBuffer() else { 139 | fatalError("Failed to create command buffer") 140 | } 141 | 142 | guard let drawable = frame.queryDrawable() else { return } 143 | 144 | _ = inFlightSemaphore.wait(timeout: DispatchTime.distantFuture) 145 | 146 | frame.startSubmission() 147 | 148 | let time = LayerRenderer.Clock.Instant.epoch.duration(to: drawable.frameTiming.presentationTime).timeInterval 149 | let deviceAnchor = worldTracking.queryDeviceAnchor(atTimestamp: time) 150 | 151 | drawable.deviceAnchor = deviceAnchor 152 | 153 | let semaphore = inFlightSemaphore 154 | commandBuffer.addCompletedHandler { (_ commandBuffer)-> Swift.Void in 155 | semaphore.signal() 156 | } 157 | 158 | updateRotation() 159 | 160 | let viewports = self.viewports(drawable: drawable, deviceAnchor: deviceAnchor) 161 | 162 | do { 163 | try modelRenderer?.render(viewports: viewports, 164 | colorTexture: drawable.colorTextures[0], 165 | colorStoreAction: .store, 166 | depthTexture: drawable.depthTextures[0], 167 | rasterizationRateMap: drawable.rasterizationRateMaps.first, 168 | renderTargetArrayLength: layerRenderer.configuration.layout == .layered ? drawable.views.count : 1, 169 | to: commandBuffer) 170 | } catch { 171 | Self.log.error("Unable to render scene: \(error.localizedDescription)") 172 | } 173 | 174 | drawable.encodePresent(commandBuffer: commandBuffer) 175 | 176 | commandBuffer.commit() 177 | 178 | frame.endSubmission() 179 | } 180 | 181 | func renderLoop() { 182 | while true { 183 | if layerRenderer.state == .invalidated { 184 | Self.log.warning("Layer is invalidated") 185 | return 186 | } else if layerRenderer.state == .paused { 187 | layerRenderer.waitUntilRunning() 188 | continue 189 | } else { 190 | autoreleasepool { 191 | self.renderFrame() 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | #endif // os(visionOS) 199 | 200 | -------------------------------------------------------------------------------- /SampleApp/Util/MatrixMathUtil.swift: -------------------------------------------------------------------------------- 1 | import simd 2 | 3 | // Generic matrix math utility functions; from Apple sample code 4 | 5 | func matrix4x4_rotation(radians: Float, axis: SIMD3) -> matrix_float4x4 { 6 | let unitAxis = normalize(axis) 7 | let ct = cosf(radians) 8 | let st = sinf(radians) 9 | let ci = 1 - ct 10 | let x = unitAxis.x, y = unitAxis.y, z = unitAxis.z 11 | return matrix_float4x4.init(columns:(vector_float4( ct + x * x * ci, y * x * ci + z * st, z * x * ci - y * st, 0), 12 | vector_float4(x * y * ci - z * st, ct + y * y * ci, z * y * ci + x * st, 0), 13 | vector_float4(x * z * ci + y * st, y * z * ci - x * st, ct + z * z * ci, 0), 14 | vector_float4( 0, 0, 0, 1))) 15 | } 16 | 17 | func matrix4x4_translation(_ translationX: Float, _ translationY: Float, _ translationZ: Float) -> matrix_float4x4 { 18 | return matrix_float4x4.init(columns:(vector_float4(1, 0, 0, 0), 19 | vector_float4(0, 1, 0, 0), 20 | vector_float4(0, 0, 1, 0), 21 | vector_float4(translationX, translationY, translationZ, 1))) 22 | } 23 | 24 | func matrix_perspective_right_hand(fovyRadians fovy: Float, aspectRatio: Float, nearZ: Float, farZ: Float) -> matrix_float4x4 { 25 | let ys = 1 / tanf(fovy * 0.5) 26 | let xs = ys / aspectRatio 27 | let zs = farZ / (nearZ - farZ) 28 | return matrix_float4x4.init(columns:(vector_float4(xs, 0, 0, 0), 29 | vector_float4( 0, ys, 0, 0), 30 | vector_float4( 0, 0, zs, -1), 31 | vector_float4( 0, 0, zs * nearZ, 0))) 32 | } 33 | -------------------------------------------------------------------------------- /SampleBoxRenderer/Resources/Assets.xcassets/ColorMap.textureset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "properties" : { 7 | "origin" : "bottom-left", 8 | "interpretation" : "non-premultiplied-colors" 9 | }, 10 | "textures" : [ 11 | { 12 | "idiom" : "universal", 13 | "filename" : "Universal.mipmapset" 14 | } 15 | ] 16 | } 17 | 18 | -------------------------------------------------------------------------------- /SampleBoxRenderer/Resources/Assets.xcassets/ColorMap.textureset/Universal.mipmapset/ColorMap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scier/MetalSplatter/ec6d0f2cdee958c9617925eda474362869cbf3de/SampleBoxRenderer/Resources/Assets.xcassets/ColorMap.textureset/Universal.mipmapset/ColorMap.png -------------------------------------------------------------------------------- /SampleBoxRenderer/Resources/Assets.xcassets/ColorMap.textureset/Universal.mipmapset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "levels" : [ 7 | { 8 | "filename" : "ColorMap.png", 9 | "mipmap-level" : "base" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /SampleBoxRenderer/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SampleBoxRenderer/Resources/Shaders.metal: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | using namespace metal; 5 | 6 | constant const int kMaxViewCount = 2; 7 | 8 | enum BufferIndex: int32_t 9 | { 10 | BufferIndexMeshPositions = 0, 11 | BufferIndexMeshGenerics = 1, 12 | BufferIndexUniforms = 2, 13 | }; 14 | 15 | enum VertexAttribute: int32_t 16 | { 17 | VertexAttributePosition = 0, 18 | VertexAttributeTexcoord = 1, 19 | }; 20 | 21 | enum TextureIndex: int32_t 22 | { 23 | TextureIndexColor = 0, 24 | }; 25 | 26 | typedef struct 27 | { 28 | matrix_float4x4 projectionMatrix; 29 | matrix_float4x4 viewMatrix; 30 | } Uniforms; 31 | 32 | typedef struct 33 | { 34 | float3 position [[attribute(VertexAttributePosition)]]; 35 | float2 texCoord [[attribute(VertexAttributeTexcoord)]]; 36 | } Vertex; 37 | 38 | typedef struct 39 | { 40 | Uniforms uniforms[kMaxViewCount]; 41 | } UniformsArray; 42 | 43 | typedef struct 44 | { 45 | float4 position [[position]]; 46 | float2 texCoord; 47 | } ColorInOut; 48 | 49 | vertex ColorInOut vertexShader(Vertex in [[stage_in]], 50 | ushort amp_id [[amplification_id]], 51 | constant UniformsArray & uniformsArray [[ buffer(BufferIndexUniforms) ]]) 52 | { 53 | ColorInOut out; 54 | 55 | Uniforms uniforms = uniformsArray.uniforms[min(int(amp_id), kMaxViewCount)]; 56 | 57 | float4 position = float4(in.position, 1.0); 58 | out.position = uniforms.projectionMatrix * uniforms.viewMatrix * position; 59 | out.texCoord = in.texCoord; 60 | 61 | return out; 62 | } 63 | 64 | fragment float4 fragmentShader(ColorInOut in [[stage_in]], 65 | texture2d colorMap [[ texture(TextureIndexColor) ]]) 66 | { 67 | constexpr sampler colorSampler(mip_filter::linear, 68 | mag_filter::linear, 69 | min_filter::linear); 70 | 71 | half4 colorSample = colorMap.sample(colorSampler, in.texCoord.xy); 72 | 73 | return float4(colorSample); 74 | } 75 | -------------------------------------------------------------------------------- /SampleBoxRenderer/Sources/SampleBoxRenderer.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | import MetalKit 3 | import os 4 | import simd 5 | 6 | public class SampleBoxRenderer { 7 | enum Constants { 8 | // Keep in sync with Shaders.metal : maxViewCount 9 | static let maxViewCount = 2 10 | } 11 | 12 | private static let log = 13 | Logger(subsystem: Bundle.module.bundleIdentifier!, 14 | category: "SampleBoxRenderer") 15 | 16 | enum Error: Swift.Error { 17 | case bufferCreationFailed 18 | case badVertexDescriptor 19 | case depthStencilStateCreationFailed 20 | } 21 | 22 | public struct ViewportDescriptor { 23 | public var viewport: MTLViewport 24 | public var projectionMatrix: simd_float4x4 25 | public var viewMatrix: simd_float4x4 26 | public var screenSize: SIMD2 27 | 28 | public init(viewport: MTLViewport, projectionMatrix: simd_float4x4, viewMatrix: simd_float4x4, screenSize: SIMD2) { 29 | self.viewport = viewport 30 | self.projectionMatrix = projectionMatrix 31 | self.viewMatrix = viewMatrix 32 | self.screenSize = screenSize 33 | } 34 | } 35 | 36 | enum BufferIndex: NSInteger { 37 | case meshPositions = 0 38 | case meshGenerics = 1 39 | case uniforms = 2 40 | } 41 | 42 | enum VertexAttribute: NSInteger { 43 | case position = 0 44 | case texcoord = 1 45 | } 46 | 47 | enum TextureIndex: NSInteger { 48 | case color = 0 49 | } 50 | 51 | struct Uniforms { 52 | var projectionMatrix: matrix_float4x4 53 | var viewMatrix: matrix_float4x4 54 | } 55 | 56 | struct UniformsArray { 57 | // maxViewCount = 2, so we have 2 entries 58 | var uniforms0: Uniforms 59 | var uniforms1: Uniforms 60 | 61 | // The 256 byte aligned size of our uniform structure 62 | static var alignedSize: Int { (MemoryLayout.size + 0xFF) & -0x100 } 63 | 64 | mutating func setUniforms(index: Int, _ uniforms: Uniforms) { 65 | switch index { 66 | case 0: uniforms0 = uniforms 67 | case 1: uniforms1 = uniforms 68 | default: break 69 | } 70 | } 71 | } 72 | 73 | public let device: MTLDevice 74 | var pipelineState: MTLRenderPipelineState 75 | var depthState: MTLDepthStencilState 76 | var colorMap: MTLTexture 77 | public let maxViewCount: Int 78 | public let maxSimultaneousRenders: Int 79 | 80 | var dynamicUniformBuffer: MTLBuffer 81 | var uniformBufferOffset = 0 82 | var uniformBufferIndex = 0 83 | var uniforms: UnsafeMutablePointer 84 | 85 | var mesh: MTKMesh 86 | 87 | public init(device: MTLDevice, 88 | colorFormat: MTLPixelFormat, 89 | depthFormat: MTLPixelFormat, 90 | sampleCount: Int, 91 | maxViewCount: Int, 92 | maxSimultaneousRenders: Int) throws { 93 | self.device = device 94 | self.maxViewCount = min(maxViewCount, Constants.maxViewCount) 95 | self.maxSimultaneousRenders = maxSimultaneousRenders 96 | 97 | let uniformBufferSize = UniformsArray.alignedSize * maxSimultaneousRenders 98 | self.dynamicUniformBuffer = device.makeBuffer(length: uniformBufferSize, 99 | options: .storageModeShared)! 100 | self.dynamicUniformBuffer.label = "UniformBuffer" 101 | self.uniforms = UnsafeMutableRawPointer(dynamicUniformBuffer.contents()).bindMemory(to: UniformsArray.self, capacity: 1) 102 | 103 | let mtlVertexDescriptor = Self.buildMetalVertexDescriptor() 104 | 105 | do { 106 | pipelineState = try Self.buildRenderPipelineWithDevice(device: device, 107 | colorFormat: colorFormat, 108 | depthFormat: depthFormat, 109 | sampleCount: sampleCount, 110 | maxViewCount: self.maxViewCount, 111 | mtlVertexDescriptor: mtlVertexDescriptor) 112 | } catch { 113 | Self.log.error("Unable to compile render pipeline state. Error info: \(error)") 114 | throw error 115 | } 116 | 117 | let depthStateDescriptor = MTLDepthStencilDescriptor() 118 | depthStateDescriptor.depthCompareFunction = MTLCompareFunction.greater 119 | depthStateDescriptor.isDepthWriteEnabled = true 120 | guard let state = device.makeDepthStencilState(descriptor: depthStateDescriptor) else { 121 | throw Error.depthStencilStateCreationFailed 122 | } 123 | depthState = state 124 | 125 | do { 126 | mesh = try Self.buildMesh(device: device, mtlVertexDescriptor: mtlVertexDescriptor) 127 | } catch { 128 | Self.log.error("Unable to build MetalKit Mesh. Error info: \(error)") 129 | throw error 130 | } 131 | 132 | do { 133 | colorMap = try Self.loadTexture(device: device, textureName: "ColorMap") 134 | } catch { 135 | Self.log.error("Unable to load texture. Error info: \(error)") 136 | throw error 137 | } 138 | } 139 | 140 | private func updateDynamicBufferState() { 141 | uniformBufferIndex = (uniformBufferIndex + 1) % maxSimultaneousRenders 142 | uniformBufferOffset = UniformsArray.alignedSize * uniformBufferIndex 143 | uniforms = UnsafeMutableRawPointer(dynamicUniformBuffer.contents() + uniformBufferOffset).bindMemory(to: UniformsArray.self, capacity: 1) 144 | } 145 | 146 | private func updateUniforms(forViewports viewports: [ViewportDescriptor]) { 147 | for (i, viewport) in viewports.enumerated() where i <= maxViewCount { 148 | uniforms.pointee.setUniforms(index: i, Uniforms(projectionMatrix: viewport.projectionMatrix, 149 | viewMatrix: viewport.viewMatrix)) 150 | } 151 | } 152 | 153 | func renderEncoder(viewports: [ViewportDescriptor], 154 | colorTexture: MTLTexture, 155 | colorStoreAction: MTLStoreAction, 156 | depthTexture: MTLTexture?, 157 | rasterizationRateMap: MTLRasterizationRateMap?, 158 | renderTargetArrayLength: Int, 159 | for commandBuffer: MTLCommandBuffer) -> MTLRenderCommandEncoder { 160 | let renderPassDescriptor = MTLRenderPassDescriptor() 161 | renderPassDescriptor.colorAttachments[0].texture = colorTexture 162 | renderPassDescriptor.colorAttachments[0].loadAction = .clear 163 | renderPassDescriptor.colorAttachments[0].storeAction = colorStoreAction 164 | renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.0) 165 | if let depthTexture { 166 | renderPassDescriptor.depthAttachment.texture = depthTexture 167 | renderPassDescriptor.depthAttachment.loadAction = .clear 168 | renderPassDescriptor.depthAttachment.storeAction = .store 169 | renderPassDescriptor.depthAttachment.clearDepth = 0.0 170 | } 171 | renderPassDescriptor.rasterizationRateMap = rasterizationRateMap 172 | renderPassDescriptor.renderTargetArrayLength = renderTargetArrayLength 173 | 174 | guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { 175 | fatalError("Failed to create render encoder") 176 | } 177 | 178 | renderEncoder.label = "Primary Render Encoder" 179 | 180 | renderEncoder.setViewports(viewports.map(\.viewport)) 181 | 182 | if viewports.count > 1 { 183 | var viewMappings = (0.. MTLVertexDescriptor { 249 | let mtlVertexDescriptor = MTLVertexDescriptor() 250 | 251 | mtlVertexDescriptor.attributes[VertexAttribute.position.rawValue].format = MTLVertexFormat.float3 252 | mtlVertexDescriptor.attributes[VertexAttribute.position.rawValue].offset = 0 253 | mtlVertexDescriptor.attributes[VertexAttribute.position.rawValue].bufferIndex = BufferIndex.meshPositions.rawValue 254 | 255 | mtlVertexDescriptor.attributes[VertexAttribute.texcoord.rawValue].format = MTLVertexFormat.float2 256 | mtlVertexDescriptor.attributes[VertexAttribute.texcoord.rawValue].offset = 0 257 | mtlVertexDescriptor.attributes[VertexAttribute.texcoord.rawValue].bufferIndex = BufferIndex.meshGenerics.rawValue 258 | 259 | mtlVertexDescriptor.layouts[BufferIndex.meshPositions.rawValue].stride = 12 260 | mtlVertexDescriptor.layouts[BufferIndex.meshPositions.rawValue].stepRate = 1 261 | mtlVertexDescriptor.layouts[BufferIndex.meshPositions.rawValue].stepFunction = MTLVertexStepFunction.perVertex 262 | 263 | mtlVertexDescriptor.layouts[BufferIndex.meshGenerics.rawValue].stride = 8 264 | mtlVertexDescriptor.layouts[BufferIndex.meshGenerics.rawValue].stepRate = 1 265 | mtlVertexDescriptor.layouts[BufferIndex.meshGenerics.rawValue].stepFunction = MTLVertexStepFunction.perVertex 266 | 267 | return mtlVertexDescriptor 268 | } 269 | 270 | private class func buildRenderPipelineWithDevice(device: MTLDevice, 271 | colorFormat: MTLPixelFormat, 272 | depthFormat: MTLPixelFormat, 273 | sampleCount: Int, 274 | maxViewCount: Int, 275 | mtlVertexDescriptor: MTLVertexDescriptor) 276 | throws -> MTLRenderPipelineState { 277 | let library = try device.makeDefaultLibrary(bundle: Bundle.module) 278 | 279 | let vertexFunction = library.makeFunction(name: "vertexShader") 280 | let fragmentFunction = library.makeFunction(name: "fragmentShader") 281 | 282 | let pipelineDescriptor = MTLRenderPipelineDescriptor() 283 | pipelineDescriptor.label = "RenderPipeline" 284 | pipelineDescriptor.rasterSampleCount = sampleCount 285 | pipelineDescriptor.vertexFunction = vertexFunction 286 | pipelineDescriptor.fragmentFunction = fragmentFunction 287 | pipelineDescriptor.vertexDescriptor = mtlVertexDescriptor 288 | 289 | pipelineDescriptor.colorAttachments[0].pixelFormat = colorFormat 290 | pipelineDescriptor.depthAttachmentPixelFormat = depthFormat 291 | 292 | pipelineDescriptor.maxVertexAmplificationCount = maxViewCount 293 | 294 | return try device.makeRenderPipelineState(descriptor: pipelineDescriptor) 295 | } 296 | 297 | private class func buildMesh(device: MTLDevice, 298 | mtlVertexDescriptor: MTLVertexDescriptor) throws -> MTKMesh { 299 | let metalAllocator = MTKMeshBufferAllocator(device: device) 300 | 301 | let mdlMesh = MDLMesh.newBox(withDimensions: SIMD3(4, 4, 4), 302 | segments: SIMD3(2, 2, 2), 303 | geometryType: MDLGeometryType.triangles, 304 | inwardNormals: false, 305 | allocator: metalAllocator) 306 | 307 | let mdlVertexDescriptor = MTKModelIOVertexDescriptorFromMetal(mtlVertexDescriptor) 308 | 309 | guard let attributes = mdlVertexDescriptor.attributes as? [MDLVertexAttribute] else { 310 | throw Error.badVertexDescriptor 311 | } 312 | attributes[VertexAttribute.position.rawValue].name = MDLVertexAttributePosition 313 | attributes[VertexAttribute.texcoord.rawValue].name = MDLVertexAttributeTextureCoordinate 314 | 315 | mdlMesh.vertexDescriptor = mdlVertexDescriptor 316 | 317 | return try MTKMesh(mesh: mdlMesh, device: device) 318 | } 319 | 320 | private class func loadTexture(device: MTLDevice, 321 | textureName: String) throws -> MTLTexture { 322 | let textureLoader = MTKTextureLoader(device: device) 323 | 324 | let textureLoaderOptions = [ 325 | MTKTextureLoader.Option.textureUsage: NSNumber(value: MTLTextureUsage.shaderRead.rawValue), 326 | MTKTextureLoader.Option.textureStorageMode: NSNumber(value: MTLStorageMode.`private`.rawValue) 327 | ] 328 | 329 | return try textureLoader.newTexture(name: textureName, 330 | scaleFactor: 1.0, 331 | bundle: Bundle.module, 332 | options: textureLoaderOptions) 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /SplatConverter/Sources/SplatConverter.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import SplatIO 4 | 5 | @main 6 | struct SplatConverter: ParsableCommand { 7 | enum Error: Swift.Error { 8 | case unknownReadError(String) 9 | case unknownWriteError(String) 10 | } 11 | 12 | static var configuration = CommandConfiguration( 13 | commandName: "SplatConverter", 14 | abstract: "A utility for converting splat scene files", 15 | version: "1.0.0" 16 | ) 17 | 18 | @Argument(help: "The input splat scene file") 19 | var inputFile: String 20 | 21 | @Option(name: .shortAndLong, help: "The output splat scene file") 22 | var outputFile: String? 23 | 24 | @Option(name: [.customShort("f"), .long], help: "The format of the output file (dotSplat, ply, ply-ascii)") 25 | var outputFormat: SplatOutputFileFormat? 26 | 27 | @Flag(name: [.long], help: "Describe each of the splats from first to first + count") 28 | var describe = false 29 | 30 | @Option(name: [.long], help: "Index of first splat to convert") 31 | var start: Int = 0 32 | 33 | @Option(name: [.long], help: "Maximum number of splats to convert") 34 | var count: Int? 35 | 36 | @Flag(name: .shortAndLong, help: "Verbose output") 37 | var verbose = false 38 | 39 | func run() throws { 40 | let reader = try AutodetectSceneReader(URL(fileURLWithPath: inputFile)) 41 | 42 | var outputFormat = outputFormat 43 | if let outputFile, outputFormat == nil { 44 | outputFormat = .init(defaultFor: SplatFileFormat(for: URL(fileURLWithPath: outputFile))) 45 | if outputFormat == nil { 46 | throw ValidationError("No output format specified") 47 | } 48 | } 49 | 50 | let delegate = ReaderDelegate(save: outputFile != nil, 51 | start: start, 52 | count: count, 53 | describe: describe, 54 | inputLabel: inputFile, 55 | outputLabel: outputFile) 56 | let readDuration = ContinuousClock().measure { 57 | reader.read(to: delegate) 58 | } 59 | if verbose { 60 | let pointsPerSecond = Double(delegate.readCount) / max(readDuration.asSeconds, 1e-6) 61 | print("Read \(delegate.readCount) points from \(inputFile) in \(readDuration.asSeconds.formatted(.number.precision(.fractionLength(2)))) seconds (\(pointsPerSecond.formatted(.number.precision(.fractionLength(0)))) points/s)") 62 | } 63 | if let error = delegate.error { 64 | throw error 65 | } 66 | 67 | if let outputFile, let outputFormat { 68 | let writeDuration = try ContinuousClock().measure { 69 | let writer: (any SplatSceneWriter) 70 | switch outputFormat { 71 | case .dotSplat: 72 | writer = try DotSplatSceneWriter(toFileAtPath: outputFile, append: false) 73 | case .binaryPLY: 74 | let splatPLYWriter = try SplatPLYSceneWriter(toFileAtPath: outputFile, append: false) 75 | try splatPLYWriter.start(sphericalHarmonicDegree: 3, binary: true, pointCount: delegate.points.count) 76 | writer = splatPLYWriter 77 | case .asciiPLY: 78 | let splatPLYWriter = try SplatPLYSceneWriter(toFileAtPath: outputFile, append: false) 79 | try splatPLYWriter.start(sphericalHarmonicDegree: 3, binary: false, pointCount: delegate.points.count) 80 | writer = splatPLYWriter 81 | } 82 | 83 | defer { 84 | try? writer.close() 85 | } 86 | 87 | try writer.write(delegate.points) 88 | } 89 | 90 | if verbose { 91 | let pointsPerSecond = Double(delegate.points.count) / max(writeDuration.asSeconds, 1e-6) 92 | print("Wrote \(delegate.points.count) points to \(outputFile) in \(writeDuration.asSeconds.formatted(.number.precision(.fractionLength(2)))) seconds (\(pointsPerSecond.formatted(.number.precision(.fractionLength(0)))) points/s)") 93 | } 94 | 95 | } 96 | } 97 | 98 | class ReaderDelegate: SplatSceneReaderDelegate { 99 | let save: Bool 100 | let start: Int 101 | let count: Int? 102 | let describe: Bool 103 | let inputLabel: String 104 | let outputLabel: String? 105 | var error: Swift.Error? 106 | 107 | var points: [SplatScenePoint] = [] 108 | var currentOffset = 0 109 | var readCount = 0 110 | 111 | init(save: Bool, 112 | start: Int, 113 | count: Int?, 114 | describe: Bool, 115 | inputLabel: String, 116 | outputLabel: String?) { 117 | self.save = save 118 | self.start = start 119 | self.count = count 120 | self.describe = describe 121 | self.inputLabel = inputLabel 122 | self.outputLabel = outputLabel 123 | } 124 | 125 | func didStartReading(withPointCount pointCount: UInt32?) {} 126 | 127 | func didRead(points: [SplatIO.SplatScenePoint]) { 128 | readCount += points.count 129 | 130 | let newCurrentOffset = currentOffset + points.count 131 | defer { 132 | currentOffset = newCurrentOffset 133 | } 134 | 135 | var points = points 136 | if start > currentOffset { 137 | let relativeStart = start - currentOffset 138 | if relativeStart >= points.count { 139 | return 140 | } 141 | 142 | points = Array(points.suffix(points.count - relativeStart)) 143 | 144 | // The deferred currentOffset = newCurrentOffset will set currentOffset to be ready for the next 145 | // call to didRead(), but we need to set it temporarily in the meantime 146 | currentOffset = start 147 | } 148 | 149 | if let count { 150 | let countRemaining = start + count - currentOffset 151 | if countRemaining <= 0 { 152 | return 153 | } 154 | if countRemaining < points.count { 155 | points = Array(points.prefix(countRemaining)) 156 | } 157 | } 158 | 159 | if save { 160 | self.points.append(contentsOf: points) 161 | } 162 | 163 | if describe { 164 | for i in 0..) -> Self { 3 | min(max(self, limits.lowerBound), limits.upperBound) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /SplatIO/Sources/DotSplatEncodedPoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PLYIO 3 | import simd 4 | 5 | struct DotSplatEncodedPoint { 6 | var position: SIMD3 7 | var scales: SIMD3 8 | var color: SIMD4 9 | var rot: SIMD4 10 | } 11 | 12 | extension DotSplatEncodedPoint: ByteWidthProviding { 13 | static var byteWidth: Int { 14 | Float32.byteWidth * 6 + UInt8.byteWidth * 8 15 | } 16 | } 17 | 18 | extension DotSplatEncodedPoint: ZeroProviding { 19 | static let zero = DotSplatEncodedPoint(position: .zero, scales: .zero, color: .zero, rot: .zero) 20 | } 21 | 22 | extension DotSplatEncodedPoint { 23 | init(_ data: D, from offset: D.Index, bigEndian: Bool) 24 | where D : DataProtocol, D.Index == Int { 25 | let sixFloats = Float32.array(data, from: offset, count: 6, bigEndian: bigEndian) 26 | let eightInts = UInt8.array(data, from: offset + 6 * Float32.byteWidth, count: 8, bigEndian: bigEndian) 27 | position = .init(x: sixFloats[0], y: sixFloats[1], z: sixFloats[2]) 28 | scales = .init(x: sixFloats[3], y: sixFloats[4], z: sixFloats[5]) 29 | color = .init(eightInts[0], eightInts[1], eightInts[2], eightInts[3]) 30 | rot = .init(eightInts[4], eightInts[5], eightInts[6], eightInts[7]) 31 | } 32 | 33 | static func array(_ data: D, from offset: D.Index, count: Int, bigEndian: Bool) -> [DotSplatEncodedPoint] 34 | where D : DataProtocol, D.Index == Int { 35 | var values: [Self] = Array(repeating: .zero, count: count) 36 | var offset = offset 37 | for i in 0.. Int { 46 | let sixFloats: [Float32] = [ position.x, position.y, position.z, 47 | scales.x, scales.y, scales.z ] 48 | let eightInts: [UInt8] = [ color.x, color.y, color.z, color.w, 49 | rot.x, rot.y, rot.z, rot.w ] 50 | var bytesWritten: Int = 0 51 | bytesWritten += Float32.store(sixFloats, to: data, at: offset, bigEndian: bigEndian) 52 | bytesWritten += UInt8.store(eightInts, to: data, at: offset + bytesWritten, bigEndian: bigEndian) 53 | return bytesWritten 54 | } 55 | } 56 | 57 | extension DotSplatEncodedPoint { 58 | var splatScenePoint: SplatScenePoint { 59 | SplatScenePoint(position: position, 60 | color: .linearUInt8(color.xyz), 61 | opacity: .linearUInt8(color.w), 62 | scale: .linearFloat(scales), 63 | rotation: simd_quatf(ix: Float(rot[1]) - 128, 64 | iy: Float(rot[2]) - 128, 65 | iz: Float(rot[3]) - 128, 66 | r: Float(rot[0]) - 128).normalized) 67 | } 68 | 69 | init(_ splatScenePoint: SplatScenePoint) { 70 | self.position = splatScenePoint.position 71 | let color = splatScenePoint.color.asLinearUInt8 72 | let opacity = splatScenePoint.opacity.asLinearUInt8 73 | self.color = .init(x: color.x, y: color.y, z: color.z, w: opacity) 74 | self.scales = splatScenePoint.scale.asLinearFloat 75 | let rotation = splatScenePoint.rotation.normalized 76 | self.rot = .init( 77 | x: UInt8((rotation.real * 128 + 128).clamped(to: 0...255)), 78 | y: UInt8((rotation.imag.x * 128 + 128).clamped(to: 0...255)), 79 | z: UInt8((rotation.imag.y * 128 + 128).clamped(to: 0...255)), 80 | w: UInt8((rotation.imag.z * 128 + 128).clamped(to: 0...255)) 81 | ) 82 | } 83 | } 84 | 85 | fileprivate extension SIMD4 { 86 | var xyz: SIMD3 { 87 | SIMD3(x: x, y: y, z: z) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /SplatIO/Sources/DotSplatSceneReader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PLYIO 3 | import simd 4 | 5 | /// A reader for Gaussian Splat files in the ".splat" format, created by https://github.com/antimatter15/splat/ 6 | public class DotSplatSceneReader: SplatSceneReader { 7 | enum Error: Swift.Error { 8 | case cannotOpenSource(URL) 9 | case readError 10 | case unexpectedEndOfFile 11 | } 12 | 13 | let inputStream: InputStream 14 | 15 | public init(_ inputStream: InputStream) { 16 | self.inputStream = inputStream 17 | } 18 | 19 | public convenience init(_ url: URL) throws { 20 | guard let inputStream = InputStream(url: url) else { 21 | throw Error.cannotOpenSource(url) 22 | } 23 | self.init(inputStream) 24 | } 25 | 26 | public func read(to delegate: any SplatSceneReaderDelegate) { 27 | let bufferSize = 64*1024 28 | let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) 29 | defer { buffer.deallocate() } 30 | 31 | inputStream.open() 32 | defer { inputStream.close() } 33 | 34 | var bytesInBuffer = 0 35 | while true { 36 | let readResult = inputStream.read(buffer + bytesInBuffer, maxLength: bufferSize - bytesInBuffer) 37 | switch readResult { 38 | case -1: 39 | delegate.didFailReading(withError: Error.readError) 40 | return 41 | case 0: 42 | guard bytesInBuffer == 0 else { 43 | delegate.didFailReading(withError: Error.unexpectedEndOfFile) 44 | return 45 | } 46 | delegate.didFinishReading() 47 | return 48 | default: 49 | bytesInBuffer += readResult 50 | } 51 | 52 | let encodedPointCount = bytesInBuffer / DotSplatEncodedPoint.byteWidth 53 | guard encodedPointCount > 0 else { continue } 54 | 55 | let bufferPointer = UnsafeBufferPointer(start: buffer, count: bytesInBuffer) 56 | let splatPoints = (0.. 10 | private var points: [SplatScenePoint] = [] 11 | 12 | public init(continuation: CheckedContinuation<[SplatScenePoint], Swift.Error>) { 13 | self.continuation = continuation 14 | } 15 | 16 | public func didStartReading(withPointCount pointCount: UInt32?) {} 17 | 18 | public func didRead(points: [SplatIO.SplatScenePoint]) { 19 | self.points.append(contentsOf: points) 20 | } 21 | 22 | public func didFinishReading() { 23 | continuation.resume(returning: points) 24 | } 25 | 26 | public func didFailReading(withError error: Swift.Error?) { 27 | continuation.resume(throwing: error ?? BufferReader.Error.unknown) 28 | } 29 | } 30 | 31 | public var points: [SplatScenePoint] = [] 32 | 33 | public init() {} 34 | 35 | /** Replace the content of points with the content read from the given SplatSceneReader. */ 36 | mutating public func read(from reader: SplatSceneReader) async throws { 37 | points = try await withCheckedThrowingContinuation { continuation in 38 | reader.read(to: BufferReader(continuation: continuation)) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SplatIO/Sources/SplatPLYConstants.swift: -------------------------------------------------------------------------------- 1 | struct SplatPLYConstants { 2 | enum ElementName: String { 3 | case point = "vertex" 4 | } 5 | 6 | enum PropertyName { 7 | static let positionX = [ "x" ] 8 | static let positionY = [ "y" ] 9 | static let positionZ = [ "z" ] 10 | static let normalX = [ "nx" ] 11 | static let normalY = [ "ny" ] 12 | static let normalZ = [ "nz" ] 13 | static let sh0_r = [ "f_dc_0" ] 14 | static let sh0_g = [ "f_dc_1" ] 15 | static let sh0_b = [ "f_dc_2" ] 16 | static let sphericalHarmonicsPrefix = "f_rest_" 17 | static let colorR = [ "red" ] 18 | static let colorG = [ "green" ] 19 | static let colorB = [ "blue" ] 20 | static let scaleX = [ "scale_0" ] 21 | static let scaleY = [ "scale_1" ] 22 | static let scaleZ = [ "scale_2" ] 23 | static let opacity = [ "opacity" ] 24 | static let rotation0 = [ "rot_0" ] 25 | static let rotation1 = [ "rot_1" ] 26 | static let rotation2 = [ "rot_2" ] 27 | static let rotation3 = [ "rot_3" ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /SplatIO/Sources/SplatPLYSceneWriter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PLYIO 3 | import simd 4 | 5 | public class SplatPLYSceneWriter: SplatSceneWriter { 6 | enum Error: Swift.Error { 7 | case cannotWriteToFile(String) 8 | case unknownOutputStreamError 9 | case alreadyStarted 10 | case notStarted 11 | case cannotWriteAfterClose 12 | case unexpectedPoints 13 | } 14 | 15 | public enum Constants { 16 | public static let defaultSphericalHarmonicDegree: UInt = 3 17 | public static let defaultBinary = true 18 | public static let elementBufferSize = 1000 // Write 1000 elements at a time 19 | } 20 | 21 | private let plyWriter: PLYWriter 22 | 23 | private var totalPointCount = 0 24 | private var pointsWritten = 0 25 | private var closed = false 26 | 27 | private var elementBuffer: [PLYElement] = Array.init(repeating: PLYElement(properties: []), count: Constants.elementBufferSize) 28 | private var elementMapping: ElementOutputMapping? 29 | 30 | public init(_ outputStream: OutputStream) { 31 | plyWriter = PLYWriter(outputStream) 32 | } 33 | 34 | public convenience init(toFileAtPath path: String, append: Bool) throws { 35 | guard let outputStream = OutputStream(toFileAtPath: path, append: append) else { 36 | throw Error.cannotWriteToFile(path) 37 | } 38 | self.init(outputStream) 39 | } 40 | 41 | public func close() throws { 42 | guard !closed else { return } 43 | try plyWriter.close() 44 | closed = true 45 | } 46 | 47 | public func start(sphericalHarmonicDegree: UInt = Constants.defaultSphericalHarmonicDegree, 48 | binary: Bool = Constants.defaultBinary, 49 | pointCount: Int) throws { 50 | guard elementMapping == nil else { 51 | throw Error.alreadyStarted 52 | } 53 | 54 | let elementMapping = ElementOutputMapping(sphericalHarmonicDegree: sphericalHarmonicDegree) 55 | let header = elementMapping.createHeader(format: binary ? .binaryLittleEndian : .ascii, pointCount: pointCount) 56 | try plyWriter.write(header) 57 | 58 | self.totalPointCount = pointCount 59 | self.elementMapping = elementMapping 60 | } 61 | 62 | public func write(_ points: [SplatScenePoint]) throws { 63 | guard let elementMapping else { 64 | throw Error.notStarted 65 | } 66 | guard !closed else { 67 | throw Error.cannotWriteAfterClose 68 | } 69 | 70 | guard points.count + pointsWritten <= totalPointCount else { 71 | throw Error.unexpectedPoints 72 | } 73 | 74 | var elementBufferOffset = 0 75 | for (i, point) in points.enumerated() { 76 | elementBuffer[elementBufferOffset].set(to: point, with: elementMapping) 77 | elementBufferOffset += 1 78 | if elementBufferOffset == elementBuffer.count || i == points.count-1 { 79 | try plyWriter.write(elementBuffer, count: elementBufferOffset) 80 | elementBufferOffset = 0 81 | } 82 | } 83 | 84 | pointsWritten += points.count 85 | } 86 | } 87 | 88 | private struct ElementOutputMapping { 89 | var sphericalHarmonicDegree: UInt 90 | 91 | var indirectColorCount: Int { 92 | switch sphericalHarmonicDegree { 93 | case 0: 0 94 | case 1: 3 95 | case 2: 3 + 5 96 | case 3: 3 + 5 + 7 97 | default: 0 98 | } 99 | } 100 | 101 | func createHeader(format: PLYHeader.Format, pointCount: Int) -> PLYHeader { 102 | var properties: [PLYHeader.Property] = [] 103 | 104 | let appendProperty = { (name: String, type: PLYHeader.PrimitivePropertyType) in 105 | properties.append(.init(name: name, type: .primitive(type))) 106 | } 107 | appendProperty(SplatPLYConstants.PropertyName.positionX.first!, .float32) 108 | appendProperty(SplatPLYConstants.PropertyName.positionY.first!, .float32) 109 | appendProperty(SplatPLYConstants.PropertyName.positionZ.first!, .float32) 110 | 111 | appendProperty(SplatPLYConstants.PropertyName.normalX.first!, .float32) 112 | appendProperty(SplatPLYConstants.PropertyName.normalY.first!, .float32) 113 | appendProperty(SplatPLYConstants.PropertyName.normalZ.first!, .float32) 114 | 115 | appendProperty(SplatPLYConstants.PropertyName.sh0_r.first!, .float32) 116 | appendProperty(SplatPLYConstants.PropertyName.sh0_g.first!, .float32) 117 | appendProperty(SplatPLYConstants.PropertyName.sh0_b.first!, .float32) 118 | 119 | for i in 0..<(indirectColorCount*3) { 120 | appendProperty("\(SplatPLYConstants.PropertyName.sphericalHarmonicsPrefix)\(i)", .float32) 121 | } 122 | appendProperty(SplatPLYConstants.PropertyName.opacity.first!, .float32) 123 | appendProperty(SplatPLYConstants.PropertyName.scaleX.first!, .float32) 124 | appendProperty(SplatPLYConstants.PropertyName.scaleY.first!, .float32) 125 | appendProperty(SplatPLYConstants.PropertyName.scaleZ.first!, .float32) 126 | appendProperty(SplatPLYConstants.PropertyName.rotation0.first!, .float32) 127 | appendProperty(SplatPLYConstants.PropertyName.rotation1.first!, .float32) 128 | appendProperty(SplatPLYConstants.PropertyName.rotation2.first!, .float32) 129 | appendProperty(SplatPLYConstants.PropertyName.rotation3.first!, .float32) 130 | 131 | let element = PLYHeader.Element(name: SplatPLYConstants.ElementName.point.rawValue, 132 | count: UInt32(pointCount), 133 | properties: properties) 134 | return PLYHeader(format: format, version: "1.0", elements: [ element ]) 135 | } 136 | } 137 | 138 | fileprivate extension PLYElement { 139 | mutating func set(to point: SplatScenePoint, with mapping: ElementOutputMapping) { 140 | var propertyCount = 0 141 | 142 | func appendProperty(_ value: Float) { 143 | if properties.count == propertyCount { 144 | properties.append(.float32(value)) 145 | } else { 146 | properties[propertyCount] = .float32(value) 147 | } 148 | propertyCount += 1 149 | } 150 | 151 | // Position 152 | appendProperty(point.position.x) 153 | appendProperty(point.position.y) 154 | appendProperty(point.position.z) 155 | 156 | // Normal 157 | appendProperty(0) 158 | appendProperty(0) 159 | appendProperty(1) 160 | 161 | // Color 162 | let color = point.color.asSphericalHarmonic 163 | let directColor = color.first ?? .zero 164 | appendProperty(directColor.x) 165 | appendProperty(directColor.y) 166 | appendProperty(directColor.z) 167 | 168 | for i in 0..= color.count ? .zero : color[shColorIndex] 171 | appendProperty(shColor.x) 172 | appendProperty(shColor.y) 173 | appendProperty(shColor.z) 174 | } 175 | 176 | // Opacity 177 | appendProperty(point.opacity.asLogitFloat) 178 | 179 | // Scale 180 | let scale = point.scale.asExponent 181 | appendProperty(scale.x) 182 | appendProperty(scale.y) 183 | appendProperty(scale.z) 184 | 185 | // Rotation 186 | appendProperty(point.rotation.real) 187 | appendProperty(point.rotation.imag.x) 188 | appendProperty(point.rotation.imag.y) 189 | appendProperty(point.rotation.imag.z) 190 | 191 | if propertyCount > properties.count { 192 | properties = properties.dropLast(properties.count - propertyCount) 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /SplatIO/Sources/SplatScenePoint+CustomStringConvertible.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SplatScenePoint: CustomStringConvertible { 4 | public var description: String { 5 | var components: [String] = [] 6 | 7 | // Position 8 | components += [ "position:(\(position.x), \(position.y), \(position.z))" ] 9 | 10 | components += [ "color:\(color.description)" ] 11 | components += [ "opacity:\(opacity.description)" ] 12 | components += [ "scale:\(scale.description)" ] 13 | components += [ "rotation:(ix = \(rotation.imag.x), iy = \(rotation.imag.y), iz = \(rotation.imag.z), r = \(rotation.real))" ] 14 | 15 | return components.joined(separator: " ") 16 | } 17 | } 18 | 19 | extension SplatScenePoint.Color: CustomStringConvertible { 20 | public var description: String { 21 | switch self { 22 | case .sphericalHarmonic(let values): 23 | switch values.count { 24 | case 0: "sh(nil)" 25 | case 1: "sh(N=0; (\(values[0].x), \(values[0].y), \(values[0].z)))" 26 | case 1+3: "sh(N=1; (\(values[0].x), \(values[0].y), \(values[0].z)), ...)" 27 | case 1+3+5: "sh(N=2; (\(values[0].x), \(values[0].y), \(values[0].z)), ...)" 28 | case 1+3+5+7: "sh(N=3; (\(values[0].x), \(values[0].y), \(values[0].z)), ...)" 29 | default: "sh(N=?, \(values.count) triples)" 30 | } 31 | case .linearFloat(let values): 32 | "linear(\(values.x), \(values.y), \(values.z))" 33 | case .linearFloat256(let values): 34 | "linearFloat256(\(values.x), \(values.y), \(values.z))" 35 | case .linearUInt8(let values): 36 | "linearByte(\(values.x), \(values.y), \(values.z))" 37 | } 38 | } 39 | } 40 | 41 | extension SplatScenePoint.Opacity: CustomStringConvertible { 42 | public var description: String { 43 | switch self { 44 | case .logitFloat(let value): 45 | "logit(\(value))" 46 | case .linearFloat(let value): 47 | "linear(\(value))" 48 | case .linearUInt8(let value): 49 | "linearByte(\(value))" 50 | } 51 | } 52 | } 53 | 54 | extension SplatScenePoint.Scale: CustomStringConvertible { 55 | public var description: String { 56 | switch self { 57 | case .linearFloat(let values): 58 | "linear(\(values.x), \(values.y), \(values.z))" 59 | case .exponent(let values): 60 | "exponent(\(values.x), \(values.y), \(values.z))" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SplatIO/Sources/SplatScenePoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import simd 3 | 4 | public struct SplatScenePoint { 5 | public enum Color { 6 | static let SH_C0: Float = 0.28209479177387814 7 | static let INV_SH_C0: Float = 1.0 / SH_C0 8 | 9 | // .sphericalHarmonic should have 1 values (for N=0 aka first-order spherical harmonics), 4 (for N=1), 9 (for N=2), or 16 (for N=3) 10 | case sphericalHarmonic([SIMD3]) 11 | case linearFloat(SIMD3) 12 | case linearFloat256(SIMD3) 13 | case linearUInt8(SIMD3) 14 | 15 | private static func primarySphericalHarmonicToLinear(_ sh0: SIMD3) -> SIMD3 { 16 | SIMD3(x: (0.5 + Self.SH_C0 * sh0.x).clamped(to: 0...1), 17 | y: (0.5 + Self.SH_C0 * sh0.y).clamped(to: 0...1), 18 | z: (0.5 + Self.SH_C0 * sh0.z).clamped(to: 0...1)) 19 | } 20 | 21 | private static func linearToPrimarySphericalHarmonic(_ values: SIMD3) -> SIMD3 { 22 | (values - 0.5) * Self.INV_SH_C0 23 | } 24 | 25 | public var asSphericalHarmonic: [SIMD3] { 26 | switch self { 27 | case let .sphericalHarmonic(values): 28 | values 29 | case let .linearFloat(values): 30 | [Self.linearToPrimarySphericalHarmonic(values)] 31 | case let .linearFloat256(values): 32 | [Self.linearToPrimarySphericalHarmonic(values / 256)] 33 | case let .linearUInt8(values): 34 | [Self.linearToPrimarySphericalHarmonic(values.asFloat / 255)] 35 | } 36 | } 37 | 38 | public var asLinearFloat: SIMD3 { 39 | switch self { 40 | case let .sphericalHarmonic(sh): 41 | Self.primarySphericalHarmonicToLinear(sh[0]) 42 | case let .linearFloat(value): 43 | value 44 | case let .linearFloat256(value): 45 | value / 256 46 | case let .linearUInt8(value): 47 | value.asFloat / 255 48 | } 49 | } 50 | 51 | public var asLinearFloat256: SIMD3 { 52 | switch self { 53 | case let .sphericalHarmonic(sh): 54 | Self.primarySphericalHarmonicToLinear(sh[0]) * 256 55 | case let .linearFloat(value): 56 | value * 256 57 | case let .linearFloat256(value): 58 | value 59 | case let .linearUInt8(value): 60 | value.asFloat 61 | } 62 | } 63 | 64 | public var asLinearUInt8: SIMD3 { 65 | switch self { 66 | case let .sphericalHarmonic(sh): 67 | (Self.primarySphericalHarmonicToLinear(sh[0]) * 256).asUInt8 68 | case let .linearFloat(value): 69 | (value * 256).asUInt8 70 | case let .linearFloat256(value): 71 | value.asUInt8 72 | case let .linearUInt8(value): 73 | value 74 | } 75 | } 76 | } 77 | 78 | public enum Opacity { 79 | case logitFloat(Float) 80 | case linearFloat(Float) 81 | case linearUInt8(UInt8) 82 | 83 | static func sigmoid(_ value: Float) -> Float { 84 | 1 / (1 + exp(-value)) 85 | } 86 | 87 | // Inverse sigmoid 88 | static func logit(_ value: Float) -> Float { 89 | log(value / (1 - value)) 90 | } 91 | 92 | public var asLogitFloat: Float { 93 | switch self { 94 | case let .logitFloat(value): 95 | value 96 | case let .linearFloat(value): 97 | Self.logit(value) 98 | case let .linearUInt8(value): 99 | Self.logit((Float(value) + 0.5) / 256) // logit(0) and logit(1) are undefined; so map them to >0 and <1 100 | } 101 | } 102 | 103 | public var asLinearFloat: Float { 104 | switch self { 105 | case let .logitFloat(value): 106 | Self.sigmoid(value) 107 | case let .linearFloat(value): 108 | value 109 | case let .linearUInt8(value): 110 | Float(value) / 255.0 111 | } 112 | } 113 | 114 | public var asLinearUInt8: UInt8 { 115 | switch self { 116 | case let .logitFloat(value): 117 | (Self.sigmoid(value) * 256).asUInt8 118 | case let .linearFloat(value): 119 | (value * 256).asUInt8 120 | case let .linearUInt8(value): 121 | value 122 | } 123 | } 124 | } 125 | 126 | public enum Scale { 127 | case exponent(SIMD3) 128 | case linearFloat(SIMD3) 129 | 130 | public var asExponent: SIMD3 { 131 | switch self { 132 | case let .exponent(value): 133 | value 134 | case let .linearFloat(value): 135 | log(value) 136 | } 137 | } 138 | 139 | public var asLinearFloat: SIMD3 { 140 | switch self { 141 | case let .exponent(value): 142 | exp(value) 143 | case let .linearFloat(value): 144 | value 145 | } 146 | } 147 | } 148 | 149 | public var position: SIMD3 150 | public var color: Color 151 | public var opacity: Opacity 152 | public var scale: Scale 153 | public var rotation: simd_quatf 154 | 155 | public init(position: SIMD3, 156 | color: Color, 157 | opacity: Opacity, 158 | scale: Scale, 159 | rotation: simd_quatf) { 160 | self.position = position 161 | self.color = color 162 | self.opacity = opacity 163 | self.scale = scale 164 | self.rotation = rotation 165 | } 166 | 167 | var linearNormalized: SplatScenePoint { 168 | SplatScenePoint(position: position, 169 | color: .linearFloat(color.asLinearFloat), 170 | opacity: .linearFloat(opacity.asLinearFloat), 171 | scale: .linearFloat(scale.asLinearFloat), 172 | rotation: rotation.normalized) 173 | } 174 | } 175 | 176 | fileprivate extension SIMD3 where Scalar == Float { 177 | var asUInt8: SIMD3 { 178 | SIMD3(x: x.asUInt8, y: y.asUInt8, z: z.asUInt8) 179 | } 180 | } 181 | 182 | fileprivate extension SIMD3 where Scalar == UInt8 { 183 | var asFloat: SIMD3 { 184 | SIMD3(x: Float(x), y: Float(y), z: Float(z)) 185 | } 186 | } 187 | 188 | fileprivate extension Float { 189 | var asUInt8: UInt8 { 190 | UInt8(clamped(to: 0.0...255.0)) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /SplatIO/Sources/SplatSceneReader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SplatSceneReaderDelegate: AnyObject { 4 | func didStartReading(withPointCount pointCount: UInt32?) 5 | func didRead(points: [SplatScenePoint]) 6 | func didFinishReading() 7 | func didFailReading(withError error: Error?) 8 | } 9 | 10 | public protocol SplatSceneReader { 11 | func read(to delegate: SplatSceneReaderDelegate) 12 | } 13 | -------------------------------------------------------------------------------- /SplatIO/Sources/SplatSceneWriter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SplatSceneWriter { 4 | func write(_ points: [SplatScenePoint]) throws 5 | func close() throws 6 | } 7 | -------------------------------------------------------------------------------- /SplatIO/TestData/test-splat.3-points-from-train.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scier/MetalSplatter/ec6d0f2cdee958c9617925eda474362869cbf3de/SplatIO/TestData/test-splat.3-points-from-train.ply -------------------------------------------------------------------------------- /SplatIO/TestData/test-splat.3-points-from-train.splat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scier/MetalSplatter/ec6d0f2cdee958c9617925eda474362869cbf3de/SplatIO/TestData/test-splat.3-points-from-train.splat -------------------------------------------------------------------------------- /SplatIO/Tests/SplatIOTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Spatial 3 | import SplatIO 4 | 5 | final class SplatIOTests: XCTestCase { 6 | class ContentCounter: SplatSceneReaderDelegate { 7 | var expectedPointCount: UInt32? 8 | var pointCount: UInt32 = 0 9 | var didFinish = false 10 | var didFail = false 11 | 12 | func reset() { 13 | expectedPointCount = nil 14 | pointCount = 0 15 | didFinish = false 16 | didFail = false 17 | } 18 | 19 | func didStartReading(withPointCount pointCount: UInt32?) { 20 | XCTAssertNil(expectedPointCount) 21 | XCTAssertFalse(didFinish) 22 | XCTAssertFalse(didFail) 23 | expectedPointCount = pointCount 24 | } 25 | 26 | func didRead(points: [SplatIO.SplatScenePoint]) { 27 | pointCount += UInt32(points.count) 28 | } 29 | 30 | func didFinishReading() { 31 | XCTAssertFalse(didFinish) 32 | XCTAssertFalse(didFail) 33 | didFinish = true 34 | } 35 | 36 | func didFailReading(withError error: Error?) { 37 | XCTAssertFalse(didFinish) 38 | XCTAssertFalse(didFail) 39 | didFail = true 40 | } 41 | } 42 | 43 | class ContentStorage: SplatSceneReaderDelegate { 44 | var points: [SplatIO.SplatScenePoint] = [] 45 | var didFinish = false 46 | var didFail = false 47 | 48 | func reset() { 49 | points = [] 50 | didFinish = false 51 | didFail = false 52 | } 53 | 54 | func didStartReading(withPointCount pointCount: UInt32?) { 55 | XCTAssertTrue(points.isEmpty) 56 | XCTAssertFalse(didFinish) 57 | XCTAssertFalse(didFail) 58 | } 59 | 60 | func didRead(points: [SplatScenePoint]) { 61 | self.points.append(contentsOf: points) 62 | } 63 | 64 | func didFinishReading() { 65 | XCTAssertFalse(didFinish) 66 | XCTAssertFalse(didFail) 67 | didFinish = true 68 | } 69 | 70 | func didFailReading(withError error: Error?) { 71 | XCTAssertFalse(didFinish) 72 | XCTAssertFalse(didFail) 73 | didFail = true 74 | } 75 | 76 | static func testApproximatelyEqual(lhs: ContentStorage, rhs: ContentStorage) { 77 | XCTAssertEqual(lhs.points.count, rhs.points.count, "Same number of points") 78 | for (lhsPoint, rhsPoint) in zip(lhs.points, rhs.points) { 79 | XCTAssertTrue(lhsPoint ~= rhsPoint) 80 | } 81 | } 82 | } 83 | 84 | let plyURL = Bundle.module.url(forResource: "test-splat.3-points-from-train", withExtension: "ply", subdirectory: "TestData")! 85 | let dotSplatURL = Bundle.module.url(forResource: "test-splat.3-points-from-train", withExtension: "splat", subdirectory: "TestData")! 86 | 87 | func testReadPLY() throws { 88 | try testRead(plyURL) 89 | } 90 | 91 | func textReadDotSplat() throws { 92 | try testRead(dotSplatURL) 93 | } 94 | 95 | func testFormatsEqual() throws { 96 | try testEqual(plyURL, dotSplatURL) 97 | } 98 | 99 | func testRewritePLY() throws { 100 | try testReadWriteRead(plyURL, writePLY: true) 101 | try testReadWriteRead(plyURL, writePLY: false) 102 | } 103 | 104 | func testRewriteDotSplat() throws { 105 | try testReadWriteRead(dotSplatURL, writePLY: true) 106 | try testReadWriteRead(dotSplatURL, writePLY: false) 107 | } 108 | 109 | func testEqual(_ urlA: URL, _ urlB: URL) throws { 110 | let readerA = try AutodetectSceneReader(urlA) 111 | let contentA = ContentStorage() 112 | readerA.read(to: contentA) 113 | 114 | let readerB = try AutodetectSceneReader(urlB) 115 | let contentB = ContentStorage() 116 | readerB.read(to: contentB) 117 | 118 | ContentStorage.testApproximatelyEqual(lhs: contentA, rhs: contentB) 119 | } 120 | 121 | func testReadWriteRead(_ url: URL, writePLY: Bool) throws { 122 | let readerA = try AutodetectSceneReader(url) 123 | let contentA = ContentStorage() 124 | readerA.read(to: contentA) 125 | 126 | let memoryOutput = DataOutputStream() 127 | memoryOutput.open() 128 | let writer: any SplatSceneWriter 129 | switch writePLY { 130 | case true: 131 | let plyWriter = SplatPLYSceneWriter(memoryOutput) 132 | try plyWriter.start(pointCount: contentA.points.count) 133 | writer = plyWriter 134 | case false: 135 | writer = DotSplatSceneWriter(memoryOutput) 136 | } 137 | try writer.write(contentA.points) 138 | 139 | let memoryInput = InputStream(data: memoryOutput.data) 140 | memoryInput.open() 141 | 142 | let readerB: any SplatSceneReader = writePLY ? SplatPLYSceneReader(memoryInput) : DotSplatSceneReader(memoryInput) 143 | let contentB = ContentStorage() 144 | readerB.read(to: contentB) 145 | 146 | ContentStorage.testApproximatelyEqual(lhs: contentA, rhs: contentB) 147 | } 148 | 149 | func testRead(_ url: URL) throws { 150 | let reader = try AutodetectSceneReader(url) 151 | 152 | let content = ContentCounter() 153 | reader.read(to: content) 154 | XCTAssertTrue(content.didFinish) 155 | XCTAssertFalse(content.didFail) 156 | if let expectedPointCount = content.expectedPointCount { 157 | XCTAssertEqual(expectedPointCount, content.pointCount) 158 | } 159 | } 160 | } 161 | 162 | extension SplatScenePoint { 163 | enum Tolerance { 164 | static let position: Float = 1e-10 165 | static let color: Float = 1.0 / 256 166 | static let opacity: Float = 1.0 / 256 167 | static let scale: Float = 1e-10 168 | static let rotation: Float = 2.0 / 128 169 | } 170 | 171 | public static func ~= (lhs: SplatScenePoint, rhs: SplatScenePoint) -> Bool { 172 | (lhs.position - rhs.position).isWithin(tolerance: Tolerance.position) && 173 | lhs.color ~= rhs.color && 174 | lhs.opacity ~= rhs.opacity && 175 | lhs.scale ~= rhs.scale && 176 | (lhs.rotation.normalized.vector - rhs.rotation.normalized.vector).isWithin(tolerance: Tolerance.rotation) 177 | } 178 | } 179 | 180 | extension SplatScenePoint.Color { 181 | public static func ~= (lhs: SplatScenePoint.Color, rhs: SplatScenePoint.Color) -> Bool { 182 | (lhs.asLinearFloat - rhs.asLinearFloat).isWithin(tolerance: SplatScenePoint.Tolerance.color) 183 | } 184 | } 185 | 186 | extension SplatScenePoint.Opacity { 187 | public static func ~= (lhs: SplatScenePoint.Opacity, rhs: SplatScenePoint.Opacity) -> Bool { 188 | abs(lhs.asLinearFloat - rhs.asLinearFloat) <= SplatScenePoint.Tolerance.opacity 189 | } 190 | } 191 | 192 | extension SplatScenePoint.Scale { 193 | public static func ~= (lhs: SplatScenePoint.Scale, rhs: SplatScenePoint.Scale) -> Bool { 194 | (lhs.asLinearFloat - rhs.asLinearFloat).isWithin(tolerance: SplatScenePoint.Tolerance.scale) 195 | } 196 | } 197 | 198 | extension SIMD3 where Scalar: Comparable & SignedNumeric { 199 | public func isWithin(tolerance: Scalar) -> Bool { 200 | abs(x) <= tolerance && abs(y) <= tolerance && abs(z) <= tolerance 201 | } 202 | } 203 | 204 | extension SIMD4 where Scalar: Comparable & SignedNumeric { 205 | public func isWithin(tolerance: Scalar) -> Bool { 206 | abs(x) <= tolerance && abs(y) <= tolerance && abs(z) <= tolerance && abs(w) <= tolerance 207 | } 208 | } 209 | 210 | private class DataOutputStream: OutputStream { 211 | var data = Data() 212 | 213 | override func open() {} 214 | override func close() {} 215 | override var hasSpaceAvailable: Bool { true } 216 | 217 | override func write(_ buffer: UnsafePointer, maxLength length: Int) -> Int { 218 | data.append(buffer, count: length) 219 | return length 220 | } 221 | } 222 | 223 | private extension SIMD3 where Scalar == Float { 224 | var magnitude: Scalar { 225 | sqrt(x*x + y*y + z*z) 226 | } 227 | } 228 | 229 | private extension SIMD4 where Scalar == Float { 230 | var magnitude: Scalar { 231 | sqrt(x*x + y*y + z*z + w*w) 232 | } 233 | } 234 | --------------------------------------------------------------------------------