你好!我是 Michael Chang,任職於 Google 的 Data Arts 團隊。我們最近完成了 100,000 Stars,這項 Chrome 實驗可將附近的星星視覺化。這個專案是以 THREE.js 和 CSS3D 建構而成,在本個案研究中,我將概述探索過程、分享一些程式設計技巧,並提出一些未來改進的想法。
本文討論的主題相當廣泛,需要具備 THREE.js 的相關知識,但希望您仍能將本文視為技術事後檢討。你可以使用右側的目錄按鈕,直接跳到感興趣的內容。首先,我會展示專案的算繪部分,接著是著色器管理,最後說明如何搭配使用 CSS 文字標籤和 WebGL。

探索 YouTube Space
完成 Small Arms Globe 後不久,我開始試用 THREE.js 粒子示範,並加入景深效果。我發現調整套用的效果量,可以改變場景的「比例」。如果景深效果非常明顯,遠處物體就會變得非常模糊,類似於移軸攝影,讓人產生在看微觀場景的錯覺。反之,如果調低效果,就會像凝視深太空。
我開始尋找可用於注入粒子位置的資料,最後在 astronexus.com 的 HYG 資料庫中找到,這個資料庫彙整了三個資料來源 (Hipparcos、Yale Bright Star Catalog 和 Gliese/Jahreiss Catalog),並附上預先計算的 xyz 笛卡兒座標。我們馬上開始!


我花了約一小時,拼湊出可將星體資料放置在 3D 空間中的程式。資料集中的星星數量正好是 119,617 顆,因此以粒子代表每顆星星對現代 GPU 來說並非難事。此外,還有 87 顆個別識別的星星,因此我使用與「小型武器地球」所述相同的技術,建立 CSS 標記疊加層。
當時我剛玩完《質量效應》系列遊戲,在遊戲中,玩家可以探索銀河,掃描各種行星並閱讀完全虛構、類似維基百科的歷史:哪些物種曾在該行星上蓬勃發展、地質歷史等等。
由於有大量關於恆星的實際資料,因此可以想見,我們也能以同樣的方式呈現星系的真實資訊。這項計畫的最終目標是讓這些資料活靈活現,讓觀眾能像在《質量效應》中一樣探索銀河,瞭解恆星及其分布情形,並希望激發對太空的敬畏和好奇心。呼!
在繼續說明這個案例研究之前,我應該先聲明我絕非天文學家,這項研究是業餘研究,並獲得外部專家的一些建議。這個專案絕對應解讀為藝術家對空間的詮釋。
建構 Galaxy
我的計畫是按部就班地生成星系模型,將恆星資料放入脈絡中,並希望呈現我們在銀河中的位置,讓使用者一覽無遺。

為了生成銀河,我產生了 10 萬個粒子,並模擬銀河臂的形成方式,將這些粒子排列成螺旋狀。我不太擔心螺旋臂形成的具體細節,因為這會是代表性模型,而非數學模型。不過,我還是盡量讓旋臂的數量大致正確,並朝「正確的方向」旋轉。
在後續版本的銀河模型中,我減少了粒子的使用,改用星系的平面圖像搭配粒子,希望呈現更像照片的外觀。實際圖片是螺旋星系 NGC 1232,距離我們約 7, 000 萬光年,經過影像處理後看起來像銀河。

我一開始就決定以光年為單位,代表一個 GL 單位 (基本上就是 3D 中的一個像素),這個慣例統一了所有視覺化內容的放置位置,但後來卻造成嚴重的精確度問題。
我決定的另一項慣例是旋轉整個場景,而不是移動攝影機,這是我在其他幾個專案中做過的事。其中一項優點是所有內容都會放置在「轉盤」上,因此只要使用滑鼠向左和向右拖曳,即可旋轉相關物件,而縮放作業只需要變更 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 的攝影機和場景相符,您就能「放置」3D CSS 元素,就像在 THREE 的場景上一樣。不過,這項功能有其限制,例如您無法讓文字顯示在 THREE.js 物件下方。相較於使用「top」和「left」CSS 屬性執行版面配置,這仍快上許多。

如要查看這個項目的示範 (以及檢視來源中的程式碼),請按這裡。不過,我發現 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「失去」從場景繼承的方向。這項技術稱為「看板」,而 Gyroscope 非常適合用來執行這項操作。
最棒的是,所有正常的 DOM 和 CSS 仍可搭配使用,例如將滑鼠游標懸停在 3D 文字標籤上,即可讓標籤發光並加上陰影。

放大檢視時,我發現排版縮放比例會導致定位問題。這可能是因為文字的字元間距和邊框間距所致。另一個問題是,由於 DOM 算繪器會將算繪的文字視為紋理四邊形,因此放大時文字會出現象素化現象。使用這個方法時,請務必留意這點。回想起來,我當時可以只使用超大字型的文字,或許這也是未來可以探索的方向。在這個專案中,我也使用了先前說明的「top/left」CSS 放置文字標籤,用於太陽系中行星旁邊的極小元素。
播放和循環播放音樂
《質量效應》「銀河地圖」中播放的音樂是由 Bioware 作曲家 Sam Hulick 和 Jack Wall 創作,而這正是我想讓訪客感受到的情緒。我們希望在專案中加入一些音樂,因為我們認為這是營造氛圍的重要元素,有助於創造我們想達成的敬畏和驚奇感。
我們的製作人 Valdean Klump 聯絡了 Sam,他慷慨地提供了一堆《質量效應》的「剪接室」音樂供我們使用。音軌名稱為「In a Strange Land」。
我使用音訊標記播放音樂,但即使在 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 也花了一些時間製作很棒的生成式「太空噪音」,但由於 Web Audio API 不穩定,不時會導致 Chrome 崩潰,因此必須刪除。這很可惜,但確實讓我們在未來的工作中,更深入思考聲音領域。據我所知,撰寫本文時,Web Audio API 已修補完畢,因此現在可能可以正常運作,但日後仍需留意。
將排版元素與 WebGL 配對仍是一項挑戰,我不太確定我們目前的做法是否正確。這還是感覺像是一種變通方法。或許未來版本的 THREE 搭配即將推出的 CSS 轉譯器,就能更完美地結合這兩個世界。
抵免額
感謝 Aaron Koblin 讓我盡情發揮,完成這個專案。感謝 Jono Brandel 提供出色的 UI 設計 + 實作、字體處理和導覽實作。感謝 Valdean Klump 為專案命名並提供所有副本。感謝 Sabah Ahmed 處理資料和圖片來源的大量使用權。感謝 Clem Wright 協助我們聯絡合適的發布對象。Doug Fritz,表彰其卓越技術。感謝 George Brower 教導我 JS 和 CSS。當然還有 THREE.js 的 Mr. Doob。