Shader 深度入门:从数学原理到引擎实践

对应月影课程:第 12~18 章(着色器基础、Uniform 与纹理采样、多 Pass 渲染)

目标:掌握 Shader 的数学推导、GPU 硬件执行原理、以及 Cocos Creator 3.8.x 中的完整使用方式。


前置知识:你需要先知道这些

在深入 Shader 之前,让我们先建立几个基础概念。如果你已经熟悉这些内容,可以快速跳过。

什么是渲染管线

想象你是一位画家,要在画布上画出一幅风景画。你不会直接把颜料泼到画布上,而是有步骤的:

  1. 先构图(确定物体在哪里)
  2. 打草稿(画出轮廓)
  3. 上色(填充颜色)
  4. 加细节(高光、阴影)

图形渲染管线就是 GPU 画画的"步骤流程"。Shader 是其中两个关键步骤的"自定义画笔"。

为什么需要学 Shader

如果不懂 Shader 会怎样?

  • 你只能使用引擎提供的默认材质,做不出特殊效果
  • 看到别人的"水面波纹"、"溶解消失"、"边缘发光"效果,完全不知道怎么做
  • 性能优化时无从下手,不知道为什么帧率低
  • 面试时被问到渲染原理,一问三不知

学会 Shader 后你能做什么:

  • 自定义任何视觉效果(水、火、电、光、影)
  • 理解 GPU 工作原理,写出高性能代码
  • 看懂并修改引擎源码
  • 在渲染优化上有独立判断力

向量和矩阵是什么(极简版)

向量:有方向的量。比如"向东走 5 米"就是一个向量。

  • vec2 = 二维向量 (x, y),比如屏幕坐标
  • vec3 = 三维向量 (x, y, z),比如空间位置、颜色 (R, G, B)
  • vec4 = 四维向量 (x, y, z, w),比如齐次坐标、颜色 (R, G, B, A)

矩阵:一种变换工具。就像"缩放 + 旋转 + 平移"打包成一个数学工具。

  • mat4 = 4×4 矩阵,是 3D 图形中最常用的变换矩阵

类比:向量是"快递包裹",矩阵是"物流路线"。包裹(向量)经过路线(矩阵)的运输,就从发货地(模型空间)到达了收货地(屏幕空间)。


一、Shader 的本质:一段在 GPU 上并行执行的程序

1.1 一句话定义

Shader(着色器)是在 GPU 上运行的、对每一个顶点/片元独立执行的小程序。它的核心特征是SIMD(Single Instruction Multiple Data)——同一条指令同时作用于成千上万个数据单元。

术语解释:SIMD SIMD = Single Instruction Multiple Data(单指令多数据)。 类比:一位老师(指令)同时给 1000 个学生(数据)布置同样的作业。所有学生同时做同样的题目,只是每个人用的数据不同。 与之相对的是 CPU 的 SISD(单指令单数据):一位老师一次只给一个学生讲题。

1.2 为什么叫"着色器"

早期 GPU 的唯一功能就是为像素"着色"(计算颜色)。现代 GPU 中,Shader 已经演变为通用并行计算单元,但名称保留了下来。在图形管线中,Shader 主要出现在两个阶段:

  • Vertex Shader(顶点着色器):处理顶点数据(位置、颜色、UV、法线等)
  • Fragment Shader(OpenGL ES 术语)/ Pixel Shader(DirectX 术语)(片元着色器):处理片元(候选像素)的颜色输出

Cocos Creator 3.8.x 使用 WebGL 2.0 / OpenGL ES 3.0 后端,因此采用 Vertex Shader + Fragment Shader 的术语体系。

1.3 GPU 执行模型:从 CPU 代码到 GPU 并行

CPU 串行执行:
  for (let i = 0; i < 10000; i++) {
      process(vertex[i]);  // 一次处理一个
  }
  // 耗时:10000 × T

GPU 并行执行:
  process(vertex[thread_id]);  // 10000 个线程同时执行
  // 耗时:≈ T(假设有足够 SM)

GPU 将 Shader 程序加载到**流式多处理器(Streaming Multiprocessor, SM)**上,每个 SM 包含多个 CUDA Core(或 ALU),可以同时执行多个线程。

GPU 架构 SM 数量 每 SM CUDA Core 总 CUDA Core
GTX 1050 (Pascal) 5 128 640
RTX 2060 (Turing) 30 64 1920
RTX 3060 (Ampere) 28 128 3584
Apple M1 (GPU) 8 ~128 ~1024

移动端 GPU(如 Mali、Adreno)SM 数量较少,但每个 SM 的线程并发数更高,以隐藏内存延迟。


二、GLSL 语法精要:Cocos 使用的着色语言

2.1 为什么需要 GLSL

如果不懂 GLSL 会怎样?

  • 你看不懂任何 Shader 代码,包括网上找到的效果代码
  • 无法调试渲染问题,因为错误信息全是 GLSL 编译错误
  • 无法和图形程序员交流,因为你们说的不是同一种语言

GLSL 是什么:GLSL = OpenGL Shading Language,是专门写给 GPU 执行的编程语言。它长得像 C 语言,但有自己的特殊规则。

2.2 基本结构(逐行注释版)

// ============================================
// 顶点着色器(Vertex Shader)
// 作用:处理每个顶点的位置,决定顶点在屏幕上的哪里
// 执行次数:每个顶点执行一次(1000 个顶点 = 执行 1000 次)
// ============================================

// attribute:顶点属性,从 CPU/VBO 传入的数据
// vec3:三维向量(x, y, z)
// a_position:变量名,a_ 前缀表示 attribute
attribute vec3 a_position;      // 顶点属性:从 VBO 传入

// vec2:二维向量(x, y),这里存储纹理坐标
attribute vec2 a_uv0;           // UV 坐标

// varying:插值变量,用于从顶点着色器传数据到片元着色器
// 光栅化阶段会自动对 varying 进行插值
// 比如三角形的三个顶点颜色不同,varying 会让内部像素平滑过渡
varying vec2 v_uv;              // 插值变量:VS → FS

// uniform:全局常量,所有顶点/片元看到的值都一样
// mat4:4×4 矩阵,用于空间变换
// cc_matViewProj:Cocos 内置的 视图投影矩阵
uniform mat4 cc_matViewProj;    // uniform:全局常量

// main():着色器的入口函数,必须有,且没有返回值
void main() {
    // vec4(a_position, 1.0):将 vec3 升级为 vec4
    // 第四个分量是 w(齐次坐标),1.0 表示这是一个点(不是方向向量)
    // cc_matViewProj * vec4(...):矩阵乘法,将模型坐标变换到裁剪空间
    // gl_Position:GLSL 内置变量,必须赋值,表示顶点在裁剪空间的位置
    gl_Position = cc_matViewProj * vec4(a_position, 1.0);
    
    // 将 UV 坐标直接传给片元着色器
    // 经过光栅化插值后,三角形内部的片元会得到平滑的 UV 值
    v_uv = a_uv0;
}

// ============================================
// 片元着色器(Fragment Shader)
// 作用:计算每个像素的颜色
// 执行次数:每个片元(候选像素)执行一次(1920×1080 屏幕 = 约 200 万次)
// ============================================

