
摘要
我們邀請了六位藝術家,在 VR 中進行繪畫、設計和雕塑。以下是我們記錄使用者的工作階段、轉換資料,以及透過網路瀏覽器即時呈現資料的程序。
https://g.co/VirtualArtSessions
真是美好的一天!隨著虛擬實境成為消費性產品,我們也發現了許多新穎的可能性。Tilt Brush 是 HTC Vive 上的 Google 產品,可讓您在三維空間中繪圖。第一次使用 Tilt Brush 時,你會感受到使用動作追蹤控制器繪圖的感受,以及「在有超能力的房間裡」的存在感;因為在空無一物的空間中繪圖,是一種獨特的體驗。

Google 資料藝術團隊面臨的挑戰,是如何在 Tilt Brush 尚未支援的網路上,向沒有 VR 頭戴式裝置的使用者展示這項體驗。為此,團隊邀請了雕塑家、插畫家、概念設計師、時尚藝術家、裝置藝術家和街頭藝術家,以這種新媒介創作各自風格的藝術作品。
在虛擬實境中錄製繪圖
Tilt Brush 軟體本身是建構在 Unity 上的桌面應用程式,可使用房間規模 VR 追蹤頭部位置 (頭戴式螢幕,或 HMD) 和雙手中的控制器。根據預設,在 Tilt Brush 中建立的藝術作品會匯出為 .tilt
檔案。為了在網頁上提供這項體驗,我們發現除了圖片資料之外,還需要其他資訊。我們與 Tilt Brush 團隊密切合作,修改 Tilt Brush 的功能,讓這項工具每秒 90 次匯出撤銷/刪除動作,以及藝術家的頭部和手部位置。
繪圖時,Tilt Brush 會取得控制器的位置和角度,並將時間內的多個點轉換為「筆劃」。如需查看範例,請前往這個頁面。我們編寫了可擷取這些筆劃的外掛程式,並將這些筆劃輸出為原始 JSON。
{
"metadata": {
"BrushIndex": [
"d229d335-c334-495a-a801-660ac8a87360"
]
},
"actions": [
{
"type": "STROKE",
"time": 12854,
"data": {
"id": 0,
"brush": 0,
"b_size": 0.081906750798225,
"color": [
0.69848710298538,
0.39136275649071,
0.211316883564
],
"points": [
[
{
"t": 12854,
"p": 0.25791856646538,
"pos": [
[
1.9832634925842,
17.915264129639,
8.6014995574951
],
[
-0.32014992833138,
0.82291424274445,
-0.41208130121231,
-0.22473378479481
]
]
}, ...many more points
]
]
}
}, ... many more actions
]
}
上方程式碼片段概述了草圖 JSON 格式的格式。
在此,每個筆劃都會儲存為動作,類型為「STROKE」。除了筆劃動作之外,我們還想展示藝術家在素描過程中犯錯並改變心意的情形,因此必須儲存「DELETE」動作,以便在整個筆劃中執行擦除或復原動作。
系統會儲存每個筆劃的基本資訊,因此會收集筆刷類型、筆刷大小和 RGB 顏色。
最後,系統會儲存筆劃的每個頂點,其中包含位置、角度、時間,以及控制器的觸發壓力強度 (在每個點中以 p
表示)。
請注意,旋轉是 4 元組四元數。這在稍後我們要轉譯筆劃以避免萬向節鎖定時非常重要。
使用 WebGL 回放草圖
為了在網路瀏覽器中顯示草圖,我們使用了 THREE.js,並編寫幾何產生程式碼,模擬 Tilt Brush 在幕後執行的操作。
雖然 Tilt Brush 會根據使用者的手勢動作即時產生三角形條紋,但在網頁上顯示時,整個草圖已「完成」。這可讓我們略過大部分即時計算,並在載入時烘焙幾何圖形。

筆劃中的每個頂點組合都會產生方向向量 (連結每個點的藍線,如上圖所示,下方程式碼片段中的 moveVector
)。每個點也包含方向,也就是代表控制器目前角度的四元數。為了產生三角形條紋,我們會對這些點逐一執行疊代,產生垂直於方向和控制器方向的切線。
計算每個筆觸的三角形條紋的程序,幾乎與 Tilt Brush 中使用的程式碼相同:
const V_UP = new THREE.Vector3( 0, 1, 0 );
const V_FORWARD = new THREE.Vector3( 0, 0, 1 );
function computeSurfaceFrame( previousRight, moveVector, orientation ){
const pointerF = V_FORWARD.clone().applyQuaternion( orientation );
const pointerU = V_UP.clone().applyQuaternion( orientation );
const crossF = pointerF.clone().cross( moveVector );
const crossU = pointerU.clone().cross( moveVector );
const right1 = inDirectionOf( previousRight, crossF );
const right2 = inDirectionOf( previousRight, crossU );
right2.multiplyScalar( Math.abs( pointerF.dot( moveVector ) ) );
const newRight = ( right1.clone().add( right2 ) ).normalize();
const normal = moveVector.clone().cross( newRight );
return { newRight, normal };
}
function inDirectionOf( desired, v ){
return v.dot( desired ) >= 0 ? v.clone() : v.clone().multiplyScalar(-1);
}
單獨結合筆劃方向和方向會傳回不清晰的數學結果;可能會產生多個法向量,並經常在幾何圖形中產生「扭曲」。
在對筆劃的點進行疊代時,我們會維持「偏好右側」的向量,並將其傳遞至函式 computeSurfaceFrame()
。這個函式會提供法向量,讓我們可以根據筆劃的方向 (從上一個點到目前的點) 和控制器的方向 (四元數),在四邊形條帶中導出四邊形。更重要的是,它還會為下一個運算組合傳回新的「偏好權」向量。

