BGMが少し大きかったので 、ワールドのスライダーを下げて遊びました。楽しかったので翌日また訪れると、また大音量で迎えられます。
音量、字幕のオンオフ、チュートリアルを見たかどうか。こういう 自分だけの設定 は、同期変数では残せません。同期はいまこの部屋にいる人へ伝えるための仕組みで、部屋を出れば消えるからです。
この記事では、スライダーで決めた音量が次回も残る ようにします。
この記事でわかること
- 同期変数とPlayerDataの使い分け
- 復元を待ってから読む理由
- 書くのは自分の分だけ、読むときは誰の分かを渡すこと
- 保存を連発しないための工夫
ネットワーク同期入門 と ワールド内にUIを置く を試した状態から始めます。
黒板は消える、ロッカーは残る
同期の話で、同期変数は黒板 というたとえを使いました。いま部屋にいる人が読める場所です。部屋を出れば、その黒板とはお別れになります。
PlayerData は、そのワールドに置かれた自分のロッカー です。中身は自分だけのもので、次に来たときもそのまま残っています。

音量で並べると、違いがはっきりします。
| やりたいこと | 使うもの |
|---|---|
| 部屋のBGMを、いる人全員でいっせいに止める | 同期変数 |
| 自分だけBGMを小さくして、次回も小さいまま | PlayerData |
保存できるのは 値 です。真偽、数値、文字列が基本で、Vector3 や Color も扱えます。
保存 できない ものもはっきりしています。AudioSourceやGameObjectそのもの、マテリアル、シーンにある物への参照。これらは保存されません。

保存するのは「音量は0.2だった」という数字だけで、スライダーもAudioSourceも保存しません。次回、その数字をいまシーンにある物へ書き戻します。
保存はワールドごと、プレイヤーごと です。同じアカウントでも、別のワールドからは見えません。逆に、同じワールドなら別のインスタンスでも同じデータが読めます。
復元を待たないと、保存が消える
PlayerDataでいちばん多い事故が、ここです。
入室した直後は、保存データがまだ届いていません。 この状態で読むと、保存した値ではなく初期値(floatなら 0)が返ってきます。

問題は、その 0 をスライダーに入れてしまうことです。スライダーが動けば「音量が変わった」と判断して保存します。こうして本物の 0.2 が 0 で上書きされ、消えます。
読むのが早いこと自体は害がありません。危ないのは、初期値を本物だと思って書き戻すこと です。
これを避けるための合図が OnPlayerRestored です。
public override void OnPlayerRestored(VRCPlayerApi player)
「このプレイヤーの保存データを取り出したので、もう読めます」という知らせです。呼ばれてから読めば、正しい値が返ってきます。
このイベントには2つの注意点があります。
他の人の分でも呼ばれます。 誰かが入室するたび、その人の復元でも呼ばれます。自分のBGM音量を他人の入室で書き換えないよう、player.isLocal で自分の分だけを見ます。
書くのと読むのは形が違います。 ここは最初とまどうところです。
PlayerData.SetFloat("bgm_volume", 0.2f); // 書く:自分の分だけ
PlayerData.TryGetFloat(player, "bgm_volume", out float saved); // 読む:誰の分かを渡す
書けるのは自分のロッカーだけなので、書く側にプレイヤーの指定はありません。読む側は他の人のロッカーも覗けるので、誰の分かを渡します。ランキング表示などは、この読み方でできています。
実践:音量を覚えるスライダー
スライダーでBGM音量を変えられて、次に来たときもその音量で始まる仕掛けを作ります。
1. BGMとスライダーを置く
床のあるシーンに、次を置きます。
| 名前 | 作り方と設定 |
|---|---|
Bgm | Create Empty に Audio Source を追加。Play On Awake と Loop をオン、Spatial Blend は 0 |
SettingCanvas | UI Canvas(World Space)。(0, 1.5, 2)、Scale (0.003, 0.003, 0.003) |
VolumeSlider | SettingCanvas の子に UI Slider |
PersistenceRoot | Create Empty。(0, 0, 0) |

