WebVR 第 5 部分:设计与实现

已发表: 2022-03-11

我喜欢让项目“完成”。 我们已经到了旅程的终点​​——我们在 WebVR 中的天体引力模拟的诞生。

在最后一篇文章中,我们将把我们的高性能模拟代码(第 1、2、3 条)插入到基于画布可视化器(第 4 条)的 WebVR 可视化器中。

  1. “n体问题”介绍和架构
  2. Web Workers 为我们提供了额外的浏览器线程
  3. WebAssembly 和 AssemblyScript 用于我们的 O(n²) 性能瓶颈代码
  4. 画布数据可视化
  5. WebVR 数据可视化

这是一篇较长的文章,因此我们将跳过之前介绍的一些技术细节。 如果您想要一个方向,请查看以前的帖子,或者危险地继续阅读。

我们一直在探索浏览器从单线程 JavaScript 运行时到多线程(网络工作者)高性能运行时(WebAssembly)的范式转变。 这些高性能桌面计算功能在渐进式 Web 应用程序和 SaaS 分发模型中可用。

WebVR 演示
WebVR 演示,示例代码

VR 将创建引人注目的、无干扰的销售和营销环境,以进行沟通、说服和衡量参与度(眼动追踪和互动)。 数据仍将是 0 和 1,但预期的执行摘要和消费者体验将是 WebVR——就像我们今天为平面网络构建移动仪表板体验一样。

这些技术还支持分布式浏览器边缘计算。 例如,我们可以创建一个基于 Web 的应用程序来运行我们的 WebAssembly 计算,以模拟数百万颗恒星。 另一个例子是一个动画应用程序,它在您编辑自己的作品时呈现其他用户的作品。

娱乐内容正在引领虚拟现实的发展,就像在移动设备上引领娱乐一样。 然而,一旦 VR 成为常态(就像今天的移动优先设计),它将是预期的体验(VR 优先设计)。 对于设计师和开发人员来说,这是一个非常激动人心的时刻——而 VR 是一种完全不同的设计范式。

如果你不能抓握,你就不是 VR 设计师。 这是一个大胆的声明,今天是对 VR 设计的深入研究。 当您阅读本文时,该领域正在被发明。 我的目的是分享我在软件和电影方面的经验,以启动“VR 优先设计”的对话。 我们都互相学习。

考虑到这些宏大的预测,我想将这个项目作为一个专业的技术演示来完成——WebVR 是一个很好的选择!

WebVR 和 Google A-Frame

WebVR git repo 是画布版本的一个分支,原因有两个。 它使在 Github 页面上托管项目变得更加容易,并且 WebVR 需要进行一些更改,这会使画布版本和这些文章变得混乱。

如果您还记得我们关于架构的第一篇文章,我们将整个模拟委托给nBodySimulator

`nBodySimulator`

