win32_game.cpp: 禁用 PFD_DOUBLEBUFFER
我们正在处理一个新的开发阶段,目标是在使用 OpenGL 渲染的同时能正常通过 OBS 进行直播。昨天我们已经尝试了一整天来解决这个问题,希望能找到一种方式让 OBS 能正确地捕捉到 OpenGL 的窗口画面。虽然我们不确定是否已经彻底解决了问题,但今天打算继续推进,试试看现在的设定是否能正常直播和显示游戏画面。
当前的目标是让游戏的图像正确地通过 OpenGL 显示出来,并确保观众可以在直播中看到它。我们计划直接动手实施这个过程,看看是否有效。如果画面不能正常显示,那就只能接受直播偶尔出问题的现实。
现在我们要做的第一件事就是让直播能够正常运行。昨天的调查结果是:如果关闭 OpenGL 的双缓冲(Double Buffering)功能,OBS 就可以更可靠地捕捉窗口内容进行推流 。因此我们尝试在平台层代码中加入一个条件编译或运行时标志,例如 HANDMADE_STREAMING
这样的宏或变量。其作用是在开启该标志时,我们就不启用 PFD_DOUBLEBUFFER(双缓冲),而是改为只用单缓冲模式进行渲染。
我们还在注释中说明了这一点:PFD_DOUBLEBUFFER
(双缓冲)这个标志可能会阻止 OBS 正常地捕捉和推送窗口画面,这似乎是目前发现的导致问题的关键。
最终的希望是我们昨天的发现能适用于大多数情况,即:关闭双缓冲可以解决 OpenGL 画面无法被 OBS 正确捕捉的问题,从而实现直播与 OpenGL 渲染的兼容共存。接下来,我们就以这个设定继续开发和测试。
尝试一下并查看我们的粉红色窗口
我们现在来尝试一下当前的设定,运行程序后,屏幕上成功显示出了一个粉红色的窗口。接下来的问题是------大家到底能不能在直播画面中看到这个粉红窗口?是否有人看到的是黑屏?是粉红色?还是只是 Windows 的 Visual Studio 开发界面?
我们开始向观众确认当前直播画面中能否正确显示这个粉红窗口。最终反馈结果是,确实可以看到粉红色的窗口。这就说明我们通过关闭双缓冲所做的调整目前是成功的,OBS 能够正确捕捉并推送 OpenGL 渲染出来的内容。
这也验证了我们昨天的假设:如果关闭 PFD_DOUBLEBUFFER
,OBS 在捕捉窗口时就不会出问题,从而能在直播中正常显示 OpenGL 渲染的画面。我们的直播和图形显示终于可以同时运作,没有互相干扰。
这是一个非常有趣也常见的技术现象,背后涉及到 OpenGL 的渲染机制 和 OBS 的屏幕捕捉方式 之间的兼容性问题。我们来详细拆解为什么关闭 PFD_DOUBLEBUFFER
(双缓冲)后 OBS 就能正常显示画面。
一、双缓冲(PFD_DOUBLEBUFFER) 是什么?
在 OpenGL 或任何图形渲染系统中:
- 双缓冲 是指有两个缓冲区(Front Buffer 和 Back Buffer):
- Back Buffer(后缓冲):绘图时内容渲染在这个缓冲上;
- Front Buffer(前缓冲):屏幕实际显示的内容来自这里。
- 每一帧完成后,程序通过
SwapBuffers()
把后缓冲的内容"交换"到前缓冲,显示在屏幕上。
好处是:
- 画面不会撕裂;
- 能实现流畅、干净的帧切换。
二、OBS 是怎么捕捉窗口的?
OBS 在捕捉窗口内容时,会尝试读取 系统显示在屏幕上的帧缓冲内容,具体方式依赖于:
- 操作系统提供的 GDI、DWM 机制;
- 显卡驱动如何暴露前台窗口的显示缓冲内容;
- 有些时候是直接采集前缓冲区(Front Buffer)内容。
但问题就在这里:
当启用双缓冲时,OpenGL 默认把图像画到后缓冲里,而不是前缓冲。
如果你没调用
SwapBuffers()
,前缓冲就是空的,OBS 捕捉到的自然也是空白(黑屏)。
三、为什么关闭 PFD_DOUBLEBUFFER 反而 OBS 能显示?
当关闭了 PFD_DOUBLEBUFFER
:
- OpenGL 只使用一个缓冲区,也就是 单缓冲(Single Buffering);
- 所有渲染直接写入前缓冲;
- 因此 OBS 看到的就是你正在渲染的内容。
换句话说:
单缓冲 = 直接把画面画在屏幕上 = OBS 能捕捉到。
四、但是关闭双缓冲的副作用是什么?
虽然 OBS 可以显示,但这不是一个理想的解决方案。单缓冲会带来很多问题:
问题 | 说明 |
---|---|
撕裂(Tearing) | 渲染过程和显示过程重叠,可能看到"断裂"的画面 |
闪烁(Flickering) | 渲染未完成时屏幕就开始显示,会造成视觉跳动 |
性能波动 | 每次绘制都直接在屏幕上,GPU 没法优化渲染流程 |
正确方案是什么?
长远来看,更合适的方案是:
- 保持双缓冲;
- 在渲染完成后 确保调用
SwapBuffers()
; - 使用 OBS 的专门支持 OpenGL 的插件或硬件采集卡;
- 或者使用 帧缓冲对象(FBO) 渲染,然后将其输出到屏幕,同时 OBS 捕捉这个输出。
总结一句话:
关闭
PFD_DOUBLEBUFFER
后,OpenGL 的渲染直接作用于前缓冲,OBS 才能看到。但这是一种权宜之计,生产环境下更推荐保持双缓冲并配合正确的帧交换和捕捉方式。
回顾当前的情况
我们目前已经有了一个完整的软件渲染器,因此在接入 OpenGL 的第一步,并不需要立刻将整个游戏都通过 OpenGL 来渲染。我们的目标仅仅是把我们已经在 CPU 端生成的图像,也就是渲染缓冲区中的画面,传输到显卡上,并通过 OpenGL 显示出来。
现在我们所做的事情非常简单,仅仅是调用了 glClearColor
设置清除颜色为粉色,然后调用 glClear
执行清屏操作。因此当前 OpenGL 显示的画面就是一片粉色,这是因为我们只发出了清屏的指令,没有告诉 GPU 要渲染任何其他东西。
OpenGL 的指令缓存(command buffer)目前仅包含:
- 设置清除颜色(粉色);
- 设置清除区域(窗口的大小范围);
- 指定清除哪一个缓冲(如颜色缓冲);
- 然后提交这些指令去执行。
接下来我们想做的是,不仅仅只是显示粉色清屏,而是把我们在内存中已经绘制好的游戏画面,也就是一个位图缓冲区(bitmap buffer),传输到显卡,然后由显卡负责显示它。
这个过程本质上和我们写的软件渲染器非常相似,也正是我们当初要自己写渲染器的原因:我们可以从中理解图形卡(GPU)背后的工作方式。回顾我们写软件渲染器的过程,可以更好地理解 GPU 的逻辑。如果记不清了,也可以回去重新看一下我们早期实现的部分。
在 GPU 中,我们需要准备两样东西:
- 图像数据源:也就是我们要传输的图像,通常叫做"纹理"(texture),这是 GPU 用来读取像素信息的对象;
- 绘制指令:也就是让显卡画出图像的方式,具体来说要绘制一种叫"图元"(primitive)的图形。图元是 GPU 所支持的基本形状,比如点、线、三角形等等。
在我们的渲染器中,我们唯一使用的图元其实是矩形。而在 GPU 的世界中,最常用的图元是三角形。为了在显卡上画出一个矩形,我们需要把它拆成两个三角形组合成一个矩形形状。
这就是我们接下来要做的事:
- 把我们的图像缓冲区上传成一张 OpenGL 纹理;
- 创建一个由两个三角形组成的矩形;
- 把这两个三角形绘制出来,并让它们使用我们上传的纹理进行采样,从而显示出原始的图像。
Blackboard: 绘制四边形的方法
我们需要在屏幕上绘制矩形区域来显示图像,为了实现这一点,有两种方法可以选择:
第一种方法:用两个三角形拼出一个矩形
我们可以通过绘制两个三角形来组成一个矩形。因为 OpenGL 最基础的绘图单位是图元(Primitive),最常见的图元是三角形,GPU 最擅长处理的也是三角形。
我们将会给出六个顶点(每个三角形三个),构成两个拼接起来的三角形,这样就能完美组成一个矩形。这样的方法通用性强,适用于任意位置的矩形绘制,也适合将来用来显示我们所有的图像精灵(Sprite)。
第二种方法:绘制一个大三角形然后进行裁剪(Clipping)
另一种思路是只绘制一个三角形,但使用 OpenGL 的裁剪功能把它裁剪成一个矩形形状来显示。这种方式可以使用 OpenGL 的裁剪功能(比如 glScissor
指定一个区域),让三角形只在屏幕指定区域内显示,其他部分被裁掉。
这类似于软件渲染中我们做的"裁剪到屏幕边界"的操作,但 OpenGL 的裁剪功能是更通用的,它允许我们裁剪到比屏幕更小甚至不规则的区域,裁剪的区域可以自定义。
但是我们不会使用第二种方法:
尽管用 glScissor
裁剪确实可以做到我们想要的效果,但我们暂时不会采用这种方式,原因如下:
- 调用裁剪功能在某些显卡上可能是一个较慢或昂贵的操作;
- 设置裁剪区域可能会让渲染流程变得更复杂;
- 更重要的是,我们将来的目标是把整个游戏的渲染从软件栅格化(软件计算每个像素)迁移到 OpenGL 上去;
- 到时候我们会有很多图像精灵需要绘制,而每个图像都需要在不同的位置绘制不同的矩形,这种情况下用两个三角形拼出矩形更通用、更灵活。
所以最终选择是:使用两个三角形绘制矩形
我们将使用两个三角形来表示矩形区域,这样我们可以在不依赖任何裁剪操作的情况下自由绘制图像,而且每个精灵(Sprite)都可以独立控制显示的位置、大小、贴图等内容。
我们接下来将按这种方式来实现把 CPU 渲染好的图像上传到 GPU,并在 GPU 上通过 OpenGL 显示出来的过程。这个方法是我们未来整个渲染系统迁移到 GPU 后的基础。
Blackboard: 使用两个三角形来绘制我们的四边形纹理
整个流程跟我们之前在软件光栅化器中所做的基本一模一样,我们需要:
1. 构造矩形(由两个三角形组成)
我们首先要做的,是在屏幕上构造一个由两个三角形组成的矩形。这个矩形将作为图像的显示区域。它的顶点坐标会告诉 GPU 把图像画在屏幕的哪个位置。
2. 设置 UV 坐标(纹理坐标)
我们要给这个矩形的四个顶点设置对应的纹理坐标,也就是所谓的 UV 坐标。
- U、V 是纹理坐标的两个轴,范围通常是 0 到 1;
- UV 坐标的作用是告诉 GPU:这个顶点对应贴图中的哪个位置;
- 比如左上角是 (0,0),右下角是 (1,1),这样整个贴图就会刚好填满整个矩形。
这和我们之前在 CPU 上自己写的渲染器中做法完全一致。
3. 将贴图加载进 GPU 内存
接下来我们需要把一张贴图,也就是一张图像,加载到 GPU 中。这张图像是我们在 CPU 渲染器中已经生成好的那一张画面。
加载的方式通常是使用 OpenGL 的 glTexImage2D
或其他相关函数,把像素数据从 CPU 端上传到 GPU 的显存中。
4. 渲染这个贴图
当我们完成以上三步之后,我们就有了:
- 一个在屏幕上的矩形区域;
- 这个矩形的每个顶点都有对应的纹理坐标;
- 一张已经加载好的贴图;
现在我们只需要用 OpenGL 渲染这个矩形,GPU 就会自动用贴图的内容来"填充"整个矩形区域,实现图像显示的目的。
最终效果
一旦完成这些步骤,屏幕上就会显示出我们原本在 CPU 中渲染出来的那张画面,但现在是由 GPU 通过 OpenGL 来负责显示的。这就是我们迁移渲染工作的一小步,从 CPU 显示到 GPU 显示的关键节点。
这个过程是整个 GPU 渲染系统的基础操作。只要能成功做出这一步,就可以在其基础上继续实现更复杂的 GPU 加速渲染。
win32_game.cpp: 解释 glVertex 命名法
我们要做的第一步,是尝试在屏幕上绘制一个矩形,这个矩形由两个三角形拼接而成。这个阶段我们不会贴图,只先试着画出纯几何形状,确认 OpenGL 渲染管线是否正常工作。由于贴图是一个更复杂的步骤,我们将其留到后面。
保持背景粉色
我们仍然保留背景为粉色的清屏操作(glClearColor
+ glClear
),这样做的好处是:
- 可以非常直观地看出我们画上去的图形是否真的显示出来;
- 如果矩形能正确覆盖粉色背景,说明渲染路径基本是通的。
使用 OpenGL 旧式固定功能管线绘制
接下来我们将采用 OpenGL 的**旧式渲染方式(Immediate Mode)**来绘制两个三角形,也就是用 glBegin()
和 glEnd()
来环绕绘图指令。
为什么用这种方式?
- 这是最基础的绘图方式;
- 逻辑清晰,利于理解整个 OpenGL 渲染流程;
- 虽然效率不高,也不适合现代项目,但非常适合教学阶段使用。
如何使用 glBegin 和 glEnd
在 glBegin(GL_TRIANGLES)
和 glEnd()
之间,依次调用 glVertex
来设置三角形的三个顶点,每三个点构成一个三角形:
c
glBegin(GL_TRIANGLES);
glVertex2f(x1, y1);
glVertex2f(x2, y2);
glVertex2f(x3, y3);
// 第二个三角形
glVertex2f(x4, y4);
glVertex2f(x5, y5);
glVertex2f(x6, y6);
glEnd();
这些坐标就是屏幕空间中我们希望绘制三角形的具体位置。
glVertex 的命名规则
OpenGL 函数的命名有一定的模式:
glVertex
表示定义顶点;- 后缀中带的数字是维度,比如:
2
表示二维(只传 X 和 Y);3
表示三维(传 X、Y、Z);
- 后缀中带的字母表示数据类型:
f
是 float(浮点型);i
是 int(整型);ub
是 unsigned byte(无符号字节);
例如:
glVertex2f(x, y)
:二维浮点坐标;glVertex3i(x, y, z)
:三维整型坐标;glVertex2ub(x, y)
:二维无符号字节坐标。
当前阶段目标
目前我们只是要把一个由两个三角形组成的矩形绘制出来,用来覆盖在粉色背景上。这是验证我们能否正确把图形从 CPU 端发送到 GPU 并在屏幕上显示的重要步骤。
优化等高级内容都暂时忽略,因为在这个基础阶段,核心是理解流程而不是追求极致效率。
下一步会逐步引入纹理和现代 OpenGL 的做法,但目前先专注于理解基本的图形绘制过程。
Blackboard: 用三角形覆盖屏幕
现在我们面临的问题是:如何确定我们要绘制的三角形的坐标?
视口(Viewport)的坐标范围
在前面已经设置好了视口,它定义了我们在窗口中绘图的区域范围:
- X 轴从
0
到窗口宽度
; - Y 轴从
0
到窗口高度
;
所以我们在使用顶点坐标时,应该以这个范围为参照系,来决定三角形的具体位置。
绘制矩形所需的两个三角形
我们要绘制的矩形,会通过两个三角形来拼接完成,构造方式如下:
第一个三角形:
- 左上角:
(0, 0)
- 右上角:
(width, 0)
- 右下角:
(width, height)
第二个三角形:
- 左上角:
(0, 0)
- 右下角:
(width, height)
- 左下角:
(0, height)
通过这两个三角形的拼接,完整覆盖整个窗口区域。
坐标值来源
这些顶点坐标都是显而易见、已知的:
width
和height
是当前窗口的宽度和高度;- 所以顶点坐标可以直接根据窗口尺寸来构造,无需复杂计算。
这一步的目标是确认:我们在不使用任何纹理的前提下,仅靠两个三角形,能否覆盖整个窗口区域。这是为后续贴图打基础的关键验证步骤。
win32_game.cpp: 构建我们的第一个 OpenGL 基元,一个三角形
我们要绘制一个矩形,方法是使用两个三角形来拼接覆盖整个窗口。这两个三角形的坐标非常直观,完全基于窗口的宽度和高度来确定。
第一个三角形(下半部分)
顶点坐标如下:
(0, 0)
------ 左上角(window_width, 0)
------ 右上角(window_width, window_height)
------ 右下角
这个三角形从窗口的左上角延伸到右上角,然后再到底部右侧,构成矩形的下半部分。
第二个三角形(上半部分)
顶点坐标如下:
(0, 0)
------ 左上角(window_width, window_height)
------ 右下角(0, window_height)
------ 左下角
这个三角形从左上角延伸到右下角,然后回到左下角,补上了矩形的上半部分。
总结
- 我们利用窗口的尺寸信息
(window_width, window_height)
构造了两个三角形; - 这两个三角形拼接起来刚好完整覆盖整个窗口区域;
- 这个绘制方式不涉及纹理或颜色,仅仅用于测试三角形的基本绘制是否正确;
- 其中一个被称为下三角形(lower triangle),另一个为上三角形(upper triangle);
这是进一步将图像贴图到 GPU 上之前非常关键的一步,确认基本图形绘制无误。
运行游戏并"看到"我们的三角形
在理论上,如果我们现在运行这段代码,应该能够看到两个三角形被绘制出来。然而,实际情况是,绘制出来的两个三角形并没有按预期填满整个屏幕。它们的位置并不正确。按照我们设想的坐标系统,应该是:
(0, 0)
是屏幕的左上角,(width, 0)
是屏幕的右上角,(0, height)
是屏幕的左下角,(width, height)
是屏幕的右下角。
这样,理论上这两个三角形应该填满整个屏幕,但实际情况是它们并没有完全覆盖屏幕。
问题分析:
这个问题的原因是因为 OpenGL 默认使用了固定功能管道(fixed function pipeline)。当不使用着色器时,OpenGL 会按照固定的方式进行处理。在这种情况下,坐标系没有直接按照屏幕像素来进行映射,而是使用了不同的坐标空间,这就是为什么绘制的三角形没有填满整个屏幕的原因。
解决思路:
要解决这个问题,首先需要理解固定功能管道的工作原理,它会对坐标进行不同的转换和处理,最终才会映射到屏幕上的位置。因此,为了让三角形正确地覆盖屏幕,需要对这些坐标进行适当的调整,或者通过使用着色器来控制坐标的转换过程。

