/ Ch17 使用图形硬件

第17章:使用图形硬件 — 零基础讲义

讲义说明
本讲义基于 Steve Marschner & Peter Shirley 所著《虎书5》第5版第17章(p.461-501)。
核心故事:你作为一个程序员,怎么让 GPU 帮你画一个三角形?
本章从这个问题出发,一步步拆解 GPU 的完整工作流程。
本版插画采用 Guizang 材质插画风格重新绘制。


17.0 故事线:我想画一个三角形

为什么需要这个?

所有图形编程,都从这里开始:你有一个想法——画一个三角形在屏幕上。

但你的 CPU 不会画三角形。CPU 只会算数。你的 GPU 会画三角形,但它听不懂 C++。所以你需要:把"画三角形"翻译成 GPU 能听懂的命令

GPU管线与Host/Device交互
图17-1:GPU管线全景——CPU(Host)通过PCIe总线与GPU(Device)交互,数据在系统内存和显存之间传输

Step 1:先定义三角形的三个点

三角形 ABC:
  A = (-0.5, -0.5, 0.0)   左下
  B = ( 0.5, -0.5, 0.0)   右下
  C = ( 0.0,  0.5, 0.0)   上

每个点的坐标是 (x, y, z),范围在 [-1, 1] 之间。这是最简单的三角形——在屏幕中心。

Step 2:这三个点怎么到 GPU?

[C++ 代码]
     ↓  编译、链接
[应用程序]  ← 你在这
     ↓
[CPU 把顶点数据拷贝到显存]
     ↓
[GPU 拿到数据,开始画]  ← 本章剩下的篇幅全在讲这一步

Step 3:GPU 内部发生了什么?

[CPU] 说:"画三角形,顶点是 A, B, C"
     ↓
[GPU 执行管线]:
  ┌─────────────────────────────────────────────┐
  │  1. 顶点处理 (Vertex Shader)                │
  │     └─ 把 A, B, C 从"模型坐标"→"屏幕坐标"   │
  │                                             │
  │  2. 光栅化 (Rasterization)                  │
  │     └─ "三角形变像素"                        │
  │     └─ 算出三角形覆盖了哪些像素点            │
  │                                             │
  │  3. 片段处理 (Fragment Shader)              │
  │     └─ 给每个像素算颜色                      │
  │                                             │
  │  4. 混合输出 (Blending)                     │
  │     └─ 把颜色写到屏幕缓冲区                  │
  └─────────────────────────────────────────────┘
     ↓
[屏幕上出现一个三角形]
核心洞察:整个图形学管线 = 回答"三角形上的每个像素是什么颜色"这个问题。光栅化就是"三角形变像素"的那一步。

17.1 异构多处理(Heterogeneous Multiprocessing)

17.1.1 为什么 CPU 和 GPU 要分开?

CPU 是"总经理"——什么都懂但只有几个脑袋;GPU 是"流水线工人"——只做一件事但有几万个。

🤔 想一想:为什么不把 CPU 和 GPU 做成一个芯片算了?
因为设计目标完全不同!CPU 要处理各种随机任务(打开网页、运行操作系统、响应键盘),所以需要复杂的控制逻辑、分支预测、大缓存。GPU 只做一件事——大规模并行计算,所以它可以把 80% 的芯片面积都用来做计算单元。合在一起的结果就是两者都做不好。
维度CPU(主机/Host)GPU(设备/Device)
核数4~32 个几千个(SM/CU)
每核能力强(乱序执行、大缓存、复杂分支预测)弱(单指令多线程,分支昂贵)
内存系统 RAM(几十 GB)显存 VRAM(几~几十 GB)
通信CPU ←PCIe总线→ GPUGPU 内部极快
擅长控制流、数据管理、串行任务数据并行大规模计算(矩阵乘法、光栅化)
画三角形?能画但极慢(CPU 渲染)光栅化是硬件天生技能

17.1.2 工作模式:Host ↔ Device

Host (CPU) 内存                Device (GPU) 显存
┌───────────┐                ┌────────────┐
│ 顶点数据   │─────PCIe─────→│ 顶点缓冲    │
│ 纹理图片   │─────PCIe─────→│ 纹理对象    │
│ Shader代码 │─────编译─────→│ Shader程序  │
│ 绘制命令   │─────PCIe─────→│ 命令队列    │
└───────────┘                └────────────┘
关键痛点:PCIe 传输是最慢的环节。一次纹理上传可能耗时几毫秒——所以游戏加载要等"Loading..."。在性能优化中,减少 CPU↔GPU 数据传输是所有优化策略的第一优先级。

