AI时代,我为何坚持手写C++游戏引擎?
2026/9/14 3:59:07 网站建设 项目流程

生成式 AI 重构一切的时候,我为什么还在为自己的引擎从零写代码

公司里最近开了好几轮会,主题无非是怎么把生成式 AI 接进产品管线,怎么用 AI 批量产出美术资产、生成关卡配置、自动写 UI 脚本。同事们的项目推进得飞快,昨天还在跑 demo,今天已经准备上生产。我坐在角落,每次轮到我发言,讲的是这个月我们的引擎又新增了哪个底层特性。

不是刻意唱反调。GitHub Copilot 和 Claude 我也在用,而且用得不少。但手头这个游戏引擎项目,我从两年前就开始写,到现在依然坚持用 C++,一行一行地写。有人问,都生成式 AI 时代了,连小游戏都能让 AI 几秒钟生成一个,你还在手搓引擎,图什么?这种话听多了,我反而把这件事想得更清楚了。这篇就把我的真实经历、技术选型逻辑、踩过的坑,以及 AI 在这个过程中到底扮演什么角色,一次性说明白。

先说结论:AI 生成的是"结果",而我需要的是"对结果的掌控力"。游戏引擎是掌控力的最终载体,C++ 是这个载体最底层的表达方式。这两个东西,在可见的未来,都不会被 AI 替代,反而会因为 AI 的存在而变得更值钱。

1. 在 AI 重构一切的时代,一个手写引擎的"局外人"经历了什么

1.1 当同事都在跑 AI Demo时,我在修的却是一个崩溃日志

上个月有个场景让我印象很深。团队里用 AI 工具把游戏场景生成从两天缩短到了两小时,美术同学那边一片欢呼。而我在同一周做的事情是:花了一整天定位一个偶发的崩溃,最后发现是纹理流送模块里一个资源生命周期管理的问题——某个纹理已经释放了,但渲染队列里还有一帧在引用它。

这种对比非常有意思。AI 在"生成内容"这件事上的确把我秒成了渣——给它一句提示词,它能产出我会花一下午才能做完的东西。但崩溃日志不会因为说"这段代码是 AI 写的"就变得更好定位。栈回溯、寄存器状态、内存布局、并发时序,这些东西不关心你是人写的还是 AI 写的,它们只关心你写的代码是不是真的正确。

后来我用 AI 工具帮我复盘了那次崩溃,它倒是在几分钟内给出了一条有效的排查思路:检查纹理的引用计数逻辑里是否遗漏了对"渲染管线中正在使用的资源"的保护逻辑。我沿着这个方向去查,果然发现了问题。但前提是,我得首先能理解它给出的思路,能把它翻译回我引擎的架构里,判断它是否符合我的设计约束——这套约束用一句话概括就是:渲染线程绝不能去访问一个生命周期已经被逻辑线程终结的资源。

这件事让我想明白了一个很重要的点:AI 能帮你"分析问题""生成代码",但它没法替你做"工程判断"。而"工程判断"恰好是把引擎这种复杂系统做好的核心能力。

1.2 热搜词里的"程序员"正在被重新定义,但底层规律没有变

我发现热搜词里关于程序员的内容越来越焦虑了,什么"程序员外包""程序员转行做什么好"经常挂在榜单上。我朋友圈里也确实有朋友在认真考虑要不要跑去做 AI 提示词工程。

我的观点是:程序员这个身份在实践中被重新定义这件事是客观存在的,但被重新定义的是"执行层",不变的是"判断层"。过去判断力体现在架构设计、复杂逻辑拆解、系统瓶颈分析;现在判断力多了一个维度——怎么有效地跟 AI 协作、怎么判断 AI 生成的代码是否靠谱、怎么在 AI 生成的方案与工程现实之间做取舍。

C++ 手搓引擎这件事,恰好同时锤炼了这层判断力的两个侧面:一方面,你必须对底层机制有足够深的理解,才能判断 AI 给的方案是否经得起推敲;另一方面,你手上掌握的东西越接近"最终被依赖的那一层",你的不可替代性越强。画皮画骨难画魂,AI 能画出很多项目的皮,但"魂"需要有人真正把它雕刻出来,而手写代码正是雕刻的基本功。

2. 为什么偏偏是 C++,又为什么偏偏是"游戏引擎"

2.1 选 C++ 不选 Rust 不选 Zig的真正理由

其实我最早也在 Rust 和 C++ 之间犹豫过。那会儿 Rust 在系统编程圈已经很热了,内存安全、无 GC、模式匹配写起来很舒服。但最后我还是选了 C++,理由可能跟你想的不太一样。

