在浏览器中实现全景浏览, 听起来是很玄的事情. 但如果你清楚它的原理, 这事就简单多了.

前言

在之前的一段时间, 朋友圈中出现了一批使用全景图浏览技术的H5页面. 比如探班吴亦凡系列, 或者是探访京东总部大楼找优惠券页面. 在当时, 这几个页面取得了不错的宣传效果. 那么, 这种新奇的全景效果到底是怎样实现的呢?

基础

现在的智能手机一般都自带全景图拍摄功能. 就算没有, 通过安装一些第三方软件也可以拥有这个功能, 但通过这些软件拍出来的只是一个非常宽的照片, 还无法达到360随意转动观看的效果. 要制作像上面那样的360全景观看页面, 我们需要从最基础的开始. 首先, 什么是全景图?

360度全景图也称为三维全景图、全景环视图。360度全景技术是一种运用数码相机对现有场景进行多角度环视拍摄之后,再利用计算机进行后期缝合,并加载播放程序来完成的一种三维虚拟展示技术。 – 360度全景图_百度百科

也就是说, 我们可以使用拍到的全景图, 使用计算机进行后期缝合, 并加载播放程序来完成三维显示. 具体到使用ThreeJS实现全景图这个场景, 我们需要做什么呢?

其实, 粗略一想也可以想到, 如果我们将拍摄到的全景图贴在一个圆柱的侧面上, 我们站在圆柱中心朝四周看的话, 应该就有全景观察的效果. 不过这样做也有坏处, 也就是我们的头顶跟脚底都是无法看到的区域. 我们需要使用其他的方式来实现. 在这之前, 我们需要了解一下ThreeJS中的相机.

ThreeJS世界中的相机

在ThreeJS中, 相机还分为CubeCamera(立方体相机), PerspectiveCamera(透视相机)以及OrthographicCamera(正交相机). 其中, CubeCamera是创建动态贴图用的, OrthographicCamera创建的照相机不具有透视效果. 在这里, 我们用到的是PerspectiveCamera.

定义一个透视相机只需要一句话:

1
2
3
4
5
6
var camera = new THREE.PerspectiveCamera(
fov,
aspect,
near,
far
);

camera示意图
在这一段代码中, fov代表相机的视角, 即视野上平面与下平面的角度, aspect是相机的宽高比, near是视野近平面的距离, far是远平面的距离.
然而, 在3D的世界中, 仅凭上面这几个参数, 我们只能确定一个照相机的自身基本属性, 却无法确定这台照相机究竟位于什么位置, 是什么样的角度. ThreeJS中, camera.position属性是一个三维向量, 我们可以用这个属性定义相机相对于原点的位置. camera.lookAt(Vector3)函数可以定义照相机的观察方向, 参数同样是一个三维向量. 对于照相机而言, 还有一个参数显得非常重要, 这就是相机的上方向. 同样的空间位置, 朝向同一个方向, 照相机还可以是横着, 也可以竖着, 最后看到的效果也不会一样. 所以camera还有一个up属性, 定义照相机的上方向. 如上图蓝色空心箭头所示.

动手做全景展示1

要让人产生全景的视觉效果, 很关键的一点是, 要让人看见他当前姿态所应当看见的景观. 如果将人眼比作一台照相机, 我们很容易想到, 我们如果将全景图贴在一个球形的内表面, 那么人眼这台照相机所看到的景象就是上下左右360无死角的全景.

想想总是美如画的. 我们不妨实践一下. 首先, 去google搜索关键字’全景图 360’, 随意下载一个全景图. 接下来, 我们需要将这个全景图贴到球形的表面. 这一步, 我们再一次用到了Blender.

首先, 新建一个工程, 然后往场景中添加一个经纬球.
经纬球

我们希望使用之前下载到的全景图作为这个球体的贴图. 所以, 我们需要首先对这个球体做uv展开. 对这个球使用球面投射, 方向选择对齐到物体, 选上缩放至边界框, 接着我们看到UV展开图是这样的:
UV展开图

