| 10 min read

最近两年的大新闻里面,其中一条想必大家都听说过:

Facebook 改名 Meta, 全力冲击全宇宙。

之前自己在知乎里面回答过 互联网的下一波红利在哪里? ,提及了 VR/AR 方向。尽管,到目前为止,我们依旧还是只能把它定义为比较有吸引力的消费设备,和当初移动互联网革命相比,还有很长一段路要走。

随着 Quest2 的出货量大增,很多开发者都开始加入到 VR 开发中来,然而对于我们 Web 前端而言,似乎还没能有一个比较好的切入场景。 Meta Quest 的开发者平台有四个部分

  • Unreal Engine (UE)
  • Unity
  • Native
  • Web Platform

前面三个部分,开发者可以自行前往关注,毕竟小编也不是很懂这块内容。这里我们重点关注 Web Platform 里面有什么内容。

在 Web Platform 里面有涉及到四块内容,分别是

其中 Web Task 可以理解为我们经常在移动互联网体验到的统一弹窗网页登录这类似的东西,这和传统的互联网开发没什么太大的差别。

而 Oculus Browser 我理解更多的是强调浏览器可以正常的支持我们传统的网页内容浏览和交互,因为在 VR 中,我们面临任务窗口,可以 3D 的,也可以 2D 的,所以我们写的页面依旧可以正常的进行浏览和阅读。

而今天我们重点是说下 PWA。

Developers can build and distribute 2D apps that take advantage of Quest’s multitasking feature using Progressive Web Apps (PWAs), an industry standard for installable web applications.

我们知道 PWA 的推出,让我们 Web 可以更加像一个独立的 App ,他可以独立安装和卸载,也支持在无网络环境下的完整体验。而在 Meta Quest 的设计目标里, PWA 可以很好的补充它们在多任务系统设计里一个应用完善。如今我们知道小程序非常火,首先它足够的轻量,开发便捷,安装和卸载也非常方便,我们完成一个任务的时候不用在去商店单独下载。同样个人预测 Meta 有目标将 PWA 引入到自己的生态(当然 Android 天生有很好的支持),也是扩大自己的开发者群里,从而促进整体开发内容的完善。

开始你的开发

前面铺垫了很长,不妨直接来看下项目是如何进行开发的。

在开发前,请先确认自己手里有一台 Meta Quest 设备。网上也有很多关于 WebXR 的文章,无论如何,你还是需要一台头显去沉浸式的体验,这样有助于你对于当前行业的理解以及开发内容的深度测试。

首先我们这里确定下基本的技术选型

  • webpack + TS 这个比较老套,这个看个人喜好配置,也可以参考这边的 repo
  • AFrame 选了 Aframe 作为基本的框架,来进行应用内容的开发,如果你熟悉这一块,你也可以选择 Three.js 或者 BabylonJS
  • workbox 协助我们做一些 pwa 相关的事情

配置 Web App Manifest

我们在开发 PWA 的时候需要创建一个 Manifest 文件,来作为当前应用信息的基本介绍;

我们在初始化的空文件项目里,建立一个 pwa 目录;然后我们新建一个 manifest.webmanifest 内容如下;

{
 "name": "Full name of your PWA",
 "display": "standalone",
 "short_name": "PWA Name",
 "start_url": "https://domain.com/startpage/",
 "scope": "https://domain.com/"
 }

其中 display 支持 standalone or minimal-ui 两个值。

由于我们此次的目标是开发单独的 PWA 应用,独立于传统的网页形式,我们并不需要发布到线上的网站,而是单独在应用目录存放该文件即可。

至于 manifest 其他的属性,可以自行去 google ,这里并不影响我们开发。

开始构建

我们可以 npm init 初始化,也可以自行复制粘贴 repo 里对应的 package.json

当前我的项目依赖版本内容如下:

