WinCNT

URP에서 팔레트 스왑(Palette Swap) 기능 구현하기 본문

Unity/URP or Shader 관련

URP에서 팔레트 스왑(Palette Swap) 기능 구현하기

WinCNT_SSS 2024. 2. 19. 14:15

서론

예산 문제로 아트팀에서 적은 코스트로 몬스터의 다양성을 늘리고 싶다는 과제가 발생했었다

그래서 해결을 위해 셰이더에 팔레트 스왑 기능 추가해봤다

 

참고로 팔레트 스왑이란 이런 느낌

 

https://namu.wiki/w/%ED%8C%94%EB%A0%88%ED%8A%B8%20%EC%8A%A4%EC%99%91

 

팔레트 스왑

Palette Swap 게임 및 애니메이션에서 색 차분 을 컴퓨터로 구현하는 방법. 부족한 메모리를 아끼는 방법이

namu.wiki

당연히 옛날 하드웨어에서 사용하던 진정한 의미의 팔레트 스왑은 아니고 비슷한 효과를 가지는 셰이더을 만들 뿐이다


Blend modes 선정

사실 옛날의 팔레트 스왑도 아니고, 단순히 색을 바꾸기 위해서는 마스크와 셰이더에서의 간단한 계산 추가로 구현할 수 있을 것이다

하지만 좀 더 궁리를 해서, 아티스트 분들이 좀 더 사용하기 쉽게 포토샵의 블렌드 모드를 선택할 수 있게 하기로 했다

 

블렌드 모드의 공식들은 위키피디아에 나와 있다

Blend modes

 

Blend modes - Wikipedia

