ammo.js 与 Cannon.js 对比:Web 物理引擎选型指南

作者:像素猫时间:2026-11-02 10:27:15浏览:649

深度对比 ammo.js 与 Cannon.js(cannon-es)的底层架构、计算性能、包体积、碰撞检测精度及开发体验,提供详尽的代码范式与选型决策指南。

Web 3D 架构设计

在 Web 3D 项目中集成物理引擎时,选型并非越庞大越好。核心结论非常明确:若你的项目以轻量 3D 展厅、网页 UI 物理动效、电商卡片堆叠、基础刚体交互为主,且对首屏体积与开发效率高度敏感,请优先选择 Cannon.js(推荐使用现代社区维护分支 cannon-es);若项目涉及工业级装配仿真、高速运动防穿透(CCD 连续碰撞检测)、复杂凹面三角网格高频对撞、布料与缆绳等软体物理,并且团队具备管理 WebAssembly 内存指针的能力,则必须选用基于 Bullet Physics 编译的 ammo.js。

ammo.js 与 Cannon.js 对比:Web 物理引擎选型指南
图 0:ammo.js 与 Cannon.js 核心技术定位与选型全景速览

一、底层架构与运行机制差异

评估物理引擎的第一步是理解其底层运行机制。ammo.js 与 Cannon.js 在技术底座上代表了完全不同的两条演进路径:

  • ammo.js:它是知名开源 C++ 物理引擎 Bullet Physics 通过 Emscripten 直接编译为 WebAssembly(以及早期 asm.js)的端口。Bullet 在游戏主机与工业建模软件(如 Blender、GTA 系列部分物理层)中久经考验,拥有极其深厚的数值碰撞与约束求解积累。在浏览器端运行时,ammo.js 依靠 Emscripten 生成的胶水代码(Glue Layer)暴露 C++ 类接口,所有物理数据存放在独立的 WebAssembly 线性内存堆(Linear Memory Heap)中。
  • Cannon.js(cannon-es):它是原作者 Stefan Hedman 参照 Bullet 经典算法完全使用 原生 JavaScript 重写的纯前端 3D 物理引擎。随着原仓库停止维护,目前业界事实上的标准是由 pmndrs(Poimandres 开源组织)接管维护的 cannon-es。它被彻底重构为 TypeScript,原生输出 ESM 规范,所有向量、四元数和刚体对象都作为普通的 JavaScript 对象分配在浏览器的 V8 堆内存中,完全由浏览器垃圾回收机制(Garbage Collector)托管。
ammo.js 与 Cannon.js 对比:Web 物理引擎选型指南
图 1:ammo.js(WASM 桥接)与 Cannon.js(纯 JS 引擎)底层架构与执行链路对比

这种架构差异直接决定了两者的开发范式:ammo.js 拥有 C++ 级的算力吞吐与算法稳健性,但在 JavaScript 端每次操作都需要跨语言桥接,必须手动申请与销毁指针;而 Cannon.js 则完美贴合前端开发直觉,API 风格与 Three.js 几乎镜像,但受限于 JavaScript 解释执行效率与 GC 垃圾回收机制。

二、核心选型指标全面对比

为了更清晰地做出工程权衡,我们从包体积、碰撞精度、软体支持、内存管理、高负载计算与生态集成六个维度展开具体分析:

ammo.js 与 Cannon.js 对比:Web 物理引擎选型指南
图 2:ammo.js 与 Cannon.js 关键工程技术指标与能力选型矩阵

1. 包体积与首屏加载成本

网络资源体积是现代 Web 前端与移动端 H5 极为敏感的指标。

  • Cannon.js (cannon-es):核心库非常轻巧,minified 体积约 110KB,经 gzip 压缩后仅约 35KB。同时因为基于 ESM 构建,支持打包工具的 Tree-shaking,如果仅使用简单的碰撞形体,最终打进 Bundle 的代码量极小。
  • ammo.js:作为一个全功能的 C++ 引擎编译产物,其 WASM 二进制文件通常在 350KB 到 1MB+ 不等(取决于构建时启用的 Bullet 模块,如是否包含 SoftBody)。此外,它需要先加载 JavaScript 包装脚本,再异步拉取并实例化 .wasm 模块,无法做细粒度的 Tree-shaking。

2. 碰撞检测与穿透防御能力(CCD)

在物理模拟中,高速移动的物体(如射击子弹、高速下落的球体或快速摆动的球棒)在相邻两帧之间可能直接越过障碍物,这种现象被称为“穿透”(Tunneling)。

  • ammo.js 原生支持 CCD(连续碰撞检测):Bullet 实现了基于时间投射(Time of Impact, TOI)的保守前进算法。开发者只需给刚体开启 setCcdMotionThreshold() 和 setCcdSweptSphereRadius(),即可彻底杜绝高速穿墙。
  • Cannon.js 仅支持基于离散时间步的检测:它在每个固定时间间隔检查物体当前的相交状态。如果物体速度超过其自身厚度除以帧耗时,就会直接击穿墙壁。要缓解这个问题,开发者必须人为缩小步长(大幅增加子步进计算,消耗 CPU),或者为关键物体附加更大的碰撞代理球。

