はじめに

こんにちは!システム開発部のYです。

WWDC2025では、visionOS 26に追加されるSwiftUIとRealityKitの連係が強化されました。
本記事では、以下のセッション内容をまとめています。詳細は WWDC セッションをご参照ください。

Better together: SwiftUI and RealityKit / SwiftUIとRealityKitの連係

目次

新APIの概要

Model3D enhancements

Animation

visionOS 26で新たに追加された機能のひとつが、Model3DAssetタイプです。
これを使用してModel3Dを構築することで、3Dコンテンツにアニメーションを読み込み、柔軟に制御できるようになります。

Model3Dをピッカー、再生ボタン、タイムスクロールバーの上に配置しています。
RobotViewではロボットのアニメーションを表示し、その下にアニメーションを選択するためのピッカーと、再生を操作するためのコントロールを配置しています。

struct RobotView: View {
  @State private var asset: Model3DAsset?
  var body: some View {
    if asset == nil {
      ProgressView().task { asset = try? await Model3DAsset(named: "sparky") }
    } else if let asset {
      VStack {
        Model3D(asset: asset)
        AnimationPicker(asset: asset)
      }
    }
  }
}

まず最初に、バンドルから読み込むシーン名を指定してModel3DAssetを初期化します。
アセットが存在する場合は、それをModel3Dのイニシャライザに渡します。
その下に、該当アセットで利用可能なアニメーションを一覧表示する、カスタマイズされたピッカーを表示します。
ピッカーで項目を選択すると、アセットのselectedAnimationに新しい値が設定されます。
その後、Model3DAssetはAnimationPlaybackControllerを作成し、選択されたアニメーションの再生を制御します。

struct RobotView: View {
  @State private var asset: Model3DAsset?
  var body: some View {
    if asset == nil {
      ProgressView().task { asset = try? await Model3DAsset(named: "sparky") }
    } else if let asset {
      VStack {
        Model3D(asset: asset)
        AnimationPicker(asset: asset)
        if let animationController = asset.animationPlaybackController {
          RobotAnimationControls(playbackController: animationController)
        }
      }
    }
  }
}

アセットはanimationPlaybackControllerを提供しており、
アニメーションの一時停止、再開、シークにはこのオブジェクトを使用します。
このanimationControllerをRobotAnimationControlsビューに渡します。

visionOS 26では、既存のAnimationPlaybackControllerがObservableに対応しました。

  • isPlaying
  • isStopped
  • isPaused
  • time: TimeInterval
struct RobotAnimationControls: View {
  @Bindable var controller: AnimationPlaybackController

  var body: some View {
    HStack {
      Button(controller.isPlaying ? "Pause" : "Play") {
        if controller.isPlaying { controller.pause() }
        else { controller.resume() }
      }

      Slider(
        value: $controller.time,
        in: 0...controller.duration
      ).id(controller)
    }
  }
}

controllerという@Bindableプロパティは、AnimationPlaybackControllerをビューのデータモデルとして使用していることを意味します。

このcontrollerのisPlayingを監視することで、ボタンの表示状態やアニメーションの再生・停止を制御しています。
また、アニメーション全体の長さに対して現在の再生位置を示すスライダーも備えています。

ConfigurationCatalog

visionOS 26では、ConfigurationCatalogを使用してModel3Dを初期化し、さまざまな表現を切り替えることができます。
USDファイルからバリエーションを読み込んだり、Realityファイルからシーン構成をロードしたりすることで、UIなどを通じて各パーツやバリエーションを柔軟に切り替えられるようになります。

struct ConfigCatalogExample: View {
  @State private var configCatalog: Entity.ConfigurationCatalog?
  @State private var configurations = [String: String]()
  @State private var showConfig = false
  var body: some View {
    if let configCatalog {
      Model3D(from: configCatalog, configurations: configurations)
        .popover(isPresented: $showConfig, arrowEdge: .leading) {
          ConfigPicker(
            name: "outfits",
            configCatalog: configCatalog,
            chosenConfig: $configurations["outfits"])
        }
    } else {
      ProgressView()
        .task {
          await loadConfigurationCatalog()
        }
    }
  }
}

