WebVR 中的《Dance Tonite》

当 Google 数据艺术团队邀请 Moniker 和我一起探索 WebVR 带来的可能性时,我感到非常兴奋。这些年来,我一直关注着该团队的作品,他们的项目总是能引起我的共鸣。我们的合作成果是Dance Tonite,这是一个充满变化的 VR 舞蹈体验,由 LCD Soundsystem 和他们的粉丝共同打造。这就是我们是如何做到的

概念

我们首先使用 WebVR 开发了一系列原型。WebVR 是一种开放标准,让用户能够通过使用浏览器访问网站进入虚拟现实。WebVR 的目标是,让所有人,无论使用什么设备,都能更轻松地享受虚拟现实体验。

我们会将您的反馈铭记在心。我们开发的任何内容都应该适用于所有类型的 VR 设备,从可与手机搭配使用的 VR 头盔(例如 Google Daydream View、Cardboard 和三星 Gear VR)到能反映您在虚拟环境中的实际动作的大型系统(例如 HTC VIVE 和 Oculus Rift)。也许最重要的一点是,我们认为需要本着网络精神,开发出对没有 VR 设备的所有用户同样有用的内容。

1. DIY 动作捕捉

由于我们希望以富有创意的方式吸引用户参与,因此开始研究如何利用 VR 技术实现用户参与和自我表达。在 VR 中,您可以移动和环顾四周的精细程度,以及真实的保真度,这给我们留下了深刻的印象。这给了我们灵感。与其让用户观看或创建内容,不如记录他们的动作吗?

有人在 Dance Tonite 上录制自己的舞蹈视频。身后的屏幕上显示了他们在头盔中看到的内容

我们制作了一个原型,在跳舞时记录 VR 护目镜和控制器的位置。我们将记录的位置替换为抽象形状,结果令人惊叹。生成的结果非常人性化,而且充满个性!我们很快意识到,我们可以使用 WebVR 在家进行低成本的动作捕获。

借助 WebVR,开发者可以通过 VRPose 对象访问用户的头部位置和方向。此值会由 VR 硬件每帧更新一次,以便您的代码能够从正确的视角渲染新帧。通过 GamePad API with WebVR,我们还可以通过 GamepadPose 对象访问用户控制器的位置/方向。我们只需在每一帧中存储所有这些位置和方向值,从而创建用户动作的“录制内容”。

2. 极简主义与服装

借助目前的室内 VR 设备,我们可以跟踪用户身体的三个点:头部和双手。在《Dance Tonite》中,我们希望 在太空中这 3 个点的移动中着重展现人性为此,我们尽可能简化了美学设计,以便专注于动作。我们很喜欢利用大脑的想法。

在考虑尽可能简化事物时,我们参考了这个演示瑞典心理学家 Gunnar Johansson 工作的视频。它展示了漂浮的白色圆点在移动时如何立即被识别为身体。

在视觉方面,我们受到了 Margarete Hastings 于 1970 年重新演绎 Oskar Schlemmer 的《三元舞》录像中色彩缤纷的房间和几何图形服装的启发。

Schlemmer 选择抽象的几何服装是为了将舞者的动作限制为木偶和木偶,但我们对《Dance Tonite》的目标与之相反。

最终,我们根据形状在旋转时传达的信息量来选择形状。无论以何种方式旋转,球体看起来都是一样的,但圆锥体实际上会朝向其所看的方向,并且正面和背面看起来不同。

3. 循环踏板移动

我们希望展示大量录制的舞者一起跳舞和移动。由于 VR 设备的数量还不够多,因此无法在线上进行测试。但我们仍然希望让一群人通过动作来回应彼此。我们想到了诺曼·麦克拉伦 1964 年的作品《Canon》中的递归表演。

McClaren 的表演包含一系列高度编排的动作,这些动作会在每次循环后开始相互交互。就像音乐中的循环踏板一样,音乐人可以通过叠加不同的现场音乐来即兴演奏,我们也希望能创造一个环境,让用户可以自由地即兴演奏更随意的版本。

4. 互通的客房

连通客房

与许多音乐一样,LCD Soundsystem 乐队的音轨使用精确计时。他们的曲目《Tonite》(在我们的项目中收录),其测量时长恰好 8 秒。我们希望用户针对曲目中的每个 8 秒循环进行表演。虽然这些小节的节奏不会改变,但其音乐内容会发生变化。随着歌曲的演奏,会出现不同的乐器和歌声,表演者可以以不同的方式做出回应。这些测量结果都表示为房间,用户可以在其中进行适合该房间的表演。