第一个理由是生态惯性。游戏引擎领域几十年的技术积累、中间件、平台 SDK、引擎工具链,大部分都是 C/C++。我接一个平台特性,查文档时最有价值的内容往往还是 C++ 写的,虽然现在很多新项目的 README 已经开始默认用 AI 生成,底层头文件注释、API 参考文档里的语义说明,翻开历史版本沉淀下来的依然以 C++ 为主。选 C++ 意味着我可以直接站在这个巨人肩膀上,不用花时间做语言层面的适配和封装。

第二个理由是我需要的特性,C++ 到现在依然是最平衡的。我要精确控制内存布局;要在多线程环境下无惧数据竞争的裸奔快感(当然要自己负责);要能在需要的时候直接嵌入汇编级优化;还要能拿到最小的二进制体积和最快的启动速度。这些事情不是别的语言做不到,而是 C++ 给了我最直接的路径,没有额外的运行时抽象挡在中间。

第三个理由,是我私心很喜欢的:C++ 标准演进的速度和方向让我觉得这个语言还活着。C++20 加了概念、协程、模块,C++23 把 std::expected 这类东西也拉了进来。虽然我们项目因为要兼容移动端工具链,暂时卡在 C++17,但这条路越走越宽,而不是越走越窄。

后面我单独说一下我们项目实际用的 C++17 特性集以及踩过的坑,这里先不铺开。

2.2 游戏引擎这个项目,为什么值得手搓

很多人理解不了手搓引擎这件事,一个很核心的问题在于:现在现成的引擎那么多,Unity、Unreal、Godot,哪个不比你自己写的强?对,它们每个都比我强一万倍。但我做这个项目的目标压根不是"做出一个商业级引擎",我把它看成三件事。

第一件事是彻底消化计算机图形学和游戏引擎架构的知识。读了十年图形学文章,不如自己写一次渲染管线来得透彻。矩阵变换、视锥裁剪、渲染状态切换、资源生命周期,这些概念在书里是知识点,在手搓引擎里是每天都要面对的"脾性"。第二件事是给我自己的游戏想法提供自由度。用现成引擎时,我经常因为引擎的框架而改变自己的设计——想做个体素游戏,引擎的流送方案可能不支持;想做个大世界,Unity 的包体结构可能要绕很多弯。自己的引擎想怎么做就怎么做,它是一张白纸。而自由度对独立开发者来说,是比性能更重要的竞争优势。

第三件事可能最实际——它的价值会被 AI 放大。现成引擎已经被 AI 训练数据覆盖得非常充分了,你让 AI 写一段 Unity 的 C# 脚本,它写得比大多数初级程序员都好。但你让它写一段专门为你的引擎定制的渲染队列管理逻辑,它没有你的上下文,写出来的东西基本没法直接用。这反而让"自己写的引擎"成了 AI 时代最能拉开差距的技术资产——因为你的代码库是私有的、独特的、不在大模型的预测分布里。

3. 我的引擎架构拆解,以及构建过程中的关键决策

3.1 从一开始就选对的核心模块拆分

项目启动时,我先画了一张非常简洁的架构图,把引擎分成四个核心模块:运行时核心、渲染后端、资源系统和工具层。这张图两年后回头看,依然是稳的,好的架构应该能扛住你在当初看不到的未来需求。

运行时核心负责底层的东西:内存分配(我直接用了一个可替换的自定义分配器,方便以后做内存池优化)、平台抽象层(后面要有 Android、Windows、macOS 三端)、事件系统和数学库。渲染后端实现了 Vulkan 1.2 的一个最小可用子集,以及一个软件渲染回退。如果说从长远来看要有更好的跨平台方案,当时其实设想过把图形 API 层面单独再抽象一层,让平台侧只关心窗口和上下文创建。后来实际接入 macOS 时,这个抽象确实帮我省了很多事。

资源系统是引擎最容易在后期爆炸的地方。大多数引擎在资源加载上踩过的坑,往往不在"加载慢",而在"生命周期管理乱"。资源A被场景引着,场景又被另一个异步加载的资源引用着,卸载顺序不对,纹理就黑屏。我在这方面一开始就做了一个设计决策:所有资源引用走引用计数,不提供手动释放的能力。开发者拿到的只有 ResourcePtr 这种智能指针,引用计数归零就自动卸载,需要缓存就持有缓存。这个决策一开始让资源管理代码复杂了一些,但两年下来,它帮我避免了大概七八个最头疼的崩溃类型,杠杆率实在太高了。

我们的 ECS(实体组件系统)模块借鉴了很多成熟方案的设计思想。我曾参考过 EnTT 的实现,但那玩意儿直接引进来太重了,而且它有它自己的一套性能假设,不一定适配我的场景。于是我用了一个比较朴素的方式:用数组存储组件,用稀疏表做实体到组件的索引,组件增删只在重建阶段做,避免在迭代过程中改容器结构。这套简化版 ECS 在几千个实体场景下的性能已经足够碾压我的游戏需求了。

