ルールベースで Texture / SpriteAtlas のインポート設定を自動化するエディタ拡張の設計と実装

目次

はじめに

サムザップの Unity 向けアプリケーション基盤『Spica』では、開発を効率化するためのエディタ拡張をいくつも整備しています。今回はそのひとつ、Spica.TextureImportRuler にフォーカスし、設計判断と内部実装を実コードを交えて掘り下げます。

Spica.TextureImportRuler は、ユーザーが定義したルールに基づいて Texture / SpriteAtlas の Importer 設定を自動適用するエディタ拡張です。アセットパス・拡張子・TextureType などのフィルタを組み合わせてルールを作り、インポート時に AssetPostprocessor 経由で自動適用します。プラットフォームごとの上書きや、ソースサイズに対する比率での MaxSize 指定にも対応しています。

この記事では、次のテーマを扱います。

  • なぜ作ったのか
  • ルールとマッチ判定の仕組み
  • OverrideValue による安全な部分上書き
  • プラットフォーム別ルールと MaxSize の Ratio モード
  • SpriteAtlas への対応
  • Sprite Mesh Type の自動切替
  • AssetPostprocessor による自動適用とパフォーマンス設計
  • Rule Viewer によるマッチ結果の可視化

なぜ作ったのか

テクスチャのインポート設定は、プロジェクトが大きくなるほど管理しづらくなります。「UI テクスチャは Mip Map を切る」「法線マップは sRGB を切る」「iOS と Android で MaxSize を変える」といったルールは頭の中にはあっても、実際のアセットへ手作業で反映していくと必ず設定漏れやばらつきが生まれます。

Unity には Preset Manager という仕組みがありますが、これは基本的に「新規インポート時」にしか効きません。既存アセットの設定を後から揃えたい、リネームやフォルダ移動でマッチすべきルールが変わったときに追従したい、といった要件には対応しづらいのです。

自作に踏み切ったもうひとつの具体的な動機が、MaxSize の指定方法でした。Unity 標準の MaxSize は 2048 や 4096 といった2の累乗値から選びます。しかし私たちが求めていたのは、2の累乗ではなく「ソース画像の元サイズからの相対値」でサイズを変える仕組みでした。背景にあったのは、同じテクスチャを PC 版では 4K UHD、モバイル版では FHD で扱いたいという要件です。元サイズがばらばらなアセット群へ一律の固定値を当てると、過剰に大きいものや劣化しすぎるものが出てしまいます。ソースの最大辺に対する比率で MaxSize を決められれば、各アセットを元の解像度に比例して縮小でき、この要件を素直に満たせます。

そこで Spica.TextureImportRuler では、ルールをプロジェクトに定義しておき、インポート・移動を検知して自動で設定を適用する方針を採りました。ルールの定義・マッチ確認・適用をすべて専用ウィンドウで完結でき、既存アセットにも後追いで適用できる構成です。あわせて、上で触れた比率ベースの MaxSize(Ratio モード)も、この段階から実現したかった中心的な要件でした。

全体像

Spica.TextureImportRuler は、次の3つの要素で構成されています。

  • Rule Editor: ルールを定義・編集するメインウィンドウ
  • Rule Viewer: ルールがどのアセットにマッチするかを確認するウィンドウ
  • AssetPostprocessor: インポート・移動を検知してルールを自動適用する仕組み

Rule Editor は、左ペインにルール一覧、右ペインに選択中ルールの詳細を表示する2ペイン構成です。上部のタブで Texture / SpriteAtlas を切り替え、それぞれ独立したルールリストを編集します。

Rule Editor ウィンドウ

なお、この Rule Editor の UI 設計(左にリスト、右に詳細という2ペイン構成やルールの持ち方)は、CyberAgent が公開している CyberAgentGameEntertainment/SmartAddresser の設計思想を参考にしています。SmartAddresser は Addressable のアドレスやラベルをルールベースで自動設定する OSS で、「ルールを一覧で並べ、選択したルールの詳細を隣で編集する」という操作感が、インポート設定の管理とも非常に相性が良かったためです。

ルールとマッチ判定の仕組み

ルールは「複数 Scope の OR」「各 Scope 内フィルタの AND」で判定されます。1つの Scope は複数のフィルタを AND で束ね、1つのルールは複数の Scope を OR で持ちます。

Scope の判定はシンプルで、全フィルタを AND で評価し、フィルタが0件の場合は「何にもマッチしない」と扱います。

public bool Contains(string assetPath, Type assetType, bool isFolder)
{
    if (_filters.Count == 0) return false;

    for (int i = 0, count = _filters.Count; i < count; i++)
    {
        var filter = _filters[i];
        if (filter == null) return false;
        if (!filter.IsMatch(assetPath, assetType, isFolder)) return false;
    }

    return true;
}

