GPU渲染管线详解

用生活化的比喻,让你彻底理解GPU是如何把代码变成画面的

参考:月影《跟月影学可视化》第04篇《GPU与渲染管线:如何用WebGL绘制最简单的几何图形?》


阅读指南(初学者必看)

为什么你需要学习这个?

如果你是一名前端/游戏开发初学者,你可能会问:"我只是用Cocos/Laya/Three.js写代码,引擎都帮我封装好了,为什么还要学GPU渲染管线?"

答案是:不懂渲染管线,你永远只能停留在"调API"的层面,遇到性能问题、画面bug、Shader报错时完全无从下手。

具体场景:

  • 你的游戏在低端手机上卡顿,不知道是该减少DrawCall还是优化Shader
  • Sprite设置了半透明但显示异常,不知道是混合模式问题还是深度测试问题
  • 写Shader时出现黑屏/花屏,连错误在哪里都不知道
  • 面试官问"说说渲染管线",你只能背概念却说不出数据怎么流动

阅读前的知识准备

你不需要是数学天才,但最好了解以下基础:

  • 基础JavaScript:能看懂简单的对象和方法调用
  • 数组概念:知道什么是二维数组、类型化数组(Float32Array)
  • 坐标系:知道平面直角坐标系(x,y),了解三维坐标更好
  • 矩阵乘法(可选):知道矩阵乘向量是什么操作即可,不需要手算

如果你完全没接触过图形学,建议按顺序阅读本文,不要跳读。

本文结构

第一部分:GPU是什么(先建立直观认知)
第二部分:渲染管线全景图(了解数据流向)
第三部分:8大阶段详解(核心内容,逐个击破)
第四部分:Early-Z优化(进阶,了解GPU如何提速)
第五部分:GPU内存架构(了解性能瓶颈)
第六部分:完整代码示例(把知识串起来)
第七部分:Sprite的一生(从引擎到屏幕的完整链路)

一、GPU是什么?和CPU有什么区别?

餐厅厨房比喻

想象一家超大的餐厅:

  • CPU = 总厨:只有1个,但极其聪明,能做任何复杂的菜式(逻辑判断、分支跳转、复杂计算)。但一次只能做一道菜。
  • GPU = 200个帮厨:每个帮厨只会做简单的事情(切菜、摆盘),但有200个同时干活!200道简单的菜同时出锅。

关键差异

特性 CPU GPU
核心数 少(4-16个) 多(几千个)
每个核心能力 强(复杂逻辑) 弱(简单运算)
擅长 逻辑判断、分支、序列处理 大量相同操作的并行处理
游戏中的角色 游戏逻辑、AI、物理 渲染、顶点变换、像素着色

为什么渲染用GPU? 因为渲染的本质就是"对大量顶点/像素做相同的数学运算"。一个1080P的画面有200万像素,每个像素都要算颜色 —— 这正是GPU最擅长的事!

逐步理解:为什么"并行"对渲染如此重要?

假设你要给一个1080P的屏幕(1920×1080 = 2,073,600像素)上色:

用CPU串行计算(比喻:1个工人刷墙):

第1个像素:计算颜色 → 写入 → 第2个像素:计算颜色 → 写入 → ... → 第207万个像素

假设每个像素计算需要10个时钟周期,总时间 = 2,073,600 × 10 = 20,736,000周期 以3GHz CPU计算:20,736,000 / 3,000,000,000 = 0.0069秒 = 6.9毫秒 看起来很快?但这只是纯色填充!如果每个像素要做纹理采样+光照计算(1000+周期),一帧就要690毫秒 —— 每秒只能跑1.4帧,完全是幻灯片!

用GPU并行计算(比喻:2000个工人同时刷不同的墙):

工人1-2000:同时计算第1-2000个像素的颜色
工人1-2000:同时计算第2001-4000个像素的颜色
...(重复约1000次)

总时间 = 2,073,600 / 2000 × 1000周期 = 约1,036,800周期 = 0.35毫秒 GPU比CPU快了约2000倍!

关键认知:GPU的每个核心都很"笨",只能做简单的加减乘除。但渲染恰恰不需要复杂逻辑 —— 每个像素的计算逻辑完全一样,只是输入数据不同。这种"数据并行"场景是GPU的设计目标。

为什么需要GPU?

如果没有GPU,所有渲染计算都在CPU上进行。CPU只有几个核心,要逐个像素计算颜色,一个1080P画面需要串行计算207万次。即使CPU再快,也无法在16ms内完成(60fps要求每帧16ms)。GPU用几千个核心同时计算,把207万次计算分摊到并行执行,才能在几毫秒内完成一帧渲染。

没有GPU会怎样?

  • 2D游戏可能还能跑(像素少),但帧率很低
  • 3D游戏完全无法实时运行,只能看幻灯片
  • 任何Shader特效都无法使用
  • 手机屏幕上的复杂UI都会卡顿

自问自答:GPU相关

Q:我的电脑CPU有8核,为什么渲染还是用GPU? A:8核CPU同时只能处理8个像素的计算,而GPU有几千个核心。而且CPU核心还要处理操作系统、游戏逻辑、网络等任务,能分给渲染的核心更少。

Q:既然GPU这么快,为什么不让GPU做所有事情? A:GPU不擅长做逻辑判断(如if/else分支)。当遇到分支时,GPU只能先执行一个分支,再执行另一个分支,导致部分核心空转。而CPU分支预测能力很强,适合做复杂逻辑。

Q:集成显卡(核显)和独立显卡有什么区别? A:核显是CPU内置的GPU,和CPU共享内存,性能较弱但省电。独显有独立的显存和更多的计算核心,性能强但耗电。移动端为了省电常用核显,游戏本和台式机用独显。


二、渲染管线是什么?

汽车制造流水线比喻

渲染管线就像一条汽车制造流水线:

原材料(顶点数据) → 冲压车间(顶点着色器) → 焊接车间(图元装配) → 
喷漆车间(光栅化+片元着色器) → 质检车间(测试) → 成品出库(帧缓冲)

原材料从流水线一端进入,经过多个车间依次处理,最终变成成品(画面)从另一端出来。每个车间只负责自己的工序,上一个车间的输出就是下一个车间的输入。

数据流全景

CPU准备数据 → [顶点数据] → ①顶点输入装配 → ②顶点着色器 → ③曲面/几何着色器(可选) → 
④裁剪与屏幕映射 → ⑤光栅化 → ⑥片元着色器 → ⑦逐片元操作 → ⑧帧缓冲与显示 → 屏幕

每个阶段的输入/输出数据格式:

阶段 输入 输出 数据格式变化
①顶点输入装配 顶点属性数组 图元(三角形) Float32Array → 顶点结构体
②顶点着色器 顶点属性 + Uniform 变换后的顶点 vec3 positionvec4 gl_Position(齐次坐标)
③曲面/几何着色器 顶点 新增/修改的顶点 可选阶段,WebGL不支持
④裁剪与屏幕映射 裁剪空间坐标 屏幕像素坐标 [-1,1] NDC → [0,width] 像素
⑤光栅化 三角形 + 顶点属性 片元(Fragment) 几何 → 像素 + 插值属性(颜色/UV/深度)
⑥片元着色器 片元属性 + Uniform 像素颜色 varying 插值 → vec4 gl_FragColor
⑦逐片元操作 片元颜色 + 深度/模板值 通过测试的片元 颜色 + 深度 + 模板 → 可能丢弃
⑧帧缓冲 通过测试的片元 最终画面 写入颜色缓冲 + 深度缓冲 + 模板缓冲

关键认知:数据在GPU中如何流动?

  1. CPU把顶点数据(位置、UV【纹理坐标,告诉GPU图片的哪个点对应这个顶点】、颜色)放入VBO(顶点缓冲区)
  2. GPU从VBO读取数据,顶点着色器对每个顶点执行一次
  3. 顶点着色器输出 gl_Position(四维齐次坐标:x, y, z, w)
  4. GPU自动做透视除法:NDC = (x/w, y/w, z/w),范围[-1, 1]
  5. 视口变换:NDC → 屏幕像素坐标
  6. 光栅化:确定三角形覆盖哪些像素,生成片元
  7. 每个片元包含:屏幕坐标(x,y) + 插值后的颜色/UV/深度
  8. 片元着色器对每个片元执行一次,输出 gl_FragColor
  9. 逐片元操作:深度测试、模板测试、Alpha混合
  10. 最终颜色写入帧缓冲区

三、GPU渲染管线8大阶段详解

阶段1:顶点输入装配 (Input Assembler) —— "备料区"

