URP / 皮肤渲染 / SSS
Unity 真实感皮肤渲染学习记录
本文是我对皮肤渲染方案的学习总结,如有错误或建议,还望大佬们指出 π_π
皮肤物理特性与表现
当光线照射到皮肤时:
- 一部分光在表面被直接反射,形成高光(比如皮肤油亮的部分)。
- 更重要的一部分光会穿透表皮,进入皮肤内部。这些进入的光线会在内部与组织发生多次散射、吸收和再发射。
- 最后,这些光线从入射点周围的另一个位置穿出皮肤表面。
表现:
- 边缘透光:在耳朵、鼻翼、手指缝等较薄的区域,背光时能看到红色的透光效果。
- 柔和阴影:皮肤下的散射模糊了阴影和接触区域的明暗交界线。
一、SSSS 屏幕分离模糊(方案一)
1. Diffuse Buffer 生成
在 DiffuseOnly Pass(Pass 2)中,渲染纯漫反射光照结果(包含 Albedo、NdotL、AO),不包含高光,写入临时的 _SSS_DiffuseBuffer。
- 高光是表面反射,不进入皮肤内部,因此不能模糊。
- 漫反射进入皮肤内部发生散射,因此需要模糊。
2. 可分离模糊(Separable Blur)
利用高斯核的可分离性,将二维模糊拆解为水平和垂直两个 Pass 进行处理(N*N → 2N):
// Horizontal
cmd.SetGlobalVector(_BlurDir, new Vector4(1, 0, 0, 0));
cmd.Blit(diffuseBufferID, tempBlurBufferID, settings.sssBlurMaterial, 0);
// Vertical
cmd.SetGlobalVector(_BlurDir, new Vector4(0, 1, 0, 0));
cmd.Blit(tempBlurBufferID, diffuseBufferID, settings.sssBlurMaterial, 0);
如果在屏幕上简单模糊,角色面部特征会糊成一片,需要使用深度权重修正:
float2 finalStep = _SSSWidth * scale * _BlurDir;
finalStep *= float2(1.0 / _ScreenParams.x, 1.0 / _ScreenParams.y);
half3 accumColor = 0;
[loop]
for (int i = 0; i < _SampleCount; i++)
{
float offset = _Kernel[i].w;
float3 weight = _Kernel[i].xyz;
float wL = (weight.x + weight.y + weight.z) * (1.0 / 3.0);
float2 sampleUV = uv + finalStep * offset;
half3 colorSample = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, sampleUV).rgb;
float depthSample = SampleSceneDepth(sampleUV);
float linearDepthSample = LinearEyeDepth(depthSample, _ZBufferParams);
// 深度权重修正
float depthDiff = abs(linearDepthM - linearDepthSample);
float correction = saturate(1.0 - depthDiff * 100.0 / (_SSSWidth + 0.001));
colorSample = lerp(colorM, colorSample, correction);
accumColor += colorSample * weight;
sumWeightRGB += weight;
sumWeightL += wL;
accumCorrRGB += correction * weight;
accumDepthDiff += depthDiff * wL;
}
3. 最终合成
finalColor = sssDiffuse + specular + translucency
4. 高光:Dual Lobe Specular
高光使用 Dual Lobe Specular 模拟皮肤的油脂层高光——两个不同粗糙度的 GGX 函数进行简单的插值后得到 D 项:
float DualLobe_D_GGX(float NdotH, float roughness)
{
float a2 = roughness * roughness;
float d = (NdotH * a2 - NdotH) * NdotH + 1.0;
return a2 / (PI * d * d + 1e-7);
}
float GetDualLobeSpecularTerm(float3 N, float3 H, float smoothness, float lobeWeight)
{
float NdotH = saturate(dot(N, H));
float r1 = (1.0 - smoothness) * (1.0 - smoothness);
float term1 = DualLobe_D_GGX(NdotH, r1);
float softSmoothness = smoothness * 0.6;
float r2 = (1.0 - softSmoothness) * (1.0 - softSmoothness);
float term2 = DualLobe_D_GGX(NdotH, r2);
return lerp(term1, term2, lobeWeight);
}
5. 黑边问题
考虑性能降采样 SSSS buffer 时,会有黑边出现,是因为当屏幕像素刚好位于角色边缘时,UV 坐标映射到低分辨率 RT 纹理上,正好落在皮肤和黑色背景的交界处,插值出的结果就是半黑的。

