游戏引擎学习第311天:支持手动排序

仓库:
https://gitee.com/mrxiao_com/2d_game_7(已满)

新仓库:
https://gitee.com/mrxiao_com/2d_game_8

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

我们接下来要继续完成之前开始的工作,上周五开始的部分内容,虽然当时对最终效果还不太确定,但现在主要任务是让渲染器的前端能够正常工作,确保我们想要实现的所有Z轴处理效果能够正确呈现。

目前我们已经实现了排序算法,精灵的排序基本上是正常工作的,但还没有实现我们想要的分层切片机制。我们打算把精灵分到不同的堆栈里,这些堆栈是基于图层的,并且希望这些图层能够相对独立地发生变化,不受屏幕上其他内容的影响。因此,现在必须回到之前想完成的工作------确保Z轴排序从头到脚都正确。

下一步要搞清楚这种分层切片的具体表现形式。因为现在我们有真正的游戏内容,比如怪物行为和渲染逻辑,我们希望这些代码能够说同一种"语言",也就是说,让实体能够简单明了地表达自己想要渲染什么、渲染在哪里,不希望有太多零碎的代码引起问题。

虽然听起来不算大事,但这实际上是大量的工作。我们需要对代码进行组织和调整,把内容合理划分成切片。第一步是确定怎么划分这些切片,第二步是确定切片如何经过渲染系统和排序阶段,最后是这些切片怎么通过后端渲染器(软件渲染或者OpenGL)输出。

这里面涉及很多复杂的决策,而且有很多相互竞争的需求。举个简单例子,现在排序主要是在渲染流程的最后完成,但我们可能需要把排序往上移一点,提前到游戏逻辑层处理。原因是我们在游戏代码中已经知道每个切片包含的内容,没必要把所有这些细节都暴露给渲染层。渲染层本身需要在软件渲染和OpenGL两套系统中都实现,并且在底层要保证简洁高效,避免复杂难控的代码。

此外,我们还要传递和存储排序键(sort keys),这可能会造成资源浪费或者效率低下,也需要仔细权衡。

总的来说,这是一件非常复杂的事情,需要我们深入思考,制定合理的设计方案,才能正确地实现这些目标。接下来会花不少时间在这方面的工作上。

计划实现一个功能:对精灵集合进行排序

接下来可能会从明天开始着手处理这个问题。今天的计划是回到上周五查看的那段代码,继续实验和探索如何实现这样一个功能:让一个角色或对象拥有一组已经预先排序好的精灵集合,然后让这个集合整体参与排序,而不是把集合中的每个精灵单独拿出来,通过各自的排序键逐个排序。

针对这个问题,我们有几种不同的实现方式,但目前还不确定哪种方式最简单可行。上次我们慢慢地开始探索这条思路。这里还涉及之前一次讨论中的一些观点,所以需要结合之前的想法一起考虑。总体来说,就是尝试设计一个系统,使得预先排序的精灵组可以作为一个整体被排序,从而简化排序流程和提高效率。

黑板讨论:我们可以使用哪些方法来对精灵集合排序

我们在讨论手动排序覆盖的问题,考虑了几种不同的处理方式。首先,有一种方案是将一组相关的精灵合并成一个整体的"盒子",这样排序器就可以简单地把它们当成一个单元来处理,这样做看起来挺简单。但问题是,这样合并的盒子可能会包含过多重叠区域,导致排序时出现不必要的复杂情况和错误,因为把多个元素合成一个大盒子,会让排序区域变得很宽泛,包含了很多本不应该被包含的部分。

因此,更安全的做法可能是保持每个精灵都有自己的盒子,同时提供一种机制来告诉排序器它们应该被视为一组一起排序。也就是说,不是物理合并它们的空间区域,而是逻辑上把它们关联起来。

另一种相对简单的方式是依赖排序键(SortKey)机制。如果我们有一组精灵需要按预定顺序排列,这些精灵可以赋予相同的排序键。比如,精灵A、B、C都给出完全相同的排序键,这样排序系统在排序时,会根据它们在缓冲区中出现的顺序来决定绘制顺序。因为排序过程通常是稳定排序,顺序相同的键值时会保持它们原来的排列顺序。

这种方法相对简单且已有部分实现。不同的精灵虽然有各自的屏幕区域,但排序键相同,就能保证它们保持在预期顺序里。具体排序键的数值该怎么确定呢?如果是Y轴精灵(Y-sprite),排序键可以用其最大Z值和某个固定的Y值(可能是平均Y或者第一个Y坐标),这些数值可以由用户指定。如果是Z轴精灵(Z-sprite),则可能需要更复杂的Y最小值和最大值区间来确定排序,这部分还不完全确定。

相较于之前考虑的"串联链表(daisy chaining)"的复杂方案,这种统一排序键的方案看起来更简单,更易于实现。把"串联链表"功能移除后,可以让这些相关的精灵按照正常顺序依次加入排序器,再让排序器依据排序键自动处理。

简而言之,尽量减少系统中的复杂特殊处理逻辑,保持排序系统尽可能简单,是有利于整体效率和可维护性的。即使这种方法在某些情况下可能略微降低效率,但减少复杂性带来的整体优化和代码简洁性会带来更大的好处。

总体来说,希望尝试这种用相同排序键,通过缓冲区顺序实现多精灵有序绘制的方式,简化整个排序系统的设计,使它既满足需求又不增加不必要的复杂度。

