technical

【VRChat】自作ワールド作りました! いろいろメモ(作業手順、UdonSharp、軽量化など)

こんにちは、ユーリです。最近VR端末のMeta Quest2を買いまして、VRにはまってます!
特にVRChatが楽しくてアバター作ったり色々してたのですが、ついにワールドも作りました。大変だったところなどをまとめました。


VRChatの紹介(軽く)
ワールドの土台作成
家具と小物の配置
UdonSharp製ギミックをつくる
ギミックの同期問題
仕上げ(軽量化とライトベイク)

VRChatの紹介(軽く)


VRChatとは、バーチャル空間上でアバターを着て他の方とおしゃべりできるツールです!

アバターは配布されているものもありますが、自作の3Dモデルをアップできるので非常に自由度が高く、人型はもちろんケモノでも触手でもなんにでもなれちゃうのがすごいところ。
楽しむために頭にかぶるようなVR機器が必要だと勘違いされがちですが、デスクトップPCでもモニタに映してプレイできます。(ただし没入感は得にくいです)

自分オリジナルの場所として「ワールド」を作って思い通りの景色に降り立つこともできるし、スクリプトを書いてギミックを置いたりもできます。
ゲームというよりソーシャルプラットフォームに近いものですが、VRならではの臨場感からあたかもその場にいるような感覚があり、好きな見た目になって好きなところへ行けるが魅力。
近い未来、バーチャル空間で暮らせるようになったらこんな感じなのかも。ギミックが作り込まれているワールドではゲームっぽい遊びもできちゃう!

ちなみにわたしはこのアバターをつかってます。

VRoidStudioというアバター制作ツール(無料)と、モデリングソフトのBlender(無料)による小物追加で自作しました!
わたしは3Dモデリングは得意なほうではありませんが、今は便利なツールがたくさんあるので専門的な技術がなくても比較的とっつきやすかったです! もしVRで見かけたらお気軽に絡んでください。

自作ワールド紹介

そして今回わたしが自作したワールドはこちら!「Cafe & Bar 宵星」です。


後述しますが、弓矢を使って夜空に星を打ち上げ、星座を作って遊べるギミックがあります。これがこのワールドの目玉!


たくさん星を打ち上げればカラフルになってにぎやかです。


カフェスペースではスイーツが食べ放題です。くつろげるスペースも置いてみました。


Barコーナーはいい感じのシーリングファンライトがまわってます。影で雰囲気をだしてる。


最近話題の画像生成AIでつくったアートを飾ったりもしてます。こういうのを配置するだけでもなんとなく見栄えがよくなるから助かる。


ご興味がある方はVRChatのアカウントがあればこちらのリンクからワールドを開けますので是非あそんでみてください!QUESTも対応してます。

以下、ワールド作成にあたり行った作業を紹介していきます。作業開始から2週間くらいでできたけど、めっちゃ楽しくて仕事と食事以外ずっとワールド作ってました。全作業合計で40~50時間くらいやった気がする。
※あくまでもわたしなりのやり方ですので参考までに!

ワールドの土台作成


Blenderで床や柱をつくる

今回のワールドはおうち+庭的な構成にするつもりでしたが、細かいレイアウトはぜんぜん考えてなかったので作りながら雰囲気で組み立てました。
床と柱部分を作ってからUnity上で配置しながら作る方向で進めます。
まずBlenderでただの立方体を作り…、


ネット上で拾ってきたフリー素材のテクスチャを貼ります。(テクスチャの貼り方はググってください)そして縦サイズを縮めて平べったくつくれば「床」の完成です。
誰がなんと言おうと床タイルです。いいですね?

同様に細長い立方体にテクスチャを貼れば柱の完成です。

柱なんです。いいですね? 完成です。
本当にただの立方体で頂点が8つしかないモデリングとも呼べないシロモノですが、テクスチャを貼れば見た目がそれっぽくなるので案外大丈夫です。

ここで大事なのは縦横の寸法を1m x 1m などキリのよい数字で、座標xyzゼロで作ることです。こうすることでUnity上で座標を打って配置する時に隙間なくピッチリ置けるようになります。

中途半端な寸法で作ると配置するときに重なったりうまく繋がらなかったりするのでご注意。

書き出す時は、対象の立体(メッシュ)を選択してからfbx形式でエクスポート。


「選択したオブジェクトのみ」、「メッシュのみ」で書き出します。パーツごとに1つ1つ書き出しましょう。


