SplatScanner is iOS-only and needs a physical device: ARKit doesn’t run in the Simulator.
Install
Upgrading from
0.1.0, which shipped the module as SplatKit? The changelog lists each breaking change with its migration.1.0.0.
View scenes in 3D
The SDK downloads scenes and doesn’t draw them. In an iOS app you can draw a downloaded scene with SplatKit, an MIT-licensed Metal renderer by Xget7. Splat3D doesn’t depend on SplatKit, so add the SplatKit package to your app yourself. The example app pins it to the exact version0.1.0-beta.1, which the notes below describe.
SplatKit needs iOS 17 and a physical device with an A14 or M1 chip or newer, while the SDK itself supports iOS 16. Elsewhere, including the Simulator, SplatMetalView.isAvailable is false and the view stays blank.
SplatKit reads SPZ and doesn’t read SOG, downloadScene’s default, so ask for SPZ and hand the file to a SplatMetalView:
pause() when the app goes to the background and release() when the view goes away. An older scene, or one whose SPZ conversion failed, has no SPZ, and downloadScene throws SplatError.notFound for it.
SplatKit reads every SPZ as +Y down, the way World Labs files are laid out, and turns it half a turn about X. The Splat3D pipeline orients scenes +Y up, so they show upside down, and SplatKit has no setting to declare a file’s orientation. The example app works around it with a Flip toggle, on by default, that turns the view half a turn on screen:
SplatWorldView.swift in the example does this.
Some scenes still come out in the wrong orientation with Flip on, which is a known issue.
SplatKit refuses a version 4 SPZ that unpacks to more than 768 MiB, and scenes from the Splat3D API are version 4. SplatKit counts a splat as 236 bytes at the default spherical harmonics degree of 3, so the limit is about 3.4 million splats. A larger scene fails with the message “SPZ payload exceeds the decoded size ceiling”, which SplatKit passes to splatView(_:worldFailed:) if you set a SplatViewDelegate. Compare the scene’s numGaussians with the limit before you download it.
The SplatCapture example shows a full integration. SceneViewer.swift downloads the SPZ, and SplatWorldView.swift wraps SplatMetalView for SwiftUI with orbit and look controls and the flip.
API reference
SplatClient
public final class SplatClient: Sendable, created with init(apiKey:baseURL:session:pollingInterval:pollingTimeout:) or init(apiKey:baseURL:session:configuration:). baseURL defaults to https://api.splat-3d.com.
Configuration holds requestTimeout (60 s), pollingInterval (10 s), pollingTimeout (165 min) and maxRetries (3).
Every operation is async throws, except allScenes(pageSize:), which returns its sequence directly.
listScenes() is deprecated: it returns only the first page. preset takes .fast, .standard, .quality or .ultra (presets); as a SceneParams, .ultra also turns on LOD.
Reads and processScene retry network failures, 5xx and rate limits up to maxRetries times, waiting out a Retry-After of up to 60 s. A quota_exceeded 429 and an upstream_error are not retried, nor are create, retrain, update, delete and cancel. processScene sends Idempotency-Key: process-<scene ID> unless you pass one, so repeating it with the same inputs replays the launch instead of charging again.
Progress callbacks run on the main actor. pct is 0–100, or nil.
createAndProcess
presetis aSceneParams:.standardand the other presets, orSceneParams(preset:enableLOD:)for LOD below.ultra.onSceneCreatedgets the scene ID as soon as the scene exists, before the upload and before anything is charged. Save it to resume after an app restart.onProgressgets each status and progress, from the upload on.- Over 1,000 poses or 50,000 LiDAR points are thinned evenly. Fewer than 5 poses aren’t sent, and the pipeline solves poses itself.
SplatError.interrupted. These errors end the run instead, and no resume continues it:
resume
resume(_:)picks up wherecreateAndProcessstopped, with exactly the request it sent and the same key. The API replays a launch that went through, and refuses one whose upload never arrived before charging;resumethen uploads the same file again and relaunches. A stopped wait just waits again.resume(sceneID:…)takes the IDonSceneCreatedpassed and the same preset, poses and points. Each is required,nilincluded, so a restart can’t launch a different job. It can’t upload: if the upload never finished, the API refuses the launch (400) before charging, so start over.
.interrupted again if they stop before the outcome is known.
SplatScene
SceneStatus and ModelFormat
Both are open structs, RawRepresentable over String, not enums: a value this SDK doesn’t name still decodes, with its raw value. A switch over either needs a default.
SceneStatus:.uploading,.previewExtracting,.previewGenerating,.previewCompressing,.previewReady,.extractingFrames,.estimatingPoses,.runningSfm,.training,.exporting,.compressing,.processing,.complete,.failed,.cancelled.isTerminalis true for the last three.ModelFormat:.sog(downloadScene’s default),.plyand.spz.
SplatError
APIError has statusCode, code, message, requestID (quote it to support) and retryAfter. error.apiError returns it for any HTTP error, looking through .interrupted. Network failures throw URLError, and a cancelled task throws CancellationError.
Interruption has sceneID, phase (.upload, .launch or .wait, an open struct too), idempotencyKey, and underlying, the error that stopped it.
SplatScanner
start() runs an ARWorldTrackingConfiguration session, with mesh reconstruction on LiDAR devices (iPhone 12 Pro+, iPad Pro), and records H.264 video. stop() finishes the file; a failed recording throws .captureFailed.
ARKitPose
A single camera pose captured from ARKit. It’s Codable, so it can be saved with the scene ID for resume(sceneID:…).
ARFrame:
What ARKit poses do
GLOMAP still solves the camera poses from the video’s frames;arkitPoses doesn’t skip that step. The poses do two things:
- Upright scenes: the scene’s up direction comes from ARKit gravity, so portrait captures don’t come out on their side. It needs at least 30 frames paired with a pose.
- Frame choice: the frames are cut at the pose timestamps, up to 150, instead of every 0.67 s.