創下 100,000 顆星

Michael Chang
Michael Chang

你好!我是 Google 資料藝術團隊的 Michael Chang,我們最近完成了 100,000 Stars Chrome 實驗,以圖像呈現附近的星星。這個專案是使用 THREE.js 和 CSS3D 建構而成。在本個案研究中,我將概略說明發現過程、分享一些程式設計技巧,並在最後分享一些未來改善方向。

這裡討論的主題相當廣泛,而且需要具備一些 THREE.js 知識,但我希望您仍能從中瞭解技術後測。您可以使用右側的目錄按鈕,直接跳到感興趣的區域。首先,我會展示專案的算繪部分,接著是著色器管理,最後是如何搭配使用 CSS 文字標籤和 WebGL。

100,000 Stars,由資料藝術團隊進行的 Chrome 實驗
100,000 Stars 使用 THREE.js 將銀河系附近的星星以視覺化方式呈現

探索聊天室

在完成Small Arms Globe 後不久,我嘗試使用具有景深的 THREE.js 粒子示範。我發現,只要調整套用效果的數量,就能變更場景的「縮放」解讀結果。當景深效果非常極端時,遠處的物體會變得非常模糊,類似傾斜移位攝影的效果,讓人產生正在觀看微觀場景的錯覺。反之,如果將效果調低,就會讓你覺得自己正凝視深空。

我開始尋找可用於插入粒子位置的資料,這條路徑帶我來到 astronexus.com 的 HYG 資料庫,這是三個資料來源 (Hipparcos、Yale Bright Star Catalog 和 Gliese/Jahreiss Catalog) 的匯總,並附帶預先計算的 xyz 笛卡兒座標。那我們開始吧!

繪製星號資料。
第一步是將目錄中的每顆恆星繪製為單一粒子。
命名星星。
目錄中的部分星星有專屬名稱,請參閱下方標示。

我花了大約一小時的時間,將星星資料放入 3D 空間。資料集包含 119,617 顆恆星,因此使用粒子代表每顆恆星,對現代 GPU 來說並非難事。另外還有 87 顆個別標示的星星,因此我使用在 Small Arms Globe 中說明的相同技巧,建立 CSS 標記疊加圖層。

在這段期間,我剛看完質量效應系列。在遊戲中,玩家會被邀請探索銀河系,並掃描各種行星,並閱讀這些行星的完全虛構、類似維基百科的歷史,例如哪些物種曾在行星上繁衍生存、地質歷史等等。

我們知道有許多關於星星的實際資料,因此可以以相同方式呈現銀河系的實際資訊。這個專案的最終目標是將這些資料化為實體,讓觀眾能像《質量效應》(Mass Effect) 一樣探索銀河系,瞭解星星和其分佈情形,並希望能激發觀眾對太空的敬畏和好奇心。呼!

在繼續介紹本案例研究之前,我應該先說明我並非天文學家,這項研究是業餘研究,並獲得外部專家提供的建議。這個專案應視為藝術家對空間的詮釋。

建構 Galaxy

我的計畫是按部就班產生星系模型,讓星星資料有個參考依據,並希望能提供令人驚豔的銀河系全景。

早期的銀河系原型。
銀河粒子系統的早期原型。

為了產生銀河系,我產生了 100,000 個粒子,並模擬星系臂的形成方式,將這些粒子排列成螺旋狀。我對螺旋臂形成的具體情況不太擔心,因為這會是呈現模型,而非數學模型。不過,我確實試著讓螺旋臂的數量大致正確,並且以「正確的方向」旋轉。

在銀河系模型的後續版本中,我減少了粒子的使用,改為搭配粒子的銀河系平面圖,希望能讓模型看起來更像相片。實際圖片是螺旋星系 NGC 1232,距離地球約 7, 000 萬光年,經過圖像處理後看起來像是銀河。

瞭解銀河的規模。
每個 GL 單位都是光年。在本例中,這個球體的寬度為 110,000 光年,涵蓋了粒子系統。

