近年来VR概念越来越火,相信大家在网上都有过VR的浏览体验,比如VR全景看房[1]、VR全景看车[2]、VR全景旅游[3]等等,VR全景给了我们视觉上的沉浸式体验。本文将会简单探究Web VR全景的实现原理,同时也会用threejs实现两个小的demo,希望对大家以后在业务上遇到类似的场景能有所帮助。
引用维基百科的定义:
虚拟现实(英语:virtual reality,缩写VR),是利用电脑模拟产生一个三维空间的虚拟世界,提供用户关于视觉等感官的模拟,让用户感觉仿佛身历其境,可以即时、没有限制地观察三维空间内的事物。用户进行位置移动时,电脑可以立即进行复杂的运算,将精确的三维世界影像传回产生临场感。
与基于现实场景进行增强效果的 AR(Augmented Reality)的区别在于,VR 的场景需要完全重建,类似于进入另一个世界。
人眼对世界的感知,是通过将三维世界投射至视网膜上,以二维图像建立的视觉体系。所以一张具备透视关系的图像,在特定的角度,可以使人感受到三维的空间关系,这就是人眼的深度知觉(depth perception)。VR 技术则建立在这个基础之上。
广泛意义上来说,只要符合模拟三维空间这一行为,就可以称为 VR,手机、电脑、大荧幕、VR 眼镜甚至于空气,都可以成为 VR 的载体。
三维空间是由2D和3D图形构成的,要模拟三维空间,必须绘制2D和3D图形,我们写js有ECMAScript规范,同理的绘制2D和3D图形也有一套规范,这套规范就是OpenGL
OpenGL(英语:Open Graphics Library,译名:开放图形库或者“开放式图形库”)是用于渲染2D、3D矢量图形的跨语言、跨平台的应用程序编程接口(API)
OpenGL规范描述了绘制2D和3D图形的抽象API,常用于CAD、虚拟现实、科学可视化程序和电子游戏开发。
可以的,在web上可以使用WebGL,它是Web浏览器中OpenGL的JavaScript绑定。
WebGL是一种JavaScript API,用于在不使用外挂程式的情况下在任何相容的网页浏览器中呈现交互式2D和3D图形。WebGL完全整合到浏览器的所有网页标准中,可将影像处理和效果的GPU加速使用方式当做网页Canvas的一部分。WebGL元素可以加入其他HTML元素之中并与网页或网页背景的其他部分混合。
有了WebGL API,我们就可以web网页上构建三维空间啦。我们可以借助canvas标签来获取WebGL实例来进行WebGL API的调用,更多API可以戳WebGL: 2D and 3D graphics for the web - Web APIs | MDN[4]查看,下面是使用示例:
// 创建canvas标签
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
// 获取webgl实例
const gl = canvas.getContext("webgl");
// 调用webgl api绘制图形
// .....
目前业界有很多成熟的WebGL3D引擎,比如three.js[5]、babylon.js[6]等等,这些3D引擎已经对WebGL API做了非常高效的封装,可以帮助我们快速绘制2D和3D图形。
在实战前先了解下three.js的基本概念
在three.js中,渲染一个三维空间的必要因素是场景(scene)、摄像机(camera)、渲染器(renderer)。渲染出一个三维空间后,可以往里面增加各种各样的物体、光源等等。
一个容器,容纳着除渲染器以外的3d世界里的一切。场景的元素采用右手坐标系,x轴正方向向右,y轴正方向向上,z轴由屏幕从里向外
// three.js创建场景
const scene = new THREE.Scene();
就像人的眼睛,在一个空间里可以看向任意方向,可以通过参数调节可视角度和可视距离。
three.js中常用的camera有两种,透视投影相机(PerspectiveCamera)与正交投影相机(OrthographicCamera)。这里的投影是指将三维空间中的物体坐标投影到二维平面上。
透视 投影是将每个点都投影到三维空间中,近大远小,看起来更符合真实世界看到的物体。
正交 投影是只考虑所有点的XY坐标,每一个二维空间中的点都是与Z轴平行的直线在观察平面上的投影。所看到的物体大小不会受到距离远近的影响。
使用perspective projection(透视投影)来进行投影。
这一投影模式被用来模拟人眼所看到的景象,它是3D场景的渲染中使用得最普遍的投影模式。
PerspectiveCamera(fov, aspect, near, far) 有四个参数:
fov - field of view,视野角,下图中绿色英文标注的地方,是距离观测点near长度处,最上端与最下端之间的角度,Fov越大,表示眼睛睁得越大,离得越远,看得更多。
aspect - 画面横宽比
near - 相机最近范围内可以看到的物体的距离
far - 相机最远范围内可以看到的物体的距离
这一摄像机使用orthographic projection(正交投影)来进行投影。
在这种投影模式下,无论物体距离相机距离远或者近,在最终渲染的图片中物体的大小都保持不变。这对于渲染2D场景或者UI元素是非常有用的。
OrthographicCamera(left, right, top, bottom, near, far)有六个参数
left, right, top, bottom - 分别是红色点距离左右上下边框的距离,对应图中XY轴的值;
near - 场景开始渲染并可以显示的起点,对应图中Z轴坐标的值,通常为负;
far - 场景结束渲染的终点,对应图中Z轴坐标的值,通常为正;
将camera在scene里看到的内容渲染/绘制到画布上
设置好 dpr、画布宽高,Three.js 就会生成一个 canvas。
const renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
const canvas = renderer.domElement;
document.body.appendChild(canvas);
// 将camera在scene里看到的内容渲染/绘制到画布上
renderer.render( scene, camera );
三维空间里的所有物体都是点组成面,面组成几何体。
three.js核心中内置了多种几何体,如下图的几何体从左到右分别为:球缓冲几何体(SphereGeometry)
、 圆柱缓冲几何体(CylinderGeometry)
、立方缓冲几何体(BoxGeometry)
、圆锥缓冲几何体(ConeGeometry)
、 圆环(TorusGeometry)
和圆环缓冲扭结几何体(TorusKnotGeometry)
等等
想象一下你手里有一个几何体,你用一张A4纸包裹上几何体,并在上面画画。你画的内容就是贴图。
下面给立方体贴上小黄鸭贴图:
// 立方缓冲几何体(BoxGeometry)
const boxGeometry = new THREE.BoxGeometry(2, 2, 2);
// 小黄鸭贴图
const texture = new THREE.TextureLoader().load( "textures/duck.png" );
const material = new THREE.MeshBasicMaterial( { map: texture } );
const cube = new THREE.Mesh( boxGeometry, material );
scene.add( cube );
也可以给立方体贴上视频
const boxGeometry = new THREE.BoxGeometry(2, 2, 2);
const video = document.getElementById( 'video' );
const texture = new THREE.VideoTexture( video );
const material = new THREE.MeshBasicMaterial( { map: texture } );
const cube = new THREE.Mesh( boxGeometry, material );
scene.add( cube );
延续贴图里的想象,你用白卡纸画画,还是用油纸画画,呈现出来的质感是不同的,这就是材质!
下面五个圆环扭结的颜色都是一样的,而材质从左至右分别是:
MeshBasicMaterial
(基础材质,简单的几何材料,不考虑光照的影响)
MeshMatcapMaterial
(网帽材质,在场景没有光源的情况下,会模拟出物体被光照的效果)
MeshPhongMaterial
(高光材质,适用于陶瓷,烤漆类质感)
MeshToonMaterial
(卡通材质)
MeshLambertMaterial
(非光泽表面的材质,没有镜面高光,可以很好地模拟一些表面,例如未经处理的木材或石材,但不能模拟具有镜面高光的光泽表面,例如涂漆木材。)
环境光的情况下:
有从上往下的平行光:
在没有手动创建光的情况下会默认有个环境光,不然你什么都看不到。
常见的灯光有以下几种类型:
AmbientLight
:环境光,没有方向,会均匀的照亮场景中的所有物体,不会产生阴影
DirectionalLight
:平行光,常常用平行光来模拟太阳光 的效果; 太阳足够远,因此我们可以认为太阳的位置是无限远,所以我们认为从太阳发出的光线也都是平行的。
PointLight
:点光源,从一个点向各个方向发射的光源。可以模拟一个灯泡发出的光。
SpotLight
:聚光灯,光线从一个点沿一个方向射出,随着光线照射的变远,光线圆锥体的尺寸也逐渐增大。
我们可以通过模拟人走在房子里的行为来实现:
用摄像机模拟人,镜头相当于人的眼睛
用立方几何体六个面贴上贴图模拟房子,贴图要求真实还原房子六个面的情况
将摄像机放置在几何体里面,移动摄像机相当于人在房子里走动
房子六面贴图:
创建场景和摄像机,并且设置摄像机位置和添加加轨道控制器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 90, window.innerWidth / window.innerHeight, 0.1, 100 );
// 设置摄像机位置
camera.position.set(1, 1, 4);
const controls = new THREE.OrbitControls( camera, renderer.domElement );
创建立方几何体,并且给几何体贴上准备好的贴图
// 获取加载6个面的贴图
const textures = getTexturesFromAtlasFile( 'textures/house.jpeg', 6 );
const materials = [];
for ( let i = 0; i < 6; i ++ ) {
// 将贴图贴在立方几何体上
materials.push( new THREE.MeshBasicMaterial( { map: textures[ i ] } ) );
}
// 创建立方几何体
const skyBox = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ), materials );
// 把几何体加到场景里
scene.add( skyBox );
function getTexturesFromAtlasFile( atlasImgUrl, tilesNum ) {
const textures = [];
for ( let i = 0; i < tilesNum; i ++ ) {
textures[ i ] = new THREE.Texture();
}
new THREE.ImageLoader()
.load( atlasImgUrl, ( image ) => {
let canvas, context;
const tileWidth = image.height;
for ( let i = 0; i < textures.length; i ++ ) {
canvas = document.createElement( 'canvas' );
context = canvas.getContext( '2d' );
canvas.height = tileWidth;
canvas.width = tileWidth;
context.drawImage( image, tileWidth * i, 0, tileWidth, tileWidth, 0, 0, tileWidth, tileWidth );
textures[ i ].image = canvas;
textures[ i ].needsUpdate = true;
}
} );
return textures;
}
页面效果:
将摄像头的位置移动到几何体内部
camera.position.set(0, 0, -0.01);
将摄像机移动到几何体内部后发现视野一片黑暗,这是因为贴图是在几何体外表面,所以需要将贴图翻转至内表面。
将几何体贴图翻转至内表面,就能看到全景啦
skyBox.geometry.scale( 1, 1, - 1 );
监听键盘事件移动摄像机
window.addEventListener('keydown', onKeydown);
function onKeydown(e) {
switch(e.keyCode) {
// 左
case 37:
if(camera.position.x > -0.5) {
camera.position.x -= 0.01;
}
break;
// 上
case 38:
if(camera.position.z > -0.5) {
camera.position.z -= 0.01;
}
break;
// 右
case 39:
if(camera.position.x < 0.5) {
camera.position.x += 0.01;
}
break;
// 下
case 40:
if(camera.position.z < 0.5) {
camera.position.z += 0.01;
}
break;
}
}
VR全景看车的难点是汽车模型的设计,需要设计一个3d汽车模型,然后渲染到页面上。汽车换装则是通过对关键节点的操作来完成。
另外我们一般说的3d模型就是一个或多个几何体,只是有的3d模型文件里除了包含几何体还可以包含一些额外的信息,比如贴图,材质等等需要在读取模型文件时解析出来
3d模型的文件格式有很多,但threejs里常用的基本是:
OBJ格式:老牌通用3d模型文件,不包含贴图,材质,动画等信息。
GLTF格式(图形语言传输格式):由OpenGL官方维护团队推出的现代3d模型通用格式,可以包含几何体、材质、动画及场景、摄影机等信息,并且文件量还小。有3D模型界的JPEG之称。GLTF格式有.gltf(JSON/ASCII)和.glb(二进制)两种拓展名。
官网上找了一个汽车模型,可以用PlayCanvas glTF Viewer[7]查看汽车模型的节点信息。
创建场景和摄像机,并且设置摄像机位置和添加加轨道控制器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 0.1, 100 );
const camera.position.set( 4.25, 1.4, - 4.5 );
const controls = new THREE.OrbitControls( camera, container );
加载汽车模型
通过GLTFLoader,我们可以加载一个gltf
格式的3d模型文件。需要注意的是,这些Loader都以插件的形式存在,需要引入相应的XXXLoader.js
才能使用
const bodyMaterial = new THREE.MeshPhysicalMaterial( {
color: 0xff0000, metalness: 1.0, roughness: 0.5, clearcoat: 1.0, clearcoatRoughness: 0.03, sheen: 0.5
} );
const detailsMaterial = new THREE.MeshStandardMaterial( {
color: 0xffffff, metalness: 1.0, roughness: 0.5
} );
const glassMaterial = new THREE.MeshPhysicalMaterial( {
color: 0xffffff, metalness: 0.25, roughness: 0, transmission: 1.0
} );
const dracoLoader = new THREE.DRACOLoader();
dracoLoader.setDecoderPath( 'draco/' );
// GLTF文件loader
const loader = new THREE.GLTFLoader();
loader.setDRACOLoader( dracoLoader );
// 存储车轮,后面让车轮旋转
const wheels = [];
loader.load( 'glb/car.glb', function ( gltf ) {
// 获取模型节点
const carModel = gltf.scene.children[ 0 ];
// 重定义模型节点的材质
carModel.getObjectByName( 'body' ).material = bodyMaterial;
carModel.getObjectByName( 'rim_fl' ).material = detailsMaterial;
carModel.getObjectByName( 'rim_fr' ).material = detailsMaterial;
carModel.getObjectByName( 'rim_rr' ).material = detailsMaterial;
carModel.getObjectByName( 'rim_rl' ).material = detailsMaterial;
carModel.getObjectByName( 'trim' ).material = detailsMaterial;
carModel.getObjectByName( 'glass' ).material = glassMaterial;
// 存储车轮
wheels.push(
carModel.getObjectByName( 'wheel_fl' ),
carModel.getObjectByName( 'wheel_fr' ),
carModel.getObjectByName( 'wheel_rl' ),
carModel.getObjectByName( 'wheel_rr' )
);
// 车底阴影
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry( 0.655 * 4, 1.3 * 4 ),
new THREE.MeshBasicMaterial( {
map: shadow, blending: THREE.MultiplyBlending, toneMapped: false, transparent: true
} )
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add( mesh );
scene.add( carModel );
});
车轮滚动
function render() {
controls.update();
const time = - performance.now() / 1000;
// 让车轮旋转,模拟行驶效果
for ( let i = 0; i < wheels.length; i ++ ) {
wheels[ i ].rotation.x = time * Math.PI * 2;
}
grid.position.z = - ( time ) % 1;
renderer.render( scene, camera );
stats.update();
}
创建场景和摄像机,并且设置摄像机位置和添加加轨道控制器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 0.1, 100 );
const camera.position.set( 4.25, 1.4, - 4.5 );
const controls = new THREE.OrbitControls( camera, container );
加载汽车模型
通过GLTFLoader,我们可以加载一个gltf
格式的3d模型文件。需要注意的是,这些Loader都以插件的形式存在,需要引入相应的XXXLoader.js
才能使用
const bodyMaterial = new THREE.MeshPhysicalMaterial( {
color: 0xff0000, metalness: 1.0, roughness: 0.5, clearcoat: 1.0, clearcoatRoughness: 0.03, sheen: 0.5
} );
const detailsMaterial = new THREE.MeshStandardMaterial( {
color: 0xffffff, metalness: 1.0, roughness: 0.5
} );
const glassMaterial = new THREE.MeshPhysicalMaterial( {
color: 0xffffff, metalness: 0.25, roughness: 0, transmission: 1.0
} );
const dracoLoader = new THREE.DRACOLoader();
dracoLoader.setDecoderPath( 'draco/' );
// GLTF文件loader
const loader = new THREE.GLTFLoader();
loader.setDRACOLoader( dracoLoader );
// 存储车轮,后面让车轮旋转
const wheels = [];
loader.load( 'glb/car.glb', function ( gltf ) {
// 获取模型节点
const carModel = gltf.scene.children[ 0 ];
// 重定义模型节点的材质
carModel.getObjectByName( 'body' ).material = bodyMaterial;
carModel.getObjectByName( 'rim_fl' ).material = detailsMaterial;
carModel.getObjectByName( 'rim_fr' ).material = detailsMaterial;
carModel.getObjectByName( 'rim_rr' ).material = detailsMaterial;
carModel.getObjectByName( 'rim_rl' ).material = detailsMaterial;
carModel.getObjectByName( 'trim' ).material = detailsMaterial;
carModel.getObjectByName( 'glass' ).material = glassMaterial;
// 存储车轮
wheels.push(
carModel.getObjectByName( 'wheel_fl' ),
carModel.getObjectByName( 'wheel_fr' ),
carModel.getObjectByName( 'wheel_rl' ),
carModel.getObjectByName( 'wheel_rr' )
);
// 车底阴影
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry( 0.655 * 4, 1.3 * 4 ),
new THREE.MeshBasicMaterial( {
map: shadow, blending: THREE.MultiplyBlending, toneMapped: false, transparent: true
} )
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add( mesh );
scene.add( carModel );
});
车轮滚动
function render() {
controls.update();
const time = - performance.now() / 1000;
// 让车轮旋转,模拟行驶效果
for ( let i = 0; i < wheels.length; i ++ ) {
wheels[ i ].rotation.x = time * Math.PI * 2;
}
grid.position.z = - ( time ) % 1;
renderer.render( scene, camera );
stats.update();
}
提供拾色器,改变车身(body)、细节(detail)和玻璃(glass)的颜色
Body
Details
Glass
// 车身颜色
const bodyColorInput = document.getElementById( 'body-color' );
bodyColorInput.addEventListener( 'input', function () {
bodyMaterial.color.set( this.value );
} );
// 车细节颜色
const detailsColorInput = document.getElementById( 'details-color' );
detailsColorInput.addEventListener( 'input', function () {
detailsMaterial.color.set( this.value );
} );
// 车玻璃颜色
const glassColorInput = document.getElementById( 'glass-color' );
glassColorInput.addEventListener( 'input', function () {
glassMaterial.color.set( this.value );
} );