回顾并为今天的内容定下基调
我们正在做一款完整的游戏,今天的重点是"移动模式"的正式化处理。目前虽然移动机制大致能运作,但写法相对粗糙,不够严谨,我们希望将其清理得更规范,更可靠一点。
目前脑逻辑(AI行为控制)系统已经基本稳定,我们也测试了各种生物的行为,基本可行。接下来的目标,是整理移动模式的系统。现在的移动行为虽然可用,但逻辑分散、不明确,因此今天会花时间来梳理它们。
我们已经进入了行为层代码的稳定阶段,整体架构没有太多问题,逻辑编写也较为直接,因此接下来的工作将集中在以下几个方面:
-
正式引入多种移动模式的定义与处理;
-
精简和改良世界构建逻辑,目前过于繁琐,尤其在处理 world chunk(世界区块)时,参数传递较复杂;
-
改善空间查询系统,让查询更加简洁、高效。
-
一条蛇,会以跳跃方式沿路径移动;
-
一个"怪物",会随机跳跃;
-
一个"familiar"(跟随者),会靠近玩家位置但行为尚未完善。
这些实体基本都使用了统一的跳跃移动模式(Hopping movement mode),无论是蛇、怪物还是主角都一样。主角的"头部"行为稍微特别一些,但也仍在同样逻辑控制下。唯独"familiar"例外,它是一个漂浮实体,没有使用跳跃模式,而是通过大脑逻辑直接移动,类似于直接设置坐标。
然而这个漂浮实体没有真正地"占据"它所在的格子,仍然保持在初始格子,这种行为显然不符合预期。我们希望它能具备以下能力:
- 像其它实体一样,尝试占据自己移动到的目标格子;
- 如果目标格子已被占据,就不能进入,移动应被阻止;
- 这样一来,它也能对其他实体产生"阻挡",而不是随意穿越。
这种逻辑将适用于所有"漂浮类实体",例如灵魂、幽灵、魔法球等,它们不应像"投射物"那样只是穿越,而是要作为完整实体存在,受阻于地形和其他物体。
因此,我们将开始引入一种正式的"漂浮移动模式"(Floating movement mode),并在逻辑上清晰地区分不同的移动类型,而不是当前仅有的"跳跃"和"固定"两种形式。
这会是我们今天的主要任务。
修改 game_entity.h
,在 entity_movement_mode
中添加 MovementMode_Floating
(漂浮移动模式)
我们查看了实体的定义后,发现每个实体都有一个"移动模式"的枚举,目前只有两个值:Planted(固定)和Hopping(跳跃)。大部分实体默认使用Planted模式,而跳跃则是当实体从一个格子跳到另一个格子时才使用的,像主角和部分敌人就是如此。
我们计划新增一个第三种移动模式,目前暂定名为Sliding(滑行) ,也可以理解为Floating(漂浮) 。我们并不在意实体是漂浮在空中还是平地滑行,关键是它不是跳跃,而是一种连续、线性的平滑移动。从功能角度而言,名称更偏向表达运动形式而不是视觉效果。因此"滑行"更能表达我们实际关心的核心含义。
我们暂定使用三个移动模式:
- Planted(固定):实体固定在某个位置,不主动移动。
- Hopping(跳跃):实体从一个格子"弹跳"到另一个格子,有一个动态跃迁过程。
- Sliding(滑行):实体以连续、线性的方式移动,没有跳跃轨迹。
虽然我们也考虑过是不是只需要一个移动模式,剩下的只是表现方式不同(比如跳跃其实只是视觉表现),但目前为了代码清晰、逻辑区分明确,还是保留多种枚举类型。
此外,我们还考虑了一个更细节的逻辑差异:如果实体是固定在某个格子上(Planted),当格子本身被移动时(例如浮岛、可移动平台等),实体也会跟着移动;但如果是漂浮的(Floating/Sliding),就不应该随平台一起移动。这就让我们有理由保留Floating和Planted作为不同类型。
我们目前位于world mode(世界模式)中处理移动相关代码的位置,在这里可以看到现有的Planted和Hopping处理逻辑,例如我们使用了一种模拟的弹跳效果来处理跳跃物理。除此之外,当前没有实现其他物理系统。
接下来,我们还会替换掉现有的MoveEntity
调用,它目前过于复杂,不再适用于我们新的设计需求。当初写它时主要是为了演示碰撞检测,所以没有很严谨。现在这个游戏采用"从一个格子跳到另一个格子"的离散移动方式,因此不需要像传统物理引擎那样处理"滑动接触"之类的复杂交互。我们准备重构这一部分,让它更简洁、更符合当前设计目标。
总的来说,我们要做的事情包括:
- 引入新的滑行移动模式(Sliding);
- 区分滑行、跳跃和固定三种基本的运动方式;
- 结合实体是否应该随着其所在格子一起移动的特性,来决定是使用Planted还是Floating;
- 重构
MoveEntity
,避免不必要的复杂逻辑; - 精简现有的碰撞处理系统,聚焦在格子级别的移动行为上。
这些改动将使我们的实体移动系统更加清晰、有组织,也为后续引入其他移动方式或行为逻辑打下良好基础。
考虑 MovementMode(移动模式)和 Brain(AI 控制逻辑)之间的职责划分
我们现在正在处理一个问题:像"familiar"这样的实体,它在当前的逻辑中是通过设置其 DDP(即目标加速度)来移动的。这种方式的问题在于,当我们直接设定加速度时,它会立即应用于实体本身,但并不会考虑实体当前所处的格子,也不会考虑从哪个格子移动到哪个格子。也就是说,它的移动是完全脱离网格逻辑的,这就导致了一个问题:实体的占用格子(occupy)状态不会更新。
按照原本的设计,占用格子的更新逻辑通常应该是在 brain(大脑逻辑)层处理的,但现在由于 DDP 是直接生效的,occupy 并没有得到更新,也没有网格间移动的概念,这是我们当前需要解决的关键问题。
我们面临两个设计选择:
-
在 brain 层处理所有占用和移动逻辑
这种方式类似于当前的跳跃(Hopping)机制,在 brain 中我们决定要跳到哪个格子,并执行跳跃,同时更新占用状态。
-
将移动细节下沉到实体(entity)层由 movement mode 决定
即在 movement mode 中根据设定的移动类型(如滑行)决定如何从一个格子移动到另一个格子。
目前我们还没有决定到底采用哪种方式。现在我们有点质疑 movement mode 的定位,因为它既像是控制物理行为的系统,也可能仅仅是视觉表现上的分类。因此我们要重新思考:我们在哪个层次上划分职责最合理?
另外也考虑过一种可能性,那就是在 brain 层只发出"我要移动"的请求,而具体如何执行交给底层系统。但我们从实现"蛇"这个生物的经验来看,将 occupy 占用逻辑保留在 brain 层的做法非常高效、简洁、而且健壮。蛇的运动虽然复杂(长链条、多关节运动、避免自撞),但我们只用了很少的代码就实现了完全正确的行为,而且这个实现不会出错,不会陷入非法状态。
这给我们带来了重要启发:保持 occupy 操作在 brain 层的好处是简洁、易维护且逻辑清晰。我们希望保留这种设计优势,不要因为引入新的 movement mode(如滑行)就打破这种简洁性。
如果为了引入滑行功能而把职责拆得过于复杂,最终可能导致连简单的行为都要写很多重复或冗余的代码,这不符合我们的设计追求。
所以我们倾向于这样的设计:
- 脑(brain)层依然负责决策实体想要移动到哪个位置;
- occupy 状态由脑层根据决策显式更新;
- movement mode 只作为执行方式的分类,例如跳跃是有垂直位移的,滑行是线性的;
- DDP(加速度)不再直接生效于实体位置,而是用于驱动目标移动意图,最终转换为 occupy 的变化。
接下来我们会动手写一些代码,帮助我们更清楚地理清这个过程。通过实践代码来验证设计思路,并进一步精炼这套结构,让系统既灵活又保持我们想要的极简主义。

