TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역] Cartoon Rendering Colouring Part 1: Forward and Deferred Mixing Shading Techniques

jplee 2025. 11. 28. 14:46

저자 : Yu-ki016

원문 Markdown와 이미지는 Github에 정리되어 있다. 이미지가 잘 보이지 않을 경우 아래 링크에서 원본을 확인할 수 있다.

github.com/Yu-ki016/Yu-ki016-Articles/tree/main/UE5卡通渲染引擎/【UE5】卡通渲染着色篇1

글의 수정 이력은 모두 Github에 업로드되어 있으며, 아래 커밋 기록 스크린샷과 대응된다.

1. 서론

1.1 Deferred 렌더링으로 Toon 렌더링을 구현할 때의 한계

현재 내가 구현해 둔 Toon 셰이딩은 2단계(밝음/어두움) 구분만 있는 단순 버전이다. 그 외의 복잡한 셰이딩 효과는 아직 거의 넣지 않았다. 대신, 먼저 그림자/투영 관련 부분을 대대적으로 손봤다. 이유는 두 가지다.

  • 캐릭터 얼굴에 생기는 보기 싫은 self‑shadow 를 먼저 정리하지 않으면, 다른 작업에 집중하기 어렵다.
  • 동시에, 어떤 방식으로 셰이딩 로직을 설계해야 확장성이 좋을지 계속 고민 중이었기 때문이다.

내 목표는 다음과 같다.

확장성이 높고, 시중에 존재하는 다양한 Toon 셰이딩 방식을 최대한 호환할 수 있는 엔진을 만드는 것

그런데 이 목표는 전통적인 Deferred Rendering 철학과는 어느 정도 충돌한다.

잘 알려져 있듯, 카툰 렌더링은 사실상 각종 trick 의 집합이다.

  • 어떤 경우에는 Toon 셰이딩이란 그저 밝은 영역 색 / 어두운 영역 색 두 가지만 지정하면 끝나기도 한다.
  • 소위 “이차원 게임(二游)” 계열 렌더링은 Ramp 텍스처를 많이 쓰며, 더 풍부한 컬러 그라데이션을 만든다.
  • 「붕괴 3」의 MMD 파이프라인은 2D Ramp 를 사용해, 부위별로 서로 다른 전이(transition)를 줄 수 있다.

이런 셰이딩 방식들 사이에는 우열이 있는 것이 아니라, 단지 선택과 취향의 차이일 뿐이다. 구현 난이도도 크게 높지 않다. 문제는 이 모든 방식을 하나의 Deferred 파이프라인 안에서 동시에 지원하려고 할 때 발생한다.

여기에 카툰 렌더링에서 흔히 등장하는 다양한 trick —— 아웃라인, 얼굴 SDF 라이팅, 코 끝 하이라이트, 각종 머리카락 하이라이트, 여러 형태의 rim light 등 —— 을 계속 쌓다 보면 GBuffer 는 필연적으로 비대해진다.

또한, 시중의 Toon 렌더링 솔루션들은 서로 다른 데이터를 요구한다. 필요한 GBuffer 구성도 구현마다 조금씩 다르다. 따라서 한정된 구성의 GBuffer 하나로, 현재 존재하거나 앞으로 등장할 Toon 셰이딩 모델을 모두 수용하기는 어렵다는 점이 핵심 문제라고 볼 수 있다.

1.2 내가 선택한 접근 방식

이러한 한계를 고려해, 내가 가장 먼저 떠올린 방법은 다음과 같다.

엔진 사용자(아티스트/TA)가 직접 셰이딩 코드를 구성할 수 있게 해 주자.

구체적으로는 머티리얼 에디터의 노드 그래프를 통해 Toon 머티리얼 셰이딩을 만들게 하는 방식이다.

  • 어떤 셰이딩 방식을 쓰고 싶든
  • 어떤 trick 을 추가하고 싶든

전부 머티리얼 그래프 레벨에서 해결하도록 하는 것이다.

여기서 이런 의문이 들 수 있다.

“그냥 순정 UE 에서 Emissive 안에 Toon 셰이딩을 다 때려 넣는 것과 뭐가 다른가? 그러면 다중 광원이나 그림자는 포기해야 하는 것 아닌가?”

