본문 바로가기

Programming/Unity

Unity - 메테리얼을 애니메이션에서 조작 가능하게 만들기

(Unity URP 사용)

 

목차

  • 개요
  • 스크립트로 메테리얼을 감싸 애니메이션 키로 조작 가능하게 만들기
  • 원본 메테리얼 값에 접근 막기

 


 

개요

 

SDF 셰이더를 활용한 링 이펙트

 

UI 연출을 작업하다 보면, 셰이더를 활용한 연출이 들어가면 좋을 것 같은 때가 있습니다. 저는 위와 같은 링 형태의 이펙트 연출을 할 때 SDF 방식의 셰이더를 주로 사용하는 편입니다.

물론 프레임 애니메이션 방식으로 링의 두께가 줄어드는 프레임 여러 장을 통해 유사한 느낌을 낼 수 있지만, 프레임 애니메이션 특성상 끊겨 보이는 문제가 있어 개인적으로는 셰이더로 처리하는 걸 선호하는 편입니다.

 

하지만 이러한 셰이더 연출을 사용할 때 단점이 있는데, 유니티에선 애니메이션에서 메테리얼에 접근하는 것이 불가능하다는 것입니다. (언리얼 엔진에서는 애니메이션에서 직접 메테리얼 값을 조작할 수 있습니다.) DoTween 스크립트를 작성해서 스크립트를 통해 메테리얼을 조작할 수는 있지만, 보통 이러한 연출은 키를 잡을 항목들도 많고, 애니메이션 커브도 만져야 하기 때문에, 애니메이션으로 작업하는 게 생산성 측면에서 좋습니다.

어떻게 해야할지 고민하던 와중, NDC 발표 영상 중 동일한 케이스가 있어서 해결의 실마리를 찾게 되었습니다.

 

NDC2022 쿠키런 킹덤 TA 임영만님 발표 中 (14:14)

 

유니티 애니메이션에서 스크립트 상의 public 변수들에는 접근 가능한데, 이걸 이용하는 것입니다. 스크립트로 메테리얼을 한번 감싸서, 애니메이션에서 스크립트를 통해  메테리얼 값에 접근이 가능하게 만들어주는 것이죠.

 


 

스크립트로 메테리얼을 감싸 애니메이션 키로 조작 가능하게 만들기

먼저 메테리얼 조작 함수만 구현된 MaterialControllerBase 라는 부모 클래스를 만들었습니다. 부모 클래스에서는 메테리얼을 들고있는 이미지만 변수로 가지고 있습니다. 나머지 동작들은, MaterialControllerBase를 상속받는 자식 클래스에서 이루어지게 했습니다.

 

조작할 메테리얼의 프로퍼티 변수들은 자식 클래스에서 선언하고, SetMaterialValue() 함수의 인자로 들어가게 됩니다. 연출에서 사용되는 메테리얼마다 프로퍼티 개수가 제각각이어서 자식 클래스에서 별도로 선언하도록 했습니다.

조작하는 프로퍼티를 배열이나 리스트 형태로 만들어, 상속 없이 하나의 스크립트로 통일하고 싶었지만... 유니티 애니메이션 상에서 배열의 요소들에는 접근이 불가능해서, 일단 연출마다 별도의 자식 스크립트를 만드는 방식으로 갔습니다.

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

[ExecuteInEditMode]
public class MaterialControllerBase : MonoBehaviour
{
    [SerializeField] private Image imageWithMat;

    public void SetMaterialValue(string property, float value = 0f)
    {
        if(imageWithMat.material != null && property != null)
        {
            imageWithMat.material.SetFloat(property, value);
        }
    }
}

 

이제 MaterialControllerBase 를 상속받는 자식 스크립트를 하나 만들어줍니다.
조작할 메테리얼의 프로퍼티 개수에 따라 변수를 선언하고, Update() 내부에서 함수를 호출해 조작해줍니다.

[ExecuteInEditMode] 키워드를 통해, 에디터상에서도 Update 구문이 돌아가며 메테리얼의 값을 조작할수 있습니다.

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class MaterialController_SampleChild : MaterialControllerBase
{
    [SerializeField] private string firstProperty;
    public float firstValue; // public 으로 선언해야 애니메이션에서 접근 가능
    [SerializeField] private string secondProperty;
    public float secondValue;
    [SerializeField] private string thirdProperty;
    public float thirdValue;
    
    void Update()
    {
        // 상속받은 메테리얼 조작 함수 동작
        SetMaterialValue(firstProperty, firstValue);
        SetMaterialValue(secondProperty, secondValue);
        SetMaterialValue(thirdProperty, thirdValue);
    }
}

 

