Unity Shader从零实现放大镜效果:原理、代码与性能优化
做Unity Shader开发的这些年被问到最多的一个功能性需求就是放大镜。很多人第一反应是截图然后用UI显示这基本是把简单问题复杂化而且效果往往很生硬。真正舒服的放大镜应该是连续的——鼠标滑过哪里哪里的画面就像被一块真实的镜片覆盖一样边缘有轻微过渡甚至高光镜片内部保持高清晰度。这篇文章我从头到尾拆解一个纯Shader方案先讲原理再给可运行的Shader和C#控制脚本然后是视觉细节打磨边缘、高光、抗锯齿最后是移动端和跨平台兼容性的实际踩坑记录。这里适合两类读者一类是做工具类App、需要放大镜功能的开发者另一类是刚学Unity Shader、想通过一个具体案例理解UV坐标操作的同学。两种人看完都能直接照着写出来。1. 放大镜效果的第一性原理为什么必须在Shader里做1.1 放大镜到底在放大什么先说一个容易被忽略的事实放大镜效果几乎从来不是在放大一张图片而是在改变采样方式。屏幕上你看到的每一帧画面本质上是GPU把一张纹理按照每个像素的UV坐标采样得到的。普通的显示过程里每个像素采样的UV和它的屏幕位置是一一对应的屏幕左上角的像素采纹理左上角的点屏幕中心的像素采纹理中心的点。放大镜要做的就是打破这种对应关系让屏幕上某个圆盘范围内的像素不去采样它们本来的位置而是去采样更靠近圆心、更浓缩的纹理区域。这样一来这个圆盘内显示出来的内容就是原始画面在圆心附近那一小块的放大投影。举个例子放大倍率设置为2。圆盘边缘的像素本来应该采样UV坐标为(0.6, 0.4)的地方但在放大镜作用下它实际采样的是(0.55, 0.375)这个位置离圆心近了一半。屏幕上的每一个像素都往圆心挤了一下表现出来就是中心区域的内容被拉大了。理解了这个逻辑后面的Shader代码就是在做两件事计算每个像素到圆心的方向和距离然后按放大倍率把UV往圆心方向收缩。整个效果的核心公式只有一个magnifiedUV center (uv - center) / magnification这个公式就是放大镜的全部灵魂。后面的所有代码、所有视觉优化都是围绕它展开的。1.2 两种现实可行的实现路线在Unity里实现放大镜大家常用的路线有两条全屏后处理和局部表面Shader材质球局部模型/UI元素。两条路线各有适用场景我做了个对比对比维度全屏后处理方案局部表面方案实现位置OnRenderImage或URP的RenderFeature一个Quad/RawImage上挂材质影响范围整个屏幕只影响镜片所在区域性能开销每帧全屏采样一次只采样镜片区域开销小适用场景放大镜可出现在屏幕任意位置放大镜是固定区域内的控件接入成本脚本挂相机即可需要处理UI坐标转换如果你做的是整屏可以随时开启放大镜的工具类App后处理方案更省事一个Material、一个脚本挂到Camera上就完事。但如果放大镜只是界面上一个圆形控件比如图纸浏览工具里的局部查看器我强烈建议用局部方案——它只在镜片覆盖到的像素上跑Shader性能开销小得多而且可以随意放到RawImage等UI控件里。这篇文章的Shader核心代码两条路线通用区别只在C#脚本怎么把鼠标位置换算成UV坐标。所以我的建议是先看懂Shader本身再根据你的实际场景选择C#接入方式。1.3 为什么缩放UV而不是缩放物体我见过不少人试图用把一张截图放大显示在UI Image里来模拟放大镜结果遇到一堆问题截图有延时、放大后像素粗糙、圆盘边缘无法和底层画面无缝衔接。根本原因在于真正的放大镜是透视效果它需要底下的画面实时可见且不断更新而截图方案是在放大一张静态的照片。Shader里缩放UV的另一个好处是零拷贝。GPU直接从屏幕纹理中按计算后的UV取值不需要任何CPU参与也没有中间纹理的复制开销。整个过程是纯粹的可编程光栅化阶段在做数学运算在移动端上也就是几个ALU操作的事。顺带说一句如果需求是放大镜下面的内容不是当前屏幕而是某个固定区域的2D内容也完全可以在同样的Shader里把_MainTex换成那张区域的RenderTexture逻辑一模一样。这也是我强烈推荐用Shader方案的根本原因——它把取样来源和取样规则彻底解耦了。2. 核心Shader逐行拆解一个镜片从零到一2.1 最小可运行版本一个能跑的放大逻辑先把最小可运行版本贴出来。这个版本没有边缘过渡、没有高光就是一个圆盘内的UV重映射但足够让你确认原理是通的Shader Custom/MagnifierBasic { Properties { _MainTex (Main Texture, 2D) white {} _Center (Center, Vector) (0.5, 0.5, 0, 0) _Radius (Radius, Range(0.01, 1)) 0.25 _Magnification (Magnification, Range(1, 10)) 2.0 } SubShader { Tags { RenderType Opaque Queue Overlay } ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; float2 _Center; float _Radius; float _Magnification; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { float2 uv i.uv; float2 dir uv - _Center; float dist length(dir); float2 magnifiedUV _Center dir / _Magnification; float mask step(dist, _Radius); uv lerp(uv, magnifiedUV, mask); return tex2D(_MainTex, uv); } ENDCG } } }这段代码的核心就三行。第一dir uv - _Center算出当前像素相对镜片中心的向量。第二magnifiedUV _Center dir / _Magnification把向量按倍率收缩得到放大后的采样坐标。第三mask step(dist, _Radius)判断当前像素是否在圆盘内在圆盘内用放大坐标圆盘外保持原坐标。step(dist, _Radius)这个函数在圆内返回1、圆外返回0配合lerp实现二选一。不过它有一个明显问题边界是硬切的圆盘边缘会有一条肉眼可见的锯齿边而且放大区和非放大区的内容会在分界线上突然切换。这就要引入下一节的边缘过渡。2.2 边缘过渡让镜片内外自然衔接硬边界的观感跟一个挖出来的洞差不多很假。真实放大镜的边缘会有轻微的过渡区——因为镜片存在光学畸变越靠近边缘放大效果逐步衰减。更关键的是如果边缘硬切放大区边缘的像素和非放大区边缘的像素在内容上错位明显人眼会立刻识别出这是图像处理效果。把step换成smoothstep就能解决float softEdge smoothstep(_Radius, _Radius * 0.85, dist); uv lerp(uv, magnifiedUV, softEdge);注意smoothstep的参数顺序第一个参数对应较远的距离第二个对应较近的距离。这里_Radius是圆盘边界_Radius * 0.85是过渡区开始的位置。当 dist 从_Radius * 0.85增大到_Radius时softEdge 从1平滑降到0实现中心完全放大、边缘逐渐消失的效果。过渡带宽度我建议控制在半径的10%到20%之间。太窄等于没过渡太宽会让人感觉放大区域边缘的内容变形了。我自己实测下来15%是比较舒服的默认值即_Radius * 0.85。如果你想要更专业的光学感可以把过渡带做成非线性的比如用二次曲线加速过渡float t 1.0 - saturate((dist - _Radius * 0.85) / (_Radius * 0.15)); float softEdge t * t * (3.0 - 2.0 * t); // smoothstep的显式形式 uv lerp(uv, magnifiedUV, softEdge);这个写法本质是手动算平滑插值方便你后面扩展自己的过渡曲线。比如想做成中间平坦、边缘急收的镜片可以调指数想做边缘翘起的鱼眼感可以调成pow(t, 0.8)。这套参数化的方法比直接调smoothstep灵活得多。2.3 镜框质感高光环与内阴影光有过渡还不够真实产品里用户对这是一个镜片的判断主要靠边缘的视觉线索。我一般会在同一个Shader里补两样东西边缘高光和环境遮挡。边缘高光用一圈渐变带模拟思路是取一个比半径窄的环带在环带内输出一个高光颜色float ring 1.0 - smoothstep(_Radius * 0.92, _Radius * 0.98, dist); float3 highlightColor float3(0.85, 0.9, 1.0); float3 finalColor tex2D(_MainTex, uv).rgb; finalColor highlightColor * ring * 0.6;内阴影则用从边缘到中心的暗角模拟镜框遮挡让镜片看起来更立体float vignette smoothstep(0.0, _Radius * 0.6, dist); finalColor * lerp(1.0, 0.85, ring * vignette);这两行代码组合起来镜片边缘会出现一条细腻的亮度渐变中间区域亮度基本不变非常接近真实玻璃镜片在光照下的表现。这里全部是纯数学计算没有额外纹理所以性能代价几乎为零。3. C#控制层让放大镜跟着鼠标走3.1 屏幕坐标到UV坐标的换算Shader负责怎么画但要画得动还得让C#每帧把鼠标位置换算成UV坐标传给_Center。这个换算看似简单实际上绝大多数新手第一个坑就在这里。Unity的Input.mousePosition返回的是屏幕像素坐标原点在左下角。而Shader里的UV坐标默认原点也是左下角。是不是直接uv.x mousePos.x / Screen.width; uv.y mousePos.y / Screen.height;就完事了理论上是但实际运行时会遇到两个变数一是后处理全屏纹理在不同图形API下的UV方向可能翻转二是局部UI方案里鼠标坐标是相对于屏幕的根本不等同于UI元素的UV。一个保险的做法是写一个换算函数并且在真机/编辑器里各测一次确认放大镜中心能准确贴在鼠标上Vector2 ScreenToUV(Vector3 mousePosition) { float uvX mousePosition.x / Screen.width; float uvY 1.0f - mousePosition.y / Screen.height; // 后处理场景通常要翻转 return new Vector2(uvX, uvY); }为什么大多数后处理场景要翻转Y因为后处理中拿到的场景纹理在DirectX系平台Windows上采样时的UV方向是从上往下的而屏幕坐标是从下往上。如果你直接把屏幕坐标除以屏幕高度会在Y方向错位整个屏幕的高度。这个问题在编辑器里可能不明显到了Windows真机或某些安卓上就暴露。后面的第5章我专门会讲这个东西先记住结论接入后先测Y轴对齐不对就翻转。3.2 全屏后处理方案的接入流程全屏后处理方案的C#脚本非常简洁挂在主Camera上即可using UnityEngine; [RequireComponent(typeof(Camera))] public class FullScreenMagnifier : MonoBehaviour { public Material magnifierMaterial; [Range(0.01f, 0.5f)] public float radius 0.25f; [Range(1f, 10f)] public float magnification 2f; void OnRenderImage(RenderTexture source, RenderTexture destination) { if (magnifierMaterial null) { Graphics.Blit(source, destination); return; } Vector2 uv new Vector2( Input.mousePosition.x / Screen.width, 1f - Input.mousePosition.y / Screen.height ); magnifierMaterial.SetVector(_Center, new Vector4(uv.x, uv.y, 0f, 0f)); magnifierMaterial.SetFloat(_Radius, radius); magnifierMaterial.SetFloat(_Magnification, magnification); Graphics.Blit(source, destination, magnifierMaterial); } }把这个脚本挂到Camera上把Shader材质拖到magnifierMaterial运行起来就是全屏后处理的放大镜。注意OnRenderImage是在场景渲染完成后调用的所以它能看到的是已经渲染好的整个场景纹理正好满足放大镜观看当前画面的需求。这里有个小技巧如果放大镜只在按住鼠标时显示可以在更新中心点之前判断Input.GetMouseButton(0)按住才设_Center为真实鼠标位置松开时把中心点设到屏幕外让镜片消失在屏幕外。这个方法比做显隐控制简单也不需要额外的分支代码。3.3 UI局部方案RawImage Material不占全屏后处理方案虽然简单但它有一个天生劣势整个屏幕每帧都要跑一遍片段着色器哪怕镜片只占了屏幕的一小块。移动端上这个开销非常不划算。UI局部方案就是把镜片做成一个UI控件让它只在自己覆盖的像素上运行Shader。思路是在Canvas下放一个RawImage给它指定放大镜材质然后每帧把鼠标位置换算成这个RawImage内部的UV坐标传给材质。坐标换算要用RectTransform的辅助函数处理UI缩放模式带来的差异using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(RawImage))] public class UIMagnifierController : MonoBehaviour { private RawImage rawImage; private Material material; void Awake() { rawImage GetComponentRawImage(); material new Material(Shader.Find(Custom/MagnifierWithEdge)); rawImage.material material; } void Update() { RectTransform rect rawImage.rectTransform; Vector2 localPoint; // 把屏幕坐标转换到RawImage的局部坐标 if (RectTransformUtility.ScreenPointToLocalPointInRectangle( rect, Input.mousePosition, null, out localPoint)) { Vector2 size rect.rect.size; Vector2 uv new Vector2( (localPoint.x size.x * 0.5f) / size.x, (localPoint.y size.y * 0.5f) / size.y ); material.SetVector(_Center, new Vector4(uv.x, uv.y, 0f, 0f)); material.SetFloat(_Radius, 0.25f); material.SetFloat(_Magnification, 2.0f); } } }这里有一个前提RawImage的源纹理必须是你想在镜片里显示的内容。如果是截图当前画布底下的内容需要先把那部分渲染到一张RenderTexture再作为RawImage的texture。我一般会把被放大的内容源单独渲染到一张RT这样放大镜可以独立控制缩放倍率而不影响画布本体。4. 视觉与性能打磨从效果能跑到体验顺畅4.1 消除边缘伪影过渡带、纹素对齐与Mipmap把基础版本跑通之后会看到几个细节问题。第一个是过渡带里的色带断裂。在smoothstep过渡区里UV被lerp混合采样点会经过纹理中的跳变区域导致边缘出现明显的条纹。解决办法是给过渡带多用一层梯度采样或者提高过渡带的采样精度。我自己常用的做法是过渡带内不用tex2D而是用tex2Dgrad手动指定屏幕方向的梯度让纹理过滤更准确float2 texelSize float2(1.0 / _ScreenParams.x, 1.0 / _ScreenParams.y); float2 gradX ddx(uv) * texelSize.x; float2 gradY ddy(uv) * texelSize.y; fixed4 col tex2Dgrad(_MainTex, uv, gradX, gradY);tex2Dgrad是逐像素采样的一个变体它使用我们显式传入的UV梯度来做Mipmap选择能避免在过渡带因为UV突变而选错Mip层级从而减少模糊和加重锯齿的问题。第二个是放大区的图像明显比非放大区糊。这是因为放大本质是用更少的纹理数据铺更大的屏幕区域放大倍率越高单像素对应的原纹素越少糊是必然的。缓解手段有两种提升源纹理分辨率或者保证源纹理的Mipmap开启了显式控制。如果被放大的是屏幕后处理RT可以让RT的分辨率略高于屏幕给放大留出余量。4.2 移动端Shader带宽与半分辨率方案移动端做放大镜最担心的不是Shader复杂而是带宽。全屏后处理方案本质上是一张全屏RT被读出来再整张写回去带宽消耗大致等于屏幕宽度 × 屏幕高度 × 4字节在2K分辨率手机上每帧光这一个Pass就要接近30MB的读写量。如果刷新率还是120Hz数字会再翻倍。实测数据参考在一个中端手机上1080p分辨率、60Hz全屏后处理放大镜占GPU时间的比例大约是8%-12%。如果是UI局部方案同一个效果只占1%-2%差距非常直观。如果一定要用全屏方案我推荐半分辨率优化把源RT降半采样放大镜只负责在降采样后的纹理上做重映射。这样带宽直接降到原本的四分之一画质损失在放大镜场景里肉眼几乎看不出来因为放大区域本来就是在放大像素轻微的纹理粗糙反而会掩盖算法的插值痕迹。具体做法是先生成一张源RT在渲染完场景后用Graphics.Blit把结果降采样到半分辨率RT再让放大镜Shader采样这张半分辨率RT。核心代码int halfW Screen.width / 2; int halfH Screen.height / 2; RenderTexture halfRT RenderTexture.GetTemporary(halfW, halfH, 0); Graphics.Blit(source, halfRT); // 只做降采样不经过放大镜shader magnifierMaterial.SetTexture(_MainTex, halfRT); Graphics.Blit(halfRT, destination, magnifierMaterial); RenderTexture.ReleaseTemporary(halfRT);注意降采样这一步要用双线性过滤不然会明显走样。Unity在Graphics.Blit到较低分辨率RT时默认会处理过滤但最好显式给RT设置filterMode FilterMode.Bilinear。4.3 镜片大小与放大倍率的联动设计最后给一个产品层面很实用的小建议放大镜的半径和放大倍率最好做成联动的。当倍率变大时镜片半径适当缩小否则用户会感觉看得到的地方太多不知道聚焦在哪。我常用radius baseRadius / sqrt(magnification)这个经验公式。不做严格推导但它符合直觉放得越大可观察的原始区域越小镜片半径也应该相应缩小让用户的视觉焦点更集中。在Shader里加一个_LensSize参数由C#端算好再传进去就行。还有一个细节当放大倍率很高时鼠标移动一点点镜片下的内容就会飞得很快用户很容易眩晕。可以给中心点加一个低通滤波让镜片中心平滑追随鼠标center Vector2.Lerp(center, targetCenter, 1f - Mathf.Exp(-Time.deltaTime * 12f));这个公式用的是帧率无关的指数平滑比直接Lerp(center, target, 0.1f)稳定得多不会因为帧率波动导致手感不一致。数值12是经验值偏好跟手可以调大偏好平滑可以调小。5. 实测踩坑记录坐标翻转、高倍率模糊与URP兼容5.1 编辑器里正常、手机上歪的经典坐标问题这个坑我至今记得清楚。某次在一台Windows开发机上跑放大镜编辑器里一切正常鼠标动、镜片动严丝合缝。结果同一个工程打包到安卓真机放大镜中心明显偏了一大截Y方向差了大概整个屏幕高度的20%。排查下来就是第3章提到的UV翻转问题。内置渲染管线里后处理全屏Quad的顶点UV在DirectX和OpenGL/Vulkan/Metal上的方向不一样。Unity提供了一个标准宏UNITY_UV_STARTS_AT_TOP在Shader里做一次条件翻转#if UNITY_UV_STARTS_AT_TOP uv.y 1.0 - uv.y; #endif但要注意这个宏判断的是当前图形API的UV方向不是屏幕坐标。更稳妥的做法是在C#端根据平台判断而不是在Shader里乱翻。我的实际习惯是C#脚本里先不做翻转shader里统一按OpenGL风格采样然后在C#里判断SystemInfo.graphicsDeviceType如果是Direct3D11、Direct3D12或Metal就把Y翻转一次bool flipY SystemInfo.graphicsDeviceType GraphicsDeviceType.Direct3D11 || SystemInfo.graphicsDeviceType GraphicsDeviceType.Direct3D12 || SystemInfo.graphicsDeviceType GraphicsDeviceType.Metal; float uvY flipY ? 1f - mouseUV.y : mouseUV.y;用了这套逻辑之后我在Windows、macOS、安卓、iOS上再也没有遇到坐标错位问题。核心原则是只在一个地方做翻转判断不要两头都翻。5.2 放大5倍以上模糊与锯齿的处理方式放大倍率超过5倍之后即使用了半分辨率RT也会明显感觉到图像发糊、边缘有锯齿。这个阶段能做这几件事。一是提高采样密度。如果产品允许直接把源RT的分辨率设置为屏幕的1.5倍成本不低但效果很直接。二是开启实时光线过滤或者使用更高阶的纹理过滤方式。把源RT的filterMode设为Trilinear并且开启MipmapShader里配合tex2Dgrad自行选择Mip层级。这个方法不需要额外RT适合快速改进。三是最实在的接受放大区域本身是低清晰度的客观事实通过边缘过渡和镜框高光来分散用户对内部细节的注意力。人眼对边缘锐利程度的敏感度远高于中间内容清晰度把边缘打磨好整体观感会上一个档次。5.3 URP管线下的调整思路如果你用的是URPUniversal Render Pipeline内置管线的OnRenderImage不再生效。URP要求用RenderFeature重写后处理逻辑流程是创建自定义RenderFeature在Execute里用Blitter.BlitCameraTexture或CommandBuffer.Blit把_CameraColorTexture当作源纹理套上放大镜材质后再写回_CameraColorTexture。URP里还有一个坑默认情况下放大镜Shader里采样_MainTex传的RT是_CameraColorTexture它的UV方向可能与内置管线不同坐标翻转判断要重新验证。我的建议是在URP里干脆全屏后处理和局部RawImage方案都用局部方案避开这个话题——反正URP项目多半也在做UI局部方案的接入成本反而更低。最后一个实际经验不要试图在URP的ScriptableRenderPass里一边写后处理一边又挂在UI上两个管线混用属于自找麻烦。选定一条路线坚持到底。写到这里我突然想到一个很多教程不会提的小点放大镜的圆心和鼠标位置不建议直接对齐而是加一个很小的偏移让镜片中心比鼠标点稍微偏上偏左。原因很现实——用户放大一个东西时眼睛焦点通常在鼠标略上方因为手指或鼠标本身会挡住视线中心。这个偏移量在10到20像素之间不同的产品场景可以调整。第一次有人告诉我这个细节时我还不以为然真机上试了一次之后就再也没去掉过。要在Shader和C#脚本之间传递这个偏移只要在换算UV时给uv加上一个常量偏移即可。放大镜这类功能代码不难难的是让用户的肉眼觉得这是真实世界的镜子。希望这篇拆解能让你少走几回我之前走过的弯路。