🎃

visionOSで天井に空を描画する

2024/02/22に公開

key-visual

概要

今月はApple Vision Proの発売記念ということで1ヶ月記事チャレンジを行っています。
この記事はその2/22の記事です!

2/21の記事はこちら (Apple Vision Pro で変わる体験)

MESONでは「SunnyTune」という天気を体感できるアプリを開発し、Apple Vision Proのローンチに合わせてリリースしています!
SunnyTune
https://prtimes.jp/main/html/rd/p/000000055.000032228.html


今回はMR表現でよくある天井に空を描画する方法をVisionProでどうやって実装するかについて解説していこうと思います。
PlaneDetection についてはシミュレーターでは動作しないため、実機がないと確認ができませんが、どのように実装するかだけでも見ていただけたらと思います!

シーンの準備

まずは空を描画するためのシーンをセットアップします。
RealityComposerProで空の準備をしていきます。
空のEntityのWorldを作成しその下に Sphere を作成し Skyと名前をつけています。
また、空のシェーダー用のマテリアルを作成し、SkyMaterial とします。

scene_setup

シーンを読み込みそのシーンから WorldSky を取得します。
Worldはポータルのターゲットとして使用するためWorldComponentをつけています。
また、Sky のサイズを大きくして スケールを -x にすることで法線を反転させて、内側が描画されるようにしています。
visionOSではカリングモードを変更することができないため、この方法で対応しています。

init(scene:Entity) {
    self.scene = scene
    self.world = scene.findEntity(named: "World")
    self.world?.components[WorldComponent.self] = .init()
    
    self.sky = scene.findEntity(named: "Sky")
    self.sky?.scale *= .init(x: -100, y: 100, z: 100)
    self.sky?.position += SIMD3<Float>(0.0, 1.0, 0.0)
}

空のシェーダー作成

次に空用のシェーダーを作成します。
空のFractalNoiseのノードを使用して、ノイズを雲として使用して、空のカラーに加算しています。
World空間のPositionに対して時間を足してノイズを動かして、雲の動きを表現しています。

sky_shader

PlaneDetectionの設定

今回は空間を平面で検知することができる PlaneDetection を作成しておきます。
alignments にはどの平面を検知するかを指定するのですが、.horizontalは重力方向に直交する平面、.verticalは重力方向と平行な平面を取得することができます。
今回は天井のみで良いので .horizontal のみを指定しておきます。

private let session = ARKitSession()
private let planeData = PlaneDetectionProvider(alignments: [.horizontal])

sessionrun に 作成した planeDataを渡してARKitSessionを動作させます。
anchorUpdatesで検知した情報があれば処理を行います。

func run() async {
    try await session.run([planeData])
    for await update in planeData.anchorUpdates {
        if update.anchor.classification != .window && update.anchor.classification != .ceiling {
            continue
        }
        switch update.event {
        case .added, .updated:
            await updatePlane(update.anchor)
        case .removed:
            await removePlane(update.anchor)
        }
    }
}

ポータルの設定

updatePlaneでは検知した PlaneAnchorからModelEntityを生成しています。
作成したメッシュを使ってModelEntityを作成し、 PortalMaterial() と、PortalComponentをつけることで、ポータルとして使用できるようにしています。
ポータルは PortalComponent に指定して WorldComponentがついているEntity以下にあるオブジェクトをポータルを通してでしか見えないようにすることができます。

@MainActor
func updatePlane(_ anchor: PlaneAnchor) async {
    if planeAnchors[anchor.id] == nil {
        if let mesh = try? await generateMesh(anchor) {
            let portalMaterial = PortalMaterial()
            let entity = ModelEntity(mesh: mesh, materials: [portalMaterial])
            entity.components[PortalComponent.self] = .init(target: self.world!)
            entityMap[anchor.id] = entity
            self.scene.addChild(entity)
        }
    }
    entityMap[anchor.id]?.transform = Transform(matrix: anchor.originFromAnchorTransform)
}

メッシュの生成

generateMeshで実際に検知したPlaneAnchorからメッシュの生成処理を行っています。
geometryに入っているmeshVerticesmeshFacesから頂点情報と頂点インデックスを取得して、メッシュを生成しています。

advanced は指定したバイト数をずらしたポインタ位置が取得できます。 bytesPerIndexにはバッファのインデックス一つ分のバイトサイズが入っているので、index * geometry.meshFaces.bytesPerIndexを行うことで、指定した位置の頂点インデックスの情報を取得して、assumingMemoryBound(to: UInt32.self).pointeeUInt32に変換しています。

@MainActor
func generateMesh(_ anchor:PlaneAnchor) async throws -> MeshResource {
    let geometry = anchor.geometry
    let vertices = geometry.meshVertices.asSIMD3(ofType: Float.self)
    let primitiveIndexCount = geometry.meshFaces.primitive.indexCount
    
    let primitiveIndexCounts = (0..<geometry.meshFaces.count).map { _ in UInt8(primitiveIndexCount) }
    let indices = (0..<geometry.meshFaces.count * primitiveIndexCount).map { index in 
        geometry.meshFaces.buffer.contents()
            .advanced(by: index * geometry.meshFaces.bytesPerIndex)
            .assumingMemoryBound(to: UInt32.self).pointee
    }
    
    var descriptor: MeshDescriptor = MeshDescriptor()
    descriptor.positions = .init(vertices)
    descriptor.primitives = .polygons(primitiveIndexCounts, indices)
    let meshResource = try await MeshResource(from: [descriptor])
    return meshResource
}

asSIMD3GeometrySourceのextensionとして、Bufferから指定したフォーマットの配列を取得しやすいようにしています。
これらの実装はこちらのフォーラムを参考に実装しています。
https://forums.developer.apple.com/forums/thread/695229

