使用 A-Frame 构建虚拟现实网页

Mozilla 的 WebVR 团队 (MozVR) 在一年前开始研究,“Web 上的虚拟现实 (VR) 会是什么样子?” 今天我们点击链接从一个页面跳转到另一个页面,而将来我们将能够通过传送门从一个世界跳转到另一个世界。不幸的是,世界上只有少数 WebGL 开发者知道如何创建高度交互式的 3D 体验。但是,可能有数百万 Web 开发者、Web 设计师和 3D 艺术家渴望拥有一个工具,能够像构建网页一样轻松地创建 VR 内容。

我们最近发布了一个名为 A-Frame 的开源框架,用于在 Web 上轻松创建 3D 和 VR 体验。A-Frame 通过允许我们使用声明性 HTML创建场景,使 VR 内容创作触手可及,并且这些场景能够在桌面、Oculus Rift 和智能手机上正常运行。我们可以像操作普通 HTML 元素一样使用纯 JavaScript 来操作场景,并且可以继续使用我们喜欢的 JavaScript 库和框架(例如,d3React)。A-Frame 中的基本场景看起来像这样

查看 CodePen 上 MozVR (@mozvr) 的笔 Hello A-Frame – 2

在这个场景中

  • 我们使用 <a-cube><a-cylinder><a-sphere> 创建了一些基本几何体。
  • 我们使用 <a-image> 从 Web 上获取了一张图片。
  • 我们使用 <a-sky> 创建了一张 360 度照片作为背景。
  • 我们可以使用 WASD 键移动,并使用鼠标拖动来环顾四周。

要进入 VR,我们点击眼镜图标。这个场景可以在桌面上的 Oculus Rift 或使用 Google Cardboard 支架的智能手机上观看。或者,它也可以作为一个普通的 3D 场景。阅读更多关于进入 VR 的内容。上面的语法应该对大多数人来说都很熟悉;<a-scene> 下的每个元素都代表一个 3D 对象,我们可以使用 HTML 属性来修改这些对象。然而,在这个简单的标记之下,隐藏着一个灵活且可扩展的 3D 框架。

three.js + 实体组件系统

A-Frame 的底层是一个three.js 框架,它将实体组件系统 (ECS) 模式引入 DOM。A-Frame 是在 three.js 之上构建的抽象层,并且足够可扩展,可以执行几乎所有 three.js 可以做的事情。

ECS 模式是游戏开发中常用的模式,它倾向于可组合性而不是继承性。由于 A-Frame 旨在将高度交互式的 3D 体验带到 Web 上,因此它采用了游戏行业中现有的模式。在 ECS 中,场景中的每个对象都是一个实体,它是一个通用的容器,本身不做任何事情。组件 是可重复使用的模块,然后插入实体以附加外观、行为和/或功能。

为了给出简单的抽象示例,我们可能具有颜色、轮胎和引擎组件。我们可以通过配置、混合和插入可重复使用的组件来组合实体

  • 使用颜色组件设置为蓝色、轮胎组件设置为四个以及附加引擎组件来组合一辆蓝色的汽车实体。
  • 使用颜色组件设置为红色、轮胎组件设置为两个以及不附加引擎组件来组合一辆红色的自行车实体。
  • 使用颜色组件设置为黄色、轮胎组件设置为零以及附加引擎组件来组合一艘黄色的船实体。

Entity-Component-System

The VR Jump 的 Ruben Mueller 对实体组件系统模式的抽象表示。

在 A-Frame 中

  • 实体由 <a-entity> 表示。它是构成场景中所有内容的核心构建块。
  • 组件由 HTML 属性表示(例如,<a-entity engine>)。
  • 组件的属性通过字符串传递到 HTML 属性中,并在稍后进行解析。
  • 如果组件只有一个属性要定义,则它看起来像一个普通的 HTML 属性(例如,<a-entity visible="false">)。
  • 如果组件有多个属性要定义,则属性将通过类似于内联 CSS 样式的语法传递(例如,<a-entity engine="cylinders: 4; horsepower: 158; mass: 200">)。

<a-cube> 为例,我们可以将其分解为几何体(形状)和材质(外观)组件