ついでにちょっと頑張って斜めの床も作ったりしました。ナナメパーツがあるとマイクラ的お豆腐建築から脱出でき、組み合わせバリエーションがグッと増えます。


Unityで配置する

それからUnity側の作業に移ります。VRChatに対応しているバージョンのUnityを入手してください。無料です。
ワールドを作り始める前の一番大事な作業はVRCWorldSDKをインポートすること。
インポートができたらAssets>VRChat Examples>Prefabsの中にあるVRCWorldをシーンに配置しましょう。これを入れることでVRChatのワールドとしてアップロードできるようになります。


SDKなどの基本ファイルをインポートできたらいよいよ制作開始です。
まずお家の柱などを置く前に地面が欲しいので、3DオブジェクトのCubeを作成し基底の床(地面)をつくります。


作ったcubeの縦横の寸法を100倍にすれば平べったい形状になります。もうこの上を立って歩けちゃいます!

真っ白な床のままだと味気ないですが、テクスチャを貼れば地面にも草原にでもサイバーパンクSFフィールドにもなります。テクスチャは万能です。
テクスチャを貼るためにはマテリアルが必要なので、画面下のファイルビューで右クリックし、作成→マテリアルを作りましょう。


次に作ったマテリアルを床にしたいCubeに割り当てます。Cubeを選択すると右インスペクタ内にCubeに対応する「Mesh Renderer」の設定ができます。これがCubeの描画を担当している部分です。

MeshRendererはデフォルトでは真っ白なマテリアルが割り当てられていますが、編集するために新規作成したマテリアルをドラッグドロップして割り当てます。

割り当てたら自由に編集できるようになるのでマテリアルを開き「アルベド」から床の見た目にしたいテクスチャを選択します。するとテクスチャ画像の模様が床に反映されます!

今回は草地にしました。テクスチャ画像についてはフリー素材を探してくるなりお絵かきソフトで作成するなり、AIで生成するなりしてください。

それからBlenderで作成した床をUnityに取り込んでならべる作業です。
Blenderで書き出したfbxファイルをドラッグドロップでUnityのフォルダに放り込んでください。

するとUnityプロジェクト内にモデリングした物体を取り込めるので、今度はそれをワールドに配置していきます。

先程の手順でマテリアルとテクスチャを取り込めば見た目もBlenderで作った通りになります。ここまでくればもう後は楽しい作業しかありません。

床をいい感じに配置して、柱をいい感じに立てます。

ここはサクッとやったように見えますが1~2時間かかってます。

次に柱の間を壁を構成する用の壁も作って、柱の間を壁で埋めたらもうそれっぽい感じがでてきます。

壁部分をblenderで作ってテクスチャをさらに用意して、窓ありverも作って…といった作業でさらに2~3時間かかってます。でも一番たのしいのでやってればいつのまにか時間が過ぎちゃいます。

床と壁ができたら、床部分をコピペして天井を作ればもう屋内化が完了。屋根をつけると真っ暗になるので、建物内に光源を置きましょう。右クリックしてライトを新規作成します。

屋内用ならポイントライトなどがオススメ。
光源を置いたら勝手に雰囲気がでてきます。すごいですね。ここまで来たらもう勝ったようなもんです。



家具と小物の配置


さて、建物のガワと床と壁は自前で用意しましたが、中にある家具や小物は自力で作ろうとすると大変です。
ですが! VRChatのワールドを作りたい需要はけっこうあるためか、Boothなどで探すと無料で使える3Dデータがたくさんあります! 高クオリティで有料のものも多数。
いろいろと置きたい小物を集めて配置しました。めちゃくちゃ良い感じになった!(これだけで10時間とかあっという間に過ぎます)

有料のも一部買いましたが、小物をひとつひとつ作るのにかかる時間を考えたら買ったほうが早いかなって…。金で解決。買ったら置くだけだから。
使わせていただいたものはワールド内のクレジットにぜんぶ書いてあります! ありがとうございます!
家具と小物を置いたらこれは既に「家」じゃん…。という完成度となりました。こうなったらもう勝利は確定したようなもんです。

が、これだけだと来た方はワールド内をひと通り見たら満足して帰っちゃうので、なにか面白いものを置きたいですよね。このような欲がでてきたらもう沼にはまっています。

UdonSharp製ギミックをつくる


VRChatのワールド作成は、コンセプトにもよりますがプログラミングスキルがなくても可能です。景観やデザインや雰囲気で魅せるなら光源やモノの配置センスだけで十分すばらしいものができあがります。
ですが、VRChatではユーザーが作ったプログラムの実行が制限付きながら許されているので、スクリプトが書けると手の込んだ仕掛けをワールドに配置することもできちゃいます!
わたしもプログラミングで食べているSEの端くれ。ここからが腕の見せ所!