Blackboard: 固定功能管线与可编程管线
在 OpenGL 中,有两种主要的渲染管线:固定功能管线(Fixed Function Pipeline)和可编程管线(Programmable Pipeline)。固定功能管线是早期 GPU 的工作方式,在这种模式下,GPU 只能执行一系列固定的操作,比如按某种方式处理顶点、裁剪三角形以及填充像素。而可编程管线则是现代 GPU 的工作方式,允许通过编写着色器来实现更灵活的操作。
在固定功能管线中,最基本的顶点着色器操作已经被硬件直接实现。这个操作包括顶点变换、裁剪三角形,以及窗口空间变换(Windows space transform)。然而,在可编程管线中,很多操作都可以通过着色器自定义,顶点变换和窗口空间变换也可以通过编写着色器来实现,而裁剪通常仍然是通过固定功能完成的。
问题出现在由于未设置合适的顶点变换,导致绘制的图形没有出现在预期的位置。在固定功能管线中,默认的变换方式并不会将输入的顶点直接映射到屏幕坐标上,因此结果可能是我们无法理解的随机位置。而如果我们自己实现着色器,可以完全控制顶点的变换和像素的填充。
理解固定功能管线的工作原理非常重要,因为我们实现的着色器实际上可以模拟固定功能管线的行为,只要设置合适的参数。