这个UV展开图非常不规则, 就算有了全景图, 我们也没法往上贴. 这是因为上下两个顶点处汇集了所有的经线, 使得Blender也无法准确得知我们想要怎样的贴图. 这种时候, 我们需要将球体的南北两个极点删除. 这样的话, 球体的上下两个顶点就成为了两个空心的圆圈. 然而, 模型空了两个洞不太好. 接下来, 使用Extude工具推挤出新的纬圈, 酌情缩小一些.
删除了极点

重新进行UV展开, 会看到这次的UV展开非常平整.
UV展开图

将新的纬圈设定scale为0, 再删除重叠的节点, 南北极就可以重新汇聚到一个点了. 接下来我们把全景图贴上, 一个全景球就这样诞生了.
全景球

我们将这个全景球通过io_three插件导出为json. 新建一个页面, 引入three.js. 核心代码大概是这样子:

1
2
3
4
5
6
7
8
9
10
11
12
objloader = new THREE.ObjectLoader();
objloader.load(
'js/360.json',
function (obj) {
scene = obj;
//scene.add(new THREE.AmbientLight(0xffffff));
//material = scene.children[0].material;
//material.side = THREE.BackSide;
//material.emissive = 0x000000;
animate();
}
);

这里需要注意, 如果将注释部分的代码删去, 我们将会发现视野中一片黑. 这是因为Blender导出的模型默认使用的是遵守Phong光照模型的材质, 这种材质在没有配置自发光, 又没有外界光照的情况下就是一坨黑色. 所以我们还需要手动配置一下. blender导出的json是scene本身. scene是一个树状的结构, 在它的children属性中有所有的对象信息. 在这里, 我们需要配置一下贴图的方向以及自发光, 接下来就可以看到效果了.
效果1

动手做全景展示2

上面这样的实现其实也有一个弊端. 球状模型的顶点与面的数量十分逆天. 这些元素的数量越多, 耗费的浏览器资源就会越多. 那么有没有更加节能环保的方法呢?
问题

答案是肯定的. 既然我们的人眼可以被类比为照相机, 那么如果摆多几台照相机, 将拍到的照片无重叠地拼在一起, 一样可以获得全景视觉.

这里, 我们使用6台90度视角, 纵宽比1的照相机, 从球体中心分别朝向立方体六个面的方向.
6个相机

将6个渲染图分别保存下来.
6个渲染图

接下来新建页面, 将这六张图片分别贴到立方体的六个面就大功告成了. 核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
loader = new THREE.TextureLoader();
/*
虽然使用THREE.ImageUtils.loadTexture也没问题
不过估计是为了适应Javascript的异步式编程
ThreeJS也逐步将一些会阻塞的api转换为异步回调的模式
原有的老api会被标记为deprecated
*/
gardenMaterials = [
'garden/px.png',
'garden/nx.png',
'garden/py.png',
'garden/ny.png',
'garden/pz.png',
'garden/nz.png'
];
Promise.all(gardenMaterials.map(function (val) {
//加载图片, 新建材质, 传给下一个步骤.
return new Promise(function (resolve, reject) {
loader.load(val, function (texture) {
resolve(new THREE.MeshBasicMaterial({
map: texture,
side: THREE.BackSide
}));
});
});
})).then(function (materials) {
//将材质贴到正方体的6个面.
geometry = new THREE.BoxGeometry(60, 60, 60);
cube = new THREE.Mesh(
geometry,
new THREE.MeshFaceMaterial(materials)
);
scene.add(cube);
animate();
});

渲染出来的效果, 其实是完全一样的.
效果2

小结

使用这两种方法做出来的全景展示其实还会有一些小问题, 比如展示空间的底部与顶部会有聚焦在一点的现象:
顶部与底部
这种情况单靠一个全景图是无法解决的, 只能通过对底部与顶部多拍一个照片来补救. 目前, 全景展示的技术已经有许多应用, 比如谷歌地图百度地图的街景展示, 或者是上面提到的几个H5页面中也有用到. 作为一名前端工程师, 懂得其中的原理并付诸实践, 这是非常重要的.