游戏引擎学习第240天:将渲染器移至第三层
这节又枯燥又无聊简直了
回顾并为今天的内容做铺垫
昨天我们说到,想对渲染器和平台层的集成方式做一些修改。我们之前简单讲了一下修改的目的:我们希望游戏本身不再直接调用 OpenGL 的渲染代码,而是只生成一组渲染指令缓冲区,然后 OpenGL 再去解析并执行这些指令,完成最终的绘制。我们现在的目标就是把这个结构搭建起来。
这其实不是一个复杂的目标,也不是个什么特别奇怪的需求,应该很容易理解,也希望大家能跟得上思路。
当前我们已经基本上通过 OpenGL 实现了渲染流程,但这个流程还不是我们想要的架构方式。除了架构问题,我们还有其他一些问题,比如现在贴图还没有正确处理,我们并没有按照理想方式上传纹理,还有很多 OpenGL 功能我们目前还没有实现,所以在后续我们还会继续完善 OpenGL 的支持。
但在那之前,我们先来完成架构方面的调整,然后再继续处理 OpenGL 的其它问题。现在就开始着手这个改动,看具体需要怎么做。
修改 game_platform.h:将 game_offscreen_buffer
替换为 game_render_commands
我们现在的目标是让同一个 RenderGroup
能够贯穿整个渲染流程,也就是从头到尾只使用一个渲染组来完成所有渲染操作。
目前在调试帧中可以看到,游戏逻辑和调试渲染部分是分别创建了两个不同的 RenderGroup
,也就是游戏用一个,调试用一个,而它们都是在游戏内部被创建和销毁的。这种做法让游戏本身依赖了 OpenGL 渲染调用,这是我们不想要的。
我们现在要做的是,把渲染组的控制权从游戏内部移出来,由平台层提供渲染组,传给游戏使用。这样游戏只负责往 RenderGroup
里写指令,真正的绘制动作由平台层完成。
首先需要修改 game_platform.h
,让平台层知道 RenderGroup
的存在,至少有一定程度的认知。我们先从简单的修改开始,比如把传入游戏的 GameOffscreenBuffer
移除,改成传入一个 RenderGroup
。这个修改本身不复杂,但涉及到的代码范围比较广,需要花时间调整。
GameOffscreenBuffer
是现在用于描述像素缓冲区的结构,而我们想要改成传入一个 RenderGroup
。这样游戏调用 GameUpdateAndRender
时就不再需要处理像素数据,而是只写入渲染命令,实际的图像生成由平台负责。
我们也将原来放在游戏内部的 RenderGroup
分配逻辑移到平台层,比如放到 win32_game.cpp
的 UpdateAndRender
调用流程中。平台先创建 RenderGroup
,然后把它传入游戏中使用。
接着我们发现,游戏中还涉及资源系统,比如调用纹理、字体等,这些都依赖某些结构,因此如果整个 RenderGroup
都挪到平台层,会导致资源访问变得困难,所以不能完全把整个渲染系统拉出来。
为了解决这个问题,我们决定只把“渲染指令缓冲区”(PushBuffer)作为桥梁结构放在平台层,而把资源管理仍然留在游戏中。这样游戏生成渲染指令,通过 PushBuffer 写入,而平台层只读取这个缓冲区并执行渲染操作。这样既避免了平台层对资源的依赖,又完成了渲染职责的拆分。
我们将这种新结构命名为 GameRenderCommands
,它包含画面宽度、高度以及实际的 PushBuffer。我们定义了 InitializeRenderCommands
函数,接收需要的信息并为 PushBuffer 分配内存,初始化结构体。
这个初始化过程由平台层完成,因此任何平台只要实现这个简单的初始化过程就能兼容整套渲染逻辑。后续游戏调用就只需要向这个结构写入渲染命令,无需知道底层是 OpenGL 还是其他渲染 API,实现了良好的解耦。
整体来说,我们正在把渲染模块从游戏逻辑中剥离出来,拆分成三层结构:
- 游戏逻辑层(只写渲染命令)
- 平台中介层(负责创建/管理指令缓冲区)
- 渲染执行层(比如 OpenGL,读取并执行命令)
这个架构改动的方向就是让渲染操作变成纯粹的数据驱动,游戏只输出描述渲染内容的指令,而具体怎么呈现完全交由平台决定。这样做既增强了灵活性,也便于移植和维护。
修改 game_platform.h:引入 InitializeRenderCommands
函数
我们现在处理的是 GameRenderCommands
的初始化细节部分。
我们已经设置好了一个渲染指令缓冲区(PushBuffer),这是整个渲染过程的核心数据结构。它用于记录游戏逻辑阶段生成的渲染命令,供后续平台层的渲染代码读取并执行。
我们也设定了画面的宽度和高度,还有其它一些相关参数,比如像素宽高比(Width/Height)和坐标系中的原点位置(OriginX / OriginY)等。这些参数用于设定渲染时的基础空间信息,确保绘制出来的内容具有正确的比例和位置。
我们把这些参数组合在一起构成了一个 RenderCommands
的结构体,它是这个初始化流程的输出,供游戏主循环使用。
此时,为了让这个结构易于调用,我们将其设计为一个 inline
函数。这样我们在平台层或者任意其他调用方就可以方便地初始化 RenderCommands
,不需要调用复杂的构造函数或处理繁琐的初始化逻辑。
通过将初始化逻辑封装为 inline
函数,我们避免了额外的函数开销,同时也让代码变得更加清晰和模块化。
我们可以在调用的时候直接传入需要的参数,比如:
- 渲染缓冲区大小
- 画面宽高
- 内存分配方式(如 VirtualAlloc)
然后这个函数就能自动初始化好一个完整的 GameRenderCommands
实例,并准备好供游戏逻辑填充渲染命令。
这种方式不仅提升了灵活性,也增强了渲染流程的可控性和可移植性,未来如果我们更换渲染 API(例如从 OpenGL 改为 Vulkan),只需要调整平台层的解析逻辑,而游戏逻辑部分完全不变。这就是我们现在要实现的三层架构中的重要一环。
修改 game_platform.h:指出 game_render_commands
中的 #inline
不起作用,接着将其改为 #define
我们现在遇到的一个问题是:如果继续使用 C 语言来实现这些渲染命令初始化逻辑,那么 inline
函数就不太适用了,因为 inline
在 C 中兼容性和表现不是特别理想,特别是跨平台或结构不同的编译器下。
因此我们考虑将初始化逻辑改为宏(#define
),来确保渲染命令结构体的初始化能够方便地完成,并且可以避免调用者遗漏某些重要字段。我们的目标是对 RenderGroup
的分配逻辑(也就是 AllocateRenderGroup
)进行提取和封装,而这个分配函数做了很多关键初始化操作,比如:
- 初始化 push buffer 的大小、内存基址等
- 设置渲染指令计数为零
- 设置排序缓冲区的指针
- 清除上一次渲染留下的状态
- 设定屏幕宽度和高度
- 分配 push buffer 空间
- 设置相关结构体字段的初值等
我们希望这些初始化步骤都被自动完成,而不是每次都手动设置。
为了实现这一点,我们提取出所有必要的字段,比如:
PushBufferSize
PushBufferBase
PushBufferElementCount
SortEntryAt
RenderWidth
RenderHeight
然后用一个宏来封装这些设置操作,可能长这样:
#define RenderCommandStruct(MaxPushBufferSize, PushBuffer, Width, Height) \{Width, Height, MaxPushBufferSize, 0, (uint8 *)PushBuffer, 0, MaxPushBufferSize};
这样我们就可以在任意地方快速、安全地初始化一个 RenderCommands
实例,只需要传入缓冲区大小、基址、宽高就可以了,所有其它字段都会自动设置为初始状态,防止忘记初始化某个字段。
另外还考虑到如果继续保留 ground chunk(地形块缓存)机制的话,这部分也可能需要额外的初始化逻辑,但我们已经意识到这部分机制从一开始就比较麻烦,意义不大,所以也在倾向于将其完全移除,从而让渲染逻辑更为纯粹和简洁。
总之,我们的目标是:
- 让渲染命令的初始化更可靠、更清晰
- 从游戏逻辑中分离平台细节
- 保持平台层对渲染资源的控制
- 提高整体渲染流程的可维护性和可扩展性
使用宏的方式能够在 C 语言下很好地完成这些目标。接下来只需在平台层统一调用这个宏,就可以快速、安全地完成渲染结构的初始化。
修改 win32_game.cpp:将 RenderCommands
传递给所有需要它的部分
现在我们进入了渲染命令使用的实际流程阶段。整个思路是,在执行 GameUpdateRender
的时候,我们不再直接操作某个后备缓冲区,而是传入一个 RenderCommands
结构体,也就是渲染命令缓冲区。游戏逻辑和调试系统分别向其中写入它们要渲染的内容,最后统一处理。
具体流程如下:
1. 传入渲染命令缓冲区
在游戏逻辑执行(GameUpdateRender
)时,我们会传入 RenderCommands
对象。这个对象包含了 push buffer、排序表、尺寸等所有与当前帧渲染相关的数据结构。
2. 调试渲染同样写入命令
调用调试绘制(比如 DebugFrame
)时,也不再直接写入屏幕或后备缓冲区,而是统一向 RenderCommands
中写入命令,保持与游戏逻辑一致的路径。
3. 最终统一渲染
当所有逻辑(包括调试和游戏)都执行完毕并填充好 RenderCommands
,我们就开始实际的渲染流程。这个渲染是在调用 DisplayBufferInWindow
的时候完成的:
DisplayBufferInWindow
不再处理具体的 back buffer。- 它改为接收
RenderCommands
,并直接基于这些命令执行渲染。
4. 处理渲染命令排序
在真正开始渲染前,我们需要对所有渲染命令进行排序(比如根据深度、图层等)。为了实现这个步骤:
- 在
RenderCommands
中预留一块临时内存(Temp Memory),而不是每次临时分配。 - 排序过程使用这块内存来构造排序缓冲区。
5. 调用具体渲染路径
排序完成后,根据平台类型执行相应渲染函数:
- 如果是硬件渲染路径(OpenGL 等),调用
RenderToOpenGL
。 - 如果是软件渲染路径,调用
TiledRenderGroupToOutput
。
这两个函数都从 RenderCommands
中读取数据,不需要平台层再进行状态配置或转换,完全解耦。
6. 总结结构优化的意义
- 统一接口:无论是游戏逻辑还是调试系统,都通过同一个
RenderCommands
接口写入命令。 - 去平台依赖:平台不再关心具体后备缓冲区管理,只要调用最终渲染函数即可。
- 排序流程合理:排序和渲染解耦,有临时内存支持。
- 更清晰的数据流:所有渲染数据都聚集在一个结构体中,方便调试与分析。
- 易于扩展和维护:不同渲染路径只需实现对应的最终渲染函数,不影响整体架构。
这个改动将渲染系统从平台层中抽离出来,转而作为游戏层、调试层的统一输出通道,使渲染逻辑更加模块化、可控且高效。
指出三层架构的出现
我们在很早之前就提到过三层架构的概念,现在这个结构逐渐开始显现出来。我们可以清晰地看到,代码分为三个层次:平台层、游戏逻辑层和渲染层。
平台层负责与操作系统或底层平台的交互,比如窗口管理、输入处理等;游戏逻辑层包含具体的游戏行为和规则;而中间的这一层——渲染层,就是现在开始拆分出来的部分。它专注于图形渲染的实现,例如使用 OpenGL 进行图形绘制,或者是基于软件的渲染方式。
渲染层的主要职责是将游戏逻辑层传来的渲染指令进行实际的图形绘制。通过将渲染的解析和执行放在这一独立层中,我们可以更灵活地替换渲染方式。例如,如果希望支持 DirectX,我们只需要在这一层增加对应的渲染逻辑,然后通过一个开关或判断语句来选择当前使用哪种渲染方式即可。这种结构带来的优势是清晰的模块划分,使得各层之间解耦,有利于后期的扩展和维护。
总之,三层结构中的第三层,也就是渲染层,现在开始独立出来,形成了一个可替换、可扩展的部分。其他层依旧保持原样,只是渲染的执行和细节处理交由这一层负责,从而实现了清晰的功能分离与灵活的架构设计。
修改 win32_game.cpp:根据条件选择通过硬件显示软件渲染结果,或使用 StretchBlt
我们目前的任务是对渲染输出部分进行整理和重构,以适应更清晰的渲染路径划分。我们已经明确知道需要进行排序处理等工作,因此第一步是将相关的旧代码裁剪掉,清除不再需要的部分。
接下来,我们实现了一个 TiledRenderGroupToOutput 的流程,用于执行瓦片化渲染。这个部分负责将渲染结果输出到指定目标。完成这一步之后,会紧接着处理图像在窗口上的拉伸显示,也就是 StretchDIBits
之类的操作,它将在这一阶段完成。
在更底层的硬件加速路径中,我们保留了对 OpenGL 的调用,也就是通过 RenderToOpenGL
的方式进行硬件渲染。这一部分仍然保留,继续发挥作用。而之前用于模拟显示、例如某些早期测试用途的代码已经不再需要,基本可以删除。虽然如此,我们也可以将部分代码保留下来,作为备用路径或者参考用途。
逻辑上,我们现在建立了一个分支判断结构:首先调用 TiledRenderGroupToOutput
,之后根据当前渲染方式的选择(软件渲染还是硬件渲染),决定具体的显示路径。如果是使用 OpenGL,那么会通过硬件路径进行渲染输出并调用 SwapBuffers
完成交换;如果是软件渲染路径,那么我们会调用软件渲染函数,然后将其显示,可以选择通过硬件拉伸或其他方式进行最终显示。
这个设计意味着渲染方式的选择是开放的,软件渲染和硬件渲染是两条互不干扰的路径。我们负责完成渲染过程,但具体如何最终将画面呈现在硬件上(比如窗口系统如何处理)就不是我们关心的范畴了。
最终,这次的主要变化其实就是对渲染路径做了清晰的拆分,其他的都是代码边缘的清理工作,例如删除旧代码、添加判断逻辑等。整体结构依然简单清晰,后续的主要工作就是确保新的路径能够正确编译并运行。以上就是我们当前的全部进展和结构调整目标。
修改 game_opengl.cpp:引入 DisplayBitmapViaOpenGL
函数
我们目前正在逐步完善 OpenGL 渲染路径的实现,并使其与游戏渲染命令系统无缝对接。在这个过程中,我们的核心目标是将原本混杂的渲染逻辑清晰地分层,进一步推动三层架构的实际落地和运作。
首先,我们尝试让 RenderToOpenGL
函数正确地运行。为此,我们考虑将相关的 OpenGL 显示函数整理为内联函数,比如定义 inline void DisplayBitmapViaOpenGL(...)
,这样可以将所有 OpenGL 的渲染代码封装得更清晰,使主逻辑更加易读。
我们不再使用早期渲染路径中的窗口参数或其他无关数据,而是只接收来自游戏层的渲染命令,这些命令结构中已经包含了诸如宽度、高度等基本信息,正是我们所需的关键数据。
我们检查了渲染指令中依赖的内容,发现基本上需要的所有东西都已经存在于命令本身,因此我们只需要简单替换原先的依赖,直接以渲染命令为输入即可。
为了让 OpenGL 能够正确渲染软件生成的图像,我们将 DisplayBitmapViaOpenGL
接收的参数限制为我们支持的格式,比如图像的 pitch
必须为 width * 4
(每像素 32 位),并添加断言确保这一限制被满足,避免混淆和潜在错误。
同时,为了简化 OpenGL 的配置,我们封装了屏幕空间矩阵设置逻辑为 OpenGLSetScreenSpace
函数,集中设置投影矩阵等参数,使主流程更清晰。之后在多个地方可以重复使用这段逻辑,减少冗余代码。
另外,像颜色填充矩形这类操作,我们也封装为更直观的调用,比如调用 OpenGLDrawRectangle
,以取代手动填写顶点数组等底层调用,这样大大简化了 OpenGL 渲染的调用路径。
在处理窗口 WM_PAINT
消息时,我们决定暂时不做处理,因为这时的 OpenGL 上下文可能尚未初始化或状态未知,未来如果有需要再引入备用的缓冲处理机制。
接下来,我们调整了软件渲染路径中的函数结构,比如 TiledRenderGroupToOutput
,准备将其移出原先的游戏渲染组模块,作为通用的、平台无关的渲染处理函数。同时将排序相关的逻辑也移出,与平台无关的功能集中在一个共享模块中,比如 GameRenderShared
,这样可以为不同平台或渲染路径共用。
通过这次重构,RenderQueue
等原本需要通过平台层中转的结构,现在可以直接在平台层完成,避免了平台与游戏层之间的来回数据流动,这样也便于多线程的任务分离,比如未来可能只让平台层负责渲染线程,而让游戏层专注于逻辑和模拟。
同时,我们在传递渲染命令时增加了窗口尺寸参数 WindowWidth
和 WindowHeight
,虽然当前还未完全使用,但为将来支持窗口尺寸与渲染分辨率不一致的处理(比如加黑边、居中显示、裁剪等)提前预留了接口。
最后,对整个游戏渲染模块进行重构,将所有真正执行渲染输出的函数,如排序、实际绘制、输出目标设置等,全部迁移到平台无关的共享模块中,只保留渲染组对象本身和它负责的资产需求等逻辑。这标志着渲染逻辑的第三层完全建立,真正实现平台独立、渲染路径可配置、渲染命令结构化的目标。
整体来看,这一阶段的改动显著提高了代码的结构清晰度和模块化程度,为未来扩展新的渲染路径、平台移植、多线程优化等打下了坚实基础。
黑板讲解:三层架构的结构
我们当前构建的整体架构正是我们早期构想中的“三层架构”的实际体现,并且现在已经逐步形成和投入使用。
整个架构分为三层:
第一层:平台层(Platform Layer)
这一层完全依赖于平台,是与操作系统和底层硬件直接交互的部分。它负责诸如窗口创建、OpenGL 上下文管理、输入获取、多线程管理、文件加载、时间控制等所有与平台相关的操作。它的特点是完全与平台绑定,因此这部分代码在不同系统上通常需要单独实现,比如 Windows 和 Linux 下是完全不同的实现方式。
第二层:游戏层(Game Layer)
这一层完全独立于平台,纯粹进行游戏逻辑的运算。它负责所有的游戏状态更新、实体逻辑处理、物理运算、AI 行为控制等。所有的数据在这里被生成并结构化,比如渲染命令队列就是由这层输出的。这部分代码理论上可以在任何平台上直接运行,无需修改,是最核心的部分。
第三层:渲染层(Render Layer)
渲染层是一个“混合层”,也就是说,它既包含平台相关的内容,也包含平台无关的部分。我们将这层分成两部分来看待:
- 平台相关的部分:例如 OpenGL、Vulkan、Direct3D 等图形 API 的具体调用,这些都是跟平台紧耦合的。
- 平台无关的部分:例如渲染命令的排序、合批、通用光栅化算法、软件光栅化器等,这些算法可以运行在任何平台上,不依赖于底层 API。
这一层作为桥梁连接游戏逻辑和实际的显示设备,它处理如何将游戏层给出的抽象渲染命令真正绘制到屏幕上,同时还可根据平台实现差异化的优化或渲染路径选择。
我们选择将这部分称作“混合层”,是因为它在架构上的地位既不像游戏层那样完全与平台无关,也不像平台层那样完全绑定底层,它具有一定的灵活性和可替换性,是整个系统中扩展性最强的一部分。
这种三层架构带来的最大优势在于模块清晰、职责明确,使得每一层都可以独立开发、测试、替换。例如:
- 更换图形 API 只需要替换渲染层中的平台相关部分;
- 移植到新平台只需重新实现平台层的部分;
- 更改游戏逻辑或添加新功能仅需在游戏层进行,无需触碰渲染或平台代码;
- 渲染优化或添加新特效时,可以在渲染层中自由扩展,而不影响核心逻辑。
最终,这种架构为我们建立一个清晰、可维护、可扩展、跨平台的游戏引擎打下了坚实的基础,也使我们能够更从容地应对未来引擎演进中出现的各种需求和挑战。
修改 game_render_group.cpp:处理编译错误
我们正在进行一项比较大的架构调整,主要目标是简化渲染流程和资源管理的逻辑,尤其是去除冗余的 BeginRender 和 EndRender 流程,同时清理与资源“(generation ID)”相关的机制,优化整个渲染管线的初始化与同步过程。
架构调整的主要方向
-
清理渲染流程的 BeginRender / EndRender 概念
发现当前架构中并没有实际频繁使用 BeginRender 和 EndRender,大多数情况下只是在游戏主循环和调试逻辑中被调用,甚至某些模块根本没有使用。由此推导出:这对函数在当前逻辑中并非必要,完全可以被移除。渲染流程将转向以 RenderGroup 为核心的方式:直接分配、使用并释放 RenderGroup,无需显式地开始和结束渲染阶段。 -
RenderGroup 的职责简化
新设计中,RenderGroup 不再负责资源分配或管理临时内存,也不需要与平台层紧耦合。它的唯一职责是将渲染命令组织好,提交给底层渲染系统即可。这意味着:- 不再需要传入 Arena 或 PushBufferSize;
- 只需要持有渲染命令结构体(GameRenderCommands);
- 只需要知道当前的 Generation ID(用于资源一致性验证);
- 其他临时状态如缺失资源数量可以附带,但不是必须。
-
Generation ID(资源)的简化与集中管理
资源系统中原本通过 Generation ID 追踪哪些资源是“在当前帧有效”的,避免多线程渲染中出现资源提前释放的问题。
现在准备将这部分逻辑从多个地方集中起来,统一在渲染系统初始化(每帧)时推进一次 Generation ID,即“AdvanceGeneration”,不再显式区分 BeginGeneration / EndGeneration。新的流程如下:- 在每帧游戏逻辑开始前,调用 AdvanceGeneration;
- 返回当前帧的 Generation ID;
- 赋值给该帧中所有使用资源的 RenderGroup;
- 在帧结束后进行统一清理。
如果未来不再支持例如“地块(Ground Chunk)”这种复杂的资源流动系统,甚至可以完全移除这套机制,进一步简化代码。
-
调试与字体系统资源访问简化
原先调试系统获取资源是通过外部引用进行的,现在可以直接使用统一传入的 GameAssets 实例,并由 RenderGroup 在创建时持有,因此所有使用者都可以通过 RenderGroup 获取一致的资源引用,无需额外逻辑。
接下来的具体调整
- 将原本的
AllocateRenderGroup
函数改造为BeginRenderGroup
; - 移除其中所有涉及 Arena、内存分配和初始化步骤;
- RenderGroup 将只保存指向 GameRenderCommands 的指针和 Generation ID;
- 所有的渲染操作都通过该结构体完成;
- 保留
EndRenderGroup
,但仅作为逻辑上的结尾,用于统计或后续清理,并非资源释放的必要操作; - 缺失资源统计仍可能保留接口,但不再贯穿各层代码;
- 调整原本依赖 PushBuffer 的接口,统一访问
RenderGroup->Commands
中的实际命令缓冲区。
总结
这次重构本质上是从一个历史遗留的、多层次、多接口的渲染和资源系统中,提炼出一个更为纯粹和现代化的逻辑结构。它让 RenderGroup 成为渲染流程中的核心容器,解耦平台逻辑和资源逻辑,使系统更加清晰、易维护,同时也为未来的性能优化、多线程渲染、调试系统独立等提供了更好的基础。
虽然当前还处于中间状态,很多代码尚未编译通过,但方向已经明确,后续将继续推动剩余部分重构完成。
反正就是改了一大堆东西懒得截图
https://gitee.com/mrxiao_com/2d_game_5/commit/f0d02fd4c8e743a7530885a19fb9da5634c06d86
“这些地面区块从头到尾都特别烦人…”β
我们决定彻底放弃“地块(ground chunks)”的机制。这个系统从一开始就是一个不断制造麻烦的存在,既不直观,也让资源系统变得异常复杂,而且并未带来实际价值。现在我们明确不再希望它出现在游戏中,计划将其彻底移除。这一改变也将带动相关的资源(generation ID)机制的简化和优化。
移除地块系统带来的变化
-
** 简化(main generation ID)**
原先我们用主 来协调资源在帧之间的生命周期,主要是为了解决地块加载、卸载过程中的资源一致性问题。随着地块机制的移除,这套复杂的处理流程也失去了存在的意义。- 将
main_generation_id
添加到transient_state
中; - 后续所有使用者直接从该结构中获取;
- 渲染初始化时(
BeginRenderGroup
)传入该 ID; - 无需在多个阶段显式调用 BeginGeneration / EndGeneration。
- 将
-
渲染流程的整合与清理
开始统一使用BeginRenderGroup
/EndRenderGroup
来标志渲染的生命周期,进一步简化调用层级,明确渲染的结构:- 原先通过
AllocateRenderGroup
、BeginRender
,EndRender
三段流程,现在仅保留前后两个函数; - 去除冗余的
ClearRenderValues
操作; - 渲染中间结构
RenderGroup
只需持有命令缓存(RenderCommands
)和; - 将命令缓存和 以参数方式传入构造函数,顺序统一整理;
- 所有需要渲染命令的地方都直接引用
RenderGroup->Commands
。
- 原先通过
-
取消不必要的内存分配和临时内存管理
在当前重构中发现,原本的临时内存区域(transient memory)在渲染流程中已无实际用途。曾经用于RenderGroup
的内存 Arena 和推送缓存(push buffer)逻辑已经不再使用:RenderGroup
初始化时不再传入 Arena;- 相关的内存参数从函数签名中删除;
- 渲染过程中不再动态申请内存;
- 计划在后续阶段完全移除相关的临时内存结构。
当前状态与后续计划
- 已将
BeginRenderGroup
替代旧的AllocateRenderGroup
和BeginRender
; EndRenderGroup
替代EndRender
;- 对渲染命令的引用统一调整;
- 临时内存和渲染资源清理机制待定,但大概率也将被精简甚至移除;
- 当前部分代码尚未完全编译通过,但逻辑框架已明确,计划下一阶段集中整理编译错误并完成全部迁移;
- 整体目标是减少架构复杂性,提高系统稳定性和可维护性,避免无谓的资源管理开销。
这次调整是一次深度的架构清理,主要清除不必要的复杂资源同步机制和渲染生命周期控制逻辑,为后续系统的可扩展性和可控性打下更稳固的基础。我们将在后续继续完善这一重构,并彻底剥离历史遗留的“地块”逻辑。
修改 game.cpp:继续清理编译错误
我们正在重构渲染系统的接口逻辑,目的是移除 DrawBuffer
的概念,并用更清晰、更合理的机制取而代之。在原有架构中,很多函数都接受一个 DrawBuffer
参数,但实际上大部分时候只是为了获取屏幕的宽度和高度,导致整体结构复杂却并不高效。
渲染目标区域替代 DrawBuffer
- 现阶段我们决定将
DrawBuffer
的使用移除,转而通过RenderCommands
直接获取屏幕的宽度与高度; - 我们引入了一个更明确的输出区域描述机制(例如
ScreenSpec
或类似结构),用来标明目标渲染区域; - 渲染函数不再需要
DrawBuffer
,只需知道输出尺寸,即可根据RenderCommands
中的信息设置视角和投影; - 此举可以减少冗余接口参数,提升模块的清晰度和可维护性。
RenderGroup
初始化与管理简化
- 重构后的渲染流程使用
BeginRenderGroup
和EndRenderGroup
包裹渲染过程; - 所有渲染命令集中在
RenderCommands
中,由RenderGroup
持有; - 初始化
RenderGroup
不再需要传入内存 arena,临时内存逻辑被彻底清除; - 清晰的代际 ID(
MainGenerationID
)依旧保留,以确保渲染资源状态正确同步; - 虽然我们暂时保留了
MainGenerationID
,但已经评估其带来的复杂性远超收益,未来计划逐步移除这一机制。
Debug 与 Game Render 的整合调整
- 调试功能模块(debug state)也迁移到了新的
RenderGroup
架构; - 调试系统中的渲染调用,例如
DebugStart
、DebugGameFrameEnd
,不再依赖GameOffscreenBuffer
,改为仅传入RenderCommands
; - 相应函数签名和调用点都进行了修改,确保渲染命令贯穿整个调试流程;
- 渲染命令由调试系统与游戏主体共同累积,最终统一由平台层处理输出。
平台层接口调整与资源同步
- 在平台层,游戏与调试的渲染命令会合并进入同一套接口;
- 不再需要为每个渲染目标单独维护 buffer;
- 所有渲染资源的准备阶段(资源是否准备齐全)可能转移到渲染前的检测流程中,而不再由具体渲染调用内部处理;
- 当前仅剩下部分平台层接口尚未完全迁移完毕,核心逻辑已就绪。
整体结果与后续任务
- 游戏已经能够成功编译,主要结构清理工作已经完成;
- 渲染命令收敛机制、调试逻辑和平台层接口之间的协作已初步理顺;
- 后续将继续完成尚未迁移的部分接口,将新的渲染命令流程全面替代原始的
DrawBuffer
流程; - 最终目标是实现一个简洁、高效、易维护的渲染架构,彻底摆脱早期设计遗留问题。
这次的重构为后续开发打下了坚实的基础,也让渲染架构更加直观、清晰,更贴合实际需求。后续还需完成资源准备与平台层渲染流程的整合,进一步提升整体的性能与灵活度。
Q&A
那渲染器的代码就不再支持热重载了吗?
目前我们已经不再为渲染代码保留热重载机制。虽然现在不支持,但如果未来有需要,依然可以实现热重载,这完全取决于是否需要该功能。
关于热重载的可行性与实现方式
- 如果希望重新启用热重载机制,只需要将相关模块(例如
GameOpenGL
和资源访问逻辑)从平台层迁移到游戏代码中; - 然后在平台层保留一个指向这些模块的指针,通过指针调用实现热重载;
- 这样的设计仍然保留热重载的灵活性,但不会影响当前的系统稳定性;
- 当前阶段不做这一改变,是因为暂时没有热重载的需求,因此也不增加额外的复杂性;
- 一旦决定启用热重载机制,只需做出小范围调整即可,主要是结构上的指针传递改动。
当前结构的选择原因
- 热重载虽然灵活,但也增加了接口复杂性与维护成本;
- 现阶段重构的核心目标是清晰、可控和稳定,因此选择先行简化架构;
- 资源如
GameOpenGL
暂时仍在平台层初始化,并不通过指针下发给游戏层; - 我们保留了将来调整的能力,但不会预先引入可能暂时不需要的特性。
总结来说,我们现在不使用渲染代码的热重载机制,但架构上完全可以在未来恢复它,只需要将相关资源交由游戏层管理,并将指针返回给平台层。这种设计既保留了未来的灵活性,也让当前系统更清晰、更稳定。
你对那些地面区块似乎有点怨念,是不是特别讨厌它们?
我们对“ground chunks”(地面区块)一直持保留态度,甚至可以说已经不太愿意继续保留它们。
不再需要地面区块的原因
- 引入了不必要的多线程复杂性:这些地面区块强制引入了大量多线程处理的问题,而如果没有它们,我们根本无需面对这些额外的线程管理与同步挑战;
- 没有实际收益:如果这些复杂性换来的是某些有意义的价值,比如可以在后台执行复合渲染、共享渲染路径等高性能功能,那倒是值得,但现在我们并没有获得这样的好处;
- 功能被闲置:目前来看,游戏本身似乎并没有真正使用这些地面区块。它们的存在仅仅是带来了系统结构上的额外负担;
- 教育意义已经达成:虽然一开始保留这些复杂结构是出于教学和学习的目的,为了展示多线程和资源管理技巧,但现在也已经充分了解了它们的处理方式;
- 性价比低:继续维护这些地面区块,需要花费大量精力去清理、简化它们带来的多线程问题,而这些投入并不能带来与之匹配的收益;
- 可以选择更有意义的方向投入开发时间:如果要花时间解决这些地面区块的问题,不如将时间用于更直接提升游戏功能或体验的模块上,毕竟开发资源和精力有限。
对未来的思考
- 虽然理论上我们可以继续清理并优化这些地面区块,使它们不再那么难以管理,也可能成为一个有趣的技术挑战;
- 但就实用性而言,与其在它们身上花费大量精力,不如彻底剔除,集中精力做真正有用的系统功能扩展;
- 因此目前的倾向是彻底抛弃这部分系统,以换取更简洁、可控、聚焦的代码结构。
总而言之,我们认为地面区块系统已经失去了存在的必要,不仅增加了复杂度,而且没有为项目带来实际好处。与其继续维护这块低价值的遗留代码,不如将时间用于更有意义的游戏开发任务。
你一开始设计地面区块的初衷是什么?后来有变化吗?如果删掉了它们会用什么替代?
我们最初设计 ground chunks(地面区块)的目标,是为了实现一个更加自由的“外部世界”场景。设想中,整个游戏的外部空间应该是开放式的,具有较强的自由度,类似“溅开的地面”、“流动的自然形态”那种不拘泥于规则格子的布局方式。
最初目标:
- 希望外部区域(overworld)能展现出自然、不规则、连续变化的视觉表现;
- 地面区块被设计成独立渲染单元,通过组合和动态加载方式表现复杂地形;
- 设想通过这种机制提升地图的视觉表现力和开放感。
当前的变化:
- 游戏设计重心转变:现在的设计思路发生了变化,游戏本身的玩法结构不再适合“自由拼接”的外部世界;
- 更偏向网格化结构:为了匹配玩法节奏、交互模式和系统设计,外部区域不再适合自由布局,而是应该类似“瓦片地图”的结构——并不是指引擎上的 tilemap 技术,而是指在设计上需要一种明确、规则的空间组织方式;
- 艺术表现需与玩法统一:既然玩法决定了空间结构的规律性,那么美术资源也必须配合,原先“溅开的地面”在新的体系下就显得格格不入;
- 性能与复杂度考量:即使我们还想保留这种视觉效果,其实也没必要通过复杂的地面区块机制来实现。现代硬件渲染路径性能充裕,即便是软件渲染路径,只要有合适的裁剪与组织逻辑,也能在运行时完成这些效果;
- 不再具备技术优势:相比过去,地面区块机制在现在的硬件环境下已经没有了技术上的必要性,反而显得繁琐、拖累系统结构。
替代方案:
- 运行时生成与裁剪:改用运行时构建可视区域的方式,通过渲染命令进行高效裁剪和绘制,直接根据 tile 数据决定哪些区域需要渲染;
- 更紧凑的瓦片系统:构建一种清晰的 tile-based 系统,确保逻辑、资源管理、渲染流程都围绕它展开,降低复杂度;
- 统一的资源调度方式:不再将地面划分成“chunks”异步加载,而是将资源绑定在 tile 层面,以线性或局部调度机制进行控制;
- 保持视觉灵活性:如果未来确实有必要实现某些特殊地形视觉效果,完全可以通过更轻量的纹理动画、shader 技术或局部粒子系统来模拟。
综上,我们放弃 ground chunks 的主要原因在于它的设计理念已与当前的游戏目标背道而驰,不仅无助于实际效果,还增添了系统负担。通过更现代化、灵活且高性能的替代手段,我们可以更好地支撑游戏整体架构,同时提升开发效率和可维护性。
你会不会整理一份“程序员应该知道的东西”列表?我想知道像“排序算法”这种初级必学内容
我们认为,整理一份程序员应当掌握的知识清单确实是一个非常好的想法,尤其是针对初学者,提供一些明确的学习方向和入门路径会非常有帮助。
这个建议的价值:
- 程序员需要掌握的基础知识非常广泛,包括但不限于算法、数据结构、计算机系统原理、编程范式、调试技巧等;
- 初学者常常缺乏明确的学习路线,这种清单可以在学习初期起到“导航”的作用;
- 如果能罗列出常见算法类型(例如排序、查找、图、动态规划等)和基础算法实现,就能帮助很多人建立起基础算法体系;
- 同时还可以涵盖基础数据结构(数组、链表、哈希表、栈、队列、树、图等),以及何时该使用它们;
- 除了理论知识,也可以包括一些实用技能,比如版本控制(如 Git)、调试方法、测试策略、代码风格、编译器工作原理、性能优化基础等;
- 最理想的形式是一个结构化的资源库,按难度或领域分类,便于逐步学习。
实际情况与困难:
- 整理这样一份资源清单本身是一项系统性工程,耗时巨大;
- 需要投入大量时间梳理、分类、验证,并附带解释说明与例子;
- 当前阶段时间和精力有限,短期内很难完成这样一个任务;
- 将来可能会有人去做,也可能某些组织或社区会整理出来这样一份优秀的资源清单。
总结:
这是一个非常值得做的项目,对初学者和自学者尤其有价值。我们非常支持这样的想法,只是当前可能没法亲自去完成它。未来如果有时间,或许会回过头来参与类似的整理项目。不过希望也能有人愿意接下这个任务,把它变成一个真正可用的公共资源。