3. 复杂网格与凹面形体对撞

在实际项目中,我们经常需要让物体在凹凸不平的复杂建筑网格或地形上移动:

  • ammo.js:拥有行业标杆级的 btBvhTriangleMeshShape(针对静态任意复杂三角网格的 BVH 树加速结构)和 btConvexHullShape(凸包碰撞体生成)。无论是复杂管道内部穿行还是车辆在崎岖地形上的悬挂计算,都能稳定支持。
  • Cannon.js:对于基础几何体(Sphere、Box、Cylinder、Plane)表现优异,虽然提供了 Trimesh,但官方明确声明 不支持 Trimesh 对 Trimesh 的碰撞,仅支持 Trimesh 与 Sphere/Plane 等基元碰撞。如果在 Cannon 中强行使用复杂网格对撞,极易发生法线穿插报错或物理爆炸。

4. 软体物理(Soft Body)

如果你的需求包括风中飘动的布料旗帜、吊桥缆绳、撕裂的橡胶或果冻弹性体:

Cannon.js 完全没有软体系统,它是一个纯粹的刚体物理引擎。而 ammo.js 则内建了 Bullet 的软体解算器 btSoftBodyWorld 与 btSoftBodyHelpers,可直接通过顶点网格生成绳索(Rope)、布料(Cloth)及弹性体积模型。

5. 开发体验、API 友好度与内存管理

这是两款引擎日常开发体验差异最大的地方:

⚠️ 注意 ammo.js 的内存泄漏陷阱
ammo.js 的 JS 变量本质上是指向 WebAssembly C++ 堆内存的整数指针。JavaScript 引擎的垃圾回收器(GC)无法感知 C++ 堆中的分配。如果你在每帧循环中执行 new Ammo.btVector3(x, y, z) 而没有显式调用 Ammo.destroy(),几分钟内就会造成数兆乃至数百兆的内存泄漏,最终导致浏览器崩溃(OOM)。

相反,cannon-es 的 API 完全契合现代前端习惯。所有向量运算可以直接重用变量,即使分配了临时对象,也可以放心地交给浏览器的垃圾回收器清理。对于团队中缺乏 C++ 背景的前端工程师,cannon-es 的上手速度往往快数倍。

三、代码实战:初始化与渲染同步闭环

任何 Web 物理引擎与渲染框架(以 Three.js 为例)的结合,都遵循固定的流程:初始化引擎 → 搭建物理世界 → 构建碰撞体并绑定渲染网格 → 每帧时钟步进 → 同步 Transform 矩阵 → 资源销毁。

ammo.js 与 Cannon.js 对比:Web 物理引擎选型指南
图 3:Web 物理与 Three.js 渲染管线生命周期交互闭环流程

1. Cannon.js (cannon-es) 极简标准范式

使用 cannon-es 配合 Three.js,代码直观清晰,几行即可完成刚体掉落:

import * as THREE from 'three';
import * as CANNON from 'cannon-es';

// 1. 创建物理世界与重力
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
world.broadphase = new CANNON.SAPBroadphase(world); // 采用高效的轴向扫描粗测
world.allowSleep = true; // 允许静止物体休眠,极大降低计算开销

// 2. 创建物理刚体 (以立方体为例)
const boxShape = new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5)); // 半宽
const boxBody = new CANNON.Body({
  mass: 1, // 质量 > 0 为动态刚体,等于 0 为静态刚体
  shape: boxShape,
  position: new CANNON.Vec3(0, 10, 0)
});
world.addBody(boxBody);

// 3. 创建 Three.js 渲染网格
const boxMesh = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshStandardMaterial({ color: 0x659B9B })
);
scene.add(boxMesh);

// 4. 渲染循环中保持同步
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const delta = Math.min(clock.getDelta(), 0.1);

  // 固定 60Hz 步长更新,最大子步进次数为 3
  world.step(1 / 60, delta, 3);

  // 物理状态同步到渲染网格
  boxMesh.position.copy(boxBody.position);
  boxMesh.quaternion.copy(boxBody.quaternion);

  renderer.render(scene, camera);
}
animate();

2. ammo.js 严谨架构范式与指针复用

ammo.js 由于涉及 WASM 模块初始化与显式内存管理,必须通过对象复用池来避免每帧重复创建 C++ 指针:

import * as THREE from 'three';

