当前位置: 首页 > news >正文

游戏引擎学习第286天:开始解耦实体行为

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

我们目前正在进入实体系统的一个新阶段,之前我们已经让实体的移动系统变得更加灵活,现在我们想把这个思路继续延伸到实体系统的更深层次。今天的重点,是重新审视我们处理实体类型(entity type)的方式,并开始思考这一机制将如何影响后续开发。

目前,我们已经实现了动态生成多个小人(entity)的能力,这些小人拥有与主角相同的运动控制逻辑。通过这个例子,可以看到引擎和游戏开发是一个非常非线性的过程。我们之前可能花了两到三整天的时间,只为了决定如何放置点位,以及实体如何占据空间。而实体存储和世界存储的设计更是占据了五到六整天的编码时间。将这些时间换算成常规工作周,目前为止也就六到七周的完整编码时间,可见前期这些系统性构建工作的比重非常大。

但当这些系统逐渐成型之后,事情就会开始加速发展。一旦这些基础设施搭建完毕,新的功能就可以以更快的速度搭建起来。比如现在,我们可以轻松生成多个可控制的实体,并将它们投入世界中。这正是实体系统成熟的一个表现。接下来的约一百天的编码时间里,预计我们将看到更多“指数级”的开发进展,也就是说,投入的工作量和产出的效果不再是线性关系,而是会获得更大的回报。

当然,我们还没完全解决所有问题。比如现在这些小人会重叠在一起,这是因为当前的碰撞系统还沿用了旧的逻辑,无法正确处理现在这种实体分布和交互方式。另外,我们在 Z 轴处理上也还有一些待解决的问题。也就是说,尽管我们已经在正确的轨道上了,但仍需进一步完善这些关键系统。

接下来,我们希望开始将实体的行为逻辑逐步从“类型驱动”方式中解耦。目前,我们的做法是根据实体类型进行大量的特殊处理,例如绘制主角,是通过创建一个 HeroHead 类型的实体和一个 HeroBody 类型的实体,然后在多个逻辑点中使用这两个类型来控制绘制、运动等行为。

这种做法并不错误,很多游戏确实采用类似方式来处理实体。如果游戏中每种实体都非常独特、没有太多相似之处,这样的类型驱动设计完全没问题,而且你依然可以通过函数复用来共享一些通用逻辑。

但我们构建的游戏并不是那种“每个实体都很独特”的类型,而是更希望整个实体系统具有连续性。也就是说,我们不仅希望存在“主角”和“Boss”这样两个离散的实体类型,我们更希望能存在一个中间状态:比如介于主角与Boss之间的某种实体。这要求我们的系统能支持高度可组合、可扩展的设计,以便形成丰富的组合、变化和行为模式。

我们希望从设计上模仿 Roguelike 游戏的传统,而非像《塞尔达传说》那样的类型结构。我们追求的是模块化构建:将实体拆分成若干独立的功能模块,然后通过组合这些模块来生成各种实体类型。甚至在运行时,我们可以通过添加或移除模块来动态改变实体的行为能力。

因此,现在就是一个不错的时机,我们可以开始尝试构建这样一个更通用、更加动态的实体系统,朝着组合式、组件化的方向前进。这将为后续的游戏机制扩展奠定良好的基础。

game_entity.h 中思考如何从实体结构体中去除 Type 字段

我们开始着手思考是否可以逐步消除实体结构中的 type 字段的实际用途,并设想如果要彻底摆脱 type 字段,我们该如何做。

举个例子:假设我们希望在游戏中加入一种敌人,这种敌人从外观和运动方式上与主角完全相同,就像是主角的邪恶镜像版(可能是幽灵或者其它黑暗形态)。它不会响应玩家的控制输入,而是使用某种 AI 或其他非玩家输入方式进行移动与行为控制。我们希望它能共享主角的所有逻辑代码,仅仅是输入方式不同。

另一个例子可能是“只有头部”的漂浮敌人,它使用的仍是主角头部的代码逻辑,只是没有身体。再比如“只有身体”的实体,它可以像青蛙一样蹦跳移动,但没有头部控制方向。尽管我们现在主角的逻辑代码非常少(可能只有几十到上百行),但仅凭这点代码,我们理论上就可以组合出很多种不同的敌人类型,只要改变控制逻辑(AI、玩家输入等),这些组合方式就能产生几十种不同行为的实体。

然而,现在我们的实体系统无法很容易实现这种灵活组合。原因在于当前系统仍然强烈依赖 type 字段来决定行为。这会使我们不得不对每种类型做硬编码,显得僵化、不可组合,未来要扩展就变得困难。

我们希望推动实体系统朝更加解耦、组合化的方向发展。目标是能够自由拼接功能模块来构建新实体,而不是为每个实体类型写一整套独立逻辑。幸运的是,我们目前的实体结构是“平坦的”,没有使用复杂的类层次结构或继承体系,因此不会遇到典型 C++ 继承体系中那种类型转换、抽象接口的问题。这一点让我们得以自由组合不同的数据片段。

问题的关键是:type 字段目前仍承担着类似“继承类名”的角色,它限制了实体之间的组合可能性,就像 C++ 的类层次会限制对象的可重用性一样。这种设计思路显然不适合我们当前追求的灵活组合目标。

我们接下来的思路,是从实体已有的运动状态入手:现在的实体已经具备某种“有限状态机”的行为,例如 Planted(固定不动)和 Hopping(跳跃移动)这两种模式。这说明:运动模式的状态变量,原本就可以作为行为的判断标准,而不需要依赖于 type 字段。任何实体只要设置了这个运动状态,就可以具备相应的行为。

因此,我们第一步要做的事情,是把那些原本可以通过更小粒度变量来控制的逻辑,从 type 字段中解耦出来。我们不再用 type == HERO_HEAD 这样的判断来决定行为,而是用实际控制行为的数据来决定逻辑分支。

总结来说,我们正在采取以下策略来推动实体系统去类型化、组件化:

  1. 把行为判断的依据从 type 字段转移到具体的数据字段上,例如运动状态、控制来源等;
  2. 将实体视为属性和行为的集合,而不是一个静态类型;
  3. 使实体具备可以自由组合的能力,以支持丰富的变化和演化;
  4. 通过数据驱动的方式,为系统后续支持复杂的 AI、角色变异、怪物组合等提供可扩展基础。

