复古主机平台的游戏开发,这几年热度一直不低。N64、PSX、Dreamcast 这三台机器,距今都有二十多年了,但社区里的 Homebrew 开发者反而越来越多。如果你也尝试过在这些平台上写一个小 demo,大概很快就会撞到同一个墙:物理怎么做?
在现代游戏引擎里,物理模块是“默认自带”的,拖进去就能用。但回到 N64 的 4MB 内存、PSX 的 2MB 内存,以及 PSX 连浮点单元都没有这个事实,你会发现成熟的物理库根本装不进去,哪怕勉强装进去,运行效率也完全不可接受。于是很多复古游戏项目最终只写了“重力 + 地面碰撞”这种最简陋的模拟,一旦遇到多角色、多平台、多碰撞体的交互,bug 就开始失控。
PicoPhysics 这类“单文件物理引擎”项目,恰恰是用一种非常复古、又非常现代的工程思路来回答这个问题:把物理核心压缩进一个 C 文件,不引入任何复杂依赖,让开发者可以直接拖进 libdragon、PSn00bSDK、KallistiOS 这类复古平台工具链里使用。
这篇文章会从“为什么复古平台做物理这么难”讲起,拆解单文件物理引擎的工程逻辑,然后给出一个可直接运行的最小 C 语言物理核心,最后说明怎么把它接到 N64、PSX、Dreamcast 的工具链中,并总结常见坑和最佳实践。
1. 为什么复古平台的物理是一个“大问题”
先回到实际开发场景。假设你现在用 libdragon 给 N64 写一个平台跳跃游戏。渲染循环跑通了,贴图压缩也搞定了,然后开始做“主角从平台上跳起来,落回地面”这个最基础的行为。
如果用现代引擎的思路,你会第一时间想到引入现成的物理库。但现实是:
- 主流物理库面向桌面和移动平台设计,体积、依赖、API 风格都适应不了嵌入式级别的复古环境。
- N64 虽然有 FPU,但内存只有 4MB;PSX 直接没有 FPU,浮点运算在软件模拟下慢到不可接受。
- 复古平台的基础工具链本身比较“原始”,交叉编译、链接、ROM 打包的流程往往很脆弱,再加一个大型第三方库会让构建系统压力剧增。
所以大多数复古游戏 demo 只能选择“手写最简物理”。手写本身没问题,但问题在于,很多开发者把物理写成“散装代码”:在主角的更新函数里判断一下是否落地,在敌人脚本里再判断一次,在碰撞回调里又做一次推挤。这种写法在只有两三个物体时勉强够用,一旦物体的数量上到几十个,或者需要处理移动平台、可破坏物、多角色交互,散装物理的 bug 量会指数级增长。
这不是某个人的代码能力问题,而是“没有把一个系统当系统设计”必然导致的结果。
PicoPhysics 这类单文件项目的核心价值,就是用一个文件把物理系统“形式化”了。虽然内部仍然只做最简单的事——加速度积分、速度更新、AABB 碰撞检测、位置修正——但它提供了统一的物理体管理接口和明确的时间步长逻辑。有了这层抽象,游戏逻辑只需要调用pico_add_body、pico_update,而不是在 10 个不同位置重复写“如果角色 y 坐标超过地面某条线就归位”这种 hack。
对复古平台来说,物理引擎不需要功能多全,更需要的是:可预测、可维护、可控制。单文件设计,正好对应了这三点。
2. N64、PSX、Dreamcast 的硬件约束对比
要理解为什么 PicoPhysics 这样的单文件物理库有价值,先得知道这三台主机各自“难”在哪里。我整理了一张对比表,重点看 CPU 主频、内存和浮点能力这三个指标:
| 平台 | CPU | 主频 | 内存 | 浮点能力 | 常用 Homebrew 工具链 |
|---|---|---|---|---|---|
| N64 | MIPS R4300i | 93.75 MHz | 4 MB RDRAM | 有 FPU | libdragon |
| PSX | MIPS R3000A | 33.87 MHz | 2 MB RAM | 无 FPU | PSn00bSDK |
| Dreamcast | SH-4 | 200 MHz | 16 MB RAM | 有 FPU | KallistiOS |
这三台机器的共同点是:即使最强的一台(Dreamcast),其 CPU 也远不如今天的任何入门级手机。更关键的是,它们的运行内存非常小,尤其是 PSX 的 2MB 和 N64 的 4MB,这意味着“物理系统中的对象数量”不可能像现代引擎里那样动辄上千。
逐台分析,你会发现每台机器的侧重点是不一样的:
N64 的 CPU 带 FPU,浮点数学可以正常写,但 4MB 内存决定了你的物理世界必须天然限定在很小规模。R4300i 虽然主频在当年算高,但要同时驱动渲染和物理逻辑,也不能随便浪。更麻烦的是,N64 的 RDRAM 延迟较高,内存访问模式对缓存不友好。
PSX 是最苛刻的平台。R3000A 没有 FPU,硬写float计算的话,编译器会生成软件浮点模拟代码,速度会慢很多倍。所以 PSX 上的物理引擎通常需要用定点数(fixed-point)实现,也就是用整数模拟小数。这个限制会深刻影响代码结构和数值范围设计。
Dreamcast 的 SH-4 自带 FPU,而且支持向量浮点指令,物理计算能力在三个平台中是最强的。16MB 内存也给开发者留了更多空间。但即便如此,Dreamcast 的硬件量级依然属于嵌入式级别,不能拿现代物理引擎的标准来要求。
所以“single file physics”这个描述,不只是简单的代码组织方式,它隐含了一个跨平台策略:物理核心需要足够小、足够独立,才能在这三种不同 CPU、不同浮点能力、不同内存上限的环境中通用。越少依赖,越容易在 PSX 这种严苛环境下做定点数替换;越短小,越容易被开发者通读全文件、按平台微调。
3. “单文件物理”不是偷懒,而是一种工程决策
很多人第一次看到“single file physics”会产生一个误解:这不就是偷懒,把所有代码塞进一个文件吗?
其实单文件库(single-header library)在 C 语言社区早就是成熟的工程形式了,最有名的就是 stb 系列库。它的核心做法是:
- 用一个
.h文件同时包含声明和实现; - 使用者在自己的一个
.c文件中先#define XXX_IMPLEMENTATION,再#include这个头文件,实现代码才会被真正编译; - 其他
.c文件只做普通#include,拿到的是纯声明,不会重复编译实现部分。
这种模式的工程收益,恰好对复古平台开发非常有意义。我们来看三个场景。
第一个场景是构建系统。复古平台工具链的 Makefile 往往很简单,多一个库就需要多一套链接参数、头文件目录、静态库路径。如果物理引擎本身就是一个.h文件,你只需要把它复制到include/目录,在 main.c 里写一句#define PICO_PHYSICS_IMPLEMENTATION然后#include,构建不用改任何东西。这对工具链不稳定的复古开发环境来说,是实打实的成本降低。
第二个场景是可审查性。物理引擎放在一个文件里,意味着任何人打开这个文件就能看到整个系统的代码路径:从体结构定义,到积分更新,到碰撞响应,全都在一个阅读上下文里。这对调试“为什么物体会抖动”“为什么穿透了”非常有利。你不需要在十几个源文件之间跳来跳去。
第三个场景是平台移植。PSX 没有 FPU,你很可能需要把浮点运算整体替换成定点数。单文件结构让这种替换变得非常直接,因为数学运算和碰撞逻辑都集中在一个文件里,你可以只对这个文件做定点化改造,游戏逻辑层的调用接口基本不变。
单文件物理库还天然避免了动态库链接、命名空间冲突、版本不匹配这一类问题。在嵌入式风格的复古游戏项目中,这些都是真实痛点。
所以,选择单文件不是“代码组织不规范”,而是主动选择了“嵌入式优先的可移植性”和“极低的集成摩擦”。
4. 一个复古动作游戏需要的最小物理核心
聊完了工程背景,现在看物理本身。一个动作游戏需要的最小物理系统,并不需要模拟真实的铰链、弹性形变、流体,也不一定要处理刚体旋转。我们需要的只是三个核心模块。
第一个是运动学积分。物理体的状态最少要有位置和速度。每一帧,我们需要根据速度更新位置,根据重力或受力更新速度。最常用也最稳定的积分方式是半隐式欧拉:先用重力更新速度,再用新速度更新位置。这个顺序虽然看起来简单,但比先更新位置再更新速度稳很多,能显著减少“越跳越高”类能量漂移问题。
第二个是碰撞检测。在复古平台游戏里,用 AABB(轴对齐包围盒)就够了,不需要凸多边形、网格体。AABB 的意思就是把每个物体简化成一个矩形:记录左上角坐标(x, y)和宽高(w, h)。两个 AABB 是否相交,只需要四组比较运算,非常快。如果一个 2D 动作游戏的主角是 16x24 像素的角色,地面是一段宽 320 高 16 的平台,用 AABB 完全可以表现跳跃落地、上下移动这类碰撞语义。
第三个是碰撞响应。当两个 AABB 重叠时,系统要把物体推出去,避免角色陷进地面。最简单有效的思路是:先判断“从哪个方向进入重叠”,然后只在垂直或水平方向做位置修正。对这个最小物理系统来说,一般处理“动态物体落在静态平台顶部”这个场景就够用了。更复杂的响应——比如反弹系数、摩擦、多方向旋转——可以在最小原型跑通之后再逐步加。
除了这三个模块,还要决定一个机制:固定时间步长还是可变时间步长。
现代引擎里通常会推荐固定时间步长,因为物理模拟在不同帧率下要保持确定性。对复古平台来说更重要,因为 N64 和 PSX 的游戏循环很容易受到渲染压力的影响,真机帧率可能在 20 到 60 之间波动。如果物理用可变步长,同一个跳跃在不同帧率下会出现不同高度和落点,手感会非常奇怪。固定步长的做法是:渲染循环每帧传一个 dt,物理内部把它按固定步长(如 1/60 秒)切成整数块,最多补 4 步,不足一个步长的余量累计到下一帧。
这种设计能保证:无论渲染帧率怎么波动,物理世界中的时间推进节奏是稳定的。手感可预测,bug 也更容易复现。
5. 完整示例:用 C 写一个单文件物理核心
下面给出一份可以直接运行的 C 语言实现,风格参考 stb 单文件库。你不用把它当成 PicoPhysics 的源码,而是当成“最小物理核心”的参考实现。
先把物理核心写在pico_physics.h里。文件同时包含声明和实现,只要在使用方某个.c文件顶部定义PICO_PHYSICS_IMPLEMENTATION,实现代码才会被编译。
// 文件:pico_physics.h #ifndef PICO_PHYSICS_H #define PICO_PHYSICS_H #define PICO_MAX_BODIES 64 #define PICO_GRAVITY 9.81f #define PICO_FIXED_STEP 0.016f #define PICO_MAX_STEPS 4 typedef struct { float x, y; float vx, vy; float w, h; int active; int is_static; int layer; } PicoBody; void pico_init(void); int pico_add_body(float x, float y, float w, float h, int is_static); void pico_body_set_velocity(int id, float vx, float vy); void pico_update(float dt); #ifdef PICO_PHYSICS_IMPLEMENTATION static PicoBody s_bodies[PICO_MAX_BODIES]; static int s_count = 0; static float s_accum = 0.0f; void pico_init(void) { int i; for (i = 0; i < PICO_MAX_BODIES; ++i) { s_bodies[i].active = 0; s_bodies[i].is_static = 0; s_bodies[i].layer = 0; s_bodies[i].x = 0.0f; s_bodies[i].y = 0.0f; s_bodies[i].vx = 0.0f; s_bodies[i].vy = 0.0f; s_bodies[i].w = 0.0f; s_bodies[i].h = 0.0f; } s_count = 0; s_accum = 0.0f; } int pico_add_body(float x, float y, float w, float h, int is_static) { int id; if (s_count >= PICO_MAX_BODIES) return -1; id = s_count++; s_bodies[id].x = x; s_bodies[id].y = y; s_bodies[id].w = w; s_bodies[id].h = h; s_bodies[id].vx = 0.0f; s_bodies[id].vy = 0.0f; s_bodies[id].active = 1; s_bodies[id].is_static = is_static; s_bodies[id].layer = 0; return id; } void pico_body_set_velocity(int id, float vx, float vy) { if (id < 0 || id >= s_count) return; s_bodies[id].vx = vx; s_bodies[id].vy = vy; } static int pico_overlap(const PicoBody *a, const PicoBody *b) { return (a->x < b->x + b->w) && (a->x + a->w > b->x) && (a->y < b->y + b->h) && (a->y + a->h > b->y); } void pico_update(float dt) { int i, j; s_accum += dt; if (s_accum > PICO_FIXED_STEP * PICO_MAX_STEPS) { s_accum = PICO_FIXED_STEP * PICO_MAX_STEPS; } while (s_accum >= PICO_FIXED_STEP) { // 模式:半隐式欧拉积分 for (i = 0; i < s_count; ++i) { PicoBody *body = &s_bodies[i]; if (!body->active || body->is_static) continue; body->vy += PICO_GRAVITY * PICO_FIXED_STEP; body->x += body->vx * PICO_FIXED_STEP; body->y += body->vy * PICO_FIXED_STEP; } // 动态体与静态体的碰撞响应(简化版:只处理从上方落下) for (i = 0; i < s_count; ++i) { PicoBody *a = &s_bodies[i]; if (!a->active || a->is_static) continue; for (j = 0; j < s_count; ++j) { PicoBody *b = &s_bodies[j]; if (i == j || !b->active || !b->is_static) continue; if (pico_overlap(a, b)) { float dy = (a->y + a->h) - b->y; if (dy < a->h && a->vy >= 0.0f) { a->y = b->y - a->h; a->vy = 0.0f; } } } } s_accum -= PICO_FIXED_STEP; } } #endif /* PICO_PHYSICS_IMPLEMENTATION */ #endif /* PICO_PHYSICS_H */这个实现里,最关键的设计有两点。
第一,物理体使用固定数组s_bodies[PICO_MAX_BODIES],而不是malloc动态分配。复古平台的堆管理器本来就不稳定,动态分配很可能引入碎片问题。固定数组有上限但完全可控,这符合嵌入式开发的最佳实践。
第二,pico_update内部使用了累加器和固定步长。s_accum累加真实帧间隔,但只在while (s_accum >= PICO_FIXED_STEP)时执行物理步骤,并且最多执行PICO_MAX_STEPS次,防止某帧卡顿后物理追帧追到离谱。
接下来写一个调用示例。把物理核心接入游戏逻辑的方法非常简单。
// 文件:main_sample.c #define PICO_PHYSICS_IMPLEMENTATION #include "pico_physics.h" static int s_player_id; void game_init(void) { pico_init(); int player = pico_add_body(20.0f, 100.0f, 16.0f, 24.0f, 0); int ground = pico_add_body(0.0f, 180.0f, 320.0f, 16.0f, 1); int wall = pico_add_body(300.0f, 0.0f, 16.0f, 196.0f, 1); pico_body_set_velocity(player, 40.0f, 0.0f); s_player_id = player; } void game_frame(float dt) { pico_update(dt); // 渲染阶段:读取物理体位置并绘制角色/平台 // 例子:从 pico_physics.h 中导出只读接口再取得坐标 // 这里省略取坐标的代码,实际项目可增加 pico_body_x(id)、pico_body_y(id) }更完整的用法是给pico_physics.h增加两个只读接口,方便渲染层读取位置:
float pico_body_x(int id) { if (id < 0 || id >= s_count) return 0.0f; return s_bodies[id].x; } float pico_body_y(int id) { if (id < 0 || id >= s_count) return 0.0f; return s_bodies[id].y; }这样渲染层就能直接拿到主角和平台的位置,绘制精灵时只需要做整数坐标转换。一个最基础的物理演示就完成了。
6. 定点数:PSX 无 FPU 环境的移植方案
上面的示例全部使用float,在 N64 和 Dreamcast 上可以正常跑。但如果在 PSX 上直接使用这段代码,编译器会生成软件浮点模拟,导致物理计算速度严重下降。
解决方法是改成定点数(fixed-point)。定点数的核心思想是:用一个整数表示小数,通过约定“低 12 位或低 16 位是小数部分”来模拟浮点运算。
// 文件:fixed_point.h #include <stdint.h> typedef int32_t fixed_t; #define FIXED_SHIFT 12 #define FIXED_ONE (1 << FIXED_SHIFT) static inline fixed_t float_to_fixed(float v) { return (fixed_t)(v * FIXED_ONE); } static inline float fixed_to_float(fixed_t v) { return (float)v / FIXED_ONE; } static inline fixed_t fixed_mul(fixed_t a, fixed_t b) { return (fixed_t)(((int64_t)a * (int64_t)b) >> FIXED_SHIFT); }在定点数世界里,加减法可以直接用整数加减,只要两个数的定标一致。乘法则需要一个“先放缩再移位”的过程,否则会把的小数位相乘后溢出。上面代码用int64_t做中间变量,避免 32 位乘法直接溢出,再右移 12 位恢复定标。
如果要在 PSX 上做物理,重力加速度、速度、位置、AABB 碰撞的坐标全部可以从float替换成fixed_t。碰撞检测仍然是四组比较运算:
static int pico_overlap_fixed(const PicoBodyFixed *a, const PicoBodyFixed *b) { return (a->x < b->x + b->w) && (a->x + a->w > b->x) && (a->y < b->y + b->h) && (a->y + a->h > b->y); }差别只在于类型从float变成了fixed_t,运算逻辑完全一致。这也反过来证明了单文件物理模块在平台移植上的优势:定点化改造只需要动pico_physics.h一个文件,游戏逻辑层根本不感知底层数值类型的变化。
不过一定要记住,定点数需要确认数值范围。用FIXED_SHIFT = 12时,每个fixed_t的小数精度是1/4096,整数部分最多能表示约 524288,对复古平台 2D 游戏的坐标范围来说绰绰有余。如果你的项目坐标范围更大或需要更高精度,可以调整FIXED_SHIFT,但精度和范围是此消彼长的关系,要在项目设计阶段确定好。
7. 接入 N64、PSX、Dreamcast 工具链的具体姿势
PicoPhysics 这类单文件物理库接入各平台工具链时,有个共同原则:不要额外编译库文件,直接把pico_physics.h放进你的源码目录,在逻辑入口文件里用#define PICO_PHYSICS_IMPLEMENTATION实例化实现即可。
下面分别给三个平台的接入说明。以 N64 的 libdragon 为例,假设项目结构如下:
n64_project/ include/pico_physics.h src/main.c Makefilemain.c顶部需要:
#define PICO_PHYSICS_IMPLEMENTATION #include "pico_physics.h"编译时,Makefile 不需要为物理库单独设置-l和头文件目录,只要把include路径加进编译器参数。libdragon 项目的 Makefile 通常这样写:
TARGET = game SRCS = src/main.c CFLAGS = -Iinclude -Wall -O2 include $(N64_INSTALL)/libdragon/libdragon.inc然后执行:
make如果要生成可烧录的 ROM,libdragon 会调用make_rom工具生成.z64文件。整个过程和普通项目没有任何区别,物理库不会引入额外构建链负担。
PSX 的 PSn00bSDK 接入方式类似。在Makefile里链接-lpsn00b之类的库,然后正常编译即可。关键区别是,PSX 版本需要把物理核心改成定点数版本,并且释放内存的策略要保守,固定体数组的上限调低一些,比如从 64 降到 32,给 PSX 的 2MB 内存留更多余地。
Dreamcast 的 KallistiOS 项目通常也是标准的 GNU Make 流程。由于 Dreamcast 的 SH-4 有 FPU,物理核心可以直接保留浮点版本。KallistiOS 的dc-setup环境配好后,在Makefile中写入普通规则即可。
# Dreamcast + KallistiOS 编译示例 makemake完成后,KallistiOS 会输出.elf或.bin文件,再交给烧录工具打包成 CDI 或使用模拟器加载。
这段流程想说明一件事:单文件物理库的最大收益,不是它帮你把物理写完了,而是它在你和硬件平台之间砍掉了“第三方依赖导入”这一层复杂度。你不再需要研究某个物理库在你这个定制工具链下能否编译,因为整个物理系统就一个文件,你随时可以打开它、调整它、用你熟悉的方式构建它。
8. 复古游戏中使用单文件物理的常见问题与排查
实际动手时,你会遇到一些很典型的坑。下面这张表总结了最常见的问题和对应的排查思路。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 角色直接穿过平台 | 物理更新速度不够,单帧移动距离超过平台厚度 | 打印物体每帧位移,检查是否超过碰撞体尺寸 | 启用固定步长,禁止单步位置变化过大,或限制最大速度 |
| 角色卡在地板里不断抖动 | 碰撞响应与重力在同一帧反复作用,位置修正后又立刻被重力拉回 | 查看位置 y 坐标是否在每个固定步长间来回振荡 | 碰撞穿透修正后,再保持该帧不重复应用重力,或在落地状态清除竖直速度 |
| 不同平台运行结果不一致 | 浮点精度差异,或帧率不一致导致积分步数不同 | 在两个模拟器里逐帧打印坐标 | 统一使用定点数,并固定物理步长 |
| PSX 上物理极慢 | 没有 FPU,使用了软件浮点模拟 | 用编译器的 profiling 功能查看浮点运算占比 | 将 float 换成定点数,避免软件浮点模拟 |
| 多文件包含同一个单文件头,导致物理状态不同步 | 在多个.c文件里都定义了PICO_PHYSICS_IMPLEMENTATION | 检查所有包含该头的文件 | 只在唯一一个.c文件中定义PICO_PHYSICS_IMPLEMENTATION,其余文件只#include |
| 物理体数量多了之后明显变卡 | AABB 碰撞检测是 O(n^2) 复杂度 | 统计每帧碰撞检测次数 | 缩小物理体数量上限,或对静态体做简单空间网格索引 |
| 角色碰到墙后沿墙滑动时卡顿 | 水平方向和垂直方向的碰撞响应没有分开处理 | 观察坐标变化过程 | 先解析垂直方向,再解析水平方向,分别修正位置 |
最需要重视的是“多文件重复实例化”这个问题。单文件库的设计初衷是“在一个地方生成实现,其他地方只取声明”,如果你在一个项目里有 3 个.c文件都写了#define PICO_PHYSICS_IMPLEMENTATION,那么每个