我早早就決定將一個 GL 單位 (基本上是 3D 中的一個像素) 視為一光年,這個慣例可統一所有可視化項目的放置位置,但很遺憾,後來出現嚴重的精確度問題。

我決定採用另一種慣例,也就是旋轉整個場景,而不是移動攝影機,這是我在其他幾個專案中採用的做法。其中一個優點是所有物件都會放置在「轉盤」上,因此只要將滑鼠拖曳向左或向右,即可旋轉相關物件,但若要放大,只需變更 camera.position.z 即可。

相機的視野 (FOV) 也是動態的。當你將視野拉遠時,視野範圍會擴大,可看到越來越多的星系。反之,如果移動方向朝星星移動,視野會縮小。這樣一來,攝影機就能將 FOV 壓縮到類似神奇放大鏡的程度,以便觀察與銀河系相比極微的物體,而無須處理近平面裁剪問題。

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

從這裡,我可以將太陽「放置」在距離銀河核心某個單位的距離。我還可以透過繪製 Kuiper Cliff 的半徑來呈現太陽系的相對大小 (我最後選擇呈現 Oort Cloud)。在這個太陽系模型中,我還可以將地球的簡化軌道和太陽的實際半徑以圖像呈現,並進行比較。

太陽系。
太陽系行星繞著太陽運行,而圓球代表古柏帶。

太陽很難算繪。我必須使用盡可能多的即時圖像技術來作弊。太陽表面是熱的等離子泡沫,需要隨著時間脈動和變化。這項效果是透過太陽表面紅外線圖像的點陣圖紋理模擬而成。表面著色器會根據此紋理的灰階進行顏色查詢,並在個別色階中執行查詢。當這個查詢隨著時間改變時,就會產生這種熔岩般的扭曲效果。

我們也使用類似的技術製作太陽的光暈,只是這次是使用 https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js 的平面圖像卡,讓光暈一律朝向相機。

算繪 Sol。
早期版本的 Sun。

太陽閃光是透過頂點和片段著色器套用到環面上的效果,並在太陽表面邊緣旋轉。頂點著色器具有雜訊函式,會以類似圓點的方式編織。

這時,我開始遇到一些 z-fighting 問題,原因是 GL 精確度。所有精確度變數都是在 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;

}
}

輕鬆執行紋理捲動

靈感來自 Homeworld。
笛卡兒平面,可協助在空間中進行空間定向。

針對「空間方向平面」,我們建立了巨大的 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 物件下方。這比嘗試使用 CSS 屬性「top」和「left」執行版面配置還要快上許多。

文字標籤。
使用 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 從場景「遺失」繼承的方向。這種做法稱為「廣告牌」,而陀螺儀非常適合用於此。

最棒的是,所有一般 DOM 和 CSS 都會繼續運作,例如將滑鼠游標懸停在 3D 文字標籤上,並讓文字標籤以陰影效果發光。

文字標籤。
將文字標籤附加至 THREE.Gyroscope(),讓文字標籤一律朝向攝影機。

放大後,我發現字體縮放導致位置出現問題。這可能是因為文字的字距和邊距?另一個問題是,當您放大時,文字會變得像像素,因為 DOM 轉譯器會將轉譯的文字視為紋理四邊形,這是使用此方法時要留意的事項。回想起來,我本來可以使用超大字型,或許這也是日後可以嘗試的做法。在這個專案中,我還使用了先前所述的「top/left」CSS 版面配置文字標籤,用於太陽系行星中非常小的元素。

音樂播放和循環播放

在《質量效應》的「銀河系地圖」中播放的音樂,是由 Bioware 作曲家 Sam Hulick 和 Jack Wall 創作,這首歌曲能帶給訪客我想要傳達的情緒。我們希望在專案中加入音樂,因為我們認為音樂是營造氛圍的重要元素,有助於營造我們想要的敬畏和驚奇感。

我們的製作人 Valdean Klump 與 Sam 聯絡,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 也花了一些時間製作一些很棒的生成式「太空噪音」,但由於網頁音訊 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 的 Doob 先生。

參考資料