这一过程的目标,是实现一个高度模块化、灵活、可组合的实体系统,让我们能以最少的代码,通过不同模块的拼接方式,快速构建出多样且具行为差异的游戏实体。

game_entity.h 中将不需要依赖 Type 的部分移除

我们开始尝试将代码中与实体移动模式相关的处理逻辑,从依赖 type 字段的实现方式中解耦出来,看看这个过程会带来什么变化。

首先,在“世界模式”中检查了当前的移动模式 movement_mode 的使用位置。原本的代码逻辑是将不同的移动行为封装在一个 switch 语句中,根据实体的 type 来决定具体行为。我们尝试最简单的方式:将这段与移动行为相关的逻辑从 switch 语句中剥离出来,放到外部独立处理,避免再依赖 type 字段。

我们从主角身体部分的处理逻辑入手。逻辑中,如果移动模式是 Planted(静止状态),那么实体会被固定在其所占据的方格中,并读取 head_delta 来计算上下浮动(bob)效果。这个 head_delta 是针对“成对实体”(如头和身体)特有的处理。除此之外,其它逻辑是通用的,不依赖实体结构是否是“成对”的。Planted 状态下其实唯一特有的是上下浮动(bob)计算,换句话说,这部分逻辑是可以独立出来并通用化的。

相比之下,Hopping(跳跃)模式的逻辑本身就不依赖头部,处理逻辑可以独立存在,不需要关心实体是不是一对“头-身体”的组合。因此,我们开始把 Planted 模式下的 bob 运算逻辑抽取出来,标记为一个处理移动模式的逻辑块。

虽然这种抽取操作最终很适合重构成一个函数,但考虑到当前时机尚早,我们暂时不将其封装为函数,而是内联保留在当前位置。同时加了一个注释,明确表示这是用于处理实体移动模式的逻辑部分,后续可以再提取出去使其更清晰。

接着,继续编译以确认当前更改没有引发错误。在处理 T_bobdT_bob 相关运算时,我们设想每个实体未来都会用到这种“上下浮动”的 bob 动画。如果有一些实体如树木(tree)是完全静止不动的,理论上它们可以拥有一种新的默认移动模式,如“静态模式”(immobile),其特征是不做任何位移,也不计算任何浮动效果。这一模式在逻辑上什么也不做,是最简单的状态。对于树这种实体来说,这种处理方式是最合适的。

但考虑到当前的实际代码结构,暂时决定不新增这个模式,而是直接使用已有的 Planted 状态,因为 Planted 状态本身只在“存在头部”时才计算浮动,没有头部时不会触发相关计算。因此对于像树这样的静态实体,不会有任何负面影响。

然后我们重新编译,发现主要问题还是出在某些没有“头部”的实体身上,因为浮动逻辑依赖 head_delta 的存在。为避免当前阶段陷入对这种情况的复杂处理,我们选择临时注释掉相关代码,跳过这部分逻辑,让整体流程能够顺利运行。

通过这一步,我们已经成功地将一部分原本绑定在 type 上的行为逻辑,从类型结构中解耦出来,向着行为由“具体状态变量”驱动的方向迈出第一步。这种方式为日后实现更加灵活、模块化的实体行为系统打下了基础。我们后续还可以继续将其它与 type 绑定的行为进一步抽象、重构,以达成彻底摆脱类型标识符的目标。
在这里插入图片描述

在这里插入图片描述

运行游戏,确认除了“暂时漂浮的东西”(FloatyThingForNow)以外没有丢失任何内容

我们现在已经基本上实现了与原来相同的逻辑,只是移除了之前的上下浮动(bobbing)动画效果。除了这一点,其他行为都保持不变。原来当实体处于“Planted”(静止)状态,并进行拉伸动作时,它会有一个向下的动态效果,现在这个效果没有了——也就是说,它不会像以前那样“低头”或“下蹲”,但这不是重大损失。

重要的是,这种调整可以广泛应用于所有实体,对系统没有明显的不良影响,除了一个特殊的例外:一个浮空的实体开始出现异常行为,它漂浮到了屏幕中央之外。这种行为出错的原因在于,我们之前用于控制浮动动画的变量 T_bob 和相关逻辑,实际上已经隐含地承担了控制浮空实体位置的功能。它通过某种弹性变化的计算,与实体位置相加,导致实体随时间偏移。这和我们刚才移除浮动动画的调整产生了冲突。

目前来说,我们尚不清楚这个“浮空实体”应该具有什么样的行为定义。它是个特例,不适用于我们当前对实体运动和浮动的统一逻辑。基于此,我们决定暂时不去处理它,仅将其标记为一个待处理的内容,后续可以具体设计这种特殊实体的逻辑需求。

更进一步地,我们意识到,变量命名必须有明确的语义。例如 T_bob 这个名称就很模糊。它似乎指的是某种弹性、浮动、时间相关的值,但具体语义不清晰,导致不同实体对其的使用不一致,甚至产生逻辑冲突。为了解决这个问题,我们需要为实体中的每个变量赋予明确的定义。每一个变量都应该对应一个特定的、单一语义的作用点,这样所有实体都可以共享并围绕这些明确的变量构建更复杂、协调的行为逻辑。

这其实不是系统的缺陷,而是一种优势。我们正在朝着更好的设计迈进,即让每个变量在系统中都拥有清晰、统一的含义,从而促成实体之间的交互更丰富,系统行为更灵活、可组合。例如:如果系统中有一个明确表示“弹跳值”的变量,那其他逻辑也可以根据它来决定动画、声音、位置、攻击行为等各种互动逻辑,这正是我们期望的那种“丰富交互”的体现。

我们接下来做了一个 TODO 标记,提醒未来要认真思考如何支持这些浮空平台或特殊实体,它们的运动机制应当有更加清晰的逻辑定义,而不是依赖隐式变量。

随后,我们将注意力转移到另一个关键问题:实体之间的“配对”关系。当前代码中,存在“头”和“身体”的关联,例如一个实体被称为“头”,并通过一个 head 变量指向另一个实体(通常是身体)。我们认为这种“配对实体”关系应该被抽象成更通用的概念——即一个实体可能与另一个实体存在某种关联关系,并基于此产生特定行为。