Blackboard: 矩阵乘法
在 OpenGL 中,矩阵和向量是核心概念。矩阵记录了一系列数学操作,这些操作会对向量进行变换。在计算机图形学中,矩阵乘法常用于对顶点坐标(如 3D 点)进行变换。
首先,矩阵乘法的过程是通过将矩阵的每一行与向量的每一列进行计算,生成新的向量。具体来说,当一个 3D 向量(如 (x, y, z)
)与一个 3x3 的矩阵相乘时,每一行的元素都会与向量的对应元素相乘,然后加和,最终得到新的坐标值。比如,假设矩阵是:
( A B C D E F G H I ) \begin{pmatrix} A & B & C \\ D & E & F \\ G & H & I \end{pmatrix} ADGBEHCFI
而向量是 (x, y, z)
,那么矩阵与向量的乘法会按照如下步骤进行:
- 新的 X 值是
Ax + By + Cz
- 新的 Y 值是
Dx + Ey + Fz
- 新的 Z 值是
Gx + Hy + Iz
这个过程就是矩阵变换。对于每个坐标轴(X、Y 和 Z),都有三个系数,分别控制 X、Y 和 Z 的输出值。这意味着你可以根据需要调整这些系数,来得到不同的变换效果,如旋转、缩放或平移。
理解矩阵变换非常重要,因为它是 OpenGL 渲染管线中的基础。无论是固定功能管线还是可编程管线,矩阵和向量的变换操作都是其核心操作之一。在 OpenGL 中,矩阵变换通常用于将物体从一个坐标空间转换到另一个坐标空间,比如从物体坐标系转换到世界坐标系、视图坐标系或者投影坐标系。
总的来说,矩阵是一个非常强大且灵活的工具,通过调整矩阵中的系数,能够实现各种复杂的变换,极大地提高了图形渲染的灵活性和效率。
举一个简单的例子来帮助理解矩阵和向量的变换过程。
假设有一个三维点 (x, y, z)
,我们想要对这个点进行 缩放 ,旋转 和 平移 三种常见的变换。每种变换都可以通过矩阵乘法来实现。我们将通过具体的矩阵和向量计算来展示这些变换是如何进行的。
1. 缩放变换
缩放变换通过缩放矩阵来实现。如果我们想要将一个点的 X 轴和 Y 轴坐标分别缩放 2 倍和 3 倍,我们可以使用一个 3x3 的缩放矩阵:
s ⋅ ( x y z ) = ( 2 0 0 0 3 0 0 0 1 ) ⋅ ( x y z ) = ( 2 x 3 y z ) s \cdot \begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} 2 & 0 & 0 \\ 0 & 3 & 0 \\ 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} 2x \\ 3y \\ z \end{pmatrix} s⋅ xyz = 200030001 ⋅ xyz = 2x3yz
然后我们将这个矩阵与点 (x, y, z)
进行矩阵乘法:
这样,经过缩放变换后,点 (x, y, z)
会变成 (2x, 3y, z)
,即 X 坐标变为 2 倍,Y 坐标变为 3 倍,而 Z 坐标保持不变。
2. 旋转变换
旋转变换常用的旋转矩阵是在二维或三维空间中的旋转。例如,假设我们要在 XY 平面 上旋转一个点 90 度(顺时针旋转)。可以使用如下的旋转矩阵:
R = ( cos ( θ ) − sin ( θ ) 0 sin ( θ ) cos ( θ ) 0 0 0 1 ) R = \begin{pmatrix} \cos(\theta) & -\sin(\theta) & 0 \\ \sin(\theta) & \cos(\theta) & 0 \\ 0 & 0 & 1 \end{pmatrix} R= cos(θ)sin(θ)0−sin(θ)cos(θ)0001
其中,θ = 90°
。代入角度,我们得到:
R = ( 0 − 1 0 1 0 0 0 0 1 ) R = \begin{pmatrix} 0 & -1 & 0 \\ 1 & 0 & 0 \\ 0 & 0 & 1 \end{pmatrix} R= 010−100001
然后,假设我们要旋转的点是 (x, y, z)
,那么将该点与旋转矩阵相乘,得到新的坐标:
R ⋅ ( x , y , z ) = ( 0 − 1 0 1 0 0 0 0 1 ) ⋅ ( x y z ) = ( − y x z ) R \cdot (x, y, z) = \begin{pmatrix} 0 & -1 & 0 \\ 1 & 0 & 0 \\ 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} -y \\ x \\ z \end{pmatrix} R⋅(x,y,z)= 010−100001 ⋅ xyz = −yxz
这样,经过旋转变换后,点 (x, y, z)
变成了 (-y, x, z)
,即在 XY 平面内顺时针旋转 90 度,Z 坐标保持不变。
3. 平移变换
平移变换通过平移矩阵实现,它是一个 4x4 矩阵,通常用于处理 3D 空间中的平移。假设我们要将点 (x, y, z)
沿 X 轴、Y 轴和 Z 轴平移一定的距离,假设分别平移 dx
,dy
和 dz
。
平移矩阵如下:
T = ( 1 0 0 d x 0 1 0 d y 0 0 1 d z 0 0 0 1 ) T = \begin{pmatrix} 1 & 0 & 0 & dx \\ 0 & 1 & 0 & dy \\ 0 & 0 & 1 & dz \\ 0 & 0 & 0 & 1 \end{pmatrix} T= 100001000010dxdydz1
平移变换会将点 (x, y, z)
转换为 (x + dx, y + dy, z + dz)
,即将点沿 X 轴平移 dx
,Y 轴平移 dy
,Z 轴平移 dz
。
综合应用:组合缩放、旋转和平移
我们还可以将这些变换组合起来,形成一个更复杂的变换。例如,首先进行缩放,再进行旋转,最后进行平移。为了完成这个操作,我们可以将所有的变换矩阵相乘,然后应用到点上。
例如,假设我们先进行缩放,再旋转,最后平移。我们可以将这些矩阵相乘,得到最终的变换矩阵:
M = T ⋅ R ⋅ S M = T \cdot R \cdot S M=T⋅R⋅S
然后,使用这个综合矩阵对点进行变换。每个变换都通过矩阵乘法依次作用在点上。
总结
- 矩阵变换 让我们可以对三维点进行各种操作,包括缩放、旋转和平移。
- 通过矩阵乘法,我们能够灵活地调整顶点的位置,并通过调整矩阵中的系数来得到不同的几何变换效果。
- 在 OpenGL 中,矩阵变换是渲染管线中的核心操作之一,它帮助将物体从模型空间变换到屏幕空间。
Blackboard: 齐次坐标和仿射变换
OpenGL 在处理坐标变换时,在线性变换的基础上更进一步,引入了齐次坐标(homogeneous coordinates),从而支持更丰富的变换形式。
在线性变换中,我们通过一个矩阵与向量相乘来实现,例如:
$$
\begin{pmatrix}
a & b & c \
e & f & g \
i & j & k \
\end{pmatrix}
\cdot
\begin{pmatrix}
x \
y \
z
\end{pmatrix}
\begin{pmatrix}
ax + by + cz \
ex + fy + gz \
ix + jy + kz
\end{pmatrix}
这类变换只能对输入向量进行缩放、旋转、错切等操作,但无法实现平移。也就是说,无法给输入值增加一个固定偏移量,因为矩阵中每一个值都要乘以输入向量的某一项,没有办法单独加一个固定值。例如,如果输入是 `(0, 0, 0)`,无论矩阵怎么写,输出永远是 `(0, 0, 0)`。 为了实现"平移"这种非线性操作,我们引入**齐次坐标**。通过将三维向量扩展为四维向量: ( x , y , z ) → ( x , y , z , 1 ) (x, y, z) \\rightarrow (x, y, z, 1) (x,y,z)→(x,y,z,1) 然后使用一个 4x4 的矩阵进行变换: ##
\begin{pmatrix}
a & b & c & d \
e & f & g & h \
i & j & k & l \
0 & 0 & 0 & 1
\end{pmatrix}
\cdot
\begin{pmatrix}
x \
y \
z \
1
\end{pmatrix}
\begin{pmatrix}
ax + by + cz + d \
ex + fy + gz + h \
ix + jy + kz + l \
1
\end{pmatrix}
其中最后一列 `d, h, l` 就实现了位移(偏移)功能,使得我们可以把一个物体从一个位置"搬到"另一个位置。这种带有平移能力的变换称为 **仿射变换(affine transform)**,它比单纯的线性变换更强大。
此外,通过控制输入向量的第 4 个分量(即 W 分量)我们还能区分"点"和"方向":
* 向量 `(x, y, z, 1)`:表示一个"点",会受到平移的影响;
* 向量 `(x, y, z, 0)`:表示一个"方向",不受平移影响,只会被旋转或缩放。
这是因为 `(x, y, z, 0)` 在矩阵乘法中不会激活平移那一列,即 `d, h, l` 被乘以 0,等价于没有平移。
这种区分在 3D 图形处理中非常重要,比如:
* 法线方向(normal vector)只需要旋转缩放,不应被平移;
* 顶点位置需要完整的仿射变换。
所以,总结来说:
* 线性变换不能平移,只能旋转、缩放、错切;
* 引入齐次坐标后,能够支持平移;
* 4x4 矩阵与 4D 向量相乘实现了仿射变换;
* `(x, y, z, 1)` 是点,`(x, y, z, 0)` 是方向;
* 这就是 OpenGL 中变换系统的基础结构。
如果需要,我可以给你举一个具体的仿射变换例子并一步步带你算一遍。需要的话告诉我即可。
## Blackboard: 模型视图和投影矩阵
在 OpenGL 的固定功能管线中,系统定义了两个用于顶点变换的矩阵:**ModelView 矩阵** 和 **Projection 矩阵**。这两个矩阵本质上是用来将我们输入的顶点坐标从模型空间一步步转换到最终的裁剪空间,从而使图形可以正确地渲染在屏幕上。
这两个矩阵虽然是分开设置的,但在实际的计算过程中,OpenGL 会将它们进行组合:将 **ModelView** 矩阵与 **Projection** 矩阵进行矩阵乘法,合并成一个最终使用的变换矩阵。这意味着,传入的每一个顶点都会先被 ModelView 变换处理,然后再被 Projection 变换处理,最终得到裁剪空间中的坐标。
值得注意的是,**矩阵乘法的顺序与我们的阅读顺序相反**。即如果我们写的是:
Projection * ModelView * Vertex
那么,变换实际上是先执行 ModelView,再执行 Projection,这是因为变换是从右往左应用的。
ModelView 矩阵原本的设计中包含两部分含义:
* **Model**:将物体从局部坐标系转换到世界坐标系;
* **View**:将物体从世界坐标系转换到摄像机(观察)坐标系。
这两个变换被合并为一个称作 ModelView 的矩阵。
而 Projection 矩阵负责将场景从摄像机坐标系变换到裁剪空间,这个阶段包括了视锥体变换(比如透视投影或正交投影)。
尽管历史上为了 OpenGL 的内建光照功能而分离了这两个矩阵(因为光照计算需要在观察空间中完成),但在现代图形编程中,这种分离已不再必要,尤其是当我们不使用固定功能光照时。
实际上,无论是两个、三个还是多个矩阵,我们都可以通过矩阵乘法将它们压缩成一个最终变换矩阵。因此,在实际编程中,我们完全可以只使用一个自定义的矩阵完成所有变换。
总结如下:
* OpenGL 固定功能管线提供了 **ModelView** 和 **Projection** 两个变换矩阵;
* 实际顶点变换时会将它们组合成一个变换矩阵:`Projection * ModelView`;
* 矩阵变换是从右往左应用的,即先应用 ModelView,再应用 Projection;
* 这两个矩阵的分离最初是为了内建光照功能而设计的,但现在不再必要;
* 所有的变换最终都可以组合为一个矩阵来简化处理;
* 我们可以忽略 ModelView,自己定义一个变换矩阵就足够完成大部分任务。
如果需要具体的矩阵组合示例或变换流程图,也可以继续补充。
## win32_game.cpp: 使用 glLoadIdentity 将 MODELVIEW 和 PROJECTION 矩阵设置为单位矩阵
我们在使用 OpenGL 的时候,可以完全忽略掉 ModelView 矩阵的存在,将其始终设置为单位矩阵(Identity Matrix),也就是一个什么都不做的矩阵。
OpenGL 内部实际上维护了两个主要的变换矩阵:**ModelView 矩阵** 和 **Projection 矩阵** 。我们可以通过 `glMatrixMode` 来指定当前要操作的矩阵是哪一个,比如选择 `GL_MODELVIEW` 或 `GL_PROJECTION`。一旦选定,我们就可以对选中的矩阵进行操作。
为了让 ModelView 矩阵不对坐标做任何变换,可以使用 `glLoadIdentity()`。这个函数的作用是将当前选中的矩阵重置为单位矩阵。单位矩阵的特点是,它不会对输入的向量做任何变换,输出等于输入。
单位矩阵的形式如下:
1 0 0 0
0 1 0 0
0 0 1 0
0 0 0 1
无论输入的是 `(x, y, z, w)` 什么值,乘上这个单位矩阵后,输出仍然是原始的 `(x, y, z, w)`。也就是说,它不会缩放、旋转、平移,也不做任何形变,就是"原样输出"。
这种矩阵也被称为 **no-op(无操作)矩阵**。它在计算中没有实际效果,但作为初始化或占位使用非常常见。
除了单位矩阵,还可以构造 **置换矩阵(Permutation Matrix)**,用于重新排列向量的各个分量。例如我们希望交换 x 和 y 分量,就可以构造一个对应的置换矩阵。但单位矩阵的作用就只是保持各分量原样输出。
在固定功能管线中,OpenGL 的行为是:
* 它内部持有多个矩阵槽(Matrix Slot),比如:ModelView、Projection、Texture 等;
* 我们通过 `glMatrixMode` 来选择操作哪个槽;
* 使用 `glLoadIdentity()` 可以清除当前槽中的变换,设置为单位矩阵;
* 这样可以保证该矩阵对输入数据不施加任何变换。
通常,在 OpenGL 的默认状态下,这些矩阵就是单位矩阵,因此顶点数据会被直接传递下去,没有任何变换。我们只需要在真正想要控制变换时,才去设置这些矩阵。
总结:
* OpenGL 内部维护多个变换矩阵(如 ModelView、Projection);
* 可以用 `glMatrixMode()` 选择要操作的矩阵;
* 使用 `glLoadIdentity()` 可将选定矩阵设为单位矩阵;
* 单位矩阵不会对输入的顶点数据做任何处理,起到透传作用;
* 我们可以将 ModelView 始终设为单位矩阵,相当于它不存在;
* Projection 矩阵则可以用于控制视图范围(如设置投影);
* 这种方式简化了变换流程,尤其适合需要完全自定义控制的场景。
这样处理后,我们就能够更专注于控制一个单一的 Projection 矩阵,或完全自定义我们的顶点变换逻辑。


