使用A-Frame从零开始做一个 Web 3D 互动游戏

不用学习新的语言,只有前端现有的知识就可以做AR了。而且是在我们熟悉的Web环境中,不用APP就可以做到。

1. 什么是A-Frame

这个框架的命名跟移动4G的“和”有得一拼,结果都是完美错过所有关键词。
A 会被浏览器忽略 – 是连词符,frame又有太多歧义。如果打出来 aframe 还会被浏览器“智能”拆分成 a frame 变成“一个框架”。
这就导致查资料不容易,沙里淘金的感觉,不过也可能是本身资料就少的缘故。

A-Frame 是一个可以在HTML中创建3d场景的框架,使用Three.js和WebGL来创建VR场景。
不需要了解低层渲染细节,但是A-Frame的说明文档也很长啊。

2. 最简单的demo

在codepen.io打开代码在codepen.io预览

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>

<a-scene>
  <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
  <a-box position="-1 0.5 -3" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>
  <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
  <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
  
  <a-sky color="#ECECEC"></a-sky>
</a-scene>

代码中的 <a-sky> 就是纯色背景,其他几个看名字就知道是哪个了。
这个demo还可以用鼠标和键盘导航,如果用手机浏览器打开,就是VR效果了。

3. 从头做一个自己的场景

3.1 模板

A-Frame 的所有元素都放在 <a-scene> 中,初始代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>Our First A-Frame Experience</title>
    <script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
      
    </a-scene>
  </body>
</html>

3.2 天空

天空使用的元素是 <a-sky>,代码如下:

<a-sky color="#C500FF"></a-sky>

此时会产生一个紫红色的天空。天空也可以是一个全景图.
flickr 有很多全景图,我们选一个作为背景,比如这一张

现在把天空换成这张全景图。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>Our First A-Frame Experience</title>
    <script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
      <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
    </a-scene>
  </body>
</html>