<!-- <a-cube>'s actual form. -->
<a-entity geometry="primitive: box; depth: 2; height: 10; width: 4"
          material="color: #FFF; src: url(texture.png)">

开发人员可以编写组件来完成几乎所有事情,并与其他开发人员共享,以便即插即用。让我们配置并附加更多组件来组合更复杂的实体

Composing an Entity

在 ECS 模式中,几乎所有逻辑和行为都应该封装在组件中,以鼓励模块化和重用。

构建交互式场景

让我们通过一个构建场景的示例来了解工作流程,该工作流程围绕编写组件展开。我们将构建一个交互式场景,在该场景中,我们可以向我们周围的敌人发射激光。我们可以使用 A-Frame 附带的标准组件,也可以使用 A-Frame 开发者发布到生态系统的组件。更好的是,我们可以编写自己的组件来做任何我们想做的事情!

如果您想参与,有几种方法可以开始使用 A-Frame 编写代码

让我们从添加一个敌人的目标开始

查看 CodePen 上 MozVR (@mozvr) 的笔 Laser Shooter – Step 1

这将创建一个基本静态场景,其中敌人会盯着你看,即使你四处移动。我们可以使用生态系统中的 A-Frame 组件来完成一些很酷的事情。

使用组件

awesome-aframe 存储库 是一个很棒的地方,可以找到社区创建的组件,以启用新功能。这些组件中的许多都是从 组件模板 开始的,并且应该在它们存储库的 dist/ 文件夹中提供构建。以 布局组件 为例。我们可以获取构建,将其放到我们的场景中,然后立即能够使用 3D 布局系统来自动定位实体。让我们不要只有一个敌人,而是有十个敌人围绕着玩家呈圆形排列

查看 CodePen 上 MozVR (@mozvr) 的笔 Laser Shooter – Step 2

在标记中重复十次敌人实体会很乱。我们可以使用 模板组件 来清理它。我们还可以使用 A-Frame 的 动画系统 让敌人围绕我们呈圆形移动。

查看 CodePen 上 MozVR (@mozvr) 的笔 Laser Shooter – Step 3

通过混合和匹配布局和模板组件,我们现在有十个敌人围绕我们呈圆形排列。让我们通过编写自己的组件来启用游戏玩法。

编写组件

熟悉 JavaScript 和 three.js 的开发人员可以编写组件,为实体添加外观、行为和功能。正如我们所见,这些组件随后可以重复使用并与社区共享。并非所有组件都需要共享;它们可以是临时的或一次性的。

组件由数据组成,数据由架构定义,可以通过 HTML 传递,以及生命周期方法,这些方法定义了如何使用数据修改它所附加的实体。生命周期方法通常会与 three.js、DOM 和 A-Frame API 交互。我之前关于 如何编写 A-Frame VR 组件 的博客文章详细介绍了如何使用组件 API 来注册组件。

对于这个场景,我们希望能够向敌人发射激光,使它们消失。我们将需要组件来在点击时创建激光,生成点击,推动激光,以及检查激光何时击中敌人。

spawner 组件

让我们从能够创建激光开始。我们希望能够生成一个以玩家当前位置为起点的激光实体。我们将创建一个 spawner 组件,该组件监听实体上的事件,当事件被触发时,我们将生成一个具有预定义的 mixin 组件的实体

AFRAME.registerComponent('spawner', {
  schema: {
    on: { default: 'click' },
    mixin: { default: '' }
  },

  /**
   * Add event listener to entity that when emitted, spawns the entity.
   */
  update: function (oldData) {
    this.el.addEventListener(this.data.on, this.spawn.bind(this));
  },

  /**
   * Spawn new entity with a mixin of componnets at the entity's current position.
   */
  spawn: function () {
    var el = this.el;
    var entity = document.createElement('a-entity');
    var matrixWorld = el.object3D.matrixWorld;
    var position = new THREE.Vector3();
    var rotation = el.getAttribute('rotation');
    var entityRotation;

    position.setFromMatrixPosition(matrixWorld);
    entity.setAttribute('position', position);

    // Have the spawned entity face the same direction as the entity.
    // Allow the entity to further modify the inherited rotation.
    position.setFromMatrixPosition(matrixWorld);
    entity.setAttribute('position', position);
    entity.setAttribute('mixin', this.data.mixin);
    entity.addEventListener('loaded', function () {
      entityRotation = entity.getComputedAttribute('rotation');
      entity.setAttribute('rotation', {
        x: entityRotation.x + rotation.x,
        y: entityRotation.y + rotation.y,
        z: entityRotation.z + rotation.z
      });
    });
    el.sceneEl.appendChild(entity);
  }
});

