2024年08月19日

ビジュアル表現に物理演算を応用する Three.js テクニック

こんにちは、ビジュアル(実装)系の中矢です。

僕は 「あらゆるアニメーションの極意は物理」 だと考えています。
学生の頃、物理の先生が苦手で、授業をちゃんと聞いていなかったことを今でも強く後悔しています。

ところで、「物理演算エンジン」「物理エンジン」と聞くと、難しそうと感じますか?
実際に業務で活用するチャンスはなかなか訪れないかもしれませんね。

この記事では、チャンスを掴み Three.js と PHY を用いて物理演算を活用したビジュアル表現の制作秘話を紹介します。
対象読者は、物理演算エンジンを使ったプロジェクトを始めたい開発者を想定しています。

目次

プロジェクトの背景とコンセプト

先日、弊社の公式 Web サイト をリニューアルしました!

コンセプトは 「オロ社員をはじめ様々な関係者が相互に関わり合いながら価値を創出し、それが広がっていく」 というもの。
このコンセプトから 「空間を漂う球体が互いに作用しながらその色を変化させる」 ビジュアルデザインに仕上がりました。
技術的には、物理演算エンジンを活用して重力や衝突などの物理的な要素を取り入れ、リアルな挙動を表現する方針を立てました。

以下は、ビジュアルデザインと完成図の比較です。
なかなか再現できている気がします。

ビジュアルデザイン

ビジュアル1

完成図

ビジュアル1

技術選定

PHY

物理演算エンジンの実装にあたり、数ある技術の中から今回は「PHY」というライブラリを使用しました。
厳密には PHY 自体は物理演算エンジンではなく、Three.js プロジェクトでの物理演算エンジンの導入を簡素化するラッパーライブラリ。

PHY を使えば、有名な物理演算エンジン Oimo, Ammo, Havok などを wasm(WebAssembly)経由で簡単に活用できます。
そして PHY の特徴である すべての物理演算エンジンに同じコマンドを提供する ことに着目し、選定しました。

なお、物理演算エンジンは Havok を選択しています。
開発中は Physx, Jolt, Havok を検証しながら進め、最終的に Havok の独自機能 impulse に着目しました。

本記事の内容は Havok を基準に書いている ため、他の物理演算エンジンでは動作しないことがあります。

PHYの問題点

PHY を導入するにあたり、大きな障壁がありました。
それは、公式ドキュメントにはパラメータの紹介など詳細に記述されていない、というネガティブな点です。
実装を進めるためには コアの実装をコードリーディングする必要 があります。
参考文献もほとんど見つかりません。

デモのコードを手がかりにコアの実装を調査し、狙った効果を得る作業がとても大変。
まぁ、それも楽しいんですけど。

本記事を読めば、コードリーディングの大部分をショートカットできるかもしれません!

GSAP

アニメーションライブラリは、みんな大好き GSAP です。
Three.js や PixiJS をはじめとした WebGL コンテンツでもバリバリ活用できますね!

本記事では GSAP の解説は行いませんが、サンプルコードで頻出するため紹介しておきます。

基本的な使い方

ここでは、PHY の基本的なメソッドを紹介します。
公式が「簡単に扱える」と謳うだけあり 使い方は非常にシンプル で、今回使用するメソッドは5つだけ。

  1. phy.init
  2. phy.set
  3. phy.add
  4. phy.change
  5. phy.setGravity

初期化と初期設定

まずは 無重力空間 を作ります。
以下はそのセットアップ構成です。

phy.init の callback を経由し、 phy.set で世界を作ります。

// 初期化
phy.init({
  type: 'HAVOK', 
  worker: true,
  compact: true,
  scene: new THREE.Scene(),
  renderer: new THREE.WebGLRenderer(),
  callback: () => {
    // 初期設定
    phy.set({
      substep: 1,
      fps: 60,
      gravity: [0, 0, 0],
    });
  },
})

phy.initのパラメータ

type で物理演算エンジンを選択します。
確かに同じコマンドで命令できますが、同じパラメータでもエンジンごとに違う挙動を示す ことを確認しているので注意が必要です。
それぞれ検証することをおすすめします。

  • OIMO
  • AMMO
  • RAPIER
  • JOLT
  • PHYSX
  • HAVOK(弊社サイトで選択したのはこれ!)

phy.setのパラメータ

プロパティ 初期値 効果
substep Int 2 処理回数、数値を上げると精度向上するが高負荷
fps Int 60 フレームレート
gravity Array [0, -9.81, 0] 重力、順に x, y, z 方向

substep は、精度調整用パラメータです。
値を大きくすると当然、処理が重くなる傾向にあります。
ただ、弊社サイトでは最低の 1 に設定しても物理が破綻するなどの違和感は起こりませんでした。

gravity は、その名の通り重力値です。
Array 型で、順に x, y, z 方向に重力値を設定できます。
地球の重力(平均値)は 9.80665 m/s2 であり、PHY の初期値にも [0, -9.81, 0] が設定されています。
月の重力 1.62 m/s2 を再現したければ [0, -1.62, 0] ですね!

