CocosEffect与Shader入门

从零开始学会CocosCreator 3.8.x的Shader编写。每个概念都有"为什么需要它"、"没有它会怎样"、生活比喻和完整代码示例。


一、Shader是什么?

"画画的配方"比喻

Shader就是GPU画画的"配方":

  • 顶点着色器 = "定位配方":告诉GPU每个顶点画在哪里
  • 片元着色器 = "上色配方":告诉GPU每个像素涂什么颜色

就像做菜有配方一样,不同的Shader配方能画出不同的效果:

  • 默认配方:正常显示图片
  • 灰度配方:把图片变灰
  • 溶解配方:让图片逐渐消失
  • 发光配方:给图片加光晕

顶点着色器 vs 片元着色器

特性 顶点着色器 片元着色器
比喻 "定位置" "上颜色"
执行次数 = 顶点数(4次/精灵) = 像素数(可达百万次)
核心任务 MVP变换,计算屏幕位置 纹理采样,计算像素颜色
输入 顶点属性(位置、UV、颜色) 插值后的顶点属性
输出 gl_Position(屏幕坐标) gl_FragColor(像素颜色)
性能影响 大(百万倍放大)

关键认知:为什么片元着色器性能影响大?

一个100×100的Sprite:
  顶点着色器:4次(4个顶点)
  片元着色器:10,000次(1万个像素)
  比例 = 1 : 2500

全屏1080P画面:
  顶点着色器:几百次
  片元着色器:2,073,600次(207万像素!)
  比例 = 1 : 10000+

→ 片元着色器的每一行代码都被放大了万倍以上!
→ 能放在顶点着色器的计算,绝不要放片元着色器

二、手把手:创建你的第一个Effect文件

这是学Shader的第一道门槛!跟着做,5分钟就能创建一个能运行的Effect文件。

步骤1:在CocosCreator中创建Effect文件

  1. 打开CocosCreator 3.8.x编辑器
  2. 资源管理器(左侧面板)中,右键点击你想存放Effect的文件夹
  3. 选择 右键 → 创建 → Effect(或"新建 → Effect文件")
  4. 命名为 my-first-effect,回车确认
  5. 双击打开,你会看到Cocos自动生成的模板代码

步骤2:将Effect关联到Sprite

  1. 在场景中创建一个Sprite节点(右键层级管理器 → 创建 → 2D对象 → Sprite)
  2. 选中Sprite节点,在属性检查器(右侧面板)中找到Sprite组件
  3. 找到 CustomMaterial 属性(在Sprite组件下方)
  4. 把刚创建的 my-first-effect 拖拽到 CustomMaterial 属性上
  5. 运行游戏,如果Sprite正常显示,说明Effect文件创建成功!

步骤3:验证Effect是否生效

修改Effect文件中的片元着色器,在main函数最后加一行:

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 强制输出红色

保存后回到编辑器,如果Sprite变成全红色,恭喜你——Effect文件已经生效了!

⚠️ 如果Sprite没有变红,检查:①CustomMaterial是否正确拖拽 ②Effect文件是否保存 ③控制台是否有编译错误

步骤4:恢复并开始自定义

验证成功后,把强制红色的那行删掉,恢复为正常的纹理采样代码。接下来我们学习Effect文件的完整结构。


三、CocosCreator中的Effect文件结构

Effect文件 = "配方手册"

一个Effect文件就像一本"配方手册",可以包含多套配方(technique),每套配方可以有多个步骤(pass)。

完整结构解析

custom-sprite.effect
│
├── CCEffect 块 ─── "配方目录"(定义属性和参数)
│   ├── techniques[] ─── "配方系列"
│   │   └── passes[] ─── "配方步骤"
│   │       ├── vert: 引用顶点着色器
│   │       ├── frag: 引用片元着色器
│   │       ├── depthStencilState ─── 深度/模板测试配置
│   │       ├── blendState ─── 混合模式配置
│   │       ├── rasterizerState ─── 光栅化配置
│   │       └── properties ─── 暴露给外部的参数
│   │           ├── alphaThreshold: { value: 0.5 }
│   │           └── u_gray: { value: 0.0 }
│
├── CCProgram sprite-vs ─── "定位配方"(顶点着色器代码)
│   ├── attribute ─── 输入的顶点属性
│   ├── uniform ─── 外部传入的统一变量
│   ├── varying ─── 传递给片元着色器的插值变量
│   └── void main() ─── 主函数
│
└── CCProgram sprite-fs ─── "上色配方"(片元着色器代码)
    ├── varying ─── 从顶点着色器接收的插值变量
    ├── uniform ─── 外部传入的统一变量
    └── void main() ─── 主函数

