ビジュアル表現に物理演算を応用する Three.js テクニック
こんにちは、ビジュアル(実装)系の中矢です。
僕は 「あらゆるアニメーションの極意は物理」 だと考えています。
学生の頃、物理の先生が苦手で、授業をちゃんと聞いていなかったことを今でも強く後悔しています。
ところで、「物理演算エンジン」「物理エンジン」と聞くと、難しそうと感じますか?
実際に業務で活用するチャンスはなかなか訪れないかもしれませんね。
この記事では、チャンスを掴み Three.js と PHY を用いて物理演算を活用したビジュアル表現の制作秘話を紹介します。
対象読者は、物理演算エンジンを使ったプロジェクトを始めたい開発者を想定しています。
目次
プロジェクトの背景とコンセプト
先日、弊社の公式 Web サイト をリニューアルしました!
コンセプトは 「オロ社員をはじめ様々な関係者が相互に関わり合いながら価値を創出し、それが広がっていく」 というもの。
このコンセプトから 「空間を漂う球体が互いに作用しながらその色を変化させる」 ビジュアルデザインに仕上がりました。
技術的には、物理演算エンジンを活用して重力や衝突などの物理的な要素を取り入れ、リアルな挙動を表現する方針を立てました。
以下は、ビジュアルデザインと完成図の比較です。
なかなか再現できている気がします。
ビジュアルデザイン
完成図
技術選定
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つだけ。
phy.init
phy.set
phy.add
phy.change
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]
を設定します。
このほか、full
や jointVisible
といったプロパティが存在しますが、機能は不明でした。
オブジェクトの追加・編集
オブジェクトの追加は 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について
container
は box
と同じように箱型のジオメトリをアウトプットしますが、用途が明確に異なります。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
- ビジュアルとして多すぎず少なすぎず、画面内で程よい密度を保って美しく漂う ように調整
mass
はsize
と同期させることで 大きいボールほど質量を持たせ、よりリアルに調整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 したオブジェクトの
material
のcolor
を変更可能
オープニング演出
サイトアクセス時には、フェードインとシーン全体を回転させるエフェクトを仕込んでいます。
最初に 大胆に画面変化することでアイキャッチ になるよう演出しています。
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.change
でname
を指定するとパラメータを変更可能 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 公式のデモは、試すだけでもかなり遊べてしまうので、ぜひチェックしてみてくださいね!