JavaScriptのアニメーションライブラリは多数あり、どれも一長一短なようで選定が難しい。どれを使えばいいのか悩んでいたところ
http://mrdoob.com/lab/javascript/threejs/css3d/periodictable/
を見て中2心を刺激されたという理由でthree.jsを触ってみることにした。
クリックして何かイベントが起こるようなものは次段階で、とりあえず何かが規則的に動くだけなら簡単なのでは?という安易な発想の元とりあえず太陽の周りを地球が公転するアニメーションを描画してみる。
先にできあがったデモとコードを示しておく。
https://spc-jpn.co.jp/solar-system/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
/** Emulate Solar System Class*/ class SolarSystem { constructor(opts = {}) { this.width = opts.width || window.innerWidth; this.height = opts.height || window.innerHeight; this.output = opts.output || document.createElement('div'); this.scene = new THREE.Scene(); this.loader = new THREE.TextureLoader(); this.earthPosition = 0; this.moonPosition = 0; } /** initialize renderer and camera */ init() { { this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setClearColor(0x000000); this.renderer.setPixelRatio(window.devicePixelRatio || 1); this.renderer.setSize(this.width, this.height); this.output.appendChild(this.renderer.domElement); } { const perscamera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 10000000); // const orthocamera = new THREE.OrthographicCamera(this.width / -2, this.width / 2, this.height / 2, this.height / -2, 1, 10000); this.camera = perscamera; this.camera.position.set(30, 15, 30); } } enableControls() { this.controls = new THREE.TrackballControls(this.camera); this.controls.autoRotate = true; } enableHelper() { const gridHelper = new THREE.GridHelper(100, 50); this.scene.add(gridHelper); const axisHelper = new THREE.AxisHelper(100, 50); this.scene.add(axisHelper); const lightHelper = new THREE.HemisphereLightHelper(this.light, 20); this.scene.add(lightHelper); } /** add lights */ addDirectionalLight() { this.light = new THREE.HemisphereLight( 0xffffbb, 0x080820, 0.5 ); this.light.position.set(10, 10, 0); this.scene.add(this.light); } addAmbientLight() { this.ambientLight = new THREE.AmbientLight(0xcccccc); this.scene.add(this.ambientLight); } /** add objects */ addSun() { const sunTexture = this.loader.load('sun.jpg'); const sunGeometry = new THREE.SphereGeometry(5, 200, 200); const sunMaterial = new THREE.MeshPhongMaterial({ map: sunTexture, }); this.sun = new THREE.Mesh(sunGeometry, sunMaterial); this.scene.add(this.sun); } addEarth() { const earthTexture = this.loader.load('earth.png'); const earthGeometry = new THREE.SphereGeometry(1, 200, 200); const earthMaterial = new THREE.MeshPhongMaterial({ map: earthTexture, }); this.earth = new THREE.Mesh(earthGeometry, earthMaterial); this.scene.add(this.earth); } addMoon() { const moonTexture = this.loader.load('moon.jpg'); const moonGeometry = new THREE.SphereGeometry(0.3, 200, 200); const moonMaterial = new THREE.MeshPhongMaterial({ map: moonTexture, }); this.moon = new THREE.Mesh(moonGeometry, moonMaterial); this.scene.add(this.moon); } addStars() { //create the texture const starTexture = this.loader.load('star.png'); const starsGeometry = new THREE.Geometry(); for (var i = 0; i < 50000; i++) { var star = new THREE.Vector3(); star.x = THREE.Math.randFloatSpread(2000); star.y = THREE.Math.randFloatSpread(2000); star.z = THREE.Math.randFloatSpread(2000); starsGeometry.vertices.push(star); } const starsMaterial = new THREE.PointsMaterial({ map: starTexture, size: 5, transparent: true, }); this.starField = new THREE.Points(starsGeometry, starsMaterial); this.scene.add(this.starField); } /** rendering function */ render() { requestAnimationFrame(() => { this.render(); }); //increase position value this.earthPosition += THREE.Math.degToRad(0.016); this.moonPosition += THREE.Math.degToRad(0.222); //rotate sun this.sun.rotation.y += THREE.Math.degToRad(0.24); //rotate and revolve earth this.earth.rotation.y += THREE.Math.degToRad(6); this.earth.position.x = Math.sin(this.earthPosition) * 20; this.earth.position.z = Math.cos(this.earthPosition) * 20; //rotate and revolve moon this.moon.rotation.y += THREE.Math.degToRad(0.222); this.moon.position.x = Math.sin(this.moonPosition) * 3 + this.earth.position.x; this.moon.position.z = Math.cos(this.moonPosition) * 3 + this.earth.position.z; this.starField.rotation.y += 0.0001; if (typeof this.controls === 'undefined') {} else { this.controls.update(); } this.renderer.render(this.scene, this.camera); } /** resize function */ onResize() { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); } } /** execute */ (() => { const solarSystem = new SolarSystem({ output: document.getElementById('stage'), }); solarSystem.init(); solarSystem.addDirectionalLight(); solarSystem.addAmbientLight(); solarSystem.enableControls(); // solarSystem.enableHelper(); solarSystem.addSun(); solarSystem.addEarth(); solarSystem.addMoon(); solarSystem.addStars(); window.addEventListener('resize', () => { solarSystem.onResize(); }, false); solarSystem.render(); })(); |
見ての通りES6シンタックスなのでBabelなどで要トランスパイル。
舞台を整える
太陽みたいな単純な形状なんてものはきっと球を描画してそれにテクスチャを貼ればいいのだろうとアタリをつけたものの、リファレンスを見るとシーンだの光源だのカメラだのを設置しろって書いてある。なんだそりゃというところでまず躓いた。
https://threejs.org/docs/index.html#manual/introduction/Creating-a-scene
言われてみれば当たり前なのだが、物体を置くには舞台が必要で、物体を視認するには光が必要で、それを映し出すにはカメラが必要だ。その辺はデフォルトでいい感じのものを用意してくれてもよくない?とか思うがこれはもうおまじない感覚で設置してあげる。
同様にどこに描画するのかを指定するためにレンダラーの設定も必須。
色々物体を追加していきたいのでクラスとしてまとめ、各物体を関数から呼び出せるようにする。最低限の要素をコンストラクタとinit関数としてまとめた。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
constructor(opts = {}) { this.width = opts.width || window.innerWidth; this.height = opts.height || window.innerHeight; this.output = opts.output || document.createElement('div'); this.scene = new THREE.Scene(); this.loader = new THREE.TextureLoader(); this.earthPosition = 0; this.moonPosition = 0; } /** initialize renderer and camera */ init() { { this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setClearColor(0x000000); this.renderer.setPixelRatio(window.devicePixelRatio || 1); this.renderer.setSize(this.width, this.height); this.output.appendChild(this.renderer.domElement); } { const perscamera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 10000000); // const orthocamera = new THREE.OrthographicCamera(this.width / -2, this.width / 2, this.height / 2, this.height / -2, 1, 10000); this.camera = perscamera; this.camera.position.set(30, 15, 30); } } |
光源はこんな感じで、平行光源と環境光源を用意してあげる。
1 2 3 4 5 6 7 8 9 10 |
/** add lights */ addDirectionalLight() { this.light = new THREE.HemisphereLight( 0xffffbb, 0x080820, 0.5 ); this.light.position.set(10, 10, 0); this.scene.add(this.light); } addAmbientLight() { this.ambientLight = new THREE.AmbientLight(0xcccccc); this.scene.add(this.ambientLight); } |
レンダラーはこんなおまじない
1 2 3 4 5 6 7 |
/** rendering function */ render() { requestAnimationFrame(() => { this.render(); }); this.renderer.render(this.scene, this.camera); } |
これで舞台は整った。まず舞台・光源・カメラを用意してあげることと、レンダラーを用意し描画先の要素を指定してあげる必要があるということを理解してしまえばなんのことはない。
ヘルパーも表示させておくとなにかと便利なので追加。
グリグリ動かせた方が3D感が出るので、three.jsのサンプルに含まれるTrackballControls.jsを流用させてもらう。これはCDNなどから引っ張ってくることはできなさそうなので、three.jsをダウンロードした中にあるファイルを読み込ませよう。
1 2 3 4 5 6 7 8 9 10 11 12 |
enableControls() { this.controls = new THREE.TrackballControls(this.camera); this.controls.autoRotate = true; } enableHelper() { const gridHelper = new THREE.GridHelper(100, 50); this.scene.add(gridHelper); const axisHelper = new THREE.AxisHelper(100, 50); this.scene.add(axisHelper); const lightHelper = new THREE.HemisphereLightHelper(this.light, 20); this.scene.add(lightHelper); } |
太陽を回す
ここから物体を配置していく。とりあえず太陽を描画してみよう。太陽っぽいテクスチャが必要になるので探す。
https://www.solarsystemscope.com/textures
のようなサイトから拝借してこよう。
Three.jsでの物体の描画は、ジオメトリとマテリアルを作成しそれらをメッシュとして合体させることで成立する。球体のジオメトリを作成し、テクスチャをマテリアルとして作成し、それを貼り付けるという感じだろうか。
1 2 3 4 5 6 7 8 9 10 |
/** add objects */ addSun() { const sunTexture = this.loader.load('sun.jpg'); const sunGeometry = new THREE.SphereGeometry(5, 200, 200); const sunMaterial = new THREE.MeshPhongMaterial({ map: sunTexture, }); this.sun = new THREE.Mesh(sunGeometry, sunMaterial); this.scene.add(this.sun); } |
これで太陽ができあがった。このままでは微動だにしないので太陽を自転させよう。
レンダリング関数にアニメーションを追記する。
1 2 |
//rotate sun this.sun.rotation.y += THREE.Math.degToRad(0.24); |
見ての通り太陽をy軸方向にローテーションさせるという命令を出すだけ。JavaScriptのMathクラスを用いても良いが、three.mathというクラスが用意されておりラジアンへの変換がこちらの方が楽そうだったのでこっちを使う。
角度の計算だが、ここで入力した数値は1フレームあたりに変化する大きさである。通常60fpsで描画するので、1秒に変化させたい角度/60を打ち込めばよい。
今回の場合1日が1秒で過ぎるように調整した。太陽の自転周期は大体25日なので25秒で360°変化すればよい。そうすると計算式は360/25/60=0.24となるわけ。
地球と月を追加しぶん回す!
ここまでできればあとは地球と月を同じ要領で書いていくだけ。
公転に関しても同様に角度を変化させ、x軸・z軸それぞれに対して正弦・余弦の値を取り適当な大きさになるよう定数を掛ければよい。直感的に分かりづらければ色々値を変化させて動きを眺めてみると理解が深まるだろう。
月の公転をどうすればいいのか少し悩んだが、地球が原点(太陽)を中心として回転しているのに対して月は地球を中心に回転するのだから、その分の値(地球の座標)をプラスしていってあげればよいという結論に至った。
1 2 3 4 5 6 7 8 9 10 11 |
//increase position value this.earthPosition += THREE.Math.degToRad(0.016); this.moonPosition += THREE.Math.degToRad(0.222); //rotate and revolve earth this.earth.rotation.y += THREE.Math.degToRad(6); this.earth.position.x = Math.sin(this.earthPosition) * 20; this.earth.position.z = Math.cos(this.earthPosition) * 20; //rotate and revolve moon this.moon.rotation.y += THREE.Math.degToRad(0.222); this.moon.position.x = Math.sin(this.moonPosition) * 3 + this.earth.position.x; this.moon.position.z = Math.cos(this.moonPosition) * 3 + this.earth.position.z; |
オマケの星空
これで太陽・地球・月が回ってくれたわけだが、なんか寂しいので適当に星空を追加する。
適当な星っぽいテクスチャを貼った物体をランダムに配置してあげる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
addStars() { //create the texture const starTexture = this.loader.load('star.png'); const starsGeometry = new THREE.Geometry(); for (var i = 0; i < 50000; i++) { var star = new THREE.Vector3(); star.x = THREE.Math.randFloatSpread(2000); star.y = THREE.Math.randFloatSpread(2000); star.z = THREE.Math.randFloatSpread(2000); starsGeometry.vertices.push(star); } const starsMaterial = new THREE.PointsMaterial({ map: starTexture, size: 5, transparent: true, }); this.starField = new THREE.Points(starsGeometry, starsMaterial); this.scene.add(this.starField); } |
レスポンシブ対応
トドメにレスポンシブに対応する。ウィンドウ幅が変わった時に再計算させてあげれば良い。
1 2 3 4 5 6 7 8 9 |
/** resize function */ onResize() { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); } window.addEventListener('resize', () => { solarSystem.onResize(); }, false); |
3D描画はかなり処理が重そうな気がするのでスマホだと厳しいかと思いきや意外とヌルヌル動く。
ちゃんと計算して大きさとか距離を現実通りにすると、太陽がデカすぎて何がなんだかわからなくなるのであくまで概念図となる。太陽と地球はもっともっと遠い。
太陽系制覇しようとして力尽きた結果
水金地火木までの惑星を追加してみたバージョンがこちら。
https://spc-jpn.co.jp/solar-system/sub/
公転周期と自転周期は概算で現実に近づけてあるが、やはり太陽がデカすぎて距離も遠すぎて地球などが豆粒になってしまうのでその辺りは適当に可視性を保った範囲で定めてある、繰り返すが太陽デカすぎ。太陽以外の各惑星の大きさと距離の比率はおおよそシュミレートできているはず、木星デカい。
星空はランダムに生成したが、リアルを追求するなら星の座標リストを用いて配置してあげれば良い。ヒッパルコス星表のcsvを発見したので、これをjsonに変換し流用すればいける。
http://astronomy.webcrow.jp/hip/
実際にやってみたが非常に重くなる。Web表現としては実用的ではなさそう。
一言
敷居が高いイメージがあった3Dだが、一度触って概念を理解すれば思っていたほど怖くない。とりあえず触ってどんな簡単なものでもいいから作ってみることが大事であることを再確認できた一例であった。
余談だが、太陽フレアをなんかこうかめはめ波的なエフェクトで描画してやろうと思って挫折したので課題としたい。