一个完整的自定义Effect示例

// ==========================================
// CCEffect 块:定义配方参数和渲染状态
// ==========================================
CCEffect %{
  techniques:
  - name: opaque
    passes:
    - vert: custom-sprite-vs:vert    // 引用下面的顶点着色器
      frag: custom-sprite-fs:frag    // 引用下面的片元着色器
      depthStencilState: &depthStencilState
        depthTest: false             // 2D游戏通常关闭深度测试
        depthWrite: false            // 2D游戏通常关闭深度写入
      blendState: &blendState
        targets:
        - blend: true                // 开启混合
          blendSrc: src_alpha        // 源因子
          blendDst: one_minus_src_alpha  // 目标因子
      rasterizerState:
        cullMode: none               // 不做背面剔除
      properties:                    // 暴露给TypeScript的参数
        alphaThreshold: { value: 0.5 }
        u_gray:                      // 灰度开关
          value: 0.0
          editor: { tooltip: "灰度开关 0=正常 1=灰度" }
        u_dissolve:                  // 溶解程度
          value: 0.0
          editor: { tooltip: "溶解程度 0=完整 1=完全溶解", range: [0, 1] }
}%

// ==========================================
// CCProgram 顶点着色器:定位配方
// ==========================================
CCProgram custom-sprite-vs %{
  precision highp float;

  // 输入属性(从VBO读取)
  attribute vec3 a_position;    // 顶点位置
  attribute vec2 a_uv;          // UV坐标
  attribute vec4 a_color;       // 顶点颜色

  // Uniform变量(从CPU传入)
  uniform mat4 u_mvp;           // MVP矩阵

  // 输出给片元着色器(会自动插值)
  varying vec2 v_uv;
  varying vec4 v_color;

  void main () {
    v_uv = a_uv;                    // 传递UV
    v_color = a_color;              // 传递颜色
    gl_Position = u_mvp * vec4(a_position, 1.0);  // MVP变换
  }
}%

// ==========================================
// CCProgram 片元着色器:上色配方
// ==========================================
CCProgram custom-sprite-fs %{
  precision highp float;

  // 从顶点着色器接收(已插值)
  varying vec2 v_uv;
  varying vec4 v_color;

  // Uniform变量
  uniform sampler2D u_texture;   // 纹理
  uniform float u_gray;          // 灰度开关
  uniform float u_dissolve;      // 溶解程度

  void main () {
    // 1. 采样纹理
    vec4 color = v_color * texture2D(u_texture, v_uv);

    // 2. 灰度效果
    if (u_gray > 0.5) {
      float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      color.rgb = vec3(gray);
    }

    // 3. 溶解效果
    if (u_dissolve > 0.0) {
      float noise = fract(sin(dot(v_uv, vec2(12.9898, 78.233))) * 43758.5453);
      if (noise < u_dissolve) {
        discard;  // 丢弃片元(透明)
      }
      // 边缘发光
      float edge = smoothstep(u_dissolve - 0.05, u_dissolve, noise);
      color.rgb += vec3(1.0, 0.3, 0.0) * (1.0 - edge) * 2.0;
    }

    gl_FragColor = color;
  }
}%

关键概念详解

Uniform变量 —— "外部参数"

// 在TypeScript中设置Uniform
const mat = sprite.getMaterial(0);
mat.setProperty('u_gray', 1.0);       // 开启灰度
mat.setProperty('u_dissolve', 0.5);    // 溶解50%

Uniform就像函数的参数——Shader定义了"公式",Uniform是代入公式的"具体数值"。

varying变量 —— "顶点到片元的桥梁"

顶点着色器输出 varying → GPU自动插值 → 片元着色器接收 varying

