# Dicyanin Labs blog (full corpus) visionOS, RealityKit, ARKit, and spatial computing. All posts as Markdown. --- # Building a volumetric splash and launch sequence on visionOS Wire DicyaninSplash and DicyaninHomeDioramaScene together to open an app with a glitchy 2D splash that hands off to a spinning volumetric diorama. - URL: https://dicyaninlabs.com/blog/volumetric-splash-and-launch-sequence-visionos - Author: Hunter - Published: 2026-07-20 - Tags: visionOS, RealityKit, SwiftUI, Spatial Computing - Reading time: 5 min read (968 words) --- Most visionOS apps open cold: the first window is the main UI, and there is no launch moment. This post wires two packages into a proper opening sequence. `DicyaninSplash` renders a two-phase 2D splash (a glitchy studio title, then a logo and loading bar). When it finishes it opens a separate volumetric window running `DicyaninHomeDioramaScene`, a low poly nature island that spins on a glowing pedestal. By the end you have a plain splash window that dismisses itself and hands off to a volume, with no bundled assets in either package. Both packages are on GitHub: [DicyaninSplash](https://github.com/hunterh37/DicyaninSplash) and [DicyaninHomeDioramaScene](https://github.com/hunterh37/DicyaninHomeDioramaScene). They target visionOS 2 / iOS 18, Swift 6, and generate all geometry from primitives. ## Why the splash is a separate plain window `DicyaninSplashView` is a flat 2D SwiftUI view. On visionOS it must live in a `.windowStyle(.plain)` window. Do not embed it in a `.windowStyle(.volumetric)` window: a volume is sized in meters, and a flat view dropped into it collapses into a degenerate box, so you get an empty glass panel. The pattern is two separate `WindowGroup` scenes. The default window is the plain splash. The volumetric diorama is its own window that the splash opens on completion, then dismisses itself. ```swift import SwiftUI import DicyaninSplash import DicyaninHomeDioramaScene @main struct MyApp: App { static let splashWindowID = "Splash" static let dioramaWindowID = "Diorama" var body: some Scene { WindowGroup(id: Self.splashWindowID) { SplashScreen() } .windowStyle(.plain) .defaultSize(width: 700, height: 820) WindowGroup(id: Self.dioramaWindowID) { HomeDioramaView( config: DioramaConfig(season: .summer, treeCount: 12, spinSpeed: 0.15) ) } .windowStyle(.volumetric) .defaultSize(width: 0.8, height: 0.7, depth: 0.8, in: .meters) } } ``` > Tip: Positions in the diorama are in meters, origin at the volume. The scene grounds itself with a constant transform, so if you change the `defaultSize` significantly you may need to offset the root. ## The splash view and its handoff `DicyaninSplashView` takes a `SplashConfiguration` and an `onFinished` closure. The configuration injects the studio name, theme, optional 3D scenes, and an optional preload. Everything app-specific lives here so the package stays free of coupling. Fire the window handoff in `onFinished`. ```swift struct SplashScreen: View { @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow var body: some View { DicyaninSplashView( configuration: SplashConfiguration( studioName: "DICYANIN", titlePhaseDuration: .milliseconds(4000), theme: .cyberGreen ), onFinished: { openWindow(id: MyApp.dioramaWindowID) dismissWindow(id: MyApp.splashWindowID) } ) } } ``` The sequence runs itself. Phase 1 shows `GlitchStudioText` for `titlePhaseDuration`, then it crossfades to the logo phase with a `CyberLoadingBar`. When loading completes, `onFinished` fires after a short beat. ## Driving the loading bar with real work If you pass no `preload`, the bar runs a synthetic 0 to 1 sweep over `syntheticLoadDuration`. To make it reflect actual startup work, pass a `preload` closure. It receives a `report(progress, label)` callback you call as your work advances. The bar only leaves the synthetic path once you report at least once. ```swift SplashConfiguration( studioName: "DICYANIN", theme: .cyberGreen, preload: { report in report(0.2, "REGISTERING SYSTEMS...") await MainActor.run { DicyaninHomeDioramaScene.register() } report(0.6, "WARMING SCENE...") try? await Task.sleep(for: .milliseconds(400)) report(1.0, "READY") } ) ``` Registering the diorama's components and systems during preload means the volume is ready the instant the window opens. `DicyaninHomeDioramaScene.register()` is idempotent, so calling it here and again from `HomeDioramaView.onAppear` is safe. ## Theming both ends to match `SplashTheme` carries the accent color, background, and an optional font name resolved from the host app's bundle. The default `.cyberGreen` matches the accent used across Dicyanin apps. Set `fontName` to `nil` to fall back to the system monospaced font. ```swift let theme = SplashTheme( accent: Color(red: 0.02, green: 1.0, blue: 0.38), background: Color(red: 0.02, green: 0.02, blue: 0.03), fontName: nil ) ``` The diorama carries its own palette through `DioramaSeason`. `.summer`, `.autumn`, and `.winter` recolor foliage, ground, water, and the pedestal rim glow. Pick the season whose accent reads closest to your splash theme so the two moments feel like one app. ![Summer diorama island](/images/blog/diorama-summer.png) ![Autumn diorama island](/images/blog/diorama-autumn.png) ![Winter diorama island](/images/blog/diorama-winter.png) ## A 3D backdrop or centerpiece in the splash The splash can host RealityKit content in either phase. `titleSceneBuilder` puts a 3D backdrop behind the studio title. `logoSceneBuilder` puts a 3D centerpiece above the loading bar. Both receive the `SplashTheme` so props match the palette, and both run inside a transparent `RealityView`. ```swift SplashConfiguration( studioName: "DICYANIN", theme: .cyberGreen, logoSceneBuilder: { theme in let root = Entity() let logo = ModelEntity( mesh: .generateSphere(radius: 0.08), materials: [makeWiremeshCyberGreenShader()] ) root.addChild(logo) return root } ) ``` `makeWiremeshCyberGreenShader()` returns the animated wireframe emissive material used for the "rendering in" look. For a full holographic title, `HoloTitleView` renders a parameterized version of the effect with scanlines and flicker lighting. ## The diorama as the landing volume Once the splash dismisses, `HomeDioramaView` is the app's first real content. It is a drop-in view: pass a `DioramaConfig` and it builds a floating island, scatters trees, rocks, and grass, and spins the whole thing. The scatter is deterministic from `seed`, so a given config always renders the same island. ```swift HomeDioramaView( config: DioramaConfig( season: .autumn, treeCount: 14, // clamped 1...24 pond: true, seed: 7, spinSpeed: 0.12 // rotations per second, 0 to hold still ) ) ``` Rotation runs through an ECS `SpinSystem` driving a `SpinComponent`, not a SwiftUI animation, so it stays smooth independent of view updates. Passing a new `config` rebuilds the island; `HomeDioramaBuilder.build(into:config:)` early-outs unless the config signature changed, so it is cheap to call every frame. ## Next steps You now have a launch sequence: a plain splash window that self-dismisses into a volumetric diorama, themed consistently, with the loading bar tied to real startup work. From here, swap the diorama window for your actual main UI once the app grows, or keep the diorama as a home screen and open your main volume from a button inside it. Both packages ship no assets, so nothing here adds meaningfully to your bundle. --- # Structuring a visionOS app or game with the ImmersiveTesting architecture Most RealityKit code ends up tangled inside one RealityView closure. ImmersiveTesting is a layered scaffold that keeps scene construction, game logic, and platform services in separate, swappable places. - URL: https://dicyaninlabs.com/blog/structuring-a-visionos-app-with-immersivetesting - Author: Hunter - Published: 2026-07-20 - Tags: visionOS, RealityKit, Swift, Spatial Computing - Reading time: 5 min read (982 words) --- Almost every immersive visionOS project I have opened starts the same way: one `RealityView` closure loading entities, wiring ARKit, running game logic, and mutating state, all in the same place. That is fine for a demo. Then you add a second entity type, then a game mode, and now every change means reading the whole closure to figure out what you might break. Everything touches everything, and there is nowhere clean to make a cut. [ImmersiveTesting](https://github.com/hunterh37/ImmersiveTesting) is a Swift package I built to give an immersive app a spine. It is a layered architecture with a small set of services underneath, meant to keep spatial 3D code from collapsing back into that one closure. This post walks through the layers and where things go. ## Three layers, one responsibility each The app splits into three layers. The one rule worth enforcing: each concern lives in exactly one of them. 1. SwiftUI shell. The `ImmersiveView` stays thin. It wires the environment and calls the scene builder, nothing more. No game logic, no entity construction. 2. Scene layer. The `SceneBuilder` constructs the entity graph. ECS systems drive per-frame behavior through static `step` methods. 3. Services layer. Provider protocols hide ARKit, `.shared` singletons, and other platform calls behind interfaces you inject. Most of the benefit here is boring and practical: when something breaks, you know which file to open. Locomotion is a system. A new entity is the builder. A hand-tracking call is a provider. You are not scrolling a 400-line closure hunting for the one line that matters. ## The scene builder is a pure function The `SceneBuilder` takes a configuration and a `SceneEnvironment` and returns a constructed entity. It does not reach for globals and it does not read hidden state. Same inputs, same scene. ```swift struct GameImmersiveView: View { @StateObject private var viewModel = GameViewModel() var body: some View { RealityView { content in let env = CompositeSceneEnvironment( worldTracking: LiveWorldTracking(), sceneEffects: LiveSceneEffects() ) let scene = GameSceneBuilder().makeScene(viewModel.config, env: env) content.add(scene.root) viewModel.sceneRoot = scene.root } } } ``` Because construction is just a function of `config`, scene variants (difficulty, level layout, spawn counts) come from passing different configuration instead of branching inside the view. The view never learns there is more than one kind of scene. ## Game logic lives in ECS systems Per-frame behavior goes into systems that expose static `step` methods. A system reads the environment and the entities it cares about, then mutates them. It does not own the world and it does not know about SwiftUI. ```swift static func step(entities: [Entity], dt: Float, env: any SceneEnvironment) { let target = env.worldTracking.devicePosition() for npc in entities { guard var ai = npc.components[NPCAIComponent.self] else { continue } npc.position += normalize(target - npc.position) * ai.speed * dt } } ``` Each system is one file doing one thing. You add a behavior by adding a system, not by threading another branch through code that already works. It also happens to match how RealityKit already wants per-frame work structured, so you are going with the grain instead of against it. > Tip: static `step` methods make a system trivial to reason about. There is no instance state hiding between frames, so what you read is what runs. ## Platform calls hide behind provider protocols Everything platform-specific goes behind a protocol. The device pose, scene effects, hand tracking, and even randomness each have a `-Providing` interface, and the `SceneEnvironment` carries the concrete implementations. - `WorldTrackingProviding` for device pose - `SceneEffectsProviding` for scene effects - `HandTrackingProviding` for hand input - `RandomProviding` for randomization In the app you inject the live adapters (`LiveWorldTracking`, `LiveSceneEffects`). Your systems and builder only ever see the interface, never ARKit or a singleton directly. So when Apple reshuffles an API, or you want to swap an implementation for a test, you touch one adapter instead of chasing the change across the whole scene. ## Determinism is built in `RandomProviding` is backed by `SeededRandom`, so procedural content is reproducible. Give the environment a seed and the scene lays out the same way every time. ```swift let env = CompositeSceneEnvironment(random: SeededRandom(seed: 42)) let scene = GameSceneBuilder().makeScene(config, env: env) ``` This matters more for a game than it first looks. If spawns are reproducible, a bug someone reports is a bug you can actually recreate instead of chase. A daily-challenge or shared-seed mode turns into a single seed value rather than a new subsystem. Procedural layout goes from something you can only watch happen to something you can pin down and step through. ## Real physics, not hand-rolled math Lean on RealityKit's real physics engine (gravity, contacts, collisions) instead of approximating motion by hand. Collisions go through actual `CollisionComponent` group and mask contracts, so the thing governing the scene is the same simulation that ships in the app. Every bespoke movement equation you delete is one less place for spatial math to quietly drift out of sync with what the user is looking at. ## Why this holds up It all comes down to keeping clean seams between parts. Scene construction, behavior, and platform services each sit in one swappable place, and each leans on an interface rather than on the layer under it. That is the whole trick to getting an immersive app past the demo stage: you can add a system, swap an adapter, or reconfigure a scene without taking the rest of it apart to do it. ## Requirements Swift 6, Xcode 16 or newer, targeting visionOS 2 (with macOS 15 and iOS 18 support). The runtime library is linkable from app targets directly. ## Next steps It is on GitHub at [ImmersiveTesting](https://github.com/hunterh37/ImmersiveTesting). You do not have to adopt all of it at once. Pull one `RealityView` closure apart into a `SceneBuilder` and a single system, then push the platform calls behind a provider. It pays off piece by piece, which is the only kind of refactor that actually gets finished. --- # Moving through immersive scenes on Vision Pro without controllers Locomotion is one of the hardest UX problems on Apple Vision Pro. Three open-source approaches: laser teleport, pinch to walk, and joystick rigs, plus the scene-root rule that keeps them all comfortable. - URL: https://dicyaninlabs.com/blog/moving-through-immersive-scenes-visionos - Author: Hunter - Published: 2026-07-20 - Tags: visionOS, RealityKit, Hand Tracking, Spatial Computing - Reading time: 4 min read (785 words) --- Movement is one of the hardest UX problems on Apple Vision Pro. There are no controllers, no thumbsticks, and no established convention for covering distance in an immersive scene. Get it wrong and users get disoriented within seconds. Get it right and the scene stops feeling like a diorama you are standing outside of. Three approaches are now open source: laser teleport and pinch to walk in [DicyaninSceneMovement](https://github.com/hunterh37/DicyaninSceneMovement), a world-anchored joystick rig in [DicyaninVirtualJoystick](https://github.com/hunterh37/DicyaninVirtualJoystick), and hand-tracked thumb input in [DicyaninThumbController](https://github.com/hunterh37/DicyaninThumbController). > [video] /videos/scene-movement-demo.mp4 ## Move the scene, not the camera The rule underneath all three: visionOS owns the camera. You do not get to move it, and trying to fight that is the first thing that goes wrong. Instead, parent the entire scene to a single root entity and translate that root. The user stays where they physically are and the world slides around them. ```swift RealityView { content in let world = Entity() // everything in the scene goes under this content.add(world) movement.attach(root: world, in: content) Task { await movement.start() } } ``` Physical walking still works normally on top of this, because the OS keeps tracking the user inside their real room. You are only offsetting where the virtual world sits relative to that room. > Tip: one root entity, translated. Nesting movement offsets at multiple levels makes the math unreadable and the motion inconsistent. ## Laser teleport (DicyaninSceneMovement) [github.com/hunterh37/DicyaninSceneMovement](https://github.com/hunterh37/DicyaninSceneMovement) Point a hand forward, a laser and reticle track the floor, pinch to teleport. Teleport remains the most comfortable option because the transition is instant, and instant motion produces no vestibular conflict at all. Surfaces opt in. Mark anything walkable with a component, and give it a `CollisionComponent` so the ray has something to hit: ```swift floor.components.set(TeleportSurfaceComponent()) ``` Use it when the scene has defined ground and the user is covering real distance. It is the safest default for anyone who has not worn a headset much. ## Pinch to walk (DicyaninSceneMovement) [github.com/hunterh37/DicyaninSceneMovement](https://github.com/hunterh37/DicyaninSceneMovement) Look at a spot, pinch to drop a glowing orb, and the scene slides you there. This is continuous motion, which reads as more natural and preserves the user's sense of where things are relative to each other, at the cost of being the option most likely to cause discomfort. Keep the speed conservative and keep it constant. Acceleration and deceleration are what actually trigger discomfort, more than velocity itself. Registration for both modes happens once at launch: ```swift import DicyaninSceneMovement SceneMovementManager.registerSystems() ``` Both need hand tracking and world sensing authorization in the immersive space. Neither works in the simulator without mocked hand input. ## Joystick rigs (DicyaninVirtualJoystick) [github.com/hunterh37/DicyaninVirtualJoystick](https://github.com/hunterh37/DicyaninVirtualJoystick) When the experience is a game rather than a walkthrough, a visible control surface beats an invisible gesture. `DicyaninVirtualJoystick` gives you two grabbable joysticks, mounted on either a flat hand-held pad (`Gamepad3DEntity`) or a floor-standing arcade pillar (`GamepadPillarEntity`). Tilt is read out as normalized two-stick input. The package never reaches into your app. It talks through one seam: ```swift VirtualJoystickBridge.isEnabled = { myControlScheme.usesJoystickRig } VirtualJoystickBridge.output = { input in myController.apply( leftDirection: input.leftDirection, leftMagnitude: input.leftMagnitude, rightDirection: input.rightDirection, rightMagnitude: input.rightMagnitude ) } ``` The sticks are driven by a `targetedToEntity` pinch-drag, which means the same input path works in the Simulator and on device with no hand-tracking wiring. That alone makes it the fastest of the three to iterate on. ## Thumb as a joystick (DicyaninThumbController) [github.com/hunterh37/DicyaninThumbController](https://github.com/hunterh37/DicyaninThumbController) `DicyaninThumbController` converts thumb position from hand tracking into a virtual joystick: a direction vector plus a magnitude, with a configurable deadzone and max distance. No visible rig, nothing to grab. ```swift import DicyaninThumbController let thumbController = ThumbController(handSide: .right) try await thumbController.start() ``` Output is a `SIMD3` direction and a magnitude you route into the same movement pipeline as anything else. It depends on [DicyaninARKitSession](https://github.com/hunterh37/DicyaninARKitSession) for hand session management. The tradeoff is discoverability. Nothing on screen tells the user this control exists, so it works best as a secondary scheme or in an app where you can teach it once. ## Picking one - Users new to headsets, or scenes with real distance to cover: laser teleport. - Short, deliberate repositioning where spatial continuity matters: pinch to walk. - Games, or anything wanting a tactile control surface: joystick rig. - Experienced users and hands-free contexts: thumb controller. Shipping more than one and letting the user switch is usually the right call. Comfort thresholds vary more between people than any single default can cover. ## Next steps Repos: [DicyaninSceneMovement](https://github.com/hunterh37/DicyaninSceneMovement), [DicyaninVirtualJoystick](https://github.com/hunterh37/DicyaninVirtualJoystick), [DicyaninThumbController](https://github.com/hunterh37/DicyaninThumbController), [DicyaninARKitSession](https://github.com/hunterh37/DicyaninARKitSession). All four are MIT licensed and listed with the rest of the packages on the [open source page](/open-source). `DicyaninSceneMovement` ships a `SceneMovementModePicker` if you want mode switching in your UI without building it yourself. --- # Hand tracking in the visionOS simulator, driven by your webcam ARKit hand tracking does not run in the visionOS simulator, which makes hand-driven apps painful to iterate on. DicyaninMockHandTracking supplies a mock pose source, on-screen joysticks, and a macOS webcam runner that streams your real hands into a running simulator build. - URL: https://dicyaninlabs.com/blog/hand-tracking-in-the-visionos-simulator - Author: Hunter - Published: 2026-07-20 - Tags: visionOS, Hand Tracking, ARKit, Swift - Reading time: 4 min read (608 words) --- ARKit hand tracking does not run in the visionOS simulator. `HandTrackingProvider` returns nothing, so any app whose core interaction is hands has to go on device for every change. That is a slow loop for a feature you are iterating on dozens of times an hour. [DicyaninMockHandTracking](https://github.com/hunterh37/DicyaninMockHandTracking) fills the gap. It publishes mock hand poses that your app reads exactly where it would read ARKit anchors, steers them from an on-screen control panel, and (as of 3.0) streams your actual hands in from a Mac webcam over the local network. > [video] /videos/mock-hand-tracking-demo.mp4 ## One pose source, two backends The package is built around a single shared controller. Read from it instead of ARKit in simulator builds: ```swift import DicyaninMockHandTracking let controller = MockHandTrackingController.shared controller.leftHandPosition // SIMD3, head-relative controller.rightHandPosition controller.rightHandYaw // Float, radians controller.isPinching // Bool for await _ in controller.updates() { // 60 fps tick, read positions here } ``` Put the environment switch behind one type so the rest of the app never branches: ```swift import simd import DicyaninMockHandTracking #if !targetEnvironment(simulator) import ARKit #endif struct HandPose { var position: SIMD3 var isPinching: Bool } @MainActor final class HandSource { #if targetEnvironment(simulator) private let mock = MockHandTrackingController.shared func rightHand() -> HandPose { HandPose(position: mock.rightHandPosition, isPinching: mock.isPinching) } #else private let session = ARKitSession() private let provider = HandTrackingProvider() func start() async throws { try await session.run([provider]) } func rightHand() -> HandPose { guard let anchor = provider.latestAnchors.rightHand, anchor.isTracked, let wrist = anchor.handSkeleton?.joint(.wrist) else { return HandPose(position: .zero, isPinching: false) } let m = anchor.originFromAnchorTransform * wrist.anchorFromJointTransform return HandPose( position: SIMD3(m.columns.3.x, m.columns.3.y, m.columns.3.z), isPinching: detectPinch(anchor) ) } #endif } ``` Call sites use `handSource.rightHand()` and never know which backend produced the pose. ## The control panel Drop the overlay into a window in simulator builds only. It gives you two joysticks, rotation sliders, and a pinch button: ```swift #if targetEnvironment(simulator) MockHandControlView() #endif ``` That covers deterministic testing: park a hand at an exact position, fire a pinch, verify the hit. ## Webcam poses Dragging joysticks is fine for reproducing a case and bad for feeling out an interaction. The `WebcamHandRunner` macOS app in `Examples/WebcamHandRunner` estimates hand poses with Vision's `VNDetectHumanHandPoseRequest` and broadcasts them over `_dicyaninhands._tcp`. Your app subscribes with one call: ```swift ContentView() .task { #if targetEnvironment(simulator) MockHandTrackingController.shared.connectToWebcamRunner() // localhost:50673 #endif } ``` The simulator shares your Mac's network, so localhost works with no configuration. On a real Vision Pro on the same Wi-Fi, use Bonjour discovery instead: ```swift MockHandTrackingController.shared.connectToWebcamRunner(bonjourName: nil) ``` Call `disconnectWebcamRunner()` to hand control back to the joysticks. Because the webcam writes into the same published state the joysticks do, every existing consumer works unchanged. > Tip: a webcam is one 2D view. Depth is approximated from apparent hand size, and yaw comes from the wrist to knuckle direction. Use it for iteration speed, not metric precision, and validate against ARKit on device. ## Gloves `DicyaninHandGlove` vendors Apple's [Tracking and visualizing hand movement](https://developer.apple.com/documentation/visionos/tracking-and-visualizing-hand-movement) sample (a `HandTrackingComponent` plus `HandTrackingSystem` mapping all 27 skeleton joints per frame) as one view, with a simulator bridge to the mock controller: ```swift import DicyaninHandGlove ImmersiveSpace(id: "Gloves") { HandGloveView() } ``` Configure the look, or load your own rigged USDZ: ```swift HandGloveView(configuration: .init(style: .joints)) // Apple's spheres HandGloveView(configuration: .init(tracksLeftHand: false, color: .orange)) HandGloveView(configuration: .init( style: .model(left: "LeftGlove_v001", right: "RightGlove_v001") )) ``` On device the gloves follow the real skeleton joint for joint. In the simulator they follow the joysticks or the webcam. Same code path. ## Next steps Add the package at `https://github.com/hunterh37/DicyaninMockHandTracking.git` (3.0.0+), then run `Examples/HandTrackingDemo` on a simulator to see gloves, control panel, and webcam bridge wired together.