摘要
六位艺术家受邀在 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
文件的形式导出。为了将这种体验带入网络,我们意识到
我们需要的不仅仅是海报图片数据。我们与 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 的红外线读数),还为我们提供了用于调试粒子输出以及将视频与草图对齐的接触点。
为此,我们在 Tilt Brush 中编写了一个自定义插件,用于提取 HMD 和每帧控制器的位置。由于 Tilt Brush 以 90fps 的速率运行,因此有大量数据会排出,草图的输入数据未经压缩就超过了 20 MB。我们还使用此技术来捕获未记录在典型 Tilt Brush 保存文件中的事件,例如,艺术家在工具面板上选择一个选项时以及镜像 widget 的位置。
在处理我们捕获的 4TB 数据时,最大的挑战之一是对齐所有不同的可视化/数据源。数码单反相机的每个视频都需要与相应 Kinect 对齐,以便像素在空间和时间上对齐。然后,需要将来自这两台摄像机装置的视频片段相互对齐才能组成一位音乐人。然后,我们需要将 3D 艺术家的数据与从他们的画作中采集到的数据对齐。好了!我们编写了基于浏览器的工具来帮助完成大多数此类任务。您可以点击此处自行试用这些工具