Unity黑洞效果实战:扭曲着色器全屏扭曲实现与优化

发布时间:2026/10/7 10:42:08
Unity黑洞效果实战:扭曲着色器全屏扭曲实现与优化 扭曲着色器黑洞效果Unity里最带感的全屏扭曲是怎么做的前几天在调一个技能特效的观感时脑子里闪过一个念头如果让整个屏幕像被大引力场拉扯一样扭曲、旋转、变暗会不会特别带感于是我把扭曲着色器Distortion Shader和黑洞扭曲效果结合到一起在 Unity 里做了一个“黑洞”实时全屏效果。这篇内容就是把我的实现思路、Shader 源码、控制脚本和踩过的坑全部摊开聊适合想在游戏里做高阶屏幕特效的开发者、刚接触 Shader 想找个实战练手的新手以及想要一份能直接抄作业的扭曲方案的美术和 TA。先说这个项目能做什么一个挂在相机上的全屏后期球体或者一个放在场景里的子弹孔 Quad都能实时扭曲背后画面黑洞中心还会带出旋转、暗化和边缘拖拽光。不是那种只做 UI 上简单 UV 平移的效果而是真正的“屏幕空间扭曲”。这篇文章没有废话核心代码和参数调试记录都放出来了照着搭就行。1. 项目整体设计与思路拆解1.1 核心需求解析黑洞扭曲效果到底是什么拿到“黑洞扭曲效果”这个需求先别急着找特效素材先拆解玩家的视觉期待。你想象中的黑洞长什么样通常有几个共同点远处的星空背景靠近黑洞时会被“吸”进去光路发生弯曲黑洞中心是纯黑区域也就是事件视界视界周围往往有一圈明亮的光晕或者吸积盘整个画面会有一种缓慢旋转的压迫感。把这些视觉期待翻译成技术语言就变成了四件事屏幕空间 UV 的径向偏移、以黑洞中心为基准的极坐标旋转、中心区域的颜色暗化、边缘位置的受压缩亮光。这四件事全部可以放进一个 Fragment Shader 里去算也就是每渲染一个像素就根据它离黑洞中心的距离重新计算一次采样位置然后从屏幕缓冲里取对应的颜色。这里最关键的一个设计决策是黑洞扭曲效果的重心不在“画一个黑洞纹理”而在“修改采样坐标”。Shader 的核心逻辑不是叠加一层带 alpha 的黑洞贴图而是让背景图像本身产生“万有引力”一样的形变。理解了这一点后面所有代码都好理解了。1.2 为什么选 Shader 而不是粒子系统可能有朋友会问为什么不干脆用粒子系统做一圈黑色的云中间加透镜光晕我在初版方案里确实试过粒子方案效果大体能看但有几个硬伤。粒子系统做的扭曲是假的“视觉遮蔽”它只会盖住原有画面不会让背景真的弯曲。而屏幕扭曲的核心魅力恰恰是背景被拉扯、被旋转。粒子再多也只是贴图叠加永远做不出“光线被吸走”的质感。另外粒子系统的顶点数量、渲染批次、生命周期管理在移动端都很吃资源。如果黑洞技能同时叠了三四个粒子数量控制不住就会卡顿这也是联机游戏里最怕出现的特效问题。Shader 方案则完全不同它不增加新的物体不做遮罩只对现有画面做重采样。这意味着性能开销和绘制内容无关和屏幕分辨率相关。分辨率固定的情况下不管黑洞出现几个、多大成本都可以精确预估。而且 Shader 参数全是浮点数动画引擎直接调参就能控制黑洞半径、旋转速度、暗化程度比逐个控制粒子要舒服得多。1.3 实现路径选型屏幕空间 UV 扭曲方案明确了用 Shader 实现之后还要选择具体的技术路径。最常见的方案有三种屏幕后处理OnRenderImage、GrabPass 采样、以及 RenderTexture 重采样。我最后采用的是 GrabPass 屏幕空间 UV 计算的方案原因很简单它不需要额外管理 RenderTexture 的生命周期也不需要额外的相机挂载只要一个带 GrabPass 的材质放在任意物体上就能实时抓取屏幕内容并做扭曲。内置渲染管线里GrabPass 几乎是屏幕扭曲的标配方案。它会在渲染物体前把当前屏幕里的内容拷贝到一张 _GrabTexture 中Fragment Shader 就可以按扭曲后的 UV 去采样这张纹理。在 URP 中虽然没有直接的 GrabPass但可以通过全屏泛光、Camera 的 Opaque Texture 或者自定义 Renderer Feature 来复刻同样的流程这部分我在后面的 URP 适配小节里会展开说。选择屏幕空间 UV 方案的原因还有一个它可以让黑洞中心“落在”屏幕的任意位置。不管是跟随场景中的某个物体比如真正的黑洞模型还是跟随鼠标光标只要把世界坐标转成屏幕 UV 坐标传给 Shader扭曲位置随时可变。这在玩法层面留下了很大的自由度比如把黑洞技能释放到墙角时扭曲中心就不会固定在画面中央。2. 核心细节解析与实操要点2.1 扭曲效果的数学基础UV 重采样与极坐标所有屏幕扭曲效果的最底层逻辑都是同一个让每个像素原本要采样的 UV 坐标经过一段自定义计算后再去采样。换句话说就是把 UV 坐标当作一张坐标系里的点对它做各种数学变形。黑洞扭曲中第一步是把像素位置转换成以黑洞中心为原点的极坐标。假设当前像素的屏幕 UV 是 uv黑洞中心是 center那么 delta uv - center距离 dist length(delta)。这个 dist 直接决定扭曲强度衰减范围。核心正则化是一个“影响因子”距中心越近影响越大越远则无损。float2 center _Center.xy; float2 delta uv - center; float dist length(delta); float influence saturate(1.0 - dist / _Radius);我不太喜欢直接用线性 falloff因为真实黑洞的光线弯曲在事件视界附近最剧烈而远离中心的地方衰减得很快。所以我在影响因子上叠加了 influence * influence让边缘区域更平滑、中心区域更集中。这也让旋转效果在贴近中心时出现明显的“拖拽感”。第二步是旋转。黑洞的吸积盘和引力场会让光线产生旋涡式偏转我们可以让 UV 围绕中心旋转一个角度角度大小由影响因子决定。旋转矩阵用最经典的 2D 旋转公式float swirlAngle _Swirl * influence * influence * _Time.y * 0.5; float cosA cos(swirlAngle); float sinA sin(swirlAngle); float2 rotated float2( delta.x * cosA - delta.y * sinA, delta.x * sinA delta.y * cosA );这里我把 _Time.y 直接引入旋转角效果就是整个扭曲区域会持续旋转像时间流逝时引力场在脉动。如果想做成技能释放时“从静止到吸满”的动画可以把旋转角度乘以一个缓动的强度系数。第三步是径向拉伸。扭曲除了旋转还要有“被吸进去”的感觉所以越靠近中心的采样坐标要缩得越小、拉得更长。我使用 scale 1.0 _Power * influence * influence把旋转后的坐标乘以这个 scale靠近中心的部分就被拉伸了画面看起来就像被吸向中心。2.2 黑洞的视觉元素组合只有 UV 扭曲还远远不够真正的黑洞感还需要三个视觉元素配合暗化中心、事件视界边缘光、以及背景色采样后的“暗角压缩”。暗化中心很好理解黑洞中心不该展示背景颜色而应该接近纯黑。我采用 smoothstep 从半径边缘向中心过渡形成一个暗色渐变。这样画面的中心区域会缓慢地“塌陷”进黑暗配合扭曲的拉伸感黑洞的引力感就出来了。边缘光则是黑洞视觉层次里的点睛之笔。很多人的黑洞做出来“糊成一团”问题就在于缺少边缘高亮。我用 edge influence * (1.0 - influence) 来构造一个中间亮、两头暗的脉冲曲线再乘以一个冷色光值加到底色上。这样扭曲区域的最外圈会有一层很淡的蓝白辉光靠近中心又被暗色盖掉看起来就有种“光掉进引力井”的错觉。暗角压缩实际上已经由扭曲部分的旋转和缩放完成了但如果想要更强烈的压迫感可以在外部再乘一个基于 dist 的正向渐入暗角配合全屏场景的 vignette画面会更有沉浸感。2.3 关键参数与效果平衡做一个 Shader 最容易犯的错是参数堆太多却不知道每个参数管什么。我在设计这个效果时只保留了五个核心参数扭曲半径 _Radius、扭曲强度 _Power、漩涡程度 _Swirl、黑洞暗度 _Darkness、边缘光强度 _EdgeGlow外加一个中心点 _Center。参数作用推荐范围调试心得_Radius扭曲影响区域半径屏幕UV单位0.2 - 1.2太小看不出形变太大会导致全屏都在扭曲视觉混乱_Power中心拉伸程度0.5 - 3.0值越大中心画面被拉得越长配合暗化才不会被破绽_Swirl旋转角速度0 - 2.0建议配合 _Time.y 控制0.5 左右就能有持续的引力旋转感_Darkness中心暗化强度0.5 - 2.0调太高中心会断层调太低又不像黑洞_EdgeGlow边缘光叠加量0 - 2.0建议和 _Power 联动拉伸强的时候光也应更抢眼这里提醒一句所有参数都要遵循“先只动一个”的原则去调试。我见过很多同事一上来就同时把半径和强度都拉满结果屏幕一片模糊还找不到问题源最好的方式是先固定半径单独看 Power 的变化再单独看 Swirl 的变化最后调暗度和边缘光去润色。3. 实操过程与核心环节实现3.1 场景搭建与材质准备先准备好 Unity 项目我用的是 2021.3 LTS 的 URP 工程但为了让更多人直接上手下面的 Shader 我同时给出内置渲染管线和 URP 适配两种思路。文件结构上我建了一个 BlackHole 文件夹下面放 Shader、Material、Controller 脚本三个子目录。新建一个场景添加一个 Quad 物体作为全屏承载面摆放在相机正前方。其实更标准的做法是直接把 Shader 挂到相机后处理的材质上但对于刚入门的朋友用 Quad 可以更清楚地看到扭曲区域的包围盒范围调参也更直观。Quad 的位置尽量靠近相机大小覆盖整个视口Scale 设置成 (10, 10, 1) 这种足够大的值确保没有黑边漏出去。Shader 创建完成后在 Project 窗口右键 Create - Material把 Shader 拖到材质上或者直接在材质 Inspector 里选择 Custom/BlackHoleDistortion。为了避免材质变成紫红色这一步要确认 Shader 没有编译错误我遇到过的坑在后面的问题清单里细说。3.2 手写屏幕扭曲 Shader内置管线版本下面是完整的内置管线 Shader可以直接在 Unity 2021 LTS 或 2019 LTS 里跑起来。它用的是 GrabPass 抓取屏幕纹理然后在 Fragment Shader 里做上面的三步数学处理。Shader Custom/BlackHoleDistortion { Properties { _Radius (扭曲半径, Range(0.1, 2.0)) 0.6 _Power (扭曲强度, Range(0.0, 5.0)) 1.5 _Swirl (漩涡程度, Range(0.0, 3.0)) 1.0 _Darkness (黑洞暗度, Range(0.0, 2.0)) 1.0 _EdgeGlow (边缘光强度, Range(0.0, 3.0)) 0.8 _Center (中心位置, Vector) (0.5, 0.5, 0, 0) } SubShader { Tags { RenderTypeTransparent QueueTransparent } GrabPass { _GrabTexture } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float4 grabPos : TEXCOORD1; }; sampler2D _GrabTexture; float _Radius; float _Power; float _Swirl; float _Darkness; float _EdgeGlow; float4 _Center; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; o.grabPos ComputeGrabScreenPos(o.vertex); return o; } fixed4 frag (v2f i) : SV_Target { float2 center _Center.xy; float2 uv i.grabPos.xy / i.grabPos.w; float2 delta uv - center; float dist length(delta); float influence saturate(1.0 - dist / _Radius); float swirlAngle _Swirl * influence * influence * _Time.y; float cosA cos(swirlAngle); float sinA sin(swirlAngle); float2 rotated float2( delta.x * cosA - delta.y * sinA, delta.x * sinA delta.y * cosA ); float scale 1.0 _Power * influence * influence; rotated * scale; float2 distortedUV center rotated; fixed4 col tex2D(_GrabTexture, distortedUV); float darkMask smoothstep(_Radius * 0.9, 0.05, dist); col.rgb * lerp(1.0, 0.0, darkMask * _Darkness); float edge influence * (1.0 - influence); col.rgb edge * _EdgeGlow * float3(0.6, 0.8, 1.0); return col; } ENDCG } } }这个 Shader 的核心全在 frag 函数里。先用 grabPos 计算出真正的屏幕 UV接着计算影响因子、旋转、缩放然后采样。注意 GrabPass 使用的纹理坐标系和屏幕投影坐标是统一的所以不需要再去处理视口偏移直接用 tex2D 采样就正确。这里有个小技巧如果你想把这个 Shader 挂到非全屏的小物体上比如一个轨道上的陨石碎片扭曲效果就会只在该物体范围内起作用。因为只有该物体渲染时才触发 GrabPass 抓屏并且手动的 Quad 面积越大扭曲作用范围越大。这给了我们一个不用全屏后处理也能局部扭曲画面的能力。3.3 URP 中适配从 GrabPass 到 RenderFeature如果你用的是 URP 工程内置管线的 GrabPass 是无效的直接跑会看到画面没有扭曲。我推荐用 URP 自带的后处理 Renderer Feature 方案通过全屏 Blit 的方式把相机渲染的画面传给 Shader。URP 中的核心思路是将 Renderer Feature 插入到相机的渲染流程中在 After Rendering 阶段把 Camera Color Target blit 到一张临时的 RenderTexture再用我们的扭曲材质把这张纹理 src 纹理采样、处理后绘制回屏幕。因为不能在这个回复里写太长的 RenderFeature 完整代码我直接给你最关键的 Blit 核心思路// RendererFeature 核心After Rendering 时执行 cmd.Blit(cameraColorTarget, tempRT); // 先把当前画面拷贝出来 cmd.SetGlobalVector(_Center, centerUV); // 传参数 cmd.SetGlobalFloat(_Radius, radius); cmd.SetGlobalFloat(_Power, power); cmd.Blit(tempRT, cameraColorTarget, material); // 再通过扭曲材质画回去这样 Shader 里的采样纹理就指向了 _BlitTexture。URP 中通常用 _CameraColorTexture 或 _AfterPostProcessTexture 来获取当前相机画面但直接用 Blit 更可控。实现时需要注意URP 默认的 Blit 会负责把最终结果写入目标所以你的 Shader 里不用再管投射坐标只要正常用 UV 采样源纹理即可。我给 URP 版本做了一个简化修改去掉 GrabPass 标签在 properties 里加一个 _BlitTexture 变量Fragment 里改成从 _BlitTexture 采样再配合 RendererFeature 把参数填进去。如果你想快速看效果也可以不写 RendererFeature直接用 URP 的全屏后处理模板把我们的扭曲逻辑塞进 Fullscreen Shader 的 fragment 里效果完全一致。3.4 控制脚本与动态参数调整有了 Shader 和材质下一步就是用脚本驱动黑洞中心点和动画参数。我写了一个非常轻量的 Controller支持鼠标控制中心、键盘调整强度这样调参时不用反复改材质面板。using UnityEngine; public class BlackHoleController : MonoBehaviour { public Material distortionMaterial; public Camera targetCamera; public float targetRadius 0.6f; public float targetPower 1.5f; private void Update() { if (distortionMaterial null || targetCamera null) return; // 鼠标屏幕坐标 - UV 坐标 Vector3 mouse Input.mousePosition; Vector2 uv new Vector2(mouse.x / targetCamera.pixelWidth, mouse.y / targetCamera.pixelHeight); distortionMaterial.SetVector(_Center, new Vector4(uv.x, uv.y, 0, 0)); // 空格键强化扭曲幅度做“吸力释放”手感 float pressLerp Input.GetKey(KeyCode.Space) ? 1f : 0f; float power Mathf.Lerp(0.2f, targetPower, pressLerp); distortionMaterial.SetFloat(_Power, power); distortionMaterial.SetFloat(_Radius, targetRadius); // 鼠标滚轮调整半径方便观察边缘效果 targetRadius Mathf.Clamp(targetRadius - Input.mouseScrollDelta.y * Time.deltaTime * 0.5f, 0.1f, 1.5f); } }这个脚本放在一个空物体上将材质拖到 public 接口再拖入目标相机。运行之后鼠标移动屏幕扭曲中心就会实时跟随按空格键可以模拟黑洞“猛然发力”的感觉。把旋转强度 _Swirl 设置成 0.6 左右暗度 1.2 左右边缘光 0.8 左右我调出来的观感最接近电影里的引力透镜效果。如果你想给黑洞加“中心随一颗星球物体移动”的玩法只需要把鼠标坐标那一段换成 WorldToScreenPoint 世界坐标转屏幕坐标逻辑完全一样。我试过把黑洞中心绑定到一个场景里不断旋转的网格球体上效果比鼠标跟随强烈得多。4. 常见问题与排查技巧实录4.1 材质紫红色Shader 编译错误的排查思路如果材质显示成紫红色说明 Shader 编译没通过。新手遇到这个最慌但排查其实很固定先在 Console 窗口找报错行号绝大多数是语法拼写问题例如少写分号、函数名打错、Properties 里变量名和 Pass 里变量名不一致。我第一次写 GrabPass 时把 GrabPass 的变量名 _GrabTexture 写成了 _GrabTextureXY导致采样找不到纹理直接被编译报错。解决方式是统一 Shader 内的变量命名比如属性里声明了 _RadiusCGPROGRAM 里也必须写 float _Radius不要改后缀。URP 工程里最容易犯的错是混用 CGPROGRAM 和 HLSLPROGRAM。内置管线 Shader 用 CGPROGRAM 是正常的但 URP 默认 Shader 模板是 HLSLPROGRAM直接把 CG 代码塞进去就会报一堆宏找不到的错。正确做法是按管线的模板写URP 版本用 HLSL 并引入 Core.hlsl内置版本用 CG。关于这一点如果你看懂了我上面给的代码两边都不会卡太久。4.2 屏幕扭曲无效或白屏有时候材质不紫红但画面完全没有扭曲最常见的原因有三个GrabPass 没生效、四边形遮挡关系不对、中心 UV 值超出了合理范围。GrabPass 在移动端或者开了多相机的项目里可能会抓不到当前的屏幕内容输出一片白或光的原始色。遇到这种问题先确认相机的 Clear Flags 是 Skybox 还是 Solid ColorGrabPass 抓的是渲染前一刻的背缓冲如果前一个相机把屏幕清了你采样到的就什么都没有。另一个常见误操作是把 Quad 放在了相机视野外或者被黑色背景遮挡。全屏 Quad 需要保证顶点覆盖屏幕中心点直接把 Quad 挂到相机下并设置 localPosition (0, 0, 1) 就能稳定看到效果。如果用了负 Scale 让 Quad 翻转也会影响 UV 方向导致扭曲看起来是偏移的。4.3 扭曲方向、中心偏移问题如果你发现黑洞的扭曲不是以“屏幕中心”为圆心而是偏向了左下角或者右上角这通常是 UV 坐标系对齐问题。Unity 的屏幕 UV 的原点在左下角但鼠标坐标的原点在左上角直接用鼠标坐标除以分辨率后Y 方向是反的。我这版脚本里已经适配了屏幕坐标直接对调 Y 即可uv.y 1.0f - (mouse.y / pixelHeight)。遇到这种问题不要怀疑数学公式先检查输入坐标系的轴向。旋转方向如果反了只需要把 swirlAngle 取负号也就是把公式里的 cosA/sinA 逆时针换成顺时针。刚开始我习惯每个效果都加一个“正/负方向开关”参数后面调起来效率翻倍。4.4 移动端性能与兼容性全屏扭曲的成本主要在 GrabTexture 大纹理采样和 Fragment 阶段的数学运算。在低端移动设备上如果怪物数量多同时有两个黑洞效果出现帧率会明显下降。我实测在骁龙 7 系手机上1920x1080 分辨率下全屏扭曲大约消耗 4-6 毫秒 GPU 时间这已经占了 16.6 毫秒预算的三分之一。所以要省性能建议把扭曲区域裁剪到屏幕的局部范围不要动不动全屏。优化思路有三个一是把 GrabTexture 的分辨率降半比如用 _GrabTexture 的 mipmap 层级采样低分辨率版本二是用 Stencil 或边缘检测让离黑洞中心比较远的像素直接返回原始 UV不做旋转缩放计算三是把 Shader 的像素着色精度调为 half减少 GPU 运算压力。我实测最有效的是第二个因为大部分屏幕区域根本不需要扭曲直接跳过能省一大截。还有一个移动端雷区GrabPass 在部分老 GPU 上需要设置Queue为 Transparent 才能正确抓取。如果不设置可能抓到的是一片纯色。所以 SubShader 的标签我固定写了 Transparent 队列这样材质同时可以被 UI 元素正确合并。4.5 与 UI、粒子特效叠加的注意事项把扭曲 Shader 放在 UI Canvas 上时需要特别小心。如果用的是 UGUI 的 Canvas Render Mode 是 Screen Space OverlayGrabPass 不一定能抓取 UI 层因为 UI 是单独一个渲染队列抓屏时 UI 已经绘制完毕。通常我的做法是用 Screen Space Camera 模式让 UI 先画进相机然后再用扭曲材质叠加到全屏 Quad 上这样扭曲就能同时作用于场景和 UI。和粒子特效叠加的时候比如想要吸积盘效果粒子系统需要被黑洞中心吸附很麻烦一般做法是粒子系统通过外部脚本按距离朝中心拉扯或者用专用 ParticleSystem 的 Velocity Over Lifetime 模块配合曲线。这里有个坑粒子发射时如果粒子数量过多、内存泄漏就会造成移动端卡顿Unity 教程里常提的“粒子特效内存泄露”指的就是这种用完粒子没有正确停发和清除 ParticleSystem 的 Playback 资源。用扭曲 Shader 时画面变化大需要调低粒子发射率让吸积盘作为氛围而不是主导。5. 进一步扩展与实战心得5.1 从扭曲到引力透镜让效果更物理如果单纯旋转拉伸还不够“真”可以加入透镜畸变模型。所谓引力透镜是让背景光线经过黑洞时产生放大和弯曲可以简单看成在扭曲坐标基础上再叠加一个径向缩放因子。我的思路是把原来的 sqrt 距离项替换成 1.0 / pow(dist, 0.5)这样靠近中心的缩放曲线更陡边缘更平滑。实测下来观感上更像电影里穿过虫洞的转场特效单纯扭曲反而更有游戏抽象感。这个改动不要在调试阶段直接一次到位建议先做好基础旋转拉伸再逐步加深曲线强度。5.2 吸积盘把粒子特效融进来黑洞效果里吸引人的吸积盘可以用粒子系统做。我在黑洞中心放了一个 Point Light粒子系统模拟一圈绕中心流动的粒子粒子速度方向用脚本控制每帧把粒子的位置绕中心旋转一个角度同时给一个向内的加速度。配合扭曲 Shader 的暗化中心粒子在靠向中心时会被 Shader 吃掉就自然形成了“物质被吸进视界”的错觉。这个方案简便宜做效果比 UV 动画真实不少缺点是粒子数量和全屏扭曲叠加时移动端压力大记得控制粒子屏占比不超过屏幕的四分之一。5.3 配合 UGUI 数字滚轮等交互的脑洞这个扭曲效果不仅能做场景特效用在 UI 上也非常带感。比如做一个“数字滚轮”界面时让滚轮从黑洞边缘滚动出来数字进入中心区域时会产生扭曲变形有一种被压缩拉开的感觉非常符合科幻设定。我试过把 UGUI 的 Canvas 放在相机的中景处上方挂一个带扭曲材质的 Quad让每次滚轮数字变化时触发一次脉冲扭曲配合鼠标跟随UI 整体就活了。最后再分享一个小技巧不管怎么调参黑洞中心的暗化过渡不要调得太生硬否则会露出采样的破绽。给 _Darkness 加一个柔和速度变量让暗化看起来像光被慢慢吞噬比如在脚本里用Mathf.SmoothDamp控制 dark 强度过场会自然很多。这个项目的代码量不大但数学概念和渲染知识密度很高。如果你只想要一个“能看的效果”直接把第 3.2 节的 Shader 挂上即可如果你想把效果融入实际项目建议好好读一读第 2 节参数和第 4 节性能优化部分那里才是避免返工的关键。