修改 game_brain.cpp
,让 ExecuteBrain
使 Familiar(跟随者)占据可通行区域并执行事务性移动
我们在 familiar(类似宠物跟随角色的实体)的移动逻辑中,当前是直接给它设置 DDP(速度意图),但是我们更想知道的是:我们是否能够在指定方向上继续加速移动 。换句话说,我们不希望只是盲目地加速,而是要判断该方向是否可通行、是否有障碍物阻拦。如果前方的格子无法被占用,那么我们希望 familiar 会弹簧回到它当前所处的格子,而不是继续尝试进入非法区域。
这个逻辑的实现可以比较简单:
- familiar 是一个"头",我们只在它存在的时候处理逻辑。
- 利用
get_closest_traversable
查找当前 familiar 最近的可通行格子。 - 检查这个可通行格子是否和当前占用的格子不同。如果不同,说明可以尝试前往这个新格子。
- 尝试进行
transactional_occupy
(事务性占用)操作,把 familiar 从当前占用格子移动到目标格子。 - 如果占用成功,说明可以向目标格子移动;否则,我们将 familiar 标记为"阻塞"(blocked)。
我们引入了一个 blocked
变量来处理这个判断流程:
- 默认假设是被阻塞的。
- 如果当前位置就是最近的可通行格子(说明还在本格子上空),那就不算阻塞。
- 如果
transactional_occupy
成功,也说明不阻塞。 - 其余情况都视为阻塞。
根据是否阻塞,我们采取不同策略:
- 如果阻塞,我们会让 familiar "弹簧"地回到它占用的格子中心,防止它移动进非法位置。
- 如果不阻塞,并且能找到一个跟随目标,我们会朝着那个目标移动。
这意味着 familiar 的移动受到了更严格的控制,只能进入合法可通行的格子,并且在被阻挡时有回弹机制,从而避免穿越或停留在非法位置。
我们还对部分逻辑做了精简优化:
- 去掉了原本对"是否需要找目标跟随"的条件判断。现在改为:如果被阻塞,就不去找目标,只管回弹;如果不阻塞,再去寻找跟随对象并调整目标点。
- 对最终目标位置 target 的赋值方式进行统一:先设置为自己所处的格子,然后在非阻塞且有目标的情况下才更新为目标点的位置。
为了让 familiar 向目标点移动,我们保留了之前的加速逻辑(乘以目标位置距离向量),简化了处理方式,不再调整太多参数。
最后的效果是:
- familiar 会尝试移动到最近的可通行格子;
- 如果遇到无法进入的格子,会自动弹回自己当前占用的格子;
- 在可以移动时,会跟随主角或目标移动;
- 整体保持了一种浮动感,避免非法穿越,同时逻辑简单清晰。
我们计划通过进一步调试观察此逻辑是否如预期运作,并适当调整 spring back 和跟随机制的细节,使得 familiar 的行为既自然又合规。