いくつかの異なるボディタイプを含むRealityファイルをバンドルし、アプリのメインバンドルからConfigurationCatalogとして読み込みます。
その後、このカタログのconfigurationsを使ってModel3Dを作成します。

このポップオーバーには設定オプションが表示され、選択内容に応じてロボットの見た目が変化します。

RealityView transition

火花を散らすパーティクルを追加したい場合、Model3Dタイプでは実現できません。
Particle EmitterはRealityKitのエンティティに追加するコンポーネントですが、Model3Dはコンポーネントの追加をサポートしていないためです。
Particle Emitterを使用するには、RealityViewへの切り替えが必要です。
ここでは、レイアウトを変更せずにRealityViewへ切り替える方法をご紹介します。

struct RobotView: View {
  let url: URL = Bundle.main.url(forResource: "sparky", withExtension: "reality")!

  var body: some View {
    HStack {
      NameSign()
      RealityView { content in
        if let sparky = try? await Entity(contentsOf: url) {
          content.add(sparky)
        }
      }
    }
  }
}

まず、Model3DからRealityViewに切り替えます。
RealityViewのmakeクロージャ内で、アプリのバンドルからBotanistモデルを読み込み、Entityを作成します。
作成したEntityは、RealityViewのコンテンツとして追加します。

しかし、名前のViewが横にはみ出してしまいました。Model3Dを使用していたときは、このような問題は発生していませんでした。
これは、デフォルトではRealityViewが、SwiftUIのレイアウトシステムから提案されたすべての利用可能なスペースを占有するためです。

struct RobotView: View {
  let url: URL = Bundle.main.url(forResource: "sparky", withExtension: "reality")!

  var body: some View {
    HStack {
      NameSign()
      RealityView { content in
        if let sparky = try? await Entity(contentsOf: url) {
          content.add(sparky)
        }
      }
      .realityViewLayoutBehavior(.fixedSize)
    }
  }
}

新しいrealityViewLayoutBehaviorモディファイアに.fixedSizeを指定することで、RealityViewがモデルの初期境界を厳密に取り囲むようにできます。

RealityViewは、そのコンテンツのエンティティの視覚的な境界をもとにサイズを決定します。
このサイズ計算は、makeクロージャの実行直後に一度だけ行われます。

これら3つのRealityViewすべてにおいて、モデルの底部はシーンの原点に位置しています。

左側の.flexibleオプションでは、RealityViewはモディファイアが適用されていないかのように動作し、原点はビューの左上のままです。
中央の.centeredオプションでは、RealityViewの原点が移動され、コンテンツがビューの中央に配置されます。
右側の .fixedSize オプションでは、RealityViewがコンテンツの境界を囲み、Model3Dと同様の動作をします。

これらのオプションは、RealityViewContentに含まれるEntityの位置やスケールを変更するものではなく、RealityView自体の原点を再配置するだけです。

※この後に紹介されるParticleEmitterやComponentSystemの作成手順については、目新しい機能はないため本記事では割愛します。詳細はWWDCセッションをご参照ください。

RealityViewは、細部まで作り込みたい場合に適したビューです。ゲームやプレイ重視の体験を構築する際は、3Dコンテンツの動作を細かく制御できるRealityViewを使用しましょう。

一方で、Model3Dは自己完結型の3Dアセットを単体で表示するためのビューです。3Dアセット用のSwiftUIにおけるImageビューのようなものと捉えると分かりやすいでしょう。

Object manipulation

visionOS 26の新しいObject Manipulation APIにより、ユーザーはアプリ内のオブジェクトを直感的に操作できるようになりました。
この機能では、片手での移動、片手または両手での回転、両手でのピンチ操作による拡大・縮小が可能です。さらに、オブジェクトを片手からもう一方の手に渡すこともできます。

オブジェクトがRealityKitのエンティティかSwiftUIのビューかによって、有効化の方法が異なります。

Model3D .manipulable modifier

struct RobotView: View {
  let url: URL
  var body: some View {
    HStack {
      NameSign()
      Model3D(url: url)
        .manipulable(
          operations: [.translation,
                       .primaryRotation,
                       .secondaryRotation]
       )
    }
  }
}