17.1.3 为什么 PCIe 这么慢?数字感知

操作大概耗时类比
CPU 执行一条指令~0.3 ns眨一下眼睛的十亿分之一
CPU 读取 L1 缓存~1 ns心跳一次的百万分之一
CPU 读取内存 RAM~100 ns一次呼吸的百万分之一
PCIe 4.0 ×16 传输 1MB~60 μs苍蝇扇一下翅膀
PCIe 传输整帧纹理 (8MB)~0.5 ms你能看清的时间
一帧渲染时间 (60fps)~16.7 ms一帧动画的极限

17.2 图形管线(GPU Pipeline)

17.2.1 为什么需要管线?

想象一家工厂:如果你让一个工人"先把铁矿石炼成铁,再把铁铸成形状,再打磨抛光",他得一个一个来。但如果你把这些人排成一条线——第一个人炼铁、第二个人铸造、第三个人打磨——你可以同时处理多个工件。图形管线也是同样的道理:每个阶段可以同时处理不同的数据

17.2.2 四个阶段全览

┌─────────────────────────────────────────────────────────┐
│  "我想画一个三角形"                                       │
│       ↓                                                  │
│  [应用程序]  ← 设置好顶点、Shader、状态                    │
│       ↓                                                  │
│  ╔═════════════════════════════════════════════════════╗  │
│  ║  阶段1:顶点处理 (Vertex Shader)                     ║  │
│  ║  输入:每个顶点 (位置、颜色、法线...)                  ║  │
│  ║  输出:变换后的顶点(屏幕坐标)                        ║  │
│  ║  可编程:是——你写 GLSL 代码                              ║  │
│  ╚═════════════════════════════════════════════════════╝  │
│       ↓                                                  │
│  ╔═════════════════════════════════════════════════════╗  │
│  ║  阶段2:光栅化 (Rasterization) — 硬件固定             ║  │
│  ║  输入:3个屏幕坐标顶点                                 ║  │
│  ║  输出:覆盖的所有像素(片段 Fragment)                ║  │
│  ║  本质:三角形变像素                                    ║  │
│  ║  可编程:否!这是硬件做好的                              ║  │
│  ╚═════════════════════════════════════════════════════╝  │
│       ↓                                                  │
│  ╔═════════════════════════════════════════════════════╗  │
│  ║  阶段3:片段处理 (Fragment Shader)                    ║  │
│  ║  输入:每个像素的位置、插值后的属性 + 纹理采样         ║  │
│  ║  输出:每个像素的颜色(RGBA)                          ║  │
│  ║  可编程:是——你也写 GLSL 代码                           ║  │
│  ╚═════════════════════════════════════════════════════╝  │
│       ↓                                                  │
│  ╔═════════════════════════════════════════════════════╗  │
│  ║  阶段4:混合 (Blending) — 硬件固定                    ║  │
│  ║  输入:新像素颜色 + 帧缓冲区已有颜色                   ║  │
│  ║  输出:→ 帧缓冲区的最终颜色                            ║  │
│  ║  公式:output = src × srcFactor + dst × dstFactor     ║  │
│  ╚═════════════════════════════════════════════════════╝  │
│       ↓                                                  │
│  [屏幕上!]                                                │
└─────────────────────────────────────────────────────────┘

17.2.3 光栅化详解:三角形怎么变成像素?

光栅化 = 回答一个问题:"这个三角形覆盖了屏幕上的哪些像素点?"

三角形三个顶点:
  A(-0.5, -0.5)      B(0.5, -0.5)      C(0.0, 0.5)

        屏幕像素网格:
   ┌────┬────┬────┬────┬────┐
   │    │    │    │    │    │
   ├────┼────┼────┼────┼────┤
   │    │    │ ██ │ ██ │    │   ██ = 被三角形覆盖的像素
   ├────┼────┼────┼────┼────┤
   │    │ ██ │ ██ │ ██ │    │   每个 ██ 会生成一个 Fragment(片段)
   ├────┼────┼────┼────┼────┤
   │    │ ██ │ ██ │    │    │   每个 Fragment 会执行一次 Fragment Shader
   ├────┼────┼────┼────┼────┤
   │    │    │    │    │    │
   └────┴────┴────┴────┴────┘