// precision:精度限定符,告诉 GPU 用多少位存储浮点数
// mediump:中精度(16-bit),移动端推荐,性能和精度的平衡
// float:浮点数类型
precision mediump float;        // 精度限定符(移动端必须)

// varying:与 VS 中同名,自动接收插值后的值
varying vec2 v_uv;              // 与 VS 中同名,自动插值

// sampler2D:纹理采样器,代表一张 2D 图片
uniform sampler2D texture;      // 纹理采样器

void main() {
    // texture2D():纹理采样函数,根据 UV 坐标从图片中取颜色
    // 参数 1:纹理采样器(哪张图)
    // 参数 2:UV 坐标(取图的哪个位置)
    // 返回值:vec4,表示 (R, G, B, A) 颜色
    gl_FragColor = texture2D(texture, v_uv);
    
    // gl_FragColor:GLSL 内置变量,表示该片元的最终颜色
}

2.3 存储限定符详解

限定符 作用域 可写性 数据来源 典型用途
attribute 顶点着色器只读 VS 内只读 VBO / 顶点数据 顶点位置、UV、法线、颜色
varying 顶点→片元传递 VS 写,FS 读 光栅化插值 UV、法线、颜色插值
uniform 全局常量 只读 CPU 通过 API 设置 MVP 矩阵、颜色参数、时间
const 编译期常量 只读 编译器内联 数学常量、固定数组大小

术语解释:attribute attribute(属性)是 CPU 传给 GPU 的顶点数据。每个顶点有自己独立的 attribute 值。 类比:每个学生(顶点)有自己的学号、姓名、成绩。attribute 就是每个学生的"个人档案"。

术语解释:varying varying(易变量)用于在顶点着色器和片元着色器之间传递数据。 类比:三个顶点分别是红色、绿色、蓝色。三角形内部的颜色不是突变的,而是从红到绿到蓝平滑过渡。这个"平滑过渡"就是插值,varying 就是承载插值结果的变量。

术语解释:uniform uniform(统一变量)是所有线程共享的只读数据。 类比:全校广播通知。校长(CPU)通过广播(uniform)向所有学生(线程)发通知,所有学生听到的是一模一样的内容。

WebGL 2.0 / GLSL ES 3.0 变化

  • attributein
  • varyingin / out
  • texture2Dtexture
  • 新增 uniform block / buffer 支持

2.4 精度限定符(Precision Qualifier)

precision highp float;      // 高精度:float 32-bit,范围大,性能差
precision mediump float;    // 中精度:float 16-bit,平衡选择
precision lowp float;       // 低精度:float 10-bit,性能最好
精度 浮点范围 典型用途 性能影响
highp -2^62 ~ 2^62 顶点位置、MVP 矩阵 移动端慢 20~50%
mediump -2^14 ~ 2^14 UV 坐标、颜色、普通纹理采样 推荐默认值
lowp -2.0 ~ 2.0 纯色、简单混合、8-bit 纹理 最快,但易溢出

陷阱:在片元着色器中使用 highp 可能导致部分低端机无法编译( Mali-400 等)。Cocos 默认对片元着色器使用 mediump


三、Uniform 的硬件原理:为什么它"存在"

3.1 用户原始疑问的解答

用户明确要求解释"每个 uniform 为什么存在"——这不是语法问题,而是 GPU 硬件架构问题。

3.2 为什么需要 Uniform:一个思想实验

假设我们要渲染一个包含 100 万个顶点的场景,每个顶点都需要 MVP 矩阵来变换位置。

方案 A:每个顶点自带矩阵(不用 Uniform)

每个顶点数据 = 位置(12字节) + MVP矩阵(64字节) = 76字节
100万顶点 = 76MB 数据
GPU 读取:每个线程从显存读取 76 字节
结果:显存带宽爆炸,帧率暴跌

方案 B:用 Uniform(实际做法)

每个顶点数据 = 位置(12字节) = 12字节
MVP矩阵 = 64字节,放在 Uniform,只存一份
100万顶点 = 12MB + 64B ≈ 12MB
GPU 读取:每个线程读 12 字节位置 + 共享读 64 字节矩阵
结果:带宽减少 84%,帧率正常

类比:Uniform 就像课堂上的投影仪。老师(CPU)把课件(uniform 数据)投到屏幕上,全班 50 个学生(50 个线程)同时看同一份课件。如果不用投影仪,每个学生人手一份打印件,那得多浪费纸张(显存带宽)!

3.3 Uniform 的物理位置

CPU 内存(RAM)
    ↓ glUniform* / glBufferSubData
GPU 显存(VRAM)
    ├─ Uniform Buffer / Constant Buffer
    │   └─ 所有 Shader 线程共享同一份数据
    ├─ Texture Memory
    └─ Vertex Buffer / Index Buffer

GPU 芯片内
    ├─ L2 Cache(Uniform 数据通常缓存于此)
    ├─ Shared Memory(线程组内共享)
    └─ Register File(每个线程私有)

Uniform 数据从 CPU 写入 GPU 的常量缓冲区(Constant Buffer / Uniform Buffer),该缓冲区位于显存中,但会被缓存到 GPU 芯片内的 L2 Cache,供所有执行单元快速读取。

3.4 Uniform 与 Varying 的本质区别

特性 Uniform Varying
每个线程的值 完全相同 各不相同(插值后)
存储位置 常量缓冲区(共享) 寄存器(每个线程私有)
修改频率 每 DrawCall 一次 每顶点/片元自动生成
带宽消耗 极低(广播一次) 较高(每个片元都不同)
典型数据量 几百字节 ~ 几 KB 每个顶点 16~64 字节

关键洞察:Uniform 的存在是因为 GPU 需要一种所有线程共享的只读数据机制。如果每个线程都需要自己的 MVP 矩阵,那 100 万个线程就要 100 万份矩阵,显存和带宽都会爆炸。

3.5 Uniform Buffer Object (UBO) vs 传统 Uniform

// 传统方式:每个 uniform 单独设置(WebGL 1.0)
uniform mat4 u_viewProj;
uniform vec4 u_color;
uniform float u_time;

// UBO 方式:结构体批量传输(WebGL 2.0 / ES 3.0)
layout(std140) uniform MyUBO {
    mat4 u_viewProj;
    vec4 u_color;
    float u_time;
    float _padding[3];  // std140 对齐要求
};
特性 传统 Uniform UBO
设置方式 glUniform* 逐个设置 glBufferSubData 批量更新
CPU 开销 高(多次 API 调用) 低(一次内存拷贝)
大小限制 通常 256~1024 个分量 通常 16KB~64KB
切换开销 需要绑定 Buffer 对象
Cocos 支持 所有版本 3.x 默认使用

Cocos Creator 3.8.x 默认使用 UBO 管理 Uniform 数据,通过 gfx::DescriptorSet 绑定到 Shader。

3.6 Cocos 内置 Uniform 全解析

Cocos 在 cc-global / cc-local / cc-environment 等 UBO 中预定义了大量 Uniform:

