Unity快速次表面散射SSS:基于曲率与厚度的移动端皮肤渲染
做了几年Unity渲染皮肤材质一直是个绕不开的坎。角色模型明明做得挺精细一打光就像上了层清漆的塑料尤其是耳廓、鼻翼、指缝这些透着薄肉的部位黑得死死的怎么调都缺那股“活人气”。其实问题不在贴图而在光照模型没处理次表面散射Subsurface ScatteringSSS。这篇文章我想聊聊一种性价比极高的快速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 GetComponentMeshFilter().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); // 邻接表每个顶点连接到的顶点索引列表 Listint[] adjacency new Listint[vertices.Length]; for (int i 0; i vertices.Length; i) adjacency[i] new Listint(); 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(Listint[] 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 { RenderTypeOpaque } 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 Amount0.4-0.60.6-0.80.4-0.5越大暗部越亮过大会发灰Transmission Strength1.0-1.82.0-3.01.5-2.5背光透射的整体强度Transmission Power3-52-32-4越小透光扩散范围越大Scatter Strength0.8-1.51.0-2.01.2-2.0边缘泛光强度Gloss Power2-41-21-2高光聚集程度皮肤不要太高Spec Strength0.5-1.00.3-0.80.3-0.6镜面高光的整体强度做蜡烛的话可以让TransmissionStrength拉到2以上蜡烛在背光下几乎是整根透亮再配合漫反射暗部偏暖色效果很有氛围感。做树叶可以调低WrapAmount让叶脉区域保留更多明暗对比散射色选亮柠檬黄颜色会很清新。5.2 移动端性能优化这套方案在移动端之所以友好是因为它把所有散射计算都放在一个光照模型函数里没有额外Pass没有全屏后处理没有深度相机。Shader的指令数取决于你开了多少特性就这套基础版而言跑在骁龙7系和天玑8000级别的设备上毫无压力真机Profile基本可以忽略不计。不过有几个优化习惯要保持目标平台不用OpenGL ES 2.0的话target 3.0是稳妥的选择。高光计算里的powndh, _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项存在适当调低PowerURP下材质完全变紫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.5TransmissionPower调成2.5再把手绘厚度图弄成整体偏白只在烛芯周围留一点黑点燃状态下那圈内透光非常迷人。做玉石关键是把WrapAmount调高到0.7左右让暗部保持足够的亮度同时传射Colors偏青绿透射Power降到2这样翻转玉石时你会看到一种“光在里面游”的通透感比纯Lambert散射真实很多。做树叶重点在双面渲染和透射。叶子一般只用两个面把材质设置里Cull Off打开让叶片内部也能接受到散射光。散射色用亮黄绿TransmissionStrength中等配合Wind动画阳光下树叶整体会有种嫩生生的鲜活感。做怪物皮肤是这套方案最过瘾的场景。你可以在厚度图上画上筋脉、伤疤、辐射纹理把这些部位画成白色受伤区域会透着光配合动画和粒子特效压迫感直接拉满。在实际项目中最后的几点体会这套快速SSS我前后用了三四个项目从二次元角色到写实怪物都试过。最大的感受是它不是一个以假乱真的物理方案而是一个性价比极高的“视觉方案”。阈值把握好观众根本不会注意到你的散射是假的也不会管你的透射是不是严格符合光学定律。反而那些追求物理正确但性能撑不住的项目最后都得在参数和平台之间做大量妥协反而更难看。如果你想在现有基础上继续深挖我建议下一步可以尝试把这套光照模型接入自定义阴影比如用两层的Shadow Map实现次表面遮挡或者加一个视差厚度贴图来增强轮廓感。最后再分享一个小技巧所有散射相关的参数都尽量做进材质属性里千万别写死。因为同一套Shader要服务截然不同的美术资产写死一个参数等于堵死了很多效果的可能性。