지적한 대로, 내 접근 방식은 Emissive 안에서 Toon 을 구현하는 것과 꽤 비슷하다. 다만 중요한 차이점이 하나 있다. 나는 여기에 별도의 ToonLight Pass 를 추가했다.

  • ToonLight Pass 는 Light Pass 뒤에서 실행된다.
  • 따라서 shadow 를 포함한 광원 관련 정보를 ToonLight Pass 로 넘길 수 있다.
  • 그 결과, 머티리얼 에디터 안에서도 그림자 데이터를 직접 읽어 올 수 있다.

머티리얼 에디터에서 Custom HLSL 노드를 통해 투영(shadow)을 읽어 오는 모습

그러면 다시 이런 질문이 생긴다.

“결국 씬은 Deferred 로, Toon 만 Forward 로 렌더링하는 셈 아닌가?”

정확히 말하면 그렇지는 않다.

  • 다중 광원(many lights) 계산을 Forward 로 처리하면 비용이 크기 때문에, 이 부분은 여전히 Deferred 쪽이 유리하다.
  • 반면 주광(main directional light) 의 셰이딩은 구현자마다 변주가 많고, 커스터마이즈 포인트도 풍부하다.

그래서 설계 방향을 다음처럼 잡았다.

  • 주광 셰이딩은 Forward 스타일로 처리하고, 이 로직은 머티리얼 에디터에 위임한다.
  • 다중 광원 등의 나머지 계산은 기존과 같이 Deferred Lights Pass 에 맡긴다.

정리하자면, 이 글의 핵심 아이디어는 다음과 같다.

주광 셰이딩을 엔진 코드에 고정하지 않고 머티리얼 레벨로 끌어올려, 엔진의 확장성을 확보한다.

2. 구현

2.1 준비 작업

먼저 약간의 준비 작업을 진행했다.

  • 기존에 사용하던 ToonPass 의 이름을 ToonBasePass 로 변경했다.
    • 이 부분은 글에서 자세히 다루지 않았다. 필요하다면 git 상의 코드를 바로 보는 편이 빠르다.

다음으로, 앞으로 Toon 셰이딩을 설계할 때 기존 광원 계산에 방해받지 않도록, Toon 머티리얼에서의 광원 기여를 모두 제거했다.

  1. 직접 조명 제거

DeferredLightPixelShaders.usf

직접 광원이 제거된 결과

  1. 환경광 제거

DiffuseIndirectComposite.usf

Lumen 기반 환경광을 제거한 상태다.

현재는 Lumen 이 만드는 환경광만 꺼 둔 상태라, Lumen 을 비활성화하면 여전히 다른 경로의 환경광이 남아 있다.

따라서 ReflectionEnvironmentPixelShader.usf 에서 Lumen 이 아닌 환경광도 함께 제거해야 완전히 정리된다.

또한 Blend Mode 가 반투명일 때도 조명 계산이 남아 있기 때문에, 이 부분도 추가로 제거했다.

BasePassPixelShader.usf

BlendMode 를 반투명으로 설정했을 때의 Shading. 깊이/정렬 문제는 어느 정도 자연스럽게 발생할 수 있다.

참고로, Emissive 는 여전히 BasePassPixelShader.usf 에서 계산되지만 이 부분은 남겨 두었다.

2.2 ToonLightPass 와 ToonLightOutput 노드 생성

이제 ToonLightPass 를 만든다. MeshDraw Pass 를 새로 추가하는 방법 자체는 이전 글에서 상세히 다뤘기 때문에, 여기서는 핵심 구조만 언급한다.

Yu-ki016: UE5 엔진 수정으로 사용자 정의 Pass 를 추가하고, 커스텀 Buffer 에 쓰는 방법에 대한 글

핵심 아이디어는 간단하다.

  • 기존 Lights Pass 뒤에 ToonLightPass 를 끼워 넣는다.

DeferredShadingRenderer.cpp

그리고 기존의 ToonMaterialOutput 노드 이름을 ToonBufferOutput 으로 변경한다.

이어서 ToonLightOutput 노드를 새로 추가한다. 이 부분 역시 이전 글에서 다룬 내용의 연장선이다.

