Unity全屏后处理Shader实现黑洞扭曲效果详解

发布时间:2026/9/6 4:26:26
Unity全屏后处理Shader实现黑洞扭曲效果详解 前阵子有个朋友跑过来问我Unity里那种类似黑洞的效果到底是怎么做出来的。他说自己在网上翻了一堆教程要么是Shader Graph连线连得头皮发麻要么就是付费资源包买回来根本不知道怎么改。其实这个需求在项目里非常常见角色放一个黑洞技能场景边缘要出现空间扭曲传送门开启的时候周边画面要跟着卷进去。核心思路就一句话——用一张全屏后处理Shader把屏幕UV坐标当作“引力场”里的质点按距离和方向重新采样画面就能模拟出那种把光线卷进中心的错觉。这套方案的核心是一个扭曲着色器挂在相机上对整帧画面做后处理不需要改场景里的任何模型和材质也不会影响光照、粒子、UI这些上层逻辑非常适合做技能演出、传送门、加载过场这类全局视觉特效。这篇文章我会从最基础的UV扭曲原理讲起到Shader完整代码落地再到URP下的移植思路和性能优化最后把我在实际项目里踩过的坑一并整理出来。内容尽量照顾到刚入门的同学同时也能给有经验的朋友一些调参和扩展的参考。1. 效果拆解与方案选型很多人一听到“黑洞扭曲”就觉得很高深觉得是不是要涉及到什么相对论、引力场模拟。实际在游戏里做这个东西本质上跟“看水里鱼”是同一个道理——你看到的画面其实是折射后的光线我们只是用UV偏移来模拟这个折射。1.1 黑洞扭曲效果到底在模拟什么如果只看视觉结果黑洞扭曲效果大致由四层东西叠加而成引力透镜画面上的物体沿着“指向黑洞中心”的方向被拉伸离中心越近拉伸越强。这是最核心的扭曲感来源。旋涡感二维画面看起来像是被中心带动着旋转越靠近中心转得越“卷”像水池里的漩涡。事件视界黑洞中心那一小块区域是纯黑的或者几乎看不到细节代表光线被完全吸进去了。边缘扰动真实黑洞外面还有一层吸积盘和杂乱光线边缘不会是一个完美的圆而是有流动、有抖动、有模糊。理解了这四层后面写Shader的时候就有方向了。你不用真的去求广义相对论场方程只需要根据“到黑洞中心的距离”做一个映射函数让偏移量随距离非线性变化就行。1.2 为什么选屏幕后处理而不是粒子特效做这个效果有几条技术路线。第一种是粒子系统模型堆砌比如在中心放一圈粒子模拟吸积盘但这种做法只能做一个“看起来像黑洞”的贴图无法把背景画面真的卷进去真实感差很多。第二种是给特定物体写专用材质Shader比如黑洞本身是一个发光球体通过顶点偏移让模型表面扭曲——这个适合做有实体的黑洞球但背景依然是静态的表现力有限。我自己最推荐的是屏幕后处理方案。原因有两个一是它的作用范围是整帧画面不论背景是地形、角色还是UI都会被自然卷进去视觉统一性最好二是它只依赖一个全屏Pass不增加场景DrawCall性能上最容易控制。它唯一的限制是你只能扭曲最终渲染出来的一张图不能在一个单独物体上局部使用。比如你只想扭曲某个角色手里的小黑洞不想画面其他地方受影响那就得额外用Stencil或者Region机制去裁剪作用区域这个后面可以单独写一篇。2. 扭曲着色器核心原理解读UV即引力场写这个Shader之前先把核心算法想清楚。在屏幕后处理Shader里一片像素所做的事其实只有一个根据当前UV坐标算出一个偏移后的UV然后从_MainTex里采样颜色。所有“扭曲”看上去的效果都来自于这个偏移函数设计得够不够巧妙。2.1 从中心偏移开始构建引力透镜最朴素的引力扭曲做法是把每个像素朝着黑洞中心方向拉一段距离。假设屏幕UV坐标是0到1的矩形黑洞中心在_Center点当前像素在uv那么float2 dir uv - _Center; float dist length(dir); float falloff 1.0 - smoothstep(0.0, _Radius, dist); uv normalize(dir) * _Distortion * falloff;这段代码的意思是离中心越近偏移量越大越往外偏移量平滑减到0。这样就会形成“附近画面被拽向中心”的视觉。问题在于如果直接用normalize(dir)乘一个常量靠近中心的地方会因为拉伸过度出现画面撕裂中心点甚至会变成一个旋涡状的极点。一个更好的做法是用“距离除以偏移量再加衰减”来模拟引力float2 dir uv - _Center; float dist length(dir); float strength _Distortion * pow(smoothstep(0.0, _Radius, dist), 1.5) / max(dist, 0.001); uv normalize(dir) * strength;这里除以dist本质上是模拟跟距离成反比的引力强度。越靠近中心strength越大画面被拉伸得越厉害。max(dist, 0.001)是防止除零。实际调参的时候你会发现dist在分母上会让扭曲变化非常剧烈所以配合pow做衰减可以让过渡更平滑。2.2 让扭曲带上旋涡感叠加旋转场只有径向拉伸画面看起来只是“被吸过去”缺少黑洞那种旋转卷曲感。因为真实黑洞周围的物质不是直线掉进去的而是在角动量作用下绕着中心转圈下落。二维画面里我们要做的是让每个像素绕中心旋转一个角度而这个旋转角度跟距离挂钩。绕中心旋转的公式很固定用二维旋转矩阵// 旋转角度越靠中心角度越大 float angle _RotStrength * (1.0 - smoothstep(_Radius * 0.4, _Radius, dist)); float sina, cosa; sincos(angle, sina, cosa); float2 rotDir float2( dot(dir, float2(cosa, -sina)), dot(dir, float2(sina, cosa)) ); uv _Center rotDir;这一步做完之后画面上所有像素会围绕黑洞中心形成一个涡旋离中心越近转得越明显。这里要注意如果你把角度设得太大靠近中心的像素会转成一团模糊的色块这时候需要调小_RotStrength或者扩大smoothstep的开始半径让外层先平稳过渡到内层再加速。2.3 事件视界与黑洞遮罩让中心变成黑色扭曲做完了但中心区域这会儿可能只是被拉伸得很乱并不会自然变成黑色。我们要主动给黑洞区域加一个遮罩把中心到某个半径以内的像素压暗到纯黑。这个其实很简单用一个smoothstep求出中心区域的强度float blackMask 1.0 - smoothstep(_Radius * 0.1, _Radius, dist); col.rgb * blackMask;为什么从0.1倍的_Radius开始如果直接从0开始中心黑场会变成一个极小的点看起来不像黑洞但如果从0.5倍开始又容易把扭曲还没完全消失的地方直接切断。0.1到_Radius这个范围会让黑场的边缘跟扭曲的衰减大致搭上视觉上“扭曲渐强、亮度渐弱”正好对应黑洞的吸积过程。如果你想做得更精致还可以在这个遮罩边缘加一点暗红或橙色的亮圈模拟吸积盘float disk smoothstep(_Radius, _Radius * 1.08, dist) * (1.0 - smoothstep(_Radius * 1.08, _Radius * 1.25, dist)); col.rgb disk * _AccretionColor * 0.6;2.4 用噪声打破“完美圆形”的生硬感纯数学算出来的扭曲是一个完美的圆形但天然的黑洞边缘是杂乱无章的吸积盘也有亮暗变化。要打破这种“塑料感”最省事的办法是叠加一张噪点纹理让半径、旋转角度、亮度都带一点随机扰动。比如采样一张128x128的RGB噪声贴图float n tex2D(_NoiseTex, uv * _NoiseScale float2(_Time.y * 0.02, 0.0)).r; float noiseRadius _Radius * (1.0 (n - 0.5) * _NoiseAmount);然后在这个noiseRadius基础上重新计算前面的falloff、旋转角度和blackMask。注意噪声一定要放到“计算偏移之前”去采样并且偏移采样用的uv和扭曲后的uv不要搞混。我最初做的时候把噪声放在扭曲之后采样结果边缘的扰动跟着画面一起转会变成一种奇怪的流动马赛克。正确的做法是先采样噪声再算扭曲最后再采样场景颜色。3. 实操在Unity里落地一个可复用的黑洞扭曲组件理论讲完了下面开始写代码。我会按传统内置渲染管线Built-in Pipeline的方式来实现这是最容易理解、也最容易迁移到老项目里的方式。URP的移植方案我会在3.3节专门说。3.1 先跑通后处理管线写一个C#脚本在Unity里做全屏后处理第一件事是拿到相机的渲染结果然后通过材质处理一遍再输出。传统做法是继承MonoBehaviour挂到相机上重写OnRenderImage方法。using UnityEngine; [RequireComponent(typeof(Camera))] public class BlackHolePostEffect : MonoBehaviour { public Shader shader; public Vector2 center new Vector2(0.5f, 0.5f); [Range(0.01f, 1.0f)] public float radius 0.35f; [Range(0.0f, 1.0f)] public float distortion 0.25f; [Range(0.0f, 5.0f)] public float rotateStrength 1.5f; public Texture noiseTex; private Material material; void Start() { if (shader null) shader Shader.Find(Custom/BlackHoleDistortion); material new Material(shader); material.hideFlags HideFlags.HideAndDontSave; } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (material ! null) { material.SetVector(_Center, center); material.SetFloat(_Radius, radius); material.SetFloat(_Distortion, distortion); material.SetFloat(_RotStrength, rotateStrength); if (noiseTex ! null) material.SetTexture(_NoiseTex, noiseTex); Graphics.Blit(source, destination, material); } else { Graphics.Blit(source, destination); } } void OnDestroy() { if (material ! null) DestroyImmediate(material); } }这里有个非常关键的细节在OnRenderImage里设置材质参数时_Center要用屏幕视口坐标也就是0到1的范围。如果你在Inspector里想输入像素坐标一定要自己再除以Screen.width和Screen.height。原因后面“常见问题”里我会细讲。3.2 Shader完整代码与参数解释接下来是主角扭曲着色器本身。这段代码在Unity 2018以上的内置管线可以直接贴到Shader文件里运行Shader Custom/BlackHoleDistortion { Properties { _MainTex (Texture, 2D) white {} _Center (Center, Vector) (0.5, 0.5, 0, 0) _Radius (Radius, Range(0.01, 1.0)) 0.35 _Distortion (Distortion, Range(0.0, 1.0)) 0.25 _RotStrength (Rotate Strength, Range(0.0, 5.0)) 1.5 _NoiseTex (Noise Texture, 2D) grey {} _NoiseScale (Noise Scale, Float) 4.0 _NoiseAmount (Noise Amount, Range(0.0, 0.3)) 0.05 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float2 _Center; float _Radius; float _Distortion; float _RotStrength; sampler2D _NoiseTex; float _NoiseScale; float _NoiseAmount; fixed4 frag (v2f_img i) : SV_Target { float2 uv i.uv; // 1. 先算基础距离和方向 float2 dir uv - _Center; float dist length(dir); // 2. 采样噪声打破完美圆形 float n tex2D(_NoiseTex, uv * _NoiseScale _Time.y * 0.02).r; float radiusWithNoise _Radius * (1.0 (n - 0.5) * _NoiseAmount); // 3. 径向扭曲越靠近中心拉得越狠 float falloff pow(smoothstep(0.0, radiusWithNoise, dist), 1.5); float strength _Distortion * falloff / max(dist, 0.001); uv normalize(dir) * strength; // 4. 重新计算到中心的距离用于旋转和黑场遮罩 dir uv - _Center; dist length(dir); // 5. 旋涡旋转绕中心旋转一个与距离相关的角度 float angle _RotStrength * (1.0 - smoothstep(radiusWithNoise * 0.4, radiusWithNoise, dist)); float sina, cosa; sincos(angle, sina, cosa); float2 rotDir float2( dot(dir, float2(cosa, -sina)), dot(dir, float2(sina, cosa)) ); uv _Center rotDir; // 6. 采样最终颜色 fixed4 col tex2D(_MainTex, uv); // 7. 中心黑场遮罩 float blackMask 1.0 - smoothstep(radiusWithNoise * 0.1, radiusWithNoise, dist); col.rgb * blackMask; return col; } ENDCG } } }这个Shader里我最想提醒的是第3和第5步的顺序你先做径向扭曲再做旋转扭曲最后才采样颜色。如果反过来先旋转再径向拉中心会像搅拌机一样把颜色混得乱七八糟而且无法形成“直线被径向拉弯”的效果。这里面每一步都在操作uv本质上是一个复合函数顺序变了结果差很多。3.3 URP中的快速实现思路如果你项目用的是URP上面的OnRenderImage是无效的原因是URP统一走ScriptableRenderPipeline渲染流程由RenderFeature控制。最简单的做法是写一个自定义Renderer Feature在渲染结束阶段用CommandBuffer执行一次全屏Blit。核心代码大致是这样public class BlackHoleRenderFeature : ScriptableRendererFeature { public Material material; public RenderPassEvent renderPassEvent RenderPassEvent.AfterRenderingTransparents; class Pass : ScriptableRenderPass { public Material material; public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd CommandBufferPool.Get(BlackHolePost); var source renderingData.cameraData.renderer.cameraColorTargetHandle; var temp Shader.PropertyToID(_TempRT); cmd.GetTemporaryRT(temp, renderingData.cameraData.camera.pixelWidth, renderingData.cameraData.camera.pixelHeight, 0, FilterMode.Bilinear); cmd.Blit(source, temp, material); cmd.Blit(temp, source); cmd.ReleaseTemporaryRT(temp); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } private Pass pass; public override void Create() { pass new Pass(); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (material null) return; pass.material material; renderer.EnqueuePass(pass); } }这里有个小坑老版本URP里cameraColorTargetHandle叫cameraColorTarget新版本改成了带Handle的变量。如果报错找不到字段看一下你自己的URP包版本直接用RT标识符或者FrameData里的颜色缓冲都行。URP 17以上还支持Fullscreen Pass Renderer Feature如果项目支持直接用那个更省事只需要输出材质就行不用写Renderer Feature类。4. 参数调节与常见问题排查代码写完只是第一步真正花时间的是调参。同一个Shader参数合适可以做出很自然的黑洞参数不对会直接变成一坨色块糊在屏幕上。4.1 一组可参考的起始参数与调参顺序我建议新同学按这个顺序调不要一上来就乱拖参数作用推荐起始值_Center黑洞中心位置(0.5, 0.5)_Radius扭曲和黑场的影响范围0.35_Distortion径向吸附强度0.2_RotStrength旋转强度1.2_NoiseScale噪声贴图缩放4.0_NoiseAmount半径随机干扰强度0.05第一步先把_RotStrength调成0只看径向扭曲。确认黑洞中心位置对不对半径是否覆盖了想要的区域。第二步慢慢加_Distortion你会看到四周画面向中心弯折。如果画面出现明显撕裂说明强度太大或者smoothstep的衰减太快。第三步恢复_RotStrength一点点加旋转直到出现“向中心螺旋收拢”的感觉。第四步开噪声给边缘加流动感。另有一个挺容易忽略的地方_Center在C#里传的是Vector2但在Shader里声明为float2。如果你的中心点还需要跟随某个物体位置那么要用世界坐标转屏幕坐标。我在一个项目里做过“黑洞跟着Boss手走”的效果就一定要在C#里把Boss的世界坐标用相机.WorldToViewportPoint转成0-1再传进去否则位置对不上。4.2 问题排查速查表后处理Shader出Bug时症状往往是“全屏黑”或“错误色块”不太好排查。我把项目里大概率会碰到的几类问题整理成一张速查表问题常见原因解决方案整个屏幕全黑材质参数没设置或者Shader编译失败检查Console报错用Shader.Find确保路径正确确认脚本挂在了Camera上扭曲现象存在但中心不是黑的没有做黑场遮罩或者遮罩半径太小检查blackMask代码增大_Radius或把smoothstep的起始值调小扭曲边缘有明显硬边falloff不够平滑或if分支直接截断用smoothstep替代step确保衰减在半径处归零画面上有一条横线或对角线状的拉伸后处理RT尺寸不匹配检查是否手动创建了RT但未按屏幕尺寸初始化确认Graphics.Blit目标正确移动端严重卡顿全屏遍历像素开销大用半分辨率RT做后处理或者Shader里做early-out黑洞中心位置跟物体总是对不上_Center单位错误确保传入视口坐标0-1而不是像素坐标如果用的像素坐标请除以Screen.width/height噪声边缘出现闪烁噪声采样频率过高或时间因子太大降低_NoiseScale减小_Time.y系数或把噪声精度改为half4.3 从性能角度谈后处理优化黑洞扭曲Shader的性能开销主要在于每个像素都要做sincos运算和纹理采样。如果全屏分辨率是1080p那就是200多万个像素点每帧都要跑一遍移动端压力不小。第一个优化思路是“降分辨率后处理”。在C#脚本里把扭曲过程渲到一张半尺寸或四分之一尺寸的RenderTexture上再升采样回目标分辨率。这样计算量直接少一半甚至四分之三画面细节损失肉眼基本看不出来尤其对扭曲这类“看整体”的效果。RenderTexture halfRT RenderTexture.GetTemporary(source.width / 2, source.height / 2, 0, source.format); material.SetFloat(_Radius, radius); material.SetFloat(_Distortion, distortion); material.SetFloat(_RotStrength, rotateStrength); Graphics.Blit(source, halfRT, material); Graphics.Blit(halfRT, destination); RenderTexture.ReleaseTemporary(halfRT);注意如果降分辨率到一半_Radius这类参数要不要跟着缩放取决于你的设计。如果你的_Radius是屏幕比例值那不需要动如果你传入的是像素半径则需要除以2。这个坑我不止一次踩过建议统一用视口坐标0-1表示半径。第二个优化是“区域跳过”。如果像素离黑洞中心足够远完全没有被扭曲影响那就没必要做后面的计算。可以加一个early-outif (dist _Radius * 2.5) return tex2D(_MainTex, uv);但注意不要留下明显边界。一般用smoothstep在半径外再做一个过渡或者干脆把“无扭曲”区域也交给后面的smoothstep去平滑衰减只在极远处提前返回。这个优化在黑洞占画面比例小的时候收益非常明显我测过在4K分辨率下能把全屏后处理的开销从1.8ms降到0.9ms。第三个优化是“减少采样次数”。核心场景色的_MainTex只需要采样一次噪声纹理用128x128或256x256就够不要加载超大纹理。如果在Shader里还想做发光、模糊、边缘光一定要在降分辨率RT上做不要在原始全屏分辨率上反复采样。5. 玩法与应用不仅是一个黑洞黑洞扭曲Shader写完之后你能做的其实远不止一个黑洞。我强烈建议你把它当做一个“全屏扭曲工具”来用因为它的底层能力是UV重映射而UV重映射可以衍生出很多特效。5.1 黑洞传送门把扭曲和场景切换结合一个很常见的扩展是“传送门效果”。思路是黑色中心不再只是压暗而是替换成另一个镜头渲染出来的纹理。实现上需要准备一张_TargetTex也就是传送门目标位置的相机渲染结果。然后当dist小于某个阈值时采样_TargetTex而不是_MainTexif (dist _Radius * 0.6) { float2 portalUV uv * 0.5 0.5; // 实际要用目标相机的UV变换 col tex2D(_TargetTex, portalUV); }这样画面会先被卷进中心然后在中心“穿越”到另一个场景。再配合一个淡入淡出的过渡曲线整个传送门效果就立住了。如果你不是做传送门只是想做一个“时间裂缝”或者“技能吸收”效果也可以把中心替换成粒子纹理或颜色渐变逻辑都是一样的。5.2 用AnimationClip驱动参数做演出控制黑洞效果最怕“一直转”那是灾难级的视觉疲劳。实际项目中通常会让它有一个生命周期生成、扩大、持续、收缩、消失。这个周期用代码写也行用Animator更直观。比如在C#里暴露一个public AnimationCurve radiusOverTime然后Update里这样调用private void Update() { if (material null) return; float eval radiusOverTime.Evaluate(time); material.SetFloat(_Radius, baseRadius * eval); material.SetFloat(_Distortion, baseDistortion * eval); }曲线前半段快速从0升到1然后持续一小段最后缓慢降回0。这样黑洞看起来就有“吸一口气再吐出来”的节奏。很多新手忽略了这个演出节奏只盯着一帧的画面效果调结果动态跑起来反而很怪。我建议一开始就把时间曲线设计进去。5.3 多个黑洞同时存在的处理方式如果你想让一个场景里同时出现多个黑洞有两个办法。一是挂多个后处理脚本一个黑洞对应一个Shader实例按顺序叠加。代价是每多一个黑洞就多一次全屏遍历性能翻倍。二是在一个Shader里支持数组传入最多4个黑洞的中心点和半径在frag里做循环或展开。后者性能更可控但是代码复杂度会上来一点。对于移动端我建议都别用老老实实做一个黑洞就好。如果一定需要多个黑洞又不想全屏遍历可以考虑“局部Stencil”方案先用一个Pass把黑洞影响的区域标记到Stencil Buffer里后处理Shader里只处理Stencil标记范围内的像素。这个方案代码量不小但确实能在多黑洞场景下大幅减少无效像素计算。等有空了我再单独写一篇Stencil后处理的文章。最后分享一个实际踩过的坑写Shader的时候我最常犯的一个错误就是把_Center当成屏幕像素坐标来传。当时在一个适配不同分辨率的项目里黑洞位置在编辑器里看着好好的一到真机上位置就偏到右下角。心里清楚是坐标单位问题但找了好久才意识到后处理Shader里的UV坐标永远是0到1不是像素。真机上屏幕分辨率一变直接用像素坐标就会出问题。正确的做法是从相机的WorldToViewportPoint拿坐标或者在最开始就把像素坐标除以屏幕宽高。另外一个很小的坑是Shader变体裁剪。如果这个Shader在项目里没有任何材质引用Unity打包的时候有可能把它裁掉运行时找不到Pass直接全屏黑。我一般会在Resources目录下放一个用这个Shader创建的材质模板或者在Project Settings的Graphics设置里把这个Shader加入到Always Included Shaders列表里确保打AB包也不会丢。这个扭曲着色器的方案我用了挺长时间最早只是想做个黑洞技能后来陆陆续续用它做了传送门、异空间裂缝、全屏时空扭曲这些效果。核心代码展开也就几十行但它解决的是“如何让画面真正动起来”这个通用需求。你如果把UV重映射这条思路吃透了以后再看很多渲染特效就不会觉得孤立了。改天有空我再把传送门那套联动摄像机的实现细节整理出来。