优化性能:避免丢帧

创建在单一代码库上运行且针对每种设备或平台都能提供最佳性能的多平台 VR 体验并非易事。

在 VR 中,最令人作呕的事情之一是帧速率无法跟上您的移动步伐。如果您转头,但眼睛看到的画面与内耳感受到的动作不相符,会导致肠胃立刻抖动。因此,我们需要避免出现任何较大的帧速率延迟。下面列出了我们实现的一些优化。

1. 实例化缓冲区几何图形

由于整个项目仅使用少量 3D 对象,因此我们能够通过使用实例化缓冲区几何图形大幅提升性能。基本上,它允许您将对象一次上传到 GPU,并在单次绘制调用中绘制任意数量的该对象“实例”。在“Dance Tonite”中,我们只有 3 个不同的对象(一个圆锥、一个圆柱和一个有洞的房间),但这些对象可能有数百个副本。实例化缓冲区几何图形是 ThreeJS 的一部分,但我们使用了Dusan Bosnjak 的实验性且正在开发中的分支,该分支实现了 THREE.InstanceMesh,这使得使用实例化缓冲区几何图形变得更加容易。

2. 避免使用垃圾回收器

与许多其他脚本语言一样,JavaScript 通过找出已分配的对象不再使用来自动释放内存。此过程称为垃圾回收。

开发者无法控制这种情况的发生时间。垃圾回收器随时都可能出现,并开始清空垃圾,这会导致帧丢失,因为它们需要花费一些时间。

解决方法是回收对象,以尽可能减少垃圾。我们标记了要重复使用的备用对象,而不是为每次计算都创建新的矢量对象。由于我们通过将对它们的引用移出我们的作用域来保留它们,因此系统并未将它们标记为要移除。

例如,以下代码会将用户头部和手部的坐标矩阵转换为我们在每个帧中存储的位置/旋转值数组。通过重复使用 SERIALIZE_POSITIONSERIALIZE_ROTATIONSERIALIZE_SCALE,我们可以避免每次调用函数时都创建新对象时发生的内存分配和垃圾回收。

const SERIALIZE_POSITION = new THREE.Vector3();
const SERIALIZE_ROTATION = new THREE.Quaternion();
const SERIALIZE_SCALE = new THREE.Vector3();
export const serializeMatrix = (matrix) => {
    matrix.decompose(SERIALIZE_POSITION, SERIALIZE_ROTATION, SERIALIZE_SCALE);
    return SERIALIZE_POSITION.toArray()
    .concat(SERIALIZE_ROTATION.toArray())
    .map(compressNumber);
};

3. 序列化动作和逐帧播放

为了捕获用户在 VR 中的动作,我们需要序列化头盔和控制器的位置和旋转,并将这些数据上传到我们的服务器。我们首先捕获每个帧的完整转换矩阵。这种方法的性能很好,但由于每秒 90 帧的 16 个数字乘以 3 个位置,导致文件非常大,因此在上传和下载数据时需要等待很长时间。通过仅从转换矩阵中提取位置和旋转数据,我们将这些值从 16 降到了 7。

由于网络访问者点击链接时往往并不知道会看到什么,因此我们需要快速显示视觉内容,否则他们会在几秒钟内离开。

因此,我们希望确保自己的项目可以尽快开始。最初,我们使用 JSON 格式加载移动数据。问题在于,我们必须先加载完整的 JSON 文件,然后才能对其进行解析。不是很循序渐进。

为了让 Dance Tonite 等项目以尽可能高的帧速率显示,浏览器在每个帧中只有很少的时间来进行 JavaScript 计算。如果您花费的时间过长,动画会开始卡顿。起初,由于浏览器解码这些巨大的 JSON 文件,我们遇到了卡顿问题。

我们发现了一种方便的信息流数据格式,称为 NDJSON,即以换行符分隔的 JSON。这里的技巧是,创建一个包含一系列有效 JSON 字符串的文件,每行一个。这样,您就可以在文件加载时解析该文件,以便我们在文件完全加载之前显示表演效果。

我们的某个录音的一段内容如下所示:

{"fps":15,"count":1,"loopIndex":"1","hideHead":false}
[-464,17111,-6568,-235,-315,-44,9992,-3509,7823,-7074, ... ]
[-583,17146,-6574,-215,-361,-38,9991,-3743,7821,-7092, ... ]
[-693,17158,-6580,-117,-341,64,9993,-3977,7874,-7171, ... ]
[-772,17134,-6591,-93,-273,205,9994,-4125,7889,-7319, ... ]
[-814,17135,-6620,-123,-248,408,9988,-4196,7882,-7376, ... ]
[-840,17125,-6644,-173,-227,530,9982,-4174,7815,-7356, ... ]
[-868,17120,-6670,-148,-183,564,9981,-4069,7732,-7366, ... ]
...

