前阵子做科幻题材的Demo,遇到一个很典型的需求:场景里要有一个黑洞,不是贴上去一张黑图,而是周围星光、星云、远处的飞船全都被它“吸进去”的那种视觉扭曲。找了一圈现成插件,要么太重,要么效果不真实,最后决定自己用Unity着色器实现一个黑洞扭曲效果。做完之后发现这个需求比想象中要经典,屏幕空间扭曲的思路在很多地方都用得上,所以把整个实现过程整理成一篇实操文章,从数学原理到Shader代码再到坑点排查,尽量说透,给同样在做类似效果的朋友一个参考。
这次实现我用的是后处理着色器方案,核心思路一句话概括:对最终画面做UV偏移,让采样点围绕黑洞中心旋转,越靠近中心扭曲越强,中心区域直接压黑。方案选型、数学公式、完整代码、参数调优以及常见问题都放在下面了,有需要的可以直接抄作业。
1. 先想清楚:扭曲效果怎么做才划算
1.1 三个方案的对比:粒子、Mesh和全屏后处理
在动手写Shader之前,我花了不少时间纠结方案选型。网上黑洞效果的做法大概能分成三类,各有利弊,这里直接放对比表。
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 粒子系统模拟 | 用大量粒子围绕中心做螺旋运动,叠加暗色材质 | 动态感强,适合做吸积盘、能量流动 | 粒子数量决定细节,数量大了性能扛不住;无法真正扭曲背景 | 距离镜头较远的装饰性黑洞 |
| Mesh顶点位移 | 把背景物体或一个曲面网格做顶点扭曲 | 能产生较真实的几何形变 | 只能影响指定物体,无法作用于整个画面;场景复杂时调试成本高 | 单个物体被吸入的特殊演出 |
| 屏幕空间后处理 | 对最终画面做UV扰动 | 影响全屏,效果直观,和场景物体无关;性能可控;实现难度低 | 本质上只是“视觉假象”,不是真正的光线追踪,物理正确性为零 | 大部分游戏内黑洞表现、传送门扭曲、热浪扭曲等 |
我最后选了屏幕空间后处理。原因很简单:黑洞扭曲在视觉上就是一个“全屏透镜”效果,场景里所有物体都要被扭曲,用粒子或Mesh根本管不过来。而且后处理方案性能是固定的,不管画面里有多少物体,Shader的复杂度不变,对项目排期来说风险最小。这个思路后来也用在了一个传送门特效上,相当于一个Shader做了两个功能。
1.2 屏幕空间后处理的原理到底在干嘛
屏幕空间后处理的原理说穿了很直白:摄像机先把整帧画面渲染到一张纹理上,然后我们的后处理Shader把这张纹理当成一张普通的图片,对每个像素重新计算“我应该去取图片上哪个位置的颜色”。
正常情况下,每个像素的UV坐标就是它自己所在位置,直接采样原图输出,画面没有变化。扭曲效果就是改变这个UV坐标,让像素去“偷看”另一个位置的颜色。离黑洞中心近的像素,被“偷看”的位置偏移得特别远,这样画面就被拉扯开了。
这里有个很容易混淆的点:扭曲不是让物体真的动起来,而是改变了你“看”物体的方式。就像透过一个哈哈镜看东西,物体本身没动,但镜片不同位置的折射率不一样,你看到的画面就变形了。黑洞扭曲本质上就是做一张“动态哈哈镜”,只是这个镜片的效果是中心旋转拉扯、周围平滑衰减。
2. 扭曲效果的数学与参数设计
2.1 简化版“引力透镜”公式
真正意义上的引力透镜得解爱因斯坦场方程,实时渲染里根本不可能这么干。我们需要的是一个视觉上“像那么回事”的近似数学公式。
我用的核心公式是这样的,先在Unity的任意向量计算工具里验证一下趋势,再落进Shader:
dir = uv - center dist = length(dir) // 像素到黑洞中心的距离 str = strength * exp(-dist * falloff) // 扭曲强度,距离越远越弱,指数衰减 angle = atan2(dir.y, dir.x) + str // 在原始角度上叠加一个偏移量 newDir = float2(cos(angle), sin(angle)) * length(dir) distortedUV = center + newDir这个公式的物理直觉对应得很好:像素离中心越近,角度偏移越多,画面围绕中心旋转的圈数就越多。atan2取的是像素相对于中心的角度,加上str这个偏移量之后重新算出一个方向向量,这样每个像素的位置就绕着中心“拧”了一圈。
选择exp指数衰减而不是简单的线性衰减,是因为黑洞的引力随着距离衰减是平方级的,视觉上需要“中心变化剧烈、外围迅速安静下来”的效果。实测下来exp(-dist * falloff)比lerp线性衰减自然得多,前者外围几乎没有生硬的扭曲感。
这里还有一个容易忽略的细节:str本质上是一个和距离相关的旋转量,但如果只有旋转,画面会显得干巴巴的,黑洞没有“往里吸”的感觉。所以我额外加了一个径向分量:
newDir *= 1.0 - pull * str; // 越近越往中心收缩pull控制在0到0.3之间,这样像素在旋转的同时还会往里缩一点,就有了“被吸进黑洞”的感觉。加上之后整个动态一下子生动了很多,不加之前更像龙卷风而不是黑洞。
2.2 事件视界:中心黑色区域的处理
扭曲公式做完之后,你会发现一个问题:黑洞中心什么都看得见,只是被拧成了万花筒,此时距离“黑洞”还差最后一步,中心必须是黑的。这就是事件视界,或者说视觉上的黑洞阴影。
处理办法是在采样完成后加一层遮罩,把中心一定半径内的像素压黑。一开始我直接写了if (dist < holeRadius) return black;,结果边缘硬得要命,黑洞像贴上去的一个圆形贴纸,非常假。
后来改成smoothstep做一个平滑过渡:
float holeMask = smoothstep(holeRadius, holeRadius * 0.7, dist); col.rgb *= holeMask;注意这里smoothstep的写法,holeRadius * 0.7是边缘宽度,数字越小过渡越窄、越锐利。我自己最终调到0.85左右,既保留了清晰的视界边缘,又不会出现锯齿状的生硬切割。
如果你想更讲究一点,可以把这里的黑色换成“吸积盘”效果。做法是先采样一张噪声贴图,根据噪声强度在视界外圈叠加暖黄色高亮,再做边缘发光。不过那部分已经超出“扭曲”本身了,属于美术向的加法,可以之后单独扩展。
2.3 参数表与默认值
Shader里的参数我全部暴露出去了,方便在Inspector里实时调。这里放一张参数表,对应我项目中最终调好的默认值,可以作为起点参考。
| 参数名 | 作用 | 默认值 | 调节建议 |
|---|---|---|---|
_Center | 黑洞中心坐标(UV空间) | (0.5, 0.5) | 屏幕正中就是0.5,0.5,左上角是0,0 |
_DistortionStrength | 扭曲总强度 | 1.2 | 值越大旋转层数越多,超过2.0画面会糊成一团 |
_DistortionRange | 扭曲影响半径 | 1.0 | 影响最终exp衰减的快慢,越大作用范围越大 |
_Falloff | 衰减系数 | 2.0 | 越大扭曲越集中在中心附近;越小越均匀 |
_Pull | 径向吸入强度 | 0.15 | 建议0~0.3,太大会出现画面整体向中心塌陷 |
_HoleRadius | 中心黑色区域半径 | 0.05 | 对应屏幕高度的比例,1920px下0.05大约是96px长度 |
_RotationSpeed | 随时间叠加的额外旋转 | 0.05 | 可选参数,给扭曲增加动态旋转的效果 |
参数调优经验我放到第4节展开,这里先给出结论:想让扭曲看起来“自然”,关键不是强度调多高,而是衰减曲线对不对。强度大但范围小、衰减陡峭,做出来是“局部风暴”;强度中等但范围大、衰减平缓,才是“大质量天体”的感觉。
3. 从零实现后处理Shader
3.1 环境准备:管线选择与脚本挂载
我当前项目用的是内置渲染管线(Built-in Pipeline),因为后处理最直接的OnRenderImage接口在内置管线里一个脚本就搞定了。Unity 2021及以上可以用URP,但URP里去掉了OnRenderImage的默认支持,需要写RenderFeature或者用CommandBuffer来Blit,稍微绕一点。后面3.4节会补充URP的兼容写法。
先说明一下我用到的环境:Unity 2021.3.16f1,内置渲染管线,非移动端开发时用的Linear颜色空间。如果你的项目是Gamma空间,Shader代码不用改,但最终颜色的观感会有细微差异,这是Unity颜色空间导致的,放心不是代码问题。
创建后处理脚本的步骤很简单:
- 在Project面板创建一个C#脚本,命名
BlackHoleEffect.cs。 - 在场景主摄像机上挂载这个脚本。
- 再创建一个Shader文件,命名
BlackHole.shader。 - 脚本公开一个
Shader字段,把Shader拖进去,运行时自动创建材质。
3.2 C#脚本完整实现
OnRenderImage的写法比较老,但胜在简单直接。完整脚本如下,我会把关键行拆开解释一遍。
using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class BlackHoleEffect : MonoBehaviour { public Shader shader; [Range(0f, 1f)] public float centerX = 0.5f; [Range(0f, 1f)] public float centerY = 0.5f; [Range(0f, 3f)] public float distortionStrength = 1.2f; [Range(0f, 3f)] public float distortionRange = 1.0f; [Range(0f, 5f)] public float falloff = 2.0f; [Range(0f, 0.5f)] public float pull = 0.15f; [Range(0f, 0.5f)] public float holeRadius = 0.05f; [Range(0f, 1f)] public float rotationSpeed = 0.05f; private Material material; private void OnEnable() { if (shader == null) { shader = Shader.Find("Hidden/BlackHoleEffect"); } if (material == null) { material = new Material(shader); material.hideFlags = HideFlags.HideAndDontSave; } } private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (shader == null || material == null) { Graphics.Blit(source, destination); return; } material.SetVector("_Center", new Vector4(centerX, centerY, 0f, 0f)); material.SetFloat("_DistortionStrength", distortionStrength); material.SetFloat("_DistortionRange", distortionRange); material.SetFloat("_Falloff", falloff); material.SetFloat("_Pull", pull); material.SetFloat("_HoleRadius", holeRadius); material.SetFloat("_RotationSpeed", rotationSpeed); material.SetFloat("_TimeScale", Time.time); Graphics.Blit(source, destination, material, 0); } }几个细节说一下。
[ExecuteInEditMode]是为了在编辑视图里直接看效果,不用进Play模式就能调试参数,极大提升调参效率。material.hideFlags = HideFlags.HideAndDontSave避免材质出现在项目资源里,纯运行时创建,干净不污染工程。OnRenderImage里每次渲染前都重新SetFloat/SetVector,是为了保证参数改动实时生效。- 如果
shader或者material没创建成功,直接Graphics.Blit(source, destination)原样输出,不黑屏。这是最基础的防御性代码。
3.3 Shader完整代码与逐段讲解
Shader本身不复杂,完整代码贴出来:
Shader "Hidden/BlackHoleEffect" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { // 后处理Shader通常不需要写队列和渲染标签,Unity默认处理 Cull Off ZWrite Off ZTest Always 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; }; sampler2D _MainTex; float4 _MainTex_TexelSize; float4 _Center; float _DistortionStrength; float _DistortionRange; float _Falloff; float _Pull; float _HoleRadius; float _RotationSpeed; float _TimeScale; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float2 uv = i.uv; float2 dir = uv - _Center.xy; float dist = length(dir); // 角度 = 原始角度 + 扭曲强度 float str = _DistortionStrength * exp(-dist * _DistortionRange * _Falloff); str += _RotationSpeed * _TimeScale * exp(-dist * _DistortionRange); // 可选的随时间旋转 float angle = atan2(dir.y, dir.x) + str; float2 newDir = float2(cos(angle), sin(angle)) * dist; // 径向吸入 newDir *= 1.0 - _Pull * str * 10.0; float2 distortedUV = _Center.xy + newDir; fixed4 col = tex2D(_MainTex, distortedUV); // 事件视界:中心黑色区域 float holeMask = smoothstep(_HoleRadius, _HoleRadius * 0.7, dist); col.rgb *= holeMask; col.a = holeMask; return col; } ENDCG } } }逐段解释几个重点。
关于_Pull的处理:我在代码里给径向吸入做了* 10.0的放大,这是因为我希望Inspector里的参数范围保持0到0.5的小数字好调,但实际吸入力度需要足够明显。这个系数没有标准答案,按你项目的画面比例调整。如果你看到的吸入效果太猛,把这个10调小就行。
关于_MainTex_TexelSize:这个变量在屏幕后处理Shader里很常用,但当前版本我没用上。后来做扩展的时候,用它来换算像素到中心的距离,可以让_HoleRadius和_DistortionRange的参数概念更直观。比如屏幕1920宽,你希望黑洞直径占200像素,那_HoleRadius设成100 * _MainTex_TexelSize.x就能精确表达。这个留个拓展思路,下面优化部分会用到。
关于smoothstep的第三个值:注意holeRadius * 0.7实际上是一个比holeRadius更小的值,smoothstep(edge0, edge1, x)要求edge0 < edge1才能得到从0到1的平滑过渡,这里dist从holeRadius * 0.7到holeRadius之间是从1平滑到0。如果你写成smoothstep(holeRadius * 0.7, holeRadius, dist),效果就是从内到外逐渐变亮,边缘柔和过渡,这正是我们想要的。这个细节会让中心黑圈边缘不至于有硬边。
3.4 URP下的兼容写法
如果你用的是URP,直接挂OnRenderImage是不生效的,这是URP的一个大变化。URP里通常有两种处理方式:写一个ScriptableRendererFeature,或者在需要后处理的地方用CommandBuffer把渲染结果Blit到另一个RT再Blit回来。
简单起见,这里给一个CommandBuffer的插入式方案,适合URP项目快速验证效果。思路是:在相机渲染结束后插入一个自定义Pass,把当前CameraTarget的内容Blit到一个临时RT,执行我们的Shader,再Blit回CameraTarget。
对应脚本核心:
using UnityEngine; using UnityEngine.Rendering; public class BlackHoleURP : MonoBehaviour { public Shader shader; private Material material; private CommandBuffer cmd; void OnEnable() { material = new Material(shader); var cam = GetComponent<Camera>(); cmd = new CommandBuffer(); cmd.name = "BlackHoleCmd"; int tempRT = Shader.PropertyToID("_TempRT"); cmd.GetTemporaryRT(tempRT, -1, -1); cmd.Blit(BuiltinRenderTextureType.CameraTarget, tempRT, material, 0); cmd.Blit(tempRT, BuiltinRenderTextureType.CameraTarget); cam.AddCommandBuffer(CameraEvent.AfterEverything, cmd); } void OnDisable() { var cam = GetComponent<Camera>(); if (cam != null) { cam.RemoveCommandBuffer(CameraEvent.AfterEverything, cmd); } } }注意AfterEverything这里时机比较粗暴,如果项目里还有后期抗锯齿、Bloom等URP后处理,执行顺序要放到它们之后才合适。正式项目建议还是用ScriptableRendererFeature插入,能更好地控制Pass顺序,这里就不展开写Feature了,网上URP Renderer Feature的教程很多,核心Shader都不用变,只需要把Blit目标换乘RenderTargetIdentifier即可。
4. 参数调优与性能实测
4.1 不同参数下的视觉表现对比
参数看起来不多,但组合起来画面差别非常大。我把试过的参数组合总结成一张经验表,方便你根据目标效果快速定位。
| 想要的效果 | _DistortionStrength | _DistortionRange | _Falloff | _HoleRadius | 观察 |
|---|---|---|---|---|---|
| 深邃大质量黑洞 | 1.0 ~ 1.5 | 1.0 ~ 1.5 | 1.0 ~ 1.5 | 0.04 ~ 0.06 | 扭曲范围广,外围安静,有“拉扯整片星空”的感觉 |
| 小型高密度天体 | 1.5 ~ 2.5 | 0.3 ~ 0.5 | 3.0 ~ 4.0 | 0.02 ~ 0.04 | 扭曲集中在中心,边缘迅速消失,像局部漩涡 |
| 传送门/时空裂隙 | 0.6 ~ 1.0 | 1.5 ~ 2.0 | 0.5 ~ 1.0 | 0.08 ~ 0.12 | 扭曲范围大但强度弱,更像“透镜”而非吞噬 |
| 龙卷风式动态扭曲 | 1.2 ~ 2.0 | 1.0 ~ 1.5 | 2.0 | 0.03 | 配合_RotationSpeed调到0.2以上,旋转感非常明显 |
我调试的优先级顺序是:先确定_HoleRadius,再调_DistortionRange,最后调_DistortionStrength。先把黑洞中心大小定好,再决定扭曲影响多远,最后看扭曲够不够强。如果一开始就调强度,很容易陷入“扭曲好强但看不出黑洞在哪”的误区。
有一个值得注意的坑:当_DistortionStrength超过2.0时,画面中心会出现“像素翻转”的乱流效果,因为角度偏移量超过了2π,像素开始跨多个旋转周期。有时候这能用来做混乱特效,但如果你要的是清晰黑洞,建议不要超过2.0。
另外,我在项目里让_Center可以跟随一个屏幕空间的目标位置运动,模拟黑洞在画面中移动的效果。实现上就是每帧把目标物体的世界坐标转换到屏幕UV坐标传入Shader:
Vector3 screenPos = Camera.main.WorldToViewportPoint(target.position); material.SetVector("_Center", new Vector4(screenPos.x, screenPos.y, 0, 0));这个技巧很简单但效果提升很大,黑洞不再是固定居中,能一边扭曲一边移动,适合做叙事演出。
4.2 移动端与PC的性能实测对比
性能必须是后处理效果的第一考量,毕竟它作用于全屏每个像素。
我当前测试环境:PC是一台2060 Super的机器,1080p分辨率全屏;安卓端是一台骁龙865的测试机,同样1080p。核心Shader没做优化的情况下,Profiler里看到的数据是这样的:
| 设备 | 分辨率 | GPU耗时(每帧) | 说明 |
|---|---|---|---|
| PC(2060 Super) | 1920x1080 | 约0.6ms | 非常轻松,完全不构成压力 |
| PC(2060 Super) | 2560x1440 | 约1.0ms | 还能接受,再叠加其他后处理要留意 |
| 安卓(骁龙865) | 1920x1080 | 约2.8ms | 有明显压力,单独跑看不出,接渲染线程合并后掉帧 |
| 安卓(骁龙865) | 960x540(半分辨率) | 约0.9ms | 降到这个量级才比较安全 |
2.8ms在移动端是什么概念?一个普通的20ms帧预算(50帧)里有2.8ms被单纯扭曲吃掉,再算上场景渲染、UI、物理,很容易掉到40帧以下。所以如果目标平台是移动端,必须做优化,不能直接把这个Shader丢上去。
4.3 优化技巧:半分辨率与预计算
优化上有三板斧,我的实际用法是按顺序叠加。
第一板斧:半分辨率扭曲。后处理扭曲对画面的高频细节要求不高,因为扭曲本身就是模糊的。所以我的做法是:先把全分辨率场景拷贝到一个四分之一的RT上,在该RT上做扭曲计算,再上采样混合回全屏。实现思路是在C#脚本里多几步Blit:
int halfRT = Shader.PropertyToID("_HalfRT"); RenderTexture.GetTemporary(halfRT, source.width / 2, source.height / 2, 0); Graphics.Blit(source, halfRT); // 降采样 material.SetTexture("_MainTex", halfRT); Graphics.Blit(halfRT, destination, material, 0); // 在低分辨率上做扭曲并上采样输出这一步在移动端能把2.8ms直接砍到1.0ms附近。视觉差异非常小,因为扭曲效果本身是低频变化,半分辨率损失的细节根本不会被注意到。注意降采样的时候可以用双线性过滤,我不会用点采样,否则上采样回来会出现锯齿。
第二板斧:用预计算贴图替代exp和atan2。移动端GPU对exp和atan2这类超越函数执行速度很差。可以在程序启动时生成一张径向渐变纹理,横轴是距离,纵轴是预计算的扭曲旋转量,Shader里直接采样这张贴图代替公式计算。这个优化能把2.8ms再砍掉30%左右,代价是参数不能无缝实时调整,改参数必须重新生成纹理。我一般测试时用公式,定稿后换贴图。
第三板斧:避免全屏重复计算。如果黑洞只在屏幕一小块区域活动,可以先在C#脚本里算一个黑洞中心的AABB包围盒,Shader里用dist > maxRange做一次早期判断(early-out),直接返回原图采样,减少无用计算。这一步优化效果看黑洞在屏幕中的占比,通常能再省10%~20%。
性能优化的顺序建议:先降分辨率,再预处理贴图,最后才考虑作用范围裁剪,每一步改动都要配合Profiler验证,不要相信感觉,GL基准线会告诉你真相。
5. 常见问题与排查技巧
5.1 后处理没生效,先查这四样
这个效果本身逻辑不复杂,如果挂上脚本后画面完全没变化,大概率是四种原因。
- Graphics.Blit前后目标参数写反了。
Graphics.Blit(source, destination, material, 0)中间那个参数是输出目标,写成destination才是屏幕,很多第一次写的人这里会写反导致黑屏或没有变化。 - Shader的SubShader里忘了写
Cull Off ZWrite Off ZTest Always。后处理Shader渲染时不应该有深度剔除和深度写入,否则在部分平台上会出问题。 - 材质没有正确创建或者Shader没有编译成功。打开Console窗口看有没有Shader报错,有报错直接看具体行号。
Hidden/BlackHoleEffect如果Unity没找到,Shader.Find会返回null,脚本里已经做了防御,画面原样输出,所以表现为“没效果”而不是黑屏。 - OnRenderImage在URP管线中根本不会被调用。如果你用的是URP,前面第3.4节的CommandBuffer方案才是入口,检查有没有用错管线。
排查时最有效的方式是先用Unity自带的Graphics.Blit(source, destination)做个最简单的反转色测试,如果反转色能显示,说明后处理链路通的,问题出在Shader逻辑;如果连反转色都不显示,说明链路本身有问题。
5.2 扭曲后边缘拉丝、画面撕裂怎么办
这个问题在后处理扭曲里非常常见,表现为黑洞周围出现放射状的细线,或者扭曲区域的边缘有撕裂感。
核心原因是采样UV越界。当扭曲把UV推出了0到1的范围,GPU会按照纹理的Wrap Mode处理,默认是Repeat模式,就会把纹理另一边的内容扯进来,形成拉丝效果。
解决办法有两个。第一个是对UV做Clamp:
distortedUV = saturate(distortedUV); // 限制在0~1范围内如果要更自然一点,可以用lerp把越界部分逐渐淡出到原采样:
float edgeFade = smoothstep(0.0, 0.1, distortedUV.x) * smoothstep(1.0, 0.9, distortedUV.x); edgeFade *= smoothstep(0.0, 0.1, distortedUV.y) * smoothstep(1.0, 0.9, distortedUV.y); col.rgb *= edgeFade;另一个问题是扭曲量太大时,边缘的像素偏移距离过大,超过了纹理的有效信息范围,拉出的线实际上是“没有信息的采样结果”。这种情况单纯Clamp不够,需要调低_DistortionStrength或者加大_Falloff让扭曲在边缘快速衰减。
我这里有一个自己的经验法则:如果扭曲导致的位移量超过屏幕宽度的四分之一,八成会出拉丝,不是被吸进去的效果而是画面崩坏。要控制单次偏移量在合理范围内,宁可多调几次参数,也别想着一次性把扭曲拉满。
5.3 不同平台结果不一致的处理思路
同样的Shader,PC上没问题,安卓上黑洞边缘出现锯齿或者完全不对,一般是两个原因。
第一个是颜色空间差异。Linear和Gamma空间下,后处理颜色计算出来的观感不同,如果项目从Gamma切到Linear,Shader的色彩效果会有差异,一般不致命,但边缘羽化区域可能看起来更生硬。解决方法不是改Shader,而是确认项目到底用了哪个颜色空间后,围绕这个空间统一调参。
第二个是精度问题。移动端GPU的half精度运算单位在一些Adreno芯片上表现和桌面GPU不同,尤其在exp这种超越函数上误差会放大。我现在项目里为了保险,在Shader最顶部加了一行:
#pragma target 3.0强制让Shader编译到较高的着色器模型,部分移动端老设备不支持浮点精确度,这个指令能有效减少误差。如果还有问题,那就把所有float都保持float,不要为了性能改成half,先把正确性保住再说。
第三个是平台差异导致的后处理执行顺序。有些安卓机型对OnRenderImage有特殊行为,比如高刷新率模式下调用频率变化。如果遇到这类问题,请优先升级Unity版本,2021 LTS版本对这个兼容性处理已经相当完善了。
最后再分享一个小技巧
这个黑洞扭曲效果的Shader版本定型后,我又给它加了一个很实用的能力:把_DistortionStrength、_HoleRadius这些参数全部开放给AnimationCurve,做成一个可以随时间变化的“扭曲动画曲线”。开场时黑洞从无到有,扭曲强度从0攀升到峰值,再回落到稳定值,配合镜头缓慢推进,效果比固定参数生动得多。如果你也想做类似演出,记得加一个全局的_TimeScale参数,这样在游戏暂停或慢动作的时候,扭曲动画也能跟着变速,不会穿帮。
做后处理Shader这几年,最大的体会是:屏幕空间扭曲这个能力本身很通用,修正一下采样规则、换一套衰减公式、调一调混合方式,就能衍生出黑洞、传送门、热浪、子弹时间碎裂等大量效果。所以不妨在项目里沉淀一个通用的扭曲后处理Shader模板,把UV扰动、旋转、径向收缩、遮罩几个功能做成可开关的开关,以后新需求来的时候,改参数就能用,不用从头写。