그 다음은 오브젝트에 MaterialContollerBase 를 상속받은 자식 스크립트를 붙여주고, 조작할 메테리얼의 프로퍼티 이름을 입력한 뒤 키를 잡아주시면 됩니다. 나머지는 평소 애니메이션 작업하는 방식과 동일합니다!

 

애니메이션에서 스크립트 프로퍼티 추가하기

 

그렇게 간단히 키를 잡고 커브를 좀 만져주면, 아래와 같은 연출이 만들어집니다.

 

메테리얼 컨트롤러 동작예

 


 

원본 메테리얼 값에 접근 막기

하지만 앞서서 적용한 코드에는 원본 메테리얼 값에 직접 접근한다는 큰 단점이 있습니다.
원본 메테리얼에 직접 접근함으로서 같은 메테리얼을 사용하는 이미지들 전부에 변경한 값들이 적용되고, 변경 내역도 형상관리 툴에 계속 남게 됩니다.

이런 문제를 해결하기 위해서, 처음에는 Awake() 에서 원본 메테리얼의 인스턴스를 생성하고, 그 메테리얼 인스턴스를 다시 이미지에 넣어주는 방식으로 수정했었습니다.

다만 수정한 방식은 잘못 꼬이게 되면 적용해두었던 메테리얼이 이미지 컴포넌트에서 빠지거나, 메테리얼의 인스턴스의 인스턴스의 인스턴스... 를 생성하는 등의 여러가지 문제가 있었습니다. 물론 잘못 꼬이지만 않으면 정상적으로 동작하기에 고칠 여력이 없었을 때는 그대로 사용했지만요.

 

여유가 생겼을 때, 회사의 천재 클라이언트 개발자 분이 다 고쳐주시고, 자료도 공유해주셨습니다. 공유해주신 아래 블로그의 글을 바탕으로 원본 메테리얼에 직접 접근하는 방식을 개선해보겠습니다.

 

 

[Unity]正攻法でUIのマテリアルをいじる - Qiita

本記事はQualiArts Advent Calendar 2021 12日目の記事となります。シェーダーのプロパティをC#で制御するUIの彩度を変更するシェーダーを作りました。シェーダーの …

qiita.com

 

···

    [RequireComponent(typeof(Graphic))]
    [ExecuteAlways]
    [DisallowMultipleComponent]
    [AddComponentMenu("UI/Effects/Saturation", 15)]
    public class UISaturation : UIBehaviour, IMaterialModifier
    {
        [SerializeField]
        private float _saturation = 1;

        [NonSerialized]
        private Graphic _graphic;
        public Graphic graphic => _graphic ? _graphic : _graphic = GetComponent<Graphic>();

        /// 채도 변경을 위한 메테리얼
        [NonSerialized]
        private Material _saturationMaterial;

        public readonly int saturationPropertyId = Shader.PropertyToID("_Saturation");

        protected override void OnEnable()
        {
            base.OnEnable();
            if(graphic == null) return;
            _graphic.SetMaterialDirty();
        }

        protected override void OnDisable()
        {
            base.OnDisable();
            if (_saturationMaterial != null) DestroyImmediate(_saturationMaterial);
            _saturationMaterial = null;

            if (graphic != null) _graphic.SetMaterialDirty();

        }

#if UNITY_EDITOR
        protected override void OnValidate()
        {
            base.OnValidate();
            if (!IsActive() || graphic == null) return;
            graphic.SetMaterialDirty();
        }
#endif

        protected override void OnDidApplyAnimationProperties()
        {
            base.OnDidApplyAnimationProperties();
            if (!IsActive() || graphic == null) return;
            graphic.SetMaterialDirty();
        }

        public Material GetModifiedMaterial(Material baseMaterial)
        {
            // 채도 변경에 대응하지 않는 메테리얼 처리
            if (IsActive() == false || _graphic == null || !baseMaterial.HasProperty(saturationPropertyId))
                return baseMaterial;

            // 메테리얼 복제
            if (_saturationMaterial == null)
            {
                _saturationMaterial = new Material(baseMaterial);
                _saturationMaterial.hideFlags = HideFlags.HideAndDontSave;
            }

            // 이전 프로퍼티 값 인수
            _saturationMaterial.CopyPropertiesFromMaterial(baseMaterial);

            _saturationMaterial.SetFloat(saturationPropertyId, _saturation);

            return _saturationMaterial;
        }
    }

 

자료의 코드에서 IMaterialModifier 인터페이스의 GetModifiedMaterial() 함수가 핵심인데, Graphic 의 메테리얼에 더티 플래그가 설정된 타이밍에 호출되는 함수라고 합니다. 찾아도 유니티 문서 한줄 외엔 잘 나오지 않아 글의 설명을 인용했습니다.