ここからはスクリプトを一部紹介。
このワールドのメインの見せ物は、入り口左手に置いてある「星の弓」です。
持ち運べるオブジェクトとしてワールドに配置してあります。

この弓には大まかに5つのギミックが設定されています。
・弓を持つと矢を引けるようになる
・矢を引いて手を離すと矢を生成して飛ばす
・弓に付属のボタンを押すと矢を星として固定する
・固定した星を線で繋いで星座にする
・固定した星を順番に消す
ひとつずつ仕組みを解説します!
弓は持ち手(見た目は透明)の部分、弓の見た目部分、矢を持つ部分の3パーツで構成されており、持ち手←弓←矢の順で位置と回転を追従することで見かけを制御しています。

最初は弓部分と矢の部分の2パーツだけだったのですが、矢を引いている時に弓の回転ができてしまうと系の表示がえらいことになるので、一時的に弓の回転を固定する必要がでてきました。しかしプレイヤーによるPickUpオブジェクトは回転の角度制限ができないため、弓の見た目部分と透明な持ち手部分を分離し、持ち手の回転をContraintで見た目部分に反映する方式にしました。
持ち手→弓見た目のConstraintは矢を引いている時だけスクリプトでOFFにします。すると矢を引いている時だけ弓の見た目部分が回転しなくなります。持ち手部分は実はプレイヤーの手の動きに合わせて回転しているのですが、透明なので見えません。

弓を持つと矢を引けるようになる

持ち手の部分にUdon BehaviourスクリプトとVRC PickUpコントローラーを付与しています。PickUpコンポーネントを付けることでそのオブジェクトは道具のようにユーザーが持って遊んだり操作できるようになります。

また、オブジェクトを持った時に紐づけられたUdon Behaviour側のOnPickUp、OnDropイベントが呼ばれます。
このスクリプトでは弓の持ち手部分を持ったら、矢の部分のPickUpをtrueにして矢を引けるようにしています。また、弓にくっついている制御用ボタンのアクティブ化も行っています。

public override void OnPickup(){ //グリップ部分を掴んだ時の処理
  //矢の部分を掴めるようにする
  ((VRC.SDK3.Components.VRCPickup)myArrow.GetComponent(typeof(VRC.SDK3.Components.VRCPickup))).pickupable = true;
  Networking.SetOwner(Networking.LocalPlayer, this.gameObject); //自分がこの弓のオーナーになる
  Networking.SetOwner(Networking.LocalPlayer, myArrow); //付属の矢もオーナーになる
  Networking.SetOwner(Networking.LocalPlayer, bowMesh); //弓本体見た目部分もオーナーになる
  //各種制御用コントローラーをアクティブに
  controllerUp.SetActive(true);
  controllerRight.SetActive(true);
  controllerLeft.SetActive(true);
}

//手を離した時の処理
public override void OnDrop(){
  //矢の部分を掴み許可を解除
  ((VRC.SDK3.Components.VRCPickup)myArrow.GetComponent(typeof(VRC.SDK3.Components.VRCPickup))).pickupable = false;
  //各種制御用コントローラーを隠す
  controllerUp.SetActive(false);
  controllerRight.SetActive(false);
  controllerLeft.SetActive(false);
}


矢を引いて手を離すと矢を生成して飛ばす

新しいオブジェクトをワールド内に動的に生成するには VRCInstantiate(GameObject) を利用します。
生成した後に位置や回転を渡すのを忘れないようにしましょう、
ソース内でpublic GameObject ~~~ と書くことで、Unity側でスクリプトで直接呼び出せるGameObjectやPrefabを登録できるようになりますので、使うオブジェクトは列挙して紐付けましょう!

矢の生成処理はワールド内にいる全員で行ってもらいので、SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “(関数名)”); で呼び出します。 ついでに最後に打った矢を変数で保持しておきます。

public GameObject starlightArrowPrefab; //発射用の矢のPrefab
private GameObject lastShootingStar; //最後に射った流れ星

//矢から手を離した時の処理
public override void OnDrop()
{
  //ローカルで引き絞り距離を計算
  Vector3 arrow_transform = this.gameObject.transform.localPosition;
  varhikishibori_y = arrow_transform.y;
  if( hikishibori_y > 0.15f ){ //一定以上矢をひいていれば
    SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “Shot”); //矢を射つイベント(全員)
  }else{ //矢のひきしぼりが足りない
    SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “NoShot”); //矢を射たないイベント
  }
}

