WinCNT

Unity에서 커스텀 ShaderGUI를 구현해보자! 그 두번째 본문

Unity/Unity 관련

Unity에서 커스텀 ShaderGUI를 구현해보자! 그 두번째

WinCNT_SSS 2024. 2. 9. 10:59

서론

전에 커스텀 ShaderGUI를 구현하는 법을 작성한 적이 있었다

https://wincnt-shim.tistory.com/371

 

Unity에서 커스텀 ShaderGUI를 구현해보자! 그 첫번째

서론 이번에는 Unity에서 커스텀 ShaderGUI를 구현해보기로 했다 ShaderGUI란 특정 셰이더의 머티리얼에 대한 Inspector의 UI를 제작할 수 있게 해주는 클래스라고 생각하면 될 것 같다 이 글에 구현하면

wincnt-shim.tistory.com

 

 

그 글을 작성한 뒤로 새롭게 알게 된 방법들이 있어서 추가로 정리하고자 한다

그리고 셰이더 키워드를 On/Off하는 방법도 겸사겸사 정리하고자 한다


ShaderGUI의 존재 의의를 뒤흔드는 MaterialPropertyDrawer

사실 간단한 UI라면 ShaderGUI를 계승한 클래스를 만들지 않고도 MaterialPropertyDrawer를 사용해서 구현할 수 있다

Unity - Scripting API: MaterialPropertyDrawer

 

Unity - Scripting API: MaterialPropertyDrawer

Use this to create custom UI drawers for your material properties, without having to write custom MaterialEditor classes. This is similar to how PropertyDrawer enables custom UI without writing custom inspectors. In shader code, C#-like attribute syntax ca

docs.unity3d.com

 

사실 많이들 봤을 거라고 생각한다

꽤나 강력한 기능으로 Enum과 Toggle 정도는 한 조각의 케이크마냥 쉽게 구현할 수 있다

심지어 Toggle은 셰이더의 Keyword도 On/Off도 바꿔줄 수 있다

Shader "Custom/Example"
{
    Properties
    {
        [KeywordEnum(None, Add, Multiply)] _Overlay("Overlay mode", Float) = 0
        [Toggle] _Invert("Invert color?", Float) = 0
    }

    // rest of shader code...
}


ShaderGUI와 MaterialPropertyDrawer를 병행하면 살짝 귀찮을 때가 있다…특히 Header라던가 Space라던가…

MaterialPropertyDrawer가 아무리 편리해도 모든 범위를 커버할 순 없으므로사실 대부분은 커버할 수 있을 거 같은데ShaderGUI은 필요하며, 둘을 병행해서 사용하기도 한다

 

문제는 다음과 같이 Header나 Space 등을 쓰는 경우 조금 귀찮아질 수 있다

Shader "Custom/Example"
{
    Properties
    {
        [Header(UV Scroll)]
        [Space(10)]
        [Toggle] _IsScroll("Is Scrolling", Float) = 0.0
    }

    // rest of shader code...
}

 

위의 소스 코드의 경우는 ShaderGUI가 없거나 base.OnGUI면 다음과 같이 별 문제 없이 나온다

 

그런데 디폴트는 그대로 두고, ShaderGUI에서는 아래와 같이 조금 다른 Label를 사용하거나 싶다고 해보자

이 때 평소 하던대로 ShaderProperty를 쓰면 Header와 Label이 이중(…)으로 나오게 된다

GUILayout.Label(Styles.ScrollText, EditorStyles.boldLabel);
_materialEditor.ShaderProperty(IsScroll, Styles.IsScrollText);

 

그렇다고 DefaultShaderProperty를 쓰기에는 Toggle이 없어져버리고…

GUILayout.Label(Styles.ScrollText, EditorStyles.boldLabel);
_materialEditor.DefaultShaderProperty(IsScroll, Styles.IsScrollText.text);

 

“그냥 셰이더 쪽의 MaterialPropertyDrawer을 포기하면 되는 거 아님?”이라고 생각할 수 있는데, 사실 필자도 그렇게 생각한다

그래도 다른 방법이 없냐하면 또 그런 것은 아니다!!

EditorGUI.Toggle과 EditorGUILayout.GetControlRect를 이용해서 구현할 수 있다