이 과정을 거치면, 머티리얼 에디터 안에서 직접 광원 계산을 수행할 수 있게 된다.

2.3 ToonLightPass 로 광원 정보 전달

지금까지의 상태만 놓고 보면, 사실상 순정 엔진에서 Emissive 안에 Toon 셰이딩을 구현한 것과 거의 동일하다.

이제 여기에서 한 걸음 더 나가, Shadow 를 비롯한 광원 관련 정보를 ToonLight Pass 로 전달한다.

  1. ToonShadowTexture 생성

먼저 Main Light 의 그림자를 저장할 ToonShadowTexture 를 만든다. SceneTexture 를 새로 만드는 일반적인 절차는 이전 글에 충분히 설명되어 있으므로 이 글에서는 요약만 한다.

SceneTexturesConfig.h

  1. Main Light 계산 시 Shadow 를 동시에 기록

FDeferredLightPS 에 Render Target 을 하나 더 추가해서, Main Light 를 계산하는 타이밍에 동시에 Shadow 값을 ToonShadowTexture 에 써 주도록 한다.

LightRendering.cpp

이와 함께, DeferredLightPixelMain 함수의 출력 구조를 수정한다.

DeferredLightPixelShaders.usf

ToonShadow 를 출력하는 핵심 코드는 다음과 같다. 사실 LightAttenuation 을 그대로 써도 큰 문제는 없지만, 여기에서는 DeferredLightingCommon.ush 에 정의된 Shadow 계산 방식을 그대로 따라갔다.

// Toon Shadow
float HairShadow = 1;

FShadowTerms Shadow;
Shadow.SurfaceShadow = ScreenSpaceData.AmbientOcclusion;
Shadow.TransmissionShadow = 1;
Shadow.TransmissionThickness = 1;
Shadow.HairTransmittance.OpaqueVisibility = 1;
const float ContactShadowOpacity = ScreenSpaceData.GBuffer.CustomData.a;

if (IsMainLight(LightData))
{
    // Shadow 계산
    GetShadowTerms(
        ScreenSpaceData.GBuffer.Depth,
        ScreenSpaceData.GBuffer.PrecomputedShadowFactors,
        ScreenSpaceData.GBuffer.ShadingModelID,
        ContactShadowOpacity,
        LightData,
        DerivedParams.TranslatedWorldPosition,
        LightData.Direction,
        LightAttenuation,
        Dither,
        Shadow
    );

    float NoL = dot(ScreenSpaceData.GBuffer.WorldNormal, LightData.Direction);

    // RayTracing Shadow 는 물체의 뒷면( N·L < 0 )에서는 레이를 발사하지 않는다.
    // 따라서 SurfaceShadow 는 뒷면에서 값이 비정상적으로 나올 수 있어,
    // step(0, NoL) 를 곱해 뒷면 영역의 섀도우를 보정한다.
    if (!(ScreenSpaceData.GBuffer.ToonBuffer.ShadowCastFlag & SCF_DISABLEONSELF))
    {
        Shadow.SurfaceShadow *= step(0, NoL);
    }

    // Toon Hair Shadow
    if (ScreenSpaceData.GBuffer.ShadingModelID == SHADINGMODELID_TOON &&
        (ScreenSpaceData.GBuffer.ToonBuffer.ToonModel == TOONMODEL_FACE ||
         ScreenSpaceData.GBuffer.ToonBuffer.ToonModel == TOONMODEL_EYE))
    {
        HairShadow = GetHairShadow(ScreenSpaceData.GBuffer, LightData, InputParams.ScreenUV);
    }
}

ToonShadow = EncodeToonShadow(Shadow.SurfaceShadow, Shadow.TransmissionShadow, HairShadow);

위 코드에서 특히 중요한 부분은 다음 한 줄이다.

Shadow.SurfaceShadow *= step(0, NoL);

이 줄이 없으면 SurfaceShadow 는 아래와 같이 잘못된 결과를 낸다.

이 줄이 없으면 SurfaceShadow 는 아래와 같이 잘못된 결과를 낸다.

