游戏引擎学习第306天:图结构排序的调试

回顾并为今天设定目标

我们编写了用于对游戏中精灵进行排序的全部逻辑代码,但要使其真正起作用,我们需要获得每个精灵在屏幕空间中所占据的矩形区域。这个信息虽然渲染器内部是知道的,但我们以前没有将其传递到更底层的渲染输出部分,也就是光栅化器(Rasterizer)那里。

所以,在能够测试我们的排序代码之前,必须先完成这部分信息的传递工作。尽管这不算特别困难,但确实需要去实现它。我们还需要确保能够正确获取每个精灵在屏幕空间中的矩形区域,这样我们才能真正验证排序系统是否按预期工作。

今天我们就从这里开始处理这项工作。

运行游戏,查看当前的状态

我们继续之前的工作。目前程序并没有出现崩溃等严重错误,但屏幕上所有精灵都不见了。造成这种情况的原因是排序系统还没有生效,现在根本没有进行正确的排序。清屏操作可能被错误地排序到了最前面,导致其他内容都被遮盖了。这个阶段,一切行为都是不可预期的,因此我们无法判断系统到底是怎样工作的。

因此,当前的首要任务是将正确的数据传递下去,然后再来调试,确保系统能够正常运作,并且排序逻辑能够利用这些数据完成它的任务。

进入代码部分,在 render_group 中,我们知道已经有了 sort_key,而我们只在三个地方调用了 PushRenderElement,分别是位图(Bitmap)、矩形(Rectangle)和清屏操作(Clear)。代码里可以看到,分别有针对位图的 PushRenderElement、矩形的 PushRenderElement,还有清屏操作的 PushRenderElement

特别是清屏操作这一块,虽然它经常出现在屏幕的最前面(从显示上看是这样),但它本应该是被排在所有其他元素之后的,因为清屏应该覆盖整个屏幕,所以无论哪个元素,它的显示区域都会被包含在清屏的区域内。理论上如果排序正确,清屏应该排在最底部。

我们目前传递下去的 sort_key 并没有包括屏幕空间区域(ScreenArea),也就是说,我们并没有设置这个关键的屏幕矩形信息。而 ScreenArea 恰恰是排序逻辑需要的重要依据之一。

修改 game_render_group.cpp:让 PushRenderElement_() 接收并设置 ScreenArea

我们现在假设要开始向下传递屏幕区域(ScreenArea)这个参数。基本思路是:我们知道,如果任何一个渲染元素希望被正确排序(其实所有元素都必须被排序以确保渲染结果正确),那它就必须具备对应的矩形屏幕区域信息。

我们会使用一个名为 rectangle2 的类型来表示这个屏幕区域。我们先确认代码里确实是使用了 rectangle2 而不是 rectangle2i,接着就将这个类型作为参数传递下去,并在相应的数据结构中保存下来。

当我们这样做之后,如果有哪段代码尚未传递这个新加的 ScreenArea 参数,编译器就会报错。这其实正是我们所希望看到的效果,因为我们知道目前在代码中只有三个地方会调用 PushRenderElement:分别是用于 bitmap(位图)、rectangle(矩形)和 clear(清屏)。所以接下来我们就会去这三处分别添加新的参数传递逻辑。

在做这些改动时还顺便修正了一些小问题,比如给代码行添加了缺失的分号,以及调整了自己的屏幕位置,避免遮挡代码查看区域。现在准备继续推进后续的开发步骤。

修改 game_render_group.cpp:让 PushRect() 设置 ScreenArea

我们需要针对每个渲染元素去确定它对应的屏幕区域(ScreenArea)。其中最简单的应该是矩形(rectangle),因为矩形本身就包含了它所占的区域信息。

矩形实际上就是由两个点决定的------从位置P到P加上条目尺寸(entry dim)。这个尺寸是经过缩放的,所以我们可以通过缩放后的尺寸和位置P来计算出矩形的最小点和最大点,从而确定屏幕区域。

不过,这里的位置P应该是经过变换(transform)后的,也就是说,要使用变换矩阵后的基点(basis P),而不是原始的P点。这样才能得到正确的屏幕区域。

所以我们打算在 PushRenderElement 函数里直接用这个变换后的点和缩放后的尺寸计算出屏幕区域,并传递下去。虽然具体效果还需要验证,但这看起来是正确的做法。

修改 game_render_group.cpp:让 PushBitmap() 为 sprite 设置一个临时的 ScreenArea

对于位图(bitmap),情况就比矩形复杂一些。因为在调用 PushBitmap 的时候,我们可能会传入 X 轴和 Y 轴参数,用于旋转或者缩放。这就意味着,我们不能像处理矩形那样简单地用一个位置加一个尺寸来计算屏幕区域。

虽然目前也可以像之前处理 sprite 包围盒(sprite bound)那样"临时糊弄一下",用一种简化的方式先跑起来,但从长远来看,这种简化处理会导致屏幕区域的范围太小,不够精确,不能覆盖真正需要渲染的区域,未来可能会出错。

所以我们仍然打算先传入一个 Rectangle2 类型的 ScreenArea 参数,并将其一路传递下去。在 PushBitmap 里,我们会先用简化方法处理:假设精灵是一个从 BasisP(变换后的位置)开始,宽高为 size 的矩形。也就是说,用 BasisPBasisP + Size 来构造出一个矩形,作为它当前的屏幕区域。

虽然这种方式目前可以先跑起来,但我们也清楚它并不够精准,之后肯定需要改进,使用更精确的方式来计算包含旋转后 bitmap 所覆盖的实际屏幕区域。

在黑板上讲解:常规 sprite 与旋转 sprite 的 ScreenArea 区别

现在我们已经获得了屏幕区域(ScreenArea),但这套处理方式并不完全正确。下面是详细的解释与背景:

对于正常未旋转的精灵,这样的做法是没问题的。如果 X 轴是 (1, 0),Y 轴是 (0, 1),也就是说没有旋转或拉伸变换,那么我们可以通过变换后的位置 P 加上尺寸 Size 来得到屏幕上的两个角,构成矩形区域。这个情况下计算出来的 ScreenArea 是准确的。