// UV Scroll Label
GUILayout.Label(Styles.ScrollText, EditorStyles.boldLabel);

// Is Scroll
bool isScroll = (IsScroll.floatValue != 0.0f);
EditorGUI.BeginChangeCheck();
EditorGUI.showMixedValue = IsScroll.hasMixedValue;
isScroll = EditorGUI.Toggle(EditorGUILayout.GetControlRect(), Styles.IsScrollText, isScroll);
EditorGUI.showMixedValue = false;
if (EditorGUI.EndChangeCheck())
{
    IsScroll.floatValue = isScroll ? 1.0f : 0.0f;
}

 

그럼 짜잔~!


프로퍼티에 Int(정수)만 입력되도록 하기!

셰이더 쪽에는 분명 int로 설정했는데도 Inspector에는 float로 나오는 게 신경쓰였다

Shader "Custom/Example"
{
    Properties
    {
        _AnimationIndex("Animation Index Control", int) = 0.0
    }
}

 

이건 Toggle과 비슷한 방식으로 EditorGUI의 IntField를 써서 해결했다! 만세!

var animIdx = (int)AnimationIndex.floatValue;
EditorGUI.BeginChangeCheck();
EditorGUI.showMixedValue = AnimationIndex.hasMixedValue;
animIdx = EditorGUI.IntField(EditorGUILayout.GetControlRect(), Styles.AnimationIndexText, animIdx);
EditorGUI.showMixedValue = false;
if (EditorGUI.EndChangeCheck())
    AnimationIndex.floatValue = animIdx;

 

…라고 생각하던 시기가 저에게도 있었습니다

Int는 Toggle과는 다르게 MaterialEditor에 API가 있었다!

_materialEditor.IntShaderProperty(AnimationIndex, Styles.AnimationIndexText);

 

참고로 내부를 확인해보니 거의 같았다…이거 써야지

public static void IntShaderProperty(this MaterialEditor editor, MaterialProperty prop, GUIContent label, System.Func<int, int> transform = null)
{
    MaterialEditor.BeginProperty(prop);

    EditorGUI.BeginChangeCheck();
    EditorGUI.showMixedValue = prop.hasMixedValue;
    int newValue = EditorGUI.IntField(GetRect(prop), label, (int)prop.floatValue);
    EditorGUI.showMixedValue = false;
    if (EditorGUI.EndChangeCheck())
    {
        if (transform != null)
            newValue = transform(newValue);
        prop.floatValue = newValue;
    }

    MaterialEditor.EndProperty();
}

프로퍼티 2개를 그럴듯하게 한 줄로 표시하기!

UI를 작성하다 보니 위와 같이 X랑 Y가 두 줄로 표시되는 것이 매우 마음에 들지 않았다…

그래서 Tiling과 Offset를 표시하는 API를 분석해서 나름 구현해봤다

float width = EditorGUIUtility.labelWidth;
float height = EditorGUIUtility.singleLineHeight;
Rect position = EditorGUILayout.GetControlRect(true);
Rect labelPosition = new Rect(position.x, position.y, width, height);

MaterialEditor.BeginProperty(position, SpeedX);
EditorGUI.BeginChangeCheck();
// Label
EditorGUI.PrefixLabel(labelPosition, Styles.SpeedText);
// Property
Rect propPosition = new Rect(position.x + width, position.y, position.width - width, height);
var value = EditorGUI.Vector2Field(propPosition, GUIContent.none, new Vector2(SpeedX.floatValue, SpeedY.floatValue));
if (EditorGUI.EndChangeCheck())
{
    SpeedX.floatValue = value.x;
    SpeedY.floatValue = value.y;
}
MaterialEditor.EndProperty();

 

핵심은 EditorGUI.Vector2Field라는 API였는데 아무튼 좋은 느낌으로 완성!

X, Y 말고는 다른 라벨을 붙일 수 없나?

X, Y 말고 위의 Row와 Col인 라벨을 붙이는 법이 없나 찾아봤다

다행히 MultiFloatField를 쓰면 어떻게 될 것 같아서 사용해봤다!

