Dark Spaces – Devlog 008

Replacing the debug navigation with a router, starting work on the plane and cube scenes.

Work on structure and navigation

In Devlog 006 I added a way to quickly load/reload Reality Composer Pro scenes. It was a great way to let me view lots of scenes and ideas without a whole lot of code. But it is time to replace that with something more flexible. Many of these scenes will require a lot of custom code, systems, components. That code may not be used elsewhere. I decided to break each scene into a SwiftUI file, then load them as a router.

I based this on the work I did when setting up the Step Into Vision labs project.

Here is a short breakdown of how it works.

The app loads a main window with a Directory view as the root. This view gets a model with data for each scene. I can include those objects when opening an Immersive Space, and pass them on to the router view.

struct DarkSpacesApp: App {

    @State private var appModel = AppModel()
    @State private var modelData = ModelData()
    
    var body: some Scene {

        // Main window
        WindowGroup {
            Directory()
                .environment(appModel)
                .environment(modelData)
        }
        .defaultSize(CGSize(width: 800, height: 532))

        // We use a single shared space with a router view at the root
        ImmersiveSpace(id: "RouterSpaceFull", for: DSScene.self, content: { $route in
            DSRouter(route: $route)
                .environment(appModel)
        })
        .immersionStyle(selection: .full, in: .full)

    }
}

The router view will check the object, and will open the corresponding SwiftUI file. This uses ViewBuilder to render one of these many views based on the switch statement. The downside is that I have to write out the name of each SwiftUI file here. There is no way to do this programmatically from within the app.

struct DSRouter: View {
    @Binding var route: DSScene?

    @ViewBuilder
    var body: some View {
        if let dsScene = route {

            switch dsScene.file {

            case "Dark 001": Dark001(dsScene: dsScene)
            case "Dark 002": Dark002(dsScene: dsScene)
            case "Dark 003": Dark003(dsScene: dsScene)
            case "Dark 004": Dark004(dsScene: dsScene)
            case "Dark 005": Dark005(dsScene: dsScene)
            case "Dark 006": Dark006(dsScene: dsScene)

            default :
                DSRouterWarning(message: "No View could be found for " + (dsScene.file))
            }
            
        } else {
            DSRouterWarning(message: "No route passed")
        }
    }
}

I also created a new Xcode template for these SwiftUI views. This will set up the view to get me started quickly.

//  Dark Spaces
//
//  File: Dark006
//
//  Name:
//
//  Description:
//
//  Type:
//
//  Created by Joseph Simpson on 3/29/25.

import SwiftUI
import RealityKit
import RealityKitContent

struct Dark006: View {

    @State var dsScene: DSScene

    var body: some View {
        RealityView { content, attachments in

            guard let scene = try? await Entity(named: dsScene.sceneName, in: realityKitContentBundle) else { return }
            content.add(scene)

        } update: { content, attachments in

        } attachments: {
            Attachment(id: "AttachmentContent") {
                Text("")
            }
        }
    }
}

The data model is basically just a hardcoded struct with the Observable macro. I can use this to build the directory view.

@Observable
class ModelData {
    var labData: [DSScene] = [

        DSScene(title: "Dark 001",
                sceneName: "Scenes/Ch1_Planes",
                type: .SPACE_FULL,
                chapter: .chapter_one,
                date: Date("03/29/2025"),
                isFeatured: false,
                subtitle: "Welcome",
                description: "")

        ,DSScene(title: "Dark 002",
                 sceneName: "Scenes/Ch1_Cubes",
                 type: .SPACE_FULL,
                 chapter: .chapter_one,
                 date: Date("03/29/2025"),
                 isFeatured: false,
                 subtitle: "Cubes",
                 description: "")

        ,DSScene(title: "Dark 003",
                 sceneName: "Scenes/Ch1_Spheres",
                 type: .SPACE_FULL,
                 chapter: .chapter_one,
                 date: Date("03/29/2025"),
                 isFeatured: false,
                 subtitle: "Spheres",
                 description: "")

    ]

}

