游戏引擎学习第278天:将实体存储移入世界区块

发布于:2025-05-15 ⋅ 阅读:(8) ⋅ 点赞:(0)

总结并为今天的内容做好铺垫

今天的内容是关于开发一个完整的实体系统,目标是让这个系统更加实际和有效。之前讨论了如何通过一个模拟区域来处理无限大的世界。最初,使用浮动点数而不是双精度浮点数来避免潜在的精度问题,因为一些平台(如树莓派)不太支持双精度浮动点数。开发者想通过这种方式,保证在没有依赖硬件支持的情况下,能够保持精度,同时处理非常大的世界。

虽然最初的方案解决了问题,现在到了需要更加认真思考的阶段,可以把这个方法进一步改进,转向一个更适合游戏开发的实际方案。在早期的架构中,开发者尝试用一种比较简洁的方式来处理世界位置、区块和实体数据:每个世界位置包含一个区块,区块内有一些实体,实体的数据存储在世界实体块中。通过这种结构,模拟区域中的实体可以被处理。

然而,之前的架构有些问题。世界模式使用了一个实体表格来存储所有世界中存在的实体,这样的方式并不理想,甚至有些笨重和不太高效。希望做出更大胆的改变,尝试一种不依赖绝对实体概念的方法,也就是说,实体只能通过其ID来识别,而不再像以前那样通过实体直接访问。这样做虽然有些奇怪,但可能能更好地适应未来的需求。

总的来说,今天的目标是优化和重构当前的实体系统,使其更加符合大型游戏世界的需求,开发者将采取一些大胆的设计,并根据实际情况进行调整。

黑板演示:如何在遍历世界时,模拟区域流式加载实体

系统的设计目标是简化存储和查询实体的方式。最初的设计中,实体被分配到一个较大的区域内,然后通过查询该区域来获取所有相关的实体。该设计采用了一个包含低保真度实体的扁平数组,然后通过查询盒子区域,将区域内的实体提取出来,并将其移动到一个较小的区域中。虽然这些实体会被移动到更小的区域内,但这个区域内的实体应该相对较大。然而,实际操作中并没有完全实现这一点。

接下来,考虑到进一步简化存储结构,提出了一种新方案:完全移除低保真度实体数组,直接使用同一区域。这意味着不再需要维护额外的低保真度存储,而所有实体都应该直接存储在世界块(world chunk)中。通过这种方法,系统可以避免额外的存储结构,只在需要时将实体直接写入到世界块中。

这种方法的核心思路是,世界区域会被分成多个世界块,并且这些世界块可以重叠。在这种方式下,所有位于特定区域内的实体将直接存储到这些重叠的世界块中,而不再需要一个独立的低保真度存储表。简单来说,实体数据会被直接打包存储在世界块中,操作过程就是将实体数据写入世界块,关闭世界块后完成写入。

当需要读取这些数据时,系统将重新打开这些世界块,并读取存储在其中的实体信息。整体过程类似于流式处理,系统会根据需要实时加载、修改并再次写入这些实体数据。每次写入时,数据会按块进行流式写入和读取,不再依赖原先的索引存储方式。

为了优化效率,系统可以将每个世界块大小设定为固定值(例如64KB)。在读取时,系统会流式读取数据,直到读取完整个块。当读取完毕后,如果需要更多的数据,会继续从其他块中读取。这样就形成了一个循环的流式处理系统,数据通过不断的读取和写入被实时更新。

此外,虽然在常规情况下,实体数据可以在内存中处理,但如果系统的内存不足,理论上也可以将数据存储到磁盘中。当内存不够时,系统可以将未使用的数据块刷新到磁盘,并在需要时再将其加载回来。这种设计保证了系统可以在极端情况下仍然运行,但实际操作中几乎不需要用到这种方式。

在这个新方案中,最重要的步骤就是首先移除低保真度实体数组,看看是否可以通过直接操作世界块来实现更加高效的实体存储与查询。

黑板演示:实体之间的相互引用

在新的设计中,存在一个问题,即实体之间的引用关系。以当前系统中的一个实际例子为例,我们有英雄的头部和身体,它们可以被拆分开来,彼此之间需要互相引用。每个部分(头部和身体)都有指向对方的指针,这样它们就能够知道对方的位置并互相访问。

在新的方案中,这种引用关系将通过“同一区域”中的唯一标识符(ID)来替代。具体来说,当实体被流式写入到同一区域时,它们将分配一个唯一的ID,这个ID可以是任意生成的数字,并且这些ID会被存储在一个哈希表中。当其他实体需要访问该ID时,它们可以通过查找哈希表获取对应的实体。