if (Row != null && Column != null)
{
    GUIContent label = EditorGUIUtility.TrTextContent("Column and Row");
    GUIContent[] subLabel = { Styles.RowText, Styles.ColumnText };
    float[] val = new[] { Row.floatValue, Column.floatValue };

    EditorGUI.BeginChangeCheck();
    EditorGUI.MultiFloatField(EditorGUILayout.GetControlRect(), label, subLabel, val);
    if (EditorGUI.EndChangeCheck())
    {
        Row.floatValue = val[0];
        Column.floatValue = val[1];
    }
    // 다음 프로퍼티와 겹치지 않도록 조정
    EditorGUILayout.GetControlRect();
}

 

그럼 따란~!

그런데 생각보다 알아보기 힘들어서 되돌렸다


Enum으로 Drop-down list 만들기

이번에는 Opaque, Cutoff의 값을 가진 Surface Type을 추가해서 셰이더의 키워드를 바꿀 수 있도록 해봤다

셰이더 키워드 설정은 나중에 정리하도록 하고 우선은 Surface Type란 Enum을 ShaderGUI에 어떻게 표시할 수 있는지 찾아봤다

EnumPopup를 사용

찾아보니 EnumPopup이란 것이 있길래 사용해봤다

(참고로 Styles은 GUIContent 등을 정리한 내부 클래스이다)

private enum SurfaceType
{
    Opaque,
    Cutoff
}
public static readonly GUIContent SurfaceTypeText = EditorGUIUtility.TrTextContent("Surface Type", "Opaque / Cutoff");
private static SurfaceType _surfaceType = SurfaceType.Opaque;

public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
{
    _surfaceType = (SurfaceType)EditorGUILayout.EnumPopup(Styles.SurfaceTypeText, _surfaceType);
}

Enum이 잘 나오긴 하지만 아쉽게도 Undo와 Redo(Ctrl + Z, Ctrl + Y)는 적용되지 않았다

셰이더 쪽에 프로퍼티가 없어도 된다는 장점이 있지만 일단 기각

PopupShaderProperty를 사용

다음은 PopupShaderProperty란 메소드를 사용해봤다

프로퍼티가 필요하다고 하니 먼저 셰이더 쪽에 추가해줬다(Find Proerties 등은 생략)

Shader "SSS/Custom"
{
    Properties
    {
        // ...생략...
        [HideInInspector]_Surface("__surface", Float) = 0.0
        // ...생략...
    }
}

그리고 다음과 같이 사용해봤다

private static class Styles
{
    public static readonly string[] SurfaceTypeNames = Enum.GetNames(typeof(SurfaceType));
    // ...생략...
}

// ...생략...

private enum SurfaceType
{
    Opaque,
    Cutout,
    Transparent
}

// ...생략...

public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
{
    SurfaceType surfaceType = (SurfaceType)_materialEditor.PopupShaderProperty(Surface, Styles.SurfaceTypeText, Styles.surfaceTypeNames);
    // ...생략...
}

Enum도 잘 나오고Undo와 Redo(Ctrl + Z, Ctrl + Y)도 잘 됐다!

PopupShaderProperty의 내부

궁금해서 메소드의 내부를 조금 확인해봤다

/// <summary>
/// Draw a popup selection field for a float shader property.
/// </summary>
/// <param name="editor"><see cref="MaterialEditor"/></param>
/// <param name="prop">The MaterialProperty to make a field for</param>
/// <param name="label">Label for the property</param>
/// <param name="displayedOptions">An array with the options shown in the popup</param>
/// <returns>The index of the option that has been selected by the user</returns>
public static int PopupShaderProperty(this MaterialEditor editor, MaterialProperty prop, GUIContent label, string[] displayedOptions)
{
    MaterialEditor.BeginProperty(prop);

    int val = (int)prop.floatValue;

    EditorGUI.BeginChangeCheck();
    EditorGUI.showMixedValue = prop.hasMixedValue;
    int newValue = EditorGUILayout.Popup(label, val, displayedOptions);
    EditorGUI.showMixedValue = false;
    if (EditorGUI.EndChangeCheck() && (newValue != val || prop.hasMixedValue))
    {
        editor.RegisterPropertyChangeUndo(label.text);
        prop.floatValue = val = newValue;
    }
    MaterialEditor.EndProperty();
    return val;
}

 

