개발일지/DOTS

(개발일지) EntitySpriteRenderer - 1

tsyang 2023. 4. 30. 15:03

개요


 

ECS에서 사용할 수 있는 SpriteRenderer를 만든다.

 

 

우선적으로 Sprite 정보 / Color / Flip X만 구현할 것이다.

 

GPU Instancing은 일단은 고려하지 않는다.

 

또한 균일하지 않은 Sprite 크기와, Custom Pivot을 지원해야 한다.

 

EntitySpriteRenderer가 하는 역할은 딱 하나다. 매 프레임 마다 지정된 Sprite를 그려주는 것.

 

 


 

개발과정


 

EntitySpriteRenderer 컴포넌트

 

우선 Authoring부터 만들어보자. Authoring에는 그리길 원하는 sprite와, 색, X축 뒤집기 여부가 들어가야 한다.

 

public class EntitySpriteRendererAuthoring : MonoBehaviour
{
    public Sprite sprite;
    public Color color = Color.white;
    public bool isFlipX;
}

 

 

 

 

그다음 컴포넌트를 만들어보자. 컴포넌트에는 참조 타입을 멤버로 추가할 수 없다. 따라서 Sprite에 대한 정보를 지정해줄 SpriteInfo를 값 타입으로 만들어 멤버로 추가한다. 여기에 색상 값과 X축 뒤집기 여부를 멤버로 추가해준다.

 

public struct EntitySpriteRenderer : IComponentData
{
    public SpriteInfo SpriteInfo;
    public float4 Color;
    public bool IsFlipX;
}

 

SpriteInfo에는 우선 Sprite의 원본 SpriteSheet를 가리키는 ID만을 추가해준다.

 

public struct SpriteInfo
{
    public int SpriteSheetID; // = Material ID

    public static SpriteInfo Make(Sprite sprite)
    {
        var spriteSheetID = SpriteSheetManager.Instance.GetSpriteSheetID(sprite.texture);
        if (spriteSheetID < 0)
            spriteSheetID = SpriteSheetManager.Instance.AddSpriteSheet(sprite.texture);

        SpriteInfo info = new SpriteInfo();
        info.SpriteSheetID = spriteSheetID;

        return info;
    }
}

 

SpriteSheetManager는 그때그때 SpriteSheet마다 ID를 할당하고 반환해주는 역할을 한다.

 

여러 쓰레드에서 동시에 접근이 가능할 수 있기 때문에 Singleton으로 구현해준다.

public abstract class Singleton<T> where T : new()
{
    private static readonly Lazy<T> Lazy = new Lazy<T>(() => new T());
    public static T Instance => Lazy.Value;
}

우선 Thread-Safe한 싱글턴 추상 클래스를 정의해주고,

 

public sealed class SpriteSheetManager : Singleton<SpriteSheetManager>
{
    //#minor Lock을 쓰는 것 보다는 애초에 게임 실행 이후 Write가 안 되도록 하는 게 낫다.
    private readonly ReaderWriterLockSlim _spriteTableLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
    private readonly Dictionary<Texture2D, int> _spriteTable = new Dictionary<Texture2D, int>();
    private readonly List<Material> _materials = new List<Material>();
    private int _lastIssuedID = -1;

    [RuntimeInitializeOnLoadMethod]
    private static void Init()
    {
        Instance.Clear();
    }

    private void Clear()
    {
        Instance._lastIssuedID = -1;
        Instance._materials.Clear();
        Instance._spriteTable.Clear();
    }
    
    public int GetSpriteSheetID(Texture2D texture)
    {
        var ret = -1;
        _spriteTableLock.EnterReadLock();
        if (_spriteTable.ContainsKey(texture))
            ret = _spriteTable[texture];
        _spriteTableLock.ExitReadLock();
        return ret;
    }
    
    //#Major 동적으로 처리하지 말고, 게임 실행시 미리 리스트를 받아서 더할 수 있도록 처리한다.
    public int AddSpriteSheet(Texture2D texture2D)
    {
        var id = -1;
        _spriteTableLock.EnterWriteLock();
        if (!_spriteTable.ContainsKey(texture2D))
        {
            id = ++_lastIssuedID;
            _spriteTable.Add(texture2D, id);
            var shader = Shader.Find("Custom/InstancedShader");
            var material = new Material(shader);
            material.mainTexture = texture2D;
            material.enableInstancing = true;
            _materials.Add(material);
        }
        else
            id = _spriteTable[texture2D];
        _spriteTableLock.ExitWriteLock();
        return id;
    }

