ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity黑洞扭曲特效:屏幕后处理Shader与UV重映射实现详解

Unity黑洞扭曲特效:屏幕后处理Shader与UV重映射实现详解 最开始我只是想在游戏里做一个“画面被黑洞吸进去”的过场特效。试过粒子、UI遮罩、把物体往中心拉效果都差点意思最后落地的是基于Unity扭曲着色器的屏幕后处理方案也就是这篇要完整拆解的黑洞扭曲效果实现。如果你已经写过最简单的Unlit Shader、知道UV是什么这套方案可以直接照抄就算你刚接触Shader把原理部分过一遍再复制代码也能快速跑出效果。核心思路就一句话把相机拍下来的整张画面当成一张贴图通过UV重映射让它像被漩涡拽进去一样发生弯曲。1. 先搞清楚扭曲效果到底扭曲的是什么1.1 黑洞扭曲的视觉本质引力透镜的“屏幕版本”现实中的黑洞之所以看起来吓人是因为巨大的引力让经过它附近的光线发生了偏折。背景的恒星、星云在被它遮挡的边缘处被拉伸、旋转形成一圈明亮的光环这就是“引力透镜效应”的视觉表现。但游戏里做的是视觉欺骗不是物理模拟。我不需要去求解广义相对论方程只需要让玩家“觉得”画面正在被吸入。这套效果的视觉语言拆开看就三个元素向心拉扯、螺旋旋转、中心变暗。向心拉扯让背景内容看起来被从四周拽向中心螺旋旋转让被拉扯的内容沿着圆弧运动中心变暗则模拟事件视界——那个所有光线都逃不出来的黑色圆盘。三者叠加人眼就会自动脑补出“黑洞正在吞噬画面”的感觉。1.2 为什么屏幕后处理是唯一正解在动手之前我其实考虑过几种实现方式。第一种是把场景里所有物体往中心点移动但物体太多而且单个物体整体位移做不到“画面被扭曲”的效果第二种是用粒子系统模拟吸入的尘埃和光点但那只适合做点缀无法改变整个画面的形态。最后真正可行的是屏幕后处理。原因是扭曲改变的不是物体本身而是“观察者看到的画面”。相机拍到的每一帧都被Unity渲染成一张RenderTexture后处理Shader在这张纹理上重新采样每个像素的颜色。我只需要告诉每个像素“你应该去画面的哪个位置取颜色”整幅画面就会发生形变。这相当于在照片上做后期扭曲而不是重新布置摄影棚里的道具。所以这套方案里真正的主角是一个Image Effect Shader配合C#脚本在OnRenderImage时机完成全屏渲染。1.3 效果边界哪些该做哪些不该做后处理扭曲能做的是“画面层面的吸入”比如背景旋转、边缘拉伸、中心压暗。它不适合做的东西也要提前说清楚如果你想让某个3D物体本身被拉成面条形状不能靠这个方案如果想让玩家角色真的被吸入并消失需要在场景层面处理模型的移动和激活状态如果要做物理级别的光线弯曲那是离线渲染的事。我建议把后处理扭曲当作“视觉包装”核心玩法改动还是得在场景逻辑里做。比如角色掉入黑洞后先把角色隐藏再启动扭曲动画视觉上就非常干净。2. 扭曲Shader的数学内核UV重映射与径向采样2.1 UV就是整张画面的“桌布经纬线”理解扭曲Shader的关键是理解UV坐标。对一张纹理来说UV就是这张“桌布”上的经纬线左下角是(0,0)右上角是(1,1)。屏幕后处理中整张画面被当成一张纹理每个屏幕像素对应一个UV坐标。正常情况下屏幕左侧的像素采样_LeftTex坐标左侧的颜色屏幕右侧采样右侧颜色画面完全不变。扭曲的逻辑是修改每个像素的采样坐标。比如某个像素原本在(0.7, 0.3)我让它去采样(0.6, 0.3)的颜色这个像素就变成了偏左的内容画面就像被推了一下。把所有像素的采样坐标按照一定规则整体改动画面就会发生连续、平滑的形变。这就是UV重映射的全部秘密不做任何几何运算只是改变“到哪里取颜色”。2.2 扭曲函数三要素拉力、衰减、螺旋我在Shader里设计了三个叠加的变换对应前面说的三个视觉元素。第一是径向拉扯。对每个像素先计算出它离中心的偏移向量toCenter和距离dist。如果距离在影响半径内就让采样点向中心方向偏移一段距离偏移量随距离衰减。越靠近中心被拽得越狠外部图像就“漏”进了中心区域。第二是衰减曲线。拉扯不能是线性的否则画面会像一个生硬的漏斗。我用的是falloff saturate(1 - dist / radius)再平方。平方的目的是让影响集中在中心附近边缘的过渡更柔和。实际调参时你会发现衰减曲线几乎决定了效果是“丝滑涡流”还是“生硬拉扯”。第三是螺旋旋转。在完成径向拉扯后再把采样点绕中心旋转一个角度。旋转角度也由falloff控制中心附近转得多、外围转得少这样画面里会产生清晰的旋涡感。这三个变换合在一起采样坐标的计算公式大致是float2 dir toCenter / (dist 1e-5); float2 warpedUV uv - dir * _Power * falloff * _Radius * 0.5; float angle _Swirl * falloff * 6.2831853; float2 centered warpedUV - _Center; float2 rotated float2( centered.x * cos(angle) - centered.y * sin(angle), centered.x * sin(angle) centered.y * cos(angle) ); warpedUV rotated _Center;2.3 参数计算逻辑与手感调校很多人拿到代码后第一步就是乱拉参数结果画面不是变糊就是范围太大。我这里给一套调参顺序和方法。先调半径_Radius它决定扭曲范围占屏幕的比例。建议从0.2开始这个数值下中心附近有清晰变形但画面边缘仍保持正常。再调拉力_Power从0.5开始逐步加大直到背景明显向中心聚拢但不要大到画面中心糊成一团。最后调螺旋_Swirl从0.5开始观察背景是否出现自然旋转。关键经验是_Power和_Swirl需要配合调整。_Power太大但_Swirl太小画面只是被压缩_Swirl太大但_Power太小旋转明显但没有吸入感。比较舒服的比例大约是_Swirl是_Power的一半到三分之二。2.4 处理屏幕边缘的拉伸和黑线做UV重映射第一个遇到的坑就是边缘失真。扭曲后的UV一旦超出[0,1]范围采样就会取到屏幕边缘重复的像素产生明显的拉伸条纹甚至黑边。最简单的处理方案是给扭曲增加一个边缘淡出权重。在Shader中计算一个edgeFade因子UV越接近屏幕边界时权重越小扭曲强度越弱。这样的好处是中心区域仍然有强烈的吸积效果但屏幕四周保持稳定不会穿帮。另一个方案是干脆缩小_Radius让扭曲根本碰不到屏幕边缘。两种方案可以结合使用我在实际项目中的做法是edgeFade兜底半径控制在0.2左右基本不会出现边缘问题。3. 在Unity中完整实现黑洞扭曲效果3.1 后处理脚本挂到相机上就能跑先说C#脚本部分。这个脚本要挂在主相机上核心入口是OnRenderImage方法。Unity在内置渲染管线中只要相机上挂了包含该方法的脚本每一帧渲染完成后都会调用它把渲染结果作为source传入我们要做的就是用材质处理source再输出到destination。using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class BlackHoleEffect : MonoBehaviour { public Shader effectShader; private Material effectMaterial; [Header(扭曲中心 (0~1 屏幕坐标))] public Vector2 center new Vector2(0.5f, 0.5f); [Header(扭曲半径)] [Range(0.01f, 1f)] public float radius 0.2f; [Header(拉扯强度)] [Range(0f, 3f)] public float power 1.2f; [Header(螺旋强度)] [Range(-2f, 2f)] public float swirl 0.6f; [Header(中心暗化强度)] [Range(0f, 1f)] public float darkness 0.85f; [Header(吸积盘光效强度)] [Range(0f, 0.5f)] public float accretion 0.1f; Material CreateMaterial() { if (effectMaterial null) { effectMaterial new Material(effectShader); effectMaterial.hideFlags HideFlags.HideAndDontSave; } return effectMaterial; } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (effectShader null) { Graphics.Blit(source, destination); return; } Material mat CreateMaterial(); mat.SetVector(_Center, center); mat.SetFloat(_Radius, radius); mat.SetFloat(_Power, power); mat.SetFloat(_Swirl, swirl); mat.SetFloat(_Darkness, darkness); mat.SetFloat(_Accretion, accretion); Graphics.Blit(source, destination, mat); } void OnDisable() { if (effectMaterial ! null) { DestroyImmediate(effectMaterial); } } }Graphics.Blit的作用是把source纹理通过材质的一个Pass处理后写入destination。如果我们不传材质直接调用Graphics.Blit(source, destination)就是原样拷贝。脚本里对每个参数都用SetFloat传入Shader这样在Inspector里拖参数就能实时看到效果。3.2 Shader核心代码逐行拆解Shader部分我使用了一个带单个Pass的后处理Shader。注意ZTest Always、Cull Off、ZWrite Off这三个状态因为后处理渲染的是全屏四边形不需要深度测试和剔除。Shader Hidden/BlackHoleDistortion { Properties { _MainTex (Texture, 2D) white {} _Center (Center, Vector) (0.5, 0.5, 0, 0) _Radius (Radius, Range(0.01, 1)) 0.2 _Power (Power, Range(0, 3)) 1.0 _Swirl (Swirl, Range(-2, 2)) 0.6 _Darkness (Darkness, Range(0, 1)) 0.8 _Accretion (Accretion, Range(0, 0.5)) 0.1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float2 _Center; float _Radius; float _Power; float _Swirl; float _Darkness; float _Accretion; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } half4 frag(v2f i) : SV_Target { float2 uv i.uv; float2 toCenter uv - _Center; float dist length(toCenter); float falloff saturate(1.0 - dist / _Radius); falloff falloff * falloff; // 径向拉扯采样点向中心方向移动 float2 dir toCenter / (dist 1e-5); float2 warpedUV uv - dir * _Power * falloff * _Radius * 0.5; // 螺旋旋转 float angle _Swirl * falloff * 6.2831853; float cosA cos(angle); float sinA sin(angle); float2 centered warpedUV - _Center; float2 rotated float2( centered.x * cosA - centered.y * sinA, centered.x * sinA centered.y * cosA ); warpedUV rotated _Center; half4 col tex2D(_MainTex, warpedUV); // 中心黑洞暗化 float2 toCenter2 uv - _Center; float innerMask saturate(1.0 - length(toCenter2) / (_Radius * 0.25)); col.rgb * lerp(1.0, 1.0 - _Darkness, innerMask); // 吸积盘暖色叠加 float discMask smoothstep(_Radius * 0.55, _Radius * 0.35, length(toCenter2)); discMask * 1.0 - smoothstep(_Radius * 0.25, _Radius * 0.1, length(toCenter2)); col.rgb float3(1.0, 0.55, 0.2) * discMask * _Accretion; return col; } ENDCG } } Fallback Off }frag函数是灵魂。从计算falloff开始这个值越接近中心越大。径向拉扯中我用uv - dir * 拉力实现“向中心采样”因为dir是从中心指向外部的方向减去它相当于往中心靠近。_Radius * 0.5是一个尺度因子让_Power的数值在不同半径下表现一致不至于换场景还要重新调半天。螺旋旋转部分用标准的二维旋转矩阵。注意旋转中心是_Center所以先把warpedUV平移到以中心为原点旋转后再平移回去。3.3 中心黑盘与吸积盘让扭曲看起来真的像黑洞如果只有扭曲没有暗化效果更像水面波纹不像黑洞。所以frag函数后面加了两段视觉强化。中心暗化用innerMask实现。它检查当前像素到中心的距离距离小于_Radius * 0.25时就逐渐变暗乘以(1 - _Darkness)。这样中心出现一个边缘柔和的黑色圆盘模拟事件视界。吸积盘是可选增强。在距离中心大约_Radius * 0.2~0.5的环形区域叠加一层暖橙色光晕。数值很克制_Accretion默认只有0.1太高的亮度会让画面变成“太阳”而不是黑洞。如果你项目里还有Bloom后处理这层暖色会被Bloom放大形成很漂亮的吸积盘辉光。3.4 快速验证先让屏幕变红再调扭曲后处理Shader如果写错最常见的症状是全屏黑或全屏白很难判断是Shader编译失败还是采样逻辑问题。我的调试习惯是先让frag函数直接返回一个固定颜色比如return half4(1, 0, 0, 1);。屏幕变成纯红色说明整个Blit链路是通的剩下的问题只在采样逻辑。再把固定颜色替换回tex2D(_MainTex, uv)确认原始画面正常最后一步步加入扭曲和暗化逻辑。这样分段验证大大省时间。黑屏的坑排雷整个处理链条是最先要排查的。4. 实操记录参数怎么配出“吸入感”4.1 从零到可调效果的完整流程实操一遍从新建项目到跑出效果只需要五个步骤。第一步打开Unity建议随便用一个3D模板内置渲染管线即可。新建一个场景放一个地面网格或者几块带纹理的Cube视觉参考物很重要否则扭曲变化不明显。第二步把上面的BlackHoleEffect.cs脚本挂到主相机把Shader资产拖到脚本的effectShader槽位。第三步在Inspector里把Center设为(0.5, 0.5)让扭曲发生在屏幕中央。Radius设0.2Power设1.2Swirl设0.6Darkness设0.85Accretion设0.1。第四步运行场景。画面中心应该出现一个黑色圆盘周围的纹理明显向中心聚拢并带有螺旋拉扯。第五步把Center改成(0.3, 0.7)再做一次确认扭曲跟随参数变化。如果你想让主角死亡时在屏幕某个位置爆发出一个黑洞把这组参数加上动画曲线即可。4.2 三组常用参数对照轻扭曲、漩涡、强吸收调参这件事没有绝对的“正确值”只能说不同参数组合适应不同场景。我整理了三组我自己常用的预设方便你快速上手。效果类型RadiusPowerSwirlDarknessAccretion适用场景轻度扭曲0.150.40.30.60.05传送门入口、镜头环境扰动漩涡吸入0.221.20.80.850.1角色死亡、技能特效强吸收黑洞0.32.01.51.00.15时空坍塌、关卡过场轻扭曲的参数会让画面边缘微微弯曲中心只出现一个非常小的黑斑漩涡吸入是最常用的档位能看到清晰的螺旋结构又不至于让画面失真太严重强吸收档位下画面中心会迅速变得一片漆黑外围有强烈的旋转感适合情绪浓烈的过场。4.3 真机帧率与Profiler实测性能方面我分别在PC和移动端做过简单测试。1080P下PC跑空场景加后处理前后帧率几乎没有区别因为一次全屏纹理采样对现在的GPU来说太轻松了。真正的压力在移动端。我的测试机是Android中端机720P下打开扭曲后处理GPU负载会明显增加但流畅运行没问题如果开到1080P再加上Bloom等后期链帧率就会明显下降。尽量不要在移动端同时挂多个全屏后处理脚本。更合理的做法是把扭曲和调色、Bloom合并到一个Shader的多个Pass里或者接受半分辨率渲染。后处理特效属于“低优先级高开销”的特效能用一张RT解决就不要用两张。5. 常见问题与排查技巧实录5.1 效果全黑/全白的典型原因做过Shader的人都知道后处理黑屏的原因往往是Shader本身出了问题。三个最常见的原因Shader编译失败。Console窗口会直接报错常见原因是有未定义的变量或者语法错误。修复后要重新把Shader资产拖到脚本槽位有时编辑器缓存不会自动更新。材质没有正确创建。脚本中我用的是new Material(effectShader)如果effectShader为空就会在运行时创建一个默认材质效果自然不对。所以第一个要检查的就是脚本上的Shader槽有没有拖对。Blit的source和destination写反。如果你自己写Graphics.Blit调用注意第一个参数是源纹理第二个是目标。写反会导致从一张空的RT采样输出全黑。排查时我的顺序是看Console报错、检查Script引用、最后用return fixed4(1,0,0,1)验证渲染通路。5.2 画面边缘出现拉扯和黑线边缘拉扯通常有两个原因。第一是扭曲后的UV超出了[0,1]采样到屏幕外的内容被纹理Clamp模式拉伸成奇怪的条纹。第二是半径设得太大扭曲范围覆盖了整个屏幕边缘。最实用的处理是加一个边缘淡出权重。在Shader中计算像素原始UV到屏幕边界的距离如果太靠近边缘就降低扭曲强度让画面在边界处保持原样。还有一种情况是某些平台上画面上下颠倒。这是因为OpenGL和DirectX的UV原点定义不同。如果你发现扭曲方向总是反的可以在C#脚本里做一个平台判断在传入Shader前翻转center.y或者去Google一下_MainTex_TexelSize.y的应用方式这个问题和平台后端相关不同Unity版本表现不同。5.3 UI与多相机如何避开扭曲这个坑很隐蔽。如果你用的是Screen Space Overlay的UI后处理不会影响UI因为UI渲染在另一个层根本不经过相机后处理通道。但如果你用的是World Space UI它会被当成3D内容渲染就会跟着整个画面一起被扭曲。解决思路是分相机。主相机挂后处理UI用单独的相机渲染并把UI相机的清屏设为Solid Color或Depth Only。这样UI始终干净清晰背景世界随便扭曲。多相机下还要注意扭曲后处理只挂在你认为“会被吸收”的相机上。比如战斗场景里主相机挂效果小地图相机不挂这样小地图不会跟着扭曲。5.4 换到URP管线后怎么办如果你项目用的是URP而不是内置管线OnRenderImage默认不会执行。URP需要改用ScriptableRendererFeature加ScriptableRenderPass的方式或者用URP下的Full Screen Pass Renderer Feature。核心思路没有变在渲染管线的某个时机取出当前相机渲染好的RT通过材质做一次全屏Blit再把结果写回。URP在不同Unity版本里的API差异很大老版本用cmd.Blit新版本推荐用Blitter.BlitTexture。建议在你当前Unity版本里直接搜索“Full Screen Pass”相关的包或文档套用官方模板再替换成我们的Shader逻辑即可。这篇分享里的Shader核心内容是通用的管线迁移只影响脚本调用方式。6. 移动平台优化与进阶玩法6.1 移动端性能优化的几个方向如果你的目标平台是手机尤其是微信小游戏这类WebGL平台性能优化要提前做。首当其冲的是分辨率。全屏后处理的耗时和像素数量成正比把RenderTexture的分辨率降到原来的一半性能立刻翻倍。1080P降到540P肉眼看上去只是稍微有点糊但帧率能稳定很多。第二个大方向是合并后处理链。不要一个Bloom脚本、一个扭曲脚本、一个调色脚本叠着挂每个脚本都是一次全屏RT拷贝。正确做法是写一个包含多个Pass的后处理Shader在一次Blit里依次完成扭曲、调色、辉光用RenderTexture的ping-pong切换传递中间结果。Shader内部也可以用半精度浮点数来减少ALU开销。在我的Shader里把关键的float2改成half2、float改成half在低端机上能省不少指令周期画面差异基本看不出来。6.2 让效果更像黑洞的进阶玩法基础效果跑通后想让它在项目里更出彩可以尝试叠加一个参数动画。让_Power从0快速上升到目标值保持一段时间再让_Radius收缩到0这种“出现-吸收-消失”的节奏非常有冲击力。配合画面震动、镜头轻微缩放过场感一下就出来了。另一个方向是让黑洞跟随鼠标或触摸点。只需要把_Center从固定值改成鼠标屏幕坐标脚本里用Input.mousePosition除以屏幕分辨率即可。接上手柄或VR的眼动数据也能形成交互玩法。如果你做的是科幻主题项目可以考虑把扭曲效果扩展到“传送门”中心不压暗而是显示另一张场景截图扭曲只在门框边缘起作用。这是同一个Shader的变体把中心暗化替换成场景纹理采样即可。7. 我的实际使用经验与建议做了这么多后处理效果我的体会是扭曲着色器本身不难真正花时间的是参数手感和效果衔接。第一次调那个强度的时候我开着Inspector一点一点拖从0.1拖到3.0中间有很长一段区间看起来都很生硬最后才发现衰减曲线和旋转角度要一起动才有丝滑感。建议拿到这套代码后先花半小时专门做“调参实验”。每次只改一个参数其他保持默认看画面变化。这样做两三轮之后你对每个参数控制什么会有肌肉记忆。不要一上来就Roam一堆参数乱拉那样只会越调越糊。最后分享一个小技巧。如果你要把这个效果接进正式项目不要把参数写死在每次调好的值上把Power、Radius、Swirl做成AnimationCurve或者用Animator驱动。我实际项目里就是动画曲线控制出现和消失玩家死亡时的那个黑洞从出现到完全吸入大概1.2秒比固定参数硬切自然得多。扭曲效果这东西数值对了就是所有物数值偏一点就是满满塑料感。
返回列表