3.2 渲染线程和逻辑线程打交道的方式,藏着最多坑

多线程渲染是引擎性能的关键,也是最容易出 bug 的地方。我的方案是一个经典的"数据双缓冲"模式:逻辑线程把一帧的渲染命令写进一个命令缓冲区;渲染线程在收到"帧结束"信号后,交换缓冲区,并把上一帧的缓冲里的命令提交给 GPU。

这套方案从《游戏引擎架构》这本书里学来的,实际落地时有两个容易漏掉的细节。

第一个是命令缓冲区的分配策略。你不可能每一帧都 new 一个 vector 然后在帧结束的时候销毁它,那样会造成严重的分配开销和缓存 miss。我的做法是预分配一组容量足够的命令缓冲区,帧结束交换时只交换指针,写完的那一帧不立即清理,而是下一轮循环复用的时候再做"条件清空"。清空不能是 clear 了之,而是遍历命令对象,逐个调用析构函数(如果有必要),再重置容器大小。

第二个是逻辑线程和渲染线程的"交付时机"。逻辑线程写完命令之后,绝不能直接唤醒渲染线程让它马上执行——因为渲染线程可能还在渲染上一帧,你如果直接把命令缓冲区用一个新的地址给过去,那根据交换语义也许还是安全的,但如果你还复用了同一个缓冲区地址,那么旧的渲染可能读到正在被逻辑线程写入的数据。这个竞态条件非常隐蔽,我花了差不多三个晚上才在一次偶发的渲染错乱中定位到它。

我现在每帧的组织结构就是这样:逻辑线程执行系统更新,写完本帧命令后,用原子变量发布"帧序号+1",渲染线程自旋等待帧序号变化后取走新缓冲区。虽然听起来有点简陋,但配合帧尾同步信号量,整个流程是非常稳的。

3.3 选型细节:为什么用 CMake 而不是其他构建系统

C++ 社区构建系统之争可以写一本书了,我是站在 CMake 这边的。理由很实在:它是当下唯一能在 Windows、macOS、Linux、Android、iOS 上统一描述的构建方案,而且 IDE、生成器生态最成熟。

版本选型上我用了 CMake 3.28,开启了一些比较新的特性,比如对 Intel LLVM 和 MinGW 工具链的改进支持(具体版本特性我用 mac 自带工具链时遇到过兼容问题,后面单独讲)。项目里构建配置的一个关键点是生成器优先级策略:

  • 在 macOS 上优先使用 Xcode 生成器,方便接 Instruments 做性能分析
  • 在 Windows 上优先使用 Visual Studio 生成器,方便接 PIX 做图形调试
  • CI 环境里统一用 Ninja 保持速度

这个策略说起来简单,落地的时候有一个小坑——CMake 的 CMAKE_GENERATOR 是在配置阶段决定的,你没法在一个 CMakeLists 里根据平台连续切换生成器。我在外层包了一个 shell 脚本,负责判断当前平台然后调用带 -G 参数的 cmake,这样做最简单可靠。

3.4 使用的第三方库以及版本锁定策略

手搓引擎不代表不用第三方库,我只是极度克制地选了几个:

  • SDL2 做窗口和输入抽象(本来在 SDL 和 GLFW 之间纠结过,SDL2 的音视频能力留着备用,成本更低)
  • Vulkan SDK 官方头文件和加载库做图形 API
  • stb_image 处理图片解码
  • Dear ImGui 做调试 UI
  • glm 做数学运算
  • 自定义 70 行实现的 log 模块,不想引 spdlog,因为只用了它 5% 的能力,还白送了几十 MB 编译时间

SDK 管理的策略很关键。我把第三方库全部用 FetchContent 的方式拉取,但锁定确切版本 hash。这样做的收益有两点:一个是新克隆项目时可以自动拉取正确版本,不用手动下 SDK;另一个是每次升级库版本非常确定。代价是每次要承担一次编译时间,不过我们用 CCache 之后这块基本被吃掉了。

最近一次 FetchContent 踩坑比较有代表性——Vulkan SDK 在某次版本更新后,vma(Vulkan 内存分配器)的头文件里因为引入了 C++20 特性导致 17 模式下编译报错。我整整花了两个多小时才定位到这个原因,当时我们为了兼容移动端工具链还锁死在 C++17。后来解决的办法是给 vma 单独建一个目标,用 C++20 特性去编译它,而引擎本体保持 C++17,两者用纯 C 接口衔接。这种"跨标准版本协作"的思路,在 C++ 项目里会经常遇到,值得记住。

4. 生成式 AI 在我的引擎开发里,到底发挥了什么作用

