摘要
我们邀请了六位艺术家在 VR 中创作、设计和雕刻。这个过程说明了我们如何记录他们的会话、转换数据,以及如何在网络浏览器中实时呈现这些数据。
https://g.co/VirtualArtSessions
活在当下真是太好了!随着虚拟现实作为消费类产品的推出,人们不断探索新的和未探索的可能性。Tilt Brush 是 HTC Vive 中提供的一款 Google 产品,可让您在三维空间中进行绘制。当我们首次尝试 Tilt Brush 时,通过运动跟踪控制器进行绘制时,您会有一种“处在一个具有超能力的房间”的感觉的感觉。
Google 的 Data Arts 团队面临着一项挑战,那就是在 Tilt Brush 尚未运行的网页上向没有 VR 头戴设备的用户展示这种体验。为此,该团队聘请了雕塑家、插画师、概念设计师、时尚艺术家、装置艺术家和街头艺术家,利用这一新媒介创作他们各自的风格。
在虚拟现实环境中录制绘图
Tilt Brush 软件本身内置于 Unity 中,它本身是一种桌面应用,它使用房间级别的 VR 来跟踪您的头部位置(头戴式显示屏,即 HMD)和您每只手的控制器。在 Tilt Brush 中创建的海报图片默认以 .tilt
文件的形式导出。为了将这种体验带入 Web 平台,我们意识到,我们需要的不仅仅是海报图片数据。我们与 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 值来选择正确的纹理。
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 );
});
}
由于每个草图的笔画数量没有限制,而且笔画无需在运行时修改,因此我们提前预计算描边几何图形,并将它们合并为一个网格。虽然每种新的 Brush 类型都必须采用自己的材质,但这仍会将绘制调用次数减少到每个 Brush 一次。
为了对系统进行压力测试,我们创建了一个草图,用 20 分钟的时间尽可能多地用顶点填充空间。生成的草图仍会在 WebGL 中以 60fps 的速度播放。
由于描边的每个原始顶点也包含时间,因此我们可以轻松回放数据。重新计算每帧的笔画非常慢,因此我们在加载时预先计算了整个草图,并在需要执行此操作时直接显示每个四边形。
隐藏四边形仅仅意味着将其顶点收起至 0,0,0 点。当时间到了应该揭示四边形的点时,我们会将顶点重新调整到位。
有待改进的方面是使用着色器完全在 GPU 上操控顶点。当前实现通过以下方式放置这些标记:循环遍历当前时间戳的顶点数组,检查需要显示哪些顶点,然后更新几何图形。这会给 CPU 带来大量负载,导致风扇旋转并浪费电池续航时间。
录制音乐人的作品
我们认为这些草图本身是不够的。我们希望向艺术家展示他们的素描,让他们描绘每一种笔触。
为了拍摄这些艺术家,我们使用 Microsoft Kinect 相机记录艺术家身体在太空中的深度数据。这样,我们就可以在绘画的同一空间里展示他们的三维图形了。
由于艺术家的身体会遮挡住身体,使我们看不到后面的内容,因此我们使用了双 Kinect 系统,这两个系统都位于房间两侧指向中心。
除了深度信息之外,我们还使用标准数码单反相机捕获场景的颜色信息。我们使用出色的 DepthKit 软件校准和合并来自深度相机和彩色相机的视频片段。虽然 Kinect 能够记录颜色,但我们选择使用数码单反相机,是因为我们可以控制曝光设置,使用精美的高端镜头,并以高清画质进行录制。
为了录制视频片段,我们建造了一个专门的房间来容纳 HTC Vive、艺术家和相机。所有表面都覆盖了可吸收红外光的材料,使之呈现更整洁的点云(墙面上铺有绒布,地板上铺有罗纹橡胶垫)。如果这些材料出现在点云视频片段中,我们选择了黑色材料,这样就不会像白色材料那样分散注意力。
产生的视频录像为我们提供足够的信息来投射粒子系统。我们在 openFrameworks 中编写了一些其他工具,以进一步清理视频片段,特别是去除地板、墙壁和天花板。
除了展示音乐人之外,我们还希望以 3D 形式渲染 HMD 和控制器。这不仅有助于在最终输出中清晰显示 HMD(HTC Vive 的反射镜头反射了 Kinect 的 IR 读数),还为我们提供了可调试粒子输出的接触点以及使视频与草图对齐的接触点。
为此,我们在 Tilt Brush 中编写了一个自定义插件,用于提取 HMD 和每帧控制器的位置。由于 Tilt Brush 以 90fps 的速率运行,因此大量数据流出,且未压缩的草图输入数据超过 20 MB。我们还使用了该技术来捕获未记录在典型 Tilt Brush 保存文件中的事件,例如艺术家在工具面板上选择一个选项以及镜像 widget 的位置。
在处理我们捕获的 4TB 数据时,最大的挑战之一是协调所有不同的视觉/数据源。数码单反相机中的每个视频都需要与相应的 Kinect 对齐,以便像素在空间和时间上对齐。然后,需要将来自这两个摄像机装置的视频片段相互对齐,才能构成一位艺术家。然后,我们需要将 3D 艺术家 与其绘画中采集到的数据进行对齐好了!我们编写了基于浏览器的工具来帮助完成大多数此类任务,您可以点击此处亲自试用它们
校准数据后,我们使用一些以 NodeJS 编写的脚本来处理所有这些数据,并输出一个视频文件和一系列 JSON 文件,这些数据全部经过剪辑和同步。为了减小文件大小,我们采取了三项措施。首先,我们降低了每个浮点数的精度,使其精度达到小数点后 3 位。其次,我们将数据点数量减少三分之一至 30fps,并在客户端插入位置。最后,我们对数据进行了序列化,因此为 HMD 和控制器的位置和旋转创建了一系列值,而不是使用带有键值对的普通 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 软件开发的一般共识是确保几何图形和着色器保持简单,以便在 HMD 上以 90+fps 的速度运行。事实证明,这非常适合 WebGL 演示,因为 Tilt Brush 中使用的技术可以很好地映射到 WebGL。
虽然显示复杂 3D 网格的网络浏览器本身并不令人兴奋,但这只是对 VR 作品和 Web 的交叉传播的概念验证。