📝

Unityでのマスタデータ管理にMasterMemoryを導入する

に公開

背景

先日学生の制作予定作品の中でゲームのデータが膨大になりそうな企画がありました。
これまで軽微なものであればScriptableObjectで対応していましたが、テーブルで管理するようなデータで件数も多いものだと100近くあり、
さすがにその数をScriptableObjectで管理するのはやりづらいなぁ…と。

以前ソシャゲ開発をしていた学生はSQLiteを使ってローカルのテーブルを管理していましたが、
調べていたところ MasterMemory なるものを発見、作者様曰くSQLiteの4700倍速いとのこと。

バイナリとしてデータが管理できるのであればセキュリティ的にも良さそう、
もともとCSVでデータ管理しようとしていたのでそれをバイナリ化して扱えるなら都合も良い、
ということでUnityにMasterMemory導入して色々試してみます。

今回使用しているUnityのバージョンは 6000.0.58f2 です。
(サンプル中ではフォルダ階層が最小限で浅くなるように分類していますので
各々の環境に合わせて各所パス等調整してください)
エディタ拡張を2つ用意しているのでコードが大分長いですが頑張っていきましょう💪

プロジェクトへの導入

プロジェクトへの導入方法はかなりシンプルです。
NuGetForUnityという仕組みを使ってプロジェクトにMasterMemoryを導入します。

まずはPackageManagerを開いて左上の+ボタンを押し、
「Install package from git URL...」を選択します。

URLの入力窓が出るので、以下のURLを入力します。

https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity

Installボタンを押すとインストールが開始します。
インストールが完了すると上部メニューバーに NuGet が追加されますので、
NuGet > Manage NuGet Packagesを選びます。

パッケージの検索ウィンドウが開くので、「MasterMemory」を検索してインストールします。

インストールが完了すると、Assets > Packages下に色々と入ったのが確認できます。

必要なものも全てまとめてインストールされる

これでMasterMemoryを使用する準備が整いました。

テーブル構造定義

テーブルの構造を定義するためのクラスを作成します。
今回は簡単な武器マスタを想定したつくりにしてみます。

Assets/Scripts/MasterData/Data/WeaponData.cs
using MasterMemory;
using MessagePack;

[MemoryTable("Weapon"), MessagePackObject(true)]
public sealed class WeaponData
{
    // 主キーにPrimarykeyアトリビュートをつける
    [PrimaryKey]
    public int Id { get; set; }

    // SecondaryKeyアトリビュートを付けると検索のインデックスを定義できる
    // ()内の数字は重複を避けるための識別子
    [SecondaryKey(0)]
    public string Name { get; set; }
    
    public int Damage { get; set; }
    public int Price { get; set; }
}

MemoryTableアトリビュートの部分でテーブル名をWeaponと指定しています。
また、PrimaryKeyやSecondaryKeyを指定しつつ必要なカラムを列挙することで構造を作っていきます。
このように書くだけで、SourceGeneratorによるコンパイル時に関連するクラスが自動生成されます
(超便利!)

CSVデータの用意

今回は武器マスタ的なものを想定しているので、そのCSVデータを用意します。
1行目にカラム名を、2行目以降にそれぞれのデータを1行ずつ持っている構成です。
今回使ったのは以下のようなファイルです。

Assets/MasterDataCsv/Weapon.csv
Id,Name,Damage,Price
101,普通の剣,60,800
102,普通の杖,70,1000
103,普通の投げナイフ,30,300
104,普通のハンマー,50,1200
105,良さげな剣,260,4000
106,良さげな杖,950,4800
107,良さげな投げナイフ,280,800
108,良さげなハンマー,1000,5050

ひとまず Assets/MasterDataCsv に保存しています。
生成したバイナリデータを Assets/Resources/Binaries に出力する予定です。

バイナリデータ生成

