现代Minecraft渲染导论 第一章:OpenGL基础(线性代数&计算机图形学)

前言

由于网络上有关于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 的世界里,一切都是三角形

为什么是三角形?

  1. 三角形永远是平面 - 三个点确定一个平面
  2. 三角形永远是凸的 - 不会有凹进去的部分
  3. 任何形状都能用三角形拼出来 - 包括圆形(用很多小三角形近似)
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)

顶点着色器你可以编写代码控制的阶段。

它对每个顶点执行一次,主要任务:

  1. 坐标变换:把顶点从模型空间变换到屏幕空间(也叫做视口变换)
  2. 传递数据:把颜色、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)

为什么需要第四个分量?

  1. 区分点和向量

    复制代码
    点:   (x, y, z, 1)  → 可以被平移
    向量: (x, y, z, 0)  → 不能被平移(方向不受位置影响)
  2. 统一表示平移

    • 3×3 矩阵无法表示平移
    • 4×4 矩阵可以!
  3. 透视除法

    • 透视投影后,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 封装一组渲染状态

状态管理原则

  1. 用完恢复:修改状态后恢复原状
  2. 使用前设置:不依赖之前的状态
  3. 最小化切换:按状态分组渲染
  4. 使用封装:RenderType 自动管理状态

第一章总结

应理解:

  1. GPU 和渲染 - 为什么需要 GPU,渲染是什么
  2. 渲染管线 - 顶点如何变成像素
  3. 顶点和属性 - VBO、VAO、顶点格式
  4. 着色器和 GLSL - 可编程管线,着色器语言
  5. 矩阵变换 - MVP 矩阵,坐标空间
  6. 纹理系统 - UV 映射,采样,Mipmap
  7. 帧缓冲 - FBO,渲染到纹理,MRT
  8. 混合与测试 - 深度测试,混合模式
  9. 状态机 - OpenGL 状态管理

下一章将学习 Minecraft 渲染演进史,了解从老版本立即模式到现代 Blaze3D 的变化,并穿插一些Arc3D的知识。

游客

全部评论 (0)

暂无评论,快来抢沙发吧~