본문 바로가기

Unreal Engine

Subsurface Shading Model in Unreal Engine

ShadingModels.ush 에서 찾을 수 있는 각 셰이딩 모델의 BxDF 함수로 Lighting 구조체의 내용이 채워지게 된다. 각 셰이딩 모델은 고유의 계산식을 가지고, 이 식에 따라 해당 모델의 특징적인 양상이 렌더링 된다.
채워질 Lighting 구조체의 내용은 다음과 같다.

  • Diffuse
  • Specular
  • Transmission

여기에서 Diffuse와 Specular 로 한번 LightAccumulator_AddSplit 을 수행하고, 다음 번에 Transmission으로 한번 더 같은 계산을 수행해 LightAccumulator에 누적된다.
Subsurface Shading Model의 BxDF 함수인 SubsurfaceBxDF 내용을 살펴보고, Subsurface 셰이딩 모델이 어떻게 동작하는지 알아보았다.
 

FDirectLighting SubsurfaceBxDF(FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, half NoL, FAreaLight AreaLight, FShadowTerms Shadow )
{
	FDirectLighting Lighting = DefaultLitBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow);

	half3 SubsurfaceColor = ExtractSubsurfaceColor(GBuffer);
	half Opacity = GBuffer.CustomData.a;

	// to get an effect when you see through the material
	// hard coded pow constant
	half InScatter = pow(saturate(dot(L, -V)), 12) * lerp(3, .1f, Opacity);

	// Wrap around lighting, 
	// * /(PI*2) to be energy consistent (hack do get some view dependnt and light dependent effect)
	// * Opacity of 0 gives no normal dependent lighting, Opacity of 1 gives strong normal contribution	
	// * Simplified version (with w=.5,  n=1.5): 
	//     half WrappedDiffuse = 2 * pow(saturate((dot(N, L) + w) / (1.0f + w)), n) * (n + 1) / (2 * (1 + w));
	//     NormalContribution = WrappedDiffuse * Opacity + 1 - Opacity;
	const half WrappedDiffuse = pow(saturate(dot(N, L) * (1.f / 1.5f) + (0.5f / 1.5f)), 1.5f) * (2.5f / 1.5f);
	const half NormalContribution = lerp(1.f, WrappedDiffuse, Opacity);
	const half BackScatter = GBuffer.GBufferAO * NormalContribution / (PI * 2);

	// Transmission
	// * Emulate Beer-Lambert absorption by retrieving extinction coefficient from SubSurfaceColor. Subsurface is interpreted as a 'transmittance color' 
	//   at a certain 'normalized' distance (SubSurfaceColorAsTransmittanceAtDistanceInMeters). This is a coarse approximation for getting hue-shiting. 
	// * TransmittedColor is computed for the 1-normalized distance, and then transformed back-and-forth in HSV space to preserving the luminance value 
	//   of the original color, but getting hue shifting
	const half3 ExtinctionCoefficients = TransmittanceToExtinction(SubsurfaceColor, View.SubSurfaceColorAsTransmittanceAtDistanceInMeters);
	const half3 RawTransmittedColor = ExtinctionToTransmittance(ExtinctionCoefficients, 1.0f /*At 1 meters, as we use normalized units*/);
	const half3 TransmittedColor = HSV_2_LinearRGB(half3(LinearRGB_2_HSV(RawTransmittedColor).xy, LinearRGB_2_HSV(SubsurfaceColor).z));

	// lerp to never exceed 1 (energy conserving)
	Lighting.Transmission = AreaLight.FalloffColor * (Falloff * lerp(BackScatter, 1, InScatter)) * lerp(TransmittedColor, SubsurfaceColor, Shadow.TransmissionThickness);

	return Lighting;
}

Diffuse, Specular

위 두 항은 DefaultLit 셰이딩 모델과 같을 것임을 예상할 수 있다.
즉 Transmission 최종값이 0이라면, DefaultLit 셰이딩 모델을 사용한 것과 같이 렌더링된다.
 

Transmission

Subsurface 셰이딩 모델의 특징적인 부분은 Transmission 계산에서 나타난다.
코드에서 볼 수 있듯, GBuffer의 Opacity 와 AO 값을 계산에 사용한다.
 

  • AO는 Opacity값이 높은 부분에서 더 두드러지게 보인다.
  • Opacity는 Scattering 강도에 직접적인 영향을 미친다. 아래 InScatter, BackScatter의 강도와 혼합 방식을 결정하는 요인이 된다.
  • InScatter, BackScatter (half)가 Scatter 양상을 결정한다.
    최종 Transmission에서, InScatter 가 0이면 BackScatter, 1인 부분은 1.0이 출력된다.
    lerp(BackScatter, 1, InScatter)

