Build an Interactive 3D Planet Explorer

Build a visionOS app with an animated 3D solar system in a volumetric window.

Introduction

30 Second Summary

Think about the last time you looked up at the night sky and tried to picture how planets actually move around the Sun. Now imagine holding that entire solar system right in front of you, watching planets orbit in real time, and tapping one to learn more about it.

In this project, you will build an interactive 3D Planet Explorer for visionOS. You will create a mini solar system inside a volumetric window where planets orbit a central sun, and users can tap any planet to open a detail panel showing its information.

What You'll Build

A spatial computing app built with Swift, SwiftUI, and RealityKit that displays an animated 3D solar system inside a volumetric window on Apple Vision Pro.

By the end of this project, you'll have:

  • A visionOS project with SwiftUI and RealityKit integration running in the Simulator.
  • 3D planet entities that orbit and spin using per-frame animation driven by real-time 3D math.
  • Tap interaction that lets users select any planet and opens a detail window with planet facts.
  • A Secret Mission extension that transforms the solar system into a full immersive space surrounding the user.

Are there any prerequisites?

You need a Mac with Apple Silicon (M1 or later) and Xcode installed. The visionOS Simulator is free to download inside Xcode. No Apple Developer Program membership is required.

Set Up Your visionOS Project

Every spatial computing app starts with an Xcode project configured for visionOS. In this project, you will build a 3D Planet Explorer that displays an interactive solar system inside a volumetric window.

Before writing any code, you need to create the project, confirm your simulator is working, and understand the files Xcode generates for you.

In this step, get ready to:
  • Create a new visionOS project in Xcode.
  • Confirm the visionOS Simulator is installed and running.
  • Explore the generated project structure.
Create a new visionOS project

Xcode includes a visionOS app template that generates the starter files and configuration you need. You will customize these files throughout the project.

  • Press Cmd+Space to open Spotlight Search.
  • Type Xcode and press Enter to open it.
  • Click Create New Project on the welcome screen.

Pro tip: welcome screen missing

If the welcome screen does not appear, go to File in the menu bar, then select New, then Project.

  • Select the visionOS tab at the top of the template chooser.
  • Choose the App template and click Next.
  • Enter PlanetExplorer in the Product Name field.
  • Set Initial Scene to Window.
  • Make sure Include Reality Composer Pro content is unchecked.
  • Confirm the Language is set to Swift.
  • Leave the Team dropdown as None. No developer team is required for simulator testing.
  • Click Next.
  • Select your Desktop as the save location and click Create.

Xcode opens the project editor with your PlanetExplorer project. You should see a file navigator on the left with several generated files.

Why uncheck Reality Composer Pro?

Reality Composer Pro is a visual tool for assembling 3D scenes from pre-made assets. In this project, you will build all 3D content programmatically using RealityKit code. Unchecking this option keeps your project clean and focused on the code-driven approach.

Verify the visionOS Simulator is running

The visionOS Simulator lets you test your spatial app without a physical Apple Vision Pro. You need to confirm the simulator runtime is downloaded before you can run your project.

  • In the menu bar, click Xcode, then select Settings.
  • Click the Platforms tab at the top of the Settings window.
  • Look for visionOS in the list of simulator runtimes.

I see visionOS listed

The visionOS simulator runtime is already installed. Close the Settings window and continue to the next instruction.

I don't see visionOS

  • Click the + button at the bottom left of the Platforms tab.
  • Select visionOS from the list and click Download & Install.
  • Wait for the download to complete. This may take several minutes depending on your internet speed.

Once the download finishes, visionOS appears in your platforms list. Close the Settings window.

Now run the template app to confirm everything works.

  • In the Xcode toolbar at the top, click the run destination dropdown (it shows a device name next to the play button).
  • Select Apple Vision Pro under the visionOS Simulators section.
  • Click the Run button (the play icon) or press Cmd+R.

The visionOS Simulator launches and displays your app as a floating window in a virtual room. You should see a default "Hello, world!" window.

What should I see?

The Simulator shows a virtual environment with your app window floating in front of you. The window contains basic template content from Xcode. You can click and drag the window bar at the bottom to move it around the space.

Simulator not launching?

  • Make sure you selected Apple Vision Pro as the run destination, not an iPhone or iPad simulator.
  • Check that your Mac has Apple Silicon (M1 or later). The visionOS Simulator does not run on Intel Macs.
  • If you see a build error, make sure you did not accidentally check Include Reality Composer Pro content when creating the project.

Why won't my visionOS Simulator launch?

Explore the generated project files

Xcode generated several files for your project. Two of them are the ones you will modify throughout this project.

  • In the file navigator on the left side of Xcode, click on PlanetExplorerApp.swift to open it in the editor.

This file is the entry point of your app. It contains a WindowGroup scene declaration that tells SwiftUI to display ContentView as the first thing the user sees.

  • Click on ContentView.swift in the file navigator to open it.

This file contains the default SwiftUI view with the "Hello, world!" content you saw in the Simulator. You will replace this with a RealityKit-powered 3D solar system view in a later step.

Why are these two files important?

In a visionOS app, PlanetExplorerApp.swift controls which windows and scenes your app can display. ContentView.swift defines what appears inside the main window. Every change you make in this project flows through one of these two files (plus new files you will create along the way).

Your visionOS project is ready and running in the Simulator. Next up, you will create the data model that defines each planet's properties like size, orbit distance, and color.

Create the Planet Data Model

In Step 1, you set up your Xcode project and confirmed the visionOS Simulator is running. Now it's time to define the data that powers your solar system.

Every planet has a unique size, orbit distance, and color. You'll create a data model to store these properties and a shared model class to connect the 3D scene to your SwiftUI views.

In this step, get ready to:
  • Define a Planet struct with properties for each celestial body.
  • Add sample data for the Sun, Mercury, Venus, Earth, and Mars.
  • Create a SolarSystemModel class to manage planet selection and share data across views.
Define the Planet struct

Before you can render planets in 3D, you need a way to describe each one in code. A Swift struct groups related properties together into a single, reusable type.

  • Right-click the PlanetExplorer folder in the Project Navigator sidebar.
  • Select New File.
  • Choose Swift File and click Next.
  • Name the file Planet and click Create.
  • Replace the contents of Planet.swift with the following code:
import SwiftUI
import Foundation

