ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity NGUI挖洞效果实战:纹理遮罩与自定义Shader实现全解析

Unity NGUI挖洞效果实战:纹理遮罩与自定义Shader实现全解析 1. 先搞清楚挖洞到底是在挖什么接到这个需求的时候我正维护一个用了NGUI的老项目。项目版本停留在Unity 5.6NGUI版本3.11.3UI框架已经跑了好几年突然策划提了一嘴主界面的背景图上要在指定坐标挖一个圆形洞洞里透出底下的动态剧情CG。我当时第一反应是这玩意儿听起来像PS里抠图但放到运行时UI里就不是套个遮罩那么简单了。在动手之前我先把挖洞这个概念掰开揉碎。说白了屏幕上产生一个洞本质就是那个位置上的像素不被绘制或者被绘制成了透明让排在它后面的图层内容显示出来。听起来很直白但实现路径其实有三条大的分支透明混合、像素裁剪、模板测试。这三条路各有适用场景选错了轻则DrawCall暴涨重则层级混乱、洞的位置对不上。还有一个很关键的现实约束NGUI和UGUI不一样它没有RectMask2D这种内置的界面裁剪组件。NGUI提供的是UIPanel的Clipping能力但它主要面向矩形裁切你要在“一张图上挖一个任意形状的洞”它并不能直接搞定。所以我们必须把目光转向三个方向做预烘焙遮罩贴图、用NGUI自带的纹理遮罩、写自定义Shader。无论选哪个其实都是在回答一个问题——“洞”以外的区域怎么处理成不可见/遮挡而洞以内的区域怎么保留透明。在分析方案之前我建议所有遇到类似需求的人先做一步需求确认。策划嘴里说的挖洞到底是指什么是仅仅要在背景图上盖一张四周黑色、中间透明的图片还是说洞这一圈要抠掉让洞内真正显示底层3D场景又或者洞要跟着手指滑动、要动态变形这三种需求对应的技术成本差别非常大。最后我项目里实际要的是第二种加第三种也就是洞内显示底层动态内容且洞口位置能由代码控制所以才有了后面整套方案。2. 选型对比三种主流NGUI挖洞路线2.1 路线A美术切图法——最简单但最不灵活最土但最稳的办法是让美术直接在PS里做一张遮挡图整张图是一块带暗色调的蒙版中间需要透出来的位置透明四周是黑色或者半透明暗色用UISprite直接盖在背景图上方。从视觉上看就像是在背景图上挖了一个洞洞外面压暗洞里透亮。这个方案在NGUI环境下实现成本几乎为零不写一行代码不碰Shader美术同学几分钟就能出一张图。而且它有个天然优势——抗锯齿效果极好。洞的边缘可以通过PS的羽化、选区处理好视觉上没有任何锯齿也不需要处理像素密度问题。但缺点同样明显洞的形状和位置基本上写死了。如果策划要动态移动洞口比如引导光圈跟着按钮走那美术就得准备几十张不同位置的图或者你在代码里运行时改UISprite的transform坐标但要保证蒙版和背景图尺寸完全对齐否则洞的位置会偏移。还有如果项目用了图集这张蒙版的边缘很容易串色或出现白边在后端压缩格式为ETC1Alpha时尤其严重。所以我的结论是静态需求、一次性使用、对性能要求极低可以直接用切图方案但只适合临时顶一下。2.2 路线BUIPanel纹理遮罩——NGUI自带的裁剪利器NGUI的UIPanel提供了几种裁剪模式其中有一个容易被忽略的选项Clipping里面的TextureMask。很多用NGUI的人平时只见过None、SoftClip、ConstrainButDontClip这几个选项。TextureMask是NGUI 3.12.x开始加入的它可以让你指定一张遮罩贴图TextureUIPanel会根据这张贴图的Alpha去裁切面板内的所有子节点裁剪区域完全不规则的。相当于NGUI官方给了个裁剪的任意形状模具。这个方案的好处是它能在保留NGUI自身合批机制的前提下实现背景图上挖洞的真实裁剪效果。我只需在Panel下放一个背景图再设置Panel的裁剪模式为TextureMask指定一张带洞的Mask贴图背景图上就会被精确地挖掉对应区域。比起Shader方案它的性能更低因为Panel内部的绘制逻辑完全由NGUI引擎处理不需要额外的DrawCall。而比起美术切图法它又是真正的裁剪不是一层遮挡盖上去画面中洞的区域呈现的是底层内容。不过它有个限制Mask贴图的UV,跟Panel的整个矩形区域是对应的所以你如果想移动洞的位置得去改Mask贴图本身或者想办法移动裁剪区域。对于固定规格的洞TextureMask是性价比很高的均衡方案。2.3 路线C自定义Shader——动态性拉满但要懂底层当你需要洞里透出的不只是UI背景而是实时渲染的3D场景、模型动画甚至洞的形状要不断变化那前两种方案都靠不住了。这时候就只能上自定义Shader自己控制片元的绘制和裁剪。思路其实很直接做一个全屏的UI板子给它一个蒙版纹理在片元着色器里采样蒙版Alpha值达到阈值就丢弃discard当前片元没达到就正常渲染遮挡色。这样蒙版上透明的区域就成了洞其余区域保持遮挡。因为蒙版是运行时可以替换、甚至可以用另一张RenderTexture实时生成的纹理所以洞的位置、形状、数量都能动态控制也可以随时间流动变化。这是三种方案中灵活性最高的但也最考验基本功。你得理解Unlit Shader的编写、NGUI对Shader命名的特殊处理、图集格式对像素采样的影响还要处理好半透明边缘的混合问题稍不留神就会出现洞的边缘发黑或透明区域显示成脏色这类问题。我在项目里最后就是采用了Shader方案所以下文我会花最大篇幅来拆细节。3. 实操基于UIPanel纹理遮罩的挖洞实现3.1 创建Mask贴图与材质如果你只想做静态的矩形、圆形、甚至方角转圆角这种规则的洞用TextureMask是最省事的选择。先把Mask贴图准备好尺寸跟Panel覆盖区域建议一致或者成整数比这样采样更清晰。拿PS建一张512x512的图整张填白色中间画一个黑色圆形这个黑色圆就是将来要挖掉的区域。注意边缘尽量保留半透明过渡不要硬切否则最终呈现的洞边缘会非常生硬像被狗啃了一样。在Unity里将这张图导入纹理类型选SpriteSprite Mode选SingleAlpha Is Transparency勾上压缩格式建议选Truecolor。万一项目里图集太满了非要塞进图集也不是不行但要确保图集格式支持独立Alpha通道否则Mask失效。3.2 搭建NGUI层级结构我在实际项目里是这样搭的UIRoot └── Camera └── Panel (裁切Panel) ├── MaskObject (UISprite显示Mask纹理) ├── BackgroundImage (UISprite要被挖洞的背景图) └── UnderContent (UISprite/UITexture放在底层的内容)注意顺序BackgroundImage放在最上层UnderContent放在最下层MaskObject作为Panel的子节点且它的渲染层级在最上面。然后选中Panel在Inspector里把Clipping改为TextureMask将Mask贴图拖入对应的Mask Texture字段。这样一来Panel内所有子节点的显示都会被这张贴图约束蒙版纹理为白色的区域正常显示黑色区域被裁掉。背景图上被裁掉的位置自然露出了底下的UnderContent。3.3 关键坑点Mask贴图与UI的坐标匹配这里有个非常容易踩的坑——Mask纹理会覆盖整个Panel的矩形区域而不是只作用于某个子物体。所以一旦Mask贴图尺寸和Panel覆盖区域对不上你看到的洞就会偏到诡异的位置去。正确做法是让Panel的baseClipRegion覆盖到你要展示的具体区域然后让Mask贴图的UV和这个区域对齐。如果你只是在一个全屏背景图中间挖个洞那Panel的Clipping区域就设为全屏。NGUI在处理TextureMask时会将该贴图映射到整个clip区域所以Mask贴图背景白、中间黑挖出来的洞就在屏幕正中央。如果要移动洞口位置需要调整的是Mask贴图资源本身或者移动整个Panel的位置。动态性不强但胜在稳定。3.4 实战效果与性能表现我实测过一个2560x1440分辨率的背景用TextureMask裁剪圆形区域整个Panel里的DrawCall没有增加和普通的NGUI面板一致。这一点非常关键——NGUI是一个极度讲究合批的UI框架一旦引入Untiy默认的Mask组件DrawCall会直接翻倍。而TextureMask走NGUI内部的裁剪逻辑不会破坏批处理。实际运行时需要注意Mask贴图虽然理论上可以做成动态的但如果你每帧更换一张不同的RenderTextureNGUI内部需要重新生成DrawCallGC压力会直线上升。所以TextureMask适合的一定是洞的形状和位置相对固定的场景比如签到界面的已领取区域、成就解锁的聚焦光圈。4. 实操自定义Shader实现任意形状挖洞4.1 Shader核心思路拆解动态挖洞我的做法是遮挡面蒙版纹理。先创建一个和背景图同样大小的UISprite或UITexture给它挂上一个自定义材质这个材质使用的Shader会采样一张蒙版纹理。在片元着色器里如果当前UV坐标对应的蒙版Alpha小于某个阈值就执行clip丢弃片元否则就输出一个暗色或者半透明黑色形成遮挡。而那些被丢弃的片元正好对应蒙版上的透明区域视觉上就是洞。这套思路最大的优势不只是动态而是洞内看洞外。因为洞的位置实际上是通过透明的像素露出来的所以洞的后面不管放的是什么——UI面板、3D模型、粒子特效——都能直接透出来。我甚至在一个广告项目里用它做寻宝图效果让地图纹理的挖空区域展现出隐藏的3D宝物模型实现成本极低。4.2 完整Shader代码下面是我在NGUI项目里用过的Shader你可以直接存成.shader文件挂到材质上Shader Custom/NGUI_HoleMask { Properties { _MainTex (Mask Texture, 2D) white {} _Color (Tint Color, Color) (0,0,0,0.8) _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; fixed _Cutoff; 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 { fixed4 mask tex2D(_MainTex, i.uv); clip(mask.a - _Cutoff); return _Color; } ENDCG } } }这段Shader的逻辑很简洁颜色和透明度由_Color决定为主遮罩纹理采样的结果是用来决定是否丢弃片元。参数_Cutoff控制挖洞阈值默认0.5。蒙版纹理Alpha高于0.5的区域会被当成洞低于0.5的区域则被当成遮挡面显示出来。4.3 NGUI接入的正确姿势如果你直接把这个Shader扔给一个UISprite很可能会发现界面变成纯黑色或者完全不显示。原因是NGUI的UIDrawCall在生成Mesh时会使用材质里的Shader决定是否要做顶点色融合它默认shader名字里带有Unlit或UI才会走UI的顶点着色逻辑而且部分版本会根据Shader名判断是否使用字体Shader。正确做法是创建一个MaterialShader选择Custom/NGUI_HoleMask然后把这个材质赋给一个UITexture而不是UISprite。UITexture本身主要用于显示一张独立的纹理它默认的Shader就是Unlit和我们的自定义Shader兼容性更好。接下来我们把UITexture的尺寸调整为背景图大小覆盖在背景图上方即可。如果你项目里的背景是用的UISprite那也不用慌。可以让UITexture作为背景图所在Panel的子物体手动设置它的深度值Depth让它稍微排在前方同时让UITexture的尺寸与背景图UDim对齐即可。4.4 C#脚本运行时控制洞的位置和大小要让洞活起来就得在C#里改蒙版纹理。我用的方案是运行时生成一张Texture2D把洞画上去。最简单的方式是直接在代码里对Texture2D逐像素操作using UnityEngine; public class HoleMaskController : MonoBehaviour { public UITexture maskTextureUI; public int textureSize 256; private Texture2D maskTex; private Color[] pixels; void Start() { maskTex new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false); maskTex.wrapMode TextureWrapMode.Clamp; maskTex.filterMode FilterMode.Bilinear; if (maskTextureUI ! null) { maskTextureUI.mainTexture maskTex; } pixels new Color[textureSize * textureSize]; } public void SetHole(Vector2 center, float radius) { // center和radius都基于纹理像素坐标 for (int y 0; y textureSize; y) { for (int x 0; x textureSize; x) { float dx x - center.x; float dy y - center.y; float dist Mathf.Sqrt(dx * dx dy * dy); // 距离小于半径Alpha为0挖洞否则Alpha为1遮挡 pixels[y * textureSize x] dist radius ? new Color(0, 0, 0, 0) : new Color(0, 0, 0, 1); } } maskTex.SetPixels(pixels); maskTex.Apply(); } }这段代码有个明显的性能隐患逐像素填充Texture2D非常昂贵256x256约6.5万像素每一帧执行一次肯定卡爆。我实际项目里的做法是只在洞的位置或半径变化时重新生成并且把纹理尺寸降到128同时在RT空间里预烘焙位置不在运行时逐像素计算。如果洞的位置平移我用的更优雅方案是把洞画在一个小的鼓励间里再把鼓励间Blit到最终Mask纹理上性能比SetPixels高一个量级。4.5 动态洞的进阶洞跟着手指滑动做新手引导时经常要一个聚光灯跟着手指滑动挖洞位置实时变化。这个场景千万别用逐像素SetPixels。我当时的做法是用一个单独的Camera渲染一个全屏QuadQuad的Shader里直接计算世界坐标和手指位置的圆环距离距离小于半径就把片元Alpha设为0完全不用CPU改纹理。但如果你坚持在NGUI的UI体系内做可以把上面Shader改一下增加_HoleCenter和_HoleRadius两个参数然后在片元着色器里用mul(unity_ObjectToWorld, i.vertex)的坐标去计算到洞中心的距离float2 center _HoleCenter; float dist distance(i.uv * _HoleAreaSize, center); float holeFactor smoothstep(_HoleRadius, _HoleRadius - _EdgeSoftness, dist); clip(holeFactor - 0.001);这样洞的位置由uniform控制每帧更新材质里的_HoleCenter即可性能开销几乎为零。边缘羽化用smoothstep在0.1像素的范围内过渡视觉上比硬边洞柔和很多也不会有明显的锯齿闪烁。5. 踩坑记录与性能优化清单5.1 NGUI版本差异导致的Shader失效我项目里一开始用的NGUI 3.11.3自定义Shader放到UISprite上会直接失效改成UITexture就正常。后来把NGUI升级到3.12.6发现UISprite自定义材质也能正常使用了。翻NGUI源码后发现问题出在UIDrawCall.Create方法里对Shader名称的判断逻辑——它会把一些特定名字的Shader识别为UI Shader然后做特殊处理而老版本对自定义Shader的支持远不如新版本。所以如果你卡在怎么挂材质都不显示这一步先确认你的NGUI版本是否支持最简单的方法是用UIWidget下的material属性强行赋值而不是改sharedMaterial。5.2 图集与TextureMask的兼容性问题使用UIPanel的TextureMask时如果Mask贴图用了项目里的UI图集一定要确认图集导入格式不能压缩成ETC1。ETC1不支持Alpha通道即使勾选了Alpha Split运行时UIShader对它的采样结果也可能出现偏差导致裁剪区域偏移几个像素。我踩过一次很隐蔽的坑Mask贴图放在图集里当时图集格式是ETC2 8bits结果挖洞的边缘出现了一道细黑线。排查了半天问题出在图集Padding因为Mask贴图边缘位置在Atlas内被左右相邻的图样污染了。最终解决方案是Mask贴图永远不放进图集单独使用纹理格式设为RGBA32。NGUI的TextureMask本质是采样一张外部纹理不依赖图集反而更安全。5.3 半透明洞边缘发黑问题用自定义Shader做挖洞时随着动态移动洞口如果蒙版边缘有半透明像素会出现洞边缘发黑或半透明处透出色斑的问题。这是因为Shader里Blend SrcAlpha OneMinusSrcAlpha情况下被clip掉的片元和半透明片元混合方式不同。smoothstep羽化出来0~1的Alpha若_Color的RGB是纯黑那半透明区域显示出来会是半透明的黑叠加到底层地图上会让边缘脏脏的。解决方法是把过渡区的Alpha曲线从线性改成smoothstep3同时让边缘像素偏向更早丢弃而不是偏向更多透明黑色。我实际用下来float threshold 0.5; float alpha mask.a; float edge smoothstep(threshold - 0.1, threshold 0.1, alpha); clip(edge - 0.001);这样边缘只有0和1两种状态视觉上边缘变硬了但配合UI底色反而更干净。如果你真的要柔边洞建议用一个偏移计算把半透明像素的颜色提前采样一张预模糊纹理作为底色不要直接输出黑色。5.4 NGUI的多Panel深度排序挖洞效果在多层Panel里特别容易出问题。因为NGUI的Depth是基于Panel和Widget深度两层概念可能你做了个UITexture遮罩板明明深度设成9999结果还是被背景图压在底下。需要特别注意同一Panel下Widget深度越大越靠前不同Panel间的排序由Panel的depth决定。并且如果Panel里的某个子物体用它自己的MaterialDrawCall会跟Panel主材质分离排序可能错乱。我在项目里为了解决这个问题单独建了一个Panel专门放遮罩UITexture把它的depth设到比背景图所在的Panel高1这样遮罩必定在最上层。而挖洞露出来的底层内容放在背景图Panel的更前一层或者干脆放到另一个相机渲染通过调整两个Panel的次序保证显示结果正确。5.5 移动端纹理压缩与动态生成的Alpha精度动态生成Texture2D挖洞时Unity在移动端的默认纹理格式可能是RGBA Compressed等动态生成的纹理默认格式通常是RGBA32没问题但如果你的RenderTexture纹理格式选错了Alpha通道会被压缩到4bit洞的边缘会出现明显色阶。解决方案是使用RenderTexture时格式选RenderTextureFormat.ARGB32颜色空间根据项目是Linear还是Gamma选择。在较新的Unity版本中如果渲染设定是Linear色彩空间Texture2D的Apply需要加上makeNoLongerReadablefalse否则Shader采样时颜色空间转换不一致Alpha值会被二次换算导致_Cutoff的阈值怎么调都感觉不对。6. 扩展玩法从挖一个洞到多洞、动态洞、流光洞6.1 多洞效果与战争迷雾圈定需求边界之后可以顺手把功能做更通用一点。比如要把一张地图上的多个区域同时挖开我的Shader可以改成循环采样多个圆心和半径。直接跑for循环采样4个洞是个不错的上限再多建议用数组传入。用C#每帧把N个洞的圆心、半径压进Vector4数组片元里依次距离判断正好利用GPU并行性开销几乎可忽略。我做战争迷雾的时候还做过视野边缘柔化效果——对每个像素计算距离最近的洞边缘用边缘距离做渐隐配合速度衰减玩家视角拖动时新探索区域会平滑展开而不是生硬地跳出一块圆形亮区。6.2 倒计时转盘式挖洞这个玩法来自签到7天需求一个圆盘上已经领取的天数区域被挖开未领取区域保持暗色蒙版。这类UI用TextureMask很容易实现——美术出一张扇形蒙版贴图按照日期数量旋转UV即可。但如果你要倒计时动态转圈美术图就不够用了。我采用的办法是Shader里根据角度算出扇形洞float2 uv i.uv - 0.5; float angle atan2(uv.y, uv.x) * 57.3 180.0; // 转成0~360角度 float radius length(uv) * 2.0; float inSector step(angle, _ProgressAngle) * step(_StartAngle, angle); clip((radius - _InnerRadius) * (radius _OuterRadius ? 1 : 0) * inSector - 0.001);这样洞本身是一段环形扇形参数由程序实时传入转过的地方就被挖开了视觉表现极其流畅完全没有美术帧动画的卡顿感。6.3 NGUI中的按钮引导高亮最后分享一个我在新手引导里反复用的配方。引导的目标是一张全屏截图上面要突出某个按钮的位置也就是在按钮周围挖一个洞。以前的做法是做一张全屏半透明暗色遮罩然后动态减去一块矩形区域。在我这个自研Shader方案里连减法都不用直接让遮罩板尺寸铺满全屏用一个四边形的_HoleRect参数表示按钮区域UV在矩形内则丢弃片元否则输出全屏暗色。这样代码量极少洞的位置直接拿按钮的UIWidget.worldCorners换算得到引导点击区域还能顺手绑定在同一个矩形上。这个用法我连续几个项目都在复用稳定性和可维护性都相当好。搞了大半天最后的实操体会是技术上挖洞并不难难的是想清楚洞和底层内容之间的层级关系以及在NGUI这种老框架里避开它自带的脾性。如果你项目只是临时要一个静态的洞老老实实让美术切图别折腾Shader如果要动态跟手、要挖出3D内容、要多洞联动的玩法Shader方案一步到位后面扩展也方便。实际动手前先在空白场景里用UITexture验证一遍Shader和合批情况再迁移到正式业务里能省下大量排查时间。这算是这几年在NGUI上做遮罩效果最值得分享的一条经验了。
返回列表