はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】Scripted Importerを使ってUnityが対応していない拡張子のファイルに対応する(テキスト暗号化も)

はじめに

今回はScripted Importerについて紹介したいと思います。

Scripted Importer は Unity スクリプティング API の一部です。Scripted Importer を使用すると C# でカスタムアセットインポーターを作成できます。これにより、Unity でネイティブにサポートされていないファイル形式の独自のサポートを加えることができます。

Scripted Importer - Unity マニュアル

youtu.be

やり方

以下の2つを満たしたクラスを作成します。

  • 抽象クラスScriptedImporterの継承
  • ScriptedImporter属性をつける
// 「.sample」という拡張子に対応,バージョンは「1」
[ScriptedImporter(version: 1, ext: "sample")]
public class SampleImporter : ScriptedImporter
{
    public override void OnImportAsset(AssetImportContext ctx)
    {
        // 登録された拡張子に一致するファイルがAssetPipelineによって検知された時に呼ばれる
    }
}

OnImportAssetの引数にあるAssetImportContextはインポートの入出力情報が含まれます。

Defines the import context for scripted importers during an import event.

This class carries both input and output information for the OnImportAsset() task.

// Google翻訳
インポート イベント中のスクリプト化されたインポーターのインポート コンテキストを定義します。

このクラスは、OnImportAsset() タスクの入力情報と出力情報の両方を運びます。

AssetImporters.AssetImportContext - Unity スクリプトリファレンス

よく使いそうなのは以下あたりでしょうか。

名前 意味
ctx.assetPath インポートされたファイルのパス
ctx.AddObjectToAsset インポート操作の結果にオブジェクトを追加する
ctx.SetMainObject Main Object(Main Asset)を選択する
ctx.DependsOnSourceAsset 依存先のアセットを指定する
ctx.DependsOnCustomDependency 依存先のアセットを指定する(DependsOnSourceAssetとの挙動の違いは未調査)
ctx.selectedBuildTarget どのプラットフォームを対象にしているか
ctx.LogImportWarning 警告をログに出力する
ctx.LogImportError エラーメッセージをログに出力する

AssetImporters.AssetImportContext - Unity スクリプトリファレンス

MainAssetとSubAsset

エディタ拡張をされている方なら馴染みがあるかもしれませんが、UnityのAssetMainAssetSubAssetに分かれます。
Unity - Scripting API: AssetDatabase.IsMainAsset
Unity - Scripting API: AssetDatabase.IsSubAsset

公式ドキュメントに分かりやすいような記載があまり見つけられませんでしたが、Main assets and sub-assetsという説明がありました。(調べた感じ英語のみ存在するページ?)

Because Unity can store multiple serialized objects within the same asset file, Unity has a concept of the main asset within any asset file. When Unity creates asset files that contain a single asset, such as a material, the main asset is always that single asset. For other types containing more than one serialized asset object, the main asset is always the first asset added to the file, unless otherwise specified with the SetMainObject method.

You can sometimes see sub-assets in the Project window of the Editor, if those sub-assets are of certain types. For example, looking at this FBX asset file containing a “Space Frigate” model in the Project window, its view has been expanded to reveal that it has a material and a mesh
as sub-assets.

// Google翻訳
Unity は複数のシリアル化されたオブジェクトを同じアセット ファイル内に格納できるため、Unity には、任意のアセット ファイル内のメイン アセットの概念があります。 Unity がマテリアルなどの単一のアセットを含むアセット ファイルを作成する場合、メインのアセットは常にその単一のアセットです。複数のシリアル化されたアセット オブジェクトを含む他のタイプの場合、SetMainObject メソッドで特に指定しない限り、メイン アセットは常にファイルに追加される最初のアセットです。

サブアセットが特定のタイプである場合、エディタのプロジェクト ウィンドウにサブアセットが表示されることがあります。たとえば、プロジェクト ウィンドウで「Space Frigate」モデルを含むこの FBX アセット ファイルを見ると、ビューが展開されて、マテリアルとメッシュがあることがわかります
サブアセットとして。

Unity - Manual: Customizing the Asset Database workflow