## win32_game.cpp: 向 glVertex2i 传递单位立方体,然后是 0.9f
我们可以用一个非常简单的方法解决屏幕显示不正确的问题,就是直接传入处于裁剪空间(clip space)内的坐标点。所谓裁剪空间是一个标准化的单位立方体,它的坐标范围是 \[-1, 1\],也就是说只要我们传入的顶点落在这个范围内,它们就不需要通过投影矩阵进行任何进一步的变换。这些点会直接在裁剪空间中进行裁剪处理,之后再被自动映射(归一化设备坐标 -\> 屏幕坐标)到最终的屏幕上。
例如我们将四个顶点设为 (-1, -1)、(1, -1)、(1, 1)、(-1, 1),构成一个完整填满屏幕的矩形。这些点完全处于裁剪空间范围内,因此不会被剔除或变形,也不会受投影矩阵的影响。最终这块矩形会完整地填满整个屏幕区域。
如果我们想验证自己的理解是否正确,还可以把这些点设为稍小的值,比如设为 (-0.9, -0.9)、(0.9, -0.9)、(0.9, 0.9)、(-0.9, 0.9)。这些点仍然位于裁剪空间中,但是距离边界略有收缩。这样绘制出来的图形就不会覆盖整个屏幕,而是在屏幕中心区域内绘制一个略小的矩形。这清晰地表明了我们所传入的坐标直接决定了图形在屏幕上的显示范围。
总结要点如下:
* 裁剪空间是 GPU 在进行可视性判断和几何处理时所使用的标准空间,范围为 \[-1, 1\]。
* 投影矩阵的作用是将模型坐标变换到裁剪空间,如果我们直接传入裁剪空间的坐标,就可以跳过这一步。
* 绘制顶点时,如果它们已经在裁剪空间内,就不再被进一步变换,最终会根据归一化规则映射到屏幕空间。
* 通过简单调整坐标值大小可以直观验证投影和裁剪的作用。
这种方式不仅让我们绕开了复杂的投影矩阵构建过程,而且清晰展示了裁剪空间和屏幕坐标之间的映射关系,有助于深入理解图形渲染管线的核心机制。
*** ** * ** ***
#### 问题出在哪里?
我们传入的坐标,比如 `(0, 0)` 到 `(屏幕宽度, 屏幕高度)`,其实是"屏幕坐标"。
但 GPU 在渲染时并不是直接处理屏幕坐标的,而是先把所有点放到一个叫做 **裁剪空间(Clip Space)** 的地方。
*** ** * ** ***
#### 什么是裁剪空间?
裁剪空间就像一个标准盒子,范围是:
* X:从 `-1` 到 `1`
* Y:从 `-1` 到 `1`
* Z:从 `-1` 到 `1`(Z 轴我们暂时可以忽略)
也就是说,只有落在这个盒子里的点才是"可见"的。
这个盒子的作用就是帮助 GPU 判断哪些点是可渲染的,哪些该被裁掉(Clip)。
*** ** * ** ***
#### 为什么我们原来画的东西没显示?
因为我们传进去的坐标,比如 `(0, 0)` 到 `(800, 600)`,**超出了裁剪空间的范围**,就好像你把画纸对准一个 1 米的窗户,但你画的是 10 米的大图,窗户外的人当然看不到完整画面。
*** ** * ** ***
#### 怎么解决?
我们直接用"标准裁剪空间"的坐标去画,也就是:
```text
(-1, -1) 到 (1, 1) 之间
```
* `(-1, -1)`:屏幕左下角
* `(1, 1)`:屏幕右上角
* `(0, 0)`:屏幕中心
所以我们只要传入在这个范围内的顶点,GPU 就知道它们在画布的哪里了,然后再自动映射到屏幕上。
*** ** * ** ***
#### 我们怎么验证这个?
可以试着只画中间区域,比如:
```text
(-0.9, -0.9) 到 (0.9, 0.9)
```
它会画一个比满屏略小的矩形,位置刚好在屏幕中央。
这说明只要我们传入的坐标在裁剪空间内,GPU 就会正确处理,显示在我们预期的位置上。
*** ** * ** ***
#### 总结(通俗版)
* GPU不直接处理屏幕像素坐标,它先统一转换到一个 \[-1, 1\] 的"标准盒子"(裁剪空间);
* 如果坐标超出这个范围,就会被"裁剪"掉;
* 如果你直接传标准范围内的坐标(比如 -1 到 1),GPU 就会很高效地直接处理它;
* 我们也可以自己构建一个投影矩阵去把实际坐标变成裁剪坐标,但现在先跳过,直接用裁剪坐标最简单;
*** ** * ** ***
如果你希望我画一张图来辅助解释这个裁剪空间和屏幕坐标的关系,我可以帮你做一个可视化图示,会更直观!要不要?




