hyoromoのブログ

最近はVRSNS向けに作ったものについて書いています

NeosVRでMMC22向けに作った「Gravity of 4Seasons」ワールドの制作工程と実装内容の紹介


NeosVR内のMMC22イベント向けに作ったゲームワールドをどう作っていったか、について書きます。

注意

NeosVRを初めて1.5から2.5ヶ月の時に制作した & 一人でのワールド制作は初めてなので、知識が乏しく間違った事を書いているかもしれません。もし間違っている箇所があれば教えて貰えると助かります。

MMC22とは

02/02-03/02の一ヶ月間で物を作るNeosVRで開かれたコンテストです。詳細は「ネオスサーチ!」で。
www.neossearch.com

どういった物が作られているかはTwitterハッシュタグを眺めて見るとなんとなく分かります。
twitter.com

制作物

以下の "Worlds/[MMC22] Gravity of 4Seasons" フォルダ内に2-3日に1度バックアップ目的で保存したWorldがあります。
neosrec:///U-hyoromo/R-cc07d3ec-8d71-4a9b-a6a8-c79995fb8d0e

制作期間の話

1週間目

最初の1週間はプロトタイプ開発をしていました。今回の目玉である重力制御は今まで試した事が無かったので、そもそも頭の中で思い描いた物が作れるかの実験。

2週間目

ステージ1(Spring)制作とチュートリアルを作ってJP/EN/KRの3言語対応し、たまにワールドをパブリック状態にして各言語の方にチュートリアル読んでプレイ頂いていました。
テストプレイヤーが迷った箇所をメモし、チュートリアルの表現を変えたりゴールまでの道筋をどう誘導させるかの指針を決定していきました。最終的に矢印を設置したり、ゴールにパーティクル出したり、どういったルートを進んでも遠回りになるけどゴール出来るようにしたり...とか。

3週間目

今まではキャラクタの重力方向制御だけを行っていましたが、重力方向を変えたまま乗り物に乗りたかったのでCharacter Parenterを利用し始めました。本当はステージの全面に入れて何処でも乗れるようにしたかった...のですがデバッグ含めると期間的に対応が難しくて断念。黒面のみ乗れるという仕様にしています。

4週間目

テストプレイヤーを募りながら冬ステージ制作と全ステージのバランス調整をしていました。

NeosVRならではな開発

他のVRSNSと比較して個人的なNeosVRで一番良いポイントは「いつでも他人がJoin出来る環境でワールド制作が出来る*1」事です。
VRChatはUnityを使ってローカルで作っていき、人に見せたい時は一旦アップして来てもらう。clusterのワールドクラフトはフレンド招待制*2

フレンドじゃなく見ず知らずの人も来れる環境なので、全く自分の事を知らない人のプレイ感想を作っている最中にその場で聞けます。
いろんな方が来られるので率直な感想、VR酔いポイントのチェック、伝わらない箇所、ローカライズしたテキストが意味不明...とか色々と考えさせられ学びが多かったです。

実装

Redprint v1.4β と RedprintManager v0.6β を使ってLogiXを組んでいます。コメント等は該当バージョン以上でアンパックしなければ表示されないかもしれません。それぞれUkilopさんのpublic folderから入手ください。
neosrec:///U-ukilop/R-75dcb1f3-678b-4b1d-81f9-2297b173a574

始めにそもそもDynamic VariablesとDynamic Impulsesを知らないとLogiXで何やっているか分からないと思うため、前提知識として軽く書きます。

前提知識1 (Dynamic Variables)

変数管理を DynamicVariables で行っています。
DynamicVariableSpaceコンポーネントをアタッチしたSlot以下に追加したDynamicValueVariable(値)/DynamicReferenceVariable(参照)コンポーネントを、同じSpace内に存在するLogiXからDynamicVariableInput/WriteDynamicVariableノードで読み込み/書き込みが行えます。

この説明だと意味分からないと思うのでrheniumさんが書かれている詳しい記事を参照ください。
DynamicVariableを始めてみる - Qiita

前提知識2 (Dynamic Impulses)

DynamicImpulsesDynamicImpulseTriggerノードでパルスを送信し、DynamicImpulseReceiverで受信するシステムです。
Triggerはコンポーネント側にも存在して、Buttonコンポーネントと同じSlotにButtonActionTriggerを付けるとButton Eventsノードを使わずLogiX側へパルスが流れます。なので、LogiX側からTriggerを見つけられなかった時はButtonコンポーネントから辿ってみてください。

スロット構成


他の方の書き方を意識していますが、独自な書き方もあるので1例程度で捉えてください。
それぞれの階層にモデル/UIやLogiX、DynamicVariables(DV)の設定があります。OderOffsetでInspector上での表示順を変更可能なため、DVは5、LogiXは6...みたいに開いた階層に該当Slotがあるか分かりやすいルール付けをしました。
Titleはスタートボタン処理、Mainはステージプレイ中の処理、Resultはゲームオーバー/クリア時の処理。それ以外はシステム周りとなっています。

ローカル処理


分かりやすい所はプレイ開始した時にMain/PlayerPrefを複製し、Main/Players/ユーザー名Slotを作成してステージ上の処理を各ローカルでのみ動作するようにしています。

