ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity URP下菲涅尔效果实现:从原理到个性化边缘光Shader

2026/9/16 5:09:34 拓冰建站 浏览量
Unity URP下菲涅尔效果实现:从原理到个性化边缘光Shader 做渲染的应该都懂菲涅尔Fresnel效果是那种“看似简单一上手全是细节”的东西。我最早在Built-in管线里写后来项目切到URP同样的代码直接报错改了半天才明白是内置变量和Pass标签全变了。这篇博文就是围绕“Unity中URP下的菲涅尔效果实现个性化修改”这个需求来的把我踩过的坑、调试思路、以及如何把边缘光玩出花样的方法都摊开讲适合刚开始学Shader、想从Built-in管线迁移到URP或者已经能写基础Shader但想加点个性化效果的朋友。很多人第一次看到菲涅尔以为就是一个“边缘发光”的遮罩实际上它背后是物理光学里一个很重要的现象光线在不同介质的交界处反射率会随入射角度变化。你在湖边看水面低头看到的是水底平着看过去却是倒影这就是菲涅尔效应。在渲染里我们不需要完全物理正确地模拟大多数时候只是用视线方向和表面法线的点积来构造一个因子控制边缘的发光强度。下面我会讲讲它在URP下的完整落地过程包括Shader怎么写、参数怎么调、怎么加噪声扰动、怎么做扫描线和X射线效果以及几个我实际测出来的版本兼容性问题。1. 菲涅尔效果的核心原理与URP实现思路1.1 物理现象为什么掠射角更容易反光先把原理说透。菲涅尔效应可以简单概括成一句话观察方向越接近表面切线方向反射越强。用数学表达就是视线方向和法线方向的夹角越大菲涅尔项越接近1夹角越小菲涅尔项越接近0。在Shader里我们通常取表面法线N和视线方向V的点积dot(N, V)然后做一次反相再用幂函数控制衰减速度pow(1 - saturate(dot(N, V)), power)。这个pow的指数就是“菲涅尔幂Fresnel Power”指数越大边缘越窄、越锐利指数越小过渡越平缓。生活化类比你站在正前方看一块玻璃几乎完全透明但侧着看玻璃边缘它会反光到看不清里面。渲染里的菲涅尔就是为了模拟这种视觉差异。做边缘光、冰面高光、能量护盾、水面波光时这个因子都是核心。1.2 URP下三条实现路线我为什么选HLSLURP里实现菲涅尔有三条路可走Shader Graph最直观拖节点搭积木适合不熟悉代码的人。但节点图一旦复杂性能问题难排查而且版本间节点位置有变动维护成本并不低。内置Shader改写把Built-in管线的菲涅尔代码搬过来改一下变量名和Include路径。老项目迁移时常这么做但代码里可能残留_LightColor0、_WorldSpaceLightPos0等只在Built-in里有效的变量容易出问题。用URP的ShaderLibrary手写HLSL这是我现在最推荐的做法。URP官方把所有常用函数都打在了Core.hlsl、Lighting.hlsl里只要Include路径正确很多底层工作不用自己重造还能顺手保证SRP Batcher兼容性。我个人选择第三条路线。虽然一开始写起来比Shader Graph慢但代码结构清清楚楚后期加细分功能、做性能优化都方便得多。1.3 理清URP Shader的基础框架URP的Shader在外层Tags上多了一个关键标记Tags { RenderPipeline UniversalPipeline }Pass里面还要指定LightMode常用的有UniversalForward、SRPDefaultUnlit、Universal2D等。如果你漏了RenderPipeline这个TagUnity会尝试用内置管线去渲染结果就是材质全变成洋红色报警。此外URP下SRP Batcher是默认开启的。这意味着所有材质参数必须放在CBUFFER_START(UnityPerMaterial)里面否则SRP Batcher会直接失效。这一点新手特别容易忽略后面我会专门讲。2. 从零手写一个URP菲涅尔Shader2.1 工程配置与Pass结构在动手写代码之前确保你的工程满足下面几个条件Unity版本建议2021.3 LTS以上我测试用的是2022.3 LTSAPI基本稳定。已经安装Universal RP包并且Project Settings里Graphics和Quality下的Render Pipeline Asset都指向了URP资产。新建一个Shader文件命名随意比如URPFresnel.shader。Shader的整体结构如下外层SubShader必须有RenderPipelineTagPass里的LightMode建议用UniversalForward。如果你不想参与光照运算只想画一个自发光边缘也可以直接用SRPDefaultUnlit但为了以后扩展多光源我一般还是用UniversalForward。2.2 核心代码法线、视线、边缘因子直接上代码。这是一个极简但可正常工作的版本核心就是计算菲涅尔因子然后和颜色混合。Shader Custom/URPFresnel { Properties { _BaseColor (Base Color, Color) (0.1, 0.1, 0.1, 1) _FresnelColor (Fresnel Color, Color) (0.0, 1.0, 1.0, 1) _FresnelPower (Fresnel Power, Range(0.1, 10)) 2.0 _FresnelIntensity (Fresnel Intensity, Range(0, 5)) 1.0 } SubShader { Tags { RenderPipeline UniversalPipeline RenderType Opaque Queue Geometry } Pass { Name ForwardLit Tags { LightMode UniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; float3 normalWS : TEXCOORD0; float3 positionWS : TEXCOORD1; }; CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _FresnelColor; float _FresnelPower; float _FresnelIntensity; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; OUT.positionWS positionInputs.positionWS; OUT.normalWS TransformObjectToWorldNormal(IN.normalOS); return OUT; } half4 frag(Varyings IN) : SV_Target { float3 viewDirWS normalize(GetCameraPositionWS() - IN.positionWS); float NdotV saturate(dot(normalize(IN.normalWS), viewDirWS)); float fresnelFactor pow(1.0 - NdotV, _FresnelPower); fresnelFactor saturate(fresnelFactor * _FresnelIntensity); half3 color lerp(_BaseColor.rgb, _FresnelColor.rgb, fresnelFactor); return half4(color, _BaseColor.a); } ENDHLSL } } FallBack Universal Render Pipeline/Unlit }这里有几个值得注意的细节。GetVertexPositionInputs是URP提供的一个工具函数一次性返回对象空间、世界空间、裁剪空间坐标省去手动转换的麻烦。它是Core.hlsl里的函数所以一定要正确Include。GetCameraPositionWS()用来获取世界空间相机位置内部会处理正交相机和透视相机的差异比自己在C#侧传参更省心。在片元阶段NdotV被saturate限制在0到1之间避免法线和视线方向因插值误差出现负值。1 - NdotV就是核心的翻转操作。注意这里的视角方向是从片元世界坐标指向相机所以要用normalize(GetCameraPositionWS() - IN.positionWS)方向千万别写反了。我用了lerp(_BaseColor.rgb, _FresnelColor.rgb, fresnelFactor)来控制颜色过渡。为什么不用加法因为加法容易让颜色溢出尤其是在移动端低精度浮点上。用lerp可以保证颜色在黑色和指定颜色之间变化不会出现不自然的过曝。2.3 材质面板参数设计为了让效果能用参数面板必须顺手。我的习惯是至少暴露4个参数参数含义推荐范围备注_BaseColor基础颜色任意效果主体的固有色_FresnelColor边缘光颜色任意想要高亮可选HDR颜色_FresnelPower边缘宽度0.1 ~ 10值越大边缘越窄_FresnelIntensity发光强度0 ~ 5用于整体增益控制你可能会问为什么不用[HDR]修饰_FresnelColor如果是想做能量护盾那种亮瞎眼的边缘光建议加上[HDR]这样颜色值能超过1再配合Bloom效果会非常醒目。我一般这样声明[HDR] _FresnelColor (Fresnel Color, Color) (1,1,1,1)同时_BaseColor也可以加上[MainColor]属性方便UI和代码统一识别。3. 个性化修改让菲涅尔效果“活”起来基础版只是把菲涅尔因子用在了颜色混合上实际项目里这样的效果太单调了。下面几个方向是我在开发里用得最频繁的你能明显感觉到同一套菲涅尔公式换几种组合产品气质完全不同。3.1 用HDR颜色加Bloom做能量护盾如果你想要那种科幻护盾、能量球的感觉光靠菲涅尔自身的渐变不够还要配合后处理Bloom。做法分三步把_FresnelColor声明成[HDR]调出一个超过1的颜色比如(3, 4, 6, 1)。在URP Renderer Asset里挂上Bloom后处理Volume阈值设在0.8~1.2之间。让菲涅尔区域只影响边缘不要大面积峰值。这时候_FresnelPower可以适当调大比如4到6让亮区收窄。护盾受到攻击时可以动态调_FresnelIntensity从0快速冲到3再回落视觉上就是护盾被敲了一拳的脉冲感。这个效果我放在项目里几乎零成本比额外做什么特效动画都来得快。3.2 加噪声扰动打破完美圆弧的塑料感默认菲涅尔是依据平滑法线算出来的效果非常“圆滑”放在科幻场景里会显得太干净。尤其是岩石、冰面、角色盔甲这种表面边缘光如果过分平滑会透出一股塑料感。要打破这个局面最简单的方法是采样噪声纹理。我通常在片元阶段用世界坐标作为采样UV叠加一张Perlin噪声或者Value噪声再把噪声值加到NdotV上float noise tex2D(_NoiseTex, IN.positionWS.xz * _NoiseScale).r; float fresnelFactor pow(1.0 - saturate(NdotV noise * _NoiseStrength), _FresnelPower);_NoiseScale控制噪声的稀疏程度_NoiseStrength控制扰动强度。注意不要扰动得太狠否则边缘会碎得像坏掉的雪花屏。一般是加一个比较小的值比如0.05到0.15。实际效果是边缘光不再是一条连续的亮圈而是断续、斑驳、有机的。做岩石裂缝、冰面开裂、魔法符文之类的内容时这个技巧几乎必备。3.3 扫描线与脉冲强度另外一个经常玩的招数是扫描线效果。核心思路是给菲涅尔因子再乘一个基于时间的方波或正弦波让它沿某个轴扫过去。比如我想做一个“信号从底部向上扫描”的效果float scan smoothstep(0.0, 0.1, frac(IN.positionWS.y * _ScanTiling - _Time.y * _ScanSpeed)); float finalFactor fresnelFactor * scan;这里IN.positionWS.y充当了扫描轴_ScanTiling控制周期间隔_ScanSpeed控制扫描速度。frac配合smoothstep让扫描边缘只出现在一个窄条里。如果只是想要脉冲呼吸感直接用正弦波float pulse 0.5 0.5 * sin(_Time.y * _PulseSpeed); finalFactor fresnelFactor * pulse;这两种和HDR颜色组合起来就是很多游戏里“全息投影”“预警提示”“传送门启动”的经典做法。3.4 结合深度纹理做X射线描边菲涅尔还可以和深度纹理结合做一个类似X射线的透视边缘效果。URP下获取场景深度纹理需要先确保Camera组件上勾选了Depth Texture选项或者URP Asset里开启了Depth Texture。然后在Shader里用屏幕坐标采样_CameraDepthTexture对比片元和场景深度的差值float4 screenPos ComputeScreenPos(IN.positionHCS); float sceneDepth LinearEyeDepth(SampleSceneDepth(screenPos.xy / screenPos.w), _ZBufferParams); float diff saturate(abs(sceneDepth - IN.positionWS.y) * _EdgeWidth);这个diff和菲涅尔因子叠加之后可以让物体即便藏在墙后面边缘也会若有若无地显示出来实现穿墙高亮的提示效果。注意这里我用IN.positionWS.y做简化示意实际项目可能需要转换到相机空间深度或者用多个深度切片具体需求具体分析。3.5 用滑动条和Input System做运行时调节调试菲涅尔参数时反复改Shader并等待编译很浪费时间。我在项目里专门写了一个小面板用Slider实时调_FresnelPower和_FresnelIntensity手感提升非常明显。核心C#脚本很简单假设你已经有一个Slider并且材质已经拖给了MeshRendererusing UnityEngine; using UnityEngine.UI; using UnityEngine.InputSystem; public class FresnelController : MonoBehaviour { public Material targetMat; public Slider powerSlider; public Slider intensitySlider; void Start() { if (powerSlider ! null) powerSlider.onValueChanged.AddListener(v targetMat.SetFloat(_FresnelPower, v)); if (intensitySlider ! null) intensitySlider.onValueChanged.AddListener(v targetMat.SetFloat(_FresnelIntensity, v)); } public void Update() { // 也可以用Input System做键盘/手柄调节 if (Keyboard.current ! null) { if (Keyboard.current.upArrowKey.wasPressedThisFrame) powerSlider.value 0.5f; if (Keyboard.current.downArrowKey.wasPressedThisFrame) powerSlider.value - 0.5f; } } }这样就不用反复改材质面板一边运行一边看效果调参数效率翻倍。4. 实战排查几个容易翻车的坑菲涅尔Shader本身写起来不算难真正让人头疼的是各种莫名其妙的兼容性问题。我把这段时间在URP下碰到的坑整理成一份速查表照着排查能省不少时间。4.1 URP版本差异与API兼容性URP每个大版本都在调整内置函数和变量名网上很多教程代码是旧版本的直接复制到2022或2023的工程里经常报undeclared identifier之类的错误。遇到报错先不要慌打开包管理器看你用的com.unity.render-pipelines.universal版本号。常见的几个坑GetVertexPositionInputs在旧版本里可能叫GetVertexPositionInputs但内部实现不同返回值字段基本没变一般还好。TransformObjectToWorldNormal在不同版本里都能用但早期URP必须显式包含ShaderVariablesFunctions.hlsl新版已经塞进Core.hlsl了。SampleSceneDepth是URP 10.x之后才有的函数旧版本可能只有SampleSceneDepth对应的旧接口或者需要手动声明_CameraDepthTexture再采样。_Time变量在URP里依旧可用但如果在SRP Batcher环境下使用记得把它放到UnityPerMaterial外面它由Unity自己管理不需要也不应该放进材质CBUFFER。我的建议是锁定一个经过充分测试的URP版本比如2022.3 LTS自带的14.0.x尽量不要在项目中途升级URP。如果非要升级先跑一遍所有自定义Shader的编译日志逐个清错。4.2 透明度渲染顺序与深度冲突菲涅尔效果如果要和半透明物体一起用很容易出现两种问题一是边缘光被不透明物体遮挡看起来像被切掉一块二是半透明物体之间绘制顺序错乱边缘淡入淡出时闪烁。解决方案很经典把菲涅尔材质放到QueueTransparent然后在Shader里设置ZWrite Off。如果还需要写入深度也可以设置ZWrite On但要注意半透明物体一般不应该写入深度否则后面的半透明物体会被前面已经填充的Alpha挡住。如果你要的是X射线那种穿墙效果更稳妥的做法是单独用一个Camera或者Layer渲染透视物体再和后场景颜色做混合避免深度冲突。我踩过一次坑单纯把ZTest改成Always效果是能看见墙后面的物体了但物体和墙叠在一起时边缘疯狂闪烁最后是用额外相机和深度替换才解决。4.3 SRP Batcher与材质属性块前面提过URP的SRP Batcher默认开启。要让菲涅尔Shader兼容所有材质参数必须放在CBUFFER_START(UnityPerMaterial)和CBUFFER_END之间。这个CBUFFER的内容需要和材质属性一一对应不能缺失。如果你发现场景里物体一多Draw Call明显上升大概率是SRP Batcher失效了。检查方式有两个打开Frame Debugger看Draw Call前面的图标如果出现了断链提示通常就是Shader没走SRP Batcher路径。在Game视图的Stats面板看Batch数量使用相同材质和相同Shader的物体应该会被合并成一个Batch。另外用MaterialPropertyBlock给每个物体设置不同的_FresnelColor或_FresnelIntensityURP也支持。但要注意使用MaterialPropertyBlock时SRP Batcher在某些版本里无法正确批量反而会导致性能下降。如果不追求每物体独立参数还是建议用共享材质最省心。4.4 移动端和WebGL性能注意事项移动端跑菲涅尔Shader最大的性能瓶颈往往是法线从对象空间转到世界空间以及片元里的pow运算。法线变换不可避免但你可以把法线变换放到顶点阶段然后通过插值传到片元阶Unity引擎自动做了插值问题不大。不过要注意归一化如果差值后法线不再单位长度Dot运算结果会偏边缘宽度会错乱。移动端的另一个隐患是半精度浮点数。在half4 frag(...)里所有计算尽量用half类型不要混用float和half。混用会导致额外的转换指令而且不同GPU对半精度处理不同容易出奇怪的边缘条纹。至于WebGL平台这个Shader本身没有特殊要求。但如果发布WebGL我遇到最多的问题反而不是Shader本身而是Unity WebGL的虚拟文件系统写入失败典型报错就是“Unable to write file ... IDBFS”。这个问题多数和浏览器存储权限或者打包时没有勾选IndexedDB支持有关跟Shader关系不大。如果你在WebGL上调试时发现材质颜色不对优先检查浏览器控制台有没有Shader编译错误有时WebGL1不支持某些高级写法URP通常要求WebGL2调试时务必要确认测试浏览器支持WebGL2。4.5 菲涅尔和阴影的关系有些场景里你明明写了菲涅尔边缘光结果物体在阴影部分时边缘光直接消失或者被压暗了。这是因为我们上面那个最简单的Shader根本没有接光照直接输出颜色所以理论上不该受阴影影响。如果你把菲涅尔色和阴影混合或者用了半透明的混合模式那么在阴影里的物体Alpha混合会受光照影响看起来就像边缘光被阴影“吃”掉了。处理思路一般有两种一是不让菲涅尔受光照衰减影响让边缘光始终保留。二是在阴影区域人为降低菲涅尔强度做一种“阴影中边缘微弱发光”的效果比如float shadow MainLightRealtimeShadow(IN.positionWS); float finalFactor fresnelFactor * lerp(0.3, 1.0, shadow);这样黑暗的地方仍有微弱边缘光亮的地方边缘光更明显整体过渡更自然。5. 调试与优化怎么快速定位效果为什么不对5.1 用Frame Debugger逐级排查Shader效果不对时别急着改代码先打开Window Rendering Frame Debugger回放一帧逐个看Draw Call的Shader属性、Blend状态、ZTest状态。很多时候问题根本不在算法而是Pass混合状态不对。比如你发现菲涅尔边缘是黑的先把_FresnelPower调小到0.5看边缘是否变宽。如果变宽说明计算链路没问题问题可能在颜色参数上比如_FresnelColor被调成了黑色或者HDR值在低动态范围下被压暗了。Frame Debugger还能帮你确认Shader是否真的走了UniversalForward这个Pass。有时候材质面板显示正常但实际运行时Pass选错了尤其是多Pass Shader最容易在Frame Debugger里现原形。5.2 移动端优化参数推荐如果你要发布到中低端手机建议直接限制参数范围别给美术留太多自由度。以下是我实测比较稳的参数范围_FresnelPower2~6不要低于1否则边缘面积太大像蒙了一层雾。_FresnelIntensity0.5~2超过2很容易过曝如果做了Bloom会更严重。_BaseColor保持暗色系这样菲涅尔边缘光更突出。噪声纹理用128x128的小图就够了平铺次数可以高一些不需要高质量大纹理。关于噪声其实还有一种不依赖纹理的做法用顶点位置和时间的某种Hash函数生成伪噪声。虽然质量不如Perlin噪声但在移动端能省一次纹理采样性能更好。如果你的效果只是想要一点随机的破碎感完全可以凑合用。5.3 多Shader变体与Keywords控制当你的菲涅尔Shader加入“是否启用噪声”“是否启用扫描线”后用[Toggle]让美术在材质面板上开关功能这就要引入Shader Keywords。我一开始图省事把能用的功能全编译进Shader结果变体爆炸打出来的包体积变大加载也变慢。正确做法是用#pragma shader_feature_local _FRESNEL_NOISE_ON和_FRESNEL_SCAN_ON这样的关键字然后在C#侧用Material.EnableKeyword和DisableKeyword控制。这样每个功能开关都会生成独立的Shader变体只有打开的变体才会被编译进最终包。关键字命名要统一规范我习惯用_XXX_ON的格式Unity的[Toggle]属性默认也支持这种格式美术不需要看懂代码勾选框就能用。5.4 多相机和摄像机跟随对菲涅尔的影响这里顺带提一个很容易被忽视的坑菲涅尔效果依赖视线方向所以它会随着摄像机的移动和旋转实时变化。如果场景里有多台相机叠加渲染比如主相机加一个武器相机或者用Render Texture做小地图每个相机看到的菲涅尔效果可能不一样。这时候要留意Shader里用的是GetCameraPositionWS()它会自动取当前渲染的相机位置所以理论上没问题。但如果你偷懒在C#里手动传相机位置参数那就必须每帧更新否则切相机后效果会穿帮。做玩家角色边缘光时摄像机跟随移动会导致边缘光一直在变化这很正常甚至算是优点。但如果角色衣服边缘有很明显的菲涅尔镜头一拉远边缘光会集中到身体轮廓上有些美术会嫌太“赛博”。解决办法是把_FresnelPower调大或者用distance做一个距离衰减让远距离时边缘光强度降下来float dist distance(GetCameraPositionWS(), IN.positionWS); float distFade saturate(_DistanceFadeRange - dist * _DistanceFadeSpeed); finalFactor * distFade;加了距离衰减后近看有边缘光远看则不会抢戏效果更自然。6. 扩展玩法菲涅尔不只是边缘光写到这里还想多说一点思路上的东西。菲涅尔因子本质上是一个0到1的权重它描述的是“表面朝向观察者的程度”。除了边缘光这个因子还能干很多事情用它做水面高光的遮罩让水波反射只在掠射角方向变强。用它做皮肤SSS的近似在角色耳朵、鼻翼这些薄的部位加强透光感。用它做粒子消散粒子从边缘向中心逐渐消失。用它做UI描边或被选中物体的高亮轮廓。在URP的Shader Graph里有一个专门的Fresnel Effect节点底层逻辑和我上面写的完全一致。如果你想快速搭原型用那个节点十分钟就能出效果。但如果要拷到多个项目里反复使用还是建议手写一份HLSL因为你永远不知道下一版URP会把节点改成什么样。我个人在实际项目里的习惯是把所有可复用的Shader片段打成.hlsl文件放在Assets/Shaders/HLSLIncludes目录下用#include引入。这样不同Shader之间可以共用同一个菲涅尔函数参数命名也统一美术在调参时不会因为一个叫_FresnelPower一个叫_EdgePower而晕头转向。最后再分享一个小技巧调菲涅尔时别光看静态画面。把相机拉近拉远、旋转角度、开关后处理一一测试一遍。很多效果在静止截图里不错一到实际运镜就漏出破绽。多花几分钟做运镜测试比后期反复返工要省心得多。