网络工作者帖子显示nBodySimulator有一个step()函数,每 33 毫秒模拟一次调用一次。 step()调用calculateForces()来运行我们的 O(n²) WebAssembly 模拟代码(文章 3),然后更新位置并重新绘制。 在我们之前创建画布可视化的文章中,我们使用画布元素实现了这一点,从这个基类开始:

 /** * Base class that console.log()s the simulation state. */ export class nBodyVisualizer { constructor(htmlElement) { this.htmlElement = htmlElement this.resize() this.scaleSize = 25 // divided into bodies drawSize. drawSize is log10(mass) // This could be refactored to the child class. // Art is never finished. It must be abandoned. } resize() {} paint(bodies) { console.log(JSON.stringify(bodies, null, 2)) } }

定义集成挑战

我们有模拟。 现在,我们希望与 WebVR 集成 - 无需重新构建我们的项目。 无论我们对模拟所做的任何调整,每 33 毫秒都会在函数paint(bodies)的主 UI 线程中发生。

这就是我们衡量“完成”的方式。 我很兴奋——让我们开始工作吧!

如何制作虚拟现实

首先,我们需要一个设计:

  • VR是由什么制成的
  • WebVR设计是如何表达的?
  • 我们如何与之互动

虚拟现实可以追溯到时间的黎明。 每一个篝火故事都是一个被琐碎细节掩盖的古怪夸张的微小虚拟世界。

通过添加 3D 立体视觉和音频,我们可以将我们的篝火故事放大 10 倍。 我的电影制作预算指导老师曾经说过, “我们只为海报付费。 我们不是在构建现实。”

如果您熟悉浏览器 DOM,您就会知道它创建了一个树状的层次结构。

平面网络场景图
平面网络“场景图”。

网络设计中隐含的是观众从“正面”观看。 从侧面看会将 DOM 元素显示为线条,而从背面看,我们只会看到<body>标签,因为它会遮挡其子元素。

VR 沉浸式体验的一部分是让用户控制他们的观点、风格、节奏和交互顺序。 他们不必特别注意任何事情。 如果您以编程方式移动或旋转相机,它们实际上会因 VR 病而呕吐

请注意,VR 疾病可不是开玩笑的。 我们的眼睛和内耳都能检测到运动。 这对于直立行走的动物来说非常重要。 当这些运动传感器不同意时,我们的大脑自然会认为我们的嘴又在胡说八道并呕吐。 我们都曾经是孩子。 已经有很多关于 VR 中这种生存本能的文章。 “Epic Fun”标题在 Steam 上是免费的,而过山车是我发现的最好的 VR 疾病演示。

虚拟现实被表示为“场景图”。 场景图与 DOM 具有相同的树状模式,以隐藏令人信服的 3D 环境的细节和复杂性。 但是,我们不是滚动和路由,而是将查看器定位在他们想要将体验拉向他们的位置。

这是来自 Google 的 A-Frame WebVR 框架的 Hello World 场景图:

 <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Hello, WebVR! • A-Frame</title> <meta name="description" content="Hello, WebVR! • A-Frame"> <script src="https://aframe.io/releases/0.9.2/aframe.min.js"></script> </head> <body> <a-scene background="color: #FAFAFA"> <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" shadow></a-box> <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E" shadow></a-sphere> <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D" shadow></a-cylinder> <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4" shadow></a-plane> </a-scene> </body> </html>

这个 HTML 文档在浏览器中创建了一个 DOM。 <a-*>标签是 A-Frame 框架的一部分,而<a-scene>是场景图的根。 在这里,我们看到场景中显示了四个 3D 图元。

平面网络浏览器中的 A-Frame 场景
平面网络浏览器中的 A-Frame 场景。

首先,请注意我们正在从平面网络浏览器查看场景。 右下角的小面具邀请用户切换到 3D 立体模式。

虚拟现实中的 A-Frame 场景
虚拟现实中的 A 帧场景 - 每只眼睛一张图像。

理论上,您应该能够:

  1. 在你的手机上打开这个
  2. 将手机举到脸前
  3. 享受新现实的辉煌!

如果没有 VR 耳机的精美镜头,我从来没有让它工作。 你可以以便宜的价格买到适用于 Android 手机的 VR 耳机(基于 Google Cardboard 的基本设备),但是,对于开发内容,我建议使用独立的 HMD(头戴式显示器),例如 Oculus Quest。

就像水肺潜水或跳伞一样,虚拟现实是一项齿轮运动。

VR设计师学习“悬崖”

重力和光的现实
欢迎来到这个带有重力和光线的舒适现实。

请注意,A-Frame Hello World 场景具有默认照明和摄像头:

  • 立方体的面是不同的颜色 - 立方体是自阴影的。
  • 立方体在平面上投下阴影 - 有一个定向光。
  • 立方体和平面之间没有缝隙——这是一个有重力的世界。

这些是对观众说的关键线索, “放松,你脸上的这个东西是完全正常的。”

另请注意,此默认设置隐含在上面的 Hello World 场景代码中。 A-Frame 明智地提供了一个合理的默认设置,但请注意——相机和照明是平面网页设计师创建 VR 必须跨越的鸿沟。

我们认为默认的照明设置是理所当然的。 例如,按钮:

纽扣

请注意这种隐式照明在设计和摄影中的普遍性。 即使是“扁平化设计”按钮也无法摆脱网络的默认照明——它会向下和向右投下阴影。

设计、沟通和实施照明和相机设置是 WebVR 设计师的学习悬崖。 “电影语言”是文化规范的集合——以不同的摄影机和灯光设置表示——将故事情感传达给观众。 设计/移动场景周围的灯光和相机的电影专业人士是抓地力部门。

回到我们的虚拟现实

现在,让我们重新开始工作。 我们的天体 WebVR 场景也有类似的模式:

 <!DOCTYPE> <html> <head> <script src="https://aframe.io/releases/0.9.2/aframe.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/aframe-event-set-component.min.js"></script> <script src="main.js"></script> </head> <body> <a-scene> <a-sky color="#222"></a-sky> <a-entity geometry="primitive: circle; radius: 12" position="0 0 -.5" material="color: #333; transparent: true; opacity: 0.5"> <a-sphere color="black" radius=."02"></a-sphere> </a-entity> <a-entity></a-entity> <a-entity geometry="primitive: plane; width: 2; height: auto" position="0 -10 .3" rotation="55 0 0" material="color: blue" text="value: Welcome Astronaut!..."> </a-entity> <a-entity position="0 -12 .7" rotation="55 0 0"> <a-camera> <a-cursor color="#4CC3D9" fuse="true" timeout="1"></a-cursor> </a-camera> </a-entity> </a-scene> </body> </html>

此 HTML 文档加载 A-Frame 框架和交互插件。 我们的场景从<a-scene>开始。

在内部,我们从<a-sky color="#222"></a-sky>元素开始为场景中未定义的所有内容设置背景色。

接下来,我们创建一个“轨道平面”,让观众在他们飞过我们陌生而未知的世界时“抓住”他们。 我们将其创建为一个圆盘和一个位于 (0,0,0) 处的黑色小球。 没有这个,转身对我来说“毫无根据”:

 <a-entity geometry="primitive: circle; radius: 12" position="0 0 -.5" material="color: #333; transparent: true; opacity: 0.5"> <a-sphere color="black" radius=."02"></a-sphere> </a-entity>

接下来,我们定义一个集合,我们可以在其中添加/删除/重新定位 A-Frame 实体。

 <a-entity></a-entity>

这是nBodyVisualizerpaint(bodies)完成其工作的空地。

然后,我们创建了观众与这个世界之间的关系。 作为一个技术演示,这个世界的目的是让观众探索 WebVR 和支持它的浏览器技术。 一个简单的“宇航员”叙事创造了一种游戏感,而这个星标是导航的另一个参考点。

 <a-entity geometry="primitive: plane; width: 2; height: auto" position="0 -10 .3" rotation="55 0 0" material="color: blue" text="value: Welcome Astronaut!\n ..."> </a-entity>

这完成了我们的场景图。 最后,我希望在电话演示中用户和这个旋转世界之间进行某种交互。 我们如何在 VR 中重新创建“Throw Debris”按钮?

按钮是所有现代设计的基本元素——VR 按钮在哪里?

WebVR 中的交互

虚拟现实有自己的“上方”和“下方”。 观众的第一次互动是通过他们的头像或相机。 这是缩放的所有控件。

如果您在桌面上阅读此内容,您可以使用 WASD 移动和鼠标旋转相机。 这种探索揭示了信息,但不表达你的意愿。

现实现实有几个非常重要的特性,这些特性在网络上并不常见:

  • 透视- 当物体远离我们时,它们会明显变小。
  • 遮挡- 根据位置隐藏和显示对象。

VR 模拟这些功能以创建 3D 效果。 它们还可以在 VR 中用于显示信息和界面 - 并在呈现交互之前设置情绪。 我发现大多数人在继续前进之前只需要一分钟来享受这种体验。

在 WebVR 中,我们在 3D 空间中进行交互。 为此,我们有两个基本工具:

  • 碰撞- 当两个对象共享相同空间时触发的被动 3D 事件。
  • 投影- 一个活动的 2D 函数调用,列出了与一条线相交的所有对象。

碰撞是最“VR-like”的交互

在 VR 中,“碰撞”正是它听起来的样子:当两个对象共享同一个空间时,A-Frame 会创建一个事件。

为了让用户“按下”一个按钮,我们必须给他们一个棋子和一些可以用来按下按钮的东西。

不幸的是,WebVR 还不能假设控制器——许多人会在他们的桌面或手机上查看平面网络版本,并且许多人会使用像 Google Cardboard 或三星的 Gear VR 这样的耳机来显示立体版本。

如果用户没有控制器,他们就无法伸手“触摸”事物,因此任何碰撞都必须与他们的“个人空间”发生冲突。

我们可以给玩家一个宇航员形状的棋子来四处移动,但是强迫用户进入一个旋转的行星瘴气似乎有点令人反感,并且与我们设计的宽敞性背道而驰。

投影是 3D 空间中的 2D“类 Web”点击

除了“碰撞”,我们还可以使用“投影”。 我们可以在我们的场景中投射一条线,看看它接触到了什么。 最常见的例子是“传送射线”。

传送光线追踪世界中的一条线,以显示玩家可以移动的位置。 这个“投影”寻找着陆的地方。 它返回投影路径中的一个或多个对象。 这是一个传送射线示例:

虚幻引擎默认内容中的传送光线
虚幻引擎默认内容中的传送光线。

请注意,射线实际上是作为指向下方的抛物线实现的。 这意味着它像抛掷物体一样自然地与“地面”相交。 这也自然地设置了最大传送距离。 限制是 VR 中最重要的设计选择。 幸运的是,现实有许多自然限制。

投影将 3D 世界“扁平化”为 2D,因此您可以像鼠标一样指向东西来点击它。 第一人称射击游戏是精心制作的“2D 点击”游戏,在精美的令人沮丧的按钮上进行 - 通常有一个精心制作的故事来解释为什么那些该死的按钮不能“点击”你回来。

VR 中有这么多枪,因为枪已经被完善为准确可靠的 3D 鼠标——而点击是消费者无需学习就知道如何做的事情。

投影还提供了与场景关系中距离的安全性。 请记住,在 VR 中更接近某些事物自然会遮挡所有其他可能尚未揭示其重要性的事物。

使用“凝视”进行无控制器投影

为了在没有控制器的 WebVR 中创建这种交互原语,我们可以将观看者的“凝视”投影为视线“光标”。 该光标可以通过编程方式使用“保险丝”与对象进行交互。 这作为一个蓝色的小圆圈传达给观众。 现在我们点击!

如果您记得篝火故事,谎言越大,出售它所需的细节就越少。 一个明显而荒谬的“凝视”互动是凝视太阳。 我们使用这种“凝视”来触发将新的“碎片”行星添加到我们的模拟中。 从来没有观众质疑过这种选择——虚拟现实在荒谬的时候非常迷人。

在 A-Frame 中,我们将相机(玩家的隐形棋子)和这条视线“光标”表示为我们的相机索具。 将<a-cursor>放在<a-camera>内会导致相机的变换也应用于光标。 当玩家移动/旋转他们的棋子( a-camera )时,它也会移动/旋转他们的视线( a-cursor )。

 // src/index.html <a-entity position="0 -12 .7" rotation="55 0 0"> <a-camera> <a-cursor color="#4CC3D9" fuse="true" timeout="1"></a-cursor> </a-camera> </a-entity>

光标的“熔断器”会等到一整秒的“凝视”时间过后才会发出事件。

我使用了默认照明,因此您可能会注意到太阳的“背面”没有被照亮。 虽然我没有离开过轨道平面,但我不认为太阳是这样工作的。 但是,它适用于我们的现实技术演示海报。

另一种选择是将照明放在相机元素内,因此它会随着用户移动。 这将创造一种更亲密——也可能是令人毛骨悚然的——小行星矿工体验。 这些是有趣的设计选择。

我们有一个整合计划

有了这个,我们现在有了 A-Frame <a-scene>和我们的 JavaScript 模拟之间的集成点:

A-Frame <a-scene>

  • 实体的命名集合: <a-entity></a-entity>

  • 将发出投影事件的光标: <a-cursor color="#4CC3D9" fuse="true" timeout="1"></a-cursor>

我们的 JavaScript 模拟:

  • nBodyVisWebVR.paint(bodies) - 从模拟主体中添加/删除/重新定位 VR 实体

  • addBodyArgs(name, color, x, y, z, mass, vX, vY, vZ)将新的碎片体添加到模拟中

index.html加载main.js ,它像画布版本一样初始化我们的模拟:

 // src/main.js import { nBodyVisualizer, nBodyVisWebVR } from ."/nBodyVisualizer" import { Body, nBodySimulator } from ."/nBodySimulator" window.onload = function() { // Create a Simulation const sim = new nBodySimulator() // this Visualizer manages the UI sim.addVisualization(new nBodyVisWebVR(document.getElementById("a-bodies"), sim)) // making up stable universes is hard // name color xyzm vz vy vz sim.addBody(new Body("star", "yellow", 0, 0, 1, 1e9)) sim.addBody(new Body("hot-jupiter", "red", -1, -1, 1, 1e4, .24, -0.05, 0)) sim.addBody(new Body("cold-jupiter", "purple", 4, 4, .5, 1e4, -.07, 0.04, 0)) // Start simulation sim.start() // Add another sim.addBody(new Body("saturn", "blue", -8, -8, .1, 1e3, .07, -.035, 0)) }

您会注意到这里我们将可视化工具的htmlElement设置为a-bodies集合来保存实体。

从 JavaScript 以编程方式管理 A-Frame 对象

index.html中声明了我们的场景后,我们现在可以编写可视化器了。

首先,我们设置nBodyVisualizernBodySimulation列表中读取,并在<a-entity></a-entity>集合中创建/更新/删除 A-Frame 对象。

 // src/nBodyVisualizer.js /** * This is the WebVR visualizer. * It's responsible for painting and setting up the entire scene. */ export class nBodyVisWebVR extends nBodyVisualizer { constructor(htmlElement, sim) { // HTML Element is a-collection#a-bodies. super(htmlElement) // We add these to the global namespace because // this isn't the core problem we are trying to solve. window.sim = sim this.nextId = 0 } resize() {}

在构造函数中,我们保存我们的 A-Frame 集合,为我们的凝视事件设置一个全局变量以查找模拟,并初始化一个 id 计数器,我们将使用它来匹配我们的模拟和 A-Frame 场景之间的主体。

 paint(bodies) { let i // Create lookup table: lookup[body.aframeId] = body const lookup = bodies.reduce( (total, body) => { // If new body, give it an aframeId if (!body.aframeId) body.aframeId = `a-sim-body-${body.name}-${this.nextId++}` total[body.aframeId] = body return total }, {}) // Loop through existing a-sim-bodies and remove any that are not in // the lookup - this is our dropped debris const aSimBodies = document.querySelectorAll(."a-sim-body") for (i = 0; i < aSimBodies.length; i++) { if (!lookup[aSimBodies[i].id]) { // if we don't find the scene's a-body in the lookup table of Body()s, // remove the a-body from the scene aSimBodies[i].parentNode.removeChild(aSimBodies[i]); } } // loop through sim bodies and upsert let aBody bodies.forEach( body => { // Find the html element for this aframeId aBody = document.getElementById(body.aframeId) // If html element not found, make one. if (!aBody) { this.htmlElement.innerHTML += ` <a-sphere class="a-sim-body" dynamic-body ${ (body.name === "star") ? "debris-listener event-set__enter='_event: mouseenter; color: green' event-set__leave='_event: mouseleave; color: yellow'" : ""} position="0 0 0" radius="${body.drawSize/this.scaleSize}" color="${body.color}"> </a-sphere>` aBody = document.getElementById(body.aframeId) } // reposition aBody.object3D.position.set(body.x, body.y, body.z) }) }

首先,我们循环遍历模拟主体以标记和/或创建查找表,以将 A-Frame 实体与模拟主体匹配。

接下来,我们循环遍历现有的 A-Frame 主体,并删除任何被模拟修剪的超出边界的物体。 这增加了体验的感知性能。

最后,我们循环遍历 sim 主体,为丢失的主体创建一个新<a-sphere> ,并使用aBody.object3D.position.set(body.x, body.y, body.z)重新定位其他主体

我们可以使用标准 DOM 函数以编程方式更改 A-Frame 场景中的元素。 为了向场景中添加元素,我们将一个字符串附加到容器的 innerHTML 中。 这段代码对我来说味道很奇怪,但它可以工作,而且我没有找到更好的东西。

您会注意到,当我们创建要附加的字符串时,我们在“star”附近有一个三元运算符来设置属性。

 <a-sphere class="a-sim-body" dynamic-body ${ (body.name === "star") ? "debris-listener event-set__enter='_event: mouseenter; color: green' event-set__leave='_event: mouseleave; color: yellow'" : ""} position="0 0 0" radius="${body.drawSize/this.scaleSize}" color="${body.color}"> </a-sphere>`

如果主体是“星”,我们添加一些额外的属性来描述它的事件。 下面是我们的星星挂载在 DOM 中时的样子:

 <a-sphere class="a-sim-body" dynamic-body="" debris-listener="" event-set__enter="_event: mouseenter; color: green" event-set__leave="_event: mouseleave; color: yellow" position="0 0 0" radius="0.36" color="yellow" material="" geometry=""></a-sphere>

三个属性,fragments debris-listenerevent-set__enterevent-set__leave ,建立了我们的交互,是我们整合的最后一圈。

定义 A-Frame 事件和交互

我们在实体的属性中使用 NPM 包“aframe-event-set-component”来改变观察者“看”太阳时的颜色。

这种“凝视”是观察者位置和旋转的投影,交互提供了他们的凝视正在做某事的必要反馈。

我们的星球现在有两个由插件启用的速记事件event-set__enterevent-set__leave

 <a-sphere ... event-set__enter="_event: mouseenter; color: green" event-set__leave="_event: mouseleave; color: yellow" … ></a-sphere>

接下来,我们用我们将作为自定义 A-Frame 组件实现的debris-listener来装饰我们的星球。

 <a-sphere ... debris-listener="" … ></a-sphere>

A-Frame 组件在全局级别定义:

 // src/nBodyVisualizer.js // Component to add new bodies when the user stares at the sun. See HTML AFRAME.registerComponent('debris-listener', { init: function () { // Helper function function rando(scale) { return (Math.random()-.5) * scale } // Add 10 new bodies this.el.addEventListener('click', function (evt) { for (let x=0; x<10; x++) { // name, color, x, y, z, mass, vx, vy, vz window.sim.addBodyArgs("debris", "white", rando(10), rando(10), rando(10), 1, rando(.1), rando(.1), rando(.1)) } }) } })

这个 A-Frame 组件就像一个“点击”监听器,可以由凝视光标触发,向我们的场景添加 10 个新的随机物体。

总结一下:

  1. 我们用标准 HTML 中的 A-Frame 声明 WebVR 场景。
  2. 我们可以通过 JavaScript 以编程方式在场景中添加/删除/更新 A-Frame 实体。
  3. 我们可以通过 A-Frame 插件和组件在 JavaScript 中创建与事件处理程序的交互。

WebVR:Veni、Vidi、Vici

我希望你能像我一样从这个技术演示中得到尽可能多的东西。 我们将这些功能(网络工作者和 WebAssembly)应用于 WebVR 的地方,它们也可以应用于浏览器边缘计算。

一个巨大的技术浪潮已经到来——虚拟现实(VR)。 无论您第一次拿着智能手机时的感受如何,第一次体验 VR 都会在计算的各个方面提供 10 倍的情感体验。 距离第一部 iPhone 仅 12 年。

VR 存在的时间要长得多,但将 VR 带给普通用户所需的技术是通过移动革命和 Facebook 的 Oculus Quest 实现的,而不是 PC 革命。

互联网和开源是人类世界上最伟大的奇迹之一。 致所有创建平面互联网的人——我为你们的勇气和冒险精神干杯。

宣言! 我们将建造世界,因为我们拥有创造的力量。

WebVR 演示
画布演示、WebVR 演示、示例代码