光栅化还做了一件重要的事:插值。三角形内部的每个像素的颜色不是随便算的——是根据三个顶点的值做重心坐标插值

举例:三个顶点颜色不同
  A=红色, B=绿色, C=蓝色

  → 三角形内部的像素颜色是"从红渐变到绿再到蓝"
  → 这叫 Gouraud Shading(高洛德着色)
  → 插值是硬件做的,你不需要写代码
一句话总结光栅化 = 三角形变 像素,同时 插值属性。
🤔 想一想:为什么光栅化是硬件固定的,不能让我们编程?
因为光栅化的效率完全取决于硬件实现——判断像素在三角形内的算法(如边函数法 Edge Function)已经被研究了30年,所有 GPU 厂商都采用几乎相同的硬件电路。如果让程序员自己写,要么慢10倍,要么有 bug。所以 GPU 厂商把光栅化做成固定硬件,只给你留出"顶点处理"和"片段处理"两个可编程接口。

17.3 Shader 编程(Shader Programming)

17.3.1 为什么需要 Shader?

早期 GPU 的管线是固定的(fixed-function pipeline)——每个阶段做什么是焊死在硬件里的。后来人们发现:不同的游戏需要不同的光照效果,固定的管线太死板了。于是 GPU 厂商开放了部分阶段的编程接口——这就是 Shader。

17.3.2 两个可编程阶段

Shader = 在 GPU 上运行的小程序

Shader流程——Vertex到Fragment
图17-2:Shader 执行流程——Vertex Shader 处理每个顶点,光栅化插值后,Fragment Shader 处理每个像素
Shader执行次数输入输出你写的代码做
Vertex Shader每个顶点一次顶点属性(位置、颜色、法线)变换后的坐标 + 传递给 Fragment 的数据坐标变换、逐顶点计算
Fragment Shader每个像素一次插值后的属性 + 纹理采样最终颜色光照计算、纹理混合、颜色输出

17.3.3 第一个 Vertex Shader:让三角形动起来

目标:把三角形从模型坐标系变换到屏幕坐标系

// ====== Vertex Shader ======
// 执行次数:每帧对三角形的 3 个顶点各执行一次 = 3 次

// 输入:in_ 开头的变量 = 每个顶点自带的数据
layout(location = 0) in vec3 in_Position;  // 顶点位置(来自 CPU 传的 VBO)
layout(location = 1) in vec3 in_Color;      // 顶点颜色(来自 CPU 传的 VBO)

// 输出:out 关键字 = 传到 Fragment Shader 的数据
out vec3 vColor;  // 顶点颜色 → 经过光栅化插值 → 到 Fragment

// Uniform = "一次绘制调用中不变"的全局变量
uniform mat4 projMatrix;  // 投影矩阵(CPU 传入,把 3D 点映射到屏幕)

// main 函数:GPU 对每个顶点自动调用
void main(void)
{
    // 把顶点颜色传递给 Fragment Shader
    vColor = in_Color;
    // 用投影矩阵把 3D 位置变换到屏幕坐标
    // vec4(位置, 1.0) = 齐次坐标表示
    // gl_Position = 内建变量,GPU 用这个值做光栅化
    gl_Position = projMatrix * vec4(in_Position, 1.0);
}

17.3.4 对应的 Fragment Shader:给每个像素上色

// ====== Fragment Shader ======
// 执行次数:每帧对三角形覆盖的每个像素各执行一次
// 如果三角形覆盖了 1000 个像素 = 执行 1000 次

// 输入:从 Vertex Shader 传来的数据(经过光栅化插值)
// 三个顶点的 vColor 分别是红、绿、蓝 → 三角形中间的 vColor 是渐变混合色
in vec3 vColor;

// 输出:最终要显示的颜色
out vec4 fragmentColor;  // RGBA 颜色

void main(void)
{
    // 直接使用插值后的颜色
    // vec4(颜色, 透明度) — 透明度设为 1.0(不透明)
    fragmentColor = vec4(vColor, 1.0);
}
通俗理解
  • Vertex Shader = 定好三角形的三个角在哪里、什么颜色
  • 光栅化(硬件做)= 算出三角形内部有哪些像素
  • Fragment Shader = 给每个像素最终决定颜色

17.3.5 Per-Vertex 着色 vs Per-Fragment 着色

Per-Vertex 着色(Gouraud Shading):

