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; // 写入显存
}
}
}
}
问题在哪里?
- 计算量大:1920x1080 = 207万个像素,每个都要判断
- 无法并行:CPU是串行执行的,一个一个算
- 浪费资源:CPU擅长逻辑控制,却让它做简单重复的数学计算
1.2 GPU是怎么解决问题的?
GPU的思路完全不一样:我有几千个小核心,让我同时算!
CPU:1个大厨炒100道菜 vs GPU:100个小厨同时炒100道菜
(串行) (并行)
厨神1人 小厨1 小厨2 小厨3 ... 小厨100
████████ █ █ █ █ █ █ █ █ █
炒完一道 100道菜同时出锅
再炒下一道
GPU的核心优势:
- 海量小核心:手机上也有几十到几百个GPU核心
- 并行执行:每个核心独立工作,互不干扰
- 专用优化:专门为图形计算优化,加法乘法特别快
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 只能画到"默认帧缓冲"(屏幕)
┌─────────────────────────────────────┐
│ 手机屏幕 │
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ 只能画到这里! │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ 默认帧缓冲 │
└─────────────────────────────────────┘
局限性:
- 画完就不能修改(已经显示到屏幕了)
- 无法做后期处理(滤镜、特效)
- 无法截图
- 无法做阴影图
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);
两种等待方式的区别:
| 方式 | 阻塞谁 | 适用场景 |
|---|---|---|
| glClientWaitSync | CPU阻塞等待 | 需要CPU知道完成时机,读取结果 |
| glWaitSync | GPU等待,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│ 蓝色
│ └───────┘
└─────────────┘
问题:B和A重叠的部分,应该显示红色还是蓝色?
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 就是这个"桥梁",它提供统一的接口来:
- 获取显示设备(屏幕)
- 创建绘图表面(Surface)
- 创建渲染上下文(Context)
- 绑定 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 | 绘图表面(窗口或离屏缓冲) |
| EGLContext | OpenGL 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 具体实现:从原理到代码
常见移动端压缩格式:
| 格式 | 压缩比 | 质量 | 支持情况 |
|---|---|---|---|
| ETC2 | 4:1 ~ 8:1 | 中等 | OpenGL ES 3.0+ |
| ASTC | 4:1 ~ 16:1 | 高 | 大多数移动设备 |
| ASTC 4x4 | 4: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. 立方体纹理 │
│ 天空盒 + 环境映射 │
│ │
└─────────────────────────────────────────────────────────────────────┘