Undo와 Redo(Ctrl + Z, Ctrl + Y)가 적용된 건 분명 editor.RegisterPropertyChangeUndo(label.text);가 있기 때문이겠지


Emission 프로퍼티는 있는 거 쓰자!

이번에 추가로 구현하면서 Emission은 의외로 신경써야 할 점이 많다는 것을 깨달았다

그냥 빛나게 하는 것 뿐이면 간단하지만, 전역 조명(GI)이 연관되면 복잡해진다!

예를 들면, Emission은 Light Map에 구울까? 아니면 리얼 타임으로 영향을 줄까? 역시 주변에는 영향을 주지 않고 그냥 물체가 빛나기만 할까? Emission의 Color가 검정색이면 어떻게 해야하지? 등등

 

설정 자체는 머티리얼의 globalIlluminationFlags라는 프로퍼티를 통해서 할 수 있다

Unity - Scripting API: Material.globalIlluminationFlags

 

Unity - Scripting API: Material.globalIlluminationFlags

Success! Thank you for helping us improve the quality of Unity Documentation. Although we cannot accept all submissions, we do read each suggested change from our users and will make updates where applicable. Close

docs.unity3d.com

하지만 다양한 플래그를 관리하려면 머리만 아플 뿐…

 

다행히 이 부분은 디폴트 Lit 셰이더의 GUI에 이미 구현되어 있기 때문에, 그대로 가져다 쓰기로 했다

아래는 예시 코드이다

public override void ValidateMaterial(Material material)
{
    // Emission
    if (EmissionColor != null)
        MaterialEditor.FixupEmissiveFlag(material);
    bool shouldEmissionBeEnabled = (material.globalIlluminationFlags & MaterialGlobalIlluminationFlags.EmissiveIsBlack) == 0;
    CoreUtils.SetKeyword(material, "_EMISSION", shouldEmissionBeEnabled);
}

public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
{
    if (materialEditor == null)
        throw new ArgumentNullException("materialEditor");

    _materialEditor = materialEditor;
    Material material = materialEditor.target as Material;

    FindProperties(properties);

    // ...생략...

    // Emission
    var emissive = _materialEditor.EmissionEnabledProperty();
    using (new EditorGUI.DisabledScope(!emissive))
    {
        if ((EmissionMask == null) || (EmissionColor == null))
            return;
        using (new EditorGUI.IndentLevelScope(2))
        {
            _materialEditor.TexturePropertyWithHDRColor(Styles.EmissionMap, EmissionMask, EmissionColor, true);
        }
        _materialEditor.LightmapEmissionFlagsProperty(MaterialEditor.kMiniTextureFieldLabelIndentLevel, emissive);
    }
}

그러면 디폴트 Lit 셰이더와 같이 Emission의 On/Off나 GI 플러그 설정을 그대로 쓸 수 있다!

 

주의해야 할 점셰이더 쪽에 _EmissionColor라는 프로퍼티를 가지고 있어야 한다는 것이다

이는 FixupEmissiveFlag 메소드가 내부에서 _EmissionColor란 이름의 프로퍼티에서 색상 값을 획득하기 때문이다(당연히 색상 값은 플러그 설정에도 영향을 준다)

일반적으로 Emission을 사용하면 Emission Color도 사용할테고 이름도 보통 _EmissionColor라고 할테니 딱히 문제는 없다고 할 수 있다

하지만 현재 프로젝트에는 한 머티리얼에 Main과 Sub 텍스처를 동시에 설정하고 물론 Emission도 따로 설정하고 심지어 Emission Color는 설정하지 않는 셰이더가 있는데 그건 어떻게 해야하지 으아아아!! 살려줘!!


Blend를 설정하기

ShaderGUI를 통해서 Blend 단계의 설정도 컨트롤할 수 있다

Unity Shader파일에서의 Blend에 대한 건 공식 문서에 자세히 설명되어 있다

Unity - Manual: ShaderLab command: Blend

 

Unity - Manual: ShaderLab command: Blend

