C++游戏引擎实战:从ECS架构到物理碰撞系统实现

📅 发布时间:2026/8/6 0:21:38
C++游戏引擎实战:从ECS架构到物理碰撞系统实现
1. 项目概述为什么选择C构建游戏引擎如果你问一个干了十年游戏开发的老兵用什么语言做引擎最“硬核”十有八九会告诉你C。这可不是什么情怀而是实打实的性能、控制力和生态决定的。我最近刚带着团队从零撸了一个轻量级的2D游戏引擎核心目标就是验证一套清晰、高效的架构并实现一套可靠的物理碰撞系统。整个过程下来感触最深的就是用C写引擎就像亲手打造一把瑞士军刀每一个零件你都知道怎么用为什么这么用出了问题也能立刻找到症结。这个项目标题“C游戏开发实战从引擎架构到物理碰撞”精准地概括了从宏观设计到微观实现的两个核心层次。引擎架构决定了你的游戏世界如何被组织、更新和渲染是骨骼和神经系统而物理碰撞则是这个世界互动规则的具体体现是肌肉和触觉。两者结合才能创造出一个既有秩序又有生动交互的虚拟世界。市面上很多教程要么只讲高大上的架构图要么只教你怎么调用某个物理库的函数很少把“为什么这么设计架构”和“碰撞检测具体怎么算出来的”串起来讲透。我这篇分享就想填补这个缺口结合我们实际趟过的坑把从顶层模块划分到底层向量运算、碰撞响应的完整链条给你捋清楚。适合谁来读呢首先是有一定C基础对游戏开发有浓厚兴趣不满足于只使用Unity、Unreal等成熟引擎想深入了解背后机制的朋友。其次是那些在中小型团队面临需要自研或深度定制引擎场景的开发者。当然如果你正在学习数据结构、设计模式想看看它们在一个真实、复杂的系统中如何应用这里也有很多活生生的例子。2. 引擎核心架构设计与模块解耦引擎不是一个大泥球而应该像一台精密的钟表各个齿轮模块各司其职通过清晰的接口咬合在一起。我们的设计目标是高内聚、低耦合、易扩展。经过几轮迭代最终稳定下来的核心架构主要包含以下几个层次。2.1 应用层与平台抽象层最上层是应用层也就是你的具体游戏逻辑。它不应该关心窗口是用GLFW还是SDL创建的也不应该直接调用OpenGL的API。它的眼里只有“精灵”、“场景”、“事件”这些游戏概念。这就引出了其下的平台抽象层。这一层的核心职责是“屏蔽差异”。我们抽象出了几个关键接口窗口管理创建、销毁窗口处理尺寸变化。输入系统将键盘、鼠标、手柄的原生事件统一封装成引擎内部的事件对象。图形上下文初始化OpenGL或Vulkan等图形API管理渲染状态。文件系统提供统一的资源图片、声音、配置文件加载接口无论资源在磁盘、内存还是网络包中。这么做的最大好处是可移植性。当我们需要从Windows移植到macOS甚至某个嵌入式平台时只需要实现一套新的平台抽象层实现上层的游戏逻辑代码几乎无需改动。我们在项目中使用了GLFW处理窗口和输入用Glad加载OpenGL函数指针它们就被封装在这一层。2.2 核心系统层ECS架构的取舍与实现这是引擎的“大脑”。关于如何组织游戏对象业界主要有两种范式传统的面向对象继承层次和现在越来越流行的实体-组件-系统架构。我们最终选择了ECS但不是那种极致的、缓存友好的“纯ECS”而是一种更务实、更易于理解的“轻量级ECS”。为什么选择ECS传统的继承树比如GameObject-RenderableObject-Sprite在项目后期很容易变得僵化。一个既需要渲染、又需要物理模拟、还需要播放音效的对象它的类会继承自多个基类导致“钻石继承”等复杂问题且难以动态增减能力。我们的ECS实现如下实体就是一个唯一的ID例如一个整数它本身没有任何数据或行为仅仅是一个标识符。组件是纯粹的数据结构。例如TransformComponent位置、旋转、缩放、SpriteComponent纹理、颜色、UV坐标、RigidbodyComponent质量、速度、碰撞形状。系统是纯粹的逻辑处理器。它遍历所有拥有特定组件组合的实体并对它们执行操作。例如RenderSystem遍历所有拥有TransformComponent和SpriteComponent的实体将它们绘制到屏幕上PhysicsSystem则处理拥有TransformComponent和RigidbodyComponent的实体。我们实现了一个简单的Registry注册表来管理实体和组件。组件以std::vector或std::unordered_map存储虽然不是完全连续的但对于我们目标规模的2D游戏来说性能完全足够且代码清晰易懂。实操心得ECS的“度”完全照搬《守望先锋》那种为了极致性能的ECS对于大多数中小项目是过度设计。我们的“轻量ECS”在清晰度和灵活性上取得了很好的平衡。关键在于将“数据”组件和“逻辑”系统彻底分离这个思想比具体的实现方式更重要。2.3 资源管理与渲染管线资源管理看似简单实则坑很多。核心原则是避免重复加载和统一生命周期管理。我们实现了一个AssetManager单例内部使用std::unordered_mapstd::string, std::shared_ptrAsset来缓存所有加载过的纹理、着色器、音效等。通过std::shared_ptr进行引用计数当没有任何游戏对象引用一个资源时它会在合适的时机被释放。渲染管线是引擎的“视觉输出流水线”。我们的2D渲染相对简单但依然遵循标准流程清屏清除颜色缓冲和深度缓冲。设置全局状态如混合模式Blending、深度测试对于2.5D游戏。批次渲染这是2D渲染性能的关键。我们将所有使用同一张纹理或图集的精灵的渲染数据顶点、矩阵收集起来通过一次Draw Call提交给GPU。这需要自己维护一个动态顶点缓冲区VBO和索引缓冲区IBO。后处理可选步骤如全屏模糊、颜色校正等通过渲染到帧缓冲区Framebuffer再绘制到屏幕来实现。我们为精灵渲染编写了简单的着色器Shader顶点着色器主要负责将本地顶点坐标通过模型矩阵来自TransformComponent变换到世界空间再通过投影矩阵变换到裁剪空间片段着色器主要负责纹理采样和颜色混合。3. 物理引擎核心碰撞检测的数学与实现物理引擎是让游戏世界“活”起来的关键。我们决定不集成庞大的Box2D或Bullet而是自己实现一套基础的2D碰撞检测与响应系统目的是为了彻底理解其原理。这套系统主要包含碰撞检测和碰撞响应两部分。3.1 基础几何与碰撞体表示一切始于数学。我们定义了Vec2二维向量类重载了加减乘除、点积、叉积、归一化等基本运算。这是所有物理计算的基础。碰撞体我们用组件ColliderComponent来表示并设计了不同的形状圆形碰撞体存储圆心位置和半径。计算最简单。轴对齐包围盒存储左上角和右下角坐标。对于不旋转的物体效率极高。定向包围盒存储中心点、半宽高和旋转角度。更精确计算更复杂。凸多边形存储顶点列表。最通用也最复杂。在项目初期我们主要使用AABB和圆形后期为了支持旋转的物体引入了OBB。3.2 分离轴定理多边形碰撞检测的万能钥匙对于AABB和圆形碰撞检测有直接的公式。但对于OBB和多边形我们使用了经典的分离轴定理。SAT的原理很直观如果能找到一条直线轴使得两个多边形的投影在此轴上完全不重叠那么这两个多边形就一定没有碰撞。这条直线就是“分离轴”。对于凸多边形我们只需要检查每个多边形的每条边的法线方向作为潜在的分离轴即可。实现步骤获取两个多边形A和B的所有边。对每条边计算其法线向量垂直于边。将多边形A和B的所有顶点分别投影到这条法线轴上得到两个投影区间[minA, maxA]和[minB, maxB]。检查这两个区间是否重叠。如果不重叠则发现分离轴立即返回“无碰撞”。如果检查了所有潜在的分离轴区间都有重叠则判定为碰撞。在碰撞发生时SAT还能顺便计算出最小穿透向量即为了让两个物体分开需要施加的最小位移方向和深度。这个向量是后续碰撞响应的关键输入。注意事项浮点数精度在比较投影区间是否重叠时直接使用或可能会因为浮点数精度问题导致误判。我们引入了一个很小的容差值epsilon如1e-7判断条件改为if (maxA minB - epsilon || maxB minA - epsilon)提高了稳定性。3.3 碰撞响应基于冲量的分辨率检测到碰撞后下一步是让物体做出合理的反应比如弹开、滑动或停止。我们采用了基于冲量的动力学响应这比简单地移动位置更符合物理规律能很好地处理多个物体连续碰撞的情况。核心公式是冲量公式J -(1 e) * (V_rel · N) / (1/mA 1/mB)其中J是冲量的大小标量。e是恢复系数弹性0为完全非弹性1为完全弹性。V_rel是两物体在碰撞点处的相对速度。N是碰撞法线从A指向B的单位向量。mA,mB是两物体的质量。得到冲量J后我们可以计算出冲量向量impulse J * N。然后分别更新两个物体的线速度velocityA - impulse / mA velocityB impulse / mB // 注意方向B受到的是反向冲量对于有旋转的刚体还需要考虑角速度的变化计算会涉及转动惯量和碰撞点到质心的向量这里为了简化先不展开。这种方法的优点是能量和动量守恒处理得比较好能模拟出真实的碰撞反弹效果。我们在此基础上还加入了简单的静摩擦和动摩擦模型让物体在斜坡上能停住或滑下。4. 架构与物理的整合PhysicsSystem的工作流设计好了架构实现了碰撞算法如何将它们优雅地结合起来这就是PhysicsSystem的职责。它在每一帧的游戏循环中在InputSystem之后、RenderSystem之前被调用。它的工作流程是一个典型的“更新-检测-响应”循环积分阶段遍历所有拥有RigidbodyComponent和TransformComponent的实体。根据受到的力重力、推力等和上一帧的速度使用欧拉积分或更精确的Verlet积分计算出新的速度和位置并更新TransformComponent。// 简化版欧拉积分 void PhysicsSystem::integrate(float dt) { for (auto entity : entities_with_rigidbody) { auto rb entity.getRigidbodyComponent(); auto transform entity.getTransformComponent(); // F m * a - a F / m glm::vec2 acceleration rb.force * rb.invMass; rb.velocity acceleration * dt; transform.position rb.velocity * dt; rb.force glm::vec2(0, 0); // 清空力 } }广义检测阶段这不是简单的两两检测O(n²)复杂度太可怕。我们使用了空间分割技术来优化。对于2D场景我们采用了均匀网格。将世界空间划分为固定大小的单元格每个物体根据其AABB被放入一个或多个单元格中。这样碰撞检测只需要检查同一单元格或相邻单元格内的物体对复杂度大幅降低。窄相位检测与响应阶段对广义检测筛选出的潜在碰撞对进行精确的SAT检测或其他形状检测。如果发生碰撞则计算碰撞法线和穿透深度并调用基于冲量的响应逻辑修正物体的速度和位置有时需要做一个小的位置修正来避免物体嵌入。约束求解可选处理像关节、绳子之类的约束关系。我们项目初期没有实现复杂的约束但预留了接口。将物理系统模块化后游戏逻辑应用层只需要给物体添加RigidbodyComponent和ColliderComponent并设置质量、弹性等参数剩下的全部由PhysicsSystem自动完成。这种设计使得游戏玩法逻辑非常干净。5. 性能优化与调试技巧实录自己动手实现物理引擎性能是绕不开的坎。以下是我们在项目中遇到并解决的一些典型问题。5.1 空间分割的粒度选择使用均匀网格时网格单元格的大小需要仔细权衡。如果格子太大每个格子里物体太多优化效果不明显如果格子太小一个物体会占据太多格子插入和查询的成本增加。经验法则网格大小最好与场景中典型物体的平均尺寸相当。我们通过统计所有碰撞体的平均宽度和高度来动态设置初始网格大小并提供了一个运行时调整的参数。动态网格 vs 静态网格我们的世界是固定的所以使用了静态网格。如果世界很大或者物体移动范围广可以考虑四叉树或动态网格。5.2 帧率与物理步长的解耦游戏渲染帧率FPS可能是波动的但物理模拟需要一个稳定的时间步长如固定的60Hz否则会出现“子弹时间”或“快进”效应导致物理不稳定。 我们采用了固定时间步长与插值渲染的策略物理更新在一个独立的循环中以固定的deltaTime如1.0/60.0秒进行。如果一帧真实时间过去了0.1秒物理系统可能会更新6次0.1 / (1/60) 6。渲染时物体的位置不再是当前物理状态的位置而是根据上一物理帧和当前物理帧的状态进行线性插值得到的位置。这样即使物理更新频率和渲染频率不同画面也能保持平滑。5.3 常见的碰撞异常与排查物体抖动或高速穿过原因通常是因为物体速度太快一帧移动的距离超过了其自身尺寸导致从“未碰撞”直接穿越到“已穿过”错过了碰撞检测。解决使用连续碰撞检测。在窄相位检测中不仅检测静态位置还检测从上一帧位置到当前帧位置之间的运动线段或 swept shape是否与对方发生碰撞。计算会更复杂但对于子弹、高速移动的玩家角色是必要的。堆叠物体不稳定原因多个物体堆叠时一帧内可能会发生多次碰撞如果响应顺序不当会导致能量异常增加物体“弹飞”。解决使用迭代求解。在一帧内对检测到的所有碰撞对多次如3-5次应用冲量响应让碰撞能量和穿透深度逐步、均匀地分解掉。或者使用更高级的位置修正方法如顺序冲量。性能热点排查工具我们大量使用了std::chrono在代码中打点测量每个系统特别是PhysicsSystem的耗时。发现SAT检测是瓶颈之一。优化对SAT进行了微优化比如提前计算并缓存多边形的边和法线对圆形和AABB碰撞使用更快的专用函数而不是通用的SAT在GPU允许的情况下甚至可以尝试将一些简单的碰撞检测如大批量子弹放到计算着色器中。调试物理引擎时可视化是神器。我们实现了一个简单的调试绘制模式可以开关。在这个模式下RenderSystem会额外绘制出所有碰撞体的形状轮廓线框、碰撞法线、速度向量等。一眼就能看出碰撞检测是否准确响应方向是否正确。6. 项目构建、依赖管理与现代C实践一个可维护的C项目离不开好的工程实践。我们放弃了单一的main.cpp采用了更现代的方式。6.1 使用CMake进行跨平台构建CMake现在是C项目构建的事实标准。我们的CMakeLists.txt主要做了以下几件事声明项目最低版本和C标准我们用了C17。使用add_subdirectory引入第三方库如GLFW、Glad或者使用find_package查找系统已安装的库。将引擎源码编译为静态库add_library(Engine STATIC ...)。将游戏示例代码编译为可执行文件并链接引擎库和第三方库。处理不同平台Windows/macOS/Linux的特定链接库和编译选项。这样无论是在Visual Studio、Xcode还是CLion中都能一键生成项目文件并编译。6.2 第三方库的选择与管理我们尽量避免重复造轮子明智地选择第三方库GLFW处理窗口、上下文和输入。轻量级API简洁。Glad用于加载OpenGL函数指针。比GLEW更轻便可以自定义需要加载的扩展。GLM数学库。提供向量、矩阵等运算完美匹配GLSL语法不可或缺。spdlog日志库。异步日志性能好格式美观。entt一个高性能的ECS库。在项目后期我们评估了entt它的性能和灵活性远超我们的简易实现。对于新项目我强烈建议直接使用entt而非自己造轮子。我们使用Git子模块git submodule来管理这些第三方库确保团队每个成员都能获取到完全一致的版本。6.3 现代C特性的应用在项目中我们有意识地使用现代C特性来提升代码安全性和表达力智能指针资源管理全部使用std::unique_ptr和std::shared_ptr基本杜绝了内存泄漏。移动语义在AssetManager返回资源、Registry创建实体时使用移动构造来避免不必要的拷贝。Lambda表达式与算法在系统遍历实体时大量使用std::for_each配合lambda代码更紧凑。类型安全使用enum class代替旧式枚举为实体ID和组件类型ID定义强类型。最后这个自研引擎项目带给我的远不止一套可运行的代码。它更像一次深度的“解剖”练习让我对游戏引擎这个黑盒子里到底发生了什么有了肌肉记忆般的理解。当你再使用Unity或Unreal时你看待Rigidbody、Collider、System这些组件的视角会完全不同你能更精准地预判性能瓶颈更高效地进行调试。如果你也有兴趣深入游戏开发的内核我建议你从一个小而美的目标开始比如“用C和OpenGL渲染一个可以跳跳跳的方块并让它和地面碰撞”亲手实现一遍这个过程你收获的会比读十篇教程都多。