Vision Pro の サンプル解説 (5) - Diorama
以下の記事が面白かったので、簡単にまとめました。
前回
1. はじめに
「Diorama」は、「RealityKit」と「Reality Composer Pro」の機能を紹介するためのサンプルです。 国立公園の登山口やレンジャーステーションにある現実世界のジオラマとよく似た、インタラクティブな仮想地形トレイルマップを表示します。この仮想地図には興味のあるポイントがあり、タップするとさらに詳細な情報が表示されます。 ヨセミテとカタリナ島の2つのトレイルマップ間をスムーズに移動することもできます。
2. Reality Composer Pro
「Reality Composer Pro」は、visionOS アプリの「RealityKit」コンテンツを作成、編集、プレビューするためのツールです。「Reality Composer Pro」プロジェクトでは、1 つ以上のシーンを作成できます。各シーンには、アプリが効率的にロードして表示できる「エンティティ」と呼ばれる仮想オブジェクトの階層が含まれています。
「Reality Composer Pro」では、エンティティ階層の作成を支援するだけでなく、エンティティにコンポーネント (自作のカスタム コンポーネントも含む) を追加することもできます。「Shader Graph」で、エンティティの視覚的な外観をデザインすることもできます。 エンティティのサーフェスとシェイプを大幅にカスタマイズできます。アプリの状態やユーザー入力を基に変化する「アニメーションマテリアル」や「ダイナミックマテリアル」を作成することもできます。
3. アセットのインポート
「Reality Composer Pro」プロジェクトには、シーンを構成するために使用する「アセット」が含まれている必要があります。「Diorama」のプロジェクトには、ジオラマテーブルなどの3Dモデル、トレイルマップ、マップ上を飛ぶ鳥や雲、多数のサウンドや画像など、いくつかのアセットが含まれています。「Reality Composer Pro」は、3Dモデルのライブラリを提供します。 ツールバーの右側にある追加 (+) ボタンをクリックしてライブラリにアクセスします。ライブラリからオブジェクトを選択すると、それらがプロジェクトにインポートされます。
「Diorama」は、利用可能なライブラリアセットの代わりにカスタムアセットを使用します。独自の「Reality Composer Pro」シーンでカスタムアセットを使用するには、プロジェクトブラウザにドラッグ、メニュー「File → Import」、.rkassets にコピーのいずれかでインポートします。
4. エンティティを含むシーンの作成
1つの「Reality Composer Pro」プロジェクトに複数のシーンを含めることができます。シーンは、「RealityView」にロードして表示できる .usda として保存されている「エンティティ階層」です。
シーンを開くには、プロジェクトブラウザでシーンの .usda をダブルクリックします。シーンを編集するには、そのタブを選択し、階層ビューア、3Dビュー、インスペクタを使用して変更を加えます。
5. シーンへのアセット追加
「Reality Composer Pro」は、3Dモデルなどの一部のアセットをシーンに配置すると、それらのアセットを自動的に「エンティティ」に変換します。 他のアセットは間接的に使用します。たとえば、モデルのエンティティのサーフェスの詳細を定義するために画像ファイルを使用します。
「Diorama」は複数のシーンを使用してアセットをグループ化し、実行時にそれらのシーンを1つの没入型エクスペリエンスに結合します。たとえば、ジオラマテーブルには、テーブル、マップ サーフェス、トレイルラインを含む独自のシーンがあります。テーブルの上に群がる鳥のシーンと、テーブルの上に浮かぶ雲のシーンが別々にあります。
エンティティをシーンに追加するには、アセットをプロジェクトブラウザから階層ビューまたは 3D ビューにドラッグします。ドラッグしたアセットがエンティティとして表現できるタイプの場合、「Reality Composer Pro」はそれをシーンに追加します。階層ビューまたは3Dビューでアセットを選択し、ウィンドウの右側にあるインスペクタまたは3Dビューのマニピュレーターを使用して、位置、回転、スケールを変更できます。
6. エンティティへのコンポーネント追加
「RealityKit」は、「ECS」(Entity Component System) と呼ばれる設計パターンに従っています。「ECS」では、コンポーネントでエンティティに追加データを保存し、コンポーネントからのデータを使用することで、「エンティティ」の動作を実装できます。
コンポーネントには、PhysicsBodyComponent などの「付属コンポーネント」と、「Reality Composer Pro Swift Package」のSourcesフォルダに作成して配置する「カスタムコンポーネント」があります。「Reality Composer Pro」で新しいコンポーネントを作成し、それを Xcode で編集することもできます。「ECS」について詳しくは「Understanding RealityKit’s modular architecture」を参照してください。
「Diorama」はカスタム コンポーネントを使用して、どのtransformが注目点であるかを特定し、アプリが確実に群れるように鳥をマークし、2つのマップのうちの1つにのみ固有のエンティティの不透明度を制御します。
コンポーネントをエンティティに追加するには、階層ビューまたは3Dビューでそのエンティティを選択します。 インスペクターの右下にある「Add Component」ボタンをクリックします。使用可能なコンポーネントのリストが表示され、そのリストの最初の項目は「New Component」です。この項目は、新しいコンポーネントを作成し、選択したエンティティに追加します。
コンポーネントのリストを見ると、どのtransformが注目点であるかを示すために「Diorama」が使用する PointOfInterestComponent が表示されます。各エンティティは、特定のタイプのコンポーネントを1つだけ持つことができます。インスペクターで既存のコンポーネントを編集できます。これにより、アプリ内で対象のポイントをタップしたときに表示される内容が変更できます。
7. transformによる位置のマーク
「transform」は空間内の位置をマークする空のエンティティです。位置、回転、スケールが含まれており、その子エンティティはそれらを継承します。ただし、transformには視覚表現がなく、それ自体は何も行いません。シーン内の位置をマークしたり、エンティティ階層を整理したりできます。
「Diorama」は、PointOfInterestComponent を使用したtransformで、地図上の関心のある地点を示します。アプリが実行されると、これらのtransformにより、浮遊プラカードの場所にその場所の名前がマークされます。プラカードをタップすると拡大され、より詳細な情報が表示されます。transformをインタラクティブなビューに変えるために、PointOfInterestComponent と呼ばれる特定のコンポーネントを探します。transformには位置、方向、スケール以外のデータが含まれていないため、このコンポーネントを使用して、アプリがプラカードに表示する必要があるデータを保持します。「Reality Composer Pro」で DioramaAssembled シーンを開き、Cathedral_Rocks というtransformをクリックすると、インスペクターに PointOfInterestComponent が表示されます。
8. シーンのロード
シーンをロードするには、load(named:in:) で、ロードするシーン名とプロジェクトのバンドルを渡します。「Reality Composer Pro Swift Package」は、そのバンドルへの簡単なアクセスを提供する定数を定義します。定数は、「Reality Composer Pro」プロジェクトの名前の末尾に「Bundle」を追加したものです。この場合、プロジェクトは RealityKitContent という名前なので、定数は RealityKitContentBundle と呼ばれます。
「Diorama」が RealityView イニシャライザにマップテーブルをロードする方法は次のとおりです。
let entity = try await Entity.load(named: "DioramaAssembled",
in: RealityKitContent.RealityKitContentBundle)
load(named:in:) は、非同期コンテキストから呼び出された場合は非同期です。RealityView イニシャライザのコンテンツ クロージャは非同期であるため、自動的に非同期バージョンを使用してシーンを読み込みます。非同期で使用する場合は、await キーワードを使用して呼び出す必要があることに注意してください。
9. フローティングビューの作成
「Diorama」は、PointOfInterestComponent をtransformに追加して、興味深い場所の詳細を表示します。すべての観光スポットの名前が、地図上のその場所の上のフローティングビューに表示されます。フローティング ビューをタップすると、詳細情報が表示されます。この情報は、アプリが PointOfInterestComponent から取得します。アプリは、各関心地点の SwiftUIビューを作成し、ImmersiveView.swift で宣言されたこのクエリを使用して PointOfInterestComponent を持つすべてのエンティティをクエリすることにより、これらの詳細を表示します。
static let markersQuery = EntityQuery(where: .has(PointOfInterestComponent.self))
RealityView イニシャライザでは、「Diorama」はクエリを実行して興味のあるポイントエンティティを取得し、それらを createLearnMoreView(for:) という関数に渡します。この関数はビューを作成し、タップされたときに表示するために保存します。
subscriptions.append(content.subscribe(to: ComponentEvents.DidAdd.self, componentType: PointOfInterestComponent.self, { event in
createLearnMoreView(for: event.entity)
}))
10. 興味のある場所のAttachmentの作成
「Diorama」は、Attachmentとして保存されている、LearnMoreView の PointOfInterestComponent に追加された情報を表示します。アタッチメントはSwiftUIビューであり、RealityKitエンティティでもあり、RealityKitシーンの特定の場所に配置できます。「Diorama」はアタッチメントを使用して、各関心ポイントの上に浮かぶビューを配置します。
アプリはまず、エンティティに PointOfInterestRuntimeComponent というコンポーネントがあるかどうかを確認します。存在しない場合は、新しいものを作成してエンティティに追加します。この新しいコンポーネントには、実行時にのみ使用される値が含まれており、「Reality Composer Pro」で編集する必要はありません。
この値を別のコンポーネントに入れて実行時にエンティティに追加すると、「Reality Composer Pro」はその値をインスペクターに表示しません。 「PointOfInterestRuntimeComponent」にはAttachmentタグと呼ばれる識別子が格納されます。この識別子はAttachmentを一意に識別するため、アプリは適切なタイミングでAttachmentを取得して表示できます。
struct PointOfInterestRuntimeComponent: Component {
let attachmentTag: ObjectIdentifier
}
次に、「Diorama」は、PointOfInterestComponent からの情報、表示するタグを使用して LearnMoreView と呼ばれるSwiftUIビューを作成し、そのタグを PointOfInterestRuntimeComponent に保存します。 最後に、ビューを AttachmentProvider に保存します。これは、アタッチメントビューへの参照を保持するカスタムクラスであり、シーン内にないときに割り当てが解除されないようにします。
let tag: ObjectIdentifier = entity.id
let view = LearnMoreView(name: pointOfInterest.name,
description: pointOfInterest.description ?? "",
imageNames: pointOfInterest.imageNames,
trail: trailEntity,
viewModel: viewModel)
.tag(tag)
entity.components[PointOfInterestRuntimeComponent.self] = PointOfInterestRuntimeComponent(attachmentTag: tag)
attachmentsProvider.attachments[tag] = AnyView(view)
11. 興味のある場所のAttachmentの表示
Attachmentプロバイダーにビューを割り当てても、実際にはそのビューがシーンに表示されるわけではありません。 RealityView の初期化子には、Attachmentを指定するために使用される、Attachmentと呼ばれるオプションのビュービルダーがあります。
ForEach(attachmentsProvider.sortedTagViewPairs, id: \.tag) { pair in
pair.view
}
ビューの内容が変更されたときに RealityKit が呼び出すイニシャライザーの更新クロージャーでは、アプリは PointOfInterestRuntimeComponent を持つエンティティをクエリし、そのコンポーネントのタグを使用して正しいAttachmentを取得し、そのAttachmentを追加して配置します。 地図上の位置の上にあります。
viewModel.rootEntity?.scene?.performQuery(Self.runtimeQuery).forEach { entity in
guard let attachmentEntity = attachments.entity(for: component.attachmentTag) else { return }
if let pointOfInterestComponent = entity.components[PointOfInterestComponent.self] {
attachmentEntity.components.set(RegionSpecificComponent(region: pointOfInterestComponent.region))
attachmentEntity.components.set(OpacityComponent(opacity: 0))
}
viewModel.rootEntity?.addChild(attachmentEntity)
attachmentEntity.setPosition([0, 0.2, 0], relativeTo: entity)
}
12. Shader Graphでのカスタムマテリアルの作成
2つの異なる地形図を切り替えるために、「Diorama」には2つの場所の間で地図を変形させるスライダーが表示されます。これを実現し、マップ上に標高線を描画するために、DioramaAssembled シーンの FlatTerrain エンティティは「Shader Graph」マテリアルを使用します。「Shader Graph」は、「Reality Composer Pro」に組み込まれているノードベースのマテリアルエディタです。実行時に変更できる動的なマテリアルを作成できます。
「Diorama」の DynamicTerrainMaterialEnhanced は2つのことを行います。ディスプレイスメントマップ画像に格納されている高さデータに基づいて地図上に等高線を描画し、同じデータに基づいて平面円板の頂点をオフセットします。2つの異なる高さマップ間を補間することにより、アプリは2つの異なる高さデータ セット間のスムーズな移行を実現します。
「Shader Graph」マテリアルを作成する時、Swiftコードから設定するプロモート入力と呼ばれる入力パラメータを与えることができます。これにより、以前はMetalシェーダの作成が必要だったロジックを実装できるようになります。エディタで構築したマテリアルは、カスタムサーフェス出力ノードを使用したエンティティの外観 (Fragment ShaderでMetalコードを記述することと同等)、またはジオメトリモディファイア出力を使用した頂点の位置 (Metalコードの実行と同等) の両方に影響を与える可能性があります。
ノードグラフには、関数に似たサブグラフを含めることができます。これらには、入力と出力を備えた再利用可能なノードのセットが含まれています。 サブグラフには、等高線を描画するロジックと頂点をオフセットするロジックが含まれています。サブグラフを編集するには、サブグラフをダブルクリックします。
「Shader Graph」を使用したマテリアルの構築の詳細については、「Explore Materials in Reality Composer Pro」を参照してください。
13. Shader Graphマテリアルの更新
マップを変更するために、DynamicTerrainmaterialEnhanced には Progress と呼ばれるプロモートされた入力があります。このパラメータが 1.0 に設定されている場合、カタリナ島が表示されます。0に設定すると、ヨセミテが表示されます。他の数字は、2つの間の遷移中の状態を示します。誰かがスライダーを操作すると、アプリはスライダーの値に基づいてその入力パラメータを更新します。
アプリは、スライダーの .onChanged クロージャーが呼び出す handlematerial() という関数に入力パラメータの値を設定します。 この関数は地形エンティティから ShaderGraphMaterial を取得し、それに対して setParameter(name:value:) を呼び出します。
private func handleMaterial() {
guard let terrain = viewModel.rootEntity?.terrain,
let terrainMaterial = terrainMaterial else { return }
do {
var material = terrainMaterial
try material.setParameter(name: materialParameterName, value: .float(viewModel.sliderValue))
if var component = terrain.modelComponent {
component.materials = [material]
terrain.components.set(component)
}
try terrain.update(shaderGraphMaterial: terrainMaterial, { m in
try m.setParameter(name: materialParameterName, value: .float(viewModel.sliderValue))
})
} catch {
print("problem: \(error)")
}
}
この記事が気に入ったらサポートをしてみませんか?