例如:三角形3个顶点颜色分别是红、绿、蓝
→ GPU自动插值 → 三角形内部颜色平滑过渡

sampler2D —— "纹理采样器"

uniform sampler2D u_texture;  // 声明纹理变量
vec4 color = texture2D(u_texture, v_uv);  // 采样:从纹理的v_uv位置取颜色

四、CocosCreator 3.8.x内置Shader解析

sprite内置shader源码解读

CocosCreator 3.8.x的内置sprite shader做了以下事情:

// 顶点着色器(简化版)
void main() {
    v_uv = a_uv;                                    // 传递UV
    v_color = a_color;                              // 传递颜色
    gl_Position = u_mvp * vec4(a_position, 1.0);   // MVP变换
}

// 片元着色器(简化版)
void main() {
    vec4 color = v_color * CCSampleWithAlphaSeparated(u_texture, v_uv);
    // CCSampleWithAlphaSeparated = 预乘Alpha采样
    // 避免半透明边缘出现黑边

    #if USE_ALPHA_TEST
    if (color.a < alphaThreshold) discard;           // Alpha测试
    #endif

    gl_FragColor = color;
}

内置Shader的关键特性

  1. 预乘AlphaCCSampleWithAlphaSeparated处理了预乘Alpha,避免黑边
  2. Alpha Test:可通过USE_ALPHA_TEST宏开关
  3. SRGB处理:自动处理颜色空间转换

ui-sprite-effect解读

ui-sprite-effect是Cocos内置的带特效Sprite Shader:

// 支持的效果:
// 1. 灰度 (Grayscale)
// 2. 颜色叠加 (Tint)
// 3. 闪烁 (Flash)

五、常见Shader编译错误及解决方法

初学者写Shader必然会遇到编译错误,不要慌!以下是最高频的错误和解决方案。

错误信息 原因 解决方法
ERROR: 0:XX: 'xxx' : undeclared identifier 变量未声明 检查拼写,确认变量已用uniform/varying/attribute声明
ERROR: 0:XX: 'xxx' : syntax error 语法错误 检查是否漏了分号;、大括号{}是否匹配
ERROR: 0:XX: 'varying' : declaration must precede statement varying声明位置不对 GLSL要求所有声明在函数之前,不能放在函数中间
ERROR: 0:XX: 'precision' : illegal use of reserved word precision声明位置不对 precision必须在CCProgram块的最开头
WARNING: 0:XX: 'xxx' : varying not read by fragment shader 顶点着色器输出了varying但片元着色器没接收 检查顶点和片元着色器的varying声明是否一致
ERROR: Cannot find program xxx Effect中引用的CCProgram名称不存在 检查vert: xxx-vs:vert中的名称是否和CCProgram名称匹配
ERROR: xxx: property not found TypeScript设置了不存在的Uniform 检查CCEffect的properties中是否声明了该参数
黑屏/全透明 Shader编译通过但逻辑错误 用调试技巧:强制输出红色gl_FragColor = vec4(1,0,0,1)排查

Cocos内置Shader函数速查

函数名 用途 示例
CCSampleWithAlphaSeparated(sampler, uv) 预乘Alpha纹理采样,避免黑边 vec4 c = CCSampleWithAlphaSeparated(u_texture, v_uv)
CCDecode(sampler, uv) 解码纹理(ETC/ASTC压缩格式) 配合CCSampleWithAlphaSeparated使用
CCRandom(v) 伪随机数生成 float r = CCRandom(vec2(12.98, 78.23))
unpackAlphaFromShared(sampler, uv) 从共享Alpha通道提取Alpha 用于Alpha分离纹理

💡 这些内置函数定义在Cocos引擎的chunk文件中。你可以在引擎目录 cocos/renderer/core/ 下找到它们。


六、自定义Shader实战

实战1:自定义灰度Shader

// gray-sprite.effect
CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: gray-fs:frag
      properties:
        u_grayAmount:
          value: 0.0
          editor: { range: [0, 1] }
}%