这种关系不仅限于“头-身体”的组合,未来可能存在如“手-武器”、“主机-附属模块”、“角色-宠物”等多种形式。因此我们需要将目前对“head”这种具体命名方式的处理抽象为更中性、结构化的关联变量,并在系统中形成一种明确的“配对实体”语义模型,以支持未来更复杂的行为组合与模块复用。我们将着手对此进行定义和重构。

game_entity.h 中对实体结构进行划分,分开有意义的内容和试验性内容

现在我们开始进入实体系统设计的真正阶段,是时候对已有的内容进行一次清晰的划分和整理。我们将把原本在原型开发阶段临时添加的各种属性、变量,与我们经过思考、设计清晰、有明确语义的内容进行区分。这种区分并不是意味着前者就是错误的,而是它们当初的目的更多是为了快速验证、测试或驱动引擎跑起来,并不具备长期使用的严谨性。

我们打算在实体结构中建立一种“分区”概念,一部分是正式的、被确认具有特定语义的字段;另一部分是临时使用的、尚未经过彻底思考的字段。随着系统的发展,我们将逐步将原型阶段的临时内容迁移到正式结构中——只有当我们明确某个变量的实际意义、用途和通用性之后,它才会被提取出来,纳入正式实体结构的一部分。

这种做法的核心目的是让系统在可维护性和可扩展性方面更加强健。一个字段一旦被纳入正式结构,它就不再只是某个特定实体为了临时功能而生的“定制数据”,而是被赋予了通用的角色,未来可能会被多个实体复用,或者作为系统逻辑交互的基础要素。

虽然将某些字段上升为正式字段不代表它们绝不可变,但我们确实要意识到其中的区别:临时字段是为眼前所用;正式字段则是为整个系统结构服务的,是基础的一部分。我们接下来的工作将集中于这类字段的梳理、命名、语义明确以及结构重构,确保每一个进入实体结构“核心区”的字段都具有明确职责、清晰用途,并为未来系统复杂行为的搭建打好地基。

在有意义的结构区添加 entity_reference *PairedEntitiesuint32 PairedEntityCount,引入实体配对的概念

我们现在开始思考实体系统中一个更通用和可扩展的结构——“配对实体”(paired entities)。这是一个对现有“head-body”这类结构的抽象化思考。我们不再仅仅将它看作是“头”和“身体”的关系,而是把它归纳为一种“实体之间的配对关系”,并尝试建立一个可以广泛适用于类似结构的机制。

我们首先确立了这样一个概念:一个实体可能会与其他一个或多个实体存在“配对关系”。比如一个复杂的 boss 怪物,它的多个部分(如八条腿)可能是独立的实体,并和主干实体之间存在某种父子级别的关系。这种设计不仅仅局限于头和身体的组合,而是能支持更加复杂的结构形式。

因此,我们计划将“配对实体”作为一个正式的结构引入实体系统。这个结构应当具备以下几个特征:

  1. 具备一组引用:不再是单一的引用,而是一个可以容纳多个实体引用的集合。意味着一个实体可以拥有多个“配对对象”。

  2. 引用数量可变:系统应允许这些配对关系的数量是动态的,例如一个实体可以配对 2 个、5 个甚至 23 个其他实体。

  3. 引用关系具备语义:每一个配对关系不仅仅是一个 ID,还应当能携带关系的语义类型,例如“父子关系”、“部件关系”、“控制关系”等。我们设想使用一个枚举或标签字段来表示这种关系的类型。

  4. 未来可能扩展结构细节:当前虽然暂时不打算完全实现这套系统,但已经开始考虑后续可能涉及的内容,比如配对的实体之间是否还应该包含空间结构或层级结构等更多信息。

在实际使用上,我们希望看到这样一个配对结构如何工作。例如我们在处理“planted”运动模式时,原本是通过头部与身体之间的距离来判断角色是否应该“下蹲”。如果我们引入了多个头部或多个配对实体,我们就必须考虑——应该如何决定“蹲下的程度”:

  • 取所有配对实体与本体的平均距离
  • 选择最远的距离(最大值)?
  • 使用最近的一个(最小值)?
  • 或者进行某种加权平均?

这些都属于向“配对系统”引入更强行为语义的一部分,可能需要以后进一步设计。

总之,我们的目标是:为实体系统引入一个通用、动态、结构化的“配对机制”,使得不同实体之间可以建立具备多样语义的关联,并以此为基础支持更复杂的逻辑行为和动画系统。这一结构不仅提升系统通用性和可维护性,也为未来添加新型实体或扩展游戏交互能力提供了基础支撑。
在这里插入图片描述

黑板时间:范数(Norm)与毕达哥拉斯定理

我们在这里引入“范数(norm)”的概念,是为了更好地理解在多维空间中如何对多个数值进行度量。范数是一种“度量方式”,它帮助我们在处理多个值(如向量)时得出一个统一的量化结果。


什么是范数(Norm)

范数是数学中用于衡量“向量大小”或“距离”的一种方式,在编程和图形系统中也非常常见。我们平时常用的向量长度计算,其实就是范数的一种具体形式——2范数(Euclidean Norm)


向量长度其实是2范数

我们平时处理二维向量 (x, y) 时,会通过勾股定理计算其长度:

length = sqrt(x² + y²)

这正是2范数的定义。在三维中就是:

length = sqrt(x² + y² + z²)

所以,2范数实际上就是将各个分量平方相加后开根号。


各种常见范数及其意义

我们可以将不同的“范数”看作是对一组数值的不同“测量方式”,它们的通用形式是:

n范数 = (|x₁|ⁿ + |x₂|ⁿ + ... + |xₖ|ⁿ)^(1/n)

以下是常见的几种范数:

1范数(Manhattan Norm / Taxicab Norm)
||v||₁ = |x| + |y| + |z| ...

这是将各分量直接相加的结果,不涉及平方或开根号。常用于需要对权重归一化的地方,例如在图形渲染中处理权重时:

所有权重加起来应该是1,于是就除以总和,也就是除以1范数。

2范数(Euclidean Norm)
||v||₂ = sqrt(x² + y² + z²)

这就是我们平时说的“向量长度”,最常用于描述实际的空间距离。

3范数(不常用)
||v||₃ = (x³ + y³ + z³)^(1/3)

实际工程中很少使用,因为没有特别明确的意义或优势。