// Each planet conforms to Identifiable so SwiftUI can track it by ID
struct Planet: Identifiable {
    let id = UUID()
    let name: String
    let radius: Float
    let orbitRadius: Float
    let color: Color
    let orbitSpeed: Float
    let description: String
    let isSun: Bool

    // Custom initializer with isSun defaulting to false for regular planets
    init(name: String, radius: Float, orbitRadius: Float, color: Color, orbitSpeed: Float, description: String, isSun: Bool = false) {
        self.name = name
        self.radius = radius
        self.orbitRadius = orbitRadius
        self.color = color
        self.orbitSpeed = orbitSpeed
        self.description = description
        self.isSun = isSun
    }
}

What does this code do?

  • Identifiable lets SwiftUI uniquely identify each planet using the id property. This is required when displaying planets in lists or tracking selection.
  • radius sets the planet's size in meters. orbitRadius sets its distance from the Sun.
  • orbitSpeed controls how fast the planet moves through its orbit. Higher values mean faster orbiting.
  • The isSun flag defaults to false. This lets you treat the Sun differently from the orbiting planets later.
  • Save Planet.swift.
  • Press Cmd+B to build the project. The build should succeed with no errors.

Seeing a build error?

  • Make sure the file is named Planet.swift and appears inside the PlanetExplorer folder in the Project Navigator.
  • Check that import SwiftUI is at the top of the file. The Color type comes from SwiftUI.
  • help me fix a build error in my Planet struct
Add the sample planet data

The struct defines the shape of a planet, but you need actual data to fill it. You'll use a Swift extension to add a static array of planets with realistic relative sizes.

  • In Planet.swift, add the following code below the closing brace of the Planet struct:
// Store sample planet data as a static property on Planet
extension Planet {
    static let allPlanets: [Planet] = [
        Planet(name: "Sun", radius: 0.03, orbitRadius: 0, color: .yellow, orbitSpeed: 0, description: "The star at the center of our solar system. It contains 99.86% of the mass in the solar system.", isSun: true),
        Planet(name: "Mercury", radius: 0.006, orbitRadius: 0.08, color: .gray, orbitSpeed: 1.6, description: "The smallest planet and closest to the Sun. A year on Mercury is just 88 Earth days."),
        Planet(name: "Venus", radius: 0.009, orbitRadius: 0.12, color: .orange, orbitSpeed: 1.2, description: "The hottest planet in our solar system. Its thick atmosphere traps heat like a greenhouse."),
        Planet(name: "Earth", radius: 0.01, orbitRadius: 0.17, color: .blue, orbitSpeed: 1.0, description: "Our home planet. The only known planet to support life, with liquid water on its surface."),
        Planet(name: "Mars", radius: 0.008, orbitRadius: 0.23, color: .red, orbitSpeed: 0.8, description: "The Red Planet. Home to the tallest volcano in the solar system, Olympus Mons.")
    ]
}

Why are the sizes so small?

Volumetric windows in visionOS are measured in meters. The window you'll create is only 0.6 meters wide.

A sun radius of 0.03 means 3 centimeters. Planet radii range from 0.006 to 0.01 meters. These small values keep everything visible and proportional inside the volumetric window.

  • Save Planet.swift.
  • Press Cmd+B to build. The build should succeed with no errors.

Build failing after adding the extension?

  • Make sure the extension is placed outside (below) the struct's closing brace, not inside it.
  • Check that each Planet(...) entry has the correct number of parameters matching the initializer.
  • The Sun entry must include isSun: true. The other planets omit it because the default is false.
  • help me fix my Planet extension

Awesome, I've got everything!

Great. Double check you've saved Planet.swift before moving on.

I'd like to double check the full code

import SwiftUI
import Foundation

struct Planet: Identifiable {
    let id = UUID()
    let name: String
    let radius: Float
    let orbitRadius: Float
    let color: Color
    let orbitSpeed: Float
    let description: String
    let isSun: Bool

    init(name: String, radius: Float, orbitRadius: Float, color: Color, orbitSpeed: Float, description: String, isSun: Bool = false) {
        self.name = name
        self.radius = radius
        self.orbitRadius = orbitRadius
        self.color = color
        self.orbitSpeed = orbitSpeed
        self.description = description
        self.isSun = isSun
    }
}

extension Planet {
    static let allPlanets: [Planet] = [
        Planet(name: "Sun", radius: 0.03, orbitRadius: 0, color: .yellow, orbitSpeed: 0, description: "The star at the center of our solar system. It contains 99.86% of the mass in the solar system.", isSun: true),
        Planet(name: "Mercury", radius: 0.006, orbitRadius: 0.08, color: .gray, orbitSpeed: 1.6, description: "The smallest planet and closest to the Sun. A year on Mercury is just 88 Earth days."),
        Planet(name: "Venus", radius: 0.009, orbitRadius: 0.12, color: .orange, orbitSpeed: 1.2, description: "The hottest planet in our solar system. Its thick atmosphere traps heat like a greenhouse."),
        Planet(name: "Earth", radius: 0.01, orbitRadius: 0.17, color: .blue, orbitSpeed: 1.0, description: "Our home planet. The only known planet to support life, with liquid water on its surface."),
        Planet(name: "Mars", radius: 0.008, orbitRadius: 0.23, color: .red, orbitSpeed: 0.8, description: "The Red Planet. Home to the tallest volcano in the solar system, Olympus Mons.")
    ]
}
Create the SolarSystemModel

The planet data needs a home that SwiftUI views and RealityKit scenes can both access. An @Observable class acts as a shared data store that automatically notifies views when its properties change.

  • Right-click the PlanetExplorer folder in the Project Navigator.
  • Select New File.
  • Choose Swift File and click Next.
  • Name the file SolarSystemModel and click Create.
  • Replace the contents of SolarSystemModel.swift with the following code:
import SwiftUI

// Shared data model that connects the 3D scene to SwiftUI views
@Observable
class SolarSystemModel {
    // Tracks which planet the user tapped (nil means nothing is selected)
    var selectedPlanet: Planet?
    // Loads the full list of planets from the static data
    let planets = Planet.allPlanets
}

What is @Observable?

@Observable is Swift's modern observation system. When a property like selectedPlanet changes, any SwiftUI view reading it automatically re-renders.

This replaces the older ObservableObject and @Published pattern. You don't need to mark individual properties. The @Observable macro handles everything for you.

  • Save SolarSystemModel.swift.
  • Press Cmd+B to build. The build should succeed with no errors.