4.1 AI 是"结对程序员",不是"替代程序员"

这里用最直接的方式说说我的实践。我日常是这样用 AI 工具的:当作一个 7x24 小时在线的结对程序员,它负责提供方案、补全样板代码、快速查文档,我负责做技术判断和最终审计。

比如今天上午我需要写一个组件序列化器。这个功能无非是把每个组件的字段名、类型、偏移拼成一个表,然后通过反射机制完成保存和加载。要说难也没多难,但写起来很消耗时间。我让 AI 先生成了一份基于模板特化的序列化框架,然后我花二十分钟审了一遍代码,发现它在字符串池的处理上有一个边界问题:它假设所有字段名都来自一个静态表,但我的组件里有些字段是运行时才能确定名字的动态属性(比如自定义事件回调的标识)。我修正了这个部分,随后它的价值就体现出来了——省掉了我写那几百行重复代码的时间。

关键点在这里:AI 生成的那部分代码,我能理解它的每一行。为什么要理解?因为如果不理解,我根本发现不了那个"字符串池静态假设"。很多同学用 AI 写完代码直接跑,跑通了就算完。在一般业务代码里这么干也许问题不大,但在引擎里,"跑通"和"正确"之间的距离是巨大的——它可能 100 次里跑通 99 次,剩下那个 1% 的崩溃会在线上咬你一口。

4.2 AI 辅助"搜索"代替"翻文档"

另一个我依赖 AI 比较重的场景是 API 查询。C++ 标准库和 Vulkan 的 API 文档都厚得能砸死人,有时候我就想知道std::optionalreset()之后*this还有没有值这种问题。传统方式要翻 cppreference,现在我会直接问 AI,让它把标准里的语义解释清楚。

但我绝不直接采纳 AI 给出的代码——除非我可以通过本地编译验证它的行为。我的工作流是:先把 AI 给的代码放进一个sandbox目录里,写个最小用例跑一下,确认 API 行为符合我的预期,再把它的核心思想拿回到引擎工程里用。这一步验证成本很低,但能防住 90% 的 AI 幻觉。

有一个很典型的例子:有一次我想用std::transform配合并行执行策略做资源文件的批量解码,AI 给了一段代码,用的是std::execution::par_unseq。编译过了,但在我们的自定义分配器环境里跑出的性能比单线程还差,而且偶尔有数据竞争。我查了一下才知道原因:par_unseq在底层可能会做向量化,而向量化要求数据在内存里连续,我们那个解码流程的数据访问模式并不满足这个条件,反而导致线程切换的开销远大于收益。这个经验后来直接沉淀到了我们引擎的编码规范里:除非数据访问模式已经明确满足并行策略的前提,否则不要用并行std::transform

4.3 我坚决不让 AI 碰的部分

在 AI 面前,有三个地方我设置了绝对禁区:核心数据结构的实现、渲染管线的状态管理、以及任何涉及资源生命周期的代码。理由很简单:这些地方的正确性依赖的是长时间的上下文理解,而不是局部代码的"看起来合理"。

举个例子。我们的 ECS 组件存储采用了一个稀疏表结构,实体 ID 是一个 32 位整数,高 8 位是版本号,低 24 位是索引。这个结构保证了"实体被销毁后 ID 不会立刻复用"这条对异步系统至关重要的语义。如果让 AI 帮我改这个地方,它很可能把实体 ID 简单当成一个 index 来用,误以为删除后马上可以复用——在本地跑 100 次测试可能都发现不了问题。但一旦进入生产环境,一个延迟一帧才释放的旧 ID 引用了一个新实体,画面就会出现一个莫名其妙的工件。所有这类 bug,都是我花了最惨痛代价才学会的,我不会让 AI 用"看起来合理"来破坏它。

如果你也打算用 AI 辅助维护自己的引擎,我建议你也在工程里设定类似的"红区"清单。这不一定基于技术本身,它可以是你代码库里所有"需要跨子系统上下文理解"的部分。AI 生成单点代码的能力很强,但跨上下文的一致性,那是人类架构师的主场。

5. 从零到第一帧画面:程序员的勇气与坚持

5.1 第一个三角形,比想象中更折磨人

说句实话,最开始的起步阶段是最难的,难在"万事开头难"这件事在图形渲染里格外真实。第一帧画面的背后,是窗口系统、交换链、渲染管线、着色器、顶点缓冲等多达十几个环节的协作,任何一个环节出问题,最终表现都是"黑屏"或"崩溃",而你很难知道到底坏在哪一环。