为什么需要这个阶段?

GPU本身不知道"我要画一个矩形"或"我要画一个三角形"。它只认识数字。这个阶段的作用就是把人类理解的"图形"翻译成GPU能理解的"数字数组"。

就像你要让外国厨师做一道中国菜,你需要先把菜谱翻译成他的语言,再把食材按顺序摆好。

做什么:从内存中读取顶点数据,按照图元类型组装成几何图形。

备料区比喻:就像厨房的备料区,把冰箱里的食材(顶点数据)取出来,按菜谱(图元类型)摆好。

关键概念

  • 顶点(Vertex):一个点,包含位置(x,y)、纹理坐标(u,v)、颜色(r,g,b,a)等信息
  • VBO(顶点缓冲区):GPU显存里存顶点数据的"容器"
  • IBO(索引缓冲区):告诉GPU哪些顶点组成三角形的"连接表"
  • 图元类型:TRIANGLES(三角形)、LINES(线段)、POINTS(点)

一个Sprite的顶点数据

4个顶点:
  V0(-1,-1)  V1(1,-1)
  V3(-1, 1)  V2(1, 1)

6个索引:[0,1,2, 0,2,3] → 组成2个三角形

为什么用索引?4个顶点就能画2个三角形,不用索引需要6个顶点(有2个重复)

逐步推导:为什么一个矩形需要2个三角形?

GPU只能画三角形(这是硬件设计决定的,三角形是最简单的多边形,且任意三个点一定共面)。

一个矩形有4个顶点,但GPU不认识"矩形"这个概念。我们需要把它拆成三角形:

矩形顶点:
V0(左上) ────── V1(右上)
│                │
│                │
V3(左下) ────── V2(右下)

拆分为2个三角形:
三角形1:V0 → V1 → V2(上半部分)
三角形2:V0 → V2 → V3(下半部分)

不用索引会怎样?

不用索引需要6个顶点:
三角形1:V0, V1, V2
三角形2:V0, V2, V3

注意:V0和V2在两个三角形中重复了!
实际数据量 = 6个顶点 × 每个顶点的大小(如8个float)= 48个float

用索引只需要4个顶点 + 6个索引:
顶点:V0, V1, V2, V3
索引:0, 1, 2, 0, 2, 3
实际数据量 = 4×8 + 6 = 38个float(节省了21%)

对于只有4个顶点的Sprite,节省不明显。但一个3D模型可能有上万个顶点,用索引可以节省大量显存和带宽!

WebGL中的实现(参考月影课程代码):

// 1. 定义顶点数据(类型化数组)
// Float32Array是JS的一种类型化数组,比普通数组更高效,直接存储32位浮点数
const points = new Float32Array([
  -1, -1,   // 顶点A:x=-1, y=-1(左下角)
  0,  1,    // 顶点B:x=0, y=1(顶部中间)
  1, -1,    // 顶点C:x=1, y=-1(右下角)
]);

// 2. 创建缓冲区并写入数据
// createBuffer() 在GPU显存中创建一个缓冲区对象
const bufferId = gl.createBuffer();
// bindBuffer() 将这个缓冲区绑定到 ARRAY_BUFFER 目标
// 后续的 bufferData 操作会作用在这个绑定的缓冲区上
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
// bufferData() 将 JS 中的数据复制到 GPU 显存
// 参数:目标、数据源、使用模式
// gl.STATIC_DRAW 表示数据不会频繁改变,GPU可以优化存储位置
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

Cocos中的体现Batcher2D会把多个Sprite的顶点数据合并到一个大VBO中,这就是"合批"的底层原理。

初学者常见错误

  1. 用普通Array代替Float32Array

    // 错误!普通JS数组会被引擎转换为Float32Array,有额外开销
    const points = [-1, -1, 0, 1, 1, -1];
    
    // 正确:直接使用Float32Array
    const points = new Float32Array([-1, -1, 0, 1, 1, -1]);
    
  2. 每帧都重新创建VBO

    // 错误!每帧创建新缓冲区是性能杀手
    function update() {
      const buffer = gl.createBuffer(); // 不要在这里创建!
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
      gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
    }
    
    // 正确:创建一次,更新用bufferSubData
    const buffer = gl.createBuffer(); // 初始化时创建
    function update() {
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, newData); // 只更新数据
    }
    
  3. 顶点数据顺序和图元类型不匹配

    // TRIANGLES 模式:每3个顶点组成一个三角形
    gl.drawArrays(gl.TRIANGLES, 0, 6); // 6个顶点 = 2个三角形
    
    // TRIANGLE_STRIP 模式:顶点顺序不同!
    // V0, V1, V2 组成三角形1
    // V1, V2, V3 组成三角形2(注意重叠了V1,V2)
    // 数据顺序必须按strip方式排列
    

自问自答:顶点输入装配

Q:为什么GPU只认三角形,不能直接画矩形或圆形? A:三角形有3个重要特性:(1)任意3个点一定共面(不会扭曲);(2)三角形内部一定是凸的;(3)任何多边形都可以拆成三角形。这些特性让GPU可以用统一的硬件电路处理所有图形。

Q:VBO存在哪里?CPU内存还是GPU显存? A:VBO在GPU显存中。gl.bufferData()的作用就是把数据从CPU内存(JS的Float32Array)复制到GPU显存。复制完成后,JS中的数组就可以释放了(如果不需要的话)。

Q:如果不懂这个阶段会怎样? A:当你写自定义Shader时,如果顶点数据格式和Shader中的attribute不匹配,画面会完全错乱甚至黑屏。比如Shader期望每个顶点有position(vec2) + color(vec4) + uv(vec2),但你只传了position,那么color和uv会读到垃圾数据。


阶段2:顶点着色器 (Vertex Shader) —— "定位员" ⭐可编程

为什么需要顶点着色器?

想象一下:你创建了一个Sprite,设置了它的位置为(100, 200)、旋转45度、缩放2倍。但GPU根本不知道这些概念!GPU只认识"顶点坐标"。

顶点着色器的工作就是:把你在引擎中设置的"位置/旋转/缩放"转换成GPU能理解的"顶点应该在屏幕的哪个像素上"

做什么:对每个顶点执行一次,核心任务是MVP矩阵变换 —— 把顶点从模型空间变换到屏幕上应该出现的位置。

定位员比喻:就像快递分拣员,拿到一个包裹(顶点),根据地址(MVP矩阵)把它放到正确的货架上(屏幕位置)。

MVP变换详解

为什么要分4个空间?不能直接一步到位吗?

不能直接一步到位,因为每个空间有自己的"语言"和用途:

模型空间 → [Model矩阵] → 世界空间 → [View矩阵] → 相机空间 → [Projection矩阵] → 裁剪空间

M = 节点的位移+旋转+缩放(你在Cocos中设置的position/rotation/scale)
V = 摄像机的位置和朝向
P = 透视投影(近大远小) 或 正交投影(2D游戏用)

逐步理解坐标空间变换:

Step 1 - 模型空间(局部坐标)

想象你在画一幅画:
- 画的中心点是你自己定义的(比如Sprite的中心是(0,0))
- 顶点的位置是相对于这个中心点的

Sprite的4个顶点在模型空间:
V0 = (-0.5, -0.5)  // 左下
V1 = ( 0.5, -0.5)  // 右下
V2 = ( 0.5,  0.5)  // 右上
V3 = (-0.5,  0.5)  // 左上

Step 2 - Model矩阵变换(模型空间 → 世界空间)

你在Cocos中设置了:
node.position = (100, 200)
node.rotation = 45
node.scale = (2, 2)

Model矩阵把局部坐标变换到世界坐标:
世界坐标 = Model矩阵 × 局部坐标

V0的世界坐标 = 旋转45°并缩放2倍后的(-0.5, -0.5) + 位移(100, 200)

Step 3 - View矩阵变换(世界空间 → 相机空间)

相机也是世界中的一个物体,有位置和朝向。
View矩阵的作用:把世界坐标"搬到"以相机为原点的坐标系中。

类比:你站在街上拍照。
- 世界坐标:建筑物在地球上的经纬度
- 相机坐标:建筑物相对于你相机的位置(前方10米、左方5米)

Step 4 - Projection矩阵变换(相机空间 → 裁剪空间)

Projection矩阵决定"怎么看":

透视投影(Perspective):
- 近大远小(3D游戏常用)
- 远处的物体看起来更小
- 需要4x4矩阵 + 齐次坐标

正交投影(Orthographic):
- 远近一样大(2D游戏常用)
- 没有透视变形
- 也是4x4矩阵,但w分量始终为1

