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

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

黑板讲解:为什么使用SOA(结构体数组)而不是AOS(数组结构体)来构建实体系统

我们在构建游戏实体系统时,探讨了使用结构体数组(SOA, Struct of Arrays)而不是结构体组成的数组(AOS, Array of Structs)的原因。之前虽然我们画了图,做了类比,但其实遗漏了一个关键的核心解释,现在补充说明清楚。

首先,我们提到了 Looking Glass 的 “act/react” 系统,这是我们第一次接触到类似的实现方式。当时我们把它类比为从传统的 AOS(每个实体是一整个结构体)转变为 SOA(每种属性都在自己的数组中)。在之前的讲解中,我们指出了 SOA 的一个明显缺点:当我们需要访问一个实体的多个属性时,我们需要从不同的数据表(数组)中抓取这些信息,增加了复杂度和潜在的性能负担。

但我们当时没有说明为什么要这么做 —— 毕竟如果只有缺点,是没人会使用的。实际上,采用 SOA 的原因正是为了优化处理某些特定系统或属性的性能。就像我们通常在内存和缓存优化中使用结构体数组来提高数据局部性一样,在实体系统中也可以用相同的方式提升效率。

举个例子:假设我们为实体设计了一个 “可燃烧” 属性,里面包含了一个表示火焰强度的值。如果我们的火焰系统是一个传播系统,比如说火焰会向周围蔓延,并需要高效计算接触范围、强度衰减等操作,那么这个系统可能有自己的网格、缓存结构、甚至独立的模拟模型。为了加快模拟速度,我们可能希望火焰系统处理的数据是连续、纯净、结构紧凑的,这样便于并行处理、缓存命中以及数据访问速度的提升。

在这种情况下,将与火焰相关的所有数据独立出来、按 SOA 格式单独管理,会比将它们分散在每个实体结构里更高效。我们甚至可以为这些特殊属性创建自己的模拟系统,并只在需要与其他系统交互时(如实体需要查询当前火焰值)才进行查找。

因此,SOA 的主要优势体现在当某些属性或系统具有特定计算模式或性能需求时,它允许我们为这些部分单独建立高效的结构和流程,而不必受限于实体整体的组织形式。

值得一提的是,我们并不需要对所有实体或属性都使用 SOA。我们完全可以保留传统的 AOS 形式,用结构体表示普通实体,同时将像火焰、水流这种计算密集型、模拟特殊的属性单独以 SOA 方式管理,从而在系统设计上获得更多的灵活性和可优化空间。

简而言之,SOA 提供了一个架构上的选项,使我们可以在特定场景下以更优方式组织数据,优化关键路径的性能。这并不是必须的做法,而是一种在需要时非常有用的工具。希望这段解释能让之前的内容更加清晰完整。


游戏场景举例:火焰传播系统

假设我们正在做一款沙盒类游戏(类似 Minecraft 或 Dwarf Fortress),游戏中存在如下场景:

  • 玩家可以点燃木头或建筑。
  • 火焰会蔓延到周围的易燃物体上。
  • 火焰会持续燃烧一段时间,然后熄灭。
  • 火焰强度会变化,并影响蔓延速度。

使用 AOS 的方式(Array of Structs)

我们用结构体表示每个实体:

struct Entity {
    int id;
    float position[3];
    bool isBurnable;
    float fireStrength;
    float health;
    // ... 还有很多其它数据
};

Entity entities[10000];

在这种结构中,fireStrength 是所有实体中某些才有用的字段。我们要更新火焰系统时,需要遍历整个 entities[],每次读取 fireStrengthpositionisBurnable 等字段。这种方式有几个问题:

  • 每次访问 fireStrength 时,CPU 缓存会载入整个 Entity,包括无关的字段,造成 缓存浪费
  • 如果火焰系统只对 1000 个正在燃烧的实体感兴趣,我们也要遍历 10000 个实体。
  • 不利于并行处理,因为结构体中有各种不同的字段,可能会出现“伪共享”问题。

使用 SOA 的方式(Struct of Arrays)

我们将属性按模块组织:

int entityIds[10000];
float positions[10000][3];
bool isBurnable[10000];
float fireStrength[10000];
float health[10000];

或者专门为火焰系统建一个火焰池:

struct FireSimData {
    int entityId;
    float fireStrength;
    float heatDissipation;
};

FireSimData firePool[1000]; // 只保存当前有火焰的实体

火焰系统现在只需要遍历 firePool[],处理其中的 1000 个实体:

  • 每次访问都只读取有用的数据,极高的缓存效率
  • 可轻松并行化火焰传播逻辑,因为数据结构简单,字段一致,适合 SIMD 和多线程
  • 无需频繁判断“这个实体是否正在燃烧”,因为只有在燃烧时才进入 firePool[]
  • 可以独立于实体系统设计更复杂的火焰传播机制(如使用网格索引、分布式模拟等)。

总结对比

特性 AOS SOA
数据访问效率 低(带入大量无用字段) 高(只处理关心的字段)
缓存友好性
并行化/向量化能力 差(字段不连续) 强(字段紧凑对齐)
系统解耦能力 一体化,难以分离优化 易于对特定子系统独立优化
适用场景 小规模、快速迭代原型 大型复杂模拟,性能敏感逻辑

因此,如果我们遇到需要对某些属性进行大量计算、优化或者独立模拟的场景,选择 SOA 是一种更好的架构方案。但在其它属性变化不大、逻辑简单的场合,使用 AOS 也完全合理。最终的结构通常是混合两种方式:大多数实体是 AOS,部分系统(如物理、火焰、水流、AI)使用 SOA 来提升性能。

回顾并为今天的内容做铺垫

我们目前正处于实体系统重构的中途阶段,这意味着系统很多部分尚未完成,整个流程还不能正常运行。我们正在继续完成昨天未完成的内容。虽然昨天已经做了很多改动,但转换工作仍未彻底结束。

当前的状态是:如果现在运行游戏,系统将处于异常状态。因为创建的实体并没有被正确地打包并存储到世界数据结构中,而是直接被丢弃了。我们只是创建了实体,设置了相关参数,但这些实体最终只是存在于临时缓冲区中,根本没有被存入任何永久的存储结构中,也就是说,它们根本不会“存在”在世界中。

我们需要做的是:

  1. 将创建出的实体从临时缓冲区移动到世界的持久存储结构中。
  2. 完善 entity 的创建和存储过程。
  3. sim_region(模拟区域)的逻辑也要进行同步修改,确保它与实体创建逻辑一致,二者的代码流程要协调。

目前 BeginSimEndSim 两个流程还没有完成,我们的目标是让模拟区域以输入输出流的方式运行,也就是“从世界中拉取实体 → 进行模拟 → 将结果再写回世界”,而不是在原地修改或保留旧的世界数据。也就是说,我们打算将世界数据中的内容清空、拉取到模拟中进行处理,然后重新打包并放回世界。整个系统设计相当激进、非传统,但我们选择了这种方式来增强系统的清晰度和控制能力。

目前我们在代码中的 BeginLowEntity 只是创建了实体并设置参数,但它们还没有被转移到任何实际的世界数据结构中,这一点需要立即解决。

同样地,在 game_sim_region.cpp 中的 BeginSim 函数中,我们也尚未将从世界中提取出的实体进行正确存储。我们遍历了与模拟区域相关的区块,用 GetWorldChunk 获取这些区块,然后试图加载对应的实体,但在这一流程中我们还没有实现如何处理这些实体的数据,也没有实现从世界中“取出并清空”的步骤。