## 运行游戏并查看更小的矩形
我们将绘制区域在宽度和高度方向上分别缩小了10%,也就是说我们传入的顶点坐标从原来的 `(-1, -1)` 到 `(1, 1)` 缩小到了 `(-0.9, -0.9)` 到 `(0.9, 0.9)`。这相当于在裁剪空间里,矩形的每一边都向内收缩了10%。
需要注意的是,这个"10%"是相对于裁剪空间中的坐标范围 \[-1, 1\] 来说的,而不是相对于屏幕像素的绝对大小。这就意味着当最终从裁剪空间映射到屏幕空间时,X 轴和 Y 轴的缩放比例不同,因为屏幕的宽和高并不是相等的。
例如,如果屏幕是宽屏(例如 16:9),那么 X 轴的放大倍数会比 Y 轴更大,因此相同"10%"的缩小在视觉上会让上下边缘的留白看起来比左右边缘更小。这并不是因为我们设置的值不对,而是由于屏幕在水平方向上的拉伸更大,导致相对单位长度在屏幕上的显示宽度更长。
也就是说:
* 在裁剪空间中我们以相同的比例(比如0.9)缩小了 X 和 Y;
* 但当映射到屏幕时,由于屏幕的长宽比不同,这个缩放在屏幕上表现为 **宽边留白更多,高边留白更少**;
* 这是裁剪空间的坐标变换到屏幕坐标时根据长宽比进行非等比缩放的自然结果。
这个过程是固定定义好的,图形渲染管线就是这样工作的。因此只要我们理解了裁剪空间的原理以及它是如何被最终映射到屏幕上的,就能够很好地预测图形在屏幕上最终的显示效果。
总结一下要点:
* 缩小10%是指在裁剪空间内缩小,也就是从 `±1` 缩小到 `±0.9`;
* 由于裁剪空间会映射到实际屏幕,而屏幕宽高比不同,导致缩小效果在水平和垂直方向上看起来不一样;
* 横向放大更多 → 相对留白更宽;
* 这是裁剪空间到屏幕空间映射中不可避免的比例失衡;
* 这种行为是图形管线的设计规范,理解之后就能更精准地控制显示区域。
如果你希望,我还可以画图帮你可视化一下这种从裁剪空间到屏幕空间映射的过程,会更直观。需要吗?
## Blackboard: 从裁剪空间到屏幕空间的转换
我们之所以看到图形缩放后的位置和大小变化,是因为 OpenGL 的坐标变换过程是\*\*先在裁剪空间中进行处理,然后再映射到屏幕空间(screen space)\*\*的。这种流程是 OpenGL 的规定行为,不是我们能够改变的。
具体来说:
*** ** * ** ***
#### 一、从裁剪空间到屏幕空间的过程
我们在裁剪空间中绘制的坐标范围是 `[-1, 1]`,这个范围代表的是一个"单位立方体"(unit cube),中心是 (0, 0),左下角是 (-1, -1),右上角是 (1, 1)。
而屏幕空间的坐标是以像素为单位的,比如一个 1920x1080 的屏幕,其左下角是 `(0, 0)`,右上角是 `(1920, 1080)`。我们需要把裁剪空间中的点,转换成这个屏幕空间中的点。
这个变换过程分两步:
*** ** * ** ***
#### 二、第一步:将裁剪空间移动到从 `[0, 0]` 到 `[2, 2]`
我们的裁剪空间是 `[-1, 1]`,也就是说,它的宽度是 2。我们要把它的最小值 `-1` 移动到 0,那只需要**加一个偏移量 (1, 1)**。
举个例子,原点 `(0, 0)` 加上 `(1, 1)` 变成了 `(1, 1)`,左下角 `(-1, -1)` 加上 `(1, 1)` 变成 `(0, 0)`,右上角 `(1, 1)` 加上 `(1, 1)` 变成 `(2, 2)`。这一步是把整个裁剪空间从 `[-1, 1]` 移动到了 `[0, 2]`。
*** ** * ** ***
#### 三、第二步:缩放到屏幕像素大小
现在裁剪空间的坐标是 `[0, 2]`,我们想把它映射到屏幕像素空间,比如 `[0, 1920]` 或 `[0, 1080]`。很简单,我们**乘以宽度的一半(width/2)和高度的一半(height/2)**。
举例:
最终屏幕坐标 = (裁剪坐标 + 1) × (宽度 / 2, 高度 / 2)
举个具体例子,假设我们有一个点 (0.5, -0.5),在 1920×1080 屏幕上:
x' = (0.5 + 1) × (1920 / 2) = 1.5 × 960 = 1440
y' = (-0.5 + 1) × (1080 / 2) = 0.5 × 540 = 270
这个点在屏幕上的坐标就是 `(1440, 270)`。
*** ** * ** ***
#### 四、屏幕映射变换由谁控制?
虽然我们不会手动去做这些加法和乘法,但是 **OpenGL 会自动做这些变换** ,它是通过我们设置的 `glViewport()` 函数来获得屏幕尺寸的。这个函数告诉 OpenGL:
* 最左边的起点是哪里(x, y)
* 这个视口的宽度和高度是多少(w, h)
于是 OpenGL 就会用这些信息来计算坐标映射规则。
我们可以设置 viewport 的参数来控制这个"映射关系",比如我们可以让视口不是从 `(0, 0)` 开始,也不是覆盖整个窗口,而是只映射到一个小区域,从而让图形绘制在屏幕某个角落。我们虽然现在是默认让它对齐整个屏幕,但完全可以让它绘制到别处。
*** ** * ** ***
#### 总结核心逻辑:
* OpenGL 总是先在裁剪空间中工作(范围 \[-1, 1\]);
* 然后会通过加偏移和乘缩放因子,把点映射到屏幕空间(单位像素);
* 加偏移是 `(x + 1, y + 1)`,变成 `[0, 2]`;
* 乘缩放是 `× (width/2, height/2)`,变成屏幕坐标;
* 这个变换是固定逻辑,不是魔法,就是简单的数学;
* 我们唯一能控制的是 glViewport 的设置,它提供 width 和 height;
* 所以 glViewport 决定了最终的"屏幕显示映射"区域。
如果你还希望可视化这个变换流程,我可以画一张图来更直观地展示整个过程。你想要我画图吗?
## Blackboard: 绘制纹理
我们已经成功在屏幕上画出了一个矩形,意味着基础的图形渲染流程已经打通了。接下来我们要做的,就是让这个矩形**显示出一张纹理图像**,也就是把图像"贴"到这个矩形上。
*** ** * ** ***
#### 一、为什么需要 UV 坐标?
为了让 GPU 知道该在图像的哪个部分采样颜色信息,我们需要给矩形的每个顶点**附加一组 UV 坐标** 。
UV 坐标是一个二维坐标系,用于在纹理图像中标识位置,取值范围是 `[0, 1]`:
* **(0, 0)** 表示纹理图的左下角;
* **(1, 1)** 表示右上角;
* 中间的坐标表示图像上的其他任意位置。
我们可以把一张图片看作一个"UV 平面",通过指定顶点的 UV 坐标,就能告诉渲染管线在绘制这个矩形时,**该从图片的哪个位置采样颜色并贴到屏幕上**。
*** ** * ** ***
#### 二、怎么设置 UV 坐标?
我们要绘制一个矩形,那么它有四个顶点。对于每个顶点,我们除了传入它在裁剪空间中的位置(clip space 坐标),还要传入对应的 UV 坐标:
| 顶点位置(clip) | 对应 UV 坐标 |
|------------|----------|
| (-1, -1) | (0, 0) |
| (1, -1) | (1, 0) |
| (1, 1) | (1, 1) |
| (-1, 1) | (0, 1) |
这样,当 GPU 在三角形内部进行插值计算时,就会自动为每个像素计算一个对应的 UV 值,从而能够正确从纹理图像上取到相应的颜色进行渲染。
*** ** * ** ***
#### 三、采样纹理的过程
GPU 拿到每个片元的 UV 坐标后,会:
1. 根据 UV 坐标在绑定的纹理图中找到对应的像素;
2. 从纹理图中取出该像素颜色;
3. 把这个颜色用于当前的片元(像素)渲染。
这个过程就是所谓的"纹理采样"。
*** ** * ** ***
#### 四、这个流程和我们之前手动实现的很像
我们之前自己模拟实现过一个图像采样过程,比如直接从图像数组中按照 `(x / width, y / height)` 的方式取颜色,概念上其实和现在在 GPU 上做的几乎是一样的:
* 都是在一个二维空间中,用一组归一化的坐标 `[0,1]` 表示采样点;
* 都需要根据这些坐标映射到原始图片上的实际像素;
* 都要插值或采样出颜色值,作为最终显示的像素颜色。
现在我们只是把这些交给了 GPU 自动执行,效率更高、控制力更强。
*** ** * ** ***
#### 总结
* 我们在屏幕上绘制矩形后,为了贴图,需要给每个顶点设置 UV 坐标;
* UV 坐标表示纹理图像中的位置,范围为 \[0, 1\];
* 顶点位置 + UV 坐标共同决定了图形形状和纹理采样方式;
* 片元着色器会用插值后的 UV 坐标从纹理中采样颜色;
* 这个采样过程和我们手动实现过的纹理映射逻辑非常类似。
接下来只要我们绑定一张纹理图,然后让着色器根据传入的 UV 坐标进行采样,就可以实现在矩形上显示图像的效果了。想继续讲纹理绑定和片元着色器的细节吗?
## win32_game.cpp: 在两个三角形之前执行 glColor3f
我们现在要讲的是 OpenGL 的一种传统模式,也就是旧式固定功能管线的工作方式。在这种模式下,**每次调用 `glVertex` 都会被认为是"提交"一个顶点**,而在这之前所设置的各种状态(比如颜色、纹理坐标等)会自动关联到这个顶点上。
*** ** * ** ***
#### 一、顶点属性的绑定方式
在固定功能管线中,OpenGL 使用一种"顺序声明"的方式来绑定属性:
* 调用 `glColor3f` 设置颜色;
* 调用 `glTexCoord2f` 设置纹理坐标;
* 然后调用 `glVertex3f` 设置顶点位置。
**这些属性都会自动绑定到当前这个顶点上。**
一旦 `glVertex` 被调用,OpenGL 就会把之前设置的所有属性值与该顶点绑定。下一次设置属性的时候,就会为下一个顶点准备。
*** ** * ** ***
#### 二、示例说明:颜色插值
假设我们绘制一个矩形的两个顶点:
* 第一个顶点之前设置颜色为**黄色**;
* 第二个顶点之前设置颜色为**白色**。
那么最终这两个顶点之间的区域,OpenGL 会自动做**颜色插值**------也就是说,在图形片元之间会自动混合颜色,让颜色从黄色平滑过渡到白色。
类似地,我们也可以为每个顶点分配不同的颜色:
* 第一个顶点设置为红色;
* 第二个设置为绿色;
* 第三个设置为蓝色。
那么渲染出来的图形在三个顶点之间会自动生成一个红-绿-蓝之间渐变过渡的彩色区域。
这个行为和我们之前讲过的 UV 坐标插值非常类似 ------ 顶点的任何属性(颜色、纹理坐标等)都会在片元阶段自动插值,这正是现代图形渲染中实现丰富视觉效果的基础。
*** ** * ** ***
#### 三、纹理坐标的指定
既然我们已经了解了颜色是如何被关联到顶点上的,那么纹理坐标(UV 坐标)也是一样:
* 我们调用 `glTexCoord2f(u, v)` 设置某个顶点的纹理坐标;
* 然后调用 `glVertex3f(x, y, z)` 设置该顶点的位置;
* 此时这个顶点就拥有了两个属性:一个是位置坐标,另一个是 UV 坐标。
OpenGL 会在片元阶段自动插值这些 UV 坐标,然后根据插值结果从纹理图像中采样颜色进行着色。
*** ** * ** ***
#### 四、小结与思路
* 在旧式 OpenGL 中,顶点的属性设置(颜色、纹理坐标等)是在 `glVertex` 之前调用的;
* 每个顶点可以拥有自己的颜色、纹理坐标等属性;
* OpenGL 会自动在多个顶点之间进行插值,生成平滑的颜色或纹理过渡效果;
* 我们只需调用合适的设置函数(如 `glColor3f`、`glTexCoord2f`),并正确地和顶点一一对应;
* 整个机制本质上是一种状态机式的提交方式,先设置属性、再提交顶点。
这种方式虽然比较老派,但它清楚地体现了图形渲染中"属性 -\> 插值 -\> 绘制"这个基本思想,对于理解现代着色器系统也非常有帮助。接下来我们会在实际代码或图板中手动列出每个顶点对应的纹理坐标,进一步实现完整的纹理贴图效果。