运行游戏,观察当前的排序效果

当前运行的是一个标准的游戏版本。我们进行了测试,加载并运行了最新版本后,观察游戏中角色的表现情况。现在关注的重点是主角的Sprite渲染顺序。

当前的Sprite渲染逻辑中,角色在Y轴向后移动时,会被排序系统判断为应该"更靠后"渲染,导致角色头部被衣服遮挡。虽然这种Y轴深度排序在大多数情况下是正确的,但这并不适用于主角的头部和身体之间的关系。

我们希望无论主角在Y轴上的位置如何变化,头部始终渲染在衣服之上,避免出现视觉上的遮挡错误。换句话说,我们要调整渲染层级,让头部始终显示在最上层,而不是被自动排序逻辑所影响。

当前的测试目的就是验证是否能够实现这个目标。我们计划通过调整渲染优先级、分层逻辑等方式,确保主角的头部在任何时候都不会被衣服遮挡,从而提升游戏的视觉一致性与表现力。

修改 game_render_group.cpp:移除 PushRenderElement_() 中的 NewElement,引入"写回"机制,允许使用新的信息覆盖已有的 SortKey

我们正在优化游戏渲染系统中的排序机制,目标是更好地控制不同Sprite(精灵)之间的渲染顺序,特别是主角Sprite的部分遮挡问题。

目前在render group中,我们开始初步设计新的排序方法。以往的逻辑是每个元素在渲染时都会生成一个新的排序元素(sort field),这在大多数情况下是有效的,但现在我们需要一种更加灵活的机制,以支持多个Sprite按照特定规则进行整体排序。

我们不再希望保留原有的通用排序逻辑,而是要引入"排序回写(write-back)"的概念,也就是说,在将多个Sprite渲染后,我们需要用一个"聚合后的排序键(aggregate sort key)"来覆盖原始的排序键。这就需要我们记录下多个Sprite组合后的边界信息,形成一个"聚合边界(aggregate bound)",供后续使用。

为此,我们计划在render group中新增一个字段,例如SpriteBound或类似的变量,用来存储聚合边界信息。这个字段会在每次添加新Sprite时,通过一系列minmax操作来不断更新,确保包含所有相关Sprite的完整范围。

值得注意的是,我们不打算更改每个Sprite自身的屏幕区域(screen area)信息,这部分仍然保持独立,确保它们各自的绘制区域正确分离。我们只聚合边界信息,用来影响排序逻辑。

通过这种方式,在render group中我们就能为多个Sprite提供统一的排序依据,使它们作为一个整体进行渲染排序。接下来,我们可能还会设计一个小型API,供调用者使用,方便地将Sprite打包为一个排序单元,从而提升整体的渲染控制能力和视觉一致性。

继续在 game_render_group.cpp 中考虑:是否让 PushBitmap() 返回一个值,以供 PushRenderElement_() 修改

我们正在进一步改进渲染排序机制,重点在于处理 push_bitmap 操作的行为变化。

以往在调用 push_bitmap(推送一个Sprite贴图)时,并不会返回任何值。但现在由于新的排序策略引入,我们需要让它返回某些信息。原因在于:我们需要在推送完一系列Sprite之后,重新写入(overwrite)它们的排序键(sort key)。这是因为直到我们完成多个Sprite的处理之后,我们才知道它们真正的聚合排序信息,进而能确定应该使用的最终排序键。

因此,我们必须引入一种机制,使调用者能够在事后回头修改这些已推送Sprite的排序信息。这也意味着我们要为每个 push_bitmap 操作返回某种可追踪的数据结构,这样在之后可以使用新的排序信息覆盖已有内容。

解决这个问题的一种思路是,在开始推送Sprite之前,显式调用一个"重置聚合边界(reset sort bound)"的函数。这样就能确保我们记录的是这批Sprite的统一边界。随后,在完成这些Sprite的所有 push_bitmap 操作之后,我们便拥有一个聚合边界(aggregate bound),其中包含我们想要用来计算最终排序键的数据。

核心问题就变成了:如何获取之前已经推送过的Sprite的排序键?因为只有拿到这些键,我们才能将新的聚合排序键写回去。

所以,下一步的设计方向是:

  1. 修改 push_bitmap 接口:返回可以被追踪和访问的结构,比如一个记录了该Sprite在渲染队列中的索引或句柄。
  2. 支持排序键覆盖:引入一个机制,允许我们在事后使用新的排序键覆盖这些记录中对应的Sprite。
  3. 提供聚合边界API :通过 reset_sort_bound 开始记录一个新的聚合过程,结束后使用聚合结果统一更新一组Sprite的排序键。

这种设计将使得多个相关的Sprite能够被当作一个整体进行排序,同时又不会破坏它们各自独立的屏幕区域信息。整体目标是实现更精确的视觉层级控制,特别是在角色头部与身体等部位之间,保持正确的渲染前后关系。

修改 game_entity.cpp:在 UpdateAndRenderEntities() 中加入 BeginAggregateSortKey()EndAggregateSortKey(),标记哪些实体共享一个排序键

我们正在继续完善渲染排序系统的结构,当前的重点是解决多个Sprite(例如一个角色的身体部位)之间如何共享统一的排序键(sort key)的问题。

