개요
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등에 저장할 수 있다.