我以 Vulkan 为例说一个非常典型的"第一个三角形"的路径。流程大概是这样的:初始化 Vulkan 实例和逻辑设备、创建交换链和深度缓冲、创建渲染管线和框架缓冲、上传顶点数据、录制命令缓冲、提交队列、呈现图像。这中间任何一步出错,结果都是窗口能开,但画面上什么都没有,或者引擎崩溃。

我当时用了整整一个周末才走通这条路径。调试过程极其痛苦——因为你不知道是该怀疑初始化的某一步,还是渲染管线的某个状态设置,还是着色器的编译结果。常常是自己改了一处无关痛痒的代码,画面还是黑的,但你根本不知道是"已经改好了一部分只是另一部分还坏着"还是"改了半天其实在原来的基础上前进了一步"。

我的做法是把这条流程拆成几个"里程碑",每个里程碑单独验证:

  • 第一个里程碑:窗口能创建,交换链能获取图像。验证方法是把后台缓冲刷成一个纯色,或者往上面绘制一个简单的颜色渐变。
  • 第二个里程碑:图形管线能创建成功,没有验证错误。这一步什么都不用绘制,只需要保证管线相关对象创建成功即可。
  • 第三个里程碑:顶点数据上传和命令录制正确,能提交给 GPU。这时候就算画不出东西,也可以通过 RenderDoc 的帧捕获看到顶点缓冲和调用链。
  • 第四个里程碑:画一个最简单的三角形。

每跨过一个里程碑,我都把项目做一次 tag,写一条简单的 commit message。这种"可验证的推进"方式让漫长的调试过程变成了一条条可以量化进展的台阶。后来我发现这几乎是所有图形程序开发者的共识——越是复杂的系统,越要切成可以独立验证的小块。这个习惯后来也延续到了引擎的其他模块开发中。

5.2 软件渲染回退的意外价值

在做渲染器的时候,我临时写了一个软件渲染回退路径,这个决策意想不到地为整个开发流程带来了巨大价值。Vulkan 驱动不是哪里都能用的,尤其在 CI 环境或者虚拟机上,硬件加速经常不可用。软件渲染路径让引擎在没有 GPU 的环境里也能跑通大部分逻辑测试——不需要创建真实的图形管线,只把 CPU 端的数学运算和场景管理跑对就行。

这对游戏开发流程太重要了。很多引擎的单元测试都被 GPU 复杂度吓退了,但我可以轻易地在 CI 里执行一组纯 ECS 和物理测试、数学库测试,以及不带渲染的集成测试。等代码合并回主分支时,只在本地用硬件渲染做一次视觉验证,这种分层验证体系帮我节省了大量调试时间。

软件渲染的另一个用途是作为渲染"正确性"的参考实现。有一次我发现硬件渲染的阴影方向跟预期不一致,我先在软件渲染里算一遍相同矩阵下的结果,两边一对比才发现是我在做法线变换时遗忘了一个变换细节:法线变换矩阵应该用逆转置矩阵,而我直接用模型矩阵做了变换。这种错误在有参考实现的情况下几秒钟就能定位,没有参考实现就得靠肉眼看画面猜了。

5.3 手写引擎给我带来的"调试者的骄傲"

说到调试,有一件事我特别有成就感。前段时间项目遇到一个非常刁钻的 bug:某些机器上渲染的贴图会有撕裂条纹,但只在镜头快速旋转时会偶现。我初步判断是采样坐标回绕模式的问题,但换了 CLAMP 和 REPEAT 都不行。

后来我把 Vulkan 的验证层打开,发现了一条警告,提示我某个图像的 mipmap 等级与实际纹理的 mip 数量不匹配。我一查才发现,我在一处纹理上传代码里硬编码了一个基于log2(width)计算的 mip 数量,但这个纹理的高宽比不是 2 的幂次,导致最后一层 mip 的尺寸计算错误,采样时产生了一些未定义的边缘行为。

这个 bug 的排查,如果用 AI 直接"帮我看一下",基本不可能定位,因为上下文太长了。但如果用传统的关键词搜索——"Vulkan mip map 撕裂条纹",第一页结果大概率也帮不上你什么忙。最终靠的还是对渲染管线的整体成熟度:我知道验证层会输出什么信息、我知道一个图像的所有 mip 都是自己手动计算上传的、我能在正确的官网上查到规范的行为。这就是我在第 1.2 节里说的"不可替代的判断力"。

6. 手写引擎和 AI 协作,最怕踩的五个大坑

6.1 被 AI "看起来正确"的代码带偏方向

这是我在开发中最常遇到的一类问题,必须要单独拿出来提醒。AI 生成代码的速度太快了,快到它给出的内容会天然给人一种"权威感"。尤其在引擎开发这种领域,普通程序员能判断 AI 输出是否正确的人本身就不多,于是很多朋友姑且信了"AI 生成的引擎代码能给我提供正确答案"的错觉。

