基于C++的游戏引擎开发
我入行写游戏引擎差不多快十年了,这些年听到最多的问题就是:“现在都用现成的引擎,为什么还要自己写?”或者“为什么非得用C++,用别的语言不行吗?”其实这两个问题放到一起问才成立:当你准备从零做一个真正意义上的游戏引擎,语言几乎只剩C++一个选项——这不是情怀,而是由性能、生态和现实约束共同决定的。这篇文章就基于我自己维护的一套自研引擎代码,聊一聊从架构拆分、ECS设计、渲染管线、帧循环到内存管理这些核心环节为什么这样做、坑在哪里,以及哪些经验是常规文档里根本不会写的。我没有打算说服所有人都去写引擎,但如果你想入这个坑,或者已经写了半截正在纠结,这份心得应该对你有用。
1. 为什么是C++?老引擎开发者的选型逻辑
1.1 性能不是唯一理由,但它是硬约束
游戏引擎跟普通业务系统的本质差异在于:每一帧,16毫秒,你要在一个确定的时间内完成物理、动画、AI、渲染提交、网络同步等所有工作。任何语言层面的“偶尔卡一下”都是不可接受的。C++在这个场景下的核心优势不是什么抽象能力,而是对硬件资源的直接掌控——分配内存、组织数据布局、控制SIMD指令、决定缓存命中率,这些在别的高级语言里往往会隔着一层抽象,你很难精确把握。
举个简单的例子。一个大型关卡里可能有几万个渲染物体,每个物体有一个Transform组件。如果用C#或者Java,对象默认是引用类型,那么每个Transform对象散布在堆上的各个角落,遍历一遍就会疯狂触发缓存未命中,几万次遍历叠加起来就是几毫秒的额外开销。而C++可以把Transform直接放进连续数组里,缓存友好,遍历开销可以忽略不计。这个差距在刚起步时感受不到,等场景复杂度上来之后会变成瓶颈,而且不是“优化一下就行”的瓶颈,是地基层面的问题。
1.2 生态和工具链的现实考量
抛开性能,还有非常现实的生态问题。GPU驱动API(DX12、Vulkan)、底层音频库、物理引擎(PhysX、Box2D的C++版本)、最终发布的SDK,这些底层库的接口几乎全是原生的C接口或者C++接口。你用别的语言做引擎,本质上还是在用FFI绕一圈调这些库,这中间多出来的封层开销和数据转换成本有时候可以接受,但在某些极端场景(比如逐顶点传递数据给GPU)会让你非常难受。
另外,我现在打开一个同事三年前写的渲染模块,能在本机上直接用Visual Studio编译运行,把中间产物的ABI保持稳定这件事,C++至今做得比Rust和Go都老道。游戏引擎的生命周期通常横跨多年,一个模块可能换好几拨人维护,ABI稳定性和长期可维护性是硬需求。这可能也是为什么Unity和Unreal的核心层至今仍是C++——商业引擎公司可不敢把核心赌在一个语言生态还在快速变动的语言上。
1.3 C++的痛点:我们为什么要忍
说实话,C++的痛,写引擎的人是体会最深的。模板编译报错能追一屏幕,构建一次几百个源文件等十分钟,内存泄漏排查起来让人想撞墙。但你要知道我们选择C++的前提是“这是目前唯一既保有足够抽象能力,又能在所有目标平台上提供确定性能、精确资源控制的语言”。不是说C++没有缺点,而是它的缺点可以用工程规范弥补——比如统一的构建流程、严格的代码审查、内存分配器的统一封装。而这些弥补手段,本身就是引擎开发的一部分。换句话说,C++的那些坑,恰恰是引擎开发基本功的一部分:如果你想做一个能把数据搬来搬去还能保证不出错的引擎,你对内存、类型、构建的理解本来就该达到这个水准。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 引擎的骨架:先拆清楚模块,再谈写代码
2.1 模块划分:核心层与平台层的边界
我见过不少刚开始写引擎的同学,上来就把App类、窗口类、渲染类揉在一起,构造函数里又开窗口又建渲染上下文,最后什么都紧耦合在一起,改一个平台的操作系统适配要翻遍整个代码库。这个问题的根源不是懒,而是没在动工前明确模块边界。
我在实践中非常受益的方案是三层结构。最底层是平台层,负责操作系统相关的东西:窗口创建、输入接收、文件系统访问、时间获取。再往上是核心层,负责引擎自己的逻辑:数学库、内存分配器、ECS、资源管理器、渲染后端抽象。最上层是游戏层,直接用引擎提供的API写游戏逻辑,这也往往是脚本语言接入的地方。
核心层和平台层之间靠一个简单的Init/Shutdown接口隔离。平台层只需要提供窗口句柄和系统事件,核心层拿到这些之后自己去创建渲染上下文、初始化资源系统。这样当你日后要接一个新平台时,只需要重写平台层,引擎部分完全不动。
2.2 资源管理器的生命周期设计
资源管理器是引擎骨架里最容易做难看的地方。很多初学者一上来就整个unordered_map存所有资源,字符串路径当key,永远不释放——结果就是加载一大堆用不上的模型占着显存。
我自己的设计原则有三条。第一,资源对象分为“加载中、已加载、卸载中”三种状态,加载和卸载都走异步队列,绝不阻塞主线程。比如加载一个关卡时,你总不能卡在Loading界面等纹理从磁盘读取吧?第二,引用计数放在资源内部,而不是散落在各个使用方手里。第三,同一个资源在GPU和CPU侧的句柄必须分开管理,因为两者的生命周期并不总是一致——GPU可能在两帧之后才用完这块资源,CPU侧已经可以回收了。
这里给个具体例子。我们的纹理资源加载流程是这样的:请求加载时先检查缓存,没有则发起异步IO,读入完成后在主渲染线程里提交给GPU创建纹理对象,创建成功后回调通知引用方。整套流程里最难处理的是“同时有多个请求指向同一个还没加载完的资源”的情况,处理方式是在资源容器里预占一个条目,状态置为“加载中”,后续请求看到这个状态后挂同一个回调节点,加载完成时统一分发。
2.3 事件系统:别让所有模块互相知道
引擎里各个模块之间需要通信,比如玩家按下攻击键,角色系统要响应,UI要刷新,音效要播放。如果让这些模块直接互相调用,那就是一张蛛网,后期谁都不敢动其中一个模块。我的做法是加一个轻量事件总线,模块只要注册自己关心的事件类型,发布方只需要向总线发送事件,不需要知道谁在听。
具体实现上,我建议用类型安全的回调列表,而不是字符串分发。每个事件类型对应一个静态ID(比如用枚举或者类型模板特化),监听器注册的时候指定ID和回调函数指针。发送事件是一个订阅列表遍历,开销非常小。这里有一个小教训:事件回调里千万不要做耗时操作,否则会把所有订阅者的执行时间都拖进调用链。正确的做法是回调里只做记账、入队、标记脏数据,等帧末统一处理。
3. ECS设计手记:数据布局比继承关系更重要
3.1 为什么现代引擎都在转向ECS
我刚做引擎那会儿用的是传统的对象树结构,每个游戏物体是一个类,继承自一个基类,组件是类的成员变量。这套模式写起来直观,但两个问题很致命:一是每个物体占用的内存取决于它身上挂了哪些组件,遍历一坨不同大小的对象,内存布局碎片化严重;二是对象间相互引用多,比如动画系统要改Transform,得拿到Transform组件的指针,然后一路指针跳转,这在缓存层面是灾难。
ECS(Entity-Component-System)把“组件数据”和“系统逻辑”彻底切开。实体只是一个ID,组件是纯数据,放在连续数组里,系统是操作组件的函数集合。这样遍历一个系统的组件数组就是顺序内存访问,性能提升非常明显,尤其是在粒子系统、刚体系统这种需要遍历大量同构数据的场景。
3.2 连续内存布局与稀疏集实现
我要强调的一点是:很多教程把ECS讲得很玄乎,其实剥开来看,核心就两个数据结构——组件存储和实体索引。前者解决“数据连续”,后者解决“实体高效查找自己的组件”。
组件存储我用的是SparseSet结构。每个组件类型维护两个数组:稠密数组存实际组件数据,稀疏数组存实体ID到稠密索引的映射。当你问“实体42有没有Transform组件”时,两步操作:查Sparse数组取下标,再查Dense数组确认实体ID匹配。加组件是稠密数组尾部插入,删组件是把尾部元素交换到被删位置再缩尾。整个删除操作是O(1),而且不产生内存碎片。
这套方案的优势在性能测试里非常明显。我们的测试场景是两万个带Transform和Renderable的实体,遍历更新Transform再加脏标记,用传统对象结构要4.6毫秒,用SparseSet的ECS只要0.8毫秒,差距将近6倍,而且数据量越大差距越明显。
3.3 系统调度与依赖顺序
ECS另一个容易踩坑的点是系统的执行顺序。比如物理系统要先更新刚体速度,然后碰撞系统检测碰撞,再更新Transform,最后渲染系统才读取Transform。顺序错了,所有逻辑就会差一帧,表现就是“明明打了子弹,但命中判定偏了半个身位”。
我的做法是给每个系统声明自己依赖哪几个组件类型,引擎在启动时根据依赖关系做拓扑排序。比如物理系统声明“写Position和Velocity,读Collider”,渲染系统声明“读Renderable和Transform”,这样引擎确定执行顺序时就能保证物理系统先跑,渲染系统后跑。如果两个系统都写了同一个组件,则按注册顺序先后来,同时给出编译期警告,避免出现数据竞争。
3.4 ECS带来的一个隐藏收益:序列化变简单了
当所有实体组件都是纯数据时,序列化和反序列化就变得非常直白。存档时只需要把实体ID和组件对应的内存块直接写文件,加载时逐块塞回内存。我们甚至可以直接比较两块内存的字节数来估算存档大小,做增量存档时相当方便。相较之下,传统对象树序列化要处理各种继承和多态,整个序列化模块会复杂一个量级。
4. 渲染管线:从顶点数据到屏幕像素的旅程
4.1 渲染队列与状态排序
熟悉图形API的人都知道,渲染性能的大敌不是计算量,而是状态切换。GPU PipeLine状态(着色器程序、渲染目标、采样器、混合模式)一旦变了,硬件要重新装配,这个开销通常比多绘制几百个三角形还贵。所以每次刷帧我做的第一件事不是直接把场景里的每个物体送到GPU,而是先构建一个渲染队列。
构建队列时会做排序:同一个材质且同一个管线状态的物体挨在一起,从远到近排,这样同一批物体只需设置一次渲染状态就能一口气绘制完。这个班次调度有点像操作系统里的进程调度——把CPU/GPU的切换成本降到最低。实测下来,单纯靠排序,一个几千物体场景的Draw Call数量能减少40%以上。
4.2 着色器工程化:别再把着色器嵌在字符串里
早期我为了让代码简单,把顶点着色器和片元着色器直接写成字符串塞在C++源文件里,编译时拼接,运行时报错才知道写错了。后来项目规模大了,发现这绝对是给自己挖坟——改一个uniform名字要从LMN一个巨大的字符串里精确找到那一行。
现在我的方案是着色器源码以文件形式存放在专门的shader目录,写一个离线编译工具,编译成字节码存到资源包,运行时直接加载字节码,还能顺便把反射信息(uniform列表、输入布局)一起生成好。这个改动带来的额外好处是热重载——编辑器里保存着色器文件,引擎检测文件变化后重新编译并热替换渲染状态,调试效果时效率翻倍。
4.3 一个典型的场景提交全流程
拿“渲染一个带阴影的场景”来拆解流程的话,大概是这样的:先是场景系统把所有相机会用到的光源收集起来,生成光源列表;然后渲染器从场景里按相机视锥体做裁剪,剔除不可见物体;接着提交每个物体的Mesh、材质参数到渲染队列;排序完成后,先渲染Shadow Map Pass,把深度信息写到一张纹理;再跑Base Pass,采样Shadow Map计算阴影,输出到颜色缓冲;最后做后处理,比如色调映射和泛光。每一步之间的依赖通过GPU Fence同步,避免CPU和GPU互相等。
这一套流程说起来简单,但细节很多,比如CPU提交的数据和GPU读取数据之间至少要隔一帧,防止CPU在GPU还在用顶点缓冲时就去覆写它。处理方式是用三重缓冲的顶点数据,每帧轮换一个缓冲,保证安全。
5. 帧循环与多线程:保证引擎心脏不乱跳
5.1 固定时间步长与渲染插值
游戏循环的经典问题:如果每帧时间不一样,物理逻辑会不稳定。假设物理步长是0.016秒,但某一帧实际过了0.05秒,你要是按实际时间直接把物体推进,碰撞检测会出问题——物体可能直接穿过墙壁。业界标准解法是固定时间步长加累积器:累积器累加实际帧时间,每达到一个固定步长就执行一次物理/逻辑更新,执行次数可能是一两次,也可能为零次;如果累积时间超过最大步长(比如0.25秒),要丢弃多余时间,防止出现“死亡螺旋”。
但如果你固定步长,渲染帧率却是不定的(60Hz还是144Hz都有),那渲染的画面可能落后于逻辑状态。我使用的做法是记录上一个逻辑状态和当前逻辑状态,渲染时根据一个alpha参数(当前时间在两步之间的插值比例)做插值,这样画面过渡平滑,不会一节一节的跳帧。
5.2 Job System与并行调度
现代CPU核心越来越多,单线程跑引擎等于自己给自己限速。我的引擎里有个轻量Job System,核心就是一个工作队列加一组工作线程。任务可以随时被提交,线程池里的空闲线程自动取出任务执行,任务之间通过依赖关系等待。比如物理系统里,每个岛(一组有碰撞关联的刚体)可以并行求解,一个岛一个Job,全部完成后再统一提交给渲染。
这套实现最需要注意的点是“不要在线程里直接分配内存”。多线程并发调用malloc会在堆锁上互相卡,性能退化得非常厉害。解决方式是给每个工作线程配一个本地内存池,Job里要内存的时候走线程私有分配器。就这一个改动,我们的多线程物理从六个线程几乎无加速比提升,变成了接近5倍的线性扩展。
5.3 数据竞争与race condition的预防
多线程最怕的是数据竞争,调试起来极其痛苦。在实践中我的经验是立几条硬规矩:第一,组件数据默认是“单写多读”,物理系统可以并行读Transform,但只有动画系统可以写它;第二,跨线程通信一律通过任务队列传递数据,不允许直接拿别的系统的容器地址;第三,标记脏数据的变量用原子操作,不用裸变量。另外,我强烈建议上线Debug构建时开启ThreadSanitizer或AddressSanitizer,虽然会拖慢运行速度,但能一套测试就抓到大多数数据竞争。
6. 内存管理的隐形战场:从new/delete到内存池
6.1 为什么malloc和delete在引擎里是“重罪”
如果你在一个游戏里给每个子弹对象都用一次new,等子弹多了,你会发现帧时间忽高忽低,卡顿毫无规律。原因是malloc在寻找空闲块的过程中可能会触发系统调用去扩展堆,也可能在释放时产生碎片,导致后续分配越来越慢。这个问题在桌面电脑上还能忍,在主机上就是灾难,因为主机内存紧张,碎片化到一定程度就OOM。
我入职第二年的一个工作就是排查一个“随机卡顿十分钟”的线上问题,查到最后发现是战斗系统每帧创建和销毁几千个小粒子对象,堆碎片化严重。从那以后,我在团队里立的规矩就一条:游戏逻辑热路径上空闲场景一律不得直接调用系统内存分配器。这是引擎开发里最重要的一条铁律之一。
6.2 对象池与帧内存分配器
实际的替代方案有两个,一个叫对象池,一个叫帧内存分配器。对象池适合长时间存活但频繁创建销毁的对象,比如子弹、粒子。提前分配一大块内存,用完了回收到池子里,下一次分配直接取用,释放是O(1)。帧内存分配器适合只存活一帧的数据,比如把本帧的碰撞点列表、临时变换矩阵全都放在一个“帧缓冲块”里,每帧结束时整体重置,不需要逐个释放。
这里有个细节值得分享:帧内存分配器可以用一个简单的线性分配算法,用一个指针指向当前偏移,每次分配把指针往后挪,整帧结束时指针复位到块首。因为允许越界替代,这个分配器的分配速度比malloc快两个数量级。唯一要小心的是“使用期不超过一帧”这个约束要写清楚,否则有人不小心把帧内存里的指针存到跨帧的数据结构里,就会变成悬垂指针。
6.3 智能指针的正确用法:引擎里别乱用
很多C++教程鼓励用shared_ptr管理一切,但游戏引擎的核心循环里我很反对用shared_ptr——原因不是“所有权不明”之类的抽象问题,而是shared_ptr的引用计数在多线程下要用原子操作,开销不小,而且一旦出现循环引用就泄漏,调试极难。我的做法是Teco:组件和资源用对象池管理,归属清晰;跨模块传递只使用原始指针或引用。只有在生命周期复杂的脚本绑定层才用shared_ptr,因为这些对象确实无法确定谁最后释放。
关于内存这个话题,我顺便解释一个面试常考点:“位运算在内存管理里的用途”。实现内存池要记录块是否空闲,小对象池用std::vector
7. 给想入坑的人:一条被验证过的学习路线
7.1 先把C++基础打扎实,不要急着碰引擎
我经常看到一些人看了一两篇引擎架构文章就热血沸腾,翻出GitHub上的开源引擎就开干,结果连构造函数初始化列表都写不明白,模板偏特化没接触过,编译报错也不会看。这属于地基没打就盖楼。如果你真想走引擎开发这条路,我的建议是把C++语法基础先啃扎实,重点内容包括:内存模型(栈、堆、生命周期)、容器和迭代器的底层实现(vector的空间增长、map的树结构)、构造函数/析构函数/拷贝语义/移动语义的完整细节、模板基础和常见泛型写法、运算符优先级(这个看起来很无聊但代码阅读速度会慢很多)、回调函数(在事件系统、任务系统里都大量用)。不是让你背语法,而是这些知识点会在看引擎源码时反复出现。
特别说一下回调函数。你在引擎代码里会频繁看到“传入一个函数指针或lambda,让某个系统在条件满足时调用它”。这是任务系统、事件系统、异步资源加载都绕不开的核心机制。C++里实现回调的方式有很多种,函数指针、std::function、lambda、模板回调,各有优劣。我的建议是引擎全局层面统一使用std::function,虽然带一点创建开销,但类型安全、捕获方便,不会在你的代码里混杂三种风格。
7.2 软渲染器:理解图形学的最佳起点
想写渲染模块,我强烈建议先做一个软渲染器——完全不依赖GPU API,用自己的C++代码把三角形光栅化、深度缓冲、纹理采样、透视投影全部实现一遍。原因很简单:GPU API过多地封装了底层细节,你会稀里糊涂地设置管线状态,把顶点传进去然后看到结果,但完全不明白中间发生了什么。软渲染器逼你亲手算“一个三角形在屏幕上长什么样”,逼你实现顶点变换矩阵,逼你处理深度排序。这些理解在今后的优化工作中会救你无数次。
我记得自己写软渲染器的那个周末,调试一个“三角形上下颠倒”的问题查了一整天,最后发现自己把屏幕坐标Y轴方向搞反了。虽然很挫败,但从此再也不会犯同样的错误。
7.3 分阶段推进,用“能玩的游戏”当里程碑
引擎开发最怕的是没有阶段性成果,写了大半年还是一堆不可运行的代码。我的建议是设定几个里程碑:第一个月目标是渲染一个旋转立方体;第二个月加入贴图、相机控制,能跑进一个场景里环视四周;第三个月加入简单物理和碰撞,能推着一个物体走;第四个月加入粒子系统和简单UI,做一个像样的TechDemo。每个里程碑都是一个“能玩的东西”,这份成就感会支撑你走完后面漫长的路。
如果中间卡住了,不要一个人死磕。现代开源引擎的源码是最好的参考,比如Godot、Unreal都有大量技术分享和源码解析。你可以选一两个核心模块读读人家的实现思路,再回到自己的代码里修正设计。很多人担心的“Godot引擎游戏乱码”这类问题其实大多是编码配置没调好,趁早搞清楚字符编码和资源导入链路,对后面的开发会很省心。
7.4 关于“C++面试八股”——真正的掌握是什么样的
最后聊点实际的。现在网上流传着各种“C++八股文”,什么智能指针实现原理、虚函数表布局、内存对齐、以及各种排序算法(冒泡、单调栈这些)在面试里反复被问。我的态度是:八股本身不是没有价值的,它是对语言底层机制的一种速查清单。但如果你只会背八股而没写过真正复杂的系统,一落到实际项目里就会露馅。面试官真正想确认的是你有没有形成“站在资源边界思考”的思维习惯——你是不是会主动关注这份数据是谁分配、谁释放、会不会被并发访问、能不能放在连续内存里。
这些能力只有在真实项目里才能长出来。引擎开发恰恰是锻炼这种思维最好的土壤,因为每一个模块都在逼迫你做资源边界和生命周期决策。所以如果你是想学C++的深度用法,又不想做无聊的控制台练习题,那么尝试写一个小型游戏引擎,哪怕最后只完成了场景渲染和简单的物理,你收获的也会比刷一百道算法题多得多。