Uniform 名称 类型 存在原因 更新频率
cc_matView mat4 视图矩阵:将世界坐标转为相机坐标 每相机每帧
cc_matProj mat4 投影矩阵:定义视锥体 相机参数变化时
cc_matViewProj mat4 VP 矩阵:世界 → 裁剪空间 每相机每帧
cc_matWorld mat4 模型矩阵:局部 → 世界空间 每节点每帧
cc_mainLitDir vec4 主光源方向 每帧
cc_mainLitColor vec4 主光源颜色 + 强度 每帧
cc_ambientSky vec4 环境光(天空) 每帧
cc_fogColor vec4 雾效颜色 每帧
cc_fogBase vec4 雾效密度参数 每帧
cc_nearFar vec4 相机近/远裁剪面 相机变化时
cc_viewPort vec4 视口大小 窗口变化时
cc_time vec4 时间(x=总时间, y=delta, z=帧数) 每帧
cc_screenSize vec4 屏幕尺寸(xy=尺寸, zw=1/尺寸) 窗口变化时

为什么 cc_time 是 vec4 而非 float? 因为 GPU 读取 vec4 和 float 的指令周期相同(都是 128-bit 读取),打包四个时间相关量(总时间、deltaTime、帧数、sinTime)可以一次读取,减少 Uniform 访问次数。


四、深度测试(Depth Test):从数学到硬件

4.1 深度测试的数学原理

4.1.1 深度值的生成

模型空间顶点 P_model = (x, y, z, 1)
    ↓ × modelMatrix
世界空间 P_world = (x_w, y_w, z_w, 1)
    ↓ × viewMatrix
观察空间 P_view = (x_v, y_v, z_v, 1)
    ↓ × projectionMatrix
裁剪空间 P_clip = (x_c, y_c, z_c, w_c)
    ↓ 透视除法(Perspective Divide)
NDC 空间 P_ndc = (x_c/w_c, y_c/w_c, z_c/w_c, 1)
    ↓ z_ndc ∈ [-1, 1]
深度缓冲值 depth = z_ndc × 0.5 + 0.5  →  [0, 1]

类比:深度测试就像画家画画时的"遮挡关系"。先画远处的山,再画近处的树。如果树挡住了山,树应该显示出来。深度测试就是 GPU 自动判断"谁在前面"的机制。

4.1.2 透视投影矩阵的推导

为什么需要投影矩阵?

我们的世界是 3D 的,但屏幕是 2D 的。投影矩阵的作用就是把 3D 世界"拍扁"到 2D 屏幕上,同时保留深度信息。

类比:投影矩阵就像一台相机的镜头。镜头把三维场景(现实世界)映射到二维底片(屏幕)上。不同的镜头(不同的投影矩阵参数)会产生不同的效果(广角、长焦等)。

透视投影将视锥体(Frustum)压缩为规范化立方体(NDC Cube)。核心思想是:

对于近裁剪面(z = n)上的点:投影后 z' = -1
对于远裁剪面(z = f)上的点:投影后 z' = 1

透视投影矩阵(右手坐标系,OpenGL 风格):

     [ 2n/(r-l)      0        (r+l)/(r-l)      0    ]
P =  [    0      2n/(t-b)     (t+b)/(t-b)      0    ]
     [    0          0        -(f+n)/(f-n)   -2fn/(f-n)]
     [    0          0            -1             0    ]

简化版(对称视锥体,l=-r, b=-t):

     [ n/r      0        0            0      ]
P =  [  0     n/t       0            0      ]
     [  0      0    -(f+n)/(f-n)  -2fn/(f-n)]
     [  0      0       -1            0      ]

4.1.3 深度值的非线性分布

将观察空间深度 z_view 映射到 NDC 深度 z_ndc

z_ndc = -(f+n)/(f-n) × z_view - 2fn/(f-n)
        ───────────────────────────────────
                    -z_view

简化后:
z_ndc = (f+n)/(f-n) + 2fn/((f-n) × z_view)

关键结论

当 z_view → n(近裁剪面):z_ndc → -1
当 z_view → f(远裁剪面):z_ndc → 1
当 z_view = 2fn/(f+n)(调和平均):z_ndc = 0

深度值在 NDC 空间是非线性分布的——近处精度高,远处精度低。

示例:n = 1, f = 1000
z_view = 1   → depth = 0.0
z_view = 2   → depth = 0.001
z_view = 10  → depth = 0.010
z_view = 100 → depth = 0.099
z_view = 500 → depth = 0.499
z_view = 990 → depth = 0.989
z_view = 1000→ depth = 1.0

近处 12 单位深度占用了 0.1% 的深度缓冲精度,远处 5001000 单位也只用 50% 精度。这就是 z-fighting 在远处更严重的原因。

z-fighting 是什么:当两个物体非常接近时,由于深度精度不够,GPU 无法判断谁在前谁在后,导致像素闪烁。就像你在两张几乎重叠的纸之间,从远处看分不清哪张在上面。

4.2 深度测试的硬件实现

片元着色器输出 gl_FragCoord.z
            ↓
    ┌─────────────────┐
    │   深度测试单元    │
    │  (Z-Compare Unit) │
    │                  │
    │  if (newDepth OP depthBuffer[x,y]) │
    │      pass → 更新颜色 & 深度缓冲    │
    │      fail → 丢弃片元               │
    └─────────────────┘
            ↓
    深度缓冲(Depth Buffer / Z-Buffer)

4.2.1 深度比较函数

// GLSL 中通过 glDepthFunc 设置(OpenGL API)
glDepthFunc(GL_LESS);    // 默认:新深度 < 缓冲深度 时通过
比较函数 通过条件 典型用途
GL_NEVER 永不通过 调试、完全禁用写入
GL_LESS 新深度 < 缓冲深度 默认,标准不透明物体
GL_EQUAL 新深度 == 缓冲深度 decals、贴纸(配合多边形偏移)
GL_LEQUAL 新深度 <= 缓冲深度 天空盒、透明物体(从远到近)
GL_GREATER 新深度 > 缓冲深度 内部渲染、反向深度缓冲
GL_GEQUAL 新深度 >= 缓冲深度 某些后处理效果
GL_ALWAYS 总是通过 UI、完全禁用深度测试

4.2.2 深度写入控制

glDepthMask(GL_TRUE);   // 允许写入深度缓冲(默认)
glDepthMask(GL_FALSE);  // 禁止写入深度缓冲

透明物体渲染的标准流程

1. 渲染所有不透明物体
   - depthTest: ON, depthWrite: ON, blend: OFF

2. 按从远到近排序,渲染透明物体
   - depthTest: ON, depthWrite: OFF, blend: ON
   // 为什么 depthWrite OFF?
   // 因为透明物体后面的物体还需要被渲染,
   // 如果写了深度,后面的透明物体会被剔除

类比:深度测试就像排队买票。不透明物体(实心墙)排在前面,后面的人(被遮挡的物体)直接被淘汰。透明物体(玻璃)排在前面但不写深度,后面的人还能看到,因为玻璃是透明的。

4.3 Early-Z 与 Hi-Z:GPU 的提前深度剔除

4.3.1 Early-Z 原理

传统流程:
  顶点着色器 → 光栅化 → 片元着色器(完整执行) → 深度测试 → 可能丢弃

Early-Z 优化:
  顶点着色器 → 光栅化 → **Early-Z 测试** → 片元着色器(仅对通过者执行)
                                    ↓
                              若失败,直接丢弃
                              节省片元着色器执行