顶点着色器输出格式

// 顶点着色器必须输出的变量
gl_Position = vec4(x, y, z, w);  // 四维齐次坐标

// 透视除法后(GPU自动执行):
// NDC.x = gl_Position.x / gl_Position.w  → 范围[-1, 1]
// NDC.y = gl_Position.y / gl_Position.w  → 范围[-1, 1]
// NDC.z = gl_Position.z / gl_Position.w  → 范围[-1, 1](深度值)

为什么用齐次坐标(四维)而不是三维坐标?

因为透视投影需要除法操作。在三维空间中,透视投影是一个非线性变换(近大远小),无法用矩阵乘法表示。引入第四维w后,透视投影可以用4x4矩阵表示,最后通过除以w完成透视除法。

没有齐次坐标会怎样?

无法用统一的矩阵乘法处理所有变换(平移、旋转、缩放、透视),GPU管线需要特殊的硬件逻辑来处理透视投影,代码复杂度大增。

GLSL代码示例(参考月影课程):

// 顶点着色器 - 基础版本
// attribute 修饰符表示这是从顶点数据读取的输入
attribute vec2 position;  // 输入:顶点位置(二维)

void main() {
    // gl_PointSize 只在绘制点(POINTS模式)时有效
    gl_PointSize = 1.0;
    // vec4(position, 1.0, 1.0) 将vec2扩展为vec4
    // 前两个分量来自position,z=1.0(最前面),w=1.0(不做透视除法)
    gl_Position = vec4(position, 1.0, 1.0);  // 输出:齐次坐标
}

带颜色传递的顶点着色器(参考月影课程):

// attribute 修饰符:顶点属性,每个顶点一个值
attribute vec2 position;
// varying 修饰符:从顶点着色器传递到片元着色器
// 在光栅化阶段,GPU会自动在三角形内插值这个值
varying vec3 color;  // 输出给片元着色器(会自动插值)

void main() {
    gl_PointSize = 1.0;
    // 将顶点位置[-1,1]映射到颜色[0,1]
    // position.x = -1 → color.r = 0
    // position.x = 1  → color.r = 1
    color = vec3(0.5 + position * 0.5, 0.0);  // 位置映射为颜色
    // position * 0.5 将坐标范围从[-1,1]缩小到[-0.5,0.5]
    gl_Position = vec4(position * 0.5, 1.0, 1.0);  // 缩小为原来的一半
}

关键认知

  • 顶点着色器执行次数 = 顶点数(一个Sprite只有4个顶点,执行4次)
  • 可以在顶点着色器中做顶点动画(波浪、飘动、膨胀等)
  • GPU并行特性:所有顶点同时计算,不需要遍历

初学者常见错误

  1. 混淆attribute和uniform

    // 错误:用uniform接收顶点数据
    uniform vec2 position; // uniform是所有顶点共享的,不能存不同顶点的位置
    
    // 正确:顶点位置用attribute
    attribute vec2 position; // 每个顶点有自己的position
    
  2. 忘记输出gl_Position

    // 错误:没有gl_Position输出
    void main() {
      vec4 pos = vec4(position, 0.0, 1.0);
    }
    
    // 正确:必须给gl_Position赋值
    void main() {
      gl_Position = vec4(position, 0.0, 1.0);
    }
    
  3. 在顶点着色器里做复杂光照计算

    // 错误:顶点着色器只有4次执行,但光照应该在每个像素上计算
    // 这里算出的颜色只在顶点处正确,三角形内部是插值的,会出错
    varying vec3 lighting;
    void main() {
      lighting = calculateComplexLighting(); // 错!
      gl_Position = ...;
    }
    
    // 正确:把光照计算移到片元着色器
    // 顶点着色器只传递法线、位置等数据
    varying vec3 normal;
    varying vec3 worldPos;
    void main() {
      normal = ...;
      worldPos = ...;
      gl_Position = ...;
    }
    

自问自答:顶点着色器

Q:为什么顶点着色器执行次数这么少,还要单独作为一个阶段? A:因为顶点变换是"必须做"的 —— 每个顶点必须从模型空间变换到屏幕空间。而且这个阶段的输出(gl_Position)决定了后续光栅化阶段要处理哪些像素。

Q:2D游戏也需要MVP矩阵吗? A:需要,但简化了。2D游戏通常用正交投影(没有近大远小),Model矩阵只包含位移和缩放。Cocos引擎会自动计算并传入MVP矩阵,你通常不需要手动处理。

Q:如果不懂顶点着色器会怎样? A:当你写自定义Shader时,如果顶点坐标算错了,整个画面会错位、拉伸、甚至完全消失。比如把z坐标设成0以外的地方,可能导致Sprite被裁剪掉。


阶段3:曲面细分 & 几何着色器 —— "精雕师"(可选,WebGL不支持)

做什么:在GPU端增加或修改几何图元。

精雕师比喻:就像木雕师傅,拿到一块粗木头(低面数模型),精雕细刻出细节(高面数模型)。

注意:WebGL/WebGL2不支持这两个阶段,H5游戏开发者了解即可。在桌面端/主机端常用于地形LOD、草地渲染等。

自问自答:曲面细分

Q:既然WebGL不支持,为什么还要学? A:了解这个概念可以帮助你理解为什么桌面3D游戏的细节比H5游戏丰富。桌面端可以用曲面细分动态增加模型细节,而H5游戏需要在建模时就确定面数。


阶段4:裁剪与屏幕映射 —— "质检员"

为什么需要裁剪?

想象你在拍照,相机只能看到前方一定角度范围内的景物。背后的东西不需要画到照片上 —— 画了也是浪费计算。

裁剪的作用就是:把那些肯定看不到的三角形提前丢掉,不让它们进入后续昂贵的计算阶段

做什么:把视锥体外的图元丢弃,把裁剪空间坐标转换为屏幕像素坐标。

质检员比喻:质检员检查产品是否在规格范围内,不合格的直接扔掉,合格的贴上出厂标签(屏幕坐标)。

三步走

  1. 裁剪:完全在视锥体外的三角形 → 丢弃;部分在外 → 沿边界切割
  2. 透视除法:齐次坐标(x,y,z,w) → NDC(x/w, y/w, z/w),范围[-1,1]
  3. 视口变换:NDC [-1,1] → 屏幕像素 [0, width] × [0, height]

逐步推导:视口变换公式

已知:
- NDC坐标范围:[-1, 1]
- 屏幕分辨率:width × height

求:NDC坐标 (nx, ny) 对应的屏幕像素坐标 (sx, sy)

Step 1:将[-1,1]映射到[0,1]
  tX = (nx + 1) / 2
  tY = (ny + 1) / 2

Step 2:将[0,1]映射到[0, width]和[0, height]
  sx = tX × width = (nx + 1) × width / 2
  sy = tY × height = (ny + 1) × height / 2

验证:
- 当nx = -1(最左)时,sx = 0(屏幕最左边)✓
- 当nx = 0(中间)时,sx = width / 2(屏幕中间)✓
- 当nx = 1(最右)时,sx = width(屏幕最右边)✓

深度值的计算

NDC.z = gl_Position.z / gl_Position.w  → 范围[-1, 1]
深度缓冲值 = NDC.z * 0.5 + 0.5  → 范围[0, 1]

为什么深度值要映射到[0,1]范围?

深度缓冲使用定点数或浮点数存储,[0,1]范围便于硬件存储和比较。0表示最近(近平面),1表示最远(远平面)。

Cocos中的体现:2D游戏中,完全超出Canvas边界的Sprite会被跳过,不生成渲染数据,这就是"2D屏幕裁剪"。

初学者常见错误

  1. 手动做透视除法

    // 错误:在顶点着色器里手动除w
    gl_Position = vec4(position / w, z, w); // 多此一举!
    
    // 正确:顶点着色器输出齐次坐标,GPU自动做透视除法
    gl_Position = vec4(position, z, w);
    
  2. 混淆NDC和屏幕坐标

    // 错误:以为gl_Position直接就是像素坐标
    gl_Position = vec4(100.0, 200.0, 0.0, 1.0); // 100,200会跑到屏幕外!
    
    // 正确:NDC范围是[-1,1],(0,0)是屏幕中心
    gl_Position = vec4(0.0, 0.0, 0.0, 1.0); // 这才是屏幕中心
    

自问自答:裁剪与屏幕映射

Q:为什么裁剪在顶点着色器之后,而不是之前? A:因为裁剪需要知道顶点在裁剪空间的位置,而这个位置是顶点着色器算出来的(gl_Position)。顶点着色器之前,顶点还在模型空间,不知道在屏幕上的位置。