Bgm の Audio Source には、ループできる曲を入れます。
Canvasには ワールド内にUIを置く と同じく、VRC Ui Shape を追加して、Graphic Raycasterが付いていることを確認します。これがないとVRChat内でスライダーを触れません。
VolumeSlider は Min Value 0、Max Value 1、Value 0.5 にしておきます。
2. コードを書く
Assets/Scripts で BgmVolumeSetting を作ります。
using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDK3.Persistence;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class BgmVolumeSetting : UdonSharpBehaviour
{
[SerializeField] private AudioSource bgm;
[SerializeField] private Slider volumeSlider;
[SerializeField] private float defaultVolume = 0.5f;
private const string VolumeKey = "bgm_volume";
private bool restored; // 復元が終わるまでは保存しない
private bool savePending; // 保存の予約が入っているか
private void Start()
{
// 復元が来るまでは、作者が決めた音量で鳴らしておく
Apply(defaultVolume);
}
// 自分の保存データが読める状態になったら呼ばれる
public override void OnPlayerRestored(VRCPlayerApi player)
{
if (!player.isLocal) return; // 他の人の復元では何もしない
float volume = defaultVolume;
if (PlayerData.TryGetFloat(player, VolumeKey, out float saved))
{
volume = saved; // 前回の値があった
}
Apply(volume);
restored = true; // ここから先は保存してよい
}
// スライダーの On Value Changed からつなぐ
public void OnVolumeChanged()
{
if (volumeSlider == null) return;
// 音はすぐ変える。耳で確かめられるように
if (bgm != null) bgm.volume = volumeSlider.value;
if (!restored) return;
// 動かしている間は予約を1つだけにして、0.5秒後にまとめて保存する
if (!savePending)
{
savePending = true;
SendCustomEventDelayedSeconds(nameof(SaveVolume), 0.5f);
}
}
public void SaveVolume()
{
savePending = false;
if (volumeSlider == null) return;
PlayerData.SetFloat(VolumeKey, volumeSlider.value);
Debug.Log("[BgmVolume] 保存しました: " + volumeSlider.value);
}
private void Apply(float volume)
{
if (bgm != null) bgm.volume = volume;
if (volumeSlider != null) volumeSlider.value = volume;
}
}
読むときのポイントが3つあります。
restored を立てるのは Apply() のあと です。Apply() はスライダーを動かすので OnVolumeChanged() が呼ばれますが、その時点ではまだ restored が false なので保存されません。復元した値をそのまま保存し直す無駄を防いでいます。
保存は0.5秒に1回までにしています。 スライダーをドラッグすると OnVolumeChanged() は毎フレーム呼ばれます。そのたびに保存すると、クラウドへの書き込みが数百回になります。予約を1つだけ持つようにして、動かし終わった0.5秒後に1回書きます。この待たせ方は 時間差で処理を呼ぶ と同じ仕組みです。
キーは文字列で自分が決めます。 "bgm_volume" のように分かる名前にして、const で1か所にまとめておくと打ち間違いが減ります。書いた型と読む型もそろえてください。
3. つなぐ
PersistenceRoot に Udon Behaviour を追加し、BgmVolumeSetting を指定します。BgmとVolume Sliderをドラッグします。
VolumeSlider の Inspector で、いちばん下の On Value Changed (Single) に + を押します。
| 欄 | 指定するもの |
|---|---|
| オブジェクト | PersistenceRoot |
| 関数 | UdonBehaviour → SendCustomEvent |
| 引数 | OnVolumeChanged |

関数の一覧から選ぶのは SendCustomEvent で、呼びたいメソッド名は文字列で入力します。ここのつづりが違うと、静かに何も起きません。
4. 本当に残るかを確かめる
まずBuild & Testで、動きだけを確かめます。
| 順序 | 操作 | 期待する結果 |
|---|---|---|
| 1 | 入る | スライダーが 0.5、BGMがその音量で鳴る |
| 2 | 0.2 まで下げる | 音がすぐ小さくなる |
| 3 | 手を離して1秒待つ | Consoleに「保存しました: 0.2」が 1回だけ 出る |
| 4 | ドラッグし続ける | ログが毎フレームではなく、間隔をあけて出る |
3番目と4番目で、保存が連発していないことを確かめます。
次回まで残るかどうかは、アップロードしたワールドで確かめます。 テスト用の保存データはクライアントの中だけにあり、閉じると消えるためです。
Community Labsに出す前でも、自分だけが入れる状態でアップロードして、退出してから入り直せば確認できます。スライダーが 0.2 の位置で始まれば成功です。
うまくいかないときは
- いつも初期値で始まる →
Start()で読んでいます。OnPlayerRestoredを待ちます - 設定が保存されない → キー名のつづりが書くときと読むときで違っていないか確認します
- 他の人が入ると自分の音量が変わる →
player.isLocalの判定が抜けています - スライダーを動かしても何も起きない → On Value Changed の設定か、CanvasのVRC Ui Shapeを確認します
- Build & Testで消える → テスト用のデータはクライアントを閉じると消えます。アップロードして確かめます
- 保存ログが大量に出る → 予約の仕組みが効いていません。
savePendingの判定を確認します
おまけ:先に知っておくと良いこと
- 初回とゼロを見分ける:
TryGetFloatがfalseを返したときが初回訪問です。0が返ってきたのなら、それは「ミュートで保存した」という意味になります。この2つを混同しないでください - キーは増やしすぎない: 設定が10個あるなら10個のキーで構いませんが、名前の付け方を決めておかないと自分で分からなくなります。
bgm_volumesubtitle_onのように、そろえた形にします - 同期とは独立している: このスクリプトの同期モードは
Noneですが、PlayerDataは問題なく使えます。個々のUdon Behaviourの同期設定とは別の仕組みです - 他の人の分も読める: 読む側にプレイヤーを渡せるので、全員の記録を集めて掲示板に出すこともできます。ランキングのあるワールドはこれを使っています
- 使いどころはいろいろ: BGM音量、ミラーの初期状態、字幕の言語、チュートリアルを見たか、前回いた部屋。「他の人と合わせる必要がない設定」はすべて対象です
まとめ
PlayerDataは、そのワールドに置かれた自分のロッカーです。
- 同期変数は部屋を出ると消える。PlayerDataは次回まで残る
- 読む前に
OnPlayerRestoredを待つ。待たずに読んだ初期値を書き戻すと保存が消える - 書くのは自分の分だけ。読むときは誰の分かを渡す
- 保存は連発しない。動かし終わってから1回書く
保存する前の問いかけは、「これは他の人と合わせる値か、自分だけの値か」 です。自分だけならPlayerDataです。
各自が自分の状態を持つ仕掛けなら PlayerObjectで各自の状態を持つ、決まった数の物を貸し出すなら VRCObjectPool入門 へ進んでください。