click-listener 组件

现在,我们需要一种方法来在玩家实体上生成点击事件,以便生成激光。我们可以只在内容脚本中编写一个纯 JavaScript 事件处理程序,但编写一个能够允许任何实体监听点击的组件更具可重复使用性

AFRAME.registerComponent('click-listener', {
  // When the window is clicked, emit a click event from the entity.
  init: function () {
    var el = this.el;
    window.addEventListener('click', function () {
      el.emit('click', null, false);
    });
  }
});

从 HTML 中,我们定义激光 mixin 并将 spawner 和 click-listener 组件附加到玩家。当我们点击时,spawner 组件将生成一个以相机前方为起点的激光

查看 CodePen 上 MozVR (@mozvr) 的笔 Laser Shooter – Step 4

projectile 组件

现在激光会在我们点击时出现在我们前方,但我们需要它们发射并移动。在 spawner 组件中,我们让激光指向相机旋转的方向,并且我们将它围绕 X 轴旋转了 90 度以正确对齐。我们可以添加一个 projectile 组件,使激光沿着它当前所面向的方向(在这种情况下,它的局部 Y 轴)笔直移动

AFRAME.registerComponent('projectile', {
  schema: {
    speed: { default: -0.4 }
  },

  tick: function () {
    this.el.object3D.translateY(this.data.speed);
  }
});

然后将 projectile 组件附加到激光 mixin

<a-assets>
  <!-- Attach projectile behavior. -->
  <a-mixin id="laser" geometry="primitive: cylinder; radius: 0.05; translate: 0 -2 0"
                      material="color: green; metalness: 0.2; opacity: 0.4; roughness: 0.3"
                      projectile="speed: -0.5"></a-mixin>
</a-assets>

现在,激光将在点击时像抛射物一样发射

查看 CodePen 上 MozVR (@mozvr) 的 激光射击器 - 第 5 步

碰撞器组件

最后一步是添加一个碰撞器组件,以便我们能够检测到激光何时击中实体。我们可以使用 three.js Raycaster 来实现这一点,从激光的两端绘制一条射线(线),然后不断检查是否有敌人与射线相交。如果敌人与我们的射线相交,那么它就接触到了激光,我们将使用一个事件来告诉敌人它被击中了。

AFRAME.registerComponent('collider', {
  schema: {
    target: { default: '' }
  },

  /**
   * Calculate targets.
   */
  init: function () {
    var targetEls = this.el.sceneEl.querySelectorAll(this.data.target);
    this.targets = [];
    for (var i = 0; i < targetEls.length; i++) {
      this.targets.push(targetEls[i].object3D);
    }
    this.el.object3D.updateMatrixWorld();
  },

  /**
   * Check for collisions (for cylinder).
   */
  tick: function (t) {
    var collisionResults;
    var directionVector;
    var el = this.el;
    var sceneEl = el.sceneEl;
    var mesh = el.getObject3D('mesh');
    var object3D = el.object3D;
    var raycaster;
    var vertices = mesh.geometry.vertices;
    var bottomVertex = vertices[0].clone();
    var topVertex = vertices[vertices.length - 1].clone();

    // Calculate absolute positions of start and end of entity.
    bottomVertex.applyMatrix4(object3D.matrixWorld);
    topVertex.applyMatrix4(object3D.matrixWorld);

    // Direction vector from start to end of entity.
    directionVector = topVertex.clone().sub(bottomVertex).normalize();

    // Raycast for collision.
    raycaster = new THREE.Raycaster(bottomVertex, directionVector, 1);
    collisionResults = raycaster.intersectObjects(this.targets, true);
    collisionResults.forEach(function (target) {
      // Tell collided entity about the collision.
      target.object.el.emit('collider-hit', {target: el});
    });
  }
});

