游戏设计模式——面向数据编程(新)

随着软件需求的日益复杂发展,远古时期面的向过程编程思想才渐渐萌生了面向对象编程思想。程序员

当人们发现面向对象在应对高层软件的种种好处时,愈来愈沉醉于面向对象,热衷于研究如何更加优雅地抽象出对象。web

然而现代开发中渐渐发现面向对象编程层层抽象形成臃肿,致使运行效率下降,而这是性能要求高的游戏编程领域不想看到的。编程

因而现代游戏编程中,面向数据编程的思想愈来愈被接受(例如Unity2018更新的ECS框架就是一种面向数据思想的框架)。设计模式

面向数据编程是什么?


先来一个简单的比较:数组

面向过程思想:考虑解决问题所需的各个步骤(函数)。
面向对象思想:考虑解决问题所需的各个模型(类)。
面向数据思想:着重考虑数据的存取及布局(数据)。缓存

那么所谓的考虑数据存储/布局是什么意思呢?markdown

这里引入2个有关CPU处理数据的概念:数据结构

  • 单指令流多数据流(SIMD)架构

  • CPU缓存(CPU Cache)

单指令流多数据流(SIMD)


什么是SIMD

SIMD全称Single Instruction Multiple Data,单指令流多数据流,是一种采用一个控制器来控制多个处理器,同时对若干个数据分别执行相同的操做从而实现空间上的并行性的技术。

简单来讲,SIMD技术可让CPU在一个指令周期执行多个数据的操做(不过操做须要同样),而不是一个指令周期执行一个数据的操做。

为何须要SIMD

在上面的介绍里,咱们能够直观的知道最大的好处在于:能够容许CPU利用并行性快速处理多个数据

可是局限性仍是有的,SIMD技术通常对矢量算术型操做(例如矢量相加,矢量相乘)支持的很好,而不支持其余类型操做(例如分支判断和跳转)。

因此SIMD技术经常使用于CPU数据计算密集型应用,例如:

  • 人工智能
  • 物理计算
  • 粒子系统
  • 光线追踪
  • 图像处理

支持SIMD技术的指令集

X86架构的CPU所支持SSE/SSE2/SSE3指令集就是典型的重点针对/支持SIMD功能的指令集。

目前的PC的CPU架构绝大多数都是Intel的X86架构,而ARM架构的CPU能够在不少消费性电子产品上看到,从可携式装置(PDA、移动电话、多媒体播放器、掌上型电子游戏,和计算机)到电脑外设(硬盘、桌上型路由器)甚至在导弹的弹载计算机等军用设施中都有它的存在。

(vs2019里项目设置能够找到指令集设置选项)

咱们能够在IDE/编译器里设置好支持SIMD技术的指令集选项。

使用SIMD编程


使用汇编内联

缺陷:

  • 汇编代码需根据不一样平台定制(无跨平台特性)
  • 汇编代码复杂,开发效率低

使用指令集库

缺陷:

  • 代码需根据不一样平台指令集,包含不一样指令集库头文件(无跨平台特性)

使用ISPC语言

ISPC是英特尔推出的面向CPU的着色器语言,它适用多种指令集的矢量指令(如SSE二、SSE四、AVX、AVX2等)。
ISPC是基于C语言的,因此它大部分语法和C语言是一致的,能够减小学习成本。
ISPC源代码,通过编译后输出.obj文件和.h文件。这样咱们在编写C/C++程序时能够包含该头文件以使用ISPC代码。

下面简单提供个代码示例比较:

// C/C++ Code
id rgb2grey(int N, 
    float R[], 
    float G[],
    float B[],
    float grey[]) {
    for (int i = 0; i < N; i++) {
        grey[i] = 0.3f * R[i] + 0.59f * G[i] + 0.11f * B[i];
    }
}
// ISPC Code
export void rgb2grey(uniform int N, 
    uniform float R[],
    uniform float G[], 
    uniform float B[],
    uniform float grey[]) {
    foreach(i = 0 ... N) { 
        grey[i] = 0.3f * R[i] + 0.59f * G[i] + 0.11f * B[i]; 
    } 
}

