游戏引擎学习第301天:使用精灵边界进行排序

发布于:2025-05-22 ⋅ 阅读:(20) ⋅ 点赞:(0)

回顾并为今天的内容做准备

昨天,我们解决了一些关于排序的问题,这对我们清理长期存在的Z轴排序问题很有帮助。这个问题我们一直想在开始常规游戏代码之前解决。虽然不确定是否完全解决了问题,但我们提出了一个看起来合理的排序标准。

有两点不确定:第一,我们不能百分之百确定这个排序标准总是适用于所有情况,尽管我们尽量考虑了大多数可能遇到的情况,但也可能有未想到的特殊精灵情况。第二点则是关于排序算法本身的问题。即使排序标准是合理的,它也可能不会产生一个完全一致的排序结果,也就是说排序标准不一定能保证产生一个全序或部分有序的关系。这样,使用常规的排序算法,比如归并排序,可能无法正确解决排序问题。

我们计划用一个简单但开销较大的N²算法,在调试场景下检查排序结果。具体做法是排序完成后,逐对比较所有精灵,根据排序规则确认排序是否正确。如果发现有任何排序顺序错误,就说明确实遇到了无法用简单排序解决的问题。

这种情况并不致命,只是意味着需要使用更复杂的图论方法来解决排序问题。昨天提到的 Andrew Russell 写的博客中讲了类似的情况,他在对《River City Ransom Underground》的精灵排序时,发现只能用带有语义理解的图排序方法,因为存在不可排序的情况。

虽然目前还不确定是否必须用图排序,但如果必须用,情况也还算好,因为可以解决问题。只不过我们更倾向于用更简单的排序方法,比如归并排序,因为图排序可能效率更低。虽然我们还没有具体测试图排序的性能,但一般情况下简单排序会更快。

总之,目前我们已经写好了排序代码,代码可以编译,但运行时发现排序功能并没有被实际调用,程序仍在执行旧的排序代码,这不是我们想要的效果。接下来会继续调试和测试,看看排序在真实场景中的表现是否符合预期,是否会遇到上述两种潜在问题。

game_sort.cpp:仔细检查 IsInFrontOf() 函数的方向是否正确

今天的主要目标是继续完成排序逻辑的最后一部分。排序系统基本已经写好了,不过还剩下一个需要处理的问题。此前有个名叫 Steven(或四分之一Tron)的观众在聊天室里指出了一个潜在的错误。他提醒我们在处理两个都是Z类型的精灵时,可能忘记翻转某些符号方向。

具体来说,Z类型精灵是指带有范围边界的精灵。问题涉及的判断逻辑是:当两个精灵都不是“扁平”的,也就是说它们的 YMinYMax 不相等时,我们就认为它们都是Z类型的精灵;如果其中一个的 YMin 等于 YMax,那么它就不是Z类型。

最初的逻辑是:如果两个精灵的 YMin 不等于 YMax,那它们就是Z类型;否则就是一个是Z类型,一个不是。回头看这段判断逻辑,还是觉得是正确的。因此也不确定聊天室里提到的错误是否真的存在。暂时认为这个逻辑没问题,但也不排除可能忽略了某些细节。

此外,在这过程中还出现了一个技术问题——键盘暂时失灵,导致操作受阻,但很快恢复了正常。

总之,现在的重点就是继续推进,保持代码逻辑清晰,必要时结合聊天室里的反馈进一步修正排序判断的边界条件。后续会继续观察排序系统在实际运行中的表现,确保所有Z轴相关的排序处理都能正确执行。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_sort.cpp:考虑如何免费实现分层排序,并将整个渲染器转变为基于精灵边界的排序

我们已经完成了我们需要的归并排序(merge sort),现在它可以对 sort_sprite_bound 类型的数据进行排序。不过这个过程当中有一些有趣的细节值得深入思考,虽然暂时不确定是否要马上利用它们。


当前排序的数据结构

除了 YMinYMaxZZMax 等用于排序的字段之外,每个 sort_sprite_bound 还带有一个 Index 字段,这个字段指向我们最终要绘制的对象。


引入“虚拟平面”的想法

我们突然意识到一个潜在的优化思路:
可以人为插入一些“虚拟平面”,作为图层的分隔标志,从而实现图层内自动排序。

  • 假设我们插入一个拥有“无限 Y 区间”的虚拟物体,它在 Y 轴上会始终被认为是一个 Z 精灵;
  • 由于它没有实际绘制内容,只存在于排序阶段;
  • 那么在排序过程中,它就可以作为一个“层分割线”,将所有 Z 值在其下方或上方的元素分组;
  • 通过这种方式,我们在排序的同时,也顺便将内容分隔成了不同的图层。

当我们在之后处理排序结果、依次“退役”渲染指令时(即开始真正绘制),只要检测到这些“虚拟平面”即可知道是否到了新的图层。

这是一种聪明的技巧:用归并排序的规则自动实现图层划分,无需额外的图层管理逻辑。


现有排序系统中的处理

我们当前的排序系统是用于多种用途的,比如用于显示性能计数器时也要用排序。
所以,我们不应该破坏原有 sort_entries 的那部分逻辑,它仍要继续保留。

接下来我们的计划是:

  • 把整个渲染器的排序方式切换为基于 sort_sprite_bound
  • 将所有需要绘制的内容推入 sort_sprite_bound 列表中;
  • 借助排序规则自动实现合理的前后关系和图层分隔。

实际挑战:数据准备

实现排序不难,真正的挑战在于:我们现在并没有准备好所有必要的排序信息

我们需要确定每个图形元素的:

  • YMin / YMax:表示它在屏幕上的垂直覆盖区域;
  • Z / ZMax:表示其深度范围,用于在 Z 精灵中判断前后关系;

特别是我们常用的“直立卡片”类型的元素(如角色等),它们是竖直的、站立在地面上的,可以忽略 Y 区间的跨度,仅需设置一个 YMin == YMax 的值。

这样做对我们的排序规则非常友好,因为它会优先按 YMin 排序,省去了复杂的计算。

而对于一些“地面瓷砖”类型的元素,它们是平躺的贴图,有实际的面积覆盖:

  • 我们确实知道它们的大小,因为地面砖块通常都是单位格;
  • 所以我们可以准确地设置它们的 YMin / YMax,从而参与到更复杂的排序中。

理想情况

如果一切顺利,我们只需要为这些“地面元素”设置正确的边界信息,然后对所有元素调用一次排序函数即可得到正确的绘制顺序,无需额外的图层机制或特殊处理。

这会大大简化渲染流程,而且运行效率也较高。


下一步计划

  • 替换渲染器使用 sort_sprite_bound 的方式;
  • 插入实际的渲染指令;
  • 逐步调试排序结果是否正确;
  • 如果必要,引入“虚拟平面”作为图层隔断;

这是当前的分析与计划。接下来就要开始动手处理这一切了。

game_render_group.h:考虑完全从 entity_basis_p_result 中去除 SortKey

我们现在的进展是这样的:


当前渲染流程的结构:Render Group

我们正在使用的渲染系统中,主要依赖一个叫 render_group 的结构来推送所有的渲染指令。而我们以往在这个过程中,使用的是一种叫做 SortKey 的机制来排序可见实体(entity basis)和相关的基础绘制结果(basis result)。

但是随着我们切换到使用 ZMaxYMin/YMax 为核心的新排序机制,旧的 SortKey 方案将不再适用


可能不再需要 SortKey

从现在的排序规则来看,我们实际上只关心 ZMax 值,这是排序判断“谁在谁前面”的核心因素之一。这个值理论上并不需要进行复杂变换:

  • 它只需要相对于摄像机的位置
  • 但其实即便不做这个偏移,也可能不影响排序
  • 因为我们排序是相对的,只要所有值在同一个空间里,排序顺序就仍然是有效的。

因此,如果我们足够幸运,可能可以完全移除 SortKey 相关的逻辑,在 entitybasis result 等结构中都不再使用它。这样会让代码变得更简洁、逻辑更清晰。

不过我们也不能太早下结论,现在还不能确定这个设想是否完全可行,只能说这是一个很好的可能性。


接下来的目标

暂时不去管是否真的能完全摆脱 SortKey,我们还是把重点放在:

  • 将新的排序逻辑正式集成到渲染器;
  • 确保所有实体和绘制项都能正确地填入 ZMaxYMin/YMax 等关键数据;
  • 替代原来的排序流程;
  • 检查整体排序是否正确地反映了视觉前后关系。

我们将以此为方向,继续推进渲染系统的重构和测试。

game_render_group.cpp:让 PushRenderElement_() 接收 sort_sprite_bound 而不是 SortKey

我们目前正在重构渲染系统的排序部分,把原本使用的 SortKey 排序逻辑,全面切换为基于 sort_sprite_bound(精灵边界)的新排序方法。以下是我们这段时间处理的核心内容与思路:


将 SortKey 替换为 Sprite Bound 排序逻辑

之前的做法是,在推送渲染元素(Render Element)时,会生成一个 SortKey,并将其作为 sort_entry 存储,用于后续排序。

现在我们不再使用 SortKey,而是使用结构体 sort_sprite_bound,它内部包含了更丰富的空间信息:

  • YMin, YMax: 精灵的垂直边界范围;
  • ZMax: 精灵的深度排序依据;
  • Index: 实际渲染命令在 push buffer 中的位置。