根據每個筆劃的控制點產生四邊形後,我們會從一個四邊形到下一個四邊形,透過內插其角落來融合四邊形。
function fuseQuads( lastVerts, nextVerts) {
const vTopPos = lastVerts[1].clone().add( nextVerts[0] ).multiplyScalar( 0.5
);
const vBottomPos = lastVerts[5].clone().add( nextVerts[2] ).multiplyScalar(
0.5 );
lastVerts[1].copy( vTopPos );
lastVerts[4].copy( vTopPos );
lastVerts[5].copy( vBottomPos );
nextVerts[0].copy( vTopPos );
nextVerts[2].copy( vBottomPos );
nextVerts[3].copy( vBottomPos );
}

每個四邊形也包含 UV,這些 UV 會在下一個步驟產生。某些筆刷包含各種筆觸模式,讓每個筆觸都給人不同的筆刷筆觸感覺。這項功能是透過_紋理圖集_完成,其中每個筆刷紋理都包含所有可能的變化。您可以修改筆劃的 UV 值,選取正確的紋理。
function updateUVsForSegment( quadVerts, quadUVs, quadLengths, useAtlas,
atlasIndex ) {
let fYStart = 0.0;
let fYEnd = 1.0;
if( useAtlas ){
const fYWidth = 1.0 / TEXTURES_IN_ATLAS;
fYStart = fYWidth * atlasIndex;
fYEnd = fYWidth * (atlasIndex + 1.0);
}
//get length of current segment
const totalLength = quadLengths.reduce( function( total, length ){
return total + length;
}, 0 );
//then, run back through the last segment and update our UVs
let currentLength = 0.0;
quadUVs.forEach( function( uvs, index ){
const segmentLength = quadLengths[ index ];
const fXStart = currentLength / totalLength;
const fXEnd = ( currentLength + segmentLength ) / totalLength;
currentLength += segmentLength;
uvs[ 0 ].set( fXStart, fYStart );
uvs[ 1 ].set( fXEnd, fYStart );
uvs[ 2 ].set( fXStart, fYEnd );
uvs[ 3 ].set( fXStart, fYEnd );
uvs[ 4 ].set( fXEnd, fYStart );
uvs[ 5 ].set( fXEnd, fYEnd );
});
}



由於每個草圖的筆觸數量沒有限制,且筆觸不需要在執行階段修改,因此我們會預先計算筆觸幾何圖形,並將其合併為單一網格。雖然每個新筆刷類型都必須是獨立的材質,但這仍可將每個筆刷的繪圖呼叫次數減少為 1 次。

為了對系統進行壓力測試,我們建立了一個草圖,花了 20 分鐘的時間,盡可能以大量頂點填滿空間。產生的草圖仍可在 WebGL 中以 60fps 播放。
由於筆劃的每個原始頂點也包含時間,我們可以輕鬆播放資料。重新計算每個影格中的筆觸會非常緩慢,因此我們改為在載入時預先計算整個草圖,並在適當時間顯示每個四邊形。
隱藏四邊形,就是將其頂點摺疊至 0,0,0 點。當時間達到四邊形應顯示的時間點時,我們會將頂點重新定位。
我們可以改善的部分,是使用著色器在 GPU 上完全操控頂點。目前的實作方式是從目前的時間戳記迴圈遍歷頂點陣列,檢查需要顯示哪些頂點,然後更新幾何圖形。這會對 CPU 造成大量負載,導致風扇旋轉並耗損電池續航力。