Q:如果不懂裁剪会怎样? A:你可能会写出"物体移出屏幕后还在消耗性能"的代码。实际上,完全在屏幕外的物体应该被引擎裁剪掉,不生成DrawCall。如果你手动设置了一些奇怪的变换,可能导致物体明明在屏幕外却还在渲染。


阶段5:光栅化 (Rasterization) —— "像素化机器"

为什么需要光栅化?

GPU处理的是"几何图形"(三角形),但屏幕显示的是"像素网格"。这两个世界之间需要一座桥梁 —— 光栅化就是这座桥。

类比:你有一张矢量图(无限清晰的线条),要把它显示在电脑屏幕上。屏幕是由一个个小方格(像素)组成的,光栅化就是决定"哪些小方格应该被点亮"。

做什么:把连续的三角形变成离散的像素点(片元),并插值顶点属性。

十字绣比喻:想象你在绣十字绣,图案(三角形)是连续的线条,但绣出来是一个个离散的格子(像素)。光栅化就是"把线条图案转成格子图案"的过程。

三大核心动作

  1. 扫描转换:确定三角形覆盖了哪些像素
  2. 片元生成:每个被覆盖的像素 → 一个Fragment(片元)
  3. 属性插值:UV/颜色在三角形内按重心坐标线性插值

逐步理解:扫描转换

假设一个三角形覆盖了屏幕上的这些像素:

    □ □ ■ ■ □ □
    □ ■ ■ ■ ■ □
    ■ ■ ■ ■ ■ ■

■ = 被三角形覆盖的像素(生成片元)
□ = 未被覆盖的像素(不生成片元)

GPU如何快速判断?
方法:扫描线算法
1. 找到三角形的最小和最大y值
2. 对每一行(y),找到三角形左边和右边的x边界
3. 边界内的所有像素都属于这个三角形

逐步理解:重心坐标插值

三角形有3个顶点,每个顶点有自己的颜色:
V0 = 红色(1, 0, 0)
V1 = 绿色(0, 1, 0)
V2 = 蓝色(0, 0, 1)

三角形内部某点P的颜色怎么算?

P = α×V0 + β×V1 + γ×V2
其中 α + β + γ = 1,且 α,β,γ ≥ 0

α,β,γ 就是P点的重心坐标,表示P到三个顶点的"权重"。

如果P在三角形中心:α=β=γ=1/3
P的颜色 = (1/3)×红 + (1/3)×绿 + (1/3)×蓝 = 灰色(1/3, 1/3, 1/3)

如果P靠近V0:α很大,β和γ很小
P的颜色 ≈ 红色

光栅化后片元包含的数据结构

Fragment {
    screenX, screenY,     // 屏幕坐标
    color,                // 插值后的颜色(来自varying)
    uv,                   // 插值后的纹理坐标(来自varying)
    depth,                // 插值后的深度值
    stencil,              // 模板值
}

为什么属性要插值?

顶点着色器只计算了3个顶点的颜色,但三角形内部有成千上万个像素。GPU通过重心坐标在三个顶点颜色之间做线性插值,得到每个像素的颜色。这样三角形内部颜色平滑过渡,而不是纯色填充。

没有插值会怎样?

每个三角形只能显示一种颜色(纯色填充),无法实现渐变、纹理贴图等效果。3D模型看起来会像低多边形风格(Flat Shading)。

关键认知

顶点着色器执行次数 = 顶点数(如4次)
片元着色器执行次数 = 覆盖像素数(可达200万次!)

→ 片元着色器的复杂度决定GPU性能!
→ 能放在顶点着色器的计算,绝不要放片元着色器

初学者常见错误

  1. 以为片元就是像素

    片元(Fragment)≠ 像素(Pixel)
    - 片元是"候选像素",经过逐片元操作后可能被淘汰
    - 像素是最终显示在屏幕上的点
    - 一个片元可能通过测试变成像素,也可能被深度测试丢弃
    
  2. 在片元着色器里做顶点级别的计算

    // 错误:在片元着色器里计算顶点位置
    // 片元着色器每像素执行一次,这里算位置浪费了!
    vec4 pos = calculateVertexPosition();
    
    // 正确:顶点位置计算在顶点着色器里做
    // 片元着色器只接收varying插值后的值
    

自问自答:光栅化

Q:光栅化是软件算法还是硬件电路? A:现代GPU有专门的光栅化硬件单元,不是软件算法。这个单元以极高的并行度工作,每秒可以处理数十亿个片元。

Q:为什么光栅化后还要做插值,不能直接用顶点颜色吗? A:如果直接用顶点颜色,每个三角形只能显示一种颜色(Flat Shading)。插值让颜色在三角形内平滑过渡,才能实现渐变、光照、纹理等效果。

Q:如果不懂光栅化会怎样? A:你可能会写出"在片元着色器里做顶点变换"的低效代码。光栅化的关键认知是:片元着色器执行次数 = 像素数,是顶点着色器的几千到几百万倍。任何可以放在顶点着色器的计算都不应该放在片元着色器。


阶段6:片元着色器 (Fragment Shader) —— "上色员" ⭐可编程

为什么需要片元着色器?

光栅化生成了"空白"的片元(只有位置信息),但片元应该是什么颜色?这取决于很多因素:

  • 这个Sprite用的是什么纹理图片?
  • 光照条件如何?
  • 有没有特殊效果(溶解、发光等)?

片元着色器就是回答这些问题的"上色专家"。

做什么:对每个片元执行一次,决定该像素的最终颜色。

上色员比喻:上色员拿到一个空白的格子(片元),根据设计图(纹理+Shader逻辑)给格子上色。

核心操作

// 最简单的片元着色器 - 纯色输出(参考月影课程)
// precision 声明浮点数的计算精度
// mediump = 中等精度(16位浮点),平衡性能和精度
// 如果省略这行,WebGL会报错或默认使用低精度
precision mediump float;

// void main() 是片元着色器的入口函数
// 每个片元都会执行一次这个函数
void main() {
    // gl_FragColor 是片元着色器的输出变量
    // vec4(1.0, 0.0, 0.0, 1.0) = RGBA颜色
    // R=1.0(红色满值),G=0.0(无绿色),B=0.0(无蓝色),A=1.0(不透明)
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 纯红色
}

带颜色插值的片元着色器(参考月影课程):

precision mediump float;
// varying 变量从顶点着色器接收数据
// 注意:在片元着色器中,varying变量已经被GPU自动插值了!
// 如果三角形3个顶点的color分别是红、绿、蓝
// 那么三角形内部的片元会根据位置自动得到插值后的颜色
varying vec3 color;  // 从顶点着色器接收(已插值)

void main() {
    // vec4(color, 1.0) 将vec3扩展为vec4
    // 前三个分量来自插值后的color,Alpha=1.0(不透明)
    gl_FragColor = vec4(color, 1.0);  // 使用插值后的颜色
}

带纹理采样的片元着色器

precision mediump float;
// varying 接收插值后的纹理坐标
varying vec2 v_uv;
// uniform 声明纹理采样器
// sampler2D 是2D纹理的类型
uniform sampler2D u_texture;

void main() {
    // texture2D() 从纹理中采样颜色
    // 参数1:纹理采样器(绑定到哪个纹理)
    // 参数2:UV坐标(纹理上的采样位置)
    // 返回值:vec4颜色(RGBA)
    vec4 texColor = texture2D(u_texture, v_uv);
    gl_FragColor = texColor;
}

可以实现的效果:溶解、发光、马赛克、灰度、描边、扭曲……

性能警示

  • 1080P全屏 = 207万像素 = 片元着色器每帧执行207万次
  • 每多一行计算 → 放大207万倍!
  • 多次texture2D采样(如模糊)→ 性能杀手

初学者常见错误

  1. 忘记声明precision

    // 错误:没有precision声明
    void main() {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }
    // 在某些GPU上会编译失败或默认使用低精度导致颜色断层
    
    // 正确:必须声明浮点精度
    precision mediump float;
    void main() { ... }
    
  2. 在片元着色器里使用attribute

    // 错误:attribute只能在顶点着色器中使用
    attribute vec2 uv; // 编译错误!
    
    // 正确:用varying从顶点着色器传递数据
    varying vec2 v_uv; // 顶点着色器输出,片元着色器输入
    
  3. 分支语句导致性能问题

    // 错误:片元着色器中的if/else会降低并行效率
    void main() {
      if (v_uv.x > 0.5) {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 一半核心做这行
      } else {
        gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0); // 另一半核心做这行
      }
    }
    // GPU必须执行两个分支,然后丢弃不需要的结果
    
    // 优化:尽量用数学运算代替分支
    void main() {
      float mask = step(0.5, v_uv.x); // x>0.5时mask=1,否则=0
      gl_FragColor = mix(
        vec4(0.0, 1.0, 0.0, 1.0), // 绿色
        vec4(1.0, 0.0, 0.0, 1.0), // 红色
        mask
      );
    }
    