フィルタ0件の Scope を意図的に false にしているのは、誤適用を防ぐためです。「フィルタを付け忘れた Scope が全アセットにマッチしてしまう」という事故を、安全側に倒して防いでいます。

早い者勝ちの優先度

ルールリストは上から順に評価され、最初にマッチしたルールだけが適用されます。ルールの並び順がそのまま優先度になるため、汎用的なルールは下、特定パスにだけ効かせたいルールは上に置く、という直感的な運用ができます。

public bool TryGetMatchedRule(
    string assetPath, Type assetType, bool isFolder,
    out TextureImportRuleAsset rule)
{
    for (int i = 0, count = _orderedRules.Count; i < count; i++)
    {
        var r = _orderedRules[i];
        if (r == null) continue;
        if (r.Matches(assetPath, assetType, isFolder))
        {
            rule = r;
            return true;
        }
    }
    rule = null;
    return false;
}

フィルタの種類

Scope に追加できる組み込みフィルタは次の4種類です。[ScopeFilter] 属性で Texture / SpriteAtlas のどちら向けかが管理され、適切なタブのメニューにのみ表示されます。

  • Asset Path Filter: アセットパスを正規表現でマッチング(複数指定は OR)
  • Extension Filter: 拡張子でマッチング
  • Texture Type Filter: TextureImporter.textureType(Default / Sprite / NormalMap など)でマッチング(Texture のみ)
  • SpriteAtlas Type Filter: Master / Variant でマッチング(SpriteAtlas のみ)

なかでも Asset Path Filter は正規表現を扱うため、書き方によっては catastrophic backtracking でエディタがフリーズしかねません。そこで、正規表現には 100ms のタイムアウトを設定しています。

private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100);

// Setup 時にタイムアウト付きでコンパイル
_compiledRegexes.Add(new Regex(pattern, RegexOptions.None, RegexTimeout));
try
{
    // ... IsMatch 評価 ...
    return matched ^ _invertMatch;
}
catch (RegexMatchTimeoutException)
{
    // バックトラック爆発などでタイムアウトした場合は不一致扱いにする。
    return _invertMatch;
}

タイムアウトしたパスは不一致扱いになるため、重いパターンを書いてもエディタが固まることはありません。

OverrideValue による安全な部分上書き

このパッケージの中心的な設計判断が、OverrideValue<T> による部分上書きです。Importer の各設定項目は「上書きするか」のトグルと値のペアで保持されており、トグルが OFF の項目は既存の設定をそのまま維持します。

[Serializable]
public sealed class OverrideValue<T> where T : struct
{
    [SerializeField] private string _label;
    [SerializeField] private bool _isOverride;
    [SerializeField] private T _value;

    /// <summary>
    /// IsOverride が真かつ targetValue と Value が異なる場合のみ setter を実行する。
    /// </summary>
    public bool TryApply(T targetValue, Action<T> setter)
    {
        if (!_isOverride) return false;
        if (setter == null) return false;
        if (EqualityComparer<T>.Default.Equals(targetValue, _value)) return false;

        setter(_value);
        return true;
    }
}

TryApply は、上書きが有効かつ現在値と異なる場合にのみ setter を呼びます。これにより「Max Size だけ揃えたい」「sRGB だけ強制したい」といった部分上書きが安全に行えます。値が変わったときだけ書き戻すため、不要な再インポートも避けられます。

Settings タブでは、各項目のトグルで上書き対象を選択し、画面下部のプラットフォームタブで個別の値を設定します。

Rule Editor の Settings タブ

プラットフォーム別ルールと MaxSize の Ratio モード

各ルールは Default / Standalone / iOS / Android の4プラットフォームぶんの設定を保持します。Unity 標準の Importer と同じく、Default は常時有効、それ以外は Override トグルが ON のときだけ適用されます。

適用ロジックは Unity 標準の表示条件分岐を踏襲しており、「UI 上で操作できる項目だけを適用する」という方針で統一しています。

// Default 以外は Override トグルを見て、OFF なら以降をスキップ
if (ShouldApplyOverridden(isDefaultPlatform))
{
    changed |= _overridden.TryApply(settings.overridden, t => settings.overridden = t);
    if (!_overridden.Value) return changed;
}

if (_maxTextureSize.IsOverride)
{
    var resolved = _maxTextureSize.Value.Resolve(sourceMaxSide);
    if (settings.maxTextureSize != resolved)
    {
        settings.maxTextureSize = resolved;
        changed = true;
    }
}

