☰
UGUI UI倒影效果Shader实现:从渲染原理到性能优化实践
2026/10/24 3:06:44 网站建设 项目流程

1. 倒影看起来简单,为什么偏偏要用Shader做

1.1 UI里常见的倒影需求长什么样

游戏和应用的UI里,倒影是一种出现频率极高的“氛围特效”。最常见的几种场景:商品展示卡片里,商品图下方拖一条淡化的镜像;角色立绘底部压一层半透明倒影,增强人物和背景的层次感;音乐播放页、唱片封面、榜单条目,也喜欢在封面图下放一小段倒影,让卡片看起来更精致。

这些倒影的共同点是:它并不需要像水面反射那样接受完整场景,只需要把原图垂直翻转,再从接触边缘往下做一条alpha渐变。这件事本身不复杂,但一旦放到UGUI里做,就牵出很多细节:翻转到底翻在哪里?为什么渐变在Shader里算比在图片上预涂更灵活?为什么直接修改RectTransform的Scale会导致各种诡异问题?

我这次的需求,是给一套商城卡片统一加倒影。原图来自服务器,尺寸不固定,商品名和价格的布局还会动态变化。如果每个卡片都让美术出单独倒影素材,维护成本会直接失控。所以我决定写一个专用的UGUI Shader,把“翻转”和“渐变”全部放到渲染阶段完成。这套方案我在几个项目里反复用过,下面把实现细节和踩过的坑完整整理出来。

1.2 直接准备素材的三个隐形代价

很多人第一反应是:倒影不就是把图片翻转后加个透明渐变吗?美术导出素材时直接把渐变做进去不就行了?确实可以,但代价往往在项目后期才显现出来。

第一个代价是尺寸适配。UI图片的显示宽度随时会变,按钮可能从320像素拉伸到480像素,倒影素材如果只有固定尺寸,要么被拉伸变形,要么露边。美术可以给你一套九宫格,但九宫格对翻转类效果很不友好,尤其是带圆角和复杂边缘的立绘图片,九宫格根本表达不了渐变。

第二个代价是样式迭代效率。倒影的高度、透明度、渐变曲线,产品经理和UI设计师经常会微调。改一次,美术导出一次,再替换一次包体资源。如果项目只有一个倒影素材还行,但像商城这种几十个商品卡片同屏的场景,倒影参数不统一会非常难看,统一的话又要重复维护多套资源。

第三个代价是内存和图集。倒影素材如果不打进图集,每张就是一次单独的纹理加载;打进图集又会浪费图集空间。倒影本质上只是原图的反转加渐变,用一张纹理的两个副本完成,等于白白占了一份显存和打包空间。

1.3 Shader方案的收益和适用边界

用Shader做倒影,核心收益有三点:

第一,倒影高度、透明度、渐变曲线全部暴露成Material参数,改数值立刻生效,不需要重新出图。第二,图片尺寸无论怎么变,Shader都按当前网格的UV范围计算,不担心拉伸变形。第三,原图和倒影共用同一张Sprite纹理,不产生额外的纹理资源。

不过要说明白,Shader方案适合“平面镜像+线性渐变”这种规则效果。如果你要做倒影在水面波动扭曲、折射、被障碍物遮挡等复杂效果,这个方案就不够,需要再加高度图采样、法线扰动、多次偏移采样等技术。对绝大多数UI需求来说,“规则倒影”已经能覆盖九成场景,这也是我选择它作为通用方案的原因。

2. 动手前先搞懂UGUI渲染的契约:四边形网格、UV与裁剪

2.1 一张Image在Canvas里到底提交了什么

在书写Shader之前,必须理解UGUI的Image被渲染时,提交给GPU的是什么数据。

一个普通的Image组件,在CanvasRenderer的渲染流程里会被拆成一个矩形网格:四个顶点、两个三角形、一组UV坐标。RectTransform的Pivot只影响顶点在局部坐标系里的位置,不影响UV方向。UV坐标永远按左下角是(0,0)、右上角是(1,1)的方式映射。如果这张图片来自图集,Unity会在生成网格时把UV改成图集里对应的小矩形区域,但方向关系不变。

这一点决定了我们在Shader里做垂直翻转非常简单:只要在采样时把uv.y改成1 - uv.y,纹理就会上下颠倒,形成倒影。无论原图是单张纹理还是图集中的一块,这个操作都有效。

2.2 翻转图片的三种姿势

我见过的翻转方案大概有三种,各有利弊:

方案实现方式优点缺点
RectTransform负Scale把localScale.y设为-1排版直观,不用碰Shader会翻转整个顶点坐标,影响动画、阴影、布局计算,容易出奇怪问题
Shader翻转UV在片段着色器里将uv.y取反不动Transform,代码可控性强,可同时计算渐变需要自己维护Shader和材质
改写网格在OnPopulateMesh里手动生成镜像顶点性能最好,可跟原图合并成一个Mesh需要写C#,且要处理动态顶点数据

很多教程推荐负Scale,因为它最快:复制一个Image,设置localScale.y = -1,再做一张带渐变的遮罩图叠上去,视觉上就完成了。但负Scale的真正问题是,它翻转的是顶点位置,而不是纹理坐标。UGUI内部很多计算都基于RectTransform,负Scale可能导致Canvas重建时边界计算异常,也让Animator、Tween插件在缩放动画上出现反直觉的表现。如果只是做静态倒影没啥大问题,一旦和动画、遮罩结合,坑就多了。

我把主方案选为Shader翻转UV。这样镜像Image可以保持完全正常的RectTransform,所有锚点、Pivot、布局行为都符合直觉,翻转逻辑全部收敛在Shader里,逻辑更纯粹。

2.3 UI/Default里那些看似无关、实则关键的约定

如果你写过普通的3D物体Shader,直接把它套到UGUI图片上,大概率会出问题。原因是UGUI的渲染有一套约定,必须处理四件事:模板缓冲、矩形裁剪、Blend混合、ZTest。这四件事正好是Unity自带UI/Default这个Shader一直在做的事。

模板缓冲用于配合Mask组件。Unity的Mask通过Stencil写入和测试来决定哪些像素可见,如果自定义Shader没有模板相关代码,Mask区域内会出现倒影完全消失或反向镂空的问题。矩形裁剪用于RectMask2D和ScrollView,它通过_ClipRect参数和UnityGet2DClipping函数对超出裁剪范围的像素直接清零。Blend混合决定了透明图片的叠加方式,UI常规环境都用SrcAlpha OneMinusSrcAlpha。ZTest则由unity_GUIZTestMode这个全局变量决定,在Overlay相机模式下会变成始终通过,保证UI元素之间的层级不受深度缓冲干扰。

所以一个能安全用在UGUI里的Shader,不是从零开始写,应该是在UI/Default的基础上改。保留它的Stencil块、混合状态和裁剪逻辑,只替换纹理采样部分,才不会让倒影在某些特定Canvas设置下变异。

3. 一个可落地的倒影Shader:完整代码、参数与挂载过程

3.1 基于UI/Default定制的MirrorFade Shader

下面这个Shader就是我项目里实际在用的版本,名称叫UI/MirrorFade。它保留了UI/Default的Stencil和裁剪逻辑,额外增加了UV翻转开关、倒影渐变和透明度控制。