为了解决这个问题,我们计划直接着手实现完整的模拟流程,而不是采取保守的、逐步推进的方法。我们希望在 BeginSim 中完成以下目标:

  • 遍历所有相关区块。
  • 从中提取所有的实体数据。
  • 清空原始世界中对应的实体存储。
  • 将数据转移至模拟区域结构中进行处理。
  • 在模拟结束后,再把更新后的实体重新打包写回世界。

game_world 的数据结构中,我们已经对 world_chunk 结构体进行了部分调整,例如将 firstBlock 字段改成了指针,并考虑到模拟区域可能会频繁处理这些区块,我们将 ChunkHash 表中的区块存储方式从值类型改成了指针类型。

不过我们开始意识到:目前指针所在的位置可能并不是最合理的。我们有可能需要重新考虑这些结构中的指针应当放置的位置,以便更好地管理区块数据和实体数据的生命周期。

总结当前的问题:

  • 实体创建流程未完成,数据未写入世界。
  • 模拟区域的逻辑尚未补齐,缺乏“从世界读取并清空实体”的过程。
  • 区块指针的位置设计可能不理想,需要优化。
  • 整个系统的“拉出-模拟-写回”过程尚未串联完整。

我们的接下来的目标就是解决以上所有问题,让实体系统和模拟系统在新架构下能真正协同工作起来,实现真正的数据拉取、模拟和回写流程。

解释将 world_entity_blocks 从 ChunkHash 中剥离会带来的问题

在当前的设计中,计划从 ChunkHash 中提取出所有世界实体块,然后将它们完全移除,并在后续过程中再将它们放回。这种方式的核心思想是将实体块从哈希表中暂时“抽离”,直到需要时再重新放回。

具体来说,我们希望能够“拉出”实体块,而不仅仅是通过 get world chunk 获取它们,并继续在原地操作。理想情况下,我们希望能够将这些实体块从哈希表中完全移除,使得它们不再存在于哈希表中,而是在后续的操作中需要时再重新插入。这种做法有助于让实体块的生命周期更加明确,也能使得对哈希表的操作更加高效。

然而,在实现过程中,可能还需要一些额外的调整来确保这种“拉出-插入”的方式在整个系统中能够顺利执行。目前的想法是,直接从哈希表中移除这些世界块,并暂时让它们消失,直到后续的模拟过程结束时,再将它们重新加入到哈希表中。

在考虑这一操作时,虽然最初对这种方法有些疑虑,但经过进一步思考,认为这种方式似乎是可行的。因此,我们决定暂时按照这个方向来实现,至少试验一下这个设计,看看是否能够顺利地解决当前的设计需求和性能瓶颈。

这种方法的优势在于:

  1. 临时移除:实体块在暂时不需要时可以从哈希表中移除,避免占用额外的内存。
  2. 重用优化:当模拟过程完成后,我们可以将处理过的实体块重新插回哈希表,这样可以更高效地管理和使用这些数据。
  3. 明确生命周期:通过将数据显式地从哈希表中移除和重新插入,能够更好地控制实体块的生命周期,避免数据冗余。

尽管目前对这种设计方式的具体效果还有不确定性,但我们决定按此方案进行实验并观察其是否能够有效解决问题。如果实际效果不好,再根据需要进行调整。

黑板讲解:事务内存(Transactional Memory)

在设计中,考虑到多线程环境下的并发问题,有时会希望将世界区块(world chunk)保持在原地,直到稍后再处理。这种思路主要源自于“事务性内存”(Transactional Memory)的概念,虽然它通常是一个复杂的术语,但其实质是一个非常简单的思想,用来处理多线程时数据的一致性问题。

事务性内存的核心概念是在多线程操作时,通过标记内存块的读取和写入操作,避免多个线程同时修改相同的内存区域,从而避免数据冲突和不一致。当我们在处理多线程时,最常见的问题是不同线程同时访问和修改相同的数据。例如,线程 A 和线程 B 同时访问并修改同一个目标内存块。在这种情况下,如果没有适当的同步机制,就可能导致数据冲突和不一致,造成程序状态的损坏。

事务性内存的工作原理如下:当一个线程开始对某个内存块进行修改时,它会首先将修改缓存在本地,直到准备好提交修改。当提交修改时,系统会检查该内存块是否已经被其他线程修改。如果有其他线程已经对该内存块进行了修改,当前线程就会重新开始操作,而不是提交不一致的修改。

这种机制的关键在于如何判断是否可以安全地提交修改。每个内存块都有一个标记值,例如一个递增的整数,表示该内存块的修改状态。当线程 A 从内存中读取数据时,它会检查这个标记值。如果标记值没有改变,说明没有其他线程修改过该数据,线程 A 可以提交修改。如果标记值发生了变化,说明数据已经被其他线程修改,线程 A 就需要重新开始操作,以保证数据的一致性。

这种方式的优势在于,它允许多个线程在不被其他线程显式阻塞的情况下并行运行。每个线程都可以在自己的速度下进行计算,当它准备提交修改时,系统会进行检查,确保没有其他线程修改过相同的数据。如果有修改冲突,线程会重新启动,而不是提交冲突的数据,这样就避免了数据不一致的情况。

在具体实现中,事务性内存可以使得多个线程的操作更加独立,不需要显式的锁机制来防止数据竞争,从而提高并发性能。然而,设计时需要确保每个线程的操作都能正确标记并在提交时进行验证,才能确保系统的正确性和效率。

黑板讲解:将实体系统多线程化

在考虑系统设计时,我们想要支持多线程同时更新世界的不同部分,但问题在于如何避免线程间的更新冲突。理想情况下,我们希望多线程更新不同的区域不会相互重叠,但实际操作中实现起来可能会很复杂。一个潜在的解决方案是,尽管线程 A 和线程 B 分别只更新各自的区域,它们可能会读取其他线程正在更新的区域。为了避免数据冲突,我们可以不强行阻止这种情况,而是允许它发生。然后,当我们提交模拟区域的更新时,可以检查所读取的块是否在此过程中被其他线程修改过,如果发现有更新,则中止当前操作并重新开始模拟。

为了支持这种无锁的机制,必须保留块的数据。如果这些块的数据在更新过程中被清除或被其他线程修改,线程 A 就无法检查到数据的变化,从而无法确保数据的一致性。因此,为了能够验证这些块的状态并检测是否有其他线程的修改,必须将块的数据保留在原地。如果没有一个高层的机制来跟踪每个线程正在更新的区域,我们就无法有效地避免这些冲突。

然而,当前的系统已经采用了“流入流出”模式,即所有与实体相关的代码都是独立于模拟部分的,这带来了一个好处:模拟部分的更新逻辑与实体代码的更新是分开的。这使得代码更具可维护性和灵活性,可以在不影响其他部分的情况下进行修改。因此,即使我们目前不确定如何解决多线程更新的问题,也不需要过于担心,因为这些问题是局部的、隔离的。

至于多线程处理,我们可以先不急于解决这个问题。考虑到多线程更新实体可能需要一些新的设计,最好的做法是等到我们实际尝试实现多线程时,再根据实际情况调整策略。直到我们真正尝试多线程实体更新时,才能更清楚地了解合适的实现方式。因此,当前不需要过多关注这个问题,未来在实现多线程时可以逐步调整和完善解决方案。

game_sim_region.cpp:修改 BeginSim,移除 Chunk 和 Block 并返回到空闲列表

在设计当前的系统时,计划通过修改对世界区块(world chunk)的处理方式来改进内存管理。具体来说,每当我们加载一个世界区块时,我们会将其从当前使用的存储中移除,并将其添加到“空闲列表”(free list)。这样,已经不再使用的区块可以被重新利用。这种方法使得系统能够有效管理内存,避免不必要的重复分配。