MaxSize には、固定値で指定する Absolute に加えて、Ratio モードがあります。Ratio はソース画像の最大辺に対する比率で MaxSize を指定するもので、Resolve(sourceMaxSide) の中で各テクスチャごとに実際のサイズが計算されます(最終的な値は最小32にクランプされます)。

たとえば「ソース解像度を半分にする」というルールを Ratio = 0.5 で組んでおけば、2048 のテクスチャは 1024、1024 のテクスチャは 512 というように、アセットごとの元サイズへ応じた MaxSize が自動で決まります。固定値では難しい「元の大きさに比例した圧縮」を、ルール1つで表現できます。

なお、Ratio モードで計算された MaxSize が2の累乗にならなかった場合、Inspector の Max Size ドロップダウンには値が表示されません(Unity の UI は2の累乗の選択肢しか持たないためです)。ただしその値自体は内部で保持されており、インポート時には計算どおりのサイズが適用されます。

SpriteAtlas への対応

Spica.TextureImportRuler は、通常のテクスチャだけでなく SpriteAtlas(V2)も別系統のルールとして管理できます。Texture タブと SpriteAtlas タブはそれぞれ独立したルールリストを持ち、別々のコンテナアセットに保存されます。

SpriteAtlas タブでは、SpriteAtlas 専用のフィルタや設定項目が表示されます。具体的には、Master / Variant を判定する SpriteAtlas Type Filter や、SpriteAtlas Importer 固有の設定項目(Allow Rotation / Tight Packing / Padding など)です。

SpriteAtlas Rules の Scopes タブ

SpriteAtlas Rules の Settings タブ

なお、SpriteAtlas はパック後のサイズが可変でソースサイズの概念が曖昧なため、SpriteAtlas ルールでは MaxSize の Ratio モードは選択できず、Absolute 値のみを扱います。

Sprite Mesh Type の自動切替

地味ながら気に入っている独自機能が、Sprite の Mesh Type の自動切替です。Texture ルールの Mesh Type には、Unity 標準の Full Rect / Tight に加えて Tight Unless Bordered を選べます。

public enum SpriteMeshTypeRule
{
    FullRect = 0,
    Tight = 1,

    /// <summary>
    /// 基本は Tight だが、Sprite に Border が設定されている場合は FullRect に切り替える。
    /// 9-slice 用の Border が引かれたタイミングで MeshType を自動追従させたいときに使う。
    /// </summary>
    TightUnlessBordered = 2,
}

Tight Unless Bordered を選ぶと、インポート時に Sprite の Border を確認し、いずれかが非ゼロなら Full Rect、すべて0なら Tight を自動適用します。9-slice 用にデザイナが Border を引いた瞬間、再インポートで自動的に Full Rect に切り替わるため、Mesh Type の手動更新を省略できます。

enum の数値を Unity 標準の SpriteMeshType(FullRect=0 / Tight=1)と揃えてある点も工夫のひとつで、既存アセットに保存済みの値をマイグレーションなしで読み戻せるようにしています。

AssetPostprocessor による自動適用

ルールの自動適用は TextureImportPostprocessor が担います。OnPostprocessAllAssets でインポート・移動を検知し、マッチするルールを適用します。

private static void OnPostprocessAllAssets(
    string[] importedAssets,
    string[] deletedAssets,
    string[] movedAssets,
    string[] movedFromAssetPaths)
{
    if (_isProcessing) return;
    if (importedAssets.Length == 0 && movedAssets.Length == 0) return;

    // ApplyXxxRule 内の SaveAndReimport が再帰的な OnPostprocessAllAssets を発火するため、
    // delayCall + _isProcessing で再入を防止する。
    EditorApplication.delayCall += () =>
    {
        if (_isProcessing) return;
        try
        {
            _isProcessing = true;
            Process(importedAssets, movedAssets);
        }
        finally
        {
            _isProcessing = false;
        }
    };
}

ポイントは2つあります。

1つ目は、movedAssets も処理対象に含めていることです。リネームやフォルダ移動では Unity はテクスチャを再インポートしませんが、移動先のパスでマッチするルールが変わるケースを拾うため、移動も適用対象にしています。

2つ目は、再入防止です。ルール適用の中で呼ぶ SaveAndReimport が再帰的に OnPostprocessAllAssets を発火させてしまうため、delayCall と _isProcessing フラグの組み合わせで多重実行を防いでいます。Domain Reload が無効な環境でフラグが固着しても大丈夫なよう、エディタ起動・再コンパイル時にフラグをリセットする保険も入れています。

