Cocos Creator轻量化模糊遮罩实现:性能优化与实战指南

发布时间:2026/7/30 9:04:36
Cocos Creator轻量化模糊遮罩实现:性能优化与实战指南 1. 项目概述为什么我们需要模糊遮罩在游戏和互动应用的开发中视觉效果的“高级感”往往体现在细节里。一个平滑的过渡、一个聚焦的提示或者一个动态的景深效果都能极大地提升用户体验。最近我在一个休闲小游戏项目中就遇到了这样一个需求需要在UI界面上实现一个局部高亮、周围模糊的“聚光灯”效果用于引导玩家点击关键按钮。这个效果的核心就是模糊遮罩。简单来说模糊遮罩技术结合了两种图形处理效果模糊Blur和遮罩Mask。模糊负责营造景深、突出焦点或者创造梦幻、朦胧的氛围而遮罩则用于精确控制模糊效果的应用范围只让画面的特定区域变模糊其他区域保持清晰。在Cocos Creator中尤其是面向性能敏感的小游戏平台直接使用全屏后处理模糊开销巨大因此我们需要一种更轻量、更高效的实现方案。我花了些时间研究和测试了几种方案最终找到了一套在Cocos Creator 2.x和3.x中都能稳定运行且几乎不增加Draw Call的免费实现方法。它不依赖任何付费插件或复杂的后处理管线核心思路是利用渲染纹理和Sprite组件的巧妙组合。下面我就把这次“踩坑”与“填坑”的全过程以及最终成型的轻量化方案分享给你。2. 核心思路拆解从“昂贵”的后处理到“巧妙”的Sprite在深入代码之前我们先搞清楚传统做法的问题所在以及新方案的巧妙之处。2.1 传统后处理方案的瓶颈一提到模糊很多开发者的第一反应是使用后处理Post-Processing。Cocos Creator官方也提供了后处理器的支持通过挂载PostProcess组件并设置模糊材质来实现。这种方法确实强大且灵活但它有几个致命缺点尤其对小游戏不友好性能开销大后处理通常意味着至少增加一次全屏渲染。高斯模糊这类效果每个像素的计算都需要采样周围多个像素计算复杂度是O(n²)。即使是优化过的双线性模糊也会显著增加GPU的负担。Draw Call增加后处理管线本身就会引入额外的Draw Call。灵活性受限实现“局部”模糊需要结合遮罩图这在后处理流程中意味着需要传递额外的纹理和参数增加了Shader的复杂度和Uniform的传递开销。兼容性问题在一些WebGL能力较弱的低端设备或微信小游戏环境下复杂的后处理Shader可能会遇到兼容性问题或直接导致性能崩溃。因此对于大多数UI特效和简单的场景特效我们需要绕开后处理寻找更“原生”的解决方案。2.2 轻量化方案的核心渲染纹理与双层Sprite我们的目标是用最基础的渲染组件来实现效果。核心武器是RenderTexture渲染纹理。核心思路如下创建“模糊层”将需要被模糊的原始内容比如整个游戏画面或某个节点子树渲染到一张RenderTexture上。处理“模糊层”将这张渲染纹理作为一个Sprite的显示内容并给这个Sprite应用一个自定义的模糊材质。这样这个Sprite显示出来的就是模糊后的图像。创建“遮罩层”我们需要一个形状来定义清晰区域。这里使用一个Graphics组件绘制一个圆形或其他形状或者直接使用一张带透明通道的遮罩贴图。这个形状将作为模板。合成最终图像将“清晰的原始内容”和“模糊的Sprite”分层放置然后利用Mask组件或Blend Func混合函数以“遮罩层”为模板让清晰内容只显示在遮罩区域内模糊内容显示在区域外。听起来有点绕我们可以用一个生活化的比喻来理解原始画面是一张清晰的照片。RenderTexture就像一台复印机把这张照片复印了一份。模糊材质就像在复印件上涂抹凡士林让它变得模糊。遮罩形状就是一个镂空的硬纸板。最后我们把模糊的复印件凡士林照片垫在底下上面盖上镂空纸板再从纸板的镂空处把清晰的原照片贴上去。这样从镂空处看到的是原照片清晰其他地方看到的是复印件模糊。这个方案的优势在于性能可控模糊只作用于RenderTexture生成的那张纹理图像而不是整个屏幕。Draw Call优化合理组织节点可以将模糊层和清晰层的Sprite合并到同一个渲染批次中。高灵活性遮罩形状可以动态变化通过Graphics实时绘制轻松实现动态的聚光灯移动、形状变化等效果。3. 分步实现从零搭建动态模糊遮罩接下来我们进入实操环节。我将以Cocos Creator 3.x版本为例进行说明2.x版本的核心思想一致主要在API和节点结构上略有差异。3.1 第一步场景结构与节点准备首先在场景中构建一个清晰的层级结构。假设我们要对整个屏幕内容做局部模糊。创建一个空节点命名为BlurMaskRoot它将作为整个效果的根节点。在BlurMaskRoot下创建两个子节点BlurredSprite用于显示模糊后的背景。MaskContainer用于容纳清晰内容和遮罩形状。在MaskContainer下再创建两个子节点MaskGraphics挂载Graphics组件用于动态绘制遮罩形状如圆形。ClearContent这个节点下可以放置你需要保持清晰的内容。如果是全屏效果这里可以放一个全屏的Sprite颜色设为透明仅用于承载Mask如果是针对某个UI就把你的UI控件放在这里。最终的节点树大致如下Canvas └── BlurMaskRoot (Node) ├── BlurredSprite (Sprite) // 显示模糊背景 └── MaskContainer (Node) ├── MaskGraphics (Graphics) // 绘制遮罩形状 └── ClearContent (Node) // 放置需要清晰显示的内容3.2 第二步生成模糊背景纹理这是最关键的一步。我们需要将当前游戏画面捕获下来处理成模糊纹理并赋给BlurredSprite。3.2.1 创建渲染纹理与摄像机我们不会用主摄像机去渲染而是创建一个专用的、隐藏的摄像机来做这件事。// BlurMaskManager.ts import { _decorator, Component, RenderTexture, Camera, Sprite, SpriteFrame, director } from cc; const { ccclass, property } _decorator; ccclass(BlurMaskManager) export class BlurMaskManager extends Component { // 用于显示模糊图像的Sprite property(Sprite) blurredSprite: Sprite null!; // 渲染纹理的尺寸通常与屏幕尺寸一致或按比例缩小以提升性能 private _renderTexture: RenderTexture null!; private _camera: Camera null!; onLoad() { this._initRenderTexture(); } private _initRenderTexture() { // 1. 创建渲染纹理。为了性能尺寸可以设为屏幕尺寸的一半。 const canvas director.getScene()?.getComponentInChildren(Canvas); const designSize canvas?.designResolution; // 使用屏幕尺寸或设计分辨率这里取设计分辨率宽高的一半 const texWidth Math.floor((designSize?.width || 960) / 2); const texHeight Math.floor((designSize?.height || 640) / 2); this._renderTexture new RenderTexture(); this._renderTexture.reset({ width: texWidth, height: texHeight, }); // 2. 创建一个新的摄像机节点 const cameraNode new Node(BlurCamera); this.node.addChild(cameraNode); this._camera cameraNode.addComponent(Camera); // 3. 配置摄像机只渲染我们需要的层级例如‘DEFAULT’并且输出到渲染纹理 this._camera.camera.clearFlags Camera.ClearFlags.SOLID_COLOR; this._camera.camera.clearColor new Color(0, 0, 0, 0); // 透明背景 this._camera.camera.projection Camera.ProjectionType.ORTHO; this._camera.camera.orthoHeight designSize?.height / 2 || 320; this._camera.camera.priority -1; // 优先级低于主摄像机确保先渲染场景再被此摄像机捕获 this._camera.camera.visibility CAMERA_VISIBILITY_FLAG ~(1 0); // 假设只渲染默认层具体按需设置 this._camera.camera.targetTexture this._renderTexture; // 关键输出到渲染纹理 cameraNode.active false; // 默认不激活需要时再开启 } }3.2.2 实现模糊Shader材质Cocos Creator内置没有简单的模糊材质我们需要自己写一个。这里提供一个高效的9-tap高斯模糊片段着色器Fragment Shader示例。你可以将其保存为blur.effect文件。// blur.effect CCEffect %{ techniques: - passes: - vert: vs frag: fs blendState: targets: - blend: true rasterizerState: cullMode: none properties: texture: { value: white } resolution: { value: [1024.0, 1024.0] } blurRadius: { value: 5.0 } }% CCProgram vs %{ precision highp float; #include cc-global #include cc-local in vec3 a_position; in vec2 a_texCoord; out vec2 v_uv; void main () { gl_Position vec4(a_position, 1); v_uv a_texCoord; } }% CCProgram fs %{ precision highp float; in vec2 v_uv; uniform sampler2D texture; uniform vec2 resolution; uniform float blurRadius; void main () { vec2 texelSize vec2(1.0) / resolution; vec4 sum vec4(0.0); // 9-tap 高斯模糊核 (近似) sum texture2D(texture, v_uv vec2(-1.0, -1.0) * texelSize * blurRadius) * 0.077847; sum texture2D(texture, v_uv vec2( 0.0, -1.0) * texelSize * blurRadius) * 0.123317; sum texture2D(texture, v_uv vec2( 1.0, -1.0) * texelSize * blurRadius) * 0.077847; sum texture2D(texture, v_uv vec2(-1.0, 0.0) * texelSize * blurRadius) * 0.123317; sum texture2D(texture, v_uv) * 0.195346; // 中心点权重最高 sum texture2D(texture, v_uv vec2( 1.0, 0.0) * texelSize * blurRadius) * 0.123317; sum texture2D(texture, v_uv vec2(-1.0, 1.0) * texelSize * blurRadius) * 0.077847; sum texture2D(texture, v_uv vec2( 0.0, 1.0) * texelSize * blurRadius) * 0.123317; sum texture2D(texture, v_uv vec2( 1.0, 1.0) * texelSize * blurRadius) * 0.077847; gl_FragColor sum; } }%注意这是一个简化版的高斯模糊blurRadius控制了采样距离值越大越模糊。resolution需要传入渲染纹理的实际尺寸。在实际项目中为了更好的性能和效果通常会使用双线性Bilateral模糊或可分高斯模糊两个Pass一个水平一个垂直后者性能更好。这里为了示例清晰使用了单Pass的9-tap模糊。在TypeScript中我们创建并使用这个材质// BlurMaskManager.ts (续) import { Material, gfx } from cc; private _blurMaterial: Material null!; private async _createBlurMaterial() { // 动态加载创建好的effect资源 const effect await new PromiseEffectAsset((resolve, reject) { resources.load(effects/blur, EffectAsset, (err, asset) { if (err) reject(err); else resolve(asset); }); }); this._blurMaterial new Material(); this._blurMaterial.initialize({ effectAsset: effect }); // 设置材质参数 const texSize this._renderTexture.width; this._blurMaterial.setProperty(resolution, new Vec2(texSize, texSize)); this._blurMaterial.setProperty(blurRadius, 3.0); // 模糊半径可调 } // 在onLoad中调用 onLoad() { this._initRenderTexture(); this._createBlurMaterial().then(() { // 将材质赋给blurredSprite this.blurredSprite.customMaterial this._blurMaterial; // 将渲染纹理创建为SpriteFrame并赋给Sprite const spf new SpriteFrame(); spf.texture this._renderTexture; this.blurredSprite.spriteFrame spf; }); }3.2.3 捕获画面并更新模糊纹理我们需要在合适的时机比如每帧或当遮罩移动时激活隐藏的摄像机捕获一帧画面然后将其作为模糊Sprite的纹理。// BlurMaskManager.ts (续) update() { // 可以根据性能需求不一定每帧都更新。例如只在遮罩移动时更新。 if (this.needUpdateBlur) { this._captureAndBlur(); this.needUpdateBlur false; } } private _captureAndBlur() { if (!this._camera || !this._camera.node) return; // 临时激活摄像机渲染一帧到RenderTexture this._camera.node.active true; // 强制摄像机渲染 director.getScene()?.renderScene.updateCamera(this._camera); this._camera.render(); this._camera.node.active false; // 渲染完立即关闭节省性能 // 此时this._renderTexture 已经包含了当前场景的截图 // 因为blurredSprite的材质已经设置并且纹理关联了this._renderTexture // 所以截图会自动经过模糊Shader处理并显示出来。 }3.3 第三步实现动态遮罩现在我们有了一张全屏的模糊背景。接下来我们需要在上面“挖”出一个清晰的洞。这里有两种主流方法方法A使用Mask组件简单但有限制Cocos Creator的Mask组件允许根据一个节点的形状或图片的Alpha通道来裁剪子节点。我们可以将MaskGraphics绘制了一个实心圆形的Graphics组件作为Mask。给MaskContainer节点添加Mask组件设置Type为GRAPHICS_STENCIL。在MaskGraphics节点的Graphics组件中动态绘制一个圆形。将需要清晰显示的内容ClearContent放在MaskContainer下。这样ClearContent就只会显示在MaskGraphics绘制的圆形区域内。而BlurredSprite节点在MaskContainer之外会完整显示从而形成“模糊背景上有一个清晰圆洞”的效果。// MaskController.ts import { _decorator, Component, Graphics, UITransform } from cc; const { ccclass, property } _decorator; ccclass(MaskController) export class MaskController extends Component { property(Graphics) maskGraphics: Graphics null!; property maskRadius: number 100; start() { this.drawMaskCircle(new Vec2(0, 0)); // 初始位置在中心 } drawMaskCircle(centerPos: Vec2) { const g this.maskGraphics; g.clear(); g.fillColor.fromHEX(#ffffff); // Mask需要非透明颜色 g.circle(centerPos.x, centerPos.y, this.maskRadius); g.fill(); } // 每帧或通过事件更新遮罩位置 updateMaskPosition(worldPos: Vec2) { const localPos this.node.getComponent(UITransform)!.convertToNodeSpaceAR(worldPos); this.drawMaskCircle(localPos); // 通知BlurMaskManager需要更新模糊纹理如果背景是静态的则不需要每帧更新 // this.getComponent(BlurMaskManager).needUpdateBlur true; } }注意使用GRAPHICS_STENCIL类型的Mask在某些平台如部分小游戏可能有兼容性问题且动态修改Graphics对性能有影响。如果遮罩形状固定更推荐使用方法B。方法B使用混合模式与Alpha遮罩更灵活、性能更优这是更推荐的方法它利用Sprite的混合功能和一张遮罩贴图。准备一张遮罩贴图一张中间透明、边缘白色的圆形渐变图或任何你想要的形状。白色区域代表“显示模糊背景”透明区域代表“显示清晰内容”。将这张贴图作为一个SpriteMaskSprite的显示资源并放置在BlurredSprite和ClearContent之间。设置MaskSprite的混合模式Blend Factor。我们需要实现的效果是用它的Alpha值来混合BlurredSprite下层和ClearContent上层。实际上Cocos Creator的渲染顺序是固定的。我们可以换一种思路让ClearContent节点完全覆盖屏幕但它的透明度由MaskSprite的Alpha值反向控制。更常见的实现是将遮罩纹理作为参数传入一个自定义的合成Shader。这个Shader同时采样“模糊纹理”、“清晰纹理”和“遮罩纹理”根据遮罩纹理的Alpha值混合前两者。由于方法B涉及更复杂的Shader编写这里给出一个概念性的混合Shader思路// combine.effect 片段着色器部分 uniform sampler2D blurTex; uniform sampler2D clearTex; uniform sampler2D maskTex; in vec2 v_uv; void main() { vec4 blurColor texture2D(blurTex, v_uv); vec4 clearColor texture2D(clearTex, v_uv); float maskAlpha texture2D(maskTex, v_uv).a; // 假设遮罩信息在Alpha通道 // 简单线性混合maskAlpha为1时完全显示模糊图为0时完全显示清晰图 // 但通常我们需要的是反过来的遮罩中心透明显示清晰边缘不透明显示模糊 float factor 1.0 - maskAlpha; // 反转 vec4 finalColor mix(clearColor, blurColor, factor); finalColor.a 1.0; // 确保不透明 gl_FragColor finalColor; }然后创建一个使用此材质的Sprite它需要三张纹理输入。blurTex来自我们的RenderTextureclearTex需要另一张捕获清晰画面的RenderTexture或直接使用场景的最终渲染结果这又回到了后处理思路但更可控maskTex是我们的渐变贴图。实操心得对于大多数动态效果如跟随手指的聚光灯方法A动态Graphics Mask在开发速度和效果上已经足够除非你对性能有极端要求或者遮罩形状非常复杂。如果选择方法A务必注意在移动遮罩时不要每帧都clear()和重绘整个Graphics可以尝试只更新其节点的位置如果形状不变的话。3.4 第四步整合与优化将以上步骤整合到BlurMaskManager中并添加控制接口。// BlurMaskManager.ts (完整控制类示例) ccclass(BlurMaskManager) export class BlurMaskManager extends Component { property(Sprite) blurredSprite: Sprite null!; property(Node) maskContainer: Node null!; // 包含Mask和清晰内容的容器 private _renderTexture: RenderTexture null!; private _camera: Camera null!; private _blurMaterial: Material null!; private _isInitialized false; // 控制参数 property blurUpdateFrequency: number 0.1; // 更新模糊纹理的频率秒0表示每帧更新 private _updateTimer: number 0; async onLoad() { await this._initialize(); } private async _initialize() { await this._initRenderTexture(); await this._createBlurMaterial(); this._isInitialized true; this.scheduleOnce(() this._captureAndBlur(), 0.1); // 延迟一帧首次捕获 } update(deltaTime: number) { if (!this._isInitialized) return; this._updateTimer deltaTime; // 按频率更新模糊背景避免每帧更新消耗过高 if (this.blurUpdateFrequency 0 this._updateTimer this.blurUpdateFrequency) { this._updateTimer 0; this._captureAndBlur(); } else if (this.blurUpdateFrequency 0) { // 每帧更新用于需要极高动态效果的场景慎用 this._captureAndBlur(); } } // 外部调用当需要强制更新模糊背景时例如场景大幅变化后 public refreshBlurBackground() { if (this._isInitialized) { this._captureAndBlur(); } } // 外部调用显示/隐藏整个模糊遮罩效果 public setEffectActive(active: boolean) { this.node.active active; if (active this._isInitialized) { this._captureAndBlur(); // 显示时立即更新一次 } } }4. 性能优化与实战避坑指南实现功能只是第一步要让它在真机上特别是小游戏环境中流畅运行还需要大量优化。4.1 性能优化关键点降低渲染纹理分辨率这是最有效的优化手段。全屏的RenderTexture比如1920x1080对内存和带宽消耗巨大。将其尺寸减半960x540或降到四分之一模糊后视觉差异不大但性能提升显著。在_initRenderTexture中我们已经做了这一点。降低模糊采样率减少Shader中的采样次数tap。将9-tap模糊改为更简单的4-tap或使用更高效的双线性模糊算法。也可以分两个Pass水平垂直每个Pass只用3-tap效果接近但采样次数更少。控制更新频率不要每帧都更新模糊纹理。如果背景是静态的只在遮罩移动时更新。如果背景动态变化不剧烈可以每0.1秒10fps甚至更慢更新一次。通过blurUpdateFrequency参数控制。使用静态遮罩贴图如果遮罩形状是固定的比如一个固定的圆形提示框强烈建议使用方法B并预先生成一张遮罩贴图。这完全避免了运行时动态绘制Graphics的开销。合并渲染批次确保BlurredSprite和MaskContainer下的UI元素在同一个渲染批次中。检查它们的材质是否相同层级Layer是否一致避免因为渲染状态切换导致Draw Call增加。适时禁用当模糊遮罩效果不需要时比如在非引导界面一定要将整个BlurMaskRoot节点设为active false这样摄像机不会渲染Shader也不会执行。4.2 常见问题与排查问题1模糊效果有锯齿或颗粒感。原因渲染纹理分辨率太低或者模糊半径blurRadius设置太小采样步长texelSize * blurRadius不够平滑。解决适当提高RenderTexture的分辨率或增大blurRadius。也可以在Shader中对纹理采样使用texture2D的LOD或手动进行双线性插值。问题2遮罩边缘有白边或黑边。原因通常是颜色混合或Alpha混合设置不正确。在使用Graphics绘制Mask时确保填充颜色为不透明的白色#FFFFFFFF。在使用自定义合成Shader时检查混合公式是否正确特别是Alpha通道的处理。解决检查Mask组件的Alpha Threshold属性尝试调整通常设为0。在自定义Shader中确保最终输出的颜色Alpha为1。问题3在部分Android手机或小游戏上效果不显示或异常。原因可能是WebGL扩展支持问题如OES_texture_float或者是Shader语法兼容性问题。解决简化Shader避免使用某些低端设备不支持的GLSL函数或精度修饰符尽量使用mediump。检查Cocos Creator项目设置中的“自定义WebGL上下文属性”确保没有禁用必要的扩展。做好降级方案在效果初始化失败时自动降级为简单的半透明黑色遮罩保证功能可用性。问题4动态更新遮罩位置时性能下降明显。原因每帧都调用Graphics.clear()和重绘或每帧都更新模糊纹理。解决如果遮罩只是移动形状不变不要重绘只需更新MaskGraphics节点的位置setPosition。Mask组件会根据节点位置实时更新裁剪区域。降低模糊纹理的更新频率blurUpdateFrequency。如果必须重绘形状考虑使用cc.Graphics的rect、ellipse等命令式API而不是每次都clear。问题5模糊背景和清晰内容对不齐有偏移。原因用于捕获的摄像机BlurCamera的视口Viewport或正交大小orthoHeight与UI摄像机的设置不匹配。解决确保BlurCamera的orthoHeight与Canvas的设计分辨率高度的一半相匹配因为纹理分辨率减半了。计算关系为orthoHeight designResolution.height / 2。同时检查摄像机节点的位置是否在(0,0,0)确保其坐标系与UI世界坐标系对齐。5. 效果扩展与应用场景掌握了基础实现后这个技术可以衍生出很多酷炫的效果动态聚光灯引导让遮罩圆形跟随手指或鼠标移动实现“哪里不会点哪里”的引导效果。结合updateMaskPosition方法即可。剧情对话聚焦在角色对话时将除对话框和角色外的场景区域模糊营造电影感。自定义形状遮罩将Graphics绘制的圆形改为矩形、圆角矩形甚至贝塞尔曲线构成的复杂形状实现各种创意的聚焦效果。多重模糊强度可以创建多张不同模糊半径的RenderTexture然后根据遮罩纹理的灰度值而不仅是Alpha进行混合实现中心清晰、边缘轻度模糊、最外围重度模糊的平滑过渡效果。这需要更复杂的合成Shader。与UI动画结合在显示/隐藏模糊遮罩时加入淡入淡出、缩放等动画让过渡更自然。最后分享一个我个人的实操心得在移动设备上永远要先考虑性能再追求效果。我最初实现的版本每帧更新全分辨率模糊在低端机上直接导致帧率降到20以下。后来通过将渲染纹理分辨率降到1/4更新频率降到0.15秒一次帧率立刻回到了55而视觉效果的损失在手机小屏幕上几乎不可察觉。这个“降级”决策比任何华丽的Shader技巧都更有价值。这套方案我已经在一个日活百万级的微信小游戏项目中稳定使用经历了各种机型的考验。希望这份详细的拆解和避坑指南能帮助你在自己的Cocos Creator项目中轻松实现既炫酷又轻量的动态模糊遮罩效果。