こんにちは。fujimuraです。
今回は前回の記事で匂わせていた、MGVのプログラム解説をしていきたいと思います。
が、プログラムの基本やthree.jsの使い方等は割愛します。
MGVの要点のみ解説していきますので、ある程度の知識がある方向けの内容となります。
あらかじめご了承ください。
開発環境
利用ライブラリは以下の通りです。
・three.js (r52)
https://github.com/mrdoob/three.js/tree/r52
・jquery.js (v1.8.2)
http://blog.jquery.com/2012/09/20/jquery-1-8-2-released/
他、JSファイルは以下の通りです。
jsonデータ。国情報が定義してあります。
jsonデータ。地図情報が定義してあります。
プログラム中で使用する共通変数を定義しています。
地球をパーティクルで表示します。
シャワーをパーティクルで表示します。
目隠し壁を作成します。
メインプログラムです。
それではプログラム解説に移ります。
地球を表示しよう
THREE.ParticleSystemを使い地球のパーティクルを作ります。
具体的には●の画像を球状に配置して立体地図を作ります。
パーティクルは基本的にはエフェクトなどの動きのあるものに対して使うものですが、
同じ絵を大量に扱うという意味では同じ為、このような使い方もできます。
プログラムはearth.jsに定義してあります。
①テクスチャーを用意する
Image may be NSFW.
Clik here to view.
作成するテクスチャー
getTexture関数でテクスチャーを作成しています。
画像ファイルを用意してもできますが、この程度の簡単な絵であれば
canvasに描画して使うこともできます。
ちょっとした変更なら関数の修正だけで対応可能です。
function getTexture() { var canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 100; var cx = Math.floor(canvas.width / 2); var cy = Math.floor(canvas.height / 2); var radius = Math.floor(Math.min(cx, cy)) / 3; var context = canvas.getContext('2d'); var gradient = context.createRadialGradient(cx, cy, 0, cx, cy, radius); gradient.addColorStop( 0.0, 'rgba(255,255,255,0.9)'); gradient.addColorStop( 0.7, 'rgba(255,255,255,0.1)'); gradient.addColorStop( 1.0, 'rgba(255,255,255,0.0)'); context.fillStyle = gradient; context.beginPath(); context.arc(cx, cy, radius, 0, 2 * Math.PI, false); context.fill(); return canvas; };
②複数のパーティクルを用意する
addParticle関数で、地図データに登録されていた経度・緯度毎の陸地(点)を配置します。
追加した陸地=パーティクルとなります。
addParticle: function(geometry, mapData, color, countryColorMap, parameters) { // 地図情報がなければ何もしない if (mapData == null || mapData.length <= 0 || mapData[0].length <= 0) { return; } // 地球の半径 var radius = parameters.radius !== undefined ? parameters.radius : 300; // 経度ポイントの数 var xl = mapData[0].length; // 緯度ポイントの数 var yl = mapData.length; var x, y, z; // 経度の間隔 var spanX = Math.PI / (xl / 2); // 緯度の間隔 var spanY = Math.PI / (yl-1); var useColor = null; var len = 0; var sc = 0; var xi1, xi2; var md, md1, md2; var cid; // latitude : 緯度 : Y軸 for (var yi = 0, latitude = 0; yi < yl; latitude += spanY, yi++) { // 緯度毎に円周の長さが変わるので、経度の間隔を調整する len = Math.round(xl * Math.abs(Math.sin(latitude)) * 1.5); len = Math.max(1, len); sc = ((xl-1) / len); if (sc <= 0) { continue; } else { sc = Math.max(1, sc); } spanX = Math.PI / (xl / 2); spanX *= sc; // longitude : 経度 : X軸 for (var xi = 0, longitude = (2*Math.PI); longitude >= 0; longitude -= spanX, xi += sc) { xi1 = Math.floor(xi); xi2 = Math.ceil(xi); md1 = mapData[yi][xi1]; md2 = mapData[yi][xi2]; md1 = (md1 == undefined) ? 0 : md1; md2 = (md2 == undefined) ? 0 : md2; md = Math.max(md1, md2); // 0以外が陸地、数値の違いは国の違い if (md != 0) { x = radius * Math.cos(longitude) * Math.sin(latitude); z = radius * Math.sin(longitude) * Math.sin(latitude); y = radius * Math.cos(latitude); geometry.vertices.push(new THREE.Vector3( x, y, z )); /* * 国毎の色を管理する。 * 国毎に管理する必要がない場合は同じ色を使い、 * 国毎に管理する場合は、色を分けておく */ // 国ID cid = 0; try { // 色を変更する国であれば、idが返ってくる cid = this.colorMap[md]; } catch (e) {} if (cid == undefined) { cid = 0; } // 国毎の色の取得 useColor = countryColorMap[cid]; // まだ色の登録をしていなければ、登録する if (useColor == null) { useColor = color.clone(); countryColorMap[cid] = useColor; } geometry.colors.push(useColor); } } } }
地球は球なので緯度毎に円周の長さは違います。
それを無理矢理平面地図にしているので、余分な点ができてしまいます。
たとえば緯度が0度であれば円周は0ですが、
平面地図では緯度が90度の時と同じ円周として扱っています。
つまり平面地図では南極は端から端まである超巨大大陸となってしまいます。
緯度によって円周が変わる
Image may be NSFW.
Clik here to view.
平面図では円周は全て同じ
Image may be NSFW.
Clik here to view.
超巨大な南極大陸
今回の場合は経度が0度でも90度でも常に600個の点データがあるということになります。
この余分な点データを経度の間隔を調整して無くしています。
また、パーティクルひとつひとつに色インスタンスを割り当てると
無駄が多くなりますので、同じ国であれば共通の色インスタンスにするようにしています。
こうすることで一つの色を変更しただけで、国全体の色の変更となりますし、
インスタンスの数も減らせます。
円座標を求める式は
x = 半径 * cosθ
y = 半径 * sinθ
でした。
球座標を求める式は
x = 半径 * cosθ* sinφ
y = 半径 * sinθ * sinφ
z = 半径 * cosφ
となります。
③パーティクルシステムを作成する
createParticleSystem関数でパーティクルシステムを作成します。
独自処理も設定し、国の表示位置によって色を変更しています。
地球を回転させるので裏側に行く国が出てきます。
その国の色を変更するようにしています。
createParticleSystem: function(geometry, color, countryColorMap, parameters) { var scope = this; parameters = parameters || {}; // テクスチャーの表示サイズ var size = parameters.size !== undefined ? parameters.size : 10; // マテリアルの設定 var material = new THREE.ParticleBasicMaterial({ // 使用するテクスチャー map: this.txtMask, // 背景色 color: 0xffffff, // 大きさ size: size, // 重なった色のブレンド方法 : 加算合成 blending: THREE.AdditiveBlending, // 透明 transparent: true, // デプスバッファへの書込 : しない depthWrite: false, // パーティクル毎の色の変更 : する vertexColors: true }); var ps = new THREE.ParticleSystem(geometry, material); ps.scale.x = ps.scale.y = ps.scale.z = 1.0; // 独自変数 // 国毎の色 ps.colorMap = countryColorMap; // 国の座標 ps.countryPoints = {}; // 基本色 ps.oColorHex = 0x666666; // アクティブな国の色 ps.aColorHex = 0xFFFF00; // 国の座標を設定 ps.setCountryPoints = function(countryPoints) { this.countryPoints = countryPoints; }; // 更新処理 ps.update = function() { // パーティクルの色を変更します this.geometry.colorsNeedUpdate = true; // 管理されている国毎の処理 for (var key in this.colorMap) { if (key == 0) { continue; } // 国の色 var color = this.colorMap[key]; // 国の座標 var point = this.countryPoints[key]; // 国の座標が裏側かどうか var reverseSide = false; // 座標が裏側かどうか判定 if (point != undefined &amp;amp;amp;amp;amp;&amp;amp;amp;amp;amp; point != null) { var matrix = point.matrixWorld; var absPos = matrix.multiplyVector3(point.position.clone()).multiplyScalar(0.5); var screenPos = screenXY(absPos); if (screenPos.z < 0) { reverseSide = true; } } // 裏側の場合は、基本色で描画 if (reverseSide) { color.setHex(this.oColorHex); } // 表側の場合は、国の色で描画 else { color.setHex(this.aColorHex); } } }; return ps; }
earth.jsの実行結果
Image may be NSFW.
Clik here to view.
シャワーを表示しよう
THREE.ParticleSystemを使いシャワーのパーティクルを作ります。
プログラムはshower.jsに定義してあります。
①シャワーの経路を決める
先ほどの地球と違いパーティクルを動かすので、移動する経路を決める必要があります。
今回は始点から終点までを直線移動させます。
この経路をcreatePath関数で作成しています。
createPath: function(location, parameters) { var radius = parameters.radius || 300; var lat = degree2radian(90 -location[0]+this.commonParams.DIFF_LAT); var lon = degree2radian(180-location[1]+this.commonParams.DIFF_LON); if (lat % this.commonParams.MAP_SPAN_Y != 0) { lat = this.commonParams.MAP_SPAN_Y * Math.round(lat / this.commonParams.MAP_SPAN_Y); } if (lon % this.commonParams.MAP_SPAN_X != 0) { lon = this.commonParams.MAP_SPAN_X * Math.round(lon / this.commonParams.MAP_SPAN_X); } var h = 500; var x1 = (radius+h) * Math.cos(lon) * Math.sin(lat); var y1 = (radius+h) * Math.cos(lat); var z1 = (radius+h) * Math.sin(lon) * Math.sin(lat); var x2 = radius * Math.cos(lon) * Math.sin(lat); var y2 = radius * Math.cos(lat); var z2 = radius * Math.sin(lon) * Math.sin(lat); var p1 = new THREE.Vector3(x1, y1, z1); var p2 = new THREE.Vector3(x1, y1, z1); var p3 = new THREE.Vector3(x2, y2, z2); var p4 = new THREE.Vector3(x2, y2, z2); // 始点付近と終点付近で溜めを作るため、3次ベジェ曲線を利用 var line = new THREE.CubicBezierCurve3(p1, p2, p3, p4); // 頂点の数(曲線の滑らかさ かつ スピード) return line.getPoints(35); }
引数のlocationには国の緯度・経度が入っていますが、
これが先程作った地球の点と位置が合わないと格好悪いので、位置調整をしています。
また、始点から終点までをただの直線ではなく3次ベジェ曲線の直線で定義しています。
3次ベジェ曲線の点P0とP1、P2とP3をそれぞれ同じ座標にすることで、
線を点に分割する際に等間隔にならず
|| | | | | | | | | | | | ||
このような感じで、始点と終点付近の間隔が狭くなるようにしています。
②パーティクルを追加する
addParticle関数でパーティクルを追加します。
先程定義した経路を、どのような条件で移動するかをパーティクルに設定しつつ追加していきます。
addParticle: function(geometry, path, color, parameters) { var cnt = 0; // 1フレームに移動する速度 var speed = parameters.speed !== undefined ? parameters.speed : 0.1; // 追加するパーティクルの数 var count = Math.floor((path.length-1) / speed); for(var p = 0; p < count; p++) { var particle = new THREE.Vector3(0, 0, 10000); // 移動を開始するフレーム数 particle.startFrame = p; // 移動するパス particle.path = path; // 現在パス位置 particle.moveIndex = 0; // 移動パス位置 particle.nextIndex = 1; // 現在座標から移動位置までパーセンテージ particle.lerpN = Math.random(); geometry.colors.push(color); geometry.vertices.push(particle); } }
・移動を開始するFrame数
全てのパーティクルが同時に動き始めては同じ位置に玉が表示されてしまうので、
何Frame目に移動を開始するか定義します。
・初期位置
初期位置を点Aから点2Bの2点間の中でランダムにしています。
このような設定をすることで、同じ動きの中に多少の違いを出すように工夫しています。
③パーティクルシステムを作成する
createParticleSystem関数でパーティクルシステムを作成します。
パーティクルを移動させる処理はTHREE.ParticleSystemのupdate関数に定義します。
createParticleSystem: function(geometry, color, parameters) { parameters = parameters || {}; var size = parameters.size !== undefined ? parameters.size : 10; var material = new THREE.ParticleBasicMaterial({ map: this.txtMask, color: 0xffffff, size: size, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, vertexColors: true }); var ps = new THREE.ParticleSystem(geometry, material); ps.scale.x = ps.scale.y = ps.scale.z = 1.0; // 独自 ps.speed = parameters.speed !== undefined ? parameters.speed : 0.1; // 開始からのフレーム数をカウント ps.frameCount = 0; ps.start = function() { // フレーム数をリセット this.frameCount = 0; }; ps.update = function() { // パーティクルの座標を更新する this.geometry.verticesNeedUpdate=true; do { // フレーム数をカウントする this.frameCount++; // 全パーティクルを処理する for (var i = 0, imax = this.geometry.vertices.length; i < imax; i++) { var particle = this.geometry.vertices[i]; // 開始フレーム未満のパーティクルは処理をスキップする if (particle.startFrame > this.frameCount) { continue; } var path = particle.path; // パス間を移動する particle.lerpN += this.speed; // パス間を移動し終えた場合、その次のパスへ移動する設定をする if (particle.lerpN > 1) { var n = particle.lerpN % 1; particle.lerpN = n; particle.moveIndex += 1; particle.nextIndex += 1; // 全パスを移動した場合は、最初のパスからやり直す if (particle.nextIndex >= path.length) { particle.moveIndex = 0; particle.nextIndex = 1; particle.lerpN = Math.random(); } } // パーティクルの移動を行う var currentPoint = path[particle.moveIndex]; var nextPoint = path[particle.nextIndex]; if (currentPoint &amp;amp;amp;amp;amp;&amp;amp;amp;amp;amp; nextPoint) { particle.copy(currentPoint); particle.lerpSelf(nextPoint, particle.lerpN); } } } while (false); }; return ps; }
・移動速度
1Frame毎に点Aから点Bの間をspeed%進みます。
speedが0.1であれば、1Frame毎に2点間を10%進みます。
1Frame毎にupdate関数が実行されます。
毎update時にspeed%進み、100%になったら移動目標を次の点に変更します。
これを終点まで続けます。
終点まで到達したら始点に戻すことで無限ループさせます。
shower.jsの実行結果
Image may be NSFW.
Clik here to view.
目隠し壁を設置しよう
プログラムはblind_wall.jsに定義してあります。
目隠し壁が必要な理由は前回の記事にも書いてありますが、
つまりは目の錯覚による逆回転現象を軽減させる為となります。
①ベースとなるパネルを用意する
THREE.PlaneGeometryのジオメトリを使い片面パネルを作ります。
裏面からは見えない不思議なパネルです。オプションで両面パネルにもできます。
一旦分かりやすくすため、ワイヤーフレームで表示します。
var radius = 300; var segX = 10; var segY = 10; var mesh = new THREE.Mesh( new THREE.PlaneGeometry(radius*2, radius*2, segX, segY), new THREE.MeshBasicMaterial( { color: 0xffffff, opacity: 0.4, wireframe: true} ) );
Image may be NSFW.
Clik here to view.
PlaneGeometryに渡している引数は、
それぞれ 横幅, 縦幅, 横分割数, 縦分割数 となっています。
分割数?と思われるかもしれませんが、これを指定することで格子状になり
交差点が頂点となります。
この頂点の位置を調整することで、四角形から半球状の物体へと変形させていきます。
また、半透明にするためにTHREE.MeshBasicMaterialに opacity: 0.4 を渡しています。
②半球状に変形する
THREE.Mesh.geometry.vertices配列の中に、
頂点情報が左上から右方向の順に格納されています。
この頂点を球状に配置していきます。
var vertices = mesh.geometry.vertices; for (var i = 0, imax = vertices.length; i < imax; i++) { var x = i % (segX+1); var y = Math.floor(i / (segX+1)); var rx = degree2radian(x * (180 / segX) + 180); var ry = degree2radian(y * (180 / segY)); var vertex = vertices[i]; vertex.x = (radius + 5) * Math.cos(rx) * Math.sin(ry); vertex.z = (radius + 5) * Math.sin(rx) * Math.sin(ry); vertex.y = (radius + 5) * Math.cos(ry); } mesh.geometry.computeFaceNormals(); mesh.geometry.computeVertexNormals();
Image may be NSFW.
Clik here to view.
※見やすい角度にしています。
この絵から見た上側を表面にするのではなく、
裏側を表面(ガチャポンの蓋の裏側を正面から見たような形)にしたいので、
半回転分の角度を足しています。
var rx = degree2radian(x * (180 / segX) + 180);
また、無理やり頂点を変更しただけですので、
変更した頂点に合わせて表面の向きを変更します。
mesh.geometry.computeFaceNormals();
mesh.geometry.computeVertexNormals();
頂点の数が多いほど滑らかな球体になりますので、
実際のプログラムでは分割数は120*120にしています。
Image may be NSFW.
Clik here to view.
※見やすい角度にしています。
わかりやすいようにワイヤーフレームの表示にしていましたが、
実際にはテクスチャを貼り付けるので、
THREE.MeshBasicMaterialに渡すパラメータを変更します。
var radius = 300; var segX = 120; var segY = 120; mesh = new THREE.Mesh( new THREE.PlaneGeometry(radius*2, radius*2, segX, segY), new THREE.MeshBasicMaterial( { map: THREE.ImageUtils.loadTexture( 'back.png'), color: 0xffffff, opacity: 0.4} ) );
Image may be NSFW.
Clik here to view.
これで目隠し壁ができました。
完成結果がこちらになります。
最後まで読んでいただきありがとうございます。
思いのほか長くなってしまいましたが、何かのお役に立てれば幸いです。
それではまた。
fujimura