这个过程需要注意的是,无法在单次遍历中完成所有操作。因为在实体数据解包之前,并不知道所有需要的ID。所以需要执行两次遍历:第一次是解包实体,第二次是修复指针(即调整ID的引用)。这种方法虽然有效,但也可能导致效率问题,尤其是需要两次遍历所有数据。

为了解决这个潜在的效率问题,有一个可能的优化方案:在第一次写入时,可以将所有的ID首先写入一个单独的区域,再将实体数据写入另一个区域。这样在解包时,所有的ID已经提前存储,可以直接查找。然而,这种方式的缺点是需要对写入过程进行两次遍历,这会增加额外的工作量。

目前决定暂时不采取这个优化方案,而是简单地分两次处理:首先进行实体解包,接着进行指针修复。这种方式虽然可能带来一些性能损失,但为了简化实现,暂时决定先这样进行。之后,如果发现性能问题,再考虑更智能的优化方法。

总之,当前方案的关键点是通过两次遍历来解决实体之间的引用问题,先解包实体,然后修复指针引用。这是一个权衡效率和实现复杂度的方案,后续可以根据实际效果进行调整。

game_world.h:向 world_entity_block 添加可读写的 EntityData 和 EntityDataSize

首先,考虑到整个代码架构的改动会非常大,因此计划将其分阶段进行,以确保不一次性打破系统的架构。虽然已经有很多经验,依旧不能假设每个步骤都能完全正确。任何系统性的改动都需要像实验一样逐步推进,并验证每个假设是否正确。因此,在做出任何重大更改之前,首先需要确保每个阶段的假设是有效的。

第一步:固定世界和实体块的大小

当前的目标是将世界和实体块的大小固定,以便更好地管理数据。具体来说,计划设置一个固定大小的数据块,例如设定为64KB(64千字节)。这个数据块将用于存储实体数据,并在需要时进行读写操作。64KB是一个合理的大小,用于流式读取和写入实体数据。

实体数据块的存储

这个64KB的数据块将用于存储所有相关的实体数据。在这个数据块内,会有一些地方专门用于存储实体数据本身。也就是说,数据块会被划分为多个区域,其中一部分用于存储实体的具体数据,另一部分可以作为预留空间,便于处理数据的流式输入输出。

关于实体数量的存储

在原先的设计中,可能会有一个记录实体数量(entity count)的字段,用于跟踪当前数据块中包含了多少个实体。然而,考虑到不再需要过多的细节来管理这个数量,决定去除这个实体计数器。取而代之的做法是,直接记录数据块的使用大小,即存储了多少有效的数据。

因此,新的设计中,数据块将不再包含实体数量字段,而是包含一个数据大小字段,表示这个数据块中实际使用的空间。例如,某个数据块可能只有30KB的实体数据被使用,而剩下的空间则为空闲状态。通过这种方式,可以灵活地管理数据的存储。

不确定的数据空间使用

尽管理论上每个数据块是64KB,但实际使用的空间可能并不满。即使如此,不需要事先预定每个块的空间使用比例,因为可以根据实际情况动态调整。对于系统来说,这并不会带来额外的复杂性。所有这些数据管理步骤都是为了确保实体数据能够高效地存储和流式处理,且不依赖于具体的实体数量或数据块结构。

未来的优化

目前的设计看起来较为简单,未来可以进一步优化。比如,可以根据实际使用情况决定如何最有效地划分和存储数据块。虽然目前的实现方案不要求严格规定每个数据块的使用情况,但如果发现有更好的存储结构,可以在后期进行调整。

总结

当前的核心改动是通过固定数据块大小(64KB)来简化实体数据的存储和管理,并通过记录数据块的实际使用大小,而不是实体数量,来提升灵活性。虽然这个方案暂时没有过多的细节约束,但它为后续的优化和调整奠定了基础。
image-335.png

调试器:编译并运行,触发 PushSize_ 中的断言

我们正在处理一个程序打包的问题。我们打算先编译程序,看看是否会发生错误或“爆炸”。目前并不太确定会不会出问题,因为不太记得相关内容的具体处理方式。估计是“按需”处理的方式,比如像“按需加载”的机制,因此理论上不会出现严重问题。但为了保险起见,还是要确认一遍。

在尝试创建“worlds”时,程序确实发生了崩溃。问题似乎出在内存分配上,特别是在创建“worlds”结构时触发了一些关于大小的错误。开始怀疑是不是由于内存不够造成的,特别是“chunk”这一部分。

我们查看了是哪个模块在分配内存,发现是在程序初始化时,某个“defaulter”结构中就进行了内存的分配。尤其是在结构体“world”里,可能有一些问题,比如内存分配方式、大小配置等。实际的问题出在“chunk cache”部分,它是一个静态数组,一共分配了4096个“chunk”,但并没有使用指针方式进行操作。

