📝

【Swift】visionOS でテキストをEntityとして表示する

2024/12/08に公開

初めに

今回は Apple Vision Pro でテキストを表示する実装を行います。
SwiftUI では通常の Text 表示ではなく、Entity としてテキストを表示することができます。
今回は Entity としてテキストを表示し、カスタマイズする方法についてまとめていきたいと思います。

記事の対象者

  • Swift, SwiftUI 学習者
  • Apple Vision Pro のテキスト表示をカスタマイズしたい方

目的

今回は Apple Vision Pro で Entity のテキストを表示する実装を行うことが目的です。
テキストの表示、カスタマイズができればアプリのタイトル画面や目立たせたいテキストなどで使用することができるかと思います。
以下のようにフォントやテキストのスタイルを変更できるようにします。

実装

実装は以下の手順で進めていきます。

  1. シンプルな実装
  2. テキストのカスタマイズ

1. シンプルな実装

まずはシンプルにテキストを Entity として表示させる実装を行います。
コードは以下の通りです。

import SwiftUI
import RealityKit

struct SimpleTextView: View {
    var body: some View {
        RealityView { content in
            do {
                let textString = AttributedString("Hello World !")
                let textMesh = try await MeshResource(
                    extruding: textString
                )
                let material = SimpleMaterial(
                    color: .black,
                    isMetallic: false
                )
                let textModel = ModelEntity(mesh: textMesh, materials: [material])

                let boundingBox = textModel.visualBounds(relativeTo: nil)
                let textWidth = boundingBox.extents.x
                textModel.position = SIMD3<Float>(
                    x: -textWidth / 2,
                    y: 1.5,
                    z: -1.5
                )

                content.add(textModel)
            } catch {
                print("テキストが表示できませんでした: \(error.localizedDescription)")
            }
        }
    }
}

それぞれ以下で詳しくみていきます。

以下では表示させるテキストとマテリアルを定義しています。
MeshResourceextruding に対して、 AttributedString を渡すと非同期で文字列から3Dのメッシュが作成されます。元々 AttributedString はテキストの一部分にスタイルを当てるなど、テキストの見た目のカスタマイズをするためのものですが、今回は文字列を渡すのみにしています。

material として、SimpleMaterial を定義しています。
SimpleMaterial は名前の通り、シンプルなマテリアルであり、色と表面の粗さとメタリック(見た目を金属っぽくするかどうか)のみが設定できます。以下では色を黒にするだけにしています。

do {
    let textString = AttributedString("Hello World !")
    let textMesh = try await MeshResource(
        extruding: textString
    )
    let material = SimpleMaterial(
        color: .black,
        isMetallic: false
    )

以下では先ほど作成した textMeshmaterialModelEntity に渡しています。
これで文字列をもとに ModelEntity を作成することができました。

let textModel = ModelEntity(mesh: textMesh, materials: [material])

以下ではテキストの位置を調整しています。
textModel.visualBounds の部分で、 textModel の大きさを BoundingBox として取得しています。取得した大きさのうち横幅のみを使用するため textWidth に代入しています。
textModel.positiontextModel を表示させる位置を調整することができます。
x: -textWidth / 2 とすることでユーザーの目の前に中心に表示させることができます。
y: 1.5 は高さ 1.5m であり、ユーザーの足元から 1.5m の高さに設定しています。
z: -1.5 は奥行き 1.5m であり、ユーザーの足元から 1.5m 離れた場所に設定しています。

let boundingBox = textModel.visualBounds(relativeTo: nil)
let textWidth = boundingBox.extents.x
textModel.position = SIMD3<Float>(
    x: -textWidth / 2,
    y: 1.5,
    z: -1.5
)

以下の content.addtextModel が追加され、RealityView で表示されるようになります。

content.add(textModel)

上記のコードを実行すると以下のような見た目になっているかと思います。

これでシンプルな表示は完了です。

App, ContentView の実装

App, ContentView は以下のようになっており、 ContentView のボタンから SimpleTextView を開けるようにしています。
これからの実装で ID や表示させる View は逐一変更する必要があります。

MyApp.swift
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        
        ImmersiveSpace(id: "SimpleTextView") {
            SimpleTextView()
        }
    }
}
ContentView.swift
import SwiftUI
import RealityKit