InScatter

	// to get an effect when you see through the material
	// hard coded pow constant
	half InScatter = pow(saturate(dot(L, -V)), 12) * lerp(3, .1f, Opacity);

 
 

왼쪽 : InScatter 항만 출력해 본 모습. 오른쪽 : N dot L

 

 

L dot -V를 제곱해서 대비 및 강도를 올린 것. pow 지수는 12.0f로 고정이다.
이것을 Opacity가 0.0에 가까울수록 4배, 1.0에 가까울수록 0.1배 한다.
→ 라이트 방향과 시선 방향이 정확히 반대가 될 때 가장 강한 InScatter 영역이 생기는 것을 볼 수 있다.
이렇게 되면 라이트가 마치 반투명한 오브젝트를 뚫고 들어오는 듯한 느낌을 준다.
L dot V는 물체의 Normal 과는 상관없이 빛 방향과 카메라 방향만으로 계산되는 값이기 때문에, 오브젝트가 평면인 것 처럼 보인다.
이 항은 Two-sided Foliage 셰이딩 모델에서는 -VoL 을 인풋으로 하고 Roughness가 0.6인 GGX 를 수행하는 것으로 대체된다.
 

BackScatter

const half WrappedDiffuse = pow(saturate(dot(N, L) * (1.f / 1.5f) + (0.5f / 1.5f)), 1.5f) * (2.5f / 1.5f);

 
주석에 따르면, Opacity가 0이면 Normal Contribution 이 없고, 
1이면 WrappedDiffuse 결과물 = Normal 로 계산된 WrappedDiffuse 결과가 나온다.
→ Inscatter와 BackScatter 는 Subsurface의 강도에만 영향을 미친다. 최종 색상은 SubsurfaecColor가 여기에 곱해지는 것으로 결정된다.

WrappedDiffuse 란?

https://gamedevforever.com/150

Wrapped Diffuse

오랜만이네요. 한 동안 멘탈이 붕괴되어서, 방황하다가 우리 아가랑 신나게 놀다가 정신차리고 돌아왔습니다. ㅎㅎ슬슬 GDC12의 기술문서들이 올라오네요.. ㅎㅎㅎ.. 비록 GDC는 못 갔지만, 대충

gamedevforever.com

https://www.cim.mcgill.ca/~derek/files/jgt_wrap.pdf
위 논문을 참고할 수 있다. 

여기에서 w가 얼마나 빛이 표면을 wrap 할지 결정하는 파라미터이다. 이 셰이딩 모델에서는 W를 1.5로 고정했음을 볼 수 있다.

왼쪽 : Opacity가 1.0일 때 InScatter, 오른쪽 : NdotL

에너지를 보존하는 선에서 NdotL보다 더 부드러운 Diffuse 양상을 만든다.
BackScatter 는 마치 Scattering 되는 것 처럼 보이는 부드러운 명암을 만든다.
 
Opacity가 1.0에 가까울수록 WrappedDiffuse가,
0.0에 가까울수록 1.0이 출력된다.
(이후 이 1.0부분은 BackScatter가 출력되는 부분이 된다)
 

최종 Scatter 강도

lerp(BackScatter, 1, InScatter) 를 출력해 보면 다음과 같다.
 

 
왼쪽부터 Opacity 0.0f, 0.5f, 1.0f.
Unlit shading model 머터리얼에서 셰이더 코드 중 Transmission 부를 재구현
 
Opacity 가 0.0에 가까워질수록 BackScatter 비중이 더 커지기 때문에 평평한 느낌이 들고,
1.0에 가까울수록 N dot L 기반인 InScatter 값 비중이 더 커지기 때문에 형태가 더 뚜렷히 보인다.
 
 

 
맨 오른쪽 - Opacity 0.0일 때 Scattering
맨 왼쪽 : Opacity 1.0일 때 Scattering
가운데 : Subsurface 셰이딩 모델에서 BaseColor 를 0으로 두어 Transmission 만 시각화 해 본 것.
위쪽 절반은 Opacity 0.0일 때, 아래쪽 절반은 1.0일 때 Transmission
 
맨 왼쪽과 맨 오른쪽 출력 결과가 최종 Transmission 임을 생각했을 때,
Opacity 가 낮은 부분일수록 어두운 부분에의 Transmission 기여가 더 약해질 것임을 예상할 수 있다.
Opacity가 낮은 부분일수록 Transmission의 모양도 더 평평해진다.
 

