中EN
概念页

享元模式

Flyweight Pattern

享元模式 (Flyweight Pattern) 把对象的数据拆成可共享的“内在状态”和每个实例独有的“外在状态”,让大量对象共用一份内在状态。森林里成千上万棵树共用一份网格和纹理,以及瓦片地图中每格只存一个指向共享地形对象的指针,都是它的典型应用。

意图

当对象多到太重时,让它们变“轻”:把与具体实例无关、可以共享的数据只存一份。(Game Programming Patterns — flyweight)

机制

  • 内在状态 (intrinsic state):GoF 的术语,Nystrom 称之为“上下文无关”的数据。例如树的网格、树皮和树叶纹理。抽成一个 TreeModel,全局只需一份。
  • 外在状态 (extrinsic state):每个实例独有的数据,例如位置、缩放、色调。每个 Tree 只保存这些字段,外加一个指向共享 TreeModel 的引用。(Game Programming Patterns — flyweight)
  • GPU 版本:实例化渲染 (instanced rendering):Direct3D 和 OpenGL 都支持提交两条数据流,一条是只发送一次的公共数据(网格与纹理),一条是每个实例的参数;一次绘制调用就能画出整片森林。这里省下的主要不是内存,而是逐棵树经总线传给 GPU 的时间,但思路相同。作者在旁注里调侃:因为这一 API 直接由显卡实现,享元可能是唯一一个有真正硬件支持的 GoF 模式。(Game Programming Patterns — flyweight)
  • 没有明确身份的共享对象:瓦片地形里,地形的移动代价、是否是水、纹理都与位置无关,也就是说全部是内在状态。于是世界网格不存枚举,而是存指向 Terrain 对象的指针,每种地形只有一个实例(直接作为 World 的成员)。这样既保留了对象式 API,World 也不再耦合地形细节,开销几乎为零,因为指针通常不比枚举大。(Game Programming Patterns — flyweight)

下图表示森林例子中的数据拆分:多个 Tree 实例各自只存外在状态,并共同指向一个 TreeModel。

flowchart LR
  T1["Tree 1:位置/缩放/色调"] --> M["TreeModel(共享):网格、树皮纹理、树叶纹理"]
  T2["Tree 2:位置/缩放/色调"] --> M
  T3["Tree N:位置/缩放/色调"] --> M

图中只有右边那一份数据是大块数据;实例数量增长时,增加的只是左边的小对象。

何时使用

  • 对象数量巨大,并且大部分字段在各实例之间相同。
  • 代码里出现“一个枚举加上很多针对它的 switch”时,可以考虑换成享元对象:数据集中封装在对象里,又不必为每个格子付出一个完整对象的代价。(Game Programming Patterns — flyweight)

取舍与坑

  • 指针间接访问可能造成缓存未命中:先跟随网格里的指针找到地形对象,再读移动代价。Nystrom 的测试里享元并不比枚举慢,甚至明显更快,但这完全取决于内存布局。原则是先做性能分析,不要凭推理就改成难维护的写法。(Game Programming Patterns — flyweight)
  • 按需创建:如果预先不知道需要哪些享元,就在请求时先查找有没有相同的现成实例,有则复用。这需要把构造藏在一个接口后面(工厂方法 Factory Method),并维护一个已创建实例的池(可借助 对象池)。(Game Programming Patterns — flyweight)
  • 享元对象几乎总是不可变的:书中 Terrain 的方法全部是 const。同一对象被多处共享,改动它会同时出现在所有地方;而共享只应是一种不改变程序可见行为的优化。(Game Programming Patterns — flyweight)
  • 生命周期:共享实例被多处引用,动态分配时生命周期管理更复杂,所以例子里直接把它们作为 World 的成员存放。
  • 和类型对象的区别:类型对象 也是把部分状态委托给多个实例共享的对象,但意图不同:类型对象是为了把“类型”提升进自己的对象模型、减少要定义的类,省内存只是附带收益;享元则纯粹为了效率。(Game Programming Patterns — flyweight)
  • 状态对象也可以是享元:状态模式里如果状态对象没有任何专属于某台状态机的字段,同一个状态实例就能同时被多台状态机复用,见 状态模式与有限状态机。(Game Programming Patterns — flyweight)

代码草图(C++)

按书中瓦片地形例子整理(地形对象全部方法为 const,每种地形一个实例,网格只存指针):

class Terrain {
public:
  Terrain(int movementCost, bool isWater, Texture texture)
  : movementCost_(movementCost), isWater_(isWater), texture_(texture) {}

