관리 메뉴

cyphen156

유니티 이미지 파일 임포트 정규화하기 본문

프로젝트/유니티

유니티 이미지 파일 임포트 정규화하기

cyphen156 2025. 12. 3. 21:36

유니티 스프라이트로 타일맵 만들다가 개별 스프라이트의 크기가 일정하지 않아 일부 잘리거나 비어서 피벗이 벗어나가는 현상이 발생해서 만든 스크립트

32 X 32 픽셀을 기준으로 폴더 안에 있는 이미지들을 정규화 해준다. 

수작업으로 할려다가 지금 내가 찾아낸 타일 Png 파일만 971개라는걸 고려하면 그냥 자동화 스크립트작성하는게 편할것 같더라.

그래서 폴더 하나를 통채로 스캔해서 자동 정규화 시킬거다.

빨간색 박스 안에 있는 폴더 내부의 모든 스프라이트를 파란박스 안의 스크립트가 강제로 32픽셀단위로 적응 시킬거다. 

 

생성된 파일은 덮어씌울거다.

혹시 이상하게 적응 시킬 수도 있으니까 원본을 다른곳에 백업해놓는편이 좋을 것이다.

피벗 기준으로 어디로 확장할지도 인스펙터상에서 선택할 수 있도록 해놨다.

Assets/Editor/

SpriteNormalizer.cs를 작성하고 유니티 에디터를 껏다가 켠다.

코드는 다음과 같다.

using System.IO;
using UnityEditor;
using UnityEngine;

public class SpriteNormalizer : EditorWindow
{
    private string rootFolder = "Assets/AssetNormalizeDirectory";
    private int tileSize = 32;

    private enum AnchorPosition
    {
        BottomLeft,
        BottomCenter,
        BottomRight,
        Center,
        TopLeft,
        TopCenter,
        TopRight
    }

    private AnchorPosition anchor = AnchorPosition.BottomLeft;

    [MenuItem("Tools/Sprites/Normalize Canvas To Grid")]
    public static void OpenWindow()
    {
        SpriteNormalizer window = GetWindow<SpriteNormalizer>();
        window.titleContent = new GUIContent("Sprite Canvas Normalizer");
        window.Show();
    }

    private void OnGUI()
    {
        EditorGUILayout.LabelField("Sprite Canvas Normalizer", EditorStyles.boldLabel);
        EditorGUILayout.Space();

        EditorGUILayout.LabelField("Root Folder (search scope)");
        rootFolder = EditorGUILayout.TextField(rootFolder);

        EditorGUILayout.LabelField("Grid Size (pixels per tile)");
        tileSize = EditorGUILayout.IntField(tileSize);

        EditorGUILayout.Space();

        EditorGUILayout.LabelField("Pivot / Anchor (원본이 새 캔버스 안에서 붙을 위치)");
        anchor = (AnchorPosition)EditorGUILayout.EnumPopup(anchor);

        EditorGUILayout.Space();

        if (GUILayout.Button("Scan And Normalize Textures"))
        {
            NormalizeAllTexturesInFolder();
        }
    }

    private void NormalizeAllTexturesInFolder()
    {
        if (!AssetDatabase.IsValidFolder(rootFolder))
        {
            Debug.LogError("[SpriteCanvasNormalizer] 유효하지 않은 폴더 경로입니다: " + rootFolder);
            return;
        }

        string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { rootFolder });

        int processedCount = 0;
        int skippedCount = 0;

        for (int i = 0; i < guids.Length; i++)
        {
            string path = AssetDatabase.GUIDToAssetPath(guids[i]);

            Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
            if (texture == null)
            {
                skippedCount++;
                continue;
            }

            int originalWidth = texture.width;
            int originalHeight = texture.height;

            int newWidth = NormalizeSize(originalWidth, tileSize);
            int newHeight = NormalizeSize(originalHeight, tileSize);

            if (newWidth == originalWidth && newHeight == originalHeight)
            {
                skippedCount++;
                continue;
            }

            bool success = NormalizeTextureCanvas(path, newWidth, newHeight, anchor);
            if (success)
            {
                processedCount++;
            }
            else
            {
                skippedCount++;
            }
        }

