NPR Project Demo

01. Face SDF_[NPR Project]

NyumMa 2026. 2. 4. 22:54

01. Face SDF

 

NPR(Non - Photo - Realistic) Shading

NPR은 만화적인 느낌을 랜더링 하기 위한 기법을 의미한다.

장면에 있어서 만화적인 느낌을 주는 요소에는 여러가지가 있지만

가장 중요한 것 중에 하나는

Ouline과 음영의 Step 처리라고 생각한다.

 

카툰이라는 것의 방향은 각 나라마다 방향성이 다르며 지향하는 스타일도 다르다.

이를 모두 다 정의하기에는 각 나라 별 스타일의 문화적 차이와 역사적 배경을 다루어야 하므로 넘어간다.

해당 글에서의 카툰은 일본풍 스타일의 캐릭터나 장면들을 표현하기 위한 것을 아우른다.

프랍과 배경등은 나중으로 하고, 캐릭터를 우선적으로 다루며 먼저 다루어 볼 것은

현대 NPR를 표현하기 위한 기법 중에 얼굴 음영에서 많이 쓰이는 SDF Texture이다.

다음 텍스쳐는 Face SDF 텍스쳐의 예시이다.

 

중요한 것은 해당 텍스쳐의 정보는 Threshold에 대한 데이터로 사용되기 때문에 sRGB 옵션을 꺼야 한다는 것이다.

sRGB에 대한 자세한 내용은 

https://nyumma02.tistory.com/31

 

2026-01-08_Gamma Correction & GPU Texture Compression

Cpu -> Gpu 데이터의 전달 흐름 Cpu에서 Asset을 메모리에 로드PNG, TGA, JPG 등의 파일을 메모리에 적재한다. 엔진 임포터에서 해당 텍스쳐에 대한 색공간을 처리함sRGB / Linear 기본적으로 모니터는 CRT의

nyumma02.tistory.com

를 참고해보자

 

해당 값에 대해서 우리는 색상 정보가 아닌 데이터의 정보로 보아야 하기 때문에

sRGB 옵션을 꺼야 한다. (Linear 공간으로 보아야 한다)

 

해당 텍스쳐에 대한 특징을 한번 살표보자

특징

  1. 텍스쳐의 형태가 얼굴의 UV 맵의 형상을 그대로 따르고 있다.
  2. 1의 값인 흰색과 0의 값인 검은색의 사이 값으로 텍스쳐 내부가 그래디언트를 이루고 있다.
  3. Bake 된 것이 아닌 직접 그린 것과 같은 형상을 띄고 있다.

먼저 해당 텍스쳐의 처리 방식을 들어가기 앞어서 SDF라는 용어에 대해서 알아볼 필요가 있다.


SDF - Signed Distance Field

SDF의 뜻은 signed distance field, 쉽게 말해서 부호화된 거리 장 이다.

Field는 물리학에서 주로 쓰이는 용어인데, 흐름, 혹은 영향력이라고 생각해 볼 수 있다.

어떤 역할을 하는 건지 문자 그대로 해석해 본다면

“부호를 가진 거리의 영향을 나타내는 텍스쳐” 라고 생각해 볼 수 있을 것이다.

엄밀하게 SDF는

경계로부터(Boundary)의 최단 거리의 값을 저장한 흐름의 집합이며

경계 안과 밖을 부호로 구분한 것이다.

어떤 형상이 있다면 해당하는 형상의 경계 위에 있는 값은 0이고

해당 경계를 기준으로 바깥은 음의 부호(+)를,

안쪽은 양의부호(-)를

나타낸다.

SDF의 용도

  1. 렌더링 품질
  2. Implicit한 기하학적 표현
  3. 충돌, 물리계산
  4. Procedural VFX Simulation

    1.렌더링 품질

텍스쳐는 내부적으로 저장할때 1/4 크기(가로 세로 각각 절반)의 급수로 밉맵 배열을 형성하여 저장하는데

