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

UnityでuGUI Prefabを一括PNG出力する|Imageの範囲とCanvas設定

Assets内のフォルダーにあるImageで構成したuGUI Prefabを、RectTransformの範囲からPNGへ書き出すEditor拡張を掲載します。SpriteRendererの2Dキャラクターやアイコンは2D Prefab用の出力手順を使います。

仕様の参照は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のuGUI Image。Text、RawImage、TMP等のGraphicやRendererが混在するPrefab、ルート以外のCanvasがあるPrefabはスキップ。
撮影範囲各ImageのRectTransform四隅を合成した外接範囲。各辺に2%分の余白。Imageの不透明部分やMask適用後の輪郭を切り抜く処理ではない。
PNG寸法初期値はCanvasのワールド1単位を1pxとして計算。長辺2048px上限で密度を共通縮小し、最終ピクセル比率からカメラを合わせる。
保存名prefab_名前_GUID.png。同名の別PrefabをGUIDで区別し、実行ごとに別の出力フォルダーを作る。
描画方式Built-inはCamera.Render。SRPはStandardRequestのサポートを問い合わせ、未対応ならそのPrefabを失敗として記録。対応回答だけで色・透過・全Shaderの動作は保証しない。

この例はImageだけのUI部品用です。UI全体のスクリーンショット、ゲーム画面、文字を含むボタン、入れ子Canvasの撮影を同じコードへ任せる設計にはしていません。非Activeの要素は有効化せず、CanvasGroupで隠れたImageの矩形は範囲に残る場合があります。

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

uGUIのImageコンポーネントの描画範囲に合わせて余白なく撮影された出力例の様子。

Canvasと親の寸法を先に決める

  1. ルートがCanvasのPrefabでは、正の幅・高さを持つRectTransformを用意します。撮影用の複製ではCanvasをWorld Spaceにし、位置0・回転0・Scale 1へそろえ、CanvasScalerを無効にします。画面サイズに応じたScale With Screen Sizeを再現する処理ではありません。
  2. ルートがImage等でCanvasを持たないPrefabでは、撮影用の親Canvasを1920×1080単位で用意します。Stretchアンカーはこの親寸法を基準に配置されます。元のゲーム画面と同じ親寸法が必要ならReferenceWidthとReferenceHeightを変更してください。
  3. 複製のレイアウトを更新した後でImageの四隅を取得します。Content Size FitterやLayout Group、Mask、Preserve Aspect、Sliced、Filled、Outlineがある場合は、1Prefabずつ表示範囲と見た目を確認します。矩形と実際の描画輪郭が一致するとは限りません。

Canvasの描画モードと親子関係はuGUI公式のCanvas資料、レイアウト更新はForceUpdateCanvasesを参照します。GetWorldCornersが返すのは矩形の四隅です。

導入して1Prefabから試す

  1. プロジェクトを複製し、Assets/EditorにImagePrefabPngExporter.csとして下の全文を保存します。旧PrefabScreenshotまたはUGUIPrefabScreenshotを使っていた場合は、旧メニューと新メニューを取り違えないよう旧スクリプトを外します。同じ新クラスを2つ置かないでください。
  2. asmdefを使っている場合はEditor専用Assemblyへ配置します。UnityEngine.UIを参照できる構成が必要です。Consoleのコンパイルエラーを確認してから進みます。GameObjectへ付けるMonoBehaviourではありません。
  3. 確認用フォルダーに、幅と高さが正のImageだけのPrefabを1つ保存します。最初はMaskや独自スクリプトを外した単純なものを使います。
  4. Projectで確認用フォルダーを1つ選び、Tools → CG Method → Export Image 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/ImagePrefabPngExporter.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;
using UnityEngine.UI;

public static class ImagePrefabPngExporter
{
    private const double Density = 1.0; // Pixels per Canvas world unit.
    private const int MaximumPixels = 2048;
    private const double Padding = 0.02;
    private const float ReferenceWidth = 1920f, ReferenceHeight = 1080f;