## Blackboard: 建立我们的 u,v 纹理坐标
我们在贴图时,需要为每个顶点指定正确的纹理坐标(UV 坐标),才能确保纹理图像准确映射到我们绘制的图形上。现在我们假设已经绘制了一个矩形(由两个三角形构成),目标是将一张完整的图片平铺覆盖在这个矩形上。
*** ** * ** ***
#### 一、明确贴图目的
我们希望纹理**完整而准确地贴合在矩形上**,也就是说:
* 图片的左下角对齐矩形的左下角;
* 图片的右上角对齐矩形的右上角;
* 中间的部分也一一对应。
因此,我们需要为每个顶点提供与其几何位置对应的纹理坐标(UV 坐标)。纹理坐标的范围是 `[0, 1]`,其中:
* `U = 0` 表示纹理图像的最左边;
* `U = 1` 表示纹理图像的最右边;
* `V = 0` 表示纹理图像的最下边;
* `V = 1` 表示纹理图像的最上边。
*** ** * ** ***
#### 二、具体顶点与纹理坐标的匹配关系
假设我们绘制的是一个矩形,由两个三角形拼成:
三角形1:左下 -> 右下 -> 右上
三角形2:左下 -> 右上 -> 左上
那么我们为每个顶点分配如下 UV 坐标:
| 顶点位置 | 对应纹理坐标(UV) |
|------|------------|
| 左下角 | (0, 0) |
| 右下角 | (1, 0) |
| 右上角 | (1, 1) |
| 左上角 | (0, 1) |
通过这样的纹理坐标设置,可以确保整个纹理图像在屏幕上的矩形区域中被完整显示,不会出现错位或拉伸。
*** ** * ** ***
#### 三、小结与原理
* UV 坐标控制纹理图像如何贴在几何图形表面;
* UV 坐标范围 `[0, 1]` 对应整个纹理图像的宽高;
* 在屏幕上绘制一个矩形时,只需要按对应关系给四个角分别设定 (0,0)、(1,0)、(1,1)、(0,1);
* OpenGL 会在三角形内插值这些纹理坐标,再据此从纹理图像中采样颜色;
* 这样就能让图像准确铺在矩形表面。
通过合理设置 UV,我们实现了一个简单的全屏贴图操作,是图像渲染中的基础步骤。