    public Material GetMaterial(int spriteSheetID)
    {
        Material material = null;
        _spriteTableLock.EnterReadLock();
        material = _materials[spriteSheetID];
        _spriteTableLock.ExitReadLock();

        return material;
    }
}

일단 대충 위와 같이 새로운 Texture가 추가되면 Material를 생성해서 ID와 함께 저장하고, 추후에 ID로 Material을 얻어올 수 있도록 했다.

 

쓰기보다 읽기가 자주 발생할 것이기 때문에 ReaderWriterLockSlim 동기화 요소를 채용하였다. 

 

물론 이것은 최선의 방법이 아니며, 추후에는 게임 시작 전 혹은 로딩 시간에 미리 사용할 Sprite 정보들을 로딩하는 작업이 필요하다. 이렇게 하면 불필요하게 동기화 요소를 사용하지 않아도 된다.

 

 

 

 

 

 

SpriteRenderSystem

 

그다음은 우선 Sprite를 그려보자.

 

아래와 같이 SpriteRendereSystem을 구현하였다.

public partial class SpriteRenderSystem : SystemBase
{
    private static readonly int ShaderIdMainTexUV = Shader.PropertyToID("_MainTex_UV");

    protected override void OnUpdate()
    {
        foreach(var (entitySpriteRenderer, transformAspect) in SystemAPI.Query<EntitySpriteRenderer, TransformAspect>())
        {
            var spriteInfo = entitySpriteRenderer.SpriteInfo;
            var material = SpriteSheetManager.Instance.GetMaterial(spriteInfo.SpriteSheetID);
            RenderParams rp = new RenderParams(material);
            rp.matProps = new MaterialPropertyBlock();  

            float uvWidth = 1 / 8f;
            float uvHeight = 1f;
            float offsetX = 0f;
            float offsetY = 0;

            var uv = new float4(uvWidth, uvHeight, offsetX, offsetY);
            
            rp.matProps.SetVector(ShaderIdMainTexUV, uv);
            
            Matrix4x4 matrix = Matrix4x4.TRS(
                transformAspect.WorldPosition,
                transformAspect.WorldRotation,
                Vector3.one * transformAspect.WorldScale);
            
            Graphics.RenderMesh(rp, MeshExtension.MakeQuad(), 0, matrix);
        }
    }
}

 

일단 UV는 임의로 SpriteSheet를 이루는 첫 번째 Sprite를 그릴 수 있도록 uvWidth를 1/8로 지정해준다.

 

그리는 함수는 Graphics.RenderMesh를 쓴다. DrawMesh는 2022버전부터 안 씀.

 

 

EntitySpriteRenderer 컴포넌트를 추가하고 실행해보면?

 

 

원하는 위치에 Sprite가 그려졌다! 카메라 각도가 수직이 아니기 때문에 약간 휘어보이지만 이게 의도다.

 

그렇다면 이제 임의의 UV값이 아닌 Sprite의 정보를 기반으로 한 UV를 생성해보자.

 

 

 

 

 

Sprite 정보로 UV 생성하기

Sprite의 정보로 UV를 생성하기 위해서는 Sprite의 PixelPerUnit, Rect, Pivot, 원본(SpriteSheet)의 Rect가 필요하다. 이 과정에서 자연스럽게 Scale도 적용할 수 있다.

 

앞서 MeshExtension.MakeQuad()를 사용하였는데 해당 코드에서는 가로/세로 길이가 1인 Quad 메쉬를 생성한다. 따라서 지금 상황에서는 Sprite가 무조건 가로1 세로1의 길이로 그려진다.

 

그리하여 새로운 SpriteInfo에는 Pivot, UV, Scale이 추가되었다.

public struct SpriteInfo
{
    public float2 Pivot;
    public float4 UV;
    public float2 Scale;
    public int SpriteSheetID; // = Material ID

    public static SpriteInfo Make(Sprite sprite)
    {
        var spriteSheetID = SpriteSheetManager.Instance.GetSpriteSheetID(sprite.texture);
        if (spriteSheetID < 0)
            spriteSheetID = SpriteSheetManager.Instance.AddSpriteSheet(sprite.texture);

        SpriteInfo info = new SpriteInfo();
        info.SpriteSheetID = spriteSheetID;

        float textureWidth = sprite.texture.width;
        float textureHeight = sprite.texture.height;

        float uvWidth = sprite.rect.width / textureWidth;
        float uvHeight = sprite.rect.height / textureHeight;
        float uvOffsetX = sprite.rect.xMin / textureWidth;
        float uvOffsetY = sprite.rect.yMin / textureHeight;

        info.UV = new Vector4(uvWidth, uvHeight, uvOffsetX, uvOffsetY);

        info.Scale = new float2(sprite.rect.width / sprite.pixelsPerUnit,
            sprite.rect.height / sprite.pixelsPerUnit);

        info.Pivot = sprite.pivot / sprite.pixelsPerUnit;
        
        return info;
    }
}

