WebVR 中的《Dance Tonite》

当 Google 数据艺术团队找到我和 Moniker 来合作探索 WebVR 带来的可能性时,我感到非常兴奋。这些年来,我一直在关注他们的团队,他们的项目总是让我心动。我们的合作催生了 Dance Tonite,这是一种变革性的 VR 舞蹈体验,它与 LCD Soundsystem 及其粉丝合作。下面介绍了我们的具体做法

概念

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

我们深知这一点。我们打造的 VR 头戴设备应该适用于所有类型的 VR,从可与手机的 VR 头戴设备(如 Google 的 Daydream View、Cardboard 和三星的 Gear VR)兼容,以及能够反映您在虚拟环境中身体运动的房间规模的系统(如 HTC VIVE 和 Oculus Rift)。或许最重要的是,我们认为,基于网络的精神,我们打造出对没有 VR 设备的所有人同样有用的产品。

1. DIY 动作拍摄

由于我们希望以创造性的方式吸引用户,因此我们开始利用 VR 探索互动和自我表达的可能性。您在 VR 中移动和环顾的精细程度以及保真度令人印象深刻。这给了我们一个灵感。如何记录他们的动作,而不是让用户观看或制作物品?

有人在《Dance Tonite》中录制了自己的名字。他们身后的屏幕显示了他们正在耳机中看到的内容

我们设计了一个原型,用来记录 VR 眼镜和控制器在跳舞时的位置。我们将记录的位置替换为抽象形状,并对结果感到惊叹。结果太人性化,也包含了 很多个性!我们很快意识到,可以使用 WebVR 在家中进行经济的动作捕捉。

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

2. 极简主义与服装

借助如今的房间规模的 VR 设备,我们可以跟踪用户身体的三个点:头部和两只手。在《Dance Tonite》中,我们希望在这 3 个空间点的运动中着重展现人类的本质。为了实现这一目标,我们尽可能将审美推向了极限,以专注于运动。我们很喜欢能让人头脑动起来的想法。

这段视频展示了瑞典心理学家 Gunnar Johansson 的工作,就是我们在考虑尽可能减少事情时所提到的例子之一。它展示了漂浮的白点在运动中如何立即识别为身体。

在视觉上,我们受到了玛格丽特·黑斯廷斯在 1970 年对奥斯卡·施莱默尔的三联体芭蕾舞的重新演绎中,彩色房间和几何服装的灵感。

Schlemmer 选择抽象几何服装的原因是将舞者的动作限制为木偶和木偶舞,而我们开发《Tance Tonite》的目的则相反。

最终,我们选择形状的方式取决于它们通过旋转传达的信息量。无论以何种方式旋转,球体看起来都一样,但圆锥实际指向它的方向,并且其外观与正面和背面不同。

3. 循环踏板移动

我们想要展示大量录制的人物在跳舞和一起舞动的画面。实时更新是不可行的,因为 VR 设备的数量不够多。但我们仍然希望让几群人通过动作来做出反应我们想到了诺曼·麦克拉伦 (Norman McClaren) 在 1964 年发布的 《佳能》视频作品中的递归表演

麦克莱伦的表演包含一系列精心编排的动作,这些动作在每次循环后都开始相互互动。就像音乐中的循环踏板一样,音乐家通过叠加不同的现场音乐来进行演奏,我们想要看看能否创造一种环境,让用户可以自由即兴演奏较宽松的版本。

4. 连通客房

连通客房

与许多音乐一样,LCD 音响系统的音轨也是使用精确计时的测量构建的。在我们的项目中,他们的轨道《Tonite》的测量时长正好为 8 秒。我们希望用户针对轨道中每 8 秒的循环进行一次性能调整。虽然这些小节的节奏没有改变,但它们的音乐内容却会发生变化。随着歌曲的进行,表演者可以在一些具有不同乐器和声乐的片段中以不同的方式做出反应。每个测量都表示为一个房间,人们可以在这里做出适合自己的表演。

优化性能:不丢帧

要打造在单个代码库上运行,并针对每种设备或平台实现最佳性能的多平台 VR 体验绝非易事。

在 VR 中,最令人恶心的事情之一就是帧速率跟不上您的运动节奏。如果您转动头部,但眼睛看到的视觉画面与内耳的运动方式不符,则会导致胃部快速流失。因此,我们需要避免较大的帧速率延迟。以下是我们实施的一些优化措施。