顶点处算光照 → 光栅化插值 → 三角形内部平滑过渡
  优点:快(每个顶点算一次,中间插值)
  缺点:大三角形时中间的光照细节丢失

  举例:一个大三角形,顶点处光源在左边
    → 顶点算出来很亮
    → 但三角形中心恰好有个高光 → 被插值抹平了 → 看不见

Per-Fragment 着色(Phong Shading):

顶点处只传法线 → 光栅化插值法线 → 每个像素重新算光照
  优点:每个像素的光照都准
  缺点:慢一点(每个像素都要算一遍光照)
// ====== Per-Fragment 着色的 Fragment Shader ======
// 输入:插值后的法线、半向量、光方向
in vec4 normal;       // 法线(已插值)
in vec3 half;         // 半向量 = 视线 + 光源方向 归一化
in vec3 lightdir;     // 光源方向

// Uniform 变量:一次 draw call 不变
uniform vec3 Ia;      // 环境光强度
uniform vec3 ka;      // 环境光反射系数
uniform vec3 kd;      // 漫反射系数
uniform vec3 ks;      // 镜面反射系数
uniform float phongExp;  // 镜面指数(越大高光越集中)
uniform vec3 lightIntensity;  // 光源强度

void main(void)
{
    // ---- 归一化:保证方向向量的长度 = 1 ----
    vec3 n = normalize(normal.xyz);
    vec3 h = normalize(half);
    vec3 l = normalize(lightdir);

    // ---- Blinn-Phong 光照模型(逐像素) ----
    // 环境光:不管光源方向,保证背光面有基本亮度
    vec3 ambient = ka * Ia;

    // 漫反射:光源方向越正对表面越亮,*max(0, n·l) 保证背面不亮
    vec3 diffuse = kd * lightIntensity * max(0.0, dot(n, l));

    // 镜面反射:半向量越接近法线越亮,pow(...) 控制高光大小
    vec3 specular = ks * lightIntensity * pow(max(0.0, dot(n, h)), phongExp);

    // ---- 合成最终颜色 ----
    fragmentColor = vec4(ambient + diffuse + specular, 1.0);
}

17.3.6 Uniform 变量的三种来源