근사하면 1 * 1/(1-1/4) 로 약 1.333···· 으로 나온다.

LOD에 기반하여 밉맵 텍스쳐가 바뀌는데 원본 텍스쳐의 품질이 떨어지면 근거리에서도 텍스쳐의 품질이 좋게 보이지 않을 수 있다.

한편, SDF는 수학적으로 경계를 정의한 값을 내포하고 있기 때문에 해상도를 자유자재로 조정이 가능하며 품질도 우수하다.

 

    2. Implicit한 기하학적 표현

일반적으로 컴퓨터는 3차원 점(vertex)의 좌표를 받아서 그래픽을 처리하는 방식으로 발전해 왔다. (2차원은 3차원 좌표 4개의 점을 받아서 2차원으로 projection 한 것이다.)

SDF를 사용하면 경계를 f(x,y,z)로 표현하여 부호로 manifold의 interior, exteriror 를 정의할 수 있기에 기하형상을 처리하는 단계를 함수 boundary로 처리하므로 처리에 대한 시간을 줄일 수 있다.

 

    3. 충돌 물리계산

두 기하학적 형상에 대하여 충돌을 처리할 때 boundary기반의 거리를 표현하는 SDF 이점으로 충돌 판정을 매우 쉽고 빠르게 할 수 있다.

 

    4.Procedural VFX Simulation

절차적 생성은 제작 공정에 있어서 매우 중요한 부분중 하나이다.

절차적 생성 방식에 적합한 툴인 Hudini 등에서 Volume에 있어서 SDF를 통한 방식에 대한 이해가 필요하다.


Face SDF

사실 여기서 말하는 SDF는 일반적인 정의로 서의 SDF와는 방향성이 살짝 다르다.

위의 Face SDF 텍스쳐는 SDF 절차적 방식과는 다르게 아티스트들이 Light 방향에 따른 얼굴 음영의 경계를 단계 별로 나누어 Custum으로 그리고 이러한 텍스쳐들을 병합한 것이다.

 

SDF라고 칭한 이유는 정황상 얼굴의 형상과 이에 따른 음영이 단계적으로 미리 그려져 있는 것이 얼굴의 빛을 받는 방향 기준으로 거리에 따라 다르게 나타나는 형상을 문맥상 말하기 위해 단지 Face SDF라고 고유명사를 붙인 것으로 파악된다.

 

램버트 라이팅에서 사용되는 일반적은 MAX(NdotL,0) 연산의 경우

기하학적 형상에 따른 normal의 요철로 인해

흔히 일본풍 카툰방식에서 사용되는 셀 셰이딩(cell shading)과 적합하지 않은 방식에 가깝다.

 

따라서 기본적으로 조명에 따른 음영 처리에 대한 데이터를 Texture에 저장하고 이를 조명 벡터와 내적 하여 측정한 유사도(similarity)결과를 바탕으로 빛 방향에 대한 음영을 모의로 표현한다.


준비물

  1. UV unwrap이 얼굴 Seam에 따라 올바르게 처리된 캐릭터 모델 데이터(Quad UV 기반 모델 불가능)
  2. 커스텀 Face SDF 텍스쳐 (추후 빛방향에 따른 UV 반전이 필수로 되어야 하므로 제작시에는 Diffuse Map의 얼굴을 텍스쳐의 한 가운데에 배치하도록 제작 공정을 처리한다.)
  3. local space face 방향 벡터 (x,y,z,w) = (0,0,1,0)
  4. 유니티 URP lighting.hlsl 라이브러리

Properites

    Properties
    {
        [Header(Face)]
        _FaceLightMap("Face Light Map", 2D) = "white" {}
        _FaceMask("Face Shadow", 2D) = "white" {}
        _FaceDirection("Face Direction", float) = (0,0,1,0)

        [MainColor] _BaseColor("Base Color", Color) = (1, 1, 1, 1)
        [MainTexture] _BaseMap("Base Map", 2D) = "white"
    }