∞范数(Infinity Norm)
||v||∞ = max(|x|, |y|, |z| ...)

这是取所有分量中的最大值。其数学定义形式是:

(x^∞ + y^∞ + z^∞)^(1/∞)

由于当指数趋近无穷时,最大的那一项会“压倒性地占据主导”,所以最终的结果等于最大分量值

这个范数在实际应用中比想象中常见,比如在进行某些最坏情况分析、约束判定等场景中非常有用。


总结

  • 范数是度量一组数值整体大小的方式。
  • 不同范数根据实际需求使用,其中 1 范数和 2 范数最为常用。
  • ∞ 范数提供的是最大单个分量的值,适用于某些特殊场景。
  • 了解这些范数的定义和几何含义,有助于我们在设计系统时选择合适的度量方式。

我们未来在构建实体之间行为权重、配对机制等系统时,可以借助这些范数来计算距离、差异或相似度等,用于实现更复杂的逻辑判断和物理表现。

game_world_mode.cpp 中编写多个实体配对的使用代码

我们刚才讲到范数是为了引出一个更实用的场景:当我们面对一个本应处理单一输入的操作,而现在却希望它能处理多个输入,但最终仍然只输出一个值的情况时,范数就成了一个非常合适的工具。


多个输入,单个输出的处理逻辑

比如说我们原来有一个系统,它只处理一个“头部”实体的位置,用来决定一个生物在起跳前需要下蹲的程度。但现在我们希望这个系统可以支持多个“头部”或其他相关实体的组合,那么我们该如何继续只输出一个单一的“下蹲值”呢?

我们就可以用范数来归约多个值为一个。


使用范数总结多个实体的数据

举个例子,我们遍历所有关联的实体(比如多个头部、或附加部分),然后对它们的位置偏差进行累加。具体来说:

  1. 遍历所有“配对的实体”。
  2. 获取每个配对实体的位置信息。
  3. 计算它和主实体之间的距离差。
  4. 将这些差值平方后累加。
  5. 遍历结束后,对总和开根号,得到类似原来那种“长度”的结果(也就是2范数)。
  6. 最终这个值就作为唯一的输出。

这种方式和原来只对一个实体做操作时保持了相同的输出逻辑结构,只是现在我们把多个输入归约成了一个总量。


优势与可扩展性

这种做法的优势非常明显:

  • 通用性强:不再依赖特定命名(如“头部”),而是通过“配对关系”处理任意多个子实体。
  • 行为一致性:只需要改变配对关系,不需要改变逻辑结构即可获得新的交互方式。
  • 未来可扩展:后续可以灵活扩展,比如允许多个部分影响跳跃力、移动速度、碰撞行为等。

我们现在其实已经可以不再关心一个实体是否拥有某个具体配件(比如头部)了,因为“是否有配件”这个判断已经隐含在遍历过程中:没有配件,循环体不会执行,结果自然为零。


更进一步的泛化目标

我们的目标,是尽量将所有逻辑从特殊命名逻辑转化为通用结构驱动逻辑。也就是说,我们希望未来的系统能自动适应各种组合,不需要为每种情况写特定代码。

通过范数和配对系统,我们就能够:

  • 用统一方式处理多个输入;
  • 提供灵活的实体组合机制;
  • 实现更复杂、动态的行为关系;
  • 为后续加入新机制打下结构基础。

这就是我们为何要引入范数作为过渡工具的根本原因。它不仅是数学技巧,更是构建通用系统逻辑的一块基石。
在这里插入图片描述

game_world_mode.cpp 中思考如何让 PackEntityIntoChunk 了解配对关系,并让 PairedEntities 动态调整大小,以及如何追踪 entity_id

我们现在面临的核心问题是实体打包(packing)系统在处理“配对实体”这种动态数组数据结构时遇到的一些复杂性,尤其是在缺乏固定数量的“头部”实体之后,原有的简化结构不再适用。为了解决这个问题,我们必须改进实体的打包与解包逻辑,使其支持可变数量的数据项,并处理动态增长的数组。


实体打包时的内存管理问题

在原有的打包流程中,我们只是将整个实体结构以块拷贝的形式复制到内存区域中,但这种方式无法很好地处理如“配对实体数组”这种可变长度数据结构。比如:

  • pair_entity_count 是一个明确数量,可以正常打包;
  • pair_entity_ptr 是一个指针,指向一段动态分配的数组,需要我们根据数组实际长度来打包,而不是直接复制指针值。

因此,我们需要明确策略:

  • 只打包实际存在的配对实体数据;
  • 避免为未使用的空间浪费内存或带宽;
  • 确保结构在被打包后能够正确重建原有信息。

数组的动态增长策略

在实际运行中,一个实体在创建时可能没有任何配对对象,但运行中可能会陆续添加。这就引出了“动态数组增长”的需求。目前尚未实现这个机制,因此我们需要决定一套策略。

我们的选择:

  • 每次增长时以固定块大小进行(如每次增加 4 个 slot);
  • 避免一次性预分配过大内存;
  • 根据常见情况进行优化:大多数实体配对数为 0~2,极少超过 10,因此无需支持非常大的配对数。

这种方式既节省内存,又保证了运行时效率。


将打包与解包逻辑集中管理

我们希望将打包与解包的逻辑从模拟系统中移出,集中放到 handmade_world 这样的模块中:

  • 模拟模块只关注实体行为;
  • handmade_world 负责打包与解包;
  • 让数据流动变得清晰、可追踪,减少重复错误。

重新思考实体引用 EntityReference 的结构

目前的 EntityReference 通常包含一个指针和一个索引(ID),这在系统设计中带来了一些限制:

  • 如果只存指针,那么一旦实体不在当前模拟范围内,引用会丢失;
  • 如果只存索引,那么在使用时每次都要查找实体表,效率不高;
  • 如果同时保留指针和 ID,则必须同步更新这两个值,否则可能造成状态错乱。

我们有两种基本策略:

  1. 只使用实体 ID
    所有引用都通过 ID 来维护,查找通过哈希表或索引表完成;这种方式更稳定,适合长期引用,但访问开销稍大。

  2. 保留 EntityReference(指针 + ID)

    • 如果指针有效,就直接使用;
    • 如果指针无效(例如目标不在模拟范围),则依然保留 ID,支持延迟匹配或远程感知;
    • 打包时依据当前状态判断是使用指针还是 ID;