一時的に自動適用を止めたい場合は、Scripting Define Symbols に SPICA_TEXTURE_IMPORT_RULER_DISABLE_POSTPROCESS を追加すれば AssetPostprocessor 全体を無効化できます。

パフォーマンスを意識した設計

ルールが数百件になっても軽快に編集できるよう、アセット構造にも工夫を入れています。

ルール本体は TextureImportRulerSettings から分離し、TextureRulesContainer / SpriteAtlasRulesContainer という専用コンテナに保存します。さらに、ルール1件ごとを個別の ScriptableObject(Subasset)としてコンテナへぶら下げる構造にしています。

public TextureImportRuleAsset CreateRule(string undoName = "Add Texture Rule")
{
    var rule = ScriptableObject.CreateInstance<TextureImportRuleAsset>();
    rule.Initialize();

    Undo.RegisterCompleteObjectUndo(this, undoName);
    Undo.RegisterCreatedObjectUndo(rule, undoName);
    AssetDatabase.AddObjectToAsset(rule, this);
    _orderedRules.Add(rule);

    EditorUtility.SetDirty(this);
    AssetDatabase.SaveAssetIfDirty(this);
    return rule;
}

ルール1件が独立した Subasset なので、編集時に SerializedObject の対象となるのは「選択中の1件」だけで済みます。全ルールをまとめて1アセットに持つ構造だと、ルールが増えるほど編集・Undo・保存のコストが膨らみますが、この分離によってルール数に依存しない軽量な編集経路を実現しています。

もうひとつの工夫が、設定アセットの自動解決です。TextureImportRulerSettings は初回参照時に自動生成され、以降は AssetDatabase.FindAssets による型検索でシングルトン的に解決されます。

private static T FindSingleAssetByType<T>() where T : ScriptableObject
{
    var guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}");
    if (guids.Length == 0) return null;
    // ... 複数見つかった場合は警告して先頭を採用 ...
    return AssetDatabase.LoadAssetAtPath<T>(AssetDatabase.GUIDToAssetPath(guids[0]));
}

固定パスに縛られないため、ユーザーがアセットを任意のフォルダへ移動・リネームしても動作します。プロジェクトごとの都合でフォルダ構成を変えても壊れない設計です。

Rule Viewer でマッチ結果を確認する

ルールを書いたら、それが意図どおりのアセットにマッチしているかを確認したくなります。それを担うのが Rule Viewer です。

Rule Viewer は、現在のルール群がプロジェクトのどのアセットにマッチするかを2ペインで一覧表示します。左ペインにルールとマッチ件数、右ペインに該当アセットの一覧が並びます。

Rule Viewer ウィンドウ

ここで特に役立つのが、次の2つの検出です。

  • Conflict(多重マッチ): 1つのアセットが複数のルールにマッチしている状態。早い者勝ちで片方しか適用されないため、意図しない優先順位になっていないかを確認できる
  • No Match: どのルールにも該当しないアセット。末尾の (No Match) 仮想行にまとめて表示され、設定漏れの発見に使える

適用前にここでマッチ状況を可視化できるため、「ルールを書いたが実は効いていなかった」「別のルールが先に拾っていた」といった事故を、再インポートを走らせる前に防げます。プロジェクト全体をスキャンする対象範囲(All / Selection)や、除外パス(Exclude Paths)による対象外設定もこのウィンドウから扱えます。

おわりに

この記事では、Spica.TextureImportRuler の設計と実装を紹介しました。設計のキーポイントを改めてまとめます。

  • ルール駆動で標準化: Scope(OR)/ Filter(AND)の複合条件で、インポート設定の適用対象を柔軟に定義できる
  • OverrideValue による部分上書き: 項目単位で上書き可否を選び、差分があるときだけ書き戻すことで安全・低コストな適用を実現した
  • プラットフォーム別 + Ratio モード: 元サイズに比例した MaxSize など、固定値では表現しづらいルールも組める
  • Tight Unless Bordered: Border の有無で Mesh Type を自動追従させ、手動更新を省いた
  • Subasset 構造: ルール数に依存しない軽量な編集経路を確保した
  • Rule Viewer による可視化: Conflict / No Match を適用前に確認でき、設定事故を未然に防げる

繰り返し発生しがちな「テクスチャのインポート設定を揃える」という作業をルールへ寄せることで、設定漏れやばらつきを仕組みで抑えられる構成になっています。


s07452
中北 龍秀

株式会社サムザップ クライアントエンジニア 2019年入社
『呪術廻戦 ファントムパレード』や『真 戦国炎舞 -KIZNA-』の開発を経て
現在は開発推進室のアプリケーション基盤と新規プロジェクトの開発を担当