해당 함수에서 원본 메테리얼을 가져와서 새 메테리얼을 만들고, 초기 설정을 마친 뒤 메테리얼의 프로퍼티 값을 변경하는 구조로 되어 있습니다.

나머지 부분에서는 오브젝트가 활성화 또는 비활성화될 때의 처리가 먼저 들어가고, 에디터상에서 값이 편집되었을 때는 OnValidate() 에서, 애니메이션을 통해 값이 변경되었을 때는 OnDidApplyAnimationProperties() 함수에서 더티 플래그로 GetModifiedMaterial() 을 호출해 메테리얼의 값을 변경하게 됩니다.

 


 

원본 코드의 GetModifiedMaterial() 내부에서 SetFloat() 로 메테리얼의 값을 변경하는 부분을 별도의 virtual 함수로 빼서, 자식 클래스에서 상속받아 override 해 사용함으로 처음 만든 코드와 동일하게 여러 개의 프로퍼티들을 조작할 수 있게 아래와 같이 수정했습니다.

 

[RequireComponent(typeof(Graphics))]
[ExecuteAlways]
public class MaterialControllerBase : UIBehaviour, IMaterialModifier
{
    [SerializeField] private string firstProperty;
    public float firstValue = 0;

    [NonSerialized]
    private Graphic _graphic;
    public Graphic graphic => _graphic ? _graphic : _graphic = GetComponent<Graphic>();

    [NonSerialized]
    protected Material material;


    protected override void OnEnable()
    {
        base.OnEnable();

        if(graphic == null)
            return;
        
        _graphic.SetMaterialDirty();
    }

    protected override void OnDisable()
    {
        base.OnDisable();

        if (material != null)
            DestroyImmediate(material);
        
        material = null;

        if (graphic != null)
            _graphic.SetMaterialDirty();

    }

#if UNITY_EDITOR
    // 에디터에서 변경사항이 생긴 경우 Dirty 플래그를 통해 GetModifiedMaterial() 호출
    protected override void OnValidate()
    {
        base.OnValidate();

        if (!IsActive() || graphic == null)
            return;

        graphic.SetMaterialDirty();
    }
#endif

    // 애니메이션을 통해 값이 변경되었을 때 Dirty 플래그를 통해 GetModifiedMaterial() 호출
    // Callback for when properties have been changed by animation.
    protected override void OnDidApplyAnimationProperties()
    {
        base.OnDidApplyAnimationProperties();

        if (!IsActive() || graphic == null)
            return;
        
        graphic.SetMaterialDirty();
    }

    // Graphic의 메테리얼에 Dirty 플래그가 설정된 타이밍에 호출됨
    // Perform material modification in this function.
    public Material GetModifiedMaterial(Material baseMaterial) 
    {
        if (IsActive() == false || _graphic == null)
            return baseMaterial;

        if (material == null) // 메테리얼 복제
        {
            material = new Material(baseMaterial);
            material.hideFlags = HideFlags.HideAndDontSave;
            // 컴포넌트에서만 생성, 삭제되는 머테리얼로 지정
            // 인스펙터에 표시되지만, 프로퍼티 조정못함
            
            material.CopyPropertiesFromMaterial(baseMaterial);
            // 현재까지의 프로퍼티 값을 인수
        }

        EditMaterialPropertiesValue();

        return material;
    }

    // 자식 클래스에서 조정하는 프로퍼티를 추가할 수 있도록 virtual 로 선언
    public virtual void EditMaterialPropertiesValue()
    {
        material.SetFloat(firstProperty, firstValue);
    }
}

 

[ExecuteAlways]
public class MaterialController_Sample : MaterialControllerBase
{
    [SerializeField] private string secondProperty;
    public float secondValue;
    [SerializeField] private string thirdProperty;
    public float thirdValue;

    public override void EditMaterialPropertiesValue()
    {
        base.EditMaterialPropertiesValue();

        // 추가 메테리얼 속성값들 편집
        material.SetFloat(secondProperty, secondValue);
        material.SetFloat(thirdProperty, thirdValue);
    }
}

 

유니티 코드 전문은 아래 링크에서 확인하실 수 있습니다.

 

 

ShaderStudy_Unity/Shader/Assets/Scripts/MaterialController at main · rkato0128/ShaderStudy_Unity

Contribute to rkato0128/ShaderStudy_Unity development by creating an account on GitHub.

github.com

 

 

 

'Programming > Unity' 카테고리의 다른 글

Unity - 렌더 텍스처 이미지로 저장하기  (0) 2022.09.18