OpenGL ES 核心原理完全指南

0 阅读43分钟

OpenGL ES 核心原理完全指南

目标:让你真正理解OpenGL ES,而不是记住一堆API

参考:字节流动、程序员kenney


一、为什么需要GPU渲染?

1.1 CPU渲染的困境

想象一下,你要在一个1920x1080的屏幕上画一个三角形:

// 纯CPU渲染 - 逐像素计算
void draw_triangle_cpu(uint32_t* framebuffer, int width, int height,
                       Point p1, Point p2, Point p3, uint32_t color) {
    // 遍历屏幕上每一个像素
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 判断这个像素是否在三角形内
            // 这需要复杂的数学计算(重心坐标)
            if (is_inside_triangle(x, y, p1, p2, p3)) {
                framebuffer[y * width + x] = color;  // 写入显存
            }
        }
    }
}

问题在哪里?

  1. 计算量大:1920x1080 = 207万个像素,每个都要判断
  2. 无法并行:CPU是串行执行的,一个一个算
  3. 浪费资源:CPU擅长逻辑控制,却让它做简单重复的数学计算

1.2 GPU是怎么解决问题的?

GPU的思路完全不一样:我有几千个小核心,让我同时算!

CPU:1个大厨炒100道菜      vs    GPU:100个小厨同时炒100道菜
   (串行)                         (并行)
   
   厨神1人                    小厨1 小厨2 小厨3 ... 小厨100
   ████████                       █ █ █ █ █ █ █ █ █
   炒完一道                      100道菜同时出锅
   再炒下一道

GPU的核心优势

  1. 海量小核心:手机上也有几十到几百个GPU核心
  2. 并行执行:每个核心独立工作,互不干扰
  3. 专用优化:专门为图形计算优化,加法乘法特别快

1.3 OpenGL ES的本质

OpenGL ES就是GPU和CPU之间的桥梁

应用程序          OpenGL ES           GPU硬件
   │                   │                  │
   │  我要画三角形      │                  │
   ├─────────────────► │                  │
   │                   │  分配任务        │
   │                   ├────────────────► │
   │                   │                  │────► 核心1: 算顶点1
   │                   │                  │────► 核心2: 算顶点2
   │                   │                  │────► 核心3: 算顶点3
   │                   │                  │       ...
   │                   │  结果返回        │
   │                   ◄─────────────────┤
   │  显示结果          │                  │
   ◄───────────────────┤                  │

二、渲染管线到底做了什么?

2.1 完整流程图

┌──────────────────────────────────────────────────────────────────────────────┐
│                           OpenGL ES 渲染管线                                  │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  CPU端                         GPU端                                          │
│  ┌───────┐                 ┌──────────────────────────────────────────┐     │
│  │顶点数据│────────────────►│ 顶点着色器 (Vertex Shader)               │     │
│  │       │                 │  - 对每个顶点执行一次                     │     │
│  │ 位置   │                 │  - 计算顶点在屏幕上的位置                │     │
│  │ 颜色   │                 │  - 传递数据给片段着色器                  │     │
│  │ 法线   │                 └──────────────────┬───────────────────┘     │
│  │ UV坐标 │                                    │                          │
│  └───────┘                                    ▼                          │
│                                         ┌─────────────────────────┐        │
│                                         │ 图元组装 (Primitive     │        │
│                                         │ Assembly)               │        │
│                                         │ - 把顶点组装成三角形     │        │
│                                         │ - 丢弃视锥体外的图元     │        │
│                                         └────────────┬────────────┘        │
│                                                      │                     │
│                                                      ▼                     │
│                                         ┌─────────────────────────┐        │
│                                         │ 光栅化 (Rasterization) │        │
│                                         │ - 三角形覆盖的每个像素  │        │
│                                         │ - 生成"片段"(Fragment)  │        │
│                                         └────────────┬────────────┘        │
│                                                      │                     │
│                                                      ▼                     │
│  ┌───────┐                 ┌──────────────────────────────────────────┐     │
│  │纹理数据│────────────────►│ 片段着色器 (Fragment Shader)            │     │
│  │       │                 │  - 对每个片段执行一次                    │     │
│  │       │                 │  - 计算像素颜色                          │     │
│  │       │                 │  - 采样纹理                              │     │
│  └───────┘                 └──────────────────┬───────────────────┘     │
│                                               │                          │
│                                               ▼                     │
│                                         ┌─────────────────────────┐        │
│                                         │ 逐片段操作              │        │
│                                         │ - 深度测试              │        │
│                                         │ - 模板测试              │        │
│                                         │ - 混合                  │        │
│                                         └────────────┬────────────┘        │
│                                                      │                     │
│                                                      ▼                     │
│                                         ┌─────────────────────────┐        │
│                                         │ 帧缓冲 (FrameBuffer)    │        │
│                                         │ - 最终显示到屏幕        │        │
│                                         └─────────────────────────┘        │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘

2.2 客户端-服务端模型

参考:程序员kenney - OpenGL ES 工作机制

理解OpenGL的一个关键概念是客户端-服务端模型

┌─────────────────────────────────────────────────────────────────────────────┐
│                        OpenGL 客户端-服务端模型                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   客户端 (CPU)                           服务端 (GPU)                        │
│   ┌─────────────────┐                  ┌─────────────────────────────┐     │
│   │                 │                  │                             │     │
│   │  我们写的代码    │──── 发送指令 ───►│   图形渲染管线               │     │
│   │                 │   + 数据         │   (不可见,不可改)           │     │
│   │  - 调用API      │◄── 返回结果 ─────│   顶点着色器 ← 可编程       │     │
│   │  - 传输数据     │                  │   片段着色器 ← 可编程       │     │
│   │  - 发号施令     │                  │   其他阶段 ← 固定功能       │     │
│   │                 │                  │                             │     │
│   └─────────────────┘                  └─────────────────────────────┘     │
│                                                                             │
│   关键点:                                                                   │
│   1. 我们的代码运行在CPU(客户端)                                           │
│   2. 图形渲染管线运行在GPU(服务端)                                         │
│   3. 两者是独立运行的,互不阻塞                                              │
│   4. 我们只能通过API"建议"GPU做什么                                         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

这意味着什么?

  • 调用 glDrawArrays() 只是把命令放进队列,立即返回
  • GPU 可能还没开始执行这条命令
  • 这就是为什么需要同步机制!

2.3 OpenGL是一个状态机

参考:程序员kenney - 状态机概念

电梯例子:
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│     静止 ──► 开门 ──► 关闭 ──► 运动 ──► 静止                                 │
│       ▲                                                          │          │
│       └──────────────────────────────────────────────────────────┘          │
│                                                                             │
│     电梯不能随意跳转状态:                                                   │
│     - 不能运动中开门                                                       │
│     - 不能开着门运动                                                       │
│     - 必须按顺序来                                                          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

OpenGL 也是一样,它是一个巨大的状态机

// 理解这些"Bind"操作
glBindVertexArray(VAO);    // 进入"VAO状态":后续操作都关联这个VAO
glBindBuffer(GL_ARRAY_BUFFER, VBO);  // 进入"VBO状态"
glUseProgram(program);    // 进入"Program状态"

glDrawArrays(...);        // 使用当前所有状态绘制

glBindVertexArray(0);     // 退出VAO状态
// 后续操作不再关联这个VAO

状态机思维

  • 执行 glBindXXX() 后,进入该对象的状态
  • 在解绑之前,所有相关操作都作用于这个对象
  • 就像"走进电梯"和"走出电梯":在电梯里做的事情都影响电梯

三、着色器:GPU是怎么运行的?

3.1 着色器的本质:并行的小程序

关键理解:顶点着色器和片段着色器都是在GPU上并行运行的小程序

GPU架构:
┌──────────────────────────────────────────────────────┐
│                     GPU 核心                          │
│  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ...    │
│  │ 核心1  │ │ 核心2  │ │ 核心3  │ │ 核心4  │        │
│  │        │ │        │ │        │ │        │        │
│  │ 执行   │ │ 执行   │ │ 执行   │ │ 执行   │        │
│  │ 顶点1  │ │ 顶点2  │ │ 顶点3  │ │ 顶点4  │        │
│  └────────┘ └────────┘ └────────┘ └────────┘        │
│                                                       │
│  片段着色器也是同样并行执行                           │
│  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ...    │
│  │ 像素1  │ │ 像素2  │ │ 像素3  │ │ 像素4  │        │
│  └────────┘ └────────┘ └────────┘ └────────┘        │
└──────────────────────────────────────────────────────┘

3.2 顶点着色器 vs 片段着色器

┌────────────────────────────────────────────────────────────┐
│                     顶点着色器                              │
├────────────────────────────────────────────────────────────┤
│  处理对象:  每个顶点执行一次                                │
│  输入:      顶点属性(位置、颜色、法线、UV等)              │
│  输出:      gl_Position(裁剪空间位置)                    │
│  主要任务:  坐标变换(模型→世界→观察→投影→裁剪)           │
│  并行度:    顶点级并行(几千个顶点同时处理)                │
└────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────┐
│                     片段着色器                              │
├────────────────────────────────────────────────────────────┤
│  处理对象:  每个片段(像素)执行一次                        │
│  输入:      顶点着色器输出的插值、uniforms、纹理            │
│  输出:      gl_FragColor(片段颜色)                       │
│  主要任务:  计算像素颜色、纹理采样、光照计算                │
│  并行度:    片段级并行(几百万个像素同时处理)              │
└────────────────────────────────────────────────────────────┘

3.3 数据传递:attribute vs uniform vs varying

// 顶点着色器
attribute vec3 aPosition;    // ← 每个顶点不同的属性(顶点输入)
attribute vec2 aTexCoord;    // ← 每个顶点不同的属性

uniform mat4 uMVPMatrix;     // ← 所有顶点相同的统一变量

varying vec2 vTexCoord;     // → 传递给片段着色器(插值输出)

void main() {
    gl_Position = uMVPMatrix * vec4(aPosition, 1.0);
    vTexCoord = aTexCoord;  // 会自动插值传递给片段着色器
}

// 片段着色器
varying vec2 vTexCoord;     // ← 接收顶点着色器传来的插值
uniform sampler2D uTexture; // ← 纹理采样器