CSVファイルを読み込んでバイナリを生成するためのコードを作成します。
今回はエディタ拡張で、CSVファイルを指定して決まったパスにバイナリを生成できるようにします。
まずCSV読み込み用のクラスを用意します。

TinyCsvReader
Assets/Scripts/MasterData/TinyCsvReader.cs
using System;
using System.Collections.Generic;
using System.IO;

/// <summary> CSV読み込みクラス </summary>
public class TinyCsvReader : IDisposable
{
    static char[] trim = new[] { ' ', '\t' };

    readonly StreamReader _reader;
    public IReadOnlyList<string> Header { get; private set; }

    public TinyCsvReader(StreamReader reader)
    {
        _reader = reader;
        {
            var line = reader.ReadLine();

            if (line == null)
            {
                throw new InvalidOperationException("Header is null.");
            }

            var index = 0;
            var header = new List<string>();

            while (index < line.Length)
            {
                var s = GetValue(line, ref index);

                if (s.Length == 0)
                {
                    break;
                }

                header.Add(s);
            }

            Header = header;
        }
    }

    string GetValue(string line, ref int i)
    {
        var temp = new char[line.Length - i];
        var j = 0;

        for (; i < line.Length; ++i)
        {
            if (line[i] == ',')
            {
                i += 1;
                break;
            }

            temp[j++] = line[i];
        }

        return new string(temp, 0, j).Trim(trim);
    }

    public string[] ReadValues()
    {
        var line = _reader.ReadLine();

        if (line == null || string.IsNullOrWhiteSpace(line))
        {
            return null;
        }

        var values = new string[Header.Count];
        var lineIndex = 0;

        for (int i = 0; i < values.Length; ++i)
        {
            var s = GetValue(line, ref lineIndex);
            values[i] = s;
        }

        return values;
    }

    public Dictionary<string, string> ReadValuesWithHeader()
    {
        var values = ReadValues();

        if (values == null)
        {
            return null;
        }

        var dict = new Dictionary<string, string>();

        for (int i = 0; i < values.Length; ++i)
        {
            dict.Add(Header[i], values[i]);
        }

        return dict;
    }

    public void Dispose()
    {
        _reader.Dispose();
    }
}

バイナリ生成クラス本体はEditorフォルダへ作成しました。

BinaryGenerator
Assets/Editor/BinaryGenerator.cs
using MasterMemory;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

/// <summary> バイナリデータ作成クラス </summary>
public class BinaryGenerator : EditorWindow
{
    TextAsset _csv;

    [MenuItem("Tools/Binary Generator")]
    static void OpenWindow()
    {
        GetWindow<BinaryGenerator>("Binary Generator");
    }

    void OnGUI()
    {
        _csv = (TextAsset)EditorGUILayout.ObjectField(_csv, typeof(TextAsset), true);

        if (GUILayout.Button("Create Binary") && _csv != null)
        {
            Run();
        }
        else if (_csv == null)
        {
            EditorGUILayout.HelpBox("CSVをセットしてください", MessageType.Info);
        }
    }

    void Run()
    {
        var meta = MemoryDatabase.GetMetaDatabase();
        var table = meta.GetTableInfo(_csv.name);
        var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(_csv.text));
        var streamReader = new StreamReader(memoryStream, Encoding.UTF8);
        var reader = new TinyCsvReader(streamReader);
        var tableData = new List<object>();

        while ((reader.ReadValuesWithHeader() is Dictionary<string, string> values))
        {
            // コンストラクタを呼び出さずにデータを作成する
            var data = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(table.DataType);

            foreach (var prop in table.Properties)
            {
                if (values.TryGetValue(prop.Name, out var rawValue))
                {
                    var value = ParseValue(prop.PropertyInfo.PropertyType, rawValue);

                    if (prop.PropertyInfo.SetMethod == null)
                    {
                        throw new Exception("Target property does not exists set method. If yot use { get; }, please change to { get; private set; }, Type:" + prop.PropertyInfo.DeclaringType + "Prop:" + prop.PropertyInfo.Name);
                    }

                    prop.PropertyInfo.SetValue(data, value);
                }
                else
                {
                    throw new Exception($"Not found \"{prop.Name}\" in \"{_csv.name}.csv\" header");
                }
            }

            tableData.Add(data);
        }

