Skip to content

Commit 2977d75

Browse files
committed
Merge branch 'master' of github.com:GameDevelopmentCollege/Game-Programming-Patterns-CN
2 parents 995f43a + 0c6bfd9 commit 2977d75

File tree

1 file changed

+205
-16
lines changed

1 file changed

+205
-16
lines changed

06.1-Data Locality.md

Lines changed: 205 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@
6161
当缓存未命中时,CPU就停止运转:它因为缺少数据而无法执行下一条指令。CPU坐在地上进行几百次空循环发呆,直到取得数据。我们的任务就是避免这一情况发生。设想你正试图通过改进一些关键性的游戏代码来提高性能,比如下面这样:
6262

6363
```c++
64-
for (int i = 0; i < NUM_THINGS; i++){ sleepFor500Cycles(); things[i].doStuff();}
64+
for (int i = 0; i < NUM_THINGS; i++)
65+
{
66+
sleepFor500Cycles();
67+
things[i].doStuff();
68+
}
6569
```
6670
对这段代码你首先可以做些啥?是的,显然循环里的函数调用开销很大。这样的调用等价于缓存未命中带来的性能损失。每次跳入主存中,就意味着往你的代码里塞了一段延时。
6771
@@ -119,14 +123,54 @@ for (int i = 0; i < NUM_THINGS; i++){ sleepFor500Cycles(); things[i].doStuff()
119123
## 连续的数组
120124
让我们从一个处理一系列游戏实体的游戏循环([Game loop](./03.2-Game Loop.md))开始。每个实体通过组件模式([Component](./05.1-Component.md))被拆解为不同的部分:AI,物理,渲染。`GameEntity`类如下:
121125
```c++
122-
class GameEntity{public: GameEntity(AIComponent* ai, PhysicsComponent* physics, RenderComponent* render) : ai_(ai), physics_(physics), render_(render) {} AIComponent* ai() { return ai_; } PhysicsComponent* physics() { return physics_; } RenderComponent* render() { return render_; }private: AIComponent* ai_; PhysicsComponent* physics_; RenderComponent* render_;};
126+
class GameEntity
127+
{
128+
public:
129+
GameEntity(AIComponent* ai,
130+
PhysicsComponent* physics,
131+
RenderComponent* render)
132+
: ai_(ai), physics_(physics), render_(render)
133+
{}
134+
135+
AIComponent* ai() { return ai_; }
136+
PhysicsComponent* physics() { return physics_; }
137+
RenderComponent* render() { return render_; }
138+
private:
139+
AIComponent* ai_;
140+
PhysicsComponent* physics_;
141+
RenderComponent* render_;
142+
};
123143
```
124144
每个组件都包含一系列相关的状态属性,或许是一些向量或矩阵,且组件具有一个更新这些状态的方法。在此其细节并不重要,但我们可以根据这些粗略设想出如下的组件结构:
125145

126146
>注解: 正如其名,这些例子正是来自[Update Method](./03.3-Update Method.md)模式。甚至连render()方法也采用这一模式,只是换了个名字而已。
127147
128148
```c++
129-
class AIComponent{public: void update() { /* Work with and modify state... */ }private: // Goals, mood, etc. ...};class PhysicsComponent{public: void update() { /* Work with and modify state... */ }private: // Rigid body, velocity, mass, etc. ...};class RenderComponent{public: void render() { /* Work with and modify state... */ }private: // Mesh, textures, shaders, etc. ...};
149+
class AIComponent
150+
{
151+
public:
152+
void update() { /* Work with and modify state... */ }
153+
private:
154+
// Goals, mood, etc. ...
155+
};
156+
157+
class PhysicsComponent
158+
{
159+
public:
160+
void update(){ /* Work with and modify state... */ }
161+
162+
private:
163+
// Rigid body, velocity, mass, etc. ...
164+
};
165+
166+
class RenderComponent
167+
{
168+
public:
169+
void render() { /* Work with and modify state... */ }
170+
171+
private:
172+
// Mesh, textures, shaders, etc. ...
173+
};
130174
```
131175
游戏维护一个很大的指针数组,它们包含了对游戏世界中所有实体的引用。每次游戏循环我们需要做以下工作:
132176

@@ -138,7 +182,28 @@ class AIComponent{public: void update() { /* Work with and modify state... */ }
138182

139183
许多游戏实体将这样进行实现:
140184
```c++
141-
while (!gameOver){ // Process AI. for (int i = 0; i < numEntities; i++) { entities[i]->ai()->update(); } // Update physics. for (int i = 0; i < numEntities; i++) { entities[i]->physics()->update(); } // Draw to screen. for (int i = 0; i < numEntities; i++) { entities[i]->render()->render(); } // Other game loop machinery for timing...}
185+
while (!gameOver)
186+
{
187+
// Process AI.
188+
for (int i = 0; i < numEntities; i++)
189+
{
190+
entities[i]->ai()->update();
191+
}
192+
193+
// Update physics.
194+
for (int i = 0; i < numEntities; i++)
195+
{
196+
entities[i]->physics()->update();
197+
}
198+
199+
// Draw to screen.
200+
for (int i = 0; i < numEntities; i++)
201+
{
202+
entities[i]->render()->render();
203+
}
204+
205+
// Other game loop machinery for timing...
206+
}
142207
```
143208
在你耳闻CPU缓存机制之前,上面的代码看不出什么毛病。但现在,我想你已经察觉到有些不妥了。这样的代码不仅伤害着缓存,甚至将它来回给搅成了一团浆糊。看看它都干了些啥吧:
144209

@@ -162,14 +227,40 @@ while (!gameOver){ // Process AI. for (int i = 0; i < numEntities; i++) {
162227
为了对这一堆游戏实体以及散乱在地址空间各个角落的组件做改进,我们将从头来过——我们有一个容纳着各类组件的大数组:存放所有AI组件的一维数组,当然还有存放物理和渲染组件的数组,如下:
163228

164229
```c++
165-
AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES];RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES];
230+
AIComponent* aiComponents =
231+
new AIComponent[MAX_ENTITIES];
232+
PhysicsComponent* physicsComponents =
233+
new PhysicsComponent[MAX_ENTITIES];
234+
RenderComponent* renderComponents =
235+
new RenderComponent[MAX_ENTITIES];
166236
```
167237
>注解: 在关于使用组件模式我最反感的一点就是component这个词的长度...
168238
169239
这里需要强调一下,这些是存储组件的数组而非组件指针的数组。数组里直接包含了所有组件的实际数据,逐个字节地在内存中分布。游戏循环可以直接遍历它们:
170240

171241
```c++
172-
while (!gameOver){ // Process AI. for (int i = 0; i < numEntities; i++) { aiComponents[i].update(); } // Update physics. for (int i = 0; i < numEntities; i++) { physicsComponents[i].update(); } // Draw to screen. for (int i = 0; i < numEntities; i++) { renderComponents[i].render(); } // Other game loop machinery for timing...}
242+
while (!gameOver)
243+
{
244+
// Process AI.
245+
for (int i = 0; i < numEntities; i++)
246+
{
247+
aiComponents[i].update();
248+
}
249+
250+
// Update physics.
251+
for (int i = 0; i < numEntities; i++)
252+
{
253+
physicsComponents[i].update();
254+
}
255+
256+
// Draw to screen.
257+
for (int i = 0; i < numEntities; i++)
258+
{
259+
renderComponents[i].render();
260+
}
261+
262+
// Other game loop machinery for timing...
263+
}
173264
```
174265
>注解: 我们会注意到在新的代码里我们已经不再使用”->”操作符,假如你希望增强数据的区域性,就尽可能想办法去掉那些间接性的(尤其是指针的)操作吧。
175266
@@ -187,19 +278,49 @@ while (!gameOver){ // Process AI. for (int i = 0; i < numEntities; i++) {
187278
>注解: `ParticleSystem`类是根据[Object Pool](./06.3-Object Pool.md)模式为某个类型的对象集合创建的类。
188279
189280
```c++
190-
class Particle{public: void update() { /* Gravity, etc. ... */ } // Position, velocity, etc. ...};class ParticleSystem{public: ParticleSystem() : numParticles_(0) {} void update();private: static const int MAX_PARTICLES = 100000; int numParticles_; Particle particles_[MAX_PARTICLES];};
281+
class Particle
282+
{
283+
public:
284+
void update() { /* Gravity, etc. ... */ }
285+
// Position, velocity, etc. ...
286+
};
287+
288+
class ParticleSystem
289+
{
290+
public:
291+
ParticleSystem()
292+
: numParticles_(0)
293+
{}
294+
295+
void update();
296+
private:
297+
static const int MAX_PARTICLES = 100000;
298+
int numParticles_; Particle particles_[MAX_PARTICLES];
299+
};
191300
```
192301

193302
同时粒子系统的一个简单的更新方法如下:
194303

195304
```c++
196-
void ParticleSystem::update(){ for (int i = 0; i < numParticles_; i++) { particles_[i].update(); }}
305+
void ParticleSystem::update()
306+
{
307+
for (int i = 0; i < numParticles_; i++)
308+
{
309+
particles_[i].update();
310+
}
311+
}
197312
```
198313

199314
但实际上我们并不需要总是更新所有的粒子。粒子系统维护一个固定大小的对象池,但它们并不总是同时都被激活而在屏幕上闪烁。下面的方法会更加合适:
200315

201316
```c++
202-
for (int i = 0; i < numParticles_; i++){ if (particles_[i].isActive()) { particles_[i].update(); }}
317+
for (int i = 0; i < numParticles_; i++)
318+
{
319+
if (particles_[i].isActive())
320+
{
321+
particles_[i].update();
322+
}
323+
}
203324
```
204325

205326
我们赋予`Particle`类一个标志来表示其是否处于激活状态。在更新循环中,我们挨个粒子地检查其标志。这使得该标志随着对应粒子的其他数据一起被加载到缓存中。假如粒子并未被激活,那么我们就跳向下一个。这时将该粒子的其他数据加载到缓存中就是一种浪费。
@@ -220,7 +341,10 @@ for (int i = 0; i < numParticles_; i++){ if (particles_[i].isActive()) { pa
220341

221342
我们也可以时刻跟踪被激活粒子的数目。这样我们就可以美化一下代码了:
222343
```c++
223-
for (int i = 0; i < numActive_; i++){ particles[i].update();}
344+
for (int i = 0; i < numActive_; i++)
345+
{
346+
particles[i].update();
347+
}
224348
```
225349

226350
现在我们不跳过任何数据。每个塞进缓存的粒子都是被激活的,也都正是我们要处理的。
@@ -230,12 +354,38 @@ for (int i = 0; i < numActive_; i++){ particles[i].update();}
230354
假设数组已经排好序——并且一开始所有的粒子都处于非激活状态。数组仅当某个粒子被激活或者反激活时处于乱序状态。我们很容易就能对这两种情况进行处理:当粒子被激活时,我们通过把它与数组中第一个未激活的例子进行交换来将其移动到所有激活粒子的末端:
231355

232356
```c++
233-
void ParticleSystem::activateParticle(int index){ // Shouldn't already be active! assert(index >= numActive_); // Swap it with the first inactive particle // right after the active ones. Particle temp = particles_[numActive_]; particles_[numActive_] = particles_[index]; particles_[index] = temp; // Now there's one more. numActive_++;}
357+
void ParticleSystem::activateParticle(int index)
358+
{
359+
// Shouldn't already be active!
360+
assert(index >= numActive_);
361+
362+
// Swap it with the first inactive particle
363+
// right after the active ones.
364+
Particle temp = particles_[numActive_];
365+
particles_[numActive_] = particles_[index];
366+
particles_[index] = temp;
367+
368+
// Now there's one more.
369+
numActive_++;
370+
}
234371
```
235372
反激活粒子就只要反其道而行之:
236373
237374
```c++
238-
void ParticleSystem::deactivateParticle(int index){ // Shouldn't already be inactive! assert(index < numActive_); // There's one fewer. numActive_--; // Swap it with the last active particle // right before the inactive ones. Particle temp = particles_[numActive_]; particles_[numActive_] = particles_[index]; particles_[index] = temp;}
375+
void ParticleSystem::deactivateParticle(int index)
376+
{
377+
// Shouldn't already be inactive!
378+
assert(index < numActive_);
379+
380+
// There's one fewer.
381+
numActive_--;
382+
383+
// Swap it with the last active particle
384+
// right before the inactive ones.
385+
Particle temp = particles_[numActive_];
386+
particles_[numActive_] = particles_[index];
387+
particles_[index] = temp;
388+
}
239389
```
240390

241391
许多程序员(包括我在内)都很厌恶在内存中移动数据。把内存里的字节移来移去让人觉得比为指针分配内存开销更大。但当你再加上遍历指针的开销时,会发现我们的直觉有时会失灵。在某些情况下,假如你能保持缓存数据满,在内存中移动数据的开销是很小的。
@@ -252,13 +402,33 @@ void ParticleSystem::deactivateParticle(int index){ // Shouldn't already be ina
252402
这是最后一个帮助你代码变得缓存友好的技术案例。假设我们为某个游戏实体配置了AI组件,其中包含了一些状态:它当前所播放的动画,它当前所走向的目标位置,能量值等等...总之这些是它在每帧都要检查和修改的量。如下:
253403

254404
```c++
255-
class AIComponent{public: void update() { /* ... */ }private: Animation* animation_; double energy_; Vector goalPos_;};
405+
class AIComponent
406+
{
407+
public:
408+
void update() { /* ... */ }
409+
410+
private:
411+
Animation* animation_;
412+
double energy_;
413+
Vector goalPos_;
414+
};
256415
```
257416

258417
而它还存储着一些并非每帧都用到的处理意外情况的量。比如存储一些关于当这家伙被开枪打死后掉落宝物的数据。掉落数据仅仅在实体的生命周期结束时才被使用,我们将其置于上面的那些状态属性之后:
259418

260419
```c++
261-
class AIComponent{public: void update() { /* ... */ }private: // Previous fields... LootType drop_; int minDrops_; int maxDrops_; double chanceOfDrop_;};
420+
class AIComponent
421+
{
422+
public:
423+
void update() { /* ... */ }
424+
425+
private:
426+
// Previous fields...
427+
LootType drop_;
428+
int minDrops_;
429+
int maxDrops_;
430+
double chanceOfDrop_;
431+
};
262432
```
263433

264434
假设我们采用前述方法,当更新这些AI组件时,我们遍历一个已经包装好,且连续的数组中的数据。然而这些数据中包含着所有的掉落信息。这使得每个组件都变得更庞大,也就导致我们在一条缓存线上能放入的组件更少。我们将引发更多的缓存未命中,因为我们遍历的总内存增加了。对每帧的每个组件,其掉落物品的数据都要被置入缓存,尽管我们根本不会去碰它们。
@@ -268,7 +438,26 @@ class AIComponent{public: void update() { /* ... */ }private: // Previous fiel
268438
这里我们的热数据为主AI组件。它是我们处理的关键,所以我们不希望通过指针来访问它。冷组件可以放到一边,但我们还是需要访问它,所以就为它分配一个指针,如下:
269439

270440
```c++
271-
class AIComponent{public: // Methods...private: Animation* animation_; double energy_; Vector goalPos_; LootDrop* loot_;};class LootDrop{ friend class AIComponent; LootType drop_; int minDrops_; int maxDrops_; double chanceOfDrop_;};
441+
class AIComponent
442+
{
443+
public:
444+
// Methods...
445+
446+
private:
447+
Animation* animation_;
448+
double energy_;
449+
Vector goalPos_;
450+
LootDrop* loot_;
451+
};
452+
453+
class LootDrop
454+
{
455+
friend class AIComponent;
456+
LootType drop_;
457+
int minDrops_;
458+
int maxDrops_;
459+
double chanceOfDrop_;
460+
};
272461
```
273462

274463
现在当我们遍历AI组件时,载入到缓存中的那些数据就是我们实际要处理的(当然指向冷数据的那部分的指针是一个小小的意外)
@@ -385,4 +574,4 @@ class AIComponent{public: // Methods...private: Animation* animation_; double
385574
- Tony Albrecht著的“[Pitfalls of Object-Oriented Programming](http://research.scee.net/files/presentations/gcapaustralia09/Pitfalls_of_Object_Oriented_Programming_GCAP_09.pdf)”一书大概是介绍关于游戏内数据结构设计来实现缓存友好性的材料中最被广泛阅读的了。它使得许多人(包括我!)意识到这样对数据结构的设计是多么地重要。
386575
- 与此同时,Noel Llopis就同一话题撰写了一篇广为流传的[博客](http://gamesfromwithin.com/data-oriented-design)
387576
- 本设计模式几乎完全地利用了一个同类型对象的连续数组。随着时间流逝,你将会往这个数组中添加和移除对象。[Object Pool](./06.3-Object Pool.md)模式恰恰阐释了这一内容。
388-
- [Artemis](http://gamadu.com/artemis/)游戏引擎是首个也是最为知名的对游戏实体使用简单ID的框架。
577+
- [Artemis](http://gamadu.com/artemis/)游戏引擎是首个也是最为知名的对游戏实体使用简单ID的框架。

0 commit comments

Comments
 (0)