        Debug.Log(
            string.Format(
                "[SpriteCanvasNormalizer] 처리 완료 - 수정: {0}개, 건너뜀: {1}개",
                processedCount,
                skippedCount
            )
        );
    }

    private static int NormalizeSize(int size, int unit)
    {
        if (size <= unit)
        {
            return unit;
        }

        int remainder = size % unit;

        if (remainder == 0)
        {
            return size;
        }

        return size + (unit - remainder);
    }

    private static bool NormalizeTextureCanvas(
        string assetPath,
        int newWidth,
        int newHeight,
        AnchorPosition anchor
    )
    {
        TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
        if (importer == null)
        {
            Debug.LogError("[SpriteCanvasNormalizer] TextureImporter 를 찾을 수 없습니다: " + assetPath);
            return false;
        }

        // 기존 설정 보관
        bool originalIsReadable = importer.isReadable;
        TextureImporterCompression originalCompression = importer.textureCompression;
        TextureImporterNPOTScale originalNpotScale = importer.npotScale;

        // 읽기 가능 + 압축 해제 + NPOT 그대로 사용
        importer.isReadable = true;
        importer.textureCompression = TextureImporterCompression.Uncompressed;
        importer.npotScale = TextureImporterNPOTScale.None;

        AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);

        Texture2D sourceTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
        if (sourceTexture == null)
        {
            Debug.LogError("[SpriteCanvasNormalizer] Texture2D 로드 실패: " + assetPath);
            return false;
        }

        int srcWidth = sourceTexture.width;
        int srcHeight = sourceTexture.height;

        if (srcWidth > newWidth || srcHeight > newHeight)
        {
            Debug.LogError(
                string.Format(
                    "[SpriteCanvasNormalizer] 새 캔버스 크기가 원본보다 작습니다. assetPath={0}, src=({1},{2}), new=({3},{4})",
                    assetPath,
                    srcWidth,
                    srcHeight,
                    newWidth,
                    newHeight
                )
            );
            return false;
        }

        // 새 텍스처 생성 (투명 배경)
        Texture2D newTexture = new Texture2D(newWidth, newHeight, TextureFormat.RGBA32, false);
        Color32[] clearPixels = new Color32[newWidth * newHeight];

        Color32 clearColor = new Color32(0, 0, 0, 0);
        for (int i = 0; i < clearPixels.Length; i++)
        {
            clearPixels[i] = clearColor;
        }

        newTexture.SetPixels32(clearPixels);

        // 원본 픽셀
        Color32[] sourcePixels = sourceTexture.GetPixels32();

        // 앵커에 따른 오프셋 계산
        int offsetX = 0;
        int offsetY = 0;
        GetAnchorOffset(anchor, srcWidth, srcHeight, newWidth, newHeight, out offsetX, out offsetY);

        // 원본 픽셀 복사
        for (int y = 0; y < srcHeight; y++)
        {
            for (int x = 0; x < srcWidth; x++)
            {
                int srcIndex = x + y * srcWidth;
                int dstX = x + offsetX;
                int dstY = y + offsetY;

                if (srcIndex < 0 || srcIndex >= sourcePixels.Length)
                {
                    continue;
                }

                if (dstX < 0 || dstX >= newWidth || dstY < 0 || dstY >= newHeight)
                {
                    continue;
                }

                int dstIndex = dstX + dstY * newWidth;
                clearPixels[dstIndex] = sourcePixels[srcIndex];
            }
        }

        newTexture.SetPixels32(clearPixels);
        newTexture.Apply();

        // PNG로 다시 저장
        byte[] pngData = newTexture.EncodeToPNG();
        if (pngData == null || pngData.Length == 0)
        {
            Debug.LogError("[SpriteCanvasNormalizer] PNG 인코딩 실패: " + assetPath);
            return false;
        }

        string fullPath = Path.GetFullPath(assetPath);
        try
        {
            File.WriteAllBytes(fullPath, pngData);
        }
        catch (System.Exception e)
        {
            Debug.LogError("[SpriteCanvasNormalizer] 파일 쓰기 실패: " + fullPath + "\n" + e);
            return false;
        }

        // 임시 텍스처 메모리 해제
        Object.DestroyImmediate(newTexture);

        // 임포터 설정 복원
        importer.isReadable = originalIsReadable;
        importer.textureCompression = originalCompression;
        importer.npotScale = originalNpotScale;

        AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);

        Debug.Log(
            string.Format(
                "[SpriteCanvasNormalizer] 캔버스 확장 완료: {0} ({1}x{2} → {3}x{4})",
                assetPath,
                srcWidth,
                srcHeight,
                newWidth,
                newHeight
            )
        );

        return true;
    }

    private static void GetAnchorOffset(
        AnchorPosition anchor,
        int srcWidth,
        int srcHeight,
        int newWidth,
        int newHeight,
        out int offsetX,
        out int offsetY
    )
    {
        int deltaX = newWidth - srcWidth;
        int deltaY = newHeight - srcHeight;

        switch (anchor)
        {
            case AnchorPosition.BottomLeft:
                {
                    offsetX = 0;
                    offsetY = 0;
                    break;
                }
            case AnchorPosition.BottomCenter:
                {
                    offsetX = deltaX / 2;
                    offsetY = 0;
                    break;
                }
            case AnchorPosition.BottomRight:
                {
                    offsetX = deltaX;
                    offsetY = 0;
                    break;
                }
            case AnchorPosition.Center:
                {
                    offsetX = deltaX / 2;
                    offsetY = deltaY / 2;
                    break;
                }
            case AnchorPosition.TopLeft:
                {
                    offsetX = 0;
                    offsetY = deltaY;
                    break;
                }
            case AnchorPosition.TopCenter:
                {
                    offsetX = deltaX / 2;
                    offsetY = deltaY;
                    break;
                }
            case AnchorPosition.TopRight:
                {
                    offsetX = deltaX;
                    offsetY = deltaY;
                    break;
                }
            default:
                {
                    offsetX = 0;
                    offsetY = 0;
                    break;
                }
        }
    }
}