From Wikipedia, the free encyclopedia How two or more digital photo layers are mixed together A sketch colored digitally with use of several different blend modes in order to preserve the pencil lines and paper texture below the color layers. Blend modes (

en.wikipedia.org

 

다양한 모드들이 있지만 자주 사용되는 Linear, Multiply, Screen, Overlay를 구현하기로 했다

  • Linear
    • 결과 = 기본색 + 합성색
  • Multiply
    • 결과 = 기본색 * 합성색
  • Screen
    • 결과 = 1 - (1 - 기본색) * (1 - 합성색)
      • ∴ 결과 = Linear - Multiply
  • Overlay
    • 기본색 < 0.5인 경우, 결과 = 2 * 기본색 * 합성색
      • ∴ 결과 = 2 * Multiply
    • 기본색 ≥ 0.5인 경우, 결과 = 1 - 2 * (1 - 기본색) * (1 - 합성색)
      • ∴ 결과 = 2 * Screen - 1
    • 다음과 같이 한 줄로 정리할 수도 있다
      • 결과 = 2 * lerp(Multiply, Screen, step(0.5, 기본색)) – step(0.5, 기본색)

Gray Scale

뜬금 없이 Gray Scale?라고 할 수 있는데 의외로 팔레트 스와핑에서는 필수적인 요소이다

 

예를 들어 녹색 고블린을 빨간색으로 팔레트 스와핑한다고 하자

녹색 고블린이므로 기본색의 대부분은 RGB(0.0, 1.0, 0.0)의 값을 가진다

하지만 합성색인 빨간색의 값은 RGB(1.0, 0.0, 0.0)이므로 Linear로도 Multiply로도 빨간색 고블린을 만들 수 없다

  • Linear = 기본색 + 합성색
    • RGB(0.0, 1.0, 0.0) + RGB(1.0, 0.0, 0.0) = 노란색 : RGB(1.0, 1.0, 0.0)
  • Multiply = 결과 = 기본색 * 합성색
    • RGB(0.0, 1.0, 0.0) * RGB(1.0, 0.0, 0.0) = 검정색 : RGB(0.0, 0.0, 0.0)

이러한 케이스를 피하는 방법 중 하나가 Base Map을 흑백화(Gray Scale)하는 것이다

 

이 흑백화 작업 자체는 포토샵 등을 사용하면 그다지 어렵진 않다

Base Map에 흑백 적응형 레이어를 추가하고 팔레트 스와핑할 부분을 흰색으로 그려주면 된다

가운데 아래의 옷 부분만 마스크한 예시(리소스는 유니티짱의 텍스처)

 

하지만 아무리 복붙하면 된다다고는 해도 Base Map과 Palette Swap Map 양쪽을 작업해야 한다는 건 귀찮을 뿐더러 어디선가 미스도 발생할 가능성이 있다

그리고 Base Map의 일부분이 흑백되어버리기 때문에 그대로 사용할 수 없게 된다는 단점이 있다

 

따라서 이번에는 셰이더 쪽에 흑백 처리(Gray Scale)를 넣기로 했다

Gray Scale의 계산식은 rgb2gray를 참고 하기로 했다(원리는 둘째치고 계산식은 단순)

How weights are assigned to each RGB component in function rgb2gray

 

How weights are assigned to each RGB component in function rgb2gray

In function rgb2gray, Gray=0.299*R + 0.587*G + 0.114*B, i understand it is a standard formula but can anyone help me understanding on how the weights for each channel are computed?

www.mathworks.com

 

요는 각 채널별로 특정 가중치를 곱해 더한 값을 모든 채널에 대입하면 된다는 것!

Gray = 0.299 * R + 0.587 * G + 0.114 * B

RGB(Gray, Gray, Gray)


Palette Swap용 Mask

캐릭터 전체의 색상을 바꾸고 싶은 건 아니라 Palette Swap용 마스크도 필요하다

필요한 건 적용될 부분만 흰색으로 하고 나머지는 검정색으로 하는 일반적인 마스크지만, 그러면 메모리가 아깝기 때문에 RGB의 채널마다 마스크를 설정하는 방식으로 아트팀하고 합의하였다

3종류의 팔레트 스와핑을 동시에 할 수 있으니 표현의 폭도 넓어진다는 메리트는 덤

 

예를 들어 RGB의 채널마다 마스크를 설정할 수 있으면, 마스크 1장으로 젤다 야생의 숨결의 보코블린처럼 R채널은 피부색, G와 B 채널은 무늬1과 무늬2를 구현할 수 있을 것이다

 

참고로 이번 샘플을 위해 다음과 같은 팔레트 스왑용 마스크를 만들어봤다

 

마스크만 적용해보면 다음과 같다


Palette Swap 구현

이제 실제 구현에 대해 정리해보고자 한다

우선 Palette Swap의 Blend Mode에 대한 Enum을 선언했다

using System;
using UnityEngine;

namespace CustomShaderGUI
{
    [Flags]
    public enum PaletteSwapMode
    {
        Linear = 1,
        Multiply = 2,
        Screen = 4,
        Overlay = 8
    }
    
    // ...생략...
}

 

그리고 셰이더의 Properties쪽에도 필요한 프로퍼티들을 선언했다

Shader "SSS/PaletteSwap"
{
    Properties
    {
        [Enum(UnityEngine.Rendering.CullMode)] _CullMode ("Cull Mode", Float) = 2 // Back
        
        [Header(Texture)][Space(10)]
        _BaseMap("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor("Base Color", Color) = (1,1,1,1)
        
        [Header(Palette Swap)]
        [Toggle] _PaletteSwap("Palette Swap On/Off", Float) = 0.0
        [NoScaleOffset] _PaletteSwapMask ("Palette Swap Mask", 2D) = "black" { }
        _PaletteSwapMask1Color ("Palette Swap Mask 1 Color", Color) = (0, 0, 0, 1)
        _PaletteSwapMask2Color ("Palette Swap Mask 2 Color", Color) = (0, 0, 0, 1)
        _PaletteSwapMask3Color ("Palette Swap Mask 3 Color", Color) = (0, 0, 0, 1)
        [Enum(CustomShaderGUI.PaletteSwapMode)] _PaletteSwapMask1ColorMode ("Palette Swap Mask 1 Color Mode", Float) = 0
        [Enum(CustomShaderGUI.PaletteSwapMode)] _PaletteSwapMask2ColorMode ("Palette Swap Mask 2 Color Mode", Float) = 0
        [Enum(CustomShaderGUI.PaletteSwapMode)] _PaletteSwapMask3ColorMode ("Palette Swap Mask 3 Color Mode", Float) = 0

        // ...생략...
    }

    SubShader
    {
        // ...생략...
        Pass
        {

            // ...생략...

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);
            TEXTURE2D(_PaletteSwapMask);
            SAMPLER(sampler_PaletteSwapMask);
            
            CBUFFER_START(UnityPerMaterial)
                float4 _BaseMap_ST;
                half4 _BaseColor;

                half4 _PaletteSwapMask1Color;
                half4 _PaletteSwapMask2Color;
                half4 _PaletteSwapMask3Color;
                float _PaletteSwapMask1ColorMode;
                float _PaletteSwapMask2ColorMode;
                float _PaletteSwapMask3ColorMode;
            CBUFFER_END

            // ...생략...

        }
}

 

그리고 RGB 채널 각각에 대해 팔레트 스왑을 해야 하니 처리를 함수로 만들었다

float3 GetPaletteSwapColor(float3 baseColor, half3 maskColor, float mask, float paletteSwapMode)
{
    // Gray Scale
    const float3 color = 0.299 * baseColor.r + 0.587 * baseColor.g + 0.114 * baseColor.b;
    
    // Linear
    float3 colorLinearMode = color + mask * maskColor;
    // Multiply
    float3 colorMultiplyMode = color * mask * maskColor;
    // Screen
    float3 colorScreenMode = colorLinearMode - colorMultiplyMode;
    // Overlay
    float3 s = step(0.5, color);
    float3 colorOverlayMode = 2 * lerp(colorMultiplyMode, colorScreenMode, s) - s;

    // The selected mode is multiplied by 1, while the others are multiplied by 0, resulting in only the color of the selected mode remaining.
    const int mode = int(paletteSwapMode);
    colorLinearMode   *= (mode & 1);
    colorMultiplyMode *= (mode & 2) >> 1;
    colorScreenMode   *= (mode & 4) >> 2;
    colorOverlayMode  *= (mode & 8) >> 3;

    // Adjust the range reflected by the mask.
    return saturate(lerp(baseColor, (colorLinearMode + colorMultiplyMode + colorScreenMode + colorOverlayMode), mask));
}

 

마지막으로 프래그먼트 셰이더에서 함수를 사용하면 끝!

half4 frag(Varyings IN) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(IN);
    
    half4 _FinalColor = half4(0.0, 0.0, 0.0, 1.0);
    half4 _BaseTex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.texcoord);

    // Palette Swap
    half3 paletteSwapMask = SAMPLE_TEXTURE2D(_PaletteSwapMask, sampler_PaletteSwapMask, IN.texcoord).rgb;
    _BaseTex.rgb = GetPaletteSwapColor(_BaseTex, _PaletteSwapMask1Color.rgb, paletteSwapMask.r, _PaletteSwapMask1ColorMode);
    _BaseTex.rgb = GetPaletteSwapColor(_BaseTex, _PaletteSwapMask2Color.rgb, paletteSwapMask.g, _PaletteSwapMask2ColorMode);
    _BaseTex.rgb = GetPaletteSwapColor(_BaseTex, _PaletteSwapMask3Color.rgb, paletteSwapMask.b, _PaletteSwapMask3ColorMode);
    
    _FinalColor = _BaseTex * _BaseColor;
    
    return _FinalColor;
}