意识到这是个潜在问题,因为这种静态分配意味着无论是否真正使用这些内存空间,它都会被占用。这造成了内存的浪费,尤其是当前这部分“chunk”占据了我们大部分的内存资源。

我们开始反思这种静态分配是否还有存在的必要。虽然保留一定量的内存用于“chunk”缓存可以减少动态分配的开销,但如果有一半的缓存区域根本没有任何数据,那这就是在无谓地浪费内存空间。由此判断,这种设计可能已经不再适用了,至少在当前场景下显得不够合理。

接下来需要重新考虑如何更有效地处理这部分缓存,是否可以引入更灵活的内存管理机制来替代目前的静态分配方式,从而减少资源浪费并提升程序稳定性。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world.h:将 world 中的 ChunkHash 改为指针,并将 Chunks 作为单链表添加

我们决定对 hash 表的处理逻辑进行优化,当某个 hash 槽(hash slot)中没有任何数据时,应保持其为空状态,这样更合理。相较于之前的做法,我们认为只在需要时替换并插入新数据会更高效、更清晰。

这意味着我们将重构之前的循环逻辑,采用一种新的方式来遍历 chunk,虽然当初为了避免一些间接性访问的性能开销而采用了旧的方式,但现在的查找操作频率并不高,因此不必太担心性能影响。

我们在新逻辑中,将通过一个更清晰的循环方式遍历现有 chunk。接着我们检查相关结构,发现某个名为 leeches 的模块返回两个结果,其中一个结果和一个名为 chunk 的对象有关。在当前实现中,如果 arena(内存区域)不为空且找不到匹配的 chunk,那么就需要动态分配一个新的 chunk。

基于此,我们意识到原本用于初始化 chunk 的标记(如 chunk_initialized)已经不再需要,因此可以删除。之前的逻辑主要用于处理初始化阶段的一些特殊处理,现在显得多余了。清除这些冗余可以简化结构和逻辑。

之后,我们将实现逻辑调整为:遍历现有 chunk,如果 hash 表中找不到匹配项,就执行以下流程:

  1. 动态创建一个新的 world chunk。
  2. 将该 chunk 插入 hash 表中的对应位置,并将原本处于该槽位的指针设置为当前 chunk 的 next 指针,实现一个单向链表的插入。
  3. 初始化该 chunk 的必要数据,但避免对整个内存区域执行清零操作,因为这部分内存很大,清零会造成不必要的性能损耗。

为此,在内存分配接口中增加一个标志位,用来指示是否跳过清零操作,改为由我们自行处理初始化。这有助于提高效率,并且能更灵活地控制内存内容。

最后,我们进一步初始化一些关键字段,包括:

  • first_blockentity_count 设置为 0;
  • entity_data_size 设置为 0;
  • next 指针也设置为 0。

但我们也注意到这类初始化似乎并没有统一管理,缺少一个集中式的初始化函数。这种零散的初始化方式显得不够规范,因此未来可能需要补充一个统一的初始化机制以保证一致性和可维护性。
在这里插入图片描述

在这里插入图片描述

game_world.cpp:引入 ClearWorldEntityBlock

我们决定,为了更清晰地管理初始化过程,需要专门创建一个函数来初始化 world entity block。这个函数名为 clear_world_entity_block,这样就不需要在每次使用该结构时都手动记住哪些字段要初始化,哪些可以保持未初始化状态。

传统的将内存清零方式(memset 或相似方式)虽然方便,但不够灵活。如果清零,默认所有字段都会被设为 0,但现在我们希望只初始化特定字段,其余字段保持未定义状态,以提高效率并体现结构初始化的意图。因此,明确制定一个初始化函数是更清晰、结构化的做法。

我们在 clear_world_entity_block 中,仅初始化以下几个关键字段:

  • entity_count 设置为 0;
  • entity_data_size 设置为 0;
  • next 指针设置为 0;

其他字段保持未初始化状态,这是我们有意为之的设计,避免对整个内存进行不必要的清零操作,从而节省性能开销。

接下来,我们将原来某个地方对 world entity block 的初始化方式替换为调用 clear_world_entity_block,并确保所使用的内存块仅包含必要的初始化内容。

在进一步维护旧代码的过程中,我们注意到 tile chunk 的初始化方式也还在代码中。我们决定暂时保留 tile_chunk_initialize 相关的部分,虽然最终这部分也会被替代或移除,但为了避免在重构过程中出现连锁问题,暂时不动这部分逻辑。

与此同时,我们也发现一些命名或访问上的错误,例如之前尝试访问 world_chunk.next,但其实这个成员应该叫 next_in_hash。我们修正了这些错误,确保使用正确的结构字段名。

最后,我们在调用 create_world 时也同步检查并修复了相关接口对接和变量命名的问题,确保整个结构初始化流程连贯、明确且高效,避免混乱或隐式逻辑带来的后续维护困难。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world.cpp:整理 CreateWorld