类比:Early-Z 就像考试前的资格审查。传统流程是所有人先参加完整考试(执行片元着色器),再审查资格(深度测试)。Early-Z 是先审查资格,不合格的直接不让进考场,节省了大量考试资源。

Early-Z 的触发条件

  • 片元着色器不修改深度值(gl_FragDepth 未被写入)
  • 片元着色器不使用 discard 关键字
  • 深度测试和深度写入均开启
  • 混合模式不影响深度

4.3.2 Hi-Z(Hierarchical Z-Buffer)

现代 GPU 使用分层深度缓冲加速 Early-Z:

完整深度缓冲:1920 × 1080 = 2,073,600 像素
Hi-Z 层级 0:  1920 × 1080(原始)
Hi-Z 层级 1:   960 ×  540(2×2 块取 min/max)
Hi-Z 层级 2:   480 ×  270
Hi-Z 层级 3:   240 ×  135
...
Hi-Z 层级 n:     1 ×    1(整个屏幕的最小/最大深度)

Hi-Z 剔除流程

一个 16×16 的图块进入光栅化
    ↓
查询 Hi-Z 对应层级(如 120×67)
    ↓
若图块的最大深度 < Hi-Z 最小深度 → 整个图块全部剔除
若图块的最小深度 > Hi-Z 最大深度 → 整个图块全部通过
否则 → 逐像素测试

Hi-Z 可以将深度测试的带宽减少 90% 以上,是现代 GPU 的关键优化。

类比:Hi-Z 就像快递分拣。不用逐个包裹检查地址,而是先看"这批包裹的目的地城市",如果整个城市都不在配送范围内,整批直接退回。只有目的地可能匹配的,才逐个检查具体地址。

4.3.3 Early-Z 失效场景与性能陷阱

// 以下操作会导致 Early-Z 失效:

// 1. 在片元着色器中写入深度
void main() {
    gl_FragDepth = customDepth;  // Early-Z 失效!
    gl_FragColor = color;
}

// 2. 使用 discard
void main() {
    if (textureColor.a < 0.5) {
        discard;  // Early-Z 失效!GPU 无法预知哪些片元会被丢弃
    }
    gl_FragColor = textureColor;
}

// 3. Alpha Test(本质就是 discard)
// Cocos 中 Sprite 的 GRAY 模式、自定义裁剪都可能导致此问题

性能数据

场景 片元着色器执行数 性能
开启 Early-Z,无重叠 1,000,000 基准
开启 Early-Z,50% 被遮挡 500,000 2× 提升
Early-Z 失效(discard) 1,000,000+ 回到基准甚至更差

4.4 对数深度缓冲(Logarithmic Depth Buffer)

解决远处 z-fighting 的方案:

// 顶点着色器
uniform float u_Fcoef;
varying float v_fragDepth;

void main() {
    vec4 clipPos = cc_matViewProj * vec4(a_position, 1.0);
    
    // 对数深度:将指数分布的深度转为线性分布
    v_fragDepth = log2(max(1e-6, 1.0 + clipPos.w)) * u_Fcoef;
    
    gl_Position = clipPos;
    gl_Position.z = v_fragDepth * clipPos.w;
}

// 片元着色器
varying float v_fragDepth;

void main() {
    gl_FragColor = texture2D(texture, v_uv);
    // 可选:写入对数深度到深度缓冲
    gl_FragDepth = v_fragDepth;
}
传统深度分布 vs 对数深度分布:

传统:[0]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[1]
      近处              中间              远处
      精度高            精度中            精度极低

对数:[0]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[1]
      近处    中间    远处
      均匀分布,远处精度大幅提升

对数深度的代价:

  1. gl_FragDepth 写入导致 Early-Z 失效
  2. 需要额外的 uniform 和计算
  3. 仅在超大场景(飞行模拟、开放世界)中收益大于代价

五、模板测试(Stencil Test):像素级的掩码系统

5.1 模板测试的本质

模板测试使用一个与屏幕等大的整数缓冲(Stencil Buffer),每个像素存储一个整数值(通常 8-bit,范围 0~255)。测试时,将参考值与缓冲值按指定规则比较,决定片元是否通过。

颜色缓冲(RGBA):存储每个像素的颜色
深度缓冲(Z):   存储每个像素的深度值
模板缓冲(S):   存储每个像素的模板值(8-bit 整数)

三者合称 Frame Buffer,通常打包为:
  - D24S8:24-bit 深度 + 8-bit 模板(最常见)
  - D32FS8:32-bit 浮点深度 + 8-bit 模板

类比:模板测试就像"盖章认证"。想象你要进入某个会场,安保人员(模板测试)检查你身上的印章(模板值)是否匹配(比较操作)。匹配的放行,不匹配的不让进。而且安保还可以在你通过后,给你盖一个新的章(模板操作)。

5.2 模板测试的数学逻辑

stencilTest = (refValue & mask)  comparisonOp  (stencilBufferValue & mask)

其中:
  refValue:通过 glStencilFunc 设置的参考值
  mask:通过 glStencilFunc 设置的掩码
  comparisonOp:比较操作(GL_EQUAL, GL_ALWAYS 等)

5.3 OpenGL / WebGL 模板 API

// 1. 设置测试函数
gl.stencilFunc(func, ref, mask);
//    func: GL_EQUAL / GL_ALWAYS / GL_NEVER / GL_LESS 等
//    ref:  参考值(0~255)
//    mask: 位掩码(0xFF = 全比较,0x00 = 不比较)

// 2. 设置测试通过/失败后的操作
gl.stencilOp(fail, zfail, zpass);
//    fail:   模板测试失败时的操作
//    zfail:  模板通过但深度测试失败时的操作
//    zpass:  模板和深度都通过时的操作

5.4 模板操作详解

操作 效果 数学表达
GL_KEEP 保持原值 S' = S
GL_ZERO 置零 S' = 0
GL_REPLACE 替换为参考值 S' = ref
GL_INCR 递增(上限 255) S' = min(S+1, 255)
GL_DECR 递减(下限 0) S' = max(S-1, 0)
GL_INVERT 按位取反 S' = ~S & 0xFF
GL_INCR_WRAP 递增(循环) S' = (S+1) & 0xFF
GL_DECR_WRAP 递减(循环) S' = (S-1) & 0xFF

5.5 实战:Mask 2-Pass 渲染(Cocos 遮罩实现原理)

Cocos Creator 的 Mask 组件使用模板测试实现裁剪:

Pass 1:绘制遮罩形状(如圆形)
  - 模板测试:GL_ALWAYS(总是通过)
  - 模板操作:GL_REPLACE(将模板值设为 ref=1)
  - 颜色写入:关闭(只写模板,不写颜色)
  - 深度写入:关闭

结果:遮罩区域内的模板值 = 1,区域外 = 0

Pass 2:绘制被遮罩的内容
  - 模板测试:GL_EQUAL(只通过模板值 == ref=1 的像素)
  - 模板操作:GL_KEEP(不改变模板值)
  - 颜色写入:开启

结果:只有遮罩区域内的内容被渲染
// Cocos Mask 组件的简化 Shader 逻辑

// Pass 1: 模板写入
void main() {
    // 只输出 alpha 用于形状判断
    float alpha = shapeTest(v_uv);
    if (alpha < 0.5) discard;
    
    // 不输出颜色,只标记模板
    gl_FragColor = vec4(0.0);
}