弊社サイトでは無重力空間を再現したいので [0, 0, 0] を設定します。

このほか、fulljointVisible といったプロパティが存在しますが、機能は不明でした。

オブジェクトの追加・編集

オブジェクトの追加は phy.add を使用します。

編集は phy.change を使用し、phy.add で設定した name で対象を指定します。

// 追加
phy.add({
  name: 'object',
  type: 'sphere',
  size: [10, 10, 10],
  pos: [0, 0, 0],
  mass: 1,
  material: new THREE.MeshPhysicalMaterial(), 
})

// 変更
phy.change({
  name: 'object',
  size: [10, 10, 10],
  pos: [1000, 0, 0],
})

phy.addおよびphy.changeのパラメータ

プロパティ 初期値 効果
type String box ジオメトリ指定
name String automatic 名前を付与すると後から指定できる
size Array [0, 0, 0] 大きさ x, y, z で、x に負数を与えると衝突しなくなる
pos Array [0, 0, 0] 位置、左から x, y, z
rot Array [0, 0, 0] 回転、左から x, y, z
impulse Array [0, 0, 0] 弾く力と方向、左から x, y, z
gravityScale Float 1.0 重力抵抗力、負数を与えると逆方向に動く
mass Float 0.0 質量
density Float 0.0 密度
restitution Float 0.0 反発力
material Three.js material MeshStandardMaterial Three.js の material を指定
b1 String ※ 衝突判定の対象A、name を渡す
b2 String ※ 衝突判定の対象B、name を渡す

上記は、コードリーディングで判明したプロパティを自分なりに解釈した表です。

特徴として、質量 mass または密度 density を与えると重力の影響下に置かれます。
このどちらも指定しない場合、重力影響を受けない固定オブジェクトとして出力します。

typeについて

type 形状
box ボックス
capsule カプセル(薬のような)
cone コーン
container ボックス(内側にオブジェクトが存在できる)
cylinder 円筒
highSphere 球体(高ポリゴン)
sphere 球体(低ポリゴン)
particle パーティクル(特殊な設定あり)
plane 平板
stair 平板( plane と使い分け不明)
contact ※ 非ジオメトリ、衝突判定の指定

type は特殊なプロパティで、PHY 組み込みのジオメトリを指定します。

弊社サイトでは highSphere を使用しています。

上記以外も定義されているようですが、Havok で動作したものだけ掲載しています。

type/contactについて

contact は特殊な指定で、衝突判定を設定するための type です。

phy.add({
  type: 'contact',
  b1: 'OBJECT-A',
  b2: 'OBJECT-B',
  callback: d => {
    // 毎フレーム実行
    if (d.hit) {
      // OBJECT-A と OBJECT-B が衝突した時のコード
    }
  }
})

b1 および b2 には phy.add で設定した name を指定します。

callback毎フレーム実行 され、衝突時の実行ではない 点に注意が必要です。
代わりに、object 型の返り値のうち hit に boolean 型の判定結果が返ります。

PHY の改善点として、このメソッドに関しては phy.add に包括せず、例えば phy.contact のような実装になると良いですね。
役割を明確化しておくことは良いことです。

type/containerについて

containerbox と同じように箱型のジオメトリをアウトプットしますが、用途が明確に異なります。
container の場合、壁の内側にオブジェクトが存在できます。

この特徴を活かして、大きく作ると全体を囲う「外壁」として機能 させることができます。
つまり、物理的な閉鎖空間を作れるのです。

さらに material を指定しなければ見えない壁になります。

ただし、僕の環境では container を使うと高頻度でエラーが発生 し、動作停止してしまいました。
エラー発生条件および回避策は未調査のため、ログとして以下に記しておきます。

