Amazonで商品を見るセール会場へ

Unityで2D Prefabを一括PNG出力する|SpriteRenderer用Editor拡張

Assets内のフォルダーにあるSpriteRendererで構成した2D Prefabを、1つずつ512×512pxのPNGへ書き出すEditor拡張を掲載します。Canvas上のUIはuGUI Image用の出力手順で条件を分けます。

仕様の参照はUnity 6.3 LTS(6000.3)です。掲載コードの寸法・フレーム・保存名の整形クラスはWindowsの.NETでコンパイルして確認しました。Unityでのコンパイル、カメラ描画、PNG生成、URP・HDRPの動作は未確認です。まずプロジェクトの複製と最小のPrefabで試してください。元記事の画像は旧コードの資料で、今回の修正版を実行した画像ではありません。

目次

対象にするPrefabと出力の仕様

項目・症状条件・確認先
入力Projectで選んだAssets内の1フォルダーと、その子フォルダーの.prefab。複数選択、Scene、Packages、FBXなどのModel Prefabは対象外。
撮影対象ActiveかつenabledのSpriteRenderer。sprite未設定と非Activeのルートはスキップ。ActiveなMesh・Particleなどが混在するPrefabもスキップ。
撮影範囲SpriteRenderer.boundsの外接範囲。正方形に収めるため長辺側へ合わせ、各辺に5%分の余白を加える。透明ピクセルの自動除去ではない。
PNG寸法512×512px。長細いPrefabでは短辺側に余白が残る。
保存名prefab_名前_GUID.png。同名の別PrefabをGUIDで区別し、実行ごとに別の出力フォルダーを作る。
描画方式Built-inはCamera.Render。SRPはStandardRequestのサポートを問い合わせ、未対応ならそのPrefabを失敗として記録。対応回答だけで色・透過・全Shaderの動作は保証しない。

単純な正面向きの2DスプライトとUnlit系のマテリアルを最初の確認対象にします。SpriteMask、独自Shader、照明を必要とするSprite、パーティクル、3D、アニメーションの特定フレームは別の撮影条件が必要です。

次は元記事にあった出力資料です。旧コードとは保存名、対象条件、フレームの計算が変わっています。

選択したフォルダ内のPrefabを一括で画像として書き出すツールの概要。

導入して1Prefabから試す

  1. プロジェクトを複製し、Assets/EditorにSpritePrefabPngExporter.csとして下の全文を保存します。旧PrefabScreenshotまたはUGUIPrefabScreenshotを使っていた場合は、旧メニューと新メニューを取り違えないよう旧スクリプトを外します。同じ新クラスを2つ置かないでください。
  2. asmdefを使っている場合はEditor専用Assemblyへ配置します。Consoleのコンパイルエラーを確認してから進みます。GameObjectへ付けるMonoBehaviourではありません。
  3. 確認用フォルダーに、Spriteが設定された有効なSpriteRendererだけのPrefabを1つ保存します。最初はMaskや独自スクリプトを外した単純なものを使います。
  4. Projectで確認用フォルダーを1つ選び、Tools → CG Method → Export Sprite Prefabs to PNGを実行します。Play Mode中は実行しません。
  5. 保存先には最初はAssetsの外の書込可能なフォルダーを選びます。保存ダイアログの取消ではファイルを作りません。処理中の取消は、それまでに出力したPNGを残して終了します。
  6. ConsoleのOutputパスを開き、候補・処理済み・成功・スキップ・失敗・取消の値を確認します。最小の対象では成功1とPNG1枚を期待します。画像の寸法、上下方向、輪郭切れ、背景のAlpha、色、余白を画像ビューアーで確認してください。
  7. 通常Sceneに別のSpriteやUIを置いたケース、同名Prefab2つ、横長・縦長、非Active、読取・書込失敗を追加します。処理後にSceneやPrefabのdirty状態とConsoleも確認します。

次のUnity画面も旧版の資料です。新しいメニュー名・出力フォルダー・ファイル名は、上の手順とコードに従います。

ツールの実行後に指定フォルダへ画像が正常に出力されたか確認する様子。

修正版コード全文

一時的なPreview SceneにPrefabを複製し、手動撮影用カメラを作ります。対象外は理由を付けてスキップし、個別の失敗はConsoleへ残して次へ進む構成です。AssetDatabase.Refreshは呼びません。Assets内へ保存した場合はEditorの監視によるImportが起きるため、最初はAssets外へ保存します。

// Unity 6.3 API-based sample. Unity compilation/rendering has not been verified.
// Save in Assets/Editor/SpritePrefabPngExporter.cs. Keep only one copy of this class.
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;


