仓库:https://gitee.com/mrxiao_com/2d_game_5
回顾上次内容并介绍今天的主题
上次留下的是一个非常简单的任务,至少第一步是非常简单的。我们需要在渲染器中加入排序功能,这样我们的精灵(sprites)才能以正确的顺序显示。为此我们已经完成了一些前期准备,现在只需要实现排序功能。
我们打算首先实现一个非常简单的排序算法,这应该会比较容易。但还有一件事需要处理:我们需要决定,并可能稍作调整,如何处理当前的数据结构,以支持改变渲染顺序。
总之,我们已经准备好了排序机制的基本架构,接下来就是正式编写排序逻辑,并在必要时对数据结构进行调整,以便支持正确的渲染顺序。
先修修复一下clangd 显示的错误,虽然编译能过不过看着显示的错误有点烦 (可以不做)
修复所有的头文件
用
#ifndef GAME_RENDER_GROUP_H
#define GAME_RENDER_GROUP_H
#endif
包含
去掉之前include cpp 文件只引用头文件
每个cpp 单独编译成对应的obj 文件
修复一下没有显示debug 字幕的情况
修复一下递归引用头文件
分成多个obj编译之后clangd 不会出现错误了
貌似还有错误
继续修复错误
回到正题
运行游戏并触发断言错误
我们当前处理的是一段和渲染输出有关的逻辑,目的是为了帮助那些可能不太清楚流程,或者已经忘记细节的人。
现在遇到的情况比较奇怪,一开始并没有印象中包含这一块内容。很有可能是由于某种原因导致我们之前设定的内存大小超出了预期。具体来说,这是在渲染组中生成临时内存的部分,我们在这里为渲染输出创建了临时用的缓冲区。
有可能是我们在某些处理上做得太多,比如添加了过多的渲染项,导致分配的临时内存不足。但我们之前处理相关逻辑的时候,并没有遇到过需要更大空间的情况,所以这一点非常值得注意。
我们本来想演示另一块内容,但因为一开始就做了某些修改,结果现在触发了这个问题。也就是说,当我们处理地面块(ground chunks)时,这个问题就会被触发。
不过我们可以先临时关闭地面块的处理,然后继续演示原本要展示的内容。反正后面我们也需要回过头来讨论这部分的内存分配问题。现在先暂停一下,把这个处理好。
在 game_world_mode.cpp
中关闭 FillGroundChunk
我们可以先把地面块的处理逻辑关闭,这样它们就不会因为内存需求过大而引发问题。当前的状况是,地面块的处理可能会超出分配的内存限制,导致渲染过程中出错。
现在这些地面块的计算似乎已经不在主游戏逻辑中了,之前我们已经把它们移动到了 game_worlds
模块中,所以这一步的处理应该不会影响其他功能。
在当前的地面块相关代码中,有一部分是关于 FillGroundChunk
的逻辑。只要暂时将这部分处理逻辑移除或者注释掉,地面块就不会再进行实际的计算操作。这样,我们就可以暂时忽略它们的行为,避免它们对内存造成压力,从而顺利继续后续的开发和调试。
接下来就可以继续进行其他部分的运行和验证了,不需要再担心地面块在后台占用过多资源或触发异常。这样既能保证整体流程正常进行,又为后续专门处理地面块相关内存问题预留了空间。
运行游戏,注意当前没有进行 sprite 排序
当前游戏运行时,并没有等待垂直同步(Vertical Retrace),但由于渲染器速度非常快——尽管是软件渲染器——因此整体运行速度远高于预期。这主要是因为当前场景中只绘制了少量的精灵(sprites),所以渲染压力极小。
在这个基础上,当我们在世界中移动角色时,可以明显看到精灵并没有经过任何排序处理。也就是说,所有的精灵都是按照它们在当前区域中出现的顺序直接绘制出来的,这个顺序是非确定性的,因为模拟器并不关心顺序,导致绘制结果很随机。
例如,角色有时候会被错误地绘制在树的后面,尽管他应该出现在前面;而当角色移动时,甚至可能因为顺序变化导致绘制层级突然发生跳变。这种状态在实际游戏完成后是完全无法接受的,因为我们需要在各种复杂的场景下确保绘制顺序是合理的。
哪怕只是一个简单的房间,并围绕它放置一些树,如果不进行排序,画面表现依然是错误的。因此,我们必须实现某种机制来对精灵进行排序,确保在绘制时,能正确决定谁应该覆盖谁。
例如,如果角色处于树后面,那么树就必须绘制在角色上方;反之如果角色站在树前面,那么角色就应该被绘制在最上层。只有这样才能保证视觉上的正确性和逻辑一致性。
当前的目标就是解决这个排序问题,以确保渲染结果和空间关系相匹配。接下来还会进一步分析具体遇到的问题。
在 game_world_mode.cpp
中重新打开 FillGroundChunk
,发现我们需要为排序留出空间
我们把之前关闭的逻辑重新打开后,观察到了之前刚刚实现的一段功能:我们需要一块额外的内存空间用于精灵排序,这就是当时的主要目的。
问题出现在这里:当我们启用地面块(ground chunk)的填充逻辑后,渲染过程中会因为内存不足而出错。具体来说,是在尝试渲染时,程序向任务内存区域请求新的内存块,但此时内存池已经被耗尽,无法再分配出更多空间。
从表现来看,应该是在分配渲染组(render_group)时请求的内存超出了我们预设的容量。为了搞清楚具体发生了什么,我们需要查看内存分配的逻辑。推测是在调用 AllocateRenderGroup
的过程中,内存请求超出了 arena 的上限。
接下来要做的是深入查看相关实现,分析 AllocateRenderGroup
具体是如何管理内存的,以及到底是哪些部分在大量分配空间,是否是排序逻辑引起了额外开销,或者是地面块处理逻辑与渲染排序逻辑之间的资源竞争,造成了内存资源的枯竭。
这将有助于我们更精准地调整内存分配策略,或者限制某些功能的使用,避免在运行时出现类似崩溃或内存分配失败的问题。
在调试器中进入 AllocateRenderGroup
函数
在执行 AllocateRenderGroup
时,我们需要关注它实际分配了多少内存。从当前观察来看,问题出现在多线程的执行过程中。由于渲染是并发进行的,我们有多个线程同时在申请渲染组内存,尤其是那些负责填充地面块的线程,它们正在同时执行这个内存分配操作。
可以通过线程监控面板看到,当前确实有两个线程正在进行渲染组的分配操作,它们就是用于地面块填充的线程。为了便于分析,我们可以临时冻结其中一个线程,专注观察另一个线程的行为,避免分析时被多个线程的执行干扰。
在进一步检查中发现,每个渲染组的最大缓冲区大小被设置为 1MB。而用于这些工作的任务内存池(task arena)本身的大小也仅为 1MB。这意味着,只要分配一个渲染组的内存,就会用光整个内存池,导致无法再进行任何额外分配。
问题的本质在于:当前为辅助任务(例如地面块填充)配置的任务内存区域过小,无法满足实际所需内存量。这是一个需要手动调整的参数,必须根据这些任务的真实需求来设定内存池大小,确保每个线程在运行期间有足够空间完成其工作。
如果继续沿用当前的内存配置方案,将导致频繁的内存分配失败,从而引发程序错误或性能问题。因此,需要根据任务复杂度对内存使用策略进行调优,例如增大每个 task arena 的大小,或限制并发任务数,以保证系统在资源允许的范围内稳定运行。
把填充地面块代码打开
在没有进入到游戏在场景画面是一个线程分配内存一直是4194304
进入游戏之后
在 game.cpp
中增加 TranState
分配的内存量
在初始化 transient 状态时,可以看到每个任务线程被分配了固定大小的内存区域。目前的设定中,每个线程的内存大小为 1MB,但这显然不足以同时满足排序操作和其它渲染需求所需的空间。
解决思路是将每个线程的任务内存池(task arena)大小提升到 2MB。这样一来,同一个线程在执行过程中就有足够的空间进行中间排序缓冲区的分配,同时也可以执行其他所需的内存操作,避免之前由于内存不足而导致的分配失败或渲染异常的问题。
这个改动属于配置层面的优化,通过适当增加每个线程的内存分配,可以提高系统稳定性,避免在高负载或并发渲染时出现资源冲突。调整的关键点在于根据实际任务的复杂程度和内存使用情况来评估所需的内存上限,从而为每个线程预留出足够的缓冲空间,使系统在运行中更加高效且不易出错。
两兆还是段错误
在调试器中进入 BeginRender
,查看 Work->Task->Arena
的状态
在当前的调试过程中,我们尝试验证任务线程在执行时是否有足够的内存空间可供使用。假设分配逻辑正确,那么在某些流程中应该还保留有大约 1MB 的可用空间。然而,当实际进入相关代码路径时,观察到任务线程的内存区域(task arena)已经被完全占用。
这暴露出一个核心问题:不是分配内存的逻辑有误,而是每次进行分配操作时,程序会尽可能地占用所有剩余的内存空间。这就导致了即使预期中应该有富余的空间,也会因为这种“贪婪”式的分配方式而出现空间耗尽的情况。
根本原因在于:当前内存分配策略不会限制实际使用上限,而是直接占满可用内存。这意味着只要存在临时排序空间或渲染中间缓冲区的请求,它们就会无视后续需要的空间,优先填满整个区域,从而导致后续分配失败。
因此,问题并不完全在于初始分配的总量,而是在于没有对不同任务间的内存使用进行有效隔离或限制。要解决这个问题,就需要在内存分配器中加入更细粒度的控制策略,确保不同用途之间能够留出合理边界,避免彼此侵占,从而提高稳定性与资源利用效率。
在 game_world_mode.cpp
中设置 RenderGroup
使用 512KB 内存
我们目前在处理的是排序前的内存管理问题。在之前的实现中,给渲染线程分配内存时,如果不显式指定大小,而是传入 0
,那么系统会默认使用任务线程可用内存区域(arena)中所有剩余的空间。这种方式存在一个明显的问题:它会占用整个剩余空间,导致之后的任务再请求内存时失败,即使之前我们预留了足够的空间也无法生效。
为了解决这个问题,我们意识到可以不传 0
,而是手动设置一个更合理的上限值,例如只使用总内存的一半。通过这种方式,可以有效防止单个模块“吞噬”全部剩余资源,从而给后续需要排序的逻辑预留空间。
目前,我们已经恢复到一个相对“正常”的状态,任务线程不再因内存溢出而出错。但排序逻辑本身还没有真正实现,这也是接下来要完成的部分。我们已经为排序留出一段预设空间(sort_space
),并定义了用于排序的数据结构 tile_sort_entry
,这个结构里包括一个 sort_key
(排序依据)和一个 PushBufferOffset
(渲染指令在缓冲区中的位置)。
接下来的目标是实现实际的排序逻辑。为了保证开发过程高效,我们选择“从最简单的事情做起”。也就是说,先写出一个最基础、能跑起来的排序系统,之后再逐步优化。这样可以更清楚地知道哪些地方是瓶颈,哪些需要改进。
为了更好地讲解接下来的排序过程,我们还计划在黑板上做一个示意图说明整个排序原理。由于当前项目中的数据量较大,加载黑板需要一些时间。加载完成后,我们会通过图示的方式详细说明排序操作如何处理渲染对象在同一个瓦片中的深度关系,确保远处的物体被正确地绘制在近处物体的后面,解决当前绘制顺序混乱的问题。
黑板讲解:渲染排序
我们现在要处理的是渲染排序的问题,情况非常基础但关键:我们有多个图像元素,比如 A 和 B,而已知 A 应该显示在 B 的前面。为了实现正确的视觉效果,我们需要确保渲染时这些元素按照特定顺序绘制。
如果采用前向渲染(front-to-back),我们应该先绘制靠前的 A,再绘制 B。这样,A 的内容会被优先显示,B 被遮挡的部分不会浪费绘制时间。而我们当前使用的是**后向渲染(back-to-front)**的方式,即先绘制 B,然后将 A 绘制在上面,从而通过 A 的像素“遮住” B 实现前后层次。
我们要达成的目标是:无论初始绘制顺序如何,比如现在是 A、C、B,我们都要确保最终绘制顺序是从后到前,即 C、B、A。这样才能正确地渲染出空间关系,物体之间不会“穿帮”。
目前的基础数据是一个 push buffer,它是一个记录了所有绘制指令的数组,这个数组内的每项都包含了渲染所需的数据,比如位置、图像指针等。这些数据结构体体积可能非常大,可能达到 64 字节甚至 128 字节。
一个直接的想法是对这个 push buffer 本身进行排序,即重新排列其中的数据块,使其按照从后到前的顺序排列。但是这就涉及大量的数据复制,特别是在排序过程中可能需要多次移动,代价很高。尤其是 push buffer 体积大、数据密集时,这种做法效率很低。
因此我们倾向于采用间接排序的方式:我们不直接修改 push buffer 的内容,而是额外维护一个“索引列表”,每个索引指向 push buffer 中的一项。这个索引结构比较小,只需要记录排序用的键(比如深度)以及该元素在 push buffer 中的偏移地址即可。我们先对这个索引列表进行排序,然后按照这个新顺序去读取 push buffer,依次绘制,从而达到理想的绘制效果,避免对大块数据做复制。
总之,我们的目标就是:
- 从原始绘制指令中抽取排序信息,形成简洁的排序结构;
- 对这些排序信息进行排序,得到正确的绘制顺序;
- 按照排序结果执行绘制操作,而不是直接修改原始指令数据。
这种方式既保证了排序的正确性,又兼顾了性能和内存效率,是处理实时渲染排序问题的常见方法。接下来我们会进入具体实现阶段。
黑板讲解:排序缓冲区
我们要进行排序,而很多排序的基本策略是:不直接对原始数据本体进行移动,而是构造一个专门用于排序的辅助缓冲区(sort buffer),该缓冲区只包含执行排序所需的最小信息。通常,这些信息包含两个部分:
- 排序键(key):用于决定该条数据在排序中的位置;
- 索引(index):用于在排序完成后能回到原始数据中,准确找到对应项。
在当前的实现中,这个索引是 push buffer 中的偏移量,即一个整数值,可以让我们定位到原始渲染指令的位置。排序键则是一个我们选定的、能代表渲染层级的值,比如一个整数类型的 sort key(目前使用的是 32 位的有符号整数 int32_t
,也就是 r32
类型),这个值根据物体的深度、层级或其他优先级策略设定。
因此,每条排序项结构包含两个字段:
sort_key
:用于比较决定排序顺序;PushBufferOffset
:指向原始 push buffer 的偏移地址。
这种方式带来的好处有几个:
- 每个排序项结构体只占用 64 位(8 字节),效率高、内存开销小;
- 可以使用高效的排序算法(如快速排序、归并排序、计数排序等)对这些结构体进行排序;
- 排序完成后不需要调整原始 push buffer 内容,只需要遍历排序后的结构数组,然后按顺序去 push buffer 中读取数据即可;
- 避免了大数据结构在排序过程中的复制,提升运行效率。
我们接下来的工作可以分成两个阶段:
- 构造排序结构数组:遍历所有 push buffer 中的渲染指令,为每一条构造一个包含排序键和偏移量的结构,放入 sort buffer 中。
- 执行排序逻辑:对 sort buffer 按照 sort_key 进行排序。排序完成后就可以用 offset 去 push buffer 中读取并执行渲染,按正确的视觉层级绘制出来。
这就是我们的排序策略,清晰高效且具有可扩展性。接下来将进入具体实现阶段,把这套思路落实到渲染流程中。
黑板讲解:生成排序键
我们现在要解决的第一个任务是:如何构造一个32位的排序键(sort key),用于决定渲染顺序。看似简单,比如如果每个对象只有一个Z值(深度值),我们可以直接用Z值排序。但实际情况更复杂,因为我们使用的是所谓“2.5D”的渲染方式,这种方式本身就是一种近似模拟三维视觉效果的手段,而这种近似就带来了各种排序上的问题。
存在的问题:
我们在渲染中面临两个关键的排序维度:
- Z值(Z-depth):比如角色站在楼梯上方或下方,确实存在“层级”上的区别,Z值可以很好地表达。
- Y值(屏幕位置):但在伪3D(例如等距视角)中,即使Z值一样,Y值不同也需要不同的排序。举个例子:
- 玩家和一棵树都在同一Z平面上;
- 树的Y值比玩家靠近屏幕底部,看起来更“靠前”,应该遮住玩家;
- 所以我们需要根据Y值来补充排序。
这意味着:Z值和Y值都要考虑,并组合成一个综合排序依据。
我们采取的策略是:
将Z值与Y值组合成一个32位整数排序键,方式如下:
- 高位部分:Z值(用于控制“层级”先后,比如地面、二楼、地下室等等);
- 低位部分:Y值(用于控制同一层级中,谁在更前面);
- 具体方法是:
假设Z值的范围是 -32 到 +32,我们可以将Z值乘以一个足够大的数,比如1024(让Z值变成高位);
然后将Y值作为低位直接加进去,得到一个 32 位的整数作为排序键。
即:
sort_key = Z * 1024 + Y
这样排序时:
- 先比较Z值(高位),确保不同“层”的物体按正确顺序排列;
- 再比较Y值(低位),确保同一层的物体根据在屏幕上的远近排序(Y值越大越靠近屏幕底部,看起来更“靠前”)。
结果与意义:
这种做法虽不是完全准确(因为不是全3D),但在2.5D世界里,它已能在绝大多数情况下提供合理的渲染顺序。例如:
- 玩家走到一棵大树后面时,树能正确遮挡玩家;
- 楼梯上下两层的物体能保持清晰的前后关系;
- 渲染顺序的一致性和视觉连贯性大大提高。
当然,如果出现像飞行、穿越结构等复杂情况,这种基于排序键的简化模型可能不够准确。但考虑到2.5D游戏的资源限制和渲染性能,这种方法已经足够实用,能够大幅提升渲染的视觉正确性。
接下来的任务就是:基于这个策略实际构造排序键并将其集成到渲染系统中,真正实现我们前面设计的排序流程。
目前我们已经明确了渲染排序的两大任务:
第一任务:生成排序键(Sort Key)
我们已经确定,排序键将由两个部分组成:
- 高位是 Z 值,代表物体所处的“层级”;
- 低位是 Y 值,代表屏幕上的“靠近程度”;
组合方法就是将 Z 乘上一个较大的常数(例如 1024)后,加上 Y 值,这样可以用一个 32 位整数表示一个物体的渲染优先级。排序时,只需要根据这个整数从小到大排序即可,越小代表越“靠后”越早绘制,越大代表越“靠前”越晚绘制,从而实现从后往前的渲染模式(即 back-to-front)。
这个策略既保留了层级关系(Z),又兼顾了伪三维中屏幕垂直方向的视觉遮挡关系(Y),非常适合 2.5D 场景下的绘制需求。
第二任务:进行排序
完成排序键生成后,我们需要对所有待绘制对象执行一次排序。
这里的策略也已经定好:
- 我们不会直接去移动绘制数据(push buffer 中的数据体积较大,直接移动代价高);
- 我们会先创建一个额外的结构(sort buffer),每个元素占用 64 位,分别是:
- 32 位的排序键;
- 32 位的偏移值(用于回到原始 push buffer 中对应的数据);
- 排序操作将仅针对 sort buffer 中的条目进行,代价低且操作快速;
- 排完后,根据排序后的偏移值再去取对应的数据进行绘制。
当前目标:
现在我们有大约半个小时的时间,计划是:
- 先完成第一部分任务:生成每个物体的排序键,先使用一个近似方法进行填充,用于测试。
- 后续再完成排序部分,实现一个基础版本的排序逻辑。
- 展示整个过程是如何工作的。
- 后面可以根据效果再逐步优化,比如考虑更复杂的遮挡情况、更细致的 Z/Y 权重调整等。
总结:
当前阶段的核心是:
- 搭建起一个排序体系,哪怕初步版本并不完美;
- 保证能通过组合 Z/Y 值的排序键来大致还原正确的绘制顺序;
- 通过使用额外的 sort buffer,避免在排序阶段频繁搬移大量渲染数据,提高效率;
- 最终目标是让渲染结果在视觉上更合理、更具层次感,符合玩家预期的空间遮挡关系。
现在正式开始动手实现第一步:为每个绘制元素分配排序键,并准备好数据结构。
在 game_render_group.cpp
中讨论排序策略
我们当前的目标是对渲染进行排序,以便按照正确的遮挡顺序进行绘制。现在进入了真正将排序逻辑整合到渲染流程中的阶段。
当前情况
我们有一个函数负责把渲染组(render_group)转换为最终输出,这个函数叫 RenderGroupToOutput
。实际上项目中有两个同名函数,虽然这有些混乱但我们暂时就这么处理。
当前的实现方式是:
我们遍历了 push buffer 中的所有渲染命令,按它们加入的顺序直接进行渲染。
这意味着它们在内存中是线性的,执行时也具有良好的缓存局部性,效率高。
我们要做的改动
我们的目标是:不再按 push buffer 的原始顺序绘制,而是先生成排序数组(Sort Array),再按排序后的顺序渲染。
也就是说,我们要做的事情包括:
建立 Sort Array(排序数组):
- 不再直接绘制,而是将排序用的信息(排序键 + 原始命令偏移地址)记录下来;
- 这个结构中每个元素 64 位:前 32 位是排序键(Sort Key),后 32 位是对应 push buffer 中数据的偏移量(Offset);
对 Sort Array 进行排序:
- 使用排序键对数组从小到大排序;
- 越小的排序键表示越“靠后”,应该越早绘制,实现 Back-to-Front 渲染;
按排序结果执行绘制:
- 遍历排序后的排序数组;
- 每次取出对应 offset,从 push buffer 中读取原始渲染数据并执行绘制命令;
关于性能问题
我们目前这样处理是有一些代价的:
- 原先 push buffer 是线性内存访问,现在是随机访问;
- 因为排序后的顺序不保证数据的物理连续性,所以内存缓存命中率会下降;
- 这会导致所谓的 scatter-gather(分散读取)问题;
但是,这个代价是必要的,因为:
- 在 2.5D 的世界中,遮挡顺序非常重要,错误的绘制顺序会破坏整个视觉效果;
- 除非我们能在游戏逻辑层面预先输出排序好的渲染命令,但那样会极大复杂化逻辑,代价更高;
因此我们选择保留这种“先推送命令、再排序执行”的机制,虽然可能会牺牲一部分缓存效率,但可以确保正确性,并保持结构清晰。
接下来要做的工作
现在我们要进入具体实现阶段:
- 在
RenderGroupToOutput
函数中,不再直接绘制; - 而是遍历所有命令,为每一个构造一个排序键+偏移对,并写入排序数组;
- 对排序数组进行排序;
- 最后再遍历排序数组,并根据 offset 访问 push buffer 中的命令并执行绘制。
小结
- 我们从“按推入顺序绘制”转换为“先排序再绘制”;
- 引入了 Sort Buffer 存储排序信息;
- 排序键是 Y 和 Z 的混合值,用来决定物体应该出现的前后关系;
- 牺牲了一些性能,但提升了视觉准确性;
- 整体框架已经搭建,接下来是逐步完善这条完整的渲染路径。
这一步的完成意味着我们已接近一个基础版“支持遮挡排序”的渲染器原型。
在 RenderGroupToOutput
中找到每个 Entry 的 PushBufferOffset
,修改函数以接收 SortEntryCount
和 *SortEntries
我们现在要做的是,从串行顺序的渲染循环转变为基于排序数组的跳跃式渲染流程。具体的改变和细节如下:
原始逻辑回顾
之前的渲染流程很简单:
我们直接从 push buffer 的基础地址开始,依次遍历每个渲染命令,逐个处理。这种方式内存访问是线性的,性能较好。
修改后的渲染逻辑
现在我们有了一个排序数组(Sort Entries),每个元素记录了:
- 一个排序键(决定渲染顺序)
- 一个 push buffer 中的偏移量(指出原始数据位置)
我们现在要:
用排序后的顺序来进行渲染:
- 遍历排序数组;
- 每次取出其中的 offset;
- 直接跳转到 push buffer 的对应位置,读取该渲染命令并执行;
删除 base address 的追踪逻辑:
- 原来循环内部是根据 base address 累加来推进;
- 现在是每条记录都有自己的 offset,所以不再需要在循环中追踪和更新 base address,这一部分逻辑可以删除,变得更简洁。
接口调整
因为排序是多线程进行的,每个 tile 会单独进行处理,因此我们需要为每个 tile 分别传递:
- 该 tile 的排序数组指针;
- 该 tile 的排序数组长度;
这意味着 RenderGroupToOutput
函数需要新增两个参数:
tile_sort_entry_count
tile_sort_entries
这样每次调用渲染输出时都能知道当前 tile 该使用哪部分排序数据。
存在的问题
虽然渲染函数逻辑已经修改得较为清晰,但在实际调用 RenderGroupToOutput
时还有一部分信息我们暂时缺失:
- 我们当前有完整的排序数组
entry_list
; - 但是我们还没有建立「每个 tile 应该使用排序数组中的哪一段」的映射关系;
这意味着我们暂时还无法将排序数据精确地分配给各个 tile。
后续处理规划
为了不把这个过程写得太复杂,我们计划把这个步骤拆成两个阶段:
- 当前阶段完成渲染循环逻辑的重写,使其基于排序数组而不是 base address;
- 后续阶段再来处理如何为每个 tile 准备对应的排序数据并传入。
小结
- 渲染循环现在改为基于排序数组跳转式访问;
- 渲染效率可能略低,但能正确处理遮挡关系;
- 接口需要调整以传入每个 tile 的排序数据;
- 目前还需要处理排序数据和 tile 之间的映射问题;
- 后续将分阶段处理,保证代码清晰易维护。
这个重构是渲染架构向更灵活、更正确的方向迈出的关键一步。
黑板讲解:当前屏幕渲染流程
当前我们屏幕的渲染方式是基于区域(chunk)划分的。具体实现逻辑如下:
当前的渲染逻辑概述
我们将屏幕划分为若干个小块(chunk),然后使用多线程的方式分别渲染这些块:
- 比如,线程1渲染第一块;
- 线程2渲染第二块;
- 线程3渲染第三块;
- 线程4渲染第四块;
- ……以此类推。
这样可以加速渲染过程,因为每个线程可以并行处理不同区域的图像数据,从而提高整体渲染效率。
接下来的目标:将排序也并行化
接下来我们希望将排序操作也变为多线程的,逻辑如下:
- 每个 chunk 对应一个独立的排序列表;
- 每个线程在处理自己区域内的渲染数据时,同时对该区域内的数据进行排序;
- 这样可以避免所有线程共享一个全局排序列表,避免竞争和资源冲突,也能更好地利用多核资源;
实现这一点的前提
要让每个 chunk 自己完成排序,我们需要做到:
- 渲染命令在进入排序系统之前,根据其位置被裁剪(clipping)进对应的 chunk;
- 即在收集渲染命令的时候,就判断这个命令应该被送入哪个 chunk 的排序队列;
- 最终每个 chunk 会拥有自己的 push buffer 数据 + 对应的 sort entries 列表;
当前暂不处理按块裁剪的细节
目前我们暂时不处理将渲染命令按 chunk 分发的问题,也就是说:
- 现在所有渲染命令都会进入一个统一的排序列表;
- 所有排序和渲染操作都仍然基于这个全局的排序列表执行;
- 这样虽然还不能实现真正的多线程排序,但能先完成基本的排序系统搭建;
等到排序机制完整运行之后,我们再进一步细分,使其支持区域划分、并行排序和多线程裁剪。
小结
- 当前使用多线程并行渲染多个区域(chunk);
- 未来目标是让排序也支持多线程处理;
- 为此,需要将渲染命令按区域裁剪并分发到对应 chunk 的排序队列中;
- 当前暂不做区域裁剪,先统一处理,等机制稳定后再并行优化。
这是从单线程渲染走向高性能并行渲染体系的重要一步,结构也会随着目标不断完善和扩展。
考虑最佳的排序实现方式
我们目前在思考排序操作的最佳实现方式,有几个关键问题需要权衡:
初步决定:先统一排序,再看是否并行优化
我们决定先在主线程里统一进行一次排序,不考虑并行优化,理由如下:
- 可以先观察排序的性能表现;
- 如果排序耗时太长,再考虑多线程处理;
- 这样做的好处是简化初期实现逻辑,不必在一开始就处理多线程相关的复杂结构;
- 后续如需并行处理,也可以再逐步拆分扩展。
结构上的考虑:是否保留额外的 SortSpace
如果选择只进行一次排序,那么可能不需要额外的 SortSpace(额外的排序缓冲区),具体分析如下:
- 当前的 push buffer 并不是固定大小;
- 我们也不知道 push buffer 中每条命令所处的具体偏移;
- 但我们要生成排序所需的 tile sort entry 列表,这个列表最好是在 push 的时候就顺便生成;
- 如果在 push 阶段就同步构造这个排序条目数组,那么在渲染阶段直接拿来用即可,无需额外处理;
- 这种方式不依赖 SortSpace,避免冗余结构,也节省内存管理的麻烦;
更进一步的优化思路
我们甚至可以将排序 entry 的数组放在 push buffer 的尾部,按顺序往下写:
- push buffer 从头部向下写入图形命令;
- 排序 entry 从尾部向上写入 key 和 offset;
- 最终 push 完成时,排序数组也已经构建完成,只需排序然后渲染即可;
- 这样可以避免多次扫描、避免临时数组和空间分配,效率更高;
总结当前思考:
- 目前决定:先统一排序,暂不做多线程并行排序处理;
- 有很大可能会取消 SortSpace,转而采用 push 阶段直接构造排序 entry;
- 如果按上述方式处理,结构将更紧凑、更高效;
- 后续如需支持并行排序,也可以基于该结构进行扩展,灵活性仍然在。
下一步,我们会基于这种更简洁的模型进行实现尝试。这个方向看起来更合理,也更容易调试。
在 game_render_group.h
中为 render_group
添加 u32 SortEntryAt
成员
在 render_group
结构中,我们本来就有一个类似 PushRenderElement
的东西,现在我们计划加入排序支持,思路是这样的:
在 render_group
中直接处理排序
我们希望在 render_group
内部直接生成排序条目(sort entries),而不是单独维护一个 SortSpace
。这种方式的主要优势是结构紧凑、效率更高、逻辑更直观。
所以我们可能会添加一个类似以下的新字段:
SoftEntryAt
这个字段用于记录当前要写入的排序条目的索引或偏移。
工作原理
在每次向 push buffer 添加绘制命令的时候:
- 计算排序 key:我们根据
Z
和Y
值组合成一个 32 位的 key,用于后续排序。 - 记录偏移位置:我们记录当前绘制命令在 push buffer 中的偏移。
- 写入 sort entry:将 key 和偏移(index)打包成一个
sort_entry
,写入 sort entry 数组,并更新SoftEntryAt
指针或索引。
这样,我们在生成 push buffer 的同时,就顺手生成了对应的排序条目,不需要再遍历一次 push buffer 来生成排序信息,节省性能开销。
优势总结
- 节省一次遍历:避免在渲染阶段额外扫描 push buffer;
- 不需要额外分配排序空间:直接和 push buffer 关联;
- 逻辑更集中统一:写入命令的地方就生成对应的排序 entry,数据生命周期和作用域也更清晰;
- 便于未来扩展:如需多线程,只需将 push 和 sort 分别放在线程专属 buffer 中,结构照搬即可拆分并行。
接下来我们就会基于这种结构,完善排序过程和渲染流程的整合。
在 AllocateRenderGroup
中设置 SortEntryAt
,并在 PushRenderElement_
中使用它
我们在初始化 render group
时,会在分配 push buffer
的时候,将排序条目的区域安排在 push buffer
的尾部。具体做法如下:
初始化阶段的布局
当我们调用 allocate_render_group
时:
- 设置
PushBufferBase
; - 计算
PushBufferBase
为:
PushBufferBase + MaxPushBufferSize
,
也就是说排序条目的写入起点就在push buffer
的末尾。
这个做法的好处是我们不需要为排序条目单独申请新的内存区域,直接利用原有分配空间的一部分即可。
写入绘制元素时同步写入排序信息
在执行 PushRenderElement
时(即将元素写入 push buffer
):
计算新的写入位置是否越界
不再仅仅判断(Group->PushBufferSize + Size) < Group->MaxPushBufferSize
,而是根据排序区域的位置进行判断,确保绘制元素不会覆盖到排序区域。
判断方式如下:(Group->PushBufferSize + Size) < (Group->SoftEntryAt - sizeof(tile_sort_entry))
在 push 元素的同时写入排序条目:
即我们把当前的绘制元素信息(例如Z
值、Y
值打包成 sort key,再加上该元素在 push buffer 中的偏移地址)打包成一个sort_entry
,写入到PushBufferBase
指向的位置。递减 PushBufferBase:
因为我们是从尾部往前写入排序信息,每次写完一条就向前移动一格(PushBufferBase -= sizeof(tile_sort_entry)
)。
可视化布局(逻辑结构)
我们内存布局可抽象为:
+----------------------+ ← PushBufferBase
| Push Buffer |
| (render data) |
| |
| |
| |
| |
+----------------------+ ← PushBufferBase 初始值
| Sort Entries ↓ | (向低地址写入)
| [key + offset] |
| [key + offset] |
+----------------------+
优势总结
- 内存利用最大化:不用额外分配排序区域;
- 保证了 push/render 和 sort 信息的一一对应性;
- 写入逻辑集中且效率高:每次 push 时顺手写入排序;
- 简化了后续排序阶段的流程:数据已准备好直接排序使用。
这种方式结构清晰且高度集成,非常适合未来进一步扩展优化或并行处理。接下来只需要设计排序逻辑和修改渲染循环来使用排序好的条目即可。
黑板讲解:从栈顶压入 entry,从栈底压入 sort 数据
我们当前采用了一种类似“栈-堆对顶分配”的结构来组织内存,即在一个连续内存块中,从一端向上推入绘制元素数据(render entries),从另一端向下推入排序条目(sort entries)。
内存布局结构
- 内存区域起始地址:
PushBufferBase
- 内存区域尾部地址:
PushBufferBase + MaxPushBufferSize
- 中间是可用空间
- 结构如下:
+---------------------------+ ← PushBufferBase
| Render Entries ↑ | 每次 push 一个绘制元素,从低地址往高地址推
| |
| ... |
| |
| |
| Sort Entries ↓ | 每次 push 一个排序条目,从高地址往低地址推
+---------------------------+ ← PushBufferBase + max_size
两端对推机制
- 每次添加一个绘制元素时,从低地址开始逐渐向高地址推进;
- 每次添加一个排序条目时,从高地址开始逐渐向低地址推进;
- 排序条目一般是 64 位(8 字节),记录排序 key 和该元素在
push buffer
中的偏移。
空间耗尽的判定
我们判断空间是否耗尽,不再只看是否超过 max_size
,而是判断两个指针是否即将“碰撞”:
if (next_push_buffer_ptr + sizeof(render_entry) > next_sort_entry_ptr - sizeof(sort_entry)) {
// 空间不足,不能再推入新的 render entry 或 sort entry
}
换句话说,只要两个区域尚未相遇(即仍有中间空隙),我们就可以继续推入数据。
总结要点
- 使用一块内存完成两种数据存储;
- 推入顺序分别从头部和尾部进行;
- 排序条目固定大小,内容为关键值 + 对应 render entry 的偏移;
- 空间耗尽的判断逻辑基于两个指针是否交叉;
- 实现非常高效、零内存浪费,无需额外分配;
这种方式结构紧凑、性能优良,非常适合低开销渲染流水线的设计,也为后续的排序和绘制逻辑提供了坚实基础。
在 game_render_group.cpp
中实现上述 push 操作,并使 PushRenderElement_
接收 r32 SortKey
我们现在的实现中,采用的是一个连续内存块的对顶分配策略,也就是说:
- 绘制元素(Render Entries) 从内存块的起始地址向上增长;
- 排序条目(Sort Entries) 从内存块的尾部向下压入;
- 这两部分数据在内存中对顶推进,直到彼此相遇(即没有可用空间)。
新的排序条目添加逻辑
每当我们向渲染组中 push 一个新的渲染元素时,会同时创建一个新的 排序条目,具体步骤如下:
计算剩余空间:
在添加新渲染元素之前,判断当前 push buffer 的位置和 sort entry 的起始地址之间是否还有空间可用。如果剩余空间不足,就不能继续 push。更新 sort entry 指针:
Group->SoftEntryAt -= sizeof(tile_sort_entry);
将 sort entry 指针向下移动,为新条目腾出空间。
写入排序条目数据:
- 排序条目有两个字段:
sort_key
(排序键)—— 这是我们目前还没有的,后续必须提供;PushBufferOffset
(指向渲染元素的偏移量)—— 这个我们已有,是当前PushBufferSize
值。
tile_sort_entry *entry = (tile_sort_entry *)(group.PushBufferBase + group.SoftEntryAt); entry->PushBufferOffset = group.PushBufferSize; entry->sort_key = sort_key; // 需要外部提供
- 排序条目有两个字段:
渲染输出逻辑的简化
因为我们在 push 阶段已经收集好了所有排序信息,因此在输出时 RenderGroupToOutput
不再需要额外传入 sort entry 的信息,它可以从 group 中自行获取:
- 排序条目的总数 = 当前已推入的渲染元素数量;
- 排序条目的起始地址 =
PushBufferBase + SoftEntryAt
。
这样一来,渲染输出逻辑变得更加简洁、明确,不需要依赖外部管理这些信息。
总结要点
- 我们在 push 阶段同时维护了一个 sort entry 数组;
- sort entry 是在 push buffer 尾部倒序增长,避免额外内存分配;
- 排序所需信息(排序键、元素偏移)在 push 时即可得出;
- 渲染输出阶段不再需要额外参数即可直接执行排序渲染;
- 唯一的新增需求是:每个渲染元素必须带有一个
sort_key
。
这种方式非常高效地将“排序”嵌入到渲染元素构建流程中,无需额外开销、结构清晰,未来也便于扩展,比如按 tile 分片进行排序、多线程并行等。
在 game_render_group.cpp
中修复编译错误并传递 SortKey
我们现在正在完成渲染系统中排序逻辑的基本搭建,重点是为每一个渲染元素生成一个 排序键(Sort Key) 并将其写入到 push buffer 末尾的排序条目数组中,使得后续的渲染阶段可以基于这些排序键进行绘制顺序的控制。以下是整个过程的详细整理:
排序键的生成与写入逻辑
我们在渲染系统中为每个渲染元素都建立了一个对应的排序条目,其中包含两个关键字段:
- 排序键(Sort Key):用于决定该元素在屏幕上应被绘制的顺序。
- Push Buffer 偏移量:指向该渲染元素在 Push Buffer 中的存储位置。
具体实现思路如下:
- 在调用
PushRenderElement
时,除了正常压入渲染数据,还需要将该元素的排序信息同时记录下来。 - 排序条目的写入地址是
PushBufferBase + SortEntryAt
,它从 buffer 尾部向前推进。 - 为了保证不发生内存覆盖,我们检查当前的 buffer 使用量和剩余空间,确保排序条目和数据不会冲突。
排序键的来源
排序键的获取依赖于渲染元素在屏幕空间中的 位置(Y轴和Z轴)。我们基于以下逻辑生成一个可用的排序值:
- 从变换结果中获取元素的
P.z
(深度)和P.y
(垂直位置)。 - 利用如下方式组合成排序键:
SortKey = Z值*4096 - Y值
- Y值的方向是从上到下递减,所以我们需要做一次翻转(4096 - Y),使得越靠下(也即靠近屏幕前面)的元素排序值越大,从而优先绘制。
这种排序逻辑意味着:
- 更小的 Z 值(更靠近摄像机)+ 更大的 Y 值(更靠近底部) → 排序键越大。
- 在排序时我们可以按升序或降序排序,来控制绘制顺序。
特殊元素处理:Clear 操作
对于不参与排序逻辑的元素,比如清除背景的 Clear 操作,我们人为赋予一个 最小的排序值(Real32Minimum
),使其始终被排到最底层进行绘制。这保证了不管其他元素如何排序,Clear 都不会被遮盖或干扰。
推进整合与回收
我们将排序键的生成提前到了创建 Basis(变换基准)的阶段:
- 所有通过
GetRenderEntityBasisP
的地方,都会生成并返回一个带有SortKey
的 Basis。 - 所有使用该 Basis 的地方(例如
PushRect
),将直接使用该SortKey
,不需要手动重复计算。
这样做的好处是逻辑更集中,避免在每次 push 时重复写一段计算排序键的代码。
当前成果与下一步计划
我们已经完成了:
- 在 PushRenderElement 时自动写入排序条目;
- 自动从 Basis 中获取排序键;
- 为 Clear 等特殊操作赋予最低排序值;
- 排序信息结构已就位,RenderGroupToOutput 可以使用这些排序条目进行绘制调度。
下一步:
可以开始在 RenderGroupToOutput
中根据这些排序条目进行排序,并依照排序后的顺序进行绘制调用,实现真正的“先后绘制”控制,优化图层堆叠正确性,避免重叠错误。
这意味着我们的渲染系统离真正的 深度感知绘制调度 只差一步了
运行游戏,发现排序看起来不对
debug的字都没有
黑屏呢
忘记遍历softEntity
目前我们已经完成了排序条目的构建工作,也就是说,现在所有的渲染元素在被推入渲染队列时,都会同时写入一个包含排序键和偏移地址的排序条目,这样理论上就具备了排序绘制的基本能力。
当前测试结果与现象分析
我们尝试运行当前的实现并观察渲染结果,发现以下现象:
- 画面确实有绘制输出,说明排序数组的构建过程在逻辑上是有效的,渲染数据成功通过排序条目被调度参与绘制。
- 但画面显示效果不正常,可能存在以下几种问题:
- 层级关系错乱,某些应被遮挡的对象却绘制在最上层;
- 屏幕内容混乱,某些对象顺序与逻辑预期完全相反。
这些现象的初步判断如下:
初步分析
排序没有生效:目前虽然排序条目数组已经准备好了,但还没有执行实际的排序操作,渲染输出仍然按原始 push 顺序绘制。这意味着:
- 比如背景清除(Clear)操作,如果最后才 push,那么它将被绘制在所有元素之上,反而遮挡了所有内容。
- 如果前景元素先被 push,背景元素后被 push,则前景被背景盖住,结果就会出现“画面看上去全乱了”的情况。
当前行为“理论上合理”:因为我们没有进行排序,所以它表现得像是反序绘制,这是符合逻辑的。
下一步计划与调试方向
为了解决当前出现的绘制顺序问题,我们需要完成两个关键步骤:
正确执行排序
在 RenderGroupToOutput
函数中:
- 遍历构建好的排序条目数组;
- 按照排序键进行升序或降序排序(取决于设定的深度与方向);
- 然后根据排序条目的 offset 去实际访问 push buffer 中的渲染数据,并执行绘制。
验证排序逻辑是否正确生成
在正式排序前,建议做以下调试辅助:
- 打印排序键值(SortKey)与对应的元素名称/标识;
- 检查 PushBuffer 中记录的 offset 是否对应正确的数据段;
- 验证排序键与元素在屏幕上的 Z/Y 位置是否匹配(即越近的元素,排序键是否越大或越小,符合预设规则);
- 重点检查 Clear 元素是否始终具有最小排序键,确保在排序后最先绘制。
总结当前进度
- 排序条目数组构建完成;
- 排序键逻辑初步实现;
- 尚未进行排序处理;
- 由于缺少排序,渲染顺序错乱属预期行为;
- 需要实现排序并进行渲染映射。
接下来一旦补上排序逻辑,系统应该就会表现得更接近预期的深度绘制结构。我们已经打好了基础,现在就差最后一块拼图了
在 game_render_group.cpp
中反转排序顺序
我们打算做一个测试:从排序条目数组的末尾开始向前遍历,看看是否能产生正确的渲染结果。
之所以这样做,是因为我们在之前构建排序条目数组时,采用的是“从头往后推”的方式将元素加入渲染队列,而排序条目本身是从 buffer 尾部往前写入的。这意味着——条目的排列顺序和它们原始入队顺序是相反的。
所以我们现在要验证一个假设:
假设
如果我们倒序遍历这些排序条目(即从最后一个向前走),那么渲染顺序将会和元素原始入队顺序一致,应该可以恢复正确的显示效果。
实施目标
- 从排序条目数组末尾开始往前遍历;
- 根据每个条目的 offset 去读取 push buffer 中的数据;
- 渲染每个元素;
- 最终观察是否恢复正确的层级/遮挡关系。
背后逻辑说明
- Push Buffer 是正向增长的,数据从前往后写入;
- Sort Entry 是反向写入的,从尾部往前;
- 所以如果我们不进行排序,也不修改逻辑,但从末尾开始按顺序走,就等于还原了原始 push 顺序;
- 这样至少能让画面恢复我们期望的入队顺序绘制(即后加入的元素绘制在后面,前面的在前面)。
测试预期结果
如果画面恢复正常,那么:
- 说明排序条目确实是按预期从尾部写入的;
- 我们可以确认 buffer 的结构与构建顺序是合理的;
- 接下来我们只需对这些条目进行排序处理,即可控制任意绘制顺序。
当前状态总结
- 正在进行倒序遍历的验证;
- 此举将确认排序条目构建逻辑是否健壮;
- 如果成功,则下一步就是正式插入排序算法,全面控制绘制顺序。
下一步我们就能朝着正确的透明排序或遮挡剔除方向迈进了。继续推进
再次运行游戏,发现排序正确
我们刚才的问题其实非常简单,问题的根源在于排序条目数组的方向是反的。
我们在构建排序条目数组的时候,是从 push buffer 的末尾向前写入排序条目的——也就是说,这些排序条目在内存中排列的顺序和我们实际推入渲染数据的顺序是相反的。
所以,问题的核心是:
我们在遍历排序条目时,是正向遍历,从头到尾一个个地去处理。但这样一来,就会打乱原本我们希望遵循的绘制顺序。
而我们真正需要的是——按照 push buffer 中元素入队的顺序来进行绘制。
解决办法:
我们将排序条目数组倒序遍历,也就是说:
- 从最后一个排序条目开始,一直到第一个;
- 每一个条目指向 push buffer 中的一个元素;
- 这样,我们就还原了元素的真实入队顺序;
- 所以绘制出来的效果就恢复正常了。
总结一下
- 排序条目是从尾部向前写入的;
- 所以遍历时也要从尾部开始向前;
- 如果我们直接从头开始遍历这些条目,会得到反向的绘制顺序,导致图层关系混乱;
- 一旦使用正确的遍历顺序,一切就恢复正常了。
这个小错误看起来不大,但它直接导致整个屏幕的渲染顺序出现了问题。现在我们已经定位并解决了这个问题,说明整体的排序条目构建和渲染机制基本是健壮的。接下来就可以开始引入真正的排序逻辑,实现更复杂的图层控制。
黑板讲解:清屏操作在所有渲染之后才执行
我们在构建排序条目数组时,是从缓冲区的末尾开始向前推入数据的,也就是说数据是逆序写入的。由于这种写入方式,所有进入排序数组的渲染条目的顺序刚好和我们实际想要的绘制顺序完全相反。
实际情况如下:
- 渲染时,第一个被推入缓冲区的条目是 清屏指令(Clear)。
- 接着是其他绘制内容,比如图片、图形等。
- 由于我们从缓冲区尾部往前写排序条目,清屏操作就被放在了排序条目数组的最末尾。
出现的问题:
当我们正向遍历这个数组时,清屏就成了最后才执行的操作,也就是说:
我们先把所有图形渲染完了,然后执行清屏操作,结果就是整张屏幕又被清空了,看不到任何内容。
解决方式:
虽然这个问题是因为遍历方向引起的,但我们其实不用修改这个顺序,因为——
我们即将引入排序机制来重新对这些排序条目进行排序,所以当前的顺序并不重要。
只要我们后面实现正确的排序逻辑(根据 sort key
),所有的绘制顺序都会变得正确,包括让清屏操作在最开始执行。
结论整理:
- 我们是从缓冲区尾部往前推排序条目的,顺序是反的;
- 因此清屏条目在数组的最后,导致它在最后才被执行;
- 渲染时清屏在最后会把整个屏幕清空,导致“看不到任何图像”;
- 我们不打算手动修改顺序,而是准备通过
sort key
排序来解决这个问题; - 所以现在唯一需要关注的是:生成正确的排序 key,而不是条目的初始顺序。
这样一来,系统最终就能自动按照正确的绘制顺序工作。我们当前只是在测试这个机制是否运作良好,验证思路是否正确,下一步将进入排序逻辑的真正实现阶段。
在 game_render_group.cpp
中引入 SortEntries
我们现在要开始实现排序操作。具体来说,我们准备在将渲染组输出(RenderGroupToOutput
)之前,先对渲染条目进行排序,以确保正确的绘制顺序。
当前结构分析:
- 有一个函数叫
TiledRenderGroupToOutput
,它负责将渲染组分块处理(tile),然后调用CompleteAllWork
执行真正的绘制工作。 - 问题在于,我们必须在这之前完成排序,否则条目的绘制顺序将是错误的。
不能依赖的机制:
- 我们之前在渲染组有一个“开始-结束”的机制(比如 begin / end),但这个机制是在
output
之后才会被调用的。 - 所以 太晚了,不能用于触发排序逻辑。
正确的解决思路:
我们决定在 RenderGroupToOutput
和 tiled_RenderGroupToOutput
内部手动调用排序函数,例如:
sort_entries(render_group);
- 排序操作必须在进行任何输出操作之前完成。
注意事项:
我们在排序调用处加了一个小的 TODO
备注:
“不要重复排序”。
意思是,如果一个渲染组被多次输出,例如由于重复渲染需求,当前逻辑会多次执行排序,其实是不必要的,因为:
- 一旦排序完成,排序列表就是有效的;
- 重复排序只是浪费性能;
- 因此需要做一次性标记或缓存判断,避免重复执行排序操作。
总结要点:
- 排序必须在渲染组输出之前进行;
RenderGroupToOutput
和tiled_RenderGroupToOutput
中都应主动调用sort_entries
;- 当前的“begin/end”机制无法满足需求,太晚执行;
- 后续要优化,避免重复排序;
- 排序完成后才进入线程任务
CompleteAllWork
进行最终渲染。
接下来,我们将进入具体的排序实现阶段,对渲染条目数组按 sort key
进行实际排序。
黑板讲解:冒泡排序
我们现在要实现排序逻辑,而我们选择的,是最简单、最愚蠢但最容易理解的排序方式:冒泡排序(Bubble Sort)。
冒泡排序的基本思想:
我们有一个包含若干元素的数组,例如:
5, 3, 1, 0, 2, 4, 6
我们的目标是把这些元素按一定顺序排列(比如从小到大)。冒泡排序的核心思想就是:不断比较相邻的两个值,如果它们顺序错了,就交换它们的位置。
具体操作过程:
- 每一轮,我们从数组开头到结尾依次检查相邻的两个元素;
- 如果前一个比后一个大,就交换它们;
- 这样一轮下来,最大的值就会“沉”到数组最后的位置;
- 接下来再重复同样的过程,每次排好一个“最大的”元素;
- 最多进行 N 次,就能排好整个数组。
例如:
- 第一轮后:最大元素排到最后;
- 第二轮后:第二大的排到倒数第二;
- 以此类推……
每一轮会把当前还未排序部分的最大值“推”到尾部,就像重力让重物往下沉一样,因此也可以把它想象成“重力排序”。
时间复杂度:
- 对于一个长度为 N 的数组,在最坏情况下,我们需要遍历数组 N 次;
- 每次遍历时可能最多比较 N 个相邻的值;
- 所以总共需要进行约
N * N = N²
次操作; - 这就是一个O(N²) 的排序算法,非常低效,但非常简单。
为什么现在选择这种方法?
- 当前我们的目标只是验证排序逻辑能不能跑起来;
- 并不打算现在就实现任何高级优化;
- 所以我们选择最简单的方法,只为了先把功能跑通;
- 真正优化排序效率的部分,后面再做。
总结关键词:
- 冒泡排序: 反复交换相邻错误顺序的元素;
- 原理清晰: 每次移动一个元素接近其目标位置;
- 效率低下: 最坏情况为 O(N²);
- 实现简单: 易于理解、便于测试;
- 临时方案: 目的是先跑通系统流程,后续可换更快算法;
我们接下来会写出这个冒泡排序的代码,用于对渲染条目的 sort key
进行排序,一旦排好,渲染顺序就会正确地体现出前后遮挡关系了。
在 game_render_group.cpp
中实现冒泡排序逻辑
我们现在正式实现了一个最基础、最原始的冒泡排序算法,其完整过程和逻辑如下:
排序整体结构
我们采用两层循环来进行排序处理:
- 外层循环(outer): 控制排序需要执行多少轮;
- 内层循环(inner): 每轮中比较相邻元素是否需要交换位置;
- 这样构成了典型的 N² 复杂度算法结构 —— 总体循环次数是
count * count
;
这种方式虽然效率低,但逻辑非常直接,便于调试和验证。
排序核心逻辑
只迭代到倒数第二个元素:
- 我们每次比较一对相邻的元素(比如第 i 和第 i+1 个);
- 所以内层循环最多到
count - 1
,防止越界;
判断排序关键字(Sort Key):
- 每个条目都有一个
sort key
,表示其在渲染中的顺序权重; - 我们希望按从小到大的顺序排列这些条目;
- 所以当我们发现当前元素 A 的
sort key
大于元素 B 的,我们就交换它们;
- 每个条目都有一个
交换操作:
- 我们先把元素 A 保存起来;
- 再把元素 B 赋值到 A 的位置;
- 然后把刚才保存的元素 A 赋值给 B 的位置;
- 这样实现了两元素的交换;
排序方向可控:
- 判断语句可以轻松改为从大到小;
- 只要比较
sort key
的大小关系即可; - 排序的方向完全取决于渲染需求;
示例代码简化描述
for (int outer = 0; outer < count; ++outer) {
for (int inner = 0; inner < count - 1; ++inner) {
Entry* a = &entries[inner];
Entry* b = &entries[inner + 1];
if (a->SortKey > b->SortKey) {
Entry temp = *a;
*a = *b;
*b = temp;
}
}
}
这个结构就是我们当前使用的完整冒泡排序逻辑。
关于性能优化的后续思考:
- 当前没有做任何早停(Early-Out)处理,即使已经排序好了也会继续跑;
- 后续可以添加标志位,比如某一轮中没有发生任何交换,说明数组已排好,可以提前结束;
- 当前是临时调试和验证逻辑的阶段,未来有需要可替换为更高效的排序方式,如快速排序、归并排序等;
总结关键点
- 实现了一个结构清晰、逻辑简单的冒泡排序;
- 使用双重循环进行相邻元素比较与交换;
- 排序基于元素中的
Sort Key
,排序方向可自由切换; - 当前算法效率低但易于验证;
- 后续可优化性能或更换更先进排序算法;
通过这一过程,我们能够确保渲染条目的顺序是正确的,为后续画面正确呈现打下基础。
运行游戏,结果看起来可能是对的
我们完成了排序代码的编写,理论上这个排序逻辑应该已经生效。虽然可能打字时有些地方没完全精确,但整体排序机制是可行的。
当前的状态和效果
- 排序已经开始起作用,这是因为每个渲染条目都带有一个 Z 值(深度值);
- 正因为如此,条目本身的插入顺序已经不重要了;
- 无论我们以什么顺序将它们放入渲染列表中,最终都会根据 Z 值正确排序并呈现;
- 这是一件非常有意义的事情,让我们可以更加灵活地组织渲染指令;
当前显示异常但可能是“正常”的原因
- 屏幕上的某些内容看起来似乎有点“糟糕”,但这不一定是错误;
- 比如那些地面图块(ground tiles)目前并没有被告知它们应当“在后面”;
- 所以在 Z 值排序时,它们可能会被和其他内容“平等对待”,进而出现在前景或不合适的位置;
- 这并不是排序出错,而是 缺少明确的层级信息;
临时思路与调整方向
- 我们意识到:目前并没有办法告诉渲染器,“某些对象应该站在这些地面图块之上”;
- 因此,接下来的关键在于如何为 不同的元素类型赋予合适的 Z 值(排序关键值);
- 例如:
- 地面图块应拥有较低的 Z 值;
- 人物、物体应具有更高的 Z 值;
- 这样在排序时才能形成我们想要的前后遮挡关系;
举例设想:设定合理的排序关键字
渲染内容 | Z 值(Sort Key)示例 |
---|---|
地面 | 0.0 |
建筑 | 1.0 |
人物 | 1.5 |
前景装饰 | 2.0 |
通过这种方式,我们可以确保视觉层次结构的正确性。
总结
- 排序功能已经在运作;
- 屏幕表现虽然有些“奇怪”,但可能是因为缺少分层信息;
- 当前排序系统仍需进一步调整不同渲染元素的
SortKey
值; - 一旦这些值被合理设置,画面将能正确呈现前后遮挡关系;
- 后续可以优化“填充阶段”时对 Z 值的赋值逻辑,使不同类型的对象具备合适的深度信息;
这一步标志着渲染系统逐步开始具备可控的图层管理能力。接下来就是通过合理赋值让排序发挥真正作用。
在 game_world_mode.cpp
中为 GroundBuffer
添加 zBias
我们意识到需要让地面区块(ground chunks)在渲染时始终处于其他元素的背后,因此想要手动地为地面设置一个较低的 Z 值,从而确保它们在排序过程中总是排在靠后位置。
当前做法与目的
在执行 UpdateAndGameRenderWorld 中
render_ground_chunks
相关操作时,有一段代码用来调用PushBitmap
来添加地面瓦片的渲染;现在希望在这一阶段直接设置一个靠后的 Z 值,确保这些地面图块在最终渲染排序时不会出现在人物或物体之前;
所以尝试在设置该瓦片的位置时,加入一个较低的 Z 值,例如:
z = zBias - 0.5f;
这样做的目的是人为地给地面一个“退后”的深度,确保它们总是比默认 Z 值的其他物体更靠后;
思路与效果
- 这是通过简单调整深度值来影响排序逻辑;
- 不用更改整个排序系统逻辑,只需在关键数据插入时赋予不同的 Z;
- 这样在
bubble sort
或任何排序算法执行后,地面图块总会被排在人物、物品等元素之后; - 渲染出来的视觉效果就是地面在底下,其它对象在其上方,形成正确的前后遮挡层次;
当前结果的不确定性
- 尽管设置了偏移 Z 值,但目前尚未确认效果是否完全正确;
- 显示上的问题可能仍然存在,这需要进一步验证是否排序逻辑和数据结构中的 Z 值被正确使用;
- 也可能需要检查
PushBitmap
内部是否支持接收并使用这个新的 Z 值参数;
下一步建议
- 检查
PushBitmap
的实现是否会将传入的 Z 值用于生成排序键; - 确认渲染条目中的 Z 值最终是否参与到排序的
sort_key
计算中; - 若一切正常,地面图块将正确被排在其他元素之后,实现视觉层级分明的渲染效果;
这一步主要是对渲染顺序做初步的逻辑梳理和实际调整,关键在于从“插入数据时就设定层级”,而不是仅依赖排序算法本身,确保地面始终被渲染在底层,构建正确的视觉深度。
在 game_world_mode.cpp
中临时关闭 GroundBuffer
我们发现地面区块可能还没有被正确地实现或整合进渲染流程中,所以暂时决定先把它们关闭,先专注于让我们能清楚看到的部分正常工作。在确保主要可视元素能够正确排序和显示之后,再回头处理地面区块的集成问题。
当前决策与操作
- 临时关闭地面区块的渲染逻辑;
- 目标是先验证排序系统是否确实能正常工作;
- 因为即使地面部分表现不对,但如果排序系统整体没有问题,那么说明排序逻辑是可靠的,之后只需要确保地面区块被正确加入排序列表即可;
排序初步验证结果
- 从当前其他图层或对象的渲染结果来看,排序似乎是起作用的;
- 即便我们输入的顺序是乱的,渲染后还是能看出有一定的前后遮挡关系;
- 所以初步判断排序算法应该已经在起效,至少基本逻辑没有错;
下一步计划
- 等确认排序系统确实稳定后,再恢复地面区块相关的渲染;
- 重新检查地面区块的插入位置、Z 值设置是否正确;
- 保证它们进入排序队列并且有明确的深度信息;
- 最终确保在统一排序后它们位于其他物体之后,正常显示在底层;
通过这种逐步验证和分阶段排查的方式,我们确保基础功能可控可靠,再逐步引入复杂内容,降低调试难度,也避免了因为多个问题叠加而导致定位困难。
运行游戏,排序逻辑看起来运行正常
现在看起来排序功能确实已经正常工作了,我们可以清楚看到渲染顺序变得一致了。
排序功能验证结果
- 渲染结果中,图像已经能按照设定的顺序进行排序,显示的效果也符合预期;
- 比如画面中角色的躯干部分现在错误地排在了人物前面,但这是因为我们尚未设定具体的排序规则来避免这种情况,并不是排序算法本身的问题;
- 此外,画面中树木沿边排列的部分,也已经明显按照正确的顺序进行了层级渲染,说明排序逻辑运行良好;
当前存在的问题
- 虽然排序在技术上已经实现,但排序的**依据(sort key)**仍然非常粗糙;
- 目前我们还没有真正设计出一个合理、统一的方式来生成这个排序键;
- 尤其是 Z 和 Y 的混合影响因素还没有深入处理,这可能会导致某些视觉上的错误,比如角色身体部件的前后关系错乱;
接下来的工作重点
- 重新审视并设计排序键(sort key)的生成逻辑;
- 需要同时考虑 Z 值(深度)和 Y 值(在画面上的垂直位置);
- 要制定一个清晰的规则来描述哪一类元素应该在前,哪一类元素应该在后;
- 使生成的排序键可以稳定且明确地反映视觉层级;
- 确保角色、地形、物体等在渲染时不会因为键值设置错误而前后颠倒;
- 解决特定细节,例如人物身体部位之间的前后错位问题;
目前我们可以确定,基础的排序算法已经无误,接下来的挑战是构建一个合理的、符合视觉逻辑的排序键系统。这将是渲染正确性的关键所在。
在 game_world_mode.cpp
中增大 zBias 并在正确的位置应用
在当前的实现中,出现了排序效果不理想的问题。通过检查,发现可能的原因在于排序键的计算不完全正确,特别是变换(transform)没有正确应用到排序上。具体来说,现有的排序逻辑并没有考虑到物体的偏移量和变换矩阵,这导致了在渲染时,物体的位置并未得到充分的更新,从而影响了渲染顺序。
目前的问题
偏移量和变换:当前使用的偏移量并没有考虑实际的变换,导致排序时没有正确反映物体的实际位置。实际的变换矩阵并没有被应用到排序逻辑中,导致渲染的结果与预期不符。
排序键计算问题:在排序过程中,可能没有正确地处理与变换相关的数据,因此导致某些物体被错误地排序,特别是地面块(ground chunk)的渲染。
解决思路
变换应用问题:需要在进行排序时,确保物体的变换(如偏移、旋转、缩放等)被正确应用。具体来说,应该确保排序过程中能够正确考虑到这些变换,而不仅仅依赖简单的偏移量。
调整偏移量计算:目前偏移量的计算可能过于简单,需要进一步优化。例如,可以引入**偏置(bias)**来处理排序中的细节问题,从而使排序逻辑更加精确。
重新审视排序键:为了使排序更加符合预期,排序键的计算方式需要进行重新设计,确保它能够考虑到变换因素,以及物体在空间中的实际位置。
下一步工作
- 改进偏移量和变换的应用:在渲染地面块等对象时,需要确保变换矩阵被正确应用,以便排序能够反映实际的空间位置。
- 优化排序键计算:调整排序键的计算方式,确保在排序时能够正确处理物体的空间位置和层级关系。
- 处理地面块的排序问题:特别是地面块的渲染顺序问题,需要考虑它们的位置相对于其他物体的位置,确保它们处于正确的层次。
通过这些调整,可以提升排序的准确性,从而确保渲染的物体按预期显示。
再次运行游戏,一切正常(除了角色上半身始终在最前)
问题的关键在于偏移量(bias)的处理方式不当。当前的偏移量并没有很好地适应排序的需求,导致排序过程中未能准确反映物体的实际位置。这是导致渲染结果不正确的主要原因。
为了修正这个问题,需要在计算偏移量时加入更合理的逻辑。需要确保在排序过程中,物体的变换和偏移量能够得到充分考虑,从而保证物体在渲染时能够按照预期的顺序正确地排列。
下一步的工作是,优化偏移量的计算方式,确保它能够适应不同的情况,并有效地解决排序中的问题。此外,还需重新审视排序逻辑,确保它能够正确地处理所有物体的空间位置和层次关系。
Q&A 问答环节
你能谈谈堆排序与快速排序的优劣吗?或者解释下四元数?
堆排序(Heap Sort)和快速排序(QuickSort)是两种常见的排序算法,它们各自有优缺点。
堆排序(Heap Sort)
优点:
- 时间复杂度:堆排序的时间复杂度始终是O(n log n),即使在最坏情况下也是如此,因此它是一个时间复杂度比较稳定的排序算法。
- 不需要额外空间:堆排序是一个原地排序算法,不需要额外的空间,除了存储堆的数据结构外,空间复杂度为O(1)。
- 稳定性:虽然堆排序本身不是稳定排序(即相等元素的相对顺序可能会改变),但是在某些应用场景中可以根据需要修改堆排序来实现稳定性。
缺点:
- 性能表现不如快速排序:虽然堆排序的最坏情况是O(n log n),但在实际情况中,堆排序通常比快速排序慢,因为堆排序需要频繁的比较和交换,且常常涉及到树结构的调整。
- 不适合缓存友好性:堆排序使用的堆结构通常会导致缓存不友好,相比于快速排序的局部性优势,它的内存访问模式较差。
- 实现复杂度较高:堆排序的实现相对较为复杂,尤其是在实现堆的插入和删除操作时。
快速排序(QuickSort)
优点:
- 平均性能:在大多数情况下,快速排序的时间复杂度是O(n log n),并且通常比堆排序和归并排序更快。这是因为它的局部性较好,利用了分治法减少了数据的移动。
- 较少的交换操作:快速排序在某些情况下比其他排序算法(如堆排序)所需的交换操作要少,因此在实际应用中通常表现得更为高效。
- 空间复杂度低:快速排序的空间复杂度是O(log n),主要用于递归调用栈,这比堆排序的O(1)空间复杂度差,但在许多情况下仍然是可以接受的。
缺点:
- 最坏情况性能差:快速排序在最坏的情况下,时间复杂度可能退化为O(n^2),比如当数据已经基本有序时,或者选取的基准元素不好时。为避免这种情况,通常会采取随机化基准或三数取中法来改善。
- 不稳定排序:快速排序不是稳定排序,意味着如果有多个相等的元素,它们的相对顺序可能会改变。
- 递归开销:快速排序是一个递归算法,尽管平均情况下性能较好,但在递归深度过大的情况下,可能会导致栈溢出问题。
总结:
- 堆排序适用于对最坏情况的性能有较高要求的场景,且不需要额外的空间,但通常比快速排序慢。
- 快速排序是平均情况下性能最佳的排序算法,适用于大多数排序场景,但需要处理最坏情况的性能问题,并且不稳定。
关于排序的其他算法:
在实际应用中,可以根据数据的不同特点选择合适的排序算法。明天或者接下来的时间里,可能会讨论如何改进当前的排序算法,并尝试实现其他排序方法,以展示它们相对于冒泡排序的优劣。
你打算最终用什么排序算法替代冒泡排序?
在讨论排序算法时,最终替代冒泡排序的选择通常会倾向于使用具有 O(n log n) 时间复杂度的算法。这是因为 O(n log n) 是理论上可以达到的最优复杂度,而不像某些库(比如 C 标准库)中使用的 O(n²) 的排序算法那样。虽然 O(n²) 算法在实践中通常表现得更快,但如果考虑到最坏情况下的表现,O(n log n) 算法通常更为稳妥。
冒泡排序是最简单的排序算法,它通过反复比较和交换相邻的元素,直到整个数组有序。然而,冒泡排序在最坏情况下的时间复杂度为 O(n²),这意味着当数据量增大时,冒泡排序的效率会急剧下降。因此,如果考虑到数据量可能增加的情况下,使用 O(n log n) 的排序算法通常更有保障,避免了最坏情况的性能问题。
虽然 O(n²) 的算法在一些小数据集上可能表现得更快,但在处理较大的数据集时,O(n log n) 算法通常会更加高效,尤其是在数据量并不是特别大的情况下,这样的算法表现仍然足够好。
总的来说,选择排序算法时,考虑到最坏情况的性能,通常更倾向于选择 O(n log n) 算法,而不只是单纯地依赖实践中通常更快的 O(n²) 算法,尤其是在数据量并不是非常大的情况下,性能差异并不明显。
冒泡排序是最简单的排序吗?我练习时做到选择排序,所以我以为它才是最基础的
在实践中,使用选择排序(Selection Sort)是为了追求简化。选择排序是一种非常简单的排序算法,基本思路是每次从未排序的部分中选择最小的元素,放到已排序部分的末尾。它的时间复杂度为 O(n²),因为每次寻找最小值时都需要遍历未排序部分,因此其效率在处理大数据集时可能会变得非常低。
然而,尽管选择排序简单易懂,但其效率较低,尤其在处理大量数据时。它的时间复杂度是 O(n²),这意味着数据量较大时,它的性能会急剧下降。在某些小规模数据的排序中,选择排序可能不会显得太差,但当数据量增大时,可能就会影响到整体的排序速度。
选择排序与冒泡排序类似,都是基于比较和交换的简单排序算法。尽管两者的工作原理相似,但选择排序的优势在于它减少了交换次数,而冒泡排序在每次比较时都可能发生交换。因此,选择排序可能比冒泡排序稍微快一些,但总体来说,它们的时间复杂度都是 O(n²),对于大数据集来说都不够高效。
综上所述,选择排序是一种简单易实现的排序算法,但由于其时间复杂度较高,在数据量较大的情况下并不适用。在实际应用中,通常会考虑使用更高效的排序算法,如归并排序(Merge Sort)或快速排序(Quick Sort)。
n² 排序在很多情况下其实更快吗?
在排序算法的时间复杂度分析中,通常会关注最坏情况的复杂度。最坏情况是指算法在最糟糕的情况下执行的时间。例如,像归并排序或快速排序这样的算法,最坏情况下的时间复杂度是 O(n log n),而冒泡排序或选择排序的最坏情况是 O(n²)。
然而,实际应用中,最坏情况并不总是发生。实际运行时,常常遇到的是“期望情况”或“常见情况”,也就是大多数情况下算法的表现。即使某个算法的最坏情况复杂度很高,如果在大多数实际情况中不会遇到最坏情况,它的效率可能反而更好。
这是因为,即使一个时间复杂度较高的算法,它的单次迭代成本可能比低复杂度算法更低。比如说,一个具有 O(n²) 时间复杂度的算法可能在每次迭代时都非常快,而一个具有 O(n log n) 的算法虽然理论上更高效,但每次迭代可能需要更多的计算资源。如果排序的数据量比较小,比如只有几千个元素,那么低复杂度算法的优势可能不会体现出来,反而 O(n²) 的算法由于常数因素较低,可能会运行得更快。
因此,理论上复杂度较低的算法(如 O(n log n))在大数据集上表现更好,但在实际应用中,考虑到数据量较小和常数开销的影响,复杂度较高的算法(如 O(n²))可能在某些情况下反而更快。
这就是为什么实际排序中,某些看似最坏情况较差的算法在实际运行时,可能比复杂度较低的算法更高效。
是否可以使用二分插入排序,让数组在插入时就已排序,这样 RenderGroupToOutput
就不需要再排序了?
可以使用二分插入法将元素插入到正确的位置,这样数组就会在整个过程中保持排序状态,从而避免在渲染组输出时再进行额外的排序操作。但这种方法并不总是最好的选择,原因在于,如果我们一直这样做,就会不断地把渲染组输出数组中的所有数据重新加载到缓存中。这是因为每次插入时,都会遍历数组并找到合适的位置,因此需要不断从缓存中拉取数据,这会带来额外的性能开销。
这种方式的问题在于它可能导致缓存的高频率使用,这对于大数据量的排序或频繁更新的数据来说,可能会引发性能瓶颈。虽然从理论上讲,二分插入可以提高查找位置的效率,但它仍然需要移动大量的数据,这在实际应用中可能并不总是最优解。因此,虽然二分插入在某些情况下有效,但在考虑到缓存性能和操作复杂度时,可能需要寻找更合适的排序方法。
黑板讲解:事后排序 vs 实时排序
无论是排序后再处理,还是边排序边处理,成本是相同的,因为排序本身就需要消耗相同的计算资源。即使采用二分插入排序(binary insertion),实际上它本质上也是一种排序方式。所以,无论是事后排序还是边做边排序,都会执行相同数量的工作,唯一的区别在于排序过程中所处理的元素量和信息量。
如果选择在插入时排序,成本可能更高,因为在执行排序时,无法一次性看到所有的元素。因此,排序的过程会更加复杂。在排序过程中,可能需要多次来回调整元素的顺序,这意味着会不断地访问和修改内存中的不同部分,这可能会导致缓存的高频率使用,从而带来性能上的额外开销。
相比之下,选择事后排序可以避免这种问题。事后排序时,可以将数据顺序流式处理,每次只处理新的数据部分,而已经处理过的部分可以从缓存中移除,直到最后需要再次访问时才重新加载。这样可以有效地减少内存的频繁访问和缓存的占用,提升性能。
总的来说,虽然看起来边插入边排序可能节省了时间,但实际上在许多情况下,可能会因为频繁访问内存,导致性能下降。因此,在选择是否使用边排序边插入时,需要特别小心,并进行实际测试,验证这种方法是否真正带来性能提升。
你以前做过类似这样的项目吗?
在进行项目时,通常是边做边编码,而不是事先做准备。原因是这个项目的重点是展示如何解决编程中的问题。目的是让观众看到如何面对编程挑战并逐步解决它们,就像在实际工作中遇到问题一样。平时工作时,虽然会有很多类似的编程经验,但这个项目的具体内容是新的,尤其是像这个类似“塞尔达”的二维半3D探索类游戏,之前从未做过这样的游戏。所以,尽管有很多经验可以借鉴,面对这些新的挑战时,还是会有很多全新的东西需要解决。
尽管如此,在进行一些代码编写时,还是会基于已有的游戏开发经验,快速处理一些常见的编程问题。这也是为什么尽管是第一次尝试这类游戏,依然能够高效地处理一些编程任务。虽然每个项目的具体需求不同,但很多概念和技术是可以迁移到新的项目中去的,因此在编码过程中,有些问题可以轻松解决。
在 Emacs 中如何关闭语法高亮但保留注释和宏高亮?
在Emacs中,虽然可以禁用语法高亮,但仍保持注释和宏的高亮,实际上并没有完全禁用语法高亮。只是通过设置颜色使得语法高亮看起来更简洁,减少了颜色的繁杂。通过修改Emacs配置文件中的set-face-attributes
,可以调整不同语法元素的颜色。例如,将字符串和常量的颜色设置为相同,类型和变量的颜色也设置为相同,从而让它们看起来没有太多颜色上的区分。这种方法并不是完全关闭语法高亮,而是让语法高亮的效果更加简化,不会太过干扰工作流。在设置时,虽然所有的语法高亮仍然在运行,但通过调整颜色,让它们变得更加统一和平滑。
你的 Visual Studio 自定义主题有地方可以下载吗?我很喜欢
使用的主题是Studio的自定义主题,实际上它是默认的深色主题,并对其做了一些简单的调整。如果预定了游戏,相关的配置文件可以在游戏项目的misc
目录下找到,文件名是handmade_hero_settings_msvc2013.vssettings
。这些设置文件可以直接用于项目中,从而对Emacs的主题进行自定义和调整。
添加vssettings