void main() {
    // 采样纹理
    vec4 color = texture2D(uTexture, vTexCoord);
    gl_FragColor = color;
}

数据流向图

CPU内存                           GPU内存
┌─────────────┐                ┌─────────────────────┐
│ 顶点属性    │ ──传输──────► │ 顶点着色器          │
│ (attribute) │                │  (每个顶点执行一次) │
└─────────────┘                └──────────┬──────────┘
                                           │
                                           ▼ 插值
                                          ┌─────────────────────┐
                                          │ 片段着色器          │
                                          │  (每个像素执行一次) │
                                          └──────────┬──────────┘
                                                     │
                                                     ▼
                                          ┌─────────────────────┐
                                          │ 帧缓冲              │
                                          │ (显示到屏幕)        │
                                          └─────────────────────┘

统一变量 (uniform):
┌─────────────────────────────────────┐
│ 着色器程序                          │
│                                     │
│ uniform mat4 uMVPMatrix;  ← 全局统一│
│ uniform vec3 uLightPos;    ← 全局统一│
│                                     │
│ attribute vec3 aPos;      ← 顶点属性│
│                                     │
│ varying vec2 vTexCoord;   ← 插值    │
└─────────────────────────────────────┘

四、纹理到底是怎么回事?

4.1 纹理的核心思想

一句话概括:纹理就是"贴图"——给物体表面贴一张图片

没有纹理:                          有纹理:
┌─────────────┐                   ┌─────────────┐
│             │                   │    ████     │
│    盒子     │   ───────►        │  ██    ██   │
│             │                   │    ████     │
└─────────────┘                   └─────────────┘
  (纯色)                              (贴图)

4.2 纹理坐标:图片怎么贴到物体上?

关键概念:UV坐标

纹理图片:
┌─────────────────────────────────┐
│ (0,0)                    (1,0) │
│    ┌─────────────────────┐     │
│    │                     │     │
│    │      图片内容        │     │
│    │                     │     │
│    └─────────────────────┘     │
│ (0,1)                    (1,1) │
└─────────────────────────────────┘

UV坐标:
U = 水平方向 (0~1)
V = 垂直方向 (0~1)

(0,0) = 图片左下角
(1,1) = 图片右上角

4.3 纹理过滤:图片拉伸/压缩了怎么办?

问题:3D物体会远会近,远的时候一个像素要显示纹理的一大块,近的时候一个像素要显示纹理的一小点。

解决方案:MipMap(多级渐远纹理)

原始纹理 (512x512):
┌────────────┐
│████████████│
│████████████│
│████████████│
│████████████│
└────────────┘

MipMap层级:
Level 0: 512x512  ████████████████
Level 1: 256x256  ████████████
Level 2: 128x128  ██████████
Level 3:  64x64   ████████
Level 4:  32x32   ██████
Level 5:  16x16   ████
...
Level N:   1x1    

GPU会自动选择合适的层级:
- 物体很近  用大纹理(清晰)
- 物体很远  用小纹理(性能好,没噪点)

五、VBO/VAO:为什么要设计这些?

5.1 原始问题:每次绘制都要传数据?

最原始的做法

// 每次绘制都从CPU传输数据 - 极慢!
float vertices[] = {0, 0.5,  -0.5, -0.5,  0.5, -0.5};

glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glDrawArrays(GL_TRIANGLES, 0, 3);

// 问题:vertices数组在内存(RAM)中
// GPU每次都要从RAM读取数据,非常慢!
// 这叫"总线瓶颈"

5.2 VBO的诞生:把数据预存到GPU

解决思路:先把数据传到GPU显存存着,下次直接用!

// 创建VBO(Vertex Buffer Object)
GLuint vbo;
glGenBuffers(1, &vbo);              // 申请一个缓冲区对象
glBindBuffer(GL_ARRAY_BUFFER, vbo); // 绑定为顶点缓冲

// 把数据传到GPU显存
float vertices[] = {0, 0.5,  -0.5, -0.5,  0.5, -0.5};
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 后续绘制时,不需要再传数据了!
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, (void*)0);
glDrawArrays(GL_TRIANGLES, 0, 3);

// GPU直接从显存(VBO)读取数据,比从RAM快10倍以上!

5.3 VAO的诞生:不用每次都配置状态

问题:每个顶点属性都要配置一次,好麻烦!

// 传统方式:每次绘制都要配置所有属性
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glVertexAttribPointer(0, 3, GL_FLOAT, ..., 0);  // 位置
glVertexAttribPointer(1, 2, GL_FLOAT, ..., 12); // UV
glVertexAttribPointer(2, 3, GL_FLOAT, ..., 24); // 法线
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);

glDrawArrays(...);

// 如果有多个物体要切换,还要重复配置所有属性!

VAO解决方案:把配置"快照"保存下来

// 创建VAO(Vertex Array Object)
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);  // 绑定VAO,后续配置都记录到这个VAO里

// 配置顶点属性 - 这些配置会被VAO自动保存!
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glVertexAttribPointer(0, 3, GL_FLOAT, ..., 0);
glEnableVertexAttribArray(0);

glBindBuffer(GL_ARRAY_BUFFER, vbo2);
glVertexAttribPointer(1, 2, GL_FLOAT, ..., 0);
glEnableVertexAttribArray(1);

glBindVertexArray(0);  // 解绑VAO,配置完成

// ============================================
// 后续绘制:一句话搞定所有配置!
// ============================================
glBindVertexArray(vao);  // 恢复所有配置
glDrawArrays(...);       // 直接画
glBindVertexArray(0);

六、FBO:为什么要离屏渲染?

6.1 问题:只能画到屏幕上吗?

默认情况:OpenGL ES 只能画到"默认帧缓冲"(屏幕)

┌─────────────────────────────────────┐
│           手机屏幕                   │
│  ┌─────────────────────────────┐   │
│  │                             │   │
│  │     只能画到这里!           │   │
│  │                             │   │
│  └─────────────────────────────┘   │
│         默认帧缓冲                   │
└─────────────────────────────────────┘

局限性

  1. 画完就不能修改(已经显示到屏幕了)
  2. 无法做后期处理(滤镜、特效)
  3. 无法截图
  4. 无法做阴影图

6.2 FBO解决方案:画到纹理

核心思想:创建一个"虚拟屏幕"(帧缓冲对象),画到这里面

默认帧缓冲(屏幕):            FBO(内存/纹理):
┌───────────────────┐         ┌───────────────────┐
│                   │         │                   │
│    显示内容       │  ◄────  │   可以随意处理    │
│                   │         │   的图像数据      │
└───────────────────┘         └───────────────────┘
   (只能看)                      (可以截图/处理/再显示)

6.3 FBO的典型应用场景

场景1:截图

// 1. 创建FBO
GLuint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);

// 2. 创建颜色纹理(用来存图像)
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

// 3. 渲染场景到FBO
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
render_scene();  // 正常渲染

// 4. 读取像素数据(截图!)
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);

// 5. 恢复绘制到屏幕
glBindFramebuffer(GL_FRAMEBUFFER, 0);
render_scene();

场景2:后期处理(滤镜)

原始画面 ──► 渲染到FBO ──► 纹理A ──► 片段着色器处理 ──► 屏幕
                              (可以做各种特效)
                              
特效例子:
- 模糊: 对周围像素取平均值
- 锐化: 增强边缘对比度
- 灰度: 颜色转灰度
- 抖音: 灵魂出窍效果

6.4 多渲染目标(MRT)

参考:程序员kenney - 多渲染目标

问题:一次渲染能同时输出到多个纹理吗?

传统FBO:
┌─────────────────────────────────────┐
│         FBO                         │
│  ┌─────────────────────────────┐   │
│  │  Color Attachment 0 (纹理0) │   │
│  └─────────────────────────────┘   │
└─────────────────────────────────────┘

MRT (多渲染目标):
┌─────────────────────────────────────┐
│         FBO                         │
│  ┌─────────────────────────────┐   │
│  │  Color Attachment 0 (纹理0) │   │
│  ├─────────────────────────────┤   │
│  │  Color Attachment 1 (纹理1) │   │
│  ├─────────────────────────────┤   │
│  │  Color Attachment 2 (纹理2) │   │
│  └─────────────────────────────┘   │
└─────────────────────────────────────┘

一次渲染,同时输出3个效果!

MRT实现

// 绑定多个纹理到不同的color attachment
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture0, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, texture1, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, texture2, 0);

// 设置draw buffers
GLenum attachments[] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2};
glDrawBuffers(3, attachments);
// Fragment Shader (OpenGL ES 3.0)
#version 300 es
layout(location = 0) out vec4 fragColor0;
layout(location = 1) out vec4 fragColor1;
layout(location = 2) out vec4 fragColor2;

uniform sampler2D u_texture;
in vec2 v_textureCoordinate;

void main() {
    vec4 color = texture(u_texture, v_textureCoordinate);
    // R通道设为1
    fragColor0 = vec4(1.0, color.g, color.b, color.a);
    // G通道设为1
    fragColor1 = vec4(color.r, 1.0, color.b, color.a);
    // B通道设为1
    fragColor2 = vec4(color.r, color.g, 1.0, color.a);
}

应用场景:一次渲染生成多个特效,减少DrawCall


七、同步机制:CPU和GPU怎么协调?

7.1 为什么需要同步?

参考:程序员kenney - fence同步

核心问题:OpenGL命令是异步执行的!

CPU:                          GPU:
┌─────────────┐              ┌─────────────┐
│ glDrawArrays│ ───命令────► │             │
│   (立即返回) │              │  还没开始画 │
│             │              │             │
│ glReadPixels│ ◄──结果───── │  终于画完了 │
└─────────────┘              └─────────────┘

问题:如果在GPU还没画完就读,可能读到空数据!

7.2 glFlush vs glFinish

┌─────────────────────────────────────────────────────────────────────────────┐
                        glFlush vs glFinish                                  