自问自答:片元着色器

Q:为什么片元着色器叫"Fragment Shader"而不是"Pixel Shader"? A:因为片元不一定是最终像素。一个片元可能通过所有测试变成像素,也可能被深度测试/模板测试丢弃。"Fragment"强调的是"候选像素"的概念。

Q:precision mediump float是什么意思? A:这是GLSL的精度声明。mediump表示中等精度(约16位浮点)。在移动端,如果不声明精度,不同GPU的行为可能不同。highp精度高但慢,lowp快但可能有颜色断层。

Q:如果不懂片元着色器会怎样? A:片元着色器是Shader编程的核心。不懂它,你无法实现任何自定义视觉效果(溶解、发光、水波纹等)。而且,片元着色器的性能问题往往是游戏卡顿的主要原因 —— 每多一行代码都被放大207万倍!


阶段7:逐片元操作 (Per-Fragment Operations) —— "检验员"

为什么需要逐片元操作?

片元着色器算出了一个颜色,但这个片元不一定能显示在屏幕上。为什么?

  • 它可能在ScrollView的裁剪区域外面
  • 它可能被另一个物体挡住了(深度测试)
  • 它可能只是Mask外面不应该显示的部分(模板测试)
  • 它可能是半透明的,需要和背景混合

逐片元操作就像工厂的"质检流水线",每个片元都要通过一系列检查才能"出厂"。

做什么:对每个片元做5步测试,决定最终是否显示。

5步测试流程