调试器:进入 ExecuteBrain
中的 Type_brain_familiar
,调查 Familiar 为何不移动
我们注意到一个奇怪的现象:当前获取到的最近可通行格(traversable
)与 familiar 头部所占用的位置(head.occupying
)看起来引用是相同的 ,但其中一个却缺少有效的索引。这是个问题,因为我们依赖这个索引来标识该实体在世界中的位置状态。
我们进一步排查:
- 在默认情况下,我们是从附近的可通行格集合(vicinity)中第一个索引位置(index 0)开始的;
- 通过调用
get_closest_traversable
找到的格子,表面看起来与head.occupying
是同一个实体(引用相等); - 但当我们查看这两个对象的索引时,发现其中一个没有有效的 index;
- 这说明虽然它们代表的是同一个实体,但其中一个的索引字段并未被正确设置。
这就引发一个怀疑:是不是我们在某处构造这个对象时,没有给它设置 index?
于是我们深入查看 get_closest_traversable
的实现细节。我们发现:
- 在该函数中,确实没有设置返回对象的 index 字段;
- 这就解释了为什么我们获取到的 traversable 虽然引用是对的,但索引是空的;
- 这会导致后续判断(例如是否与当前占用位置相同)失败,进而影响到阻塞逻辑或位移逻辑。
所以问题的本质是:
我们在
get_closest_traversable
中虽然找到了正确的 traversable 对象,但是返回的 EntityRef 中缺少必要的 index 信息,从而在后续逻辑中与 head 的占用位置产生不一致。
解决思路是:
- 在
get_closest_traversable
中需要明确设置返回的 EntityRef 的 index; - 以确保后续的比较逻辑(包括阻塞判断、占用切换等)能够正确执行;
- 否则即便实体引用相等,由于索引为空,整个系统仍然判断它们不相等,造成错误判断。
我们计划调整 get_closest_traversable
的实现逻辑,确保其返回的实体引用包含完整的 index 信息,从而保持系统的一致性和稳定性。这样 familiar 的占用与实际位置匹配,阻塞判断、回弹和移动逻辑才不会出现误判。
修改 game_sim_region.cpp
,让 GetClosestTraversable
设置 Entity.Index
我们认为确实应该这样做,因为这样一来,如果我们试图让两个引用(例如 head.occupying
和 get_closest_traversable
返回值)都能正常工作,就可以始终确保它们的字段都被正确设置。具体来说:
- 我们确保
EntityRef
类型在使用时包含了完整的引用信息,包括指向的实体指针和对应的索引; - 如果两者引用的是同一个实体,但其中之一缺少 index 字段,那么它们在逻辑判断中会被误认为是不同的对象;
- 这在像
transactional_occupy
这样的逻辑中可能导致错误的判断,进而导致 familiar 无法正确地移动或者对阻塞状态做出反应; - 因此,在我们设置或返回 EntityRef 的地方,应始终明确填充其 index,以避免这种不一致性。
总结就是:
我们决定要修复这一点,确保所有关键引用都包含完整的元信息,这样在判断实体是否相同时就不会因为缺少 index 而出错。这是实现逻辑严谨性和可靠行为的基本保障。
运行游戏,观察 Familiar 是否开始移动
目前,我们的熟练体(familiar)只会在确认自己可以进入某个格子时才施加加速度;如果不能进入目标格子,它会施加一个反向加速度回到当前所占据的格子。这种机制基本运作良好,但仍存在一个问题:由于没有阻尼(damping),熟练体很容易由于惯性而越过目标点,导致振荡。
我们观察到的行为是:熟练体会在格子之间不断摆动,虽然最终会停下来,但在没有阻尼的情况下会永久震荡。因此我们需要引入更稳定的控制机制,使其像主角(hero)的 head spring 那样更有恢复性。
为了解决这个问题,我们考虑引入弹簧阻尼系统,使用一种称为**比例导数控制器(PD 控制器)**的方式(Proportional-Derivative Controller):
- 比例部分(P):基于当前位置与目标位置的差距,施加一个恢复力,引导对象朝向目标位置;
- 导数部分(D):基于速度或加速度的变化,抑制系统的震荡,实现更平滑的停止效果。
这种机制就像一个带阻尼的弹簧,如果我们把当前的位置看作一个质量块,它在朝目标位置运动过程中不会来回振荡很久,而是会迅速趋于稳定。
我们在主角的 head spring 上已经实现了这样的控制方式,因此在熟练体上也采用类似逻辑是合理的。下一步是将这种弹簧控制机制(带阻尼)整合进熟练体的加速度逻辑中,从而使其行为更稳定、自然,不再震荡漂移。
总结要点如下:
- 熟练体现在只有在目标格子可达时才施加加速度;
- 若不可达,会尝试回到当前格子;
- 没有阻尼会导致持续振荡;
- 需要引入带阻尼的弹簧逻辑;
- 采用比例导数控制器(PD 控制)建模运动行为;
- 主角已有类似控制模型,可复用结构与参数。
这样能确保熟练体在尝试移动时既具备响应性又保持稳定性,避免过冲与无限震荡。
黑板内容:比例微分(与积分)控制器
我们目前讨论的是比例导数控制器(Proportional-Derivative Controller,简称 PD 控制器或 PDC),这是一种常用于模拟弹簧和控制系统中物体运动的控制方法。PD 控制器的作用是根据当前位置与目标位置之间的差距,以及当前速度与期望速度之间的差距,计算出一个加速度,从而控制对象逐步趋近目标状态。
一、PD 控制器的基本原理
PD 控制器的数学形式如下:
位置的二阶导数(加速度):
a ( t ) = K p ⋅ ( x target − x ) + K d ⋅ ( v target − v ) a(t) = K_p \cdot (x_{\text{target}} - x) + K_d \cdot (v_{\text{target}} - v) a(t)=Kp⋅(xtarget−x)+Kd⋅(vtarget−v)
- a ( t ) a(t) a(t):物体当前的加速度
- x target x_{\text{target}} xtarget:目标位置
- x x x:当前实际位置
- v target v_{\text{target}} vtarget:目标速度
- v v v:当前速度
- K p K_p Kp:比例系数,控制"弹簧"拉力强度
- K d K_d Kd:导数系数,控制阻尼强度,防止震荡
这一公式中,加速度的计算源自于两个差值:一个是位置差,另一个是速度差。每个差值都乘以一个调节系数,分别决定弹簧拉回力和速度校正的大小。
目标是让这两个差值都接近零,即:
- 当前的位置靠近目标位置;
- 当前的速度靠近目标速度。
只有当这两个差值都为零时,系统才达到稳定状态,加速度为零,物体也不再运动。
二、为什么叫"比例导数"控制器?
命名的由来如下:
- "比例"部分(Proportional):指的是与当前位置误差成正比的控制项;
- "导数"部分(Derivative):指的是与当前速度误差成正比的控制项;
但这里提出一个值得思考的观点:实际上这里的"导数"项看起来更像是"速度差值"而不是严格意义上的导数,因此称为"导数"可能在某些情况下不够贴切。
我们甚至可以认为这是一个 "比例差值控制器(Proportional Delta Controller)" ,因为它控制的是状态之间的差值(Delta),不管是位置差还是速度差,而非严格数学意义上的导数。
三、与积分控制器的比较(PI 控制器)
另一种控制器是 PI 控制器(比例积分控制器):
- 它会累计误差,即在每一个时间步中都把当前位置与目标位置的误差累加起来;
- 随着时间推移,误差积累得越多,控制器产生的作用力越强;
- 这种方式在偏离目标太久时会产生更强的修正力,适用于消除持续的稳态偏差(steady-state error);
相比之下,PD 控制器更加侧重于即时响应和动态调整,而 PI 控制器更擅长处理持续的误差偏差问题。
四、总结要点
- PD 控制器基于当前误差进行快速调节,适用于快速趋近目标并抑制振荡;
- 加速度由位置误差和速度误差共同决定,分别由比例项和导数项控制;
- 名称中的"导数"本质上可以理解为"差值",因此也可类比为"比例差值控制器";
- PI 控制器通过累积误差强化调整力,适合纠正长期误差,但响应速度较慢;
- 实际应用中常使用 PID 控制器(比例-积分-导数)结合多种优势。
我们将在熟练体的运动控制中采用 PD 控制机制,使其运动过程既能迅速趋近目标,又能抑制震荡和来回漂移,提升整体行为的稳定性和自然感。
比例-微分控制器(Proportional-Derivative Controller,简称PD控制器)和PID控制器(Proportional-Integral-Derivative Controller)不是同一个东西,但它们有密切的关系。以下是详细的对比和说明:
1. 定义和组成
-
PD控制器:
- 由**比例(Proportional, P)和微分(Derivative, D)**两部分组成。
- 控制信号公式为:
u ( t ) = K p e ( t ) + K d d e ( t ) d t u(t) = K_p e(t) + K_d \frac{de(t)}{dt} u(t)=Kpe(t)+Kddtde(t)- K p K_p Kp: 比例增益
- K d K_d Kd: 微分增益
- e ( t ) e(t) e(t): 误差, e ( t ) = 设定点 (SP) − 实际输出 (PV) e(t) = \text{设定点 (SP)} - \text{实际输出 (PV)} e(t)=设定点 (SP)−实际输出 (PV)
- 没有积分(Integral, I)部分。
-
PID控制器:
- 由比例(P) 、**积分(I)和微分(D)**三部分组成。
- 控制信号公式为:
u ( t ) = K p e ( t ) + K i ∫ e ( t ) d t + K d d e ( t ) d t u(t) = K_p e(t) + K_i \int e(t) \, dt + K_d \frac{de(t)}{dt} u(t)=Kpe(t)+Ki∫e(t)dt+Kddtde(t)- K i K_i Ki: 积分增益
- 相比PD控制器,PID多了一个积分项。
2. 功能和作用对比
-
PD控制器:
- 比例(P) :根据当前误差 e ( t ) e(t) e(t) 快速调整控制输出,推动系统向设定点靠近。
- 微分(D) :根据误差变化率 d e ( t ) d t \frac{de(t)}{dt} dtde(t) 预测系统行为,减少超调和振荡,提高稳定性。
- 特点 :
- 响应速度快,稳定性好。
- 但无法消除稳态误差(Steady-State Error),即系统可能无法精确达到设定点,存在长期偏差。
-
PID控制器:
- 包含PD控制器的所有功能(比例和微分),并额外增加了积分控制。
- 积分(I) :通过累积误差 ∫ e ( t ) d t \int e(t) \, dt ∫e(t)dt,消除稳态误差,确保系统最终精确达到设定点。
- 特点 :
- 既能快速响应,又能消除稳态误差,同时保持稳定性。
- 但积分项可能导致超调或振荡,尤其在调参不当的情况下。
3. 适用场景
-
PD控制器:
- 适用于不需要完全消除稳态误差的场景,或者系统中已经通过其他方式(如机械设计)消除了稳态误差。
- 常用于对响应速度和稳定性要求高,但对精度要求不高的系统,例如:
- 某些机械系统的阻尼控制。
- 快速动态响应场景,如无人机姿态控制中的某些部分。
- 优点:结构简单,响应快,振荡少。
- 缺点:存在稳态误差。
-
PID控制器:
- 适用于需要高精度控制的场景,尤其是需要消除稳态误差的情况。
- 广泛应用于工业控制、机器人、温度控制等领域,例如:
- 激光二极管温度控制(文中例子)。
- 电机速度或位置控制。
- 优点:能实现快速响应、高精度和稳定性。
- 缺点:调参更复杂,积分项可能导致超调或不稳定。
4. PD控制器与PID控制器的关系
-
PD是PID的子集:
- 如果将PID控制器的积分增益 K i K_i Ki 设置为0,PID控制器就退化为PD控制器:
u ( t ) = K p e ( t ) + 0 ⋅ ∫ e ( t ) d t + K d d e ( t ) d t = K p e ( t ) + K d d e ( t ) d t u(t) = K_p e(t) + 0 \cdot \int e(t) \, dt + K_d \frac{de(t)}{dt} = K_p e(t) + K_d \frac{de(t)}{dt} u(t)=Kpe(t)+0⋅∫e(t)dt+Kddtde(t)=Kpe(t)+Kddtde(t) - 因此,PD控制器可以看作是PID控制器的简化版本。
- 如果将PID控制器的积分增益 K i K_i Ki 设置为0,PID控制器就退化为PD控制器:
-
实际应用中的选择:
- 系统中是否需要积分项取决于具体需求。如果稳态误差可以接受,或者系统本身有其他机制消除偏差,可以选择PD控制器,简化设计和调参。
- 如果需要精确控制(如温度、位置等必须严格达到设定点),则需要使用完整的PID控制器。
5. 文中例子的角度
文中提到,PID控制器可以通过选择性地使用P、I、D中的一个或多个部分来适应系统需求(例如P、PI、PD或PID)。因此:
- PD控制器是PID控制器的一种特定形式,仅使用比例和微分部分。
- 在激光二极管温度控制的例子中:
- 如果只使用PD控制器,温度可能无法精确达到设定点(例如设定25°C,但实际稳定在24.8°C)。
- 使用完整的PID控制器(带积分项)可以消除这个偏差,确保温度精确稳定在25°C。
6. 总结
- PD控制器和PID控制器不是同一个东西 :
- PD控制器只有比例和微分两部分,专注于快速响应和稳定性,但无法消除稳态误差。
- PID控制器多了积分部分,能够消除稳态误差,实现更高精度的控制。
- 关系 :PD控制器是PID控制器的简化形式,可以通过将PID的 K i K_i Ki 设为0来实现。
- 选择依据 :根据系统需求选择使用PD还是PID。如果需要高精度(无稳态误差),用PID;如果只追求快速响应和稳定性,且稳态误差可以接受,用PD。
在 game_brain.cpp
中设置 Familiar 的弹簧效果
我们之前已经详细讨论过相关内容,因此这里不再赘述。我们的重点是:在当前的控制系统中,我们需要引入一些额外的项来提升性能。首先,需要一个足够"硬"的弹簧(即较大的弹性系数),以确保系统具有足够的回复力,从而在偏离目标状态时能够迅速做出调整。
其次,我们在速度控制方面还需要引入一个负的速度反馈系数。其核心思想是,我们希望系统的目标速度为零,然后减去当前速度,从而得到一个负值作为反馈修正项。为了简化表达,我们可以直接将负号吸收入系数中,而不必额外计算"目标速度减当前速度"的差值。
因此,我们将速度项的系数设为一个负数,比如暂定为 -4.0,具体数值待后续通过调试(tuning)进一步确定。这样做的目的是在运动过程中引入阻尼效果,抑制系统的振荡,增强稳定性。
目前我们已经完成了初步设定,接下来会对这些参数进行调优,找出最合适的取值,使得系统在保持稳定的同时,具备良好的响应速度和误差修正能力。
运行游戏,查看弹簧效果是否生效
我们注意到当前这个小家伙的移动方式不太令人满意,虽然总体上它的行为是相对正确的。一个明显的问题是它的移动显得有些"粘人",也就是说它总是紧跟着我们,但最终会在中途停下来。这种停下来的原因是它抵达了某个中间点,而不是始终保持追踪。
目前我们实现了一个规则,它不会进入我们的方格,我们也不能进入它的方格,这一点是我们希望实现的。但整体表现仍然不尽如人意,这里面可能有几个原因。
首先,它出现来回振荡的现象,并不是因为弹簧机制,而是和"阻挡"的概念有关。当它试图前进时发现目标格子已被占用,于是被迫退回,而一旦退回到前一个格子又检测到当前格子是可通行的,于是再次尝试前进,这就导致了往返式的振荡。这种行为反映出当前系统的一个根本问题------我们没有机制让移动中的对象预判前方格子的状态。它们只有在到达之后,才知道那个位置是否可以占用。
理想情况下,我们希望这个小家伙在即将进入下一个格子前,能够预测那个格子是否被占用。如果发现被占用,那么它应当直接停在当前格子中,而不是试图进入目标格子后再退回来。要实现这一点,我们可能需要引入一个新的逻辑,用于提前判断预期移动方向上的下一个"可通行格"。
这样一来,当我们知道下一步想移动的位置已经被占据时,我们就不会尝试去移动过去,从而可以避免那种不必要的前后跳跃和振荡。同时,我们可以保留当前的"被阻挡逻辑"作为一种安全机制,以防有人在我们决策过程中突然进入了我们原本打算前往的格子。
具体地说,我们需要在做出位置更新决定前,预先判断这个"目标位置"是否是可通行的。如果不能通行,即使当前并没有被硬性阻挡,我们也不应该尝试前往。
接下来,我们准备将这些逻辑进一步整理,封装成一些辅助函数,以便后续在构建AI控制行为时能够更方便地复用这些逻辑。我们预期在今后的系统中会大量使用类似的预测与判断机制,因此将相关代码结构化处理是非常有必要的。



