Unity黑洞扭曲效果实战:从屏幕后处理到Shader Graph完整方案

发布时间:2026/9/4 10:05:14
Unity黑洞扭曲效果实战:从屏幕后处理到Shader Graph完整方案 黑洞扭曲效果在游戏里的出镜率其实不低技能特效、传送门、场景装饰甚至loading界面都能用上。但很多人在Unity里做这类效果时第一反应是找现成资源包或者拼Shader Graph节点最后做出来的东西要么生硬、要么和场景风格完全不搭。我自己在项目里也调过不少次这类扭曲效果从早期的屏幕后处理到现在的Shader Graph方案都踩过一遍这篇就把完整思路和可落地的做法梳理出来。1. 扭曲效果的本质一张贴图怎么被“掰弯”先说清楚扭曲到底在做什么。屏幕上一个像素最终显示什么颜色取决于它采样了哪张贴图的哪个位置。正常情况下UV坐标和屏幕像素是一一对应的画面自然就是正常的。扭曲的核心思路就是打破这种对应关系让每一个像素不再去采原始位置的颜色而是去采周围某个偏移过的位置。偏移的规则不同扭曲的形态就不同。黑洞效果的本质就是一套有规律的UV偏移规则——绕着中心点旋转同时从边缘向中心拉伸模拟出光线被引力弯折的视觉感受。很多人第一次接触扭曲会用噪声图来做扰动给UV加一层Perlin噪声偏移画面会像水面波纹一样晃动。但黑洞扭曲和噪声扰动有一个根本区别黑洞的偏移是有方向、有中心的。噪声扰动是随机的、全局的而黑洞扭曲必须围绕一个中心点做向心旋转还要有视界附近那种强烈的向内拖拽感。所以不能直接用一张噪声图了事得自己构造“绕中心旋转”的偏移向量。这里还有个关键细节扭曲是屏幕空间效果还是世界空间效果。如果是UI特效或者技能特效通常在屏幕空间直接做简单直观如果要把黑洞放在3D场景里并且让靠近它的物体产生真实弯曲感就需要用深度纹理做后处理或者用额外的渲染Pass采样场景颜色。我后文会同时给出两种做法的思路因为不同项目需求差别真的很大选错方案后面返工很痛苦。2. 破开HTTPS证书校验的常见做法与取舍很多资料会默认你的黑洞只是“一个贴在屏幕上的洞”但实际项目中要处理的情况比这个复杂得多。比如摄像机在移动黑洞在场景里的坐标会变化比如黑洞前面有角色走过扭曲范围应该覆盖到角色身上比如URP管线环境下后处理写法会变。本章先讲清楚方案选型再给出可以直接用的代码。2.1 屏幕空间扭曲的数学逻辑屏幕空间扭曲的数学核心是两个向量的问题采样点相对黑洞中心的方向和距离。方向决定旋转偏移朝哪个方向推距离决定偏移强度的大小。旋转偏移的构造很简单把方向向量旋转90度或者90度加某个角度然后乘上强度就行。距离衰减则用来控制只有靠近黑洞的区域才有明显扭曲远处不受影响。具体展开一下偏移公式的推导过程。假如某像素的屏幕UV坐标是uv黑洞中心在屏幕上的UV坐标是center那么相对向量dir uv - center距离dist length(dir)归一化方向n dir / dist。我们要构造的偏移量大概是rotated float2(-n.y, n.x) falloff smoothstep(outerRadius, innerRadius, dist) // 从外到内衰减越靠近中心强度越大 uv_offset uv rotated * strength * falloff这里smoothstep(outerRadius, innerRadius, dist)的作用是生成一个从外圈0渐变到内圈1的系数表示越靠近黑洞中心旋转偏移越强。rotated是切线方向乘以系数后像素会沿着黑洞圆周方向被“拧”开形成旋涡感。这只是旋转部分如果要做黑洞的向心吞噬感还得再加一项沿-dir方向的拉伸让靠近中心的像素被向内部挤压产生被拉长变形的效果。2.2 手写Shader方案Blit和OnRenderImage的兼容性处理手写Shader做黑洞扭曲老项目用内置渲染管线的话直接在OnRenderImage里做一次全屏材质Blit就可以。这是我的首选因为逻辑最清晰调试也方便而且不用依赖URP的Volume框架。下面给出一套可直接用的完整实现。先写Shader一个标准的Unlit后处理Shader只有一个MainTex作为输入经过扭曲UV计算后再采样Shader Custom/ScreenDistortionBlackhole { Properties { _MainTex (Screen Texture, 2D) white {} _Center (Center, Vector) (0.5, 0.5, 0, 0) _Radius (Radius, Float) 0.3 _Strength (Strength, Float) 0.1 _Swirl (Swirl, Float) 1.0 } SubShader { Tags { RenderTypeOpaque } Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float4 _Center; float _Radius; float _Strength; float _Swirl; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float2 uv i.uv; float2 dir uv - _Center.xy; float dist length(dir); float2 norm dist 0.001 ? dir / dist : float2(0, 0); // 内半径和外半径内半径越小视界边缘越锐利 float innerR _Radius * 0.6; float outerR _Radius; // 从外圈到内圈的衰减系数越靠近中心扭曲越强 float falloff 1.0 - smoothstep(innerR, outerR, dist); // 限制内圈不要无限扭曲给一个最小距离保护 falloff * saturate(dist / (_Radius * 0.1 0.0001)); // 切线方向旋转偏移 float2 tangent float2(-norm.y, norm.x) * _Swirl; // 径向向心拉伸偏移 float2 radial -norm * _Strength * falloff; // 综合偏移 float2 distortedUV uv tangent * _Strength * falloff * _Radius radial; fixed4 col tex2D(_MainTex, distortedUV); return col; } ENDCG } } }配套的C#控制脚本负责把黑洞的世界坐标转成屏幕坐标传给Shader同时把半径、强度等参数暴露到Inspector方便调using UnityEngine; [RequireComponent(typeof(Camera))] public class BlackholeDistortionEffect : MonoBehaviour { public Shader distortionShader; public Transform blackholeTarget; [Range(0f, 1f)] public float radius 0.3f; [Range(0f, 0.5f)] public float strength 0.1f; [Range(-2f, 2f)] public float swirl 1f; private Material distortionMat; void Start() { if (distortionShader null) distortionShader Shader.Find(Custom/ScreenDistortionBlackhole); distortionMat new Material(distortionShader); } void OnRenderImage(RenderTexture src, RenderTexture dest) { if (distortionMat null || blackholeTarget null) { Graphics.Blit(src, dest); return; } Vector3 screenPos GetComponentCamera().WorldToScreenPoint(blackholeTarget.position); if (screenPos.z 0f) { Graphics.Blit(src, dest); return; } distortionMat.SetVector(_Center, new Vector4(screenPos.x / Screen.width, screenPos.y / Screen.height, 0, 0)); distortionMat.SetFloat(_Radius, radius); distortionMat.SetFloat(_Strength, strength); distortionMat.SetFloat(_Swirl, swirl); Graphics.Blit(src, dest, distortionMat); } void OnDestroy() { if (distortionMat ! null) Destroy(distortionMat); } }这套实现里有个值得注意的点falloff里的saturate(dist / (_Radius * 0.1 0.0001))是为了给黑洞中心一圈加保护。如果完全不限制中心像素的偏移量会趋近无穷大画面中心会出现可怕的撕裂。加上这个保护之后中心一圈虽然也被扭曲但不会直接炸掉。2.3 为什么不用GrabPass或是单独相机渲染方案不少人会问扭曲场景里的物体用带透明通道的GrabPass抓屏再偏移不是更简单吗其实GrabPass在移动端性能损耗很大每帧要额外抓一次屏幕内容而且抓取的时机、顺序都不稳定尤其在多相机、多层UI叠加的时候后期出现的偏差会让你怀疑人生。我在一个H5小游戏项目里试过一次GrabPass做UI扭曲后来因为包体和渲染顺序问题彻底放弃了。单独相机渲染方案的思路是把黑洞周围要扭曲的物体渲染到一张RT上然后用这张RT采样并偏移最后合成到主画面。好处是不改动主相机纹理精度可控制但坏处是工程结构复杂。你得维护一个额外相机处理它的Layer、范围、正交投影还是透视投影的问题还得处理透明排序、景深、色散等后处理效果的叠加。对于大部分中轻量级项目来说屏幕后处理的思路已经够用而且可以随时在Spector图层面板上看到_MainTex的内容排查起来特别直观。3. Shader Graph方案不用写代码也能实现扭曲如果你的项目用的Unity 2021以上版本且管线是URPShader Graph是更高效的方案。它最大的优势在于可视化调试和节点组合的灵活性——不用编译Shader改完立刻能在预览窗口看到结果而且内置了很多Voronoi、Gradient Noise这类噪声节点做复杂特效时省很多事。3.1 核心节点树的拆解与连接顺序用Shader Graph做一个屏幕空间黑洞扭曲核心还是那套数学逻辑算方向、算距离、算衰减、构造偏移向量、最后采样场景颜色。下面是节点连接顺序Tiling And Offset节点用于接收UV坐标并把它作为后续偏移的基础起始点。实际上Shader Graph中有专门的Screen Position节点通常在“Master Stack”里已经存在场景颜色采样的入口做后处理时我们用Screen Position生成UV而不是UV节点。Screen Position节点输出当前像素的屏幕坐标Value类型选Raw或Default都可以。黑洞中心也用Screen Position采样一张黑白图或用Vector2节点手填中心坐标。关键是将黑洞中心坐标与屏幕坐标相减得到dir向量。Distance节点输入dir得到dist距离数值。Normalize节点输入dir得到归一化方向norm。Rotate节点或者直接用Vector2节点手动组成float2(-norm.y, norm.x)来获得切线方向。用Rotate节点看起来直观但底层还是要自己拼向量建议直接用Vector2算更可控。Smoothstep节点输入distEdge1设为outerREdge2设为innerR得到从外圈0到内圈1的衰减系数falloff。注意Smoothstep参数顺序和手写Shader里要反过来Shader Graph的Edge1是输入小于它时结果为0Edge2是输入大于它时结果为1。Multiply节点把切线方向、swirl系数、strength、falloff、outerR依次相乘得到旋转偏移量同样径向方向、strength、falloff相乘得到径向拉伸量。Add节点把原始Screen Position加上两个偏移量得到扭曲后的屏幕坐标。Sample Texture 2D节点用扭曲后的坐标去采样场景颜色RT。在URP后处理Shader Graph里场景颜色通常来自“Scene Color”节点而不是自己去声明_MainTex。所以实际上在“Master Stack”的Emission输入那里直接传扭曲后的颜色的做法在URP里会有额外处理逻辑。这里我用的是更直接的方式通过Custom Function节点接一段C#函数来采样屏幕或者直接用URP的Fullscreen Shader Graph模板。用URP的Fullscreen Shader Graph模板是最省心的方式它已经帮你处理好了屏幕坐标和后处理Blit的全过程。基于这个模板只需要关注扭曲偏移的计算部分。模板里有一个“Screen Position”节点参考它的输出去计算偏移即可。Shader Graph方案有个比较明显的坑URP的Fullscreen Shader Graph默认采样的是_BlitTexture而你的扭曲偏移是在采样之前算的。如果直接在Screen Position上加偏移但忘了后续节点用偏移后的坐标来采样出来的结果就是没有任何扭曲。我见过好几个人在这个地方卡了很久检查了半天发现只是连接顺序错了。3.2 一个替代思路用噪音扰动做补充黑洞扭曲不是只有那个旋涡在视界边缘加一点高频噪声扰动能大幅提升真实感。因为真实黑洞视界周围的光线弯曲是很混乱的不是那种完美平滑的螺旋。用Gradient Noise节点生成噪音图乘以一个非常小的幅度比如0.002叠加到UV偏移里。节点连接就是Gradient Noise → Multiply(幅度) → Add到distortedUV。这一步不复杂但效果提升非常大从“规整的漩涡”直接变成“被引力扰乱的扭曲流场”。4. 参数调节与性能考量让黑洞“活”起来一个简陋的扭曲效果和一个让人惊艳的扭曲效果差距往往不在原理而在参数调节的细节上。很多使用者把扭曲效果做出来后看着总感觉少了点什么却没有方向去调。下面这几个参数和视觉细节是我实际项目中反复验证过的值得挨个试。4.1 强度、半径、旋转速度的协同调节扭曲强度strength、半径radius、旋转速度swirl这三者不是独立变量。它们协同影响最终画面感受。通常我的调参顺序是先定半径也就是扭曲影响的最大范围再调strength控制扭曲的最大幅度太大画面完全糊掉太小看不出扭曲最后调swirl控制旋转的“拧劲”。一个容易忽视的细节是strength和radius要同向增大——radius变大时strength如果不变外圈扭曲会显得“虚”radius变小时strength不降核心区就会糊成一团。经验参考值radius在0.2-0.4之间strength在0.05-0.2之间swirl在0.8-3之间具体看画面比例和镜头距离。黑洞“活”起来的另一个关键是让扭曲中心移动。中心移动时周围像素的偏移方向和距离都会跟着变画面会自己产生一种被拖拽的呼吸感。实现上只需要在C#脚本里持续更新_Center值就行不用额外写Shader逻辑。另外加入一个极小的时间变化量_Time.y * speed叠加到tangent上能让旋涡不但有空间上的扭曲还有时间上的转动感。这个简单的Time叠加会让整个特效生动很多强烈建议加。4.2 帧率杀手在哪里一个后处理的性能分析屏幕扭曲后处理的主要性能消耗不在Shader本身而在采样屏幕上。最直接的消耗来自GPU的纹理采样带宽——一次全屏扭曲需要约一到两次全屏纹理采样如果再加上噪声图的额外采样带宽压力会成倍增长。移动端尤其明显因为移动GPU的带宽有限。实测下来在iPhone 12级别的设备上1920x1080分辨率下不额外采噪声时帧率从60掉到56左右加了噪声图采样后掉到50左右。这说明根本瓶颈不是浮点运算而是纹理带宽。优化思路有三个方向降分辨率采样降低后处理渲染目标的分辨率比如用半分辨率RT做扭曲再拉伸上屏。视觉上边缘会稍微软化但由于扭曲效果的模糊属性基本无感知。限制扭曲范围不是全屏都做扭曲只在黑洞半径的外圈区域内做。Shader里用分支判断让远离黑洞中心的像素直接返回原始颜色减少不必要的偏移计算和采样。避免每帧动态创建RT在URP里做后处理时尽量复用临时RT而不是每帧重新申请。RenderTexture.GetTemporary是首选用完立即释放。移动端还要考虑Overdraw的问题。如果你这枚黑洞特效是放在UI层上的而且UI层还有别的半透明元素那GPU的负担会成倍上涨。我的建议是尽量把黑洞特效放在全屏后处理阶段而不是UI阶段因为UI阶段的混合方式更多对带宽要求更高。4.3 配合颜色渐变和光晕的视觉整合扭曲只是黑洞的“骨架”真正让观众觉得“这是个黑洞”的往往是那些额外细节比如引力透镜产生的圆环光带、中心视界区域的黑暗吞噬感、以及光线经过弯曲后的偏色效果。在扭曲Shader的基础上可以做三件事强化视觉第一在扭曲后的颜色上叠加一个从暗到亮的环形渐变。这个环的位置对应dist / radius等于0.8-1.0的区间可以在Shader里直接利用之前算出来的dist值计算一个灰度渐变作为亮度叠加。中心区域压低亮度接近0.7倍半径的位置提高亮度形成明亮的吸积盘边缘。第二加入HDR Bloom支持。让亮环的输出值大于1让Bloom后处理去烘焙光晕。这是做特效很常用的“亮度1溢出”技巧比叠加一张发光贴图自然得多。第三给扭曲的旋转方向加一点不对称性。真实的光线扭曲不是纯圆周对称的因为观测者的视角会有倾斜。在构造切线方向时可以让tangent.x和tangent.y分别乘上不同的系数比如x乘1.0y乘0.8制造一个轻微的不对称感看起来更随机更真实。5. 踩坑实录与排查链路从“画面撕裂”到“完全不扭曲”做后处理效果最怕的就是画面出来不对劲然后对着代码干瞪眼。这里整理几个我踩过的、也是社区里问得最多的问题每一类都给出排查路径和最终结论下次遇到同类问题照着查就行。5.1 扭曲范围不动但中心区域撕裂成彩色噪点这个现象往往出现在strength值调大后中心区域直接撕裂成彩色噪点。原因基本都在衰减函数上——没有给中心加上限幅保护。我在前面的代码里特意写了saturate(dist / (_Radius * 0.1 0.0001))这一行就是这么来的。如果没有这行保护dist接近0时偏移向量除以一个接近0的数数值爆炸溢出到各分量极大或极小GPU采样时就会采到奇怪的紫黑色或者彩色噪点。排错链路很简单先检查Shader里falloff计算之前有没有对dist做最小安全处理再把strength临时调到0看扭曲是否消失——如果消失了说明问题就在偏移计算最后把tangent和radial两个偏移量分别注释掉逐个确认是哪个分量导致的炸裂。通常在radial这部分出问题的概率更大因为它是直接除以dist的。5.2 扭曲效果在移动端完全不显示但编辑器正常这个情况通常是Shader被移动端编译器优化掉了或者使用了不被支持的语法。Unity的移动端Shader编译确实比较严格一些PC端能用的语法在移动端会被优化或直接报错。排查链路第一步打开Inspector里Shader的“Compile and show code”选项看编译后的GLSL代码有没有保留你写的偏移逻辑。第二步在Shader里临时加一个#pragma target 3.0或者更高有些数值计算需要更高的Shader Model移动端默认不支持高精度。第三步检查是否在Mobile平台设置了单独的Keywords把后处理禁用掉了——特别是URP里通过Renderer Feature挂载后处理时记得检查该Feature在Android/iOS平台是否启用。第四步如果以上都正常把Shader里的tex2D改成使用saturate包一层有些移动GPU对负坐标采样处理得很奇怪加一个saturate能规避一部分兼容性问题。5.3 黑洞跟随物体移动时扭曲中心会滞后一个像素这个问题比较隐蔽也比较经典。屏幕坐标转换时WorldToScreenPoint得到的坐标Y轴是从屏幕左下角开始的但Shader里采样的UV坐标Y轴也是从底部到顶部吗其实不完全一致。WorldToScreenPoint返回的坐标Y轴已经是从顶部到底部如果摄像机是透视投影且无翻转具体取决于平台和管线设置。排查链路先检查C#脚本里屏幕坐标的计算是否考虑Screen.height。简单来说Shader的UV中(0,0)通常对应屏幕左下角Metal上可能是左上角。Unity在OnRenderImage中会帮我们处理好方向但URP或者某些平台特别是Metal的坐标翻转会让你的_CenterY轴偏转。经验做法是在C#端这样处理Vector2 uvCenter new Vector2(screenPos.x / Screen.width, 1.0f - screenPos.y / Screen.height);然后把uvCenter传给Shader。这个1.0f -就是处理Y轴翻转的关键。如果发现黑洞位置总差那么一点点但又不完全沿X轴对称基本就是这个问题。5.4 与其它后处理Bloom/Depth of Field的叠加顺序问题如果项目里还有Bloom、DOF或者Color Grading等其它后处理黑洞扭曲的处理顺序会影响最终效果。扭曲采样的是场景颜色如果先把Bloom做完了再采样那就把Bloom的光晕也一起扭曲了如果先扭曲再做Bloom那扭曲区域内的光晕会正常计算视觉效果更自然。通常推荐的做法是在后处理链的早期阶段做扭曲也就是在Bloom之前。在URP里可以通过Renderer Feature的顺序控制实现这一点。在OnRenderImage里也可以用Graphics.Blit多次传递来控制顺序。但在URP的Fullscreen Shader Graph里默认的顺序是跟随Renderer Feature的注入顺序。你可以在Renderer列表里把黑洞扭曲的Renderer Feature写在Bloom之前或者在C#端用RenderPipelineManager.endCameraRendering来手动指定后处理顺序。这里有个很坑的点URP的Volume系统会管理后处理顺序自己写的Renderer Feature如果没有特别声明InjectionPoint默认是在所有Volume后处理之后执行——这会导致扭曲永远在Bloom后面效果总是“发疯”。所以要记得把InjectionPoint设为BeforeRenderingPostProcessing。6. 场景延伸黑洞扭曲之外还能做什么这套扭曲着色的核心原理其实可以迁移到很多其它效果上。写这篇的最后一节聊聊我在项目里顺手做的几个变体。6.1 空间传送门与虫洞效果把上述扭曲的中心区域改为不做扭曲而是直接采样另一张场景纹理或者另一个相机的渲染结果边缘保留扭曲就变成了传送门。虫洞的话加一个向心压缩和内部噪声混合能表现出空间被折叠的感觉。核心改动只有一处在Shader里判断如果dist小于某个阈值就不再采样原始屏幕而是采样目标贴图。注意在边缘处做一点渐变混合不然传送门边界会非常硬。传送门效果有两个注意点一是目标贴图的UV映射建议用极坐标转换到目标贴图的UV这样传送门内看到的画面会有一种“透过圆洞看世界”的扭曲感二是一定要给传送门边缘加上发光带因为透视关系会让光线在边缘弯曲人会自然期待那个区域有一圈亮光。6.2 时空涟漪与技能范围指示的整合思路扭曲的核心公式稍作调整让扰动跟距离衰减不是单调的而是带波峰波谷的振荡就能变成时空涟漪效果。实际操作是把falloff从smoothstep改成sin(dist * frequency) * exp(-dist / radius)这样从中心向外就会形成一圈圈涟漪状的扭曲。这个效果放在技能预警或者Boss出场前的空间波动上非常合适。如果要更进一步把涟漪的相位叠加_Time.y让力场像波纹一样扩散出去就是一个动态的“冲击波”雏形。这套扭曲逻辑已经能用于做很多创新的交互反馈比如靠近NPC时周围空间微微弯曲比如拾取道具时整个场景短暂扭曲再恢复——把时间轴反转做恢复细节感和高级感马上不一样。6.3 性能优化时的LOD策略与平台适配最后聊一下不同平台的LOD策略。PC上效果可以开得比较满但在手机上往往需要简化把噪声采样去掉不要用半分辨率RT甚至可以把扭曲半径缩小。这些差异不应该靠写死判断而是通过一个全局质量档位变量来控制Shader关键字。可以在C#里设定一个_QualityLevel参数根据QualitySettings.GetQualityLevel()动态调整Shader里是否读取噪声图、是否开启中心保护等开关。用Shader的multi_compile加两个keyword比如_DISTORTION_QUALITY_HIGH和_DISTORTION_QUALITY_LOW来控制是否采样噪声图。这样不用维护两套Shader运行时切换质量档位也不会卡顿。写在最后的实战几句话回头再看黑洞扭曲效果的底层原理很单纯——绕中心旋转加向心拉伸的UV偏移但最终观感的差距全在细节处理上。中心保护不能省时间轴转动要加Bloom溢出值得做平台兼容性测试要趁早。我个人的经验是做这类效果一定要有一个“现场调参”的阶段把strength、radius、swirl三个值各拉几个版本同时看不同背景颜色下的表现因为同一个参数在亮场景和暗场景下的体验差别相当大。你先照着本文的方案跑通一遍再把参数按自己的场景重新磨一遍最后的效果应该会让你的同事问一句“这效果怎么做的”——那时候就算是成了。