        // バイナリデータ生成
        var databaseBuilder = new DatabaseBuilder();
        databaseBuilder.AppendDynamic(table.DataType, tableData);
        var binary = databaseBuilder.Build();

        // できたバイナリは永続化しておく
        var path = $"Assets/Resources/Binaries/{_csv.name}Master.bytes";   // !保存先パスは環境に合わせて変える
        var directory = Path.GetDirectoryName(path);

        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }

        File.WriteAllBytes(path, binary);
        AssetDatabase.Refresh();
    }

    // 読み取ったデータをその型に変換する
    static object ParseValue(Type type, string rawValue)
    {
        if (type == typeof(string))
        {
            return rawValue;
        }
        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            if (string.IsNullOrWhiteSpace(rawValue))
            {
                return null;
            }

            return ParseValue(type.GenericTypeArguments[0], rawValue);
        }
        if (type.IsEnum)
        {
            var value = Enum.Parse(type, rawValue);
            return value;
        }

        switch (Type.GetTypeCode(type))
        {
            case TypeCode.Boolean:
                if (int.TryParse(rawValue, out var intBool))
                {
                    return Convert.ToBoolean(intBool);
                }
                return Boolean.Parse(rawValue);
            case TypeCode.Char:
                return Char.Parse(rawValue);
            case TypeCode.SByte:
                return SByte.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Byte:
                return Byte.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Int16:
                return Int16.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.UInt16:
                return UInt16.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Int32:
                return Int32.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.UInt32:
                return UInt32.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Int64:
                return Int64.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.UInt64:
                return UInt64.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Single:
                return Single.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Double:
                return Double.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Decimal:
                return Decimal.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.DateTime:
                return DateTime.Parse(rawValue, CultureInfo.InvariantCulture);
            default:
                if (type == typeof(DateTimeOffset))
                {
                    return DateTimeOffset.Parse(rawValue, CultureInfo.InvariantCulture);
                }
                else if (type == typeof(TimeSpan))
                {
                    return TimeSpan.Parse(rawValue, CultureInfo.InvariantCulture);
                }
                else if (type == typeof(Guid))
                {
                    return Guid.Parse(rawValue);
                }

                throw new NotSupportedException();
        }
    }
}

公式サンプルからそのまま貰ってきている部分も多いのですが、
ひとまずこれで生成ができるようになるので試してみます。

Unity上部ツールバーに「Tools」が追加されていますので、
「Binary Generator」を選択します。(今はメニューが1つしかない)
バイナリ変換用のウィンドウが開きます。

ここに変換したいCSVファイルをドラッグ&ドロップし(①)
Create Binaryボタンを押すと、
Resources/Binariesに「○○Master」というファイルが生成されます(②)
○○の部分はCSVファイルの名前になります。(▼画像参照)

これでひとまずバイナリの生成が完了しました!

バイナリの一括ロード

後はデータが必要なところで個別に読み込めばいいのですが、
先ほど生成したバイナリにどんなデータが入っているかチェックができていません。

そこで、こちらもエディタ拡張でバイナリデータの中身を取得して表示するツールを用意します。
Editorフォルダにソースコードを追加します。

BinaryLoader
Assets/Editor/BinaryLoader.cs
using MasterMemory;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Unity.VisualScripting;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;

/// <summary> バイナリデータ表示クラス </summary>
public class BinaryLoader : EditorWindow
{
    TreeViewState _treeViewState;
    BinaryDataTreeView _dataTreeView;
    SearchField _searchField;
    TextAsset _binary;

