7
7

Cannon.js の世界へようこそ! 3歩でわかる お手軽 物理シミュレーション

Posted at

恒例の年末記事です。
今回は Cannon.js (cannon-es) についてご紹介します。

(過去の記事)

Cannon.js とは

Cannon.js はオープンソースの JavaScript 3D物理エンジンです。軽量性と柔軟性により,開発者は比較的簡単に物理演算を統合でき,リッチでインタラクティブなユーザー体験を提供できます。
Three.js (WebGL) と組み合わせて使用されることが多く,Webベースのゲーム,インタラクティブな Webサイト,VR や AR アプリケーション,教育とトレーニング,製品デザインなど,さまざまなシナリオで利用できます。
リアルタイムの物理シミュレーションが求められる場面で特に有用です。例えば,オブジェクトが重力に従って落下するアニメーションや,オブジェクト同士が衝突して反発する動作を表現する場合などに適しています。

cannon-es について

cannon-es は,開発が停止してしまった Cannon.js をフォークして継続しているプロジェクトです。現代の JavaScript のスタンダードなスタイルに合うようアップデートされ,メンテナンスや機能拡張が行われています。この記事のサンプルも cannon-es を利用しています。
https://github.com/pmndrs/cannon-es

cannon-es の例

モジュール群

主な登場人物を見てみる

World

World は Cannon.js の中心的なコンポーネントで,すべての物理オブジェクトとその相互作用を管理します。World インスタンスでは,重力や空気抵抗などの全般的な物理環境を設定し,複数のBodyオブジェクトをこの環境内で管理します。

Body

Body は個々の物理オブジェクトを表し,質量,位置,速度,回転などの物理的特性を持ちます。これらのオブジェクトは,Shape によって具体的な形状が定義され,動的,静的,または運動学的なものとして設定できます。

Shape

ShapeBody の幾何学的な形状を定義し,さまざまなタイプがあります。例えば Box は箱型の形状を,Sphere は球形の形状を表します。ConvexPolyhedron は多面体,Plane は無限の平面を表し,HeightfieldTrimesh はより複雑な形状を表現するために使用されます。Particle は質点としての振る舞いをシミュレートします。

Material

Material は物体の物理的な特性,例えば摩擦や反発係数などを定義します。異なる Body 間の相互作用は,ContactMaterial を介して詳細に設定することができます。

Constraint

Constraint は二つの Body 間に動きや相互作用に特定の制約を定義し,物体の動きを制限します。例えば DistanceConstraint は二つの Body 間の距離を固定し,PointToPointConstraint は二つの Body を特定の点で接続します。

Solver

Solver は物理方程式を解くためのアルゴリズムを提供し,衝突や制約の解決に使用されます。これにより,物理世界のリアリズムとパフォーマンスがバランス良く維持されます。Solver は物理シミュレーションの精度と効率性を向上させるために重要であり,Cannon.js の核心的な部分の一つです。

GSSolver (Gauss-Seidel Solver)

  • ガウス-ザイデル法を用いて物理方程式を解くソルバーです。
  • 衝突や制約が関与する複雑な物理シミュレーションにおいて,効率的な反復計算を行います。制約を順番に処理し,各反復でシステムの状態を更新することで,最終的な解に収束させます。
  • 一般的な物理シミュレーションに適しており,特にリアルタイムのパフォーマンスが求められる場面で有用です。

SplitSolver

  • 衝突処理を分割して行うソルバーです。
  • 物理システムを複数の小さなサブシステムに分割し,それぞれ個別に解決します。これにより,複雑なシミュレーションでも計算負荷を分散させ,効率的な処理が可能になります。
  • 大規模または特に複雑な物理シミュレーションに適しており,計算効率の向上が期待できます。

簡単 3 Step で体験してみよう!

  • Step 1: 物理オブジェクトの基本設定
    • 自由落下,斜面上の転がり,振り子,衝突
  • Step 2: 3Dオブジェクトとの連動
    • Cannon.js の Body の位置/向きを Three.js の Mesh へ同期
  • Step 3: 複合とインタラクション