然而,一旦精灵发生了旋转,这种方式就完全不准确了。比如说,如果精灵被旋转了,或者发生了剪切(shear)变形,那么实际的屏幕占用区域就变成了一个斜的平行四边形,而我们依然只是计算 PP + Size 的矩形,这就会导致判断错误。例如当精灵发生 90 度旋转时,我们认为的矩形区域可能还在原地,而实际上它已经移动到了别的位置。甚至,如果 XAxisYAxis 的长度大于 1,那么实际的屏幕区域也会比计算出来的要大得多,这样的差异会造成严重的渲染错误。

虽然这种处理方式在短期内可以凑合使用,因为目前还没有太多依赖旋转渲染的内容,主要是"hopping hero"这个角色用到。但从长远来看,肯定会引发 bug,所以这不是一个可接受的长期方案。

出于测试目的,暂时我们可以先这样使用,快速验证排序系统是否运行良好,然后再回来专门处理这个问题。

接下来,还发现我们其实已经有一个实用函数 RectMinDim(),它可以自动处理矩形的创建,这会让我们的代码更简洁、健壮。

最后,在整理参数时还注意到宏定义(macro)中遗漏了 ScreenArea 的传递,因此我们补上了这一部分。虽然其他游戏追求微操(micro),但我们在这里追求的是完美的宏观结构(macro),也要确保每一步都规范清晰。现在,我们准备继续往下推进调试。

修改 game_render_group.h:为 render_group 添加 ScreenArea,并让 Clear() 接收它

我们现在只需要为"清屏"操作(clear)构造一个假的矩形区域,这个矩形区域应该代表整个屏幕区域。获取这个矩形区域理论上不应该太困难,因为渲染组中应该包含有关于整个屏幕边界的信息。

目前我们拥有一个相机变换(active camera transform),它可以提供屏幕中心的位置。另外,还有一个叫做"monitor half dim"的参数,它以米为单位表示屏幕的一半尺寸。因此,如果我们把这个值从米转换成像素,就可以计算出整个屏幕所覆盖的矩形区域。

虽然通过单位转换能得出结果,但这种做法显得有些繁琐。在屏幕空间中,理应直接存储该矩形值。因此,更合适的方式是直接在渲染组中记录一个 screen_area 字段,然后在创建渲染组时就初始化好它,这样后续渲染元素就可以直接使用。

接着,我们检查当前渲染组是否已经正确地初始化了这个矩形区域。理论上,渲染组不可能只被清零处理,应该在某个位置有明确的赋值操作。于是我们尝试在代码中查找这个初始化逻辑,但一开始似乎没找到位置,推测可能是不小心移动或者遗漏了相关代码。

继续追踪下去,发现渲染组是在 update_and_render_world 函数中创建并传入的,于是深入检查 begin_render_group 函数,这应该是创建并配置渲染组的地方。然而一开始的搜索操作失败了,后来发现是因为搜索路径错误,没在正确的文件中搜索,导致没有找到目标函数。

最后确认到 begin_render_group 确实存在,是配置渲染组的关键函数,只要在此处加入对 screen_area 的初始化即可,后续所有渲染元素都可以正确引用这个值用于排序。至此,逻辑上可以保证所有渲染元素都会拥有一个合理的屏幕空间矩形,用于排序操作。

修改 game_render_group.cpp:让 Perspective() 设置 ScreenArea

虽然刚才是一个小插曲,但回到正题,在 begin_render_group 函数内部,我们应该要有某种方式来获取实际的屏幕区域信息。现在重新审视代码后发现,其实我们只在调用透视(perspective)或正交(orthographic)设置时,才会传入像素高度和宽度。这种设计决定现在看来有些奇怪,可能之后需要重新审视一下,因为这些信息理应在初始化阶段就已经被设置好。

不过,既然目前在设置透视或正交投影时会传入屏幕的像素宽高,我们就可以利用这一点来初始化一个矩形区域,比如设定为从 (0, 0) 到 (像素宽度, 像素高度)。我们可以使用一个 rect_min_dim 来设置这个区域,并且利用 v2i 将浮点坐标转换为整数坐标,以符合屏幕像素的格式。

这样一来,无论是透视投影还是正交投影的情况,我们都可以统一设置这个屏幕区域信息,从而保证后续的清屏操作能正确获得整个屏幕的范围。这个区域也会被用于其他渲染元素的排序和裁剪等处理。

总之,我们通过这种方式在渲染组初始化时就把屏幕矩形信息保存好,确保在之后的渲染过程中都能正确使用这块数据,特别是在执行清屏操作时。现在逻辑上已经打通这部分内容。

调试器:运行游戏,在 BuildSpriteGraph() 崩溃,检查 ScreenArea 的值

我们现在已经把排序所需的信息正确地传递下去了,这一部分已经没问题。接下来就需要开始调试我们实际的代码了。因为我们现在已经有了实际的 screen_area(屏幕区域)数据,而这些值是被某处设置了的,具备实际意义,所以接下来要仔细检查这些 screen_area 是否合理。

我们预期这些 screen_area 是以像素为单位的,因此我们希望在数据中看到一些像素范围内合理的数值。但从当前的数据显示来看,有些值看起来非常不对劲。例如,有一个屏幕区域的最小坐标是 -2076, 28,这显然不是一个正常屏幕范围应有的值。这个区域几乎相当于整块屏幕那么大,但位置却极度偏离,跑到了屏幕左边远离主视角的位置,这很不合理。

不过我们也考虑到一种可能:也许是有一个 Z 值非常大的对象非常靠近摄像机,导致投影时屏幕坐标被极度放大并偏移,这理论上是可能的。为了确认,我们查看了该对象的排序关键值,也就是 zmax,它的值是 0,这表示它刚好在摄像机的前面。我们无法容许这种距离过近的物体参与排序和渲染,因为这会对屏幕排序和覆盖逻辑产生巨大影响。

因此,我们意识到可能需要丢弃那些与摄像机距离过近的对象,不让它们参与渲染。这个问题不能忽视,因为它会导致屏幕区域计算错乱,渲染排序失效,最终画面出错。我们准备接下来在 entity 的 basis_p(实体变换基准点)部分进一步查看这些异常对象的具体数据,判断这些不合理的 screen_area 值是如何计算出来的,从根源上解决这个问题。

查看 GetRenderEntityBasisP(),注意 NearClipPlane 设置为 0 会被裁剪掉

当前遇到的情况是,result.valid = true,而 result.value = true_distance_to_pz > near_clip_plane。换句话说,我们正在判断一个点距离摄像机的深度值是否超出了近平面(Near Clip Plane),结果却是这个值并没有被剔除,这显得非常奇怪。

通常来说,近平面会被设置为一个略大于 0 的值,用来剔除那些离摄像机太近的对象,防止它们干扰投影或排序。而现在情况是,某些 Z 值为 0 的点似乎并没有被剔除出去,这不符合预期。

这引起了我们的警觉,初步怀疑可能是我们用于深度判断的 Z 值并不准确,或者某些地方的坐标变换逻辑存在问题,导致计算出来的屏幕空间 Z 值不符合真实的投影空间意义。

我们可以确认一点,那就是目前的近平面裁剪逻辑本身确实存在,且逻辑是到位的,只是实际运行时的数据结果却违背了逻辑推断。这说明要么近平面距离设定不对,要么实际物体的位置计算有误,或者坐标空间的变换过程中出现了意料之外的问题。

这种异常可能会导致屏幕上显示不正确的内容,比如错误的排序、遮挡不正确、物体"穿透"视图等,因此非常值得进一步调查。我们需要回头检查 Z 值的来源、近平面设置的位置以及 transform 到视图空间的过程,以确保这部分逻辑的精度和一致性。

调试器:检查变量 B 的值

我们现在的目标是深入查看当前渲染过程中涉及到的对象,尤其是那些在屏幕空间中行为异常的对象。

首先我们希望检查一些关键数据,以弄清楚当前到底是哪些对象参与了渲染,看看它们的具体参数是什么,尤其是位置 P、Z 值等。这些信息能帮助我们确认是不是存在某些非法或者极端的对象数据正在被传入渲染流程。

我们查看了变量 b,看上去它的状态令人不安------也就是说,它包含的数据或表现出来的逻辑可能不合理或者令人担忧。这可能表明其位置、大小或者坐标变换结果存在问题,可能就是导致屏幕空间矩形异常的元凶。

接着我们确认了我们正在查看的是否是对象数据队列中的第一组,从后续语境可以看出,我们正在查看的是最新的一组数据,也就是渲染队列末尾的数据项。这些项可能是我们当前游戏帧最新添加的渲染元素,也是最容易暴露问题的地方。

从整体分析看,目前主要在围绕两个关键目标展开:

  1. 识别异常渲染对象:通过遍历当前的渲染数据,找到那些具有可疑坐标或矩形尺寸的对象(如位置极端偏移、Z 值为 0 等),以便进一步定位问题源头。

  2. 确认渲染流程中数据排序的完整性与合理性:当前看到的渲染排序末尾的数据令人困惑,需要我们检查渲染流程是否在维护合理的排序逻辑,特别是涉及 Z 值排序是否存在边界错误或遗漏。

这些操作将帮助我们初步锁定问题范围,从而为后续修复和优化打好基础。

修改 game_render_group.cpp:在 PushRenderElement_() 添加断言,确保 GetArea(ScreenArea) 小于 2000x2000

我们目前想先排查一个显然存在异常的屏幕空间矩形问题,虽然可以直接继续调试当前阶段的功能,但我们对之前观察到的一些异常值感到不安,因此决定优先查明这些数值的来源。

我们目前的策略是,从渲染元素被推送(push)到渲染队列的地方入手,检查推送时传入的屏幕矩形(screen area)的值。我们认为这个位置调试开销较小,适合快速插入断言或检测代码。

具体计划如下:

  1. 检查传入的屏幕矩形尺寸是否合理

    我们打算对传入 screen area 的矩形计算其面积,并添加一个断言(assert),比如面积不能超过 2000x2000 像素。这样可以快速捕捉到不合逻辑的矩形数据。

  2. 发现工具函数缺失

    在尝试实现这一点时,我们意识到目前的矩形结构体中并没有实现一个用于获取面积的函数。尽管有函数比如 has_areaget_clamped_rect_area,但它们并不适用于我们当前的目的。

  3. 决定添加面积计算函数

    鉴于这一点,我们决定直接给该矩形结构体添加一个内联函数 get_area(),用于计算矩形的面积,便于后续使用断言来验证矩形是否合法。

这个操作是一个快速诊断手段,目标是立刻捕获那些尺寸远超常规的屏幕区域,从而定位导致渲染异常的根源。这将帮助我们更好地理解当前数据状态,提升后续调试效率。

修改 game_math.h:添加 GetArea() 函数

我们决定为矩形结构体添加一个用于计算面积的函数 get_area()。因为面积的计算本身非常简单,就是矩形的宽度乘以高度,而我们已经有了一个 get_dim() 函数可以返回矩形的尺寸(宽和高),所以可以直接基于这个函数来计算面积。

具体做法如下:

  1. 实现 get_area() 函数

    • 通过调用 get_dim() 获取矩形的宽度和高度。
    • 然后将这两个维度相乘,得到矩形的面积。
    • 返回计算结果。
  2. 编写时出现了一些小问题

    在实现过程中不小心打了一些错别字,但修正后逻辑非常直接,没有复杂性。

  3. 函数用途和预期

    实现这个函数的目的,是为了配合前面提到的断言逻辑。我们希望在推送渲染元素时能检测每个 screen area 的面积是否在合理范围内,例如面积不能超过 2000x2000。

    • 如果断言失败,说明某个矩形过大(例如尺寸错误或计算有误),可以及时定位问题。
  4. 调试状态说明

    当前由于手部不便("flipper hand"),操作有些不便,影响了一些编码效率,但希望这个断言检查能尽快帮助我们发现问题。

这一步完成后,我们可以更有信心地分析和验证渲染过程中矩形数据的合法性,从而进一步解决屏幕空间错乱或渲染排序错误等问题。

调试器:运行游戏,触发断言,单步跳出,查看是谁调用了该函数

我们在调试过程中遇到了一些屏幕区域(screen area)特别大的矩形。为了更高效地定位问题,而不是等到渲染链条末端才去排查,我们在推送渲染元素的过程中加入了断言,以便一旦出现异常尺寸的矩形时能立即中断程序并跳转到相关代码。

我们希望借此快速判断是谁生成了这个异常矩形,并能够查看调用堆栈以找到源头。在调试时,我们使用了调试器的"设置下一语句"功能,将断点设在矩形推送之后的位置,这样就可以从当前调用中跳出,观察到底是哪个调用者传入了这个大矩形。

在实际调试中,我们发现触发断言的大矩形来自一个预期中的逻辑:它们是用于渲染"世界笔"(world pens)的矩形------即用于显示模拟区域边界的巨大元素。这些矩形的尺寸确实非常大,但它们的用途是明确且合理的,不是由于计算错误或逻辑 bug 导致的。

因此,尽管最初看到这些矩形让人感到不安,担心可能存在严重问题,但现在我们确认这些是合法且有意设计的。我们决定不再对此进行额外追踪或排查,认为这是可以接受的行为,暂时放下这部分的顾虑,继续其他部分的调试和开发。

修改 game_render_group.cpp:注释掉断言,运行游戏,观察实际的 bug

我们先将之前用于调试的大矩形断言逻辑关闭,因为我们已经确认那部分并没有问题,只是用于渲染"世界笔"的合法行为。

接下来开始着手真正要解决的 bug。虽然此前对那些巨大矩形有些担心,但现在已经确认它们的存在是合理的,我们不是在胡思乱想,之前的担忧只是出于谨慎。我们现在可以安心地将注意力集中到当前真正的渲染问题上。接下来就是专注排查导致实际错误的那部分代码和逻辑。

修改 game_render.cpp:重新启用 SortEntries() 中的 PushStruct() 调用

我们发现某处代码中 PushStruct 被注释掉了,推测是之前还没写完所以暂时屏蔽了这段逻辑。看起来这可能只是个简单的遗漏问题,我们只需要把代码重新启用即可。

虽然目前还无法确定这是否就是导致问题的唯一原因,但这是一个很明显的线索,值得先处理掉。把相关注释取消恢复代码之后,再进行进一步调试,有可能问题就能部分解决。当然我们对此持保留态度,因为也有可能背后还隐藏着其他更深层次的逻辑错误。接下来会继续观察是否还有其他异常。

运行游戏,发现运行速度极慢

首先,目前程序运行非常缓慢,我们尚不清楚具体画面上显示的内容是什么,但性能问题显而易见。我们推测性能下降的主要原因,可能与之前讨论过的问题一致:渲染流程中存在一个数量庞大的元素参与的 O(n²) 时间复杂度的循环。

换句话说,程序中某段逻辑对每个元素都进行了和其他所有元素的对比或处理,而当前元素数量非常庞大,导致整体处理时间呈平方级别增长,造成显著卡顿。虽然尚未完全确认这一点,但从表现上看,这是最合理的假设之一。

接下来需要进一步定位并优化这段性能瓶颈代码,避免不必要的全量遍历或比较,采用更高效的数据结构或算法来提升性能。我们也可能需要先减少参与排序或处理的元素数量,或者对那些不必要参与某些计算的部分加以筛选,以降低每帧计算成本。

修改 game_render.cpp:为 BuildSpriteGraph()WalkSpriteGraph() 添加 TIMED_FUNCTION

假设如果要确认耗时的部分,很可能是"构建精灵图"(build sprite graph)占用了大量时间,但目前还不能确定。因此,第一步是对整个过程进行详细的时间测量,确保清楚每一步的耗时情况。

具体来说,需要分别对以下几个阶段进行计时:

  • 遍历(walks)部分
  • 构建精灵图(build sprite graph)部分

通过这样做,可以精准了解每个阶段的性能开销,进而在调试和优化时针对具体瓶颈进行改进,确保整个流程的正确性和效率。

运行游戏,发现无法看到调试显示,因为排序不正确

有趣的是,发布模式(release mode)运行速度快得多,但我们无法看到调试信息覆盖层(debug HUD overlay),因为它被卡在了渲染引擎的排序过程中。这很烦人,因为屏幕显示混乱不堪,而且在我们解决排序问题之前,调试覆盖层也无法正确显示,这对调试造成了不小的阻碍。

尽管如此,我们并非完全没有解决办法,仍然有一些方法可以尝试来应对当前的问题。

运行游戏,观察到非常奇怪的结果

排序结果仍然不正确,甚至出现了明显的矛盾现象,比如明显在前面的物体被排在了明显不在前面的物体后面。不过排序在很多情况下表现得比较稳定,闪烁现象减少了不少,但仍然存在。

为了找出排序不合理的原因,考虑到当前屏幕上物体太多,调试变得复杂,决定先关闭大部分渲染内容,只专注于渲染少量几个物体,观察它们的排序情况,从而更容易定位问题。

修改 game.cpp:切换回标题序列,以便观察排序算法的表现

考虑切换回处理标题序列的渲染逻辑,因为标题序列中只有一些较大的元素,没有成千上万的对象,调试排序会更简单,也更容易观察排序算法的表现。

观察发现标题序列中的元素排序看起来相对正常,没有出现严重错乱,这让调试排序的问题变得稍微复杂一些,因为没有明显的错误作为突破口。不过依然会逐步跟踪排序过程,尝试找出隐藏的问题。

观察文字阴影被错误排序

观察到排序结果非常不正确,比如阴影层被错误地排在了文本前面。奇怪的是,背景层的排序却很稳定且正确,而UI覆盖层的文本排序却不可靠,情况恰恰相反。

发现"构建精灵图"(build sprite graph)花费了大量时间,符合之前的预期,正是因为存在N²复杂度的操作。这其实是好消息,因为我们知道这是主要瓶颈,且可以针对这个部分进行优化,使其变得更加高效。如果排序过程中瓶颈仅仅是构建精灵图,那么优化起来会比较明确且可控。

目前背景元素渲染正确,没有发现明显错误。还在观察其它元素的渲染表现,尝试弄清楚为什么部分排序会出现问题,同时确认某些关键元素(比如"Krampus")是否渲染正确。整体来看,排序和渲染的错误主要集中在UI文本相关的层面,而背景排序相对正常。

修改 game_cutscene.cpp:尝试打乱第一个场景图层的顺序

目前发现背景层排序工作正常,虽然"正常"听起来有些奇怪,但事实如此。接下来考虑是否排序结果依赖于元素在数组中的顺序。想尝试通过调整元素顺序,比如把某个元素提前到最前面,观察渲染结果是否改变。

目标是确认排序是否只是因为原本元素已经处于正确顺序,才看起来渲染正确,还是排序算法真正起作用。通过改变元素顺序来验证,看看渲染顺序是否随之改变,从而判断排序是否真的有效。

运行游戏,发现场景排序现在完全错误

排序功能目前有些问题,排序结果依赖于元素在数组中的原始顺序,这说明排序逻辑没有正确生效。确认了这个问题后,可以进入代码调试,将游戏内部的调试代码关闭,使屏幕上的元素数量减少,只剩下几个位图。这样便于设置断点,逐步跟踪这几个元素的排序值,检查排序判断是否正确,进一步定位排序功能的问题。

调试器:在 BuildSpriteGraph() 中断点,检查 A 和 B 的值

现在看到两个元素A和B,它们应该是有重叠的情况,但奇怪的是,它们的屏幕区域完全相同,这点让人难以理解,感觉不太合理。这个问题可能是排序或屏幕区域计算中的一个关键点,值得进一步调查。

修改 game_render.cpp:防止 BuildSpriteGraph() 比较相同的 NodeIndexA

发现了一个严重的bug:在比较节点时,不应该把一个节点与它自己进行比较,但目前的代码却这样做了。具体表现是,索引a从0到节点总数遍历时,会出现节点与自身比较,导致判断节点与自己相交,进而在排序图中添加了从该节点指向自身的边,这样就形成了一个即时的循环,导致排序逻辑出错。这种情况是必须立即修复的,因为它会严重破坏排序过程。

调试器:再次中断进入 BuildSpriteGraph(),确认 A 和 B 不再相同

现在确认了节点A、B、C确实是不同的,这符合预期。观察排序的代码逻辑时,发现渲染顺序是从渲染栈的后面往前面推,生成排序键,因此会先看到某个节点,再看到另一个节点。目前在比较的排序中,只有两个人物参与排序,第三个没有参与比较。排序的核心是基于Z轴值,Y轴的影响相对较小。需要确认这些节点是否设置为"upright"状态,推测它们应该是设置为upright为false,即属于Z轴排序的精灵。这样排序的依据就是Z轴深度值,确保深度较高的对象正确排序。

调试器:单步执行 BuildSpriteGraph(),确认其行为是否正确

我们观察到两个节点的Z最大值分别是-39和-29,我们希望Z值较高的节点能够排在前面。在这个例子中,节点A的Z值较高,因此期望它出现在前面。两个节点确实有相交,符合预期。我们取两个节点的索引,判定A在前,B在后,目前排序顺序是正确的,所以不进行交换。接着,我们为它们创建一条新的边,加入边链,并更新前端精灵的边指针,形成链式结构,这样的处理看起来是合理的。

在实际运行过程中,我们只会看到一次"前后翻转"的情况,那就是最后一个排序项发生变化时,这也符合我们调整节点顺序后的预期。整体逻辑是对的,虽然存在N²的复杂度问题,导致处理效率低下,但排序边的创建和链接大体上是正确的。接下来应该继续针对这个基础,做进一步优化和调整。

调试器:单步执行 WalkSpriteGraph()RecursiveFromToBack(),确认行为

我们查看了遍历图的过程,目的是弄清楚为什么排序结果不符合预期。首先准备好输出索引数组,初始为空。我们从第一个节点开始,调用递归遍历,确认节点未被访问,然后标记为已访问。第一个节点理论上不应该有任何指向它的边,只会有指向其它节点的边,因为它是最前端的元素。我们看到第一个节点的下一条边确实指向了它后面的节点。

举例来说,当前节点索引是0,递归进入了索引为6的节点,这符合预期,因为边链上对应的索引确实是6。节点6本身没有指向更后面的节点,因此没有入边,首先绘制的就是节点6。接着递归访问索引5,节点5应该指向节点6,但由于节点6已访问,跳过了它,所以将节点5加入绘制序列。

整个遍历过程看起来执行得很正确,所有节点都应该被绘制,跳过了已访问的节点,保证了不重复访问。输出的索引数组顺序也符合预期,节点索引基本是递增的,除了最后调整过顺序的那个节点。

虽然遍历和排序逻辑看上去完全正常,但我们依然遇到奇怪的结果,这让我们感到困惑。整个排序流程符合预期,但实际表现却不对,具体哪里出了问题还不清楚,只能继续深入调试,期待在代码中发现问题所在。

调试器:单步执行 OpenGLRenderCommands(),检查 SortedIndices 数组

我们查看了排序索引数组,整体看起来非常正常,符合预期。进入每个条目时,数值表现也很合理,比如512、168、224、283、36这些值都没有异常,排序数组整体状态良好。这让我们感到非常困惑,因为根据之前的逻辑,所有排序和遍历的流程看起来都没有问题,结果却不符合预期。情况十分扑朔迷离,因此必须进一步深入调试,提升排查力度,继续追踪问题的根源。这个问题还没有解决,只能暂时停在这里,等待后续进一步分析。

修改 game_cutscene.cpp:在 RenderLayeredScene() 中添加调试标签用于跟踪图层

我们发现当前对排序和渲染过程的理解存在误差,需要一种更清晰的方式来标记和识别各个元素,以便更准确地追踪问题。之前在渲染条目处理中有调试标签的机制,但目前并没有实际将调试标签应用到条目上。为了改进这一点,我们决定在"intro layers"部分添加代码,确保每个渲染层在渲染调用之前都被赋予对应的调试标签,这样可以清楚知道每个条目对应的是哪一层。

具体做法是在循环遍历场景层时,将当前层的索引赋值给调试标签,便于后续通过调试标签确认元素身份。这样一来,在查看剪辑序列时,可以直接根据调试标签判断每个条目的具体情况,帮助定位排序或渲染异常。

不过,目前调试标签的读取仍然有些麻烦,主要是因为排序键的位置不够方便,无法直接访问渲染组条目的头部信息。为此,进一步优化是在将条目放入游戏慢路径(game slow path)时,同时在条目头和条目本体都附加调试标签,保证调试信息完整且易于追踪。这样调整后,调试标签就能更有效地辅助我们理解排序和渲染的流程,为后续分析提供更清晰的线索。

调试器:运行游戏,单步调试 BuildSpriteGraph()RecursiveFromToBack(),观察调试标签的变化

我们通过调试标签观察渲染顺序时,确认这些标签确实如预期那样显示出来了,比如调试标签8确实存在,并且顺序似乎是倒序的。虽然这个函数的工作方式看起来是正确的,但目前还没有通过观察这些信息得到新的有用发现。

接下来,我们重点关注输出顺序,也就是实际绘制时的顺序。输出的第一个调试标签是2,这对应了剪辑序列中的第二个元素,这是合理的;随后依次输出了3、4、5、6,最后应该输出编号为1的元素。

尽管输出顺序看起来合理,但实际渲染时仍旧没有正确显示,画面上依然出现了明显的不正确覆盖,说明排序逻辑仍有问题。接下来准备继续检查其他可能的原因,试图找出导致渲染错误的根本原因。

修改 game_cutscene.cpp:将图层顺序恢复为原始状态

我们尝试反过来观察渲染顺序,回到之前那个能够正确渲染的状态,查看那时的输出顺序。目的是对比两种情况下的输出结果,看看正确渲染时输出的顺序是什么样的,是否有不同于之前错误渲染时的顺序。

希望通过这种对比,能够发现排序或者输出顺序上的差异,从而找出导致渲染异常的具体原因。通过输出结果,期望能看到与之前错误情况不同的顺序,从而验证排序逻辑是否真的出了问题。

调试器:单步执行 RecursiveFromToBack(),观察调试标签,确认程序按预期工作

我们发现无论哪种情况,输出的调试标签顺序都是按预期的顺序1、2、3、4、5、6、7、8输出,这说明代码确实按照预期在执行排序和输出。这意味着代码本身的排序逻辑是正确的,代码的行为和我们的期望是一致的。

然而,问题依然存在,说明我们的某个假设可能是错误的。尽管排序代码运行正常,但最终渲染结果却不符合预期。这暗示着在整个系统中,可能存在某些我们没有考虑到的细节,或者是对上下文环境的理解不够准确,导致排序正确却没有产生正确的渲染效果。

当前最有趣的点在于排序的操作方式,特别是当我们调整元素位置时,怀疑索引的含义或使用可能没有完全理解清楚,索引可能并不是我们想象中的那样工作。需要进一步确认索引的定义和用法是否正确,以便弄清楚为何排序虽正确,但渲染顺序依旧出错。

时间有限,暂时只能先暂停,接下来计划是更深入地检查索引和排序逻辑的结合部分,看看是否存在某些隐含的逻辑漏洞或误解,导致最终效果与预期不符。

修改 game_render.cpp:研究 sort_sprite_bound 是如何在函数中流动的

我们现在处理的是一个叫做"排序精灵边界"(sort sprite bound)的数据结构,实际上排序操作就是基于它进行的。这个结构包含了偏移量等信息,可能现在有些复杂,等这个功能实现完毕后可以考虑简化。

这些"排序精灵边界"来自于渲染组(render group),具体是在调用"推入渲染元素"(push render element)的时候产生的。每次推入渲染元素时,实际上是成对推入数据:一个是渲染头信息(header),另一个是对应渲染内容的具体信息。同时,会创建一个排序用的精灵条目(sort sprite entry),其中的偏移量是当前推送缓冲区(push buffer)的位置,也就是相对于缓冲区起点的一个索引,用来标识这条渲染命令在缓冲区中的位置。

我们收集了这些条目后,在渲染阶段会对它们进行排序和遍历。在遍历时,我们根据记录的偏移量确定到底绘制哪个渲染命令。这个偏移量本质上是指向推送缓冲区中的具体渲染内容。

现在的问题是,观察到改变推送这些渲染元素的顺序,会影响最终渲染结果,但却好像并没有改变实际绘制时的顺序,这让人非常困惑。换句话说,渲染结果的变化和绘制顺序之间似乎没有直接对应关系,这种现象非常奇怪且难以理解。

所以目前我们在尝试搞清楚,为什么排序和绘制顺序不一致,或者说渲染结果的改变到底是由什么引起的。这个问题非常复杂,需要进一步深入理解这些索引和排序机制的实际行为。

修改 game_cutscene.cpp:指出动画片段排序异常的原因

我们意识到一个核心问题:排序结构中并不包含位图 ID(bitmap ID)。这其实是一个非常基础的问题,但也是导致之前测试结果出现误判的根本原因。因为这些排序结构只记录了与文件中渲染数据对应的偏移量,并不包含实际的位图资源信息。因此,当我们尝试通过移动这些结构位置来验证渲染顺序时,并没有真正改变渲染内容,而是造成了所有图层使用了错误的设置。

换句话说,那次测试并不具有参考价值,因为只是导致所有图层加载了错误的资源设置,而并未真正反映出排序机制的问题。不过,这次测试的副作用是验证了我们的排序逻辑在某些情况下确实是正确的:在一堆对象叠加的简单场景下,排序结果是符合预期的。

虽然这并不能解释为什么在复杂场景中排序会失败,但至少可以确认当前的排序逻辑在基础层面是有效的。因此,接下来的任务是进一步提升复杂度,去观察在更复杂的场景中到底发生了什么,从而找出导致排序失败的真正原因。

我们现在做好了心理准备,知道接下来不是轻松的工作。但也正因如此,我们对已经走通的部分感到满意,尤其是能亲自一步一步跟踪验证了排序机制的基本正确性。接下来我们将重新启用之前禁用的部分,为进一步的复杂场景调试做好准备。

修改 game_world_mode.cpp:让 UpdateAndRenderWorld() 只生成一个屏幕并禁用 Monstar

为了方便下周继续推进,我们需要为排序系统的调试和性能优化做好准备。因此,我们计划先简化当前场景,清除多余的渲染负担,以便快速聚焦于排序机制本身的问题。

我们决定先将多个屏幕裁剪为只保留一个屏幕的内容。原因在于:即便只有一个屏幕,当前的排序结果依旧是错误的。由此可以判断问题不在多屏管理上,而在更基础的排序逻辑上。因此,通过减小测试范围,有助于加快定位和验证错误的过程。

我们接着进行了一些基本清理:

  • 在初始化场景时,剔除了大部分元素,仅保留"主角"(hero),这样便能直接观察到最核心角色的排序情况。
  • 准备进一步清理标准房间(standard room)中的 Z tiles(通常为墙体或高度元素),以观察是否是这些带有层级属性的元素对排序结果产生了干扰。

通过这些裁剪和清理措施,我们的目标是:

  1. 精简场景,最小化变量,专注调试排序逻辑;
  2. 加速调试过程,提升迭代效率;
  3. 为下周继续攻克复杂场景中的排序异常打好基础。

当前策略是逐步"筛选"(winnow away)场景中可能导致排序混乱的因素,从最简单的情况入手,逐层构建复杂性,并在每一步进行验证。这样一来,我们可以快速构建一个稳定、可重复的排序验证流程,为彻底解决排序问题奠定基础。

修改 game_world_mode.cpp:阻止 AddStandardRoom() 调用 BeginGroundedEntity()

我们在向场景中添加内容时,为了进一步简化测试环境并聚焦于排序逻辑的调试,决定完全取消地面元素(ground identities)的创建。也就是说,在构建场景过程中,我们主动让代码跳过任何涉及地面图块或地面对象的生成逻辑。

这样做的目的有几个:

  1. 进一步精简测试数据:之前已经裁剪掉大多数其他元素(比如墙体和多余的房间),这一步是更进一步地消除视觉干扰,只保留主角或关键可视对象。

  2. 排除干扰项:地面元素可能会影响排序机制(比如由于它们的Z值或绘制层级),我们希望先排除所有可能混淆视听的因素,从最干净的状态下验证排序是否正确执行。

  3. 提升调试效率:元素越少,渲染队列越短,便于我们以更快的速度执行调试循环,观察排序行为的每一个细节。

总结来说,我们通过屏蔽地面元素的创建,使测试环境变得极为纯粹,从而确保排序系统的测试结果不会被多余的内容干扰。接下来,我们将在这个精简环境下,验证渲染顺序是否真正符合预期,如有问题,也更容易定位和修复。

运行游戏,在 OpenGLRenderCommands() 中触发断言,因为 ClipRectCount == 0

出现的现象非常不符合预期:在某个时刻,剪裁矩形(clip rects)突然消失了,导致当前渲染过程中不存在任何有效的剪裁区域。这让我们感到困惑,因为在我们的假设中,系统应当始终至少维护一个有效的剪裁矩形。

从逻辑上讲,我们认为每次渲染流程开始时,都会默认推入一个初始的剪裁矩形,作为渲染区域的边界限制,因此按理说,不可能出现"剪裁矩形列表为空"的情况。为此我们决定回到源头检查该逻辑是否真的被正确执行。

我们将回顾以下几个关键点来排查:

  1. 初始化阶段是否确实推入了剪裁矩形

    我们需要定位渲染流程初始化时是否调用了用于推送剪裁矩形的代码逻辑。理论上每一帧开始时,渲染系统应当向剪裁矩形堆栈中压入一个覆盖整个窗口区域的默认剪裁框。

  2. 是否有逻辑在之后清空了剪裁矩形列表

    我们还要确认在随后的过程中是否存在将剪裁栈清空或覆盖的操作,尤其要检查是否有某个地方意外地调用了 pop 操作或 reset 操作,从而导致剪裁区域变为空。

  3. 逻辑分支路径是否绕过了预期行为

    我们要确认当前代码执行路径确实有经过预期的初始化逻辑。如果由于某些条件判断未满足,导致初始化剪裁矩形的逻辑未被调用,也有可能解释为何剪裁矩形突然缺失。

  4. 剪裁栈是否是线程局部或上下文敏感的资源

    如果剪裁栈不是全局的,而是每个渲染上下文或每个线程单独持有的资源,有可能是当前操作的上下文并没有得到正确初始化,从而导致其剪裁栈为空。

目前最关键的是验证:当前帧是否真的走过了剪裁区域初始化逻辑。我们会接着查看对应代码块,确认是否在 render group 或 render pass 初始化时正常调用了剪裁矩形推入函数,以及这段逻辑在本次执行中是否被绕过或失败。

接下来将从 render group 的构造函数、开始渲染帧的入口函数、以及任何显式 push_clip_rect 调用处着手,逐层排查剪裁矩形失效的原因。

不清楚为什么是这个段错误

调试器:调查为什么没有生成任何 ClipRects

在游戏渲染过程中,发现了一个意外的现象:在某些特定情况下,剪裁矩形(clip rect)列表为空,而这不符合之前对渲染流程的预期逻辑。

我们仔细检查了整个流程,发现正常情况下,在开始渲染组(begin_render_group)时,都会默认压入一个剪裁矩形。无论是透视(perspective)视角还是正交(orthographic)视角,系统设计上都会调用对应函数,并在其中执行一次剪裁矩形的压栈操作,因此理论上应当始终存在至少一个有效的剪裁区域。

问题逐渐显现:虽然渲染命令最初确实存在一个剪裁矩形,但在某些场景中(如第二次执行渲染流程时),剪裁矩形计数降为零,并最终导致崩溃。我们进一步推测可能的原因是:当场景中没有任何对象(如地面)可以承载角色时,角色无法被加入到场景中,进而导致渲染流程直接跳出当前剧情(cutscene)并切换至标题界面(title screen)。

此时进入的是标题界面的渲染流程 update_and_render_title_screen,我们继续追踪发现在该路径中:

  1. 确实执行了清屏操作(clear)
  2. 但是并没有设置任何视图矩阵(未调用透视或正交设定函数)
  3. 由其他地方传入的 render_group 并未经历过 orthographicperspective 的初始化过程
  4. 最终导致没有任何剪裁矩形被推入到剪裁栈中,产生"无剪裁区域"的异常状态

也就是说,在标题界面的某个特定代码路径中,存在"未显式设置剪裁矩形"的逻辑分支,是导致问题的根本原因。系统原本假设任何渲染流程都会先调用视图初始化函数,从而自动推入剪裁矩形;但该假设在标题界面中未被满足。

当前的结论如下:

  • 问题出在没有调用 orthographicperspective 的代码路径上,导致剪裁矩形为空。
  • 这种情况出现在角色无地可站、切出当前场景后进入标题界面的过程中。
  • 修复方式可能是:无论在哪个界面或逻辑分支中,初始化渲染组后都必须显式设置剪裁矩形(如默认调用 orthographic,以保证剪裁栈不为空。

后续准备调整相关逻辑,确保无论任何进入路径,都有一段统一代码负责初始化视图矩阵并推入至少一个默认的剪裁矩形。

修改 game_render_group.cpp:改为由 BeginRenderGroup() 而不是 Perspective() 接收像素宽高来计算 ScreenArea,并调用 PushClipRect()

我们意识到目前的渲染流程存在设计上的缺陷,容易导致错误状态。原本的设计中,begin_render_group 并不会自动设定屏幕尺寸或推入初始剪裁矩形(clip rect),这导致当后续流程未手动设置投影参数时(如标题界面没有调用 orthographicperspective),系统就会进入无效渲染状态。

为了解决这个问题,我们提出了一项改进措施:begin_render_group 直接接收并保存屏幕尺寸信息,并在其内部初始化一个默认剪裁矩形。 这一变更的主要思路和步骤如下:

  1. 修改 begin_render_group 的设计,使其能够直接接收屏幕宽高等像素信息(pixel width/height),并在初始化时保存这些信息。
  2. 由于屏幕区域在渲染周期中不会变化(与是否使用透视投影或正交投影无关),所以将该信息直接写入渲染组对象中是合理的。
  3. 有了这些信息后,渲染组内部的任意流程(包括 orthographicperspective)就可以随时访问屏幕像素尺寸,而无需额外传参或计算。
  4. begin_render_group 内部自动调用 push_clip_rect,推入一个默认剪裁矩形,确保后续所有渲染操作都有有效的剪裁区域,不会导致崩溃。
  5. 删除 orthographicperspective 中原本用于手动推入剪裁矩形的代码,从而减少冗余,降低错误率。
  6. 修改了对应代码位置以配合新的设计,比如删去了不再必要的手动推入操作,只保留透视/正交矩阵设置本身。

这项改动的好处包括:

  • 避免了无效状态的发生,例如之前出现的"剪裁矩形计数为零"的致命错误。
  • 简化了调用流程,无需每次调用都手动推剪裁矩形。
  • 使得渲染流程更加健壮,逻辑更加集中与合理。

虽然我们暂时仍面临角色没有可站立地面导致被踢出主流程的问题,但这一设计上的修复显著提升了渲染系统的可靠性,确保任何进入 begin_render_group 的路径下,都具备合法的剪裁状态。后续将继续处理场景内容为空引发的其他逻辑问题。

奇怪

修改 game_world_mode.cpp:让 AddStandardRoom() 只创建一个 tile

我们对之前的测试环境进行了进一步优化和简化,使问题排查更高效。为了确保角色在游戏世界中至少有一个立足点,我们对场景构建逻辑进行了以下调整:

  1. 清理并简化了场景初始化流程,移除了多余的图块生成逻辑,只保留必要的部分。
  2. add_standard_room 过程中,为主角(hero)手动添加了一个可以站立的图块 。选择了地图中坐标 (22, 22) 的位置,并明确指出此处必须生成地面图块,使主角不再陷入"无地可站"的空场景中。
  3. 删除了原本用于屏蔽图块生成的 else 分支,使其不会完全阻止地面生成,但仍然最大限度减少了无关地形的添加,仅保留一个必要图块用于测试。

这个变动的目的在于:

  • 避免角色初始化失败导致游戏逻辑中断。
  • 简化测试场景,使后续对排序逻辑的测试更专注、更具针对性。
  • 为排序系统提供最小可行测试案例,方便复现并分析渲染顺序问题。

当前设置下,主角处于一个极简环境,仅有一个有效地面,极大减少了潜在变量,有助于快速定位渲染异常的根本原因。我们接下来将继续在这个基础上进行调试与验证。

运行游戏,发现排序处理了 Y 轴方向的 sprite 没有问题,但 Z 轴方向的 sprite 出现了错误

这次测试的关键发现是:Y轴方向的精灵排序表现良好,而Z轴方向的精灵排序仍然存在问题。这正是我们想通过测试验证的信息点。

具体观察包括:

  • 屏幕左右两侧的树木渲染顺序是正确的,说明基于Y轴位置的排序逻辑能够准确处理多个图层的遮挡关系。
  • 相对地,基于Z轴的图层渲染顺序混乱,说明Z轴排序逻辑在当前实现下存在明显缺陷。

由此我们可以确认:

  1. Y轴排序系统工作正常,在二维空间中按垂直方向叠加图层时可以正常判断遮挡关系。
  2. Z轴排序系统存在逻辑错误或实现缺陷,可能在计算深度、推入渲染队列或排序算法本身中存在问题。
  3. 这次测试有效缩小了问题范围,我们现在可以更集中精力排查Z轴方向的排序实现,而无需对整体排序系统产生怀疑。

这为后续调试奠定了基础,下一步我们将专注分析Z轴排序相关逻辑,确保其和Y轴一样精确可靠。测试虽已结束,但结果非常有价值,下一阶段的改进方向已基本明确。

将所有问题留到周一再处理,到此为止结束今天的工作

我们目前的调试已经取得了阶段性的成果,整个渲染流程看起来基本恢复正常,尤其是Y轴方向的排序已经验证无误。然而,通过最后的观察与分析,我们得出一个重要结论:

  • Z轴的比较逻辑存在问题。这可能是当前渲染系统中唯一尚未完全修正的排序逻辑错误,需要进一步排查和修复。

尽管今天调试时间超出了预期,但我们:

  1. 完成了基础功能的验证,确认了大部分排序逻辑(尤其是Y轴)按预期运作。
  2. 初步定位了问题在Z轴比较方法或相关数据结构。
  3. 为后续的修复工作奠定了良好的基础,并确认这是可解决的问题。

目前系统状态"基本合理",接下来主要工作是:

  • 对Z轴比较逻辑进行复核和修改。
  • 简化冗余代码,提高渲染系统的健壮性与可维护性。

虽然今天没有时间继续展开,但我们已经明确了下一个攻关方向,因此在下一次调试时有望迅速解决剩余问题,推进系统稳定运行。

调试告一段落,我们将在下周继续深入这个问题。至此,这一阶段的工作告一段落,准备工作已就绪。

相关推荐
Eward-an5 分钟前
LeetCode 76. 最小覆盖子串(详细技术解析)
python·算法·leetcode·职场和发展
guygg888 分钟前
基于ADMM的MRI-PET高质量图像重建算法MATLAB实现
开发语言·算法·matlab
moonlight030410 分钟前
类加载子系统
java·jvm·算法
baivfhpwxf202316 分钟前
ACS X轴回零程序 项目实战版
网络·数据库·算法
盐水冰25 分钟前
【Redis】学习(2)Redis常见命令
数据库·redis·学习
一叶落43830 分钟前
LeetCode 219. 存在重复元素 II(C语言详解)
算法·哈希算法·散列表
adore.96831 分钟前
3.13 复试学习
学习
像污秽一样32 分钟前
算法设计与分析-习题2.4
数据结构·算法·排序算法
不想看见40433 分钟前
Reverse Bits位运算基础问题--力扣101算法题解笔记
笔记·算法·leetcode
SteveSenna38 分钟前
机械臂模仿学习2.3:生成式对抗模仿学习GAIL
学习