// 1. 异步等待 WASM 模块加载就绪
Ammo().then((AmmoLib) => {
  // 物理管线基础配置组件
  const collisionConfiguration = new AmmoLib.btDefaultCollisionConfiguration();
  const dispatcher = new AmmoLib.btCollisionDispatcher(collisionConfiguration);
  const overlappingPairCache = new AmmoLib.btDbvtBroadphase();
  const solver = new AmmoLib.btSequentialImpulseConstraintSolver();

  // 创建离散刚体动力学世界
  const physicsWorld = new AmmoLib.btDiscreteDynamicsWorld(
    dispatcher,
    overlappingPairCache,
    solver,
    collisionConfiguration
  );
  physicsWorld.setGravity(new AmmoLib.btVector3(0, -9.82, 0));

  // 2. 创建物理刚体
  const mass = 1.0;
  const colShape = new AmmoLib.btBoxShape(new AmmoLib.btVector3(0.5, 0.5, 0.5));
  const startTransform = new AmmoLib.btTransform();
  startTransform.setIdentity();
  startTransform.setOrigin(new AmmoLib.btVector3(0, 10, 0));

  const localInertia = new AmmoLib.btVector3(0, 0, 0);
  colShape.calculateLocalInertia(mass, localInertia);

  const motionState = new AmmoLib.btDefaultMotionState(startTransform);
  const rbInfo = new AmmoLib.btRigidBodyConstructionInfo(mass, motionState, colShape, localInertia);
  const body = new AmmoLib.btRigidBody(rbInfo);
  physicsWorld.addRigidBody(body);

  // 3. 内存优化核心:预分配一个复用的 Transform 指针,严禁在帧循环中反复 new
  const transformAux = new AmmoLib.btTransform();

  function animate() {
    requestAnimationFrame(animate);
    const delta = Math.min(clock.getDelta(), 0.1);

    // 步进模拟:参数分别为 (帧时间, 最大子步数, 固定步长)
    physicsWorld.stepSimulation(delta, 10, 1 / 60);

    // 读取最新空间变换并映射给 Three.js 网格
    motionState.getWorldTransform(transformAux);
    const origin = transformAux.getOrigin();
    const rotation = transformAux.getRotation();

    boxMesh.position.set(origin.x(), origin.y(), origin.z());
    boxMesh.quaternion.set(rotation.x(), rotation.y(), rotation.z(), rotation.w());

    renderer.render(scene, camera);
  }
  animate();

  // 4. 组件销毁时的显式清理规范
  window.addEventListener('beforeunload', () => {
    physicsWorld.removeRigidBody(body);
    AmmoLib.destroy(body);
    AmmoLib.destroy(motionState);
    AmmoLib.destroy(colShape);
    AmmoLib.destroy(transformAux);
    AmmoLib.destroy(physicsWorld);
  });
});

四、选型决策树与典型落地场景

为了帮助开发团队迅速做出合理的架构选型,我们归纳了一套层层递进的逻辑决策树:

ammo.js 与 Cannon.js 对比:Web 物理引擎选型指南
图 4:Web 物理引擎结构化选型决策树

典型业务场景最佳实践对应表

业务场景类型 典型物理特征 推荐物理引擎 核心选型理由
营销 H5 / 电商 3D 展示 小球滚落、卡片翻转、UI 按钮惯性回弹 Cannon.js (cannon-es) 35KB 超小体积,秒级首屏加载;零 WASM 初始化等待,低端手机兼容好。
React Three Fiber 商业项目 中大型后台 3D 交互、数字孪生控制面板 cannon-es 官方 @react-three/cannon Hooks 生态成熟,声明式组件极大提升研发效率。
工业机构与机械装配仿真 铰链传动、滑块机构、高精度受力测量 ammo.js Bullet 拥有极佳的 6DOF 通用多自由度约束,求解器数值发散概率极低。
载具仿真与越野驾驶小游戏 车轮悬挂、地面摩擦衰减、高速越野 ammo.js 提供专门的 btRaycastVehicle 车辆悬挂求解器,动力学仿真平滑可控。
布料展示 / 旗帜绳索飘动 网格形变、风阻扰动、缆绳悬垂 ammo.js 唯一的原生 SoftBody 支持,内置弹簧质点网络,免去手写 Verley 积分器。

五、工程落地避坑与性能优化指南

选定引擎后,在实际工程交付中还需注意以下关键实践,以保障 60 FPS 稳定运行:

  1. 开启刚体休眠机制(Sleep):在场景中堆叠多块积木或静止道具时,物理引擎如果持续计算静摩擦与接触力,会白白消耗算力。在 Cannon 中设置 world.allowSleep = true,在 ammo.js 中配置合适的 setDeactivationTime(),当刚体速度小于阈值时自动冻结计算,待受外力碰撞后唤醒,可降低 60% 以上的物理计算耗时。
  2. 使用碰撞代理(Proxy Colliders):切忌直接将 Three.js 中数万面的渲染几何体直接扔进物理引擎充当碰撞体。正确做法是用最简单的 Box、Sphere、Capsule 作为物理代理,将渲染 Mesh 作为子节点跟随代理运动,复杂模型可用多个简单形体组合(Compound Shape)。
  3. Web Worker 物理多线程解耦:如果物理模拟刚体数量超过 200 个,必须将物理引擎整体迁移至 Web Worker 运行。主线程只负责 WebGL 渲染,物理线程定期通过 SharedArrayBuffer 或 Transferable Objects 向主线程传递位置四元数 Float32Array 数组,彻底避免复杂物理卡死主线程渲染帧率。
  4. 警惕版本选型陷阱:请务必使用 npm 上的 cannon-es,切勿继续安装已经数年未更新的旧版 cannon 包;对于 ammo.js,推荐使用自带 TS 类型声明与最新 WASM 构建的现代封装版本(如通过 Three.js 官方示例目录中的 ammo 构建包)。
作者最新文章
相关文章
更多