回顾前一天内容,并为今天的开发工作设定方向
我们正在直播制作完整游戏,当前正在实现一个精灵图(sprite graph)的排序系统。排序的代码已经写完,过程并不复杂,虽然还没做太多优化,但总体思路比较直接,已经实现了基本功能。
为了让这个系统正常运行,需要对一些底层基础设施进行升级和改进。昨天结束时想到这些事情,我提到希望相关代码能放到平台层(platform layer)去实现。现在我们意识到,也许可以趁机把大部分渲染相关的代码都移回平台层。因为实际上我们调用的接口不多,而且之前做了不少隔离工作,所以这样做是可行的。
将渲染代码放回平台层还会带来好处,比如能够重用已有的资源和逻辑,提升整体架构的合理性和维护性,这看起来是非常值得尝试的事情。当前会先仔细看看这些代码和结构,暂时还没决定具体方案,计划今天深入研究后再做判断。
game_opengl.cpp:考虑将整个文件变为平台无关代码
我们现在思考的是关于OpenGL部分的代码结构。观察OpenGL相关的代码,发现其实回调函数调用非常少。主要的回调是平台层的纹理分配函数(platform allocate texture),但它们实际上是从反方向调用的。如果把这些代码移到主代码部分,就不再需要这些回调,这样可以减少边界转换,减少边界切换总是有好处的,能提升性能和代码简洁度。
除此之外,其他代码大部分是内联函数,结构相当简单。唯一比较特别的是一个叫“knit call”的调用,还有一个OpenGL渲染命令调用。想到这里,我们可以考虑把一些不必要的调用删掉,甚至不需要写代码就能去掉它们。
目前感觉,既然OpenGL是跨平台的,我们完全可以把它相关的东西整理好,进行部分重构。比如,OpenGL代码里有一部分是和“wiggle”相关的(可能是调试或动画效果),这部分只在Win32平台层执行,可以把这部分特定代码移到平台层,而把和OpenGL本身无关的部分留在游戏代码中。游戏代码只需要暴露一两个调用,比如OpenGL渲染命令调用,大概只需要调用一次,初始化也可能需要。
另外,OpenGL显示位图的功能主要是为了支持软件渲染模式。这个功能的处理方式和OpenGL渲染命令类似。如果我们想继续支持两种显示模式——一种是通过OpenGL,一种是直接调用Windows的图形显示接口(GDI),那么就可以分别保留这两套调用。比如一种显示方式是把图像直接拉伸后显示,另一种是通过OpenGL路径提交给显卡。
总结来说,可以考虑把OpenGL相关渲染代码和平台相关的窗口操作代码适当拆分和归类,优化边界转换,同时保留多种显示模式的支持,提升代码的整洁度和扩展性。
game_platform.h:考虑将 platform_opengl_display_bitmap
和 platform_opengl_render_commands
从 game_opengl.cpp 移到这里
我们现在开始着手进行代码重构的操作,初步判断这是一个比较安全的改动,没有明显的理由不去做。因此决定逐步推进这项调整,如果过程中发现问题或操作不太顺利,也可以随时撤回,不会有太大代价。
计划采用谨慎推进的方式,一步一步来操作,确保每个步骤都符合预期。如果有任何部分让人感觉不对劲,可以立即停止调整,回退到原状态。
首先查看当前的 platform_api
接口,虽然这个接口本身已经显得有些复杂,今后也许应该对其做一次简化整理。但从目前来看,调整不会进一步加重接口复杂度,因为本质上我们只是新增类似 platform_opengl_display_bitmap
和 platform_opengl_render_commands
这样的函数而已。
这样一来,从接口边界管理上看也不会变得更复杂,算是一种“等价交换”,不会增加额外的负担。
但在进一步检查之后,意识到先前的想法有误:原本认为可以通过在 platform_api
中增加这些OpenGL函数来处理,但这些函数其实是从游戏层传入平台层的,而不是从平台层传给游戏层的。因此,最终并不会在 platform_api
中添加这些,而是要在类似 GameUpdateAndRender
的接口层面新增一个用于执行图形渲染工作的调用接口。
所以重新调整思路,不是在原来的平台API中添加函数,而是新增一个从平台传入游戏代码的图形渲染调用接口。最终的结构将稍有不同,但仍然保持边界职责明确、复杂度不变的目标。这个结构更符合程序模块之间的职责分工,也更利于后续维护和扩展。
讨论让所有平台层都包含 OpenGL 的绑定并链接 DLL 的弊端
我们最初设想将 OpenGL 渲染相关的函数移入主游戏代码中,这样所有的 OpenGL 调用也会跟着进入游戏模块。理论上,这并不成问题,因为我们可以在每个平台上链接对应的 OpenGL 库,并引入所需的绑定。这种做法在技术上是可行的,每个平台编译时都链接其平台特有的 OpenGL 实现就行了。
但在进一步思考后,我们对这种做法产生了犹豫。原因是,一旦将所有 OpenGL 调用放入游戏模块中,就会引入平台特有的调用,而当前我们的游戏代码其实并不直接依赖任何平台特性。现有结构允许游戏代码被完全独立地编译和使用,不依赖平台层实现的内容。
如果强行让游戏模块依赖 OpenGL,那么游戏模块在编译时将不得不链接特定平台的图形库。这样会破坏我们保持游戏代码平台中立性的设计。我们理想中的状态是,甚至可以直接将 Windows 平台上构建好的 DLL,在 macOS 或 Linux 中通过平台层的适配器加载和运行——因为游戏逻辑本身并不直接调用任何平台接口。
当前这种结构是非常有价值的,它为跨平台部署提供了极大的灵活性。我们只需为每个平台实现不同的“平台层”,就可以在不改动游戏代码的前提下实现平台兼容。如果现在将渲染代码塞进游戏层,这种灵活性将被破坏。
当然,如果我们以后确实需要对渲染模块支持热重载,也可以将其从平台层中单独拆出来,作为另一个动态库加载。但从目前来看,这样的复杂度远超当前需求,也没有足够的收益。
因此,我们最终决定放弃这个想法,不再将 OpenGL 渲染逻辑移入游戏代码中。还是沿用之前讨论的方案,保留平台代码与游戏代码的明确分界,不做过于激进的结构调整。这样既可维护性好,也保留了跨平台的灵活性。
win32_game.cpp:让 Win32DisplayBufferInWindow()
调用 SortEntries()
,同时考虑是否将排序或 memory_arena 移入平台专属层
现在我们有两种方式可以处理当前的问题,但我们还没有最终决定采用哪种方案。
之前我们在代码中实现了一个 SortEntries
的函数,它的功能是对一些图形项进行排序。但这个函数的问题在于它期望接收一个 memory_arena
(内存分配区域)作为参数。而我们在调用它的时候,并没有传入这个参数,这就导致了使用上的不便。
我们真正想要的是在调用类似 SortEntries
这样的函数时,能够自然地传入内存区域,从而简化代码逻辑并使其更具可用性。
目前我们在两个方向之间做权衡:
方案一:
将排序逻辑移入平台层
也就是说,把和排序相关的功能放在平台特定的代码中。这样做的好处是所有需要用到内存区域的临时操作都可以集中在平台层里完成。我们可以在平台层内自由创建临时内存,进行排序,然后把结果交给游戏逻辑。这种方式封装性好,但可能让平台层变得更重。
方案二:
将 memory_arena
的定义移动到平台层中,使其成为共享功能
也就是说,把 memory_arena
这种通用的数据结构和相关工具,直接作为平台层的一部分进行共享。这样做的好处是无论平台层还是游戏层,都可以使用统一的内存分配策略,也让平台层代码更易复用现有的分配逻辑。
我们倾向于方案二的理由是:
当前在平台层(比如 Windows 平台的具体实现中)已经有很多地方本可以用 memory_arena
简化内存操作,但由于我们没有包含它,所以不得不写了一些重复甚至冗余的逻辑。如果把 memory_arena
移出来,放在平台共享代码中,以后在平台层需要分配临时内存或管理短期对象的时候,就可以直接使用这些结构,而不用重复造轮子。
这也是一个好时机来完成这件事——将 memory_arena
移动到平台级共享位置中,这样今后就不需要在调用这些工具函数时再担心是否可以使用临时内存或手动管理分配。
接下来我们就准备着手进行这项调整。这样一来,我们就为平台层和游戏逻辑之间搭建了更自然、更通用的内存共享机制。
新建 game_memory.h,并在 game_shared.h 中引入
我们现在进入了 game 的代码目录,准备进行一些代码结构调整。
首先,我们创建了一个新的头文件 game_memory.h
和一个对应的 game_memory.cpp
文件。这两个文件的目的是将原来分散在其他文件中的内存管理逻辑独立出来,使其成为一个统一的、平台与游戏逻辑都可以共享的组件。
我们从已有的内存管理代码中复制了一些功能,包括:
memory_arena
(内存分配区)结构体- 临时内存分配器(temporary arena)
- 用于对齐和清零的辅助宏和函数
- 内存区域的分配与释放工具(如 PushSize、PushArray)
- 一些零大小结构体与内联工具函数
这一部分本身就具备很强的独立性,几乎构成了一个完整的内存模块。所以我们只需要把它拷贝出来,放入单独的文件中,再清理掉那些依赖于上下文但不属于该模块的杂项引用就行。
在 .cpp
文件中,目前并不需要写什么内容,因为这个内存模块的功能基本上都通过内联函数实现,逻辑相对简单,暂时没有复杂的运行时逻辑。如果将来需要添加线程安全或其他高级内存管理功能,可以扩展 .cpp
文件来处理。
接下来,为了使这部分模块在平台层与游戏逻辑中都能使用,我们需要确保它被正确地包含。最初我们查看了 game_shared.h
文件,这是一个用于多个子系统之间共享声明的头文件。我们发现它本来是用于预处理器(比如我们之前写的小型预编译器)共享的一些类型声明的,但现在它也可以承担更多共享逻辑的职责。
因此,我们决定将 #include "game_memory.h"
添加进 game_shared.h
,这样所有包含该共享头文件的模块都能直接访问 memory_arena
等结构。这一步统一了依赖,避免在多个地方重复包含。
接下来,我们回到了原始内存相关代码所在的地方,并删除了那些现在已经被独立出来的部分。这样做可以保持源文件整洁,也明确了模块之间的边界。
这一操作完成后,我们就可以在任何需要使用内存分配器的地方直接声明 memory_arena
并进行内存操作了,例如排序操作。
现在只需要做一件事:确保传递 memory_arena
给排序函数时,有一块可用的内存可供使用。
同时,我们还注意到另一个待办事项:out_index_array
,这是我们之后渲染过程中想要依赖的数据结构。目前渲染流程还没有利用它,但我们希望将来能改进这一点,把渲染流程与这个输出索引数组连接起来。这部分工作将会在稍后的流程中分开处理。
总结如下:
- 创建并引入了
game_memory.h/cpp
,统一并封装了内存管理逻辑。 - 将内存模块抽离为独立组件,方便平台与游戏层复用。
- 清理了原始代码中的重复实现。
- 修改了共享头文件以提供模块访问能力。
- 准备将其用于更高级的用途,例如排序与渲染。
后续计划包括把排序结果与渲染逻辑结合,并进一步理清 out_index_array
的作用和使用方式。
当前没有引用.cpp 文件因此之前没有引入并且不需要这个shared文件
memory 相关的提取到memory中
引入 overflow memory state(溢出内存状态)的概念
现在是一个很合适的时机来探索一下我们刚刚抽离出来的内存管理系统,并对其进行进一步扩展和增强。一个很值得尝试的改进方向是加入“溢出内存状态”(overflow memory state)的概念。
目前我们对内存的使用模式是一次性地预分配一整块内存,然后在这块内存中进行分配。这种方式在一些环境下非常理想,比如:
- 我们清楚地知道平台上可用的内存量;
- 需要最大限度保证确定性与性能;
- 类似旧式游戏主机或内嵌系统的架构,这种方法非常稳定可控。
但在现代系统,尤其是32位 Windows 上,内存管理变得更加复杂。虽然理论上我们可以申请比如 1GB 或更多内存,但实际上可能无法获得一整块连续的地址空间。原因是系统的地址空间被碎片化了,即使系统有足够的空闲内存,也可能无法分配出一整块连续的大内存区域。举个例子:
- 尝试一次性申请 1GB 内存,有时会失败;
- 系统中虽然确实有 1GB 空闲内存,但它被切成了多个小块;
- 系统可能只愿意分配多块小区域,而不是一整块连续的空间。
在这种情况下,预先一次性分配大块内存的方式就不太适用了。为此,我们可以让内存分配器支持“分块扩展式”的模式:
可扩展的内存分配模式设想:
我们为内存分配器加入两种工作模式:
1. 固定块模式(当前已有)
- 提前分配好一整块内存;
- 分配失败即为整体失败;
- 适用于对资源调度严格、内存确定性的应用。
2. 溢出块模式(待添加)
- 初始分配一个块,使用完之后继续动态分配新的内存块;
- 每次内存不足时,内部自动添加新的区块接入分配链;
- 允许系统动态决定能分配多少;
- 适合需要动态扩展内存的场景;
- 直到系统拒绝再分配,才真正“用尽内存”。
通过这种方式,我们可以支持以下情况:
- 游戏在运行时根据实际需要扩展内存;
- 玩家可以创建大量或无限量的游戏对象;
- 某些具有高动态内容密度的游戏场景(如大世界模拟、开放世界生成);
- 可以在64位系统中使用几乎无限的内存空间,也能尽可能兼容32位系统的限制。
现有代码的兼容性
我们目前的内存分配器设计非常灵活,从逻辑结构上来看已经为这种扩展方式做好了准备。因此实现这类功能并不需要大幅改动代码结构,只需要:
- 在
memory_arena
中增加链式存储多个内存块的能力; - 在每次分配失败时自动扩展新的内存块;
- 在内存释放或重置时,统一处理多块内存的回收。
这种功能虽然在某些项目中可能并不会被用到,比如体量较小的游戏或逻辑非常受限的应用,但一旦涉及到玩家行为高度不可预测或数据规模非常庞大的应用场景,就能展现出它的巨大价值。
因此,这会是一个值得实现的功能点,也可以作为下一步继续开发与探索的内容。
win32_game.cpp:修改 Win32DisplayBufferInWindow()
,让它接受一个 memory_arena,并创建临时内存供 SortEntries()
和 LinearizeClipRects()
使用
我们注意到,在调用 Win32DisplayBufferInWindow
的时候传入了两种不同类型的内存,但实际上没必要这么做。我们完全可以使用一个共享的临时内存池(temporary arena
),将其作为参数传递给相关模块,由它们自己在其中申请所需的内存,这样既简洁又高效。
我们的想法是把这个临时内存池传递给如排序(SortEntries
)或线性化裁剪矩形(LinearizeClipRects
)这些流程,让它们直接从这个共享内存池中分配数据结构所需的内存。我们只需确保该内存池预分配的内存足够大即可。
以排序为例,我们可以将这个临时内存池传入 SortEntries
,在其中分配排序后的索引数组(例如 out_index_array
)。也就是说,这个索引数组完全可以由 SortEntries
通过临时内存池自动分配出来,而不是外部调用者显式提供。
这样一来:
- 排序的输出数组通过内存池自动分配;
- 裁剪矩形的线性化输出也可以通过相同机制处理;
- 所有临时分配都绑定在一次绘制(render)调用生命周期之内;
- 在渲染结束后,统一调用释放或重置该内存池,就能清理所有内存,过程简单干净。
此外,我们可以通过 BeginTemporaryMemory
和 EndTemporaryMemory
来管理整个渲染过程的内存生命周期,这样就不必担心遗留内存泄漏或资源无法释放的问题。
我们注意到部分函数如 CreateWindowClipRects
理应返回某些结果或数据结构,但从现有调用来看,似乎并未实际返回或传递。这个行为可能是因为这些数据被嵌入到了 RenderQueue
或 GameRenderCommands
之中,通过统一的渲染指令流传递下去。
从代码组织的角度看,虽然这种做法可能略显隐晦,但它的好处是:
- 所有渲染相关的数据结构统一附着在
GameRenderCommands
结构上; - 方便传递,避免修改过多调用者接口;
- 易于扩展,后续添加渲染数据只需修改该结构,无需到处传参。
虽然这种做法在结构清晰度上稍有折中,但从维护成本和代码一致性的角度来看是合理的,因此我们决定保留现有机制。
总结来说:
- 使用统一的临时内存池代替多种内存类型传递,更简洁高效;
- 排序与裁剪流程内部直接从该内存池分配输出数据;
- 使用
BeginTemporaryMemory
/EndTemporaryMemory
管理内存生命周期; - 保留
GameRenderCommands
作为统一的数据载体,避免接口碎片化; - 后续可继续强化这种模式,提升系统灵活性与可维护性。
game_opengl.cpp:在 OpenGLRenderCommands()
中调用 GetSortedIndices()
我们设想如果要在这个位置进行渲染索引的排序(DrawIndices
)处理,那么整个逻辑就会变得很清晰。
核心变化是:我们不再单独去获取 SortEntries
了,也就是说,不再在这个模块里显式调用 GetSortEntries
,而是直接通过 SortDrawIndices
来获取需要的渲染顺序。这个排序处理会向前传递、穿过调用链,而不在每一层手动操作排序过程。
具体执行过程如下:
SortDrawIndices
会返回一个排好序的索引数组;- 然后我们直接使用这个数组的索引顺序,遍历其中的每一项进行绘制;
- 这个排序索引数组是
uint32
类型的数组,其中每一个元素表示一个entry
的下标; - 实际遍历的时候,不再使用额外的中间结构来存储
entry
本身,只需要用这些排好序的索引即可; - 每次渲染时,通过索引数组获取目标
entry
,然后进行相应操作; - 原来的
entry
数组下标不再直接参与渲染顺序的逻辑,完全由排序后的索引数组控制流程。
这种做法带来的好处:
- 简化了逻辑:不需要额外维护复杂的
SortEntry
结构; - 减少了内存使用:只用一个索引数组就能完成渲染排序;
- 避免重复计算:排序操作在前置步骤中集中完成;
- 更灵活:后续要调整排序策略,只需更改
SortDrawIndices
的实现,无需波及渲染过程; - 渲染流程变得更清晰:渲染顺序和数据访问分离,提高可读性和可维护性。
总之,我们调整了结构,不再以结构体 SortEntry
为核心中介,而是让排序阶段只生成一个 uint32
数组作为索引参考。渲染阶段仅依赖这个排序后的索引数组,从而让整个流程变得更加线性、高效和清晰。
game_render.cpp:在 RenderCommandsToBitmap()
中调用 GetSortedIndices()
我们现在可以回到渲染逻辑中,做一模一样的调整。因为本质上,这两个渲染循环之间没有本质区别,它们只是前端接口不同,背后执行的是同样的操作:都要遍历渲染命令并进行渲染。
这意味着,它们前半部分的逻辑几乎完全一样,唯一的区别在于循环内部实际执行的渲染方式不同。由于它们都依赖同一套渲染命令结构并按照顺序执行操作,我们可以将处理流程统一,进一步简化和标准化渲染系统的结构。
我们所做的改动是:用排好序的索引数组来代替原本的 entry
遍历。
具体处理方式如下:
- 在渲染模块中也替换为使用排序后的
uint32
索引数组; - 不再显式遍历原始渲染命令数组,而是通过这个索引数组访问对应的
entry
; - 我们将这些排序索引放入渲染命令结构中,使其可以在后续渲染阶段统一访问;
- 将原来的排序逻辑封装成一个函数
GetSortedIndices
,它用于生成这个索引数组; - 渲染阶段只关注一个结果:已排序的索引,然后按照这个顺序读取命令并执行;
- 索引数组在
clip_rects
的基础上生成,和之前我们对SortEntries
的设想类似; - 这样整个渲染流程变得统一、简洁、易于维护。
最终,所有渲染系统只需关注如何根据排序后的索引执行命令,而无需重复进行排序或管理中间结构。排序与渲染逻辑彻底解耦,系统更具灵活性,适应性也更强。只需根据不同情况替换排序策略,不必改动渲染代码,结构更加稳定可靠。
game_platform.h:引入 game_render_prep
结构,并让必要的函数使用它
我们现在需要做的一件事,是为“已排序的索引”提供存储空间。这个需求之所以重要,是因为整个渲染过程可以划分为两个本质不同的阶段:
第一阶段:构建渲染命令
这个阶段是我们在游戏层面创建渲染命令并交由平台层处理。这个结构叫做“游戏渲染命令”(GameRenderCommands),它基本上运行良好,结构也算清晰。
第二阶段:准备渲染数据
这个阶段才是真正的“渲染准备”,在这里:
- 渲染命令不再直接使用,而是被加工成更适合渲染管线的形式;
clip_rects
在这个阶段才被线性化(linearized);- “已排序的索引”数组也是在这个阶段生成;
- 这些数据是临时性的,在整个渲染阶段结束后就会被清理。
结构问题与重构方向
目前我们把 clip_rects
硬塞进了渲染命令结构中,看起来不太自然。实际上,这些东西只在渲染准备阶段才真正存在,不属于游戏逻辑中的数据。为了更清晰地表达不同阶段的职责,我们应该把这些“后处理生成的数据”移到一个新的结构中,比如叫 GameRenderPrep
。
这个 GameRenderPrep
用于承载所有与“渲染准备阶段”相关的内容:
- 线性化后的
clip_rects
; - 排序完成的
uint32
索引数组; - 所需的临时内存。
这样做的好处:
- 职责分离明确:渲染命令结构只关注游戏逻辑产生的数据,渲染准备结构关注从这些命令中加工出来的派生数据;
- 便于维护和理解:清晰地知道哪一部分是输入,哪一部分是为渲染过程服务的中间态;
- 更符合系统设计原则:让数据的生命周期更加清晰,避免混乱;
- 渲染接口更清晰:例如传递给 OpenGL 渲染器时,统一传递
GameRenderCommands
与GameRenderPrep
,渲染器从中提取必要的数据,无需再通过函数调用“偷”数据或全局访问。
实际操作中的应用变更:
- 在 OpenGL 渲染部分,我们从
GameRenderPrep
中提取sorted_indices
和clip_rects
; - Metal 渲染部分也采用完全相同的方式;
- 我们将原来的
GetSortedIndices
那些操作封装为“渲染准备”阶段的一部分,不再混杂在渲染命令处理流程中; - 原来和排序有关的实现文件也不再适合当前用途,排序逻辑演变为图遍历(graph traversal)类型,建议将其迁移到新的、更合适的位置中;
- 不再使用那些
MergeSort
,RadixSort
等原始排序逻辑,这些已不再服务于当前渲染结构。
总结:
我们现在通过引入 GameRenderPrep
,将渲染数据预处理(如排序、裁剪矩形线性化等)从主渲染命令中剥离,形成清晰的两阶段渲染体系。这样一来,渲染流程更整洁、逻辑更清晰、接口更统一,未来拓展、调试、优化都将更加方便。
game_render.cpp:引入 PrepForRender()
函数
我们会创建一个新模块,用来生成 GameRenderPrep
结构,这样的设计更简洁、更具扩展性。新的设计思路如下:
引入独立的渲染准备模块
我们会专门有一个函数或流程来生成 GameRenderPrep
,这个函数的职责是:
接收渲染命令
GameRenderCommands
;接收用于临时分配的内存区
Arena
;构建出
GameRenderPrep
,其中包含:- 已排序的绘制条目索引;
- 线性化后的裁剪矩形;
- 可能还有其他临时性的渲染数据。
排序算法变得非常简单
由于排序逻辑已经被简化,不再依赖复杂的手写排序算法,我们可以将排序代码抽出,放入通用模块(如 shared 或 utilities),方便共享与复用。这个排序功能已经足够独立,不再依赖渲染管线中其他细节,因此将其放到平台无关的公共代码中更加合理。
渲染平台不再关心处理细节
我们让每个平台的渲染层(OpenGL、Metal、Vulkan 等)都只接收已经准备好的 GameRenderPrep
:
- 平台层不再负责排序、不再处理裁剪矩形转换;
- 只需调用通用的
PrepareForRender()
函数; - 渲染平台只关注执行,不管背后数据如何构建;
- 使渲染逻辑清晰分工,职责明确。
新的 PrepareForRender
函数
这个函数位于平台无关模块中,它非常简单,主要做两件事:
接收两个参数:
- 渲染命令
GameRenderCommands
; - 内存 arena;
- 渲染命令
内部完成一整套的渲染准备工作,包括:
- 排序;
- 生成索引;
- 线性化
clip_rects
; - 构建
GameRenderPrep
对象。
最后,这个函数将 GameRenderPrep
返回给调用者,由调用者决定将其传递给哪个平台渲染实现。
优点总结:
- 平台解耦:不同平台都用同样的入口函数
PrepareForRender()
,不再处理准备细节; - 逻辑清晰:职责划分明确,渲染命令构建与数据准备完全分开;
- 更易维护:排序和准备工作封装在一处,代码集中易于修改;
- 扩展灵活:未来添加更多的中间处理步骤也能统一放进
PrepareForRender
中处理; - 复用性高:通用的排序、裁剪转换等逻辑可以跨平台共享。
总的来说,我们的渲染准备系统实现了清晰的职责划分,通过引入 GameRenderPrep
,使平台渲染逻辑解耦并简化。整个渲染流程被拆分成清晰的阶段:命令生成 → 渲染准备 → 平台执行,让系统更加健壮、可维护,也方便后续的调试与优化。
win32_game.cpp:让 WinMain()
不再处理 ClipMemory
,而由 game_render.cpp
中的 LinearizeClipRects()
自行处理
我们决定重新组织线性化裁剪矩形(clip rects)的生成方式,以更简洁、更高内聚的方式处理这部分逻辑。
移除平台层对临时内存的干预
过去平台层需要了解裁剪矩形的数量、计算内存大小、手动分配一块临时内存,然后将其传递下来供裁剪处理函数使用。这种做法:
- 增加了平台层的复杂度;
- 导致了职责混乱;
- 不符合模块封装的设计原则。
现在我们完全取消平台层对此流程的干预,所有相关逻辑集中在裁剪处理模块内部。
在线性化裁剪时自动分配内存
我们将在 LinearizeClipRects
函数中自行处理所有必要的内存分配。这部分逻辑将被集中管理,包括:
- 根据
first_rect
和last_rect
确定所需裁剪矩形数量; - 使用内存 arena 分配目标数组空间;
- 不再要求调用者提供目标内存,只需要提供 arena;
- 返回包含所有线性化后裁剪矩形的数组引用(或结构体封装)。
这让调用变得更简单、更清晰。
计算目标数组大小逻辑
我们使用一种可靠方式来计算需要多少 RenderEntryClipRect
:
count = (last_rect_index - first_rect_index + 1);
然后调用类似 PushArray(arena, count, RenderEntryClipRect)
的分配方式,把内存交给 arena 自动管理,不需要平台层处理。
简化调用流程
以前平台层调用 GetTempSortMemoryAndClipMemory(...)
,然后再手动组织参数传入裁剪处理函数。而现在我们只需要做:
clip_rects = LinearizeClipRects(commands, arena);
然后后续直接使用 clip_rects
即可。平台层既不再关心内存大小,也不再关心裁剪矩形数量,只需要关心自己传入 arena 即可。
逻辑职责划分更明确
平台层:
- 只关心准备好 arena;
- 调用渲染准备函数;
- 接收处理完的结构体。
渲染准备函数:
完整处理所有需要构建的中间结构,如:
- 排序索引;
- 裁剪矩形数组;
- 其他需要线性化或缓存的数据。
各渲染后端(如 OpenGL):
- 使用渲染准备结构;
- 不再执行排序或分配等准备逻辑;
- 只关注执行。
优化后的结构和思路优势
- 移除冗余和重复代码;
- 提高模块独立性和复用性;
- 避免错误分配或忘记释放;
- 更易维护和调试;
- 符合高内聚低耦合的设计模式。
整体来说,这种优化重构不仅提升了代码可读性和可维护性,还让平台层更加干净,渲染准备流程更加集中和统一,为后续扩展和平台适配打下良好基础。
强调拥有良好工具函数的重要性
我们对渲染准备流程进行了一次彻底清理和模块划分,目标是让内存管理和职责分离变得更加明确和简洁。以下是本次工作内容的详细总结:
利用内存Arena构建更健壮的实用工具逻辑
我们强调了**内存Arena(Memory Arena)**这类实用工具功能的关键作用。虽然不是“类”概念,但这种工具提供了类似“标准操作集合”的功能,使代码更易读、更健壮、出错更少。这类通用内存管理模块的优势包括:
- 避免临时手动计算内存需求;
- 不需要到处传递“需要多少内存”之类的信息;
- 所有资源的分配归于统一系统管理,便于生命周期控制;
- 提升可维护性与代码清晰度。
渲染准备模块中集成裁剪与排序的内存管理
渲染准备阶段(PrepareRender)现在完全包办了线性化裁剪矩形(ClipRects)和排序索引(Sorted Indices)的内存申请与构建:
- 在函数内部完成所需的临时内存计算;
- 使用Arena分配内存,不再通过平台层暴露裁剪数量;
- 渲染准备结构中包含线性化后的数据;
- 平台层只需传递Arena和渲染命令,完全不需关心细节。
例如,之前通过平台层调用如下代码:
GetTempSortMemoryAndClipMemory(...)
现在可以改为:
clip_rects = LinearizeClipRects(commands, arena);
这大大简化了调用逻辑。
对平台层的影响
通过将所有与渲染有关的内存分配逻辑移出平台层:
- 平台层无需了解裁剪矩形数量、排序数据格式等信息;
- 移植平台层变得更简单,开发者无需了解渲染细节;
- 代码分层更清晰,降低系统耦合度;
- 由于平台层极有可能在多个系统间反复重写(如 Windows、Linux、Mac、Raspberry Pi等),这种去耦合设计非常关键。
我们明确了一个设计理念:
平台层应当尽可能“贫血”,不参与渲染核心逻辑。
渲染准备结构组织
渲染准备结构 RenderPrep
包含以下数据:
- SortedIndices:已按绘制顺序排列的索引;
- LinearizedClipRects:从命令中提取并压缩后的裁剪矩形集合;
- 所有数据均由传入的
MemoryArena
统一管理。
这一设计允许其他系统完全不关心底层如何生成这些数据,只需使用即可。
内存线性化优化
针对线性化部分:
- 使用
PushArray
等工具函数,确保类型安全; - 自动根据
first_rect
和last_rect
计算大小; - 避免平台层误用或漏掉内存分配。
最终生成类似:
RenderEntryClipRect *clip_rects = PushArray(arena, count, RenderEntryClipRect);
排序逻辑进一步简化
排序也不再依赖外部传入缓冲区或自定义排序函数:
- 如果排序方式简单(如合并排序或拓扑遍历),可以放入共享模块;
- 渲染模块只需给出排序结果,不暴露排序实现;
- 后期我们还可以统一一个
SortEntries(RenderCommands, Arena)
方法,对外透明。
清理过时的临时逻辑
我们彻底移除了旧的、分散的内存管理代码,包括:
- 手动传递排序内存、裁剪内存大小;
- 各平台分配
TempSortMemory
等临时逻辑; - 大量的前向声明、类型转换代码;
- 杂乱的结构体字段,如过多与 SpriteBounds 相关字段。
最终效果
- 模块更清晰:职责分工明确,渲染逻辑归渲染,平台逻辑归平台;
- 接口更简洁:调用更自然,参数更少;
- 易于维护和替换:适配不同平台或重构某部分都不需要了解整个系统;
- 系统更安全:由统一的内存管理系统控制所有临时资源,避免内存泄露或混乱使用。
通过这次优化,渲染准备流程从繁杂分散转变为集中高效,真正做到高内聚、低耦合,使代码更加健壮、易维护并具备良好的可移植性。
新建 game_render.h 并将 sprite 相关结构体放入其中
我们对渲染系统进行了结构性优化和清理,主要集中在内存管理、头文件组织、代码职责划分、初始化流程规范化等方面,以下是详细总结:
重构渲染模块的头文件与依赖关系
- 将
handmade_render.h
独立为正式头文件并开始在渲染模块中引入; - 在
RenderGroup
初始化逻辑中,加入#include "handmade_render.h"
,确保所有渲染相关定义集中统一; - 清理掉冗余的或已废弃的头文件引用,简化编译依赖。
清理冗余与错误的代码引用
- 删除了已经不存在的文件引用、无效的变量和宏;
- 比如之前误保存的无效内容或临时代码块被彻底移除;
- 明确了
RenderCommands
中哪些数据是长期保留的(如ClipRectCount
),哪些应该是临时数据。
明确渲染准备结构(RenderPrep)传递逻辑
- 渲染器需要准备渲染前的数据(如裁剪矩形、排序索引等),这些数据已经在
RenderPrep
结构中完成准备; - 在调用软件渲染函数
SoftwareRenderCommands
时,直接传入RenderPrep*
指针; - 所有必要的输出数据如索引数组、ClipRects 都从
Prep
中读取,而不再依赖外部单独传参; - 所有初始化、排序、ClipRects 线性化等,统一使用内存 Arena 分配。
重建临时内存Arena系统
引入了统一的
MemoryArena
命名为FrameArena
,专门为每帧中所有临时性资源分配服务;删除旧的临时分配逻辑如
SortMemory
和ClipMemory
等,彻底转向PushArray()
类工具分配;示例代码:
MemoryArena FrameArena; void *ArenaBase = PlatformAllocateMemory(64 * 1024 * 1024); // 64MB InitializeArena(&FrameArena, ArenaBase, 64 * 1024 * 1024);
所有渲染前处理(如排序、裁剪)数据均从该 Arena 分配,生命周期与帧同步。
清理和重建排序索引分配逻辑
- 删除硬编码的
SortMemory
管理; - 通过
PushArray(&Arena, count, uint32_t)
直接生成排序所需的索引数组; - 索引数量与渲染命令条目数量一致;
- 所有操作均使用类型安全的 Arena 工具函数,无需手动转换和校验。
简化并标准化初始化与释放流程
- 所有临时资源仅需在一处初始化 Arena,所有子模块共享使用;
- 不再需要每个模块手动指定排序缓冲区、裁剪缓冲区;
- 平台层只需提供一段内存并传入 Arena,具体使用细节由渲染系统内部控制。
完善命名与逻辑表达
- 明确变量命名,例如
result
用于存储排序或裁剪处理后的结果; - 消除了命名歧义,使代码结构更清晰、更容易跟踪调试;
- 所有数据结构组织逻辑更统一,避免了模块间信息泄露。
进入调试准备阶段
- 由于大量重构操作,预期会出现编译错误或功能逻辑缺失;
- 暂时关闭一些调试逻辑(如排序顺序校验),确保系统先能正确运行;
- 下一步将逐步修复编译器提示的问题,并确认所有模块间正确连接。
最终目标和方向
- 渲染逻辑模块化:所有数据准备逻辑集中于渲染模块;
- 平台逻辑简化:只需分配统一的大块内存,完全不涉及渲染细节;
- 内存管理集中:所有临时资源集中使用
FrameArena
管理,生命周期清晰; - 可维护性提升:结构清晰,模块独立,易于移植与调试;
- 接口清晰:平台传 Arena,渲染模块自行决定使用方式,完全对外隐藏内部实现。
通过这一轮重构,渲染准备和内存管理体系得到了极大优化,整个系统从杂乱无章的临时逻辑转变为结构清晰、职责明确、高度可维护的架构,为后续支持多平台渲染和高性能渲染打下了坚实基础。
未定义
运行游戏,命中 OpenGLRenderCommands()
中的断言
当前遇到的问题是关于渲染命令中裁剪矩形(clip rect)索引越界的断言失败,具体表现为“clipped rect index 大于命令数量”,导致读取了无效或垃圾数据。
详细分析:
- 断言失败表明索引超出了合法范围,意味着在访问裁剪矩形时,索引值远超预期,比如出现了 560 这样的非法数字,而有效的渲染类型数量远远少于此(仅有0到4共5种类型);
- 由于索引越界,程序实际上读取的是无效内存或者随机数据,因此产生了错误或断言;
- 调试过程中发现,尽管索引为0且有多个条目存在,理论上不应出错,但可能是条目0处本身没有有效数据,导致访问无效内容;
- 进一步怀疑是在访问渲染条目数据时,未正确初始化或传递数据,造成裁剪矩形索引没有被正确设置,或者访问顺序错误。
可能的根源与方向:
- 渲染命令数组或裁剪矩形数组可能存在内存污染,未正确清理或初始化,导致旧数据或随机值残留;
- 对裁剪矩形索引和渲染命令数量的管理不严谨,没有统一验证机制防止越界访问;
- 需要检查渲染命令准备阶段对裁剪矩形索引的赋值过程,确保索引合法且不会超出命令数组范围;
- 应该添加更多的防御性编程措施,比如对索引范围进行严格校验,防止非法索引流入渲染流程;
- 对渲染条目的有效性进行校验,避免访问空或无效条目导致错误。
结论:
当前遇到的断言错误源于渲染命令中裁剪矩形索引越界,表现为读取非法数据。需要对渲染命令生成和裁剪矩形索引的管理进行全面检查,确保所有索引均在合法范围内,并且渲染条目正确初始化,避免访问无效数据。这是定位和解决问题的关键方向。
game_render.cpp:在 WalkSpriteGraph()
中添加断言,确保 OutIndex - OutIndexArray == InputNodeCount
,并修改 SortEntries()
遍历节点并写入索引
当前的重点是确认在处理图形遍历(graph walk)过程中,是否确实向输出数组写入了有效的数据。具体分析如下:
具体内容总结:
- 之前在图形遍历和相关处理时,怀疑实际并没有写入任何数据到输出索引数组中;
- 为了验证,添加了断言来确保写入的输出数量与输入节点数量相匹配,保证每个节点对应的索引都被正确记录;
- 如果写入数量和节点数量不一致,说明图形遍历过程中未正确生成或输出所有数据,存在遗漏;
- 为了简化问题,暂时绕过复杂的图形排序逻辑,使用一个更简单的循环,直接遍历所有节点,将它们对应的索引直接复制到输出数组;
- 这样做的目的是验证基础的写入机制是否正常,即使没有复杂排序逻辑,也能保证索引正确输出;
- 简单循环的作用是快速排查问题,确保数据生成和写入流程的基本正确性,然后再逐步恢复更复杂的处理逻辑。
结论:
当前通过添加断言和使用简化循环确认,验证图形遍历阶段是否向输出数组写入了正确且完整的数据,确保基础流程的有效性。这是排查图形索引输出异常的关键步骤。
运行游戏,看到一切流程正常,没有崩溃
整体流程现在运行稳定,没有崩溃,这正是想要确认的情况。做了许多改动之后,检查了提交的代码量,感觉合理,没有出现异常增长或者不受控的资源占用问题。因为还没有实现动态内存申请,所以不太可能出现内存无节制增长的情况。总体来看,系统保持了稳定性,没有出现明显的异常或错误。
game_render.cpp:修正 RecursiveFromToBack()
中未正确递增 OutIndex
的问题
现在回过头来看代码,发现一些地方处理得不正确。很快就会发现,我们要么触发断言失败,要么写出了错误的值。事实上,问题确实出在这里,写出的索引并不是我们真正需要的那个。显然,我们想要的索引应该是另一个值。这里变量命名也有些不太合适,比如用“in next”这个名字其实不太准确,但也只能这样用了。总体来说,就是索引的逻辑需要调整,不能用当前这个错误的值。
运行游戏,再次命中 BuildSpriteGraph()
中的断言
总之,在这个情况下,我们断言flags为零,但实际上flags并没有在上游被清理干净。因此,需要确保flags在上游阶段确实被清零,避免后续逻辑出现异常。
game_render.cpp:讲解 RecursiveFromToBack()
的具体工作原理
我们遇到了两个“索引”的概念混淆问题。第一种是普通的索引,比如对sprite(精灵)数组中的元素编号,像第一个是0,第二个是1,依此类推。这是我们用来排序的数组索引。第二种则是偏移量,指的是数据在push buffer(推送缓冲区)中的位置。渲染系统并不能用普通的索引来定位数据,而是需要用这个偏移量来找到实际数据的位置。
之前写代码时,把这个偏移量当成了普通索引来写入,导致渲染时取到的是无效的内存区域,引发了问题。这个问题虽然基本,但因为编程中涉及的概念多且复杂,容易让人混淆。
为了解决这个问题,我们打算把这个“索引”变量改名为“offset”,明确它是指缓冲区中的偏移量,而不是数组的索引。同时,在写入这些条目时,也要注意正确区分和使用索引与偏移量。这样做可以帮助理清代码逻辑,避免后续类似混淆带来的错误。
game_render_group.cpp:在 PushRenderElement_()
中清除 Flags
我们确认需要将flags设置为零,但目前flags并没有被正确地清零。除了flags,我们还发现“first edge with me as front”(这里指的应该是某个标志位或状态变量)也没有被清理,而这个值目前我们还不清楚它具体应该是什么,清理起来比较麻烦。虽然我们不确定是否必须清理它,但从安全和稳定的角度来看,清理应该是必要的。
为了避免潜在问题,在渲染排序阶段遍历数据时,除了断言flags为零,我们还需要检查“first edge with me as front”是否也被清理为零。虽然有些情况下它可能不一定是零,但至少我们可以在关键点做一个断言或者投射(project),确保它的状态符合预期。
总的来说,当前的任务包括确保所有相关的标志和状态变量都被适当清理,避免旧数据残留导致渲染逻辑出现错误,这样能够提升代码的健壮性和稳定性。
运行游戏,确认排序未生效,并思考如何修复
现在的问题是,我们虽然完成了大部分需要做的工作,但排序功能还无法正常工作。即使没有程序上的错误,排序依然不起作用,原因是屏幕区域(screen area)没有被正确设置。
当前的屏幕区域数据是垃圾数据,完全没有初始化或赋值,因此任何关于矩形相交的判断都会失败或者变得随机。排序算法依赖于这些屏幕区域的边界来判断元素间的相对关系,如果边界信息不准确,排序结果自然也会出现错误,导致渲染异常。这是一个非常重要的细节,需要特别注意。
在执行推送渲染元素(push render element)的时候,程序中有几个地方负责生成这些屏幕区域的边界矩形。但是目前这些地方没有正确调用,导致屏幕区域没有被正确赋值。我们必须确保在这些位置正确生成并设置屏幕区域的矩形信息,以便排序算法能够基于准确的边界进行工作。
其中比较简单的是“清屏操作”(clear)和“位图”(bitmap)对应的矩形,因为它们本身就是矩形形状,计算屏幕区域边界较为直接。但是更复杂的地方是大地图(big map)和带有倾斜变换(shearing)等变换的元素,这就需要计算变换后的所有顶点坐标,从而得到准确的屏幕空间包围盒。这个过程相对复杂,需要检查所有顶点并计算出正确的边界矩形。
此外,还有一些细节,比如当前的清屏颜色操作,是否应该在所有排序图里执行,这可能是多余的,也可以后续优化。
总之,接下来重点是实现和完善屏幕区域的计算和赋值,保证排序功能能基于真实有效的屏幕边界正确运行。这个任务相对复杂,需要细致处理顶点变换和边界计算,准备在后续时间里专门解决。
问答环节
是否最终游戏将使用正交投影?
讨论确认最终游戏采用正交投影视角,游戏整体是俯视角度的。提到目前还在处理精灵相关的问题,一旦精灵部分处理好,接下来将专注于游戏的资源包(art pack)。整体期待完成当前工作后能够顺利推进到后续内容。
在预分配缓冲区并计算偏移时,是否容易遇到指针错误?是否经验越多越不成问题?
当计算预分配缓冲区位置和偏移时,指针相关的错误是否难以追踪,或者是否因为经常使用而不成问题?
对此的回答是,虽然编程多年,对指针操作已经习惯了,但有时仍会遇到难以定位的错误。刚开始接触指针时确实比较困难,记忆中那时适应指针操作很不容易。随着经验积累,出现指针相关难以发现的bug变少了,因为经常使用,对指针的理解和操作变得熟练。
即使编程三十多年,有时仍会遇到非常难排查的bug,可能花费几天时间定位。其他资深程序员也有类似经历。那些难找的bug通常不是简单的指针错误,而是源于对程序状态的误解,比如数学计算没直观可视化,或假设了某种情况却实际上是另一种情况,导致错误难以察觉。简单来说,大多数难找的bug源于认知偏差,即认为代码做的是X,但实际执行的是Y。
不论是指针算术、数学运算、字符串拼接还是解析,只要代码复杂且理解有偏差,定位bug都很困难。即使是经验丰富的程序员,寻找自己没预料到的bug也不容易。很多时候,真正的问题在于心智模型出了错,没有做足够的测试来验证假设,导致错误一直没被发现。
针对指针算术,虽然它会导致内存崩溃或数据破坏,但通常能比较快定位,因为崩溃会立即表现出来,可以通过断点监视数据写入找到原因。问题复杂的是那些并非真正指针算术错误,但由于某些链条上的有效操作最终导致错误的情况,这种错误排查就更费时费力。
另外,编程复杂度不仅仅是指针操作的专利,像JavaScript这种不直接使用指针的语言,也能写出极其复杂难懂且难调试的代码。指针并不比其他编程语言特性更难理解或更难调试,害怕指针多半是心理障碍。
程序中的bug不论是否因指针导致,都是需要解决的问题。崩溃bug虽吓人,但逻辑错误、性能问题或数据错误同样严重,甚至更糟。指针引发的崩溃只是众多问题之一,其他问题往往也有很大影响。
总结来看,指针只是编程中众多复杂因素之一,不必过度恐惧。编程复杂的东西很多,很多非指针特性也同样让人头疼。真正重要的是建立正确的心智模型,细致验证假设,良好设计和测试代码,这样才能有效减少难找的bug。
除了用于调试函数耗时的代码,是否还有用于调试 memory arena 使用量的代码?
目前确实存在用于调试函数执行时间的代码,但还没有用于监控内存使用情况的 Java 代码。虽然这方面的功能还未实现,但确实有必要编写相关工具。调试系统的开发已经耗费了相当多的时间和精力,因此没有覆盖所有潜在的调试需求,比如内存监测这一块。尽管如此,将来还是应该补充这个功能,因为它非常有用。只是现在为止,还没有写相关的实现。
目前调试部分的工作重心主要放在执行时间和功能正确性上,因此没有深入实现所有可能的调试工具。虽然投入了大量时间进行调试系统的开发,但还是有一些可以补充的内容,比如内存监控代码,这也是之后值得添加的一个功能点。当前,如果需要监控内存使用,只能依赖其他外部工具或者系统级监视手段,而不是项目内部自带的功能模块。
回忆 Rainbow 100 和 Turbo Pascal 的使用经历
我们最早使用的是一台 DEC Rainbow 计算机,那是一台非常有趣的电脑,当时用的是某种 BASIC 语言进行编程。虽然不确定是哪种 BASIC,但由于那台电脑是从家里带回来的,很有可能是出厂预装的,因此很可能是 Microsoft BASIC。那是我们最开始学习编程时所用的语言,在那台机器上也没有学过其他编程语言。
在学习完 BASIC 之后,我们转而开始学习 Pascal,但那已经是在另一台计算机上了。当时家里之所以有这些电脑,是因为父亲的工作单位配有电脑,可以带回家使用。他在 Digital Equipment Corporation(DEC)工作,我们使用的电脑就是从那里带回来的。DEC Rainbow 最终被归还或换掉了,之后使用的应该是另一种机型,例如 VAXstation 或者某种基于 VAX 架构的机器。
之后使用的计算机可能是 VAXstation 26 系列之类的设备,记得处理器是 VAX-11/26,也记得大概是配了 8MB 内存。这台机器上我们开始学习 Pascal 编程语言。不过,具体使用的是哪一个版本的 Pascal 已经不太记得了。虽然最常见的 Pascal 编译器是 Turbo Pascal,但我们印象中使用的版本似乎并不是 Turbo Pascal,可能是另一个 Pascal 变种。
当时最大的遗憾之一就是没有汇编语言的参考资料,也不知道如何编写汇编程序。我们从未拥有过相关的文档,也不知道如何操作硬件。因此在那段时间只能使用 Pascal 编程,而 Pascal 在这些电脑上很难做出高性能的图形程序。即便机器性能还不错,比如 VAX-11/26 搭配汇编语言是完全可以实现很多图形操作的,但我们当时缺乏这方面的知识,导致只能做一些效率低、效果不理想的小程序。
后来回顾起来,我们一直很遗憾在那个阶段没能掌握汇编语言,也没有接触相关资源,否则在那些设备上完全可以做出更复杂和高效的程序。
最后也试图回忆当时使用的 Turbo Pascal 是什么样子,记忆中和后来熟知的彩色版本界面不太一样,可能因为使用的是单色显示器。最终在看到单色版本的 Turbo Pascal 截图时,才觉得更加熟悉和接近当时的实际使用体验。回忆至此,也就准备结束当天的使用。
是否编程过 Amiga 系统?
我们后来确实在 Amiga 上进行过编程,不过那是很久之后的事情了,应该是在 1987 年或 1988 年才开始接触 Amiga。当时并不是一开始就使用 Amiga 进行编程,而是在接触其他计算机系统之后才转向它。
虽然具体是哪一年开始已经记不太清楚,但当时 Amiga 的出现确实引起了我们的关注。Amiga 在图形和声音处理方面具有相当强的能力,吸引了不少热衷于图形编程和多媒体开发的使用者。正是这种强大的硬件能力,使得我们后来也开始在这个平台上尝试进行一些开发工作。虽然最初主要是在其他平台上使用 Pascal 和 BASIC,但到了接触 Amiga 的时候,可能就更多开始涉及底层或多媒体相关的内容了。