1. 实例缓冲区几何图形

由于我们的整个项目仅使用少量 3D 对象,因此我们可以使用 Instanced Buffer Geometry 大幅提升性能。基本上,它允许您将对象上传到 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 中的动作,我们需要对用户的耳机和控制器的位置和旋转进行序列化,并将此数据上传到我们的服务器。我们一开始捕获每一帧的完整转换矩阵。这种方法效果良好,但 16 位数字乘以 3 个位置(每个位置每秒 90 帧),会导致文件非常大,因此在上传和下载数据时需要等待很长时间。通过仅从转换矩阵中提取位置和旋转数据,我们得以将这些值从 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 音频元素非常适合渐进式加载和播放声音,但它提供的时间属性不会与浏览器的帧循环同步更改。结果总是有点偏离。有时候稍微早了几分之一毫秒,有时候则晚了。

这会导致我们精美的舞蹈录音出现卡顿,我们希望不惜一切代价避免这种情况。为了解决此问题,我们在 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 或 Samsung Gear)的用户会如何体验该项目?为此,我们在环境中引入了一个新元素:黄色球体。

黄色球体
黄色球体

当您在 VR 中观看项目时,您是从黄色球体的角度进行操作。当您从一个房间漂浮到另一个房间时,舞者会对您的仪态和风度做出反应。 它们会向你做手势,在你周围跳舞,在背后做出有趣的动作,然后快速离开,以免撞到你。黄色球体始终是人们关注的焦点

这是因为在录制演出时,黄色球体会与音乐同步地穿过房间中心,并循环回放。球体的位置可让表演者了解他们所处的时间,以及他们在循环中还剩多长时间。它可以自然而然地重点提升观众的表现。

2. 其他观点

我们不希望遗漏没有使用 VR 的用户,尤其是因为他们可能是我们最大的受众群体。我们不是打造人造 VR 体验,而是为基于屏幕的设备提供独特的体验。我们的想法是从等轴角度展现性能这种观点在计算机游戏领域有着丰富的历史。它首次出现在 Zaxxon(1982 年推出的太空射击游戏)中。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 的访客可以通过该舞者的角度观看表演。从这个角度可以看出很多小细节舞者们尽量保持步伐,舞者迅速看向对方。当球体进入房间时,您会看到他们紧张地望向那个方向。虽然作为观看者,您无法影响这些动作,但确实能出人意料地传达沉浸感。同样,我们更喜欢这样做,而不是向用户展示由鼠标控制的普通人造 VR 版本。

5. 分享录音

我们深知,您精心编织了一个 20 层表演者彼此反应的精美视频,自己会感到十分自豪。我们就知道用户很想向朋友展示但这一壮举的静态图片已无法充分传达信息。我们想允许用户分享他们表演的视频事实上,为什么不采用 GIF 格式呢?我们的动画采用扁平阴影,非常适合该广告格式数量有限的调色板。

分享录音

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

遗憾的是,鉴于动画所需的帧数,编码过程仍然太慢。GIF 可通过使用有限的调色板制作小文件。我们发现,大多数时间都花在了为每个像素寻找最接近的颜色上。通过入侵一个小的快捷方式,我们得以将这一过程优化十倍:如果像素的颜色与上一种颜色相同,则使用与之前相同的调色板颜色。

现在,我们可以使用快速编码,但生成的 GIF 文件过大。借助 GIF 格式,您可以通过定义每个帧的处理方法,指明该帧将如何显示在最后一帧之上。为获得更小的文件,我们只更新已更改的像素,而不是每帧更新每个像素。虽然再次减慢编码过程,但这确实大大降低了文件大小。

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 是一项最新的创新技术,可帮助管理网站资源的缓存。在本例中,Service Worker 可以极快地为回访者加载内容,甚至允许网站离线工作。这些都是重要功能,因为我们的许多访问者都将使用不同质量的移动网络连接。

得益于便捷的 Webpack 插件,可代您完成许多繁杂的工作,让 Service Worker 可以轻松添加到项目中。在下面的配置中,我们生成一个 Service Worker,它将自动缓存我们的所有静态文件。它将从网络中拉取最新的播放列表文件(如果有),因为播放列表会随时更新。所有录制 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)的早期阶段,我们期待看到这一新媒介在广告素材和意想不到的方向上会带来怎样的新创意。尽情舞蹈