## win32_game.cpp: 设置我们的纹理坐标
我们现在已经为所有用于绘制矩形的顶点分配了正确的纹理坐标(UV 坐标),这一步非常关键,它确保纹理图像能正确映射到我们绘制的图形上。
*** ** * ** ***
#### 一、完整的纹理坐标分配
我们所绘制的是一个矩形,由两个三角形构成:
* 第一个三角形是从左下角 → 右下角 → 右上角;
* 第二个三角形是从左下角 → 右上角 → 左上角。
对于这些顶点,我们为其分配了以下纹理坐标:
| 顶点位置 | 对应纹理坐标(UV) |
|-------------|------------|
| 左下角 (−P,−P) | (0, 0) |
| 右下角 (P,−P) | (1, 0) |
| 右上角 (P,P) | (1, 1) |
| 左上角 (−P,P) | (0, 1) |
这种分配方式确保纹理图像完整地贴合整个矩形区域。UV 的值清晰地描述了纹理图像中对应的区域如何贴合到屏幕上的几何形状。
*** ** * ** ***
#### 二、当前执行效果说明
尽管我们已经给所有顶点都分配好了纹理坐标,但此时运行程序时并不会看到任何图像上的变化,原因很简单:我们**尚未提供纹理本身的图像数据**,也就是没有指定实际的纹理内容。
此时的状态是:
* 几何图形已经准备好;
* UV 坐标已经绑定好;
* 但纹理图像数据为空,因此渲染结果看起来没有变化。
*** ** * ** ***
#### 三、纹理矩阵与变换(可选知识)
除了绑定静态的纹理坐标,还可以使用纹理矩阵(texture matrix)来对纹理进行额外的变换。这允许我们实现一些更高级的效果,例如:
* 滚动纹理(平移 UV);
* 缩放或旋转纹理;
* 镜像翻转纹理;
* 动态动画效果等。
虽然我们在这里不会实际使用它,但可以通过设置 `GL_TEXTURE` 模式并应用矩阵变换,对纹理坐标进行进一步处理,从而改变纹理在图形上的映射方式。这种变换只影响纹理坐标,而不影响几何图形的位置。
*** ** * ** ***
#### 四、接下来要做的事
下一步是把实际的纹理图像数据"上传"到显卡(即 GPU),让 OpenGL 使用这张纹理来进行采样和渲染。
不过这一步在实际操作中并不简单,因为:
* OpenGL 的纹理上传过程涉及多个状态和函数调用;
* 默认状态下可能存在兼容性问题或格式不正确;
* 需要正确设置纹理参数(如过滤模式、环绕模式等);
* 还需要保证数据格式与 GPU 所期望的格式一致。
因为时间关系,我们暂时不会在这一节课上完成上传操作,而是先写下相关函数调用框架,作为下次详细讲解的基础。
*** ** * ** ***
#### 总结
* 已完成顶点坐标与 UV 坐标的绑定;
* 正确分配纹理坐标后,图像可以完整映射到矩形;
* 虽未加载纹理数据,但坐标已经就绪;
* 可选使用纹理矩阵进行进一步变换;
* 下一步是加载并上传纹理图像数据,使纹理真正显示在屏幕上。
目前我们已经完成了纹理映射的准备工作,接下来只需将图像传递到显卡,即可完成完整的纹理绘制流程。


## win32_game.cpp: 使用 glTexImage2D 向图形卡提交纹理
在OpenGL中,指定纹理的过程是通过调用 `glTexImage2D` 来实现的,这个函数允许我们为纹理提供图像数据,并指定如何存储和使用这些数据。不过,这个过程比较繁琐,涉及到一些非常复杂且难以理解的参数,因此可以说这是OpenGL中一些最糟糕的设计之一。
*** ** * ** ***
#### 一、`glTexImage2D`函数的参数说明
* **像素数据** :`glTexImage2D` 最后的一个参数是 `pixels`,它是指向图像像素数据的指针。这一部分的处理方式与我们之前创建缓冲区的方式类似,因此可以理解为它指定了一个图像的像素数据。
* **宽度和高度**:显而易见,这是图像的尺寸,分别表示图像的宽度和高度。
* **边框** :`border` 参数定义了图像是否有额外的边框环绕它。在大多数情况下,我们并不使用边框,因此它通常设置为 0。
* **格式参数** :参数 `internalFormat` 和 `format` 定义了如何在内存中存储纹理数据以及我们如何提供数据给OpenGL。
* `format` 是指我们传入的数据的格式,比如 RGB 或 RGBA。
* `internalFormat` 是OpenGL如何存储这些数据的建议格式。例如,在RGB情况下,我们使用 `GL_RGBA8` 来指定每个颜色通道使用 8 位存储。
* **颜色顺序(BGR与RGB)** :因为Windows平台使用的是BGR格式(而非常见的RGB),所以内存中的颜色顺序是 BGR 而不是 RGB。在OpenGL中,处理这种格式是正常的,可以通过 `GL_BGR` 或 `GL_BGRA` 来指定。
* **类型** :`type` 参数定义了每个颜色分量的数据类型。在这种情况下,我们通常使用 `GL_UNSIGNED_BYTE`,表示每个颜色通道是一个无符号字节(0到255)。
* **目标类型** :`target` 参数指定了纹理的类型,它告诉OpenGL我们正在处理的是1D纹理、2D纹理还是其他类型的纹理。
* **级别** :`level` 参数通常用于指定多级渐远纹理(Mipmaps),这里我们设置为0,因为我们不使用多级渐远纹理。
*** ** * ** ***
#### 二、如何上传纹理到显卡
上传纹理到显卡并不简单。`glTexImage2D` 会将像素数据传输到显卡内存中,但是OpenGL并不会立刻执行这个操作。相反,它会将此操作放入命令队列,并稍后执行。这也意味着,我们调用了 `glTexImage2D` 后,纹理可能并不会立即显示在屏幕上。
*** ** * ** ***

#### 三、启用纹理功能
为了使纹理生效,我们需要明确启用纹理功能。OpenGL的固定功能管线允许我们启用或禁用一些特性,纹理也是其中之一。我们需要通过 `glEnable(GL_TEXTURE_2D)` 来启用纹理操作。
然而,仅仅启用纹理并不意味着我们能够看到纹理。因为OpenGL会保持一些状态,而我们需要确保在合适的时机绑定正确的纹理。
*** ** * ** ***

#### 四、绑定纹理
OpenGL允许我们管理多个纹理,因此我们必须明确地绑定纹理。每个纹理都有一个"槽",我们需要用 `glBindTexture` 来绑定正确的纹理槽。
* **生成纹理句柄** :我们需要通过 `glGenTextures` 函数生成纹理句柄。这个句柄就像一个指针,它帮助我们在OpenGL内部标识纹理。
* **绑定纹理**:绑定纹理意味着告诉OpenGL"接下来的操作会应用到哪个纹理"。这个绑定过程确保了每次绘制时,正确的纹理被应用到目标图形。
*** ** * ** ***

#### 五、设置纹理环境
纹理本身并不会直接影响图形的颜色,它只是提供了一个额外的信息源,用来影响像素的最终颜色。我们需要配置纹理环境,以定义纹理如何与其他颜色信息结合。
OpenGL提供了一个 `glTexEnv` 函数来控制纹理与颜色的混合模式。最常用的模式之一是 `GL_MODULATE`,这表示纹理的颜色会与当前颜色相乘,从而影响最终绘制的颜色。
*** ** * ** ***