// Pass 2: 内容渲染(被 Mask 裁剪)
void main() {
    // 模板测试自动过滤:只有 stencil == 1 的像素能执行到这里
    gl_FragColor = texture2D(texture, v_uv);
}

5.6 模板测试的层级嵌套

层级 0:无 Mask,模板值 = 0
层级 1:Mask A,模板值 = 1
层级 2:Mask B(在 A 内),模板值 = 2
层级 3:Mask C(在 B 内),模板值 = 3

每个层级的模板操作:
  进入:GL_INCR(递增)
  退出:GL_DECR(递减)

渲染内容时:
  stencilFunc(GL_EQUAL, currentLevel, 0xFF)
  // 只有模板值 == currentLevel 的像素通过

Cocos Creator 的 Mask 组件支持嵌套,最大嵌套层数受限于模板缓冲的位深(8-bit = 255 层,实际通常限制为 8~16 层)。

5.7 模板测试的性能影响

场景 额外开销 说明
单个 Mask 1 个额外 DrawCall 模板写入 Pass
N 个嵌套 Mask N 个额外 DrawCall 每层都需要写入模板
全屏 Mask 全屏片元着色器执行 模板写入也有片元开销
频繁变化的 Mask 每帧重新写入模板 无法缓存

优化建议

  1. 避免过多 Mask(>5 个同时显示)
  2. 静态 Mask 考虑用 Sprite 裁剪代替(预渲染到纹理)
  3. 矩形 Mask 用 RectMask(Scissor Test,无额外 DrawCall)

六、混合(Blending):颜色合成公式推导

6.1 混合的数学定义

混合发生在片元着色器输出颜色后、写入颜色缓冲前:

最终颜色 = 源颜色 × 源因子  OP  目标颜色 × 目标因子

其中:
  源颜色(Src / S)= 当前片元着色器输出的颜色
  目标颜色(Dst / D)= 颜色缓冲中已有的颜色
  OP = 加、减、反减、最小、最大

类比:混合就像调鸡尾酒。源颜色是新倒入的酒,目标颜色是杯子里已有的酒。混合公式就是"倒多少新酒、保留多少旧酒"的配方。

6.2 OpenGL / WebGL 混合 API

// 设置混合函数
gl.blendFunc(srcFactor, dstFactor);
// 或分别设置 RGB 和 Alpha
gl.blendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha);

// 设置混合方程
gl.blendEquation(mode);
// mode: GL_FUNC_ADD / GL_FUNC_SUBTRACT / GL_FUNC_REVERSE_SUBTRACT / GL_MIN / GL_MAX

6.3 混合因子详解

因子 典型用途
GL_ZERO (0, 0, 0, 0) 完全消除该颜色
GL_ONE (1, 1, 1, 1) 完整保留该颜色
GL_SRC_COLOR (S.r, S.g, S.b, S.a) 乘法混合
GL_ONE_MINUS_SRC_COLOR (1-S.r, 1-S.g, 1-S.b, 1-S.a) 反色混合
GL_DST_COLOR (D.r, D.g, D.b, D.a) 乘法混合
GL_ONE_MINUS_DST_COLOR (1-D.r, 1-D.g, 1-D.b, 1-D.a) 反色混合
GL_SRC_ALPHA (S.a, S.a, S.a, S.a) 最常用:标准透明混合
GL_ONE_MINUS_SRC_ALPHA (1-S.a, 1-S.a, 1-S.a, 1-S.a) 最常用:标准透明混合
GL_DST_ALPHA (D.a, D.a, D.a, D.a) 预乘 Alpha
GL_ONE_MINUS_DST_ALPHA (1-D.a, ...) 特殊效果
GL_CONSTANT_COLOR 常量颜色 调色
GL_SRC_ALPHA_SATURATE (min(S.a, 1-D.a), ...) 抗锯齿边缘

6.4 标准混合模式公式推导

6.4.1 标准透明混合(Normal / Alpha Blend)

gl.blendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
gl.blendEquation(GL_FUNC_ADD);
公式:
Color_final = Src × SrcAlpha + Dst × (1 - SrcAlpha)

展开:
R_final = S.r × S.a + D.r × (1 - S.a)
G_final = S.g × S.a + D.g × (1 - S.a)
B_final = S.b × S.a + D.b × (1 - S.a)
A_final = S.a × S.a + D.a × (1 - S.a)  // Alpha 也混合

直观理解

源颜色贡献 = 源颜色 × 源不透明度
目标颜色贡献 = 目标颜色 × (1 - 源不透明度)

例如:
  源 = 红色 (1, 0, 0),Alpha = 0.5(半透明红)
  目标 = 蓝色 (0, 0, 1)
  
  结果 = (1,0,0) × 0.5 + (0,0,1) × 0.5
       = (0.5, 0, 0.5)
       = 紫色(红蓝混合)

类比:半透明玻璃(Alpha=0.5)放在红色背景上。你看到的颜色 = 50% 玻璃颜色 + 50% 背景颜色。玻璃越透明(Alpha 越小),背景贡献越大。

6.4.2 加法混合(Additive)

gl.blendFunc(GL_SRC_ALPHA, GL_ONE);
gl.blendEquation(GL_FUNC_ADD);
公式:
Color_final = Src × SrcAlpha + Dst × 1
            = Src × SrcAlpha + Dst

用途:火焰、光晕、魔法效果(颜色越叠越亮)

问题:颜色可能溢出(>1.0),需要 HDR 处理或钳制。

类比:加法混合就像把两束光照在同一个地方。红光 + 绿光 = 黄光,三原色都加满 = 白光。这就是为什么加法混合适合做"发光"效果。

6.4.3 乘法混合(Multiply)

gl.blendFunc(GL_DST_COLOR, GL_ZERO);
// 或
gl.blendFunc(GL_ZERO, GL_SRC_COLOR);
公式:
Color_final = Src × Dst + Dst × 0
            = Src × Dst

用途:阴影、暗角、正片叠底(Photoshop Multiply)

类比:乘法混合就像把两张幻灯片叠在一起投影。白色(1,1,1)不影响下层,黑色(0,0,0)把下层完全遮住。适合做"阴影覆盖"效果。

6.4.4 屏幕混合(Screen)

gl.blendFunc(GL_ONE, GL_ONE_MINUS_SRC_COLOR);
公式:
Color_final = Src × 1 + Dst × (1 - Src)
            = Src + Dst - Src × Dst
            = 1 - (1 - Src) × (1 - Dst)

用途:Photoshop Screen 模式,提亮效果

6.4.5 预乘 Alpha(Premultiplied Alpha)

// 纹理数据已经预乘了 Alpha
gl.blendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
标准 Alpha:纹理存储 (R, G, B, A),渲染时 Src × A
预乘 Alpha:纹理存储 (R×A, G×A, B×A, A),渲染时 Src × 1

公式:
Color_final = (Src.rgb × Src.a) × 1 + Dst × (1 - Src.a)
            = Src.rgb × Src.a + Dst × (1 - Src.a)

结果与标准混合相同,但:
1. 过滤(Filtering)时不会出黑边
2. Mipmap 生成更准确
3. 需要工具链支持预乘

