你好!我是 Google 資料藝術團隊的 Michael Chang,我們最近完成了 100,000 Stars Chrome 實驗,以圖像呈現附近的星星。這個專案是使用 THREE.js 和 CSS3D 建構而成。在本個案研究中,我將概略說明發現過程、分享一些程式設計技巧,並在最後分享一些未來改善方向。
這裡討論的主題相當廣泛,而且需要具備一些 THREE.js 知識,但我希望您仍能從中瞭解技術後測。您可以使用右側的目錄按鈕,直接跳到感興趣的區域。首先,我會展示專案的算繪部分,接著是著色器管理,最後是如何搭配使用 CSS 文字標籤和 WebGL。
探索聊天室
在完成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 單位 (基本上是 3D 中的一個像素) 視為一光年,這個慣例可統一所有可視化項目的放置位置,但很遺憾,後來出現嚴重的精確度問題。
我決定採用另一種慣例,也就是旋轉整個場景,而不是移動攝影機,這是我在其他幾個專案中採用的做法。其中一個優點是所有物件都會放置在「轉盤」上,因此只要將滑鼠拖曳向左或向右,即可旋轉相關物件,但若要放大,只需變更 camera.position.z 即可。
相機的視野 (FOV) 也是動態的。當你將視野拉遠時,視野範圍會擴大,可看到越來越多的星系。反之,如果移動方向朝星星移動,視野會縮小。這樣一來,攝影機就能將 FOV 壓縮到類似神奇放大鏡的程度,以便觀察與銀河系相比極微的物體,而無須處理近平面裁剪問題。
從這裡,我可以將太陽「放置」在距離銀河核心某個單位的距離。我還可以透過繪製 Kuiper Cliff 的半徑來呈現太陽系的相對大小 (我最後選擇呈現 Oort Cloud)。在這個太陽系模型中,我還可以將地球的簡化軌道和太陽的實際半徑以圖像呈現,並進行比較。
太陽很難算繪。我必須使用盡可能多的即時圖像技術來作弊。太陽表面是熱的等離子泡沫,需要隨著時間脈動和變化。這項效果是透過太陽表面紅外線圖像的點陣圖紋理模擬而成。表面著色器會根據此紋理的灰階進行顏色查詢,並在個別色階中執行查詢。當這個查詢隨著時間改變時,就會產生這種熔岩般的扭曲效果。
我們也使用類似的技術製作太陽的光暈,只是這次是使用 https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js 的平面圖像卡,讓光暈一律朝向相機。
太陽閃光是透過頂點和片段著色器套用到環面上的效果,並在太陽表面邊緣旋轉。頂點著色器具有雜訊函式,會以類似圓點的方式編織。
這時,我開始遇到一些 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;
}
}
輕鬆執行紋理捲動
針對「空間方向平面」,我們建立了巨大的 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」執行版面配置還要快上許多。
您可以在這裡找到相關示範影片 (以及檢視原始碼中的程式碼)。不過,我發現 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 文字標籤上,並讓文字標籤以陰影效果發光。
放大後,我發現字體縮放導致位置出現問題。這可能是因為文字的字距和邊距?另一個問題是,當您放大時,文字會變得像像素,因為 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 先生。