1. 为什么我要写一篇“假”的次表面散射先聊点实在的。在Unity里做皮肤、蜡烛、树叶、大理石这类材质时,大家第一反应都是上真正的次表面散射(Subsurface Scattering,SSS)——就是光打进去之后在物体内部散射、再从其他地方溢出来的效果。手放在手电筒后面会透出红光,蜡烛耳朵被光一照会亮得发透,这些都是典型的次表面散射。但真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 { "RenderType"="Opaque" "Queue"="Geometry" } 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 { "RenderType"="Transparent" "Queue"="Transparent" } 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用Queue=Transparent-1,正面Pass用Queue=Transparent。如果模型内部有自遮挡,用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 Texture:half2 uv = half2(thickness, NdotL); half3 sssColor = tex2D(_SSSRamp, uv).rgb;这个方案特别适合风格化项目。美术可以直接在Ramp贴图里画“什么厚度、什么光照角度下呈现什么颜色”,表现力比固定颜色高一个量级。不过它的前提是要正确理解厚度和角度的映射关系,美术需要一些上手时间。7. 我的最终建议与避坑心得做完这套伪SSS方案,我在几个项目里反复验证过,最大的感受是:它不是一个“可以随处套用”的万能Shader,而是一套需要针对项目调校的“光影思路”。如果你想在团队里推广这套方案,我会建议美术和TA先约定清楚资产规范。比如叶子的厚度贴图画到什么程度、蜡烛的散射颜色用哪个色域、角色耳朵的散射范围有多大。这些看似琐碎的约定,决定了最终效果是否统一。另外我特别想强调一个容易被忽略的细节:Shader参数命名和范围。很多美术同事看到_SSSPower是0到16的范围,会习惯性拖到最大。结果就是一片过曝。后来我把Shader参数范围改成更“直觉化”的0到1,再用内部映射把_SSSPower从1映射到16:float power = lerp(1.0, 16.0, _SSSPowerNormalized);美术拖起来直观,代码逻辑也不受影响。最后,如果你只是做个个人练习或者小Demo,不需要追求真SSS级别的物理正确性。先把这篇文章里的伪SSS方案跑通,理解厚度、角度、颜色这三个变量之间的关系,再决定要不要往更复杂的方案升级。很多情况下,一个调得好的伪SSS已经能撑起整个项目的质感需求了。
1. 为什么我要写一篇“假”的次表面散射先聊点实在的。在Unity里做皮肤、蜡烛、树叶、大理石这类材质时,大家第一反应都是上真正的次表面散射(Subsurface Scattering,SSS)——就是光打进去之后在物体内部散射、再从其他地方溢出来的效果。手放在手电筒后面会透出红光,蜡烛耳朵被光一照会亮得发透,这些都是典型的次表面散射。但真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 { "RenderType"="Opaque" "Queue"="Geometry" } 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 { "RenderType"="Transparent" "Queue"="Transparent" } 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用Queue=Transparent-1,正面Pass用Queue=Transparent。如果模型内部有自遮挡,用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 Texture:half2 uv = half2(thickness, NdotL); half3 sssColor = tex2D(_SSSRamp, uv).rgb;这个方案特别适合风格化项目。美术可以直接在Ramp贴图里画“什么厚度、什么光照角度下呈现什么颜色”,表现力比固定颜色高一个量级。不过它的前提是要正确理解厚度和角度的映射关系,美术需要一些上手时间。7. 我的最终建议与避坑心得做完这套伪SSS方案,我在几个项目里反复验证过,最大的感受是:它不是一个“可以随处套用”的万能Shader,而是一套需要针对项目调校的“光影思路”。如果你想在团队里推广这套方案,我会建议美术和TA先约定清楚资产规范。比如叶子的厚度贴图画到什么程度、蜡烛的散射颜色用哪个色域、角色耳朵的散射范围有多大。这些看似琐碎的约定,决定了最终效果是否统一。另外我特别想强调一个容易被忽略的细节:Shader参数命名和范围。很多美术同事看到_SSSPower是0到16的范围,会习惯性拖到最大。结果就是一片过曝。后来我把Shader参数范围改成更“直觉化”的0到1,再用内部映射把_SSSPower从1映射到16:float power = lerp(1.0, 16.0, _SSSPowerNormalized);美术拖起来直观,代码逻辑也不受影响。最后,如果你只是做个个人练习或者小Demo,不需要追求真SSS级别的物理正确性。先把这篇文章里的伪SSS方案跑通,理解厚度、角度、颜色这三个变量之间的关系,再决定要不要往更复杂的方案升级。很多情况下,一个调得好的伪SSS已经能撑起整个项目的质感需求了。