AIGC标识 【游戏架构】【笔记】优化型模式——《游戏编程模式》学习笔记005

该笔记中有AI辅助内容。

数据局部性

“通过合理组织数据利用CPU的缓存机制来加快内存访问速度。”

为了提高数据局部性,现代游戏底层不再一味使用传统的面向对象结构。

当代CPU带有多级缓存以提高内存访问速度。这一机制加快了对最近访问过的数据的邻近内存的访问速度。通过增加数据局部性并利用这一点可以提高性能——保持数据位于连续的内存中以供程序进行处理。


使用场景

找出出现性能问题的地方,并且是由于缓存未命中引起的。

不要在非频繁执行的部分浪费时间。

检查工具:Cachegrind

1. 缓存行(Cache Line)与装载粒度

CPU 并不是按字节(Byte)向主存读取数据的,而是以 缓存行(Cache Line,常见为 64 Bytes) 为单位拉取。

  • 当你读取一个 int(4 Bytes)时,CPU 顺带将它相邻的 60 Bytes 一起载入了 L1 Cache。
  • 空间局部性(Spatial Locality):如果你紧接着读取内存中相邻的下一个变量,直接从 L1 命中(耗时 ~1ns);如果指针跳跃到了另一个内存地址,触发 Cache Miss,CPU 必须暂停流水线等待从 DRAM 读取(耗时 ~100ns,相差近百倍)。

2. 多线程陷阱:伪共享(False Sharing)

当两个运行在不同 CPU 核心上的线程,频繁修改位于同一个 Cache Line 内的不同变量时,核心之间为了保持缓存一致性(MESI 协议),会导致该 Cache Line 在多个核心间反复失效(Cache Line Bouncing),大幅降低并发性能。

  • 解法:使用 alignas(64) 或填充字节(Padding)将多线程竞态数据隔离到不同的 Cache Line 中。

注意事项

之前学习的许多解耦的方法,都用上了指针或者虚方法,而使用指针进行访问,也就是要在内存里来回跳跃,这都会导致容易出现缓存未命中的现象。

所以为了做到缓存友好,就可能需要牺牲一些之前所做的抽象化。


设计决策

  1. 如何处理多态
    • 避开继承
      尽量避开子类化,或者说至少在进行缓存优化的地方避开继承
      • 优点:
        • 安全而容易
        • 速度更快
      • 缺点:
        • 灵活性差
    • 为不同的对象类型使用互相独立的数组
      • 优点:
        • 让对象紧密地封包
        • 可静态地调用分发
      • 缺点:
        • 但是必须时刻追踪这些集合
        • 必须注意每一个类型,无法从这些类型集合中解耦它们
    • 使用指针集合
      • 优点:
        • 灵活度高
      • 缺点:
        • 缓存不友好
  2. 游戏实体中组件是如何定义的
    • 假如游戏实体通过类中的指针来索引其组件
      • 优点:
        • 可以将组件存于相邻的数组中
        • 可以轻易获取给定的实体的组件
      • 缺点:
        • 很难在内存中移动组件
    • 游戏实体通过一系列ID来索引其组件
      • 缺点:
        • 更加复杂
        • 更慢
        • 需要访问组件管理器
    • 游戏实体本身就只是个ID
      • 优点:
        • 没有游戏实体类,而是优雅的数值包装
        • 无需管理游戏实体的生命周期
      • 缺点:
        • 实体类本身是空的,不再有地方存放非组件构成的实体状态和行为
        • 检索一个实体的所有组件会很慢

核心内存布局范式(数据组织方式)

1. AoS vs SoA(结构体数组 vs 数组结构体)

  • AoS (Array of Structures - 传统 OOP)

    struct Particle { Vector3 pos; Vector3 vel; Color color; float lifetime; };
    Particle particles[1000]; // 内存:[pos, vel, color, life][pos, vel, color, life]...
    
    
    • 问题:如果某帧只需要更新所有粒子的位置(pos + vel),每读取一个粒子,color 和 lifetime 也会被强制装进 Cache Line,挤占了有效数据空间。
  • SoA (Structure of Arrays - 数据定向 DOP)

    struct ParticleSystem {
    	Vector3 pos[1000];
    	Vector3 vel[1000];
    	Color color[1000];
    	float lifetime[1000];
    }; // 内存:[pos, pos, pos...][vel, vel, vel...]
    
    
    • 优势:更新位置时,CPU 连续载入全都是 pos 数据,Cache 命中率达到 100%,且极易触发 SIMD(单指令多数据)向量化加速。