game_brain.cpp
中引入 TargetTraversable
概念,让 Familiar 朝目标可通行区域移动
我们想修改角色移动逻辑,使其更具预判性、避免来回振荡。在现有的逻辑中,我们会在已经尝试移动之后,才发现目标格子被占用,然后角色再回弹回来。这样会导致角色在目标与当前位置之间反复震荡。
我们希望通过以下步骤来优化这个过程:
1. 获取目标方向
我们已经有了头部当前位置 Head->P
,还有目标点 ClosestHeroP
。我们先计算出两者之间的差值 Delta
,也就是移动方向向量。
接着我们对这个向量进行归一化或零向量处理,用于获取单位方向。这里可能用到了已有的 NormalizeOrZero
这样的函数。
2. 预测下一步移动会进入哪个 traversable(可通行格)
我们用归一化后的方向向量,乘上一个固定的步长(暂定为1单位),相当于"朝目标方向移动一步"。
这样就能得到预测中的目标 traversable 区块位置。这个位置不是我们真的去,而是我们"假设"下一步会去那里,用于提前判断。
3. 判断目标 traversable 是否被占用
接下来我们检查该目标 traversable 是否被占用(occupied)。如果没有被占用,我们再真正地尝试向这个方向移动。
如果已被占用,我们就不去动它,让角色当前停留。这种方式就避免了盲目地前进再回弹回来导致的来回震荡。
4. 当前代码中缺失的部分
目前我们发现,系统中并没有一个直接判断 traversable 是否被占用的函数(比如 IsOccupied()
)。所以我们需要实现这个逻辑。
虽然判断格子是否被占用是显而易见的需求,但之前的系统似乎没有专门抽象出这类函数,这会是后面需要补充的工具方法。
5. 系统中"手动设定"的部分
这里提到了一些逻辑是"写死"的(baked in),例如:
- 默认前进一步是"1单位";
- 如何计算 traversable;
- occupied 判断方式。
这些我们后面都要做成更清晰、通用的接口(systematize),以便更方便地构建智能体行为。
目标效果
最终我们希望实现:
- 移动前预判;
- 只有当下一格未被占用时才移动;
- 避免抖动与振荡;
- 增强智能体的"感知性"和"预测性";
- 后续把这一类逻辑抽象成公共的工具函数,用于构建智能行为逻辑(brains)时更容易调用。
这部分改动是系统行为从"反应式"向"预测式"迈出的关键一步。
在 game_sim_region.cpp
中引入 IsOccupied
(是否被占用)逻辑
我们希望在判断某个格子是否被占用时能使用一个更简单、直观的方式,而不是手动去访问 occupier 成员或者其他底层实现细节。虽然判断某个 traversable 是否被占用是一个非常基础的操作,但我们仍然希望将它封装成一个统一接口,比如 IsOccupied()
,这样后期即使实现逻辑发生变化,我们也无需修改所有使用位置。
1. 简化判断方式
我们实现了一个新的封装函数,比如 IsOccupied(traversable)
,它本质上就是判断该格子的 occupier
是否为零。如果 occupier == 0
,说明没有对象占用这个格子,返回 false;否则为 true。
这样我们在主逻辑中就可以简单地写:
cpp
if (IsOccupied(targetTraversable)) { ... }
而无需重复写具体的判断条件。
2. 函数实现过程中遇到的细节问题
- 初始时因为少了宏定义,比如
32X
,导致了未定义的标识符错误(undeclared identifier destrF
)。我们补上了这部分定义。 - 接着程序仍然没有表现出理想效果,看上去像是角色没有移动足够距离。但从视觉表现上看,问题实际上相反------我们可能误写了判断条件逻辑,把"未被占用"误认为"已被占用"。
3. 逻辑修正后得到正确行为
我们发现之前的判断中方向反了,把空格当成了有物体占用。所以我们修正了这个判断的"正负逻辑"。
修正之后,角色开始表现得正常了:只有在目标 traversable 没有被占用时,才会前进;否则就会停在当前位置,避免了之前的跳动或震荡问题。
4. 结果验证
通过视觉调试,我们确认封装函数 IsOccupied()
生效了。角色的行为变得更加合理、自然,路径选择的逻辑更加清晰可靠。
还提到一个"Texture download"的提示,说明程序中其他图形相关部分仍然正常工作,也可能是在打印调试信息。
总结
我们成功封装了一个判断格子是否被占用的统一接口,使逻辑更具可读性和可维护性。并且通过修正逻辑判断方向错误,解决了实际表现与预期不符的问题。最终使角色移动逻辑更加健壮,为后续扩展打下了基础。


