1. 效果拆解与实现方案选型
1.1 黑洞扭曲效果的核心视觉构成
先说结论:Unity里做黑洞扭曲效果,本质上不是“真的造一个黑洞”,而是在屏幕空间做一次有方向的UV偏移——模拟光线经过强引力场时被弯曲的视觉效果。这个方案不需要物理模拟,不需要粒子系统,一个Shader加上一个控制脚本就能跑起来,性能开销也完全可控。
拆开看,一个让人“哇”出来的黑洞扭曲效果,至少包含三层视觉元素:
- 扭曲层:背景画面以黑洞中心为圆心,径向被拉伸、扭转,越靠近中心扭曲越剧烈,这是“引力透镜”感的来源。
- 暗核层:中心区域必须是一个纯黑的圆形,因为黑洞本身不发光,连光都逃不出来。这个黑核的边界要柔和,不能是硬边,否则一眼就穿帮。
- 边缘光晕(可选):围绕黑洞边缘有一圈发亮的吸积盘效果,用噪声纹理做UV扰动就能模拟出那种滚动的炽热感。这一层属于加分项,但加了之后质感提升非常明显。
我最初踩的一个坑是把重点全放在扭曲上,结果做出来的东西像“水面旋涡”,不像黑洞。后来反复调才发现,暗核的边界柔和度和扭曲强度之间的比例关系才是像不像的关键——扭曲要强但过渡要长,黑核要实但边缘要虚。
1.2 为什么选择屏幕空间后处理方案
实现扭曲效果有三条路:模型顶点扭曲、粒子模拟、屏幕空间后处理。我直接说结论,做黑洞扭曲效果,无脑选屏幕空间后处理。
模型顶点扭曲只对3D模型生效,背景是天空盒或者UI的时候就完全没辙了。粒子模拟适合做吸积盘的“喷流”效果,但拿来做整体画面的扭曲,粒子数量和精度都不够看。屏幕空间后处理的思路是把整个屏幕渲染成一张纹理,然后根据每个像素和黑洞中心的距离来重新采样这张纹理,实现“看似空间被弯曲”的视觉效果。
后处理方案最大的优势是效果和场景内容解耦——场景里是模型、UI、天空盒都没关系,只要能被渲染到屏幕上,就能被扭曲。黑洞放在场景中哪个位置都行,甚至可以做多个黑洞同时扭曲,只需要在Shader里多加几组中心点和强度参数。
用生活化的类比来理解这件事:屏幕空间后处理相当于你拿着一块哈哈镜放在眼前看整个房间,房间里的家具、人、光线都没变,但镜面不同位置的曲率让画面发生了扭曲。黑洞Shader干的就是这个“哈哈镜”的活,只不过这个镜子的曲率是围绕一个中心点动态变化的。
1.3 关于Shader Graph和手写Shader的选择
很多新手会纠结用Shader Graph还是手写Shader。我的建议是:这个效果用手写Shader,而且一定要用Untiy原始管线(Built-in)配合GrabPass来实现。
Shader Graph当然可以做出扭曲效果,它有个Screen Position节点和Custom Function节点可以凑合实现,但调试起来远不如代码直观。更重要的是,Shader Graph生成的代码里有大量冗余插值计算,在移动端跑起来效率明显偏低——我做PICO 4开发的时候就吃过这个亏,Shader Graph版本在PC上跑60帧,上真机直接掉到30帧左右。
手写Shader看着吓人,但核心逻辑压缩到极致也就几十行。后处理扭曲Shader的代码结构其实非常固定:抓屏(GrabPass)→ 计算UV偏移 → 应用衰减 → 输出颜色。今天这篇文章就按这个框架一步步来拆。
2. 扭曲着色器核心代码逐段解析
2.1 GrabPass抓屏与坐标变换
先上完整的Shader代码,然后我逐段拆解。这里用的是Unity Built-in管线,内置渲染管线,不需要装任何额外插件。
Shader "Custom/BlackHoleDistortion" { Properties { _DistortionStrength ("扭曲强度", Range(0, 1)) = 0.3 _DistortionRadius ("扭曲半径", Range(0, 2)) = 0.5 _DarkHoleRadius ("暗核半径", Range(0, 1)) = 0.15 _Smoothness ("边缘柔和度", Range(0, 1)) = 0.3 _SwirlSpeed ("旋转速度", Range(0, 5)) = 1.0 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } GrabPass { "_BlackHoleGrabTexture" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _BlackHoleGrabTexture; float4 _BlackHoleGrabTexture_TexelSize; float _DistortionStrength; float _DistortionRadius; float _DarkHoleRadius; float _Smoothness; float _SwirlSpeed; float2 _BlackHoleCenter; // 黑洞在屏幕空间的位置(0~1)GrabPass的作用是在当前物体渲染之前,把屏幕上已经画好的内容抓取到一张名为《_BlackHoleGrabTexture》的纹理里。之后我们在fragment shader里对这张纹理做采样,就能拿到背景画面。
这里有个理解关键点:GrabPass抓到的纹理,坐标原点在左下角,而Unity的屏幕坐标默认也是左下角为(0,0),两者是一致的。但如果你后面要跟鼠标位置或者UI坐标做换算,这个坐标系问题就非常容易出bug,我后面专门讲。
_BlackHoleGrabTexture_TexelSize这个变量是Unity自动传入的,代表纹理的宽高信息:x是一个像素的宽度(1/宽度),y是一个像素的高度(1/高度),之后计算像素偏移时会用到。
2.2 顶点着色器和片元着色器框架
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 grabPos : TEXCOORD1; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.grabPos = ComputeGrabScreenPos(o.vertex); return o; }这里有两个细节必须说清楚。
第一,ComputeGrabScreenPos是Unity提供的工具函数,它把裁剪空间的坐标转换成GrabPass纹理的采样坐标。这个函数的输出是一个四维向量(x, y, z, w),其中x和y的数值范围不是0到1,而是带有一个w分量的齐次坐标,在采样前必须除以w分量,否则采样会完全错位。
第二,我在这里同时保留了物体的UV(o.uv),用途是后面画暗核圆和光晕时,可以直接用UV坐标判断像素离中心点的距离,不需要再借助屏幕坐标。这种做法能让暗核的圆在任何分辨率下都是正圆形,不受屏幕宽高比影响。当然,如果黑洞要跟随3D世界中的某个物体移动,那就得用grabPos换算成视口坐标去计算,两者场景不同。
2.3 核心扭曲算法:距离场与径向偏移
接下来是重头戏,fragment shader里的扭曲计算。
fixed4 frag (v2f i) : SV_Target { // 将grabPos转换为正确的采样坐标 float2 grabUV = i.grabPos.xy / i.grabPos.w; // 计算当前片元到黑洞中心的向量和距离 float2 dir = grabUV - _BlackHoleCenter; float dist = length(dir); float2 dirNorm = dist > 0.001 ? dir / dist : float2(0, 0); // 扭曲强度随距离衰减:靠近中心越强,边缘趋近于0 float distortionFalloff = saturate(1.0 - dist / _DistortionRadius); distortionFalloff = pow(distortionFalloff, 1.5); // 扭曲偏移量 = 方向向量 × 强度 × 衰减 float2 distortionOffset = dirNorm * _DistortionStrength * distortionFalloff; // 扭转分量:让画面绕中心旋转 float swirlFactor = _SwirlSpeed * distortionFalloff * 0.1; float2x2 rotMatrix = float2x2( cos(swirlFactor), -sin(swirlFactor), sin(swirlFactor), cos(swirlFactor) ); distortionOffset = mul(rotMatrix, distortionOffset); // 应用扭曲 float2 distortedUV = grabUV + distortionOffset; // 暗核:距离小于暗核半径时,置为纯黑 float darkHole = smoothstep(_DarkHoleRadius, _DarkHoleRadius + _Smoothness * 0.1, dist); // 采样背景并应用暗核遮罩 fixed4 col = tex2D(_BlackHoleGrabTexture, distortedUV) * darkHole; return col; } ENDCG } } }这段代码的核心逻辑是:每个像素都根据自己和黑洞中心的距离,决定一条偏移向量,然后去采样抓屏纹理上另一个位置的颜色。距离越近,偏移越大,视觉上就是背景被“吸”向黑洞中心。
distortionFalloff的计算是这一整段最关键的细节。为什么要用1.0 - dist / _DistortionRadius再乘方?如果你直接使用距离的倒数做强度,那么画面会在某个边界处出现一条明显的“剪切线”,因为偏移量从有到无之间是硬过渡的。而用saturate(1.0 - dist / radius)让偏移量在边界处平滑下降到0,画面扭曲和正常区域之间就没有生硬的衔接了。pow的指数设为1.5,是让扭曲强度在靠近中心时增长得比线性更快,模拟引力平方反比的直觉感受,但又不至于太极端导致画面撕裂感过强。
这里我调整了很多次参数,最终的经验值是:_DistortionStrength在0.2到0.4之间最自然,超过0.5画面就像“融化”了一样,观感反而不好。_DistortionRadius根据黑洞在画面中的占比调整,一般占屏幕宽度的四分之一到三分之一比较合适。
2.4 关于浮点数精度和宽高比的一个提醒
上面代码里有个我一开始没注意到的问题:如果直接用屏幕UV的差值计算距离,在宽屏(比如16:9)上画出来的“等效半径”会被横向拉伸,导致黑洞周围扭曲区域是一个椭圆,而不是圆形。
解决方式有两种。简单粗暴的方式是把宽高比信息用_ScreenParams传进Shader,在计算dir横向分量时乘以宽高比修正。更好的方式是自己写C#脚本,把黑洞中心点和半径直接换算成屏幕像素坐标,在像素空间里做计算,就完全不受宽高比影响了。我会在下一节详细讲这个写法。
3. 从静态Shader到动态黑洞:C#控制与场景整合
3.1 用C#脚本驱动Shader参数
Shader写好了,画面是不会动的——你需要一个脚本来实时给Shader传参数,尤其是黑洞中心位置。这里最大的坑是坐标转换。
黑洞在场景中的位置是3D世界坐标,而Shader要的_BlackHoleCenter是屏幕视口坐标(0到1之间)。这中间要经历“世界坐标 → 观察坐标 → 裁剪坐标 → 屏幕像素坐标 → 视口坐标”的变换链。在Unity里,最省事的做法是直接用Camera.WorldToViewportPoint:
using UnityEngine; public class BlackHoleController : MonoBehaviour { public Camera targetCamera; public Material blackHoleMaterial; public float maxDistortion = 0.3f; public float pulseSpeed = 1.5f; private void Start() { if (targetCamera == null) targetCamera = Camera.main; } private void Update() { // 把黑洞物体的世界坐标转为屏幕视口坐标(0~1) Vector3 viewportPos = targetCamera.WorldToViewportPoint(transform.position); // 在相机背后的物体不需要显示(视口z为负或大于1) if (viewportPos.z < 0 || viewportPos.z > 1) { blackHoleMaterial.SetFloat("_DistortionStrength", 0); return; } blackHoleMaterial.SetVector("_BlackHoleCenter", new Vector4(viewportPos.x, viewportPos.y, 0, 0)); // 扭曲强度可以做一个脉冲效果模拟“呼吸感” float pulse = 0.7f + 0.3f * Mathf.Sin(Time.time * pulseSpeed); blackHoleMaterial.SetFloat("_DistortionStrength", maxDistortion * pulse); } }脚本逻辑不复杂,但有几个点需要特别留意。
WorldToViewportPoint返回的坐标系原点在屏幕左下角,而Unity的GUI/UI坐标系原点在左上角。如果你做的黑洞要跟随鼠标位置,必须做一次y轴翻转:viewportY = 1 - mouseY。我做那个跟随鼠标的黑洞小Demo时,在这里卡了一晚上,最后打印坐标才发现方向反了。
还有一个细节是viewportPos.z的判断。当物体转到相机背后时,WorldToViewportPoint仍然会返回一个坐标,但z分量会变为负数,此时如果继续传坐标,画面会出现明显的“镜像扭曲”跳变。要做一次遮挡判断把强度归零,等物体重新出现在相机前方时再恢复。
3.2 在场景中放置黑洞物体
黑洞本身需要一个载体。最简单的做法是新建一个Quad,把材质赋上去,然后调整大小和位置。但直接用默认Quad有毛病:它会遮挡后面需要被扭曲的物体。所以要把黑洞物体的Layer放到一个“相机不渲染”的自定义Layer里,或者在Shader的Tags里设置"Queue"="Transparent",然后在Culling设置里排除它。
更稳妥的方式是把黑洞物体设置在一个单独的Layer上,然后把主相机的Culling Mask排除掉这个Layer。这样这个物体仍然存在,可以被脚本获取位置坐标,但不会被相机的画面里渲染出来。Shader正常跑,因为GrabPass抓取的是已经渲染好的屏幕内容,不包含黑洞物体自己的像素。
这算是我踩过比较多坑的一个点:如果你不做任何设置,黑洞物体自身也会出现在GrabPass的抓屏纹理里,造成暗核中间出现自己的重影。
3.3 移动端性能优化:避免GrabPass的全屏拷贝
GrabPass虽然写起来爽,但它有一个性能软肋:每一帧都会做一次全屏纹理拷贝。在PC上这不算什么,但在移动端,尤其是要做PICO 4这类VR一体机开发时,全屏GrabPass的带宽开销可能就是压垮帧率的最后一根稻草。
如果目标平台是移动端或VR,建议做一层优化:把黑洞扭曲从全屏后处理改成局部后处理。思路是只对黑洞周围半径内的屏幕区域做GrabPass采样,其他区域直接返回原色。可以这样做:在fragment shader里先判距离,如果dist > _DistortionRadius就直接return tex2D(_BlackHoleGrabTexture, grabUV),跳过所有后续计算。
if (dist > _DistortionRadius) { return tex2D(_BlackHoleGrabTexture, grabUV); }这个early-out对移动端GPU的Fragment Shader优化非常有效,因为Shader单元是按2x2像素块并行执行的,如果整块像素都落在外圈,可以直接跳过,节省大量计算。
另外,在真机上GrabPass的分辨率通常是屏幕分辨率的一半(取决于设备),所以扭曲效果会有一定程度的“发虚”,这是正常现象,不需要强行拉高分辨率,否则性能开销会成倍增加。
4. 进阶玩法:多黑洞叠加与吸积盘光效
4.1 同时实现多个黑洞扭曲
一个黑洞好玩,两个黑洞才能出效果——它们之间的扭曲区域会互相干扰,形成更加变态的“引力透镜”交互感。
要支持多黑洞,不需要写多个Pass,只需要在Shader里用循环处理多个中心点和强度参数。Unity提供了数组类型给Shader,你可以在C#里维护一个黑洞列表,然后把坐标和强度打包成数组传进去。
// 声明最多支持4个黑洞 float2 _BlackHoleCenters[4]; float _BlackHoleStrengths[4]; float _BlackHoleRadii[4]; int _BlackHoleCount;在fragment shader里做循环叠加:
float2 finalOffset = 0; float finalDarkHole = 1; for (int j = 0; j < _BlackHoleCount; j++) { float2 dirToCenter = grabUV - _BlackHoleCenters[j]; float distToCenter = length(dirToCenter); // 单个黑洞的衰减和偏移 float falloff = saturate(1.0 - distToCenter / _BlackHoleRadii[j]); falloff = pow(falloff, 1.5); finalOffset += dirToCenter / max(distToCenter, 0.001) * _BlackHoleStrengths[j] * falloff; // 暗核遮罩做乘法叠加 finalDarkHole *= smoothstep(_BlackHoleRadii[j] * 0.3, _BlackHoleRadii[j] * 0.3 + 0.02, distToCenter); } float2 distortedUV = grabUV + finalOffset; fixed4 col = tex2D(_BlackHoleGrabTexture, distortedUV) * finalDarkHole;多黑洞的暗核叠加要用乘法而不是加法,否则两个黑洞靠近时,暗核区域会连成一片,看起来像一个大墨团。乘法遮罩能保证每个黑洞各自保留独立的暗核。
性能上要注意:数组循环在Fragment Shader里是逐像素执行的,如果循环体太复杂,在移动端会吃不住。4个黑洞以内还好,超过4个建议分多个材质处理或者选用其他方案。
4.2 吸积盘光效的锦上添花
如果嫌纯黑洞太单调,可以在片子元着色器里加上吸积盘效果。吸积盘的原理很简单:围绕黑洞中心,在一个环形区域内,用噪声纹理对UV做旋转扰动,再加上热力渐变颜色。
我用的方案是在Shader里加一个_AccretionTex噪声纹理属性,然后在采样背景之前,先对噪声纹理采样得到一个扰动值,把这个扰动叠加到扭曲偏移量里去:
// 吸积盘扰动 float2 accretionUV = grabUV - _BlackHoleCenter; float accretionDist = length(accretionUV); float2 accretionDir = accretionUV / max(accretionDist, 0.001); // 仅在一圈环形区域内做扰动 float ringMask = smoothstep(_DarkHoleRadius, _DarkHoleRadius + 0.05, accretionDist) * (1 - smoothstep(_DarkHoleRadius + 0.2, _DarkHoleRadius + 0.3, accretionDist)); // 噪声扰动 float2 noiseOffset = float2( tex2D(_AccretionTex, grabUV * 3.0 + _Time.y * 0.5).r, tex2D(_AccretionTex, grabUV * 3.0 - _Time.y * 0.5).g ) - 0.5; // 把噪声扰动叠加到主扭曲上 distortionOffset += noiseOffset * ringMask * 0.05; // 吸积盘颜色:简单的橙黄渐变 float heat = ringMask * (sin(accretionDist * 100 - _Time.y * 10) * 0.5 + 0.5); col.rgb += heat * fixed3(1.0, 0.6, 0.1) * 0.3;吸积盘用的是普通噪声纹理做颜色补充,实际效果是黑洞边缘会有一条忽明忽暗的炽热光环。这个视觉效果在第三人称游戏里,放在一个星球或敌方单位背后,氛围感拉满。
不过要提醒的是,噪声采样会增加Fragments Shader的ALU开销,在移动端如果帧率紧张,优先砍掉吸积盘层,保核心扭曲层。
4.3 和Unity其他系统的联动:UI与物理
黑洞扭曲效果做出来之后,你完全可以把它玩出更多花样。
在2D游戏里,我可以给黑洞物体加一个Rigidbody2D和圆形碰撞体,然后用FixedUpdate去吸引附近的刚体。黑洞的引力大小跟扭曲强度是两套逻辑:扭曲负责“看起来被吸”,物理负责“真的被吸”。两者配合得当,玩家的子弹会先被物理吸到中心,同时屏幕上的画面也被扭曲,沉浸感翻倍。
在UI层,如果黑洞出现在世界空间,可以考虑用Canvas的World Space模式放UI。但要注意,后处理扭曲对UI是生效的,因为UI也会被渲染进GrabPass。所以在UI上做黑洞,效果一样强,但UI的交互区域不会跟着扭曲变化,需要额外做坐标映射。
我之前做过一个AR小工具——用黑洞特效把用户手指点的位置画面扭曲,配合触屏手势做“空间扭曲感”的交互反馈。用的就是这套Shader代码,只是在C#里把触摸坐标做了翻转处理,其他逻辑完全一致。所以这套效果在交互原型、视觉Demo、游戏技能特效、创意UI等场景都能复用到。
5. 常见问题与排查技巧实录
5.1 黑洞区域一片黑或全屏扭曲的排查
症状一:黑洞周围全黑,没有背景内容。这个大概率是ComputeGrabScreenPos的坐标问题。检查fragment shader里有没有除以w分量。如果你直接用了i.grabPos.xy而没有除以i.grabPos.w,那么采样的坐标会偏到屏幕外的区域,返回出来的颜色自然是黑的。
症状二:整个画面都扭曲,没有黑洞的“局部感”。这是_DistortionRadius没传进去或者传得过大导致的。检查C#脚本里有没有设置这个值。如果你直接在材质面板上调参数,确认没有动画或者脚本在覆盖这个值。
症状三:扭曲方向是反的,背景往外“吐”而不是往里“吸”。把fragment shader里的偏移方向反过来就行。dirNorm前面的正负号,看你的项目需要,反了就加个负号。这个没有标准答案,取决于“黑洞在屏幕左边时画面往左还是往右偏”的直觉。
5.2 GrabPass在某些设备上失效怎么处理
在Unity 2019以上版本,如果你把渲染管线切换到URP(通用渲染管线),GrabPass是不生效的,它会直接显示黑色。这是URP的架构决定的,不支持GrabPass这种旧式抓屏方案。
如果要用URP,可以有两个思路。一是用URP的Render Objects功能和Custom Pass结合,手动把屏幕纹理传入Blit目标。二是更省事的做法,在URP的Asset里开启Opaque Texture选项,然后用_CameraOpaqueTexture采样来代替GrabPass。后者改造成本最小,但只支持不透明物体,半透明物体不会出现在这张纹理里。
我的建议是,如果这个黑洞效果主要用于个人项目或Demo,就老老实实用Built-in管线,省心。如果要上URP项目,先用URP的Blit加自定义Pass的方案,想清楚再动手,不要一头扎进去。
5.3 性能剖面:这个Shader到底贵在哪里
最后聊个实际话题。有人担心后处理扭曲效果会不会很吃性能。我实测过,在PC上(GTX 1060),一个1080p屏幕上跑这个Shader,一个黑洞加吸积盘,整体GPU开销大约0.5毫秒,基本可以忽略。在PICO 4 VR一体机上,不优化的时候有3毫秒的开销,优化后稳定在1.5毫秒左右,在VR的11毫秒总预算里勉强能接受。
开销的大头是GrabPass的全屏拷贝,约占60%到70%。Fragment Shader本身的计算量并不高,因为绝大多数像素在dist > _DistortionRadius时就会被early-out,不进入后面昂贵的噪声采样和偏移计算。
如果要做极致优化,还有一个方向:把GrabPass改成按需抓屏,在黑洞刚出现时抓一次,扭曲效果不再变化时复用纹理,需要更新时再重新抓。这种“脏矩形”式优化适合黑洞长时间停留在画面中央的关卡。
5.4 一份常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 黑洞区域全黑 | GrabPass坐标未除以w分量 | 检查i.grabPos.xy / i.grabPos.w |
| 全屏扭曲无局部感 | _DistortionRadius过大或未设置 | 在材质面板或脚本中设置半径值 |
| 扭曲反向呈“喷发”状 | dirNorm方向正负号反了 | 反转偏移向量方向 |
| 物体转到镜头后画面跳变 | WorldToViewportPoint.z为负未处理 | 加上z值遮挡判断,强度归零 |
| 在URP下效果全黑 | GrabPass在URP不生效 | 改用Opaque Texture或Custom Pass |
| 黑洞物体出现“重影” | 黑洞物体自身被渲染入GrabPass | 把黑洞物体放在相机不渲染的Layer |
| 移动端帧率下降明显 | GrabPass全屏拷贝占带宽 | 添加外圈early-out,或考虑局部方案 |
6. 从Demo到产品:把黑洞效果做扎实的一点体会
写这个效果前前后后花了我两个周末,从最开始的“能看到扭曲就行”,到后来调暗核的柔和度、调旋转的顺滑感、调吸积盘的温度变色,越往深做越觉得“像素级的视觉细节”真的会决定一个效果的质感。
我个人最想提醒的一点是:不要一上来就追求大而全的“真实黑洞模拟”,先把基础扭曲和暗核这两个图层跑通,再逐渐加料。很多新手做这类效果,一上来就把吸积盘、引力透镜、多普勒效应全堆上去,结果就是什么地方都糊成一片,效果反而很差。清晰的层次感,比堆砌元素重要得多。
另外,代码里凡是涉及“距离”和“方向”的地方,多加一些max()和saturate()保护,防止除零。坐标转换的事,肉眼调式看不出问题,但一打印变量就原形毕露,所以调试阶段把关键数据用Debug.Log打出来看,别只盯着屏幕猜。
这个效果后续还可以往两个方向扩展:一个是结合Unity的Addressables做资源动态加载,在运行期切换不同风格的黑洞纹理;另一个是把扭曲逻辑抽成通用后处理管线,配合自定义的Volume组件做其它类型的扭曲变形,比如热浪、水波、空间裂隙。底层思路都是这篇讲的这套东西——屏幕空间的UV重新映射和衰减控制。把这一步吃透,其他效果就是参数和纹理的事。