こんにちは。Google のデータ アート チームに所属している Michael Chang と申します。先日、近くの星を可視化する Chrome Experiments「100,000 Stars」を終了しました。このプロジェクトは THREE.js と CSS3D で作成されています。このケーススタディでは、検出プロセスの概要、プログラミング手法のいくつかを説明し、最後に今後の改善点について説明します。
ここで説明するトピックは非常に幅広く、THREE.js に関する知識が必要ですが、技術的なポストモーテムとして楽しんでいただければ幸いです。右側の目次ボタンを使用して、興味のある項目に移動してください。まず、プロジェクトのレンダリング部分、次にシェーダー管理、最後に CSS テキストラベルを WebGL と組み合わせて使用する方法について説明します。
宇宙の探索
Small Arms Globe の制作が終わった直後、私は被写界深度のある THREE.js パーティクル デモを試していました。適用されるエフェクトの量を調整することで、シーンの解釈された「スケール」を変更できることに気付きました。被写界深度の効果が極端に強くなると、遠くの被写体が非常にぼやけ、傾斜シフト フォトグラフィーで顕微鏡で見たように錯覚を起こすようになります。逆に、効果を下げると、深宇宙を見つめているように見えます。
粒子の位置を挿入するために使用できるデータを探し始め、astronexus.com の HYG データベースにたどり着きました。これは、3 つのデータソース(Hipparcos、Yale Bright Star Catalog、Gliese/Jahreiss Catalog)をまとめたもので、事前に計算された xyz 直交座標が付いています。では、始めましょう。
星データを 3D 空間に配置するプログラムを作成して動作させるのに約 1 時間かかりました。データセットには正確に 119,617 個の星が含まれているため、各星をパーティクルで表現しても、最新の GPU では問題ありません。個別に識別された星も 87 個あるため、Small Arms Globe で説明した手法と同じ方法で CSS マーカー オーバーレイを作成しました。
この間、私は Mass Effect シリーズを終えたばかりでした。このゲームでは、プレイヤーは銀河を探索し、さまざまな惑星をスキャンして、その惑星で繁栄した種や地質学的な歴史など、完全に架空のウィキペディア風の歴史を読み取ることができます。
星に関する豊富な実際のデータを知っている人は、銀河に関する実際の情報を同じように提示できるかもしれません。このプロジェクトの最終的な目標は、このデータを可視化し、視聴者が Mass Effect のように銀河を探索し、星とその分布について学び、宇宙に対する畏敬の念と驚きを抱くきっかけとなることです。さて、
このケーススタディの残りの部分に入る前に、私は天文学者ではなく、これは外部の専門家からのアドバイスを参考にしたアマチュア研究の結果であることを申し上げておく必要があります。このプロジェクトは、アーティストによる空間の解釈として解釈されるべきです。
構築" id="building_a_galaxy" tabindex="-1">銀河の構築
星のデータのコンテキストに沿って銀河のモデルをプロシージャルに生成し、銀河系における地球の位置を素晴らしい視点で表現することを計画しました。
銀河を生成するために、10 万個の粒子を生成し、銀河の腕が形成される方法をエミュレートして螺旋状に配置しました。これは数学的なモデルではなく表現モデルであるため、渦巻き腕の形成の詳細についてはあまり心配しませんでした。ただし、らせん状の腕の数はほぼ正確にし、「正しい方向」に回転するようにしました。
後続バージョンの銀河モデルでは、粒子の使用を抑え、粒子に付随する銀河の平面画像を優先することで、より写真のような外観になるようにしました。実際の画像は、約 7, 000 万光年離れた渦巻銀河 NGC 1232 で、天の川のように見えるように画像処理されています。
初期段階で、1 光年を 1 GL 単位(基本的に 3D のピクセル)として表すことにしました。これは、可視化されるすべてのものの配置を統一する規則でしたが、残念ながら後で精度に関する深刻な問題が発生しました。
もう 1 つの規則として、カメラを動かすのではなくシーン全体を回転させることにしました。これは、他のプロジェクトでも行ってきたことです。1 つの利点は、すべてが「ターンテーブル」に配置されているため、マウスを左右にドラッグすると対象のオブジェクトが回転しますが、ズームインは camera.position.z を変更するだけです。
カメラの画角(FOV)も動的に変化します。外側に引っ張ると、視野が広がり、銀河のより多くの部分が写ります。星に向かって内側に移動すると、視野は狭くなります。これにより、近接クリッピングの問題に対処することなく、画角を神のような拡大鏡にまで圧縮し、銀河に比べて極小の物体を撮影できます。
ここから、銀河の中心から一定の距離に太陽を「配置」できました。また、カイパー崖の半径をマッピングすることで、太陽系の相対的なサイズを可視化することもできました(最終的には、代わりにオールト雲を可視化することにしました)。このモデルの太陽系では、地球の簡素化された軌道と、太陽の実際の半径を比較して可視化することもできます。
太陽のレンダリングが困難でした。そのため、知っている限りのリアルタイム グラフィック手法を使ってごまかす必要がありました。太陽の表面はプラズマの熱い泡であり、時間とともに脈動して変化する必要があります。これは、太陽表面の赤外線画像のビットマップ テクスチャによってシミュレートされました。サーフェス シェーダーは、このテクスチャのグレースケールに基づいて色のルックアップを行い、別のカラーランプでルックアップを実行します。このルックアップが時間とともにずれると、溶岩のような歪みが生じます。
太陽のコロナにも同様の手法が使用されていますが、https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js を使用して、常にカメラを向いているフラットなスプライトカードになっています。
太陽フレアは、トーラスに適用された頂点シェーダーとフラグメント シェーダーによって作成され、太陽の表面の端の周りを回転しています。頂点シェーダーにはノイズ関数があり、これにより、ぼんやりとした形状で織り込まれます。
ここで、GL の精度が原因で z ファイティングの問題が発生し始めました。精度のすべての変数は THREE.js で事前定義されているため、膨大な作業なしに精度を現実的に上げることはできませんでした。精度の問題は原点付近ではそれほど深刻ではありませんでした。しかし、他の恒星系のモデリングを始めると、これが問題になりました。
z ファイティングを軽減するために、いくつかのハックを使用しました。THREE の Material.polygonoffset は、ポリゴンを認識される位置とは異なる位置にレンダリングできるプロパティです(私の理解では)。これは、コロナ面を常に太陽の表面の上にレンダリングするために使用されていました。その下には、球体から離れるシャープな光線を表現するために、太陽の「ハロー」がレンダリングされています。
精度に関連する別の問題として、シーンをズームすると星モデルがジッターし始めるというものがありました。この問題を解決するために、シーンの回転を「ゼロ」にして、星のモデルと環境マップを個別に回転させ、星を周回しているように見せかけました。
レンズフレアの作成
宇宙のビジュアリゼーションでは、フレア効果を過剰に使用しても許されるように感じます。THREE.LensFlare がこの目的に役立ちます。アナモルフィック ヘキサゴンと JJ Abrams を少し追加するだけで、次のスニペットは、シーンでそれらを構築する方法を示しています。
// This function returns a lesnflare THREE object to be .add()ed to the scene graph
function addLensFlare(x,y,z, size, overrideImage){
var flareColor = new THREE.Color( 0xffffff );
lensFlare = new THREE.LensFlare( overrideImage, 700, 0.0, THREE.AdditiveBlending, flareColor );
// we're going to be using multiple sub-lens-flare artifacts, each with a different size
lensFlare.add( textureFlare1, 4096, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
// and run each through a function below
lensFlare.customUpdateCallback = lensFlareUpdateCallback;
lensFlare.position = new THREE.Vector3(x,y,z);
lensFlare.size = size ? size : 16000 ;
return lensFlare;
}
// this function will operate over each lensflare artifact, moving them around the screen
function lensFlareUpdateCallback( object ) {
var f, fl = this.lensFlares.length;
var flare;
var vecX = -this.positionScreen.x _ 2;
var vecY = -this.positionScreen.y _ 2;
var size = object.size ? object.size : 16000;
var camDistance = camera.position.length();
for( f = 0; f < fl; f ++ ) {
flare = this.lensFlares[ f ];
flare.x = this.positionScreen.x + vecX * flare.distance;
flare.y = this.positionScreen.y + vecY * flare.distance;
flare.scale = size / camDistance;
flare.rotation = 0;
}
}
テクスチャ スクロールを簡単に行う方法
「空間の向き平面」には、巨大な THREE.CylinderGeometry() が作成され、太陽を中心に配置されました。外側に広がる「光の波」を作成するために、テクスチャ オフセットを時間とともに次のように変更しました。
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
はマテリアルに属するテクスチャで、上書き可能な onUpdate 関数を取得します。オフセットを設定すると、その軸に沿ってテクスチャが「スクロール」されます。needsUpdate = true をスパムすると、この動作が強制的にループします。
カラーランプを使用する
各星には、天文学者が割り当てた「色指数」に基づいて異なる色が付けられています。一般に、赤い星は温度が低く、青/紫色の星は温度が高いです。このグラデーションには、白と中間オレンジ色の帯があります。
星をレンダリングする際に、このデータに基づいて各粒子に独自の色を付けたいと思いました。そのためには、パーティクルに適用されるシェーダー マテリアルに「属性」を指定します。
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
colorIndex 配列を入力すると、シェーダーで各パーティクルに固有の色が割り当てられます。通常はカラー vec3 を渡しますが、この例では最終的なカラーランプの検索用に浮動小数点数を渡しています。
色傾斜は次のようになりますが、JavaScript からそのビットマップの色データにアクセスする必要がありました。具体的には、まず画像を DOM に読み込み、キャンバス要素に描画してから、キャンバス ビットマップにアクセスしました。
// make a blank canvas, sized to the image, in this case gradientImage is a dom image element
gradientCanvas = document.createElement('canvas');
gradientCanvas.width = gradientImage.width;
gradientCanvas.height = gradientImage.height;
// draw the image
gradientCanvas.getContext('2d').drawImage( gradientImage, 0, 0, gradientImage.width, gradientImage.height );
// a function to grab the pixel color based on a normalized percentage value
gradientCanvas.getColor = function( percentage ){
return this.getContext('2d').getImageData(percentage \* gradientImage.width,0, 1, 1).data;
}
この同じ方法は、星モデルビュー内の個々の星に色を付けるためにも使用されます。
シェーダーの操作
プロジェクトを進めていくうちに、すべての視覚効果を実現するには、シェーダーをどんどん記述する必要があることに気づきました。index.html にシェーダーを配置するのが面倒だったので、この目的のためにカスタム シェーダー ローダを作成しました。
// list of shaders we'll load
var shaderList = ['shaders/starsurface', 'shaders/starhalo', 'shaders/starflare', 'shaders/galacticstars', /*...etc...*/];
// a small util to pre-fetch all shaders and put them in a data structure (replacing the list above)
function loadShaders( list, callback ){
var shaders = {};
var expectedFiles = list.length \* 2;
var loadedFiles = 0;
function makeCallback( name, type ){
return function(data){
if( shaders[name] === undefined ){
shaders[name] = {};
}
shaders[name][type] = data;
// check if done
loadedFiles++;
if( loadedFiles == expectedFiles ){
callback( shaders );
}
};
}
for( var i=0; i<list.length; i++ ){
var vertexShaderFile = list[i] + '.vsh';
var fragmentShaderFile = list[i] + '.fsh';
// find the filename, use it as the identifier
var splitted = list[i].split('/');
var shaderName = splitted[splitted.length-1];
$(document).load( vertexShaderFile, makeCallback(shaderName, 'vertex') );
$(document).load( fragmentShaderFile, makeCallback(shaderName, 'fragment') );
}
}
loadShaders() 関数は、シェーダー ファイル名のリスト(フラグメント シェーダーの場合は .fsh、頂点シェーダーの場合は .vsh を想定)を受け取り、データを読み込もうとします。読み込みに失敗すると、リストをオブジェクトに置き換えます。最終的な結果は、次のようにシェーダーを渡すことができる THREE.js ユニフォームに表示されます。
var galacticShaderMaterial = new THREE.ShaderMaterial( {
vertexShader: shaderList.galacticstars.vertex,
fragmentShader: shaderList.galacticstars.fragment,
/_..._/
});
require.js を使用することもできましたが、この目的のためだけにコードを再構成する必要がありました。このソリューションははるかに簡単ですが、THREE.js 拡張機能として改善できる可能性があります。ご提案や改善方法がございましたら、お気軽にお知らせください。
THREE.js の上に CSS テキストラベルを配置する
前回のプロジェクト「Small Arms Globe」では、THREE.js シーンの上にテキストラベルを表示する方法について試行錯誤しました。私が使用していた方法では、テキストを表示する場所の絶対モデル位置を計算し、THREE.Projector() を使用して画面位置を解決し、最後に CSS の「top」と「left」を使用して CSS 要素を目的の位置に配置します。
このプロジェクトの初期の反復処理では、この同じ手法を使用していましたが、Luis Cruz が説明しているこの他の方法を試してみたかったのです。
基本的な考え方: CSS3D のマトリックス変換を THREE のカメラとシーンに一致させると、CSS 要素を THREE のシーンの上に置くように 3D に「配置」できます。ただし、制限事項もあります。たとえば、テキストを THREE.js オブジェクトの下に配置することはできません。それでも、CSS 属性「top」と「left」を使用してレイアウトを実行するよりもはるかに高速です。
デモ(およびソースコード)はこちらでご覧いただけます。ただし、THREE.js ではマトリックスの順序が変更されていることがわかりました。更新した関数は次のとおりです。
/_ Fixes the difference between WebGL coordinates to CSS coordinates _/
function toCSSMatrix(threeMat4, b) {
var a = threeMat4, f;
if (b) {
f = [
a.elements[0], -a.elements[1], a.elements[2], a.elements[3],
a.elements[4], -a.elements[5], a.elements[6], a.elements[7],
a.elements[8], -a.elements[9], a.elements[10], a.elements[11],
a.elements[12], -a.elements[13], a.elements[14], a.elements[15]
];
} else {
f = [
a.elements[0], a.elements[1], a.elements[2], a.elements[3],
a.elements[4], a.elements[5], a.elements[6], a.elements[7],
a.elements[8], a.elements[9], a.elements[10], a.elements[11],
a.elements[12], a.elements[13], a.elements[14], a.elements[15]
];
}
for (var e in f) {
f[e] = epsilon(f[e]);
}
return "matrix3d(" + f.join(",") + ")";
}
すべてが変換されているため、テキストはカメラに向かなくなります。この問題を解決するには、THREE.Gyroscope() を使用して、シーンから継承された向きを Object3D が「失う」ようにしました。この手法は「ビルボード表示」と呼ばれ、ジャイロスコープはこれに最適です。
3D テキストラベルにマウスオーバーしてドロップシャドウで光らせるなど、通常の DOM と CSS がすべて機能するのは非常に便利です。
ズームインすると、タイポグラフィのスケーリングによって配置に問題が発生することがわかりました。テキストのケーニングとパディングが原因でしょうか?また、DOM レンダラはレンダリングされたテキストをテクスチャ化された四角形として扱うため、ズームするとテキストが粗くなるという問題もありました。この方法を使用する場合は、この点に注意してください。振り返ってみると、巨大なフォントサイズのテキストを使用すればよかったかもしれません。これは今後検討すべき点です。このプロジェクトでは、太陽系の惑星に付随する非常に小さな要素にも、前述の「top/left」CSS プレースメント テキストラベルを使用しました。
音楽の再生とループ
「Mass Effect」の「銀河地図」で流れる楽曲は、Bioware の作曲家 Sam Hulick と Jack Wall によるもので、来場者に感じてもらいたい感情が込められています。音楽は、目指していた神秘的な雰囲気を演出するうえで重要な要素であると考え、プロジェクトに音楽を取り入れました。
プロデューサーの Valdean Klump が Sam に連絡し、Sam は Mass Effect の「カットされた」音楽をたくさん持っていて、それを快く使用させてくれました。トラックのタイトルは「In a Strange Land」です。
音楽の再生に audio タグを使用しましたが、Chrome でも「loop」属性が信頼できないため、ループが失敗することがあります。最終的に、このデュアル オーディオ タグ ハックを使用して、再生の終了をチェックし、再生するために別のタグに切り替えました。残念だったのは、この静止画像が常に完璧にループしなかったことですが、これが最善の方法だったと思います。
var musicA = document.getElementById('bgmusicA');
var musicB = document.getElementById('bgmusicB');
musicA.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playB = function(){
musicB.play();
}
// make it wait 15 seconds before playing again
setTimeout( playB, 15000 );
}, false);
musicB.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playA = function(){
musicA.play();
}
// otherwise the music will drive you insane
setTimeout( playA, 15000 );
}, false);
// okay so there's a bit of code redundancy, I admit it
musicA.play();
改善の余地あり
THREE.js をしばらく使用した後、データがコードと混在しすぎていると感じました。たとえば、マテリアル、テクスチャ、ジオメトリ インストラクションをインラインで定義する場合、基本的には「コードによる 3D モデリング」を行っていました。これは非常に不便で、今後の THREE.js の開発で大幅に改善できる分野です。たとえば、マテリアル データを別のファイルで定義し、必要に応じて表示、調整できるようにして、メイン プロジェクトに戻せるようにします。
同僚の Ray McClure は、生成的な「宇宙のノイズ」を作成するために時間を費やしましたが、ウェブ オーディオ API が不安定で、Chrome が頻繁にクラッシュするため、カットせざるを得ませんでした。残念ですが、今後の作業に向けて、音響空間についてより深く考えるきっかけになりました。本稿執筆時点では、Web Audio API にパッチが適用されているため、現在は動作している可能性があります。今後は注意してください。
WebGL と組み合わせたタイポグラフィ要素は依然として課題であり、ここで行っていることが正しい方法であるとは 100% 確信していません。まだハッキングのように感じます。今後の CSS レンダラを備えた THREE の将来のバージョンでは、2 つの世界をより適切に統合できるかもしれません。
クレジット
このプロジェクトを自由に進めることを許可してくれた Aaron Koblin に感謝します。Jono Brandel 様: 優れた UI デザインと実装、タイポグラフィ、ツアーの実装。Valdean Klump 様(プロジェクトの名前とすべてのコピーを提供)Sabah Ahmed 様: データソースと画像ソースの使用権を大量にクリアしていただきました。Clem Wright 様、公開に向けて適切な担当者に連絡していただきました。Doug Fritz は技術的な優秀さで表彰されました。George Brower 氏に、JS と CSS を教えていただきました。そしてもちろん、THREE.js の Mr. Doob です。