    [MenuItem("Tools/Binary Loader")]
    static void OpenWindow()
    {
        var instance = GetWindow<BinaryLoader>("Binary Loader");
        instance.Init();
    }

    void Init()
    {
        _treeViewState = new TreeViewState();
    }

    void OnGUI()
    {
        _binary = (TextAsset)EditorGUILayout.ObjectField(_binary, typeof(TextAsset), true);

        if (GUILayout.Button("データ表示") && _binary != null)
        {
            GetData();
        }
        else if (_binary == null)
        {
            EditorGUILayout.HelpBox("バイナリデータをセットしてください", MessageType.Info);
        }

        EditorGUILayout.Space(10);

        if (_dataTreeView == null)
        {
            return;
        }

        // 検索
        var searchRect = EditorGUILayout.GetControlRect(false, GUILayout.ExpandWidth(true), GUILayout.Height(EditorGUIUtility.singleLineHeight));
        // _searchFieldがnullの場合のみ代入する
        _searchField ??= new SearchField();
        _dataTreeView.searchString = _searchField.OnGUI(searchRect, _dataTreeView.searchString);

        if (_dataTreeView != null && !_dataTreeView.GetRows().Any())
        {
            EditorGUILayout.LabelField("一致するデータがありません");
        }

        var treeViewRect = EditorGUILayout.GetControlRect(false, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
        _dataTreeView.OnGUI(treeViewRect);
    }

    void GetData()
    {
        // ロード
        var binaryData = _binary.bytes;

        // memoryDatabaseをバイナリから作成
        var memoryDatabase = new MemoryDatabase(binaryData);
        // 作成したバイナリデータは名前にMasterを付けているのでテーブル検索時には取り外す
        var tableObject = MemoryDatabase.GetTable(memoryDatabase, _binary.name.Replace("Master", ""));
        var allData = GetValueByKeyName(tableObject, "All");
        var firstData = GetValueByKeyName(allData, "First");

        // カラム設定
        var dataPropertyInfos = firstData.GetType().GetProperties().ToArray();
        var columns = new List<MultiColumnHeaderState.Column>();

        foreach (var dataPropertyInfo in dataPropertyInfos)
        {
            var column = new MultiColumnHeaderState.Column { headerContent = new GUIContent(dataPropertyInfo.Name) };

            if (dataPropertyInfo.PropertyType == typeof(string))
            {
                // 文字列のカラムの初期幅は長めに調整
                column.width = 150;
            }

            columns.Add(column);
        }

        var state = new MultiColumnHeaderState(columns.ToArray());
        var multiColumnHeader = new MultiColumnHeader(state);
        _dataTreeView = new BinaryDataTreeView(_treeViewState, multiColumnHeader, dataPropertyInfos, tableObject);
    }

    // 指定した名前のPropertyを取得しobject型で返す
    static object GetValueByKeyName(object obj, string name)
    {
        object result = null;
        var objType = obj.GetType();
        var props = objType.GetProperties();

        foreach (var prop in props)
        {
            if (prop.Name.Equals(name))
            {
                result = prop.GetValue(obj);
                break;
            }
        }

        return result;
    }
}

class BinaryDataTreeView : TreeView
{
    PropertyInfo[] _dataPropertyInfos;
    object _datas;

    public BinaryDataTreeView(TreeViewState state, MultiColumnHeader multiColumnHeader, PropertyInfo[] dataPropertyInfos, object datas) : base(state, multiColumnHeader)
    {
        _dataPropertyInfos = dataPropertyInfos;
        _datas = datas;

        showAlternatingRowBackgrounds = true;
        useScrollView = true;
        multiColumnHeader.sortingChanged += OnSortingChanged;

        Reload();
    }

