游戏引擎学习第277天:希罕实体系统

[复制链接]
发表于 昨天 23:30 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
回顾并为本日定下基调

上次我们竣事的时候,基本上已经控制住了跳跃的部分,达到了我想要的效果,如今我们主要是在等候一些新的艺术资源。因此,等新艺术资源到位后,我们可能会重新处置处罚跳跃的部分,因为如今的跳跃感觉已经做得差不多了,只是艺术资源还没有完全到位,以是我把它缩小并且按需求举行了奇怪的缩放,实际上这是我希望或需要艺术资源最终出现出来的样子。正如我所说,这个问题最终需要通过艺术资产来解决,但目前的感觉还是挺不错的,游戏的可玩性也以为还算合理。固然如今很难做最终评估,因为还没有真正与游戏互动过,但至少目前的状态足够让我感觉可以继续推进游戏的其他部分,逐步完成。
接下来我们要做什么呢?我想我会动手推进实体系统和世界模式的结构。一直以来,它们都是一个乱七八糟的地方,我们就把各种东西都放进去,反正做什么都行,不太管它。但如今我们需要开始让这些部分变得更实际一些,至少要把逻辑部分提升到我以为更合适的系统层次。以前我们一直没有认真处置处罚实体系统,但如今是时候认真去做了。
另外,我还想控制好渲染部分。如今我大致知道了我们将如那边理场景层次的合成问题,因此希望可以大概清算掉不必要的Z轴以及其它相关内容。接下来,我打算将代码分成几个步调来处置处罚。起首,我会从清算实体处置处罚开始,重点改善整个逻辑部分,将其整理到一个更合理的状态。这包括实体存储的方式,怎样打包息争包这些数据等。我会对这些部分举行一次彻底的检查。
然后,我打算切换到渲染部分,确保渲染效果正常,尤其是房间怎样渲染的问题。我希望可以大概把渲染部分做得更加完满,确保这一块已经稳定后,再回头进一步完满实体系统的部分。接下来我们将会合精力处置处罚像世界生成、AI、游戏玩法等内容,这部分的代码可能不太有趣,因为它比较随意,也不太涉及到核心问题,但这些都是必须做的工作。
总的来说,这是接下来的工作筹划。目前我打算花些时间评估一下当前的进展,并和大家讨论一下我的筹划。我知道这个筹划听起来有点疯狂,但我会详细地告诉大家接下来的方向。
黑板:实体系统

实体系统有很多种实现方式,根据我的明白,实际上并没有所谓的“尺度”或“最佳”的方式,也没有某种“圣杯”式的实体系统设计方法,一旦采用就能确保一切都能顺利运行。我从未见过如许一种方法可以解决所有问题。人们就这些问题睁开争论,涉及到面向对象的部分,也有很多关于组件和实体组件系统(Entity Component System,ECS)等的讨论,这些只是一些术语,但很多时候这些方法实际上并没有深入发展。并不像编译器理论那样有清晰的定义,实体系统到底是什么、它的作用是什么、实现这些系统的不同方法有哪些以及它们的复杂性怎样,这些都没有被很好的规范化,整个领域看起来更像是一种黑暗艺术。
很多时候,人们之以是会在这些问题上争论,是因为他们喜欢就某个方法是否有效睁开激烈讨论,大概某些人声称某种方式是最好的,而其他方法则不可取。但是,在这些争论中,只有少数情况下有人能提供理论依据或证明来支持他们的观点。大多数时候,人们只是随口说说而已,这些实体系统的设计方法大多也并没有得到深刻验证。
已往,很多人采用过不同的方式来实现这些系统。比方,有一种方式看起来像是传统的面向对象编程(OOP)。这种方式可能会定义一个基类,表示一样寻常的实体类型,然后在这个基类下再创建一些详细的类,比方“兽人”类、”人类“类等。这些类在继续关系中层层分类,比方“王子”类、”骑士“类等。但这种做法如今险些没有人以为是一个好的方法了,缘故原由在于,大多数游戏中的实体并不像传统面向对象编程那样有严格的分类。游戏中创建的单元或实体通常会从多个地方借用不同的特性。举个例子,“巫师”和“王子”之间可能有一些共用的代码,而“骑士”和“巫师”之间也可能有一些共用的代码,但“骑士”和“王子”之间通常没有共用的代码。
这种类层次结构的设计方式通常会遇到问题,尤其是在游戏中的单元具有多重特性时。很少有“王子”的代码是独立的,大概“巫师”代码是完全独立的。大多数情况下,它们是基于某些通用的实体代码库实现的。因此,尝试把这些实体分别到严格的类层次结构中往往是行不通的。以是如今,人们在设计实体系统时,更倾向于采用更加灵活和松散的结构,避免严格的继续体系。
为了更好地说明这个问题,我们可以通过几个详细的游戏实体例子来展示传统的面向对象设计和今世更灵活的设计方式怎样运作。
传统面向对象设计的例子

假设我们正在开发一款角色扮演游戏,游戏中有多个类型的角色:王子、巫师、骑士等。在传统的面向对象设计中,可能会有如下的类层次结构:

  • Entity (实体)
    这是所有游戏实体的基类。
  • Character (角色)
    继续自Entity类,表示所有的角色。
  • Prince (王子)
    继续自Character类,表示王子角色。
  • Knight (骑士)
    继续自Character类,表示骑士角色。
  • Wizard (巫师)
    继续自Character类,表示巫师角色。
然后,你可能会为这些角色添加各种功能,比如攻击、移动等。

  • 王子可能有自己的特殊技能(比方领导力)。
  • 骑士有攻击力和防御力。
  • 巫师可能有魔法技能。
但问题是,很多时候这些角色之间会有重叠的行为。比方,“王子”和“巫师”可能共享一些类似的能力,如“魔法攻击”,而“骑士”也可能使用剑举行攻击。但按照传统的面向对象设计,所有这些重复的行为都必须在各个类中举行实现,即使它们有很多共同的代码。
问题:这种继续结构会导致代码冗余和不灵活。假设“骑士”与“巫师”共享某些技能,而“王子”又有一部分技能可以与他们共享。那么我们就碰面临“代码重复”和“类之间强耦合”的问题。
今世的组件化设计 (Entity-Component-System)

在今世的组件化设计中,我们不再用传统的继续来表示不同角色的关系,而是将功能和属性拆分成多个组件,每个组件负责一部分行为。比如:

  • Entity (实体)
    这是一个底子的实体,什么都不做,只是一个标识。
  • Components (组件)

    • HealthComponent (健康组件):管理实体的生命值。
    • MovementComponent (移动组件):负责实体的移动。
    • AttackComponent (攻击组件):处置处罚攻击行为。
    • MagicComponent (魔法组件):处置处罚魔法技能。
    • LeadershipComponent (领导力组件):王子独有的能力。

  • Systems (系统)

    • MovementSystem (移动系统):管理所有具有MovementComponent的实体的移动。
    • CombatSystem (战斗系统):管理所有具有AttackComponent的实体的攻击行为。
    • MagicSystem (魔法系统):处置处罚所有具有MagicComponent的实体的魔法行为。

在这种设计下,王子巫师骑士都可以被看作是不同的实体,每个实体根据其特点组合不同的组件:

  • 王子:一个实体,可能有HealthComponent(健康)、MovementComponent(移动)、AttackComponent(攻击)、LeadershipComponent(领导力)。
  • 巫师:一个实体,可能有HealthComponent(健康)、MovementComponent(移动)、AttackComponent(攻击)、MagicComponent(魔法)。
  • 骑士:一个实体,可能有HealthComponent(健康)、MovementComponent(移动)、AttackComponent(攻击)。
长处

  • 组件化设计使得角色的行为更加灵活,可以根据需求动态地组合不同的组件,而不需要为每个角色创建大量的子类。
  • 代码复用性更高,因为行为(如攻击、魔法、领导力)是被拆分到组件中的,不同角色可以共享这些组件。
  • 系统与实体的解耦,使得程序更加模块化。每个系统独立管理特定的功能(如战斗、移动),不需要关心实体的详细类型。
总结
通过采用组件化设计,我们避免了传统面向对象设计中类继续层次过深、行为重复的问题。在组件化系统中,实体只是数据容器,行为通过组件和系统来定义,这使得代码更加简洁、灵活,并且更容易维护和扩展。
黑板:当前的组合方法

在很多情况下,传统的面向对象编程(OOP)方法逐渐被更加组合化的方法所取代。组合化方法与传统的继续关系不同,它更偏重于将功能模块化,并且通过组合多个组件来实现复杂的行为,而不是通过继续层次结构来建立类之间的关系。
传统的继续方法的问题

在传统的面向对象设计中,通常会通过继续关系来定义不同类型的实体。比方,假设我们有多个角色类型:王子、骑士、巫师等。这些角色共享一些共同的属性和行为,比方“健康值”、“攻击力”等。这些共同的行为通常会被放入父类中,然后子类(如王子、骑士、巫师)继续这个父类,来复用这些通用的行为和属性。
然而,这种方法会遇到一些问题。当不同的角色之间只需要共享部分行为时,继续层次会变得冗长和复杂。比方,某些角色可能会共享“攻击力”,但它们的攻击方式却不同。假如将所有这些功能放入继续体系中,代码将变得难以维护和扩展。而且,继续关系过于紧密,导致了灵活性不敷。
组合化方法的上风

相比之下,组合化方法通过“组合”多个组件来定义一个角色的属性和行为,而不是使用继续。详细来说,每个角色(如巫师、骑士、王子等)都不直接继续自一个父类,而是通过包含多个不同的组件来描述其特性。
举个例子:

  • Necro(巫师):可能拥有多个组件,比方:

    • Burnable(可燃组件):这个组件使得巫师可以被点燃并受到火焰伤害。
    • Health(健康组件):巫师的生命值。
    • Magic(魔法组件):巫师的魔法能力。
    这些组件可以通过组合的方式,描述巫师这个角色的各项特性。通过组合,巫师的行为变得更加灵活和模块化,不必通过继续来定义一个固定的层级结构。

组件化的方式

在这种方法中,每个角色大概实体都被看作是由多个组件(比方健康、攻击、魔法等)组成的。这些组件可以是独立的模块,通过组合来赋予角色特定的行为。比方:

  • 王子:可以拥有Burnable(可燃组件)、Health(健康组件)、Leadership(领导力组件)等。
  • 骑士:可以拥有Health(健康组件)、Attack(攻击组件)等。
  • 巫师:可以拥有Health(健康组件)、Magic(魔法组件)、Burnable(可燃组件)等。
如许做的好处是,可以更加灵活地组合这些组件。每个角色可以根据需要选择并组合不同的组件,而无需通过复杂的继续关系来定义它们。
结果

通过组合不同的组件,我们可以避免传统面向对象设计中的继续层次问题。每个组件都是独立的、可复用的功能模块,可以在不同角色之间共享和组合。如许不仅避免了代码重复,还提高了系统的灵活性和可扩展性。每个角色的行为和属性可以通过组件的组合来定义,而不需要依靠复杂的继续层次结构。
总结来说,组合化的方法通过更加模块化和灵活的设计,解决了传统面向对象设计中的一些问题,使得代码更加易于维护和扩展。
黑板:Looking Glass的Act / React模子

在很多系统设计中,实体系统的实现方法有很多种,其中一种是“行为反应模式”(Act-React Model),这种方法最初由Looking Glass公司在《The Dark Project》游戏中提出。它与“结构化数组”(SOA,Structure of Arrays)和“数组结构”(AOS,Array of Structures)有一些相似之处,但也有独特的处置处罚方式。
行为反应模子与数组结构方法的类比

起首,回顾一下“结构化数组”与“数组结构”的区别。在传统的“数组结构”(AOS)中,像一个极点(vertex)通常会有一个包含多个属性的结构体,比方一个包含X、Y、Z和W坐标的结构体。每个数组的元素都是这种结构体的一个实例,如许做可以直观地将极点的所有信息存储在一个结构体中,并且每个极点的所有属性都在同一个地方。
但是,当我们需要批量处置处罚这些数据时,例如同时操作所有极点的X坐标、Y坐标等时,使用“结构化数组”的方法会更有效。在这种方法中,极点的每个属性(如X、Y、Z坐标)都会分别存储在不同的数组中。通过这种方式,处置处罚器可以更高效地并行处置处罚相同类型的数据。
行为反应模式的核心头脑

行为反应模式采用了类似的思路,但其核心是将实体的状态和行为分开处置处罚。详细来说,在这个模式中,游戏中的每个特性(如可燃性、健康值等)都会存储在独立的“表”中,而不是将这些信息存储在单一的实体结构体中。比方:

  • 可燃性表:记载所有可燃对象的状态。
  • 健康值表:记载所有实体的健康值。
每个表都独立存储特定的属性,每个实体的状态可以通过一个唯一的ID来索引和查找。如许,系统可以分开处置处罚每种属性的更新,比如“可燃性更新”和“健康值更新”是分开举行的。如许做的好处是,系统可以更加高效地处置处罚每个独立的属性,避免了复杂的结构层次和冗余数据。
多表数据库的类比

这种方法本质上类似于一个多表数据库。在数据库中,每个表都存储不同类型的数据,查询时可以通过ID等标识符来获取相应的数据。在游戏中,这意味着当需要查询某个实体的状态时,可以通过查找对应的表格来获取信息,而不必访问整个实体的所有属性。
解决复杂的游戏规则问题