struct ContentView: View {
    
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace

    var body: some View {
        Button(action: {
            Task {
                await openImmersiveSpace(id: "SimpleTextView")
            }
        }, label: {
            Text("Open Immersive View")
        })
    }
}

2. テキストのカスタマイズ

次にテキストをカスタマイズしていきます。
テキストのカスタマイズは以下の手順で進めていきます。

  1. フォントの大きさ
  2. フォントの種類
  3. 段落スタイル
  4. 立体感のあるテキスト
  5. マテリアルの変更

1. フォントの大きさ

まずはフォントの大きさを変更してみます。
先程のコードを以下のように変更してみます。
AttributedString には font を設定することができます。
このコードではフォントの大きさを 5 に設定しています。

RealityView { content in
    do {
+       var textString = AttributedString("Hello World !")  // var に変更
+       let font = UIFont.systemFont(ofSize: 5)
+       textString.font = font
        let textMesh = try await MeshResource(
            extruding: textString
        )

上記の変更を加えて実行すると以下のようにフォントの大きさが小さくなっていることがわかります。

2. フォントの種類

次はフォントの種類を変更してみます。
以下のように UIFontname にフォントの名前を指定することでフォントを変更することができます。

    var textString = AttributedString("Hello World !")
+   let font = UIFont(name: "Academy Engraved LET", size: 5)
    textString.font = font
    
    let textMesh = try await MeshResource(
        extruding: textString
    )

上記のコードで実行すると以下のようにフォントが変更されているのが確認できます。

なお、フォントの名前は MacBook の「Font Book」アプリで確認できます。
以下の赤枠で囲まれた部分を UIFontname として指定することでフォントが表示できます。

これでフォントが表示できない場合は以下の手順を踏むことで正常に表示できるようになります。

  1. フォントの書き出し
  2. Xcode に追加
  3. Info.plist の編集

1. フォントの書き出し
Font Book で使用したいフォントを選んで、ファイル > 書き出す を選択してフォントを書き出します。

2. Xcode に追加
書き出したフォントを Xcode に追加します。
Fonts のようなディレクトリを作ってそこに追加すると良いかと思います。
この時、追加したフォントのファイルの「Target Membership」にアプリが含まれていることを確認します。ここの設定ができていないとアプリのプロジェクトでフォントが認識されず使用することができません。

3. Info.plist の編集
最後に Info.plist を開いて、「Fonts provided by application」の項目を追加し、 Item で追加したフォントのファイル名を記述します。
以下では「Doto-Light.ttf」と「Doto-Black.ttf」という二つのフォントのファイルを追加しています。

これで、追加したフォントの名前を UIFontname に指定することで表示できるかと思います。筆者の手元で追加した「Doto-Black.ttf」のフォントを使用したい場合は UIFontnameDoto-Black と指定すれば正常に表示できます。

ちなみに、Doto-Black のフォントを使用して、文字の大きさを大きくして表示すると以下の画像のようになります。通常のテキストとはかなり印象が異なるかと思います。
サービスのイメージや伝えたいことによって使い分けられるのが理想かなと思います。

3. 段落スタイル

次に複数行のテキストに対応して、かつ段落のスタイルを変更していきます。
AttributedString には append で新たなテキストを追加することができます。
以下のようにするとテキストを追加できます。

    var textString = AttributedString("Hello World !")
+   textString.append(AttributedString("SwiftUI"))
+   textString.append(AttributedString("Apple Vision Pro"))
    let font = UIFont(name: "Academy Engraved LET", size: 5)
    textString.font = font
        
    let textMesh = try await MeshResource(
        extruding: textString
    )

これで実行すると以下のようになります。

上記のままだと改行ができていないので、それぞれのテキストの前に \n を追加して実行します。

    var textString = AttributedString("Hello World !")
+   textString.append(AttributedString("\nSwiftUI"))
+   textString.append(AttributedString("\nApple Vision Pro"))
    let font = UIFont(name: "Academy Engraved LET", size: 5)
    textString.font = font
        
    let textMesh = try await MeshResource(
        extruding: textString
    )

これで実行すると以下のようになります。

append で複数行のテキストが追加できることがわかりました。
次は段落のスタイルを変更していきます。

以下のようにコードを変更します。
段落のスタイルは NSMutableParagraphStyle を変更することで設定できます。
NSMutableParagraphStylealignment でテキストを左寄せ、中央寄せ、右寄せなどに設定することができます。以下のコードでは中央寄せにしています。
作成した paragraphStyleAttributeContainer で包んで mergeAttributes に渡すことで、今まで設定したフォントなどのスタイルと結合することができます。

    var textString = AttributedString("Hello World !")
    textString.append(AttributedString("\nSwiftUI"))
    textString.append(AttributedString("\nApple Vision Pro"))
    let font = UIFont(name: "Academy Engraved LET", size: 5)
    textString.font = font
    
+   let paragraphStyle = NSMutableParagraphStyle()
+   paragraphStyle.alignment = .center
+   let paragraphAttributeContaner = AttributeContainer([
+       .paragraphStyle: paragraphStyle
+   ])
+   textString.mergeAttributes(paragraphAttributeContaner)
    
    let textMesh = try await MeshResource(
        extruding: textString
    )

これで実行すると以下のようにテキストが中央寄せになります。

4. 立体感のあるテキスト

次に立体感のあるテキストにしてみます。

以下のようにコードを変更します。
MeshResource.ShapeExtrusionOptions で立体感のあるメッシュの設定を行うことができます。
extrusionMethod.linear(depth: 5) を指定することで直線的に 5 だけ奥側にテキストを押し出すことができます。
そして、その設定を MeshResourceextrusionOptions に渡すことで反映させることができます。

    var textString = AttributedString("Hello World !")
    textString.append(AttributedString("\nSwiftUI"))
    textString.append(AttributedString("\nApple Vision Pro"))
+   let font = UIFont(name: "Academy Engraved LET", size: 10)  // フォントサイズ変更
    textString.font = font
    
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineBreakMode = .byClipping
    paragraphStyle.alignment = .center
    let paragraphAttributeContaner = AttributeContainer([
        .paragraphStyle: paragraphStyle
    ])
    textString.mergeAttributes(paragraphAttributeContaner)
    
+   var extrusionOptions = MeshResource.ShapeExtrusionOptions()
+   extrusionOptions.extrusionMethod = .linear(depth: 5)
    
    let textMesh = try await MeshResource(
        extruding: textString,
+       extrusionOptions: extrusionOptions
    )
    let material = SimpleMaterial(
        color: .black,
        isMetallic: false
    )

上記のコードで実行すると以下のように立体感のあるテキストにすることができます。

以下のように chamferRadius の設定を追加することで、テキストのモデルの角を丸めることができるようになります。

    var extrusionOptions = MeshResource.ShapeExtrusionOptions()
    extrusionOptions.extrusionMethod = .linear(depth: 5)
+   extrusionOptions.chamferRadius = 0.1

「角を丸める」とは公式ドキュメントの chamferradius の以下の画像がわかりやすいかと思いますが、モデルの角が滑らかになるということです。
ただ、この値はフォントの種類や大きさ、押し出す depth の大きさなどによってエラーになることがあるため、慎重に値を変更しつつ設定する必要があります。

5. マテリアルの変更

最後にテキストのマテリアルを変更してみます。

以下のようにコードを変更します。
今までは黒色の SimpleMaterial を使用していましたが、 PhysicallyBasedMaterial を使って水色で多少発光するマテリアルを使用してみます。
baseColor でマテリアル自体の色、 emissiveColor で発光する色を指定することができます。
作成した cyanEmissionMaterialModelEntitymaterials に割り当てることでモデルに反映させることができます。

    let textMesh = try await MeshResource(
        extruding: textString,
        extrusionOptions: extrusionOptions
    )

-   let material = SimpleMaterial(
-       color: .black,
-       isMetallic: false
-   )

+   var cyanEmissionMaterial = PhysicallyBasedMaterial()
+   cyanEmissionMaterial.baseColor = .init(tint: .cyan)
+   cyanEmissionMaterial.emissiveColor = .init(color: .cyan)
    
    let textModel = ModelEntity(
        mesh: textMesh,
+       materials: [cyanEmissionMaterial]
    )

上記のコードで実行すると以下のように明るい水色のテキストのモデルが表示されます。

以下ではさらに細かいマテリアルの設定を行います。

コードを以下のように変更します。

    var extrusionOptions = MeshResource.ShapeExtrusionOptions()
+   extrusionOptions.extrusionMethod = .linear(depth: 1)  // 押し出し量を調節
+   extrusionOptions.materialAssignment = .init(
+       front: 1
+   )
    
    let textMesh = try await MeshResource(
        extruding: textString,
        extrusionOptions: extrusionOptions
    )

+   let blackMaterial = SimpleMaterial(  // 黒色のマテリアルを再度追加
+       color: .black,
+       isMetallic: false
+   )
    
    var cyanEmissionMaterial = PhysicallyBasedMaterial()
    cyanEmissionMaterial.baseColor = .init(tint: .cyan)
    cyanEmissionMaterial.emissiveColor = .init(color: .cyan)
    
    let textModel = ModelEntity(
        mesh: textMesh,
+       materials: [cyanEmissionMaterial, blackMaterial]  // blackMaterial をリストに追加
    )

これで実行すると以下のような表示になります。

以下で詳しくみていきます。

以下の materialAssignment ではモデルのどの部分にどのマテリアルを割り当てるかを細かく設定することができます。ここでは front1 を割り当てているため、 materials に入っているマテリアルのうち、インデックスが 1 のマテリアルが割り当てられます。
先程のコードでは materials: [cyanEmissionMaterial, blackMaterial] となっているため、 front には blackMaterial が割り当てられ、結果としてテキストの前面が黒色になります。

    extrusionOptions.materialAssignment = .init(
        front: 1
    )

materialAssignment には以下の設定項目が設けられており、モデルのどの部分をどのマテリアルで表示するかを細かく指定することができます。

  • front : モデルの前面
  • back : モデルの背面
  • extrusion : 押し出し部分
  • frontChamfer : 前面の角が丸まっている部分
  • backChamfer : 背面の角が丸まっている部分

以下のコードでそれぞれの色に対応するマテリアルを作成して、割り当てて実行すると以下の画像のようになります。

    var extrusionOptions = MeshResource.ShapeExtrusionOptions()
    extrusionOptions.extrusionMethod = .linear(depth: 5)
    extrusionOptions.chamferRadius = 0.8
    extrusionOptions.materialAssignment = .init(
        front: 0,  //  blackMaterial
        back: 1,  //  whiteMaterial
        extrusion: 2,  //  redMaterial
        frontChamfer: 3,  // greenMaterial
        backChamfer: 4  //  blueMaterial
    )

    let textModel = ModelEntity(
        mesh: textMesh,
        materials: [
            blackMaterial,  // 黒色のマテリアル
            whiteMaterial,  // 白色のマテリアル
            redMaterial,  // 赤色のマテリアル
            greenMaterial,  // 緑色のマテリアル
            blueMaterial  // 青色のマテリアル
        ]
    )

前から見たモデル

後ろから見たモデル

それぞれ対応する部分にマテリアルが割り当てられていることがわかります。

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は visionOS におけるテキストの扱いについてまとめました。
今回紹介した以外にも見た目をカスタマイズすることができるので、必要に応じて調べつつ実装できればと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://1planet.co.jp/tech-blog/applevisionpro-oneplanet-24063001-text3d-lowleveltexture

https://developer.apple.com/documentation/scenekit/scnshape/1524145-chamferradius

Discussion