Vision Pro の サンプル解説 (4) - Swift Splash
以下の記事が面白かったので、簡単にまとめました。
前回
1. はじめに
「Swift Splash」は「RealityKit」と「Reality Composer Pro」を活用した、仮想ウォータースライドを作成するアプリです。
2. Reality Composer Pro でのスライドピースの作成
「スライドピース」は、「Swift Splash」の構成要素です。「Reality Composer」プロジェクトには、「スライドピース」ごとに個別のシーンが含まれています。
2-1. connect_in・connect_out
上のスクリーンショットの階層ビューアには、「connect_in」と 「connect_out」という2つの「transform」エンティティがあります。 これらは、「スライドピース」が前後の「スライドピース」に接続するポイントです。これら「transform」を使用して、既存のスライドの最後に新しいピースを配置したりします。
2-2. animation
「animation」エンティティには、RideAnimationComponent が含まれています。 このコンポーネントには、次に接続する「スライドピース」でアニメーションを開始するタイミングを指定する「isPersistent」「duration」などのプロパティも含まれています。
3. マテリアル参照によるマテリアル共有
スライドピースの多くは同じ素材を使用しています。各マテリアルの重複を避けるため、「マテリアル参照」を利用して、複数のシーンの複数のエンティティ間でマテリアルを共有します。
「Reality Composer Pro」プロジェクトには、共有マテリアルごとに個別のシーンが含まれており、その1つのマテリアルのみが含まれています。他のトラック部分は、そのマテリアルへの参照を作成します。元のマテリアルを変更すると、それを参照するすべてのエンティティに影響します。
たとえば、シーン「M_RainbowLights.usda」にはマテリアル「M_RainbowLights」が含まれており、「StartPiece.usda」と「EndPiece.usda」の両方がそのマテリアルを参照します。
4. アセットのロードの並列化
読み込み速度を最大化するために、「TaskGroup」で「Reality Composer」プロジェクトからのシーンの読み込みを並列化します。
アプリは、ロードする必要があるシーンごとに個別のタスクを作成します。
await withTaskGroup(of: LoadResult.self) { taskGroup in
// 通常のスライド部分をロードしアニメーション
logger.info("Loading slide pieces.")
for piece in pieces {
taskGroup.addTask {
do {
guard let pieceEntity = try await self.loadFromRCPro(named: piece.key.rawValue,
fromSceneNamed: piece.sceneName) else {
fatalError("Attempted to load piece entity \(piece.name) but failed.")
}
return LoadResult(entity: pieceEntity, key: piece.key.rawValue)
} catch {
fatalError("Attempted to load \(piece.name) but failed: \(error.localizedDescription)")
}
}
}
// アセットロードジョブの追加を続ける
// ...
}
次に、アプリは非同期イテレーターを使用して結果を待機し、受信します。
for await result in taskGroup {
if let pieceKey = pieces.filter({ piece in
piece.key.rawValue == result.key
}).first {
self.add(template: result.entity, for: pieceKey.key)
setupConnectible(entity: result.entity)
result.entity.generateCollisionShapes(recursive: true)
result.entity.setUpAnimationVisibility()
}
// ...
}
「TaskGroup」の詳細については、「The Swift Programming Language」の「Concurrency」を参照してください。
これらのロードされた各部分はテンプレートとして機能します。プレーヤーがそのタイプの新しいピースを追加すると、アプリは「Reality Composer Pro」 からロードされたピースのクローンを作成し、そのクローンをシーンに追加します。
5. 透明エンティティの並べ替え順序の指定
複数のエンティティに複数の重なり合う不透明マテリアルがある場合、「RealityKit」のデフォルトの深度ソートにより、それらのエンティティが間違った順序で描画される可能性があります。
「Swift Splash」は、「ModelSortGroupComponent」 を透明なエンティティのそれぞれに割り当てて、相対的な深さの並べ替えを手動で指定します。同じ 「ModelSortGroup」を使用して、異なる順序を指定して 「ModelSortGroupComponent」を重複するエンティティのそれぞれに割り当てることでこれを行います。
fileprivate func setEntityDrawOrder(_ entity: Entity, _ sortOrder: Int32, _ sortGroup: ModelSortGroup) {
entity.forEachDescendant(withComponent: ModelComponent.self) { modelEntity, model in
logger.info("Setting sort order of \(sortOrder) of \(entity.name), child entity: \(modelEntity.name)")
let component = ModelSortGroupComponent(group: sortGroup, order: sortOrder)
modelEntity.components.set(component)
}
}
/// 透明な開始ピースのメッシュのソート順序を手動で指定
func handleStartPieceTransparency(_ startPiece: Entity) {
let group = ModelSortGroup()
// 不透明な魚の部分
if let entity = startPiece.findEntity(named: fishIdleAnimModelName) {
setEntityDrawOrder(entity, 1, group)
}
if let entity = startPiece.findEntity(named: fishRideAnimModelName) {
setEntityDrawOrder(entity, 2, group)
}
// 透明な魚の部分
if let entity = startPiece.findEntity(named: fishGlassIdleAnimModelName) {
setEntityDrawOrder(entity, 3, group)
}
if let entity = startPiece.findEntity(named: fishGlassRideAnimModelName) {
setEntityDrawOrder(entity, 4, group)
}
// 水
if let entity = startPiece.findEntity(named: sortOrderWaterName) {
setEntityDrawOrder(entity, 5, group)
}
// ガラス製の地球
if let entity = startPiece.findEntity(named: sortOrderGlassGlobeName) {
setEntityDrawOrder(entity, 6, group)
}
// 選択範囲の輝き
if let entity = startPiece.findEntity(named: startGlowName) {
setEntityDrawOrder(entity, 7, group)
}
}
6. 接続されたトラックピースの横断
すべての個々のスライド部分のルートエンティティには「ConnectableComponent」があります。このカスタムコンポーネントは、他の接続可能なエンティティに接続またはスナップできるエンティティとしてマークを付けます。実行時に、アプリは追加する各スライド部分に 「ConnectableStateComponent」を追加します。 このコンポーネントには、「Reality Composer Pro」で編集する必要のないトラック部分の状態情報が保存されます。このコンポーネントが保存する状態情報の中には、前後のパーツへの参照が含まれます。
接続されていない部分を無視してライド全体を反復するために、アプリは開始部分への参照を取得し、nextPiece が nil になるまで反復します。この反復は、リンクリストの反復と同様に、アプリ全体で何度も繰り返されます。1つの例は、個々の部分を反復処理し、アニメーションの継続時間を合計することによって、構築されたライドの継続時間を計算する関数です。
/// 個々の所要時間を合計して構築された乗車の所要時間を計算
public func calculateRideDuration() {
guard let startPiece = startPiece else { fatalError("No start piece found.") }
var nextPiece: Entity? = startPiece
var duration: TimeInterval = 0
while nextPiece != nil {
// 一部のピースには複数のライドアニメーションがある。最長のものを使用して期間を計算
var longestAnimation: TimeInterval = 0
nextPiece?.forEachDescendant(withComponent: RideAnimationComponent.self) { entity, component in
longestAnimation = max(component.duration, longestAnimation)
}
duration += longestAnimation
nextPiece = nextPiece?.connectableStateComponent?.nextPiece
}
// ゴールポスト後のアニメーションを削除
rideDuration = duration / animationSpeedMultiplier + 1.0
}
7. ライドのインタラクション
「ライド」を作成および編集するには、プレイヤーは2つの異なる方法で 「Swift Splash」を操作します。
「SwiftUIウィンドウ」と対話して、ライドの新しいパーツの追加や既存の部分の削除などの特定のタスクを実行します。また、タップ、ダブルタップ、ドラッグ、回転などの標準の「ジェスチャー」を使用してスライドパーツを操作します。
これらをいつでもサポートできるように、アプリは SimultaneousGesture を使用してジェスチャーを宣言します。 すべてのジェスチャーのコードは、アプリのImmersiveSpaceを制御する「TrackBuildingView」に含まれています。 アプリが回転ジェスチャを定義する方法は次のとおりです。
.simultaneousGesture(
RotateGesture()
.targetedToAnyEntity()
.onChanged({ value in
guard appState.phase == .buildingTrack || appState.phase == .placingStartPiece || appState.phase == .draggingStartPiece else { return }
handleRotationChanged(value)
})
.onEnded({ value in
guard appState.phase == .buildingTrack || appState.phase == .placingStartPiece || appState.phase == .draggingStartPiece else { return }
handleRotationChanged(value, isEnded: true)
})
)
同じ RealityView 上の複数のタップジェスチャは異なるタップ数で実行されるため、複数のジェスチャが一度に呼び出される可能性があります。 たとえば、プレーヤーがエンティティをダブルタップすると、シングル タップとダブルタップのジェスチャ コードの両方が呼び出され、アプリはどちらを実行するかを決定する必要があります。「Swift Splash」は、shouldSingleTapを使用してこの決定を行います。
.simultaneousGesture(
TapGesture()
.targetedToAnyEntity()
.onEnded({ value in
guard appState.phase == .buildingTrack else { return }
Task {
shouldSingleTap = true
try? await Task.sleep(for: .seconds(doubleTapTolerance))
if shouldSingleTap {
次回
この記事が気に入ったらサポートをしてみませんか?