Seeing 'cannot find type Planet'?

  • Make sure Planet.swift is in the same target as SolarSystemModel.swift. Both files should appear under the PlanetExplorer folder in the Project Navigator.
  • Click on Planet.swift in the Project Navigator, then check the File Inspector (right sidebar). Make sure PlanetExplorer is checked under Target Membership.
  • help me fix the 'cannot find type' error

Awesome, I've got everything!

Great. Double check you've saved SolarSystemModel.swift before moving on.

I'd like to double check the full code

import SwiftUI

@Observable
class SolarSystemModel {
    var selectedPlanet: Planet?
    let planets = Planet.allPlanets
}

Your data model is ready. Next up, you'll use these planet definitions to build a 3D solar system inside a volumetric window.

Build the 3D Solar System in a Volumetric Window

Your data model is ready with planet sizes, orbit distances, and descriptions. Now it is time to bring those planets to life as 3D objects inside a volumetric window.

A volumetric window is a visionOS window that has depth. Instead of flat 2D content, it can hold 3D objects that the user can walk around and view from different angles. You will configure your app to use one, then fill it with sphere entities representing the sun and planets.

In this step, get ready to:
  • Configure the app window as a volumetric 3D container.
  • Create a RealityView with 3D sphere entities for the sun and each planet.
  • Verify the solar system renders in the visionOS Simulator.
Configure the app for a volumetric window

The default visionOS template creates a flat 2D window. To display 3D content, you need to switch the window style to volumetric and set its physical dimensions in meters.

  • Open PlanetExplorerApp.swift in the Project Navigator sidebar on the left.
  • Replace the entire contents of the file with the following code:
import SwiftUI

@main
struct PlanetExplorerApp: App {
    // Create the shared data model for the solar system
    @State private var solarSystemModel = SolarSystemModel()

    var body: some Scene {
        WindowGroup {
            SolarSystemView()
                .environment(solarSystemModel)
        }
        // Display this window as a 3D volumetric container
        .windowStyle(.volumetric)
        // Set the volume size in meters (60cm wide, 40cm tall, 60cm deep)
        .defaultSize(width: 0.6, height: 0.4, depth: 0.6, in: .meters)
    }
}

What does this code do?

  • @State private var solarSystemModel creates a single instance of your data model that lives as long as the app is running.
  • .environment(solarSystemModel) injects the model into the SwiftUI environment so any child view can access it.
  • .windowStyle(.volumetric) transforms the flat default window into a 3D volume that can hold RealityKit entities.
  • .defaultSize(width: 0.6, height: 0.4, depth: 0.6, in: .meters) sets the volume to 60cm wide, 40cm tall, and 60cm deep. All visionOS spatial dimensions are in meters.

Why @State instead of @StateObject?

SolarSystemModel uses the @Observable macro, which is the modern SwiftUI observation pattern. With @Observable, you use @State and .environment() instead of the older @StateObject and @EnvironmentObject pair.

  • Save PlanetExplorerApp.swift.

You will see a build error because SolarSystemView does not exist yet. You will create it in the next substep.

Create the solar system entities in a RealityView

A RealityView is a SwiftUI view that hosts RealityKit 3D content. Its make closure runs once when the view first appears. That is where you create your 3D entities.

  • Create a new Swift file by pressing Cmd+N in Xcode.
  • Select Swift File and click Next.
  • Name the file SolarSystemView and click Create.
  • Replace the contents of SolarSystemView.swift with the following code:
import SwiftUI
import RealityKit

struct SolarSystemView: View {
    // Read the shared solar system model from the environment
    @Environment(SolarSystemModel.self) private var model

    var body: some View {
        RealityView { content in
            // Create a root entity to hold the entire solar system
            let root = Entity()

            for planet in model.planets {
                // Generate a sphere mesh sized to this planet's radius
                let mesh = MeshResource.generateSphere(radius: planet.radius)
                let material = SimpleMaterial(
                    color: UIColor(planet.color),
                    isMetallic: planet.isSun ? false : true
                )
                let entity = ModelEntity(mesh: mesh, materials: [material])
                // Store the planet name so we can find this entity later
                entity.name = planet.name

                if planet.isSun {
                    // Place the sun at the center of the volume
                    entity.position = SIMD3<Float>(0, 0, 0)
                } else {
                    // Position each planet along the x-axis at its orbit distance
                    entity.position = SIMD3<Float>(planet.orbitRadius, 0, 0)
                    // Make the planet respond to tap gestures
                    entity.components.set(InputTargetComponent())
                    entity.components.set(
                        CollisionComponent(shapes: [.generateSphere(radius: planet.radius)])
                    )
                }

                root.addChild(entity)
            }

            // Add the entire solar system to the RealityView
            content.add(root)
        }
    }
}

What does this code do?

  • @Environment(SolarSystemModel.self) reads the shared data model that you injected in PlanetExplorerApp.swift.
  • RealityView { content in ... } creates a 3D scene container. The make closure runs once to set up all the 3D entities.
  • MeshResource.generateSphere(radius:) creates a sphere mesh. The radius is in meters, matching the values from your Planet data model.
  • SimpleMaterial gives each sphere its color. Planets are metallic for a reflective look. The sun is non-metallic so it appears flat and bright.
  • entity.position = SIMD3<Float>(planet.orbitRadius, 0, 0) places each planet along the x-axis at its orbit distance. SIMD3 is a 3D vector representing (x, y, z) coordinates in meters.
  • InputTargetComponent and CollisionComponent work together to make an entity tappable. Without both, the entity ignores all gestures.
  • root.addChild(entity) attaches each planet to the root entity. Then content.add(root) adds the entire hierarchy to the RealityView in one call.
  • Save SolarSystemView.swift.

Seeing a build error about SolarSystemModel?

  • Make sure SolarSystemModel.swift exists in your project and uses the @Observable macro. Without it, the @Environment(SolarSystemModel.self) line in SolarSystemView cannot resolve the type.
  • Check that both Planet.swift and SolarSystemModel.swift are listed under your PlanetExplorer target in the File Inspector panel on the right.

Help me fix my SolarSystemModel build error

Awesome, I've got everything!

Make sure both PlanetExplorerApp.swift and SolarSystemView.swift are saved before moving on.

I'd like to double check the full code

Here is what PlanetExplorerApp.swift should look like so far:

import SwiftUI

@main
struct PlanetExplorerApp: App {
    @State private var solarSystemModel = SolarSystemModel()