sdf 텍스쳐를 받기 위해 properties에 텍스쳐 슬롯과

local face dir vector인 z방향의 (0,0,1,0)을 추가한다.

유니티는 +x가 오른쪽, +y가 위쪽, detph가 +z인 LHS 월드 좌표계를 사용한다.

 

주의해야 할 점은 graphics API에서 말하는 LHS RHS와는 무관하다는 것이고

단지 월드 공간의 정의를 유니티에서 그렇게 해 놓았을 뿐이다.

Lighting

 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/lighting.hlsl"

해당 유틸리티를 통해서 쉐이더에서 월드 공간의 조명 정보들을 받을 수 있도록 한다.

lighting.hlsl은 URP에서 제공하는 조명 계산 유틸리티 라이브러리

안에

 

lighting color

light dir

light attenuation(for pointlight, spotlight)

 

등의 정보가 담겨 있다.

Light mainLight = GetMainLight();

다음과 같은 방법으로 light 에 대한 정보를 가져올 수 있다.

Vertex Information

struct Attributes
{
    float4 positionOS   : POSITION;
    float3 normalOS     : NORMAL;
    float4 tangentOS    : TANGENT;
    float4 color        : COLOR;
    float2 uv           : TEXCOORD0;
}

Vertex Shader에 입력으로 받아드릴 구조체의 정보들을 추가한다.

: POSITION, : NORMAL 등은 SEMANTIC이라고 하며 다음 파이프라인에다가 해당 정보들을 어떠한 ‘용도로’ 사용할지 알려주는 역할이다.

 

모델을 GPU 그래픽스 파이프라인에 타는 과정에 있어서 Vertex는 기본적으로 그냥 단순한 3차원 벡터들의 리스트 및 배열이다.

그 자체로 가지고 있는 의미는 없으며 따라서 각 GPU 파이프라인에서 해당 정보들에 대해서 의미를 부여 해 주어야 한다.

시멘틱은 FBX나 OBJ 등의 모델 파일에서 가지고 있는 정점에 대한 POSITION이나 UV, NORMAL, VERTEXCOLOR,

TANGENT나 BITANGENT 정보는 엔진 내부에서 MIKKTSPACE 기반으로 재구성 되거나 Tangent의 경우에는 모델 파일 안에 내포되어 있는 경우도 있음

 

Tangent가 중요한 이유는 npr 모델링에서 Kajiya kay 를 통한 동적 스펙큘러를 처리할 수 있기 때문인데, 현재 mihoyo 기반 npr 스타일에서는 미리 그려져 있는 스펙큘러를 통한 처리를 하므로 크게 사용되진 않는다.

Texcoord0, 1은 DCC에서 모델을 만들때 이미 정점에 대해서 uv 좌표가 지정되어 있기 때문에 슬롯을 마음데로 바꾸어서는 안된다.

struct Varyings
{
    float2 uv           : TEXCOORD0;
    float3 positionWS   : TEXCOORD1;
    half3 tangentWS     : TEXCOORD2;
    half3 bitangentWS   : TEXCOORD3;
    half3 normalWS      : TEXCOORD4;
    float4 positionNDC  : TEXCOORD5;
    half4 color         : COLOR;
    float4 positionCS   : SV_POSITION;
};

Vertex Shader에서 출력으로 내보낼 정보들을 추가한다.

여기서 Texcoordn은 정점 입력에 대한 texcoord와 다르게

interpolation 용도를 위한 slot(rasterizer 등에서) 으로 사용되기 때문에 특별한 의미는 없다.

COLOR 또한 TEXCOORD와 같은 일반적인 보간 슬롯이지만 이름만 특별하게 지정되어 있다.

반면, 여기서 중요한 것은 SV_POSITION인데

GPU에서 해당 값을 화면 좌표라고 인식하도록 하는 시멘틱이므로 바꿀수 없고 용도에 맞게 써야 함

 