I kept it pretty simple with the directory for now. I’m only starting with six scenes, so I just show them all in a grid. Eventually, I’ll want a more complex interface here.

The Dark Spaces directory view
struct DSCollectionView: View {
    @Environment(ModelData.self) var modelData
    
    // Grid layout configuration
    private let columns = [
        GridItem(.flexible(), spacing: 12),
        GridItem(.flexible(), spacing: 12),
        GridItem(.flexible(), spacing: 12)
    ]

    var chapterOneDarks: [DSScene] {
        modelData.labData.filter { $0.chapter == .chapter_one }
    }

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(chapterOneDarks) { dsScene in
                    DSCollectionButton(dsScene: dsScene)
                }
            }
            .padding()
        }
    }
}

Users can tap any of these buttons to enter the space. They can tap again to exit. They can also move from one space to another (close current, open new).

I spent more time than I care to admit on the subviews here. When I have each scene done, I’ll include an image of it here instead of the text label. I needed a way to quickly show the active scene, while also letting users exit or enter a new one. I ended up using play and pause icons for now, along with some custom hover effects and groups.

struct DSCollectionButton: View {
    @Environment(AppModel.self) private var appModel
    var dsScene: DSScene
    @Namespace private var hoverNamespace

    // Window environment values
    @Environment(\.openWindow) private var openWindow
    @Environment(\.dismissWindow) private var dismissWindow
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

    // Local state
    @State private var currentRoute: String?

    init(dsScene: DSScene) {
        self.dsScene = dsScene
        _currentRoute = State(initialValue: dsScene.file)
    }

    let width: CGFloat = 240
    let height: CGFloat = 160
    let indicatorSize: CGFloat = 18

    var body: some View {
        ZStack {
            // Background
            Color.black
                .frame(width: width, height: height)
                .hoverEffect(.lift, in: HoverEffectGroup(hoverNamespace))

            // Content
            VStack {
                Text("\(dsScene.name)")
                    .font(.largeTitle)
            }

            // Open indicator
            Image(systemName: appModel.isSceneOpen(dsScene) ? "stop.fill" : "play.fill")
                .frame(width: indicatorSize, height: indicatorSize)
                .foregroundColor(.white)
                .position(x: width - indicatorSize - 8, y: indicatorSize + 8)
                .hoverEffect(in: HoverEffectGroup(hoverNamespace)) { effect, isActive, _ in
                    effect.opacity(isActive ? 1.0 : appModel.isSceneOpen(dsScene) ? 1.0 : 0)
                }
        }
        .frame(width: width, height: height)
        .clipShape(.rect(cornerRadius: 24))
        .onTapGesture {
            Task {
                await handleSpace(spaceId: "RouterSpaceFull")
            }
        }
    }
        
    /// Handles opening and closing of immersive spaces
    private func handleSpace(spaceId: String) async {
        if appModel.isSceneOpen(dsScene) {
            await closeSpace()
        } else {
            await openSpace(spaceId: spaceId)
        }
    }
    
    /// Closes the current immersive space
    private func closeSpace() async {
        appModel.immersiveSpaceState = .inTransition
        await dismissImmersiveSpace()
        appModel.removeOpenScene(dsScene)
        appModel.immersiveSpaceState = .closed
    }
    
    /// Opens a new immersive space, closing any existing space first
    private func openSpace(spaceId: String) async {
        // Close existing space if one is open
        if appModel.immersiveSpaceState == .open {
            appModel.immersiveSpaceState = .inTransition
            await dismissImmersiveSpace()
            if let currentScene = appModel.currentImmersiveScene {
                appModel.removeOpenScene(currentScene)
            }
        }
        
        // Open the new space
        appModel.immersiveSpaceState = .inTransition
        switch await openImmersiveSpace(id: spaceId, value: dsScene) {
        case .opened:
            appModel.addOpenScene(dsScene)
            appModel.immersiveSpaceState = .open
        case .error, .userCancelled:
            fallthrough
        @unknown default:
            appModel.immersiveSpaceState = .closed
        }
    }
}