黑边问题:标准 Alpha 纹理在双线性过滤时,透明区域的 RGB 值(通常是黑色)会渗入边缘,导致黑边。预乘 Alpha 避免了这个问题。

6.5 Cocos Creator 中的 Blend 配置

// 在 Effect 文件中定义 Pass 的混合状态
Pass {
    blendState {
        targets {
            blend: true
            blendSrc: src_alpha
            blendDst: one_minus_src_alpha
            blendSrcAlpha: one
            blendDstAlpha: one_minus_src_alpha
        }
    }
}
Cocos Blend 枚举 对应 GL 常量
none -
zero GL_ZERO
one GL_ONE
src_color GL_SRC_COLOR
one_minus_src_color GL_ONE_MINUS_SRC_COLOR
src_alpha GL_SRC_ALPHA
one_minus_src_alpha GL_ONE_MINUS_SRC_ALPHA
dst_color GL_DST_COLOR
one_minus_dst_color GL_ONE_MINUS_DST_COLOR
dst_alpha GL_DST_ALPHA
one_minus_dst_alpha GL_ONE_MINUS_DST_ALPHA

七、Shader 变体(Shader Variants):条件编译的艺术

7.1 为什么需要 Shader 变体

一个 Effect 文件通常需要支持多种渲染模式:

同一个 Sprite Effect:
  - 有纹理 / 无纹理
  - 使用颜色 / 不使用
  - 开启遮罩 / 不开启
  - 开启雾效 / 不开启
  - 使用法线贴图 / 不使用
  - 方向光数量:0, 1, 2, 3, 4

如果全部用 if 分支:
  片元着色器内有大量运行时 if,GPU  warp divergence 严重

如果用 Shader 变体:
  编译期确定分支,每个变体都是最优代码

术语解释:warp divergence GPU 以 warp(32 个线程为一组)执行。如果 warp 内的线程走不同的 if 分支,GPU 必须串行执行每个分支,导致性能下降。 类比:32 人的旅游团,如果一半人想去 A 景点、一半想去 B 景点,导游只能先带一半人去 A,再去 B。 Shader 变体就是出发前就分好团,每团去一个地方。

7.2 Cocos 的 Shader 变体系统

// Cocos Effect 文件中的宏定义
CCEffect %{
  techniques:
  - name: opaque
    passes:
    - vert: general-vs:vert
      frag: unlit-fs:frag
      properties: &props
        mainTexture:    { value: white }
        tilingOffset:   { value: [1, 1, 0, 0] }
        mainColor:      { value: [1, 1, 1, 1], editor: { type: color } }
      macros:
        - USE_TEXTURE        # 宏开关
        - USE_COLOR
        - USE_ALPHA_TEST
}%
// 在 Shader 代码中使用宏
#if USE_TEXTURE
    uniform sampler2D mainTexture;
    in vec2 v_uv;
#endif

#if USE_COLOR
    uniform vec4 mainColor;
#endif

void main() {
    vec4 color = vec4(1.0);
    
    #if USE_TEXTURE
        color *= texture(mainTexture, v_uv);
    #endif
    
    #if USE_COLOR
        color *= mainColor;
    #endif
    
    #if USE_ALPHA_TEST
        if (color.a < 0.5) discard;
    #endif
    
    gl_FragColor = color;
}

7.3 变体爆炸问题

假设有 N 个布尔宏开关,每个开关独立:
总变体数 = 2^N

例如:
  USE_TEXTURE × USE_COLOR × USE_ALPHA_TEST × USE_FOG × USE_LIGHTMAP
  = 2^5 = 32 个变体

如果再加上方向光数量(0~4):
  = 32 × 5 = 160 个变体

如果再加上阴影质量(低/中/高):
  = 160 × 3 = 480 个变体

变体过多的危害

  1. 编译时间爆炸(每个变体都要编译一次)
  2. 运行时内存占用大(所有变体的 GPU 程序都驻留显存)
  3. 首次渲染卡顿(遇到未编译的变体需要实时编译)

7.4 Cocos 的变体管理策略

// 1. 在编辑器中配置需要编译的变体组合
// Project Settings → Macro Configurations

// 2. 代码中动态切换宏
material.setDefine('USE_TEXTURE', true);
material.setDefine('USE_FOG', false);

// 3. 这会重新编译 Shader(如果该变体未编译过)
// 首次切换可能有 50~200ms 卡顿

最佳实践

  1. 控制宏数量(<10 个布尔宏)
  2. 在构建时预编译所有需要的变体组合
  3. 运行时避免频繁切换宏
  4. 使用 shader variant collection 预热常用变体

八、多 Pass 渲染:逐层构建画面

8.1 什么是多 Pass

一个 Technique 可以包含多个 Pass,每个 Pass 执行一次完整的渲染管线:

Technique "default" {
    Pass 0: 基础颜色 + 深度写入
    Pass 1: 边缘光(Rim Light)叠加
    Pass 2: 描边(Outline,背面渲染 + 外扩)
}

类比:多 Pass 就像 Photoshop 的图层。第一层画底色,第二层加高光,第三层加描边。每一层(Pass)独立绘制,最后叠加成完整画面。

8.2 多 Pass 的执行顺序与状态继承

Pass 0:
  - 设置深度测试:LESS,写入开启
  - 设置混合:关闭
  - 绘制主体
  → 写入颜色和深度

Pass 1:
  - 继承上一 Pass 的深度缓冲
  - 设置深度测试:LEQUAL,写入关闭
  - 设置混合:ADD(叠加)
  - 绘制边缘光
  → 颜色叠加到已有画面上

Pass 2:
  - 设置剔除:FRONT(剔除正面,只渲染背面)
  - 顶点外扩法线方向
  - 设置深度测试:LESS,写入开启
  - 绘制纯色
  → 产生描边效果

8.3 Cocos Effect 中的多 Pass 定义

CCEffect %{
  techniques:
  - passes:
    # Pass 0: 基础渲染
    - vert: standard-vs:vert
      frag: standard-fs:frag
      properties: *standardProps
      depthStencilState:
        depthTest: true
        depthWrite: true
      blendState:
        targets:
          blend: false
    
    # Pass 1: 边缘光叠加
    - vert: rim-vs:vert
      frag: rim-fs:frag
      depthStencilState:
        depthTest: true
        depthWrite: false
      blendState:
        targets:
          blend: true
          blendSrc: one
          blendDst: one
    
    # Pass 2: 描边
    - vert: outline-vs:vert
      frag: outline-fs:frag
      rasterizerState:
        cullMode: front
      depthStencilState:
        depthTest: true
        depthWrite: true
      blendState:
        targets:
          blend: false
}%

8.4 多 Pass 的性能代价

指标 单 Pass 3 Pass 5 Pass
DrawCall 1 3 5
顶点着色器执行
片元着色器执行 2~3× 3~5×
带宽(读写缓冲) 2~3× 3~5×

关键原则:Pass 数量直接影响性能,应尽量减少。移动端建议每个材质 ≤3 Pass。

8.5 常见多 Pass 技术

8.5.1 描边(Outline)

// Pass 1: 背面外扩描边
// 顶点着色器
void main() {
    vec3 pos = a_position + a_normal * u_outlineWidth;
    gl_Position = cc_matViewProj * cc_matWorld * vec4(pos, 1.0);
}