2. 冷热数据分离(Hot / Cold Data Splitting)

将高频更新的数据(热数据,如 Position、Velocity、Health)集中紧凑存放;将低频访问的数据(冷数据,如 Name、LootTable、AudioSource)剥离出去,通过指针或句柄在需要时延迟查找。


引擎落地(Unity / UE)

维度 Unity (DOTS / Entities) Unreal Engine (C++)
内存组织形式 Archetype & Chunk (16KB)

相同组件组合的实体紧凑连续打包放在 16KB 的内存块中。
TArray<T> 连续动态数组

替代 C++ 原生 std::list 或指针链表。
高级框架 Entities / Burst Compiler

借助 SoA 布局自动生成 SIMD 汇编代码。
Mass Framework (UE5)

UE 专为大群 AI / 粒子级 Actor 设计的数据定向 (DOP) 架构。

拓展:性能分析与检测工具链

除了 Cachegrind 之外,现代游戏工业界更常用的性能剖析工具包括:

  1. Intel VTune Profiler(CPU 硬件级分析):精确统计 L1/L2/L3 Cache Miss 比例与 CPI(Clock Cycles Per Instruction)。
  2. Tracy Profiler(轻量级实时 Profiler):跨平台性能插桩工具,极其适合分析多线程与内存分配瓶颈。
  3. Unreal Insights / Unity Frame Debugger:分析引擎主线程 Tick 与内存连续性表现。

脏标记模式

“将工作推迟到必要时进行以避免不必要的工作。”

脏标记模式(Dirty Flag Pattern)的核心——利用“延迟计算(Lazy Evaluation)”与“结果缓存(Caching)”来用空间/标志位换取 CPU 计算性能。

当有一组原始数据随时间变化,需要将这些数据以代价昂贵的操作来确定一组新的衍生数据,这时候我们可以使用脏标记模式优化。
使用一个脏标记跟踪这个衍生数据是否和原始数据同步,查看它在原始数据改变时是否被设置了:

  • 如果它被设置了,那么当需要衍生数据时,它们就会被重新计算并且标记被清除;
  • 否则就使用缓存的数据。

使用场景

涉及到“计算”和“同步”的情况。

  • 原始数据的修改次数比衍生数据的使用次数多
  • 递增地更新数据十分困难
    但是这个问题的更简单的解决方法是保持一个动态的总量。当我们增加或者减少物品时,就从总量上增加或者减去这个物体的重量。像这样保持衍生数据更新时,这种方法要比使用这个模式要好。

注意事项

  1. 延时太长会有代价
    • 假如一个庞大的数据计算,延时到玩家要看到时才进行计算,就会导致视觉卡顿
    • 如果某个物体出错了,可能会完全无法工作
      比如文件编辑时未保存就意外关闭了,所以为了减缓这种损失,会做折中的自动保存
  2. 必须保证每次状态改动时都设置脏标记
    • 要避免缓存失效
      所以要在任何改动原始数据的地方设置脏标记。
      • 可将原始数据的改动封装起来——防御性封装

      决不将原始数据暴露为 public 变量,强制通过 Setter 方法改写,在修改数据的同时自动打标记:

      • C# 语法糖(Unity):
        private Vector3 _position;
        private bool _isDirty = true;
        
        public Vector3 Position {
        	get => _position;
        	set {
        		if (_position != value) {
        			_position = value;
        			SetDirty(); // 自动设置脏标记并向上/下广播
        		}
        	}
        }
        
        
      • C++ 语法(Unreal Engine):
        void UMyComponent::SetHealth(float NewHealth) {
        	if (FMath::IsNearlyEqual(Health, NewHealth)) return;
        	Health = NewHealth;
        	bIsDirty = true; // 属性改变,自动触发标记
        	OnHealthChanged();
        }
        
        
  3. 必须在内存中保存上次的衍生数据

