GameTech/URP 渲染实战

案例 12.6皮肤:Wrap 与程序生成的预积分 LUT高阶

目标:在 C# 里数值积分生成预积分皮肤 LUT(同时提供编辑器菜单把它烘焙成贴图);Shader 里用屏幕空间导数估计曲率,对比 Lambert、彩色 Wrap 和 LUT 三种漫反射。

生成 LUT

先把 d'Eon 的 6 高斯剖面写出来:

Assets/Cookbook/Ch12/Editor/SkinLutBaker.cs(节选:profile)
// 每个高斯的方差(mm²)与 RGB 权重
static readonly float[] Variance = { 0.0064f, 0.0484f, 0.187f, 0.567f, 1.99f, 7.41f };
static readonly Vector3[] Weight =
{
    new Vector3(0.233f, 0.455f, 0.649f),
    new Vector3(0.100f, 0.336f, 0.344f),
    new Vector3(0.118f, 0.198f, 0.000f),
    new Vector3(0.113f, 0.007f, 0.007f),
    new Vector3(0.358f, 0.004f, 0.000f),
    new Vector3(0.078f, 0.000f, 0.000f),
};

// 散射剖面 R(d):距离入射点 d 毫米处的出射能量
// 注意归一化用的是一维高斯 1/sqrt(2πv):我们只沿圆环(一维)积分,
// 若用二维的 1/(2πv),宽高斯(红光的长距离散射)会被严重低估
static Vector3 Profile(float d)
{
    Vector3 r = Vector3.zero;
    for (int k = 0; k < 6; k++)
    {
        float v = Variance[k];
        float g = Mathf.Exp(-d * d / (2f * v)) / Mathf.Sqrt(2f * Mathf.PI * v);
        r += Weight[k] * g;
    }
    return r;
}

这里有个容易出错的细节:原文的 6 个高斯是二维归一化的(在平面上积分为 1),而我们只沿一个圆环做一维积分。如果照搬二维的归一化系数 1/(2πv),方差大的高斯(恰恰是红光那几项)在一维积分里会被压低一个 v 量级,生成的 LUT 几乎看不出红色。笔者第一次实现时就踩了这个坑,改成一维的 1/2πv 之后才得到正常的结果,Penner 原文附带的代码也是这种写法。

积分本身就是公式的直接翻译,用 256 个采样点的中点法则:

Assets/Cookbook/Ch12/Editor/SkinLutBaker.cs(节选:integrate)
// 在半径为 r 的圆环上积分:入射点与出射点夹角 x,弦长 2r·sin(x/2)
// D(θ) = ∫ saturate(cos(θ + x)) R(2r sin(x/2)) dx / ∫ R(2r sin(x/2)) dx
public static Color Integrate(float cosTheta, float invRadius)
{
    float theta = Mathf.Acos(Mathf.Clamp(cosTheta, -1f, 1f));
    float r = 1f / Mathf.Max(invRadius, 1e-4f);
    Vector3 num = Vector3.zero, den = Vector3.zero;
    const int Steps = 256;
    float dx = 2f * Mathf.PI / Steps;
    for (int s = 0; s < Steps; s++)
    {
        float x = -Mathf.PI + (s + 0.5f) * dx;
        Vector3 p = Profile(Mathf.Abs(2f * r * Mathf.Sin(x * 0.5f)));
        num += p * Mathf.Max(0f, Mathf.Cos(theta + x));
        den += p;
    }
    return new Color(num.x / den.x, num.y / den.y, num.z / den.z, 1f);
}
Assets/Cookbook/Ch12/Editor/SkinLutBaker.cs(节选:generate)
public static Texture2D Generate(int width = 256, int height = 64)
{
    var tex = new Texture2D(width, height, TextureFormat.RGBAHalf, false, true)   // 半精度 + 线性:暗部不出色带
    {
        name = "SkinLUT", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear
    };
    var px = new Color[width * height];
    for (int y = 0; y < height; y++)
    {
        float invR = (y + 0.5f) / height * MaxInvRadius;
        for (int x = 0; x < width; x++)
        {
            float ndl = (x + 0.5f) / width * 2f - 1f;
            px[y * width + x] = Integrate(ndl, invR);
        }
    }
    tex.SetPixels(px);
    tex.Apply();
    return tex;
}