步骤 测试名称 作用 算法 Cocos中的体现
1 裁剪测试 矩形区域外丢弃 `if (x < scissorX
2 Alpha测试 透明度低于阈值丢弃 if (color.a < alphaThreshold) discard; 图片镂空效果
3 模板测试 Mask遮罩的实现原理 if ((stencilRef & stencilMask) != (stencilBuffer & stencilMask)) discard; Mask组件
4 深度测试 前后遮挡关系 if (fragmentDepth > depthBuffer[x,y]) discard; 3D物体的遮挡
5 混合操作 透明物体颜色混合 Result = SrcColor * SrcAlpha + DstColor * (1 - SrcAlpha) 半透明Sprite的叠加

逐步理解:测试的顺序为什么重要?

测试是按固定顺序执行的,任何一步失败都会立即丢弃片元:

片元进入 → 裁剪测试?→ 失败?→ 丢弃
              ↓ 通过
          Alpha测试?→ 失败?→ 丢弃
              ↓ 通过
          模板测试?→ 失败?→ 丢弃
              ↓ 通过
          深度测试?→ 失败?→ 丢弃
              ↓ 通过
          混合操作 → 写入帧缓冲

这个顺序是硬件固定的,不能改变!

为什么先深度测试后混合? 因为如果一个片元被前面的物体挡住了(深度测试失败),就不需要浪费时间去计算混合了。这个顺序是GPU硬件优化的结果。

深度测试详解

深度缓冲(Z-Buffer)数据结构

深度缓冲是一个二维数组,大小与屏幕分辨率相同:
depthBuffer[width][height] = float

每个像素存储一个深度值(0.0 ~ 1.0):
- 0.0 = 最近(近平面)
- 1.0 = 最远(远平面)

深度测试的工作流程

假设要画两个重叠的Sprite:

Sprite A(在后面):深度值 = 0.5
Sprite B(在前面):深度值 = 0.3

画Sprite A时:
1. 片元深度 = 0.5
2. 深度缓冲初始值 = 1.0(最远)
3. 比较:0.5 < 1.0?是!通过测试
4. 片元颜色写入帧缓冲
5. 更新深度缓冲:depthBuffer[x,y] = 0.5

画Sprite B时:
1. 片元深度 = 0.3
2. 深度缓冲当前值 = 0.5
3. 比较:0.3 < 0.5?是!通过测试(B更近)
4. 片元颜色覆盖A的颜色
5. 更新深度缓冲:depthBuffer[x,y] = 0.3

最终显示的是B的颜色(正确!)

深度缓冲存储格式对比

格式 精度 内存占用(1080P) z-fighting风险 适用场景
16位整数 ~1/65536 4MB 2D游戏、UI
24位整数 ~1/16777216 6MB 一般3D游戏
32位浮点 非均匀分布 8MB 大场景3D、高精度需求

为什么深度缓冲精度不均匀?

32位浮点深度缓冲中,深度值经过透视投影后不是线性分布的。靠近近平面的区域精度很高(可以区分很小的深度差),但靠近远平面的区域精度很低(大范围的深度值映射到同一个浮点数)。这就是为什么远处的物体会出现z-fighting(深度值相同导致闪烁)。

解决方案:

  1. 增加近平面距离:让物体不要太靠近相机
  2. 使用对数深度缓冲logDepth = log(z) / log(far),让深度分布更均匀
  3. 使用更高精度的深度格式:24位或32位
  4. 避免共面:不要让两个面完全重合

z-fighting可视化

现象:两个物体重合时,像素不断闪烁(一帧显示A,一帧显示B)
原因:它们的深度值相同(或非常接近),深度测试时随机通过
解决:增加偏移(Polygon Offset)、调整近平面、使用对数深度

模板测试详解

模板测试的生活类比: 想象你在做喷漆工艺,先用胶带贴出不需要喷漆的区域。模板缓冲就像"数字胶带",控制哪些像素可以被绘制。

模板缓冲操作

操作 说明 应用场景
keep 保持当前值 默认操作
zero 设为0 清除模板区域
replace 替换为参考值 Mask形状写入
increment 加1(饱和) 多层Mask嵌套
decrement 减1(饱和) 退出Mask区域
invert 按位取反 特殊效果

Mask组件的2-Pass实现

Pass 1(写模板):
  stencilFunc: ALWAYS        // 总是通过
  stencilPassOp: REPLACE     // 通过时替换为参考值
  stencilRef: 1              // 参考值=1
  stencilMask: 0xFF          // 所有位都写
  → 把Mask形状区域写入模板值1

Pass 2(使用模板):
  stencilFunc: EQUAL         // 模板值等于参考值才通过
  stencilRef: 1              // 参考值=1
  stencilMask: 0xFF          // 比较所有位
  → 只有模板值为1的区域(Mask内部)才能通过测试

混合公式推导

标准Alpha混合

结果色 = 源色 × 源Alpha + 目标色 × (1 - 源Alpha)

其中:
- 源色 = 当前片元颜色(要画上去的颜色)
- 目标色 = 帧缓冲中已有的颜色
- 源Alpha = 当前片元的透明度

逐步推导Alpha混合公式

场景:一张半透明玻璃(Alpha=0.5,红色)放在绿色背景上

玻璃贡献的颜色 = 玻璃颜色 × 玻璃透明度
               = 红色 × 0.5
               = 0.5红色

背景透过玻璃显示的颜色 = 背景颜色 × (1 - 玻璃透明度)
                       = 绿色 × 0.5
                       = 0.5绿色

最终颜色 = 0.5红色 + 0.5绿色 = 黄色(红+绿=黄)

验证极端情况:
- Alpha=1(不透明):最终 = 1×红色 + 0×绿色 = 红色 ✓
- Alpha=0(完全透明):最终 = 0×红色 + 1×绿色 = 绿色 ✓

其他混合模式

混合模式 公式 应用场景
标准Alpha Src × SrcAlpha + Dst × (1 - SrcAlpha) 普通半透明
加法混合 Src × 1 + Dst × 1 发光、特效叠加
乘法混合 Src × Dst + Dst × 0 变暗、阴影
屏幕混合 Src × (1 - Dst) + Dst × 1 提亮、光晕

为什么混合公式是这样的?

标准Alpha混合公式来源于"光的叠加原理"。假设背景是Dst,前景是Src,前景的透明度是α。那么最终看到的颜色 = 前景贡献的颜色(Src × α)+ 背景透过前景显示的颜色(Dst × (1-α))。当α=1时不透明,完全显示前景;当α=0时完全透明,只显示背景。

踩坑

  • 透明物体必须关闭depthWrite,否则遮挡后面的透明物体
  • 透明物体必须从后往前渲染,否则混合结果不对
  • Early-Z优化:不透明物体从前往后渲染 → 被挡的片元不执行片元着色器 → 省GPU

初学者常见错误

  1. 透明物体从前往后渲染

    错误顺序(从前到后):
    先画前面的透明玻璃 → 再画后面的物体
    结果:后面的物体被深度测试丢弃,玻璃后面什么都看不到!
    
    正确顺序(从后到前):
    先画后面的物体 → 再画前面的透明玻璃
    结果:玻璃和后面的物体正确混合 ✓
    
  2. 透明物体没有关闭深度写入

    // 错误:透明物体也写深度缓冲
    // 导致后面的透明物体被前面的透明物体挡住
    material.depthWrite = true; // 错!
    
    // 正确:透明物体只读深度,不写深度
    material.depthWrite = false; // 对!
    material.depthTest = true;   // 仍然要测试深度
    
  3. 忘记清除深度缓冲

    // 错误:每帧不清除深度缓冲
    // 上一帧的深度值会干扰这一帧的渲染
    gl.clear(gl.COLOR_BUFFER_BIT); // 只清颜色,没清深度!
    
    // 正确:颜色和深度都要清除
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    

自问自答:逐片元操作

Q:2D游戏也需要深度测试吗? A:2D游戏通常不需要复杂的深度测试,因为Sprite按渲染顺序叠加即可。但Cocos的2D渲染仍然使用深度缓冲来管理节点层级(z-order),只是深度值通常由引擎自动设置。

Q:为什么Mask组件会增加2个DrawCall? A:因为Mask需要2-Pass渲染:Pass 1把Mask形状写入模板缓冲,Pass 2用模板测试裁剪子节点。每Pass至少1个DrawCall。

Q:如果不懂逐片元操作会怎样? A:你会遇到各种"莫名其妙"的渲染bug:半透明物体显示异常、Mask不生效、3D物体重叠时闪烁(z-fighting)、ScrollView裁剪区域不工作等。这些问题都源于对测试顺序和状态的不理解。


阶段8:帧缓冲与显示 —— "展示柜"

为什么需要帧缓冲?

GPU渲染的结果不能直接显示在屏幕上(屏幕是显示器的硬件,GPU是独立的计算单元)。帧缓冲就是GPU和屏幕之间的"中转站"。

做什么:所有测试通过的颜色写入帧缓冲区,最终显示到屏幕。

双缓冲比喻:就像画展的展板,有两块:

  • 后缓冲(Back Buffer):画家正在上面画画(GPU写入)
  • 前缓冲(Front Buffer):观众正在看的那块(屏幕显示)
  • 画完一帧 → 两块展板瞬间对调(Swap) → 观众看到新画

为什么需要双缓冲? 如果GPU边画边显示,观众会看到画了一半的画面(画面撕裂)。双缓冲确保每次看到的都是完整的帧。

单缓冲的问题可视化

时间线:
t=0ms: GPU开始画第1帧(画到一半)
t=8ms: 屏幕刷新,显示半成品的画面(上半部分是新的,下半部分是旧的)→ 画面撕裂!
t=16ms: GPU画完第1帧

双缓冲解决:
t=0ms: GPU在后缓冲画第1帧,前缓冲显示旧帧
t=8ms: 屏幕刷新,显示前缓冲(完整的旧帧)→ 没有撕裂
t=16ms: GPU画完后缓冲,Swap前后缓冲
t=24ms: 屏幕刷新,显示前缓冲(完整的第1帧)→ 完美!

三缓冲(Triple Buffering)

比双缓冲多一块缓冲区:
- 前缓冲:显示中
- 中缓冲:已渲染完成,等待交换
- 后缓冲:正在渲染

优点:GPU不需要等待V-Sync,可以持续渲染,减少空闲
缺点:多占用一块内存,延迟增加一帧

V-Sync(垂直同步):把缓冲区交换时机和屏幕刷新率同步,防止撕裂。

初学者常见错误

  1. 在渲染过程中读取帧缓冲

    // 错误:GPU正在写入帧缓冲时读取
    // 会导致未定义行为(可能读到半新半旧的数据)
    gl.readPixels(0, 0, width, height, ...); // 不要在渲染时读取!
    
    // 正确:使用FBO离屏渲染,或等待当前帧完成
    
  2. 混淆帧缓冲和纹理

    帧缓冲(Frame Buffer)是渲染目标,包含颜色/深度/模板附件
    纹理(Texture)是图像数据,可以作为帧缓冲的附件
    
    关系:帧缓冲可以附加纹理作为颜色输出
    用途差异:帧缓冲用于渲染,纹理用于采样
    

自问自答:帧缓冲与显示

Q:为什么SwapBuffers不会导致画面闪烁? A:因为Swap操作只是交换指针(告诉显示器"现在开始读另一块内存"),而不是复制像素数据。这个指针交换是硬件级别的原子操作,耗时极短(微秒级),不会在屏幕上留下中间状态。

Q:V-Sync开启后为什么帧率被锁在60fps? A:因为屏幕刷新率是60Hz,V-Sync要求GPU必须等屏幕刷新完才能交换缓冲。如果GPU在8ms就画完了,它要再等8.67ms才能交换,所以帧率被锁在60fps。

Q:如果不懂帧缓冲会怎样? A:你可能会在使用RenderTexture(FBO)时遇到各种问题:画面拉伸、黑屏、性能下降等。理解帧缓冲有助于你正确使用离屏渲染和后处理效果。


四、Early-Z优化详解

为什么需要Early-Z?

假设你正在画一个复杂的3D场景:前面有一堵墙,墙后面有很多物体。如果不做优化,GPU会:

  1. 对每个像素执行复杂的片元着色器(纹理采样、光照计算)
  2. 然后做深度测试发现"哦,这个像素被墙挡住了"
  3. 丢弃结果 —— 刚才的计算全部浪费了!

Early-Z 就是解决这个问题:在片元着色器之前做深度测试,如果被遮挡就直接跳过,不做任何无用计算

什么是Early-Z?

正常情况下,深度测试在片元着色器之后执行(阶段7)。这意味着即使一个片元最终被深度测试丢弃,它的片元着色器也已经执行了(浪费GPU计算)。

Early-Z 是一种硬件优化:在片元着色器之前就做深度测试,如果确定会被遮挡,就直接跳过片元着色器执行。

传统流程:片元着色器 → 深度测试 → 可能丢弃(浪费)
         ↓
         计算了1000条指令 → 然后丢弃 → 100%浪费

Early-Z流程:深度测试 → 片元着色器 → 保证通过(不浪费)
             ↓
             被遮挡?→ 直接跳过 → 节省1000条指令!

Early-Z的实现机制

Hi-Z(层次Z缓冲)

GPU维护一个低分辨率的深度缓冲(如8x8像素一个块),记录每个块的最大/最小深度值。

测试时:
1. 先查Hi-Z,如果片元深度明显大于块的最大深度 → 肯定被遮挡 → 跳过
2. 如果可能通过,再查完整的深度缓冲
3. 这样大部分被遮挡的片元在Early-Z阶段就被剔除了

类比:快递分拣
- 传统方式:每个包裹都拆开来检查(片元着色器),然后发现地址不对(深度测试失败)→ 浪费
- Early-Z:先看邮编(Hi-Z),邮编不对直接退回 → 高效

Early-Z的失效条件

以下情况会导致Early-Z失效,GPU回退到传统的后期深度测试:

失效条件 原因
片元着色器中修改 gl_FragDepth 硬件无法在着色器执行前知道最终深度
使用 discard 关键字 片元可能在着色器中被丢弃,影响深度写入
Alpha Test(透明度测试) 片元可能在着色器中被丢弃
开启Alpha混合且关闭深度写入 透明物体通常不按深度排序
模板测试状态复杂 模板操作可能改变片元命运
关闭深度测试 没有深度缓冲,Early-Z无从谈起

为什么修改gl_FragDepth会导致Early-Z失效?

Early-Z的前提是"片元着色器执行前的深度值就是最终深度值"。如果片元着色器修改了深度,硬件在Early-Z阶段用的深度值就不对了,可能导致错误的剔除(把应该显示的片元剔掉了)。因此GPU遇到这种情况会直接关闭Early-Z。

初学者常见错误

  1. 在片元着色器里随意使用discard

    // 错误:使用discard会关闭Early-Z优化
    void main() {
      if (texture2D(u_texture, v_uv).a < 0.5) {
        discard; // 这行代码会让整个Shader失去Early-Z优化!
      }
      gl_FragColor = ...;
    }
    
    // 优化方案1:用Alpha混合代替discard
    // 设置 blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)
    // 让透明像素Alpha=0,通过混合自然消失
    
    // 优化方案2:如果必须用discard,尽量把不透明物体和不使用discard的物体先渲染
    
  2. 透明物体和不透明物体混排

    // 错误:透明物体穿插在不透明物体中间
    // 这会打断Early-Z的连续性
    
    // 正确:按顺序渲染
    // 1. 先渲染所有不透明物体(从前往后,最大化Early-Z效果)
    // 2. 再渲染所有透明物体(从后往前,正确混合)
    

自问自答:Early-Z

Q:Early-Z能提升多少性能? A:在复杂3D场景中,Early-Z可以剔除50%-90%的被遮挡片元。这意味着片元着色器的实际执行次数可能从200万次降到20万次,性能提升巨大!

Q:为什么我的Shader用了discard后游戏变卡了? A:因为discard会关闭Early-Z优化。GPU无法提前知道哪些片元会被丢弃,只能对所有片元执行完整的片元着色器。如果场景中有大量重叠物体,性能会急剧下降。

Q:如果不懂Early-Z会怎样? A:你可能会写出"看起来正确但性能极差"的代码。比如用discard做镂空效果、在片元着色器里修改深度、透明物体和不透明物体混排等。这些代码在简单场景下没问题,但在复杂场景下会导致严重的性能问题。


五、GPU内存架构

为什么需要了解GPU内存架构?

很多初学者以为"GPU快"是因为计算能力强。但实际上,GPU很多时候不是在"计算慢",而是在"等数据"。纹理采样一次需要从全局显存读取数据,延迟高达200个时钟周期。如果Shader里连续采样多次,GPU核心大部分时间都在空等。

理解内存层次,才能写出"缓存友好"的高效Shader。

GPU内存层次

┌─────────────────────────────────────────┐
│           全局显存 (Global Memory)         │  ← 最大最慢,几GB到几十GB
│         延迟:几百个时钟周期                │
├─────────────────────────────────────────┤
│           L2缓存 (L2 Cache)               │  ← 中等大小,几百KB到几MB
│         延迟:几十个时钟周期                │
├─────────────────────────────────────────┤
│        共享内存 (Shared Memory)            │  ← 小块高速,几十KB
│         延迟:几个时钟周期                  │  ← 同一线程块内共享
├─────────────────────────────────────────┤
│        寄存器 (Registers)                  │  ← 最小最快,几十KB
│         延迟:1个时钟周期                   │  ← 每个线程私有
└─────────────────────────────────────────┘

GPU内存层级性能对比

层级 大小 延迟 带宽 访问方式
寄存器 ~32KB/SM 1周期 ~8TB/s 线程私有
共享内存 ~64KB/SM ~5周期 ~1.5TB/s 线程块共享
L2缓存 ~2-4MB ~50周期 ~500GB/s 全局共享
全局显存 ~4-24GB ~200周期 ~100-900GB/s 全局共享

生活类比:图书馆找书

寄存器 = 你手边的笔记本( instantly available,但只能记几行字)
共享内存 = 你的书桌(很快拿到,但桌面有限)
L2缓存 = 房间里的书架(走几步就到,书不少)
全局显存 = 图书馆仓库(书很多,但要去仓库取,很慢)

如果你写Shader时频繁从"仓库"取数据(多次texture2D),
性能就会很差。应该尽量让数据留在"书桌"和"笔记本"上。

为什么GPU内存架构对渲染优化很重要?

  1. 纹理采样从全局显存读取,延迟高 → 需要缓存友好(局部性)
  2. Uniform变量存储在常量缓存(特殊的高速缓存),广播到所有线程
  3. VBO/IBO 从全局显存读取,但现代GPU有专门的顶点获取单元优化
  4. 共享内存可以用于线程间协作计算(如Compute Shader中的归约操作)

优化原则:让数据尽可能靠近计算单元(寄存器 > 共享内存 > L2 > 全局显存)

初学者常见错误

  1. Shader里连续多次纹理采样

    // 错误:对同一张纹理连续采样4次,每次都从显存读取
    vec4 c1 = texture2D(u_texture, uv + vec2(-1, 0));
    vec4 c2 = texture2D(u_texture, uv + vec2( 1, 0));
    vec4 c3 = texture2D(u_texture, uv + vec2(0, -1));
    vec4 c4 = texture2D(u_texture, uv + vec2(0,  1));
    // 4次采样 = 4次显存访问,性能很差
    
    // 优化:采样点尽量靠近,利用纹理缓存的局部性
    // GPU纹理缓存会预加载附近的像素,相邻采样大概率命中缓存
    
  2. 在Shader里定义大量临时变量

    // 错误:寄存器是有限的,太多变量会"溢出"到全局显存
    vec4 temp1, temp2, temp3, temp4, temp5, temp6, temp7, temp8;
    // 如果寄存器不够用,部分变量会被存储到全局显存,访问速度暴跌
    
    // 优化:及时释放不需要的变量(复用变量名)
    vec4 temp = ...; // 用完temp后,可以复用它存其他数据
    

自问自答:GPU内存架构

Q:为什么纹理采样这么慢? A:纹理数据存在全局显存中,采样需要从显存读取数据。虽然显存带宽很高(几百GB/s),但延迟很大(200周期)。GPU通过缓存和预加载来缓解这个问题,但连续随机采样仍然会命中缓存未命中。

Q:Uniform变量为什么快? A:Uniform变量存储在特殊的"常量缓存"中,这个缓存有独立的硬件通道,可以同时广播到所有线程。而且Uniform在Shader执行期间不变,GPU可以预加载到缓存中。

Q:如果不懂GPU内存架构会怎样? A:你可能会写出"计算量不大但性能极差"的Shader。比如一个模糊效果采样了25次纹理,虽然每像素只有25次采样,但200万像素 × 25次 = 5000万次显存访问!这就是很多"简单Shader"在低端手机上卡顿的原因。


六、关键认知总结

顶点着色器 vs 片元着色器执行次数

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

一个全屏1080P画面:
  顶点着色器:可能只有几百次
  片元着色器:2,073,600次(207万像素!)

结论:片元着色器的每一行代码都被放大了百万倍!优化片元着色器是GPU优化的重中之重。

WebGL绘制三角形的完整流程(参考月影课程)

下面这段代码把前面讲的所有阶段串了起来。建议逐行阅读,对照前面的阶段理解每行代码对应管线的哪个部分。

// ============================================================
// 步骤一:创建WebGL上下文
// ============================================================
// 获取canvas元素,这是GPU渲染的输出目标
const canvas = document.getElementById('canvas');
// 获取WebGL上下文,这是JS控制GPU的入口
// 'webgl' 请求WebGL1上下文,'webgl2' 请求WebGL2
const gl = canvas.getContext('webgl');

// ============================================================
// 步骤二:创建Shader程序(顶点着色器 + 片元着色器)
// ============================================================
// 顶点着色器:处理每个顶点的位置
// 对应管线阶段:阶段2(顶点着色器)
const vertex = `
  // attribute:从VBO读取的顶点属性
  // vec2:二维向量(x, y)
  attribute vec2 position;
  
  void main() {
    // gl_PointSize:绘制点时的像素大小
    gl_PointSize = 1.0;
    // gl_Position:顶点着色器必须输出的位置
    // vec4(position, 1.0, 1.0):将vec2扩展为vec4
    // z=1.0表示在最前面,w=1.0表示不做透视除法
    gl_Position = vec4(position, 1.0, 1.0);
  }
