
1. 项目概述为什么我们需要动态边缘光与高级模糊在Cocos Creator里做2D项目尤其是那些需要点视觉冲击力的卡牌、RPG或者横版动作游戏美术同学经常会提一些“高级”需求。比如想让角色在释放技能、被选中或者进入某种状态时身上能亮起一圈酷炫的光边又或者想让UI弹窗、技能图标有一种朦胧的、带景深的高级模糊感。用传统的帧动画或者序列帧去模拟这些效果不仅资源体积大而且动态调整起来极其麻烦换个颜色、调个强度就得重新出图。这时候自定义Shader就成了我们的“瑞士军刀”。它直接操作GPU进行像素级渲染性能开销可控效果动态可调一次编写随处复用。这次要聊的就是把“动态边缘光”和“高级模糊”这两个高频特效用Cocos Shader给做出来并且是能实打实应用到Sprite上的。我们会从原理开始掰开揉碎一直讲到完整的代码实现和避坑指南目标就是让你看完就能在自己的项目里用起来。2. 核心原理深度拆解光与模糊的GPU魔法在动手写代码之前我们必须搞清楚Shader到底在干什么。很多人觉得Shader神秘其实就是一串告诉GPU“每个像素该怎么画”的指令。对于我们的两个目标效果其核心原理可以分开来看。2.1 动态边缘光如何让轮廓“亮”起来边缘光的本质是识别并高亮显示精灵Sprite的轮廓边界。这里的“轮廓”在2D Sprite的语境下通常指纹理Alpha通道透明度发生剧烈变化的地方。2.1.1 核心检测算法Sobel算子与Alpha检测最常用的方法是采样当前像素周围8个邻域像素的Alpha值通过一个叫做Sobel算子的卷积核来计算透明度变化的“梯度强度”。梯度越大说明此处从透明到不透明或反之的变化越剧烈就越可能是边缘。我常用的一个简化但高效的边缘检测步骤是采样九宫格获取当前处理像素及其上下左右、四个对角共9个点的Alpha值。计算横向与纵向梯度横向梯度Gx (右上右右下)的Alpha - (左上左左下)的Alpha纵向梯度Gy (左下下右下)的Alpha - (左上上右上)的Alpha计算梯度强度edge sqrt(Gx*Gx Gy*Gy)。这个值在边缘处较大在平滑区域接近0。阈值化与输出设定一个阈值当edge大于该阈值时我们认为它是边缘然后根据需求输出边缘光颜色。2.1.2 “动态”体现在哪里动态性就来自于我们可以用Cocos Creator的Material属性系统实时调整几个关键参数_EdgeColor边缘光的颜色可以做成随时间变化的呼吸灯效果。_EdgeWidth通过调整采样步长或模糊半径控制光边的粗细。_EdgeThreshold边缘检测的灵敏度。调低它更多细微的纹理变化会被识别为边缘调高它只有最强烈的轮廓才会发光。_Intensity发光的整体强度。通过脚本动态修改这些材质参数就能实现边缘光随着游戏逻辑如角色血量低时红光闪烁动态变化的效果。2.2 高级模糊不只是简单的毛玻璃模糊学名“卷积滤波”其核心是让一个像素的颜色受其周围像素颜色的影响从而产生平滑、朦胧的效果。在Shader中实现性能是关键。2.2.1 高斯模糊行业标准为何是它高斯模糊是最常见、效果最自然的模糊算法。它使用一个符合正态分布高斯分布的卷积核进行加权平均。离中心越远的像素权重越低。这样做的好处是模糊过渡平滑不会产生生硬的块状感。但是在Shader中直接进行完整的高斯模糊比如7x7核计算量巨大因为每个像素需要采样49次并计算49次加权乘法。这对于移动设备是难以承受的。2.2.2 优化策略双Pass与RenderTarget标准的优化方案是将二维高斯模糊拆解为两个一维模糊Pass水平模糊 垂直模糊。一个N x N的二维卷积可以近似为先后进行一个1 x N的水平卷积和一个N x 1的垂直卷积。这样每个像素在每个Pass中只需要采样N次总采样次数从N²降为2N性能提升巨大。在Cocos Creator中实现这个流程就需要用到RenderTexture渲染纹理Pass 1 (水平模糊)将原始Sprite绘制到一个临时的RenderTexture A上应用水平方向的一维高斯模糊Shader。Pass 2 (垂直模糊)将RenderTexture A作为输入绘制到最终的画布或另一个RenderTexture B上应用垂直方向的一维高斯模糊Shader。这样我们就得到了一个高效的高斯模糊效果。通过控制模糊半径采样距离和迭代次数可以实现从轻微羽化到重度毛玻璃的各种模糊程度。3. 完整Shader代码实现与逐行解析理解了原理我们来看代码。我会把边缘光和模糊分开实现因为它们通常是独立的效果可以根据需要组合使用。3.1 动态边缘光ShaderCCEffect CCProgram首先我们创建一个名为sprite-edge-glow.effect的Effect资源。// sprite-edge-glow.effect CCEffect %{ techniques: - passes: - vert: vs frag: fs properties: props mainTexture: { value: white } mainColor: { value: [1, 1, 1, 1], editor: { type: color } } // 边缘光专属属性 edgeColor: { value: [0.2, 0.6, 1.0, 1.0], editor: { type: color, displayName: 边缘光颜色 } } edgeWidth: { value: 0.01, editor: { type: slider, min: 0.0, max: 0.05, step: 0.001, displayName: 边缘宽度 } } edgeThreshold: { value: 0.1, editor: { type: slider, min: 0.0, max: 1.0, step: 0.01, displayName: 边缘阈值 } } intensity: { value: 1.0, editor: { type: slider, min: 0.0, max: 3.0, step: 0.1, displayName: 发光强度 } } }% CCProgram vs %{ precision highp float; #include cc-global #include cc-local in vec3 a_position; in vec2 a_uv0; out vec2 v_uv0; void main () { vec4 pos vec4(a_position, 1.0); #if CC_USE_MODEL pos cc_matViewProj * cc_matWorld * pos; #else pos cc_matViewProj * pos; #endif gl_Position pos; v_uv0 a_uv0; } }% CCProgram fs %{ precision highp float; #include cc-global #include cc-local in vec2 v_uv0; uniform sampler2D mainTexture; uniform Constant { vec4 mainColor; vec4 edgeColor; float edgeWidth; float edgeThreshold; float intensity; }; out vec4 fragColor; // 核心边缘检测函数 float getEdgeAlpha(vec2 uv, float width) { // 定义8个方向的采样偏移基于edgeWidth控制采样距离 vec2 offsets[8]; offsets[0] vec2(-width, -width); // 左上 offsets[1] vec2( 0.0, -width); // 上 offsets[2] vec2( width, -width); // 右上 offsets[3] vec2(-width, 0.0); // 左 // 中心点 (0,0) 即当前像素 offsets[4] vec2( width, 0.0); // 右 offsets[5] vec2(-width, width); // 左下 offsets[6] vec2( 0.0, width); // 下 offsets[7] vec2( width, width); // 右下 // 简化Sobel算子计算梯度 float Gx 0.0; float Gy 0.0; // 为8个邻域点赋予近似的Sobel权重 // 实际权重矩阵是 [ -1, 0, 1; -2, 0, 2; -1, 0, 1 ] 等这里做简化计算 Gx texture(mainTexture, uv offsets[2]).a * 1.0; // 右上 Gx texture(mainTexture, uv offsets[4]).a * 2.0; // 右 Gx texture(mainTexture, uv offsets[7]).a * 1.0; // 右下 Gx - texture(mainTexture, uv offsets[0]).a * 1.0; // 左上 Gx - texture(mainTexture, uv offsets[3]).a * 2.0; // 左 Gx - texture(mainTexture, uv offsets[5]).a * 1.0; // 左下 Gy texture(mainTexture, uv offsets[5]).a * 1.0; // 左下 Gy texture(mainTexture, uv offsets[6]).a * 2.0; // 下 Gy texture(mainTexture, uv offsets[7]).a * 1.0; // 右下 Gy - texture(mainTexture, uv offsets[0]).a * 1.0; // 左上 Gy - texture(mainTexture, uv offsets[1]).a * 2.0; // 上 Gy - texture(mainTexture, uv offsets[2]).a * 1.0; // 右上 // 计算梯度强度 float edge sqrt(Gx*Gx Gy*Gy); return edge; } void main () { vec4 texColor texture(mainTexture, v_uv0); // 计算当前像素的边缘强度 float edgeAlpha getEdgeAlpha(v_uv0, edgeWidth); // 阈值判断edgeAlpha大于阈值才被认为是边缘 float edgeFactor step(edgeThreshold, edgeAlpha); // 可以加上平滑过渡使用smoothstep代替step // float edgeFactor smoothstep(edgeThreshold, edgeThreshold 0.05, edgeAlpha); // 混合计算原始颜色 边缘光颜色 * 边缘因子 * 强度 vec3 finalColor texColor.rgb * mainColor.rgb; vec3 glowColor edgeColor.rgb * edgeColor.a * edgeFactor * intensity; // 最终输出边缘光以叠加(Additive)方式混合 fragColor vec4(finalColor glowColor, texColor.a * mainColor.a); } }%代码关键点解析与避坑edgeWidth的单位这里的edgeWidth是UV空间的偏移量。UV范围是[0,1]所以值通常很小如0.01。这个值需要根据纹理分辨率调整。对于1024x1024的图0.01大约对应10个像素的采样范围。stepvssmoothstepstep会产生硬边缘发光边界锐利。如果想得到柔和的、有渐变的光边请使用被注释掉的smoothstep函数它会在两个阈值之间进行平滑插值。性能考量这个Shader每个像素采样了8次纹理中心像素在texColor中采样了一次getEdgeAlpha中采样了6个不同位置但通过精心安排部分采样可重用实际采样次数约为6-8次。对于移动端如果性能吃紧可以简化为只采样上下左右四个方向虽然边缘检测准确性会下降但效果在多数情况下仍可接受。Alpha预乘问题注意最后一行texColor.a * mainColor.a。这是正确的Alpha混合方式。确保你的纹理导入设置和混合状态正确否则会出现黑边或亮边。3.2 高级模糊Shader双Pass高斯模糊模糊效果需要两个Pass因此我们创建两个Effect一个用于水平模糊一个用于垂直模糊并配合RenderTexture使用。3.2.1 水平模糊Effect (blur-horizontal.effect)CCEffect %{ techniques: - passes: - vert: vs frag: fs-horizontal properties: props mainTexture: { value: white } blurRadius: { value: 0.005, editor: { type: slider, min: 0.0, max: 0.02, step: 0.001 } } // 可以添加迭代次数控制但通常一个Pass只做一次一维模糊 }% CCProgram vs %{ // ... 顶点着色器同上负责坐标变换和UV传递 ... }% CCProgram fs-horizontal %{ precision highp float; uniform sampler2D mainTexture; uniform Constant { float blurRadius; }; in vec2 v_uv0; out vec4 fragColor; void main() { // 使用5-tap高斯核进行近似模糊权重近似为 [0.06136, 0.24477, 0.38774, 0.24477, 0.06136] // 为了性能我们常使用更简单的线性采样或3-tap核 // 这里展示一个标准的5-tap高斯水平模糊 vec2 offset vec2(blurRadius, 0.0); vec4 color vec4(0.0); color texture(mainTexture, v_uv0 - 2.0 * offset) * 0.06136; color texture(mainTexture, v_uv0 - offset) * 0.24477; color texture(mainTexture, v_uv0) * 0.38774; color texture(mainTexture, v_uv0 offset) * 0.24477; color texture(mainTexture, v_uv0 2.0 * offset) * 0.06136; fragColor color; } }%3.2.2 垂直模糊Effect (blur-vertical.effect)垂直模糊的Fragment Shader与水平模糊几乎一样只是偏移方向改为垂直。CCProgram fs-vertical %{ precision highp float; uniform sampler2D mainTexture; uniform Constant { float blurRadius; }; in vec2 v_uv0; out vec4 fragColor; void main() { // 垂直方向偏移 vec2 offset vec2(0.0, blurRadius); vec4 color vec4(0.0); color texture(mainTexture, v_uv0 - 2.0 * offset) * 0.06136; color texture(mainTexture, v_uv0 - offset) * 0.24477; color texture(mainTexture, v_uv0) * 0.38774; color texture(mainTexture, v_uv0 offset) * 0.24477; color texture(mainTexture, v_uv0 2.0 * offset) * 0.06136; fragColor color; } }%注意高斯核的权重和采样次数tap数决定了模糊质量和性能。5-tap是质量与性能的较好平衡。对于更强的模糊可以增加blurRadius或者进行多次迭代即用第一次模糊的结果作为输入再进行一次模糊但每次迭代都会增加Draw Call。4. Cocos Creator中的实战应用与脚本控制有了Shader下一步就是在Cocos Creator里把它们用起来。这里涉及到Material材质的创建、RenderTexture的运用以及通过脚本进行动态控制。4.1 为Sprite应用边缘光材质创建材质在资源管理器右键 - 创建 - Material。将其命名为Mat_EdgeGlow。指定Effect在材质Inspector面板的Effect属性中选择我们刚才编写的sprite-edge-glow.effect。调整参数你可以在材质面板上直接调整edgeColor,edgeWidth等参数实时在场景中看到效果。应用到Sprite将一个Sprite节点拖入场景在它的Sprite组件里将Custom Material属性设置为刚刚创建的Mat_EdgeGlow材质。现在这个Sprite应该已经带有基础的边缘光了。4.2 实现动态模糊效果使用RenderTexture模糊效果需要离屏渲染步骤稍多创建两个RenderTexture在资源管理器右键 - 创建 - RenderTexture。创建两个命名为RT_BlurTemp和RT_BlurFinal。建议尺寸设置为你需要模糊的UI或精灵的实际尺寸或按比例缩放如屏幕宽高的一半以节省性能。创建两个Camera节点用于离屏渲染。第一个Camera (Camera_BlurH)用于水平模糊。将ClearFlags设为SOLID_COLOR并选择一个纯色背景如透明黑。TargetTexture设置为RT_BlurTemp。创建一个子节点挂载你希望模糊的Sprite原始UI或角色并为这个Sprite应用水平模糊材质(blur-horizontal.effect)。调整Camera的visibility和cullingMask确保只渲染这个模糊用的Sprite。第二个Camera (Camera_BlurV)用于垂直模糊。ClearFlags同样设为SOLID_COLOR。TargetTexture设置为RT_BlurFinal。创建一个子节点添加一个Sprite组件将这个Sprite的spriteFrame设置为RT_BlurTemp即上一步的渲染结果。并为这个Sprite应用垂直模糊材质(blur-vertical.effect)。同样设置好cullingMask。在UI中显示最终模糊结果在你的主UI中创建一个Sprite节点将其spriteFrame设置为RT_BlurFinal。这样双Pass高斯模糊的结果就显示出来了。4.3 脚本动态控制参数效果要“动态”离不开脚本。我们写一个组件来动态控制边缘光的参数。// EdgeGlowController.ts import { _decorator, Component, Material, Sprite } from cc; const { ccclass, property } _decorator; ccclass(EdgeGlowController) export class EdgeGlowController extends Component { property(Sprite) targetSprite: Sprite null; // 需要控制的目标Sprite private _material: Material null; private _timeAccumulator: number 0; private _pulseDirection: number 1; // 可动态调整的参数 property pulseSpeed: number 1.0; // 呼吸闪烁速度 property pulseMinIntensity: number 0.5; // 最小强度 property pulseMaxIntensity: number 2.0; // 最大强度 property colorCycleSpeed: number 0.0; // 颜色循环速度0为不循环 start() { if (this.targetSprite this.targetSprite.customMaterial) { // 获取材质实例注意要clone一份避免共享材质影响其他节点 this._material this.targetSprite.customMaterial; // 如果希望每个实例独立可以 // this._material new Material(); // this._material.copy(this.targetSprite.customMaterial); // this.targetSprite.customMaterial this._material; } } update(deltaTime: number) { if (!this._material) return; this._timeAccumulator deltaTime * this.pulseSpeed; // 1. 控制强度呼吸效果 let intensity 0.0; if (this.pulseSpeed 0) { // 使用正弦函数实现平滑的呼吸效果 intensity (Math.sin(this._timeAccumulator) 1.0) * 0.5; // 映射到 [0, 1] intensity this.pulseMinIntensity intensity * (this.pulseMaxIntensity - this.pulseMinIntensity); this._material.setProperty(intensity, intensity); } // 2. 控制颜色循环 (HSV色彩空间变化效果更佳) if (this.colorCycleSpeed 0) { const hue (this._timeAccumulator * this.colorCycleSpeed) % 1.0; // 色相在 [0, 1] 循环 // 将HSV转换为RGB这里简化实际可使用cc.Color.fromHSV const rgb this.hsvToRgb(hue, 1.0, 1.0); this._material.setProperty(edgeColor, new (window as any).cc.Color(rgb[0]*255, rgb[1]*255, rgb[2]*255, 255)); } // 3. 示例根据游戏状态动态改变边缘宽度如受伤时变宽 // if (player.isHurt) { // this._material.setProperty(edgeWidth, 0.02); // } else { // this._material.setProperty(edgeWidth, 0.01); // } } // 简易HSV转RGB函数 private hsvToRgb(h: number, s: number, v: number): [number, number, number] { let r0, g0, b0; const i Math.floor(h * 6); const f h * 6 - i; const p v * (1 - s); const q v * (1 - f * s); const t v * (1 - (1 - f) * s); switch (i % 6) { case 0: r v; g t; b p; break; case 1: r q; g v; b p; break; case 2: r p; g v; b t; break; case 3: r p; g q; b v; break; case 4: r t; g p; b v; break; case 5: r v; g p; b q; break; } return [r, g, b]; } }将这个脚本挂载到场景中任意节点并将应用了边缘光材质的Sprite节点拖拽赋值给targetSprite运行后就能看到动态呼吸和颜色变化的边缘光了。5. 性能优化、常见问题与排查实录在实际项目中应用这些特效性能和兼容性问题是我们必须面对的坎。下面是我踩过的一些坑和总结的经验。5.1 性能优化要点减少纹理采样次数这是Shader性能的头号杀手。边缘光如果效果要求不高可将8邻域采样简化为4邻域上下左右。甚至可以尝试用fwidth函数在屏幕空间估算梯度但这种方法在Cocos的Sprite上可能不直接适用。高斯模糊5-tap是平衡点。对于移动端3-tap权重如[0.25, 0.5, 0.25]可能更安全。模糊半径(blurRadius)不要设得太大否则采样距离过远缓存命中率低性能下降明显。控制RenderTexture分辨率模糊效果使用的RenderTexture其尺寸不必与屏幕一致。对于全屏背景模糊使用1/2或1/4屏幕大小的RT能极大降低填充率。在Cocos Creator中创建RenderTexture时就可以设置宽高。避免每帧更新动态效果不一定要每帧都变。例如UI的模糊背景可能在弹窗打开时计算一次即可除非背景内容在动态变化。通过脚本控制Camera的enabled属性或Material参数的更新频率。合批与材质实例化多个使用相同边缘光材质的Sprite如果材质参数完全相同Cocos可能会进行合批。但如果需要动态修改参数务必像上面脚本里提到的那样使用material.clone()为每个Sprite创建独立的材质实例否则修改一个会影响到所有使用该材质的对象。但这会打断合批需要在效果和性能间权衡。Precision精度选择在Fragment Shader开头我们用了precision highp float;。对于大多数现代移动GPUhighp没问题。但如果遇到一些低端机兼容性问题可以尝试改为mediump。注意mediump可能导致一些计算特别是sin,pow等精度不足产生色带或闪烁需要测试。5.2 常见问题与解决方案速查表问题现象可能原因排查与解决方案边缘光不显示或显示不全1. 纹理Alpha通道不正确。2.edgeThreshold设置过高。3.edgeWidth太小。1. 检查图片资源Alpha通道确保轮廓清晰。在PS里查看Alpha通道。2. 逐步调低edgeThreshold观察效果。3. 适当增大edgeWidth如从0.01调到0.02。边缘光有锯齿毛刺1. 纹理本身分辨率低边缘锯齿。2. Shader中边缘检测采样步长(edgeWidth)与纹理分辨率不匹配。1. 使用更高精度的纹理资源。2. 尝试在Shader中对UV进行一下轻微模糊或者在边缘检测后对edgeFactor做一次平滑处理用smoothstep代替step。模糊效果性能很差1. RenderTexture分辨率过高。2. 高斯模糊采样次数(tap)过多或半径过大。3. 每帧都在进行模糊计算。1. 降低RT分辨率如屏幕宽高/2。2. 减少tap数改用3-tap减小blurRadius。3. 将模糊计算设为按需触发而非每帧更新。模糊效果有接缝或错误1. RenderTexture的Wrap Mode不是CLAMP_TO_EDGE。2. 模糊时采样到了纹理边界之外。1. 在Cocos Creator中确保用于模糊的Sprite所采样的RenderTexture其wrapMode属性设置为CLAMP_TO_EDGE在纹理资源的Inspector面板。2. 在Shader中对UV进行clamp操作确保其在[0,1]范围内。修改材质参数无效1. 材质是共享的被其他节点修改覆盖。2. 脚本中获取或设置Material的API使用错误。1. 确保为需要独立控制的Sprite使用了material.clone()创建的实例。2. 检查脚本getComponent(Sprite).customMaterial获取的是否是材质实例并使用setProperty方法设置参数参数名与Effect中定义的property名完全一致。在Web Mobile上效果异常或崩溃1. Shader语法或精度不支持。2. 运算过于复杂超出移动GPU能力。1. 简化Shader避免使用for循环除非是常量循环检查所有函数和变量声明是否被支持。2. 使用性能分析工具如浏览器开发者工具的Performance面板定位瓶颈大幅降低模糊RT分辨率或关闭特效。5.3 一个进阶技巧结合Mask实现局部边缘光有时我们只希望精灵的某个部分发光比如只有武器发光。这时可以结合Mask遮罩来实现。准备一张遮罩纹理白色区域表示需要发光黑色区域表示不需要。在边缘光Fragment Shader中额外采样这张遮罩纹理。在计算最终发光颜色时乘以遮罩纹理的灰度值mask.r。uniform sampler2D maskTexture; ... vec4 maskColor texture(maskTexture, v_uv0); float maskValue maskColor.r; // 假设遮罩在R通道 vec3 glowColor edgeColor.rgb * edgeColor.a * edgeFactor * intensity * maskValue; // 乘以遮罩值这样发光效果就被限制在遮罩定义的区域内了。Shader特效的调试是个耐心活很多时候需要一点点调整参数在真机上反复测试。我的习惯是先在编辑器里把效果调到满意然后立刻在目标最低配置的真机上进行测试确保性能达标。如果效果和性能冲突优先保证流畅度再想办法简化效果或寻找替代方案。毕竟一个稳定60帧的“简约”特效远胜于一个卡顿的“华丽”特效。把这些动态边缘光和高级模糊的效果掌握后你就能为你的Cocos Creator项目注入更多视觉活力而且是用一种高性能、可编程的方式。