티스토리 뷰
이번 시간에는 UE5의 차세대 머티리얼 표현 방식인 Substrate에 대해 알아보겠습니다.
Substrate는 UE5.2에서 Experimental 기능으로 본격적으로 공개되었으며, UE5.5에서는 Beta 단계로 전환되었습니다.
이후 UE5.7에서 Production Ready 단계에 도달해 새로 생성하는 프로젝트에서 Substrate가 기본적으로 활성화됩니다.
1. 언리얼에 Substrate를 도입한 이유
왜 Substrate를 도입했는지 이해하려면 일단 기존에 Material 시스템이 재질을 어떤 식으로 표현했는지 알아야 합니다.
기본 Material에는 Shading Model이라는 개념이 있습니다.
Shading Model은 재질이 빛에 반응하는 방식을 정의한 모델로, Default Lit, Unlit, Hair, Subsurface 등 여러 종류가 미리 정의되어 있습니다.
어떤 Shading Model을 선택하느냐에 따라 사용할 수 있는 Material Input이 달라지고, 엔진 내부에서 수행되는 라이팅 계산 방식도 달라집니다.
그 결과 같은 조명 환경에서도 재질마다 서로 다른 시각적 특성을 표현할 수 있습니다.
Shading Model마다 라이팅 계산 방식이 다른 이유는 실제 세계에서도 재질에 따라 빛이 반사되고, 투과되고, 산란되는 방식이 서로 다르기 때문입니다.
기존 Material 시스템은 이러한 재질별 특성을 각각의 Shading Model과 그에 대응하는 라이팅 계산식으로 표현합니다.
아래 이미지는 Shading Model 별로 라이팅을 계산하는 방식을 도식화한 것입니다.
Default Lit의 경우 반사광을 계산하고, Hair는 반사광 뿐만 아니라, 투사광도 계산합니다.
Subsurface에서는 내부에서 산란하고 다시 밖으로 나오는 Subsurface Scattering도 계산합니다.

하지만 이러한 방식만으로 세상에 존재하는 다양한 표면을 표현하기에는 한계가 있습니다.
대표적인 한계는 서로 다른 특성을 가진 여러 재질이 하나의 표면에 겹쳐 있는 모습을 자유롭게 표현하기 어렵다는 것입니다.
Substrate를 설명할 때 자주 등장하는 예시가 바로 "자동차의 도장 표면"입니다.

자동차의 표면은 단순한 금속 하나로 이루어진 것이 아니라, 금속성 도장층 위에 투명한 코팅층이 있습니다.
기존 Material 시스템에도 Clear Coat라는 Shading Model이 있기 때문에 이러한 표면을 어느 정도 표현할 수 있습니다.
하지만 Clear Coat는 기본 재질 위에 얇은 투명 코팅층이 존재하는 구조를 표현하기 위해 미리 설계된 Shading Model입니다.
따라서 이와 조금 다른 구조의 복합 재질을 만들고 싶다면, 기존의 고정된 Shading Model만으로는 표현에 한계가 생깁니다.
또 다른 예로 "흙이 묻은 피부"를 생각해 볼 수 있습니다.

흙은 일반적인 불투명 표면의 특성에 가깝고, 피부는 빛이 표면 아래로 들어가 산란되는 Subsurface 특성을 가집니다.
기존 Material 시스템에서는 이러한 서로 다른 재질의 광학적 특성을 하나의 표면 안에서 자유롭게 조합하고 계층화하기가 어렵습니다.
물론 메시의 영역을 나눈 뒤 각각 다른 Material을 할당하는 방식으로 표현할 수도 있습니다.
하지만 이 경우 재질 표현을 바꾸기 위해 Mesh나 Material Slot 같은 리소스 구조까지 함께 수정해야 합니다.
Substrate의 핵심은 다양한 재질을 보다 일반화된 방식으로 표현하고 조합할 수 있도록 만든 것입니다.
예를 들어 흙이 묻은 피부나 녹슨 금속처럼 서로 다른 재질의 특성이 섞여 있는 표면도, 사용자가 각각의 특성을 정의하고 원하는 방식으로 조합하여 표현할 수 있습니다.
이를 위해 Substrate에서는 Slab이라는 개념을 사용합니다.
Slab은 하나의 물질을 표현하는 기본 단위이며, Material Graph에서 여러 개의 Slab을 다양한 Operator를 이용해 트리 구조로 조합할 수 있습니다.
기본적인 Slab은 크게 Interface와 Medium으로 구성됩니다.