public override void OnImportAsset(AssetImportContext ctx)
{
    // 登録された拡張子に一致するファイルがAssetPipelineによって検知された時に呼ばれる
    var text = File.ReadAllText(ctx.assetPath);
    var textAsset = new TextAsset(text);

    // ↓以下MainAssetとSubAssetの定義
    // 最初にctx.AddObjectToAssetしたオブジェクトがMainAssetとして登録される(ctx.SetMainObjectでMainAssetを順番関係なく指定できる)
    ctx.AddObjectToAsset(identifier: "MainAsset", obj: textAsset);
        
    ctx.AddObjectToAsset("SubAsset1", new TextAsset("SubAsset1"));
    ctx.AddObjectToAsset("SubAsset2", new TextAsset("SubAsset2"));
    ctx.AddObjectToAsset("SubAsset3", new TextAsset("SubAsset3"));
}

MainAssetの下にSubAssetの一覧がProjectビューに表示されます。

MainAssetとSubAsset

このコードは意味をなしていませんが、例えばPrefabMaterialSubAssetとして表示するようにするなんてことに利用できます。

CubeImporterを試した例
[ScriptedImporter(1, "cube")]
public class CubeImporter : ScriptedImporter
{
    public float m_Scale = 1;

    public override void OnImportAsset(AssetImportContext ctx)
    {
        var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
        var position = JsonUtility.FromJson<Vector3>(File.ReadAllText(ctx.assetPath));

        cube.transform.position = position;
        cube.transform.localScale = new Vector3(m_Scale, m_Scale, m_Scale);

        // 'cube' は ゲームオブジェクトで、自動的にプレハブに転換されます
        // ( 'Main Asset' だけがプレハブになります)
        ctx.AddObjectToAsset("main obj", cube);
        ctx.SetMainObject(cube);

        var material = new Material(Shader.Find("Standard"));
        material.color = Color.red;
        
        // NOTE : 公式ドキュメントにこれを追加しました
        cube.GetComponent<Renderer>().material = material;

        // アセットには、インポート内で一貫した固有のID文字列を割り当てられる必要があります
        ctx.AddObjectToAsset("my Material", material);

        // インポート出力としてコンテキストに渡されないアセットは破棄する必要があります
        var tempMesh = new Mesh();
        DestroyImmediate(tempMesh);
    }
}

ScriptedImporter - Unity マニュアル

こちらは公式ドキュメントのコードを一部改変した例です。具体的に変更した箇所は以下の通り。

// NOTE : 公式ドキュメントにこれを追加しました
cube.GetComponent<Renderer>().material = material;

TextAssetの暗号化1(これは間違った手法)

こちらの記事を参考にさせてもらいながら、TextAssetを暗号化してみます。
Unity製アプリにおいてアセットを暗号化する手法 | QualiArtsエンジニアブログ

[ScriptedImporter(version: 1, ext: "sample")]
public class SampleImporter : ScriptedImporter
{
    public override void OnImportAsset(AssetImportContext ctx)
    {
        using var stream = File.OpenRead(ctx.assetPath);
        var textAsset = SampleConverter.Read(stream);

        ctx.AddObjectToAsset(identifier: "MainAsset", obj: textAsset);
    }
}

public static class SampleConverter
{
    public static TextAsset Read(Stream input)
    {
        if (input == null) throw new ArgumentNullException(nameof(input));

        var length = (int)input.Length;
        var target = length <= 1024 ? stackalloc byte[length] : new byte[length];

        input.Read(target);
        input.Dispose();
        Decrypt(target);
        
        return new TextAsset(Encoding.UTF8.GetString(target));
    }

    public static void Write(Stream output, TextAsset textAsset)
        => Write(output, Encoding.UTF8.GetBytes(textAsset.text).AsSpan());

    public static void Write(Stream output, string text)
        => Write(output, Encoding.UTF8.GetBytes(text).AsSpan());
    
    public static void Write(Stream output, Span<byte> bytes)
    {
        if (output == null) throw new ArgumentNullException(nameof(output));
        if (bytes == null) throw new ArgumentNullException(nameof(bytes));
        
        Encrypt(bytes);
        output.Write(bytes);
        output.Dispose();
    }
    