├─────────────────────────────────────────────────────────────────────────────┤
                                                                             
  glFlush:                                                                   
  ┌─────────────────────────────────────────────────────────────────────┐   
   - 将命令队列中的命令发送到GPU                                           
   - 立即返回,不等待GPU执行完成                                           
   - 确保命令"已经开始执行"                                               
   - 性能损失小                                                            
  └─────────────────────────────────────────────────────────────────────┘   
                                                                             
  glFinish:                                                                  
  ┌─────────────────────────────────────────────────────────────────────┐   
   - 将命令发送到GPU并等待执行完成                                         
   - 阻塞直到GPU完成所有命令                                               
   - 确保命令"已经执行完"                                                  
   - 性能损失大(CPU和GPU无法并行)                                        
  └─────────────────────────────────────────────────────────────────────┘   
                                                                             
  性能对比:                                                                  
                                                                             
  glFlush:     CPU ████████████    GPU ████████████████████████            
                      (并行)              (异步执行)                         
                                                                             
  glFinish:    CPU ████████████______GPU ████████████████████████           
                      (等待)              (串行执行)                         
                                                                             
  建议:一般用glFlush,需要强同步时再用glFinish                              
                                                                             
└─────────────────────────────────────────────────────────────────────────────┘

7.3 Fence同步 - 多线程时代的解决方案

参考:程序员kenney - fence同步

glFinish的局限:只能等待本线程的命令执行完!

场景:两个线程协同工作

线程A (渲染线程):
┌─────────────────────────┐
│ 渲染纹理到FBO           │
│ glDrawArrays(...)       │
│ glFinish()  ← 只能等A的 │
└─────────────────────────┘

线程B (处理线程):
┌─────────────────────────┐
│ 读取纹理数据             │
│ glReadPixels(...)       │
│                         │
│ 问题:纹理可能还没渲染完!│
└─────────────────────────┘

Fence解决方案

线程A (渲染线程):                    线程B (处理线程):
┌─────────────────────────┐        ┌─────────────────────────┐
│ 渲染到纹理                │        │                         │
│ glDrawArrays(...)       │        │ 等待fence               │
│                         │        │ glClientWaitSync(...)   │
│ 插入fence                │        │                         │
│ glFenceSync(...)        │        │ 读取纹理(现在安全了)  │
│ glFlush()               │ ──fence──► glReadPixels(...)    │
│                         │        │                         │
└─────────────────────────┘        └─────────────────────────┘

fence = 栅栏/围栏
- 线程A在某个点"立栅栏"
- 线程B在另一个点"等栅栏"
- 栅栏倒下 = 线程A的命令执行完了

代码实现

// 线程A: 插入fence
GLsync fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
glFlush();  // 确保fence被发送到GPU

// 线程B: 等待fence

// 方式1: glClientWaitSync - 在CPU上等待(会阻塞CPU线程)
// CPU会暂停在这里,等待GPU完成
// 适用于:需要CPU知道什么时候完成
glClientWaitSync(fence, 0, GL_TIMEOUT_IGNORED);

// 方式2: glWaitSync - 在GPU上等待(不阻塞CPU,但GPU会暂停)
// CPU继续做别的事,但GPU会暂停后续命令
// 适用于:下一个GPU命令依赖这个fence的结果


// 使用完毕后删除
glDeleteSync(fence);

两种等待方式的区别

方式阻塞谁适用场景
glClientWaitSyncCPU阻塞等待需要CPU知道完成时机,读取结果
glWaitSyncGPU等待,CPU继续执行下一个GPU命令依赖这个结果

7.4 共享上下文

参考:程序员kenney - OpenGL资源共享

另一个多线程方案:共享上下文

创建共享上下文:
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│  EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);                  │
│  eglInitialize(display, ...);                                               │
│                                                                             │
│  // 主线程上下文                                                            │
│  EGLContext mainContext = eglCreateContext(display, config,                │
│                                              EGL_NO_CONTEXT, attribs);      │
│                                                                             │
│  // 共享上下文(共享资源,但各自有独立状态)                                │
│  EGLContext shareContext = eglCreateContext(display, config,               │
│                                              mainContext, attribs);         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

可以共享的资源

  • 纹理 (Texture)
  • 着色器 (Shader)
  • 程序 (Program)
  • 缓冲区 (VBO, EBO, RBO)

不能共享的资源

  • FBO(帧缓冲对象)
  • VAO(顶点数组对象)

注意:每个线程同时只能绑定一个Context,共享Context需要用fence同步!


八、深度测试:谁挡着谁?

8.1 问题:两个三角形重叠,谁显示在上面?

场景:画两个正方形
┌─────────────┐
│  正方形A    │  红色
│  ┌───────┐  │
│  │       │  │
│  └───────┘  │
│      ┌───────┐
│      │ 正方形B│  蓝色
│      └───────┘
└─────────────┘

问题:BA重叠的部分,应该显示红色还是蓝色?

8.2 解决方案:Z-Buffer(深度缓冲)

核心思想:每个像素记录自己的"深度"(离摄像机多远)

帧缓冲 (显示颜色):           深度缓冲 (记录深度):
┌─────────────┐              ┌─────────────┐
│  红 红 红   │              │  1  1  1    │
│  红 蓝 蓝   │  ← 重叠区域  │  1  0.5 0.5 │  ← B更近(0.5),覆盖A(1.0)
│  红 蓝 蓝   │              │  1  0.5 0.5 │
└─────────────┘              └─────────────┘

8.3 深度测试流程

对于每个要绘制的片段:

1. 获取当前片段的深度值 Z_new
2. 读取深度缓冲中已有深度值 Z_old
3. 比较: glDepthFunc(func)
   
   常用比较函数:
   - GL_LESS:  Z_new < Z_old  →  新片段更近,绘制并更新深度缓冲
   - GL_LEQUAL: Z_new <= Z_old → 新片段更近或相等,绘制
   - GL_GREATER: Z_new > Z_old → 新片段更远,不绘制
   - GL_ALWAYS:  总是绘制(关闭深度测试效果)

4. 如果通过测试,写入颜色缓冲 + 更新深度缓冲

九、混合:透明是怎么实现的?

9.1 问题:透明物体和 opaque 物体混在一起,怎么渲染?

场景:
┌─────────────────────┐
│    不透明盒子        │  ← 红色,不透明
│  ┌───────────────┐  │
│  │  透明玻璃      │  │  ← 蓝色,半透明
│  │   (能看到后面) │  │
│  └───────────────┘  │
│    背景              │  ← 绿色
└─────────────────────┘

问题:透明玻璃应该显示什么颜色?
答案:玻璃颜色 + 背景颜色(混合)

9.2 混合公式

核心公式

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

即:  Out = Src × SrcFactor + Dst × DstFactor
源 (Src):  当前要绘制的片段(玻璃)
目标 (Dst):  已经画在缓冲里的颜色(背景)

┌─────────────────────────────────────────┐
│  混合示意:                                │
│                                          │
│  源颜色 (玻璃):  (0, 0, 1, 0.5)          │
│  目标颜色 (背景): (0, 1, 0, 1)           │
│                                          │
│  混合后:                                  │
│  R = 0×0.5 + 0×0.5 = 0                   │
│  G = 0×0.5 + 1×0.5 = 0.5                 │
│  B = 1×0.5 + 0×0.5 = 0.5                 │
│  A = 0.5×0.5 + 1×0.5 = 0.75              │
└─────────────────────────────────────────┘

9.3 渲染顺序问题

关键问题:透明物体必须最后渲染吗?

错误顺序(会导致透明物体看起来不对):
┌─────────────────────────────┐
│ 1. 先画透明物体              │
│    glDrawArrays(透明盒子)    │
│    → 颜色写入缓冲             │
│                              │
│ 2. 再画不透明物体             │
│    glDrawArrays(不透明盒子)   │
│    → 覆盖了透明物体!         │
└─────────────────────────────┘

正确顺序:
┌─────────────────────────────┐
│ 1. 先画所有不透明物体         │
│    (开启深度测试,不写入alpha)│
│                              │
│ 2. 关闭深度写入               │
│    glDepthMask(GL_FALSE);    │
│                              │
│ 3. 画所有透明物体             │
│    (从远到近排序)             │
│                              │
│ 4. 恢复深度写入               │
│    glDepthMask(GL_TRUE);     │
└─────────────────────────────┘

十、光照模型:现实世界的光是怎么模拟的?

10.1 现实世界的光照

你看到的一切,都来自

光源(太阳/灯泡)
    │
    │ 光线
    ▼
    │
    ├──► 照射到物体表面
    │        │
    │        ├──► 部分被吸收
    │        │
    │        └──► 部分反射出来 ──► 进入你的眼睛 ──► 看到颜色
    │
    └──► 直接进入眼睛 ──► 刺眼(太亮)

10.2 OpenGL ES 光照模型

核心三要素

┌─────────────────────────────────────────────────────────────┐
│                    光照 = 环境光 + 漫反射 + 镜面反射         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  环境光 (Ambient):                                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 即使没有直接光照,也有基础亮度                        │   │
│  │ 模拟: 光线经过多次反射后的环境照明                     │   │
│  │ 公式: ambient = ambientStrength * lightColor        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  漫反射 (Diffuse):                                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 粗糙表面向各个方向均匀反射                            │   │
│  │ 模拟: 墙壁、桌面等不光滑表面                          │   │
│  │ 公式: diffuse = max(dot(normal, lightDir), 0.0)     │   │
│  │       * lightColor                                   │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  镜面反射 (Specular):                                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 光滑表面在特定方向集中反射                            │   │
│  │ 模拟: 金属、镜子、水面                                │   │
│  │ 公式: specular = pow(max(dot(viewDir, reflectDir),  │   │
│  │                   0.0), shininess) * lightColor      │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

十一、坐标系统:位置是怎么变换的?

11.1 为什么需要坐标变换?

问题:我在Blender里画了一个立方体,它的坐标是(0,0,0)。怎么显示到手机屏幕的不同位置?

答案:通过一系列坐标变换!

你定义的立方体:              手机屏幕显示:
┌─────────────┐             ┌─────────────────────┐
│             │             │                     │
│    立方体    │  ───────►   │    屏幕左上角        │
│  (0,0,0)     │             │    某个位置         │
│             │             │                     │
└─────────────┘             └─────────────────────┘