我们不再需要初始化这部分内容,现在它默认就是零值,因此之前相关的所有逻辑都可以忽略,不再关心这些内容,也不需要做任何处理。

接下来关于 world arena 和 parent arena 的部分,我们对于“做一些 arena 操作”时的具体行为已经有些不太记得了,尤其是处理子 arena 的时候,它是否会被清理得足够干净这点并不确定。但我们目前的意图是不希望对它进行清理,因此也不需要对这一块进行额外的处理或者担心它的状态问题。当前的处理逻辑中,清理行为不在我们的考虑范围之内。
在这里插入图片描述

运行游戏并查看发生了什么

在这里插入图片描述

在这里插入图片描述

EntityCount 没有被初始化吗
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

怎么还是内存不足

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_sim_region.cpp 和 game_world_mode.cpp:移除 sword

我们现在已经把之前的问题处理好了,并且确认可以将一堆数据写入到这些内存块中。接下来的步骤是清理掉不再需要的内容,重点是将“剑(sword)”这一部分完全移除。

“剑”原本是作为一个独立存在于空域(null space)中的概念存在的,但现在已经没有必要继续保留它了。我们已经有其他方法来处理类似的机制或功能,将来可能会用别的方式重新引入或替代它。因此,现在阶段的目标就是完全去除“剑”的相关内容,因为它在当前开发阶段并不是必须的,保留它只会增加不必要的干扰和复杂度。

具体的操作包括:
我们在某些区域定义了“实体(entity)”以及与“剑”相关的概念,现在这些定义将会被清除。任何与“剑”有关的内容都会被移除,包括所有和它关联的代码逻辑、数据结构、初始化过程等。这一部分早期作为一个示例存在,现在已经没有实际作用,也不会影响当前的功能运行。

我们会全面搜索项目中所有与“sword”相关的代码并进行清理。某些与“sword”相关的碰撞逻辑、创建逻辑等也会被一起删除。同时,在排查过程中发现存在一个叫做“disorder”的概念,它虽然名字里包含“sword”,但实际上还在使用,这部分会暂时保留,只是后续可能需要改名以避免混淆。

最终的目标是将“剑”这一整块逻辑从代码库中彻底剔除,为后续开发留下更干净、更明确的代码结构,避免遗留的历史示例干扰当前系统的构建和设计方向。整个清理过程是有条理、彻底并且符合当前开发阶段目标的。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏并发现游戏仍然运行正常

目前系统依然运行正常,没有出现问题,整体状态看起来良好。

退出流程也如预期那样顺利执行,至少从表现上来看是一切正常的。我们新调整的结构或组件也已经成功运行,确实在对应的位置上出现,说明修改后的内容已经被正确加载并发挥作用。

总体上,运行过程平稳,没有报错或异常,退出机制也表现良好,新加入或重构的部分初步测试没有问题,整个系统状态符合当前的预期。
在这里插入图片描述

game_world_mode.h:从 game_world_mode 中移除 LowEntities

现在一切运行良好,状态稳定。

接下来的目标是实现之前提到的关于 低级实体(low entities) 的处理逻辑。目前这些低级实体是被统一存储在一个数组中的,我们现在要做的,是将它们从这个数组中移出,迁移到系统中的其他部分去。

第一步的计划是直接将这些低级实体逐个原样地拷贝到对应的内存块(chunks)中,这个过程虽然相对机械,但涉及到多个地方的修改,包括模拟区域(sim region)以及实体创建流程。需要确保在这些关键位置都正确地进行了调整,整个过程工作量较大,但这是必要的步骤。

为了开始迁移,我们暂时删除了低级实体数组中的原始存储结构,这么做暂时让整个系统陷入一种“彻底崩坏”的状态,但这正是我们预期中的过渡阶段。现在的状态虽然混乱,但正是我们想要达成重构的前提,接下来就是逐步清理、修复这些连锁反应。

在模拟区域(sim region)中,我们需要对低级实体的位置和状态进行重新映射。实际上,之前就已经在模拟过程中使用了一个哈希表(hash table)来根据 storage index 进行实体映射。现在回头来看,我们可以直接复用那套哈希逻辑,不需要重新实现映射机制。

检查代码后发现,哈希函数已经存在,并且在实际运行中已经被用于实体的位置映射。这意味着我们曾经做的某些准备工作现在派上了用场,不必重复开发,节省了不少时间和精力。

回过头来看,这部分工作可能最初是为了应对低级实体与模拟实体之间的差异,而当时考虑到以后可能需要合并或者替换掉低级实体结构,于是预先实现了哈希映射。现在看来这是一个非常有价值的预备工作,避免了当前阶段的很多复杂性。

