做了几年Unity渲染,皮肤材质一直是个绕不开的坎。角色模型明明做得挺精细,一打光就像上了层清漆的塑料,尤其是耳廓、鼻翼、指缝这些透着薄肉的部位,黑得死死的,怎么调都缺那股“活人气”。其实问题不在贴图,而在光照模型没处理次表面散射(Subsurface Scattering,SSS)。这篇文章我想聊聊一种性价比极高的快速SSS方案,它不用屏幕空间卷积也不需要深度贴图,靠着顶点曲率和厚度信息就能在移动端把皮肤、蜡烛、树叶这类材质做出相当能打的效果。整套逻辑跑通之后,你再看任何“透光”材质,思路都会开阔很多。
适合看这篇东西的,主要是Unity开发的TA、图形程序,以及那些被“皮肤塑料感”困扰的美术同学。我会把原理、选型思路、完整Shader代码、踩坑记录一次性写完,保证你照着抄完能跑出效果,也能自己调出想要的味道。
1. 次表面散射是什么:为什么皮肤、蜡烛、树叶都自带“柔光”
1.1 一句话说清SSS的物理本质
光打到不透明物体表面,要么被反射,要么被吸收。但皮肤、蜡烛、树叶、玉石这类材质不一样,一部分光会从表面进入物体内部,在材质里连续散射、折来折去,最后从离入射点有一定距离的位置重新出射。这个过程就是次表面散射。
拿手电筒照自己的手指,你会发现除了被照亮的那面,手指背面也泛着红光。光线从手电侧射入,在皮肤组织里经历多次散射,一部分绕过了指骨区域透到另一侧出射。这就是SSS最直观的现象。落在渲染层面,SSS会带来两个典型视觉特征:一是轮廓边缘发亮甚至泛红,二是暗部不会死黑,总有一种“血液在皮下流动”的暖通感。
1.2 为什么SSS是实时渲染的“老大难”
理论很美好,实现很骨感。光的散射路径在物理上是无限次的,真正的模拟要依赖光线追踪里的BSSRDF(双向散射表面反射分布函数)模型,看看次表面散射游戏行业的标杆做法就知道了——顽皮狗在《最后生还者》里的皮肤方案,用到了屏幕空间散射和高斯模糊,画质好,但计算量也大得多。对于移动端和中小团队来说,光是拿到一张深度纹理再跑卷积,性价比就极低。
SSS的难点说白了就是:你要回答“光从哪进来、在内部跑了多远、从哪出去”这三个问题,全算出来几乎等于做一次子表面模拟。实时的思路都是想办法跳过这些精确计算,用近似值演戏。
1.3 常见SSS实现方案横向对比
主流的实时SSS方案就三种,各有各的适用场景。
| 方案 | 原理 | 画质 | 性能 | 适用场景 |
|---|---|---|---|---|
| 屏幕空间散射(SSSSS) | 在屏幕空间对高光/边缘做模糊扩散 | 高 | 中高 | 主机/PC第三人称游戏 |
| 深度贴图透射 | 从光源方向渲染场景深度,计算光穿透厚度 | 较高 | 中 | PC/中高端移动平台,刻薄材质 |
| 厚度/曲率近似(快速SSS) | 用模型表面厚度或曲率模拟光衰减 | 中高 | 极低 | 移动端/低端平台,全类型材质 |
快速SSS的优势很明确:它不需要额外Pass,不需要深度相机,更不需要全屏后处理,一个Shader就能搞定。对正在做移动端项目、又不想在皮肤表现上妥协的团队,这是最务实的切入点。
2. 快速SSS的方案选型:为什么用厚度和曲率而不是屏幕空间卷积
2.1 “快速”的定位:想清楚你的平台和目标
很多团队一上来就说我要做次表面散射,然后去查论文、抄屏幕空间方案,最后发现手机跑不动,PC上效果也一般。我建议先问自己三个问题:目标平台是什么,角色在镜头里的占比有多大,项目里有多少角色需要套这个材质。
如果是写实手游、锁视角、角色大头照频出的项目,屏幕空间方案是可以考虑的。但如果是策略游戏、塔防、或者角色的脸部只在中距离出现,快速SSS的低成本优势就非常明显。方案选型最大的坑不是你选错了算法,而是你为了一个低频画面效果背上了全屏后处理的性能债。
2.2 厚度/曲率近似:为什么它能以假乱真
快速SSS的核心假设是:模型表面的凸起程度能间接反映透光强度。耳朵、鼻翼、手指缝隙这些凸起尖锐的区域,物理上对应着更薄的体积,光更容易穿透,散射也更强烈;而脸颊、额头、肚子这些平滑饱满的区域,体积厚,光穿透困难,散射微弱。
所以我们的目标就变成了:如何用现有数据表示“这个顶点所在位置的薄厚程度”。有两种常用方案:
- 曲率烘焙:通过顶点法线在相邻顶点之间的变化速率计算曲率,凸起尖锐的位置曲率大,存进顶点色。
- 厚度贴图:美术手动用灰度图绘制,白表示薄,黑表示厚。几乎全手绘,但是可控性极强,同一套网格要做出角色和怪物的差异,直接换贴图就行。
我个人的项目经验是,曲率烘焙适合做通用材质,速度快、自动生成,但精度一般;厚度贴图效果可控,特别适合主角脸模这种需要精雕细琢的部位。实际项目中可以把两个数据一起用:曲率负责边缘散射的自动分布,厚度图负责主要块面的透射强度。
2.3 光照模型设计思路
拿到厚度信息之后,下一步就是把它塞进光照模型里。快速SSS的光照模型一般拆成三个部分:
- 漫反射:用Wrap Lighting让明暗过渡更柔和,避免皮肤上出现生硬的明暗分界线。Wrap Lighting的本质是把NdotL的值域重新映射,让光线在光照方向变化时不是突然断开,而是慢慢衰减。
- 透射光:根据视角方向和光线方向的点积,模拟背光时“看穿”薄区域的透射效果。
- 边缘散射:类似边缘光(Rim Light),但是用厚度信息做权重,只有薄的地方才泛光。
三个部分叠加之后,材质就有了“透”的层级感,而不是全物体均匀发光。重点在于:散射的强度必须和厚度挂钩,否则效果就变成了跑马灯。
3. 实操配件准备:贴图通道、顶点曲率与Shader框架
3.1 贴图与模型准备
动手写Shader之前,先整理一下需要的资产。不用太复杂,四张贴图就够起步:
- 漫反射贴图(Albedo):给出皮肤的基础颜色,这个不用多说。
- 法线贴图:提供表面凹凸细节,让高光和散射都有细节可以抓。
- 高光贴图(Gloss Map):控制局部的光滑度,嘴唇、鼻子区域高光更强。
- 厚度信息:可以直接用顶点色R通道,或者单独的一张灰度贴图。白色代表薄、红色代表薄(顶点色)这种约定你自己定,但写Shader的时候要跟烘焙逻辑对上。
厚度的取值建议在0到1之间线性映射。0代表完全实心,光完全穿不过去;1代表薄如蝉翼,光直接穿透。做怪物皮肤时,可以让美术在耳部、翼膜、尾巴尖这些位置把厚度的值拉高,增强透光诡异感。
3.2 顶点曲率烘焙脚本:复制就能用
如果你不打算手绘厚度贴图,那么一个自动曲率烘焙工具能省不少事。思路很简单:遍历每个顶点,取它相邻顶点的法线,计算法线夹角平均值。夹角越大,说明该位置弯折越剧烈,曲率越高,对应的薄度越高。
下面这个C#脚本我放在项目里很久了,简单粗暴但够用。把它挂到带MeshFilter的物体上,勾选Bake On Start,运行一次就能把结果写进顶点色。注意它直接修改了sharedMesh,编辑时建议复制一份Mesh再烘焙。
using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(MeshFilter))] public class CurvatureBaker : MonoBehaviour { public bool bakeOnStart = true; [Range(0f, 1f)] public float blendAmount = 0.8f; void Start() { if (bakeOnStart) Bake(); } public void Bake() { Mesh mesh = GetComponent<MeshFilter>().sharedMesh; if (mesh == null) return; if (!mesh.isReadable) { Debug.LogError("Mesh must be set to readable before baking curvature."); return; } Vector3[] vertices = mesh.vertices; Vector3[] normals = mesh.normals; // 顶点色不够长就重新建一个 Color[] colors = mesh.colors; if (colors == null || colors.Length != vertices.Length) colors = new Color[vertices.Length]; else // 保留原顶点色的RGB,只把R通道覆盖为曲率 for (int i = 0; i < colors.Length; i++) colors[i] = new Color(0, colors[i].g, colors[i].b, colors[i].a); // 邻接表:每个顶点连接到的顶点索引列表 List<int>[] adjacency = new List<int>[vertices.Length]; for (int i = 0; i < vertices.Length; i++) adjacency[i] = new List<int>(); int[] tris = mesh.triangles; for (int i = 0; i < tris.Length; i += 3) { AddEdge(adjacency, tris[i], tris[i + 1]); AddEdge(adjacency, tris[i + 1], tris[i + 2]); AddEdge(adjacency, tris[i + 2], tris[i]); } for (int i = 0; i < vertices.Length; i++) { float sumDot = 0f; int count = 0; for (int j = 0; j < adjacency[i].Count; j++) { int n = adjacency[i][j]; sumDot += Vector3.Dot(normals[i], normals[n]); count++; } float avgDot = count > 0 ? sumDot / count : 1f; // 法线越不平行,avgDot 越小,曲率越大 float curvature = Mathf.Clamp01(1f - avgDot); colors[i].r = Mathf.Lerp(colors[i].r, curvature, blendAmount); colors[i].g = colors[i].r; colors[i].b = colors[i].r; } mesh.colors = colors; } void AddEdge(List<int>[] adj, int a, int b) { if (!adj[a].Contains(b)) adj[a].Add(b); } }脚本里的邻接表用List.Contains判断重复,在几万顶点的网格上跑会有性能浪费,但烘焙是一次性的,没必要优化到底。如果你在脚本上遇到过UV被冲掉的问题,注意赋值colors之前先把旧顶点色备份,或者直接把R、G、B都覆盖成曲率,低频信息足够用了。
3.3 Shader框架搭建
接下来就是Shader部分。我选择用Surface Shader来写,理由很简单:Unity内置管线里Surface Shader能快速接入各种光源类型、阴影和全局光照,我们只需要实现一个光照模型函数,省去大量手写顶点/片元Shader的样板代码。
先声明Properties块,把需要用到的变量都暴露出来:
Shader "Custom/FastSSS" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "bump" {} _GlossMap ("Gloss (R)", 2D) = "white" {} _ScatterColor ("Scatter Color", Color) = (1.0, 0.4, 0.3, 1.0) _ScatterStrength ("Scatter Strength", Range(0, 3)) = 1.2 _TransmissionColor ("Transmission Color", Color) = (1.0, 0.3, 0.25, 1.0) _TransmissionStrength ("Transmission Strength", Range(0, 3)) = 1.5 _TransmissionPower ("Transmission Power", Range(1, 8)) = 4.0 _WrapAmount ("Wrap Amount", Range(0, 1)) = 0.5 _Gloss ("Gloss Power", Range(0.5, 8)) = 2.5 _SpecStrength ("Spec Strength", Range(0, 2)) = 0.8 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf FastSSS fullforwardshadows #pragma target 3.0 ... } }这里我把散射颜色和透射颜色分开,一个是边缘泛光色,一个是背光透射色。对于皮肤来说两者相近,都是偏红偏暖;如果是做树叶,散射色可以偏黄绿,透射色偏亮绿,效果会很有生命力。
4. 核心Shader实现:快速SSS完整代码与逐段拆解
4.1 核心光模型代码
下面这段是完整的Surface Shader光照模型部分,逻辑全写在LightingFastSSS函数里。我先把完整的代码放出来,再逐段讲为什么这么写。
sampler2D _MainTex; sampler2D _NormalMap; sampler2D _GlossMap; fixed4 _ScatterColor; half _ScatterStrength; fixed4 _TransmissionColor; half _TransmissionStrength; half _TransmissionPower; half _WrapAmount; half _Gloss; half _SpecStrength; struct Input { float2 uv_MainTex; float2 uv_NormalMap; float2 uv_GlossMap; float4 vertexColor : COLOR; float3 viewDir; }; void surf(Input IN, inout SurfaceOutput o) { fixed4 albedo = tex2D(_MainTex, IN.uv_MainTex); o.Albedo = albedo.rgb; fixed3 normal = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap)); o.Normal = normal; o.Gloss = tex2D(_GlossMap, IN.uv_GlossMap).r; // 顶点色的R通道作为“薄度”,0为厚,1为薄 float thinness = saturate(IN.vertexColor.r); o.Alpha = thinness; } inline half4 LightingFastSSS(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { half3 normal = normalize(s.Normal); half3 lightColor = _LightColor0.rgb; // 1. Wrap漫反射:让明暗过渡更柔和 half ndl = saturate(dot(normal, lightDir)); half wrap = saturate((ndl + _WrapAmount) / (1.0 + _WrapAmount)); half diff = wrap * wrap; // 二次映射让衰减更自然 // 2. Blinn-Phong高光 half3 halfDir = normalize(lightDir + viewDir); half ndh = saturate(dot(normal, halfDir)); half spec = pow(ndh, _Gloss * 32.0) * _SpecStrength * s.Gloss; // 3. 透射光:背光穿透过薄区域 half transDot = saturate(dot(viewDir, -lightDir)); half trans = pow(transDot, _TransmissionPower) * _TransmissionStrength; half3 transCol = _TransmissionColor.rgb * trans * s.Alpha; // 4. 边缘散射:视角与法线方向垂直时,薄区域泛光 half rim = pow(1.0 - saturate(dot(viewDir, normal)), 3.0); half3 scatterCol = _ScatterColor.rgb * rim * s.Alpha * _ScatterStrength; half3 color = lightColor * (s.Albedo * diff + spec) + transCol + scatterCol; return half4(color * atten, 1.0); } ENDCG } FallBack "Diffuse" }4.2 关键公式逐个讲透
先看Wrap漫反射。普通的lambert漫反射是NdotL,光照方向一转,明暗切换特别生硬。Wrap Lighting的思路是把NdotL从原来的映射范围扩展一下,让光照在物体转向背面时仍然保留一小段衰减,而不是瞬间跌到零。公式里的_WrapAmount控制在0到1之间,_WrapAmount越大,暗部越亮,皮肤就越“透”。
再看透射光。dot(viewDir, -lightDir)这个式子理解起来有点绕,我拆开说。viewDir是视线方向,-lightDir是光线反方向。当视线方向正好和光线反方向重合时,你看到的是光线穿过物体朝镜头射来的效果,也就是“对着光源看透光”;当两者垂直时,透射几乎不可见。用这个点积作为透射的依据,再叠加厚度信息s.Alpha,就实现了“薄的地方才透光”的效果。
边缘散射部分,我用的是1 - dot(viewDir, normal),这是经典Rim Light公式。视角与法线越接近90度,边缘越亮,再乘上薄度因子s.Alpha,让散射主要集中在凸起边缘。这个公式跟透射光有区别:透射光依赖光源方向,主要用于背光场景;边缘散射依赖视角方向,无论光从哪来,轮廓处都会有点泛光,更接近皮肤在环境光下的“通透感”。
4.3 如何把材质调到像皮肤
代码写完后,材质面板上会有十几个参数。我一般按顺序调,别一上来就乱扭:
- 先把_WrapAmount调到0.5左右,看整体明暗过渡是否柔和。再往大调,模型会像在冒浓雾,所以要适量。
- 调_TransmissionStrength。背光下耳廓和鼻翼应当出现透光,亮度比周围肤色明显高一个层级。TransmissionPower控制过渡的锐利程度,数值越低,透光范围越大。
- 调_ScatterStrength,让轮廓和曲面转折的地方微微泛红。皮肤散射色用(1.0, 0.4, 0.3),树叶可以换成(0.3, 0.8, 0.3)。
- 最后调高光。皮肤的Gloss值不要调太高,嘴唇、鼻子尖可以有比较强的点高光,但两颊的高光要弱。Gloss通道贴图就是干这个用的。
注意,透射光和散射光的位置可能会重叠,导致某些区域偏亮。这种情况下我会把Transmission Strength稍微降一点,同时调小ScatterStrength的Rim Power,让它更集中地锁在轮廓线附近。
5. 参数调优与移动端性能注意事项
5.1 参数速查表
材质调参是我见过美术新手翻车最多的地方。整理了一份速查表,照着调基本能稳住:
| 参数 | 皮肤推荐值 | 蜡烛推荐值 | 树叶推荐值 | 效果说明 |
|---|---|---|---|---|
| Wrap Amount | 0.4-0.6 | 0.6-0.8 | 0.4-0.5 | 越大暗部越亮,过大会发灰 |
| Transmission Strength | 1.0-1.8 | 2.0-3.0 | 1.5-2.5 | 背光透射的整体强度 |
| Transmission Power | 3-5 | 2-3 | 2-4 | 越小透光扩散范围越大 |
| Scatter Strength | 0.8-1.5 | 1.0-2.0 | 1.2-2.0 | 边缘泛光强度 |
| Gloss Power | 2-4 | 1-2 | 1-2 | 高光聚集程度,皮肤不要太高 |
| Spec Strength | 0.5-1.0 | 0.3-0.8 | 0.3-0.6 | 镜面高光的整体强度 |
做蜡烛的话可以让TransmissionStrength拉到2以上,蜡烛在背光下几乎是整根透亮,再配合漫反射暗部偏暖色,效果很有氛围感。做树叶可以调低WrapAmount,让叶脉区域保留更多明暗对比,散射色选亮柠檬黄,颜色会很清新。
5.2 移动端性能优化
这套方案在移动端之所以友好,是因为它把所有散射计算都放在一个光照模型函数里,没有额外Pass,没有全屏后处理,没有深度相机。Shader的指令数取决于你开了多少特性,就这套基础版而言,跑在骁龙7系和天玑8000级别的设备上毫无压力,真机Profile基本可以忽略不计。
不过有几个优化习惯要保持:
- 目标平台不用OpenGL ES 2.0的话,target 3.0是稳妥的选择。
- 高光计算里的pow(ndh, _Gloss * 32.0),如果_Gloss很高,低端GPU可能有一点点压力。实际项目里把_Gloss限制在10以内,就用32倍乘数,高光范围足够细腻。
- 如果在URP管线里用这套方案,注意LightingFastSSS函数是内置管线的写法,URP需要改成LightingFastSSS_float并配合HLSL。如果你是用Shader Graph,可以直接用Custom Function节点封装核心公式。
5.3 和阴影系统配合的坑
快速SSS最容易出问题的是和阴影搭配。默认Surface Shader已经支持fullforwardshadows,所以阴影本身没有问题。问题出在透射光上:主光源被挡住时,背面产生的透射光会让角色像“漏光”,因为我们的透射项不感知阴影。
解决方案有两种。一种是在Lighting函数里用atten乘掉透射光中的主要光源贡献,但这会把背光透射也削弱,因为主光源被挡住时其实背光强了。另一种更实用的做法是给透射项增加一个独立的距离衰减,让近距离的透射更明显,远距离时减小强度,模拟光强随距离衰减的直觉。
我个人推荐的做法是保留atten对透射光的影响但不做全乘,而是乘一个0.5的权重,再叠加一个环境光带来的微弱透射。这样主光源被遮挡时依然有一层淡淡的透射,既不会死黑,也不会漏光。
6. 常见问题与排查技巧实录
6.1 问题速查表
实际项目里总会碰到各种画面问题,我把高频问题整理成了表格,方便你遇到时直接对号入座:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 整个模型像在发光 | 散射强度和透射强度太高 | 降低Scatter Strength和Transmission Strength,检查是否顶点色全白 |
| 透射光只在背光时出现,但效果很生硬 | TransmissionPower太高 | 降低TransmissionPower,从6降到3会有明显改善 |
| 边缘没有泛红 | 顶点色Curvature烘焙失败或没有导入 | 检查Mesh是否Readable,确认Shade模式顶点色面板有数据 |
| 高光像金属 | GlossPower过高或Gloss图过白 | 把GlossPower降到3以下,用Gloss图控制局部高光 |
| 背光时暗部一片死黑 | 没启用透射,或者透射Power过高 | 确认Lighting函数里trans项存在,适当调低Power |
| URP下材质完全变紫 | Surface Shader在URP下不直接兼容 | 改用URP的Custom Function或者Shader Graph复刻 |
6.2 我踩过的三个坑
第一个坑是顶点色烘焙后模型变紫色。原因是我在C#脚本里重建colors数组时,直接把Color数组new出来,因为未赋值的Color默认为0,0,0,0,在Shader里如果错误地用顶点色R当Alpha混色,就可能导致半透明黑。后来我把顶点色初始化改成先赋值白色,再覆盖R通道,问题立刻消失。
第二个坑是背光透射把整根脖子变成一个亮条。头颈部网格曲率高,顶点色R会偏白,套用透射公式后整个脖子都发光。解决办法是在模型上限制曲率的作用范围:要么在顶点色R通道上限值,要么在Shader里用saturate缩紧权重。我现在习惯在脚本里对curvature做一个pow映射,让大多数区域保持暗色,只有真正尖锐的凸起才高亮。
第三个坑是插件材质和顶点色不兼容。项目用了一些第三方着色器,不支持顶点色,导致同一个模型换材质后皮肤又变回塑料。这时需要统一约定:SSS材质必须用顶点色R通道,普通材质直接忽略。最好在材质命名和检入流程上做好规范,否则团队协作时容易互相覆盖。
6.3 拓展:从皮肤到蜡烛、玉石、树叶
快速SSS的价值在于同一套思路可以覆盖多种材质。做蜡烛时,把漫反射贴图换成一盏半透明白的纹理,TransmissionStrength拉到2.5,TransmissionPower调成2.5,再把手绘厚度图弄成整体偏白,只在烛芯周围留一点黑,点燃状态下那圈内透光非常迷人。
做玉石,关键是把WrapAmount调高到0.7左右,让暗部保持足够的亮度,同时传射Colors偏青绿,透射Power降到2,这样翻转玉石时你会看到一种“光在里面游”的通透感,比纯Lambert散射真实很多。
做树叶,重点在双面渲染和透射。叶子一般只用两个面,把材质设置里Cull Off打开,让叶片内部也能接受到散射光。散射色用亮黄绿,TransmissionStrength中等,配合Wind动画,阳光下树叶整体会有种嫩生生的鲜活感。
做怪物皮肤是这套方案最过瘾的场景。你可以在厚度图上画上筋脉、伤疤、辐射纹理,把这些部位画成白色,受伤区域会透着光,配合动画和粒子特效,压迫感直接拉满。
在实际项目中最后的几点体会
这套快速SSS我前后用了三四个项目,从二次元角色到写实怪物都试过。最大的感受是:它不是一个以假乱真的物理方案,而是一个性价比极高的“视觉方案”。阈值把握好,观众根本不会注意到你的散射是假的,也不会管你的透射是不是严格符合光学定律。反而那些追求物理正确但性能撑不住的项目,最后都得在参数和平台之间做大量妥协,反而更难看。
如果你想在现有基础上继续深挖,我建议下一步可以尝试把这套光照模型接入自定义阴影,比如用两层的Shadow Map实现次表面遮挡,或者加一个视差厚度贴图来增强轮廓感。最后再分享一个小技巧:所有散射相关的参数,都尽量做进材质属性里,千万别写死。因为同一套Shader要服务截然不同的美术资产,写死一个参数等于堵死了很多效果的可能性。