    [MenuItem("Tools/CG Method/Export Image 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, "ugui_" +
            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, ImagePrefabPngExporterFormatting.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 canvases = copy.GetComponentsInChildren<Canvas>(true);
            if (canvases.Any(c => c.gameObject != copy))
            { reason = "Nested or child Canvas is outside this sample's supported conditions."; return false; }
            var graphics = copy.GetComponentsInChildren<Graphic>(false)
                .Where(g => g.isActiveAndEnabled).ToArray();
            if (graphics.Any(g => !(g is Image)))
            { reason = "This sample is Image-only: Text, RawImage and TMP need a separate capture design."; return false; }
            if (copy.GetComponentsInChildren<Renderer>(false).Any(r => r.enabled))
            { reason = "Non-uGUI Renderer is outside the supported conditions."; return false; }
            Canvas canvas = copy.GetComponent<Canvas>();
            if (canvas == null)
            {
                var canvasObject = new GameObject("CG Method capture canvas", typeof(RectTransform));
                SceneManager.MoveGameObjectToScene(canvasObject, preview);
                canvas = canvasObject.AddComponent<Canvas>();
                ((RectTransform)canvas.transform).sizeDelta = new Vector2(ReferenceWidth, ReferenceHeight);
                copy.transform.SetParent(canvas.transform, false);
            }
            else
            {
                var scaler = canvas.GetComponent<CanvasScaler>();
                if (scaler != null) scaler.enabled = false; // Temporary copy only.
            }
            canvas.renderMode = RenderMode.WorldSpace;
            canvas.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
            canvas.transform.localScale = Vector3.one;
            canvas.enabled = true;
            var rootRect = (RectTransform)canvas.transform;
            if (rootRect.rect.width <= 0 || rootRect.rect.height <= 0)
            { reason = "Set a positive root Canvas size in the prefab."; return false; }
            Canvas.ForceUpdateCanvases();
            LayoutRebuilder.ForceRebuildLayoutImmediate(rootRect);
            Canvas.ForceUpdateCanvases();
            var images = copy.GetComponentsInChildren<Image>(false)
                .Where(i => i.isActiveAndEnabled).ToArray();
            if (images.Length == 0) { reason = "No active enabled Image."; return false; }
            bool first = true;
            bounds = new Bounds();
            var corners = new Vector3[4];
            foreach (var item in images)
            {
                item.rectTransform.GetWorldCorners(corners);
                foreach (var corner in corners)
                {
                    if (first) { bounds = new Bounds(corner, Vector3.zero); first = false; }
                    else bounds.Encapsulate(corner);
                }
            }
            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;
            ImagePrefabPngExporterFormatting.Dimensions(bounds.size.x * (1.0 + Padding * 2.0),
                bounds.size.y * (1.0 + Padding * 2.0), Density, MaximumPixels, out pw, out ph);
            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)ImagePrefabPngExporterFormatting.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;
            canvas.worldCamera = camera;
            Canvas.ForceUpdateCanvases();
            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 ImagePrefabPngExporterFormatting
{
    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";
    }
}

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

幅と高さを別々に2048pxでClampすると出力aspectが変わり、縦だけにカメラを合わせたときに横が切れることがあります。共通の密度を下げ、整数pxへ切り上げた後のaspectで縦横の両方を収めます。幅400・高さ100・密度20・余白なしの式では2048×512px、幅100・高さ400では512×2048pxです。初期コードのDensityは1、Paddingは0.02なので、この式の例と初期値の出力寸法は区別してください。

掲載した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、Image以外のGraphicや子Canvasはこの例の対象外。
SRPのサポートエラー有効なパイプラインとCamera条件を確認。未対応をCamera.Renderへ無条件で戻して成功扱いにしない。
背景が黒い・全面透明・見た目が違うRGBAを扱えるビューアー、Shader、Alpha、照明、パイプラインを確認。完全透明のPNGは警告を出すが、保存成功件数と描画品質の合格は別。
輪郭が切れる・余白が多いPaddingとboundsを確認。RectTransformと実画像の余白、Mask、Outline、親Canvas寸法を分けて調べる。文字を含むUIは対象外。
同名・再実行で結果が見つからない名前_GUID.pngと実行ごとのOutputパスを確認。GUI上のPrefab名だけで結果を探さない。
途中で失敗・取消・保存できない候補と成功数を比べ、失敗パスと例外を確認。書込権限、長いパス、ディスク容量を確認。完了ログだけで全件成功と判断しない。

関連するPrefabの出力ツール

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

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

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

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

Unityのおすすめ書籍

開発PCの選び方

周辺機器の優先度

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