스프라이트 에디터: 커스텀 물리 모양
Sorting Group

스프라이트 패커(Sprite Packer)

스프라이트 그래픽을 디자인할 때는 각 캐릭터마다 별도의 텍스처 파일로 작업하는 것이 편리합니다. 그러나 이 경우 그래픽 요소 사이의 빈 공간이 스프라이트 텍스처의 상당한 부분을 차지하게 되며 이 공간은 런타임 시점에 비디오 메모리를 낭비하는 결과를 낳게 됩니다. 최적의 성능을 위한 가장 좋은 방법은 여러 스프라이트 텍스처의 그래픽을 아틀라스라는 단일 텍스처에 최대한 모아 넣는 방법입니다. Unity에서는 Sprite Packer 유틸리티를 제공하여 개별 스프라이트 텍스처로 아틀라스를 생성하는 과정을 자동화합니다.

Unity가 내부적으로 스프라이트 아틀라스 텍스처의 생성 및 사용을 처리하기 때문에 사용자가 수동으로 할당할 필요가 없습니다. 플레이 모드에 들어갈 때 또는 빌드 중에 아틀라스를 패킹하는 옵션이 있으며 스프라이트 오브젝트용 그래픽스는 일단 아틀라스가 생성되면 아틀라스로부터 얻어오게 됩니다.

텍스처의 스프라이트를 패킹하도록 하려면 텍스처 임포터의 패킹 태그를 지정해야 합니다.

스프라이트 패커(Sprite Packer) 사용

스프라이트 패커는 기본적으로 비활성화되어 있지만 에디터 설정(메뉴: Edit > Project Settings > Editor)에서 이를 구성할 수 있습니다. 스프라이트 패킹 모드를 Disabled 에서 Enabled for Builds(패킹을 재생 모드가 아니라 빌드에 사용) 또는 Always Enabled(패킹을 재생 모드와 빌드에서 모두 활성화)로 변경할 수 있습니다.

Sprite Packer 창(메뉴: Window > 2D > Sprite Packer)을 열고 왼쪽 코너 상단의 Pack 버튼을 클릭하면 텍스처 배열이 아틀라스로 패킹됩니다.

프로젝트 패널에서 스프라이트를 선택하면 아틀라스에서도 해당 포지션이 표시됩니다. 외곽선은 실제로는 렌더 메시 외곽선이며 타이트 패킹에 사용되는 영역 또한 정의합니다.

스프라이트 패커 창 맨 위에 있는 툴바에는 패킹 및 뷰에 영향을 주는 여러 컨트롤이 있습니다. Pack 버튼을 누르면 패킹 동작이 시작되지만 마지막으로 패킹된 이후 아틀라스가 변경된 적이 없을 경우 강제로 업데이트를 하지는 않습니다. (아래 __ 스프라이트 패커 커스터마이징__에서 설명한 것처럼 커스텀 패킹 정책을 구현할 경우 관련하여 Repack 버튼이 나타납니다.) View AtlasPage # 메뉴로 창에 어떤 아틀라스의 어느 페이지가 나타날지를 선택할 수 있습니다. (최대 텍스처 크기에서 모든 스프라이트를 넣을 공간이 충분하지 않을 경우 하나의 아틀라스를 한 개 이상의 “페이지”로 분할할 수 있습니다.) 페이지 번호 옆에 있는 메뉴는 어떤 “패킹 정책”이 아틀라스에 사용되는지를 선택하는 메뉴입니다(아래 참조). 툴바의 오른쪽에는 뷰를 줌하는 컨트롤과 아틀라스의 컬러 및 알파 디스플레이를 변경할 수 있는 컨트롤이 있습니다.

패킹 정책(Packing Policy)