ISPC语言的语法很是易学,由于它的关键字真的不多:

  • 相似于C/C++的关键字:if, else, switch, for, while, do…while, goto
  • 固然也有为了支持并行循环的关键字:foreach, foreach_active, foreach_tiled, foreach_unique
  • 还有其它一些不经常使用关键字就不列举了

更具体的ISPC语法就很少讲解,能够本身自行去查看官方文档(文章末尾参考部分会给出连接)。

在线编译器godbolt,能够用于测试ISPC代码及调试汇编代码:Compiler Explorer

并行循环

// C/C++ Code
void func(int N, 
    float A[], 
    float B[],
    float C[]) {
    for (int i = 0; i < N; i++) {
        C[i] = A[i] * B[i];
    }
}

上面是一个正常的C/C++循环代码,这样就是通常的份量操做,以下图左侧:

在ISPC语法里,只需简单的写上foreach(i = 0 ... N) ,IPSC编译器编译时会为其编译成图中右侧的行为,即一次循环并行处理M个元素,实际循环N/M次。

// ISPC Code
export void rgb2grey(int N, 
    uniform float A[], 
    uniform float B[],
    uniform float C[]) {
    foreach(i = 0 ... N) { 
        C[i] = A[i] * B[i];
    } 
}

更方便的是,ISPC会自动处理并行循环的边界状况(例如每次并行处理4个元素时,N/4次循环后余出1~3个元素)。

避免Gather行为

这是一个正常的颜色结构,文中定义了若干个颜色对象。

struct Color{
  float r,g,b;
};
Color colors[1024];

SIMD技术读取变量通常都是连续若干个(在图中为4个)变量一次性读取,这种行为叫作矢量读取。

而因为上文的颜色结构定义,其内存分布则如图中的上部分。
要对4个红色份量进行操做时,则须要进行屡次读取,这被称为Gather行为。

struct VaryingColor{
  float r[vectorLen];
  float g[vectorLen];
  float b[vectorLen];
};
Color colors[1024/vectorLen];

假若咱们使用以下结构定义,则内存分布会如图中下部分。这样就能一次读入4个红色份量,高效地利用SIMD技术。这种结构被称为SIMD友好型结构。

在ISPC语言里,使用varying类型能够方便的定义SIMD友好型结构。

CPU缓存(CPU cache)


在组装电脑购买CPU的时候,不知道你们是否留意过CPU的一个参数:N级缓存(N通常有1/2/3)

什么是CPU缓存

img

简单地剖析结构,大概会是这个关系:

CPU寄存器 <————> CPU缓存 <————> 内存

能够看到CPU缓存是介于内存和CPU寄存器之间的一个存储区域。
CPU缓存地存储空间比内存小,比寄存器大

为何须要CPU缓存

CPU的运行频率太快了,而CPU访问内存的速度很慢,这样在处理器时钟周期内,CPU经常须要等待寄存器读取内存,浪费时间。
而CPU访问CPU缓存则速度快不少。为了缓解CPU和内存之间速度的不匹配问题,CPU缓存则预先存储好潜在可能会访问的内存数据。

CPU缓存预先存的是什么

时间局部性:若是某个数据被访问,那么在不久的未来它极可能再次被访问。
空间局部性:若是某个数据被访问,那么与它相邻的数据很快也能被访问。

CPU多级缓存根据这两个特色,通常存储的是被访问过的数据被访问数据的相邻数据

CPU缓存命中/未命中

CPU把待处理的数据或已处理的数据存入缓存指定的地址中,若是即将要处理的数据已经存在此地址了,就叫做CPU缓存命中,这会比直接访问内存要快的多。

若是CPU缓存未命中,就转到内存地址访问,也就是直接访问内存。

提升CPU缓存命中率


要尽量提升CPU缓存命中率,关键就是要尽可能让使用的数据连续在一块儿。

