公司动态

单文件物理引擎:复古游戏平台的轻量刚体碰撞解决方案

📅 2026/8/28 8:17:32
单文件物理引擎:复古游戏平台的轻量刚体碰撞解决方案
当你在一台 MIPS 架构的 N64 真机上调试物理效果时最直接的问题不是“这个引擎功能多不多”而是“编译器生成的指令能不能放进 4KB 的代码段”“浮点运算会不会成为每帧瓶颈”“一个头文件能不能解决全部问题”。Picophysics 这个项目就是在这样的背景下出现的它把用于 N64、PSX、Dreamcast 等复古平台的物理系统压缩进了一个单独的文件用最克制的方式解决了复古游戏开发中“刚体运动、碰撞、重力”这一整类需求。这篇文章会讲清楚三件事单文件物理引擎为什么适合复古平台开发Picophysics 在架构上到底做了哪些取舍以及你在自己的 N64 / PSX / DC 游戏工程里应该怎么接入、验证和排错。1. 复古游戏物理为什么不能直接搬现代引擎很多刚从 Unity 或 Unreal 转向复古平台开发的程序员第一个误判是低估了目标硬件的“脾气”。N64 的 CPU 主频是 93.75MHzPSX 是 33.87MHzDreamcast 是 200MHz 的 SH-4。作为参照一颗现代手机上随便一颗 Cortex-A76 大核都有 2.8GHz 以上而且还有乱序执行、分支预测、多级缓存。复古平台没有这些它们甚至没有独立的 FPU 浮点单元。N64 虽然有 RSP 协处理器可以帮忙算向量运算但很多早期开发者在实际项目中还是会选择定点数来回避浮点性能损耗。在这种硬件上跑一个现代物理引擎等于让一台计算器去跑机器学习推理。现代引擎假设的底层条件——足够的内存带宽、硬件浮点加速、多核并行、动态内存分配——复古平台几乎都不满足。硬件厂商当年给开发者准备的开发库也很直接要么自己写要么用官方 SDK 里那几个很基础的运动学函数。这就是 Picophysics 这类项目存在的核心原因复古游戏需要的不是“真实的物理模拟”而是“看起来对、跑得快、不会拖垮帧率的物理效果”。现代引擎里那些大而全的功能对复古游戏来说反而是负担。表格对比一下两类项目的诉求维度现代引擎物理复古平台物理刚体数量上千甚至上万几十到几百碰撞体类型凸包、网格、SDF圆、AABB、线段积分精度10ms 以内多次子步一帧一次或固定步长内存分配允许堆分配尽量零分配浮点依赖高谨慎或使用定点数源码体积数万行单文件、数百行Picophysics 选择“单文件”形态表面上是为了方便拷贝到工程里深层原因是它明确服务“资源受限平台”这个场景。2. 单文件物理引擎的架构价值2.1 单文件不只是省事更是依赖管理策略在 N64、PSX、DC 这代平台上构建系统远没有现代这么成熟。Modern CMake、NuGet、vcpkg 这些东西在当时不存在开发者面对的是一个 Makefile 加上一个编译器前端有时甚至要手动维护链接顺序。在这种环境下第三方库每多一个文件就要多处理一次 Makefile 变更、头文件路径、目标文件依赖顺序。一个.c文件配上声明的.h文件是最稳妥的集成方式。Picophysics 把实现和声明压缩进单文件好处直接体现在构建流程上不用配置单独的静态库编译步骤。不用关心动态链接。不会出现“头文件版本和库文件版本不一致”的问题。可以直接把.c文件拖进工程参与整体编译即可。这个设计对复古开发板、模拟器环境、自定义构建脚本都友好。你甚至可以把这个文件放在src/physics/下然后让构建系统把整个目录扫描进去。2.2 单文件物理引擎的内部模块划分单文件不意味着“没有架构”而是把所有模块平铺在一个编译单元里通过命名前缀和文件内静态函数控制可见性。从实现角度Picophysics 这类引擎一般会包含以下模块体Body位置、速度、角速度、质量、位移。形状Shape圆形、AABB、线段等基础碰撞体。碰撞检测Broad-phase 和 Narrow-phase。积分器半隐式欧拉或 Verlet。约束求解位置修正、速度修正。外部接口创建物体、设置重力、步进世界、查询碰撞。每个模块对应文件内的一组函数外部只能看到ppCreateBody、ppStepWorld这类公开 API。这是一个很典型的“逻辑内聚层”只是在文件层面不拆开而已。这种设计有一个容易被忽略的优势编译器可以更激进地内联优化。同一编译单元内的函数互相调用时编译器可以看到所有实现可以直接内联掉短小的辅助函数不需要跨编译单元的 LTO 优化。对老平台有限的编译环境来说这是一个实在的性能收益。3. 在 N64 / PSX / DC 上实现物理的硬约束3.1 CPU 算力预算一帧的 CPU 预算在复古平台上通常是 16.6msNTSC 60Hz或 20msPAL 50Hz。物理系统不能独占太多一般控制在 2-4ms 以内。这意味着每帧最多只能处理几百次碰撞检测。不能做多轮迭代的约束求解一般 1-2 轮就要收敛。要避免复杂的三角函数、除法、平方根。Picophysics 这类单文件引擎通常用圆形和 AABB 作为基础碰撞体原因很简单圆形碰撞只需要比较距离平方AABB 重叠检测只需要几次区间比较。这些都不会触发 CPU 的高延迟指令。3.2 内存限制与零分配原则N64 的内存是 4MBPSX 是 2MBDreamcast 是 16MB。看起来 DC 还算宽裕但游戏资产、音频、渲染缓冲都在里面挤。物理系统如果用堆分配管理刚体数组malloc 和 free 碎片的代价在慢速平台上会非常明显。单文件物理引擎的标准做法是刚体数组使用内存池或固定长度数组在ppCreateBody时返回一个索引而不是指针。物理世界作为一个结构体内部所有存储都是静态分配的成员或传入的外部缓冲区。这样既不依赖堆实现也方便在关卡加载时一次性重置状态。3.3 浮点 vs 定点数一部分复古平台运行时浮点性能差所以这类引擎往往会保留“切换到定点数”的余地通过编译宏控制。最常见的做法是typedef struct ppVec2 { ppFixed x; ppFixed y; } ppVec2;ppFixed可以定义为float也可以定义为int32_t配合#define PP_USE_FIXED_POINT在编译期选择。用整数做定点数时通常用“高位为整数部分、低位为小数部分”的 Q 格式比如 Q16.16 即低位 16 位存储小数。加减可以直接用 int32 完成乘法需要先扩展到 int64 再截断成本比浮点乘法高但在老平台上可预测。如果你不确定应该选哪种模式优先用 float 跑通逻辑再用宏切到定点数做真机测试。不要从一开始就深入定点数优化那会让调试难度翻倍。4. Picophysics 核心概念与 API 设计4.1 一个典型的最小物理系统一个单文件物理引擎的对外世界结构通常可以简化成下面这个 C 结构体typedef struct ppWorld { ppBody bodies[PP_MAX_BODIES]; int bodyCount; ppVec2 gravity; float dt; int iterations; } ppWorld;ppWorld直接暴露给用户而不是通过不透明指针。这是单文件引擎的一贯风格你不想要动态分配我就让你静态初始化整个世界。代码里可以直接写ppWorld world; ppInitWorld(world, 0, -9.8f);ppInitWorld会把 body 数组清零设置重力向量同时把bodyCount置零。这样即使在一个只支持 C89 的老编译器上也可以靠局部变量静态声明世界完全不依赖操作系统。4.2 刚体创建与销毁刚体的创建接口关注的是“使用方便”不是“类型安全”。在复古游戏里最常见的需求是创建一个具有初始位置、速度、质量的刚体然后每帧更新它。int ppCreateBody(ppWorld* w, ppVec2 position, float mass, float radius);返回值为刚体索引-1表示创建失败通常是数量达到上限。通过索引而不是指针访问可以避免用户在游戏逻辑里长期保存一个失效的指针也方便内存池在失败时进行整理。销毁可以把ppDestroyBody设计成”交换删除”把最后一个刚体搬到被删除的位置维持数组紧凑。代价是索引顺序不稳定适合实体不需要保持列表顺序的场景。4.3 步进循环物理引擎最核心的接口是步进函数。Picophysics 这类引擎建议用固定时间步长而不是直接吃渲染帧间隔。原因在现代游戏开发里也适用物理模拟对时间步长敏感如果一步切到 30ms碰撞穿透和抖动都会出现。void ppStepWorld(ppWorld* w, float dt);内部会做三件事检测所有刚体之间的碰撞。解算碰撞响应和速度变化。用积分更新位置。如果使用固定物理步长可以在游戏循环里这样调用static float accumulator 0.0f; void gameFrameUpdate(float frameDt) { accumulator frameDt; while (accumulator PHYSICS_DT) { ppStepWorld(world, PHYSICS_DT); accumulator - PHYSICS_DT; } }这是最常见的处理方式也是为了保证物理稳定性。5. 完整示例代码实现下面我给出一份可以独立编译的 C 语言单文件物理引擎示例用来说明 Picophysics 这类项目在代码层面的实现思路。示例使用纯 C89 编写不依赖任何外部库碰撞体只支持圆形和 AABB积分器使用半隐式欧拉。5.1 文件picophysics.h引擎对外暴露头文件接口。#ifndef PICOPHYSICS_H #define PICOPHYSICS_H #define PP_MAX_BODIES 128 #define PP_MAX_AABBS 64 typedef struct ppVec2 { float x; float y; } ppVec2; typedef enum ppShapeType { PP_SHAPE_CIRCLE 0, PP_SHAPE_AABB } ppShapeType; typedef struct ppBody { int active; ppShapeType shapeType; ppVec2 pos; ppVec2 vel; float mass; float invMass; float radius; /* circle */ float minX, minY, maxX, maxY; /* aabb */ } ppBody; typedef struct ppWorld { ppBody bodies[PP_MAX_BODIES]; int bodyCount; ppVec2 gravity; float damping; } ppWorld; void ppInitWorld(ppWorld* w, float gx, float gy); int ppCreateCircle(ppWorld* w, ppVec2 pos, float mass, float radius); int ppCreateAABB(ppWorld* w, ppVec2 pos, float mass, float halfW, float halfH); void ppApplyForce(ppWorld* w, int bodyIndex, ppVec2 force); void ppStepWorld(ppWorld* w, float dt); #endif头文件里只有 6 个公开接口没有任何一个涉及内存分配。ppCreateCircle和ppCreateAABB返回刚体索引外部不需要关心内部数组是怎么管理的。5.2 文件picophysics.c实现部分。#include picophysics.h #include string.h void ppInitWorld(ppWorld* w, float gx, float gy) { memset(w, 0, sizeof(ppWorld)); w-gravity.x gx; w-gravity.y gy; w-damping 0.998f; } int ppCreateCircle(ppWorld* w, ppVec2 pos, float mass, float radius) { if (w-bodyCount PP_MAX_BODIES) { return -1; } ppBody* b w-bodies[w-bodyCount]; memset(b, 0, sizeof(ppBody)); b-active 1; b-shapeType PP_SHAPE_CIRCLE; b-pos pos; b-radius radius; b-mass mass; b-invMass (mass 0.0f) ? 1.0f / mass : 0.0f; return w-bodyCount; } int ppCreateAABB(ppWorld* w, ppVec2 pos, float mass, float halfW, float halfH) { if (w-bodyCount PP_MAX_BODIES) { return -1; } ppBody* b w-bodies[w-bodyCount]; memset(b, 0, sizeof(ppBody)); b-active 1; b-shapeType PP_SHAPE_AABB; b-pos pos; b-minX pos.x - halfW; b-maxX pos.x halfW; b-minY pos.y - halfH; b-maxY pos.y halfH; b-mass mass; b-invMass (mass 0.0f) ? 1.0f / mass : 0.0f; return w-bodyCount; } void ppApplyForce(ppWorld* w, int bodyIndex, ppVec2 force) { if (bodyIndex 0 || bodyIndex w-bodyCount) { return; } ppBody* b w-bodies[bodyIndex]; if (!b-active || b-invMass 0.0f) { return; } b-vel.x force.x * b-invMass; b-vel.y force.y * b-invMass; } static int circleCircleCollide(const ppBody* a, const ppBody* b, float* depth, ppVec2* normal) { float dx b-pos.x - a-pos.x; float dy b-pos.y - a-pos.y; float rsum a-radius b-radius; float distSq dx * dx dy * dy; if (distSq rsum * rsum) { return 0; } float dist (distSq 0.0f) ? (float)sqrt(distSq) : 0.0f; *depth rsum - dist; if (dist 0.0f) { normal-x dx / dist; normal-y dy / dist; } else { normal-x 1.0f; normal-y 0.0f; } return 1; } static int aabbAABBCollide(const ppBody* a, const ppBody* b) { return (a-minX b-maxX a-maxX b-minX a-minY b-maxY a-maxY b-minY); } static void resolveCollision(ppBody* a, ppBody* b, ppVec2 normal, float depth) { float e 0.5f; /* 恢复系数 */ if (a-invMass 0.0f b-invMass 0.0f) { return; } /* 位置修正不让两个物体继续重叠 */ float totalInvMass a-invMass b-invMass; if (totalInvMass 0.0f) { ppVec2 correction; correction.x normal.x * (depth / totalInvMass); correction.y normal.y * (depth / totalInvMass); a-pos.x - correction.x * a-invMass; a-pos.y - correction.y * a-invMass; b-pos.x correction.x * b-invMass; b-pos.y correction.y * b-invMass; } /* 速度修正 */ ppVec2 rv; rv.x b-vel.x - a-vel.x; rv.y b-vel.y - a-vel.y; float velAlongNormal rv.x * normal.x rv.y * normal.y; if (velAlongNormal 0.0f) { return; /* 已经在分离不需要处理 */ } float impulse -(1.0f e) * velAlongNormal / totalInvMass; ppVec2 imp; imp.x impulse * normal.x; imp.y impulse * normal.y; a-vel.x - imp.x * a-invMass; a-vel.y - imp.y * a-invMass; b-vel.x imp.x * b-invMass; b-vel.y imp.y * b-invMass; } void ppStepWorld(ppWorld* w, float dt) { int i, j; /* 先应用重力并积分到速度 */ for (i 0; i w-bodyCount; i) { ppBody* b w-bodies[i]; if (!b-active) { continue; } b-vel.x w-gravity.x * dt; b-vel.y w-gravity.y * dt; b-vel.x * w-damping; b-vel.y * w-damping; b-pos.x b-vel.x * dt; b-pos.y b-vel.y * dt; if (b-shapeType PP_SHAPE_AABB) { float halfW (b-maxX - b-minX) * 0.5f; float halfH (b-maxY - b-minY) * 0.5f; b-minX b-pos.x - halfW; b-maxX b-pos.x halfW; b-minY b-pos.y - halfH; b-maxY b-pos.y halfH; } } /* 碰撞检测与响应 */ for (i 0; i w-bodyCount; i) { ppBody* a w-bodies[i]; if (!a-active) { continue; } for (j i 1; j w-bodyCount; j) { ppBody* b w-bodies[j]; if (!b-active) { continue; } if (a-shapeType PP_SHAPE_CIRCLE b-shapeType PP_SHAPE_CIRCLE) { float depth; ppVec2 normal; if (circleCircleCollide(a, b, depth, normal)) { resolveCollision(a, b, normal, depth); } } else if (a-shapeType PP_SHAPE_AABB b-shapeType PP_SHAPE_AABB) { if (aabbAABBCollide(a, b)) { /* 简单处理把两个 AABB 分开沿最小重叠轴 */ float overlapX (a-maxX - b-minX) (b-maxX - a-minX) ? (a-maxX - b-minX) : (b-maxX - a-minX); float overlapY (a-maxY - b-minY) (b-maxY - a-minY) ? (a-maxY - b-minY) : (b-maxY - a-minY); ppVec2 n; if (overlapX overlapY) { n.x (a-maxX - b-minX) (b-maxX - a-minX) ? -1.0f : 1.0f; n.y 0.0f; resolveCollision(a, b, n, overlapX); } else { n.x 0.0f; n.y (a-maxY - b-minY) (b-maxY - a-minY) ? -1.0f : 1.0f; resolveCollision(a, b, n, overlapY); } } } } } }这段代码里包含了圆形碰撞和 AABB 碰撞两种路径虽然响应逻辑做了简化但已经足够演示单文件物理引擎的核心套路刚体数组、重力积分、碰撞修正、速度冲量。值得注意的点是ppCreateCircle中mass传入0.0f时invMass会被设为 0表示这个刚体是静态的。静态刚体不会受重力影响也不会被其他刚体推开这在实现平台、地面、墙壁时非常有用。5.3 在游戏循环中接入接入方法很简单先把引擎文件拷进工程然后在游戏主循环里调用#include picophysics.h static ppWorld world; void initGame() { ppInitWorld(world, 0.0f, -9.8f); ppVec2 groundPos { 0.0f, 0.0f }; ppCreateAABB(world, groundPos, 0.0f, 320.0f, 16.0f); ppVec2 ballPos { 0.0f, 200.0f }; ppCreateCircle(world, ballPos, 1.0f, 12.0f); } void updatePhysics(float frameDt) { static float accumulator 0.0f; const float physicsDt 1.0f / 60.0f; accumulator frameDt; while (accumulator physicsDt) { ppStepWorld(world, physicsDt); accumulator - physicsDt; } }这里用accumulator做了固定步长累加。即使渲染帧率波动物理步长仍然稳定在 60Hz这是避免碰撞抖动最简单的办法。5.4 编译验证在 PC 上用 gcc 编译一个最小的测试程序gcc -Wall -O2 -o test_physics test_physics.c picophysics.c -lm-lm是为了链接sqrt函数。如果编译环境是老平台的交叉编译器很可能需要对照目标平台的手册确认数学库怎么链。6. 运行结果与效果验证6.1 最小测试程序写一个简单的测试程序让一个小球自由落体撞上地面#include stdio.h #include picophysics.h int main(void) { ppWorld world; ppInitWorld(world, 0.0f, -9.8f); ppVec2 groundPos { 0.0f, 0.0f }; ppCreateAABB(world, groundPos, 0.0f, 320.0f, 16.0f); ppVec2 ballPos { 0.0f, 100.0f }; int ball ppCreateCircle(world, ballPos, 1.0f, 10.0f); int i; for (i 0; i 120; i) { ppStepWorld(world, 1.0f / 60.0f); ppBody* b world.bodies[ball]; printf(%d %.2f %.2f\n, i, b-pos.x, b-pos.y); } return 0; }预期输出是小球 y 坐标从 100 逐渐减小到达地面附近后反弹反弹高度逐次降低最终稳定在地面上方 10 像素左右。如果看到 y 坐标直接穿过地面变成负数说明碰撞响应里的速度修正没有生效这是单文件物理引擎最常遇到的调试点。6.2 性能验证方法在目标平台或者模拟器上验证时可以用一个简单的性能计数器unsigned int start timerGetTicks(); ppStepWorld(world, dt); unsigned int elapsed timerGetTicks() - start;在 N64 SDK 环境里可用OS_GetTime()PSX 可用Timer()DC 可用timer_now_get()。开发时把耗时打点显示在调试菜单里观察每帧物理耗时是否在预算内。如果超过 4ms优先检查刚体数量是否太多以及碰撞体直径是否合理。6.3 可视化验证如果没有真机屏幕可以把物理数据导出成 CSV在 PC 上用 Python 画轨迹曲线import matplotlib.pyplot as plt xs [] ys [] with open(pos.csv) as f: for line in f: parts line.strip().split(,) xs.append(float(parts[0])) ys.append(float(parts[1])) plt.plot(xs, ys) plt.gca().invert_yaxis() plt.show()这一招很适合验证反弹衰减、平台弹跳、刚体堆积这些视觉效果。7. 常见问题与排查思路以下问题是在复古平台接入单文件物理引擎时最容易遇到的整理成排查表方便对照。问题现象可能原因排查方式解决方案小球直接穿过地面半隐式欧拉积分步长太大检查是否使用了固定 60Hz 步长将步长缩小到 1/60 或 1/120增加子步物理效果在不同帧率下不一致直接用了渲染帧时间查看 updatePhysics 是否累加器处理使用固定物理步长不要直接传 frameDt平台有多个刚体时性能下降碰撞检测是 O(N^2) 暴力遍历打印每帧耗时用空间网格或只检测活跃刚体调用 ppCreateBody 返回 -1刚体数量达到 PP_MAX_BODIES检查 bodyCount增大上限或复用已销毁刚体刚体抖动严重两个刚体重叠深度过大位置修正过量查看 position 是否剧烈跳动降低恢复系数减少单次修正量静态刚体被弹开静态刚体的 invMass 不是 0检查创建时 mass 是否传 0设置 mass 0.0f使 invMass 为 0定点数版本数值漂移Q16.16 乘法溢出或截断错误打印中间结果对比 float检查乘法是否先提升到 int64如果把单文件物理引擎从 float 切换到定点数版本第一个排查点永远是乘法溢出。比如 Q16.16 中两个 16 位小数相乘之后需要右移 16 位但右移之前要保证 int64 位宽能容纳结果。这里最容易出现“看起来每次差 0.01累计几十帧后位置偏了很多”的隐蔽问题。8. 最佳实践与工程建议8.1 用固定时间步长不用渲染帧时间这一点值得反复强调。复古平台帧率波动非常明显尤其是在复杂场景里。直接用渲染帧间隔作为物理 dt会出现某个掉帧场景里物理突然跨过一大段距离导致碰撞穿透。固定步长的成本是一个累加器加一个 while 循环收益是物理行为可预测、可复现。8.2 刚体尽量少形状尽量简单单文件物理引擎不是给万人同屏游戏用的。设计关卡时把刚体控制在几十到一两百个以内比较合理。平台、墙壁、机关用 AABB角色、子弹、圆形物件用 Circle两种形状的碰撞路径是最省性能的。如果刚体数量超过上限增加PP_MAX_BODIES只解决表面问题真正要检查的是关卡里是否有不必要的刚体。很多装饰物件根本不需要参与碰撞用视觉效果替代即可。8.3 静态刚体统一使用 mass 0所有不参与动力学计算的地面、墙、平台创建时都要把 mass 设为 0。这会让引擎把invMass置为 0从而在碰撞响应中被当成不可移动的物体。一个常见错误是给静态地面设置了 1.0f 的质量导致重力系统里地面被轻微推动出现平台缓慢下沉的幻觉。8.4 不要把引擎状态分布到游戏对象里单文件引擎认为物理世界的唯一状态来源是ppWorld。不要在游戏对象里单独存一份位置、速度也不要每帧从外部覆盖刚体的坐标。正确的做法是把游戏对象与刚体索引绑定需要读取位置时从world.bodies[index].pos获取。这样物理引擎的迭代计算才能保持一致。8.5 在真机平台做定点数分支测试如果你打算移植到 N64 或 PSX 真机建议先在 PC 上用 float 版本跑通游戏逻辑再通过编译宏切到定点数版本看两类关键场景大量小球堆积时是否会出现穿透。长时间运行后物体是否缓慢漂移。定点数版本的问题通常不是单帧逻辑错误而是累积误差。为了定位可以在 PC 上把每一步的位置输出和 float 版本对比找出第一个差异超过阈值的帧。8.6 为物理系统预留调试可视化复古平台没有调试器界面所有调试信息都要自己画。最简单的做法是在游戏代码里加一个“物理调试模式”用不同颜色把刚体的 AABB 包围盒和 Circle 半径渲染出来。这个开关在正式发行前可以编译时关掉但开发期建议常开。穿透和重叠在这种可视化下会一目了然。9. 总结与后续学习方向Picophysics 所代表的单文件物理引擎思路解决的是复古游戏平台上一个很实际的工程问题如何用最小的代码和内存代价实现稳定的刚体运动、碰撞和反弹。它的核心不是“功能多强大”而是“在受限环境下哪些功能可以被砍掉哪些功能必须保留”。通过本文的示例可以看到一个可用的 2D 物理引擎可以压缩到两百行左右接口只有几个函数不依赖动态内存分配可以在 N64、PSX、DC 的 C 编译环境中直接编译。如果你想继续深入建议按这个顺序学习把示例扩展到圆形与 AABB 的混合碰撞这在实际关卡里几乎是必须的。给刚体增加旋转这会让示例从平移物理升级成真正的刚体物理。引入空间网格或排序扫掠把碰撞检测复杂度从 O(N^2) 降到接近 O(N)。对比 Q16.16 定点数实现和 float 实现理解老平台上的数值精度取舍。当你真正在一个复古平台上跑通物理逻辑时会理解所有取舍都是值得的一个文件几十个函数就能让游戏里的平台、弹球和机关活起来。