    var body: some Scene {
        WindowGroup {
            SolarSystemView()
                .environment(solarSystemModel)
        }
        .windowStyle(.volumetric)
        .defaultSize(width: 0.6, height: 0.4, depth: 0.6, in: .meters)
    }
}

Here is what SolarSystemView.swift should look like so far:

import SwiftUI
import RealityKit

struct SolarSystemView: View {
    @Environment(SolarSystemModel.self) private var model

    var body: some View {
        RealityView { content in
            let root = Entity()

            for planet in model.planets {
                let mesh = MeshResource.generateSphere(radius: planet.radius)
                let material = SimpleMaterial(
                    color: UIColor(planet.color),
                    isMetallic: planet.isSun ? false : true
                )
                let entity = ModelEntity(mesh: mesh, materials: [material])
                entity.name = planet.name

                if planet.isSun {
                    entity.position = SIMD3<Float>(0, 0, 0)
                } else {
                    entity.position = SIMD3<Float>(planet.orbitRadius, 0, 0)
                    entity.components.set(InputTargetComponent())
                    entity.components.set(
                        CollisionComponent(shapes: [.generateSphere(radius: planet.radius)])
                    )
                }

                root.addChild(entity)
            }

            content.add(root)
        }
    }
}
Run the app and verify the 3D scene

Your code is complete for this step. Time to see the solar system in the Simulator.

  • Make sure the Apple Vision Pro simulator is selected as your run destination in the toolbar at the top of Xcode.
  • Press Cmd+R to build and run the app.

You should see a volumetric window floating in the Simulator. Inside it, a yellow sun sits at the center with four smaller colored spheres lined up to its right along the x-axis: gray (Mercury), orange (Venus), blue (Earth), and red (Mars).

Why are the planets in a line?

You positioned each planet at SIMD3<Float>(planet.orbitRadius, 0, 0), which places them all along the x-axis. They are not orbiting yet because you have not added any animation. In the next step, you will use trigonometry to move them in circular paths around the sun.

Don't see any spheres in the Simulator?

  • Check that .windowStyle(.volumetric) is applied to the WindowGroup in PlanetExplorerApp.swift. Without it, the flat window clips 3D content.
  • Make sure you called content.add(root) at the end of the RealityView make closure. Creating entities without adding them to the content means they exist in memory but are not rendered.
  • Verify that Planet.allPlanets in Planet.swift contains entries with non-zero radius values. A radius of 0 produces an invisible sphere.

Help me debug why my solar system is not visible in the visionOS Simulator

Your solar system is taking shape. The sun and planets are visible as 3D spheres inside a volumetric window. Next, you will bring them to life with orbital animation using trigonometry and per-frame updates.

Animate Orbital Motion with 3D Math

Your solar system scene now has a sun and four planets rendered as 3D spheres inside a volumetric window. Right now, they're frozen in place along the x-axis.

In this step, you'll bring the scene to life by animating each planet along a circular orbit. You'll use per-frame updates to recalculate positions with trigonometric math and apply self-rotation using quaternions.

In this step, get ready to:
  • Subscribe to per-frame scene updates using SceneEvents.Update.
  • Calculate circular orbital paths with cosine and sine.
  • Add self-rotation to each planet using quaternion math.
Subscribe to per-frame updates

RealityKit's SceneEvents.Update fires a callback every single frame. Each callback receives a deltaTime value representing the time elapsed since the last frame.

By accumulating that delta into an angle and feeding it into cosine and sine functions, you can plot each planet's position along a circular path. This is the same technique used in game engines and real-time simulations everywhere.

Why SceneEvents.Update instead of SwiftUI animation?

SwiftUI's built-in animation system is great for animating UI properties like opacity or position. It handles easing curves and timing for you. But for 3D scenes where you need to run custom math every frame, SceneEvents.Update gives you direct control over each frame's calculations.

The job posting calls for "deep knowledge of 3D math concepts for programming real-time interactive experiences." Per-frame update loops with trigonometric positioning are exactly that skill.

Your animation needs to track two angles for each planet: one for its orbit around the sun, and one for its spin on its own axis. You'll store these in dictionaries keyed by planet name.

  • In SolarSystemView.swift, find the @Environment(SolarSystemModel.self) private var model line.
  • Add these two state properties directly below it:
@State private var orbitAngles: [String: Float] = [:]
@State private var spinAngles: [String: Float] = [:]
  • Now scroll down to the content.add(root) line inside the RealityView make closure.
  • Add the following per-frame subscription directly below it:
_ = content.subscribe(to: SceneEvents.Update.self) { event in
    let delta = Float(event.deltaTime)

    for planet in model.planets where !planet.isSun {
        guard let entity = root.findEntity(named: planet.name) else { continue }

        // Calculate the new orbit angle based on time elapsed
        let currentOrbitAngle = (orbitAngles[planet.name] ?? 0) + delta * planet.orbitSpeed
        orbitAngles[planet.name] = currentOrbitAngle

        // Use cosine and sine to plot circular motion
        let x = cos(currentOrbitAngle) * planet.orbitRadius
        let z = sin(currentOrbitAngle) * planet.orbitRadius
        entity.position = SIMD3<Float>(x, 0, z)
    }
}

What does this code do?

  • content.subscribe(to: SceneEvents.Update.self) registers a callback that runs every frame. The _ = discards the subscription token since the RealityView manages its lifetime.
  • event.deltaTime tells you how many seconds passed since the last frame. Multiplying this by orbitSpeed gives frame-rate-independent movement.
  • cos(angle) * radius and sin(angle) * radius plot a point on a circle. As the angle increases each frame, the point traces a circular orbit in the x-z plane.
  • The where !planet.isSun filter skips the sun so it stays fixed at the center.
  • Save SolarSystemView.swift.

You won't run the app just yet. The next substep adds spin rotation, and then you'll verify both effects together.

Seeing a compile error?

  • Make sure the subscribe block is inside the RealityView { content in ... } make closure, after content.add(root). Placing it outside the closure means root and content are out of scope.
  • Confirm the two @State properties are declared at the struct level (inside SolarSystemView but outside body), not inside the RealityView closure.
  • help me fix a compile error in my SceneEvents.Update subscription
Add self-rotation with quaternions

Orbiting around the sun is only half the animation. Real planets also spin on their own axis. In 3D graphics, rotations are represented using quaternions, which avoid the distortion problems that come with combining multiple angle-based rotations.

