ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
RealityKitデバッガの詳細
RealityKitデバッガについて説明します。この新しいツールを使用して、空間アプリのエンティティ階層を検査する方法、不適切な変換をデバッグし、欠落したエンティティを特定する方法、コードのどの部分が原因でシステムに問題が生じているかを突き止める方法を確認しましょう。
関連する章
- 0:00 - Introduction
- 0:23 - Agenda
- 0:53 - Prepare for the Journey
- 1:41 - Meet the RealityKit debugger
- 2:40 - Transform the BOTanist
- 4:02 - Traverse hierarchy issues
- 7:20 - Address bad behaviors
- 10:52 - Find what's missing
- 18:44 - Embrace uniqueness
- 22:34 - Wrap up
リソース
関連ビデオ
WWDC24
-
ダウンロード
こんにちは Jeremiahです 空間アプリやゲームの作成を支援する デベロッパ向けツールを作っています 今回は RealityKitアプリに潜む 一般的なバグについてお話しします その中で RealityKitデバッガを紹介します バグの発見に役立つ新しいツールです まずRealityKitデバッガを 簡単に見ていきます そして このデバッガを使い アプリを調べてバグを見つけ出します エンティティ階層をたどり 予期しない変換を探します 様々なコンポーネントの誤りを明らかにして システムの不正な挙動に対処します レンダリングで陥りやすい問題に取り組み 足りないコンテンツを見つけます 最後に アプリの独自性に合わせて RealityKitデバッガを使うための ヒントとコツを紹介します 準備はいいですか? では始めましょう RealityKitを使えば 驚くような3Dアプリを作成し iOS macOS visionOSに デプロイできます その一例とも言えるのが BOT-anistサンプルという 植物の世話をするかわいいロボットです でも 大きな荷物を背負って 一日中駆け回るのは 大変な仕事です 時には ゆっくりとリラックスできる場所が ロボットにも必要です 友達と会ったり 高級オイルを楽しんだり ロボットだけにダンスをしたり とにかくストレスを発散できる空間です そこで このセッションでは チルアウトモードを BOT-anistサンプルに追加します 庭をクラブに変える プロトタイプを今作っています しかし たくさんのバグが あちこちに残っているので まだオープンできる状態ではありません RealityKitデバッガを使って バグを突き止めましょう
RealityKitデバッガは実行中の アプリの3Dスナップショットを取り Xcodeに読み込んで 調査できるようにします 画面の下部にあるデバッグ領域で ボタンを クリックして開始します
スナップショットの処理が完了すると キャプチャされたRealityKitシーンが 左側のデバッグナビゲータに表示されます
シーンを選択すると 隣のアウトラインビューに そのエンティティ階層が表示されます
また 3Dビューポートで コンテンツが再構築されます
階層またはビューポートで エンティティを選択すると そのプロパティと 要素のプロパティが 右側のインスペクタに表示されます
現在選択されている階層の 統計情報を表示する インスペクタもあります RealityKitデバッガは 既存のXcodeワークフローになじみ 3D開発体験を より生産的で楽しいものにする上で 役立つ新たな情報を提供します 新しいツールを手に入れたので クラブの手直しを始めましょう
このサンプルを変えるための コードパッチはかなり大掛かりです 確かめながら進めるには ClubView.swiftファイルをダウンロードし Xcodeプロジェクトにドラッグして ターゲットに含めてください その後 小さな変更を2つ加える必要があります 1つ目に クラブの 新しいボリュメトリックシーンを定義します そして これを BOTanistAppのbodyに追加します
2つ目に クラブを開くための ボタンが必要です これをRobotViewのbodyに追加します 既存のボタンの 「Start Planting」の横です これで アプリをビルドして visionOSシミュレータで実行できます
アプリが起動すると ロボットビューでアプリが開きます ロボットを作るのではなく ミラーボールを クリックしてクラブに忍び込みます
カメラ制御を使って近づくことができます
シーンの既存のアセットに 色々と手を加えています 例えば プランターをテレポーターに変えています ミラーボールなど ゼロから作成した 新しいエンティティもあります 今は 少し変な感じに見えますね シーンを調べて原因を探ってみましょう
Xcodeでデバッグ領域のボタンを使って RealityKitデバッガを起動します
本題に入る前に 変換の階層がどう機能するか 少し考えてみましょう 3Dシーンにコンテンツを配置する時は 位置 向き スケールを設定します よくあるバグの1つは 設定した場所に コンテンツが表示されないことです 一般にこの問題が発生する理由は エンティティの最終的な配置が それ自体の変換とすべての祖先の変換の 組み合わせであるからです そのため 1つのエンティティにのみ 適用すべき変換が 別のエンティティにも 現れることがよくあります このミラーボールでも 同じようなことが起こっていそうです RealityKitデバッガを使って 確認してみましょう
デバッグナビゲータで シーンラッパーを展開し RealityKitコンテンツを選択します そのシーンがデバッガで開きます
この時点で ナビゲータとデバッグ領域を 非表示にして 作業スペースを広げることができます
ビューポートでミラーボールを ダブルクリックして選択し中央に配置します
メインビューポートには シーンに現れるエンティティが 祖先からの変換が 適用された状態で表示されます ビューポートでエンティティを選択すると エンティティ階層とインスペクタでも そのエンティティが選択されます 現在選択されている エンティティの名前はOutlineです このエンティティは ミラーボールの線を表示します エンティティインスペクタには 小さい副ビューポートがあります このビューポートで 変換が適用されていない エンティティの ModelComponentをプレビューできます プレビューでは Outlineエンティティは歪んでいないので 問題の原因はメッシュではありません また プレビューウィンドウの 下に表示されている このエンティティのTransform要素を見ると スケールは1に揃っています つまり エンティティが 自らを歪めているのではありません どうやら この問題は 祖先から引き継がれたようです 階層をたどって 問題のある変換を見つけましょう エンティティ階層で 親の Backgroundエンティティをクリックします インスペクタのプレビュービューポートと Transform要素を見ると このエンティティも 歪みの原因ではありません 階層でその親の Supportエンティティを クリックしてみましょう
SupportエンティティのTransform要素を インスペクタで見ると Y軸のスケールが 大きくなっていることがわかります このエンティティを目的の形状にするために 拡大縮小すること自体は間違っていません ここでの誤りはこの拡大縮小を そのすべての子孫に 意図せず適用していることです
SupportとBackgroundを 親子ではなく 兄弟にすると問題は解決します
シーンではつながったままに見えますが Supportの変換は ミラーボールに影響しなくなります アプリをもう一度実行し クラブ内に移動して 結果を確認しましょう
RealityKitデバッガを使って 階層をたどり エンティティを歪めていた不適切な変換を 見つけることができました そして親を変更して修正できました これでミラーボールができました 改装中のクラブは 体裁が整いつつあります 次はこれらのオブジェクトに 命を吹き込みましょう RealityKitではエンティティ コンポーネントシステム(ECS)を使って オブジェクトと動作を管理します エンティティを特徴づけるには データを保持できる要素を エンティティに割り当てます その後 特定の要素を持つ エンティティに対して システムで更新を実行します エンティティの要素がなかったり 正しく設定されていないと システムの動作は予測不能になります クラブに戻って例を見てみましょう
すべてのプランターを クラブのテレポーターに変えました テレポートシステムで ロボットが出てくるはずですが 誰も現れません デバッガを開いて原因を調べてみましょう
まずテレポートシステムの 仕組みについて説明します
コントロールセンター要素に システムのデータが保存されます 更新のたびに カウントダウンの値が減っていきます カウントダウンの値が0になると テレポーター要素を使って シーンのエンティティがすべて特定され ランダムに1つ選択されます その位置にロボットが出てきます カウンタがリセットされ クラブが 満員になるまでこの処理が繰り返されます デバッガに切り替えて これらの要素を調べてみましょう
まず疑わしい点はテレポーター要素を テレポーターエンティティに 追加し忘れたことです その場合は テレポーターが見つからなくなり ロボットが出てくる場所がなくなります RealityKitデバッガを使えば 簡単に確認できます エンティティ階層で BOT Clubと Teleportation Centerを順に展開し 最初のテレポーターをダブルクリックします
エンティティインスペクタで テレポーター要素が 実際にあることを確認します 他の2つのテレポーターも 念のため確認してみましょう
やはり テレポーター要素があります となると問題は コントロールセンターかもしれません 階層で親エンティティの Teleportation Centerを選択します
コントロールセンター要素の プロパティを見てみましょう
思い当たるのはカウントダウン値です 初期値と同じであることがわかります RealityKitデバッガで捕捉されるのは 一時停止した瞬間のアプリの状態なので システムが動作していれば この値は変更されているはずです コントロールセンター要素が 何らかの理由で更新されていないのです コードを調べて原因を突き止めましょう
更新のたびに コントロールセンター要素で カウントダウン値が減るようにしています その後 更新した要素を エンティティに保存しないといけませんが その手順を忘れたようです 足りない手順を追加して アプリを再実行しましょう これはよくある間違いです 変更した要素は そのエンティティに代入し直す必要があります シミュレータに切り替えて 問題が解決したか確認してみましょう
RealityKitデバッガを使って うまく動作していないシステムを 調査して修正しました では クラブに戻って お客さんを待ちましょう 早くお客さんが来るといいのですが 何しろここの家賃は 超高額ですから よかった 最初のお客さんがやっと来ました
ロボットがテレポートしてくるように なりましたが まだ問題があります カウンターの上に オイルのボトルがあるはずですが 見当たりません ちゃんと補充したはずなのに 早く見つけないと ロボットたちが踊るのをやめ この場所は軋んで止まってしまいます レンダラにより 非表示になっているように思います 説明しましょう RealityKitのような3Dレンダラでは 一定のパフォーマンスを得るために レンダリングする対象を選んで 時間を節約します 例えば 遠くにあるものや 近すぎるもの 他のコンテンツに隠れているもの 不透明度の設定が低すぎるもの ARKitアンカーが必要なもの アセットが不足しているもの こうした問題がある場合 そのコンテンツはレンダリングされません そして理由の分析は たいてい消去法になります
Reality Composer Proを使って アセットを準備 テスト パッケージ化すると このような問題を回避できます それでもコンテンツが失われる場合は RealityKitデバッガで原因を特定できます では デバッガに切り替えて ボトルが表示されない 今回のケースを解決しましょう
ビューポートで カウンターをダブルクリックし カメラを調整して うまくクローズアップします
よさそうです カウンターには最高級のオイルが入った 緑色のボトルが9本あるはずです しかし 今は1本しかなく それも正しくレンダリングされていません 原因を調べましょう エンティティ階層で CounterとBottle Groupを順に展開し 1本目のボトルを選択します
選択したエンティティは それ自体が他のエンティティに隠れていても ハイライト表示されます この場合 目的のボトルがあるのを確認できますが カウンターの下にあります これをインスペクタで確認し ボトルのTransform要素を見てみると Y方向が負の値になっています この修正は簡単なので 後で行うことにします とりあえず先に進んで 次の問題を調べましょう
階層でボトル2を選択します
選択した要素が ビューでハイライトされていません 階層でエンティティを ダブルクリックすると それにカメラがフォーカスします
かなり遠くにありますね とても離れていて 黄色い枠で示された シーンの境界を越えています このため レンダラでクリッピングされ 表示されることはありません 1本目のボトルと同様に この問題を解決するには変換を修正します
次に進みましょう 階層で3本目のボトルを ダブルクリックして選択しフォーカスします
今度は とても大きいボトルで その中にシーンがあるようです メッシュを構成する三角形は 通常は片側しか見えません そのため メッシュの内側からは まったく見えないことがよくあります このオブジェクトは 小さくすれば問題が解決します 今はカウンターからかなり離れているので 階層でボトル4を ダブルクリックして戻りましょう
ご覧のように 階層で ボトル4の横にアイコンがあります このアイコンはエンティティが アクティブでないことを示しています アクティブでないエンティティは レンダリングされません 要素を調べると 問題を特定するのに役立ちます
今までのボトルとは違い このボトルにはOutOfStock要素があります 在庫切れの商品については この要素でタグを付けて非表示にしています つまり レンダリングされないのは 意図した通りの動作です
ボトル5に進みましょう
このボトルのインスペクタで 別の予期せぬ要素が見つかりました Anchoring要素です クラブでディナーサービスを 追加するつもりだったのですが これは その初期プロトタイプの レガシーコードです その機能を削除した時に この要素を削除するのを 忘れてしまったようです 一致するARKitアンカーがシーンにない Anchoring要素を持つ エンティティについては レンダリングが停止します
ボトル6に進みましょう
ビューポートに選択対象の輪郭はなく 軸だけ表示されています これはエンティティに ModelComponentがないことを意味し インスペクタでもそれを確認できます 読み込みに失敗したか 間違ったエンティティに関連づけたようです ここでは判断できないので 後でコードを確認する必要があります ただし 何が問題で どこに注目すべきかはわかっています
ボトル7に進みます
このボトルはメインビューポートにも プレビュービューポートにも見当たりません つまり ModelComponentの 問題と考えられます メインビューポートに表示された 輪郭は正しいようです このことからメッシュは正常であり 問題はマテリアルにあると考えられます ModelComponentでマテリアルの プロパティを展開して調べてみましょう
このマテリアルは 半透明に設定されていますが 同時に不透明度のしきい値が1です これは設定ミスであり 実質的に 不透明度が1未満の モデルのどの部分も レンダリングしないように指定しながら モデルのすべての部分の 不透明度を1未満に設定しています 結果的にボトル全体が見えなくなっています
次のボトル8は実際に表示されています ただ不完全です インスペクタでは 明らかな間違いはないようです このような状況では RealityKitデバッガで 追加の表示を使うと 他の方法では見逃す可能性がある 問題を特定しやすくなります
プレビュービューポートで 右端のドロップダウンを使って レンダリングモードを変更します 一番上の 法線を表示するオプションを選択します 各点の法線値を使って オブジェクトが色付けされます 法線値はサーフェスの向きを示し 照明とレンダリングの計算に使用されます 最初は難しく感じるかもしれませんが ボトル1のような 正常とわかっているボトルに切り替えると
メッシュに何か問題があることがわかります
こうしたエラーは インポートしたアセットでよく見られ 3Dコンテンツ作成ツールで 修正する必要があります
では最後の1つです ボトル9を選択します ボトル9が見当たりませんね シーンに追加するのを忘れたのでしょう これを確認するために 階層ビューの下部にある フィルタバーを使用して 名前にBTが含まれる エンティティのみを表示します
予想通り シーンにありません
多くの問題を駆け足で見てきたので 簡単にまとめましょう 見当たらなかった最初の数本のボトルは 変換が原因で隠れていたり クリッピングされていたり内と外が逆でした 無効になっていたために 非表示だったボトルもあり アンカーがなかったために 非表示だったボトルもありました メッシュに問題があったボトルもあり メッシュがないものもあり マテリアルの設定ミスによって 見えなくなったボトルもありました そして単純に シーンに追加し忘れたボトルもありました コピー&ペーストによるエラーが ボトル作成コードに多数含まれていました
そこで このコードを 1つの作成ループに置き換えるつもりです 3Dアセットをコード内で 直接配置して設定するのは難しいので できるだけ Reality Composer Proで シーンのレイアウトを 準備することをお勧めします アプリを再実行して クラブ内に移動しましょう
デバッガの様々なツールを使って 見当たらないボトルを発見できました カウンターには今や オイルがいっぱいあります これでスムーズに進められます
問題のある変換や 誤って設定された要素 レンダリングの落とし穴など 独自のアプリを構築する時に遭遇しやすい 多くの問題についてすでに説明しました とはいえ アプリの多くの部分は独自のものです そして複雑性が増すにつれ デバッグする際の課題も増えていきます しかし ECSの柔軟性を活用して RealityKitデバッガの 使い方をカスタマイズできます どのようなものか見てみましょう こちらはダンスシステムです ダンスフロアの周りに配置された 目に見えない 一連のアトラクターエンティティを 通じて機能します Newcomerのロボットが クラブにテレポートしてくると 空のアトラクターのターゲットになります そして更新のたびに アトラクターに徐々に引き寄せられます ロボットがアトラクターに到達すると モチベーターが作動し ロボットがダンスを始めます しかし 何かおかしいと お気づきかもしれません ロボットがテレポートしてきても その場に立っているだけで アトラクターの方へ移動していません RealityKitデバッグ機能を このシステムに組み込みましょう
まず シーンに 基本的なモデルエンティティを追加して 見えないアトラクターを視覚化します アトラクター状態など インスペクタに渡す値を格納する カスタム要素を それぞれに指定します そしてこれらを 1つの見えないエンティティにグループ化し 実行中に何も表示されないようにします この親エンティティにも カスタム要素を指定して システム全体に関する情報を 表示するようにします
こちらは簡略版のコードです ClubView.swiftファイルには 完全版がすでに含まれています 大掛かりに見えますが 標準的なRealityKitしか使っていません エンティティ 要素 システムです 奇妙に思われるかもしれませんが デバッグコンパイルブロック内に これらすべてが配置されています こうすると リリースされるアプリから このコードがコンパイルアウトされるので パフォーマンスへの影響を 心配する必要がなくなります 新しいデバッグシステムを 用意したのでアプリを実行し ロボットが現れるまで待ってから デバッガを作動させましょう
「 Dance System」を 階層で見つけて選択します ご覧のように インスペクタに デバッグ要素のプロパティが表示されます RealityKitデバッガでは アプリで使うことが多い ほとんどの型を表示できます これは カウンタの表示など 簡単な方法で使えるだけでなく UIImageプロパティとして保存して Swiftのグラフを表示するなど 創造的な方法でも利用できます この新しいデバッグ要素を活用して ダンスシステムの問題を特定できます すべてのアトラクターが 引き込み中の状態になっていますが これはあり得ないことです これを観察するもう1つの方法は 視覚化を使うことです 階層内の「 Dance System」を 副ボタンでクリックして コンテキストメニューを開き 表示と非表示を切り替えます
これにより 各アトラクターの状態が視覚化されます 実際にすべてオレンジ色です この色は引き込み中の状態を示しています 同じロボットを複数のアトラクターで ターゲットにすることはできません テレポートしてきたロボットは Newcomer要素でタグ付けされ 1つのアトラクターのターゲットになると そのタグが削除されます デバッグの視覚化を1つ選択して そのデバッグ要素を調べてみましょう
この要素はターゲットロボットへの 参照を格納するように設定されていて RealityKitデバッガにより その値がリンクに変換されます クリックしてターゲットを見つけましょう
ロボットの要素を調べると 問題が明らかになります Newcomer要素がまだありますが 最初にターゲットになった時に 削除されるべきだった要素です 削除されなかったので 全部のアトラクターがこのロボットを見つけ 引き込もうとしています そのため ロボットは 行き先を選べず動けなくなっています 問題を特定したので コードを確認して修正できます
ダンスシステムではターゲットの設定時に Newcomer要素を削除する必要がありますが そうしていません そのためのコードを追加しましょう その後 アプリを再実行します
この種のバグは簡単に修正できますが 追跡が難しくなる場合もあります システムの複雑さが増したり アプリの規模が大きくなると特にそうです しかし 同じシステムを活用して視覚化を構築し カスタム要素を使って インスペクタを追加すれば 開発体験は プレイヤーや ロボットのために構築する体験と同じくらい 楽しいものになります それでは いよいよクラブをオープンし 成功を満喫しましょう
RealityKitデバッガを利用して エンティティ階層や要素の問題を追跡し 修正することができました また ECSの柔軟性を活用して 視覚化と カスタムインスペクタを追加しました これにより アプリの 独自の部分でも適切にデバッグできます このセッションでは 多くのことを取り上げました では ロボットの友達と同じように 私もリラックスしに行ってきます
-
-
2:45 - ClubView
/* Abstract: The full club patch. SwiftUI view, state, extensions and helpers. */ import SwiftUI import RealityKit import OSLog import BOTanistAssets import Combine import Charts struct ClubView: View { @State var state = ClubViewState() var body: some View { ZStack { RealityView { content in state.loadEnvironment() state.rootEntity.scale = SIMD3<Float>(repeating: 0.5) content.add(state.rootEntity) } update: { updateContent in if !state.doorSupervisor.doorsOpen { state.transformIntoClub(content: updateContent) } } } } } @Observable @MainActor final public class ClubViewState: Sendable { let rootEntity = Entity() private var loadedEnvironmentRoot: Entity? private var robotRevolutionController: Entity? private var host: Entity? private(set) var doorSupervisor: DoorSupervisor { get { rootEntity.components[DoorSupervisor.self]! } set { rootEntity.components[DoorSupervisor.self] = newValue } } init() { RevolvingSystem.registerSystem() HoverSystem.registerSystem() TeleportationSystem.registerSystem() DanceMotivationSystem.registerSystem() rootEntity.name = "The B0T Club" rootEntity.components[DoorSupervisor.self] = DoorSupervisor(capacity: 9) } /// Load the existing garden assets func loadEnvironment() { guard loadedEnvironmentRoot == nil else { return } if let environment = try? Entity.load(named: "scenes/volume", in: BOTanistAssetsBundle) { environment.name = "Environment" self.loadedEnvironmentRoot = environment rootEntity.addChild(environment) } } /// Renovate the loaded environment to build our club func transformIntoClub(content: RealityViewContent) { guard !doorSupervisor.doorsOpen else { return } // Build a teleportation center and use it to spawn robots addTeleportationCenterToTheClub() // Haphazardly clean up the space by hiding anything un-club-like hideStuffInTheEnvironment() // Polish that floor and add some spin addRevolvingDanceFloorToTheClub() // Keep the robots moving in an orderly fashion addRobotRevolutionControllerToTheClub() // Install some attractors to entice robots to the dance floor addDanceFloorAttractors() // Set the mood addSpotlightsToTheClub() // Stock up on oil to keep the moves smooth addCounterToTheClub() // And add a huge Disco Ball, because... addDiscoBallToTheClub() // Let the party begin openDoors() } /// Construct a Teleportation Center and add it to the Club's root entity private func addTeleportationCenterToTheClub() { let teleportationCenter = Entity() teleportationCenter.name = "Teleportation Center" rootEntity.addChild(teleportationCenter) // Liven up the planters to look more like teleporters let positions: [SIMD3<Float>] = [[0.128, 0, 0.14], [-0.255, 0, 0.23], [0.05, 0, -0.17]] let colors: [(UIColor, UIColor)] = [(.green, .yellow), (.magenta, .purple), (.cyan, .blue)] for index in 0...2 { if let teleporter = rejigPlanter(identifier: String(index + 1), position: positions[index], colors: colors[index]) { teleportationCenter.addChild(teleporter) } } // Create a Control Center and provide a closure to handle robot spawning let teleportationControlCenter = ControlCenterComponent( initialValue: 10, interval: 5, rootEntity: rootEntity) { teleporter in self.spawnRobot(from: teleporter) self.countVisitor() // Have the host say hello if let hostCharacter = self.host?.components[AutomatonControl.self]?.character { hostCharacter.transitionToAndPlayAnimation(.idle) hostCharacter.transitionToAndPlayAnimation(.wave) } } // Assign the new control center component to the teleportation center entity teleportationCenter.components[ControlCenterComponent.self] = teleportationControlCenter } /// Transforms the visuals of the planters to look more teleporter-y private func rejigPlanter(identifier: String, position: SIMD3<Float>, colors: (UIColor, UIColor)) -> Entity? { if let rim = rootEntity.findEntity(named: "heroPlanter_rim_\(identifier)"), let dirt = rootEntity.findEntity(named: "dirt_hero_\(identifier)"), let rimModelComponent = rim.components[ModelComponent.self], var dirtModelComponent = dirt.components[ModelComponent.self] { // Apply the luminous material from the rims to the dirt (trust me it will look cool). dirtModelComponent.materials = rimModelComponent.materials dirt.components[OpacityComponent.self] = OpacityComponent(opacity: 0.7) dirt.components[ModelComponent.self] = dirtModelComponent } // Make a teleporter container entity let teleporter = Entity() teleporter.name = "Teleporter-T\(identifier)" teleporter.position = position teleporter.components[TeleporterComponent.self] = TeleporterComponent() // Add a particle emitter let radius: Float = 0.035 var particleEmitter = ParticleEmitterComponent.Presets.teleporter particleEmitter.emitterShapeSize = .init(repeating: radius) particleEmitter.mainEmitter.color = .constant(.random(a: colors.0, b: colors.1)) let particleEntity = Entity() particleEntity.orientation = .init(angle: -.pi / 2, axis: [1, 0, 0]) particleEntity.components[ParticleEmitterComponent.self] = particleEmitter particleEntity.name = "Photons" particleEntity.scale = .init(repeating: 1) teleporter.addChild(particleEntity) #if DEBUG // Add a debug marker in case we want to visually inspect this in the RealityKit Debugger teleporter.addDebugMarker(radius: radius, color: colors.0) #endif return teleporter } /// adds a random robot to the club root, positioned at the provided point private func spawnRobot(from spawnPoint: Entity) { guard let robotCharacter = randomRobot() else { logger.error("Robot creation malfunction 🤖💥") return } let guest = Entity() guest.addChild(robotCharacter.characterParent) guest.position = spawnPoint.position(relativeTo: rootEntity) guest.components[Newcomer.self] = Newcomer() guest.components[AutomatonControl.self] = AutomatonControl(character: robotCharacter) rootEntity.addChild(guest) // Play a little flashy burst on the particle emitter if let particles = spawnPoint.findEntity(named: "Photons") { var component = particles.components[ParticleEmitterComponent.self] component?.burst() particles.components[ParticleEmitterComponent.self] = component } } /// misuses AppState as a robot factory - don't try this at home, or do, but don't ship it! private func randomRobot() -> RobotCharacter? { let robotMaker = AppState() // Use offsets from the loaded animation rig, with some random parts guard let skeleton = robotMaker.robotData.meshes[.body]?.findEntity(named: "rig_grp") as? ModelEntity else { logger.error("Failed to find a robot animation rig... all dancing in cancelled ❌🕺") return nil } robotMaker.randomizeSelectedRobot() guard let head = robotMaker.robotData.meshes[.head]?.clone(recursive: true), let body = robotMaker.robotData.meshes[.body]?.clone(recursive: true), let backpack = robotMaker.robotData.meshes[.backpack]?.clone(recursive: true) else { fatalError() } let robotCharacter = RobotCharacter( head: head, body: body, backpack: backpack, appState: robotMaker, headOffset: skeleton.pins["head"]?.position, backpackOffset: skeleton.pins["backpack"]?.position ) // Pick a random robot name from the sequence robotCharacter.characterParent.name = RobotNames.next // Remove the character controller and animation state, as we'll manually control these robotCharacter.characterParent.components[CharacterControllerComponent.self] = nil AnimationState.handlers.removeAll() // The robots are here to chill, so actually, let's put their backpacks in the cloakroom backpack.removeFromParent() // Say Hi robotCharacter.transitionToAndPlayAnimation(.wave) return robotCharacter } /// Update capacity when we have a visitor private func countVisitor() { var management = self.doorSupervisor management.visitorCount += 1 self.doorSupervisor = management } /// Find and hide a bunch of stuff in the loaded environment private func hideStuffInTheEnvironment() { // We used the RealityKit Debugger to identify the names of things we want to hide in the club ["setDressing", "MovementBoundaries", "planter_side", "planter_Hero", "planter_Hero_1", "planter_Hero_2", "PlantLightGroup", "PlantLightGroup_1", "PlantLightGroup_2", "SidePlanterLights", "pipe_2", "pipe_3", "dirt_coffeeBerry_1", "dirt_coffeeBerry_2", "dirt_coffeeBerry_3", "dirt_side"].forEach { name in if let entity = rootEntity.findEntity(named: name) { entity.removeFromParent() } } } /// Repurpose some existing bits in the environment to create a makeshift revolving dance floor - if it looks like dirt, that's because it is private func addRevolvingDanceFloorToTheClub() { guard let dirtFloor = loadedEnvironmentRoot?.findEntity(named: "dirt_end") else { return } // Add a revolving container entity let revolvingDanceFloor = Entity() revolvingDanceFloor.name = "Revolving Dance Floor" revolvingDanceFloor.scale = [1, 1, 1] revolvingDanceFloor.position = [0, 0.181, 0] revolvingDanceFloor.components[RevolvingComponent.self] = RevolvingComponent(relativeTo: rootEntity) // Polish up the dirt floor let geometry = dirtFloor.clone(recursive: false) geometry.name = "Dirt Floor" geometry.transform = .identity geometry.position = [0, 0, 0] geometry.scale = dirtFloor.scale(relativeTo: rootEntity) let polish = geometry.clone(recursive: false) polish.name = "Polish Layer" polish.position = [0, 0.0004, 0] if var modelComponent = geometry.components[ModelComponent.self] { var polishedFloorMaterial = PhysicallyBasedMaterial() polishedFloorMaterial.baseColor = .init(tint: .gray) polishedFloorMaterial.roughness = .init(floatLiteral: 0.2) polishedFloorMaterial.metallic = .init(floatLiteral: 0.8) polishedFloorMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5)) polishedFloorMaterial.clearcoat = .init(floatLiteral: 0.4) modelComponent.materials = [polishedFloorMaterial] polish.components[ModelComponent.self] = modelComponent } // Add it to the revolving container revolvingDanceFloor.addChild(geometry) revolvingDanceFloor.addChild(polish) rootEntity.addChild(revolvingDanceFloor) } /// Creates a revolving container entity to keep robots moving in sync with the dance floor private func addRobotRevolutionControllerToTheClub() { let robotRevolutionController = Entity() robotRevolutionController.name = "Robot Revolution Controller" robotRevolutionController.components[RevolvingComponent.self] = RevolvingComponent(relativeTo: rootEntity) rootEntity.addChild(robotRevolutionController) self.robotRevolutionController = robotRevolutionController } /// Add invisible attractors to the dance floor to position and control robots private func addDanceFloorAttractors() { guard let robotRevolutionController else { logger.error("The Robot Revolution Controller is missing 😱") return } // Add a few dance spots on the outside of the club that we know don't obstruct the furniture let staticAttractors = Entity() staticAttractors.name = "Static Attractors" let placementRadius: Float = 0.25 let outerRadius = placementRadius * 0.8 addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 10), placementRadius: outerRadius, name: "Static-A1", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 90), placementRadius: outerRadius, name: "Static-A2", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 130), placementRadius: outerRadius, name: "Static-A3", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 240), placementRadius: outerRadius, name: "Static-A4", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 325), placementRadius: outerRadius, name: "Static-A5", variation: 0) rootEntity.addChild(staticAttractors) // The remaining center attractors are on the revolving dance floor and can be more randomly positioned let innerRingCapacity = doorSupervisor.capacity - 5 let revolvingAttractors = Entity() revolvingAttractors.name = "Revolving Attractors" addDanceFloorAttractors(to: revolvingAttractors, count: innerRingCapacity, placementRadius: placementRadius * 0.3, namePrefix: "Revolving") robotRevolutionController.addChild(revolvingAttractors) #if DEBUG // Add some debug visualizations let debugRoot = Entity() debugRoot.name = "[Debug] Dance System" debugRoot.isEnabled = false debugRoot.components[DanceSystemDebugComponent.self] = DanceSystemDebugComponent() rootEntity.addChild(debugRoot) let allAttractors = Array(staticAttractors.children) + Array(revolvingAttractors.children) // Create a new visualization for each attractor allAttractors.forEach { attractor in if let visualization = Entity.makeDebugMarker(height: 0.08, radius: 0.03, enabled: true) { guard let attractorComponent = attractor.components[AttractorComponent.self] else { return } let debugComponent = AttractorDebugComponent(state: attractorComponent.state, attractor: attractor) visualization.position = [0, 0.04, 0] visualization.components[AttractorDebugComponent.self] = debugComponent debugRoot.addChild(visualization) } } #endif } /// Add multiple dance floor attractors along the circumference of a circle with the specified placementRadius private func addDanceFloorAttractors(to danceFloor: Entity, count: Int, placementRadius: Float, namePrefix: String, variation: Float = 0.005) { let angleIncrements = 360 / count for offset in 0..<count { let angle = Angle2D(degrees: Double(angleIncrements * offset)) let name = "\(namePrefix)-A\(offset + 1)" addDanceFloorAttractor(to: danceFloor, angle: angle, placementRadius: placementRadius, name: name, variation: variation) } } /// Adds a single dance floor attractor at a point on the circumference of a circle with the specified placementRadius private func addDanceFloorAttractor(to danceFloor: Entity, angle: Angle2D, placementRadius: Float, name: String, variation: Float = 0.005) { let attractor = Entity() attractor.name = name attractor.components[AttractorComponent.self] = AttractorComponent(club: rootEntity) attractor.position = pointOnCircumference(angle: angle, radius: placementRadius, variation: variation) danceFloor.addChild(attractor) } /// Adds some revolving spot lights to the club private func addSpotlightsToTheClub() { let placementRadius: Float = 0.5 let lightsWrapper = Entity() lightsWrapper.name = "Light Rig" let magentaLight = SpotLight() magentaLight.light.color = .magenta magentaLight.light.intensity = 500 var lightPosition = pointOnCircumference(angle: Angle2D(degrees: 0), radius: placementRadius, y: 0.5) magentaLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity) lightsWrapper.addChild(magentaLight) let greenLight = magentaLight.clone(recursive: true) greenLight.light.color = .green lightPosition = pointOnCircumference(angle: Angle2D(degrees: 120), radius: placementRadius, y: 0.5) greenLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity) lightsWrapper.addChild(greenLight) let cyanLight = magentaLight.clone(recursive: true) cyanLight.light.color = .cyan lightPosition = pointOnCircumference(angle: Angle2D(degrees: 240), radius: placementRadius, y: 0.5) cyanLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity) lightsWrapper.addChild(cyanLight) lightsWrapper.components[RevolvingComponent.self] = RevolvingComponent(speed: -0.2, relativeTo: rootEntity) rootEntity.addChild(lightsWrapper) } /// Repurpose some planters to make a counter and stocks with a premium aged oil, and a friendly host private func addCounterToTheClub() { guard let planter = rootEntity.findEntity(named: "planter_big"), let dirt = rootEntity.findEntity(named: "dirt_big") else { logger.error("Making the counter failed... too much dancing may now cause rust 🤖") return } // Group into a container entity let counter = Entity() counter.name = "Counter" counter.position = [0.333, 0.05, -0.09] rootEntity.addChild(counter) // Repurpose existing assets let counterGeometry = Entity() counterGeometry.name = "Counter Geometry" counterGeometry.addChild(planter, preservingWorldTransform: true) counterGeometry.addChild(dirt, preservingWorldTransform: true) counterGeometry.scale = [2, 6, 2] counterGeometry.position = [-0.3335, -0.15, 0.09] counter.addChild(counterGeometry) var counterTopMaterial = PhysicallyBasedMaterial() counterTopMaterial.baseColor = .init(tint: .white) counterTopMaterial.roughness = .init(floatLiteral: 0) counterTopMaterial.metallic = .init(floatLiteral: 1) dirt.components[ModelComponent.self]?.materials = [counterTopMaterial] dirt.position += [0, 0.001, 0] // Add a fancy hover rail if let rim = rootEntity.findEntity(named: "bottom_rim_1") { let hoverRailing = rim.clone(recursive: true) hoverRailing.name = "Hover Railing" hoverRailing.position = [0, 0.1, 0] hoverRailing.scale = rim.scale(relativeTo: rootEntity) * 0.5 hoverRailing.components[HoverComponent.self] = HoverComponent(from: hoverRailing.position, to: hoverRailing.position + [0, -0.03, 0]) counter.addChild(hoverRailing) } // Add some bottles to the counter let bottles = stockBottles(placementRadius: 0.045) counter.addChild(bottles) // Hide any out of stock items for bottle in bottles.children { bottle.isEnabled = bottle.components[OutOfStockComponent.self] == nil } // Add a friendly host addHostToTheCounter(counter) } /// Adds 9 green bottles of the finest aged oil to the counter (assuming we have them in stock) private func stockBottles(placementRadius: Float) -> Entity { let bottleRadius: Float = 0.003 let bottleHeight: Float = 0.022 let angleIncrement: Float = -12 let outOfStockBrands: Set = [3] // Make a wrapper entity let bottleGroup = Entity() bottleGroup.name = "Bottle Group" bottleGroup.position = [0, 0.04, 0] bottleGroup.orientation = .init(angle: 180 * (.pi / 180), axis: [0, 1, 0]) // Make a nice green material var bottleMaterial = PhysicallyBasedMaterial() bottleMaterial.baseColor = .init(tint: .green) bottleMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5)) // A simple cylinder mesh let bottleMesh = MeshResource.generateCylinder(height: bottleHeight, radius: bottleRadius) // Error 1: Content occluded let bottle1 = Entity() bottle1.name = "BT1" bottle1.position = pointOnCircumference(angle: .zero, radius: placementRadius, y: -0.03) bottle1.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle1) // Error 2: Content clipped let bottle2 = Entity() bottle2.name = "BT2" bottle2.position = pointOnCircumference(angle: Angle2D(degrees: angleIncrement), radius: 1.6, y: bottleHeight / 2) bottle2.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle2) // Error 3: Content inside out let bottle3 = Entity() bottle3.name = "BT3" bottle3.position = pointOnCircumference(angle: Angle2D(degrees: 2 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle3.scale = .init(repeating: 650) bottle3.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle3) // Error 4: Content not enabled let bottle4 = Entity() bottle4.name = "BT4" bottle4.position = pointOnCircumference(angle: Angle2D(degrees: 3 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle4.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottle4.components[OutOfStockComponent.self] = OutOfStockComponent() bottleGroup.addChild(bottle4) // Error 5: Content not anchored let bottle5 = Entity() bottle5.name = "BT5" bottle5.position = pointOnCircumference(angle: Angle2D(degrees: 4 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle5.components[AnchoringComponent.self] = AnchoringComponent(.plane(.horizontal, classification: .table, minimumBounds: .zero)) bottle5.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle5) // Error 6: Content missing a mesh let bottle6 = Entity() bottle6.name = "BT6" bottle6.position = pointOnCircumference(angle: Angle2D(degrees: 5 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle5.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle6) // Error 7: Content's material misconfigured let bottle7 = Entity() bottle7.name = "BT7" bottle7.position = pointOnCircumference(angle: Angle2D(degrees: 6 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) var simplifiedBottleMaterial = UnlitMaterial(color: .green.withAlphaComponent(0.5)) simplifiedBottleMaterial.opacityThreshold = 1 bottle7.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [simplifiedBottleMaterial]) bottleGroup.addChild(bottle7) // Error 8: Content has a broken mesh let alternativeMesh = MeshResource.generateAbnormalCylinder(height: bottleHeight, radius: bottleRadius) let bottle8 = Entity() bottle8.name = "BT8" bottle8.position = pointOnCircumference(angle: Angle2D(degrees: 7 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle8.scale = [bottle8.scale.x, bottle8.scale.y, -bottle8.scale.z] bottleMaterial.opacityThreshold = 0 bottle8.components[ModelComponent.self] = ModelComponent(mesh: alternativeMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle8) // Error 9: Content not added to the scene hierarchy let bottle9 = Entity() bottle9.name = "BT9" bottle9.position = pointOnCircumference(angle: Angle2D(degrees: 8 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle9.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle8) // FIXME: Bottles are missing from the counter return bottleGroup } /// Add a host robot to the counter private func addHostToTheCounter(_ counter: Entity) { // Make a clone of our hero BOTanist let robotMaker = AppState() guard let skeleton = robotMaker.robotData.meshes[.body]?.findEntity(named: "rig_grp") as? ModelEntity else { fatalError() } // But use the hover body to best complement the counter robotMaker.setMesh(part: .body, name: "body3") guard let head = robotMaker.robotData.meshes[.head]?.clone(recursive: true), let body = robotMaker.robotData.meshes[.body]?.clone(recursive: true), let backpack = robotMaker.robotData.meshes[.backpack]?.clone(recursive: true) else { fatalError() } let robotCharacter = RobotCharacter( head: head, body: body, backpack: backpack, appState: robotMaker, headOffset: skeleton.pins["head"]?.position, backpackOffset: skeleton.pins["backpack"]?.position ) // Remove the character controller and animation state, as we'll manually control these AnimationState.handlers.removeAll() robotCharacter.characterParent.components[CharacterControllerComponent.self] = nil // Take off that heavy backpack backpack.removeFromParent() // Setup our host using the character and add it to the counter let host = Entity() host.name = "Host" host.orientation = .init(angle: 300 * (.pi / 180), axis: [0, 1, 0]) host.position = [0, 0.005, 0] host.components[AutomatonControl.self] = AutomatonControl(character: robotCharacter) host.addChild(robotCharacter.characterParent) counter.addChild(host) // Have them say Hi robotCharacter.transitionToAndPlayAnimation(.wave) // Save a reference so they can wave later when other bots enter self.host = host } /// Generates a disco ball looking entity, makes it revolve and hover, and adds it to the club private func addDiscoBallToTheClub() { // Add the top level revolving, hovering disco ball entity let discoBall = Entity() discoBall.name = "Disco Ball" discoBall.position = [-0.305, 0.17, 0.02] discoBall.components[RevolvingComponent.self] = RevolvingComponent(speed: -0.02, relativeTo: rootEntity) discoBall.components[HoverComponent.self] = HoverComponent(from: discoBall.position, to: discoBall.position + [0, 0.02, 0]) rootEntity.addChild(discoBall) // Add a support beam to hold the disco ball var supportMaterial = PhysicallyBasedMaterial() supportMaterial.baseColor = .init(tint: .lightGray) supportMaterial.roughness = .init(floatLiteral: 0.8) supportMaterial.metallic = .init(floatLiteral: 0.8) let support = ModelEntity(mesh: .generateCylinder(height: 0.01, radius: 0.01), materials: [supportMaterial]) support.scale = [0.2, 1.8, 0.2] support.position = [0, 0.05, 0] support.name = "Support" discoBall.addChild(support) // Add the shiny ball that is the base of our disco ball var backgroundMaterial = PhysicallyBasedMaterial() backgroundMaterial.baseColor = .init(tint: .lightGray) backgroundMaterial.roughness = .init(floatLiteral: 0) backgroundMaterial.metallic = .init(floatLiteral: 1) let background = ModelEntity(mesh: .generateSphere(radius: 0.05), materials: [backgroundMaterial]) background.name = "Background" // FIXME: Unintentionally inheriting an ancestor's transformation support.addChild(background) // Add some detailed lines on top of the background var lineMaterial = PhysicallyBasedMaterial() lineMaterial.baseColor = .init(tint: .lightGray) lineMaterial.sheen = .init(tint: .lightGray) lineMaterial.emissiveColor = .init(color: .lightGray) lineMaterial.emissiveIntensity = 1 lineMaterial.triangleFillMode = .lines let ballOutline = ModelEntity(mesh: .generateSphere(radius: 0.0505), materials: [lineMaterial]) ballOutline.name = "Outline" background.addChild(ballOutline) } /// Marks the club as ready private func openDoors() { var management = self.doorSupervisor management.doorsOpen = true self.doorSupervisor = management } /// finds a point along the edge of a circle on an XZ-plane, given a radius and y value. Optionally applies some variance. private func pointOnCircumference(angle: Angle2D, radius: Float, variation: Float = 0, y: Float = 0) -> SIMD3<Float> { .init( x: (Float(cos(angle)) * radius) + .random(in: -variation...variation), y: y, z: (Float(sin(angle)) * radius) + .random(in: -variation...variation) ) } } // MARK: Club Management /// Manages club capacity and ready state struct DoorSupervisor: Component { let capacity: Int var doorsOpen = false var visitorCount = 0 var hasCapacity: Bool { visitorCount < capacity } } /// Tag to indicate if a retail item is in stock struct OutOfStockComponent: Component {} // MARK: Revolution Control /// Works with the RevolvingSystem to apply a continuous rotation to an entity struct RevolvingComponent: Component { var speed: Float var angle: Float var axis: SIMD3<Float> var relativeTo: Entity? init(speed: Float = 0.05, initialAngle: Float = 0, axis: SIMD3<Float> = [0, 1, 0], relativeTo: Entity? = nil) { self.speed = speed self.angle = initialAngle self.axis = axis self.relativeTo = relativeTo } } /// Works with the RevolvingComponent to apply a continuous rotation to an entity @MainActor class RevolvingSystem: System { private static let query = EntityQuery(where: .has(RevolvingComponent.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { if var revolvingComponent = entity.components[RevolvingComponent.self] { let relativeTo = revolvingComponent.relativeTo revolvingComponent.angle += .pi * Float(context.deltaTime) * revolvingComponent.speed entity.setOrientation(.init(angle: revolvingComponent.angle, axis: revolvingComponent.axis), relativeTo: relativeTo) entity.components[RevolvingComponent.self] = revolvingComponent } } } } // MARK: Hover Control /// Works with the HoverSystem to apply a continuous levitation like bounce to an entity struct HoverComponent: Component { var speed: Float var angle: Float var from: SIMD3<Float> var to: SIMD3<Float> init(speed: Float = 0.06, angle: Float = 0, from: SIMD3<Float>, to: SIMD3<Float>) { self.speed = speed self.angle = angle self.from = from self.to = to } } /// Works with the HoverComponent to apply a continuous levitation like bounce to an entity @MainActor class HoverSystem: System { private static let query = EntityQuery(where: .has(HoverComponent.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { if var hoverComponent = entity.components[HoverComponent.self] { hoverComponent.angle += .pi * Float(context.deltaTime) * hoverComponent.speed let range = hoverComponent.to - hoverComponent.from let proportion = (sin(hoverComponent.angle) + 1) / 2 entity.position = hoverComponent.from + (proportion * range) entity.components[HoverComponent.self] = hoverComponent } } } } // MARK: Robot Parts /// A wrapper around a Robot Character that is actually used as an Automaton struct AutomatonControl: Component { var character: RobotCharacter } extension RobotCharacter { /// manually control the animation transition of a single robot instance func transitionToAndPlayAnimation(_ animationState: AnimationState) { if self.animationState.transition(to: animationState) { playAnimation(animationState) } } } /// A collection of shuffled robot names for our Automatons @MainActor enum RobotNames { static var count: Int = 0 static var next: String { count += 1 return "Robo-v\(count)" } } // MARK: Teleportation /// Works with the TeleportationSystem to control spawning across all teleporters struct ControlCenterComponent: Component { typealias SpawnHandler = (Entity) -> Void var initialValue: TimeInterval var interval: TimeInterval var countdown: TimeInterval var rootEntity: Entity var _spawnHandler: SpawnHandler init(initialValue: TimeInterval, interval: TimeInterval, rootEntity: Entity, spawnHandler: @escaping SpawnHandler) { self.initialValue = initialValue self.interval = interval self.countdown = initialValue self.rootEntity = rootEntity self._spawnHandler = spawnHandler } } /// Represents a single Teleporter in the TeleportationSystem struct TeleporterComponent: Component {} /// Works with the ControlCenterComponent to control spawning across all teleporters @MainActor class TeleportationSystem: System { private static let controlCenterQuery = EntityQuery(where: .has(ControlCenterComponent.self)) private static let teleporterQuery = EntityQuery(where: .has(TeleporterComponent.self)) private static let robotQuery = EntityQuery(where: .has(AutomatonControl.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.controlCenterQuery, updatingSystemWhen: .rendering) { update(controlCenter: entity, context: context) } } private func safeToUse(teleporter: Entity, context: SceneUpdateContext) -> Bool { let someBotIsStandingToClose = context.entities(matching: Self.robotQuery, updatingSystemWhen: .rendering) .contains { entity in distance(entity.position(relativeTo: nil), teleporter.position(relativeTo: nil)) < 0.02 } return !someBotIsStandingToClose } private func update(controlCenter controlCenterEntity: Entity, context: SceneUpdateContext) { guard var controlCenter = controlCenterEntity.components[ControlCenterComponent.self], let clubManager = controlCenter.rootEntity.components[DoorSupervisor.self], clubManager.hasCapacity else { return } // 1. Decrease countdown, and activate if it reaches zero controlCenter.countdown -= context.deltaTime if controlCenter.countdown <= 0 { // 2. Find all the active teleporters and pick a random one if let teleporter = context.entities(matching: Self.teleporterQuery, updatingSystemWhen: .rendering).shuffled().first { // 3. If no other robots are in the way, pass it to the designated spawn method if safeToUse(teleporter: teleporter, context: context) { controlCenter._spawnHandler(teleporter) } } // 4. Set the delay till the next spawn event controlCenter.countdown = controlCenter.interval } // FIXME: Control Center is not being updated } } extension ParticleEmitterComponent.Presets { /// Makes a particle emitter component that looks like a teleporter fileprivate static var teleporter: ParticleEmitterComponent { var particleEmitter = ParticleEmitterComponent.Presets.rain particleEmitter.birthLocation = .surface particleEmitter.emitterShape = .torus particleEmitter.particlesInheritTransform = false particleEmitter.fieldSimulationSpace = .global particleEmitter.speed = 0.07 particleEmitter.speedVariation = 0.03 particleEmitter.radialAmount = 360 particleEmitter.torusInnerRadius = 0.001 particleEmitter.emissionDirection = [0, 1, 0] particleEmitter.spawnedEmitter = nil particleEmitter.burstCount = 5000 particleEmitter.mainEmitter.opacityCurve = .linearFadeOut particleEmitter.mainEmitter.birthRate = 50 particleEmitter.mainEmitter.birthRateVariation = 10 particleEmitter.mainEmitter.lifeSpan = 0.5 particleEmitter.mainEmitter.lifeSpanVariation = 0.01 particleEmitter.mainEmitter.size = 0.001 particleEmitter.mainEmitter.sizeVariation = 0.0005 particleEmitter.mainEmitter.sizeMultiplierAtEndOfLifespan = 0.01 particleEmitter.mainEmitter.stretchFactor = 10 particleEmitter.mainEmitter.noiseStrength = 0 particleEmitter.mainEmitter.spreadingAngle = 0 particleEmitter.mainEmitter.angle = 0 particleEmitter.spawnedEmitter = nil return particleEmitter } } // MARK: Dancing /// Represents a single Attractor in the DanceMotivationSystem struct AttractorComponent: Component { enum State { case vacant case attracting case motivating } private(set) var state: State = .vacant var target: Entity? var walkSpeed: Float = 0.1 var interval: TimeInterval = 5 var countdown: TimeInterval = 5 var club: Entity? var isVacant: Bool { if case .vacant = state { return true } return false } mutating func setTarget(_ target: Entity) { self.target = target self.state = .attracting } mutating func targetReached() { self.state = .motivating } } /// Represents a single Robot in the DanceMotivationSystem struct Newcomer: Component {} /// Works with the DanceMotivationSystem to provide additional Debug information to the RealityKit Debugger struct DanceSystemDebugComponent: Component { var states: UIImage? = nil var vacant: Int = 0 var attracting: Int = 0 var motivating: Int = 0 } /// Provides additional Debug information about a single Attractor in the DanceMotivationSystem to the RealityKit Debugger struct AttractorDebugComponent: Component { var state: AttractorComponent.State var attractor: Entity var robot: Entity? } /// Manages the states of dance floor attractors, the movement of robots and the relationships between them @MainActor class DanceMotivationSystem: System { private static let attractorQuery = EntityQuery(where: .has(AttractorComponent.self)) private static let targetQuery = EntityQuery(where: .has(Newcomer.self)) private static let clubbersQuery = EntityQuery(where: .has(AutomatonControl.self)) private static let debugRootQuery = EntityQuery(where: .has(DanceSystemDebugComponent.self)) private static let debugVisualizationsQuery = EntityQuery(where: .has(AttractorDebugComponent.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { // 1. Check for newcomers at the club who could be enticed to come and dance for visitor in context.entities(matching: Self.targetQuery, updatingSystemWhen: .rendering) { // 2. Randomly pick an attractor guard let attractor = context.entities(matching: Self.attractorQuery, updatingSystemWhen: .rendering) .filter({ $0.components[AttractorComponent.self]?.isVacant ?? false }) .randomElement() else { return } // 3. Start attracting the visitor var attractorComponent = attractor.components[AttractorComponent.self]! attractorComponent.setTarget(visitor) attractor.components[AttractorComponent.self] = attractorComponent // FIXME: Stop attractors competing over the same bot } // Let the attractors do their thing and attract visitors to come and dance for attractor in context.entities(matching: Self.attractorQuery, updatingSystemWhen: .rendering) { guard var attractorComponent = attractor.components[AttractorComponent.self] else { continue } switch attractorComponent.state { case .attracting: if let updatedAttractorComponent = attractRobot(attractor: attractor, deltaTime: Float(context.deltaTime)) { attractorComponent = updatedAttractorComponent } case .motivating: if let updatedAttractorComponent = motivateRobot(attractor: attractor, context: context) { attractorComponent = updatedAttractorComponent } default: break } // save changes attractor.components[AttractorComponent.self] = attractorComponent } #if DEBUG updateDebugInfo(context: context) #endif } private func attractRobot(attractor: Entity, deltaTime: Float) -> AttractorComponent? { guard var attractorComponent = attractor.components[AttractorComponent.self], case .attracting = attractorComponent.state, let target = attractorComponent.target, let robotCharacter = target.components[AutomatonControl.self]?.character else { return nil } // robots wave when they first arrive, make sure that is completed first before moving var transitionAnimationTo: AnimationState? switch robotCharacter.animationState { case .wave: transitionAnimationTo = .idle case .idle: transitionAnimationTo = .walkLoop case .walkLoop: transitionAnimationTo = nil default: return attractorComponent } if let transitionAnimationTo { if robotCharacter.animationState.transition(to: transitionAnimationTo) { robotCharacter.playAnimation(robotCharacter.animationState) } } // Convert the robot and target positions into the same coordinate system let targetPosition = target.position(relativeTo: attractorComponent.club) var danceSpotPosition = attractor.position(relativeTo: attractorComponent.club) danceSpotPosition.y = targetPosition.y let movementVector = danceSpotPosition - targetPosition let normalizedMovement = movementVector / length(movementVector) let move = normalizedMovement * deltaTime * attractorComponent.walkSpeed target.setPosition(targetPosition + move, relativeTo: attractorComponent.club) robotCharacter.characterModel.look(at: robotCharacter.characterModel.position - normalizedMovement, from: robotCharacter.characterModel.position, relativeTo: robotCharacter.characterParent) // If the target is more or less in position then attach to the dance spot and change state to motivating if distance(danceSpotPosition, target.position(relativeTo: attractorComponent.club)) < 0.005 { attractor.addChild(target, preservingWorldTransform: true) // Start Dancing robotCharacter.transitionToAndPlayAnimation(.celebrate) // Update attractor state attractorComponent.targetReached() } return attractorComponent } private func motivateRobot(attractor: Entity, context: SceneUpdateContext) -> AttractorComponent? { guard var attractorComponent = attractor.components[AttractorComponent.self], case .motivating = attractorComponent.state, let target = attractorComponent.target, let robotCharacter = target.components[AutomatonControl.self]?.character else { return nil } attractorComponent.countdown -= context.deltaTime if attractorComponent.countdown <= 0 { // Turn to face a random fellow clubber if let friend = Array(context.entities(matching: Self.clubbersQuery, updatingSystemWhen: .rendering)).randomElement() { let friendsPosition = friend.position(relativeTo: robotCharacter.characterParent) robotCharacter.characterModel.look(at: friendsPosition, from: robotCharacter.characterModel.position, relativeTo: robotCharacter.characterParent) // TODO: remove me print("🔥 friendsPosition \(friendsPosition) targetPosition \(robotCharacter.characterModel.position)") } attractorComponent.countdown = attractorComponent.interval } return attractorComponent } #if DEBUG let vacantColor = UnlitMaterial.BaseColor(tint: .yellow.withAlphaComponent(0.5)) let attractingColor = UnlitMaterial.BaseColor(tint: .orange.withAlphaComponent(0.5)) let motivatingColor = UnlitMaterial.BaseColor(tint: .red.withAlphaComponent(0.5)) private func updateDebugInfo(context: SceneUpdateContext) { var vacantCount: Int = 0 var attractingCount: Int = 0 var motivatingCount: Int = 0 context.entities(matching: Self.debugVisualizationsQuery, updatingSystemWhen: .rendering).forEach { visualization in guard let visualizationComponent = visualization.components[AttractorDebugComponent.self], let attractorComponent = visualizationComponent.attractor.components[AttractorComponent.self] else { return } updateVisualizationEntity(visualization, relativeTo: attractorComponent.club) switch attractorComponent.state { case .vacant: vacantCount += 1 case .attracting: attractingCount += 1 case .motivating: motivatingCount += 1 } } context.entities(matching: Self.debugRootQuery, updatingSystemWhen: .rendering).forEach { debugRoot in if var debugComponent = debugRoot.components[DanceSystemDebugComponent.self] { debugComponent.vacant = vacantCount debugComponent.attracting = attractingCount debugComponent.motivating = motivatingCount debugComponent.states = makeChart(vacantCount: vacantCount, attractingCount: attractingCount, motivatingCount: motivatingCount) debugRoot.components[DanceSystemDebugComponent.self] = debugComponent } } } private func updateVisualizationEntity(_ visualization: Entity, relativeTo root: Entity?) { guard var visualizationComponent = visualization.components[AttractorDebugComponent.self], let attractorComponent = visualizationComponent.attractor.components[AttractorComponent.self] else { return } // Update the position var position = visualizationComponent.attractor.position(relativeTo: root) position.y = visualization.position.y visualization.setPosition(position, relativeTo: root) // Update the state visualizationComponent.state = attractorComponent.state visualization.name = "[Debug] \(visualizationComponent.attractor.name) (\(attractorComponent.state))" // Update the base material color to signify the attractor state if var modelComponent = visualization.components[ModelComponent.self], var material = modelComponent.materials.first as? UnlitMaterial { switch attractorComponent.state { case .vacant: material.color = vacantColor case .attracting: material.color = attractingColor case .motivating: material.color = motivatingColor } modelComponent.materials = [material] visualization.components[ModelComponent.self] = modelComponent } // Update the target visualizationComponent.robot = attractorComponent.target visualization.components[AttractorDebugComponent.self] = visualizationComponent } private func makeChart(vacantCount: Int, attractingCount: Int, motivatingCount: Int) -> UIImage? { ImageRenderer(content: chartView(vacantCount: vacantCount, attractingCount: attractingCount, motivatingCount: motivatingCount)).uiImage } private func chartView(vacantCount: Int, attractingCount: Int, motivatingCount: Int) -> some View { Chart( [ (name: "Vacant", count: vacantCount), (name: "Attracting", count: attractingCount), (name: "Motivating", count: motivatingCount) ], id: \.name) { name, count in SectorMark( angle: .value("Value", count), angularInset: 1.5 ) .cornerRadius(5) .foregroundStyle(by: .value("Name", name)) } .chartLegend(.hidden) .chartForegroundStyleScale(["Vacant": .yellow, "Attracting": .orange, "Motivating": .red]) .frame(width: 1024, height: 1024) } #endif } // MARK: Debug Helpers extension Entity { /// creates an semi-transparent entity that can be useful in debug invisible entities in the RealityKit Debugger static func makeDebugMarker(name: String? = nil, height: Float, radius: Float, color: UIColor = .white, enabled: Bool = false) -> Entity? { #if DEBUG var debugMaterial = UnlitMaterial() debugMaterial.color = .init(tint: color) debugMaterial.blending = .transparent(opacity: 0.7) let marker = ModelEntity(mesh: .generateCylinder(height: height, radius: radius), materials: [debugMaterial]) if let name { marker.name = name } marker.isEnabled = enabled return marker #else return nil #endif } /// adds an semi-transparent child entity that can be useful in debug invisible entities in the RealityKit Debugger @discardableResult func addDebugMarker(name: String? = nil, height: Float? = nil, radius: Float? = nil, color: UIColor = .white, enabled: Bool = false) -> Entity? { #if DEBUG var markerRadius: Float if radius != nil { markerRadius = radius! } else { // If no provided radius then calculate from the visual bounds let extents = visualBounds(relativeTo: nil).extents let boundingXZRadius = max(extents.x, extents.z) / 2 if boundingXZRadius.isNormal { markerRadius = boundingXZRadius } else { // If no visual bounds then use a default radius of 1cm markerRadius = 0.01 * scale(relativeTo: nil).max() } } // If no provided height then use a default value of 10cm let markerHeight = height ?? 0.1 * scale(relativeTo: nil).max() let name = name ?? "[Debug] \(self.name)" if let marker = Entity.makeDebugMarker(name: name, height: markerHeight, radius: markerRadius, color: color, enabled: enabled) { marker.position = [0, markerHeight / 2, 0] addChild(marker) return marker } #endif return nil } } // MARK: Demo Helpers extension MeshResource { /// Generates an cylinder with all the normals facing downwards. Probably has no uses other than demo'ing a broken mesh. static func generateAbnormalCylinder(height: Float, radius: Float) -> MeshResource { let meshResource = MeshResource.generateCylinder(height: height, radius: radius) var contents = meshResource.contents let models = contents.models.map { model in var model = model let parts = model.parts.map { part in var part = part part.normals = part.normals.map { normals in let transformedNormals: [SIMD3<Float>] = normals.map { _ in [0, -1, 0] } return MeshBuffer(transformedNormals) } return part } model.parts = MeshPartCollection(parts) return model } contents.models = MeshModelCollection(models) try? meshResource.replace(with: contents) return meshResource } }
-
3:02 - Add a volumetric club scene
WindowGroup(id: "RobotClub") { GeometryReader3D { geometry in ClubView() .volumeBaseplateVisibility(.visible) .environment(appState) .scaleEffect(geometry.size.width / initialVolumeSize.width) } .onAppear { dismissWindow(id: "RobotCreation") } } .windowStyle(.volumetric) .defaultWorldScaling(.dynamic) .defaultSize(initialVolumeSize)
-
3:09 - Add a button to open the club
VStack { Button("🪩") { openWindow(id: "RobotClub") } .padding() Spacer() } .padding([.trailing, .top])
-
6:50 - FIX: Unintentionally inheriting an ancestor's transformation
discoBall.addChild(background)
-
10:18 - FIX: Control Center is not being updated
// 5. Save updated component back to the entity controlCenterEntity.components[ControlCenterComponent.self] = controlCenter
-
18:15 - FIX: Stocking bottles
private func stockBottles(placementRadius: Float) -> Entity { let bottleRadius: Float = 0.003 let bottleHeight: Float = 0.022 let angleIncrement: Float = -12 let outOfStockBrands: Set = [3] // Make a wrapper entity let bottleGroup = Entity() bottleGroup.name = "Bottle Group" bottleGroup.position = [0, 0.04, 0] bottleGroup.orientation = .init(angle: 180 * (.pi / 180), axis: [0, 1, 0]) // Make a nice green material var bottleMaterial = PhysicallyBasedMaterial() bottleMaterial.baseColor = .init(tint: .green) bottleMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5)) for i in 0..<9 { let angle = Angle2D(degrees: angleIncrement * Float(i)) let bottleMesh = MeshResource.generateCylinder(height: bottleHeight, radius: bottleRadius) let bottle = ModelEntity(mesh: bottleMesh, materials: [bottleMaterial]) bottle.name = "BT\(i)" bottle.position = pointOnCircumference(angle: angle, radius: placementRadius, y: bottleHeight / 2) if outOfStockBrands.contains(i) { bottle.components[OutOfStockComponent.self] = OutOfStockComponent() } bottleGroup.addChild(bottle) } return bottleGroup }
-
22:48 - FIX: Attractors
// 4. Untag them as a Newcomer visitor.components[Newcomer.self] = nil
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。