visionOSで天井に空を描画する
概要
今月はApple Vision Proの発売記念ということで1ヶ月記事チャレンジを行っています。
この記事はその2/22の記事です!
2/21の記事はこちら (Apple Vision Pro で変わる体験)
MESONでは「SunnyTune」という天気を体感できるアプリを開発し、Apple Vision Proのローンチに合わせてリリースしています!
今回はMR表現でよくある天井に空を描画する方法をVisionProでどうやって実装するかについて解説していこうと思います。
PlaneDetection
についてはシミュレーターでは動作しないため、実機がないと確認ができませんが、どのように実装するかだけでも見ていただけたらと思います!
シーンの準備
まずは空を描画するためのシーンをセットアップします。
RealityComposerPro
で空の準備をしていきます。
空のEntityのWorld
を作成しその下に Sphere
を作成し Sky
と名前をつけています。
また、空のシェーダー用のマテリアルを作成し、SkyMaterial
とします。
シーンを読み込みそのシーンから World
と Sky
を取得します。
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
に対して時間を足してノイズを動かして、雲の動きを表現しています。
PlaneDetectionの設定
今回は空間を平面で検知することができる PlaneDetection
を作成しておきます。
alignments
にはどの平面を検知するかを指定するのですが、.horizontal
は重力方向に直交する平面、.vertical
は重力方向と平行な平面を取得することができます。
今回は天井のみで良いので .horizontal
のみを指定しておきます。
private let session = ARKitSession()
private let planeData = PlaneDetectionProvider(alignments: [.horizontal])
session
の run
に 作成した 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
に入っているmeshVertices
とmeshFaces
から頂点情報と頂点インデックスを取得して、メッシュを生成しています。
advanced
は指定したバイト数をずらしたポインタ位置が取得できます。 bytesPerIndex
にはバッファのインデックス一つ分のバイトサイズが入っているので、index * geometry.meshFaces.bytesPerIndex
を行うことで、指定した位置の頂点インデックスの情報を取得して、assumingMemoryBound(to: UInt32.self).pointee
でUInt32
に変換しています。
@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
}
asSIMD3
はGeometrySource
のextensionとして、Buffer
から指定したフォーマットの配列を取得しやすいようにしています。
これらの実装はこちらのフォーラムを参考に実装しています。
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) }
}
}
描画結果
これで一通りの実装ができたので実機で確認してみると、空になっているのが確認できます。
ポータルを使用して天井を抜いているので、動画だと分かりにくいですが奥行き感のある空になっています。
まとめ
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へ入社。
MESON Works
MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。
Discussion