//矢を打つ
public void Shot()
{
  myAnimator.SetTrigger(“PositionConstraintON”); //矢を元の位置に戻す
 
  GameObject ArrowShoot = VRCInstantiate(starlightArrowPrefab); //Prefabから矢を生成
  ArrowShoot.transform.position = this.gameObject.transform.position; //矢の位置を決める

  var angles = bowMesh.transform.rotation.eulerAngles; //弓部分の角度を得る
  ArrowShoot.transform.rotation = Quaternion.Euler(angles); //角度をセット

  Rigidbody ArrowShootRigidbody = ArrowShoot.GetComponent<Rigidbody>(); //矢の「Rigidbody」を取得
  ArrowShootRigidbody.AddForce(bowMesh.transform.forward * shootPower); //Rigidbodyに前方向の推進力を伝える

  lastShootingStar = ArrowShoot; //最後に射った星として記録
}



弓に付属のボタンを押すと矢を星として固定する

弓に付属のボタンは別オブジェクト&別スクリプトです。このオブジェクトをInteract(使用)するとグリップ側のイベントを呼ぶようにしました。

SendCustomEventで、スクリプト内から別スクリプトの関数を呼び出せます。ただし引数は設定できません。

//星生成ボタン側のスクリプト
public class StarCreate : UdonSharpBehaviour{
  public GameObject myGrip; //グリップ部分
  private UdonBehaviour gripBehaviour; //グリップのうどん

  void Start(){
    gripBehaviour = (UdonBehaviour)myGrip.GetComponent(typeof(UdonBehaviour));
  }
  public override void Interact() { //オブジェクト使用時の処理
    gripBehaviour.SendCustomEvent(“StarCreate”); //グリップ側のイベント発火
  }
}



グリップ側のイベントで前述の発射処理で最後に射った矢を変数で保持しているものを使います。
物理情報はRigidbodyが持っているので、GetComponentから矢のRigidbodyを取得し、useGravityをfalse、isKinematicをtrueにするとその物体は物理演算をやめて停止します。

//持ち手ボタン側のスクリプト
[SerializeField]private int myStarMaxLength = 12; //星を撒ける最大数
[UdonSynced]private int myStarsIndex = 0; //現在扱っている星

public void StarCreate(){
  if( lastShootingStar ){ //最後の矢が存在すれば
    SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “Burst”);
  }
}

//矢を空中で固定し、その場にエフェクトを生成
public void Burst(){
  if( lastShootingStar ){
    Rigidbody lastShootRigidBody = lastShootingStar.GetComponent<Rigidbody>(); //矢のRegidBody取得
    lastShootRigidBody.useGravity = false; //重力無効化
    lastShootRigidBody.isKinematic = true; //その場に固定

    GameObject effect = VRCInstantiate(createEffect); //作成エフェクトを生成
    effect.transform.position = lastShootingStar.transform.position; //エフェクトの位置を決める
    if (stampSE) stampSE.PlayOneShot(stampSE.clip); //効果音を鳴らす

    Destroy(lastShootingStar, 3.0f); //矢は用済みなので3秒後に消す

    myStarsIndex += 1; //星の管理番号を加算
  }
}


固定した星を線で繋いで星座にする

これは見た目上は複雑なことやってるように見えますが、実は簡単です。それぞれの星をLookAtで一つ前の星に向かせて、距離ぶんの大きさの線を生成しているだけです。生成するPrefabの大きさをデフォルトで横幅1にするのがポイントです。

星は12個まで固定できる仕様で、0~11の番号で制御し GameObject配列で管理しています。

//星座をつくる(星座ボタンから呼ばれる)
public void DrawConstellation(){
  SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “NetworkDrawConstellation”);
}
public void NetworkDrawConstellation(){
  //myStarsIndex : 現在空に固定済みの星の数
  for( int index = 0; index < myStarsIndex; index++ ){ //手持ちの星すべて処理 index1から始めるので最初の星は処理しない
    GameObject currentStar = myStars[index];//最新の星
    GameObject backStar = myStars[index-1]; //一つ前の星
    if( currentStar && backStar ){ //最新と前の星がちゃんとあれば
      float distance = Vector3.Distance(currentStar.transform.position,backStar.transform.position); //距離を算出
      GameObject stellaLineSky = VRCInstantiate(stellaLine); //星座の線を生成
      stellaLineSky.transform.position = currentStar.transform.position; //星の位置に移動
      stellaLineSky.transform.LookAt(backStar.transform); //星座の線を一つ前の星へ向ける
      Vector3 localScale = stellaLineSky.transform.localScale; //拡大率(ローカル)
      localScale.z = distance; //距離を拡大率に代入
      stellaLineSky.transform.localScale = localScale; //ローカル拡大率に反映
      stellaLineSky.transform.parent = currentStar.transform; //親を最新の星にする(親の星が消えれば線も消えるようになる)
    }
  }
}



