Rendering Gaussian splats with RealityKit
At a glance
| Item | Summary |
|---|---|
| Purpose | Load a Gaussian splat from a bundled PLY or USD file into LowLevelBuffers and render it with GaussianSplatComponent. On visionOS it appears in a mixed immersive space. On iOS and macOS it appears in a window. |
| App architecture | One SwiftUI app with a small @Observable AppModel (loadedAsset, immersiveSpaceState). Compile-time #if os(...) picks the presentation: SpatialSplatView in an ImmersiveSpace on visionOS, or WindowSplatView inside ContentView on iOS and macOS. Both views call the same free-function loaders and entity builder. |
| Main patterns | SwiftUI environment injection; observable state; SwiftUI–RealityKit bridge; platform-conditional view variants |
| Project style | One multiplatform target. Ten flat Swift files in RealityKit-GaussianSplats/. Platform-only files are wrapped entirely in #if. |
| Execution model | The target defaults to main-actor isolation. Parsing runs inside an awaited Task.detached that calls nonisolated functions. Entity construction is @MainActor. ARKit anchor updates are consumed by a SwiftUI .task. |
| State/event model | Swift Observation on AppModel. .task(id: appModel.loadedAsset) triggers loads. RealityView update: syncs @State splatEntity into the entity graph. A retained EventSubscription handles manipulation release. |
| Key frameworks/packages | RealityKit, SwiftUI, ARKit (visionOS only), USDKit, Metal (device builds), Foundation, os. No package manifest. |
Platform boundary: Apple’s documentation page lists Mac Catalyst. The verified project declares SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator" and has no Mac Catalyst setting (RealityKit-GaussianSplats.xcodeproj/project.pbxproj:317). The code branches on os(macOS) || os(iOS) (RealityKit-GaussianSplats/WindowSplatView.swift:15). Front-matter platforms follow the official listing. Every macOS statement below is grounded in the native macosx destination.
Project structure
RealityKit-GaussianSplats/
├── RealityKitGaussianSplatsApp.swift // scenes; ImmersiveSpace only on visionOS
├── AppModel.swift // @Observable shared state
├── ContentView.swift // two #if variants (visionOS / iOS+macOS)
├── SpatialSplatView.swift // #if os(visionOS): immersive splat + snapping
├── SceneReconstructionManager.swift // #if os(visionOS): ARKit meshes → collision
├── WindowSplatView.swift // #if os(macOS) || os(iOS): turntable window
├── PLYLoader.swift // loadPLYEntity (Task.detached)
├── PLYParser.swift // nonisolated PLY parse + deinterleave
├── USDLoader.swift // loadUSDEntity via USDKit (Task.detached)
└── SplatEntityBuilder.swift // GaussianSplatBuffers, makeSplatEntity
RealityKit-GaussianSplats.xcodeproj/
Configuration/SampleCode.xcconfig
Structure observations
- The files fall into two layers. Presentation is
App,ContentView,SpatialSplatView, andWindowSplatView. Loading isPLYLoader,PLYParser,USDLoader, andSplatEntityBuilder. The loading layer has no types beyond value structs: its API is free functions such asloadPLYEntity(assetName:)andmakeSplatEntity(from:isLinear:). - Platform separation happens at the file level.
SpatialSplatView.swiftandSceneReconstructionManager.swiftare entirely inside#if os(visionOS).WindowSplatView.swiftis entirely inside#if os(macOS) || os(iOS)(RealityKit-GaussianSplats/SpatialSplatView.swift:16,RealityKit-GaussianSplats/SceneReconstructionManager.swift:13,RealityKit-GaussianSplats/WindowSplatView.swift:15).ContentView.swiftholds two completeContentViewdeclarations separated by#else(RealityKit-GaussianSplats/ContentView.swift:13,RealityKit-GaussianSplats/ContentView.swift:67). - Simulator builds compile throwing stubs instead of the GPU path:
makeSplatEntityandloadUSDEntitysit behind#if targetEnvironment(simulator)(RealityKit-GaussianSplats/SplatEntityBuilder.swift:43,RealityKit-GaussianSplats/USDLoader.swift:25). - The project uses
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActorwithSWIFT_VERSION = 5.0(RealityKit-GaussianSplats.xcodeproj/project.pbxproj:319,RealityKit-GaussianSplats.xcodeproj/project.pbxproj:322). This is why parser functions are markednonisolated.
Overall architecture
flowchart LR
App["RealityKitGaussianSplatsApp"] -->|environment| Model["AppModel"]
CV["ContentView"] -->|sets loadedAsset| Model
Model -->|task id| Spatial["SpatialSplatView (visionOS)"]
Model -->|task id| Window["WindowSplatView (iOS, macOS)"]
Spatial --> Loaders["loadPLYEntity / loadUSDEntity"]
Window --> Loaders
Loaders --> Builder["makeSplatEntity"]
Spatial --> Scene["SceneReconstructionManager"]
Reference code
RealityKit-GaussianSplats/RealityKitGaussianSplatsApp.swift:13 — RealityKitGaussianSplatsApp
struct RealityKitGaussianSplatsApp: App {
@State private var appModel = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appModel)
}
// ...
#if os(visionOS)
ImmersiveSpace(id: appModel.immersiveSpaceId) {
SpatialSplatView()
.environment(appModel)
}
.immersionStyle(selection: .constant(.mixed), in: .mixed)
#endif
}
}Interpretation
Observation: buttons never call a loader directly. They set AppModel.loadedAsset. The visible splat view reacts with .task(id: appModel.loadedAsset), clears its current entity, and loads the new one (RealityKit-GaussianSplats/SpatialSplatView.swift:100, RealityKit-GaussianSplats/WindowSplatView.swift:100). On visionOS, ContentView.load(_:) first opens the immersive space and sets loadedAsset only after .opened (RealityKit-GaussianSplats/ContentView.swift:42).
Inference: one enum value is the whole contract between the control UI and the renderer. That lets both platform views reuse the same trigger without a shared view model.
Ownership and state
classDiagram
RealityKitGaussianSplatsApp *-- AppModel : @State
ContentView --> AppModel : @Environment, mutates loadedAsset
SpatialSplatView --> AppModel : @Environment, observes
SpatialSplatView *-- SpatialSplatViewCoordinator : @State
SpatialSplatViewCoordinator *-- SceneReconstructionManager : let sceneManager
SpatialSplatViewCoordinator *-- EventSubscription : var releaseSubscription
SceneReconstructionManager *-- ARKitSession : private let
WindowSplatView *-- WindowSplatViewCoordinator : @State
WindowSplatViewCoordinator *-- PerspectiveCamera : let cameraEntity
Ownership evidence
RealityKit-GaussianSplats/SpatialSplatView.swift:40 — state that must outlive view rebuilds
final class SpatialSplatViewCoordinator {
let rootEntity = Entity()
let sceneManager = SceneReconstructionManager()
let spotLight = Entity()
var releaseSubscription: EventSubscription?
var attachedSplat: Entity?
// ...
}RealityKit-GaussianSplats/SpatialSplatView.swift:81 — the view stores it in @State
struct SpatialSplatView: View {
@Environment(AppModel.self) private var appModel
@State private var coordinator = SpatialSplatViewCoordinator()
@State private var splatEntity: Entity?
// ...
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
RealityKitGaussianSplatsApp |
AppModel |
Creates in @State. Injects into both scenes (RealityKit-GaussianSplats/RealityKitGaussianSplatsApp.swift:14) |
— |
ContentView (both variants) |
AppModel.loadedAsset, .immersiveSpaceState |
Reads from the environment and assigns directly (RealityKit-GaussianSplats/ContentView.swift:56, RealityKit-GaussianSplats/ContentView.swift:81) |
ContentView is the only writer in source. |
SpatialSplatView |
SpatialSplatViewCoordinator, splatEntity |
@State. SwiftUI keeps them across body re-evaluation, as the doc comment says (RealityKit-GaussianSplats/SpatialSplatView.swift:34-38). |
The view’s syncSplat and loadAsset |
SpatialSplatViewCoordinator |
rootEntity, sceneManager, spotLight |
Creates as let (RealityKit-GaussianSplats/SpatialSplatView.swift:41-43) |
Immutable references. Entity transforms are mutated by the view. |
SpatialSplatViewCoordinator |
releaseSubscription: EventSubscription? |
Retains the manipulation-release subscription. Set to nil before re-subscribing (RealityKit-GaussianSplats/SpatialSplatView.swift:113, RealityKit-GaussianSplats/SpatialSplatView.swift:127) |
SpatialSplatView.syncSplat |
SceneReconstructionManager |
ARKitSession, SceneReconstructionProvider, meshEntities |
private stored state (RealityKit-GaussianSplats/SceneReconstructionManager.swift:38-40) |
The manager only |
WindowSplatView |
WindowSplatViewCoordinator, splatEntity, spin/tilt |
@State (RealityKit-GaussianSplats/WindowSplatView.swift:74-84) |
The drag gesture and syncSplat |
Note: the type names end in Coordinator, but the source describes them as holders for “RealityKit state that lives longer than one render of the view” (RealityKit-GaussianSplats/SpatialSplatView.swift:34, RealityKit-GaussianSplats/WindowSplatView.swift:52). They coordinate no navigation and no multi-object flow.
Concurrency, scheduling, and thread safety
Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution; Sendable conformance alone does not prove thread-safe mutation.
The target compiles with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (RealityKit-GaussianSplats.xcodeproj/project.pbxproj:319). Views, AppModel, both …Coordinator classes, SceneReconstructionManager, and the loader entry points are main-actor isolated, several also by explicit @MainActor. The parsing helpers opt out with nonisolated (RealityKit-GaussianSplats/PLYParser.swift:151, RealityKit-GaussianSplats/PLYParser.swift:285, RealityKit-GaussianSplats/USDLoader.swift:131).
| Concern | Source mechanism | Verified placement or handoff | Evidence |
|---|---|---|---|
| Main-actor isolation | @MainActor on AppModel, SceneReconstructionManager, …Coordinator, loadPLYEntity, loadUSDEntity, makeSplatEntity |
Entity creation, GaussianSplatComponent assembly, and model state run on the main actor. |
RealityKit-GaussianSplats/AppModel.swift:11, RealityKit-GaussianSplats/SplatEntityBuilder.swift:111 |
| Detached work | try await Task.detached(priority: .userInitiated) { ... }.value |
PLY read, header parse, and AoS→SoA deinterleave run in a detached task that calls only nonisolated synchronous functions. It does not inherit the main actor. No specific thread is established. The main-actor caller suspends until .value, then builds the entity. |
RealityKit-GaussianSplats/PLYLoader.swift:29 |
| Detached work (USD) | Same shape | USDStage.open, prim lookup, and buildBuffers run in a detached task. makeSplatEntity(from:isLinear: true) follows on the main actor. |
RealityKit-GaussianSplats/USDLoader.swift:41 |
| Transfer contract | struct GaussianSplatBuffers: @unchecked Sendable |
Not synchronization. The comment states the invariant: the loader writes the buffers in the background task, then nothing writes them after the handoff. The compiler does not check that invariant. | RealityKit-GaussianSplats/SplatEntityBuilder.swift:21 |
| Load trigger and cancellation | SwiftUI .task(id: appModel.loadedAsset) |
SwiftUI owns this task and restarts it when loadedAsset changes. The detached child is not structured, so cancelling the .task does not cancel parsing. loadAsset() has no cancellation check before assigning splatEntity. |
RealityKit-GaussianSplats/SpatialSplatView.swift:100, RealityKit-GaussianSplats/SpatialSplatView.swift:135 |
| Async sequence | .task { await coordinator.sceneManager.run() }, then for await update in provider.anchorUpdates |
The view-scoped task runs authorization, session.run, and the anchor loop. Each applyAnchor is awaited before the next update, so meshEntities is mutated serially on the main actor. |
RealityKit-GaussianSplats/SpatialSplatView.swift:104, RealityKit-GaussianSplats/SceneReconstructionManager.swift:60 |
| Event subscription | content.subscribe(to: ManipulationEvents.WillRelease.self, on:) |
The returned EventSubscription is stored in the coordinator and released before re-subscribing for a new splat. The handler calls @MainActor snapToNearestSurface. |
RealityKit-GaussianSplats/SpatialSplatView.swift:127, RealityKit-GaussianSplats/SpatialSplatView.swift:166 |
| UI tasks | Plain Task { await load(.ply) } in buttons |
Created in a main-actor view context. Not stored or cancelled. The buttons are disabled while .inTransition. |
RealityKit-GaussianSplats/ContentView.swift:30, RealityKit-GaussianSplats/ContentView.swift:36 |
No DispatchQueue, lock, or custom actor appears in the tree. Mutable shared state is limited to main-actor objects. The off-main work exchanges only freshly allocated buffers, returned through .value.
Inference on ordering: suppose the user switches assets while a parse is still running. The old .task is cancelled, but its detached parse keeps going, and the old loadAsset() can still assign splatEntity when it finishes. The source shows no guard against a slower earlier load finishing last.
Reference code
RealityKit-GaussianSplats/PLYLoader.swift:26 — loadPLYEntity(assetName:)
@MainActor
func loadPLYEntity(assetName: String) async throws -> Entity {
let url = try bundlePLYURL(assetName: assetName)
let buffers = try await Task.detached(priority: .userInitiated) {
let splatData = try readGaussianSplatFile(url)
return try deinterleaveGaussianSplatData(splatData)
}.value
return try makeSplatEntity(from: buffers)
}RealityKit-GaussianSplats/SpatialSplatView.swift:100 — view-scoped tasks
.task(id: appModel.loadedAsset) {
splatEntity = nil
await loadAsset()
}
.task {
await coordinator.sceneManager.run()
}State propagation, frameworks, and dependencies
Evidence limit: an import proves a source-level compilation dependency at the cited line; it does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.
| Category | Mechanism or module | Verified role | Evidence |
|---|---|---|---|
| State propagation | @Observable AppModel plus @Environment(AppModel.self) |
loadedAsset changes restart the splat view’s load task. immersiveSpaceState gates buttons. |
RealityKit-GaussianSplats/AppModel.swift:35, RealityKit-GaussianSplats/ContentView.swift:16 |
| State propagation | @State splatEntity → RealityView update: |
syncSplat compares by identity (===) and attaches or detaches only when the entity changed. |
RealityKit-GaussianSplats/SpatialSplatView.swift:109, RealityKit-GaussianSplats/WindowSplatView.swift:145 |
| State propagation | DragGesture → @State spin/tilt → update: |
The turntable rotation is recomputed from view state on each update. The tilt is clamped. | RealityKit-GaussianSplats/WindowSplatView.swift:121, RealityKit-GaussianSplats/WindowSplatView.swift:94 |
| Framework import | RealityKit, SwiftUI | All presentation files and loaders | RealityKit-GaussianSplats/SplatEntityBuilder.swift:10, RealityKit-GaussianSplats/AppModel.swift:9 |
| Framework import | ARKit, os | visionOS-only files | RealityKit-GaussianSplats/SceneReconstructionManager.swift:15, RealityKit-GaussianSplats/SpatialSplatView.swift:18 |
| Framework import | USDKit | USD prim and attribute reads | RealityKit-GaussianSplats/USDLoader.swift:11 |
| Framework import | Metal (non-simulator only) | MTLAttributeFormat for buffer descriptors |
RealityKit-GaussianSplats/SplatEntityBuilder.swift:52 |
No Combine import, notification, or package manifest appears in the tree.
Class and protocol design
classDiagram
class GaussianSplatBuffers {
<<struct, unchecked Sendable>>
}
PLYLoader --> GaussianSplatBuffers : deinterleave
USDLoader --> GaussianSplatBuffers : buildBuffers
GaussianSplatBuffers --> SplatEntityBuilder : makeSplatEntity
SplatEntityBuilder --> GaussianSplatComponent : assembles
Reference code
RealityKit-GaussianSplats/SplatEntityBuilder.swift:111 — the shared construction point
@MainActor
func makeSplatEntity(from buffers: GaussianSplatBuffers, isLinear: Bool = false) throws -> Entity {
let comp = try assembleSplatComponent(from: buffers, isLinear: isLinear)
let entity = Entity()
entity.components.set(comp)
return entity
}| Type or file | Responsibility | Depends on |
|---|---|---|
AppModel |
Asset selection and immersive-space phase | SwiftUI (Observation) |
ContentView (visionOS) |
Opens and dismisses the immersive space, then sets loadedAsset |
openImmersiveSpace, dismissImmersiveSpace |
ContentView (iOS, macOS) |
Hosts WindowSplatView with overlay buttons |
WindowSplatView |
SpatialSplatView + SpatialSplatViewCoordinator |
Immersive placement, spotlight, manipulation, raycast snap | SceneReconstructionManager, loaders |
WindowSplatView + WindowSplatViewCoordinator |
Virtual camera and turntable rotation in a window | loaders |
SceneReconstructionManager |
ARKit mesh anchors → static collision entities in collisionGroup |
ARKit, RealityKit |
GaussianSplatBuffers |
Immutable SoA GPU buffers handed from parser to builder | LowLevelBuffer |
PLYLoader / PLYParser, USDLoader |
Format-specific parsing into GaussianSplatBuffers |
Foundation, USDKit |
SplatEntityBuilder |
Descriptors, activations (exponential/sigmoid vs identity), entity |
RealityKit, Metal |
No app-defined protocol exists. The two loaders converge on one value type and one builder function instead of sharing a loader protocol.
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
bundlePLYURL, bundleUSDURL (RealityKit-GaussianSplats/PLYLoader.swift:15, RealityKit-GaussianSplats/USDLoader.swift:16) |
file-scope private |
Callable only within each loader file | Inference: the public surface per format is one load…Entity function. |
SplatAttributes, loadSplatAttributes, buildBuffers, fill helpers (RealityKit-GaussianSplats/USDLoader.swift:58, RealityKit-GaussianSplats/USDLoader.swift:131) |
file-scope private (private nonisolated func) |
USD parsing internals are invisible to other files. | Inference: keeps format details local. |
PLYHeader (RealityKit-GaussianSplats/PLYParser.swift:59) |
private nonisolated struct |
Header parsing is file-local. readGaussianSplatFile and deinterleaveGaussianSplatData stay implicit internal. |
Inference: only the two entry points cross files. |
SceneReconstructionManager.session, .provider, .meshEntities (RealityKit-GaussianSplats/SceneReconstructionManager.swift:38) |
private |
Only run() and applyAnchor touch the ARKit session and mesh map. rootEntity and collisionGroup are implicit internal for the view. |
Inference: expose the scene graph root and raycast mask, not the session. |
splatScale, spotLightIntensity, cameraDistance, tilt limits (RealityKit-GaussianSplats/SpatialSplatView.swift:30, RealityKit-GaussianSplats/WindowSplatView.swift:31) |
file-scope private let |
Tuning constants are duplicated per file, not shared. | Inference: each platform view tunes itself independently. |
ContentView.load(_:), close() (RealityKit-GaussianSplats/ContentView.swift:42, RealityKit-GaussianSplats/ContentView.swift:59) |
private |
Only the view body calls them. | Inference: the space transition is UI-local. |
…Coordinator stored properties (RealityKit-GaussianSplats/SpatialSplatView.swift:44) |
implicit internal var |
The owning view mutates attachedSplat and releaseSubscription directly. |
Inference: the holder is a passive state box for its view, not an encapsulated service. |
Reference code
RealityKit-GaussianSplats/SceneReconstructionManager.swift:29 — narrow internal surface
final class SceneReconstructionManager {
// ...
static let collisionGroup = CollisionGroup(rawValue: 1 << 2)
let rootEntity = Entity()
private let session = ARKitSession()
private let provider = SceneReconstructionProvider()
private var meshEntities: [UUID: Entity] = [:]
// ...
}Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Space open → then select asset | ContentView.load(_:) (visionOS) |
Sets loadedAsset only after .opened. Resets to .closed on cancel or error (RealityKit-GaussianSplats/ContentView.swift:43-56). |
| Asset → entity | SpatialSplatView.loadAsset / WindowSplatView.loadAsset |
Identical switch in both views (RealityKit-GaussianSplats/SpatialSplatView.swift:135, RealityKit-GaussianSplats/WindowSplatView.swift:157) |
| Parsing and buffer layout | PLYParser.swift, USDLoader.swift |
nonisolated pure functions, so they can run inside the detached task |
| GPU resource assembly | SplatEntityBuilder.swift |
One place that knows descriptor strides and activation choice (RealityKit-GaussianSplats/SplatEntityBuilder.swift:99-105) |
| Surface snapping | SpatialSplatView.snapToNearestSurface with SceneReconstructionManager.collisionGroup |
Raycasts up and down, picks the nearest hit, animates move(to:) (RealityKit-GaussianSplats/SpatialSplatView.swift:167-201) |
| Turntable math | WindowSplatView.turntableRotation |
Spin before tilt, tilt clamped to 8°–80° (RealityKit-GaussianSplats/WindowSplatView.swift:138) |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| SwiftUI environment injection | RealityKit-GaussianSplats/RealityKitGaussianSplatsApp.swift:19 and RealityKit-GaussianSplats/RealityKitGaussianSplatsApp.swift:33 |
One AppModel instance reaches the window and the immersive space. |
| Observable state | RealityKit-GaussianSplats/AppModel.swift:12 @Observable class AppModel |
A tiny shared model. loadedAsset is the trigger that restarts the view’s .task(id:). |
| SwiftUI–RealityKit bridge | RealityKit-GaussianSplats/SpatialSplatView.swift:94 (RealityView make/update plus @State holder) |
SwiftUI state (splatEntity, spin, tilt) is pushed into a persistent entity graph held in a reference type, so rebuilding the view doesn’t recreate entities. |
| Platform-conditional view variants | RealityKit-GaussianSplats/ContentView.swift:13 and RealityKit-GaussianSplats/WindowSplatView.swift:15 |
Same type name (ContentView) and shared loaders, with platform presentation chosen at compile time. The tradeoff is duplicated loadAsset and constants. |
Removed from the draft: Coordinator. The draft inferred it from the SpatialSplatViewCoordinator type name. The source documents these classes as state holders kept in @State across view rebuilds (RealityKit-GaussianSplats/SpatialSplatView.swift:34-38). They coordinate no navigation, flow, or collaborators.
Not claimed: Builder. SplatEntityBuilder.swift contains free functions that construct in one call, not an incremental builder object. Also not claimed: protocol-oriented design, since no app protocol exists.
Main application flow
sequenceDiagram
actor User
participant CV as ContentView
participant Model as AppModel
participant View as SpatialSplatView
participant Loader as loadPLYEntity
participant Detached as Task.detached
User->>CV: Load PLY
CV->>CV: openImmersiveSpace → .opened
CV->>Model: loadedAsset = .ply
Model-->>View: task(id:) restarts, splatEntity = nil
View->>Loader: await loadPLYEntity
Loader->>Detached: read + deinterleave
Detached-->>Loader: GaussianSplatBuffers
Loader-->>View: makeSplatEntity → Entity
View->>View: update: syncSplat attaches, subscribes WillRelease
Reference code
RealityKit-GaussianSplats/SpatialSplatView.swift:109 — syncSplat(into:)
private func syncSplat(into content: RealityViewContent) {
if coordinator.attachedSplat === splatEntity { return }
coordinator.attachedSplat?.removeFromParent()
coordinator.releaseSubscription = nil
coordinator.attachedSplat = nil
guard let splatEntity else { return }
configureManipulableObject(splatEntity)
// ...
coordinator.attachedSplat = splatEntity
coordinator.releaseSubscription = content.subscribe(
to: ManipulationEvents.WillRelease.self,
on: splatEntity
) { event in
snapToNearestSurface(event.entity)
}
}Naming conventions
- Types: the
Spatial…andWindow…prefixes name the presentation surface (immersive space or window), not the platform.…ViewCoordinatornames a view-owned reference holder (see the note above).…Managerowns an ARKit session.AppModelis the shared observable model.GaussianSplatBuffersandGaussianSplatDataname data shapes.GaussianSplatError.invalidData(String)is the single error case. - Functions:
load…Entity(assetName:)is the async loader entry point.make…builds a value (makeSplatEntity,makeDescriptor,makeSHDescriptor).read…,parse…,deinterleave…,fill…Buffer, andcopyPLYFieldare pipeline steps.configure…mutates an entity in place (configureSpotLight,configureManipulableObject).sync…reconciles state into the scene (syncSplat). - State:
immersiveSpaceStatewithclosed/inTransition/open.loadedAssetwithunloaded/ply/usd.settledSpin/spindistinguish the committed value from the in-drag value. - Files: one primary type or format per file.
SplatEntityBuilder.swiftnames a role but holds free functions. Rename from the earlier sample:SplatView→SpatialSplatView,SplatViewCoordinator→SpatialSplatViewCoordinator,GaussianSplatsOnVisionOSApp→RealityKitGaussianSplatsApp.
Architecture takeaways
- Use one observable enum (
loadedAsset) as the contract between controls and renderer..task(id:)turns each change into a restartable load without a dedicated view model. - Keep RealityKit objects in a reference type stored in
@State, and reconcile inRealityView’supdate:by identity. Rebuilding the view then never recreates entities or subscriptions. - Run CPU-heavy parsing as
nonisolatedfunctions inside an awaitedTask.detached, and hand back an immutable buffer struct.@unchecked Sendabledocuments an invariant, it doesn’t enforce one, and detached work isn’t cancelled with the caller. - Add platforms with whole-file
#ifboundaries and a same-namedContentViewper branch. The loading layer stays shared, at the cost of small duplicated view logic. - Read type suffixes against behavior. Here
Coordinatormeans “view-lifetime state holder”, not the Coordinator pattern.
Source map
| Source file | Relevant symbols |
|---|---|
RealityKit-GaussianSplats/RealityKitGaussianSplatsApp.swift |
RealityKitGaussianSplatsApp, ImmersiveSpace (visionOS) |
RealityKit-GaussianSplats/AppModel.swift |
AppModel, ImmersiveSpaceState, LoadedAsset |
RealityKit-GaussianSplats/ContentView.swift |
ContentView (two variants), load(_:), close() |
RealityKit-GaussianSplats/SpatialSplatView.swift |
SpatialSplatViewCoordinator, SpatialSplatView, syncSplat, snapToNearestSurface, SnapOffset |
RealityKit-GaussianSplats/WindowSplatView.swift |
WindowSplatViewCoordinator, WindowSplatView, dragGesture, turntableRotation |
RealityKit-GaussianSplats/SceneReconstructionManager.swift |
SceneReconstructionManager, run(), applyAnchor, collisionGroup |
RealityKit-GaussianSplats/PLYLoader.swift |
loadPLYEntity, Task.detached |
RealityKit-GaussianSplats/PLYParser.swift |
readGaussianSplatFile, deinterleaveGaussianSplatData, PLYHeader |
RealityKit-GaussianSplats/USDLoader.swift |
loadUSDEntity, buildBuffers, SplatAttributes |
RealityKit-GaussianSplats/SplatEntityBuilder.swift |
GaussianSplatBuffers, assembleSplatComponent, makeSplatEntity |
RealityKit-GaussianSplats.xcodeproj/project.pbxproj |
SUPPORTED_PLATFORMS, SWIFT_DEFAULT_ACTOR_ISOLATION, SWIFT_VERSION |