总的来说,我们现在所做的,是将低级实体从集中存储转变为分块式结构,通过已存在的映射机制完成转换,虽然中间会导致暂时的结构混乱,但这是结构优化的必经过程,之前的准备工作也让整个迁移变得更加可控和高效。
在这里插入图片描述

在这里插入图片描述

game_sim_region.cpp:重写 LoadEntityReference,确保实体只能在模拟区域内访问

当前我们处理的是实体引用(reference)机制的改动。

原本的逻辑是在尝试获取某个实体引用时,如果在当前模拟区域中找不到对应的实体数据,就会回退到低级实体存储中查找,也就是从一个统一的全局存储区域拉取数据,相当于“顺着线把毛衣一点点解开”的过程。

但现在我们要改变这个逻辑。

新的规则是:只允许访问模拟区域中存在的实体,如果找不到,就直接设为 null,不再去低级存储中查找。也就是说,实体引用不再意味着“无论在哪都可以找到”,而是限定于当前模拟范围之内。这种变化让引用机制更加清晰、安全,也更契合区域性模拟的设计目标。

引用本身仍然会保留实体的唯一标识(ID),但其指针是否有效,完全取决于这个实体是否处于当前模拟区域中。只有在当前区域内,该引用才会解析为有效指针;如果超出范围,则无法获取内容,引用为空。

代码上,我们会对引用加载逻辑进行修改:
原先在找不到实体时会触发的“从低级实体拉取”的逻辑将被删除,取而代之的是直接查找哈希表中是否存在当前索引对应的实体指针。如果哈希查找失败或指针为空,则直接设为 null,不再有任何后备数据源。

这项变更的核心是移除跨区域引用自动拉取机制,改为严格限定在当前模拟区域内进行实体可见性判断,这样做更符合局部模拟系统的结构性原则,也能带来更高的性能和更少的意外副作用。整体逻辑更清晰,数据访问路径更直接,行为也更可控。
在这里插入图片描述

game_sim_region.cpp:让 BeginSim 存储实际实体,而不是索引

当前的重点是对低级实体(low entities)数据结构的重构与优化,目标是从原来的索引引用机制,转变为直接在数据块中存储实体本体,从而简化访问逻辑并提升效率。

首先,原本的机制是通过存储实体索引(entity index)来间接引用实体数据,现在则准备直接将完整的低级实体结构体存入数据块中。由于低级实体本身比较大(包含完整的模拟数据,未进行压缩或精简),每个块(chunk)能够容纳的实体数量受限,大致只能存放一百到两百个,甚至更少。

目前暂不考虑数据块的压缩或扩展,仅假定使用第一个块即可,不进行复杂内存管理。这为后续引入“回收列表”(recycle list)做了准备,将来可以更智能地复用和释放内存。读取逻辑保持不变,不受影响,修改主要集中在写入逻辑。

实体写入操作中不再使用原来的索引映射方式,而是直接将结构体地址当作实体指针操作。接着,通过直接访问数据块中实体数组的内存,并将其强制类型转换为 LowEntity* 来实现新的读取方式。

此外,原来用于区分是否为非空间性实体(non-spatial entity)的判断也被移除,当前阶段暂不处理这类特殊情况,留待将来完善。

AddEntityAddEntityRaw 函数进行了分析和调整:

  • AddEntity 仍然用来决定是否更新已有实体或添加新实体。
  • AddEntityRaw 更底层,负责实际在哈希表中放置实体,并设置其位置信息。

这些函数结构暂时保留,但最终会进行较大调整以配合新的实体管理模式。

在引用逻辑方面,原来通过 low_entity_index 获取实体的方式已经不再需要,改为直接从 LowEntity 结构中读取 storage_index,作为唯一标识符在系统中传递和使用。该索引将替代之前的实体索引系统,成为统一引用路径。

整个过程核心目标是:

  • 移除冗余的实体索引系统。
  • 将实体数据直接存入块中。
  • 使用 storage_index 作为主要索引方式。
  • 简化读写逻辑,统一访问路径。
  • 准备未来内存压缩、回收等高级功能。

目前尚未处理结构压缩和内存管理细节,但框架已经搭建清晰,后续可逐步演进并实现更高效的数据布局。
在这里插入图片描述

game_sim_region.cpp:暂时禁用一些 EndSim 的功能,只为查看哪些部分会出问题

当前正在处理将低级实体(low entities)真正重新打包(repack)进世界块(world blocks)的问题,涉及数据结构的彻底转换以及访问路径的更改。

目前出现一个编译错误提示:low_entities 不是 game_world 的成员。确实如此,因为在新的架构中,实体将直接被分布式地存储在各个世界块中,而不是集中存在一个全局结构中。因此,不能再试图通过 game_world.low_entities 的方式访问这些数据。