Model3Dの場合は、新しく追加された.manipulableモディファイアを使用します。
operationsを指定することで、ユーザーが行える操作を制御できます。
上記の例では、スケーリングを無効にしつつ、移動と片手での回転操作は可能なように設定しています。

struct RobotView: View {
  let url: URL
  var body: some View {
    HStack {
      NameSign()
      Model3D(url: url)
        .manipulable(inertia: .high)
    }
  }
}

inertiaプロパティに.highを指定することで、オブジェクトに重量感のある動きを与えることができます。

.manipulableモディファイアは、Model3Dが画面上に表示されている場合にのみ機能します。
このモディファイアは、Model3D全体、またはそれにアタッチされているすべてのビューに適用されます。

RealityView ManipulationComponent

visionOS 26では、ManipulationComponentという新しい型が追加され、エンティティに設定することでオブジェクト操作を有効にできます。

RealityView { content in
  let sparky = await loadSparky()
  content.add(sparky)
  ManipulationComponent.configureEntity(sparky)
}

エンティティには、ManipulationComponentの静的関数configureEntityを使って操作設定を追加します。
また、このエンティティへのタップをインタラクションシステムが検出できるように、CollisionComponentを追加します。
さらに、ジェスチャー入力に反応させるためにInputTargetComponentを加え、視線やカーソルが当たった際に視覚効果を与えるためにHoverEffectComponentも追加します。

RealityView { content in
  let sparky = await loadSparky()
  content.add(sparky)
  ManipulationComponent.configureEntity(
    sparky,
    hoverEffect: .spotlight(.init(color: .purple)),
    allowedInputTypes: .all,
    collisionShapes: myCollisionShapes()
  )
}

体験をさらにカスタマイズするために、いくつかのパラメータを指定できます。
たとえば、hoverEffectプロパティでは、紫色のスポットライト効果を設定しています。
allowedInputTypesでは、直接タッチ、視線、ピンチなど、すべての入力タイプを許可しています。
collisionShapesプロパティでは、ロボットの外形に基づいたコリジョン形状を定義しています。

public enum ManipulationEvents {

  /// When an interaction is about to begin on a ManipulationComponent's entity
  public struct WillBegin: Event { }
  
  /// When an entity's transform was updated during a ManipulationComponent
  public struct DidUpdateTransform: Event { }

  /// When an entity was released
  public struct WillRelease: Event { }

  /// When the object has reached its destination and will no longer be updated
  public struct WillEnd: Event { }

  /// When the object is directly handed off from one hand to another
  public struct DidHandOff: Event { }
}

ユーザーがアプリ内のオブジェクト操作をした時に、応答するために以下の重要なタイミングでイベントを発生させます。

  • インタラクションの開始、終了する時
  • エンティティの移動、回転、拡大縮小で更新される時
  • エンティティが解放される時
  • 片方の手から反対の手に渡される時

これらのイベントをサブスクライブして状態を更新します。

RealityView { content in
  let sparky = await loadSparky()
  content.add(sparky)

  var manipulation = ManipulationComponent()
  manipulation.audioConfiguration = .none
  sparky.components.set(manipulation)

  didHandOff = content.subscribe(to: ManipulationEvents.DidHandOff.self) { event in
    sparky.playAudio(handoffSound)
  }
}

デフォルトでは、インタラクションの開始時やハンドオフが発生した際に、標準のサウンドが再生されます。
カスタムサウンドを使用するには、まずaudioConfigurationを.noneに設定し、標準サウンドを無効化します。
続いて、ManipulationEvent.didHandOffにサブスクライブし、ハンドオフが発生した際にカスタムオーディオリソースを再生するようにクロージャ内で処理を記述します。

SwiftUI components

RealityKitには3つの主要なコンポーネントが導入されています。
まず、ViewAttachmentComponentを使用するとSwiftUIビューをエンティティに直接追加出来ます。
次に、GestureComponentにより、エンティティがタッチやジェスチャーに応答できるようになります。
そして最後に、PresentationComponentを使うことで、RealityKitシーン内からポップオーバーのような SwiftUIビューを表示できます。