    // ルート要素を構築して返す
    protected override TreeViewItem BuildRoot()
    {
        var root = new TreeViewItem { id = 0, depth = -1, displayName = "Root" };
        var id = 1;
        // 表示したいテーブルデータを取得
        var allProp = _datas.GetType().GetProperty("All");
        var allValue = allProp.GetValue(_datas);
        var enumerable = allValue as IEnumerable;

        // 表示したい要素をDictionary型に格納しList化する
        var items = enumerable.Cast<object>().Select(x =>
        {
            var dict = new Dictionary<string, object>();

            foreach (var prop in _dataPropertyInfos)
            {
                dict[prop.Name] = prop.GetValue(x);
            }

            return (TreeViewItem)new BinaryDataTreeViewItem { id = id++, depth = 0, Data = dict };
        }).ToList();

        SetupParentsAndChildrenFromDepths(root, items);

        return root;
    }

    // 各行の要素を描画
    protected override void RowGUI(RowGUIArgs args)
    {
        if (args.item is BinaryDataTreeViewItem dataTreeViewItem)
        {
            // 要素をdataTreeViewItemから取り出して描画する
            for (var i = 0; i < args.GetNumVisibleColumns(); i++)
            {
                var rect = args.GetCellRect(i);
                var columnIndex = args.GetColumn(i);
                var dataPropertyInfo = _dataPropertyInfos[columnIndex];
                // 表示したいデータがobject型なのでDictionary型にキャストして値を取り出せるようにする
                var list = (Dictionary<string, object>)dataTreeViewItem.Data;
                var data = list[dataPropertyInfo.Name];
                EditorGUI.LabelField(rect, data.ToString(), EditorStyles.wordWrappedLabel);
            }
        }
        else
        {
            base.RowGUI(args);
        }
    }

    // 検索処理
    protected override bool DoesItemMatchSearch(TreeViewItem item, string search)
    {
        var dataTreeViewItem = item as BinaryDataTreeViewItem;

        if (dataTreeViewItem == null)
        {
            return false;
        }

        // いずれかのカラムに指定した文字列が含まれているか判定
        foreach (var dataPropertyInfo in _dataPropertyInfos)
        {
            var list = (Dictionary<string, object>)dataTreeViewItem.Data;
            var data = list[dataPropertyInfo.Name].ToString();

            if (data.Contains(search))
            {
                return true;
            }
        }

        return false;
    }

    // ソート変更時処理
    void OnSortingChanged(MultiColumnHeader multiColumnHeader)
    {
        if (GetRows().Count <= 1)
        {
            return;
        }

        Sort();
    }

    // ソート処理
    void Sort()
    {
        if (multiColumnHeader.sortedColumnIndex == -1 || !multiColumnHeader.state.sortedColumns.Any())
        {
            return;
        }

        var sortedColumn = multiColumnHeader.state.sortedColumns.First();
        var isAscending = multiColumnHeader.IsSortedAscending(sortedColumn);
        var dataTreeViewItems = rootItem.children.Cast<BinaryDataTreeViewItem>();

        if (isAscending)
        {
            rootItem.children = dataTreeViewItems.OrderBy(x =>
            {
                var list = (Dictionary<string, object>)x.Data;
                var data = list[_dataPropertyInfos[sortedColumn].Name];
                return data;
            }).Cast<TreeViewItem>().ToList();
        }
        else
        {
            rootItem.children = dataTreeViewItems.OrderByDescending(x =>
            {
                var list = (Dictionary<string, object>)x.Data;
                var data = list[_dataPropertyInfos[sortedColumn].Name];
                return data;
            }).Cast<TreeViewItem>().ToList();
        }

        var rows = GetRows();
        rows.Clear();
        rows.AddRange(rootItem.children);
        Repaint();
    }
}

class BinaryDataTreeViewItem : TreeViewItem
{
    public object Data { get; set; }
}

こちらもツールバーのToolsから「Binary Loader」を選択します。
バイナリの中身表示用のウィンドウが開きます。
今度はBinariesフォルダにあるバイナリデータをドラッグ&ドロップし、
データ表示ボタンを押すと中身が確認できます!

リストは検索や項目ごとのソートにも対応しています

バイナリの読み込みテスト

最後に実際にシーンの中でバイナリを読み込んでコンソールにログ出力してみます。
まずは汎用でバイナリを読み込むためのクラスを用意します。

Assets/Scripts/MasterData/GetMasterData.cs
using MasterMemory;
using UnityEngine;

// マスターデータの種類を指定するための列挙体
public enum MasterDataType
{
    // !マスターデータが増えたらここに追加
    Weapon,
}

/// <summary> マスターデータ読み込みクラス </summary>
public class GetMasterData
{
    /// <summary> マスターデータを取得 </summary>
    /// <param name="type"> マスターデータタイプ </param>
    public MemoryDatabase GetDatabase(MasterDataType type)
    {
        // Resourcesからロード
        var asset = Resources.Load<TextAsset>("Binaries/" + GetDataName(type));
        var binary = asset.bytes;

        // memoryDatabaseをバイナリから作成
        var memoryDatabase = new MemoryDatabase(binary);

        return memoryDatabase;
    }