这种混合策略兼顾效率与功能,适合需要动态识别关系、记忆状态的复杂交互,比如:

  • 实体记住一个遥远的目标(如宝石);
  • 召唤物记得主人,即使双方暂时不在同一模拟区域;
  • 任务系统中的目标跟踪;

对于 ID 持久化的进一步思考

我们希望让实体能长期记住另一个实体的 ID,而不依赖其是否在当前的模拟范围中。为了实现这个目标:

  • 需要允许某些 EntityReference 的 ID 独立于其指针存在;
  • 在打包与解包过程中确保 ID 被保留;
  • 即使该实体不在当前的 active chunk 中,也能通过 ID 比对识别其身份;
  • 使用者在逻辑上要意识到:指针为 null 不代表引用无效,只是代表引用对象不在当前范围。

例如:

if (ref.pointer == 0 && ref.id != 0) {// 实体不在当前区域,但我们仍然“认识”这个目标
}

总结目标

  • 支持动态数量的配对实体;
  • 动态数组可增长,按需分配;
  • 优化打包流程,按实际数据量处理;
  • 保留实体 ID,支持长时间引用;
  • 将打包逻辑集中管理,简化系统架构;
  • 构建一个更灵活、可扩展的数据管理体系,为更复杂的游戏行为做准备。

在这里插入图片描述

game_entity.h 中考虑引入枚举 entity_relationship 来表示实体之间的配对关系

我们正在探讨如何更合理地设计和使用 EntityReference(实体引用)结构,以支持模拟范围内外的实体关系。这一问题看似简单,实则牵涉到数据结构的健壮性、可维护性以及未来的功能扩展。


基础设计建议:固定16字节结构

我们提出了一种设计方式,将 EntityReference 明确定义为16字节的结构,包含以下三个部分:

  • 关系标志位(例如用于后续可能的用途);
  • 索引(ID):用于持久标识实体;
  • 指针:用于高效访问当前在模拟范围内的实体对象。

这种结构让我们在编码时总是使用指针进行实体访问操作,但在需要验证引用是否合法或需要进行逻辑判断时使用索引。

我们还提出了一种方式使指针和索引“更难直接访问”,以提示调用者不应随意使用,而应该通过专门的 helper 函数 进行访问和处理,从而确保使用行为的一致性和正确性。


三态状态设计

核心难点在于实体引用实际上是一个“三态状态”,而不是传统意义上的“引用/未引用”两态:

  1. 空引用:既没有指针也没有索引(表示没有任何引用关系);
  2. 就地引用:指针有效,指向当前模拟范围内的实体;
  3. 远程引用:指针为空,但索引有效,表示引用了一个不在模拟区域内的实体。

我们必须在系统中处理这三种状态,不能假设引用要么存在(有效指针),要么不存在(空指针)。尤其是在支持大规模世界、跨区域实体追踪的设计中,远程引用(指针为空,ID有效)是非常常见且必要的场景,例如:

  • 玩家离开某区域,但某个敌人依然记得其 ID;
  • 宠物记住主人的 ID,即使主人暂时不在视野内;
  • 任务系统追踪特定实体的状态,不依赖是否在当前加载的区域。

是否引入“空代理实体”

我们考虑过另一种方案,即当引用指针为空时,让它总是指向一个特殊的“空代理实体”,以避免空指针判断。但我们认为这方案不够优雅:

  • 增加了概念复杂度;
  • 需要维护一个虚拟实体池;
  • 程序员很可能误将代理实体当成真实实体使用,反而增加混淆。

因此,我们倾向于不采用这种方式。


实际代码中的应用

通过分析已有的代码,我们发现,在大多数情况下,使用实体引用并不需要特别判断三种状态:

  • 如果我们只是访问指针,访问失败就意味着该实体不在模拟范围内;
  • 如果我们只是收集数据或做简单遍历,大多数逻辑在“有指针时处理,无指针时忽略”的方式下就能运作良好;
  • 唯独在需要判断“是否引用了某个对象”时,才需要判断索引是否有效(即使指针为空)。

因此,推荐的使用方式是:

if (EntityRef.pointer) {// 模拟范围内,可直接访问实体
} else if (EntityRef.index != 0) {// 模拟范围外,但引用仍然有效// 可延迟加载或标记为远程交互
} else {// 完全没有引用
}

这种方式在逻辑上清晰,避免了直接依赖单一指针的脆弱性,同时允许我们在需要时支持更复杂的行为。


最佳实践与未来展望

为了减少后续维护成本和误用风险,我们计划:

  • 强制所有对 EntityReference 的操作必须通过封装函数;
  • 这些函数将根据引用状态自动选择是使用指针还是使用索引;
  • 系统所有模块都需要遵守“引用可能为空,但 ID 可能有效”的基本原则;
  • 设计辅助逻辑来优雅地处理“远程引用”的情况,比如:自动预加载、延迟绑定、弱引用行为等。

通过这样的结构,我们不仅提高了系统的稳健性,还为大规模、动态世界中的复杂交互打下了良好基础。
在这里插入图片描述

game_entity.h 中引入 ReferenceIsValidentity_stored_referenceReferencesAreEqual

我们进一步完善了实体引用(Entity Reference)的设计方案,特别是关于如何判断引用是否有效以及如何比较两个引用是否相等的问题。这些都是在模拟范围内外处理实体引用时非常关键的逻辑环节。


引入判断引用有效性的辅助函数

我们意识到,单纯通过指针来判断一个引用是否有效是不可靠的,因为指针为 0 并不意味着引用无效。引用仍可能携带一个合法的索引(ID),只是当前模拟区域内没有该实体对象而已。

为了解决这个问题,我们引入了一个辅助函数,比如:

b32 ReferenceIsValid(EntityReference ref);

这个函数仅检查 index 字段,而不会检查指针。这样即使指针为零,只要索引有效,函数依然会返回引用有效。

此举大大简化了使用方的判断逻辑。调用者只需要问:“这个引用是有效的吗?”,而不需要关心它是模拟内实体还是远程引用。


引入独立结构 EntityStoredReference

为了更好地区分“引用实体”和“存储引用”,我们新建了一个 EntityStoredReference 结构,它专门用于持久保存实体引用信息,内容可能包括:

  • 实体 ID(index);
  • 关系类型(例如敌人、同伴等);
  • 可能保留指针(但主要用于临时加速访问)。

