NTTドコモR&Dの技術ブログです。

Apple Vision ProでSwiftのみでスロットアプリを作ってみた

※ 本記事は 2026/3/31 以前にNTTコノキューにて記載した記事になります

0.はじめに

この記事はNTTコノキュー アドベントカレンダー2025の記事です。

こんにちは。NTTコノキューの乗松です。

半年ほど前に、Apple Vision Proの知見を得るためにアプリを開発してみました。

本記事では、そのアプリ実装の流れを苦労点や獲得できた知見を交えながら紹介していきます。

アプリの内容は私の趣味全開のスロットのアプリです。用語が一部分からないかもしれませんがご容赦ください。

1.開発環境

MacBook Apple M4

Version 16.2

2.要件設定

やろうと思うと無限に作り込むことができるので、ある程度要件を設定してゴールを決めようと思います。要件は下記です。

・3つのリールを作成し、回転する

・ボタンでそれぞれのリールを停止でき、再回転させることができる

・確率で特定の役が横一列に揃う

・特定の役を引いた場合、演出が発生する

3.実装(スロット作成編)

3.1 プロジェクト作成

ここは簡単に説明します。

XCodeを開き、「Create New project」を選択します。

その後「visionOS」→「App」を選択するとオプション設定を行うことができます。

オプション設定を行うと「Hello,World!」が表示されるプロジェクトが作成されるので、このプロジェクトを編集してアプリを作成していきます。

3.2 3Dのリールを作る

RealityKitを使って3D空間に円筒形のリールを作成し、その表面に図柄が描かれたテクスチャを貼り付けます。

下記はContentView.swiftに追加した3Dモデルのコードです。

import SwiftUI
import RealityKit
import RealityKitContent

struct ContentView: View {
    var body: some View {
        RealityView { content in
            // リール(円柱)のモデルエンティティを作成
            let reelEntity = makeCylinderReel()

            // リールを配置するためのアンカーエンティティを作成
            // ここでは、ユーザーの正面0.5メートル手前に配置します。
            let anchor = AnchorEntity(world: SIMD3<Float>(0, 0, -0.5))

            // アンカーエンティティにリールモデルを追加
            anchor.addChild(reelEntity)

            // RealityViewのコンテンツにアンカーエンティティを追加して表示
            content.add(anchor)
        }
    }

    /// 円柱形のリールモデルエンティティを作成する関数
        func makeCylinderReel() -> ModelEntity {
            let radius: Float = 0.1
            let height: Float = 1.5
            let cylinderMesh = MeshResource.generateCylinder(
                height: height,
                radius: radius
            )

            // マテリアルの設定
            var bodyMaterial = SimpleMaterial(color: .blue, isMetallic: false)
            // faceCullingを .none に設定すると、マテリアルの裏面も描画されるようになります。これを設定しないと内側が透明になってしまいます。
            bodyMaterial.faceCulling = .none

            // 配列のマテリアルを使用して、円柱のモデルエンティティを作成
            let reelModelEntity = ModelEntity(mesh: cylinderMesh, materials: [bodyMaterial])
            // 回転
            let radians = Float.pi / 2
            reelModelEntity.transform.rotation = simd_quatf(angle: radians, axis: SIMD3<Float>(1, 0, 0))
            return reelModelEntity
        }
}

上記により、青いリールの原型を作成できました。

位置を調整して3Dモデルを見えるところ(Volume内)に配置するのですが、なかなか苦戦しました。

(正面の面が無いのはVolumeの壁で円柱が切れているからです。これから位置調整します。)

ここから

reelModelEntity.transform.rotation = simd_quatf(angle: radians, axis: SIMD3<Float>(1, 0, 0))

を調整して側面をこちら側に向けていきます。

すぐ画面外に飛んでしまうので、下記の位置も同時に調整していきます。

let anchor = AnchorEntity(world: SIMD3<Float>(0, 0, -0.5))

調整後の位置がこちらです。

位置、大きさは下記に調整しました。

reelModelEntity.transform.rotation = simd_quatf(angle: radians, axis: SIMD3<Float>(0, 0, 1))
let radius: Float = 0.2
let height: Float = 0.3
let anchor = AnchorEntity(world: SIMD3<Float>(0, 0, -1.6))

3.3 リールを複製する

スロットにはリールが3つ必要です。