Transmitted Color

	// Transmission
	// * Emulate Beer-Lambert absorption by retrieving extinction coefficient from SubSurfaceColor. Subsurface is interpreted as a 'transmittance color' 
	//   at a certain 'normalized' distance (SubSurfaceColorAsTransmittanceAtDistanceInMeters). This is a coarse approximation for getting hue-shiting. 
	// * TransmittedColor is computed for the 1-normalized distance, and then transformed back-and-forth in HSV space to preserving the luminance value 
	//   of the original color, but getting hue shifting
	const half3 ExtinctionCoefficients = TransmittanceToExtinction(SubsurfaceColor, View.SubSurfaceColorAsTransmittanceAtDistanceInMeters);
	const half3 RawTransmittedColor = ExtinctionToTransmittance(ExtinctionCoefficients, 1.0f /*At 1 meters, as we use normalized units*/);
	const half3 TransmittedColor = HSV_2_LinearRGB(half3(LinearRGB_2_HSV(RawTransmittedColor).xy, LinearRGB_2_HSV(SubsurfaceColor).z));

 
최종 Transmission 색상은 GBuffer의 Subsurface Color 로부터 물질 고유의 흡광율을 계산하고, 이 물질을 1미터가량 통과한 뒤의 빛 색상을 계산해 사용한다. 
Subsurface Color는 현재 표현하고자 하는 물질을 특정 두께만큼 지난 이후의 Transmitted Color로 간주되는데, 이 특정 두께는 SubSurfaceColorAsTransmittanceAtDistanceInMeters 이다. 이것은 r.SSS.SubSurfaceColorAsTansmittanceAtDistance의 값을 참조하고, 기본값은 0.15 이다.
 

Beer-Lambert law

https://www.youtube.com/watch?v=fJRJLUYZe9c

같은 너비의 비커에 같은 용액을 농도만 다르게 담았다고 하자.
#1 비커에는 낮은 농도로, #2 비커에는 높은 농도로 담아두었다. 이 두 비커에 같은 양의 빛을 입사시킨다고 할 때(I0), #1 비커를 통과한 이후의 빛의 양 I1 이 #2 비커를 통과한 빛보다 더 강하다.
 

I2 < I1
 

#3 비커는 #2 비터의 용액과 같은 농도이지만, 비커의 너비가 2배이다. 즉, 빛이 통과해야 하는 거리가 2배인 것이다. 이때 빛의 양을 앞의 두 비커와 비교하면 아래와 같다.
 

I3 < I2 < I1
 

#1 비커에서, Transmittance T는 T = I1 / I0 (입사한 빛 대비 투과한 빛의 비율) 이다.
I2 < I1 이므로, 투과율도 비커 #1이 비커 #2보다 크다.
이렇듯 투과율은 빛이 얼마나 물질을 잘 통과하는지의 척도인데, absorbance는 이 반대이다.
즉 빛이 잘 통과할수록 absorbance는 낮다- 빛을 더 적게 흡수한다.
빛이 더 적게 통과하면, 그만큼 흡광율 absorbance는 더 높은 것이 된다.
이렇듯 투과율과 흡광율은 서로 반비례하는데, 흡광율 A는 투과율의 negative log로 계산된다.
 

A = -log10T

또는 #1 비커의 예에서는 흡광율이
 

A = -log10(I1 / I0)

이 된다.
 
Beer라는 사람은 Absorbance는 물질의 고유 흡광계수인 상수 ε (액체의 고유한 성질에 따른 상수. 여기에는 압력과 온도 등의 변수도 포함된다) 을 기반으로 빛이 이동해야 하는 거리 l 과 비례하고, 또한 액체의 농도 c 와 비례한다고 보았다. 이것을 아래와 같은 식으로 표현할 수 있다.
 

A = εlc
 

 
이 공식이 유용한 이유는, 어떤 용액의 일정한 농도에서의 흡광율을 측정을 통해 알아낼 수 있다면, 물질이 가진 고유 흡수 상수 ε 또한 계산해낼 수 있다는 것이다.
 
위 내용을 알고, 다시 Subsurface 셰이딩 모델의 아래 Transmission 부분을 보자.

// Transmission
	// * Emulate Beer-Lambert absorption by retrieving extinction coefficient from SubSurfaceColor. Subsurface is interpreted as a 'transmittance color' 
	//   at a certain 'normalized' distance (SubSurfaceColorAsTransmittanceAtDistanceInMeters). This is a coarse approximation for getting hue-shiting. 
	// * TransmittedColor is computed for the 1-normalized distance, and then transformed back-and-forth in HSV space to preserving the luminance value 
	//   of the original color, but getting hue shifting
	const half3 ExtinctionCoefficients = TransmittanceToExtinction(SubsurfaceColor, View.SubSurfaceColorAsTransmittanceAtDistanceInMeters);
	const half3 RawTransmittedColor = ExtinctionToTransmittance(ExtinctionCoefficients, 1.0f /*At 1 meters, as we use normalized units*/);
	const half3 TransmittedColor = HSV_2_LinearRGB(half3(LinearRGB_2_HSV(RawTransmittedColor).xy, LinearRGB_2_HSV(SubsurfaceColor).z));

 