降采样导致的 SSSS 黑边对比。左图为降采样 1×,角色边缘出现明显的半黑半亮脏边;右图为拉到 2×,脏边在可接受范围内。
6. RenderFeature 配置
// 获取当前相机
var desc = renderingData.cameraData.cameraTargetDescriptor;
// 降采样系数
int ds = Mathf.Max(1, settings.downsample);
// 将宽和高除以降采样系数
desc.width = Mathf.Max(1, desc.width / ds);
desc.height = Mathf.Max(1, desc.height / ds);
desc.msaaSamples = 1; // 不需要抗锯齿,因为之后会模糊
desc.useMipMap = false;
desc.depthBufferBits = 16;
// 使用高精度格式以保证光照计算准确
desc.colorFormat = RenderTextureFormat.RGB111110Float;
// 申请临时 RT(diffuseBufferID 是那个低分辨率的 Buffer)
// FilterMode.Bilinear 非常重要,因为后续采样回大屏幕时需要线性插值
cmd.GetTemporaryRT(diffuseBufferID, desc, FilterMode.Bilinear);
cmd.GetTemporaryRT(tempBlurBufferID, desc, FilterMode.Bilinear); // 模糊的临时 Buffer
调节 ssss 强度使光照边缘变软过渡,顺便生成 lut 做了预积分方案
二、SSS 预积分 lut(方案二)
不细说了,主要就是使用 NdotL 和曲率采样 lut
跟方案一对比,预积分 LUT 走"离线烘焙 + 查表"路线,适合不需要逐帧调参的稳定环境;屏幕分离模糊走"运行时 GPU 模糊"路线,效果实时可调但有降采样黑边问题。两套方案各有适用场景,可以根据项目需要选其一或叠加。
三、耳后鼻尖透射
光线在穿过半透明物体内部时,方向会发生散射。Shader 会计算一个偏移的光线方向,并检测视线是否与这个偏移后的光线逆方向对齐。如果对齐且该位置厚度较薄,就通过计算出的透射颜色。
方案一
1. 采样厚度图
float thickness = SAMPLE_TEXTURE2D(_ThicknessMap, sampler_ThicknessMap, input.uv).r;
2. 计算 Light Distortion
// 主光源方向向法线的方向进行偏移
float3 Lt = normalize(L + N * _TranslucencyDistortion);
3. 计算视线向量与畸变后的逆光方向的点积,判断观察者是否正对着透射过来的光线
float viewToDistortedLight = saturate(dot(V, -Lt));
// 数值越高,透光区域越集中;数值越低,透光越发散。
float transShape = lerp(viewToLight, viewToDistortedLight, 0.6);
transShape = pow(max(transShape, 1e-5), _TransPower);
4. 防止透射光在明暗交界处突然截断,算法引入了 Wrap 项,模拟光线在表面的散射覆盖。
float backLight = saturate((-NdotL + _TransWrap) / (1.0 + _TransWrap));
5. 透射光也应该受到阴影的影响
float transShadow = lerp(1.0, mainLight.shadowAttenuation, _TransShadow);
float transAtten = mainLight.distanceAttenuation * transShadow;
6. 合成
half3 translucency = _TranslucencyColor.rgb * mainLight.color * (backLight * transShape) * transAtten;
方案二
half3 CalculateTranslucency(SkinSurfaceData surface, SkinLightingData lighting)
{
// 视线与反向光线的夹角
half3 transLightDir = lighting.lightDir + surface.normal * 0.1;
half transDot = pow(saturate(dot(surface.viewDir, -transLightDir)), 2.0);
half thicknessMask = surface.thickness * surface.sssIntensity;
// 使用 SSSColor 染色
half3 transColor = _SSSColor.rgb * lighting.lightColor * transDot * thicknessMask;
return transColor;
}
四、血渍
单令 roughness,mask 的 RGB 三个通道分别控制三层不同颜色和不同强度做 alpha,插值到基色(加权平均):
float alpha1 = saturate(bloodMask.r * _BloodIntensity1);
float alpha2 = saturate(bloodMask.g * _BloodIntensity2);
float alpha3 = saturate(bloodMask.b * _BloodIntensity3);
float totalBloodAlpha = saturate(alpha1 + alpha2 + alpha3);
half3 bloodColorSum = _BloodColor1.rgb * alpha1 + _BloodColor2.rgb * alpha2 + _BloodColor3.rgb * alpha3;
half3 finalBloodColor = bloodColorSum / max((alpha1 + alpha2 + alpha3), 0.0001);
half3 finalBloodColor = bloodColorSum / max((alpha1 + alpha2 + alpha3), 0.0001);
albedo = lerp(albedo, finalBloodColor, totalBloodAlpha);
float bloodSmoothness = 1.0 - _BloodRoughness;
smoothness = lerp(smoothness, bloodSmoothness, totalBloodAlpha);
踩坑与自检
之前鼻孔错误的透光发亮问题是因为厚度不对:用顶点沿法线反向偏移在做厚度来用,为了解决鼻孔亮还单独画鼻孔 AO 把颜色压下去 T^T。后面用了正确的厚度图就解决了。
编辑于 2026-04-20 · 江苏