纵轴 v ∈ [0, 1] 映射到 1/r ∈ [0, 1] mm⁻¹,也就是半径从无穷大到 1 mm。贴图用 RGBAHalf 且标记为线性:LUT 里是数据而不是颜色,不能让 GPU 做 sRGB 解码;8 位精度在暗部会出色带,存成 PNG 时要注意(菜单 Cookbook › Ch12 › Bake Skin LUT 会存一张 8 位 PNG 并把导入设置改为非 sRGB;要求高的项目建议存 EXR)。

图 12-19程序生成的预积分皮肤 LUT。横轴:N·L 从 −1(左)到 1(右);纵轴:曲率从 0(下)到 1 mm⁻¹(上)。底部一行就是 Lambert,越往上红色越向暗面渗透

图中右上角略带青色,这是对的:红光“搬”到暗面去了,亮面剩下的红光变少,于是相对偏青。真实皮肤上这种偏色被固有色的红掩盖,几乎察觉不到。

在 Shader 里使用

三种漫反射模式写在一个函数里:

Assets/Cookbook/Ch12/Skin.shader(节选:diffuse)
float3 SkinDiffuse(MaterialCtx m, float NoLu, float shadow)
{
#if defined(_DIFFUSE_WRAP)
    // 每个通道包裹量不同:红光在皮下走得最远,所以红通道包得最多
    // 阴影同样折进 N·L:阴影里的点按“刚好背光”处理,包裹项让阴影边缘也透出一点红
    float3 w = _Wrap * _ScatterColor.rgb;
    float ndl = lerp(min(NoLu, 0.0), NoLu, shadow);
    return saturate((ndl + w) / (1.0 + w));
#elif defined(_DIFFUSE_LUT)
    // 预积分 LUT:u = N·L 映射到 0~1,v = 曲率
    // 阴影先折进 N·L 再查表,让阴影边缘也带上散射的红色过渡
    float u = lerp(min(NoLu, 0.0), NoLu, shadow) * 0.5 + 0.5;
    return SAMPLE_TEXTURE2D_LOD(_SkinLUT, sampler_SkinLUT, float2(u, m.curvature), 0).rgb;
#else
    return saturate(NoLu) * shadow;
#endif
}
vec3 SkinDiffuse(MaterialCtx m, float NoLu, float shadow)
{
#if defined(_DIFFUSE_WRAP)
    // 每个通道包裹量不同:红光在皮下走得最远,所以红通道包得最多
    // 阴影同样折进 N·L:阴影里的点按“刚好背光”处理,包裹项让阴影边缘也透出一点红
    vec3 w = _Wrap * _ScatterColor.rgb;
    float ndl = mix(min(NoLu, 0.0), NoLu, shadow);
    return saturate((ndl + w) / (1.0 + w));
#elif defined(_DIFFUSE_LUT)
    // 预积分 LUT:u = N·L 映射到 0~1,v = 曲率
    // 阴影先折进 N·L 再查表,让阴影边缘也带上散射的红色过渡
    float u = mix(min(NoLu, 0.0), NoLu, shadow) * 0.5 + 0.5;
    return textureLod(_SkinLUT, vec2(u, m.curvature), 0.0).rgb;
#else
    return vec3(saturate(NoLu) * shadow);
#endif
}

阴影的处理值得一提。如果像 Lambert 那样最后乘阴影,阴影边缘会是一条生硬的线,散射效果被一刀切掉。这里把阴影“折”进 N·L:处于阴影中的点按“刚好背光”(N·L 取 min(N·L, 0))去查表,阴影边缘就也有了红色的过渡。Penner 的原始方案更进一步,为阴影的半影再做一张 LUT;对大多数项目,这个折中已经足够。

曲率用屏幕空间导数估计:fwidth(N) 是相邻像素间法线的变化量,fwidth(positionWS) 是相邻像素间位置的变化量,二者相除就是“每米弯多少”,即 1/r:

