見出し画像

visionOSでウィンドウに3Dコンテンツが見えるポータルを表示してみた

去年リリースしたSpace Time Attackというハンドトラッキングを活用した空間ゲームでは、タイトルウィンドウにスペースシップの3DモデルやSkyboxを表示していて、PLAYボタンでImmersive Spaceに入れるようになっています。

しかし、タイトルウィンドウ表示だけではImmersive Spaceに入った後に、どのような空間が広がり、どのようにプレイするのかがわかりにくかったので、今回はDisplaying a 3D environment through a portalという公式のサンプルコードを参考にしながら、タイトルウィンドウに3Dコンテンツが見えるポータルを表示して、さらに選択中のスペースシップがデモフライトを行うようにしてみました!

ウィンドウにポータルを表示する

ウィンドウに3Dコンテンツのポータルを表示するには、まずRealityViewの初期化でポータルで表示したいコンテンツのEntityを用意して、WorldComponentを設定します。公式ドキュメントによれば、WorldComponentを設定したentityとその子entitiesはデフォルトのワールド表示から分離され、ポータルを通してしか表示されなくなるとのことです。

次にポータル自体のentity(平面など)を用意して、PortalComponentのtargetに先程のWorldComponentを設定したentityを指定することで、ポータルと3Dコンテンツが紐づけられ、ポータルを通して3Dコンテンツを見ることができるようになります。

struct TitleView: View {
	var body: some View {
	    ZStack(alignment: .bottom) {
	        portalView
	        
	        // title and PLAY button
	        titleView
	    }
	    .frame(width: 800, height: 600)
	}
	
	var portalView: some View {
	    GeometryReader3D { geometry in
	        RealityView { content in
	            try? await createPortal()
	            content.add(root)
	        } update: { content in
	            // RealityViewのコンテンツサイズをもとにポータルサイズを更新する
	            let size = content.convert(geometry.size, from: .local, to: .scene)
	            updatePortalSize(width: size.x, height: size.y)
	        }
	        .frame(depth: 0.4)
	    }
	    .frame(depth: 0.4)
	}
}
@MainActor @Observable
final class TitleViewModel {
    var rootEntity: Entity?
    var worldEntiry: Entity?
    var skyboxEntity: Entity?
    var shipEntity: Entity?
    
    // ポータルを平面で生成
    private let portalPlane = ModelEntity(
        mesh: .generatePlane(width: 1.0, height: 1.0),
        materials: [PortalMaterial()]
    )
    
    func setupPortal() async {
        // ポータルを通して見るコンテンツのentity
        let world = Entity()
        worldEntiry = world
        
        world.scale *= 0.5
        world.position.y -= 0.5
        world.position.z -= 0.5
        
        // WorldComponentを設定することで、world entityをポータルを通して見れるようにする
        world.components.set(WorldComponent())
        
        do {
            if let skybox = createSkybox(UserDefaultsWrapper.skybox()) {
                skyboxEntity = skybox
                world.addChild(skybox)
            }
            
            let ship = try await createShip(UserDefaultsWrapper.shipModel())
            shipEntity = ship
            world.addChild(ship)
            
            let checkpoint = await createCheckpoint()
            world.addChild(checkpoint)
            
            rootEntity?.addChild(world)
            
            // portalPlaneとworldを紐づけて、ポータルを通してコンテンツを見れるようにする
            portalPlane.components.set(PortalComponent(target: world))
            rootEntity?.addChild(portalPlane)
        } catch {
            fatalError("Failed to create environment: \\(error)")
        }
    }
    
    // ポータルのメッシュサイズを設定する
    func updatePortalSize(width: Float, height: Float) {
        portalPlane.model?.mesh = .generatePlane(width: width, height: height, cornerRadius: 0.03)
    }
}

ポータル内でデモフライト

ウィンドウに3Dコンテンツが見えるポータルを表示できましたが、スペースシップが静止しているだけだと味気ないので、デモフライトで動きを付けてみます。

RealityKitではSystemでEntityに動きやロジックを与えられるので、今回はcreateShipでスペースシップのEntityを生成する時に、DemoFlightComponentを付与して、DemoFlightSystemでDemoFlightComponentを持つEntityに対して位置や回転を更新するようにしています。

@MainActor @Observable
final class TitleViewModel {
	private func createShip(_ shipModel: ShipModel) async throws -> ModelEntity {
	    let ship = ModelEntity()
				...
	    ship.components.set(DemoFlightComponent())
				...        
	    return ship
	}
}

struct DemoFlightComponent: Component {
    init () {
        DemoFlightSystem.registerSystem()
    }
}

final class DemoFlightSystem: System
{
    let query = EntityQuery(where: .has(DemoFlightComponent.self))
    
    private var deltaTime = 0.0
    private var angle: Double {
        deltaTime * 0.5
    }
    
    init(scene: Scene) {}
    
    func update(context: SceneUpdateContext) {
        deltaTime += context.deltaTime
        
        let entities = context.entities(matching: query, updatingSystemWhen: .rendering)
        for entity in entities {
            // チェックポイントの位置やサイズに合わせて、angleから位置や周回半径を計算して反映する
            let x = cos(angle) * 1.5
            let y = 0.6
            let z = sin(angle) * 1.5 - 3
            entity.transform.translation = .init(Float(x), Float(y), Float(z))
            
            // y軸周りで周回する円周の接線方向の角度に回転させる
            let yaw = simd_quatf(angle: -Float(angle) + .pi, axis: .upward)
            // 進行方向まわりに30°傾かせる
            let roll = simd_quatf(angle: -.pi/6, axis: .forward)
            entity.transform.rotation = yaw * roll
        }
    }
}

以上でタイトルウィンドウのポータル内で、スペースシップがチェックポイントの周りを、一定速度で周回するデモフライトができました!位置や回転の計算を工夫すれば、もっと複雑なルートで飛行させることもできそうです。

おわりに

今回はウィンドウに3Dコンテンツが見えるポータルを表示し、さらにスペースシップのデモフライトを追加してみました。Immersive Spaceに入る前に、どのような空間が広がるのかがポータルで見える体験は良さそうで、さらに動きがあるとよりワクワクする感じでした。

いいなと思ったら応援しよう!