使用 NDJSON 后,我们可以将表演的各个帧的数据表示形式保留为字符串。我们可以等到达到所需时间后,再将其解码为位置数据,从而将所需的处理分摊到相应时间段。

4. 插值运动

由于我们希望能够同时显示 30 到 60 个性能,因此需要进一步降低数据速率。Data Arts 团队在 Virtual Art Sessions 项目中解决了同样的问题,即他们使用 Tilt Brush 在 VR 中播放艺术家绘画的录制内容。他们通过以下方式解决了这个问题:制作具有较低帧速率的用户数据的中间版本,并在播放数据时在帧之间进行插值。我们惊讶地发现,我们几乎无法区分以 15 FPS 运行的插值录制内容与原始 90 FPS 录制内容之间的差异。

为了亲自体验,您可以使用 ?dataRate= 查询字符串强制 Dance Tonite 以不同的速率播放数据。您可以使用此功能比较以 90 帧/秒45 帧/秒15 帧/秒 录制的动作。

对于位置,我们会根据两个关键帧之间的时间距离(比率),在上一个关键帧和下一个关键帧之间执行线性插值:

const { x: x1, y: y1, z: z1 } = getPosition(previous, performanceIndex, limbIndex);
const { x: x2, y: y2, z: z2 } = getPosition(next, performanceIndex, limbIndex);
interpolatedPosition = new THREE.Vector3();
interpolatedPosition.set(
    x1 + (x2 - x1) * ratio,
    y1 + (y2 - y1) * ratio,
    z1 + (z2 - z1) * ratio
    );

对于方向,我们会在关键帧之间执行球面线性插值 (slerp)。方向以四元数的形式存储。

const quaternion = getQuaternion(previous, performanceIndex, limbIndex);
quaternion.slerp(
    getQuaternion(next, performanceIndex, limbIndex),
    ratio
    );

5. 将动作与音乐同步

为了知道要播放录制的动画的哪一帧,我们需要知道音乐的当前时间(精确到毫秒)。事实证明,虽然 HTML Audio 元素非常适合逐步加载和播放音频,但它提供的时间属性不会与浏览器的帧循环同步更改。它总是会出现一点偏差,有时会提前几微秒,有时会晚几微秒。

这会导致美妙的舞蹈录制内容出现卡顿,而我们会尽一切努力避免这种情况。为解决此问题,我们在 JavaScript 中实现了自己的计时器。这样,我们就可以确定帧之间变化的时间量与上一个帧以来经过的时间完全相同。只要计时器与音乐的不同步时间超过 10 毫秒,我们就会重新将其同步。

6. 清除和模糊处理

每个故事都需要一个好的结局,我们希望为坚持完成体验的用户提供一些惊喜。离开最后一间房间后,您进入了一个由圆锥和圆柱组成的宁静景观。想知道“这样就结束了吗?”随着运动的深入,音乐的音调突然将不同的圆锥体和圆柱体形成舞者。您发现自己身处一场大型派对中!音乐突然停止时,一切都掉落到地面上。

虽然这对观看者来说很棒,但也带来了一些需要解决的性能问题。我们使用了全身沉浸式 VR 设备及其高端游戏设备,完美地完成了新结局所需的 40 多场额外表演。但某些移动设备上的帧速率减半。

为此,我们引入了雾。在一定距离后,所有内容都会慢慢变黑。由于我们无需计算或绘制不可见的内容,因此会剔除不可见房间的性能,这让我们可以为 CPU 和 GPU 节省工作量。但如何确定合适的距离?

有些设备可以处理您投放的任何内容,而有些设备则更受限。我们选择了采用浮动费率。通过持续衡量每秒帧数,我们可以相应地调整雾的距离。只要帧速率稳定运行,我们就会尝试消除迷雾,从而完成更多渲染工作。如果帧速率不够流畅,我们会将雾化效果加深,这样我们就可以跳过黑暗中的渲染性能。