以往每次调用 push_bitmap(推送一个Sprite)时,并不会返回任何数据,也不追踪这些调用的位置。但在当前的设计中,我们需要将这些Sprite当作一个整体来处理,因此必须追踪这些操作,以便在最后用一个统一的排序键来覆盖它们。也就是说,我们需要一种机制来记录在某段时间内推送了哪些Sprite,并在它们全部被推送后统一更新它们的排序信息。

在查看push_bitmap调用的位置时,我们注意到Sprite的推送逻辑已经被移到entity系统中。在遍历piece index并进行Bitmap推送的地方,是一个合适的切入点来标记这些Sprite将共享同一个排序键。

但问题在于:如何标记和覆盖这些排序键?这是当前遇到的主要挑战。

我们不希望引入太多复杂的内存复制或指针操作,因此在考虑如何以简单清晰的方式实现这一功能。设想如下:

  • 开始和结束一个聚合排序阶段 :通过调用类似 begin_aggregate_sort_key()end_aggregate_sort_key() 的函数,标志着接下来的一批 push_bitmap 调用将属于同一个聚合排序单元。
  • 在这个阶段内推送的所有Sprite会记录索引位置(例如在渲染缓冲区中的位置),而不是返回复杂的结构或指针。
  • end_aggregate_sort_key() 被调用时,我们将计算出一个聚合的排序边界(aggregate bounds),并统一将该排序键写入到之前记录的Sprite中。

这一思路的好处是:

  1. 不需要外部干预,用户只需明确开始和结束聚合区域即可;
  2. 内部结构高度有序,可以轻松基于索引范围更新排序键;
  3. 不依赖额外指针,避免复杂的内存管理。

目前还有一个技术上的难点,就是聚合排序键的类型推断。例如:

  • 如果推入的是Y轴排序类型的Sprite(y-sprite),那么聚合后也应该保留为y-sprite;
  • 但使用简单的min-max方式进行聚合,可能意外将其"扩展"为Z轴类型,从而改变渲染逻辑;

因此,需要决定:

  • 是否允许用户在 end_aggregate_sort_key() 中手动提供排序类型信息;
  • 或者是否从某个代表性Sprite(比如第一个)中提取排序类型作为基准;
  • 又或者直接在内部通过某种策略来保持最初的排序类型。

为简化初期实现,我们将先做一个完全自动化版本,即用户无需传递任何信息,系统会自动追踪聚合边界并更新排序键。后续如果发现自动推断带来问题,再考虑添加更细化的用户控制接口。

总体而言,当前的设计逐渐清晰,将支持更强大而灵活的排序控制能力,尤其适用于多个Sprite需要共同排序但又要保持各自独立屏幕区域的复杂渲染场景。

game_render_group.cpp 中实现 BeginAggregateSortKey()EndAggregateSortKey()

我们目前正在实现一套聚合排序键(Aggregate Sort Key)机制,用于在渲染流程中统一管理一批被推入的 Sprite,使它们拥有一致的排序行为。核心目标是确保在调用 begin_aggregate_sort_key()end_aggregate_sort_key() 之间的所有 Sprite 都共享一个统一的排序边界(Sort Bound)。

以下是详细设计与实现逻辑:


初始化聚合边界(Aggregate Bound)

begin_aggregate_sort_key() 被调用时,我们首先需要初始化聚合边界数据:

  • 最小值设为最大可能值最大值设为最小可能值。这样可以确保后续任何一个 Sprite 推入时,它的边界信息都会"扩展"当前聚合边界,保证边界最终包含所有 Sprite 的范围。
  • 这种初始化策略是通用技巧,可以确保聚合边界在遍历过程中被正确更新。

记录 Sprite 推入的位置

我们在每次调用 push_bitmap 时,都会生成一条排序命令并推入渲染命令缓冲区。为了在 end_aggregate_sort_key() 时能够回头修改这些命令的排序键,我们必须知道它们在缓冲区中的确切位置。

解决方案:

  • 在调用 begin_aggregate_sort_key() 时记录当前缓冲区的起始位置,命名为 first_aggregate_at
  • 随后每推入一个 Sprite,就自增一个聚合计数器 aggregate_count
  • end_aggregate_sort_key() 中,我们使用 first_aggregate_ataggregate_count 计算出所有参与聚合的命令位置,然后统一修改它们的排序键为聚合边界所生成的排序键。

结束聚合并覆盖排序键

end_aggregate_sort_key() 中:

  1. 遍历从 first_aggregate_at 开始的所有 aggregate_count 条渲染命令。
  2. 每一条命令的排序键字段被替换为聚合边界生成的统一排序键。

这个操作确保所有聚合内的 Sprite 都按照这个统一的排序逻辑参与最终渲染。


排序键计算中的注意事项

我们还需要考虑排序类型的保持,例如:

  • 若起始 Sprite 是一个 Y-Sprite,则不希望聚合后变成 Z-Sprite。
  • 使用边界 Min/Max 来自动生成排序键时,可能会误将 Y-Sprite 变为 Z-Sprite,因为边界扩大了。

为此我们考虑两种方案:

  1. 完全自动推断:在聚合过程中自动提取最初 Sprite 的排序类型,并在结束时应用到统一排序键上;
  2. 用户手动指定 :允许 end_aggregate_sort_key() 接收一个明确的排序类型参数,避免误推断。

