Skip to content

Commit 71ff248

Browse files
committed
SampleApp: Add Procedural Splat mode
- Three procedurally-generated RGB cubes, each with three variations - Shows how to use multiple chunks, and dynamically replace them
1 parent 32346b6 commit 71ff248

File tree

7 files changed

+197
-1
lines changed

7 files changed

+197
-1
lines changed

SampleApp/App/Constants.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,12 @@ enum Constants {
99
static let fovy = Angle(degrees: 65)
1010
#endif
1111
static let modelCenterZ: Float = -8
12+
13+
// Procedural splat geometry
14+
static let proceduralCubeSize: Float = 1.0
15+
static let proceduralCubeDistance: Float = 1.0
16+
static let proceduralCubeGridSizes: [Int] = [10, 20, 50]
17+
static let proceduralCubeSplatRelativeRadius: Float = 0.1
18+
static let proceduralCubeSwapDelay: TimeInterval = 2.0
1219
}
1320

SampleApp/MetalSplatter_SampleApp.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
61791E162B42297B00302B57 /* MetalKitSceneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61791E142B42294700302B57 /* MetalKitSceneView.swift */; };
2525
617E50C92B314C4800F99766 /* PLYIO in Frameworks */ = {isa = PBXBuildFile; productRef = 617E50C82B314C4800F99766 /* PLYIO */; };
2626
617E50CB2B314C4800F99766 /* SplatIO in Frameworks */ = {isa = PBXBuildFile; productRef = 617E50CA2B314C4800F99766 /* SplatIO */; };
27+
61E963922F46D321002AD4B5 /* ProceduralSplatController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E963912F46D321002AD4B5 /* ProceduralSplatController.swift */; };
2728
/* End PBXBuildFile section */
2829

2930
/* Begin PBXFileReference section */
@@ -42,6 +43,7 @@
4243
61791E112B416DCF00302B57 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
4344
61791E142B42294700302B57 /* MetalKitSceneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetalKitSceneView.swift; sourceTree = "<group>"; };
4445
617E50B02B314BE200F99766 /* MetalSplatter SampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "MetalSplatter SampleApp.app"; sourceTree = BUILT_PRODUCTS_DIR; };
46+
61E963912F46D321002AD4B5 /* ProceduralSplatController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProceduralSplatController.swift; sourceTree = "<group>"; };
4547
/* End PBXFileReference section */
4648