// this is called every frame
// the FPS calculation is based on stats.js by @mrdoob
tick: (interval = 3000) => {
    frames++;
    const time = (performance || Date).now();
    if (prevTime == null) prevTime = time;
    if (time > prevTime + interval) {
    fps = Math.round((frames * 1000) / (time - prevTime));
    frames = 0;
    prevTime = time;
    const lastCullDistance = settings.cullDistance;

    // if the fps is lower than 52 reduce the cull distance
    if (fps <= 52) {
        settings.cullDistance = Math.max(
        settings.minCullDistance,
        settings.cullDistance - settings.roomDepth
        );
    }
    // if the FPS is higher than 56, increase the cull distance
    else if (fps > 56) {
        settings.cullDistance = Math.min(
        settings.maxCullDistance,
        settings.cullDistance + settings.roomDepth
        );
    }
    }

    // gradually increase the cull distance to the new setting
    cullDistance = cullDistance * 0.95 + settings.cullDistance * 0.05;

    // mask the edge of the cull distance with fog
    viewer.fog.near = cullDistance - settings.roomDepth;
    viewer.fog.far = cullDistance;
}

人人都能找到适合自己的内容:构建适用于 Web 的 VR 内容

设计和开发多平台不对称体验意味着,需要根据用户的设备来考虑每个用户的需求。在做出每一个设计决策时,我们都需要考虑这可能会对其他用户产生怎样的影响。如何确保您在 VR 中看到的内容与在没有 VR 的情况下看到的内容一样令人兴奋?反之亦然?

1. 黄色球体

因此,我们的全局范围 VR 用户将进行表演,但移动 VR 设备(例如 Cardboard、Daydream View 或三星 Gear)的用户将如何体验该项目?为此,我们为环境中引入了一个新元素:黄色球体

黄色球体
黄色球体

在 VR 中观看项目时,您是从黄色球体的视角观看的。当您从一个房间飘到另一个房间时,舞者会对您的到来做出回应。 他们会向您挥手、围着您跳舞、在您背后做出滑稽的动作,并快速避开您,以免撞到您。黄色球体始终是焦点。

这是因为在录制表演时,黄色球体会随着音乐在房间中央移动,然后循环播放。球体的位置让表演者可以了解他们所处的时间位置,以及他们的循环剩余时间。这为他们提供了一个自然的焦点,以便围绕其构建表演。

2. 其他观点

我们不想忽略没有 VR 设备的用户,尤其是因为他们可能是我们的最大观众群。我们不想打造虚假的 VR 体验,而是希望为基于屏幕的设备打造专属的体验。我们想到了从等距透视图的角度从上方显示效果的想法。这种视角在计算机游戏中有着悠久的历史。它最早出现在 1982 年的太空射击游戏 Zaxxon 中。与 VR 用户身临其境不同,等距透视图可让用户以上帝视角观看动作。我们选择将模型略微放大,使其具有玩具屋般的美感。

3. 阴影:假以真时

我们发现,部分用户很难在等距投影视图中看到深度。我很确信,正是因此,Zaxxon 也是历史上首批在飞行物体下投射动态阴影的计算机游戏之一。

阴影

事实证明,在 3D 中制作阴影很难。尤其是对于手机等受限设备。最初,我们不得不做出艰难的决定,将它们从等式中剔除,但在向 Three.js 的作者和经验丰富的演示版黑客 Mr doob 寻求建议后,他提出了一个新颖的想法:伪造它们。

我们无需计算每个浮动对象如何遮挡光线,进而投射不同形状的阴影,而是在每个对象下方绘制相同的圆形模糊纹理图片。由于我们的视觉效果从一开始就没有尝试模仿现实,因此我们发现只需进行一些调整,就能轻松解决此问题。当物体靠近地面时,我们会让纹理变暗变小。当它们向上移动时,我们会使纹理更透明且更大。

在制作这些图案时,我们使用了此纹理,并将其从白色渐变为黑色(不带 Alpha 透明度)。我们将材质设为透明,并使用减色混合。这有助于它们在重叠时进行精细融合:

function createShadow() {
    const texture = new THREE.TextureLoader().load(shadowTextureUrl);
    const material = new THREE.MeshLambertMaterial({
        map: texture,
        transparent: true,
        side: THREE.BackSide,
        depthWrite: false,
        blending: THREE.SubtractiveBlending,
    });
    const geometry = new THREE.PlaneBufferGeometry(0.5, 0.5, 1, 1);
    const plane = new THREE.Mesh(geometry, material);
    return plane;
    }

4. 在现场

没有 VR 设备的访问者可以点击舞者的头部,以舞者的视角观看表演。从这个角度,很多细节都变得清晰可见。舞者们尽量保持表演节奏,他们快速看向对方。当光球进入房间时,您会看到他们紧张地看着光球。虽然作为观看者,您无法影响这些移动,但它确实能出色地传达沉浸感。再次强调,我们更倾向于这样做,而不是向用户展示由鼠标控制的乏味虚拟现实版本。

5. 分享录制内容

我们知道,当您完成精心编排的录制内容,其中包含 20 层表演者相互回应时,您会感到非常自豪。我们知道用户可能希望向好友展示自己的照片。但是,单一静态图片无法充分展示这一壮举。我们希望用户能够分享自己的表演视频。其实,为什么不使用 GIF 格式?我们的动画采用平面着色,非常适合该格式的有限调色板。

分享录音

我们选择了 GIF.js,这是一个 JavaScript 库,可让您在浏览器中对动画 GIF 进行编码。它将帧的编码工作分流到能够作为单独进程在后台运行的 Web 工作器,从而能够利用并行工作的多个处理器。

遗憾的是,由于动画需要的帧数较多,编码过程仍然太慢。GIF 能够通过使用有限的调色板来制作小文件。我们发现,大部分时间都花在查找每个像素最接近的颜色上。我们利用一个小巧的快捷方式将这一过程优化了十倍:如果像素的颜色与上一个像素的颜色相同,请使用与之前相同的调色板中的颜色。

现在,我们可以快速编码,但生成的 GIF 文件大小过大。借助 GIF 格式,您可以通过定义其 dispose 方法来指明如何在前一帧上显示每一帧。为了减小文件大小,我们只会更新发生变化的像素,而不是在每一帧中更新每个像素。虽然这会再次减慢编码速度,但确实有效缩减了文件大小。

6. 坚实的基础:Google Cloud 和 Firebase

“用户生成的内容”网站的后端通常很复杂且易出问题,但得益于 Google Cloud 和 Firebase,我们设计出了一个简单且强大的系统。表演者将新的舞蹈上传到系统后,系统会通过 Firebase Authentication 匿名验证其身份。他们有权使用 Cloud Storage for Firebase 将录音上传到临时空间。上传完成后,客户端计算机会使用其 Firebase 令牌调用 Cloud Functions for Firebase HTTP 触发器。这会触发服务器进程,用于验证提交内容、创建数据库记录,并将录音移至 Google Cloud Storage 上的公共目录。

坚实地面

我们的所有公开内容都存储在 Cloud Storage 存储分区中的一系列平面文件中。这意味着,我们的数据可在全球范围内快速访问,而无需担心高流量会以任何方式影响数据可用性。

我们使用 Firebase Realtime Database 和 Cloud Functions 函数端点构建了一个简单的审核/整理工具,让我们能够在 VR 中观看每次新提交的内容,并通过任何设备发布新播放列表。

7. Service Worker

Service Worker 是一项较新的创新,可帮助管理网站资源的缓存。在我们的示例中,服务工作线程可为回访者极速加载内容,甚至允许网站在离线状态下运行。这些都是重要的功能,因为我们的许多访问者使用的移动网络连接质量参差不齐。

借助实用的 webpack 插件,您可以轻松地向项目添加服务工作器,因为该插件会为您处理大部分繁重工作。在下面的配置中,我们生成了一个会自动缓存所有静态文件的服务工件。由于播放列表会不断更新,因此它会从网络中提取最新的播放列表文件(如果有)。所有录制的 json 文件都应从缓存中提取(如果有),因为这些文件永远不会更改。

const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
config.plugins.push(
    new SWPrecacheWebpackPlugin({
    dontCacheBustUrlsMatching: /\.\w{8}\./,
    filename: 'service-worker.js',
    minify: true,
    navigateFallback: 'index.html',
    staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/],
    runtimeCaching: [{
        urlPattern: /playlist\.json$/,
        handler: 'networkFirst',
    }, {
        urlPattern: /\/recordings\//,
        handler: 'cacheFirst',
        options: {
        cache: {
            maxEntries: 120,
            name: 'recordings',
        },
        },
    }],
    })
);

目前,该插件不能处理音乐文件等渐进式加载的媒体资源,因此我们解决了这个问题,解决方法是将这些文件上的 Cloud Storage Cache-Control 标头设置为 public, max-age=31536000,让浏览器将文件缓存长达一年。

总结

我们非常期待看到表演者如何利用这项功能来丰富自己的表演体验,并将其用作利用动作进行创意表达的工具。我们已经发布了所有开源代码,您可以在 https://github.com/puckey/dance-tonite 上找到这些内容。在 VR 尤其是 WebVR 的早期阶段,我们期待看到这个新媒介将朝着哪些富有创意且意想不到的方向发展。继续跳舞