我们开始在代码中替换所有以 sort_entry 命名的逻辑为使用 sort_sprite_bound


渲染流程中的调整

在实际操作中,我们发现我们并没有在太多地方使用 sort_entry,这使得替换的复杂度没我们预期的那么高。

  • 我们定位到具体调用 PushRenderElement 的地方;
  • 然后修改逻辑,使其推送 sort_sprite_bound
  • 此结构比原来的 sort_entry 稍大(大约两倍),但仍然在可接受范围内。

Push 过程的关键细节

我们注意到 sort_sprite_bound 中的 Index 成员并不应该由调用者填充,而应由 PushRenderElement 内部计算并设置:

  • 因为只有 PushRenderElement 知道渲染命令在 buffer 中的偏移量;
  • 所以在调用方传入时,只需要传入 YMin, YMax, ZMax 等排序信息;
  • Index 字段应在 push 时自动设置,确保其准确对应实际的渲染命令位置。

这就带来了一个小问题:我们不能直接传入完整的 sort_sprite_bound 实例,因为其中的一个字段(Index)是由函数内部控制的,而不是由调用者决定的。


设计上的权衡思考

这让我们开始思考是否需要调整结构设计:

  • 是继续保留当前设计,在外部构建 sort_sprite_bound,再在 push 时覆盖 Index?
  • 还是将 Index 从结构中剥离,让排序信息和缓冲区位置分开管理?

目前还没有定论,但我们倾向于保留结构的完整性,只是在使用时注意内部字段的控制归属。为了简化接口与使用,未来也可能考虑封装成构造函数或者构建器来创建这个结构。


当前目标

  • 用新的 sort_sprite_bound 结构完全替代旧的排序逻辑;
  • 将所有 PushRenderElement 逻辑改为支持新结构;
  • 确保 Index 正确反映渲染命令位置;
  • 在后续排序时使用新结构进行排序(YMin/YMax/ZMax)。

我们已经完成了主要的结构替换工作,接下来将开始在实际数据流中测试并调试这些变更。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_sort.cpp:将 sort_sprite_bound 分离成 sprite_bound,并让 IsInFrontOf() 接收 sprite_bound

我们正在进一步优化渲染排序系统的结构与接口设计,目的是让排序相关的数据结构更加清晰、解耦,并减少潜在的优化器混淆或调试性能损耗。以下是我们目前的思路和调整内容:


结构设计上的优化思考

当前我们在推送渲染指令时,需要提供三个核心值:

  • YMin:精灵底部位置
  • YMax:精灵顶部位置
  • ZMax:用于深度排序的最大 Z 值

之前我们使用 sort_sprite_bound 结构体来打包这三项加一个 Index,但由于 Index 是在 push 渲染时才生成的,而不是调用方能确定的,所以我们在传参时会面临一个逻辑上的割裂。

为了解决这个问题,我们引入了一个新的中间结构,例如:

struct sprite_bound {
    float YMin;
    float YMax;
    float ZMax;
};

然后,我们在推送函数中传入的是这个结构而不是完整的 sort_sprite_bound。在内部再由系统自动设置 Index 值,构造最终的 sort_sprite_bound,用于排序和渲染调度。

这种做法的好处在于:

  • 接口更简洁:调用方无需知道 Index 的存在;
  • 结构更清晰:把用于排序的信息和控制信息分离;
  • 更易优化:让编译器更容易理解数据结构的只读特性。

接口重构与调用优化

由于我们现在传入的是一个简单的 sprite_bound 类型值:

  • 所有使用 IsInFrontOf() 比较函数的地方也要相应更新;
  • 不再使用包含 Index 的完整结构去判断谁在前谁在后;
  • 只需要在比较时传入两个 sprite_bound 即可。

例如:

bool32 IsInFrontOf(sprite_bound A, sprite_bound B);

传值(pass-by-value)而非指针或引用的形式也带来潜在好处:

  • 编译器可以明确知道这些值在函数内部不会被修改;
  • 避免潜在别名(aliasing)问题;
  • 有助于更好的内联优化(尤其是在 Release 模式下)。

虽然这可能在 Debug 模式下稍微降低性能(由于结构复制),但整体来看,在 Release 编译中能带来更高的效率和更稳定的优化行为。


旧逻辑的迁移与简化

我们同时对旧代码中使用 sort_key 的部分进行替换或标记:

  • 将原来的 sort_key 替换为新的 sprite_bound
  • 在推送逻辑中构建最终结构并写入渲染命令缓冲区;
  • 在排序前操作中,完全使用新的边界数据结构。

总结

我们目前的策略是在系统中引入一个更清晰、更职责单一的结构(如 sprite_bound),作为排序用的传参值,从而:

  • 降低使用复杂度;
  • 避免不必要的冗余字段传递;
  • 减少编译器优化上的不确定性;
  • 提升后续排序、渲染调度的可维护性。

未来我们还可以考虑进一步封装排序规则,让调用层几乎不需要理解排序细节,只需关注其渲染表达的语义。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_sort.cpp:考虑传给 PushBitmap() 的正确值

我们现在已经完成了排序逻辑内部的调整,使其接受新的排序键 sprite_bound,并在合并和单元素比较的两个关键环节都替换为传入该结构体。这一变化使得整个排序流程本身准备就绪,可以从外部正式切换过来使用新的机制。但是,接下来要面对的问题是,原先所有调用排序的渲染接口现在都无法正确工作,因为它们仍旧传入的是旧式的“排序键”(如简单的 sort key 值),而我们现在需要的是 包含 YMin、YMax 和 ZMax 的完整边界信息


外部渲染调用的问题

主要问题集中在类似 PushBitmap() 这样的接口调用上:

  • 原先调用这些接口时只传入了一个 Z 值或简化的 sort key;
  • 现在排序系统需要完整的边界信息(YMin/YMax/ZMax)才能完成排序;
  • 所以原有的调用点都“失效”了,必须重新构造这些值。

推测边界信息的思路

当前我们并没有完整的边界数据可用,只能根据已有信息推断,比如:

  1. 对象是否为“直立型”(upright)

    • 这个信息我们可以从对象的 transform 结构中获得;
    • 如果是直立型精灵(例如人物立绘),我们可以假设它们是垂直延展的,Y 方向范围很小;
    • 如果不是直立的,而是“贴地面”的(如地板、道具等),那么它们会在 Y 方向上有一定的宽度。
  2. 位图的尺寸

    • 通过 bitmap 的高度和宽度,我们可以估算它在 Y 方向上的投影范围;
    • 将此尺寸应用在物体位置上,推算出 YMin/YMax;
    • ZMax 的推算可以保持与原有逻辑一致,甚至可以直接使用对象 transform 的 Z 值。

拟定临时方案测试效果

我们准备尝试一种折中的临时处理办法:

  • 使用对象 transform 中的“upright”字段来判断 Y 方向的延展方式;
  • 如果是直立型,我们可能设置一个极小的 Y 范围(或统一的默认值);
  • 如果是平铺型,我们根据 bitmap 尺寸推导出一个 YMin/YMax;
  • 然后将这些值组成 sprite_bound,传入新的排序系统。

这不是绝对精确的方法,但足以用于尝试渲染流程是否能正常运作。我们会根据实际运行效果来判断这个方法是否值得保留或进一步细化。


总结

目前的目标是将外部渲染调用过渡到新的排序系统。面临的问题是缺乏完整的排序所需边界数据。我们提出了以下策略应对:

  • 利用 transform 中的“upright”字段来初步区分排序方式;
  • 使用位图尺寸推算 Y 方向的边界值;
  • 尝试这种方法后观察排序与渲染是否合理,后续再逐步精细化处理边界估算逻辑。

这是从系统结构向实际数据落地的关键一步,也是排序系统彻底统一的前置条件。后续会继续调整 bitmap 渲染等接口,确保每个调用点都能提供正确的边界信息。

game_render_group.h 和 *.cpp:从 entity_basis_p_result 中删除 SortKey

我们现在明确了一点:原有的 sort key(排序键)系统已经彻底作废,因为我们现在采用的是新的排序机制,基于 sprite_bound(包含 YMin、YMax、ZMax 的结构体)来完成排序。因此,所有依赖旧 sort key 的逻辑都可以直接删除,整个代码中与 sort key 相关的部分都将被移除。


清理旧逻辑

首先清除掉:

  • 所有从 DIM Basis(如 entity basis)传递 sort key 的过程;
  • PushBitmap 或其他渲染接口中设置 sort key 的操作;
  • 在排序和渲染流程中依赖 sort key 的判断或处理逻辑。

这些旧逻辑都不再有任何作用,统一删除。


新逻辑的核心依赖:upright 状态

新的排序方式依赖的是 sprite_bound 的三个值:YMin、YMax 和 ZMax。为了正确生成这三个值,我们需要知道一个关键属性:

当前位图是“平面贴地”还是“垂直竖立”?