CCProgram sprite-vs %{
  precision highp float;
  attribute vec3 a_position;
  attribute vec2 a_uv;
  attribute vec4 a_color;
  uniform mat4 u_mvp;
  varying vec2 v_uv;
  varying vec4 v_color;
  void main() {
    v_uv = a_uv;
    v_color = a_color;
    gl_Position = u_mvp * vec4(a_position, 1.0);
  }
}%

CCProgram gray-fs %{
  precision highp float;
  varying vec2 v_uv;
  varying vec4 v_color;
  uniform sampler2D u_texture;
  uniform float u_grayAmount;

  void main() {
    vec4 color = v_color * texture2D(u_texture, v_uv);

    // 灰度混合:normal和gray之间插值
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    color.rgb = mix(color.rgb, vec3(gray), u_grayAmount);

    gl_FragColor = color;
  }
}%
// 使用灰度Shader
@ccclass('GrayEffect')
export class GrayEffect extends Component {
    @property(Sprite)
    sprite: Sprite = null;

    @property(Material)
    grayMaterial: Material = null;

    setGray(isGray: boolean) {
        if (isGray) {
            this.sprite.material = this.grayMaterial;
        } else {
            this.sprite.material = this.sprite.sharedMaterials[0];  // 恢复默认材质
        }
    }

    // 渐变灰度
    tweenGray(duration: number) {
        const mat = this.grayMaterial;
        tween(mat)
            .to(duration, { u_grayAmount: 1.0 })
            .start();
    }
}

实战2:自定义溶解Shader

// dissolve-sprite.effect
CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: dissolve-fs:frag
      properties:
        u_dissolveAmount:
          value: 0.0
          editor: { range: [0, 1] }
        u_edgeWidth:
          value: 0.05
          editor: { range: [0, 0.2] }
        u_edgeColor:
          value: { r: 1.0, g: 0.3, b: 0.0, a: 1.0 }
}%

CCProgram sprite-vs %{
  precision highp float;
  attribute vec3 a_position;
  attribute vec2 a_uv;
  attribute vec4 a_color;
  uniform mat4 u_mvp;
  varying vec2 v_uv;
  varying vec4 v_color;
  void main() {
    v_uv = a_uv;
    v_color = a_color;
    gl_Position = u_mvp * vec4(a_position, 1.0);
  }
}%

CCProgram dissolve-fs %{
  precision highp float;
  varying vec2 v_uv;
  varying vec4 v_color;
  uniform sampler2D u_texture;
  uniform float u_dissolveAmount;
  uniform float u_edgeWidth;
  uniform vec4 u_edgeColor;

  // 简单的噪声函数(不需要额外纹理)
  float random(vec2 st) {
    return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
  }

  void main() {
    vec4 color = v_color * texture2D(u_texture, v_uv);

    float noise = random(v_uv);

    // 溶解:噪声值小于溶解量的片元被丢弃
    if (noise < u_dissolveAmount) {
      discard;
    }

    // 边缘发光效果
    float edgeStart = u_dissolveAmount;
    float edgeEnd = u_dissolveAmount + u_edgeWidth;
    if (noise < edgeEnd) {
      float edgeFactor = 1.0 - (noise - edgeStart) / u_edgeWidth;
      color.rgb = mix(color.rgb, u_edgeColor.rgb, edgeFactor);
    }

    gl_FragColor = color;
  }
}%
// 使用溶解Shader
@ccclass('DissolveEffect')
export class DissolveEffect extends Component {
    @property(Sprite)
    sprite: Sprite = null;

    @property(Material)
    dissolveMaterial: Material = null;

    playDissolve(duration: number = 1.0) {
        this.sprite.material = this.dissolveMaterial;
        const mat = this.dissolveMaterial;
        mat.setProperty('u_dissolveAmount', 0.0);

        tween({ value: 0.0 })
            .to(duration, { value: 1.0 }, {
                onUpdate: (target) => {
                    mat.setProperty('u_dissolveAmount', target.value);
                }
            })
            .start();
    }
}

实战3:通过Shader合并不同纹理的渲染

核心思路:把多张小图合并到一张大纹理,通过Uniform传递UV偏移,让不同"图片"的Sprite用同一个纹理 → 可以合批!