// 片元着色器
void main() {
    gl_FragColor = u_outlineColor;
}

8.5.2 阴影映射(Shadow Map)

Pass 0(Shadow Pass):
  - 从光源视角渲染深度到 Shadow Map 纹理
  - 使用专门的 ShadowCaster Shader

Pass 1(Main Pass):
  - 正常渲染场景
  - 在片元着色器中采样 Shadow Map
  - 比较当前深度与 Shadow Map 深度,判断是否在阴影中

8.5.3 后处理(Post-Processing)

Pass 0:渲染场景到 Offscreen Render Target
Pass 1:全屏 Quad,对 RT 应用效果(Bloom、Color Grading 等)
Pass 2:将结果 blit 到屏幕

九、Cocos Creator 3.8.x Shader 完整实践

9.1 创建一个自定义 Effect

// assets/effects/custom-sprite.effect

CCEffect %{
  techniques:
  - name: opaque
    passes:
    - vert: sprite-vs:vert
      frag: sprite-fs:frag
      properties: &props
        mainTexture:    { value: white }
        tintColor:      { value: [1, 1, 1, 1], editor: { type: color } }
        flashSpeed:     { value: 2.0, target: flashSpeed, editor: { slide: true, range: [0, 10], step: 0.1 } }
      macros:
        - USE_FLASH    // 闪烁效果宏开关
}%

CCProgram sprite-vs %{
  precision mediump float;
  
  #include <cc-global>
  #include <cc-local>
  
  in vec3 a_position;
  in vec2 a_texCoord;
  in vec4 a_color;
  
  out vec2 v_uv;
  out vec4 v_color;
  
  vec4 vert() {
    vec4 pos = vec4(a_position, 1.0);
    pos = cc_matWorld * pos;
    pos = cc_matViewProj * pos;
    
    v_uv = a_texCoord;
    v_color = a_color;
    
    return pos;
  }
}%

CCProgram sprite-fs %{
  precision mediump float;
  
  #include <output>
  
  in vec2 v_uv;
  in vec4 v_color;
  
  uniform sampler2D mainTexture;
  uniform vec4 tintColor;
  uniform float flashSpeed;
  uniform CCGlobal {
    vec4 cc_time;
  };
  
  vec4 frag() {
    vec4 color = texture(mainTexture, v_uv);
    color *= v_color * tintColor;
    
    #if USE_FLASH
      // 使用 cc_time.x(总时间)创建闪烁效果
      float flash = abs(sin(cc_time.x * flashSpeed));
      color.rgb += flash * 0.3;  // 亮度脉冲
    #endif
    
    return color;
  }
}%

9.2 TypeScript 中使用自定义 Material

import { _decorator, Component, Sprite, Material, Texture2D, Color } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('FlashSprite')
export class FlashSprite extends Component {
    @property(Texture2D)
    texture: Texture2D = null;
    
    @property(Color)
    tintColor: Color = Color.WHITE.clone();
    
    @property
    flashSpeed: number = 2.0;
    
    private _sprite: Sprite = null;
    private _material: Material = null;
    
    onLoad() {
        this._sprite = this.getComponent(Sprite);
        
        // 1. 从 Effect Asset 创建 Material
        const effectAsset = this._sprite.customMaterial?.effectAsset;
        this._material = new Material();
        this._material.initialize({
            effectAsset: effectAsset,
            defines: { USE_FLASH: true },  // 启用闪烁宏
        });
        
        // 2. 设置 Uniform
        this._material.setProperty('mainTexture', this.texture);
        this._material.setProperty('tintColor', this.tintColor);
        this._material.setProperty('flashSpeed', this.flashSpeed);
        
        // 3. 应用到 Sprite
        this._sprite.customMaterial = this._material;
    }
    
    update(dt: number) {
        // 运行时动态修改 Uniform
        if (this._material) {
            this._material.setProperty('flashSpeed', this.flashSpeed);
        }
    }
}

9.3 Shader 调试技巧

// 技巧 1:可视化深度值
void main() {
    vec4 color = texture(mainTexture, v_uv);
    
    // 将深度值 [0,1] 映射到灰度可视化
    float depth = gl_FragCoord.z;
    color.rgb = vec3(depth);  // 近处黑,远处白
    
    gl_FragColor = color;
}

// 技巧 2:可视化法线
void main() {
    // 法线范围 [-1,1] → [0,1] 用于颜色显示
    vec3 normalColor = v_normal * 0.5 + 0.5;
    gl_FragColor = vec4(normalColor, 1.0);
}

// 技巧 3:可视化 UV
void main() {
    gl_FragColor = vec4(v_uv, 0.0, 1.0);  // U=红, V=绿
}