"dependencies": {
    "aframe": "^1.3.0"
  },
  "devDependencies": {
    "@yandeu/prettier-config": "^0.0.3",
    "copy-webpack-plugin": "^10.1.0",
    "html-webpack-plugin": "^5.5.0",
    "javascript-obfuscator": "^4.0.0",
    "prettier": "^2.5.1",
    "rimraf": "^3.0.2",
    "serve": "^13.0.2",
    "ts-loader": "^9.2.6",
    "typescript": "^4.5.3",
    "webpack": "^5.65.0",
    "webpack-cli": "^4.9.1",
    "webpack-dev-server": "^4.6.0",
    "webpack-merge": "^5.8.0",
    "webpack-obfuscator": "^3.5.0",
    "workbox-webpack-plugin": "^6.4.2"
  }

安装完依赖后,我们可以新建一个 webpack 目录,更改一些构建配置;主要有三个文件;

  • webpack.common.js
const path = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: ['./src/index.ts'],
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js',
    chunkFilename: '[name].chunk.js'
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  module: {
    rules: [{ test: /\.tsx?$|\.jsx?$/, include: path.join(__dirname, '../src'), loader: 'ts-loader' }]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          filename: '[name].bundle.js'
        }
      }
    }
  },
  plugins: [
    new HtmlWebpackPlugin({ gameName: 'AFrame PWA', template: 'src/index.html' }),
    new CopyWebpackPlugin({
      patterns: [
        { from: 'src/assets', to: 'assets' },
        { from: 'pwa', to: '' },
        { from: 'src/favicon.ico', to: '' }
      ]
    })
  ]
}

  • webpack.dev.js
const { merge } = require('webpack-merge')
const common = require('./webpack.common')

const dev = {
  mode: 'development',
  stats: 'errors-warnings',
  devtool: 'eval',
  devServer: {
    open: true
  }
}

module.exports = merge(common, dev)

  • webpack.prod.js
const path = require('path')
const { merge } = require('webpack-merge')
const common = require('./webpack.common')
const { InjectManifest } = require('workbox-webpack-plugin')
// const WebpackObfuscator = require('webpack-obfuscator')

const prod = {
  mode: 'production',
  stats: 'errors-warnings',
  output: {
    filename: '[name].[contenthash].bundle.js',
    chunkFilename: '[name].[contenthash].chunk.js'
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          filename: '[name].[contenthash].bundle.js'
        }
      }
    }
  },
  plugins: [
    // disabled by default (uncomment to active)
    // new WebpackObfuscator(
    //   {
    //     rotateStringArray: true,
    //     stringArray: true,
    //     stringArrayThreshold: 0.75
    //   },
    //   ['vendors.*.js', 'sw.js']
    // ),
    new InjectManifest({
      swSrc: path.resolve(__dirname, '../pwa/sw.js'),
      swDest: 'sw.js'
    })
  ]
}

module.exports = merge(common, prod)

webpack 配置这块我也是借鉴网上的 start kit ,这里灵活度比较高,大家可以自己结合自己喜欢的技术栈进行配置。

新建 .prettierrc

"@yandeu/prettier-config"

新建 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ES2015",

    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "allowJs": true,
    "allowUnreachableCode": false,
    "noImplicitAny": false,
    "sourceMap": true,
    "strictPropertyInitialization": false,
    "moduleResolution": "node",
    "lib": ["dom", "esnext"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

接下里我们添加一些 npm scripts

 "start": "webpack serve --config webpack/webpack.dev.js",
    "build": "rimraf dist && webpack --config webpack/webpack.prod.js",
    "bundle": "npm run build",
    "serve": "serve dist",
    "format": "prettier --check src/scripts/**",
    "format:write": "prettier --write src/scripts/**"
  },

使用 A-frame 开发一个简单的全景视频播放器

首先我们新建一个 src 目录;

然后添加一个 favicon.ico 到当前新建的目录里面。这个不是很重要,你可以自己随便找些图片即可。