// atlas-selector.effect - 通过UV偏移选择图集中的子图
CCEffect %{
  techniques:
  - passes:
    - vert: atlas-vs:vert
      frag: atlas-fs:frag
      properties:
        u_uvOffset:
          value: { x: 0.0, y: 0.0 }
        u_uvScale:
          value: { x: 0.25, y: 0.25 }
}%

CCProgram atlas-vs %{
  precision highp float;
  attribute vec3 a_position;
  attribute vec2 a_uv;
  attribute vec4 a_color;
  uniform mat4 u_mvp;
  varying vec2 v_uv;
  varying vec4 v_color;
  void main() {
    v_uv = a_uv;
    v_color = a_color;
    gl_Position = u_mvp * vec4(a_position, 1.0);
  }
}%

CCProgram atlas-fs %{
  precision highp float;
  varying vec2 v_uv;
  varying vec4 v_color;
  uniform sampler2D u_texture;
  uniform vec2 u_uvOffset;
  uniform vec2 u_uvScale;

  void main() {
    // UV变换:从大图集中选择子区域
    vec2 uv = v_uv * u_uvScale + u_uvOffset;
    vec4 color = v_color * texture2D(u_texture, uv);
    gl_FragColor = color;
  }
}%
// 使用图集选择Shader
@ccclass('AtlasSelector')
export class AtlasSelector extends Component {
    @property(Sprite)
    sprite: Sprite = null;

    // 4×4图集,每个子图占1/4
    private readonly COLS = 4;
    private readonly ROWS = 4;

    selectSubImage(col: number, row: number) {
        const mat = this.sprite.getMaterial(0);
        const scaleX = 1.0 / this.COLS;
        const scaleY = 1.0 / this.ROWS;
        const offsetX = col * scaleX;
        const offsetY = row * scaleY;

        mat.setProperty('u_uvScale', { x: scaleX, y: scaleY });
        mat.setProperty('u_uvOffset', { x: offsetX, y: offsetY });
    }
}

为什么这能减少DrawCall?

原来:10个Sprite用10张不同小图 → 10个纹理 → 10个DrawCall
现在:10个Sprite用同一张大图 + 不同UV偏移 → 1个纹理 → 1个DrawCall!

所有Sprite使用同一个材质(同一个Effect + 同一张纹理)
→ 满足合批条件 → 自动合批 → 1个DrawCall搞定!

七、如何通过修改Shader减少DrawCall

原理回顾

DrawCall切换的原因:

纹理不同 → 新DrawCall
材质不同 → 新DrawCall
Shader不同 → 新DrawCall
混合模式不同 → 新DrawCall

Shader优化的核心:把"需要切换材质/纹理"的差异,变成"Uniform参数"的差异。

原来:正常Sprite用材质A,灰度Sprite用材质B → 2个DrawCall
现在:所有Sprite用材质A(包含灰度Uniform)→ 1个DrawCall
      正常的:u_gray = 0.0
      灰度的:u_gray = 1.0

注意:修改Uniform是否会打断合批?

关键问题:如果两个Sprite用同一个Material,但设置了不同的Uniform值,会打断合批吗?

答案:会!因为Material是共享的,修改Uniform会影响所有使用该Material的Sprite。

解决方案:使用Material实例化!

// ❌ 错误:直接修改共享材质,会影响所有Sprite
const mat = sprite.sharedMaterials[0];
mat.setProperty('u_gray', 1.0);  // 所有用这个材质的Sprite都变灰!

// ✅ 正确:使用材质实例
const matInstance = sprite.getMaterial(0);  // 获取实例
matInstance.setProperty('u_gray', 1.0);     // 只影响当前Sprite

但是! 实例化材质后,每个Sprite的材质引用不同 → 不满足合批条件 → 打断合批!

终极解决方案:利用顶点颜色传递参数!

这是最关键的技巧:用顶点颜色传递Shader参数,所有Sprite共享同一个材质 → 不打断合批!

顶点颜色传参:完整端到端示例

核心思路:顶点颜色是每个Sprite独有的,不需要实例化材质。在Shader中读取顶点颜色的某个通道,根据通道值决定渲染效果。

第1步:编写支持顶点颜色传参的Effect

// gray-by-alpha.effect
CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: gray-fs:frag
      properties:
        alphaThreshold: { value: 0.5 }
}%