sprite의 정보를 이용하여 지정된 sprite가 원본 SpriteSheet의 어디서부터 어디를 그려야 하는지(UV)를 계산한다. 여기에 Pivot과 Scale도 구해준다. Scale은 Scale보다는 Size 개념에 가깝지만...

 

 

새로운 시스템은 다음과 같다. 

public partial class SpriteRenderSystem : SystemBase
{
    private static readonly int ShaderIdMainTexUV = Shader.PropertyToID("_MainTex_UV");

    protected override void OnUpdate()
    {
        //#minor MeshExtension.MakeQuad(), MaterialPropertyBlock, UV를 Array로 만들 때 매번 힙 메모리 할당이 일어난다.
        
        foreach(var (entitySpriteRenderer, transformAspect) in SystemAPI.Query<EntitySpriteRenderer, TransformAspect>())
        {
            var spriteInfo = entitySpriteRenderer.SpriteInfo;
            var material = SpriteSheetManager.Instance.GetMaterial(spriteInfo.SpriteSheetID);
            RenderParams rp = new RenderParams(material);
            rp.matProps = new MaterialPropertyBlock();
            
            var uv = spriteInfo.UV;
           
            rp.matProps.SetVector(ShaderIdMainTexUV, uv);
            
            Matrix4x4 matrix = Matrix4x4.TRS(
                transformAspect.WorldPosition - new float3(spriteInfo.Pivot.x, spriteInfo.Pivot.y, 0f),
                transformAspect.WorldRotation,
                transformAspect.WorldScale * new float3(spriteInfo.Scale.x, spriteInfo.Scale.y, 0f));
            
            Graphics.RenderMesh(rp, MeshExtension.MakeQuad(), 0, matrix);
        }
    }
}

 

Pivot값은 엔티티의 위치에서 빼주고, 스케일 값에는 Sprite의 Scale(=Size)를 곱해준다.

 

 

잘 될까?

 

크기와 Pivot이 제 각각인 Sprite들을 적절한 위치에 지정해줬다. Sprite가 잘 나오고, Pivot이 잘 지정해졌는지 확인해보자.

 

각각의 Sprite가 아주 잘 그려졌다!

 

Pivot도 아주 잘 설정된 것을 확인할 수 있다!

 

 

 

 

 

 

색상과 IsFlip_X 추가하기

 

Flip을 구현하는 법은 간단하다. 그냥 UV를 뒤집어주면 된다.

if (entitySpriteRenderer.IsFlipX)
{
    uv.z += uv.x; //offset.x += width
    uv.x *= -1f; //width = -width
}

이런 식으로!

 

색상도 간단하다. 그냥 쉐이더에서 색상 아이디 얻어와서 지정해주면 됨.

private static readonly int ShaderIdColor = Shader.PropertyToID("_Color");

{
...
rp.matProps.SetVector(ShaderIdColor, entitySpriteRenderer.Color);
}

 

 

마지막 Sprite에 Flip을 체크하고 아무런 초록색이나 지정해주면?

 

잘 뒤집어지고 색상도 잘 입혀진다.

 

 

 


 

더 해야 할 것


(필수) 소팅을 구현해야 한다. 카메라를 Perspective를 쓸 가능성이 있기 때문에 단순히 z축에 값을 더하는 방법은 안 될거 같음.

 

(선택) SpriteSheet정보를 미리 Bake해두면 쓰레드 동기화를 신경 쓸 필요도 없고, 실제 게임 플레이 중 연산을 줄일 수 있음.

 

(선택)GPU Instancing을 적용할 수 있다. 다만 동일한 SpriteSheet를 사용하는 개체가 동시에 40개정도는 존재해 줘야 효과를 볼 수 있다. 이 경우 Material을 SharedComponent로 지정해주거나, 아니면 Material자체를 하나의 엔티티로 보고 해당 Material을 Draw할 정보를 DynamicBuffer등에 저장할 수 있다.