Yuyeyyy · Graphics

Compute Shader / 皮肤 SSS

皮肤渲染预积分 LUT 制作

返回文章

在做渲染练习的时候在网上没有找到合适的 LUT,截图分辨率又太低,解包还没有找到预积分 lut 图,我又还不会截帧。问朋友要了一张 lut 把皮肤渲染完成了,现在来填一下 lut 的制作这个坑 (_;)。如有错误或建议,还望大佬们指出。

把 Lambert 漫反射沿着 N·L 轴做一次按皮肤散射 profile 加权的 1D 卷积,并把卷积结果预先烘成一张 2D LUT,这样运行时只需查表,不用做昂贵的 blur pass。

Pre Integrated LUT Manager 编辑器面板:可以实时调 Strength / Falloff / Curvature Scale / 表面颜色,并勾选 Auto Generate

自制的 Pre Integrated LUT Manager 编辑器面板(基于 Unity EditorWindow + Compute Shader)。下方是 LUT 实时生成与材质自动应用开关。

1. BSSRDF 为什么能预积分 LUT 近似?

皮肤的次表面散射属于 BSSRDF:光线从表面某点进入、在内部扩散、从周围点出射。真正的物理做法有邻域积分 / 卷积,实时成本很高。

预积分 LUT 的近似是:

  • 散射最明显的视觉把明暗过渡(入射光梯度)变软:阴影边界更柔、薄处更亮更透。
  • 在方向光下,很多"明暗变化"可以被投影到一个简单的变量:NdotL。
  • 所以我们可以把"散射导致的软化"理解成:把 Lambert 漫反射曲线沿 N·L 轴做一次带权模糊(卷积),再把结果提前烘焙成 LUT。

在 kernel 里每个像素对应一个参数点:

  • X 轴(u) → NdotL
  • Y 轴(v) → curvature

2. 数值积分

在角度 θ 附近,把周围方向的入射光(cos)按距离相关散射核 R 做一次加权平均,然后再除一次总权重(能量归一):

D(θ)=∫−ππcos⁡(θ+x)⋅R(2sin⁡(x/2)) dx∫−ππR(2sin⁡(x/2)) dxD(\theta) = \frac{\int_{-\pi}^{\pi} \cos(\theta + x) \cdot R(2\sin(x/2)) \, dx}{\int_{-\pi}^{\pi} R(2\sin(x/2)) \, dx}

物理意义:在角度 θ 附近,把周围方向的入射光(cos)按距离相关散射核 R 做一次加权平均然后再除一次总权重(能量归一)。

// 角度
float NdotL = uv.x * 2.0 - 1.0;  // [-1, 1]

// 步长
float stepSize = scatterRadius * RANGE * 2.0 / INTEGRATE_STEPS;
float start = NdotL - scatterRadius * RANGE + stepSize * 0.5;

// 第 i 个样本
float sampleNdotL = start + i * stepSize;
float diffuse = saturate(sampleNdotL);  // 在 N·L 轴上"左右采样"

// 归一化距离(用于 profile 查权重)
float distance = abs(NdotL - sampleNdotL) / scatterRadius;
float3 weight = ScatterProfile(distance, _Falloff);  // 散射核 ScatterProfile()

// 最后归一化
totalWeights += weight;
...
float3 result = totalLight / max(totalWeights, 0.0001);  // 分母

3. 扩散 Profile:多高斯混合

真实皮肤扩散核有"短程强 + 长尾弱",用单一高斯很难同时拟合。用 curvature * _CurvatureScale 让卷积核宽度随 y 变化——同一个 N·L,在不同部位(厚薄 / 曲率)上得到不同的软化程度。

用 5 个高斯混合:

return 0.100 * Gaussian(0.0484, distance, falloff) +
       0.118 * Gaussian(0.187,  distance, falloff) +
       0.113 * Gaussian(0.567,  distance, falloff) +
       0.358 * Gaussian(1.99,   distance, falloff) +
       0.078 * Gaussian(7.41,   distance, falloff);

并且在 Gaussian() 里对 RGB 做距离缩放:

float rr = r / (0.001 + falloff[i]);
g[i] = exp(-(rr * rr) / (2.0 * variance)) / (2.0 * PI * variance);
// 皮肤对不同颜色透的距离不一样:红更透,蓝更不透。