What is a quaternion?

A quaternion is a mathematical way to describe a rotation in 3D space. Instead of saying "rotate 45 degrees on X, then 30 degrees on Y," a quaternion encodes the full rotation as a single value. In RealityKit, simd_quatf(angle:axis:) creates a quaternion from an angle (in radians) and an axis to rotate around.

  • In SolarSystemView.swift, find the line entity.position = SIMD3<Float>(x, 0, z) inside the subscribe closure's for loop.
  • Add the following code directly below that line to spin each planet on its y-axis:
// Spin the planet on its own axis
let currentSpinAngle = (spinAngles[planet.name] ?? 0) + delta * 2.0
spinAngles[planet.name] = currentSpinAngle
entity.transform.rotation = simd_quatf(
    angle: currentSpinAngle,
    axis: SIMD3<Float>(0, 1, 0)
)

What does this code do?

  • spinAngles tracks a separate angle for each planet's self-rotation, independent of its orbital angle.
  • The 2.0 multiplier makes all planets spin at the same speed. The orbit speed varies per planet, but the spin speed stays uniform for simplicity.
  • simd_quatf(angle:axis:) creates a quaternion rotation. The axis SIMD3<Float>(0, 1, 0) points straight up, so the planet spins like a top.
  • Save SolarSystemView.swift.

You won't see a change yet. The next substep runs the app so you can see both orbital motion and spin together.

Run the animated solar system

All the animation code is in place. Time to see it in motion.

  • Press Cmd+R to build and run the app in the visionOS Simulator.

You should see the four planets moving in smooth circular paths around the yellow sun. Each planet travels at a different speed based on its orbitSpeed property. Mercury moves fastest. Mars moves slowest.

Look closely and you'll notice each planet is also spinning on its own axis. The sun stays fixed at the center.

Planets not moving?

  • Check that the content.subscribe(to: SceneEvents.Update.self) block is inside the RealityView { content in ... } make closure, not in the update: closure.
  • Make sure you're using root.findEntity(named: planet.name) to look up entities. If entity names don't match, findEntity returns nil and the guard skips that planet silently.
  • Verify that the orbitSpeed values in Planet.swift are greater than 0 for all planets (the sun's is 0, but it's filtered out).
  • help me debug why my planets aren't animating in the visionOS Simulator

Awesome, I've got everything!

Great. Double check you've saved SolarSystemView.swift before moving on.

I'd like to double check the full code

Here is the full SolarSystemView.swift as it should look at the end of this step.

import SwiftUI
import RealityKit

struct SolarSystemView: View {
    @Environment(SolarSystemModel.self) private var model

    @State private var orbitAngles: [String: Float] = [:]
    @State private var spinAngles: [String: Float] = [:]

    var body: some View {
        RealityView { content in
            let root = Entity()

            for planet in model.planets {
                let mesh = MeshResource.generateSphere(radius: planet.radius)
                let material = SimpleMaterial(
                    color: UIColor(planet.color),
                    isMetallic: planet.isSun ? false : true
                )
                let entity = ModelEntity(mesh: mesh, materials: [material])
                entity.name = planet.name

                if planet.isSun {
                    entity.position = SIMD3<Float>(0, 0, 0)
                } else {
                    entity.position = SIMD3<Float>(planet.orbitRadius, 0, 0)
                    entity.components.set(InputTargetComponent())
                    entity.components.set(
                        CollisionComponent(shapes: [.generateSphere(radius: planet.radius)])
                    )
                }

                root.addChild(entity)
            }

            content.add(root)

            _ = content.subscribe(to: SceneEvents.Update.self) { event in
                let delta = Float(event.deltaTime)

                for planet in model.planets where !planet.isSun {
                    guard let entity = root.findEntity(named: planet.name) else { continue }

                    let currentOrbitAngle = (orbitAngles[planet.name] ?? 0) + delta * planet.orbitSpeed
                    orbitAngles[planet.name] = currentOrbitAngle

                    let x = cos(currentOrbitAngle) * planet.orbitRadius
                    let z = sin(currentOrbitAngle) * planet.orbitRadius
                    entity.position = SIMD3<Float>(x, 0, z)

                    let currentSpinAngle = (spinAngles[planet.name] ?? 0) + delta * 2.0
                    spinAngles[planet.name] = currentSpinAngle
                    entity.transform.rotation = simd_quatf(
                        angle: currentSpinAngle,
                        axis: SIMD3<Float>(0, 1, 0)
                    )
                }
            }
        }
    }
}

Your solar system is alive. Next up, you'll make the planets interactive by adding tap gestures and visual feedback.

Add Tap Interaction and Visual Feedback

Your solar system is alive with motion. The planets orbit the sun and spin on their axes, all driven by the per-frame animation you built in the last step.

Right now, the user can watch the planets but can't interact with them. In this step, you'll add tap gesture handling so the user can select any planet and see it respond with a satisfying scale-up effect.

In this step, get ready to:
  • Add a tap gesture to detect which planet the user taps.
  • Scale up the selected planet as visual feedback.
  • Test the interaction in the Simulator.
Add a tap gesture to the RealityView

In the previous step, you gave each planet an InputTargetComponent and a CollisionComponent. Those two components made the planet entities eligible to receive gestures. Now you need to attach the actual gesture that responds when a user taps one.

  • In SolarSystemView.swift, find the closing } of the RealityView make closure. Add the following .gesture modifier directly after it:
.gesture(
    TapGesture()
        .targetedToAnyEntity()
        .onEnded { value in
            // Read the tapped entity's name
            let tappedName = value.entity.name
            // Find the matching planet and select it
            model.selectedPlanet = model.planets.first { $0.name == tappedName }
        }
)

What does this code do?

  • TapGesture().targetedToAnyEntity() listens for tap gestures on any entity inside the RealityView that has an InputTargetComponent and CollisionComponent. The Sun doesn't have these components, so it's automatically excluded.
  • value.entity.name gives you the name string you assigned to each entity when you created it (e.g. "Earth"). This is how you identify which planet was tapped.
  • model.planets.first { $0.name == tappedName } looks up the matching Planet struct from your data model and stores it in selectedPlanet.
  • Save SolarSystemView.swift.

You won't see a visible change yet. The gesture updates the data model behind the scenes, but nothing in the view reacts to that change. The next substep fixes that.

Scale the selected planet with the update closure