设计决策

1. 何时清除脏标记

  • 当需要计算时
    • 优点:
      • 避免了计算从不需要使用的计算结果
    • 缺点:
      • 如果计算十分耗时,会造成明显的卡顿
  • 在精心设计的检查点
    • 优点:
      • 不会影响用户体验
    • 缺点:
      • 在游戏中,失去了控制权(玩家不一定到检查点)
  • 在后台
    在最初变动的时候启动一个固定的计时器,并在计时器到达时处理之间的所有变动
    • 优点:
      • 可以调整工作执行的频率
      • 可以做更多冗余的工作
    • 缺点:
      • 需要支持异步操作
      • 需要考虑并行修改数据的安全性

2. 脏标记追踪的粒度有多大

  • 更精细的粒度
    只需处理真正变动的数据,将真正变动的数据发送给服务器
  • 更粗糙的粒度
    需要把所有数据都发送给服务器
    • 优点:
      • 存储脏标记需要的内存更少
    • 缺点:
      • 需要固定开销花费的时间较少

经典模型:场景树 Transform 矩阵计算

脏标记模式在游戏引擎中最典型的运用就是场景树(Scene Graph / Transform Hierarchy)。

在 3D 引擎中,每个角色的世界坐标系矩阵(World Matrix)由 父节点世界矩阵 × 子节点本地矩阵 递归计算得出:

  • 级联传递(Cascading Invalidation):当父节点移动时,不立即更新所有子节点的坐标,而是递归地将自己和所有后代节点的 isDirty 标记设为 true。
  • 延迟计算:只有当渲染器(Renderer)或物理系统在帧末需要用到子角色的真实世界坐标时,才沿着树向上查找并重新计算世界矩阵,并将标记清空(isDirty = false)。

双引擎对标

脏标记在 Unreal Engine 和 Unity 中随处可见,主要集中在 Transform 变换、UI 重绘 以及 网络同步 三大模块:

模块 Unreal Engine (C++) Unity (C#)
Transform 场景树 USceneComponent

内部维护 bWorldToComponentUpdated 标记。当调用 SetRelativeTransform() 时将标记置为脏,直到 UpdateComponentToWorld() 时才重新计算。
Transform 组件

底层 C++ 引擎维护内部变换矩阵的 Dirty Bit。在 Unity DOTS/Entities 中则由 LocalToWorldSystem 专门处理带脏标记的 LocalTransform。
UI 重新布局/重绘 UMG / Slate Invalidation

控件掉帧优化利器 SInvalidationPanel。通过 EInvalidateWidgetReason(Layout, Paint, Prepass)精确标脏局部 UI,避免全屏 UI 重新绘制。
UGUI Canvas Rebuild

调用 Graphic.SetVerticesDirty() 或 SetLayoutDirty()。UI 变更时不立即重绘,而是在 CanvasUpdateRegistry 的 WillRenderCanvases 阶段统一处理所有标记为脏的 UI。
网络属性同步 Push Model Replication

在 UE5 中引入了 Push Model,当属性通过 SET_PROPERTY_DIRTY_AND_MARK_DETAILED_UNCHANGED 修改时设置 Dirty Bit Mask,只有脏属性才会被序列化发送给客户端。
Netcode for GameObjects / Mirror

通过 NetworkVariable<T> 的 SetDirty() 记录比特掩码(Bitmask),在帧末 Tick 统一将有脏标记的数据打包成网络数据包。
编辑器保存/序列化 MarkPackageDirty()

当关卡或 Asset 发生变动时标脏,编辑器右上角会出现 * 未保存状态。
EditorUtility.SetDirty()

显式告知 Unity 某个 ScriptableObject 或 Prefab 已经修改,需要在磁盘上进行持久化保存。

对象池

“使用固定的对象池重用对象,取代单独地分配和释放对象,以此来达到提升性能和优化内存使用的目的。”

定义一个保持着可重用对象集合的对象池类。

其中的每个对象支持对其“使用中(in use)”状态的访问,以确定这一对象目前是否“存活(alive)”。

在对象池初始化时,它预先创建整个对象的集合(通常为一块连续堆区域),并将它们都置为“未使用(not in use)”状态。

当你想要创建一个新对象时就向对象池请求。

它将搜索到一个可用的对象,将其初始化为“使用中(in use)”状态并返回给你。

当该对象不再被使用时,它将被置回“未使用(not in use)”状态。

使用该方法,对象便可以在无需进行内存或其他资源分配的情况下进行任意的创建和销毁。


使用场景

  • 当需要频繁地创建和销毁对象时;
  • 对象的大小一致时;
  • 在堆上进行对象内存分配较慢或会产生内存碎片时;
  • 每个对象中封装着获取代价昂贵且可充分利用的资源,如数据库、网络的连接。

1. 堆内存碎片化(Heap Fragmentation)

image

如上图所示,当程序频繁在堆上分配和释放不同大小的对象时,内存空间会被切割得支离破碎。

  • 致命问题:即使系统当前剩余的总空闲内存大于你需要申请的大小,但由于找不到一块连续的内存空间,导致 new 操作直接失败崩溃。
  • 对象池解法:在游戏启动或关卡加载时,一次性向系统申请一块连续的巨大内存块(如 1000 个 Bullet 的数组),将后续的“动态内存分配”降级为“对已有连续数组的下标偏移访问”。

2. C# 垃圾回收(GC Spike)压力 - Unity 专属

在 Unity (C#) 中,频繁 Instantiate 和 Destroy 弹幕或特效会导致托管堆迅速堆积垃圾,触发 GC 暂停(GC Freeze),造成严重的帧率掉帧。

  • 对象池将频繁的垃圾产生变为长期存活的常驻对象(直接提升至 GC 大对象堆或老年代),从根源上消除了 GC 尖峰。

使用须知

  1. 对象池可能在闲置的对象上浪费内存

    不能让池子太大,也不能让池子太小

  2. 任意时刻处于存活状态的对象数目恒定

    • 这样子不会把所有可用内存全部占用
    • 但申请复用某个对象时,可能会失败
      常见对策:
      • 阻止其发生
        • 约束对象池大小,这样子无论使用者如何分配都不会溢出
        • 但是,这就会为了避免非常罕见的边际情况而导致腾出了很多空闲的对象空间。
        • 所以可以针对不同的场景将池子调整至不同尺寸
      • 不创建对象
      • 强行清理现存对象
      • 增加对象池的大小
        可以在运行时对对象池扩容,或者增设一个二级的溢出池
  3. 每个对象的内存大小是固定的

    假如所有对象都是同一个类型,那没问题。但是假如该池子想要存入不一样类型的对象,就要保证它们的内存大小是一致的。
    当对象大小不一,将浪费内存。可以考虑根据对象的尺寸将一个池划分为多个大小不同的池。

  4. 复用对象不会被自动清理

    需要特别注意用于初始化对象池中新对象的代码,是否完整地初始化了对象。
    或者为回收对象池内存增设一个排错功能。

  5. 未使用的对象将占用内存

    当对象池中的对象不再被需要时,应当清空对象指向其他任何对象的引用。

    工程上一般有以下防御机制:

    1. 统一生命周期钩子(Lifecycle Hooks)
      规定池中对象必须实现两个接口:

      • OnAcquired() / OnSpawn():出池时调用,进行属性重置与数据初始化。
      • OnReleased() / OnDespawn():入池时调用,清空引用关系、取消事件订阅(防止内存泄露)。
    2. 重复归还防御(Double-Release Bug)
      如果不小心把同一个对象归还了对象池两次,会导致空闲表(Free List)产生环形链表(Dead Loop),下次获取对象时会导致死循环。

      • 防御手段:在 Debug 构建下,用 std::set 或在节点中维护 bIsInPool 状态,归还前检查是否已在池中,若在则抛出断言错误。

实现时技巧

  • 空闲表
    可用空闲表快速定位未被使用的对象
    我们给每个对象添加一个next指针,当对象不被使用时,添加回next中

在 C++ 底层实现中,为了不给每个对象增加额外的 next 指针内存(4/8 Bytes),通常会利用 union(共用体)巧妙地复用对象自身的内存空间:

class Particle {
    // 对象的真实数据(占 32 Bytes)
    Vector3 Position;
    Vector3 Velocity;
    float Lifetime;
};

// 对象池节点:当对象【活跃】时是 Particle,当【闲置】时是一个指向下一个闲置节点的指针
union PoolNode {
    Particle LiveParticle; // 正在使用的数据
    PoolNode* NextFree;    // 闲置时的链表指针
};

  • 机制:当对象闲置在池中时,它的内存区域没有任何游戏数据,此时直接将这块内存解释为 NextFree 指针;当对象被领用时,再将其解释为 LiveParticle 数据。

设计决策

  1. 对象是否被加入对象池

    • 假如对象和对象池耦合
      • 实现简单
        给池中的对象增加“使用中”的标志位或函数即可
      • 保证对象只能通过对象池创建
        简单实现方法:简单地将对象池类作为对象类的友元类,并将对象的构造函数私有化
      • 避免存储一个 “使用中”的标志位
        假如对象类知道自己可能被对象池使用,则它可以提供inUse()方法来检查这一状态
    • 假如对象独立于对象池
      • 任意类型的对象都可以被置入池中
      • “使用中”状态必须能够在对象外部被追踪
        简单实现方法:可以在对象池中额外创建一块独立的空间
  2. 谁来初始化被复用的对象

    • 假如在对象池内部初始化复用对象
      • 对象池可以完全封装它管理的对象
      • 对象池与对象如何被初始化密切相关
    • 假如对象在外部被初始化
      • 对象池的接口会简单些,不需要实现所有对象的初始化版本
      • 外部编码可能需要处理新对象创建失败的情况

引擎对标

维度 Unity (C#) Unreal Engine (C++)
原生 API 支持 UnityEngine.Pool 命名空间

内置 ObjectPool<T>、GenericPool<T>、CollectionPool<T>,支持自动上限管理与 OnGet/OnRelease 委托绑定。
机制差异

UE 对 UObject 和 AActor 拥有自己的 GC 垃圾回收机制,原生未提供全局 Actor 对象池,需要开发者手动实现或使用第三方插件。
GameObject / Actor 池化策略 SetActive(false) 隐藏

出入池时切换 GameObject.SetActive(),重置 Transform,关闭 ParticleSystem / AudioSource。
隐藏 & 停用策略

对于 AActor:出池时 SetActorHiddenInGame(true)、SetActorEnableCollision(false)、SetActorTickEnabled(false),并将其移动至遥远的地方(如 Z=-99999)暂存。
引擎内置子系统池化 VFX Graph / Particle System

粒子系统内部自动维护粒子的对象池。
Niagara & Audio Subsystem

UE5 的 Niagara 粒子系统和 MetaSound 声音系统内部高度集成了 System Pooling 机制,支持设置 Scalability 预池化数量。

空间分区

“将对象存储在根据位置组织的数据结构中来高效地定位它们。”

核心思想:“将对象存储在根据位置组织的数据结构中,把 \(O(N^2)\) 的全量暴力检索降级为 \(O(1)\) 或 \(O(\log N)\) 的局部检索。”


使用场景

在没有空间分区时,判断“哪些敌人受到了 AOE 伤害”或“两两物体是否碰撞”,需要遍历所有物体进行两两比对,时间复杂度是 \(O(N^2)\)。

使用 空间分区解法时,通过将空间切块,查询时只与同处于一个单元格或相邻单元格的物体进行比对,可以裁切掉大部分不可能相交的物体。


设计决策

  1. 分区是层级的还是扁平的

    • 如果是扁平的分区(如固定网格 Grid)
      • 相对简单
      • 内存使用量恒定
      • 当对象改变位置时可以更快速地更新
      • 优点:内存连续紧凑、内存开销固定、\(O(1)\) 快速存取,移动更新极快。
      • 缺点:如果地图很大但单位很少,会浪费大量内存;若所有单位挤在一个格子里,会退化为 \(O(N^2)\)。
    • 如果是层级的分区(如八叉树 Octree、BVH)
      • 可以更有效地处理空白的空间
      • 在处理对象稠密区域时更有效
      • 优点:能极好地处理大片空白区域,自适应密集区域。
      • 缺点:指针跳转多,对 CPU Cache 不友好;树的重构开销大。
  2. 分区是否依赖于对象集合

    • 自适应/基于对象的分区(如 k-d 树、BSP 树、标准四叉树)
      • 移动成本:极高(避免在运行期频繁触发)。物体的移动会改变空间密度,进而改变划分边界,导致大量邻近对象被强制重新搬运与重构。
      • 优点:根据对象密度完美平衡内存与查询效率。
      • 适用场景:静态地形、建筑、预烘焙地理数据,或极低频移动的结构。

      四叉树:分区不依赖于对象,层级依赖于对象

    • 固定分区(如固定均匀网格 Fixed Grid)
      • 移动成本:极低 (\(O(1)\))。只需将单位从旧网格移除,插入新网格即可。
      • 缺点:可能出现空间浪费或单网格物体过密。
      • 适用场景:大量高频移动的物体(如玩家、子弹、怪物)。
  3. 对象是否只存储在分区中

    • 它是对象唯一存储的地方
      • 避免了两个集合的内存开销和复杂性(不需要维护多个集合)
      • 但遍历所有对象时需要深度遍历树/网格。
    • 如果存在存储对象的另外一个集合
      • 遍历所有对象更快速
      • 保留一个扁平的 List<Entity> 用于高效遍历更新,空间分区仅作为地理位置查询的“索引外挂”。

四大主流数据结构选型表

在实际工程中,空间分区主要有以下 4 种落地方案,根据物体动态性与空间分布密度进行选型:

数据结构 空间切分方式 移动更新成本 最佳适用场景 经典游戏应用
均匀网格

(Uniform Grid)
将空间死板地切成大小相等的方格 极低 (\(O(1)\))

重新计算网格坐标即可
空间分布均匀、大批量高频移动的 2D/3D 单位 幸存者割草游戏、RTS 单位碰撞、弹幕游戏
四叉树 / 八叉树

(Quadtree / Octree)
按照数据密度递归地一分为四(2D)或一分为八(3D) 较高

跨节点需重新插树,只适合静态或低频移动
空间物体密度极不均匀、高空视域裁切 开放世界地形 LOD、视锥体剔除 (Culling)
BVH

(包围盒层次结构)
不切分空间,而是用层层嵌套的包围盒 (AABB) 裹住物体 中等

可通过胖包围盒 (Fat AABB) 做增量更新
形状复杂、高频碰撞的物理实体 PhysX / Havok 物理引擎粗阶段 (Broadphase)
空间哈希

(Spatial Hashing)
无边界网格,通过 Hash(x, y, z) 映射到哈希表 极低 (\(O(1)\))

无视地图大小限制
极大的开放世界、无限随机生成地图 无限地图 RPG、无界粒子系统

引擎落地

  • Unreal Engine:

    • World Partition(UE5 开放世界分区):基于网格(Grid)的流送系统,将大地图划分为多个 Grid Cell,根据玩家位置动态加载/卸载远处单元格。
    • PhysX / Chaos 物理引擎:采用 MBP (Multi-Box Broadphase) 或 Dynamic AABB Tree (BVH) 进行物理碰撞检测的粗阶段筛选(Broadphase)。
  • Unity:

    • CullingGroup API:利用八叉树或球体层次结构进行相机可见性剔除。
    • Unity Physics / DOTS:利用 Spatial Hashing (空间哈希) 与 BVH 在 Burst 编译器和 Job System 中实现极其高效的平行物理碰撞比对。

[1] Robert Nystrom. 游戏编程模式[M]. 微信读书版

posted @ 2026-09-27 21:55  SEHOD  阅读(7)  评论(0)    收藏  举报