ViewAttachmentComponent
struct RealityViewAttachments: View {
  var body: some View {
    RealityView { content, attachments in
      let bolts = await loadAndSetupBolts()
      if let nameSign = attachments.entity(
        for: "name-sign"
      ) {
        content.add(nameSign)
        place(nameSign, above: bolts)
      }
      content.add(bolts)
    } attachments: {
      Attachment(id: "name-sign") {
        NameSign("Bolts")
      }
    }
    .realityViewLayoutBehavior(.centered)
  }
}

visionOS 1では、RealityViewのイニシャライザ内で、あらかじめアタッチメントを宣言することができました。
アタッチメントビューが構築されると、システムはその結果をエンティティとしてupdateクロージャに渡します。
これらのエンティティはシーンに追加され、3D 空間内に配置することができます。

struct AttachmentComponentAttachments: View {
  var body: some View {
    RealityView { content in
      let bolts = await loadAndSetupBolts()
      let attachment = ViewAttachmentComponent(
          rootView: NameSign("Bolts"))
      let nameSign = Entity(components: attachment)
      place(nameSign, above: bolts)
      content.add(bolts)
      content.add(nameSign)
    }
    .realityViewLayoutBehavior(.centered)
  }
}

visionOS 26では、この仕組みがよりシンプルになりました。
任意のSwiftUIビューを指定することでViewAttachmentComponentを作成でき、
それをエンティティのコンポーネントコレクションに追加するだけで利用できます。

GestureComponent
struct AttachmentComponentAttachments: View {
  @State private var bolts = Entity()
  @State private var nameSign = Entity()

  var body: some View {
    RealityView { ... }
    .realityViewLayoutBehavior(.centered)
    .gesture(
      TapGesture()
        .targetedToEntity(bolts)
        .onEnded { value in
          nameSign.isEnabled.toggle()
        }
    )
  }
}

targetedToEntityモディファイアを使用すると、RealityViewにジェスチャーを付与することができます。

struct AttachmentComponentAttachments: View {
  var body: some View {
    RealityView { content in
      let bolts = await loadAndSetupBolts()
      let attachment = ViewAttachmentComponent(
          rootView: NameSign("Bolts"))
      let nameSign = Entity(components: attachment)
      place(nameSign, above: bolts)
      bolts.components.set(GestureComponent(
        TapGesture().onEnded {
          nameSign.isEnabled.toggle()
        }
      ))
      content.add(bolts)
      content.add(nameSign)
    }
    .realityViewLayoutBehavior(.centered)
  }
}

visionOS 26の新機能として、GestureComponentが追加されました。
ViewAttachmentComponentと同様に、GestureComponentをエンティティに直接追加し、通常のSwiftUIジェスチャーを渡すことができます。
ジェスチャーの値は、デフォルトでエンティティの座標空間を基準に処理されます。

PresentationComponent

RealityKitから直接PresentationComponentを使って、ポップオーバーを表示する事が出来ます。

// PresentationComponent

struct CatalogView: View {
    @State private var isPresented = false
    var body: some View {
        RealityView { content in
            let (bolts, catalog) = await loadAndSetupBolts()
            let tap = GestureComponent(
                TapGesture().onEnded {
                    isPresented.toggle()
                }
            )

            let popover = PresentationComponent(
                isPresented: $isPresented,
                configuration: .popover(arrowEdge: .bottom),
                content: CatalogOptionsView(catalog: catalog)
            )
            bolts.components.set(popover)
            content.add(bolts)
        }
    }
}

isPresentedパラメーターには、ポップオーバーの表示状態を制御し、閉じられたことを通知するためのBoolのバインディングを渡します。
configurationパラメーターには、表示スタイル(この例では popover)を指定します。
contentパラメーターには、表示するSwiftUIのViewを渡します。

Information flow

// Observable entity

extension Entity {
    public var observable: Entity.Observable { get }

    public struct Observable {
        public var name: String { get set }
        public var children: Entity.ChildCollection { get set }
        public var transform: Transform { get set }
        public var position: SIMD3<Float> { get set }
        public var scale: SIMD3<Float> { get set }
        public var orientation: simd_quatf { get set }