A RealityView supports two closures. The make closure runs once when the view first appears. The update: closure runs every time SwiftUI state changes. When selectedPlanet changes, you want the selected entity to scale up and all others to reset.

  • In SolarSystemView.swift, find the closing } of the RealityView make closure. This is the } just above the .gesture modifier you just added.
  • Replace that single } with the following code, which closes the make closure and immediately opens an update: closure:
} update: { content in
    guard let root = content.entities.first else { return }

    // Loop through all planets (skip the Sun)
    for planet in model.planets where !planet.isSun {
        guard let entity = root.findEntity(named: planet.name) else { continue }

        // Scale up the selected planet, reset others
        if model.selectedPlanet?.name == planet.name {
            entity.transform.scale = SIMD3<Float>(repeating: 1.5)
        } else {
            entity.transform.scale = SIMD3<Float>(repeating: 1.0)
        }
    }
}

What does this code do?

  • content.entities.first grabs the root entity you created in the make closure. All planet entities are children of this root.
  • findEntity(named:) searches the entity hierarchy by the name string you assigned when building each planet.
  • The if/else block compares each planet's name to the selected planet's name. A match gets scaled to 1.5x its original size. Everything else resets to 1.0.
  • Setting transform.scale does not interfere with the transform.rotation being set in the animation loop. The Transform component stores rotation, scale, and translation separately.

Why use the update closure instead of the animation loop?

The SceneEvents.Update loop runs every single frame (up to 90 times per second on Apple Vision Pro). Checking the selection state every frame would work, but it's wasteful. The update: closure only fires when SwiftUI state actually changes, so the scale logic runs only when the user taps a new planet.

  • Save SolarSystemView.swift.

Seeing a compiler error?

  • Make sure the } update: { content in line replaced the old standalone }. If you pasted it below the old brace instead, you'll have an extra } causing a syntax error.
  • The update: closure must come directly after the make closure and before .gesture. Check that the order is: make closure, then update closure, then gesture modifier.
  • Still stuck? Help me fix a compiler error in my RealityView update closure.
Test the tap interaction in the Simulator
  • Run the app by pressing Cmd+R.
  • In the visionOS Simulator, click on one of the orbiting planets.

The planet you tapped should scale up to 1.5x its original size while continuing to orbit and spin. All other planets stay at their normal size.

  • Click on a different planet to confirm the selection switches. The previously selected planet shrinks back, and the new one scales up.

Tapping a planet does nothing?

  • Confirm that each planet entity (except the Sun) has both an InputTargetComponent() and a CollisionComponent. Without both, the entity is invisible to gestures.
  • Check that the CollisionComponent radius matches the planet's radius. If the collision shape is too small, your tap might miss it.
  • Make sure your .gesture modifier is attached to the RealityView itself, not to another view inside body.
  • Need more help? Help me debug why tapping planets in my RealityView doesn't trigger the gesture.

Awesome, I've got everything!

Double check you've saved SolarSystemView.swift before moving on.

I'd like to double check the full code

Here is the complete SolarSystemView.swift file as it should look at the end of this step.

import SwiftUI
import RealityKit

struct SolarSystemView: View {
    @Environment(SolarSystemModel.self) private var model

    @State private var orbitAngles: [String: Float] = [:]
    @State private var spinAngles: [String: Float] = [:]

    var body: some View {
        RealityView { content in
            let root = Entity()

            for planet in model.planets {
                let mesh = MeshResource.generateSphere(radius: planet.radius)
                let material = SimpleMaterial(
                    color: UIColor(planet.color),
                    isMetallic: planet.isSun ? false : true
                )
                let entity = ModelEntity(mesh: mesh, materials: [material])
                entity.name = planet.name

                if planet.isSun {
                    entity.position = SIMD3<Float>(0, 0, 0)
                } else {
                    entity.position = SIMD3<Float>(planet.orbitRadius, 0, 0)
                    entity.components.set(InputTargetComponent())
                    entity.components.set(
                        CollisionComponent(shapes: [.generateSphere(radius: planet.radius)])
                    )
                }

                root.addChild(entity)
            }

            content.add(root)

            _ = content.subscribe(to: SceneEvents.Update.self) { event in
                let delta = Float(event.deltaTime)

                for planet in model.planets where !planet.isSun {
                    guard let entity = root.findEntity(named: planet.name) else { continue }

                    let currentOrbitAngle = (orbitAngles[planet.name] ?? 0) + delta * planet.orbitSpeed
                    orbitAngles[planet.name] = currentOrbitAngle

                    let x = cos(currentOrbitAngle) * planet.orbitRadius
                    let z = sin(currentOrbitAngle) * planet.orbitRadius
                    entity.position = SIMD3<Float>(x, 0, z)

                    let currentSpinAngle = (spinAngles[planet.name] ?? 0) + delta * 2.0
                    spinAngles[planet.name] = currentSpinAngle
                    entity.transform.rotation = simd_quatf(
                        angle: currentSpinAngle,
                        axis: SIMD3<Float>(0, 1, 0)
                    )
                }
            }
        } update: { content in
            guard let root = content.entities.first else { return }

            for planet in model.planets where !planet.isSun {
                guard let entity = root.findEntity(named: planet.name) else { continue }

                if model.selectedPlanet?.name == planet.name {
                    entity.transform.scale = SIMD3<Float>(repeating: 1.5)
                } else {
                    entity.transform.scale = SIMD3<Float>(repeating: 1.0)
                }
            }
        }
        .gesture(
            TapGesture()
                .targetedToAnyEntity()
                .onEnded { value in
                    let tappedName = value.entity.name
                    model.selectedPlanet = model.planets.first { $0.name == tappedName }
                }
        )
    }
}

Your planets are now interactive. The selectedPlanet value lives in the shared model, which means any other view in the app can read it. Next up, you'll create a detail window that opens when a planet is selected and displays information about it.

Open a Detail Window on Selection

Your planets respond to taps with a satisfying scale-up effect. Now it's time to turn that interaction into something more useful by showing actual planet information.

In this step, you'll create a detail panel that displays facts about the selected planet. This panel opens as a separate flat window next to the volumetric solar system, demonstrating a core visionOS pattern for multi-window spatial apps.

In this step, get ready to:
  • Create a PlanetDetailView to display planet information.
  • Register a second window scene in the app.
  • Open the detail window when a planet is tapped.
Create the planet detail view

