先说明一点,标题里的“快速”加引号,我觉得特别贴切。真实的次表面散射(Subsurface Scattering,SSS)在渲染里是个很重的效果,尤其是皮肤、蜡烛、树叶这类半透明材质,光线进入表面内部再散射出来,那种通透感是普通漫反射贴图很难模拟的。做全屏散射成本太高,移动端基本不用想。所以这个Fast SSS的思路,本质是找一个视觉上够用的近似方案,在性能和效果之间取一个平衡。这篇文章我会把实现思路、核心参数、完整Shader代码和踩过的坑都拆开讲,适合已经在用Unity Shader写基础光照、但对SSS只有概念没动过手的同学。
1. 内容整体设计与思路拆解
1.1 为什么需要“快速”次散射方案
先聊聊为什么我们不能直接上PBR那套完整的次表面散射模型。真正的SSS需要计算光线在材质内部多次散射后的出射分布,业界常见做法是屏幕空间的SSSSS(Screen-Space Subsurface Scattering),先渲染出带光照的场景,再在屏幕空间做高斯模糊来模拟光在皮肤下的扩散。效果确实好,但它的开销在于:
- 需要单独渲染一张屏幕空间的散射图,分辨率跟屏幕一致;
- 要做两次甚至多次高斯模糊,采样次数多;
- 内存带宽、GPU占用都往上走。
这个成本放在PC端可以接受,但放到移动端或者需要大量角色同屏的场景(比如手游里的NPC、怪物群),帧率压力就非常明显。我做过的项目里,一个场景20个角色用全屏SSSSS,帧率直接从60掉到40,优化到后面只能砍角色数量,项目策划那边天天催,说真的挺头疼。
Fast SSS的思路完全不一样。它不是物理正确的散射模拟,而是用光照模型里的一个近似技巧来“骗”过眼睛——光泽度、半角偏移、阴影渐变一起配合,让物体看起来像是光线在表面内部透射出来。它不是真实计算散射路径,而是伪造出那种光晕、透光、过渡柔和的效果。只要参数调得准,在90%的日常渲染场景里,肉眼几乎分不出和全屏SSS的差别。
1.2 方案选型:为什么不用次表面轮廓透射(Subsurface Translucency)
做SSS的时候,很多人第一反应是去查“透射”相关方案,比如透射贴图、次表面透射材质,或者看Unity的HDRP里那个Subsurface Scattering材质。但我个人建议,如果只是做“角色皮肤看起来有血色”“树叶透光”“蜡烛耳廓发亮”这种程度的视觉效果,不要直接上完整的透射方案。
原因有两方面:
第一,完整透射方案需要额外的透射贴图或者厚度贴图来驱动,涉及美术制作成本。你要让TA去出图、调参数,不是每个项目都有这个时间。
第二,现有的一些透射效果在移动端做了很多近似,但本质上还是需要额外的采样和计算。而Fast SSS只需要在现有的漫反射光照模型里改几行代码,额外开销几乎可以忽略。
我经常跟别人打比方:完整SSS是拍电影,需要好的演员、灯光、后期;Fast SSS是舞台剧,灯光道具用得好,效果也能很沉浸。关键是看你的舞台有多大——对大多数项目来说,Fast SSS绝对够用。
1.3 技术原理:在BRDF层面“作弊”
Fast SSS最常见的一种实现方式,是基于“Wrap Lighting”的变体。正常情况下,漫反射光照用NdotL(法线方向点乘光照方向)来计算,当NdotL小于0时,物体直接进入阴影,边缘特别生硬。而Wrap Lighting把NdotL做了一个偏移(Wrap)处理:
[ NdotL_{wrap} = max(0, \frac{NdotL + wrap}{1 + wrap}) ]
这里的wrap是一个0到1之间的参数。当wrap=0.2时,原本NdotL从1到-1的范围被压缩到1到-0.2,也就是说光照不会在法线半球的边界处直接消失,而是往后“包裹”了一小段距离,边缘会有渐变而不是一刀切。
利用这个Wrap Lighting之后,再用一个幂运算(比如pow(light, scatteringPower))来控制散射的衰减速度,就能模拟出光在材质内部扩散、逸出的感觉。再配合视角方向、阴影信息,可以让遮蔽区域里面透出一点被光“穿透”的颜色,效果会更接近真实的次表面散射。
2. 核心细节解析与实操要点
2.1 最重要的三个参数:Wrap值、散射强度和散射颜色
先明确一点,Fast SSS并不复杂,但能不能调出好看的效果,全看参数理解。
第一个参数是Wrap值,它决定了光“包裹”的宽度。Wrap值越大,整个物体受光的范围就越大,阴影区域越少,但光感会越“平”。我个人建议数值范围控制在0.2到0.4。小于0.2效果跟普通漫反射几乎没区别,大于0.4整个模型容易变成灰蒙蒙的一片,丢失立体感。
第二个是散射强度,这个参数在代码里表现为幂运算的指数数值,我习惯叫它scatterPower。它控制的是光衰减到0的速度。数值在2到8之间,数值越高,边缘过渡越锐利,光晕越集中在明暗交界处;数值越低,散射范围越大,但整体会偏灰。
第三个是散射颜色,这很好理解。皮肤通常是偏红色,因为光线穿透表皮和真皮时红光波长最长,穿透力最强,所以你会看到皮肤在背光边缘透出红色。树叶是偏黄绿色,蜡烛耳朵是亮黄色,具体要看你做的是什么材质。
2.2 处理实时阴影与SSS的互动
这个点特别容易踩坑:当你给角色开了阴影,用Fast SSS之后,阴影区域和光照过渡区域会出现很明显的断层。阴影内部是纯黑,阴影边缘却透出光晕,看起来特别假。
原因在于:直接光照经过Wrap Lighting之后,原本的明暗分界线变成了渐变过渡,但阴影贴图(Shadow Map)里的采样结果仍然是二值的——在阴影里就是0,在阴影外就是1,光线从0到1的突变让Wrap的效果全部失效。
解决办法有几种,为了“快速”,我推荐给阴影采样加一层一定范围的渐变软化,也就是对ShadowCoord做一次平滑:
float shadow = saturate(shadowMap * fresnel + (1 - fresnel) * shadowSoftness);这个思路是:阴影边缘根据视角方向上菲涅尔效应的强度做插值,让阴影边缘有一定的柔和度。不要直接在阴影里用硬切光,否则Fast SSS的效果会大打折扣。
2.3 透光效果增强:厚度估算的简单做法
真实SSS里,光线能不能穿透材质取决于厚度。耳朵、手掌、叶片边缘比较薄,所以透光更强。做Fast SSS时,很多人会忽略这一点,整个模型用统一参数,结果就是所有部位出现同样的光晕,看起来不像皮肤,像是塑料上了层油。
这里有一个很方便的近似做法:用模型背面的法线方向来模拟厚度(就像正常渲染时背面法线朝里,可以把背面法线转换到世界空间再取负)。用当前片元的法线和光线方向的夹角来判断:
float thickness = saturate(dot(-lightDir, normalWS) * thicknessScale + thicknessBias);当这个数值越接近0,说明光线方向跟法线垂直,通常意味着材质边缘;越接近1,说明法线正对光源,基本上就是受光最强的地方。用这个thickness值去控制散射强度,可以让边缘透光更明显,中间区域保持常规漫反射。
如果你有次表面散射贴图或者AO贴图,也可以用它来替代这个近似厚度值,效果会更可控。
2.4 纹理采样的选择与注意事项
在实际项目中,Fast SSS的材质大概率会用到主纹理、法线贴图、高光贴图等。在做SSS效果时,有一个常被忽略的点:主纹理的Alpha通道是否要参与计算。
很多人把Alpha通道留给了平滑度或者金属度,但当启用了_ALPHABLEND_ON做半透明透光时,Alpha会和SSS的厚度估算冲突,导致边缘出现多余的透明或发亮。
我建议做皮肤、叶子这类材质时,不要开半透明混合,用“Alpha Test”或者直接不透明。用透贴也可以,但阈值要调得比较高。总之,别让透明混合的Alpha值波动牵扯到SSS的厚度计算,否则调试时期你会非常崩溃。
3. 实操过程与核心环节实现
3.1 环境准备与Shader结构拆解
我这边用的环境是Unity 2021.3 LTS,内置渲染管线,URP下也是一样可以改,后面会提。先建一个Unlit Shader,然后自己把光照模型加上去,这样最干净,不会受Unity自带Standard的干扰。
Shader的整体结构分几个部分:
- Properties:定义主纹理、法线贴图、光照参数(Wrap值、散射强度、散射颜色等);
- SubShader:Pass里定义Tags(比如LightMode=ForwardBase);
- 顶点/片元着色器:实现Fast SSS光照计算,处理阴影、视角等。
3.2 完整Shader代码与逐段解析
下面这个是我在项目里实际用过的精简版本,默认走Forward渲染路径,单盏平行光,支持阴影。想接URP的话,把UnityObjectToClipPos换成TransformObjectToHClip,UnityWorldSpaceLightDir换成GetMainLight系列函数即可。
Shader "Custom/FastSSS" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "bump" {} _SSSColor ("Scatter Color", Color) = (0.8, 0.2, 0.2, 1.0) _WrapValue ("Wrap Amount", Range(0.1, 0.9)) = 0.3 _ScatterPower ("Scatter Power", Range(1.0, 12.0)) = 4.0 _ShadowSoftness ("Shadow Softness", Range(0.0, 1.0)) = 0.3 _ThicknessScale ("Thickness Scale", Range(0.0, 1.0)) = 0.5 } SubShader { Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include "UnityCG.cginc" #include "AutoLight.cginc" sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NormalMap; float4 _SSSColor; float _WrapValue; float _ScatterPower; float _ShadowSoftness; float _ThicknessScale; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; float4 tangent : TANGENT; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 posWS : TEXCOORD2; SHADOW_COORDS(3) }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.normalWS = UnityObjectToWorldNormal(v.normal); o.posWS = mul(unity_ObjectToWorld, v.vertex).xyz; TRANSFER_SHADOW(o) return o; } fixed4 frag (v2f i) : SV_Target { float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.posWS); float3 normalWS = normalize(i.normalWS); // Fast SSS diffuse term float ndl = dot(normalWS, lightDir); float wrap = (ndl + _WrapValue) / (1 + _WrapValue); float scatter = pow(saturate(wrap), _ScatterPower); float sssIntensity = saturate(1 - saturate(dot(-lightDir, normalWS)) * 0.5 + 0.5); // Thickness approximation float thickness = saturate(dot(-lightDir, normalWS) * _ThicknessScale + 0.5); // Shadow with soft edge float shadow = SHADOW_ATTENUATION(i); shadow = saturate(shadow + (1 - shadow) * _ShadowSoftness); // Combine float3 albedo = tex2D(_MainTex, i.uv).rgb; float3 scattering = _SSSColor.rgb * scatter * thickness * shadow; float3 diffuse = albedo * max(0.1, wrap) * shadow; float3 finalColor = diffuse + scattering; return float4(finalColor, 1.0); } ENDCG } } }先解释一下几个关键计算公式为什么这么写。
float wrap = (ndl + _WrapValue) / (1 + _WrapValue);这是前面提到的Wrap Lighting标准公式。当ndl=-0.3、_WrapValue=0.3时,分子是0,分母是1.3,wrap=0,刚好是不会亮的位置。这个偏移量让阴影区域向光源方向“让”了一点距离,过渡更软。
float scatter = pow(saturate(wrap), _ScatterPower);为什么散射要用幂运算?因为我们希望光在材质内部扩散时,靠近直接光照区域的部分衰减慢一点,远离的部分衰减快一点。pow函数天然能做这个非线性映射。_ScatterPower=4时,wrap=0.5时scatter=0.0625,衰减极快;wrap=0.9时scatter=0.6561,偏亮但离暗部远。如果你的模型边缘想要更透,可以把_ScatterPower降到2左右。
关于thickness的近似,我的写法是:
float thickness = saturate(dot(-lightDir, normalWS) * _ThicknessScale + 0.5);当光线方向和法线方向正好垂直(比如模型边缘)时,dot结果为0,加上0.5就是0.5,不算特别薄。如果想强调边缘透光,可以把Bias调小一点,让整体数值往0偏,散射更强。
3.3 在Unity里的接入步骤与效果调试
跟我之前项目里的操作一样,按下面几步来就可以复现:
- 在Project窗口右键 -> Create -> Shader -> Unlit Shader,把上面代码粘贴进去保存。
- 右键 -> Create -> Material,把Shader换成Custom/FastSSS。
- 给场景里建一个默认的Unity球体(Sphere),把材质拖上去。
- 场景里建一盏平行光,开启阴影(Shadow Type选Soft Shadows)。
- 运行,调整Inspector面板上的_WrapValue、_ScatterPower和_SSSColor。
调试的时候我有几个习惯:
- 先用纯色贴图调参数,不要一上来就用带纹理的皮肤,否则会分不清是纹理明暗还是SSS光晕。
- 把_ScatterPower从低到高推,观察明暗交界处的过渡带宽度变化。
- 打开Scene视图的线框模式,观察边缘处透光颜色是否均匀,如果出现局部过曝,多半是thickness计算需要调_ThicknessScale。
3.4 URP下的迁移要点
如果你项目用的是URP,别慌,改动量不大。把Shader里的光照相关API换成URP版本就行。核心改动:
UnityObjectToClipPos换成TransformObjectToHClip;- 平行光方向用
GetMainLight()拿,注意里面有light.shadowAttenuation; - 阴影接收用
TransformWorldToShadowCoord配合MainLightRealtimeShadow。
URP里做SSS有个好处:可以借助Scriptable Render Pipeline的渲染特征做更多屏幕空间的微调,但Fast SSS作为代码量极小的光照模型,依旧很实用。
4. 常见问题与排查技巧实录
4.1 突然全黑/全白,多半是方向向量没有归一化
新手特别容易犯的一个问题是,在片元着色器里对lightDir、viewDir、normalWS这些向量做运算时忘了归一化,结果dot值超出[-1,1]范围,pow函数一出问题,画面要么过曝要么全黑。
我之前调一个角色皮肤效果时就遇到过:把法线贴图采样后直接放入计算,没做UnpackNormal,结果整个模型全是噪点,运行起来像黑白电视机雪花。排查半天,发现是法线贴图没有正确解包。
解决办法很简单:用了法线贴图就用UnpackNormal(tex2D(_NormalMap, i.uv)),所有向量参与点乘之前,确保normalize。
4.2 阴影边缘生硬,像贴了块创可贴
这个前面提过,因为阴影贴图是二值的。Fast SSS的Wrap Lighting虽然把明暗过渡做了软化,但阴影采样仍然是硬切。如果你用了上面的代码,把_ShadowSoftness调到0.5以上应该会好转。
如果调到0.8还是生硬,可以考虑对Shadow Coord采样做两次或三次PCF软阴影。不过这是额外开销,移动端慎用。更好用的方式是用屏幕空间软阴影,或者用VSM(Variance Shadow Map)替代硬阴影。
4.3 散射光晕出现在不透明物体内部,边缘没效果
要排查的是你的法线是否正确。如果用内置球体测试,没问题;但用某些模型时,法线可能是软边(Smoothing Groups),边缘过渡特别多,导致dot(-lightDir, normalWS)的值在边缘和中间没太大区别,看起来哪哪都亮。
建议检查Mesh导入设置里的Normals和Tangents,确认是Import或者Calculate(如果是从别的软件导出的,有时候法线会丢失)。把"Normals"设为Calculate,Tangents设为Calculate Tangent Space,可以解决大部分异常。
4.4 移动端性能评估:能省就省
这个Shader在移动端的开销其实非常低,几乎可以忽略不计。主要消耗是阴影采样、Pow函数和纹理采样各一次。但如果要批量渲染大量角色,建议把_ScatterPower、_WrapValue这些参数合并进一个Vector4里,减少uniform参数数量,更有利于合批。
如果你需要在移动端跑大世界,记住一个原则:给SSS材质单独建一个Shader变体,不要跟完整PBR材质混在一起。不然一个渲染队列里既有的材质走复杂PBR、有的走Fast SSS,合批率会非常难看。
4.5 常见问题速查表
| 问题表现 | 可能原因 | 解决办法 |
|---|---|---|
| 模型全白/全灰 | 法线未归一化、Wrap值过大 | 检查向量归一化,_WrapValue控制在0.4以内 |
| 边缘发黑 | 散射强度太低、厚度Bias太高 | 降低_ScatterPower或增大_SSSColor强度 |
| 阴影边缘断层 | ShadowMap硬采样 | 加上阴影软化参数,或换软阴影 |
| 光晕出现在内部 | 法线/厚度计算异常 | 检查法线导入设置,调整_ThicknessScale |
| 移动端帧率骤降 | 阴影采样次数过高 | 降低阴影质量,减少PCF次数 |
| 散射颜色过脏 | 散射颜色和主纹理混合过度 | 降低_SSSColor的Alpha系数或把光照强度和散射分离 |
5. 实际项目中Fast SSS的扩展用法
5.1 角色皮肤:血色透光的正确打开方式
做角色皮肤时,_SSSColor建议调成(0.8, 0.2, 0.15)这种偏向深红的颜色,_WrapValue在0.25到0.35之间,_ScatterPower在3到5之间效果最好。
另外一个技巧是给散射强度乘一个菲涅尔项(Fresnel),让面向视角的皮肤区域散射更强,而边缘散射更弱,这会更接近真实皮肤的在薄边缘和厚中心交替变化的感觉。
5.2 树叶与植物材质
植物的SSS,关键在于“边缘透光”。树叶被阳光照射时,从背面看边缘亮度极高,叶脉透亮。_SSSColor用黄绿色,_ScatterPower可以在5到8之间比较高,让叶片的散射范围变窄,集中在边缘。厚度估算可以配合一张叶脉贴图来用,效果会特别细。
5.3 蜡烛、玉石、冰面等半透明硬材质
这类材质与皮肤的区别是散射范围小、衰减快,且高光比较明显。建议_ScatterPower调到8以上,_SSSColor选择跟材质主色调一致的颜色。比如蜡烛用淡黄色,玉石用浅绿,冰面用淡蓝。高光部分用Blinn-Phong或者GGX叠加一层,会比纯Fast SSS更完整。
5.4 与后处理的配合:Bloom是神器
Fast SSS如果配合Bloom后处理,效果会上一个台阶。SSS在暗部透出的光晕经过Bloom后,会有一种柔和自发光的效果,特别适合做科幻风格的霓虹材质和能量护盾。
我在一个项目里做过能量护盾,直接给材质加了一个强度稍微高一点的SSS散射项,配合Bloom,护盾被击中时边缘亮光异常好看,关键是代码量几乎没有增加。
6. 与真实SSS方案的对比与选型建议
| 方案 | 性能开销 | 效果真实度 | 适用场景 |
|---|---|---|---|
| Fast SSS(本文方案) | 极低,几乎无额外开销 | 视觉近似,缺乏真实透射路径 | 移动端、大批量角色、实时交互 |
| 屏幕空间SSSSS | 高,需要多pass和模糊 | 非常接近真实皮肤质感 | PC端、高保真角色展示、CG级效果 |
| 光线步进SSS | 极高,需要体积计算 | 物理正确,可用于电影级渲染 | 离线渲染、高端Demo |
| 透射贴图+深度偏移 | 中低,需要美术出图 | 可控性强,但制作成本高 | 需要特写的角色/物体 |
选型建议很简单:如果你只需要视觉上“像”SSS,Fast SSS是首选,别犹豫。如果你要做角色面部特写、皮肤质感成为核心卖点,那可以考虑全屏SSSSS。一般项目里先用Fast SSS,出问题了再升级,性价比最高。
7. 写在最后的实操总结
文章写到这,我不太想用那种“综上所述”的收尾方式,就说一点个人体会。
Fast SSS这个方案我从一开始做就被它“骗”过,以为它只能做一个廉价的伪造,直到有次在项目里用它做角色皮肤,导演在屏幕上看了半天,说了一句“这质感不错,怎么做的”,我才意识到它对大多数人来说已经足够真实了。
它真正的价值在于:用极低的成本让材质有了“生命感”,而不是冷冰冰的塑料反射。要记住,参数一定要自己亲手调过才能理解,你看我再多的数值建议,都不如自己在场景里把_WrapValue从0.1拖到0.9,感受那一瞬间的光影变化来得实在。
如果在调试过程中遇到什么问题,欢迎在评论区一起交流。我见过有人用这个方案做怪物皮肤、做剧情动画里的光线角色、做科幻载具的能量管线,每一个都很有巧思。剩下的,就交给你手里的项目了。