ShaderLab command: AlphaToMask ShaderLab command: BlendOp ShaderLab command: Blend Determines how the GPU combines the output of the fragment shaderA program that runs on the GPU. More infoSee in Glossary with the render target. The functionality of this c

docs.unity3d.com

Early-Z…또 너야?! 하지만 이번엔 괜찮았죠?

그런데 공식 문서를 보니 매우 신경쓰이는 문구를 발견했다

Enabling blending disables some optimizations on the GPU (mostly hidden surface removal/Early-Z), which can lead to increased GPU frame times. 블렌딩을 활성화하면 GPU의 일부 최적화(주로 숨겨진 표면 제거/Early-Z)가 비활성화되어 GPU 프레임 시간이 늘어날 수 있습니다.

이 문구를 보고 불투명 오브젝트에도 불구하고 Blend가 On이라서 퍼포먼스가 떨어지진다는 최악의 경우가 발생하지 않을까 매우 걱정이 되었다

(알파 테스트나 반투명일 때는 퍼포먼스가 떨어지는 거 알고 사용하니 별 상관 없지만)

좀 그만 괴롭혀…Early-Z야…흑흑

그래도 실제로는 어떤지 궁금해서 Blend Off로 설정한 셰이더가 프레임 디버그에서 어떻게 나오는지 확인해봤다

어…?

 

이걸 보니 ‘혹시 유니티에서는 Blend One Zero를 해도 Blend를 Off로 해주지 않을까?’라는 생각이 들기 시작했다

나름 근거를 얻고 싶어서 진짜 엄청 찾아봤는데 검색어 선정하기 어려워서 좀처럼 나오지 않았다

그래도 결국 다음과 같은 Forum을 찾았다! 만세!

Is There Extra Performance Cost for "Blend One Zero" ?

 

Is There Extra Performance Cost for "Blend One Zero" ?

I want to write a custom shader that can let users to pick which blend mode they want just like the Standard shader, but my question is does explicitly...

forum.unity.com

조사가 끝났으면 구현이 시작되지

조사가 끝났을 때 일이 다 끝난 것처럼 느껴졌지만 실제로는 만들어진 건 아무것도 없었다

이제부터는 어떻게 구현해봤는지 정리해보려고 한다

 

하지만 기본은 똑같았다

셰이더 쪽에 프로퍼티를 선언하고, ShaderGUI 쪽에서 상황에 알맞게 값을 설정해주면 된다

 

아래는 샘플 코드의 일부분이다(셰이더의 프로퍼티 값을 찾는 부분은 생략)

 

Shader