接下来要实现的,是将所有实体从旧的集中式存储中“打包”到新的分块式系统里。每个实体应该根据其坐标位置、状态等被放入对应的世界块中。这个过程不容省略,因为:

  • 一旦实体在模拟过程中发生移动,它们在内存中的位置会变化;
  • 如果跳过初始化数据结构的过程,后续运行中位置计算或内存访问就会失败;
  • 所有依赖实体位置分布的逻辑,如碰撞检测、更新调度等都会失效。

尽管有短暂的想法尝试“跳过”这部分工作(即运行不搬迁逻辑的代码,看会出什么问题),但最终还是决定要老老实实地实现这部分内容,以保证系统逻辑正确性。

目前的重点是在每一个世界块中,根据实际实体的位置,正确地重新组织实体数据。在结构变更初期,这一过程可能看起来复杂,但从长远看,会带来以下好处:

  • 更高效的区域模拟;
  • 更清晰的内存结构;
  • 更少的数据复制与冗余;
  • 更便于后续的功能拓展(如异步加载、并行计算等)。

虽然现阶段还只是初步实现,还未进行性能优化或压缩处理,但已经可以看到这种结构设计的优势与潜力,对未来的发展具有积极作用。整体逻辑逐步趋于清晰和合理,代码结构也正向着可维护、高性能的方向迈进。
在这里插入图片描述

game_world_mode.cpp:引入 BeginLowEntity 和 EndLowEntity,将 AddLowEntity 拆分成这两个函数,以便创建低级实体并将其打包到实际位置

我们正在重构实体的创建和插入逻辑,目的是将实体真正插入到它们所对应的物理位置(即世界块中),而不是像之前那样通过一套间接系统或中心索引方式处理。

为此,我们引入了一种新的实体创建流程,取代了旧的 AddLowEntity 调用模式。我们将创建过程分为两个阶段:

  1. BeginLowEntityCreation:返回一个可写的低级实体结构体指针(low entity pointer),用于填充必要的实体信息。
  2. EndLowEntityCreation:在创建完成后,将实体打包并插入到正确的世界块位置中。

这个结构的改变,意图更清晰地管理实体生命周期,确保数据写入完成之后才能插入世界块,并避免未初始化或未完整构造的数据被使用。

为了在当前原型阶段避免多线程冲突,我们加入了一个creation buffer lock的调试机制。此机制不会做实际同步控制,仅在调试阶段通过断言防止并发写入 creation buffer。当有多个线程未来可能并发添加实体时,这一机制可以及时暴露出线程安全隐患。

在新流程中,我们不再使用以往的 low_entity_index 等索引方式,而是直接从 low_entity 中提取一个唯一的 storage index(存储索引)。为此我们引入了一个全局递增的 last_used_entity_storage_index,用于为每个新创建的实体分配唯一 ID,并从 1 开始计数(保留 0 作为无效引用的标记)。

我们也意识到:当前使用 uint32_t 表示 storage index,当实体频繁创建销毁时,长期运行系统最终会耗尽 ID 并回绕(wraparound),导致潜在的问题。为此我们计划后续引入:

  • free list(空闲索引列表):用于回收已删除实体的 ID;
  • 或将 ID 扩展为 64 位,以彻底避免溢出问题。

在实际创建实体时,我们通过 begin 调用获得实体指针,设置其世界位置、方向、类型等属性,然后通过 end 调用将其打包。这使得原本冗长和耦合的构造逻辑变得更明确,并简化了错误检查与调试工作。

此外,在这一结构下,我们也准备将目前的 similarity 概念彻底移除,取而代之的只有实体(entity),也就是说:

  • 不再需要通过 similarity 做双层索引;
  • 没有被打包的结构也不能被访问;
  • 所有操作都围绕 “一个实体” 展开,减少了系统复杂性。

这个方向将统一内存模型,并消除大量条件分支、特殊判断和类型转换问题,让我们可以专注于实体本身的行为逻辑。

总结下来,我们:

  • 重构了实体创建流程,引入明确的 begin/end 模式;
  • 引入唯一存储索引,替代模糊索引方式;
  • 引入 creation buffer lock 用于调试期间防止并发写;
  • 放弃 similarity、low_entity_index 等多余层次;
  • 准备扩展 ID 管理,考虑使用 free list 或 64 位 ID;
  • 准备以更简化的方式进行实体压缩与打包;
  • 逐步转向统一而高效的实体管理模型。

整个重构工作正朝着更清晰、更安全、更可扩展的方向前进。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:引入 PackEntityIntoChunk,作为 EndLowEntity 中的调用

我们现在继续完善实体系统,进入了打包阶段。我们实现了一个新函数 PackEntityIntoChunk,用于将刚刚创建完的实体压缩打包到它所在的 原始世界块(Raw Chunk) 中。这个函数的职责是读取实体当前的位置 P,然后将其压缩后存储到该位置对应的世界块中,完成实体正式入驻世界的过程。

