はじめに
UnityのゲームをWebGLとしてビルドし、進行状況を保存したいのです。
やり方としては4つ考えられて、
- PlayerPrefsを使う
- JavaScriptにアクセスする経路を作成し、localStorageを使う
- 自前のサーバ建てる
- 外部サービス利用する
自前のサーバはめんどいので最後の手段として、外部サービスを利用するような(ランキングとかの)仕様も含んでいません。
となるとPlayerPrefsかlocalStorageかなんですが、PlayerPrefsの解説を読むと「ブラウザのIndexedDB API使ってるで」と書いてありました。
つまりlocalStorageより良いもの使っていて、そこを自前で作るのも車輪の再開発になってしまうので、おべんきょするには良いと思うのですが開発が最終段に近付いていて今はリリースを優先したいので今回はパス。
となるとPlayerPrefsしか選択肢ないのではとなりました。
しかしPlayerPrefsはどうにも使いにくいのです。
プリミティブ型しか保存できないし、一個一個ちまちま保存展開処理を書かないといけないし。
PlayerPrefsXも見たのですが、プリミティブ型を配列にして一括保存で効率良くなるのは分かりましたが、でもやりたいことではない。
クラスのインスタンスを丸ごとシリアライズして保存したいし、それを取り出してデシリアライズして使いたい。
ので、Unity用のシリアライザをいくつか試して、StringにしてPlayerPrefsに保存/取り出しが一番速いのはどれか調べました。
一つ注意点として、どうもPlayerPrefsは非印刷文字をうまく保存してくれないようで、シリアライズ後のバイト列を直接ToString()した文字列は保存できませんでした。
エラーも出ず保存自体は成功しているように見えて、取り出すと最初の非印刷文字の部分で情報が途切れているとしか思えない文字列しか取得できませんでした。
(13バイト保存して1バイトしか取り出せないなど)
PlayerPrefsXでもBASE64にエンコードして保存しており、おそらく仕様であろうということで今回はテストから省きました。
なのでPlayerPrefsへの保存はすべてJSONまたはBASE64としています。
Quoted-Printableっていう手もあるかなーと思ったんですが、Unity C#ではあんまり良いライブラリが出てこなかったのでこれも今回は無しとしています。
確認時環境
- Windows 10 Pro 日本語版
- Unity 2018.2.7f1 Personal
比較対象シリアライザ
- 素のPlayerPrefsを並べただけのもの(これが一番遅くなることを期待)
- JsonUtility(割と速いらしいので一縷の望みをかけて)
- Utf8json(シリアライズとJson化が同時に可能、Jsonから直接デシリアライズ可能)
- ZeroFormater(デシリアライズ速度ゼロに期待大)
- MessagePack-CSharp(たぶんこれが一番速いと思います、のやつ)
速くて使いやすいシリアライザに絞って確認しようと思ったら、@neueccさんのものばっかりになってしまって、でもまぁそうなるよなーと思ってこれでいくことにしました。
結果
結論を先に書けと方方からよく聞きますので先に書きます。
Windowsビルド(UWPでない)でのテスト結果(各回ループ10,000回、単位はms)
1回目 | 2回目 | 3回目 | 3回合計 | 3回平均 | |
---|---|---|---|---|---|
non Serialize save | 1383 | 1501 | 1505 | 4389 | 1463 |
(JsonUtility / JSON save) | 252 | 293 | 285 | 830 | 276.666 |
Utf8json / JSON save | 183 | 191 | 194 | 568 | 189.333 |
Utf8json / BASE64 save | 171 | 176 | 181 | 528 | 176 |
ZeroFormatter / BASE64 save | 200 | 184 | 184 | 568 | 189.333 |
MessagePack-CSharp / BASE64 save | 177 | 181 | 185 | 543 | 181 |
MessagePack-CSharp / JSON save | 212 | 214 | 217 | 643 | 214.333 |
non Serialize load | 807 | 1115 | 1080 | 3002 | 1000.666 |
(JsonUtility / JSON load) | 261 | 267 | 275 | 803 | 267.666 |
Utf8json / JSON load | 163 | 166 | 170 | 499 | 166.333 |
Utf8json / BASE64 load | 195 | 191 | 193 | 579 | 193 |
ZeroFormatter / BASE64 load | 182 | 179 | 178 | 539 | 179.666 |
MessagePack-CSharp / BASE64 load | 144 | 146 | 146 | 436 | 145.333 |
MessagePack-CSharp / JSON load | 202 | 207 | 209 | 618 | 206 |
WebGLビルドでのテスト結果(各回ループ10,000回、単位はms)
1回目 | 2回目 | 3回目 | 3回合計 | 3回平均 | |
---|---|---|---|---|---|
non Serialize save | 4502 | 5994 | 6130 | 16626 | 5542 |
(JsonUtility / JSON save) | 917 | 842 | 846 | 2605 | 868.333 |
Utf8json / JSON save | 571 | 686 | 678 | 1935 | 645 |
Utf8json / BASE64 save | 618 | 663 | 659 | 1940 | 646.666 |
ZeroFormatter / BASE64 save | 710 | 628 | 634 | 1972 | 657.333 |
MessagePack-CSharp / BASE64 save | 676 | 659 | 654 | 1989 | 663 |
MessagePack-CSharp / JSON save | 794 | 753 | 744 | 2291 | 763.666 |
non Serialize load | 47 | 45 | 46 | 138 | 46 |
(JsonUtility / JSON load) | 198 | 188 | 188 | 574 | 191.333 |
Utf8json / JSON load | 101 | 100 | 99 | 300 | 100 |
Utf8json / BASE64 load | 119 | 123 | 113 | 355 | 118.333 |
ZeroFormatter / BASE64 load | 80 | 78 | 77 | 235 | 78.333 |
MessagePack-CSharp / BASE64 load | 71 | 66 | 64 | 201 | 67 |
MessagePack-CSharp / JSON load | 164 | 153 | 154 | 471 | 157 |
太字にしている箇所は、save/load別でそれぞれ最速値のものです。
また、JsonUtilityだけ使っているデータクラスの形式が本当に少しだけですが違う(後述)ので、JsonUtilityの結果は参考記録としておきたいです。
まずはWindowsビルドのほうですが、綺麗に傾向が分かれて、save最速はUtf8json / BASE64で、load最速がMessagePack-CSharp / BASE64でした。
non Serializeは期待通りの遅さで、JsonUtilityはだいぶ健闘しているけどもやっぱり遅め、他はどれを選んでもだいたい速いですが、MessagePack-CSharp / BASE64はload最速かつsave速度も2位でバランスが良く総合点は一番高そうです。
WebGLビルドではまた結果が違っていて、non Serializeのloadが最速でこれインメモリのキャッシュがかなり効いているのでは?といった印象です。しかしsaveが死ぬほど遅いのでやはり候補には出来ません。
non Serializeを除くと、JsonUtilityがやはり健闘はしてるけども遅め、次がMessagePack-CSharp / JSON で、その他はどれを選んでもあまり大差が無さそうです。
一番バランスが良いのはZeroFormatter / BASE64 でしょうか?
ただ数値のバラ付きが大きく誤差範囲内の気がするので、好みで選んでもだいたい大丈夫そうです。
という結果になりました。
Windowsビルドのほうがsave/load共に速いのかと思っていましたが、WebGLビルドのほうはsaveが遅くloadが速い傾向があって、これも新たな発見でした。
保存対象クラス(JsonUtility以外)
保存の対象とするクラスですが、速度差が比較出来れば良いので、あんまり複雑にする意味はなく、ただ多少複雑な程度ならそのままシリアライズ/デシリアライズ出来るくらいは確認したい、ということで、クラス内に別クラスを一つ内包する、程度の形を作りました。
using MessagePack;
using ZeroFormatter;
[MessagePackObject]
[ZeroFormattable]
public class Status
{
[Key(0)]
[Index(0)]
public virtual int lv { get; set; }
[Key(1)]
[Index(1)]
public virtual int exp { get; set; }
[Key(2)]
[Index(2)]
public virtual int hp { get; set; }
[Key(3)]
[Index(3)]
public virtual int max_hp { get; set; }
[Key(4)]
[Index(4)]
public virtual int atk { get; set; }
[Key(5)]
[Index(5)]
public virtual int def { get; set; }
[Key(6)]
[Index(6)]
public virtual int spd { get; set; }
[Key(7)]
[Index(7)]
public virtual Equip equip { get; set; }
}
using MessagePack;
using ZeroFormatter;
using ZeroFormatter.Segments;
[MessagePackObject]
[ZeroFormattable]
public class Equip
{
[Key(0)]
[Index(0)]
public virtual string name { get; set; }
[Key(1)]
[Index(1)]
public virtual int atk { get; set; }
[Key(2)]
[Index(2)]
public virtual int def { get; set; }
[Key(3)]
[Index(3)]
public virtual int spd { get; set; }
}
MessegePack-CSharpとZeroFormatterの属性が両方ついています。
また、ZeroFormatterはシリアライズ出来るデータとしてプロパティを要求するため、プロパティとして実装してあります。
@neueccさんのシリアライザは、どれもResolverが型ごとに最適な処理を選んで自動でシリアライズしてくれるようになっているのですが、Utf8jsonの標準のResolverがSystem.Reflection.Emitを利用する設定になっており、UnityのiOSビルドとWebGLビルドはSystem.Reflection.Emitが使われているとUnsupportedエラーで動きません。
ただ、ちゃんとやり方は用意してもらってあって、Utf8Json.UniversalCodeGeneratorを使って事前にPre Code Generationすると、特製Resolverのソースコードを自動生成してくれるので、こちらで生成したコードなら利用可能です。
で、ZeroFormatterもMessagePack-CSharpも類似のResolver自動生成ツールを用意してもらっているので、条件をあわせるためにすべて事前コード生成を利用しています。
その結果がMessegePack-CSharpとZeroFormatterの属性が両方ついているという今回のこの謎コードなのですがテスト用なのでお許しください。
保存対象クラス(JsonUtility)
JsonUtilityはプロパティをシリアライズ対象とすることが出来ないので、プロパティでなくメンバ変数としたクラスも用意しました。
プロパティかメンバ変数かでシリアライズ速度に影響が出るのか定かではありませんが、使ったデータクラスが一応別物なので、JsonUtilityの結果は参考記録とします。
using System;
[Serializable]
public class StatusToJsonUtility
{
public int lv;
public int exp;
public int hp;
public int max_hp;
public int atk;
public int def;
public int spd;
public EquipToJsonUtility equip;
}
using System;
[Serializable]
public class EquipToJsonUtility
{
public string name;
public int atk;
public int def;
public int spd;
}
テストコード
絞ったつもりなんですが、JSON / BASE64 との組み合わせでパターンが増えてしまって長くなってしまいました。
using System;
using UnityEngine;
using UnityEngine.Events;
using Utf8Json;
using MessagePack;
using ZeroFormatter;
public class PlayerPrefsPerformanceTest : MonoBehaviour
{
void Start () {
utf8jsonInit();
messagePackInit();
var status_in = new Status {
lv = 1,
exp = 0,
hp = 10,
max_hp = 10,
atk = 1,
def = 1,
spd = 1,
equip = new Equip {
name = "ぬののふく",
atk = 0,
def = 1,
spd = 0,
},
};
var status_out = new Status {
equip = new Equip()
};
var loop_num = 10000;
var status_in_json_utility = new StatusToJsonUtility {
lv = 1,
exp = 0,
hp = 10,
max_hp = 10,
atk = 1,
def = 1,
spd = 1,
equip = new EquipToJsonUtility {
name = "ぬののふく",
atk = 0,
def = 1,
spd = 0,
},
};
var status_out_json_utility = new StatusToJsonUtility {
equip = new EquipToJsonUtility()
};
//////////// non Serialize test ////////////
timeCheck("non Serialize save", () => {
for (var i = 0; i < loop_num; i += 1) {
PlayerPrefs.SetInt("Status1.lv", status_in.lv);
PlayerPrefs.SetInt("Status1.exp", status_in.exp);
PlayerPrefs.SetInt("Status1.hp", status_in.hp);
PlayerPrefs.SetInt("Status1.max_hp", status_in.max_hp);
PlayerPrefs.SetInt("Status1.atk", status_in.atk);
PlayerPrefs.SetInt("Status1.def", status_in.def);
PlayerPrefs.SetInt("Status1.spd", status_in.spd);
PlayerPrefs.SetString("Equip1.name", status_in.equip.name);
PlayerPrefs.SetInt("Equip1.atk", status_in.equip.atk);
PlayerPrefs.SetInt("Equip1.def", status_in.equip.def);
PlayerPrefs.SetInt("Equip1.spd", status_in.equip.spd);
}
});
timeCheck("non Serialize load", () => {
for (var i = 0; i < loop_num; i += 1) {
status_out.equip.name = PlayerPrefs.GetString("Equip1.name");
status_out.equip.atk = PlayerPrefs.GetInt("Equip1.atk");
status_out.equip.def = PlayerPrefs.GetInt("Equip1.def");
status_out.equip.spd = PlayerPrefs.GetInt("Equip1.spd");
status_out.lv = PlayerPrefs.GetInt("Status1.lv");
status_out.exp = PlayerPrefs.GetInt("Status1.exp");
status_out.hp = PlayerPrefs.GetInt("Status1.hp");
status_out.max_hp = PlayerPrefs.GetInt("Status1.max_hp");
status_out.atk = PlayerPrefs.GetInt("Status1.atk");
status_out.def = PlayerPrefs.GetInt("Status1.def");
status_out.spd = PlayerPrefs.GetInt("Status1.spd");
}
});
equalCheck(status_in, status_out);
//////////// JsonUtility / JSON test ////////////
timeCheck("JsonUtility / JSON save", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = JsonUtility.ToJson(status_in_json_utility);
PlayerPrefs.SetString("jsonUtility_JSON", str);
}
});
timeCheck("JsonUtility / JSON load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("jsonUtility_JSON");
status_out_json_utility = JsonUtility.FromJson<StatusToJsonUtility>(str);
}
});
equalCheckToJsonUtility(status_in_json_utility, status_out_json_utility);
//////////// Utf8json / JSON test ////////////
timeCheck("Utf8json / JSON save", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = JsonSerializer.ToJsonString(status_in);
PlayerPrefs.SetString("Utf8json_json", str);
}
});
timeCheck("Utf8json / JSON load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("Utf8json_json");
status_out = JsonSerializer.Deserialize<Status>(str);
}
});
equalCheck(status_in, status_out);
//////////// Utf8json / BASE64 test ////////////
timeCheck("Utf8json / BASE64 save", () => {
for (var i = 0; i < loop_num; i += 1) {
var bytes = JsonSerializer.Serialize(status_in);
var str = Convert.ToBase64String(bytes);
PlayerPrefs.SetString("Utf8json_base64", str);
}
});
timeCheck("Utf8json / BASE64 load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("Utf8json_base64");
var bytes = Convert.FromBase64String(str);
status_out = JsonSerializer.Deserialize<Status>(bytes);
}
});
equalCheck(status_in, status_out);
//////////// ZeroFormatter / BASE64 test ////////////
timeCheck("ZeroFormatter / BASE64 save", () => {
for (var i = 0; i < loop_num; i += 1) {
var bytes = ZeroFormatterSerializer.Serialize(status_in);
var str = Convert.ToBase64String(bytes);
PlayerPrefs.SetString("ZeroFormatter_BASE64", str);
}
});
timeCheck("ZeroFormatter / BASE64 load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("ZeroFormatter_BASE64");
var bytes = Convert.FromBase64String(str);
status_out = ZeroFormatterSerializer.Deserialize<Status>(bytes);
}
});
equalCheck(status_in, status_out);
/*
//////////// ZeroFormatter / JSON test ////////////
timeCheck("ZeroFormatter / JSON save", () => {
for (var i = 0; i < loop_num; i += 1) {
var bytes = ZeroFormatterSerializer.Serialize(status_in);
var str = JsonUtility.ToJson(bytes);
Debug.Log("str : " + str);
PlayerPrefs.SetString("ZeroFormatter_JSON", str);
}
});
timeCheck("ZeroFormatter / JSON load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("ZeroFormatter_JSON");
var bytes = JsonUtility.FromJson<byte[]>(str);
status_out = ZeroFormatterSerializer.Deserialize<Status>(bytes);
}
});
equalCheck(status_in, status_out);
*/
//////////// MessagePack-CSharp / BASE64 test ////////////
timeCheck("MessagePack-CSharp / BASE64 save", () => {
for (var i = 0; i < loop_num; i += 1) {
var bytes = MessagePackSerializer.Serialize(status_in);
var str = Convert.ToBase64String(bytes);
PlayerPrefs.SetString("MessagePackCS_BASE64", str);
}
});
timeCheck("MessagePack-CSharp / BASE64 load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("MessagePackCS_BASE64");
var bytes = Convert.FromBase64String(str);
status_out = MessagePackSerializer.Deserialize<Status>(bytes);
}
});
equalCheck(status_in, status_out);
//////////// MessagePack-CSharp / JSON test ////////////
timeCheck("MessagePack-CSharp / JSON save", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = MessagePackSerializer.ToJson(status_in);
PlayerPrefs.SetString("MessagePackCS_JSON", str);
}
});
timeCheck("MessagePack-CSharp / JSON load", () => {
for (var i = 0; i < loop_num; i += 1) {
var str = PlayerPrefs.GetString("MessagePackCS_JSON");
var bytes = MessagePackSerializer.FromJson(str);
status_out = MessagePackSerializer.Deserialize<Status>(bytes);
}
});
equalCheck(status_in, status_out);
}
void utf8jsonInit()
{
Utf8Json.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
Utf8Json.Resolvers.GeneratedResolver.Instance
//Utf8Json.Resolvers.StandardResolver.Default
);
}
void messagePackInit()
{
MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
MessagePack.Resolvers.GeneratedResolver.Instance,
MessagePack.Resolvers.BuiltinResolver.Instance,
//AttributeFormatterResolver.Instance,
MessagePack.Resolvers.PrimitiveObjectResolver.Instance
);
}
void equalCheck(Status inStatus, Status outStatus)
{
if (inStatus.lv != outStatus.lv) throw new Exception("Status equal check error : lv");
if (inStatus.exp != outStatus.exp) throw new Exception("Status equal check error : exp");
if (inStatus.hp != outStatus.hp) throw new Exception("Status equal check error : hp");
if (inStatus.max_hp != outStatus.max_hp) throw new Exception("Status equal check error : max_hp");
if (inStatus.atk != outStatus.atk) throw new Exception("Status equal check error : atk");
if (inStatus.def != outStatus.def) throw new Exception("Status equal check error : def");
if (inStatus.spd != outStatus.spd) throw new Exception("Status equal check error : spd");
if (inStatus.equip.name != outStatus.equip.name) throw new Exception("Status equal check error : equip.name");
if (inStatus.equip.atk != outStatus.equip.atk) throw new Exception("Status equal check error : equip.atk");
if (inStatus.equip.def != outStatus.equip.def) throw new Exception("Status equal check error : equip.def");
if (inStatus.equip.spd != outStatus.equip.spd) throw new Exception("Status equal check error : equip.spd");
}
void equalCheckToJsonUtility(StatusToJsonUtility inStatus, StatusToJsonUtility outStatus)
{
if (inStatus.lv != outStatus.lv) throw new Exception("Status equal check error : lv");
if (inStatus.exp != outStatus.exp) throw new Exception("Status equal check error : exp");
if (inStatus.hp != outStatus.hp) throw new Exception("Status equal check error : hp");
if (inStatus.max_hp != outStatus.max_hp) throw new Exception("Status equal check error : max_hp");
if (inStatus.atk != outStatus.atk) throw new Exception("Status equal check error : atk");
if (inStatus.def != outStatus.def) throw new Exception("Status equal check error : def");
if (inStatus.spd != outStatus.spd) throw new Exception("Status equal check error : spd");
if (inStatus.equip.name != outStatus.equip.name) throw new Exception("Status equal check error : equip.name");
if (inStatus.equip.atk != outStatus.equip.atk) throw new Exception("Status equal check error : equip.atk");
if (inStatus.equip.def != outStatus.equip.def) throw new Exception("Status equal check error : equip.def");
if (inStatus.equip.spd != outStatus.equip.spd) throw new Exception("Status equal check error : equip.spd");
}
void timeCheck(string key_name, UnityAction action)
{
var start_time = time();
action();
Debug.Log(key_name + " : " + (time() - start_time).ToString());
}
int time()
{
return DateTime.Now.Hour * 60 * 60 * 1000 +
DateTime.Now.Minute * 60 * 1000 +
DateTime.Now.Second * 1000 +
DateTime.Now.Millisecond;
}
}
テストは、各シリアライザについて、保存処理を10,000回ループして時間計測、取り出し処理を10,000回ループして時間計測、その後に保存前と取り出し後のデータの一致チェック(これは時間計測なし)を行っています。
一致チェックは仮のもので本来は1回ずつで正しい数値が取れていればいいのですが、これでもコードは通るけど結果が正しくないものの抽出に役立ちました。
あとは、ZeroFormatterでシリアライズ後のバイト列をJSON化したかったんですが、これもバイト列からのJSON化のライブラリがうまく探せてなくて無しにしました。バイト列からJSON化はシリアライザとセットじゃないと原理的に出来ない気がするので仕方ないところかなぁとも思います。
参考資料
IndexedDBとWebStorage(localStorageとsessionStorage)のざっくりまとめ - Qiita
[Unity] JsonUtility vs ZeroFormatter vs FlatBuffers (ローカルデータの永続化) - Qiita