固定した星を順番に消す

こちらはもっと単純で、星を固定した後に加算する管理番号でGameObject配列から消したいオブジェクトを引っ張り出してDestroyしています。消したら管理番号の数も減算します。

//星を消すイベント(別ボタンから呼ばれる)
public void StarDestroy(){
  SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “RefreshLastStar”);
}

//夜空に配置した星をひとつ消す
public void RefreshLastStar(){
  //myStarsIndex : 現在空に固定済みの星の数
  if( myStarsIndex > 0 ){
    var lastStar = myStars[myStarsIndex-1];
    if( lastStar ){
      Destroy(lastStar, 0.0f); //星を即座に消す
 
      GameObjecteffect = VRCInstantiate(destroyEffect); //削除エフェクトを生成
      effect.transform.position = lastStar.transform.position; //エフェクトの位置を決める
      Destroy(effect, 5.0f); //エフェクトは5秒後に消す
      myStarsIndex -= 1; //管理番号をひとつ戻す
    }
  }
}


他にも細かい制御がありますが、大まかにはこんな感じです!

おまけ:矢の衝突判定

矢と星の処理とは直接関係ないのですが、矢がおうちまたはワールド内にあるターゲットに当たると消滅する処理をいれました。
おうちの中でくつろいでいる時に矢が部屋の中に飛んできたら落ち着かないだろうなと思ったためです。
最初は当たったオブジェクトのレイヤーで条件付けを試したのですが、何故かうまくいかなかったため最終的には当たったGameObjectの名前部分一致で処理を発火させるようにしました。

こちらではTargetと名のつくオブジェクトに紐づいたTriggerコライダーに当たった時に処理を行うようにし、おうち型の当たり判定を用意しています。

//矢(Perfab生成)のスクリプト

//入ってはいけないところに矢が入った時またはTargetに当てた時に矢が消滅する
public void OnTriggerEnter(Collider other){ //トリガーに入った時
  triggerTarget(other);
}
public void OnTriggerStay(Collider other){ //トリガーの中にいる時
  triggerTarget(other);
}
public void triggerTarget(Collider other){ //実際の処理
  if( hit ){ return; } //private変数 既に何かに当たってたら処理しない
  if (other != null){ //コライダーのnullチェック(念の為)
    string otherAsString = ((object)other).ToString();
    if (otherAsString.Contains(“VRCPlayer”)){ //他のプレイヤーに当たった時
      //何も演出しないことにした
    }else if(otherAsString.Contains(“Target”)){ //名前にTargetが含まれている
      //自分オブジェクトその場で停止する
      Rigidbody myRigidBody = this.gameObject.GetComponent<Rigidbody>(); //自分のRegidBody取得
      myRigidBody.useGravity = false; //重力オフ
      myRigidBody.isKinematic = true; //その場に固定
      MeshRenderer myRenderer = this.gameObject.GetComponent<MeshRenderer>(); //メッシュレンダラ取得
      myRenderer.enabled = false; //矢の見た目だけオフにする(すぐ消すと軌跡が残らないため)
      Destroy(this.gameObject, 3.0f); //3秒後に破棄
      //エフェクト
      GameObject effect = VRCInstantiate(destroyEffect); //エフェクトを生成
      effect.transform.position = this.gameObject.transform.position; //エフェクトの位置を矢と同じにする
      Destroy(effect, 5.0f); //5秒後に破棄
      hit = true; //ヒットフラグON(2回目は処理しない)
    }
  }
}



おまけ:弓以外のギミック

他、置いてあるスイーツとドリンクを飲み食いできる機能もUdonSharpで組みました。こちらはアニメーターで出し入れなど見かけを作って、UdonSharp側は簡単なフラグ管理とPickUpから取り上げるのと位置リセットのみ行ってます。
こちらはスイーツ食べるやつのソース(全文)

 

using UdonSharp;


using UnityEngine;


using VRC.SDKBase;


using VRC.Udon;

 

public class EatSweets : UdonSharpBehaviour