因为面向数据编程技巧不少,本文篇幅有限,只介绍部分。

使用连续数组存储要批处理的对象

传统的组件模式,每每让游戏对象持有一个或多个组件的引用数据(指针数据)。

(一个典型的游戏对象类,包含了2种组件的指针)

class GameObject {
    //....GameObject的属性
    Component1* m_component1;
    Component2* m_component2;
};

下面一幅图显示了这种传统模式的结构:img

游戏对象/组件每每是批处理操做较多(每帧更新/渲染/或其余操做)的对象。

这个传统结构相应的每帧更新代码:

GameObject g[MAX_GAMEOBJECT_NUM];

for(int i = 0; i < GameObjectsNum; ++i) {
      g[i].update();
      if(g[i].componet1 != nullptr)g[i].componet1->update();
      if(g[i].componet2 != nullptr)g[i].componet2->update();
}

而根据图中能够看到,这种指来指去的结构对CPU缓存极其不友好:为了访问组件老是跳转到不相邻的内存。

假若游戏对象和组件的更新顺序不影响游戏逻辑,则一个可行的办法是将他们都以连续数组形式存在。

注意是对象数组,而不是指针数组。若是是指针数组的话,这对CPU缓存命中没有意义(由于要经过指针跳转到不相邻的内存)。

GameObject g[MAX_GAMEOBJECT_NUM];
Component1 a[MAX_COMPONENT_NUM];
Component2 b[MAX_COMPONENT_NUM];

//连续数组存储能让下面的批处理中CPU缓存命中率较高
for (int i = 0; i < GameObjectsNum; ++i) {
    g[i].update();
}
for (int i = 0; i < Componet1Num; ++i) {
    a[i].update();
}
for (int i = 0; i < Componet2Num; ++i) {
    b[i].update();
}

避免无效数据夹杂在连续内存区域

这是一个简单的粒子系统:

const int MAX_PARTICLE_NUM = 3000;
//粒子类
class Particle {
private:
    bool active;
    Vec3 position;
    Vec3 velocity;
    //....其它粒子所需方法
};

Particle particles[MAX_PARTICLE_NUM];
int particleNum;

它使用了典型的lazy策略,当要删除一个粒子时,只需改变active标记,无需移动内存。

而后利用标记判断,每帧更新的时候能够略过删除掉的粒子。

当须要建立新粒子时,只须要找到第一个被删除掉的粒子,更改其属性便可。

for (int i = 0; i < particleNum; ++i) {
    if (particles[i].isActive()) {
        particles[i].update();
    }
}

表面上看这很科学,实际上这样作CPU缓存命中率不高:每次批处理CPU缓存都加载过不少不会用到的粒子数据(标记被删除的粒子)。

一个可行的方法是:当要删除粒子时,将队列尾的粒子内存复制到该粒子的位置,并记录减小后的粒子数量。

移动内存(复制内存)操做是程序员最不想看到的,可是实际执行批处理带来的速度提高相比删除的开销多的很是多,除非你移动的内存对象大小实在大到使人发指

particles[i] = particles[particleNum];
particleNum--;

这样咱们就能够保证在这个粒子批量更新操做中,CPU缓存老是能以高命中率击中。

for (int i = 0; i < particleNum; ++i) {
    particles[i].update();
}

冷数据/热数据分割

有人可能认为这样能最大程度利用CPU缓存:把一个对象全部要用的数据(包括组件数据)都塞进一个类里,而没有任何用指针或引用的形式间接存储数据。

实际上这个想法是错误的,咱们不能忽视一个问题:CPU缓存的存储空间是有限的

因而咱们但愿CPU缓存存储的是常用的数据,而不是那些少用的数据。这就引入了冷数据/热数据分割的概念了。

热数据:常常要操做使用的数据,咱们通常能够直接做为可直接访问的成员变量。

冷数据:比较少用的数据,咱们通常以引用/指针来间接访问(即存储的是指针或者引用)。