그리고 나서 아래 처럼 경로를 수정하고 Scan And Normalize Textures 버튼을 누르고 잠시 기다리면 된다.

피벗은 왜 선택하게 해놧냐면 가끔가다 이렇게 확장되는 놈들이 있기 때문이다.

이런애들은 원본을 다시 넣고서 조정해주면된다.

그러니까 항상 스프라이트 에디터를 켜놓고 필요한 애들만 넣고 어디를 기준으로 스프라이트를 늘릴지 생각하고 버튼을 누르도록 하자.

이런 경우는 아래있는 비슷한 친구랑 비교해보고 위로 확장시킬것이다.

그리고 아래의 경우는 좌측, 상단으로 확장할것이다.

그러니까 확장 기준을 대칭점으로 잡으면 된다.

코드 생성은 지피티가 했어요!

얘 이런거 잘함 

+@

번외로 디렉토리 이름 정규화도 & 자동 정리기도 예전에 파이썬으로 만든적 이씀

아래는 지피티가 정리한 블로그 글 작성 초안

더보기

사용 방법

  1. 폴더 준비
    • 정규화할 스프라이트들을 한 폴더 아래로 모읍니다.
    • 예시에서는 Assets/AssetNormalizeDirectory 를 기준으로 사용합니다.
    • 이미 사용 중인 리소스라면, 반드시 다른 위치에 백업 을 만들어 두는 편이 안전합니다.
  2. 에디터 스크립트 위치
    • 아래 코드를 Editor 폴더 안에 SpriteNormalizer.cs 파일로 저장합니다.
    • 예: Assets/Editor/SpriteNormalizer.cs
  3. 유니티 에디터에서 실행
    • Unity 메뉴에서
      Tools > Sprites > Normalize Canvas To Grid 를 클릭합니다.
    • 뜨는 윈도우에서 다음 필드를 설정합니다.
      • Root Folder: 정규화할 텍스처들이 들어있는 루트 폴더
      • Grid Size: 타일 단위 (예: 32, 64 등)
      • Pivot / Anchor: 새 캔버스 내부에서 원본 이미지가 위치할 기준점
    • Scan And Normalize Textures 버튼을 클릭하면,
      • 해당 폴더 이하의 모든 Texture2D를 스캔하고,
      • 캔버스 크기를 정규화한 뒤,
      • PNG 파일을 직접 덮어쓰기 합니다.
  4. 결과 확인
    • Unity 가 자동으로 에셋을 리임포트합니다.
    • Sprite Importer에서 픽셀 크기와 스프라이트 경계가 그리드에 잘 맞는지 확인합니다.
    • 타일맵에 올려보면서, 더 이상 잘리거나 빈 공간이 생기지 않는지 테스트합니다.