当前阶段我们先采用全自动模式,在必要时再引入手动控制的选项。


技术实现细节

  • 使用 push buffer 的索引计算 Sprite 的实际位置;
  • 渲染命令结构通过已有字段(如 SortEntryAt)与渲染组内的 Sprite 数组结构对齐,因此只需要计算偏移量即可精准访问;
  • 所有聚合 Sprite 的排序键都通过统一聚合边界生成,避免了冗余数据复制或不必要的内存操作;
  • 编译时也需调整 include 顺序,确保相关的结构体如 render_grouprender_doh 在使用前已正确定义。

这一机制的设计简洁、直接,避免了复杂的内存管理或返回值处理,同时具备高扩展性,为后续更高级的渲染排序策略(例如动态层级分组)打下了坚实基础。我们下一步要处理的问题,是如何在结束聚合时确保类型标识的一致性。

PushRenderElement_() 能够正确设置 Y/Z 精灵的聚合边界(AggregateBound

我们正在解决的问题是:在执行聚合排序(Aggregate Sort Key)操作的过程中,如何根据 Sprite 的类型(Y Sprite 或 Z Sprite)合理地处理它们的排序边界,尤其是在多次 push_bitmap 的情况下正确扩展或设置聚合边界信息。


基本逻辑流程

每当 push_bitmap 被调用,我们都会做以下几件事:

  1. 递增聚合计数器 aggregate_count

  2. 判断当前是否为聚合的第一个 Sprite:

    • 如果是第一个(aggregate_count == 0),则将其排序边界直接复制给聚合边界。
    • 如果不是第一个,则进入边界合并逻辑。

关键判断:Y Sprite vs Z Sprite

在第一个 Sprite 被推入后,后续的每一个都要判断是否应该合并进同一个聚合边界,而这个判断的核心在于 Sprite 类型:

  • Y Sprite 通常根据 Y 坐标进行排序。
  • Z Sprite 通常根据 Z 坐标进行排序。

我们必须确保所有被聚合的 Sprite 类型保持一致,否则排序逻辑会出错。

我们引入一个辅助条件判断机制来处理这个:

c 复制代码
if (is_y_sprite) {
    // 聚合逻辑:更新 Y 边界
} else {
    // 聚合逻辑:更新 Z 边界
}

判断是否是 Y Sprite 或 Z Sprite 的方法,我们已有代码逻辑可用,例如通过 Sprite 的排序键中的某个位来判断是否为 Y Sprite。


聚合边界的处理策略

  • 第一个 Sprite:

    • 聚合边界直接等于当前 Sprite 的排序边界;
  • 后续 Sprite:

    • 如果是 Z Sprite:通常对 Z 方向的 min/max 值做合并(扩展范围);
    • 如果是 Y Sprite:则可能只对 YMin/YMax 做扩展,且不能改变 Sprite 类型(仍需标记为 Y Sprite);
    • 类型一致的情况下,我们只需对边界进行标准 min/max 扩展。

这个机制确保了聚合排序键能保持一致性并且不意外改变原始的排序逻辑。


总结当前策略的优势

  1. 自动处理逻辑简洁明确: 使用 aggregate_count == 0 判定首个 Sprite,后续扩展边界;
  2. 类型判断严谨: 根据 Sprite 类型(Y/Z)决定如何合并边界;
  3. 数据结构利用高效: 不需要引入额外的复杂数据结构或指针操作;
  4. 行为可扩展: 未来可添加如平均边界值等策略,仅需在此逻辑中扩展分支判断即可。

下一步要继续完善的是聚合结束时是否允许外部指定排序类型,或者完全依赖首个 Sprite 类型作为基准并自动传播,这还需要进一步验证更复杂使用场景中的表现。

修改 game_render.cpp:新增 IsYSprite() 函数用于判断是否为 Y 向精灵

我们正在实现一个聚合排序系统,并进一步完善其中的逻辑处理,尤其是针对 Y Sprite 和 Z Sprite 的处理方式。


目标与背景

我们希望支持在一段时间内连续推送多个 Sprite(比如多个 tile、物体或图层片段),在逻辑上把它们作为一个整体进行排序。因此需要构建一个聚合边界(Aggregate Bound),这个边界能够代表所有 Sprite 的联合排序信息。

但由于不同类型的 Sprite(Y Sprite 与 Z Sprite)在排序方式上是完全不同的,我们必须确保一个聚合内部的所有元素类型是统一的,否则无法正确排序。


主要处理逻辑细化

1. 判断 Sprite 类型

我们通过排序键(Sort Key)中的某些字段是否相等来判断 Sprite 的类型:

  • 如果某两个特定字段 不相等,那么这是一个 Z Sprite;
  • 否则就是一个 Y Sprite。

于是我们实现了一个函数 is_z_sprite() 来封装这个判断逻辑。

2. 对聚合中的 Sprite 做类型一致性检查

我们加入断言逻辑:

  • 若聚合中第一个 Sprite 是 Z Sprite,后续所有必须也是 Z Sprite;
  • 若聚合中第一个 Sprite 是 Y Sprite,后续也必须是 Y Sprite;
  • 否则立即触发断言,阻止继续聚合不同类型的 Sprite。

这保证了聚合排序的合法性和行为一致性。

3. 聚合边界更新方式的差异
  • Z Sprite 的聚合:

    需要不断扩展聚合边界的 Z 值区间(例如 zMin/zMax),以涵盖所有 Z Sprite 的实际可见排序范围。

  • Y Sprite 的聚合:

    当前策略下我们选择不更新边界,即保持第一个 Sprite 的 Y 值作为聚合边界。这是因为 Y Sprite 的排序往往固定在起始值即可。

    若后续需求复杂,可以考虑改为扩展 YMin/YMax。


聚合状态的控制机制

为避免错误地在非聚合期间执行聚合逻辑,我们增加了一个标志变量:

c 复制代码
bool aggregating;

在 Render Group 内部使用该变量表示当前是否处于聚合状态。

控制逻辑:
  • 调用 begin_aggregate_sort_key() 时:

    • aggregating 必须为 false;
    • 设置为 true;
    • 初始化聚合状态;
    • 断言防止重复 begin;
  • 调用 end_aggregate_sort_key() 时:

    • aggregating 必须为 true;
    • 设置为 false;
    • 写入聚合键;
    • 断言防止漏掉 begin 或嵌套聚合;

这样可以有效防止多次 begin 或 end 调用造成的状态混乱,也防止在非聚合状态下执行聚合边界扩展逻辑。


当前实现的优势

  1. 强一致性保证:

    类型判断+断言确保不会错误聚合 Y 和 Z Sprite。

  2. 逻辑清晰且可扩展:

    分支结构处理不同 Sprite 类型聚合行为,后续可扩展更多行为策略(如平均值聚合、特殊 Y 扩展等)。

  3. 状态机制明确可靠:

    使用 aggregating 标志位严格限制聚合边界的执行范围,避免误操作。

  4. 调试友好性高:

    合理使用断言让调试过程中的逻辑错误快速暴露,减少潜在 bug。


下一步计划

  • 检查调用聚合 API 的地方,确保 beginend 成对出现;
  • 验证所有 Y/Z Sprite 的聚合逻辑在实际渲染流程中的正确性;
  • 如有必要,进一步引入聚合类型枚举或配置,以便支持更复杂的排序需求。

再次运行游戏,发现当前效果其实还不错

现在我们需要确认之前实现的聚合排序逻辑是否真的生效。虽然结果乍一看好像不太对,但仔细观察之后发现,可能其实是工作正常的,只是视觉表现让我们误以为出了问题。


观察现象分析

我们看到屏幕上有三个元素顺序发生了变化,本以为这是错误的表现。但之后发现这三个元素实际上并不是被作为一个聚合体传递的,它们来自两个独立的实体(entity),所以目前还不能用它们的排序变化来判断聚合是否正确工作。

进一步检查后发现躯干(torso)和披风(cape)在排序中并未发生变化,说明当前聚合机制可能已经部分起效,只是头部(head)尚未被纳入同一聚合序列中。


当前阶段判断

因此,在当前的测试中:

  • 躯干与披风作为一个实体,没有显示出异常行为;
  • 头部暂时未聚合,表现正常;
  • 暂无明显排序错误或断言触发。

所以可以推测:虽然还没有正式完成全部聚合逻辑的使用,但当前的聚合排序机制本身在底层已经能够正常运作。


现有方案的价值与意义

目前的方案不仅基本可行,还有以下几个额外优势:

1. 结构清晰

聚合逻辑通过明确的 begin_aggregate_sort_key()end_aggregate_sort_key() 来界定使用范围,便于维护与阅读。

2. 不强制依赖结构完整性

即便没有把所有相关 Sprite(比如头部)立即聚合进去,现有逻辑仍然稳健,不会导致渲染失败或逻辑崩坏。

3. 允许逐步集成

我们可以先把躯干、披风等部分纳入聚合,确认其工作机制,再逐步引入更多 Sprite,比如头部。每一步都是可测试、可观察的,降低了调试难度。

4. 支持动态可变组合

当我们在使用聚合机制时,不需要所有 Sprite 都一开始就被静态组合,可以根据实际情况动态加入聚合区域。只要确保聚合边界正确,系统就能正确处理。


后续改进方向

  • 添加头部 Sprite 到聚合结构中,验证整个聚合单元能否被正确排序;
  • 确保在所有需要聚合的地方都使用了 beginend 包裹;
  • 增加可视化调试机制,比如调试打印聚合索引、Sprite 类型,以确认实际聚合行为;
  • 最终目标是:整个人物(头部、躯干、披风等)作为一个聚合单元,始终保持视觉与逻辑排序一致性。

总结来说,目前的聚合排序系统已经基本具备了功能,并且具备良好的可扩展性和测试分层能力,虽然还未完全应用到所有部件,但已有良好基础。接下来将逐步整合更多组件验证完整性。

修改 game_render_group.h:移除 render_group_entry_header 结构中的链式指针(daisy-chaining)

我们在实现聚合机制的过程中,发现了一种非常实用的"后门"方式。通过聚合排序键(aggregate key),我们不仅能够在一段连续的 Sprite 推送中完成聚合,还可以保存这个聚合键,并在稍后的时刻直接将其他元素加入同一聚合组,只要它们使用相同的聚合键即可。这个设计思路让我们的系统更灵活,不再依赖原来的顺序式推送,增强了结构的可控性。


优化方向:删除多余的链式结构

原先我们使用了一种"链式"的方式进行聚合管理,通过 next 字段将多个 render_entry 串联起来,以便构建聚合单元。但在新的聚合键机制下,这种链式结构显得不再必要,甚至变得多余与复杂。因此,我们决定彻底移除这部分功能以简化系统。


具体修改操作

1. 移除 next 字段

render_entry_header 结构体中,曾经存在一个 next_offset 字段用于链式链接。现在我们将其彻底删除。这样,每个渲染条目只保存自己,不再拥有对下一个条目的链接信息。

2. 修改相关逻辑

所有使用 next_offset 或链表逻辑的地方都需要进行调整:

  • 删除相关偏移量的处理;
  • 移除遍历链表的循环;
  • 确保每次只处理一个条目,不再期望自动进入下一个条目。
3. 清理游戏渲染路径中的遗留代码

在游戏渲染函数中,曾经存在一个基于 next_offset 的遍历循环,这在链式结构下是合理的,但在当前逻辑下则会导致死循环或错误渲染。我们识别出这段代码是个潜在的无限循环,并及时清除,避免运行错误。


当前优化的好处

  • 简化数据结构 :去除了不再必要的字段,让 render_entry 更轻量;
  • 提升逻辑清晰度:每个条目独立存在,通过聚合键统一识别归类,逻辑更直接;
  • 提高灵活性:可以在不同时间、不同位置将元素归入相同聚合组;
  • 避免错误或复杂度膨胀:减少出错点,降低维护成本。

后续可行方向

  • 增加聚合键注册和回收机制,避免重复或错误使用键值;
  • 增强对聚合键渲染区域的可视化调试;
  • 验证"延迟加入聚合组"的实际使用效果,确认渲染输出符合预期。

最终,我们通过删除链式聚合结构、引入更灵活的聚合键机制,使聚合渲染系统变得更简洁、高效、易维护,同时也更符合现代实时渲染系统对灵活性与稳定性的要求。

再次运行游戏,开始思考主角精灵的绘制顺序问题

我们现在面临的问题是:到底以什么顺序来处理这些渲染对象。对此,我们采取了一个非常实际的策略:按照最利于渲染系统当前实现的顺序来处理,也就是说,我们不会去强行定义某种理想顺序,而是直接顺从现有逻辑。


顺序处理策略

我们决定:采用和对象被推入的顺序相反的绘制顺序。也就是说:

  • 如果某个对象最后一个被加入到渲染队列中,它就会第一个被绘制;
  • 最先加入的则最后绘制;
  • 这种策略可以简化当前的渲染逻辑,减少对排序机制的额外负担。

这种做法实际上在很多渲染队列中非常常见,特别是在基于栈式结构的处理方式里,后入先出(LIFO)非常自然。


对当前结构的适配

在具体代码逻辑中,我们查看了 world 相关的渲染部分,确认了当前渲染顺序的处理模式。因此,决定不打乱已有顺序,只要反向读取就能满足渲染需求:

  • 不需要对聚合组内部再做额外排序;
  • 渲染系统只需按原顺序反向处理渲染指令;
  • 保证聚合组整体的渲染顺序依旧合理,且性能不会受影响。

好处总结

  • 简洁清晰:直接复用已有的推入顺序,省去了额外排序逻辑;
  • 性能友好:避免在渲染阶段增加计算负担;
  • 行为可控:由于是固定规则,开发者对对象的渲染顺序拥有明确的预期;
  • 易于调试:出问题时能根据对象推入顺序快速定位渲染顺序问题。

后续优化可能

虽然目前我们使用的是反向顺序策略,但未来如果渲染需求变化,比如加入了深度层级控制、多层视差背景、半透明图层等复杂情况,可能需要引入:

  • 显式排序字段;
  • 按需自定义渲染层级;
  • 动态优先级调整机制。

但在当前架构下,这种"推入顺序反向渲染"的方案既高效又简单,足以满足我们现阶段的所有需求。我们可以基于这个规则继续推进聚合渲染系统的其他部分构建与测试。

修改 game_world_mode.cpp:调整 AddPlayer()AddPiece() 的调用顺序

我们接下来查看了角色(hero)创建的部分逻辑,尤其是在添加身体部件(piece)的时候的顺序安排。通过检查与处理代码,我们确认了一件事情:


角色部件添加顺序的控制

我们在构造角色时,对各个渲染片段(如头部、躯干、披风等)的添加顺序进行了检查和整理:

  • 每一个角色的部件都是按特定顺序进行添加的;
  • 例如:先添加躯干,再添加披风,最后添加头部;
  • 这个顺序是有意设定的,以确保在渲染阶段,绘制顺序也是合理的(后添加者先绘制);

通过当前渲染系统中采用的"推入顺序反向绘制"策略,这种添加顺序就直接转化为了视觉上的前后关系:

  • 最后添加的头部先绘制 → 显示在最上层;
  • 最先添加的躯干最后绘制 → 显示在最底层。

这一点在我们实际测试时也得到了验证,绘制出来的角色结构清晰,遮挡关系正确。


渲染结构与添加逻辑对齐

我们通过这种策略达成了一个非常重要的目标:

  • 添加逻辑和渲染结构完全一致
  • 不需要额外的"排序"过程来处理遮挡顺序;
  • 简化了代码逻辑,也降低了维护成本。

效果验证

从现有的运行效果来看:

  • 渲染顺序完全符合我们的预期;
  • 部件之间的覆盖和叠加关系是正确的;
  • 没有出现错乱或者不一致的问题;

因此可以认为,这部分逻辑目前是稳定并且可靠的。


下一步方向

在角色的部件渲染顺序确认无误之后,我们就可以继续推进:

  • 实现更多角色、物体或场景的聚合渲染;
  • 将这个逻辑通用于所有类似需要排序控制的渲染元素;
  • 开始进一步测试聚合与非聚合对象之间的排序关系是否稳定;

我们当前的基础框架已经奠定,后续就是逐步扩展与稳固整体的渲染系统结构。

修改 AddPiece() 调用的数值参数,不再使用难以理解的偏移值

我们接下来的问题,更多是实体系统层面的问题,而不仅仅是渲染排序本身。问题在于:如何表达一种非标准但又非常关键的规则,比如"如果头部和身体同时存在,那么头部始终应该绘制在身体之上"。


问题背景与症状

我们发现了一个渲染问题:

  • 当角色头部在 Z 值上"进入"身体的后方时,会出现一种视觉闪烁的伪影;
  • 这是一种由于 Z 值排序引起的不一致视觉现象;
  • 用户看到的就是角色部件突然在前后之间"跳动",非常不自然;

这个问题并不是简单地靠现有排序逻辑可以解决的,因为:

  • 我们的排序是通用性的;
  • 而这个需求是特例化的逻辑关系:头部必须盖在身体上,不管 Z 值如何。

潜在的解决方向

我们需要在实体系统中找到一种方式来表达这类"特殊部件关系":

  • 一种可能是:添加一个渲染顺序的附加规则表

    • 比如:当某个实体中包含"头部"和"躯干"时,强制让"头部"渲染在"躯干"之上;
  • 这种规则要能够在 render group API 中传递下去,并影响最终的渲染排序逻辑;

  • 这需要我们在设计 API 时,就考虑到这类语义性的关系排序需求。

虽然目前我们主要在处理排序系统的核心逻辑,但这类附加规则对 API 设计有重要影响,所以我们提前考虑是有必要的。


清理临时 hack 和冗余代码

同时,我们也注意到以前为了临时解决排序问题添加的一些"乱七八糟"的 tweak:

  • 有些实体被硬编码了不合理的 Z 值,仅仅是为了让排序"看起来"对;
  • 现在由于我们有了更合理、语义化的排序方式,这些 hack 就变得多余了;
  • 我们可以清除这些值,避免系统逻辑混乱或后续维护困难。

这样,整个渲染系统就能更加"干净",不再依赖硬编码技巧,而是真正基于逻辑语义来驱动绘制顺序。


下一步计划

  • 回到实体系统,探索在哪一层可以表达这种"部件优先级"的规则;
  • 确保这些规则能够通过 API 向渲染层传递;
  • 清理掉不再需要的临时代码;
  • 保证排序系统的核心逻辑和特殊规则都能良好共存。

这不仅让渲染行为更可控,也为后续扩展复杂角色(更多可组合部件)提供了基础。

探讨:如何明确表达一个 sprite(比如头部)始终要绘制在另一个 sprite(比如身体)之上的意图

我们现在面临的问题,是如何表达一种部件之间的前后绘制关系约束 ,尤其是像"头部必须始终绘制在身体前面"这种需求。这个问题的复杂之处在于:头部和身体作为独立实体存在,它们在渲染时并不知道彼此的存在,也无法预知彼此的渲染顺序。


问题分析

  1. 实体之间相互独立

    • 头部和身体是两个不同的实体;
    • 渲染顺序依赖于它们被推入渲染队列的时间顺序;
    • 我们当前没有机制可以让一个实体提前声明"后续还会有另一个需要与我有关联排序的实体"。
  2. 无法前向引用

    • 如果身体先被推入渲染队列,它无法告知渲染器:"等一下还会来一个头部,那个要画在我前面";
    • 如果头部先进入渲染队列,也无法修改身体的渲染顺序;
    • 渲染器的排序是"事后统一进行"的,但缺少跨实体排序依赖关系的输入
  3. 不希望强制规定推送顺序

    • 虽然可以通过让调用方严格规定"先推身体再推头"来解决部分问题;
    • 但这会让系统变得脆弱、不通用,稍有疏漏就出错,属于不可靠设计。

可能的解决方向

  1. 引入"排序依赖边"机制

    • 可以在渲染系统内部构建一个图结构,表示实体之间的"绘制先后依赖关系";
    • 每个渲染实体可以显式指定"我必须绘制在另一个实体之后(或之前)";
    • 在最终排序阶段,基于这些依赖边执行拓扑排序,生成最终绘制顺序;
    • 这种方式不依赖实体被推入渲染队列的顺序,允许前向或后向引用
  2. 在 render group 中添加接口

    • 我们可以在 render group 中增加一个方法,比如:

      cpp 复制代码
      PushRenderOrderingDependency(entityA, entityB); // 表示 A 应该绘制在 B 之后
    • 每个实体在推送时(或脑系统中),可以检查自身是否有关联的依赖;

    • 如果有,就把该关系推送到渲染系统内部的依赖图中。

  3. 在脑系统中分析依赖

    • 比如在 Hero 的脑模块中,我们知道头部和身体是成对存在的;
    • 可以在这里收集渲染顺序关系;
    • 把这些逻辑从渲染系统中剥离出来,保持渲染系统本身的"纯粹性"。

对已有系统的影响

  • 渲染系统中的 Sprite 图构建阶段,需要支持读取并处理这些依赖边;
  • 排序逻辑要从纯 Z 值或层级排序,转变为有向图排序
  • 清理掉原来依赖 Z 值的"排序 hack"和伪造位置数据;
  • 所有这些操作应保持模块化,避免在一般情况下影响性能。

当前策略总结

  • 不建议强行规定推送顺序,太脆弱;

  • 不建议使用临时 Z 值偏移做排序,维护成本高;

  • 最佳方式是:

    1. 实体系统中收集排序依赖;
    2. 在 render group 中注册这些关系;
    3. 渲染系统在排序阶段执行拓扑排序处理。

这种机制不仅能解决当前"头部-身体"问题,也为未来扩展更复杂角色结构打下基础,比如装备叠加、坐骑、多人动作交错等场景。最终我们可以获得一个语义清晰、易于维护的 2D 渲染排序系统。

黑板讨论:手动指定排序边缘(Manual Edge Specification)

我们考虑采用手动边(Manual Edge)指定机制来解决某些实体之间的绘制顺序问题,比如"头部必须绘制在身体之前"这种情况。以下是该策略的完整思路与分析:


核心想法:构建手动排序边 Sideband

  • 在构造 Sprite 渲染图(Edge Graph)时,额外附加一份手动边的列表
  • 每条边表示一个明确的"绘制优先关系",例如:"Sprite A 应该在 Sprite B 前面绘制";
  • 这个"谁在谁前"是我们代码中明确知道的,并且可由我们主动指定;
  • 我们只需在前面那个 Sprite上指定即可,因为渲染系统处理的是"前在后之上"的逻辑。

边的定义方式

  • 使用的形式大致如下:

    复制代码
    is_in_front_of(SpriteA, SpriteB)
  • 表达的含义是:SpriteA 应该在 SpriteB 上方渲染

  • 因为边是从"前面那一位"发出的,我们只需要关注那个前面的对象;

  • 所以我们可以在"头部"那一边,记录说"我要在身体前面"。


标识目标对象(如何找到要依附的对象)

  • 遇到的难点是:我们不知道 SpriteB 是哪个对象,因为它可能稍后才出现;
  • 解决方案是:为需要关联的对象打上标记(Tag),并记录 Tag 编号;
  • 具体操作流程如下:
  1. 每个需要被引用的 Sprite(比如身体)被赋予一个 tag 编号,比如 tag = 3;
  2. 在头部 Sprite 被推入渲染队列时,附加一条边信息:我在 tag 3 的前面;
  3. 渲染排序阶段,遍历 Sprite 列表时将所有带 tag 的节点加入到对应 tag 的桶(bin)中;
  4. 随后根据手动边的描述,从前者找到对应 tag 后者,在图上添加边;

总结该机制的优势

特性 描述
灵活 可以处理前向或后向引用关系,无需控制实体推送顺序
精确 明确指定"谁在谁前",不依赖 Z 值或偏移等临时手段
通用 不局限于头部-身体关系,也可扩展到任意其他视觉层叠需求
兼容原有机制 不影响原有自动排序系统,仅在有特殊关系时才使用手动边

实现细节建议

  • 维护一个 manual_edges[] 列表,包含:{ front_tag, back_tag };
  • 每个 Sprite 允许指定一个 tag,并注册到 tag_bin[] 中;
  • 渲染排序图构建时,先将所有节点加入图;
  • 遍历 manual_edges[],根据 tag 在图中查找目标节点,添加方向边;
  • 执行拓扑排序,完成最终绘制顺序计算。

这个机制设计虽然多了一些小的结构和步骤,但逻辑清晰、结构稳定、可扩展性强,是目前看来最合理、最通用的处理方式。我们决定采用这个方案,并作为后续处理实体层级绘制关系的标准模式。

相关推荐
EndingCoder1 分钟前
React从基础入门到高级实战:React 核心技术 - React 与 TypeScript:构建类型安全的应用
前端·安全·react.js·typescript·前端框架
谢尔登1 分钟前
【React】jsx 从声明式语法变成命令式语法
前端·react.js·前端框架
kooboo china.1 小时前
Tailwind css实战,基于Kooboo构建AI对话框页面(二)
前端·css
数据与人工智能律师2 小时前
加密货币投资亏损后,能否以“欺诈”或“不当销售”索赔?
大数据·网络·算法·云计算·区块链
benben0442 小时前
Unity3D仿星露谷物语开发54之退出菜单及创建可执行文件
游戏·ui·unity·游戏引擎
努力学习的小廉2 小时前
我爱学算法之—— 二分查找(下)
算法
AdSet聚合广告2 小时前
APP广告变现,开发者如何判断对接的广告SDK安全合规?
大数据·后端·算法·安全·uni-app
不二狗2 小时前
每日算法 -【Swift 算法】实现回文数判断!
开发语言·算法·swift
NULL指向我3 小时前
STM32F407VET6学习笔记5:STM32CubeMX配置串口工程_HAL库
笔记·stm32·学习
秋天的落雨4 小时前
MFC中嵌入外部独立EXE程序
c++·mfc