做Unity黑洞扭曲效果时我一开始想得很天真用粒子在黑洞位置做个吸积盘再加个光晕贴图看起来差不多就行。结果把粒子叠到真实场景里一测问题立刻暴露——粒子只能影响自己周围那一小块画面粒子背后的建筑、天空、地面全都是原封不动的。黑洞的“吞噬感”完全立不住。后来我才想明白真正的扭曲必须作用在已经渲染好的整张画面上也就是要对屏幕图像做二次采样让每个像素去重新决定“自己应该显示原来画面里的哪个位置”。这正是扭曲着色器的核心价值。这篇就完整拆解这个实现从屏幕空间UV重映射的原理、Shader代码、C#相机脚本到调参细节和踩坑记录最后给出能直接跑起来的完整工程思路。1. 为什么黑洞扭曲必须交给屏幕空间着色器而不是粒子或模型变形1.1 三种方案放在一起比过之后结论很明确我最早踩的坑是用粒子方案。粒子系统对“吞噬”这种效果确实擅长把粒子从四周吸过来、改变大小、叠加发光材质视觉冲击力很强。但粒子是场景里的独立物体它渲染出来的内容只能叠加在自己所在的片元上不可能对背景已经画好的像素产生“弯曲”。你想象一下黑洞下方有一堵墙墙不会因为粒子飞过就真的扭起来。所以粒子方案只能做气氛做不了真正的空间扭曲。第二种思路是模型顶点变形。把场景里的Mesh做高细分然后在顶点着色器里根据距离旋转顶点坐标确实可以让那一个模型扭起来。但问题是天空盒、UI、粒子、大量没有足够细分的场景物件怎么办你的黑洞效果不可能只作用于某一个模型它需要扭曲整个画面。顶点变形天然解决不了“全屏扭曲”这个需求。第三种就是屏幕空间UV重映射也是最终采用的方案。它和前两种有本质区别它不修改任何场景物体而是等整帧画面渲染完成之后把这张画面当作一张大纹理再去重新采样。每个像素都问自己一个问题我不显示自己原本的颜色我去采原画面里另一个位置的颜色这样看起来是不是就像画面被拧了一下。这就是扭曲着色器的核心逻辑——屏幕空间UV重映射。它作用在整个画面上所有已经渲染出来的东西都会跟着扭曲。1.2 UV重映射的本质改写采样地址而不是移动物体用一个生活类比来说明。把屏幕里已经渲染好的完整画面当作一张打印出来的照片黑洞扭曲不是用PS里的“液化”一点点涂抹而是相当于在照片上放了一块哈哈镜你眼睛看到的是镜子里反射出来的照片局部镜子在这个位置把画面拧了一圈、拉近了一点。着色器做的就是这件事它不改变原照片只是让每个屏幕像素的采样地址发生偏移。具体到黑洞场景采样地址的偏移规律只有两条第一越靠近黑洞中心的像素采样的位置要旋转得越多第二越靠近黑洞中心的像素采样的位置要越往中心方向压缩。旋转产生“旋涡感”压缩产生“吸入感”两个叠加起来屏幕空间的黑洞扭曲效果就出来了。后面的Shader代码本质就是在实现这两条规律。2. Shader核心屏幕取图、漩涡旋转与径向压缩的实现2.1 先搞清楚取屏幕画面的两条路OnRenderImage的source与GrabPass实现全屏扭曲第一步是拿到“已经渲染好的画面”。Unity里有两种常见取图方式这里必须分清楚因为它决定了整个Shader怎么写。第一种是后处理管线里的OnRenderImage。相机的渲染结果会作为source RenderTexture传给C#函数然后在Shader里直接采样_MainTex就能拿到当前画面。这种方式适合全屏后处理简单直接Unity会自动处理平台差异代码里基本不用关心UV翻转问题。我们的主方案就走这条路。第二种是GrabPass。它在物体渲染之前抓取当前屏幕内容保存到一张纹理里之后任何Shader都可以通过GrabPass {}声明并采样这张纹理。这种方式适合“局部物体扭曲屏幕内容”的场景比如在黑洞物体位置放一个Quad只处理Quad覆盖范围内的画面从而减少全屏后处理的开销。代价是GrabPass的开销本身不低而且在OpenGL和DirectX下纹理UV方向不一致需要手动处理翻转。对于这篇的Demo我优先用OnRenderImage的后处理方式因为逻辑最清晰、最不容易踩平台坑方便你把核心扭曲算法调明白。2.2 扭曲算法的推导距离衰减、旋转量与压缩Shader的fragment shader里屏幕UV坐标范围内每个像素都有一段自己的逻辑。核心分四步第一步算当前像素到黑洞中心的距离。这一步很简单UV相减取length得到dist。第二步根据距离算衰减系数t。当像素刚好在黑洞边缘时衰减应该接近0越靠近黑洞中心衰减接近1。用t 1.0 - dist / _Radius就能得到从边缘0到中心1的线性变化。但线性变化视觉上太均匀不够“拧”所以后面还要加pow和smoothstep做衰减塑形。第三步根据衰减系数算旋转角度。想让画面旋转起来角度必须跟时间挂钩用_Time.y乘以一个速度系数即可。同时为了让中心区域比边缘转得更狠角度要再乘一个衰减系数。这样距离中心越近的像素采样点被旋转的角度越大就形成“中心拧得最厉害”的旋涡效果。第四步做向心压缩。把原本指向黑洞中心的方向向量旋转angle角度之后再乘一个小于1的压缩系数让采样点倾向于被拉向中心。这是“吸入感”的来源。2.3 可直接复制的Shader代码下面是可以直接运行的Shader代码。使用方法新建Shader文件粘贴这段代码再新建材质把Shader指定成它其余参数保持默认即可。Shader Custom/BlackHoleDistortion { Properties { _MainTex (Screen Texture, 2D) white {} _Center (Center (Viewport), Vector) (0.5, 0.5, 0, 0) _Radius (Radius, Range(0, 1)) 0.25 _SwirlStrength (Swirl Strength, Range(0, 5)) 1.5 _SwirlSpeed (Swirl Speed, Range(-5, 5)) 2.0 _PullStrength (Pull Strength, Range(0, 1)) 0.35 _FalloffPower (Falloff Power, Range(0.5, 4)) 2.0 } 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 _SwirlStrength; float _SwirlSpeed; float _PullStrength; float _FalloffPower; fixed4 frag (v2f_img i) : SV_Target { float2 uv i.uv; // 计算当前像素到黑洞中心的偏移和距离 float2 toCenter uv - _Center; float dist length(toCenter); // 半径之外直接返回原始画面 if (dist _Radius) return tex2D(_MainTex, uv); // t从边缘0过渡到中心1 float t 1.0 - dist / _Radius; float falloff pow(t, _FalloffPower); falloff * smoothstep(0.0, 0.05, t); // 旋转角度衰减系数乘以时间形成漩涡动态 float angle _SwirlStrength * falloff * _Time.y * _SwirlSpeed; float s, c; sincos(angle, s, c); // 对偏移向量做旋转 float2 rotated float2( c * toCenter.x - s * toCenter.y, s * toCenter.x c * toCenter.y ); // 向心压缩越靠近中心压缩得越狠 rotated * 1.0 - _PullStrength * falloff; // 重新映射采样位置 float2 finalUv _Center rotated; // 防止采样到屏幕外产生拉伸黑边 float2 minUv _MainTex_TexelSize.xy; float2 maxUv 1.0 - _MainTex_TexelSize.xy; finalUv clamp(finalUv, minUv, maxUv); return tex2D(_MainTex, finalUv); } ENDCG } } }注意几个关键点vert_img来自UnityCG.cginc是后处理Pass专用的顶点函数它会自动填好v2f_img里的uv。_MainTex_TexelSize是Unity自动提供的纹理尺寸信息这里用来做采样边界的clamp防止扭曲后采样到屏幕外的区域。_Time.y是Unity内置的时间变量单位是秒。2.4 每个参数控制什么Shader属性速查表参数默认值作用建议范围_Center(0.5, 0.5)黑洞中心在屏幕上的位置视口坐标0~1由C#每帧设置_Radius0.25黑洞扭曲的影响半径占屏幕高度的比例0.05 ~ 0.45_SwirlStrength1.5旋涡旋转的强度越大转得越明显0.5 ~ 5_SwirlSpeed2.0旋涡旋转速度负数可反向旋转-5 ~ 5_PullStrength0.35向心吸入强度越大画面越被拽向中心0 ~ 0.8_FalloffPower2.0衰减曲线幂次越大边缘越锐利、影响范围越小0.5 ~ 4调参的时候最容易出现的问题是_SwirlStrength和_SwirlSpeed同时调大中心区域每秒旋转的圈数会疯涨视觉上很容易晕甚至出现画面倒转的错觉。建议先固定速度从小到大推Strength找到最舒服的手感。3. C#脚本控制让黑洞中心跟着场景物体走3.1 OnRenderImage的执行时机与Blit的作用Shader写完后要让相机在渲染完正常画面之后自动执行这段扭曲逻辑得把Shader挂到材质上再让材质参与相机的后处理。Unity内置的后处理入口就是OnRenderImage。这个函数的执行时机在相机渲染完所有场景物体之后但在最终输出到屏幕之前。你会拿到两个RenderTexture参数source是已经渲染好的完整场景图destination是最终要输出的目标。通常用Graphics.Blit(source, destination, material)把source作为输入纹理传给Shader的_MainTex经过材质中的Pass处理后再写入destination。这就是着一个后处理的全过程。如果材质或控制参数不满足条件直接Graphics.Blit(source, destination)做一次无处理的拷贝即可这样黑洞脚本即使空转也不会黑屏。3.2 世界坐标到视口坐标的换算Shader里需要的_Center是视口坐标范围0到1而你在场景里摆放的黑洞物体是一个世界坐标。所以要先把世界坐标转换到屏幕Viewport坐标。Unity里一个现成的API是Camera.WorldToViewportPoint。这个方法返回一个Vector3x和y就是0到1的视口坐标z代表物体到相机的深度距离。当z小于0时说明物体在相机背后此时黑洞不在画面内应该跳过扭曲直接拷贝。还要考虑一种情况物体虽然在相机前方但x或y跑出了0到1范围此时黑洞不在屏幕内同样跳过。把viewport坐标直接设置给Shader的_Center就能让黑洞中心跟着场景里的物体实时移动。3.3 完整C#脚本与使用步骤下面是可以直接用的控制脚本。挂到相机上后把材质和场景中的黑洞锚点拖进去即可运行。using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class BlackHoleEffect : MonoBehaviour { public Material effectMaterial; public Transform blackHoleTransform; [Range(0f, 1f)] public float radius 0.25f; [Range(0f, 5f)] public float swirlStrength 1.5f; [Range(-5f, 5f)] public float swirlSpeed 2f; [Range(0f, 1f)] public float pullStrength 0.35f; [Range(0.5f, 4f)] public float falloffPower 2f; private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (effectMaterial null || blackHoleTransform null) { Graphics.Blit(source, destination); return; } Vector3 viewportPos GetComponentCamera().WorldToViewportPoint(blackHoleTransform.position); // 黑洞在相机背后时不处理 if (viewportPos.z 0f) { Graphics.Blit(source, destination); return; } effectMaterial.SetVector(_Center, new Vector4(viewportPos.x, viewportPos.y, 0f, 0f)); effectMaterial.SetFloat(_Radius, radius); effectMaterial.SetFloat(_SwirlStrength, swirlStrength); effectMaterial.SetFloat(_SwirlSpeed, swirlSpeed); effectMaterial.SetFloat(_PullStrength, pullStrength); effectMaterial.SetFloat(_FalloffPower, falloffPower); Graphics.Blit(source, destination, effectMaterial); } }使用步骤在场景中放置一个空物体命名为BlackHoleAnchor作为黑洞中心锚点。创建Shader和材质材质选择Custom/BlackHoleDistortion。新建脚本将上面的代码复制进去挂到主相机上。把材质拖到effectMaterial把BlackHoleAnchor拖到blackHoleTransform。运行场景移动BlackHoleAnchor就能看到扭曲效果跟随物体移动。[ExecuteInEditMode]加上之后在编辑器里不点击Play也能预览效果调参非常方便。但要注意编辑模式下如果相机不在当前显示状态OnRenderImage也可能不会触发。实践中最稳妥的做法是运行时调整。4. 让效果“像黑洞”的调参思路速度、衰减、径向压缩的配合4.1 为什么不能只转圈要加径向压缩先做一个实验把_PullStrength调成0只保留旋转。你会看到画面虽然拧起来了但更像“搅拌机”而不是黑洞。原因很简单真实黑洞对周围空间的拉扯是向心的物质不仅绕中心转也在不断被拉向中心。只有旋转没有向心压缩画面缺乏“被吸进去”的紧张感。把_PullStrength从0慢慢往上加观察画面上细节明显的区域比如网格地面或贴了纹理的墙面。你会发现它们不仅被旋拧还逐渐向中心聚拢。这个视觉变化就是“吞噬感”的来源。但压缩不能加过头后面会说为什么。4.2 衰减曲线决定“拧”的范围和力度分布_FalloffPower这个参数控制的是衰减曲线形态很多人会忽略它。power1时从中心到边缘的旋转量是线性递减的视觉上过度均匀但缺少焦点。power2或3时中心附近的旋转量明显更大边缘更平缓画面看起来“中心拧得很紧、边缘自然散开”更接近旋涡的视觉预期。power继续加大到4以上扭曲区域会向中心收窄黑洞看起来更小、更有压迫感。同时Shader里还乘了一个smoothstep(0, 0.05, t)它的作用是让紧贴半径边缘的那一薄层过渡更柔和避免边界出现明显的“圆圈烙印”。如果觉得边缘还是硬可以把这个smoothstep的范围从0.05加到0.1代价是扭曲影响范围会略微扩大。4.3 径向压缩不能超过0.8的数学原因_PullStrength在Shader里的用法是rotated * 1.0 - _PullStrength * falloff;仔细看这个式子当_PullStrength小于1时1.0 - _PullStrength * falloff始终大于0采样地址始终在中心到原位置的连线上不会越过中心点。一旦_PullStrength大于1靠近中心的区域可能出现负系数采样点会翻转到中心的另一侧画面会出现类似“对折”的撕裂感看起来非常奇怪。所以这个参数的范围限制在0到1之间实际操作中我建议最高只调到0.7。超过0.7后中心区域的画面被压缩得过于密集像素采样的画面内容高度重复会出现脏兮兮的混色效果。想增强“吸入感”可以配合增加半径或者降低FalloffPower都比硬拉压缩系数稳定。4.4 时间积分与长时间运行的角度精度Shader里的旋转角度是_SwirlStrength * falloff * _Time.y * _SwirlSpeed意思是角度随运行时间线性增长。如果游戏跑了几十分钟角度值会变得非常大此时使用sincos仍然能计算出正确结果但在某些移动平台的低精度浮点上大角度计算可能产生微小的抖动。实际项目里我倾向于在C#侧维护一个归一化的角度值取模到360度或者2π以内再传入Shader避免这种潜在问题。比如在C#里float angle Mathf.Repeat(_timeSinceStart * swirlSpeed, 360f); material.SetFloat(_AngleOffset, angle);然后把Shader里的角度改为angle _AngleOffset * falloff * _SwirlStrength。这样既保证动画连续又让角度值始终在一个安全范围内。5. 实测踩坑坐标翻转、硬边缘、GrabPass开销与移动端优化5.1 屏幕UV翻转问题为什么同样的代码在两个平台表现不一样后处理Shader里如果你直接使用OnRenderImage的sourceUnity内置的vert_img已经帮你处理了OpenGL和DirectX的UV方向差异。但如果你改用GrabPass方案就要自己处理翻转。具体现象是同样的GrabPass代码在编辑器里正常打包到Android模拟器或某些OpenGL设备上扭曲中心偏移到了对称位置像是把画面翻了个面。根因是OpenGL的渲染纹理原点在左下角DirectX的原点位于左上角。Unity在大多数情况下会通过_MainTex_TexelSize.y的符号告诉你当前平台是否需要翻转。使用GrabPass时一个常见的处理方式是#if UNITY_UV_STARTS_AT_TOP if (_GrabTexture_TexelSize.y 0) uv.y 1 - uv.y; #endif只有搞清楚纹理坐标系的差异GrabPass方案才不会出问题。这也是我推荐先学OnRenderImage方案的另一个原因它能让你避开干扰项专心调扭曲效果本身。5.2 扭曲边缘的生硬断层不只是衰减问题全屏后处理实现的黑洞扭曲最影响观感的问题之一就是扭曲区域和正常区域之间出现明显分界线。分界线的来源不只是衰减曲线还有采样越界。如果扭曲后的finalUv超出了屏幕范围tex2D会按Clamp模式采样把边缘像素拉长成一条条色带看起来像“光圈”一样套在黑洞外面。解决方式是Shader里对finalUv做clamp并且clamp的边界不是0和1而是留一个_MainTex_TexelSize的边距。这样即使理论采样的UV越界也不会直接暴露为硬边而是被柔和地限制在有效像素范围内。另一个常见坑是clamp后画面出现“冻结”效果这是Clamp采样本身的特点无法完全避免。如果追求极端效果可以考虑在边缘追加一个遮盖Pass把分界线藏在暗色渐变里。5.3 全屏后处理的性能代价与局部Quad方案全屏后处理意味着每一帧都要对屏幕上每个像素运行一遍fragment shader。即使半径之外的像素直接返回原始颜色也要执行一次距离计算和一次纹理采样。在1080p分辨率下这就是1920×1080约200万个像素的运算量。对高端机不算什么但在低端移动设备上如果在OnRenderImage里叠加多个后处理效果帧率会明显下滑。一种常见的优化思路是把扭曲效果从全屏改成局部即“局部Quad GrabPass”方案。具体做法是在黑洞位置放置一个始终面向相机的QuadQuad的Shader使用GrabPass抓取屏幕然后在Quad的fragment shader里执行相同的扭曲算法。这样Shader只处理Quad覆盖范围内的像素范围之外完全不跑。缺点是GrabPass抓屏本身有一定开销而且Quad需要遮挡剔除、屏幕尺寸动态适配等额外逻辑。我的经验是在老旧的移动设备上局部Quad方案通常能比全屏方案快20%到30%但在HDRI、多相机场景下优势会被GrabPass的带宽消耗抵消。具体取舍要结合项目跑分。5.4 移动端与WebGL的适配建议Shader里的数学函数在移动端都没有问题但要注意不要依赖半精度浮点来计算大范围距离。length函数在不同精度下的表现不太一样低精度下边缘曲线可能抖动。建议在项目里开启Metal/Vulkan的#pragma target 3.0并确保_FalloffPower的pow计算用的是float精度。WebGL方面这个效果的兼容性重点在纹理采样格式。后处理source通常是RGBA Half FloatWebGL1.0不支持半浮点纹理的话可能出现画面变紫或采样异常。如果项目面向WebGL建议在Gradle或Player Settings里确认Graphics API优先WebGL2.0并将后处理纹理格式保持默认的R8G8B8A8等效果跑通后再考虑更高质量的HDR格式。6. 进阶玩法事件视界光晕、粒子配合与URP适配6.1 在同一个Pass里给黑洞叠加环形光晕黑洞画面如果只有扭曲缺少一个明确的视觉锚点观众不容易一眼看清黑洞中心在哪。一个低成本的做法是在Shader片段里叠加一个事件视界光晕也就是围绕黑洞中心画一个发光环。不需要额外Pass在扭曲完成后直接修改颜色即可// 在return之前追加 fixed4 col tex2D(_MainTex, finalUv); // 事件视界光环距离中心约radius的一定比例处产生亮环 float ringCenterDist _Radius * 0.35; float ringDist abs(dist - ringCenterDist); float ring 1.0 - smoothstep(0.0, _Radius * 0.06, ringDist); col.rgb ring * _RingColor.rgb * _RingIntensity; return col;注意dist是当前像素到中心的距离ringCenterDist可以理解为光环落在扭曲范围的哪个位置。调整_Radius * 0.35这个比例可以让光环靠近中心或远离中心。光晕强度不要太高否则会把扭曲细节完全盖住。6.2 粒子系统与黑洞效果联动Shader只能改画面采样位置无法凭空生成新的内容。想让黑洞周围有吸积盘尘埃、光线被吸入的动态细节最好配合粒子系统。做法是在BlackHoleAnchor下挂一个ParticleSystem粒子使用程序化噪声的流动贴图从外围向中心运动渲染模式选择Billboard。粒子材质不要用Additive建议用Alpha Blend配合暗色背景压暗否则会像一团白光浮在场景上。粒子发射方向始终不是真实黑洞的物理模拟但要的就是“视觉上被吸进去”的感觉。把粒子速度曲线设置成越靠近中心越快同时在中心附近的粒子透明度快速降低造成被吞噬的错觉。再配合Bloom后处理整体观感会非常接近电影里的黑洞表现。6.3 URP环境下的适配思路以上的OnRenderImage方案在Unity内置渲染管线Built-in里可以跑通。如果你的项目用的是URPOnRenderImage不再被支持需要改用Renderer Feature方式。思路并不复杂创建一个ScriptableRendererFeature在其中注入一个自定义Pass在Execute中通过cmd.Blit把相机渲染纹理传入同一个Shader执行扭曲后再Blit回目标。Shader本体基本不用改动唯一需要注意的是URP下纹理获取方式有所变化需要在CameraColorTarget中读取当前画面的颜色缓冲。具体代码改起来有一定篇幅网上也有大量现成的“URP Custom Blit Renderer Feature”模板可以参考。核心思想仍然是拿到相机画面纹理喂给Custom/BlackHoleDistortion让Shader做UV重映射。掌握了这层逻辑无论渲染管线怎么换实现路径都很顺畅。6.4 多黑洞叠加与参数隔离如果场景里同时有多个黑洞比如两个扭曲中心互相靠近直接套同一套参数就会出现互相覆盖的问题。最基本的处理方式是给每个黑洞分配独立的材质防止SetVector和SetFloat互相污染。更复杂的做法是把黑洞中心列表传入Shader在fragment阶段遍历每个黑洞中心计算最近中心的扭曲贡献加权合成。遍历能保证多个黑洞同时作用但性能开销随黑洞数量线性增长建议黑洞数量控制在3个以内再多就考虑用Compute Shader处理。动手做多个黑洞前先想清楚每个黑洞的半径和压缩强度是否可以互相叠加还是应该用“最强者优先”的策略。这类业务逻辑放到C#端会更清晰Shader只负责执行单一公式即可。我个人在实际操作中的最大体会是这个效果调试起来并不难难的是视觉上“刚刚好”。扭曲太强画面瞬间变成灾难扭曲太弱又像玻璃上沾了水完全没有黑洞的压迫感。我通常会先关掉所有后处理只留这个扭曲在网格地面上反复移动黑洞锚点确认旋转、压缩的配合稳定后再叠加Bloom和粒子。最后再分享一个技巧在编辑器里开着[ExecuteInEditMode]一边拖动场景里的黑洞锚点一边看着Game视图调参比一遍遍点Play高效得多这套工作流对这类视觉类Shader的调试几乎都能用上。