我给你举一个具体的例子。有一次我问 AI 怎么写一个基础的平行光阴影映射的 shader。它给了一个看起来很标准的实现,深度写入、采样、比较。我投进去一跑——阴影确实出现了,但非常"脏",像屏幕上有随机噪点。我看到噪点的第一反应还以为是采样偏移的问题,调了半天,后来一查发现 AI 在深度比较时用的 pcf 偏移搞错了坐标系:它把偏移加到了裁剪空间的坐标上而不是采样坐标上。这种错误,如果让我手写,我大概率不会犯。因为它来自于"AI 对接口理解不精确"——它把不同坐标空间的惯例混在了一起。

从这次以后,我养成了一种"怀疑优先"的心态:AI 给的代码,我先默认它有隐藏错误,再通过本地验证来推翻这个假设,而不是反过来。这套心态配合上面提到的 sandbox 最小用例验证法,能过滤掉几乎所有的低级错误。

6.2 忽略工具链之间的兼容性

引擎开发里,工具链是最容易被忽略的"隐藏假设"。AI 给的代码经常默认你在用最新标准、默认你的编译器和库之间是兼容的。但真实环境里,特别是跨平台项目,同一个 C++ 源码在不同编译器下的行为差异会大得惊人。

我踩过最典型的坑是 macOS 上用 Xcode 的 AppleClang 编译我们项目里的一个第三方库,突然报错说std::span不可用。查了一下,AppleClang 当时对 C++20 的支持还停留在标准库层面,std::span 这种标准库类型没实现完整。我们 CMake 文件里写的是target_compile_features(engine PRIVATE cxx_std_17),理论上有 C++17 就够了,但那个库的某个版本头文件里用了一个 C++20 的特性做条件编译,导致实际编译路径就跑到了不支持的标准库里。

这种问题的解决靠的不是 AI,而是对工具链发布周期的了解,以及 CMake 的 feature 检测机制。后来我在工程里加了CMAKE_CXX_STANDARD检测并检查编译器版本,在 CI 里对每个平台至少跑一次编译检查,才把这类问题彻底堵住。

6.3 构建时间失控,AI 的"加码"是雪上加霜

手搓引擎项目天然编译压力大——因为代码库整个是自己的,你没法预期编译时间。我之前提到用了 CCache,确实帮了大忙,但还有另一个隐患:AI 生成的代码特别喜欢"一次性把大段逻辑全写出来",这从代码规模上是高效的,但在编译复杂度上是灾难——模板嵌套、编译期计算、包含重型头文件,一个文件编译要几十秒,这还是中了 CCache 之后的情况。

我的处理方式是给引擎设立"编译预算"制度。每次提交代码时,我会记录新增的头文件依赖和模板复杂度,如果某个文件的编译时间超过了 5 秒,就必须把里面的模板特化拆到一个更小的单元里,或者用 pimpl 把依赖隔离掉。AI 生成的代码我基本都会做一次"编译友好化重构":拆分重型模板、把实现从头文件移到 cpp、尽量少用全特化而用普通重载。这样虽然多花了一点修改时间,但换来的是日常开发里更快的迭代速度。

6.4 "AI 能读懂我的架构"是一个危险幻觉

这是我必须提醒所有尝试让 AI 在大型项目里长期辅助开发的朋友的一点。很多工具号称"了解你的整个代码库",但它的"了解"和你的"了解"完全是两回事。它知道你的符号表、函数签名、文件依赖,但它不理解你的设计意图——为什么某个接口设计成那样,为什么某个组件不允许跨线程访问,为什么某块代码要故意写成看起来低效的样子。

最危险体现在重构上。有一次我想重构一个资源加载模块,把同步加载替换成异步加载。我把这个任务描述给 AI 并让它生成主要的改动方案,它产出了一个看起来很完整的计划,把加载函数改成了接受回调的形式,还把调用点都改掉了。我耐着性子审了一遍,发现它把两个在逻辑上属于"互斥"的资源加载请求合并到了一个回调里,这在某些竞态条件下会导致死锁。如果我没有理解这个模块的设计约束,这套"看起来完整"的重构方案合进主线,后果不堪设想。

我因此给自己的协作定制了一条规则——只有在我已经能完整描述某模块的架构约束、数据流和并发模型之后,才会允许 AI 做这个模块的批量性变更。在这之前,它最多只能做点局部辅助。

6.5 过度依赖对话式开发,失去"全局思维"

最后一个坑,是从"人机协作模式"层面讲的。长期一条一条地跟 AI 对话开发,很容易把你自己训练成一个"小任务解决器"而不是"系统构建者"。引擎开发恰恰最需要系统性思维:一个 API 改了,所有调用点、序列化、调试可视化、关卡编辑器、网络同步都可能受影响。这种"改一处牵全身"的思维方式,AI 不负责替你养成,甚至它的回答方式还会反向强化你的碎片化思维。