Interface는 빛이 물질의 표면과 상호작용하는 방식을 표현하고, Medium은 빛이 물질 내부에서 산란되고, 투과되고, 흡수되는 방식을 표현합니다.
사용자는 이러한 특성을 조절하고 여러 Slab을 조합함으로써, 기존처럼 미리 정의된 하나의 Shading Model을 선택하는 방식에서 벗어나 보다 다양한 재질의 빛 반응을 구성할 수 있습니다.
2. UE5에서 Substrate를 사용하는 법
UE5에서 Substrate를 사용하려면 프로젝트 셋팅 -> 엔진 -> 렌더링 항목의 서브스트레이트 부분을 수정해줘야 합니다.
가장 먼저 "서브스트레이트 머터리얼"을 활성화 해줍니다.
그리고 "서브스트레이트 GBuffer 포맷"을 Adaptive GBuffer로 설정해줍니다.
Substrate에는 Adaptive GBuffer와 Blendable GBuffer라는 두 가지 GBuffer 포맷이 있는데 복잡한 Slab 구조를 활용한 고급 재질 표현을 사용하려면 Adaptive GBuffer를 사용하는 것이 좋습니다.

프로젝트 셋팅을 변경하고 나서는 Material Editor에서 Substrate 관련 노드를 추가하면 됩니다.
Substrate를 활용한 Material 예시입니다.



ClearCoat를 위쪽에 쌓고, 아래쪽에는 CheckerPattern으로 BaseCoat와 Flakes를 조합했습니다.
BaseCoat와 Flakes를 조합하는 부분은 Substrate Horizontal Blend 노드를 사용했으며, ClearCoat를 쌓는 부분은 Substrate Vertical Layer 노드를 활용했습니다.
각각의 계층은 Slab Node를 사용했습니다.
3개의 계층 중에서 Flakes의 Slab Node를 예시로 들어서 보여드리겠습니다.

이런 형태로 구성되어 있고, 필요한 파라미터만 변수로 만들어 노출했습니다.
MaterialInstance에서는 각 파라미터를 아래와 같이 셋팅했습니다.