The detail view reads the selected planet from the shared SolarSystemModel and displays its name, description, and key stats. It also handles the case where no planet is selected yet.

  • In Xcode, right-click the PlanetExplorer folder in the Project Navigator sidebar.
  • Select New File from Template.
  • Choose Swift File and click Next.
  • Name the file PlanetDetailView and click Create.
  • Replace the contents of PlanetDetailView.swift with the following code:
import SwiftUI

struct PlanetDetailView: View {
    // Read the shared model to access the selected planet
    @Environment(SolarSystemModel.self) private var model

    var body: some View {
        Group {
            if let planet = model.selectedPlanet {
                VStack(alignment: .leading, spacing: 16) {
                    // Planet name with a colored circle indicator
                    HStack {
                        Circle()
                            .fill(planet.color)
                            .frame(width: 24, height: 24)
                        Text(planet.name)
                            .font(.largeTitle)
                            .fontWeight(.bold)
                    }

                    // Planet description
                    Text(planet.description)
                        .font(.body)
                        .foregroundStyle(.secondary)

                    Divider()

                    // Key facts displayed as labeled rows
                    VStack(alignment: .leading, spacing: 8) {
                        Label("Radius: \(String(format: "%.3f", planet.radius))m", systemImage: "circle")
                        Label("Orbit Distance: \(String(format: "%.2f", planet.orbitRadius))m", systemImage: "circle.dashed")
                        Label("Orbit Speed: \(String(format: "%.1f", planet.orbitSpeed))x", systemImage: "arrow.trianglehead.clockwise")
                    }
                    .font(.callout)
                }
                .padding(24)
                .frame(width: 300, height: 250)
            } else {
                // Empty state when no planet is selected
                ContentUnavailableView(
                    "No Planet Selected",
                    systemImage: "globe",
                    description: Text("Tap a planet in the solar system to see its details.")
                )
                .frame(width: 300, height: 250)
            }
        }
    }
}

What does this code do?

  • @Environment(SolarSystemModel.self) reads the shared model so this view always knows which planet is selected.
  • if let planet = model.selectedPlanet safely unwraps the optional. If a planet is selected, it shows the detail layout. If not, it shows a ContentUnavailableView placeholder.
  • The three Label views display the planet's radius, orbit distance, and orbit speed with SF Symbols icons alongside each fact.
  • .frame(width: 300, height: 250) sets a fixed size for the detail panel content.
  • Save PlanetDetailView.swift with Cmd+S.

You won't see a change yet. The detail view needs to be connected to a window scene before it can appear.

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

import SwiftUI

struct PlanetDetailView: View {
    // Read the shared model to access the selected planet
    @Environment(SolarSystemModel.self) private var model

    var body: some View {
        Group {
            if let planet = model.selectedPlanet {
                VStack(alignment: .leading, spacing: 16) {
                    // Planet name with a colored circle indicator
                    HStack {
                        Circle()
                            .fill(planet.color)
                            .frame(width: 24, height: 24)
                        Text(planet.name)
                            .font(.largeTitle)
                            .fontWeight(.bold)
                    }

                    // Planet description
                    Text(planet.description)
                        .font(.body)
                        .foregroundStyle(.secondary)

                    Divider()

                    // Key facts displayed as labeled rows
                    VStack(alignment: .leading, spacing: 8) {
                        Label("Radius: \(String(format: "%.3f", planet.radius))m", systemImage: "circle")
                        Label("Orbit Distance: \(String(format: "%.2f", planet.orbitRadius))m", systemImage: "circle.dashed")
                        Label("Orbit Speed: \(String(format: "%.1f", planet.orbitSpeed))x", systemImage: "arrow.trianglehead.clockwise")
                    }
                    .font(.callout)
                }
                .padding(24)
                .frame(width: 300, height: 250)
            } else {
                // Empty state when no planet is selected
                ContentUnavailableView(
                    "No Planet Selected",
                    systemImage: "globe",
                    description: Text("Tap a planet in the solar system to see its details.")
                )
                .frame(width: 300, height: 250)
            }
        }
    }
}
Register a second window scene

visionOS apps can display multiple windows at the same time. Each window is declared as a separate WindowGroup scene in the app's body.

You'll add a second window that stays in the default flat style. This makes it appear as a 2D info panel alongside your volumetric solar system.

  • Open PlanetExplorerApp.swift.
  • Find the .defaultSize(width: 0.6, height: 0.4, depth: 0.6, in: .meters) line at the end of the first WindowGroup.
  • Add the following code directly below that line:
// Second window: flat detail panel for planet info
WindowGroup(id: "planetDetail") {
    PlanetDetailView()
        .environment(solarSystemModel)
}
.defaultSize(width: 350, height: 300)

What does this code do?

  • WindowGroup(id: "planetDetail") declares a second window scene with the identifier planetDetail. You'll use this ID to open the window programmatically.
  • .environment(solarSystemModel) passes the same shared model so the detail view can read selectedPlanet.
  • No .windowStyle(.volumetric) is applied, so this window uses the default flat style. It appears as a standard 2D panel in the user's space.

Why a flat window instead of volumetric?

The planet facts are purely textual. A flat window is the right container for 2D content like text and labels. Mixing a flat detail panel with a volumetric 3D scene is a common visionOS pattern for spatial apps that combine information and interaction.

  • Save PlanetExplorerApp.swift.

You won't see a change yet. The next substep connects the tap gesture to this window so it opens when you select a planet.

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

import SwiftUI

@main
struct PlanetExplorerApp: App {
    @State private var solarSystemModel = SolarSystemModel()

    var body: some Scene {
        WindowGroup {
            SolarSystemView()
                .environment(solarSystemModel)
        }
        .windowStyle(.volumetric)
        .defaultSize(width: 0.6, height: 0.4, depth: 0.6, in: .meters)

        WindowGroup(id: "planetDetail") {
            PlanetDetailView()
                .environment(solarSystemModel)
        }
        .defaultSize(width: 350, height: 300)
    }
}
Wire up the open window action

The detail view and its window scene are ready. Now you need to tell SolarSystemView to open that window whenever the user taps a planet.

  • Open SolarSystemView.swift.
  • Find this line near the top of the struct:
@Environment(SolarSystemModel.self) private var model
  • Add this new line directly below it:
@Environment(\.openWindow) private var openWindow

What is openWindow?

openWindow is a SwiftUI environment action that opens another window by its ID. It's the programmatic way to launch additional windows in a visionOS app.