  int getMovementCost() const { return movementCost_; }
  bool isWater() const { return isWater_; }
  const Texture& getTexture() const { return texture_; }

private:
  int movementCost_;
  bool isWater_;
  Texture texture_;
};

class World {
public:
  World()
  : grassTerrain_(1, false, GRASS_TEXTURE),
    hillTerrain_(3, false, HILL_TEXTURE),
    riverTerrain_(2, true, RIVER_TEXTURE) {}

  const Terrain& getTile(int x, int y) const { return *tiles_[x][y]; }

  void generateTerrain() {
    for (int x = 0; x < WIDTH; x++)          // 铺满草地,随机点缀山丘
      for (int y = 0; y < HEIGHT; y++)
        tiles_[x][y] = (random(10) == 0) ? &hillTerrain_ : &grassTerrain_;

    int x = random(WIDTH);                   // 铺一条河
    for (int y = 0; y < HEIGHT; y++) tiles_[x][y] = &riverTerrain_;
  }

private:
  Terrain* tiles_[WIDTH][HEIGHT];
  Terrain grassTerrain_;
  Terrain hillTerrain_;
  Terrain riverTerrain_;
};

int cost = world.getTile(2, 3).getMovementCost();

UE 与渲染中的对应

  • 实例化绘制 → 绘制调用与状态切换优化:该页把实例化 (instancing) 列为减少绘制调用的手段,即一次调用多次绘制同一基础模型并附带每实例数据,适合植被和人群,正是本模式的 GPU 版本。
  • 共享不可变资产 + 每次使用的实例数据 → Gameplay 能力系统:Gameplay Effect 是运行时不可变的资产,运行时实际传递的是包在资产外的 GE Spec(实例数据)。这与“内在状态共享、外在状态独有”的拆分一致。
  • 无状态行为共享同一对象:同一页中,Non-Instanced 的能力不创建对象,直接用 CDO 执行,代价是不能修改成员变量,正对应“无字段的命令或状态可以共享一个实例”。CDO 见 UObject 与反射系统。
  1. Game Programming Patterns — flyweight
  2. Game Programming Patterns — flyweight(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) flyweight 章,gameprogrammingpatterns.com/flyweight.html

相关页面

游戏开发类型对象Type Object类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。游戏开发对象池Object Pool对象池 (Object Pool) 预先一次性分配一组可复用对象,需要时从池里取一个未使用的对象并初始化,用完标记为未使用,而不是逐个 new/delete。它能避免频繁分配的开销和堆碎片,常用于粒子、子弹、正在播放的声音等。代价是池大小要调校、同时活跃的数量有上限、复用的对象不会被自动清零,在有 GC 的环境里还可能让本该回收的对象一直活着。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。图形学绘制调用与状态切换优化Draw Calls & State Change Optimization调用图形 API 的开销主要在 CPU 端的驱动里:状态切换昂贵,每次绘制调用有固定开销,大量小批次会让 GPU"饿死"。对策是:合理放置缓冲;按状态代价排序分组;共享纹理和着色器;合并网格和实例化以减少调用;并用一个整数排序键同时兼顾状态分组与前后排序。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发命令模式Command Pattern命令模式 (Command Pattern) 把一次方法调用“物化”成对象,从而可以存储、传递、延迟执行、排队或撤销。游戏里最典型的三种用法是:可配置的按键映射、让玩家输入和 AI 共用同一套“驱动角色”的接口、以及撤销/重做。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。游戏开发 · UE5Gameplay 能力系统Gameplay Ability SystemGameplay 能力系统 (Gameplay Ability System, GAS) 是 UE 用来构建「Actor 可拥有并反复触发的能力」的框架:能力系统组件 (Ability System Component, ASC) 是持有者,Gameplay 能力 (Gameplay Ability) 编排执行过程,属性集 (Attribute Set) 存数值,Gameplay 效果 (Gameplay Effect, GE) 改数值和标签,Gameplay 标签 (Gameplay Tags) 做状态与互斥,Gameplay Cue 负责表现。它内建复制与客户端预测,代价是概念多、上手重,游戏开发 · UE5UObject 与反射系统UObject & Reflection System虚幻引擎 (Unreal Engine) 的对象基类是 UObject。在 C++ 类、属性、函数上加 UCLASS/UPROPERTY/UFUNCTION 等宏,再由构建前的预处理工具虚幻头文件工具 (Unreal Header Tool, UHT) 扫描头文件生成代码,引擎就能在运行时“看见”这些类型——这就是反射系统 (Reflection System)。垃圾回收、序列化、编辑器面板、蓝图、网络复制都建立在它之上。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原