游戏引擎学习第307天:排序组可视化
简短谈谈直播编程的一些好处。
上次结束后,很多人都指出代码中存在一个拼写错误,因此这次我们一开始就知道有一个 bug 等待修复,省去了调试寻找错误的时间。
今天的任务就是修复这个已知 bug,然后继续排查其他潜在的问题。如果短期内没有更多 bug,我们就要着手解决我们算法中的 O(N²) 部分,因为这个性能瓶颈迟早会带来问题。
当前的目标:
- 修复已知拼写错误;
- 检查其他渲染相关的 bug;
- 若时间允许,则优化算法性能,减少不必要的平方复杂度处理逻辑。
接下来就正式开始今天的开发流程。
运行游戏并展示当前存在的排序 bug。
画面中的问题不太容易看清,不过可以看到矩形渲染存在一些异常。问题其实在打开某些调试信息后会更明显。我们打算先回到修改代码之前的状态,也就是在进行 bug 简化和定位之前的原始状态,这样可以更清楚地看到错误在没有任何修复之前表现得有多严重,尤其是排序方面的问题。
这样做的目的是为了建立一个“基线”——即了解在当前代码下排序错误的严重程度。之后我们再进行修复和优化时,就能更直观地对比效果,评估修复是否有效,以及系统是否朝着更正确的方向发展。
简而言之,现在的做法是:
- 暂时回退对代码的简化和修复操作;
- 恢复原始状态,观察 bug 在“未处理”情况下的完整表现;
- 通过实际运行画面获取视觉反馈,建立一个判断基准;
- 为后续排序逻辑修复和验证提供参考依据。
打开 game_world_mode.cpp
,重新启用 AddStandardRoom()
函数中被注释掉的 else 分支,以便生成完整房间内容。
在添加标准房间时,我们之前把房间的所有瓦片都移除了,具体是将 else
分支中的逻辑清空了。现在如果把那段逻辑恢复,游戏中就会重新填充大量房间内容。
恢复后可以看到,仅仅是渲染一个房间的内容,就已经明显出现了排序错误的问题。角色在游戏中会被一些物体(例如树木)遮挡在后面,而这些物体原本应该出现在角色的后方。也就是说,图层的前后渲染顺序是错误的。
我们目前仍无法完全确认排序到底错得有多严重,因为有些情况下传递给排序系统的数据本身就是不准确的。然而从屏幕上呈现出的效果来看,错误已经非常明显了:
- 树木被渲染在角色前方;
- 遮挡关系紊乱,透视不符合预期;
- 这些错误在只渲染一个房间时就已经存在;
- 如果有多个房间堆叠在一起,排序错误可能会更加严重。
这些现象表明,我们当前的渲染排序系统存在明显问题,排序逻辑和输入数据的准确性都需要进一步检查和修复。我们必须在继续开发之前解决这个渲染顺序的核心错误。
在 game_render_group.cpp
中修复一个错误:将 RectMinMax
改为 RectMinDim
,用于 PushRect()
的调用。
这次出现的问题是由于一个小小的笔误引起的。当我们在渲染组的代码中,把使用的 RectMinMax
改为 RectMinDim
时,忘记将所有相关函数同步修改,具体是在 PushRect
的调用中仍然保留了旧的 RectMinMax
,而这本应该也要改为 RectMinDim
。
回顾当初的修改过程,我们决定不再使用 RectMinMax
,因为已经有了更合适的 RectMinDim
,于是将某处替换为了新的函数,但遗漏了另一处调用,造成了不一致的问题。
由于 RectMinMax
接收的是一个最小点和一个最大点,而 RectMinDim
接收的是一个最小点和一个尺寸(宽高),所以如果传递了不正确的最大点,就可能导致矩形变得没有意义。例如:
- 如果最小点设得比最大点还大,就可能导致矩形变成负面积;
- 这会让该矩形在渲染过程中根本无法参与排序逻辑;
- 或者更糟糕,参与排序时产生了非预期行为,比如错乱的遮挡关系;
- 实际上很多矩形可能都无法被识别或处理,因为它们从逻辑上就“不存在”。
因此,这个笔误不仅使得渲染出来的图形异常,还可能导致整个排序系统行为异常。这一错误解释了为何部分图形完全不显示,或者出现在错误的位置。这也强调了代码一致性的重要性,在对函数调用进行重构时必须全面同步修改相关调用点。
再次运行游戏,观察修复错字后精灵排序的效果。
代码在修复了笔误之后重新运行,整体表现上确实有了一些改善。例如,一些场景元素像是台阶、地面等现在的排序已经变得正确,可以按照预期的遮挡关系来显示。
不过,仍然存在一些奇怪的问题,并不是所有地方都已经正常排序。有几个值得注意的现象我们逐个分析:
-
主角排序不正确
主角的排序目前看起来是有问题的,但这并不会带来太大困扰。我们现在还没有对主角的排序方式做详细设计。目前使用的是一些不精确的Z值,这不是未来最终的实现方式。
预计会采用一种更精确的方式,比如利用拓扑排序,把主角的身体部分之间建立严格的前后关系:头在披风上方,披风在躯干上方等等,而不需要使用Z值来强行控制顺序。通过这样的拓扑规则,我们能够让主角在所有情况下都能按照正确的层级显示。 -
跳上台阶后出现渲染异常
当跳跃到某些台阶或平台上时,会发现图像出现奇怪的重叠或交错。这些情况的发生主要是因为相关图形被渲染为在同一个Z平面上,且它们的矩形区域相互交叉。这种情况本身就是逻辑错误,不应该在正式游戏中出现。因此当前这些问题并不重要,不打算处理。 -
左侧树木排序错误
真正令人担忧的问题是屏幕左侧的树木排序。虽然其他区域的树木显示正常,但左侧明显存在排序错乱的现象。树的上半部分似乎被错误地绘制在了某些元素之下,或遮挡了本不该遮挡的内容。这个问题比较奇怪,因为同样是树,其他位置的就没有出问题。由此推测,问题可能出现在排序算法在某些位置的判断条件中,或者是数据输入阶段某些位置的图形边界不一致、坐标偏差等。
目前,虽然部分问题依旧存在,但已有初步判断哪些是暂时可以忽略的(如主角或重叠图形的问题),哪些是需要继续深入排查的(如树木的排序错乱)。接下来的计划应该会着重分析左侧树木的排序逻辑,以查清其不一致的根本原因。
运行游戏,观察剖析器排序仍存在问题。
我们观察到,目前某些元素依然显示错误,具体来说,有一些应该属于Z排序精灵(Z sprites)的图像却没有按照正确的顺序渲染出来。
这些图形本应拥有非常高的Z值,也就是说,在渲染排序中应该始终位于其他元素之上,因为它们代表的是场景中离相机最远或最前景的对象。然而,在当前的画面中,这些图像却没有处于最上层,表明它们的排序出现了问题。
进一步观察,在某些图像显示或被激活的时候,重叠关系变得正确,这暗示了某些图像之间并没有真正参与排序过程。也就是说,部分对象似乎根本没有和其他对象发生比较排序。这种情况可能说明这些对象没有被正确地插入到排序系统中,或者排序图(sort graph)中没有正确构建这些图像之间的依赖边关系,导致它们在拓扑排序中被孤立。
我们推测出现这种问题的可能原因有:
- 某些Z精灵的Z值虽然应该很大,但实际上可能因为某些错误,被设定成了错误或默认值;
- 排序图在构建时未能识别这些对象之间应当存在遮挡或前后关系;
- 排序函数在比较这些对象时未能触发预期的逻辑,导致排序无效;
- 这些对象被错误地标记或遗漏,未参与最终的图像合成与显示流程。
后续需要验证这些精灵对象是否确实是Z精灵,并确认它们的Z值是否正确传入了排序系统,同时也要进一步检查排序系统是否对它们进行了正确的拓扑比较和连接。总的来说,这种缺失排序连接关系的现象是导致排序错误的关键症结之一。
考虑增强调试能力,便于观察排序逻辑是如何工作的。
我们目前遇到的排序问题,归根结底是由于缺乏有效的可视化手段,无法直观了解排序系统的实际运行情况。
首先,我们在屏幕上看到了一些明显排序错误的图像对象,比如左侧一列树木,它们并未按照应有的顺序从下至上正确遮挡。虽然我们可以观察到结果错误,但我们无法确认具体是哪些部分出了问题。可能的原因包括对象的屏幕空间边界(Bounds)设置错误、对象没有被纳入同一个排序分组(Sort Group)、或者排序依赖关系链(即图中的边)未能正确建立。
为了解决这个问题,我们决定引入一种新的可视化机制,目标是能够:
-
绘制每个图像对象的屏幕空间边界:当前我们无法判断某个对象在屏幕上的具体边界是否正确。通过在渲染器中添加调试绘制功能,我们可以看到实际用于排序判断的矩形范围是否合理,从而发现问题所在,比如矩形是否太小、位置是否错位,是否与视觉上对象位置不符等。
-
可视化排序分组(Sort Group):我们想看到哪些对象是被分在同一个排序组中的。例如屏幕左侧的一整列树木,它们应该形成一个连续的排序链,从下到上每一个都要知道自己遮挡上面的那个。如果这些树之间没有形成连续的依赖关系(比如A遮挡B,B遮挡C),就会出现排序断裂,导致渲染错误。我们计划让调试显示能直观表示哪些对象属于同一个分组,或者以颜色、编号等方式区分不同的组。
-
辅助判定排序链断裂问题:没有这些可视化机制,我们只能从“结果错误”间接推测问题来源,缺乏对“结构错误”的直观判断。通过显示排序组结构,可以快速识别断链、不完整分组、遗漏链接等逻辑问题。
我们预计这套可视化机制一旦建成,不仅可以帮助排查当前的问题,也将对未来进一步完善拓扑排序逻辑提供重要帮助。因为排序错误如果仅凭最终图像难以溯源,而这套机制将成为我们定位排序逻辑问题的核心工具。我们将从实现边界绘制开始,逐步加入排序组高亮显示等功能。
修改 game_entity.cpp
,禁用可行走区域的体积高亮显示与轮廓渲染(PushRectOutline
)。
我们准备开始对排序的可视化进行改进。首先要做的是关闭部分实体的高亮显示,因为当前屏幕上高亮的元素太多,视觉上显得杂乱,影响对排序调试的观察效果。具体来说:
- 关闭碰撞体积的高亮显示,这样就不会再突出显示这些碰撞区域,减少干扰。
- 关闭遍历对象(traversable)的矩形边框轮廓绘制,因为这部分显示对当前调试没有必要,甚至可以考虑彻底移除。
这样一来,屏幕上就不会有过多矩形形状的叠加,界面会更加简洁,方便专注于观察排序相关的可视化内容。我们先从世界模式(World Mode)的实体渲染部分着手,逐步减少无关的视觉元素,为后续绘制精确的排序边界和分组效果做准备。
再次运行游戏,注意到渲染速度明显提升,同时树木的排序也正确了。
关闭了部分实体高亮后,界面变得清爽不少,运行速度也明显提升了。推测原因是因为减少了高亮的矩形和分组,导致需要排序的元素减少,从而降低了排序算法中n²复杂度的开销,但具体影响还有待确认。
观察当前状态发现,移除这些高亮之后,有些元素反而能正确排序了,这点有些奇怪,不太清楚为什么会有这么明显的影响。尤其是在操作选择光标时,排序表现出很怪异的现象。
发现只要加入几个层次分明的Z轴精灵(z sprites),排序顺序就会和只有一个平面的海面精灵时完全不同,说明排序逻辑受这些分层元素影响很大。这种现象很反常,所以打算进一步收集数据,尝试在已有房间上再叠加一个房间,以观察排序表现如何变化。
修改 game_world_mode.cpp
中的 AddStandardRoom()
函数,添加一个上层房间。
我们决定在现有场景中再叠加一个标准房间,目的是观察新增房间中的精灵(sprites)在排序时的表现,看看多层叠加对排序系统会产生什么影响。我们准备快速查看这个变化,然后再决定是否保留或移除这个叠加房间。
运行游戏,发现排序实际上还是不正确。
我们观察到叠加房间后,排序结果依然错误,有些精灵显示在上方,有些却显示在下方,说明排序存在比较根本性的问题。虽然界面上有些辅助显示条可以帮助定位问题,但现在看来这些辅助工具并不是特别必要,当然如果需要排查那个bug,还是可以重新启用它们。总之,排序问题依旧明显,需要继续深入调试。
修改 game_opengl.cpp
,让 OpenGLRenderCommands()
绘制精灵边界框。
我们计划从渲染器入手,主要是在OpenGL渲染部分进行修改,因为这是现在最常用的渲染方式。目标是在渲染过程中增加对排序组和精灵边界的可视化展示。幸运的是,精灵边界的数据是有序的,可以通过相同的索引访问其他相关数据,尽管实际上不一定非得依赖索引对应。
具体做法是在绘制完所有内容后,再增加一个绘制步骤,专门绘制所有精灵的边界。因为我们已经有了这些边界数据,理论上没有什么障碍。实现时,可以通过一个循环遍历所有边界数据,然后绘制出来。
在代码中,使用了“sort sprite bound”结构,里面包含了边界的详细信息,比如边缘情况、哪些精灵在前哪些在后等,这些信息对应的是索引到精灵边界数组。基于这些数据,我们可以绘制出很多有用的信息,帮助理解排序的具体情况,辅助后续调试和问题定位。
在 game_render.h
的 sprite_flag
枚举中添加 Sprite_DebugBox
标志位。
在已有的“visited”和“drawn”标记基础上,可以新增一个“sprite debug box”功能,用来实现类似递归的边界绘制效果。通过这个功能,可以展示不同连接组之间的路径关系,帮助我们直观地看到排序组内部的连接情况和层级结构。这种可视化有助于更深入理解排序过程中的复杂关联,从而更有效地调试和优化排序逻辑。
继续完善 OpenGLRenderCommands()
的实现。
实现这个功能相对简单,主要是写一个递归绘制边界的函数。这个函数会接受一个边界对象,同时传递该边界的索引,以便在递归调用时能清楚知道当前处理的是哪个边界。通过递归调用,可以逐步绘制连接的排序边界,帮助我们更清晰地理解不同排序组之间的关系。这个方法借鉴了之前的做法,但增加了传递边界索引的参数,使调试时能更好地追踪和区分不同的调用层级。
在 game_opengl.cpp
中引入递归函数 OpenGLDrawBoundsRecursive()
用于绘制边界框。
我们打算实现一个递归绘制边界的功能,首先在调用该函数时,会传入当前边界的索引,以便能在递归中跟踪访问状态。每次访问一个边界时,会设置一个调试标志,避免重复绘制。然后会遍历与当前边界相连的所有边,按照从前到后的顺序递归调用绘制函数。
在绘制时,我们计划先画出每个精灵的屏幕边界区域,这样可以直观地看到我们为每个精灵指定的边界范围。同时,还想在这些边界之间画出连接线,连接不同边界的中心点,方便观察它们之间的排序关系。
为了更清晰地展示排序组的结构,我们打算给同一连通排序组中的所有边界用同一种颜色绘制。为此,准备使用一个系统性的调试颜色表,这个颜色表是共享的,方便团队其他成员也能用到它进行调试和可视化。
整体上,这个方案能够帮助我们更直观地理解和调试排序边界及其连接关系,从而更好地定位排序中的问题。
考虑还是键一个game_shared.h把减少文件直接的相互依赖,放一些公共的common(公共)
修改 OpenGLRenderCommands()
,让不同边界框使用不同颜色。
我们计划在绘制递归边界时,为每个排序组分配不同的颜色,具体做法是给每个组传入一个颜色参数。整个绘制过程会用线条来表现,递归调用时用一个开始-结束的绘制块包裹。为了区分不同组,每当绘制新的组时,从调试颜色表中依次取颜色。
为了避免给已经绘制过的边界重复分配颜色,我们会先检查该边界的标志位,只有未被标记的边界才会进入绘制并分配颜色。这样就不会因为索引顺序而浪费颜色资源,保证颜色表被合理使用,从而使颜色分配更有意义。
在实现中遇到了一些C++语言的细节难点,比如变量未声明、函数参数不匹配等问题,也发现了一些模块间的依赖和声明顺序问题,进行了调整,比如前向声明相关数据结构。关于颜色传递,最后发现其实不用显式传递颜色参数,因为可以直接调用OpenGL设置颜色,所有线条绘制统一颜色即可。
整体流程就是先遍历所有边界,根据标志位判断是否绘制新组,分配颜色后递归绘制对应边界和连接线条,过程中处理好C++代码中的类型声明和参数匹配问题,确保绘制逻辑顺畅。这样能直观展现排序组及其关系,辅助调试排序问题。
运行游戏,发现没有任何效果。
目前虽然已经绘制了彩色的OpenGL框架,但由于还没有真正发出绘制线条的指令,所以画面上暂时不会有变化,仍然只显示之前的内容,这样暂时足够用于观察和调试。
在 OpenGLRenderCommands()
中调用 glDisable(GL_TEXTURE_2D)
来关闭纹理,在绘制边界框之前执行。
准备开始绘制线条时,需要回顾一下OpenGL的绘制流程。绘制时要先启动一个图元类型(比如三角形),然后设置顶点指针。这里绘制线条会遇到一个问题,就是默认情况下纹理功能是开启的(通常是启用的texture 2D),这会导致绘制的线条被纹理影响。因此,在绘制线条之前必须关闭纹理功能,确保线条不会被任何纹理污染。关闭纹理后再进行线条绘制,这样才能正确显示纯色的调试线条。
在 OpenGLDrawBoundsRecursive()
中添加功能:在两个精灵边界中心之间绘制线条。
在OpenGL渲染中,只需提交一些实际顶点即可绘制线条。这里有几种线条类型需要绘制:
首先是从当前元素指向其“排序邻居”的线条。可以将这个邻居称为“neighbor”,即当前的bound加上“behind edge”的索引得到。为绘制这条线,需获取当前元素和邻居的屏幕空间边界(screen area)的中心点。可以调用 get_center
函数来获取这些矩形的中心点,如果函数未实现,可以在矩形结构中添加该方法。
获取了两个中心点后,就能在它们之间绘制一条线。由于这些线都使用相同颜色进行绘制,因此实际提交顶点的时机无所谓,只要在适当递归调用后提交就可以了。绘制时,只需提交两个顶点,一个是当前中心点,一个是邻居的中心点。
在绘制完连接线后,还需要绘制代表每个元素的矩形框。这个过程是通过获取每个元素的 screen_area
的最小和最大点(min 和 max)来实现的,然后用这四个点绘制一个矩形轮廓线。系统中应该已经有一个用于绘制矩形轮廓的OpenGL函数,可以调用它完成这一步。
总结:
- 关闭纹理绘制以避免干扰;
- 遍历每个sprite bound,递归绘制与其相连的其他bounds;
- 每个group用不同颜色绘制;
- 每个bound绘制其矩形框;
- 每两个相连的bounds之间绘制一条连接线;
- 所有线和框都以非纹理方式纯色绘制,方便调试排序关系和边界结构。
引入 OpenGLLineVertices()
函数用于绘制线条。
接下来可以增加一个用于绘制线框矩形的功能,比如叫做 OpenGLLineVertices
。这个功能的作用与之前绘制实心矩形类似,不过不是绘制填充的三角形,而是沿着矩形的边界画线,也就是只画矩形的轮廓。
具体实现步骤如下:
- 从矩形的左上角(min)开始;
- 向右绘制至右上角(min.x 到 max.x);
- 向下绘制至右下角(min.y 到 max.y);
- 向左绘制至左下角(max.x 到 min.x);
- 再向上返回起点(max.y 到 min.y);
这样就顺时针或逆时针地将矩形的四条边都绘制出来了,共需画四条线段。
每条边都由两个顶点构成,总共需要提交八个顶点来绘制出这个线框矩形。这种方式适合用于调试或可视化 sprite 的边界信息,因为线框矩形不会遮挡其他图形内容,可以清晰地展现出各个元素的占位情况和层级关系。
这个功能可以与之前提到的递归遍历排序节点、绘制连线的流程结合使用,从而全面可视化 sprite 系统中各个对象的空间关系和排序边界。
在 OpenGLDrawBoundsRecursive()
中调用 OpenGLLineVertices()
进行绘制。
现在我们可以简单地输出矩形的顶点信息来绘制线框,只需要针对这个矩形调用对应的顶点输出函数就可以了。希望一切顺利。
不过这时候出现了一个小问题:尝试访问 screen_area
的时候发现了一个标识符找不到的错误。原来是 get_min
函数中用的是 mid_corner
,而不是预期中的 min
,至于为什么最初取名为 mid_corner
,已经记不清了,但当时可能是觉得这样更准确或更符合语义。
无论如何,解决这个问题的方法就是把调用处做相应调整,改成使用正确的命名(即使用 mid_corner
来访问对应信息),这样就能顺利地提取矩形边界并继续完成线框绘制的工作了。
整个流程的目的,是为了在不遮挡场景的前提下,通过简单的线段清晰可视化每一个 sprite 的屏幕占用范围,以及它们之间的连接关系。这在调试复杂排序和遮挡逻辑时是非常有帮助的。
运行游戏,看到新的调试可视化效果。
为什么没有显示呢
发现是GL_LINES的原因
https://registry.khronos.org/OpenGL-Refpages/gl4/html/glPolygonMode.xhtml
glPolygonMode() 函数中的 GL_LINE 模式,而不是 glBegin(GL_LINE)(后者是不存在的)
画的不对
只留hero看看
画的hero周边的线不怎么对
ScreenArea 中的值貌似不对
在 game_world_mode.cpp
中暂时禁用 UpdateAndRenderWorld()
中的清屏操作(Clear()
)。
我们决定暂时去掉清屏(clear screen)操作,以验证当前渲染问题是否与之有关。
因为之前在屏幕上看到有些矩形区域的渲染结果明显不正确,看起来像是“无效”或“杂乱”的区域。这让我们怀疑是否所有图形都被错误地连接到了背景或者清屏图形对象上,从而导致视觉上的错乱。
为此我们尝试暂时去掉清屏逻辑,看看是否所有多余的连接线都会消失,这样就能确认是否是由于这些区域始终连向清屏造成的。如果结果确实如此,那么这些异常现象并不表示真正的逻辑错误,而只是调试辅助图像渲染中的可视化误导。
这一步的核心目的是排查边界框的递归连接绘制时是否与背景或清屏图层存在不当的依赖。我们希望通过排除清屏图层验证是否真的是所有错误连线都是因为它在场。
简单来说,我们采用“移除清屏,再观察结果”的方式来调试视觉问题,确保数据结构和可视化逻辑之间的对应关系没有出错。
运行游戏,观察未清屏情况下的效果,并思考更合理的清屏时机。
我们确认了一个重要问题:之前所有的元素都被错误地归并进一个巨大的分组,原因就在于“清屏(clear)”操作本身。因为清屏的图像覆盖了整个屏幕,所以其他所有元素在排序时都被强制连接到了这个清屏对象,导致它们在调试辅助图形中被显示为同属一个巨大连通区域。
现在已经明确这是由清屏造成的行为之后,我们对当前的分组检测结果感到比较满意,看起来也相对正常合理了。
但是,保留清屏作为一个普通图层参与排序操作其实是没有必要的。因为清屏总是在所有渲染动作之前发生,它的存在本就不需要作为排序流程的一部分。如果继续将它当作一个标准渲染项参与排序,会导致整个排序系统无法正确区分不同元素的逻辑关系,使所有图层都间接连接为一个统一组,这是没有意义的。
于是我们开始思考如何“合理地”将清屏逻辑从整个排序流程中抽离出去。有一种最直接的方式是把清屏设置为一个独立的状态设置项,比如只设定一个清屏颜色参数,然后在渲染流程开始时自动清除颜色缓冲,而不让它出现在排序图层队列中。
我们之所以对这种方式稍有保留,是担心未来渲染系统可能会扩展到多渲染目标(Render Target)的场景,比如有多个屏幕区域、不同缓冲区需要分别清理的情况。但仔细一想,这种扩展也可以通过设定“渲染目标 + 清屏颜色”组合的方式来处理,因此清屏操作作为单独管理是合理且不会带来问题的。
总的来说,我们可以将清屏操作独立管理,从排序流程中剥离出去。这样可以避免无意义的全局连接,提升调试可读性,同时也不会限制未来系统的拓展性。清屏作为启动初始化动作,并不需要成为排序逻辑的参与者,因此将其从中剔除是当前正确的优化决策。
修改 OpenGLRenderCommands()
,直接在这里调用 glClear()
,并考虑永久将清屏逻辑从 UpdateAndRenderWorld()
中移除。
我们考虑了一种更合适的做法来处理清屏(Clear)的问题:既然这是游戏内部的 OpenGL 处理,我们可以把清屏操作直接归为“内部渲染指令”,而不是像现在这样把它作为一种排序元素参与整个图层排序。
具体而言,我们可以在一个合适的地方,直接使用 glClearColor
函数来设定清屏的颜色,然后在渲染流程开始时调用一次 glClear
,就完成了背景清除的任务。这样可以完全绕开将清屏逻辑纳入图层排序系统的需求。
更进一步讲,我们可以在渲染流程的初始化阶段,像如下方式处理:
glClearColor(R, G, B, A);
glClear(GL_COLOR_BUFFER_BIT);
这意味着清屏只执行一次,作为整个渲染管线的“背景准备步骤”,不再作为一个实际渲染元素出现。
这样的做法有几个明显的好处:
- 避免干扰图层排序逻辑:清屏元素覆盖整个屏幕,如果参与排序,会让其他所有图层都与它发生“连接”,形成无意义的巨大连通块,干扰调试辅助图形的判读。
- 符合渲染流程直觉:清屏本质是“初始化画布”,和具体的图层绘制是两件事情,不应放在同一处理逻辑中。
- 提高可维护性与可扩展性:未来即使有多个渲染目标(Render Targets)或不同的清屏策略,也可以通过集中管理清屏状态,而不污染排序流程。
综上,我们决定将清屏作为一个独立处理的 OpenGL 状态设定,并在适当位置调用执行,而不是像以前那样把它放进排序系统中去参与分组或连通性判断。这种方式更加合理、简洁,并为后续功能扩展留下了良好的基础。
“让我们开启令人惊艳的粉红色背景”α。
在 game_platform.h
的 game_render_commands
结构体中添加 ClearColor
字段,同时修改屏幕清除逻辑。
我们决定对平台系统中的渲染命令结构进行调整。为此,我们在原有传递内容的基础上,新增了一个 clear color(清屏颜色)的字段。这么做的目的,是为了让渲染系统(如 OpenGL)可以直接读取这个字段,作为清屏时使用的颜色,而不再通过单独的“清屏”命令来实现。
接着我们取消了原本在渲染命令队列中定义的 RenderEntryType_Clear
类型。也就是说,清屏操作不再通过向命令队列中压入一条专门的清屏命令来完成。而是在执行清屏的地方,直接从命令结构中读取 clear color 字段的值来执行清屏动作。这样一来,系统不需要为清屏操作维护单独的命令数据,清理了逻辑结构。
随后,在原本用于生成清屏命令的代码部分,我们取消了向命令缓冲区压入数据的操作,转而直接在命令结构中设置 clear color 值。如此一来,整个渲染流程中不再依赖清屏命令对象,而是通过状态值进行控制。
同时,虽然我们清除了与清屏相关的命令部分,但仍然保留了原有的 screen area(屏幕区域)数据结构,出于其他用途的考虑,暂时不对这部分结构做修改。
在调整过程中,我们还处理了一个类型转换相关的问题:系统中某处需要从 u32
类型转换到 real32
类型。为此,我们在渲染命令结构中为 clear color 设置了初始默认值,比如黑色(black),确保系统在未显式设置时也有安全默认值可用。
最后,为保持渲染系统一致性,我们还移除了软件渲染器中与原清屏命令相关的处理逻辑,避免冗余,同时确保整个渲染系统的行为仍然符合预期。通过以上修改,我们完成了从基于命令的清屏方式向基于状态字段的清屏机制的迁移,简化了渲染命令管理流程,并提升了结构清晰度和执行效率。
运行游戏,观察当前渲染结果。
我们现在已经成功恢复了 clear color(清屏颜色)的设置功能,这个功能重新回到了渲染流程中,说明相关机制已经恢复正常。
接着,我们观察到了渲染命令的排序逻辑现在按预期进行了优化:各项命令被正确地分组和排序,在逻辑上被划分为了本地的、独立的小组,这种排序方式符合我们之前的目标。
我们还注意到,目前的排序方式是“单一排序”(single sort),这是我们希望实现的形式。它避免了原本由于复杂依赖关系带来的排序循环问题。之前排序操作中可能存在多个依赖回路,导致排序无法有效进行,性能受限。而现在这种简化后的排序机制更容易打破循环,提高了排序效率。
为了更准确地评估当前系统的排序性能,我们暂时关闭了调试模式(debug),以便排除调试信息对性能的干扰。我们认为,之前的排序系统由于依赖关系复杂,导致了性能瓶颈,现在通过这种更清晰的排序方式,性能应该有明显改善。
我们下一步的计划是尝试启用更多的房间(rooms),测试在更复杂场景下当前排序机制的表现。同时我们也准备研究如何让某些特定渲染项不参与排序逻辑,作为排序系统之外的独立存在,这将在后续进一步处理。现在我们先切换回主场景进行这些操作的验证。
修改 OpenGLRenderCommands()
,增加开关控制是否绘制精灵边界框。
我们计划添加一个功能,用于显示渲染排序分组(sort groups),这样便于在调试或观察排序逻辑时更清晰地看到各个命令的分组情况。为此,我们打算新增一个全局变量 b32 Global_ShowSortGroups
,作为一个控制开关来启用或禁用该功能。
虽然我们一开始对整个排序系统的实现细节已经有些遗忘,但大致流程仍可回忆起来。我们可以在合适的位置插入一个条件判断,根据 Global_ShowSortGroups
的值来决定是否执行显示排序分组的逻辑。
随后我们意识到,这段逻辑是在平台层(platform layer)中实现的,因此需要回顾平台层是如何处理控制变量的。我们找到了平台控制逻辑的定义,其中已经存在两个控制变量,通过这部分结构,我们可以向其添加我们自己的控制变量 Global_ShowSortGroups
。
我们将其放置在控制结构的顶部,与已有变量并列。按理论推导,这样一来,只要我们在平台层正确处理这个变量的输入(如按键触发、命令行标志等),并在渲染层读取这个变量的状态,就能实现对排序分组显示功能的控制。
整体上,这是为调试渲染命令排序系统所设计的辅助工具之一,目的在于进一步可视化和验证分组逻辑是否符合预期,提升开发效率和代码可靠性。
运行游戏,测试边界框开关功能。
我们完成了对 Global_ShowSortGroups
控制变量的添加,接下来当然需要重新编译整个工程,确保变量能被正确识别并参与运行时控制。虽然逻辑上还存在一些小问题,功能还不是完全正确,但从运行效果来看,整体机制已经基本起效,说明方向是正确的。
当前的实现允许我们在运行时切换是否显示排序分组,这为我们后续进行调试和逻辑验证提供了方便。我们可以手动启用或禁用该选项,观察渲染系统中不同命令被分配到的排序组,从而判断排序策略是否按照预期执行。
尽管目前切换行为存在一些瑕疵,可能表现为状态未及时更新或部分渲染效果异常,但至少功能上已经联通了输入、平台控制逻辑以及渲染展示,这为进一步修正和完善打下了基础。
整体来看,我们正在逐步理清整个排序显示控制流程的结构,系统开始向可调试、可验证的方向推进。接下来的重点将是解决当前逻辑中仍然存在的不一致问题,确保开关行为更加准确、稳定。
修改 AddStandardRoom()
,让其生成多个图层。
我们现在已经可以通过开关 Global_ShowSortGroups
来控制是否显示排序分组。在关闭该选项后,渲染排序看起来明显更接近预期,虽然未必完全正确,但至少逻辑上已经朝着正确的方向发展。当前的排序机制已经表现出初步有效的迹象,说明之前的问题部分已经被解决。
不过,我们也发现仍然存在一些问题。例如,在界面中某些对象的排序仍然不正确,显示效果不符合预期。此外,目前的测试场景相对简单,无法充分检验排序系统在复杂条件下的表现,因此无法全面评估排序逻辑的健壮性。
为更彻底地测试排序系统,我们打算构造一些更加复杂和极端的测试场景,例如在多个房间(rooms)叠加在一起的情况下观察排序行为。这种“堆叠房间”的场景更容易暴露隐藏的排序问题,有助于检验当前排序策略是否具备足够的通用性和正确性。
通过这些测试,我们可以更清晰地了解当前系统距离最终理想状态还有多远,识别出尚未解决的问题,进一步修正排序规则,朝着实现全面、准确的排序逻辑目标迈进。
运行游戏,观察当前精灵排序情况。
我们实际测试了一下当前的排序效果,结果并不算糟糕,整体表现已经有了一定改善,但显然仍然不够正确,存在不少问题。从渲染结果来看,很多元素的前后顺序仍然混乱,视觉上无法实现我们期望的图层叠加效果。
根据这些观察,我们判断问题很可能出在两个方面:
一是排序准则(sort criteria)本身存在不足。当前的排序规则可能无法全面覆盖所有需要考虑的层级逻辑、空间位置或优先级关系,导致在某些情况下元素排列顺序不符合预期。我们需要对排序权重、比较逻辑等进行重新审视,确保排序系统具备更强的判别能力。
二是存在排序环(sort cycles)。这意味着部分渲染项之间形成了互相依赖的循环引用,导致无法建立一个稳定且无矛盾的排序顺序。这种情况会严重干扰渲染顺序的决策过程,进而使最终输出结果错乱。因此,我们必须引入机制来检测并打破这些排序环,或者在逻辑上避免这种依赖关系的出现。
下一步,我们会集中精力分析当前排序逻辑中的关键节点,定位可能导致错误的比较条件,并结合实际运行时数据查找潜在的环状依赖。只有解决了这两个核心问题,排序系统才能真正稳定且正确地工作,实现我们对渲染秩序的预期控制目标。
在黑板上记录:排序错误来源分析。
我们现在打算添加一种可视化指示器,用于在出现排序循环问题(sort cycles)时给予提示,帮助识别和调试这些逻辑错误。在正式处理这一问题之前,我们先回顾并整理了当前排序系统中可能导致错误的三大来源。
我们在画板上做了归纳,明确了排序错误的三类核心来源:
-
代码中的 Bug
最常见也最直接的问题来源就是代码实现中的错误。例如排序逻辑编写不当、比较条件失效、内存越界或数据未初始化等,这类问题通常通过审查代码或调试日志可以发现。 -
排序循环(Sort Cycles)
排序过程中存在命令之间互相依赖的情况,导致形成一个闭环,使排序无法线性化。这种问题较为隐蔽,不容易通过单步调试发现,但会直接导致排序失败或异常结果。 -
排序准则设计不足(Criteria Deficiency)
排序规则本身设计不完善,比如缺少考虑某些空间关系、遮挡关系或逻辑优先级,导致某些场景下排序结果错误。即使没有 Bug,也没有循环,这类设计缺陷依然会造成结果不正确。
我们目前准备重点处理第二类问题:排序循环。为了更有效地识别这类问题,我们计划添加图形化或调试辅助方式,例如绘制一个实时指示器或高亮机制,用于标记当前渲染数据中是否存在循环依赖。一旦检测到循环,可以直接通过界面上看到提醒,而无需每次都手动调试,这将极大提升排查效率。
接下来我们将专注于实现这个检测机制,并验证其在复杂场景下的可靠性,为后续彻底解决排序逻辑中的结构性问题打下基础。
黑板记录:可能存在的 bug。
我们目前在排查和处理排序系统的问题时,明确了理论上设想都是正确的,但实现过程中可能存在具体的错误,这成为一个主要的错误来源。
我们已经识别出的第一类排序错误来源是:实现层面的失误。
即使我们在理论设计、逻辑推导和整体架构上都没有问题,也可能因为实际编码中的失误导致系统无法正常工作。例如最典型的是拼写错误(type),就像我们在开发初期修复的一个拼写错误那样,看似简单,却足以影响整个功能的执行。这类错误也包括变量未初始化、数组越界、错误的条件判断、遗漏的分支处理等典型代码层面的 bug。
这种问题的特点是:不在理论逻辑中产生,而是在具体代码实现中引入。它通常通过调试和代码审查能较快定位和修复,但在复杂系统中,如果没有良好的日志和验证机制,仍可能造成大量时间的浪费。
接下来我们将继续整理剩下几类排序错误来源,并在此基础上构建更完整的排序问题检测和提示系统,确保无论是代码错误、结构缺陷还是逻辑矛盾都能被尽早发现并加以处理。
黑板记录:Z值比较逻辑可能有误。
我们总结出的第二类排序错误来源是:比较函数设计不合理。
当前使用的排序比较逻辑是我们“临时”制定的,用于处理普通精灵(sprite)与深度精灵(Z-sprite)之间的排序关系。然而,这套比较规则从未经过严格验证,也未明确地证明它适用于所有渲染场景。因此,即使排序算法本身逻辑无误、代码无 Bug,排序结果仍可能出现严重错误,原因就在于比较逻辑本身理论上就是不成立的。
我们面临的一个根本性问题是:在二维渲染系统中试图模拟三维场景下的物体层级关系。当前的实现方式只是将三维对象投影为二维“卡片”(cards),但这些卡片无法准确表达三维物体之间复杂的遮挡、嵌套、交错等空间关系。这意味着,我们目前的排序依据其实是一种近似,是人为设计的一套简化模型,它在很多情况下无法真实反映物体间的正确前后关系。
这种错误的特点是:比较函数本身在理论层面无法适配所有场景,它不是由实现 bug 导致的,而是设计本身的局限性。一些具体场景中,即使比较逻辑完全按预期运行,得到的排序结果也可能不正确。
我们已经意识到这种问题是不可避免的,只要使用当前这种简化模型,就一定会存在某些场景是无法被正确排序的。为了应对这种情况,我们需要:
- 识别并记录这种排序不可能正确处理的场景;
- 尝试设计更加稳健或多阶段的排序逻辑;
- 考虑引入深度信息或其他结构性数据作为辅助判断;
- 提供可视化工具用于提示潜在不可靠排序结果。
最终目标是尽可能在系统层面规避比较逻辑固有的不足,或者在用户视角下提供预警与修复机制,从而提升渲染的稳定性和可靠性。
黑板记录:图结构中存在循环引用的问题。
最后一个排序错误的来源是:图的循环问题及其解决(graph cycle resolution)。
这里所说的“解决”指的是处理循环依赖关系的行为,而不是像素分辨率之类的意思。排序系统中,我们可能会遇到顶点之间形成环状依赖的情况,也就是所谓的“循环”,这意味着没有办法对这些对象给出一个唯一且正确的绘制顺序。
例如最简单的循环是几个元素按顺序相互依赖,形成一个环(环状图),导致排序准则无法确定哪个元素应该先绘制。这种情况本质上说明排序比较函数存在不足,因为它无法为这组对象提供一个一致且线性的排序结果。
不过,也有可能是因为我们缺乏一个更加智能和合理的断环策略。比如当前系统选择在某个环路的特定位置断开,导致绘制顺序为 ABC,但如果换个断开点,比如断在 B 或 C 处,绘制顺序变成 BCA,最终效果可能更好。这表明不同的断环点会影响排序结果的合理性。
因此,除了改进比较逻辑,我们还需要研究如何更有效地检测和打破这些排序循环,从而得到更符合实际需求的绘制顺序。没有万能的解决方案,因为这是一个本质上复杂且难以穷尽的问题,尤其是在我们仅用二维卡片来模拟三维空间,没有使用诸如深度缓冲(z-buffer)等硬件支持的情况下。
总结来说,这三个主要的排序错误来源包括:
- 实现上的代码错误(bug);
- 比较函数设计本身的理论不足;
- 排序依赖关系中存在的循环及其断环策略。
理解并解决这些问题,是提升渲染排序正确性和稳定性的关键所在。
考虑绘制图循环结构,以及解决该循环的方法。
我们现在剩下大约五分钟的时间,计划快速实现一个功能,用于绘制和检测图中的排序循环(graph cycles),这样就能直观地知道排序中是否存在循环问题。
当前排序算法在遍历精灵图时,每访问一个节点都会设置一个“访问过”的标记(visited flag),用来避免重复访问同一个节点。这虽然方便,但存在一个局限:它只能告诉我们某个节点曾经被访问过,但无法判断在本次特定的遍历路径中是否重新访问了该节点。
也就是说,现有标记机制不能确定我们是否“绕回了自己本来的路径”,而只有这种情况才代表真正的循环。节点可能是在别的路径中被访问过的,但这不代表当前路径存在环。
为了解决这个问题,我们需要一种方式来记录当前遍历路径中的访问情况,以便发现当路径再次访问到自己时,确认存在循环。
实现上有两种方案:
-
利用一个额外的“路径访问标记”,区别于全局的访问标记。每次进入新节点时,将其标记为当前路径中的节点,如果在路径中再次遇到该节点,立即判定为循环。
-
保持原有算法大体不变,只做小修改,在递归进入和退出节点时分别设置和清除路径标记,从而追踪当前路径状态。
这两种方法都可以较为简单地集成到现有排序遍历代码中,不需要大幅修改算法逻辑。通过加入这种路径跟踪,我们能够准确检测排序依赖图中存在的循环,并基于此绘制出循环结构,方便调试和进一步优化排序策略。
黑板记录:增加 Generation Tag 用于标记图遍历阶段。
我们现在有两种非常简单的方法可以检测图中的循环,下面是对这两种方法的详细总结:
第一种方法是使用生成标签(generation tag)。
这个概念之前在资产跟踪系统中也用过,基本思路是维护一个数字标签,每次算法运行时这个标签会递增。具体做法是:
-
每个节点有一个标签变量,表示上一次被访问时的“代数”或“时间戳”。
-
在每次排序遍历开始时,我们增加全局的“当前代数”值。
-
当遍历节点时,检查该节点的标签值是否等于当前代数:
- 如果是等于,说明这个节点在本次遍历已经被访问过,出现了环路(循环)。
- 如果不是,说明节点还未在本次遍历中被访问,可以继续处理,同时将节点标签更新为当前代数。
相比传统的用一个布尔值标记是否访问过的方法,生成标签能更精确地区分不同遍历过程中的访问状态。布尔值只能表示是否“曾经访问过”,但不能区分“这次遍历”或“上次遍历”等不同时间点。生成标签机制则能够准确反映本次遍历中的访问路径,帮助我们判断是否存在循环。
总结来说,生成标签法通过一个整数递增计数器配合每个节点的标签值,能在每次遍历时准确标记访问历史,轻松检测出循环路径。
第二种方法在后续继续讨论,但第一种生成标签法已经能够较好解决当前检测循环的问题,且实现简单,易于集成到现有排序代码中。
黑板记录:增加 Erasable Bit,用于调试/回退精灵状态。
第二种方法是使用可擦除标签(erasable tag)。
具体来说,我们有一个“访问过”的标记位,这个位需要是永久性的,也就是说一旦某个节点被访问过,这个位就一直保持,防止之后的遍历重复处理已经遍历过的图部分。这样做的目的是,当新的遍历开始时,如果碰到已经标记过的节点,可以直接跳过,认为该部分的排序已经完成,不必再次遍历。
然而,这样的问题是:这个永久的访问标记无法区分当前遍历是谁设定的。也就是说,某个节点上的访问标记可能是之前某次遍历留下的,而当前遍历者无法判断它是不是自己“走过”的路径。所以,这种情况下,我们不能确定访问标记的存在是否意味着当前路径中有循环,因为它可能是由其他路径设置的。
为了解决这个问题,我们可以增加另一个标记位,这个位是在当前遍历过程中动态设置的。遍历时进入节点就设置该位,如果再次访问时发现该位已被设置,说明当前路径出现了环(循环)。当递归返回时,可以擦除这个位,保持状态的准确。
总结来说,这种方法用一个永久访问标记来避免重复遍历,用另一个可擦除的“当前路径访问标记”来检测当前遍历中的循环。这样能更精准地判断循环,同时又能避免多次重复遍历图的部分,提高效率。
在 game_render.h
的 sprite_flag
中添加 Sprite_Cycle
枚举值。
我们准备在节点结构中增加一个字段,称为“sprite cycle check”(精灵循环检查标志),用来检测图中的循环问题。原本考虑使用两个标志位,一个用来检测是否出现循环,一个用来标记节点是否处于循环中,但后来觉得其实只用一个标志就足够了。
在具体实现时,会用这个“sprite cycle check”标志来判断当前遍历是否遇到了循环,一旦发现某节点的这个标志已经被设置,说明在当前路径中再次访问了该节点,确认存在循环。
由于时间有限,暂时决定只用这一个标志来完成循环检测的功能,简化实现过程。这样可以快速开始对图中的循环问题进行检测和调试。
在 game_render.cpp
中更新 RecursiveFromToBack()
:遍历前设置 Sprite_Cycle
,遍历后清除。
我们准备快速把循环检测的逻辑加进去,具体操作是:
在遍历某个节点时,会用按位或(OR)操作把“循环标志”设置进去,表示当前路径已经经过该节点。
当完成对该节点的所有边遍历后(即回溯回来时),会用按位与(AND)配合取反操作把这个循环标志清除掉,表示当前路径已经离开该节点,不再算作正在访问中。
这样做的目的是在递归遍历过程中,动态地标记当前访问路径中的节点,当遇到已标记节点时,能够准确判断存在循环;而在递归返回时清除标记,保证状态的准确和不会误判。
目前先把这个逻辑写进代码,后续再考虑如何更好地处理和利用检测到的循环信息。
在 game_render.h
中的 sprite_graph_walk
结构体添加 HitCycle
字段。
我们在遍历节点时,进入节点时会设置循环标志,离开节点时会清除该标志。这样,当到达某个节点时,如果发现该节点的循环标志已经被设置,说明我们遇到了一个循环,因为对于非循环路径,标志会在离开节点时被清除。
因此,我们可以通过检查这个标志来判断是否存在循环。
为了方便表示当前是否处于循环状态,我们计划利用已有的“graph lock”(图锁)机制,增加一个变量或标志来记录“是否在循环中”,从而在遍历和后续处理时能及时获知是否发生了循环情况。
修改 WalkSpriteGraph()
和 RecursiveFromToBack()
,配合 HitCycle
检测图中的循环。
我们在开始遍历一个组时,会先将“未检测到循环”状态初始化为假(即还没发现循环)。在遍历过程中,如果发现有循环,则将“检测到循环”的标志设置为真。
每次遍历节点时,会通过逻辑“或”操作,将当前节点的循环标志与整体的“检测到循环”标志合并,从而确保只要路径中有一个循环,整个遍历结果都会反映出存在循环。
当遍历过程中遇到已经处于循环状态的节点时,会保持该循环标志不清除,保证整个组都被标记为含有循环。
在遍历结束回退时,如果没有检测到循环,则清除循环标志,否则保留该标志,确保最终能标记出所有包含循环的组。
这样我们就能识别并“标记”任何存在循环的组,方便后续处理和调试。
修改 OpenGLRenderCommands()
,仅绘制带有循环标志位的精灵边界框。
我们计划绘制那些标记为存在循环的组,并且默认开启这个功能。具体做法是在绘制边界的递归函数中增加一个判断:只有当组的循环标志被设置时,才进行绘制;如果组没有循环标志,则跳过绘制。
这样,我们就能只显示那些在排序过程中检测到有循环问题的组,方便我们定位和调试排序中的循环错误。
运行游戏,确认当前无图结构循环。
理论上,如果存在循环,我们应该能看到对应组的轮廓被绘制出来,但目前没有看到任何轮廓,这说明要么我们系统中没有循环存在,要么我们实现的检测循环的算法有问题。具体算法是否有效,后续还需要继续调试验证,因为现在时间已经不够了。这是接下来要做的工作重点。
命中断言了
注释掉断言
栈溢出了不会是发生循环图了
检查一下什么原因
晕死
进入问答环节。
你是如何判断一个问题是否复杂到需要可视化的?
判断一个问题是否复杂到需要可视化,通常依赖于我们在解决问题过程中遇到的认知难度。如果我们在脑中思考问题时感到混乱,或者尝试多种调试方式依然无法明确问题本质,通常就意味着我们可能需要借助可视化手段。
这个判断并不总是显而易见的,它更多地依赖于经验积累形成的一种直觉。当我们在面对一个 bug 或一个逻辑难题时,通常会先用自己的习惯方式着手,比如逐步调试、打印日志或分析数据结构的状态。如果这些方法能够清晰揭示问题,那说明问题本身比较线性、简单,不需要可视化。比如涉及简单数学运算、明确的流程逻辑、单次执行路径等,这些内容在调试器中逐步执行就能看明白。
但另一些问题则不同,比如涉及大量对象间相互引用的结构问题、复杂数据结构(如图、树、网络)的维护、或游戏中物理对象之间的空间关系等。这类问题往往由于其包含多个元素之间的复杂关系,使得用代码或断点调试方式难以完全掌握其中的状态。这时我们很容易“在脑子里看不清”,比如你写了一个平衡二叉树的插入函数,但结构一旦涉及几百个节点,仅靠输出或调试器已经很难判断一次旋转是否正确完成,节点指针是否指向正确的子树等。
此时,可视化手段就显得非常必要。我们可以把节点和指针的关系画出来、动态更新树结构的变化、或者在游戏中用图形方式表示物体的包围盒、Z序关系等等。通过图形化的方式,可以立刻看到结构中是否有环、错乱、偏移等异常状态,节省大量试错时间。
总结就是:
当问题规模较小、流程单一、调试器输出足以支撑分析时,通常不需要可视化。
当问题涉及大量复杂对象关系、多层结构交互、状态难以线性分析时,就应该考虑使用可视化手段帮助理解和调试。尤其是在结构错综复杂、需要“看清楚整体”才能发现异常的场景中,可视化几乎是唯一可行手段。
构建图的时间复杂度还是多少?是 O(n²) 吗?
我们目前还没有优化这部分逻辑。具体来说,在 SortEntries
内部,包含了两个关键函数:BuildSpriteGraph
和 WalkSpriteGraph
。其中:
WalkSpriteGraph
的时间复杂度大致为 $O(n)$,更准确地说是 $O(2m)$,其中 $m$ 是边的数量。这部分的效率还算可以,遍历的是图中的连接关系。BuildSpriteGraph
则仍然是 $O(n^2)$ 的复杂度。这是因为它目前的实现方式会在构建时进行所有对象之间的两两比较,尽管因为只比较一次(即(i, j)
而不重复(j, i)
)导致操作次数略少于 $n^2$,比如说是 $n \times (n+1)/2$,但本质上仍然是一个与 $n^2$ 成比例的复杂度。
换句话说,虽然实际操作数量少于 $n^2$,但增长趋势仍然是平方级别的,只要输入数量增加,性能压力就会迅速上升。
目前没有使用任何索引结构(比如空间分区、BSP 树或哈希)来减少不必要的比较,也没有任何提前排除的机制,因此是一个典型的朴素全比较实现。这使得在大量精灵(sprites)或图元素的场景下,这部分代码是性能瓶颈之一。
优化的方向包括:
- 引入空间索引结构,排除不可能发生遮挡或交互的对象对。
- 使用排序或扫描线技术,减少比较次数。
- 将构建与遍历阶段分离优化,结合更高效的图分析方法。
目前来说,这段代码仍然是一个与 $n^2$ 成比例的瓶颈模块。