变换过程:
模型空间 ──► 世界空间 ──► 观察空间 ──► 裁剪空间 ──► 屏幕空间

11.2 四大坐标空间

┌────────────────────────────────────────────────────────────────────┐
│                        坐标变换流程                                 │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  模型空间 (Model Space)                                            │
│  ┌──────────────────────────┐                                      │
│  │  物体自身的坐标系          │                                      │
│  │  立方体中心是(0,0,0)       │                                      │
│  │  顶点坐标相对于物体中心    │                                      │
│  └───────────┬──────────────┘                                      │
│              │ Model Matrix                                        │
│              ▼                                                     │
│  世界空间 (World Space)                                            │
│  ┌──────────────────────────┐                                      │
│  │  物体在世界中的位置        │                                      │
│  │  可能平移到(10, 5, 0)      │                                      │
│  │  顶点坐标相对于世界原点    │                                      │
│  └───────────┬──────────────┘                                      │
│              │ View Matrix                                         │
│              ▼                                                     │
│  观察空间 (View / Camera Space)                                     │
│  ┌──────────────────────────┐                                      │
│  │  从摄像机角度看            │                                      │
│  │  摄像机位置是(0,0,0)       │                                      │
│  │  顶点坐标相对于摄像机      │                                      │
│  └───────────┬──────────────┘                                      │
│              │ Projection Matrix                                   │
│              ▼                                                     │
│  裁剪空间 (Clip Space)                                              │
│  ┌──────────────────────────┐                                      │
│  │  透视投影后的坐标          │                                      │
│  │  x,y,z 在 [-w, w] 范围内  │                                      │
│  │  超出范围的会被裁剪掉      │                                      │
│  └───────────┬──────────────┘                                      │
│              │ 透视除法 + 视口变换                                  │
│              ▼                                                     │
│  屏幕空间 (Screen Space)                                            │
│  ┌──────────────────────────┐                                      │
│  │  最终显示的像素位置        │                                      │
│  │  x: [0, width]           │                                      │
│  │  y: [0, height]          │                                      │
│  └──────────────────────────┘                                      │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

总结:核心概念一览

┌─────────────────────────────────────────────────────────────────────┐
                        OpenGL ES 核心概念                            
├─────────────────────────────────────────────────────────────────────┤
                                                                     
  1. 客户端-服务端模型                                               
     - 代码运行在CPU,渲染运行GPU                                    
     - 命令是异步执行的                                              
                                                                     
  2. 状态机                                                          
     - OpenGL是一个巨大的状态机                                      
     - Bind操作进入状态,解绑退出状态                                
                                                                     
  3. 渲染管线                                                         
     顶点着色器  光栅化  片段着色器  逐片段操作  帧缓冲          
                                                                     
  4. 着色器                                                           
     - 顶点着色器: 处理顶点,坐标变换                                 
     - 片段着色器: 处理像素,计算颜色                                 
     - attribute: 顶点属性(每个顶点不同)                           
     - uniform: 统一变量(所有顶点相同)                             
     - varying: 插值(顶点→片段)                                    
                                                                     
  5. 纹理                                                             
     - UV坐标: 0~1,映射到纹理图片                                    
     - MipMap: 多级渐远纹理,优化远距离显示                          
     - 过滤模式: NEAREST(像素风) / LINEAR(平滑)                     
                                                                     
  6. 缓冲区                                                           
     - VBO: 预存顶点数据到GPU显存                                    
     - VAO: 保存顶点配置状态                                         
     - EBO: 索引复用顶点                                             
     - FBO: 离屏渲染到纹理                                           
                                                                     
  7. 同步机制                                                         
     - glFlush: 发送命令,不等待                                     
     - glFinish: 等待完成(阻塞)                                    
     - Fence: 跨线程同步                                             
     - 共享Context: 多线程共享资源                                   
                                                                     
  8. 测试                                                             
     - 深度测试: Z-Buffer,解决遮挡                                  
     - 模板测试: 遮罩/轮廓效果                                       
     - 混合: 透明效果                                                
                                                                     
  9. 坐标系统                                                         
     模型空间  世界空间  观察空间  裁剪空间  屏幕空间            
                                                                     
  10. 光照模型                                                        
     环境光 + 漫反射 + 镜面反射 = 最终颜色                           
                                                                     
└─────────────────────────────────────────────────────────────────────┘

十二、EGL:OpenGL ES 怎么连接到屏幕?

12.1 问题背景:OpenGL ES 怎么画到屏幕上?

解决什么问题?

OpenGL ES 只是一个图形渲染API,它定义了如何画图,但它不知道如何把图画到你的手机屏幕上

问题:
┌─────────────────────────────────────────────────────┐
│                                                     │
│  OpenGL ES 定义了:                                  │
│  - 如何创建着色器                                   │
│  - 如何调用 glDrawArrays 画三角形                   │
│  - 如何设置各种状态                                  │
│                                                     │
│  但它不知道:                                        │
│  - 你的手机屏幕在哪里                               │
│  - 如何获取屏幕的绘图表面                            │
│  - 如何把渲染结果显示出来                           │
│                                                     │
└─────────────────────────────────────────────────────┘

12.2 设计思路:抽象出"窗口系统层"

设计师如何思考的?

既然不同平台(Android、iOS、Linux)的屏幕API完全不同,那就在OpenGL ES和平台之间加一层抽象!

┌─────────────────────────────────────────────────────────────┐
│                     架构对比                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  直接绑定(不现实):                                        │
│  ┌─────────┐                                                │
│  │OpenGL ES │ ──► 直接调用 ──► 手机屏幕?  ❌             │
│  └─────────┘           每个平台API不同,无法统一            │
│                                                             │
│  引入EGL层:                                                │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐              │
│  │OpenGL ES │ ──►│   EGL   │ ──►│ 平台相关 │              │
│  └─────────┘    └─────────┘    └─────────┘              │
│                      统一接口        (Android/iOS/...)      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

EGL 就是这个"桥梁",它提供统一的接口来:

  1. 获取显示设备(屏幕)
  2. 创建绘图表面(Surface)
  3. 创建渲染上下文(Context)
  4. 绑定 OpenGL ES 到 Surface

12.3 具体实现:从原理到代码

EGL 初始化流程

// 1. 获取 EGL 显示对象(连接到屏幕)
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) {
    // 获取显示设备失败
}

// 2. 初始化 EGL
EGLint majorVersion, minorVersion;
if (!eglInitialize(display, &majorVersion, &minorVersion)) {
    // 初始化失败
}

// 3. 选择配置(匹配屏幕特性)
EGLConfig config;
EGLint numConfigs;
EGLint configAttribs[] = {
    EGL_SURFACE_TYPE, EGL_WINDOW_BIT,    // 窗口表面
    EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,  // OpenGL ES 2.0
    EGL_DEPTH_SIZE, 16,                    // 深度缓冲
    EGL_NONE
};
if (!eglChooseConfig(display, configAttribs, &config, 1, &numConfigs)) {
    // 选择配置失败
}

// 4. 创建窗口表面(绑定到原生窗口)
EGLSurface surface = eglCreateWindowSurface(display, config, 
                                             nativeWindow,  // Android: ANativeWindow*
                                             NULL);
if (surface == EGL_NO_SURFACE) {
    // 创建表面失败
}

// 5. 创建渲染上下文(OpenGL ES 状态存储)
EGLint contextAttribs[] = {
    EGL_CONTEXT_CLIENT_VERSION, 2,  // OpenGL ES 2.0
    EGL_NONE
};
EGLContext context = eglCreateContext(display, config, 
                                       EGL_NO_CONTEXT,  // 不共享
                                       contextAttribs);
if (context == EGL_NO_CONTEXT) {
    // 创建上下文失败
}

// 6. 绑定:让 OpenGL ES 在这个表面上绘制
if (!eglMakeCurrent(display, surface, surface, context)) {
    // 绑定失败
}

// ====================
// 现在可以正常使用 OpenGL ES 了!
// ====================
glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
eglSwapBuffers(display, surface);  // 显示到屏幕

// 7. 结束时释放资源
eglDestroyContext(display, context);
eglDestroySurface(display, surface);
eglTerminate(display);

EGL 核心概念

概念说明
EGLDisplay抽象的显示设备(屏幕)
EGLSurface绘图表面(窗口或离屏缓冲)
EGLContextOpenGL ES 状态存储
EGLConfig屏幕配置(颜色深度、缓冲等)

12.4 常见陷阱/面试题

Q1: eglMakeCurrent 失败怎么办?

  • 检查 surface 是否有效
  • 检查 context 是否已被其他线程占用
  • 每个线程同时只能绑定一个 context

Q2: 为什么画的内容不显示?

  • 检查是否调用了 eglSwapBuffers
  • 检查 surface 是否正确创建
  • 检查是否 eglMakeCurrent 成功

Q3: 离屏渲染怎么做?

  • 使用 EGL_PBUFFER_BIT 创建 pbuffer 表面
  • 不需要 nativeWindow

十三、模板测试:遮罩和描边怎么做?

13.1 问题背景:如何只画物体的轮廓?

解决什么问题?

有时候我们只想画物体的某一部分,或者只画轮廓

  • 描边效果(物体边缘画一圈线)
  • 遮罩效果(只显示某个区域)
  • 阴影体积(Stencil Shadow Volume)
场景:给立方体画红色描边
┌─────────────────────┐
│                     │
│   ┌─────────┐       │
│   │ ████████ │ ← 内部填充颜色   │
│   │ ████████ │       │
│   │ ████████ │       │
│   └─────────┘       │
│   ┌─────────┐       │
│   │         │ ← 边框(描边)     │
│   └─────────┘       │
│                     │
└─────────────────────┘

13.2 设计思路:引入"模板缓冲"

设计师如何思考的?

在颜色缓冲和深度缓冲之外,再增加一个缓冲来记录"这里能不能画":

