1. 放大镜效果的整体设计思路1.1 先搞清本质放大镜其实在“重排采样坐标”我之前做一款带有2D地图编辑器的项目时需要给地图加一个局部细节检视功能。一开始想的很简单就是在UI上叠一层圆形遮罩里面放一张尺寸更大、位置跟随鼠标偏移的底图。这个方案能用但问题也很明显只能对静态贴图有效换成3D场景就抓瞎而且UI层叠多了之后Canvas的刷新开销也跟着涨移动端上掉帧很直观。后来我把方案换成全屏后处理Shader思路完全不一样了。放大镜效果并不是真的把贴图像素“撑大”而是对当前屏幕图像做一次 UV 重映射——离中心越近的像素采样源图中更靠近中心的坐标那么在屏幕上看起来就是这一小块区域被拉伸放大了。举个容易理解的例子你拿放大镜看报纸纸上的字并没有变多只是同一段文字在视网膜上占了更大的投影面积。Shader 里做的事也一样它没有增加任何新信息只是改变了“显示区域和纹理坐标的对应关系”。这个本质决定了后面所有代码的组织方式我们只需要一个全屏后处理Pass传入屏幕图像作为主纹理在片元着色器里根据像素与放大中心的距离改写采样坐标就能凭空“造出”一只放大镜。1.2 圆形的限定与软边质感全看过渡带只做坐标重映射还不够。如果直接把整个屏幕都放大那就不叫放大镜叫“近视眼模拟器”了。放大镜必须限定在一个局部区域内而且这个区域通常是圆形。于是第二个核心问题出现如何判断当前像素到底在不在放大范围内标准做法是距离场。把鼠标位置转换成UV坐标后当作圆心每个像素用自己和圆心的距离 d 去对比半径 r。d 小于 r 的做放大采样d 大于 r 的保持原样。如果这个判断是“非黑即白”的画面会在圆形边缘出现非常扎眼的硬切边像素级别看起来就像有一个锯齿状的大窟窿特别廉价。我在项目里用的是 smoothstep 做一个软过渡带在半径内侧区域完全放大半径外侧完全保持原样中间一小段环形区域按距离比例混合。过渡带越宽边缘越柔和玻璃质感越强过渡带越窄边界越清晰更像仪表盘上的物理放大镜。这个参数我通常放在 0.02 到 0.1 之间具体看美术风格——写实向项目用宽软边科技感UI用窄硬边。1.3 方案选型为什么放弃UI组件选择后处理Shader这里有一个值得展开说清楚的选型对照。我也被问过无数次“为什么不用RawImageMask”所以我把几种常见方案的优缺点用一张表列出来方便你根据实际场景选方案适用场景优点缺点截图后局部放大贴图纯2D静态内容实现简单、消耗低只能静态无法覆盖3D场景实时性差UI Mask 多层Image2D动态UI层级好控制交互方便依赖Canvas3D场景无效性能开销随层数上升ScrollRect模拟放大长图预览有现成惯性滚动体验不是真正的局部放大不适合任意位置全屏后处理Shader任意3D/2D画面作用于整帧画面可叠加多种效果性能可控会占用一次全屏RT需要处理坐标换算我最后选第四种最核心的原因是它和渲染管线天然兼容。后处理发生在所有物体绘制完成之后拿到的是最终合成画面所以不管是3D模型、粒子、UI还是摄像机输出的任何内容都能被放大镜作用到。这种“一网打尽”的覆盖能力是UI方案做不到的。同时它的性能开销基本固定不会因为场景复杂度变化剧烈波动这是很多项目组在意的事。2. 核心细节与关键概念2.1 UV归一化与坐标空间别把屏幕像素直接喂给Shader这是我初期踩过最久的坑。Unity 的 Input.mousePosition 返回的是屏幕像素坐标例如在 1920×1080 的窗口下范围是 (0,0) 到 (1920,1080)。而 Shader 里纹理采样用的 UV 是归一化坐标范围是 (0,0) 到 (1,1)。直接把鼠标像素坐标当 UV 用放大镜会飞到屏幕外面去甚至连方向都是反的。正确的换算很简单mouseUV.x Input.mousePosition.x / Screen.width; mouseUV.y Input.mousePosition.y / Screen.height;这里还有一个特别容易被忽略的细节Input.mousePosition 的原点在屏幕左下角而 OpenGL 风格的 UV 原点也在左下角所以大多数情况下 Y 轴不用翻转。如果你在 DirectX 风格的平台或者某个特殊相机配置下发现放大镜上下颠倒就需要用1.0 - uv.y翻转一下。判断依据很简单在片元着色器里直接输出return fixed4(i.uv, 0, 1);观察屏幕左上、左下角颜色就能确定当前 UV 坐标系的原点在哪。另外如果项目要处理多显示器或动态分辨率最好统一用_MainTex_TexelSize这个由 Unity 自动填充的变量来换算出当前渲染纹理的实际尺寸而不是写死某个分辨率。后处理Shader的概念里常用到这个变量它本质上是“一个像素占多大UV”的逆尺寸。2.2 距离场与smoothstep边缘软硬的数学表达圆形遮罩的数学表达式非常简洁对每个像素计算向量dir uv - center再取长度d length(dir)。这就是二维平面上从圆心到当前像素的距离。接下来用一个典型做法做边缘混合float edge _ZoomRadius _Softness; float alpha smoothstep(edge, _ZoomRadius, d);这里值得停下来解释一下 smoothstep 的意义。它接受三个参数边界下限、边界上限、输入值。当输入值小于等于下限时返回0大于等于上限时返回1中间做Hermite插值。我上面把edge放下限、_ZoomRadius放上限得到的结果是d 越小越靠近圆心返回值越大越大越接近1d 越大返回值越小最终归零。所以这个 alpha 的语义就是“放大区域权重”圆心处权重1半径外权重0过渡带内权重从1平滑衰减到0。采样坐标的混合公式也就顺理成章float2 scaledDir dir / _ZoomFactor; float2 zoomedUV _ZoomCenter scaledDir; float2 sampleUV lerp(uv, zoomedUV, alpha);当 alpha1也就是在放大区域内当前像素会去采样“更靠近圆心”的纹理坐标alpha0 时则完全保持原样。缩放倍数 _ZoomFactor 大于 1所以dir / _ZoomFactor让采样点向圆心收缩画面中心的图像就被拉伸了。2.3 缩放倍数与采样频率为什么放大到4倍以上会糊有一点必须提前打好预防针Shader 放大本质上不会让信息变多。当放大倍数超过源图分辨率能提供的细节上限时放大区域内会出现明显的模糊和锯齿。我在实际测试里发现2 到 4 倍是比较舒服的范围5 倍以上除非源图特别大否则马赛克感会比较强。如果你确实需要超高倍率放大有两个补救方向第一在后处理前先用双线性或双三次插值算法做一次中间放大再做遮罩裁剪第二用额外的清晰度增强Shader比如Unsharp Mask在放大区域叠加高频细节。但大部分项目其实不需要走到这步因为放大镜效果本身是局部交互玩家会持续移动鼠标看不同区域轻微的纹理细节丢失反而像真实的玻璃透镜模糊不太会被挑毛病。还有一个容易翻车的地方是 mipmap 偏导数。当你对 UV 做大幅度缩放采样时GPU 依据屏幕空间偏导数选择的 mipmap 层级可能过低导致放大区域比预期更糊。如果出现“内部特别糊、边缘特别锐”的诡异现象可以考虑在采样时固定一个较小的 mipmap 层级或者关掉该纹理的 mipmap 生成实测能缓解。2.4 描边与玻璃折射感别让放大镜像一张贴纸纯粹做一个圆形的放大区域视觉上会非常单调像屏幕上贴了一块透明胶带。我习惯在此基础上叠加两个小效果边缘描边和边缘折射偏移。描边的实现仍然是靠距离场。在半径附近取一段环形区间输出一个边缘颜色float ringAlpha smoothstep(_ZoomRadius _Softness * 0.5, _ZoomRadius, d) * (1.0 - smoothstep(_ZoomRadius, _ZoomRadius - _Softness * 0.5, d)); col lerp(col, _RingColor, ringAlpha * 0.6);折射偏移模拟的是真实凸透镜边缘的扭曲感。实现方式也不复杂在过渡带内对采样UV额外加一个沿法线方向的偏移量同时把采样坐标再向外推一点点让边缘出现一圈轻微的“拉扯”效果。这个细节放大镜的“玻璃感”一下就出来了玩家会明显感觉到自己拿的是一个立体透镜而不是平面圆形区域。3. 完整实现与实操步骤3.1 工程准备后处理脚本与材质先说准备工作。你需要两个文件一个C#脚本用来挂到相机上一个Shader用来写后处理逻辑。Shader 建议放在Assets/Shaders/Magnifier.shaderC# 脚本放在Assets/Scripts/MagnifierPostEffect.cs。挂载方式很直接把 MagnifierPostEffect 拖到主相机上然后在 Inspector 里把 Shader 赋值给脚本的材质参数。脚本会在OnRenderImage自动执行后处理不需要手动管理摄像机的 RenderTexture。这里有一个工程设置层面的建议如果目标平台是PC建议把相机的 Allow HDR 打开后再测试放大镜这样在高动态范围场景下颜色空间转换不会对最终效果产生色偏。在移动端如果性能吃紧可以关掉 HDR 并选择低一档的渲染分辨率。3.2 完整的Shader代码一个可直接运行的版本下面是我测试过的完整 Shader。它不依赖任何额外资源你新建一个默认Unlit Shader把代码全部替换就能跑通Shader Custom/Magnifier { Properties { _MainTex (Base (RGB), 2D) white {} _ZoomCenter (Zoom Center, Vector) (0.5, 0.5, 0, 0) _ZoomRadius (Zoom Radius, Range(0.01, 0.5)) 0.15 _ZoomFactor (Zoom Factor, Range(1, 8)) 2.5 _Softness (Edge Softness, Range(0.001, 0.5)) 0.06 _RingColor (Ring Color, Color) (0.1, 0.4, 0.8, 1) _RingIntensity (Ring Intensity, Range(0, 1)) 0.6 _RefractStrength (Refraction Strength, Range(0, 0.05)) 0.01 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float2 _ZoomCenter; float _ZoomRadius; float _ZoomFactor; float _Softness; float4 _RingColor; float _RingIntensity; float _RefractStrength; 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; } fixed4 frag (v2f i) : SV_Target { float2 uv i.uv; float2 dir uv - _ZoomCenter; float d length(dir); // 放大区域采样坐标向圆心收缩 float2 zoomedUV _ZoomCenter dir / _ZoomFactor; // 边缘折射偏移依赖方向而非单纯距离 float2 refractDir dir / max(d, 0.001); zoomedUV refractDir * _RefractStrength; // 遮罩权重 float edge _ZoomRadius _Softness; float alpha smoothstep(edge, _ZoomRadius, d); // 混合采样 float2 sampleUV lerp(uv, zoomedUV, alpha); fixed3 col tex2D(_MainTex, sampleUV).rgb; // 圆环描边 float ringInner _ZoomRadius - _Softness * 0.5; float ringOuter _ZoomRadius _Softness * 0.5; float ringMask smoothstep(ringOuter, _ZoomRadius, d) * (1.0 - smoothstep(ringInner, _ZoomRadius, d)); col lerp(col, _RingColor, ringMask * _RingIntensity); return fixed4(col, 1.0); } ENDCG } } Fallback Off }注意上面这段代码里我加了几处抗坑设计。refractDir用dir / max(d, 0.001)做了归一化避免圆心处除零出现异常值描边区域用两级 smoothstep 做环带不会在圆心出现颜色污染。3.3 C#交互脚本鼠标位置与参数传递后处理Shader需要外部持续输入鼠标位置这个工作交给C#脚本完成。为了演示清晰我把参数都做成Inspector可调节的字段using UnityEngine; [RequireComponent(typeof(Camera))] public class MagnifierPostEffect : MonoBehaviour { public Shader magnifierShader; [Range(1f, 8f)] public float zoomFactor 2.5f; [Range(0.01f, 0.5f)] public float zoomRadius 0.15f; [Range(0.001f, 0.3f)] public float edgeSoftness 0.06f; [Range(0f, 0.05f)] public float refractStrength 0.01f; public Color ringColor new Color(0.1f, 0.4f, 0.8f, 1f); [Range(0f, 1f)] public float ringIntensity 0.6f; private Material magnifierMaterial; void OnRenderImage(RenderTexture src, RenderTexture dest) { if (magnifierShader null) { Graphics.Blit(src, dest); return; } if (magnifierMaterial null) { magnifierMaterial new Material(magnifierShader); magnifierMaterial.hideFlags HideFlags.HideAndDontSave; } // 鼠标屏幕坐标 - 归一化UV float mouseX Input.mousePosition.x / Screen.width; float mouseY Input.mousePosition.y / Screen.height; magnifierMaterial.SetVector(_ZoomCenter, new Vector2(mouseX, mouseY)); magnifierMaterial.SetFloat(_ZoomRadius, zoomRadius); magnifierMaterial.SetFloat(_ZoomFactor, zoomFactor); magnifierMaterial.SetFloat(_Softness, edgeSoftness); magnifierMaterial.SetFloat(_RefractStrength, refractStrength); magnifierMaterial.SetColor(_RingColor, ringColor); magnifierMaterial.SetFloat(_RingIntensity, ringIntensity); Graphics.Blit(src, dest, magnifierMaterial); } void OnDisable() { if (magnifierMaterial ! null) { DestroyImmediate(magnifierMaterial); } } }脚本的OnRenderImage在相机完成渲染后自动调用src 是相机渲染好的画面dest 是屏幕目标。整个后处理的执行顺序完全由Unity管好你不需要关心RenderTexture的申请和释放这是 Unity 后处理最友好的一点。运行场景后把鼠标移到画面任意位置就能看到半透明的放大镜跟着鼠标走边缘带有渐变和描边。如果没看到任何效果先检查 Shader 编译是否通过再把zoomFactor调大一点确认中心点附近有放大感。3.4 参数调整与手感优化实际项目中的调参顺序我每次调这类效果都有固定的顺序先保证功能正确再打磨观感。第一步把zoomFactor固定在 2.5半径 0.15软边 0.06这时候应该能肉眼看到局部放大。第二步调节半径和软边半径决定了玩家操作时“有效热区大小”太小会导致鼠标稍微一动放大镜就滑出观察目标太大又遮挡太多画面我实测 0.1 到 0.2 之间比较舒服同时软边保持在半径的五分之一左右最自然。第三步才是放大倍数和折射强度。放大倍数和移动速度之间有一个手感平衡倍数越大鼠标移动时画面的“流动感”越强但超过 5 倍后反而会看不清目标在哪折射强度我通常维持在 0.005 到 0.02数值太大会让边缘出现重影般的失真看起来像图像出错了。第四步是描边。描边颜色最好从画面主色调中取一个高对比色而不是纯白或纯黑。我有个项目用了淡蓝色描边加一点透明度效果就很像真实光学镜片的镀膜反射。整个调参过程我习惯在Game视图全屏预览因为Scene视图分辨率不一样观感会有偏差。4. 常见问题与排查技巧实录4.1 问题放大镜中心对不上鼠标位置这是出现频率最高的问题。我早期调试时鼠标明明在右上角放大镜却出现在左下角。排查后发现是两套坐标系不一致某些情况下渲染纹理的UV原点和屏幕坐标原点不在一起尤其在开启了“图形API风格差异”的平台工程中容易碰到。我的排查方法很笨但有效在片元着色器最开头临时改成return fixed4(uv, 0.0, 1.0);看屏幕左下角是不是红色。如果左下角是 (0,0,0) 黑色左上角是蓝色说明UV原点在左上角这时候C#脚本里需要mouseUV.y 1.0f - mouseUV.y。同时留意多相机叠加的情况OnRenderImage里拿到的 src 已经包含所有相机输出但鼠标坐标永远基于屏幕如果存在第二个相机负责UI层它的输出位置可能和主相机偏移导致放大镜定位不准。解决办法是把放大镜作用范围限制在主相机画面或者保证所有相机输出画面完全重叠。4.2 问题圆形边缘锯齿明显锯齿来源通常是遮罩边缘变化过于陡峭。softness很小或者半径很小smoothstep过渡带不足一两个像素时GPU光栅化会产生明显的阶梯感。可以在Shader里把过渡带宽度限制在至少三个像素以上即max(_Softness, _MainTex_TexelSize.x * 3.0)。另一个容易被忽略的因素是后处理之前的抗锯齿状态。后处理拿到的画面是已经完成光栅化的如果相机没有开MSAA且场景里有很多细线结构即使Shader做了软边放大镜内部还是可能贴出明显的硬件采样噪点。我的做法是把软边过渡带加宽到4像素同时适度降低放大倍数双管齐下肉眼几乎看不出瑕疵。4.3 问题放大镜被UI遮挡或导致UI点击失效这个问题的原因要分情况。如果放大镜覆盖在UI上面通常是因为你给后处理脚本挂在了UI相机上或者某UI元素渲染顺序在后处理输出之后。我的建议是把放大镜效果只挂载到主场景相机UI相机保持独立输出两者互不干扰。如果是UI遮挡了鼠标事件导致某些按钮点不到这不是Shader问题而是后处理无法感知UI布局。一个实用技巧是在C#脚本里加一个静态开关当检测到鼠标下方有可交互UI时将放大镜中心移出屏幕或者把缩放因子临时降到1这样玩家点按钮时不会受到视觉干扰。4.4 问题移动端画质变差和发热移动端GPU跑全屏后处理有一个天然代价每次OnRenderImage都要多一次全屏读写。在低端机上1080P分辨率下这个开销不容忽视。我建议做成两层策略平时放大镜处于非激活状态时直接把 src blit 到 dest跳过核心计算只有鼠标进入需求区域时才启动物理效果。另外Shader里尽量不用过多的依赖纹理采样次数。我的做法是放大区域内只做两次采样一次源图、一次可选的颜色修正描边和折射都用距离场数学算出来完全不依赖额外纹理。这在移动端能省不少带宽。4.5 排查速查表症状可能原因解决办法放大镜位置错乱UV原点或坐标空间不一致临时输出uv检查原点翻转Y轴放大区域没有画面Shader未编译或材质没赋上检查Console报错确认Shader赋值画面整体模糊没有做遮罩整个屏幕被放大检查alpha权重是否smoothstep参数反了边缘锯齿严重过渡带太窄或未开抗锯齿加宽softness开启MSAA放大镜内噪点放大倍数过高mipmap层级异常降低倍数固定mipmap采样级别移动端发热高持续全屏后处理带宽太大加入激活开关降低内部分辨率关于效果的扩展我曾经在同一个项目做过双放大镜左上角一个位置和倍率固定的鹰眼镜右下角一个跟随鼠标的标准放大镜实现方式只需要复制两个材质实例并传给不同的center值。后来还尝试了矩形区域版本只需要把距离场从欧几里得距离换成带轴权重的距离函数代码改动非常小但应用场景一下就广了比如阅读器的段落放大、地图工具的区域检视。我在实际项目里最大的体会是这类Shader效果看起来炫但真正决定成败的是坐标空间和过渡带细腻程度。花十分钟把坐标换算搞清楚比盲目调参一整天都有效。调试时先从返回uv开始再返回center距离场最后才拼合完整逻辑每步都验证基本不会出玄学问题。希望这篇实战记录能帮你少走一些弯路。
阅读完成 · 觉得有帮助?