我自己的实践证明,最有效的应对方式是定期做"无 AI 日"。每周我强制留出半天,关掉所有 AI 辅助工具,只用手写代码和阅读源码。这半天要么用来重构最不熟悉的那块模块,要么用来阅读 Vulkan 或 SDL 的源码。做这种事的过程很慢,但它能强迫我重新建立对代码库整体的"感觉"——这种感觉和看 AI 总结出来的架构说明完全不一样。可能是因为当你亲手在代码里穿行,你会从一些非常具体的细节里感知到系统的整体气质,这些信息密度远远高于任何一篇架构文档。

7. 构建流程的优化,以及我如何让手搓引擎变得"可持续"

7.1 把构建时间当成第一等公民

引擎开发里,构建速度直接影响开发心态。如果你一次完整构建要 15 分钟,你每次改代码的反馈循环就是 15 分钟,心态很容易崩。所以我很早就把构建优化当成一个正式的性能指标来管理。

我的具体手段包括:

  • 使用 CCache 做编译缓存,命中率长期维持在 90% 以上。
  • 尽量把模块拆小,让每次改动能触发的重编译范围尽量小。
  • 头文件里尽量前向声明,只在 cpp 里包含具体定义。
  • 对 Dear ImGui 这类每帧都在变化的调试 UI,我把它单独拆成动态库,改 UI 代码不用重编引擎主体。

这套做下来,一台 8 核的开发机上,引擎冷编译大概在 40 秒左右,增量编译常态只有几秒到十几秒。这个水平对我来说已经够舒服了,毕竟我不是在写一个几百人的大项目。

7.2 里程碑式开发:每个阶段都有可玩的输出

引擎开发的另一个大坑是"永远在做地基,永远没有成品"。很多人写了很久的引擎,自己的游戏还停留在"窗口能开,看到个三角形"的阶段。为了避免这种状态,我从一开始就给自己定了一条规则:每个里程碑结束,必须有一个"可玩"的输出物。

比如我的第一个里程碑是能渲染出一个旋转的立方体,带双线性贴图采样和简单的方向光。这个版本跑通以后,我立刻做了一个很简陋的"3D 展示间"小应用——你可以用 WASD 和鼠标在场景里逛一逛。这个应用今天看来非常粗糙,但它让我第一次感受到了"引擎从代码变成体验"的完整链路。有了这个正反馈,之后写物理、写动画系统、写音频模块,都是带着"这个功能会让我的游戏更好玩"的期待去写的,而不是为了写而写。

这条经验我强烈推荐给所有正在手搓引擎或想开始手搓引擎的人——引擎开发如果没有输出物,会大概率烂尾。反过来,只要有持续的输出物,哪怕很简陋,你也会被"这周我要看到什么新效果"这种内在驱动力推着走。

7.3 用 AI 写单元测试,把宝贵时间留给架构

引擎开发的测试通常分为两类:数学库和数据结构这种纯逻辑单元,以及渲染、物理这类需要环境和状态的系统测试。前者非常适合 AI 来批量生成,后者则不太适合。我会让 AI 帮我生成前者的测试用例,特别是边界条件的枚举,这一下子省了我很多体力活。

比如我们引擎里的Math::Matrix4::LookAt函数,我用 AI 生成了十几个传统视角向量、退化输入、极端参数下的测试用例。大部分它生成的是对的,但其中有一个边界用例让我印象深刻——当 eye 和 center 重合时,up 向量与视线平行,叉积为零向量,这时应该怎么处理?它给出按文档规范的退化处理建议(返回单位矩阵并打印警告),这个建议我采纳了。而且因为是我让它写的,我对这个测试的意图非常清楚,后面维护起来也没有障碍。

7.4 手写引擎之后,我的目标其实也不需要太大

网上有人形容手搓引擎的人不是"在造轮子"而是在"造坦克",语气里总带一点不值当的味道。但从我自己的实践来看,这件事的价值完全取决于你的目标。如果你的目标是快速做出一款独立的商业游戏,那用现成引擎肯定是理性的选择;但如果你的目标是成为一个真正"懂游戏技术底层"的程序员,我认为手搓引擎的过程是无法被替代的。

对我个人来说,这个项目的意义已经超越了"做一个引擎"。它帮我建立了一套完整的、跨越多层抽象的技术直觉:从 CPU 缓存到 GPU 缓存、从编译器优化到渲染状态、从资源流送到资产管理,我都能在一个统一的框架里理解。这套直觉,是我在这个 AI 时代最有竞争力的"底牌"。因为 AI 可以帮我把引擎的很多局部做得更快更好,但只有当我自己清楚整体系统的行为边界时,才能判断 AI 的建议哪些真正有价值,哪些只是貌似合理的表面功夫。

