资讯动态

Unity Shader技巧:移动端伪次表面散射模拟实战

发布时间:2026/10/21 16:30:35 来源:尧图企业网站定制
1. 为什么我要写一篇“假”的次表面散射先聊点实在的。在Unity里做皮肤、蜡烛、树叶、大理石这类材质时大家第一反应都是上真正的次表面散射Subsurface ScatteringSSS——就是光打进去之后在物体内部散射、再从其他地方溢出来的效果。手放在手电筒后面会透出红光蜡烛耳朵被光一照会亮得发透这些都是典型的次表面散射。但真SSS术贵啊。无论是预积分皮肤还是屏幕空间散射随便一套方案下来要么是纹理烘焙复杂要么是后处理Pass多到手机扛不住。我自己最开始做项目时跟很多朋友一样直接上了一个完整的SSS方案结果真机帧率掉得惨不忍睹。后来才意识到很多时候我们并不需要物理上“正确”的散射只需要让观众“看过去像那么回事”就行。这就是伪次表面散射模拟的出发点。这篇文章我想分享的就是一套我自己在手机端项目里验证过、成本可控、效果又很能打的Unity Shader方案。它的核心思路是用“厚度 背面光照 半透明混合”来伪造出光穿透物体的感觉。听起来不复杂但真正做好看、做自然中间有不少坑。我尽量把每一步的原理和参数都讲透适合正在做风格化渲染、或者想在移动端做低成本皮肤/植物材质的朋友参考。2. 方案选型为什么是“伪SSS”而不是真SSS2.1 先搞清楚真SSS是怎么算的我们首先要理解真实次表面散射的物理过程光线进入物体表面后并不会立刻反射出来而是在材质内部跟微粒反复碰撞、被吸收、再改变方向最终从表面的另一个点射出。能量在这个过程中会被“抹开”——所以皮肤看起来柔和关节处会泛红蜡烛耳朵边缘会透光。物理上描述这个过程靠的是双向散射表面反射分布函数BSSRDF它比普通的BRDF多了一个“空间偏移”的维度。渲染时需要对散射半径内的多个点做采样和积分计算量非常大。离线渲染器还能接受游戏实时渲染就得靠各种近似。常见的实时近似方案主要有这几种预积分皮肤着色把散射效果预先烘焙到一张查找表LUT里运行时根据“曲率光线方向”查表效果很好但需要预处理网格数据。屏幕空间散射在屏幕后处理阶段做模糊模拟光线在表面下的弥散效果也不错但需要额外的深度和法线信息移动端带宽压力大。基于厚度图的散射预先把模型“由内到外”的厚度烘焙出来运行时用厚度值和光照方向做衰减这是最接近“伪SSS”思路的做法但厚度图需要额外烘焙工具链。这些方案在PC和主机上都有成熟落地案例但放到移动端要么是采样次数太多要么是需要额外的G-Buffer。而伪SSS的思路很直接我们不看复杂的物理只看“光从哪里来、材质有多薄、视线在哪个方向”用这几个变量组合出一个足够可信的散射光。2.2 伪SSS的核心假设伪SSS方案成立依靠的是三个关键假设第一散射光主要从“背面”进入。也就是说当一个物体背对光源时光的入射方向跟视线方向相反散射光会从物体边缘透出来。这正好可以模拟蜡烛耳朵、树叶边缘那种“亮边”。第二散射强度跟厚度强相关。材质越薄的地方光穿透过来的能量越多。这可以用轮廓线、曲率或者预烘焙的厚度贴图来衡量。第三散射光有明显的光谱偏移。物理上短波长的光更容易被散射、长波长的光更容易被吸收所以皮肤内部散射光偏红橙色。这个偏移我们可以直接用一个“透射颜色”来控制甚至不需要物理模拟。基于这三个假设在Shader里模拟散射就变成一个很轻量的光照计算分支检测“光源在背面”的方向关系乘以一个衰减因子再乘上透射颜色。整个过程只需要几次向量点积和一张可选纹理移动端跑起来毫无压力。2.3 效果对比什么场景适合用伪SSS伪SSS并不适合所有场景用之前你得先评估需求。我自己用过之后总结了一张对比表可以帮大家快速判断需求场景真SSS方案伪SSS方案写实人物皮肤近距离特写必须不建议精度不够风格化角色皮肤可选推荐表现力足够蜡烛、灯笼、玉石可用强烈推荐树叶、花瓣等植物可选推荐半透明塑料、磨砂玻璃不适用推荐手机端低中端机型吃力流畅运行用一句话总结就是如果你做的是写实向、需要角色面部特写级别的皮肤渲染老老实实上真SSS方案如果你做的是风格化、或者材质本身就是“半透不透”的质感伪SSS的性价比几乎无敌。3. 从零开始实现伪SSS Shader3.1 准备基础Shader框架我习惯在Unity内置渲染管线下用一个Surface Shader来写这套逻辑然后在URP下单独维护一套HLSL版本。先讲内置管线的版本因为它的代码结构更容易看懂方便大家理解原理。新建一个Shader文件先搭出基础框架Shader Custom/FakeSSS { Properties { _MainTex (Albedo (RGB), 2D) white {} _Tint (Tint, Color) (1,1,1,1) _SSSColor (SSS Color, Color) (0.8,0.2,0.1,1) _SSSPower (SSS Power, Range(1,16)) 4 _SSSStrength (SSS Strength, Range(0,5)) 1 _Transmission (Transmission, Range(0,1)) 0.5 } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 200 CGPROGRAM #pragma surface surf FakeSSS fullforwardshadows sampler2D _MainTex; fixed4 _Tint; fixed4 _SSSColor; float _SSSPower; float _SSSStrength; float _Transmission; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { fixed4 c tex2D (_MainTex, IN.uv_MainTex) * _Tint; o.Albedo c.rgb; o.Alpha c.a; } // 自定义光照函数 half4 LightingFakeSSS (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { // 这里填入散射计算 } ENDCG } FallBack Diffuse }这里我用了Surface Shader的自定义光照函数功能这样可以直接复用Unity的阴影、雾效等管线把精力集中在散射计算本身。3.2 核心散射计算接下来是重头戏怎么算出一个自然的透射光强。先理一下需要的变量法线方向s.Normal光照方向lightDir注意这里用的是指向光源的方向也就是从表面指向光源视线方向viewDir从表面指向相机前面提到散射光主要来自“背面入射”。这里的关键量是“法线与光线的夹角”和“法线与视线的夹角”。当我们看到物体的边缘时视线跟法线接近垂直此时如果光源在物体另一侧光就会从边缘透出来。我的实现思路分两步第一步计算“厚度感”。最简单的方法是用法线与视线的点积float thickness 1.0 - saturate(dot(s.Normal, viewDir));当视线垂直于法线比如观察物体边缘时这个值趋近于1说明我们正对着物体的轮廓边缘透光应该最强。而当视线正对法线看物体正面时这个值趋近于0。第二步计算“光在背面”的程度。用光照方向与法线的点积float backLight saturate(dot(s.Normal, -lightDir));为什么要用-lightDir因为Unity的lightDir方向是“指向光源”的而“光从背面来”意味着光线方向跟法线方向相反。当法线恰好朝光源时dot(s.Normal, lightDir)接近1这时是正面受光背面散射应该非常弱而当法线背对光源时dot(s.Normal, -lightDir)接近1散射最强。把这两部分乘起来float sss backLight * pow(thickness, _SSSPower) * _SSSStrength;这里加了个pow是为了控制厚度衰减的锐利程度。_SSSPower越大散射越集中在轮廓线附近看起来越“锐利”越小散射扩散范围越大看起来越“朦胧”。最终把散射光加到漫反射结果上half4 LightingFakeSSS (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { half NdotL saturate(dot(s.Normal, lightDir)); half3 diffuse s.Albedo * _LightColor0.rgb * NdotL; float thickness 1.0 - saturate(dot(s.Normal, viewDir)); float backLight saturate(dot(s.Normal, -lightDir)); float sss backLight * pow(thickness, _SSSPower) * _SSSStrength; half3 sssColor _SSSColor.rgb * _LightColor0.rgb * sss * _Transmission; half3 finalColor diffuse sssColor; half4 c; c.rgb finalColor * atten; c.a s.Alpha; return c; }这段代码跑起来模型边缘会透出_SSSColor的散射光而且越薄的边缘越亮。3.3 不要忽略的半透明混合细节上面这套方案有个问题如果模型本身是一个不透明体它的“背面”会被遮挡散射光只能出现在轮廓边缘——这其实已经能满足很多需求了。但如果你想让“背面”也透出光来那就需要让背面参与渲染。我常用的做法是给Shader加一个透明混合模式然后渲染两次第一次渲染背面并输出散射光第二次照常渲染正面。这样光就能“穿透”物体从背面溢出来。Unity的Surface Shader里控制这个可以通过设置Blend和Cull实现。注意Surface Shader里其实不好直接写两个Pass我通常是分成两个SubShader或者直接改用顶点片元Shader。这里给一个简化写法SubShader { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Front // 背面Pass只输出散射光 // 正面Pass正常光照少量散射 }背面Pass的Cull Front会剔除正面只渲染朝向相机内部的三角形。这里我用背面Pass输出纯散射光Alpha用厚度控制正面Pass输出正常光照和边缘散射。这样近看会有明显层次远看就是一个半透明的发光体。需要注意透明物体渲染时深度写入要关掉否则物体会挡住自己。但关掉ZWrite可能让物体跟场景其他物体穿插时排序出问题这属于透明渲染的通病一般通过调整Queue来控制。4. 实操过程做一个蜡烛和一片叶子4.1 蜡烛材质的关键参数调优理论说得再多不如直接上手调。我拿了一个蜡烛模型做实验这种“光从内部透出来”的材质对SSS参数极其敏感。先给蜡烛模型赋上上面写的Shader初始参数_SSSColor (0.8, 0.3, 0.1, 1) _SSSPower 4.0 _SSSStrength 1.0 _Transmission 0.6我先把蜡烛放进一个黑暗场景用一个点光源在侧面打光。看效果第一版的问题是散射光太散整个蜡烛看起来像在发光而不是“透光”。这时候把_SSSPower从4提到8散射范围立刻聚拢到蜡烛边缘看起来就自然多了。接下来蜡烛的顶部被火苗照亮的部分应该有最强的透光感。我发现_Transmission调高会让整个物体发亮调低又不够通透。后来我把_Transmission设成0.4加大_SSSStrength到1.5局部补光后效果好了很多。这个组合意味着散射整体强度提高但只出现在很薄的区域反而更像真实的透光。再给蜡烛加一点自发光贴图不然暗面太死板。蜡烛材质里我加了_EmissionMap通道用火苗位置做发光替代了原本需要额外补的光。蜡烛调完之后我有一个感受_SSSPower是这套方案里最关键的参数它决定了“透光带”的宽度其他参数都只是微调亮度和颜色。4.2 叶子材质用半透明玩出透光感叶子跟蜡烛不太一样叶子是平面结构没有蜡烛那种立体厚度。我用了一片双面叶子模型按照下面步骤调第一步把Shader的Cull Off打开让叶片两面都可见。叶子背面的光照在真实世界中非常重要尤其是阳光穿透叶片的时候。第二步调整散射参数。叶子透光颜色应该是黄绿色而不是橙红色。我设_SSSColor (0.6, 0.9, 0.2, 1) _SSSPower 2.0 _SSSStrength 2.0 _Transmission 0.5因为叶片很薄散射范围其实不需要特别窄反而要大面积地透光才有真实感。第三步添加一张“叶片厚度”纹理作为Alpha贴图。叶脉、叶片边缘比较薄透光强叶柄和主叶脉比较厚透光弱。这里我手工画了一张灰度图白色代表薄透光强黑色代表厚透光弱在Shader里用这张图乘以散射强度float thicknessMask tex2D(_ThicknessMap, IN.uv_MainTex).r; sss * thicknessMask;这样透光效果会跟随叶脉分布一下子从“均匀塑料”变成“真实叶片”。这也是伪SSS方案最灵活的地方——你可以用任意美术资源控制散射的“形状”。4.3 参数速查与制作清单调过几个项目之后我整理了一套常用参数参考值方便大家起手时有个数素材类型SSS颜色SSS PowerSSS StrengthTransmission备注蜡烛橙红(0.8,0.3,0.1)8-121.0-1.50.3-0.5散射集中边缘叶片黄绿(0.6,0.9,0.2)1-31.5-2.50.4-0.6配合厚度图风格化皮肤红(0.7,0.1,0.05)3-60.8-1.20.3-0.5用于耳廓、鼻翼玉石绿(0.3,0.8,0.4)4-81.0-2.00.5-0.7配合高光滑度半透明塑料白(0.9,0.9,0.8)2-40.5-1.00.2-0.4漫反射为主注意这些参数是“起点”而不是“终点”。不同光照环境、不同模型比例都需要重新微调。特别是_SSSPower同一个材质在白天场景和夜晚场景可能要用完全不同的值。5. 常见问题与排查技巧实录5.1 散射光完全看不到是怎么回事这个坑我踩过很多次而且多半不是Shader本身的问题。排查顺序如下第一步检查光照方向。散射计算依赖dot(s.Normal, -lightDir)如果物体是正面受光背面散射基本为零这时候你会觉得“没效果”。先把灯转到物体侧后方看边沿有没有亮起来。第二步检查材质球_SSSStrength是否设置得太低。如果低于0.3几乎看不出效果。第三步检查模型是不是用了错误的缩放比例。如果模型整体特别大厚度计算是基于“轮廓接近程度”而非实际物理厚度模型尺寸会影响厚度。同一个Shader小模型看起来透光强大模型看起来透光弱。解决方法是把模型缩放到正常比例或者给厚度计算加一个基于模型缩放的补偿系数。第四步检查Shader里是否有其他代码覆盖了Alpha或颜色输出。特别是很多人会在surf函数里直接给o.Emission赋值这会把散射效果冲淡。5.2 散射区域出现脏脏的“条带”或断痕如果散射光不是连续的一条亮带而是断断续续的斑块大概率是模型法线问题。伪SSS对法线极其敏感因为厚度感完全是靠dot(s.Normal, viewDir)算出来的。解决方法是检查模型法线是否平滑清一下模型上的硬边或者把光滑组重新算一遍。对于低模建议在导入设置里把Normals设为Calculate并且增加Smoothing Angle。还有一种情况是使用法线贴图之后散射效果很杂乱。这是因为法线贴图改变了表面法线导致厚度计算出现高频噪声。如果要用法线贴图建议把散射计算放到“几何法线”即未经过法线贴图修改的法线上float thickness 1.0 - saturate(dot(o.Normal, viewDir));注意这里的o.Normal是Surface Shader里已经处理过的法线。如果你不想受法线贴图影响需要在顶点着色器阶段把法线单独传一份。5.3 透明混合后物体之间排序混乱这个问题的根源是Unity透明渲染的排序机制。关闭ZWrite后透明物体之间会按距离排序但两个交叉的透明物体会出现“忽前忽后”的闪动。我常用的几个办法给同一个模型的不同透明Pass设置不同Queue偏移比如背面Pass用QueueTransparent-1正面Pass用QueueTransparent。如果模型内部有自遮挡用Offset命令调整深度偏移防止z-fighting。最省事的办法进模型内部看的视角不多的话可以直接给模型分两个子Mesh内部透光部分用单独的材质球。5.4 移动端真机闪烁或性能暴跌性能问题主要出在填充率上。透明度混合的Shader通常意味着Overdraw很高如果模型占了屏幕很大面积低端手机会扛不住。我踩过的一个坑是给叶子Shader同时开启了双面渲染 全屏透明结果石头一样的老机型直接掉到20帧。后来改成仅在近景LOD级别使用伪SSS材质远景自动切换成普通不透明材质问题解决。如果一定要在移动端全场景使用建议把散射计算改成“半精度”half运算大幅度减少ALU开销half thickness 1.0 - saturate(dot(half3(s.Normal), half3(viewDir)));替代方案是做一个“LOD Shader”——距离远了就用简化版散射范围用固定值不做逐像素运算。6. 进阶扩展让伪SSS更“真”的几个方向6.1 加入菲涅尔边缘光伪SSS的散射光集中在轮廓边缘这本身跟菲涅尔效应有重合之处。我试过在散射计算上叠加菲涅尔half fresnel pow(1.0 - saturate(dot(s.Normal, viewDir)), 3.0); sss fresnel * _FresnelColor * _FresnelStrength;这样做的直观效果是即使没有光源从背面照过来物体边缘也有一层“空气感”的暖色溢出更像真实皮肤边缘在环境光下的表现。尤其是在风格化角色上加一点菲涅尔散射能让人物跟背景分离得更干净。但要注意别加太多。我之前把菲涅尔强度开到0.8结果整个角色像披了一层金边非常塑料。控制在0.2到0.4之间比较合适。6.2 多光源散射的累加处理前面的Shader只处理了主光源。如果场景里有点光源、聚光灯散射也应该跟着响应。最简单的做法是把LightingFakeSSS改成遍历多个光源但Surface Shader的自定义光照函数对多光源支持不算友好。我后来改成了Vertex Fragment Shader用Unity的UNITY_LIGHT_ATTENATION逐光源累加散射。多光源散射的算法其实跟单光源完全一样只是多循环几次。关键是控制性能一个物体同时受5个光源照射就要跑5次散射计算移动端可能吃不消。我的优化思路是只对最亮的一两个光源计算散射其余光源只走漫反射。6.3 预烘焙厚度贴图前文提到的厚度贴图可以手工画也可以从3D模型直接烘焙。烘焙的原理是从模型每个点的法线方向发射一条射线记录射线在模型内部穿过的距离这个距离就是“厚度”。市面上有一些工具可以自动烘焙实际操作时需要注意模型必须是封闭的开口模型烘焙出来的厚度值会飘。烘焙分辨率一般512或1024就够厚度贴图不需要很高的精度。烘焙结果要尽量平滑避免出现硬边。如果出现硬边可以在Photoshop里做一次轻微模糊。有了厚度贴图之后散射计算可以升级为float thicknessTex tex2D(_ThicknessMap, IN.uv).r; float thickness 1.0 - thicknessTex;用贴图代替几何计算的好处是厚度感不再依赖视角方向即使正视物体表面也能看到透光效果。代价是需要额外一张纹理采样还需要预处理模型。对于做风格化项目的团队这个成本并不高。6.4 把透射色放进光照模型严格来说真实次表面散射的透射颜色跟光源颜色、材质厚度都有关系不应该是一个固定常量。一个比较高级的玩法是把_SSSColor替换成一张“散射颜色渐变图”横轴是厚度、纵轴是入射角度查表得到透射色。我做过一版用的是2D Ramp Texturehalf2 uv half2(thickness, NdotL); half3 sssColor tex2D(_SSSRamp, uv).rgb;这个方案特别适合风格化项目。美术可以直接在Ramp贴图里画“什么厚度、什么光照角度下呈现什么颜色”表现力比固定颜色高一个量级。不过它的前提是要正确理解厚度和角度的映射关系美术需要一些上手时间。7. 我的最终建议与避坑心得做完这套伪SSS方案我在几个项目里反复验证过最大的感受是它不是一个“可以随处套用”的万能Shader而是一套需要针对项目调校的“光影思路”。如果你想在团队里推广这套方案我会建议美术和TA先约定清楚资产规范。比如叶子的厚度贴图画到什么程度、蜡烛的散射颜色用哪个色域、角色耳朵的散射范围有多大。这些看似琐碎的约定决定了最终效果是否统一。另外我特别想强调一个容易被忽略的细节Shader参数命名和范围。很多美术同事看到_SSSPower是0到16的范围会习惯性拖到最大。结果就是一片过曝。后来我把Shader参数范围改成更“直觉化”的0到1再用内部映射把_SSSPower从1映射到16float power lerp(1.0, 16.0, _SSSPowerNormalized);美术拖起来直观代码逻辑也不受影响。最后如果你只是做个个人练习或者小Demo不需要追求真SSS级别的物理正确性。先把这篇文章里的伪SSS方案跑通理解厚度、角度、颜色这三个变量之间的关系再决定要不要往更复杂的方案升级。很多情况下一个调得好的伪SSS已经能撑起整个项目的质感需求了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