스프라이트 패커는 packing policy 를 사용해 스프라이트를 아틀라스에 할당하는 방법을 결정합니다. 직접 패킹 정책을 만들 수 있으며(아래 참조) 그 외에도 언제든지 Default Packer Policy, Tight Packer Policy, Tight Rotate Enabled Sprite Packer Policy 옵션을 사용할 수 있습니다. 이러한 정책에서 텍스처 임포터Packing Tag 프로퍼티는 스프라이트가 패킹될 아틀라스의 이름을 직접 선택하며 따라서 패킹 태그가 동일한 모든 스프라이트는 같은 아틀라스에 패킹됩니다. 이후 아틀라스는 텍스처 임포트 설정에 따라 정렬되어 소스 텍스처의 설정과 일치하게 됩니다. 같은 텍스처 압축 설정이 적용된 스프라이트는 가능하다면 동일 아틀라스에 그룹이 지어지게 됩니다.

  • DefaultPackerPolicy는 Packing Tag 에 “[TIGHT]”가 지정되어 있지 않다면 디폴트로 사각 패킹을 사용합니다. (즉, 패킹 태그를 “[TIGHT]Character”로 설정하면 타이트 패킹을 사용하게 됩니다.)
  • TightPackerPolicy는 스프라이트가 타이트 메시를 가지고 있으면 디폴트로 타이트 패킹을 사용합니다. 만약 Packing Tag 에 “[RECT]”가 지정되어 있으면 사각 패킹이 이루어집니다. (즉, 패킹 태그를 “[RECT]UI_Elements”로 설정하면 강제로 사각 패킹이 일어납니다.)
  • TightRotateEnabledSpritePackerPolicy는 스프라이트가 타이트 메시를 가지고 있으면 디폴트로 타이트 패킹을 사용하고 스프라이트의 회전이 가능하게 합니다. 만약 Packing Tag 에 “[RECT]”가 지정되어 있으면 사각 패킹이 이루어집니다. (즉, 패킹 태그를 “[RECT]UI_Elements”로 설정하면 강제로 사각 패킹이 일어납니다.)

스프라이트 패커 커스터마이징

대부분의 경우 DefaultPackerPolicy 옵션이 적합하지만 필요하다면 직접 커스텀 패킹 정책을 구현할 수도 있습니다. 커스텀 정책을 구현하려면 에디터 스크립트의 클래스용 UnityEditor.Sprites.IPackerPolicy 인터페이스를 구현해야 합니다. 인터페이스에는 다음 메서드가 있어야 합니다.

  • GetVersion - 패커 정책의 버전 값을 반환합니다. 정책 스크립트에 수정이 가해질 경우 버전이 업데이트되어야 하며 이 정책은 버전 관리에 저장됩니다.
  • OnGroupAtlases - 패킹 로직을 여기에 구현합니다. 패커 작업에 아틀라스를 정의하고 주어진 TextureImporters에서 스프라이트를 할당해야 합니다.

DefaultPackerPolicy는 디폴트로 사각 패킹을 사용합니다(SpritePackingMode 참조). 텍스처 공간 효과를 사용하거나 스프라이트 렌더링에 다른 메시를 사용하고자 할 때 유용합니다. 커스텀 정책에서는 이를 오버라이드하여 타이트 패킹을 대신 사용할 수 있습니다.

  • 리팩 버튼은 커스텀 정책이 선택된 경우에만 활성화됩니다.
    • OnGroupAtlases는 TextureImporter 메타데이터 또는 선택된 PackerPolicy 버전 값이 변하지 않는 이상 호출되지 않습니다.
    • 커스텀 정책에서 작업 시에는 리팩 버튼을 사용해야 합니다.
  • 스프라이트는 TightRotateEnabledSpritePackerPolicy 옵션의 경우 자동으로 회전하여 패킹됩니다.
    • SpritePackingRotation은 향후 Unity 버전에서 사용하기 위해 예약된 타입입니다.

기타

  • 아틀라스는 Project\Library\AtlasCache에 캐시됩니다.
    • 이 폴더를 삭제하고 Unity를 시작하면 아틀라스가 강제로 리패킹됩니다. Unity는 반드시 종료된 상태여야 합니다.
  • 아틀라스 캐시는 시작할 때 로드되지 않습니다.
    • 모든 텍스처는 Unity가 재시작된 후 최초로 패킹될 때 반드시 체크되어야 합니다. 이 동작은 프로젝트 내의 전체 텍스처 수에 따라 시간이 다소 걸릴 수도 있습니다.
    • 필요한 아틀라스만 로드됩니다.
  • 디폴트 최대 아틀라스 크기는 2048x2048입니다.
  • PackingTag가 설정되어 있으면 텍스처는 압축되지 않으며 이에 따라 SpritePacker가 원본 픽셀 값을 얻은 후 아틀라스에서 압축을 진행합니다.

