HN 日本語サマリー

← 一覧へ戻る
プログラミング

Cubacadabraの背後にある魔法

The Magic Behind Cubacadabra (andrewarrow.dev)

11 pointsby andrewfromx4 コメント

要約

この記事は、Cubacadabraというプロジェクトが、複数のプラットフォーム(iOS、Android、Web)で共通のロジックをRustで共有するアプローチについて説明しています。これにより、コードの重複を避け、メンテナンス性を向上させ、一貫したユーザー体験を提供することを目指しています。ネイティブUIは各プラットフォームに最適化しつつ、アプリケーションのコアな意思決定ロジックはRustに集約することで、開発効率と品質の向上を図っています。

全文翻訳

Cubacadabra™の裏にある魔法

01 / コードを消し去る

abracadabra(アブラカダブラ)がシルクハットの上で唱える呪文になる前は、首に巻くお守りでした。古代ローマのLiber Medicinalis(クイントゥス・セレーヌス・サムモニクス著)には、熱病から身を守るお守りとして、その言葉を繰り返し書き、毎回一文字ずつ削除して身につける処方が記されています。問題を小さくする、非常に文字通りのアプローチです。

iOS、Android、Web版の何かをリリースしたことがあるなら、いつものルーチンはご存知でしょう。Swiftで機能をビルドします。Kotlinで再度ビルドします。JavaScriptでさらにビルドします。各バージョンには独自のバリデーション、リクエスト、ローディング状態、エラーハンドリングがあります。それらはすべて同じバックエンドと通信するため、私たちはそれらを1つの製品の3つのクライアントと呼びます。それでも、製品の多くを3回書きました。

そして機能が変更されます。iOSは保存の失敗から回復する方法を学びます。Androidは1週間後に修正を受け取ります。ブラウザは同じエラーを異なる方法で処理します。誰も3つの異なる動作を設計しようとしたわけではありません。それらは、皆が通常の開発を行っている間に自然に蓄積されたものです。