Shader "UI/MirrorFade" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} [PerRendererData] _TextureSampleAdd ("Texture Sample Add", Float) = 0 _Color ("Tint", Color) = (1,1,1,1) _FadeOffset ("Fade Offset", Range(0, 1)) = 0 _FadePower ("Fade Power", Range(0.2, 8.0)) = 2.0 _FadeAlpha ("Fade Alpha", Range(0, 1)) = 0.8 [Toggle(_FLIP_UV)] _FlipUV ("Flip UV In Shader", Float) = 1 _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15 [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { Name "Default" CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 2.0 #pragma multi_compile _ UNITY_UI_CLIP_RECT #pragma multi_compile _ UNITY_UI_ALPHACLIP #pragma shader_feature _FLIP_UV #include "UnityCG.cginc" #include "UnityUI.cginc" struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPosition : TEXCOORD1; }; sampler2D _MainTex; fixed4 _Color; fixed4 _TextureSampleAdd; float4 _ClipRect; half _FadeOffset; half _FadePower; half _FadeAlpha; v2f vert(appdata_t IN) { v2f OUT; OUT.worldPosition = IN.vertex; OUT.vertex = UnityObjectToClipPos(IN.vertex); OUT.texcoord = IN.texcoord; OUT.color = IN.color * _Color; return OUT; } fixed4 frag(v2f IN) : SV_Target { float2 uv = IN.texcoord; #ifdef _FLIP_UV uv.y = 1.0 - uv.y; #endif fixed4 color = (tex2D(_MainTex, uv) + _TextureSampleAdd) * IN.color; // 沿镜像Image的Rect,从顶部接触边往下衰减 float t = saturate((IN.texcoord.y - _FadeOffset) / max(1e-4, 1.0 - _FadeOffset)); float fade = pow(t, _FadePower); color.a *= fade * _FadeAlpha; #ifdef UNITY_UI_CLIP_RECT color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip(color.a - 0.001); #endif return color; } ENDCG } } FallBack "UI/Default" }

3.2 核心逻辑逐段拆解

这段Shader看着长,真正新增的渲染逻辑其实只有三块:UV翻转、渐变衰减、透明度系数。

UV翻转在片段着色器里完成:uv.y = 1.0 - uv.y。采样前把Y方向反转,相当于把原图上下颠倒采样。为了灵活性,这个开关没有写死,而是用_FlipUV控制。默认值为1,也就是开启翻转。如果你以后要复用这个Shader做普通图片处理,把开关关掉即可。

渐变衰减公式:

float t = saturate((IN.texcoord.y - _FadeOffset) / max(1e-4, 1.0 - _FadeOffset)); float fade = pow(t, _FadePower); color.a *= fade * _FadeAlpha;

IN.texcoord.y在镜像Image的网格里,顶部是1,底部是0。倒影和原图接触的上边缘应该完全不透明,越往下越透明,所以用t表示“距离底部的位置比例”。_FadeOffset表示底部保留多少比例完全透明的区域,取值越大,透明区域越多。_FadePower控制渐变曲线:等于1时是线性衰减,大于1时顶部附近更清晰、底部快速消失,视觉上更自然。

需要特别注意,这里的IN.texcoord.y始终是镜像Image自身网格的UV,和纹理是否翻转无关。因为我们在Shader里翻转的是采样坐标,而不是网格坐标,所以渐变方向不会因为翻转逻辑而混乱。这也是我坚持不做负Scale的原因之一。

3.3 在场景里挂载镜像Image

Shader写好后,实际操作分三步:创建镜像Image、设置RectTransform、指定材质。

第一步,在原图同级创建另一个Image,把Source Image设置为和目标相同的Sprite。第二步,把镜像Image的RectTransform对准原图下边缘,并向下扩展。第三步,把材质换成新建的UI/MirrorFade材质。如果直接用代码完成,可以参考下面这段:

public void SetupMirror(Image source, Image mirror) { RectTransform srcRect = source.rectTransform; RectTransform dstRect = mirror.rectTransform; dstRect.anchorMin = new Vector2(0.5f, 0f); dstRect.anchorMax = new Vector2(0.5f, 0f); dstRect.pivot = new Vector2(0.5f, 1f); dstRect.sizeDelta = new Vector2(srcRect.rect.width, srcRect.rect.height * 0.5f); dstRect.anchoredPosition = new Vector2(0f, 0f); mirror.sprite = source.sprite; mirror.raycastTarget = false; mirror.material = mirrorMaterial; }

这里的核心思路是:镜像Image的顶边和父节点底部对齐,Pivot选在(0.5, 1),也就是顶部中心点,这样它整个图形会从对齐边向下生长,正好落在原图下方。镜像高度取原图高度的一半,是因为实际倒影很少做完整等高度的镜像,半高通常更自然。

raycastTarget必须设为false,否则倒影会挡住下层按钮的点击事件。这个细小设置很多人会漏掉,等UI点击穿透测试时才暴露。

3.4 为什么选择Shader翻转UV而不是负Scale

关于负Scale和Shader翻转,我在实际项目里做过对比,最后选Shader的原因很实际。

第一,负Scale会改变Image在Canvas里的包围盒方向,部分Unity版本中,RectTransform上的负缩放可能导致UI的边界裁剪判断失误,尤其在ScrollView里会不定期出现“明明该露出来的部分被裁掉”的现象。第二,负Scale与Shader翻转不能叠加,很多新手先设置负Scale,又套用了这种翻转Shader,结果倒影像被镜像了两次,原图又倒了回去。第三,负Scale对Tween和Layout组件不太友好,比如UI的LayoutGroup在计算布局时通常只参考RectTransform的Attribute,负Scale会让布局表现变得反直觉。

Shader翻转则完全绕开这些,Transform本身干干净净,视觉效果只在最终渲染阶段生效。后续要调整倒影高度、偏移、淡出曲线,只需改Material参数,或者通过同一个Shader的多个材质变体,实心动效也很方便。

4. 实际项目里最容易翻车的四个点

4.1 Mask和ScrollView里倒影整体消失或半透明

如果倒影被放在ScrollView里,或者外层套了Mask和RectMask2D,用普通自写Shader很容易出问题。缺了Stencil块,Mask区域内的倒影会渲染不出来;缺了UNITY_UI_CLIP_RECT相关的裁剪函数,RectMask2D会把倒影的下半部分直接硬切,露出明显边缘。

这个Shader从UI/Default继承三角板,从根部规避了这两类问题。Stencil块按UI/Default的默认方式读写,RectMask2D的裁剪则通过UNITY_UI_CLIP_RECT和UnityGet2DClipping完成。如果你要在自己的Shader基础上改,务必保留这三个关键词:Stencil块、UNITY_UI_CLIP_RECT、UNITY_UI_ALPHACLIP。

还要确认一点:镜像Image本身要处于Mask的层级内部。如果把它放在Mask组件外面,Shader支持再多的Stencil也没用,它根本不参与Mask区域的渲染。

4.2 Tiled和Sliced图片的倒影形状奇怪

UGUI的Image有四种绘制类型:Simple、Sliced、Tiled、Filled。这个倒影Shader只建议搭配Simple类型使用。

Sliced九宫格类型的图片,顶点会被拆分成九块区域,每块区域的UV并不连续,直接在Shader里做uv.y = 1 - uv.y会导致切片边缘错位,倒影出现明显的接缝或者重复纹理。Tiled类型更夸张,本身就会重复平铺像素,再做翻转,结果完全不可控。Filled类型的填充百分比和翻转逻辑没有语义对应关系,出来的效果也容易出乎意料。

所以实际项目中,如果原图是按钮、卡片这种九宫格背景,最好不要在同一个Image上做倒影。正确做法是把倒影作为一个独立的Simple Image,指定一张不带九宫格的展示图,或者把按钮内容分层处理。

4.3 RectTransform布局变化后倒影错位

倒影是原图外的独立Image,它不会自动跟随原图移动。如果原图所在位置会随布局动态变化,比如商品列表刷新、文字换行、卡片高度变化,倒影的RectTransform需要同步更新。

最简单的同步思路是让原图和镜像Image挂在同一个父节点下,且镜像的锚点设置为父节点底部。这样可以保证只要父节点位置稳定,镜像就能跟着走。如果父节点本身也会移动缩放,就需要在LayoutRebuild或RectTransform变更事件里重新计算镜像位置。

我自己踩过的一个坑是,只设置了镜像的anchoredPosition,忘了改Pivot,导致镜像的顶部中心点和对齐点不在一个位置,倒影总是有一段错位的透明区域。后来把Pivot设为(0.5, 1),再配合sizeDelta设定高度,基本就没有错位问题了。

4.4 RaycastTarget挡点击和渲染顺序混乱

倒影Image默认会参与射线检测,这个隐藏的透明区域会挡住后面的按钮。不光是倒影,很多半透明效果图都有这个问题。新建镜像Image后第一件事就是把raycastTarget关闭。

渲染顺序也要梳理。Canvas的UI元素默认按层级从上到下渲染,兄弟节点中,层级靠后者会绘制在上层。倒影通常应该被绘制在原始图片之后、但不影响其它背景元素,所以把镜像Image放在原图的下一个兄弟节点位置即可。如果还出现倒影盖住主图的现象,先检查是不是层级被放在了原图前面,或者ZTest被错误改成了LessEqual。

5. 性能账本:额外DrawCall与Canvas重建的权衡

5.1 究竟增加了多少渲染成本

增加一个镜像Image,本质上就是增加一个Quad。从DrawCall角度看,镜像Image的材质和普通UI/Default材质不同,无法与背景元素合并批次,通常会额外产生一个DrawCall。如果场景里只有几个倒影,这个开销完全可以忽略。

真正需要警惕的是Canvas重建。UGUI中RectTransform的任何尺寸、旋转、缩放变化,都会让Canvas标记为dirty,然后在下一次渲染时触发顶点重建和批次合并。如果倒影参数不变化还好,受UI布局动画影响时,多出来的这个镜像Image等于把整棵Canvas的重建成本也往上推了一点。

我的经验是:静态倒影随便用,动态倒影控制在几十个以内也问题不大,但如果倒影图片数量非常多,比如长列表中几百个卡片都有倒影,就需要考虑合并方案。

5.2 单Mesh合并:倒影面积与原图合成一个Quad

如果追求极限性能,可以让一个自定义MaskableGraphic在OnPopulateMesh阶段同时生成原图和倒影两组顶点,这样整个倒影效果只占用一个Graphic、一个Mesh、一个DrawCall。

核心思路是,第一个四边形按正常UV生成,颜色保持不变;第二个四边形放在原图下方,顶点UV的y方向取反,顶点颜色Alpha按渐变曲线赋值。顶点颜色自带的透明度会被最终混合到像素颜色里,不需要Shader做额外计算。参考代码结构:

protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); // 原图四个顶点 UIVertex v = UIVertex.simpleVert; v.uv0 = new Vector2(0, 0); ... // 倒影四个顶点 v.uv0 = new Vector2(0, 1); // 顶点颜色 alpha 按渐变计算 }

这个方法省DrawCall,但代码复杂度明显更高,而且镜像部分无法独立调节Material参数,参数变化时必须通过代码去改顶点颜色。如果项目对性能极其敏感,推荐用这个方向;如果只是想快速落地一个视觉不错的效果,第一版用Shader方案就够了。

5.3 材质的实例数量要控制

UGUI里给多个Image赋同一个Material,只要参数一致,它们可以合批。但倒影Shader有_FadePower、_FadeOffset这类参数,如果每个倒影都新建一份Material,不仅内存增加,更重要的是批次被彻底打散。

能共享参数就尽量共享,比如整个界面统一用一套倒影参数。需要个别微调时,也尽量只调_FadeAlpha这种“数值变化但不影响关键字状态”的参数,避免动态开启关键字触发Shader变体切换。真遇到必须个性化参数的情况,再考虑复制Material,但要清楚它会破坏合批。

6. 思路延伸:倒影Shader还能再玩出什么花样

6.1 给倒影加一段模糊效果

基础Shader里只用一次tex2D采样。模糊倒影其实很常见,做法也很简单:在片段着色器里对垂直方向相邻的多个UV进行采样,加权平均后再输出。比如以当前UV为中心,采样上下平移几个像素的坐标,再将结果混合。

需要注意模糊范围和屏幕分辨率的关系。UI图片在不同Canvas缩放下,物理像素和逻辑像素的比例不同,直接固定偏移量可能在手机上表现不一致。更稳妥的做法是把倒影图片的高度换算成目标纹理的归一化偏移,或者干脆用一张低分辨率的模糊纹理作为倒影专用图,配合Shader翻转。

6.2 让倒影带一点水面波纹

水面波纹的核心是修改倒影采样时的UV,让它沿着水平方向错位。可以引入一个简单噪声函数,用时间_Time.y驱动,对uv.x添加微小偏移,模拟水面扰动。

代价是Shader片段着色器里至少要额外做一次噪声计算,对移动端UI会有一定开销,所以这种效果适合局部使用,不适合所有倒影统一开启。

6.3 渐变不再只靠Alpha,还能混入背景色

默认倒影是“原图翻转+透明度降低”,但在某些深色背景的界面里,倒影底层还应该透出背景色,让整体更协调。可以在Shader里增加一个_TintBottom颜色,让倒影底部颜色向背景色靠拢。实际做法是在渐变计算后,将color.rgb按渐变比例向_TintBottom差值混合。这一改动对美术调UI质感很有用,却也容易被忽略。

一些项目里沉淀下来的实用建议

如果你第一次在UGUI里尝试这类型效果,建议按下面的顺序走一遍完整链路:先做一张静态测试图,用普通Image方式确认Shader的翻转和渐变都符合预期,再把它挂到真实UI布局上测试Mask、点击事件和性能,最后再做动态尺寸和批量复用的优化。

我自己在几个版本里反复调整后,最终会绕回同一个结论:Shader方案只负责渲染层面,布局和层级还是要靠UI框架解决。倒影的RectTransform锚点、Pivot和位置如果不在一开始定对,后面所有微调都会在错误的基础上放大问题。另一个小技巧是,在镜像Image的Inspector里把图片类型保持Simple,同时关闭Raycast Target,这两个设置一个保证视觉正确,一个避免交互事故,几乎适用于所有倒影场景。

最后提醒一点,Shader文件里的关键字变体(_FLIP_UV、UNITY_UI_CLIP_RECT)要配合勾选状态和实际使用的材质一起检查。改错关键字、或者某些变体没被编译进包,倒影在编辑器里正常,发布到真机后却可能变成一团黑。遇到这种情况,优先打开帧调试器逐Pass检查,比盲目调参数高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询