一个栗子:对于人类来讲,生命值位置速度都是常常须要操做的变量,是热数据。
而掉落物对象只有人类死亡的时候才须要用到,因此是冷数据;

class Human {
private:
    float health;
    float power;
    Vec3 position;
    Vec3 velocity;
    LootDrop* drop;
    //....
};

class LootDrop{
    Item[2] itemsToDrop;
    float chance;
    //....
};

频繁调用的函数尽量不要作成虚函数

C++的虚函数机制,简单来讲是两次地址跳转的函数调用,这对CPU缓存十分不友好,每每命中失败。

实际上虚函数能够优雅解决不少面向对象的问题,然而在游戏程序若是有不少虚函数都要频繁调用(例如每帧调用),很容易引起性能问题。

解决方法是,把这些频繁调用的虚函数尽量去除virtual特性(即作成普通成员函数),并避免调用基类对象的成员函数,代价是这样一改得改不少与之牵连代码。

因此最好一开始设计程序时,须要先想好哪些最好不要写成virtual函数。

这实际上就是在优雅与性能之间寻求一个平衡。

从新认识C++ STL容器

STL容器,特别是set,map,有着不少O(logN)的操做速度,但并不意味着是最佳选择,由于这种复杂度表示每每隐藏了常数很大的事实。

例如说,集合的主流实现是基于红黑树,基于节点存储的,而每次插入/删除节点都意味着调用一次系统分配内存/释放内存函数。这相比vector等矢量容器全部操做仅一次系统分配内存(理想状况来讲),实际上就慢了很多。

此外,矢量容器对CPU缓存更加友好,遍历该种容器容易命中缓存,而节点式容器则相对容易命中失败。

综合上述,若是要选择一个最适合的容器,那么不要过分信赖时间复杂度,除非你十分完全的了解STL容器,或对各容器进行屡次效率测试。

更多小细节(不经常使用)

面向数据编程还有更多小细节,可是这些都不经常使用,就只做为一种思考面向数据编程的另类角度。

对二维数组int a[100][100]的遍历:

for(int x=0;x<100;++x)
for(int y=0;y<100;++y)
a[x][y];    //do something
for(int y=0;y<100;++y)
for(int x=0;x<100;++x)
a[x][y];    //do something

内循环应该是对x递增仍是对y递增比较快?答案是:对y递增比较快。

由于对 y 的递增,结果是一个int大小的跳转,也就是说容易访问到相邻的内存,即容易击中CPU缓存。
而对 x 的递增,结果是100个int大小的跳转,不容易击中CPU。

而内循环若是是y的话,那么就能内外循环总共递增100*100次y。
但内循环若是是x的话,那么就内外循环总共只能递增100次y,相比上者,CPU击中比较少。

总结


对面向对象和面向数据的见解:

先说结论:应该兼有。
由于游戏程序是一个既须要高性能又复杂的工程。
使用面向对象的游戏程序新手,经常就有一个问题:过分设计/过分抽象,什么都想用设计模式封装一下抽象一下。
这就很容易致使一些过分设计/过分抽象致使游戏性能太差。
博主如今的项目风格都比较偏向面向数据思想,尽可能减小虚函数的使用,多利用数据组合成对象,而不是重写各类基类虚函数。
对于一些数据结构的考量,也尽可能偏多使用连续存储的结构(例如数组)。
如何兼有两种思想,这种玄学的问题可能得靠本身去感悟,多尝试和测试性能差异。

参考


《Game Engine Architecture》 2014-1 做者: Jason Gregory

使用英特尔® ISPC 简化SIMD开发 | 英特尔® 软件

WebAssembly and SIMD - Wasmer - Medium

游戏设计模式——面向数据编程(旧) - KillerAery - 博客园

游戏设计模式系列-其余文章:
https://www.cnblogs.com/KillerAery/category/1307176.html

本文使用markdown”重置“之前写的面向数据编程文章,顺便添加和修改了一些内容。吐槽一下,博客园的博文发出去指定tinymce后就不能再修改为md类型了。

相关文章
相关标签/搜索