这一结构的作用是把引用看作一个完整的、可序列化的数据,而不仅仅是指针 + ID 的临时拼装。


引入辅助函数判断两个引用是否相等

我们还设计了另一个辅助函数,例如:

b32 ReferencesAreEqual(EntityStoredReference a, EntityStoredReference b);

这个函数用于判断两个引用是否指向同一个实体(以及是否属于相同关系)。比较逻辑主要基于:

  • StoredIndex(即实体 ID);
  • StoredRelationship(可选,取决于是否参与判断);

通过这个函数,我们可以优雅地处理实体之间的各种关系判断,而不必暴露低层字段,也避免了判断指针时可能出现的歧义。


保留指针的讨论与处理策略

我们考虑是否在引用结构中继续保留实体指针。尽管指针值并不能反映引用的持久性,但它在模拟区域内时可以加快访问速度。

我们倾向于 保留指针字段,原因包括:

  • 某些代码可能只设置了指针,但还未设定索引;
  • 模拟区域内操作频繁,保留指针可以避免重复查找;
  • 通过封装访问和判断逻辑,可以安全使用指针,同时避免错误用法。

设计思路总结

我们目前建立的引用系统具有以下特点:

  1. 三态引用模型:无引用、本地引用、远程引用;
  2. 封装判断函数:使用 ReferenceIsValid 等接口统一判断有效性;
  3. 引用比较逻辑清晰:通过 ReferencesAreEqual 等接口明确引用是否等价;
  4. 结构分离:运行时引用与持久引用分离,便于序列化与存储;
  5. 封装访问机制:强调不直接访问指针和 ID 字段,强制使用辅助函数。

这种设计不仅提高了系统健壮性,还使得引用机制具备良好的扩展性,能够应对复杂的世界构建和长时实体追踪需求。我们可以安全处理离线实体、跨区交互等功能,而不会破坏局部模拟的高效性。
在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp 中使 PackEntityReferenceArray 能正确打包变长的实体配对信息

我们正在实现实体引用的打包机制,核心目标是确保实体在序列化/打包时能够正确保留它们与其他实体的关系,尤其是在存在多个配对实体引用的情况下。以下是我们所做工作的详细分解:


实体引用打包的整体流程设计

我们在打包实体数据(PackEntityIntoChunk)时,不仅要打包实体本身的数据,还必须打包它所持有的实体引用数组(即与其他实体的关系)。因此,计算打包大小时,不能只考虑实体本体的大小,还需要加上:

配对实体数量 × 单个存储引用的大小

这样可以保证目标内存块分配足够空间来容纳这些引用。


动态处理引用数组打包

我们定义了 PackEntityReferenceArray 的逻辑,对引用数组进行遍历与打包处理。每一个引用项将被转换为 StoredEntityReference 结构,存储到目标内存中。这一结构保存以下信息:

  • index:实体 ID;
  • relationship:关系类型;
  • pointer:可以选择保留指针;
  • 其他必要字段。

这个过程类似于序列化:将运行时状态(可能包含指针)转换为持久化状态(以索引为主),便于保存与传输。


条件复制引用索引逻辑

在处理每个引用时,我们判断:

  1. 如果源引用的 pointer 非空,说明它指向当前模拟区域的实体。
  2. 如果是这样,我们尝试保留它的 ID
  3. 但如果指针为空,我们可以选择清空索引,或判断它是否仍然在哈希表中(即仍处于模拟区域中),从而决定是否保留索引。

这一判断依赖于模拟区域的哈希表(用于实体查找),这使我们思考是否应该将打包逻辑迁移到 SimRegion 内部而不是 World 层面。因为当前 World 对象缺乏这部分上下文信息。


关于引用数组的结构选择

我们注意到一个设计点:是否使用数组或命名槽(named slots)来存储配对引用。目前我们采用数组,这样可以支持任意数量的关系(可扩展性强),但也缺乏语义清晰度(例如不知道第一个引用指向“目标”,第二个指向“父节点”)。

我们思考后决定保持数组结构,因为:

  • 我们可能有多个类型的引用,使用数组结构可支持动态数量;
  • 如果需要多个语义分明的引用,也可以使用多个数组;
  • 同时每个引用项中可携带“关系类型”字段,这弥补了语义缺失的问题。

正确计算偏移并执行打包

在打包时,我们严格记录:

  • source.paired_entities 是源实体引用数组;
  • source.paired_entity_count 是数组长度;
  • dest 是目标位置(实体本体之后的存储引用区域);

我们遍历源数组,依次将每个 EntityReference 转换为 StoredEntityReference 并写入目标内存,过程中保持初始化安全性,避免数据未定义。


进一步思考:引用是否应在模拟区域关闭时统一重新打包?

我们还讨论了另一个问题:是否应该将引用的重新打包操作(尤其是判断引用是否仍存在于哈希表)放在模拟区域(SimRegion)关闭时统一执行。这种做法虽然可能带来一定的冗余计算,但也可能更清晰地统一引用状态管理。


总结

当前的实体引用打包系统已经具备以下核心特性:

  1. 支持引用数组的序列化,考虑了配对引用数量与结构;
  2. 判断引用有效性,基于是否存在于模拟区域哈希表中;
  3. 动态扩展能力强,通过数组结构支持任意数量引用;
  4. 字段初始化安全,打包过程中明确初始化所有字段;
  5. 可能需结构调整,未来可考虑将打包逻辑移动至 SimRegion
  6. 处理关系语义,通过存储引用的“关系”字段保留引用类型信息。

整个系统保持了良好的清晰度、扩展性与运行时安全性,为大型动态实体世界的序列化、网络传输与远程引用提供了扎实基础。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world.cppgame_sim_region.cppgame_world_mode.cpp 中修复编译错误

我们正在完善“遍历引用”(Traversal Reference)与“打包引用”系统的实现,目标是实现一个结构良好、可反序列化并能正确描述实体间关系的系统,即通过存储引用(StoredEntityReference)来表示实体间的配对关系。


引用结构的最终形式确定

我们决定使用 StoredEntityReference 来统一表示配对引用。这种引用不仅包含实体指针(pointer),还包含与其关系(relationship)和实体索引(index)等字段,便于在运行时和序列化之间转换。


