2025.09.29 - [TA/툰셰이더] - URP 고품질 툰셰이딩 R&D 리뷰 : 개요
가장 최근에 했던 것이고, 아직도 이게 맞는지 확신은 없는 상태이나, 나름 이러한 개념이 이전에 어떤 레퍼런스에서도 시도하지 않았던 방향이기도 해서 먼저 이것부터 작성하고자 한다.
URP Lit 셰이더의 기본 구조
Lit Shader를 만들려고 우선 URP에서 기본 제공되는 셰이더를 수정하기로 하였었다. 먼저 URP Lit Shader의 구조부터 설명하고자 한다.
기존 URP Lit 셰이더의 라이팅은 다음과 같이 3가지로 구분된다.
- Global Illumination
- Main Lighting
- Additional Lighting
LightingData라는 구조체를 만들어서 싹다 넣어놓고 나중에 합산하는 식으로 동작한다.
struct LightingData
{
half3 giColor;
half3 mainLightColor;
half3 additionalLightsColor;
half3 vertexLightingColor;
half3 emissionColor;
};
half3 CalculateLightingColor(LightingData lightingData, half3 albedo)
{
half3 lightingColor = 0;
if (IsOnlyAOLightingFeatureEnabled())
{
return lightingData.giColor; // Contains white + AO
}
if (IsLightingFeatureEnabled(DEBUGLIGHTINGFEATUREFLAGS_GLOBAL_ILLUMINATION))
{
lightingColor += lightingData.giColor;
}
if (IsLightingFeatureEnabled(DEBUGLIGHTINGFEATUREFLAGS_MAIN_LIGHT))
{
lightingColor += lightingData.mainLightColor;
}
if (IsLightingFeatureEnabled(DEBUGLIGHTINGFEATUREFLAGS_ADDITIONAL_LIGHTS))
{
lightingColor += lightingData.additionalLightsColor;
}
if (IsLightingFeatureEnabled(DEBUGLIGHTINGFEATUREFLAGS_VERTEX_LIGHTING))
{
lightingColor += lightingData.vertexLightingColor;
}
lightingColor *= albedo;
if (IsLightingFeatureEnabled(DEBUGLIGHTINGFEATUREFLAGS_EMISSION))
{
lightingColor += lightingData.emissionColor;
}
return lightingColor;
}
Global Illumination에서는 다음과 같이 Indirect Diffuse, Indirect Specular를 표현하며, 라이트맵을 쓰지 않는 캐릭터 특성상 Light Probe와 Reflection Probe 데이터가 관여하는 부분이다.
half3 GlobalIllumination(BRDFData brdfData, BRDFData brdfDataClearCoat, float clearCoatMask,
half3 bakedGI, half occlusion, float3 positionWS,
half3 normalWS, half3 viewDirectionWS, float2 normalizedScreenSpaceUV)
{
half3 reflectVector = reflect(-viewDirectionWS, normalWS);
half NoV = saturate(dot(normalWS, viewDirectionWS));
half fresnelTerm = Pow4(1.0 - NoV);
half3 indirectDiffuse = bakedGI;
half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdfData.perceptualRoughness, 1.0h, normalizedScreenSpaceUV);
half3 color = EnvironmentBRDF(brdfData, indirectDiffuse, indirectSpecular, fresnelTerm);
if (IsOnlyAOLightingFeatureEnabled())
{
color = half3(1,1,1); // "Base white" for AO debug lighting mode
}
#if defined(_CLEARCOAT) || defined(_CLEARCOATMAP)
half3 coatIndirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdfDataClearCoat.perceptualRoughness, 1.0h, normalizedScreenSpaceUV);
// TODO: "grazing term" causes problems on full roughness
half3 coatColor = EnvironmentBRDFClearCoat(brdfDataClearCoat, clearCoatMask, coatIndirectSpecular, fresnelTerm);
// Blend with base layer using khronos glTF recommended way using NoV
// Smooth surface & "ambiguous" lighting
// NOTE: fresnelTerm (above) is pow4 instead of pow5, but should be ok as blend weight.
half coatFresnel = kDielectricSpec.x + kDielectricSpec.a * fresnelTerm;
return (color * (1.0 - coatFresnel * clearCoatMask) + coatColor) * occlusion;
#else
return color * occlusion;
#endif
}
Indirect Diffuse는 BakedGI라는 데이터로 되어있는데, 이 데이터는 결국 Per Object Data로 들어온 Light Probe의 계수를 기반으로 결정한다.
half3 SampleSHPixel(half3 L2Term, half3 normalWS)
{
#if defined(EVALUATE_SH_VERTEX)
return L2Term;
#elif defined(EVALUATE_SH_MIXED)
half3 res = L2Term + SHEvalLinearL0L1(normalWS, unity_SHAr, unity_SHAg, unity_SHAb);
#ifdef UNITY_COLORSPACE_GAMMA
res = LinearToSRGB(res);
#endif
return max(half3(0, 0, 0), res);
#endif
// Default: Evaluate SH fully per-pixel
return SampleSH(normalWS);
}
그리고 Scene에서 Main Light는 Directional Light가 존재하면 무조건 Directional Light로 지정된다. Additional Light와 Directional Light는 구조적으로 아예 분리해서 사용하고 있다. 왜 이런 구조로 했는지는 모름.
일반적으로 많이 쓰는 CSM(Cascade Shadow Mapping) 기능이 이 MainLight로 지정된 Directional Light에 종속된 기능이다.
Light GetMainLight(float4 shadowCoord, float3 positionWS, half4 shadowMask)
{
Light light = GetMainLight();
light.shadowAttenuation = MainLightShadow(shadowCoord, positionWS, shadowMask, _MainLightOcclusionProbes);
#if defined(_LIGHT_COOKIES)
real3 cookieColor = SampleMainLightCookie(positionWS);
light.color *= cookieColor;
#endif
return light;
}
그렇다면 MainLight가 존재하지 않는 씬, 즉 Directional Light가 없이 Point, Spot Light만 이용해서 꾸민 씬에서는 MainLight 루프가 아예 존재하지 않을까?
이런 식으로 Directional Light가 아예없으면 쓰레기 값을 넣어서 아무런 영향이 없음을 표현하는 식이다.
Point, Spot Light는 무조건 Additonal Light 루프를 타게 되어 있으며, CSM을 쓰려면 Directional Light가 없으면 안된다는 뜻이다.
이러한 구조 하에서 지금까지 찾아본 모든 툰셰이더는 MainLighting에서 툰셰이딩을 적용하는 식으로 동작하고 있었다.
나 역시 처음엔 MainLighting에서 여러 툰셰이딩 기법을 적용하였고, 툰셰이더는 어쩔 수 없이 이런 식으로 구조를 사용할 수 밖에 없지 않을까 생각했다.
MainLight 종속성으로 인한 한계
하지만 실제로 마주한 현실은 달랐다. 기본 Lit Shader를 기반으로 배경 작업이 진행되면서, 씬마다 굳이 Directional Light가 필요하지 않은 예외 상황이 발생했다. 대표적인 것이 실내 씬이다. Directional Light는 곧 태양광과 같이 아득히 먼 광원을 표현하기 위한 것이고, 실내는 이러한 방향광이 모두 차폐되는 공간이기 때문에 굳이 Directional Light를 사용할 필요가 없는 것이다.
배경은 CSM을 사용하지 못한다는 제약이 있지만, 그게 없더라도 Lit Shader에서 MainLight와 Additional Light의 조명 계산식이 동일하기 때문에 Directional Light가 없어도 딱히 문제될 게 없었다.
캐릭터 셰이딩은 MainLight 방향과 색상을 통해서 음영을 셀식으로 쪼개기, 림라이트 처리 등을 모두 계산하고 있었다.
간접광은 방향 정보가 없으니 그냥 더하는 값일 뿐이고, Additional Light에 대해서도 일일이 처리를 해주기엔 Rim Light를 위한 연산량도 많고, 셀식 음영을 additional light에 적용해버리면 당연하게도 캐릭터가 배경에 비해 과도하게 밝아지는 문제가 있었다.
그러다보니 마주한 문제가 바로 캐릭터 툰셰이딩은 Directional Light를 배경과 독립적으로 처리해주어야 한다는 것이다. 비록 Directional Light가 없더라도 캐릭터의 툰셰이딩을 위해선 가짜 Directional Light가 필요하다. 게다가 배경과 달리 그림자 영역을 간접광 정보로만 표현하는 게 아니라 MainLight의 색상이 묻어나는 방식으로 라이팅이 되다보니, 항상 배경에 비해서 캐릭터는 더 밝을 수 밖에 없었다. 게다가 간접광이 직사광보다 밝은 경우엔 간접광은 캐릭터 Normal에 따라서 라이팅이 되기 때문에 툰셰이딩 느낌이 사라지고 불필요한 입체감이 강조되는 결과를 낳았다.
캐릭터 모델 자체가 잘 만들어져서 이런 식으로 라이팅이 들어가도 좀 덜 어색한 것 같기도 하다. 그래서 앞서 말했듯 결국 리소스가 잘 만들어져 있어야 의미가 있고 셰이더는 그냥 보조장치의 느낌이라는 걸 깨달아서 이렇게 글을 쓰고 있는 것이다.
이 MainLight 종속성으로 인한 구조적 문제는 비단 실내 씬에만 해당되는 것이 아니다. 인물 간의 대화를 묘사하는 다이얼로그, 시네마틱 등 컷 별로 라이팅에 변화를 주어야 하는 경우 문제가 생긴다. 배경에 Directional Light를 맞추면 캐릭터의 툰셰이딩이 특정 컷에서는 안 예쁘게 나오고, 결국 컷마다 캐릭터의 툰셰이딩을 원하는 느낌으로 묘사하기 위한 추가적인 스크립트 지원이 필요하다.
구현 사양
조금 뒤늦게 말하는 감이 있지만 결국 현재 Lit Shader(PBR) 기반으로 에셋이 만들어지는 배경 환경에서 Toon Shading을 하는 캐릭터가 MainLight를 기반으로 툰셰이딩을 처리하는 경우, 어쩔 수 없이 배경과 이격이 발생할 수 밖에 없고, 이를 보완하기 위해서 간접광은 어떻게 하고, 직접광 색상은 어떻게 하고, 방향은 어떻게 하고 등등의 스크립트와 Volume 설정을 지원하여야 한다는 것이다. 그리고 이렇게 씬 별로, 또는 컷 별로 작업을 일일이 해주어야 한다는 것은 아티스트의 업무 부담이 그만큼 커진다는 뜻이다. 작업량도 그렇지만 프로젝트에서 항상 일정한 룩이 나와야 하는데 작업자 별로 다르게 세팅할 수도 있으니 기준도 명확하게 잡고 계속 상위 직책자가 검수 피드백 사이클을 빡빡하게 굴려야 한다는 뜻이다.
그래서 내가 툰셰이딩에서 고려한 요소는 다음과 같다.
- 에너지 보존 : 캐릭터가 받는 조명량과 배경이 받는 조명량은 동일해야 한다.
- 전역 시스템 : Per Object, Per Scene, Per Cut 단위의 수정 방식은 필요한 경우가 아니면 지양한다.
- 아티스트의 업무 과중 및 통일감을 해치는 요소 최소화
결국 뭐... 프로그래머 관점에서 해피한 무언가를 만들려고 했다고 볼 수도 있다. Override를 반복할 경우 관리가 불가능에 가까우니 최대한 시스템 적으로 컨트롤 할 수 있는 구조를 만들어야 한다고 생각했다.
모든 라이트 타입에 대응하는 툰셰이딩
위에 작성한 구현 사양에 맞추기 위해선 캐릭터의 툰셰이더가 배경과 마찬가지로 모든 라이트 타입에 대응이 가능한 구조로 작성이 되어야 한다. 과연 이를 달성할 수 있을까?
툰셰이딩을 위해서는 일단 Light의 방향 정보가 무엇보다 중요하다. 모든 Dynamic Light에는 방향정보가 포함되어 있지만, 문제는 간접광이다. 간접광을 어떻게 처리할 지부터 해결해야 한다.
간접광의 방향성 정보
우선 전제를 하나 깔고 가고자 한다. 캐릭터의 간접광 정보는 Probe 데이터, 즉 Spherical Harmonics(구면 조화 함수)를 통해 처리한다는 것이다.
왜냐하면 배경은 LightMap을 쓰면서 Dynamic Object에 대해서는 Light Probe를 통해 간접광을 표현하는 식으로 구조가 만들어져 있기 때문이다.
툰셰이더들이나 인터넷 튜토리얼 같은데를 보면 이러한 Probe 데이터의 입체적인 느낌을 없애기 위해서 SampleSH 함수에 입력파라미터로 들어가는 Normal 값을 0을 집어넣는 행위를 한다.
SampleSH(normalWS) | SampleSH(0.0) |
![]() |
![]() |
일단 이러한 행위가 어떤 원리에 의해 동작하는지 언급해보자면, Spherical Harmonics의 특성과 관련이 있다.
L0 상수항에, 방향성 정보를 갖는 L1, L2 정보를 합산해서 현재 위치에서의 최종적인 색상값을 지정하는데, 이 각도를 표현하는데 사용되는 Normal 벡터에 0을 넣는다는 것은 곧 L1, L2 정보를 모두 날려버린 채 L0만 출력하겠다는 뜻이 된다.
// Ref: "Efficient Evaluation of Irradiance Environment Maps" from ShaderX 2
real3 SHEvalLinearL0L1(real3 N, real4 shAr, real4 shAg, real4 shAb)
{
real4 vA = real4(N, 1.0);
real3 x1;
// Linear (L1) + constant (L0) polynomial terms
x1.r = dot(shAr, vA);
x1.g = dot(shAg, vA);
x1.b = dot(shAb, vA);
return x1;
}
이 함수를 보면 알 수 있는데, float4 데이터를 선언하면서 xyz에는 normal 값을, w값에는 1을 넣는다. 즉, 1.0 값에 대응하는 shAr, shAh, shAb의 w값은 단순히 Normal과 관계없이 더하겠다는 뜻이다.
즉 SampleSH(0.0)의 의미는 다음과 동일하다.
// SampleSH(0.0) == float3(unity_SHAr.w, unity_SHAg.w, unity_SHAb.w);
outColor.rgb = float3(unity_SHAr.w, unity_SHAg.w, unity_SHAb.w);
문제는 LightProbe에 저장되는 데이터가 비단 간접광 정보 뿐만이 아니라는 점이다. LightMap을 굽거나, Adaptive Probe Volume 등을 사용하게 되는 경우, Light Probe에는 Direct Lighting 정보까지 포함된다.
이러한 상황에서 L0 정보만 표현한다면 다음과 같이 출력된다.
조명 결과가 상당히 플랫한 것을 알 수 있다. 간접광 정보를 가지고선 캐릭터의 형태감을 표현할 수 없게 되었다는 의미이기도 하다. 추가로 조명이 어느 방향에서 비추고 있는지 표현하기 위해 필요한 Rim Light, Specular 정보도 사용할 수 없다는 것이 매우 아쉽다.
스타일에 따라서 이러한 플랫한 느낌을 지향하는 프로젝트라면 이런 식으로 간접광을 처리하도록 만들어도 무방하나, 내가 집중한 부분은 바로 다음과 같은 부분이다.
누가봐도 노란색 조명이 오른쪽에서 캐릭터를 비추고 있는 상황이며, 그 세기가 거의 직접광에 맞먹을 정도로 밝다. 이걸 단순히 L0 정보만 처리하면?
이렇게 나온다. 여기에 Rim Light 라던가, self shadow 표현 등을 좀 더 추가해볼 수 없을까? Probe 데이터로 들어갔지만 현 상황에서 저 노란 프로브 조명이 캐릭터의 Key Light에 해당하는 상황이니까.
일단 선 결론부터 보여주자면 위와 같이 스크린 스페이스 림라이트가 프로브 데이터를 통해서도 표현되도록 구현하였다.
이러한 기법은 나중엔 다 합쳐서 설명을 하긴 하겠지만 일단은 프로브에 한정해서 설명해보고자 한다.
앞서 언급했던 대로, 프로브의 L1, L2 정보는 방향성 정보이다. 즉, L1, L2 정보를 이용하여 역으로 프로브에서 가장 지배적인 조명의 방향을 역산할 수도 있다는 얘기가 된다.
In-depth: Extracting dominant light from Spherical Harmonics
In this reprinted #altdevblogaday in-depth piece, game programmer Simon Yeung looks at extracting the dominant directional light from spherical harmonics-projected light, to fake specular lighting.
www.gamedeveloper.com
Extracing Dominant Light From Spherical Harmonics 라는 아티클이다. 일단 나도 수학적으로 깊게 아는 건 아니지만, 어찌되었건 간에 L1 계수만 가지고도 지배적인 조명 방향과 색상을 계산해낼 수 있다는 개념이다. 이걸 배경 셰이더에서는 Specular 처리에 사용하고 있다. Probe 데이터를 이용해 Diffuse, Specular 두개 항을 모두 처리하도록 구조를 만든 셈이다.
캐릭터에도 이러한 방식으로 조명 방향을 계산하고, 해당 방향 벡터를 이용해 UV Offset으로 변환하는 과정을 거쳤다.
사고의 확장
자, 그러면 이런 식으로 간접광에 대해서도 Rim Light 처리가 가능하도록 만들었는데, Dominant Light를 구하는 계산식이 계속 아른거린다. 이걸 좀 더 확장시켜서 직접광 정보까지 모두 합산한 데이터를 기준으로 툰셰이딩을 처리하도록 하면 안되는 걸까? 현재 캐릭터가 받는 모든 조명 데이터를 합산해서 최종적으로 어떤 방향을 기준으로 툰셰이딩을 실행할 것인지 결정하도록 코드를 작성해보면 어떨까 하는 아이디어를 생각했다.
그래서 모든 조명 방향 벡터에 대한 가중치 합을 이용한 Dominant Light Direction을 계산하는 식을 구성했다.
half3 dominantDirection = 0;
dominantDirection += CalculateWeight(probeColor) * probeDominantLightDir;
dominantDirection += CalculateWeight(mainLightColor) * mainLightDirection;
#if defined(_ADDITIONAL_LIGHTS)
uint pixelLightCount = GetAdditionalLightsCount();
LIGHT_LOOP_BEGIN(pixelLightCount)
Light light = GetAddLight(lightIndex, c);
half3 lightColor = light.color * light.distanceAttenuation * light.shadowAttenuation;
dominantDirection += CalculateWeight(lightColor) * light.direction;
LIGHT_LOOP_END
#endif
dominantDirection = normalize(dominantDirection);
모든 값을 더하고 Normalize한다. 그리고 색상 값에 따른 추가 가중치를 두어서 어떤 벡터에 더 가깝게 만들 것인지 지정한다.
이렇게 얻은 지배적인 방향과 색상 데이터를 이용하여 다음과 같이 라이트가 어떤 방향에서 어떤 타입으로 처리되던 간에, 항상 툰셰이딩이 되도록 하면서도, 배경과 캐릭터가 동일한 조명을 받도록 구성할 수 있다.
이러한 방식이 통할 수 있을 거라고 생각한 근거는 다음과 같다.
툰셰이딩과 같은 기법은 결국 의도적으로 정보를 제거하여 출력하는 행위이다. 조명에 있어서도 마찬가지로 오히려 다양한 색상의 조명이 알록달록한 것보다는 2, 3가지 정도의 색상 무드를 어떻게 조화시킬 것인지 고민하는 과정이라고 생각했다.
"아름다움이란 모든 색을 예쁘게 만드는 게 아니라 평범한 2개의 색을 조화롭게 연결시키는 데에서 나온다."
https://www.youtube.com/watch?v=BCuIY3Cddx8&t=691s
이 유튜브 영상의 초반에서 언급하는 부분이다.
화음도 너무 많이 섞이면 불협화음이 되는 것처럼 사실 라이트 조명 색상이 다양하고 많이 존재한다는 것이 곧 아름다움과 직결된다고 볼 순 없으며, 어떻게 주어진 몇 가지의 재료들을 버무리냐의 문제로 귀결된다.
영상을 보면서 그냥 한번 조명 배치해봤다. 라이팅 딱히 해본 적은 없지만... 그래도 최근 이런 기능을 제작했을 때 회사 내에서도 홍보를 어떻게 하냐에 따라 만든 기능이 그냥 버려질 수도 있고 적극적으로 밀어주기도 하니까.. 포장지를 잘 만들어보려고 노력중이다.
나중에 캐릭터의 그림자도 언급할 것이다. 일단 상단의 Spot Light를 그림자로 그리도록 했더니 좀 이상하게 그려진다.
흠... 모델 퀄리티가 높아서 그런가 진짜 그냥 셰이더 셋업만 했는데 엄청 괜찮아 보인다.. 나도 언젠가 이렇게 만들 수 있을까? 일단 이렇게 만드려면 게임을 끊어야 할 것 같긴 하다..
성능 이점
게다가 이런 식으로 계산을 해버리면 실제로 Rim Light 구하기 위한 Depth Texture 샘플, 그리고 만약 재질 표현을 위해 GGX Specular 등을 추가했다면 기존 Lit Shader는 Additional Light 개수 만큼의 연산을 해주어야 했던 것에 반해 단 1번의 계산만 하더라도 처리가 가능하다. 이는 Dominant Light 방향을 Fragment 단위로 계산하기 때문이다.
갑자기 여기서 헛소리를 하는 것 같지만 기존의 유니티의 Per Object Data로 모든 시스템을 관리하던 게 진짜 얼마나 구린지 알 수 있는 대목이다. 3년 정도 일하면서 느낀 바로는 셰이더의 최적화는 셰이더 초월 함수를 덜쓰고 연산식을 수정하는 데에서 오는 게 아니라 필요없는 연산을 얼마나 덜어내는가에 있다. 딱 기존 Forward -> Forward+의 구현을 보면 이러한 기조의 변화를 확인할 수 있다. 실제로 해당 픽셀에 영향을 주지 않는 데이터가 불필요하게 연산에 참여한다는 것만으로 셰이더는 느려진다.
기존 Forward 쓰던 프로젝트에서 가장 문제 되는 게 바로 Terrain과 같이 거대하게 펼쳐진 메쉬에 대한 라이팅 성능 저하일 것이다. Terrain 부분부분만 떼서 보면 영향을 받는 조명은 2개정도인데, 실제로 조명 데이터는 Per Object 단위로 컬링되어서 집어넣다보니 Terrain 조명 연산을 하겠다면서 8개 조명 루프가 돌고 있다. 이건 유니티가 구현을 개떡같이 해놓은 게 문제인거다.
단점
어쨌든 모바일 환경에서 구동하는 것에 있어서도 해당 방식은 큰 이점을 가진다. 다만 결국 조명 연산을 1번만 하기 때문에 여러 조명이 중첩되는 느낌을 표현하기엔 무리가 있다. 아니... 무리가 있...나?
음... 일단 해당 방식을 현재 적극 사용중인 건 아니기 때문에 해당 방식으로 라이팅을 진행하면서 발생하는 추가적인 사이드 이펙트가 어떤 게 존재할 지는 아직 파악을 해봐야 할 것 같다.
'TA > 툰셰이더' 카테고리의 다른 글
(작성중)URP 고품질 툰셰이딩 R&D 리뷰 : 양감 (0) | 2025.09.30 |
---|---|
URP 고품질 툰셰이딩 R&D 리뷰 : 개요 (0) | 2025.09.29 |