우리는 Ray Tracing Shadow 를 사용하고 있기 때문에, 법선과 광원 방향의 내적이 음수인 픽셀( N·L < 0 )에서는 그림자를 계산하기 위한 레이가 아예 발사되지 않는다. 그 결과, 뒷면의 Shadow 값이 어색해진다. 이를 보정하기 위해 step(0, NoL) 를 곱해 주는 것이다.

RayTracingOcclusionRGS.usf

EncodeToonShadow 함수는 ToonBufferCommon.ush 안에 정의해 두었다. 단순히 Shadow 관련 값을 float4 로 정리하는 헬퍼에 가깝다.

float4 EncodeToonShadow(float SurfaceShadow, float TransmissionShadow, float HairShadow)
{
    float4 ToonShadow = 1.0f;

    ToonShadow.r = SurfaceShadow;
    ToonShadow.g = TransmissionShadow;
    ToonShadow.b = HairShadow;

    return ToonShadow;
}

이제 Main Light 의 그림자를 텍스처에 저장할 수 있게 되었으니, 이를 ToonLight Pass 에 전달하면 된다.

  1. ToonLight Uniform Buffer 구성

ToonLight Pass 에 전달해야 할 각종 데이터를 모아 두기 위해, ToonLight 라는 이름의 Uniform Buffer 를 만든다. 이후 ToonLight Pass 에 필요한 데이터는 전부 이 버퍼를 통해 전달한다.

  • SHADER_PARAMETER_RDG_UNIFORM_BUFFER 로 ToonLight 를 선언하면,
  • 엔진이 내부적으로 "/Engine/Generated/UniformBuffers/ToonLight.ush" 를 생성하고
  • 이 파일은 다시 /Engine/Generated/GeneratedUniformBuffers.ush 에 포함된 뒤 Common.ush 에서 include 된다.

실제 HLSL 코드에서는 ToonLight.ToonShadowTexture 처럼 접근하면 된다.

ToonLightPassRendering.cpp

GetToonLightPassParameters 함수에서는 ToonShadowTexture 를 ToonLight 버퍼에 채워 넣는다.

ToonLightPassRendering.cpp

한 가지 주의할 점은, 셰이더에서 ToonLight 의 값을 실제로 사용하지 않으면 컴파일 시 최적화로 사라질 수 있다는 것이다. 그 경우, 이후 Custom HLSL 노드에서 ToonLight 를 참조했을 때 “기호를 찾을 수 없음” 오류가 날 수 있다. 이를 방지하기 위해 ToonLight.ush 를 명시적으로 include 해 둔다.

ToonMainLightPS.usf

이제 ToonLight Pass 안에서 ToonShadowTexture 를 정상적으로 사용할 수 있다.

현재는 Custom HLSL 노드에서 ToonShadowTexture 를 직접 샘플링하고 있다. 나중에는 이를 감싸는 전용 머티리얼 노드를 만들어, 더 편하게 사용할 수도 있을 것이다.

머티리얼 에디터에서 투영 정보를 성공적으로 읽어 온 모습

float2 UV = GetSceneTextureUV(Parameters);
#if TOONLIGHTING == 1
    float3 Shadow = Texture2DSampleLevel(
        ToonLight.ToonShadowTexture,
        ToonLight.ToonShadowTextureSampler,
        UV,
        0.0f
    ).rgb;
#else
    float3 Shadow = float3(1.0f, 1.0f, 1.0f);
#endif

return Shadow;

위 코드에서 TOONLIGHTING 매크로는 FToonLightPassPS 의 ModifyCompilationEnvironment 에서 정의한다. 이렇게 해 두면, FToonLightPassPS 가 아닌 다른 셰이더에서 ToonShadowTexture 를 잘못 참조했을 때 발생할 수 있는 문제를 피할 수 있다.

ToonLightPassRendering.h

  1. Main Light 파라미터 전달 (색/방향/각도 등)

마지막으로 Main Light 의 색과 방향, Source Angle 관련 데이터도 ToonLight Pass 로 전달한다. 이를 위해 ToonData Uniform Buffer 안에 FLightShaderParameters 를 추가한다.

ToonLightPassRendering.cpp

GetToonLightPassParameters 에서 해당 값을 실제로 채워 넣는다.

ToonLightPassRendering.cpp

Custom HLSL 노드에서 LightParameters 를 읽어 오는 코드는 다음과 같다.