Step 1: 物理オブジェクトの基本設定

まずは,基本的な物理現象をシミュレートする方法を見てみましょう。

基本設定

Cannon.js + Three.js でアプリケーションをつくるための基本的な設定をします。

Cannon.js の World を生成します。以下では Y軸のマイナス方向に力が働くように重力を設定しています。
(Y軸のプラス方向が世界の上方向と規定します)

const world = new CANNON.World({
  gravity: new CANNON.Vec3(0, -9.82, 0),
});

ここに Cannon.js のオブジェクト (Body や Constraint) を追加していきます。

const body = new CANNON.Body({ ... });
world.addBody(body);

物理シミュレーション結果をキャプチャするために Three.js の Scene と Renderer を生成します。

const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });

CannonDebugger を使うことで,Three.js の Mesh なしでも結果を表示することができます。

const cannonDebugger = new CannonDebugger(scene, world);

requestAnimationFrame を使って,物理シミュレーションの計算および表示を一定間隔で行います。

const animate = () => {
  world.fixedStep(); // framerate every 1 / 60 ms
  cannonDebugger.update();
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
};
animate();

Scene 1: 自由落下

地面に対して球体が落下するシーンを作成してみましょう。

地面を表す Body を作成します。shape (形状)には Box を薄くして使用します。
mass (質量)に 0 を設定することで,重力に対して静的になります。
Cannon.js の Material は物理的な特性を設定できます。restitution は反発係数です。0 にすると全く反発しなくなります。

const groundBody = new CANNON.Body({
  mass: 0, // static
  shape: new CANNON.Box(
    new CANNON.Vec3(10, 0.01, 10), // size
  ),
  material: new CANNON.Material({
    restitution: 0.5,
  }),
});
world.addBody(groundBody);

球体を表す Body を作成します。
mass (質量)に 0 より大きい値を設定することで,重力にしたがって落下する力が働くようになります。
position (初期位置)を地面(原点)より少し高い位置に設定し,地面に向かって落下するようにします。

const radius = 2.5;
const sphereBody = new CANNON.Body({
  mass: 5, // kg
  shape: new CANNON.Sphere(radius),
  position: new CANNON.Vec3(0, 10, 0),
  material: new CANNON.Material({
    restitution: 1,
  }),
});
world.addBody(sphereBody);

Scene 2: 斜面上の転がり

地面に傾斜をつけて,球体が斜面を転がるようにしてみましょう。

groundBody の quaternion に傾きを設定します。

groundBody.quaternion.setFromEuler(0, 0, -Math.PI / 9); // rotate ground body by 20 degrees

Scene 3: 振り子

制約を使って,球体に振り子の運動をさせてみましょう。

PointToPointConstraint は,二つの物体を特定の点で接続します。これは,ロープで接続されたようなもので,物体は接続点を中心にして回転したり,スイングしたりできます。
1つ目の物体として固定点を設定し,高さ 7.5 の位置に1つ目の接続点を設定します。
2つ目の物体として球体(sphereBody)を設定し,球体の中心を基準として x軸プラス方向に 5 の位置に相手の接続点が来るように設定します。

const constraint = new CANNON.PointToPointConstraint(
  new CANNON.Body({ mass: 0 }), // A body with mass 0 to represent the fixed point
  new CANNON.Vec3(0, 7.5, 0),   // Connection point in world space
  sphereBody,
  new CANNON.Vec3(5, 0, 0),     // Connection point on the sphere, relative to the sphere's center
);
world.addConstraint(constraint);

また,球体(sphereBody)の初期値として,制約の1つ目の接続点からみて x軸マイナス方向 5 の位置に position を設定します。

const radius = 2.5;
const sphereBody = new CANNON.Body({
  mass: 5, // kg
  shape: new CANNON.Sphere(radius),
  position: new CANNON.Vec3(-5, 7.5, 0),
});
world.addBody(sphereBody);

球体は重力に従って落下しますが,点同士の距離を維持しようとするため,振り子の運動になります。

衝突 (Scene 4)

複数の物体同士を衝突させてみましょう。

ランダムな大きさと向きの立方体を生成する関数を作成します。

const generateBox = () => {
  const size = Math.random() + 0.1;
  const body = new CANNON.Body({
    mass: 1,
    shape: new CANNON.Box(
      new CANNON.Vec3(size, size, size),
    ),
    position: new CANNON.Vec3(0, 10, 0),
  });
  body.quaternion.setFromEuler(
    Math.random() * Math.PI,
    Math.random() * Math.PI,
    Math.random() * Math.PI,
    "XYZ"
  );
  world.addBody(body);
};

一定間隔で関数を呼び出します。

// Generate Boxes regularly
setInterval(generateBox, 250);

次々と生成される Box 同士が衝突する様子が観察できます。

実際には無限にオブジェクトが生成されないように,オブジェクトの数に制限を設けるとよさそうです。

Step 2: 3Dオブジェクトとの連動

次に,Three.js のと連動した 3D のビジュアル表現を追加してみましょう。

概要: Three.js と Cannon.js の連携

Cannon.js では,World に Body を追加していくことで,物理シミュレーションを実現します。
Body は物理オブジェクトです。mass(重量),shape(形状),material(材質:摩擦や反発係数など物理特性)を持ち,World 内での重力の影響や Body 同士の相互作用や衝突によって位置や向きが決まります。
 
Three.js では,Scene に Mesh を追加していくことで,3D のビジュアル表現を実現します。
Mesh は3Dオブジェクトです。geometry(形状),material(材質:色や光の反射,メタリックな質感やマットな質感などの特性)を持ち,光源や他の Mesh との位置関係などにより見た目の表現が決まります。
 
Three.js の3Dオブジェクトに Cannon.js の物理演算の結果を適用するには,各3Dオブジェクトに対応する物理オブジェクトを作成し,アニメーションの各フレームごとに物理オブジェクトの位置と向きを3Dオブジェクトにコピーします。
これにより,3Dオブジェクトが物理オブジェクトに追従するため,物理法則に基づいて動くようになります。

実装: 3Dオブジェクトの追加

Step 1 の Scene 1 で作成したものに Three.js の3Dオブジェクトを追加してみましょう。

はじめに世界に光を灯しましょう。
DirectionalLight は,無限遠から降り注ぐ平行な光源です。

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(0, 1, 0);
scene.add(directionalLight);

Ground の対になる Mesh を追加します。
よく分からないのですが,BoxGeometry のサイズはそれぞれ2倍の値を設定すると丁度よくなります。

const groundMesh = new THREE.Mesh(
  new THREE.BoxGeometry(20, 0.02, 20),
  new THREE.MeshPhongMaterial({
    color: 0x9999ff,
    transparent: true,
    opacity: 0.5,
  }),
);
scene.add(groundMesh);

同様に Sphere の対になる Mesh も追加します。
radius の方は Cannon.js のオブジェクトと同じ値を設定すればいいようです。
SphereGeometry の第2・第3引数は widthSegments, heightSegments で,数が多いほど滑らかになります。

const radius = 2.5;
const sphereMesh = new THREE.Mesh(
  new THREE.SphereGeometry(radius, 32, 32),
  new THREE.MeshPhongMaterial({
    color: 0x99ff99,
    transparent: true,
    opacity: 0.75,
  }),
);
scene.add(sphereMesh);

物理オブジェクトの位置と向きを3Dオブジェクトにコピーするための関数を作成します。

const copy = () => {
  groundMesh.position.copy(groundBody.position);
  groundMesh.quaternion.copy(groundBody.quaternion);
  sphereMesh.position.copy(sphereBody.position);
  sphereMesh.quaternion.copy(sphereBody.quaternion);
};