정점 좌표를 HCS(homogeneous clip space) 좌표로 변환시 해당 정점 좌표를 나타내는 시멘틱으로 사용함

구조체를 정의했으면

정점 입력과 출력에 대한 값을 연결한다.

Varyings ForwardPassVertex(Attributes input)
{
    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    
    Varyings output = (Varyings)0;
    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    output.positionWS = vertexInput.positionWS;
    output.tangentWS = normalInput.tangentWS;
    output.bitangentWS = normalInput.bitangentWS;
    output.normalWS = normalInput.normalWS;
    output.color = input.color;
    output.positionNDC = vertexInput.positionNDC;
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);

    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    return output;
}

VertexPositionInputs

VertexNormalInputs

유니티의 Core.hlsl에서 제공하는 구조체 유틸이다.

각각 vertex 값을 통해서

각각 정점의 position, normal에 대해

OS, WS, VS, CS, NDC의 좌표로 굳이 matirx 사용하지 않아도 변환해 주도록 처리되어 있다.

 

한편, 중요하게 보아야 할 것은 normal에 받는 인자가 normalOS와 tangentOS인데 이 두 개를 받는 이유는 TBN에서 bitangent를 구할 때 tangent와 normal vector의 outer product로 복원하기 때문이다.

( mikkt space에 의해 tangent.w에 handle 방향이 저장되어 있음)

로직 설명

빛 방향 벡터 lightDirection

정점 출력의 월드 노말 벡터 normalWS

로컬 얼굴 방향 벡터 _FaceDirection

Face Shadow Map Texture _FaceLightMap, uv좌표 uv

지금 수행하고자 하는 문제는 다음과 같다.


1. 얼굴 방향 _FaceDirection에 대해서 lightdir(표면에서 빛방향에 대한 방향임)의 사이 각 관계로부터 나온

scalar 값에 대해서 FaceSDF 텍스쳐에 Threshold의 데이터로 사용한다. (Threshold Mapping)

 

얼굴방향과 lightdir에 대한 유사도를 판단하므로 dotproduct를 원형으로

함수의 평행이동과 확대 축소를 사용함.

2.  하나의 SDF 텍스쳐에서 커스텀 라이팅을 처리하고 있으므로 빛 방향 벡터가 반대 방향이 되었을 때 

음영이 반대로 지도록 처리한다.(UV Switching)

 

이러한 로직을 처리하는데에는 여러가지 방법이 있겠지만 가장 눈에 띄는 아이디어는 외적에 대한 부호를 switching으로 사용하는 것이다.


1번 문제에 대하여

   1. _facedir 와 lightdir가 서로 일치할때는 (f dot l = 1)

얼굴 전체가 빛을 받도록 해야 한다.

임계값이 0으로 매핑 되어야 한다.

 

   2. _facedir와 lightdir가 수직을 이룰때는 ( f dot l = 0)

얼굴의 반쪽 면이 음영 지고 나머지 반쪽은 빛을 받는다.

임계값이 0.5로 매핑 되어야 한다

 

   3. _facedir와 lightdir가 서로 반대 방향을 이룰때는 ( f dot l = -1)

얼굴 전체 모두 음영이 지도록 한다

임계값이 1로 매핑 되어야 한다

이를 적절하게 어떻게 변형할지 궁리해보면 다음과 같다.

 

dot(f, l) * -0.5 + 0.5

 

→ 해당 값을 바탕으로 facesdf 텍스쳐의 value에 stepfunction 혹은 smoothstep을 처리한다.

한편, 위와 같은 로직을 처리하기 전에 빛 방향과 face의 방향이 서로 같은 평면상에

존재해야 한다는 제약 조건이 있다.

다음과 같이 측면에서 본 경우 만약 위와같이 각도가 벌어져 있을 때에는

위에서 봤을때의 관점에서는 서로 일치하지만 방향벡터는 3차원 벡터이기에 각도 차이가 발생하므로

