回顾并为今天的内容做准备
我们已经完成了硬件显示的实现,现在通过GPU来显示游戏。原本以为这会花费很长时间,但结果实际所需的时间并不多。因此,我们现在有了进展,但接下来应该做什么还不确定。虽然有很多事情可以做,但我认为最合适的做法是继续深入GPU的部分,进一步展示如何通过GPU来渲染游戏。
这样做的好处是,实际上我们可以很简单地在GPU渲染和软件渲染之间切换,而无需太多工作。特别是,因为目前我们不需要使用着色器来实现这一点,这使得切换渲染方式变得非常简单。虽然以后可能会需要使用着色器,特别是当我们为游戏加入更多光照等效果时,着色器将变得必不可少,但到目前为止,使用目前的技术,我们几乎可以完成整个游戏的开发,除了一个问题。
唯一的难点是如何将纹理(也就是我们用来做游戏的小精灵图像)有效地传输到GPU上。如何高效地将这些纹理从内存中传送到显卡,仍然是我们需要解决的关键问题。
回顾目前我们的图形架构是如何工作的
目前,游戏的图像加载和渲染流程相对简单。当需要图像时,我们只需从硬盘读取图像并将其加载到内存中,并确保渲染器不会在图像加载完成之前尝试使用它。这个过程不涉及与显卡的复杂通信,操作非常直接。
然而,当我们将渲染转移到GPU时,渲染的流程可以保持基本不变,因为即使游戏中有成千上万个精灵,2D游戏的命令流量也不是非常复杂。但问题出在纹理数据上。我们的游戏分辨率是1920x1080,尤其是像过场动画那样的大量全屏图像数据,在渲染时需要管理大量的纹理数据。在纹理数据传输过程中,如果处理不当,可能会导致画面卡顿,因此需要插入一个额外的阶段来优化纹理的流式传输。
尽管这看起来并不复杂,但现实中GPU并非最初为实时纹理流式传输设计。纹理通常是在加载画面时上传到显卡,而不是在游戏运行时动态加载。因此,GPU的API并未很好地支持实时纹理流式传输,使用这些API时,需要通过一些扩展机制来完成,而且并非所有显卡都支持或稳定地处理这些机制。
其中一个需要关注的问题是“同步标志”(fencing)机制,用于检测纹理是否上传完成。在一些显卡上,支持这一机制的稳定性较差,这增加了开发的复杂度。因此,考虑到这些因素,可能的做法是让游戏暂时以较低的帧率和卡顿的状态运行,直到纹理上传到显卡。虽然这种做法会导致明显的卡顿,但可以将其作为一个独立的问题,等到适当的时机再解决,避免一开始就让人陷入过于复杂的GPU知识中。
从开发的角度来看,虽然最终我们需要解决这个卡顿问题,但目前可以接受这种不稳定的表现。接下来,可以在游戏中实现切换渲染方式(GPU渲染和软件渲染),并启用垂直同步(vsync),这样可以保证游戏在显示器刷新率下稳定运行,并保证GPU渲染的路径足够快速,从而确保游戏能够以60帧每秒的速度运行,而不必一开始就过多关注软件光栅化器的优化。
最终,目标是确保游戏能够在不同的渲染路径之间切换,并进行性能测试,以便决定是否需要对软件渲染进行进一步优化。
运行游戏并刷新我们对上次进展的记忆
我们运行了游戏,可以清楚看到它的表现和之前使用软件渲染器时几乎完全一致,只是现在通过显卡进行渲染。各项功能仍然正常运作,没有明显的异常。
我们回想起之前似乎曾出现过一个与浮点数转换相关的 bug。当让角色持续运行一段时间后,印象中在某个场景转换时会触发一个断言错误,提示排序结果不正确。虽然初看可能会以为是基数排序(radix sort)的问题,但更可能是出在浮点数转整数的代码上。
现在观察下来还没有明显异常,也许还需要运行到更后面的场景才会复现这个问题。不过目前为止一切正常,暂时不需要担心。如果这个问题确实存在,那它迟早会在之后的剧情预览中再次出现,届时可以再深入排查。
另外,我们注意到排序结果中有两个元素处于相同的 Z 值,这导致了一个可见的问题:圣诞老人被错误地渲染在背景后方。由于现在启用了基于 Z 值的排序,这类问题必须被认真处理。为了解决这种情况,可以考虑为某些剧情或过场动画提供关闭 Z 排序的选项,因为这些场景中可能并不需要进行 Z 层级排序。
总的来说,当前状态看似一切正常,之前担心的问题可能只是误判。不过,我们会继续观察,一旦问题再次出现,就会着手修复。
game_render_group.cpp: 查看当前渲染器的工作方式,并讨论将渲染器拆分的概念
现在我们要开始正式处理接下来的工作。当前的渲染架构已经初步完成,并且从结构上来说相当合理。我们将渲染器很好地“隔离”开来,在外部代码中几乎没有直接依赖渲染器的细节。外部调用只是向渲染器发送一系列指令,然后调用诸如 TiledRenderGroupToOutput
和 Render
之类的函数完成渲染操作。换句话说,渲染是一个非常“非侵入式”的过程,这一点是刻意设计的。
这样做的目的是为了让渲染器具备良好的可替换性。比如,我们一方面使用了软件渲染器,另一方面也希望能够引入硬件加速渲染。即使暂时不需要硬件和软件的切换,未来也可能需要支持不同的硬件 API,比如 Vulkan、Direct3D 等。有些平台或驱动不支持某些 API,比如有些机器无法使用 Vulkan 或 DirectX 12,那么就需要有 OpenGL 或 DirectX 11 等备用方案。因此,从专业开发的角度来看,提早建立起模块化、可替换的渲染结构是一个很有价值的经验策略。
尽管在项目初期完全可以采用更压缩式、更紧耦合的写法,但出于长期维护和扩展的考虑,我们一开始就把渲染器写得比较模块化。当然,目前为止,这种模块化还没有完成得很彻底,我们还没完全把渲染系统抽离出来。因此现在的目标就是开始真正把渲染器完整提取出来,使其成为一个可以真正替换的软件模块,从而支持软件渲染和硬件渲染两种路径。
具体怎么做要看我们希望在两者之间共享多少代码。我们目前的软件渲染器主要逻辑都集中在 RenderGroup
这块。实际工作发生的地方,是在执行排序操作之后进入 DoTiledRenderWork
,而这个函数又被包装在一个平台回调里,直接搜索不太容易找到。但实际上,核心的渲染工作发生在 RenderGroupToOutput
函数中。这个函数消耗了绝大多数的 CPU 时间,它负责遍历所有渲染指令,并调用具体的绘制函数。
从优化角度来看,我们针对 DrawRectangleQuickly
做了优化,因为大多数渲染工作都会走这条路径。游戏最终呈现的所有图像,基本上都通过这个函数产生。
因此,如果想要将渲染切换到硬件路径,比如 OpenGL,那么我们真正需要做的,就是写一个新的 RenderGroupToOutput
版本,把原本指向软件渲染的调用,替换为对应的 OpenGL 渲染调用。
这就是我们现在要做的第一步。
创建 game_opengl.cpp 并将 RenderGroupToOutput 函数转移到其中
现在开始着手真正编写用于 OpenGL 的渲染代码。首先创建了一个新的源文件,命名为 game_opengl
,这个文件中将专门放置所有与 OpenGL 渲染相关的逻辑,方便统一管理和替换。当前的目标是将 RenderGroupToOutput
这个函数从软件渲染路径移植为基于 OpenGL 的实现。
原本的坐标系测试功能 entry_coordinate_system
是测试用的内容,目前阶段不再需要,可以直接忽略。当前实际要处理的渲染指令非常简单,只有三种:清屏(clear)、绘制矩形(render_entry_rectangle)、绘制图像(render_entry_bitmap),其中大部分渲染工作其实都是通过绘制矩形来完成的。
首先检查了排序指令的调用,发现其使用范围很小,实际影响渲染流程的不多,因此转换工作也相对轻松。
清屏操作非常简单,拿到颜色值和目标区域宽高之后,只需要调用 OpenGL 的颜色缓冲区清除函数即可。而这个颜色值在 game_render_group.h
中定义得很明确,是一个 v4
类型的向量,因此可以直接传入 OpenGL 的清除函数中,不需要额外处理。只要把这个逻辑写好,就已经完成了清屏命令的 OpenGL 版本。
接下来就是绘制矩形的部分。矩形的绘制分为两种情况:一种是纯色矩形,另一种是带图像纹理的矩形。后者复杂一些,因为目前还没有实现图像纹理的后台加载机制,所以暂时跳过这一部分,等之后再处理。当前阶段的目标是统一将两种矩形都绘制为纯色矩形。
查看矩形的相关参数,得出绘制范围为一个起点 minP
到终点 maxP
,颜色也已经提供。因此可以通过绘制两个三角形构成矩形来完成绘制。已有的 OpenGL 代码中就包含类似的绘制流程,通过 glBegin(GL_TRIANGLES)
的方式来渲染,可以复用该代码。
构建三角形时,只需将 minP
和 maxP
的 x、y 坐标分别传入即可,具体就是:
- 左下角:minP.x, minP.y
- 右上角:maxP.x, maxP.y
- 左上角、右下角同理构造
唯一需要注意的问题是目前使用的是 OpenGL 的单位立方体坐标系统(-1 到 1),而传入的坐标是已经转换好的屏幕空间坐标,因此需要一个投影矩阵来做转换,否则渲染结果将不正确。虽然这一部分暂时还没实现,但已经识别出它是接下来必须要处理的问题。
总体来说,只要加上一个适当的投影变换矩阵,就可以顺利实现所有纯色矩形的绘制。这意味着只需替换原有的 RenderGroupToOutput
中的软件绘制调用为 OpenGL 调用,就可以实现完整的硬件渲染路径的初步版本。后续再逐步完善纹理图像的支持和坐标转换的问题即可。
game_opengl.cpp: 引入 OpenGLRectangle
现在我们准备进一步封装 OpenGL 渲染调用,以提升代码结构和可重用性。可以将绘制矩形的操作抽象成一个独立的函数,比如叫做 OpenGLRectangle
。这个函数接受矩形的最小点 minP
、最大点 maxP
和颜色 color
参数,然后直接调用 OpenGL 的相关接口进行绘制。由于当前使用的都是纯色填充,不涉及颜色插值处理,因此颜色可以直接设置一次,然后渲染整个矩形。
虽然目前这种方式每次绘制都会进行多次函数调用,看起来会比较低效,但暂时不用担心性能问题。等功能全部实现并稳定之后,可以统一将所有顶点信息打包成一个大的顶点缓冲区(VBO),一次性传入 GPU 执行批量绘制,以显著提高效率。OpenGL 支持这类批处理机制,我们在后续会进行优化。而当前以直接方式绘制更方便调试和理解整个渲染流程。
接下来把之前的绘制调用替换成这个新的 OpenGLRectangle
函数,比如一个矩形的绘制现在可以简单地调用该函数传入对应的坐标与颜色。对于某些类型的绘制,比如使用 entryP
表示起点,而通过两个偏移向量确定最大点坐标的情况,我们也可以通过简单计算得到 maxP = entryP + offset
,然后使用相同的函数进行绘制。
还注意到原来传入的 PixelsToMeters
参数其实并没有被使用,似乎只是以前处理光照用的,现在可以删去,不影响功能。
到目前为止,其实整个硬件渲染器的构建已经相当接近完成了:我们已经实现了清屏命令和基本矩形绘制逻辑,只剩下纹理图像渲染(Textured Rect)部分没有完成。这个会稍微复杂一点,因为需要在后台加载纹理图像资源,并传递到 GPU 上使用。但可以等其他部分稳定之后再解决这个问题。
此外还有一个必须要做的工作:设置投影矩阵。因为当前绘制传入的是屏幕坐标,而 OpenGL 默认使用的是单位立方体坐标([-1, 1])。如果不进行投影变换,绘制出来的位置就会错乱。所以我们需要设置一个投影矩阵,将屏幕坐标正确映射到 OpenGL 的坐标空间。
总结一下当前还未完成但接下来要做的事情有两个核心点:
- 纹理贴图支持:需要实现将图像传入 GPU 并进行纹理坐标处理;
- 坐标转换矩阵:需要设定合适的投影矩阵,让 OpenGL 能够正确解释我们传入的屏幕空间顶点坐标。
完成这两个部分后,整个硬件渲染器的基础功能就将完备。接下来可以进一步优化性能,例如使用 VBO/VAO 等 OpenGL 高效机制替代逐个函数调用绘制。总体来看,得益于 OpenGL 内建的图形管线支持,整个流程并不复杂,只需要合理组织和封装代码结构即可。
黑板:数学α
我们接下来要解决的关键问题是如何将屏幕空间的坐标转换为 OpenGL 所需的单位立方体坐标,也就是 [-1, 1] 区间。这个过程说起来不复杂,但在实际操作中容易因为细节或偏差而出错,需要细致处理。
数学上来说,我们其实已经有了一些基础知识和思路。这个投影变换的核心原理,其实就是一个线性变换加上偏移,它会将屏幕上的像素坐标(例如从左上角是 (0,0),右下角是 (width,height))映射到 OpenGL 的 NDC(Normalized Device Coordinates)坐标系统。
我们要做的就是构建一个正交投影矩阵(Orthographic Projection Matrix)。这个矩阵负责将二维屏幕坐标映射到单位立方体空间。例如,在一个分辨率为 1280x720 的窗口中,左上角的坐标应该被映射为 (-1, 1),右下角为 (1, -1),这样我们才能准确在屏幕上绘制图形。
整个转换流程如下:
-
确定投影边界:根据渲染输出的尺寸设定左、右、上、下边界。例如,left = 0,right = width,top = 0,bottom = height(视是否从左上角开始绘制而定)。
-
构建正交投影矩阵:我们利用这些边界构建一个矩阵,该矩阵会将输入坐标按比例缩放并移动,使其落入 OpenGL 所需要的坐标区间。这个矩阵可以使用类似 glm::ortho 或手动构建:
[ 2/(r-l) 0 0 -(r+l)/(r-l) ] [ 0 2/(t-b) 0 -(t+b)/(t-b) ] [ 0 0 -2/(f-n) -(f+n)/(f-n) ] [ 0 0 0 1 ]
其中 r 是右边界,l 是左边界,t 是上边界,b 是下边界,f 是远平面,n 是近平面。
-
上传矩阵到 GPU:在使用 shader 时将这个矩阵传给顶点着色器,一般通过 uniform 变量完成。
-
在顶点变换时使用矩阵:每个传入的顶点都将乘以这个矩阵,完成从屏幕空间到裁剪空间的转换。
通过这个过程,我们就可以在屏幕上的任意像素位置绘制图形,并且能正确显示在我们期望的位置,不会因为 OpenGL 默认的坐标系而错位。
目前阶段的重点是在程序初始化时计算出正确的投影矩阵,并在渲染前设置好 OpenGL 的视口大小和传入矩阵。之后我们传给 OpenGL 的所有顶点就可以是“屏幕空间坐标”,不再需要手动进行归一化或转换。
这个设置一旦正确完成,就可以为纹理、文本、UI 等一切图形基础打下正确坐标基础,是实现 2D 渲染最重要的一步之一。后续我们就可以继续完善纹理加载、坐标偏移、变换组合等功能,建立完整的渲染体系。
黑板:将 P 从裁剪空间投影到屏幕空间
我们现在讨论的是 OpenGL 中顶点变换的核心流程,重点是如何通过矩阵变换将我们传入的顶点坐标映射到裁剪空间(clip space),进而映射到屏幕空间显示图形。
在 OpenGL 的固定功能管线中,顶点的位置是通过以下矩阵计算得出的:
P' = M_projection × M_modelview × P
这里的 P
是我们传入的顶点坐标(通常是一个四维向量,即 x, y, z, w),而最终得到的 P'
是变换后的裁剪空间坐标。
我们当前把 M_modelview
设置为单位矩阵(Identity),意味着它对顶点坐标没有任何影响,所以变换简化为:
P' = M × P
其中 M
就是投影矩阵,我们唯一关心的变换来自这里。我们传入的顶点坐标 P
是一个四维向量,通常是 (x, y, 0, 1)
,因为我们在做 2D 渲染,不使用 Z 坐标,而 W 设置为 1 以启用齐次坐标系。
在裁剪空间中,OpenGL 的坐标范围是 [-1, 1],即屏幕的左边是 -1,右边是 1,上边是 1,下边是 -1。因此我们需要构建一个投影矩阵,它可以将屏幕坐标(例如从 0 到屏幕宽度、0 到屏幕高度)映射到 [-1, 1] 的标准化坐标。
这种映射本质上是一个线性变换加位移操作,矩阵形式如下:
M = 正交投影矩阵(orthographic projection matrix)
这个矩阵的作用是将坐标从屏幕空间映射到裁剪空间。
除此之外,还有一个重要但不常被注意的步骤 —— 除以 W 分量。这是 OpenGL 做透视投影的关键机制:
- 在经过投影矩阵变换后,得到的是一个四维向量 (x, y, z, w)。
- 接下来,OpenGL 会自动将这个向量除以 W 分量,把它变成 NDC(Normalized Device Coordinates)空间中的坐标,即
(x/w, y/w, z/w)
。 - 在 2D 渲染中,我们通常设置 W 为 1,这样除以 W 就没有变化。
这个步骤看起来“很奇怪”,但它其实是为了支持 3D 投影中“远处变小”的透视效果。在我们当前做的 2D 渲染中并不需要考虑这个透视缩放,因此我们设置 W = 1。
此外,Z 坐标在我们的 2D 场景中基本无用,只是为了兼容 OpenGL 的流程设置为 0。实际我们只关注 X 和 Y。
所以总的来说,我们需要做的是:
- 设置一个合适的正交投影矩阵 M,使其可以将屏幕坐标(如 0 到 width,0 到 height)映射到 [-1, 1] 区间。
- 将顶点乘以该矩阵后得到新的 P’,即裁剪空间坐标。
- OpenGL 会自动做一次除以 W 的操作得到 NDC 坐标。
- 然后 OpenGL 再将这些 [-1, 1] 范围的坐标映射回屏幕的实际像素位置(视口变换)。
从逻辑上来看,我们只是为了配合 OpenGL 的固定流程才做这一步,其实是从“屏幕坐标 → 裁剪空间 → 屏幕像素”的一个来回映射。但这是 OpenGL 的要求,必须满足。
总结一下,我们的主要任务是构建这个矩阵 M 来完成空间映射,并确保我们传入的顶点坐标符合它的要求,剩下的工作就交给 OpenGL 处理了。这为后续的所有绘制打下基础。
我们之所以需要做这些矩阵变换和裁剪空间映射,其实就是为了能够正确利用 GPU 的强大图形处理能力。虽然听起来绕了一些、多做了几步,但这是使用 OpenGL 必须遵守的一套流程,所以我们也无所谓,代价不算大,换来的是整个图形渲染流水线的全部加速能力。
因此,我们真正需要做的就是构建一个合适的矩阵,使得它能够把我们原始的屏幕坐标(比如从左到右是 0 到屏幕宽度,从上到下是 0 到屏幕高度)变换成 OpenGL 所期望的裁剪空间坐标(clip space),也就是从 -1 到 1 的范围。
我们要设置的这个矩阵,其本质上是一个正交投影矩阵,它的任务就是将我们当前的 2D 坐标线性映射到 OpenGL 的标准坐标范围。这个过程需要两个步骤:
-
缩放(Scale):将像素单位的屏幕宽高变换成 [-1,1] 的大小单位。
- 例如,把屏幕坐标的宽度从 [0, width] 缩放成 [-1, 1]。
- 这个缩放因子就是
2.0 / width
,对应的高度是2.0 / height
。
-
偏移(Translate):把原点从屏幕左上角(或左下角)移动到中心点(0,0)。
- 因为裁剪空间中心是 (0, 0),而我们原始坐标系统中心在屏幕左上或左下,所以要加上偏移量,把它拉回中心。
- 这个偏移量通常是
-1.0
,加上缩放后的坐标后刚好能把最左/最下的坐标映射到 -1,最右/最上的映射到 1。
综合这两个变换,我们可以构造出如下的正交投影矩阵 M:
M = | 2/width 0 0 -1 |
| 0 2/height 0 -1 |
| 0 0 1 0 |
| 0 0 0 1 |
这个矩阵乘以我们输入的顶点坐标 (x, y, 0, 1)
,就会得到裁剪空间坐标 (x', y', z', w')
,然后 OpenGL 自动执行除以 W 的操作,最终将其映射到屏幕上。
总结来说:
- 为了兼容 GPU 的图形管线,我们需要手动构造一个矩阵,将 2D 屏幕坐标映射到裁剪空间。
- 这个矩阵本质就是对坐标进行线性缩放和偏移,让原本从 0 到 width、0 到 height 的值变成 [-1, 1]。
- 这是使用 OpenGL 的一个基础操作,不复杂但必须做。
- 一旦设置完成,后续所有的矩形绘制、顶点输入等就可以直接使用屏幕空间坐标,完全由这个矩阵帮我们映射到 OpenGL 的裁剪空间,从而利用 GPU 正常渲染。
这就是我们现在需要完成的任务,目的是为了进入 GPU 加速图形渲染的“正轨”,后续可以绘制更复杂的图形、更高效地管理顶点数据等。
黑板:设置我们的转换矩阵
我们现在要做的,是设置一个矩阵,而这个矩阵其实可以被简化处理,特别是当我们不关心矩阵底部某些值时。因为我们知道输入的向量是类似 (x, y, 0, 1)
的形式,而输出的向量也是类似结构的形式,所以我们就可以预设一些矩阵的值,这样就不需要对每一个元素都深思熟虑,能大大简化思路。
例如,对于输出向量中的 W 分量,我们希望它始终保持为 1,因此在矩阵的最后一行中,我们只保留最右边的那个元素为 1,其它值为 0,这样无论输入什么,W 都会被保持为 1。Z 分量我们也不关心,所以它相关的矩阵部分我们可以保留为零,保证它不影响 X 和 Y 的计算。
这样一来,矩阵的上两行(即用于计算输出 X 和 Y 的部分)就变得很简单了。我们不打算进行任何旋转操作,所以 X 不应受到 Y 的影响,Y 也不应受到 X 的影响,因此我们可以直接将矩阵中的交叉项设为 0。剩下的其实就是两个一元一次线性函数:
- 对于 X 的变换函数:
f(x) = a*x + e
- 对于 Y 的变换函数:
g(y) = d*y + f
我们要做的,就是确定这两个函数的参数 a
、e
(X 轴)和 d
、f
(Y 轴),使得原始屏幕坐标可以被正确地映射到 OpenGL 的裁剪空间,即从 [0, width]
映射到 [-1, 1]
,从 [0, height]
映射到 [-1, 1]
。
以 X 为例,我们知道两个条件:
- 当输入
x = 0
时,输出应该是-1
(对应裁剪空间最左端) - 当输入
x = width
时,输出应该是1
(对应裁剪空间最右端)
带入函数 f(x) = a*x + e
中:
f(0) = a*0 + e = -1
⇒ 得出e = -1
f(width) = a*width + e = 1
⇒ 代入 e 得到:a*width - 1 = 1
⇒a*width = 2
⇒a = 2/width
于是我们得到了 X 坐标的线性变换公式:
f(x) = (2/width)*x - 1
同样地,对 Y 做类似处理,得出:
g(0) = -1
g(height) = 1
最终得出:
g(y) = (2/height)*y - 1
这样,我们的矩阵前两行就构造好了,完整的 4×4 投影矩阵如下:
| 2/width 0 0 -1 |
| 0 2/height 0 -1 |
| 0 0 1 0 |
| 0 0 0 1 |
这个矩阵的作用就是把我们日常使用的像素坐标(例如从 0 到 width)转换成 OpenGL 的裁剪空间坐标系(即从 -1 到 1 的单位立方体中心坐标)。之所以是这个范围,是因为 GPU 之后会把这些值进一步映射成屏幕像素,整个图形渲染流程都基于这个坐标系工作。
这里的数学推导非常直观:
- 先除以
width
或height
,把坐标归一化到[0,1]
范围; - 再乘以 2,把它扩展成
[0,2]
; - 最后减去 1,把它平移到
[-1,1]
,正好对应裁剪空间范围。
也就是说我们用矩阵完成了一个缩放 + 平移的仿射变换操作,目标就是适配 OpenGL 的坐标体系。
后续我们可以先在已有的、已经能正常绘制图形的测试代码中验证这个矩阵是否工作正常。我们暂时不需要把它立即应用到其它部分,因为那部分还没完全接好,测试起来可能效率不高。不如就用现有的框架快速试一下这个矩阵能否正常工作,把这个核心变换验证清楚。这样接下来的工作就能更加稳妥地继续推进。
推导过程
这是一个非常经典的图形学问题,涉及从世界空间(World Space)中的点 P
通过一系列矩阵变换,最终得到裁剪空间(Clip Space)坐标 P'
的过程。我们现在详细地一步一步来推导从:
P' = M_projection × M_modelview × P
如何简化、并最终推导出如下的二维正交投影矩阵:
| 2/width 0 0 -1 |
| 0 2/height 0 -1 |
| 0 0 1 0 |
| 0 0 0 1 |
Step 1:理解各个空间和矩阵的含义
我们从屏幕空间或世界空间中的一个二维点 P
(x, y)出发,OpenGL 渲染管线的变换过程如下:
世界空间坐标 P
→ 模型视图矩阵 M_modelview
→ 相机空间
→ 投影矩阵 M_projection
→ 裁剪空间 P'
整个变换可以合并为一个总的矩阵:
P' = M_projection × M_modelview × P
Step 2:简化为二维情况
在我们当前的2D渲染场景中:
- 模型视图矩阵
M_modelview
被设置为单位矩阵(Identity Matrix)- 即:
M_modelview = I
- 所以:
M_projection × I × P = M_projection × P
- 即:
- 我们输入的是二维顶点
(x, y)
,转换成齐次坐标就是(x, y, 0, 1)
- 也就是说我们只关注一个自定义投影矩阵 M_projection,让它把我们输入的
(x, y, 0, 1)
映射成 OpenGL 要求的裁剪空间坐标[-1, 1]
范围内
Step 3:根据坐标映射目标构造 M_projection
我们目标:
- 把 x 从
[0, width]
映射到[-1, 1]
- 把 y 从
[0, height]
映射到[-1, 1]
- 不关心 z(可以保留)
- 保证 w=1 以满足透视除法(但我们其实在 2D 下不会影响最终效果)
这就等价于构造一个正交投影矩阵,把 2D 矩形区域 [0, width] × [0, height]
映射到裁剪空间 [-1, 1] × [-1, 1]
。
我们可以从 OpenGL 的标准正交投影矩阵公式出发(左手坐标系):
M_projection =
| 2/(r-l) 0 0 -(r+l)/(r-l) |
| 0 2/(t-b) 0 -(t+b)/(t-b) |
| 0 0 -2/(f-n) -(f+n)/(f-n) |
| 0 0 0 1 |
- l = left = 0
- r = right = width
- b = bottom = 0
- t = top = height
- n = near = -1(默认)
- f = far = 1(默认)
代入得到:
M_projection =
| 2/width 0 0 -1 |
| 0 2/height 0 -1 |
| 0 0 -1 0 |
| 0 0 0 1 |
这里我们可以看到第3行的 -1
是为了满足标准 OpenGL 的 z 范围 [-1, 1]
,在 2D 场景中其实可以忽略(因为 Z 不参与渲染),于是我们可以简化为:
| 2/width 0 0 -1 |
| 0 2/height 0 -1 |
| 0 0 1 0 |
| 0 0 0 1 |
这正是我们希望得到的矩阵。
总结推导过程
- 从
P' = M_projection × M_modelview × P
开始 - 因为我们在 2D 中使用单位模型视图矩阵:
M_modelview = I
,所以简化为P' = M_projection × P
- 构造投影矩阵的目标是将
[0, width] × [0, height]
映射到[-1, 1] × [-1, 1]
- 使用标准正交投影矩阵公式代入数值,得到矩阵
- 因为我们是 2D,不关心 Z 深度,可简化第3行为恒等行
- 得出最终结果
如果你还想推导 z 的变换,或者想了解为什么标准正交投影矩阵是那种形式,我也可以继续带你一步步推下去。需要的话直接说!
win32_game.cpp: 在内联函数中编写新的 OpenGLRectangle 函数,最初传递旧值
我们正在做的是把一小段功能迁移到一个我们已经验证可行的部分中进行测试和调试。之前我们是直接把坐标传给单位立方体的方式解决的,但现在我们决定回到之前使用 minP
和 maxP
的方式来绘制矩形。
我们使用的是一个封装好的 OpenGL 绘制矩形函数 OpenGLRectangle
,这个函数内部通过 minP
(最小坐标点)和 maxP
(最大坐标点)来定义矩形的位置和尺寸。我们打算复用这个函数,暂时保留颜色参数,先全部设为白色 [1,1,1,1]
,即纯白。
我们设定:
minP = (0, 0)
:表示矩形的起始点在左下角maxP = (bufferWidth, bufferHeight)
:表示矩形的终止点在右上角,覆盖整个渲染缓冲区
但这里我们意识到一个重要问题:我们最初写成了 windowWidth
和 windowHeight
,这是错误的。
正确的是使用 bufferWidth
和 bufferHeight
,因为我们实际想绘制的是图像缓冲的尺寸,而不是窗口本身的尺寸。窗口尺寸可能跟实际渲染区域不同,比如有缩放、DPI 变化、或窗口拉伸等。直接使用窗口大小会导致显示不一致的问题。
此外,我们还提到了“宽高比”的问题:
- 如果窗口尺寸和缓冲尺寸不一致(比如画面被拉伸),就会导致图像变形。
- 为了解决这个问题,可能需要添加黑边(black bars)来保持原始画面比例。
- 目前我们还没有做这部分处理,不过已经提到之后需要处理。
结论是:我们会继续使用这个 minP/maxP
方法,在我们熟悉、稳定的函数中先验证坐标转换和绘图逻辑是否正确,然后再考虑后续如何将其整合进更完整的系统中。同时保留对宽高比处理逻辑的思考,以便后续实现更完善的适配策略。
运行游戏并看到我们之前的 bug
我们运行了程序后,重新出现了之前的一个老问题:渲染结果明显错误。具体表现为图像被画在了错误的位置,而且非常巨大。虽然从屏幕上可能不太容易看出细节,但其实我们绘制的只是位图中的一个像素,却被以极大的比例显示出来,看起来非常怪异。
问题的根本原因是:当前使用的是 OpenGL 的标准坐标系范围,也就是从 -1 到 1,这是 NDC(Normalized Device Coordinates)的范围。然而我们传入的却是实际像素坐标,比如 1920
,这远远超出了 NDC 范围,导致画面错位并且被极度放大。
简单来说,我们是把屏幕的左上角像素 (0,0) 当成单位立方体坐标来使用,结果坐标完全错位了。比如传入一个 (1920, 1080)
的点,就相当于在一个只允许 [-1, 1]
坐标的空间里,硬塞进一个超出一千倍大小的点,自然位置、比例都不对。
为了解决这个问题,我们需要修正投影矩阵。也就是设置一个合适的 投影矩阵(projection matrix),将我们传入的像素级坐标 变换到 [-1, 1] 的标准化设备坐标空间内。
当前我们在设置矩阵时使用的是 load_identity()
,这意味着我们并没有对任何输入坐标进行转换处理,而是直接将顶点坐标送入渲染管线,自然会出现严重的比例和位置问题。
下一步就是替换掉 load_identity()
,用我们自己构造的投影矩阵来修正坐标系统,让 OpenGL 能正确理解我们传入的像素坐标。这也是我们在图像渲染中最关键的一步:构造一个合适的矩阵来完成从像素空间到标准化空间的映射,确保图像正确显示在屏幕上。
网络:glLoadMatrix1
我们现在需要使用一个新的 OpenGL 调用,用来加载一个非单位矩阵,从而替换之前默认使用的 load_identity()
。这一步的目标是:加载我们自己构造的投影矩阵,以便实现从像素坐标到标准化坐标(NDC)的转换。
OpenGL 提供了一个叫做 glLoadMatrix
的函数,它有两个版本:
glLoadMatrixf
:用于传入float
类型的矩阵;glLoadMatrixd
:用于传入double
类型的矩阵。
这个后缀字母 f
或 d
表示我们传入的数据类型,并不是像传统匈牙利命名那样多余,而是在没有函数重载机制的 C 语言中,是用来区分两个函数版本的重要手段,实际非常有用。
我们构造的投影矩阵是 float
类型的,因此我们会使用 glLoadMatrixf
这个版本。使用方式也很直接,就是准备好一个 4×4 的矩阵,以列优先顺序(column-major)存放在一个长度为 16 的数组中,然后传递给这个函数。
这一操作的作用是把当前的矩阵设置为我们传入的矩阵,也就是用我们构造好的投影矩阵直接替代默认的单位矩阵,从而使得接下来传入的顶点数据能正确地经过该矩阵转换,实现我们想要的屏幕映射效果。接下来我们就可以正式切换投影逻辑,应用新的变换矩阵了。
glLoadMatrix
的作用是:将当前矩阵(当前矩阵模式下)直接替换为你提供的一个 4×4 矩阵。
具体说明:
OpenGL 中存在一个当前的矩阵栈,取决于你当前在哪种模式下,比如:
GL_MODELVIEW
:模型-视图矩阵模式;GL_PROJECTION
:投影矩阵模式;GL_TEXTURE
:纹理矩阵模式。
当你处于某个模式下,调用 glLoadMatrixf
(或 glLoadMatrixd
)的作用就是:
把你传入的矩阵 完全替换掉当前矩阵,不做乘法叠加,也不保留之前的矩阵内容。
函数原型:
void glLoadMatrixf(const GLfloat *m);
void glLoadMatrixd(const GLdouble *m);
-
m
是一个指向包含 16 个元素的数组的指针,按照 列主序(column-major order) 排列,也就是说:m = { m0, m4, m8, m12, m1, m5, m9, m13, m2, m6, m10, m14, m3, m7, m11, m15 }
用途举例:
假设我们想要设置一个自定义的正交投影矩阵(比如从像素坐标映射到 NDC [-1,1]),我们可以先:
-
切换到投影矩阵模式:
glMatrixMode(GL_PROJECTION);
-
加载我们的矩阵:
float projectionMatrix[16] = { 2.0f/width, 0, 0, 0, 0, 2.0f/height, 0, 0, 0, 0, 1, 0, -1, -1, 0, 1 }; glLoadMatrixf(projectionMatrix);
这会把我们设置好的矩阵直接加载到 OpenGL 的当前矩阵中,接下来所有的顶点都会被这个矩阵变换。
小结一句话:
glLoadMatrix
的作用是:用你给定的 4×4 矩阵直接替换掉当前矩阵栈顶部的矩阵,通常用于手动构建和应用自己的投影或模型变换。
win32_game.cpp: 初始化 Proj 数组,传递给 glLoadMatrix
我们需要构建一个投影矩阵,并将它以 float
数组的形式传递给 glLoadMatrixf
,从而替换当前的投影矩阵。OpenGL 的 glLoadMatrixf
要求传入一个长度为 16 的浮点数组,表示一个 4×4 的矩阵,按列主序(column-major order)排列。
最初我们用的是单位矩阵,但为了将坐标从像素坐标映射到标准化设备坐标(NDC)的范围 [-1, 1],我们需要对矩阵进行修改。
我们在矩阵中填入了横向和纵向缩放系数,分别是 2 / width
和 2 / height
,这样就能把从 (0, width)
和 (0, height)
的坐标缩放到 (0, 2)
,然后再通过平移 -1
,使坐标落入 (-1, 1)
区间。
在设置缩放项时,我们还需要处理除零的特殊情况。因为在一些极端情况下,buffer width
或 buffer height
可能为零,所以我们使用了一个“安全比值”的方式(SafeRatio1
),防止除以零导致程序异常。
但接下来我们面临一个额外的问题:虽然我们可以按顺序将这些值填入 float 数组中,并传递给 glLoadMatrixf
,但这种写法存在一些限制,尤其是当我们想在矩阵中进行更复杂的变换或者进一步封装计算过程时,就没办法简单地写死在代码中了。
因此,虽然我们现在可以手动设置 2/width
、2/height
和偏移的 -1
等值并传入数组,但这仅适用于当前这种简单的情况。一旦变换逻辑变复杂,我们需要考虑更系统的矩阵管理方法,比如封装成数学库或者使用矩阵构建函数来生成矩阵内容,以便更清晰、灵活地控制变换逻辑。
win32_game.cpp: #include game_intrinsics.h 和 game_math.h
我们在代码中需要用到数学函数,比如 sqrt
(平方根),但是当前的环境里并没有包含数学库,因此在尝试调用 sqrt
的时候会提示找不到这个函数。
我们开始思考是否可以直接引入数学库,没有看到什么限制或障碍,因此计划直接包含相应的头文件,引入这些数学函数。接着我们查看了 sqrt
函数通常所在的位置,发现它可能定义在一个叫 intrinsics
的地方,也可能是在某些平台的特殊实现中定义。
因此我们接下来的目标是,假设一切正常,去正确包含数学库,以便后续可以使用像 sqrt
这样的常见数学函数。这样我们就能在代码中更方便地处理数学相关的操作,比如矩阵变换、缩放系数计算等,也有助于后续更复杂的图形逻辑实现。
运行游戏并看到一切没有按预期工作
我们以为只要数学部分都处理好了,其他部分也没问题的话,这个投影矩阵应该可以正常工作。我们期望加载了投影矩阵之后,图形就会正确地显示在画面上。
但实际上运行之后发现结果并不是我们预期的那样——显示效果看起来是坏的,虽然看起来出问题的原因可能另有他因,所以不能完全归咎于当前的矩阵设置,但显然一切还没有完全跑通。
关键点是,即使我们按照之前的计算方式设置好了投影矩阵,把它通过 glLoadMatrixf
加载进去,依旧没能直接解决渲染错误的问题。这提醒我们还需要进一步排查,看看是不是其他地方,比如模型变换矩阵、顶点输入数据、绘制顺序等出了问题,总之目前还不能指望仅靠投影矩阵就一切搞定。我们要带着怀疑继续检查代码中的其他环节。
黑板:数学矩阵乘法与计算矩阵乘法的区别
我们一开始直接在代码里写出了一个矩阵,按照我们在数学课本中学到的方式来表示它,这种写法在数学上是清晰明确的,比如我们习惯性地从矩阵乘法的角度去理解它:将输入向量的 x、y、z、w 分别与矩阵对应位置的数值相乘,并进行累加,得出最终变换后的结果。
数学上的表达没有歧义,因为数学会明确区分是“列向量 × 矩阵”,还是“行向量 × 矩阵”,而且都合法,只要满足矩阵乘法的维度匹配原则,即左边矩阵的列数要等于右边矩阵的行数。例如:
- 如果我们把向量看作列向量,那就是
M × V
(矩阵左乘向量),矩阵的列就是变换的方向分量; - 如果我们把向量看作行向量,那就是
V × M
(向量右乘矩阵),矩阵的行是变换方向分量。
数学中哪种方式都可以使用,并且数学定义是健全的——只要维度匹配,乘法就有定义;维度不匹配才会出现无法计算的情况。
但问题出现在我们将这些数学运算搬到计算机中时:在计算机里,矩阵只是一个内存中的浮点数组,没有任何显式的结构说明它是“按行排列”还是“按列排列”。比如这两个都合法:
- 我们可能用“行主序”(Row-major order)存储矩阵:每一行接着一行存在内存里;
- 也可能用“列主序”(Column-major order)存储:每一列接着一列存在内存里。
而且,OpenGL(特别是固定管线时代的 OpenGL)采用的是列主序(column-major order),并且假设我们在做的是列向量乘法 M × V
,也就是说向量在右边,乘法时按照“列”来分布变换影响。
这就意味着如果我们直接把一个用“数学直觉”写出的矩阵数组放进 OpenGL,可能在内存中排列顺序不对,变换结果自然也会错。比如我们可能希望某个值在最后一列,表示平移,但实际内存中它在了最后一行,那么它就不会起作用,甚至会起错误的作用。
更进一步,如果我们在 C/C++ 中写出一个 4×4 的 float 数组,并想通过 glLoadMatrixf()
加载它到 OpenGL 中,那么我们必须严格按照 OpenGL 的列主序规则来排列这个数组的内容,才能保证它在矩阵乘法过程中表现正确。
总的来说,关键问题在于:
- 数学里的矩阵和向量有明确的乘法方向(行向量或列向量);
- 在程序里,矩阵只是数组,必须明确按哪种顺序排列才能得到期望结果;
- OpenGL 固定管线采用列主序矩阵存储,向量乘法采用列向量左乘方式
M × V
; - 因此,我们在写代码加载投影矩阵时,一定要根据 OpenGL 的存储方式将矩阵按正确的顺序展开为一维数组,否则会导致变换错误、图像错位或完全不显示。
这个细节非常重要,是很多图形开发初学者容易忽略的陷阱。
win32_game.cpp: 为了 OpenGL 的需要重新编码 Proj 数组
我们在这里最终验证了之前设置投影矩阵的一些细节,特别是矩阵在内存中的排列方式。我们确认 OpenGL 固定功能管线中,矩阵数据是按列主序(column-major order)排列的。也就是说,当我们用 glLoadMatrixf()
函数传递一个 4×4 矩阵时,这16个浮点数应该按照逐列排列的顺序来写入数组,而不是逐行排列。
我们通过阅读文档中的矩阵例子进一步确认了这一点:它清楚地按照列来标注索引,从 0 到 3 是一列一列往下排的,这与 C 语言中的数组初始化顺序(默认认为是逐行)是相反的。如果我们不考虑这个排列差异,直接把数学上看起来“对”的矩阵塞进 OpenGL,就会导致各种错位、渲染失败或变形的问题。
为了让矩阵更容易阅读,我们对代码做了结构优化,使得矩阵在代码中的可视布局更清晰,便于确认每一列每一行的含义和位置。这种视觉对齐方式有助于我们更直观地确认矩阵内容与变换效果是否一致。
之后我们将设置好的这个投影矩阵投入实际使用,发现它终于如预期那样正确地将屏幕坐标转换到 OpenGL 所需的裁剪空间范围,实现了我们想要的单位立方体内的绘制效果。原先图像渲染错位、比例错误的问题已经被修复。
现在渲染流程基本完成,接下来的任务就只是做一些集成工作:
- 把这套设置流程整合到最终渲染函数的调用流程中;
- 确保渲染目标窗口和输出尺寸正确传递进去;
- 确保
glLoadMatrixf()
和矩阵初始化调用在正确的地方执行; - 确保绘制结束后可以正确地调用
SwapBuffers
,将结果显示到屏幕; - 最后处理与纹理上传相关的一些细节,例如绑定和更新纹理数据。
整体上现在的代码结构已经非常接近一个基础但完整的基于 OpenGL 的硬件渲染流程了。我们已经完成了坐标变换的核心部分,接下来的工作主要是连接上下文、组织代码结构,并处理一些边缘情况。这些工作比较琐碎但不复杂,很快就能实现出一个最小可运行的图像渲染系统。
虽然中间涉及了一些矩阵乘法、内存排列等计算机图形学的概念,但我们通过实际的验证和调试确认了每一个环节的正确性,为后续的系统稳定性和可扩展性打下了良好的基础。
这个 projectionMatrix
是一个 4×4 的列主序(column-major order)矩阵,表示 OpenGL 中的正交投影矩阵(Orthographic Projection Matrix),用于将屏幕空间映射到裁剪空间(单位立方体,即 x/y/z ∈ [-1, 1])。虽然在代码中我们是按 C 风格逐行书写的,但 OpenGL 实际是按列来读取的。
我们先按照列主序还原这个矩阵:
根据 OpenGL 的读取顺序,数组是这样填充矩阵的(每4个值为一列,从左到右):
按列主序排列:
列 0 列 1 列 2 列 3
[ 2/width 0 0 -1 ]
[ 0 2/height 0 -1 ]
[ 0 0 1 0 ]
[ 0 0 0 1 ]
对应的 4×4 矩阵就是:
[ 2 width 0 0 0 0 2 height 0 0 0 0 1 0 − 1 − 1 0 1 ] \begin{bmatrix} \frac{2}{\text{width}} & 0 & 0 & 0 \\ 0 & \frac{2}{\text{height}} & 0 & 0 \\ 0 & 0 & 1 & 0 \\ -1 & -1 & 0 & 1 \end{bmatrix} width200−10height20−100100001
含义解读:
这个矩阵是将一个二维坐标范围 [0, width] × [0, height]
映射到 OpenGL 的裁剪空间 [-1, 1] × [-1, 1]
:
- 左边界 0 → -1,右边界 width → 1
- 上边界 0 → -1,下边界 height → 1
- Z轴不做透视变换,保持为 1
- 最后一行
[-1, -1, 0, 1]
负责偏移坐标,把左上角(0, 0)
映射到(-1, -1)
,完成 OpenGL 裁剪空间的对齐
注意:
如果你用的是 OpenGL 的固定管线(legacy pipeline),你必须确保:
- 使用
glMatrixMode(GL_PROJECTION)
激活投影矩阵模式; - 调用
glLoadMatrixf(projectionMatrix)
时,按列主序排布; - 如果用的是现代 OpenGL(如 GLSL),你可以自己选择行/列主序,只要在 shader 里配套乘法方向即可。
我可以开始了吗?是的,我安装了一个驱动程序软件公司。Kapp?
game_opengl.cpp 会包含在平台层还是游戏层?
这段内容讲述了在开发过程中,如何合理地组织不同层次的代码,尤其是如何处理与平台相关的代码和与OpenGL相关的代码。总结如下:
在设计和开发过程中,涉及到的“平台层”和“游戏层”两个重要概念。游戏层通常包含与平台无关的逻辑,例如 OpenGL 的实现。虽然 OpenGL 是与平台相关的技术(因为不同平台可能需要不同的实现),但是为了避免重复工作,OpenGL 的核心实现被设计成与平台无关的部分,这样可以避免为不同平台重新编写几乎相同的代码。
这意味着,尽管 OpenGL 的实现会和平台有关(比如 Win32 和 macOS 有各自不同的实现方式),但 OpenGL 的实现本身应该位于游戏层,而不是与特定平台相关的层(如 Win32 层)。这样设计的原因是,游戏代码和 OpenGL 渲染代码在不同平台上共享,可以避免重新编写同样的代码,只需要在平台层处理平台特定的部分。
比如,交换缓冲区(swap buffers)这样的操作依然会属于平台层,因为它涉及到底层的图形 API 调用。而所有与 OpenGL 相关的通用代码会被抽象到可以在多个平台上重用的文件中,从而保持平台无关性。
这使得代码更具可重用性和可维护性,避免了在多个平台间复制粘贴相同的逻辑,进而提高了开发效率。
详细中文总结:
在软件开发,尤其是与图形渲染相关的开发过程中,通常会有多个层次的代码结构来应对不同的需求。在这种架构中,游戏层和平台层扮演了重要角色。游戏层负责处理和平台无关的代码逻辑,如OpenGL的核心实现,而平台层则处理与特定操作系统相关的代码,如 Win32 或 macOS 中的特定实现。
为了最大程度地减少代码的重复工作,OpenGL 渲染的实现被设计成可以在不同平台间共享,这样即使开发者需要在不同的操作系统上进行开发,也不需要重复编写大部分的 OpenGL 代码。具体来说,OpenGL 的核心功能放在游戏层,而涉及特定平台的操作(如交换缓冲区)则放在平台层,这样平台层的代码只需要处理操作系统特定的功能。
这种做法带来的好处是可以避免重复编写代码,使得同一份代码可以在多个平台上共享和重用。这样一来,即便是不同平台间的差异,只需要在平台层进行适当的调整即可,而通用的图形渲染代码(OpenGL部分)可以被直接重用,大大提高了开发效率。
稍微晚了一点进来,我们现在使用的是固定功能的 OpenGL API 吗,还是以后会写自己的矩阵结构?
这段内容讨论了在使用OpenGL时的不同方法,特别是关于固定功能管线(fixed-function pipeline)和着色器(shaders)的使用,以及是否会使用自定义的矩阵结构。以下是详细总结:
目前使用的还是固定功能的OpenGL API。固定功能管线是OpenGL中的一种传统方式,它使用一些预定义的功能来进行矩阵变换、光照计算等操作,开发者不需要编写自己的矩阵结构,OpenGL会处理这些任务。
然而,当转向使用着色器时,矩阵变换会由着色器来完成。在这种情况下,开发者可能不再使用固定功能管线,而是编写自己的矩阵结构(matrix struct)。在使用着色器时,矩阵的计算和变换会转交给GPU进行,开发者需要自己定义矩阵如何存储和使用。
尽管如此,目前这个过程的转换并不会立即发生,开发者可能会依然使用OpenGL的固定功能管线,直到迁移到着色器阶段。在着色器阶段,矩阵变换会变得更加灵活,可以按照开发者需要的顺序来应用,但这也属于稍后的事情,和当前的工作相比是一个不同的方向。
简而言之,现在仍然依赖固定功能管线,而在将来过渡到着色器时,会有更多的控制权,可以根据需要自定义矩阵结构。
对程序员来说推荐吗,还是只是你个人的问题?
这段内容讨论了程序员是否需要佩戴护腕(brace),并提到了一些个人的经验和观点。总结如下:
佩戴护腕是否对程序员有帮助,主要取决于个人的身体状况。并没有统一的建议,因为每个人的情况不同。对于一些人来说,长时间编程可能会引起手腕不适或伤害,因此佩戴护腕可能有助于减轻压力,提供支撑,防止受伤。然而,这并不是所有程序员都需要的东西,也没有明确的规定说程序员必须佩戴护腕。
总体来说,是否佩戴护腕是一个个人选择的问题,更多的是根据个人的身体需求和是否感到不适来决定,而不是一种普遍推荐的做法。
如果你把 game_opengl.cpp 包含在游戏层,那是不是意味着你必须在游戏层中包含 windows.h 和所有 OpenGL 头文件?
在讨论OpenGL游戏开发时,提到了一些关于代码结构和跨平台开发的最佳实践。总结如下:
在游戏层使用OpenGL时,不会强制要求在游戏层中包含 Windows.h 或所有 OpenGL 头文件。因为在构建时,所有的代码最终会合并成一个大的文件,所以层次结构并不会影响编译或链接。关键在于要确保游戏层的文件中只包含 OpenGL 相关的代码,不使用任何特定于平台的调用。
如果在这个文件中加入了 Windows 特有的函数(比如 wglSwapBuffers
),那么当需要移植到其他平台(比如 Mac)时,就会遇到问题,无法在其他平台上使用该文件。因此,为了确保代码的可移植性,应该避免在 OpenGL 代码中加入任何与操作系统特定的调用。
这样做的目的是让 OpenGL 代码即使在不同的平台间切换,仍然能够被重用。例如,Windows 和 Mac 平台的文件都会包含这个通用的 game_opengl.cpp
文件,这样它们就能共享相同的 OpenGL 代码,保持代码的跨平台可用性。
推送缓冲渲染主要是在软件渲染器中进行的吗?因为真实的硬件渲染器也会以这种方式工作,还是说是由于 GPU 的原因?
渲染主要在软件中进行,原因是硬件渲染器的工作方式受到GPU的限制。但在这种情况下使用软件渲染器的原因是排序。我们之所以选择使用推送缓冲区(push buffer)样式,是因为如果没有这个方法,我们无法实现排序。在2D游戏中,排序非常重要,特别是为了处理透明度、获得更好的合成效果,甚至是为了性能优化。为了实现这一点,通常需要使用延迟渲染(deferred rendering)技术。
延迟渲染的过程涉及将图像数据先存储在缓冲区中,然后再进行渲染,这样可以有效地控制渲染顺序,从而确保透明物体能够按正确的顺序渲染,这对于实现高质量的合成和优化渲染性能至关重要。因此,排序是必须的,尤其是在需要正确处理透明效果的情况下。
我对用浮动点表示像素感到不舒服。你曾经遇到过 OpenGL 和浮动点值的问题吗,比如将它们传递给着色器或纹理坐标?
关于将浮点值传递给OpenGL并处理像素的问题,很多程序员都可能感到不适应或不确定。当传递浮点坐标值时,特别是涉及到像素定位时,很多开发者都不太清楚这些浮点值会如何映射到实际的像素上。其实,这种不确定感是很常见的,尤其是在早期的OpenGL文档中,这个概念并没有很清晰地解释清楚,导致许多程序员在使用时会出错。
现代的图形卡通常遵循严格的规范,正确处理浮点坐标与像素的映射,保证每个浮点值映射到预期的像素位置,但这并不是总是如此,特别是在旧版OpenGL实现中,浮点坐标和像素的处理可能存在问题。
对于大多数开发者来说,OpenGL的文档可能并没有从一开始就提供关于纹理坐标和像素坐标如何一起工作的明确示意图,这就导致了许多混乱。在某些情况下,程序员可能会在处理浮点坐标和像素映射时遇到困难,理解这些细节是很重要的,尤其是在涉及纹理映射和像素坐标时。这个问题虽然在特定的应用中可能不常见,但在需要精确控制渲染和纹理时,理解这些细节会帮助解决潜在问题。
至于是否使用元素缓冲区(Element Buffer)或顶点缓冲区对象(VBO),这取决于具体的实现需求。VBO常用于存储顶点数据,Element Buffer则用于存储索引数据,两者都能提高渲染效率,减少API调用次数。
你在 OpenGL 代码中使用过 EBO(元素缓冲对象)或 VBO(顶点缓冲对象)吗?如果没有,为什么?
在OpenGL中使用顶点缓冲区对象(VBO)是为了优化函数调用的开销。当前渲染器的设置要求通过图形卡高效地渲染大量矩形,并且每个矩形都有对应的纹理。因此,不需要使用GL顶点调用来处理每一个矩形,这样做可以避免不必要的函数调用开销,从而提高渲染效率。
然而,存在一个问题,尤其是在低端系统上,许多系统并不支持直接从缓冲区中设置纹理。在一些高端的视频硬件上,确实可以通过指定每个元素的纹理来渲染,但在大多数情况下,尤其是对于低端硬件,这种做法是不可行的。因此,虽然使用顶点缓冲区对象可以减少函数调用的开销,但它并不能显著提高速度,因为很多时间将被花费在切换纹理上。
此外,是否将纹理打包到纹理页面中也是一个需要考虑的因素。虽然可以通过资产打包工具来做,但这涉及到额外的处理,并且有可能带来纹理流处理等方面的问题,因此是否这么做还不确定。
目前,虽然还没有使用顶点缓冲区对象,因为首先要确保渲染管线在固定功能模式下能够正常工作,并且没有缓冲区对象的支持。等到固定功能管线运作顺利后,再考虑使用顶点缓冲区对象,作为一种优化手段来减少函数调用的开销,但它并不会改变渲染结果,只是提升性能。
你在设置投影矩阵时做了自己的 glOrtho 调用吗?另外,谢谢你所有关于矩阵的解释
在设置投影矩阵时,使用了类似于 GL ortho
的方法,但并没有直接调用 GL ortho
。我们所做的是自己实现了一个等效的投影矩阵,主要是因为 GL ortho
在处理投影时通常会涉及到深度缓冲区(Z-buffer)和 Z 坐标的计算,而在当前的渲染场景中并不需要这些功能。对于大多数情况下的投影矩阵,Z 坐标并不重要,因此我们省略了对 Z 坐标的处理。
GL ortho
是 OpenGL 中的一个内置函数,它用于创建正交投影矩阵,该矩阵支持深度缓冲功能,可以在渲染时记录每个像素的深度值,这对于支持 3D 场景非常重要。然而,在当前的 2D 渲染情况下,深度缓冲并不是必需的,因此不需要处理 Z 坐标的生成。正因为如此,当前实现的矩阵和 GL ortho
有些不同。
GL ortho
通常会生成包含 Z 坐标的投影矩阵,而我们自己实现的投影矩阵则没有这种处理,因为我们不需要 Z-buffer 或者深度缓冲的支持。在实际使用时,如果查找 GL ortho
的实现,你会发现它生成的矩阵与我们当前使用的矩阵非常相似,但会在 Z 坐标的处理上有所不同。