┌─────────────────────────────────────────────────────────────┐
│                     三大缓冲                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  颜色缓冲 (Color Buffer):                                  │
│  ┌─────────────┐                                           │
│  │  红 绿 蓝   │  存像素颜色                                │
│  └─────────────┘                                           │
│                                                             │
│  深度缓冲 (Depth Buffer):                                  │
│  ┌─────────────┐                                           │
│  │  0.1 0.5   │  存深度值                                  │
│  └─────────────┘                                           │
│                                                             │
│  模板缓冲 (Stencil Buffer) ← 新增                         │
│  ┌─────────────┐                                           │
│  │  1  0  1   │  存"模板值"(遮罩)                        │
│  └─────────────┘                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

模板测试逻辑:

if (模板值 符合 条件) {
    画这个像素
} else {
    丢弃这个像素
}

13.3 具体实现:从原理到代码

基础模板测试

// 1. 开启模板测试
glEnable(GL_STENCIL_TEST);

// 2. 设置模板测试函数
// stencilRef: 参考值
// mask: 掩码(与模板值做AND操作前)
glStencilFunc(GL_EQUAL,  // 测试函数
              1,         // 参考值 = 1
              0xFF);    // mask = 11111111

// 3. 设置模板操作(通过/失败时怎么做)
glStencilOp(GL_KEEP,    // 模板测试失败时:保持原值
            GL_KEEP,    // 深度测试失败时:保持原值
            GL_REPLACE); // 都通过时:替换为参考值

// 4. 绘制
glDrawArrays(GL_TRIANGLES, 0, count);

实现描边效果(两遍渲染)

// 第一遍:画物体,填充模板缓冲
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_ALWAYS, 1, 0xFF);  // 总是通过
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);  // 通过时写入1

glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);  // 不写颜色
glDepthMask(GL_FALSE);  // 不写深度

drawObject();  // 画物体(只写模板)

// 第二遍:画放大的物体做描边
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);  // 模板 != 1 的地方才画
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);

glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);  // 恢复写颜色
glDepthMask(GL_TRUE);

glCullFace(GL_FRONT);  // 只画正面(外扩)
glCullFace(GL_BACK);   // 或者只画背面(内缩)
glEnable(GL_CULL_FACE);

drawObjectScaled(1.05f);  // 画稍大的物体

13.4 常见陷阱/面试题

Q1: 模板缓冲没效果?

  • 检查 EGL 配置是否包含模板缓冲
  • 检查是否开启了 GL_STENCIL_TEST

Q2: 模板值一直是0?

  • 检查 glStencilOp 是否正确设置
  • 确保模板测试通过了

Q3: 描边有缺口?

  • 使用 glCullFace(GL_FRONT) 只画正面
  • 或者使用背面剔除 + 内缩

十四、抗锯齿:边缘锯齿怎么消除?

14.1 问题背景:为什么边缘会有锯齿?

解决什么问题?

3D 物体的边缘是斜的,但屏幕像素是方的:

问题示意:
┌─────────────────────────────────────┐
│                                     │
│     理想线条:  \  /                 │
│               \/                     │
│                                     │
│     实际显示:  ▌▐                  │
│               ▌▐   ← 锯齿!         │
│                                     │
│     放大看:                          │
│     ■■■■■■                          │
│       ■■■■■■                        │
│                                     │
└─────────────────────────────────────┘

这叫"走样"(Aliasing),是采样理论中的经典问题。

14.2 设计思路:增加采样点

设计师如何思考的?

一个像素只采样一次会走样,那一个像素采样多次取平均不就行了?

原始方式(1个采样点):
┌─────────────┐
│      ●      │  ● = 采样点
│             │  如果在线上→画黑,不在线上→画白
└─────────────┘  → 结果:要么全黑要么全白

MSAA(4个采样点):
┌─────────────┐
│  ●    ●     │  4个采样点
│             │
│  ●    ●     │  3个黑1个白 → 75%灰度
└─────────────┘  → 结果:平滑过渡

MSAA = Multi-Sample Anti-Aliasing(多重采样抗锯齿)

14.3 具体实现:从原理到代码

方式1:MSAA(硬件支持,推荐)

// EGL 配置中启用 MSAA
EGLint configAttribs[] = {
    EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
    EGL_SAMPLES, 4,        // 4x MSAA
    EGL_NONE
};

// Android 端(Java)
getWindow().setFlags(
    WindowManager.LayoutParams.FLAG_BITMAP_QUALITY_HIGH,
    WindowManager.LayoutParams.FLAG_BITMAP_QUALITY_HIGH
);

// iOS 端
glView.enableSetNeedsDisplay = YES;
glView.isMultipleTouchEnabled = YES;
// 创建时设置
EAGLView *glView = [[EAGLView alloc] initWithFrame:frame 
                                          pixelFormat:kEAGLColorFormatRGBA8 
                                           depthFormat:GL_DEPTH24_STENCIL8_OES
                                    preserveBackbuffer:NO 
                                            sharegroup:nil 
                                         multiSampling:YES 
                                       numberOfSamples:4];

方式2:FXAA(后处理)

// 渲染到 FBO
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
renderScene();

// 后处理着色器(简化版 FXAA)
#version 300 es
precision highp float;

uniform sampler2D u_texture;
in vec2 v_texCoord;
out vec4 fragColor;

void main() {
    vec2 texelSize = 1.0 / vec2(textureSize(u_texture, 0));
    
    // 采样周围像素
    vec3 c = texture(u_texture, v_texCoord).rgb;
    vec3 n = texture(u_texture, v_texCoord + vec2(0.0, texelSize.y)).rgb;
    vec3 s = texture(u_texture, v_texCoord - vec2(0.0, texelSize.y)).rgb;
    vec3 e = texture(u_texture, v_texCoord + vec2(texelSize.x, 0.0)).rgb;
    vec3 w = texture(u_texture, v_texCoord - vec2(texelSize.x, 0.0)).rgb;
    
    // 简单平均
    fragColor = vec4((c + n + s + e + w) / 5.0, 1.0);
}

14.4 常见陷阱/面试题

Q1: MSAA 不起作用?

  • 检查 EGL 配置是否包含 EGL_SAMPLES
  • 检查是否使用了 FBO(默认帧缓冲才支持 MSAA)
  • FBO 需要使用多重采样的纹理/渲染缓冲

Q2: MSAA 开4倍还是8倍?

  • 4x 是平衡选择
  • 8x 性能开销大,移动端谨慎使用

Q3: 性能太差怎么办?

  • 考虑 FXAA(后处理,开销小)
  • 考虑 TAA(时域抗锯齿)

十五、阴影贴图:实时阴影怎么做?

15.1 问题背景:如何生成实时阴影?

解决什么问题?

游戏中需要实时阴影:

  • 角色脚下的阴影
  • 建筑物投射的阴影
没有阴影:                    有阴影:
┌─────────────┐            ┌─────────────┐
│     ☀️      │            │     ☀️      │
│      █      │            │      █      │
│      █      │            │      █      │
│             │            │    ░░░      │ ← 阴影
│             │            │   ░░░░░     │
└─────────────┘            └─────────────┘

15.2 设计思路:两遍渲染法

设计师如何思考的?

核心思想:从光源角度看世界,看不到的地方就是阴影区域

第一遍(光源视角):
┌─────────────────────────────────────────────┐
│                                             │
│   光源位置                                   │
│      👁                                     │
│      │                                      │
│      │   物体                                │
│      │  ████                                │
│      │                                      │
│      ▼                                      │
│   投影平面 ──► 记录深度到纹理                │
│                                             │
└─────────────────────────────────────────────┘

第二遍(相机视角):
┌─────────────────────────────────────────────┐
│                                             │
│   相机                                       │
│      👁                                     │
│      │                                      │
│      │   物体                                │
│      │  ████                                │
│      │                                      │
│      ▼                                      │
│   比较:当前像素深度 vs 阴影贴图中的深度      │
│   如果 当前深度 > 阴影贴图深度 → 在阴影中    │
│                                             │
└─────────────────────────────────────────────┘

15.3 具体实现:从原理到代码

Shadow Map 实现

// ====================
// 第一遍:从光源视角渲染深度图
// ====================

// 1. 创建深度纹理
GLuint depthTexture;
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 
             shadowWidth, shadowHeight, 0, 
             GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

// 2. 创建 FBO 并绑定深度纹理
GLuint depthFBO;
glGenFramebuffers(1, &depthFBO);
glBindFramebuffer(GL_FRAMEBUFFER, depthFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, 
                        GL_TEXTURE_2D, depthTexture, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);

// 3. 设置光源的正交投影矩阵(平行光)
float lightProjection[16];
float lightView[16];
orthoMatrix(lightProjection, -10, 10, -10, 10, 1, 50);
lookAtMatrix(lightView, lightPos, target, up);

// 4. 渲染深度图(只写深度,不写颜色)
glViewport(0, 0, shadowWidth, shadowHeight);
glBindFramebuffer(GL_FRAMEBUFFER, depthFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glCullFace(GL_FRONT);  // 剔除正面,防止阴影 acne

useShadowShader();
setMatrix("u_lightProjection", lightProjection);
setMatrix("u_lightView", lightView);
drawScene();

// ====================
// 第二遍:正常渲染 + 阴影计算
// ====================

glViewport(0, 0, screenWidth, screenHeight);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

useMainShader();
setMatrix("u_projection", projectionMatrix);
setMatrix("u_view", viewMatrix);
setMatrix("u_model", modelMatrix);
setMatrix("u_lightProjection", lightProjection);
setMatrix("u_lightView", lightView);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, depthTexture);
setInt("u_shadowMap", 1);

drawScene();

片段着色器中的阴影计算

#version 300 es
precision highp float;

uniform sampler2D u_shadowMap;
uniform mat4 u_lightProjection;
uniform mat4 u_lightView;

in vec3 v_fragPos;
in vec3 v_normal;
out vec4 fragColor;