public static class SpritePrefabPngExporter
{
    private const int Size = 512;
    private const double Padding = 0.05;

    [MenuItem("Tools/CG Method/Export Sprite Prefabs to PNG")]
    public static void Export()
    {
        if (EditorApplication.isPlayingOrWillChangePlaymode)
            throw new InvalidOperationException("Use this command outside Play Mode.");
        var selected = Selection.objects;
        if (selected.Length != 1)
            throw new InvalidOperationException("Select exactly one Assets folder.");
        string folder = AssetDatabase.GetAssetPath(selected[0]);
        if (!(folder == "Assets" || folder.StartsWith("Assets/", StringComparison.Ordinal)) ||
            !AssetDatabase.IsValidFolder(folder))
            throw new InvalidOperationException("Select a folder below Assets in the Project window.");
        var paths = AssetDatabase.FindAssets("t:Prefab", new[] { folder })
            .Select(AssetDatabase.GUIDToAssetPath)
            .Where(p => p.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
            .Distinct().OrderBy(p => p, StringComparer.Ordinal).ToArray();
        if (paths.Length == 0) { Debug.Log("No .prefab assets found: " + folder); return; }
        string parent = EditorUtility.OpenFolderPanel("Choose PNG output folder", "", "");
        if (string.IsNullOrEmpty(parent)) return;
        string output = Path.Combine(parent, "sprite_" +
            DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(output);
        int success = 0, skipped = 0, failed = 0, processed = 0;
        bool cancelled = false;
        try
        {
            foreach (string path in paths)
            {
                if (EditorUtility.DisplayCancelableProgressBar("Prefab PNG", path,
                    (float)processed / paths.Length)) { cancelled = true; break; }
                try
                {
                    var asset = AssetDatabase.LoadAssetAtPath<GameObject>(path);
                    if (asset == null) throw new InvalidOperationException("Prefab could not be loaded.");
                    string png = Path.Combine(output, SpritePrefabPngExporterFormatting.FileName(
                        Path.GetFileNameWithoutExtension(path), AssetDatabase.AssetPathToGUID(path)));
                    string reason;
                    if (Capture(asset, png, out reason)) success++;
                    else { skipped++; Debug.LogWarning("Skipped " + path + ": " + reason); }
                }
                catch (Exception error) { failed++; Debug.LogError("Failed " + path + ": " + error); }
                finally { processed++; }
            }
        }
        finally
        {
            EditorUtility.ClearProgressBar();
            Debug.Log("Candidates=" + paths.Length + ", processed=" + processed +
                ", success=" + success + ", skipped=" + skipped + ", failed=" + failed +
                ", cancelled=" + cancelled + "\nOutput: " + output);
        }
    }

    private static bool Capture(GameObject asset, string png, out string reason)
    {
        reason = "";
        Scene preview = EditorSceneManager.NewPreviewScene();
        RenderTexture previous = RenderTexture.active;
        RenderTexture target = null;
        Texture2D image = null;
        Camera camera = null;
        try
        {
            var copy = PrefabUtility.InstantiatePrefab(asset, preview) as GameObject;
            if (copy == null) throw new InvalidOperationException("Could not instantiate prefab.");
            if (!copy.activeInHierarchy) { reason = "Inactive prefab root."; return false; }
            foreach (var c in copy.GetComponentsInChildren<Camera>(true)) c.enabled = false;
            // Do not enable hidden objects or execute Play Mode animation.
            Bounds bounds;
            var renderers = copy.GetComponentsInChildren<Renderer>(false)
                .Where(r => r.enabled && r.gameObject.activeInHierarchy).ToArray();
            if (renderers.Any(r => !(r is SpriteRenderer)))
            { reason = "This sample accepts SpriteRenderer-only prefabs, not Mesh/Particle renderers."; return false; }
            var sprites = renderers.OfType<SpriteRenderer>().Where(r => r.sprite != null).ToArray();
            if (sprites.Length == 0) { reason = "No active enabled SpriteRenderer with a sprite."; return false; }
            bounds = sprites[0].bounds;
            foreach (var sprite in sprites.Skip(1)) bounds.Encapsulate(sprite.bounds);
            if (bounds.size.x <= 0 || bounds.size.y <= 0)
            { reason = "Bounds have zero width or height."; return false; }
            var cameraObject = new GameObject("CG Method capture camera");
            SceneManager.MoveGameObjectToScene(cameraObject, preview);
            camera = cameraObject.AddComponent<Camera>();
            camera.enabled = false;
            camera.scene = preview;
            camera.clearFlags = CameraClearFlags.SolidColor;
            camera.backgroundColor = new Color(0, 0, 0, 0);
            camera.orthographic = true;
            camera.allowHDR = false;
            camera.allowMSAA = false;
            camera.cullingMask = ~0;
            int pw, ph;
            pw = ph = Size;
            double framedWidth = bounds.size.x * (1.0 + Padding * 2.0);
            double framedHeight = bounds.size.y * (1.0 + Padding * 2.0);
            camera.aspect = (float)pw / ph;
            camera.orthographicSize = (float)SpritePrefabPngExporterFormatting.HalfHeight(framedWidth, framedHeight, pw, ph);
            camera.transform.SetPositionAndRotation(new Vector3(bounds.center.x,
                bounds.center.y, bounds.min.z - 10f), Quaternion.identity);
            camera.nearClipPlane = 0.01f;
            camera.farClipPlane = Mathf.Max(20f, bounds.size.z + 20f);
            target = new RenderTexture(pw, ph, 24, RenderTextureFormat.ARGB32);
            target.antiAliasing = 1;
            if (!target.Create()) throw new InvalidOperationException("RenderTexture allocation failed.");
            camera.targetTexture = target;
            
            if (GraphicsSettings.currentRenderPipeline == null) camera.Render();
            else
            {
                var request = new RenderPipeline.StandardRequest { destination = target };
                if (!RenderPipeline.SupportsRenderRequest(camera, request))
                    throw new NotSupportedException("The active pipeline does not support StandardRequest. " +
                        "Check pipeline/camera configuration; do not assume SRP or alpha support.");
                RenderPipeline.SubmitRenderRequest(camera, request);
            }
            RenderTexture.active = target;
            image = new Texture2D(pw, ph, TextureFormat.RGBA32, false);
            image.ReadPixels(new Rect(0, 0, pw, ph), 0, 0, false);
            image.Apply(false, false);
            byte[] bytes = image.EncodeToPNG();
            if (bytes == null || bytes.Length == 0) throw new InvalidOperationException("PNG encoding failed.");
            if (!image.GetPixels32().Any(pixel => pixel.a != 0))
                Debug.LogWarning("PNG is fully transparent; inspect rendering conditions: " + png);
            // Publish only a complete file. Do not replace an existing output.
            string temporary = png + ".tmp";
            bool ownsTemporary = false;
            try
            {
                using (var file = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write))
                {
                    ownsTemporary = true;
                    file.Write(bytes, 0, bytes.Length);
                }
                File.Move(temporary, png);
            }
            finally
            {
                if (ownsTemporary && File.Exists(temporary)) File.Delete(temporary);
            }
            Debug.Log("Saved " + pw + "x" + ph + ": " + png);
            return true;
        }
        finally
        {
            RenderTexture.active = previous;
            if (camera != null) camera.targetTexture = null;
            try
            {
                if (image != null) UnityEngine.Object.DestroyImmediate(image);
                if (target != null) { target.Release(); UnityEngine.Object.DestroyImmediate(target); }
            }
            finally
            {
                // Close also if explicit resource cleanup throws.
                if (preview.IsValid() && !EditorSceneManager.ClosePreviewScene(preview))
                    Debug.LogError("Could not close capture preview scene.");
            }
        }
    }
}

public static class SpritePrefabPngExporterFormatting
{
    public static void Dimensions(double width, double height, double density,
        int limit, out int pixelsWide, out int pixelsHigh)
    {
        if (double.IsNaN(width) || double.IsInfinity(width) || width <= 0 ||
            double.IsNaN(height) || double.IsInfinity(height) || height <= 0 ||
            double.IsNaN(density) || double.IsInfinity(density) || density <= 0 || limit < 1)
            throw new ArgumentOutOfRangeException("Positive finite dimensions required.");
        double longest = Math.Max(width, height);
        double scale = Math.Min(density, limit / longest);
        pixelsWide = Math.Max(1, Math.Min(limit, (int)Math.Ceiling(width * scale)));
        pixelsHigh = Math.Max(1, Math.Min(limit, (int)Math.Ceiling(height * scale)));
    }