錄製藝人
我們認為單靠草圖是不夠的。我們想讓藝術家在素描中展示每個筆觸的繪製過程。
為了捕捉藝術家,我們使用 Microsoft Kinect 攝影機記錄藝術家身體在空間中的深度資料。這樣一來,我們就能在繪圖出現的相同空間中,顯示三維圖形。
由於藝人的身體會遮住自己,導致我們無法看到身體後方的畫面,因此我們使用了雙 Kinect 系統,並將兩者分別放在房間的兩側,指向房間中央。
除了深度資訊之外,我們也使用標準單眼相機擷取場景的色彩資訊。我們使用出色的 DepthKit 軟體校正及合併深度相機和彩色相機的錄影畫面。Kinect 可錄製彩色影像,但我們選擇使用單眼數位相機,因為我們可以控制曝光設定、使用美麗的高階鏡頭,並以高畫質錄製。
為了錄製影像,我們建構了一個專屬房間,用來安置 HTC Vive、藝術家和攝影機。所有表面都覆蓋吸收紅外線光的材質,以便產生更清晰的點雲 (牆壁上是杜邦網,地板上是帶紋橡膠墊)。萬一材質出現在點雲短片中,我們選擇黑色材質,這樣就不會像白色材質那樣分散注意力。

這項技術產生的錄影畫面提供了足夠的資訊,讓我們能夠投射粒子系統。我們在 openFrameworks 中編寫了一些額外的工具,進一步清理影像,特別是移除地板、牆壁和天花板。

除了顯示藝術家,我們也想以 3D 格式算繪 HMD 和控制器。這不僅有助於在最終輸出內容中清楚顯示 HMD (HTC Vive 的反光鏡片會影響 Kinect 的紅外線讀數),還可讓我們偵錯粒子輸出內容,並將影片與草圖對齊。

這項功能是透過在 Tilt Brush 中編寫自訂外掛程式來實現,該外掛程式會在每個影格中擷取頭戴式顯示器和控制器的位置。由於 Tilt Brush 的執行速度為 90fps,因此會傳送大量資料,而草圖的輸入資料未經壓縮就超過 20 MB。我們也使用這項技術擷取未在 Tilt Brush 一般儲存檔案中記錄的事件,例如藝術家在工具面板上選取選項時,以及鏡像小工具的位置。
在處理我們擷取的 4 TB 資料時,其中一個最大挑戰就是要對齊所有不同的視覺/資料來源。數位單眼相機拍攝的每部影片都必須與對應的 Kinect 對齊,以便在空間和時間上對齊像素。接著,這兩個攝影機裝置的鏡頭畫面必須對齊,才能組合成單一藝術家。接著,我們需要將 3D 藝術家與從繪圖擷取的資料對齊。大功告成!我們已編寫瀏覽器工具,可協助完成大部分這些工作,您可以在此親自試用。

資料對齊後,我們使用 NodeJS 編寫的部分指令碼處理所有資料,並輸出經過裁剪和同步的影片檔案和一系列 JSON 檔案。為了縮減檔案大小,我們採取了三項措施。首先,我們降低每個浮點數的精確度,讓精確度上限為 3 小數位。第二,我們將點數減少三分之一,降至 30fps,並在用戶端側插補位置。最後,我們會將資料序列化,因此除了使用含有鍵/值組合的純 JSON 之外,還會為頭戴式顯示器和控制器的位置和旋轉建立值順序。這麼一來,檔案大小就會縮減到 3 MB 以下,可透過網路傳送。

由於影片本身會以 HTML5 影片元素的形式放送,並由 WebGL 紋理讀取,以便轉換成粒子,因此影片本身必須在背景中播放,且必須隱藏起來。著色器會將深度影像中的顏色轉換為 3D 空間中的位置。James George 分享了實例,說明如何直接使用 DepthKit 的素材。
iOS 對內嵌影片播放設有限制,我們認為這項限制是為了避免使用者遭到自動播放的網路影片廣告騷擾。我們採用的技術與網站上的其他解決方法相似,也就是將影片影格複製到畫布中,然後每隔 1/30 秒手動更新影片尋找時間。
videoElement.addEventListener( 'timeupdate', function(){
videoCanvas.paintFrame( videoElement );
});
function loopCanvas(){
if( videoElement.readyState === videoElement.HAVE\_ENOUGH\_DATA ){
const time = Date.now();
const elapsed = ( time - lastTime ) / 1000;
if( videoState.playing && elapsed >= ( 1 / 30 ) ){
videoElement.currentTime = videoElement.currentTime + elapsed;
lastTime = time;
}
}
}
frameLoop.add( loopCanvas );
不幸的是,我們的方法會導致 iOS 影格率大幅降低,因為從影片複製至畫布的像素緩衝區會大量耗用 CPU。為解決這個問題,我們只放送相同影片的較小尺寸版本,讓 iPhone 6 至少能以 30fps 播放。
結論
自 2016 年起,VR 軟體開發的共識是讓幾何圖形和著色器保持簡單,以便在頭戴式顯示器中以 90 以上 fps 的速度執行。這項技術在 Tilt Brush 中使用的技巧與 WebGL 非常相容,因此對 WebGL 示範來說,這項技術實在是個絕佳的目標。
雖然顯示複雜 3D 網格的網頁瀏覽器本身並不令人興奮,但這項概念驗證證明,VR 作品和網頁的跨領域合作完全可行。