
这篇接着前面的Shader系列往下写聊聊CocosCreator里很常见的“图片圆形裁剪”。开头先说清楚这功能听起来简单就是让一张图只显示圆形区域但实际动手写shader时会踩出一堆和uv坐标、宽高比、合图有关的坑。这篇主要是把从原理推导到代码、再到真机调试的完整链路讲明白适合正在学自定义Shader的人也适合只想快速抄一个能用的圆形裁剪效果、但不想被坑的开发者。1. 圆形裁剪到底是哪种“裁剪”1.1 先分清几种常见的圆形效果如果你刚接到一个“头像圆形裁剪”的需求很可能第一时间想到的是给节点挂一个Mask组件或者直接用一张圆形遮罩图贴上。这两种方案在功能上都能做到“显示出圆形区域”但Shader方案和它们不是一回事。“图片圆形裁剪”在Shader语境下指的是在片元着色器里逐像素计算当前位置是否落在目标圆内圆内区域保留原图颜色圆外区域将alpha改成0。这个思路不只适用于圆形稍微改一下距离公式就能扩展成圆环、圆角矩形、扇形、多边形甚至带柔和边缘的光圈。所以学的不是“裁剪”这个动作而是一套“用数学表达式描述形状”的方法后续复用价值极高。1.2 为什么Shader是这里的最优解先说结论能用Shader解决尽量不要用Mask节点。Mask的实现机制通常是模板缓冲或者对子节点做特殊渲染处理。在小项目里用起来方便但遇到同一个父节点下挂十几个头像、每个都要圆形裁剪时Draw Call和模板缓冲开销会明显变多在低端安卓机上尤其能感觉到掉帧。而Shader方案是在同一个Sprite渲染流程里完成的不增加额外节点、不消耗模板缓冲CPU和GPU的压力几乎没有变化。还有一个场景差异值得注意Mask的裁剪是几何级的被裁掉的地方连渲染都不会发生Shader的裁剪是像素级的图还是完整渲染只是在片元阶段把边缘像素的alpha压低了。如果你追求“圆外完全不计算纹理采样”这种极致优化可以在Shader里用discard提前跳过不过这个选择会牺牲某些GPU优化我在后面性能部分单独展开。2. 把“圆”翻译成Shader能懂的话2.1 uv是什么这里卡住了很多人要写圆形裁剪第一件事是搞懂uv。uv是一个取值范围通常在(0,0)到(1,1)之间的二维坐标表示当前像素在纹理上的位置。左下角是(0,0)右上角是(1,1)中心点是(0.5,0.5)。这就像在一张地图上用经纬度定位一个地点u对应经度方向v对应纬度方向。我们想判断一个像素在不在圆内思路是先把它和圆心比较距离。但uv坐标的原点在左下角圆心在(0.5,0.5)直接算距离不仅写法别扭而且后续要判断“距离是否小于半径”就得加上大量偏移换算。所以第一步是把uv重新映射成以圆心为原点的坐标vec2 uv v_uv0 * 2.0 - 1.0;这个公式把(0,0)变成(-1,-1)(1,1)变成(1,1)(0.5,0.5)变成(0,0)。于是图片中心变成了坐标系原点四个角分别落在(-1,-1)、(1,-1)、(1,1)、(-1,1)。在这个坐标系里图片覆盖的是一个边长为2的正方形而我们想要的内切圆半径正好是1判断条件就是“到原点的距离是否小于1”。2.2 距离公式与纵横比修正有了上面这个坐标判断圆的标准就明确了float dist length(uv);length是向量求模等价于sqrt(uv.x * uv.x uv.y * uv.y)。两点之间直线距离初中几何的东西放GPU里跑起来非常便宜。但这里有个非常常见的问题如果图片不是正方形直接这样算出来的不是圆而是椭圆。原因在于uv坐标系不管图片实际宽高多少始终会被归一化到0到1。也就是说一张宽度100、高度50的图片在u方向映射的是100像素在v方向映射的却是50像素。当我把uv乘2减1变成(-1,1)范围后x方向的一个单位代表100像素y方向的一个单位只代表50像素画出来的“距离等于1”的轨迹自然是一个横扁的椭圆。解决办法是给x方向乘上图片的宽高比让x和y各自代表的“实际长度”一致vec2 uv v_uv0 * 2.0 - 1.0; uv.x * aspect;这里的aspect是图片宽度除以高度。但如果你直接拿这个公式去处理宽高比小于1的图片比如宽度50、高度100圆的直径就会按宽度而不是高度来取最终得到的是超出边界的“不可能完整内切圆”。所以我习惯用一个更通用的版本保证短边总是作为圆的直径vec2 ratio vec2(max(aspect, 1.0), max(1.0 / aspect, 1.0)); vec2 uv (v_uv0 - 0.5) * 2.0 * ratio;宽高比大于1时x方向放大宽高比小于1时y方向放大。无论图片是横的还是竖的都能得到一个完整、不变形的内切圆。2.3 softness参数与边缘平滑距离值算出来了接下来要决定怎么用这个值控制alpha。最直接的做法是if (dist 1.0) { o.a 0.0; }但这样会得到一个锯齿严重的硬边。原因很好理解像素是离散的边缘像素可能只有一部分落在圆内另一部分落在圆外。如果不做任何过渡处理GPU只能二选一圆边缘就会出现明显的阶梯状锯齿。要解决这个问题最优雅的方式是用smoothstep做一个平滑过渡带。smoothstep(edge0, edge1, x)会在edge0到edge1之间生成一个从0到1的平滑插值而且两端的一阶导数为0视觉上非常自然。float soft 0.05; float alpha 1.0 - smoothstep(1.0 - soft, 1.0, dist);当dist小于1 - soft时alpha是1完全显示当dist大于1时alpha是0完全隐藏在中间那一段alpha从1平滑下降到0。这里的soft就是过渡带的宽度数值越大边缘越柔和。这里有一个我踩过的小坑soft的单位不是像素而是归一化坐标。假如图片半径是100像素soft0.05对应的实际过渡宽度是100乘0.05也就是5像素。这个关系在不同分辨率下会变切换场景或换图时记得重新确认视觉效果。3. 完整Effect代码与逐行拆解3.1 effect文件骨架与渲染状态CocosCreator 3.x的Shader是一个后缀为.effect的文本文件里面同时包含渲染状态配置和GLSL代码。下面给出一份我一直在用的圆形裁剪effect基于内置sprite的写法兼容性和可读性都比较均衡。CCEffect %{ techniques: - passes: - vert: vs frag: fs blendState: targets: - blend: true blendSrc: one blendDst: one_minus_src_alpha rasterizerState: cullMode: none properties: aspect: { value: 1.0, editor: { tooltip: 图片宽高比宽/高 } } softness: { value: 0.05, editor: { slide: true, range: [0.0, 0.5] } } }% CCProgram vs %{ precision highp float; #include cc-global #include cc-local in vec3 a_position; in vec2 a_uv0; out vec2 v_uv0; vec4 vert () { vec4 pos cc_matViewProj * cc_matWorld * vec4(a_position, 1.0); v_uv0 a_uv0; return pos; } }% CCProgram fs %{ precision highp float; #include texture in vec2 v_uv0; uniform sampler2D texture; uniform Properties { float aspect; float softness; }; void main () { vec4 o CCSample(texture, v_uv0); vec2 ratio vec2(max(aspect, 1.0), max(1.0 / aspect, 1.0)); vec2 uv (v_uv0 - 0.5) * 2.0 * ratio; float dist length(uv); float alpha 1.0 - smoothstep(1.0 - softness, 1.0, dist); o.a * alpha; gl_FragColor o; } }%先看CCEffect块里的配置。blendState里我用了blendSrc: one、blendDst: one_minus_src_alpha这套是预乘Alpha的混合方式和CocosCreator内置Sprite的默认混合一致能保证透明边缘不会出现白边。如果换成src_alpha加one_minus_src_alpha很多素材会出现一圈发亮的光晕具体原理我在第五部分细讲。rasterizerState里cullMode: none意思是关闭面剔除。2D的Sprite本质是平面不需要剔除保持默认就行。3.2 顶点着色器只负责传uv顶点着色器在这里做的事情非常简单把顶点位置从本地坐标变换到裁剪坐标再把uv原封不动传给片元着色器。vec4 pos cc_matViewProj * cc_matWorld * vec4(a_position, 1.0); v_uv0 a_uv0;如果你之前学过OpenGL标准Shader可能会好奇为什么这边没有gl_Position。这是CocosCreator 3.x的引擎封装vert函数返回值会被引擎当作裁剪坐标使用不需要也不应该自己再去赋值gl_Position。一些从旧项目迁移过来的effect代码会用gl_Position在3.x里经常直接编译报错这个差异值得留意。顶点着色器本身没有任何圆形相关的计算。圆形判断放在片元着色器里是因为uv坐标随像素位置逐点变化必须在片元阶段做逐像素计算。顶点着色器只管四个角点的传递这正是Shader渲染管线的分工逻辑。3.3 片元着色器圆就在这三行里片元着色器一开始先采样纹理颜色vec4 o CCSample(texture, v_uv0);CCSample是Cocos引擎封装好的采样函数内部会处理spriteFrame的UV映射和纹理状态。这里注意不能直接写texture2D(texture, v_uv0)因为Cocos 3.x在多平台适配上有自己的一套宏定义直接用裸函数容易踩空。接下来的三行就是整个圆形裁剪的核心vec2 ratio vec2(max(aspect, 1.0), max(1.0 / aspect, 1.0)); vec2 uv (v_uv0 - 0.5) * 2.0 * ratio; float dist length(uv);第一行根据图片宽高比算出x、y各自需要的修正系数第二行把uv从(0,1)映射到圆心在原点的坐标系第三行算距离。之后用smoothstep把距离转换成alphafloat alpha 1.0 - smoothstep(1.0 - softness, 1.0, dist); o.a * alpha;原来贴图alpha为0的地方比如PNG透明区域本来就该透明这里乘上裁剪alpha只会更透明不会出现原本透明、裁剪后反而不透明的问题。把alpha乘到o.a就等于把两个透明信息合并了。整份代码里没有出现任何if/else这是我喜欢的方式。smoothstep天然完成了渐变代码简洁也能避免GPU分支判断带来的额外开销。4. 从创建文件到真机效果全流程4.1 创建effect与材质实际操作时不需要从零开始写effect文件。最简单的方式是找到CocosCreator内置的builtin-sprite.effect复制一份重命名然后把里面的片元着色器内容替换成上面这份。这样做的好处是CCEffect头部的宏定义和包含项大多是现成且兼容的不容易出语法层面的低级错误。在资源管理器里新建材质选Effect时指定刚才创建的effect文件。然后给场景里的Sprite节点添加一个Sprite组件把SpriteFrame选成你要裁剪的图片再把CustomMaterial设置成新建的材质。设置完之后编辑器预览窗口应该立刻能看到圆形效果。如果你发现图片没有变化先检查两点一是材质是否真的应用到了节点上二是Sprite组件的Color是否被调成了透明。后者是个很隐蔽的因素很多初学者调了半天shader结果只是节点颜色alpha被设成了0整个图都看不见了。4.2 参数调整与预览材质面板里能看到两个自定义参数aspect和softness。aspect是用来做纵横比修正的关键参数值应该是“图片宽度/图片高度”。比如图片是200乘100就填2.0是100乘200就填0.5。如果你用的是正方形头像素材填1.0就不需要改动。这个参数让我纠结过一阵子它需要用户手动填而不是从spriteFrame里自动计算。想省事的话可以写一个几十行的小组件脚本在onLoad里从Sprite组件拿到spriteFrame.width和spriteFrame.height然后调用材质接口把aspect设置进去const sprite this.getComponent(Sprite); const material sprite.customMaterial; material.setProperty(aspect, sprite.spriteFrame.width / sprite.spriteFrame.height);这样换图时参数会自动更新不容易出现换了一张非正方形图、圆形变成椭圆的问题。softness建议调到0.02到0.08之间。太大会让边缘虚得过头像没对上焦的照片太小则锯齿感明显。这个参数和图片分辨率没有直接依赖但视觉感知会随分辨率变化最好真机上预览确认一遍。4.3 性能与兼容性注意事项圆形裁剪的Shader计算量非常小片元着色器里无非是一个距离公式加一个smoothstep移动端随便跑。真正影响性能的是你额外引入的特性。很多教程喜欢在圆形判断里做discard比如if (dist 1.0) discard;这样可以跳过圆外像素的混合输出理论上省GPU带宽。但在移动端GPU上discard会破坏Early-Z机制导致本该提前丢弃的深度测试失效。对于2D游戏来说这里的负面影响往往大于收益。尤其当你的界面上同时存在大量圆头像时discard反而可能让帧数下降。除非你明确知道自己在做什么否则不建议用discard做2D圆形裁剪。如果只是做头像这种中小尺寸的圆直接输出alpha为0完全够用。圆外像素虽然还参与了混合流程但绝大多数GPU对全透明像素的写入成本极低不必过度优化。真机兼容性方面主要注意着色器精度。我这里在vs和fs里都声明了highp float。某些低端安卓机的片元着色器默认浮点精度是mediump当图片像素比较高时圆形边缘可能出现抖动或氰边提高精度后问题基本消失。5. 常见问题与排查技巧实录5.1 圆形变椭圆或位置偏移这是我被问过最多的问题。先看快门结论绝大多数情况是aspect参数和图片实际宽高比不一致。你在材质面板里手动填了一个值结果换图时忘了更新或者填反了圆形就会变成横向或纵向的椭圆。解决方法很简单要么确认每次换图都同步更新aspect要么用脚本自动计算。在自动计算时还有一点容易忽略——如果你在Sprite节点上做了拉伸比如把节点尺寸设成了固定值而不是保持原始图片比例那么Shader里的aspect应该用节点实际宽高比而不是spriteFrame的宽高比。因为屏幕上的最终显示比例由节点尺寸决定Shader在纹理坐标系里画圆最终渲染到屏幕时会被节点拉伸扭曲。5.2 边缘锯齿感严重边缘锯齿主要来自两个原因softness太小或者纹理采样本身有硬边。softness低于0.01时过渡带只有极少像素视觉上基本就是硬边。调大一点即可。要注意的是这个参数是归一化单位不是像素单位。同样softness0.03在小尺寸图标上可能是1到2个像素的渐变在大尺寸背景图上可能变成明显发虚的柔和边缘。建议根据实际用途分层调节。如果softness已经合适但边缘还是“电子感”很强可以在导入素材时确认一下图片生成设置里的Filter是否为线性过滤最近邻过滤会让圆形边缘出现明显的颗粒状锯齿。5.3 图片突然不显示或全黑这个坑我栽过好几次。造成全黑最常见的原因不是Shader写错而是effect文件里语法报错后CocosCreator用默认的白色材质替代了你的自定义材质。这时候场景里看到的是一张纯色块而不是你想裁的圆。排查步骤很简单打开控制台看有没有GLSL编译报错报错信息通常会指出是第几行。常见的语法错误包括忘记在CCProgram末尾添加%把in写成attribute或者把out写成varying。CocosCreator 3.x已经全面使用GLSL300语法旧写法是不支持的。另一种情况是材质面板里参数范围设错了。softness如果设置成负数smoothstep的阈值会反转整个圆可能直接消失只剩一个透明图像。编辑器面板上的range范围最好和我给出的保持一致。5.4 合图与九宫格带来的坑圆形偏移这个现象十有八九和合图有关。很多项目的图集会自动把spriteFrame打包成一张大图因此Shader拿到的texture是大纹理而v_uv0是当前spriteFrame在合图里占的那一块矩形区域的坐标。直接做“整张图中心到边缘”的圆形判断圆心位置和半径长度都是错的表现是什么圆心的位置可能偏到某一边圆形被生生裁掉一块。最简单的解决办法是在SpriteFrame资源属性里把packable勾选去掉让这张图不参与合图。代价是会多一个纹理上传但省心。如果你必须保留合图就需要在Shader里把spriteFrame在原纹理中的矩形区域信息算出来用相对坐标而不是绝对uv来做圆判断。这个方案涉及引擎内部uv变换细节不同版本写法有差异不推荐新手入场。九宫格Sliced模式的坑则更直接九宫格的uv不是简单的0到1连续分布节点中间区域会被拉伸边缘区域有不同的映射规则。在这种模式下你按原始uv算出来的圆会被切成好几块。所以使用圆形裁剪Shader的Sprite一定要选择Simple渲染模式不要用Sliced。5.5 边缘出现一圈亮色光晕之前我提到混合模式用预乘Alpha这里解释一下为什么。如果图片边缘像素是半透明状态假设有一个像素颜色是(0.8, 0.3, 0.2, 0.5)。在非预乘混合模式下渲染时RGB和Alpha会被各自独立处理最终混合结果往往会比原图更亮形成一圈肉眼可见的亮边。而Cocos内置Sprite默认处理的是预乘Alpha要求贴图在导入时就把RGB通道提前乘以透明度。所以我给的effect里blendSrc: one、blendDst: one_minus_src_alpha配合引擎的Sprite渲染流程一致能最大限度避免发亮问题。如果你的素材来源复杂有的贴图本身就是预乘过的有的是普通RGB那么在圆形边缘颜色会略有出入。这个属于美术资源层面的问题不能用Shader一劳永逸修正最好在资源规范里统一好素材格式。问题现象主要原因快速对策圆形变椭圆aspect参数不对自动取spriteFrame宽高算aspect圆心偏移素材图参与合图关闭packable或采样spriteFrame矩形区域边缘锯齿softness过小或纹理过滤类型不符调大softness确认过滤方式为线性图片全黑effect语法错误或参数越界看控制台GLSL报错检查softness范围边缘亮边混合模式与贴图预乘状态不匹配使用one / one_minus_src_alpha规范素材Sliced模式错乱九宫格uv分布特殊Sprite改用Simple模式5.6 不同机型上的表现差异安卓阵营的GPU碎片化严重同一段GLSL代码在不同驱动下的表现差异有时会让人怀疑人生。我遇到过一台老机型圆形边缘会出现非常细的粉色描边排查了半天发现是驱动对highp float的支持不到位纹理采样结果在高精度模式下产生了偏差。针对这类问题的通用做法是如果只在个别机型上出现异常先把精度申明改为mediump float试试如果大面积机型有问题则要从混合模式和宏定义入手排查。CocosCreator的Effect系统在编译时会按目标平台做宏展开有时一处宏定义的差异就会导致完全不同的编译结果。调试时最有效的方式是打开CocosCreator的Effect预览面板切换预设设备快速对比各档位的表现。这比反复打包到真机效率高得多但最终验收一定不能少了低端真机。我个人在实际操作中的体会是圆形裁剪这个Shader的代码量虽然不到几十行但背后牵出的知识点一个接一个uv坐标映射、距离场、平滑过渡、预乘Alpha、合图规则、渲染管线优化。把这些吃透了再去看圆角矩形、环形进度条、噪波溶洞这些效果会发现它们本质上都是一套逻辑换了一层皮。后续如果你想把圆形边缘做得更像“头像框”可以在这个效果上增加一个内圈描边比如把dist位于0.9到1.0之间的像素替换成边框色几分钟就能出一个不错的UI组件。做这个项目时我就是先实现了圆形裁剪再往里头堆这些扩展需求Shader能力的提升速度明显比死记硬背API快得多。