extension GeometrySource {
    func asArray<T>(ofType: T.Type) -> [T] {
        dispatchPrecondition(condition: .onQueue(.main))
        assert(MemoryLayout<T>.stride == stride, "Invalid stride \(MemoryLayout<T>.stride); expected \(stride)")
        return (0..<self.count).map {
            buffer.contents().advanced(by: offset + stride * Int($0)).assumingMemoryBound(to: T.self).pointee
        }
    }
    func asSIMD3<T>(ofType: T.Type) -> [SIMD3<T>] {
        return asArray(ofType: (T, T, T).self).map { .init($0.0, $0.1, $0.2) }
    }
}

描画結果

これで一通りの実装ができたので実機で確認してみると、空になっているのが確認できます。
ポータルを使用して天井を抜いているので、動画だと分かりにくいですが奥行き感のある空になっています。
sky_ceiling

まとめ

Apple Vision Proでも他のQuest3のように天井を抜くことができました。
Quest3ではルーム設定が行えるために、天井全体に空を描画することができますが、visionOSではルーム設定が存在しないため、検知した面しか抜くことはできません。
部屋全体に何か処理を行う際にはルーム設定が行えるQuestの方が作りやすいかもしれませんね。

最後にコード全文を載せておきますので参考にしてみてください!

コード全文
//
//  PlaneDetectorHandler.swift
//  SkyCeiling
//
//  Created by hisaki sato on 2024/02/22.
//

import RealityKit
import ARKit
import SwiftUI

extension GeometrySource {
    func asArray<T>(ofType: T.Type) -> [T] {
        dispatchPrecondition(condition: .onQueue(.main))
        assert(MemoryLayout<T>.stride == stride, "Invalid stride \(MemoryLayout<T>.stride); expected \(stride)")
        return (0..<self.count).map {
            buffer.contents().advanced(by: offset + stride * Int($0)).assumingMemoryBound(to: T.self).pointee
        }
    }
    func asSIMD3<T>(ofType: T.Type) -> [SIMD3<T>] {
        return asArray(ofType: (T, T, T).self).map { .init($0.0, $0.1, $0.2) }
    }
}

final class PlaneDetectionHandler {
    private let scene: Entity
    private let world: Entity?
    private let sky: Entity?
    private let session = ARKitSession()
    private let planeData = PlaneDetectionProvider(alignments: [.horizontal])
    @MainActor var planeAnchors: [UUID: PlaneAnchor] = [:]
    @MainActor var entityMap: [UUID: Entity] = [:]
    
    init(scene:Entity) {
        self.scene = scene
        self.world = scene.findEntity(named: "World")
        self.world?.components[WorldComponent.self] = .init()
        
        self.sky = scene.findEntity(named: "Sky")
        self.sky?.scale *= .init(x: -100, y: 100, z: 100)
        self.sky?.position += SIMD3<Float>(0.0, 1.0, 0.0)
    }
    
    func run() async {
        guard PlaneDetectionProvider.isSupported else {
            print("PlaneDetectionProvider is not supported.")
            return
        }
        do {
            try await session.run([planeData])
            for await update in planeData.anchorUpdates {
                if update.anchor.classification != .window && update.anchor.classification != .ceiling {
                    continue
                }
                switch update.event {
                case .added, .updated:
                    await updatePlane(update.anchor)
                case .removed:
                    await removePlane(update.anchor)
                }
            }
        } catch {
            print("ARKit session error \(error)")
        }
    }
    
    @MainActor
    func updatePlane(_ anchor: PlaneAnchor) async {
        if planeAnchors[anchor.id] == nil {
            if let mesh = try? await generateMesh(anchor) {
                let portalMaterial = PortalMaterial()
                let entity = ModelEntity(mesh: mesh, materials: [portalMaterial])
                entity.components[PortalComponent.self] = .init(target: self.world!)
                entityMap[anchor.id] = entity
                self.scene.addChild(entity)
            }
        }
        entityMap[anchor.id]?.transform = Transform(matrix: anchor.originFromAnchorTransform)
    }
    
    @MainActor
    func generateMesh(_ anchor:PlaneAnchor) async throws -> MeshResource {
        let geometry = anchor.geometry
        let vertices = geometry.meshVertices.asSIMD3(ofType: Float.self)
        let primitiveIndexCount = geometry.meshFaces.primitive.indexCount
        
        //https://forums.developer.apple.com/forums/thread/695229
        let primitiveIndexCounts = (0..<geometry.meshFaces.count).map { _ in UInt8(primitiveIndexCount) }
        let indices = (0..<geometry.meshFaces.count * primitiveIndexCount).map {
            geometry.meshFaces.buffer.contents()
                .advanced(by: $0 * geometry.meshFaces.bytesPerIndex)
                .assumingMemoryBound(to: UInt32.self).pointee
        }
        
        var descriptor: MeshDescriptor = MeshDescriptor()
        descriptor.positions = .init(vertices)
        descriptor.primitives = .polygons(primitiveIndexCounts, indices)
        let meshResource = try await MeshResource(from: [descriptor])
        return meshResource
    }

    @MainActor
    func removePlane(_ anchor: PlaneAnchor) {
        entityMap[anchor.id]?.removeFromParent()
        entityMap.removeValue(forKey: anchor.id)
        planeAnchors.removeValue(forKey: anchor.id)
    }
}

書いた人

ひー

佐藤 寿樹

株式会社コナミデジタルエンタテインメントに入社し5年間ウイニングイレブンのオンライン実装に携わる。
その後、株式会社コロプラで9年間エンジニアとしてアプリ開発・運用を行い、位置情報やARを使用したARゲーム開発、OculusRiftやPSVRなどのVRゲーム開発を経験しMESONへ入社。

X

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

Discussion