float calculateShadow(vec3 fragPos) {
    // 从光源视角计算纹理坐标
    vec4 lightSpacePos = u_lightProjection * u_lightView * vec4(fragPos, 1.0);
    
    // 透视除法(裁剪空间 → NDC)
    vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
    
    // 变换到 [0,1] 范围
    projCoords = projCoords * 0.5 + 0.5;
    
    // 超出范围则不在阴影中
    if (projCoords.z > 1.0) return 0.0;
    
    // 采样深度图
    float closestDepth = texture(u_shadowMap, projCoords.xy).r;
    
    // 当前像素深度
    float currentDepth = projCoords.z;
    
    // 阴影偏移(防止 shadow acne)
    float bias = 0.005;
    
    // PCF 软阴影(采样周围像素)
    float shadow = 0.0;
    vec2 texelSize = 1.0 / vec2(2048.0, 2048.0);
    for(int x = -1; x <= 1; ++x) {
        for(int y = -1; y <= 1; ++y) {
            float pcfDepth = texture(u_shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
            shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
        }
    }
    shadow /= 9.0;
    
    return shadow;
}

void main() {
    // 正常光照计算
    float ambient = 0.15;
    float diffuse = max(dot(normal, lightDir), 0.0);
    
    // 计算阴影
    float shadow = calculateShadow(v_fragPos);
    
    // 应用阴影
    vec3 lighting = (ambient + (1.0 - shadow) * diffuse) * color;
    fragColor = vec4(lighting, 1.0);
}

15.4 常见陷阱/面试题

Q1: 阴影有锯齿?

  • 增加阴影贴图分辨率
  • 使用 PCF(Percentage-Closer Filtering)软化边缘

Q2: 阴影有"飘影"(Shadow Acne)?

  • 添加 bias 偏移
  • 使用 glCullFace(GL_FRONT) 剔除正面

Q3: 阴影范围太小?

  • 调整正交投影的范围(ortho 参数)
  • 确保光源视锥体覆盖场景

Q4: 移动端性能差?

  • 阴影贴图分辨率不要太高(1024x1024 足够)
  • 考虑级联阴影贴图(CSM)

十六、性能优化:如何提升渲染效率?

16.1 问题背景:为什么帧率上不去?

解决什么问题?

游戏/应用需要高帧率(60fps),但渲染太慢:

帧时间 = 16.67ms(60fps)

CPU时间:
┌──────────────────────────────────────┐
│  glDrawArrays  ← 切换状态很慢!     │
│  glBindTexture ← 切换纹理很慢!      │
│  glUseProgram  ← 切换着色器很慢!    │
└──────────────────────────────────────┘

GPU时间:
┌──────────────────────────────────────┐
│  顶点着色器                          │
│  片段着色器                          │
└──────────────────────────────────────┘

问题:状态切换是性能杀手!

16.2 设计思路:减少切换 + 批量处理

设计师如何思考的?

传统方式(每个物体一次 DrawCall):
┌─────────────────────────────────────────────────────┐
│                                                     │
│  画物体A:                                           │
│    glBindTexture(A的纹理)                           │
│    glUseProgram(A的着色器)                          │
│    glDrawArrays(...)                               │
│                                                     │
│  画物体B:                                           │
│    glBindTexture(B的纹理) ← 切换!慢!             │
│    glUseProgram(B的着色器) ← 切换!慢!            │
│    glDrawArrays(...)                               │
│                                                     │
│  画物体C:                                           │
│    glBindTexture(C的纹理) ← 切换!慢!             │
│    glUseProgram(C的着色器) ← 切换!慢!            │
│    glDrawArrays(...)                               │
│                                                     │
│  100个物体 = 100次切换 = 极慢                       │
└─────────────────────────────────────────────────────┘

优化方式(批量处理):
┌─────────────────────────────────────────────────────┐
│                                                     │
│  1. 合并纹理 → 纹理图集                            │
│  2. 合并顶点 → 单一VBO                             │
│  3. 排序渲染 → 减少切换                            │
│                                                     │
│  结果:100个物体 = 几次 DrawCall!                  │
│                                                     │
└─────────────────────────────────────────────────────┘

16.3 具体实现:从原理到代码

1. 纹理图集(Texture Atlas)

// 把多个小纹理合并成一个大纹理
//
// 原始方式:
// ┌───┬───┬───┬───┐
// │ A │ B │ C │ D │  ← 4次纹理切换
// └───┴───┴───┴───┘
//
// 纹理图集:
// ┌─────────────┐
// │ A │ B │    │
// ├───┼───┤    │
// │ C │ D │    │
// └─────────────┘
//            ← 1次纹理切换!

// UV坐标需要根据图集调整
// 物体A的UV: (0,0) → (0.5,0.5)
// 物体B的UV: (0.5,0) → (1,0.5)

2. 实例化渲染(Instanced Rendering)

// 画1000棵树,每棵位置不同
// 传统方式:glDrawArrays 1000次!

// 实例化方式:1次 DrawCall!

// 顶点数据(每棵树)
float positions[1000 * 3];  // 位置
float instancePositions[1000 * 3];  // 每个实例的位置

// 设置实例属性
glVertexAttribDivisor(0, 1);  // 位置属性,每1个实例更新一次
glVertexAttribDivisor(1, 0);  // 顶点属性,每个顶点更新一次

// 一次绘制所有实例
glDrawArraysInstanced(GL_TRIANGLES, 0, vertexCount, 1000);

3. 渲染排序(减少状态切换)

// 按着色器分组渲染
void renderScene() {
    // 1. 渲染所有使用着色器A的物体
    glUseProgram(programA);
    for (auto& obj : objectsWithProgramA) {
        drawObject(obj);
    }
    
    // 2. 渲染所有使用着色器B的物体
    glUseProgram(programB);
    for (auto& obj : objectsWithProgramB) {
        drawObject(obj);
    }
    
    // 3. 渲染所有使用着色器C的物体
    glUseProgram(programC);
    for (auto& obj : objectsWithProgramC) {
        drawObject(obj);
    }
}

// 而不是:
// for (auto& obj : allObjects) {
//     glUseProgram(obj.program);  // 频繁切换!
//     drawObject(obj);
// }

4. 预生成 Mipmap

// 运行时生成 Mipmap(卡顿!)
glBindTexture(GL_TEXTURE_2D, texture);
glGenerateMipmap(GL_TEXTURE_2D);  // 每一帧都生成?太慢!

// 解决方案:离线生成
// 使用 texturetool / etcpack 等工具预生成
// 加载时直接使用
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 
                GL_LINEAR_MIPMAP_LINEAR);

16.4 常见陷阱/面试题

Q1: DrawCall 多少算合理?

  • 移动端:控制在 100 以内
  • PC端:可以更高

Q2: 纹理图集有什么限制?

  • 最大纹理尺寸(GL_MAX_TEXTURE_SIZE)
  • 2D 纹理最大 4096x4096 或 8192x8192

Q3: 实例化渲染有什么限制?

  • 需要 GPU 支持(OpenGL ES 3.0+)
  • 实例数量有限制

Q4: 性能分析工具有哪些?

  • Android: Adreno Profiler, Unity Profiler
  • iOS: Xcode Instruments (GPU Driver)

十七、纹理压缩:移动端如何省内存?

17.1 问题背景:纹理太大内存爆炸

解决什么问题?

一张 1024x1024 的 RGBA 纹理:

  • 内存 = 1024 × 1024 × 4 = 4MB

游戏可能有几十张纹理,瞬间内存爆炸!

移动端内存有限:
┌─────────────────────────────────────┐
│                                     │
│   手机内存: 2GB / 4GB / 6GB         │
│                                     │
│   系统占用: 1GB+                     │
│   应用限制: ~500MB                   │
│                                     │
│   纹理开销: 几十MB就会OOM!          │
│                                     │
└─────────────────────────────────────┘

17.2 设计思路:压缩纹理

设计师如何思考的?

PNG/JPEG 是有损/无损压缩,解压慢,不能被 GPU 直接使用。

我们需要GPU可直接读取的压缩格式

传统纹理:
┌─────────────────────────────────────┐
│  PNG文件 ──► 解压 ──► RGBA数据     │
│  (压缩)      (CPU慢)    (GPU可用)   │
└─────────────────────────────────────┘

压缩纹理:
┌─────────────────────────────────────┐
│  ETC2/ASTC  ──► GPU直接读取         │
│  (压缩)         (硬件解压,快)      │
└─────────────────────────────────────┘

17.3 具体实现:从原理到代码

常见移动端压缩格式

格式压缩比质量支持情况
ETC24:1 ~ 8:1中等OpenGL ES 3.0+
ASTC4:1 ~ 16:1大多数移动设备
ASTC 4x44:1接近无损主流手机

ASTC 格式参数

// ASTC 压缩级别
// blockSize  压缩比    适用场景
// ─────────────────────────────────
// 4x4        4:1      高质量(图标、UI)
// 6x6        2.25:1   中等质量
// 8x8        1:1      低质量(大背景图)
// 10x10      0.64:1   极低质量

加载压缩纹理

// 方法1: 使用工具预压缩
// $ texturetool -e ASTC -p output.png input.png

// 方法2: 代码加载
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

// ASTC 格式
glCompressedTexImage2D(GL_TEXTURE_2D, 0, 
                       GL_COMPRESSED_RGBA_ASTC_4x4_KHR,
                       width, height, 0,
                       dataSize, compressedData);

// 设置参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

// 生成 Mipmap
glGenerateMipmap(GL_TEXTURE_2D);

ETC2 格式

// ETC2 是 OpenGL ES 3.0 的强制要求
glCompressedTexImage2D(GL_TEXTURE_2D, 0,
                       GL_COMPRESSED_RGBA8_ETC2_EAC,
                       width, height, 0,
                       dataSize, compressedData);

17.4 常见陷阱/面试题

Q1: 压缩纹理和非压缩纹理混用?

  • 优先使用压缩纹理
  • 重要 UI 可以用非压缩(质量优先)

Q2: ASTC 不兼容老设备?

  • 检查 GL_EXT_texture_compression_astc_decode_mode
  • 回退到 ETC2/PVRTC

Q3: 压缩纹理加载失败?

  • 检查数据是否对齐(4字节边界)
  • 检查格式是否正确

十八、立方体纹理与天空盒

18.1 问题背景:如何表现天空和环境反射?

解决什么问题?

天空怎么画?环境反射怎么做?

天空盒:
┌─────────────────────────────────────┐
│          ┌─────────┐                │
│        ╱ │         │ ╲              │
│       ╱  │   天空   │  ╲            │
│      ╱   │         │   ╲           │
│     ╱_____________│____╲          │
│                                     │
└─────────────────────────────────────┘