3. Substrate의 코드 구현
Substrate 기능과 관련된 코드는 엔진 곳곳에 있지만, 이번 시간에는 간단하게 BasePass와 LightingPass에서 Substrate 분기가 어떻게 구현되었는지만 보도록 하겠습니다.
Substrate의 코드 구현을 알아보기에 앞서 기존 방식의 BasePass와 LightingPass를 간단하게 살펴보겠습니다.
기존 방식의 BasePass와 LightingPass
CPU에서는 이 흐름을 통해서 BasePass를 실행합니다.
DrawCall을 하는 부분은 아래와 같습니다.
FDeferredShadingRenderer::Render →
FDeferredShadingRenderer::RenderBasePass →
FDeferredShadingRenderer::RenderBasePassInternal →
GraphBuilder.AddDispatchPass를 통해 FParallelMeshDrawCommandPass::Dispatch →
TGraphTask<FDrawVisibleMeshCommandsAnyThreadTask>::CreateTask를 통해서 FDrawVisibleMeshCommandAnyThreadTask::DoTask →
FInstanceCullingContext::SubmitDrawCommand →
FMeshDrawCommand::SubmitDrawEnd →
RHICommandList의 DrawIndexedPrimitive
Shader를 선택하는 부분과 바인딩하는 부분은 따로 있습니다.
먼저 Mesh에 맞는 BasePass Shader를 선택하는 부분은 아래와 같습니다.
FBasePassMeshProcessor::Process →
GetBasePassShaders를 호출해서 Shader를 Lookup →
BuildMeshDrawCommands에서 PipelineStateObject(VS/PS/래스터라이저/블렌드/뎁스스텐실) 객체를 만들고 PipelineID로 해당 객체를 참조하는 테이블에 넣습니다.
Shader를 바인딩하는 부분은 아래와 같습니다.
FMeshDrawCommand:: SubmitDrawBegin →
MeshDrawCommand의 PipelineID로 PipelineStateObject를 찾아서 SetGraphicsPipelineStateCheckApply →
RHICommandList의 SetGraphicsPipelineState
GPU에서는 BasePassPixelShader.usf의 FPixelShaderInOut_MainPS 함수를 실행합니다.
함수 초반에 FMaterialPixelParameters 구조체와 FPixelMaterialInputs 구조체를 만듭니다.
FMaterialPixelParameters는 GetMaterialPixelParameters 함수를 통해서 값이 채워지는데, VF(VertexFactory) 마다 함수 내용이 다르게 정의되어 있기 때문에 채워지는 값은 VF 종류에 따라 다르며 VS에서 PS로 넘어간 보간 값입니다.
UV, Vertex Color 등 지오메트리에서 바로 나오는 값들을 채워줍니다.
GetMaterialPixelParameters 함수를 호출한 이후에 CalcMaterialParametersEx 함수를 통해서 View에 의존적인 나머지 값들도 채워줍니다.
FPixelMaterialInputs는 Material Graph에 의해서 출력된 값이 저장된다고 보면 됩니다.
CalcPixelMaterialInputs 함수를 통해서 값이 채워지는데, 해당 함수는 아래 처럼 토큰으로 구성되어 있고, 머터리얼 컴파일 타임 때 해당 토큰이 코드로 변환됩니다.
void CalcPixelMaterialInputs(in out FMaterialPixelParameters Parameters, in out FPixelMaterialInputs PixelMaterialInputs)
{
...
%{calc_pixel_material_inputs_initial_calculations}
...
%{calc_pixel_material_inputs_normal}
...
%{calc_pixel_material_inputs_other_inputs}
...
}
%{calc_pixel_material_inputs_other_inputs} 부분이 Material Graph에 설정한 구성이 만든 코드로 치환됩니다.
치환된 코드 내용은 대략
PixelMaterialInputs.BaseColor = MaterialFloat3(0.00000000f,0.00000000f,0.00000000f);
와 같은 내용으로 Material Node들을 HLSL 코드로 치환해서 PixelMaterialInputs 구조체에 값을 넘기는 코드입니다.
FMaterialPixelParameters(VS로부터 넘어온 값)과
FPixelMaterialInputs(Material Editor로부터 넘어온 값)이
전부 셋팅된 이후에는 GBuffer에 필요한 값들을 넘기는 작업을 하게 됩니다.
SetGBufferForShadingModel이라는 함수를 통해서 FGBufferData 구조체에 값을 채웁니다.
void SetGBufferForShadingModel(
in out FGBufferData GBuffer,
in out FMaterialPixelParameters MaterialParameters,
FPixelMaterialInputs PixelMaterialInputs,
const float Opacity,
const half3 BaseColor,
...
const uint ShadingModel)
{
GBuffer.WorldNormal = MaterialParameters.WorldNormal;
GBuffer.WorldTangent = MaterialParameters.WorldTangent;
GBuffer.BaseColor = BaseColor;
GBuffer.Metallic = Metallic;
...
if (ShadingModel == SHADINGMODELID_CLEAR_COAT)
{
float ClearCoat = saturate(GetMaterialCustomData0(PixelMaterialInputs));
float ClearCoatRoughness = saturate(GetMaterialCustomData1(PixelMaterialInputs));
GBuffer.CustomData.x = ClearCoat;
GBuffer.CustomData.y = ClearCoatRoughness;
}
else if ...
...
}
그 후 마지막 부분에서는 MRT(Multi Render Target)을 사용해서 FGBufferData 객체에서 값을 꺼내서 Out.MRT[0], Out.MRT[1], Out.MRT[2] 에 값을 넘겨서 Texture에 값을 씁니다.
float4 OutGBufferA = 0;
float4 OutGBufferB = 0;
float4 OutGBufferC = 0;
EncodeGBuffer(GBuffer, OutGBufferA, OutGBufferB, OutGBufferC, ...);
Out.MRT[1] = OutGBufferA;
Out.MRT[2] = OutGBufferB;
Out.MRT[3] = OutGBufferC;
FGBufferData 구조체에 있던 값을 RenderTarget으로 적절하게 Packing해서 저장하는 과정을 Encode라고 하고, 나중에 Texture로부터 값을 읽어서 FGBufferData 구조체에 값을 채우는 과정을 Decode라고 합니다.
EncodeGBuffer 함수는 아래와 같은 형태입니다.
void EncodeGBuffer(
FGBufferData GBuffer,
out float4 OutGBufferA,
out float4 OutGBufferB,
out float4 OutGBufferC,
...)
{
float3 Normal = GBuffer.WorldNormal;
uint NormalFace = 0;
EncodeNormal( Normal, NormalFace );
OutGBufferA.rg = Normal.xy;
OutGBufferA.b = 0;
OutGBufferA.a = GBuffer.PerObjectGBufferData;
OutGBufferB.r = GBuffer.Metallic;
OutGBufferB.g = GBuffer.Specular;
OutGBufferB.b = GBuffer.Roughness;
OutGBufferB.a = EncodeShadingModelIdAndSelectiveOutputMask(GBuffer.ShadingModelID, GBuffer.SelectiveOutputMask);
OutGBufferC.rgb = EncodeBaseColor( GBuffer.BaseColor );
...
}
이로써 BasePass는 끝이 나게 됩니다.
GBuffer에 담긴 값들을 읽어서 처리하는 부분은 대표적으로 LightingPass가 있습니다.
CPU는 이 흐름을 통해서 LightingPass 셰이더를 호출합니다.
FDeferredShadingRenderer::Render →
FDeferredShadingRenderer::RenderLights →
RenderLight →
FDeferredLightPS →
DeferredLightPixelShaders.usf의 DeferredLightPixelMain
GPU는 DeferredLightPixelMain 함수 안에서 다음과 같은 흐름을 통해 GBuffer 값을 가져옵니다.
DeferredLightPixelMain →
GetScreenSpaceData →
GetGBufferData →
DecodeGBufferDataUV
다만, DecodeGBufferDataUV 함수는 코드 상으로 정의되어 있지 않습니다.
그 이유는 DecodeGBufferDataUV 함수는 코드로 만드는 함수이기 때문입니다.
ShaderGenerationUtil.cpp 파일의 CreateGBufferDecodeFunctionVariation 함수에서 해당 함수를 만들어서 AutogenShaderHeaders.ush라는 파일에 써두면 필요한 곳에서 해당 파일을 include해서 사용합니다.
이렇게 따로 만드는 이유는 프로젝트 셋팅마다 GBuffer의 포맷이 달라질 수 있는데 이를 대응하기 위함입니다.
DecodeGBufferDataUV 함수와 해당 함수가 호출하는 DecodeGBufferDataDirect 함수는 대략 아래와 같이 생겼습니다.
// @param PixelPos relative to left top of the rendertarget (not viewport)
FGBufferData DecodeGBufferDataUV(float2 UV, bool bGetNormalizedNormal = true, bool bForceUnlitOrDefaultLit = false)
{
...
float4 InMRT1 = Texture2DSampleLevel(SceneTexturesStruct.GBufferATexture, SceneTexturesStruct_GBufferATextureSampler, UV, 0).xyzw;
float4 InMRT2 = Texture2DSampleLevel(SceneTexturesStruct.GBufferBTexture, SceneTexturesStruct_GBufferBTextureSampler, UV, 0).xyzw;
float4 InMRT3 = Texture2DSampleLevel(SceneTexturesStruct.GBufferCTexture, SceneTexturesStruct_GBufferCTextureSampler, UV, 0).xyzw;
float4 InMRT4 = Texture2DSampleLevel(SceneTexturesStruct.GBufferDTexture, SceneTexturesStruct_GBufferDTextureSampler, UV, 0).xyzw;
FGBufferData Ret = DecodeGBufferDataDirect(InMRT1,
InMRT2,
InMRT3,
InMRT4,
...);
return Ret;
}
FGBufferData DecodeGBufferDataDirect(float4 InMRT1,
float4 InMRT2,
float4 InMRT3,
float4 InMRT4,
...)
{
FGBufferData Ret = (FGBufferData)0;
...
WorldNormal_Compressed.x = InMRT1.x;
WorldNormal_Compressed.y = InMRT1.y;
WorldNormal_Compressed.z = InMRT1.z;
Ret.PerObjectGBufferData.x = InMRT1.w;
Ret.Metallic.x = InMRT2.x;
Ret.Specular.x = InMRT2.y;
Ret.Roughness.x = InMRT2.z;
Ret.ShadingModelID.x = (((uint((float(InMRT2.w) * 255.0f) + .5f) >> 0) & 0x0f) << 0);
Ret.SelectiveOutputMask.x = (((uint((float(InMRT2.w) * 255.0f) + .5f) >> 4) & 0x0f) << 0);
Ret.BaseColor.x = InMRT3.x;
Ret.BaseColor.y = InMRT3.y;
Ret.BaseColor.z = InMRT3.z;
Ret.GenericAO.x = InMRT3.w;
Ret.CustomData.x = InMRT4.x;
Ret.CustomData.y = InMRT4.y;
Ret.CustomData.z = InMRT4.z;
Ret.CustomData.w = InMRT4.w;
...
return Ret;
}
보시다시피 Texture에 써져 있는 값을 읽어서 리턴할 FGBufferData 구조체에 값을 채워 넣고 있습니다.
이렇게 구한 FGBufferData는 Lighting을 할 때 유용하게 사용됩니다.
ShadingModels.ush를 보면 IntegrateBxDF 함수가 있는데 대략 아래와 같습니다.
FDirectLighting IntegrateBxDF(FGBufferData GBuffer, half3 N, half3 V, FAreaLight AreaLight, FShadowTerms Shadow)
{
switch( GBuffer.ShadingModelID )
{
case SHADINGMODELID_DEFAULT_LIT:
return DefaultLitBxDF(GBuffer, N, V, AreaLight, Shadow);
case SHADINGMODELID_SUBSURFACE:
return SubsurfaceBxDF(GBuffer, N, V, AreaLight, Shadow);
...
case SHADINGMODELID_EYE:
return EyeBxDF(GBuffer, N, V, AreaLight, Shadow);
default:
return (FDirectLighting)0;
}
}
인자로 넘어온 GBuffer 값에서 ShadingModelID 값을 가져와 적절한 BxDF 함수를 고르고, GBuffer 값을 넘겨줘서 N과 V에 따라 Lighting을 계산할 수 있도록 합니다.
DefaultLitBxDF 등의 함수에서는 실제 라이팅 연산을 해서 픽셀의 Diffuse, Specular 등을 계산해서 FDirectLighting 구조체에 담아 리턴하고 이 구조체는 나중에 SceneColor에 값을 쓰는데 사용됩니다.
이렇게 LightingPass는 끝이 나게 됩니다.
Substrate의 BasePass와 LightingPass
본격적으로 Substrate의 동작 방식에 대해서 알아보기 전에 먼저 용어부터 알아봅시다.
Slab vs Closure
Slab은 Material Graph에서 하나의 BSDF 레이어를 뜻하는 노드입니다. 위 예제에서 본 그 Node입니다.
Closure는 그 Slab들이 Substrate Tree를 거쳐서 런타임에 실제로 평가 가능한 형태로 합쳐진 최종 셰이딩 단위입니다.
하나의 Slab은 하나의 Closure로 대응되기도 하지만 아니기도 합니다.
픽셀 당 Closure 개수가 Material이 Simple/Single/Comples로 분류되는 기준이 됩니다.
Adaptive GBuffer vs Blendable GBuffer
Adaptive GBuffer는 GBuffer의 각 픽셀에 할당된 데이터가 상황에 맞게 늘어나거나 줄어들 수 있는 형태입니다.
Blendable GBuffer는 GBuffer의 각 픽셀에 할당된 데이터가 고정된, 기존 방식과 비슷한 형태입니다.
Substrate의 모든 기능을 활용하려면 Adaptive GBuffer를 사용하는게 좋습니다.
다만, 추가적인 로직이 들어가기 때문에 비교적 무거운 연산이 들어가게 됩니다.
Evaluation Blending vs Parameter Blending
Evaluation Blending은 각 Slab을 따로 라이팅 계산한 뒤 그 셰이딩 결과를 블렌딩하는 방식입니다.
더 정확하지만 라이팅을 여러 번 평가해야 해서 비용이 더 듭니다.
Parameter Blending은 두 Slab의 파라미터(Albedo, Roughness, Normal 등)를 먼저 섞은 뒤 그 결과로 라이팅을 한 번만 계산하는 방식입니다.
비용이 비교적 저렴하지만 부정확할 수 있습니다.
이제 본격적으로 Substrate를 사용했을 때의 BasePass와 LightingPass에 대해서 알아보겠습니다.
Substrate의 BasePass 역시 기존의 BasePass처럼 Material Inputs을 받아서 GBuffer에 값을 채우는 역할을 합니다.
다만, Adaptive GBuffer를 사용하므로 한 픽셀에 여러 개의 Closure가 담겨 있을 수 있습니다.
또한 Material Editor에서 Slab을 Tree 구조로 설계할 수 있으므로 그 구조를 표현할 수 있어야 합니다.
Tree 구조를 어떻게 표현하는지 알아보기 위해 먼저 Material의 Slab 노드와 VerticalLayering 노드가 어떻게 표현되는지 알아보겠습니다.
FHLSLMaterialTranslator::SubstrateSlabBSDF 함수와
FHLSLMaterialTranslator::SubstrateVertialLayering 함수를 보면 HLSL로 번역되는 내용을 볼 수 있습니다.
return AddCodeChunk(
MCT_Substrate, TEXT("Parameters.%s.PromoteParameterBlendedBSDFToOperator(GetSubstrateSlabBSDF(Parameters.SubstratePixelFootprint,\n\
/*Normal*/ %s,\n\
/*DiffuseAlbedo*/ %s,\n\
/*F0*/ %s,\n\
..."),
*GetParametersSubstrateTreeName(CurrentSubstrateCompilationContext),
*SubstrateGetCastParameterCode(Normal, MCT_Float3),
*SubstrateGetCastParameterCode(DiffuseAlbedo, MCT_Float3),
*SubstrateGetCastParameterCode(F0, MCT_Float3),
...
);
return AddCodeChunk(
MCT_Substrate, TEXT("Parameters.%s.SubstrateVerticalLayering(%s, %s, %u, %u) /* Thickness = %s */"),
*GetParametersSubstrateTreeName(CurrentSubstrateCompilationContext),
*GetParameterCode(Top),
*GetParameterCode(Base),
OperatorIndex,
MaxDistanceFromLeaves,
*ThicknessCode
);
생성된 코드는 Legacy 방식에서 봤던 CalcPixelMaterialInputs 함수의 %{calc_pixel_material_inputs_other_inputs} 토큰을 치환하는 코드의 재료로 쓰이게 됩니다.
토큰 전체가 치환된 코드는 대략 아래와 같이 구성됩니다.
FSubstrateData Local3 = Parameters.SubstrateTree.PromoteParameterBlendedBSDFToOperator(GetSubstrateSlabBSDF(Parameters.SubstratePixelFootprint,
/*Normal*/ DERIV_BASE_VALUE(Local1),
/*DiffuseAlbedo*/ Material.PreshaderBuffer[3].xyz,
/*F0*/ Material.PreshaderBuffer[2].z,
...
Parameters.SharedLocalBases.Types)
/* Normal = DERIV_BASE_VALUE(Local1) ; Tangent = NONE ; Thickness = Material.PreshaderBuffer[2].x */, 1, 0, 0, 0);
FSubstrateData Local6 = Parameters.SubstrateTree.PromoteParameterBlendedBSDFToOperator(GetSubstrateSlabBSDF(Parameters.SubstratePixelFootprint,...);
...
FSubstrateData Local18 = Parameters.SubstrateTree.PromoteParameterBlendedBSDFToOperator(GetSubstrateSlabBSDF(Parameters.SubstratePixelFootprint,...);
...
FSubstrateData Local33 = Parameters.SubstrateTree.SubstrateHorizontalMixing(Local6, Local18, Local32, 2, 1);
FSubstrateData Local34 = Parameters.SubstrateTree.SubstrateVerticalLayering(Local3, Local33, 0, 2) /* Thickness = Material.PreshaderBuffer[2].x */;
...
PixelMaterialInputs.FrontMaterial = Local34;
코드상으로 Tree 구조가 생성된 것을 확인할 수 있습니다.
코드를 도식화하면 아래와 같은 형태로 볼 수 있습니다.

코드에 보이는 Parameters 변수는 Legacy 방식에서 봤던 FMaterialPixelParameters 구조체입니다.
내부적으로 FSubstrateTree 타입의 SubstrateTree 변수를 가지고 있습니다.
FSubstrateTree 구조체는 대략적으로 아래와 같이 구성되어 있습니다.
struct FSubstrateTree
{
int BSDFCount;
uint OperatorCount;
...
FSubstrateBSDF BSDFs[SUBSTRATE_MAX_CLOSURE_COUNT];
FSubstrateOperator Operators[SUBSTRATE_MAX_OPERATOR_COUNT];
...
FSubstrateData SubstrateHorizontalMixing(FSubstrateData Background, FSubstrateData Foreground, float Mix, int OperatorIndex, uint MaxDistanceFromLeaves);
FSubstrateData SubstrateVerticalLayering(FSubstrateData Top, FSubstrateData Base, int OperatorIndex, uint MaxDistanceFromLeaves);
FSubstrateData PromoteParameterBlendedBSDFToOperator(FSubstrateData SubstrateData, int OperatorIndex, int BSDFIndex, int LayerDepth, int bIsBottom);
};
위에서 사용한 PromoteParameterBlendedBSDFToOperator 함수나, SubstrateHorizontalMixing 함수는 대략적으로 아래와 같습니다.
FSubstrateData FSubstrateTree::PromoteParameterBlendedBSDFToOperator(FSubstrateData SubstrateData, int OperatorIndex, int BSDFIndex, int LayerDepth, int bIsBottom)
{
BSDFCount++;
OperatorCount++;
BSDFs[BSDFIndex] = SubstrateData.InlinedBSDF;
Operators[OperatorIndex] = GetInitialisedSubstrateOperator();
...
BSDFs[BSDFIndex].OperatorIndex = OperatorIndex;
...
return SubstrateData;
}
FSubstrateData FSubstrateTree::SubstrateHorizontalMixing(FSubstrateData Background, FSubstrateData Foreground, float Mix, int OperatorIndex, uint MaxDistanceFromLeaves)
{
...
FSubstrateData SubstrateData = GetInitialisedSubstrateData();
SubstrateData.OperatorIndex = OperatorIndex;
OperatorCount++;
Operators[OperatorIndex] = GetInitialisedSubstrateOperator();
Operators[OperatorIndex].MaxDistanceFromLeaves = MaxDistanceFromLeaves;
Operators[OperatorIndex].Type = SUBSTRATE_OPERATOR_HORIZONTAL;
Operators[OperatorIndex].Weight = Mix;
Operators[OperatorIndex].LeftIndex = Background.OperatorIndex;
Operators[OperatorIndex].RightIndex = Foreground.OperatorIndex;
Operators[Background.OperatorIndex].ParentIndex = OperatorIndex;
Operators[Foreground.OperatorIndex].ParentIndex = OperatorIndex;
return SubstrateData;
}
인자로 받은 값을 BSDFs 배열과 Operators 배열에 넘기는 작업을 하므로 Tree 구조를 배열에 담는 역할을 한다고 보시면 됩니다.
그 다음으로 해야 할 일은 이렇게 Tree 구조를 내포하는 배열 데이터를 Bit-Stream으로 Packing하여 LightingPass에 넘겨줄 수 있도록 하는 것입니다.
SubstrateExport.ush 파일의 PackSubstrateOut 함수를 사용합니다.
void PackSubstrateOut(
inout FRWSubstrateMaterialContainer SubstrateBuffer,
RWTexture2DArray<uint> ExtraMaterialDataUAV,
...
FSubstratePixelHeader SubstratePixelHeader,
FSubstrateData Substrate,
...
)
{
...
}
입력으로 받은 Operators 배열을 순회하여 Operator Type에 따라 필요한 정보를 뽑아내고 BSDFs 배열을 순회하며 BSDF 타입 별로 Buffer에 값을 채워 넣는 로직이 있는데 코드가 복잡하여 자세한 내용을 생략하였습니다.
결론적으로는 나중에 LightingPass에서 사용할 FRWSubstrateMaterialContainer와, RWTexture2DArray<uint> 타입의 Buffer에 값들을 저장합니다.
LightingPass에서는 Packing된 Bit-Stream 데이터를 읽어와서 원래의 의도대로 데이터를 해석해서 Lighting을 합니다.
Lighting 자체는 기존 방식이랑 비슷합니다.
다른 점이 있다면 Legacy 방식에서는 현재 Pixel의 ShadingModel에 따라 알맞는 BxDF를 구해서 바로 사용했다면,
Substrate에서는 한 픽셀에 여러 개의 Closure가 있을 수 있으므로 데이터를 Unpack하고 Closure를 순회해서 계산해서 BSDF를 계산하고 조합합니다.
실제 Lighting 루프는 SubstrateDeferredLighting.ush 파일의 SubstrateDeferredLighting 함수에 있습니다.
대략적으로 중요한 부분만 가져오면 아래와 같습니다.
FLighting SubstrateDeferredLighting(
FLight Light,
float3 V,
float3 L,
...
FSubstratePixelHeader Header,
FSubstrateMaterialContainer MaterialBuffer)
{
FSubstrateDeferredLighting Lighting = 0;
for (uint i = 0; i < Header.ClosureCount; ++i)
{
FSubstrateBSDF BSDF = Unpack(MaterialBuffer, i);
FSubstrateBSDFContext Context = CreateBSDFContext(BSDF, V, L);
FSubstrateEvaluateResult Result = EvaluateBSDF(Context, Light);
// Tree가 구워둔 합성 가중치를 가져옴
float3 Weight = LuminanceWeight(Context, BSDF);
Lighting = Accumulate(Lighting, Result * Weight, IsTopLayer(BSDF));
}
return Lighting;
}
FSubstrateMaterialContainer는 해당 픽셀의 Substrate 정보를 담은 bitstream입니다.
RWTexture2DArray<uint>로 define되어 있어서 실질적으로는 RWTexture2DArray<uint>로 볼 수 있습니다.
FSubstrateBSDF는 현재 Closure의 재질 정보이고, FSubstrateBSDFContext는 FSubstrateBSDF 외에 추가적으로 표면 자체의 정보를 담고 있는 구조체입니다.
Context와 Light 정보를 이용해서 현재 픽셀, 현재 Closure, 현재 Light에 대한 BSDF 결과를 담고 이것을 모든 Closure에 대해서 계산해서 합성하는 방식으로 Lighting이 진행되게 됩니다.
결론
지금까지 알아본 것과 같이 Substrate의 가장 큰 장점은 기존의 고정된 Shading Model 방식에서 벗어나, 여러 재질의 특성을 자유롭게 조합해 다양한 표면을 표현할 수 있다는 점입니다.
다만 새로운 개념이 많아 학습 난이도가 높고, 복잡하게 구성할수록 성능 비용도 커질 수 있습니다.
따라서 표현력과 최적화 사이의 균형을 고려해 사용하는 것이 중요합니다.
앞으로 Substrate가 더욱 안정화되고 최적화된다면, 적극적으로 사용해보는 것도 좋을 것 같습니다.
'프로그래밍 > Unreal' 카테고리의 다른 글
| Unreal을 이용한 CelShading (0) | 2020.10.02 |
|---|---|
| Unreal Blueprint를 이용해 직접 만든 DLL 사용하기 (0) | 2019.12.25 |
| 언리얼 HLSL을 이용한 Custom Shader 제작 (1/2) (1) | 2019.11.29 |
| Unreal4를 이용한 Camera 제어 무작정 시작하기 (0) | 2019.10.11 |
- Total
- Today
- Yesterday
- DirectX12
- opengl
- Unreal
- collision detection
- 조합
- RubiksCube
- 루빅스큐브
- 참조 형식
- Mesh
- 통계학
- 경우의 수
- 중복 순열
- 수학
- 최적화
- RL
- Mesh Processing
- 값 형식
- MeshProcessing
- perspective projection
- 중복 조합
- 유니티
- Unity
- 순열
- normalized device coordinate
- VTK
- C#
- CollisionDetection
- value type
- Scriptable Render Pipeline
- AABB
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |