游戏引擎学习第304天:构建与遍历图

回顾并为今天的内容定下基调

大家好,我们今天开始进行图排序的实现。昨天我们尝试了一种可能的排序方法,但发现它并不适用,只是试着验证一些想法而已。现在我们决定回到最初的计划,也就是实现一个完整的图排序算法。

这么做的理由也很充分------没有明显的理由不去做,而且这是一个非常合理、实用的方法。我们的游戏代码中迟早都会涉及图结构,趁这个机会练习一下图相关的代码实现是有益的,尤其是考虑到后续我们可能在世界生成、路径寻找等系统中也会用到图。因此,早点熟悉图结构是有必要的。

目前还没有太多实际需求让我们使用图结构,现在正好是一个不错的切入点。

在之前的代码中,我们曾尝试实现一个合并排序的替代方案,并借助一张图表帮助理解了其局限性,最终发现这个方法在很多情况下是不可行的,因此这些旧代码已经不再具有参考价值。

接下来我们需要的,是一种能根据图形语义来排序的算法。也就是说,我们希望根据精灵(sprites)之间的覆盖关系进行排序。对于重叠的精灵进行排序,而对于没有重叠的精灵,则不进行干涉,这种排序是局部的、语义化的。

这样做的好处是,我们可以基于这个机制,自由地定义前后显示的规则。然后通过图排序算法处理后,总能得出一个符合我们定义约束的最佳排序结果。这是我们的目标。

接下来就是实际开始图排序的编写与实现。

浏览 game_sort.cpp,快速了解 BuildSpriteGraph() 的作用

奇怪之前给删掉了吗

重新拷贝过来

我们之前开始编写了一个 BuildSpriteGraph 的函数,用于构建精灵之间的图结构。

这个函数的主要目标是:遍历所有精灵节点的两两组合,判断它们在屏幕上的显示区域是否有交集。如果两个精灵在屏幕上发生了重叠,就需要判断它们谁在前谁在后。我们会使用一个 IsInFrontOf 的函数来判断前后关系。

一旦得出某个精灵 A 在精灵 B 的前面,我们就在图中添加一条从 A 指向 B 的边(edge),表示 A 要画在 B 的前面;反之,如果 B 在 A 的前面,就添加从 B 指向 A 的边。这种方式等于在图中建立了"绘制顺序"的依赖关系。

目前为止,这段代码虽然是伪代码,但已经非常接近真正的可执行代码。不过,当前的逻辑是一个 N² 的实现,也就是说我们对所有精灵进行两两比较,这在精灵数量较多(比如 2000 或 3000 个)时,会产生严重的性能问题,直接影响帧率。

因此,后续还需要引入空间划分(spatial partitioning)的机制,优化遍历方式,避免真正执行 O(N²) 的计算。虽然我们一开始可能会直接使用 N² 的方式实现,为了验证效果,但这样做的性能问题会非常明显。

构建图的伪代码跑完后,我们就已经拥有了排序所需的全部关系信息,但实际上排序本身还没有进行。下一步我们需要实现的,是根据这个图结构决定精灵的绘制顺序,即哪些应该先画、哪些应该后画。

我们可以把每个精灵看作图中的一个节点,节点之间的有向边表示前后关系。只要按照拓扑排序的方式遍历整个图,就可以得出一个正确的绘制顺序,确保所有的遮挡关系都得到正确处理。这就是我们接下来的任务。

黑板讲解:图排序

现在我们来进一步理解图排序的实际执行逻辑。

我们有一组精灵(sprites),这些精灵在屏幕空间中可能会发生重叠。我们的排序规则是:当两个精灵发生重叠时,如果规则判断 A 在 B 前面,我们就添加一条从 A 指向 B 的有向边,表示 A 要在 B 之前绘制。

举个例子,假设我们有三个精灵 A、B、C,通过规则判断得出:

  • A 在 B 前面
  • B 在 C 前面

于是我们在图中构建的结构就是:

A → B → C

这个图就有三个节点,分别代表精灵 A、B 和 C,并且包含两个方向明确的边。

下面是图结构中一个重要的概念:只要图中没有"环"(cycle),我们就可以进行可靠的拓扑排序。环的意思是,比如说如果我们再加一条 C 指向 A 的边,那么就形成了 A → B → C → A 的闭环,这种情况是无法进行有效排序的,因为没有明确的"最先"和"最后"。

只要图是无环图(DAG, Directed Acyclic Graph),我们就可以通过遍历来确定绘制顺序。绘制顺序应该从最靠后的开始,也就是那些"没有出边"的节点。这种节点的定义是:没有任何边从它指向其他节点,即没有其他精灵依赖它出现在其前方,这样的精灵就可以直接绘制。

具体的绘制逻辑可以这样进行:

  1. 任意选择一个精灵节点作为起点开始遍历;
  2. 沿着它的出边一直往后遍历,直到没有出边为止,找到这个路径的末端节点;
  3. 这个末端节点可以被安全地绘制,因为它之后没有任何依赖;
  4. 绘制之后回退,检查是否还有未遍历的路径;
  5. 对所有路径重复上述过程,直到整个图的所有节点都被绘制;

这样就可以保证绘制顺序从后往前,符合遮挡关系。

再进一步,有可能图中会有多个不相连的子图(子图=connected subgraph)。例如左边一组精灵之间有关系,而右边一组精灵彼此有关系,但这两组之间没有交集。在这种情况下,可以将整张图划分成多个子图,分别进行独立排序和绘制。

我们可以对每个子图进行相同的遍历处理:从其中某个节点开始,遍历整个子图,完成绘制,然后再处理下一个子图。只要每个子图内部是无环的,就可以分别独立排序,不影响其他子图的正确性。

总结来说,整个图排序的核心在于:

  • 检测精灵之间的遮挡关系;
  • 构建一个无环有向图表示绘制依赖;
  • 对图进行拓扑排序;
  • 从后往前输出绘制顺序;
  • 支持多个子图并独立处理。

黑板讲解:子图,以及标记访问过的每个节点

我们在处理一个连通子图(connected subgraph)时,只需要确保这个子图中的所有精灵都被正确绘制。接下来的问题是,如何处理多个不相连的子图。

一个非常简单的方法是:每当绘制了一个精灵后,就在它的结构中打一个"已绘制"的标记。例如:

  • 绘制了第一个精灵,就给它标记为"已绘制";
  • 绘制了第二个精灵,也打上同样的标记;
  • 第三个、第四个依此类推。

然后我们从头开始检查所有精灵:

  1. 检查第一个精灵,发现它已被绘制,跳过;
  2. 检查第二个精灵,也被绘制,继续跳过;
  3. 第三个也绘制了,继续;
  4. 当检查到某个精灵尚未绘制时,说明它属于一个未处理的子图。我们就从这个精灵开始,再次运行图遍历和绘制逻辑,把它所属的子图全部绘制出来,并对所有遍历过的精灵打上"已绘制"标记;
  5. 重复这个过程,直到所有精灵都被标记为已绘制为止。

这个过程的本质是:对整个图中所有精灵节点逐一检查,如果没被绘制过,就从该节点出发进行一次完整的图遍历,将相关精灵全部绘制完成。这种方式可以保证多个不相连的子图也都能被完整处理,不会遗漏。

最后,通过这个方法,我们就可以非常简单地将整张图从"后往前"绘制出来。依赖关系也会被自动满足,不会出现绘制错误。这个方法的实现既清晰又有效,完全可以满足我们在遮挡排序中的需要。

黑板讲解:图中的环(循环)

我们在处理图形排序时,唯一需要特别担心的问题是出现**循环依赖(cycle)**的情况。因为没有任何机制能自动阻止循环的发生。

例如:

  • 如果 A 应该绘制在 B 前面,
  • B 应该绘制在 C 前面,
  • 而 C 又应该绘制在 A 前面,

那么这种情况下就形成了一个闭环(cycle)。这个闭环意味着:无论我们怎么排序,都无法满足所有依赖条件 ------ 因为总会有一个精灵没有按"应该在谁前面"的规则排好。这就是一个无法解决的排序冲突

在游戏的二维或"2.5D"图形环境中,这种情况其实是可能发生的。我们为了让图像表现得像是有立体感的三维世界,常常会人为地设定一些"谁应该在谁前面"的规则。这些规则可能只是为了让视觉效果更好,但它们本质上并不一定是严格一致的,甚至可能会互相冲突,最终形成循环依赖。

为了解决这个问题,我们必须引入打破循环的机制 。一个最基础的方法就是:当在图遍历中发现我们又回到了之前走过的路径(即检测到当前路径上出现了重复节点),就说明存在循环。这时可以简单粗暴地终止这条路径的继续遍历,相当于无视导致循环的那条边。这样就可以打破循环,从而继续完成图的遍历和绘制顺序的确定。

当然,这种方法只是一个临时、简单的处理方式。如果游戏中经常出现循环依赖,我们就需要更智能的策略来决定"打断哪一条边更合适"。比如可以根据某些权重或优先级判断哪种依赖关系是可以舍弃的,从而避免对最终绘制效果造成太大影响。

总之:

  • 循环依赖是我们在图排序中必须特别处理的问题;
  • 初始处理可以通过标记和跳过检测到的循环路径;
  • 如果循环发生频率很高,需要设计更复杂的逻辑判断如何"智能地"打断某些边;
  • 这样既保证排序不会陷入死循环,又尽量保留视觉逻辑的正确性。

接下来,我们将逐步实现这个图排序算法,以便确保所有精灵都能按照正确的遮挡顺序绘制出来。

黑板讲解:进行图排序的各个步骤

我们现在的目标分为几个明确的阶段,将按顺序逐步完成,而不是一次性全部搞定。具体步骤如下:


第一步:构建图结构(Build the Graph)

我们已经写好了非常直观的伪代码,用于构建表示精灵前后遮挡关系的图结构。接下来要做的就是将这些伪代码转化为真正可执行的程序逻辑。

构建方式是:

  • 遍历所有精灵的组合对;
  • 如果两个精灵在屏幕空间中发生重叠;
  • 则通过"谁在谁前面"的规则添加图中的有向边(例如:A 在 B 前面,则添加一条从 A 指向 B 的边)。

第二步:检测并处理循环(Find and Remove Cycles)

在完成图构建后,下一步不是直接使用它来进行绘制,而是先对图进行分析,检查是否存在循环依赖

  • 目标是检测出图中所有存在的循环路径;

  • 检测之后的第一个处理思路是简单地将产生循环的边删除;

    • 例如如果 A→B→C→A 构成了一个循环,则尝试移除其中某一条边打破这个闭环;
    • 这将避免后续排序和绘制时出现无解的顺序问题。

这个步骤可能还包含一个子任务:

  • **子任务:**从图中移除产生循环的边,使得图变成无环图(DAG);

第三步:绘制排序(Topological Drawing Order)

最后一步才是根据图结构来决定绘制顺序。

  • 基于前面移除循环后的有向无环图(DAG);
  • 遍历图中的各个节点,按从后往前的顺序进行绘制;
  • 保证每个精灵都在其"在它前面"的其他精灵之后被绘制,从而达到正确的遮挡效果。

方法与原则:

  • 整个过程中不会使用复杂的图论算法
  • 所采用的方式是最基础、最朴素的图处理方式
  • 所有图的状态和标记只依赖简单的节点标记机制,例如给节点打标签表示它是否已经访问或绘制;
  • 在处理图的遍历时,不刻意将当前算法划入什么已知范式或理论模型中,只做最直观、基础的实现;
  • 如果最终发现这种简单方案不足以解决问题,再考虑引入更复杂的图论方法或优化策略。

这种逐步推进的方式有助于更清晰地理解图处理的基本原理,也有利于后续根据需要逐层扩展和优化。

game_sort.cpp:重新组织图的结构体

我们现在正式开始实现构建图的过程,基于之前的思路继续展开。


目标

我们已经有了一个精灵节点(sprite node)列表,这些节点包含了精灵的边界信息。现在我们的目标是:基于精灵遮挡关系构建一张有向图,图中的边代表"哪个精灵在另一个精灵前面"。


伪代码修正与初步设计

在最初的伪代码中有一个小错误,我们在进行 A 与 B 的比较时,应该传递的是 A 和 B 的边界信息,而不是直接传递节点本身。

此外,图的目的是要让我们快速从一个节点遍历到它后续的所有节点,也就是通过边向后走。这就要求我们在设计数据结构时,要确保每个节点能高效访问其所有出边。


精灵节点结构与优化

目前我们有一个用于排序的结构 SortSpriteBound,它包括:

  • SortKey:决定精灵排序的主键,比如其在屏幕上的位置或层级;
  • Index:对应的精灵在原始列表中的索引。

我们并不需要再为图单独创建一个"节点结构",完全可以直接在 SortSpriteBound 中附加图相关的信息,即每个节点所连接的边。

因此,我们在 SortSpriteBound 中加入图相关的字段:

  • FirstEdge:指向该节点的第一条边;
  • 每条边结构中包含 NextEdge,用于形成链表;
  • 边可以用索引或指针表示,这里我们先用索引来实现,因为可以直接查表,不必担心对象引用复杂性。

这种方式避免了多余的内存分配,也提高了结构的紧凑性和访问效率。


新增结构设计(示意)

cpp 复制代码
struct Edge {
    int TargetIndex;     // 边指向的目标节点索引
    int NextEdgeIndex;   // 同一节点中链表的下一个边
};

struct SortSpriteBound {
    SortKey key;
    int Index;           // 对应精灵的原始索引
    int FirstEdgeIndex;  // 指向当前节点的第一条出边
};

构建图的过程:

  1. 遍历所有精灵对 (A, B)
  2. 判断 A 与 B 是否在屏幕空间重叠;
  3. 如果 A 在 B 前面,添加一条从 A → B 的边;
  4. 将边加入 A 的边链表中;
  5. 图就构建完成了。

总结

我们现在完成了以下内容:

  • 明确了不需要新建额外的图节点结构,复用已有 SortSpriteBound
  • 为图添加了链式边结构用于快速遍历;
  • 选择使用索引来表示边连接目标,兼顾效率和易实现;
  • 结构设计紧凑,便于后续的遍历、排序和循环检测等操作。

下一步是开始实际编写代码,实现图的构建逻辑。

game_sort.cpp:开始正式实现 BuildSpriteGraph()

我们现在具体实现构建精灵图的过程。核心思路和步骤如下:


1. 输入和准备

  • 输入是一个 SortSpriteBound 类型的精灵列表,包含了所有精灵的边界和排序键信息;
  • 之前的伪代码里输入节点的概念被简化,我们直接使用已有的 SortSpriteBound 列表和它的数量。

2. 两两遍历检查相交

  • 遍历所有精灵对 (A, B),判断它们的屏幕边界是否相交;
  • 这里的判断依据是节点内的边界信息,是否存在交集。

3. 确定遮挡关系并排序

  • 如果相交,我们需要判断哪一个在前面,哪一个在后面;

  • 我们有一个 isInFrontOf(A, B) 的函数来判断这一关系;

  • 规则:

    • 如果 AB 前面,保持顺序不变,即边从 A 指向 B
    • 如果 BA 前面,我们交换 AB 的指针,确保 A 总是前面的那个。

这样做的目的是保证图中所有边的方向都是从"前面的节点"指向"后面的节点",方便后续排序。


4. 添加边到图结构

  • 现在我们需要将这条边添加到图结构中;

  • 边结构包含:

    • 前置节点(front)
    • 后置节点(behind)
    • 下一条边的指针(形成链表)
  • 我们为每个精灵节点维护一个边链表,表示从该节点出发的所有"后面节点";

  • 添加边时,需要知道:

    • 当前节点已有的第一条边(FirstEdge
    • 新增边的 NextEdge 指向之前的第一条边
    • 然后更新当前节点的 FirstEdge 指向新边

5. 交换和排序关键点

  • 对于 SortSpriteBound 中的排序键(SortKey),现在还不完全确定是否参与边的添加判断,但目前主要是用来辅助判断相对前后;
  • 当两个精灵的排序键相同时,仍然需要添加边以保证图的完整性,避免排序错误。

6. 边的存储和管理

  • 需要预先准备一个边的存储池,用来存储所有的边信息;
  • 每添加一条边,就从池中分配一个 SpriteEdge 结构,存储该边的信息;
  • 维护链表结构来实现边的连接,方便快速遍历。

总结

  • 我们先从一组 SortSpriteBound 结构出发;
  • 遍历所有节点对,判断它们是否相交并确定遮挡关系;
  • 通过交换保证所有边的方向都是从前置节点指向后置节点;
  • 为每个节点维护一个边链表,存储指向它后置节点的边;
  • 使用边池管理边的存储;
  • 这样我们就构建了一张有向图,表示了精灵间的遮挡顺序关系。

后续可以基于这张图进行拓扑排序或循环检测,确定最终的绘制顺序。

黑板讲解:从最前面到最后面遍历边

我们需要为每条边设置更具体的标签。对于一条特定的边,我们关心的是其中哪一方是"前方"。我们始终希望将自己作为"前方"的一方来处理边的连接关系。也就是说,我们不会处理那些自己处于"后方"的边,因为我们要从"前方"的元素出发,向"后方"的元素遍历。我们不需要从后往前走,只需要从前往后。

这是因为我们是按照从后到前的顺序进行绘制的,因此我们在逻辑上也需要从前向后遍历。如果要按相反顺序绘制,则遍历顺序也需要反转。

因此,对于每个对象(比如一个 sprite),我们要找到第一条"以我为前"的边,作为起点。然后我们需要找到"与我同为前方"的下一条边。更准确地说,是下一个前端索引与当前相同的边。

在例如一个前方节点连接到多个后方节点的情况下,我们需要依次遍历所有这些边,从当前这个前方节点出发,走向所有被标记为"我在前"的后方节点。

这样可以保证我们能够遍历所有"从我出发"的边,从而遍历整个从前至后的结构,保证绘制顺序的正确性。我们每次推进的过程,都是在不断找到相同前端索引的下一条边,从而实现对整个结构的有序遍历。

cpp 复制代码
// 表示从一个精灵指向另一个精灵的排序边结构,
// 用于表示图中"前方-后方"的可视化遮挡关系(例如绘制顺序)
struct sprite_edge {
    // 指向具有相同 Front 节点的下一条边,形成单向链表
    // 这样可以快速遍历所有从同一个前方精灵出发的边
    sprite_edge *NextEdgeWithSameFront;

    // 当前边的前方精灵索引(起点)
    // 也就是这条边从哪个精灵出发
    uint32 Front;

    // 当前边的后方精灵索引(终点)
    // 也就是这条边指向哪个被遮挡的精灵
    uint32 Behind;
};

// 表示一个精灵的排序边界信息,包含其屏幕位置、排序关键字和边链表信息
struct sort_sprite_bound {
    // 指向以该精灵为 Front 的所有边的链表头
    // 便于从该精灵出发遍历所有与其存在遮挡关系的后方精灵
    sprite_edge *FirstEdgeWithMeAsFront;

    // 当前精灵在屏幕上的区域,用于判断可见性或排序优先级
    rectangle2 ScreenArea;

    // 精灵的排序键值,可能包含深度、图层等排序参考信息
    sprite_bound SortKey;

    // 当前精灵在整体精灵数组中的索引
    uint32 Index;
};

game_sort.cpp:继续实现 BuildSpriteGraph()

我们现在不关心边的顺序,我们的目标是确定某条边的前方节点是谁。我们通过输入节点数组加上边的起始节点索引(node index a)来确定前方节点。然后,我们希望把这条边作为当前前方节点的"第一条边"。

接下来,我们会把这条边接到该前方节点的"下一条边"链表的末尾。整个结构是一个单向链表,因此我们会在边的数据中填写相关信息,然后将这条边加入前方节点的边表中。

换句话说,我们通过将边连接到相应的节点来构建整个图结构。每个节点会拥有一条指向它作为前方节点的所有边的链表,通过这个链表,我们可以遍历所有"从这个节点出发"的边。

至此,我们已经完成了图结构的构建,所有的边都已经连接到相应的节点上。如果需要,我们可以开始遍历这些边,实现各种图上的操作。这些就是我们构建图结构所需要的全部步骤。

game_sort.cpp:介绍 WalkSpriteGraph()

我们已经构建好了精灵图,接下来的任务是遍历这个图。目标非常明确------我们要找到其中的有向环(cycles)

遍历精灵图的过程将基于已构建的数据结构(也就是说,边和节点都已经初始化完毕)。我们不会进行任何两两比较的操作,因此避免了 O(n²) 的性能开销。我们只是对每个精灵节点进行一次访问,然后通过其连接的边进行图遍历。

具体思路如下:

  • 从每个精灵节点出发,遍历整个图;

  • 由于是遍历整个图,因此每个节点都将被访问一次;

  • 不再使用成对比较的方式(pairwise comparison),而是直接对图结构进行处理,效率更高;

  • 为了实现图的遍历,我们初步会使用递归方式来处理;

    • 使用递归可以快速实现图遍历逻辑,尤其是在探索路径和查找环时;
    • 不过递归通常效率较低,后续如果性能受限可能需要改为非递归实现;
  • 尽管如此,真正的性能瓶颈大概率还是在于前面构建图时的 O(n²) 比较部分;

    • 遍历本身预计不会成为主要的性能负担,但实际情况还需通过测试验证。

总结来说:

  • 遍历图的目的是查找有向环;
  • 起初会使用递归方式实现;
  • 每个节点都会被访问一遍;
  • 主要优化重点应该放在构建图的过程,而非遍历本身;
  • 整体思路是基于有向图的深度优先遍历,以查找图中潜在的循环依赖关系。

game_sort.cpp:介绍 RecursiveFromToBack()

我们打算以递归方式遍历整个精灵图,从前向后(Front-to-Back)地处理。目标是通过递归跟随每个精灵的边(即遮挡关系)进行遍历,同时为后续检测循环和处理排序关系打基础。

我们只需要传入两个信息:精灵节点数组和当前要处理的节点索引。

具体流程如下:


一、遍历结构设计

  1. 对于当前节点:

    • 从该节点的 FirstEdgeWithMeAsFront 开始,遍历所有"以我为前"的边;
    • 由于图构建时已经将边组织为链表结构,因此只需沿链表向后走即可;
  2. 对于遍历到的每一条边:

    • 可以获取该边指向的"后方"节点;
    • 对这个"后方"节点递归调用遍历函数(递归向后展开);

二、递归函数内部逻辑

  • 每次递归调用,我们会从当前节点获取其所有 outgoing 边(即"我在前"的边);
  • 然后逐条边进行递归遍历;
  • 为了安全,我们可以加入一个断言:检查该边的 Front 是否真的等于当前节点索引,验证图结构是否正确构建;
  • 整体流程就是,从一个节点出发,沿着"我在前"的边不断向后方节点递归,直到边链终止;

三、注意事项

  • 当前实现尚未做任何"处理"工作,即递归函数目前只是遍历图结构,还未检测循环或进行拓扑排序;

  • 如果存在有向环(例如 A→B→C→A),那么递归将无限进行,最终栈溢出崩溃;

    • 因此,必须在递归函数中加入访问标记机制(比如标记"已访问"或"正在访问中")来检测环;
    • 或使用显式栈改为非递归实现,避免栈空间问题;
  • 后续将在递归过程中插入实际处理逻辑(如排序、标记、环检测等);


总结逻辑步骤

  1. 从某个精灵节点开始;
  2. 获取所有"以该节点为前方"的边;
  3. 遍历这些边,对每条边指向的"后方"节点继续递归;
  4. (待实现)在递归中加入环检测与处理逻辑;
  5. 整体形成一套图遍历机制,从前到后完整访问所有可能存在遮挡关系的精灵节点;

这一遍历结构为后续的拓扑排序和循环检测提供了基础,是图结构处理中关键的一步。

game_sort.cpp:引入 enum sprite_flag,用于标记精灵是否被访问和绘制过

我们现在需要在递归遍历精灵图的过程中,真正做一些处理工作,特别是为了防止在图中存在有向环(cycle)时无限递归的问题。为此,我们必须引入终止条件,确保在遇到循环结构时能够及时中止。


一、重点在于节点的标记

  • 我们不需要对边进行标记,因为我们关心的是节点是否参与了循环

  • 所以我们可以为节点结构 sort_sprite_bound 添加一个 标志字段(flags),用于记录节点在图遍历过程中的状态;

  • 可以定义一个枚举类型 sprite_flag,包含如下状态:

    • Sprite_Visited:表示该节点已经被访问过;
    • Sprite_Drawn:表示该节点已经处理完成(例如用于绘制完成);

通过这些标记,我们可以:

  • 检测是否进入了一个已经"正在访问"的节点(检测到 cycle);
  • 判断当前节点是否已经处理过,从而避免重复处理;

二、清理和初始化状态

  • 在开始构建图或开始遍历前,可以统一清空所有节点的标志位;
  • 也可以在构建图的过程中,添加断言或显式初始化,确保所有标志位为零或清除状态;
  • 清除的位置可以灵活安排,既可以在构建阶段做,也可以在图遍历前单独清理;

三、边索引顺序的处理

  • 在构建边(sprite_edge)的时候,需要保证 FrontBehind 的索引顺序正确;

  • 若发现输入中前后关系搞错(例如后方的节点被放到了 Front 位置),则需要进行交换处理:

    cpp 复制代码
    if (B 是在前方而 A 在后方) {
        交换 Front 和 Behind 索引;
    }
  • 确保每条边都从"前方"节点指向"后方"节点,是后续遍历逻辑正确运行的基础;

  • 一旦顺序正确,我们就可以安全地把这条边加入到 FirstEdgeWithMeAsFront 所构成的链表中;

  • 后续递归遍历时才不会出现从后往前的错误路径,也更容易检测循环结构;


四、下一步计划

  • 在递归函数中使用 VisitedDrawn 标记,来处理访问控制和终止条件;
  • 检测到某节点再次被访问且尚未"Drawn",说明出现了循环;
  • 如果检测到 cycle,可记录错误、跳出递归或处理依赖失败的情况;
  • 最终形成一个稳健的图遍历逻辑,支持拓扑排序与环检测;

总结

  • 添加了节点状态标记机制,避免递归陷入死循环;
  • 在构建边时纠正前后索引顺序,确保图结构合法;
  • 标志位将作为后续处理逻辑的核心依据,例如排序、绘制、检测环等;
  • 为图的深度遍历与拓扑排序打好了坚实的基础。

game_sort.cpp:让 BuildSpriteGraph()RecursiveFromToBack() 使用这些标记

现在我们继续完善遍历精灵图的逻辑,关键在于防止无限递归的发生,确保在遇到图中的有向环时能正确退出递归,避免程序崩溃。为此,我们引入了标志位(flags)来标记节点的访问状态,并对标记的使用位置进行了详细分析。


一、标记机制与循环检测

我们为每个节点设置了一个 flags 字段,用来标记其在遍历过程中的状态。特别是:

  • Visited 标志表示该节点已经被访问过;
  • 在递归过程中,我们会根据这个标志来决定是否继续递归;
  • 如果发现某节点已经被访问,说明已经进入过该路径,就不能再继续递归下去,否则会陷入无限循环;

二、判断是否已经访问的逻辑

在递归函数中,每次进入时:

  • 首先检查当前节点的 Visited 标志;
  • 如果已经设置,说明我们再次到达这个节点------这可能是由于循环路径导致的,所以应该立刻终止这条递归分支;
  • 如果未设置,就将其设置为 Visited,然后继续往下递归;

三、标志的设置位置非常关键

有两个地方可以设置标志位:

  1. 递归调用之后再设置(错误):

    • 如果把标志设置放在递归调用之后,那么在调用递归函数时,节点还没被标记;
    • 如果递归路径再走回这个节点,就会错误地认为它尚未访问;
    • 会导致递归死循环,程序最终栈溢出崩溃;
  2. 递归调用之前设置(正确):

    • 在递归调用之前就标记为 Visited
    • 这样如果递归路径中再次遇到这个节点,会发现已经访问过,立即终止;
    • 可以有效避免进入循环;

因此,必须优先设置标志,再进行递归调用。


四、整合逻辑顺序

完整的遍历逻辑如下:

  1. 获取当前节点;
  2. 检查 Visited 标志,若已访问则终止递归;
  3. 设置 Visited 标志;
  4. 遍历所有以该节点为前方的边;
  5. 对每条边的后方节点递归调用处理函数;
  6. 最终处理当前节点的"完成"状态(比如设置为 Drawn);

五、当前问题与后续方向

目前我们成功实现了避免递归死循环的基本机制,但还存在进一步完善的空间:

  • 当前遍历只做了访问与防环处理;
  • 接下来需要在遍历末尾添加"绘制"或"排序输出"的操作;
  • 还需要加入对图中检测到的环结构进行记录、提示或修复;
  • 还可能需要从递归方式改为非递归实现,以提升性能和可靠性;

总结

  • 添加 Visited 标志用于避免循环;
  • 标志必须在递归之前设置,否则无法阻止路径环;
  • 遍历逻辑清晰:先标记再递归;
  • 为拓扑排序、图分析等操作打下了正确的框架基础。

黑板讲解:我们其实并没有按照顺序完成图排序的步骤

我们目前所实现的遍历过程,实际上只是一个按依赖顺序绘制图的算法 (graph draw-in-order algorithm),但它并没有处理图中存在环(cycles)的情况。因此严格来说,这是一个不具备智能循环检测与处理机制的简化版图遍历算法


一、当前实现的局限性

  1. 无法处理循环结构

    • 当前实现中,如果图中存在有向环,那么递归会陷入死循环;
    • 我们虽然加入了 Visited 标志来阻止重复访问,但这只是一个简单的终止机制并不是真正意义上的环检测与处理
    • 如果我们想对存在循环的图进行正确的拓扑排序或绘制,就需要识别并处理这些环(例如打断它们);
  2. 缺少递归 ID 或状态管理

    • 理论上我们应该引入一种**"当前递归路径"的标识机制**,用于判断当前访问路径是否进入了一个"正在递归"的节点;
    • 简单的 Visited 标志只告诉我们"曾经访问过",但无法区分"已经递归完成的节点"与"当前正在递归的节点";
    • 正确的环检测需要引入例如 VisitingVisitedFinished 三种状态,或使用一个唯一递归 ID 标识当前路径;

二、实际需求更简单

  • 虽然检测和处理图中循环结构是非常重要的一步,但目前的目标其实只是简单地"按正确顺序绘制图中的元素";
  • 所以暂时来说,我们不需要处理循环;
  • 在没有环的前提下,当前算法已经可以完成基本的绘制顺序处理;
  • 因此目前实现的是一个简化版本,用于非循环有向图(DAG)的图绘制

三、后续需要的进阶内容

未来若要扩展功能并支持更复杂场景,还需要:

  1. 加入递归状态追踪机制,以识别循环;
  2. 记录循环信息,用于提示或修复;
  3. 提供打破环的策略(如删边、优先级调整等);
  4. 支持对包含环的图进行合理降解处理(如强连通分量检测);
  5. 引入非递归方式实现遍历,提升健壮性和性能;

四、总结

  • 当前算法是一个"按依赖顺序绘制图元素"的简化方案,适用于非循环图;
  • 尚未具备识别和处理有向环的能力;
  • 如果图中存在循环,则仍可能出现逻辑错误或绘制失败;
  • 后续如需处理复杂依赖关系,需要引入更高级的图分析与状态管理机制。

game_sort.cpp:引入 struct sprite_graph_walk,用来记录在图中的位置

我们当前实现的递归图遍历已经具备基本结构,现在需要进一步完善它,使其真正完成我们想要的功能------生成绘制顺序。下面是对这一过程的详细总结:


一、遍历的基本思路回顾

我们遍历图的方式是从前向后递归地追踪每一个 sprite 的边 ,并在访问时留下标记,防止重复处理。现在我们要在这个递归遍历过程中做更多实际工作,例如输出绘制顺序


二、为什么需要输出结构

我们遍历图的目的不仅是遍历本身,更是为了得出一个绘制顺序列表。这个顺序列表表示在不违反依赖关系的情况下,图中各个 sprite 的绘制先后关系。

要实现这一点,我们必须为遍历函数提供一个可写的输出空间,在遍历结束后能够得到完整的顺序列表。


三、输出结构的设计考量

我们不能简单地传入一个输出数组指针作为函数参数,因为指针是值传递,函数内更新不会反映到调用者。解决方案是封装一个上下文结构体(例如 sort_graph_walk,其中包含:

  • 输入节点数组:sprite 节点信息;
  • 输出索引数组:用于记录最终绘制顺序;
  • 当前写入位置:标记输出数组的当前位置,便于在递归过程中依次写入;

四、简化输出信息

虽然 sprite_bound 结构体本身可能包含大量信息用于排序,但最终我们仅关心绘制顺序索引 ,不需要保留全部结构。因此我们只记录每个 sprite 的 Index 值即可。


五、遍历输出逻辑的实现流程

  1. 在遍历函数中传入 sort_graph_walk 上下文

  2. 在每次递归返回后(即回溯阶段)将当前节点索引写入输出数组

    • 这是因为只有等到所有依赖都被处理完,我们才可以安全地绘制当前节点;
  3. 生成的输出数组是合法的绘制顺序

    • 如果某节点有依赖关系,它会在依赖项之后被写入;
    • 如果某节点有环,但我们未检测处理,那么会跳过或造成错误,这点需注意;

六、为什么这个逻辑是正确的

这是典型的拓扑排序逻辑实现(适用于无环有向图):

  • 深度优先遍历整个图;
  • 遍历完成后在回溯阶段将节点加入输出列表;
  • 生成的输出列表可以用于按照依赖顺序处理图中的元素;

七、下一步注意事项

目前实现仍有几点尚未完善:

  • 没有处理图中环的情况,遇到环可能导致跳过或死循环(需引入额外机制检测);
  • 尚未初始化输出数组的位置指针
  • 尚未封装完整的 sort_graph_walk 结构定义及递归函数签名与实现

八、总结

我们通过封装上下文结构,结合递归遍历与输出指针写入,实现了一个用于生成绘制顺序的图遍历流程。这套系统的关键点包括:

  • 深度优先递归遍历;
  • 标记已访问节点避免重复处理;
  • 在回溯阶段写入绘制索引;
  • 利用封装结构传递上下文并实现可写输出;
  • 输出的是 sprite 的绘制索引顺序;

最终构建出了一个适用于拓扑无环图的简单绘制调度系统。

思考下一步如何进行

我们当前实现的绘制顺序生成逻辑虽然基本框架已经完成,但要真正发挥作用,还存在多个未解决的依赖和准备工作。以下是对这些待办事项的详细总结:


一、输出索引列表尚未使用

我们已经扩展了设计,引入了一个输出索引列表,用于记录图遍历后每个 sprite 的绘制顺序。但目前这个索引列表虽然已在结构中声明,实际上仍未被填充或使用。必须确保:

  • 遍历过程确实将索引写入该列表;
  • 输出数组的初始化与大小必须合理;
  • 后续的绘制逻辑要基于该索引顺序执行。

二、临时内存分配机制未完善

我们计划使用一种"临时内存"机制(temp memorypush memory)来动态分配遍历过程中需要的数据结构(如索引列表或标记数组)。但这方面存在以下问题:

  • 虽然我们拥有 temp memory 的使用能力,但尚未真正集成;
  • 分配内存时要准确计算所需空间,否则可能越界或浪费;
  • 对于节点数量未知或可变的情况,要动态处理 temp memory 的初始化与释放。

解决办法可能是:

  • 在初始化遍历上下文时预估最大节点数;
  • 一次性申请全部所需内存块(索引数组、标记位等);
  • 在遍历结束后一次性释放;

三、关键字段尚未初始化:ScreenArea

图中节点(sprite)之间的遮挡判断依赖于一个字段:ScreenArea,即 sprite 在屏幕上的投影矩形。它是实现排序、依赖判定的重要基础。但目前为止:

  • 尚未有任何代码实际计算并填充 ScreenArea 字段
  • 依赖它进行矩形相交(RectanglesIntersect)等判定将无效;
  • 所有基于空间遮挡关系的依赖链构建都会失败;

因此,在进入构建依赖图(即边)的阶段前,必须:

  • 为每一个 sprite 计算其 ScreenArea
  • 这通常依赖其世界坐标、缩放、视口变换等;
  • 填充好每个 sort_sprite_boundScreenArea 字段;

四、结构体数据未完全填充

除了 ScreenArea,可能还有其他字段(如 sprite_bound.SortKey)尚未被正确填充或初始化。在继续构建和遍历图前,必须确保:

  • 所有参与排序和判断的字段都具有正确有效的值;
  • 否则算法结果将不可预测甚至失效;

五、总结:当前系统的阻断点

为了让遍历逻辑真正发挥作用并输出有效的绘制顺序,必须解决以下核心问题:

问题 影响
输出索引列表未填充 无法获得最终绘制顺序
临时内存机制未接入 无法动态保存索引、标记等遍历中间状态
ScreenArea 未计算填充 遮挡判断失效,依赖关系图构建无效
结构体字段未完整初始化 所有后续算法运行结果将不可靠

只有在这些关键数据准备充分后,排序图的遍历及输出才能正确运行,绘制系统也才能得到一个可用的顺序信息。


接下来要做的工作是围绕这几点逐步完善数据准备流程,特别是 ScreenArea 的计算逻辑以及临时内存系统的接入和测试。

game_sort.cpp:清理编译错误

目前代码已接近完成,但在编译和调用过程中发现了一些具体问题和调整点,现将内容详细总结如下:


一、函数和变量命名及调用调整

  • 之前用到的函数名 sort_graph_walk 其实已经更改为 sprite_graph_walk,调用时需确保使用新的名称。
  • 传入参数方面,之前误用了未声明的标识符 input_nodes,实际上应该传入一个包含遍历状态和输出信息的上下文结构(walk)。
  • 该上下文包含输入节点数组和输出绘制顺序索引数组等信息,传递时必须保证完整。

二、输出绘制顺序的数组应为索引数组(Index Array)

  • 输出的绘制顺序并非复杂结构体列表,而是简单的索引数组(即存放节点索引的数组)。
  • 命名上尽量避免用"list",因为它只是简单的数组,不具备链表性质。
  • 需要保证传递给递归函数的参数能够被修改(即传引用或通过指针传递),以便递归过程中动态更新输出索引。

三、递归调用中参数传递需统一

  • 递归函数调用时,不应直接传入单独的输入节点数组,而是应传入包含所有状态和输出指针的上下文(walk)。
  • 这样保证递归过程中所有数据和输出都能被正确共享和更新,避免重复声明未定义标识符。

四、编译错误及类型使用

  • 代码中出现了非法类型使用的错误,主要是因为变量或函数未声明或使用了错误类型。
  • 需要检查所有函数原型和变量声明是否在调用前都已正确定义。

五、总结

整体代码结构已经趋于完善,主要工作集中在:

  • 修正函数名和调用参数,统一传递上下文结构。
  • 确保输出的绘制顺序为索引数组,便于后续绘制处理。
  • 修复所有编译时提示的未声明标识符和类型错误。
  • 准备好递归调用逻辑,正确传递和修改状态。

完成这些调整后,代码应该能够成功编译,并开始进入实际的绘制顺序计算和输出阶段。


后续重点是确保递归遍历能正确执行,输出索引数组完整填充,从而为渲染流程提供正确的绘制顺序。

game_sort.cpp:让 BuildSpriteGraph()SortEntries() 使用 memory_arena

目前需要整理和完善周边代码,重点是合理管理临时内存。详细总结如下:


一、临时内存管理方案

  • 使用已有的内存管理机制------内存arena(memory arena),这可以方便且安全地分配和释放临时内存。
  • 通过内存arena,我们可以在排序或构建sprite图时,分配临时数据所需的内存,避免手动管理内存带来的错误。
  • 使用内存arena还有一个好处是,如果出现错误,会有警告或提示,便于调试。

二、代码调用调整

  • 在调用sort_entries函数时,不再传递原先设计的"sort memory",而是传递内存arena对象。
  • 在构建sprite图时,也将临时内存的使用通过这个内存arena进行管理,保证内存使用的规范和安全。

三、具体实现细节

  • 代码中使用了"push_struct"这一机制,可能是对内存arena中分配结构体的封装方法。
  • 通过push_struct从arena中分配空间,保证了分配的高效和安全。
  • 需要注意的是,平台层可能还没有定义push_struct相关的内容,需要补充或兼容。

四、潜在问题和后续工作

  • 目前对push_struct的支持还不完整,需要在平台层确认并实现相关接口。
  • 需要确认整个内存arena的生命周期和管理,确保不会出现内存泄漏或非法访问。
  • 在完善这部分后,整体构建和排序流程会更加稳定可靠。

综上,核心工作是将临时内存管理切换到内存arena机制,结合push_struct等工具简化内存分配,同时保证平台层的兼容性和支持,提升代码整体的健壮性和安全性。

思考如何将排序分片进行,以避免相互干扰

我们目前面临的问题是,这段排序代码放在平台层(platform layer)并不合适,导致代码重复且逻辑不清晰。虽然渲染器被放到了平台层,但排序逻辑应该提升到更高层次管理,而不是埋在底层平台代码里。


一、当前问题分析

  • 排序操作需要在渲染最终数据生成之前完成,而不是在平台层的代码执行后再处理。
  • 由于调试系统(debug system)的设计,我们的渲染流程被统一处理,这样导致排序操作和调试绘制混杂在一起,互相影响,难以在正确的时机做排序。
  • 目前排序只能在平台层或调试系统代码中执行,没有一个合适的、统一的入口去进行排序操作。

二、理想的处理方案

  • 希望排序能"提早"发生,在游戏主循环的更新和渲染阶段(game update and render)中就执行,而不是等到平台层渲染结束后。
  • 具体来说,排序应当在"render group"构造期间完成。render group是我们收集和管理绘制元素的地方,排序在这里做,可以保证绘制顺序正确,且分块处理。
  • 调试系统应当和游戏渲染分开排序处理,甚至可以让调试系统的绘制顺序不排序,或者单独调用排序逻辑,这样两者互不干扰。

三、为什么这样设计更好

  • 渲染顺序更清晰,调试绘制不会影响游戏绘制的排序。
  • 各个模块独立处理自己的绘制和排序逻辑,降低耦合,代码更易维护。
  • 提前排序让最终渲染阶段只做绘制操作,不再涉及复杂排序,提升性能和简洁性。

四、当前执行计划和后续工作

  • 现有代码中,render group管理绘制条目,条目包括push操作和最终的sort entries。
  • 这些sort entries会堆积在render group的末尾,代表绘制顺序。
  • 接下来需要调整代码,将排序逻辑移至render group的生成阶段,而非渲染最终阶段。
  • 这项工作比较复杂,短时间内难以完成,但可以先逐步重构,逐渐拆分排序逻辑与渲染代码。
  • 明天可以继续推进,让整体架构更合理,方便后续调试和完善。

总结来说,我们需要把排序逻辑从平台层"提出来",放到渲染组生成阶段,分开处理游戏绘制和调试绘制的排序,这样才能实现合理的绘制顺序管理,避免代码重复和混乱。

黑板讲解:将游戏排序数据和调试排序数据分开处理

我们现在考虑的主要问题是如何更有效地处理游戏绘制数据和调试绘制数据的排序问题。当前设计中,游戏数据和调试数据都填充到了同一个排序缓冲区中,最终对整个缓冲区进行排序,得到绘制的顺序。


一、分开排序的想法

  • 理论上,游戏绘制数据和调试绘制数据可以分别独立排序,没有必要非得在同一个缓冲区里等到所有数据都准备好才一起排序。
  • 这样做的好处是,我们可以分别处理游戏排序数据和调试排序数据,然后分别得到两个绘制顺序列表。
  • 分开排序后,两个排序结果可以分别传给渲染阶段,分别绘制。

二、分开排序的潜在设计方案

  1. 分多个排序缓冲区

    • 为游戏绘制数据和调试绘制数据各自维护独立的元素缓冲区和排序数据缓冲区。
    • 每个缓冲区独立排序,得到对应的绘制顺序。
    • 最终渲染时,先绘制游戏数据,再绘制调试数据。
  2. 绘制两遍(双通道渲染)

    • 先进行一遍游戏绘制,输出游戏内容。
    • 再进行一遍调试绘制,覆盖在游戏内容上。
    • 这样可以保证调试信息总是在游戏绘制内容之上。

三、问题与考虑

  • 如果分开排序,而不统一排序游戏和调试数据,那么必须明确规定绘制顺序,确保调试内容总是覆盖在游戏内容之上。
  • 目前的设计中,调试元素是和游戏元素混合排序,这样调试内容能插入游戏绘制的任何位置,保证渲染顺序一致。
  • 不过这是否必要值得思考,因为调试信息通常是覆盖层,没必要与游戏元素混合排序。
  • 需要考虑是否确实有必要将调试元素"插入"游戏渲染流程中排序,否则单独两遍绘制会更简单清晰。

四、总结

  • 可以考虑将游戏和调试绘制分别管理排序缓冲区和排序过程。
  • 采用先绘制游戏,再绘制调试的两遍绘制方式,简单有效,避免复杂混合排序带来的麻烦。
  • 设计时需要决定是否保留调试元素混入游戏排序的需求,或者彻底分离绘制流程。
  • 这会让绘制管理更清晰,也更容易维护。

整体来说,这里讨论的重点是如何在保证绘制顺序正确的同时,让排序流程更模块化、独立和高效。

win32_game.cpp:考虑将 RenderCommands()LinearizeClipRects() 分两处调用

我们当前的思路是想进一步简化平台层(例如 Win32)中与渲染有关的职责,把更多的逻辑迁移到平台无关层,减少渲染命令处理、排序和裁剪等步骤对平台层的依赖,使系统结构更清晰、职责更明确。


一、渲染命令流程现状

  • 渲染命令的创建流程是:在平台层创建一个 render_commands,然后将它传递给游戏代码,让游戏逻辑将要绘制的内容写入其中。
  • 当前的逻辑中,我们在游戏层填充完 render_commands 之后,并没有立即使用,而是延后到平台层再处理,例如排序、裁剪等。

二、改进思路:双阶段渲染命令处理

  • 新方案设想如下:

    1. 在平台层创建第一个 render_commands,将其传给游戏逻辑,游戏逻辑在里面填充所有绘制内容。
    2. 填充完成后,在平台层立刻处理这个命令集合,例如执行排序、裁剪等操作。
    3. 如果需要调试渲染或额外的数据绘制,可以创建第二个 render_commands 并再次传给调试系统或其他部分,进行另一轮填充与处理。
  • 这样,渲染命令的生命周期变得清晰,处理逻辑变得分离,平台层的职责也得以简化。


三、对裁剪内存使用的分析

  • 在 Win32 层中,传递了 clip_memory 主要用于裁剪矩形处理。
  • 查看调用栈中 win32_play_buffer_and_window,会发现这里使用了 clip_memory 来执行 linearize_clip_rects,即将裁剪区域线性化。
  • 问题在于: 这个裁剪处理本可以在平台无关层中完成,却混入了平台层,仅仅是出于当时实现方便的考虑。

四、目标与优化方向

  • 我们想要让平台无关层承担以下职责:

    • 对渲染命令排序;
    • 线性化裁剪区域;
    • 生成最终的绘制命令数据;
  • 让平台层只承担:

    • 接收一组完全准备好的绘制命令;
    • 调用后端 API(如 GDI 或 OpenGL)渲染到屏幕上;
  • 游戏逻辑只关注填充渲染命令,不关心后续处理;

  • 调试逻辑可以作为第二阶段使用新的 render_commands 对象再处理一遍,互不干扰。


五、总结

  • 当前代码中渲染命令处理和裁剪逻辑过度耦合到平台层。
  • 我们打算将排序、裁剪等逻辑上移到平台无关代码中,使平台层职责简化。
  • 可以在一个帧周期中执行多个渲染命令集合处理(如游戏与调试分别一套),通过先后顺序控制叠加效果。
  • 这样有助于模块化、可维护性和调试的灵活性,构建更清晰的渲染流水线。

game_sort.cpp:阻止 BuildSpriteGraph() 调用 PushStruct()

我们暂时不会立即对渲染系统进行重构,因为当前要处理的内容过多,不太适合现在就展开。因此我们决定暂时先保持系统"半完成"的状态,先处理局部问题,确保现有逻辑能够正常运作。


当前我们打算进行的处理方式如下:

  1. 暂不进行完整重构

    当前渲染系统的架构问题已经识别出来,包括平台层职责过重、排序和裁剪逻辑混杂等,但因为这部分改动较大,不适合在当下这个阶段立即着手,因此先搁置。

  2. 暂时保留简化处理路径

    现阶段我们不会分离 debug 和 game 的 render group 或重新设计排序路径,而是保留一个"简化版"的处理流程,确保渲染逻辑基础部分可以运行,等待后续时间再进行优化和结构调整。

  3. 验证 push 操作可用性

    当前我们关注的重点是让 push 操作能够正常运行。push 是构建 render entries 的基础步骤之一,我们将确保其可以正确执行,为后续填充渲染命令做准备。

  4. 保留逻辑断点,为下一步铺路

    当前实现虽然是残缺状态(gimped),但我们清楚后续改动的方向,因此可以在现有基础上逐步展开,比如分阶段处理 render_commands 或对 render_group 的排序方式做模块化拆分。


总结

我们选择暂时保留现状,不做过多结构性调整,而是先确保当前渲染命令构建逻辑正常工作。在此基础上,后续再逐步将排序、裁剪、命令组分离等优化提上日程,以避免一次性改动过多带来的混乱和不确定性。这是一个以稳定为主、逐步演进的策略。

win32_game.cpp:阻止 Win32DisplayBufferInWindow() 调用 SortEntries()

我们决定在当前的上下文中调用 SortEntries 函数,并暂时传入一个空指针(传 0)作为临时内存区域的参数,虽然这样做并不能真正实现排序的效果,但可以先保证程序流程的完整性。接下来我们打算对排序逻辑的调用位置做一次调整,将它从当前层级移动到更合适的位置,也就是平台相关代码之中,这样可以更好地与平台独立层解耦。


具体处理如下:

  1. 临时传入空内存以占位

    为了让 SortEntries 的函数调用不报错,我们先传入一个空指针(0)代替实际的内存区域。当前并不依赖排序的输出结果,因此这个做法在现阶段是可以接受的。

  2. 计划将排序调用移入平台相关代码层

    由于排序操作其实是与底层渲染平台更贴近的功能,因此我们意识到更合理的做法是将 SortEntries 的调用放入平台相关的实现文件中。这样可以避免将平台独立的逻辑与具体排序实现耦合在一起。

  3. 接下来将会执行的操作

    接下来的计划是将排序逻辑迁移后,重新整理调用顺序,确保在平台渲染命令真正执行之前,排序已经完成并得到正确的绘制顺序。同时,还要处理好内存管理部分,确保排序用到的临时内存是安全且合适的。


小结

当前我们做了一个简化处理:先以空参数调用排序函数保证流程走通。接下来将会把排序逻辑转移至平台层中,这样能更合理地组织代码结构并减少层级之间的耦合问题。这个步骤是为后续结构优化和更稳定的渲染流程打基础。

运行游戏,发现条目没有排序,但程序没有崩溃

我们决定在没有完成排序逻辑的情况下,先暂时传入空指针调用 SortEntries,这样虽然排序不会实际执行,但至少程序能够继续运行,不会发生崩溃。


当前处理策略的详细说明:

  1. 排序函数仍被调用,但不执行任何排序操作

    通过传入空指针(例如 0)作为临时内存参数,我们让排序函数能够正常被调用,但其内部不会进行实际的排序计算。这样做的目的主要是为了保持函数调用链的完整,同时暂时规避内存未配置的复杂处理。

  2. 保障运行流程不崩溃

    尽管渲染顺序可能是混乱的,但由于所有流程都得以顺利执行,程序不会因为空指针或未初始化的内存而崩溃。因此这是一种权宜之计,用于在开发早期验证其它部分逻辑。

  3. 当前状态下仍然可以运行并输出渲染结果

    虽然渲染顺序没有被优化,但由于核心数据结构是完整的,仍然可以看到未排序的渲染效果。这使我们可以继续开发其它系统,例如内存分配或平台层结构调整。


小结

我们采取了一种临时的容错策略,在未完成排序逻辑和内存管理之前,先调用 SortEntries 而不传有效内存。这样程序依旧可以运行,只是渲染结果不会按正确顺序绘制。该做法为我们后续排序逻辑的真正接入打下基础,同时保障整体架构不被中断。

问答环节

你有没有讨论过为什么使用递归?是否担心栈溢出?

我们一开始在实现过程中使用了递归,但那只是为了快速验证逻辑并看清整体结构,实际上我们并不打算最终采用递归方式来遍历图结构。


详细说明如下:

  1. 避免递归的首要原因是性能问题

    虽然栈溢出确实是使用递归的一个潜在问题,尤其是在深度较大的图或树结构中,但我们更关注的是性能损耗。递归函数在每次调用时都会有额外的函数调用开销,这种反复进出栈的操作会显著降低性能,尤其是在频繁遍历图结构时。

  2. 迭代方式更适合高频场景

    我们更倾向于使用显式的栈或队列结构来代替递归,以实现图的遍历。通过这种方式,我们能够更灵活地管理内存使用,并避免系统调用栈过深带来的风险。

  3. 之前的经验教训

    我们过去在某些系统中使用递归处理图结构,结果发现这类方法在运行时效率较差,经常导致不可预测的卡顿或处理延迟,尤其在需要高性能实时反馈的应用中表现不佳。

  4. 后续方向的推测

    由于我们的图遍历过程中可能会有额外的"骚操作"或复杂逻辑嵌套,因此递归显然不是理想选择。我们需要更稳定、可控的方式进行图的构建与遍历,从而确保系统健壮性。


小结

虽然当前实现中临时使用了递归以便理清逻辑,但考虑到递归的性能问题和潜在的栈溢出风险,我们计划将其替换为显式的迭代实现。这将提升效率,增强系统稳定性,更适合处理大型图结构。

图结构确实难处理。除了观察屏幕渲染,还有其他调试方法吗?

我们当前处理的图结构相关内容相对简单,因此在目前阶段可能只通过观察屏幕渲染结果来调试就足够了。但通常来说,仅靠渲染结果进行调试是远远不够的,尤其是在面对更复杂的图结构时。


图结构调试的两种主要手段:

  1. 自行绘制图结构的可视化工具

    我们可以编写代码在屏幕上直接绘制出图的结构------比如节点、边的连接关系、节点的排序状态、遍历路径等。这样可以直观地看到每一步处理是否正确。这种方式适用于调试逻辑相对固定的图处理算法,尤其当我们希望实时观察程序运行时的图状态时非常有效。

  2. 将图数据导出为中间格式并使用专用工具查看

    另一种常见方法是将图的结构导出成特定的数据格式,比如 Graphviz 的 DOT 文件或 JSON 等,然后用专门的图可视化工具进行查看。这种方式适合调试大规模复杂图结构,特别是当图的遍历顺序、结构构建存在隐蔽错误时,通过外部工具能提供更强的分析和交互功能。


是否需要视项目复杂度而定:

  • 如果处理的图比较简单,比如基本的拓扑排序、路径记录、前后依赖关系追踪等,通过直接渲染验证最终排序结果是否正确,或渲染路径是否连贯,通常就能发现大多数错误。
  • 但如果图涉及动态生成、多级依赖、复杂遍历逻辑、状态更新等操作,靠肉眼观察渲染输出就可能非常低效或根本无法定位问题。

后续可能的策略:

我们预计目前采用的图算法技术不复杂,可能不会遇到必须依赖图调试工具的场景。但一旦发现排序错误、死循环、逻辑断裂等情况且难以定位原因,我们会:

  • 先尝试用屏幕上的图形辅助渲染调试信息(如绘制箭头、编号、颜色编码)。
  • 如仍无法定位,再导出图数据至外部工具进行更深入分析。

总结:

虽然现在可能只用屏幕观察调试就足够,但这是因为当前的逻辑简单。如果之后遇到更复杂的图算法处理,我们会采用图结构可视化工具或者数据导出辅助调试的方式,确保问题能被快速准确地发现和解决。

说真的,你有考虑过精灵之间可能会相交吗?

我们不会处理精灵之间的相交问题,也就是说,不允许精灵发生相交。换句话说,如果两个精灵发生了相交,不会对其进行特殊处理,无论渲染结果如何,都不会做出干预或额外判断。


具体说明如下:

  • 精灵相交不是一个被支持的情况:系统设计中默认精灵之间不应该有重叠交集,因此渲染排序、遮挡关系、图层控制等逻辑都不会考虑相交精灵的特殊性。

  • 一旦发生相交,结果就是不可控的:我们不会去判断谁在上层、谁该显示、是否透明混合等逻辑,渲染结果由当前排序状态决定,可能出现遮挡错误、闪烁或图像交叠错乱。

  • 这是一种明确的设计约定:通过限制输入场景中的精灵布局,来避免需要实现复杂的遮挡检测和深度管理等逻辑,从而简化渲染路径与排序算法,提高效率。

  • "发生什么就是什么"是可接受的:我们接受这种不处理的结果,不认为这是 bug 或系统问题,而是场景布置不当的后果。


总结:

我们不处理精灵之间的相交问题,也不会做相应的渲染补偿或逻辑回避。系统只关注非相交的精灵排序与绘制,对于重叠的情况,不做保证,也不进行任何调试或容错设计,完全由现有渲染流程决定其表现结果。

相关推荐
vegetablesssss3 分钟前
QGrphicsScen画布网格和QGrphicsItem对齐到网格
c++·qt
chen_song_14 分钟前
CUDA的设备,流处理器(Streams),核,线程块(threadblock),线程,网格(‌gridDim),块(block)和多gpu设备同步数据概念
c++·人工智能·计算机视觉·数据挖掘·cuda编程·并行化计算·并行化计算与cuda编程
vibag15 分钟前
第十六届蓝桥杯复盘
java·算法·蓝桥杯·竞赛
Owen_Q16 分钟前
Leetcode百题斩-回溯
算法·leetcode·职场和发展
珹洺28 分钟前
计算机操作系统(十一)调度器/调度程序,闲逛调度与调度算法的评价指标
android·java·算法
理论最高的吻1 小时前
HJ33 整数与IP地址间的转换【牛客网】
c++·算法·牛客网·ip地址转换
东京老树根1 小时前
SAP学习笔记 - 开发13 - CAP 之 添加数据库支持(Sqlite)
笔记·学习
我漫长的孤独流浪2 小时前
STL中的Vector(顺序表)
开发语言·c++·算法
编程版小新2 小时前
封装红黑树实现mymap和myset
c++·学习·set·map·红黑树·红黑树封装set和map·红黑树封装
通达的K2 小时前
Java的常见算法和Lambda表达式
java·数据结构·算法