Assets/Cookbook/Ch12/Skin.shader(节选:curvature)
// 曲率 = 法线变化量 / 位置变化量,用屏幕空间导数估计(单位 1/米)
float curv = length(fwidth(N)) / max(length(fwidth(i.positionWS)), 1e-5);
curv = saturate(curv * _CurvatureScale);   // 换算到 LUT 的 0~1,比例取决于场景单位和 LUT 的定义
if (_Curvature >= 0) curv = _Curvature;
// 曲率 = 法线变化量 / 位置变化量,用屏幕空间导数估计(单位 1/米)
float curv = length(fwidth(N)) / max(length(fwidth(positionWS)), 1e-5);
curv = saturate(curv * _CurvatureScale);   // 换算到 LUT 的 0~1,比例取决于场景单位和 LUT 的定义
if (_Curvature >= 0.0) curv = _Curvature;

这个估计在三角形边界处会有跳变(插值法线的导数不连续),正式项目通常在 DCC 里预先计算曲率存进顶点色或贴图。_CurvatureScale 负责把“场景单位下的 1/米”换算成 LUT 的纵坐标——如果角色头部半径 0.1 m(曲率约 10 m⁻¹),要映射到 LUT 的哪一行,取决于你希望效果有多强,这本质上是一个美术参数。

Assets/Cookbook/Ch12/Skin.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float NoLu = dot(m.N, L);
    float NoL = saturate(NoLu);
    float3 diffuse = m.albedo * SkinDiffuse(m, NoLu, light.shadowAttenuation);

    // 双瓣高光:一个偏光滑、一个偏粗糙,各占一半,模拟皮脂层 + 角质层
    float3 spec = 0.5 * AdvSpecularGGX(m.N, m.V, L, m.a, _SpecularF0)
                + 0.5 * AdvSpecularGGX(m.N, m.V, L, saturate(m.a * 2.2), _SpecularF0);
    spec *= PI * NoL * light.shadowAttenuation;

    return (diffuse + spec) * light.color * light.distanceAttenuation;
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    float NoLu = dot(m.N, L);
    float NoL = saturate(NoLu);
    vec3 diffuse = m.albedo * SkinDiffuse(m, NoLu, light.shadowAttenuation);

    // 双瓣高光:一个偏光滑、一个偏粗糙,各占一半,模拟皮脂层 + 角质层
    vec3 spec = 0.5 * AdvSpecularGGX(m.N, m.V, L, m.a, _SpecularF0)
              + 0.5 * AdvSpecularGGX(m.N, m.V, L, saturate(m.a * 2.2), _SpecularF0);
    spec *= PI * NoL * light.shadowAttenuation;

    return (diffuse + spec) * light.color * light.distanceAttenuation;
}

高光用了两个 GGX 波瓣各占一半,F0 取 0.028(皮肤折射率约 1.4):皮肤表面有一层油脂,再下面是角质层,单一粗糙度的高光看起来像塑料,双瓣会自然很多。

图 12-20不同大小的“皮肤”球,侧光照明,环境光压暗以便观察明暗交界。左:Lambert;中:彩色 Wrap(红色包裹最多);右:预积分 LUT,小球(曲率大)明显更“透”、交界线更红更软

注意中图和右图的区别:Wrap 对所有球一视同仁,大球和小球的交界线宽度一样,而且在阴影边缘出现了一条较硬的红线;LUT 的效果随曲率变化——这正是预积分方法的价值所在:鼻尖、耳朵、手指这些曲率大的地方散射明显,额头、脸颊这些平缓的地方几乎不变。

常见坑① LUT 贴图一定要关掉 sRGB 和压缩,Wrap Mode 设为 Clamp,否则 u = 0 和 u = 1 处会与另一侧插值。② 法线贴图带来的高频细节会让曲率估计严重失真,算曲率时要用几何法线(未经法线贴图扰动的插值法线),查 LUT 的 N·L 则可以用扰动后的法线。③ 预积分只处理“曲率造成的散射”,处理不了薄处的透光,那是下一节的内容。