    // データ名を取得
    string GetDataName(MasterDataType type)
    {
        switch (type)
        {
            // !マスターデータが増えたらここに追加
            case MasterDataType.Weapon:
                return "WeaponMaster";
            default:
                return "";
        }
    }
}

このクラスを使って、データを取得してみたいと思います。
適当にLoadTestオブジェクトを作り、LoadTestスクリプトをアタッチして検証します。

Assets/Scripts/LoadTest.cs
using MasterMemory;
using UnityEngine;

public class LoadTest : MonoBehaviour
{
    GetMasterData _masterData;  // マスタデータ読み込みクラス
    MemoryDatabase _weaponMaster;

    void Start()
    {
        _masterData = new GetMasterData();

        // MemoryDatabase型の変数にマスタデータを読み込んでおく
        _weaponMaster = _masterData.GetDatabase(MasterDataType.Weapon);

        // 武器IDでデータ取得
        var weaponData1 = _weaponMaster.WeaponDataTable.FindById(101);
        // 武器名でデータ取得
        var weaponData2 = _weaponMaster.WeaponDataTable.FindByName("普通の杖");
        // 武器ID範囲取得(105~108)
        var weaponData3 = _weaponMaster.WeaponDataTable.FindRangeById(105, 108);

        // コンソールに出力テスト
        Debug.Log(weaponData1.Name);
        Debug.Log(weaponData2.Id);
        foreach (var data in weaponData3)
        {
            Debug.Log($"{data.Name}");
        }
    }
}


実行結果
PrimaryKeyに指定したIdで検索をかけるFindByIdメソッド、
SecondaryKeyに指定したNameで検索をかけるFindByNameメソッド、
武器IDの範囲で複数検索をかけるFindRangeByIdメソッドなどを試しいずれも取得できていることが分かりました。

PrimaryKeyはもちろん、SecondaryKeyにも対応したメソッドが用意されます。
SecondaryKeyは複合キーにすることもでき、
使いこなせるともっと便利な検索メソッドも作れそうです。

メソッドは他にもデータが存在するかを確かめるためのTryFindBy○○というものや、
FindClosestBy○○という近似値を探す(?)ようなものもありました。

まとめ

かなりざっくりではありましたが、一通り要素を試した形になります。
設定の手間はあるものの、データをバイナリで扱えるのはメリットだと思います。

今回は軽微なサンプルのためResourcesフォルダにバイナリを配置しましたが、
Resourcesフォルダ自体は非推奨になっているのでいずれはAddressablesなどに置き換えたいですね。

参考記事

https://github.com/Cysharp/MasterMemory
https://neue.cc/2024/12/20_mastermemory_v3.html
https://light11.hatenadiary.com/entry/2025/04/09/195837
https://zenn.dev/yasunomono/articles/yasunomono_20250402

ありがとうございました🙇

Discussion