저자: Jason Ma
Substrate는 UE의 차세대 머티리얼 프레임워크다. 기존의 고정 셰이딩 모델 방식에서 벗어나 BSDF를 유연하게 조합하는 구조로 바뀌면서, 훨씬 다양하고 복잡한 머티리얼 표현이 가능해졌다. 덕분에 게임 프로젝트의 성능 요구와 영상 프로젝트의 품질 요구를 모두 만족시킬 수 있다.
UE 5.7부터 새로 만드는 프로젝트에는 Substrate가 기본으로 활성화되며, 기존 프로젝트는 Substrate로 단방향 업그레이드를 선택할 수 있다.
콘텐츠 예제 프로젝트: https://www.fab.com/listings/4d251261-d98c-48e2-baee-8f4e47c67091
⚠️ 참고: 현재 플랫폼에서는 이미지와 서식이 온전하지 않으므로 원문을 함께 보는 것을 권장한다: https://elysium.jason-ma.com/TA-UE-5-8-Substrate-39b92a22c0c28105aca5f7e2e24b2c87
전체 파이프라인 비교
기존 머티리얼 파이프라인

Substrate 머티리얼 파이프라인


핵심 용어
| 용어 | 의미 |
| BSDF | Bidirectional Scattering Distribution Function(양방향 산란 분포 함수). 특정 각도 조합에서 입사 광 에너지와 출사 광 에너지의 비율을 정의하며, 특정 유형의 머티리얼을 셰이딩하는 핵심 함수다. Substrate는 Slab, Toon, Eye, Hair 등 여러 BSDF를 제공한다. |
| Slab | 기하학적으로는 두 평행 평면 사이의 부피, 즉 '판'을 뜻한다. Substrate의 Slab은 기본 BSDF이자 머티리얼의 기본 구성 블록으로, **계면(인터페이스)**과 매질로 이루어진다. 계면은 빛이 머티리얼 표면과 상호작용하는 경계로 러프니스, 노멀, 디퓨즈 알베도, F0, F90 값으로 정의된다. 매질은 계면 아래에서 빛이 산란·투과·흡수되는 물질의 부피로, 평균 자유 행로와 디퓨즈 알베도로 정의된다. Slab의 물리적 정의 덕분에 다층 산란과 투과를 정확히 계산할 수 있고, 이것이 한 픽셀에서 여러 BSDF를 혼합할 수 있는 토대가 된다. 그래서 Slab은 Substrate에서 유일하게 혼합을 지원하는 BSDF 타입이며, 나머지 BSDF는 각자 전용 셰이딩 모델로서 픽셀 전체를 독점하고 혼합할 수 없다. |
| Operator | Slab 사이를 어떻게 혼합할지 기술한다. Weight(마스크), Vertical(수직 적층), Horizontal(수평 배치), Add(가산)의 4종이 있다. |
| BSDF 트리 | 여러 Slab을 리프 노드로, Operator를 내부 노드로 갖는 이진 트리. Substrate 머티리얼의 런타임 표현이며, 한 픽셀에서 다중 BSDF 레이어링을 가능하게 한다. |
| Closure | 하나의 BSDF의 '가시 인스턴스'. 트리 하나에 여러 BSDF가 들어 있을 수 있지만, Operator의 Coverage 계산을 거친 뒤 픽셀별로 실제로 보이는 BSDF의 부분집합을 Closure라고 부른다. |
| Coverage | 커버리지는 머티리얼의 존재를 정의한다. 머티리얼이 어디에, 얼마나 존재하는지를 정하는 0~1 범위의 '마스크'라고 이해하면 된다. 커버리지를 조절하는 것만으로 머티리얼을 혼합할 수 있다. Substrate에서 커버리지는 Coverage Weight 노드로 제어한다. |
| Throughput | 투과율은 빛이 머티리얼과 상호작용하는 방식, 즉 얼마나 많은 빛이 머티리얼을 통과하는지를 정의한다. Substrate에서 투과율은 Slab의 평균 자유 행로(MeanFreePath) 입력으로 제어한다. 평균 자유 행로(MFP)는 빛이 물질과 상호작용하는 평균 거리를 뜻한다. |
Shading Model에서 BSDF로
Substrate를 활성화하면 기존 머티리얼 루트 노드에 Front Material 핀이 하나 추가되고, 여기에 BSDF를 연결하는 순간 Substrate 머티리얼로 업그레이드된다.


가장 중요한 변화는 Metallic이 F0과 F90으로 대체되었다는 점이다.
F0: 빛이 표면에 수직으로 입사할 때의 스페큘러 반사 색. 절연체는 보통 0.02~0.08, 보석은 최대 약 0.16, 도체는 보통 0.5~1.0이다. UE의 기존 Metallic 워크플로에서 F0은 다음과 같이 계산됐다:
SpecularColor = lerp(0.08 * Specular, BaseColor, Metallic)
Specular 기본값이 0.5이고 비금속의 Specular Color 상한이 0.08이다 보니, 보석이나 유리처럼 강한 반사가 필요한 비금속 머티리얼을 만들 때 하이라이트가 부족한 문제를 자주 겪었다.
반면 Substrate는 F0을 임의의 색으로 설정할 수 있어서 표현 가능한 머티리얼의 범위가 기존 Metallic 워크플로를 크게 뛰어넘는다. 금속이든 플라스틱이든 보석이든 F0 범위만 조절하면 구현할 수 있다.
F90: 빛이 표면과 거의 평행하게(그레이징 각으로) 입사할 때의 스페큘러 반사 색. 밝기는 1.0으로 고정되며(물리적으로 모든 머티리얼의 그레이징 각 반사율은 1에 수렴한다) 색조와 채도만 조절할 수 있다. 기존 Metallic 워크플로에서는 아예 조절이 불가능했다.
Substrate는 F90에도 임의의 색을 입력할 수 있으며, 최종적으로 가장자리 스페큘러 색의 색조와 채도에 미세한 영향을 준다.
워크플로를 새로 짜는 동시에, Substrate는 기존 워크플로와의 호환을 위한 일련의 보조 함수도 함께 제공한다.
셰이더 데이터 구조
기존 GBuffer 데이터 구조
// 기존 방식: 픽셀 하나 = ShadingModel 하나 선택 + 고정 파라미터 슬롯 채우기
struct FGBufferData {
float3 WorldNormal; float PerObjectShadow;
float3 BaseColor; float Metallic;
float Specular; float Roughness;
float ShadingModel; // 4bit 열거형: DEFAULT_LIT/CLEAR_COAT/HAIR/EYE/...
float GenericAO;
// 문제: Hair 모드는 Metallic 필드를 Scatter로, Eye 모드는 CustomData를 IrisMask로 재사용
// 같은 GBuffer라도 ShadingModel에 따라 필드의 의미가 완전히 달라진다
};
Substrate: BSDF 트리
// Substrate: 픽셀 하나 = BSDF 트리 한 그루 (BSDF 리프 + Operator 노드)
// 모든 BSDF 타입이 같은 구조체를 공유한다
struct FSubstrateBSDF {
// ─── 식별 및 트리 전파 메타데이터 ───
uint State; // 자기 서술형 비트필드:
// [0:0] Anisotropy
// [1:1] IsTopLayer
// [2:4] SSSType (3bit)
// [6:6] HasF90
// [7:7] HasFuzz
// [9:9] HasGlint
// [10:12] BSDF_Type (3bit: Slab=0/Hair=3/Eye=5/Toon=6...)
// ...그 외 플래그 비트들
float3 LuminanceWeightV; // 시선 방향 휘도 가중치 (트리를 위에서 아래로 전파한 뒤 기록)
float CoverageAboveAlongN; // 위쪽 물질의 노멀 방향 누적 커버리지
float3 TransmittanceAboveAlongN; // 위쪽 물질의 노멀 방향 누적 투과율
int bIsBottom; // 플래그: 머티리얼 계층 구조의 최하단인지
int bIsTop; // 플래그: 최상단인지
// ─── 인라인 셰이딩 데이터 ───
float Coverage; // 현재 픽셀에서 보이는 물질의 커버리지 [0,1]
float3 Emissive; // 자발광 (모든 BSDF 타입 공통)
float ThicknessCm; // 두께(cm), SSS/투과에 사용
float TopLayerDataWeight; // TopLayerTexture(SSR/SSAO용)에 대한 기여 가중치
// ─── 범용 파라미터 슬롯 ───
float4 VGPRs[7]; // BSDF 타입에 따라 다르게 해석:
// Slab: [0]=Diffuse.rgb + Roughness
// [1]=F0.rgb + Anisotropy
// [2]=F90.rgb + FuzzRoughness
// [3]=SSSMFP.rgb + SSSPhaseAnisotropy
// ...
// Hair: [0]=BaseColor, [1]=Specular+Scatter+Roughness+Backlit
// Eye: [0]=Diffuse+F0+..., [2]=IrisMask+IrisDist+IrisNormal
// Toon: [0]=BaseColor+ProfileId, [1]=Metallic+Specular+Roughness
// [2]=PatternUVs+Anisotropy
};
// 트리의 최상위 컨테이너
struct FSubstrateData {
FSubstrateBSDF InlinedBSDF; // 단순 머티리얼(단일 BSDF)은 이 필드로 바로 업로드
int OperatorIndex; // 복잡 머티리얼(다중 BSDF)은 Operator 트리의 루트를 가리킴
};
// 트리 전체의 셰이더 레지스터 상 런타임 표현
struct FSubstrateTree {
FSubstrateBSDF BSDFs[8]; // 리프 노드 최대 8개
FSubstrateOperator Operators[15]; // 내부 노드 최대 15개
int BSDFCount;
uint OperatorCount;
};
디퍼드 렌더링 전체 흐름
Base Pass MRT
Substrate BasePass는 기존 GBuffer를 대체하는 4개의 MRT를 출력한다:
| MRT | 내용 | 포맷 |
| MRT 0~2 | MaterialTextureArray Slice 0~2 | PF_R32_UINT (3×32bit = 96bit) |
| MRT 3 | TopLayerTexture | PF_R32_UINT(기본) 또는 PF_R32G32_UINT(고정밀 노멀) |
96bit를 넘어가는 복잡한 머티리얼 데이터(다중 BSDF, Glint 등)는 UAV 오버플로를 통해 Texture2DArray의 상위 Slice에 기록된다.
TopLayerTexture에는 픽셀 최상층의 노멀(22bit), 러프니스(8bit)와 머티리얼의 타입(2bit, NONE/VALID/SLWATER)이 1~2개의 uint에 패킹되어 저장된다. SSR, SSAO, 컨택트 섀도, Lumen처럼 전체 BSDF 트리가 필요 없는 패스가 이 RT 한 장만 읽어서 최상층의 노멀과 러프니스를 얻을 수 있게 하여, BSDF 트리 전체를 다시 디코딩하는 비용을 피하는 것이 목적이다.
Deferred Lighting이 읽는 입력:
- Substrate.Material(Texture2DArray, SRV) — 전체 머티리얼 데이터
- Substrate.TopLayer(Texture2D, SRV) — 최상층 노멀/러프니스
- Substrate.ClosureOffset(Texture2D, 선택적, 다중 Closure에서 Lumen 어드레싱용)
기존 방식과의 핵심 차이:
- 기존은 4개의 MRT(GBufferA/B/C/D)를 출력했고, Substrate는 4개 + 선택적 UAV 오버플로를 출력한다
- 기존 MRT는 각각 고정된 의미를 갖지만, Substrate의 MaterialTextureArray는 연속된 바이트 스트림이다
- TopLayerTexture는 완전히 새로운 요소다 — 기존 SSR/SSAO는 GBuffer에서 노멀을 언패킹해야 했지만, Substrate는 RT 한 장만 읽으면 된다.
Base Pass Packing
기존 방식: 고정 포맷 MRT
// 기존 BasePass 출력: 픽셀마다 고정된 4개의 RenderTarget, 포맷도 고정
// 머티리얼이 단순하든 복잡하든 무조건 128비트를 가득 채운다
// GBufferA: WorldNormal.xyz(24bit) + PerObjectShadow(8bit)
// GBufferB: BaseColor.rgb(24bit) + Metallic(8bit) + ShadingModel(4bit) + SelectiveOutputMask(4bit)
// GBufferC: GGXSpecular(8bit) + Roughness(8bit) + GenericAO(8bit) + IndirectIrradiance(8bit)
// GBufferD: CustomData(24bit) + ... (ShadingModel마다 다른 데이터 저장)
// 디퍼드 라이팅 단계의 읽기:
FGBufferData GBuffer = GetGBufferData(PixelPos); // 고정 오프셋으로 언패킹
switch (GBuffer.ShadingModelID) { ... } // ID에 따라 분기
문제: 단순 머티리얼은 대역폭을 낭비하고(128비트), 복잡한 머티리얼에는 오히려 모자란다.
Substrate 방식: 복잡도에 따른 동적 인코딩
Substrate는 PackSubstrateOut(Substrate.ush)에서 BSDF 개수에 따라 3가지 인코딩 경로로 나뉜다:
// === Layout 0: Simple 경로 (2 uint32 = 64비트) ===
// 적용 조건: 단일 Slab, 등방성, SSS/퍼즈/박막 간섭 없음
if (IsSimpleMaterial()) {
// DiffuseAlbedo + F0 + Roughness를 uint32 2개에 압축 패킹
PackDiffuseAlbedoF0Roughness(DiffuseAlbedo, F0, Roughness, Packed0, Packed1);
// [uint32 0]: Header(비트필드: SimpleLayout+BSDF 타입) | DiffuseAlbedo 일부
// [uint32 1]: F0 + Roughness
StoreUint1(Header | Packed0);
StoreUint1(Packed1);
// 총 크기: 2 uint32 = 64비트 (기존의 절반)
}
// === Layout 1: Single 경로 (3 uint32 = 96비트) ===
// 적용 조건: 단일 Slab이지만 부가 특성 보유 (SSS/Fuzz/ClearCoat/이방성)
else if (IsSingleMaterial()) {
PackSlabBSDF(DiffuseAlbedo, F0, F90, Roughness, SSSProfile, ..., Packed0, Packed1, Packed2);
StoreUint1(Header | Packed0);
StoreUint1(Packed1);
StoreUint1(Packed2); // 추가 데이터: SSS MFP, FuzzRoughness 또는 Haziness
// 총 크기: 3 uint32 = 96비트
}
// === Layout 2: Complex 경로 (N uint32, N은 BSDF 개수에 따라 증가) ===
// 적용 조건: 다중 BSDF / SpecialPath 머티리얼 (Glint 등)
else {
// 1. Header 기록: BSDFCount + 인코딩 레이아웃 기술 포함
StoreUint1(PackedHeader);
// 2. 공유 탄젠트 기저 기록 (가시 BSDF마다 셰이더에서 노멀/탄젠트가 다를 수 있음)
for (int i = 0; i < SharedBasisCount; i++)
StoreUint1(PackedBasis);
// 3. 가시 BSDF를 하나씩 압축 인코딩해 기록
for (int i = 0; i < VisibleBSDFCount; i++) {
PackSingleOrComplexPathSlabBSDF(BSDFs[i], Packed0, Packed1);
StoreUint1(Packed0);
StoreUint1(Packed1);
}
// 총 크기: 1(Header) + BasisCount + 2×BSDFCount = 최소 uint32 3개
}
// 추가 출력: TopLayerTexture — SSR/SSAO의 빠른 접근용
// 최상층 머티리얼의 노멀(22bit)+러프니스(8bit)+타입(2bit) 저장
// SSR 패스가 BSDF 트리 전체를 다시 디코딩하지 않아도 됨
TopLayerTexture[PixelPos] = PackTopLayerData(TopLayerNormal, TopLayerRoughness, BSDFType);
비교 요약:
| 항목 | 기존 | Substrate |
| 출력 포맷 | 고정 MRT 4개 | 동적 Texture2DArray + TopLayerTexture |
| 픽셀당 크기 | 고정 128비트 | 64비트(Simple) / 96비트(Single) / N×32비트(Complex) |
| 단순 머티리얼 | 대역폭 낭비 | 64비트만 사용 |
| 복잡 머티리얼 | 부족함 (ShadingModel 하나만 가능) | 적응형으로 증가 |
| SSR/SSAO | MRT 4개를 다시 읽음 | TopLayerTexture 한 장만 읽음 |
Deferred Lighting Draw Call
Deferred Lighting 단계는 모든 BSDF를 하나의 셰이더 배리언트에 담는 대신, TileType의 복잡도에 따라 4개의 셰이더 배리언트로 나눠 각각 Draw Call을 그린다.
TileType 등급(SubstrateDefinitions.h):
TileType 값 셰이더 매크로 Closure 수 포함되는 BSDF
| SIMPLE | 0 | SUBSTRATE_FASTPATH | 1 | 부가 특성 없는 단순 Slab |
| SINGLE | 1 | SUBSTRATE_SINGLEPATH | 1 | SSS/Fuzz/이방성이 있는 단일 Slab |
| COMPLEX | 2 | SUBSTRATE_COMPLEXPATH | 1~8 | 다중 Slab 혼합, Toon, Hair, Eye, Water |
| COMPLEX_SPECIAL | 3 | +SUBSTRATE_COMPLEXSPECIALPATH | 1~8 | Complex + Glint 글린트 |
컴파일 타임에 TileType별로 4개의 배리언트를 생성하고, 런타임에는 같은 TileType의 픽셀들을 타일 단위로 묶어 한 번에 그린다.
핵심 성능 차이: COMPLEX 이상(TileType ≥ 2)의 셰이더에서만 진짜 for 루프가 컴파일되어, 루프 안에서 Closure별 라이팅을 계산한다:
// SubstrateEvaluation.ush
#if SUBSTRATE_FASTPATH || SUBSTRATE_SINGLEPATH || ClosureCount <= 1
#define Substrate_for(X,Y,Z)X;if(Y)// ← 단일 if로 전개, 루프 오버헤드 없음
#else
#define Substrate_for(X,Y,Z)[loop]for(X;Y;Z)// ← COMPLEX 경로에서만 진짜 루프가 컴파일됨
#endif
즉 씬의 대부분을 차지하는 단순 Slab 머티리얼은 디퍼드 라이팅에서 루프 오버헤드 없이 단일 Closure만 처리한다.
프레임 캡처 분석: Mega Lights를 제외하면, Deferred Lighting에서 광원 하나당 최대 4번 그려지며 이는 4개의 TileType에 대응한다.
Directional Light는 Stencil Test로 TileType별 마스킹을 한다:




Local Light는 Mesh + Depth + Stencil Test로 해당 TileType의 픽셀만 그린다:


Deferred Lighting Unpacking
기존 방식: 고정 오프셋 + switch 분기
// 기존: GBuffer의 고정 슬롯에서 한 번에 언패킹
FGBufferData GBuffer = GetGBufferData(PixelPos); // 고정 오프셋
Lighting = 0;
switch (GBuffer.ShadingModelID) {
case SHADINGMODELID_DEFAULT_LIT: {
// GBuffer에서 파라미터를 바로 꺼낸다:
float3 DiffuseColor = GBuffer.BaseColor * (1 - GBuffer.Metallic);
float3 F0 = lerp(0.04, GBuffer.BaseColor, GBuffer.Metallic);
float Roughness = GBuffer.Roughness;
Lighting = DefaultLitBRDF(DiffuseColor, F0, Roughness, Light);
} break;
case SHADINGMODELID_HAIR: {
// Hair는 GBuffer 필드를 재사용하지만 의미가 다르다
float3 BaseColor = GBuffer.BaseColor; // 표준 용법
float Scatter = GBuffer.Metallic; // Metallic 필드가 Scatter로 재사용됨!
float Roughness = GBuffer.Roughness; // 표준 용법
Lighting = HairShading(BaseColor, Scatter, ...); // 독립된 셰이딩 함수
} break;
// ... 나머지 ShadingModel도 비슷
}
Substrate 방식: 바이트 스트림 디코딩 + for 루프로 Closure 순회
// === 1단계: Header를 읽고 인코딩 포맷 판별 ===
uint PixelHeader = MaterialBuffer[0]; // Texture2DArray에서 첫 uint32 읽기
uint HeaderLayout = ExtractLayout(PixelHeader); // Simple / Single / Complex?
uint ClosureCount = ExtractClosureCount(PixelHeader); // 가시 BSDF 개수
// === 2단계: Layout에 따라 분기 디코딩 ===
if (HeaderLayout == SUBSTRATE_LAYOUT_SIMPLE) {
// 빠른 경로: Header에서 시프트/마스크만으로 Diffuse/F0/Roughness 언패킹
BSDF.DiffuseColor = UnpackR8G8B8(PixelHeader >> 8);
BSDF.F0 = UnpackR8G8B8(PackedData1);
BSDF.Roughness = UnpackR8G8B8(PackedData1) >> 24;
} else if (HeaderLayout == SUBSTRATE_LAYOUT_SINGLE) {
// uint32 3개에서 완전한 Slab BSDF 언패킹 (SSS/Fuzz 파라미터 포함 가능)
BSDF = UnpackSingleBSDF(MaterialBuffer, PixelHeader);
} else { // COMPLEX
// 가변 길이 바이트 스트림에서 언패킹: 먼저 공유 탄젠트 기저, 그다음 BSDF를 하나씩
ReadOffset = 1; // Header 건너뛰기
for (int i = 0; i < SharedBasisCount; i++)
TangentBasis[i] = UnpackBasis(MaterialBuffer[ReadOffset++]);
for (int i = 0; i < VisibleBSDFCount; i++) {
BSDFs[i] = UnpackComplexBSDF(MaterialBuffer, ReadOffset);
ReadOffset += 2; // BSDF 하나당 uint32 2개
}
}
// === 3단계: Closure를 하나씩 순회하며 평가 ===
Lighting = 0;
for (int ClosureIdx = 0; ClosureIdx < ClosureCount; ClosureIdx++) {
BSDF = GetBSDF(ClosureIdx);
FSubstrateBSDFContext Ctx = CreateBSDFContext(BSDF, TangentBasis[ClosureIdx], V, L);
// 통합 진입점, 내부에서 SLAB/HAIR/EYE 등으로 분기
FSubstrateEvaluateResult Result = SubstrateEvaluateBSDF(Ctx, Settings);
Lighting += Result.DiffusePath * BSDF.LuminanceWeightV * LightColor * Shadow;
Lighting += Result.SpecularPath * BSDF.LuminanceWeightV * LightColor * Shadow;
}
디코딩 비교:
| 항목 | 기존 | Substrate |
| 읽기 방식 | 고정 오프셋 | 바이트 스트림 (Header를 먼저 읽어 레이아웃 판별) |
| BSDF 개수 | 항상 1개 | 1~8개, for 루프로 순회 |
| 단순 경로 | 복잡한 머티리얼과 같은 MRT 4개 사용 | Simple Layout 빠른 경로는 uint 2개만 사용 |
| 파라미터 출처 | GBuffer 슬롯 (ShadingModel별 의미로 해석) | BSDF.State 비트필드 자기 서술 + VGPR 배열 |
| Hair 처리 | 독립된 switch 분기 | for 루프 안에서 Hair 분기로 통합 디스패치 |
셰이딩 구현 디테일
기존 Default Lit BxDF
// 기존 DefaultLit의 디퍼드 라이팅
float3 DefaultLitBxDF(FGBufferData GBuffer, FLightData Light, float3 V, float3 L, float3 N) {
float3 DiffuseColor = GBuffer.BaseColor * (1.0 - GBuffer.Metallic);
float3 F0 = lerp(DielectricSpecularToF0(GBuffer.Specular), GBuffer.BaseColor, GBuffer.Metallic);
float Roughness = GBuffer.Roughness;
// 디퓨즈: Lambert
float3 Diffuse = DiffuseColor * saturate(dot(N, L));
// 스페큘러: GGX 마이크로패싯 + Schlick Fresnel
float NoH = saturate(dot(N, normalize(L + V)));
float D = D_GGX(Roughness * Roughness, NoH);
float Vis = Vis_SmithJointApprox(dot(N, L), dot(N, V), Roughness * Roughness);
float3 F = F_Schlick(F0, saturate(dot(V, normalize(L + V))));
return Diffuse + (D * Vis * F);
}
Substrate Slab의 BSDF
아래 의사 코드는 SubstrateEvaluateBSDFCommon의 SUBSTRATE_BSDF_TYPE_SLAB 분기에 해당한다:
// Substrate Slab의 디퍼드 라이팅 (통합 Dispatch 진입점, SubstrateEvaluation.ush)
FSubstrateEvaluateResult SlabEvaluateBSDF(
FSubstrateBSDF Slab, float3 V, float3 L, float3 N, float3 LightColor, float Shadow) {
// 1. BSDF의 VGPR 압축 배열에서 모든 물리 파라미터 언패킹
float3 DiffuseAlbedo = Slab.VGPRs[0].xyz; // 디퓨즈 색
float Roughness = Slab.VGPRs[0].w; // 러프니스
float3 F0 = Slab.VGPRs[1].xyz; // 수직 입사 반사율
float3 F90 = Slab.VGPRs[2].xyz; // 그레이징 각 반사율
// 2. 마이크로 오클루전 보정 (F0이 0인 픽셀의 스페큘러 노이즈 방지)
F90 *= F0RGBToMicroOcclusion(F0);
// === 디퓨즈 ===
float3 DiffuseTerm;
if (Slab이 RoughDiffuse 모드를 사용) {
// 거친 표면 디퓨즈 (마이크로패싯 상호 반사 고려)
DiffuseTerm = Diffuse_GGX_Rough(DiffuseAlbedo, Roughness, dot(N,L), dot(N,V));
} else {
// 표준 Lambert
DiffuseTerm = Diffuse_Lambert(DiffuseAlbedo) * saturate(dot(N, L));
}
// === 스페큘러: GGX 마이크로패싯 + 에너지 보존 ===
// 다중 산란 에너지 보상 (기존 Schlick 한 번보다 정확한 에너지 보존)
GGXEnergyTerms Energy = ComputeGGXSpecEnergyTerms(Roughness, dot(N,V), F0, F90);
float3 MultiScatterComp = ComputeEnergyConservation(Energy); // 손실된 에너지 보상
float HalfNoL = saturate(dot(normalize(L + V), N)); // NoH
float D, Vis;
if (Slab에 이방성이 있음) {
D = D_GGX_Aniso(AlphaX, AlphaY, HalfNoL, ...);
Vis = Vis_GGX_Aniso(dot(N,L), dot(N,V), AlphaX, AlphaY);
} else {
D = D_GGX(Roughness*Roughness, HalfNoL);
Vis = Vis_SmithJointApprox(dot(N,L), dot(N,V), Roughness*Roughness);
}
float3 Fresnel = F_Schlick(F0, F90, saturate(dot(V, normalize(L+V)))); // 고정값 1.0 대신 독립적인 F90 사용
float3 SpecularTerm = (D * Vis * Fresnel) * MultiScatterComp;
// === State 비트필드로 활성화되는 선택적 특성 ===
if (Slab에 SSS가 활성화됨) {
// 서브서피스 스캐터링: DiffusionProfile 또는 WrapLighting
DiffuseTerm *= EvaluateSSS(Slab.SSSProfile, Slab.SSSMFP, dot(N,L));
}
if (Slab에 Fuzz가 활성화됨) {
// 퍼즈/천 반사 (Kelemen/Sadeghi 모델 기반)
float3 FuzzTerm = Sheen_BRDF(Slab.FuzzColor, Slab.FuzzRoughness, dot(N,L), dot(N,V), HalfNoL);
SpecularTerm += FuzzTerm;
}
if (Slab에 ThinFilm이 활성화됨) {
// 박막 간섭: F0/F90에 간섭 색을 중첩
SubstrateGetThinFilmF0F90(dot(N,V), Slab.ThinFilmThickness, Slab.ThinFilmIOR, F0, F90);
}
return {
DiffuseTerm * LightColor * Shadow, // DiffusePathValue
SpecularTerm * LightColor * Shadow, // SpecularPathValue
0, // ThroughputV(기본 Slab 불투명)
0 // TransmittanceAlongN
};
}
다른 BSDF도 같은 switch 디스패치를 탄다:
SubstrateEvaluation.ush, SubstrateEvaluateBSDFCommon()
switch (BSDF_GETTYPE(BSDF)) {
case SUBSTRATE_BSDF_TYPE_SLAB: → 앞서 상세히 기술함
case SUBSTRATE_BSDF_TYPE_HAIR: → Hair BSDF
case SUBSTRATE_BSDF_TYPE_EYE: → Eye BSDF
case SUBSTRATE_BSDF_TYPE_TOON: → SubstrateToonEvaluateCommon
case SUBSTRATE_BSDF_TYPE_SINGLELAYERWATER: → 주석 처리됨, 전용 패스
}
구현 비교:
| 항목 | 기존 DefaultLit | Substrate Slab |
| F0 | lerp(0.04, BaseColor, Metallic)(Metallic에 의해 고정됨) | 독립 RGB 값(임의 색) |
| F90 | 고정 1.0 | 독립 RGB 값, 색조·채도 조절 가능 |
| 에너지 보존 | 단순 한 번의 Schlick | 다중 산란 보상 + GGX 에너지 항 |
| 디퓨즈 | Lambert만 | Lambert / RoughDiffuse 선택 가능 |
| 이방성 | 미지원 | 지원(Aniso GGX) |
| 선택적 특성 | 없음(ClearCoat는 다른 ShadingModel) | SSS / Fuzz / ThinFilm / Glint를 비트필드로 활성화 |
| 파라미터 저장 | GBuffer의 여러 MRT에 패킹 | VGPR 배열에 패킹 |
Slab 혼합의 알고리즘 디테일
한 픽셀은 여러 Slab을 지원하고, Slab 사이는 여러 Operator로 혼합하면서 에너지 보존을 유지한다.
예를 들어 상층은 SSS가 있는 플라스틱, 하층은 금속이다. 수직 적층으로 상층의 두께를 조절해 플라스틱의 불투명도를 제어할 수 있다.