Next, you'll add the openWindow call inside the existing tap gesture handler.

  • Scroll down to the .gesture modifier at the bottom of the RealityView.
  • Find this line inside the .onEnded closure:
model.selectedPlanet = model.planets.first { $0.name == tappedName }
  • Add this line directly below it, so the detail window opens right after the planet is selected:
openWindow(id: "planetDetail")

What does the gesture handler look like now?

The complete gesture block should now look like this:

.gesture(
    TapGesture()
        .targetedToAnyEntity()
        .onEnded { value in
            let tappedName = value.entity.name
            model.selectedPlanet = model.planets.first { $0.name == tappedName }
            openWindow(id: "planetDetail")
        }
)
  • Save SolarSystemView.swift.
  • Run the app by pressing Cmd+R.
  • Tap any planet in the volumetric window.

A flat detail window appears alongside the volumetric solar system. It shows the selected planet's name, description, and key facts. Tapping a different planet updates the detail panel with that planet's information.

Detail window not appearing?

  • Make sure the id string in openWindow(id: "planetDetail") matches exactly with WindowGroup(id: "planetDetail") in PlanetExplorerApp.swift. The IDs are case-sensitive.
  • Confirm that @Environment(\.openWindow) private var openWindow is declared inside the SolarSystemView struct, not inside a function or closure.
  • Check that UIApplicationSupportsMultipleScenes is set to true in your Info.plist. The default visionOS template sets this to true, but verify it hasn't changed.

Still stuck? Ask for help.

Awesome, I've got everything!

Great. Double check you've saved all your files.

I'd like to double check the full code

import SwiftUI
import RealityKit

struct SolarSystemView: View {
    @Environment(SolarSystemModel.self) private var model
    @Environment(\.openWindow) private var openWindow

    @State private var orbitAngles: [String: Float] = [:]
    @State private var spinAngles: [String: Float] = [:]

    var body: some View {
        RealityView { content in
            let root = Entity()

            for planet in model.planets {
                let mesh = MeshResource.generateSphere(radius: planet.radius)
                let material = SimpleMaterial(
                    color: UIColor(planet.color),
                    isMetallic: planet.isSun ? false : true
                )
                let entity = ModelEntity(mesh: mesh, materials: [material])
                entity.name = planet.name

                if planet.isSun {
                    entity.position = SIMD3<Float>(0, 0, 0)
                } else {
                    entity.position = SIMD3<Float>(planet.orbitRadius, 0, 0)
                    entity.components.set(InputTargetComponent())
                    entity.components.set(
                        CollisionComponent(shapes: [.generateSphere(radius: planet.radius)])
                    )
                }

                root.addChild(entity)
            }

            content.add(root)

            _ = content.subscribe(to: SceneEvents.Update.self) { event in
                let delta = Float(event.deltaTime)

                for planet in model.planets where !planet.isSun {
                    guard let entity = root.findEntity(named: planet.name) else { continue }

                    let currentOrbitAngle = (orbitAngles[planet.name] ?? 0) + delta * planet.orbitSpeed
                    orbitAngles[planet.name] = currentOrbitAngle

                    let x = cos(currentOrbitAngle) * planet.orbitRadius
                    let z = sin(currentOrbitAngle) * planet.orbitRadius
                    entity.position = SIMD3<Float>(x, 0, z)

                    let currentSpinAngle = (spinAngles[planet.name] ?? 0) + delta * 2.0
                    spinAngles[planet.name] = currentSpinAngle
                    entity.transform.rotation = simd_quatf(
                        angle: currentSpinAngle,
                        axis: SIMD3<Float>(0, 1, 0)
                    )
                }
            }
        } update: { content in
            guard let root = content.entities.first else { return }

            for planet in model.planets where !planet.isSun {
                guard let entity = root.findEntity(named: planet.name) else { continue }

                if model.selectedPlanet?.name == planet.name {
                    entity.transform.scale = SIMD3<Float>(repeating: 1.5)
                } else {
                    entity.transform.scale = SIMD3<Float>(repeating: 1.0)
                }
            }
        }
        .gesture(
            TapGesture()
                .targetedToAnyEntity()
                .onEnded { value in
                    let tappedName = value.entity.name
                    model.selectedPlanet = model.planets.first { $0.name == tappedName }
                    openWindow(id: "planetDetail")
                }
        )
    }
}

Secret mission

Add an Immersive Space Mode

Your solar system is trapped inside a volumetric window. What if you could break it free and surround yourself with orbiting planets at full scale? In this secret mission, you will add an immersive space mode that places the solar system directly into the user's environment using mixed immersion.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Xcode and the visionOS Simulator, so there are no ongoing costs.

Resources you used:

  • PlanetExplorer Xcode project (local files on your Mac).
  • visionOS Simulator runtime (downloaded within Xcode).
  • Simulator app data (generated when you run the app in the visionOS Simulator).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Xcode project stays on disk and is ready to open anytime.
  • The visionOS Simulator runtime stays installed in Xcode for future projects.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your project files stay on disk so you can pick up right where you left off.

  • Stop the running app in Xcode by clicking the Stop button (or pressing Cmd+.).
  • Quit the visionOS Simulator from the menu bar to free up system memory.
  • Optionally quit Xcode if you are done for the day.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to fully clean up.

  • Stop the running app in Xcode and quit the visionOS Simulator.
  • Delete the PlanetExplorer project folder from Finder by moving it to the Trash.
  • Optionally remove the visionOS Simulator runtime to reclaim disk space. Go to Xcode > Settings > Platforms, select the visionOS simulator, and click the delete button.
  • Empty the Trash to permanently remove the files.

Mission accomplished

That's a Wrap!

Nice work! You just built an interactive 3D Planet Explorer for visionOS, complete with animated orbits, tap interactions, and a multi-window spatial UI.

Here's what you learned:

  • Built a visionOS app with a volumetric window that displays a 3D solar system using SwiftUI, RealityKit, and programmatically generated sphere entities.
  • Implemented per-frame orbital animation using SceneEvents.Update, trigonometric math for orbit paths, and quaternion rotation (simd_quatf) for planet spin.
  • Made 3D entities interactive with tap gestures and connected them to a detail window using openWindow(id:) for a true multi-window spatial experience.
  • Added an immersive space mode using ImmersiveSpace and openImmersiveSpace so the solar system surrounds the user at full scale.

Ready to quiz yourself?