2차원 평면으로 projection 해서 처리해야 할 필요가 있다.

 

따라서 y축 성분을 뺀 xz 평면에서 처리할 수 있도록 한다.

half4 frag(Varyings input) : SV_Target
{
    
    Light mainLight = GetMainLight();
    half3 lightDirection = SafeNormalize(mainLight.direction);

    half3 F = SafeNormalize(half3(_FaceDirection.x, 0.0, _FaceDirection.z));
    half3 L = SafeNormalize(half3(lightDirection.x, 0.0, lightDirection.z));
    half FDotL = dot(F, L);
    half2 shadowUV = input.uv;

    half faceShadowMap = SAMPLE_TEXTURE2D(_FaceLightMap, sampler_FaceLightMap, shadowUV).r;
    
    half faceShadow = step(-0.5 * FDotL + 0.5, faceShadowMap);

    return faceShadow;
}

2번 문제에 대하여

현재 다음과 같이 빛의 방향이 바뀌어도

이미 그려진 Facesdf 텍스쳐에 종속되어 빛 방향 처리가 되기에

저렇게 방향에 대해서 반복적으로 나타났다가 사라졌다 하게 된다.

이를 해결 하기 위해서 외적 연산을 통해 UV 좌표의 x축을 뒤집어 텍스쳐를 반전한다.

half4 frag(Varyings input) : SV_Target
{
    
    Light mainLight = GetMainLight();
    half3 lightDirection = SafeNormalize(mainLight.direction);

    half3 F = SafeNormalize(half3(_FaceDirection.x, 0.0, _FaceDirection.z));
    half3 L = SafeNormalize(half3(lightDirection.x, 0.0, lightDirection.z));
    half FDotL = dot(F, L);
    half2 shadowUV = input.uv;

             
    //uv 반전 처리 로직
    half FCrossL = cross(F, L).y;
    shadowUV.x = lerp(shadowUV.x, 1.0 - shadowUV.x, step(0.0, FCrossL));


    half faceShadowMap = SAMPLE_TEXTURE2D(_FaceLightMap, sampler_FaceLightMap, shadowUV).r;

    // fdotl 매핑
    half faceShadow = step(-0.5 * FDotL + 0.5, faceShadowMap);


    return faceShadow;
}. 

번외

 

원신 텍스쳐에는 다음과 같은 마스킹 텍스쳐가 별도로 존재한다.

눈쪽에 SDF 그림자의 영향을 받지 않도록 추가로 마스킹 해 준다.

half faceMask = SAMPLE_TEXTURE2D(_FaceMask, sampler_FaceMask, input.uv).a;
half maskedFaceShadow = lerp(faceShadow, 1.0, faceMask);    

return maskedFaceShadow;

다음과 같이 빛을 안받는 부분에 대해서도 눈동자 부분이 마스킹 된 것을 볼 수 있다.

마무리

해당 값을 통해 Ramp Diffuse에 매핑하여 BaseColor와 곱해 그림자 색상을 표현할 수 있도록 한다. (다음 챕터에서 설명)

지금까지의 코드를 재사용성을 고려하여 함수로 모듈화 하여 처리한다.

 

전체코드

