回顾并为今天的内容做个铺垫
昨天我们完成了一个用于排序的空间划分系统,但还没有机会真正利用它。昨天的工作刚好在结束时才完成,所以今天我们打算正式使用这个空间划分来加速排序。
现在我们在渲染代码中,可以看到在代码底部隐藏着一个“sort entries”的调用。目前,我们正在执行“build sprite graph”的操作,可以看到这段代码正在把屏幕上的所有精灵按照网格划分进行排序和分区。尽管已经完成了分区,但代码并没有真正利用这些分区信息,而是继续执行旧的排序逻辑。
因此,今天的目标就是让代码真正使用这个网格分区的结果,从而提升排序效率。不过在这之前,还有一些准备工作需要完成。
win32_game.cpp:开启 GlobalShowSortGroups,运行游戏并查看这些图表
有几件事情需要先做,其中之一就是开启并查看排序图,因为目前还看不到排序加速的效果。为了能看到这些“成果”,需要在 win32 的代码里开启一个叫做 GlobalSortGroups 的开关。虽然可以用图形界面操作,但这里直接在代码中永久开启,这样我们可以清楚地看到所有的排序分组,并对其运作有一个直观的了解。
观察排序图会发现当前的精灵并没有被很好地裁剪,这点挺搞笑的。之后切换到交互式打包模式并对精灵做适当裁剪会更好,尤其是软件渲染下大量无意义的过度绘制现象非常明显。
通过排序图可以看到各种线条,这些线条表示不同精灵之间的重叠关系和排序顺序。大部分排序结果都相当不错,也基本符合预期。虽然有些细节还没有完全实现,比如合理地向下传递数据,但整体排序表现已经挺好。
不过,排序系统还不能完全处理一些复杂情况,比如某些精灵在排序中需要出现在其他精灵前面,这种情况还存在一定问题。除此之外,地面瓦片等元素的排序也基本正常,整体效果满足需求。考虑到二维排序是一个复杂且信息不足的问题,这样的结果已经相当令人满意。
既然我们已经有了一个看起来合理且正确的排序结果,意味着我们能清楚地看到预期的排序效果,那么当切换到优化后的版本时,至少可以通过某种方式进行可视化比较,确认优化版本是否正常工作。毕竟,优化过程中很容易引入各种错误,所以必须确保优化后的排序至少和之前的效果相近,能够正确执行。
game_render.cpp:通过让 BuildSpriteGraph() 在插入时遍历每个格子内的所有元素来加速它
我们在执行 BuildSpriteGraph
的时候,会遍历每一个网格单元,并在对应位置插入新的元素。原本插入操作是分两个步骤进行的:先记录下来,之后再做第二遍处理。但我们意识到,其实没必要分成两步,完全可以在第一次遍历时就顺便处理插入逻辑,因为这时候所有数据都已经在手边,这样效率更高,也更符合逻辑。
因此我们决定改进这部分逻辑:在每次插入元素之前,直接遍历当前网格内已有的所有排序条目,执行必要的检查。这样就不需要等待第二轮处理,能立即完成排序关系的判断和建立。
我们保留了原来的索引逻辑,例如当前要插入的精灵索引(NewIndex)仍然保持不变。在插入之前,我们新增了一段代码用于遍历当前网格单元中的所有已存在的精灵。遍历过程中,每个精灵都作为另一个排序节点(B)与当前要插入的节点(A)进行比较,然后使用之前的前后关系判断逻辑,决定两者之间的排序边界。
为了方便控制这段优化代码的开关,我们引入了一个宏 ACCELERATED_SPRITE_SORT
。当这个宏开启时,使用新逻辑;否则就退回到原来那种朴素的 O(n²) 排序方法。这样可以方便我们在调试中随时切换、比较优化前后的行为差异。
在实际实现中,我们通过网格条目获取对应的精灵索引,然后就能像之前一样进行交叉检测和前后顺序判断。这些逻辑本身已经写好,只需要在新的位置调用即可。
一开始运行的时候遇到了一些问题,看起来是链表在插入时出现了错误,可能是不小心写错了指针操作,导致链表结构出现了环,形成了无限循环。在排查中发现是某个关键操作拼写错误,修正之后再次尝试运行,准备验证优化是否真正起效。
这部分优化的目标是将原先低效的全局两两比较替换为局部化、网格内的有针对性比较,大幅减少无意义的重复判断,从而加快整体的构建速度。即使目前遇到了一些小错误,但从整体结构来看方向是明确的,问题也可控,属于调试阶段的常见情况。
断点没命中
NodeIndexB 不对
运行游戏,看看我们加速后的 ACCELERATED_SPRITE_SORT 效果如何
如果在执行过程中没有正确更新积分器(integrator),那么自然得不到理想的结果。
目前来看,优化后的加速版本没有出现明显的错误,至少没有立即暴露出逻辑上的重大问题。这并不意味着它就是完全正确的,但至少在初步观察中,没有出现致命的排序错误或崩溃。
接下来查看性能分析(Profile),虽然分析中充斥着大量 OpenGL 的调用干扰,但从整体趋势来看,程序的运行速度相比优化前已经有明显提升。这是一个积极的信号,说明优化确实在发挥作用。
接着关闭排序图的绘制,以便更清晰地观察性能表现,尤其关注当前 BuildSpriteGraph 的开销。通过性能图可以看出,BuildSpriteGraph 仍然占用了相当大的处理时间——大约 50% 的总帧时间。这显然还是偏高的,尤其是在追求高效渲染的目标下。
尽管如此,这已经比之前有所改善,说明优化方向是正确的。当前版本虽然尚未达到理想状态,但已经朝着更高效率前进了一步。
接下来还有一些可以尝试的调整和实验,以进一步压缩运行时开销、提高执行效率。因此现阶段虽然不能完全满意,但已经进入一个值得继续优化和推进的良好阶段。
怎么是0%
game_update 100% 有问题
game_render.h:提高 SORT_GRID 的分辨率,运行游戏观察这对 BuildSpriteGraph() 速度的影响
我们想顺便测试一下,当加速排序使用更大、更细分的网格时,性能会发生怎样的变化。也就是说,原来网格是16×9格,我们尝试将网格数量翻倍,变成32×18格。
这样做的目的在于增加网格的单元数,理论上每个单元中需要排序的元素会更少,可能带来更高的效率。实际测试结果显示,整体耗时确实有所降低,性能有所提升。
不过我们也注意到,耗时下降的幅度不像最初优化时那么显著,仍然存在一定程度的平方复杂度表现(n²性质),这表明虽然网格划分更细,但并没有完全消除部分性能瓶颈。
另外,随着网格单元数量的增加,插入元素到单元的操作也会变慢,因为每次插入时需要处理的单元变多,管理和遍历的开销也随之上升。
因此,性能优化存在一个折中点:网格越细,单元中元素越少,有利于减少排序工作量;但单元数量增多,插入和管理成本也随之增加。
目前来看,扩大网格规模带来的性能提升已经足够明显,虽然还有进一步调优的空间,但考虑到当前使用的并非优化过的编译版本,现阶段的表现已经令人满意,不急于做更激进的调整。
game_render.cpp:在 BuildSpriteGraph() 中给 PushStruct() 调用传递 NoClear() 参数
我们知道现在有一些操作可能比较耗费性能,特别是在调试模式下,某些函数调用(比如 push 操作)可能比预期更昂贵,因为调试模式不会优化掉很多代码,这导致这些操作的成本更高。
虽然尝试了一些清理和改进,性能确实有所提升,但整体来说,这个部分依然比较复杂且“繁忙”,代码中还有不少开销存在。
有些想法想要进一步优化,比如减少这个过程中涉及的元素数量,或者试着在构建拓扑排序的过程中就一步完成,而不是分两遍来做。不过目前还没找到特别明确或实用的方法实现这一点,也不确定是否真的需要所有的链表操作或第二遍处理。
总体感觉目前的状态已经足够,暂时不打算做更多激进的改动,觉得可以先停在这里。最终决定放弃旧版本的代码,暂时采用现在的加速版本,继续往前推进。
运行游戏,考虑我们是否在多次检查同样的东西
现在我们面对一个问题,这个问题可以选择解决也可以选择忽略。虽然不知道是否真的需要解决,但这个问题确实存在。之前也提过,如果我们不考虑循环依赖(cycles)的问题,暂时不会带来实际麻烦,尤其是在目前排序算法中对循环的处理比较简单,忽略循环可能不会影响结果。
但是,如果我们需要更合理地处理循环依赖,比如在排序中真正打破循环,避免逻辑错误,那么当前的问题就会变得棘手。这个问题的根源在于,一个元素可能出现在多个网格(grid)中,因此我们不确定某个元素是否已经被访问过或者已经处理过,从而可能多次添加同一个元素,导致循环产生。
虽然在现实中这个问题可能不是很严重,但既然想到了一些比较简单的解决办法,就想先做个演示,展示一下循环检测和处理的效果。之前设置了当检测到循环时,会用亮色标记这些循环的排序组。
观察发现,现在几乎所有的排序组都被标记为循环,说明循环现象非常普遍。这主要是因为多个网格的存在,导致元素多次被添加和访问,从而频繁出现循环。
下一步可以通过画图来说明这个问题的具体情况。
黑板讲解:网格划分导致我们多次检查同一元素的原因
由于现在元素可能存在于多个网格中,比如一个元素A可能同时存在于多个网格单元里。当另一个元素B出现时,B可能跨越多个网格单元。为了找到所有可能与B重叠的元素,必须检查B所在的所有网格单元,否则可能会遗漏只存在于某一个单元的元素C,导致排序时无法正确处理重叠关系。
问题是,当我们根据这些检查结果建立连接(边)时,B在检查第一个单元格时会发现A,在检查第二个单元格时又会再次发现A。由于没有机制来标记已经访问过的元素,程序无法判断这两个A是否是同一个元素,实际上它们是同一个,但程序无法识别这一点。
实现标记访问过的元素,比如使用哈希表来记录已访问的元素索引,是一种方案,但这会带来额外的开销和复杂度。
因此,关键的问题是如何避免对同一对元素重复添加边。第一次遇到A时会添加连接边,而第二次遇到时不应重复添加,否则会产生多余的边,影响排序和性能。
接下来我们需要想办法解决这个重复添加边的问题,确保每对元素的连接只被添加一次,从而减少冗余和错误。
黑板讲解:避免重复检查的两个方案
我们面临的问题是避免重复添加边连接,这里有两种解决思路。
第一种是理论上更完善的方法,比如用树或者哈希表来存储边。这样在添加边的时候,可以查询这个结构,判断这条边是否已经存在,避免重复添加。这种方法结构灵活且严谨,但在实际应用中尤其是需要快速运行的渲染后台,这种复杂的数据结构可能会带来性能开销,导致效率下降,不太适合我们当前的需求。
第二种是希望找到一种简洁、直接且轻量级的“hack”方案,直接在已有的数据结构中进行简单修改,方便操作和维护。目的是能够快速判断是否已经添加过某条边,从而避免重复添加,而不用引入复杂的结构和额外开销。我们希望这个方案尽可能简单,能直接通过已有的数据结构快速查验和更新,保持性能和效率。
总结就是,我们想找到一种轻量且直接的方式,在现有数据结构中轻松判断并避免重复添加边,而不想使用理论上完备但性能和复杂度较高的哈希表或树结构。
讨论如何标记已经被处理过的精灵
我们面对的问题是如何避免在添加边的时候重复检测同一个矩形。解决思路并不复杂,因为数据结构里有两个主要的流程:一个是添加边的流程,一个是遍历边的流程。
在添加边时,我们有一个“节点索引 a”,和一个“条目 b”,在做矩形相交测试之前,我们希望能够判断“如果我还没有检测过这个矩形,且矩形确实相交,那么才添加这条边”。换句话说,我们需要一种方式来标记哪些矩形已经在当前节点索引 a 的检查过程中被访问过。
关键点是,在为节点索引 a 添加边的时候,所检测的矩形对象一定不是 a 自己,因为 a 自己还没被加入网格。这意味着我们检测的对象都是 a 还未访问过的,这为我们标记访问提供了便利。
所以,我们只需要一个标记系统,记录在对某个节点索引 a 的检测中,哪些矩形已经被访问过。这样在添加边的时候,如果发现某矩形已经被访问过,就不重复添加边。
当我们切换到下一个节点索引时,标记需要重置或者更新,以确保新的检测不会被旧的访问记录干扰。
总结来说,方案是利用对每个节点索引的检测过程做访问标记,只要在添加边时检测该矩形是否被访问过,没访问过且相交时才添加边,从而避免重复检测和添加边。
game_render.h:把 sprite_flag 枚举中的现有标志移动到高位,并新增 Sprite_IndexMask
我们目前有一种相对简单的方法可以实现这一功能。现有的 sprite 标志包括 visited
、drawn
、debug box
和 cycle
等,这些标志其实可以上移,不必占用额外的空间。我们可以将它们移至内存的高位区域,比如直接放在上部某个位置。这样一来,底部区域就被完全释放出来,可以用来存储其他数据。
这个底部区域提供了约 24 位的空间,相当于能够容纳约 2400 万个不同的 sprite 索引。如果有更多需求,还可以再向上多用一位。实际上原本的结构可能就没有必要那样设计,我们完全可以重新调整布局。
基于这片释放出来的空间,我们可以将索引值直接嵌入底部的位中。这就让我们能够通过设置一个“索引掩码”(index mask)来屏蔽出我们所需要的位段,然后把这些位作为一个“代数计数器”来使用。这个代数计数器表示当前操作的轮次编号。
当需要检查一个对象是否在当前轮次中被处理过时,只需查看其标志位的底部是否包含当前轮次编号。如果没有包含当前编号,就说明它在这一轮中尚未被处理。这种方式既高效又节省空间,并且可以避免重复处理,从而提升整体的执行效率。
总的来说,通过将不常变动的标志位上移,释放出底部的 24 位空间,并结合掩码和轮次数字的设计,实现了对状态管理和处理状态判断的高效方案。
game_render.cpp:在 BuildSpriteGraph() 和 B->Flags 中使用 Sprite_IndexMask
我们可以这样处理:如果我们获取某个条目,比如搜索中对应的某个节点(例如 B 节点),然后查看该节点的 flags,再将其与我们定义好的索引掩码(index mask)进行按位与运算。
如果按位与的结果不等于当前轮次的索引值,那就说明这一轮我们还没有访问过这个节点。这个判断非常简洁明确,只要发现未访问,就可以直接设置该节点的 flags 为当前的索引值。
在赋值之前,我们还可以加上一条断言,确保我们当前要写入的索引值不会覆盖到 flags 中用于保存其他数据的部分。这条断言的作用是验证:我们用来表示轮次索引的那部分掩码,不应该覆盖掉任何用于存储节点索引的重要数据。只有在节点数量达到了几十亿的规模,才有可能触发这种冲突,而在那种极端情况下,程序本身就会因为性能瓶颈远远无法维持每秒六十帧的运行速度,所以一般来说这并不是一个实际问题。
另外,我们也可以再加一层保障机制:除了上述断言,还可以添加一条新的断言,用来检查在当前算法执行的过程中,没有其他部分尝试使用这些被我们拿来表示轮次的 flags 位。这个断言的意义在于防止将来算法结构发生改变时,出现意料之外的数据写入冲突。
具体做法是:对 flags 取反索引掩码后,再与原 flags 做一次按位与操作,确认这些被排除在掩码之外的位都是空的,没有任何数据被写入。这种方式可以进一步保障数据的完整性和系统的健壮性。
排查一下为什么树木问题
GetGridSpan 函数有问题
触发段错误
运行游戏,看到循环次数大幅减少
通过以上方式,我们得到了所需的标记机制,从而可以实现准确的 cycle(循环)检测逻辑。
我们利用每轮遍历时生成的唯一索引值,将其写入节点的 flags 指定位置,用来标识该节点是否在当前轮次中被访问过。这样一来,每次访问节点时,只需将其 flags 中的索引位与当前轮次索引进行对比,就能快速判断当前节点是否已经被处理过,从而判断是否存在循环。
这种方法具备非常高的效率,不需要额外的空间,也避免了频繁清除标记或重新初始化整个标志数组的问题。只需依赖索引值的递增与掩码匹配,就能确保每轮检测都是独立、准确的。
实际验证时也发现:当真正存在循环时,系统准确地在原先就应该触发的位置识别出了这个循环,表现与期望一致,说明这种机制有效地支撑了循环检测功能的正确性。
目前这一机制已经完成,并且运行过程中表现稳定,正如我们所预期的那样,在处理有循环路径的场景下准确识别,并且避免了不必要的重复访问。整体逻辑清晰,效率高,达到了预期目标。
解释刚刚发生了什么
我们现在的处理逻辑是:在每一轮遍历中,当检测一个特定的 sprite 矩形与其所在区域内的所有其他矩形是否存在重叠或交互关系时,我们会给每一个被检测的矩形打上当前轮次的索引标记。
这样一来,每次我们检查到某个矩形时,只需查看它的标记是否已经等于当前轮次的索引。如果是,说明这一轮已经处理过它,就不需要再次处理;如果不是,则说明这一轮还未检查过,我们就给它打上标记,确保后续不重复处理。
这一技巧的关键在于,我们的检测流程是有结构、有顺序的:我们是按顺序遍历 sprite 矩形,一个个进行区域检测的。在检测某个矩形时,我们只会将它与当前区域中的其他矩形进行比较,并在完成后继续前进,绝不会回头将某个矩形再次作为“主矩形”进行检测。
正因如此,我们只需为每个节点打上一个轮次标记即可,不必为每个节点维护一张“已检测过谁”的完整表格,也不需要记录谁已经检测过它。这大大节省了存储空间和运算时间。
如果我们的算法是完全无结构的,任意两个节点都可能互相检测,那么这种标记机制就无法生效,因为那种情况下必须为每个节点记录所有与其交互过的对象,复杂度会迅速上升,且效率低下。
而现在,我们的结构和流程是固定的、单向的,始终是当前矩形检测它所在区域内的其他对象,检测完成后不会回头。因此我们只需用一个简单的轮次标记就可以完成整个循环检测和状态判断的需求,既高效又简洁,逻辑也清晰可靠。
win32_game.cpp:关闭 GlobalShowSortGroups,运行游戏,看看我们修改后 BuildSpriteGraph() 的速度表现
目前整体状态非常稳定,基本处于一个理想的阶段。排序系统的调试也已接近尾声,最后关闭了相关的调试输出,但从目前的表现来看,排序逻辑运行良好,基本无需再进行额外调整。
从性能分析上来看,尽管这不是一个严格的科学测试,但通过目测观察,之前在没有优化冗余边缘(extra edges)的情况下,每帧耗时大约在 20 到 24 毫秒之间。优化之后,尤其是避免无意义的边缘推入操作(edge push),在 debug 模式下节省了不少处理时间。因为现在不会再推送那些本不需要添加的边,整体流程更加干净高效。
在 release 模式下,尽管边缘推送本身的开销已经很低,因此节省的时间没有那么明显,但这依然是一个值得保留的优化措施。尤其是在 debug 模式下,这项优化意义更大。由于 debug 模式主要用于调试和演示,运行速度直接影响开发体验和教学展示。
我们通常需要在 debug 模式下单步执行、观察状态变化,而很多调试器在处理 release 代码时表现不佳,因此必须保持 debug 模式的可用性和性能。如果 debug 模式运行缓慢,那么对系统的理解和问题分析将非常困难。而通过减少冗余边缘操作带来的优化,现在即使系统处于高负载下,帧率略微下降,也不会掉得太多,整体性能曲线仍然非常可观,即使是在最极端的测试情况下,也依然表现坚实。
下一步准备工作方面,当前可能不适合启动一个特别庞大的新任务。考虑到时间限制,可以着手处理一些较小但仍然有价值的任务,尽量控制在三十分钟内完成。虽然不完全确定能否完成,但这个时间段应该可以处理一些相对独立的小模块或者准备性的工作,为后续的开发打好基础。
黑板讲解:手动排序覆盖(Manual Sort Override)
目前我们进入了一个新的关注点:如何为排序算法提供手动排序覆盖的机制。虽然当前的自动排序系统在很多情况下运行得还不错,但仍存在某些特殊情况,自动排序可能无法满足美术需求,这就需要我们能够手动指定某些元素的排序逻辑,以确保在特定情境下显示正确。
我们目前尚未实际设计这个系统的细节,但有必要提前进行规划。以下是我们面临的问题和潜在方案的详细思考:
背景与动机
我们希望在将来的实际游戏资产中加入更清晰的透视规则与视觉分层。目前的美术资源(尤其是新资源)拥有更强烈的上视角透视,统一性更强,使得排序错误较少发生。但是,这并不意味着自动排序可以在所有场景下完全胜任。
举例来说:
- 某些角色(例如主角)由多个部分组成,例如:头部、身体、手套。
- 在正常动画中,这些部分的显示层级是固定的:手套可能根据动作变化,头部始终在身体上方。
- 由于我们采用的是“伪3D”视角(2.5D风格),这些 sprite 本身是“宽”的(wide sprites),并且在深度上没有真实的 Z 坐标。
- 如果仅靠当前的排序规则,可能会因为 sprite 的位置移动而导致“头部”被错误地排到身体之后,这在视觉上是不可接受的。
问题本质
这个问题的核心在于:
- 排序规则本身是启发式的(hack),不可能涵盖所有情况。
- 某些 sprite 集合(如一个角色的各个部件)从美术上是固定层级结构,而不是依赖动态位置来决定排序。
- 但是,我们仍然希望在某些场景中保留 sprite 的自由排序能力,例如:拳套可以绕到身体前后,造成遮挡效果。
所以我们面对的是一个局部强制排序 + 全局动态排序共存的需求。
潜在解决方案
1. 引入“手动排序覆盖”系统
可以通过某种机制对部分 sprite 或 sprite 组进行手动排序指定。
例如,我们可以定义某个“角色实体”,它拥有一组 sprite:
Head
层级为 2Body
层级为 1Glove
层级为动态排序(自动)
系统在进行排序时,优先根据这些显式指定的层级对这些 sprite 排序。
2. 引入“虚拟 Z 平面”
- 为 sprite 增加一个可选的“Z 平面”参数。
- 类似分层概念:比如
Z=10
是 UI,Z=5
是前景角色,Z=1
是背景角色等。 - 在同一个“Z 平面”中再进行自动排序。
- 这样可以将某些关键 sprite 固定在特定层面,避免位置偏移带来的视觉错误。
3. 小范围内禁用自动排序
- 对某些 sprite 组使用标志位禁止自动排序,而采用它们自身的层级结构。
- 例如:主角身体与头部之间禁用自动排序,但手套依然参与自动排序。
实用意义
这种机制不仅可以解决“主角头部穿插错误”的问题,也可以被应用于:
- NPC群体排序:多个角色并列显示,头部与身体应始终稳定排序。
- 装饰物件:例如,装饰在角色身上的背包、帽子等,应在角色移动时不被错误遮挡。
- 复杂动画表现:例如爆炸过程中,残骸顺序需要手动指定。
结论
我们无法也不应尝试构建一个能自动处理所有排序情况的算法,因为任何通用的自动排序逻辑在面对特殊艺术需求时都不够灵活。因此,加入一个手动排序覆盖机制是必要的。
下一步,我们应当为排序系统设计一个清晰的接口,允许某些 sprite 明确指定其显示层级,在维持动态交互能力的同时,保证关键视觉层次的正确性。这种机制将为后续的角色系统、动画系统、美术迭代提供强有力的支持。
黑板讲解:手动添加边(Edges Added Manually)
我们现在面临一个实现手动排序覆盖的设计选择问题。目前有两个主要的方向可以实现这一目标,我们逐一进行分析和推导。
第一种方式:通过显式“边(edges)”建立强制排序
我们可以在渲染队列填充的过程中,除了插入当前正在使用的 SortSpriteBands
(排序分区),还引入强制排序边的机制。
实现思路:
- 在构建渲染队列时,我们不仅加入普通 sprite,还可以插入一些“边”,这些边表示“这个 sprite 一定在那个 sprite 前”。
- 这些边不需要通过实际的空间交集检测产生,也就是说,不需要靠 sprite 之间的重叠关系来决定顺序,而是我们主动指定的先后顺序。
- 在排序阶段处理这些边时,跳过相应的空间检测和相交计算,直接将边缘关系加入排序图中。
优点:
- 可以精准控制关键视觉元素的排序,比如:角色的头部永远在身体上方。
- 与已有的排序结构兼容,只需在排序图构建阶段加入边即可。
- 可在必要时完全绕过空间检测逻辑,提高稳定性。
难点与挑战:
- 边的管理开销问题:要保证这些“额外边”不会在排序时被重复添加,或被系统的交集检测逻辑“打回”,需要精细的“边缘抑制(suppression)”机制。
- 效率控制:需要确保这类边添加的代价非常低,不能拖慢主排序逻辑。
- 覆盖逻辑的优先级定义:如何在默认交集边和手动边之间协调优先级,避免出现冲突或排序环。
对策:
- 如果我们仅需要每个 sprite 加入 一个额外的强制边,那么整体结构仍然相对简单。
- 如果某些 sprite 需要多个强制边(例如:角色拥有多个身体部件),系统复杂度会快速上升。
- 我们可能需要增加一个标记或边缘类型字段,来指示某些边是“手动指定”的,从而在后续处理时可以跳过自动计算。
小结:
这是一个将“逻辑上的优先顺序”显式建模进排序图的做法。我们将额外的信息(比如“头在身体上面”)通过添加边的方式强制编码到排序系统中。这种方式具备高度的表达力和控制力,但必须注意抑制机制的效率和结构冲突的管理,才能保证性能与稳定性。
紧接着我们还会讨论另一种方式来对比其优劣。
黑板讲解:指定精灵作为一组绘制(Specifying Sprites to be Drawn as a Group)
我们还考虑了第二种方式来实现手动排序覆盖,这种方式的核心思想是:
第二种方式:将某些 Sprite 视为一个整体组进行绘制,组内不参与全局排序
也就是说,我们引入一种机制,使得某些 sprite 在渲染阶段被当作一个不可分割的整体,不再进行单独参与排序。这种方式的基本概念是:
实现思路:
- 定义绘制组(Draw Group),其中包含多个 sprite。
- 渲染系统将整个绘制组视为一个单位进行排序,而组内元素的顺序是预先设定好的,或者是局部排序的。
- 可以允许组内继续进行小范围排序,比如按照 Z 值、层级或其他设定方式,但这一切都限定在组内部,不参与外部排序规则。
优点:
简化逻辑:当我们已经知道某些 sprite 相对顺序永远不变(如:角色的头、身体、手套),就无需在每一帧都重新参与排序。
提高性能:跳过这部分 sprite 的排序计算,可以减少图构建和边缘处理负担。
适用于多种场景:
- Debug 专用层(仅用于调试显示)
- 房间层(Room Layers):这些层通常不会频繁变化顺序,可以分离处理。
- 多层叠加的 UI 元素(界面可分组)
更容易表达艺术需求:例如美术想要某个角色的头一定画在最上面,这种方式天然就可以保证。
挑战与难点:
加速结构问题:我们使用的是网格加速(grid acceleration)机制,该机制默认处理的是统一结构的数据。
- 一旦引入分组机制,我们可能就需要在渲染或排序时跳出网格系统,转而对“组”进行处理。
- 如果组数很多,可能导致网格逻辑复杂化。
多个排序区域并存的问题:引入多个独立排序域(sort zones),意味着我们必须维护多个小排序过程。
- 如果这些小排序逻辑相互嵌套,或者彼此之间有依赖,可能造成管理混乱或 bug。
- 特别是在 debug 模式下,排序行为如果不统一,可能难以追踪。
实现灵活性要求较高:
- 如果未来希望组内可选开启/关闭内部排序,或者根据上下文动态改变组的绘制层级,需要额外机制支持。
对策与可行路径:
- 可以将这种绘制组作为一种逻辑抽象层,而不是渲染层强绑定结构。
- 在渲染流程中,为这些组提供“独立排序域”入口,并标记是否跳过主排序机制。
- 对于组内排序,可以简化为线性结构或局部拓扑排序,保持效率。
小结:
这是一种更加“结构化”的处理方式,适合当我们在逻辑上已经明确 sprite 间顺序且不希望再让排序机制干涉时使用。虽然在和现有网格加速系统集成时可能稍显复杂,但从可维护性、可控制性和性能角度来看,是一个非常有吸引力的选项,尤其适合美术表达优先的场景。
结合之前提出的“添加强制排序边”的方案,这一方案提供了一种更加结构性、稳定性的解决路径。我们可以根据不同情况选择性使用:
- 对于局部偶发性的覆盖,使用手动边更灵活;
- 对于一组 sprite 恒定结构的实体,如角色身体部分,使用“绘制组”更合适。
game_render.h:考虑在 sort_sprite_bound 结构体中添加计数(Count)
我们认为最合适、也最简单可行的方案,可能是对当前的 SortSpriteBounds
结构进行扩展,使其支持“分段绘制”或“成组运行”的能力。这种设计既不复杂,也能很好地配合已有渲染队列的结构。具体分析如下:
当前结构问题分析:
当前的
SortSpriteBounds
结构体显得有些臃肿:- 包含太多数据,一些字段并非核心逻辑所必需;
- 指针结构(如指向其他元素的边)可以考虑换成索引结构,进一步压缩内存;
- 屏幕区域的矩形(rect)数据占用空间较大,或许可与已有的 sort key 信息整合;
整体而言,结构存在一定的优化空间,但尚不至于阻碍功能扩展;
新功能建议:支持“运行段(run)”信息
在排序数据结构中加入“count”字段(数量):
- 当前已有偏移(offset)字段表示开始的位置;
- 若再加一个 count 字段,就可以表示从某个偏移开始的一段连续元素(即一个“运行段”);
- 渲染时可以按照这些“运行段”进行处理,把段视为一个单元,避免组内重复排序;
- 这就为我们提供了一种表达“这些 sprite 是一个不可分割的整体”的方式。
另一可行性方案:引入链式结构(chain)
也可以考虑将渲染条目(Render Group Entry)进行链式连接:
- 比如每个条目中加入一个链表指针或索引,标明下一个属于同一组的条目;
- 从而在渲染阶段可以顺着链表依次处理;
- 这种方式会更灵活,但可能牺牲一些处理效率,因为链式遍历比顺序数组访问慢。
总结优劣:
方案 | 优点 | 缺点 |
---|---|---|
增加 count 字段 | 简洁直观,内存友好,兼容现有结构 | 只适合处理连续片段,灵活性较差 |
引入链式结构 | 支持非连续组,适应性强 | 内存访问不友好,遍历成本高,结构更复杂 |
实现建议:
在实际开发中,可以优先使用“运行段(offset + count)”方案:
- 简单且更贴近现有框架;
- 在大多数需要手动排序覆盖的场景中,这种处理粒度已足够;
若未来遇到必须处理复杂非连续组的需求,再评估是否引入链表结构进行扩展;
小结:
我们可以通过在现有排序结构中引入“运行段”的概念,让某些 sprite 被当作整体进行排序与绘制。这一设计简单、易于实现,也能满足大多数艺术与表现需求。而针对当前结构的冗余问题,也可以考虑后续优化空间,如结构瘦身、指针改为索引、字段整合等。整体上这是一条性价比非常高的路线。
game_render_group.h:尝试在 render_group_entry_header 结构体中添加 NextOffset
我们意识到,当前的 RenderGroupEntry
实际上并没有真正实现为一个 union,虽然它的结构初看像是 union,大家可能也默认写上了头部(header),但在实际使用中我们并没有以 union 的方式使用它,而是选择在数据打包时最后再写入我们需要的字段。这种打包方式避免了传统 union 的一些问题,更加灵活。
结构扩展设想
我们想到,可以在现有的 RenderGroupEntry
中添加一个**“下一条偏移(next offset)”字段,从而建立一种链式渲染结构**。这种方式可以实现:
- 将多个渲染条目串联起来,形成一个固定绘制顺序的链条;
- 绘制时跳过传统的排序流程,直接按链顺序绘制;
- 特别适用于那些我们明确知道绘制顺序,并且不希望它们被排序逻辑打乱的元素。
实现机制
每个
RenderGroupEntry
增加一个next_offset
字段:- 如果为 0,表示没有后续条目;
- 如果不为 0,表示还有一个渲染条目要按顺序绘制,位于
next_offset
所指向的位置;
这就形成了一个渲染条目的链表结构,可被顺序遍历和绘制;
对于链式结构中的条目,跳过 Z 排序或其他自动排序逻辑,完全依赖链表给出的顺序进行渲染;
实现时几乎不影响现有渲染逻辑,只需在遍历队列时判断
next_offset
即可;
应用场景
- 主要用于需要人工指定绘制顺序的 sprite,如人物的“头-身体-手”结构等;
- 适合解决当前 Z 排序容易出现视觉错误的问题;
- 特别适用于位图绘制,因为目前渲染系统大部分绘制对象就是位图;
- 即便不是所有元素都使用,也可以选择性地启用,仅在确有需求的 sprite 上设置非零
next_offset
即可;
结构冗余影响评估
- 考虑到大多数渲染条目本就属于位图渲染类型,所以即便这个
next_offset
字段普遍存在,也不会造成显著结构冗余; - 而且在绝大多数情况下该字段为 0,因此对内存或运行效率影响极小;
- 在极少数需要使用手动排序链条的情况时,这个字段能发挥重要作用。
优点总结
- 实现简单,结构直观;
- 不需要改动现有复杂的排序逻辑;
- 为手动排序需求提供了明确控制力;
- 保持结构统一,避免类型分裂;
- 可拓展性强,未来可以进一步结合排序组(SortSpriteBounds)做更高级的分层策略。
小结
我们可以在渲染条目中增加一个 next_offset
字段,从而实现链式手动排序的机制。这种设计结构统一、逻辑清晰、实现成本低,而且与当前的排序系统兼容性极好。在需要对特定 sprite 实现严格绘制顺序控制的场景下,它是一个非常高性价比的解决方案。
game_opengl.cpp:让 OpenGLRenderCommands() 串联渲染条目
我们计划实现一种渲染条目的链式处理机制,以便在渲染流程中灵活地跳过排序,直接按照我们定义的顺序进行绘制。这一想法的实现逻辑相对清晰,下面是我们目前的完整思路:
渲染链式结构接入点
当前的渲染流程主要依赖 sort entries
,我们通过一个循环,逐个取出条目,然后基于索引获取对应的渲染数据进行处理。现在我们要在这个流程中添加链式机制,即:
- 在每个渲染条目中加入
next_offset
字段; - 渲染时检查该字段,如果不为零,就继续渲染链上的下一个条目;
- 以此类推,直到整个链条被完整渲染。
初步实现策略
- 初始时,我们有
header_offset
,通过索引查表获得第一个渲染条目; - 接下来进入一个
while
循环,每次从header_offset
获取对应条目,进行绘制处理; - 每处理完一个条目,就读取它的
next_offset
,赋值给header_offset
,进入下一个循环; - 当
next_offset == 0
时,表示链条结束,跳出循环;
关于偏移量的使用规范
我们决定保留 offset 为 0 的位置,永远不允许任何链条回到第 0 个偏移位置;
这样可以确保:
0
表示链表终止;- offset 从 1 开始,可以避免很多边界条件问题;
同时也使循环逻辑更加清晰,无需额外判断是否是第一次渲染或特殊处理第一个条目的问题;
对结构的调整
渲染条目的头部结构中新增字段
next_offset
;初始化时设为 0;
当我们明确需要将若干条目组合为一个固定顺序绘制单元时,将其链接在一起:
- 第一个条目指向第二个条目的偏移;
- 第二个条目指向第三个;
- 直到最后一个
next_offset == 0
;
实现优势
- 实现成本极低:只需要在当前渲染流程中添加极少量逻辑代码;
- 兼容性极强:对现有系统没有任何破坏性更改;
- 灵活性极高:只在需要手动排序控制时使用,大部分普通情况不受影响;
- 结构统一:仍然使用统一的渲染条目结构,无需对不同类型的 sprite 做额外区分;
技术细节上的一些优化点
- 由于链式渲染不适合用
for
循环实现(因为 offset 是动态更新的),我们改用while
循环; - 每次循环前检查 offset 是否为 0;
- 循环内部处理绘制逻辑;
- 末尾再更新 offset,进入下一个节点;
- 这种逻辑简单清晰,且易于维护;
小结
我们已经确定了一种低成本、高灵活度的链式渲染机制,通过在渲染条目中引入 next_offset
字段,并在渲染流程中通过 while
循环递归处理链条,即可轻松支持多条目固定顺序绘制的需求。此方案代码层面实现简单,对结构的侵入性极低,同时在渲染系统中保留了更多手动控制的可能性,为后续扩展打下了良好基础。
运行游戏,演示一个需要解决的排序场景,并讨论解决方案
我们目前已经完成了渲染链式结构的搭建,也就是说现在每个渲染条目都可以通过 next_offset
字段连接到下一个条目,实现多个条目的手动绘制顺序控制,而无需依赖排序系统。理论上,所有 next_offset
默认为 0 的条目不会进入链式逻辑,系统保持原有行为。现在进入测试阶段后,我们观察到一些行为和排序不符合预期的情况,尤其是在特定情况下,比如角色的排序明显错乱,违背了我们设定的艺术规则。
问题核心
我们观察到:
- 尽管实际游戏运行中不会遇到,但目前某些构建状态下,角色的排序出错;
- 这些排序错误是由于当前系统仍依赖于 sort key 系统,而不是链式规则;
- 我们不希望继续依赖 z 值或临时 hack 来修复这些视觉错误;
- 更希望使用我们刚刚实现的
offset
链式系统来控制渲染顺序;
接下来的实现方向
目前系统中只支持以带有排序信息的形式添加渲染条目,即每次使用 push_render_element()
都需要附带 sort key
。而这与我们希望手动控制顺序(链式渲染)是冲突的。为了解决这个问题,我们需要:
提供一个新的方式用于推送不参与排序的渲染条目,即:
- 能够添加一个渲染条目;
- 不生成新的 sort key;
- 而是将其链接到前一个条目,通过
next_offset
实现顺序渲染;
初步思路
目前 push_render_element()
是一个包含位置、变换、屏幕区域、排序等信息的完整操作:
我们计划扩展或重载这个函数,增加一个版本专门用于链式追加;
比如增加一个新版本:
push_chained_render_element()
;- 该函数不会生成新排序信息;
- 而是返回当前条目的偏移地址,供后续元素调用并链入;
也可以考虑给现有对象变换结构添加一个成员变量,用于记录上一个条目的偏移量,便于链式追加;
具体实现上,我们可以通过存储最近一次推送的 offset,使得下一个元素可以写入
last_offset.next_offset = new_offset
;
技术权衡点
- 当前结构中还包含了屏幕区域(rectangle)和 sort key,它们不一定适用于链式条目;
- 如果我们决定走链式路线,需要考虑是否允许这些信息为空或保持前一个值;
- 或者我们可以只允许特定类型的条目(比如 bitmap)进入链式通道,这样逻辑上更简单;
- 此外,为了保持系统整洁,尽量不要让链式写入逻辑变得太过复杂,避免未来维护困难;
当前暂停点
我们已经准备好进行链式追加的实际使用,只差最后一块拼图——提供一种将条目追加到现有链条的方法,而不插入新排序信息。这一部分将作为下一步的开发内容。
我们将从:
- 修改或扩展渲染条目的推送函数;
- 为链式结构设计高效的数据追踪方案;
- 并开始测试实际场景下,是否可以仅通过 offset 控制顺序,避免使用 z 值、hack 等手段;
在下一步中继续完成这些逻辑。
总结
当前已经完成渲染链式机制的基础结构搭建。下一步的关键是打通链式推送流程,使我们可以自由控制渲染顺序,无需依赖排序系统,解决诸如角色排序混乱、艺术规则难以保持等问题。整个系统正朝着更高自由度和更强表达力的方向发展。
黑板讲解:为什么把串联放入排序系统中
我们面临一个选择,是否要将连锁排序(daisy chaining)直接放入实际的排序系统中。原因是如果一个角色只有一个排序条目,那么该角色的排序区域会变成所有被使用的精灵区域的并集。这样就会包括大量角色实际上并未占用的空白区域。
举个例子,假设角色形象是一个大身体配一个很小的头部(类似《以撒的结合》里的某些形象),如果只有一个排序键,那么必须将所有精灵区域的矩形合并成一个总矩形。这意味着排序系统会把这个大矩形内的所有内容都认为是重叠的,必须一并参与排序。结果是,所有在这个大区域下的元素都会被误认为和这个角色相关联,从而影响排序的正确性。
因此,有两个方案:
把所有的精灵区域矩形合并成一个总的排序区域。优点是数据结构简单,缺点是区域过大,排序时会误判重叠,导致排序精度下降。
将每个精灵区域矩形分别存储,排序时对每个矩形单独检查。这样排序系统可以准确地对每个局部区域进行排序,而不是用一个大区域覆盖所有。但这种方式会增加排序时的复杂度和计算量,可能影响性能。
另外,即使用同一个排序键,多个小矩形在屏幕上的实际覆盖区域也不相同,这样能保证排序更加精确。选择合并还是分开存储,取决于在排序准确性和计算复杂度之间如何权衡。
总之,这个问题比较棘手,需要在性能和精度之间找到平衡点。
game_render_group.cpp:尝试让 PushRenderElement_() 仅对新元素排序,否则合并已有条目
我们在处理渲染元素的推入(push render element)时,有机会决定是否执行命令排序条目(command sort entry)的相关操作,也可以选择不做。关于深度标签(DebugTag)是否应该存在,目前倾向于让它存在这里。
在具体实现上,推入缓冲区元素计数(push buffer element count)的增减有些疑问,尤其是不确定是否可以不递增该计数。推入缓冲区元素计数主要用于统计需要排序的元素数量和对应的矩形数目。这里似乎存在一个潜在的笔误,当前用的是推入缓冲区元素计数,但其实更合适用的是裁剪矩形计数(clip rect count),因为裁剪矩形数量才是反映被裁剪区域的实际数量,用来进行排序更合理。排序功能大概是唯一真正用到这些计数的地方,因此可能以后应该重命名这个计数变量以便更清晰。
当添加新元素时,需要判断是否是新元素:如果是,就新增相关信息;如果不是,则合并已有的排序键和屏幕区域。具体来说,对于屏幕区域,这个合并操作就是计算当前屏幕区域和新加入区域的并集。对于精灵边界(sprite bound),因为通常是通过最小值和最大值确定区域边界,也需要进行类似的合并,取最小的起点和最大的终点。
同时,在实现连锁(daisy chaining)时,需要设置头部的下一偏移量(header next offset),这个偏移量对应的是当前命令推入缓冲区的大小。最后,在完成操作后,要更新现有的元素,使其状态反映最新的合并结果。
总体来看,整个流程是:
- 判断是否要添加新元素或者合并现有元素。
- 对屏幕区域和精灵边界进行并集或边界合并。
- 处理链表结构中的下一偏移量,以实现元素间的链式连接。
- 更新现有元素的状态。
这套操作确保了排序时能正确处理复合区域的排序键和显示区域,同时也保留了灵活性,以避免冗余或错误的排序计数。
编译并正确运行
现在的进展是,系统已经接近完成,能够顺利编译并正确运行,目标是在明天或隔夜完成所有调整和测试,确保整体功能稳定。这意味着目前的实现方案已经基本完善,剩下的工作主要是验证和优化,确保没有编译错误,运行时表现符合预期。整体情况良好,为接下来的使用和部署打下了坚实基础。
问答环节
感谢网格划分,我们现在能否用 SIMD 方便地做图排序,比如一次处理四个网格或四个格内精灵?还是没那么简单?
关于利用网格分区来进行图形处理或者一次处理多个精灵的内嵌方块,直觉上确实可以实现类似的批量操作,但实际上情况并没有那么简单。
首先,虽然从理论上讲,过去也能实现一次处理多个矩形检测(比如4个一组),但我们通常不会这样做。原因在于这类优化往往导致代码变得非常脆弱和难以维护,尤其是在C、C++这类语言中,更难写出既高效又健壮的代码。汇编语言虽然更底层,但同样不方便管理复杂的数据结构。虽然有人尝试用自动向量化(auto vectorization)技术来解决这个问题,但目前的技术水平和工具链仍不足以让这种优化成为常态。
数据本身的组织方式比代码更关键。要发挥向量化的优势,需要将数据以特定结构存储,使得批量处理变得高效。例如,如果想加速之前的n²矩形测试,如果把数据按4个一组进行分桶,批量处理4个矩形的边界(比如最小最大X和Y值),确实可以实现一次性批量检测。理论上,这种处理在数组中更容易实现。
但是,现实中网格分区的填充过程使得批量处理变得更加复杂。网格分区本身就增加了填充的开销,因为每个网格桶(bucket)通常只包含很少的精灵,比如8个左右,有时更少。这样批量处理时效率并不高,因为许多桶并没有被填满,造成大量计算资源的浪费。
相比之下,旧的方法中虽然写入次数多,但大部分桶是满的,处理更连贯,效率反而更高。也就是说,网格分区虽然有助于减少不必要的碰撞检测,但在批量处理上的潜力被其稀疏性限制了。
总结来说,虽然可以利用网格数据结构来做类似批量图形处理或碰撞检测,但实现起来不简单,主要因为:
- 代码和数据结构会变得复杂和脆弱,容易引入bug。
- 当前主流编程语言和工具链对这种优化支持不够友好。
- 处理稀疏数据结构时,批量处理效率下降。
- 性能提升未必显著,尤其是在现有运行速度已能满足需求的情况下。
因此,通常不会为了这类优化而牺牲代码的稳定性和可维护性。只有当实际遇到性能瓶颈时,才值得考虑采用这类复杂优化。期待未来有更好的编程语言和工具能简化这类高性能编程任务,但目前还没达到那个阶段。
关于标志和顺序检查,如果用 flags & id 来判断是否已经处理过,当另一个元素也做相同检查且 & 同一个(改变过的)单元,会不会导致判断结果不同?我理解错了吗?还是先用 mask & flags 清除了旧的 id 后再设置?
关于标志位(flags)和顺序检测的问题,特别是在用ID来判断是否已经处理过某个元素时,存在一个疑问:当另一个元素执行相同的检查,并且与同一个已改变的单位(changed unit)进行按位与(AND)操作时,结果是否会不同于检查标志和质量(mass)是否等于新元素的ID?是否遗漏了某些关键步骤,或者在设置ID之前,是否先清理了之前的标志和质量?
具体来说,代码中关注的是用标志和精灵索引掩码(sprayed index mask)进行按位与操作,并判断结果是否不等于零,来决定是否继续处理某个索引元素。这里涉及到的检查重点是矩形区域是否重叠,但这部分其实与防止重复处理的代码关系不大。
为理清思路,需要逐步分析这段代码的每个部分,尤其是“be flags 和 sprayed index”的按位与操作的意义是什么,以及这些操作如何帮助判断是否已经访问过某个元素。整体思路是通过标志位和索引掩码的位运算,避免重复访问和处理相同的元素。
目前还不完全确定是否理解了全部问题,但通过一步步梳理代码逻辑,可以更好地确定答案,确认是否有遗漏或者逻辑上的混淆。
黑板讲解:标志和 Sprite_IndexMask 的工作原理
我们这里讨论的是一段使用标志位(flags)和掩码(mask)来标识和检测元素是否已经处理过的机制。核心思想是将一个字段划分为两个部分:高位用于存储标志位,低位用于存储某种索引值或ID。通过位运算,我们可以灵活地提取或更新其中任意一部分。
首先,我们有一段按位与的操作,即:
(NodeIndexA & Sprite_IndexMask) == NodeIndexA
这个操作的目的是:从整个 flags 字段中清除高位的标志部分,只保留底部的索引部分,然后用它来和目标索引 NodeIndexA
进行比较。所用的掩码是一个低位为全1、高位为全0的掩码(即只保留索引部分)。
虽然这么做在功能上是安全的,但实际上,在当前上下文中这步操作并非严格必要。原因在于:在这段代码之前,我们从未对高位的标志部分做过设置,因此这些高位本来就是 0,所以即使不做这一步,比较的结果也是正确的。这里加入按位与只是出于谨慎性和代码整洁性的考虑。位运算代价极低,所以即便多做一次也不会影响性能。
此外,我们还将整个 b_flags
直接设置为 NodeIndexA
,而这里可能本应保留高位标志,但目前没有这么做,这意味着这段代码是写得稍微不一致的。上面特意做了掩码提取以获得纯索引值,但这里却没有掩码保留之前的标志部分,而是整个替换掉了 b_flags
。在更严谨的实现中,可能应先清除低位索引部分,再用按位或(OR)的方式合并新索引值,从而保留原有标志位。
另外,这里提到的一些断言(assert)并不是核心逻辑,只是用于在调试期间验证代码假设是否成立,比如确保某个值为0或某个状态已经被设置,用于增加安全性和可读性。
总结:
- 使用掩码是为了从复合字段中提取出低位索引部分。
- 当前情况下不一定需要掩码,因为高位标志部分没有被使用。
- 尽管掩码操作冗余,但由于其性能代价极小,保留它是为了代码更安全和清晰。
- 后续对
b_flags
的赋值可能略显粗糙,若想保留高位标志,应该分步骤地清除并合并。 - 整体设计体现了用位运算实现状态与索引信息复合存储的思想,但需要在读写时非常小心,避免逻辑错误。
这种方式虽灵活高效,但也带来了实现复杂度和潜在的维护成本。
game_render.cpp:稍微简化 BuildSpriteGraph()
我们在这里处理的是一个避免重复处理矩形对的优化逻辑,主要依赖于一个 flags
字段,用来记录每个矩形是否在当前迭代中已经被访问过。之前我们使用掩码 & mask
是为了从 flags
字段中提取低位索引部分以做对比,但实际上,这个掩码在当前情况下是完全不必要的。
我们通过断言(assert)已经确保这些高位用于标志位的部分最初是清零的,也就是默认没有设置任何标志。因此,直接比较 flags != currentIndex
就足够了。做掩码操作虽然性能几乎不受影响,但会让代码变复杂。更合理的方式是在逻辑入口通过断言确保 flags
的预期状态,然后用最简单直接的方式进行比较,既提高可读性又避免冗余运算。
进一步地,我们在处理过程中,给每个访问到的矩形打上当前遍历编号 currentIndex
,标记它已经被处理过。由于每次处理都是按顺序对 indexA
扫描一次,因此我们只需要判断目标矩形的 flags
是否等于当前的 indexA
,如果不是,就说明我们还没有处理过这个组合,可以继续判断重叠等信息;如果是,就跳过,避免重复判断。
有趣的是,我们在实现中无意间避免了一个潜在的 bug:
- 初始化时,所有的
flags
默认值是 0。 - 而我们从
indexA = 0
开始遍历。 - 如果不仔细考虑,很容易导致第一个遍历误以为其他矩形已经被访问过了(因为它们的
flags
也为 0)。 - 但实际上,在遍历 indexA 为 0 时,这些矩形尚未被插入网格,所以根本不会参与判断。
- 因此,这个本可能导致错误的“误判”为已访问,因为数据还未加入,所以实际上没产生问题。
换句话说,我们选取的“默认标志值”和实际网格构建过程中的顺序刚好错位得很好,因此虽然设计中存在潜在逻辑漏洞,但在运行时恰好没有暴露出问题。这是一种“幸运的无 bug”情况。
总结:
- 去掉了对
flags
的掩码处理,因为断言已经保证高位为 0。 - 用直接比较
flags != indexA
替代复杂掩码逻辑,更高效也更清晰。 - 利用
flags = indexA
来记录是否处理过,避免重复判断。 - 由于初始值为 0,我们在 indexA 为 0 时看似会出错,但实际上不会,因为那个阶段没有其他矩形存在。
- 这是一个逻辑上有隐患但在当前流程下不会触发的“伪 bug”,值得后续在重构时更严格地处理初始化与遍历逻辑。
这类设计说明在复杂系统中,即使代码运行正常,也要特别注意初始化状态与数据流程间的隐性依赖,防止未来修改时引入难以发现的错误。
我们会在游戏上做网络编程(比如多人联机)吗?
我们不会在的项目中进行网络编程。
这一点已经明确表态,未来也不会加入任何相关的内容。我们完全专注于本地系统的实现,例如渲染、输入处理、音频、平台层、游戏逻辑等方面,而不会涉及联网功能的开发。也就是说,不会设计多人联机、网络同步、在线通信、客户端-服务器架构等相关机制。
这一决定确保我们可以将精力集中在低层系统、性能优化、引擎结构等基础领域的深入探索上,同时避免网络编程带来的复杂性和调试难度。最终目标仍然是构建一个完整、可运行的单机游戏框架与引擎,而非网络化的多人游戏系统。
我喜欢你说“我们不想用 hack 解决这个排序问题”,你觉得引擎应该覆盖绝大多数边界情况,还是极端情况下可以用“边界 hack”?
关于引擎是否应涵盖所有边界情况或是否可以在极端情况下采用边界性“hack”的问题,我们认为:
在具体情况中,我们可能不会对某些特定排序条件进行“hack”,这通常是出于特定理由,比如保持通用性或避免引入复杂性。然而,从更广泛的角度来看,整个2D排序系统本身就是一种“hack”。
在实际场景中,如果我们真正想要实现准确的可视层排序,单靠2D sprite 排序是做不到的。现实中物体是三维的,因此没有完整的三维对象支持,任何排序系统都不可能完美应对所有情况。换句话说,2D排序本质上就是一种近似、妥协——一种为了简化而采取的“hack”。
例如,如果两个对象从视觉上看应该是一个遮挡另一个的,但它们的排序关系无法通过简单的Y轴或Z值判断,那我们就处于需要“作弊”的地步。理论上,解决这类问题需要对对象之间的空间结构有完整理解,也即是要构建一个3D场景理解和遮挡关系系统,但那远远超出了大多数2D引擎的目标和实现复杂度。
因此,我们接受并理解在渲染系统、排序系统中存在“hack”的必要性,但我们也强调,在特定情况下不对某部分进行hack,往往是为了维护代码的稳定性、简洁性或避免引发连锁反应的问题。这是一种在灵活性和系统健壮性之间的权衡。
总结如下:
- Sprite排序本身就是一种妥协手段;
- 彻底解决排序问题需要3D对象支持;
- 不能期望引擎处理所有边界条件完美;
- 极端情况下可接受适度“hack”;
- 但我们会在某些关键地方避免“hack”以维持系统的通用性与清晰性。
这一理念贯穿整个架构设计。我们既重视结构清晰,也理解现实中必要的技术妥协。
黑板讲解:二维排序时缺乏必要信息的问题
我们明确认为,在当前所使用的2D或2.5D渲染体系中,排序本质上就是一个“hack”(权宜之计),因为我们缺乏做出真正正确排序所需的关键信息。
举个最简单的例子:假设我们有一个表示金字塔的 sprite,然后一个人物站在某个位置。那么问题来了——他到底是在金字塔前面,还是在后面?
答案是:如果我们没有金字塔的真实三维深度信息,也没有人物的精确深度数据,我们根本无法判断他应该是在金字塔的前面还是后面。
特别是,如果人物稍微平移一个位置(例如从金字塔侧面移动到正前方),遮挡关系就可能完全改变,而 sprite 本身的矩形边界并不会反映这种遮挡变化。
也就是说,在这种情况下,即使两个 sprite 的边界矩形几乎完全一样、相互重叠的方式也类似,我们仍然无法正确排序,因为我们没有三维数据。这就暴露了核心问题:
2.5D 渲染中的排序逻辑完全是基于人为规则的妥协,本质上就是“hack”。
因此,我们只能够制定一套“合理”的规则,使之在特定条件下看起来“差不多正确”。我们目前实现的系统正是朝这个方向前进的结果。我们从来不认为当前系统是某种完美、高洁、无懈可击的引擎架构,只是做了一个符合目标场景的、有针对性的、工程上合适的解决方案。
目前我们的排序算法虽然不是极致优化,但整体还算合理:
- 通过空间划分(partitioning)规避了 O(n²) 的暴力比较;
- 图遍历只需一次;
- 可以正确处理循环依赖;
- 整体效率、健壮性都达到了不错的水平;
- 不是一坨糟糕的实现,但也没有自诩为“最终形态”。
所以我们秉持的态度是:
- 整个排序系统就是一个hack,这无法避免;
- 我们不会去“hack”那些可以通过通用方式优雅解决的问题;
- 对于无法彻底解决的情况,就制定一套能跑、合理的规则,然后认真实现;
- 在极端情况下,“hack”是允许的,但要明白这不是理想方案,只是权衡后的现实选择。
归根结底,我们是在一个无法完美解决的系统内,尽可能构建一个行为合理、性能良好、易于维护的机制,而不是追求某种不存在的完美引擎抽象。