{


private Vector3 respawnPosition; //リポップする時の位置


private Quaternion respawnRotation; //リポップする時の回転


private float deltaTime = 0.0f; //経過時間


private bool eated = false; //食べました


[SerializeField] private Animator myAnimator; //アニメーター


[SerializeField] private AudioSource eatSE; //食べる音

 


void Start(){


 //初期位置を保管


 respawnPosition = this.gameObject.transform.position;


 respawnRotation = this.gameObject.transform.rotation;


}

 


private void Update()


{


 deltaTime += Time.deltaTime; //経過時間を足す


 if( eated && deltaTime > 1.5f ){ //一定時間たっていれば位置を戻す


  this.gameObject.transform.position = respawnPosition;


  this.gameObject.transform.rotation = respawnRotation;


  eated = false; //食べフラグ初期化


 }


}

 


public override void OnPickupUseDown() { //このオブジェクトを持って使った時


 SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, “Eat”); //ワールド内全員にイベント通知


}

 


//ワールド内全員に処理してもらう


public void Eat(){


 if (eatSE) eatSE.PlayOneShot(eatSE.clip); //食べる音


 myAnimator.SetTrigger(“Eat”); //アニメーター発火(縮小していくやつ)


 deltaTime = 0; //経過時間を初期化


 eated = true; //たべたフラグ

 



 //ピックアップから強制的に手を離す


 VRC_Pickup pickup = (VRC_Pickup)this.gameObject.GetComponent(typeof(VRC_Pickup));


 pickup.Drop();


 }


}


ギミックの同期問題


上記のギミックは一人で動かすぶんには何の問題もなかったのですが、いざ他人をワールドに呼んでテストしてみると色々と不具合が出ることが判明しました…。
ネットワークを介した挙動となると途端に気を遣う部分がたくさん出てきて大変になります!

同期の基本

Udonには便利な「VRC Object Sync」というコンポーネントが用意されています。基本的にはこのコンポーネントを付与しておけば位置や状態が他のプレイヤーと同期されるようになります!

逆に言うと、このコンポーネントを付与していない物体は何も同期しません。基本はローカル動作で、グローバル化したい物には全て「VRC Object Sync」を付けなければなりません。

うまく同期されないケース

これが最大のワナだったのですが、Instantiateで動的に生成したオブジェクトはVRC Object Syncを付与していても同期されません!
つまり、同期を行いたいオブジェクトは最初からワールドに配置した状態にしなければならなかったのです。
これでは矢から星を生成しても位置が完全には合わなくなるし、後からワールドに入ってきた人には生成済みの星が見えなくなります。これでは会話が噛み合わず遊んでいるひとが困っちゃいます。
そこで、やむをえず星として夜空に固定するオブジェクトはあらかじめワールドの下に埋めて隠しておく方式に変更しました。泥臭くてあまり美しくないやり方ですが仕方なし。この都合により星は最大12発しか固定できません。

矢のほうはその場で生成しているので無限に打てます。ワールドに入っている人全員に矢の発射イベントを投げれば弓の現在位置と角度に基づいてだいたい合ってる感じで射出されます!

その他、同期挙動の細かいところ

VRC Object Syncは[Udon synced]を付与すればUdon Behaviourスクリプト内の変数も同期してくれます。射った矢の番号はこの仕組みで同期しています。
オブジェクト位置の同期は静止中は常にしているわけではなく、移動時のみ再判定されるとのことなので、矢を射ったり弓を持ったりといった動作をするたびに全ての固定済みの星を誤差ミリ程度にズラすことで念入りに位置同期するようにしました。
また、プレイヤーのワールド離脱時に誰のものでもなくなった弓は同期が怪しくなるため、自動的にオーナーになったプレイヤー側で明示的に位置合わせを行うようにしました。

private float deltaTime = 0.0f; //経過時間
private bool syncedToPlayerLeft = false; //プレイヤーいなくなった後に同期するフラグ
private float toggleStarPosition = -0.0001f; //同期するときに微妙に星をずらす時に足す座標(毎回反転する)
 
public override voidOnPlayerJoined(VRCPlayerApi player){
  //プレイヤーが入ってきた時、自分がオーナーなら全ての星を再確認する
  if (Networking.IsOwner(this.gameObject) ){
    RefreshStarsCheck(); //全ての星を確認
  }
}
//プレイヤーがどっかいった時
public override void OnPlayerLeft(VRCPlayerApi player){
  deltaTime = 0;
  syncedToPlayerLeft = true; //同期用フラグON
}