CCProgram sprite-vs %{
  precision highp float;
  attribute vec3 a_position;
  attribute vec2 a_uv;
  attribute vec4 a_color;
  uniform mat4 u_mvp;
  varying vec2 v_uv;
  varying vec4 v_color;
  void main() {
    v_uv = a_uv;
    v_color = a_color;
    gl_Position = u_mvp * vec4(a_position, 1.0);
  }
}%

CCProgram gray-fs %{
  precision highp float;
  varying vec2 v_uv;
  varying vec4 v_color;
  uniform sampler2D u_texture;

  void main() {
    vec4 color = v_color * texture2D(u_texture, v_uv);

    // alpha通道 < 0.98 表示灰度(对应TypeScript中alpha=200/255≈0.78)
    if (v_color.a < 0.98) {
      float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      color.rgb = vec3(gray);
    }

    // 恢复alpha为1.0(因为我们用alpha传参,不是真的半透明)
    color.a = 1.0;

    gl_FragColor = color;
  }
}%

第2步:在TypeScript中设置顶点颜色

import { _decorator, Component, Sprite, Color } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('GrayByAlpha')
export class GrayByAlpha extends Component {
    @property(Sprite)
    sprite: Sprite = null;

    setGray(isGray: boolean) {
        const color = this.sprite.color;
        this.sprite.color = new Color(
            color.r,
            color.g,
            color.b,
            isGray ? 200 : 255  // alpha=200表示灰度,alpha=255表示正常
        );
    }
}

第3步:验证合批成功

  1. 创建3个Sprite,都使用同一个Effect材质
  2. 第1个Sprite设置 alpha=200(灰度)
  3. 第2个Sprite设置 alpha=255(正常)
  4. 第3个Sprite设置 alpha=200(灰度)
  5. 开启DisplayStats查看DrawCall
如果DrawCall = 1 → 合批成功!✅
如果DrawCall = 3 → 合批失败,检查材质是否共享

为什么这能合批?

3个Sprite使用同一个材质(同一个Effect + 同一张纹理)
→ 满足合批条件(hash相同、material相同、depthStencilState相同)
→ 自动合批 → 1个DrawCall!

区别只是顶点颜色不同(alpha=200 vs alpha=255)
→ 顶点颜色是VBO数据的一部分,不影响合批判断
→ 就像两个三角形颜色不同也能合批一样

注意事项

  • 这种方式"借用"了alpha通道,意味着Sprite不能同时是半透明的
  • 如果需要同时支持半透明和灰度,可以用RGB通道的某个位来传参(如R通道的最低位)
  • 顶点颜色传参的"带宽"有限,只能传递简单的开关或少量枚举值

八、Shader优化技巧

1. 使用lowp精度

// ❌ 不好:所有变量用highp(32位浮点)
precision highp float;
varying vec2 v_uv;

// ✅ 好:颜色和UV用lowp(8位浮点),节省GPU计算
precision lowp float;       // 默认精度
varying lowp vec2 v_uv;     // UV坐标不需要高精度
varying lowp vec4 v_color;  // 颜色不需要高精度
uniform lowp sampler2D u_texture;

2. 减少纹理采样

// ❌ 不好:多次采样
vec4 c1 = texture2D(u_texture, v_uv);
vec4 c2 = texture2D(u_texture, v_uv + vec2(0.01, 0.0));
vec4 c3 = texture2D(u_texture, v_uv + vec2(-0.01, 0.0));
vec4 blur = (c1 + c2 + c3) / 3.0;

// ✅ 好:用数学近似代替多次采样
vec4 c1 = texture2D(u_texture, v_uv);
// 如果只需要简单的模糊效果,可以在顶点着色器中偏移UV

3. 避免分支

// ❌ 不好:if分支(GPU并行执行,分支导致部分核心空转)
if (u_gray > 0.5) {
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    color.rgb = vec3(gray);
}

// ✅ 好:用数学函数代替分支
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(color.rgb, vec3(gray), step(0.5, u_gray));
// step(0.5, u_gray) = u_gray >= 0.5 ? 1.0 : 0.0
// mix(a, b, t) = a * (1-t) + b * t

4. 把计算从片元着色器移到顶点着色器