먼저 SubsurfceColor 와 View.SubSurfaceColorAsTransmittanceAtDistanceInMeters 로 ExtinctionCoefficients 를 계산한다. (여기에서 Extinction = Absorbance 와 동의어로 봐도 될 것 같다)
TransmittanceToExtinction등의 함수들은 ParticipatingMediaCommon.ush 에 정의되어 있다.

float3 TransmittanceToExtinction(in float3 TransmittanceColor, in float ThicknessMeters)
{
	// TransmittanceColor	= exp(-Extinction * Thickness)
	// Extinction			= -log(TransmittanceColor) / Thickness
	return -log(clamp(TransmittanceColor, PARTICIPATING_MEDIA_MIN_TRANSMITTANCE, 1.0f)) / max(PARTICIPATING_MEDIA_MIN_MFP_METER, ThicknessMeters);
}

 
Subsurface Color를 투과된 이후의 색으로, 빛이 이동한 거리 l을 View.SubSurfaceColorAsTransmittanceAtDistanceInMeters (r.SSS.SubSurfaceColorAsTansmittanceAtDistance 값을 사용. 기본값 0.15) 으로 두어 ExtinctionCoefficient(흡광률) A를 계산한다. 이것이 물질이 가진 고유 흡광계수로 간주한다.

float3 ExtinctionToTransmittance(in float3 Extinction, in float ThicknessMeters)
{
	return exp(-Extinction * ThicknessMeters);
}

 
계산해 낸 흡광율을 다시 투과율로 변환한다. 빛이 이동한 거리 l은 1미터로 고정해서, 이 색상이 투과한 이후의 색상을 계산한다.
이렇게 계산해 낸 색상이 현재 렌더하려는 물질의 1m 두께만큼 통과한 이후의 색상(RawTransmittedColor)이 된다.
 
아래는 위 코드를 적용해 SubsurfaceColor 와 TransmittedColor를 비교해 본 것이다.
 

(왼쪽 색상: Subsurface Color 원본 / 오른쪽 색상: TransmittedColor)

 
최종 TransmittedColor는 이 색상의 명도와 채도만 사용한다 - HSV의 H, S 값만 사용한다. 밝기는 Subsurface Color의 그것을 유지한다.
 

Shadow.TransmissionThickness

TransmissionThickness 가 1인 부분은 SubsurfaceColor를 그대로 출력하고, 0에 가까울수록 아래에서 계산될 TransmittedColor가 출력된다. TransmittedColor는 SubsurfaceColor를 기반으로 해서 투과된 이후 변형된 (Hue Shifted) 색상이다. 밝기는 Subsurface Color의 그것을 그대로 사용한다.
이 값은 LightAttenuation의 Y 와 W 성분에서 더 낮은 값을 사용하고 있는데 (min 계산), VSM 을 사용할 경우에는 ShadowMask의 모든 성분이 같은 값으로 들어간다.

DeferredLightingCommon.ush의 GetShadowTerms(). 모바일일 경우에는 LightAttenuation.w를 사용한다.
GetShadowTerms() 호출부 in DeferredLightingCommon.ush, AccumulateDynamicLighting()
AccumulateDynamicLighting() 호출부 in DeferredLightingCommon.ush, GetDynamicLightingSplit()
DeferredLightPixelShaders.usf DeferredLightPixelMain()
DeferredLightPixelShaders.usf

따라서, VSM ShadowMap Mask를 사용하는 환경이라면 TransmissionThickness는 모두 1.0 이다.
이렇게 되면 TransmittedColor 는 출력되지 않고, 모든 영역에 Subsurface Color가 출력된다.
 

정리

  • Subsurface Color값은 해당 표면을 통과한 이후의 색상을 상정하고 작성한다.
  • InScattering, BackScattering이 합성된 형태로 Scattering 양상이 정해지고, Opacity에 따라 이 두 값이  혼합된다.
  • Opacity가 1.0 이라도, Transmission 강도가 0.0이 아님을 알 수 있다.
    (Opacity 값이 1일 때 InScattering 이 pow(saturate(dot(L, -V)), 12) * 0.1 이 되기 때문)
  • Subsurface Color의 밝기는 스캐터링 강도에 영향을 미친다.
    Transmission 을 완전히 0으로 만들고 싶다면 (Scattering을 완전히 없애고 싶다면) 해당 부분의 Subsurface Color 가 0.0 이어야 한다.
  • 셰이딩 모델을 사용하는 입장에서는 Opacity = 1 - Scattering 강도 라고 생각하기 쉬운데, 위 두 가지 사항을 때문에 조금 혼란스러울 수 있을 듯 하다..