        public var components: Components { get }
    }
}

visionOS 26では、エンティティが観測可能になりました。
通知を受け取るには、エンティティの「observable」プロパティを読み取るだけです。

これにより、エンティティの位置、スケール(拡大縮小)、回転、さらにはカスタムコンポーネントを含む、さまざまなコンポーネントの変化を監視できます。

当初から、RealityViewのupdateクロージャを使って、SwiftUIからRealityKitへ情報を渡すことができました。
一方、visionOS 26では、エンティティのobservableプロパティにより、RealityKitからSwiftUIへ情報を送ることも可能になりました。

しかし、この双方向のやり取りは、条件によっては無限ループを引き起こす可能性があります。
ここでは、その無限ループを回避する方法を見ていきましょう。

// Don't modify observed state in your update closure

struct CautionaryTale: View {
    @State var robot: Entity

    var body: some View {
        MiniMap(robot: robot.observable.position)

        RealityView { content in }
        update: { _ in
            // ⚠️ This will cause an update cycle!
            robot.position.x += 0.01
        }
    }
}

Viewのbody内でobservableプロパティを読み取ると、そのプロパティがViewの依存関係として認識されます。
そのため、プロパティの値が変更されるたびに、SwiftUI は該当のViewを再描画(更新)します。

一方で、RealityViewのupdateクロージャ内でエンティティの位置を変更(書き込み)している場合、
その変更がobservable経由でSwiftUIに伝わり、再びViewが更新され、さらにupdateクロージャが呼ばれる……という無限ループが発生する可能性があります。

無限ループを防ぐために、監視対象の状態はupdateクロージャ内で変更しないようにしてください。

// Check yourself before you wreck yourself

struct EqualityCheck: View {
    @State var robot: Entity
    @State var incomingPosition: Float

    var body: some View {
        MiniMap(robot: robot, ...)

        RealityView { ... }
        update: { content in
            if robot.position.x != incomingPosition {
                robot.position.x = incomingPosition
            }
        }
    }
}

監視対象のプロパティを変更する必要がある場合は、現在の値を確認し、同じ値を再度代入しないようにしてください。

独自のカスタムシステム内から値を更新することも可能です。
システムの更新関数はSwiftUIのViewボディの計算範囲外にあるため、監視対象のエンティティの値を変更するのに適した場所です。

// Gesture closures are not inside the scope of the SwiftUI view body evaluation
struct TapToTurnEntityGreen: View {
    @State private var entity: Entity = ModelEntity(...)

    var currentColor: SwiftUI.Color {
        SwiftUI.Color(
            (entity.observable.components[ModelComponent.self]?
                .materials.first as? SimpleMaterial)?.color.tint
            ?? .white
        )
    }

    var body: some View {
        Circle()
            .fill(currentColor)
            .frame(width: 100, height: 100, alignment: .center)
            .gesture(TapGesture(count: 1)
                .onEnded { _ in setEntityColor(.green) })
    }

    private func setEntityColor(_ color: UIColor) { ... }
}

GestureクロージャもSwiftUIのViewボディの計算範囲外で実行されます。
これはユーザーの入力に応じて呼び出されるため、この中でも監視対象のエンティティの値を安全に変更できます。

Unified coordinate conversion

左側のSparkyはSwiftUIのModel3Dビューで、右側のBoltsはRealityKitのEntityです。
この2体のロボットを近づけたいと考えています。
SparkyとBolts は異なる座標空間に属しているため、2 体の間のワールド座標での絶対距離を取得する必要があります。

この問題を解決するために、Spatial フレームワークでは、抽象的な座標空間を表すCoordinateSpace3Dプロトコルが導入されています。
このプロトコルに準拠していれば、異なるフレームワーク間であっても、任意の 2 つの型の間で値を簡単に変換できます。

// Unified coordinate conversion

extension Entity: CoordinateSpace3D { ... }

extension Scene: CoordinateSpace3D { ... }

extension GeometryProxy3D {
    public func coordinateSpace3D() -> some CoordinateSpace3D
}