DefaultPackerPolicy

using System;
using System.Linq;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;


public class DefaultPackerPolicySample : UnityEditor.Sprites.IPackerPolicy
{
        protected class Entry
        {
            public Sprite            sprite;
        public UnityEditor.Sprites.AtlasSettings settings;
            public string            atlasName;
            public SpritePackingMode packingMode;
            public int               anisoLevel;
        }

        private const uint kDefaultPaddingPower = 3; // Good for base and two mip levels.

        public virtual int GetVersion() { return 1; }
        protected virtual string TagPrefix { get { return "[TIGHT]"; } }
        protected virtual bool AllowTightWhenTagged { get { return true; } }
        protected virtual bool AllowRotationFlipping { get { return false; } }

    public static bool IsCompressedFormat(TextureFormat fmt)
    {
        if (fmt >= TextureFormat.DXT1 && fmt <= TextureFormat.DXT5)
            return true;
        if (fmt >= TextureFormat.DXT1Crunched && fmt <= TextureFormat.DXT5Crunched)
            return true;
        if (fmt >= TextureFormat.PVRTC_RGB2 && fmt <= TextureFormat.PVRTC_RGBA4)
            return true;
        if (fmt == TextureFormat.ETC_RGB4)
            return true;
        if (fmt >= TextureFormat.ATC_RGB4 && fmt <= TextureFormat.ATC_RGBA8)
            return true;
        if (fmt >= TextureFormat.EAC_R && fmt <= TextureFormat.EAC_RG_SIGNED)
            return true;
        if (fmt >= TextureFormat.ETC2_RGB && fmt <= TextureFormat.ETC2_RGBA8)
            return true;
        if (fmt >= TextureFormat.ASTC_RGB_4x4 && fmt <= TextureFormat.ASTC_RGBA_12x12)
            return true;
        if (fmt >= TextureFormat.DXT1Crunched && fmt <= TextureFormat.DXT5Crunched)
            return true;
        return false;
    }