在设计游戏时,尤其是面临复杂的互动规则时,这种方法非常有效。因为游戏设计通常需要非常灵活和多变的规则。比方,一个角色可能会在火中受伤,但这个伤害的水平可能取决于该角色是否拥有某种特殊的抗火能力。别的,火灾可能会影响角色的健康,而角色的健康状态也可能影响他是否能在火中存活。
这种灵活的规则设计非常具有挑战性,尤其是当这些规则相互交织时。游戏的开发者需要考虑如那边理这些复杂的交互,并保证在代码中可以大概清晰地表现出来。
结论

行为反应模子通过将每种属性分开存储,避免了传统面向对象编程中可能出现的继续和类层次问题。它将不同的属性存储在独立的表格中,从而使得每种属性可以独立更新和查询。这种方法使得系统更加灵活,同时可以大概有效处置处罚复杂的游戏规则和交互关系。
举个例子来更好地明白“行为反应模式”(Act-React Model)和怎样运作:
假设一个角色(Entity)在游戏中包含多个属性(比如健康值、火焰状态等),而这些属性的状态和行为是分开管理的。

1. 游戏中的角色属性:

我们有几个角色,每个角色都有不同的属性。比如:

  • 角色1(Necromancer)有健康值、可燃性和攻击力。
  • 角色2(Prince)有健康值、可燃性和攻击力。
  • 角色3(Knight)有健康值、可燃性和防御力。
每个角色可能有不同的属性,比方“健康值”可能与“火焰状态”相关,而“可燃性”属性决定了角色是否容易着火。
2. 传统的做法:

在传统的面向对象编程(OOP)方法中,可能会把这些属性放入一个单独的对象中。比方:
  1. struct Entity {
  2.     int health;
  3.     bool isBurnable;
  4.     int attackPower;
  5.     // 其他属性
  6. };
  7. Entity necromancer = {100, true, 50};
  8. Entity prince = {120, true, 60};
  9. Entity knight = {150, false, 70};
复制代码
但这种方法的问题是,某些属性之间可能会有复杂的交互(比如健康和可燃性),而在不同的角色之间,属性的共享和继续可能变得非常复杂和不清晰。
3. 行为反应模式的做法:

在行为反应模式中,我们将这些属性分开存储,形成多个表格。每个表格只关注特定类型的属性。
比如:

  • 健康值表(Health Table):记载所有角色的健康值。
  • 可燃性表(Burnable Table):记载哪些角色是可燃的。
  • 攻击力表(Attack Table):记载每个角色的攻击力。
  1. struct HealthTable {
  2.     int entityID;
  3.     int health;
  4. };
  5. struct BurnableTable {
  6.     int entityID;
  7.     bool isBurnable;
  8. };
  9. struct AttackTable {
  10.     int entityID;
  11.     int attackPower;
  12. };
  13. // 角色1的数据
  14. HealthTable healthTable[] = { {1, 100}, {2, 120}, {3, 150} };
  15. BurnableTable burnableTable[] = { {1, true}, {2, true}, {3, false} };
  16. AttackTable attackTable[] = { {1, 50}, {2, 60}, {3, 70} };
复制代码
如今,当我们要处置处罚角色是否燃烧时,我们只需要检查可燃性表,检察该角色是否有isBurnable为true。假如是,角色就可以被点燃。同样,假如角色的健康值发生变化,我们只需要更新健康值表中的相应条目。
4. 更新操作:

在这种结构下,我们可以单独处置处罚每个属性。比方,假如火灾发生,我们只更新可燃性表中isBurnable属性的状态,检查角色是否着火。而健康值变化则会单独处置处罚。
  1. // 处理火灾更新:检查可燃性
  2. for (int i = 0; i < sizeof(burnableTable) / sizeof(BurnableTable); ++i) {
  3.     if (burnableTable[i].isBurnable) {
  4.         // 角色着火,减少健康值
  5.         healthTable[i].health -= 10;
  6.     }
  7. }
  8. // 处理角色的攻击
  9. for (int i = 0; i < sizeof(attackTable) / sizeof(AttackTable); ++i) {
  10.     // 角色攻击,处理攻击逻辑
  11.     attackTable[i].attackPower -= 5;
  12. }
复制代码
5. 查询状态:

当我们想要查询某个角色的状态时,比方我们想知道角色1是否还在火中,可以通过其entityID在可燃性表中查找:
  1. int entityID = 1;  // 查询Necromancer
  2. bool isBurning = burnableTable[entityID - 1].isBurnable;
复制代码
上风:


  • 性能优化:通过这种方法,系统可以更高效地处置处罚每种类型的属性,因为每个属性被单独存储和处置处罚,可以批量更新某一类属性,而不需要每次都访问整个实体的所有属性。
  • 扩展性:假如想增长新属性(比如“抗火能力”),可以直接添加到相应的表中,而不需要修改现有的类结构,减少了耦合。
  • 灵活性:这种方法可以处置处罚更加复杂的交互规则,如“火焰影响健康”和“健康影响火焰”,并且非常适合处置处罚动态变化和复杂条件。
总结:

通过将每种属性存储在独立的表格中,行为反应模子避免了传统面向对象方法中可能出现的继续复杂性,并且通过分离关注点,可以更灵活、有效地处置处罚游戏中的状态变化和复杂交互。
黑板:AOS方法的问题

在设计和实现游戏中的实体系统时,常常碰面临一些性能和灵活性之间的权衡。以下是几种常见的处置处罚方式及其优缺点:
1. 传统的结构体存储方法:

假设我们有一个“Necromancer”(死灵法师)结构体,在这种方法中,每个实体(比如Necromancer)会将所有相关的属性直接包含在其结构体中:
  1. struct Necro {
  2.     int health;
  3.     bool isBurnable;
  4.     int attackPower;
  5.     // 其他属性...
  6. };
复制代码
长处:


  • 性能:这种方式的主要上风在于速度。由于所有属性都保存在结构体中,编译器可以直接访问这些数据,避免了额外的查找开销。处置处罚速度非常快,尤其是在C语言或C++等编译语言中。
  • 简洁明了:代码简朴且易于明白,处置处罚一个实体的每个属性都非常直接。
缺点:


  • 灵活性差:假如我们希望在运行时为某个实体动态添加新的属性,修改结构体就变得困难。需要重新编译整个系统,并且这些变动是固定的,不能在游戏过程中动态调整。
  • 扩展性差:随着实体种类和属性的增长,结构体可能会变得非常复杂和臃肿。
2. 基于表格的动态属性管理:

为了提高灵活性,另一个常见的做法是将每种属性分开存储在不同的表格中(如健康、可燃性、攻击力等)。每个属性都用一个独立的表格来管理,可以动态地为不同实体添加不同的属性。比方:

  • 健康值表:记载所有实体的健康值。
  • 可燃性表:记载哪些实体是可燃的。
  • 攻击力表:记载每个实体的攻击力。
  1. struct HealthTable {
  2.     int entityID;
  3.     int health;
  4. };
  5. struct BurnableTable {
  6.     int entityID;
  7.     bool isBurnable;
  8. };
  9. struct AttackTable {
  10.     int entityID;
  11.     int attackPower;
  12. };
复制代码
在这种方法中,查询一个实体的属性时,需要通过其ID去查询多个不同的表格,而不是直接从一个结构体中获取所有信息。
长处:


  • 灵活性高:通过将不同的属性分开存储,可以在运行时动态地为实体添加或移除属性。比方,假如需要为某个实体添加“芭蕾舞”状态,只需要在芭蕾舞表中为该实体添加一条记载,而不需要修改实体的结构体。
  • 扩展性强:这种方式答应开发人员根据需要随时扩展新的属性类型,而无需重新编译和修改实体结构体。
缺点:


  • 性能开销:每次处置处罚一个实体时,需要查询多个表格,这会带来一定的性能开销,尤其是当实体有大量属性时。每次访问一个属性都需要额外的查询操作,可能导致服从低下。
  • 复杂性增长:由于属性分散在不同的表格中,代码的可维护性可能变得更复杂,需要管理更多的表格和数据结构。
3. 动态添加属性的解决方案:

在这种方法中,可以使用一种灵活的属性管理方式,比方为每个实体维持一个“属性列表”。这些属性可能在游戏运行时动态增长。比如,可以通过简朴的方式将不同的属性(如芭蕾舞状态)添加到这个列表中。
这种方式答应实体在运行时获得更多的动态属性,同时避免了表格查询的复杂性。然而,和传统的结构体方法一样,这种方式也可能导致一些性能上的问题,尤其是当属性列表变得非常大时。
4. 综合比较:


  • 性能 vs 灵活性:传统的结构体方法在性能上有显著上风,尤其是在需要频繁访问和更新实体属性时,但它的灵活性差,无法在运行时动态地改变实体的属性。而基于表格的方法固然灵活性强,但在性能上可能存在瓶颈,尤其是当属性数目巨大时。
  • 扩展性和可维护性:基于表格的方案具有更好的扩展性和可维护性,尤其是在复杂的游戏设计中需要为实体添加各种各样的动态属性时。
  • 详细需求的取舍:假如游戏的设计需要高度灵活和动态的属性管理,那么基于表格的动态方法是更好的选择;但假如对性能有严格要求,且属性变化较少,使用传统结构体的方式可能会更合适。
总的来说,选择哪种方法取决于游戏的设计需求。假如需要快速处置处罚并且实体属性较为固定,结构体方法更为适合;假如游戏要求更高的灵活性和扩展性,表格方法则更加灵活和强盛。
黑板:希罕实体系统

在开发过程中,偶然会遇到需要创造一个全新实体系统的情况,这种系统从底子原则出发,以不同于传统方法的方式设计。为了寻求性能与灵活性的平衡,提出了一个创新的想法:希罕实体系统(Sparse Entity System)。这个系统的目的是将实体管理系统的性能提升至最优,同时仍旧保持灵活性,可以大概让实体具备在运行时随意变化和扩展的能力。
设计思路

希罕实体系统试图在保证快速运行的同时,给实体系统带来更多的灵活性。传统上,实体系统往往要么偏重性能,导致灵活性不敷,要么偏重灵活性,导致性能下降。而这种新的实体系统设计理念,试图在两者之间找到平衡。
希罕实体系统的核心头脑


  • 性能优先:传统的C语言代码对于实体的处置处罚非常高效,因为它可以大概直接访问结构体内的每个属性,编译器可以大概准确地知道这些数据的存储位置。这种方式是最快的,但在面临复杂、动态变化的实体属性时,灵活性不敷。
  • 灵活性需求:另一方面,游戏中的实体常常需要具有高度的灵活性,可以在运行时根据需求动态地增长或修改属性。这就需要一个能支持这种动态调整的系统。
  • 解决方案的尝试:希罕实体系统的设计目的就是尝试找到一种既可以大概快速处置处罚数据,又能支持实体属性动态变化的方式。其核生理念就是希罕存储,通过将实体的各个属性分离存储并以希罕方式访问,从而避免了性能瓶颈,同时保持了灵活性。
系统的工作原理

希罕实体系统的一个可能实现方式是将每个实体的属性分散存储,而不是将所有属性放入同一个结构体中。这意味着每个属性(如健康、攻击力等)都可以存在不同的表格或数据结构中,每个实体的详细信息会通过查找这些表格中的记载来获取。
这种做法答应开发人员在运行时为实体动态地添加或修改属性。比方,假如需要给某个实体添加一个新的状态(比如“芭蕾舞”状态),只需要在对应的属性表中添加一条记载,而不需要修改实体的结构体或重新编译游戏代码。这为游戏设计提供了巨大的灵活性,尤其是在复杂的游戏系统中,可以随时为实体增添新的功能。
评估系统的优缺点


  • 长处

    • 灵活性:可以动态地为实体增长新的属性,不受结构体限制。
    • 扩展性:可以方便地扩展和修改实体的属性,而不需要重新定义和编译结构体。
    • 灵活的设计:实体可以根据需要在运行时变得不同,满足不同游戏需求。

  • 缺点

    • 性能开销:每次查询实体的属性时,都需要通过查找不同的表格,可能导致性能下降,尤其在属性非常多的情况下。
    • 实现复杂:这种设计思路可能会增长系统的复杂性,管理多个表格和动态修改属性的过程可能会让代码更加难以维护。

总的来说,希罕实体系统的设计是一种创新的尝试,旨在平衡性能和灵活性。固然它带来了新的灵活性,但也有可能在性能和维护性上遇到一些挑战,是否可以大概在实际开发中成功实验,还需要通过实验和实际开发来验证其可行性。
为了资助明白希罕实体系统的设计,我们可以通过一个简朴的游戏实体管理的例子来展示它怎样工作。假设我们正在开发一个角色扮演游戏(RPG),其中有多个不同类型的角色,比如“巫师”、“战士”和“弓箭手”。每个角色都会有一些共同的属性,比方“生命值”、“攻击力”和“防御力”,但也有一些特有的属性,比如“魔法值”(仅巫师有)或“射击精度”(仅弓箭手有)。
1. 传统的结构体存储方式

在传统的方式中,我们可能会为每种角色类型定义一个结构体,如下所示:
  1. struct Character {
  2.     int health;
  3.     int attack;
  4.     int defense;
  5.     int magic;  // 仅巫师有
  6.     int accuracy;  // 仅弓箭手有
  7. };
复制代码
然而,这种方式有一个问题:假如我们要为角色动态添加新属性(比方添加“飞行能力”或“冰冻技能”),每次都需要重新定义结构体,并且重新编译整个程序。这就限制了游戏的灵活性。
2. 希罕实体系统存储方式

在希罕实体系统中,角色的属性将被分散存储在不同的表格或数据结构中,而不是放在一个统一的结构体中。每个属性(如健康、攻击、魔法等)都会有一个独立的表格来管理,实体自己的属性则通过表格中的记载举行访问。
假设我们有如下的结构:
  1. // 角色的唯一标识符(ID)
  2. typedef int EntityID;
  3. // 存储不同属性的表格
  4. int healthTable[MAX_ENTITIES];
  5. int attackTable[MAX_ENTITIES];
  6. int defenseTable[MAX_ENTITIES];
  7. int magicTable[MAX_ENTITIES];  // 仅巫师有
  8. int accuracyTable[MAX_ENTITIES];  // 仅弓箭手有
复制代码
每个角色都有一个唯一的ID(EntityID),这些表格会通过角色ID来存储和访问该角色的属性。比方:
  1. // 设置巫师的属性
  2. EntityID wizardID = 0;  // 巫师的ID为0
  3. healthTable[wizardID] = 100;
  4. attackTable[wizardID] = 50;
  5. defenseTable[wizardID] = 30;
  6. magicTable[wizardID] = 200;  // 巫师有魔法值
  7. // 设置弓箭手的属性
  8. EntityID archerID = 1;  // 弓箭手的ID为1
  9. healthTable[archerID] = 120;
  10. attackTable[archerID] = 60;
  11. defenseTable[archerID] = 25;
  12. accuracyTable[archerID] = 90;  // 弓箭手有射击精度
复制代码
3. 动态添加新属性

在希罕实体系统中,最重要的上风之一是可以动态地添加新属性。比方,假如我们想为某个角色添加一个新的技能,如“飞行能力”,我们只需要为这个技能创建一个新的表格,并且为所有相关的角色添加该属性,而无需修改原有的结构体或重新编译代码。
  1. int flightAbilityTable[MAX_ENTITIES];  // 新的飞行能力表格
复制代码
然后我们可以动态地为角色添加“飞行能力”属性:
  1. // 为巫师添加飞行能力
  2. flightAbilityTable[wizardID] = 1;  // 巫师拥有飞行能力
  3. // 为弓箭手添加飞行能力
  4. flightAbilityTable[archerID] = 0;  // 弓箭手不具备飞行能力
复制代码
4. 查询属性

当我们需要查询某个角色的属性时,我们只需使用角色的ID去查找相关的表格。比方,查询巫师的健康值和魔法值:
  1. int wizardHealth = healthTable[wizardID];  // 巫师的健康值
  2. int wizardMagic = magicTable[wizardID];    // 巫师的魔法值
复制代码
这种方法使得游戏中的实体非常灵活,可以根据需要随时修改属性,也可以随时为角色添加新的能力或状态,而不需要改变整个系统的结构。
5. 总结

希罕实体系统的最大长处是它答应在游戏运行时动态地调整实体的属性,而不需要修改原始的结构体定义或重新编译代码。固然这种方法可能会带来一定的性能开销(因为每次查询都需要举行查找操作),但它为游戏设计提供了极大的灵活性,尤其在开发复杂的、需要经常变动的游戏时,可以大概大大提高开发服从和可扩展性。
黑板:继续

在讨论继续的过程中,起首需要明白一个常见的误解和概念。继续通常被以为是面向对象编程中的一种常见结构,尤其在 C++ 中,它指的是从一个结构体(比如 struct foo)派生出另一个结构体(比如 struct bar)。通过继续,我们可以在 bar 中继续 foo 的属性和方法,并且可以对其举行扩展或修改。
1. 基本的继续机制

起首,假设我们有一个结构体 foo,里面包含两个成员:一个整数 X 和一个布尔值 fat。这两个成员常常一起出现,并且我们有一些操作依靠于这两个成员:
  1. struct Foo {
  2.     int X;   // 整数X
  3.     bool fat;  // 布尔值fat,表示是否肥胖
  4. };
复制代码
接下来,我们写一个函数 D_fatty,这个函数根据 X 的值来决定是否将 fat 设为 false:
  1. void D_fatty(Foo* foo) {
  2.     if (foo->X < 10) {
  3.         foo->fat = false;
  4.     }
  5. }
复制代码
这里,D_fatty 函数接受一个 Foo 结构体的指针,并根据 X 的值来修改 fat。这时候,Foo 被视为一个封装了两个数据成员的实体,所有操作都是围绕这两个成员睁开的。
2. 通过指针访问成员的优化

如今,我们将这个问题举行简化并优化。假如我们将 X 和 fat 独立出来,作为单独的指针转达,而不是通过一个 Foo 结构体转达,那么函数就变得更灵活,可以大概操作任何 X 和 fat 对应的组合,而不仅仅是 Foo 类型。这种方式固然增长了灵活性,但却带来了额外的复杂性,并且可能会导致性能下降,因为每次我们需要处置处罚这些数据时,都必须解包这些数据。
  1. void D_fatty(int* X, bool* fat) {
  2.     if (*X < 10) {
  3.         *fat = false;
  4.     }
  5. }
复制代码
尽管这种方式更加灵活,但它失去了将相关数据封装在一起的长处。与之相比,将 X 和 fat 保持在同一个结构体中,不仅便于编程,也让编译器可以大概更高效地优化代码,因为编译器可以清晰地知道这些数据的内存布局,并且可以大概在机器代码层面上优化访问。
3. 继续的引入

在继续的情况下,假设我们有一个 Foo 结构体,并且我们希望从它派生出一个 Bar 结构体。Bar 需要包含 Foo 的所有属性,并且可以添加新的属性(比如 swim)。我们可以通过继续来实现这一点:
  1. struct Bar {
  2.     Foo foo;   // 继承Foo
  3.     float swim;  // 新增的属性
  4. };
复制代码
在这里,Bar 包含了 Foo,并且可以访问 Foo 的所有成员。使用继续的好处是,可以在 Bar 结构体中复用 Foo 的所有功能,而不必重复定义 X 和 fat。
4. 指针与继续的关系

继续的强盛之处在于我们可以将 Bar 的指针作为 Foo 的指针来使用,因为 Bar 总是包含一个 Foo。这意味着我们可以通过转达 Bar 类型的指针来访问 Foo 的成员,而不需要显式地转达 Foo 的指针。比方,调用 D_fatty 函数时,我们可以直接转达 Bar 的指针,而不需要从中提取出 Foo 的指针。
  1. void D_fatty(Bar* bar) {
  2.     if (bar->foo.X < 10) {
  3.         bar->foo.fat = false;
  4.     }
  5. }
复制代码
由于 Bar 结构体中的 foo 是 Foo 类型的实例,我们可以像操作 Foo 一样操作 Bar,这让代码变得更加简洁易用。
5. 继续机制的优化

继续机制的关键上风在于,编译器可以知道 Foo 在 Bar 结构体中的位置,进而优化内存布局和指针访问。由于继续的特性,编译器知道 Foo 是 Bar 的第一个成员,因此,它可以直接通过 Bar 的指针访问 Foo。这种优化降低了程序的复杂度,并提高了性能。
通过继续,我们不需要像之前那样每次都显式地解包 X 和 fat,而是可以直接在 Bar 中使用它们,编译器会主动处置处罚这些访问和内存布局。
6. 继续机制的局限性

尽管继续提供了很多便利,它也有一些局限性。继续机制比较简朴,它通常假设子类只会继续父类的属性和行为,但假如我们需要更多的灵活性(比方多个父类的继续),C++ 的单继续机制可能就无法满足需求。别的,继续还可能导致类之间的紧耦合,使得维护和扩展变得更加复杂。
7. 总结

继续通过将公共的属性和行为封装在基类中,使得子类可以继续和扩展这些属性,避免了代码的重复。它的上风在于提高了代码的重用性、简化了代码结构,并且通过优化内存布局和访问,提高了程序的性能。然而,继续也有其局限性,尤其在需要多个父类的情况下,它的设计就变得不够灵活。
黑板:当继续失败时

当讨论继续时,问题变得更为复杂,特殊是在实际应用中。当涉及到多个属性(如健康、可燃性等)时,继续开始面临挑战。假设我们有一个角色(比如“necros”),我们希望这个角色可以大概拥有多种功能,比方可以大概拥有“健康”功能、可燃性功能等。这就要求我们可以大概通过继续机制把这些功能结合在一起,使得这个角色可以大概兼容各种预期的功能。
1. 多重继续的挑战

在传统的继续机制中,假如我们尝试从多个功能(如健康、可燃性等)继续,就会遇到问题。多个继续在 C++ 中是一个非常棘手的问题,因为继续机制假设只有一个“基类”存在,且这些基类的成员顺序是确定的。当我们使用多重继续时,问题就出现了。继续的顺序变得重要,如许可能导致我们无法像之前那样,简朴地将一个指向 necros 的指针同时当做指向健康、可燃性等其他功能的指针使用。
比方,在单继续时,我们可以将一个指针转达给预期使用某个特性(如健康)的函数,因为编译器知道这个指针可以直接指向结构体的起始位置。但在多重继续中,指针的相对位置会变化,因为继续的各个成员的位置不同,这意味着不能直接将同一个指针转达给不同的功能模块,必须调整指针的偏移量。
2. 指针调整问题

当使用多重继续时,指针的调整变得更加复杂。由于每个基类在派生类中的位置不同,因此无法保证指针可以直接用于所有的功能模块。比方,在上面的 necros 示例中,假如我们把 necros 类定义为继续自多个功能(如 health 和 burnable),那么编译器并不知道如那边理指向 necros 的指针。指向 necros 的指针不能直接用作指向 health 或 burnable 的指针,因为这两个类的内存位置不同,编译器必须做额外的盘算来调整指针,导致服从低下且增长了复杂性。
3. 运行时动态改变继续结构的局限性

更大的问题是,C++ 的多重继续是一个编译时的概念,意味着类的继续关系在编译时就已经确定了,无法在运行时动态地修改。假如我们想要动态地给一个已经存在的对象添加新的功能(比方给一个 necros 添加一个新的属性或技能),这在 C++ 中是无法实现的。因为继续关系和内存布局都是在编译时就决定的,而 C++ 不答应在运行时修改这些结构。
这就导致了问题:假如我们创建了一个 necros 对象,之后给它施加一个新的属性(比如增长一个新的技能),我们无法在运行时动态地添加新的继续关系或成员。这在 C++ 中是不可行的,因为 C++ 的继续机制是以编译时决定的结构为底子的,而不是运行时可扩展的。
4. 为何 C++ 不支持这种动态继续

C++ 不支持在运行时添加继续层次的缘故原由是,这种机制涉及到编译器在编译阶段已经对内存布局做出了优化。继续关系和成员的偏移量被硬编码进了机器代码,编译器通过这些偏移量来直接访问内存。假如在运行时动态地修改继续关系,编译器将无法继续举行这种优化,导致程序变得非常低效。因此,C++ 不支持这种动态的继续结构,也无法在运行时改变类的内存布局和继续结构。
5. 总结

继续在 C++ 中有其上风,但也存在明显的局限性,尤其是在多重继续和运行时动态修改继续关系的情况下。多重继续会导致指针访问的复杂性,无法像单一继续那样简化代码结构,并且需要举行额外的指针调整。而动态继续则完全无法实现,因为 C++ 的继续机制是基于编译时确定的结构,无法在运行时举行调整。如许的设计固然有助于提高性能和优化代码,但也限制了灵活性,特殊是在需要动态修改类的行为时。
为了更好地明白继续机制中的问题,下面通过一个详细的例子来展示继续在多重继续和运行时动态修改结构时的局限性。
1. 单一继续的简朴例子

假设我们有一个 Creature 类,它包含 health 属性,表示生物的健康状态。并且我们有一个 Necro 类,它继续自 Creature 类,表示具有特殊功能的生物,比如死灵法师。以下是单一继续的代码:
  1. #include <iostream>
  2. struct Creature {
  3.     int health;
  4.     Creature(int h) : health(h) {}
  5. };
  6. void heal(Creature* creature) {
  7.     if (creature->health < 100) {
  8.         creature->health += 10;
  9.     }
  10. }
  11. int main() {
  12.     Creature c(50);
  13.     heal(&c);
  14.     std::cout << "Creature's health: " << c.health << std::endl;
  15.     return 0;
  16. }
复制代码
解释

  • Creature 是一个基类,包含 health 属性和一个构造函数。
  • heal 函数接受一个 Creature 指针并增长它的健康值。
  • main 函数创建了一个 Creature 对象,初始化它的健康为 50,并调用 heal 函数恢复健康。
在这种单一继续的情况下,代码非常简洁,且运行时访问内存没有任何问题。我们只需要转达一个指向 Creature 对象的指针,函数就可以正常工作。
2. 多重继续的复杂情况

如今我们尝试使用多重继续。假设我们有两个功能:Burnable 和 Healable,分别表示生物是否可以被燃烧和是否可以被治疗。我们将 Necro 类同时继续这两个类。
  1. #include <iostream>
  2. struct Burnable {
  3.     bool isBurnable;
  4.     Burnable(bool b) : isBurnable(b) {}
  5. };
  6. struct Healable {
  7.     int health;
  8.     Healable(int h) : health(h) {}
  9.     void heal() {
  10.         if (health < 100) health += 10;
  11.     }
  12. };
  13. struct Necro : public Burnable, public Healable {
  14.     Necro(bool b, int h) : Burnable(b), Healable(h) {}
  15. };
  16. int main() {
  17.     Necro necro(true, 50);
  18.     necro.heal();
  19.     std::cout << "Necro's health after healing: " << necro.health << std::endl;
  20.     return 0;
  21. }
复制代码
解释

  • Burnable 类包含 isBurnable 属性,表示物体是否可以被燃烧。
  • Healable 类包含 health 属性和一个 heal 函数,表示物体是否可以治疗。
  • Necro 类继续了 Burnable 和 Healable,并在构造函数中初始化了这两个类的成员。
  • main 函数创建了一个 Necro 对象,初始化它的 isBurnable 为 true,健康值为 50,并调用 heal 函数恢复健康。
此时 Necro 类拥有两个独立的功能:可以燃烧和可以治疗。代码看起来没有问题,Necro 对象可以正常使用 heal 函数。
3. 多重继续中的问题

但是,问题在于内存布局。当我们使用多个继续时,Necro 类中实际上包含了 Burnable 和 Healable 的两个子对象。假设我们再参加一个新的类 MagicUser,并且我们希望给 Necro 增长一个额外的魔法属性。为了实现这一点,我们需要将这个新属性添加到 Necro 类中,但这时就会遇到指针调整的问题。
假设我们修改 Necro 类,使其包含 MagicUser 功能:
  1. #include <iostream>
  2. struct MagicUser {
  3.     bool hasMagic;
  4.     MagicUser(bool magic) : hasMagic(magic) {}
  5. };
  6. struct Necro : public Burnable, public Healable, public MagicUser {
  7.     Necro(bool b, int h, bool m) : Burnable(b), Healable(h), MagicUser(m) {}
  8. };
  9. int main() {
  10.     Necro necro(true, 50, true);
  11.     necro.heal();
  12.     std::cout << "Necro's health after healing: " << necro.health << std::endl;
  13.     std::cout << "Necro has magic: " << (necro.hasMagic ? "Yes" : "No") << std::endl;
  14.     return 0;
  15. }
复制代码
问题

  • Necro 类如今同时继续了 Burnable、Healable 和 MagicUser。
  • 由于多重继续,Necro 类包含了三个基类的成员,它们的位置会受到影响。
  • 假如我们转达 Necro 对象的指针给某些函数,这时我们无法保证指针始终指向正确的成员。因为每个基类的内存布局不同,以是要使用 Necro 对象的指针时,需要举行额外的偏移调整。
4. 动态修改继续结构的局限性

如今假设我们希望在运行时为 Necro 增长一个新的功能,比如增长一个新的魔法技能 SpellCaster。然而,由于 C++ 的继续结构是在编译时就固定的,我们不能在运行时动态地给 Necro 对象添加新的继续功能:
  1. // 这是无法在运行时动态添加的
  2. struct SpellCaster {
  3.     void castSpell() {
  4.         std::cout << "Casting a spell!" << std::endl;
  5.     }
  6. };
  7. Necro necro(true, 50, true);
  8. SpellCaster* spellCaster = new SpellCaster();
  9. // 无法动态将 SpellCaster 加入 necro 对象
复制代码
问题

  • 在 C++ 中,继续结构是静态的,不能在运行时动态修改。这意味着,假如你想给 Necro 对象添加新的功能或改变它的行为,你必须重新编译代码,而不能像一些动态语言那样随时改变对象的属性和功能。
  • 在这个例子中,尽管我们创建了一个 SpellCaster 对象,但无法将其动态地“附加”到 Necro 类实例上。
总结


  • 单一继续:简朴且高效,实用于简朴的对象关系。
  • 多重继续:提供了更大的灵活性,但也引入了指针调整问题,导致访问内存时需要额外的复杂性。
  • 动态修改继续结构的局限性:C++ 不支持在运行时修改继续结构,这使得在运行时动态添加功能变得困难,特殊是在多重继续的复杂情况下。
这些问题展示了继续在 C++ 中的复杂性和局限性,尤其是在多重继续和动态修改继续结构时。
在 C++ 的多重继续中,指针调整问题(pointer adjustment)之以是出现,是因为多重继续会导致对象的内存布局变得复杂,基类子对象的地址可能与派生类对象的地址不完全相同。为了明白这个问题,我们需要深入探讨多重继续中的内存布局以及指针在访问基类成员时的行为。

1. 多重继续的内存布局

在多重继续中,派生类(如 Necro)会包含所有基类的子对象(Burnable、Healable、MagicUser 等)。这些基类的子对象在内存中按照继续顺序依次排列,且每个基类子对象都有自己的内存起始地址。
以你的例子中的 Necro 类为例:
  1. struct Burnable {
  2.     bool isBurnable;
  3.     Burnable(bool b) : isBurnable(b) {}
  4. };
  5. struct Healable {
  6.     int health;
  7.     Healable(int h) : health(h) {}
  8. };
  9. struct MagicUser {
  10.     bool hasMagic;
  11.     MagicUser(bool magic) : hasMagic(magic) {}
  12. };
  13. struct Necro : public Burnable, public Healable, public MagicUser {
  14.     Necro(bool b, int h, bool m) : Burnable(b), Healable(h), MagicUser(m) {}
  15. };
复制代码
假设我们创建了一个 Necro 对象:
  1. Necro necro(true, 50, true);
复制代码
在内存中,Necro 对象的布局可能如下(假设 32 位系统,忽略填充字节):
  1. Necro 对象内存布局:
  2. +-------------------+
  3. | Burnable 子对象   |  // 包含 isBurnable (偏移 0)
  4. +-------------------+
  5. | Healable 子对象   |  // 包含 health (偏移 1 或 4,取决于对齐)
  6. +-------------------+
  7. | MagicUser 子对象  |  // 包含 hasMagic (偏移 5 或 8,取决于对齐)
  8. +-------------------+
复制代码

  • Necro 对象的起始地址是 Burnable 子对象的起始地址。
  • Healable 子对象的地址相对于 Necro 对象的起始地址有一个偏移(比方,偏移 1 或 4 字节)。
  • MagicUser 子对象的地址有更大的偏移(比方,偏移 5 或 8 字节)。
当你将 Necro 对象的指针转达给某个函数,并试图将其作为某个基类的指针(如 Healable* 或 MagicUser*)使用时,编译器需要调整指针的地址,以确保它指向正确的基类子对象的起始地址。这就是所谓的指针调整

2. 为什么需要指针调整?

指针调整的根本缘故原由是:派生类对象的地址与每个基类子对象的地址可能不同。在单一继续中,基类子对象通常位于派生类对象的开头,因此派生类指针可以直接作为基类指针使用,无需调整。但在多重继续中,基类子对象的地址会因为内存布局而偏移,导致需要调整指针。
举个例子,假设我们有一个函数,接受 Healable 类型的指针:
  1. void heal(Healable* healable) {
  2.     if (healable->health < 100) {
  3.         healable->health += 10;
  4.     }
  5. }
复制代码
如今我们将 Necro 对象的指针转达给这个函数:
  1. Necro necro(true, 50, true);
  2. heal(&necro); // 隐式转换为 Healable*
复制代码

  • &necro 是 Necro 对象的地址,指向内存布局的开头(即 Burnable 子对象的地址)。
  • 但是 heal 函数需要一个 Healable* 类型的指针,而 Healable 子对象在 Necro 对象中的地址并不是 Necro 对象的起始地址,而是有一定偏移(比方,偏移 4 字节)。
  • 因此,编译器会在将 Necro* 转换为 Healable* 时,主动调整指针的地址,增长必要的偏移量,使其指向 Healable 子对象的正确位置。
这个过程是编译器在幕后完成的,通常对程序员是透明的。但它引入了以下问题:

  • 性能开销:每次将派生类指针转换为基类指针时,编译器可能需要插入额外的指令来调整指针地址,这会增长运行时开销。
  • 复杂性:在多重继续中,指针调整可能导致难以调试的错误,尤其是当你手动操作指针或涉及虚函数调用时。
  • 潜在的错误:假如程序员错误地假设指针无需调整(比方,通过 reinterpret_cast 强制转换),可能会导致访问错误的内存位置,引发未定义行为。

3. 指针调整的详细场景

为了更清晰地说明指针调整,我们可以通过一个更详细的例子来展示:
  1. #include <iostream>
  2. struct Burnable {
  3.     bool isBurnable;
  4.     Burnable(bool b) : isBurnable(b) {}
  5.     virtual void print() { std::cout << "Burnable: " << isBurnable << std::endl; }
  6. };
  7. struct Healable {
  8.     int health;
  9.     Healable(int h) : health(h) {}
  10.     virtual void print() { std::cout << "Healable: " << health << std::endl; }
  11. };
  12. struct Necro : public Burnable, public Healable {
  13.     Necro(bool b, int h) : Burnable(b), Healable(h) {}
  14. };
  15. int main() {
  16.     Necro necro(true, 50);
  17.     Burnable* burnable = &necro; // 指向 Burnable 子对象
  18.     Healable* healable = &necro; // 指向 Healable 子对象
  19.     std::cout << "Necro address: " << &necro << std::endl;
  20.     std::cout << "Burnable address: " << burnable << std::endl;
  21.     std::cout << "Healable address: " << healable << std::endl;
  22.     burnable->print(); // 调用 Burnable::print
  23.     healable->print(); // 调用 Healable::print
  24.     return 0;
  25. }
复制代码
输出示例(实际地址会因系统而异):
  1. Necro address: 0x7ffee4c0a4a0
  2. Burnable address: 0x7ffee4c0a4a0
  3. Healable address: 0x7ffee4c0a4a8
  4. Burnable: 1
  5. Healable: 50
复制代码
解释

  • Necro 对象的地址是 0x7ffee4c0a4a0。
  • Burnable* 指针指向 Necro 对象的起始地址(0x7ffee4c0a4a0),因为 Burnable 子对象位于 Necro 对象的开头。
  • Healable* 指针指向一个偏移后的地址(0x7ffee4c0a4a8),因为 Healable 子对象位于 Burnable 子对象之后(假设 Burnable 占 8 字节,包括对齐)。
  • 编译器在将 &necro 转换为 Healable* 时,主动增长了 8 字节的偏移量,这就是指针调整。

4. 指针调整与虚函数

指针调整问题在涉及虚函数时会更加复杂。假如基类有虚函数,派生类对象会包含一个虚表指针(vptr),用于动态分派虚函数调用。在多重继续中,每个基类子对象可能有自己的虚表指针,且虚表的内容可能不同。当你将派生类指针转换为基类指针时,编译器不仅需要调整指针地址,还需要确保虚表指针指向正确的虚表。
在上面的例子中,Burnable 和 Healable 都有虚函数 print。当你调用 burnable->print() 或 healable->print() 时,编译器会根据指针的类型(Burnable* 或 Healable*)选择正确的虚表和函数地址。这进一步增长了多重继续的复杂性。

5. 怎样避免指针调整问题?

指针调整是多重继续的固有特性,但可以通过以下方式减少其带来的问题:

  • 使用单一继续或接口式继续

    • 假如可能,只管使用单一继续,大概通过虚继续(virtual 关键字)来避免重复的基类子对象。
    • 使用纯虚函数(抽象基类)来定义接口,减少多重继续的复杂性。

  • 避免直接操作指针

    • 只管避免手动转换指针(如 static_cast 或 reinterpret_cast),让编译器主动处置处罚指针调整。
    • 使用 dynamic_cast(假如需要运行时类型检查)来确保类型转换安全

  • 使用组合代替继续

    • 考虑使用组合(composition)而不是继续。比方,将 Burnable、Healable 和 MagicUser 作为 Necro 的成员变量,而不是基类。如许可以避免复杂的内存布局和指针调整问题。

  • 明白内存布局

    • 假如必须使用多重继续,仔细设计类的内存布局,了解每个基类子对象的偏移量,并使用调试工具(如 sizeof 和指针地址打印)验证布局。


6. 总结


  • 为什么需要指针调整?
    在多重继续中,派生类对象包含多个基类子对象,这些子对象的内存地址相对于派生类对象的起始地址有偏移。当将派生类指针转换为基类指针时,编译器需要调整指针地址,使其指向正确的基类子对象。
  • 指针调整带来的问题

    • 性能开销:指针调整增长了运行时指令。
    • 复杂性:内存布局和虚函数调用变得更复杂,可能导致调试困难。
    • 潜在错误:错误的指针操作可能导致未定义行为。

  • 怎样应对?
    通过单一继续、接口式继续、组合代替继续,或小心设计内存布局,可以减少指针调整带来的问题。
希望这个解释清晰地解答了你的疑问!假如还有其他问题,接待继续提问。
黑板:“继续是压缩”

继续可以看作是一种“压缩算法”,它的本质是对数据结构的压缩,而不仅仅是简朴的功能继续。详细来说,继续的作用是通过提取和合并常用的子设置,减少不必要的冗余,优化内存和存储。
1. 继续与数据压缩

假设我们有一个实体,它包含多个属性,数目可能非常巨大,甚至高达数百个。比方,实体可能包含位置、健康状态、燃烧属性、魔法能力等各种属性。假如我们将所有可能的属性直接放入一个巨大的结构体中,这个结构体可能会非常巨大,包含了所有可能的属性。如许,任何需要这些属性的函数,都能直接从这个结构体中提取出所需的数据。
举个例子:
  1. struct GiantEntity {
  2.     int x;            // 位置
  3.     int health;       // 健康
  4.     bool isBurnable;  // 是否可以燃烧
  5.     bool hasMagic;    // 是否有魔法
  6.     // 其他可能的属性
  7. };
复制代码
在这个结构体中,所有可能的属性都被包含了进去。假如我们将这个结构体转达给任何函数,无论该函数需要什么属性,所有相关的属性都已经在结构体中,可以随时访问。
2. 继续的压缩作用

而继续的作用,就是将这个巨大的结构体“压缩”成多个子结构体,通过继续的方式,提取出常用的设置和组合。比方,假如一个实体只需要健康和燃烧属性,我们可以将这两个属性提取成一个类,形成一个小的“压缩结构”:
  1. struct HealthAndBurnable : public GiantEntity {
  2.     HealthAndBurnable(int health, bool isBurnable) {
  3.         this->health = health;
  4.         this->isBurnable = isBurnable;
  5.     }
  6. };
复制代码
这里,HealthAndBurnable 只是从本来的巨大结构体中提取了健康和燃烧的属性,其他无关的属性(如魔法属性)被剔除掉。如许,不仅减少了内存的占用,还使得代码更加清晰。
3. 继续的灵活性与局限性

继续使得我们可以大概根据实际需求选择需要的属性组合,而无需为每种可能的组合都定义一个全新的类。比方,假如某个实体只需要健康属性,可以定义一个专门的类来存储健康,而不必添加其他不必要的功能。
然而,这也带来了继续的一些局限性:在 C++ 中,继续结构在编译时已经固定,不能动态地举行组合或修改。这意味着我们在设计时需要提前考虑好可能的所有组合,而不能在运行时根据实际情况自由地创建新的组合。假如一个实体需要临时添加新的属性或功能,比如一个新的魔法技能,我们就无法在运行时动态地为其“添加”这些功能,必须重新设计并编译代码。
4. 动态编程语言的解决方法

一些动态编程语言(如 Python)通过支持在运行时动态添加属性和方法,来绕过这个问题。它们答应开发者在运行时灵活地为对象添加新的功能或改变对象的行为,而无需在编译时就确定好所有可能的组合。尽管这种方式可能会带来一定的性能开销,但它解决了继续在静态语言中面临的灵活性问题。
5. 总结

继续的本质是对数据结构的“压缩”,通过提取常用的组合和属性来减少不必要的冗余,节流内存和存储空间。它的长处在于可以大概减少不必要的数据存储,使得程序更加高效。缺点是,它要求在编译时就确定好所有可能的组合,缺乏动态灵活性。这种静态的设计方式固然可以大概提高性能,但也让代码在面临变化时变得不够灵活。而动态语言通过在运行时答应修改对象的属性和行为,解决了这一问题,固然可能断送一定的性能。
黑板:一个极其巨大的实体结构体

我们打算尝试构建一种非常极度的对头系统,详细做法是:我们会设计一个巨大的 Entity 结构体,这个结构了解包含游戏中所有可能出现的属性,大小可能达到 64KB,甚至更大。这个结构体将被称为“过分实体”(over entity),它包含了游戏中所有可能存在的功能和数据,比如移动、血量、燃烧状态、魔法状态等等,任何一个实体可能拥有的属性它都具备。
但我们不会让每一个实体在任何时候都实际占用 64KB 内存,因为那样太浪费,而且我们也不希望每次都处置处罚这么大的数据结构。我们已经有了“模仿区域”(sim regions)的机制,即将实体临时加载到一个统一的空间中举行处置处罚,处置处罚完再写回去。以是我们打算利用这一机制来举行压缩与解压。
1. 实体解压与压缩流程

我们的做法是:

  • 在模仿开始前,从“冷存储”中将压缩的数据解压(decompress)为这个巨大的实体结构体(full Entity)。
  • 在模仿竣事后,把 Entity 再压缩(compress)回去并写入存储。
这个过程就像一个希罕矩阵处置处罚器:解压阶段我们只填充当前需要的数据,压缩阶段我们只保存修改过或使用过的部分属性。整个过程并不是传统意义上的压缩(比如 LZ 算法),而是一种属性选择式的压缩机制。
我们每个实体可能有上千个属性,但某个详细实体只会使用其中的几十个,我们会通过位图或标记位记载哪些属性被解压或使用过,模仿过程中若添加了新属性,也会标记出来。如许在压缩时我们只写回那些实际存在的属性,未使用的部分不会被存储。
2. 为什么不直接用组件系统

你可能会问:为什么不采用传统的组件系统(Component-based system),把各个属性分开放在不同列表中,然后组合成一个实体?这不是更灵活更节流空间吗?
我们的理由是:如许会严重影响模仿代码的实验服从
假如采用组件系统,模仿逻辑就必须检查某个实体是否具有某个属性,然后再决定如那边理。如许不仅增长了判定逻辑,还会引入指针间接访问、缓存命中率下降等问题,最终导致运行服从下降。
而假如我们把实体的数据都放在一个巨大的结构体中,并通过位图控制其希罕性,那么所有的模仿代码都可以直接通过偏移访问数据,不需要做任何条件判定,也不需要查表,只要硬编码访问地址就可以。这种方式非常接近 C 语言风格的直接内存操作,运行服从非常高。
3. 实际运行中的流式处置处罚机制

整个系统还与“世界区块”(world chunks)机制结合。当我们写入一个区块时,会将该区块中的所有实体压缩写入。当我们需要模仿该区块时,则读取并解压这些实体。这个过程构成了一个流式的、希罕的实体处置处罚系统:

  • 解压阶段只构造需要的属性。
  • 模仿代码自由访问整个 Entity。
  • 模仿竣事后,只压缩使用或修改过的属性。
  • 通过位图/希罕索引保持数据的最小化存储。
4. 结论

这种设计的核心头脑是:

  • 用一个超大的结构体表示一切可能的实体状态;
  • 在需要时解压,只填充需要的数据;
  • 模仿代码直接访问,不做任何抽象层判定;
  • 模仿竣事后压缩,仅保存使用过的部分;
  • 通过流式处置处罚和希罕控制,分身运行服从与内存服从。
这种方式断送了一些抽象性和灵活性,但获得了极高的性能和控制力,特殊适合对 CPU 实验服从要求极高的游戏系统。
我们举一个详细的例子,来说明这个“超大实体结构体 + 解压/压缩 + 希罕标记”系统是怎样工作的。

举例:游戏中的对头「火焰僵尸(FlameZombie)」

我们设想一个游戏对头叫做「火焰僵尸」,它有以下几个特性:

  • 有生命值(Health)
  • 可以被点燃(Burnable)
  • 有当前位置(Position)
  • 有动画状态(AnimationState)
  • 没有魔法属性(MagicPower = 不存在)
  • 没有飞行能力(FlyingAbility = 不存在)

1. 超大结构体 Entity

我们定义一个巨大的 Entity 结构体,比如(伪代码):
  1. struct Entity {
  2.     bool HasHealth;
  3.     int Health;
  4.     bool HasBurnable;
  5.     float BurnTime;
  6.     bool HasPosition;
  7.     float X, Y;
  8.     bool HasAnimationState;
  9.     int FrameIndex;
  10.     bool HasMagicPower;
  11.     float Mana;
  12.     bool HasFlyingAbility;
  13.     float FlyHeight;
  14.     // ...后面还有成百上千个其它可能的属性
  15. };
复制代码
注意每个字段前都有一个 HasXXX 标记位,用于标记当前这个实体是否使用了该属性。这就像一个希罕矩阵中的“是否存在”标记。

2. 解压(Decompress)

当我们要加载某个区域,模仿其中的实体时,我们读取「火焰僵尸」的压缩版本
  1. {
  2.     "Health": 100,
  3.     "BurnTime": 3.0,
  4.     "X": 50.0,
  5.     "Y": 100.0,
  6.     "FrameIndex": 12
  7. }
复制代码
然后将其“解压”进完整结构体中:
  1. entity.HasHealth = true;
  2. entity.Health = 100;
  3. entity.HasBurnable = true;
  4. entity.BurnTime = 3.0;
  5. entity.HasPosition = true;
  6. entity.X = 50.0;
  7. entity.Y = 100.0;
  8. entity.HasAnimationState = true;
  9. entity.FrameIndex = 12;
  10. entity.HasMagicPower = false;
  11. entity.HasFlyingAbility = false;
  12. // ...其它字段都初始化为 false
复制代码

3. 模仿阶段的访问

游戏模仿代码可以直接像普通 struct 一样访问字段:
  1. if (entity.HasHealth) {
  2.     entity.Health -= 10;
  3. }
  4. if (entity.HasBurnable) {
  5.     entity.BurnTime += deltaTime;
  6. }
复制代码
这段代码无需做复杂的属性查找或组件组合判定,可以完全静态睁开,甚至直接在 SIMD 中举行处置处罚。

4. 压缩(Compress)

当该区域模仿完毕后,我们要将实体压缩存储:
我们遍历所有属性的标记位,发现这个实体使用了以下字段:

  • Health
  • BurnTime
  • X, Y
  • FrameIndex
于是我们只将这些写入磁盘或存储系统,跳过未使用的部分:
  1. {
  2.     "Health": 90,
  3.     "BurnTime": 3.5,
  4.     "X": 50.0,
  5.     "Y": 100.0,
  6.     "FrameIndex": 13
  7. }
复制代码
如许既节流存储,又保存了高速模仿时的完整数据访问结构。

总结这个例子的上风:


  • 所有模仿代码可直接访问字段,没有动态派发,没有虚函数,没有组件查询。
  • 运行阶段只保存必要属性,避免 64KB 全占用,靠标记位保持希罕性。
  • 存储和加载时可压缩,只保存用到的部分,便于存档与区域流加载。
  • 任意组合属性都支持,不需要预先定义所有组合类型,避免了传统继续体系的爆炸增长问题。
这就是这种极度实体系统的应用场景和上风。适适用于性能敏感、状态多样但变化希罕的复杂游戏世界模仿中。
黑板:希罕矩阵求解器

这个部分主要是在讲一个概念类比:我们设计的“超大实体结构体 + 希罕标记 + 解压/压缩”的系统,其头脑与“希罕矩阵求解器(sparse matrix solver)”非常相似。
我们详细解释如下:

类比的本质头脑:

希罕矩阵求解器的基本思路是如许的:

  • 在内存中,我们可能为一个非常大的矩阵预留了完整空间,比如说:
    一个 64,000 x 64,000 的二维矩阵,理论上包含 40 多亿个元素。
  • 实际上,这个矩阵里大部分元素都是 0 或无效值,我们不会去填充它们。
  • 我们只在需要的地方去写入值,并且记载我们写入了哪些位置(比方:第 5 行第 7 列写了一个非零值)。

存储与访问计谋:


  • 当我们要举行数值运算或直接访问某个值(比如:获取矩阵的第 m 行第 n 列的元素),我们仍旧可以直接通过索引去访问——因为我们在内存中已经为它们预留了完整结构,它们在正确的位置上,只是空的地方默认返回 0。
  • 当我们要把矩阵存盘(或举行压缩处置处罚)时,我们不会写出整个 40 亿个值,而是:

    • 只提取出我们记载过的、实际写入过的那些值。
    • 以是最终的“压缩矩阵”只包含很少的一部分内容,极大节流空间。


这个类比在我们实体系统中的应用:

我们使用类似计谋设计实体系统:
1. 内存中结构体是完整的

我们定义了一个超大的结构体(比如 64K 大小),这个结构体有成百上千个字段,比如:生命值、燃烧状态、动画帧、坐标、魔法值、飞行高度、投掷物轨迹、对话文本、AI 状态、任务标识符……等等。
即便这些字段险些不可能全部在一个实体中被用上,我们还是为它们预留好了完整的空间
2. 使用时只激活需要的部分

某个实体假如只是个普通僵尸,只会设置其中几个字段,比如:

  • 生命值
  • 坐标
  • 动画状态
我们就只激活这几个字段,并设置对应的 HasXxx = true。
3. 读取时是快速的

模仿逻辑访问这些字段时,不需要查找、不需要组件系统调度,不需要运行时分发判定属性是否存在,而是直接按偏移地址读取数据,这就像在希罕矩阵中按位置直接读取元素值一样,速度极快。
4. 存储时只压缩生动字段

和希罕矩阵一样,我们只会压缩和存储那些 HasXxx = true 的字段。其他字段固然在结构体中占据空间,但它们不会被写入,也不会影响 IO 和存储。

总结类比的意义:


  • 我们实现的结构体系统,本质上就是一个希罕矩阵,结构体字段是矩阵元素。
  • 我们通过标记哪些字段“存在”,实现了类似希罕矩阵的希罕性。
  • 读取和模仿逻辑保持满速访问,就像希罕矩阵直接按索引读取一样
  • 存储和序列化则跳过空字段,只处置处罚实际使用的数据,节流资源。
这种设计思路就是鉴戒了希罕矩阵求解器的核心上风:既能高效访问,又能节流资源,非常适合复杂、动态、多样的游戏实体系统。
黑板:筹划总结

我们筹划尝试一种全新的实体系统设计思路,其核心是构建一个统一的、巨大的实体结构体。我们会为这个实体结构体预留出所有可能需要的属性字段——无论是跳跃、行走、攻击、发射子弹、喷火、施法、对话、变形等,还是其它任意游戏中可能出现的行为或状态。所有这些能力都被整合进同一个结构体中,成为可选的组成部分。

系统架构核心流程如下:


  • 世界区块存储机制(World Chunks):

    • 游戏世界被分别为多个区块。
    • 每个区块在存储时,使用“压缩”形式保存,只保存实体当前实际用到的字段。
    • 当区块被载入举行模仿时,我们举行“解压缩”操作,将实体恢复成完整结构体,只激活需要用到的属性。
    • 模仿完成后,再次压缩写回磁盘或缓存中。

  • 实体结构体特点:

    • 每个实体结构体尺寸巨大,假设为 64KB。
    • 它包含了我们可能在游戏中使用的所有字段,即使当前实体并没有用到它们。
    • 实际运行时,只有被激活的字段才会被读写。
    • 实体结构具有最大通用性,任何行为都可以被赋予任何实体。


灵活性举例:


  • 假如我们有一个“树”实体,本来只是一个静态物体;
  • 某个技能施加在它身上,比如“赋予移动能力”;
  • 我们只需将其结构体中的“可移动”字段激活;
  • 它立刻变成一个“会走路的树”;
  • 不需要更换结构体或派生类,也不需要实体类型的变化,一切行为都通过结构体字段的激活来实现。
这就实现了最洪流平的设计灵活性,答应游戏机制在运行时自由组合行为,而不受类型系统或继续关系的限制。

系统运行中的关键操作:


  • 解压(Decompress):

    • 从压缩存储中加载实体数据;
    • 恢复为完整的实体结构体;
    • 标记哪些字段有效,预备参与模仿。

  • 压缩(Compress):

    • 将使用过或更新过的字段网络起来;
    • 写入压缩存储格式(节流空间);
    • 用于保存或卸载区域。

这个机制类似希罕矩阵求解器的压缩-解压思路。内存中保存完整结构,但压缩与传输时只处置处罚实际存在的数据,极大减少资源开销。

潜在问题与预期风险:

固然理论上这种方式非常灵活,但也存在一些潜在问题,我们必须在实践中验证其可行性:

  • 解压/压缩过程中的数据流量:

    • 假如实体数目多(如 500 个实体),压缩与解压频率高,可能造成显著的内存带宽压力。

  • 内存占用问题:

    • 即使只使用了少量字段,每个实体依旧要保存整个结构体(64KB),总体内存使用量可能过高。

  • 缓存局部性问题:

    • 由于结构体非常大,字段之间间隔远,可能导致缓存服从降低(所谓的“伪共享”或“false sharing”问题)。
    • 不过考虑到我们是希罕访问的,缓存影响可能没有表面看起来那么严重。


实验目的:


  • 用最直接的结构体访问方式,加快模仿代码运行速度;
  • 避免运行时的查找和调度逻辑,减少逻辑分支;
  • 为游戏系统提供最大灵活性,使任何实体在任何时候都可以拥有任意行为;
  • 只管避免继续、类型分支、组件注册等复杂的运行时逻辑。

最终意图:

我们希望通过这种机制实现“极致灵活性 + 原始访问速度”的统一。固然这种做法还未颠末验证,但看起来没有明显的致命缺陷,因此值得尝试实验并评估其实际效果。假如可行,它可能成为一种实用于大型复杂游戏项目的全新实体系统模子。
问答环节

本日的内容就到这里了,我们已经把整体的设计思路完整地梳理和讲解了一遍。固然最初并没有特殊筹划要讲这些,但最终我们确实系统地说明了整个方案的核生理念与技术细节。这是一件功德,因为这意味着来日诰日可以直接进入实际开发环节,节流时间,提高服从。
我们已经清晰地论述了如下要点:

1. 实体系统的基本框架


  • 构建一个包含所有可能字段的超大实体结构体;
  • 所有的功能行为都通过这个结构体中的字段开关来控制;
  • 实体不再依靠于类型分别或继续层次;
  • 所有行为可以动态组合,具备完全的灵活性。

2. 压缩与解压机制


  • 从磁盘或内存中加载实体数据时举行解压,生成完整结构体;
  • 模仿运行后,再次压缩,仅存储被使用或修改过的字段;
  • 达到内存服从与模仿性能的平衡。

3. 与希罕矩阵对比


  • 结构体就像希罕矩阵中的稠密内存区域;
  • 实际只有少部分字段被访问或修改;
  • 类似于希罕矩阵的索引方式,仅记载生动字段,避免资源浪费;
  • 模仿阶段仍旧可以实现快速、直接的内存访问。

4. 风险与权衡


  • 潜在的内存压力:每个实体占用大空间;
  • 解压与压缩可能带来数据流量瓶颈;
  • 缓存服从受结构体体积影响可能降低;
  • 但由于访问希罕、操作会合,实际影响可能远小于理论值。

5. 总体目的


  • 用最简朴的结构体直接映射所有实体状态与行为;
  • 模仿代码在运行时无需判定类型、查找组件或分派函数;
  • 以断送一定内存换取最大灵活性与极致模仿性能;
  • 建立一个可以支持任意行为组合的游戏运行底子。

接下来的步调将围绕详细实现睁开。我们将从存储模块动手,建立世界区块的压缩/解压流程,然后构建实体模仿所需的接口与访问方式。本日的讲解奠基了坚实的理论底子,来日诰日可以正式进入实际开发阶段。我们将继续推进。
你能讲讲动态调度吗?

我们如今来谈谈动态分派(dynamic dispatch),固然之前主要关注的是数据层面的设计,但分派机制也是整个架构中不可忽视的一部分,它关系到怎样调用行为、怎样组织逻辑代码以及系统运行的服从。

什么是动态分派?

动态分派的本质是一种运行时函数选择机制。当我们有一组不同种类的实体,它们可能在逻辑上都需要“实验某个动作”,但这个动作的实现方式不同。动态分派答应我们在运行时根据详细的对象或实体类型,主动选择并调用对应的实现函数。

我们为何需要动态分派?

在构建一个具备高灵活性和扩展性的游戏系统时,我们不希望写一堆冗长的 if 或 switch 判定去硬编码每种类型的行为。特殊是当我们构建一个超大结构体,答应所有实体行为共存时,我们更希望行为是“附着”在数据上的,而不是通过大量条件判定来切换。
比方:

  • 我们希望“假如实体具备 burnable 属性,就可以对其举行燃烧处置处罚”;
  • “假如实体具备 health 属性,就可以吸收伤害”。
这些行为的实验逻辑,应由属性驱动,而不是类型驱动。

动态分派怎样实现?

通常有以下几种方式:
1. 虚函数表(vtable)机制(如 C++ 中的多态)


  • 每个对象维护一个函数指针表;
  • 调用方法时,通过该表的索引找到对应函数;
  • 缺点是需要明白的继续体系,不适合我们这种扁平、混淆结构。
2. 函数指针字段


  • 在我们的实体结构中,某些行为可以用函数指针字段表示;
  • 比如 onDamage、onUpdate 等;
  • 赋值时绑定详细函数,实现自由组合;
  • 长处是灵活、轻量、无需继续;
  • 缺点是函数署名必须统一。
3. 查表实验(函数查找表 + 数据驱动)


  • 用属性或标记位作为 key,在一个全局或局部的查找表中查询对应的处置处罚函数;
  • 比如,if (hasBurnable) -> dispatch(burnFuncTable);
  • 这种方式适合我们这种希罕属性设计,因为行为是“属性驱动”的。

动态分派 vs 性能优化

使用动态分派可能带来的问题是分支预测失败函数指针跳转引发指令缓存命中率下降,不过我们当前的架构有自然上风:

  • 因为只会对生动字段举行访问,大多数情况下行为分派是希罕且有明白指向的;
  • 每种实体行为调用的函数也会相对会合,不会出现极其发散的调用路径;
  • 可以通过批处置处罚行为分组进一步减少动态分派带来的开销。

总结

我们当前的架构答应实体以最通用的方式表示,也就是说一个实体可以在运行时具备任意属性集合。基于这种架构,传统的类继续和虚函数体系已无法实用,因此我们转而采用属性驱动 + 函数表 + 希罕访问的方式举行动态分派。
这种分派机制不再关心“这是什么类型”,而只关注“这个实体当前具备哪些功能”,并据此实验操作,真正实现了行为的组合式、数据驱动式设计。
这种设计同时分身了:

  • 性能(直访数据,无额外判定);
  • 灵活性(行为可组合、可切换);
  • 可扩展性(添加新行为无需改动其他部分)。
后续假如有实际落地,也可以根据调试结果进一步选择是否优化为静态表、JIT 编译、内联缓存等更高级的分派机制。我们将视实际运行情况灵活调整。
黑板:调度

我们如今来详细讲解 动态分派(Dynamic Dispatch) 的核心头脑和详细实现方式。

动态分派的核心目的

本质上,我们希望实现的是:根据某个内存位置的数据内容,来决定实验哪个函数。
这与通例做法相反——通常我们是调用一个已知函数,并传入数据。而动态分派的做法是:我们先有数据,然后根据数据内容来“反推出”应当实验的函数。

传统静态调用 vs 动态分派

比如我们有如下两种结构体:
  1. struct Foo {
  2.     // ...
  3. };
  4. struct Bar {
  5.     // ...
  6. };
复制代码
我们可能写了两个函数:
  1. void DoFoo(Foo* f);
  2. void DoBar(Bar* b);
复制代码
在程序中,当我们知道自己拿到的是 Foo 或 Bar 时,可以明白地调用 DoFoo() 或 DoBar()。
但有些场景中,我们希望不提前知道数据的类型,而是通过数据自己来决定调用哪个处置处罚函数。我们不想手动写一堆 if/else,而是希望数据自己带着“该怎么处置处罚自己”的信息。

怎样实现动态分派(函数与数据绑定)

可以通过在结构体中参加一个函数指针来实现:
  1. struct Foo {
  2.     void (*doFunc)(Foo*);  // 函数指针
  3.     // 其他成员
  4. };
复制代码
如许,每个 Foo 实例就可以携带自己的行为函数。比方:
  1. void FooHandler(Foo* f) {
  2.     // 做一些事情
  3. }
  4. Foo f;
  5. f.doFunc = FooHandler;
  6. f.doFunc(&f);  // 调用函数,无需关心具体类型
复制代码
这种方式的好处在于:我们把“数据”和“处置处罚该数据的逻辑”放在了一起。每个内存块都可以携带一个“行为指针”,当我们想对这个数据举行操作时,只需要调用这个函数指针即可。

多种结构共用分派机制

我们可以继续扩展这个思路,比如说:
  1. struct Foo {
  2.     void (*doFunc)(void*);  // 函数签名统一,参数为 void*
  3.     int a;
  4. };
  5. struct Bar {
  6.     void (*doFunc)(void*);
  7.     float b;
  8. };
复制代码
如今,无论是 Foo 还是 Bar,只要我们约定函数指针在结构体的首部(第一个字段),那么我们可以不关心详细类型,只要知道该结构体的开头就是一个函数指针即可:
  1. void DoGeneric(void* p) {
  2.     void (**func)(void*) = (void (**)(void*))p;
  3.     (*func)(p);
  4. }
复制代码
如许,就可以实现统一入口,实验时根据数据自己携带的函数指针举行处置处罚,实现面向行为的分派

应用场景与实用范围


  • 在需要高扩展性的系统中,比方实体系统、插件系统等;
  • 当数据类型可能非常多、行为可以动态绑定时;
  • 不希望使用复杂的面向对象体系结构时,采用纯 C 风格处置处罚方式;
  • 想把处置处罚逻辑和数据打包成一体、提高模块独立性和封装性时。

动态分派的简明总结


  • 将函数指针放在结构体中,实现函数和数据的绑定;
  • 通过访问结构体中的函数指针,实现按需调用;
  • 假如函数指针作为结构体第一个字段,可以实现通用调用机制;
  • 适合处置处罚类型不确定但行为确定的系统逻辑;
  • 不依靠类继续或虚函数机制,是一种轻量级且高效的动态逻辑切换方式。

这就是动态分派的基本机制与应用方式。通过这种机制,我们可以让每块数据都拥有自己的行为定义,在实验时主动完成正确的行为调用。如此一来,无需手动判定类型或构建复杂分支,整个系统的设计也更加清晰和高效。
黑板:C++的动态调度实现

我们在设计系统中使用动态分派,核心目的是希望可以大概根据详细的数据对象,在运行时动态选择并调用相应的处置处罚函数。而我们以为,C++ 对于动态分派的实现是极其糟糕的,其设计放弃了大量灵活性,只保存了一点点压缩空间的上风,导致其实际用途极为有限。下面详细解释。

动态分派的本质

动态分派的本质是:我们有一个数据结构,希望能在运行时为这个结构动态决定该调用哪个函数。这与传统调用方式相反,传统方式是我们在代码中明白指定调用哪个函数,然后传入数据,而动态分派是数据中“自带”它该实验的函数。
这类做法在手写结构体时非常自然,比如我们在结构体中添加一个函数指针:
  1. struct Foo {
  2.     void (*Do)(Foo*);
  3. };
复制代码
如许每个对象都可以独立设置自己的 Do 函数指针,实现真正的动态行为。

C++ 动态分派的做法(虚函数)

C++ 使用虚函数(virtual function)来实现动态分派,其做法是:

  • 每个含有虚函数的类,编译器会为它生成一个静态虚函数表(vtable)
  • 每个对象实例的前面,会隐式保存一个指向该表的指针;
  • 调用虚函数时,会通过这个表指针查找对应的函数地址,并跳转实验。
听上去似乎也做到了动态分派,但其实存在非常多的问题:

C++ 实现的问题和限制


  • 不能访问或修改虚表指针
    无法直接访问对象内部的虚表指针,也无法修改它。我们不能随时更换对象的行为逻辑,也不能在运行时切换不同的 vtable。
  • 无法为每个函数独立切换行为
    vtable 是整个函数表的集合,不能只修改其中某个函数的指针。只能整体切换,不支持更细粒度的控制。
  • 不支持每个实例动态定义自己的行为
    所有同一类型的实例都共享同一个 vtable,无法让两个实例具有不同的动态行为,完全丧失了动态性。
  • 调度服从反而更低
    调用虚函数需要做一次间接跳转,也就是双重指针解引用;同时,由于所有函数会合在一个表里,还可能引入缓存失效或局部性降低等性能问题。
  • 语义被语言语法锁死
    所有行为都绑定在类定义中,运行时缺乏灵活性。我们不能根据运行条件动态改变对象的行为,这严重限制了系统的表达能力。