主な対応方法はReferenceUserOverrideコンポーネントをCreateOverrideOnWriteをTrue、PersistentOverridesをFalseにして使っています。
CreateOverrideOnWriteをTrueにして使うと、Targetに設定した値を更新(Write等)した時に更新者ごとに値を個別に持てるようになります。なので、ユーザーごとに見え方の違いが出せます*3

これを使って言語やBGM音量、ワールドのステージ解禁情報なども個別管理しています。

重力制御


RaycastOneでユーザーの足元から真下方向へRayを飛ばし、Colliderに当たった時の向きを元にSetCharacterGravityで重力方向を切り替えています。

工夫したポイントはRaycastOneで取得出来る地面までの距離(HitDistance)を元に、地面から距離が離れるほど重力変更を弱めるようLerpで調整しています。
なぜこれをしたかと言うと、例えば飛び石のような場所をジャンプで飛び越える際。飛び先までの対空時間中に足元の先に別モデルがあるとジャンプ中に急な姿勢変更が行われてしまいます。それだと違和感あったので調整を入れました。
該当コードは上図のようにグチャグチャですが、Main/PlayerPref/Logix -Local/MainLoopで書いています。

ステージのアニメーション


Panner1D/ValueGradientDriverコンポーネントでアニメーションさせています。
近くにあるもので同じ速度でアニメーションさせたい時はPanner1DのValueValueMultiDriverでそれぞれ設定しているValueGradientDriverへDrive。
ステージ上でアニメーションしているSlot自身か親Slotのコンポーネントにあると思います。

ローカライズ


おそらく正規の対応方法はLocalizationカテゴリにあるコンポーネントを使うのだと思いますが、よく分からなかったので別手段を取りました。
セッションへホストが入って来たら呼ばれるノードが無さそうだったため、Updateを回してパルスが走ったらDynamicTriggerでLocalizeManager/LogiXへパルスを流し、自身は非アクティブになるようにしています。


Receiver側ではCurrentCultureでユーザーの言語を取得してJP/KRかチェックし、該当しなければENを表示するよう切り替えています。
DataPresetValueコンポーネントに登録しておいたテキストをDataPresetコンポーネントがActive化される事でテキストを流し込んでいます。このとき、そのままだと誰かが言語切り替えたら他ユーザーにも適用されてしまうため、前述のReferenceUserOverrideコンポーネントでユーザー毎に表示言語を管理します。

負荷対策

負荷対策はこちらを参考にしつつ可能な範囲で対応しています。

LOD Groupコンポーネントを使ってプレイヤーと対象モデルとの距離に応じ、表示するモデルを切り替える対応をしています。
Rendererの有無をローカルで切り替える機能なため、レンダリングコストが高いもの*4のみを対象に設定しています。
小さいものは距離が離れると消す、大きいものはローポリを別途用意して切り替わるよう設定しています。

それとゲームスタート時に他ステージで行っているアニメーション(Pannerコンポーネント)をローカルで停止させています。
これは効果あったかDebug表示で見てもイマイチよく分かりませんでした。見た目は停止しているのにMoveAssetsの数字は減ってなさそうだったので効果無かったのかも...

細かいモデルはまとめてbakeしています。広範囲でbakeすると視錐台カリングの恩恵を受けにくくなるのでほどほどにしています。
オクルージョンカリングはNeosにあるかどうか分からなかったので何もしていません。

ステージのモデリング

99%くらいNeos内のMesh組み合わせと凸包を使って作っています。赤い矢印のみ違います。
あまりBakeしていないので何が何のMeshを使っているか分かると思います。
Box形状の箇所は上動画のようにDevToolTipのSnap機能で作っています。

移動方法の制限

ゲーム開始時に歩行を強制するためにSwitchLocomotionModuleノードで歩行モードへ強制切り替えしています。
あとプレイ中はGetActiveLocomotionModuleノードでLocomotionを監視し、歩行以外に切り替わったら強制的に歩行へ戻しています。ここはワールド側で制御出来るっぽいため、そちらで行うほうがユーザーフレンドリーかと思います*5

プレイヤーのスケール制限

プレイヤーのステージ開始時にプレイヤーをサイズをSetUserScaleノードを使って小さくしています。
プレイ中にスケール値が変更されると強制リスポンするようにもしています。

ワールド保存時のゴミ掃除

WorldSavedノードを使うとワールド保存時にパスルが流れるので、そちらにワールドにあるゴミを消す処理を掛けば消えた状態で保存されます。
通常はワールド保存したくないSlotはPersistentをFalseにすれば問題ないのですが、スコアボードのようにプレイ毎に毎回排出される & ユーザーがそれをインベントリに保存するものをPersistent Falseに設定するとインベントリから取り出せない状態*6となります。なので、そういった時に使うのに都合良かったです。

謝辞

ワールド作りきれたのは色んな事を親切に教えてくれる方々や、期間中にワールドへ来てテストプレイしに来てくれた方々のお陰です。
どうもありがとうございました!

*1:ワールドの設定で変えられるためプライベートやフレンドのみにも変更可能

*2:MMC22期間中に来たからエアプなので間違っているかも

*3:Neos EssentialにあるミラーON/OFFみたいな感じ

*4:例えばポリゴン数の高いキューブとか

*5:ワールド側の制御は終盤に知ったので今回は使いませんでした

*6:サムネイル+空データの保存扱い