Shader "Study/MihoyoStyleToon"
{
    Properties
    {

        [Header(Face)]
        _FaceLightMap("Face Light Map", 2D) = "white" {}
        _FaceMask("Face Shadow", 2D) = "white" {}
        _FaceDirection("Face Direction", Vector) = (0,0,1,0)
        _FaceShadowOffset("FaceShadowOffset", Range(0.0, 0.5)) = 0.0

        [MainColor] _BaseColor("Base Color", Color) = (1, 1, 1, 1)
        [MainTexture] _BaseMap("Base Map", 2D) = "white"
    }

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

        Pass
        {
            HLSLPROGRAM

            #pragma vertex ForwardPassVertex
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/lighting.hlsl"

            struct Attributes
            {
                float4 positionOS   : POSITION;
                float3 normalOS     : NORMAL;
                float4 tangentOS    : TANGENT;
                float4 color        : COLOR;
                float2 uv           : TEXCOORD0;
            };

           struct Varyings
            {
                float2 uv           : TEXCOORD0;
                float3 positionWS   : TEXCOORD1;
                half3 tangentWS     : TEXCOORD2;
                half3 bitangentWS   : TEXCOORD3;
                half3 normalWS      : TEXCOORD4;
                float4 positionNDC  : TEXCOORD5;
                half4 color         : COLOR;
                float4 positionCS   : SV_POSITION;
            };

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);

            TEXTURE2D(_FaceLightMap);
            SAMPLER(sampler_FaceLightMap);

            TEXTURE2D(_FaceMask);
            SAMPLER(sampler_FaceMask);


            CBUFFER_START(UnityPerMaterial)
                half4 _BaseColor;
                float4 _BaseMap_ST;
                float4 _FaceMask_ST;
                float4 _FaceLightMap_ST;
                float4 _FaceDirection;
                half _FaceShadowOffset;
            CBUFFER_END

            Varyings ForwardPassVertex(Attributes input)
            {
                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
                
                Varyings output = (Varyings)0;
                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
                output.positionWS = vertexInput.positionWS;
                output.tangentWS = normalInput.tangentWS;
                output.bitangentWS = normalInput.bitangentWS;
                output.normalWS = normalInput.normalWS;
                output.color = input.color;
                output.positionNDC = vertexInput.positionNDC;
                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);

                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
                return output;
            }


			//FaceShadow 구하는 함수
            half FaceShadow(Varyings input, half3 lightDirection)
            {
                half3 F = SafeNormalize(half3(_FaceDirection.x, 0.0, _FaceDirection.z));
                half3 L = SafeNormalize(half3(lightDirection.x, 0.0, lightDirection.z));
                
                half FDotL = dot(F, L);

                
                half FCrossL = cross(F, L).y;

                half2 shadowUV = input.uv;

                shadowUV.x = lerp(shadowUV.x, 1.0 - shadowUV.x, step(0.0, FCrossL));


                half faceShadowMap = SAMPLE_TEXTURE2D(_FaceLightMap, sampler_FaceLightMap, shadowUV).r;

                // fdotl 매핑
                half faceShadow = step(-0.5 * FDotL + 0.5 - _FaceShadowOffset, faceShadowMap);

                half faceMask = SAMPLE_TEXTURE2D(_FaceMask, sampler_FaceMask, input.uv).a;
                half maskedFaceShadow = lerp(faceShadow, 1.0, faceMask);    

                return maskedFaceShadow;
            }

			//vertex output -> fragment input
            half4 frag(Varyings input) : SV_Target
            {

                half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
                half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap,sampler_BaseMap, input.uv);
                half3 albedo = baseMap.rgb * _BaseColor.rgb;
                half alpha = baseMap.a;

                Light mainLight = GetMainLight();
                half3 lightDirection = SafeNormalize(mainLight.direction);


                half NdotL = dot(input.normalWS, lightDirection);
                half halfLambert = NdotL * 0.5 + 0.5;


                //Face SDF
                ///////////////////////////////////////////////////////////////
                //uv 반전 처리 로직
                half shadow = FaceShadow(input, lightDirection);  
                ///////////////////////////////////////////////////////////////

                
                return shadow * color;
            }
            ENDHLSL
        }
    }
}

'NPR Project Demo' 카테고리의 다른 글

04. Specular_[NPR Project]  (0) 2026.02.17
03. Normal Map_[NPR Project]  (0) 2026.02.11
02. Ramp Color Diffuse_[NPR Project]  (0) 2026.02.06
2025-12-25~2026-01-23 까지 진행한 것들  (0) 2026.01.23
NPR Project Demo  (0) 2026.01.15