来源关键字谁提供每次变化时机
内建变量gl_Position, gl_FragCoordGPU自动
顶点属性(Attribute)in_*(如 in_PositionVBO 数据每个顶点不同
Uniformuniform(如 projMatrixCPU 调用 glUniform*()一次 draw call 不变
核心记忆法Attribute = 每个顶点不一样;Uniform = 这次画都一样。

17.4 顶点数据(Vertex Data)

17.4.1 为什么需要 VBO?

你要画一个三角形,GPU 需要知道三个顶点的位置。但你不可能从 C++ 里每一帧都把这三个点"塞"给 GPU——那太慢了。解决方案:先把数据存在 GPU 的显存里,画的时候 GPU 直接读显存。这个存放顶点数据的显存缓冲区就是 VBO(Vertex Buffer Object)。

17.4.2 VBO = Vertex Buffer Object

VBO = GPU 显存里的一块缓冲区,专门存顶点数据。

1. 你的 C++ 程序定义了一个三角形(3 个顶点 = 位置 + 颜色 + 法线...)
2. 这些数据在 CPU 内存里(系统 RAM)
3. 你调 glBufferData() → 数据从 RAM 拷贝到 VRAM(VBO)
4. 你调 glDrawArrays() → GPU 从 VBO 读数据执行管线

C++ 端的顶点结构体:

// 定义一个顶点的数据结构
struct Vertex {
    float pos[3];     // 位置 x, y, z     (3 × 4 = 12 字节)
    float color[3];   // 颜色 r, g, b     (3 × 4 = 12 字节)
    float uv[2];      // 纹理坐标 u, v     (2 × 4 =  8 字节)
    // 每个顶点总共:12 + 12 + 8 = 32 字节
};
VBO交错布局
图17-3:VBO交错内存布局——每个顶点的所有字段连续排列,相邻顶点首尾相接

交错布局(Interleaved)的内存排列:

显存中连续排列(每个顶点的所有字段挨着):
┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐
│ pos.x │ pos.y │ pos.z │ col.r │ col.g │ col.b │ uv.u  │ uv.v  │
├───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
│ <----------- 顶点 0 的 8 个 float = 32 字节 ------------> │
├───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
│ 顶点 1 ..............................................  │
├───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
│ 顶点 2 ..............................................  │
└───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘

17.4.3 创建 VBO 的完整代码(每行中文注释)

// ---- 步骤 1:定义三角形数据 ----
// 三个顶点,每个顶点 8 个 float(位置3 + 颜色3 + 纹理2)
float vertices[] = {
    // 位置 x,y,z       颜色 r,g,b       纹理 u,v
    -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,  // 顶点 A:左下,红色
     0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,  // 顶点 B:右下,绿色
     0.0f,  0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.5f, 1.0f   // 顶点 C:顶上,蓝色
};

// ---- 步骤 2:生成 VBO 对象 ----
GLuint vbo;                    // 声明 VBO 句柄(一个无符号整数 ID)
glGenBuffers(1, &vbo);         // 让 OpenGL 分配一个 VBO,把 ID 存入 vbo

// ---- 步骤 3:绑定 VBO ----
glBindBuffer(GL_ARRAY_BUFFER, vbo);  // 把 vbo 绑定到"顶点数组缓冲"目标
// 绑定 = "接下来对这个目标的操作都是对 vbo 做的"

// ---- 步骤 4:把数据从 RAM 传到 VRAM ----
glBufferData(
    GL_ARRAY_BUFFER,                   // 目标:顶点数组缓冲
    sizeof(vertices),                   // 数据大小(字节数):8个float×3顶点×4字节 = 96字节
    vertices,                           // CPU 端数据指针
    GL_STATIC_DRAW                      // 提示:数据不常改(静态),GPU 可以优化存储位置
);

// ---- 步骤 5:生成 VAO(顶点数组对象) ----
// VAO 像一个"数据线配置模板"——记住"位置在第 0 个槽位、颜色在第 1 个槽位..."
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);  // 绑定 VAO,后续 VertexAttribPointer 配置都记在这个 VAO 里

17.4.4 glVertexAttribPointer:6 个参数逐项拆解

这个函数是 VBO 编程中最重要也是最容易搞错的地方。

它的作用:告诉 GPU "VBO 里的数据是怎么排列的,每个属性从哪里开始读"。

// 函数签名:
void glVertexAttribPointer(
    GLuint index,       // ① 属性索引(对应 Vertex Shader 里的 layout(location = x))
    GLint size,         // ② 每个属性由几个分量组成(1/2/3/4)
    GLenum type,        // ③ 每个分量的数据类型
    GLboolean normalized, // ④ 整数类型时是否归一化到 [0,1] 或 [-1,1]
    GLsizei stride,     // ⑤ 两个相邻顶点之间间隔多少字节(步长)
    const void* pointer // ⑥ 这个属性在顶点内的起始偏移(字节)
);

顶点数据布局(每个顶点 32 字节 = 8 个 float):

偏移量:         0          12         24       32
               ↓           ↓           ↓        ↓
           ┌──────────┬──────────┬──────────┐
           │ 位置 xyz │ 颜色 rgb │ 纹理 uv  │
           │ (3 float)│ (3 float)│ (2 float)│
           └──────────┴──────────┴──────────┘
            ←—— stride = 32 字节 ————→
参数属性0:位置属性1:颜色属性2:纹理
① index0(对应 layout=01(对应 layout=12(对应 layout=2
② size3(xyz 三个分量)3(rgb 三个分量)2(uv 两个分量)
③ typeGL_FLOATGL_FLOATGL_FLOAT
④ normalizedGL_FALSEGL_FALSEGL_FALSE
⑤ stride32 字节32 字节32 字节
⑥ pointer(void*)0(void*)12(void*)24
// ---- 完整 VBO 属性和 VAO 配置代码(每行注释) ----

// 启用顶点属性 0(位置):默认是禁用的,必须先启用
glEnableVertexAttribArray(0);

// 配置顶点属性 0(位置)
// index=0: layout(location = 0) in vec3 in_Position;
// size=3:  位置有 xyz 三个值
// type=GL_FLOAT: 每个值是 32 位浮点数
// normalized=GL_FALSE: 浮点数不需要归一化
// stride=32: 每个顶点 32 字节
// pointer=0: 位置在顶点数据的最开头,偏移 0
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 32, (void*)0);

// 启用顶点属性 1(颜色)
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 32, (void*)12);

// 启用顶点属性 2(纹理坐标)
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 32, (void*)24);

17.4.5 绘制三角形

// ---- 画! ----
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shaderProgram);
glDrawArrays(GL_TRIANGLES, 0, 3);

// 或者用索引画法(更常用,可以复用顶点):
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);

17.5 纹理对象(Texture Objects)