在处理每个块(block)时,原本的代码是通过迭代来访问每个块并执行操作。为了将块放回空闲列表,我们需要对代码做一些调整。理想情况下,如果每个块只是一个指针,代码会更简洁,因为可以直接操作指针。但是,由于当前的设计需要处理更多复杂的数据结构,导致代码的编写变得有些麻烦。为了使代码更简洁,可能需要将某些结构改为指针,以便直接操作。

在具体的操作过程中,当我们迭代一个块时,首先需要将当前块添加到空闲列表,然后再访问下一个块。这个过程需要保证在操作完当前块后,不会丢失对下一个块的引用,因此需要进行一些判断,以确保不会覆盖下一个块的指针。为了避免这个问题,可以在添加当前块到空闲列表时,做一个快速的检查,确认当前块不是我们正在处理的起始块。如果是起始块,就不需要添加到空闲列表,因为它还在使用中。

为了支持这一点,系统需要扩展世界区块的概念,包含“第一个空闲区块”和“第一个空闲块”的指针。这意味着每个区块不仅要记录自己是否为空闲状态,还需要知道下一个空闲区块的位置。这些改动使得内存管理更加灵活,并为未来可能的扩展(如多线程支持)提供了更好的基础。

在这里插入图片描述

在这里插入图片描述

game_sim_region.cpp:修改 EndSim,将实体重新打包进新的 chunk

在当前的设计中,我们希望简化实体的移动过程,避免原有的“改变实体位置”概念。新的方案是直接将实体进行“打包”,即将实体放入适当的区块中,而不是手动更新实体的位置。这将通过一个新的函数 pack entity into world 实现,它负责确定实体应该存储在哪个区块,并将其打包到该区块中。这个过程可能会增加一些哈希查找的开销,但在未来可以通过优化来解决。

为了提高效率,可以考虑通过某种方式在模拟区域内缓存世界区块(world chunks),从而避免频繁的哈希查找。在处理实体的移动时,系统会检查实体是否越过了当前区块的边界。如果越界,则更新其所在的区块。这样可以快速处理所有区块中的实体,避免过多的哈希查询。

在实现过程中,原先的实体结构需要进行一些调整,去除冗余的字段。特别是“非空间实体”(non-spatial entities)这一概念可能不再需要,因为即使是非空间实体,也会与承载它们的实体一起移动,因此不再需要单独标记其为非空间类型。实体的“低级模拟”(low sim)状态标志也不再必要,这些标志将会被去除。

另外,实体的位置信息(如 world position)将直接存储在实体的结构体中,而不再依赖外部的“低级实体”结构。这意味着每个实体将包含一个指向其所在区块的指针,从而简化了代码逻辑。在这个过程中,还将去除“低级实体”的概念,并将这些信息整合到实体本身中,使得系统更加简洁和高效。

最终,这些改动会使得实体管理系统更加模块化和高效,同时也为未来可能的扩展(例如多线程支持)奠定了基础。所有的改动将逐步进行,并且在每一步都确保系统能够继续运行,直到最终完成一个完整的流式处理系统。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world.cpp:删除 ChangeEntityLocation 并清除编译错误

在当前的设计中,我们需要对实体的存储和位置管理进行简化和优化。首先,之前用于改变实体位置的功能将被完全移除,因为现在的目标是通过直接“打包”实体来完成位置的管理。这意味着,实体不再单独维护自己的位置标志,而是通过打包过程,自动确定实体应当存储的区块。

为了实现这一点,新的方法将直接使用“世界区块位置(world chunk position)”,而不再需要通过旧的“低级实体”结构来获取位置。这意味着实体的位置信息将直接存储在实体的结构体中,而不再依赖其他外部信息。

接下来,之前提到的 get sim space Pworld position 概念将被重构。通过将 world position 移至实体结构体中,位置管理变得更加直接和高效。这一改动还允许我们将“存储实体”的概念去除,因为实体的位置现在已经可以直接通过其所在的区块指针(chunk P)进行管理。

在移除冗余的存储信息后,系统将更加简洁,避免了不必要的中间存储。这一变动也为未来的性能优化提供了空间,特别是在模拟过程中的数据查找和实体管理上。为了进一步简化代码,原来存储位置的部分将被替换为直接传递位置信息的方式,确保实体在打包过程中能够准确地存放到指定的区块中。

通过这些修改,我们将逐步去除过时的代码结构,并确保位置管理更加高效、直接。同时,这也为后续的功能扩展(如多线程处理)提供了更为清晰的基础结构。

在讨论关于代码编辑和智能化处理的可能性时,考虑到是否可以通过某种编辑器自动完成某些操作,例如自动将参数从一个地方移动到另一个地方,这显得非常有趣且具有潜力。如果能够实现这种智能编辑,编辑器可能会智能地识别出需要进行的操作,并在用户提出请求时自动执行这些操作,这将极大提高开发效率和简化代码管理。

在这一讨论中,还提到了一些关于键盘使用的细节。一个特定的按键(数字键盘上的“0”)被设定为删除缓冲区的快捷键,但由于数字锁定键被开启,导致了一些误操作。这个问题被意识到并决定通过重启来解决。此外,还提到自己并不习惯使用数字键盘上的某些功能,特别是在日常使用的其他键盘上已经不再配备这些键。

最后,讨论的焦点回到了结构性变化的实现。尽管这个过程中有些杂乱无章,但整体上是通过放松规则、进行试验性的修改来推动进程,并确保系统功能可以逐步完善。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:将 low_entity 替换为 entity

我们决定彻底去除“双重概念”的结构。原本存在两个实体概念:一个是“low entity”,另一个是“entity”。但我们已经明确不再需要“low entity”这个概念,因此只保留“entity”。

接下来我们所做的就是:将原来所有使用“low entity”的地方,统一替换为“entity”。也就是说,我们将原来的“low entity”重命名为更通用的“entity”,以避免混淆和冗余。这个替换是全局性的,确保代码或逻辑中不再出现“low entity”这一命名。

过程中一度出现误操作,比如错误地将“entity”又替换成了别的内容,导致一些混乱。但经过检查和确认,最终确保所有替换操作都正确完成。每一个替换步骤都得到了确认,确保逻辑和命名保持一致性。最终结果是整个结构中只存在统一的“entity”概念,没有多余的命名或重复的定义。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_sim_region.h:将 sim_entity 替换为 entity

我们对系统中涉及“entity”的部分进行了全面简化与统一,将原本冗余的多个相关概念合并为一个更简洁的设计。

最初,我们在某个头文件中发现,很多地方仍然使用了“simEntity”这种旧命名。我们统一将其替换为“entity”,因为现在整个系统中只保留一个实体类型。这样一来,既保留了拥有多种“entity type”所带来的好处,又避免了管理多个实体概念所带来的复杂性。这种设计既高效又清晰,达到了预期的架构简化目标。

过程中我们识别到大量仍使用“simEntity”命名的地方,逐个进行了替换。同时,发现还有一些地方使用了“lowEntity”,这些也一并替换成了“entity”。此外,还有一个名为“storedSim”的结构或变量,由于其命名已经过时且含义重复,我们将其更名为“stored”,因为“sim”这个前缀已经没有存在必要。

对于指针相关的代码部分,例如“source”,我们注意到其本质上是指向某个实体的指针,因此调整了其使用方式,确保逻辑清晰。例如,我们通过解引用操作明确了其作为实体源的作用,进一步理顺了代码的语义。

