はじめに

こんにちは!システム開発部のYです。
担当した案件でEntityを常に回転させる処理が必要になりました。
タイマーを使用してEntityを回転させる方法を検討しましたが、複数のシーンで複数のEntityを回転させるケースが多く、コードが冗長になりそうでした。
調べたところ、Systemを使うことで、Entityをフレームごとに更新し、効率的に回転させるロジックを実装できることがわかりました。今回は、この方法についてまとめていきたいと思います。

環境

  • Swift Version: 5.9.2
  • Xcode: 15.2
  • visionOS: 1.0〜1.3
  • Reality Composer Pro: 1.0
  • macOS: 14.5

Entity Component System (ECS) とは

Entity Component System (ECS) は、データ指向の設計アプローチの一つであり、機能とデータを分離することで効率化を図る仕組みです。
ECSは、エンティティを「Entity(物体)」「Component(要素・属性)」「System(機能・処理)」の3つに分割して設計します。

System

機能やロジックを担う部分であり、シーン内のエンティティの状態を更新するための処理を実行します。
Systemは、複数のエンティティから必要なコンポーネント(属性データ)を取得し、それらに基づいて一括で処理を行います。
この構造により、従来のように各エンティティごとに独立したメソッドを呼び出す必要がなくなり、効率的に多くのエンティティに影響を与える共通のロジックを実装できます。

実装について

固定値の回転

1. プロジェクトの作成

まず、Xcodeを開き、新規プロジェクト作成時に「visionOS」を選択します。

作成したプロジェクトをビルドして実行すると、ウィンドウが表示され、その中央に「Show Immersive Space」というボタンが配置されています。このボタンを押すと、Full Immersive Spaceに入り、2つの球体が表示されます。

2. RotationCustomComponent.swiftの作成

New File → Swift File で『RotationCustomComponent』の新規ファイルを作成します。
ComponentProtocolに準拠させます。

import RealityKit
import SwiftUI

public struct RotationCustomComponent: Component {
    public var isOn: Bool = false
    
    public init() {}
}

isOnで、そのEntityを回転させるか制御します。

3. RotationSystem.swiftの作成

New File → Swift File で『RotationSystem』の新規ファイルを作成します。
SystemProtocolに準拠させます。

import Foundation
import RealityKit
import RealityKitContent

class RotationSystem: System {
   
   required init(scene: Scene) {
   }
   
   func update(context: SceneUpdateContext) {
   }    
}

RotationCustomComponentを取得して操作するため、RotationCustomComponentを取得するクエリを定義します。

static let query = EntityQuery(where: .has(RotationCustomComponent.self))

回転量を初期化時に、定義させます。

import Foundation
import RealityKit
import RealityKitContent

class RotationSystem: System {
   
   static let query = EntityQuery(where: .has(RotationCustomComponent.self))
   
   private let degrees: Float = 0.04
   private let additionalRotation: simd_quatf

   required init(scene: Scene) {
       let rotationAngle = degrees * .pi / 180
       additionalRotation = simd_quatf(angle: rotationAngle, axis: SIMD3<Float>(0, 1, 0))
   }
   
   func update(context: SceneUpdateContext) {
   }
}

updateメソッドでは、毎フレーム処理が呼び出されるため、ここで回転の処理を実行します。
まず、引数であるcontext.sceneからRotationCustomComponentが付いているEntityを取得します。

let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)

取得したEntityから対象のコンポーネントを取得し、その中でもisOntrueのEntityに絞り込んで処理を行います。
絞り込んだEntityに対して、初期化時に設定した回転角度を加えて回転を適用します。

   func update(context: SceneUpdateContext) {
       
       // RotationCustomComponentを付与したEntityに絞る
       let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
       
       for entity in entities {
           
           guard let component = entity.components[RotationCustomComponent.self] else { continue }
           
           guard component.isOn else { continue }
                       
           let currentRotation = entity.transform.rotation
           let newRotation = currentRotation * additionalRotation
           
           entity.transform = Transform(
               scale: entity.transform.scale,
               rotation: newRotation,
               translation: entity.transform.translation
           )
       }
   }

以下公式にある通りupdateメソッドは毎フレーム呼び出されるため、不要な作業は行わないでください。

updateメソッドは非常に頻繁に呼び出されるため、メソッド内では不要な作業を行わないでください。
update(context:)メソッドが戻るまでに長い時間がかかる場合、アプリのフレーム レートに悪影響を与える可能性があります。

https://developer.apple.com/documentation/realitykit/implementing-systems-for-entities-in-a-scene/#Create-a-system-class

4. Reality Composer Proを変更

Reality Composer Proを開き、「Immersive.usda」を選択して編集を行います。
『Sphere_Left』と『Sphere_Right』を削除して、新たにEntityを配置します。
今回は、Earthを配置します。

5. ImmersiveView.swiftの変更

プロジェクト作成後のコードは以下になっています。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))

                // Put skybox here.  See example in World project available at
                // https://developer.apple.com/
            }
        }
    }
}

EarthのEntityを取得し、そのEntityに作成したRotationCustomComponentを設定します。
また、RotationCustomComponentのisOnをtrueにします。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))

                // Put skybox here.  See example in World project available at
                // https://developer.apple.com/
                
                // EarthEntityを取得
                if let earthEntity = immersiveContentEntity.findEntity(named: "Earth") {
                    // RotationCustomComponentを作成
                    var rotationCustomComponent = RotationCustomComponent()
                    // isOnをtrueに変更し、回転させる
                    rotationCustomComponent.isOn = true
                    // Componentをセット
                    earthEntity.components[RotationCustomComponent.self] = rotationCustomComponent
                }
            }
        }
    }
}

