摘要
我们邀请了 6 位艺术家在 VR 中绘画、设计和雕塑。这是我们记录用户会话、转换数据并通过网络浏览器实时呈现数据的过程。
https://g.co/VirtualArtSessions
多么美好的一天啊!随着虚拟现实作为消费产品的推出,我们发现了许多尚未探索的新可能性。Tilt Brush 是 HTC Vive 上推出的一款 Google 产品,可让您在三维空间中绘图。当我们第一次尝试 Tilt Brush 时,那种使用动作跟踪控制器绘图的感觉,以及“置身于拥有超能力的房间”的感觉,会一直留在您的脑海中;没有任何体验能像在周围的空白空间中绘图一样令人兴奋。
Google 的数据艺术团队面临着一个挑战,即如何在 Tilt Brush 尚不支持的 Web 上向没有 VR 头戴设备的用户展示这一体验。为此,该团队邀请了雕塑家、插画家、概念设计师、时尚艺术家、装置艺术家和街头艺术家,让他们在这项新媒介中创作出各自风格的艺术作品。
在虚拟现实中录制绘图
Tilt Brush 软件本身是内置于 Unity 中的桌面应用,它使用全局范围 VR 跟踪头部位置(头盔式显示器 [HMD])以及您双手中的控制器。在 Tilt Brush 中创建的图形默认会导出为 .tilt
文件。为了将这种体验引入到网络上,我们意识到,除了海报图片数据之外,我们还需要其他数据。我们与 Tilt Brush 团队密切合作,修改了 Tilt Brush,使其以每秒 90 次的速度导出撤消/删除操作以及艺术家的头部和手部位置。
在绘制时,倾斜画笔会获取控制器的位置和角度,并将一段时间内的多个点转换为“笔触”。您可以点击此处查看示例。我们编写了插件来提取这些笔触并将其输出为原始 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 播放草图
为了在 Web 浏览器中显示草图,我们使用了 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 );
});
}
由于每个草图的笔触数量不受限制,并且笔触无需在运行时修改,因此我们会提前预计算笔触几何图形,并将其合并为单个网格。即使每种新画笔类型都必须有自己的材质,但这仍然会将我们的绘制调用次数减少到每个画笔一个。
为了对系统进行压力测试,我们花了 20 分钟创建了一张草图,在其中尽可能填充了许多顶点。生成的草图仍在 WebGL 中以 60 fps 的速度播放。
由于笔触的每个原始顶点也包含时间,因此我们可以轻松地回放数据。重新计算每帧的笔触会非常慢,因此我们改为在加载时预计算整个草图,并在需要时直接显示每个四边形。
隐藏四边形只是指将其顶点收缩到 0,0,0 点。当时间达到应显示四边形的位置时,我们会将顶点重新放回原位。
一个有待改进的方面是,完全使用着色器在 GPU 上操控顶点。当前实现是通过从当前时间戳开始循环遍历顶点数组、检查需要显示哪些顶点,然后更新几何图形来放置这些顶点。这会给 CPU 带来大量负载,导致风扇旋转并浪费电池电量。
录制音乐人
我们认为仅提供草图是不够的。我们希望在简笔画中展示艺术家绘制每笔画作的画面。
为了捕捉舞者的动作,我们使用了 Microsoft Kinect 摄像头来记录舞者在空间中的身体深度数据。这样,我们就可以在绘图出现的同一空间中显示其三维图形。
由于舞者的身体会遮挡自己,使我们无法看到背后的情况,因此我们使用了双 Kinect 系统,两个 Kinect 分别位于房间的两侧,朝向房间中央。
除了深度信息之外,我们还使用标准 DSLR 相机捕获了场景的颜色信息。我们使用出色的 DepthKit 软件校准并合并深度相机和彩色相机拍摄的视频片段。Kinect 能够录制彩色视频,但我们选择使用 DSLR 相机,因为我们可以控制曝光设置、使用美观的高端镜头,并以高清画质进行录制。
为了录制视频,我们专门建造了一个房间,用于放置 HTC Vive、舞者和摄像机。所有表面都覆盖了吸收红外光的材料,以便获得更清晰的点云(墙壁上覆盖了杜邦防水布,地板上覆盖了带肋橡胶垫)。为防止材料出现在点云视频片段中,我们选择了黑色材料,以免其像白色材料一样分散注意力。
获得的视频录制内容为我们提供了足够的信息,以投影粒子系统。我们在 openFrameworks 中编写了一些其他工具,以进一步清理视频片段,尤其是移除地板、墙壁和天花板。
除了展示音乐人之外,我们还希望以 3D 方式渲染头盔式显示器和控制器。这不仅对于在最终输出中清晰显示头戴式显示器至关重要(HTC Vive 的反射镜片会干扰 Kinect 的红外读数),还为我们提供了调试粒子输出和将视频与草图对齐的接触点。
为此,我们在 Tilt Brush 中编写了一个自定义插件,用于在每一帧中提取头盔显示器和控制器的位置。由于 Tilt Brush 以 90fps 的速度运行,因此会流出大量数据,而草图的未压缩输入数据超过 20MB。我们还使用此技术捕获了未记录在典型 Tilt Brush 保存文件中的事件,例如艺术家在工具面板上选择选项的时间和镜像 widget 的位置。
在处理我们捕获的 4 TB 数据时,最大的挑战之一是协调所有不同的视觉/数据源。来自 DSLR 相机的每个视频都需要与相应的 Kinect 保持一致,以便像素在空间和时间上保持一致。然后,需要将这两个摄像头平台拍摄的画面对齐,以形成单个舞者的画面。然后,我们需要将 3D 设计师与从其绘图中捕获的数据对齐。大功告成!我们编写了基于浏览器的工具来帮助完成大多数此类任务,您可以点击此处自行试用
数据对齐后,我们使用了一些用 NodeJS 编写的脚本来处理所有数据,并输出一个视频文件和一系列经过剪裁和同步的 JSON 文件。为了缩减文件大小,我们做了三件事。首先,我们降低了每个浮点数的精度,使其精度最多为 3 位小数。其次,我们将点数减少了三分之一,降至 30fps,并在客户端插值了位置。最后,我们对数据进行了序列化,因此系统会为头盔显示器和控制器的位置和旋转创建一个值顺序,而不是使用包含键值对的纯 JSON。这样一来,文件大小就缩减到了略低于 3MB 的大小,可以通过网络传输。
由于视频本身是作为 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 );
由于从视频复制像素缓冲区到画布非常耗费 CPU,因此我们的方法有一个不幸的副作用,即会显著降低 iOS 帧速率。为了解决此问题,我们只需提供相同视频的较小版本,以便在 iPhone 6 上至少达到 30 fps。
总结
截至 2016 年,VR 软件开发领域的普遍共识是,应尽量简化几何图形和着色器,以便在头戴式显示器 (HMD) 中以 90 帧/秒以上的速度运行。事实证明,这是一个非常适合 WebGL 演示的目标平台,因为 Tilt Brush 中使用的技术非常适合 WebGL。
虽然浏览器显示复杂的 3D 网格本身并不令人兴奋,但这证明了 VR 内容和 Web 内容之间的相互交叉完全可行。