保持系统在可编译状态

尽管时间紧迫,但我们选择将系统最小化保持在可编译状态,以便下次可以继续工作。这意味着我们先实现基本框架,保证编译通过,并为后续打包、解包和逻辑处理留出结构。


头部和身体的实体配对初始化

在添加玩家的过程中,我们需要初始化两个实体的引用关系,例如一个“头部”和一个“身体”:

  1. 分别定义 head_refsbody_refs 数组,每个只包含一个 EntityReference

  2. 设置它们之间的互相引用,即:

    • body_refs[0].pointer = head
    • head_refs[0].pointer = body
  3. 设置引用数量为 1paired_entity_count = 1);

  4. 将数组赋值给实体的 paired_entities 字段。

这种互相引用关系定义了两实体之间的逻辑连接(例如身体和头部属于同一个单位)。


暂时禁用引用数组(用于空实体)

在有些情况下,例如某个配对实体目前尚不存在,我们仍保留结构,但将指针设为 nullptr 或暂不填充。这种情况下:

  • 仍设置 paired_entity_count = 1,表明结构预留了引用;
  • 但指针为空,实际在运行时可以通过其他机制(如哈希表)重新建立引用。

引用数组在“打包”中的处理逻辑回顾

在之前的打包过程中,我们已经准备好以下机制:

  • 实体引用数组会被打包到内存块中;
  • 每个引用会转换为 StoredEntityReference
  • 指针信息在某些条件下保留(如仍在模拟区域中);
  • 如果不再有效,则只保留 index 或清空;
  • 打包大小会考虑引用数组大小;
  • 解包逻辑尚未完全实现,待后续补全。

对后续问题的预期

我们已经认识到目前的代码中仍然存在一些尚未实现或可能存在 Bug 的部分,包括:

  • 引用数组的完整遍历与打包校验;
  • 反序列化/解包时如何恢复引用(根据 index 或重新查找实体);
  • 多个引用数组或多重关系时如何高效表达;
  • 如何在实体失效或删除时维护这些引用的正确性;
  • 模拟区域(SimRegion)结束时是否重新打包引用,保证跨帧引用正确。

结语

目前系统已进入一个清晰可扩展的阶段:

  • 我们实现了基本的数据结构和打包处理框架;
  • 引用初始化逻辑已经清楚,尤其是互相引用的实体配对;
  • 保持系统可编译状态,为下次迭代提供良好基础;
  • 后续将完善引用打包、恢复、验证和失效处理等功能。

尽管还有未完成的部分,但整体架构稳定,逻辑清晰,支持灵活扩展和更复杂的引用管理需求。
在这里插入图片描述

在这里插入图片描述

问答时间

一个简单理解无穷范数的方法是画出各种常见范数的单位超球:1-范数是菱形,2-范数是圆或球体,3-范数开始变得更方……你会发现这一系列趋近于一个方形/超立方体

我们正在讨论如何形象地理解“无穷范数”(Infinity Norm,也称为 ∞ 范数)。我们提出了一种相对简单但具有直观可视化效果的方法:通过绘制不同范数的单位超球面(unit hypersphere)来进行比较和理解。


范数的几何解释方式

我们考虑用以下方式直观理解常见范数:

  • 一范数(L1 Norm)
    对应的单位“超球面”在二维中是一个菱形(diamond shape),其边界是所有绝对值和为 1 的点构成的集合。

  • 二范数(L2 Norm)
    即我们最熟悉的欧几里得范数,对应的单位超球面就是普通的圆形(或在更高维中是超球体)。

  • 三范数(L3 Norm)及更高
    形状会越来越接近于方形(或在高维中为超立方体),即边界逐渐变得更“平”。

  • 无穷范数(L∞ Norm)
    对应单位超球面是完美的正方形或超立方体,因为它表示所有坐标的最大绝对值不超过 1 的集合。


趋势与极限的可视化

随着范数阶数 p → ∞ p \to \infty p,其单位超球面的形状会越来越“扁平”,边界会越来越靠近正方形/立方体的边缘,最后趋于一个完美的正方形/超立方体边界。这种从圆形逐渐向正方形演化的过程可以有效帮助我们理解 ∞ 范数的本质:它不再考虑所有坐标的合成量,而是只关注最大分量的值。


理解难点与保留意见

尽管这种几何形象的解释在我们内部看来“相对简单”,但从严格教学角度来看,它可能仍然不适合初学者直接使用:

  • 前提是听众或读者要能够理解什么是“单位超球面”,这本身就涉及抽象的高维几何;
  • 同时需要对不同范数的定义有清晰的代数理解,才能映射到图形感知上。

因此我们虽然认可这种方法在可视化角度是有效的,但要作为“简单描述方式”可能仍需结合上下文或逐步引导才更合适。


总结

我们通过绘制不同范数对应的单位超球面形状(从菱形到圆形再到方形)来直观地理解 ∞ 范数的本质——其最终单位超球面是一个超立方体。这是一种有效的趋势分析方式,能够辅助理解高维范数的行为及其极限。但作为解释手段仍需考虑听众的数学背景,适当引导。

你看过 Scott Meyer 关于 CPU 缓存的演讲吗?他提到将相同类型的数据存储在一起有助于它们出现在同一缓存行上,从而提升性能。这是你在游戏项目中会考虑或涉及的内容吗?

我们讨论了关于 CPU缓存优化与数据布局 的问题,特别是在程序设计中关于内存局部性(locality)数据排列、以及对性能的影响。


关于“相同类型数据应该排在一起”的说法

这个说法是错误的。正确的说法应该是:

应该把那些“会被一起访问的数据”放在一起,而不是“类型相同的数据”。

也就是说,优化CPU缓存并不是看数据类型,而是看访问模式。如果我们在程序中经常同时访问变量A、B、C,那么即使它们的类型不同,也应该把它们放在一起,保证它们能被加载进同一个缓存行(cache line)。反过来,如果我们只是有很多x坐标值(例如一个结构体数组中只关心x字段),但处理它们时是分散访问的,那么仅仅因为它们是相同类型的数据放在一起是没有意义的,甚至可能会浪费缓存空间,降低性能。


空间局部性(Spatial Locality)是关键

优化缓存命中率的关键是空间局部性

把程序中会“在时间上接近地被访问”的数据放在内存上接近的位置