由于每个实体都包含了自己的世界坐标位置 P,我们不再需要额外传入位置参数,也不再需要传入任何类似实体索引的信息。我们已经不再维护 entity index 概念,所有实体唯一身份由 storage index 管理,而这个索引只在哈希查找中起作用,对打包流程并不重要。

新的 PackEntityIntoChunk 函数只需要接收一个 low entity,负责将其压缩并插入世界块的正确位置。当前我们先留下这个函数接口,准备接下来实现实际打包逻辑。

同时,我们决定彻底取消“删除实体”的机制。在这个架构中,我们不再像以往那样需要明确删除一个实体,而是通过 不再将其流出(stream out) 来实现实体的“消失”。

具体逻辑如下:

  • 实体通过流入(stream in)进入模拟区域;
  • 每一帧都会对模拟区域进行重新构建;
  • 实体若未被标记为“存活”,则不会被流出(stream out),也就不再存在于模拟区域中;
  • 不再需要显式从内存或索引结构中删除实体;
  • 只需要简单地设置一个标记,指明某个实体“已死亡”或“已移除”,便完成整个删除操作。

这样我们实现了一个更高效、数据流动更加清晰的设计:

  • 避免了复杂的删除逻辑、引用管理、内存清理等;
  • 不需要维护实体生命周期的所有细节,简化了内存管理;
  • 实体只在被需要的时刻存在,符合数据驱动设计思想;
  • 世界块成为了实体的容器,实体是可流动的、可打包的数据单元。

我们还将在后续实现中继续完善这个“打包-流入-流出”流程,使其更加适应动态世界和高性能模拟的需求。这一设计为实现大型开放世界或高密度模拟提供了良好的扩展基础。
在这里插入图片描述

game_sim_region.cpp 和 game_world_mode.cpp:处理大量编译错误,以使用 entity_id

我们目前正集中在将实体系统进一步重构成更整洁、高效的数据驱动架构。主要目标是:


1. 实现 PackEntityIntoChunk 函数

我们为实体打包进入世界块(chunk)实现了框架:

  • 函数 PackEntityIntoChunk 被定义出来,但暂时还没有具体实现。
  • 这个函数的职责是将一个已完成构建的实体压缩后放入其所在位置对应的世界块中。
  • 由于实体自身携带其世界坐标 P,因此不再需要传递位置参数或实体索引。
  • 不再需要使用 entity index,唯一标识由 storage index 或更明确的 entity ID 负责。

同时,为未完成实现的函数添加了一个断言宏(例如 NOT_IMPLEMENTED),确保程序在运行到未实现代码时能明确报错。


2. 重构 BeginGroundedEntity

  • 实体添加流程重构为“开始-结束”的操作模型,明确实体构建的生命周期。
  • 原来的 AddGroundedEntity 被替换成 BeginGroundedEntity,更符合当前逐步组装的实体构建方式。
  • 实体组返回时现在仅提供指针,无需返回其他标识,但保留未来扩展返回 EntityID 的可能。

3. 去除删除流程,改为标记方式

  • 不再执行实体“删除”,改用“停止流出”代替:

    • 实体只在需要的时候流入;
    • 若一个实体“死亡”或“移除”,不再将其流出即可;
    • 只需为实体添加一个标志位,指示其是否有效,无需显式内存释放或索引移除;
    • 删除接口暂时保留为未实现并用断言占位,例如 DeleteLowEntity

4. 引入 EntityID 替代 StorageIndex

为了明确身份管理与引用安全:

  • 引入 EntityID 类型,封装原始 StorageIndex
  • 所有需要引用实体身份的位置均统一使用 EntityID
  • 通过类型系统约束行为,防止非法操作;
  • EntityID 是不可修改的标识符,只能在创建实体时生成。

此改动让整个实体管理系统的类型更明确,语义更清晰。


5. 全面替换旧式索引系统

  • 所有用到 StorageIndexEntryIndex 等索引的逻辑替换为 EntityID
  • 包括哈希表中的索引访问、实体引用、控制角色跟踪(例如 ControlledHero)等;
  • 清除原来为预分配槽位所设置的 ReserveSlot 类逻辑。

6. 调整返回流程与存储结构

  • 所有构造函数 BeginEntity 结束时不再返回裸指针,而是通过 EntityResult 类型返回;
  • AddPlayer 等场景中,需要保存并返回实体的 EntityID
  • 多个系统如摄像机跟踪、角色控制等,现在统一使用 EntityID

7. 暂未解决的部分