Scene: Planes

The idea for this scene is to limit myself to planes as much as possible. I may have a few other entities here, such as the dome, and some light orbs, but I want to build some place that is primarily constructed our of simple planes.

I started with a function to generate a plane with some pseudo-random sizing and positioning code. I need to make some more changes this. I’d like to create other formations. I’d also like to animate the planes over time, add custom hover shaders, and add an organic feel to the space. Here is the scene for now.

struct Dark001: View {

    @State var dsScene: DSScene

    private func generateRandomTemplatePlane(material: ShaderGraphMaterial) -> ModelEntity {
        let width = Float.random(in: 1...8.0)
        let height = Float.random(in: width...(width * 8))
        let cornerRadius: Float = 0.0

        let templatePlane = ModelEntity(
            mesh: .generatePlane(width: width, height: height, cornerRadius: cornerRadius),
            materials: [material])

        let collision = CollisionComponent(shapes: [.generateBox(size: .init(x: width, y: height, z: 0.05))])
        templatePlane.components.set(collision)
        
        // Generate random X and Z positions that are outside the 5-meter radius
        var randomX: Float
        var randomZ: Float
        var distance: Float
        let size: Float = 50.0
        repeat {
            randomX = Float.random(in: -size...size)
            randomZ = Float.random(in: -size...size)
            distance = sqrt(randomX * randomX + randomZ * randomZ)
        } while distance < 5.0

        let yPosition = height / 2
        templatePlane.setPosition([randomX, yPosition, randomZ], relativeTo: nil)

        let rotationY = atan2(randomX, randomZ) + .pi // Add 180 degrees to face the correct direction
        templatePlane.setOrientation(simd_quatf(angle: rotationY, axis: [0, 1, 0]), relativeTo: nil)

        return templatePlane
    }

    var body: some View {
        RealityView { content in

            // Load the scene
            guard let scene = try? await Entity(named: dsScene.sceneName, in: realityKitContentBundle) else { return }
            content.add(scene)

            // Get the entity with the shader graph material
            guard let matEntity = scene.findEntity(named: "MatSphere") else { return }
            matEntity.isEnabled = false

            // Get the matrial
            guard let matFresnel = matEntity.components[ModelComponent.self]?.materials.first as? ShaderGraphMaterial else { return }

            for _ in 0..<144 {
                let templatePlane = generateRandomTemplatePlane(material: matFresnel)
                content.add(templatePlane)
            }

        } update: { content in
            // We'll be using this later
        }
    }
}

Scene: Cubes

For this one, I started with a custom component and system to create an array of cubes. I added the same fresnel shader to them, then placed them in a 10×10 wall. The system will run a sequence that will slowly select cubes and modify their z position values. At the end of the sequence they all snap back to the initial value. I have a lot of ideas for how to improve this one too. I might construct a large structure around the user, then slowly change it over time.

video demo of the cube wall
public struct CubeWallComponent: Component, Codable {
    var targetZPosition: Float?
    var isAnimating: Bool
    var animationDuration: Float
    var currentAnimationTime: Float
    var startZPosition: Float?
    
    public init() {
        self.isAnimating = false
        self.animationDuration = 0
        self.currentAnimationTime = 0
        self.targetZPosition = nil
        self.startZPosition = nil
    }
}

public enum WallPhase {
    case waiting
    case breakingApart
    case reassembling
    case complete
}

public class CubeWallSystem: System {
    static let query = EntityQuery(where: .has(CubeWallComponent.self))
    private var elapsedTime: Float = 0
    private var waitDuration: Float = 0
    private var phase: WallPhase = .waiting {
        didSet {
            print("Phase changed to: \(phase)")
        }
    }
    private var phaseStartTime: Float = 0
    
    // Configuration
    private let initialDelay: Float = 4.9
    private let breakingPhaseLength: Float = 56.0  // 1 minute of breaking apart
    private let reassemblyDuration: Float = 0.1    // Time to reassemble the wall

