Unity Shader实现黑洞引力透镜效果:从UV扰动到视觉奇观
1. 项目概述从“引力奇点”到屏幕上的视觉奇观最近在做一个科幻题材的独立游戏项目里面有个关键场景需要模拟一个黑洞。不是那种贴个黑色圆形贴图就完事的“假黑洞”而是希望它能真正地扭曲背景、吞噬光线甚至让周围的物体产生视觉上的形变营造出那种既神秘又危险的宇宙奇观感。这个需求Unity内置的Standard Shader或者URP/Lit Shader显然是搞不定的必须得自己动手写Shader。所谓“黑洞效果”在视觉上的核心就是模拟引力透镜效应。简单来说就是黑洞的巨大引力会弯曲周围经过的光线导致我们看到的背景图像发生扭曲、拉伸和复制。在Shader里我们不需要真的去解爱因斯坦场方程而是通过一种叫做屏幕空间UV扰动的技术来“欺骗”眼睛。其基本原理是根据当前像素到黑洞“中心”的距离和方向去采样背景图上“本该”是另一个位置的像素颜色从而制造出空间被扭曲的错觉。这个效果非常适合对Unity Shader有一定基础想挑战一下进阶图形学应用的朋友。它不涉及复杂的光照模型PBR核心是数学计算和纹理采样技巧。通过实现它你能深刻理解片元着色器Fragment Shader如何操作屏幕图像、如何用数学函数模拟物理现象以及如何优化这类全屏后处理效果的性能。下面我就把自己从零实现这个效果的过程、踩过的坑和最终优化方案详细拆解一遍。2. 核心思路与数学模型构建2.1 效果原理与方案选型实现视觉上的黑洞扭曲主流方法有两种顶点偏移和屏幕UV扰动。顶点偏移的方法是在顶点着色器阶段根据模型顶点到黑洞中心的距离沿着径向或法线方向移动顶点位置。这种方法对于扭曲一个特定的模型比如一个平面广告牌比较直接但它有几个致命缺点首先效果依赖于模型的网格密度网格稀疏的话扭曲效果会很生硬其次它无法扭曲场景中其他不相关的物体比如天空盒和远处的景物黑洞的“影响力”就被局限在单个物体上缺乏沉浸感。因此我选择了屏幕空间UV扰动方案。这是一个典型的后处理效果。它的思路是渲染完整个场景之后得到一张完整的屏幕图像在Unity里就是_CameraOpaqueTexture。然后我们运行一个全屏的Shader对这张图像的每一个像素即屏幕上的每一个点根据该点与黑洞屏幕坐标的距离计算出一个“偏移量”。用原始的UV坐标加上这个偏移量再去采样屏幕纹理得到的就是被黑洞引力“弯曲”后我们真正应该看到的颜色。这个方法能扭曲场景中的一切——模型、粒子、天空盒、UI如果处理的话效果非常统一和完整。2.2 引力扭曲的数学模型如何计算这个UV偏移量这是整个效果的灵魂。我们需要一个函数输入是当前像素到黑洞中心的距离r输出是径向的偏移强度strength。最直观的想法是使用平方反比定律模拟万有引力strength G * M / (r * r)。其中G是引力常数M是黑洞质量。在Shader里我们可以把G*M合并成一个可调节的强度参数_GravityStrength。这个公式在距离r很小的时候强度会变得极大容易导致数值不稳定画面在黑洞中心附近会“爆掉”。更常用且视觉效果更好的是倒数衰减strength k / r。k是一个控制整体扭曲力的参数。这个公式计算简单在近处强度增长不至于过于剧烈能形成一个从中心向外平滑衰减的扭曲场。然而无论是1/r还是1/(r*r)在r0即黑洞中心点时都会出现分母为零的情况导致计算无效。为了解决这个问题我们通常会给距离r加上一个非常小的保护值epsilon例如r max(distance(center, uv), 0.001)。但这样处理黑洞中心点r极小的偏移量会极大且方向随机导致中心区域采样混乱通常呈现为无意义的噪点或条纹。为了让中心区域看起来更像一个吞噬一切的“事件视界”我们需要在强度函数上再加一个处理。一个常见的技巧是当r小于某个阈值_EventHorizonRadius时让强度函数不再增长甚至可以将UV偏移到一个固定的“吞噬色”比如纯黑或者进行极端的扭曲直到纹理采样失效。我们可以用smoothstep函数来实现这个平滑过渡。最终我采用的强度计算函数如下float GetDistortionStrength(float r) { // 事件视界半径此区域内的像素将被完全吞噬 float horizon _EventHorizonRadius; // 扭曲影响的最大半径 float maxRadius _MaxEffectRadius; // 如果距离大于影响半径则无扭曲 if (r maxRadius) return 0.0; // 计算基础倒数衰减强度 float strength _GravityStrength / (r 0.001); // 如果处于事件视界内将强度急剧增大模拟被吞噬的效果 // 使用smoothstep创建一个平滑但快速的过渡 float horizonFactor 1.0 - smoothstep(0.0, horizon * 0.5, r); strength horizonFactor * _GravityStrength * 100.0; // 视界内极大增强 // 整体衰减距离越远影响越小使用一个平滑的衰减曲线 float falloff 1.0 - smoothstep(0.0, maxRadius, r); strength * falloff; return strength; }这个函数的核心逻辑是strength 基础衰减强度 视界内增强强度并整体乘上一个基于最大影响半径的平滑衰减系数。它保证了效果在中心最强、边缘平滑消失并且在事件视界处有一个视觉上可接受的突变。2.3 坐标空间与参数设计在动手写代码前必须理清坐标空间。我们的计算发生在屏幕后处理阶段因此最方便的坐标是屏幕空间UV坐标范围是(0,0)到(1,1)。黑洞的中心位置_BlackHolePos也需要从世界空间转换到屏幕空间UV坐标再传入Shader。需要暴露给材质面板调节的关键参数有_GravityStrength引力强度控制扭曲力的整体大小。_EventHorizonRadius事件视界半径屏幕空间比例值如0.05此区域内的画面将被完全扰乱或吞噬。_MaxEffectRadius最大影响半径屏幕空间比例值如0.3超过此距离则无效果。_AspectRatio屏幕宽高比。因为我们的UV是归一化的但屏幕像素不是正方形直接计算圆形区域会变形需要用它来校正。_DistortionColor可选的扭曲区域染色用于给被强烈扭曲的区域添加一些色散或辉光效果。3. Shader实现与核心代码解析我选择在Unity URP通用渲染管线下实现使用后处理渲染器特性RenderFeature来注入我们的全屏Pass。这种方式比传统的OnRenderImage更高效与现代渲染管线集成更好。3.1 Shader主体结构首先创建一个Unlit Shader因为它不依赖光照只处理屏幕纹理。Shader Hidden/BlackHoleDistortion { Properties { _MainTex (Texture, 2D) white {} _GravityStrength(Gravity Strength, Range(0, 5)) 1.0 _EventHorizonRadius(Event Horizon Radius, Range(0.001, 0.2)) 0.05 _MaxEffectRadius(Max Effect Radius, Range(0.01, 0.5)) 0.3 _AspectRatio(Aspect Ratio (Width/Height), Float) 1.777 _DistortionColor(Distortion Color, Color) (0,0,0,1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline} Pass { Name BlackHoleDistortion HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv IN.uv; return OUT; } // 声明属性变量 float _GravityStrength; float _EventHorizonRadius; float _MaxEffectRadius; float _AspectRatio; float4 _DistortionColor; float4 _BlackHolePos; // xy为屏幕空间UV坐标z为深度暂未使用w为标志位 // 强度计算函数即上一节中的GetDistortionStrength float GetDistortionStrength(float r) { ... } half4 Frag(Varyings IN) : SV_Target { // 核心计算流程 } ENDHLSL } } }3.2 片元着色器核心逻辑在Frag函数中我们实现核心的扭曲算法。half4 Frag(Varyings IN) : SV_Target { // 1. 获取当前片元的屏幕UV float2 uv IN.uv; // 2. 校正UV的宽高比以便在非正方形屏幕上计算正确的圆形距离 float2 centeredUV uv - _BlackHolePos.xy; centeredUV.x * _AspectRatio; // 将x轴拉伸补偿屏幕宽度 // 3. 计算到黑洞中心的校正后距离 float r length(centeredUV); // 转换为未校正的比例距离用于后续判断 float r_original length(uv - _BlackHolePos.xy); // 4. 如果距离超过最大影响半径直接返回原屏幕颜色 if (r_original _MaxEffectRadius) { half4 originalColor SampleSceneColor(uv); return originalColor; } // 5. 计算扭曲强度 float strength GetDistortionStrength(r); // 6. 计算UV偏移方向向量指向黑洞中心 float2 dir normalize(centeredUV); // 注意扭曲方向是**指向**黑洞中心所以偏移量是负方向 float2 offset -dir * strength; // 7. 将偏移量转换回未校正的UV空间 offset.x / _AspectRatio; // 8. 应用偏移得到采样UV float2 distortedUV uv offset; // 9. 采样屏幕纹理 half4 distortedColor SampleSceneColor(distortedUV); // 10. 事件视界处理如果距离小于视界半径混合吞噬色 if (r_original _EventHorizonRadius) { // 使用距离比例进行平滑混合 float blendFactor smoothstep(0.0, _EventHorizonRadius, r_original); distortedColor lerp(_DistortionColor, distortedColor, blendFactor); // 也可以在这里让颜色变暗或添加噪点模拟被吞噬的物质 distortedColor.rgb * (0.2 0.8 * blendFactor); } return distortedColor; }关键细节解析方向计算dir normalize(centeredUV)得到的向量是从黑洞中心指向当前像素的。但引力扭曲是让光线“落向”中心所以采样点应该沿着-dir方向移动。这里非常容易搞反如果方向反了效果会变成“喷涌”而非“吸入”。宽高比校正centeredUV.x * _AspectRatio这一步至关重要。假设屏幕是16:9不校正的话一个在UV空间里是圆形的区域在屏幕上会显示成椭圆形。我们在计算距离和方向时进行校正确保扭曲场在屏幕上是圆形的但在应用偏移后需要将offset.x除回去因为最终的采样UV仍然是原始的、未校正的UV空间。采样安全SampleSceneColor是URP提供的宏用于安全采样_CameraOpaqueTexture。我们直接使用distortedUV进行采样即使它略微超出[0,1]范围纹理的Wrap Mode通常为Clamp会处理边界情况这有时会产生有趣的边缘拉伸效果模拟光线从屏幕外弯曲进来。3.3 在C#中驱动效果我们需要一个C#脚本将黑洞的世界坐标转换为屏幕UV并传递给Shader。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class BlackHoleDistortionFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material; public float gravityStrength 1.0f; public float eventHorizonRadius 0.05f; public float maxEffectRadius 0.3f; public Color distortionColor Color.black; } public Settings settings new Settings(); private BlackHoleDistortionPass _renderPass; public Transform blackHoleTransform; // 在Inspector中指定黑洞物体 public override void Create() { if (settings.material null) return; _renderPass new BlackHoleDistortionPass(settings.material); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (blackHoleTransform null || settings.material null) return; // 将黑洞世界坐标转换为视口坐标0-1 Camera cam renderingData.cameraData.camera; Vector3 viewportPos cam.WorldToViewportPoint(blackHoleTransform.position); // 只有当黑洞在摄像机前方时才启用效果 if (viewportPos.z 0) { settings.material.SetVector(_BlackHolePos, new Vector4(viewportPos.x, viewportPos.y, viewportPos.z, 1)); settings.material.SetFloat(_GravityStrength, settings.gravityStrength); settings.material.SetFloat(_EventHorizonRadius, settings.eventHorizonRadius); settings.material.SetFloat(_MaxEffectRadius, settings.maxEffectRadius); settings.material.SetColor(_DistortionColor, settings.distortionColor); settings.material.SetFloat(_AspectRatio, (float)Screen.width / Screen.height); renderer.EnqueuePass(_renderPass); } } private class BlackHoleDistortionPass : ScriptableRenderPass { private Material _material; public BlackHoleDistortionPass(Material material) { _material material; renderPassEvent RenderPassEvent.BeforeRenderingPostProcessing; // 在后期处理前执行 } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (_material null) return; CommandBuffer cmd CommandBufferPool.Get(BlackHoleDistortion); // 使用Blit绘制全屏三角形应用材质 Blit(cmd, renderingData.cameraData.renderer.cameraColorTarget, renderingData.cameraData.renderer.cameraColorTarget, _material, 0); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } }这个ScriptableRendererFeature需要挂载在URP Asset的Renderer中。它每一帧都会将黑洞的位置转换并传递给Shader并安排一个全屏Pass来执行扭曲。4. 效果优化与进阶技巧基础版本完成后效果已经有了但可能显得有点“平”不够震撼。下面分享几个我实测有效的优化和增强技巧。4.1 添加吸积盘与引力红移真实的黑洞周围往往有炽热的气体盘即吸积盘。我们可以用另一个Pass来模拟这个效果。绘制吸积盘在黑洞位置画一个圆盘可以使用一个简单的粒子系统或一个朝向摄像机的圆面片并使用一个自定义Shader。这个Shader的关键在于UV扭曲让吸积盘的纹理也受到同一个黑洞扭曲函数的影响使其内缘向中心拉伸。颜色渐变从内到外颜色从高温的蓝白色渐变到低温的红色模拟引力红移和多普勒效应靠近观察者的一侧蓝移远离的一侧红移。透明与叠加使用Additive混合模式叠加在扭曲的背景之上。模拟引力红移在扭曲Shader中我们可以根据扭曲的强度轻微地调整采样颜色的色相。一个简单的模拟方法是在Frag函数返回颜色前加入// 简单的颜色偏移模拟扭曲越强颜色越向红色波段偏移 float redshiftFactor saturate(strength * 0.5); distortedColor.rgb lerp(distortedColor.rgb, distortedColor.rgb * float3(1.2, 0.8, 0.8), redshiftFactor);这只是一个视觉把戏但能增加物理上的可信度。4.2 性能优化策略全屏后处理对性能有影响尤其是移动平台。以下是几个优化点降低采样次数我们的Shader目前每个像素采样一次屏幕纹理。这是最低消耗了无法再减。但要确保没有不必要的采样。使用Half精度在移动平台或不需要极高精度时在Shader中使用half或fixed类型来存储颜色和中间向量可以提升运算速度。将float2、float替换为half2、half将half4替换为fixed4对于颜色。控制渲染分辨率如果效果不需要全分辨率可以在URP的渲染器特性中将渲染目标设置为一个降采样的纹理。或者只在黑洞靠近屏幕中心、影响区域较大时才启用全分辨率渲染其他时候使用半分辨率。分帧渲染对于非实时变化的静态黑洞可以考虑每两帧或更长时间更新一次扭曲效果将历史帧的结果进行混合这在某些视角固定的场景中能大幅节省性能。4.3 与场景物体的交互目前我们的黑洞只扭曲背景但一个更酷的效果是让场景中的物体也被“吸”向黑洞。这需要修改物体的Shader或使用顶点动画。一个取巧的方法是为需要被影响的物体编写一个顶点着色器在这个着色器中访问同一个_BlackHolePos和扭曲参数计算每个顶点在世界空间或视图空间中到黑洞的向量和距离然后根据一个类似的强度函数将顶点位置沿着指向黑洞中心的方向偏移。这样模型本身就会发生形变与屏幕空间的扭曲效果结合能产生非常强烈的立体吸入感。注意事项顶点动画会破坏静态合批增加Draw Call需要权衡性能。通常只对少数关键物体如飞船、碎片使用。5. 常见问题与调试实录在开发过程中我遇到了不少问题这里把典型的几个和解决方法列出来。5.1 效果边缘出现硬边或锯齿问题描述在黑洞影响区域的边界扭曲效果突然消失形成一圈明显的硬边或者因为像素采样产生锯齿。原因与解决强度函数不连续检查GetDistortionStrength函数在r _MaxEffectRadius处是否平滑过渡到0。我使用了smoothstep来创建平滑的衰减边缘float falloff 1.0 - smoothstep(_MaxEffectRadius * 0.8, _MaxEffectRadius, r_original);这会让强度在最大半径的80%到100%之间平滑降为0消除硬边。抗锯齿后处理效果本身可能会放大锯齿。确保你的项目开启了合适的抗锯齿如MSAA或URP的FXAA、SMAA。如果问题依然存在可以在Shader中对采样UV进行轻微的随机偏移抖动采样但这会增加性能开销需谨慎使用。5.2 黑洞中心区域画面混乱或闪烁问题描述事件视界内部不是稳定的黑色而是出现不断闪烁的杂乱像素或条纹。原因与解决UV偏移过大当r接近0时strength会非常大导致distortedUV偏移量极大可能采样到屏幕纹理非常遥远甚至重复的区域。解决方案是钳制强度。在GetDistortionStrength函数中当r小于一个极小值时直接返回一个固定的大值或者更简单地在事件视界内直接返回一个固定的吞噬色不再进行扭曲采样。if (r_original _EventHorizonRadius * 0.5) { return _DistortionColor; }数值精度问题在r极小时normalize(centeredUV)计算出的方向向量可能不稳定。可以添加一个判断如果r小于某个阈值如0.001则直接使用一个默认方向或跳过偏移计算。5.3 效果在屏幕边缘变形或不正确问题描述当黑洞靠近屏幕边缘时扭曲形状变得奇怪或者效果突然减弱。原因与解决宽高比校正错误这是最常见的原因。务必确认校正逻辑计算距离和方向时校正计算最终偏移时反向校正。仔细检查第3.2节代码中的第2、7步。屏幕坐标边界当黑洞中心在屏幕外或者扭曲导致采样UV远超出[0,1]范围时纹理的Clamp模式会导致边缘像素被拉伸。这有时是期望的物理效果模拟从屏幕外弯曲进来的光线但如果不希望这样可以钳制采样UVdistortedUV clamp(distortedUV, 0.001, 0.999)或者对边界外的采样使用一个固定的颜色。5.4 性能开销过大问题描述开启效果后帧率明显下降。排查与优化使用Frame Debugger或RenderDoc检查是否意外导致了多余的Pass或全屏Blit。确保我们的RenderPassEvent设置正确没有在不需要的摄像机如场景摄像机、反射探头上执行。简化强度计算GetDistortionStrength函数中的smoothstep和多次乘除运算有一定开销。可以尝试用更简单的函数近似比如strength _GravityStrength / (r 0.1) * (1.0 - r/_MaxEffectRadius)。限制影响区域如果黑洞很小可以不用处理全屏像素。通过计算黑洞在屏幕上的边界矩形只对这个矩形区域内的像素执行扭曲Shader。这需要更复杂的RenderFeature实现可能使用Blit的sourceRect参数或CommandBuffer.DrawProcedural指定一个缩小的四边形。实现这个黑洞效果的过程就像是在用代码和数学重新发现一次广义相对论预言的神奇现象。从最初一个简单的UV偏移想法到逐步加入事件视界、吸积盘、性能优化每一次迭代都让这个虚拟的天体更加真实和令人信服。Shader编程的魅力就在于此它不仅是技术实现更是一种艺术创作。当你看到背景的星空被无情地拉伸、旋转最终消失在那个深邃的黑暗中心时那种成就感是无可替代的。最后一个小建议多调节参数用不同的背景星云、行星、空间站进行测试你会发现同一套Shader能演绎出无数种截然不同的视觉风格。