はじめに
めっちゃ流行った某塗り絵かくれんぼゲームをVRでも遊びたいと思い、VRChatで再現してみることにした。
完成品はこちら。
テクスチャを自由にペイントし、それを他のプレイヤーに同期するというのはVRChatのUdon帯域制限下ではなかなか野心的なプロジェクトである。
試行錯誤の結果なんとか形になったので過程と知見を共有しようと思う。
この解説は既にある程度UdonSharpを使い込んでいて基本的な同期の仕方を理解している人向けです。
前提条件
やりたいこととしては以下の3点がゲームの肝になる。
- 人形に筆でタッチすることで色を塗ることができる
- 色、筆の太さ、人形のポーズが任意に変更できる
- 塗られた人形の見た目がリアルタイムに他プレイヤーに同期される
UdonではRenderTextureとVRCGraphics.Blitが使えるので、「テクスチャの狙った位置に好きな色を付ける」という部分はわりとどうにかなる。問題は、それを他のプレイヤーにどーやって同期するかだ。

一番最初のプロトタイプ。同期以前に狙った位置を塗るのにも意外と四苦八苦した
試行1:画像圧縮
最初は256×256のRGBテクスチャを丸ごと送信することを考えた。無圧縮状態では196kBちょっとあるが、適当に圧縮すれば十分小さくなるかな~と期待した。
しかし塗った内容が複雑になるとすぐに破綻してしまった。色違いで何回か塗りを入れるとすぐに数十kBに達してしまい、いつまで経っても同期されなくなってしまう。
Udonの送信帯域はインスタンス全体で11kB/s、シリアライズ上限は約280kBとなっている。
シリアライズ上限に収まってはいるが、圧縮して50kBまで減らしても、1つの人形の同期が終わるまでに約5秒かかる。10人いれば50秒。同期周期がほとんど分単位とあっては、塗りが同期される前にハンターに見つかってしまう。
いくつかの圧縮アルゴリズムを変えながら試したが根本的な解決にはならず、このアプローチはよくないと結論した。
試行2:画像分割送信
上限を超えてでかいパケットを送受信できる有名アセットがあるな~と思い参考にしてみることにした。
VRChatの重要インフラの一つ、QvPenである。
QvPenの中身を見たことがない人向けにざっくり解説すると、まず1本の線の書き始めから終わりまでを1ストロークとし、ストロークの束をローカルな変数に保存しておく。そしてそれを一度に全部送信するのではなく、ある程度のサイズのパケットに区切って少しずつ送信する。
同期変数はそれらのストロークの束のインデックスのようなものとして持ち、線が足された時と誰かJoinした時を契機に送信するのだ。
線がたくさん描かれたインスタンスにJoinすると、QvPenの線が少しずつ現れてくる様子を見たことがあると思う。あれはまさに、一度に全部ではなく少しずつ送って少しずつ再現しているからあのように見えているのだ。
この仕組みを応用して、画像を小さな単位に分解して、差分のある所だけ送信するようにしてはどうかと考えた。
最初はある程度成功をおさめて、3人くらいまでなら比較的スムーズに同期するようになった。
しかしやはり全体の情報量のデカさが問題になった。特に塗り始めは人形全体を一度塗り潰すように描くことが多く、せっかく分解したパケットを結局全部送ることになる様子が観測された。これでは意味がない。
人数が増えればすぐに破綻するのが自明になったのでこのアプローチはここで断念した。
試行3:ドット記録+分割送信
画像全体を送るとどうしても無理が出てくるので、QvPenのように1ブロックがごく小さいリストでペイント内容を表現できないかと考えた。
ペンの軌跡が点のリストなら、筆の軌跡はストロークの位置と太さの組でリストに変換できないだろうか?
ストローク全体で「線」にするのを諦めて、一定間隔でドットを打つようにして描く、そしてその1点1点を1ブロックにまとめることを考える。
座標XYZ+太さをfloat×4にまとめて16byte、
色はRGBAを1byteずつ使って4byte。
これで1ドット当たり計20byteとなる。
Alphaは使わないので1byte余るが、これは後でSmooth/Metallicに使えたらいいなと思い敢えて残した。結局初版ではUIを詰め切れなくて実装しなかったが。
それでもこの圧縮によりスペック上の11kB/sを履歴全体で超えるのに564ドット必要となり、通常のプレイではかなり通信量を節約できるはずだ。
QvPenとの違いは、リスト全体で順序をキープしなければならないということだ。
QvPenで線を描く順番が前後しても最終的な見た目は変わらないが、ペイントでは後から塗った色が一番上になってくれないと困る。
検討の結果、受信者側に自身の「適用済み件数」を持たせ、不足や不整合を検知して再送リクエストを送る機能を追加することで解決した。
受信者は履歴に抜けを見つけるとそこで適用を止め、Ownerに再送リクエストを出す。Ownerは全履歴を送り直すが、受信者は手持ちの分を重複として読み飛ばし、足りない末尾だけを追記する。
| 持ち物 | 持つ人 | 役割 |
|---|---|---|
| ドットのログ | 全員 | 全履歴。再描画・再送・Owner交代の根拠 |
| 送信済みカーソル | Owner | どこまで確実に送ったか。送信成功を確認してから進める |
| 適用済み件数 | 受信者 | 自分が途切れなく持っている履歴の長さ |
| 世代番号(clearGen) | 全員 | 塗り直し(クリア)のたびに増える履歴のID |
同期変数の構造は以下のようになった。
バイト サイズ 型 フィールド
┌─ ヘッダ (12B) ─────────────────────────────────────
│ 0– 3 4B int32 開始番号 (このパケットの先頭ドットが、履歴の何番目にあたるか)
│ 4– 7 4B int32 収録件数 (このパケットに入っているドットの数)
│ 8– 9 2B uint16 世代番号 (どの盤面の履歴かを示すID。クリアのたびに+1される)
│ 10 1B byte パケット種別(新規送信/再送信開始/再送データ/再送信終了/クリア)
│ 11 1B byte (未使用・常に 0)
├─ ドット #0 (20B) ────────────────────────────────
│ 12–15 4B float32 位置 X
│ 16–19 4B float32 位置 Y
│ 20–23 4B float32 位置 Z
│ 24–27 4B float32 筆の太さ半径
│ 28 1B byte 色 R
│ 29 1B byte 色 G
│ 30 1B byte 色 B
│ 31 1B byte 色 A (常に 255)
├─ ドット #1 (20B) ────────────────────────────────
│ 32–51 …以降、「収録件数」の数だけ 20B ずつ続く
└────────────────────────────────────────────────────
全長 = 12 + 20 × 収録件数 (バイト)
位置が3次元あるのは、UVではなくメッシュのローカル座標を指定しているためだ。これはUV同士が離れているシーム位置を塗る仕組み上必要だったのでこのようにしている。
パケット種別は通常の差分送信とリクエストを受けて送信した再送を区別できるように入れている。
また、塗り潰し機能をゲームリセット時のクリアと統合し、「履歴をリセットして新しい色から始める」という形にした。全部を塗り潰した後はそれ以前の色が一切残らないためである。
受信側の処理に注目するとこのような感じ。
このような方法で分割同期を行うことでようやくそこそこ安定して同期ができるようになった。
同期抜け対策
そこそこ安定はしたが、まだ時々不一致になることがあって、しかも発生条件がよくわからない。
調査の結果、人形がカリングされていたり、動きがないまま放置されていると同期の優先度のようなものが下がって同期のコールバック類が正常に発火しないような振る舞いをすることがわかった。
正確な発生条件は結局よくわかっていないが、一定間隔で小さい虚無リクエストを送り続ける、いわゆるHeartbeatを送ることでなんか起きなくなった。
Canny案件な気もするが正確な再現手段を確立できていないのでなんとも……。
また、OnDeserialization()の中でペイント処理を全部やるのも良くなかった。
Udonの原則に従えば全部OnDeserialization()でやりたいところだが、受信パケットは正しいのにRenderTexture上で塗りが正しく再現されない問題が起きていて、塗り処理を1フレームずつ分けて実行するようにしたら直った。
1フレーム以内に大量にBlitを呼びまくるのが良くないのかもしれない。
これも正確な発生条件を特定できていない。誰かなんか知ってたら教えてください。
まとめ
やっぱりQvPenってすげー!
謝辞
ワールド開発に当たってはNiAkaちゃんのAgent Skills for VRChat UdonSharpを大いに活用させていただきました。
もうこれなしではギミック開発したくないかも!
モチベーション維持にはわこりんこと夢猫わこー先生の配信に大いに助けられました。
めっちゃかわいいし頑張って塗ると褒めてくれるので最高です。
みんなフォローしよう。
そしてもちろんめっちゃ面白い例のゲームに最大の経緯と称賛を贈ります。
神ゲーなのでみんな買いましょう。
ヨドコロちゃんは当ワールドを通じて何らかの収益を得ることはありません!パクリだから!


