发布于 

《游戏引擎架构》笔记3:游戏软件工程基础

本文是阅读《游戏引擎架构》第二章"专业工具”进行的归纳和总结,部分内容经过了AI的扩展。

  1. 菱形继承
  • 结构:
1
2
3
4
5
   A
/ \
B C
\ /
D
  • 问题1
    • 基类A成员有多份冗余(数据冗余)
    • 二义性(访问A的成员歧义)
  • 解决1
    • 虚继承
1
2
3
4
class B : virtual public A {};
class C : virtual public A {};

class D : public B, public C {};
  • 问题2:指针基类类型转换复杂,因为有多个vtable指针。
  • 解决2:避免类似菱形继承这样的继承,可以选择继承多个没有父类的类。
  1. 宏是预处理器的文本替换,跨C/C++作用域和命名空间范围,命名空间里要小心宏的跨作用域行为。

  2. 定点数和浮点数

  • 定点数:运算速度快,计算结果稳定,表示范围极小,大数小数不能同时存,固定比例缩放,极易溢出,无法自然表示极小/极大数值

  • 浮点数:数值范围超大,能表示极小到极大数,适用通用计算,运算相对慢,存在精度丢失,大小数混合计算误差累积

  1. 基本数据类型
  • char:8位,不同编译器还分有符号和无符号
  • int/short/longint在32位系统是32位,64位系统是64位。shortint小,多数机器位16位,long大于等于int为32位或64位。
  • float/double:多数为32位/64位。
  • bool:不同编译器实现不同8位/32位,不会实现为1位。
  • 其他:
    • Visual StudioC/C++需要知道某些变量的确切大小,定义扩展关键字声明特定位数变量:__int8__int16__int32__int64
    • C++11在<cstdint>头文件定义了std::int8_tstd::int16_tstd::int32_tstd::int64_tstd::uint8_tstd::uint16_tstd::uint32_tstd::uint64_t,标准化特定大小整数类型。
    • 游戏引擎也可以自己定义基本数据类型。比如Radian和Degree来包装描述角度单位。
  1. 小端序和大端序问题解决办法:
  • 所有数据以文字方式写入文件。多字节数值以一串十进制数字或十六进制数字,每数字一个字节写入。缺点是浪费空间。
  • 先用工具转换数据字节序,然后再把转换后的数据写进二进制文件。
  • 采用固定字节序的文件,程序读取文件按需转换(比如JPEG固定以大端序存储)。
  • 浮点数float32使用union保存数据而不是进行强制转换int32后改变字节序。
  1. 链接规范:内部链接(static)和外部链接(extern
  • 全局变量 / 普通全局函数
    • 不加 static → 外部链接
    • static → 内部链接
  • 局部变量
    • 永远无链接,只在函数内生效
  • const 常量
    • const int x:内部链接
    • extern const int x:改成外部链接
  • extern 作用
    • 声明外部链接的符号,告诉编译器:定义在别的文件
  1. 映像文件的内存布局:
  • 代码段:可执行机器码
  • 数据段:初始化全局及静态变量
  • BSS段:未初始化全局和静态变量。C/C++明确定义,任何未初始化的全局变量和静态变量皆为零。不过与其在BSS段存储可能很大块的零值,链接器只需简单地存储所需零值的字节个数,足以安置此段内未初始化的全局及静态变量。当操作系统载入程序时,便会保留BSS段所需的字节个数,并为该部分内存填入零,之后才调用程序进入点。
  • 只读数据段:只读(常量)全局变量。所有浮点常量(如const float kPi =3.141592f;)及所有用const关键字声明的全局对象实例(如const Foo gReadOnlyFoo;)就隶属此段。编译器通常把整数常量(如const int kMaxMonsters =255;)视为明示常量(manifest constant),并且直接把明示常量插进机器码中。明示常量直接占用代码段的存储空间,而不存储于只读数据段。
  1. 对象的内存布局
  • extern声明的全局变量以及类声明内的类静态变量并不占用内存,必须在一个.cpp文件内定义类静态变量以分配内存。
  • 对齐和填充:
    数据对象的对齐是指其内存地址是否为对齐字节大小的倍数(通常是2的幂次):
    • 1字节对齐的对象,可置于任何地址。
    • 2字节对齐的对象,地址必须是2的倍数。
    • 4字节对齐的对象,地址必须是4的倍数。
    • 包含4个浮点数的SIMD矢量通常需要16字节对齐。
    • 结构体末端可能加入填充字节,其对齐需求等同于其成员中的最大对齐需求。
  1. C++类的内存布局:
  • 类的派生类会简单地把其数据成员附加到末端,即使类之间可能因对齐而加入填充。
  • 虚函数会在类的布局添加4字节/8字节,通常放在类的最前端,称为虚表指针,指向名为虚函数表的数据结构,各个类的虚函数表包含该类声明或继承来的所有虚函数指针。每个非抽象类类有一个虚函数表,类的实例用虚表指针指向该虚函数表。
  1. 错误处理
  • 异常会有一些开销:调用帧会变大,堆栈的开解通常较单纯返回函数慢。一个好的替代方案是在出错时返回错误值,并且显式处理这些错误。
  • 断言:下面是在Windows平台给的一个宏定义的断言例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <windows.h>   // 必须头文件

#ifdef ASSERTIONS_ENABLED
#define ASSERT(expr) \
do { \
if (!(expr)) { \

printf("断言失败:%s 文件:%s 行号:%d\n", #expr, __FILE__, __LIN E__); \
DebugBreak(); // Windows Win32 API \
} \
} while(0)
#else
// 发布版本直接空语句

#define ASSERT(expr) ((void)0)
#endif
  1. 链接器对代码的内存布局的影响:
  • 单个函数的机器码几乎总是置于连续的内存中。在绝大多数情况下,链接器不会把一个函数切开,并在中间放置另一个函数。
  • 编译器和链接器按函数在翻译单元源代码(.cpp文件)中的出现次序排列内存布局。即位于一个翻译单元内的函数总是置于连续内存中。即链接器永不会把已编译的翻译单元切开,中间插入其他翻译单元的代码。
  1. 降低指令缓存不命中率:
  • 高效能代码的体积越小越好,体积以机器码指令数目为单位。(编译器和链接器会负责把函数置于连续内存中。)
  • 若要调用某函数,就把该函数置于最接近调用函数的地方,最好是紧接调用函数的前后,而不要把该函数置于另一翻译单元(因为这样会完全无法控制两个函数的距离)。
  • 谨慎地使用内联函数。内联小型函数能增进效能。然而,过多的内联会增大代码体积,使性能关键代码再不能完全装进缓存。
  1. CPU指令流水线问题:
  • 数据依赖造成的流水线停顿(气泡)。
  • 分支预测。
  • load-hit-store:如浮点数转换至整数,然后把结果用于之后的运算时产生停顿,因为CPU在将float转换至int时,无法直接把数据从浮点寄存器传送至整数寄存器。因此,该结果必须从浮点寄存器写进内存,然后再从内存读进整数寄存器。