环境映射:
┌─────────────────────────────────────┐
│                                     │
│   金属球 ← 反射周围环境              │
│      ███                           │
│     █   █                          │
│    █     █                         │
│     █   █                          │
│      ███                           │
│                                     │
└─────────────────────────────────────┘

18.2 设计思路:把天空和环境"打包"成纹理

设计师如何思考的?

天空是一个巨大的盒子(立方体),那就把6张图贴在这个盒子内部:

立方体纹理展开:
┌───────────────────────────┐
│      │  上  │            │
│  左  │  前  │  右  后   │
│      │  下  │            │
└───────────────────────────┘

采样方式:
┌───────────────────────────┐
│                           │
│    方向向量 ──► 采样      │
│                           │
│    (0,1,0) → 上方纹理     │
│    (0,-1,0) → 下方纹理   │
│    (1,0,0) → 右方纹理    │
│    ...                    │
│                           │
└───────────────────────────┘

18.3 具体实现:从原理到代码

创建立方体纹理

GLuint cubemap;
glGenTextures(1, &cubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemap);

// 6个面分别设置
const char* faces[] = {
    "right.jpg",   // GL_TEXTURE_CUBE_MAP_POSITIVE_X
    "left.jpg",    // GL_TEXTURE_CUBE_MAP_NEGATIVE_X
    "top.jpg",     // GL_TEXTURE_CUBE_MAP_POSITIVE_Y
    "bottom.jpg",  // GL_TEXTURE_CUBE_MAP_NEGATIVE_Y
    "front.jpg",   // GL_TEXTURE_CUBE_MAP_POSITIVE_Z
    "back.jpg"     // GL_TEXTURE_CUBE_MAP_NEGATIVE_Z
};

for (int i = 0; i < 6; i++) {
    int width, height, channels;
    unsigned char* data = stbi_load(faces[i], &width, &height, &channels, 0);
    
    glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
                 0, GL_RGB, width, height, 0,
                 GL_RGB, GL_UNSIGNED_BYTE, data);
    
    stbi_image_free(data);
}

// 设置过滤参数
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

天空盒渲染

// 顶点着色器
#version 300 es
layout(location = 0) in vec3 a_position;

out vec3 v_texCoord;

uniform mat4 u_view;
uniform mat4 u_projection;

void main() {
    // 移除平移(天空盒跟着相机移动,但不随位置改变)
    mat4 view = mat4(mat3(u_view));
    
    v_texCoord = a_position;
    vec4 pos = u_projection * view * vec4(a_position, 1.0);
    
    // z = w 确保天空盒在最后绘制
    gl_Position = pos.xyww;
}
// 片段着色器
#version 300 es
precision highp float;

in vec3 v_texCoord;
out vec4 fragColor;

uniform samplerCube u_skybox;

void main() {
    fragColor = texture(u_skybox, v_texCoord);
}
// 渲染天空盒
glDepthFunc(GL_LEQUAL);  // 允许深度相等时通过
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemap);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthFunc(GL_LESS);  // 恢复默认

环境反射

// 金属/镜面反射
#version 300 es
precision highp float;

in vec3 v_worldPos;
in vec3 v_normal;

uniform samplerCube u_envMap;
uniform vec3 u_cameraPos;

out vec4 fragColor;

void main() {
    vec3 I = normalize(v_worldPos - u_cameraPos);
    vec3 R = reflect(I, normalize(v_normal));  // 反射向量
    
    vec4 color = texture(u_envMap, R);
    fragColor = color;
}

18.4 常见陷阱/面试题

Q1: 天空盒有缝隙?

  • 使用 CLAMP_TO_EDGE 避免边缘重复
  • 确保6张图尺寸一致

Q2: 天空盒被物体遮挡?

  • 天空盒最后绘制
  • 或者使用 z = w 的技巧

Q3: 反射效果不对?

  • 确保法线正确
  • 确保相机位置正确

十九、Gamma校正:为什么光照看起来不对?

19.1 问题背景:明明参数一样,为什么光照这么暗?

解决什么问题?

你可能遇到过这种情况:

  • 着色器里光照参数都是对的
  • 但画面就是暗得不正常
  • 镜面高光位置也不对

19.2 设计思路:颜色空间不匹配

设计师如何思考的?

问题的根源:显示器显示的不是线性颜色!

物理世界(Linear):
亮度 50% = 物理能量 50%

显示器(sRGB):
显示值 50% = 物理能量 ~22%!

这就是 Gamma 2.2 校正
显示值 = 物理能量 ^ (1/2.2)

光照计算必须在 Linear 空间进行!

19.3 具体实现:从原理到代码

方式1:使用 OpenGL ES 的 sRGB 扩展

// 创建 sRGB 格式的纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height, 0,
             GL_RGBA, GL_UNSIGNED_BYTE, data);

// 渲染时自动进行 Gamma 校正
glEnable(GL_FRAMEBUFFER_SRGB);

方式2:手动在着色器中处理

// 片段着色器
#version 300 es
precision highp float;

uniform sampler2D u_texture;
in vec2 v_texCoord;
out vec4 fragColor;

const float gamma = 2.2;

void main() {
    // 1. 读取纹理(sRGB → Linear)
    vec3 color = texture(u_texture, v_texCoord).rgb;
    color = pow(color, vec3(gamma));

    // 2. 在 Linear 空间进行光照计算
    // ... 正常的光照计算 ...

    // 3. 输出前转换回 sRGB(Linear → sRGB)
    result = pow(result, vec3(1.0 / gamma));

    fragColor = vec4(result, 1.0);
}

19.4 常见陷阱/面试题

Q1: 纹理是 sRGB 还是 Linear?

  • 普通图片(照片、UI)通常是 sRGB
  • 法线贴图、高度图是 Linear
  • 金属度/粗糙度贴图通常是 Linear

Q2: 镜面反射高光位置不对?

  • 检查是否在 Linear 空间计算

二十、法线贴图:如何让低模看起来像高模?

20.1 问题背景:模型面数不够,细节不足

解决什么问题?

游戏里不可能用几百万面的模型,这时候需要用贴图来模拟细节

20.2 设计思路:用颜色存储法线方向

设计师如何思考的?

既然不能增加几何细节,那就用纹理来存储"表面凹凸"信息

法线贴图: 存储法线方向 (RGB)
RGB → XYZ 映射:
R: -1 ~ 1  (X轴方向)
G: -1 ~ 1  (Y轴方向)
B:  0 ~ 1  (Z轴,朝向观察者)

蓝色 = 表面朝向没变
紫色 = 表面有倾斜

20.3 具体实现:从原理到代码

1. 顶点着色器计算 TBN 矩阵

#version 300 es
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texCoord;
layout(location = 3) in vec3 a_tangent;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_fragPos;
out vec2 v_texCoord;
out mat3 v_TBN;

void main() {
    vec4 worldPos = u_model * vec4(a_position, 1.0);
    v_fragPos = worldPos.xyz;
    v_texCoord = a_texCoord;

    vec3 T = normalize(vec3(u_model * vec4(a_tangent, 0.0)));
    vec3 N = normalize(vec3(u_model * vec4(a_normal, 0.0)));
    T = normalize(T - dot(T, N) * N);
    vec3 B = cross(N, T);
    v_TBN = mat3(T, B, N);

    gl_Position = u_projection * u_view * worldPos;
}

2. 片段着色器中使用法线贴图

#version 300 es
precision highp float;

in vec3 v_fragPos;
in vec2 v_texCoord;
in mat3 v_TBN;

uniform sampler2D u_normalMap;
uniform sampler2D u_diffuseMap;
uniform vec3 u_lightPos;

out vec4 fragColor;

void main() {
    // 从法线贴图读取法线(切线空间)
    vec3 normal = texture(u_normalMap, v_texCoord).rgb;
    normal = normal * 2.0 - 1.0;
    normal = normalize(v_TBN * normal);

    // 光照计算
    vec3 color = texture(u_diffuseMap, v_texCoord).rgb;
    vec3 lightDir = normalize(u_lightPos - v_fragPos);
    float diff = max(dot(normal, lightDir), 0.0);

    fragColor = vec4(diff * color, 1.0);
}

20.4 常见陷阱/面试题

Q1: 法线贴图是紫色的?

  • 正常!蓝色表示法线朝上(0,0,1)

Q2: 凹凸效果反了?

  • 检查法线方向和 TBN 矩阵计算

二十一、延迟渲染:复杂光照怎么优化?

21.1 问题背景:几十个光源怎么画?

解决什么问题?

场景里有几十个光源,**前向渲染(Forward Rendering)**会爆炸:

场景: 100个物体 + 10个光源
前向渲染: DrawCall = 1000 次!← 爆炸!

21.2 设计思路:把"画什么"和"怎么画"分开

设计师如何思考的?

与其每个光源画一遍物体,不如先把所有物体的几何信息存起来,再统一处理光照

延迟渲染核心思想:
第一遍(G-Buffer): 不画光照,只存几何信息
  - 位置纹理 (Position)
  - 法线纹理 (Normal)
  - 颜色纹理 (Albedo)
  - 金属度/粗糙度

第二遍(光照计算): 读取G-Buffer,计算光照
DrawCall = 1次(画全屏四边形)+ N次光源计算

21.3 具体实现:从原理到代码

1. 创建 G-Buffer

struct GBuffer {
    GLuint fbo;
    GLuint positionTex;   // RGBA16F
    GLuint normalTex;     // RGBA16F
    GLuint albedoTex;     // RGBA8
    GLuint materialTex;   // RG
    GLuint depthRBO;
};

void createGBuffer(GBuffer& gb, int width, int height) {
    glGenFramebuffers(1, &gb.fbo);
    glBindFramebuffer(GL_FRAMEBUFFER, gb.fbo);

    // 位置纹理
    glGenTextures(1, &gb.positionTex);
    glBindTexture(GL_TEXTURE_2D, gb.positionTex);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0,
                 GL_RGBA, GL_FLOAT, NULL);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                           GL_TEXTURE_2D, gb.positionTex, 0);

    // 法线、颜色、材质纹理类似...

    // 设置 Draw Buffers
    GLenum attachments[] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1,
                            GL_COLOR_ATTACHMENT2, GL_COLOR_ATTACHMENT3};
    glDrawBuffers(4, attachments);
}