なので、作成したリールを複製して両側にくっつけていきます。

var body: some View {
        RealityView { content in
            // リールの横幅
            let reelWidth: Float = 0.3
            // リール同士のすき間
            let reelGap: Float = 0.05

            let centerReel = makeCylinderReel()
            let leftReel = makeCylinderReel()
            let rightReel = makeCylinderReel()
            
            // 各リールの位置を設定 (アンカーからの相対位置)
            // leftReel を中央リールの左側に配置
            leftReel.position = SIMD3<Float>(-(reelWidth + reelGap), 0, 0)
            
            // rightReel を中央リールの右側に配置
            rightReel.position = SIMD3<Float>(reelWidth + reelGap, 0, 0)
            
            // すべてのリールをアンカーに追加
            content.add(centerReel)
            content.add(leftReel)
            content.add(rightReel)
        }
    }

----------中略----------

makeCylinderReel()メソッドは3.2で記載しているメソッドを使用します。

各リールは独立して動く必要があるので、別々のEntityを作成しています。

ここまでPreviewで確認していたのですが、content.add(anchor)だとSimulatorにて3Dモデルが表示されないという現象が発生したのでcontent.add(centerReel)のようにModelEntityをcontentにaddするよう修正しました。

3.4 リールに画像を追加する

リールが3つ作成できたので、リールの画像を貼っていきます。

イメージとしては、縦長のリール画像を円柱の側面に一周させる感じです。

makeCylinderReelメソッドを下記に変更しました。

func makeCylinderReel(textureName: String) -> ModelEntity {
        let radius: Float = 0.2
        let height: Float = 0.3
        
        
        let cylinderMesh = MeshResource.generateCylinder(height: height, radius: radius)
        var bodyMaterial = UnlitMaterial(color: .blue)
        do {
            let texture = try TextureResource.load(named: textureName)
            var texturedMaterial = UnlitMaterial()
            texturedMaterial.color = .init(texture: .init(texture))
            bodyMaterial = texturedMaterial
        } catch {
            print("Error: テクスチャ「\(textureName)」の読み込みに失敗しました: \(error)。")
        }
        bodyMaterial.faceCulling = .none

        ----------中略----------
        
        
        return reelModelEntity
    }

リールの画像はAssetsに入れています。

TextureResourceを使用することでAssetsの画像を使用することができます。

力技ですが、円柱を横に倒しているためAssetsの画像も横向きにしています。

リールの画像を各リールに表示することができました。(画像はフリー素材です)

側面にも画像が付いてしまいましたが一旦放置です。

半径を調整すればもっと良い感じになります。

let radius: Float = 0.3
let height: Float = 0.2

3.5 リールを回転させる

回転のアニメーションを作るには、RealityKitのEntity-Component-System (ECS) を使用します。

リールの回転速度などの情報を保持する回転コンポーネント(SpinningComponent.swift)と、SpinningComponentを実際に回転させる回転システム(SpinningSystem.swift)の2つのファイルを作成していきます。

SpiningComponent.swift

import RealityKit
struct SpinningComponent: Component {
    var speed: Float // 1秒あたりの回転量(ラジアン単位)

SpinningSystem.swift

import RealityKit

class SpinningSystem: System {
    // このシステムが処理するエンティティを定義するクエリ
    // Transform と SpinningComponent の両方を持つエンティティが対象
    private static let query = EntityQuery(where: .has(Transform.self) && .has(SpinningComponent.self))

    // System の初期化に必要
    required init(scene: Scene) {}

    // 毎フレーム呼び出される更新関数
    func update(context: SceneUpdateContext) {
        // 前回のアップデートからの経過時間(秒)
        let deltaTime = Float(context.deltaTime)

        // クエリに一致するすべてのエンティティに対して処理を実行
        context.scene.performQuery(Self.query).forEach { entity in
            // エンティティからSpinningComponentを取得
            guard let spinningComponent = entity.components[SpinningComponent.self] else { return }
            
            // このフレームでの回転角度を計算
            let angleThisFrame = spinningComponent.speed * deltaTime
            
            // リールのローカルY軸を中心に回転させるクォータニオンを作成
            // (makeCylinderReel関数でリールの長軸をローカルY軸に合わせ、
            //  その後全体を回転させてワールドX軸方向に向けているため、
            //  ローカルY軸周りの回転が期待するスロットリールの回転になります)
            let rotationThisFrame = simd_quatf(angle: angleThisFrame, axis: SIMD3<Float>(0, 1, 0))
            
            // エンティティの現在の向きに、このフレームの回転を乗算して適用
            entity.orientation *= rotationThisFrame
        }
    }
}

上記をContentView.swiftに組み込んでいきます。

struct ContentView: View {
    
