前言
由于网络上有关于Minecraft渲染系统的相关文档实在是太少,相关的渲染系统介绍要么年久失修(停留在1.12+左右),要么内容不全对于初学者不友好,故笔者结合自己的开发经验,整理OpenGL中的基本知识与MC渲染管线的诸多内容,以尽可能通俗易懂的方式讲解,本系列笔记共三章,供笔者自己和社区开发者参考
注意:笔者自己也在学习,故不一定保证是正确的
1.1 GPU 与图形渲染基础
本节目标:理解为什么需要 GPU,以及图形渲染的基本概念
1.1.1 什么是渲染?
渲染(Rendering) 就是把数据变成图像的过程。
想象一下,你有一份建筑图纸(数据),渲染就是根据这份图纸画出一张逼真的效果图(图像)。在游戏中:
- 数据:方块的位置、玩家的坐标、怪物的模型、纹理贴图...
- 图像:你在屏幕上看到的每一帧画面
每秒钟,游戏需要渲染 60 帧甚至更多的画面,每一帧都要处理成千上万个三角形、计算光照、贴上纹理。这个工作量非常大。
1.1.2 为什么需要 GPU?
CPU vs GPU:两种不同的思维方式
CPU 就像一个超级聪明的数学家:
- 擅长复杂的逻辑运算
- 一次只能做一件事,但做得很精细
- 核心数量少(4-16个),但每个核心都很强大
GPU 就像一个巨大的计算工厂:
- 擅长简单但重复的计算
- 一次能做成千上万件相同的事
- 核心数量多(数千个),但每个核心相对简单
一个形象的比喻
假设你要给 1000 张照片调整亮度:
| 方式 | 比喻 | 效率 |
|---|---|---|
| CPU | 一个专家,一张一张处理,每张都仔细调整 | 慢,但灵活 |
| GPU | 1000 个工人,每人处理一张,同时进行 | 快,但只能做简单操作 |
渲染正是这种"简单但重复"的任务:
- 屏幕上有 1920×1080 = 2,073,600 个像素
- 每个像素都需要计算颜色
- 每个像素的计算方式几乎相同
这正是 GPU 的强项!
1.1.3 显存(VRAM)
GPU 有自己的内存,叫做 显存(Video RAM / VRAM)。
为什么需要显存?
数据在 CPU 和 GPU 之间传输是很慢的(相对于计算速度)。如果每次渲染都要从内存传数据到 GPU,效率会很低。
所以我们把渲染需要的数据提前存到显存里:
- 纹理贴图
- 3D 模型的顶点数据
- 着色器程序
显存 vs 内存
┌─────────────────────────────────────────────────────────┐
│ 你的电脑 │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ CPU │ │ GPU │ │
│ │ (大脑) │◄────────────►│ (渲染工厂) │ │
│ └──────┬──────┘ PCIe总线 └──────────┬──────────┘ │
│ │ (较慢) │ │
│ ┌──────▼──────┐ ┌──────────▼──────────┐ │
│ │ 内存 │ │ 显存 │ │
│ │ (RAM) │ │ (VRAM) │ │
│ │ 游戏逻辑 │ │ 纹理、模型、着色器 │ │
│ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
1.1.4 OpenGL 是什么?
OpenGL(Open Graphics Library) 是一套图形 API(应用程序接口)。
简单说,它是你的程序和 GPU 之间的"翻译官":
plaintext
你的代码 ──► OpenGL API ──► 显卡驱动 ──► GPU 硬件
为什么需要 OpenGL?
不同厂商的显卡(NVIDIA、AMD、Intel)硬件都不一样。如果你直接和硬件打交道,就要为每种显卡写不同的代码。
OpenGL 提供了统一的接口:
- 你只需要调用
glDrawArrays()这样的函数 - OpenGL 和显卡驱动会帮你翻译成具体硬件能理解的指令
OpenGL 的替代品
| API | 平台 | 特点 |
|---|---|---|
| OpenGL | 跨平台 | 历史悠久,Minecraft 使用 |
| Vulkan | 跨平台 | 更底层,性能更好,但更复杂 |
| DirectX | Windows | 微软专属,游戏常用 |
| Metal | macOS/iOS | 苹果专属 |
Minecraft Java 版使用 OpenGL(通过 LWJGL,即Light Weight Java Game Library 库)。
1.1.5 渲染的基本单位:三角形
在 GPU 的世界里,一切都是三角形。
为什么是三角形?
- 三角形永远是平面 - 三个点确定一个平面
- 三角形永远是凸的 - 不会有凹进去的部分
- 任何形状都能用三角形拼出来 - 包括圆形(用很多小三角形近似)
plaintext
一个正方形 = 2 个三角形
┌─────────┐ ┌─────────┐
│ │ │ ╲ │
│ │ = │ ╲ │ + 剩下的三角形
│ │ │ ╲ │
└─────────┘ └───────╲─┘
Minecraft 中的例子
一个方块有 6 个面,每个面是一个正方形:
- 6 个面 × 2 个三角形/面 = 12 个三角形
一个区块(16×16×256)如果全是方块:
- 16 × 16 × 256 × 12 = 786,432 个三角形
当然,实际渲染时MC会使用剔除技术优化掉看不见的面。
1.1.6 帧与帧率
什么是帧(Frame)?
一帧就是一张静态图像。游戏通过快速连续显示多帧图像,产生动画效果。
帧率(FPS - Frames Per Second)
每秒显示多少帧:
- 30 FPS - 勉强流畅
- 60 FPS - 流畅
- 144 FPS - 非常流畅(当然,你需要高刷新率的显示器)
一帧的生命周期
plaintext
┌──────────────────────────────────────────────────────────┐
│ 一帧的渲染过程 │
├──────────────────────────────────────────────────────────┤
│ 1. 游戏逻辑更新(CPU) │
│ - 玩家移动 │
│ - 怪物 AI │
│ - 物理计算 │
├──────────────────────────────────────────────────────────┤
│ 2. 准备渲染数据(CPU) │
│ - 确定哪些东西要渲染 │
│ - 准备顶点数据 │
│ - 设置渲染状态 │
├──────────────────────────────────────────────────────────┤
│ 3. GPU 渲染 │
│ - 顶点处理 │
│ - 光栅化 │
│ - 片段处理 │
│ - 输出到帧缓冲 │
├──────────────────────────────────────────────────────────┤
│ 4. 显示 │
│ - 帧缓冲内容显示到屏幕 │
└──────────────────────────────────────────────────────────┘
小结
| 概念 | 一句话解释 |
|---|---|
| 渲染 | 把数据变成图像 |
| GPU | 专门做渲染的并行计算芯片 |
| 显存 | GPU 专用的内存,存放纹理和模型 |
| OpenGL | 程序和 GPU 之间的标准接口 |
| 三角形 | GPU 渲染的基本单位 |
| 帧 | 一张静态图像,连续播放产生动画 |
1.2 渲染管线详解
本节目标:理解 GPU 如何一步步把顶点数据变成屏幕上的像素
1.2.1 什么是渲染管线?
渲染管线(Rendering Pipeline) 是 GPU 处理图形数据的流水线。
想象一个汽车工厂的流水线:
- 原材料进入 → 经过多个工位加工 → 成品汽车出来
- 每个工位只做一件事,做完就传给下一个工位
渲染管线也是这样:
- 顶点数据进入 → 经过多个阶段处理 → 像素颜色出来
plaintext
┌─────────────────────────────────────────────────────────────────┐
│ 渲染管线概览 │
│ │
│ 顶点数据 ──► [顶点着色器] ──► [图元装配] ──► [光栅化] │
│ │ │
│ ▼ │
│ 屏幕像素 ◄── [帧缓冲] ◄── [测试与混合] ◄── [片段着色器] │
│ │
└─────────────────────────────────────────────────────────────────┘
当然以上是光栅化的计算过程,如果你使用光追这里的过程是不一样的,这里不再涉及(因为笔者也不懂)
1.2.2 管线阶段详解
阶段 1:顶点输入(Vertex Input)
这是管线的入口,你需要告诉 GPU:
- 顶点数据:每个顶点的位置、颜色、纹理坐标等
- 顶点格式:数据是怎么排列的
示例:一个三角形的顶点数据
顶点0: 位置(-0.5, -0.5, 0.0), 颜色(1.0, 0.0, 0.0), UV(0.0, 0.0)
顶点1: 位置( 0.5, -0.5, 0.0), 颜色(0.0, 1.0, 0.0), UV(1.0, 0.0)
顶点2: 位置( 0.0, 0.5, 0.0), 颜色(0.0, 0.0, 1.0), UV(0.5, 1.0)
阶段 2:顶点着色器(Vertex Shader)
顶点着色器是你可以编写代码控制的阶段。
它对每个顶点执行一次,主要任务:
- 坐标变换:把顶点从模型空间变换到屏幕空间(也叫做视口变换)
- 传递数据:把颜色、UV 等数据传给后续阶段
下面我们以GLSL Shader代码来实操一下(Version 150,OpenGL 3.2)
glsl
// 一个简单的顶点着色器
#version 150
in vec3 Position; // 输入:顶点位置(x,y,z)向量
in vec4 Color; // 输入:顶点颜色(r,g,b,a)向量
in vec2 UV0; // 输入:纹理坐标(u,v)向量
uniform mat4 ModelViewMat; // 模型视图矩阵
uniform mat4 ProjMat; // 投影矩阵
out vec4 vertexColor; // 输出:传给片段着色器
out vec2 texCoord; // 输出:传给片段着色器
void main() {
// 坐标变换:模型空间 → 裁剪空间(按照矩阵乘法,应从右往左读
// 这里给向量扩充一列是为了变为齐次坐标,因为变换过程中需要进行平移操作
// 而平移并不属于线性变换(它属于仿射变换),因此出现了齐次坐标系,多增广一行(列)
// 以处理平移变换(在低纬度的平移仿射变换等价于高维度的线性变换)
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
// 传递数据
vertexColor = Color;
texCoord = UV0;
}
这就是我们在Shader里经常看到的那些.vsh文件里写的内容
坐标空间变换
顶点要经过多次坐标变换才能到达屏幕:
plaintext
模型系 世界系 观察系 裁剪系 屏幕系
(Local) ──► (World) ──► (View) ──► (Clip) ──► (Screen)
Model矩阵 View矩阵 Projection矩阵 视口变换
┌───┐
│ ▲ │ 模型空间:模型自己的坐标系,原点在模型中心
└───┘
▼ Model 矩阵(平移、旋转、缩放)
┌─────────────────┐
│ ▲ │ 世界空间:游戏世界的坐标系
│ ● │ 所有物体都在同一个坐标系中
└─────────────────┘
▼ View 矩阵(相机位置和朝向)
┌─────────────────┐
│ ▲ ● │ 观察空间:以相机为原点
│ /│\ │ 相机看向 -Z 方向
│ / │ \ │
└─────────────────┘
▼ Projection 矩阵(透视或正交)
┌─────────────────┐
│ ┌───────────┐ │ 裁剪空间:标准化的立方体
│ │ ▲ ● │ │ 坐标范围 [-1, 1]
│ └───────────┘ │
└─────────────────┘
▼ 视口变换(GPU 自动完成)
┌─────────────────┐
│ ▲ ● │ 屏幕空间:像素坐标
│ │ (0,0) 到 (width, height)
└─────────────────┘
阶段 3:图元装配(Primitive Assembly)
把顶点组装成图元(Primitive),也就是基本形状:
| 图元类型 | 说明 | 顶点数 |
|---|---|---|
| 点 (Point) | 单个点 | 1 |
| 线 (Line) | 线段 | 2 |
| 三角形 (Triangle) | 最常用 | 3 |
plaintext
顶点数据:v0, v1, v2, v3, v4, v5
三角形模式 (TRIANGLES):
三角形1: v0, v1, v2
三角形2: v3, v4, v5
三角形带模式 (TRIANGLE_STRIP):
三角形1: v0, v1, v2
三角形2: v1, v2, v3 (复用顶点)
三角形3: v2, v3, v4
...
阶段 4:光栅化(Rasterization)
光栅化是把几何图形转换成像素的过程。
plaintext
三角形(矢量) 像素(光栅)
▲ □ □ ■ □ □
/█\ □ ■ ■ ■ □
/███\ ──► ■ ■ ■ ■ ■
/█████\ ■ ■ ■ ■ ■
▔▔▔▔▔▔▔▔▔ ■ ■ ■ ■ ■
光栅化还会进行插值(Interpolation):
plaintext
顶点着色器输出:
顶点A: 颜色 = 红色 (1,0,0)
顶点B: 颜色 = 绿色 (0,1,0)
顶点C: 颜色 = 蓝色 (0,0,1)
光栅化插值后:
三角形中间的像素会得到混合的颜色
比如 AB 中点的颜色 ≈ 黄色 (0.5, 0.5, 0)
这就是为什么你能看到渐变色的三角形
阶段 5:片段着色器(Fragment Shader)
片段着色器对每个片段(潜在像素)执行一次。
主要任务:计算这个像素的最终颜色。
glsl
// 一个简单的片段着色器
#version 150
in vec4 vertexColor; // 从顶点着色器传来的颜色(已插值)
in vec2 texCoord; // 从顶点着色器传来的 UV(已插值)
uniform sampler2D Sampler0; // 纹理采样器
out vec4 fragColor; // 输出:最终颜色
void main() {
// 采样纹理
vec4 texColor = texture(Sampler0, texCoord);
// 纹理颜色 × 顶点颜色
fragColor = texColor * vertexColor;
}
这就是我们在Shader里经常看到的那些.fsh文件里写的内容
片段 vs 像素
- 片段(Fragment):光栅化产生的潜在像素,还没确定是否会显示
- 像素(Pixel):最终显示在屏幕上的点
一个像素位置可能有多个片段(比如半透明物体重叠),需要后续阶段决定最终颜色。
阶段 6:测试与混合(Tests & Blending)
在片段写入帧缓冲之前,要经过一系列测试:
深度测试(Depth Test)
决定"谁在前面":
plaintext
场景中有两个物体,A 在前,B 在后
没有深度测试: 有深度测试:
后画的会覆盖先画的 近的会覆盖远的
┌─────┐ ┌─────┐
│ B │ ← B 后画 │ A │ ← A 更近
│ ┌───┼──┐ │ ┌───┼──┐
│ │ A │ │ │ │ │ │
└─┼───┘ │ └─┼───┘ │
│ B │ │ B │
└──────┘ └──────┘
模板测试(Stencil Test)
用于特殊效果,比如:
- 镜子反射(只在镜子区域内渲染)
- 阴影体积
- 轮廓描边
混合(Blending)
处理半透明物体:
plaintext
混合公式:
最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子
常见混合模式:
- 普通透明:src × srcAlpha + dst × (1 - srcAlpha)
- 加法混合:src × 1 + dst × 1 (发光效果)
- 乘法混合:src × dst (阴影效果)
阶段 7:帧缓冲输出(Framebuffer Output)
最终颜色写入帧缓冲(Framebuffer)。
帧缓冲就是一块内存,存储着屏幕上每个像素的颜色。当一帧渲染完成后,帧缓冲的内容会显示到屏幕上。
1.2.3 完整流程图
plaintext
┌─────────────────────────────────────────────────────────────────────────┐
│ 渲染管线完整流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 顶点数据 │ 位置、颜色、UV、法线... │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 顶点着色器 │ 可编程 │
│ │ │ · 坐标变换 (MVP) │
│ │ │ · 传递属性给片段着色器 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 图元装配 │ 把顶点组装成三角形 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 光栅化 │ 三角形 → 片段 │
│ │ │ · 确定哪些像素被覆盖 │
│ │ │ · 插值顶点属性 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 片段着色器 │ 可编程 │
│ │ │ · 计算每个片段的颜色 │
│ │ │ · 纹理采样、光照计算 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 测试与混合 │ │
│ │ │ · 深度测试:谁在前面? │
│ │ │ · 模板测试:特殊区域 │
│ │ │ · 混合:半透明处理 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 帧缓冲 │ 最终图像存储在这里 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 屏幕 │ 显示给玩家看 │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
1.2.4 可编程 vs 固定功能
| 阶段 | 类型 | 说明 |
|---|---|---|
| 顶点输入 | 固定 | 你只能提供数据,不能改变处理方式 |
| 顶点着色器 | 可编程 | 你可以写Shader控制 |
| 图元装配 | 固定 | GPU 自动完成 |
| 光栅化 | 固定 | GPU 自动完成 |
| 片段着色器 | 可编程 | 你可以写Shader控制 |
| 测试与混合 | 可配置 | 你可以设置参数,但不能写代码 |
| 帧缓冲输出 | 固定 | GPU 自动完成 |
现代 GPU 还有其他可编程阶段(几何着色器、曲面细分着色器、计算着色器),但 Minecraft 主要使用顶点和片段着色器。
1.2.5 并行处理
GPU 的强大之处在于并行:
plaintext
CPU 方式(串行):
顶点1 → 顶点2 → 顶点3 → ... → 顶点1000
[====================================================] 很慢
GPU 方式(并行):
顶点1 ─┐
顶点2 ─┼─► 同时处理
顶点3 ─┤
... ─┤
顶点1000─┘
[====] 很快
同样,片段着色器也是并行的:
- 1920×1080 分辨率 = 2,073,600 个像素
- GPU 可以同时处理成千上万个像素
小结
| 阶段 | 输入 | 输出 | 可编程? |
|---|---|---|---|
| 顶点输入 | 顶点数据 | 顶点属性 | ✗ |
| 顶点着色器 | 顶点属性 | 变换后的顶点 | ✓ |
| 图元装配 | 顶点 | 三角形 | ✗ |
| 光栅化 | 三角形 | 片段 | ✗ |
| 片段着色器 | 片段 | 颜色 | ✓ |
| 测试与混合 | 颜色 | 最终颜色 | 可配置 |
| 帧缓冲 | 最终颜色 | 图像 | ✗ |
1.3 顶点与顶点属性
本节目标:理解顶点数据的组织方式,以及 VBO、VAO 的作用
1.3.1 什么是顶点?
顶点(Vertex) 不仅仅是一个点的位置,它是一个数据包,包含了渲染这个点所需的所有信息。
plaintext
一个顶点可能包含:
┌─────────────────────────────────────────────────┐
│ 位置 (Position) : (1.0, 2.0, 3.0) │ 在哪里?
│ 颜色 (Color) : (1.0, 0.0, 0.0, 1.0) │ 什么颜色?
│ 纹理坐标 (UV) : (0.5, 0.5) │ 贴图的哪个位置?
│ 法线 (Normal) : (0.0, 1.0, 0.0) │ 朝向哪里?(用于光照)
│ 光照坐标 (UV2) : (240, 240) │ 光照强度?
└─────────────────────────────────────────────────┘
1.3.2 顶点属性(Vertex Attributes)
每种数据都叫做一个顶点属性。常见的属性:
| 属性 | 类型 | 分量数 | 用途 |
|---|---|---|---|
| Position | float | 3 (x,y,z) | 顶点在空间中的位置 |
| Color | float/ubyte | 4 (r,g,b,a) | 顶点颜色 |
| UV0 (TexCoord) | float | 2 (u,v) | 主纹理坐标 |
| UV1 (Overlay) | short | 2 | 覆盖层坐标(比如史蒂夫受伤变红) |
| UV2 (Lightmap) | short | 2 | 光照贴图坐标 |
| Normal | byte/float | 3 (x,y,z) | 法线方向(光照计算) |
纹理坐标 (UV) 详解
UV 坐标告诉 GPU 这个顶点对应纹理图片的哪个位置:
plaintext
纹理图片 3D 模型上的面
(0,0)────────(1,0)
│ │ 使用 UV 坐标映射
│ 图片 │ ──► 纹理贴到模型上
│ │
(0,1)────────(1,1)
UV 坐标范围通常是 0.0 到 1.0
(0,0) = 左上角
(1,1) = 右下角
光照坐标 (UV2 / Lightmap)
Minecraft 使用光照贴图来计算光照:
plaintext
UV2 = (blockLight, skyLight)
blockLight: 方块光源(火把、熔岩等)0-15 → 0-240
skyLight: 天空光(太阳、月亮)0-15 → 0-240
采样光照贴图得到最终亮度
1.3.3 顶点数据的内存布局
顶点数据在内存中是连续存储的。有两种常见布局:
交错布局(Interleaved)- 最常用
所有属性交替存储:
plaintext
内存布局:
[位置0][颜色0][UV0] [位置1][颜色1][UV1] [位置2][颜色2][UV2] ...
|←── 顶点0 ──→| |←── 顶点1 ──→| |←── 顶点2 ──→|
优点:缓存友好,访问一个顶点的所有数据时内存连续
分离布局(Separate)
同类属性连续存储:
plaintext
内存布局:
[位置0][位置1][位置2]...[颜色0][颜色1][颜色2]...[UV0][UV1][UV2]...
|←──── 所有位置 ────→| |←──── 所有颜色 ────→| |←── 所有UV ──→|
优点:更新单个属性时效率高
Minecraft 使用交错布局。
1.3.4 VBO(Vertex Buffer Object)
VBO 是存储顶点数据的显存缓冲区。
为什么需要 VBO?
在没有 VBO 的时代(立即模式,对应MC1.14之前),每一帧都要把顶点数据从内存传到 GPU:
plaintext
立即模式(老方法,已废弃):
每一帧:
CPU 内存 ──传输──► GPU
CPU 内存 ──传输──► GPU
CPU 内存 ──传输──► GPU
...
非常慢!
VBO 模式(现代方法):
初始化时:
CPU 内存 ──传输──► VBO(显存) 只传一次
每一帧:
GPU 直接从 VBO 读取 非常快!
VBO 的生命周期
plaintext
1. 创建 VBO
int vboId = glGenBuffers();
2. 绑定 VBO(告诉 OpenGL 接下来操作这个 VBO)
glBindBuffer(GL_ARRAY_BUFFER, vboId);
3. 上传数据
glBufferData(GL_ARRAY_BUFFER, data, GL_STATIC_DRAW);
使用提示:
- GL_STATIC_DRAW : 数据不会变,用于静态模型
- GL_DYNAMIC_DRAW : 数据会经常变,用于动画
- GL_STREAM_DRAW : 数据每帧都变,用于粒子
4. 使用 VBO 进行渲染
glDrawArrays(...);
5. 删除 VBO(不再需要时)
glDeleteBuffers(vboId);
1.3.5 VAO(Vertex Array Object)
VAO 存储顶点属性的配置信息,告诉 GPU 如何解读 VBO 中的数据。
为什么需要 VAO?
VBO 只是一堆字节,GPU 不知道:
- 哪些字节是位置?哪些是颜色?
- 每个属性有几个分量?
- 数据类型是什么?
VAO 就是用来描述这些信息的。
plaintext
VBO 中的原始数据(字节):
[12字节][16字节][8字节][12字节][16字节][8字节]...
? ? ? ? ? ?
VAO 告诉 GPU:
- 属性0(位置):从偏移0开始,3个float,步长36字节
- 属性1(颜色):从偏移12开始,4个float,步长36字节
- 属性2(UV):从偏移28开始,2个float,步长36字节
GPU 就知道如何解读了:
[位置0 ][颜色0 ][UV0 ][位置1 ][颜色1 ][UV1 ]...
VAO 的配置
c
// 创建并绑定 VAO
int vaoId = glGenVertexArrays();
glBindVertexArray(vaoId);
// 绑定 VBO
glBindBuffer(GL_ARRAY_BUFFER, vboId);
// 配置属性0(位置)
glVertexAttribPointer(
0, // 属性索引
3, // 分量数量(vec3 = 3)
GL_FLOAT, // 数据类型
GL_FALSE, // 是否归一化
36, // 步长(一个顶点的总字节数)
0 // 偏移量(位置从0开始)
);
glEnableVertexAttribArray(0); // 启用属性0
// 配置属性1(颜色)
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 36, 12);
glEnableVertexAttribArray(1);
// 配置属性2(UV)
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 36, 28);
glEnableVertexAttribArray(2);
VAO 的好处
配置一次,多次使用:
plaintext
没有 VAO:
每次渲染都要重新配置属性
glVertexAttribPointer(0, ...);
glVertexAttribPointer(1, ...);
glVertexAttribPointer(2, ...);
glDrawArrays(...);
有 VAO:
初始化时配置一次
渲染时只需要:
glBindVertexArray(vaoId); // 一行搞定
glDrawArrays(...);
1.3.6 EBO/IBO(Element/Index Buffer Object)
索引缓冲用于复用顶点,减少数据量。
问题:顶点重复
画一个正方形需要 2 个三角形,6 个顶点:
plaintext
不使用索引:
三角形1: v0, v1, v2
三角形2: v0, v2, v3
顶点数据:
v0(0,0) v1(1,0) v2(1,1) v0(0,0) v2(1,1) v3(0,1)
↑重复 ↑重复
6 个顶点,但实际只有 4 个不同的点
解决:使用索引
使用索引:
顶点数据(只存 4 个):
v0(0,0) v1(1,0) v2(1,1) v3(0,1)
索引数据:
0, 1, 2, // 三角形1: v0, v1, v2
0, 2, 3 // 三角形2: v0, v2, v3
节省了 2 个顶点的数据!
索引绘制
c
// 创建并上传索引数据
int eboId = glGenBuffers();
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, eboId);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW);
// 使用索引绘制
glDrawElements(
GL_TRIANGLES, // 图元类型
6, // 索引数量
GL_UNSIGNED_INT, // 索引数据类型
0 // 偏移
);
1.3.7 完整示例:渲染一个三角形
c
// ===== 初始化阶段 =====
// 顶点数据:位置(3) + 颜色(4) + UV(2) = 9 个 float
float vertices[] = {
// 位置 颜色 UV
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下,红色
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, // 右下,绿色
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.5f, 1.0f, // 顶部,蓝色
};
// 1. 创建 VAO
int vaoId = glGenVertexArrays();
glBindVertexArray(vaoId);
// 2. 创建 VBO 并上传数据
int vboId = glGenBuffers();
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 配置顶点属性
int stride = 9 * sizeof(float); // 一个顶点 36 字节
// 位置属性(location = 0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
glEnableVertexAttribArray(0);
// 颜色属性(location = 1)
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
// UV 属性(location = 2)
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (void*)(7 * sizeof(float)));
glEnableVertexAttribArray(2);
// ===== 渲染阶段(每帧) =====
glBindVertexArray(vaoId); // 绑定 VAO
glDrawArrays(GL_TRIANGLES, 0, 3); // 绘制 3 个顶点
// ===== 清理阶段 =====
glDeleteBuffers(vboId);
glDeleteVertexArrays(vaoId);
1.3.8 Minecraft 中的顶点格式
Minecraft 预定义了多种顶点格式(DefaultVertexFormat):
| 格式名 | 属性 | 字节数 | 用途 |
|---|---|---|---|
POSITION |
pos(3f) | 12 | 纯位置 |
POSITION_COLOR |
pos(3f) + color(4b) | 16 | 调试线条 |
POSITION_TEX |
pos(3f) + uv(2f) | 20 | 简单纹理 |
POSITION_TEX_COLOR |
pos(3f) + uv(2f) + color(4b) | 24 | GUI |
PARTICLE |
pos(3f) + uv(2f) + color(4b) + uv2(2s) | 28 | 粒子 |
BLOCK |
pos(3f) + color(4b) + uv(2f) + uv2(2s) + normal(3b) | 32 | 方块 |
plaintext
PARTICLE 格式详解:
┌────────────────────────────────────────────────────────────┐
│ Position │ UV0 │ Color │ UV2 │ Padding │
│ 3×float │ 2×float │ 4×ubyte │ 2×short │ 2 bytes │
│ 12 bytes │ 8 bytes │ 4 bytes │ 4 bytes │ (对齐) │
└────────────────────────────────────────────────────────────┘
总计:28 字节(实际可能 32 字节,因为对齐)
小结
| 概念 | 作用 |
|---|---|
| 顶点 | 包含位置、颜色、UV 等信息的数据包 |
| 顶点属性 | 顶点中的每种数据(位置、颜色等) |
| VBO | 存储顶点数据的显存缓冲区 |
| VAO | 存储顶点属性配置,告诉 GPU 如何解读 VBO |
| EBO/IBO | 存储索引,用于复用顶点 |
plaintext
数据流:
CPU 内存 ──► VBO(显存)──► VAO 描述格式 ──► 顶点着色器
1.4 着色器与 GLSL
本节目标:理解着色器的概念,学会阅读和编写基本的 GLSL 代码
1.4.1 什么是着色器?
着色器(Shader) 是运行在 GPU 上的小程序。
虽然叫"着色器",但它不只是用来上色的。着色器可以:
- 变换顶点位置(顶点着色器)
- 计算像素颜色(片段着色器)
- 实现各种视觉效果(光照、阴影、后处理等)
plaintext
着色器 = 运行在 GPU 上的代码
= 告诉 GPU "如何处理每个顶点/像素"
1.4.2 着色器的类型
| 类型 | 执行时机 | 主要任务 | Minecraft 使用 |
|---|---|---|---|
| 顶点着色器 (Vertex) | 每个顶点执行一次 | 坐标变换 | ✓ |
| 片段着色器 (Fragment) | 每个片段执行一次 | 计算颜色 | ✓ |
| 几何着色器 (Geometry) | 每个图元执行一次 | 生成新顶点 | 很少 |
| 计算着色器 (Compute) | 通用计算 | 并行计算 | 光影包使用 |
Minecraft 主要使用顶点着色器和片段着色器。
1.4.3 GLSL 语言基础
GLSL(OpenGL Shading Language) 是编写着色器的语言,语法类似 C 语言。
基本结构
glsl
#version 150 // 版本声明,Minecraft 1.17+ 使用 150 (OpenGL 3.2+)
// 输入变量
in vec3 Position;
in vec4 Color;
// 输出变量
out vec4 vertexColor;
// Uniform 变量(全局常量)
uniform mat4 ModelViewMat;
uniform mat4 ProjMat;
// 主函数
void main() {
// 你的代码
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
vertexColor = Color;
}
数据类型
标量类型
glsl
bool b = true; // 布尔
int i = 42; // 整数
float f = 3.14; // 浮点数(最常用)
向量类型
glsl
vec2 v2 = vec2(1.0, 2.0); // 2D 向量
vec3 v3 = vec3(1.0, 2.0, 3.0); // 3D 向量(位置、颜色RGB)
vec4 v4 = vec4(1.0, 2.0, 3.0, 1.0); // 4D 向量(颜色RGBA)
ivec2 iv = ivec2(1, 2); // 整数向量
bvec3 bv = bvec3(true, false, true); // 布尔向量
向量分量访问
glsl
vec4 color = vec4(1.0, 0.5, 0.0, 1.0);
// 方式1:xyzw(位置)
float x = color.x; // 1.0
float y = color.y; // 0.5
float z = color.z; // 0.0
float w = color.w; // 1.0
// 方式2:rgba(颜色)
float r = color.r; // 1.0
float g = color.g; // 0.5
float b = color.b; // 0.0
float a = color.a; // 1.0
// 方式3:stpq(纹理坐标)
float s = color.s; // 1.0
float t = color.t; // 0.5
// Swizzling(重排)
vec3 rgb = color.rgb; // vec3(1.0, 0.5, 0.0)
vec3 bgr = color.bgr; // vec3(0.0, 0.5, 1.0) 反转!
vec4 rrrr = color.rrrr; // vec4(1.0, 1.0, 1.0, 1.0)
vec2 xy = color.xy; // vec2(1.0, 0.5)
矩阵类型
glsl
mat2 m2; // 2×2 矩阵
mat3 m3; // 3×3 矩阵(法线变换)
mat4 m4; // 4×4 矩阵(MVP 变换)
// 矩阵乘法
vec4 transformed = m4 * vec4(position, 1.0);
采样器类型
glsl
sampler2D tex; // 2D 纹理采样器
sampler3D tex3d; // 3D 纹理采样器
samplerCube texCube; // 立方体贴图采样器
变量限定符
glsl
// ===== in =====
// 输入变量,从上一阶段接收数据
in vec3 Position; // 顶点着色器:从顶点数据接收
in vec4 vertexColor; // 片段着色器:从顶点着色器接收(已插值)
// ===== out =====
// 输出变量,传递给下一阶段
out vec4 vertexColor; // 顶点着色器:传给片段着色器
out vec4 fragColor; // 片段着色器:最终颜色
// ===== uniform =====
// 全局常量,CPU 设置,整个绘制调用中不变
uniform mat4 ModelViewMat; // 模型视图矩阵
uniform mat4 ProjMat; // 投影矩阵
uniform float Time; // 时间
uniform sampler2D Sampler0; // 纹理
// ===== const =====
// 编译时常量
const float PI = 3.14159265;
const vec3 UP = vec3(0.0, 1.0, 0.0);
内置变量
glsl
// ===== 顶点着色器 =====
gl_Position // 必须设置!变换后的顶点位置(vec4)
gl_PointSize // 点的大小(用于 GL_POINTS)
// ===== 片段着色器 =====
gl_FragCoord // 片段的屏幕坐标(vec4)
gl_FrontFacing // 是否是正面(bool)
gl_FragDepth // 片段深度(可选,覆盖默认深度)
常用内置函数
数学函数
glsl
// 基础数学
abs(x) // 绝对值
sign(x) // 符号 (-1, 0, 1)
floor(x) // 向下取整
ceil(x) // 向上取整
fract(x) // 小数部分 = x - floor(x)
mod(x, y) // 取模
min(x, y) // 最小值
max(x, y) // 最大值
clamp(x, a, b) // 限制范围 [a, b]
// 插值
mix(a, b, t) // 线性插值 = a * (1-t) + b * t
smoothstep(a, b, x) // 平滑插值
// 三角函数
sin(x), cos(x), tan(x)
asin(x), acos(x), atan(x)
// 指数
pow(x, y) // x 的 y 次方
exp(x) // e 的 x 次方
log(x) // 自然对数
sqrt(x) // 平方根
inversesqrt(x) // 1 / sqrt(x)
向量函数
glsl
length(v) // 向量长度
distance(a, b) // 两点距离
dot(a, b) // 点积
cross(a, b) // 叉积(仅 vec3)
normalize(v) // 归一化(单位向量)
reflect(I, N) // 反射向量
refract(I, N, eta) // 折射向量
纹理采样
glsl
texture(sampler, uv) // 采样纹理
texelFetch(sampler, coord, lod) // 精确像素采样
textureSize(sampler, lod) // 获取纹理尺寸
1.4.4 顶点着色器详解
顶点着色器的主要任务:坐标变换
glsl
#version 150
// 输入:顶点属性
in vec3 Position; // 位置
in vec4 Color; // 颜色
in vec2 UV0; // 纹理坐标
in ivec2 UV2; // 光照坐标
// Uniform:变换矩阵
uniform mat4 ModelViewMat; // 模型视图矩阵
uniform mat4 ProjMat; // 投影矩阵
uniform sampler2D Sampler2; // 光照贴图
// 输出:传给片段着色器
out vec4 vertexColor;
out vec2 texCoord0;
out float vertexDistance;
void main() {
// 1. 坐标变换:模型空间 → 裁剪空间
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
// 2. 计算顶点到相机的距离(用于雾效)
vertexDistance = length((ModelViewMat * vec4(Position, 1.0)).xyz);
// 3. 传递纹理坐标
texCoord0 = UV0;
// 4. 计算顶点颜色(包含光照)
// UV2 是光照坐标,除以 16 得到采样坐标
vec4 lightColor = texelFetch(Sampler2, UV2 / 16, 0);
vertexColor = Color * lightColor;
}
坐标变换详解
plaintext
模型空间 (Local Space)
│
│ ModelViewMat = ViewMat × ModelMat
▼
观察空间 (View Space)
│
│ ProjMat
▼
裁剪空间 (Clip Space) ← gl_Position 在这里
│
│ GPU 自动完成(透视除法 + 视口变换)
▼
屏幕空间 (Screen Space)
1.4.5 片段着色器详解
片段着色器的主要任务:计算颜色
glsl
#version 150
// 输入:从顶点着色器传来(已插值)
in vec4 vertexColor;
in vec2 texCoord0;
in float vertexDistance;
// Uniform
uniform sampler2D Sampler0; // 主纹理
uniform vec4 ColorModulator; // 颜色调制
uniform float FogStart; // 雾起始距离
uniform float FogEnd; // 雾结束距离
uniform vec4 FogColor; // 雾颜色
// 输出:最终颜色
out vec4 fragColor;
void main() {
// 1. 采样纹理
vec4 texColor = texture(Sampler0, texCoord0);
// 2. 丢弃透明像素(可选)
if (texColor.a < 0.01) {
discard; // 不渲染这个片段
}
// 3. 计算基础颜色
vec4 color = texColor * vertexColor * ColorModulator;
// 4. 应用雾效
float fogFactor = smoothstep(FogStart, FogEnd, vertexDistance);
color = mix(color, FogColor, fogFactor * FogColor.a);
// 5. 输出最终颜色
fragColor = color;
}
常见片段着色器技巧
丢弃片段
glsl
if (texColor.a < 0.5) {
discard; // 完全不渲染这个像素
}
颜色混合
glsl
// 线性插值
vec4 result = mix(colorA, colorB, factor); // factor: 0.0-1.0
// 叠加
vec4 result = colorA + colorB;
// 正片叠底
vec4 result = colorA * colorB;
边缘发光
glsl
// 根据法线和视线方向计算边缘
float rim = 1.0 - max(dot(normal, viewDir), 0.0);
rim = pow(rim, 3.0); // 增强边缘
vec4 result = baseColor + rimColor * rim;
1.4.6 着色器程序的生命周期
plaintext
┌─────────────────────────────────────────────────────────────┐
│ 着色器程序创建流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 创建着色器对象 │
│ vertexShader = glCreateShader(GL_VERTEX_SHADER) │
│ fragmentShader = glCreateShader(GL_FRAGMENT_SHADER) │
│ │
│ 2. 上传源代码 │
│ glShaderSource(vertexShader, vertexCode) │
│ glShaderSource(fragmentShader, fragmentCode) │
│ │
│ 3. 编译着色器 │
│ glCompileShader(vertexShader) │
│ glCompileShader(fragmentShader) │
│ // 检查编译错误! │
│ │
│ 4. 创建程序对象 │
│ program = glCreateProgram() │
│ │
│ 5. 附加着色器 │
│ glAttachShader(program, vertexShader) │
│ glAttachShader(program, fragmentShader) │
│ │
│ 6. 链接程序 │
│ glLinkProgram(program) │
│ // 检查链接错误! │
│ │
│ 7. 删除着色器对象(已链接,不再需要) │
│ glDeleteShader(vertexShader) │
│ glDeleteShader(fragmentShader) │
│ │
│ 8. 使用程序 │
│ glUseProgram(program) │
│ │
│ 9. 设置 Uniform │
│ location = glGetUniformLocation(program, "Time") │
│ glUniform1f(location, time) │
│ │
│ 10. 渲染... │
│ │
│ 11. 清理(程序结束时) │
│ glDeleteProgram(program) │
│ │
└─────────────────────────────────────────────────────────────┘
1.4.7 Uniform 的使用
Uniform 是从 CPU 传递给着色器的数据:
c
// CPU 端(C/Java/Kotlin)
// 1. 获取 uniform 位置
int location = glGetUniformLocation(program, "Time");
// 2. 设置值
glUniform1f(location, 1.5f); // float
glUniform2f(location, 1.0f, 2.0f); // vec2
glUniform3f(location, 1.0f, 2.0f, 3.0f); // vec3
glUniform4f(location, 1.0f, 2.0f, 3.0f, 1.0f); // vec4
glUniform1i(location, 0); // int(纹理单元)
glUniformMatrix4fv(location, 1, false, matrix); // mat4
glsl
// GPU 端(GLSL)
uniform float Time;
uniform vec4 ColorModulator;
uniform mat4 ModelViewMat;
uniform sampler2D Sampler0;
void main() {
// 使用 uniform
float wave = sin(Time * 2.0);
vec4 color = texture(Sampler0, uv) * ColorModulator;
}
1.4.8 Minecraft 着色器文件结构
Minecraft 的着色器由三个文件组成:
plaintext
assets/<namespace>/shaders/core/
├── <name>.json # 配置文件
├── <name>.vsh # 顶点着色器
└── <name>.fsh # 片段着色器
JSON 配置文件
json
{
"blend": {
"func": "add",
"srcrgb": "srcalpha",
"dstrgb": "1-srcalpha"
},
"vertex": "minecraft:particle",
"fragment": "minecraft:particle",
"attributes": [
"Position",
"UV0",
"Color",
"UV2"
],
"samplers": [
{ "name": "Sampler0" },
{ "name": "Sampler2" }
],
"uniforms": [
{ "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] },
{ "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] },
{ "name": "ColorModulator", "type": "float", "count": 4, "values": [1.0, 1.0, 1.0, 1.0] },
{ "name": "FogStart", "type": "float", "count": 1, "values": [0.0] },
{ "name": "FogEnd", "type": "float", "count": 1, "values": [1.0] },
{ "name": "FogColor", "type": "float", "count": 4, "values": [0.0, 0.0, 0.0, 0.0] }
]
}
顶点着色器 (.vsh)
glsl
#version 150
#moj_import <fog.glsl> // Minecraft 的 include 语法
in vec3 Position;
in vec2 UV0;
in vec4 Color;
in ivec2 UV2;
uniform sampler2D Sampler2;
uniform mat4 ModelViewMat;
uniform mat4 ProjMat;
uniform int FogShape;
out float vertexDistance;
out vec2 texCoord0;
out vec4 vertexColor;
void main() {
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
vertexDistance = fog_distance(ModelViewMat, Position, FogShape);
texCoord0 = UV0;
vertexColor = Color * texelFetch(Sampler2, UV2 / 16, 0);
}
片段着色器 (.fsh)
glsl
#version 150
#moj_import <fog.glsl>
in float vertexDistance;
in vec2 texCoord0;
in vec4 vertexColor;
uniform sampler2D Sampler0;
uniform vec4 ColorModulator;
uniform float FogStart;
uniform float FogEnd;
uniform vec4 FogColor;
out vec4 fragColor;
void main() {
vec4 color = texture(Sampler0, texCoord0) * vertexColor * ColorModulator;
if (color.a < 0.01) discard;
fragColor = linear_fog(color, vertexDistance, FogStart, FogEnd, FogColor);
}
小结
| 概念 | 说明 |
|---|---|
| 着色器 | 运行在 GPU 上的程序 |
| GLSL | 编写着色器的语言 |
| 顶点着色器 | 处理每个顶点,主要做坐标变换 |
| 片段着色器 | 处理每个片段,计算最终颜色 |
| Uniform | CPU 传给 GPU 的全局常量 |
| in/out | 着色器之间传递数据的变量 |
1.5 矩阵变换
本节目标:理解 3D 图形中的坐标变换,掌握 Model、View、Projection 矩阵的作用
1.5.1 为什么需要矩阵?
在 3D 图形中,我们需要对物体进行各种变换:
- 平移:移动位置
- 旋转:改变朝向
- 缩放:改变大小
矩阵可以把这些变换统一表示,而且可以组合:
多个变换组合成一个矩阵:
先缩放 → 再旋转 → 再平移
= 平移矩阵 × 旋转矩阵 × 缩放矩阵
= 一个组合矩阵
应用变换只需要一次矩阵乘法!
1.5.2 齐次坐标
3D 点通常用 (x, y, z) 表示,但在图形学中我们用 4D 齐次坐标 (x, y, z, w)。
为什么需要第四个分量?
-
区分点和向量
点: (x, y, z, 1) → 可以被平移 向量: (x, y, z, 0) → 不能被平移(方向不受位置影响) -
统一表示平移
- 3×3 矩阵无法表示平移
- 4×4 矩阵可以!
-
透视除法
- 透视投影后,w 不再是 1
- 最终坐标 = (x/w, y/w, z/w)
1.5.3 基本变换矩阵
平移矩阵 (Translation)
把点移动 (tx, ty, tz)由抽象代数的知识得为何最后一列是tx,ty,tz,1,下同:
::: align-center
\begin{bmatrix}1&0&0&tx\\0&1&0&ty\\0&0&1&tz\\0&0&0&1\end{bmatrix}\begin{bmatrix}x\\y\\z\\1\end{bmatrix}=\begin{bmatrix}x+tx\\y+ty\\z+tz\\1\end{bmatrix}
:::
缩放矩阵 (Scale)
按 (sx, sy, sz) 缩放:
::: align-center
\begin{bmatrix}sx&0&0&0\\0&sy&0&0\\0&0&sz&0\\0&0&0&1\end{bmatrix}\begin{bmatrix}x\\y\\z\\1\end{bmatrix}=\begin{bmatrix}x\times sx\\y\times sy\\z\times sz\\1\end{bmatrix}
:::
旋转矩阵 (Rotation)
绕 X 轴旋转 θ 度(x这一行列不变,下同):
::: align-center
\begin{bmatrix}
1 & 0 & 0 & 0 \\
0 & \cos(\theta) & -\sin(\theta) & 0 \\
0 & \sin(\theta) & \cos(\theta) & 0 \\
0 & 0 & 0 & 1
\end{bmatrix}
:::
绕 Y 轴旋转 θ 度:
::: align-center
\begin{bmatrix}
\cos(\theta) & 0 & \sin(\theta) & 0 \\
0 & 1 & 0 & 0 \\
-\sin(\theta) & 0 & \cos(\theta) & 0 \\
0 & 0 & 0 & 1
\end{bmatrix}
:::
绕 Z 轴旋转 θ 度:
::: align-center
\begin{bmatrix}
\cos(\theta) & -\sin(\theta) & 0 & 0 \\
\sin(\theta) & \cos(\theta) & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1
\end{bmatrix}
:::
1.5.4 坐标空间
一个顶点要经过多个坐标空间才能到达屏幕:
plaintext
┌─────────────────────────────────────────────────────────────────┐
│ 坐标空间变换流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 模型空间 (Model/Local Space) │
│ ├─ 模型自己的坐标系 │
│ ├─ 原点通常在模型中心 │
│ └─ 例:一个方块,顶点坐标是 (-0.5 到 0.5) │
│ │ │
│ │ Model 矩阵(平移、旋转、缩放) │
│ ▼ │
│ 世界空间 (World Space) │
│ ├─ 游戏世界的坐标系 │
│ ├─ 所有物体都在同一个坐标系中 │
│ └─ 例:方块在世界坐标 (100, 64, 200) 处 │
│ │ │
│ │ View 矩阵(相机位置和朝向) │
│ ▼ │
│ 观察空间 (View/Eye/Camera Space) │
│ ├─ 以相机为原点 │
│ ├─ 相机看向 -Z 方向 │
│ └─ 例:方块在相机前方 10 格处 │
│ │ │
│ │ Projection 矩阵(透视或正交) │
│ ▼ │
│ 裁剪空间 (Clip Space) │
│ ├─ 标准化的立方体 [-1, 1] │
│ ├─ 超出范围的会被裁剪 │
│ └─ gl_Position 就在这个空间 │
│ │ │
│ │ 透视除法(GPU 自动) │
│ ▼ │
│ NDC (Normalized Device Coordinates) │
│ ├─ 坐标范围 [-1, 1] │
│ └─ 透视除法后:(x/w, y/w, z/w) │
│ │ │
│ │ 视口变换(GPU 自动) │
│ ▼ │
│ 屏幕空间 (Screen Space) │
│ ├─ 像素坐标 │
│ └─ (0,0) 到 (width, height) │
│ │
└─────────────────────────────────────────────────────────────────┘
1.5.5 Model 矩阵
Model 矩阵把顶点从模型空间变换到世界空间。
plaintext
Model = Translation × Rotation × Scale
注意顺序!矩阵乘法不满足交换律
先缩放 → 再旋转 → 再平移
示例:放置一个方块
plaintext
方块模型:中心在原点,边长为 1
目标:放置在世界坐标 (10, 64, 20),绕 Y 轴旋转 45°
Model = Translate(10, 64, 20) × RotateY(45°) × Scale(1, 1, 1)
顶点变换:
worldPos = Model × localPos
Minecraft 中的 Model 矩阵
在 Minecraft 中,Model 矩阵通常通过 PoseStack 矩阵栈构建:
kotlin
poseStack.pushPose()
poseStack.translate(x, y, z) // 平移
poseStack.mulPose(Axis.YP.rotationDegrees(45f)) // 旋转
poseStack.scale(2f, 2f, 2f) // 缩放
val modelMatrix = poseStack.last().pose() // 获取 Model 矩阵
使用时,弹出的顺序正好是刚才矩阵乘法从右到左的顺序
1.5.6 View 矩阵
View 矩阵把顶点从世界空间变换到观察空间(相机空间)。
相机的定义
相机由三个要素定义:
- 位置 (Eye):相机在哪里
- 目标 (Target):相机看向哪里
- 上方向 (Up):哪个方向是"上"
plaintext
Up
↑
│
Eye ──┼──► 看向 Target
│
LookAt 矩阵
plaintext
给定:
eye = 相机位置
target = 看向的点
up = 上方向
计算:
forward = normalize(target - eye) // 前方
right = normalize(cross(forward, up)) // 右方
up' = cross(right, forward) // 真正的上方
View 矩阵:
┌ ┐
│ right.x right.y right.z -dot(right, eye) │
│ up'.x up'.y up'.z -dot(up', eye) │
│ -forward.x -forward.y -forward.z dot(forward, eye) │
│ 0 0 0 1 │
└ ┘
Minecraft 中的 View 矩阵
Minecraft 的 View 矩阵由 Camera 类提供:
kotlin
// 相机信息
val camera: Camera = ...
val position = camera.position // 相机位置
val xRot = camera.xRot // 俯仰角(Pitch)
val yRot = camera.yRot // 偏航角(Yaw)
// View 矩阵通常已经包含在 ModelViewMat 中
// ModelViewMat = View × Model
1.5.7 Projection 矩阵
Projection 矩阵把顶点从观察空间变换到裁剪空间。
有两种投影方式:
透视投影 (Perspective)
近大远小,符合人眼视觉:
近平面 远平面
│ │
╱─────┼──────────────┼─────╲
╱ │ │ ╲
╱ │ 视锥体 │ ╲
╱ │ (Frustum) │ ╲
╱─────────┼──────────────┼─────────╲
相机 near far
参数:
- FOV (Field of View):视野角度,通常 60°-90°
- Aspect:宽高比 = width / height
- Near:近裁剪面距离
- Far:远裁剪面距离
plaintext
透视投影矩阵:
┌ ┐
│ 1/(aspect×tan(fov/2)) 0 0 0 │
│ 0 1/tan(fov/2) 0 0 │
│ 0 0 -(far+near)/(far-near) -2×far×near/(far-near) │
│ 0 0 -1 0 │
└ ┘
正交投影 (Orthographic)
没有近大远小,用于 2D UI 或等距视角:
plaintext
近平面 远平面
│ │
┌─────┼──────────────┼─────┐
│ │ │ │
│ │ 长方体 │ │
│ │ │ │
└─────┼──────────────┼─────┘
near far
参数:
- left, right:左右边界
- bottom, top:上下边界
- near, far:前后边界
plaintext
正交投影矩阵:
┌ ┐
│ 2/(right-left) 0 0 -(right+left)/(right-left) │
│ 0 2/(top-bottom) 0 -(top+bottom)/(top-bottom) │
│ 0 0 -2/(far-near) -(far+near)/(far-near) │
│ 0 0 0 1 │
└ ┘
Minecraft 中的投影
kotlin
// 游戏世界:透视投影
// FOV 可在设置中调整(默认 70°)
// GUI:正交投影
// 坐标直接对应屏幕像素
1.5.8 MVP 矩阵
MVP = Model × View × Projection
但在着色器中,通常是:
glsl
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
// ModelViewMat = View × Model(已经合并)
// 所以实际是:Projection × View × Model × Position
变换顺序
Position (模型空间)
│
│ × Model
▼
世界空间
│
│ × View
▼
观察空间
│
│ × Projection
▼
裁剪空间 (gl_Position)
1.5.9 矩阵栈
在复杂场景中,物体之间有层级关系:
机器人
├── 身体
│ ├── 左臂
│ │ └── 左手
│ └── 右臂
│ └── 右手
└── 腿
└── 脚
矩阵栈用于管理这种层级变换:
plaintext
渲染身体:
push()
translate(身体位置)
渲染身体
渲染左臂:
push()
translate(左臂相对身体的位置)
rotate(左臂角度)
渲染左臂
渲染左手:
push()
translate(左手相对左臂的位置)
渲染左手
pop() // 恢复到左臂的变换
pop() // 恢复到身体的变换
pop() // 恢复到初始状态
Minecraft 的 PoseStack
kotlin
val poseStack = PoseStack()
poseStack.pushPose() // 保存当前状态
poseStack.translate(1.0, 2.0, 3.0)
poseStack.mulPose(Axis.YP.rotationDegrees(45f))
// 渲染...
poseStack.popPose() // 恢复之前的状态
// pushPose/popPose 必须配对!
1.5.10 法线矩阵
法线(Normal)用于光照计算,但法线的变换和位置不同:
plaintext
问题:
如果物体被非均匀缩放(比如只在 X 方向拉伸),
直接用 Model 矩阵变换法线会导致法线不再垂直于表面!
解决:
法线矩阵 = transpose(inverse(Model))
或者简化为 Model 矩阵的左上 3×3 部分的逆转置
在 Minecraft 中:
kotlin
val normalMatrix = poseStack.last().normal() // Matrix3f
小结
| 矩阵 | 作用 | 变换 |
|---|---|---|
| Model | 模型空间 → 世界空间 | 放置物体 |
| View | 世界空间 → 观察空间 | 相机视角 |
| Projection | 观察空间 → 裁剪空间 | 透视/正交 |
| MVP | 模型空间 → 裁剪空间 | 完整变换 |
| Normal | 变换法线 | 光照计算 |
顶点着色器中:
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
1.6 纹理系统
本节目标:理解纹理的概念、UV 映射、采样方式和纹理单元
1.6.1 什么是纹理?
纹理(Texture) 就是贴在 3D 模型表面的图片。
plaintext
没有纹理的方块: 有纹理的方块:
┌─────────────┐ ┌─────────────┐
│ │ │ ░░▓▓░░▓▓░░ │
│ 纯色 │ → │ ▓▓░░▓▓░░▓▓ │ 泥土纹理
│ │ │ ░░▓▓░░▓▓░░ │
└─────────────┘ └─────────────┘
纹理让简单的几何体看起来有丰富的细节,而不需要增加顶点数量。
1.6.2 UV 坐标
UV 坐标告诉 GPU 每个顶点对应纹理图片的哪个位置。
为什么叫 UV?
- X、Y、Z 已经用于 3D 空间坐标
- 所以纹理坐标用 U、V(有时还有 W)
UV 坐标系
plaintext
纹理图片:
(0,0)─────────────────(1,0)
│ │
│ ┌─────────┐ │
│ │ 图片 │ │
│ │ 内容 │ │
│ └─────────┘ │
│ │
(0,1)─────────────────(1,1)
UV 范围:0.0 到 1.0
(0,0) = 左上角
(1,1) = 右下角
注意:OpenGL 的纹理坐标 (0,0) 在左下角
但 Minecraft 加载图片时会翻转,所以 (0,0) 在左上角
UV 映射示例
plaintext
3D 正方形的 4 个顶点:
顶点0 (左下): Position(-0.5, -0.5, 0), UV(0.0, 1.0)
顶点1 (右下): Position( 0.5, -0.5, 0), UV(1.0, 1.0)
顶点2 (右上): Position( 0.5, 0.5, 0), UV(1.0, 0.0)
顶点3 (左上): Position(-0.5, 0.5, 0), UV(0.0, 0.0)
这样整张纹理就贴到了正方形上
部分纹理映射
你可以只使用纹理的一部分:
plaintext
纹理图集(Atlas):
┌────┬────┬────┬────┐
│草 │泥土│石头│木头│
├────┼────┼────┼────┤
│沙子│水 │岩浆│...│
└────┴────┴────┴────┘
要使用"泥土"纹理:
UV 范围:(0.25, 0.0) 到 (0.5, 0.5)
Minecraft 的方块纹理就是这样组织的!
1.6.3 纹理对象
在 OpenGL 中,纹理是一个对象,需要创建、配置、使用、删除。
纹理的生命周期
c
// 1. 生成纹理 ID
GLuint textureId;
glGenTextures(1, &textureId);
// 2. 绑定纹理(告诉 OpenGL 接下来操作这个纹理)
glBindTexture(GL_TEXTURE_2D, textureId);
// 3. 设置纹理参数(过滤、环绕等)
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_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// 4. 上传纹理数据
glTexImage2D(
GL_TEXTURE_2D, // 纹理类型
0, // Mipmap 级别
GL_RGBA, // 内部格式
width, height, // 尺寸
0, // 边框(必须是 0)
GL_RGBA, // 数据格式
GL_UNSIGNED_BYTE, // 数据类型
pixels // 像素数据
);
// 5. 使用纹理进行渲染
glBindTexture(GL_TEXTURE_2D, textureId);
// 渲染...
// 6. 删除纹理(不再需要时)
glDeleteTextures(1, &textureId);
1.6.4 纹理过滤
当纹理被放大或缩小时,需要决定如何采样。
放大过滤 (Magnification)
纹理比屏幕像素小,需要放大:
plaintext
GL_NEAREST(最近邻):
┌──┬──┐ ┌──┬──┬──┬──┐
│A │B │ → │A │A │B │B │ 像素化,锐利
├──┼──┤ ├──┼──┼──┼──┤ Minecraft 默认使用
│C │D │ │A │A │B │B │
└──┴──┘ ├──┼──┼──┼──┤
│C │C │D │D │
├──┼──┼──┼──┤
│C │C │D │D │
└──┴──┴──┴──┘
GL_LINEAR(线性插值):
┌──┬──┐ ┌──┬──┬──┬──┐
│A │B │ → │A │AB│AB│B │ 平滑,模糊
├──┼──┤ ├──┼──┼──┼──┤
│C │D │ │AC│..│..│BD│
└──┴──┘ ├──┼──┼──┼──┤
│AC│..│..│BD│
├──┼──┼──┼──┤
│C │CD│CD│D │
└──┴──┴──┴──┘
缩小过滤 (Minification)
纹理比屏幕像素大,需要缩小:
plaintext
GL_NEAREST:直接取最近的像素
GL_LINEAR:取周围像素的平均值
GL_NEAREST_MIPMAP_NEAREST:使用 Mipmap,最近邻
GL_LINEAR_MIPMAP_LINEAR:使用 Mipmap,三线性过滤(最平滑)
Minecraft 的选择
plaintext
方块纹理:GL_NEAREST(保持像素风格)
GUI:GL_NEAREST
一些特效:GL_LINEAR(平滑效果)
1.6.5 纹理环绕模式
当 UV 坐标超出 [0, 1] 范围时怎么办?
plaintext
GL_REPEAT(重复):
UV = 1.5 → 实际采样 0.5
纹理会平铺
┌───┬───┬───┐
│ A │ A │ A │
├───┼───┼───┤
│ A │ A │ A │
└───┴───┴───┘
GL_MIRRORED_REPEAT(镜像重复):
UV = 1.5 → 实际采样 0.5(镜像)
┌───┬───┬───┐
│ A │ Ɐ │ A │
├───┼───┼───┤
│ A │ Ɐ │ A │
└───┴───┴───┘
GL_CLAMP_TO_EDGE(边缘钳制):
UV = 1.5 → 实际采样 1.0
超出部分使用边缘颜色
┌───┬───┬───┐
│ A │ → │ → │
├───┼───┼───┤
│ A │ → │ → │
└───┴───┴───┘
GL_CLAMP_TO_BORDER(边框钳制):
超出部分使用指定的边框颜色
1.6.6 Mipmap
Mipmap 是预先生成的多级缩小版纹理。
为什么需要 Mipmap?
当物体很远时,纹理会被大幅缩小。如果直接采样原始纹理:
- 会产生闪烁(摩尔纹)
- 性能差(采样大纹理)
plaintext
Mipmap 层级:
Level 0: 256×256 ← 原始纹理
Level 1: 128×128
Level 2: 64×64
Level 3: 32×32
Level 4: 16×16
Level 5: 8×8
Level 6: 4×4
Level 7: 2×2
Level 8: 1×1
GPU 根据距离自动选择合适的层级
生成 Mipmap
c
glBindTexture(GL_TEXTURE_2D, textureId);
glTexImage2D(...); // 上传原始纹理
glGenerateMipmap(GL_TEXTURE_2D); // 自动生成所有 Mipmap 层级
Mipmap 过滤模式
plaintext
GL_NEAREST_MIPMAP_NEAREST:
- 选择最近的 Mipmap 层级
- 在该层级使用最近邻采样
GL_LINEAR_MIPMAP_NEAREST:
- 选择最近的 Mipmap 层级
- 在该层级使用线性采样
GL_NEAREST_MIPMAP_LINEAR:
- 在两个最近的 Mipmap 层级之间插值
- 每个层级使用最近邻采样
GL_LINEAR_MIPMAP_LINEAR(三线性过滤):
- 在两个最近的 Mipmap 层级之间插值
- 每个层级使用线性采样
- 最平滑,但性能开销最大
1.6.7 纹理单元
GPU 可以同时绑定多个纹理,每个纹理绑定到一个纹理单元(Texture Unit)。
plaintext
┌─────────────────────────────────────────────────┐
│ GPU │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 单元 0 │ │ 单元 1 │ │ 单元 2 │ ... │
│ │ (主纹理) │ │ (光照图) │ │ (法线图) │ │
│ │ Sampler0 │ │ Sampler1 │ │ Sampler2 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
使用多个纹理
c
// 激活纹理单元 0
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseTexture);
// 激活纹理单元 1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, lightmapTexture);
// 告诉着色器 Sampler0 使用单元 0,Sampler1 使用单元 1
glUniform1i(glGetUniformLocation(program, "Sampler0"), 0);
glUniform1i(glGetUniformLocation(program, "Sampler1"), 1);
在着色器中使用
glsl
uniform sampler2D Sampler0; // 主纹理
uniform sampler2D Sampler1; // 光照贴图
void main() {
vec4 diffuse = texture(Sampler0, texCoord);
vec4 light = texture(Sampler1, lightCoord);
fragColor = diffuse * light;
}
Minecraft 的纹理单元
| 单元 | 名称 | 用途 |
|---|---|---|
| 0 | Sampler0 | 主纹理(方块、实体纹理) |
| 1 | Sampler1 | 覆盖层(实体受伤变红) |
| 2 | Sampler2 | 光照贴图 |
1.6.8 纹理采样
在着色器中采样纹理:
texture() 函数
glsl
// 基本采样
vec4 color = texture(sampler, uv);
// sampler: 采样器(uniform sampler2D)
// uv: 纹理坐标(vec2)
// 返回: 采样到的颜色(vec4)
texelFetch() 函数
精确采样指定像素,不进行过滤:
glsl
// 精确像素采样
vec4 color = texelFetch(sampler, ivec2(x, y), lod);
// ivec2(x, y): 像素坐标(整数)
// lod: Mipmap 层级
// Minecraft 用这个采样光照贴图
vec4 light = texelFetch(Sampler2, UV2 / 16, 0);
textureSize() 函数
获取纹理尺寸:
glsl
ivec2 size = textureSize(sampler, 0); // 返回 (width, height)
1.6.9 纹理格式
常见格式
| 格式 | 通道 | 每像素字节 | 用途 |
|---|---|---|---|
| GL_RGB | R, G, B | 3 | 不透明图片 |
| GL_RGBA | R, G, B, A | 4 | 带透明度 |
| GL_RED | R | 1 | 灰度图 |
| GL_DEPTH_COMPONENT | 深度 | 2-4 | 深度纹理 |
内部格式
c
// 标准格式
GL_RGBA8 // 每通道 8 位,共 32 位
// 浮点格式(HDR)
GL_RGBA16F // 每通道 16 位浮点
GL_RGBA32F // 每通道 32 位浮点
// 深度格式
GL_DEPTH_COMPONENT24 // 24 位深度
GL_DEPTH_COMPONENT32F // 32 位浮点深度
1.6.10 Minecraft 中的纹理
TextureManager
kotlin
val textureManager = Minecraft.getInstance().textureManager
// 绑定纹理
textureManager.bindForSetup(ResourceLocation("minecraft", "textures/block/dirt.png"))
// 获取纹理对象
val texture = textureManager.getTexture(resourceLocation)
val textureId = texture.id // OpenGL 纹理 ID
RenderSystem 设置纹理
kotlin
// 设置着色器纹理
RenderSystem.setShaderTexture(0, resourceLocation) // 单元 0
RenderSystem.setShaderTexture(1, anotherTexture) // 单元 1
// 或者使用纹理 ID
RenderSystem.setShaderTexture(0, textureId)
纹理图集(Atlas)
Minecraft 把很多小纹理合并成大图集:
kotlin
// 方块纹理图集
val blockAtlas = Minecraft.getInstance()
.getTextureAtlas(InventoryMenu.BLOCK_ATLAS)
// 获取某个方块纹理的 UV
val sprite = blockAtlas.getSprite(ResourceLocation("minecraft", "block/dirt"))
val u0 = sprite.u0 // 左边 U
val u1 = sprite.u1 // 右边 U
val v0 = sprite.v0 // 上边 V
val v1 = sprite.v1 // 下边 V
小结
| 概念 | 说明 |
|---|---|
| 纹理 | 贴在模型表面的图片 |
| UV 坐标 | 顶点对应纹理的位置 (0-1) |
| 过滤模式 | NEAREST(像素化)/ LINEAR(平滑) |
| 环绕模式 | REPEAT / CLAMP 等 |
| Mipmap | 多级缩小纹理,防止闪烁 |
| 纹理单元 | 同时使用多个纹理 |
| 采样 | texture() / texelFetch() |
1.7 帧缓冲对象 (FBO)
本节目标:理解帧缓冲的概念,学会渲染到纹理,了解 MRT 多渲染目标
1.7.1 什么是帧缓冲?
帧缓冲(Framebuffer) 是渲染的目标,存储渲染结果的内存区域。
plaintext
渲染管线的输出 ──► 帧缓冲 ──► 显示到屏幕
默认情况下,渲染结果直接输出到默认帧缓冲(屏幕)。但我们可以创建自定义帧缓冲,把渲染结果输出到纹理。
1.7.2 为什么需要自定义帧缓冲?
后处理效果
plaintext
场景渲染 ──► FBO(纹理)──► 后处理着色器 ──► 屏幕
例如:
1. 先把场景渲染到纹理
2. 对纹理应用模糊效果
3. 把模糊后的结果显示到屏幕
这就是 Bloom、景深、运动模糊等效果的原理
阴影贴图
plaintext
从光源视角渲染深度 ──► 深度 FBO ──► 用于阴影计算
反射/折射
plaintext
渲染镜子中的场景 ──► FBO ──► 作为镜子的纹理
延迟渲染
plaintext
渲染几何信息 ──► 多个 FBO(位置、法线、颜色)──► 光照计算
1.7.3 帧缓冲的组成
一个帧缓冲可以有多个附件(Attachment):
plaintext
┌─────────────────────────────────────────────────────────┐
│ 帧缓冲对象 (FBO) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ 颜色附件 0 (Color Attachment 0) │
│ │ 颜色纹理/RBO │ 存储 RGBA 颜色 │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ 颜色附件 1 (Color Attachment 1) │
│ │ 颜色纹理/RBO │ 可选,用于 MRT │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ 深度附件 (Depth Attachment) │
│ │ 深度纹理/RBO │ 存储深度值,用于深度测试 │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ 模板附件 (Stencil Attachment) │
│ │ 模板纹理/RBO │ 存储模板值,用于模板测试 │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
附件类型
| 附件 | 用途 | 格式示例 |
|---|---|---|
| 颜色附件 | 存储像素颜色 | GL_RGBA8, GL_RGBA16F |
| 深度附件 | 存储深度值 | GL_DEPTH_COMPONENT24 |
| 模板附件 | 存储模板值 | GL_STENCIL_INDEX8 |
| 深度模板附件 | 同时存储深度和模板 | GL_DEPTH24_STENCIL8 |
纹理 vs 渲染缓冲对象 (RBO)
附件可以是纹理或渲染缓冲对象(RBO):
| 类型 | 可采样 | 用途 |
|---|---|---|
| 纹理 | ✓ | 需要在着色器中读取(后处理) |
| RBO | ✗ | 只需要存储,不需要读取(深度缓冲) |
RBO 性能可能更好,但不能在着色器中采样。
1.7.4 创建帧缓冲
完整流程
c
// ===== 1. 创建帧缓冲对象 =====
GLuint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// ===== 2. 创建颜色附件(纹理)=====
GLuint colorTexture;
glGenTextures(1, &colorTexture);
glBindTexture(GL_TEXTURE_2D, colorTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 附加到帧缓冲
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexture, 0);
// ===== 3. 创建深度附件(RBO)=====
GLuint depthRbo;
glGenRenderbuffers(1, &depthRbo);
glBindRenderbuffer(GL_RENDERBUFFER, depthRbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, width, height);
// 附加到帧缓冲
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRbo);
// ===== 4. 检查帧缓冲完整性 =====
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
// 错误!帧缓冲不完整
}
// ===== 5. 解绑(恢复默认帧缓冲)=====
glBindFramebuffer(GL_FRAMEBUFFER, 0);
帧缓冲完整性检查
帧缓冲必须满足以下条件才能使用:
- 至少有一个附件
- 所有附件都已完成初始化
- 所有附件尺寸相同
- 颜色附件格式正确
c
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
switch (status) {
case GL_FRAMEBUFFER_COMPLETE:
// 成功!
break;
case GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
// 附件不完整
break;
case GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
// 没有附件
break;
case GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER:
// 绘制缓冲配置错误
break;
// ... 其他错误
}
1.7.5 使用帧缓冲
渲染到 FBO
c
// 绑定自定义帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// 设置视口(匹配 FBO 尺寸)
glViewport(0, 0, fboWidth, fboHeight);
// 清除缓冲
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 渲染场景...
renderScene();
// 切回默认帧缓冲(屏幕)
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 恢复视口
glViewport(0, 0, screenWidth, screenHeight);
读取 FBO 内容
渲染到 FBO 后,颜色附件(纹理)就包含了渲染结果,可以:
c
// 方式1:在着色器中采样
glBindTexture(GL_TEXTURE_2D, colorTexture);
// 使用后处理着色器...
// 方式2:复制到默认帧缓冲
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(
0, 0, fboWidth, fboHeight, // 源区域
0, 0, screenWidth, screenHeight, // 目标区域
GL_COLOR_BUFFER_BIT, // 复制颜色
GL_LINEAR // 过滤模式
);
1.7.6 后处理示例
简单的后处理流程
plaintext
┌─────────────────────────────────────────────────────────────┐
│ 后处理流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 渲染场景到 FBO │
│ ┌─────────┐ │
│ │ 场景 │ ──► FBO (颜色纹理) │
│ └─────────┘ │
│ │
│ 2. 使用后处理着色器渲染全屏四边形 │
│ ┌─────────┐ ┌─────────────┐ ┌─────────┐ │
│ │ FBO纹理 │ ──► │ 后处理着色器 │ ──► │ 屏幕 │ │
│ └─────────┘ └─────────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
全屏四边形
后处理需要渲染一个覆盖整个屏幕的四边形:
c
// 顶点数据(NDC 坐标,覆盖整个屏幕)
float quadVertices[] = {
// 位置 // UV
-1.0f, 1.0f, 0.0f, 1.0f, // 左上
-1.0f, -1.0f, 0.0f, 0.0f, // 左下
1.0f, -1.0f, 1.0f, 0.0f, // 右下
-1.0f, 1.0f, 0.0f, 1.0f, // 左上
1.0f, -1.0f, 1.0f, 0.0f, // 右下
1.0f, 1.0f, 1.0f, 1.0f, // 右上
};
后处理着色器示例
glsl
// 顶点着色器
#version 150
in vec2 Position;
in vec2 UV;
out vec2 texCoord;
void main() {
gl_Position = vec4(Position, 0.0, 1.0);
texCoord = UV;
}
// 片段着色器(反色效果)
#version 150
in vec2 texCoord;
uniform sampler2D screenTexture;
out vec4 fragColor;
void main() {
vec4 color = texture(screenTexture, texCoord);
fragColor = vec4(1.0 - color.rgb, color.a); // 反色
}
1.7.7 MRT(多渲染目标)
MRT(Multiple Render Targets) 允许片段着色器同时输出到多个颜色附件。
为什么需要 MRT?
plaintext
传统方式(多 Pass):
Pass 1: 渲染颜色 ──► FBO1
Pass 2: 渲染法线 ──► FBO2
Pass 3: 渲染深度 ──► FBO3
需要渲染 3 次!
MRT 方式(单 Pass):
Pass 1: 同时输出颜色、法线、深度 ──► FBO (3个颜色附件)
只需要渲染 1 次!
设置 MRT
c
// 创建帧缓冲
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// 创建多个颜色附件
GLuint textures[3];
glGenTextures(3, textures);
for (int i = 0; i < 3; i++) {
glBindTexture(GL_TEXTURE_2D, textures[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0, GL_RGBA, GL_FLOAT, NULL);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, textures[i], 0);
}
// 指定绘制缓冲
GLenum drawBuffers[] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, drawBuffers);
MRT 片段着色器
glsl
#version 150
in vec3 fragPos;
in vec3 fragNormal;
in vec2 texCoord;
uniform sampler2D diffuseTexture;
// 多个输出
layout(location = 0) out vec4 gColor; // 颜色
layout(location = 1) out vec4 gNormal; // 法线
layout(location = 2) out vec4 gPosition; // 位置
void main() {
gColor = texture(diffuseTexture, texCoord);
gNormal = vec4(normalize(fragNormal), 1.0);
gPosition = vec4(fragPos, 1.0);
}
1.7.8 深度纹理
有时需要在着色器中读取深度值(阴影、SSAO 等)。
创建深度纹理
c
GLuint depthTexture;
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, width, height, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
// 附加到帧缓冲
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTexture, 0);
采样深度纹理
glsl
uniform sampler2D depthTexture;
void main() {
float depth = texture(depthTexture, texCoord).r; // 深度值在 r 通道
// depth 范围是 [0, 1],0 = 近平面,1 = 远平面
}
线性化深度
透视投影的深度是非线性的,需要转换:
glsl
float linearizeDepth(float depth, float near, float far) {
float z = depth * 2.0 - 1.0; // 转换到 [-1, 1]
return (2.0 * near * far) / (far + near - z * (far - near));
}
1.7.9 Minecraft 的 RenderTarget
Minecraft 封装了帧缓冲为 RenderTarget 类:
创建 RenderTarget
kotlin
// 创建带深度缓冲的 RenderTarget
val target = RenderTarget(width, height, true, Minecraft.ON_OSX)
// 参数:宽度, 高度, 是否使用深度, 是否在 Mac 上
// 或者使用 MainTarget(主渲染目标)
val mainTarget = Minecraft.getInstance().mainRenderTarget
RenderTarget 常用方法
kotlin
// 绑定为渲染目标
target.bindWrite(true) // true = 同时设置视口
// 解绑
target.unbindWrite()
// 绑定为读取源
target.bindRead()
// 清除
target.clear(Minecraft.ON_OSX)
// 复制到屏幕
target.blitToScreen(screenWidth, screenHeight)
// 获取纹理 ID
val colorTextureId = target.colorTextureId
val depthBufferId = target.depthBufferId
// 调整大小
target.resize(newWidth, newHeight, Minecraft.ON_OSX)
// 复制深度
target.copyDepthFrom(otherTarget)
// 设置清除颜色
target.setClearColor(r, g, b, a)
// 设置过滤模式
target.setFilterMode(GlConst.GL_LINEAR) // 或 GL_NEAREST
渲染到 RenderTarget
kotlin
// 保存当前状态
val previousTarget = Minecraft.getInstance().mainRenderTarget
// 绑定自定义目标
myTarget.bindWrite(true)
myTarget.clear(Minecraft.ON_OSX)
// 渲染...
renderSomething()
// 恢复
previousTarget.bindWrite(true)
1.7.10 HDR 帧缓冲
标准帧缓冲使用 8 位颜色(0-255),无法表示超过 1.0 的亮度。
HDR(High Dynamic Range) 帧缓冲使用浮点格式:
c
// 标准格式(LDR)
glTexImage2D(..., GL_RGBA8, ...); // 每通道 8 位,范围 [0, 1]
// HDR 格式
glTexImage2D(..., GL_RGBA16F, ...); // 每通道 16 位浮点,范围无限
glTexImage2D(..., GL_RGBA32F, ...); // 每通道 32 位浮点,更精确
HDR 的用途
HDR 渲染流程:
1. 渲染到 HDR 帧缓冲(颜色可以超过 1.0)
2. 提取高亮部分做 Bloom
3. 色调映射(Tone Mapping):HDR → LDR
4. 输出到屏幕
小结
| 概念 | 说明 |
|---|---|
| 帧缓冲 (FBO) | 渲染目标,可以是屏幕或纹理 |
| 颜色附件 | 存储像素颜色 |
| 深度附件 | 存储深度值 |
| RBO | 渲染缓冲对象,不可采样 |
| MRT | 多渲染目标,一次输出多个纹理 |
| 后处理 | 渲染到纹理,再处理 |
| HDR | 高动态范围,浮点格式 |
后处理流程:
场景 ──► FBO ──► 后处理着色器 ──► 屏幕
1.8 混合与测试
本节目标:理解深度测试、模板测试和混合模式的原理与应用
1.8.1 片段测试概述
在片段着色器输出颜色后,片段还要经过一系列测试才能写入帧缓冲:
plaintext
片段着色器输出
│
▼
┌─────────────┐
│ 裁剪测试 │ 片段是否在裁剪区域内?
└──────┬──────┘
│
▼
┌─────────────┐
│ 模板测试 │ 模板值是否匹配?
└──────┬──────┘
│
▼
┌─────────────┐
│ 深度测试 │ 片段是否比现有像素更近?
└──────┬──────┘
│
▼
┌─────────────┐
│ 混合 │ 如何与现有颜色混合?
└──────┬──────┘
│
▼
帧缓冲
任何一个测试失败,片段就会被丢弃。
1.8.2 深度测试 (Depth Test)
深度测试决定"谁在前面"。
深度缓冲
深度缓冲存储每个像素的深度值(到相机的距离):
plaintext
颜色缓冲: 深度缓冲:
┌─────────────────┐ ┌─────────────────┐
│ ████████████████│ │ 0.5 0.5 0.3 0.3 │
│ ████████████████│ │ 0.5 0.5 0.3 0.3 │
│ ████████████████│ │ 0.8 0.8 0.8 0.8 │
│ ████████████████│ │ 0.8 0.8 0.8 0.8 │
└─────────────────┘ └─────────────────┘
深度值:0.0 = 最近,1.0 = 最远
深度测试流程
plaintext
新片段深度 = 0.3
现有深度 = 0.5
深度函数 = GL_LESS(小于则通过)
0.3 < 0.5 → 测试通过!
新片段写入颜色缓冲
新深度写入深度缓冲
深度函数
c
glDepthFunc(func);
// 可选函数:
GL_NEVER // 永不通过
GL_LESS // 新深度 < 现有深度(默认)
GL_EQUAL // 新深度 == 现有深度
GL_LEQUAL // 新深度 <= 现有深度
GL_GREATER // 新深度 > 现有深度
GL_NOTEQUAL // 新深度 != 现有深度
GL_GEQUAL // 新深度 >= 现有深度
GL_ALWAYS // 总是通过
启用/禁用深度测试
c
glEnable(GL_DEPTH_TEST); // 启用
glDisable(GL_DEPTH_TEST); // 禁用
深度写入
可以只测试不写入:
c
glDepthMask(GL_TRUE); // 允许写入深度(默认)
glDepthMask(GL_FALSE); // 禁止写入深度
// 用途:渲染半透明物体时,只测试不写入
// 这样后面的半透明物体也能正确混合
Minecraft 中的深度测试
kotlin
// 启用深度测试
RenderSystem.enableDepthTest()
RenderSystem.depthFunc(GlConst.GL_LEQUAL)
// 禁用深度测试(GUI 渲染)
RenderSystem.disableDepthTest()
// 深度写入
RenderSystem.depthMask(true) // 允许
RenderSystem.depthMask(false) // 禁止
1.8.3 深度冲突 (Z-Fighting)
当两个面非常接近时,深度值可能相同,导致闪烁:
两个面几乎重叠:
┌─────────────────┐
│ ▓░▓░▓░▓░▓░▓░▓░ │ ← 闪烁!
│ ░▓░▓░▓░▓░▓░▓░▓ │
└─────────────────┘
解决方案
1. 多边形偏移
c
glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(factor, units);
// factor: 根据斜率偏移
// units: 固定偏移量
// 渲染第二个面时稍微偏移
glPolygonOffset(1.0, 1.0);
2. 增加深度精度
c
// 使用更大的近平面距离
// 近平面越近,远处精度越低
gluPerspective(fov, aspect, 0.1, 1000.0); // 近平面 0.1
gluPerspective(fov, aspect, 1.0, 1000.0); // 近平面 1.0,精度更好
3. 对数深度缓冲
在着色器中使用对数深度,提高远处精度。
1.8.4 模板测试 (Stencil Test)
模板测试使用模板缓冲来控制哪些像素可以渲染。
模板缓冲
模板缓冲存储每个像素的模板值(通常 8 位,0-255):
模板缓冲:
┌─────────────────┐
│ 0 0 0 0 0 0 0 0 │
│ 0 0 1 1 1 1 0 0 │ ← 1 的区域是"镜子"
│ 0 0 1 1 1 1 0 0 │
│ 0 0 0 0 0 0 0 0 │
└─────────────────┘
只有模板值为 1 的像素才渲染镜子中的反射
模板测试配置
c
glEnable(GL_STENCIL_TEST);
// 设置模板函数
glStencilFunc(func, ref, mask);
// func: 比较函数(GL_EQUAL, GL_NOTEQUAL 等)
// ref: 参考值
// mask: 掩码
// 设置模板操作
glStencilOp(sfail, dpfail, dppass);
// sfail: 模板测试失败时的操作
// dpfail: 模板通过但深度失败时的操作
// dppass: 都通过时的操作
// 操作选项:
GL_KEEP // 保持不变
GL_ZERO // 设为 0
GL_REPLACE // 设为 ref
GL_INCR // 增加 1
GL_DECR // 减少 1
GL_INVERT // 按位取反
模板测试示例:轮廓描边
c
// 第一遍:正常渲染物体,写入模板值 1
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
renderObject();
// 第二遍:渲染放大的物体,只在模板值不为 1 的地方渲染
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
renderScaledObject(); // 稍微放大,用纯色渲染
模板测试示例:镜子反射
c
// 第一遍:渲染镜子,写入模板值 1
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); // 不写颜色
renderMirror();
// 第二遍:渲染反射场景,只在模板值为 1 的地方渲染
glStencilFunc(GL_EQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
renderReflectedScene();
1.8.5 混合 (Blending)
混合决定新片段颜色如何与现有颜色组合。
混合方程
最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子
源颜色 (Source): 新片段的颜色
目标颜色 (Destination): 帧缓冲中现有的颜色
启用混合
c
glEnable(GL_BLEND);
glBlendFunc(srcFactor, dstFactor);
混合因子
| 因子 | 值 |
|---|---|
| GL_ZERO | (0, 0, 0, 0) |
| GL_ONE | (1, 1, 1, 1) |
| GL_SRC_COLOR | (Rs, Gs, Bs, As) |
| GL_ONE_MINUS_SRC_COLOR | (1-Rs, 1-Gs, 1-Bs, 1-As) |
| GL_DST_COLOR | (Rd, Gd, Bd, Ad) |
| GL_ONE_MINUS_DST_COLOR | (1-Rd, 1-Gd, 1-Bd, 1-Ad) |
| GL_SRC_ALPHA | (As, As, As, As) |
| GL_ONE_MINUS_SRC_ALPHA | (1-As, 1-As, 1-As, 1-As) |
| GL_DST_ALPHA | (Ad, Ad, Ad, Ad) |
| GL_ONE_MINUS_DST_ALPHA | (1-Ad, 1-Ad, 1-Ad, 1-Ad) |
常用混合模式
标准透明混合
c
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// 公式:
// 最终 = 源 × 源Alpha + 目标 × (1 - 源Alpha)
// 示例:
// 源颜色 = (1.0, 0.0, 0.0, 0.5) 红色,50% 透明
// 目标颜色 = (0.0, 0.0, 1.0, 1.0) 蓝色
// 最终 = (1,0,0) × 0.5 + (0,0,1) × 0.5 = (0.5, 0, 0.5) 紫色
加法混合(发光效果)
c
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
// 公式:
// 最终 = 源 × 源Alpha + 目标 × 1
// 颜色会叠加,越来越亮
// 适合:火焰、光晕、粒子
乘法混合(阴影效果)
c
glBlendFunc(GL_DST_COLOR, GL_ZERO);
// 公式:
// 最终 = 源 × 目标颜色 + 目标 × 0 = 源 × 目标
// 颜色相乘,越来越暗
// 适合:阴影、暗角
预乘 Alpha
c
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
// 源颜色已经乘以了 Alpha
// 避免边缘出现黑边
分离混合
RGB 和 Alpha 可以使用不同的混合函数:
c
glBlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha);
// 示例:
glBlendFuncSeparate(
GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, // RGB 混合
GL_ONE, GL_ONE_MINUS_SRC_ALPHA // Alpha 混合
);
混合方程
除了加法,还可以使用其他方程:
c
glBlendEquation(mode);
GL_FUNC_ADD // 源 + 目标(默认)
GL_FUNC_SUBTRACT // 源 - 目标
GL_FUNC_REVERSE_SUBTRACT // 目标 - 源
GL_MIN // min(源, 目标)
GL_MAX // max(源, 目标)
Minecraft 中的混合
kotlin
// 启用混合
RenderSystem.enableBlend()
// 默认混合(标准透明)
RenderSystem.defaultBlendFunc()
// 等同于 blendFuncSeparate(SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA)
// 自定义混合
RenderSystem.blendFunc(srcFactor, dstFactor)
RenderSystem.blendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha)
// 禁用混合
RenderSystem.disableBlend()
1.8.6 透明物体渲染顺序
混合是顺序相关的!必须从后往前渲染透明物体。
问题
plaintext
错误顺序(前到后):
1. 渲染前面的玻璃 A
2. 渲染后面的玻璃 B
→ B 被深度测试丢弃!看不到 B
正确顺序(后到前):
1. 渲染后面的玻璃 B
2. 渲染前面的玻璃 A
→ A 和 B 正确混合
解决方案
plaintext
渲染流程:
1. 渲染所有不透明物体(任意顺序,深度测试开启)
2. 关闭深度写入
3. 按距离排序透明物体(从远到近)
4. 渲染透明物体
5. 恢复深度写入
c
// 1. 渲染不透明物体
glDepthMask(GL_TRUE);
glDisable(GL_BLEND);
renderOpaqueObjects();
// 2. 渲染透明物体
glDepthMask(GL_FALSE); // 关闭深度写入
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// 排序并渲染
sortByDistance(transparentObjects); // 从远到近
for (obj : transparentObjects) {
render(obj);
}
// 3. 恢复
glDepthMask(GL_TRUE);
Minecraft 的处理
Minecraft 使用多个 RenderType 来处理渲染顺序:
plaintext
1. solid() - 不透明方块
2. cutout() - 镂空方块(树叶)
3. translucent() - 半透明方块(水、冰)
translucent 会按距离排序
1.8.7 裁剪测试 (Scissor Test)
裁剪测试限制渲染区域。
c
glEnable(GL_SCISSOR_TEST);
glScissor(x, y, width, height);
// 只有在裁剪矩形内的片段才会被渲染
// 用于:GUI 裁剪、分屏渲染
Minecraft 中的裁剪
kotlin
// 启用裁剪
RenderSystem.enableScissor(x, y, width, height)
// 禁用裁剪
RenderSystem.disableScissor()
1.8.8 面剔除 (Face Culling)
面剔除跳过背对相机的三角形。
plaintext
正面(逆时针):渲染
背面(顺时针):跳过
┌───────┐
╱│ ╱│
╱ │ ╱ │
┌──┼────┐ │
│ └────┼──┘
│ ╱ │ ╱ ← 背面被剔除,节省性能
│╱ │╱
└───────┘
配置面剔除
c
glEnable(GL_CULL_FACE);
// 剔除哪个面
glCullFace(GL_BACK); // 剔除背面(默认)
glCullFace(GL_FRONT); // 剔除正面
glCullFace(GL_FRONT_AND_BACK); // 都剔除(只剩线框)
// 定义正面
glFrontFace(GL_CCW); // 逆时针为正面(默认)
glFrontFace(GL_CW); // 顺时针为正面
Minecraft 中的面剔除
kotlin
RenderSystem.enableCull() // 启用
RenderSystem.disableCull() // 禁用
// 渲染双面物体(如树叶)时禁用剔除
1.8.9 颜色写入遮罩
控制哪些颜色通道可以写入:
c
glColorMask(red, green, blue, alpha);
// 示例:
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); // 全部写入
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); // 不写颜色(只写深度/模板)
glColorMask(GL_TRUE, GL_FALSE, GL_FALSE, GL_FALSE); // 只写红色通道
Minecraft 中的颜色遮罩
kotlin
RenderSystem.colorMask(red, green, blue, alpha)
1.8.10 完整渲染状态示例
kotlin
// 渲染半透明粒子的典型设置
// 1. 深度测试:开启,但不写入
RenderSystem.enableDepthTest()
RenderSystem.depthFunc(GlConst.GL_LEQUAL)
RenderSystem.depthMask(false)
// 2. 混合:加法混合(发光效果)
RenderSystem.enableBlend()
RenderSystem.blendFunc(GlConst.GL_SRC_ALPHA, GlConst.GL_ONE)
// 3. 面剔除:关闭(粒子是双面的)
RenderSystem.disableCull()
// 4. 渲染粒子...
renderParticles()
// 5. 恢复状态
RenderSystem.depthMask(true)
RenderSystem.defaultBlendFunc()
RenderSystem.enableCull()
小结
| 测试/操作 | 作用 | 常用场景 |
|---|---|---|
| 深度测试 | 决定谁在前面 | 3D 场景 |
| 深度写入 | 是否更新深度缓冲 | 透明物体关闭 |
| 模板测试 | 限制渲染区域 | 镜子、轮廓 |
| 混合 | 颜色叠加 | 透明、发光 |
| 裁剪测试 | 限制渲染矩形 | GUI |
| 面剔除 | 跳过背面 | 性能优化 |
常用混合模式速查
| 效果 | srcFactor | dstFactor |
|---|---|---|
| 标准透明 | SRC_ALPHA | ONE_MINUS_SRC_ALPHA |
| 加法(发光) | SRC_ALPHA | ONE |
| 乘法(阴影) | DST_COLOR | ZERO |
| 预乘 Alpha | ONE | ONE_MINUS_SRC_ALPHA |
1.9 OpenGL 状态机
本节目标:理解 OpenGL 的状态机模型,学会正确管理渲染状态
1.9.1 什么是状态机?
OpenGL 是一个状态机。这意味着:
- OpenGL 维护着大量的状态变量
- 这些状态会一直保持,直到你显式修改它们
- 渲染操作会使用当前的状态
plaintext
┌─────────────────────────────────────────────────────────────┐
│ OpenGL 状态机 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 当前绑定的 VAO: 5 │
│ 当前绑定的 VBO: 12 │
│ 当前绑定的纹理: [单元0: 纹理3, 单元1: 纹理7, ...] │
│ 当前绑定的 FBO: 0 (默认) │
│ 当前着色器程序: 8 │
│ │
│ 深度测试: 启用 │
│ 深度函数: GL_LESS │
│ 深度写入: 启用 │
│ │
│ 混合: 禁用 │
│ 混合函数: (GL_ONE, GL_ZERO) │
│ │
│ 面剔除: 启用 │
│ 剔除面: GL_BACK │
│ │
│ 视口: (0, 0, 1920, 1080) │
│ 裁剪区域: (0, 0, 1920, 1080) │
│ │
│ ... 还有很多其他状态 ... │
│ │
└─────────────────────────────────────────────────────────────┘
1.9.2 状态的持久性
状态会一直保持,这可能导致问题:
c
// 函数 A:渲染发光粒子
void renderGlowParticles() {
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE); // 加法混合
// 渲染...
// 忘记恢复状态!
}
// 函数 B:渲染普通物体
void renderNormalObjects() {
// 期望混合是关闭的,但实际上还是开启的!
// 期望混合函数是默认的,但实际上是加法混合!
// 渲染结果错误!
}
解决方案:状态管理
c
// 方案1:用完恢复
void renderGlowParticles() {
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
// 渲染...
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // 恢复默认
glDisable(GL_BLEND); // 恢复
}
// 方案2:使用前设置
void renderNormalObjects() {
glDisable(GL_BLEND); // 确保混合关闭
// 渲染...
}
// 方案3:保存/恢复(见下文)
1.9.3 状态查询
可以查询当前状态:
c
// 查询布尔状态
GLboolean blendEnabled = glIsEnabled(GL_BLEND);
GLboolean depthEnabled = glIsEnabled(GL_DEPTH_TEST);
// 查询整数状态
GLint currentProgram;
glGetIntegerv(GL_CURRENT_PROGRAM, ¤tProgram);
GLint viewport[4];
glGetIntegerv(GL_VIEWPORT, viewport);
GLint boundTexture;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &boundTexture);
// 查询浮点状态
GLfloat clearColor[4];
glGetFloatv(GL_COLOR_CLEAR_VALUE, clearColor);
// 查询混合函数
GLint srcFactor, dstFactor;
glGetIntegerv(GL_BLEND_SRC_RGB, &srcFactor);
glGetIntegerv(GL_BLEND_DST_RGB, &dstFactor);
注意:查询有性能开销
频繁查询状态会影响性能,因为需要 CPU-GPU 同步。最好自己跟踪状态。
1.9.4 状态保存与恢复
手动保存/恢复
c
// 保存状态
GLboolean wasBlendEnabled = glIsEnabled(GL_BLEND);
GLint blendSrc, blendDst;
glGetIntegerv(GL_BLEND_SRC_RGB, &blendSrc);
glGetIntegerv(GL_BLEND_DST_RGB, &blendDst);
// 修改状态
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
// 渲染...
// 恢复状态
if (wasBlendEnabled) {
glEnable(GL_BLEND);
} else {
glDisable(GL_BLEND);
}
glBlendFunc(blendSrc, blendDst);
使用 glPushAttrib/glPopAttrib(已废弃)
c
// 老版本 OpenGL 有这个功能,但现代 OpenGL 已废弃
glPushAttrib(GL_ENABLE_BIT | GL_COLOR_BUFFER_BIT);
// 修改状态...
glPopAttrib(); // 恢复
现代方法:自己管理
现代 OpenGL 推荐自己跟踪和管理状态,而不是依赖查询。
1.9.5 常见状态分类
启用/禁用状态
c
glEnable(cap) / glDisable(cap)
// 常用的 cap:
GL_DEPTH_TEST // 深度测试
GL_STENCIL_TEST // 模板测试
GL_BLEND // 混合
GL_CULL_FACE // 面剔除
GL_SCISSOR_TEST // 裁剪测试
GL_POLYGON_OFFSET_FILL // 多边形偏移
GL_MULTISAMPLE // 多重采样
绑定状态
c
// 纹理
glActiveTexture(GL_TEXTURE0 + unit);
glBindTexture(GL_TEXTURE_2D, textureId);
// 缓冲
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, eboId);
// VAO
glBindVertexArray(vaoId);
// 帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, fboId);
// 着色器
glUseProgram(programId);
参数状态
c
// 深度
glDepthFunc(func);
glDepthMask(flag);
glDepthRange(near, far);
// 混合
glBlendFunc(src, dst);
glBlendFuncSeparate(srcRGB, dstRGB, srcA, dstA);
glBlendEquation(mode);
glBlendColor(r, g, b, a);
// 模板
glStencilFunc(func, ref, mask);
glStencilOp(sfail, dpfail, dppass);
glStencilMask(mask);
// 面剔除
glCullFace(mode);
glFrontFace(mode);
// 视口
glViewport(x, y, width, height);
glScissor(x, y, width, height);
// 颜色
glColorMask(r, g, b, a);
glClearColor(r, g, b, a);
// 多边形
glPolygonMode(face, mode);
glPolygonOffset(factor, units);
glLineWidth(width);
glPointSize(size);
1.9.6 Minecraft 的状态管理
RenderSystem
Minecraft 通过 RenderSystem 类封装了 OpenGL 状态管理:
kotlin
// ===== 深度测试 =====
RenderSystem.enableDepthTest()
RenderSystem.disableDepthTest()
RenderSystem.depthFunc(func)
RenderSystem.depthMask(flag)
// ===== 混合 =====
RenderSystem.enableBlend()
RenderSystem.disableBlend()
RenderSystem.blendFunc(src, dst)
RenderSystem.blendFuncSeparate(srcRGB, dstRGB, srcA, dstA)
RenderSystem.defaultBlendFunc() // 标准透明混合
// ===== 面剔除 =====
RenderSystem.enableCull()
RenderSystem.disableCull()
// ===== 多边形偏移 =====
RenderSystem.enablePolygonOffset()
RenderSystem.disablePolygonOffset()
RenderSystem.polygonOffset(factor, units)
// ===== 颜色 =====
RenderSystem.colorMask(r, g, b, a)
RenderSystem.clearColor(r, g, b, a)
RenderSystem.clear(mask, checkError)
// ===== 视口 =====
RenderSystem.viewport(x, y, width, height)
// ===== 裁剪 =====
RenderSystem.enableScissor(x, y, width, height)
RenderSystem.disableScissor()
// ===== 着色器 =====
RenderSystem.setShader(shaderSupplier)
RenderSystem.setShaderTexture(unit, texture)
RenderSystem.setShaderColor(r, g, b, a)
// ===== 雾 =====
RenderSystem.setShaderFogStart(start)
RenderSystem.setShaderFogEnd(end)
RenderSystem.setShaderFogColor(r, g, b, a)
RenderSystem.setShaderFogShape(shape)
// ===== 线程安全 =====
RenderSystem.isOnRenderThread()
RenderSystem.assertOnRenderThread()
RenderSystem.recordRenderCall(runnable) // 延迟到渲染线程执行
GlStateManager
GlStateManager 是更底层的封装,直接对应 OpenGL 函数:
kotlin
// 纹理
GlStateManager._glGenTextures()
GlStateManager._glDeleteTextures(id)
GlStateManager._glBindTexture(id)
GlStateManager._texImage2D(target, level, internalFormat, width, height, border, format, type, pixels)
// 帧缓冲
GlStateManager._glGenFramebuffers()
GlStateManager._glDeleteFramebuffers(id)
GlStateManager._glBindFramebuffer(target, id)
GlStateManager._glFramebufferTexture2D(target, attachment, textarget, texture, level)
GlStateManager._glCheckFramebufferStatus(target)
// 缓冲
GlStateManager._glGenBuffers()
GlStateManager._glDeleteBuffers(id)
GlStateManager._glBindBuffer(target, id)
GlStateManager._glBufferData(target, data, usage)
// VAO
GlStateManager._glGenVertexArrays()
GlStateManager._glDeleteVertexArrays(id)
GlStateManager._glBindVertexArray(id)
// 着色器
GlStateManager._glUseProgram(program)
GlStateManager._glUniform1i(location, value)
GlStateManager._glUniform1f(location, value)
GlStateManager._glUniformMatrix4fv(location, transpose, matrix)
// 绘制
GlStateManager._glDrawElements(mode, count, type, indices)
GlStateManager._glDrawArrays(mode, first, count)
// 清除
GlStateManager._clearColor(r, g, b, a)
GlStateManager._clearDepth(depth)
GlStateManager._clear(mask, checkError)
// 视口
GlStateManager._viewport(x, y, width, height)
// 裁剪
GlStateManager._enableScissorTest()
GlStateManager._disableScissorTest()
GlStateManager._scissorBox(x, y, width, height)
1.9.7 状态管理的最佳实践
1. 最小化状态切换
状态切换有开销,尽量减少:
kotlin
// 不好:频繁切换
for (particle in particles) {
RenderSystem.setShaderTexture(0, particle.texture) // 每个粒子都切换纹理
render(particle)
}
// 好:按纹理分组
val grouped = particles.groupBy { it.texture }
for ((texture, group) in grouped) {
RenderSystem.setShaderTexture(0, texture) // 每组只切换一次
for (particle in group) {
render(particle)
}
}
2. 使用MC的 RenderType
RenderType 封装了一组渲染状态,自动管理:
kotlin
// RenderType 会自动设置正确的状态
val renderType = RenderType.translucent()
// 开始渲染时设置状态
renderType.setupRenderState()
// 渲染...
// 结束时清理状态
renderType.clearRenderState()
3. 状态栈模式
对于复杂的渲染,使用栈来管理状态:
kotlin
class RenderStateStack {
private val stack = ArrayDeque<RenderState>()
fun push() {
stack.push(captureCurrentState())
}
fun pop() {
val state = stack.pop()
restoreState(state)
}
}
// 使用
stateStack.push()
// 修改状态,渲染...
stateStack.pop() // 恢复
4. 渲染前重置关键状态
在渲染开始时,确保关键状态是已知的:
kotlin
fun beginRender() {
// 重置到已知状态
RenderSystem.enableDepthTest()
RenderSystem.depthFunc(GlConst.GL_LEQUAL)
RenderSystem.depthMask(true)
RenderSystem.disableBlend()
RenderSystem.enableCull()
RenderSystem.colorMask(true, true, true, true)
}
1.9.8 调试状态问题
常见问题
| 症状 | 可能原因 |
|---|---|
| 物体不显示 | 深度测试失败、面剔除、颜色遮罩 |
| 物体全黑 | 纹理未绑定、着色器错误 |
| 透明不正确 | 混合未启用、混合函数错误 |
| 渲染顺序错误 | 深度测试/写入配置错误 |
| 只有部分显示 | 裁剪测试、视口设置 |
调试技巧
kotlin
// 1. 打印当前状态
fun debugState() {
println("Blend: ${GL11.glIsEnabled(GL11.GL_BLEND)}")
println("Depth: ${GL11.glIsEnabled(GL11.GL_DEPTH_TEST)}")
println("Cull: ${GL11.glIsEnabled(GL11.GL_CULL_FACE)}")
val viewport = IntArray(4)
GL11.glGetIntegerv(GL11.GL_VIEWPORT, viewport)
println("Viewport: ${viewport.contentToString()}")
}
// 2. 使用 RenderDoc 等工具
// 可以捕获帧,查看每个绘制调用的状态
// 3. 逐步排除
// 禁用各种测试,看问题是否消失
RenderSystem.disableDepthTest()
RenderSystem.disableCull()
RenderSystem.disableBlend()
1.9.9 线程安全
OpenGL 上下文绑定到特定线程,只能在该线程调用 OpenGL 函数。
kotlin
// 检查是否在渲染线程
if (RenderSystem.isOnRenderThread()) {
// 可以直接调用
RenderSystem.setShaderTexture(0, texture)
} else {
// 需要延迟到渲染线程
RenderSystem.recordRenderCall {
RenderSystem.setShaderTexture(0, texture)
}
}
// 或者使用断言
RenderSystem.assertOnRenderThread() // 不在渲染线程会抛异常
小结
| 概念 | 说明 |
|---|---|
| 状态机 | OpenGL 维护全局状态,影响后续操作 |
| 状态持久性 | 状态保持直到显式修改 |
| 状态查询 | glGet* 函数,有性能开销 |
| RenderSystem | Minecraft 的状态管理封装 |
| GlStateManager | 更底层的 OpenGL 封装 |
| RenderType | 封装一组渲染状态 |
状态管理原则
- 用完恢复:修改状态后恢复原状
- 使用前设置:不依赖之前的状态
- 最小化切换:按状态分组渲染
- 使用封装:RenderType 自动管理状态
第一章总结
应理解:
- GPU 和渲染 - 为什么需要 GPU,渲染是什么
- 渲染管线 - 顶点如何变成像素
- 顶点和属性 - VBO、VAO、顶点格式
- 着色器和 GLSL - 可编程管线,着色器语言
- 矩阵变换 - MVP 矩阵,坐标空间
- 纹理系统 - UV 映射,采样,Mipmap
- 帧缓冲 - FBO,渲染到纹理,MRT
- 混合与测试 - 深度测试,混合模式
- 状态机 - OpenGL 状态管理
下一章将学习 Minecraft 渲染演进史,了解从老版本立即模式到现代 Blaze3D 的变化,并穿插一些Arc3D的知识。
全部评论 (0)
暂无评论,快来抢沙发吧~