当微信小程序遇到AR(四)
当微信小程序遇到AR,会擦出怎么样的火花?期待与激动......
通过该教程,可以从基础开始打造一个微信小程序的AR框架,所有代码开源,提供大家学习。
本课程需要一定的基础:微信开发者工具,JavaScript,Html,Css
第四章:基石-摄像头与Three.js结合
【前情提要】
上一章,前面的两章内容,我们学习了基本的摄像头数据读取以及Three.js三维场景的创建。这两章内容学习之后,我们已经可以做很多更定制化的开发了。例如:
1. 我们已经可以做基于摄像头图像的AR内容开发,(比如人脸识别,AR美妆涂口红,戴帽子,适眼镜等等)
2. WebGL的三维游戏。
这一章,既是基础也是升华,主要是探讨,如何在微信小程序中出现摄像头画面的背景,然后在背景之上渲染出WebGL的三维内容。
1. 实现在微信小程序中访问摄像头,并且可以实时的拿到每一帧画面的数据。 |
2. 实现在微信小程序中访问WebGL接口,实现绘制三维物体。该教程采用Three.js引擎 |
3. 实现在背景为摄像头实时画面的背景上显示WebGL的3D物体。 |
4. 整体框架搭建 |
5. 图像算法接入 |
【目的】
微信小程序中实现摄像头画面为背景,之上渲染WebGL内容
[方案]
在开始开发之前,我们想罗列一下各种可能的方案。
要想在摄像头画面之上渲染出WebGL的内容,有一种方案比较容易想到:
方案一:双层Canvas结构
如上图所示,我们可以构建两个画布,一个画布用来渲染摄像头的画面,另一个画布用来渲染WebGL的画面。WebGL的画面在上层,而摄像头的画面在下层,并且WebGL画布的背景要是透明的。
这种方案,就要求我们的画布支持多层的结构,并且WebGL的背景支持透明。
方案二:单层Canvas,WebGL内平面贴图
这种方案就采用纯粹的WebGL画布,在场景中建立一个竖直面向摄像头的平面,并将摄像头画面的每一帧图像作为贴图显示在平面上。
当然两种方案各有各的优缺点,就目前而言:
1. 方案一的优点是结构简单,不需要在场景中添加物体,再每一帧贴图。不过可能不同手机对于WebGL透明背景的支持并不好,另外在计算WebGL层上物体的位置的时候需将坐标转换到摄像头画面层上的坐标才可以显示正常。
2. 方案二,结构相对复杂,不过可以适配性更强,全部采用WebGL硬件加速。三维物体和摄像头画面的坐标转换在同一个场景中完成。
【准备】
下面需要搭建环境,做一些准备工作。
首先,需要注册微信小程序开发者。注册地址=>
注册成功之后,需要下载微信小程序开发工具。下载地址=>
目前笔者的开发环境是:Windows 10
下载的微信小程序版本为:RC v1.0.2.1909111
【创建工程】
按照与上第二章同样的步骤,我们创建一个简单的基本工程。这里就不再赘述了。这里我们分别建立两个page:“scenario1”和“scenario2”,分别用来演示两种方案。同时也添加“libs”文件夹,并将第三章中修改过的three.js文件放入进去。建立好之后的项目目录如下:
【开发:方案一(scenario1文件夹下)】
首先,我们在index.wxml文件中按顺序添加用于摄像头和用于WebGL的2个层:
<!--index.wxml--> <view> <!--WebGL层--> <canvas type="webgl" id="webgl" canvas-id="webgl" style="position:fixed;top:0;width:{{canvasWidth}}px;height:{{canvasHeight}}px;z-index:1;"> </canvas> <!--摄像头层--> <camera mode="normal" device-position="back" flash="auto" frame-size="medium" style="position:fixed;top:0;width:100%;height:100%;z-index:0;"> </camera> </view>
需要注意的是,两个标签中的style属性,相比于之前的2各章节,都添加了position,top和z-index字段的设置,这样是为了让每个层都是从最手机屏幕最上方开始,并且保证WebGL层在上面。
接下来,我们可以把上一章中关于three.js创建的旋转cube的代码复制过来。也就是把上一个章节中的index.js文件的内容复制过来。唯一需要修改的就是在创建WebGLRenderer渲染器的时候,指定渲染器的背景是透明的。具体代码如下:
//index.js //导入three.js库 import * as THREE from '../../libs/three.js' //获取应用实例 const app = getApp(); Page({ data: { canvasWidth: 0, canvasHeight: 0 }, /** * 页面加载回调函数 */ onLoad: function () { //初始化Canvas对象 this.initWebGLCanvas(); }, /** * 初始化Canvas对象 */ initWebGLCanvas: function () { //获取页面上的标签id为webgl的对象,从而获取到canvas对象 var query = wx.createSelectorQuery(); query.select('#webgl').node().exec((res) => { var canvas = res[0].node; this._webGLCanvas = canvas; //获取系统信息,包括屏幕分辨率,显示区域大小,像素比等 var info = wx.getSystemInfoSync(); this._sysInfo = info; //设置canvas的大小,这里需要用到窗口大小与像素比乘积来定义 this._webGLCanvas.width = this._sysInfo.windowWidth * this._sysInfo.pixelRatio; this._webGLCanvas.height = this._sysInfo.windowHeight * this._sysInfo.pixelRatio; //设置canvas的样式 this._webGLCanvas.style = {}; this._webGLCanvas.style.width = this._webGLCanvas.width.width; this._webGLCanvas.style.height = this._webGLCanvas.width.height; //设置显示层canvas绑定的样式style数据,页面层则直接用窗口大小来定义 this.setData({ canvasWidth: this._sysInfo.windowWidth, canvasHeight: this._sysInfo.windowHeight }); this.initWebGLScene(); }); }, /** * 初始化WebGL场景 */ initWebGLScene: function () { //创建摄像头 var camera = new THREE.PerspectiveCamera(60, this._webGLCanvas.width / this._webGLCanvas.height, 1, 1000); this._camera = camera; //创建场景 var scene = new THREE.Scene(); this._scene = scene; //创建Cube几何体 var cubeGeo = new THREE.CubeGeometry(30, 30, 30); //创建材质,设置材质为基本材质(不会反射光线,设置材质颜色为绿色) var mat = new THREE.MeshBasicMaterial({ color: 0x00FF00 }); //创建Cube的Mesh对象 var cube = new THREE.Mesh(cubeGeo, mat); //设置Cube对象的位置 cube.position.set(0, 0, -100); //将Cube加入到场景中 this._scene.add(cube); //创建渲染器,指定渲染器背景透明 var renderer = new THREE.WebGLRenderer({ canvas: this._webGLCanvas, alpha:true }); //设置渲染器大小 this._renderer = renderer; this._renderer.setSize(this._webGLCanvas.width, this._webGLCanvas.height); //记录当前时间 var lastTime = Date.now(); this._lastTime = lastTime; //开始渲染 this.renderWebGL(cube); }, /** * 渲染函数 */ renderWebGL: function (cube) { //获取当前一帧的时间 var now = Date.now(); //计算时间间隔,由于Date对象返回的时间是毫秒,所以除以1000得到单位为秒的时间间隔 var duration = (now - this._lastTime) / 1000; //打印帧率 console.log(1 / duration + 'FPS'); //重新赋值上一帧时间 this._lastTime = now; //旋转Cube对象,这里希望每秒钟Cube对象沿着Y轴旋转180度(Three.js中用弧度标是,所以是Math.PI) cube.rotation.y += duration * Math.PI; //渲染执行场景,指定摄像头看到的画面 this._renderer.render(this._scene, this._camera); //设置帧回调函数,并且每一帧调用自定义的渲染函数 this._webGLCanvas.requestAnimationFrame(() => { this.renderWebGL(cube); }); } })
保存代码,编译运行,我们就可以看到在摄像头画面的背景之上,出现了我们的旋转Cube了(真机上测试同样效果),并且帧率也是维持在60FPS左右的。
【开发:方案二(scenario2文件夹下)】
按照之前对方案的描述,我们:
1. 首先在场景中创建一个Plane平面的Geometry
2. 接着我们在Camera的回调函数中更新一个贴图
3. 最后在渲染器更新中将新的贴图应用到Plane平面上面
首先我们来编写index.wxml文件,代码如下:
<!--pages/scenario2/index.wxml--> <view> <canvas type="webgl" id="webgl" canvas-id="webgl" style="position:fixed;top:0;width:{{canvasWidth}}px;height:{{canvasHeight}}px;"> </canvas> <!--摄像头层--> <camera mode="normal" device-position="back" flash="auto" frame-size="medium" style="position:fixed;top:-100%;width:100%;height:100%;"> </camera> </view>
在这个代码中我们不需要指定z-index的值,而是将摄像头Camera标签的style属性将top设置为了-100%,这样这一层就在屏幕的外面的,不会显示。因为后面我们会将摄像头显示的画面显示在WebGL中。
接下来,我们就可以编写index.js文件了。
//index.js //导入three.js库 import * as THREE from '../../libs/three.js' //获取应用实例 const app = getApp(); Page({ data: { canvasWidth: 0, canvasHeight: 0 }, /** * 页面加载回调函数 */ onLoad: function () { //初始化Camera this.initCamera(); //初始化Canvas对象 this.initWebGLCanvas(); }, /** * 初始化Canvas对象 */ initWebGLCanvas: function () { //获取页面上的标签id为webgl的对象,从而获取到canvas对象 var query = wx.createSelectorQuery(); query.select('#webgl').node().exec((res) => { var canvas = res[0].node; this._webGLCanvas = canvas; //获取系统信息,包括屏幕分辨率,显示区域大小,像素比等 var info = wx.getSystemInfoSync(); this._sysInfo = info; //设置canvas的大小,这里需要用到窗口大小与像素比乘积来定义 this._webGLCanvas.width = this._sysInfo.windowWidth * this._sysInfo.pixelRatio; this._webGLCanvas.height = this._sysInfo.windowHeight * this._sysInfo.pixelRatio; //设置canvas的样式 this._webGLCanvas.style = {}; this._webGLCanvas.style.width = this._webGLCanvas.width.width; this._webGLCanvas.style.height = this._webGLCanvas.width.height; //设置显示层canvas绑定的样式style数据,页面层则直接用窗口大小来定义 this.setData({ canvasWidth: this._sysInfo.windowWidth, canvasHeight: this._sysInfo.windowHeight }); //初始化场景 this.initWebGLScene(); }); }, /** * 初始化摄像头 */ initCamera:function() { //获取Camera Coontext对象 const cContex = wx.createCameraContext(); //添加帧回调事件监听器 const listener = cContex.onCameraFrame((frame) => { //在回调事件中,拿到每一帧的数据 var data = new Uint8Array(frame.data); //通过RGBA的数据格式生成贴图 var tex = new THREE.DataTexture(data, frame.width, frame.height, THREE.RGBAFormat); //清理次摄像头数据的贴图 if(this._tex != null) { this._tex.dispose(); } //保留最新帧的贴图 this._tex = tex; }); //启动监听 listener.start(); }, /** * 初始化WebGL场景 */ initWebGLScene: function () { //创建摄像头 var camera = new THREE.PerspectiveCamera(60, this._webGLCanvas.width / this._webGLCanvas.height, 1, 1000); this._camera = camera; //创建场景 var scene = new THREE.Scene(); this._scene = scene; //创建Cube几何体 var cubeGeo = new THREE.CubeGeometry(30, 30, 30); //创建材质,设置材质为基本材质(不会反射光线,设置材质颜色为绿色) var mat = new THREE.MeshBasicMaterial({ color: 0x00FF00 }); //创建Cube的Mesh对象 var cube = new THREE.Mesh(cubeGeo, mat); //设置Cube对象的位置 cube.position.set(0, 0, -100); //将Cube加入到场景中 this._scene.add(cube); //创建平面几何 var planeGeo = new THREE.PlaneGeometry(100,100); //创建平面的MEsh var plane = new THREE.Mesh(planeGeo,new THREE.MeshBasicMaterial()); //设置平面的位置,为了不让平面挡住前面的Cube,所以将平面设置的更远了。 plane.position.set(0,0,-200); //将平面加入到场景中 this._scene.add(plane); //创建渲染器,指定渲染器背景透明 var renderer = new THREE.WebGLRenderer({ canvas: this._webGLCanvas, }); //设置渲染器大小 this._renderer = renderer; this._renderer.setSize(this._webGLCanvas.width, this._webGLCanvas.height); //记录当前时间 var lastTime = Date.now(); this._lastTime = lastTime; //开始渲染 this.renderWebGL(cube,plane); }, /** * 渲染函数 */ renderWebGL: function (cube,plane) { //获取当前一帧的时间 var now = Date.now(); //计算时间间隔,由于Date对象返回的时间是毫秒,所以除以1000得到单位为秒的时间间隔 var duration = (now - this._lastTime) / 1000; //打印帧率 //console.log(1 / duration + 'FPS'); //重新赋值上一帧时间 this._lastTime = now; //旋转Cube对象,这里希望每秒钟Cube对象沿着Y轴旋转180度(Three.js中用弧度标是,所以是Math.PI) cube.rotation.y += duration * Math.PI; //设置plane的贴图 if(this._tex != null) { //当前摄像头贴图存在的时候 if(plane.material != null) { //清理上次帧的材质 plane.material.dispose(); } //用新的贴图生成新的材质赋值给平面对象 plane.material = new THREE.MeshBasicMaterial({color: 0xFFFFFF, map: this._tex}); } //渲染执行场景,指定摄像头看到的画面 this._renderer.render(this._scene, this._camera); //设置帧回调函数,并且每一帧调用自定义的渲染函数 this._webGLCanvas.requestAnimationFrame(() => { //启动下一帧渲染 this.renderWebGL(cube,plane); }); } })
新的Js有几个地方的改动:
1. 首先在OnLoad函数中添加了一个初始化Camera的自定义函数initCamera,这个函数中添加了帧事件监听器,并且用每一帧返回的数据生成了一个新的贴图存于this._tex对象上面。在创建贴图的地方,我们用到了Three.js的DataTexture贴图类型,它可以通过一个像素值数组来创建贴图。在第二章中我们已经知道手机相机的帧回调函数中返回的每一帧的数据是RGBA的形式,所以按照这个格式就可以正确的创建贴图了。
2. 在场景初始化函数中,新创建了一个平面,这个平面放置到了距离Cube更远(相对于Camera)的距离上。这样不会挡住Cube。
3.在渲染函数中,传入了平面对象plane,并且每一次渲染更新plane的贴图。
保存,编译,我们就可以看到最后的效果了(真机测试同样有效):
不过,我们会发现现在有一些问题,就是Plane上面摄像头的贴图画面是反的,左右上下都有颠倒。着说面摄像头的帧事件中传回来的每一帧画面的值里像素的排布顺序和three.js中贴图Texture里像素的排布顺序不一样。所以我们需要将plane旋转一下,才可以看到正确的结果。另外左右相反的问题,旋转之后就到了平面的背面,默认情况下,平面的背面是不会显示出来的。所以,我们需要将平面的材质设置为双面材质,这样才可以显示背面的内容。
所以要修改一下,平面创建时候的代码:
//创建平面几何 var planeGeo = new THREE.PlaneGeometry(100,100); //创建平面的MEsh var plane = new THREE.Mesh(planeGeo,new THREE.MeshBasicMaterial()); //设置平面的位置,为了不让平面挡住前面的Cube,所以将平面设置的更远了。 plane.position.set(0,0,-200); //旋转平面的方向,正确的显示摄像头画面 plane.rotation.z = Math.PI; plane.rotation.y = Math.PI; //将平面加入到场景中 this._scene.add(plane);
以及渲染函数中,创建材质的代码
//设置plane的贴图 if(this._tex != null) { //当前摄像头贴图存在的时候 if(plane.material != null) { //清理上次帧的材质 plane.material.dispose(); } //用新的贴图生成新的材质赋值给平面对象,并设置为双面材质 plane.material = new THREE.MeshBasicMaterial({color: 0xFFFFFF, map: this._tex, side:THREE.DoubleSide}); }
这样我们看到的画面就是正确的了。
不过这时细心的朋友又会有新的疑问了。摄像头创建的贴图长宽是按照每一帧画面的长宽,即frame.height和frame.width得到的,但是我们将这个贴图贴在了一个正方形的平面上,由于贴图的尺寸不是正方形,所以会导致最后看到的画面被拉伸或者压缩了。另外整个平面也没全全屏占满整个屏幕。
这些问题就需要了解Three.js的三维空间知识,利用屏幕的长宽比和透视矩阵计算得到正确的平面对象plane的长宽,这样才可以全屏的显示这个平面。
又会有同学问,现在这个平面和Cube都是在三维场景中的,假如场景中的物体被放大了,或者位置接近平面的位置了,就会发生碰撞,导致显示出现bug。
这个问题就是我们方案二存在的一个弊端。要修复它也可以有很多形式。比如多个摄像头分别渲染摄像头画面和三维场景再贴加起来,有点类似CSS里面多个Canvas的层次结构。也可以时刻改变plane的位置在最远处,等等。
这些就需要在实际的应用场景中具体的结局了。
【总结】
通过这一章的学习,我们终于有了一些质的变化了。可以在摄像头背景下面显示三维物体。当然通过这一章的学习,我们也了解了如何将摄像头的画面变为贴图,应用到三维物体上。
至此,AR的几个基本的要素我们都已经做出了技术实现,获取摄像头数据,显示三维物体,在摄像头画面前显示三维物体。这样我们已经可以在小程序中实现在第一章中介绍的现有的AR方案,也就是识别一个图片显示不带跟随信息的三维物体。
不带有跟随信息也就是说不需要将三维物体的位置时刻根据背后摄像头画面的变化而变化。
如果要做一个真正的AR,那就需要一个更完整的框架,以便应用不同的算法,实现不同的效果。所以下面的章节,将会做更深此次的和更结构化的讲解。从程序的框架入手,打开AR真正的大门。
【代码】