`;

// 片元着色器:处理每个像素的颜色
// 对应管线阶段:阶段6(片元着色器)
const fragment = `
  // precision:声明浮点数精度
  // mediump:中等精度(16位浮点),平衡性能和画质
  precision mediump float;
  
  void main() {
    // gl_FragColor:片元着色器输出的颜色
    // vec4(1.0, 0.0, 0.0, 1.0) = RGBA = 红色不透明
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 红色
  }
`;

// 编译顶点着色器
// createShader创建一个新的着色器对象
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
// shaderSource将GLSL源代码传给着色器对象
gl.shaderSource(vertexShader, vertex);
// compileShader编译着色器(将GLSL翻译成GPU机器码)
gl.compileShader(vertexShader);
// 注意:实际代码中应该检查编译是否成功!
// if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
//   console.error(gl.getShaderInfoLog(vertexShader));
// }

// 编译片元着色器(同上)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);

// 链接程序:将顶点着色器和片元着色器组合成一个可执行程序
// 对应管线阶段:阶段1(顶点输入装配)到阶段8(帧缓冲)的完整链路
const program = gl.createProgram();
gl.attachShader(program, vertexShader);   // 附加顶点着色器
gl.attachShader(program, fragmentShader); // 附加片元着色器
gl.linkProgram(program);                  // 链接(检查两个着色器是否兼容)
gl.useProgram(program);                   // 使用这个程序(激活它)

// ============================================================
// 步骤三:将数据存入缓冲区
// 对应管线阶段:阶段1(顶点输入装配)
// ============================================================
// Float32Array:类型化数组,比普通JS数组更高效
// 这3个顶点组成一个三角形
const points = new Float32Array([
  -1, -1,  // 顶点1:左下角(NDC坐标)
  0,  1,   // 顶点2:顶部中间
  1, -1,   // 顶点3:右下角
]);

// 创建VBO(顶点缓冲区对象)
const bufferId = gl.createBuffer();
// 绑定缓冲区到 ARRAY_BUFFER 目标
// "绑定"意味着后续的缓冲区操作都会作用在这个buffer上
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
// 将数据从CPU内存(JS数组)复制到GPU显存(VBO)
// STATIC_DRAW 提示GPU:这个数据不会频繁改变,可以优化存储
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

// ============================================================
// 步骤四:将缓冲区数据绑定到着色器变量
// 对应管线阶段:阶段1到阶段2的连接
// ============================================================
// getAttribLocation:获取Shader中attribute变量的位置索引
// 'position' 是顶点着色器中声明的 attribute vec2 position;
const vPosition = gl.getAttribLocation(program, 'position');
// vertexAttribPointer:告诉GPU如何从VBO中读取数据
// 参数:位置索引、每个属性分量数(2=x,y)、数据类型、是否归一化、
//       步长(0=紧密排列)、偏移量(0=从开头)
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
// enableVertexAttribArray:启用这个attribute
// 如果不启用,GPU会认为这个attribute是常量,不会从VBO读取
gl.enableVertexAttribArray(vPosition);

// ============================================================
// 步骤五:执行绘制
// 对应管线阶段:阶段1到阶段8的完整执行
// ============================================================
// clear:清除画布(用默认颜色填充)
// COLOR_BUFFER_BIT:只清除颜色缓冲
gl.clear(gl.COLOR_BUFFER_BIT);
// drawArrays:执行绘制!
// 参数:图元类型(TRIANGLES)、起始顶点索引、顶点数量
// points.length / 2 = 6 / 2 = 3个顶点
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

// 执行drawArrays后,GPU开始工作:
// 1. 从VBO读取3个顶点 → 阶段1(顶点输入装配)
// 2. 对每个顶点执行顶点着色器 → 阶段2(顶点着色器)
// 3. 裁剪、透视除法、视口变换 → 阶段4(裁剪与屏幕映射)
// 4. 三角形光栅化 → 阶段5(光栅化)
// 5. 对每个片元执行片元着色器 → 阶段6(片元着色器)
// 6. 逐片元测试 → 阶段7(逐片元操作)
// 7. 写入帧缓冲 → 阶段8(帧缓冲与显示)

渲染管线流程图(参考月影课程)

┌─────────────────────────────────────────────────────────────┐
│                        CPU(JavaScript)                      │
│  1. 创建WebGL上下文                                          │
│  2. 创建Shader程序(顶点+片元)                               │
│  3. 数据存入缓冲区(VBO)                                     │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                        GPU渲染管线                            │
│                                                              │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐     │
│  │ 顶点输入装配 │ → │  顶点着色器  │ → │  裁剪/映射   │     │
│  │ (VBO→图元)  │    │ (MVP变换)   │    │ (NDC→屏幕)  │     │
│  └─────────────┘    └─────────────┘    └─────────────┘     │
│                                              │              │
│  ┌─────────────┐    ┌─────────────┐    ┌────▼────────┐     │
│  │  帧缓冲输出  │ ← │ 逐片元操作   │ ← │  片元着色器  │     │
│  │ (显示到屏幕) │    │ (深度/模板)  │    │ (计算颜色)   │     │
│  └─────────────┘    └─────────────┘    └─────────────┘     │
│                           ↑                                  │
│                      光栅化(三角形→像素)                      │
└─────────────────────────────────────────────────────────────┘

七、Sprite的一生 —— 从创建到显示

这一节把引擎层面和GPU层面串起来,让你看到一个Sprite从代码到屏幕的完整旅程。

1. 你写下代码:this.node.addComponent(Sprite)
       ↓
   【引擎层 - 组件初始化】
   
2. 引擎创建Sprite组件,加载纹理资源
   - 解析图片文件 → 上传到GPU显存成为Texture
   - 创建Material(引用内置的sprite-effect)
       ↓
   【引擎层 - 游戏逻辑】
   
3. 主循环update阶段:更新节点的Transform(位置/旋转/缩放)
   - 如果position/rotation/scale变化 → 标记Transform为"脏"
   - 脏标记避免每帧重复计算
       ↓
   【引擎层 - 渲染数据准备】
   
4. 渲染阶段:Sprite.updateRenderData() 生成4个顶点+6个索引
   - 根据Sprite的size、anchor生成顶点位置
   - 根据Sprite的color生成顶点颜色
   - 生成UV坐标(默认[0,0]到[1,1])
   - 这些顶点数据暂存在CPU内存
       ↓
   【引擎层 - 合批优化】
   
5. Batcher2D合批:把多个Sprite的顶点数据合并到大VBO
   - 检查合批条件:相同纹理?相同材质?相同混合模式?
   - 如果满足条件 → 顶点追加到当前VBO
   - 如果不满足 → 提交当前批次,开始新批次
   - 这一步大幅减少了DrawCall数量
       ↓
   【引擎层 → GPU驱动层】
   
6. 提交DrawCall:通过GFX层发送给GPU
   - GFX层把Cocos的渲染指令翻译成WebGL API调用
   - 设置Shader程序、绑定VBO/纹理、设置Uniform
   - 调用gl.drawElements() 或 gl.drawArrays()
       ↓
   【GPU层 - 阶段1:顶点输入装配】
   
7. GPU顶点着色器:4个顶点 × MVP变换 → 屏幕坐标
   - GPU从VBO读取4个顶点的数据
   - 对每个顶点执行顶点着色器(并行4次)
   - 计算MVP矩阵变换 → 输出gl_Position
       ↓
   【GPU层 - 阶段4:裁剪与屏幕映射】
   - 透视除法:gl_Position.xyz / gl_Position.w → NDC
   - 视口变换:NDC → 屏幕像素坐标
       ↓
   【GPU层 - 阶段5:光栅化】
   
8. GPU光栅化:三角形 → 1万个片元
   - 确定三角形覆盖哪些像素(假设Sprite是100×100)
   - 生成约10,000个片元
   - 对每个片元插值UV、颜色、深度
       ↓
   【GPU层 - 阶段6:片元着色器】
   
9. GPU片元着色器:1万次纹理采样 → 1万个颜色值
   - 对每个片元执行片元着色器(并行10,000次)
   - 根据UV坐标从纹理采样颜色
   - 应用顶点颜色(如果有)
   - 输出gl_FragColor
       ↓
   【GPU层 - 阶段7:逐片元操作】
   
10. 逐片元测试+混合 → 写入帧缓冲 → 交换缓冲区 → 显示在屏幕上!
    - 裁剪测试(是否在ScrollView内)
    - 深度测试(2D通常简单处理)
    - Alpha混合(如果是半透明Sprite)
    - 通过测试的片元写入帧缓冲
    - 帧缓冲交换 → 屏幕显示

配套Demo:打开 rendering-demos/ 下的HTML文件,可以交互式体验每个阶段。


学习路径建议

如果你是初学者,建议按以下顺序学习:

第1遍:通读全文,不求甚解,建立整体认知
       ↓
第2遍:重点看"为什么需要这个阶段"和"类比解释",理解直觉
       ↓
第3遍:对照"逐步推导"和"代码逐行注释",理解技术细节
       ↓
第4遍:看"初学者常见错误",避免踩坑
       ↓
第5遍:看"自问自答",检验自己是否真正理解
       ↓
实践:打开WebGL示例代码,逐行调试,观察每个阶段的效果

如果读完还是不懂怎么办?

  • 正常现象!渲染管线涉及的概念很多,不可能一次全懂
  • 建议先实践:写一个简单的WebGL程序,画一个三角形
  • 遇到问题时再回来看对应章节,带着问题学习效果更好
  • 可以配合 rendering-demos/ 下的交互式Demo学习

参考资源