extension DragGesture {
    public init(
        coordinateSpace3D: some CoordinateSpace3D
    )
}

RealityKitのEntityおよびScene型は、CoordinateSpace3Dプロトコルに準拠しています。
SwiftUI 側では、GeometryProxy3Dに対して新しく.coordinateSpace3D()関数が追加され、座標空間を提供できるようになりました。
さらに、Gesture型は特定のCoordinateSpace3Dに対する相対的な値を指定することが可能です。

CoordinateSpace3Dプロトコルは、まずSparkyの座標空間にある値を、RealityKitとSwiftUIの両方で共有される共通の座標空間に変換することで機能します。
その後、この共有空間からBoltの座標空間へと変換されます。この際、ポイントからメートルへの単位変換や軸の向きといった低レベルの詳細も適切に処理されます。

// Convert a point from a GeometryReader3D's GeometryProxy to an Entity's CoordinateSpace3D

Model3D(named: "sparky")
    .onGeometryChange3D(for: Point3D.self) { proxy in
        try! proxy
            .coordinateSpace3D()
            .convert(value: Point3D.zero, to: bolts)
    } action: { old, new in
        let distance = new.magnitudeSquared
        sparksRate = mapDistanceToSparksRate(distance)
    }
    .manipulable()

Model3DViewでは、ビューのジオメトリが変更されるたびに、システムがonGeometryChange3D関数を呼び出します。
この関数にはGeometryProxy3Dが渡され、これを使って座標空間を取得します。
ビューの位置をエンティティの空間内の点へ変換することで、2体のロボット間の距離を算出することができます。

Animation

visionOS 26では、SwiftUIのアニメーションを使用して、RealityKitコンポーネントの変更を暗黙的にアニメーション化できるようになりました。
状態の変化にアニメーションを関連付ける方法として、2 つのアプローチが用意されています。

struct RealityKitAnimation: View {
    @State private var isOffset = true
    @State private var sparky = Entity()
    var animatedIsOffset: Binding<Bool> {
        $isOffset
            .animation(.bouncy(extraBounce: 0.4))
    }

    var body: some View {
        Toggle("Offset?", isOn: animatedIsOffset)
            .fixedSize()
        RealityView { content in
            let sparkyModel = try! await Entity(
                named: "sparky"
            )
            sparky.addChild(sparkyModel)
            content.add(sparky)
        } update: { content in
            content.animate {
                sparky.position.x = isOffset ? -0.15 : 0
            }
        }
    }
}

RealityView内からcontent.animate()を使用することで、animateブロック内でコンポーネントに新しい値を設定できます。
RealityKitは、updateクロージャをトリガーしたSwiftUIのトランザクションに関連付けられたアニメーションを使用します。

struct RealityKitAnimation: View {
    @State private var isOffset = true
    @State private var sparky = Entity()

    var body: some View {
        Toggle("Offset?", isOn: $isOffset)
            .fixedSize()

        SparkyView(rootEntity: sparky)
            .onChange(of: isOffset) { oldValue, newValue in
                Entity.animate(
                    .animation(.bouncy(extraBounce: 0.4))
                ) {
                    sparky.position.x = isOffset ? -0.15 : 0
                }
            }
    }
}

もう 1 つの方法は、新しいEntity.animate()関数を使用することです。
この関数に、SwiftUIのアニメーションと、コンポーネントに新しい値を設定するクロージャを渡します。

まとめ

visionOS 26に追加されたSwiftUIとRealityKitの連携機能についてまとめました。
今回のアップデートにより、SwiftUIとRealityKitの連携が大きく進み、格段に使いやすくなった印象です。

個人的には、AnimationPlaybackControllerがObservable 対応したのが特に嬉しいポイントです。
visionOS 1 や 2 ではisCompleteしか使えず、正直かなり扱いにくかったので……。

また、以前は独自実装が必要だったジェスチャー処理も、Object Manipulationの導入によって簡単に扱えるようになりました。これは非常にありがたいですね。

その他にも気になる新機能が多く、今後いろいろ試してみたいと思っています。
この記事が少しでも参考になれば幸いです。詳細はWWDCのセッションをご参照ください。

参考



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる