Three.js備忘録(6)

目次
  1. 物理演算を追加する
  2. Cannon.js
    1. 基本的な設定
    2. CANNON.Material
    3. 物体に力やインパルスを加える
    4. イベントのリスニング
    5. Constraints(制約)

物理演算を追加する

物体が現実世界のように動く(物理法則に基づいた挙動)と、ユーザー体験が向上します。

  • Tension(張力)
    張力は、物体が引っ張られる力です。たとえば、バネやロープが伸びると、その中に張力が発生します。この力は、バネやロープを元の長さに戻そうとする反作用です。
  • Friction(摩擦力)
    摩擦力は、物体が移動する際にそれに抵抗する力です。地面に転がるボールが最終的に停止するのは、摩擦力の作用によります。
  • Bouncing(反発力)
    反発力(または反発係数)は、物体が衝突した後、どれだけ跳ね返るかを示します。ゴルフボールが地面に当たった後、高く跳ね返るのは、反発力が高いからです。
  • Constraints(制約)
    制約は、物体の動きを特定の条件で制限することです。たとえば、ドアは一定の軸周りでしか動かないように制約がかかっています。
  • Pivots(ピボット、回転軸)
    ピボットは、物体が回転する際の軸点です。たとえば、シーソーは中央のピボット点を中心に動きます。

基本的なステップ

  • Three.jsのセットアップ: まず、基本的なThree.jsのシーン、カメラ、レンダラーを設定します。
  • 物理エンジン(ライブラリ)の選定: Cannon.js, Ammo.js など、どの物理エンジンを使うか選びます
  • 物理エンジンで「物理の世界」を作ります。
    これは見えない空間で、物体の動き(落下、衝突など)が計算されます
  • Three.jsと物理エンジンの連携:
    Three.jsで表示されるオブジェクト(メッシュ)ごとに、物理の世界に相当する物体(物理ボディ)を作成します
  • 物理エンジンでの計算結果をThree.jsのオブジェクトに適用します

three.jsと一緒によく使われる物理演算のライブラリ

  • Cannon.js: Cannon.jsはJavaScriptで書かれた軽量な3D物理エンジンです。three.jsとの連携が容易であり、リアルタイムの物理シミュレーションによく用いられます
  • Ammo.js: Ammo.jsはC++のBullet物理エンジンを基にしたJavaScriptライブラリです
    Emscriptenを使用してC++からJavaScriptにコンパイルされています
  • Oimo.js: このライブラリもJavaScriptで書かれており、リアルタイムの物理シミュレーションに使用されます。Oimo.jsは性能に優れており、シンプルなAPIを提供しています
  • Matter.js: 2Dの物理エンジンであり、three.jsの2Dプロジェクトでよく用いられます。非常にシンプルで使いやすいAPIを提供しています
  • Physijs: three.jsに特化した物理エンジンプラグインです。Ammo.jsを内部で使用しています
  • Verlet-js: Verlet統合を用いたシンプルな物理エンジンです。主にクロスや布のような柔らかい物体のシミュレーションに使用されます

Cannon.js

*Cannon.jsは開発が止まりcannon-esが開発継続しているようです

基本的な設定

1Three.jsで物体を作る(これが視覚的に見える部分です)
2物理世界の基本設定
3物理エンジンでキューブと床を作る: これは見えませんが、物理演算に使われます
4物理エンジンでの計算結果をThree.jsのオブジェクトに反映させる

物理世界の基本設定について

//Worldオブジェクトを作成して、物理世界を初期化
const world = new CANNON.World()
//(最適化)衝突判定のためのBroadphaseアルゴリズムを設定
world.broadphase = new CANNON.SAPBroadphase(world)
//(最適化)オブジェクトが動かない場合に物理計算を省略(スリープ状態にする)ことを許可
world.allowSleep = true
//重力の方向と大きさを設定します。通常、地球上の重力加速度
world.gravity.set(0, - 9.82, 0)

備考:物理エンジンの最適化
Broadphase(広範な衝突判定)とSleep(スリープ機能)について

  • NaiveBroadphase: すべてのBodyを他のすべてのBodyとテストします(非効率)。
  • GridBroadphase: 世界をグリッドに分け、同じグリッド内または隣接するグリッド内のBody同士だけをテストします。
  • SAPBroadphase (Sweep and Prune broadphase): 複数のステップで任意の軸に沿ってBodyをテストします。

*デフォルトは NaiveBroadphase ですが、性能向上のために SAPBroadphase を使用、動いていない物体は、衝突判定から除外すること(スリープ機能)で計算負荷を減らします。

1:Three.jsでキューブと床
2:物理エンジンでキューブと床を作ります
具体的には、Cannon.jsのBodyオブジェクトを作成して、それに形状(Shape)を追加します
それによって物理的な性質(質量、大きさ、位置、速度など)を設定することができます

//物理世界
const world = new CANNON.World()
world.broadphase = new CANNON.SAPBroadphase(world)
world.allowSleep = true
world.gravity.set(0, - 9.82, 0)
// キューブ
const geometryCube = new THREE.BoxGeometry(1,1,1);
const materialCube = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh(geometryCube, materialCube);
cube.position.y = 2;
scene.add(cube);
//物理エンジンキューブ
const cubeShape = new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5));
const cubeBody = new CANNON.Body({
    mass: 1, // 質量
    position: new CANNON.Vec3(0, 2, 0), // 初期位置
});
cubeBody.addShape(cubeShape); // 形状を追加
world.addBody(cubeBody); // 世界に追加
// 床
const geometryFloor = new THREE.PlaneGeometry(10, 10);
const materialFloor = new THREE.MeshBasicMaterial({color: 0xdddddd});
const floor = new THREE.Mesh(geometryFloor, materialFloor);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -2; //位置を追加
scene.add(floor);
//物理エンジン床
const groundShape = new CANNON.Plane(); // Plane形状は無限に広がる平面です
const groundBody = new CANNON.Body({
    mass: 0, // 質量0は動かないオブジェクトを意味します
});
groundBody.addShape(groundShape);
// 床を水平にする  
//または、setFromAxisAngle(new CANNON.Vec3(-1, 0, 0), Math.PI / 2)
groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2); 
groundBody.position.y = -2; 
world.addBody(groundBody); // 世界に追加
  • Three.jsとCannon.jsで形状を一致させる際の一般的なルール
    • Three.jsでBoxGeometryを用いて立方体を定義する場合
      new THREE.BoxGeometry(高さ, 幅, 奥行き)
      Cannon.jsではその半分のサイズを用いてCANNON.Boxを定義します
      new CANNON.Box(new CANNON.Vec3(高さ * 0.5, 幅 * 0.5, 奥行き * 0.5))
    • Three.jsでSphereGeometryを用いて球を定義する場合
      new THREE.SphereGeometry(半径)
      Cannon.jsでは同じ半径を用いてCANNON.Sphereを定義します
      new CANNON.Sphere(半径)
  • 位置の基準: Vec3で位置を設定するとき、この位置は原点(0, 0, 0)からどれだけ離れているかと考えます
  • 質量(mass)大きさとは関係ないです。
    質量が0だと、その物体は動かない(例:床)
  • 回転の設定: オブジェクトを回転させたい場合、Cannon.jsではquaternionを使います
  • 複数の形状: 一つの物体(Body)には、複数の形(Shape)を加えることがもできます
    位置はその物体からの相対位置です

物理エンジンでの計算結果をThree.jsのオブジェクトに適用します
*通常はアニメーションループ内

const animate = function () {
  requestAnimationFrame(animate);
  
  // 物理エンジンの計算 物理エンジンは毎秒60回更新されます
  world.step(1 / 60);
  
  // 物理エンジンでの位置と回転をThree.jsのオブジェクトに反映
  cube.position.copy(cubeBody.position);
  cube.quaternion.copy(cubeBody.quaternion);

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

world.step(timeStep, [timeSinceLastCalled], [maxSubSteps])

  • timeStep: 必須の引数で、この時間ステップ(秒単位)で物理エンジンを更新します
  • timeSinceLastCalled: オプションの引数で、前回この関数が呼び出されてからの経過時間(秒)です。この引数を指定しない場合、timeStepだけが用いられます
  • maxSubSteps: オプションの引数で、この関数が一度に取ることができる最大のサブステップ(計算ステップ)の数です
    この値が大きいほど、計算は正確になりますが、パフォーマンスが低下する可能性があります
    指定しない、または0に設定すると、物理エンジンは何も制限なくサブステップを取ります

CANNON.Material

物理エンジンのマテリアルを作成して各物体に適用することで、摩擦や反発(弾性)などの物理的なプロパティをシミュレートすることができます

氷(iceMaterial)とプラスチック(plasticMaterial)が
接触した場合の摩擦と反発を設定するコード例

const iceMaterial = new CANNON.Material("ice");
iceMaterial.friction = 0.1;  // 低摩擦
iceMaterial.restitution = 0.2;  // 低反発

const plasticMaterial = new CANNON.Material("plastic");
plasticMaterial.friction = 0.4;  // プラスチックの摩擦
plasticMaterial.restitution = 0.6;  // プラスチックの反発

//マテリアルが接触した場合の ContactMaterialを設定
const icePlasticContactMaterial = new CANNON.ContactMaterial(
  iceMaterial, 
    plasticMaterial, 
  {
    friction: 0.25,  // ここで摩擦係数を設定
    restitution: 0.4  // ここで反発係数を設定
 }
);
// ワールドに接触設定を追加
world.addContactMaterial(icePlasticContactMaterial);

//各物体に適用
const body1 = new CANNON.Body({
  mass: 1,
  material: iceMaterial
});
const body2 = new CANNON.Body({
  mass: 1,
  material: plasticMaterial
});

摩擦係数(Friction)と反発係数(Restitution)

  • 氷:摩擦: 0.05 – 0.1 反発: 0.1 – 0.25
  • ゴム:摩擦: 0.5 – 0.8 反発: 0.7 – 0.9
  • 木:摩擦: 0.4 – 0.6 反発: 0.2 – 0.4
  • 鉄:摩擦: 0.6 – 0.8 反発: 0.2 – 0.3
  • ガラス:摩擦: 0.4 – 0.7 反発: 0.6 – 0.7
  • コンクリート 摩擦: 0.6 – 0.75 反発: 0.1 – 0.2
  • 皮革 摩擦: 0.3 – 0.6 反発: 0.2 – 0.3
  • プラスチック 摩擦: 0.2 – 0.4 反発: 0.2 – 0.4

物体に力やインパルスを加える

物体に力を加えるとき、その力は座標系に基づいて作用し、座標系には主に2種類あります

  • ワールド座標系(Global Coordinates)
    *ワールド座標系では、全てのオブジェクトが共通の「基準」に対して位置づけられます。
    各都市を考えたときに、地点は特定の緯度・経度で定義されます
  • ローカル座標系(Local Coordinates)
    *車を考えたとき、その車内で「前」「後ろ」「左」「右」は、車自体の向きに依存します
    同じ道を進んでいても、車が180度回転すれば、車内での「前」は逆になります

メソッド

  • applyForce: 空間の特定の点から物体に力を適用します
    この力はその後、速度の変更に影響を与えます
  • applyImpulse: applyForceと似ていますが、力ではなく直接速度に影響を与えます
  • applyLocalForce: applyForceと同じですが、座標が物体のローカル座標系に基づいています
  • applyLocalImpulse: applyImpulseと同じですが、座標が物体のローカル座標系に基づいています

風の力を適応する例
継続的なので、アニメーションループ内で適応させています
*一般的にパラメータを更新(.applyForce()などで)した後に、物理エンジンの計算(world.step())を行う

const windForce = new CANNON.Vec3(0.5, 0, 0);  // x方向に力を適用
const animate = function () {
  requestAnimationFrame(animate);
 // 風の力を適用
  cubeBody.applyForce(windForce, cubeBody.position);
  world.step(1 / 60);
  cube.position.copy(cubeBody.position);
  cube.quaternion.copy(cubeBody.quaternion);
  renderer.render(scene, camera);
};
animate();

画面をクリックしたら、キューブが落下します(摩擦係数を低くして氷の上を滑るイメージにしました)
*床に傾斜をつけたり、摩擦係数を低くしたり、キューブに風の力を適応したり、動きを調整

コードを見る

 <!-- キャンバスの配置 -->
  <div id="canvas" style="width:100%; height:300px"></div>

  <!-- Three.jsとCANNON.jsのライブラリを読み込む -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js"></script>

  <script>
    // HTML要素とサイズの取得
    const container = document.getElementById('canvas');
    const containerWidth = container.offsetWidth;
    const containerHeight = container.offsetHeight;

    // Three.jsの初期設定
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(containerWidth, containerHeight);
    container.appendChild(renderer.domElement);

    // CANNON.js(物理エンジン)の初期設定
    const world = new CANNON.World();
    world.broadphase = new CANNON.SAPBroadphase(world);
    world.allowSleep = true;
    world.gravity.set(0, -9.82, 0);

    // Three.jsでキューブを作成
    const geometryCube = new THREE.BoxGeometry(1, 1, 1);
    const materialCube = new THREE.MeshNormalMaterial();
    const cube = new THREE.Mesh(geometryCube, materialCube);
    cube.position.y = 2;
    scene.add(cube);

    // CANNON.jsで物理エンジン用のキューブを作成
    const cubeShape = new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5));
    const cubeBody = new CANNON.Body({
      mass: 1,
      position: new CANNON.Vec3(0, 2, 0),
    });
    cubeBody.addShape(cubeShape);
    world.addBody(cubeBody);

    // Three.jsで床を作成
    const slopeAngle = 0.1; // 傾斜角
    const geometryFloor = new THREE.PlaneGeometry(10, 10);
    const materialFloor = new THREE.MeshBasicMaterial({ color: 0xdddddd });
    const floor = new THREE.Mesh(geometryFloor, materialFloor);
    floor.rotation.x = -Math.PI / 2 + slopeAngle;
    floor.position.y = -2;
    scene.add(floor);

    // CANNON.jsで物理エンジン用の床を作成
    const groundShape = new CANNON.Plane();
    const groundBody = new CANNON.Body({ mass: 0 });
    groundBody.addShape(groundShape);
    groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2 + slopeAngle);
    groundBody.position.y = -2;
    world.addBody(groundBody);

    // キューブと床のマテリアル設定
  const plasticMaterial = new CANNON.Material('plastic');
  plasticMaterial.friction = 0.1;  // 摩擦係数
   plasticMaterial.restitution = 0.2;  // 反発係数
   // 氷のマテリアルを作成
   const iceMaterial = new CANNON.Material('ice');
   iceMaterial.friction = 0.02;  // 摩擦係数
   iceMaterial.restitution = 0.9;  // 反発係数
   // キューブの物理エンジンボディにマテリアルを設定
   cubeBody.material = plasticMaterial;
  // 床の物理エンジンボディにマテリアルを設定
  groundBody.material = iceMaterial;
  // プラスチックと氷が接触した場合の設定
  const plasticIceContactMaterial = new CANNON.ContactMaterial(
   plasticMaterial,
   iceMaterial,
  {
    friction: 0,  // 接触面の摩擦係数
    restitution: 0.7  // 接触面の反発係数
   }
  );
 // ワールドに接触設定を追加
 world.addContactMaterial(plasticIceContactMaterial);

    // カメラの位置設定
    camera.position.z = 5;

    // アニメーション制御フラグ
    let isAnimating = false;

    // キューブをリセットする関数
    function resetCube() {
      cubeBody.position.set(0, 2, 0);
      cubeBody.velocity.set(0, 0, 0);
      cubeBody.angularVelocity.set(0, 0, 0);
      cubeBody.wakeUp();
      cube.position.set(0, 2, 0);
    }

    // キャンバスがクリックされたときの処理
    function handleClick() {
      isAnimating = !isAnimating;
      if (!isAnimating) {
        resetCube();
      }
    }
    container.addEventListener('click', handleClick);

    // 風の力を定義
    const windForce = new CANNON.Vec3(1, 0, 0);

    // アニメーションループ
    function animate() {
      if (isAnimating) {
        cubeBody.applyForce(windForce, cubeBody.position);
        world.step(1 / 60);
        cube.position.copy(cubeBody.position);
        cube.quaternion.copy(cubeBody.quaternion);
      }
      requestAnimationFrame(animate);
      renderer.render(scene, camera);
    }

    // アニメーション開始
    animate();
  </script>

イベントのリスニング

オブジェクトが衝突したときに特定のアクションをトリガーします
*「Body」に対してイベントリスナーを追加します
‘collide’ イベントは、このオブジェクトが他のオブジェクトと衝突したときに発火します

body.addEventListener('collide', function(event) {
  console.log('衝突を検知');
  // ここで何らかの処理を行う
});

//コールバック関数に渡される eventオブジェクトを使用して詳細情報を取得できます
//例
body.addEventListener('collide', function(event) {
  const impactStrength = event.contact.getImpactVelocityAlongNormal();
  if (impactStrength > 1.5) {
    console.log('強い衝撃を検知');
  }
});

sleep:Bodyが「スリープ状態」に入ったときに発火
wakeup:Bodyが「スリープ状態」から「アクティブ状態」に遷移したときに発火

body.addEventListener('sleep', function() {
  console.log('sleeping');
});

body.addEventListener('wakeup', function() {
  console.log('woke up');
});

Constraints(制約)

HingeConstraint
ドアの蝶番のように働きます
2つの物体がある特定の点と軸周りで回転できるように連結されます

const hingeConstraint = new CANNON.HingeConstraint(bodyA, bodyB, {
  pivotA: new CANNON.Vec3(0, 0, 0),
  axisA: new CANNON.Vec3(0, 1, 0),
  pivotB: new CANNON.Vec3(0, 1, 0),
  axisB: new CANNON.Vec3(0, 1, 0)
});
world.addConstraint(hingeConstraint);

DistanceConstraint
2つの物体が常に一定の距離を保つようにします

const distanceConstraint = new CANNON.DistanceConstraint(bodyA, bodyB, distance);
world.addConstraint(distanceConstraint);

LockConstraint
2つの物体を1つの塊として動かします

const lockConstraint = new CANNON.LockConstraint(bodyA, bodyB);
world.addConstraint(lockConstraint);

PointToPointConstraint
2つの物体(または1つの物体とワールド座標)を特定の点で接続します

const pointToPointConstraint = new CANNON.PointToPointConstraint(
  bodyA, new CANNON.Vec3(0, 0, 0),
  bodyB, new CANNON.Vec3(1, 1, 1)
);
world.addConstraint(pointToPointConstraint);

布(cloth)のような構造をシミュレーションしてみる
ロジックはPlaneGeometryの頂点を、パーティクル(点)として扱います
1:各パーティクルに物理的な性質(位置、質量、速度など)を設定します
2:パーティクルは隣接するパーティクルと「制約(DistanceConstraint)」で接続します
3:各フレームでこの物理的な位置(物理エンジンによって計算されたパーティクルの動き)をPlaneGeometryの頂点に反映させます
*注意:CANNON.jsは物理の世界では重量を考慮して、y軸は通常上方向
一方THREE.jsでPlaneGeometryを作成する際には、y軸は下方向に増加します

<div id="canvas" style="width:100%; height:300px"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js"></script>
<script>
  const container = document.getElementById('canvas');
  const containerWidth = container.offsetWidth;
  const containerHeight = container.offsetHeight;
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(75, container2Width / container2Height, 0.1, 1000);
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(containerWidth, containerHeight);
  container.appendChild(renderer.domElement);
  camera.position.set(0, 0, 1);
  camera.lookAt(0, 0, 0);
  
  const world = new CANNON.World();
  world.gravity.set(0, -9.81, 0);
  
  const col = 10;
  const row = 10;
  const clothSize = 1;
  const dist = clothSize / col; //パーティクル間の距離
  const shape = new CANNON.Particle();
  const particles = [];
//各パーティクルのCANNON.Bodyを作成  
  for (let i = 0; i <= col; i++) {
    particles.push([]);
    for (let j = 0; j <= row; j++) {
      const positionX = (i - col * 0.5) * dist;
      const positionY = (j - row * 0.5) * dist;
      const positionZ = 0;
      const initialPosition = new CANNON.Vec3(positionX, positionY, positionZ);
      const particle = new CANNON.Body({
     //一番上を固定して他の点を自由に動かすため
     //現在の行数が総行数と等しい場合、質量(mass)を0
        mass: j === row ? 0 : 1,
        shape,
        position: initialPosition,
     //パーティクルが布の上端からどれだけ離れているかを示し、これに-0.1を掛けて初期速度を計算
     //布の下端がより速く動き、上端がほぼ動かない
        velocity: new CANNON.Vec3(0, 0, -0.1 * (row - j))
      });
      particles[i].push(particle);
      world.addBody(particle);
    }
  }
//パーティクルがdistの距離を保つように制約するための関数  
  function connect(i1, j1, i2, j2) {
    world.addConstraint(new CANNON.DistanceConstraint(
      particles[i1][j1],
      particles[i2][j2],
      dist,
    ));
  }
//各パーティクルにCANNON.DistanceConstraint(制約)を設定  
  for (let i = 0; i <= col; i++) {
    for (let j = 0; j <= row; j++) {
      if (i < col) connect(i, j, i + 1, j);
      if (j < row) connect(i, j, i, j + 1);
    }
  }
  
  const clothGeometry = new THREE.PlaneGeometry(1, 1, col, row);
  const clothMaterial = new THREE.MeshBasicMaterial({
    color: '#ff0000',
    side: THREE.DoubleSide,
  //  wireframe: true,
  });  
  const clothMesh = new THREE.Mesh(clothGeometry, clothMaterial);
  scene.add(clothMesh);

 // PlaneGeometryの頂点の位置を更新する(パーティクルの位置をPlaneGeometryの頂点にマッピング)関数 
  function updateParticules() {
    for (let i = 0; i <= col; i++) {
      for (let j = 0; j <= row; j++) {
   //2Dのパーティクル配列から1DのPlaneGeometryの頂点へのマッピングが必要
   //そのために2Dのiとjの位置を1Dのインデックスに変換
        const index = j * (col + 1) + i;
        const positionAttribute = clothGeometry.attributes.position;
   //パーティクルの位置の位置を取得
   //(row-j)でy軸方向の順序を反転させて、PlaneGeometryの頂点の位置にあわせる
        const position = particles[i][row - j].position;
        positionAttribute.setXYZ(index, position.x, position.y, position.z);
        positionAttribute.needsUpdate = true;
      }
    }
  }

// 風の力をシミュレートする関数
function applyWindForce() {
  for (let i = 0; i <= col; i++) {
    for (let j = 0; j <= row; j++) {
      const force = new CANNON.Vec3(
        (Math.random() - 0.5) * 2,
        0,
        (Math.random() - 0.5) * 2 
      );
      particles[i][j].applyForce(force, particles[i][j].position);
    }
  }
}

  function animate() {
    applyWindForce();
    updateParticules();
    world.step(1 / 60);
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
  }  
  animate2();
</script>