然后,我们在敌人身上附加一个类,将其指定为目标,附加碰撞时触发的动画以使其消失,最后将碰撞器组件附加到以敌人为目标的激光上。

<a-assets>
  <img id="enemy-sprite" src="img/enemy.png">

  <script id="enemies" type="text/x-nunjucks-template">
    <a-entity layout="type: circle; radius: 5">
      <a-animation attribute="rotation" dur="8000" easing="linear" repeat="indefinite" to="0 360 0"></a-animation>

      {% for x in range(num) %}
        <!-- Attach enemy class. -->
        <a-image class="enemy" look-at="#player" src="#enemy-sprite" transparent="true">
          <!-- Attach collision handler animations. -->
          <a-animation attribute="opacity" begin="collider-hit" dur="400" ease="linear"
                       from="1" to="0"></a-animation>
          <a-animation attribute="scale" begin="collider-hit" dur="400" ease="linear"
                       to="0 0 0"></a-animation>
        </a-image>
      {% endfor %}
    </a-entity>
  </script>

  <!-- Attach collider that targets enemies. -->
  <a-mixin id="laser" geometry="primitive: cylinder; radius: 0.05; translate: 0 -2 0"
                      material="color: green; metalness: 0.2; opacity: 0.4; roughness: 0.3"
                      projectile="speed: -0.5" collider="target: .enemy"></a-mixin>
</a-assets>

这样我们就拥有了一个完整的 A-Frame 基本交互式场景,可以在 VR 中查看。我们将功能封装到组件中,使我们能够以声明式方式构建场景,而不会失去控制或灵活性。结果——一个基本的 FPS 游戏,支持 VR,最终只用 **30 行 HTML** 即可实现。

查看 CodePen 上 MozVR (@mozvr) 的 激光射击器 - 最终

社区

社区仅使用 A-Frame 的初始版本就构建了一些很棒的东西。看看在 用 A-Frame 制作很棒的 A-Frame 上分享了什么。

我们大家都在 A-Frame Slack 上聊天,目前有近 350 人在试用。使用 A-Frame 玩玩,告诉我们你的想法!虚拟现实正在到来,你不想错过这趟列车。

关于 Kevin Ngo

Kevin 是 Mozilla 的虚拟现实开发者,也是 A-Frame(一个开源 WebVR 框架)的核心开发者。他在 Twitter 上的用户名是 @ngokevin_。

更多 Kevin Ngo 的文章…


8 条评论

  1. 动感小前端

    很好的教程!
    THX:)

    2016 年 3 月 5 日 下午 8:36

  2. Ruben Müller

    A-Frame 的很棒的入门教程——喜欢你在这里做的事情!

    2016 年 3 月 7 日 下午 10:02

  3. Mobilock Pro

    您好,

    简单且信息量很大。感谢与我们分享这个很棒的教程。

    干杯!

    2016 年 3 月 11 日 上午 4:23

  4. Matt

    惊讶地看到 30 行代码。A-frame 非常酷。Web2.0 然后是 RIA,现在是虚拟现实 Web。

    2016 年 3 月 11 日 下午 6:17

  5. niutech

    这不是在重新发明轮子吗?我们已经有了 VRML 和 X3D。

    2016 年 3 月 13 日 下午 2:26

    1. Kevin Ngo

      http://ngokevin.com/blog/aframe-vs-3dml/
      https://aframe.io/faq/#How_is_A-Frame_different_from_VRML_3F

      2016 年 3 月 13 日 下午 2:53

  6. Ardianorio773

    很棒的信息,感谢提供的信息!
    Obat Penyakit Glaukoma Tradisional

    2016 年 3 月 14 日 下午 9:38

  7. Mike Bradford

    感谢 Kevin 分享这个有用的教程!将实体-组件-系统 (ECS) 模式应用于 DOM 对网站中的 3D 体验非常有价值。

    2016 年 3 月 29 日 上午 3:50

本文的评论已关闭。