我们自定义实现的上风

相对 C++ 的机制,我们手动在结构体中添加函数指针的方式拥有巨大的灵活性:

  • 每个对象实例可独立设置行为函数
    每个对象的函数指针都是独立的,随时可以变动,完全支持运行时动态切换。
  • 可自由组合行为函数
    可以设置多个函数指针字段,比方 Do, Draw, Update 等,各自可以绑定不同逻辑,互不影响。
  • 可以动态切换对象“类型”
    可以通过切换多个函数指针或表指针,让对象在运行时“变形”,拥有不同的行为集合。
  • 更清晰、直观的控制模子
    不依靠编译器黑盒操作,行为绑定逻辑直接体如今结构体和初始化代码中,更易于调试和明白。
  • 无需继续体系
    不需要建立复杂的类继续关系,单一结构体共同函数指针就可以实现所有多态特性,实用于更广泛的场景。

C++ 的设计选择:以“压缩”为优先

可以推测,C++ 当初选择 vtable 机制的初志,可能是为了节流空间:

  • 多个对象共享一个 vtable,节流内存;
  • 所有虚函数会合管理,便于类型静态推导。
但为此断送了动态性、灵活性、可控性、可扩展性,甚至降低了实验服从。我们以为这种折中方式是极其不值得的。

我们的建议与做法

我们明白选择:不使用 C++ 的虚函数机制,而是自己在结构体中定义函数指针。如许做可以获得更高的动态性、更大的行为表达能力以及更符合需求的控制结构。

  • 结构体首部放置行为函数;
  • 根据对象类型、状态,动态赋值;
  • 可根据实际运行状态修改行为逻辑;
  • 支持混淆、切换、合并不同行为逻辑集;
  • 用一套极简方式,替代语言级别的复杂特性。

总结

C++ 的虚函数机制本质上是一种受限的动态分派机制,险些是最弱的一种实现方式。我们弃用它的缘故原由在于:

  • 无法动态修改;
  • 不支持对象级别的行为设置;
  • 服从与灵活性都差;
  • 难以扩展,不利于复杂系统架构设计。
我们更倾向于手动构建函数指针机制,它能满足系统在动态行为、可控性与扩展性方面的全部需求,具有更强的实用价值。
黑板:为什么我们的系统不会使用动态调度

我们基本不会在这个系统中使用动态分派,缘故原由非常明白:我们的实体系统寻求的是一种非常灵活、可变、非结构化的设计方式。我们并不希望通过传统的面向对象方法将实体类型事先定义清晰,比如“这个实体是主角”“那个实体是某种对头”。相反,我们的目的是让实体的本质完全模糊,它们只不过是64KB空间中散布着的一堆参数。

实体的设计哲学:极致的灵活性

我们的设计思路是:

  • 实体仅由其拥有的参数定义;
  • 这些参数以分散的方式存储在内存中;
  • 渲染时,我们从这些参数中提取可视化信息;
  • 在任何阶段,我们都不需要也不想知道这个实体“是什么”。
这意味着我们拒绝使用任何固定的类型分类系统。一旦我们引入了子类或函数指针来定义行为,就即是锁定了实体的“类型”,从而丧失了流动性与可变性。我们寻求的是:

  • 每个实体都可以演化为任何其他类型的实体;
  • 每个属性都可以互相组合、融合;
  • 整个系统像一个“熔炉”,没有硬性的界限。

目的:构建一个“万物可变”的系统

我们愿意为此目的付出代价:

  • 可以接受代码更难写;
  • 可以接受调试更复杂;
  • 可以接受系统在初期阶段的低效。
因为我们信赖,通事后续优化,这些问题是可以逐步解决的,但灵活性和高度动态性必须优先确保。我们寻求的是一个**“神圣的动态结构”** —— 任何时候,任何实体都可以改变自己的特性,拥有不同的表现或行为,而不依靠于事先定义好的类型或逻辑绑定。

动态分派为何不适合我们的系统

动态分派,比如虚函数大概函数指针的方式,会将行为静态绑定在某个结构上:

  • 它要求事先定义某些函数;
  • 然后通过函数指针或虚函数表来调用这些函数;
  • 这就即是说“这个对象必须知道自己是个什么”。
这和我们要的完全背道而驰。
在我们系统中,不需要让实体知道自己是谁,它只需要在被使用时暴露出自己所拥有的参数即可。我们希望系统可以:

  • 基于多个属性综合判定;
  • 通过逻辑推导决定该做什么;
  • 而不是直接跳转到某个绑定好的函数。

取而代之的机制:枚举 + 逻辑组合

我们更倾向于使用枚举值共同逻辑判定来驱动行为选择:

  • 每个实体拥有多个属性(通过枚举编码);
  • 系统根据多个属性的组合判定该实验什么行为;
  • 比方判定它的“移动类型”“动画类型”等,然后推导出该怎么渲染、怎么响应输入等。
这种做法固然比直接调用函数指针更复杂,但它答应我们跨多个维度组合信息,从而生成行为,而不是依靠一对一的绑定。
举例说明:
  1. if (entity.move_type == WALK && entity.form == HUMANOID) {
  2.     draw_legs(entity);
  3. } else if (entity.move_type == FLY && entity.form == INSECT) {
  4.     draw_wings(entity);
  5. }
复制代码

多维组合的上风

使用这种模式我们可以获得:

  • 多态行为无需继续结构
  • 行为可以根据任意数目的状态组合变化
  • 运行时可以轻松添加新的组合逻辑而无需修改结构定义
  • 更贴近真实游戏逻辑中的非线性多样性
而这些是传统函数指针机制难以表达的。

总结

我们放弃动态分派的根本缘故原由在于:

  • 它绑定行为于类型,限制了实体的可变性;
  • 它不适合我们的“融合型”实体系统设计;
  • 它无法支持任意组合属性推导行为的逻辑流程;
  • 它将“对象是谁”视为前提,而我们只关心“它如今该干什么”。
我们选择了一种更加原始但也更强盛的方式,通过枚举、数据组合与逻辑判定来驱动实体行为。这种方式尽管更难实现,但它带来了真正的自由,是我们实现非常动态、混淆、可塑实体系统的核心本事。
你在当前游戏中使用结构体条目方法吗?

我们当前的游戏中还没有实现任何实体系统。这意味着在目前的阶段,游戏并没有包括角色、对头、物体等概念上的“实体”。换句话说,游戏世界中还没有一个被定义为“实体”的对象存在。

当前阶段的开发重点

我们临时还没有进入到需要实体结构的那一部分开发流程,当前关注的可能更多是底层系统、渲染机制或工具链的构建。因此:

  • 没有设计或实现“实体”所需要的数据结构;
  • 没有定义实体行为的逻辑代码;
  • 也没有采用或开发任何和实体相关的系统框架,比如组件系统或继续体系。

未来可能涉及的方向

固然目前没有实体,但未来一旦进入游戏逻辑层面的开发,我们可能会考虑构建一个符合我们设计理念的实体系统:

  • 可能会是无类型、动态、灵活的实体结构;
  • 每个实体通过属性集合举行定义,而非通过类型分类;
  • 使用统一的数据结构和逻辑判定来驱动行为和渲染;
  • 避免传统“实体=类+行为”的设计,改为高度解耦的参数系统。

小结

当前的游戏尚处于前期架构搭建阶段,还未进入涉及实体的开发工作,因此尚未实现或使用任何“实体”相关的机制或方法。未来一旦需要引入实体,将会按照我们此前建立的“非常灵活、参数驱动”的设计理念举行构建。
你见过我在直播中使用的伪黑客判别联合继续系统吗?它非常酷

Discriminated union 这个不知道怎么翻译
在游戏的开发过程中,有提到一种伪黑客的可区分联合继续系统。这种系统在某些情况下会被使用,而且似乎和**可区分联合(Discriminated Union,简称DU)**有关。可区分联合是一个非常强盛的结构,可以大概让数据类型在运行时基于不同的条件举行选择,从而顺应不同的情境。
目前来看,这种方式在开发中可能得到了广泛的应用,比如在游戏中,99% 的代码都涉及到了使用可区分联合的模式。这种设计理念非常适适用于处置处罚不同的实体或对象类型,尤其是在灵活性和类型安全性之间做出平衡时。
固然详细的伪黑客可区分联合继续系统细节未被详细描述,但从已知信息来看,这种方法可能强调了灵活性和类型的动态管理。选择这种方式的缘故原由可能是为了减少传统面向对象编程中的复杂继续结构,避免过于僵化的类型约束
Discriminated Unions(也称为 Tagged UnionsSum Types)是一种数据结构,用于表示一个值可以是多种不同类型中的一种,并且通过一个“标签”(discriminator)来区分详细是哪种类型。它在编程语言中常用于类型安全地处置处罚多种可能的变体,常见于函数式编程语言(如 Rust、Haskell、OCaml)以及一些支持高级类型系统的语言(如 TypeScript)。
在 C++ 中,Discriminated Unions 通常通过 std::variant(C++17 引入)或手动实现的结构体/枚举组合来实现。它的核心头脑是:

  • 存储一个值,这个值可以是几种类型之一。
  • 提供一个标识(标签),指示当前存储的是哪种类型。
  • 确保类型安全,避免错误地访问不正确的类型。

Discriminated Union 的特点


  • 互斥性:在一个 Discriminated Union 中,值在任意时候只能是其可能类型之一。
  • 类型安全:通过标签或类型检查,确保访问的值与当前类型匹配。
  • 高效性:内存占用通常是所有可能类型的最大尺寸加上标签的开销。

举例说明

以下通过几个例子展示 Discriminated Unions 在不同语言中的实现。
1. C++ 中的 Discriminated Union(使用 std::variant)

假设我们需要表示一个值,它可以是 int、 double 或 std::string 中的一种。我们可以使用 std::variant 实现:
  1. #include <iostream>
  2. #include <variant>
  3. #include <string>
  4. int main() {
  5.     // 定义一个 Discriminated Union,可以存储 int、double 或 std::string
  6.     std::variant<int, double, std::string> value;
  7.     // 存储一个 int
  8.     value = 42;
  9.     std::cout << "Value is int: " << std::get<int>(value) << std::endl;
  10.     // 存储一个 double
  11.     value = 3.14;
  12.     std::cout << "Value is double: " << std::get<double>(value) << std::endl;
  13.     // 存储一个 string
  14.     value = std::string("Hello");
  15.     std::cout << "Value is string: " << std::get<std::string>(value) << std::endl;
  16.     // 使用 std::visit 安全地访问值
  17.     std::visit([](const auto& v) {
  18.         std::cout << "Current value: " << v << std::endl;
  19.     }, value);
  20.     // 错误访问示例(会抛出异常)
  21.     try {
  22.         std::get<int>(value); // 此时 value 是 string,抛出 std::bad_variant_access
  23.     } catch (const std::bad_variant_access& e) {
  24.         std::cout << "Error: " << e.what() << std::endl;
  25.     }
  26.     return 0;
  27. }
复制代码
输出
  1. Value is int: 42
  2. Value is double: 3.14
  3. Value is string: Hello
  4. Current value: Hello
  5. Error: bad variant access
复制代码
解释

  • std::variant<int, double, std::string> 是一个 Discriminated Union,value 可以存储 int、double 或 std::string 中的一种。
  • std::variant 内部维护一个索引(标签),记载当前存储的是哪种类型。
  • 使用 std::get<T> 或 std::visit 访问值,确保类型安全。假如访问错误的类型,会抛出异常。
  • 这避免了传统 C 风格的 union 的不安全性(传统 union 不会跟踪当前类型,容易导致未定义行为)。

2. Rust 中的 Discriminated Union(使用 enum)

Rust 的 enum 是 Discriminated Union 的范例实现,广泛用于类型安全的变体处置处罚。假设我们想表示一个外形,它可以是圆形、矩形或三角形:
  1. #[derive(Debug)]
  2. enum Shape {
  3.     Circle { radius: f64 },
  4.     Rectangle { width: f64, height: f64 },
  5.     Triangle { base: f64, height: f64 },
  6. }
  7. fn area(shape: &Shape) -> f64 {
  8.     match shape {
  9.         Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
  10.         Shape::Rectangle { width, height } => width * height,
  11.         Shape::Triangle { base, height } => 0.5 * base * height,
  12.     }
  13. }
  14. fn main() {
  15.     let circle = Shape::Circle { radius: 2.0 };
  16.     let rectangle = Shape::Rectangle { width: 3.0, height: 4.0 };
  17.     let triangle = Shape::Triangle { base: 3.0, height: 5.0 };
  18.     println!("Circle: {:?}", circle);
  19.     println!("Circle area: {}", area(&circle));
  20.     println!("Rectangle: {:?}", rectangle);
  21.     println!("Rectangle area: {}", area(&rectangle));
  22.     println!("Triangle: {:?}", triangle);
  23.     println!("Triangle area: {}", area(&triangle));
  24. }
复制代码
输出
  1. Circle: Circle { radius: 2.0 }
  2. Circle area: 12.566370614359172
  3. Rectangle: Rectangle { width: 3.0, height: 4.0 }
  4. Rectangle area: 12
  5. Triangle: Triangle { base: 3.0, height: 5.0 }
  6. Triangle area: 7.5