6. App.swiftの変更

作成したComponentと、システムを使用できるように登録する必要があります。
App.swiftのinitメソッドを追加し、ComponentはregisterComponentで、SystemはregisterSystemで登録します。

import SwiftUI
import RealityKitContent

@main
struct RotationSystemProjectApp: App {
    
    init() {
        // Componentの登録
        RotationCustomComponent.registerComponent()
        // Systemの登録
        RotationSystem.registerSystem()
    }
        
    var body: some Scene {
        WindowGroup {
            ContentView()
        }

        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView()
        }.immersionStyle(selection: .constant(.full), in: .full)
    }
}

ここまででアプリをビルドして実行し、ImmersiveSpaceを開くと、Earthが表示され回転していることを確認できます。

Componentで定義した値で回転させる

1. RotationCustomComponent.swiftの変更

これまでのisOnに加え、degreesaxisプロパティも持つようにします。
degreesは1フレームでの回転角度を制御し、axisで回転軸を指定します。

import RealityKit
import SwiftUI

public struct RotationCustomComponent: Component {

    public var isOn: Bool = false

    public var degrees: Float = 0.04
    
    public var axis = SIMD3<Float>(0, 1, 0)
    
    public init() {}
}

2. RotationSystem.swiftの変更

回転量を初期化時に定義するのではなく、updateメソッド内で計算するように変更しています。
それ以外の変更はありません。

import Foundation
import RealityKit
import RealityKitContent

class RotationSystem: System {
   
   static let query = EntityQuery(where: .has(RotationCustomComponent.self))
   
   required init(scene: Scene) {
   }
   
   func update(context: SceneUpdateContext) {
       
       // RotationCustomComponentを付与したEntityに絞る
       let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
       
       for entity in entities {
           
           guard let component = entity.components[RotationCustomComponent.self] else { continue }
           
           guard component.isOn else { continue }
            
           // 各Componentで定義した回転量を計算
           let rotationAngle = component.degrees * .pi / 180
           let additionalRotation = simd_quatf(angle: rotationAngle, axis: component.axis)
           
           let currentRotation = entity.transform.rotation
           let newRotation = currentRotation * additionalRotation
           
           entity.transform = Transform(
               scale: entity.transform.scale,
               rotation: newRotation,
               translation: entity.transform.translation
           )
       }
   }
}

3. Reality Composer Proを変更

Reality Composer Proを開き、「Immersive.usda」を選択して編集を行います。
Jupiterと、Moonを配置します。

4. ImmersiveView.swiftの変更

EarthのEntity取得に加え、JupiterとMoonのEntityも取得し、それぞれにRotationCustomComponentをセットします。
jupiterEntityにはdegreesを1、axisSIMD3(1, 0, 0)に設定します。
moonEntityにはdegreesを0.01、axisSIMD3(0, 0, 1)に設定します。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))

                // Put skybox here.  See example in World project available at
                // https://developer.apple.com/
                
                // Earthを取得してくる
                if let earthEntity = immersiveContentEntity.findEntity(named: "Earth") {
                    // RotationCustomComponentを作成
                    var rotationCustomComponent = RotationCustomComponent()
                    // isOnをtrueに変更し、回転させる
                    rotationCustomComponent.isOn = true
                    // Componentをセット
                    earthEntity.components[RotationCustomComponent.self] = rotationCustomComponent
                }
                
                // Jupiterを取得してくる
                if let jupiterEntity = immersiveContentEntity.findEntity(named: "Jupiter") {
                    // RotationCustomComponentを作成
                    var rotationCustomComponent = RotationCustomComponent()
                    // isOnをtrueに変更し、回転させる
                    rotationCustomComponent.isOn = true
                    // 1フレームで回転する角度の変更
                    rotationCustomComponent.degrees = 1
                    // 回転軸を変更
                    rotationCustomComponent.axis = SIMD3<Float>(1, 0, 0)
                                        
                    // Componentをセット
                    jupiterEntity.components[RotationCustomComponent.self] = rotationCustomComponent
                }
                
                // Moonを取得してくる
                if let moonEntity = immersiveContentEntity.findEntity(named: "Moon") {
                    // RotationCustomComponentを作成
                    var rotationCustomComponent = RotationCustomComponent()
                    // isOnをtrueに変更し、回転させる
                    rotationCustomComponent.isOn = true
                    // 1フレームで回転する角度の変更
                    rotationCustomComponent.degrees = 0.01
                    // 回転軸を変更
                    rotationCustomComponent.axis = SIMD3<Float>(0, 0, 1)
                                        
                    // Componentをセット
                    moonEntity.components[RotationCustomComponent.self] = rotationCustomComponent
                }
            }
        }
    }
}

ここまででアプリをビルドして実行し、ImmersiveSpaceを開くと、Earth、Jupiter、Moonがそれぞれ異なる軸で回転していることを確認できます。

まとめ

Systemを使って回転し続けるEntityの作成方法についてまとめました。
ECSは、iOSアプリなどでは馴染みが薄く少し難しい概念ですが、visionProの開発においては強力で必要不可欠な機能だと感じました。
この記事が皆様のお役に立てれば幸いです。また、もし誤りや改善点がありましたら、ぜひ教えていただけると幸いです。

参考



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