在处理一些底层结构时,例如“world chunk”或“collision storage index”等,我们也做了必要的排查和保留,只对需要变动的地方进行修改。

此外,在处理一些逻辑结构如“addFlags”等函数调用时,也统一调整了参数中的命名或结构,使其与现在的“entity”系统保持一致。我们通过全局查找、替换和逻辑验证,确保所有相关调用和数据结构都已成功过渡到新的命名与结构体系。

最终,整个系统中关于“entity”的结构变得清晰统一,冗余命名被移除,相关逻辑更加直观,整体代码结构得到了大幅优化。我们正在接近这次重构的完成阶段。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world.cpp:移除 ChangeEntityLocationRaw,引入 PackEntityIntoWorld

我们完成了所有结构层面的修改,现在只需要实现还未完成的几个关键函数,主要集中在“world”的相关处理上。

首先,我们观察到原先与位置变更相关的一些逻辑(比如从 chunk 中拉出实体然后再放回去)现在已经变得多余,这些旧代码不再有用,也没有任何地方会再调用,因此决定将这部分冗余代码彻底移除。原有的这类“临时性”、“冗长”的逻辑已经不再适应当前的架构,直接删除能够有效提升清晰度和可维护性。

接下来开始着手实现核心功能:将实体打包(pack)进世界数据结构中。这个过程的第一步是实现 PackEntityIntoWorld 函数。

该函数接收三个参数:世界指针、实体数据和目标位置。我们利用现有的 GetWorldChunk 函数来获取目标位置对应的 chunk,如果该位置还没有对应的 chunk,则会自动创建一个。这是我们非常需要的行为。

打包过程的关键是判断目标 chunk 是否有足够的空间容纳新的实体。当前我们的打包逻辑还没有实现实际的“压缩”或“紧凑”存储操作,所以当前操作只是简单的内存复制,依据实体结构的大小来计算所需空间。我们对当前 chunk 的已有数据大小加上传入实体的数据大小进行比较,如果超出最大容量,就需要新建一个 chunk。

如果空间不足,我们从世界的空闲块列表(free list)中取出一个块来用。如果空闲列表为空,就分配一个新的块。这里优化了空闲块的处理逻辑:先检查空闲块是否为空,如果为空就创建一个新的,这样的代码比以往更清晰简洁。然后初始化该块(调用 ClearWorldEntityBlock),以清除旧数据,确保块处于干净状态。

接着执行实体数据的打包。计算打包地址:在当前 chunk 的数据末尾处。这就像一个内存池或 arena 的使用方式,我们将新数据“推进去”,并更新数据大小指针。拷贝操作完成后,实体就被成功打包进世界。

此外,为了保证健壮性,我们还建议在操作完成后加一个断言,用于验证实际操作没有超出空间限制,确认逻辑执行正确。

目前虽然没有进行真正意义上的“压缩打包”,但这个框架已经完全具备了之后实现压缩优化的能力。未来只需替换拷贝操作为压缩逻辑即可,不影响现有设计。

这套设计清晰明确,结构合理,不仅完成了打包操作的功能性实现,也为后续的优化预留了空间。我们已经非常接近整个系统更新的完成阶段。
在这里插入图片描述

game_world.cpp:引入 PackEntityIntoChunk

我们在打包实体(Pack Entity)流程上进行了进一步优化,将原本的“查找并打包”逻辑分离为两个独立阶段,从而提升灵活性和可扩展性。

原先 PackEntityIntoWorld 函数中,逻辑是:先通过位置查找目标 chunk,然后将实体打包进该 chunk。现在,我们将打包操作从查找中独立出来,封装为 PackEntityIntoChunk 函数。这么做的目的是为了允许未来在已知 chunk 的前提下,直接进行打包操作,避免频繁哈希查找,提高性能。

新的 PackEntityIntoChunk 函数接收三个参数:世界、实体(现在称为 source 以便更清晰地表达语义)以及目标 chunk。这样可以明确数据来源(unpacked entity)与目标位置(packed entity)之间的关系。

通过这一设计,PackEntityIntoWorld 函数变得更加简洁,它只负责获取 chunk,并将其传给 PackEntityIntoChunk。如果系统调用者已经拥有目标 chunk,就可以直接调用 PackEntityIntoChunk,跳过哈希表查找步骤,从而大幅减少不必要的开销。

然而在实现过程中,我们意识到先前的逻辑还遗漏了一层处理。原先误以为实体被直接打包到 chunk,其实实际的结构是 chunk 中包含一个或多个 block,实体真正被打包到的是 block 中。因此,在 PackEntityIntoChunk 函数中,我们需要先从 chunk 中获取 block,再在 block 中进行实体的打包操作。

这一点非常关键,因为每个 chunk 内部可能包含多个 block,当一个 block 装不下新的实体时,我们必须在该 chunk 下新建一个 block 并继续打包。这个机制确保了实体存储的连续性与灵活性。

总结来说,我们完成了以下工作:

  1. 将原先耦合的“查找+打包”流程解耦为“查找 chunk + 打包实体”两个阶段;
  2. 新增 PackEntityIntoChunk 函数,专责实体打包操作;
  3. 明确 source 与目标数据之间的角色关系;
  4. 修正打包层级,明确实体最终是打入 block 而不是 chunk;
  5. 为未来的性能优化(跳过哈希查找)和功能扩展奠定了良好基础。

这使得整个实体打包流程更加清晰、灵活,并具备更高的执行效率与结构适应性。我们已进入系统构建中的深度优化阶段。
在这里插入图片描述

game_world.h:让 world_chunk 中的 FirstBlock 成为指针

我们现在聚焦在实体打包过程中关于内存块(block)指针管理的关键优化问题上,尤其是关于如何组织 first_block 的结构形式以及如何动态推进打包进度。

当前我们发现了一个问题:如果我们希望持续地将实体打入内存块中,当一个块装满后,自动推进(push down)到下一个块继续打包,我们必须维护指向“当前活跃块”的指针。否则,每次操作都需要从 first_block 起步逐个遍历链表直至末尾,效率极低。因此,我们得出一个明确结论:first_block 应该是一个指针,而不是结构体本身。

这个指针的存在将允许我们在打包时直接访问最新的块,无需遍历整条链表,大大简化了操作复杂度。这种设计在数据结构上虽然牺牲了一些内存局部性,但在运行效率与逻辑清晰度之间取得了更好的平衡。由于我们迟早要解引用该指针,所以也不会带来太多额外负担。

在分析过程中我们也曾思考是否可以将这一逻辑与哈希结构结合,例如通过 hash bucket 中找到匹配项后拉取一组数据并操作。但这种方式一方面逻辑更加复杂,另一方面也不利于对内存结构的控制,缺乏直接性与透明性。综合考虑之后,认为不将其与哈希混合更为妥当。

随后,我们明确了打包操作的基本条件:如果当前 chunk 的 first_block 为空,或现有块空间不足以容纳新的实体数据,就需要创建一个新的块并链接到链表中。此时需要从世界级别的空闲块列表中取出一个块或新分配一个。然后通过将其挂载到 chunk 的 first_block 链表上,更新当前块的末尾指针,以便继续打包。

为进一步简化逻辑,我们还可以考虑是否将 block 局部变量彻底移除,改为直接操作 chunk->first_block,减少中间变量,提高可读性。这取决于代码风格偏好和后续操作是否需要频繁访问中间块。

总结当前阶段的关键要点如下:

  1. first_block 明确为指针,避免频繁遍历,提升操作效率。
  2. 增加“当前打包块”判断逻辑:空间不足则申请新块并链接。
  3. 不将块打包流程与哈希查找混合,避免逻辑复杂化。
  4. 可能简化变量使用,直接操作 chunk 的 first_block 成员,提升代码清晰度。

