Shader 深度入门:从数学原理到引擎实践
对应月影课程:第 12~18 章(着色器基础、Uniform 与纹理采样、多 Pass 渲染)
目标:掌握 Shader 的数学推导、GPU 硬件执行原理、以及 Cocos Creator 3.8.x 中的完整使用方式。
前置知识:你需要先知道这些
在深入 Shader 之前,让我们先建立几个基础概念。如果你已经熟悉这些内容,可以快速跳过。
什么是渲染管线
想象你是一位画家,要在画布上画出一幅风景画。你不会直接把颜料泼到画布上,而是有步骤的:
- 先构图(确定物体在哪里)
- 打草稿(画出轮廓)
- 上色(填充颜色)
- 加细节(高光、阴影)
图形渲染管线就是 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 变化:
attribute→invarying→in/outtexture2D→texture- 新增
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
近处 1
2 单位深度占用了 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]
近处 中间 远处
均匀分布,远处精度大幅提升
对数深度的代价:
gl_FragDepth写入导致 Early-Z 失效- 需要额外的 uniform 和计算
- 仅在超大场景(飞行模拟、开放世界)中收益大于代价
五、模板测试(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 | 每帧重新写入模板 | 无法缓存 |
优化建议:
- 避免过多 Mask(>5 个同时显示)
- 静态 Mask 考虑用 Sprite 裁剪代替(预渲染到纹理)
- 矩形 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 个变体
变体过多的危害:
- 编译时间爆炸(每个变体都要编译一次)
- 运行时内存占用大(所有变体的 GPU 程序都驻留显存)
- 首次渲染卡顿(遇到未编译的变体需要实时编译)
7.4 Cocos 的变体管理策略
// 1. 在编辑器中配置需要编译的变体组合
// Project Settings → Macro Configurations
// 2. 代码中动态切换宏
material.setDefine('USE_TEXTURE', true);
material.setDefine('USE_FOG', false);
// 3. 这会重新编译 Shader(如果该变体未编译过)
// 首次切换可能有 50~200ms 卡顿
最佳实践:
- 控制宏数量(<10 个布尔宏)
- 在构建时预编译所有需要的变体组合
- 运行时避免频繁切换宏
- 使用
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 |
| 顶点着色器执行 | 1× | 3× | 5× |
| 片元着色器执行 | 1× | 2~3× | 3~5× |
| 带宽(读写缓冲) | 1× | 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:常见原因:
- 使用了
highp,但手机不支持 - 使用了 WebGL 2.0 特性,但手机只支持 WebGL 1.0
- 指令数超过手机 GPU 限制
- 循环次数不是常量
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 的失效条件 | discard、gl_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 |