創下 100,000 顆星

Michael Chang
Michael Chang

你好!我是 Michael Chang,任職於 Google 的 Data Arts 團隊。我們最近完成了 100,000 Stars,這項 Chrome 實驗可將附近的星星視覺化。這個專案是以 THREE.js 和 CSS3D 建構而成,在本個案研究中,我將概述探索過程、分享一些程式設計技巧,並提出一些未來改進的想法。

本文討論的主題相當廣泛,需要具備 THREE.js 的相關知識,但希望您仍能將本文視為技術事後檢討。你可以使用右側的目錄按鈕,直接跳到感興趣的內容。首先,我會展示專案的算繪部分,接著是著色器管理,最後說明如何搭配使用 CSS 文字標籤和 WebGL。

100,000 Stars:Data Arts 團隊的 Chrome 實驗
「100,000 Stars」使用 THREE.js 將銀河中附近的星星視覺化

探索 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 單位都是光年。在本例中,球體寬度為 110,000 光年,涵蓋粒子系統。

我一開始就決定以光年為單位,代表一個 GL 單位 (基本上就是 3D 中的一個像素),這個慣例統一了所有視覺化內容的放置位置,但後來卻造成嚴重的精確度問題。

我決定的另一項慣例是旋轉整個場景,而不是移動攝影機,這是我在其他幾個專案中做過的事。其中一項優點是所有內容都會放置在「轉盤」上,因此只要使用滑鼠向左和向右拖曳,即可旋轉相關物件,而縮放作業只需要變更 camera.position.z 即可。

相機的視野 (或 FOV) 也是動態的。向外拉動時,視野會逐漸擴大,納入越來越多銀河系。反之,如果朝向星星移動,視野就會縮小。這樣一來,攝影機就能透過縮小視野,以神一般的放大鏡檢視微小的事物 (相較於星系),而不必處理近平面剪裁問題。

呈現星系的不同方式。
(上圖) 早期粒子星系。(下方) 粒子伴隨圖像平面。

接著,我就可以將太陽「放置」在距離銀河核心若干單位的距離。我也能繪製出古柏帶懸崖的半徑,進而呈現太陽系的相對大小 (我最後選擇呈現歐特雲)。在這個太陽系模型中,我也可以看到簡化的地球軌道,以及太陽的實際半徑。

太陽系。
行星環繞太陽運行,以及代表古柏帶的球體。

太陽難以算繪,我必須盡可能運用所知的即時繪圖技術來作弊。太陽表面是熱騰騰的電漿泡沫,會隨著時間脈動和變化。這是透過太陽表面紅外線圖像的點陣圖紋理模擬而成。表面著色器會根據這個紋理的灰階進行顏色查閱,並在個別的色階中執行查閱。如果這個查閱作業隨著時間推移而發生變化,就會產生類似熔岩的扭曲效果。

太陽日冕也採用類似技術,但會使用平面精靈卡,並透過 https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js 永遠面向攝影機。

轉譯 Sol。
Sun 的早期版本。

太陽耀斑是透過套用至環面的頂點和片段著色器建立,並在太陽表面邊緣旋轉。頂點著色器具有雜訊函式,因此會以類似斑點的方式交織。

我開始在這裡遇到一些因 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 屬性執行版面配置,這仍快上許多。

文字標籤。
使用 CSS3D 轉換,將文字標籤放在 WebGL 上方。

如要查看這個項目的示範 (以及檢視來源中的程式碼),請按這裡。不過,我發現 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 文字標籤上,即可讓標籤發光並加上陰影。

文字標籤。
將文字標籤附加至 THREE.Gyroscope(),讓文字標籤永遠面向攝影機。

放大檢視時,我發現排版縮放比例會導致定位問題。這可能是因為文字的字元間距和邊框間距所致。另一個問題是,由於 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。

參考資料