运行游戏,观察 Familiar 是否能正确跟随
我们需要让判断目标位置是否被占用的检测范围稍微超过一个单位距离。这是之前提到过的一个问题,目前只检测一个单位的距离是不够的。
具体来说,如果只判断向前移动一个单位后的格子是否被占用,可能会导致角色在某些情况下停得过早或者动作不够流畅。因此,我们希望能检测一个比单单位距离稍微远一点的位置,这样可以更提前地感知障碍物或占用状态,从而让角色的移动更加自然和合理。
这部分功能还没有完全实现,在修正之前,先说明这段思路,以便后续调整代码时能清楚目标是什么。简而言之,就是希望能扩展检测的距离,不仅仅局限于当前位置往前一步,而是能预判更远一点的目标位置是否可通行。这样有助于提升路径规划和避障的效果。
还是在震荡

修改 game_world_mode.cpp
,让 AddStandardRoom
随机偏移网格点位置
我们当前使用的是规则网格系统,但实际上没有任何限制必须使用规则网格。我们完全可以使用不规则的位置来放置元素,以此打破对网格的依赖。为了验证这一点并避免产生误解,我们进行了一个测试性修改。
在标准房间生成过程中,我们为其中的元素分配了可通行(traversable)属性,并设置了它们的世界位置(world position)。这些位置本来是固定的,但我们意识到完全可以在这些位置上增加偏移量来打破规则排列。例如,我们可以在X轴和Y轴方向分别添加一个随机的双边偏移(random bilateral offset),使得元素的位置有一定的浮动,从而不再严格遵守网格中心的排列。
事实上,我们早就已经在Z轴上实现了类似的偏移,现在只是将这个逻辑扩展到X轴和Y轴。初始尝试时偏移量设置得过大,导致画面效果异常,这并不是我们预期的行为。经过检查,我们发现是误将原始位置数据完全覆盖了,忘记加上偏移,而不是在原位置基础上累加偏移。修正后,我们将偏移改为"加法",使其叠加在原始坐标上,并将随机偏移的范围设定为最大0.25单位,以避免偏移过远。
这样做的目的,是为了演示系统并不依赖于固定网格的位置逻辑。我们可以通过灵活地调整实体生成的位置,让系统在视觉和逻辑上表现出更加自然、不规则的世界布局。这也为后续扩展非网格化的导航和交互方式打下了基础。