目前仍有少量部分尚未完成,需要后续处理:

  • 实体删除逻辑未完成(仅标记了断言);
  • Debug 系统中的唯一 ID 未实现生成机制;
  • 某些碰撞系统的索引引用需要升级为 EntityID
  • 存在少量指针-索引混用需要统一,例如 debug 列表、Entity 引用的清理等;
  • 未统一清理原有的 LowEntities 数据结构。

8. 编译通过,准备收尾整理

虽然部分功能未完整实现,但目前整个系统已经可以编译通过:

  • 所有实体构建、管理流程已经迁移到新架构;
  • 运行时断言清楚标示尚未实现的部分;
  • Debug、碰撞系统等可以在后续继续补完。

总结

我们完成了实体系统向更现代、类型安全的数据流动架构的初步转型,主要包括实体标识统一、构建流程明确、去除删除操作和打包入世界块的准备。虽然还有部分未完成的系统,但基础框架已经搭建完毕,为后续系统整合和优化奠定了良好基础。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

bein end 没匹配吗

在这里插入图片描述

问答环节

我们也考虑是否可以临时处理一些简单的事情,但结论是暂时搁置所有复杂或需要深入的部分,推迟到明天再继续完成当前正在进行的系统重构和整理工作。

这次暂停是为了避免在剩下时间里仓促推进重要部分,保持系统的整洁性和一致性。同时,也为明天完成剩余的打包逻辑和系统清理留出完整的时间与精力。

这是一个关于元问题的问题,但我发现自己总是在代码中放 TODO,通常是在我处理其他事情时不想处理的地方。然而,像我们所有人一样,我是怎么回到这些 TODO 上的?

我们在开发过程中常常会遇到这样的情况:在处理某项重要工作时,不经意地在代码中留下了一些不打算立即处理的“TODO”注释,实际上这是一种典型的“拖延式编程”行为。当我们暂时不想面对某些复杂的问题时,就会在代码中简单地做个标记,留待以后处理。

为了不让这些“TODO”残留物影响到最终的项目交付,我们会在真正进入“冲刺模式”(也就是准备发布版本的状态)之前进行一次彻底的清理。这一过程通常包括以下几个步骤:

  1. 打印出所有的 TODO 清单:通过工具或脚本列出代码中所有包含“TODO”的位置与内容,形成一个全面的概览。

  2. 逐一审查:我们会逐条检查这些标记,评估它们的实际意义和必要性。

  3. 分类处理

    • 必须完成的 TODO:这些是当前版本发布之前必须解决的问题,属于功能完整性或稳定性方面的关键任务。
    • 可以推迟的 TODO:这类问题虽然有价值,但不影响当前版本交付。我们将它们归类为未来版本的候选任务,记录在产品或技术债务清单中。
    • 已经无意义的 TODO:由于架构变动或功能调整,一些标记已经不再适用,或者它们描述的问题已经不存在。此类标记会被直接删除,避免混淆。
  4. 生成“交付清单”:经过清理和分类后,我们会整理出一个明确的交付前任务列表,确保在发布前所有必须事项都能得到处理。这有助于聚焦重要内容,减少不必要的干扰。

通过这种方式,我们可以有效管理开发过程中的遗留问题,保证项目的条理性与可维护性,并确保交付版本的质量和稳定性。

你编译之后遇到过的错误数量最多是多少?

我们在使用编译器进行编译时,曾遇到过错误数量非常多的情况。默认情况下,大多数编译器会对显示出来的错误数量设置上限,例如上限通常是100个错误。这意味着即使代码中存在超过100个错误,编译器也只会显示前100个,剩下的会被隐藏。

以 MSVC(Microsoft Visual C++)为例,它默认就是在输出100条错误信息后就不再继续显示新的错误了。这个行为是为了避免编译输出过于庞大,导致开发者难以阅读,也为了提升编译器的效率,减少输出负担。

我们在实际开发中,如果没有关闭这个上限,就经常会遇到“只显示100个错误”的情况。特别是在进行大规模重构、宏系统错误传播、模板编程出错或是类型系统崩塌时,一次性触发几十上百个错误是很常见的事。通常此时我们会:

  1. 只关注第一个错误:因为后续很多错误往往是“连带效应”,由前面某个根本性问题引起的,先解决根本问题再重新编译会消除大部分连锁错误。

  2. 修改编译器设置:如果需要看到所有错误,会选择在编译器选项中关闭或提升这个错误输出的上限。比如在 MSVC 中可以用 /errorReport:prompt 或其他参数来调整行为。

  3. 借助 IDE 工具:现代 IDE(如 Visual Studio)可以在后台记录所有错误,即使命令行中只显示了100个,也能在错误列表中查看更多细节。

总之,编译时错误数突破上限是常见现象,而合理利用编译器设置和工具,可以帮助我们更有效地处理这些问题。


网站公告

今日签到

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