然后新建 index.html 内容

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />

    <meta name="theme-color" content="#000000" />

    <link rel="manifest" href="./manifest.json" />

    <meta name="mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="application-name" content="<%= htmlWebpackPlugin.options.gameName %>" />
    <meta name="apple-mobile-web-app-title" content="<%= htmlWebpackPlugin.options.gameName %>" />
    <meta name="msapplication-starturl" content="./" />

    <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-icon-180x180.png">
    <link rel="icon" type="image/png" sizes="192x192"  href="/icons/android-icon-192x192.png">

    <link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />

    <title><%= htmlWebpackPlugin.options.gameName %></title>
    <script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
    <style>
      html,
      body {
        height: 100%;
        margin: 0;
        background-color: #000000;
      }
    </style>

    <noscript>Please enable javascript to continue using this application.</noscript>

    <!-- installs the serviceWorker -->
    <!-- <script>
      if ('serviceWorker' in navigator) {
        window.addEventListener('load', () => {
          navigator.serviceWorker.register('./sw.js')
        })
      }
    </script> -->
  </head>
  <body>

 

  </body>
</html>

基于 AFrame 开发一些简单的内容。AFrame 不做过多的介绍,它是基于 Web Components 来提供了大量方便我们实现应用需求的组件,可以像写网页的流程一样开发具体的功能。

我们在 Body 标签里添加下面的内容

<body>
  <a-scene>
      <a-assets>
        <video id="video" loop="true" src="./assets/vrtest.mp4"> </video>
        <img id="my-image" src="./assets/cover.jpg">
      </a-assets>
      <a-plane cursor-listener id="button" position="0 0 -10" color="#555" height="1" width="2"></a-plane>
      <a-text position="0 0 -10" align="center" value="Play Video"></a-text>
      <a-image width="16" height="9" position="0 0 -20" src="#my-image"></a-image>
      <a-entity id="video-container" visible="false">
        <a-videosphere radius="50" src="#video" position="0 0 0"></a-videosphere>
      </a-entity>

      <a-sky color="#ddd"></a-sky>
    </a-scene>
</body>

AFrame 采用的 ECS 设计。它和我们传统的页面开发还是有些区别的。这里推荐知乎上的一篇介绍 https://zhuanlan.zhihu.com/p/30538626 。这篇文章并不是重点介绍如何进行 XR 应用的内容设计,更多的是还是结合传统网页开发的经验,去普及如何逐步进入Meta 的 生态。

我们简单的说下上述代码基本功能。

a-scene 代表一个场景,也就是我们应用当前展示的内容都需要放到该场景中,当然场景可以有很多。

a-assets 辅助我们进行资源处理的,在 VR 应用开发中,我们并不希望使用网页那种渲染方式,

a-planea-text 目标是实现一个按钮,让用户可以进行点击

a-entity 类似 div ,可以作为一个容器节点存在;

a-videospherea-image 顾名思义,一个适用于展示全景视频内容,一个是用于展示 2D 图片内容;

接下俩我们需要新建一个 index.ts 处理一些基本的交互

const videoEl = document.querySelector('#video') as HTMLVideoElement;

const btn = document.querySelector('#button');

btn?.addEventListener('click', () => {
  // videoEl?.play();
});
const AFRAME = (window as any).AFRAME;
AFRAME.registerComponent('cursor-listener', {
  init: function () {
    this.el.addEventListener('click', function (evt) {
      const videoContainer = document.querySelector('video-container') as any;
      videoContainer.visible = true;
      videoEl?.play();
      console.log('I was clicked at: ', evt.detail.intersection.point);
    });
  }
});

逻辑也比较简单,就是播放按钮点击后,播放全景适配的内容。

assets 目录的素材可以参考 github 上面的内容,这里不多说了。

调试与部署

我们在安装完成依赖后,可以使用

npm start

你可以在 Chrome 看下基本效果:

在 Chrome 目前没有办法响应手柄事件,之前有推荐 webxr ,但是 AFrame 效果不是很明显。这里我们可以尝试在 Oculus Browser 里面打开。

打开头显,

You Can Speak "Hi" to Me in Those Ways