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 position → vec4 gl_Position(齐次坐标) |
| ③曲面/几何着色器 | 顶点 | 新增/修改的顶点 | 可选阶段,WebGL不支持 |
| ④裁剪与屏幕映射 | 裁剪空间坐标 | 屏幕像素坐标 | [-1,1] NDC → [0,width] 像素 |
| ⑤光栅化 | 三角形 + 顶点属性 | 片元(Fragment) | 几何 → 像素 + 插值属性(颜色/UV/深度) |
| ⑥片元着色器 | 片元属性 + Uniform | 像素颜色 | varying 插值 → vec4 gl_FragColor |
| ⑦逐片元操作 | 片元颜色 + 深度/模板值 | 通过测试的片元 | 颜色 + 深度 + 模板 → 可能丢弃 |
| ⑧帧缓冲 | 通过测试的片元 | 最终画面 | 写入颜色缓冲 + 深度缓冲 + 模板缓冲 |
关键认知:数据在GPU中如何流动?
- CPU把顶点数据(位置、UV【纹理坐标,告诉GPU图片的哪个点对应这个顶点】、颜色)放入VBO(顶点缓冲区)
- GPU从VBO读取数据,顶点着色器对每个顶点执行一次
- 顶点着色器输出
gl_Position(四维齐次坐标:x, y, z, w)- GPU自动做透视除法:
NDC = (x/w, y/w, z/w),范围[-1, 1]- 视口变换:NDC → 屏幕像素坐标
- 光栅化:确定三角形覆盖哪些像素,生成片元
- 每个片元包含:屏幕坐标(x,y) + 插值后的颜色/UV/深度
- 片元着色器对每个片元执行一次,输出
gl_FragColor- 逐片元操作:深度测试、模板测试、Alpha混合
- 最终颜色写入帧缓冲区
三、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中,这就是"合批"的底层原理。
初学者常见错误
用普通Array代替Float32Array
// 错误!普通JS数组会被引擎转换为Float32Array,有额外开销 const points = [-1, -1, 0, 1, 1, -1]; // 正确:直接使用Float32Array const points = new Float32Array([-1, -1, 0, 1, 1, -1]);每帧都重新创建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); // 只更新数据 }顶点数据顺序和图元类型不匹配
// 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并行特性:所有顶点同时计算,不需要遍历
初学者常见错误
混淆attribute和uniform
// 错误:用uniform接收顶点数据 uniform vec2 position; // uniform是所有顶点共享的,不能存不同顶点的位置 // 正确:顶点位置用attribute attribute vec2 position; // 每个顶点有自己的position忘记输出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); }在顶点着色器里做复杂光照计算
// 错误:顶点着色器只有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:裁剪与屏幕映射 —— "质检员"
为什么需要裁剪?
想象你在拍照,相机只能看到前方一定角度范围内的景物。背后的东西不需要画到照片上 —— 画了也是浪费计算。
裁剪的作用就是:把那些肯定看不到的三角形提前丢掉,不让它们进入后续昂贵的计算阶段。
做什么:把视锥体外的图元丢弃,把裁剪空间坐标转换为屏幕像素坐标。
质检员比喻:质检员检查产品是否在规格范围内,不合格的直接扔掉,合格的贴上出厂标签(屏幕坐标)。
三步走:
- 裁剪:完全在视锥体外的三角形 → 丢弃;部分在外 → 沿边界切割
- 透视除法:齐次坐标(x,y,z,w) → NDC(x/w, y/w, z/w),范围[-1,1]
- 视口变换: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屏幕裁剪"。
初学者常见错误
手动做透视除法
// 错误:在顶点着色器里手动除w gl_Position = vec4(position / w, z, w); // 多此一举! // 正确:顶点着色器输出齐次坐标,GPU自动做透视除法 gl_Position = vec4(position, z, w);混淆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处理的是"几何图形"(三角形),但屏幕显示的是"像素网格"。这两个世界之间需要一座桥梁 —— 光栅化就是这座桥。
类比:你有一张矢量图(无限清晰的线条),要把它显示在电脑屏幕上。屏幕是由一个个小方格(像素)组成的,光栅化就是决定"哪些小方格应该被点亮"。
做什么:把连续的三角形变成离散的像素点(片元),并插值顶点属性。
十字绣比喻:想象你在绣十字绣,图案(三角形)是连续的线条,但绣出来是一个个离散的格子(像素)。光栅化就是"把线条图案转成格子图案"的过程。
三大核心动作:
- 扫描转换:确定三角形覆盖了哪些像素
- 片元生成:每个被覆盖的像素 → 一个Fragment(片元)
- 属性插值: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性能!
→ 能放在顶点着色器的计算,绝不要放片元着色器
初学者常见错误
以为片元就是像素
片元(Fragment)≠ 像素(Pixel) - 片元是"候选像素",经过逐片元操作后可能被淘汰 - 像素是最终显示在屏幕上的点 - 一个片元可能通过测试变成像素,也可能被深度测试丢弃在片元着色器里做顶点级别的计算
// 错误:在片元着色器里计算顶点位置 // 片元着色器每像素执行一次,这里算位置浪费了! 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采样(如模糊)→ 性能杀手
初学者常见错误
忘记声明precision
// 错误:没有precision声明 void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); } // 在某些GPU上会编译失败或默认使用低精度导致颜色断层 // 正确:必须声明浮点精度 precision mediump float; void main() { ... }在片元着色器里使用attribute
// 错误:attribute只能在顶点着色器中使用 attribute vec2 uv; // 编译错误! // 正确:用varying从顶点着色器传递数据 varying vec2 v_uv; // 顶点着色器输出,片元着色器输入分支语句导致性能问题
// 错误:片元着色器中的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(深度值相同导致闪烁)。
解决方案:
- 增加近平面距离:让物体不要太靠近相机
- 使用对数深度缓冲:
logDepth = log(z) / log(far),让深度分布更均匀- 使用更高精度的深度格式:24位或32位
- 避免共面:不要让两个面完全重合
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
初学者常见错误
透明物体从前往后渲染
错误顺序(从前到后): 先画前面的透明玻璃 → 再画后面的物体 结果:后面的物体被深度测试丢弃,玻璃后面什么都看不到! 正确顺序(从后到前): 先画后面的物体 → 再画前面的透明玻璃 结果:玻璃和后面的物体正确混合 ✓透明物体没有关闭深度写入
// 错误:透明物体也写深度缓冲 // 导致后面的透明物体被前面的透明物体挡住 material.depthWrite = true; // 错! // 正确:透明物体只读深度,不写深度 material.depthWrite = false; // 对! material.depthTest = true; // 仍然要测试深度忘记清除深度缓冲
// 错误:每帧不清除深度缓冲 // 上一帧的深度值会干扰这一帧的渲染 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(垂直同步):把缓冲区交换时机和屏幕刷新率同步,防止撕裂。
初学者常见错误
在渲染过程中读取帧缓冲
// 错误:GPU正在写入帧缓冲时读取 // 会导致未定义行为(可能读到半新半旧的数据) gl.readPixels(0, 0, width, height, ...); // 不要在渲染时读取! // 正确:使用FBO离屏渲染,或等待当前帧完成混淆帧缓冲和纹理
帧缓冲(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会:
- 对每个像素执行复杂的片元着色器(纹理采样、光照计算)
- 然后做深度测试发现"哦,这个像素被墙挡住了"
- 丢弃结果 —— 刚才的计算全部浪费了!
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。
初学者常见错误
在片元着色器里随意使用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的物体先渲染透明物体和不透明物体混排
// 错误:透明物体穿插在不透明物体中间 // 这会打断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内存架构对渲染优化很重要?
- 纹理采样从全局显存读取,延迟高 → 需要缓存友好(局部性)
- Uniform变量存储在常量缓存(特殊的高速缓存),广播到所有线程
- VBO/IBO 从全局显存读取,但现代GPU有专门的顶点获取单元优化
- 共享内存可以用于线程间协作计算(如Compute Shader中的归约操作)
优化原则:让数据尽可能靠近计算单元(寄存器 > 共享内存 > L2 > 全局显存)
初学者常见错误
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纹理缓存会预加载附近的像素,相邻采样大概率命中缓存在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学习
参考资源
- 月影《跟月影学可视化》第04篇:《GPU与渲染管线:如何用WebGL绘制最简单的几何图形?》
- 课程源码:https://github.com/akira-cn/graphics/tree/master/webgl
- 学习笔记源码:https://github.com/kaimo313/visual-learning-demo