8. 引擎现状,以及给同样想手搓引擎的人一份避坑清单

8.1 我的引擎现在跑了哪些玩法

写到这里,顺便交代一下这个项目目前的状态。两年下来,这套 C++17 引擎已经具备了一个小型 3D 游戏所需的大部分基础能力:渲染了多盏点光和平行光、物理引擎用的是自研的一套简化刚体模拟(球形和盒形碰撞)、资源系统支持 glTF 模型和纹理加载、地形是分块 LOD 的(这部分写得还比较粗糙)、音频用 SDL2 的 mixer 模块做了个简单的音乐和音效播放。

目前我在用它开发一个小的低多边形风格解谜游戏。说实话,用自研引擎做游戏,你会在开发过程中发现很多奇怪的工作量:一边要处理玩家的操作手感,一边要处理引擎自身的 bug。但这个东西有一个好处——你永远不用担心"引擎不支持我要做的效果"。因为如果引擎不支持,你就去写引擎。这种感觉非常自由,自由到你会觉得一切皆有可能。

8.2 给新手的避坑清单(全是拿时间换来的)

如果你也想开始或者已经开始手搓引擎,我给你一份最实用的避坑清单,这些基本上都是我在这个项目里付过学费换来的教训。

第一,不要一开始就追求大而全的 ECS 架构。先用简单的组件结构把游戏逻辑跑起来,等确实遇到性能问题了再引入更复杂的 ECS。市面上流行的 ECS 库都假设你理解很多底层设计权衡,一开始就上容易把你绕晕。

第二,所有"资源生命周期"相关的代码,一定要从一开始就设计好引用计数。你早晚会需要异步加载,如果没有引用计数,异步加载和销毁之间的竞态条件会让你的调试变成一个无底洞。

第三,图形 API 的选择上,如果目标是 3D 引擎,趁早学 Vulkan 或 DirectX 12 这类现代 API。OpenGL 虽然容易入门,但它的状态模型和现代硬件架构差异太大,很多概念学到后面反而会阻碍你理解现代 GPU 工作方式。

第四,多线程渲染一定要早做。我知道单线程渲染先跑通 demo 很诱人,但后面再改造多线程的代价比一开始就按多线程设计大好几倍。我的建议是哪怕你暂时只用一个渲染线程,也要从架构层面把"逻辑更新"和"渲染生成命令"分离,为将来多线程留下空间。

第五,编译时间管理和构建系统优化,永远值得提早投入。这件事最容易被新人忽视,因为在项目规模不大的时候感觉不到,但等到规模上来再改构建系统,成本就非常高了。

第六,调试工具链最好在项目早期就搭好:一个帧调试器(RenderDoc 免费且强大)、一个 CPU 性能分析器(macOS 用 Instruments、Windows 用 Visual Studio 的 CPU Profiler)以及配套的日志系统。这三件套能覆盖掉 95% 的引擎开发调试场景。不要省这一步,不要觉得"等有 bug 了再配"。

8.3 引擎之外,我对手写系统的最终理解

如果非要浓缩成一句经验的话,我会说:游戏引擎是程序员的"实体作品",它的存在感比任何抽象的架构图都强。你写一个 Web 服务,它跑在一个你看不见的服务器里;你写一个移动应用,它在用户的设备里休眠或亮起。而你写一个游戏引擎,它会产生一种非常具体的、可以触摸的技术体验——玩家控制角色时画面的变化、物理碰撞时物体的响应、资源加载时屏幕的过渡。这种"我的代码直接在三维世界中产生即时反馈"的感觉,是其他任何软件开发方向都给不了你的。

所以我虽然理解并拥抱生成式 AI 带来的效率革命,但我仍然偏执地认为,在游戏引擎这种对底层掌控力要求极高的领域,亲手一行一行把代码写出来的过程,会持续产生 AI 无法替代的价值。它不是效率的浪费,而是一种对技术本体的深度理解,是对程序员这个身份最脚踏实地的回敬。

最后分享一个很细节的经验:手搓引擎如果遇到想放弃的时刻,不用硬撑,放下代码去做点完全不相关的事。我很多时候最关键的架构决策是在散步或洗澡时想通的,而不是在盯着 IDE 的时候。因为引擎这种系统,它的复杂度不在于单点,而在于无数个组件之间的相互作用。你的大脑需要一个"离线整合"的过程,把那些你在代码里零散接触到的细节串成一个整体。这个过程,AI 目前也给不了你——它会替你总结,但那种总结是基于它的逻辑,不是基于你的直觉,而后者才是真正属于你的资产。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询