    public void OnGroupAtlases(BuildTarget target, UnityEditor.Sprites.PackerJob job, int[] textureImporterInstanceIDs)
        {
            List<Entry> entries = new List<Entry>();

            foreach (int instanceID in textureImporterInstanceIDs)
            {
                TextureImporter ti = EditorUtility.InstanceIDToObject(instanceID) as TextureImporter;

                TextureFormat desiredFormat;
                ColorSpace colorSpace;
                int compressionQuality;
                ti.ReadTextureImportInstructions(target, out desiredFormat, out colorSpace, out compressionQuality);

                TextureImporterSettings tis = new TextureImporterSettings();
                ti.ReadTextureSettings(tis);

            Sprite[] sprites =
                AssetDatabase.LoadAllAssetRepresentationsAtPath(ti.assetPath)
                    .Select(x => x as Sprite)
                    .Where(x => x != null)
                    .ToArray();
                foreach (Sprite sprite in sprites)
                {
                    Entry entry = new Entry();
                    entry.sprite = sprite;
                    entry.settings.format = desiredFormat;
                    entry.settings.colorSpace = colorSpace;
                    // Use Compression Quality for Grouping later only for Compressed Formats. Otherwise leave it Empty.
                entry.settings.compressionQuality = IsCompressedFormat(desiredFormat) ? compressionQuality : 0;
                entry.settings.filterMode = Enum.IsDefined(typeof(FilterMode), ti.filterMode)
                    ? ti.filterMode
                    : FilterMode.Bilinear;
                    entry.settings.maxWidth = 2048;
                    entry.settings.maxHeight = 2048;
                    entry.settings.generateMipMaps = ti.mipmapEnabled;
                    entry.settings.enableRotation = AllowRotationFlipping;
                    if (ti.mipmapEnabled)
                        entry.settings.paddingPower = kDefaultPaddingPower;
                    else
                        entry.settings.paddingPower = (uint)EditorSettings.spritePackerPaddingPower;
                    #if ENABLE_ANDROID_ATLAS_ETC1_COMPRESSION
                        entry.settings.allowsAlphaSplitting = ti.GetAllowsAlphaSplitting ();
                    #endif //ENABLE_ANDROID_ATLAS_ETC1_COMPRESSION

                    entry.atlasName = ParseAtlasName(ti.spritePackingTag);
                    entry.packingMode = GetPackingMode(ti.spritePackingTag, tis.spriteMeshType);
                    entry.anisoLevel = ti.anisoLevel;

                    entries.Add(entry);
                }

                Resources.UnloadAsset(ti);
            }

            // First split sprites into groups based on atlas name
            var atlasGroups =
                from e in entries
                group e by e.atlasName;
            foreach (var atlasGroup in atlasGroups)
            {
                int page = 0;
                // Then split those groups into smaller groups based on texture settings
                var settingsGroups =
                    from t in atlasGroup
                    group t by t.settings;
                foreach (var settingsGroup in settingsGroups)
                {
                    string atlasName = atlasGroup.Key;
                    if (settingsGroups.Count() > 1)
                        atlasName += string.Format(" (Group {0})", page);

                UnityEditor.Sprites.AtlasSettings settings = settingsGroup.Key;
                    settings.anisoLevel = 1;
                    // Use the highest aniso level from all entries in this atlas
                    if (settings.generateMipMaps)
                        foreach (Entry entry in settingsGroup)
                            if (entry.anisoLevel > settings.anisoLevel)
                                settings.anisoLevel = entry.anisoLevel;

                    job.AddAtlas(atlasName, settings);
                    foreach (Entry entry in settingsGroup)
                    {
                        job.AssignToAtlas(atlasName, entry.sprite, entry.packingMode, SpritePackingRotation.None);
                    }

                    ++page;
                }
            }
        }

        protected bool IsTagPrefixed(string packingTag)
        {
            packingTag = packingTag.Trim();
            if (packingTag.Length < TagPrefix.Length)
                return false;
            return (packingTag.Substring(0, TagPrefix.Length) == TagPrefix);
        }

        private string ParseAtlasName(string packingTag)
        {
            string name = packingTag.Trim();
            if (IsTagPrefixed(name))
                name = name.Substring(TagPrefix.Length).Trim();
            return (name.Length == 0) ? "(unnamed)" : name;
        }

        private SpritePackingMode GetPackingMode(string packingTag, SpriteMeshType meshType)
        {
            if (meshType == SpriteMeshType.Tight)
                if (IsTagPrefixed(packingTag) == AllowTightWhenTagged)
                    return SpritePackingMode.Tight;
            return SpritePackingMode.Rectangle;
        }
}

TightPackerPolicy

using System;
using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEditor.Sprites;
using System.Collections.Generic;

// TightPackerPolicy will tightly pack non-rectangle Sprites unless their packing tag contains "[RECT]".
class TightPackerPolicySample : DefaultPackerPolicySample
{
        protected override string TagPrefix { get { return "[RECT]"; } }
        protected override bool AllowTightWhenTagged { get { return false; } }
        protected override bool AllowRotationFlipping { get { return false; } }
}

TightRotateEnabledSpritePackerPolicy

using System;
using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEditor.Sprites;
using System.Collections.Generic;

// TightPackerPolicy will tightly pack non-rectangle Sprites unless their packing tag contains "[RECT]".
class TightRotateEnabledSpritePackerPolicySample : DefaultPackerPolicySample
{
        protected override string TagPrefix { get { return "[RECT]"; } }
        protected override bool AllowTightWhenTagged { get { return false; } }
        protected override bool AllowRotationFlipping { get { return true; } }
}

스프라이트 에디터: 커스텀 물리 모양
Sorting Group