1. 项目概述为什么描边效果值得你花时间在游戏开发里描边效果是个高频需求。无论是角色选中、敌人锁定还是让UI按钮在视觉上“跳”出来一个恰到好处的描边都能极大地提升交互反馈和视觉层次。很多刚接触Cocos Creator的朋友尤其是从Unity转过来的可能会觉得“描边”不就是给Sprite加个Outline组件吗确实引擎内置的UI组件或一些2D精灵的Outline效果能解决大部分基础需求。但当你遇到更复杂的场景比如3D模型描边、自定义形状的描边、或者需要性能更优、效果更可控的方案时内置组件就可能力不从心了。这时Shader就成了你的“瑞士军刀”。通过编写一个简单的描边Shader你不仅能实现从单色到渐变、从等宽到随距离变化的各类高级描边更能从根本上理解图形渲染的管线流程。这对于想在游戏开发这条路上走得更远的朋友来说是一次绝佳的“破冰”实践。它不像写一个复杂的PBR基于物理的渲染Shader那样令人望而生畏但又涵盖了从顶点变换到片元着色的核心概念是学习Shader编程性价比极高的切入点。我见过不少项目初期为了快速出效果给大量物体叠加了多层UI Outline组件在移动端上帧率直接“跳水”。后来用自定义Shader重构后性能提升立竿见影。所以学习这个案例你收获的不仅仅是一个“描边”功能更是一把打开图形渲染大门的钥匙以及一种优化项目性能的思路。2. 描边Shader的核心原理与方案选型在动手写代码之前我们必须搞清楚在GPU的渲染管线里描边到底是怎么“画”出来的理解了原理才能在各种方案中做出最适合自己项目的选择。2.1 主流描边实现方案剖析常见的描边实现方案主要有以下几种各有优劣基于法线外扩的描边这是3D模型描边最经典和常见的方法。其核心思想是在顶点着色器阶段沿着顶点法线Normal方向将顶点位置向外挤出一点点然后用纯色渲染这个“外扩”的模型作为描边层接着再正常渲染原模型覆盖在上面。这种方法效果稳定描边宽度均匀非常适合角色、场景物件等3D模型。基于后处理的描边在全部场景渲染完成后对屏幕图像进行二次处理。通常使用Sobel、Roberts等边缘检测算子通过卷积计算像素之间的颜色或深度差异来识别物体的边缘然后为这些边缘像素着色。这种方法一次性能为场景中所有物体添加描边与物体复杂度无关但比较消耗性能且对透明物体的边缘检测可能不准确。基于几何着色器的描边在GPU管线中利用几何着色器Geometry Shader为每个三角形生成额外的边缘几何体。这种方法非常灵活可以生成各种复杂风格的描边但兼容性是个大问题尤其在WebGL和部分移动设备上支持不佳且性能开销较大。基于多Pass渲染的描边在一个材质中使用多个渲染通道Pass。第一个Pass专门用于渲染描边通常结合法线外扩第二个Pass再渲染物体本身。这是Cocos Creator中实现自定义描边Shader最常用、最直接的方法也是我们本次案例采用的核心方案。注意对于纯粹的2D Sprite有时也可以使用一种“取巧”的办法复制一个相同的Sprite将其放大一点并设置为描边颜色放在原Sprite的下层。但这本质上还是多渲染了一次且放大操作可能导致纹理采样失真效果不如此处讨论的基于Shader的方案精致和高效。2.2 为什么选择多Pass法线外扩方案结合Cocos Creator尤其是3.x版本的生态和我们的学习目标多Pass法线外扩方案是最佳选择。学习曲线平缓它清晰地分离了“描边”和“本体”两个渲染阶段代码结构一目了然非常适合初学者理解Shader的Pass概念和渲染顺序。效果可控且高质量通过法线外扩描边宽度在模型表面是均匀的不受视角影响视觉效果专业。我们可以轻松地通过一个参数来控制描边的粗细和颜色。性能相对可靠虽然需要多渲染一次模型但对于单个或少量需要描边的角色/物体来说开销是可接受的。相比后处理的全屏处理在对象数量少时更有优势。平台兼容性好完全基于标准的顶点/片元着色器在Cocos Creator支持的WebGL、OpenGL ES等图形API上都能良好运行。这个方案的流程图可以简单理解为渲染队列开始 - 执行描边Pass顶点外扩输出单一描边色 - 执行本体Pass正常渲染模型 - 混合输出到屏幕。两个Pass的渲染状态如深度测试、混合模式需要精心设置才能保证描边在下面、本体在上面且不会相互覆盖出错。3. 在Cocos Creator 3.x中构建描边Shader理论清晰了我们开始在Cocos Creator 3.8.1版本号仅供参考原理通用中动手实现。我们将创建一个完整的材质和Shader资源。3.1 创建着色器资源与材质创建Effect文件在资源管理器中右键点击某个文件夹例如assets选择创建 - Effect。将其命名为outline.effect。这个.effect文件就是Cocos Creator的Shader资源它使用一种特定的DSL领域特定语言来组织多个Pass和渲染状态。创建材质右键点击选择创建 - Material。将其命名为OutlineMat.mat。然后在材质Inspector面板的Effect属性中选择我们刚刚创建的outline.effect。现在我们有了一个空的Shader框架和一个使用它的材质。接下来就是编写核心的Effect代码。3.2 编写多Pass描边Effect代码打开outline.effect文件我们将编写完整的Shader代码。以下是逐段解析// outline.effect CCEffect %{ techniques: - name: opaque passes: - vert: general-vs:vert # 使用通用顶点着色器 frag: outline-fs:frag # 第一个Pass描边片元着色器 properties: props mainColor: { value: [1.0, 0.2, 0.2, 1.0], editor: { type: color } } outlineWidth: { value: 0.05, editor: { type: slider, min: 0.0, max: 0.2, step: 0.01 } } outlineColor: { value: [0.0, 0.0, 0.0, 1.0], editor: { type: color } } depthStencilState: depthTest: true depthWrite: true blendState: targets: - blend: false - vert: general-vs:vert # 使用通用顶点着色器 frag: standard-fs:frag # 第二个Pass标准片元着色器渲染本体 properties: *props # 继承第一个Pass的属性 depthStencilState: depthTest: true depthWrite: false # 关键关闭深度写入防止覆盖描边 blendState: targets: - blend: true blendSrc: src_alpha blendDst: one_minus_src_alpha }% CCProgram outline-fs %{ precision highp float; #include output #include cc-fog-fs in vec2 v_uv; in vec3 v_normal; uniform Constant { vec4 mainColor; vec4 outlineColor; float outlineWidth; }; vec4 frag () { // 描边Pass直接输出描边颜色 return outlineColor; } }% // 注意我们使用了 general-vs 和 standard-fs 这两个内置的CCProgram。 // 这意味着顶点变换和本体渲染直接使用引擎内置的高效、功能完整的着色器。 // 我们只需要专注于描边Pass的特殊处理。代码关键点解析Technique与Passes一个technique包含多个pass。这里我们定义了opaque技术包含两个pass。它们会按顺序执行。Pass 1 - 描边层vert: general-vs:vert使用了Cocos内置的通用顶点着色器它已经包含了基础的MVP模型-视图-投影变换等。但是这里有个大问题内置的general-vs不会为我们做法线外扩。为了实现外扩我们必须自定义一个顶点着色器。我们稍后会修正这一点。frag: outline-fs:frag使用我们自定义的outline-fs片元着色器它非常简单直接返回outlineColor。depthStencilState开启了深度测试和深度写入。这意味着描边层会写入深度缓冲区后续的物体可以根据深度判断前后关系。blendState: blend: false关闭混合描边色直接覆盖。Pass 2 - 本体层vert: general-vs:vert同样使用内置顶点着色器正常变换顶点。frag: standard-fs:frag使用内置的标准片元着色器它会处理漫反射、光照等如果材质有设置贴图、颜色等。这里我们通过mainColor属性来影响其输出。depthStencilState: depthWrite: false这是关键技巧关闭深度写入但保持深度测试开启。这样在渲染本体时它会和已经写入深度缓冲的描边层进行深度比较。由于本体顶点位置在原处没有外扩它比描边层“更靠里”因此能通过深度测试并渲染在描边层之上同时不会抹掉描边层的深度值。blendState开启了Alpha混合用于处理半透明效果。当前代码的缺陷它缺少了最核心的“顶点外扩”逻辑。所以我们需要创建一个自定义的顶点着色器。3.3 实现顶点外扩自定义顶点着色器让我们修正Effect文件添加一个用于描边Pass的自定义顶点着色器。// 更新后的 outline.effect 部分 CCEffect %{ techniques: - name: opaque passes: - vert: outline-vs:vert # 改为使用自定义的描边顶点着色器 frag: outline-fs:frag properties: props mainColor: { value: [1.0, 0.2, 0.2, 1.0], editor: { type: color } } outlineWidth: { value: 0.05, editor: { type: slider, min: 0.0, max: 0.2, step: 0.01 } } outlineColor: { value: [0.0, 0.0, 0.0, 1.0], editor: { type: color } } - vert: general-vs:vert frag: standard-fs:frag properties: *props }% // 新增自定义描边顶点着色器 CCProgram outline-vs %{ precision highp float; #include cc-global #include cc-local // 从应用程序传入的顶点属性 in vec3 a_position; in vec3 a_normal; // 法线信息用于外扩方向 // 传递给片元着色器的变量 out vec3 v_normal; // Uniform变量来自材质的属性 uniform OutlineParams { float outlineWidth; }; void vert () { // 1. 将法线从模型空间变换到视图空间相机空间 // 外扩应该在视图空间进行这样描边宽度在不同视角下更一致。 vec3 viewNormal normalize(mat3(camera.matView) * a_normal); // 2. 将顶点位置变换到视图空间 vec4 viewPos camera.matView * vec4(a_position, 1.0); // 3. 在视图空间中沿法线方向外扩顶点 // outlineWidth 是一个可调节的参数单位可以理解为模型空间或视图空间的缩放因子。 // 这里我们简单地在视图空间进行外扩。更精确的做法可能需要考虑投影矩阵的影响。 viewPos.xyz viewNormal * outlineWidth; // 4. 将外扩后的视图空间位置变换到裁剪空间输出位置 gl_Position camera.matProj * viewPos; // 将法线传递给片元着色器如果后续需要 v_normal a_normal; } }% // 片元着色器保持不变 CCProgram outline-fs %{ precision highp float; #include output #include cc-fog-fs in vec3 v_normal; uniform Constant { vec4 mainColor; vec4 outlineColor; float outlineWidth; }; vec4 frag () { // 描边Pass直接输出描边颜色 return outlineColor; } }%自定义顶点着色器详解输入与输出我们获取了顶点的模型空间位置a_position和法线a_normal。outlineWidth是我们在材质面板中定义的属性。空间变换这是核心。我们先将法线a_normal乘以模型视图矩阵的左上3x3部分mat3(camera.matView)将其从模型空间变换到视图空间。这样外扩的方向是基于当前相机视角的。顶点外扩同样将顶点位置变换到视图空间得到viewPos。然后让viewPos加上视图空间法线viewNormal与outlineWidth的乘积。这就完成了顶点的外扩。投影变换最后将外扩后的视图空间坐标乘以投影矩阵camera.matProj得到最终的裁剪空间坐标gl_Position交给GPU进行后续处理。实操心得outlineWidth的值需要根据你的模型尺寸进行调试。对于通常的模型0.01到0.1是一个合理的范围。值太大会导致描边过粗且模型本身被过度挤压变形值太小则描边不明显。建议在材质面板上将其设置为Slider类型方便实时调节。3.4 配置材质与应用到模型配置材质属性选中之前创建的OutlineMat.mat材质。在Inspector面板中你应该能看到我们在Effect中定义的三个属性Main Color、Outline Width、Outline Color。尝试调整它们特别是Outline Width和Outline Color观察材质球的预览变化。应用到模型在场景中创建一个3D模型节点例如一个Cube或Sphere或者导入的FBX角色模型。选中该节点在MeshRenderer组件中将Materials数组的第一个元素替换为我们创建的OutlineMat材质。如果一切正确你应该能在场景编辑器中立刻看到模型带有描边效果。拖动相机视角描边应该均匀地包裹在模型周围。4. 效果优化与进阶技巧基础的描边实现了但可能还不够完美。下面分享几个优化点和进阶技巧让你的描边效果更上一层楼。4.1 解决平滑问题与背面剔除你可能会发现在模型某些边缘描边看起来有“断裂”或不平滑。这通常是因为模型顶点法线不连续硬边导致的。外扩方向在硬边两侧截然不同从而产生了缝隙。解决方案使用平滑法线或顶点色。如果模型是你自己制作的确保在建模软件中导出了平滑的法线信息。如果不可控一个替代方案是在顶点着色器中不直接使用a_normal而是尝试基于位置信息计算一个简单的“膨胀”方向但这会牺牲一些准确性。另一个常见问题是背面描边。我们的法线外扩是双向的模型背对相机的部分也会被外扩并渲染出来在某些情况下可能造成视觉错误。对于封闭实体模型可以通过在描边Pass中开启背面剔除cullMode: front来解决只渲染背面让外扩的背面可见而正面被剔除这里需要仔细思考。实际上更常见的技巧是在描边Pass中设置cullMode: front剔除正面。这样我们只渲染模型“背面”经过外扩后的部分这正好形成了包裹模型的轮廓线。这是一个非常经典和高效的技巧。在本体Pass中保持cullMode: back默认剔除背面。需要在Effect的Pass中增加渲染状态设置// 在描边Pass的properties下面添加 rasterizerState: cullMode: front // 剔除正面只渲染背面外扩的部分4.2 实现渐变描边与发光效果纯色描边有时显得生硬。我们可以修改片元着色器实现更丰富的效果。渐变描边根据片元在模型表面的位置如法线方向与视角的夹角或屏幕空间位置来混合颜色。// 在 outline-fs 的 frag 函数中修改 vec4 frag () { // 示例基于法线方向与视线方向的点积做简单渐变 vec3 viewDir normalize(camera.position - v_position); // 需要从VS传递v_position float fresnel 1.0 - max(dot(v_normal, viewDir), 0.0); fresnel pow(fresnel, 2.0); // 增加对比度 vec3 finalOutlineColor mix(outlineColor.rgb, vec3(1.0, 1.0, 0.0), fresnel); // 从outlineColor渐变到黄色 return vec4(finalOutlineColor, outlineColor.a); }发光描边模拟边缘光效。通常需要将描边Pass的混合模式改为叠加Additive并可能使用多次渲染模糊来制造光晕。这属于更高级的后处理或多Pass组合技巧实现成本较高。4.3 性能考量与移动端适配Draw Call翻倍多Pass方案最直接的代价是Draw Call增加。一个物体渲染两次Draw Call就翻倍。对于需要描边的物体数量必须心中有数。在移动端要严格控制数量。Overdraw描边Pass渲染了整个模型外扩的表面可能覆盖比原模型更多的像素导致Overdraw过度绘制增加影响填充率。在低端移动设备上需注意。精度与范围outlineWidth在视图空间计算在透视投影下距离相机越远的物体相同的outlineWidth值产生的屏幕空间描边宽度会越细。如果你需要屏幕空间固定的描边宽度就需要在顶点着色器中将外扩计算转换到裁剪空间或标准化设备坐标NDC后进行这会更复杂一些。Shader变体如果你的Effect使用了#ifdef等宏来定义不同功能分支Cocos在构建时会生成多个Shader变体。虽然这个简单案例变体不多但在复杂项目中需要关注shader variant log level all shaders这类构建日志管理变体数量避免包体膨胀和运行时切换开销。5. 常见问题排查与调试实录即使按照步骤操作你也可能会遇到一些问题。这里记录了一些我踩过的坑和解决方案。5.1 描边不显示或显示异常问题现象可能原因排查步骤与解决方案完全看不到描边1. 材质Effect未正确关联。2. 描边Pass的顶点着色器外扩计算错误顶点被缩进内部。3. 描边颜色与背景色太接近。1. 检查材质Inspector的Effect属性是否为outline.effect。2. 检查outlineWidth值是否为负或太小。尝试调大到一个明显的值如0.1。3. 在顶点着色器中使用gl_Position.y 0.1;临时测试如果模型整体移动则说明着色器基本工作问题在外扩计算。检查法线a_normal是否被正确传入和变换。4. 将outlineColor设置为一个鲜艳的颜色如红色[1,0,0,1]。描边闪烁(Z-fighting)描边层和本体层深度值过于接近深度测试精度不足导致渲染顺序随机。1. 在描边Pass的顶点着色器中对外扩后的顶点在视图空间的Z值做一个微小的向后偏移viewPos.z - 0.0001;让描边层稍微“靠后”一点。这是最常用的技巧。2. 确保本体Pass的depthWrite为false这能有效避免深度值被重写。描边内部有空洞或断裂1. 模型法线信息有问题如硬边。2.outlineWidth太大导致外扩后的背面部分在特定视角下被错误剔除。1. 检查模型资源尝试在建模软件中重新计算平滑法线。2. 尝试减小outlineWidth。3. 调整描边Pass的剔除模式尝试cullMode: none不剔除观察效果但这会渲染更多片元。只有模型一部分有描边顶点着色器中用于外扩的法线信息a_normal可能没有在所有顶点上正确启用或传递。检查模型导入设置确保勾选了“包含法线”。在Cocos Creator中检查模型节点的MeshRenderer组件确保其使用的Mesh包含法线属性。5.2 与引擎其他功能集成时的冲突与阴影的冲突描边Pass渲染的几何体通常也会投射阴影这可能产生不正确的阴影轮廓。通常的解决方案是在描边Pass的渲染状态中禁用阴影映射的写入。这需要在Effect中定义适当的渲染队列和标签或者使用Shader宏在渲染阴影时跳过描边Pass。这是一个相对高级的话题需要查阅Cocos的渲染管线文档。在UI上使用本文介绍的方法主要针对3D模型。对于2D UI Sprite原理类似但通常更简单。UI是屏幕空间元素其“外扩”可以直接在裁剪空间或屏幕空间进行无需考虑法线。你可以创建一个专门用于UI的描边Shader基于纹理的Alpha通道进行边缘检测和外扩。5.3 调试Shader的实用技巧使用简单颜色测试在片元着色器中直接返回vec4(1.0, 0.0, 0.0, 1.0)这样的纯色可以快速判断该Pass是否被执行以及片元着色器是否编译成功。可视化中间变量例如在描边片元着色器中将法线(v_normal 1.0) / 2.0转换为颜色输出可以检查法线数据是否正确从顶点着色器传递过来。利用Cocos Creator的材质预览在材质面板中实时调整参数并观察预览球的变化这是最快的迭代方式。查看渲染调试器Cocos Creator提供了强大的渲染调试工具通常通过gfx-debug或引擎内部工具。你可以查看每个Pass的渲染结果、深度缓冲区等对于诊断渲染顺序、混合、深度问题至关重要。这个从零构建Cocos描边Shader的过程本质上是一次微型的图形渲染管线之旅。它强迫你去思考顶点数据如何流动、空间如何变换、渲染状态如何影响最终像素。当你成功看到第一个自定义描边包裹住模型时那份成就感会激励你去探索更复杂的Shader世界。记住所有的复杂效果都是由这些基础技术组合而成的。掌握了这个案例你就已经拿到了通往更高级图形编程的第一张门票。