WebXR 应用开发之 aframe 框架入门

avatar
前端工程师 @公众号:ELab团队

本文主要共包含三大部分:第一部分为 WebXR,包括WebXR 的概念、标准、优点以及主流的开发方式;由 WebXR 开发方式中【使用封装好的第三方库开发】又引出了第二部分—— aframe 框架,其简介、特性及其中应用的 ECS 架构;第三部分为通过一个小游戏 demo,快速掌握 aframe 开发基础。

可以先对小游戏进行一个体验:

游戏体验地址:webxr-game.zlxiang.com

源码地址:github.com/zh-lx/webxr…

WebXR

什么是 WebXR?

WebXR 是一组支持将渲染3D场景用来呈现虚拟世界(虚拟现实,也称作VR将图形图像添加到现实世界(增强现实,也称作AR的标准。这个标准实际上是一组 WebXR Device API,它们实现了 WebXR 功能集的核心,管理输出设备的选择,以适当的帧速率将3D场景呈现给所选设备,并管理使用输入控制器创建的运动矢量。

WebXR 兼容性设备包括沉浸式3D运动和定位跟踪耳机,通过框架覆盖在真实世界场景之上的眼镜,以及手持移动电话,它们通过用摄像机捕捉世界来增强现实,并通过计算机生成的图像增强场景。

为了完成这些事情,WebXR Device API 提供了以下关键功能:

  • 查找兼容的VR或AR输出设备
  • 以适当的帧率将3D场景渲染到设备
  • (可选)将输出镜像到2D显示器
  • 创建代表输入控件运动的向量

WebXR api 是建立在早期 WebVR api 之上的,如今除了支持 VR 之外,还添加了对 AR 的支持,VR、AR是从感官体验的角度来区分的:

  • VR是用户借助外设输入输出(头戴、手柄、体感、运动感知等软硬件系统)来和纯虚拟场景的交互体验
  • AR也是用户借助外设来体验额外的虚拟内容,区别是虚拟内容是叠加在真实世界上,其方式可以是通过投射或者视频叠加

一个 WebXR 应用的生命周期

一个 WebXR 应用,底层都要经过以下生命周期:

image.png

WebXR 开发 VR 应用的优点

WebXR是基于网页的XR应用程序,可以用来支持一些本地XR应用不那么适合的场景,比如一些短小精干时效不长的营销推广页面、在线沉浸式视频、电商、在线小游戏和艺术创作等。

相比于本地 XR 应用,WebXR 具备如下等优点:

  • Web的即时性:我们只需通过链接分享一段内容然后单击,就能立即使用该内容。从用户的角度来看,这是一个优势——无需安装App即可使用内容。从开发者的角度来看,我们可以完全掌控自己的工作,无需征得许可,也无需通过管理或审批流程即可发布应用。
  • Web 标准的稳定性:因为 Web 标准的存在,WebXR api 在现阶段已经发布之后,浏览器几乎就不会删除这些 api 了,因此我们通过 WebXR 建立的应用,可以保持长时间的稳定性,而不像原生应用那般需要不断随系统升级而进行适配。
  • Web 开发拥有大量的从业者:目前 XR 在 Web 开发中尚未流行起来,而 Web 开发者的群体基数十分庞大,一旦 WebXR 开发在 Web 开发从业者中流行,那么势必会得到一个快速发展,促进该项技术的繁荣。

WebXR 应用的开发方式

主流的 WebXR 应用开发方式有三种:

image.png

image.png

使用封装好的第三方库

对于没有 WebGL 基础的用户,学习和开发成本相对都比较高,因此市面上有一些在 WebGL 基础上封装的库,帮助我们快速上手开发 WebXR,例如 aframebabylonthree.js

WebGL + WebXR api

使用 WebGL 加 WebXR api 开发的方式,相对来说是比较贴近于底层的,对于底层,特别是渲染模块我们可以做一些优化操作从而提升 XR 的性能和体验。

传统 3d 引擎 + emscripten

传统的 3D 应用开发我们一般都会采用一些比较知名的 3D 引擎例如 unity、unreal 等,借助 emscripten,我们可以将 C 和 C++ 代码编译为 WebAssembly,从而实现 web 端的 XR。

aframe 框架

简介

aframe 是一个用来构建虚拟现实(VR)应用的网页开发框架,它基于 HTML 之上,使其上手十分简单。但是 aframe不仅仅是一个3D场景渲染引擎或者一个标记语言,其核心思想是基于 Three.js 来提供一个声明式、可扩展以及组件化的编程结构。

它由WebVR的发起人 Mozilla VR 团队所构建,是当下用来开发WebVR内容主流技术方案,现在由 aframe 在 Supermedium 中的联合创建者维护。作为一个独立的开源项目,aframe 已经成长为最大的 VR 社区之一。

特性

  • 简单的 VR 制作:只需要引入 <script> 标签 和 <a-scene>,aframe 将自动生成3D渲染的样板代码、VR相关设置和缺省的交互控制。不需要安装任何东西也无需编译构建。
  • 声明式 HTML:aframe 通过 html 标签的方式,将大量的 3D 逻辑封装在内,容易阅读,理解和复制粘贴。
  • ECS 架构:aframe 基于强大的 three.js 框架, 同时提供声明式、组件化、可复用的实体组件结构。HTML只是冰山的一角,开发者可以自由的使用 JavaScript、DOM API,Three.js,WebVR,和 WebGL。
  • 高性能:aframe 从底层对 WebVR 做了优化,尽管其使用 DOM,但其元素并不接触浏览器的布局引擎。3D 对象的更新全部在低开销内存中通过单个 requestAnimationFrame 来调用,甚至能够像本地应用一样来运行 (90+ FPS)。
  • 跨平台:A-Frame 能构建能兼容主流头显设备的 VR 应用程序,如HTC Vive, Rift, Daydream, GearVR,Pico, Oculus乃至在普通电脑和手机上运行。
  • 工具无关性:由于是构建在 HTML 之上,所以 A-Frame 和大多数开发库、框架和工具如 react, vueangular 等都能够兼容。
  • 可视化的检测工具:aframe 提供一个便捷的内置3D可视化检测工具。打开任意的A-Frame场景,Mac 敲击 <control> + <option> + <i> 或者 windows 敲击 <ctrl> + <alt> + i 组合键,将切换到3D元素检测模式。

    •   

ECS 架构

ECS 全称 Entity-component-system(实体-组件-系统) ,是一种主要在游戏开发领域使用的架构模式。ECS 架构遵循组合模式要好于继承和层次结构的设计原则,具有很大的灵活性。

组成

实体

实体是指存在于游戏中的一个物体,实际上它是一系列组件的集合

aframe 中使用 <a-entity> 元素来表示一个实体,如同在 ECS 架构中定义的那样,实体是一个占位符对象,我们通过插入组件来提供其外观、行为和功能。其中,位置(position), 旋转(rotation)和尺寸(scale)是实体的固有组件。

在代码中,一个实体你可以看做是一个 html 标签:

<!-- 一个空实体,它没有外表、行为或功能 -->
<a-entity />

<!-- 我们可以给实体加上几何模型(geometry)和 材料(material)组件 ,使它具有形状和外观-->
<a-entity geometry= primitive: box  material= color: red  />

组件

组件是一个可重用和模块化的数据块,我们将其插入到一个实体中,以添加外观、行为或功能。aframe 中,组件修改场景中的三维对象实体,我们将组件组合在一起构建复杂对象(实际上其封装了 three.js 和 js 代码逻辑)。

aframe 内置了大量的组件供我们使用:aframe.io/docs/1.3.0/…

在代码中,一个组件可以看作是 html 标签的一个属性:

<!-- 如下给实体添加了 position 组件,用以改变实体在三维坐标中的位置 -->
<a-entity position= 1 2 3 ></a-entity>

可以通过 AFRAME.registerComponent api 来注册一个组件:

<script>
AFRAME.registerComponent('very-high', {
  init: function () {
    this.el.setAttribute('position', '0 9999 0')
  }
});
</script>

<a-entity very-high></a-entity>

系统

一个系统为组件类提供全局范围,服务和管理 它为组件类提供公共 API (方法和属性) 。一个系统可以通过场景元素来访问,并能帮助组件和全局场景交互。

系统的注册方式和组件类似,通过 AFRAME.registerSystem 进行注册。如下代码,注册了一个 car 系统,它为 car 组件提供服务,car 组件可以通过 this.system 来访问它的同名系统,根据 car 组件的不同 type ,系统为组件对应的实体设置了不同的 speed

AFRAME.registerSystem('car', {
  getSpeed: function (type) {
    if (type === 'tractor') {
      return 40;
    } else if (type === 'sports car') {
      return 300;
    } else {
      return 100;
    }
  }
})

AFRAME.registerComponent('car', {
  schema: {
    type: { default: 'tractor' },
  },
  init: function () {
    this.el.setAttribute('speed', this.system.getSpeed(this.data.type))
  },
})

优势

在 3D 和 VR 游戏开发领域,ECS 架构经久考验,著名的 unity 游戏引擎就是采用 ECS 架构,那么相比于 OOP(面向对象),ECS 有什么优势呢?

与面向对象相比,ECS 架构最大的区别就是面向对象是通过继承的方式来构建复杂的类,而 ECS 通过组合的方式来构建复杂的实体。在 OOP 模式下,当一个新的类型需要多个老类型的不同功能的时候,不能很好的继承出来;而 ECS 把大量的模块进行集成并解耦,用最小的耦合来集成大量分散的系统,更为灵活。

举个例子:

现在我们有一个游戏,里面有玩家、敌人、建筑、树等物体,游戏开发了一段时间后,我们需要增加一类会攻击的建筑。

如果通过面向对象的方式,在一系列冗长的继承链之后,会攻击的建筑无法再直接继承 Building 和 Enemy 类了(Enemy 类继承了 Dynamic 类):

image.png

而在 ECS 架构下,由于每个组件都是最小单元且相互解耦的,所以只需要组合 Position、Rotation、Scale、Recover、Attack 组件就可以构建出新的 AttackBuilding 实体。

image.png

VR 开发中的重要概念

VR 开发是基于 3D 的,几乎在所有的 3D 开发中,都有以下两个较为重要的概念:相机(camera) 和 三维坐标系,理解这两个概念,是进行 3D 开发的基础。

相机 (camera)

相机定义了用户从哪个角度查看场景,你可以将相机理解为观察者的眼睛,只有相机看到的画面,才会呈现在屏幕画布上。相机通常与允许输入设备移动和旋转相机的控件组件配对。

相机通常分为正交相机(OrthographicCamera)和透视相机(PerspectiveCamera),3D 场景中一般使用透视相机,而正交相机一般用于 2D 渲染。

正交相机

正交相机(OrthographicCamera)所看到的物体都是三维的,但是人的眼睛只能看到正面,不能看到被遮挡的背面,你看到的是一个2D的投影图。 空间几何体转化为一个二维图的过程就是投影,不同的投影方式意味着投影尺寸不同的算法。

image.png

透视相机

透视相机(PerspectiveCamera)的结果除了与几何体的角度有关,还和距离相关,人的眼睛观察世界就是透视投影,比如你观察一条铁路距离越远你会感到两条轨道之间的宽度越小。

image.png

aframe 是基于 three.js 的,无论正投影还是透视投影,three.js都对相关的投影算法进行了封装,大家只需要根据不同的应用场景自行选择不同的投影方式。使用OrthographicCamera相机对象的时候,three.js会按照正投影算法自动计算几何体的投影结果;使用PerspectiveCamera相机对象的时候,three.js会按照透视投影算法自动计算几何体的投影结果。

三维坐标系

aframe 中的 3d 坐标系使用右手坐标系统,默认的摄像机的朝向,就是下图视角:

image.png

image.png

距离单位

aframe的距离单位是米(meters),因为 WebXR API 以米为单位返回姿势数据。

旋转单位

aframe的旋转单位是角度(degrees),它会在three.js内部转换为弧度。要确定旋转的正方向,需要使用右手法则:把大拇指指向正轴的方向,我们手指绕的方向就是旋转的正方向。

从一个小游戏上手 aframe 开发

了解了 WebXR 及 aframe 的一些基本概念后,我们可以尝试动手制作一个小游戏了。通过这个小游戏的制作过程,你将学习到 a-frame 开发的一些常用 api,并具备上手开发的能力。

构建 aframe 开发环境

首先要引入 aframe 框架,aframe 支持多种引入方式(github.com/aframevr/af… script 标签的方式直接在 html 中引入:

<!DOCTYPE html>
<html lang= en >
  <head>
    <meta charset= UTF-8  />
    <meta http-equiv= X-UA-Compatible  content= IE=edge  />
    <meta name= viewport  content= width=device-width, initial-scale=1.0  />
    <title>WebXR Game</title>
    <script src= https://aframe.io/releases/1.3.0/aframe.min.js ></script>
  </head>
  <body>
  </body>
</html>

需要注意的是,aframe 中的资源(assets)、纹理贴图(textures) 以及模型 (models) 通常需要远程加载,如果是直接通过本地绝对路径打开 html 页面会因为跨域而无法正常访问资源,所以需要通过 host 或者 localhost 访问 html 文件进行开发:

这里我通过 webpack 起了一个 devServer 去访问本地的 HTML 文件。

添加原语/实体

什么是原语

实体我们前面说过了,aframe 中通过 <a-entity> 创建一个实体,表示 VR 世界中的一个物体。原语(primitives) 同样是 <a-xxx> 形式的一个 html 标签,其内部实际上是 实体-组件 的封装。aframe 内置了大量的原语供我们使用: aframe.io/docs/1.3.0/…

添加场景

前面引入了 aframe 框架,接下来我们在 body 中添加一个 <a-scene> 原语, <a-scene>是场景容器,用来包含所有实体,我们所有的实体和原语都需要添加在 <a-scene> 里面。 <a-scene> 帮我们处理了所有 XR 开发所需要的设置:

  • 设置画布(canvas),渲染器(renderer)以及渲染循环
  • 缺省相机和光照
  • 设置webvr-polyfill, VREffect
  • 添加进入 VR 的界面,来启动WebXR API
<!DOCTYPE html>
<html lang= en >
  <head>
    <meta charset= UTF-8  />
    <meta http-equiv= X-UA-Compatible  content= IE=edge  />
    <meta name= viewport  content= width=device-width, initial-scale=1.0  />
    <title>WebXR Game</title>
    <script src= https://aframe.io/releases/1.3.0/aframe.min.js ></script>
  </head>
  <body>
    <a-scene></a-scene>
  </body>
</html>

添加了 <a-scene> 之后,我们在页面的右下角能看到一个 VR 的图标,表示添加成功了。点击图标我们可以进入到 VR 的页面,来启动 WebXR API:

引入社区资源

对于我们 web 开发者来说,VR 最难的可能是建立合适的 3D 模型,好在社区有许多已经封装好的资源供我们使用,A-Frame Registry 收集并组织这些资源以便开发者发现和复用,在这里面我们可以找到许多供我们开箱即用的资源。

这里我引用了 aframe-environment-component,它可以帮助我们快速创建一个美观的场景,引入脚本,然后在 <a-scene 上面添加一个 environment:

<!DOCTYPE html>
<html lang= en >
  <head>
    <meta charset= UTF-8  />
    <meta http-equiv= X-UA-Compatible  content= IE=edge  />
    <meta name= viewport  content= width=device-width, initial-scale=1.0  />
    <title>WebXR Game</title>
    <script src= https://aframe.io/releases/1.3.0/aframe.min.js ></script>
    <script src= https://unpkg.com/aframe-environment-component@1.3.1/dist/aframe-environment-component.min.js ></script>
  </head>
  <body>
    <a-scene environment= preset: forest; ></a-scene>
  </body>
</html>

一个森林的场景就出现在了我们的屏幕中:

添加一面墙

接下来我们要在场景中添加一面墙,用于展示我们游戏的开始、结束、得分以及生命值等信息。使用 <a-box> 原语,在场景中建立一个立方体作为墙:

<a-scene environment= preset: forest; >
  <a-box></a-box>
</a-scene>

前面我们说了原语是对 实体-组件 的封装,上面的代码等价于:

<a-scene environment= preset: forest; >
  <a-entity geometry= primitive: box; ></a-entity>
</a-scene>

添加3D坐标变换

为我们的墙添加 scale= 30 20 4 ,将其设置为一堵 x 轴方向长 30 米,z 轴方向宽 4 米,y 轴方向高 20 米的墙;并添加 postion= 0 0 -20 ,将其位置设置在 z 轴方向 -20 米的位置:

<a-scene environment= preset: forest; >
  <a-box scale= 30 20 4  position= 0 0 -20 ></a-box>
</a-scene>

应用图片纹理

<a-box> 添加一个 src 属性,指定一个图片地址, aframe 会将图片作为贴图渲染在物体的表面。如下代码,我们给墙壁表面贴上了石头纹理:

<a-scene environment= preset: forest; >
  <a-box
    scale= 30 20 4 
    position= 0 0 -20 
    src= https://image-1300099782.cos.ap-beijing.myqcloud.com/wall.jpeg 
  >
   <a-box position= 0 0 0 ></a-box>
  </a-box>
</a-scene>

使用资源管理系统

上面给墙添加图片纹理的方式有一个缺点:墙体的资源加载不会等待图片加载完再开始,这可能导致场景中先渲染出没有图片纹理的墙,等墙体的图片加载完成后墙面才会渲染图片纹理。

出于性能考虑我们推荐使用资源管理系统(<a-assets>)。资源管理系统使浏览器缓存资源更容易(例如图像,视频,模型),并且A-Frame框架将确保所有的资源都在渲染之前被获取到。

如果我们在资源管理系统里面定义一个 <img> ,three.js就无需在底层再创建一个 <img> 。在 aframe 中自行创建 <img> 也给了我们更多的控制,让我们在多个实体上重用纹理。同时必要时 aframe 还能自动设置 crossOrigin 以及其他一些属性。

要将资源管理系统用于图像纹理:

  • 添加<a-assets>到场景中。
  • 将纹理定义为<a-assets>下面的<img>
  • <img>一个HTML ID (e.g. id= boxTexture )。
  • 以DOM选择器格式使用ID来引用资源(src= #boxTexture

使用资源管理系统来实现上面给墙添加图片纹理效果的代码如下:

<a-scene environment= preset: forest; >
  <a-assets timeout= 30000 >
    <img
      id= wallImg 
      src= https://image-1300099782.cos.ap-beijing.myqcloud.com/wall.jpeg 
    />
  </a-assets>
  
  <a-box scale= 30 20 4  position= 0 0 -20  src= #wallImg ></a-box>
</a-scene>

添加文字

WebGL 有多种方法来处理文字的渲染,各有优缺点,aframe 采用 SDF 文本 实现方案,使用了three-bmfont-text,简单且性能好。通过添加 <a-text> 原语,实现文本的渲染。

<a-scene environment= preset: forest; >
  <!-- ... -->
  <a-text
    id= start-text 
    value= Start 
    color= #BBB 
    position= -3 6 -18 
    scale= 10 10 10 
    font= mozillavr 
  ></a-text>
</a-scene>

其他的一些文本渲染方案还有:

  • html-shader:把HTML渲染为一个纹理,好处是容易设置样式,缺点是性能糟糕。

添加光标

在 VR 世界中,我们可以通过 VR 设备的控制器进行交互,考虑到目前许多开发人员没有合适的带控制器的 VR 硬件,我们可以使用内置的 cursor 组件来进行交互。

光标原语 <a-cursor> 既可以用于基于注视的交互,也可以用于基于控制器的交互。默认的外观是一个环形几何图形,它通常作为相机的子对象放置。

如下我们通过 <a-camera> 添加一个自己的相机去代替 <a-scene> 设置的缺省相机,并将 <a-cursor> 作为相机的子对象挂载,这样无论我们的相机如何旋转和移动,我们始终能看到光标。

这里说明一下:aframe 中当一个实体作为另一个实体的子对象挂载后,子对象实体的 3d 坐标属性都是相对于其父对象实体的坐标位置,而不是整个 3d 世界的坐标位置。

<a-scene environment= preset: forest; >
  <a-camera>
    <a-cursor color= #FAFAFA ></a-cursor>
  </a-camera>
</a-scene>

使用 gltf 模型

gltf 是 Khronos 的一个开放项目,它为3D资产提供了一种通用的、可扩展的格式,这种格式既高效又与现代web技术高度可交互。gltf-model 组件使用glTF ( .gltf.glb)文件来加载模型数据,我们此应用中大量的 3d 模型都将使用 gltf 模型。

开放资源

下面是几个开放的 gltf 资源网站:

  • Sketchfab:提供所有可下载模型的自动转换,包括PBR模型以及 gltf 格式
  • Poly Haven:提供 CC0 HDRIs、PBR 纹理和 gltf 模型

添加一个武器

引入 gltf 模型来加载一个武器,在资源管理系统中,通过 <a-asset-item> 原语引入一个 gltf 资源,然后在相机下挂载一个实体子对象,将实体的 src 设置为 <a-asset-item> 原语的 id:

<a-scene environment= preset: forest; >
  <a-assets timeout= 30000 >
    <img
      id= wallImg 
      src= https://image-1300099782.cos.ap-beijing.myqcloud.com/wall.jpeg 
    />
    <a-asset-item
      id= weapon 
      src= https://vazxmixjsiawhamofees.supabase.co/storage/v1/object/public/models/blaster/model.gltf 
    ></a-asset-item>
  </a-assets>

  <a-camera>
    <a-cursor color= #FAFAFA ></a-cursor>
    <a-gltf-model
      id= _weapon 
      src= #weapon 
      position= 0.5 -0.5 -0.8 
      scale= 1 1 1 
      rotation= 0 180 0 
    ></a-gltf-model>
  </a-camera>
</a-scene>

在页面上就出现了我们的武器:

使用组件

前面我们提到过组件的注册方式,下面我们要实现当光标聚焦了 start 文字时,文字变大并且变色;光标失焦是还原的效果。

组件有很多生命周期,在本例中我们使用了 init() 方法,它方法在组件生命周期开始时被调用一次,通常被用于设置初始状态和变量 绑定方法 以及 附加事件侦听器

注册组件

下面的代码中我们注册了一个 start-focus 组件,它在 init() 生命周期时给对应的实体注册了 mouseentermouseleave 事件监听,当触发 mouseenter 事件时,start 字体会变大并且变成橙色;当触发 mouseleave 事件时,start 字体会复原:

AFRAME.registerComponent('start-focus', {
  init: function () {
    this.el.addEventListener('mouseenter', function () {
      if (window.startLeaveTimer) {
        clearTimeout(window.startLeaveTimer);
        window.startLeaveTimer = null;
      }
      window.CursorFocusEntity = 'start';
      this.setAttribute('scale', '12 12 12');
      this.setAttribute('color', 'orange');
    });

    this.el.addEventListener('mouseleave', function () {
      window.startLeaveTimer = setTimeout(() => {
        window.CursorFocusEntity = null;
        this.setAttribute('scale', '10 10 10');
        this.setAttribute('color', '#bbb');
      }, 500);
    });
  },
});

挂载组件

我们把刚刚注册的 start-focus 组件挂载到 start 文本原语上:

<a-scene environment= preset: forest; >
  <!-- ... -->
  <a-text
    id= start-text 
    value= Start 
    color= #BBB 
    position= -3 6 -18 
    scale= 10 10 10 
    font= mozillavr 
    start-focus
  ></a-text>
</a-scene>

现在就实现了我们想要的效果:

监听光标点击事件

按照刚刚的方法,故技重施,如法炮制,给光标添加一个点击监听事件:

<script>
AFRAME.registerComponent('cursor-listener', {
  init: function () {
    // 点击进行攻击
    this.el.addEventListener('click', function (evt) {
      console.log('光标点击了')
    });
  }
});
</script>

<a-camera>
  <a-cursor color= #FAFAFA  cursor-listener></a-cursor>
</a-camera>

javaScript 进行交互

因为 aframe 本质上就是HTML,所以我们可以像普通Web开发一样使用 JavaScript 和 DOM API来控制其中的场景和实体。

下面我们要实现一个点击光标时,武器向光标位置发射子弹的效果,使用 JavaScript 的 DOM API 来做一些实体与场景的交互。

获取光标点信息

当光标点击事件触发时,回调函数有一个默认参数 evt,里面包含了光标的相关信息,我们可以打印一下:

AFRAME.registerComponent('cursor-listener', {
  init: function () {
    // 点击进行攻击
    this.el.addEventListener('click', function (evt) {
      console.log(evt)
    });
  }
});

通过打印的结果得知,evt.detail.intersection.point 包含了当前光标所指向的三维坐标位置:

现在我们在光标点击事件的回调函数中,执行一个 createAttack 方法,其接收 evt.detail.intersection.point 作为参数:

AFRAME.registerComponent('cursor-listener', {
  schema: {},

  init: function () {
    // 点击进行攻击
    this.el.addEventListener('click', function (evt) {
      createAttack(evt.detail.intersection.point);
    });
  },
});

创建实体

我们要发射子弹,首先要创建子弹实体,通过 document.createElement api 来创建子弹实体,如下代码创建了一个球体:

function createAttack(point) {
  const attackEntity = document.createElement('a-sphere');
}

给实体设置组件

通过 javascript 给实体设置组件,与 js 给 dom 设置属性一样,也是通过 Node.setAttribute 方式。

如下代码中,给刚刚创建的球体设置了 radiuscolorpositionanimation 等组件:

function createAttack(point) {
  const { newX, newY, newZ } = getPosition(point);
  const attackEntity = document.createElement('a-sphere');
  attackEntity.setAttribute('radius', '0.2');
  attackEntity.setAttribute('color', 'red');
  attackEntity.setAttribute('position', `${newX} ${newY} ${newZ}`);
  attackEntity.setAttribute(
    'animation',
    `property: position; dur: 300; to: ${point.x} ${point.y} ${point.z};`
  );
}

其中 position 的位置,是球体生成的位置,我们需要让子弹从武器的枪口位置处发出,最终通过动画,发射到光标所在的位置。

计算子弹的初始位置

上面的代码中,getPosition 是计算子弹初始位置的函数,此部分设计复杂且枯燥的数学计算,不感兴趣的可以跳过。

前面我们提到过,aframe 中实体的 position 是相对于父对象实体的,我们的子弹最终是要挂载到 <a-scene> 下面,由于当相机转动时,武器在三维世界坐标系中的位置会发生变化,所以我们需要计算出武器枪口所在的初始位置,即子弹的初始位置。

我们先看三维坐标系中的其中一个平面,以 x 轴和 z 轴所在的平面为例:

image.png

首先无论相机如何转动,枪口位置和相机指向的位置与相机所在点的连线在 xz 平面所成的夹角是始终不变的。

光标起始点和点击时光标所在点的坐标我们都是已知的,所以可以求出点击时和初始状态在 xz 平面所旋转的弧度 θ,然后根据下面的数学知识,我们能计算出点击时枪口所在的位置。

坐标系中求两条直线之间的夹角:

image.png

直线 l1 的斜率 k1 : k1 = (y1 - y) ``/ (x1 - x)

直线 l2 的斜率 k2: k2 = (y2 - y) ``/ (x2 - x)

夹角 θ 的正切值: tanθ = (k2 - k1) ``/ (1 + k1 * k2)

夹角 θ:θ = Math.atan(tanθ)

坐标系求一个点以另一个点为圆心旋转 θ 后的坐标:

x2 = (x1 - x) * cosθ - (y1 - y) * sinθ + x

y2 = (y1 - y) * cosθ + (x1 - x) * sinθ + y

image.png

通过上面的数学公式,代入 getPosition 函数中,就可以求出我们子弹的初始位置。

获取实体

我们最终要把创建出的子弹挂载到场景中,所以需要先获取到场景,通过 document.querySelector api 去获取:

const scene = document.querySelector('a-scene');

挂载实体

同样通过 appendChild dom api,我们可以将刚刚创建的子弹实体给挂载到场景中:

function createAttack(point) {
  const { newX, newY, newZ } = getPosition(point);
  const attackEntity = document.createElement('a-sphere');
  attackEntity.setAttribute('radius', '0.2');
  attackEntity.setAttribute('color', 'red');
  attackEntity.setAttribute('position', `${newX} ${newY} ${newZ}`);
  attackEntity.setAttribute(
    'animation',
    `property: position; dur: 300; to: ${point.x} ${point.y} ${point.z};`
  );
  scene.appendChild(attackEntity);
}

销毁实体

当子弹发射到光标所在位置之后,我们不能让其一直停留在场景中,需要将其销毁,即通过 removeChild 将其从场景中移除:

function createAttack(point) {
  const { newX, newY, newZ } = getPosition(point);
  const attackEntity = document.createElement('a-sphere');
  attackEntity.setAttribute('radius', '0.2');
  attackEntity.setAttribute('color', 'red');
  attackEntity.setAttribute('position', `${newX} ${newY} ${newZ}`);
  attackEntity.setAttribute(
    'animation',
    `property: position; dur: 300; to: ${point.x} ${point.y} ${point.z};`
  );
  scene.appendChild(attackEntity);
  const timer = setTimeout(() => {
    scene.removeChild(attackEntity);
    clearTimeout(timer);
  }, 300);
}

至此我们的子弹发射效果就完成了:

添加音频资源

音频对于在虚拟现实中提供沉浸感是很重要的,方法是添加一个<audio>元素到我们的HTML(最好是<a-assets>)中来播放一个音频文件,并在相机下面挂载一个实体,通过 sound 组件来挂载对应的音频:

<a-scene environment= preset: forest; >
      <a-assets timeout= 30000 >
        <audio
          id= shooting-sound 
          src= https://audio-1300099782.cos.ap-beijing.myqcloud.com/shooting.mp3 
          preload= auto 
        ></audio>
      </a-assets>
      
      <a-camera>
        <a-cursor color= #FAFAFA  cursor-listener></a-cursor>
        <a-gltf-model
          id= _weapon 
          src= #weapon 
          position= 0.5 -0.5 -0.8 
          scale= 1 1 1 
          rotation= 0 180 0 
        ></a-gltf-model>
        <a-entity
          sound= src: #shooting-sound 
          id= shooting_sound_player 
          position= 0.5 -0.5 -0.8 
          poolSize= 10 
        ></a-entity>
      </a-camera>
    </a-scene>

通过 entity.components.sound.playSound() 方法,我们可以播放实体上挂载的音频,所以我们在 createAttack 方法执行时通过如下代码播放射击的音频:

const shootingSoundPlayer = document.querySelector('#shooting_sound_player');

function createAttack(point) {
  shootingSoundPlayer.components.sound.playSound();
  const { newX, newY, newZ } = getPosition(point);
  const attackEntity = document.createElement('a-sphere');
  attackEntity.setAttribute('radius', '0.2');
  attackEntity.setAttribute('color', 'red');
  attackEntity.setAttribute('position', `${newX} ${newY} ${newZ}`);
  attackEntity.setAttribute(
    'animation',
    `property: position; dur: 300; to: ${point.x} ${point.y} ${point.z};`
  );
  scene.appendChild(attackEntity);
}

其余工作

到这里,我们本游戏 demo 所涉及的所有 aframe 的知识点都已经讲完了,你可以根据上面的知识点,结合 javascript,完成剩余的部分工作:

  • 定时生成怪物
  • 怪物间隔一定时间对我们发射攻击,造成伤害
  • 攻击怪物,造成伤害,消灭怪物时获得分数
  • 更新分数和我们剩余的 HP
  • 游戏开始、结束和重新开始

总结

通过本文,你应该收获了有关 WebXR 的概念、标准以及如何通过 aframe 框架开发 WebXR 应用等知识,WebXR 无论在 Web 开发还是 VR 开发中都是目前参与人数较少的一片蓝海,其前景十分的广阔,甚至我们的教育业务如果结合 WebXR 技术,也是一个新的思路。

希望通过本文能够引起大家对 WebXR 的兴趣,总结了 WebXR 的一些学习资料和开发资源,便于感兴趣的同学开发上手: