Layer Stack Concept图层栈概念

NeoX's Multi-Layered PBR implements the Stack-Lit material model (derived from Frostbite's research). Each material is a stack of optical layers: a dielectric Coat layer (like clear lacquer), an absorbing Medium layer (like tinted resin), and the opaque Base material below. Light bounces between each interface according to the Fresnel equations.

NeoX 的多层 PBR 实现了 Stack-Lit 材质模型(源自 Frostbite 研究)。每个材质是一个光学层栈:电介质涂层(如清漆)、吸收性介质层(如有色树脂)和底部不透明基础材质。光线按菲涅尔方程在每个界面间反弹。

+==============================================================+ | Stack-Lit Layer Structure (1 coat = NB_COAT_LAYERS=1) | +==============================================================+ | | | Air (IOR = 1.0) | | ──────────────────────────────────── interface 0 | | [COAT layer] IOR, roughness, normal map | | R01 = Fresnel(IOR) T01 = 1 - R01 | | | | [MEDIUM layer] absorption = exp(-thickness * extinction) | | T12 = Beer-Lambert transmittance | | | | ──────────────────────────────────── interface 1 | | [BASE model] standard GGX PBR | | R_base = GGX(roughness, metalness) | | | | Adding-Doubling | | e_R = R01 + T01 * R_base * T10 / (1 - R10 * R_base) | | ^^ Total reflectance including all inter-layer bounces | +==============================================================+

The system supports 1, 2, or 3 coat layers via the NB_COAT_LAYERS macro. Each coat adds one normal map texture (t5, t6, t7) and its own IOR, roughness, thickness, and extinction color parameters.

系统通过 NB_COAT_LAYERS 宏支持 1、2 或 3 个涂层。每个涂层添加一张法线贴图纹理(t5、t6、t7)及其 IOR、粗糙度、厚度和消光颜色参数。

Adding-Doubling AlgorithmAdding-Doubling 算法

The physical core of the multi-layer model is ComputeAdding() — it solves the radiative transfer equations iteratively across all layers using the Adding-Doubling method. This correctly accounts for multiple inter-reflections between layer interfaces:

多层模型的物理核心是 ComputeAdding()——它使用 Adding-Doubling 方法在所有层上迭代求解辐射传输方程。这正确处理了层界面之间的多次内部反射:

// Simplified Adding-Doubling loop (from multi_layered_pbr.hlsl)
void ComputeAdding(float theta, Material mtl, LightingDataReadOnly data_r) {
  // Iteratively compose layers from top to bottom
  for (int i = 0; i < NB_COAT_LAYERS; i++) {
    ComputeStatistics(cti, 0, i, mtl, ...);  // Coat
    ComputeStatistics(cti, 1, i, mtl, ...);  // Medium (absorption)
    
    // Adding equations: compose stack so far with new layer
    m_R0i = (T0i * R12 * Ti0) / (1 - Ri0 * R12);  // multiple-bounce contribution
    e_R0i = R0i + m_R0i;                             // total reflectance
    e_T0i = (T0i * T12) / (1 - Ri0 * R12);          // total transmittance
  }
  // Add base layer (GGX)
  ComputeStatistics(cti, 2, 0, mtl, fresnel0);
  
  // Final effective roughness propagated through all layers
  data_r.ibl_perceptual_roughness[MODEL_LOBE_IDX] =
    LinearVarianceToPerceptualRoughness(_s_r0m);
}
Why Adding-Doubling? A naive "multiply Fresnel" approach ignores the light that bounces back upward inside the stack. For a car paint (clear coat over metallic base), ignoring inter-layer reflection underestimates the total reflectance by up to 10-15% at grazing angles. Adding-Doubling computes the exact solution via geometric series: R_total = R01 + T01²·R_base / (1 - R10·R_base). 为什么用 Adding-Doubling?简单的"乘菲涅尔"方法忽略了在层栈内向上反弹的光。对于汽车漆(清漆覆盖金属底漆),忽略层间反射会在掠射角最多低估 10-15% 的总反射率。Adding-Doubling 通过几何级数精确求解:R_total = R01 + T01²·R_base / (1 - R10·R_base)

ComputeStatistics — Per-Layer Optical PropertiesComputeStatistics — 逐层光学属性

ComputeStatistics() evaluates one layer's contribution at a given incident angle. It handles the three distinct layer types differently:

ComputeStatistics() 在给定入射角下计算单个层的贡献。它以不同方式处理三种不同的层类型:

layer_idx层索引 Type类型 R12 (reflectance)R12(反射率) T12 (transmittance)T12(透射率) Direction change方向变化
0 (Coat)Dielectric interface电介质界面FresnelUnpolarized(cti, eta, 1.0)1 - R12Snell's law refraction → computes new cti折射定律 → 计算新 cti
1 (Medium)Absorbing volume吸收性体积0exp(-thickness * extinction / cti)None (Beer-Lambert only)无(仅 Beer-Lambert)
2 (Base)Opaque material不透明材质F_Schlick(fresnel0, cti)0None
// Coat layer: full polarisation-resolved Fresnel + refraction angle
float FresnelUnpolarized(float ct1, float n1, float n2) {
  float sinT2 = (n1/n2)*(n1/n2) * (1 - ct1*ct1);
  if (sinT2 > 1) return 1;   // total internal reflection
  float ct2 = sqrt(1 - sinT2);
  // Rs = ((n1*ct1 - n2*ct2) / (n1*ct1 + n2*ct2))^2
  // Rp = ((n1*ct2 - n2*ct1) / (n1*ct2 + n2*ct1))^2
  return 0.5 * (Rs + Rp);    // unpolarised average
}

// Medium layer: Beer-Lambert absorption
// thickness in mm; extinction_color is per-channel absorption coefficient
float3 T12 = exp(-thickness * extinction_color / cti);

Roughness Variance Propagation粗糙度方差传播

When light refracts through a rough coat layer, the apparent roughness of underlying layers changes. NeoX propagates roughness using linear variance (a representation that makes variances additive):

当光线折射通过粗糙涂层时,下层的表观粗糙度会改变。NeoX 使用线性方差传播粗糙度(该表示使方差具有可加性):

// Conversion functions (stack_lit.hlsl)
float RoughnessToLinearVariance(float a) {
  return pow(a, 1.1) / (1.0 - pow(a, 1.1));
}
float LinearVarianceToPerceptualRoughness(float v) {
  return sqrt(LinearVarianceToRoughness(v));
}

// Propagation: combined variance = coat_variance + refracted_base_variance
// refracted roughness accounts for the lens-law magnification jacobian:
float magnification = (n1 * ct2) / (n2 * ct1);   // Snell's law jacobian
float refracted_variance = base_variance * (magnification * magnification);
float total_variance = coat_variance + refracted_variance;
Why linear variance? In the spatial frequency domain, convolving two NDF lobes corresponds to adding their variances. Using roughness² directly would be incorrect for non-Gaussian distributions. The pow(a, 1.1) exponent is a practical approximation that makes the GGX NDF variance-additive to within a few percent error across the full roughness range. 为什么用线性方差?在空间频率域,对两个 NDF 波瓣做卷积对应于相加其方差。直接使用 roughness² 对非高斯分布是不正确的。pow(a, 1.1) 指数是一个实用近似,使 GGX NDF 在整个粗糙度范围内以几个百分点的误差内具有方差可加性。

FGD Pre-Integration — ibl.hlslFGD 预积分 — ibl.hlsl

Each coat lobe needs its own FGD (Fresnel-GGX-D) pre-integrated LUT. This is a 2D texture (t4 in all ML-PBR materials) encoding the integrated BRDF over the hemisphere — precomputed at content build time and looked up at runtime:

每个涂层波瓣需要自己的 FGD(菲涅尔-GGX-D)预积分 LUT。这是一张 2D 纹理(所有 ML-PBR 材质中的 t4),在内容构建时预计算并在运行时查找:

// ibl.hlsl — FGD integration (run offline / baking tool)
float3 IntegrateGGXAndDisneyDiffuseFGD(float NdotV, float roughness,
                                         int sampleCount = 4096) {
  float3 acc = 0;
  for(int i = 0; i < sampleCount; i++) {
    float2 u = Hammersley2d(i, sampleCount);    // low-discrepancy sample
    // GGX importance-sampled contribution
    acc.xy += ImportanceSampleGGX(u, V, localToWorld, roughness, NdotV, ...);
    // Disney diffuse contribution
    acc.z  += ImportanceSampleLambert(u, ...);
  }
  return acc / sampleCount;
  // Output: (scale=acc.x, bias=acc.y, disneyDiffuse=acc.z)
}

// Runtime LUT lookup (multi_layered_pbr.hlsl)
void GetPreIntegratedFGDGGXAndDisneyDiffuse(Texture2D tex, float NdotV,
      float perceptualRoughness, float3 fresnel0,
      out float3 specFGD, out float diffFGD, out float reflectivity) {
  float3 preFGD = tex.SampleLevel(s_linear_clamp, float2(NdotV, perceptualRoughness), 0);
  specFGD     = lerp(preFGD.xxx, preFGD.yyy, fresnel0);  // scale + bias * f0
  diffFGD     = preFGD.z + 0.5;
  reflectivity = Luminance(specFGD);
}
LUT ChannelLUT 通道 Meaning含义 Usage用途
R (scale)FGD scale — multiply by F0FGD 缩放系数,乘以 F0specFGD = scale * F0 + bias
G (bias)FGD bias — additive termFGD 偏置,加法项Handles off-specular at grazing处理掠射角的离轴高光
B (Disney diffuse)Pre-integrated Disney diffuse lobe预积分 Disney 漫反射波瓣diffFGD = preFGD.z + 0.5

1 / 2 / 3-Coat Variants1 / 2 / 3 涂层变体

Three material files cover the three coat-layer counts. All are identical in structure except for the NB_COAT_LAYERS value and the additional coat-specific texture slots and uniform arrays:

三个材质文件涵盖三种涂层数量。所有文件在结构上相同,仅 NB_COAT_LAYERS 值、额外的涂层纹理槽位和 Uniform 数组不同:

File文件 NB_COAT_LAYERS Total textures总纹理数 Coat normal slots涂层法线槽位 Extra uniforms额外 Uniform
material_multi_layered_pbr.hlsl15t5CoatInfo01, CoatExtinctionColor01
material_multi_layered_pbr2.hlsl26t5, t6CoatInfo01-02, CoatExtinctionColor01-02
material_multi_layered_pbr3.hlsl37t5, t6, t7CoatInfo01-03, CoatExtinctionColor01-03

Each CoatInfo float4 packs: (IOR, roughness, thickness, unused). Each CoatExtinctionColor float4 packs (R, G, B, unused) extinction coefficients for Beer-Lambert absorption per coat.

每个 CoatInfo float4 打包:(IOR, roughness, thickness, unused)。每个 CoatExtinctionColor float4 打包每个涂层 Beer-Lambert 吸收的 (R, G, B, unused) 消光系数。

// Array sizes derived from NB_COAT_LAYERS:
// NB_TOTAL_NORMALS  = NB_COAT_LAYERS + 1  (coat normals + base normal)
// NB_TOTAL_LOBES    = NB_COAT_LAYERS + 1  (one lobe per normal)
// NB_TOTAL_LAYERS   = NB_COAT_LAYERS*2+1  (Coat + Medium) per coat + Base

// Energy coefficient array tracks what fraction of light reaches each layer
float layer_energy_coeff[NB_TOTAL_LAYERS];

// DirectLighting sums all lobes:
for (int lobe = 0; lobe < NB_TOTAL_LOBES; lobe++) {
  light_specular += specular_gd[lobe] * data_rw.fresnel[lobe]
                  * layer_energy_coeff[lobe_to_layer[lobe]];
}
New vs old: The built_in/layered_pbr/ framework files (stack_lit.hlsl, ibl.hlsl, hammersley.hlsl, bsdf/) exist in both directories with largely identical content. The key difference: the new engine adds the three material_multi_layered_pbr*.hlsl driver files in res_upgrade/ that wire the framework into the production material pipeline, making it a first-class material type selectable in the editor (up to 3 coat layers). In res/shader/ the framework existed but had no production material using it. 新旧对比:built_in/layered_pbr/ 框架文件(stack_lit.hlslibl.hlslhammersley.hlslbsdf/)在两个目录中都存在且内容基本相同。关键区别:新引擎在 res_upgrade/ 中新增了三个 material_multi_layered_pbr*.hlsl 驱动文件,将框架接入生产材质管线,使其成为可在编辑器中选择的一流材质类型(最多 3 个涂层)。res/shader/ 中框架存在但没有生产材质使用。