Compute Shader / 皮肤 SSS
皮肤渲染预积分 LUT 制作
在做渲染练习的时候在网上没有找到合适的 LUT,截图分辨率又太低,解包还没有找到预积分 lut 图,我又还不会截帧。问朋友要了一张 lut 把皮肤渲染完成了,现在来填一下 lut 的制作这个坑 (
_;)。如有错误或建议,还望大佬们指出。把 Lambert 漫反射沿着 N·L 轴做一次按皮肤散射 profile 加权的 1D 卷积,并把卷积结果预先烘成一张 2D LUT,这样运行时只需查表,不用做昂贵的 blur pass。

自制的 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 做一次加权平均,然后再除一次总权重(能量归一):
物理意义:在角度 θ 附近,把周围方向的入射光(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 · 江苏