    private static void Encrypt(Span<byte> value)
        => Reverse(value);

    private static void Decrypt(Span<byte> value)
        => Reverse(value);

    private static void Reverse(Span<byte> value)
        => value.Reverse();
}

XORだったりAESだったりと暗号化の仕方はありますが、ひとまず一番簡単そうなバイナリを逆順にする操作をしてみます。

ちなみに.sampleを作成するエディタ拡張も作っておきました。

namespace Editor
{
    public class SampleConverterWindow : EditorWindow
    {
        private string _path;

        [MenuItem("Tools/SampleConverter")]
        private static void Init()
        {
            var instance = GetWindow<SampleConverterWindow>();
            instance._path = Application.dataPath;
            instance.minSize = new Vector2(700, 80);
        }

        private void OnGUI()
        {
            EditorGUILayout.LabelField("Select File", _path);
            if (GUILayout.Button("Select File"))
            {
                var selectedPathName = EditorUtility.OpenFilePanel( "Select File",  "",  "");
                if (!string.IsNullOrEmpty(selectedPathName)) _path = selectedPathName;
            }

            if (GUILayout.Button("Convert .sample"))
            {
                if (!File.Exists(_path)) return;
                
                var target = File.ReadAllBytes(_path);
                var outputPath = Path.ChangeExtension(_path, ".sample");
                using var writer = File.OpenWrite(outputPath);
                SampleConverter.Write(writer, target);

                AssetDatabase.Refresh();
            }
        }
    }
}

この場合test.txttest.sampleの中身・metaは以下のようになります。

ファイルの中身
metaの中身

.sampleの中身はしっかりと暗号化されていますが、Unityエディタ上では同じようにTextAssetとして利用することができます。

Unity上での表記の違い


Asset暗号化の目的はユーザーにファイルの中身を見られないことです。典型的なやり方としてAsset Studioが挙げられるので、それの対策ができているか確認してみます。
【Unity】AssetStudioでUnity製のゲーム・AssetBundleの中身を覗き見・エクスポートする(悪用はしないように) - はなちるのマイノート

AssetStudioの結果

普通に見えてますね・・・・・・。

勝手にFileがビルドに同梱されているのかと勘違いしていましたが、変換後のTextAssetが同梱されるのですね。
確かにScriptedImporterAssembly-CSharp-Editor.dllに含まれるので当然ちゃ当然か...。

TextAssetの暗号化2(これは正しい手法)

暗号化の流れ
namespace Editor
{
    [ScriptedImporter(1, "sample2")]
    public class Sample2Importer : ScriptedImporter
    {
        public override void OnImportAsset(AssetImportContext ctx)
        {
            // 登録された拡張子に一致するファイルがAssetPipelineによって検知された時に呼ばれる
            using var stream = File.OpenRead(ctx.assetPath);
            var textAsset = SampleConverter.Read(stream);
            
            // MainAssetとしてバイナリ逆順にしたTextAssetを登録する
            ctx.AddObjectToAsset("MainAsset", textAsset);
        }
    }
}
インスペクターでの表示

このTextAssetを利用するときは復号化の処理を挟んでからでなければいけません。

例えばインスペクターからTextAssetを参照してそのままテキストとして出力とかしたら、NGです。(複数人で行う場合は要注意)

public class Test : MonoBehaviour
{
    [SerializeField] private Text textObj;
    [SerializeField] private TextAsset textAsset;

    private void Start()
    {
        // NG : textAsset.textを復号化してからでないと暗号化されたままのテキストが表示されてしまう。
        textObj.text = textAsset.text;
    }
}

さいごに

ファイル自体がアプリに含まれるなら結構いいのかなと思ったのですが、正直予想外の使い方がされる確率が高そうであんまり実用的ではないような気がします。

画像データや音楽データなんかもScriptedImporterで暗号化できたらなとか密かに思っていたのですが、AssetBundleにして暗号化が一番無難そうですかね。

何か他に良い手法がないかとかも調べてみれたらと思います。