还是不对先看看有什么问题



运行游戏,看到不规则网格上的所有角色都能正常工作
在当前的系统中,我们其实并没有硬性规定地图必须是规则网格。我们的代码从一开始就并没有强制要求使用网格结构,所有的实体都可以在完全任意的空间位置上运作。唯一真正被严格要求的是:在任意时刻,每个可通行的位置只能有一个实体占据。虽然我们目前可能暂时不会利用这个特性,但也不确定未来是否需要用到它,因此我们希望保留这种灵活性,而不是过早放弃。
我们希望将来可以构建一些拥有不同拓扑结构的地图,所以保留这种"非网格化"能力是很有必要的。这也意味着我们不能依赖某种"逻辑上的邻近关系"来判断某个方向上的"下一个格子"在哪。因为在当前系统中,根本不存在所谓"左边"或"右边"的格子------没有严格的格子存在。这些点是几何位置,而不是逻辑顺序的网格单元。
因此,我们必须以几何的方式定义"邻近点"。比如我们可以说:给我一个方向,在那个方向上搜索,看在多远的距离内会遇到一个新的可通行位置,这个距离不能太近(避免检测到自己),也不能太远(避免跳过目标点)。
我们的思路是通过"方向投射(cast)"来确定要前往哪个下一个点。例如,我们要判断向左走会走到哪里,就需要从当前位置往左做一个一定距离的投射,找到第一个可以被认为是"下一个"的点。
不过目前阶段我们暂时不会专门编写一个"get_point_in_direction"的查询函数,因为现在的元素布局还算比较规则,我们可以暂时依赖这种规律来实现所需行为。但在未来做空间查询时,显然需要定义一个更通用的"按方向查询点"的逻辑。
总结来说:
- 我们系统中位置是基于几何坐标的,而不是基于规则网格;
- 所有实体只能独占某个位置,不能共用;
- 无法用"左边/右边"这种逻辑关系来判断下一个点,而必须通过方向+距离来几何投射;
- 当前为了灵活性,我们不打算放弃这种自由布局的特性;
- 未来可能需要更健壮的"方向投射式"空间查询函数来适配更复杂的地图结构和导航逻辑。
在 game_sim_region.cpp
中引入 GetClosestTraversableAlongRay
(沿射线寻找最近可通行点)
我们目前正在设想实现一种新的查询方式,替代现有的"获取最近可通行点"的方法。现有的方法是基于当前位置直接获取最近的可通行位置,但我们希望引入一种新的逻辑:从当前位置沿着某个方向发射射线,并获取射线路径中遇到的第一个可通行位置。虽然现在我们还不会正式实现它,但我们想先设计出这个接口,这样在未来真正需要实现时,我们可以知道哪些地方已经在使用它,便于后续替换和优化。
因此,我们提出了一个假设函数叫 GetClosestTraversableAlongRay
。这个函数的目标是:
- 在一个模拟区域中,从一个起始点出发,沿着某个指定方向发出一条"射线";
- 沿着这条射线采样多个点;
- 找到沿这条射线第一个遇到的可通行点;
- 跳过起始点自身,以免误判当前点为"下一个"点;
- 虽然目前我们还未进行优化,只是用一种相对低效、粗略的方式实现采样逻辑,但这些后续都可以进行加速处理。
我们目前打算复用已有的、较慢的点查询逻辑做一个简单的占位实现,只要可以实现基本功能即可,性能暂不考虑,后续会重构并优化。
此外,由于我们要沿着一条射线去查找目标,而不是简单最近点查询,我们还得特别处理一个问题:如何正确"跳过"当前位置,避免返回自身作为目标。这是因为在连续采样过程中,第一个返回的点很可能是当前所在位置,因此我们需要加入一些逻辑来跳过当前格子或者做一些排除判断。
接下来我们计划在实际代码中调用这个新函数名,虽然暂时还没有真正实现它,这样我们可以清晰地知道它的用途,并在后续需要真正实现时迅速定位调用处和逻辑影响。
总结如下:
- 我们希望支持一种"朝某方向查询第一个可通行点"的新方式;
- 这比"找最近点"更加符合智能导航的思路;
- 尽管现在我们不会立即优化它,但会先设计接口、写个基础实现;
- 必须跳过当前所在点,避免错误判断;
- 未来这些射线查询逻辑将成为空间导航功能的重要组成部分;
- 所有这些操作都是为了支持灵活地图和更智能行为决策。

在 game_brain.cpp
中让 ExecuteBrain
使用 GetClosestTraversableAlongRay
控制 Familiar
我们正在实现一个新的函数,用于沿着射线方向寻找可通行点。这个函数比原来直接获取"最近可通行点"的方法更加智能和灵活,适用于当前不处于规则网格的系统结构。
我们设想该函数 GetClosestTraversableAlongRay
接收以下参数:
- 当前模拟区域(sim region)
- 起始位置(head P)
- 方向向量(Delta)
- 一个用于跳过的目标,即不希望返回的可通行点
我们希望它能做以下几件事:
- 不要返回当前位置所在的可通行点,这是为了防止因为自身刚好就是一个可通行点而被错误返回。
- 不需要过滤被占用的可通行点,因为我们正是要检测目标是否被占用,所以要保留这些信息。
为实现这个目标,我们使用如下策略:
- 设置一个循环,用于在射线上探测多个采样点(例如最多采样5次,探测深度递进);
- 每一次探测都通过起始点加上方向向量乘以步长来获得一个新采样点;
- 步长暂定为0.5单位,这样采样更密集;
- 对每一个采样点,调用现有的
GetClosestTraversable
方法; - 如果返回了一个可通行点,我们还需要判断这个点是不是我们打算跳过的那个(也就是当前位置所对应的那个);
- 如果返回的结果不是跳过的点,则说明找到目标,标记为已找到并退出循环;
- 如果没找到,则继续采样直到探测完毕。
整个过程总结如下:
- 利用方向向量,从当前点出发向外逐步采样;
- 每个采样点调用可通行点查询逻辑;
- 跳过起始点代表的通行点,避免误判;
- 一旦找到合适结果就立即停止;
- 此方法目前为简化实现,后续可根据需求优化采样策略与性能。
这个新方法将作为临时版本使用,为未来引入更高效、更结构化的空间查询逻辑(如射线投射、空间索引等)打下基础。我们当前的目标是确保在非规则地图结构下,依然可以灵活地向任意方向查找下一个可通行单元,不依赖严格的网格约束。


运行游戏,Familiar 表现更稳定
现在角色的运动方式更加稳定了,表现也更加自然,不再出现之前那种奇怪的停顿行为,除非靠近我们需要停下来时才会出现短暂停止。
接下来我们继续测试,把更多的角色放入场景中,以观察整体行为和稳定性。同时我们也意识到一个改进点:希望能观察到这些角色在什么时候认为自己被阻挡了,以及他们的阻塞状态,这对于调试行为逻辑非常重要。
目前这些角色分散地留在地图上,也带来一种有趣的视觉效果。不过时间所剩不多,因此我们准备进行一些收尾工作,优先处理当前能完成的部分。
一个接下来的小目标是增强可视化调试功能,例如:
- 显示某个角色是否处于被阻挡状态;
- 在屏幕上标注他们的阻塞原因;
- 更方便地跟踪他们的路径决策和状态变化。
虽然这些功能暂时还没有完全实现,但已确定其必要性,并计划在后续进一步加入。我们希望通过这些增强手段,更清楚地掌握角色的移动状态与行为逻辑,提升整体系统的可控性和可维护性。
在 game_sim_region.cpp
中为 GetClosestTraversable
和 GetClosestTraversableAlongRay
添加 TIMED_FUNCTION
性能标记
我们现在关注的重点是想了解某些函数实际运行时消耗了多少时间。之前已经为此写了一些工具,不过具体的函数名记不太清了,可能是 TimeFunction
之类的。我们的目标是给某些函数加上时间统计,查看它们在运行时的性能开销。
我们决定前往 sim_region.cpp
文件中查看和设置这些时间统计的调用,因为我们怀疑程序中大量的性能损耗其实是来自这些空间查询(spatial queries)。这些查询往往会遍历大量数据,因此速度很慢。
虽然目前还不能确定,但我们的直觉是,正是这些空间查询函数消耗了程序的大部分时间,因此希望通过加上时间分析来验证这一点。一旦加上 TimeFunction
,我们就能从性能分析报告中确切地看到这些函数所占用的时间比例,从而指导我们接下来的优化方向。

运行游戏并查看性能分析器
我们在性能分析中观察到,大约 15% 的时间都消耗在 GetClosestTraversable
函数中。这非常符合预期,因为该函数执行了大量的空间查询操作,而这类操作本身效率低下、计算密集。虽然目前函数逻辑较为简单直接,但这种结构很适合进行高效优化,提升潜力非常大。
从另一个角度看,这是件好事:因为这意味着瓶颈集中在一个易于识别和隔离的地方。针对该函数,我们可以引入更高效的空间数据结构(例如空间哈希、网格划分、四叉树等)来加速邻近对象的查找,理论上可以将函数执行时间降低到几乎可以忽略不计的水平,从而显著提升整体模拟效率。
与此同时,我们注意到 BeginSim
函数也消耗了大量的处理时间。目前尚不完全清楚其具体的性能瓶颈位置,不过初步判断是在执行数据解包的过程中,因为其中存在三重循环和对大量数据块(blocks)的逐一处理。在这些处理逻辑中,大量结构体被复制、移动,很可能是性能开销的主要来源。
为验证这一点,计划进一步对 BeginSim
的内部执行流程进行精细的时间标记,将整个函数过程拆分为更小的片段进行单独计时。重点关注:
- 三重循环中的迭代次数和内存访问模式
- 数据块的解包过程是否涉及大量结构体复制或内存分配
- 是否有重复无效的操作或可以重构的流程
通过这些更细致的观察,可以找出具体耗时点,并进一步评估是否可以通过数据结构优化、使用引用代替值传递、减少缓存未命中等方式进行性能优化。
总的来说,目前定位到的两个主要性能瓶颈都比较明确,其中 GetClosestTraversable
的优化空间更大,且改动相对独立易控,是非常理想的优化目标。BeginSim
虽然复杂一些,但其内部结构也比较清晰,后续分析后同样有望进行结构化优化,从整体上提高系统的模拟效率。
在 game_sim_region.cpp
中引入 GetClosestEntityWithBrain
和 closest_entity
(最近带 AI 的实体)
我们实现了一个新的功能:在模拟区域中查找最近的、具有特定脑类型(brain type)的实体。这个查询功能通过一个叫 GetClosestEntityWithBrain
的方法来实现。我们提供一个起始点(位置)、一个模拟区域、以及目标脑类型作为输入,然后返回一个结构体,包含查询结果的各项信息。
为了提高函数的实用性,我们还加入了默认参数。默认情况下,如果调用者不指定搜索半径,我们使用一个默认值(例如20米),这样调用方在不关心搜索范围时也可以直接使用函数,保持接口简洁。
在结果结构中,我们不仅返回是否找到目标实体(found),还包含了:
- 找到的实体引用(entity)
- 距离平方(distance squared)------这个在很多逻辑中非常有用,可以避免额外的平方根运算
- 位移向量 Delta ------ 从当前点到目标实体的向量差,这样使用者就无需再做重复的向量计算
在查询过程中,我们遍历模拟区域中的所有实体,对每个实体计算其与起点的 Delta 向量和距离平方,并根据这些值判断是否更新当前最优(最近)结果。所有计算都被明确地保存下来,以便复用,避免重复计算浪费性能。
此外,为了代码清晰和避免表达式嵌套引起重复计算,我们显式地计算了 Delta 向量,并将其单独保存,而不是嵌入在某个 if 判断或函数调用中。
当前版本还比较基础,未来可能需要扩展查询方式的灵活性,例如允许使用更复杂的匹配规则或过滤条件。但由于 C++ 的闭包传参机制并不简便(尤其在没有使用现代 lambda 的情况下),我们暂时没有引入通用的谓词接口,以避免引入不必要的封装开销和语法复杂性。
总体而言,这个功能实现了灵活且高效的空间实体查找机制,具备良好的扩展性和性能优化空间,同时提供了简洁的接口供上层逻辑调用,后续可根据需要进一步扩展查询粒度和筛选方式。

在 game_brain.cpp
中让 Familiar 使用 GetClosestEntityWithBrain
实现更智能的追踪
我们将空间查询的调用方式做了优化,原先在执行一大段逻辑来查找具有特定"脑类型"的实体,现在将这部分逻辑提取出来,通过调用一个通用函数 GetClosestEntityWithBrain
来完成。
我们传入模拟区域(sim region)、当前位置(head P)和想要寻找的脑类型(brain type),然后函数就会返回最近符合条件的实体及相关数据。使用这个函数之后,原来的代码就能大幅简化,不需要再重复写一堆循环和判断逻辑。
我们进一步清理了代码结构:
- 不再需要自己计算目标实体的位置偏移(delta),因为
GetClosestEntityWithBrain
已经把这个值作为返回结果的一部分。 - 如果函数返回一个实体,我们就直接使用它,不需要检查额外的"found"标志位,因为只要返回的实体非空,就说明找到了。
- 原来为了排除重复或处理状态而引入的一些判断逻辑,也可以取消,比如 ray stamping 相关的标记,因为新的查询方式已经简化了流程,不再依赖那些中间状态。
因此,最终我们只需根据返回结果进行方向归一化(normalize),然后使用这个方向进行后续的导航或决策。通过这种方式,我们将空间感知与实体决策代码解耦,大大提高了可读性和重用性,同时也为未来的维护和优化打下了基础。




运行游戏,Familiar 行为表现良好
我们对代码进行了整理和简化,现在通过封装好的工具函数来处理查找特定"脑类型"实体的逻辑。这个新的通用查询工具极大地提升了代码的清晰度和可维护性:
- 提供了一个统一接口,可以用于查找任意类型的目标实体,不再需要手动重复编写循环查找逻辑。
- 封装的结果结构中包含了是否找到目标、目标实体本身、与当前位置的偏移量(Delta)以及平方距离等信息,这样其他模块在使用这些数据时可以直接复用,无需重新计算。
- 通过设置默认的搜索半径参数,也方便了不关心具体范围的调用者。
- 所有原先零散实现的功能,例如计算偏移、判断最近距离等,现都整合到一个地方,便于集中优化和未来维护。
- 此外,之前多余的变量、状态判断逻辑也被清除,使主逻辑更简洁、更聚焦于行为本身。
借助这个查询系统,我们可以很方便地在模拟区域中定位具有指定智能行为的实体,并对其作出决策或交互反应。这种结构化的改进,不仅提高了整体代码质量,也为后续扩展新功能或提升性能提供了清晰的切入点。至此,这部分的重构已圆满完成。
进入问答环节
对于实体系统的多线程处理有什么想法?
我们在讨论将能量系统进行多线程化的可能性时,首先明确了一点:是否能多线程以及该如何多线程,取决于我们具体想并行化的部分。
我们识别出两个方向:
-
明确并行的目标任务
不是所有能量系统的逻辑都适合并行。例如,如果某些能量计算之间存在高度依赖性(比如状态更新彼此依赖),那么强行并行可能引发竞态条件或同步复杂度过高。因此,需要优先分析哪些部分是数据独立、可并发执行的。典型的例子可能包括对多个实体独立能量状态的评估更新。
-
并行粒度与同步策略
如果我们打算并行更新多个实体的能量状态,我们可能需要引入某种任务分发机制,将实体划分为批次分配给不同线程进行处理。这要求我们在设计时控制好内存访问,避免多个线程同时访问或修改同一内存区域。也就是说,在这些更新中需要做好同步,可能需要使用互斥锁、原子操作或线程局部存储等手段,或者通过任务分区避免交叉访问。
此外,我们还注意到性能的提升不一定线性,要根据系统瓶颈来决定是否值得并行化。如果能量系统本身耗时较少,多线程反而会引入不必要的管理成本。
总结来说,我们准备进行如下步骤:
- 识别系统中耗时较多、数据互不依赖的处理逻辑。
- 将这类任务划分为线程安全的批处理单元。
- 使用任务系统或线程池结构来分发计算任务。
- 控制数据共享和同步开销,防止并发冲突。
- 在具体实现之前,做好性能分析,以判断并行化是否带来实质性收益。
整体上,这种多线程策略的核心是"目标明确,数据独立,同步可控"。只要把握住这几点,能量系统就有可能通过并行处理获得可观的性能提升。
使用 ++ 和 += 减少内存使用......
我们目前在思考如何对系统进行多线程优化,归纳起来主要考虑两个方面:
一、多线程优化当前实体模拟(entity sim)
我们认为暂时没有必要对当前的实体模拟进行多线程加速,原因如下:
- 当前性能尚未成为瓶颈:我们目前在一个模拟区域内运行了数百个实体,但在Release模式下,它几乎没有在性能分析中留下痕迹,说明它运行非常快。
- 尚未进行任何优化:实体模拟系统还没有进行过系统性的优化,现阶段对其进行并行处理属于"过早优化",可能效果有限且得不偿失。
- 当前硬件性能较强:我们运行的硬件性能并不差,实体模拟的负载目前完全可以承受。
因此,我们认为现在将实体模拟进行多线程化并不是一个明智的投入方向,至少在系统中其他更耗时部分优化之前不值得优先考虑。
二、在世界范围内对远离玩家的区域进行并行模拟
这是我们更感兴趣的并行方向,原因如下:
-
SimRegion是并行隔离的:每个SimRegion的更新完全在其自身作用域内进行,不会影响其他区域。这意味着可以很容易地将不同的SimRegion交由不同线程处理而无需担心数据竞争问题。
-
线程安全设计已初步具备:当前系统的设计本身就考虑到了可扩展性,尤其是多线程方向。只需要对以下少数部分做线程安全处理:
- ID分配(如获取新实体ID等)需确保使用原子操作。
- 某些可能使用普通加法的逻辑需要替换为原子加法(例如对全局统计变量的修改等)。
-
有助于世界扩展性:当世界中存在多个远离玩家的位置同时需要进行模拟更新时,可以将它们分配给多个工作线程,实现"真正的并行模拟",这对性能提升非常有帮助。
三、未来可能需要并行的部分(例如AI)
目前我们还未遇到这类情况,但预判到将来可能会存在某些非常重型的AI计算或路径规划(如复杂导航网格查询等),这类运算可能不能简单地放进主线程,届时可能会考虑将这些AI行为拆分到独立线程进行处理。
总结
我们当前的多线程策略可分为几个阶段:
- 短期内不对当前实体模拟并行处理,因为它尚未成为瓶颈。
- 中期将系统结构设计成多SimRegion并行更新模型,这是可行的,也是当前架构支持的。
- 长期考虑将高耗时AI逻辑分离为异步处理,根据具体需求评估是否需要。
总的来说,我们已经具备了向多线程扩展的基本条件,并在架构上有意识地避免了线程干扰,为未来的高并发处理打下了良好基础。
你还要被纹理上传问题坑几次才会修它?
我们遇到了一些问题,具体表现为在修复之前会先被问题影响,比如出现渲染纹理上的问题,但现在并不适合立刻去修复这些问题。原因在于当前处于开发过程中的关键阶段,没有时间去处理这些细节故障,因为修复这些问题可能会打断正在进行的工作流程。尽管如此,这些问题并不会导致系统崩溃或影响整体运行,只要关闭程序然后重新启动就可以暂时规避。预计这些问题会等到以后重新回到渲染相关工作时才会被正式解决。目前的策略是保持整体工作的连贯性和稳定性,避免在关键时刻去修补非致命的细节问题。
你在调试显示中是否有模拟区域内最大实体数?
。在模拟区域开始时(begin sim),理论上在整个流程结束时应该能够调用一个编辑账户的操作,但目前还不确定这个值具体会是什么。通过实际测试,当前数值大约是740,当走到有更多对象的地方时,数值会涨到1121。令人惊讶的是,现在的机器性能非常强大,即使在发布版本中,对1100多个实体每秒运行16帧的处理,几乎没有在性能分析中显现出明显的负担。这主要是因为我们做的其实并不是传统意义上的数据打包解包,而是块复制操作,这种操作效率极高。现代计算机的速度远超我们的预期,这也是为什么网络速度常常显得很慢而令人不满的原因。整体来看,计算机处理速度非常快,尽管用户体验中有时候感觉不到。最后,目前没有更多关于编码的疑问。