// ❌ 不好:在片元着色器中计算(执行百万次)
void main() {
    float wave = sin(v_uv.x * 10.0 + u_time) * 0.01;
    vec2 uv = vec2(v_uv.x + wave, v_uv.y);
    gl_FragColor = texture2D(u_texture, uv);
}

// ✅ 好:在顶点着色器中计算(只执行4次)
void main() {
    v_uv = a_uv;
    float wave = sin(a_uv.x * 10.0 + u_time) * 0.01;
    vec2 uv = vec2(a_uv.x + wave, a_uv.y);
    v_uv = uv;  // 传递偏移后的UV
    gl_Position = u_mvp * vec4(a_position, 1.0);
}

九、Shader调试技巧

Shader没有断点,没有console.log,调试只能靠"看颜色":

// 调试技巧1:把变量映射到颜色
void main() {
    float value = someCalculation();
    // 把value映射到0-1范围,作为红色通道输出
    gl_FragColor = vec4(value, 0.0, 0.0, 1.0);
}

// 调试技巧2:用颜色区分分支
void main() {
    if (someCondition) {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 红色 = 条件成立
    } else {
        gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0);  // 蓝色 = 条件不成立
    }
}

// 调试技巧3:可视化UV坐标
void main() {
    gl_FragColor = vec4(v_uv.x, v_uv.y, 0.0, 1.0);
    // 左下角黑色(0,0),右下角红色(1,0),左上角绿色(0,1),右上角黄色(1,1)
}

十、深度测试、模板测试与Alpha混合(Cocos Effect配置速查)

💡 深度测试、模板测试、Alpha混合的完整原理和详细讲解,请参考 01-GPU渲染管线入门。本节只补充 Cocos Effect中的配置方法

深度测试配置

CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: sprite-fs:frag
      depthStencilState:
        depthTest: false      // 2D游戏通常关闭
        depthWrite: false     // 2D游戏通常关闭
}%
配置 2D游戏推荐 3D不透明物体 3D透明物体
depthTest false true true
depthWrite false true false

模板测试配置(Mask实现原理)

depthStencilState:
  stencilTestFront:
    stencilTest: true
    stencilFunc: equal       // 比较函数
    stencilRef: 1            // 参考值
    stencilReadMask: 0xFF
    stencilPassOp: replace
    stencilFailOp: keep
    stencilZFailOp: keep

⚠️ 每个Mask = +2 DrawCall,嵌套Mask = +4 DrawCall。尽量减少Mask使用。

Alpha混合配置

CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: sprite-fs:frag
      blendState:
        targets:
        - blend: true
          blendSrc: src_alpha
          blendDst: one_minus_src_alpha
}%
混合模式 srcFactor dstFactor 适用场景
正常透明 SRC_ALPHA ONE_MINUS_SRC_ALPHA 大部分半透明UI
叠加 SRC_ALPHA ONE 光效、火焰
正片叠底 DST_COLOR ZERO 阴影

踩坑速查

  1. 透明物体必须关闭depthWrite,否则遮挡后面的透明物体
  2. 透明物体必须从后往前渲染,否则混合结果不对
  3. 不要在片元着色器中写gl_FragDepth,会禁用Early-Z
  4. 每个Mask = +2 DrawCall,嵌套Mask = +4 DrawCall
  5. 如果只需要矩形裁剪,用Scissor Test(零开销),不要用Mask

十一、动手练习

练习1:创建一个闪白Shader

参考灰度Shader,创建一个闪白Shader:当u_flashAmount = 1.0时,Sprite全白;u_flashAmount = 0.0时,正常显示。

点击查看提示

在片元着色器中,用mix函数混合原色和白色:

vec4 white = vec4(1.0, 1.0, 1.0, color.a);
color = mix(color, white, u_flashAmount);

练习2:用顶点颜色控制闪白

把练习1的闪白Shader改为用顶点颜色alpha通道控制,实现不打断合批的闪白效果。

练习3:调试UV坐标

在片元着色器中输出UV坐标作为颜色:gl_FragColor = vec4(v_uv.x, v_uv.y, 0.0, 1.0),观察颜色分布,理解UV坐标的含义。