Shader "Custom/Example"
{
    Properties
    {
        [HideInInspector][Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("__SrcBlend", Float) = 1
        [HideInInspector][Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("__DstBlend", Float) = 0
        // rest of shader code...
    }

    SubShader
    {
        Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }

        Pass
        {
            Name "UVScroll"
            Tags { "LightMode" = "UniversalForward" }

            // <https://forum.unity.com/threads/is-there-extra-performance-cost-for-blend-one-zero.1154021/>
            Blend [_SrcBlend][_DstBlend]
        }
    }
}

 

ShaderGUI

public override void ValidateMaterial(Material material)
{
    if (material == null)
        throw new ArgumentNullException("material");
    
    bool alphaClip = false;
    if (Cutoff != null)
        alphaClip = Cutoff.floatValue >= 0.5;
    
    // Clear
    int renderQueue = material.shader.renderQueue;
    material.SetOverrideTag("RenderType", "");
    if (SrcBlend != null) SrcBlend.floatValue = (float)BlendMode.One;
    if (DstBlend != null) DstBlend.floatValue = (float)BlendMode.Zero;
    if (AlphaToMask != null) AlphaToMask.floatValue = 0.0f;
    material.DisableKeyword("_SURFACE_TYPE_TRANSPARENT");
    material.DisableKeyword("_ALPHATEST_ON");

    if (Surface != null)
    {
        SurfaceType surfaceType = (SurfaceType)Surface.floatValue;

        if (surfaceType == SurfaceType.Opaque)
        {
            material.SetOverrideTag("RenderType", "");
            if (SrcBlend != null) SrcBlend.floatValue = (float)BlendMode.One;
            if (DstBlend != null) DstBlend.floatValue = (float)BlendMode.Zero;
            if (ZWrite != null) ZWrite.floatValue = 1.0f;
            if (AlphaToMask != null) AlphaToMask.floatValue = 0.0f;
            material.renderQueue = (int)RenderQueue.Geometry;
        }
        else if (surfaceType == SurfaceType.Cutout)
        {
            material.SetOverrideTag("RenderType", "TransparentCutout");
            if (SrcBlend != null) SrcBlend.floatValue = (float)BlendMode.One;
            if (DstBlend != null) DstBlend.floatValue = (float)BlendMode.Zero;
            if (ZWrite != null) ZWrite.floatValue = 1.0f;
            if (AlphaToMask != null) AlphaToMask.floatValue = 1.0f;
            CoreUtils.SetKeyword(material, "_ALPHATEST_ON", alphaClip);
            material.renderQueue = (int)RenderQueue.AlphaTest;
        }
        else
        {
            // Blending Mode
            BlendingMode blendMode = (BlendingMode)Blend.floatValue;

            var srcBlend = UnityEngine.Rendering.BlendMode.One;
            var dstBlend = UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha;
            
            switch (blendMode)
            {
                case BlendingMode.Alpha:
                    srcBlend = UnityEngine.Rendering.BlendMode.SrcAlpha;
                    dstBlend = UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha;
                    break;
                case BlendingMode.Premultiply:
                    srcBlend = UnityEngine.Rendering.BlendMode.One;
                    dstBlend = UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha;
                    break;
                case BlendingMode.Additive:
                    srcBlend = UnityEngine.Rendering.BlendMode.One;
                    dstBlend = UnityEngine.Rendering.BlendMode.One;
                    break;
                case BlendingMode.Multiply:
                    srcBlend = UnityEngine.Rendering.BlendMode.DstColor;
                    dstBlend = UnityEngine.Rendering.BlendMode.Zero;
                    break;
            }
            
            material.SetOverrideTag("RenderType", "Transparent");
            if (SrcBlend != null) SrcBlend.floatValue = (float)srcBlend;
            if (DstBlend != null) DstBlend.floatValue = (float)dstBlend;
            if (AlphaToMask != null) AlphaToMask.floatValue = 1.0f;
            CoreUtils.SetKeyword(material, "_SURFACE_TYPE_TRANSPARENT", true);
            CoreUtils.SetKeyword(material, "_ALPHATEST_ON", alphaClip);
            // Geometry, AlphaTest < GeometryLast < Transparent
            material.renderQueue = (int)RenderQueue.GeometryLast;
        }
        if (QueueOffset != null)
            material.renderQueue += (int)QueueOffset.floatValue;
}

잘 사용하면 위의 코드처럼 반투명이 아닌 특수한 반투명을 구현하는 등의 응용도 가능하다!

코드에선 생략됐지만 ZTest도 같은 방식으로 가능하고, Blend Mode에서 RGB 값과 알파 값의 팩터를 별도로 설정하는 것도 가능하다


마무리

이번 작업을 통해서 ShaderGUI에 대한 이해가 더 깊어지게 되었다

개인적으로는 드디어 셰이더에 키워드를 설정할 수 있게 되었다는 실감이 들었다

아무튼 이번에도 크나큰 도움을 준 BaseShaderGUI.cs에 대해 무한한 감사를……


참고 사이트

How to write a general shader code for different render type like standard shader

 

How to write a general shader code for different render type like standard shader

Hi. I have some subshaders for each render type, opaque, transparent, etc. How can I select one of them in C# scripts or the inspector like standard...

forum.unity.com

Is There Extra Performance Cost for "Blend One Zero" ?

 

Is There Extra Performance Cost for "Blend One Zero" ?

I want to write a custom shader that can let users to pick which blend mode they want just like the Standard shader, but my question is does explicitly...

forum.unity.com

[Unity]ZTestやBlend等をシェーダー外から設定する - Qiita

 

[Unity]ZTestやBlend等をシェーダー外から設定する - Qiita

サンプルとして単純なSprite用のシェーダーっぽいのShader "Custom/Sprite" { Properties { // TIPS: モバイル環境でScaleとOffsetは使えない…

qiita.com