// 技巧 4:检查模板值
void main() {
    // 需要扩展支持,通常用 Spector.js 查看
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

十、性能优化:Shader 层面的最佳实践

10.1 指令数优化

// 差的写法:多次纹理采样
vec4 c1 = texture(tex, uv + offset1);
vec4 c2 = texture(tex, uv + offset2);
vec4 c3 = texture(tex, uv + offset3);
vec4 c4 = texture(tex, uv + offset4);

// 好的写法:如果可能,合并或预计算
// 使用 Mipmap 代替手动偏移采样
vec4 c = texture(tex, uv, lod);  // 一次采样 + LOD

10.2 避免分支(Branch)

// 差的写法:动态分支
if (useLight) {
    color *= calcLighting(normal);
}

// 好的写法:Shader 变体(编译期分支)
// 或用数学函数替代
float lightFactor = float(useLight);  // 0 或 1
color *= mix(vec3(1.0), calcLighting(normal), lightFactor);

// 更好的写法:Step 函数(无分支)
float lightFactor = step(0.5, float(useLight));

10.3 精度优化

// 顶点着色器:highp 用于位置计算
attribute highp vec3 a_position;

// 片元着色器:mediump 足够用于颜色和 UV
varying mediump vec2 v_uv;
uniform lowp vec4 tintColor;  // 颜色用 lowp 足够

// 避免在片元着色器中使用 highp 循环
for (int i = 0; i < 4; i++) {  // 常量循环可展开
    // ...
}

10.4 纹理采样优化

// 1. 使用 Mipmap(自动 LOD)
texture(sampler, uv);  // GPU 自动选择 LOD

// 2. 避免依赖纹理读取(Dependent Texture Read)
// 差的:先采样 A,用结果计算 B 的 UV
vec4 a = texture(texA, uv);
vec2 uvB = a.rg * 2.0 - 1.0;  // 依赖 A 的结果
vec4 b = texture(texB, uvB);   // 依赖纹理读取!延迟增加

// 好的:尽可能独立计算 UV
vec2 uvB = someIndependentCalculation(uv);
vec4 a = texture(texA, uv);
vec4 b = texture(texB, uvB);   // 可并行采样

// 3. 纹理数组代替多个纹理切换
uniform sampler2DArray texArray;
texture(texArray, vec3(uv, layer));  // 一次绑定,多层访问

十一、初学者常见错误

11.1 错误 1:在片元着色器里修改 attribute

// ❌ 错误:attribute 只能在顶点着色器中读取
void main() {
    a_position = vec3(0.0);  // 编译错误!attribute 是只读的
}

// ✅ 正确:在顶点着色器中读取,通过 varying 传给片元着色器
// 顶点着色器
varying vec3 v_worldPos;
void main() {
    v_worldPos = (cc_matWorld * vec4(a_position, 1.0)).xyz;
}

11.2 错误 2:忘记设置 precision

// ❌ 错误:片元着色器没有 precision 声明
void main() {
    gl_FragColor = vec4(1.0);
}

// ✅ 正确:必须声明精度
precision mediump float;
void main() {
    gl_FragColor = vec4(1.0);
}

11.3 错误 3:varying 变量名不匹配

// ❌ 错误:VS 和 FS 的 varying 名字不同
// 顶点着色器
varying vec2 v_uv;

// 片元着色器
varying vec2 v_texCoord;  // 名字不同!数据无法传递

// ✅ 正确:名字必须完全一致
// 顶点着色器
varying vec2 v_uv;

// 片元着色器
varying vec2 v_uv;

11.4 错误 4:矩阵乘法顺序错误

// ❌ 错误:矩阵乘法顺序反了
// 向量在右边时,应该从右往左读变换顺序
gl_Position = vec4(a_position, 1.0) * cc_matWorld;  // 错误!

// ✅ 正确:矩阵 × 向量
gl_Position = cc_matWorld * vec4(a_position, 1.0);  // 正确

// 多个矩阵连乘时,注意顺序
// 模型 → 世界 → 观察 → 投影
gl_Position = cc_matViewProj * cc_matWorld * vec4(a_position, 1.0);

11.5 错误 5:在片元着色器里用 highp 循环

// ❌ 错误:动态循环次数在片元着色器中性能极差
uniform int u_lightCount;
void main() {
    for (int i = 0; i < u_lightCount; i++) {  // 动态循环!性能灾难
        // ...
    }
}

// ✅ 正确:使用常量循环,或用 Shader 变体
#if MAX_LIGHTS == 4
for (int i = 0; i < 4; i++) {  // 常量循环,GPU 可以展开
    // ...
}
#endif

11.6 错误 6:忽略 Early-Z 失效

// ❌ 错误:随意使用 discard
text2D(tex, uv);
if (color.a < 0.1) {
    discard;  // Early-Z 失效,性能下降
}

// ✅ 正确:如果可能,用 Alpha 混合代替 discard
// 或者在不需要 Early-Z 的场景使用

十二、自问自答(Q&A)

Q1:为什么 Shader 代码看起来这么短?

A:因为 Shader 是"并行执行"的。你写的代码只处理一个顶点/片元,但 GPU 会同时把它应用到成千上万个顶点/片元上。所以 Shader 代码通常只有几十行,但执行次数是百万级的。

Q2:attribute、varying、uniform 到底怎么区分?

A:记住这个类比:

  • attribute = 每个学生的个人档案(每个顶点不同)
  • varying = 学生之间传递的纸条(顶点传给片元,会自动插值)
  • uniform = 全校广播通知(所有线程共享,同时听到)

Q3:为什么我的 Shader 在手机上编译失败?

A:常见原因:

  1. 使用了 highp,但手机不支持
  2. 使用了 WebGL 2.0 特性,但手机只支持 WebGL 1.0
  3. 指令数超过手机 GPU 限制
  4. 循环次数不是常量

Q4:深度测试和模板测试有什么区别?

A

  • 深度测试:判断"谁在前面"(基于 Z 值)
  • 模板测试:判断"谁在允许区域内"(基于整数值)

类比:深度测试是"前后遮挡关系",模板测试是"门禁刷卡"。

Q5:为什么透明物体要从远到近渲染?

A:因为混合公式是 Src × A + Dst × (1-A)。如果先渲染近的透明物体,它会写入颜色缓冲;再渲染远的透明物体时,远的颜色会和近的颜色混合。但如果先渲染远的,再渲染近的,近的颜色会正确地叠加在远的上面。

类比:画画时先画背景(远的),再画前景(近的)。如果反过来,背景会把前景盖住。

Q6:Shader 变体和 if 语句有什么区别?

A

  • Shader 变体:编译期确定,生成多个 Shader 程序。运行时根据条件选择用哪个。无性能损失。
  • if 语句:运行时判断,可能导致 warp divergence,性能下降。

类比:变体是"出发前分好团",if 是"到了景点再分组"。

Q7:为什么 GPU 不喜欢分支(if)?

A:GPU 以 warp(32 线程)为单位执行。一个 warp 内的所有线程必须执行相同的指令。如果 warp 内有些线程走 if 的 true 分支,有些走 false 分支,GPU 必须串行执行两个分支,导致性能下降。

Q8:MVP 矩阵是什么?

A:MVP = Model(模型)× View(视图)× Projection(投影)。

  • M(Model):把物体从"局部坐标"变到"世界坐标"
  • V(View):把世界坐标变到"相机坐标"(以相机为原点)
  • P(Projection):把相机坐标变到"裁剪坐标"(准备投影到屏幕)

类比:M 是"从家出发",V 是"到车站",P 是"买票上车"。


十三、知识图谱:Shader 核心概念关联

Shader
├── 语言层
│   ├── GLSL ES 3.0(WebGL 2.0)
│   ├── 存储限定符(attribute/uniform/varying/const)
│   └── 精度限定符(highp/mediump/lowp)
│
├── 执行模型
│   ├── SIMD 并行(Warp/Wavefront)
│   ├── 顶点着色器(逐顶点)
│   ├── 片元着色器(逐片元)
│   └── Uniform 硬件原理(常量缓冲区)
│
├── 固定功能管线
│   ├── 深度测试(Depth Test)
│   │   ├── 非线性深度分布
│   │   ├── Early-Z / Hi-Z
│   │   └── 对数深度缓冲
│   ├── 模板测试(Stencil Test)
│   │   ├── Mask 实现原理
│   │   └── 层级嵌套
│   └── 混合(Blending)
│       ├── 标准/加法/乘法/屏幕混合
│       └── 预乘 Alpha
│
├── 高级特性
│   ├── Shader 变体(宏开关)
│   ├── 多 Pass 渲染
│   └── UBO / SSB O
│
└── Cocos 实践
    ├── Effect 文件格式
    ├── 内置 Uniform 体系
    ├── Material API
    └── 性能优化

十四、自检清单

检查项 说明
□ 理解 Uniform 为什么存在 所有线程共享的只读数据,避免重复存储
□ 掌握深度值的非线性分布 z_ndc = (f+n)/(f-n) + 2fn/((f-n)*z_view)
□ 知道 Early-Z 的失效条件 discardgl_FragDepth、关闭深度测试
□ 能推导标准混合公式 Src×SrcAlpha + Dst×(1-SrcAlpha)
□ 理解模板测试的 Mask 实现 2-Pass:先写模板,再测试渲染
□ 会写 Cocos Effect 文件 YAML + GLSL 结构
□ 知道 Shader 变体的代价 2^N 个变体,编译时间和内存
□ 掌握多 Pass 的性能影响 每 Pass 增加 DrawCall 和带宽
□ 能使用精度限定符优化 VS 用 highp,FS 用 mediump
□ 会调试 Shader 可视化深度/法线/UV