ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于C++的游戏引擎开发实战:从语言选型到架构与性能优化

基于C++的游戏引擎开发实战:从语言选型到架构与性能优化 引擎开发这件事圈内一直有个共识想做引擎的人多能坚持下来的少。C作为游戏引擎事实上的主力语言从Unreal到CryEngine再到各家引擎几乎绕不开它。很多人一开始是奔着写个引擎跑起来很酷去的结果撞上的却是构建系统、内存管理、平台抽象这些硬骨头最后项目烂尾。这篇内容我想从实际开发的角度把基于C做游戏引擎这条路掰开讲清楚——包括语言选型背后的理由、引擎整体架构怎么搭、核心模块怎么落地、性能优化和调试怎么做以及我自己踩过的那些坑希望能帮正在入门或已经动手的开发者省掉几个月的弯路。适合看这篇内容的人范围很宽写过一些C想在游戏领域深入的程序员刚学完C语法、被下一步做什么卡住的新手或者已经在用现成引擎做游戏、打算自己动手写点底层的人都能找到对应的参考价值。1. 为什么游戏引擎绕不开C——语言选型这件事不是在装古董1.1 性能边界和内存控制能力是刚需聊游戏引擎绕不开性能。3A大作一帧里面要做的事非常多渲染要提交无数个绘制调用物理要跑碰撞检测和刚体求解AI要做行为树的更新和寻路动画系统要采样骨骼数据并混合更别提世界流送和资源压缩解压这种后台任务。一帧只有16.6毫秒60 FPS或者33.3毫秒30 FPS分给每个系统的时间往往只有几毫秒容不得任何语言层面的解释执行开销。C在这里的核心优势在于它能做到零抽象级别的性能你在C里写一个循环遍历数组直接操作连续内存通常可以编译成非常接近机器指令的代码。相比之下带GC的语言可能在某个时间点突然停下来做垃圾回收这一停顿在帧率敏感的场景里就是一次肉眼可见的卡顿。C让你自己管理内存虽然责任更大但换来的确定性是引擎开发极其需要的。还有内存布局的控制。现代CPU的性能瓶颈很多时候不在计算本身而在内存访问——cache miss的代价远超一次浮点运算。C允许你用struct和数组组织数据把同一帧内要一起访问的对象排布在连续内存里这种面向数据设计Data-Oriented Design的做法对缓存友好度至关重要。Java里对象的分布依赖JVM堆的管理策略C#虽然可以用struct和Span做些控制但灵活性仍然不如C来得彻底。1.2 生态和历史的双重壁垒确实有人问我Rust都这么火了为什么引擎领域还是C的天下答案其实很现实引擎开发不只是写逻辑还要让别的代码跑在你的地基上。GPU驱动接口、操作系统的窗口API、几万个现成的C/C库这些底层生态全都在C/C那一侧。物理引擎PhysX、渲染库DirectX和Vulkan、音频库FMOD官方SDK基本第一语言都是C。Rust当然可以通过FFI调这些库但多了一层bindings之后内存所有权模型经常产生摩擦写起来远不如直接用C顺手。用C还有个隐性优势——行业里能看懂引擎源码的人多。招一个懂C的工程师容易找一个愿意啃Rust底层代码的就有难度了。游戏公司里十多年的老引擎项目代码都是C03或者C11风格写出来的新同事进来了看的还是这些代码。所以学引擎开发掌握C是一个投出产出比很高的投资不只是技术能力的问题更是能否读得懂、改得动现有引擎的问题。1.3 C版本演进带来的实际红利很多人对C的印象还停在裸指针满天飞、头文件地狱的年代但现代CC11之后已经把它拉到了一个相当舒服的位置上。我现在写引擎核心写的是C17部分模块用C20特性。std::unique_ptr和std::shared_ptr让所有权表达语义清晰避免裸指针散落。std::variant和std::visit在处理多类型实体比如不同类型的组件时比继承更灵活。constexpr支持在编译期算好一些表格数据运行时零开销。移动语义和右值引用对资源密集型对象比如顶点数据、纹理的传递非常关键少了很多深度拷贝。不过说实话引擎里有些地方我还是故意不用智能指针——比如实体组件ECS里的对象存储用std::vector连续排布加index句柄的效率远高于一堆shared_ptr乱指。这也侧面印证了现代的C不是让你全盘接受某套范式而是让你工具箱里的工具变多了怎么用要看场景。2. 从零搭引擎骨架——模块划分是一场必修的权衡课2.1 应用层、核心层和平台抽象层怎么切C引擎最怕的就是一开始没想清楚分层后面想改架构发现已经改不动了。一个合理的分层通常是这样平台抽象层Platform Layer封装操作系统相关的窗口创建、事件循环、文件系统路径、高精度计时器。这层可以是Win32、SDL或者GLFW的薄封装做到上层代码里不出现任何平台相关的#ifdef WIN32。核心层Core Layer提供日志系统、内存分配器malloc封装、线性分配器、池分配器、数学库向量、矩阵、四元数、时间管理、事件分发机制。这层是整个引擎的心脏跨平台、无状态或者少状态。功能层Feature Layer渲染、音频、物理、动画、粒子、地形等一切跑在核心之上的系统模块。应用层Application/Gameplay Layer真正出游戏逻辑的地方比如场景游戏对象、组件绑定、关卡数据加载。引擎本身提供一个运行框架游戏层往里塞内容。我见过的最大的新手错误是第一步就把功能层和核心层混在一起写导致渲染代码里直接出现CreateWindowExA这种WinAPI调用后面想支持跨平台就非常痛苦了。2.2 构建系统和依赖管理怎么选C没有官方包管理器这确实是痛点。但引擎这种项目你用CMake作为构建系统基本是现在的主流。CMake生成的工程在Windows上是Visual Studio在Linux上是Makefile或Ninja在macOS上是Xcode工程一套CMakeLists写得好的话三个平台都能构建。库的管理方面头文件型库直接拉源码编译或者用FetchContent从GitHub拉指定版本这是很多现代C项目走的路。像glm、stb这些单头文件库直接#include进来就行不需要单独构建。像Bullet Physics、OpenAL这种带源码的库当时我直接用FetchContent编译虽然第一次构建会慢一点但后续增量编译还好。提示Git LFS要尽早配置好引擎项目会有大量二进制资源模型、纹理后期会有等到文件大了再迁移一堆同事和CI都要改非常麻烦。2.3 最小可运行引擎需要哪些模块如果目标不是一开始就做一个全功能引擎而是先把框架跑通最小闭环需要这些主循环初始化系统、加载初始场景、每帧调用Update和Render。窗口加上下文至少能打开一个有清屏效果的窗口。时间管理跨帧记录deltaTime控制游戏速度。日志系统一个简单的带时间戳print加文件输出能力就够起步。资源加载最小情况下一个能加载文本文件并解析出数据的模块比如加载一个简单的自定义格式关卡文件。渲染后端可以直接用OpenGL或Vulkan初始化一个渲染管线提交一帧。起步阶段不必搞多后端一个能跑的足够。把这个跑通等于打通了操作系统的门后面再加东西就都是往这个框架上挂的事情了。我个人的经验是先用最直接的方式跑通再想模块化的抽象空想架构很容易过度设计。3. 动手实现核心模块——窗口、渲染循环和资源系统落地经验3.1 窗口和输入系统如何与平台解耦这里我直接给一个实用性建议尽量选成熟的库不要一开始就手撸Win32Linux双平台窗口层。我自己前期手写过后面切到GLFW之后发现省下了一大半开发时间。思路是做一个Window抽象类提供create、pollEvents、getWidth/getHeight、setTitle这些接口实际实现类里包一个GLFW窗口。这样你的输入系统可以只关心哪个按键被按下而不关心是哪个平台发的消息。// window.h class Window { public: virtual bool init(int width, int height, const char* title) 0; virtual void pollEvents() 0; virtual bool shouldClose() const 0; virtual void swapBuffers() 0; virtual int getWidth() const 0; virtual int getHeight() const 0; virtual ~Window() default; }; // glfw_window_impl.h class GLFWWindow : public Window { public: bool init(int width, int height, const char* title) override; void pollEvents() override; // ... private: GLFWwindow* m_handle nullptr; glm::ivec2 m_size{800, 600}; };输入这块定义了一套统一的KeyCode和MouseButton枚举GLFW实现内部做映射。注意不要让输入事件的原始平台代码飘到引擎上层后面接玩家手柄、触屏甚至AI模拟输入的扩展会轻松很多。3.2 渲染循环与帧率控制的真相主循环的写法看似很简单while (!window.shouldClose()) { float dt timer.tick(); processInput(); update(dt); render(); }但帧率控制里面藏着不少门道。核心问题就是那16.6毫秒预算初始实现最容易掉进每帧都做完整逻辑的坑里。更合理的思路是固定时间步长可变插值// 固定时间步长示例 const float fixedDelta 1.0f / 60.0f; float accumulatedTime 0.0f; while (!window.shouldClose()) { float frameDelta timer.tick(); accumulatedTime frameDelta; while (accumulatedTime fixedDelta) { update(fixedDelta); accumulatedTime - fixedDelta; } render(frameDelta); // 可传剩余时间做插值 }物理和逻辑在固定步长下会更稳定不容易出现帧率波动导致物理穿透或跳跃。渲染则用实际时间驱动保证不同帧率下画面平滑。3.3 资源管理引用计数、资源句柄和热重载资源系统是引擎里最容易被低估的部分。起步阶段可以直接用std::string做资源ID配一个std::unordered_mapstring, Resource*来缓存。但项目大了之后路径字符串做key很浪费内存和时间而且资源依赖关系也复杂。我最后推荐的演进路径是起步字符串ID 共享指针缓存简单直接能在极短的时间内跑通资源加载。中期用整数ID代替字符串hash之后存整数加载的时候记录资源的依赖并做引用计数资源不再用时自动释放。后期实现资源异步加载和热重载回调。比如用户修改了着色器文件编辑器里按个快捷方式资源系统发现文件mtime变化就重新加载并通知使用方刷新。关于资源句柄值得一提句柄最好是16位或32位的handle而不是裸指针。因为一旦涉及热重载原来的指针可能失效句柄则可以通过一个indirection映射到新资源地址上。这种做法虽然多了一次查表但在资源管理这个环节稳定性比性能更重要。3.4 一帧渲染的实际提交代码骨架用简单的OpenGL做例子一个引擎的最小渲染提交看起来像这样void RenderSystem::render() { glClearColor(m_clearColor.r, m_clearColor.g, m_clearColor.b, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); for (auto mesh : m_opaqueMeshes) { mesh-bind(); glDrawElements(GL_TRIANGLES, mesh-indexCount(), GL_UNSIGNED_INT, nullptr); } }稍微再往前走一步就要引入材质和Shader绑定流程。Shading的意思是把渲染状态分组顶点输入布局、着色器程序、纹理、渲染状态深度测试、混合、面剔除把这些组成一个渲染命令然后提交给后台。后续要做Draw Call合并或者多线程渲染提交时这套数据结构就是基础。这一步不能跳过因为后面每一个渲染优化几乎都在操作它。4. 调试、性能分析和优化——这些坑必须提前心里有数4.1 构建配置Debug、Release和Profile的差别不只是名字Debug构建通常关优化、带调试信息方便断点和单步。Release则打开全部优化通常也会去调试符号。但引擎开发真正重要的往往是一个中间的Profile/RelWithDebInfo配置——它开优化但保留调试信息这样在高峰负载下能crash也能看到优化后的代码崩溃到哪里。我在早期就吃过亏只在Debug下测性能结果功能看起来流畅无压力切到Release后逻辑全跑飞了。原因就是因为Debug编译期不做大量优化有些未定义行为或者优化假设只在Release下暴露出来。所以我的习惯是日常能跑的模式就是Release模式Debug模式只在跳逻辑时用。4.2 性能分析从哪里开始——先量化再优化性能分析的第一步不是感觉哪里慢而是用profiler告诉你时间都在哪里。自己手动用std::chrono打点可以但一个大工程里更推荐直接用现成的CPU Profiler工具像Windows上的Visual Studio Profiler、跨平台的Tracy和Optick都很成熟。Tracy的使用体验值得一提它是一个实时采样的profiler能把每一帧的调用栈和耗时可视化对于游戏引擎的帧时间分析极其有用。它的开销很小实测下来大约5%左右平时开着profiling构建跑问题不大。然后就是最经典的优化优先级先找最大开销再动手改改完重新分析看时间变化。这个过程循环往复而不是凭直觉这里应该优化。一个常见的量级排序是开销来源常见占比说明栅格化及着色30%-50%像素填充率、Overdraw顶点处理和提交10%-20%LOD、网格简化物理模拟10%-15%刚体数量、碰撞复杂度CPU端逻辑15%-30%遍历、组件更新加载和反序列化5%-15%场景进入卡顿4.3 拷贝、分配、虚函数——C引擎的三大隐形杀手这三个词基本上概括了引擎性能掉坑的主要原因。不必要拷贝。比如按值传一个大物体或者把顶点vector反复从函数返回。移动语义在这时候很重要// 反例每次调用都拷贝 std::vectorVertex getVertices() { std::vectorVertex v; // 填充... return v; // NRVO可能消除拷贝但不是所有情况都保证 } // 好例接收方可以move std::vectorVertex v std::move(getVertices());堆分配。游戏开发里每帧几百上千次堆分配是非常糟糕的。每帧main构造一堆临时std::string或者每帧new一个vector去装辅助数据这种模式会造成内存碎片和cache不连续性。解决思路是引入内存池或线性分配器把一帧内用到的临时内存都放到一个预算内的环形buffer里。虚函数。不是说虚函数本身慢而是频繁的间接调用indirect branch会打乱CPU分支预测。在渲染或者物理这种每帧上百万次调用的路径上里面出现大量虚函数性能会肉眼可见地下降。我遇到的典型是用一个抽象System基类跑所有子系统更新每个组件都有虚update——这种模式碰到大场景就会被profiler炸开花。更好的做法是用函数指针数组、编译期分派或者直接写成switch。4.4 热重载与迭代效率——现代引擎开发者的命脉引擎开发迭代速度很关键。你改一行物理代码如果整个重新编译要两分钟开发节奏就很伤。热重载的方式多种多样改游戏逻辑层面的代码尽量用脚本层Lua、Python或内嵌的DSL做热更。引擎C代码做不到全部热重载但可以切分DLL实现部分热更Windows下的DLL重载Linux下的.so重载。着重做资源热重载让美术改纹理不需要重启引擎。我当时做的折中方案是引擎核心编译仍然要等但策划和美术用的逻辑层用Lua脚本承载锁定bug时主要改脚本就能看到效果省掉了编译反馈周期。C侧的热重载做了一层DLL边界主渲染和核心逻辑在EXE里玩法逻辑库放DLLDLL重新编译后按F5触发重载。这里面比较大的坑是状态管理DLL里的静态变量和全局对象在重载时要重新构造或者清掉否则容易带上一代的状态。所以逻辑层尽量无全局状态数据存在场景或实体上。5. 引擎开发中最容易翻车的几件事——我踩过的坑和解决办法5.1 架构设计过头的问题一开始就追求变成UE这是新手最常见的泥潭。看Unreal和Unity的架构文档看得热血沸腾上来就想搞ECS加DOD加多线程渲染加资产包结果写了三个月连个能显示三角片面的窗口都没有。我个人的转向是从必须按某套架构变成先做功能再抽出合适抽象。也就是说先实现一个能渲染出三角形和简单Mesh的裸CPU版本把数据流理清楚再回头看哪些部分需要接口抽象。起步阶段代码看起来会有点乱但正因为乱你才确切知道哪些抽象是真的需要的——比如你真正需要抽象的不是渲染设备接口而是Draw Call提交队列因为没有它你会被状态管理逼疯。5.2 多线程与帧同步问题实战中才学会的教训引擎到了中后期单线程跑满CPU核心是不行的。于是自然会加多线程渲染线程、逻辑线程、资源加载线程并行跑。这一阶段几乎每个人都会在帧同步上翻车。最常见的bug是逻辑线程在给一个Mesh做顶点数据更新时渲染线程正在读同一份顶点数据提交绘制。解决办法不多渲染线程每帧开始时从逻辑线程拿到一份干净的快照或者逻辑线程修改数据后发一个dirty标记渲染线程在下一帧统一处理。再或者整个场景分成两套数据运行。加锁经常不是好选择因为你可能在锁的临界区里卡一帧都overrun了。另外一个很隐蔽的坑是std::atomic用错语义。很多人以为只要变量是atomic就安全了但其实还要考虑memory ordering。默认的seq_cst性能不错但可读性差而且如果你只是为了防止两个线程读同一个int导致数据竞争可能更合适的做法是让数据本身只属于一个线程。5.3 第三方库的集成泥潭静态库、动态库和符号冲突C引擎几乎不可能完全自己写所有东西。总是要带一些第三方库这个库用OpenSSL那个库用一套zlib头文件里#define冲突、符号重名是很常见的。我在实践中的几个处理原则尽量让第三方库只在自己的模块内部可见通过模块接口对外暴露自己的类型不要让库的头文件泄漏到全局include里因为一旦泄漏就变成无穷无尽的宏冲突现场。静态库体积虽然大一点但比动态库少了DLL hell不过要注意静态库在多个模块之间链接时的OICOne Definition Rule问题——同一个符号在多个编译单元被定义。版本锁定要非常严格最好用FetchContent里指定commit或tag绝对不让它拉最新版。因为第三方库升级往往顺手改了API你的代码和它一起滚动升级就是灾难。上面几个原则我是花了大概快一年才总结出来的前期各种试错最后发现也就是这三板斧隐藏、锁定、隔离。5.4 跨平台问题的提前规划不要等Windows跑通了才想别的引擎最终大概率不止跑一个平台。如果一开始只用Windows的API写了文件系统后面迁到Linux或者macOS你会发现很多代码都不是简单地换个API而是整个抽象层次出了问题。文件路径分隔符、大小写敏感性、权限模型全都不同而这些细节如果散落在所有模块里改起来等于重写。建议从一开始就做一个FileSystem模块屏蔽平台差异。文件的枚举、读取字节、路径标准化都在这一层做上层永远只处理统一格式的路径。后面接安卓的asset系统时只改这个模块即可。5.5 真正的跨平台困境是工具链不只是代码跨平台最难的部分其实不只是代码还有工具链差异。Windows用MSVCLinux用GCCmacOS用Apple Clang这三家对C标准的支持程度都不一样比如std::filesystem在三家里都支持但某些边角API比如file_time_type在Windows上精度不同。还有编译器特有的警告级别和优化行为差异。推荐的策略是尽早接入CI在至少两个编译器上编译项目。一个编译器编译通过的代码在另一个编译器上很可能冒出警告或错误。严格启用W4MSVC或-Wall -WextraGCC/Clang并开启-Werror把警告当作错误。前期可能折腾但之后代码质量会有明显改善。注意AOT vs JIT的差异影响不大但每种编译器各自生成的代码性能可能差异很大所以性能测试要在所有目标平台上跑。6. 从能跑到真能用——引擎稳定性的几个关键决策引擎跑到能显示出一个三角形和几个模型后大概会进入能跑阶段但离真能用还有不少距离。我理解的真能用至少要满足长时间运行不崩溃不泄漏内存。崩溃时日志里有足够的信息定位到发生问题的系统。资源可以安全地加载、卸载、重载不会出现悬挂引用。编辑器或调试工具可以观察场景里实体的实时状态。有单元测试覆盖核心数学、序列化和渲染后端的基础行为。这五条里面我早期最忽视的是第五条。总觉得反正都是自己写的数学库跑起来没毛病就行。实际上矩阵运算的边界案例比如行列式为零的矩阵求逆、零长度向量归一化很容易出差错而这些错误在正常的游戏场景下很难暴露会在很极端的玩家操作情况下炸掉。加上单测后这些问题基本都能在改代码的当下发现省了后面大量定位时间。稳定性和能用的价值回归到一个点上游戏引擎不是写出来给人看的而是要在别人手里一直被折磨的。你越早建立测试和崩溃定位机制后面就越舒服。这一块我踩过的坑就是前期不上单测后来场景越来越多每次改渲染代码都要手动把场景一个个跑一遍浪费的时间够写好几组测试了。所有模块里我最建议给数学库向量、矩阵、四元数和序列化系统加单测因为这两个模块最基础、最容易出现出错累积而且改起来最麻烦。7. 一些关于接下来怎么走的个人感想如果要说整个引擎开发下来我最感谢的时刻其实不是引擎跑出了第一个场景的时候。反而是某次深夜一个资源的加载顺序bug追了两天最终发现是文件系统在大小写不敏感的平台上的行为差异导致的。那一刻我突然明白了引擎开发大部分时间其实不是在创造而是在与复杂性博弈。你每做一层抽象都在降低未来的熵增但抽象本身又可能引入新的复杂性。这个平衡感是需要长时间才能练出来的。另一个体会是引擎项目如果要进团队协作设计文档比代码更快老化。团队里每个人对什么应该是引擎、什么应该是游戏层的理解可能不一样如果没有一份稳定的模块边界约定就会出现有人把渲染代码写进Asset加载系统、有人在UI系统里放物理碰撞之类的混乱。我建议在引擎进入第二个大版本之前把模块边界和所有权规则写成文档比写代码本身重要。如果你问我现在还会推荐别人从C引擎开发起步吗我会说会而且值得。这条路会让你对性能和架构的理解跟只在应用层做开发的人明显拉开差距。但要从写着玩变成能交付的程度你需要足够长的时间投入——我说的至少是一年以上的持续迭代而不是几个月的突击。中间会反复推翻自己重写已经存在的模块甚至怀疑自己在做重复劳动。但只要扛过去你获得的不只是引擎代码而是一套在面对任何复杂底层系统时都能抽丝剥茧的能力。最后分享一个我仍在用的习惯每个模块做到一定规模都会花一个晚上把它的公共接口重新读一遍问自己如果我现在重新实现这个接口会设计成什么样子。别着急符合什么标准先把你和你的引擎之间的默契建立起来这点我觉得是核心。毕竟没有哪两套引擎是真正相同的理解了这一点开发引擎这件事才真正开始变成一件属于你自己的事。
返回列表