private void Update(){
  deltaTime += Time.deltaTime; //経過時間を足す
  //プレイヤー離脱後、一定時間したら同期を行う
  if( syncedToPlayerLeft && deltaTime > 5.0f ){
    syncedToPlayerLeft = false;
    if (Networking.IsOwner(Networking.LocalPlayer, this.gameObject)){ //オーナーになってたら
      getAllPartsOwner(); //全てを相続する
    }
  }
}
//この弓に関連するすべてのパーツのオーナーを得る
public void getAllPartsOwner(){
  Networking.SetOwner(Networking.LocalPlayer, myAnimator.gameObject); //弓の一番親のオーナーになる
  Networking.SetOwner(Networking.LocalPlayer, this.gameObject); //自分がこの弓のオーナーになる
  Networking.SetOwner(Networking.LocalPlayer, myArrow); //付属の矢もオーナーになる
  Networking.SetOwner(Networking.LocalPlayer, bowMesh); //弓本体ビジュアルもオーナーになる
  for( int index = 0; index < myStarMaxLength; index++ ){ //全ての星に実行
    var star = myStars[index];
    if( star ){
      Networking.SetOwner(Networking.LocalPlayer, star); //うちの子です
    }
  }
  RefreshStars(); //全ての星を確認
}
//すべての星を確認する(オーナー専用)
publicvoidRefreshStars(){
  toggleStarPosition *= -1;
  for( int index = 0; index < myStarMaxLength; index++ ){ //全ての星に実行
    var star = myStars[index];
    if( star ){
      star.transform.position += newVector3(0,0 + toggleStarPosition ,0);
    }
  }
}


仕上げ(軽量化とベイク)


ここからはやらなくてもワールドが動作する部分ですが、光源やオブジェクトを置きすぎて重くなっていたので力を入れて取り組みました。
快適なワールド作りのためにはある意味いちばん大事なところかも。

オブジェクトのStatic化

まず、オブジェクトには動くモノと動かないモノがあります。ワールド内に置いてある弓や飲み物や本などは掴んで移動することができるので当然「動くモノ」です。
しかし家や木や山はその場から絶対に移動しません。移動しないなら影の計算や物理演算を一部省略することができるため「このオブジェクトは絶対動かしませんよ」と、あらかじめ登録しておくことで処理を軽くできるわけです!

Static(静的オブジェクト)の登録はインスペクターの右上のチェックを行うことでできます。動かないものはどんどんstatic化しましょう。

諸々配置が終わったら、動く可能性のあるギミック系と、動かない建物と、建物の外のオブジェクトとで大まかにオブジェクトを親子関係にして分けました。

こうすると一括でStatic化チェック管理ができて楽になります!

インポート設定による容量圧縮

ワールドに読み込むテクスチャや3Dモデルのインポート設定をいじると、見た目の影響を少なめに容量を削減できます。
まずはテクスチャ。最大サイズを下げることで画像を軽量化できます。ちょっとした手のひらサイズの小物程度であれば256や128でも見た目の影響はそんなに無かったりします。

逆に、壁や床など面積が大きいものは解像度を低くするとぼんやりとしますので、512や1024などを選んでおいたほうが無難です。

次に3Dモデル。メッシュ圧縮を設定すると容量を削減できます。見た目が大体あってればOK程度のオブジェクトはどんどん圧縮しましょう。中程度なら影響も少ないようです。

また、後述のライトベイク対象として使う3Dモデルの場合は「ライトマップUV生成」をONに設定しておきましょう。この設定をしないとライトベイクが正しくできません。

ライトベイク

軽量化のための最重要作業。それがライトベイクです! これは必ずおさえておきたい。
ライトベイクが何かというと、ひとことで言うと光の当たり具合をテクスチャに焼き付けることです。
光源がオブジェクトに当たって影を落とす計算は負荷が大きく、リアルタイムで行うと処理落ちします。光源をたくさん置いたり、影の計算対象のオブジェクトが増えれば増えるほど顕著になります。それを解決するのがライトベイク!
まず、こちらがライトベイク済みの室内です。特に違和感ないと思います。


しかし、家具をどかしてみると…。床に家具の影がお化けみたいに焼き付いています。こわいね。影の計算はその場ではしていなくて、あらかじめ床に影の模様が描かれているのです。

オブジェクトをStatic化して「絶対にその場所から動かない」とわかっているなら影が動くことはないのですから、リアルタイム計算する必要がなくなるためです。実はこれ、コンシューマゲームなどでもよく使われている手法だったりします。

ライトベイクは、画面上にあるウィンドウ→レンダリング→ライティング設定 から行うことができます。