핵심 코드 설명

1. 사이즈 정규화 로직

 
private static int NormalizeSize(int size, int unit) { if (size <= unit) { return unit; } int remainder = size % unit; if (remainder == 0) { return size; } return size + (unit - remainder); }
private static int NormalizeSize(int size, int unit)
{
    if (size <= unit)
    {
        return unit;
    }

    int remainder = size % unit;

    if (remainder == 0)
    {
        return size;
    }

    return size + (unit - remainder);
}
  • size 가 unit 보다 작으면 → unit 으로 맞춥니다.
  • 이미 배수이면 그대로 두고,
  • 나머지가 있으면 다음 배수까지 올림 합니다.

2. 캔버스 확장 & 앵커

새 텍스처는 항상 RGBA32 포맷 + 투명 배경으로 만들고,
기존 픽셀을 선택한 앵커 기준으로 복사합니다.

 
private static void GetAnchorOffset(
    AnchorPosition anchor,
    int srcWidth,
    int srcHeight,
    int newWidth,
    int newHeight,
    out int offsetX,
    out int offsetY
)
{
    int deltaX = newWidth - srcWidth;
    int deltaY = newHeight - srcHeight;

    switch (anchor)
    {
        case AnchorPosition.BottomLeft:
        {
            offsetX = 0;
            offsetY = 0;
            break;
        }
        case AnchorPosition.BottomCenter:
        {
            offsetX = deltaX / 2;
            offsetY = 0;
            break;
        }
        case AnchorPosition.BottomRight:
        {
            offsetX = deltaX;
            offsetY = 0;
            break;
        }
        case AnchorPosition.Center:
        {
            offsetX = deltaX / 2;
            offsetY = deltaY / 2;
            break;
        }
        case AnchorPosition.TopLeft:
        {
            offsetX = 0;
            offsetY = deltaY;
            break;
        }
        case AnchorPosition.TopCenter:
        {
            offsetX = deltaX / 2;
            offsetY = deltaY;
            break;
        }
        case AnchorPosition.TopRight:
        {
            offsetX = deltaX;
            offsetY = deltaY;
            break;
        }
        default:
        {
            offsetX = 0;
            offsetY = 0;
            break;
        }
    }
}
  • deltaX, deltaY 는 “얼마나 늘어났는지” 값입니다.
  • 앵커에 따라 offsetX, offsetY 를 설정하고,
  • 복사할 때는 (x + offsetX, y + offsetY) 위치에 픽셀을 넣습니다.

한계 및 주의점

  • 원본 덮어쓰기
    • 정규화 과정에서 PNG 파일이 바로 덮어쓰여서,
    • 되돌리려면 버전 관리(Git 등)나 사전 백업이 필요합니다.
  • 멀티 스프라이트 아틀라스
    • 이 스크립트는 이미지 단위(Texture2D) 로만 처리합니다.
    • Sprite Editor 에서 잘려 있는 칸 수까지는 개입하지 않고,
    • “바탕 캔버스” 만 확장하는 방식입니다.
  • 압축 설정
    • 처리할 때는 강제로 Uncompressed + isReadable = true 로 바꾸고,
    • 작업 후에는 기존 설정을 복원하도록 해 두었습니다.

번외: 디렉토리 이름 정규화 툴과의 연계

예전에 Python 으로 작성해둔 디렉토리 이름 정규화 & 자동 정리 스크립트 가 있다면,

  • 디렉토리 구조/이름 정리 →
  • Unity 에서 Sprite 캔버스 정규화

이 순서로 한 번에 작업 플로우를 구성해 두면,
앞으로는 “리소스 정리용 파이프라인” 형식으로 반복해서 활용하실 수 있습니다.