ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Unity] 커서 모양 바꾸기 + 커서 회전하기 [How to rotate custom cursor texture]
    앱 개발/Unity, C# 2024. 2. 18. 00:43

    역시 아래 블로그 내용과 같다.

     

    [Unity] 커서 모양 바꾸기 + 커서 회전하기 [How to rotate custom cursor texture]

    다들 Unity에서 custom cursor을 지정하려면 Cursor.SetCursor(texture, hotspot, cursorMode)를 사용...

    blog.naver.com

    같은 내용의 글을 두 번씩 업로드하는 게 좀 별로인 것 같아서, 앞으로 두 블로그를 분리해서

    티스토리-개발용/네이버-취미용으로 사용할까 한다.

    고로 앞으로 잘 부탁드립니다~~~


     

    다들 Unity에서 custom cursor을 지정하려면

    Cursor.SetCursor(texture, hotspot, cursorMode)를 사용하면 되는 것 정도는 알고 있을 것이다.

    Unity UI를 작성할 때 놓칠 수 있는 부분이 마우스 커서인데,

    커서 모양이 바뀌는 것만으로도 이 버튼이 누를 수 있는 것인지 알 수 있으며

    사용자가 어떤 동작을 해야 하는지에 대한 힌트도 제공해주기 때문에 빠질 수 없는 필수 요소이다!

     

    이번 글은 커서 바꾸는 방법에 대해서 작성을 하려 하는데, 이 방법 자체는 앞서 언급한 SetCursor가 전부다. 그래서 나는 커서를 회전시키기 위해 시도한 여러 방법과, (최종적으로 선택한 커서 회전 방법을 포함한) custom cursor을 다루는 내 나름대로의 방법을 정리하고자 한다.

     

    커서 회전이 필요했던 이유는 아래의 영상처럼 스티커 기능을 구현하기 위해서로,

    영상을 보고 내 이야기다 싶으면 커서 회전 방법 내용을 읽으면 되고,

    아니면 아래에 정리된 CursorManager 관련 내용만 읽으면 될 것 같다.

    참고로 이 내용은 검색 능력 부족으로 인해 내가 막 작성한/시도해본 방법이라,

    혹시 개선 사항이나 제가 찾지 못한 다른 brilliant한 방법이 있으면 댓글로 조언 부탁드립니다..! 언제나 환영입니다!

     

     

     


    마우스 커서 회전

    일단 마우스 커서 회전과 관련해 이미 구현/제공 되고 있는 기능이 있는지 찾아봤다.

    Unity 공식 documentation도 찾아보고 UnityEngine의 Cursor 클래스도 읽어 봤지만, 관련 내용은 한 톨도 찾아볼 수 없었다.

    우리가 할 수 있는 것은 여러 번 언급했다시피 SetCursor뿐.

    따라서 내가 내린 결론은 아래와 같았다:

    커서를 회전시키기 위해서는 기본 커서 텍스쳐를 회전 시킨 텍스처로 커서를 설정해야한다!

     

    이를 해결하기 위해 크게 3가지 방법을 시도해보았다.

    ---

    1. 기본 Texture 회전시키기

    2. CustomRenderTexture 이용해서 Texture 회전시키기

    3. 각도별로 360개의 커서 이미지를 추가하여 리스트로 관리하기

    ---

    결론은 3번이 가장 깔끔했다. 하나씩 설명해보겠다.

     

    1. 기본 Texture 회전시키기

    가장 먼저 떠올린 방법이기도 했고, 코드만 작성하면 되는 방법이라 첫 번재로 시도해보았다.

    public bool SetCursor(int angle, CursorMode mode = CursorMode.Auto)
    {
        if (Texture == null) return false;
        
        var rotatedTexture = RotateCursorImage(Texture, angle);
        var hotspot = new Vector2(Texture.width * HotSpot.x, Texture.height * HotSpot.y);
        Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto);
        Cursor.SetCursor(rotatedTexture, hotspot, mode);
                
        return true;
    }
    
    private static Texture2D RotateCursorImage(Texture2D originTexture, int angle)
    {
        var result = new Texture2D(originTexture.width, originTexture.height, TextureFormat.RGBA32, false );
        var pix1 = result.GetPixels32();
        var pix2 = originTexture.GetPixels32();
        var width = originTexture.width;
        var height = originTexture.height;
        var pix3 = RotateSquare(pix2, (Math.PI / 180 * (double)angle), originTexture);
        for (var j = 0; j < height; j++)
        {
            for (var i = 0; i < width; i++)
            {
                pix1[result.width / 2 - width / 2 + i + result.width * (result.height / 2 - height / 2 + j)] = pix3[i + j * width];
            }
        }
        result.SetPixels32(pix1);
        result.Apply();
        return result;
    }
    
    private static Color32[] RotateSquare(Color32[] arr, double phi, Texture2D originTexture)
    {
        int x;
        int y;
        var sn = Math.Sin(phi);
        var cs = Math.Cos(phi);
        var arr2 = originTexture.GetPixels32();
        var width = originTexture.width;
        var height = originTexture.height;
        var xc = width / 2;
        var yc = height / 2;
        for (var j = 0; j < height; j++)
        {
            for (var i = 0; i < width; i++)
            {
                arr2[j * width + i] = new Color32(0, 0, 0, 0);
                x = (int)(cs * (i - xc) + sn * (j - yc) + xc);
                y = (int)(-sn * (i - xc) + cs * (j - yc) + yc);
                if ((x > -1) && (x < width) && (y > -1) && (y < height))
                {
                    arr2[j * width + i] = arr[y * width + x];
                }
            }
        }
        return arr2;
    }
     

    단순히 할당된 커서 이미지를 angle 값만큼 회전시키는 로직이다.

    뭐 당연히 커서 회전 로직으로 검색하니 안 나왔고,

    '유니티 텍스쳐 회전시키기'나 'Unity texture rotation' 이런 검색어로 열심히 구글링해서 긁어온 것으로 조합했다.

     

    이 코드는 처음에는 잘 작동하는 것으로 보였으나,

        1. 픽셀 깨짐이 있었고,

        2. 연산량이 많아 회전 값이 계속해서 변하는 커서에는 적절하지 않았으며(커서가 깜빡거리는 현상 발생),

        3. 캐시가 남는지 최초 실행 이후부터는 커서 모양이 제대로 반영되지 않는 문제가 발생했다.

     

    3번 문제를 해결하기 위해 buffer을 이용하는 코드로 변경해보기도 했다.

    private static Texture2D RotateCursorImage(Texture2D originTexture, int angle)
    {
        var result = new Texture2D(originTexture.width, originTexture.height, TextureFormat.RGBA32, false);
        Graphics.CopyTexture(originTexture, result);
        RotateImage(result, angle);
        return result;
    }
    
    public static void RotateImage(Texture2D tex, float angleDegrees)
    {
        int width = tex.width;
        int height = tex.height;
        float halfHeight = height * 0.5f;
        float halfWidth = width * 0.5f;
    
        var texels = tex.GetRawTextureData<Color32>();        
        var copy = System.Buffers.ArrayPool<Color32>.Shared.Rent(texels.Length);
        Unity.Collections.NativeArray<Color32>.Copy(texels, copy, texels.Length);
    
        float phi = Mathf.Deg2Rad * angleDegrees;
        float cosPhi = Mathf.Cos(phi);
        float sinPhi = Mathf.Sin(phi);
    
        int address = 0;
        for (int newY = 0; newY < height; newY++)
        {
            for (int newX = 0; newX < width; newX++)
            {
                float cX = newX - halfWidth;
                float cY = newY - halfHeight;
                int oldX = Mathf.RoundToInt(cosPhi * cX + sinPhi * cY + halfWidth);
                int oldY = Mathf.RoundToInt(-sinPhi * cX + cosPhi * cY + halfHeight);
                bool InsideImageBounds = (oldX > -1) & (oldX < width)
                                                     & (oldY > -1) & (oldY < height);
        
                texels[address++] = InsideImageBounds ? copy[oldY * width + oldX] : default;
            }
        }
    
        // No need to reinitialize or SetPixels - data is already in-place.
        tex.Apply(true);
    
        System.Buffers.ArrayPool<Color32>.Shared.Return(copy);
    }
     

    그러나 해당 문제가 크게 해결된 것처럼 보이지 않았고, 여전히 다른 문제들이 남아 있어 다른 방법을 찾을 수밖에 없었다.

    연산량이 많기도 하고 화질도 깨져서 이런 단점 상관없이 한 번만 회전해야하는 이미지에는 해당 코드를 쓸 수도 있겠지만, 과연 그런 기능이 있을까 싶다.

     

     

    2. CustomRenderTexture 이용해서 Texture 회전시키기

    앞선 방법에서 화질 깨지는 문제를 해결하기 위해 생각해본 방법으로, 유니티 셰이더를 이용해 회전시키는 방법이다.

    기존에 유니티에서 이미지를 회전시키기 위해 Shader을 사용하는 방법을 확장하여, CustomRenderTexture에 이 셰이더를 적용시킨 Material을 설정하여 angle 값만큼 회전시킨 이미지를 커서로 설정하기로 했다.

    이를 위해 CustomRenderTexture을 생성하고, 이미지를 회전하는 Shader을 작성하고, 기존에 작성했던 CursorManager(아래에서 소개 예정)에 관련 로직 추가하고... 등등의 작업을 했지만...

     

    결론은 이 친구도 기각. RenderTexture을 Texture로 변환하는 과정에서 깜빡거림이 여전히 존재했다.

    (참고로 회전 Shader 작성은 아래의 영상을 참고했다.)

     

     

    3. 각도별로 360개의 커서 이미지를 추가하여 리스트로 관리하기

    결국... 가장 확실하지만 이게 맞나 싶어서 최후의 수단으로 남겨뒀던 방법을 꺼내들었다.

    말 그대로 0~359도 별로 이미지를 생성해 리스트로 관리하고, angle을 인덱스로 사용하는 방법이다.

     

    이렇게 보니까 자기장 같네...

    암튼 텍스쳐에 다른 처리 안 하고 그냥 저장되어 있는 놈을 가져와 쓰기 때문에 앞서 언급한 문제가 발생하지 않았다!

    커서가 깜빡거리지 않았고, 무엇보다도 텍스쳐가 깨지지 않아 눈이 편했다.

     

    추가로 이걸 하나하나 텍스쳐로 만드는 건 용량 + 관리 측면에서 별로인 것 같아,

    multi-sprite(Sprite Editor - Slice 하는 방식)으로 관리하기로 했다.

    이를 위해 Sprite array에서 가져온 sprite를 texture로 변환한 후 커서로 설정해주는 로직을 추가로 작성했다.

    (CursorSprites 코드 참고)

     

    아래는 static 클래스인 SpriteExtension에 추가로 구현한 메서드로, Sprite를 Texture2D로 바꾸는 코드이다.

    (sprite.texture 로 가져올 수 있는 줄 알았는데, 이러니까 slice된 sprite의 경우 원본 texture을 가져오더라는 사실... 알고 계셨읍니까...)

    public static Texture2D ConvertToTexture(this Sprite sprite, TextureFormat format = TextureFormat.RGBA32, bool mipChain = false)
    {
        var texture = new Texture2D( (int)sprite.textureRect.width, (int)sprite.textureRect.height, format, mipChain );
    
        var pixels = sprite.texture.GetPixels(  (int)sprite.textureRect.x, 
            (int)sprite.textureRect.y, 
            (int)sprite.textureRect.width, 
            (int)sprite.textureRect.height );
    
        texture.SetPixels( pixels );
        texture.Apply();
        return texture;
    }
     

    마우스 커서 관리 방법

     

    그래서 나는 커서를 어떻게 관리하는가?

    CursorManager라는 싱글톤 클래스와 CursorTexture / CursorSprites 클래스,

    그리고 CursorTexturesSO라는 ScriptableObject 클래스를 이용해 관리하고 있다.

     

     

    CursorTexturesSO : ScriptableObject

    [CreateAssetMenu(fileName = "CursorTextures", menuName = "AvaKit/Core/UI/CursorTextures")]
    public class CursorTexturesSO : ScriptableObject
    {
        public CursorTexture[] Cursors;
        public CursorSprites[] RotatableCursors;
    }
     

    커서 텍스쳐를 저장하는 스크립터블 오브젝트.

     

    커서 텍스쳐 관리 방법

     

    참고로 커서 텍스쳐 설정은 아래와 같이 하면 화질이 깨지지 않는다. (혹시 다른 최적화된 설정이 있으면 공유 부탁드립니다..!)

    유니티 커서 텍스쳐 설정 Unity Cursor Texture Setting

     

    CursorTexture / CursorSprites

    SetCursor 메서드를 가지고 있으며, 해당 커서의 성질에 맞게 커서 설정을 진행한다.

    public enum CursorType
    {
        Default,
        Pointer,
        Open,
        Grab,
        Text,
        Loading,
        Move,
        NorthSouth,
        EastWest,
        NorthWestSouthEast,
        NorthEastSouthWest,
        Pin,
    }
    
    [Serializable]
    public class CursorTexture
    {
        public Texture2D Texture;
        public CursorType Type;
        [Tooltip("Range [0, 1]")]
        public Vector2 HotSpot;
    
        public bool SetCursor(CursorMode mode = CursorMode.Auto)
        {
            if (Texture == null) return false;
            
            var hotspot = new Vector2(Texture.width * HotSpot.x, Texture.height * HotSpot.y);
            Cursor.SetCursor(Texture, hotspot, mode);
    
            return true;
        }
    }
     
    public enum RotatableCursorType
    {
        Rotate,
        Resize,
    }
    
    [Serializable]
    public class CursorSprites
    {
        public Sprite[] Sprites;
        public RotatableCursorType Type;
        [Tooltip("Range [0, 1]")]
        public Vector2 HotSpot;
    
        public bool SetCursor(int angle, CursorMode mode = CursorMode.Auto)
        {
            if (Sprites == null) return false;
    
            var modAngle = angle % 360;
            var index = modAngle < 0 ? modAngle + 360 : modAngle;
          
            var texture = Sprites[index].ConvertToTexture();
            var hotspot = new Vector2(texture.width * HotSpot.x, texture.height * HotSpot.y);
            Cursor.SetCursor(texture, hotspot, mode);
          
            return true;
        }
    }
     

     

    CursorManager : Singleton Class

    커서 설정에 대한 static 메서드를 가지고 있어, 어느 곳에서든 CursorManager.SetCursor(CursorType)을 통해 커서를 변경할 수 있다.

    참고로 현재 상속 중인 Singleton은 커스텀하게 작성한 클래스로, 일반적인 싱글톤 패턴이라고 생각하면 된다.

    public class CursorManager : Singleton<CoreSingleton, CursorManager>
    {
        #region Variables
        
        [SerializeField]
        private CursorTexturesSO CursorTexturesSO;
    
        [Header("Default Cursor")] [SerializeField]
        private CursorTexture DefaultCursor;
    
        private Dictionary<CursorType, CursorTexture> _cursors;
        private Dictionary<CursorType, CursorTexture> Cursors
        {
            get
            {
                if (_cursors == null)
                {
                    if (CursorTexturesSO == null) return null;
                    
                    var length = CursorTexturesSO.Cursors.Length;
                    _cursors = new Dictionary<CursorType, CursorTexture>();
                    for (var i = 0; i < length; i++)
                    {
                        _cursors.Add(CursorTexturesSO.Cursors[i].Type, CursorTexturesSO.Cursors[i]);
                    }
    
                    if (DefaultCursor == null && _cursors.TryGetValue(CursorType.Default, out var defaultCursor))
                    {
                        DefaultCursor = defaultCursor;
                    }
                }
    
                return _cursors;
            }
        }
        private Dictionary<RotatableCursorType, CursorSprites> _rotatableCursors;
        private Dictionary<RotatableCursorType, CursorSprites> RotatableCursors
        {
            get
            {
                if (_rotatableCursors == null)
                {
                    if (CursorTexturesSO == null) return null;
                    
                    var length = CursorTexturesSO.RotatableCursors.Length;
                    _rotatableCursors = new Dictionary<RotatableCursorType, CursorSprites>();
                    for (var i = 0; i < length; i++)
                    {
                        _rotatableCursors.Add(CursorTexturesSO.RotatableCursors[i].Type, CursorTexturesSO.RotatableCursors[i]);
                    }
                }
    
                return _rotatableCursors;
            }
        }
        
        #endregion Variables
    
        #region Unity Methods
    
        private void OnEnable() => SetCursorToDefault();
    
        #endregion Unity Methods
        
        #region Static Methods
    
        public static void ShowCursor() => Cursor.visible = true;
        public static void HideCursor() => Cursor.visible = false;
    
        public static void SetCursor(CursorType type, CursorMode mode = CursorMode.Auto) =>
            Instance?.SetCursorTexture(type, mode);
        public static void SetCursorToDefault(CursorMode mode = CursorMode.Auto) =>
            Instance?.SetCursorTexture(CursorType.Default, mode);
    
        public static void SetCursor(RotatableCursorType type, int angle, CursorMode mode = CursorMode.Auto) =>
            Instance?.SetCursorTexture(type, angle, mode);
    
        #endregion Static Methods
        
        #region Help Methods
        
        public void SetCursorTexture(CursorType type, CursorMode mode = CursorMode.Auto)
        {
            if (Cursors.TryGetValue(type, out var cursor))
            {
                cursor.SetCursor();
            }
            else
            {
                ResetCursor();
            }
        }
        public void SetCursorTexture(RotatableCursorType type, int angle, CursorMode mode = CursorMode.Auto)
        {
            if (RotatableCursors.TryGetValue(type, out var cursor))
            {
                cursor.SetCursor(angle);
            }
            else
            {
                ResetCursor();
            }
        }
    
        private void ResetCursor() => DefaultCursor?.SetCursor();
    
        #endregion Help Methods
    
    }
     
     

    뭔가 이번 포스팅에서는 텍스쳐 회전 방법이라던가, 커서 텍스쳐 설정 값이라던가,

    텍스쳐 회전 셰이더 등 잔잔바리한 정보들이 많은 것 같다.

    사실 저 CursorManager에 커서 위치 강제로 설정하는 메서드가 추가되어 있었는데,

    현재 주제랑 관련이 없기도 하고 코드가 너무 길어 내용이 잘리길래 일단 제외시켰다.

    이 부분은 이후에 다른 포스팅 하나로 작성할듯?

     

    암튼 커서 회전을 시도하시는 분들, 용기를 내서 이미지 360개 만들어봅시다!

    저는 아무런 정보가 없어서 반신반의하면서 하긴 했는데, 작동 잘 됩니다. 조금 귀찮긴 하지만, 생각해보면 커서를 계속 회전 시킬 거면 제일 효율적인 방법인 것 같기도 하구여.. 이렇게 선례가 있으니 도전해보셔도 나쁘지 않을듯...

    좋은 방법 있으시면 공유도 부탁드립니당... 맨땅에 헤딩 쉽지 않네요...

     

    끗~