ShaderGUI를 쓰기 쉽게 바꿔보자

셰이더의 프로퍼티에 작성하는 것만으로는 다음과 같이 GUI가 조금 보기 어렵다…

 

그래서 Color와 Mode을 한 줄로 표시하기 위한 코드를 삽질을 통해 구현해봤다

private void DrawPropertyPaletteSwap(MaterialProperty paletteSwapMaskColorMode, MaterialProperty paletteSwapMaskColor, GUIContent label)
{
    Rect rectForSingleLine = EditorGUILayout.GetControlRect(false);
    MaterialEditor.BeginProperty(rectForSingleLine, paletteSwapMaskColorMode);
    MaterialEditor.BeginProperty(rectForSingleLine, paletteSwapMaskColor);
    
    EditorGUI.BeginChangeCheck();
    rectForSingleLine.width /= 3;
    EditorGUI.LabelField(rectForSingleLine, label);
    rectForSingleLine.x += rectForSingleLine.width;
    rectForSingleLine.x += EditorGUIUtility.fieldWidth * 0.5f - 10f;
    PaletteSwapMode selected = (PaletteSwapMode)EditorGUI.EnumPopup(rectForSingleLine, GUIContent.none, (PaletteSwapMode)paletteSwapMaskColorMode.floatValue);
    rectForSingleLine.x -= EditorGUIUtility.fieldWidth * 0.5f - 10f;
    rectForSingleLine.x += rectForSingleLine.width;
    Color color = EditorGUI.ColorField(rectForSingleLine, GUIContent.none, paletteSwapMaskColor.colorValue);
    if (EditorGUI.EndChangeCheck())
    {
        paletteSwapMaskColorMode.floatValue = (float)selected;
        paletteSwapMaskColor.colorValue = color;
    }
    
    MaterialEditor.EndProperty();
    MaterialEditor.EndProperty();
}

 