Strength:散射与 Lambert 的混合权重

// Strength:散射与 Lambert 的混合权重
return lerp(saturate(NdotL), result, saturate(_Strength));

4. 完整 Compute Shader 代码

#pragma kernel CSMain

#define PI 3.1415926
#define INTEGRATE_STEPS 51
#define CENTER_STEP 25

float3 _Strength;
float3 _Falloff;
float _CurvatureScale;
float2 _OutputSize;

RWTexture2D<float4> _LutTexture;

float3 Gaussian(float variance, float r, float3 falloff)
{
    float3 g = 0;
    for (int i = 0; i < 3; i++)
    {
        float rr = r / (0.001 + falloff[i]);
        g[i] = exp(-(rr * rr) / (2.0 * variance)) / (2.0 * PI * variance);
    }
    return g;
}

float3 ScatterProfile(float distance, float3 falloff)
{
    return 0.100 * Gaussian(0.0484, distance, falloff) +
           0.118 * Gaussian(0.187,  distance, falloff) +
           0.113 * Gaussian(0.567,  distance, falloff) +
           0.358 * Gaussian(1.99,   distance, falloff) +
           0.078 * Gaussian(7.41,   distance, falloff);
}

float3 IntegrateDiffuseScattering(float NdotL, float curvature)
{
    const float RANGE = 2.0;
    float scatterRadius = curvature * _CurvatureScale;

    if (scatterRadius < 0.001)
        return saturate(NdotL);

    float3 totalWeights = 0;
    float3 totalLight = 0;

    float stepSize = scatterRadius * RANGE * 2.0 / INTEGRATE_STEPS;
    float start = NdotL - scatterRadius * RANGE + stepSize * 0.5;

    for (int i = 0; i < INTEGRATE_STEPS; i++)
    {
        float sampleNdotL = start + i * stepSize;
        float diffuse = saturate(sampleNdotL);
        float distance = abs(NdotL - sampleNdotL) / scatterRadius;
        float3 weight = ScatterProfile(distance, _Falloff);

        totalLight += weight * diffuse;
        totalWeights += weight;
    }

    float3 result = totalLight / max(totalWeights, 0.0001);

    // 使用 _Strength 做每通道混合权重(0..1)
    // strength = (1, 0.8, 0.6) 这种很常见:R 更透、B 更少
    return lerp(saturate(NdotL), result, saturate(_Strength));
}

[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    if (id.x >= _OutputSize.x || id.y >= _OutputSize.y)
        return;

    float2 uv = (float2(id.x, id.y) + 0.5) / _OutputSize;

    float NdotL = uv.x * 2.0 - 1.0;   // -1 to 1
    float curvature = uv.y;           // 0 to 1

    float3 final = IntegrateDiffuseScattering(NdotL, curvature);
    _LutTexture[id.xy] = float4(final, 1.0);
}

再写一个脚本把 _LutTexture 传给材质就行。

三个调参旋钮的物理意义

旋钮 物理含义 典型值
Strength(float3) 散射与 Lambert 的混合权重,每个通道独立控制。R 更透、B 更不透 (1.0, 0.3, 0.05)(来自原图 skin preset)
Falloff(float3) 散射核的 RGB 距离缩放,控制每种颜色的扩散范围 (1.0, 0.6, 0.0)(红扩散最远,蓝几乎不散)
CurvatureScale(float) 整体卷积核宽度,影响"软化"程度 1.0(平面软化范围约为 1 个 N·L 单位)

调 Strength 时注意三通道差异((1, 0.8, 0.6) 很常见,让红更透、蓝更不透)。CurvatureScale 增大整张 LUT 会更"糊",但也要小心薄处和厚处的过渡。

参考与学习

  • 《【Unity】Compute Shader 的基础介绍与使用》
  • 浅谈 Unity URP 中 LUT 的应用
  • 预积分皮肤次表面散射 LUT 研究实践与遇到的问题
  • Pre-Integrated Skin Shading 数学模型理解
  • 预积分皮肤渲染 LUT 生成实践
  • 色彩分级
  • Penner pre-integrated skin rendering (siggraph 2011 advances in real-time rendering course)
  • Compute Shader 快速简易入门(译)

编辑于 2025-12-21 · 江苏

评论