    required public init(scene: Scene) {
        self.waitDuration = initialDelay
        print("CubeWallSystem initialized with \(initialDelay) second delay")
    }
    
    public func update(context: SceneUpdateContext) {
        let deltaTime = Float(context.deltaTime)
        elapsedTime += deltaTime
        phaseStartTime += deltaTime
        
        let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
        if phase == .waiting {
            print("Waiting phase: \(elapsedTime)/\(waitDuration) seconds, found \(entities) entities")
        }
        
        switch phase {
        case .waiting:
            if elapsedTime >= waitDuration {
                phase = .breakingApart
                phaseStartTime = 0
                elapsedTime = 0
                print("Starting breaking apart phase")
            }
            
        case .breakingApart:
            if phaseStartTime >= breakingPhaseLength {
                phase = .reassembling
                phaseStartTime = 0
                startReassembly(context: context)
                print("Starting reassembly phase")
            } else {
                updateBreakingPhase(context: context, deltaTime: deltaTime)
            }
            
        case .reassembling:
            if phaseStartTime >= reassemblyDuration {
                phase = .waiting
                phaseStartTime = 0
                elapsedTime = 0
                waitDuration = initialDelay
                print("Cycle complete, waiting \(initialDelay) seconds before restarting")
            } else {
                updateReassemblyPhase(context: context, deltaTime: deltaTime)
            }
            
        case .complete:
            break
        }
    }
    
    private func updateBreakingPhase(context: SceneUpdateContext, deltaTime: Float) {
        if elapsedTime >= waitDuration {
            elapsedTime = 0
            waitDuration = Float.random(in: 0.2...0.8)
            
            let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
            let nonAnimatingEntities = entities.filter { entity in
                guard let component = entity.components[CubeWallComponent.self] else { return false }
                return !component.isAnimating
            }
            
            if let selectedEntity = nonAnimatingEntities.randomElement() {
                var component = selectedEntity.components[CubeWallComponent.self]!
                component.targetZPosition = Float.random(in: -0.25...0.25)
                component.startZPosition = selectedEntity.position.z
                component.animationDuration = Float.random(in: 0.05...0.5)
                component.currentAnimationTime = 0
                component.isAnimating = true
                selectedEntity.components[CubeWallComponent.self] = component
            }
        }
        
        updateAnimatingEntities(context: context, deltaTime: deltaTime)
    }
    
    private func startReassembly(context: SceneUpdateContext) {
        let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
        
        for entity in entities {
            if abs(entity.position.z) > 0.001 { // Check if cube is not at z=0
                var component = entity.components[CubeWallComponent.self]!
                component.targetZPosition = 0
                component.startZPosition = entity.position.z
                component.animationDuration = reassemblyDuration
                component.currentAnimationTime = 0
                component.isAnimating = true
                entity.components[CubeWallComponent.self] = component
            }
        }
    }
    
    private func updateReassemblyPhase(context: SceneUpdateContext, deltaTime: Float) {
        updateAnimatingEntities(context: context, deltaTime: deltaTime)
    }
    
    private func updateAnimatingEntities(context: SceneUpdateContext, deltaTime: Float) {
        for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
            guard var component = entity.components[CubeWallComponent.self],
                  component.isAnimating,
                  let startZ = component.startZPosition,
                  let targetZ = component.targetZPosition else {
                continue
            }
            
            component.currentAnimationTime += deltaTime
            let progress = min(component.currentAnimationTime / component.animationDuration, 1.0)
            
            let newZ = startZ + (targetZ - startZ) * progress
            var position = entity.position
            position.z = newZ
            entity.position = position
            
            if progress >= 1.0 {
                component.isAnimating = false
                component.startZPosition = nil
                component.targetZPosition = nil
            }
            
            entity.components[CubeWallComponent.self] = component
        }
    }
}

There is so much more to do and so many ideas to explore for these spaces. I’ll keep working!

Questions or feedback?