この記事は何?
先日(2023/4/15)、このようなワールドをアップしました。
この記事は、このワールドに用いた技術・技法を紹介するものです。
ワールドへはこちらからどうぞ。
https://vrchat.com/home/world/wrld_20d0f109-ffdd-404f-8e87-651a461ddab2
注意
性質上、ワールドやゲームのネタバレを含みます。
以下の「取り入れた技術・技法」から既にネタバレなので、よければ先にワールドを見てみて下さい。
謝辞
本ワールドで展示されている楽曲は、同人ゲーム「けものワンダラーズ」の登場曲です。
制作サークル「きまぐれペンギン工房」主催の杏仁みかんさん( https://twitter.com/xiao_mikan/ )にはワールドの監修をしていただいています。
また、ワールドの一部プロップを提供していただきました。
この場をお借りしてお礼申し上げます。
ゲーム本体は下記リンクからどなたでもご購入いただけます。
執筆時点で体験版v0.43が500円で販売中です。
https://booth.pm/ja/items/4432568
再度注意
性質上、ワールドやゲームのネタバレを含みます。
以下の「取り入れた技術・技法」から既にネタバレなので、よければ先にワールドを見てみて下さい。
…ここまでスクロールしてもらったらさすがにネタバレ内容書いていいよね…?
取り入れた技術・技法
- オープニング
- Timeline
- BGMに合わせたFPS設定
- Timelineで独自スクリプトのタイミングを制御する
- 二重Animator
- 視線誘導
- 描画順との戦い
- スキップ機能
- Timeline
- 楽曲
- ブース
- 楽曲の再生タイミングと音量
- イントロ付きループ
- 同時再生
- シークバー
- エディタ拡張によるデータ流し込み
- ブース間
- LineRendererを用いた誘導線
- ブース
- ワールド全体
- 軽量化
- メッシュ結合
- 環境に合わせたLOD選択
- 軽量化
オープニング
本作には、入室後1分弱のオープニングが再生されます。
このオープニングでは、
- 黒いエリアにサークルカット・キャッチコピーを出した後、
- 徐々に明るくしてワールドの入口エリアを見せつつタイトルロゴドーン
- 入口エリア全面にゲームのスクショを徐々に登場させる
- ワールド説明を表示し、次のエリアを開放する
という流れを、オープニング曲に合わせて行っています。
Timeline
Unityには、AnimatorやAudioSourceなどを制御できるTimelineという機能があります。
これは、何をいつ表示・再生するかを時系列でグラフィカルに設定できる便利な機能です。
本ワールドでは、オープニング曲のタイミングに合わせて各種アニメーションを再生するために使用しています。
この画像はオープニングの進行を制御するタイムラインの画像です。
BGMに合わせたFPS設定
このタイムラインでは、BGMに合わせてアニメーションを開始したりスクリプトを実行したりするため、FPSを22.33にしています。この値は、以下のようにして算出しています。
fps = (1Beatに進めたいFrame数) * (曲のBPM) / 60
例えば、本ワールドのオープニング曲ではBPM=134の曲を使用しているので、
fps = 10 * 134 / 60 = 22.333...
と計算できます。
こうすることで、曲のビートに合わせて処理を実行したいときは、(10の倍数)フレーム目にオブジェクトを有効化してあげればよくなります。
Timelineで独自スクリプトのタイミングを制御する
しかし、このTimeline(やそれが制御するAnimator)では独自スクリプトに対して何か操作するということができません。そこで、本ワールドでは、オブジェクトの有効・無効を切り替えることで、各スクリプトのOnEnable
/OnDisable
を発火させています。
二重Animator
オープニングでは、スクリーンショットが徐々に現れる演出を行っています。このとき、現れ方がスクリーンショット毎に異なることに気づいた方は多いと思います。
実は、出現アニメーションは入室する毎にランダムで設定しています。ランダム化は、各スクリーンショットに紐づけたスクリプトからAnimatorにパラメータをランダムで渡すことで行っています。
一方、各ブロックのタイミングは別のAnimatorで制御、各ブロック内での各スクリーンショットのタイミング制御はスクリプトで行っています。
整理するとこのような感じです。
- スクリーンショット統括オブジェクト(各グループの表示タイミング制御用Animator)
- スクリーンショットグループ(各スクリーンショットの表示タイミング制御用U#)
- スクリーンショットグループ(各スクリーンショットの表示タイミング制御用U#)
- スクリーンショット(Animator+パターン制御用U#)
- スクリーンショット(Animator+パターン制御用U#)
視線誘導
皆さんはワールドに入った後、黒いエリアが晴れてスクリーンショットが現れ始めたとき、その出現に合わせて次の画像のように回って見たと思います。(というかそうであってほしいです)
この動きの実現のため、左上のスクリーンショットは横幅が狭めのデスクトップでもギリギリ見える位置に画像を置く必要がありました。
この動きを取り入れたのには3つの理由があります。
1つ目は、大量のスクリーンショットを1箇所に置くと小さくて見づらくなってしまうのを防ぐため、ワールド内に分散させたかったこと。
2つ目は、オープニングの1分弱の間に全く動かないと飽きるので、回転という比較的低コストな移動を取り入れてマンネリ化を防止したかったこと。
3つ目は、以下で述べる描画順の話が絡みます。
描画順との戦い
本ワールドではTextMesh Proを用いて描画している箇所がいくつかあります。これが曲者で、描画優先度(Render Queue = RQ)の指定ができません。
オープニングで使用する最初のエリアでは主に以下の要素が描画されます。
- 背景(RQ=2000)
- スクリーンショット(RQ=2000)
- 壁に張り付くテキストA
- 真っ暗な空間を作る裏向きCubeC
- 真っ暗な空間に浮かぶテキストB
- 真っ暗な空間に浮かぶサークルカット(RQ=3000)
ここで、2種類のTMPro([A][B])がこの空間にあることが分かります。
最初の黒い空間では[B]のみ表示させたいのですが、TMProのテキストはRQ=3000となっているようで、半透明を作るために[C]をTransparentシェーダーにすると、[C]のRQによって以下のような挙動になります。
- [A]も[B]も完全に見えない([C]のRQを2999以下に設定)
- [B]だけ見えるが、半透明時に[A]が見えない([C]のRQを3000以上に設定)
- [B]だけでなく[A]も黒を貫通して見える([C]のRQを3001以上に設定)
そこで、本ワールドでは[C]のRQを3000に設定した上で、視線誘導により背面のテキスト群に目を向けさせ、後ろを見たタイミングでenabld=falseにしています。[C]をないものとすれば[A]まで遮るものがなくなるので、このタイミングで[A]が表示されます。
実際、視線誘導に抗い、正面を見続けると、これらの文字が遅れて表示されるのが分かるかと思います。
スキップ機能
「フレンドに会いに来たのになんかよく分からんムービー流れてウザったい」と不快感を抱くユーザーを減らすため、本ワールドではオープニングのだいたいの時間を最初に示しています。さらに、メニューからRespawnすることでオープニングをスキップできるようにしています。
ちなみに、なぜRespawnかと言うと、それが一番操作しやすい割に誤操作が少なく、Udonで簡単に取得できるイベントだからです。
(実はこのスキップを実現するために、アニメーション周りでちょっと工夫が必要でしたが、それはまた別の機会に…)
楽曲
本ワールドのメインコンテンツである、「楽曲」の展示方法について紹介します。
ブース
各楽曲は、画像のような「ブース」と呼ばれる場所で聴くことができます。
ブースには、シークバーの他に、その曲について解説するパネル、曲を聴くことができる範囲の表示が用意されています。
楽曲の再生タイミングと音量
楽曲はブースに近づくと再生されます。
Unityで「何かの近くに行かないと聞こえない音」を表現するとき、よく3D Spatializationを使うと思います。しかし、本ワールドでは、以下の2つの理由からこの方式を取っていません。
- ブースに近づいたタイミングで、曲の先頭からフル音量で聴いてほしい
- 途中からヌルッと始まってほしくない
- ブースに対する向きによって音の聞こえ方を変化させたくない
- 3D Spatializationでは音源の位置から聞こえてくるように調整がかかるが、本ワールドでは左右の定位も含めて音楽を正確に再現したい
ではどうやっているかと言いますと、2DのAudioSourceに対してUdonで毎フレームvolumeを調整しています。脳筋
ブースへの進入時には一定以上近づいたときに100%で開始、退出時には一定以上離れたときに0%になるよう一定距離から線形減衰をかけています。式にすると
AudioSource.volume = Mathf.Clamp((Distance - B) / -(B - A) , 0, 1);
といった形です。(Distance
は基準位置からのプレイヤーの距離、A
とB
はそれぞれ基準位置からどれだけ離れたら(最大音量/音量が0)になるか)
ただし、この方法で全楽曲に対して常時計算するのは計算リソースの無駄なので、各ブースにコライダーを用意し、プレイヤーがその領域に入っているときのみ上記計算を行い、それ以外のときは計算を止めるだけでなくAudioSourceも停止してしまいます。
イントロ付きループ
本展示ワールドの楽曲はオープニング曲以外全て「イントロ付きループもの」です。
こんな感じの音声について、「イントロ」部分を1回再生した後、「ループ」部分をずっと再生する曲です。ゲームではよくある再生方式ですが、Unityはwav形式以外でネイティブにこれをサポートしていません。wavはファイルが大きすぎて扱いが面倒なので、今回もフレーム数ベースのループを導入しました。詳しくは過去の記事↓をご覧ください。
https://qiita.com/YutaGameMusic/items/7311b3f0d7b9711b1869
同時再生
本ワールドには、1つ「聴く場所によって雰囲気が変わる楽曲」を後半で用意しています。
楽曲制作時にテンポ・拍子・コードを合わせ、同時に再生したりクロスフェードしたりすることができるようにしています。このときの各BGMの音量は以下の式で計算されます。
audioSourceA.volume = BoothVolume * Mathf.Clamp01(0.5f - P);
audioSourceA.volume = BoothVolume * Mathf.Clamp01(0.5f + P);
ここで、BoothVolume
は先ほどの音量の計算結果、P
は「どちらのブースに寄っているか」を表す数値です。
なお、同時再生の際、Unityの様々な要因で片方がズレたときに修正する必要があります。そのため、再生中は2つのAudioSourceを常時監視し、timeSamplesに一定以上の差が生じたら片方に合わせるという処理をしています。ただし、通常時でもループの際に値がループ時間分だけズレることがあるため、差はループ時間で割った余りを見ます。
シークバー
各楽曲の解説板の下にはシークバーが置いてあります。制御はC#(U#)で行っています。
public const float DIFF_TOLERANCE = 0.3f;
[SerializeField] private Slider SeekBar;
[SerializeField] private AudioSource AudioSource;
[SerializeField] private Text TimeText;
void Update(){
float time = SeekBar.value = AudioSource.time;
TimeText.text = ...;
}
// SeekBarのOnValueChangedで呼び出す
public void OnSliderValueChanged()
{
if(Mathf.Abs(AudioSource.time - SeekBar.value) > DIFF_TOLERANCE)
{
float time = AudioSource.time = SeekBar.value;
TimeText.text = ...;
}
}
このスクリプトで注目してほしいのは、Seekbarの値が変わったときのif(Mathf.Abs(AudioSource.time - SeekBar.value) > DIFF_TOLERANCE)
というチェックです。
通常、シークバーを操作するだけであればこのチェックは不要です。「〇秒以上ズラしたときにのみ音声位置を変える」なんてユーザーからしたら「細かく調整したいのに…」と不満を抱くだけですから。
ではなぜこのようなチェックを入れているのかというと、「UnityのSeekbarはユーザーが操作したときだけでなくスクリプトから値を変えたときもOnValueChangedを発火する」からです。
この仕様が絡んでくるのはUpdate関数内のSeekBar.value = AudioSource.time;
の部分、つまり時間経過によってシークバーの表示位置を進める箇所です。すなわちOnValueChanged()は毎フレーム呼び出されます。この仕様めんどくさい
(なおC#であれば「シークバーにクリックされているか」を別途スクリプトから管理してあげればOnValueChanged内で条件分岐できるので回避できます。U#だとできません。)
ただし、ここまで簡単なのは1つの楽曲を再生するときのみの話です。というのも、先ほどの「同時再生」を行うブースでは、2つの音声位置を同期しなければなりません。
2つの楽曲の解説板それぞれの下にシークバーが設置されています。これらはともに同時に再生位置を示すとともに、どちらかの値が変更されたら楽曲の再生位置ともう片方のシークバーの位置を変更しなければなりません。
そこで、次のようなソースコードを書きました。
public const float DIFF_TOLERANCE = 0.3f;
public const float AUDIO_SYNC_TOLERANCE = 0.1f;
[SerializeField] protected AudioSource AudioSource1;
[SerializeField] protected AudioSource AudioSource2;
[SerializeField] protected Slider SeekBar1;
[SerializeField] protected Slider SeekBar2;
[SerializeField] protected Looper Looper;
[SerializeField] protected Text TimeText1;
[SerializeField] protected Text TimeText2;
private float _bgmLoopLength; //初期化処理は省略するが、楽曲のループ長(秒)が格納される
void Update()
{
_sliderValueLock = false;
// シークバーの位置を調整
float time = SeekBar1.value = SeekBar2.value = AudioSource1.time;
TimeText1.text = TimeText2.text = ...;
if (Mathf.Abs(AudioSource1.time - AudioSource2.time) > AUDIO_SYNC_TOLERANCE)
{
// 2つの音がズレていたら調整
AudioSource2.timeSamples = AudioSource1.timeSamples;
}
}
private bool _sliderValueLock = false;
// SeekBar1とSeekBar2のOnValueChangedで呼び出す
public void OnSliderValueChanged()
{
if (!_sliderValueLock && Mathf.Abs(AudioSource1.time - SeekBar1.value) > DIFF_TOLERANCE)
{
// SeekBar1が操作されたとき
float time = AudioSource1.time = AudioSource2.time = SeekBar1.value;
TimeText1.text = TimeText2.text = ...;
_sliderValueLock = true;
}
if (!_sliderValueLock && Mathf.Abs(AudioSource2.time - SeekBar2.value) > DIFF_TOLERANCE)
{
// SeekBar2が操作されたとき
float time = AudioSource1.time = AudioSource2.time = SeekBar2.value;
TimeText1.text = TimeText2.text = ...;
_sliderValueLock = true;
}
}
しかし、これではうまくいきませんでした。なぜかと言うと、SliderのOnValueChangedの仕様として、「値が変わった瞬間に呼び出す」というものがあるからです。つまり、SeekBar1.value = SeekBar2.value = AudioSource1.time;
という記述を実行すると、
- AudioSource1の時刻を読み取る
- SeekBar2に読み取った値を代入する
- SeekBar2.OnValueChangedが発火する
- SeekBar1に読み取った値を代入する(すなわちここまでSeekBar1の値は書き換わる前の値)
- SeekBar1.OnValueChangedが発火する
という処理順となっており、SeekBar2を操作すると
- SeekBar2からOnValueChangedが発火され、
// SeekBar2が操作されたとき
が実行される- AudioSource1・AudioSource2の時刻がSeekBar2の値になる
- 次のUpdateで
SeekBar1.value = SeekBar2.value = AudioSource1.time;
が実行される-
SeekBar2.value = AudioSource1.time;
によりSeekBar2からOnValueChangedが再度発火される- このときAudioSource1.timeはSeekBar2の値だがSeekBar1の値は変更されていないので
// SeekBar1が操作されたとき
も実行されてしまう- AudioSource1・AudioSource2の時刻がSeekBar1の値(つまり変更前の値)になる
- AudioSource2.timeがSeekBar1の値に書き換わる
- _sliderValueLockのおかげで(せいで?)
// SeekBar2が操作されたとき
は実行されない
- _sliderValueLockのおかげで(せいで?)
- このときAudioSource1.timeはSeekBar2の値だがSeekBar1の値は変更されていないので
-
SeekBar1.value = (SeekBar2.value = AudioSource1.time);
が実行される- このとき
(SeekBar2.value = AudioSource1.time)
は変更後の値 - SeekBar1が変更後の値にセットされる
- _sliderValueLockのおかげで(せいで?)
// SeekBar1が操作されたとき
は実行されない
- _sliderValueLockのおかげで(せいで?)
- このとき
-
という処理が行われます。この一連の流れが終わった後、AudioSource1・AudioSource2の再生位置はSeekBar2を変更する前の状態に戻ってしまっていることが分かります。
そこで、以下のように書き換えました。
private const float SLIDER_VALUE_LOCK_LENGTH = 0.1f;
[SerializeField] protected AudioSource AudioSource1;
[SerializeField] protected AudioSource AudioSource2;
[SerializeField] protected Slider SeekBar1;
[SerializeField] protected Slider SeekBar2;
private float _bgmLoopLength;// 初期化は別の場所で行っている
private void Update()
{
if (_sliderValueLockTimer > 0)
{
_sliderValueLockTimer -= Time.deltaTime;
if (_sliderValueLockTimer <= 0)
{
SeekBar1.value = AudioSource1.time;
SeekBar2.value = AudioSource1.time;
}
}
else
{
float time = SeekBar1.value = SeekBar2.value = AudioSource1.time;
TimeText1.text = TimeText2.text = ...;
}
if (Mathf.Abs(AudioSource1.time - AudioSource2.time) > AUDIO_SYNC_TOLERANCE)
{
AudioSource2.timeSamples = AudioSource1.timeSamples;
}
}
private float _sliderValueLockTimer = 0;
public void OnSlider1ValueChanged()
{
if (_sliderValueLockTimer<=0 && Mathf.Abs((AudioSource1.time - SeekBar1.value + _bgmLoopLength) % _bgmLoopLength) > 0.3f)
{
float time = SeekBar2.value = AudioSource1.time = AudioSource2.time = SeekBar1.value;
TimeText1.text = TimeText2.text = ...;
_sliderValueLockTimer = SLIDER_VALUE_LOCK_LENGTH;
}
}
public void OnSlider2ValueChanged()
{
if (_sliderValueLockTimer <= 0 && Mathf.Abs((AudioSource2.time - SeekBar2.value + _bgmLoopLength) % _bgmLoopLength) > 0.3f)
{
float time = SeekBar1.value = AudioSource1.time = AudioSource2.time = SeekBar2.value;
TimeText1.text = TimeText2.text = ...;
_sliderValueLockTimer = SLIDER_VALUE_LOCK_LENGTH;
}
}
このように2つのシークバーから発火する関数を分けることで、先ほどのような問題を起こさず同期させることができます。
エディタ拡張によるデータ流し込み
本ワールドで表示する解説はこのようなScriptableObjectで管理しています。
ただし、U#では独自ScriptableObjectを扱えないので、データをエディタ拡張で流し込んでいます。流し込む、と言っても特別なことをしているわけではなく、以下のようなコンポーネントを各ブースに付けてImport関数をコンテキストメニューから呼び出すことで流し込むことができるようになっています。
public class BoothDataImporter : MonoBehaviour
{
#if UNITY_EDITOR
public const string DESCRIPTION_PREFAB_PATH = "Assets/PATH/TO/DescriptionPage.prefab";
public GameObject DescriptionPrefab => AssetDatabase.LoadAssetAtPath<GameObject>(DESCRIPTION_PREFAB_PATH);
public DescriptionData DescriptionData;
public DescriptionCanvas DescriptionCanvas; // 流し込む対象のキャンバス
[ContextMenu("Import ALL")]
public void ImportAll()
{
BoothDataImporter[] importers = FindObjectsOfType<BoothDataImporter>();
foreach(var importer in importers)
{
if (importer != null)
{
importer.Import();
}
}
}
[ContextMenu("Import")]
public void Import()
{
for(var i = DescriptionCanvas.PageCount - 1; i >= 0; i--)
{
DestroyImmediate(DescriptionCanvas.Pages[i]);
}
{
var CanvasSO = new SerializedObject(DescriptionCanvas);
CanvasSO.Update();
CanvasSO.FindProperty(nameof(DescriptionCanvas.Pages)).arraySize = DescriptionData.Descriptions.Length;
{
var TitleTextSO = new SerializedObject(DescriptionCanvas.TitleViewer);
TitleTextSO.FindProperty("m_text").stringValue = DescriptionData.Title;
TitleTextSO.ApplyModifiedProperties();
}
for(var descIndex = 0; descIndex < DescriptionData.Descriptions.Length; descIndex++)
{
var descriptionData = DescriptionData.Descriptions[descIndex];
{
var DescriptionGO = (GameObject)PrefabUtility.InstantiatePrefab(DescriptionPrefab, DescriptionCanvas.DescriptionPageBase);
{
var DescriptionSO = new SerializedObject(DescriptionGO);
DescriptionSO.FindProperty("m_Name").stringValue = "Page" + (descIndex + 1);
DescriptionSO.ApplyModifiedProperties();
}
var DescriptionPageObject = DescriptionGO.GetComponent<DescriptionPageObject>();
{
var TextSO = new SerializedObject(DescriptionPageObject.text);
TextSO.FindProperty("m_text").stringValue = descriptionData.text;
TextSO.ApplyModifiedProperties();
}
{
var ImageSO = new SerializedObject(DescriptionPageObject.image);
if (descriptionData.image == null)
{
ImageSO.FindProperty("m_Enabled").boolValue = false;
}
else
{
ImageSO.FindProperty("m_Enabled").boolValue = true;
ImageSO.FindProperty("m_Sprite").objectReferenceValue = descriptionData.image;
}
ImageSO.ApplyModifiedProperties();
}
{
CanvasSO.FindProperty(nameof(DescriptionCanvas.Pages)).GetArrayElementAtIndex(descIndex)
.objectReferenceValue = DescriptionGO;
}
}
}
CanvasSO.ApplyModifiedProperties();
}
}
#endif
}
これはエディタ拡張であり、流し込んだデータをシーンに保存しなければならないので、Text.textなどを直接触ることはせず、SerializedObject経由で値を流し込んでいきます。
ちなみに、このコンポーネントはU#ではなくC#です。U#としてランタイムで動かす必要がないのでC#にしています。独自C#はアップロード時に自動で外されます。
(厳密には、コンポーネントが存在していることは分かるが、どのMonoBehaviourかが分からず、あたかもコンポーネントが存在しないかのように振る舞うダミーコンポーネントとして残ります。ダミーコンポーネントを含むオブジェクトは生成時にコンソールに警告が出ますが、大きな問題にはならないと踏んでいます。)
ブース間
ブースの間には、その道順を示すための誘導線が引かれています。
LineRendererを用いた誘導線
この誘導線はLineRendererを用いて描画しています。
通常、LineRendererは自分の向きに向き続けますが、今回はテクスチャがあるため、向きを固定する必要があります。
これはLineRendererコンポーネントの一部です。このAlignmentをTransform Zとし、向きを整えると、常時上を向くLineRendererが作れます。
このTransform Zでは、オブジェクトのZ-からZ+に向けて見るときに正面が見えるように敷き詰めます。必要に応じてオブジェクト自体を回転させてから点を打って下さい。
ワールド全体
この章では、各部屋の共通事項を紹介します。
軽量化
このワールドには、原作ゲームでも使用されるブロックが多数配置されています。その数、実に4254個。当然、その数ぶんだけBatches数もVerts数も増えます。実際、特段の軽量化なしではPCでもガクガクになりました。
こちらは利用しているCube World Setのブロックです。Verts=192、Tris=380なので、これを4254個置くとVertsが約800K、Trisが約1.5Mとなり、こちらも馬鹿にはなりません。
メッシュ結合
まずはBatchesを減らそうと、自作の軽量化ツールでメッシュをざっくりまとめました。その結果、ワールドの背景を2メッシュにまとめられました。これにより、Batches数を120前後に抑えることができました。
ツールはコチラに公開しているので、よければこちらもご覧ください!
https://yuta-game-music.booth.pm/items/4706917
環境に合わせたLOD選択
メッシュの結合でもある程度の軽量化はできました。実際、PCのFPSが10前後→45前後に改善しました。しかし、Quest単機で入ってみると、ときどきメッシュが荒れたりガクガクっとしたりと不安定な印象でした。そこで、QuestではLODを調整することにしました。
こちらが同じアセットに入っているLOD=2のオブジェクトです。Verts=56、Tris=108となり、ともに3分の1以上削れました。
ただし、この変更は上記のメッシュ結合を行ってからはできないので、背景を独立オブジェクトで作成してからバックアップを取り、Prefabを書き換えてからメッシュ結合、という作業をStandaloneとAndroidの両方で行いました。
この両プラットフォームのデータはともにワールドに配置してあり、Startでプラットフォームに合わせて有効化するもの(というよりは不要でDestroyすべきもの)を変えています。
public class PlatformSpecificObject : UdonSharpBehaviour
{
public bool EnableOnWindows = true;
public bool EnableOnQuest = true;
void Start()
{
#if UNITY_STANDALONE_WIN
if (!EnableOnWindows)
{
Destroy(gameObject);
}
#elif UNITY_ANDROID
if (!EnableOnQuest)
{
Destroy(gameObject);
}
#endif
}
}
#if UNITY_STANDALONE_WIN
のような プラットフォーム別ディレクティブは、VRChatのワールドをビルドする際にも有効利用できます。
この改善により、Quest単機でも(快適とまでは言いませんが)ワールドを楽しめるようになりました。