float3 Color = 0.0f;
float3 Direction = 0.0f;
float SourceRadius = 0.0f;
float SoftSourceRadius = 0.0f;

#if TOONLIGHTING == 1
    Color = ToonLight.Color;
    Direction = ToonLight.Direction;
    SourceRadius = ToonLight.SourceRadius;
    SoftSourceRadius = ToonLight.SoftSourceRadius;
#endif

return 0.0f;

여기에서 SourceRadius 와 SoftSourceRadius 는 사실상 평행광의 Source Angle / Source Soft Angle 에 대응되는 값이다.

  • 다만 Source Angle / Source Soft Angle 은 0~180 범위를 사용하고,
  • SourceRadius / SoftSourceRadius 는 0~1 범위를 사용한다.

2.4 머티리얼 에디터에서 Shading 구성하기

이제부터는 머티리얼 에디터에서 노드를 연결해 실제 셰이딩을 만드는 단계다. 이 부분은 구현자 취향이 크게 반영되는 영역이라, 글에서는 큰 흐름만 정리한다.

나는 소녀전선 2 의 vepley 캐릭터 렌더링 분석/복원 글을 많이 참고했다.

현재는 Lumen 환경광을 코드 상에서 주석 처리해 둔 상태이기 때문에, 실제 환경광은 Base Color × EnviromentColor 정도의 단순한 형태다.

지금까지 구현한 주요 렌더링 특성은 다음과 같다.

  • Ramp 기반 Toon 셰이딩
  • 얼굴 SDF 라이팅 및 코 끝 하이라이트
  • 머리카락 하이라이트
  • 의상 PBR 셰이딩

이 중에서도 특히 PBR 셰이딩 파트가 조금 더 설명할 만한 내용이다.

PBR 항은 Custom HLSL 안에서 계산하고 있다.

float3 DiffuseTerm = 0;
float3 SpecularTerm = 0;
float3 TransmissionTerm = 0;

#if TOONLIGHTING == 1
    FToonLighting ToonLighting = ToonPBRBxDF(
        BaseColor,
        Metallic,
        Specular,
        Roughness,
        Normal,
        LightVector,
        LightRadius,
        LightSoftRadius,
        ViewVector
    );

    DiffuseTerm = ToonLighting.DiffuseTerm;
    SpecularTerm = ToonLighting.SpecularTerm;
    TransmissionTerm = ToonLighting.TransmissionTerm;
#endif

return 1;

여기서 사용하는 ToonPBRBxDF 함수는 새로 만든 ToonShadingModel.ush 파일에 정의되어 있으며, 기본적으로 Unreal 의 DefaultLitBxDF 를 참고해 수정한 버전이다.

구조체 FToonLighting 은 다음과 같은 형태를 가진다.

InitialAreaLight 함수는 아래와 같은 형태이며, 여기서 사용하는 SourceRadius / SoftSourceRadius 는 2.3 절에서 머티리얼 에디터로 전달해 둔 값이다.

DefaultLitBxDF 와 비교했을 때의 차이점은 다음과 같다.

  • DefaultLitBxDF 의 Diffuse / Specular 는 AreaLight.FalloffColor, Falloff, NoL 까지 모두 곱하지만
  • ToonPBRBxDF 에서는
    • 평행광 기준으로 AreaLight.FalloffColor / Falloff 는 항상 1 에 가깝기 때문에 생략하고
    • NoL 은 머티리얼 에디터 레벨에서 Ramp 와 곱해 주기 위해 의도적으로 분리했다.

위가 ToonPBRBxDF, 아래가 DefaultLitBxDF 의 구조

마지막으로 DiffuseTerm / SpecularTerm 에 Ramp 결과와 광원 색을 곱해 최종 Shading 을 완성한다.

아래는 ToonShadingModel.ush 전체 코드다.

// ---------------------------------- YK Engine Start ----------------------------------
// Toon Shading
#pragma once
#include "../ShadingModels.ush"

struct FToonLighting
{
    float3 DiffuseTerm;
    float3 SpecularTerm;
    float3 TransmissionTerm;
};