这个信息由 object_transform 中的 upright 字段给出,这是我们目前判断物体朝向的唯一依据。

  • 若为 upright = true,表示该对象是竖直朝上的,比如人物立绘、招牌等;

    • 此时我们可以忽略 Y 范围,直接使用 ZMax = transform.Z
  • 若为 upright = false,表示该对象是铺地的,比如地砖、地面物品等;

    • 这时必须设置 YMin/YMax,我们将通过 bitmap 的尺寸来推算;
    • ZMax 仍然可以直接取 transform 的 Z 值。

如何将 upright 正确用于排序构建

我们目前的问题是:upright 字段存在于 object_transform 中,但未必在所有地方都方便读取。所以我们面临一个设计上的抉择:

  • 是否应该把 upright 提取出来作为 PushBitmap 的一个明确参数?

    • 这可以使排序判断逻辑更加明确清晰;
    • 避免未来 object_transform 内部结构变动带来的耦合问题。

虽然我们还没做最终决定,但可以明确一点:**无论放在哪里,upright 的信息是不可或缺的。**因为排序逻辑离不开它。


总结

我们完成了以下几个关键步骤和思考:

  1. 旧 sort key 全面作废,相关代码将被清除
  2. 新的排序机制依赖 sprite_bound,必须构造出 YMin/YMax/ZMax
  3. 构造方式依赖物体的 upright 属性,必须保证其在调用 PushBitmap 时可以获取到
  4. 未来可能需要调整接口设计,将 upright 明确作为参数传入,而不是隐含在 transform 中

这一步已经为我们清晰了外部调用排序逻辑的入口条件,接下来的任务就是在所有调用点确保 sprite_bound 能够被正确构造。这样整个渲染系统才能正式迁移到新的排序机制上。
在这里插入图片描述

在这里插入图片描述

game_render_group.cpp:让 PushBitmap() 为 Upright(直立)精灵设置 SpriteBound.YMin 和 .ZMax

在当前的排序逻辑重构过程中,我们面临两种情况:竖直物体(upright)平铺物体(lying flat)。我们分别讨论了它们在构造 sprite_bound(YMin、YMax、ZMax)时的差异。


对于竖直物体(upright)

这类物体的高度决定它在 Z 轴上的可见性,但在 Y 轴上并不需要参与排序。

  • ZMax:直接取 transform 的 Z 值即可;
  • YMin/YMax:可以设为任意默认值,因为在排序中不使用。

这是一个理想情况,计算简单,不容易出错,因此我们已经实现了这部分逻辑并通过测试。


对于平铺物体(lying flat)

问题正好相反:

  • ZMax:确定且简单,直接使用 transform.Z,因为这类物体“贴地”,Z 值不会随着尺寸发生变化;
  • YMin/YMax:困难的地方在于我们不知道这个 sprite 的 Y 向投影范围。

虽然我们大致知道这个 sprite 是围绕某个 Y 值居中渲染的,但缺少精确数据,不知道这个 sprite 在 Y 轴上实际“占了多少空间”。


临时解决办法

我们决定采用一种简化策略,虽然它可能不准确,但可以作为临时手段以验证整体流程是否可行:

  • YMin = transform.y - 某个“向后”偏移(假设值);
  • YMax = transform.y + 某个“向前”偏移(假设值);

这个偏移量的计算方式依赖 sprite 的宽高信息或尺寸参数,但目前我们并未做精确计算,只是粗略模拟。

我们意识到:

  • 这种方式不能覆盖所有情况
  • 很可能在游戏后续运行中,某些 sprite 会因为排序错误而出现穿插、遮挡等视觉问题;
  • 等整体系统跑通之后,我们需要回头对这部分逻辑进行精度提升,可能要读取 sprite 的真实边界、处理非居中渲染、旋转缩放等复杂因素。

下一步任务

  1. 继续推进 PushBitmap 调用链中 sprite_bound 构造的适配;
  2. 临时使用上述方式填充 YMin/YMax;
  3. 等排序稳定后,再评估哪些错误是由不准确的边界计算造成的;
  4. 再反过来优化这部分逻辑,引入更精细的边界推断方法。

总结

我们为平铺物体临时设计了一种不准确但可运行的 sprite_bound 生成逻辑,确保整个新排序系统能跑通一遍。虽然它会带来一些排序误差,但这样可以快速验证结构正确性,等验证通过后再逐步替换为更精确的算法。这是一个典型的“先跑起来再精细化”的迭代策略。
在这里插入图片描述

在这里插入图片描述

game_render_group.cpp:讨论 SpriteBound

我们已经完成了构造 sprite_bound 的逻辑,现在可以直接将其传递到后续的排序流程中去,整个排序路径可以顺利运转了。下面是我们对逻辑的进一步整合和优化,详细说明如下:


初步统一逻辑处理

我们识别出一些公共部分,可以简化处理流程,减少重复代码,提高可读性和可维护性:

  1. ZMax 值始终是 transform 的 z 分量
    无论是竖直还是平铺的 sprite,ZMax 都是基于 transform.z 进行计算,因此我们将这部分代码抽离出来,统一处理。

  2. YMin / YMax 的条件注入

    • 如果是平铺(lying flat),我们会为 YMin 和 YMax 添加推导的范围;
    • 如果是竖直(upright),则跳过 Y 轴的范围计算,只用 ZMax 即可。

这种分离式处理结构更清晰,逻辑也更直观。


核心计算逻辑结构

我们在代码结构上形成如下模式:

// 统一处理 zmax
sprite_bound.zmax = transform.offset.z;

// 判断物体朝向并决定是否处理 y 范围
if (is_upright) {
    // 竖直物体:Y 范围无关,仅设置 zmax 即可
    // YMin/YMax 保持默认值或不处理
} else {
    // 平铺物体:需计算 y 范围
    sprite_bound.ymin = transform.offset.y - backward_extent;
    sprite_bound.ymax = transform.offset.y + forward_extent;
}

这样的逻辑可以清晰区分不同的 sprite 类型,并提供基础的排序依据。


下一步可扩展方向

虽然当前的逻辑可以支撑排序逻辑的正确性,但我们也意识到有不少地方仍存在提升空间:

  1. 对偏移量 forward_extent / backward_extent 的来源进行精化
    目前我们是基于 sprite 尺寸估算的,需要根据实际 bitmap 尺寸或 bounding box 来精确推导。

  2. 支持更复杂的变换
    未来如果引入旋转、非等比缩放等情况,当前的基于 transform.offset 的方式将不再足够,需要矩阵乘法等更复杂的处理。

  3. 统一封装 Y/Z 范围构造
    可以进一步封装为一个辅助函数,根据 sprite 的朝向、尺寸等信息自动构建完整的 sprite_bound


总结

我们完成了将 sprite_bound 构造逻辑合理地嵌入渲染流程的改造,同时抽离出 ZMax 的统一计算和 YMin/YMax 的条件处理逻辑,从而形成了清晰可控的结构。这为后续排序系统的准确性和性能优化打下了良好基础。后续重点在于精化边界估算逻辑,使排序更加精准。
在这里插入图片描述

在这里插入图片描述

game_render_group.cpp:考虑让 PushRect() 做和 PushBitmap() 相同的计算,并让 Clear() 排序在所有内容下方

我们现在推进到处理 push_rect 的部分。从逻辑上来看,我们判断这部分很可能可以复用与 push_bitmap 相同的排序边界计算代码。虽然目前还不确定完全适配,但初步猜测是可以的,所以我们决定尝试用相同的计算方式处理。


push_rect 的处理思路

我们推测 push_rect 也能沿用 push_bitmap 中的 sprite_bound 构造方法,即:

  • zmax 统一设定为 transform.offset.z
  • 根据是否“竖直”或“平铺”来决定是否添加 Y 方向上的 ymin / ymax
  • 采用和 bitmap 一样的判定方法来处理 rect 的排序逻辑

这意味着我们有机会提取出一段共享代码来统一处理所有此类“可排序可绘制元素”的边界计算。


clear 操作的排序策略

接下来处理的是 clear 操作,它并不是真正的 sprite,但我们依旧希望它能“出现在最底层”,也就是被排到所有内容之前渲染。因此我们需要构造一个特殊的排序边界来达到这一目的。

具体做法:

  • 构造一个“虚拟”的 sprite_bound,其值如下:

    • ymin = REAL32_MIN
    • ymax = REAL32_MAX
    • zmax = REAL32_MIN

这样的设置方式可以确保 clear 排序值永远小于其他任何内容,因此会被排在最前面,达到“清屏在先”的目标。

这是一个非常简单粗暴但有效的策略。


接下来目标:提取共享逻辑

由于我们现在已经有多个地方(如 push_bitmappush_rectclear)都需要构造 sprite_bound,所以我们下一步的目标是提取一段共享的代码逻辑来统一处理这部分计算。

大致目标如下:

  • 编写一个辅助函数,比如 ComputeSpriteBound(transform, size, is_upright),统一处理 Y/Z 方向的边界计算
  • 对于 clear 操作,单独使用特定的常量值(不通过函数),以避免冗余计算

这样我们就能避免重复书写大量的逻辑分支,提高系统的整体清晰度与健壮性。


小结

  • push_rect 有望复用 push_bitmap 的边界构造逻辑
  • clear 操作通过构造极端值实现最底层排序
  • 下一步目标是提取出共享的 sprite_bound 构造逻辑,统一用于所有绘制操作的排序准备流程

整体来看,这是一种渐进式演进思路,先统一策略,再逐步抽象与优化。
在这里插入图片描述

game_render_group.cpp:引入 GetBoundFor() 并将 PushBitmap() 的功能抽取到其中

我们现在开始将构造排序边界(sprite_bound)的逻辑进一步抽象成一个可复用的函数,以便在不同渲染路径中(比如 push_bitmappush_rect)共享。


目标:提取出 GetBoundFor 函数

我们要做的,是将构造 sprite_bound 的逻辑提取出来,形成一个统一的工具函数,比如叫 GetBoundFor。这个函数用于根据传入的参数生成用于排序的边界信息。

所需参数分析如下:
  1. object_transform.offset_p
    对象的位置,是排序中 Z 值和 Y 值计算的基础。

  2. upright 标志位
    用来判断当前图像是竖立的(如角色或树木)还是平铺的(如地面贴图),这影响 Y 和 Z 的排序方式。

  3. offset
    图像的偏移量,在计算边界时需要加入用于精确定位。

  4. height
    图像的高,用于计算 Y 向或 Z 向的扩展边界范围。

只要这四项有了,就能精确地生成 sprite_bound


调用方式及用法示例

函数调用形式如下:

sprite_bound = GetBoundFor(offset_p, upright, offset, height);

调用中,我们传入:

  • 当前的偏移位置 offset_p
  • 是否竖立的布尔标志 upright
  • 图像偏移 offset
  • 图像高度 height

然后函数内部会判断是“平铺”还是“竖立”,从而使用不同的逻辑构造 ymin/ymax/zmax


应用到 PushRect 中

push_rect 的情况下,只需要把 height 设置为 Y 维度大小即可。因为 push_rect 通常就是用来画二维矩形,而我们系统中的 Y 是垂直方向,所以它也符合之前的建模方式。

因此可以直接写成:

sprite_bound = GetBoundFor(transform.offset_p, upright, offset, rect_dim.y);

补充说明:清理旧代码

随着这个函数的引入,我们可以彻底删除之前在 push_bitmappush_rect 等路径中重复的排序边界构造代码,统一通过 GetBoundFor 处理。


总结

  • 成功抽象出 GetBoundFor 方法,用于统一构建 sprite_bound
  • 函数依赖四个参数:位置、是否竖立、图像偏移、高度
  • 适配了 push_bitmappush_rect
  • 提升代码复用性和可维护性,后续如果排序逻辑要调整,也只需改动一个地方

下一步,我们可以继续观察运行结果是否符合预期,若发现某些排序仍不对,就再调整 GetBoundFor 的具体实现逻辑即可。
在这里插入图片描述

在这里插入图片描述

运行游戏并迅速遇到大量问题

我们目前遇到的问题是,虽然在构建排序键(sort key)时做了结构上的改变并完成了代码更新,但渲染器并不知道我们已经更改了排序键的格式。这会导致运行时崩溃或产生严重渲染错误。


问题核心

渲染器依旧使用旧的 sort_key 格式去解释和处理渲染命令。而我们已经将 sort_key 替换为一个新的结构(比如 sprite_bound),并用它来进行深度和顺序上的排序。

但是,在 render 过程中,渲染器仍然:

  • 从渲染命令中读取 sort_key
  • 使用旧的结构去解析数据(比如使用了旧的字段、类型转换等)
  • SortEntries 时将内存内容强制转换为老式结构体

下一步调整方向

我们必须同步更新渲染器中的排序逻辑与结构定义,以匹配我们更新后的 sort_key 结构。

首先,定位到渲染器中的排序逻辑:
  • 找到 SortEntries 相关函数
  • 查看它是如何处理每条渲染命令的
  • 重点检查它是否对 sort_key 使用了固定偏移量、结构体强转或硬编码解读方式

例如,可能看到如下逻辑:

sort_entry = (sort_entry_type *)command->sort_key;

我们需要把这一类逻辑替换成基于新结构的访问方式。

然后,更新 sort_entry 结构定义

如果我们将 sort_key 替换为一个包含以下字段的新结构:

struct sprite_bound {
    float ymin;
    float ymax;
    float zmax;
};

那么渲染器中排序函数就应当改为:

sprite_bound *a = &command_a->sort_key;
sprite_bound *b = &command_b->sort_key;

if (a->zmax != b->zmax)
    return a->zmax < b->zmax;
else if (a->ymax != b->ymax)
    return a->ymax < b->ymax;
else
    return a->ymin < b->ymin;

注意这里必须确保排序逻辑保持一致,避免前后代码不对齐导致逻辑错乱。


小结

  • 渲染器仍然使用旧格式解析排序键,导致严重问题
  • 必须更新渲染器中关于 sort_key 的访问逻辑和数据结构
  • 要确保排序函数匹配我们新的排序边界结构体(sprite_bound
  • 所有基于旧结构体的强制转换、偏移操作都需要移除

更新完成后,渲染系统才能理解新的排序键格式,整体渲染才会恢复正确排序逻辑并正常运行。

game_sort.cpp:引入 GetSortEntries() 用于将 Entries 转换为 sort_sprite_bound

我们正在对渲染系统中使用的排序键结构进行结构性重构,以适配新的 sprite_bound 类型。在处理过程中,发现了代码中存在大量直接对排序键内存进行类型转换(cast)的做法,这种方式在我们更新排序结构之后将变得非常危险,因为一旦结构体发生改变,所有这些显式转换都会默默失效,导致渲染逻辑出错,且很难追踪和维护。


🚩 主要问题

目前大量地方通过如下方式读取排序键数据:

sort_entry = (sort_entry_type *)command->sort_key;

这种做法的问题在于:

  • 对排序结构的更改不具备可追踪性
  • 所有调用处都必须手动更新
  • 易错、难以维护
  • 无法形成统一的数据访问通路

✅ 解决方案

我们将这部分逻辑提取为统一的接口函数,避免在多个调用点重复手动转换结构,提高可维护性与健壮性。

🧱 实施步骤如下:
  1. 定义统一的访问接口函数

    在渲染器公共模块中新增函数(例如在 sort.cpp 中):

    sprite_bound* GetSortEntries(game_render_commands *commands) {
        return (sprite_bound *)commands->SortMemory.Base;
    }
    

    此函数明确指定返回类型为 sprite_bound*,并隐藏了具体的类型转换过程。

  2. 替换所有旧的直接类型转换调用

    将所有类似以下的代码:

    sprite_bound *entries = (sprite_bound *)commands->SortMemory.Base;
    

    替换为统一调用:

    sprite_bound *entries = GetSortEntries(commands);
    
  3. 统一在各个渲染路径中使用新接口

    包括:

    • 通用渲染器路径
    • OpenGL 渲染路径
    • 可能存在的调试或测试路径

    我们已经在通用路径和 OpenGL 路径中完成替换。

  4. 构建错误传播链以验证完整性

    替换之后,编译器将帮助我们发现所有仍然使用旧方式访问排序键的位置,借助这些错误信息,我们可以确保整个代码库中排序键的访问方式全部统一。


总结

  • 排序键结构体更新后,不能再通过直接强制转换方式访问

  • 应将所有访问封装进统一的函数接口中

  • 函数接口具备以下优势:

    • 自动适配结构更新
    • 提高代码安全性
    • 错误定位更清晰
    • 更易维护与重构
  • 当前已完成替换并确保各路径同步更新

随着这个结构封装完成,我们的排序逻辑访问就更加稳固,后续再做结构调整也会变得轻松许多。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_render.cpp:让 SortEntries() 调用 GetSortEntries(),不再调用 RadixSort(),改用 MergeSort()

我们还有一个地方没有正确调用新的接口,那就是执行排序本身的部分。现在排序调用的仍然是旧的 sort_entries 概念,而不是我们新引入的 sprite_bound。如果当初在这里就调用了正确的封装函数,那么一开始编译就会报错,能立刻暴露问题。这也是我们进行接口封装的意义所在:确保结构发生变化时,编译器能帮我们发现所有错误使用的地方。


排序部分的更新与适配

  1. 停止使用 Radix Sort

    当前的排序逻辑原本依赖于 Radix Sort,但由于我们新设计的 sprite_bound 结构体是复杂的浮点值结构,不再是适合 Radix 的整数位字段形式,因此:

    • Radix Sort 完全不适用
    • 必须改用 Merge Sort
  2. 改用 Merge Sort 实现

    在实际排序代码中已经更换为 Merge Sort,并更新相关调用,确保它使用的是我们新的 sprite_bound 结构:

    void Sort(sprite_bound *Entries, sprite_bound *Temp, uint32 Count) {
        // Merge sort implementation...
    }
    

    为此,我们需要为 Temp 分配等量空间,用来辅助排序操作。

  3. 同步修正内存分配处

    在渲染初始化或排序所需内存分配中,原本是基于 sizeof(sort_entry) 进行内存分配:

    NeededMemorySize = MaxSortEntryCount * sizeof(sort_entry);
    

    这必须改为:

    NeededMemorySize = MaxSortEntryCount * sizeof(sprite_bound);
    

    否则即使逻辑正确,分配的内存不够也会导致数据越界或崩溃。

  4. 将大小定义和访问统一封装

    为了统一后续处理,可以考虑将排序项的类型与大小统一封装为函数或宏,比如:

    inline size_t GetSortEntrySize() {
        return sizeof(sprite_bound);
    }
    
    inline sprite_bound* GetSortEntries(game_render_commands *Commands) {
        return (sprite_bound *)Commands->SortMemory.Base;
    }
    

    所有关于排序项类型和大小的调用都改用这些封装,确保修改结构体时可以集中修改逻辑。


总结

  • 已完全废弃 Radix Sort,改用 Merge Sort,以适配复杂排序键结构
  • 所有 sort_entry 使用点替换为 sprite_bound
  • 对内存分配中的类型和大小做了同步修正
  • 排序接口调用已封装为统一函数,减少未来维护成本
  • 通过编译器错误实现错误定位闭环,提升可维护性与健壮性

通过这一系列的结构调整和接口统一,我们的排序系统现在更加稳健、灵活,能够适应不同结构体下的渲染排序需求,为后续功能拓展和调试提供了良好基础。
在这里插入图片描述

在这里插入图片描述

game_render.cpp:将 GetSortEntries() 从 game_sort.cpp 中提取出来,并引入 GetSortTempMemorySize()

我们在这里要做的,是为排序内存的大小分配增加统一的查询函数,比如 GetSortMemorySizeGetSortTempMemorySize,这样在 Win32 层的代码中,就不需要手动硬编码去计算排序内存的大小,而是可以通过调用这个统一的函数来获取准确的数值。这么做的原因并不复杂,只是希望将相关操作的逻辑尽量聚集在一起,避免因改动某一个地方而遗漏另一个地方,从而导致错误。


目标

我们要实现的,就是在结构层级中,创建一个获取排序内存大小的接口函数,使得任何地方在需要知道排序内存大小时,都可以调用这个函数而不是自己计算。


做法细节

  1. 引入内存大小接口函数

    比如我们定义以下函数:

    size_t GetSortMemorySize(uint32 ElementCount) {
        return ElementCount * sizeof(SpriteBound);
    }
    

    或者对临时排序缓冲区也有:

    size_t GetSortTempMemorySize(uint32 ElementCount) {
        return ElementCount * sizeof(SpriteBound); // 假设一样大
    }
    
  2. 用于统一调用

    在 Win32 渲染初始化代码或排序所需内存配置处,就不再是:

    size_t NeededSize = ElementCount * sizeof(SpriteBound);
    

    而是:

    size_t NeededSize = GetSortMemorySize(ElementCount);
    
  3. 方便未来结构变化

    如果以后 SpriteBound 的结构体变化,或者内存排列方式发生变动(比如多一个对齐字段、加了 padding、变化为指针等),我们只需修改 GetSortMemorySize() 函数内部的实现,其他调用此函数的地方完全不需要修改。


整体意义

我们这样做的核心目的,并不是追求复杂的功能,而是追求一致性和可维护性:

  • 改动结构时自动联动:通过统一的接口,任何结构变化都只需修改一个地方。
  • 提高可读性:调用者更容易知道这里的内存是做什么用途的(例如排序),不用再去猜 sizeof(...) 的意图。
  • 避免重复逻辑:减少冗余代码和硬编码。

总结

我们增加了统一的排序内存大小接口,意在:

  • 绑定内存结构和调用逻辑的关系
  • 提高代码维护时的连贯性
  • 避免因结构更改导致遗漏更新的潜在 bug

操作本身很简单,但能极大提升项目规模变大后的可维护性,是一个典型的架构优化策略。

在这里插入图片描述

win32_game.cpp:让 WinMain() 调用 GetSortTempMemorySize()

我们接下来要做的事情,是在需要排序操作的地方统一调用 GetSortTempMemorySize 来获取所需的临时排序内存大小。我们把这种逻辑统一封装起来,意味着今后在任何地方需要计算排序内存时,都不再手动处理,而是调用标准接口,保证一致性和可维护性。


实施步骤详解

  1. 统一替换内存大小计算逻辑
    将所有之前用来计算排序内存大小的地方,例如:

    size_t NeededSize = ElementCount * sizeof(SpriteBound);
    

    统一改成:

    size_t NeededSize = GetSortTempMemorySize(RenderCommands);
    
  2. 实现 GetSortTempMemorySize 函数
    该函数内部会从渲染命令中提取元素数量,然后乘以排序所需的数据结构大小:

    size_t GetSortTempMemorySize(GameRenderCommands* Commands) {
        return Commands->SortEntryCount * sizeof(SortSpriteBound);
    }
    
  3. 整理冗余代码
    原先散落在各处的排序内存分配代码可以删除或重构,比如临时数组的空间分配、类型转换等。


影响范围覆盖

  • OpenGL 渲染器:确保在排序前使用统一的内存大小函数。
  • Win32 平台下的渲染逻辑:内存分配阶段也使用新接口。
  • 排序实现逻辑(如 MergeSortSpriteBounds):调用这个函数保证输入参数合法。
  • GameSlow 或调试渲染路径:这部分如果也涉及排序内存,同样适配。

可预期的进一步修改

在继续推进过程中可能会发现以下情况:

  • 有的模块根本没有走通这条路径,可能是死代码或者走了其他后门逻辑;
  • 有的排序函数内部并未真正使用类型安全方式访问排序内存(例如强转未统一);
  • 某些旧路径直接写了 sizeof(...),没有调用 GetSortTempMemorySize(),需逐步替换。

最终目的

整个重构的目的并不复杂:

  • 提高排序结构和渲染流程之间的耦合性可控;
  • 降低维护成本,未来排序结构一旦变动(比如排序字段变化、尺寸变动、类型升级),只需改动一处;
  • 减少手动计算、强制类型转换、复制粘贴代码等易错操作;
  • 保证渲染器中所有路径(主流程、OpenGL、调试路径)一致性。

总结

我们现在正将排序所需内存的处理逻辑集中封装到 GetSortTempMemorySize 这类接口中,逐步淘汰所有手动计算与冗余逻辑,使得未来无论渲染流程怎么扩展或排序逻辑如何复杂化,我们都能通过统一入口点进行维护和升级。这是一种非常有效的工程实践,虽然短期内修改面略大,但长远来看将极大提升代码质量与稳定性。

在这里插入图片描述

在这里插入图片描述

game_sort.cpp:将 SortEntries() 标记为 TIMED_FUNCTION()

我们现在正好处于一个非常合适的测试点,恰好可以验证排序逻辑的准确性,特别是“是否在前方(is in front of)”这个判断逻辑是否在各种情况下都能成立。


当前测试目的

我们的目标是验证排序系统在所有相关对象上的“前后”逻辑是否准确,也就是:

确保对于所有应该在前方的对象,is_in_front_of 函数都返回 true

这类验证在 game_slow 之类的调试或慢速执行路径中尤为重要,因为可以精确观察渲染顺序的错误。


执行的具体内容

  1. 测试“我的头”是否在其他元素前方
    举例来说,像“我的头”这个精灵,应该被排在地面元素或身体之上。这就意味着对应的排序判断逻辑必须能识别出其位置在前。

  2. 验证 is_in_front_of 逻辑在各种数据上的正确性
    包括:

    • 平面精灵(如地面砖块)
    • 立起的精灵(如人物、物体)
    • 特殊对象(如 HUD 或 UI 元素)
  3. 调试构建中的意外表现
    有一个情况令人惊讶,就是在 debug 模式下并没有出现期望中的报错或异常。这说明可能存在一些函数逻辑在调试构建下被优化掉了,或者断言未触发。


后续行动

  • 运行调试版本观察输出:通过手动或自动方式检查哪些对象被错误排序;
  • 明确是否所有路径都调用了 is_in_front_of:有可能部分对象走了旧路径或未被加入排序;
  • 回溯 debug 构建未报错的原因:例如检查是否缺失了断言逻辑、类型转换判断、nullptr 检查等;
  • 利用当前测试场景增强单元测试:将当前视为基础测试用例,逐步扩展场景(如遮挡、嵌套对象、非标准 transform)。

总结

我们正处于验证排序逻辑核心的阶段,利用现有场景可以高效测试 is_in_front_of 的正确性。尤其是像“我的头在什么前面”这类逻辑,可以作为非常典型的判断用例。如果这类基础排序都不能确保准确,那将影响整个渲染管线的视觉正确性。通过集中测试这部分逻辑,同时检查 debug 模式下的意外表现,有助于我们及时发现并修复渲染排序系统的核心问题。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

调试器:单步进入 SortEntries() 并检查 EntryA 和 EntryB 的 SortKey 值

我们现在正深入调试排序系统的问题,目标是定位为什么某些精灵在渲染顺序中出现错误。通过逐步进入排序比较函数,开始具体分析两个参与排序的精灵条目的数据。


目的:验证排序比较逻辑是否符合预期

我们希望检查排序时比较逻辑是否在某些情况下未按我们想要的行为执行,尤其是“ZMax 值较大的精灵应被排在后面”的规则。


调试过程和观察结果

  1. 初步测试失败
    最开始尝试比较两个精灵条目时,发现它们完全相同(YMinYMaxZMax都一样),这类情况下排序结果无法判定优先级,即排序稳定性不保证顺序——这种情况跳过处理。

  2. 加入断言以缩小排查范围
    于是我们添加了判断逻辑:

    • 若两个条目的 YMinYMaxZMax 全相等,则跳过;
    • 若有差异,则进入断言,确认我们是否确实在处理排序顺序有误的精灵条目。
  3. 成功抓到一个排序异常样本
    在某一对条目中:

    • 两个精灵都是立起的(YMinYMax 相等);
    • 然而其中一个的 ZMax 为 1.25,另一个为 0.75;
    • 根据逻辑,ZMax = 0.75 的条目应排在前,但实际 ZMax = 1.25 的条目却被排在前,这表明排序行为有误。
  4. 排序状态分析

    • 当前比对的索引为 70;
    • 表示在前 70 个条目中排序都是正确的;
    • 这一条目是第一个排序错误的个例;
    • 从而可知这个 bug 不是系统性全错,而是局部在特定数据下触发。

关键问题分析

  • ZMax 作为深度优先排序参考值未被正确比较
  • 排序逻辑可能忽略了部分排序关键字或比较函数实现不符合预期
  • 排序算法本身(如 merge sort)没有问题,但排序函数的比较逻辑存在缺陷

后续修正方向

  1. 检查排序比较函数是否严格实现了“ZMax 小者优先”逻辑
  2. 确认所有排序输入项在排序前已正确填充(YMin, YMax, ZMax)
  3. 为边界情况(如精灵条目完全相同)添加稳定性保障逻辑
  4. 扩展测试用例,专门测试排序的不同情况(同高、同位、不同位)
  5. 考虑调试工具中增加详细的排序比较跟踪输出,便于快速发现其他潜在错误

总结

通过断言与逐步调试,我们确认排序逻辑在某些特定情况下未正确执行,具体表现为精灵的 ZMax 值未被当作排序关键优先级,导致视觉错误。这类排序错误可能是极偶发的,但在视觉上影响较大,因此必须尽快修正排序比较函数的实现,确保所有参与排序的字段都被严格处理。此轮调试也说明对排序逻辑的测试覆盖需进一步加强,特别是针对“排序相等”与“排序不等”的边界情况。

game_sort.cpp:用 #if 0 注释掉 SortEntries() 中的 game_SLOW 代码,运行游戏并无任何异常

我们当前面对的问题是,在渲染约 2000 个精灵时,屏幕显示为空白,这意味着排序或渲染过程中存在严重错误。考虑到目前排序数据混乱、难以逐一调试所有精灵,我们决定采取更简化、可控的方式来逐步排查问题。


当前目标

让排序和渲染逻辑在可控的小规模场景中运行,以便我们能观察排序效果,并验证排序是否按照 Z 轴(深度)正确进行。


调整策略与计划

  1. 放弃使用 2000 个复杂的测试精灵
    当前测试场景中的精灵数量太多,视觉上混乱,排序验证困难,同时调试复杂,效率极低。

  2. 简化输入:使用离散层级的数据场景
    计划切换到类似“过场动画”一类的场景。这类场景通常只需要按照 Z 值进行排序,精灵数量有限,分层清晰,易于观察排序效果。

  3. 过场动画的优势:

    • 排序只依赖 Z 值,无需考虑 YMin / YMax
    • 精灵位置稳定,且视觉布局清晰;
    • 是理想的验证排序系统是否基础正确的起点。
  4. 下一步行动:

    • 修改当前测试入口,加载一个过场动画场景;
    • 检查该场景下的排序输出是否按照 Z 值正确排列;
    • 确认渲染是否成功显示预期精灵顺序;
    • 若显示正常,则逐步引入更复杂的测试数据结构(如混合排序条件);
    • 若依然显示为空白,则需要进一步排查渲染管线本身的问题。

小结

当前的大规模精灵排序场景过于复杂,不利于调试。我们决定先采用精简、结构明确的过场动画场景作为验证排序系统正确性的切入点。在该场景中,只需确保精灵按照 Z 值渲染即可,验证通过后再扩展至复杂场景。这是更科学、更高效的调试思路,有助于快速定位问题根源并逐步恢复渲染正常。

在这里插入图片描述

game.cpp:让 GAME_UPDATE_AND_RENDER() 只播放过场动画

我们现在回到项目中,准备切换到一个更简单、更易调试的场景——过场动画(cutscene),用以验证排序逻辑是否正确运行。


当前目标

强制程序在启动后直接进入过场动画模式,便于我们在更少干扰、数据明确的条件下调试渲染与排序系统。


操作步骤与验证流程

  1. 查找并确认入口设置位置:
    当前推测修改是在某段代码里设置了启动场景为 cutscene,只是记不清具体在哪个函数内。
    我们重新定位代码段并确认该逻辑是否生效。

  2. 强制进入 cutscene:
    找到该逻辑后,通过直接赋值或跳转方式,程序在启动后将直接跳入过场动画模式,跳过主游戏流程。

  3. 运行程序,确认结果:
    运行游戏后,观察是否如预期那样直接加载并进入 cutscene。

  4. 成功标志:
    若界面上正确渲染出预期的过场动画内容(如角色、对话背景等),说明切换成功,当前环境适合我们进行排序调试。


小结

我们已经找到并确认了强制程序进入 cutscene 的代码位置,并成功启动了一个只涉及离散 Z 层级的过场动画场景。接下来可以在这个简单环境下调试 sprite 排序逻辑,验证渲染是否符合 Z 值排序规则。如果一切正常,将为更复杂场景提供稳定的基础验证参考。
在这里插入图片描述

在这里插入图片描述

调试器:中断 SortEntries(),确认 IsInFrontOf() 和 MergeSort() 的方向正确

我们现在正在对合并排序(merge sort)与前后判断逻辑进行逐步核查与调试,目标是确保在过场动画(cutscene)中 sprite 的 Z 轴排序是正确的。


核查“是否在前方”的判断逻辑

  • 检查 IsInFrontOf() 函数,语义是 “A 是否在 B 的前面”。
  • 对于按 Z 轴排序的情况,若 A 的 ZMax 大于 B 的 ZMax,说明 A 更靠近观察者,应排在前方 → 返回 true。
    逻辑正确
  • 若是按 Y 轴排序(即从上到下绘制),判断方向应相反 → 此处代码也处理了这种情况。
    方向处理无误

检查合并排序逻辑

  • 合并排序会递归地对数组划分两半,然后合并时对两半的首项进行对比:

    • 若“第二半的首项应在第一半前方”,则交换两者顺序。
    • 否则保持当前顺序。
  • 合并阶段也判断当前读取的是哪一半:

    • 若只剩一边,直接输出;
    • 若两边都有数据,则使用 IsInFrontOf() 决定先写入哪一边。
  • 这些逻辑在代码中被清晰实现,判断条件也符合预期。
    合并排序整体逻辑正确


Y 坐标绑定判断

  • 判断两个 sprite 是否为 Y 轴排序相关的 sprite,是通过 YMinYMax 是否不同来确认的。
  • 若两者的 Y 范围完全重合,当前逻辑认为它们不属于可以按 Z 排序的范围。
  • 目前对于严格的 Z 轴排序场景而言(如过场动画),这个判断应该没问题。

准备实际运行测试

  • 当前进入排序函数后,只看到了两个 sprite → 数量过少,不具代表性。

  • 稍作运行之后发现有 39 个 sprite,推测大多数是 debug sprite。

  • 为减少干扰,决定 暂时关闭 debug 渲染输出,以便只关注真正参与排序的游戏元素:

    • 进入 handmade 系统模块;
    • 通过 GameInternal 标志来关闭 debug 输出。

小结

  • 我们确认了“是否在前方”的判断逻辑完全正确;
  • 合并排序的实现和调用逻辑也符合预期;
  • 计划通过关闭 debug sprite 来进一步简化测试环境,从而专注观察 Z 层排序的实际效果;
  • 接下来的测试将着眼于 cutscene 中的 sprite 是否按照 Z 值正确前后排序,进一步验证渲染逻辑的准确性。

调试器:中断 SortEntries(),检查前 8 个 Entries 的 SortKey() 值

我们当前进入了一个理想的测试环境:屏幕上不再绘制其他内容,只有用于排序测试的几层基本图层。这些图层用于验证渲染排序的最简单情况是否表现正确。下面是详细分析:


简化测试环境已构建完成

  • 当前只保留了八个用于测试排序的图层;
  • 这是最简化的测试场景,有助于排查基础逻辑问题;
  • 理论上应该确保所有渲染对象仅根据 Z 值排序,且顺序完全可控。

排查图层数据

  • 所有图层的 Y 值(如 yMinyMax)被设置为特定数值,但尚不清楚这些值为何如此设定;
  • 为了理解排序是否正常,首先需要弄清楚这些 Y 值的由来;
  • 虽然当前排序主要基于 Z 值,但 Y 值设定仍可能影响进入哪一类比较路径(例如 Z-only 排序 vs. Z+Y 排序路径)。

排序逻辑回顾

  • 所有渲染条目都有 Z 值,意味着它们应该全部走 Z-sprite 的排序路径;
  • 排序过程只会比较 ZMax 值,而不会用到 YMin/YMax
  • 因此这些对象的 ZMax 值应当能完全决定其排序顺序;
  • 正确行为:具有最大 ZMax 值的对象应排在最前。

观察异常现象

  • 实际排序结果可能存在异常;
  • 排序表现与预期不符,说明可能存在逻辑错误或者初始数据设置不合理;
  • 接下来将进一步调查 Y 值为何被设置成当前值,确认是否误导了排序逻辑;
  • 还需要逐步检查排序比较函数是否确实只在 Z-sprite 路径中使用 Z 值。

总结当前状态

  • 已建立纯粹的图层测试环境;
  • 渲染数据简洁、可控,有助于定位错误;
  • 正在聚焦于 ZMax 排序路径是否完全可靠;
  • 下一步需确认排序行为与设定数据是否一致,并检查 Z 值是否确实主导排序。

在如此简洁的场景下仍能观察到潜在排序问题,说明排序逻辑中可能存在深层次的小错误。后续将重点跟踪每个图层的排序输入值与实际绘制顺序,确保二者一一对应。

这不是一个前到后的渲染器

当前我们确认了一个重要事实:这个渲染器并不是一个“从前到后”(front-to-back)的渲染器。这一点很明显,意味着它并不会自动确保屏幕上距离相机更近的物体覆盖更远的物体。


渲染排序逻辑定位

  • 当前渲染排序机制不是按照物体距离(Z 值)由近到远进行渲染
  • 这种渲染方式会影响遮挡关系,特别是在需要深度层级呈现的场景下,可能导致前景物体被后景物体覆盖;
  • 若希望渲染器具备“从前到后”特性,必须手动控制渲染条目的顺序或引入深度测试机制。

检查恢复断点逻辑

  • 准备将一项判断逻辑重新加入代码中,以便观察或验证某些关键状态;
  • 此处可能是用于调试或确认某些变量、行为是否如预期进行;
  • 后续可能会定位到排序错误具体出现在哪一环。

当前排序机制的局限

  • 在现有机制下,排序更多是依赖于手动设定的 sort key(如 Z 值、Y 值等);
  • 一旦 sort key 设置不合理,或比较函数存在瑕疵,就可能导致渲染结果与期望不符;
  • 尤其在处理具有遮挡关系的场景时,这种机制的缺陷会被放大。

接下来的方向

  • 需要进一步审查排序逻辑是否能支持类似“从前到后”的行为;
  • 如果不能,需要评估是否引入新的排序标准,或者在特定场景手动控制顺序;
  • 重新启用某些检查(如断言、日志)有助于暴露问题出现的位置;
  • 总体目标是确保渲染顺序与视觉逻辑一致,避免前景物体被错误覆盖。

通过这一步,我们不仅确认了当前渲染器不具备从前到后的自动排序能力,也明确了改进方向:要么改进排序逻辑以适应复杂视觉层级,要么在特定情况下手动干预排序以获得正确结果。

game_sort.cpp:让 IsInFrontOf() 按正确方向排序

我们在检查 is_in_front_of 函数时,发现尽管它本身返回的判断逻辑是正确的,但使用这个判断结果的地方,逻辑是反的。换句话说,当前的排序行为与渲染的需求相违背。


逻辑错误分析

  • is_in_front_of(A, B) 返回的是 A 在 B 前面(也就是 A 更靠近相机);
  • 但是在当前的排序实现中,当判断 A 在 B 前时,A 被移动到了更靠前的位置(更早绘制)
  • 然而在渲染中,更靠近相机的物体应当在后面绘制,以遮挡背景中的物体;
  • 因此这个逻辑恰好应该反过来使用 —— 如果 A 在 B 前面,A 应该排在后面(晚一点绘制);
  • 正确的处理应该是:如果 A 在 B 前面,就交换 A 和 B 的顺序,让 A 在 B 后绘制

修正排序方向

  • 所以之前的交换逻辑是错误的;
  • 正确的做法是:当判断出 A 在 B 前时,我们应该将 A 放在 B 的后面,也就是交换 A、B 的顺序;
  • 原本的排序使用方式是错误的,必须将其颠倒。

修正后的行为预期

  • 修正之后,排序逻辑就会变成“后绘制的在前面”,即前景物体会盖住背景物体;
  • 视觉上会呈现符合物理遮挡规律的画面;
  • 同时逻辑也与 is_in_front_of 的语义保持一致:返回 true 意味着 A 更靠近,所以 A 应该排在后绘制的位置。

总结

我们发现了一个关键性的问题:尽管前置判断逻辑(是否在前)是正确的,但其应用方式和渲染需求相违背。通过调整交换顺序逻辑,确保 越靠近观察者的物体被越晚绘制,从而正确处理遮挡关系,修复了渲染排序的根本错误。

在这里插入图片描述

在这里插入图片描述

运行游戏,确认基础逻辑没有彻底混乱

目前的目标是验证基础渲染排序逻辑是否正确。以下是我们当前的分析与状态总结:


当前验证结果

  • 通过对一组 简单的 Z 平面精灵(Z-flat sprites) 进行测试,已经确认 基础的排序逻辑是正确的
  • 我们所实现的排序可以正确地按照 Z 值来判断前后关系,并按从远到近的顺序渲染,确保遮挡关系正确;
  • 这意味着在最基本的情形下,我们使用的合并排序与判断前后关系的逻辑并没有根本性错误,可以正常工作。

验证目的说明

  • 由于整个排序流程相对复杂,为了避免基础部分出现问题导致后续调试困难,我们先验证了最简单场景下的正确性;
  • 这是为了确认系统的底层机制没有崩坏,可以为接下来更复杂情况的调试提供信心;
  • 排除了“根本逻辑错误”的可能性,使我们可以更专注于更高级别的 bug 排查。

后续挑战与问题

  • 游戏实际运行中的渲染情况远比简单测试复杂,排序逻辑是否足够强大以处理所有真实情况仍有待验证
  • 特别是涉及 Y + Z 层混合排序、多层遮挡、交叉重叠等情况,可能无法通过简单的线性排序解决;
  • 当前排序逻辑是否能“完全适用于复杂游戏场景”这一点仍不确定;
  • 后续需要验证:当前排序策略是否本质上能解决所有精灵遮挡逻辑,还是说在某些情况下必须引入拓扑排序或其他更复杂的方法。

下一步计划

  • 回到真实游戏场景中进行调试,观察在复杂精灵组合下的渲染是否仍然正确;
  • 定位当前存在的问题;
  • 确定是否有更高阶的排序需求,例如无法用单一比较函数处理的排序冲突;
  • 继续改进逻辑,或调整场景数据以适应现有排序机制。

总结

我们已确认基础排序逻辑在简单 Z 平面精灵上是正确的,这为后续复杂场景调试打下了基础。下一步将转向游戏主流程中的实际排序逻辑,识别剩余的渲染 bug,同时思考是否需要更复杂的排序模型来支撑整个系统。我们准备继续深入。
在这里插入图片描述

在这里插入图片描述

game.cpp:让 GAME_UPDATE_AND_RENDER() 直接进入游戏,运行后确认线性扫描中没有明显失败

目前已将渲染流程切换回主游戏场景,跳过了片头序列,进入游戏后进行了初步观察和验证。以下是详细总结:


当前观察结果

  • 在当前游戏场景中进行 线性遍历检查(linear sweep) 时,没有触发排序失败的断言,这说明:

    • 在大多数情况下排序顺序看起来是有效的
    • 排序算法本身并没有在最表层出现明显错误;
    • 基本排序机制在真实游戏场景中的表现尚可,未立即暴露崩溃或逻辑异常。

当前存在的问题

  • 屏幕上仍然存在大量 物体相互穿透(interpenetrating objects) 的情况;

    • 这导致排序测试不够明确,因为物体之间的遮挡关系不清晰;
    • 穿透情况削弱了我们观察排序正确性的能力,使调试复杂化;
  • 当前测试场景的精灵排列较为混乱,不具备良好的测试条件;

    • 缺乏有层次、清晰遮挡关系的对象,难以精准验证深度排序逻辑。

下一步改进方向

  • 明日的工作重点将转向为场景补充更真实的层次结构(layering),具体包括:

    • 明确设置对象的 Y、Z 轴位置,避免模糊重叠;
    • 构造更合理、分层清晰的测试对象;
    • 改进原有的“图层系统”(layer system),提供可控的精灵分布;
  • 此外,还将继续优化排序测试,使其能更好地暴露潜在问题。


总结

当前初步验证表明,在大场景中排序系统没有表现出崩溃或明显错误,基础逻辑可用。然而由于场景中存在大量对象穿透,导致排序正确性难以确认。接下来将重点重构测试用例,增加更真实有效的分层精灵布局,以便更清楚地验证渲染排序机制的有效性和鲁棒性。

game_entity.cpp:让 UpdateAndRenderEntities() 绘制实体时按 Z 值排序,不将它们压平到平面上

为了讨论方便,假设暂时不做之前对实体进行的截断处理,也就是说,不将所有东西压平到同一层级,而是直接在“世界模式”下输出实体,并使用实体自身的真实Z值进行绘制,而不是进行相对层级变换。

具体来说:

  • 目前渲染流程中,有一个“相对层级变换”步骤,会对实体的层级和深度进行调整,目的是统一处理和简化排序。
  • 现在假设不做这一步,直接用实体本身的Z值绘制。
  • 这样做的目的是观察在不做层级变换的情况下,排序机制会产生什么效果,看看能否更准确或有何不同。
  • 实际代码中这部分被移动到了单独的实体文件中,需要注意这一点。
  • 通过这种方式,可以验证排序系统对于真实Z值的处理是否合理,以及观察排序结果是否符合预期。

简而言之,就是尝试关闭原本为了简化排序而做的层级扁平化处理,直接用实体的原始Z值输出,来测试排序逻辑在这种情况下的表现。
在这里插入图片描述

在这里插入图片描述

运行游戏,发现排序结果不符合预期

在这种情况下,排序结果看起来不正确,没有达到预期的效果。具体表现为,观察到的一些对象的位置和排序顺序与预期不符,说明排序逻辑可能存在问题。

另外,因为开启了“游戏内部调试模式”导致鼠标光标被关闭,为了更方便观察当前画面情况,暂时将鼠标光标重新打开。这样可以更直观地查看对象的位置和排序情况,帮助进一步调试和分析排序问题。

总结就是,直接用实体真实Z值绘制时,排序没有按预期工作,需要检查排序算法和相关逻辑。同时为了更好观察,先恢复鼠标光标显示。

问答环节

阅读这篇博客让我觉得非常有趣,尤其是关于2D游戏中如何正确排序精灵显示的问题。虽然我平时不太做2D游戏,但对这个问题一直不是很了解,也没意识到它其实是个很有语义复杂度的问题——毕竟这不是完全的3D,而是2D画面中通过某种方式模拟深度感。如何让精灵正确地表现“前后”关系,其实远比单纯按Y轴排序复杂。

博客介绍了一个很棒的解决方案,我觉得非常酷,也让我对不同游戏中处理排序的方式产生了浓厚兴趣。因为肯定有很多游戏在这方面做过有趣的尝试和创新,可能有的做法我以前完全不知道。现在知道这不是一件简单的事情,而是一个需要精心设计的系统,我非常想了解更多其他游戏是怎么解决排序问题的。

总体来说,我很喜欢这篇博客里的思路和方法,也期待玩这个游戏,因为我已经很久没玩过类似“热血双截龙 Double Dragon”风格的游戏了,感觉很怀念那种体验。

最后,关于坐标的使用问题,也让我有了更多思考。

排序规则使用的是世界坐标吧?有没有可能用屏幕空间坐标,从屏幕顶端往下排序?

排序规则是基于世界空间坐标的,另一种方法可能是根据屏幕空间坐标从屏幕顶部到底部进行排序。之前我们一直是用屏幕坐标来排序,但这样做的问题在于,排序屏幕坐标无法处理某些情况,因为屏幕坐标本身并不包含物体之间的深度关系信息。换句话说,仅仅依赖屏幕坐标排序无法准确判断哪个物体应该被绘制在前面或后面,特别是在有重叠或层次关系复杂的情况下,这就导致排序结果不符合预期。因此,单纯通过屏幕空间坐标排序并不能满足需求,需要结合世界空间坐标的信息来进行更合理的排序处理。

Blackboard:讨论为什么不能仅用屏幕空间坐标而不考虑 Z 进行排序

问题在于,如果在绘制三维场景时,比如有一个平台(瓦片)和一个放在平台上的物体,从顶视图来看,这些物体在屏幕上的投影可能会重叠,但仅凭屏幕上的二维坐标,无法判断哪个物体实际是在前面。举例来说,假设有两个物体A和B,B应该先绘制,A后绘制,这样A会遮挡B,但单纯用屏幕坐标看不出来哪个先绘制。

因此,至少需要在屏幕坐标的基础上加入Z轴深度信息,也就是说,排序时不能只用XY屏幕坐标,还要用Z坐标。但即便如此,问题依旧存在。比如,有些物体在视角下会重叠,但它们的Y坐标大小关系并不能直接决定绘制顺序。举个例子,一个物体放在四个瓦片上面,人站在物体上,这时如果只用Y坐标排序,会出现顺序错误,因为人可能在Y坐标上小于某些瓦片,但实际应该绘制在瓦片前面。

所以,不能简单用Y值或Z值单独排序,而是需要使用物体在Y轴上的范围(y-min和y-max)以及Z轴信息结合起来判断。只有知道每个物体的Y轴上下界,才能正确判断它们的遮挡关系,解决排序的“平局”问题。仅凭屏幕坐标没有Z信息的排序根本不够,因为这没法体现物体间真实的空间关系。

综上,要正确处理2D画面中的深度关系,必须用物体在空间中的范围(尤其是Y轴的最小最大值)和Z轴信息综合考虑,而不是简单排序Y或者Z坐标。这个结论是通过反复推敲和测试得到的,单用屏幕空间坐标无法解决实际问题。

我有个关于开发方法的问题:我没见过你单独写程序或环境测试东西,总是在运行中的游戏里做,你觉得这样“嘈杂”吗?

关于开发方式,通常根据具体情况而定。大多数时候并不会专门创建独立的程序或环境来测试功能,而是直接在运行中的游戏环境里进行开发和调试。只有在处理纯理论算法或者需要专门测试某些算法时,才会单独搭建一个测试环境。

曾经有过类似的经历,比如之前做3D算法时,写过一个叫做“math viz”的小工具,用来快速测试和可视化3D算法的效果。这个工具类似一个小型的实验平台,可以方便地绘制和试验各种算法。它曾经存在于以前的开发工具源码树里,但现在已经找不到了,可能是机器清理文件时被删掉了。

虽然独立测试环境在理论上很有用,但实际操作中往往会因为切换环境而降低效率,所以通常直接在游戏内部调试更方便。不过,当预计会在某个算法上花很多时间时,会花心思制作类似的工具箱,方便快速原型开发和测试。

总体来说,制作单独的测试程序是一个有用但不常用的手段,更多时候依赖于游戏内部的调试和开发流程。过去的这些实验工具是内部开发用的,不是随SDK发布的公共资源,现在基本已经丢失或无法找到。

好吧,我说谎了,Witness Wednesdays 里见过你做过类似操作

在开发过程中,虽然尽量在直播或录制中真实展现实际的游戏开发过程,不事先准备解决方案,遇到问题时现场思考和调试,但也有些复杂的算法和工作是不会在公开环境中详细演示的。

例如,曾经在一个复杂的算法(像是曲线拟合算法)上花费了大量时间,可能达到200小时以上,这种级别的工作几乎相当于整个项目的大部分时间。这样的深度研究和长时间调试,公开展示是不现实的,因为会非常枯燥且难以让观众持续关注,甚至大部分时间都在沉默思考。

因此,公开的开发内容更多是中等难度或者常规的程序设计,而超难问题的研究性工作基本不会在公开内容中出现。公开内容的目的是尽量真实地表现游戏开发的常态,展现现场调试和思考的过程,但不会展示那些耗时长且枯燥的算法攻关。

总的来说,虽然有时候会做一些新算法和研究工作,但大多数难题和复杂算法的攻关不会在公开场合展示,因为这些内容既难以观看,也不适合直播或系列内容的节奏,这也是制作公开开发内容时必须面对的现实限制。

数学可视化演示

我们曾经使用一个叫做Math Vis的程序作为开发工具,主要用于开发Granny引擎里的各种算法。这个程序包含了一个比较原始但实用的用户界面,虽然不像现代即时模式UI那么先进,但它有一些简单的拖拽滑块控件,可以快速调整参数,方便调试和测试算法。

Math Vis允许我们插入各种可视化模块,大概有十多个,能够同时编译进程序里,根据需求点击切换不同的可视化界面。它支持画箭头、操作移动控制器等功能,类似于游戏开发中调试时常用的工具。通过这些工具,可以直观地看到算法的执行过程,比如GJK碰撞检测算法的具体步骤。

在GJK算法的调试中,我们用这个工具显示了不同点构成的简单形状(simplex),标注并动态调整点的位置,验证算法在不同情况下是否正确识别碰撞区域和计算结果。通过拖动数值,可以实时观察算法对点的选择和转换是否合理,确保逻辑的准确性。

另外,Math Vis还实现了一些空间划分和剔除(culling)功能的可视化,用来测试空间数据结构的效果。比如有一部分是轴对齐包围盒(AABB)的显示和交集测试,涉及Minkowski差分的计算,这些都是用于碰撞检测的重要数学工具。通过颜色和图形表示,能够帮助判断是否存在重叠或包含关系。

该程序支持各种可调参数,通过滑块调整显示内容,比如是否显示网格,调整视角等,方便根据需要观察不同的细节。虽然有些参数具体作用不太清楚,但整体设计非常灵活,支持快速迭代和调试。

Math Vis不仅仅用于GJK算法,也用于曲线拟合、基于姿态的信息处理等多个复杂算法的原型制作和测试,是一个综合性的数学和算法开发平台。在开发过程中,任何非简单的算法都会先在这个测试环境里做原型,确保逻辑正确和性能可行,然后再集成到游戏或引擎中。

总之,Math Vis是一个非常重要的工具,通过简单直观的可视化界面帮助我们理解和调试复杂算法,提高开发效率,确保核心技术的可靠性。


网站公告

今日签到

点亮在社区的每一天
去签到