VR(Virtual Reality) 的意思就是虚拟现实,可以通过VR 眼镜给人环境沉浸感。
VR 的制作需要考虑两点:
- 搭建场景,当前比较常见的搭建场景的方法就是将全景图贴到立方体,或者球体上。
- 场景变换,一般会把透视相机塞进立方体,或者球体里,然后变换场景。
接下来咱们 具体说一下其实现步骤。
1-搭建场景
1.用720°全景相机拍摄一张室内全景图。
2.在之前地球文件的基础上做修改,把地图替换成上面的室内全景图。
const image = new Image()
image.src = './https://blog-st.oss-cn-beijing.aliyuncs.com/16406884365522771800455335651.jpg'
3.把相机打入球体之中
// 目标点
const target = new Vector3()
//视点
const eye = new Vector3(0.15, 0, 0)
const [fov, aspect, near, far] = [
60, canvas.width / canvas.height,
0.1, 1
]
效果如下:
现在VR的效果就已经有了,接下来我们还需要考虑VR 场景的变换。
2-VR 场景的变换
VR 场景的变换通过相机轨道控制器便可以实现。
当前相机轨道控制器已经具备了旋转、缩放和平移功能。
只不过,针对VR 还得对相机轨道控制器做一下微调。
1.取消相机的平移,以避免相机跑到球体之外。
为相机轨道控制器 OrbitControls 添加一个是否启用平移的功能。
const defAttr = () => ({
……
enablePan: true,
})
在平移方法中,做个是否平移的判断:
pointermove({ clientX, clientY }) {
const { dragStart, dragEnd, state,enablePan, camera: { type } } = this
dragEnd.set(clientX, clientY)
switch (state) {
case 'pan':
enablePan&&this[`pan${type}`] (dragEnd.clone().sub(dragStart))
break
……
}
dragStart.copy(dragEnd)
}
这样就可以在实例化OrbitControls 对象的时候,将enablePan 设置为false,从而禁止相机平移。
const orbit = new OrbitControls({
camera,
target,
dom: canvas,
enablePan: false
})
2.使用透视相机缩放VR 场景时,不再使用视点到目标的距离来缩放场景,因为这样的放大效果不太明显。所以,可以直接像正交相机那样,缩放裁剪面。
为OrbitControls 对象的wheel 方法添加一个控制缩放方式的参数。
wheel({ deltaY },type=this.camera.type) {
const { zoomScale} = this
let scale=deltaY < 0?zoomScale:1 / zoomScale
this[`dolly${type}`] (scale)
this.updateSph()
}
这样就可以像缩放正交相机那样缩放透视相机。
canvas.addEventListener('wheel', event => {
orbit.wheel(event, 'OrthographicCamera')
})
3.在缩放的时候,需要限制一下缩放范围,免得缩放得太大,或者缩小得超出了球体之外。
为OrbitControls 添加两个缩放极值:
- minZoom 缩放的最小值
- maxZoom 缩放的最大值
const defAttr = () => ({
……
minZoom:0,
maxZoom: Infinity,
})
在相应的缩放方法中,对缩放量做限制:
dollyOrthographicCamera(dollyScale) {
const {camera,maxZoom,minZoom}=this
const zoom=camera.zoom*dollyScale
camera.zoom = Math.max(
Math.min(maxZoom, zoom),
minZoom
)
camera.updateProjectionMatrix()
}
在实例化OrbitControls 对象时,设置缩放范围:
const orbit = new OrbitControls({
……
maxZoom: 15,
minZoom: 0.4
})
3-陀螺仪
VR 的真正魅力在于,你可以带上VR 眼镜,体会身临其境的感觉。
VR 眼镜之所以能给你身临其境的感觉,是因为它内部有一个陀螺仪,可以监听设备的转动,从而带动VR 场景的变换。
目前市场上常见的VR 眼镜有两种:需要插入手机的VR眼镜和一体机。
一般手机里都是有陀螺仪的,因此我们可以用手机来体验VR。
接下来,咱们可以先整个小例子练练手。
我要画个立方体,然后用陀螺仪旋转它。
为了更好的理解陀螺仪。我们把之前的球体变成立方体,在其上面贴上画有东、西、南、北和上、下的贴图。然后在其中打入相机,用陀螺仪变换相机视点,如下图:
1.建立立方体对象Box
/*
属性:
w:宽
h:高
d:深
vertices:顶点集合
normals:法线集合
indexes:顶点索引集合
uv:uv坐标集合
count:顶点数量
*/
export default class Box{
constructor(w=1,h=1,d=1){
this.w=w
this.h=h
this.d=d
this.vertices=null
this.normals=null
this.indexes = null
this.uv = null
this.count = 36
this.init()
}
init() {
const [x, y, z] = [this.w / 2, this.h / 2, this.d / 2]
this.vertices = new Float32Array([
// 前 0 1 2 3
-x, y, z, -x, -y, z, x, y, z, x, -y, z,
// 右 4 5 6 7
x, y, z, x, -y, z, x, y, -z, x, -y, -z,
// 后 8 9 10 11
x, y, -z, x, -y, -z, -x, y, -z, -x, -y, -z,
// 左 12 13 14 15
-x, y, -z, -x, -y, -z, -x, y, z, -x, -y, z,
// 上 16 17 18 19
-x, y, -z, -x, y, z, x, y, -z, x, y, z,
// 下 20 21 22 23
-x,-y,z,-x,-y,-z,x,-y,z,x,-y,-z,
])
this.normals = new Float32Array([
0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1,
1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1,
-1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0,
0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0,
0,-1,0,0,-1,0,0,-1,0,0,-1,0,
])
/* this.uv = new Float32Array([
0,1,0,0,1,1,1,0,
0,1,0,0,1,1,1,0,
0,1,0,0,1,1,1,0,
0,1,0,0,1,1,1,0,
0,1,0,0,1,1,1,0,
0,1,0,0,1,1,1,0,
]) */
this.uv = new Float32Array([
0,1, 0,0.5, 0.25,1, 0.25,0.5,
0.25,1, 0.25,0.5, 0.5,1, 0.5,0.5,
0.5,1, 0.5,0.5, 0.75,1, 0.75,0.5,
0,0.5,0,0,0.25,0.5,0.25,0,
0.25,0.5,0.25,0,0.5,0.5,0.5,0,
0.5,0.5,0.5,0,0.75,0.5,0.75,0,
])
this.indexes = new Uint16Array([
0, 1, 2, 2, 1, 3,
4, 5, 6, 6, 5, 7,
8, 9, 10, 10, 9, 11,
12, 13, 14, 14, 13, 15,
16, 17, 18, 18, 17, 19,
20,21,22,22,21,23
])
}
}
2.在Google浏览器中打开传感器,模拟陀螺仪的旋转。
一我们在电脑里做测试的时候,需要用浏览器里的开发者工具
之后我们可以在js中通过deviceorientation 事件监听陀螺仪的变化。
从deviceorientation 事件的回调参数event里,可以解构出alpha, beta, gamma 三个参数。
window.addEventListener('deviceorientation', (event) => {
const { alpha, beta, gamma }=event
})
alpha, beta, gamma对应了陀螺仪欧拉旋转的三个参数。
在右手坐标系中,其概念如下:
- alpha:绕世界坐标系的y轴逆时针旋转的角度,旋转范围是[-180°,180°)
- beta:绕本地坐标系的x轴逆时针旋转的角度,旋转范围是[-180°,180°)
- gamma :绕本地坐标系的z轴顺时针旋转的角度,旋转范围是[-90°,90°)
注:alpha, beta, gamma具体是绕哪个轴旋转,跟我们当前程序所使用的坐标系有关。所以大家之后若是看到有些教程在说陀螺仪时,跟我说的不一样,也不要惊奇,只要在实践中没有问题就可以。
陀螺仪欧拉旋转的顺序是'YXZ',而不是欧拉对象默认的'XYZ'。
欧拉旋转顺序是很重要的,如果乱了,就无法让VR旋转与陀螺仪相匹配了。
接下来,基于之前VR.html 文件做下调整。
3.建立立方体对象
import Box from './lv/Box.js'
const box = new Box(1, 1, 1)
4.调整相机数据
// 目标点
const target = new Vector3()
//视点
const eye = new Vector3(0, 0.45, 0.0001)
const [fov, aspect, near, far] = [
120, canvas.width / canvas.height,
0.01, 2
]
// 透视相机
const camera = new PerspectiveCamera(fov, aspect, near, far)
camera.position.copy(eye)
相机的视线是根据陀螺仪的初始状态设置的。
在陀螺仪的alpha, beta, gamma皆为0的情况下,手机成俯视状态。
所以,相机也要成俯视状态,因此视点的y值设置为0.45。
然而,视线也不能完全垂直,因为这样视点绕y轴旋转就会失效。
所以,视点的z值给了一个较小的数字0.0001。
5.场景的渲染和之前是一样。
// 轨道控制器
const orbit = new OrbitControls({
camera,
target,
dom: canvas,
enablePan: false,
maxZoom: 15,
minZoom: 0.4
})
// 场景
const scene = new Scene({ gl })
//注册程序对象
scene.registerProgram(
'map',
{
program: createProgram(
gl,
document.getElementById('vs').innerText,
document.getElementById('fs').innerText,
),
attributeNames: ['a_Position', 'a_Pin'],
uniformNames: ['u_PvMatrix', 'u_ModelMatrix','u_Sampler']
}
)
//立方体
const matBox = new Mat({
program: 'map',
data: {
u_PvMatrix: {
value: orbit.getPvMatrix().elements,
type: 'uniformMatrix4fv',
},
u_ModelMatrix: {
value: new Matrix4().elements,
type: 'uniformMatrix4fv',
},
},
})
const geoBox = new Geo({
data: {
a_Position: {
array: box.vertices,
size: 3
},
a_Pin: {
array: box.uv,
size: 2
}
},
index: {
array: box.indexes
}
})
//加载图片
const image = new Image()
image.src = './images/magic.jpg'
image.onload = function () {
matBox.maps.u_Sampler = {
image,
magFilte: gl.LINEAR,
minFilter: gl.LINEAR,
}
scene.add(new Obj3D({
geo: geoBox,
mat: matBox
}))
render()
}
function render() {
orbit.getPvMatrix()
scene.draw()
requestAnimationFrame(render)
}
效果如下:
7.监听陀螺仪事件时,需要考虑三件事:
- 判断当前设备里是否有陀螺仪。
- 让用户触发浏览器对陀螺仪事件的监听,可通过click 之类的事件触发。
- 若系统是ios,需要请求用户许可。
css 样式:
html {height: 100%;}
body {
margin: 0;
height: 100%;
overflow: hidden
}
.wrapper {
display: flex;
position: absolute;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
top: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 10;
}
#playBtn {
padding: 24px 24px;
border-radius: 24px;
background-color: #00acec;
text-align: center;
color: #fff;
cursor: pointer;
font-size: 24px;
font-weight: bold;
border: 6px solid rgba(255, 255, 255, 0.7);
box-shadow: 0 9px 9px rgba(0, 0, 0, 0.7);
}
html 标签:
<canvas id="canvas"></canvas>
<div class="wrapper">
<div id="playBtn">开启VR之旅</div>
</div>
js 代码:
// 遮罩
const wrapper = document.querySelector('.wrapper')
// 按钮
const btn = document.querySelector('#playBtn')
// 判断设备中是否有陀螺仪
if (window.DeviceMotionEvent) {
// 让用户触发陀螺仪的监听事件
btn.addEventListener('click', () => {
//若是ios系统,需要请求用户许可
if (DeviceMotionEvent.requestPermission) {
requestPermission()
} else {
rotate()
}
})
} else {
btn.innerHTML = '您的设备里没有陀螺仪!'
}
//请求用户许可
function requestPermission() {
DeviceMotionEvent.requestPermission()
.then(function (permissionState) {
// granted:用户允许浏览器监听陀螺仪事件
if (permissionState === 'granted') {
rotate()
} else {
btn.innerHTML = '请允许使用陀螺仪🌹'
}
}).catch(function (err) {
btn.innerHTML = '请求失败!'
});
}
//监听陀螺仪
function rotate() {
wrapper.style.display = 'none'
window.addEventListener('deviceorientation', ({ alpha, beta, gamma }) => {
const rad = Math.PI / 180
const euler = new Euler(
beta * rad,
alpha * rad,
-gamma * rad,
'YXZ'
)
camera.position.copy(
eye.clone().applyEuler(euler)
)
orbit.updateCamera()
orbit.resetSpherical()
})
}
关于陀螺仪的基本用法我们就说到这。
我之前在网上看了一些陀螺仪相关的教程,很多都没说到点上,因为若是不知道欧拉旋转的概念,就说不明白陀螺仪。
所以,我们一定不要舍不得花时间学习图形学,图形学关系着我们自身发展的潜力。
接下来我们可以在VR.html 文件里,以同样的原理把陀螺仪写进去,并结合项目的实际需求做一下优化。
4-VR+陀螺仪
我们可以先把陀螺仪封装一下,以后用起来方便。
1.封装一个陀螺仪对象Gyro.js
import {Euler} from 'https://unpkg.com/three/build/three.module.js';
const rad = Math.PI / 180
const defAttr = () => ({
//用于触发事件的按钮
btn: null,
//没有陀螺仪
noDevice: () => { },
//当点击按钮时
onClick: () => { },
//可以使用陀螺仪时触发一次
init: () => { },
//用户拒绝开启陀螺仪
reject: () => { },
//请求失败
error: () => { },
//陀螺仪变换
change: () => { },
})
export default class Gyro {
constructor(attr) {
Object.assign(this, defAttr(), attr)
}
start() {
const { btn } = this
if (window.DeviceMotionEvent) {
// 让用户触发陀螺仪的监听事件
btn.addEventListener('click', () => {
this.onClick()
//若系统是ios,需要请求用户许可
if (DeviceMotionEvent.requestPermission) {
this.requestPermission()
} else {
this.translate()
}
})
} else {
this.noDevice()
}
}
//请求用户许可
requestPermission() {
DeviceMotionEvent.requestPermission()
.then((permissionState) => {
// granted:用户允许浏览器监听陀螺仪事件
if (permissionState === 'granted') {
this.translate()
} else {
this.reject()
}
}).catch((err) => {
this.error(err)
});
}
// 监听陀螺仪
translate() {
this.init()
window.addEventListener('deviceorientation', ({ beta, alpha, gamma }) => {
this.change(new Euler(
beta * rad,
alpha * rad,
-gamma * rad,
'YXZ'
))
})
}
}
2.把陀螺仪对象引入VR文件
import Gyro from './lv/Gyro.js'
// 遮罩
const wrapper = document.querySelector('.wrapper')
// 按钮
const btn = document.querySelector('#playBtn')
// 陀螺仪
const gyro = new Gyro({
btn,
noDevice: () => {
btn.innerHTML = '您的设备里没有陀螺仪!'
},
reject: () => {
btn.innerHTML = '请允许使用陀螺仪🌹'
},
error: () => {
btn.innerHTML = '请求失败!'
},
init: () => {
wrapper.style.display = 'none'
},
change: (euler) => {
camera.position.copy(
eye.clone().applyEuler(euler)
)
orbit.updateCamera()
orbit.resetSpherical()
}
})
gyro.start()
3.优化图像的加载。
在实际开发中,为了让用户看到比较清晰的效果,往往需要使用比较大的全景图,比如4096*2048 的尺寸。
大尺寸的图片加载起来会很慢,为了减少用户的等待,我们可以先加载一个较小的图片,然后慢慢的过度到大图。
比如,我们可以从小到大准备4张不同尺寸的全景图:
- 512*256
- 1024*512
- 2048*1024
- 4096*2048
接下来先加载第一张小图,将其显示出来后,再依次加载后面的大图。
//图片序号
let level = 0
//加载图片
loadImg()
function loadImg() {
const image = new Image()
image.src = `./images/room${level}.jpg`
image.onload = function () {
if (level === 0) {
firstRender(image)
} else {
//更新贴图
matEarth.setMap('u_Sampler', { image })
}
if (level < 3) {
level++
loadImg()
}
}
}
// 第一次渲染
function firstRender(image) {
btn.innerHTML = '开启VR之旅'
matEarth.maps.u_Sampler = {
image,
magFilte: gl.LINEAR,
minFilter: gl.LINEAR,
}
scene.add(new Obj3D({
geo: geoEarth,
mat: matEarth
}))
render()
}
与此同时,我们还得微调一下Mat.js里的更新贴图方法:
updateMap(gl,map,ind) {
……
//gl.bindTexture(gl.TEXTURE_2D, null)
}
我们需要取消对纹理缓冲区的清理。
以前我们要清理纹理缓冲区,是因为我们不需要对纹理缓冲区里的纹理对象进行更新,将其清理掉还可以节约内存。
而现在我们需要对纹理缓冲区里的纹理对象进行更新,那就不能清理掉了。
5-开场动画
开场动画的作用,就是给用户一个吸引眼球的效果,提高项目的趣味性。
开场动画的开场方式有很多,咱们这里就说一个比较常见的:从上帝视角到普通视角的过度。
上帝视角就是一个俯视的视角,视野一定要广,如下图:
之后,我会用补间动画,将其过度到普通视角,如下图:
从上帝视角到普通视角的变换涉及以下属性:
- 相机视点的位置
- 相机视椎体的垂直视角
接下来我们便可以基于上面的属性做缓动动画。
1.把当前的相机视角调为上帝视角。
// 目标点
const target = new Vector3()
//视点-根据陀螺仪做欧拉旋转
const eye = new Vector3( 0.15,0, 0.0001)
// 透视相机
const [fov, aspect, near, far] = [
130, canvas.width / canvas.height,
0.01, 2
]
const camera = new PerspectiveCamera(fov, aspect, near, far)
// 上帝视角
camera.position.set(0, 0.42, 0)
2.基于相机的视点和视椎体的垂直夹角建立两个目标变量
const endPos = camera.position.clone()
let endFov = fov
上面的两个目标变量默认是和当前相机一致的,之后陀螺仪发生变化时会对其做修改。
3.在陀螺仪发生变化时,设置目标变量
// 陀螺仪
const gyro = new Gyro({
……
change: (euler) => {
endFov = 60
endPos.copy(
eye.clone().applyEuler(euler)
)
}
})
当前的开场动画是针对有陀螺仪的手机而言的,接下来再做对PC端的开场动画。
4.当鼠标点击“开启VR之旅” 的时候,若浏览器在PC端,将视角调为普通视角。
const pc = isPC()
const gyro = new Gyro({
……
onClick: () => {
if (pc) {
endPos.set(0.15, 0, 0.0001)
endFov = 60
}
}
})
isPC() 是判断浏览器是否在PC端的方法。
const isPC=()=>!navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i)
5.建立缓动动画方法
function tween(ratio = 0.05) {
//若当前设备为PC,缓动结束后就不再缓动,之后的变换交给轨道控制器
if (pc && camera.fov < endFov + 1) { return }
camera.position.lerp(endPos, ratio)
camera.fov += (endFov - camera.fov) * ratio
camera.updateProjectionMatrix()
orbit.updateCamera()
orbit.resetSpherical()
}
-
camera.updateProjectionMatrix() 更新投影矩阵。
因为上面更新了视椎体的垂直夹角,所以相机的投影矩阵也要做同步的更新,具体原理参见之前的透视投影矩阵。
-
orbit.updateCamera() 更新相机,将相机的视点位置和视线写入相机的本地矩阵里。
-
orbit.resetSpherical() 重置球坐标。用鼠标旋转相机的时候会将旋转信息写入球坐标。
6.在连续渲染时执行缓动动画
function render() {
tween()
orbit.getPvMatrix()
scene.draw()
requestAnimationFrame(render)
}
6-添加标记点
标记点可以告诉用户不同区域的名称,或者为用户指引方向。
在添加标记点的时候,我们要考虑两点:
- 如何添加标记点
- 如何制作标记点
接下来我会在VR 场景中添加HTML类型的标记点,这种方法是比较常见的。
在VR中添加HTML类型标记点的核心问题是:如何让HTML 标记点随VR 场景同步变换。
解决这个问题要按以下几步走:
1.鼠标点击canvas 画布时,将此点的canvas坐标转世界坐标A。
注:canvas坐标转世界坐标的原理在“进入三维世界”的选择立方体里说过。
2.以视点为起点,A点位方向做射线EA。
3.求射线EA与球体的交点P,此点便是标记点在世界坐标系内的世界坐标。
4.在变换VR场景时,将标记点的世界坐标转canvas坐标,然后用此坐标更新标记点的位置。
在上面的步骤中,第3步是关键,我们详细讲解以下。
已知:
- 射线 EA
- 球体球心为O,半径为 r
求:射线 EA与球体的交点P
解:
先判断射线的基线与球体的关系。
设:EA的单位向量为v
用EO叉乘EA的单位向量,求得球心O 到直线的距离|OB|
|OB|=|EO^v|
基于|OB|和半径r,可以知道基线与球体的关系:
- |OB|>r,直线与球体相离,没有交点
- |OB|=r,直线与球体相切,1个交点 ,交点为B点
B=v*(EO·v)
- |OB|<r,直线与球体相交,2个交点,其算法如下:
在直线EA上的点可以写做:
E+λv, λ∈R
直线和球体相交的点满足:
(E+λv-O)²=r²
(λv+OE)²可理解为向量OP的长度的平方。
E-O可写做OE:
(λv+OE)²=r²
展开上式:
λ²v²+2λv·OE+OE²=r²
因为:单位向量与其自身的点积等于1
所以:
λ²+2λv·OE+OE²=r²
λ²+2λv·OE+OE²-r²=0
解一元二次方程式,求λ:
为了方便书写,设:
b=2v·OE
c=OE²-r²
则:
λ²+λb+c=0
λ²+bλ+(b/2)²=-c+(b/2)²
(λ+b/2)²=(b²-4c)/4
λ+b/2=±sqrt(b²-4c)/2
λ=(-b±sqrt(b²-4c))/2
知道了λ ,也就可以直线与球体的交点。
λv+OE
注:当λ小于0时,交点在射线EA的反方向,应该舍弃。
关于射线与球体的交点,咱们就说到这。
接下来咱们基于之前的VR.html 代码,在VR球体上打一个标记点。
1.建立一个标记点。当前先不考虑标记点的文字内容的编辑,只关注标记点的位置。
<style>
#mark {
position: absolute;
top: 0;
left: 0;
color: #fff;
background-color: rgba(0, 0, 0, 0.6);
padding: 6px 12px;
border-radius: 3px;
user-select: none;
}
</style>
<div id="mark">标记点</div>
2.获取标记点,建立markWp变量,用于记录标记点的世界坐标。
// 标记
const mark = document.querySelector('#mark')
// 标记点的世界位
let markWp = null
3.当鼠标双击canvas 画布的时候,添加标记点。
canvas.addEventListener('dblclick', event => {
addMark(event)
})
addMark() 方法做了3件事情:
-
worldPos() 把鼠标点击在canvas画布上的canvas坐标转换为世界坐标.
此方法咱们之前写过,从Utils.js 中引入即可。
-
getMarkWp() 根据鼠标点的世界坐标设置标记点的世界坐标。
这便是参照之前射线和球体交点的数学公式来写的。
注:鼠标点的世界坐标并不是标记点的世界坐标。
-
setMarkCp() 设置标记点的canvas坐标位。
function addMark(event) {
//鼠标点的世界坐标
const A = worldPos(event, canvas, pvMatrix)
//获取标记点的世界坐标
markWp =getMarkWp(camera.position, A, target, earth.r)
//设置标记点的canvas坐标位
setMarkCp(event.clientX, event.clientY)
}
/* 获取射线和球体的交点
E 射线起点-视点
A 射线目标点
O 球心
r 半径
*/
function getMarkWp(E, A, O, r) {
const v = A.clone().sub(E).normalize()
const OE = E.clone().sub(O)
//b=2v·OE
const b = v.clone().multiplyScalar(2).dot(OE)
//c=OE²-r²
const c = OE.clone().dot(OE) - r * r
//λ=(-b±sqrt(b²-4c))/2
const lambda = (-b + Math.sqrt(b * b - 4 * c)) / 2
//λv+OE
return v.clone().multiplyScalar(lambda).add(OE)
}
//设置标记点的canvas坐标位
function setMarkCp(x, y) {
mark.style.left = `${x}px`
mark.style.top = `${y}px`
}
4.当旋转和缩放相机的时候,对标记点进行同步变换。
canvas.addEventListener('pointermove', event => {
orbit.pointermove(event)
updateMarkCp()
})
canvas.addEventListener('wheel', event => {
orbit.wheel(event, 'OrthographicCamera')
updateMarkCp()
})
//更新标记点的位置
function updateMarkCp() {
if (!markWp) { return }
//判断标记点在相机的正面还是背面
const {position}=camera
const dot = markWp.clone().sub(position).dot(
target.clone().sub(position)
)
if (dot > 0) {
mark.style.display = 'block'
} else {
mark.style.display = 'none'
}
// 将标记点的世界坐标转裁剪坐标
const { x, y } = markWp.clone().applyMatrix4(pvMatrix)
// 将标记点的裁剪坐标转canvas坐标
setMarkCp(
(x + 1) * canvas.width / 2,
(-y + 1) * canvas.height / 2
)
}
之后围绕标记点还可以再进一步优化:
- 使标记点的文字内容可编辑
- 优化标记点样式
- 使标记点可拖拽
- 添加多个标记点
- ……
这些都是正常的前端业务逻辑,我这里就只重点说图形学相关的知识了。
之后大家可以参考一个叫“720云”的网站,它就是专业做VR的。
7-VR 场景的切换
在实际开发中我们通常会遇到这样的需求:
在客厅的VR场景中有一扇进入卧室的门,在卧室门上有一个写着“卧室”的标记点。
当我们点击“卧室”标记点时,就进入卧室的VR 中。
这个需求便涉及了客厅和卧室两个VR场景的切换。
两个VR场景的切换最简单的实现方法就是直接换贴图了,这个方法快速、简单、直接,所以咱们先用代码写一下这个功能。
1.准备一份VR数据,我把它放进了vr.json 文件里,这就相当于后端数据库里的数据了。
[ { "id": 1, "imgSrc": "./https://blog-st.oss-cn-beijing.aliyuncs.com/16406884365522771800455335651.jpg", "eye": [-0.14966274559865525, -0.009630159419482085, 0.002884893313037499],
"marks": [
{
"name": "次卧",
"pos": [-0.45099085840209097, 0.0889607157340315, 0.19670596506927274],
"link": 2
},
{
"name": "主卧",
"pos": [-0.34961792927865026, 0.30943492493218633, -0.17893387258739163],
"link": 3
}
]
},
{
"id": 2,
"imgSrc": "./images/secBed.jpg",
"eye": [-0.14966274559865525, -0.009630159419482085, 0.002884893313037499],
"marks": [
{
"name": "客厅",
"pos": [-0.34819482247111166, 0.29666506812630905, -0.20186679508508473],
"link": 1
}
]
},
{
"id": 3,
"imgSrc": "./images/mainBed.jpg",
"eye": [-0.14966274559865525, -0.009630159419482085, 0.002884893313037499],
"marks": [
{
"name": "客厅",
"pos": [-0.07077938553590507, 0.14593627464082626, -0.47296181910077806],
"link": 1
}
]
}
]
当前这个json 文件里有3个VR 场景的数据,分别是客厅、主卧、次卧。
-
imgSrc VR图片
-
eye 相机视点
-
marks 标记点
- name 标记点名称
- pos 标记点世界位,可在上一节添加标记点的时候,将其存储到后端
- link 当前标记点链接的VR 的id
2.基于之前添加标记点文件,做下调整,建立一个标记点容器marks,之后会往marks里放html类型的标记点。
<style>
.mark {
position: absolute;
transform: translate(-50%, -50%);
top: 0;
left: 0;
color: #fff;
background-color: rgba(0, 0, 0, 0.6);
padding: 6px 12px;
border-radius: 3px;
user-select: none;
cursor: pointer;
}
</style>
<body>
<canvas id="canvas"></canvas>
<div id="marks"></div>
……
</body>
3.简化出一个VR场景。
import { createProgram, worldPos } from "/jsm/Utils.js";
import {
Matrix4, PerspectiveCamera, Vector3
} from 'https://unpkg.com/three/build/three.module.js';
import OrbitControls from './lv/OrbitControls.js'
import Mat from './lv/Mat.js'
import Geo from './lv/Geo.js'
import Obj3D from './lv/Obj3D.js'
import Scene from './lv/Scene.js'
import Earth from './lv/Earth.js'
const canvas = document.getElementById('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
let gl = canvas.getContext('webgl');
// 球体
const earth = new Earth(0.5, 64, 32)
// 目标点
const target = new Vector3()
const [fov, aspect, near, far] = [
60, canvas.width / canvas.height,
0.1, 5
]
// 透视相机
const camera = new PerspectiveCamera(fov, aspect, near, far)
// 轨道控制器
const orbit = new OrbitControls({
camera,
target,
dom: canvas,
enablePan: false,
maxZoom: 15,
minZoom: 0.4
})
//投影视图矩阵
const pvMatrix = orbit.getPvMatrix()
//标记
const marks = document.querySelector('#marks')
// 场景
const scene = new Scene({ gl })
//注册程序对象
scene.registerProgram(
'map',
{
program: createProgram(
gl,
document.getElementById('vs').innerText,
document.getElementById('fs').innerText,
),
attributeNames: ['a_Position', 'a_Pin'],
uniformNames: ['u_PvMatrix', 'u_ModelMatrix', 'u_Sampler']
}
)
//球体
const mat = new Mat({
program: 'map',
data: {
u_PvMatrix: {
value: orbit.getPvMatrix().elements,
type: 'uniformMatrix4fv',
},
u_ModelMatrix: {
value: new Matrix4().elements,
type: 'uniformMatrix4fv',
},
},
maps: {
u_Sampler: {
magFilter: gl.LINEAR,
minFilter: gl.LINEAR,
}
}
})
const geo = new Geo({
data: {
a_Position: {
array: earth.vertices,
size: 3
},
a_Pin: {
array: earth.uv,
size: 2
}
},
index: {
array: earth.indexes
}
})
scene.add(new Obj3D({ geo, mat }))
// 渲染
render()
function render() {
orbit.getPvMatrix()
scene.draw()
requestAnimationFrame(render)
}
/* 取消右击菜单的显示 */
canvas.addEventListener('contextmenu', event => {
event.preventDefault()
})
/* 指针按下时,设置拖拽起始位,获取轨道控制器状态。 */
canvas.addEventListener('pointerdown', event => {
orbit.pointerdown(event)
})
/* 指针移动时,若控制器处于平移状态,平移相机;若控制器处于旋转状态,旋转相机。 */
canvas.addEventListener('pointermove', event => {
orbit.pointermove(event)
updateMarkCp()
})
/* 指针抬起 */
canvas.addEventListener('pointerup', event => {
orbit.pointerup(event)
})
/* 滚轮事件 */
canvas.addEventListener('wheel', event => {
orbit.wheel(event, 'OrthographicCamera')
updateMarkCp()
})
当前是渲染不出东西来的,因为我还没有给球体指定贴图。
3.请求VR 数据,更新VR。
let data;
let curVr;
fetch('./data/vr.json')
.then((res) => res.json())
.then(dt => {
data = dt
curVr = getVrById(1)
//更新VR
updateVr()
// 渲染
render()
});
//根据id获取VR数据
function getVrById(id) {
for (let i = 0; i < data.length; i++) {
if (id === data[i].id) {
return data[i]
}
}
}
//根据数据更新VR
function updateVr() {
const image = new Image()
image.src = curVr.imgSrc
image.onload = function () {
//更新图片
mat.setMap('u_Sampler', { image })
//更新相机视点
camera.position.set(...curVr.eye)
orbit.updateCamera()
orbit.resetSpherical()
//显示标记点
showMark()
}
}
//显示标记点
function showMark() {
curVr.marks.forEach(ele => {
const div = document.createElement('div')
div.className = 'mark'
div.innerText = ele.name
div.setAttribute('data-link', ele.link)
marks.append(div)
})
}
//更新标记点的canvas坐标位
function updateMarkCp() {
if (!marks.children.length) { return }
const { position } = camera
const EO = target.clone().sub(position)
curVr.marks.forEach((ele, ind) => {
const markWp = new Vector3(...ele.pos)
const mark = marks.children[ind]
const dot = markWp.clone().sub(position).dot(EO)
mark.style.display = dot > 0 ? 'block' : 'none'
const { x, y } = markWp.clone().applyMatrix4(pvMatrix)
mark.style.left = `${(x + 1) * canvas.width / 2}px`
mark.style.top = `${(-y + 1) * canvas.height / 2}px`
})
}
5.点击标记点时,根据标记点的data-link 更新VR
marks.addEventListener('click', ({ target }) => {
if (target.className !== 'mark') { return }
marks.innerHTML = ''
curVr = getVrById(parseInt(target.getAttribute('data-link')))
updateVr()
})
6.连续渲染的时候,更新标记点的canvas坐标位。
function render() {
orbit.getPvMatrix()
scene.draw()
updateMarkCp()
requestAnimationFrame(render)
}
7.把鼠标的位移事件绑定到window上。
当鼠标移动到标记点上时,会被标记点卡住,无法移动,这是因为标记点挡住了canvas,所以不能再把鼠标事件绑定到canvas上了。
window.addEventListener('pointermove', event => {
orbit.pointermove(event)
})
到目前为止,VR 场景切换的基本功能已经搞定了。
然而,老板可能还会让我们给VR一个过度动画,因为别人家的VR 也会有这样的效果。
8-VR场景的过度动画
当前显示的VR就叫它旧VR,接下来要显示的VR就叫它新VR。
这两个VR可以想象成两张图片,旧VR在新VR上面,旧VR遮挡了新VR。
在切换VR的时候,我们可以先实现这样一个过渡效果:让旧VR渐隐,从而露出下面的新VR。
帧缓冲区便可以视之为存储在内存里的图片。
我们将两个VR场景分别渲染到两个帧缓冲区里后,便可以基于透明度融合一下,然后贴到一个充满窗口的平面上,从而实现过度效果。
1.封装个场景对象出来,这个场景里只有一个充满窗口的平面,之后会把帧缓冲区贴上去。
- VRPlane.js
import { createProgram } from "./Utils.js";
import Mat from './Mat.js'
import Geo from './Geo.js'
import Obj3D from './Obj3D.js'
import Scene from './Scene.js'
import Rect from './Rect.js'
const vs = `
attribute vec4 a_Position;
attribute vec2 a_Pin;
varying vec2 v_Pin;
void main(){
gl_Position=a_Position;
v_Pin=a_Pin;
}
`
const fs = `
precision mediump float;
uniform float u_Ratio;
uniform sampler2D u_SampNew;
uniform sampler2D u_SampOld;
varying vec2 v_Pin;
void main(){
vec4 t1 = texture2D( u_SampNew, v_Pin );
vec4 t2 = texture2D( u_SampOld, v_Pin );
gl_FragColor = mix(t2,t1, u_Ratio);
}
`
export default class VRPlane extends Scene{
constructor(attr){
super(attr)
this.createModel()
}
createModel() {
const { gl } = this
this.registerProgram('map', {
program: createProgram(gl,vs,fs),
attributeNames: ['a_Position', 'a_Pin'],
uniformNames: ['u_SampNew', 'u_SampOld', 'u_Ratio']
})
const mat = new Mat({
program: 'map',
data: {
u_Ratio: {
value: 0,
type: 'uniform1f',
},
}
})
const rect = new Rect(2, 2, 0.5, 0.5)
const geo = new Geo({
data: {
a_Position: {
array: rect.vertices,
size: 3
},
a_Pin: {
array: rect.uv,
size: 2
}
},
index: {
array: rect.indexes
}
})
this.add(new Obj3D({ geo, mat }))
this.mat=mat
}
}
2.封装一个包含VR场景的帧缓冲区对象。
- VRFrame.js
import { createProgram } from "./Utils.js";
import {Matrix4} from 'https://unpkg.com/three/build/three.module.js';
import Mat from './Mat.js'
import Geo from './Geo.js'
import Obj3D from './Obj3D.js'
import Earth from './Earth.js'
import Frame from './Frame.js'
const vs = `
attribute vec4 a_Position;
attribute vec2 a_Pin;
uniform mat4 u_PvMatrix;
uniform mat4 u_ModelMatrix;
varying vec2 v_Pin;
void main(){
gl_Position=u_PvMatrix*u_ModelMatrix*a_Position;
v_Pin=a_Pin;
}
`
const fs = `
precision mediump float;
uniform sampler2D u_Sampler;
varying vec2 v_Pin;
void main(){
gl_FragColor=texture2D(u_Sampler,v_Pin);
}
`
/* 参数
gl,
orbit,
*/
export default class VRFrame extends Frame{
constructor(attr){
super(attr)
this.createModel()
}
createModel() {
const { orbit, gl } = this
this.registerProgram('map', {
program: createProgram(gl,vs,fs),
attributeNames: ['a_Position', 'a_Pin'],
uniformNames: ['u_PvMatrix', 'u_ModelMatrix', 'u_Sampler']
})
const mat = new Mat({
program: 'map',
data: {
u_PvMatrix: {
value: orbit.getPvMatrix().elements,
type: 'uniformMatrix4fv',
},
u_ModelMatrix: {
value: new Matrix4().elements,
type: 'uniformMatrix4fv',
},
},
maps: {
u_Sampler: {
magFilter: gl.LINEAR,
minFilter: gl.LINEAR,
}
}
})
const earth = new Earth(0.5, 64, 32)
const geo = new Geo({
data: {
a_Position: {
array: earth.vertices,
size: 3
},
a_Pin: {
array: earth.uv,
size: 2
}
},
index: {
array: earth.indexes
}
})
this.add(new Obj3D({ geo, mat }))
this.draw()
this.mat=mat
}
}
3.基于之前的场景切换文件修改代码,引入组件。
import {
Matrix4, PerspectiveCamera, Vector3
} from 'https://unpkg.com/three/build/three.module.js';
import OrbitControls from './lv/OrbitControls.js'
import VRFrame from './lv/VRFrame.js';
import VRPlane from './lv/VRPlane.js';
import Track from '/jsm/Track.js';
4.开启透明度。
let gl = canvas.getContext('webgl');
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
5.实例化一个场景对象,两个帧缓冲区。
const scene = new VRPlane({ gl })
const vrNew = new VRFrame({ gl, orbit })
const vrOld = new VRFrame({ gl, orbit })
scene.mat.setMap('u_SampOld', {
texture: vrOld.texture
})
初始状态,旧VR 是没有图片的,默认画出来就是一张黑图,将其传给场景对象的u_SampOld后,便可以实现在黑暗中渐现新VR的效果。
新VR 需要在加载到图片后,画出VR,再传给场景对象的u_SampNew。
6.实例化时间轨对象。
// 是否制作补间动画
let tweenable = false
// 补间数据
let aniDt = { ratio: 0 }
// 时间轨
let track = new Track(aniDt)
track.timeLen = 1000
track.keyMap = new Map([
['ratio', [[0, 0], [1000, 1]]]
])
track.onEnd = () => {
tweenable = false
}
7.在切换图片时:
- 开启补间动画
- 设置新、旧VR的图片
- 把旧VR的纹理对象传入u_SampOld
- 根据VR 数据更新视点位置
- 根据VR 数据显示标记点
//暂存当前的VR图像
let tempImg = null
// 更新VR图像
function updateVr() {
const image = new Image()
image.src = curVr.imgSrc
image.onload = function () {
//开启补间动画
tweenable = true
//时间轨起始时间
track.start = new Date()
//若tempImg 不为null
if (tempImg) {
// 设置旧VR的图片
vrOld.mat.setMap('u_Sampler', { image: tempImg })
vrOld.draw()
// 把旧VR的纹理对象传入u_SampOld
scene.mat.setMap('u_SampOld', {
texture: vrOld.texture
})
}
//暂存当前图片
tempImg = image
//设置新VR的图片
vrNew.mat.setMap('u_Sampler', { image })
//设置相机视点
camera.position.set(...curVr.eye)
orbit.updateCamera()
orbit.resetSpherical()
//显示当前VR的标记点
showMark()
}
}
将之前 Mat.js 对象的setMap 方法做下调整。
setMap(key,val) {
const obj = this.maps[key]
val.needUpdate = true
if (obj) {
Object.assign(obj,val)
} else {
this.maps[key]=val
}
}
这样,若key在maps中存在,就合并val;若不存在,就写入val。
8.连续渲染。
function render() {
if (tweenable) {
// 更新时间轨的时间
track.update(new Date())
// 更新场景对象的插值数据
scene.mat.setData('u_Ratio', {
value: aniDt.ratio
})
}
// 更新投影视图矩阵
orbit.getPvMatrix()
// 新VR绘图
vrNew.draw()
// 更新场景对象的u_SampNew
scene.mat.setMap('u_SampNew', {
texture: vrNew.texture
})
//场景对象绘图
scene.draw()
// 更新标记点的canvas坐标
updateMarkCp()
requestAnimationFrame(render)
}
到目前为止,我们便可以实现VR场景的透明度补间动画。
接下来,我们还可以再丰富一下补间动画效果。
9.在透明度动画的基础上,再让旧VR做一个放大效果,给用户营造一个拉进视口的效果,使项目看起来更加走心。
precision mediump float;
uniform float u_Ratio;
uniform sampler2D u_SampNew;
uniform sampler2D u_SampOld;
varying vec2 v_Pin;
void main(){
vec2 halfuv=vec2(0.5,0.5);
float scale=1.0-u_Ratio*0.1;
vec2 pin=(v_Pin-halfuv)*scale+halfuv;
vec4 t1 = texture2D( u_SampNew, v_Pin );
vec4 t2 = texture2D( u_SampOld, pin );
gl_FragColor = mix(t2,t1, u_Ratio);
}
放大旧VR的方法有很多,可以从模型、相机、片元着色器等方面来实现。
我这里就找了个比较简单的方法,在片元着色器里放大旧VR。
在片元着色器里放大旧VR 的方法,至少有两种:
- 基于片元放大旧VR
- 基于UV放大旧VR
基于片元放大旧VR,需要在片元着色器里知道canvas画布在gl_FragCoord 坐标系里的中心点,有点麻烦。
基于UV放大旧VR,直接基于uv 坐标系的中心点(0.5,0.5) 缩小uv坐标即可,比较简单,所以我就使用这个方法放大旧VR了。
到目前为止,VR相关的核心原理算是告一段落了。
用过原生webgl 做项目,大家可以发现一个好处,只要底子扎实,就不会畏惧各种新需求、新功能,因为你会拥有更多、更自由、更灵活的选择,从而找到问题最优的解。