17.5.1 为什么需要纹理?

如果你只有顶点颜色,那你的三角形要么是纯色、要么是渐变色——永远做不出"砖墙"、"草地"、"木纹"这样的细节。纹理就是把一张图片"贴"到 3D 模型表面,让模型看起来有真实的细节。

17.5.2 纹理的本质

不需要纹理:三角形是单一颜色
需要纹理:三角形上包含"砖墙图案"——把一张砖墙图片映射到每个像素上

纹理坐标系统(UV):

纹理左上角 (0,1)               纹理右上角 (1,1)
      ┌──────────────────┐
      │                  │
      │     纹理图片      │
      │                  │
      └──────────────────┘
纹理左下角 (0,0)               纹理右下角 (1,0)

每个顶点的纹理坐标告诉 GPU:"这个顶点对应纹理图片上的哪个位置"
三角形内部的像素 → 光栅化插值 UV → 从纹理中取色

17.5.3 顶点数据 + 纹理的完整结构

// ---- 顶点结构体:带纹理坐标 ----
struct vertexData {
    glm::vec3 pos;        // 位置 x,y,z  → 12 字节
    glm::vec3 normal;     // 法线 x,y,z  → 12 字节(用于光照计算)
    glm::vec2 texCoord;   // 纹理坐标 u,v →  8 字节
                          // 每个顶点合计 = 32 字节
};

// ---- 加载纹理到 GPU ----
GLuint textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);

// 把图片数据从 RAM 传到显存
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0,
             GL_RGB, GL_UNSIGNED_BYTE, imageData);

// 自动生成 mipmap 链
glGenerateMipmap(GL_TEXTURE_2D);

17.5.4 Mipmap:为什么远处的墙不需要 1024×1024 纹理?

Mipmap 的核心直觉:远处的一面墙在屏幕上只占 4×4 像素,你根本不需要加载 1024×1024 的纹理来贴它。

现实例子:你站在远处看一栋大楼
  ① 大楼在屏幕上大约 100×200 像素
  ② 墙壁上的砖头纹理是 2048×2048
  ③ 每个砖头块在屏幕上只有 2 个像素
  ④ 相邻像素的纹理颜色会剧烈跳变 → 屏幕上闪烁的噪点("混叠 Aliasing")
  ⑤ 解决方案:预制 2048→1024→512→256→...→1 的纹理系列
  ⑥ 远处的墙只用 4×4 的 mipmap 层级就行
Mipmap选层——远处小物体用小纹理
图17-4:Mipmap 层级——从原图 2048×2048 逐级下采样到 1×1,GPU 自动选择适合当前屏幕大小的层级
Mipmap 层级:
原图 Level 0:  2048×2048  (4,194,304 像素)
  Level 1:  1024×1024  (下采样 ½)
  Level 2:   512×512
  Level 3:   256×256
  ...
  Level 9:     4×4     ← 远处墙正好用这个!
  ...
  Level 11:    1×1     (最小的 mip,相当于平均颜色)

总存储:比原图多约 33% 内存(4/3 倍率)
好处:消除闪烁,提升缓存命中率
// ---- Fragment Shader 中采样纹理 ----
uniform sampler2D texture0;
in vec2 vTexCoord;

void main(void)
{
    // texture() 函数自动做:
    //   1. 根据 UV 坐标从纹理中查找颜色
    //   2. 自动处理 mipmap 选择
    //   3. 双线性插值(4 个 texel 加权平均)
    vec4 texColor = texture(texture0, vTexCoord);
    fragmentColor = texColor;
}
核心洞察:Mipmap = "远处的物体用小纹理,近处的用大纹理"。不是你选的,是 GPU 自动判断的。你只需要调 glGenerateMipmap() 生成好 mipmap 链。

17.5.5 纹理参数

// 设置纹理环绕方式(UV 超出 [0,1] 时怎么办)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
// GL_REPEAT: 重复纹理(瓷砖效果)
// GL_CLAMP_TO_EDGE: 边缘颜色延伸
// GL_MIRRORED_REPEAT: 镜像重复

// 设置纹理过滤方式(放大/缩小时的插值方式)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
// GL_LINEAR_MIPMAP_LINEAR: 三线性过滤(质量最好)
// GL_LINEAR: 双线性过滤(不做 mipmap 切换)
// GL_NEAREST: 最近邻(像素风)

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 放大过滤:GL_LINEAR(模糊平滑)或 GL_NEAREST(锯齿)