通过这一系列优化,我们成功梳理了“实体打包进块”的逻辑流程,使内存管理更加流畅、高效,同时保留了可维护性和扩展性。系统运行效率与架构清晰度进一步提升。
在这里插入图片描述

game_world.cpp:引入 HasRoomFor

我们对实体打包流程的结构和逻辑进行了进一步整理与完善,围绕如何判断内存块是否具备足够空间来容纳新的实体,进行了函数层级的抽象与逻辑封装,从而提高了可读性和可维护性。

核心改进内容如下:


封装空间检查逻辑为独立函数

我们编写了一个名为 HasRoomFor 的函数(返回 bool32),接受一个内存块指针和期望打包的大小作为参数,用于判断该块是否还有足够空间容纳一个新的实体:

bool32 HasRoomFor(WorldEntityBlock* block, uint32 size)

这个函数封装了之前手动写在多个地方的空间判断逻辑,让整个流程更加清晰、模块化。我们现在只需要调用 HasRoomFor(block, packSize) 就可以得知是否需要新建块。

该函数的好处还包括:

  • 可复用性增强
  • 代码语义更明确
  • 可以同样用于调试断言,比如直接用作 Assert(HasRoomFor(...)) 检查当前是否真有足够空间。

处理内存块分配逻辑

在打包过程中,如果:

  • 当前 chunk 没有任何块(first_block == nullptr),
  • 当前块空间不足(!HasRoomFor(first_block, packSize)

我们就执行新建块的流程:

  1. 从世界的空闲块链表中取出一个块,如果没有空闲块就通过内存池(arena)分配一个新的。
  2. 清空该块的内容(调用 ClearWorldEntityBlock())防止残留数据。
  3. 将其作为新的 first_block 链接到 chunk 上。
if (!chunk->first_block || !HasRoomFor(chunk->first_block, packSize)) {
    if (!world->first_free_block) {
        world->first_free_block = PushStruct(arena, WorldEntityBlock);
    }
    chunk->first_block = world->first_free_block;
    world->first_free_block = world->first_free_block->next;
    ClearWorldEntityBlock(chunk->first_block);
}

数据写入与空间推进

当确认有空间之后:

  1. 找出打包位置:即当前块的 EntityData + CurrentSize
  2. 将实体数据写入该位置
  3. 增加已使用空间标记(更新 EntityDataSize
Entity* dest = (Entity*)(block->EntityData + block->EntityDataSize);
*dest = *source;
block->EntityDataSize += packSize;

这一部分体现了 arena 式内存分配策略的优势:只需推进一个指针,无需复杂分配或释放。


更新 GetWorldChunk 初始化逻辑

由于我们在 WorldChunk 中新增了 first_block 成员,它在创建时需显式初始化为 nullptr。这是 GetWorldChunk 唯一需要改动的地方,以确保结构干净:

new_chunk->first_block = nullptr;

保留指针结构设计以增强灵活性

虽然 first_block 理论上可以是一个嵌套结构而非指针,但我们保留其为指针结构,出于以下考虑:

  • 后续打包可能需要推进多个块组成的链表
  • 避免复制块数据而是通过指针高效链接
  • 调试和动态分析更方便
  • 可视为轻量的链式 Arena 子分配机制

未来如果有性能瓶颈再考虑变更结构布局。


总结

当前我们通过封装判断逻辑(HasRoomFor)、简化内存块管理、理清打包数据写入流程,以及明确结构初始化要求,已经基本完成了 PackEntityIntoChunk 的实现,并使整个打包系统更为稳健、可扩展:

  • 结构清晰,每个步骤目的明确。
  • 扩展方便,可轻松替换内存策略或压缩逻辑。
  • 调试友好,断点和断言检查点集中。
  • 代码复用增强,抽象函数如 HasRoomFor 可在多个位置使用。

整体而言,实体数据在世界中打包过程的核心流程已经成型,并具备继续构建压缩逻辑、优化性能的良好基础。
在这里插入图片描述

在这里插入图片描述

game_world.cpp:引入 AddBlockToFreeList 和 AddChunkToFreeList

我们现在要实现的部分是释放内存块并将其重新加入空闲链表的功能,这一步是配合实体打包过程的对称操作,即实体数据使用完毕或无效后释放对应的内存块,以便后续重用,减少内存分配压力,提高性能和效率。


核心工作目标:实现两类释放操作

我们需要两个释放函数:

  1. 添加单个数据块到空闲链表(AddBlockToFreeList)
  2. 添加整个世界块(Chunk)到空闲链表(AddChunkToFreeList)

两者逻辑基本一致,都是将块头部插入到空闲链表表头,形成链式结构,用于下次分配时快速复用。


操作逻辑详解

添加数据块到空闲链表
inline void AddBlockToFreeList(World* world, WorldEntityBlock* block) {
    block->Next = world->FirstFreeBlock;
    world->FirstFreeBlock = block;
}
  • 将当前 blockNext 指向 world->FirstFreeBlock
  • 然后把 block 设为新的 FirstFreeBlock

这是一个标准的单向链表头插操作。非常简单,但需要严谨实现,否则容易破坏链表结构。


添加 Chunk 到空闲链表
inline void AddChunkToFreeList(World* world, WorldChunk* chunk) {
    chunk->Next = world->FirstFreeChunk;
    world->FirstFreeChunk = chunk;
}
  • 原理与添加数据块相同
  • 把 Chunk 插入世界的 FirstFreeChunk 链表

这两段代码虽然只有几行,但为了避免手动写错链表连接逻辑,通常会通过元编程模板抽象出通用的空闲链表操作函数,在不同类型上复用,提高安全性。


元编程动机解释

虽然只是两行代码,但手动实现链表总有出错的可能(如指针方向、空指针检查遗漏等),因此通常推荐抽象成宏或泛型函数:

  • 避免重复劳动
  • 降低人为错误风险
  • 提高维护性与一致性

对 RemoveWorldChunk 的改进

RemoveWorldChunk 函数中,现在我们可以更简洁地处理 Chunk 的释放逻辑了:

  • 从 chunk 中获取 first_block
  • 直接将这个 block 加入空闲块链表
  • 其余部分无需改动

这样处理可以避免原先那些复杂或不一致的释放逻辑,让整个回收机制统一、清晰。


总结

整个内存释放与回收机制的实现已经完善,形成了一个完整的内存管理闭环:

  • 实体数据使用时从空闲块链表中获取可用内存
  • 不足时通过 Arena 分配新的块
  • 无需使用后通过空闲链表回收资源
  • 避免频繁分配与释放,提高性能

结构简洁,效率高,易维护,并为后续更复杂的内存压缩、碎片整理、调试与追踪留下了良好空间。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world.cpp:引入 RemoveWorldChunk

我们现在着手处理的是 “移除 World Chunk” 的实现细节,这个过程虽然看起来类似于之前的 get_world_chunk,但由于目标是从现有结构中移除指定 Chunk,而不是查找或创建,因此仍有一些关键差异需要处理。


总体目标

我们要实现的是 remove_world_chunk 函数,它的功能是:

  • 从哈希表中移除一个特定的 Chunk
  • 回收其内存
  • 更新相应指针,确保链表结构正确

与 get_world_chunk 的相似与不同

我们可以参考 get_world_chunk 的遍历逻辑来查找目标 Chunk,因为两者都需要在哈希桶中遍历链表。但有一个重要差异:

移除操作需要追踪 前一个指针

这是为了能从链表中正确地“断开”当前 Chunk

  • 需要 prev_chunk->next = chunk->next
  • 如果没有 prev_chunk(即要删除的是桶的第一个元素),那就要更新桶头为 chunk->next

这就解释了为什么不能简单复用 get_world_chunk:它返回的是找到的 Chunk,但没有返回其前驱节点。


具体处理逻辑

我们将手动实现链表移除逻辑,伪代码如下:

uint32_t hash_index = HashChunkCoord(x, y, z) & (HashSize - 1);
WorldChunk* chunk = world->ChunkHash[hash_index];
WorldChunk* prev = nullptr;

while (chunk != nullptr) {
    if (chunk->Matches(x, y, z)) {
        // 找到要删除的 Chunk
        if (prev) {
            prev->Next = chunk->Next;
        } else {
            world->ChunkHash[hash_index] = chunk->Next;
        }

        // 回收所有块
        if (chunk->FirstBlock != nullptr) {
            AddBlockToFreeList(world, chunk->FirstBlock);
        }

        // 回收 chunk 本身
        AddChunkToFreeList(world, chunk);

        break;
    }

    prev = chunk;
    chunk = chunk->Next;
}

实现中的关键点

  1. 遍历哈希桶对应的链表
  2. 匹配目标 Chunk 的坐标
  3. 使用前驱指针正确断开链表
  4. 将 Chunk 持有的数据块加入空闲块链表
  5. 将 Chunk 本身加入空闲 Chunk 链表

这样可以保证结构清晰、资源完整释放,并为后续分配做好准备。


最终效果

我们完成 remove_world_chunk 后,就具备了完整的:

  • Chunk 查找与复用(get_world_chunk
  • Chunk 数据打包(pack_entity_into_chunk
  • Chunk 移除与回收(remove_world_chunk

为下次调试阶段做了充分准备,接下来只需集中精力让系统正常运转、完善边界处理和性能调优。
在这里插入图片描述

game_world.cpp:将 GetWorldChunk 拆分为 GetWorldChunkInternal,以提供多种入口点

这个之前做了

我们现在的目标是实现一个正确的哈希链表移除机制,并通过对现有逻辑的重构,解决无法访问链表中前一个指针(前驱节点)的问题,使我们可以在不损坏链表结构的情况下,精确移除一个 world chunk。以下是详细的总结:


问题背景

在链表中,要移除某个节点,通常需要知道它的“前一个节点”,因为我们必须让前一个节点的 next 指向当前节点的 next,才能完成“跳过当前节点”的操作。

而目前的 get_world_chunk 返回的是当前节点的指针(chunk),但在链表结构中,它并没有提供“指向这个 chunk 的那个指针”(前驱的 next 指针)。


解决方案:返回一个指针的指针

我们将现有的 get_world_chunk 拆分为两个函数:

1. get_world_chunk(外部接口)

这是对外提供的标准查找接口,用户调用的仍然是它。

2. get_world_chunk_internal(内部实现)

新建一个内部函数,负责实际查找逻辑,并返回一个指针的指针(即 WorldChunk**)**:

  • 这个指针的指针指向的是 chunk 在哈希表链表中的位置(可能是表头 HashTable[index],也可能是某个节点的 next 指针)
  • 通过它我们既可以获取目标 chunk,又可以修改它的上一节点的 next 值,实现插入/移除操作

查找过程逻辑(内部函数)

我们遍历哈希桶链表时,不再直接存储当前 chunk,而是用一个 WorldChunk** 来表示“当前 chunk 的地址”:

WorldChunk** chunk_ptr = &world->ChunkHash[hash_index];
while (*chunk_ptr != nullptr && !Matches(*chunk_ptr, x, y, z)) {
    chunk_ptr = &((*chunk_ptr)->Next);
}
  • 如果匹配成功,*chunk_ptr 就是目标 chunk
  • chunk_ptr 就是我们要的“前一个节点的 next 指针”

这就是我们可以用来直接修改链表结构的入口


插入和移除操作变得简单

插入 chunk:
*chunk_ptr = new_chunk;
new_chunk->Next = old_value;
移除 chunk:
WorldChunk* to_remove = *chunk_ptr;
*chunk_ptr = to_remove->Next;
回收 to_remove 和它的 block 链表;

这完全避免了维护“前一个节点”的烦恼,因为我们现在直接操作 前一个节点的 next


对返回值的修正

既然 get_world_chunk_internal 返回的是 WorldChunk**,那么:

  • 如果找不到对应 chunk,也要返回哈希桶链表中应插入该 chunk 的位置
  • 即使 *chunk_ptr == nullptr,也能直接在该位置插入新 chunk

所以现在无论是插入还是删除,我们都统一使用这套机制,逻辑更清晰,结构更合理。


最终效果

  • 实现了真正可控、灵活的 chunk 插入与删除机制
  • 无需维护繁琐的“前指针”,避免潜在的内存破坏和指针悬挂
  • 为调试和后续逻辑(比如 chunk 重用、缓存优化)打下坚实基础
  • 保持了接口的清洁,对外用户无感知

总结来说,我们通过使用“指针的指针”的技术,精妙地解决了哈希链表中难以访问前驱节点的问题,使 chunk 的插入和移除操作都变得简单、安全且高效。这一改动是底层系统设计中非常经典而强大的技巧。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏并让它崩溃以测试

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

现在我们完成了主要的结构改动,可以让程序运行起来,目的是验证新改动下的行为是否正确。而一运行,就很成功地触发了一个 bug,这其实是一件好事,因为这表示我们的防御机制有效地发现了系统中的一个潜在问题。以下是详细总结:


触发点

运行程序后,在 add_player 过程中,触发了一个错误。日志显示尝试同时创建两个实体(entity),这是当前系统不支持的行为。


具体错误信息

错误信息指向了:

creation_buffer_locked at add_player

也就是说,在添加玩家时,程序试图进行两个并发的 entity 创建操作,而我们目前使用的 creation_buffer锁定(locked)状态,不能同时进行两次写入操作。这直接引发了逻辑错误。


错误检测机制发挥了作用

这次运行并没有“静默失败”,而是明确抛出了错误,成功地抓住了这个不被支持的行为,说明之前加上的断言或防御逻辑工作正常。

这验证了:

  • 我们的系统能够检测并拒绝无效的 entity 创建请求
  • 对并发不安全路径的防御生效了

这是构建鲁棒系统中的一个重要进展。


后续修复方向(提及)

这类问题其实可以修复,比如:

  • 对 entity 创建操作做排队或延迟处理
  • 或实现一个简单的任务队列,把并发请求序列化

但当前我们暂时不会处理这个问题,只是确认机制有效。


总结

  • 系统在运行中触发了一个已知限制:不支持同时创建两个实体
  • 错误被成功捕获,说明改动后的系统稳定性在提升
  • 同时验证了断言与逻辑保护机制是有效的
  • 这类错误将来是可以解决的,比如通过请求队列等方式

整个过程体现了一个典型的系统开发思路:先构建清晰的数据结构和边界判断机制,再通过运行验证边界情况是否触发,进而改进逻辑。我们朝着更健壮的系统结构又迈进了一步。

game_world_mode.h:支持同时创建多个实体

我们现在进入了系统开发中的一个“收尾阶段”,主要是对之前实现的一些逻辑进行反思、优化和调整,确保整个系统处于一种可运行、易维护的状态。以下是详细的中文总结:


对实体批量创建机制的反思

我们在尝试创建多个实体(entity)时遇到了限制,即不支持同时创建多个实体。但实际上我们开始反思这种限制是否真的必要:

  • 想法一:其实完全可以支持一次性创建多个实体。
  • 想法二:我们原来用了一些固定的 creation buffer 来控制实体创建,但其实也可以直接用 transient memory(瞬时内存),例如将实体信息直接临时推入内存 arena 中去。

这说明:

  • 原先的做法有些过度设计不必要复杂
  • 使用 transient arena 会更加灵活高效,不需要锁定 creation buffer。

优化后的实体创建流程草图

根据上述反思,改进后的创建流程如下:

  1. begin_low_entity 时:

    • 进行断言检查,例如 buffer index 是否小于最大创建数等;
    • 记录操作在 world_mode.creation_buffer 中;
    • 使用 transient arena(瞬时内存区域)处理创建数据。
  2. end_entity 时:

    • 标记当前 buffer 项完成;
    • 验证是否正确创建(断言指向的是创建中的那个);
    • 结束实体创建过程。

这实现方式更加合理、灵活,避免了不必要的复杂状态管理,也不再受“只能创建一个实体”的限制。


强迫症式“留下干净代码”的开发心态

我们习惯性地想在每次开发结束前将状态清理干净、逻辑完整

  • 比如,虽然时间已经超出计划,但仍坚持修复、测试、调整逻辑;
  • 不喜欢在系统“半工作”的状态下中断,这是作为一个对系统整体性执着的程序员常有的心态;
  • 尤其在刚刚完成重构或大改动之后,更希望**“一鼓作气”**验证并整理好,避免留下隐患。

补充的健壮性处理

remove_world_chunk 中,我们还补了一些健壮性代码:

  • 在尝试移除 chunk 之前,先检查返回的 chunk 指针是否有效;
  • 如果没有任何 chunk 可移除,就不要进行“越过某个指针”这种操作,避免非法访问。

这是防御式编程的一种实践,确保即使输入非法或状态不完整时,系统也不会崩溃或写入错误数据。


当前状态总结

  • 我们已经完成了实体打包、chunk 分配、内存管理、free list 回收等关键结构的实现;
  • 系统可以成功运行并检测出问题,表明基础逻辑可靠;
  • 现阶段虽然还有一系列工作要做(比如处理英雄状态、相机、实际游戏逻辑等),但目前的基础已经打稳,可以比较安心地开始进行上层逻辑开发;
  • 当前状态可运行、可测试、可扩展,是一个非常合适的“暂停点”。

通过这次优化,我们不仅解决了具体的问题,还反思并简化了系统设计,为后续开发打下了更干净的基础。这是典型的“先架好梁柱,再搭建房屋”的系统工程思想。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

问答环节

黑板讲解:使用指针的指针来编辑 chunk

我们遇到了一个链表移除节点时的经典问题,并在这个过程中优化了哈希表中链式散列(chained hash)结构的处理方式。以下是该过程的详细中文总结:


系统结构说明

我们有一个包含哈希表的世界结构(world):

  • 这个哈希表本质上是一个指针数组;
  • 每个槽位(hash bucket)里存放的是一个指向 chunk 的指针;
  • 每个 chunk 内部又有一个指针,指向实体 block(这部分暂时无关,可忽略);
  • 多个 chunk 在同一个 hash bucket 中用链表方式串联(即 chunk 的 next 指针);

这意味着每个 hash 槽可能对应一个 chunk 链表。


原始问题

我们有一个函数用于执行哈希查找:

  • 在 hash bucket 中查找匹配的 chunk;
  • 找到时,原本是直接返回 chunk 的指针

这就带来了一个问题:

如果调用者想要从链表中移除这个 chunk,仅仅拥有 chunk 本身的指针是不够的,因为无法访问 前一个指针指向自己的位置,也就是无法修改链表中指向它的那个指针。


解决方案:返回“指向指针的指针”

为了解决“无法修改链表中指向当前 chunk 的那个指针”的问题,我们改为:

  • 在查找过程中,从 hash bucket 的开头开始;
  • 不直接处理 chunk 指针,而是处理指向 chunk 指针的 指针的指针(pointer to pointer)
  • 当遍历过程中找到匹配的 chunk 时,返回的是这个指针位置的地址,而不是 chunk 本身。

这样我们就可以做到:

  1. 访问 chunk 本体:通过 *result_ptr
  2. 修改指向它的指针:通过直接写入 *result_ptr = new_value,例如跳过当前 chunk,连接到下一个。

遍历逻辑示意

WorldChunk **chunkPtr = &world->hashTable[hashIndex];
while (*chunkPtr != NULL) {
    if (chunk matches) {
        return chunkPtr; // 返回指向 chunk 的指针
    }
    chunkPtr = &(*chunkPtr)->next;
}

这样设计后:

  • 无论是读取、替换还是删除某个 chunk,都可以通过操作 *chunkPtr 实现;
  • 对链表的增删改查都变得非常干净、直接,不再需要额外追踪“前驱指针”。

好处总结

  • 清晰:结构简洁,便于理解;
  • 灵活:可以直接进行链表节点的替换与删除;
  • 通用:这种“指针的指针”技术适用于所有链式结构,极大减少边界条件逻辑。

通过这次结构优化,我们解决了链式哈希表中无法高效删除元素的问题,并提升了整体链表管理的灵活性和可维护性。

Sparse Entity System 和之前提到的 mixin 是一样的吗?如果不是,我们会聊到 mixin 吗?我很好奇它们是什么

Mixin(混入)不是一种技术,而是一种能力或设计概念,核心思想是允许将一组特定的功能或行为组合进某个对象或类型中,而无需继承完整的类型体系。以下是我们对 Mixin 概念的详细理解与总结:


Mixin 的基本定义

  • Mixin 是一种可复用的功能组件;
  • 它本身并不构成完整的类型,而是设计为“混入”其他类型中;
  • 通常用于为多个不同类添加共同的功能,而不引入类层级的继承复杂度;
  • 可以理解为一组方法或属性的集合,这些集合可以被插入到目标对象或类中。

Mixin 与继承的区别

特性 继承 Mixin
结构性 构建类层级结构 非层级化,平行组合
重用方式 通过父类重用 通过组合、混入重用
灵活性 较低(继承链复杂) 较高(可选择性组合多个 mixin)
多重继承支持 语言限制(如 Java 不支持) 通常语言层面支持或通过语法模拟实现

Mixin 的常见用途

  1. 功能注入

    • 比如日志功能、序列化功能、权限管理功能,这些不属于核心业务,但可能在多个对象中都需要用到;
    • 我们可以将这些功能做成 mixin,按需组合到不同的结构或对象中。
  2. 状态模块化

    • 在 ECS(Entity-Component-System)或数据驱动架构中,经常需要为不同实体组合不同状态和行为;
    • Mixin 机制天然适用于此,可以按组件粒度拼接实体功能。
  3. 行为增强

    • 某些系统中可以通过 mixin 动态地为对象增加新行为;
    • 常见于脚本语言(如 Python、JavaScript)中动态原型修改或类扩展。

示例简要说明(伪代码风格)

// 假设是某种 C++/结构化语言
struct LoggerMixin {
    void log(const char* msg);
};

struct SerializableMixin {
    void save();
    void load();
};

struct Player : LoggerMixin, SerializableMixin {
    // 组合了日志与存储功能
};

或者更动态的语言中:

class LoggerMixin:
    def log(self, msg):
        print(msg)

class SerializableMixin:
    def save(self):
        ...

class Player(LoggerMixin, SerializableMixin):
    ...

使用 mixin 的好处

  • 避免复杂继承树;
  • 实现“横向扩展”而非“纵向继承”;
  • 提高代码复用性与可维护性;
  • 易于构建模块化、组合型系统架构。

总结

我们理解 Mixin 是一种用于提升代码组织灵活性和复用性的设计理念。它不是固定的语法结构,而是一种通过组合方式实现功能共享的机制,适用于多种编程语言和场景。在今后的系统设计中,尤其是组件化架构或功能扩展上,我们非常可能会使用 mixin 的思路,以构建更清晰、可组合的代码结构。

黑板讲解:Mixin

我们想实现的是:能够在运行时任意组合一组功能或行为模块。这是传统 C++ 无法直接做到的事情。


Mixins 与 C++ 的关系

  • C++ 本身不支持运行时 mixin。
  • C++ 的多重继承更像是编译时的 mixin:可以在编译阶段组合多个基类,实现代码复用;
  • 但这种组合在运行时是固定死的,不能动态改变或添加行为模块

C++ 无法做到的事情

  • 无法在运行时说“现在我希望这个对象拥有这个功能和另一个功能”;
  • 一旦类被定义,行为就是固定的;
  • 也没有语言层面机制在运行时“装配”多个类型特征或功能块。

如何解决:我们自定义的运行时 mixin 系统

为了实现运行时组合功能的能力,我们采用了自定义实现方式:

1. Sanity 系统
  • 我们建立了自己的“能力系统”,允许动态地组合与拆卸特性;
  • 类似“组件化实体系统”(ECS),一个对象可以在运行时添加/移除不同的功能模块。
2. 可选方案(提到但未采用)
  • 维护一个“属性列表”或“功能集合”;
  • 每个属性表示某个行为块;
  • 可以在运行时添加、移除这些属性,实现行为的动态组合;
  • 缺点是这种方式通常不具备静态类型支持,调试更困难,代码更松散。

friend class ≠ mixin

  • friend class 是用于打破封装性、让另一个类访问自己的私有成员;
  • 不会引入行为或功能组合,也不能在运行时改变任何行为;
  • 所以,friend class 并不是 mixin,只是访问控制的一种例外机制。

小结

  • 我们希望有一种“功能拼装”的能力,能在运行时将不同的功能模块组合成一个对象
  • C++ 编译期多重继承虽然类似 mixin,但无法满足运行时动态组合的需求;
  • 因此,我们设计了 Sanity 系统,实现了自己版本的“运行时 mixin”;
  • 这种系统允许我们按需组合、拆分对象功能,构建灵活的游戏/应用架构。

这正是我们为提升灵活性、可扩展性和代码可重用性所采取的关键设计策略。

friend class 算是 mixin 吗?

我们明确区分两种混入(mixin)方式:编译时混入运行时混入


我们真正需要的是运行时 mixin

  • 核心需求是:能够在程序运行过程中,将不同类型的功能动态组合在一起
  • 例如,某个游戏实体可以在运行时获得“可移动”“可渲染”“可攻击”等功能,而不是编译阶段就固定下来;
  • 任意组合、动态拼接类型的能力是必要的,尤其在灵活系统设计中,如游戏引擎、插件系统、工具型应用等。

C++ 提供的语言特性都无法满足这个需求

  • C++ 的类、结构体(classstruct)、friend 类、union都是编译时构造,无法在运行时改变;
  • 多重继承只能在编译时组合多个父类,运行时无法修改组合结构
  • 这些都不是我们想要的运行时 mixin,它们根本无法支持运行时的任意组合功能模块
  • 所以,所有这些 C++ 自带的特性都帮不上忙

我们只能用 C++ 提供的基本机制自己实现

虽然语言不支持,但我们依然可以在 C++ 中使用它的底层能力(例如指针、函数表、数据结构等)来自行构建一个运行时 mixin 系统

  • 使用组件系统或属性映射表来管理实体功能;
  • 使用虚函数表或手动函数指针切换行为;
  • 使用动态容器(如 std::vector 或自定义内存池)存储和管理功能模块;
  • 运行时动态分配与组合这些模块,从而实现我们想要的灵活性。

本质总结

  • C++ 的所有语言特性都是编译时静态结构,无法用于实现真正的运行时 mixin
  • 为了实现运行时类型组合,只能用语言基础构建自己的系统
  • 这是一种架构能力,而不是语言内置的语法或特性;
  • 因此,构建灵活系统的关键在于理解需求本质,然后用底层工具组合实现功能

这个理解是实现复杂系统(如游戏引擎、可扩展应用)时的基础能力之一。

如果使用 union,是否能避免实体压缩?

我们在实体系统中不会使用 union(联合体),原因如下:


使用 union 的问题

  • 一旦将两个不同的结构或属性用 union 组合在一起,就失去了同时拥有这两个属性的可能性

  • 因为 union 共享同一段内存,任意时刻只能存在一个有效的数据类型,意味着:

    只能激活其中一个,无法同时激活多个行为模块

  • 举个例子:

    • 如果我们将“敌人AI状态”和“投掷行为”放进一个 union 中;
    • 那么实体要么有 AI 状态,要么有投掷能力,不能同时拥有
    • 这对于游戏实体来说是非常受限的设计。

我们需要的是完全组合能力

  • 在游戏中,我们希望每个实体都可以拥有任意功能的组合,如:

    • 既是“可移动的”;
    • 又是“可渲染的”;
    • 同时可能具有“敌人AI”、“投掷物属性”、“可交互性”等;
  • 所以我们需要一种组合式结构,能支持任意多个功能模块的共存

  • 这就意味着:

    • 不能用 union,因为它会破坏“同时存在”这个能力;
    • 必须用如组件系统、属性映射表、动态模块拼接等运行时结构来支持。

极少情况会考虑使用 union

  • 唯一可能用 union 的情形是:完全确认两个功能绝不可能同时存在

  • 例如:

    • 某些互斥状态;
    • 特定模式下只激活一类功能;
  • 但这非常少见,且容易破坏系统的灵活性,所以我们几乎不会这样做


总结

我们要构建的是一个灵活、模块化、运行时可组合的实体系统,任何会限制组合能力的设计(如 union)都不适用。为了保证功能模块的任意组合与动态激活,我们必须避免使用 union,转而使用更灵活的运行时组件结构。

你怎么看其他数据导向的实体系统实现,比如 anax、EntityX 等?

我们提到了一些现成的面向对象实体系统实现,比如 anaxEntityX 等框架,并探讨了对它们的初步看法和态度:


对现有 ECS 框架的观察态度

  • 没有深入研究过这些实现,如 anax 或 EntityX;
  • 虽然可以搜索一下,但猜测这类系统的设计细节并不是能通过简单查阅文档或一两页代码就能完全理解的;
  • 这些库通常结构复杂、模板泛滥,代码行中分号密度高,常常一行里含有 8 个以上的分号,给人一种非常繁琐和形式主义的感觉;

对于复杂模板代码的态度

  • 不喜欢写很多模板和复杂语法的代码
  • 觉得这种风格带来了大量无谓的打字、冗余结构;
  • 即便系统设计得不错,也会因为代码阅读性和维护性的问题而选择避而远之
  • 简洁和直接的系统设计是更倾向的方向,而不是“语法技巧堆砌”的复杂模板系统;

总体总结

我们没有深入评估或使用这些面向对象的 ECS 库,但从代码风格和语法复杂度上已经不太倾向去采用这类系统。即便它们功能上可能是合理的,过度使用模板与复杂语法会影响可读性和维护性,不符合我们对代码简洁、可控、透明的追求。

所以,我们更倾向自己实现一个清晰明确、功能组合灵活、可运行时控制的 ECS 系统,而不是使用现成的、复杂的库。


网站公告

今日签到

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