
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 { QueueTransparent RenderTypeTransparent } 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~1GrabPass的作用是在当前物体渲染之前把屏幕上已经画好的内容抓取到一张名为《_BlackHoleGrabTexture》的纹理里。之后我们在fragment shader里对这张纹理做采样就能拿到背景画面。这里有个理解关键点GrabPass抓到的纹理坐标原点在左下角而Unity的屏幕坐标默认也是左下角为00两者是一致的。但如果你后面要跟鼠标位置或者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分量否则采样会完全错位。第二我在这里同时保留了物体的UVo.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的差值计算距离在宽屏比如169上画出来的“等效半径”会被横向拉伸导致黑洞周围扭曲区域是一个椭圆而不是圆形。解决方式有两种。简单粗暴的方式是把宽高比信息用_ScreenParams传进Shader在计算dir横向分量时乘以宽高比修正。更好的方式是自己写C#脚本把黑洞中心点和半径直接换算成屏幕像素坐标在像素空间里做计算就完全不受宽高比影响了。我会在下一节详细讲这个写法。3. 从静态Shader到动态黑洞C#控制与场景整合3.1 用C#脚本驱动Shader参数Shader写好了画面是不会动的——你需要一个脚本来实时给Shader传参数尤其是黑洞中心位置。这里最大的坑是坐标转换。黑洞在场景中的位置是3D世界坐标而Shader要的_BlackHoleCenter是屏幕视口坐标0到1之间。这中间要经历“世界坐标 → 观察坐标 → 裁剪坐标 → 屏幕像素坐标 → 视口坐标”的变换链。在Unity里最省事的做法是直接用Camera.WorldToViewportPointusing 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里设置QueueTransparent然后在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重新映射和衰减控制。把这一步吃透其他效果就是参数和纹理的事。