4749
/* Begin PBXFrameworksBuildPhase section */
@@ -62,6 +64,7 @@
6264
610347DF2B4689DB00693CE3 /* Model */ = {
6365
isa = PBXGroup;
6466
children = (
67+
61E963912F46D321002AD4B5 /* ProceduralSplatController.swift */,
6568
61791DDB2B4154FB00302B57 /* ModelIdentifier.swift */,
6669
61791DDE2B4154FB00302B57 /* ModelRenderer.swift */,
6770
61791DDC2B4154FB00302B57 /* SampleBoxRenderer+ModelRenderer.swift */,
@@ -212,6 +215,7 @@
212215
610347DB2B46875700693CE3 /* VisionSceneRenderer.swift in Sources */,
213216
610347D92B46864500693CE3 /* SampleApp.swift in Sources */,
214217
61791E102B41688000302B57 /* MetalKitSceneRenderer.swift in Sources */,
218+
61E963922F46D321002AD4B5 /* ProceduralSplatController.swift in Sources */,
215219
61791DE02B4154FB00302B57 /* SplatRenderer+ModelRenderer.swift in Sources */,
216220
61791DE82B4154FB00302B57 /* ModelRenderer.swift in Sources */,
217221
61791DCF2B41544300302B57 /* Assets.xcassets in Sources */,

SampleApp/Model/ModelIdentifier.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import Foundation
22

33
enum ModelIdentifier: Equatable, Hashable, Codable, CustomStringConvertible {
44
case gaussianSplat(URL)
5+
case proceduralSplat
56
case sampleBox
67

78
var description: String {
89
switch self {
910
case .gaussianSplat(let url):
1011
"Gaussian Splat: \(url.path)"
12+
case .proceduralSplat:
13+
"Procedural Splat"
1114
case .sampleBox:
1215
"Sample Box"
1316
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import Foundation
2+
import Metal
3+
import MetalSplatter
4+
import simd
5+
import SplatIO
6+
7+
/// Controller that procedurally generates colored splat cubes and cycles through LOD levels.
8+
/// Demonstrates adding/removing/enabling/disabling chunks on a live SplatRenderer.
9+
final class ProceduralSplatController: @unchecked Sendable {
10+
let splatRenderer: SplatRenderer
11+
12+
// 3 colors × 3 LOD levels
13+
private let chunks: [[SplatChunk]] // [colorIndex][lodIndex]
14+
private var activeChunkIDs: [ChunkID] // One per color (3 total)
15+
16+
// Swap cycle state
17+
private var cycleStep = 0
18+
private var cycleTask: Task<Void, Never>?
19+
20+
private static let colors: [SIMD3<UInt8>] = [
21+
SIMD3(255, 0, 0), // Red
22+
SIMD3(0, 255, 0), // Green
23+
SIMD3(0, 0, 255), // Blue
24+
]
25+
26+
init(device: MTLDevice,
27+
colorFormat: MTLPixelFormat,
28+
depthFormat: MTLPixelFormat,
29+
sampleCount: Int,
30+
maxViewCount: Int,
31+
maxSimultaneousRenders: Int) async throws {
32+
splatRenderer = try SplatRenderer(device: device,
33+
colorFormat: colorFormat,
34+
depthFormat: depthFormat,
35+
sampleCount: sampleCount,
36+
maxViewCount: maxViewCount,
37+
maxSimultaneousRenders: maxSimultaneousRenders)
38+
39+
// Generate cube centers at 120° intervals in the XZ plane
40+
let centers: [SIMD3<Float>] = (0..<3).map { i in
41+
let angle = Float(i) * (2 * .pi / 3)
42+
return SIMD3(
43+
cos(angle) * Constants.proceduralCubeDistance,
44+
0,
45+
sin(angle) * Constants.proceduralCubeDistance
46+
)
47+
}
48+
49+
// Generate 3 colors × 3 LOD grid sizes
50+
var allChunks: [[SplatChunk]] = []
51+
for colorIndex in 0..<3 {
52+
var lodChunks: [SplatChunk] = []
53+
for gridSize in Constants.proceduralCubeGridSizes {
54+
let chunk = try Self.generateCubeChunk(
55+
device: device,
56+
center: centers[colorIndex],
57+
size: Constants.proceduralCubeSize,
58+
gridSize: gridSize,
59+
color: Self.colors[colorIndex]
60+
)
61+
lodChunks.append(chunk)
62+
}
63+
allChunks.append(lodChunks)
64+
}
65+
chunks = allChunks
66+
67+
// Add initial chunks (LOD 0) as enabled
68+
var initialIDs: [ChunkID] = []
69+
for colorIndex in 0..<3 {
70+
let id = await splatRenderer.addChunk(chunks[colorIndex][0], enabled: true)
71+
initialIDs.append(id)
72+
}
73+
activeChunkIDs = initialIDs
74+
}
75+
76+
/// Generate a cube of splats arranged in a uniform grid.
77+
private static func generateCubeChunk(
78+
device: MTLDevice,
79+
center: SIMD3<Float>,
80+
size: Float,
81+
gridSize: Int,
82+
color: SIMD3<UInt8>
83+
) throws -> SplatChunk {
84+
let count = gridSize * gridSize * gridSize
85+
let spacing = size / Float(gridSize)
86+
let splatScale = spacing * Constants.proceduralCubeSplatRelativeRadius
87+
let halfSize = size / 2
88+
89+
var points: [SplatPoint] = []
90+
points.reserveCapacity(count)
91+
92+
for ix in 0..<gridSize {
93+
for iy in 0..<gridSize {
94+
for iz in 0..<gridSize {
95+
let position = center + SIMD3(
96+
-halfSize + (Float(ix) + 0.5) * spacing,
97+
-halfSize + (Float(iy) + 0.5) * spacing,
98+
-halfSize + (Float(iz) + 0.5) * spacing
99+
)
100+
let point = SplatPoint(
101+
position: position,
102+
color: .sRGBUInt8(color),
103+
opacity: .linearFloat(1.0),
104+
scale: .linearFloat(SIMD3(repeating: splatScale)),
105+
rotation: simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)
106+
)
107+
points.append(point)
108+
}
109+
}
110+
}
111+
112+
return try SplatChunk(device: device, from: points)
113+
}
114+
115+
/// Called each frame to advance the LOD swap cycle.
116+
func update() {
117+
guard cycleTask == nil else { return }
118+
119+
let colorIndex = cycleStep % 3
120+
let targetLOD = (cycleStep / 3 + 1) % 3
121+
let oldChunkID = activeChunkIDs[colorIndex]
122+
let newChunk = chunks[colorIndex][targetLOD]
123+
let renderer = splatRenderer
124+
125+
cycleTask = Task {
126+
// Phase 1: Add new chunk (disabled), then wait for a sort that includes it
127+
let newID = await renderer.addChunk(newChunk, enabled: false)
128+
await withCheckedContinuation { continuation in
129+
renderer.afterNextSort {
130+
continuation.resume()
131+
}
132+
}
133+
134+
// Phase 2: Enable new chunk and remove old chunk atomically
135+
await renderer.withChunkAccess {
136+
await renderer.setChunkEnabled(newID, enabled: true)
137+
await renderer.removeChunk(oldChunkID)
138+
}
139+
self.activeChunkIDs[colorIndex] = newID
140+
141+
try? await Task.sleep(for: .seconds(Constants.proceduralCubeSwapDelay))
142+
143+
// Advance to next step
144+
self.cycleStep = (self.cycleStep + 1) % 9
145+
self.cycleTask = nil
146+
}
147+
}
148+
149+
}

SampleApp/Scene/ContentView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,14 @@ struct ContentView: View {
8585
}
8686
}
8787

88-
Spacer()
88+
Button("Procedural Splat") {
89+
openWindow(value: ModelIdentifier.proceduralSplat)
90+
}
91+
.padding()
92+
.buttonStyle(.borderedProminent)
93+
#if os(visionOS)
94+
.disabled(immersiveSpaceIsShown)
95+
#endif
8996

9097
Button("Show Sample Box") {
9198
openWindow(value: ModelIdentifier.sampleBox)

SampleApp/Scene/MetalKitSceneRenderer.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
2121

2222
var model: ModelIdentifier?
2323
var modelRenderer: (any ModelRenderer)?
24+
var proceduralSplatController: ProceduralSplatController?
2425

2526
let inFlightSemaphore = DispatchSemaphore(value: Constants.maxSimultaneousRenders)
2627

@@ -45,6 +46,7 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
4546
self.model = model
4647

4748
modelRenderer = nil
49+
proceduralSplatController = nil
4850
switch model {
4951
case .gaussianSplat(let url):
5052
let splat = try SplatRenderer(device: device,
@@ -58,6 +60,16 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
5860
let chunk = try SplatChunk(device: device, from: points)
5961
await splat.addChunk(chunk)
6062
modelRenderer = splat
63+
case .proceduralSplat:
64+
let controller = try await ProceduralSplatController(
65+
device: device,
66+
colorFormat: metalKitView.colorPixelFormat,
67+
depthFormat: metalKitView.depthStencilPixelFormat,
68+
sampleCount: metalKitView.sampleCount,
69+
maxViewCount: 1,
70+
maxSimultaneousRenders: Constants.maxSimultaneousRenders)
71+
proceduralSplatController = controller
72+
modelRenderer = controller.splatRenderer
6173
case .sampleBox:
6274
modelRenderer = try! SampleBoxRenderer(device: device,
6375
colorFormat: metalKitView.colorPixelFormat,
@@ -118,6 +130,7 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
118130
}
119131

120132
updateRotation()
133+
proceduralSplatController?.update()
121134

122135
let didRender: Bool
123136
do {

SampleApp/Scene/VisionSceneRenderer.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ final class VisionSceneRenderer: @unchecked Sendable {
3232

3333
private var model: ModelIdentifier?
3434
private var modelRenderer: (any ModelRenderer)?
35+
private var proceduralSplatController: ProceduralSplatController?
3536

3637
let inFlightSemaphore = DispatchSemaphore(value: Constants.maxSimultaneousRenders)
3738

@@ -68,6 +69,7 @@ final class VisionSceneRenderer: @unchecked Sendable {
6869
self.model = model
6970

7071
modelRenderer = nil
72+
proceduralSplatController = nil
7173
switch model {
7274
case .gaussianSplat(let url):
7375
let splat = try SplatRenderer(device: device,
@@ -81,6 +83,16 @@ final class VisionSceneRenderer: @unchecked Sendable {
8183
let chunk = try SplatChunk(device: device, from: points)
8284
await splat.addChunk(chunk)
8385
modelRenderer = splat
86+
case .proceduralSplat:
87+
let controller = try await ProceduralSplatController(
88+
device: device,
89+
colorFormat: layerRenderer.configuration.colorFormat,
90+
depthFormat: layerRenderer.configuration.depthFormat,
91+
sampleCount: 1,
92+
maxViewCount: layerRenderer.properties.viewCount,
93+
maxSimultaneousRenders: Constants.maxSimultaneousRenders)
94+
proceduralSplatController = controller
95+
modelRenderer = controller.splatRenderer
8496
case .sampleBox:
8597
modelRenderer = try SampleBoxRenderer(device: device,
8698
colorFormat: layerRenderer.configuration.colorFormat,
@@ -170,6 +182,7 @@ final class VisionSceneRenderer: @unchecked Sendable {
170182
frame.startSubmission()
171183

172184
updateRotation()
185+
proceduralSplatController?.update()
173186

174187
// Use first drawable for timing/anchor calculations
175188
let primaryDrawable = drawables[0]

0 commit comments

Comments
 (0)