拓冰建站拓冰建站
首页 / 资讯中心 / 正文

复古主机游戏开发:单文件物理引擎如何适配N64、PSX与Dreamcast

复古主机平台的游戏开发这几年热度一直不低。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但内存只有 4MBPSX 直接没有 FPU浮点运算在软件模拟下慢到不可接受。复古平台的基础工具链本身比较“原始”交叉编译、链接、ROM 打包的流程往往很脆弱再加一个大型第三方库会让构建系统压力剧增。所以大多数复古游戏 demo 只能选择“手写最简物理”。手写本身没问题但问题在于很多开发者把物理写成“散装代码”在主角的更新函数里判断一下是否落地在敌人脚本里再判断一次在碰撞回调里又做一次推挤。这种写法在只有两三个物体时勉强够用一旦物体的数量上到几十个或者需要处理移动平台、可破坏物、多角色交互散装物理的 bug 量会指数级增长。这不是某个人的代码能力问题而是“没有把一个系统当系统设计”必然导致的结果。PicoPhysics 这类单文件项目的核心价值就是用一个文件把物理系统“形式化”了。虽然内部仍然只做最简单的事——加速度积分、速度更新、AABB 碰撞检测、位置修正——但它提供了统一的物理体管理接口和明确的时间步长逻辑。有了这层抽象游戏逻辑只需要调用pico_add_body、pico_update而不是在 10 个不同位置重复写“如果角色 y 坐标超过地面某条线就归位”这种 hack。对复古平台来说物理引擎不需要功能多全更需要的是可预测、可维护、可控制。单文件设计正好对应了这三点。2. N64、PSX、Dreamcast 的硬件约束对比要理解为什么 PicoPhysics 这样的单文件物理库有价值先得知道这三台主机各自“难”在哪里。我整理了一张对比表重点看 CPU 主频、内存和浮点能力这三个指标平台CPU主频内存浮点能力常用 Homebrew 工具链N64MIPS R4300i93.75 MHz4 MB RDRAM有 FPUlibdragonPSXMIPS R3000A33.87 MHz2 MB RAM无 FPUPSn00bSDKDreamcastSH-4200 MHz16 MB RAM有 FPUKallistiOS这三台机器的共同点是即使最强的一台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如果要生成可烧录的 ROMlibdragon 会调用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那么每个
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门