Uncaught DOMException: Failed to execute 'postMessage' on 'Worker': function Rt(t){const e=t.target;e.removeEventListener("dispose",Rt),function(t){(function(t){const e=Q.get(t).p...<omitted>...)} could not be cloned.
    at Object.post (http://localhost:8060/ja/js/phy-engine/build/Phy.module.js:42586:21)
    at Motor.step (http://localhost:8060/ja/js/phy-engine/build/Phy.module.js:43038:23)
    at Worker.message (http://localhost:8060/ja/js/phy-engine/build/Phy.module.js:42563:15)

弊社サイトでは6 枚の plane を床、天井、壁として add しました。
本当は便利な container を使いたかったのですが…。

ビジュアルの実装

ここでは、ビジュアルの実装について紹介します。
ボールの追加、衝突判定、演出をセッティングして、ボールが飛び交う 3D ビジュアルを作り出します。

ボールの追加

閉鎖空間内に 140 個のグレーのボールを放ちます。
ボールのパラメータは、最適に決まるまで長い時間をかけてチューニングしました。

let ball = []
for (let i = 0; i < 140; i++) {
  const randomSize = Math.random() * 16 + 10
  const randomPosition = {
    x: Math.random() * 200 - 100,
    y: Math.random() * 200 - 100,
    z: Math.random() * 200 - 100,
  }

  ball[i] = phy.add({
    name: 'ball' + i,
    size: randomSize,
    pos: [randomPosition.x, randomPosition.y, randomPosition.z],
    material: new THREE.MeshPhysicalMaterial(), 
    gravityScale: Math.random() * 0.8 + 0.2,
    mass: randomSize / 10,
    impulse: [randomPosition.x, randomPosition.y, randomPosition.z],
    restitution: 1,
  })

  ball[i].mode = 'gray'
}

POINT

  • ビジュアルとして多すぎず少なすぎず、画面内で程よい密度を保って美しく漂う ように調整
  • masssize と同期させることで 大きいボールほど質量を持たせ、よりリアルに調整
  • gravityScale にランダム値を与えることで重力方向の変化に対するレスポンスの速さにバラつきを持たせ、隠し味のような面白さ を付与

衝突判定の実装

コンセプトの「広がっていく」を表現 するため、ボール衝突時のインタラクションを実装しました。

「イエロー」が「グレー」に衝突したとき「イエローに変える」というロジックを仕込みます。

for (let i = 0; i < 140; i++) {
  for (let j = 0; j < 140; j++) {
    phy.add({
      type:'contact',
      b1:'ball' + i,
      b2:'ball' + j,
      callback: d => {
        if (d.hit) {
          let target
          if (ball[i].mode == 'yellow' && ball[j].mode == 'gray') target = ball[j]
          else if (ball[j].mode == 'yellow' && ball[i].mode == 'gray') target = ball[i]
          else return

          target.mode = 'yellow'
          let initial = new THREE.Color(0xD1C4B5)
          let value = new THREE.Color(0xFABE04)

          gsap.to(initial, {
            r: value.r,
            g: value.g,
            b: value.b,
            onUpdate: () => {
              target.material.color = initial
            }
          })
        }
      }
    })
  }
}

POINT

  • 衝突に関する包括的な設定がなく、まさかのボール総当たりロジック
  • カラーは add したオブジェクトの materialcolor を変更可能

オープニング演出

サイトアクセス時には、フェードインとシーン全体を回転させるエフェクトを仕込んでいます。
最初に 大胆に画面変化することでアイキャッチ になるよう演出しています。

gsap.to('canvas', {
  opacity: 1,
})

gsap.to(scene.rotation, {
  x: Math.PI / 180 * 15,
  y: Math.PI / 180 * 20,
})

開幕ビリヤードショット

オープニング直後は全体的に緩慢な動きを見せますが、すぐに強めのショットボールが横切り、中央のボールに衝突します。
このショットボールは唯一のイエローボールであり、ここから色が広がっていきます。
ショットボールによって他のボールが弾かれ連鎖していく様子を見せることで、今見ているビジュアルに物理法則の存在を認知 させています。

// 最初のボールをセット
php.add({
  name: 'firstBall'
})

// x 方向に弾く
phy.change({
  name: 'firstBall',
  impulse: [100, 0, 0]
})

POINT

  • 既存オブジェクトは phy.changename を指定するとパラメータを変更可能
  • impulse は Havok でしか動作しない

マウス位置による重力方向変化

マウスの位置によって重力方向が変わるインタラクションを仕込んでいます。
これは、コンセプトの「価値を創出」の部分にある 「自分たちが価値を創出していくんだ」というニュアンスを表現 しています。

container.addEventListener('mousemove', event => {
  phy.setGravity([
    event.clientX - window.innerWidth / 2,
    -(event.clientY - window.innerHeight / 2),
    0,
  ])
})

また、隠し要素として Android 端末のみジャイロセンサーで重力変化を楽しめます。
iOS 端末の方はゴメンナサイ。

window.addEventListener('deviceorientation', event => {
  phy.setGravity([
    event.gamma,
    -event.beta,
    0,
  ])
})

POINT

  • phy.setGravity で後から重力を変更可能
  • y 値に正数を与えると上方向の重力が発生する

完全停止の抑制

サイトアクセス後にしばらく放置していると、運動エネルギーは時間の経過とともに収束し、だんだんと緩慢な動きになってしまいます。
これを防止する工夫として、一定間隔でいずれかのボールが弾かれる演出を組み込んでいます。
弾かれたボールは別のボールに衝突を繰り返し、ビジュアルは常に変化し続けます。

setTimeout(() => {
  const id = Math.ceil(Math.random() * 140)
  phy.change({
    name: 'ball' + ceil,
    impluse: [
      Math.random() * 200 - 100,
      Math.random() * 200 - 100,
      Math.random() * 200 - 100
    ]
  })
}, 5000)

終わりに

Three.js + GSAP + PHY + Havok による物理演算を用いた演出テクニック について、弊社サイトのリニューアルをもとに紹介しました。
PHY の日本語の参考文献はまだ少ないので、参考にしてくれると嬉しいです。
今後は、Havok 以外の物理演算エンジンの挙動を検証してみたいですね。

PHY はまだ開発途上のようですが、オーナーのアップデート活動はとてもアクティブなので、今後に期待。
PHY 公式のデモは、試すだけでもかなり遊べてしまうので、ぜひチェックしてみてくださいね!

RELATED POSTS