設定は詳しく解説しているサイトさんを参考にしてください。解像度は高いほど綺麗になりますが、ワールド読み込み時のサイズが重くなります。一度ダウンロードしてワールドに入ってしまえば軽いのですが、ライトベイクするということはテクスチャが増えるので必然的にサイズが増えます。
あまりサイズを肥大化させたくない場合はベイクを行う対象を絞りましょう。

ライトベイクしたくないオブジェクトのMeshRendererのグローバルイルミネーションをOFFにすればベイク対象から外せます。

いろいろ準備できたら「ライティングの生成」を押せばライトベイクが開始されます。

CPUスペックにもよりますが、けっこう重い計算なので場合によっては数十分から数時間待たされることもあります。PCを長時間放置できるタイミングで行うようにしましょう。つよつよGPUがあるなら別ですが!

他にもライトベイクに関連して「ライトプローブ」も配置しました。こちらはベイク済みのライトの影響を動的なオブジェクトに受けさせたい場合に役立つ仕組みです。

ただ、ひとつひとつ配置するのが面倒だったので「Magic Light Probes」というアセット(有料)を使って自動配置しちゃいました。作業にかかる時間を考えたら買ったほうが早いかなって…。金で解決。

メッシュベイク

ライトベイクだけでも十分軽くなりますが、さらに軽さを追求したいのであればメッシュベイクでしょう。
これは同じような見た目のモノが複数置いてある時や、細々したオブジェクトがたくさん置いてある時に有効です。
下の画像のように雑多にモノが配置されている場合、ひとつひとつのオブジェクトに座標や回転などが設定され、別々に描画処理が走るのでモノを置くほど負荷が増えるのですが…。


メッシュベイクすれば「小物がたくさん集まっている見た目の、一つのオブジェクト」として扱われるので、見た目に反して負荷がかからなくなります!

見かけ上はモノがいっぱい置いてあるように見えますが、全にして一なのです。ただし、その場から動かさないstatic化が条件。

これはBlenderなどをうまく使えば人力でできなくもないのですが、基本はツールを使うことをオススメします。
わたしは「Mesh Combine Studio」というアセット(有料)を使ってボタン一つで結合/解除できるようにしちゃいました。作業にかかる時間を考えたら買ったほうが早いかなって…。金で解決。
このアセットはとても使いやすくてオススメです! まとめたいGameObjectの親を放り込んでボタンを押せば全部やってくれます。建物の1階部分はこのアセットでくっつけてあります。ちゃんと結合後の見た目でライトベイクも考慮してくれます。

マテリアルの結合はできないため、床や壁など同じマテリアルを使った構造物をまとめるのに適しています。

無料の「Mesh Baker」というアセットも良いです! こちらも併用しました。
こちらは一度ベイクしたものを元に戻す手順が若干面倒ですが、ぜんぶばっちり配置した後に最終的にベイクするのであれば問題なさそうです。

オクルージョンカリング

これも地味に重要。
ひとことで言うと「見えない場所は描画しない」という設定です。
ユーザーの視界に入っていない部分も常に描画していると負荷になりますから、描画を行わないようにします。これもあらかじめStatic設定しているオブジェクトが影響を受けます。明示的にONにする必要があるので忘れずに設定しましょう。
ウィンドウ→レンダリング→オクルージョンカリングから設定画面を開きます。

ライトベイクと同じように「ベイク」を押す事でいまstatic化されているオブジェクトの位置を元にオクルージョンカリングの設定が行われます。static化オブジェクトが増減したら再度ベイクしましょう。

「遮蔽物の最小値」は、この値より小さいサイズのものは非表示処理に含まれなくなります。
「最小の穴」は、カメラから見る事ができるワールド内の小さな隙間より小さく設定してください。うまく設定されていないと、モノとモノの隙間から景色を覗いた時などに視界内にあるのにオブジェクトが消えることがあります。

設定が完了すると、カメラの視界から外れて見えない位置にあるモノがスッと消えるようになります。こわいね。

現実も実は人間が観測していない場所は暗闇なのかもしれないですね。

以上です!
VRChatは無料だしPCだけでもできますが、VR機器があるともっと楽しいです。MetaQuestなど購入した方は是非一度はやってみて!

  • Category: technical / Blender
  • Posted: 2022/9/30 19:15
  • Author: ユーリ
関連タグ
前後の記事
【Blender】ボーンを動かしてアニメーションを作成する

コメントを残す


※おなまえ空欄可("ななしさん"になります。)