私はCubacadabraのクライアントがRustの薄いシェルになることを望んでいます。ネイティブコントロール、デバイスサービス、ブラウザ統合に必要な最小限のSwift、Kotlin、JavaScriptを維持します。ポータブルなアプリケーションロジックは、共有Rustクレートに移動します。これには、退屈なアカウント画面からゲームエンジンまでが含まれます。DRY(Don't Repeat Yourself - 繰り返しを避ける)は、アプリケーションが行う決定に適用されるべきです。

ユーザー名フィールドは、引き続きSwiftUIのテキストフィールド、Composeのテキストフィールド、またはHTMLのinput要素になります。それぞれが編集内容をRustに転送し、結果の状態を表示します。Rustが保存ボタンを有効にするかどうか、どのリクエストを行うか、その応答が何を意味するかを決定します。ルールを変更すれば、実装は1つだけ変更されます。3つの画面は、それぞれのプラットフォームに属しているように見せ続けることができます。

良い先例はあります。Litterは、セッション状態、ストリーミング、再接続の動作を管理するRustコアの上に、UniFFIバインディングを通じて公開されるネイティブiOSおよびAndroidインターフェースを持っています。Mozillaの共有Rustコンポーネントは、Firefoxのデスクトップおよびモバイルアプリの同期実装を別々に維持することから生まれました。その理由は、重複したロジックの維持が困難であり、実装間の違いがバグを引き起こしたため、という、聞き覚えのあるものになるでしょう。

02 / これらすべてが保存ボタンの価値があるか?

私はこの問題を長いアーキテクチャ議論とiOS開発ノートで検討しました。経験豊富なモバイル開発者は、なぜユーザー名フィールドにRust、C ABI、JNI、WASMが必要なのかと疑問に思うかもしれません。個別のネイティブ実装は、それぞれのIDEでデバッグするのが容易です。バインディングはビルド作業とライフタイムのバグを追加します。数個のフォームしかない小さなアプリであれば、重複の方が安価かもしれません。

Cubacadabraにとっては、共有コアの価値は十分にあると確信しています。RustはすでにエンジンとデスクトップStudioを実行しており、ブラウザはWASMを通じてそれを実行しています。プラットフォームが成長するにつれて、ゲームへの参加には、コンテンツの互換性、ペアレンタルコントロール、サブスクリプションアクセス、ブロックされたプレイヤー、そして途中で接続が切れた際の復旧などが関わってきます。私はそれらのインタラクションを一度だけ解決したいのです。新しいルールが3つの実装間の調整問題になることを望んでいません。

この投資は成果を出し始めています。iOS統合コミットの1つは、106行を追加し269行を削除して、ユーザー名画面とそのブリッジを単純化しました。最初のWeb統合は、インフラストラクチャを必要としたため成長しました。将来の機能は、その仕組みを再利用するはずです。テストは、それらの複雑な動作が1か所に存在し、ホストアダプターが小さく保たれているかどうかです。

03 / 保存ボタンには意見がある

1つの実装が、巨大な1つのクレートを意味するわけではありません。cubacadabra-clientはエンジン側のマルチプレイヤーセッションを所有します。cubacadabra-appはゲームプレイ外のポータブルな動作を所有します。エンジンはシミュレーションとレンダリングを所有します。ユーザー名の保存は、3つのコンシューマークライアントがすべて使用できるアプリクレートに属します。

例えば、「Dragon_7」と入力して保存し、応答が来る前にサインアウトしたとします。古い応答が、より新しい下書きを上書きしたり、次にサインインした人を更新したりする可能性があります。それはまさに、一度だけ修正したい種類のルールです。

ホストはUsernameChangedとSaveUsernameをディスパッチします。Rustは下書きを検証し、ID、アカウントID、メソッド、パス、ボディを持つHTTPエフェクトを発行します。ホストは認証情報を提供し、リクエストを実行し、その生のステータスとボディを返します。Rustは、画面がプロフィールを更新する前に、応答を受け入れるか拒否します。HTTPエグゼキューターはネイティブのままで、リクエストの意味は共有されたままです。

セッションの置き換えは、保留中の作業を無効にします。エフェクトIDはモデル内で再利用されないため、遅延した応答が新しいアカウントの操作を完了することはできません。保存中の編集は、より新しい下書きを保持します。これらの決定には、単一の実装と共有契約ケースのセットができました。

私はそれらのインタラクションを一度だけ解決したいのです。

アバターの保存、カタログのページネーション、楽観的なブロック/アンブロックは、現在同じパターンに従っています。報告はホスト所有のままです。電話では、AppViewModelがGameViewModelの隣に配置されているため、ゲームの再起動がアカウントの保存をキャンセルすることはありません。ランタイム契約は、その境界とそのテストを文書化しています。デバイス統合はまだ確認が必要です。製品ルールは、画面とは独立して実行できます。

04 / そのポインタは誰が所有しているのか?

私たちのバインディングはLitterのものとは異なります。Cubacadabraは現在、モバイルでは小さなCインターフェースを、ブラウザではwasm-bindgenを使用しています。アプリクレートはSerdeとserde_jsonを必要としますが、レンダラーや非同期HTTPランタイムは必要としません。これにより、ゲームを開始せずに共有アプリケーションの動作を使用できます。

ネイティブアプリABIは7つの関数です:作成、破棄、JSONのディスパッチ、スナップショットJSONの取得、エフェクトJSONのポーリング、および出力ポインタと長さの読み取り。ハンドルはシングルスレッドです。出力はRustが所有し、次のミューテーションの前にコピーする必要があります。SwiftはそれをDataにコピーします。Androidには小さなJNIバイト配列アダプターがあります。どちらもスナップショットプロトコルのバージョンを確認します。互換性のないバインディングは、静かに第2のアカウントルールを復活させるのではなく、失敗します。

[画像: プレイヤーが明るいゲームの世界にいるCubacadabraがiPhone 17で実行されている]

iPhone / ネイティブクライアントホストはデバイスサーフェスを提供し、共有ランタイムは世界、コントロール、プレイヤーの状態を提供します。

シリアライゼーションとコピーがあります。アカウント編集やカタログページについては、契約を読むことができるのは良いことです。Studioは通常のRustメソッドと列挙型を通じてゲームクライアントを呼び出します。ネイティブゲームクライアントは、既存の入力およびレンダリングAPIのために借用されたエンジンポインタを公開します。クライアントを破棄すると、それが無効になります。Rustの所有権ルールは、次の呼び出し元がCである場合に、正直な説明を必要とします。

ブラウザには、もう1つの簡単な間違いが待っています。WebClientとWebRendererは同じ生成されたWASMモジュールから来るため、エンジンハンドルは両方の側で同じ線形メモリを参照します。同じソースからコンパイルされた2つのモジュールは、ポインタを相互に交換可能にはしません。アプリランタイムは別のWASMモジュールであり、これによりアカウントページはレンダラーをロードせずにユーザー名を編集できます。共有するエンジンポインタはありません。

05 / 同じものを2人が触った

クライアントの抽出は、別の種類の繰り返しを削除しました。各ホストは、ソケットメッセージの翻訳、リモートプレイヤーの維持、ワールド変更のルーティング、Luauネットワークアウトボックスの排出を行っていました。ブラウザの「crate 2nd」コミットでは、244行が削除され74行が追加されました。これは1つのホストの差分であり、そのアダプターが含まれています。有用な部分は、修正するセッション実装が1つであることです。

[画像: Cubacadabra Studioテストワークスペース。アクティブなプレイヤーセッションが1つと、3つのセッションプレビューパネルが表示されている]

Studio / テストワークスペースマルチプレイヤーサーフェスは、「クライアントが1つ動作する」と「セッションが動作する」の間のギャップを無視できないものにします。

ホストはソケットテキストをClientSession::receive_textに渡し、エンジンをステップ実行する前後にアクションをポーリングします。RustはSetWorldとSendTextを発行します。ホストは引き続き認証、再接続バックオフ、移動送信スロットリングを所有します。起動はエンジンの論理ワールドを「アリーナ」として保持しながら、ソケットを「arena:7」にルーティングできます。RustはリモートIDと世代も追跡し、無視されたアカウントをフィルタリングし、変更時にバージョン付きの担当者リストを送信します。

ここで、2人の友人が同じリレーチェックポイントにいるとします。両方ともシーケンス7を読み取ります。両方ともラウンドを進めようとします。JavaScriptバックエンドは、ワールドインスタンスとしてCloudflare Durable Objectを使用しており、その保持状態APIは、クライアントが観測したシーケンスとの比較・設定を受け入れます。1つの更新がシーケンスを進めます。もう1つは、競合フラグ付きで現在の状態を返されます。

[画像: Cubacadabraのプラットフォーマーゲームで、カラフルな峡谷のチェックポイントに立つサリー]

Signal Run / チェックポイント共有セッションは、2人のプレイヤーが同時に同じ状態に達した場合でも、ワールドを動かし続けます。

共有Luau SDKは保留中のインテントを保持し、それらをその応答に基づいてリベースし、再試行します。Signal Runでは、リデューサーは誰かがすでにそのノードをキャプチャしている場合、nilを返します。重複したインテントは消える可能性があります。保持チャネルは新しい到着者にも送信され、サーバーから派生したageMsにより、電話が思っている時間を信じることなく、短いタイマーを再開できます。

これは協調的な状態を調整するのに十分です。プレイヤーがスコアを獲得したことを確立するものではありません。クライアントは引き続きペイロードを提案し、バックエンドはゲームのリデューサーを理解しません。ネットワーク契約はその制限について明示的です。競争的なゲームにはサーバーサイドのルール検証が必要になります。2つの主張を正しく順序付けることが、どちらの主張も真実にするわけではありません。

06 / ドラゴンを描く部分

レンダリングはwgpuを経由します。iOSではMetal、AndroidではVulkan/GLESバックエンドを設定し、ブラウザではWebGPU/WebGLパスを使用します。これにより、多くのレンダラーコードが共有されます。サーフェスのライフタイム、入力、オーディオ再生、オペレーティングシステムによる中断は、依然として各ホストに属します。Androidサーフェスが消えることは、エンジンがアプリケーション全体ではないことを思い出させるかなり効果的なリマインダーです。

[画像: プレイヤーが遠くにいる、明るいCubacadabraゲームの世界に立つサリーのアバター]

共有レンダラー / ライブクライアントキャラクター中心のランタイムは、ブラウザ、電話、またはデスクトップツール内で表示できます。

キャラクターレンダラーは、インデックス付きメッシュカタログを使用し、メッシュとマテリアルごとにインスタンスをバッチ処理します。投影された高さを使用して3つのLODから選択し、180ピクセルと70ピクセルに公称境界があります。インスタンスレイアウトは128バイトです。固定の15ジョイント階層がキャラクターを駆動します。これらはコードで見つけられ、誰かの頭に別の装飾的なものを追加する前に推論できる具体的な制約です。

[画像: プレイヤーがSignal Runレベルに立つiPad Airで実行されているCubacadabra]

iPad / ネイティブホストレンダラーは共有されています。デバイスは依然としてゲームに独自のサーフェス、コントロール、および中断を提供します。

そこには、驚くほど具体的なソリューションがいくつかあります。キャラクターのフード付きスリーブは、頂点シェーダーで肘の周りに曲がり、法線行の余分なレーンに軸角データを運びます。他の衣服は剛体アタッチメントを使用します。髪は、キャップが頭にくっついたままで、バウンドスプリングモーションを持っています。これは、誰かが服をバラバラにすることなく手を振らせるという、一見単純な要求の裏にある仕事の種類です。

キャラクターバジェットには、50のレンダラー専用キャラクターと32 MiBのメッシュ/インスタンスバッファが含まれています。これらは実装上の制限であり、すべての電話が特定のフレームレートを維持するという主張ではありません。ネットワーク担当者リストには、独自の小さな制限があります。パフォーマンスの約束にする前に、物理デバイスのフレームタイムを確認したいです。

07 / 帽子には248個の三角形がある

これでシルクハットに戻ります。モーフィング作業ログには、Cylinderという名前のノードと248個の三角形を持つBlenderエクスポートがあります。バリデーターはそれを拒否しました。完全に理解可能な帽子ですが、サイドカーが必要とする3つの異なるLODノードが欠けていました。これは、ファイルを開くことができたという緑色のチェックマークよりも、はるかに有用な初期結果です。

[画像: Cubacadabra Studioモーフィングインスペクタ。248個の三角形があり、LODマッピングが欠落しているトップハットGLBが表示されている]

Studio / モーフィングプレビューバリデーターは、不完全なアセットをデバイスに到達する前に判読可能にすることができます。

既存のキャラクターシステムには、Rustの列挙型でエンコードされたボディとアウトフィットがあり、レンダラーがそれらの組み合わせを準備します。これによりプロトタイプが動きます。帽子を追加するためにエンジンのコードを変更する必要がある場合や、独立したヘアスタイルと衣服が組み合わせを増やす場合、それは厄介になります。アーティストが2つ目の帽子を追加するのを、コンテンツの変更で済ませたいです。

新しいcubacadabra-morphsクレートは、ID、カタログスキーマ、ロードアウト、互換性決定を所有します。そのリゾルバーは、サポートされているベース、リグの互換性、一般的なフィットプロファイル、占有スロット、競合、およびmesh.rigid.v1のような必要な機能を確認します。決定論的な診断を生成します。StudioはGPUを初期化せずにそれを使用できます。ランタイムの採用はまだ初期段階です。既存の外観とレンダリングパスはすべて移行されていません。

Studioオーサリングクレートはソース境界を扱います。GLBエクスポートには、読み取り可能な.morph.jsonサイドカーがあり、アセット、ジオメトリパス、名前付きニア/ミッド/ファーノード、三角形数、および「head」のようなセマンティックジョイントへの剛体アタッチメントを宣言します。アタッチメント変換は、正規化されたクォータニオンを含めてチェックされます。宣言されたカウントはnear >= mid >= far > 0を満たす必要があります。1つのノード名を3つのスロットすべてにコピーすると、検証に失敗します。

GLBインスペクタは、glTFマジック、バージョン2、宣言されたファイル長、チャンク境界をチェックします。JSONノード、メッシュ、アクセサカウントを読み取り、導出された三角形数をサイドカーと比較します。その計算のために三角形、ストリップ、ファンを理解します。ライブラリは、64 MiBを超えるソースと256 KiBを超えるサイドカーを拒否します。診断はコードとフィールドパスを識別するため、アーティストは「インポート失敗」よりも有用なものを持っています。

今日、morph_validateをサイドカーとGLBに対して実行できます。まだ頂点バッファのデコードやランタイムパックのコンパイルを停止する前です。StudioのMorphsタブには、検索可能なカタログとインスペクタがあります。インポート、再インポート、および完全なレンダリングされたオーサリングループは未完成です。メタデータチェックは、メッシュが正しく描画されることを証明するものではありません。

計画されたパスは、Blenderエクスポート、Studioでの検証とコンパイル、そして共有レンダラーが消費する制限付き.morphpackです。インポート機械はStudioに残ります。電話はランタイムアセットを受け取ります。既存の機能を使用する新しいジオメトリはデータであるべきです。新しい変形またはマテリアル動作は、エンジンの作業を必要とする可能性があります。私が関心のあるマイルストーンは、Rustソースファイルを開かずにその2つ目の帽子を追加することです。

08 / ゲームのためにスペースを残す

同じ考え方はゲーム自体にも当てはまります。ソースプロジェクトにはmanifest.json、src/main.luau、およびアセットがあります。Pythonツールは明示的なインクルードを展開し、ポータブルパッケージを組み立てます。Studioはメモリ内で生のプロジェクトを組み立て、生成されたファイルをソースディレクトリに書き戻さずに実行することもできます。

[画像: プレイヤーとツリーアセットが選択された森のシーンが表示されているCubacadabra Studioワールドワークスペース]

Studio / ワールドワークスペースオーサリングサーフェスは、プロジェクトが場所になる場所です:シーン、マテリアル、ランドマーク、そして手がかり。

[画像: キャンプファイヤーとビーコンの隣にある、草の生い茂ったStormline Outpostゲームの世界を探索するサリー]

Stormline Outpost / プレイ中ランタイムが配管を所有すると、ゲームは天候、リソース、奇妙な小さな物語にエネルギーを費やすことができます。

ネイティブビルドは、mluaとバンドルされたLuauを通じてLuauを実行します。WASMは純粋なRustのluaur-rtランタイムを使用します。それらは同じゲームAPIを公開しますが、2つのランタイム実装は依然としてパリティチェックに値します。ゲームHUDもLuauに配置でき、Rustはセーフエリアレイアウト、ヒットテスト、wgpuオーバーレイを行います。アカウント画面はネイティブシェルに残ります。

Signal Runのリデューサーや、ビーコン燃料とラジオ供給を備えたStormline Outpostの例を読んでください。それらのルールはゲームに属します。誰かが嵐を変更したり、リレーをばかげたものにしたり、ドラゴンに帽子をかぶせたりするのを、3つのクライアントパッチとバックエンドリリースを調整することなく行いたいのです。

Cubacadabraが成長するにつれて、複雑さが、理解して一緒にテストできる共有コードに蓄積されることを望んでいます。新しいプラットフォームには、シェルが必要であり、私たちが製品の実行について学んだことすべてを再実装する必要はありません。だからこそ、私はロジックをRustに移行し続けています。古いお守りは文字を削除して、ほとんど何も残らなくなるまで続きました。Swift、Kotlin、JavaScriptだけが実際に異なる必要があるレベルまで到達できれば、私は満足です。