FAreaLight InitialAreaLight(float SourceRadius, float SoftSourceRadius, float Roughness)
{
    FAreaLight AreaLight;

    float InvDist = 1.0f;

    Roughness = max(Roughness, View.MinRoughness);
    float a = Pow2(Roughness);

    AreaLight.SphereSinAlpha = saturate(SourceRadius * InvDist * (1 - a));
    AreaLight.SphereSinAlphaSoft = saturate(SoftSourceRadius * InvDist);
    AreaLight.LineCosSubtended = 1;
    AreaLight.FalloffColor = 1;
    AreaLight.Rect = (FRect)0;
    AreaLight.IsRectAndDiffuseMicroReflWeight = 0;
    AreaLight.Texture = InitRectTexture();

    return AreaLight;
}

FToonLighting ToonPBRBxDF(
    half3 BaseColor,
    half Metallic,
    half Specular,
    half Roughness,
    half3 Normal,
    half3 LightVector,
    half SourceRadius,
    half SoftSourceRadius,
    half3 ViewVector)
{
    FAreaLight AreaLight = InitialAreaLight(SourceRadius, SoftSourceRadius, Roughness);

    FToonLighting ToonLighting;
    BxDFContext Context;

    Init(Context, Normal, ViewVector, LightVector);

    SphereMaxNoH(Context, AreaLight.SphereSinAlpha, true);

    Context.NoV = saturate(abs(Context.NoV) + 1e-5);

    half3 SpecularColor = ComputeF0(Specular, BaseColor, Metallic);
    half3 DiffuseColor = BaseColor - BaseColor * Metallic;

    ToonLighting.DiffuseTerm = Diffuse_Lambert(DiffuseColor);
    ToonLighting.SpecularTerm = SpecularGGX(Roughness, SpecularColor, Context, saturate(Context.NoL), AreaLight);

    FBxDFEnergyTerms EnergyTerms = ComputeGGXSpecEnergyTerms(Roughness, Context.NoV, SpecularColor);

    // 스펙큘러 레이어 에너지 보존에 따라 디퓨즈를 감쇠
    ToonLighting.DiffuseTerm *= ComputeEnergyPreservation(EnergyTerms);

    // 마이크로패싯 다중 산란에 의한 스펙큘러 보정 (에너지 보존)
    ToonLighting.SpecularTerm *= ComputeEnergyConservation(EnergyTerms);

    ToonLighting.TransmissionTerm = 0;

    return ToonLighting;
}

// ---------------------------------- YK Engine End ----------------------------------

3. 결론

이 글에서 다룬 핵심 아이디어를 한 줄로 정리하면 다음과 같다.

Toon 의 주광 계산은 Forward 방식으로 구현하되, 그 셰이딩 로직은 머티리얼 에디터에 위임한다.

이를 통해 다음과 같은 장점을 얻을 수 있다.

  • 엔진 내부 코드에 셰이딩 로직을 고정하지 않아도 된다.
  • 머티리얼 레벨에서 Toon 의 주광 표현을 자유롭게 설계할 수 있다.
  • 다중 광원 쪽은 여전히 Deferred 파이프라인에 맡겨, 기능과 성능을 모두 챙길 수 있다.

물론 이 설계에도 분명한 한계가 있다. 가장 큰 가정은 다음과 같다.

주광 이외의 평행광이나 기타 광원에 대해서는, 그 정도까지 복잡한 커스터마이즈가 필요하지 않을 것이다.

예를 들어 누군가는 평행광을 여러 개 배치하고, 각 광원마다 서로 다른 Ramp 를 샘플링해 Toon 을 구성하고 싶을 수 있다. 이런 요구 사항은 현재 구조로는 대응하기 어렵다. 다행히도 애니메이션/카툰 렌더링에서 이런 식으로 여러 평행광을 운용하는 경우는 흔치 않다.

현재 이 글에서 실제로 구현한 부분은 주광 처리까지만이다. 다중 광원과 환경광 처리는 아직 남아 있으며, 이는 이후 글에서 다룰 예정이다.

마지막으로, 개인적인 한 컷.

4. 참고 자료

少女前线2:追放 vepley 角色渲染 분석/복원 글


원문

https://zhuanlan.zhihu.com/p/698077782?share_code=8WiOiU4bu5DZ&utm_psn=1977333904445834548