现在代码(在codepen.io打开代码在codepen.io预览

现在用手机看效果是不是有身临其境的感觉了。

3.3 放一个球进去

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere position="0 1.25 -5" radius="1.25" color="#66ffcc"></a-sphere>
</a-scene>

现在场景中多了一个蓝色的球,直接看效果

球也可以不是纯色的,这就需要给球表面贴图,我们先从 subtlepatterns 选一个材质,
这一张木纹吧:

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere position="0 1.25 -5" radius="1.25" src="https://www.toptal.com/designers/subtlepatterns/patterns/retina_wood.png"></a-sphere>
</a-scene>

效果是这样的

3.4 光标交互

VR里也有对应的交互方案,我们现在增加动画和事件库。场景里增加一个camera和放在其中的curosr。

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere position="0 1.25 -5" radius="1.25" src="https://www.toptal.com/designers/subtlepatterns/patterns/retina_wood.png"></a-sphere>

  <!-- Camera + cursor. -->
  <a-entity camera look-controls>
    <a-cursor id="cursor" 
        animation__click="property: scale; startEvents: click; from: 0.1 0.1 0.1; to: 1 1 1; dur: 150"
        animation__fusing="property: fusing; startEvents: fusing; from: 1 1 1; to: 0.1 0.1 0.1; dur: 1500"
        event-set__1="_event: mouseenter; color: #0092d8" 
        event-set__2="_event: mouseleave; color: black"></a-cursor>
  </a-entity>
</a-scene>

现在代码(在codepen.io查看代码在codepen.io预览)。

现在随着视口(摄像机)移动,在屏幕中央的光标(定位相对于摄像机固定)会跟着摄像机移动。

光标与鼠标一样遇到圆球,触发 mouseenter 事件,离开圆球触发 mouseleave 事件。现在我们已经增加了这两个事件,进入的时候光标变蓝色,离开变回默认的黑色。点击时伴随有光标缩放效果。

3.5 与场景中的球互动

就像鼠标点击按钮一样,这个光标也可以触发场景中的元素事件,不过这时就没有现成的代码库了,要自己写事件绑定。

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script>
    AFRAME.registerComponent('hide-on-click', {
        dependencies: ['raycaster'],
        schema: {
            target:{type: 'selector'}
        },
        init: function () {
            var data = this.data;
            var el = this.el;
            el.addEventListener('click', function () {
                el.setAttribute('visible', false);
                data.target.setAttribute('visible', true);
            });
        }
    });
</script>

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere hide-on-click="target:#another_cube" position="0 1.25 -5" radius="1.25" src="https://www.toptal.com/designers/subtlepatterns/patterns/retina_wood.png"></a-sphere>
  <a-box id="another_cube" visible="false" position="-1 1.5 -4" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>

  <!-- Camera + cursor. -->
  <a-entity camera look-controls>
    <a-cursor id="cursor" 
        animation__click="property: scale; startEvents: click; from: 0.1 0.1 0.1; to: 1 1 1; dur: 150"
        animation__fusing="property: fusing; startEvents: fusing; from: 1 1 1; to: 0.1 0.1 0.1; dur: 1500"
        event-set__1="_event: mouseenter; color: #0092d8" 
        event-set__2="_event: mouseleave; color: black"></a-cursor>
  </a-entity>
</a-scene>

现在代码(在codepen.io打开代码在codepen.io预览)。

现在如果用PC浏览器看效果,鼠标左右拖动能移动摄像机(同时移动光标),鼠标单击任意位置,光标触发点击事件。点击圆球以后,圆球消失,正方体的盒子出现了。

4 做一个互动游戏

我的游戏过程是,进入页面在随机位置生成一个鸡蛋,用户用光标点击鸡蛋,鸡蛋隐藏,然后播放鸡蛋破壳动画,最后触发弹窗,提示用户你获得一只鸡。

4.1 基础代码

跟上面的差不多,只是去掉了绑定事件和圆球。因为球形太圆了,不像鸡蛋。只能用一张静态的鸡蛋图片代替。为什么不用椭圆形的3d元素呢,因为除了方形、圆柱、圆形、平面以外的物体,多边形太多了,占用内存太大。

要展示平面图形就需要另外一个库 aframe-html-shader。

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-layout-component@3.0.1"></script>
<script src="https://rawgit.com/mayognaise/aframe-html-shader/master/dist/aframe-html-shader.min.js"></script>

<script>
    AFRAME.registerComponent('hide-on-click', {
        dependencies: ['raycaster'],
        schema: {
            target:{type: 'selector'}
        },
        init: function () {
            var data = this.data;
            var el = this.el;
            el.addEventListener('click', function () {
                el.setAttribute('visible', false);
                data.target.setAttribute('visible', true);
                
            });
        }
    });
</script>

<div id="textToDisplay"><img src="./frame_1.png" alt=""></div>
<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  
    <a-entity hide-on-click="target:#chickenAnimate">
        <a-entity id="default_egg"
                  geometry="primitive:plane;width:2;height:2;"
                  position="-3 2 -10"
                  material="shader:html;target:#textToDisplay;transparent:true;fps:0;">
        </a-entity>
    </a-entity>
    <a-box id="chickenAnimate" visible="false" position="-1 1.5 -4" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>


  <!-- Camera + cursor. -->
  <a-entity camera look-controls>
    <a-cursor id="cursor" 
        animation__click="property: scale; startEvents: click; from: 0.1 0.1 0.1; to: 1 1 1; dur: 150"
        animation__fusing="property: fusing; startEvents: fusing; from: 1 1 1; to: 0.1 0.1 0.1; dur: 1500"
        event-set__1="_event: mouseenter; color: #0092d8" 
        event-set__2="_event: mouseleave; color: black"></a-cursor>
  </a-entity>
</a-scene>

上面的代码改动增加了一个div#textToDisplay 里面只有一张鸡蛋的图片,这张图会用来渲染到 a-entity#default_egg。语法是设置 matrial 的值。具体可参考
github aframe-html-shader 项目主页

这里有个问题,aframe-html-shader 原理是用canvas渲染DOM中的元素,如果有图片会需要由canvas处理,这就涉及到跨域问题。

跨域问题可以通过html2canvas-php-proxy解决,
但是在服务器部署这个代理有安全风险,所以我们的demo放到同一个域测试。

演示地址在: demo7

此时用光标点击鸡蛋,鸡蛋会消失,正方体出现。

4.2 播放动画

鸡蛋的动画也需要用canvas渲染出来,这里再引入一个库:

aframe-gif-shader github 地址

<script src="https://aframe.io/releases/0.5.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-layout-component@3.0.1"></script>
<script src="https://rawgit.com/mayognaise/aframe-html-shader/master/dist/aframe-html-shader.min.js"></script>
<script src="./aframe-gif-shader.js"></script>

然后把替代的正方体盒子换成孵蛋动画:

<a-entity id="chickenAnimate" visible="false">
    <a-entity id="animate_egg" geometry="primitive:plane;width:2;height:2;"
              position="-3 2 -10"
              material="shader:gif;src:url(./egg_2017_1.gif);transparent:true;"
              gif=""></a-entity>
</a-entity>

此处的gif渲染原理是用js加载并解析每一帧动画,并用canvas渲染出来。

这里有两个小坑:

  • aframe-gif-shader 组件中渲染每一帧之前,忘记清空画布,导致影响重叠。(难怪demo使用的都是不透明gif图片)我已经修复了这个问题,所以引用的是本地文件。
  • aframe-gif-shader 组件中使用了第三方的优化算法,但是这个优化算法存在bug,连续的两帧图片上部透明部分太多会被认为是多余帧优化掉。此处可以修改gif素材绕过。

脚本也增加一点内容,播放孵蛋的动画后,等3秒弹出alert,提醒用户游戏结果,然后重置游戏。

AFRAME.registerComponent('hide-on-click', {
    dependencies: ['raycaster'],
    schema: {
        target: {type: 'selector'}
    },
    init: function () {
        var data = this.data;
        var el = this.el;
        el.addEventListener('click', function () {
            el.setAttribute('visible', false);
            data.target.setAttribute('visible', true);
            setTimeout(function(){
                alert('恭喜您获得一只鸡!');
                window.location.reload();
            },5000);
        });
    }
});

完整的效果和源码请查看: demo8

4.3 增加趣味性,随机位置

现在游戏可以玩了,但是每次鸡蛋都处在统一位置,根本不用找。为了增加趣味性,我们随机改变鸡蛋的位置。
但是根据我的测试,完全随机的游戏一点都不好玩,太远了会变成几个像素大小,根本找不到,太近了有种糊在脸上的感觉。还有上下左右位置都不能太离谱。
多次测试后决定把位置放在用户站的位置周围固定距离的圆圈上,高度比身高多一点点的时候,比较合理。

我翻出来初中数学课本,找到了下面的公式:

var R = Math.random() * 360; // 弧度
var r = 8; // 半径
var X = Math.sin(R) * r; // x 坐标
var Z = Math.cos(R) * r; // 俯视图 z 坐标

$('#default_egg,#animate_egg').attr('position',{ x: X, y: 2, z: Z });

这下位置随机了,可是鸡蛋是平面图形,朝向必须面对摄像头才行,不然会有一种挨着窗户坐在教室第一排看黑板的感觉。

这时就需要另外一个库,从官方插件库中分出来的 kframe 插件库,其中有个 look-at 插件,看名字就知道作用就是让元素面对镜头。加上后的js引用有这么多:

<script src="https://aframe.io/releases/0.5.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-layout-component@3.0.1"></script>
<script src="https://rawgit.com/mayognaise/aframe-html-shader/master/dist/aframe-html-shader.min.js"></script>
<script src="./aframe-gif-shader.js"></script>
<script src="./aframe-look-at-component.min.js"></script>

这里使用本地版本是没找到 cdn 地址的。

元素上也加上属性绑定:

<a-entity hide-on-click="target:#chickenAnimate">
    <a-entity id="default_egg"
              geometry="primitive:plane;width:2;height:2;"
              look-at="[camera]"
              position="-3 2 -10"
              material="shader:html;target:#textToDisplay;transparent:true;fps:0;">
    </a-entity>
</a-entity>
<a-entity id="chickenAnimate" visible="false">
    <a-entity id="animate_egg" geometry="primitive:plane;width:2;height:2;"
              look-at="[camera]"
              position="-3 2 -10"
              material="shader:gif;src:url(./egg_2017_1.gif);transparent:true;"
              gif=""></a-entity>
</a-entity>

此时已经可以完整的游戏了,源代码和演示都在: demo9

4.4 再增加点趣味性,AR?

现在浏览器的运算能力还做不到通过摄像头识别复杂的内容,最多只能识别有限的几个二维码。但是我们也可以退而求其次,先把场景放到用户周围再说。

我们调用摄像头,作为网页的背景就能模拟显示场景了。

iOS 系统没有开放摄像头的权限,只能通过android手机测试了。

我们再引用一个 threex.webar库中的一个threex.webcamgrabbing组件。

etUserMedia() no longer works on insecure origins.
To use this feature, you should consider switching your application to a secure origin,
such as HTTPS. See https://goo.gl/rStTGz for more details.

悲剧啊,过完年 MediaStreamTrack.getSources 接口被Chrome抛弃了,微信X5也停止支持了,但是新的接口navigator.mediaDevices.getUserMedia旧手机不支持。

所以我们使用 webrtc adapter 兼容新版本的获取资源方法。自己实现获取摄像头内容。

navigator.mediaDevices.getUserMedia 接口只能在 HTTPS 地址中被调用。

就是游戏的最终版本: demo10

微信里要点最下面的,访问原网页。

最终版做了判断,支持获取摄像头的设备就使用当前环境做背景,不支持的就在小树林里找蛋。

到这里这个游戏就做出来了,如果想要做成产品给用户玩,还需要做很多美化工作。至少要有加载游戏前的Loading页面,游戏界面的装饰效果,提示浮层美化,游戏后台接口记录成绩,给用户发奖品等工作。


参考资料

使用Let’s Encrypt免费SSL证书 ubuntu 教程

1. 生成证书

1.1 安装 certbot

#安装到 /usr/local 目录中
cd /usr/local
wget https://dl.eff.org/certbot-auto
chmod a+x certbot-auto

1.2 生成证书

# 注意此处 mysite.com 和 www.mysite.com 在 /www/mysite.com 目录
# wx.mysite.com 在 /www/wx.mysite.com 中
# 可以根据情况增删目录

./certbot-auto certonly --webroot -w /www/mysite.com -d mysite.com -d www.mysite.com -w /www/wx.mysite.com -d wx.mysite.com

安装前保证域名能正常访问。如果生成不成功,去google错误信息。

1.3 证书更新

现在证书已经有了。但是Let’s Encrypt免费SSL证书每90天要自动续期。

更新的命令是

/usr/local/certbot-auto renew --quiet --no-self-upgrade

可以用 crontab 定时更新

# 编辑 crontab
crontab -e

在定时任务中增加

# 每隔两个月1号的零点就会自动更新证书,5分钟后nginx重新加载配置。

00 00 1 2,4,6,8,10,12 * /usr/local/certbot-auto renew --quiet --no-self-upgrade
05 00 1 2,4,6,8,10,12 * /usr/sbin/service nginx -s reload

2. 配置nginx

certbot 自动生成的证书保存在/etc/letsencrypt/live/mysite.com/目录。

配置nginx的时候,修改原来的配置文件

server {
    listen  80;
    server_name  mysite.com www.mysite.com;
    location /.well-known/acme-challenge {
        location your/site;
    }
    location / {
        return 301 https:$host$request_uri;
    }

}

certbot-auto renew 的时候会访问 /.well-known/acme-challenge 目录验证网站所有权,但是这个请求不支持https!因此保留http访问方式。
并新增一段server配置

server{
    listen 443 ssl;

    ssl_certificate /etc/letsencrypt/live/mysite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mysite.com/privkey.pem;

    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;    

    # intermediate configuration. tweak to your needs.
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
    ssl_prefer_server_ciphers on;

    # 以下与原来的配置相同
    # ...
}

修改后重启nginx。

3. 其他设置

如果启用了防火墙,确保开启443端口。

我在这里遇到了端口被占用的情况,发现是ssserver 占用了443端口(原来的默认端口容易被封)。修改了ssserver端口,重启nginx,网站已经自动跳转到https了。

备注

如果是别的系统,也不麻烦,参看这里:https://letsencrypt.org/getting-started/,查找对应系统的生成方法。

删除的方法

如果一个域名已经不需要SSL证书了,删除略麻烦。
先备份

sudo cp /etc/letsencrypt/ /etc/letsencrypt.backup -r

再删除这三处相应的文件

rm -rf /etc/letsencrypt/live/${DOMAIN}
rm -rf /etc/letsencrypt/renewal/${DOMAIN}.conf
rm -rf /etc/letsencrypt/archive/${DOMAIN}

前端性能优化关键词:Critical rendering path

什么是关键渲染路径

Critical:必需的,关键的
Render:渲染
Path:浏览器现实初始视图(initial view )所必须的一系列过程。初始视图也称为首屏(above the fold),用户不需要滚动就能看到的内容。
所以关键渲染路径的定义就是:浏览器显示网页(首屏)所必须的一系列事件。
回顾浏览器渲染过程
1. 加载HTML
2. 加载资源
3. 解析
4. 渲染
首屏内容的优化对用户体验至关重要,首屏所需的步骤精简,时间加快,其余内容延迟加载或者滚动加载。

常见的优化方法

去掉或者减少阻塞资源
控制首屏所需字节先加载
推迟其余的资源

参考链接:

above the fold 定义
延迟加载图片
都有谁在阻塞渲染
阻塞渲染的CSS
阻塞渲染的JavaScript
网络字体阻塞渲染
优化首屏显示

前端性能优化关键词:Render blocking resources

什么是Render blocking resources?

根据前几篇提到的浏览器渲染过程,有了DOM 和 CSSOM 之后,浏览器才开始渲染。所以挡在渲染前面的资源都叫做阻塞渲染的资源。
这些资源包括:
  • HTML,当然了,没有dom,就没什么可渲染的;
  • CSS,根据浏览器渲染过程看到,没有CSSOM,也不会开始渲染;
  • JavaScript,因为JS有可能改变页面内容和样式,所以浏览器会等待JS加载完成并解析运行后才继续向下解析文档。
从以上可以知道:
  • DOM节点越多,浏览器耗时(下载、解析)越多(优化建议:快看天猫首页源代码——2016年11月16日,首屏以外的DOM节点动态生成);
  • CSS内容越多,文件越多,浏览器下载、解析时间越长(优化建议:看淘宝首页源代码——2016年11月16日,inline关键CSS,其他CSS异步加载);
  • JavaScript的话,只要不是渲染页面必须的,都放到文档底部,就不会阻塞渲染。
另外:图片不会Block render。
参考文档:

前端性能优化关键词:Resource timing

什么是Resource timing?

客户端的延迟一直是网站性能重要的衡量标准。虽然用JS可以大概的测量每个请求的时间,但是不能确定中间每个步骤的数据。为了获取用户体验数据的完整信息,W3C草案增加了ResourceTiming API,可以用JavaScript接口看到每个请求的完整时间信息。接口可以精确测量用户感知到的资源加载时间。具体定义如下图:
resource-timing-overview-1
查看方法,用chrome浏览器控制台命令:
window.performance
其中当前页面加载的详情在:
window.performance.timing
也可以看页面所有资源的加载时间节点:
var resourceList = window.performance.getEntriesByType(“resource”);
看到的结果大概是这样的
Chrome 的 Dev tools 的 network panel 看到的就是这些数据图形化的展示:
通过这些诊断,可以得到很多关于优化的信息,比如常见的关键资源等待加载,First Byte 太迟,同时加载资源太多等等,关于这些问题的原因以及会怎样影响网页性能,以后会作文解说。

参考链接