`glTexEnv` 是 OpenGL 固定管线中用于设置 **纹理环境(Texture Environment)** 的函数,作用是告诉 OpenGL:**纹理颜色和当前颜色(如 glColor 设置的颜色)如何组合**,这一步是在片元着色前决定最终像素颜色的重要一环。
*** ** * ** ***
#### 函数原型
```c
void glTexEnvf(GLenum target, GLenum pname, GLfloat param);
void glTexEnvi(GLenum target, GLenum pname, GLint param);
```
*** ** * ** ***
#### 常用参数解析
| 参数 | 含义 |
|----------|------------------------------------------------------------|
| `target` | 一般为 `GL_TEXTURE_ENV`,表示修改纹理环境参数 |
| `pname` | 指定要设置的属性,通常用 `GL_TEXTURE_ENV_MODE` |
| `param` | 设置值(模式),如 `GL_MODULATE`、`GL_REPLACE`、`GL_DECAL`、`GL_BLEND` |
*** ** * ** ***
#### 常见模式解释
| 模式 | 效果 | 说明 |
|---------------|--------------|-----------------------------|
| `GL_REPLACE` | 使用纹理颜色替代原始颜色 | 结果颜色只取纹理颜色,忽略 glColor 设置的颜色 |
| `GL_MODULATE` | 纹理颜色 \* 当前颜色 | 最常用,用于根据顶点颜色对纹理做亮度调节 |
| `GL_DECAL` | 仅贴图 RGB,忽略原色 | 类似于 `REPLACE`,但更适用于不透明纹理 |
| `GL_BLEND` | 纹理颜色和当前颜色混合 | 按指定混合方式融合,较少使用 |
*** ** * ** ***
#### 示例代码
```c
// 设置纹理颜色与顶点颜色进行相乘(常用)
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
// 替换为纹理颜色(不受 glColor 影响)
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
```
*** ** * ** ***
#### 使用流程中的位置
调用 `glTexEnv` 通常出现在设置纹理前或绑定纹理后,如:
```c
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, texture_id);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE); // 设置纹理混合模式
```
*** ** * ** ***
#### 可视化理解(假设纹理颜色为红色,glColor 设置为绿色):
* `GL_REPLACE`:最终颜色 = 红色(纹理)
* `GL_MODULATE`:最终颜色 = 红色 × 绿色 = 黑色(因为 r×0, g×1, b×0)
* `GL_DECAL`:最终颜色 = 红色(忽略 glColor)
*** ** * ** ***
#### 注意事项
* 这个函数 **仅适用于固定管线(OpenGL 1.x)**,在使用着色器(OpenGL 3.3+ 或现代 OpenGL)时已被废弃。
* `GL_TEXTURE_ENV_MODE` 是设置整体行为,和 `glTexParameteri` 设置采样规则不同。
*** ** * ** ***
#### 总结一句话
> `glTexEnv` 决定了 **纹理颜色如何与已有颜色(如顶点色)混合**,是固定管线渲染中控制最终色彩表现的关键控制点。
*** ** * ** ***
#### 六、总结
1. **上传纹理** :通过 `glTexImage2D` 上传图像数据,指定图像的格式、大小、颜色等参数。
2. **启用纹理** :调用 `glEnable(GL_TEXTURE_2D)` 来启用纹理功能。
3. **绑定纹理** :使用 `glBindTexture` 绑定正确的纹理,以确保后续的绘制操作应用到正确的纹理。
4. **设置纹理环境** :通过 `glTexEnv` 设置纹理与其他颜色的结合方式,例如使用 `GL_MODULATE` 模式将纹理颜色与当前颜色相乘。
尽管我们已经完成了纹理的上传与绑定工作,但最终的效果仍然未必显示出来。这是因为OpenGL的操作是非常状态驱动的,我们需要进一步完善纹理采样规则,确保纹理正确应用到图形上。

`glTexImage2D` 是 OpenGL 中用于 **指定一个二维纹理图像** 的核心函数,它的作用是将我们准备好的图像数据上传给 GPU,以便在渲染时能通过纹理采样使用这些图像。它是纹理工作的关键步骤之一。
*** ** * ** ***
#### 函数原型
```c
void glTexImage2D(
GLenum target,
GLint level,
GLint internalFormat,
GLsizei width,
GLsizei height,
GLint border,
GLenum format,
GLenum type,
const void * data
);
```
*** ** * ** ***
#### 每个参数详细解释
| 参数名 | 含义 |
|--------------------|------------------------------------------------|
| `target` | 纹理目标,通常是 `GL_TEXTURE_2D`(表示二维纹理) |
| `level` | Mipmap 级别,0 表示基础级,越大越小分辨率,初期一般为 0 |
| `internalFormat` | 指定 GPU 存储纹理的格式,如 `GL_RGBA8` 表示 8 位每通道 RGBA |
| `width` / `height` | 图像的宽度和高度 |
| `border` | 是否有边框,必须是 0(OpenGL ES 已废弃此参数) |
| `format` | 数据格式,如 `GL_BGRA_EXT`, `GL_BGR_EXT`,描述传入的数据通道顺序 |
| `type` | 每个通道的数据类型,如 `GL_UNSIGNED_BYTE`(8 位无符号整数) |
| `data` | 实际图像像素数据的指针,可以是 `unsigned char*` 等类型 |
*** ** * ** ***
#### 举个例子
```c
glTexImage2D(
GL_TEXTURE_2D, // target
0, // level (base level)
GL_RGBA8, // internal format (store as 8-bit per channel RGBA)
buffer_width, // width
buffer_height, // height
0, // border (must be 0)
GL_BGRA_EXT, // format (how the data is laid out)
GL_UNSIGNED_BYTE, // type (each channel is 8-bit)
buffer_memory // pointer to pixel data
);
```
*** ** * ** ***
#### 使用前置条件(调用前必须做的事情)
在调用 `glTexImage2D` 前,必须完成以下步骤:
1. **生成纹理 ID**:
```c
GLuint texture_id;
glGenTextures(1, &texture_id);
```
2. **绑定纹理**:
```c
glBindTexture(GL_TEXTURE_2D, texture_id);
```
3. **设置纹理参数(如过滤模式、环绕方式)**:
```c
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
```
然后再调用 `glTexImage2D` 上传图像。
*** ** * ** ***
#### 常见问题与坑
| 问题 | 说明 |
|---------|----------------------------------------------------------|
| 图像显示不出来 | 没有启用纹理,或者没有正确绑定纹理、设置参数 |
| 颜色错乱 | `format` 与内存中的通道顺序不匹配(如 BGRA vs RGBA) |
| 崩溃或花屏 | `data` 指针错误,或者图像尺寸不合法(宽高为0) |
| 图像模糊 | 可能没有设置 `GL_TEXTURE_MIN_FILTER` 和 `GL_TEXTURE_MAG_FILTER` |
*** ** * ** ***
#### 总结一句话:
> `glTexImage2D` 是把你CPU准备的图像数据上传到GPU显存中,给OpenGL渲染时使用的关键一步,只有数据、格式、尺寸、绑定、参数都设置对了,图像才会被正确显示出来。
*** ** * ** ***

## win32_game.cpp: 使用 glTexEnvi 和 glTexParameteri
我们继续讲解 OpenGL 中纹理相关的设置流程。
*** ** * ** ***
在我们启用纹理之后,仅仅使用 `glTexEnv` 设置纹理的混合方式(如 `GL_MODULATE`,实现纹理颜色与顶点颜色的相乘)还不够,我们还需要进一步配置纹理的参数,这时候就要使用 `glTexParameter`。
*** ** * ** ***
#### glTexParameter 概述
`glTexParameter` 是一个非常核心的函数,它用于设置纹理对象的各种行为,比如:
* 纹理的采样方式(放大/缩小时使用何种算法)
* 是否启用纹理重复(环绕)或边缘拉伸
* mipmapping 行为(如果启用)
* 边界颜色(当超出纹理范围时)
*** ** * ** ***
#### 函数原型
```c
void glTexParameteri(GLenum target, GLenum pname, GLint param);
void glTexParameterf(GLenum target, GLenum pname, GLfloat param);
```
*** ** * ** ***
#### 参数说明
* `target`:通常为 `GL_TEXTURE_2D`,表示这是一个二维纹理的设置
* `pname`:设置项的名字,比如 `GL_TEXTURE_MIN_FILTER` 或 `GL_TEXTURE_WRAP_S`
* `param`:具体的参数值,比如 `GL_NEAREST`、`GL_LINEAR`、`GL_REPEAT` 等
*** ** * ** ***
#### 常用设置项举例
| 设置项(pname) | 含义 | 常用取值 |
|-------------------------|-------------|---------------------------|
| `GL_TEXTURE_MIN_FILTER` | 缩小纹理时的采样方式 | `GL_NEAREST`, `GL_LINEAR` |
| `GL_TEXTURE_MAG_FILTER` | 放大纹理时的采样方式 | `GL_NEAREST`, `GL_LINEAR` |
| `GL_TEXTURE_WRAP_S` | 水平方向超出范围时行为 | `GL_REPEAT`, `GL_CLAMP` |
| `GL_TEXTURE_WRAP_T` | 垂直方向超出范围时行为 | `GL_REPEAT`, `GL_CLAMP` |
*** ** * ** ***
#### 示例设置代码
```c
// 设置放大和缩小时都使用线性过滤
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 设置纹理在超出坐标范围时重复显示
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
```
*** ** * ** ***
#### 总结一下我们到目前为止做的内容:
1. 使用 `glTexEnv` 设置纹理与当前颜色的混合方式(例如 `GL_MODULATE` 代表相乘)
2. 使用 `glTexParameteri` 设置纹理的采样方式与边界行为等
3. 这些设置都依赖于 `glBindTexture` 绑定的当前纹理对象
*** ** * ** ***
#### 注意
即使完成了上面的所有设置,有时画面上仍然不会出现任何内容。这很可能是因为还有一些其他问题(比如没有正确传入纹理坐标,或者片元根本没有被绘制),这个问题可以留作后续调试练习。
*** ** * ** ***