这才是我们进行数据组织时需要关注的原则,而不是盲目根据类型分类。


SIMD对数据布局的影响

有些情况下,SIMD(Single Instruction Multiple Data,单指令多数据)指令集会强制我们批量处理一类数据。例如:

  • SIMD要求将一组浮点数据(如4个或8个浮点数)连续排列,才能高效处理。
  • 这种情况下我们可能需要使用“结构体数组”(SoA, Structure of Arrays)而不是“数组结构体”(AoS, Array of Structures),以便每种字段连续排列。

但即使如此,这也是因为算法或硬件处理方式强制要求我们一起访问这些数据,因此仍然符合“将一起访问的数据排在一起”的原则。


数据组织通常不是程序初期关注的重点

我们通常不会在架构初期就过度优化缓存,因为这样做会造成极大的复杂性成本,而大部分代码并不需要这类优化。应当在瓶颈分析之后有针对性地优化:

  • 编写合理、清晰的逻辑;
  • 用性能分析工具识别热点;
  • 对热点代码进行局部的内存优化。

C++在数据组织方面的局限

我们指出 C++ 在数据组织能力上表现很差:

  • 没有良好的原生工具来支持数据重排
  • 封装结构强,但对数据导向设计(Data-Oriented Design)支持差;
  • 若希望改变数据布局,经常需要手动重写大量代码,维护成本高。

相比之下,有些语言或引擎支持灵活的数据布局描述,例如有能力让程序员指定如何存储结构体内部的数据,便于根据使用场景进行切换(如 SoA 和 AoS 之间的切换),这对优化缓存一致性和并行化极为重要。


总结

我们在内存优化方面的核心观点是:

  • 并非“相同类型的数据放一起”能提高性能;
  • 真正重要的是访问模式:一起访问的数据应该在内存中靠得更近;
  • 在需要做SIMD处理等特殊场景时,我们也会依赖特定布局;
  • 优化缓存应当在确定存在性能瓶颈之后再进行,避免过早优化;
  • C++在数据组织上的能力薄弱,更现代的语言和系统可以提供更好的支持。

这类缓存优化和内存组织的内容我们在实践中已多次应用,并会继续在架构中考虑其影响。

你能用实体配对的概念实现例如“飞船-乘客”这种关系吗?如果可以,那乘客在飞船上的移动会如何实现?

关于“车辆乘员关系”的概念,比如飞艇和其乘客,目前系统中可能没有专门的父级概念来表示这种关系。现有系统里已经有“站在(standing on)”这个概念,因此通常会用“站在”来表示乘客处于飞艇上的状态。飞艇上的可通行点(traversable points)会用来描述乘客在飞艇上的具体位置和行动路径。

关于移动的实现,乘客的移动会基于他们“站在”飞艇上的状态来处理,意味着他们的动作和位置变化是依赖于飞艇本身的运动和飞艇上的可通行点,而不是单独维护一个车辆与乘员的特殊父子关系结构。因此,乘员的移动是通过已有的“站在”关系和飞艇的运动同步实现的。

我觉得这个实体引用系统现在用可能还太早了,因为我们目前还没有太多它的使用场景示例。你现在做这个是有什么特定原因吗?

目前来看还为时过早,因为还没有太多实际用例来说明这个设计具体怎么用。之所以现在开始做,是因为需要在系统里试验“实体引用数组”的概念,而不是之前已有的单个实体引用。之前的设计更多依赖于实体类型,比如某个实体类型知道自己会用“头部指针”来定位,但现在想写一些代码,不预设具体有多少输入,举个例子,比如不知道一共有多少条腿,可能只是有几条腿附着在实体上,腿在走路时会有摇晃效果,但腿的数量是不确定的。

因此想先做一个基础的结构,能支持实体引用不止一个,而是一组引用。现在先用一个只包含一个引用的简单示例来测试,看看这种设计感觉如何,是一种“探索”的过程。接下来会尝试支持更多引用,看看实际效果怎么样。猜测最终可能需要用多个数组来存储不同种类的引用,比如“关系”部分可能不是放在单个实体内部的关系字段,而是拆分出来单独存储。总之先做一个数组的示例,之后再慢慢调整优化。

现在还没确定最终方案,主要是想通过实际写代码去“试水”,看看不同方案的利弊,再决定下一步怎么走。总之目前先做一个支持数组的基础示例是必要的,方便后续的实验和迭代。

http://www.xdnf.cn/news/483373.html

相关文章:

  • R1 快开门式压力容器操作证备考练习题及答案
  • 2025程序设计天梯赛补题报告
  • 《数字藏品APP开发:解锁高效用户身份认证与KYC流程》
  • xss-labs靶场第11-14关基础详解
  • 2025认证杯数学建模第二阶段A题完整论文(代码齐全):小行星轨迹预测思路
  • MySQL的 JOIN 优化终极指南
  • RAG-MCP:突破大模型工具调用瓶颈,告别Prompt膨胀
  • Android Studio AI插件与Bolt工具实战指南:从零到一打造智能应用
  • PostgreSQL中的全页写
  • 【python编程从入门到到实践】第十章 文件和异常
  • Spring框架(三)
  • 7.重建大师点云处理教程
  • 每周靶点:PCSK9、Siglec15及文献分享
  • python基础语法(三-中)
  • [Java][Leetcode middle] 238. 除自身以外数组的乘积
  • 学习alpha
  • 【基础】Windows开发设置入门4:Windows、Python、Linux和Node.js包管理器的作用和区别(AI整理)
  • go.mod关于go版本异常的处理
  • 数据治理域——数据同步设计
  • HTML 中的 input 标签详解
  • 芯片测试之X-ray测试
  • 算法练习:19.JZ29 顺时针打印矩阵
  • SpringAI-RC1正式发布:移除千帆大模型!
  • handsome主题美化及优化:10.1.0最新版 - 2
  • [Unity]AstarPathfindingProject动态烘焙场景
  • 电脑出故障驱动装不上?试试驱动人生的远程服务支持
  • Vue3项目,子组件默认加载了两次,使用 defineAsyncComponent 引入组件后只加载一次
  • 简单入门RabbitMQ
  • Centos7 中 Docker运行配置Apache
  • 基于Scrapy-Redis的分布式景点数据爬取与热力图生成