Picophysics 这个名字,指向的是一个给 N64、PSX、Dreamcast 这类旧游戏平台准备的“单文件物理引擎”。它要解决的是复古自制游戏开发里最容易卡住的一环:在内存紧张、CPU 主频不高、工具链古老的环境下,把刚体运动、碰撞检测、重力这些基础物理能力,用尽量小的代价嵌进 ROM 项目。这个主题最值得看的并不是物理效果有多花哨,而是“单文件、无依赖、能编译进旧平台”的工程组织方式。
如果你正在写 N64 自制游戏,或者想给 PSX 项目加一点碰撞和跳跃手感,又或者在 Dreamcast 上想快速做个平台跳跃 Demo,这篇内容会比较合适。下面先讲清楚这类开源库的适用边界,再按开发流程拆解环境、最小示例、核心参数、跨平台移植和问题排查。
1. 为什么旧游戏平台会需要单文件物理引擎
1.1 复古自制开发的真实环境
写 N64、PSX、Dreamcast 自制游戏,和现代 Unity、Unreal 开发完全是两个世界。最后代码会直接编译成 ROM 或 CD 镜像,跑在几十 MHz 的 CPU 上,内存按 MB 算。PC 上随便开几十个对象做物理,几毫秒就完成的一件事,放到这些平台上,就需要认真考虑对象数量、数据结构、浮点运算成本,甚至堆栈长度。
几个硬条件先摆出来:
- N64:MIPS 架构,主机内存通常只有 4MB 到 8MB。纹理、模型、代码、堆分配都挤在这几 MB 里,物理引擎的内存占用不能太大。
- PSX:MIPS R3000 衍生 CPU,主频大概在 33 MHz 级别,内存 2MB 左右,显存 1MB。很多自制项目会刻意避免动态内存分配。
- Dreamcast:SH-4 架构,主频 200 MHz 级别,内存 16MB,比前两代宽裕一些,但同样不是现代开发机能比。
在这些平台做游戏,物理库如果自带几十个依赖文件,光适配工具链就能消耗掉大半天。单文件方案的价值就在这里:一个头文件加一个 C 文件,或者一个实现型头文件,整个工程里包含进来就能用。N64 的 libdragon、PSX 的野火 SDK、Dreamcast 的 KallistiOS,都只提供基础入口、图形和输入输出,没有完整物理系统。要实现主角跳跃、敌人碰撞、平台移动,最省事的方式就是找一个小型单文件物理方案,自己融进去。
1.2 “单文件”到底省在哪里
单文件物理引擎的实际好处,可以归纳成几点:
编译集成成本低。你不用处理多个头文件的依赖继承,不用把一堆静态库链接进 ROM,不用做复杂的条件编译。把 C 文件拷到源码目录,在工程里包含头文件,编就完了。
行为透明。整个物理逻辑只集中在一个文件里,调试时可以直接阅读求解器循环,修改碰撞回调,不用在多个模块间跳来跳去。
可改性强。旧平台没有统一物理规则,你要的是“这个游戏玩起来手感对”,不是“物理库所有功能都齐全”。单文件方便你删掉用不到的代码,压缩体积。
但这里要提醒一句:单文件不等于“完成一切”。它一般提供的是 2D 或简化 3D 的刚体、圆形、AABB 或球的碰撞、重力和简单约束。如果你想做布料、软体、复杂关节链,大概率需要自己扩展。Picophysics 这类项目,核心目标是“够用 + 能放进旧平台”,而不是做成通用物理大引擎。
1.3 常见误解:旧平台不能跑物理
一个比较常见的误解是,N64 和 PSX 这么老的机器,跑得动物理吗?实际上,平台跳跃、弹跳球、方块解谜、2D 格斗、简单 3D 刚体掉落,这些场景在旧平台上完全跑得动。所谓“跑不动”,多数是因为对象数量没控制住,或者用了高成本的凸包碰撞、连续碰撞检测、关节约束。这类需求本来就不属于轻量单文件物理的适用边界。
我把这类引擎的定位总结成一句话:它适合“规则简单、对象几十到几百个、不需要精确模拟”的游戏玩法。如果你要做严谨的车辆模拟,或者角色身体由多个刚体关节组成,建议换更完善的库,或者自己扩展约束求解器。
2. 使用单文件物理引擎的边界和取舍
2.1 能解决什么,不能解决什么
先看能解决的部分:
- 重力影响下的物体下落。
- 圆形、矩形或圆形刚体之间的碰撞。
- 简单反弹、摩擦、地面支撑。
- 触发检测,比如玩家进入某个区域触发开关。
- 很短链的简单约束,比如弹弓、绳索的简化版本。
再看不能解决的部分:
- 高质量 3D 人物物理,需要多个约束和姿态控制。
- 布料、流体、软体变形。
- 大型物理场景,对象数量动辄上千。
- 连续碰撞检测,防止高速子弹穿透薄墙。
- 针对复杂关节的逆运动学。
这不是“能力不行”,而是设计目标不同。单文件物理库优先保证代码量小、逻辑简单、容易编译。你在选型时先列需求,再决定是否够用。
2.2 为什么不要一开始就把参数拉满
我见过不少人拿到一个物理引擎,第一件事就是加 500 个物体,然后开满摩擦、反弹、关节,跑起来发现卡顿或者穿模,立刻得出结论“这库不行”。实际上,旧平台物理的调试顺序应该是从小到大。
我建议第一次测试分三步走:
- 只用一个球,从固定高度掉落,看它落到地面上是否弹跳正常。
- 用 16 个对象做混合碰撞,观察是否穿透、是否震动。
- 再逐步加对象,直到 CPU 占用和内存占用达到你的预期上限。
不要一上来就开最大数量。低配平台能跑多少,和物理库本身关系不大,更多取决于你的数据结构和更新频率。
2.3 单文件方案的主要风险
单文件物理引擎也有明显风险,需要提前知道。
第一是功能固化。如果你需要的功能库里没有,自己改起来可能比重新写一个更费劲。单文件的逻辑往往和其他代码耦合很深,阅读成本不低。
第二是精度和确定性。旧平台编译器的浮点行为、指令顺序,可能影响物理模拟结果。同一个物理库,在 PC 上表现正常,移植到 MIPS 或 SH-4 平台上,结果可能不一样。不是库有问题,而是浮点运算在目标平台上的实现差异。
第三是内存策略。很多单文件库为了简单,会使用固定数组或无限制的全局对象池。如果你在工程里动态创建和销毁对象,需要确认库是直接管理数组,还是通过 id 管理对象。id 复用和数组越界是常见坑。
旧平台开发里,内存占用本身比 CPU 占用更容易被低估。你写了一个看似精简的物理库,但如果每个对象都用了较大的结构体,几百个对象也可能吃掉几十 KB,放进 4MB 的总内存里,仍然需要精打细算。
3. 在开发机上先跑通最小示例
3.1 环境选择和编译方式
不需要一开始就为 N64 或 PSX 配置交叉编译环境。标准的做法是:先把物理库在 PC 上编译成命令行工具,用一个小 Demo 验证基本功能。这样调试快、日志方便、内存问题容易复现。
推荐环境是 Linux 或 macOS,配合 GCC 或 Clang。Windows 下用 MSVC 也能编,但需要留意库是否用了 POSIX 风格的时间函数。单文件库一般只依赖 C 标准库,跨平台问题不大。
先创建一个测试目录,结构大概是这样:
picophysics_test/ picophysics.h picophysics.c demo_main.c Makefile这里只是通用结构示例,具体文件名称以你下载到的源码为准。Makefile 只需要简单编译规则:
CFLAGS = -std=c99 -Wall -O2 LDLIBS = -lm demo: demo_main.c picophysics.c $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)如果你的目标平台工具链只支持 C89,那需要把-std=c99改成-std=c89或直接不指定。很多复古平台 SDK 的编译器版本较老,C99 特性不一定全支持。
3.2 最小可运行示例
下面这个示例,主要是演示物理引擎的典型使用流程,不代表 Picophysics 的真实 API,具体接口要以你拿到的头文件为准。流程是关键:
#include "picophysics.h" #include <stdio.h> int main(void) { struct PFWorld *world = pf_create_world(); struct PFBody *ball = pf_add_body(world); pf_set_position(ball, 0.0f, 10.0f); pf_set_shape_circle(ball, 0.5f); pf_set_density(ball, 1.0f); struct PFBody *ground = pf_add_body(world); pf_set_shape_aabb(ground, -20.0f, -1.0f, 20.0f, 0.0f); pf_set_static(ground, 1); for (int i = 0; i < 120; i++) { pf_step(world, 1.0f / 60.0f); float y = pf_get_position_y(ball); printf("frame %d: ball y = %f\n", i, y); } pf_destroy_world(world); return 0; }这个流程包含四步:创建世界、添加刚体、逐帧调用pf_step、销毁世界。你只需要把注意力放在pf_step的参数上。这一步传入的是固定时间步长,一般用1.0f / 60.0f,表示每次模拟 1/60 秒的物理变化。
3.3 判断一次物理更新是否正常的标准
运行程序后,你可以观察输出。正常现象是:球的 y 坐标从 10 开始逐渐减小,触碰地面后开始反弹,反弹高度逐渐降低,最终贴近地面并保持稳定。如果出现以下现象,需要排查:
- 数值变成
nan或inf,通常是积分步长过大,或者碰撞响应里产生了过大的速度。 - 球直接穿过地面,说明碰撞检测没有生效,或者球速度过快、时间步长过大。
- 球在地面附近抖动,说明碰撞响应和重力之间没有收敛,可能是迭代次数不足或恢复系数设置过大。
这里最值得先做的,是跑 10 万帧,确认物理状态不会越来越糟。如果不稳定,哪怕 Demo 看起来正常,也不能放到 ROM 里。
运行 10 万帧的目的不是验证速度,而是验证稳定性和确定性。物理引擎在长时间运行后如果出现漂移或爆炸,说明求解器参数或积分方式有问题。
4. 核心参数和调优顺序
4.1 固定时间步长是第一步
物理模拟最忌讳的是每帧用不固定的时间差。游戏帧率在旧平台上经常波动,有时 30 帧,有时 20 帧,如果你直接把每帧的间隔时间传给物理引擎,物体运动会变得不稳定。
正确做法是使用累积器:
double accumulator = 0.0; const double dt = 1.0 / 60.0; uint64_t last_time = get_time_ms(); while (game_running) { uint64_t now = get_time_ms(); double frame_time = (now - last_time) / 1000.0; last_time = now; if (frame_time > 0.25) { frame_time = 0.25; // 防止长时间停顿后一次性补太多帧 } accumulator += frame_time; while (accumulator >= dt) { pf_step(world, dt); accumulator -= dt; } }这个模式看到很多引擎在用。它保证物理始终以固定步长更新,渲染帧率不会直接影响物理稳定性。补帧上限也要设,否则窗口拖拽或调试暂停后,物理会一次性补几百帧,把模拟搞崩。
4.2 迭代次数、摩擦和恢复系数
单文件物理引擎一般会用迭代求解器来处理碰撞。迭代次数决定碰撞后物体之间的“分离速度”。迭代次数越高,穿透越小,但 CPU 开销越大。在旧平台上,默认迭代次数可能只有 1 到 4 次,已经可以用。如果物体堆叠时明显抖动,可以尝试增加到 6 或 8 次。
摩擦系数和恢复系数需要按游戏手感调:
- 摩擦系数 0 表示完全光滑,1 表示非常粗糙。
- 恢复系数 0 表示完全无弹跳,1 表示完全弹性。
- 大于 1 的恢复系数会引起能量增加,使用后物体可能越弹越高,最终爆炸。
调参顺序建议是先固定恢复系数为 0,确认碰撞能稳定分离;再逐步增加摩擦,看物体滑动是否合理;最后调弹跳。这个方法能避免多个参数相互干扰时找不到问题根源。
4.3 浮点和定点数的选择
N64 有浮点协处理器,Dreamcast 的 SH-4 也支持浮点,PSX 则比较尴尬。PSX 的 CPU 浮点性能并不强,很多 PSX 自制开发者会使用定点数,或者直接用整数模拟位置计算。
如果你要移植到 PSX,先确认 Picophysics 是否使用浮点,以及它对浮点性能的依赖有多大。如果只是在水平和垂直方向做简单物理,可以使用定点数。基本做法是把位置、速度用整数表示,单位是 1/256 或 1/1024 像素,每帧更新时用固定小数乘法处理。
这里没有统一标准,需要根据目标平台和游戏类型选择。不过有一个通用原则:如果库本身是浮点实现,先不要急着改成定点,等游戏逻辑稳定后再优化。很多物理不稳定问题,不是定点浮点引起的,而是时间步长、迭代次数、对象数量三个因素没配合好。
下面给一个简单的参数对比参考:
| 参数 | 入门推荐值 | 进阶调整方向 | 判断标准 |
|---|---|---|---|
| 固定时间步长 | 1/60 秒 | 1/30 秒,或 1/120 秒 | 高速物体是否穿墙 |
| 迭代次数 | 2 到 4 | 6 到 8 | 堆叠物体是否抖动 |
| 恢复系数 | 0.2 到 0.5 | 0 到 1 | 是否越弹越高 |
| 摩擦系数 | 0.3 | 0 到 1 | 滑动是否自然 |
| 最大对象数 | 32 | 按内存和 CPU 实测调整 | 帧率是否稳定 |
5. 移植到 N64、PSX、Dreamcast 的差异
5.1 平台工具链和内存差异
三个平台的编译方式完全不同。
N64 自制开发常用 libdragon 或 libultra。libdragon 的优点是工具链较新,内存管理和文件接口贴近现代 C 习惯,可以把单文件物理库直接编译进去。但要注意,libdragon 项目默认使用静态内存分配,物理库如果使用malloc,需要确认 SDK 是否提供堆管理。
PSX 常用野火 SDK 或 PsyQ。这些工具链比较老,对 C 标准支持有限,代码里不能随便用 C99 的新语法。单文件库如果依赖stdint.h或某些内建函数,可能会遇到问题,需要提前查看头文件兼容性。
Dreamcast 用 KallistiOS,工具链是基于 GCC 的sh-elf交叉编译器,整体最接近现代开发环境。Dreamcast 内存 16MB,运行单文件物理引擎通常没有压力,你可以把更多精力放在图形和渲染上。
| 项目 | N64 | PSX | Dreamcast |
|---|---|---|---|
| CPU | MIPS 系列 | MIPS R3000 系列 | SH-4 |
| 常见内存规模 | 4MB 到 8MB | 2MB 左右 | 16MB |
| 常见 SDK | libdragon / libultra | 野火 SDK / PsyQ | KallistiOS |
| 浮点支持 | 有 FPU | 偏弱 | 支持 |
| C 标准兼容性 | 较新工具链可用 | 较老 | 较新 |
5.2 缓存、字节序、指令集和栈
MIPS 和 SH-4 都是 RISC 架构,访问未对齐的内存时可能产生异常。物理对象结构体里如果有大量浮点数组,需要注意结构体对齐,避免跨平台移植后出现随机崩溃。
字节序问题也很现实。N64 的 MIPS 是大端,PSX 的 MIPS 是小端,Dreamcast 的 SH-4 是小端。如果你的物理引擎会把对象数据序列化到存档文件,或者通过通信接口传递,字节序处理必须统一。否则 PC 端构建的存档放到 N64 上,位置数据全部是颠倒的。
还有一个容易忽略的点是栈空间。旧平台可用的栈通常很小,递归调用要避免。物理引擎如果使用递归遍历碰撞树,在栈小的平台上很容易触发溢出。建议用迭代方式替代,或者把递归深度控制住。
5.3 从模拟器到真机的测试顺序
我的习惯顺序是:先 PC 命令行测试,再模拟器测试,最后真机测试。模拟器速度慢,但调试方便,能看日志,能打断点,能检查内存。真机测试暴露的是时序、缓存、硬件差异问题,比如有的 N64 模拟器没有正确模拟 RSP 的浮点行为,导致物理结果和真机不同。
如果你先在模拟器上跑 10 万帧没有问题,再上真机跑 5 分钟游戏流程,记录是否出现异常抖动、卡死、穿模。真机上的帧率变化、中断频率、控制器输入时机,都会影响物理表现。尤其是 N64 和 PSX,它们的视频输出和内存控制器行为比模拟器更保守,有时模拟器能跑通,真机就是不行。
6. 常见问题排查顺序
6.1 构建失败先查工具链和 C 标准
单文件物理库最常出现的问题是编译错误。比如变量声明在for循环内部,C89 编译器不支持;比如用了//行注释,旧编译器可能不认;比如缺少#include <string.h>,但在某些 SDK 里没有默认包含。
遇到构建失败,先看两个地方:
- 工具链版本。如果你用的是老 SDK,优先确认库是否兼容 C89。
- 源文件编码。某些 Windows 编辑器会保存成带 BOM 的 UTF-8,交叉编译器可能报错。
不要在编译错的时候急着改物理库。先写一个空文件,只#include物理头文件,编一次。如果空文件都编不过,问题在工具链不在库。
6.2 物理抖动先查时间步和迭代
物体抖动是最常见的物理 bug。出现抖动时,先确认传入pf_step的时间步长是不是固定值。如果时间步长不固定,物理能量会不断变化,表现就是物体在地面附近高频震动。
时间步长正常之后,再看迭代次数。迭代次数太低,物体堆叠时接触点多,约束无法在一步内全部求解,会产生“弹跳感”和“颤抖感”。这种情况可以增加迭代次数,但由于旧平台 CPU 有限,我更推荐减少同屏堆叠物体数量。
6.3 对象穿透和内存异常该怎么查
对象穿透通常有两种原因。一是时间步长过大,物体速度太快,一个物理步内穿过了整个碰撞体。解决方法是缩小固定时间步长,比如从 1/60 改成 1/120,或者开启连续碰撞检测。二是碰撞检测只处理了形状的重叠,没有处理高速相对运动。单文件库一般不会做 CCD,所以如果游戏里有高速子弹,建议自己用射线检测补充。
内存异常表现为:花屏、随机卡死、对象数据莫名其妙被修改。先检查物理对象数组是否越界。很多单文件库用固定数组存储对象,删除时如果只是标记删除,没有真正减少计数,最终会导致数组写满。你可以把对象数量打印出来,看看是不是在持续增长。
如果对象数据被其他模块覆盖,优先检查物理世界的结构体大小,以及你在工程里是否重复定义了同一个宏。这种问题往往不是物理库的问题,而是工程文件中多个模块之间有命名冲突。
遇到一次随机崩溃,不要急着改物理算法。先开日志,把每帧对象数量、位置、速度、内存占用打印出来。很多时候,崩溃发生在物理模块内部,但真正原因是其他模块写坏了内存。
7. 落地经验:什么值得留意
7.1 单文件物理在工程里的定位
如果把游戏工程比作一辆车,单文件物理引擎更像是一个“可以直接改的底盘”,而不是“自动巡航系统”。它给你基本物理能力,但你需要根据目标平台做裁剪和加固。
我的建议是,把单文件物理库当作一个独立模块,尽量不直接和游戏逻辑耦合。你可以在它外面封装一层“物理世界接口”,比如create_game_world、spawn_ball、check_hit。这样以后如果发现库不够用,可以替换成更复杂的引擎,不需要改动游戏主体。
7.2 把物理模块封装成独立接口
封装的好处有三个:
- 方便调试。你可以在接口层记录所有物理对象的输入输出。
- 方便替换。换物理库时,只需重写封装层。
- 方便测试。可以单独跑物理测试用例,不依赖渲染、输入、音频模块。
一个比较简单的封装结构是:
struct game_physics { struct PFWorld *world; int next_id; struct game_object objects[MAX_OBJECTS]; }; void game_physics_init(struct game_physics *gp); int game_physics_add_ball(struct game_physics *gp, float x, float y, float r); void game_physics_update(struct game_physics *gp, float dt);这里的示例命名只是说明分层思路,实际情况以你项目风格为准。
7.3 复古平台之外的价值
这类单文件物理方案,不只对复古平台有用。在 Web 开发、嵌入式开发、小型游戏机上,同样适用。只要你面对的环境内存小、编译器老、依赖少,单文件物理库都是一个很好的起点。它能让你用最少的成本,先把游戏玩法验证起来。
但也要再次强调:不要因为“单文件”就误以为它是万能库。功能边界、参数调整、跨平台测试,仍然要自己完成。真正落地时,最该盯住的不是功能列表,而是输入格式、资源占用和失败重试。物理引擎在每个平台上的确定性、内存布局和碰撞回调,才是决定你能不能顺利发布的关键。
我个人的建议是,先把 PC 命令行 Demo 跑稳,再放进模拟器跑 10 万帧,最后才考虑移植到真实硬件。很多问题不是物理库能力不够,而是前置环境和输入材料没有处理干净。等你把这些都理顺了,再决定是否在游戏里开放给玩家使用,就会踏实很多。