案例 12.6皮肤:Wrap 与程序生成的预积分 LUT高阶
目标:在 C# 里数值积分生成预积分皮肤 LUT(同时提供编辑器菜单把它烘焙成贴图);Shader 里用屏幕空间导数估计曲率,对比 Lambert、彩色 Wrap 和 LUT 三种漫反射。
生成 LUT
先把 d'Eon 的 6 高斯剖面写出来:
// 每个高斯的方差(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),而我们只沿一个圆环做一维积分。如果照搬二维的归一化系数 ,方差大的高斯(恰恰是红光那几项)在一维积分里会被压低一个 量级,生成的 LUT 几乎看不出红色。笔者第一次实现时就踩了这个坑,改成一维的 之后才得到正常的结果,Penner 原文附带的代码也是这种写法。
积分本身就是公式的直接翻译,用 256 个采样点的中点法则:
// 在半径为 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); }
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 时要注意(菜单 会存一张 8 位 PNG 并把导入设置改为非 sRGB;要求高的项目建议存 EXR)。

图中右上角略带青色,这是对的:红光“搬”到暗面去了,亮面剩下的红光变少,于是相对偏青。真实皮肤上这种偏色被固有色的红掩盖,几乎察觉不到。
在 Shader 里使用
三种漫反射模式写在一个函数里:
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:
// 曲率 = 法线变化量 / 位置变化量,用屏幕空间导数估计(单位 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;
这个估计在三角形边界处会有跳变(插值法线的导数不连续),正式项目通常在 DCC 里预先计算曲率存进顶点色或贴图。_CurvatureScale 负责把“场景单位下的 1/米”换算成 LUT 的纵坐标——如果角色头部半径 0.1 m(曲率约 10 m⁻¹),要映射到 LUT 的哪一行,取决于你希望效果有多强,这本质上是一个美术参数。
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):皮肤表面有一层油脂,再下面是角质层,单一粗糙度的高光看起来像塑料,双瓣会自然很多。

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