기존 머티리얼은 금속을 혼합할 때 밝은 가장자리 결함이 생긴다:

수평 혼합을 쓰면 금속과 비금속을 혼합할 때 가장자리 결함이 생기지 않는다:


수평 혼합은 기존 머티리얼의 파라미터 혼합이나 반투명 혼합과 더 비슷하고, 수직 적층은 물리적으로 진짜 두 층을 쌓아 빛과 정확히 상호작용한다:


Weight
Weight는 두 층을 혼합하지 않고, 단일 마스크 값으로 자식 노드의 가시 범위를 축소한다. 에너지 보존: Coverage는 등비로 축소되고, ThroughputV는 그대로 전달된다.
this.Coverage = saturate(Weight) * LeftChild.Coverage;
this.ThroughputV = LeftChild.ThroughputV;
Horizontal
두 자식 노드를 Mask로 병렬 배치한다. 화면 공간에서 비율에 맞춰 두 가지 머티리얼을 나란히 섞는 것과 비슷하다. 에너지 보존: Coverage는 가중합으로 계산되어 ≤1을 보장하고, ThroughputV는 단순 lerp가 아니라 Coverage 가중 평균이다 — Coverage가 큰 층이 투과율을 주도한다.
float LeftRatio = 1.0 - Mask;
float RightRatio = Mask;
this.Coverage = LeftRatio * LeftChild.Coverage + RightRatio * RightChild.Coverage;
this.ThroughputV = (LeftRatio * Coverage_L * ThroughputV_L
+ RightRatio * Coverage_R * ThroughputV_R)
/ max(0.0001, this.Coverage); // ← Coverage 가중, 단순 lerp 아님!
Add
두 층을 직접 가산하고, 사용자 가중치는 없다. 에너지 보존: Coverage는 직접 가산되지만 상한은 1(오버플로 방지); ThroughputV는 좌우 자식의 Coverage 비율로 lerp한다 — 면적 비율이 큰 층이 투과율을 주도한다.
float LeftProp = Coverage_L / max(0.0001, Coverage_L + Coverage_R);
this.Coverage = saturate(Coverage_L + Coverage_R); // 가산, 상한=1
this.ThroughputV = lerp(ThroughputV_R, ThroughputV_L, LeftProp);
// 파라미터: 진폭량(Diffuse/F0)은 saturate(A+B), 방향량(Roughness)은 lerp(A,B,LeftProp)
Vertical
가장 복잡한 혼합 모드. 빛이 두 층 머티리얼을 관통하는 것을 시뮬레이션한다(예: 클리어코트→금속). 에너지 보존: Coverage는 세 개의 독립적인 가시 영역(상층만/하층만/양층 중첩)으로 분할; ThroughputV는 세 영역의 기여 가중합이다 — 양층 중첩 영역에서 빛은 두 층을 모두 통과하므로 두 ThroughputV를 곱한다.
float TopOnly = (1 - Coverage_B) * Coverage_T;
float BotOnly = (1 - Coverage_T) * Coverage_B;
float Both = Coverage_T * Coverage_B;
this.Coverage = Coverage_T + (1 - Coverage_T) * Coverage_B; // = TopOnly+BotOnly+Both
this.ThroughputV = TopOnly * ThroughputV_T
+ BotOnly * ThroughputV_B
+ Both * ThroughputV_T * ThroughputV_B; // 두 층을 관통, 에너지 승법 감쇠
Coverage와 Throughput의 차이
네 가지 Operator가 모두 같은 일을 한다: 두 자식의 Coverage와 ThroughputV를 서로 다른 규칙으로 하나로 병합한다. 하지만 이 다른 병합 규칙이 완전히 다른 의미를 만든다:
| Operator | Coverage 규칙 | ThroughputV 규칙 | 의미 |
| Weight | C = Weight × C_child | 불변 | 단일 층의 투명도 스케일링 |
| Horizontal | C = lerp(C_L, C_R, Mask) | Coverage 가중 평균 | 화면 공간에서 두 머티리얼이 나란히 공존, Mask로 픽셀 귀속 결정 |
| Add | C = saturate(C_L + C_R) | Coverage 비율 lerp | 두 머티리얼이 완전히 중첩·가산 |
| Vertical | C = C_T + (1-C_T) × C_B | 세 영역 가중(양층 관통 시 승법 감쇠) | 머티리얼이 깊이 방향으로 적층, 상층 Coverage가 하층이 얼마나 비치는지 결정 |
핵심은 ThroughputV의 전파 방식이다:
- Horizontal/Add: ThroughputV는 혼합/가중이다 — 빛은 "한 층"만 통과하고, A나 B 혹은 그 평균을 선택한다.
- Vertical: ThroughputV는 양층 중첩 영역에서 승법(ThroughputV_T × ThroughputV_B)이다 — 빛은 상층을 먼저 통과하고, 다시 하층을 통과해야 하므로 에너지가 층마다 감쇠된다. 이는 공간에서의 앞뒤 관계(깊이 방향)를 고려하는 유일한 Operator다.
리프 BSDF의 Coverage는 어디서 오는가?
Coverage와 Throughput이 최종적으로 라이팅에 미치는 영향
핵심 채널은 LuminanceWeightV이다. 흐름은 간단하다:
- 초기화: 트리 평가 시 각 리프 BSDF의 LuminanceWeightV = BSDF.Coverage.
- Operator 감쇠: 위에서 아래로 순회할 때 세 Operator가 각각 하층 BSDF에 LuminanceWeightV *= 승법 감쇠를 가한다: Weight는 마스크 값을 곱하고, Horizontal은 분기 Mask를 곱하고, Vertical은 (1-TopCov) + TopCov × TopThroughput을 곱한다.
- 라이팅에 곱입: LuminanceWeight()는 2단계를 바탕으로 광원 방향의 투과율을 보정한다(비스듬한 빛은 상층을 통과하는 거리가 더 길다), 그리고 직접 CommonMultiplier = LightColor × LightMask × LuminanceWeight에 곱하고, 최종적으로 라이팅 += (Diffuse + Specular) × CommonMultiplier가 된다.
머티리얼 자동 단순화
Substrate는 플랫폼 능력에 따라 머티리얼 복잡도를 자동으로 낮춘다. 핵심 메커니즘은 두 층으로 나뉜다.
컴파일 타임: 세 단계 점진적 단순화
머티리얼의 Closure 수나 바이트 수가 플랫폼 제한을 초과하면, 컴파일러는 바로 에러를 내지 않고 점진적으로 기능을 제거한다:
- 깊이 우선 파라미터 혼합: 트리에서 Operator의 깊이 순으로 정렬해, 가장 깊은 것부터 ParameterBlending으로 표시한다——두 층의 자식 Slab 파라미터를 미리 lerp로 병합해, 최종 출력 BSDF 수를 줄인다
- 모든 BSDF 특성 비활성화: 그래도 부족하면 전체 BSDFFeatures를 비운다(SSS/Fuzz/Glint/ThinFilm을 끈다), 가장 단순한 Slab으로 퇴화
- 컴파일 실패: 두 단계를 모두 거쳐도 예산을 초과하면 → error
런타임: 순수 절단
셰이더 측에서는 한 줄의 min()만 실행한다:
// Substrate.ush
SubstrateTree.BSDFCount = min(SubstrateTree.BSDFCount, SUBSTRATE_CLAMPED_CLOSURE_COUNT);
LuminanceWeight나 Coverage 기반의 정렬/우선순위 선택은 없다——제한을 초과한 BSDF는 배열의 자연 순서대로 조용히 버려진다.
세 층 플랫폼 등급:
등급 조건 GBuffer Closure 수 특성
| 등급 | 조건 | GBuffer | Closure 수 | 특성 |
| 완전 | AdaptiveGBuffer 플랫폼 | 가변 Bitstream, ≤256B/px | 1~8 | 전부 |
| Blendable | 모바일 / Adaptive 미지원 플랫폼 | 고정 Legacy 호환, 20B/px | 강제 1 | Glint/SpecProfile 제거, 다중 Slab 혼합 비활성 |
| FULLYSIMPLIFIED | RT Payload / Lumen Card / Path Tracing | 최소화 | 강제 1 | BSDF 트리 순회 전체 생략, Glint를 F0/F90에 곱입 |
원문
(76 封私信 / 77 条消息) 面向TA的UE 5.8 Substrate架构深度解析 - 知乎
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] 5.7 ShortRangeAO (0) | 2026.07.16 |
|---|---|
| Expanse Volumetrics for Unreal Engine (0) | 2026.07.16 |
| [YOUTUBE] Profiling with Pirates: Performance Plundered from a Real Game | Unreal Fest Chicago 2026 (1) | 2026.07.15 |
| [YOUTUBE] Slang: Bridging Graphics and Machine Learning [HD] (0) | 2026.07.12 |
| [공유] 반다이남코 TrueHDRI 연구 (0) | 2026.07.09 |