[Unity]パーティクルのテクスチャをAtlas化してドローコールを抑えるツール

## はじめに

パーティクルを作っていると、つい大量のテクスチャを使ってしまいドローコールがやばいことになることがあります。
そんなとき同じシェーダーであればテクスチャを1枚にまとめる(Atlas化する)ことによってドローコールを削減することが可能です。

## 設定方法

Atlas化したテクスチャの一部分のみを使うにはパーティクル側で Texture Sheet Animation の設定が必要です。
本来はアニメーション用の機能ですが、フレームを固定することによりテクスチャの一部分を使う機能としてみなすというわけです。

まずは共通で以下の設定を行います。

* Mode = Grid
* Animation = Single Row
* Row Mode = Custom
* Time Mode = Lifetime
* Frame over Time = 右側の矢印から Constant を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368570/c8e08e8c-252e-0782-a507-49875dd73752.png)

`Mode = Grid` は1枚のテクスチャを格子状に区切ってアニメーションさせる設定です。
ここで `Animation = Single Row, Row Mode = Custom` とすることで特定の1行のみを使うようにできます。
更に `Time Mode = Lifetime, Frame over Time = Constant` とすることで特定のフレームで固定します(つまり特定の列で固定)。

## 設定例

例として各マスが1pxの8×8サイズの画像で説明します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368570/be75223b-71ac-7c93-ac2f-379c1e696fd4.png)

### 赤い部分

全体の大きさに対して、縦横それぞれが1/2サイズなので `Tiles = (2, 2)` と設定します。
(画像を2×2サイズの格子として使うという意味)
赤い部分は2×2サイズの格子うち0行目の0列目の位置にあるので `Row = 0, Frame over Time = 0` と設定します。

### 青い部分

今度は1/8サイズなので `Tiles = (8, 8), Row = 2, Frame over Time = 6` です。

### 黄色い部分

縦横のサイズが異なっていても設定可能です。
この場合、`Tiles = (2, 4), Row = 3, Frame over Time = 1` とします。

## 自動化

以上のように設定すれば良いのですが、テクスチャをAtlas化するのも設定を適用するのも手作業でやるとめちゃくちゃ辛いのでツールで自動化しましょう。
[Texture2D.PackTextures](https://docs.unity3d.com/jp/460/ScriptReference/Texture2D.PackTextures.html) という画像をまとめるためのAPIが用意されているのでこれを使います。
戻り値で各画像がどこに配置されたかが得られるので、この情報を元にパーティクルに設定してやりましょう。
なお、格子に区切ったときに整数位置に配置されていないと設定できないので、テクスチャは2のべき乗サイズしか使えません。

```csharp:ParticlePacker.cs
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// パーティクルで使われているTextureをパックする
/// </summary>
public static class ParticlePacker
{
    /// <summary>
    /// シーン内の全てのパーティクルに対して実行する
    /// ※シーン内のオブジェクトが参照しているPrefabを含む
    /// </summary>
    [MenuItem("Tools/ParticlePacker/Execute(Scene)")]
    public static void ExecuteScene()
    {
        ExecuteCore(Resources.FindObjectsOfTypeAll<ParticleSystem>());
    }

    /// <summary>
    /// 選択している全てのオブジェクトの子孫要素のパーティクルに対して実行する
    /// </summary>
    [MenuItem("Tools/ParticlePacker/Execute(Selection)")]
    public static void ExecuteSelection()
    {
        ExecuteCore(Selection.gameObjects.SelectMany(o => o.GetComponentsInChildren<ParticleSystem>(includeInactive: true)).Distinct());
    }

    /// <summary>
    /// 実行処理本体
    /// </summary>
    /// <param name="particles">対象のパーティクル</param>
    private static void ExecuteCore(IEnumerable<ParticleSystem> particles)
    {
        try
        {
            var i = 0;
            var progress = 0f;
            void UpdateProgressBar(string text)
                => EditorUtility.DisplayProgressBar(nameof(ParticlePacker), text, progress);

            UpdateProgressBar("オブジェクトを収集中...");

            // textureSheetAnimation を使っているもの、組み込み texture を使っているものは除外
            var targets = particles.Select(p => new Data(p))
                .Where(t => !t.Particle.textureSheetAnimation.enabled && t.Importer != null)
                .ToArray();

            // shader ごとにまとめる
            foreach (var group in targets.GroupBy(p => p.Renderer.sharedMaterial.shader))
            {
                // texture を readable, 2のべき乗 に変更
                foreach (var t in group)
                {
                    progress = (float)i++ / targets.Length;
                    UpdateProgressBar($"テクスチャ設定を変更中...({i}/{targets.Length})");

                    var changed = false;
                    if (!t.Importer.isReadable)
                    {
                        t.Importer.isReadable = true;
                        changed = true;
                    }
                    if (t.Importer.npotScale == TextureImporterNPOTScale.None)
                    {
                        t.Importer.npotScale = TextureImporterNPOTScale.ToNearest;
                        changed = true;
                    }
                    if (changed) t.Importer.SaveAndReimport();
                }

                UpdateProgressBar("テクスチャパッキング中...");

                var tex = new Texture2D(1, 1);
                var packInfos = tex.PackTextures(group.Select(p => p.Texture).ToArray(), padding: 0, maximumAtlasSize: 4096);
                var fileName = "Assets/" + group.Key.name.Replace("/", "-") + "_packed.png";
                File.WriteAllBytes(fileName, tex.EncodeToPNG());
                AssetDatabase.ImportAsset(fileName);

                UpdateProgressBar("マテリアル生成中...");

                var mat = new Material(group.Key);
                mat.mainTexture = tex;
                fileName = fileName.Replace(".png", ".mat");
                AssetDatabase.CreateAsset(mat, fileName);
                AssetDatabase.ImportAsset(fileName);

                UpdateProgressBar("マテリアル適用中...");

                group.Zip(packInfos, (p, packInfo) =>
                {
                    // x,y を固定して使うための設定
                    var sheet = p.Particle.textureSheetAnimation;
                    sheet.enabled = true;
                    sheet.mode = ParticleSystemAnimationMode.Grid;
                    sheet.animation = ParticleSystemAnimationType.SingleRow;
                    sheet.rowMode = ParticleSystemAnimationRowMode.Custom;
                    sheet.timeMode = ParticleSystemAnimationTimeMode.Lifetime;
                    sheet.startFrame = 0;

                    // サイズ、位置を指定
                    sheet.numTilesX = Mathf.RoundToInt(1 / packInfo.width);
                    sheet.numTilesY = Mathf.RoundToInt(1 / packInfo.height);
                    sheet.rowIndex = sheet.numTilesY - Mathf.RoundToInt(sheet.numTilesY * packInfo.yMin) - 1;
                    sheet.frameOverTime = packInfo.xMin;

                    p.Renderer.sharedMaterial = mat;
                    return p;
                }).ToArray();
            }
        }
        finally
        {
            EditorUtility.ClearProgressBar();
        }
    }

    /// <summary>
    /// パーティクルと関連データをまとめたデータ
    /// </summary>
    private class Data
    {
        public ParticleSystem Particle { get; }
        public ParticleSystemRenderer Renderer { get; }
        public Texture2D Texture { get; }
        public TextureImporter Importer { get; }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="particle">パーティクル本体</param>
        public Data(ParticleSystem particle)
        {
            Particle = particle;
            Renderer = particle.GetComponent<ParticleSystemRenderer>();
            Texture = Renderer.sharedMaterial.mainTexture as Texture2D;
            Importer = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(Texture)) as TextureImporter;
        }
    }
}

```

ぜひバックアップしてからお試しください。