复制代码
解释

  • Shape 是一个 Discriminated Union,包含三种变体:Circle、Rectangle 和 Triangle。
  • 每个变体可以携带不同类型的数据(比方,Circle 携带 radius,Rectangle 携带 width 和 height)。
  • match 表达式用于安全地处置处罚每个变体,编译器会确保你处置处罚了所有可能的变体(穷尽性检查)。
  • Rust 的 enum 提供类型安全和内存服从,标签(表示当前是哪种变体)由编译器主动管理。

3. TypeScript 中的 Discriminated Union

TypeScript 使用带有“标签字段”的联合类型来实现 Discriminated Union。假设我们想表示不同的消息类型:
  1. type Message =
  2.     | { kind: "text"; content: string }
  3.     | { kind: "image"; url: string; caption?: string }
  4.     | { kind: "error"; code: number; message: string };
  5. function processMessage(msg: Message) {
  6.     switch (msg.kind) {
  7.         case "text":
  8.             console.log(`Text message: ${msg.content}`);
  9.             break;
  10.         case "image":
  11.             console.log(`Image URL: ${msg.url}, Caption: ${msg.caption ?? "None"}`);
  12.             break;
  13.         case "error":
  14.             console.log(`Error ${msg.code}: ${msg.message}`);
  15.             break;
  16.     }
  17. }
  18. const textMsg: Message = { kind: "text", content: "Hello, world!" };
  19. const imageMsg: Message = { kind: "image", url: "http://example.com/img.jpg" };
  20. const errorMsg: Message = { kind: "error", code: 404, message: "Not found" };
  21. processMessage(textMsg);
  22. processMessage(imageMsg);
  23. processMessage(errorMsg);
复制代码
输出
  1. Text message: Hello, world!
  2. Image URL: http://example.com/img.jpg, Caption: None
  3. Error 404: Not found
复制代码
解释

  • Message 是一个联合类型,包含三种变体,每种变体通过 kind 字段(标签)区分。
  • kind 字段充当 Discriminator,用于在运行时确定详细是哪种变体。
  • 使用 switch 或 if 语句根据 kind 处置处罚不同的变体,TypeScript 的类型系统确保类型安全。
  • TypeScript 的类型检查会提示你处置处罚所有可能的 kind 值。

Discriminated Union 与普通 Union 的区别

在 C/C++ 中,传统的 union 也可以存储多种类型之一,但它不安全,因为它不跟踪当前存储的类型。比方:
  1. union UnsafeUnion {
  2.     int i;
  3.     double d;
  4.     char* s;
  5. };
复制代码

  • UnsafeUnion 可以存储 int、double 或 char*,但程序员需要手动跟踪当前类型。
  • 假如错误地访问了不正确的类型(比方,将 double 解释为 int),会导致未定义行为。
相比之下,Discriminated Union(如 std::variant 或 Rust 的 enum)通过标签或类型系统确保类型安全,防止错误访问。

总结


  • Discriminated Union 是一种类型安全的联合类型,通过标签区分值的详细类型。
  • 它在 C++ 中通过 std::variant 实现,在 Rust 中通过 enum 实现,在 TypeScript 中通过带标签的联合类型实现。
  • 长处:类型安全、内存高效、表达力强,特殊适合处置处罚多种变体的场景。
  • 应用场景:状态机、消息处置处罚、外形盘算、错误处置处罚等。
为什么选择这种方法,而不是AoS风格并在主实体结构体中存储每个组件的索引?

这种方法比起 AO S(Array of Structures)风格以及每个组件在主要实体结构中的索引更具上风。之以是不选择这种方式,是因为希望代码中的所有内容都能有硬编码的偏移量,即所有的内存位置都是固定的。这意味着,所有代码都将是纯C代码,可以大概让编译器对其举行充分优化,直接在一块连续的内存区域内举行操作,从而提高性能。假如明白了这一点,就能明白为什么会采用这种方法。
我错过了。矩阵[i,j]是什么?我有点迷糊

在矩阵操作中,很多时候需要检察特定的元素,比方“我上面那个元素是什么”大概“我在矩阵中的镜像元素是什么”。假如矩阵的存储方式是希罕存储,只记载那些非零元素的位置,那么每次查询这些元素时就需要不断地查找或扫描存储结构,这两者的服从都很低。因此,在已往,通常采用的做法是,创建一块大的内存块,把矩阵元素存放进去,并且初始化为零。然后,在举行操作时,把需要的元素放入内存块中,操作完毕后再将它们取出。如许做的好处是,可以直接举行查找,就像是为矩阵元素创建了一个完善的哈希表,从而提高了查找服从。
你会在什么地方检查你的‘超级结构体’是否存在某个属性?假如未定义,它们会是空指针吗?

在举行操作时,可以通过检查“mega struct”中的某个属性是否存在来判定。假如属性存在,它的值是已定义的,而不是一个指针。所有数据都会直接存储在结构体内部,并且每个属性都会初始化为一个默认值。比方,假如某个属性表示“是否着火”,其初始值为零,表示没有火。当查询该属性时,会始终得到一个值,表示它是否着火。这种方式确保了每个属性都有一个固定的值,无论它是否处于某个特定状态。
你打算用什么来追踪哪个结构体成员已被触及或重新设置为空?

关于是否追踪结构体成员是否被修改,目前还不确定,可能需要在开发过程中举行一些测试。根据测试的结果,假如性能答应,可能不会做任何优化,仍旧会扫描整个64K的内存块。固然完全扫描可能会比较慢,但考虑到今世盘算机的处置处罚速度,这个过程并不会像预期的那样缓慢。当开发进一步深入,开始关注优化问题时,可能会考虑根据实际需求来决定怎样追踪哪些部分已经被修改。
你预见到这个设计的哪些部分可能会造成性能问题?

一种潜在的问题是内存带宽的浪费,尤其是在缓存利用方面。假如一个结构体被分别为64字节的块举行存储,处置处罚时每次访问某个特定成员(比方一个4字节的变量)时,实际上需要加载整个64字节的块进缓存。如许即使只使用了4字节,其他60字节也会被加载到缓存中,造成内存带宽的浪费。这种结构的优化效果取决于怎样安排结构体成员以及成员的希罕水平。假如结构体的内存布局不理想,可能会出现大量无用的内存带宽被占用。
这种问题的影响并非致命,因为通常我们不会处置处罚成千上万的实体,通常只会处置处罚当前视野内的几个实体。比方,可能需要处置处罚300个实体每秒60帧,大概30帧每秒的场景。这意味着假如内存带宽的浪费比较严重,处置处罚仍旧可能保持在可接受的范围内。然而,随着实体数目的增长,尤其是在大型游戏中,内存带宽的浪费可能变得不那么可忽视。尤其是在上千个实体的情况下,假如占用了过多的内存带宽,可能会影响性能,甚至变得不太舒服。
固然这种方式带来的内存带宽浪费是一个潜在问题,但考虑到实体数目相对较少,这个问题并不是致命的,仍旧可以尝试。但也不能掉以轻心,不能完全忽视这种可能导致问题的风险。
实体系统的替代方案有哪些?

在游戏开发中,实体系统险些是不可避免的,尤其是在涉及到具有多个可交互对象的游戏中。即使是比较简朴的游戏,比方经典的《吃豆人》,也可以看作拥有某种实体系统。固然游戏的代码可能只是简朴地处置处罚几个固定的幽灵,但这些幽灵在游戏中作为独立的实体存在,并且它们之间可能会有碰撞、互动等操作。这种方式就是一个简朴的实体系统。
传统的动作游戏通常都会依靠于实体系统,因为这些游戏的核心就是管理和操作这些独立的实体。无论是射击、碰撞还是其他交互,都会依靠实体系统来处置处罚这些元素。
然而,并不是所有的游戏都需要实体系统。比方,一些类似“行走模仿器”的游戏,它们的核心玩法并不依靠于实体之间的交互,更多的是关于情况的探索、叙事的推进大概某种静态世界的沉醉感。在这些游戏中,实体的概念可能就不再重要。
同样,像《我们1935年》这类以电影风格出现的互动游戏,玩法本质上是通过与对话互动来推进剧情,这时实体的作用也险些不存在。没有需要举行碰撞检测或实体之间交互的需求,更多的是通过叙事和视觉效果来举行互动。
总之,实体系统对于大多数传统的动作游戏至关重要,但对于一些特定类型的游戏(如行走模仿器、互动电影等),则可能并不需要实体系统。
你是否已经在考虑备份筹划,并且假如必须转换到该系统,你会怎样举行?

目前并没有详细的备份筹划来应对实体系统可能出现的问题。通常来说,假如系统出现问题,比如缓存问题,大概其他类型的问题,开发人员会通过监测来识别问题的发生位置,并据此举行调整和优化。系统的调整将会根据实际问题的性质来举行,解决方案会根据问题的发生情况逐步形成。
目前,游戏并没有寻求超高的处置处罚能力,因此不太担心系统在高负载下出现无法解决的问题。即便出现问题,预期中也可以通过某些方法解决。至于最终系统的详细形态,还不确定,因为无法完全预测各种权衡和选择会怎样影响系统的表现。因此,现阶段并没有预先确定一个最终的备份筹划,而是预备根据实际情况举行调整。
你玩过陌头霸王吗?你最喜欢哪个角色?

并不玩《陌头霸王》,而是更喜欢《真人快打》,尤其是《真人快打1》。对于厥后的《真人快打》系列,固然玩过《2》和《3》,但并没有继续玩下去。喜欢最初的《真人快打》,但对于《陌头霸王》及其后续版本并不感兴趣。缘故原由之一是无法忍受动画停息效果,尤其是在格斗游戏中,当某个角色举行上勾拳时,动画会忽然停息然后继续,这种效果让人无法接受,影响了游戏体验。
因此,《陌头霸王》这款游戏的感觉并不对胃口,尽管它更受接待,也未能让人真正沉迷其中。相反,《真人快打1》在自己的家乡和周边地区玩得非常轻松,险些可以成为当地的最佳玩家,而《陌头霸王》由于玩家数目更多,可能更难达到顶尖水平,因此并未投入太多精力去尝试。
实体系统会包括像粒子特效如许的东西吗?

对头系统包括了粒子效果等内容。粒子效果通常是图形效果,它们通常会被触发或生成,但并不由对头系统来处置处罚。这些粒子效果通常是为了高效的吞吐量而设计的,并且它们没有可变的属性。比如,假如某个物体生成了火焰粒子效果,那么这些火焰粒子一旦生成,就不会自行变成水等其他形态,任何变化都必须由外部因素触发或修改。因此,粒子效果在对头系统中仅用于生成,而不是举行复杂的属性变动或处置处罚。
你玩过炸弹人吗?

有些人会让人感到特殊难以忍受,尤其是那些表现得过于甜蜜或卖弄的人。他们常常通过表面上的友好和甜言蜜语来掩饰自己真实的意图大概行为。这种行为往往让人感到不真实,甚至让人产生一种被利用的感觉。固然表面上他们可能表现得非常热情和蔼良,但往往背后却隐蔽着某些不为人知的动机。对这些人来说,尽管外表的甜蜜和密切可能会让一开始打仗的人产生好感,但随着深入了解,往往会发现他们并不真诚,这种卖弄的行为使得与他们相处变得非常困难和不舒畅。
你受不了陌头霸王的停息,但却喜欢恶搞战士的旋转画面?

关于游戏的外观和感觉,固然《陌头霸王》和《真人快打》这两款游戏在外观上各有不同,但实际上它们的吸引力并不完全取决于视觉效果,而是游戏的操作感和流畅度。对于《陌头霸王》,固然它的画面更具吸引力,某些人可能会以为它的外观更为精致,但其中的“动画停息”设计是一个致命的缺点,尤其是在实验某些特殊动作时,角色动作会忽然停息,这种设计粉碎了游戏的流畅感。而《真人快打》尽管视觉效果不如《陌头霸王》,但它的操作感更加连贯,不会因动画停息而中断,给人一种更流畅、更紧凑的战斗体验,这使得《真人快打》在操作体验上要优于《陌头霸王》。固然这两款游戏的画面风格各有千秋,但最重要的还是游戏的手感和连贯性,尤其是在战斗过程中,停息的设计直接影响了游戏的流畅性和玩家的沉醉感。
是的,这看起来合理,简直很少会有300个实体需要60FPS的模仿,除非你说的那种子弹地狱,子弹作为实体,而不是作为独立优化的对象

在考虑游戏中实体的数目时,固然一开始听起来可能以为 300 个实体在 60 帧每秒的模仿中不太可能实现,但实际上,整个游戏世界是由大量实体组成的。比方,每个地面元素也可以视作一个实体,以是假如在一个 17x9 的瓦片区域,每个瓦片上有两个实体(一个是瓦片自己,另一个是站在其上的物体),那么就已经有 306 个实体。而假如每个实体还配有一个子弹,可能就有 600 个实体,这个数目在游戏中是合理的。
因此,假如想要举行压力测试,测试 1000 个实体在 60 帧每秒的情况下是有可能的。假如我们考虑到 60,000 个实体,这其实并不会消耗非常多的盘算周期。假设使用的是一台 3 GHz 的机器,并且要保持每秒 60 帧,那么每个实体所需的盘算周期其实并不多,尽管如此,这依然是一个值得注意的因素。由于游戏是 GPU 加快的,图形处置处罚方面的工作负担不大,这有助于缓解一些压力,但每个实体大约需要 50,000 个盘算周期,这并不是一个非常充裕的数字,因此仍旧需要保持警觉。
总的来说,固然看起来这些盘算量不会引发严重问题,但仍旧存在一定的不确定性,因此需要在实际开发中举行充分的测试和优化,以确保游戏可以大概顺利运行。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
继续阅读请点击广告
回复

使用道具 举报

×
登录参与点评抽奖,加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表