17.6 实例化(Instancing)

17.6.1 为什么需要实例化?

想象你要画一片草地——10000 根草。每根草都是一个三角形(或者两个三角形组成的小四边形)。如果不实例化,你要调用 10000 次 glDrawArrays——每次都要重复设置状态、发送命令到 GPU,效率极低。

实例化:调一次 glDrawArraysInstanced,GPU 帮你画 1000 次。

Instancing批量绘制——一次draw画多个物体
图17-5:实例化绘制——一次 glDrawArraysInstanced 调用,GPU 自动复制管线绘制多个实例,每个实例有独立的位置和颜色

17.6.2 何时用实例化?

场景实例化?原因
草地上 10000 根草必须用每根草形状相同,位置不同
100 个敌人强烈推荐共享模型网格,位置/材质不同
场景里唯一的 BOSS不需要只有一次绘制
粒子系统 50000 个粒子必须用每个粒子是一个小四边形

17.6.3 实例化代码

// ---- CPU 端 ----
struct InstanceData {
    glm::vec3 position;   // 位置偏移
    glm::vec3 color;      // 颜色
};

GLuint instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, instanceCount * sizeof(InstanceData),
             instanceData, GL_STATIC_DRAW);

// 配置实例属性(注意 divisor = 1)
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(InstanceData), (void*)0);
glVertexAttribDivisor(3, 1);   // ← 关键:每实例而非每顶点

// 画!
glDrawArraysInstanced(GL_TRIANGLES, 0, 3, instanceCount);
// GPU 内部复制 1000 次管线,每次用不同的实例数据
// ---- Vertex Shader(实例化版) ----
layout(location = 0) in vec3 in_Position;
layout(location = 1) in vec3 in_Color;
layout(location = 3) in vec3 in_InstancePos;
layout(location = 4) in vec3 in_InstanceColor;

uniform mat4 projMatrix;
uniform mat4 viewMatrix;

out vec3 vColor;

void main(void)
{
    // 把实例位置加到顶点坐标上
    vec3 worldPos = in_Position + in_InstancePos;
    // 每个实例用自己的颜色
    vColor = in_InstanceColor;
    // 标准坐标变换
    gl_Position = projMatrix * viewMatrix * vec4(worldPos, 1.0);
}
🤔 想一想glVertexAttribDivisor(3, 1) 的 divisor=1 是什么意思?
divisor 控制属性更新的频率:divisor=0 表示每个顶点更新一次(普通属性),divisor=1 表示每个实例更新一次(实例属性)。所以 3 个顶点×1000 个实例 = 位置属性读 3000 次,实例位置属性只读 1000 次。这保证了同一个实例的所有顶点共享同一份实例数据。

17.7 调试 Shader

17.7.1 为什么需要调试技巧?

Shader 运行在 GPU 上——你不能像调试 C++ 那样打断点、看变量值。GPU 上没有 printf。所以你需要一些"曲线救国"的调试方法。最基本的原则:把你想看的值输出为颜色。

17.7.2 三个最基本的调试技巧

问题现象调试手段
顶点位置不对三角形找不到或变形把位置直接输出为颜色 → 看颜色图猜位置
法线方向反了光照看起来"凹进去"用法线作为颜色输出 → 法线朝哪个方向一目了然
纹理没看到模型空白或黑色输出 UV 坐标为颜色 → UV 范围对不对?

17.7.3 "法线可视化"调试 Shader

// ---- Normal Shader ----
in vec4 normal;           // 传入的法线(世界空间)
out vec4 fragmentColor;   // 输出颜色

void main(void)
{
    // normal.xyz 范围 [-1, 1]
    // *0.5 + 0.5 映射到 [0, 1]
    vec3 intensity = normalize(normal.xyz) * 0.5 + 0.5;

    // 红色 = X 正方向,绿色 = Y 正方向,蓝色 = Z 正方向
    fragmentColor = vec4(intensity, 1.0);
}
🤔 想一想:为什么法线映射到颜色要 *0.5+0.5?
法线分量范围是 [-1, 1],而颜色分量范围是 [0, 1]。直接输出 [-1, 1] 的值会被 GPU 截断为 [0, 1]——所有负数变成 0,丢失信息。乘以 0.5 再加 0.5 后:-1→0, 0→0.5, 1→1,完整保留了方向信息。

17.8 面向对象封装

17.8.1 为什么需要封装?