2. 渲染流程

void renderDeferred(Scene& scene) {
    // 第一遍:几何通道
    glBindFramebuffer(GL_FRAMEBUFFER, gBuffer.fbo);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    useGeometryPassShader();
    for (auto& object : scene.objects) {
        drawObject(object);
    }

    // 第二遍:光照通道
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    glClear(GL_COLOR_BUFFER_BIT);

    useLightingPassShader();
    // 绑定 G-Buffer 纹理...
    drawFullscreenQuad();
}

21.4 常见陷阱/面试题

Q1: 延迟渲染有什么限制?

  • 不支持半透明物体(需要单独用前向渲染)
  • 带宽压力大(G-Buffer 很大)

Q2: 移动端能用吗?

  • 移动端带宽有限,不推荐延迟渲染
  • 移动端更推荐前向渲染+光照桶

二十二、PBR光照模型:现代游戏怎么做光照?

22.1 问题背景:传统光照模型太假了

解决什么问题?

传统 Blinn-Phong 光照看起来很"假",不同材质看起来都差不多。

22.2 设计思路:基于物理的渲染

设计师如何思考的?

与其手动调参数,不如模拟真实物理

PBR 核心思想:
1. 能量守恒: 反射 + 折射 = 1
2. 菲涅尔效应: 视角越平行于表面,反射越强
3. 几何项: 表面微几何体遮挡自己

PBR 材质参数

  • 基础色 (Albedo): 表面反射的基础颜色
  • 金属度 (Metallic): 0=非金属, 1=纯金属
  • 粗糙度 (Roughness): 0=镜面光滑, 1=完全粗糙

22.3 具体实现:从原理到代码

#version 300 es
precision highp float;

in vec3 v_fragPos;
in vec3 v_normal;
in vec2 v_texCoord;

uniform vec3 u_albedo;
uniform float u_metallic;
uniform float u_roughness;
uniform vec3 u_cameraPos;
uniform vec3 u_lightPositions[4];
uniform vec3 u_lightColors[4];

out vec4 fragColor;

const float PI = 3.14159265359;

// 法线分布函数 (GGX)
float distributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    return a2 / (PI * denom * denom);
}

// 几何遮蔽函数 (Schlick-GGX)
float geometrySchlickGGX(float NdotV, float roughness) {
    float r = (roughness + 1.0);
    float k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
    return geometrySchlickGGX(max(dot(N, V), 0.0), roughness) *
           geometrySchlickGGX(max(dot(N, L), 0.0), roughness);
}

// 菲涅尔方程 (Schlick近似)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

void main() {
    vec3 N = normalize(v_normal);
    vec3 V = normalize(u_cameraPos - v_fragPos);

    vec3 F0 = vec3(0.04);
    F0 = mix(F0, u_albedo, u_metallic);

    vec3 Lo = vec3(0.0);

    for (int i = 0; i < 4; i++) {
        vec3 L = normalize(u_lightPositions[i] - v_fragPos);
        vec3 H = normalize(V + L);

        float distance = length(u_lightPositions[i] - v_fragPos);
        float attenuation = 1.0 / (distance * distance);
        vec3 radiance = u_lightColors[i] * attenuation;

        float NDF = distributionGGX(N, H, u_roughness);
        float G = geometrySmith(N, V, L, u_roughness);
        vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

        vec3 numerator = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
        vec3 specular = numerator / denominator;

        vec3 kS = F;
        vec3 kD = (vec3(1.0) - kS) * (1.0 - u_metallic);

        float NdotL = max(dot(N, L), 0.0);
        Lo += (kD * u_albedo / PI + specular) * radiance * NdotL;
    }

    vec3 ambient = vec3(0.03) * u_albedo;
    vec3 color = ambient + Lo;

    // Gamma 校正
    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0 / 2.2));

    fragColor = vec4(color, 1.0);
}

22.4 常见陷阱/面试题

Q1: 金属度是1但颜色是黑的?

  • 金属的 albedo 应该是彩色(铜、金、银)

Q2: 粗糙度贴图黑色部分太亮?

  • 黑色 = 光滑 = 强反射

二十三、移动端优化:手机上的特殊挑战

23.1 问题背景:手机和PC不一样

解决什么问题?

同样的代码,在PC上跑60fps,在手机上可能只有15fps!

移动端 vs PC端:
- 移动端功耗限制严格(发热降频)
- 移动端内存带宽有限(显存和内存共用)
- 移动端GPU规模小(流处理器少)
- 移动端分辨率可能很高(2K/3K屏幕)

23.2 设计思路:移动端特有的优化策略

设计师如何思考的?

移动端优化核心原则:
1. 减少数据传输 ← 带宽是瓶颈
2. 减少计算量 ← GPU规模小
3. 适应硬件特性 ← 了解你的GPU
4. 帧率稳定优先 ← 避免卡顿

23.3 具体实现:从原理到代码

1. 动态分辨率

float frameTime = getFrameTime();
if (frameTime > 16.67f * 1.2f) {
    currentScale *= 0.9f;  // 降低10%
}
glViewport(0, 0, screenWidth * currentScale, screenHeight * currentScale);

2. 减少过度绘制

// 先画不透明物体(从远到近),利用深度测试丢弃被遮挡的像素
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glDisable(GL_BLEND);

// 再画透明物体(从近到远)
glEnable(GL_BLEND);

3. 内存优化 - 对象池

class ObjectPool {
    std::vector<Mesh*> available;
public:
    Mesh* allocate() {
        if (!available.empty()) {
            Mesh* obj = available.back();
            available.pop_back();
            return obj;
        }
        return new Mesh();
    }
    void release(Mesh* obj) {
        available.push_back(obj);  // 归还,不是 delete
    }
};

4. 着色器优化技巧

// ❌ 避免条件分支
// if (u_useNormalMap > 0.5) { ... }

// ✅ 用 mix 代替分支
float useNormal = step(0.5, u_useNormalMap);
normal = mix(baseNormal, texture(u_normalMap, uv).xyz, useNormal);

// ✅ 循环次数固定
for (int i = 0; i < 4; i++) {
    if (i < u_numLights) { ... }
}

5. 纹理压缩

// ETC2(OpenGL ES 3.0 强制支持)
glCompressedTexImage2D(GL_TEXTURE_2D, 0,
                       GL_COMPRESSED_RGBA8_ETC2_EAC,
                       width, height, 0, dataSize, data);

// ASTC(更高压缩比)
glCompressedTexImage2D(GL_TEXTURE_2D, 0,
                       GL_COMPRESSED_RGBA_ASTC_4x4_KHR,
                       width, height, 0, dataSize, astcData);

23.4 常见陷阱/面试题

Q1: 为什么在手机上帧率不稳定?

  • 检查是否有 GC(垃圾回收)触发
  • 检查是否有过热降频

Q2: 移动端适合用延迟渲染吗?

  • 不推荐!移动端带宽有限

Q3: 如何检测移动端GPU型号?

const GLubyte* vendor = glGetString(GL_VENDOR);
const GLubyte* renderer = glGetString(GL_RENDERER);
// 高通: "Adreno (TM)"
// ARM: "Mali-"
// PowerVR: "PowerVR"

总结:核心概念一览

┌─────────────────────────────────────────────────────────────────────┐
                        OpenGL ES 核心概念                            
├─────────────────────────────────────────────────────────────────────┤
                                                                     
  1. 客户端-服务端模型                                               
     - 代码运行在CPU,渲染运行GPU                                    
     - 命令是异步执行的                                              
                                                                     
  2. 状态机                                                          
     - OpenGL是一个巨大的状态机                                      
     - Bind操作进入状态,解绑退出状态                                
                                                                     
  3. 渲染管线                                                         
     顶点着色器  光栅化  片段着色器  逐片段操作  帧缓冲          
                                                                     
  4. 着色器                                                           
     - 顶点着色器: 处理顶点,坐标变换                                 
     - 片段着色器: 处理像素,计算颜色                                 
     - attribute: 顶点属性(每个顶点不同)                           
     - uniform: 统一变量(所有顶点相同)                             
     - varying: 插值(顶点→片段)                                    
                                                                     
  5. 纹理                                                             
     - UV坐标: 0~1,映射到纹理图片                                    
     - MipMap: 多级渐远纹理,优化远距离显示                          
     - 过滤模式: NEAREST(像素风) / LINEAR(平滑)                     
     - 压缩格式: ETC2/ASTC                                           
                                                                     
  6. 缓冲区                                                           
     - VBO: 预存顶点数据到GPU显存                                    
     - VAO: 保存顶点配置状态                                         
     - EBO: 索引复用顶点                                             
     - FBO: 离屏渲染到纹理                                           
                                                                     
  7. 同步机制                                                         
     - glFlush: 发送命令,不等待                                     
     - glFinish: 等待完成(阻塞)                                    
     - Fence: 跨线程同步                                             
     - 共享Context: 多线程共享资源                                   
                                                                     
  8. 测试                                                             
     - 深度测试: Z-Buffer,解决遮挡                                  
     - 模板测试: 遮罩/轮廓效果                                       
     - 混合: 透明效果                                                
                                                                     
  9. 坐标系统                                                         
     模型空间  世界空间  观察空间  裁剪空间  屏幕空间            
                                                                     
  10. 光照模型                                                        
     环境光 + 漫反射 + 镜面反射 = 最终颜色                           
                                                                     
  11. EGL                                                             
     显示设备  表面  上下文  绑定到屏幕                          
                                                                     
  12. 抗锯齿                                                          
     MSAA: 硬件多重采样                                              
     FXAA: 后处理                                                    
                                                                     
  13. 阴影                                                            
     Shadow Map: 两遍渲染                                            
                                                                     
  14. 性能优化                                                        
     纹理图集 + 实例化渲染 + 渲染排序                                
                                                                     
  15. 纹理压缩                                                        
     ETC2/ASTC: 移动端压缩纹理                                       
                                                                     
  16. 立方体纹理                                                      
     天空盒 + 环境映射                                               
                                                                     
└─────────────────────────────────────────────────────────────────────┘