    public static double HalfHeight(double width, double height, int pw, int ph)
    {
        return Math.Max(height / 2.0, width * ph / (2.0 * pw));
    }

    public static string FileName(string name, string guid)
    {
        if (string.IsNullOrEmpty(guid) || guid.Length != 32 ||
            !guid.All(c => "0123456789abcdefABCDEF".IndexOf(c) >= 0))
            throw new ArgumentException("Expected an AssetDatabase GUID.");
        var safe = new string((name ?? "Prefab").Select(c =>
            c < 32 || "<>:\"/\\|?*".IndexOf(c) >= 0 ? '_' : c).ToArray());
        safe = safe.Trim().TrimEnd('.');
        if (safe.Length == 0) safe = "Prefab";
        if (safe.Length > 64) safe = safe.Substring(0, 64);
        // Prefix also avoids reserved Windows base names such as CON.
        return "prefab_" + safe + "_" + guid.ToLowerInvariant() + ".png";
    }
}

フレームとファイル名を確認した範囲

カメラの半高さは、縦の半分と「横幅÷出力aspect÷2」の大きい方です。512×512pxではaspectが1なので、長い方へ合わせます。画像を引き伸ばす処理ではなく、余白を含めて範囲を収める計算です。orthographicSizeの公式仕様とaspectの仕様を基準にしています。

掲載した2つの整形クラスそのものを.NETでコンパイルし、横長・縦長・正方形・極端な横長・小数・大きな寸法の12ケース、無効な幅8ケース、ファイル名6呼出しを確認しました。異なるGUIDの同名Prefabは別名になり、禁則文字とWindowsの予約名を考慮する形式です。Unity API部分のコンパイルやPNG描画を実行した結果ではありません。長い保存先パスやOSの権限は実環境で確認します。

Scene・描画方式・透過の注意点

NewPreviewSceneとCamera.sceneを使って撮影対象を指定します。finallyでRenderTexture.activeを元へ戻し、カメラのtargetTextureを外し、Texture2D・RenderTextureを破棄してPreview Sceneを閉じます。ReleaseだけではRenderTextureオブジェクトは破棄されません。

元Prefabを保存するAPIは呼びません。ただし、Prefabの複製時に動くEdit Modeの独自スクリプトやEditor処理の副作用まで無変更と保証する例ではありません。初回は複製で、通常Sceneの写り込み、生成物の残留、処理前後のdirty状態を確認してください。

SRPの分岐はStandardRequestとSupportsRenderRequestの仕様を参照したものです。URPではBase Cameraとカメラスタック、HDRPでは出力設定等の条件があります。このサンプルはパイプライン固有の追加設定やLightを用意せず、すべてのレンダラー・Shaderで同じ表示になると保証していません。

背景Alpha 0とRGBA32で読戻す設定を入れていますが、半透明・Alphaの保持は使用するパイプライン、マテリアル、後処理、画像ビューアーで確認します。ReadPixelsはGPUの完了待ちを伴い得るため、大量Prefabの速度は測定していません。EncodeToPNGのPNGはガンマ補正情報や色プロフィールを含まない仕様なので、色空間と比較用ビューアーもそろえて確認します。

出力できない・画像がおかしいとき

項目・症状条件・確認先
メニューがない・コンパイルエラー.cs拡張子、Editorフォルダー/asmdef、クラス重複、Unity版を確認。旧版へそのまま適用できる保証はない。
選択エラー・候補0ProjectでAssets内のフォルダーを1つ選ぶ。.prefabと子フォルダーを確認。ファイルそのものやPackagesは選ばない。
スキップがあるConsoleの対象パスと理由を確認。非Active、無対象、幅・高さ0、Mesh/Particle混在はこの例の対象外。
SRPのサポートエラー有効なパイプラインとCamera条件を確認。未対応をCamera.Renderへ無条件で戻して成功扱いにしない。
背景が黒い・全面透明・見た目が違うRGBAを扱えるビューアー、Shader、Alpha、照明、パイプラインを確認。完全透明のPNGは警告を出すが、保存成功件数と描画品質の合格は別。
輪郭が切れる・余白が多いPaddingとboundsを確認。SpriteMaskやShaderで変化する描画範囲は単純なboundsと分けて調べる。
同名・再実行で結果が見つからない名前_GUID.pngと実行ごとのOutputパスを確認。GUI上のPrefab名だけで結果を探さない。
途中で失敗・取消・保存できない候補と成功数を比べ、失敗パスと例外を確認。書込権限、長いパス、ディスク容量を確認。完了ログだけで全件成功と判断しない。

関連するPrefabの出力ツール

uGUI ImageのPNG出力、Prefab内部のGameObjectとコンポーネントCSV、Prefabファイルの保存階層CSVは出力対象が違います。見た目の確認と内部構造の一覧を使い分けてください。旧GitHub配布の01.csは改稿前のコードなので、今回の修正版は本文のコード全体を使用します。

エディタ拡張の記事は、ほかにもあります。

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

Unityのおすすめ書籍

開発PCの選び方

周辺機器の優先度

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次