
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に入る前に、どのような空間が広がるのかがポータルで見える体験は良さそうで、さらに動きがあるとよりワクワクする感じでした。