公司动态

Picophysics:为N64/PSX/DC老平台打造单文件物理引擎

📅 2026/8/28 17:44:10
Picophysics:为N64/PSX/DC老平台打造单文件物理引擎
先聊一个比较实际的问题给 N64、PSX、Dreamcast 这类老主机写游戏时物理系统到底应该怎么落地很多从现代引擎入门的朋友第一次接触 retrogame 开发时往往会有一种错觉——物理嘛无非是加个重力、算个碰撞引擎里拉一下组件就行了。但当你真正切到老平台的工具链时才会发现跑在 33MHz CPU、2MB 内存上的 C 代码跟你熟悉的现代引擎完全是两个世界。更麻烦的是网上关于这些老平台物理系统的资料非常零散不是讲模拟器滤镜就是讲贴图压缩真正能落到代码层面的教程少之又少。Picophysics 这个项目走了一条很特别的路线把一整套基础物理封装成单个 C 文件专门面向 N64、PSX、Dreamcast 这类资源极度受限的平台。本文就围绕这个思路完整拆解单文件物理库的设计目标、核心原理、集成步骤和分平台适配问题。如果你正在做复古风游戏、老平台移植或者单纯想在低配环境下跑出可控的物理效果这篇文章应该能帮你省不少时间。读完你可以掌握为什么老平台物理要单独设计、单文件库应该怎么集成、固定时间步长与定点数怎么选以及各个平台的真实约束在哪里。1. 什么是 Picophysics给老平台做物理为什么这么难1.1 老平台做物理的痛点先明确一个背景N64、PSX、DC 这三台主机虽然被统称为“复古平台”但它们的硬件差异非常大。简单列一下关键配置平台CPU主频内存浮点能力Nintendo 64MIPS R430093.75 MHz4 MB RDRAM自带 FPU但开发时常用定点数PlayStationMIPS R3000A33.87 MHz2 MB RAM无硬件 FPU浮点开销极大DreamcastSH-4200 MHz16 MB RAM有 FPU相对宽裕在这个表里能读出几件事内存按 MB 算不是按 GB 算。一个物理系统如果每个物体都挂一堆浮点缓存、网格数据内存直接爆掉。CPU 主频低。现代引擎里一帧几千个物理体的规模在老平台上想都不要想单个物体数量要控制在几十到几百这个量级。浮点运算不可靠。PSX 没有 FPU浮点运算是编译器帮你在 CPU 上模拟出来的速度慢且不同硬件上误差表现还不一样。所以老平台物理几乎绕不开定点数fixed-point方案。1.2 Picophysics 的定位与设计思路Picophysics 从名称上就能看出两个关键词pico和physics。pico在拉丁语里是“极小”的意思用在项目命名上通常暗示“微型实现”。这类项目的核心目标不是做一套功能齐全的通用物理引擎而是在满足基本物理表现的前提下把代码体积、内存占用、计算量压到最小。它的设计思路可以概括成几点单文件分发。整个物理库只提供一个.c文件和一个配套头文件甚至在某些实现中可以直接把头文件写成header-only风格。这样做的最大好处是集成成本极低不用配置复杂链接库直接#include进工程即可。无外部依赖。不依赖标准库之外的任何东西内存分配、数学运算、碰撞检测全部自包含。原因是老平台工具链往往比较“原始”标准库实现差异大依赖越少越稳妥。面向 C 语言。老平台开发几乎以 C 为主C 在老工具链上虽然能用但异常、模板、STL 这些东西在嵌入式和复古平台环境下很容易出问题。单文件 C 库对工具链的兼容性最好。可裁剪配置。通过宏开关关闭不需要的模块比如不需要碰撞体就关掉碰撞检测不需要刚体旋转就关掉角速度减少最终二进制体积。1.3 单文件库模式有什么好处单文件库single-file library在 C 社区里其实是非常成熟的一种分发方式最著名的例子是 stb 系列。它的核心约定是普通编译时文件只暴露声明只有在你自己的某个源文件里定义特殊宏后再包含它才会展开实现。这种模式对老平台开发尤其友好拷贝即用。把picophysics.h放进项目目录#include就行不需要包管理器不需要配置 include path 之外的链接路径。便于交叉编译。老平台工具链版本各异少一个依赖就少一类兼容问题。方便魔改。物理参数、碰撞逻辑不满意直接打开那个文件改改完重新编译即可不需要在多个抽象层之间来回跳。下面是一段典型的单文件库使用方式// 任意一个 .c 文件中先定义实现宏 #define PICO_PHYSICS_IMPLEMENTATION #include picophysics.h而在其他源文件里只需要#include picophysics.h这样整份实现只编译一次不会出现多重定义问题。后面的实战部分还会再讲这个机制。2. 环境准备与工具链2.1 各平台推荐的开发工具链在写物理代码之前先确保有可用的构建环境。老平台开发通常使用社区维护的交叉编译工具链这几个是你一定会碰到的平台常用工具链说明N64libdragon / libultralibdragon 用 GCC 交叉编译适合现代开发环境libultra 是官方 SDK授权限制较多PlayStation开源 PSX SDK如 psyq / psxsdkPSYQ 是老牌商业工具链的兼容版本社区也有完整开源实现DreamcastKallistiOS目前最主流的 DC 开发环境基于 GCC 交叉编译这些工具链的安装方式各有差异不同系统上也可能有不同的依赖要求。建议首次接触时先在模拟器上跑通官方的 hello world 示例确认画面输出和手柄输入正常再引入物理库。否则物理代码写好了却分不清是自己写的 bug 还是渲染环境的问题排查起来会很折磨。本文的示例代码重点在物理逻辑本身不绑定具体平台 API因此你可以把这段逻辑直接套用到任何支持 C 编译的平台工程中。2.2 示例工程目录结构为了便于理解建议工程按下面这样组织game/ ├── Makefile ├── src/ │ ├── main.c │ ├── game.c │ ├── game.h │ └── picophysics.h物理头文件直接放在src/下不需要单独建 lib 目录。如果你的项目已经引入了第三方库把picophysics.h和所有第三方头文件放在一起管理即可。如果你用的是 CMake 构建也可以把项目写得更规范但老平台工具链大部分仍然以 Makefile 为主。为了跟真实开发环境保持一致本文默认使用 Makefile 风格的组织方式。3. 核心概念受限环境下的物理引擎设计3.1 固定时间步长与帧率控制老平台游戏的帧率通常不稳定。N64 上某些场景可能稳定 30 帧遇到复杂场景掉到 20 帧也很常见。如果物理模拟直接跟随渲染帧率同一个球的运动轨迹会随着掉帧而改变有时会直接穿过地面有时会突然加速。解决办法是固定时间步长fixed timestep。即不管实际渲染帧率是多少物理模拟始终按照固定的时间间隔更新例如每 1/60 秒更新一次。渲染帧会累积一个时间残差残差达到一个步长就补一次物理更新。伪代码如下#define FIXED_DT (1.0f / 60.0f) static float accumulator 0.0f; void game_update(float frameDelta) { accumulator frameDelta; while (accumulator FIXED_DT) { physics_step(FIXED_DT); // 固定步长更新物理 accumulator - FIXED_DT; } }这样做的好处是物理表现与渲染帧率解耦掉帧不会导致物理穿透或弹跳异常。多次采样之间结果可复现同一输入序列每次运行结果一致方便测试。碰撞检测更容易保持稳定因为每次移动距离被限制在固定范围内。需要注意的是while循环要设置最大迭代上限防止渲染卡顿时物理循环追帧追到停不下来俗称“死亡螺旋”。建议限制一次帧更新最多执行 3 到 5 次物理步进。3.2 定点数还是浮点数这是老平台物理绕不开的决策点。PSX没有 FPU浮点运算全部走软浮点库速度慢且代码体积大。强烈建议使用定点数。N64虽然内核自带 FPU但早期开发中为了追求稳定和性能很多游戏仍然使用定点数。DreamcastSH-4 自带 FPU性能相对宽裕但物理体数量多时仍有压力。定点数的做法是把一个float或者double用整数来表示。以最常见的 Q16.16 格式为例一个 32 位整数的高 16 位表示整数部分低 16 位表示小数部分可以表达的精度约为 0.000015。基本的定点数运算typedef int32_t fixed_t; #define FIXED_SHIFT 16 #define FIXED_ONE (1 FIXED_SHIFT) static inline fixed_t fix_from_float(float f) { return (fixed_t)(f * FIXED_ONE); } static inline fixed_t fix_add(fixed_t a, fixed_t b) { return a b; } static inline fixed_t fix_mul(fixed_t a, fixed_t b) { // 两个定点数相乘结果需要右移 16 位恢复比例 return (fixed_t)(((int64_t)a * b) FIXED_SHIFT); } static inline fixed_t fix_div(fixed_t a, fixed_t b) { return (fixed_t)(((int64_t)a FIXED_SHIFT) / b); }这里有两个关键点乘法必须用 64 位中间量。两个 32 位定点数相乘会变成 64 位结果直接赋给 32 位变量会丢失高位。上面的fix_mul先转int64_t再右移就是为了避免溢出。除法先左移再除。直接a / b会丢失小数部分所以先把被除数左移 16 位再除。如果你的目标平台是 Dreamcast 这种带 FPU 的平台浮点直接用问题也不大。但如果你打算同时兼容 PSX最好从架构层面就统一成定点数避免后期做双版本维护。3.3 碰撞检测与响应老平台上的碰撞检测主流方案是 AABB轴对齐包围盒和球体碰撞。它们有两个共同优点计算量小实现简单。AABB 重叠检测typedef struct { fixed_t min_x; fixed_t min_y; fixed_t max_x; fixed_t max_y; } aabb_t; int aabb_overlap(const aabb_t *a, const aabb_t *b) { if (a-max_x b-min_x) return 0; if (a-min_x b-max_x) return 0; if (a-max_y b-min_y) return 0; if (a-min_y b-max_y) return 0; return 1; }球体碰撞更简单检测两个圆心距离是否小于半径之和。为了避免开平方直接比较距离平方和半径平方typedef struct { fixed_t x; fixed_t y; fixed_t radius; } circle_t; int circle_overlap(const circle_t *a, const circle_t *b) { fixed_t dx a-x - b-x; fixed_t dy a-y - b-y; fixed_t dist_sq fix_mul(dx, dx) fix_mul(dy, dy); fixed_t r_sum a-radius b-radius; return dist_sq fix_mul(r_sum, r_sum); }碰撞响应的基本原则是先分离后反弹。意思是在检测到碰撞后先把物体沿最小穿透方向推出碰撞区域再计算速度变化。如果顺序反过来物体可能会在下一帧仍然残留在碰撞区域内导致反复检测、抖动。4. 完整实战在项目中集成单文件物理4.1 引入头文件与配置宏先定义物理库的配置文件。单文件库通常会提供一段配置区允许你通过宏开关决定启用哪些功能例如是否启用旋转、是否启用摩擦、是否使用定点数等。假设picophysics.h顶部有类似这样的配置区// 文件路径src/picophysics.h节选 #ifndef PICO_PHYSICS_H #define PICO_PHYSICS_H // 用户可以在包含本头文件之前定义这些宏 #ifndef PICO_USE_FIXED_POINT #define PICO_USE_FIXED_POINT 1 #endif #ifndef PICO_MAX_BODIES #define PICO_MAX_BODIES 128 #endif #ifndef PICO_ENABLE_ROTATION #define PICO_ENABLE_ROTATION 0 #endif // ...类型声明、函数声明... #endif注意单文件库的分发约定你需要在某个源文件里定义PICO_PHYSICS_IMPLEMENTATION然后包含头文件实现代码才会展开。推荐单独建一个源文件做这件事// 文件路径src/pico_physics_impl.c #define PICO_PHYSICS_IMPLEMENTATION #include picophysics.h其余文件正常#include picophysics.h即可。4.2 初始化物理世界物理引擎通常需要先创建一个“世界”用来管理所有物体。下面是一个简化版初始化逻辑// 文件路径src/game.c #include picophysics.h static struct { fixed_t gravity_x; fixed_t gravity_y; int body_count; pico_body_t bodies[PICO_MAX_BODIES]; } world; void world_init(void) { world.gravity_x 0; world.gravity_y fix_from_float(-9.8f); // 向下重力 world.body_count 0; } int world_spawn_box(fixed_t x, fixed_t y, fixed_t w, fixed_t h) { if (world.body_count PICO_MAX_BODIES) { return -1; } pico_body_t *body world.bodies[world.body_count]; body-type PICO_BODY_DYNAMIC; body-pos_x x; body-pos_y y; body-vel_x 0; body-vel_y 0; body-aabb.min_x x; body-aabb.min_y y; body-aabb.max_x x w; body-aabb.max_y y h; body-active 1; return world.body_count; }这里展示了一个关键点PICO_MAX_BODIES决定了内存占用上限。128 个物体时每个物体如果只保存坐标、速度、包围盒内存开销非常小完全可以接受。如果你需要更多物体可以在包含头文件前重新定义这个宏。4.3 每帧更新物理更新是整个引擎的核心。每一步要做三件事对每个动态物体施加重力更新速度。根据速度更新位置。检测物体与地面、墙壁以及物体之间的碰撞做分离和反弹。一个简化的步进函数如下// 文件路径src/game.c static void integrate_velocity(fixed_t dt) { for (int i 0; i world.body_count; i) { pico_body_t *body world.bodies[i]; if (!body-active || body-type ! PICO_BODY_DYNAMIC) { continue; } body-vel_x fix_add(body-vel_x, fix_mul(world.gravity_x, dt)); body-vel_y fix_add(body-vel_y, fix_mul(world.gravity_y, dt)); } } static void integrate_position(fixed_t dt) { for (int i 0; i world.body_count; i) { pico_body_t *body world.bodies[i]; if (!body-active || body-type ! PICO_BODY_DYNAMIC) { continue; } body-pos_x fix_add(body-pos_x, fix_mul(body-vel_x, dt)); body-pos_y fix_add(body-pos_y, fix_mul(body-vel_y, dt)); body-aabb.min_x body-pos_x; body-aabb.min_y body-pos_y; body-aabb.max_x body-pos_x body-w; body-aabb.max_y body-pos_y body-h; } } void physics_step(fixed_t dt) { integrate_velocity(dt); integrate_position(dt); solve_collisions(); }dt是固定步长通常等于FIXED_ONE / 60。这里之所以用定点数表示 dt是为了跟物体位置保持一致的单位避免混用导致精度问题。4.4 渲染与调试输出物理层不负责渲染它只负责更新物体状态。游戏主循环中应该把物理计算和渲染分开// 文件路径src/main.c #include picophysics.h extern void world_init(void); extern void physics_step(pico_fixed_t dt); // 简单的主循环示意具体平台 API 以实际工具链为准 void game_loop(float frame_delta) { static float accumulator 0.0f; accumulator frame_delta; // 每 1/60 秒固定步进一次 while (accumulator (1.0f / 60.0f)) { physics_step(fix_from_float(1.0f / 60.0f)); accumulator - 1.0f / 60.0f; } // 渲染当前所有物体 for (int i 0; i body_count(); i) { const pico_body_t *body get_body(i); // 在这里调用平台绘图 API画出 body-aabb 或 body-pos } }调试阶段最常见的做法是把物体位置用 printf 输出到串口或文本窗口先把物理逻辑跑对了再做图形渲染。例如void debug_print_bodies(void) { for (int i 0; i body_count(); i) { const pico_body_t *body get_body(i); printf(body %d: pos(%d, %d) vel(%d, %d)\n, i, (int)(body-pos_x 16), (int)(body-pos_y 16), (int)(body-vel_x 16), (int)(body-vel_y 16)); } }这里把定点数右移 16 位相当于换算成整数坐标输出。调试信息能帮你快速判断是物理逻辑错误还是渲染坐标换算错误。5. 分平台适配要点5.1 N64 上的适配N64 的内存是 4 MB RDRAM加上扩展包可以到 8 MB但即便如此留给物理系统的内存仍然非常有限。在 N64 上主要需要注意利用 RSP/RDP 分担不了物理计算。N64 的图形协处理器RSP主要负责变换和渲染物理逻辑基本全部落在 CPU 上。换句话说物理代码要写成纯 CPU 友好的逻辑避免大量分支和跳转。缓存友好性。把物体数组放在连续内存里遍历时直接顺序访问比链表结构快得多。上面示例中的静态数组这种设计就是为这种场景准备的。定点数选择建议。N64 有 FPU但如果项目不打算为浮点和定点各维护一套逻辑统一用定点数是更稳妥的选择。5.2 PSX 上的适配PSX 是最考验优化功底的目标平台原因在于CPU 只有 33.87 MHz没有 FPU定点数几乎是唯一合理选择。内存只有 2 MB物理缓冲区要克制。PSX 的分辨率低物理表现不需要特别精细物体数量控制在几十个即可满足大多数玩法需求。在 PSX 上尤其要注意避免在物理热循环里调用除法。定点数除法虽然通过左移再除实现了但本质上仍然是整数除法在 PSX 上开销偏高。可以预先计算一些常量的倒数用乘法代替除法。比如地面反弹系数、摩擦系数都可以在初始化时转成定点数保存起来运行时只做乘法和加法。5.3 Dreamcast 上的适配DC 在三者中硬件条件最好SH-4 有 FPU16 MB 内存也宽裕很多。如果你只想做 DC 独占项目直接用浮点问题不大。如果计划做多平台复用仍然建议在代码层面对数学运算做一层薄薄的封装这样在 DC 上可以展开成浮点指令在 PSX 上展开成定点数。封装示例#if PICO_USE_FIXED_POINT typedef fixed_t real_t; #define real_from_float(f) fix_from_float(f) #define real_mul(a, b) fix_mul(a, b) #else typedef float real_t; #define real_from_float(f) (f) #define real_mul(a, b) ((a) * (b)) #endif有了这一层大部分物理代码可以做到平台无关移植时只需要切换编译宏。6. 常见问题与排查思路老平台开发中问题往往不是“代码写不出来”而是“代码看起来对但现象不对”。把最常见的问题整理成下面这张表问题现象常见原因解决思路物体直接穿过地面单帧位移过大碰撞检测没有做连续检测缩小固定步长或增加碰撞检测时的分段移动物体在地面附近抖动碰撞分离后仍残留在碰撞区域内下一帧又重复分离先分离再反弹并给分离方向加一个小的穿透容差不同机器上弹跳高度不同浮点运算在不同硬件上的舍入误差统一使用定点数避免浮点误差累积固定时间步长循环卡死渲染帧率骤降物理循环追帧过多限制每帧最多执行 3 到 5 次物理步进定点数乘法结果异常32 位乘法溢出或忘记右移恢复比例统一使用fix_mul不要直接写a * b物体数量一多就掉帧物理体之间两两检测O(n²) 复杂度引入空间网格或只检测相邻物体编译时出现重复定义多个源文件都定义了实现宏单独建一个源文件定义实现宏其它文件只包含头文件这里面最值得展开的是“物体直接穿过地面”这个问题。它的根因并不是碰撞检测写错了而是位置更新步长太大。假设物体下落速度为每秒 1000 像素固定步长是 1/60 秒那单帧位移约 16.7 像素。如果地面厚度只有 8 像素物体在这一帧内可能直接从地面一侧跳到了另一侧AABB 重叠检测根本捕捉不到碰撞。解决办法通常有两种缩小固定步长比如改为 1/120 秒但会增加每帧物理计算量。对移动距离做分段检测把一帧位移拆成多段小位移逐段检测碰撞。在老平台上方案 1 开销增加不明显因为物理体数量本身不大方案 2 的实现复杂度更高建议先用方案 1确实不行再优化。7. 最佳实践与工程建议7.1 从“能跑”到“稳跑”物理引擎是游戏里最容易出“随机 bug”的部分因为它在每一帧都在做数值计算。几条工程建议直接抄走物理更新与渲染严格分离。不要在渲染回调里直接改物理体坐标否则帧率波动会直接影响物理表现。所有物理参数集中配置。重力、摩擦、反弹系数、步长、最大速度全部集中到头文件的宏或配置区里方便统一调整而不是散落在各个函数里。限制最大速度。物体速度过大不仅容易穿透碰撞体还会让定点数溢出。在更新速度后做一个clamp是成本极低、效果极好的保护。优先使用静态数组。老平台环境不稳定malloc可能导致内存碎片或堆耗尽。物理体数量固定用静态数组最安全。物理逻辑保持确定性。同样的输入序列应产生同样的结果这样你才能放心地做录像回放和自动化测试。7.2 碰撞体的简化建模很多玩法根本不需要复杂碰撞体。一个平台跳跃游戏玩家和敌人用 AABB 就够一个弹球玩法圆球碰撞更合适。不要把碰撞精度当成第一目标先把游戏性跑起来。如果确实需要复杂地形优先考虑把地面拆成若干静态 AABB而不是用多边形碰撞。静态物体不参与积分只参与碰撞检测计算量会小很多。7.3 安全边界与内存规划老平台没有内存保护越界写往往不是报错而是悄悄破坏其他数据。给物理系统规划内存时建议记住估算最大物体数量时留 20% 到 30% 的余量。在调试版本里对数组索引做边界检查发布版本再关掉。如果物理系统和渲染系统共用内存物理缓冲区单独划分避免相互污染。7.4 测试与回归物理代码改起来风险很高一个参数改动可能影响整个场景表现。建议做两件事准备一个最小复现用例比如“一个小球从固定高度落下”每次修改后先跑这个用例确认弹跳高度和落地时间没变化。记录关键场景的物理输出日志对比改动前后的差异而不是靠肉眼观察画面。8. 总结回到最开始的问题给 N64、PSX、DC 这类老平台写物理到底应该怎么做Picophysics 给出的答案是用单文件、无依赖、可裁剪的微型物理库把内存和算力消耗控制在平台承受范围内。这篇文章从概念到实战讨论了如下几个核心点老平台硬件约束决定了物理设计必须克制固定时间步长是保证物理稳定的基础定点数是兼容全部目标平台的更稳妥选择单文件库模式则让集成和魔改都变得非常简单。如果你准备在自己的复古平台项目里加入物理我的建议是从这里开始先跑通固定时间步长的空循环再放一个小球做自由落体确认轨迹符合预期后逐步加地面碰撞、物体间碰撞、摩擦系数。每加一个功能就跑一遍回归用例。这样你的物理系统会一直处在一个可验证、可回退的状态。如果你正在做老平台游戏或者对单文件、低资源占用的物理实现感兴趣可以到社区里搜索 Picophysics 的最新源码结合实际工程阅读。源码中的参数表和示例项目往往比任何博客教程都有说服力。希望这篇文章能帮你少踩几个坑顺利把物理跑在你的目标平台上。