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
来设置三角形的三个顶点,每三个点构成一个三角形:
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 米的大图,窗户外的人当然看不到完整画面。
怎么解决?
我们直接用“标准裁剪空间”的坐标去画,也就是:
(-1, -1) 到 (1, 1) 之间
(-1, -1)
:屏幕左下角(1, 1)
:屏幕右上角(0, 0)
:屏幕中心
所以我们只要传入在这个范围内的顶点,GPU 就知道它们在画布的哪里了,然后再自动映射到屏幕上。
我们怎么验证这个?
可以试着只画中间区域,比如:
(-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 坐标后,会:
- 根据 UV 坐标在绑定的纹理图中找到对应的像素;
- 从纹理图中取出该像素颜色;
- 把这个颜色用于当前的片元(像素)渲染。
这个过程就是所谓的“纹理采样”。
四、这个流程和我们之前手动实现的很像
我们之前自己模拟实现过一个图像采样过程,比如直接从图像数组中按照 (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 设置的颜色)如何组合,这一步是在片元着色前决定最终像素颜色的重要一环。
函数原型
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 |
纹理颜色和当前颜色混合 | 按指定混合方式融合,较少使用 |
示例代码
// 设置纹理颜色与顶点颜色进行相乘(常用)
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
// 替换为纹理颜色(不受 glColor 影响)
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
使用流程中的位置
调用 glTexEnv
通常出现在设置纹理前或绑定纹理后,如:
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
决定了 纹理颜色如何与已有颜色(如顶点色)混合,是固定管线渲染中控制最终色彩表现的关键控制点。
六、总结
- 上传纹理:通过
glTexImage2D
上传图像数据,指定图像的格式、大小、颜色等参数。 - 启用纹理:调用
glEnable(GL_TEXTURE_2D)
来启用纹理功能。 - 绑定纹理:使用
glBindTexture
绑定正确的纹理,以确保后续的绘制操作应用到正确的纹理。 - 设置纹理环境:通过
glTexEnv
设置纹理与其他颜色的结合方式,例如使用GL_MODULATE
模式将纹理颜色与当前颜色相乘。
尽管我们已经完成了纹理的上传与绑定工作,但最终的效果仍然未必显示出来。这是因为OpenGL的操作是非常状态驱动的,我们需要进一步完善纹理采样规则,确保纹理正确应用到图形上。
glTexImage2D
是 OpenGL 中用于 指定一个二维纹理图像 的核心函数,它的作用是将我们准备好的图像数据上传给 GPU,以便在渲染时能通过纹理采样使用这些图像。它是纹理工作的关键步骤之一。
函数原型
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* 等类型 |
举个例子
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
前,必须完成以下步骤:
生成纹理 ID:
GLuint texture_id; glGenTextures(1, &texture_id);
绑定纹理:
glBindTexture(GL_TEXTURE_2D, texture_id);
设置纹理参数(如过滤模式、环绕方式):
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 行为(如果启用)
- 边界颜色(当超出纹理范围时)
函数原型
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 |
示例设置代码
// 设置放大和缩小时都使用线性过滤
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);
总结一下我们到目前为止做的内容:
- 使用
glTexEnv
设置纹理与当前颜色的混合方式(例如GL_MODULATE
代表相乘) - 使用
glTexParameteri
设置纹理的采样方式与边界行为等 - 这些设置都依赖于
glBindTexture
绑定的当前纹理对象
注意
即使完成了上面的所有设置,有时画面上仍然不会出现任何内容。这很可能是因为还有一些其他问题(比如没有正确传入纹理坐标,或者片元根本没有被绘制),这个问题可以留作后续调试练习。
https://registry.khronos.org/OpenGL-Refpages/es3.0/html/glTexParameter.xhtml
注意:我们还没有指定步幅(stride)
在使用 glTexImage2D
上传纹理数据时,即使已经正确设置了宽度、高度、颜色格式(如 GL_RGBA
)、数据类型(如 GL_UNSIGNED_BYTE
)等参数,仍然可能遇到纹理无法正确显示的问题。一个常被忽视的关键点是**像素数据的行间距(stride)和数据排列方式(packing)**没有被显式指定,这可能导致纹理数据在上传时出现错位或扭曲。
主要问题:缺少像素对齐方式的设置
默认情况下,OpenGL 会根据其内部规则来推测图像每一行的数据间距(如是否按照4字节对齐)。如果上传的数据在内存中的排列方式与 OpenGL 期望的不一致,就会出现图像显示异常的问题。
什么是 stride 和 packing
stride(步长):图像中每一行占据的字节数,可能大于图像宽度 × 每像素字节数。举例来说,某些图像数据格式可能在每行后填充额外的字节用于对齐。
packing(数据对齐方式):OpenGL 如何解析 CPU 提供的图像数据的每一行之间的间距。默认是按照4字节对齐的,即每一行数据长度必须是4的倍数;如果不是,就可能需要手动指定。
如何解决:使用像素存储参数函数
OpenGL 提供了几个函数用于设置像素存储的方式,最常用的是:
glPixelStorei(GL_UNPACK_ALIGNMENT, x);
这个函数设置 CPU 到 GPU 传输像素数据时的行对齐方式:
x
可以是 1, 2, 4, 或 8(默认是 4)- 如果图像宽度乘每像素字节数不是 4 的倍数,应该设置为 1
示例
假设我们上传的图像数据是一张 3x3 的 RGB 图像(每像素3字节),3×3 = 9 字节每行,这不是4的倍数,所以必须这样写:
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // 按字节对齐
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 3, 3, 0, GL_RGB, GL_UNSIGNED_BYTE, imageData);
否则,OpenGL 会认为每行是4字节对齐,读取时会跳过错误的字节,导致图像错乱。
还有哪些相关函数
除了 GL_UNPACK_ALIGNMENT
,还有其他像素传输设置函数可以根据需要配置:
函数 | 说明 |
---|---|
glPixelStorei(GL_UNPACK_ROW_LENGTH, value) |
每一行像素的长度(单位:像素) |
glPixelStorei(GL_UNPACK_SKIP_ROWS, value) |
跳过图像前 value 行 |
glPixelStorei(GL_UNPACK_SKIP_PIXELS, value) |
每行开头跳过 value 个像素 |
glPixelStorei(GL_UNPACK_ALIGNMENT, value) |
行对齐方式,默认是 4 |
这些设置用于告诉 OpenGL:从 CPU 提供的内存中解析图像数据时该如何跳过、对齐和读取像素。
总结
- 使用
glTexImage2D
上传纹理时,不仅要提供尺寸、格式、数据类型等信息,还必须确保数据的行对齐方式与 OpenGL 设置一致。 - 若图像数据不是4字节对齐,必须调用
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
等函数手动指定正确的 unpack 设置。 - 如果纹理出现错乱、错位或颜色异常,第一时间应检查像素传输参数设置是否与实际数据格式一致。
是否还想补充 glTexSubImage2D
的使用方法或纹理更新相关内容?
win32_game.cpp: 解释这些参数
在设置纹理参数时,有许多关键项需要理解和配置。我们将按类别详细梳理这些设置的意义和使用方法,以下是对 glTexParameter
中常见配置项的全面解析:
纹理过滤设置(Texture Filtering)
1. GL_TEXTURE_MIN_FILTER
- 作用:控制纹理在被缩小时(即原始纹理分辨率大于实际渲染显示区域)如何采样。
- 常用设置:
GL_NEAREST
:使用最邻近的像素,不做任何插值(不平滑)。GL_LINEAR
:使用双线性插值,进行模糊处理。- 其他如
GL_NEAREST_MIPMAP_NEAREST
等用于 mipmapping,但我们不使用。
我们的配置:
我们关闭所有 mipmapping,选择 GL_NEAREST
,这样当纹理缩小时直接采样最近的 texel,不进行插值或模糊处理。
2. GL_TEXTURE_MAG_FILTER
- 作用:控制纹理在被放大时(纹理分辨率小于实际渲染显示区域)如何采样。
- 常用设置:
GL_NEAREST
:直接放大最近的像素,效果类似像素风格。GL_LINEAR
:放大时做插值处理,更平滑。
我们的配置:
同样设置为 GL_NEAREST
,关闭任何模糊效果,方便调试和观察原始像素数据。
Mipmapping 相关参数(我们不使用)
Mipmapping 是一种预处理技术,用于生成不同分辨率的纹理版本以提升缩放时的渲染性能和质量。但我们暂时禁用该特性,因此以下参数全部忽略:
GL_TEXTURE_BASE_LEVEL
/GL_TEXTURE_MAX_LEVEL
GL_TEXTURE_MIN_LOD
/GL_TEXTURE_MAX_LOD
纹理坐标环绕模式(Texture Wrapping)
OpenGL 使用 S
, T
, R
来表示纹理坐标轴:
S
对应 U 坐标(横向)T
对应 V 坐标(纵向)R
对应 W 坐标(用于三维纹理)
常用设置项:
GL_TEXTURE_WRAP_S
:S轴的环绕方式GL_TEXTURE_WRAP_T
:T轴的环绕方式- (如果是三维纹理,还可设置
GL_TEXTURE_WRAP_R
)
可选值:
GL_CLAMP_TO_EDGE
:坐标超出[0,1]范围后钳制到边缘像素GL_REPEAT
:坐标超出[0,1]范围后重复纹理GL_MIRRORED_REPEAT
:坐标超出后以镜像方式重复GL_CLAMP_TO_BORDER
:超出时使用边界颜色(需要设置边界色)
我们的配置:
我们选择 GL_CLAMP_TO_EDGE
,不希望出现纹理重复或镜像,仅希望坐标超出范围后直接贴边。
其他不常用参数(我们忽略)
GL_TEXTURE_PRIORITY
:设定纹理在 GPU 显存中的优先级,用于内存管理,我们暂不涉及。GL_TEXTURE_RESIDENT
、GL_TEXTURE_COMPARE_MODE
等也不适用于当前上下文。
注意版本兼容性
有些参数或功能在当前使用的 OpenGL 版本中并未定义或支持,可能因为我们使用的是较旧或较基础的版本。在配置参数时需要结合实际 OpenGL 环境进行确认。
总结配置示例(纹理 2D):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_ENV_MODE, GL_MODULATE);
我们关闭了所有 mipmapping 和过滤,设置为最近采样模式;同时环绕模式设置为钳制边缘,避免纹理重复或镜像。这些设置简化了调试流程,确保渲染输出清晰直观,符合像素精度的显示需求。
是否还需要我们整理一份完整的 glTexImage2D
+ glTexParameter
全流程配置参考?
运行游戏并确认我们已经做到了
我们已经快完成了整个纹理设置流程。当前的配置已经非常接近正确的状态了,但仍需最后的核对和调整。
我们通过设置了一系列的 OpenGL 纹理参数,使得纹理渲染过程可以按照我们期望的方式工作。我们主要配置了以下几个关键方面:
1. 纹理过滤模式(Texture Filter)
- GL_TEXTURE_MIN_FILTER:当纹理被缩小时使用的过滤方式。我们设置为
GL_NEAREST
,即使用最近邻采样,不进行双线性插值或 Mipmap 等处理。这样会让图像看起来更“锐利”,但可能产生锯齿或闪烁感。 - GL_TEXTURE_MAG_FILTER:当纹理被放大时使用的过滤方式,同样设置为
GL_NEAREST
,避免插值造成模糊。
我们选择关闭所有 Mipmap(多级渐远纹理)相关的设置,如 GL_TEXTURE_MIN_LOD
、GL_TEXTURE_MAX_LOD
、GL_TEXTURE_BASE_LEVEL
、GL_TEXTURE_MAX_LEVEL
等。因为当前我们不希望启用 Mipmap 功能,所以这些设置暂时不需要理会。
2. 纹理包裹模式(Texture Wrapping)
- 设置了
GL_TEXTURE_WRAP_S
、GL_TEXTURE_WRAP_T
(以及可能的GL_TEXTURE_WRAP_R
,用于三维纹理),用于定义当纹理坐标超出 [0,1] 范围时的行为。 - 我们选择使用
GL_CLAMP_TO_EDGE
,表示当纹理坐标超出范围时,使用边缘颜色进行扩展,不进行重复或镜像。这样可以避免不必要的重复图案或拉伸变形。
3. 其他设置
- 纹理优先级(GL_TEXTURE_PRIORITY):当前未设置,因为我们不需要考虑纹理被丢弃或缓存淘汰的情况。
- OpenGL 版本兼容性问题:我们注意到部分纹理参数可能在当前使用的 OpenGL 版本中尚未支持或存在差异,需要根据实际情况确认支持情况或进行兼容性处理。
总结
整体配置已经非常接近目标,只差最后的验证和细节修正。我们设定了基础的纹理格式、大小、颜色通道、纹理过滤与包裹方式。接下来应进一步确认纹理数据在内存中的排列是否正确,比如 stride(步幅)、像素对齐方式是否与 glTexImage2D
的默认期望一致,必要时通过设置像素存储参数(如 glPixelStorei
)来显式指定数据的对齐和步进行为,确保传入的数据能够被正确解释并绘制。
整个过程虽然参数繁多,但每一项设置都为正确显示纹理提供了保障。我们正在朝着最终目标逐步推进。
“这完全是谎言”
我们已经完成了整个 OpenGL 渲染设置流程,并且成功运行了游戏。这说明所有相关的参数都已经正确配置,OpenGL 可以顺利读取我们提供的纹理数据并将其渲染到屏幕上。
尽管我们没有显式指定纹理数据的打包方式(比如 stride 或像素对齐),但幸运的是,我们提供的缓冲区刚好以 OpenGL 默认所期望的方式进行了内存打包。也就是说,内存布局和 OpenGL 的读取方式正好一致,从而避免了可能出现的显示错误或图像错位问题。这种情况下,我们无需使用额外的像素存储设置函数(例如 glPixelStorei
)去调整读取行为。
在确认一切正常后,我们还将画面分辨率设置为 1920x1080,实现了全屏渲染,并成功进入游戏画面。场景正常渲染,小角色也可以在场景中自由移动,说明整体渲染流程、纹理绑定、绘制调用等都已经稳定运行。
目前这只是第一轮完成基础流程的尝试。后续我们还需要逐步深入理解和完善各个环节,包括:
- 更细致地控制纹理数据的上传和管理;
- 更全面地理解 OpenGL 的状态设置和渲染流程;
- 引入更多的游戏渲染逻辑,而不仅仅是将一张图像贴到屏幕上;
- 研究和实现更复杂的渲染技术,如着色器、灯光、阴影、动态效果等。
这次的实现展示了从初始化 OpenGL 到完整渲染流程的一条基本路径,也为后续进一步构建完整游戏渲染系统打下了基础。
至此,我们顺利完成了基本的 OpenGL 环境搭建、纹理上传与参数配置、最终显示到屏幕等关键步骤,整个渲染流程验证成功。我们将在此基础上逐步扩展和提升渲染能力。
win32_game.cpp: 引入全局变量 GLuint BlitTextureHandle
我们暂时先去掉了一部分代码,当前阶段其实并不需要保留它。虽然迟早都要处理这部分逻辑,但目前可以先跳过,专注于更关键的部分。
我们知道,在初始化 OpenGL 的时候,一旦创建了有效的渲染上下文(Context),就可以立即生成纹理对象(使用 glGenTextures
)。这实际上是我们需要做的最基本的事情,一旦有了上下文,纹理的生成就可以立刻完成。
之所以之前没有这么做,是因为当时不想额外处理这部分内容,但这不是一个永久的状态,之后肯定要把这块逻辑加回来。
关于纹理的初始化,我们应该尽早执行,在有了 OpenGL 上下文之后尽快调用纹理生成和配置的代码,可以确保渲染流程更加清晰且稳定。当前的做法只是为了快速推进整体流程,因此对部分步骤进行了临时跳过,但这不是最终方案,后续必须清理和调整这些临时代码,确保结构清晰、逻辑正确。
我们能否以某种方式验证垂直同步(vsync)?
我们目前尚未启用垂直同步(VSync),因此也就没有任何可以验证的内容。换句话说,现在并没有让 OpenGL 对渲染进行垂直同步的请求,所以此时还无法判断或测试其是否生效。
垂直同步的相关设置会在后续步骤中加入。只有在我们显式请求 OpenGL 启用 VSync 的时候,它才会开始起作用,也就是说只有在明确告诉它要进行帧率同步之后,才需要去验证是否有效。
当前阶段的渲染过程没有启用 VSync,因此图像刷新可能不会与显示器的刷新率保持一致。实际渲染中是否发生撕裂或卡顿等现象,取决于系统和驱动默认的设置,但由于我们并未明确要求 VSync,因此这些行为不是我们控制范围内的事情,后续将专门设置和处理。
从 u,v 坐标到屏幕坐标转换时是否会有拉伸问题?如果有,你会如何修复?
在将纹理的 UV 坐标映射到屏幕坐标的过程中,确实可能会出现拉伸(Stretching)问题。虽然通常我们会将单位立方体(unit cube)直接映射到视口(viewport)上,从而实现一一对应的关系,但必须明确一点:前提是所有数学变换正确,纹理坐标和顶点坐标匹配,才能避免视觉上的变形。
你绑定的纹理名称在第一次迭代后总是为零
在迭代过程中,有人提到绑定(binding)始终在第一次迭代后为 0,但这个说法并不准确。至少在我们所使用的 OpenGL 版本中,情况并非如此。
在旧版本的 OpenGL 中,确实存在某些行为让人误解为绑定值在迭代后会自动变为 0,但这通常是由于上下文状态管理不当导致的。例如,如果我们没有明确设置或更新绑定目标,OpenGL 会维持先前的绑定状态,这可能会给人一种“总是为 0”的错觉。
实际上,只要我们在每次使用纹理(或其他可绑定资源)前,明确调用如 glBindTexture
或类似绑定函数,并传入正确的纹理 ID,就不会出现绑定始终为 0 的问题。需要特别注意的是:
- OpenGL 是一个状态机,如果我们在某一帧之后没有重新设置绑定,它会继续使用上一次的状态;
- 若绑定目标未初始化或被错误释放,可能导致其值不可用或者表现出为 0;
- 在某些调试或驱动工具下,如果资源生命周期没管理好,也可能观察到绑定失败或被清零。
因此,我们必须确保在每一帧或每一次绘制调用之前,正确并明确地绑定需要的资源,避免依赖任何隐式状态。只要状态设置得当,绑定的值在迭代中不会无故变为 0。关键在于清晰地管理上下文状态和资源生命周期。
我想在旧版 OpenGL 中,你甚至不需要 glGenTextures,你可以选择任意的整数
关于是否可以绕过 glGenTextures
而直接使用任意整数作为纹理 ID,这是不正确的。我们必须调用 glGenTextures
来生成合法的纹理对象 ID。这个函数的作用是让 OpenGL 为我们分配并初始化一个有效的纹理对象,并返回一个可以使用的纹理句柄(通常是一个整数)。这个 ID 是 OpenGL 内部管理资源的关键,它并不是一个可以随意指定的任意数值。
尝试直接使用一个自己硬编码的整数(比如 42
或 123
)作为纹理 ID 来进行绑定,例如:
glBindTexture(GL_TEXTURE_2D, 42);
虽然在某些驱动或调试环境下不会立即报错,但这并不会创建一个实际有效的纹理对象。OpenGL 规范要求,只有通过 glGenTextures
获取的 ID,才会被视为合法的纹理对象。否则:
- 绑定的纹理对象是未定义的行为;
- 很可能不会触发任何图像上传或绘制效果;
- 在启用调试输出的环境下可能会看到相关警告或错误。
因此,必须使用:
GLuint texID;
glGenTextures(1, &texID);
来获得一个有效的纹理对象句柄,然后才能对其调用 glBindTexture
、glTexImage2D
等函数进行配置和使用。
总结:
- 不能随意指定整数作为纹理 ID
- 必须使用
glGenTextures
来获得合法 ID - 这是 OpenGL 内部资源管理的标准机制
正确使用这一机制是确保渲染正确性和资源稳定性的基础。
win32_game.cpp: 设置 GlobalBlitTextureHandle = 1; 而不是使用 glGenTextures
在讨论是否需要调用 glGenTextures
生成纹理对象时,出现了对这个过程的疑问。一些实验表明,如果直接将纹理 ID 设置为一个固定的数字,比如 1
,可能会出现问题。这样做可能导致绑定的纹理对象是默认的纹理(ID 为 0),这意味着实际上并没有创建有效的纹理对象。
为了验证这个问题,提出了一个测试方案:尝试在提交一个纹理之后,再设置另一个纹理 ID(例如 10),并查看是否能得到一个空白的纹理效果。如果可以成功显示一个白色纹理,这意味着 ID 为 10 的纹理并没有内容,这样的测试可以帮助验证是否需要 glGenTextures
。
结果显示,若没有使用 glGenTextures
生成有效的纹理对象,那么直接设置一个数字可能无法确保该纹理有效。这也让人质疑是否 glGenTextures
真的没必要调用。实际上,glGenTextures
的作用是确保分配出有效的纹理对象,并返回一个合法的纹理 ID。因此,即使有人提出直接使用固定的纹理 ID,还是需要依赖 glGenTextures
来确保纹理的合法性。
总结来说:
- 不能随意使用任意数字作为纹理 ID,必须通过
glGenTextures
来生成一个合法的纹理对象。 glGenTextures
的作用是生成有效的纹理 ID,确保 OpenGL 能够管理这些纹理资源。- 尽管直接使用纹理 ID(如 1 或 10)似乎能正常工作,但这种做法并不符合 OpenGL 的规范,也有可能导致不可预期的行为。
- 理论上,如果不调用
glGenTextures
,设置的纹理 ID 可能会指向一个未初始化的纹理,进而导致渲染出错或表现不正确。
因此,正确的做法依然是调用 glGenTextures
来生成纹理对象。
当图像和纹理大小相同的时候,纹理过滤(->GL_NEAREST)会发生吗?
在使用 GL_NEAREST
进行纹理过滤时,即使纹理图像和显示的大小完全一致,过滤操作仍然会发生。具体来说,如果选择了 GL_LINEAR
作为纹理过滤方式,表示应用双线性过滤,那么在纹理的每个像素采样时,系统会计算周围四个像素的加权平均值。然而,如果纹理和显示大小完全一致,双线性过滤的计算结果会变成一种特殊情况,其中权重系数变成 0
和 1
,即 0, 0, 0, 1
。
这种情况下,虽然双线性过滤的计算依旧被执行,但因为权重系数的设置,最终效果与 GL_NEAREST
(最近点采样)一样,看起来仿佛没有任何变化。在理论上,如果硬件没有问题,这样的计算会确保正确的采样,但实际上仍然会应用过滤操作,只是结果与没有应用过滤时相同。这个过程的关键在于过滤本身始终会执行,但实际视觉效果可能看起来没什么不同,除非纹理和屏幕大小不一致。
在你的职业生涯中,你偏好使用哪个 GPU 库,例如 OpenGL、DirectX、GLSL 等?你现在使用的是哪个,为什么?
在职业生涯中,虽然我倾向于使用 OpenGL,因为它具有跨平台的优势,但实际上,我并不喜欢任何 GPU 库。无论是 OpenGL、DirectX 还是 GLSL,它们都有各自的问题,都不是我设计的方式。它们的设计方式并不完全符合我的期望,存在很多不足之处。因此,尽管 OpenGL 在跨平台性上有一定的优势,但我对这些工具的设计并不感到满意。
等我们升级到更现代的 OpenGL 后,游戏会先渲染到帧缓冲区,然后再渲染到相同的三角形,还是直接渲染到主窗口缓冲区?
在升级到更现代的 OpenGL 时,渲染过程是否通过帧缓冲进行,取决于是否需要后处理效果。如果有后处理效果,那么首先需要将游戏渲染到一个缓冲区中,之后对该缓冲区进行一些效果处理,通常是通过缓冲区链来实现,而不是常见的“乒乓”缓冲。最终的步骤是将缓冲区的内容解析出来,显示到屏幕上。
换句话说,如果没有后处理效果,通常会直接将游戏渲染到主窗口缓冲区。但如果涉及到图像后处理,首先渲染到一个中间缓冲区,应用效果后,再将最终结果显示到屏幕上。
在你移除 Init 变量并内联初始化纹理之前,你总是将 0 作为名称传递,顺便提一下
在代码中,提到的“单位变量”和“内联”可能是关于纹理名称的处理,指出始终传递零作为纹理名称。这个问题的原因可能是忘记在纹理变量前面加上 static
关键字,导致变量没有被正确处理为静态变量。通过加上 static
,变量的生命周期和作用域才会正确管理,从而避免问题的发生。
那么现在就没有办法直接将图像复制到后台缓冲区了吗?
目前,直接向显卡的后备缓冲区(back buffer)进行绘制的方法已经不再常见。在过去的 DirectDraw 日子里,通过将显存映射到主内存,CPU 可以直接写入显卡的内存,这样虽然数据还是通过 PCI 总线传输,但看起来好像是 CPU 直接写入显卡内存。但实际上,数据依然需要通过 PCI 总线传输。
如今,随着图形处理技术的进步,图形处理变得更加异步,直接将数据写入显卡内存已经不再是一个高效的做法。现代的做法是将数据先存储在内存中的某个地方,然后告诉显卡去获取这些数据,这种方式比直接写入显卡内存更高效。
当然,理论上还是可以通过编写特殊的驱动程序来实现直接写入显卡内存,将 GPU 内存映射到主内存,允许 CPU 直接向其写入,但这种方式效率较低,已经不再是主流方法。
现在我们使用 OpenGL 将缓冲区移到 GPU 上,速度上有区别吗?
使用 OpenGL 将缓冲区移到 GPU 上,与之前的做法相比,速度差异并不显著。虽然没有做精确的性能测量,但从实际表现来看,速度差距较小,甚至可能会稍微变慢一些。这是因为现在的渲染方式可能更难预测,导致性能上有所波动。
不过,如果不进行初始化而直接进行图像的转换(blit)操作,渲染的速度可能会有所改变。另一个需要注意的因素是双缓冲(double buffering)被关闭了,这可能会影响渲染的平滑度。如果启用双缓冲,可能会提高渲染的效率和流畅度。
此外,由于流式捕捉的原因,也有可能导致性能受到影响。整体而言,这种方法可能不是最理想的渲染方式,特别是在需要高效渲染时。
你知道如何使用 OpenGL 优化 CPU 和 GPU 之间的 PCI 传输吗?
在使用 OpenGL 进行 PCI 传输优化时,实际上并没有太多可以显著优化的空间。原因在于,基本上只是将纹理传送到 GPU 并一次性绘制,整个过程由驱动程序处理得相当高效。
如果一定要优化,唯一可能的方法是减少不必要的内存复制操作。假设关注的是内存传输的时间,可以尝试通过多线程来重叠执行。例如,使用一个线程来处理图像数据的传输,同时在另一个线程上进行渲染工作(如游戏模拟)。这样可以在纹理传输的同时进行其他工作,从而减少等待时间,虽然并不会加速传输本身。
此外,早期有报道指出,通过 GPU 锁定内存并写入数据的方式其实比直接传输慢,因为这种方法会在驱动中产生同步点,导致 GPU 和驱动之间需要协调内存访问。而直接传输(如通过 OpenGL)通常更高效。
不过,这种内存传输操作并不是系统中的性能瓶颈,因此优化这部分的收益并不会非常大。所以虽然可以通过重叠操作来优化整体流程,但对性能的影响相对有限。
我不知道这个问题是否适用于上一个问题,我刚刚才到,但 glTexSubImage2D 可能比 glTexImage2D 更快
在讨论是否使用 glTexSubImage2D
替代 glTexImage2D
时,通常认为 glTexSubImage2D
会比 glTexImage2D
快,然而这实际上可能并非如此。因为在使用 glTexSubImage2D
时,显卡能够检测到纹理并不被替换,这意味着可能会变得更慢。
简单来说,glTexSubImage2D
在某些情况下可能导致性能下降,因为显卡通常会优化纹理替换操作,而如果它认为纹理没有发生变化,可能会导致它进行额外的检查或操作。因此,从性能角度来看,直接替换纹理的操作可能更为高效。
总体来说,虽然看起来替换纹理可能更快,但实际上在某些情况下,显卡的优化机制可能使得 glTexSubImage2D
比 glTexImage2D
更慢。
Blackboard: glTexImage2D vs glTexSubImage2D
在讨论 glTexImage2D
和 glTexSubImage2D
时,重点在于两者的效率差异以及依赖关系的影响。
glTexImage2D
与 glTexSubImage2D
的区别
glTexImage2D
:它会完全替换纹理内容,每次调用都会重新分配内存并复制整个图像数据到纹理中。所有的纹理数据都会被覆盖,并且会创建一个新的依赖链,所有依赖于该纹理的操作都会等待纹理更新完成后才会继续执行。glTexSubImage2D
:与glTexImage2D
不同,glTexSubImage2D
只替换纹理中的一部分数据,其余的纹理内容保持不变。这意味着更新部分数据时,其他部分的纹理可以继续使用,依赖链更为复杂,更新操作需要等到纹理更新完成才能继续进行。
为什么 glTexSubImage2D
可能不如 glTexImage2D
高效
虽然 glTexSubImage2D
可以更新纹理的部分内容,理论上看起来比 glTexImage2D
更高效,但是由于它在创建依赖链时的复杂性,反而可能导致性能下降。具体原因包括:
- 依赖链增加:如果使用
glTexSubImage2D
更新纹理的部分内容,驱动程序会生成更多的依赖链,纹理的更新会受到影响,其他操作会等待纹理更新完成。这种依赖关系的增加会使得 GPU 的任务处理变得更加串行化,减少并行度,从而影响整体效率。 - 更长的等待时间:因为在更新纹理时,GPU 需要等待
glTexSubImage2D
完成,所有依赖这个纹理的操作都会被延迟执行,造成更长的等待时间,影响渲染效率。
对比 glTexImage2D
和 glTexSubImage2D
glTexImage2D
重新创建整个纹理,允许 GPU 更好地并行处理后续任务,因此通常更高效,尤其是在处理完整的纹理替换时。glTexSubImage2D
尽管只更新纹理的一部分,但由于产生的依赖关系可能导致更多的串行化执行,因此通常不如glTexImage2D
高效,尤其在需要频繁更新纹理时。
在实际开发中的应用
- 如果是完全替换纹理数据,
glTexImage2D
更合适,因为它的执行不需要复杂的依赖关系管理。 - 如果只更新部分纹理,理论上
glTexSubImage2D
应该更高效,但在某些情况下可能因为依赖链过长而导致性能下降。 - 需要注意的是,实际效果还要依赖于具体的硬件和驱动程序的实现。为了得到更好的性能,通常需要根据具体的情况进行测量和优化,可能需要使用不同的纹理更新策略(例如使用双缓冲或并行执行)。
总结
glTexSubImage2D
可以在更新纹理的一部分时避免重新分配整个纹理的内存,但由于增加了依赖链,可能会导致更低的并行性,从而影响性能。相对来说,glTexImage2D
在完全替换纹理时可能会更高效。
在那一连串的 gl 命令中,显卡究竟在什么时刻参与进来?是命令依赖的吗?还是驱动程序依赖的?
在 OpenGL 命令的执行过程中,GPU 介入的时机和方式主要取决于 驱动程序。具体来说,GPU 的参与时机完全依赖于驱动程序如何优化和管理渲染过程。驱动程序负责决定如何调度和处理图形命令的执行顺序,可能会进行某些优化,如批处理、延迟执行或并行处理等。
此外,虽然硬件本身(GPU)也会对优化和执行产生影响,但大部分的优化决策和执行安排都是由驱动程序控制的。因此,GPU 的参与实际上是由驱动程序的实现和选择的优化策略决定的,而不是固定的硬件行为。不同的驱动程序可能会有不同的策略和处理流程,因此,GPU 参与的时机也可能有所不同。
总结来说,GPU 何时开始介入渲染任务,完全由驱动程序的设计和优化决定,而驱动程序又根据不同的硬件特性进行相应的调整。