これをアニメーションのたびに呼び出します。

const animate = () => {
  copy(); // added in this step 
  world.fixedStep();
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
};
animate();

これでシーンが彩られました。

Step 3: 複合とインタラクション

最後に,RigidVehicle ヘルパークラスを使った複合オブジェクトとキーボードによるインタラクションを追加してみましょう。

物理オブジェクトの作成

地面を表す Body を生成します。shape には Box の代わりに Cylinder を使用して,円形のフィールドにしています。
のちほど Material を使用するので,変数に取っておきます。

const groundMaterial = new CANNON.Material({
  name: 'ground',
  restitution: 1,
});
const groundBody = new CANNON.Body({
  mass: 0,
  shape: new CANNON.Cylinder(125, 125, 0.01, 32),
  position: new CANNON.Vec3(0, -5, 0),
  material: groundMaterial,
});
world.addBody(groundBody);

RigidVehicle を使って車両本体を生成します。
初期化のタイミングでシャーシの Body を指定します。

const vehicle = new CANNON.RigidVehicle({
  chassisBody: new CANNON.Body({
    mass: 1,
    shape: new CANNON.Box(new CANNON.Vec3(6, 0.4, 2.4)),
  }),
});

ホイールを生成する関数を作成します。
こちらの Material も変数に取っておきます。
Shape は Cylinder を90度回転させて設定しています。
 
angularDamping は,角減衰を指定するプロパティです。角減衰とは,オブジェクトの回転運動が時間とともにどのように減少するかを制御する要素です。0 の場合,Body は外力が加わらない限り回転を続けます。1 に近い値の場合,Body の回転は非常に早く減衰し,ほぼ即座に停止します。

const wheelMaterial = new CANNON.Material({
  name: 'wheel',
  restitution: 1,
});
const createWheelBody = () => {
  const wheelBody = new CANNON.Body({
    mass: 1,
    material: wheelMaterial,
  });
  const wheelQuaternion = new CANNON.Quaternion();
  wheelQuaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), Math.PI / 2);
  wheelBody.addShape(
    new CANNON.Cylinder(1.25, 1.25, 1, 32),
    new CANNON.Vec3(),
    wheelQuaternion
  );
  wheelBody.angularDamping = 0.8;
  return wheelBody;
};

ホイール4つを車両本体に追加します。

const addWheelToVehicle = (position) =>
  vehicle.addWheel({
    body: createWheelBody(),
    position,
    axis: new CANNON.Vec3(0, 0, 1),
    direction: new CANNON.Vec3(0, -1, 0),
  });
[
  new CANNON.Vec3(-5, 0,  3.6), // Front Left
  new CANNON.Vec3(-5, 0, -3.6), // Front Right
  new CANNON.Vec3( 5, 0,  3.6), // Rear Left
  new CANNON.Vec3( 5, 0, -3.6), // Rear Right
].forEach(addWheelToVehicle);

特性オブジェクトの作成

ContactMaterial を使用して地面とホイールの接触時の挙動を決定するためのパラメーターを設定します。
先ほど生成した wheelMaterialgroundMaterial を使用します。
 
friction は,摩擦係数です。値が大きいほど摩擦が強くなります。値が小さいと車輪が滑ってしまって進まなくなります。
contactEquationStiffness は,剛性です。値が大きいほど接触時に物体が変形しにくくなります。値が小さいと車体が沈んでしまって進まなくなります。

const contactMaterial = new CANNON.ContactMaterial(
  wheelMaterial,
  groundMaterial,
  {
    friction: 0.5,
    contactEquationStiffness: 1000,
  }
);
world.addContactMaterial(contactMaterial);

3Dオブジェクトとの同期

アニメーション時に実行するコピー関数を作成します。
対となる 3Dオブジェクト groundMesh, chassisMesh, wheelMeshes は生成済みとします。

車両のシャーシとをホイールの Body は vehicle.chassisBodyvehicle.wheelBodies で取得します。
 
ポイントは,3Dオブジェクトの wheelMeshes と物理オブジェクトの vehicle.wheelBodies と間で向きが 90度ズレてしまっているため,調整する必要があるというところです。Three.js のクォータニオン (Quaternion) を用いて向きを調整します。(wheelMeshes[i].rotation.x で調整しようとするとおかしなことになります)

chassisMesh.position.copy(vehicle.chassisBody.position);
chassisMesh.quaternion.copy(vehicle.chassisBody.quaternion);

const wheelQuaternion = new THREE.Quaternion();
wheelQuaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);

vehicle.wheelBodies.forEach((wheelBody, i) => {
  wheelMeshes[i].position.copy(wheelBody.position);
  wheelMeshes[i].quaternion.copy(
    new THREE.Quaternion()
      .copy(wheelBody.quaternion)
      .multiply(wheelQuaternion)
  );
});

キーバインドの設定

キーボードによるインタラクションを追加します。

vehicle.setWheelForce を用いることで,ホイールの推進力を設定できます。value の数値が大きいほど,前進する方向に力が働きます。0 にすると停止し,マイナスの値にすると後退する方向に力が働きます。wheelIndexvehicle.wheelBodies のインデックスで追加した順に 0 から振られます。今回は 2, 3 が後輪なので,2, 3 に値を設定します。(二輪駆動)

vehicle.setWheelForce(value, wheelIndex);

vehicle.setSteeringValue を用いることで,ホイールの方向を設定できます。value の数値が大きいほど,ホイールが左に向きます。0 にすると直進方向を向き,マイナスの値にすると右に向きます。今回は 0, 1 が前輪なので,0, 1 に値を設定します。

vehicle.setSteeringValue(value, wheelIndex);

インタラクティブな操作をするためにイベントリスナーを設定します。
keydown 時にホイールの推進力と向きの設定をし,keyup 時にそれぞれの値をリセットします。
event.key でキーを判定して,該当のキーが押された場合に処理します。

// Add force on keydown
document.addEventListener('keydown', (event) => {
  switch (event.key) {
    case 'ArrowUp':
      vehicle.setWheelForce(100, 2);
      vehicle.setWheelForce(100, 3);
      break;
    case 'ArrowLeft':
      vehicle.setSteeringValue(Math.PI / 8, 0);
      vehicle.setSteeringValue(Math.PI / 8, 1);
      break;
...
// Reset force on keyup
document.addEventListener('keyup', (event) => {
  switch (event.key) {
    case 'ArrowUp':
      vehicle.setWheelForce(0, 2);
      vehicle.setWheelForce(0, 3);
      break;
    case 'ArrowLeft':
      vehicle.setSteeringValue(0, 0);
      vehicle.setSteeringValue(0, 1);
      break;
...

これで,キーボードによる操作が可能になりました。

押されている時間に応じてアキュムレートするようにすると,よりスムーズな表現になるかもしれません。

おしまい

  • 想像以上に簡単に物理シミュレーションを使ったアプリケーションを作成することができました。
  • 自分でパラメーターを変えながら試すことで,挙動の違いを視覚的に確認することができ,より理解が深まりました。
    • たとえば,摩擦係数や剛性の値を変えてみたり,タイヤを1つ外してみたりなど

おまけ

Step 1 / 2 の Scene 4 について
物理計算および 3D 表示のレンダリングは requestAnimationFrame を使っていますが,generateBox の呼び出しは setInterval にしています。
これにより,ウィンドウが非アクティブのときに前者の処理は走りませんが,後者の処理だけは走るという状況になります。しばらく,ウィンドウを非アクティブ(裏や最小化)にしておいてから切り替えると Box が滞留して面白いことになります。(溜めすぎるとめっちゃ重くなります)

参考

cannon-es

Cannon.js (本家 by schteppe)

Three.js

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7