위의 함수를 OnGUI 등에 다음과 같이 사용하면!

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);

    if (_firstTimeApply)
    {
        RegisterHeader(material, _materialEditor);
        _defaultInspector = false;
        _firstTimeApply = false;
    }
    
    // ...생략...

    // Palette Swap
    if (PaletteSwap != null)
    {
        _materialEditor.ShaderProperty(PaletteSwap, Styles.PaletteSwapText);
        if (PaletteSwap.floatValue > 0.0f)
        {
            EditorGUI.indentLevel++;
            if (PaletteSwapMask != null)
                _materialEditor.TexturePropertySingleLine(Styles.PaletteSwapMaskText, PaletteSwapMask);
            
            EditorGUI.indentLevel++;
            if (PaletteSwapMask1ColorMode != null && PaletteSwapMask1Color != null)
                DrawPropertyPaletteSwap(PaletteSwapMask1ColorMode, PaletteSwapMask1Color, Styles.PaletteSwapMask1Text);
            if (PaletteSwapMask2ColorMode != null && PaletteSwapMask2Color != null)
                DrawPropertyPaletteSwap(PaletteSwapMask2ColorMode, PaletteSwapMask2Color, Styles.PaletteSwapMask2Text);
            if (PaletteSwapMask3ColorMode != null && PaletteSwapMask3Color != null)
                DrawPropertyPaletteSwap(PaletteSwapMask3ColorMode, PaletteSwapMask3Color, Styles.PaletteSwapMask3Text);
            EditorGUI.indentLevel--;

            EditorGUI.indentLevel--;
        }
        else
        {
            if (PaletteSwapMask != null)
                PaletteSwapMask.textureValue = null;
        }
    }

    // ...생략...

}

 

짜잔~! 더 깔끔해진 GUI를 볼 수 있다


결과


마무리

색을 한 번만 스와핑하는 것은 간단한데 3번을 하려고 하니 생각보다 예상대로의 결과가 나오지 않았다

그래도 전반적으로 많은 시간이 걸리지 않았고 아트팀으로부터 무수히 많은 감사의 목소리가 나온 작업이었다

이렇게 가성비 좋은 작업 또 없으려나…


참고 사이트

Blend modes

 

Blend modes - Wikipedia

From Wikipedia, the free encyclopedia How two or more digital photo layers are mixed together A sketch colored digitally with use of several different blend modes in order to preserve the pencil lines and paper texture below the color layers. Blend modes (

en.wikipedia.org

How weights are assigned to each RGB component in function rgb2gray

 

How weights are assigned to each RGB component in function rgb2gray

In function rgb2gray, Gray=0.299*R + 0.587*G + 0.114*B, i understand it is a standard formula but can anyone help me understanding on how the weights for each channel are computed?

www.mathworks.com