1. 项目概述为什么我们需要一个高性能的毛玻璃弹窗在Cocos Creator 3.8.x的项目里UI弹窗几乎是每个游戏或应用都绕不开的组件。一个普通的半透明遮罩弹窗视觉上已经有些单调了。你有没有想过当弹窗弹出时背后的游戏场景能像被水浸润的玻璃一样呈现出一种朦胧、柔和、富有质感的模糊效果这就是毛玻璃效果或者更专业地说高斯模糊效果。这个效果听起来很酷但在移动端尤其是性能敏感的游戏里它是个不折不扣的“性能刺客”。直接对全屏进行高斯模糊计算量巨大掉帧、卡顿几乎是必然的。所以这个项目的核心挑战远不止是写一个Shader那么简单。它是一场在视觉表现和运行性能之间寻找最佳平衡点的实战。我们需要从Shader底层原理出发设计一套完整的、可复用的、且对性能影响可控的毛玻璃弹窗方案。这涉及到渲染管线理解、RenderTexture的运用、降采样策略、以及Cocos Creator引擎的UI合批机制等多个层面。接下来我会带你从零开始拆解这个效果的每一个技术环节分享我在实际项目中踩过的坑和优化心得目标是让你不仅能做出这个效果更能理解背后的“为什么”从而灵活应用到自己的项目中去。2. 核心思路与方案设计不走弯路的顶层规划在动手写代码之前我们先得把整个方案的设计思路理清楚。一个错误的起点会让后续所有优化都事倍功半。2.1 效果的本质与实现路径选择毛玻璃效果的视觉核心是背景模糊。在Cocos Creator里实现背景模糊主要有三条路后处理Post-Processing全局模糊这是最“正宗”但也是最重的方式。通过后处理管线对相机渲染的整个画面进行高斯模糊。它效果完美但代价是每一帧都要对全屏分辨率进行模糊计算性能开销极大在移动端基本不可行。UI Widget组件局部模糊利用Cocos的cc.Widget组件让一个子节点跟随背景节点然后对这个子节点渲染的内容进行模糊。这种方式更灵活但需要处理好节点层级和渲染顺序实现起来相对复杂且容易破坏UI的合批。RenderTexture渲染纹理方案这是我们本次实战选择的路径。其核心思想是只模糊我们需要的那一部分背景。步骤一在弹窗打开前用另一个相机我们称之为“背景相机”将弹窗区域背后的场景内容渲染到一张RenderTexture上。步骤二将这张已经包含了背景画面的RenderTexture作为一张普通图片赋值给弹窗的背景遮罩Sprite。步骤三在这张Sprite使用的材质上应用我们编写的高斯模糊Shader。这个方案的巨大优势在于我们模糊的对象是一张静态的纹理图片而不是动态的每帧画面。这意味着模糊计算只需要在弹窗打开时执行一次或有限的几次后续只要弹窗背后的场景没有剧烈变化就可以直接复用这个模糊结果性能开销骤降。2.2 系统架构与组件职责基于RenderTexture方案我们设计一个简单的架构BlurBackgroundCamera背景捕捉相机一个独立的Camera组件其clearFlags设为DONT_CLEAR或SOLID_COLOR且Alpha为0只渲染我们关心的背景层如DEFAULT层。它的视口viewport和渲染目标targetTexture需要动态计算以匹配弹窗的屏幕位置和大小。BlurRenderTexture渲染纹理由引擎动态创建的一张RenderTexture作为背景相机的渲染目标。其尺寸是关键优化点通常远小于屏幕分辨率。BlurMaterial模糊材质一个自定义材质使用我们编写的高斯模糊Shader。它将被赋给弹窗的背景遮罩Sprite。BlurDialogComponent弹窗控制组件附着在弹窗预制体根节点上负责协调整个流程在onEnable时计算区域、创建/更新RenderTexture、启动背景相机渲染、将纹理赋给遮罩Sprite。这个设计将功能模块化职责清晰便于维护和性能调优。3. 核心环节一高斯模糊Shader的编写与解析Shader是整个效果的心脏。我们不是简单地调用一个模糊函数而是要理解每一步运算的意义。3.1 高斯模糊的原理与采样高斯模糊的数学本质是卷积。对于纹理上的每一个像素取其周围一定范围内这个范围叫“核”Kernel的多个像素按照高斯函数计算出的权重进行加权平均得到一个新的颜色值。核越大模糊程度越高。在Shader中我们通常采用两次一维高斯模糊来近似二维模糊这能将采样次数从N×N减少到2×N是巨大的性能优化。例如一个7×7的核二维需要采样49次而两次一维水平垂直只需要7714次。// 一段简化的片段着色器代码展示水平方向模糊的核心逻辑 CCProgram fs %{ precision highp float; #include cc-global #include cc-local in vec2 v_uv; uniform sampler2D mainTexture; // 高斯核的权重例如对于核半径3共7个采样点权重是预先计算好的 const float weight[7] float[7](0.006, 0.061, 0.242, 0.383, 0.242, 0.061, 0.006); uniform float u_blurRadius; // 控制模糊强度的参数影响采样步长 vec4 blurHorizontal() { vec2 texelSize vec2(1.0) / vec2(textureSize(mainTexture, 0)); // 获取一个像素的UV大小 vec4 result vec4(0.0); float totalWeight 0.0; // 从中心点向左、右各采样3次 for (int i -3; i 3; i) { float currentWeight weight[i 3]; vec2 offset vec2(float(i) * u_blurRadius * texelSize.x, 0.0); result texture(mainTexture, v_uv offset) * currentWeight; totalWeight currentWeight; } // 归一化确保总权重为1避免颜色变亮或变暗 return result / totalWeight; } void main () { vec4 color blurHorizontal(); gl_FragColor vec4(color.rgb, 1.0); } }%注意上面的代码只是一个方向的示例。一个完整的Shader需要定义两个Pass第一个Pass进行水平模糊输出到一张中间纹理第二个Pass读取中间纹理进行垂直模糊输出最终结果。在Cocos Creator的Effect框架中这需要通过renderPasses来配置。3.2 在Cocos Creator中组织Effect文件Cocos Creator 3.x使用基于YAML和GLSL的Effect系统。我们需要创建一个.effect文件来组织我们的Shader。# BlurUI.effect name: builtin-2d-blur techniques: - passes: - vert: vs frag: blur-horizontal # 水平模糊Pass properties: props mainTexture: { value: white } u_blurRadius: { value: 0.005 } - vert: vs frag: blur-vertical # 垂直模糊Pass properties: *props blendState: targets: - blend: true blendSrc: src_alpha blendDst: one_minus_src_alpha blendSrcAlpha: src_alpha blendDstAlpha: one_minus_src_alpha然后在对应的.chunk文件如blur-horizontal.chunk中编写上面展示的片段着色器代码。blur-vertical.chunk的逻辑类似只是偏移方向改为vec2(0.0, float(i) * u_blurRadius * texelSize.y)。实操心得一权重预计算与核大小在实际项目中我通常不会在Shader里硬编码权重。我会在Cocos Creator的材质属性面板上暴露一个Blur Intensity模糊强度参数然后在组件的TS脚本中根据这个强度动态计算出一个合适的核半径如3, 5, 7并预先计算出高斯权重数组通过Uniform传递给Shader。这样更灵活也便于美术调整。但要注意动态计算权重在CPU端进行每帧更新会有开销适合在初始化或参数变更时计算。4. 核心环节二动态RenderTexture的创建与背景捕捉有了Shader下一步就是获取要模糊的背景内容。4.1 精准计算捕捉区域弹窗的位置和大小可能千变万化我们的背景相机必须精准地对准它。关键在于将UI节点弹窗根节点的世界坐标系包围盒AABB转换到背景相机的视锥体内。// 在 BlurDialogComponent 中 import { _decorator, Component, Node, Camera, RenderTexture, view, Rect, Size } from cc; const { ccclass, property } _decorator; ccclass(BlurDialogComponent) export class BlurDialogComponent extends Component { property(Camera) public bgCamera: Camera | null null; // 用于捕捉背景的相机 private _renderTexture: RenderTexture | null null; onEnable() { this._captureBackground(); } private _captureBackground() { if (!this.bgCamera) return; // 1. 计算弹窗在屏幕上的矩形区域像素坐标 const uiTransform this.node.uiTransform; const worldRect uiTransform.getBoundingBoxToWorld(); const screenRect new Rect(); // 这里需要将世界坐标转换到屏幕坐标Cocos提供了相关方法 // 为简化示例假设我们已经得到了屏幕区域的Rect: screenRect // 2. 根据屏幕区域决定RenderTexture的尺寸这是性能关键 // 我们不需要原屏分辨率通常取区域的1/2或1/4即可模糊本身会损失细节 const downSampleFactor 2; // 降采样系数 const rtWidth Math.floor(screenRect.width / downSampleFactor); const rtHeight Math.floor(screenRect.height / downSampleFactor); // 3. 创建或更新RenderTexture if (!this._renderTexture || this._renderTexture.width ! rtWidth || this._renderTexture.height ! rtHeight) { if (this._renderTexture) { this._renderTexture.destroy(); } this._renderTexture new RenderTexture(); this._renderTexture.reset({ width: rtWidth, height: rtHeight }); this.bgCamera.targetTexture this._renderTexture; } // 4. 调整背景相机的视口和投影矩阵使其只渲染screenRect区域 // 这需要一些矩阵计算核心是设置相机的orthoHeight或projectionMatrix // 以及camera.rect (Viewport) const viewport new Rect( screenRect.x / view.getVisibleSize().width, screenRect.y / view.getVisibleSize().height, screenRect.width / view.getVisibleSize().width, screenRect.height / view.getVisibleSize().height ); this.bgCamera.rect viewport; // 5. 手动渲染一帧如果相机不是常开的话 // this.bgCamera.render(); // 更常见的做法是让这个背景相机常开但只在我们需要时弹窗打开才设置targetTexture并启用。 // 6. 将RenderTexture赋值给弹窗背景Sprite的SpriteFrame const blurSprite this.node.getChildByName(BlurBackground)?.getComponent(Sprite); if (blurSprite this._renderTexture) { const spf new SpriteFrame(); spf.texture this._renderTexture; blurSprite.spriteFrame spf; // 确保Sprite的尺寸模式设置为CUSTOM并设置size为弹窗大小 blurSprite.type Sprite.Type.SIMPLE; blurSprite.sizeMode Sprite.SizeMode.CUSTOM; blurSprite.node.setContentSize(screenRect.width, screenRect.height); } } }4.2 性能生命线降采样与纹理尺寸管理上面代码中的downSampleFactor是第一个性能阀门。RenderTexture的尺寸直接决定了模糊Shader需要处理的像素数量。将尺寸设为屏幕区域的1/2像素数减少到1/4设为1/4像素数减少到1/16模糊效果对低频信息大体颜色和形状敏感对高频细节锐利边缘不敏感因此适度降采样在视觉上损失很小却能换来巨大的性能提升。实操心得二动态分辨率策略在高端机上我们可以用downSampleFactor 1全分辨率获得最佳效果在低端机上可以动态调整为2或4。可以根据设备的cc.sys.platform和cc.sys.graphicsCapacity来做一个简单的分级配置。同时一定要做好RenderTexture的缓存和复用。对于相同尺寸的弹窗不要每次都创建新的RenderTexture应该使用一个对象池进行管理。5. 核心环节三与UI系统的整合与性能优化效果做出来只是第一步让它能流畅地跑在真实的项目里才是真正的挑战。5.1 避免破坏UI合批Cocos Creator的UI系统有一个重要的性能优化合批Batching。将多个使用相同材质和纹理的UI元素合并成一个Draw Call绘制调用。一旦我们给弹窗背景使用了一个自定义的、动态纹理的材质它几乎肯定会打断合批导致Draw Call增加。优化策略隔离模糊层确保只有弹窗的背景遮罩使用这个模糊材质。弹窗内的其他UI元素按钮、文字、图标尽量使用标准UI材质并且保持纹理在同一张图集Auto Atlas中让它们自己能够合批。合并静态模糊区域如果游戏中有多个样式相同、大小固定的弹窗可以考虑预先烘焙bake好一张模糊纹理作为普通的SpriteFrame使用而不是每个弹窗都动态生成。这完全避免了运行时模糊计算和动态纹理的创建。控制更新频率除非背景场景发生剧烈变化如场景切换、主要角色移动否则不要每帧都更新RenderTexture。可以在弹窗打开时渲染一次之后就不再更新。对于需要动态模糊的背景如背后有动画也需要限制更新频率比如每3帧更新一次。5.2 内存与GPU资源管理RenderTexture和自定义材质都是GPU资源管理不当会导致内存泄漏。及时销毁在弹窗关闭时onDisable或onDestroy必须销毁创建的RenderTexturethis._renderTexture.destroy()并将背景Sprite的spriteFrame置空或设为默认值。材质实例化每个使用模糊效果的Sprite都应该有独立的材质实例materialInstance而不是共享同一个材质。因为每个弹窗的u_blurRadius、纹理偏移量等参数可能不同。直接修改共享材质会影响到所有使用它的节点。const sprite blurSprite.getComponent(Sprite); sprite.customMaterial sprite.materialInstance; // 创建并使用材质实例 const matInst sprite.getMaterial(0); matInst.setProperty(u_blurRadius, this.blurIntensity); // 设置实例独有的属性5.3 多分辨率与适配处理游戏需要适配不同屏幕比例。我们的捕捉区域计算必须考虑Canvas的适配模式如FIT_WIDTH,FIT_HEIGHT等。view.getVisibleSize()和view.getDesignResolutionSize()的配合使用是关键。计算屏幕坐标时要使用Camera的screenToWorld或worldToScreen方法而不是简单的手动换算这样才能保证在不同分辨率下模糊区域都能准确覆盖弹窗背景。6. 常见问题、调试技巧与进阶优化即使按照流程操作你也可能会遇到一些“坑”。这里记录了几个典型问题和我的解决方法。6.1 问题排查清单问题现象可能原因排查步骤与解决方案弹窗背景全黑或全白1. RenderTexture创建失败或未赋值。2. 背景相机未正确渲染到纹理。3. 相机的视口Viewport计算错误没拍到内容。4. 背景相机的渲染层级Visibility设置不对没包含背景节点。1. 检查RenderTexture的width/height是否大于0。2. 在编辑器中临时将RenderTexture拖到一个RawAsset上保存为图片查看其内容。3. 开启背景相机的Frustum可视框调试看是否覆盖了目标区域。4. 检查相机和背景节点的Layer设置是否匹配。模糊效果边缘有硬边或错位1. 模糊Shader的采样核半径u_blurRadius太小或RenderTexture尺寸与Sprite尺寸不匹配。2. 背景Sprite的锚点Anchor或尺寸ContentSize设置错误未能完全覆盖弹窗区域。3. 高斯模糊在纹理边缘采样时越界边缘处理模式不对。1. 适当增大u_blurRadius。确保Sprite的SizeMode为CUSTOM且尺寸等于弹窗区域像素大小。2. 将背景Sprite的锚点设为(0,0)位置设为(0,0)使其与弹窗左上角对齐。3. 在Shader中将纹理的采样模式wrapMode设置为CLAMP_TO_EDGE或对越界的UV进行钳制clamp处理。性能开销依然很大1. RenderTexture尺寸过大。2. 模糊Shader的采样次数核半径过多。3. 每帧都在更新RenderTexture。4. 多个模糊弹窗同时打开Draw Call激增。1. 增加降采样系数如从2调到4。2. 降低模糊质量使用核半径5代替7或尝试更快的模糊算法如方框模糊。3. 改为仅在需要时更新弹窗打开、背景变化时。4. 考虑是否真的需要同时打开多个毛玻璃弹窗或对非焦点弹窗降低模糊质量/关闭效果。在Web平台尤其微信小游戏上效果异常或崩溃1. WebGL对RenderTexture的数量或尺寸有限制。2. 频繁创建销毁RenderTexture导致内存碎片或泄漏。3. 某些低端设备不支持浮点纹理如果用了。1. 严格管理RenderTexture的生命周期使用对象池。2. 统一使用RGB或RGBA格式避免使用RGBA16F等浮点格式除非必要。3. 增加平台判断在低端Web平台禁用此效果或使用极低的参数。6.2 进阶优化方向当基本效果满足需求后可以考虑这些进阶优化来提升品质或性能可变模糊强度结合弹窗的动画如缩放、淡入动态调整u_blurRadius实现模糊效果跟随动画一起变化体验更丝滑。分层模糊景深模拟对背景中不同深度的物体使用不同的模糊强度。这需要更复杂的渲染设置可能需要对场景进行分层渲染。使用Compute Shader如果引擎支持对于需要高性能动态模糊的场景Compute Shader在GPU上的并行计算效率远高于传统的渲染管线模糊。但这依赖于引擎底层的支持。后处理方案的轻量化变体如果项目本身使用了轻量级的后处理管线可以尝试编写一个只对UI层特定矩形区域进行后处理模糊的Pass但这需要修改引擎的渲染流程复杂度较高。最后的个人体会实现一个“好看”的毛玻璃效果Shader编写可能只占30%的功夫剩下的70%全是性能和兼容性上的“攻坚战”。最大的经验就是一定要有数据支撑在目标真机特别是最低配置要求的设备上用性能面板Profiler时刻监控Draw Call、GPU时间、内存变化。任何优化策略都不要想当然以实测数据为准。这个从Shader到性能的全流程走下来你对Cocos Creator的渲染和UI系统的理解一定会加深不少。