直接写裸的 OpenGL 调用——glGenBuffersglBindBufferglBufferData——代码很快就变成一团乱麻。你需要把 GPU 资源封成类:构造函数创建资源,析构函数释放资源,render 函数封装绘制逻辑。

17.8.2 把 GPU 资源封成类

// ---- 一个可复用的三角形类 ----
class Triangle {
private:
    GLuint vbo;         // 顶点缓冲对象
    GLuint vao;         // 顶点数组对象
    GLuint shader;      // Shader 程序
    glm::mat4 model;    // 模型矩阵(位置、旋转、缩放)

public:
    // 构造:创建 VBO、编译 Shader
    Triangle() {
        // 创建 VBO...
        // 编译 Vertex Shader + Fragment Shader...
        // 链接成 Program...
    }

    // 析构:释放 GPU 资源
    ~Triangle() {
        glDeleteBuffers(1, &vbo);
        glDeleteProgram(shader);
    }

    // 每帧绘制
    void render(const glm::mat4& viewProj) {
        glUseProgram(shader);
        glBindVertexArray(vao);
        glm::mat4 mvp = viewProj * model;
        glUniformMatrix4fv(locMVP, 1, GL_FALSE, &mvp[0][0]);
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }

    void setPosition(float x, float y, float z) {
        model = glm::translate(glm::mat4(1.0f), glm::vec3(x, y, z));
    }
};

17.8.3 实例化 + OOP

// ---- 敌人群体 ----
// 所有敌人共享同一个 VBO(网格相同)
// 每个敌人有自己的 Model Matrix
class Enemy {
public:
    static GLuint sharedVBO;    // 敌人网格 VBO(只加载一次)
    static GLuint sharedShader; // 敌人 Shader(只编译一次)
    glm::mat4 modelMatrix;

    void render(const glm::mat4& viewProj) {
        glUseProgram(sharedShader);
        glUniformMatrix4fv(locMVP, 1, GL_FALSE,
                           glm::value_ptr(viewProj * modelMatrix));
        glBindBuffer(GL_ARRAY_BUFFER, sharedVBO);
        glDrawArrays(GL_TRIANGLES, 0, vertexCount);
    }
};

17.9 总结

核心管线回顾

你(程序员):"我想画一个三角形"
     ↓
CPU:分配 VBO,上传顶点数据到显存
CPU:编译 Shader(Vertex + Fragment),链接成 Program
CPU:设置 Uniform(投影矩阵、光源...)
CPU:调用 glDrawArrays → GPU 开始工作
     ↓
GPU 管线:
  ① Vertex Shader(可编程):坐标变换(模型→世界→视图→屏幕)
  ② Rasterization(硬件固定):三角形变像素 + 重心插值
  ③ Fragment Shader(可编程):光照计算、纹理采样
  ④ Blending(硬件固定):透明混合、深度测试
     ↓
屏幕上出现了你画的三角形!

关键公式速查

顶点变换:
  gl_Position = M_proj × M_view × M_model × vec4(pos, 1.0)

Blinn-Phong 光照:
  环境光  = ka × Ia
  漫反射  = kd × I_light × max(0, n·l)
  镜面光  = ks × I_light × pow(max(0, n·h), phongExp)

VBO 上传:
  glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW)

VBO 属性配置(6 参数口诀:索引·维度·类型·归一化·步长·偏移):
  glVertexAttribPointer(index, size, type, normalized, stride, pointer)

纹理上传:
  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGB, GL_UNSIGNED_BYTE, data)
  glGenerateMipmap(GL_TEXTURE_2D)

实例化绘制:
  glDrawArraysInstanced(GL_TRIANGLES, 0, vertexCount, instanceCount)

常见错误速查

错误表现原因解决方法
黑屏什么都看不到Shader 编译失败检查 glGetShaderInfoLog()
位置错乱三角形飞到远处stride/offset 算错核对 sizeof(Vertex)
颜色不对颜色"脏"颜色值不在 [0,1] 范围记得除以 255
UV 不对纹理拉伸/镜像UV 坐标范围不对输出 UV 为颜色调试
深度混乱前后关系反了没开深度测试glEnable(GL_DEPTH_TEST)
一句话记住本章GPU 管线 = 4 步(顶点→光栅化→片段→混合),你写两个 Shader(顶点+片段),其他硬件帮你搞定。

下一步:第18章《颜色》——颜色空间、色度学、视觉感知。理解"三角形变像素"之后,该理解"像素是什么颜色"了。

目录 X
章节 X