    init() {
        SpinningComponent.registerComponent()
        SpinningSystem.registerSystem()
    }
    
    
    var body: some View {
        RealityView { content in
            // --- リールの設定 ---
            // makeCylinderReel内の 'height' と同じ値を使います (これが1つのリールの幅になります)
            let reelWidth: Float = 0.2
            // リール同士のすき間
            let reelGap: Float = 0.05
            
            let centerReel = makeCylinderReel(textureName: "center")
            centerReel.components.set(SpinningComponent(speed: -.pi*4))
            let leftReel = makeCylinderReel(textureName: "left")
            leftReel.components.set(SpinningComponent(speed: -.pi*4))
            let rightReel = makeCylinderReel(textureName: "right")
            rightReel.components.set(SpinningComponent(speed: -.pi*4))

----------中略----------

引数に速度を設定していますが、SpinningComponentに固定値で登録しても良かったかもしれません。

上記により、リールを回転することができました。

※おそらくSimulatorの仕様なのか、Volumeを動かさないとリールが回転しないという現象があったのでVolumeを手動で動かしています(実機では問題なかったです)

3.6 ボタンを追加する

追加するボタンは、リールを止めるボタン×3と再回転ボタン×1の計4つです。

まずリールの回転状態を管理するViewModelを作成します。

ReelViewModel.swift

import Combine

class ReelViewModel: ObservableObject {
    @Published var isLeftSpinning = true
    @Published var isCenterSpinning = true
    @Published var isRightSpinning = true
    /// すべてのリールの回転を再開させる
    func restartAllReels() {
        isLeftSpinning = true
        isCenterSpinning = true
        isRightSpinning = true
    }

SpinningComponentに

var isSpinning: Bool = true

を追加します。

SpinningSystemで、上記のisSpinningを見て回転させるようにします。

class SpinningSystem: System {
    private static let query = EntityQuery(where: .has(SpinningComponent.self))
    required init(scene: Scene) {}

    func update(context: SceneUpdateContext) {
        context.scene.performQuery(Self.query).forEach { entity in
            // isSpinning が true のエンティティのみ回転させる
            guard let component = entity.components[SpinningComponent.self], component.isSpinning else { return }
            
            let angle = component.speed * Float(context.deltaTime)
            let rotation = simd_quatf(angle: angle, axis: [0, 1, 0])
            entity.orientation *= rotation
        }
    }
}

4つのボタンを追加し、それぞれ押下された時にViewModelの値を変えるようにします。

ContentView.swift

import SwiftUI
import RealityKit
import RealityKitContent

struct ContentView: View {
    @StateObject private var viewModel = ReelViewModel()

    init() {
        SpinningComponent.registerComponent()
        SpinningSystem.registerSystem()
    }
    
    var body: some View {
        VStack(spacing: 10) {
            // MARK: RealityView
            RealityView { content in
                let rotationSpeed: Float = -.pi*4
                
                let leftReel = makeCylinderReel(textureName: "left")
                leftReel.name = "left_reel"
                leftReel.components.set(SpinningComponent(speed: rotationSpeed, isSpinning: viewModel.isLeftSpinning))
                
                // (中央と右のリールも同様に作成)
                let centerReel = makeCylinderReel(textureName: "center")
                centerReel.name = "center_reel"
                centerReel.components.set(SpinningComponent(speed: rotationSpeed, isSpinning: viewModel.isCenterSpinning))
                
                let rightReel = makeCylinderReel(textureName: "right")
                rightReel.name = "right_reel"
                rightReel.components.set(SpinningComponent(speed: rotationSpeed, isSpinning: viewModel.isRightSpinning))

                // 位置を設定し、直接シーンに追加
                let basePosition = SIMD3<Float>(0, 0, -1.5)
                centerReel.position = basePosition
                leftReel.position = basePosition + SIMD3<Float>(-0.12, 0, 0)
                rightReel.position = basePosition + SIMD3<Float>(0.12, 0, 0)
                
                content.add(centerReel)
                content.add(leftReel)
                content.add(rightReel)

            } update: { content in
                // content.entitiesから名前でリールを探してisSpinningを更新
                if let leftReel = content.entities.first(where: { $0.name == "left_reel" }) {
                    leftReel.components[SpinningComponent.self]?.isSpinning = viewModel.isLeftSpinning
                }
                if let centerReel = content.entities.first(where: { $0.name == "center_reel" }) {
                    centerReel.components[SpinningComponent.self]?.isSpinning = viewModel.isCenterSpinning
                }
                if let rightReel = content.entities.first(where: { $0.name == "right_reel" }) {
                    rightReel.components[SpinningComponent.self]?.isSpinning = viewModel.isRightSpinning
                }
            }
            
            HStack(spacing: 20) {
                // --- 再開ボタン ---
                Button(action: { viewModel.restartAllReels() }) {
                    Image(systemName: "arrow.clockwise")
                        .fontWeight(.bold)
                        .imageScale(.large)
                        .frame(width: 60, height: 60)
                        .background(.blue)
                        .foregroundColor(.white)
                        .clipShape(Circle())
                }
                Spacer()
                // --- 停止ボタン ---
                Button(action: { viewModel.isLeftSpinning = false }) {
                    Circle().fill(.red).frame(width: 60, height: 60)
                }
                Button(action: { viewModel.isCenterSpinning = false }) {
                    Circle().fill(.red).frame(width: 60, height: 60)
                }
                Button(action: { viewModel.isRightSpinning = false }) {
                    Circle().fill(.red).frame(width: 60, height: 60)
                }
            }
            .padding(.horizontal, 30).padding(.bottom, 30)
            .glassBackgroundEffect()
        }
    }
    
    ----------中略----------
}

これで実機イメージのボタンを追加することができました。

3.7 リール制御をしたい

ボタンを押す→リールが止まるだけだと図柄は一列に止まりません。

図柄が21個あるので、角度で図柄を21分割しそれぞれ番号を割り当てます。

そうすることで、図柄が一列になる且つ好きな図柄を揃えさせることができます。

※滑るコマ数は考慮しません

SpinningComponentを更新し、spinning(回転中)、stopping(停止処理中)、stopped(完全停止)の3つの状態を追加します。

// リールの状態を表すenum
enum ReelState {
    case spinning // 回転中
    case stopping // 停止処理中
    case stopped // 停止状態
}

struct SpinningComponent: Component {
    static let totalSegments = 21
    var speed: Float
    
    // 状態管理
    var currentState: ReelState = .spinning
    var currentAngle: Float = 0.0
    
    // 停止ロジック用のプロパティを変更
    var finalStopAngle: Float? = nil // 最終的に停止する目標の角度
}

viewModelも変更していきます。

import Combine

class ReelViewModel: ObservableObject {
    // 各リールの状態を管理
    @Published var leftReelState: ReelState = .spinning
    @Published var centerReelState: ReelState = .spinning
    @Published var rightReelState: ReelState = .spinning

    @Published var leftTargetSegment: Int = 0
    @Published var centerTargetSegment: Int = 0
    @Published var rightTargetSegment: Int = 0

    init() {
       setNewTargets()
    }

        /// 新しい停止目標を設定する
    private func setNewTargets() {
        leftTargetSegment = Int.random(in: 0..<SpinningComponent.totalSegments)
        centerTargetSegment = Int.random(in: 0..<SpinningComponent.totalSegments)
        rightTargetSegment = Int.random(in: 0..<SpinningComponent.totalSegments)
    }

    // 指定したリールの停止処理を開始する
   func stopReel(reel: ReelPosition) {
        switch reel {
        case .left:
            if leftReelState == .spinning { leftReelState = .stopping }
        case .center:
            if centerReelState == .spinning { centerReelState = .stopping }
        case .right:
            if rightReelState == .spinning { rightReelState = .stopping }
        }
    }
    
    /// すべてのリールを再開させる
    func restartAllReels() {
        leftReelState = .spinning
        centerReelState = .spinning
        rightReelState = .spinning
    }
    
    // どのリールかを識別するためのenum
    enum ReelPosition {
        case left, center, right
    }
}

SpinningSystemも変更していきます。

class SpinningSystem: System {
    private static let query = EntityQuery(where: .has(SpinningComponent.self))
    required init(scene: Scene) {}

    func update(context: SceneUpdateContext) {
        let deltaTime = Float(context.deltaTime)

        context.scene.performQuery(Self.query).forEach { entity in
            guard var component = entity.components[SpinningComponent.self] else { return }

            // 状態に応じて処理を分岐
            switch component.currentState {
            case .spinning:
                component.currentAngle += component.speed * deltaTime
                
            case .stopped:
                break

            case .stopping:
                // 新しい停止処理
                // 停止目標角度(finalStopAngle)が設定されているか確認
                if let finalStopAngle = component.finalStopAngle {
                    // 通常通り回転を進める
                    let nextAngle = component.currentAngle + component.speed * deltaTime
                    
                    // 目標角度を通り過ぎたかチェック
                    // (speedがマイナスなので、大小関係が逆になることに注意)
                    if nextAngle <= finalStopAngle {
                        // 通り過ぎたら、目標角度にスナップさせて停止
                        component.currentAngle = finalStopAngle
                        component.currentState = .stopped
                    } else {
                        // まだ到達していなければ、角度を更新
                        component.currentAngle = nextAngle
                    }
                }
            }

            // 角度が一周したら0に戻す
            if abs(component.currentAngle) >= .pi * 2 {
                component.currentAngle = component.currentAngle.truncatingRemainder(dividingBy: .pi * 2)
            }
            
            // 計算した現在の角度に基づいて、常にエンティティの向きを更新
            let initialOrientation = simd_quatf(angle: .pi / 2, axis: [0, 0, 1])
            let spinRotation = simd_quatf(angle: component.currentAngle, axis: [0, 1, 0])
            entity.orientation = initialOrientation * spinRotation
            
            // 変更したコンポーネントをエンティティに再設定
            entity.components.set(component)
        }
    }
}

最後にContentViewを変更していきます。

リールの状態によって処理を分岐させるメソッドを追加します。

private func updateReelState(for reelName: String, in content: RealityViewContent, to newState: ReelState, targetSegment: Int) {
    if let reel = content.entities.first(where: { $0.name == reelName }),
       var component = reel.components[SpinningComponent.self] {
        
        if newState == .stopping && component.currentState == .spinning {
            component.currentState = .stopping
            // ViewModelから渡されたtargetSegmentを計算メソッドに渡す
            component.finalStopAngle = calculateFinalStopAngle(currentAngle: component.currentAngle, targetSegment: targetSegment)
            reel.components.set(component)
        }
        else if newState == .spinning && component.currentState != .spinning {
            component.currentState = .spinning
            component.finalStopAngle = nil
            reel.components.set(component)
        }
    }
}

// 最終的な停止角度を計算する関数
private func calculateFinalStopAngle(currentAngle: Float, targetSegment: Int) -> Float {
        let twoPi = Float.pi * 2
        let anglePerSegment = twoPi / Float(SpinningComponent.totalSegments)
        
        let targetAngleOnCircle = -(Float(targetSegment) * anglePerSegment)
        
        let currentCycle = floor(currentAngle / twoPi)
        var potentialFinalAngle = currentCycle * twoPi + targetAngleOnCircle
        
        if potentialFinalAngle > currentAngle {
            potentialFinalAngle -= twoPi
        }
        
        return potentialFinalAngle
    }

上記メソッドをRealityViewのupdateクロージャー内で呼ぶようにします。

} update: { content in
    updateReelState(for: "left_reel",   in: content, to: viewModel.leftReelState,   targetSegment: viewModel.leftTargetSegment)
    updateReelState(for: "center_reel", in: content, to: viewModel.centerReelState, targetSegment: viewModel.centerTargetSegment)
    updateReelState(for: "right_reel",  in: content, to: viewModel.rightReelState,  targetSegment: viewModel.rightTargetSegment)
}

ViewModelのsetNewTargetsを変更すれば好きな図柄を指定して揃えることができます。

※Simulatorの仕様でリールが止まってしまうのでボタンを数回押しています

4.実装(演出作成編)

4.1 演出を追加してみる

スロットといえば役と演出が必要不可欠ということで、5分の1で当たりが成立するようにしてみます。

回転ボタンを押下した時に当たり外れを抽選しているので、そのタイミングで当たりが発生したら文字を表示するようにしてみます。

まずViewModelに当たりとハズレを分岐させるメソッドを作成します。

当たりを引いた時は1番目の図柄(=7図柄)が揃うようにしています。

@Published var isChanceActive: Bool = false
    
    
init() {
    setNewTargets()
}

private func setNewTargets() {
    if Int.random(in: 1...5) == 1 {
        isChanceActive = true
        let jackpotSegment = 1
        leftTargetSegment = jackpotSegment
        centerTargetSegment = jackpotSegment
        rightTargetSegment = jackpotSegment
    } else {
        // 通常の場合
        print("Normal spin targets determined.")
        isChanceActive = false // ★ 発光フラグをOFFにする
        leftTargetSegment = Int.random(in: 0..<SpinningComponent.totalSegments)
        centerTargetSegment = Int.random(in: 0..<SpinningComponent.totalSegments)
        rightTargetSegment = Int.random(in: 0..<SpinningComponent.totalSegments)
    }
}

ContentViewで当たりフラグ(isChanceActive)がtrueの時に文字が浮かび上がるように変更します。

ZStackに追加することでリールの手前に表示させることができます。

ZStack {
            VStack {
                RealityView { content in
                    
                   ----------中略----------                
            }
            
            if viewModel.isGekiatuChanceActive {
                ZStack {
                    // 1層目:背景の光彩
                    Text("激熱")
                        .blur(radius: 15) // ぼかしをかけて光を表現
                        .foregroundColor(.black)
                        .opacity(0.8)
                    
                    // 2層目:枠線
                    // 同じテキストを少しだけずらして4方向に配置し疑似的な枠線を作る
                    // 枠線の色を指定
                    Text("激熱").offset(x: 2, y: 2).foregroundColor(.red)
                    Text("激熱").offset(x: -2, y: 2).foregroundColor(.red)
                    Text("激熱").offset(x: 2, y: -2).foregroundColor(.red)
                    Text("激熱").offset(x: -2, y: -2).foregroundColor(.red)
                    
                    // 3層目:文字本体
                    Text("激熱")
                        .foregroundColor(.yellow)
                    
                }
                // ZStack全体に共通のフォント設定を適用
                .font(.system(size: 80, weight: .heavy))
                .transition(.opacity.animation(.easeInOut(duration: 1.0)))
                
            } else {
                // 表示されていない時も高さを確保してレイアウトのガタつきを防ぐ
                ZStack {
                    Text("激熱").font(.system(size: 80, weight: .heavy))
                }
                .hidden()
            }
        }

少し物足りない感ありますが、文字を手前に出すことができました。

上から見ると2Dになっています。

次項で色々装飾していきます。

4.2 文字を装飾してみる

2つの色違いの文字を重ねる+MeshResourceを使用して3Dにしてみます。

文字の種類によって色を変更するためにenumを作っておきます。

enum TextEffectType {
    case gekiatu
    case chance

    // 表示する文字列を返すプロパティ
    var displayText: String {
        switch self {
        case .gekiatu: return "激熱"
        case .chance: return "チャンス"
        }
    }
    
    // 文字本体の色を返すプロパティ
    var fillColor: SimpleMaterial.Color {
        switch self {
        case .gekiatu: return .red
        case .chance: return .blue
        }
    }
    
    // 枠線の色を返すプロパティ
    var strokeColor: SimpleMaterial.Color {
        switch self {
        case .gekiatu: return .yellow
        case .chance: return .green
        }
    }
}

文字エンティティを作成するメソッドを作成します。

private func makeTextEntity(type: TextEffectType) -> Entity {
        // テキストの形状(メッシュ)を生成
        let textMesh = MeshResource.generateText(
            type.displayText,
            extrusionDepth: 0.02,
            font: .systemFont(ofSize: 0.15),
            alignment: .center
        )
        
        // 枠線用のエンティティを作成
        let strokeMaterial = SimpleMaterial(color: type.strokeColor, roughness: 1.0, isMetallic: false)
        let strokeEntity = ModelEntity(mesh: textMesh, materials: [strokeMaterial])
        strokeEntity.transform.scale = SIMD3<Float>(1.05, 1.05, 1)
        
        // 文字本体用のエンティティを作成 (手前側)
        let fillMaterial = SimpleMaterial(color: type.fillColor, roughness: 0.2, isMetallic: true)
        let fillEntity = ModelEntity(mesh: textMesh, materials: [fillMaterial])
        fillEntity.transform.translation.z = 0.001
        
        // 親エンティティを作成し、2つをまとめる
        let parentEntity = Entity()
        parentEntity.addChild(strokeEntity)
        parentEntity.addChild(fillEntity)
        
        return parentEntity
    }

文字が表示される時にフェードインさせてみます。

private func moveTextEntity(textEntity: Entity, in content: RealityViewContent) {
        // 最終的な状態を定義
        let finalPosition = SIMD3<Float>(-0.15, 0, 0.7)
        let finalRotation = simd_quatf(angle: 0, axis: [0, 1, 0])
        let finalScale = SIMD3<Float>(1, 1, 1)
        let finalTransform = Transform(scale: finalScale, rotation: finalRotation, translation: finalPosition)
        
        // 開始時の状態を定義
        var startTransform = finalTransform
        startTransform.translation.z -= 0.3
        startTransform.rotation = simd_quatf(angle: .pi / 6, axis: [0, 1, 0])
        startTransform.scale = .zero // スケール0で見えない状態から開始
        
        // エンティティの初期状態を開始時の状態に設定
        textEntity.transform = startTransform
        
        // シーンにエンティティを追加
        content.add(textEntity)
        
        // 最終的な状態への移動アニメーションを開始する
        textEntity.move(
            to: finalTransform,
            relativeTo: nil,
            duration: 0.8,
            timingFunction: .easeOut
        )
    }

RealityViewのupdateクロージャーで上記メソッドを呼び出します。

var textEffectType: TextEffectType?
if viewModel.isGekiatuChanceActive {
    textEffectType = .gekiatu
} else if viewModel.isChanceActive {
    textEffectType = .chance
} else {
    textEffectType = nil
}

let textEntityName = "text"
if let textEffectType = textEffectType {
    let existingTextEntity = content.entities.first(where: { $0.name == textEntityName })
    if existingTextEntity == nil {
        let textEntity = makeTextEntity(type: textEffectType)
        textEntity.name = textEntityName
        
        moveTextEntity(textEntity: textEntity, in: content)
    }
    
} else {
    if let entityToRemove = content.entities.first(where: { $0.name == textEntityName }) {
        content.remove(entityToRemove)
    }
}

上記実装を行った結果がこちらです。

TextではなくMeshResourceを使用すると、文字が3Dになります。

4.3 画像を表示させてみる

テキストだけでなく、画像も表示できるようにしてみます。

テキストと同じようにenumで種類を定義します。

enum ImageEffectType {
    case god
    
    var imageName: String {
        switch self {
        case .god: return "back"
        }
    }
}

画像表示用にメソッドに切り出します。

private func makeBackgroundImageEntity(imageName: String) -> ModelEntity {
    // 平面メッシュを作成
    let planeMesh = MeshResource.generatePlane(width: 0.6, height: 0.6)
    
    // 画像をテクスチャとしてマテリアルに設定
    var imageMaterial = UnlitMaterial()
    do {
        let texture = try TextureResource.load(named: imageName)
        imageMaterial.color = .init(texture: .init(texture))
        // PNG画像の透明部分を有効にするための設定
        imageMaterial.blending = .transparent(opacity: PhysicallyBasedMaterial.Opacity(floatLiteral: 0.8))

    } catch {
        print("背景画像「\(imageName)」のロードに失敗しました。")
    }
    
    return ModelEntity(mesh: planeMesh, materials: [imageMaterial])
}

テキスト表示と同じように呼び出せば良いです。

let imageEntity = makeBackgroundImageEntity(imageName: imageEffectType.imageName)
imageEntity.name = imageEntityName
                        
content.add(imageEntity)

見た目的にアレですが、画像を表示できるようになりました。

4.まとめ

(半分趣味で始めましたが)スロットアプリは3Dモデル操作、ロジック作成、演出作成など幅広く着手できるので入門編としては丁度良い難易度だと思いました。

UnityやUEを使用しなくても、このくらいの簡単なものであればSwiftのみで完結できます。

今回は個人制作レベルのアプリですが、また時間がある時にバージョンアップさせていこうと思います。