使用 three.js 实现全景图 VR 预览功能
近期工作上遇到一个需求,需要实现 VR 全景图的功能。经过调研,发现可以使用 photo-sphere-viewer-4 来实现。不过最后使用的时候发现和需求还是有一部分不符的地方。因此还是选回来用 three.js 来实现这个功能:
安装 three.js 和 tween.js
全景图涉及到漫游时的动画过度,这里选用了 tween.js 来实现
首先是安装这两个库
js-nolint
npm install three
npm install tween.js引用
js
import * as THREE from "three";
import TWEEN from 'tween.js';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";变量定义
这里有一个要点,three.js 相关的参数最好在不要在 vue 的 data 中定义,要不然会卡顿
js
import * as THREE from "three";
import TWEEN from 'tween.js';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 定义场景
const scene = new THREE.Scene();
// three的控制器必须放在data外,否则会造成卡顿的问题
var controls;
var camera;
var renderer;
var raycaster;
var tween;
let currentTargetIndex = 0;
var mouse = new THREE.Vector2();
var sphere, sphereTexture;
// 监控点标记组,方便统一管理
var cameraGroup = new THREE.Group();
// 漫游标记组,方便统一管理
var roamGroup = new THREE.Group();
// 文字标题组,方便统一管理
var textGroup = new THREE.Group();
export default {
name: "three-model",
}完整代码
这里分别监听了鼠标的左键和右键,分别实现了点击漫游、加点和右键菜单功能。还实现了普通标记点开打开图像以及小地图的功能。
html
<template>
<div class="three-box">
<div class="three-model" id="container"></div>
<div class="scene-box" @click="isShow = !isShow">场景</div>
<div class="scene-list" v-if="isShow">
<div class="scene-item" v-for="(item, index) in dataList" :key="index" @click="toChange(item)">{{ item.name }}
</div>
</div>
<div class="thumb-box">
<img :src="thumbImg" alt="">
<img id="thumb-point" class="thumb-point" :src="pointImg" :style="{ 'top': thumby, 'left': thumbx }" alt="">
</div>
<div v-if="showMenu" class="menu-box" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }">
<div class="menu-item" @click.stop="deletePoint">删除</div>
</div>
<el-image-viewer v-if="dialogVisible" :url-list="previewList" hide-on-click-modal teleported :on-close="handleClose"
class="my-image-viewer"></el-image-viewer>
</div>
</template>
<script>
import * as THREE from "three";
import TWEEN from 'tween.js';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import ElImageViewer from 'element-ui/packages/image/src/image-viewer'
// 定义场景
const scene = new THREE.Scene();
// three的控制器必须放在data外,否则会造成卡顿的问题
var controls;
var camera;
var renderer;
var raycaster;
var tween;
let currentTargetIndex = 0;
var mouse = new THREE.Vector2();
var sphere, sphereTexture;
// 监控点标记组,方便统一管理
var cameraGroup = new THREE.Group();
// 漫游标记组,方便统一管理
var roamGroup = new THREE.Group();
// 文字标题组,方便统一管理
var textGroup = new THREE.Group();
// 标注标题组,方便统一管理
var labelGroup = new THREE.Group();
export default {
name: "three-model",
components: { ElImageViewer },
data() {
return {
publicPath: process.env.BASE_URL,
// 监控点图片
camera: require('@/assets/camera.png'),
// 漫游标记图片
roamImg: require('@/assets/logo.png'),
// demo数据
dataList: [
{
id: '1', name: '大厅', url: require('@/assets/background.jpg'),
thumbx: '10%',
thumby: '20%',
// lookat:
link: [{ id: '2', name: '公路', type: 'link', url: require('@/assets/back.jpg'), x: -4, y: -1, z: -2, }],
marker: [{ id: '6', name: '大厅摄像头', type: 'marker', x: 2, y: 1, z: 3 }],
label: [{ id: '10', name: '柜子', type: 'label', x: 4, y: 0, z: -2, url: require('@/assets/guizi.jpg') }]
},
{
id: '2', name: '公路', url: require('@/assets/back.jpg'),
thumbx: '10%',
thumby: '30%',
link: [{ id: '1', name: '大厅', type: 'link', url: require('@/assets/background.jpg'), x: 4, y: 1, z: 2, }, { id: '3', name: '房间', type: 'link', url: require('@/assets/back1.jpg'), x: 1, y: -1, z: 4, }], marker: [{ id: '6', name: '大厅摄像头2号', type: 'marker', x: 1, y: 1, z: 3 }]
},
{
id: '3', name: '房间', url: require('@/assets/back1.jpg'), link: [], marker: [],
thumbx: '30%',
thumby: '60%',
},
],
isShow: false,
isClickCamera: false,
// 菜单
showMenu: false,
menuTop: 30,
menuLeft: 30,
spriteObj: null,
// 图片预览
dialogVisible: false,
previewList: [],
//缩略图参数
thumbImg: require('@/assets/thumb.png'),
pointImg: require('@/assets/point.png'),
thumbx: '10%',
thumby: '20%',
thumbRotate: '0'
};
},
created() { },
mounted() {
this.$nextTick(() => {
this.init(this.dataList[0]);
});
},
methods: {
init(row) {
this.createScene(); // 创建场景
this.createModel(row); // 导入模型
this.createPoint(row);
this.createLight(); // 创建光源
this.createCamera(); // 创建相机
this.createRender(); // 创建渲染器
this.createControls(); // 创建控件对象
raycaster = new THREE.Raycaster();
this.render();
// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
// 绑定点击事件
window.addEventListener('click', e => {
this.onClick(e)
this.showMenu = false;
if (this.isClickCamera) {
this.toAddCamera(e)
}
});
window.addEventListener(
'contextmenu',
(event) => {
this.rightClickEvent(event)
},
false
)
},
createScene() {
scene.background = new THREE.Color("#172333");
},
// 创建全景图背景
createModel(row) {
let sphere_geometry = new THREE.SphereGeometry(5, 64, 64)
sphereTexture = new THREE.TextureLoader().load(row.url, (tex) => {
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.generateMipmaps = true;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
})
sphereTexture.needsUpdate = true
let sphere_material = new THREE.MeshStandardMaterial({ map: sphereTexture })
sphere_geometry.scale(1, 1, -1);
sphere = new THREE.Mesh(sphere_geometry, sphere_material)
scene.add(sphere)
},
// 创建监控点和漫游标记以及物品标记
createPoint(row) {
let roamList = row.link;
let markerList = row.marker;
let labelList = row.label;
const texLoader = new THREE.TextureLoader()
const texture = texLoader.load(require("@/assets/roam.png"))
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true, //开启透明(纹理图片png有透明信息),
})
const texLoader1 = new THREE.TextureLoader()
const texture1 = texLoader1.load(require("@/assets/camera.png"))
const spriteMaterial1 = new THREE.SpriteMaterial({
map: texture1,
transparent: true, //开启透明(纹理图片png有透明信息),
})
const texLoader2 = new THREE.TextureLoader()
const texture2 = texLoader2.load(require("@/assets/label.png"))
const spriteMaterial2 = new THREE.SpriteMaterial({
map: texture2,
transparent: true, //开启透明(纹理图片png有透明信息),
})
// 漫游标记
roamList?.map(v => {
let sprite = new THREE.Sprite(spriteMaterial)
// sprite.id = v.id
sprite.scale.set(.5, .5, .5)
sprite.position.set(v.x, v.y, v.z)
sprite.userData = v;
roamGroup.add(sprite)
this.createTextSprite(v.name, v.x, v.y, v.z, 'rgba(0, 0, 0, 0.5)', 'white', 24)
})
// 监控点标记
markerList?.map(v => {
let sprite = new THREE.Sprite(spriteMaterial1)
// sprite.id = v.id
sprite.scale.set(1, 1, 1)
sprite.position.set(v.x, v.y, v.z)
sprite.userData = v;
cameraGroup.add(sprite)
this.createTextSprite(v.name, v.x, v.y, v.z, 'rgba(0, 0, 0, 0.5)', 'white', 24)
})
labelList?.map(v => {
let sprite = new THREE.Sprite(spriteMaterial2)
// sprite.id = v.id
sprite.scale.set(.5, .5, .5)
sprite.position.set(v.x, v.y, v.z)
sprite.userData = v;
labelGroup.add(sprite)
this.createTextSprite(v.name, v.x, v.y, v.z, 'rgba(0, 0, 0, 0.5)', 'white', 24)
})
scene.add(roamGroup)
scene.add(cameraGroup)
scene.add(textGroup)
scene.add(labelGroup)
},
// 创建灯光
createLight() {
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 2); // 创建环境光
scene.add(ambientLight); // 将环境光添加到场景
},
// 创建相机
createCamera() {
const element = document.getElementById("container");
const width = element.offsetWidth; // 窗口宽度
const height = element.offsetHeight; // 窗口高度
const k = width / height; // 窗口宽高比
camera = new THREE.PerspectiveCamera(75, k, 0.1, 1000);
camera.position.z = 3
scene.add(camera);
},
// 创建渲染器
createRender() {
const element = document.getElementById("container");
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance"
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(element.clientWidth, element.clientHeight); // 设置渲染区域尺寸
element.appendChild(renderer.domElement);
},
// 创建控制器
createControls() {
controls = new OrbitControls(camera, renderer.domElement);
// 初始控制器配置
controls.enableDamping = true; // 启用阻尼效果
// controls.dampingFactor = 0.05;
controls.minDistance = 1; // 最小缩放距离(球体半径2 + 安全距离1)
controls.maxDistance = 5; // 最大缩放距离
},
// 渲染
render() {
requestAnimationFrame(this.render);
TWEEN.update();
renderer.render(scene, camera);
controls.update();
this.getCamerPosition();
},
// 监听点击事件
onClick(event) {
// 将鼠标点击位置标准化到 [-1, 1]
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 更新射线并计算交点
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
// 检查是否有 Sprite 被点击
for (const intersect of intersects) {
if (intersect.object instanceof THREE.Sprite) {
const spriteData = intersect.object.userData;
this.toAction(spriteData)
break; // 找到第一个后退出循环
}
}
},
// 根据 type 来判断接下来的操作
toAction(row) {
// 如果是漫游 link 则切换背景图并重新渲染
if (row.type == 'link') {
currentTargetIndex = 0;
this.switchCameraTarget(row);
// return;
setTimeout(() => {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(
row.url, // 新纹理路径
(newTexture) => {
// 替换材质纹理并更新
sphere.material.map = newTexture;
sphere.material.needsUpdate = true; // 关键步骤!
},
undefined,
(err) => console.error('纹理加载失败:', err)
);
while (cameraGroup.children.length > 0) {
const child = cameraGroup.children[0];
cameraGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
while (roamGroup.children.length > 0) {
const child = roamGroup.children[0];
roamGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
while (textGroup.children.length > 0) {
const child = textGroup.children[0];
textGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
while (labelGroup.children.length > 0) {
const child = labelGroup.children[0];
labelGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
let obj = this.dataList?.find((item) => item.id == row.id)
this.thumbx = obj.thumbx;
this.thumby = obj.thumby;
camera.position.set(0, 0, 3)
this.createPoint(obj)
}, 900)
}
// 如果是监控点,则打开监控点弹窗播放视频
else if (row.type == 'marker') {
console.log('open video')
/*--------------------*/
}
else if (row.type == 'label') {
this.handleOpen(row)
}
},
// 漫游过程的补间动画
switchCameraTarget(row) {
if (TWEEN.getAll().length > 0) return; // 防止重复触发
// 切换目标位置
const targetPos = new THREE.Vector3(row.x / 2, row.y / 2, row.z / 2);
// 创建位置补间动画
new TWEEN.Tween(camera.position)
.to(targetPos, 1000)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => {
camera.lookAt(row.x, row.y, row.z); // 保持注视中心点
})
.start();
},
// 创建文字Sprite的函数
createTextSprite(text, x, y, z, bgColor = 'rgba(0, 0, 0, 0.5)', fontColor = 'white', fontSize) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = `${fontSize}px Arial`;
const textWidth = context.measureText(text).width;
const padding = 10;
const canvasWidth = textWidth + padding * 2;
const canvasHeight = fontSize + padding * 2;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
context.fillStyle = bgColor;
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = fontColor;
context.font = `${fontSize}px Arial`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(text, canvasWidth / 2, canvasHeight / 2);
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(canvasWidth * 0.01, canvasHeight * 0.01, 1);
sprite.position.set(x, y + 0.5, z)
textGroup.add(sprite)
},
// 切换到不同的场景
toChange(row) {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(
row.url, // 新纹理路径
(newTexture) => {
// 替换材质纹理并更新
sphere.material.map = newTexture;
sphere.material.needsUpdate = true; // 关键步骤!
},
undefined,
(err) => console.error('纹理加载失败:', err)
);
while (cameraGroup.children.length > 0) {
const child = cameraGroup.children[0];
cameraGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
while (roamGroup.children.length > 0) {
const child = roamGroup.children[0];
roamGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
while (textGroup.children.length > 0) {
const child = textGroup.children[0];
textGroup.remove(child);
// 可选:释放子对象的几何体和材质资源
if (child.isMesh) {
child.geometry?.dispose();
child.material?.dispose();
}
}
let obj = this.dataList?.find((item) => item.id == row.id)
this.thumbx = obj.thumbx;
this.thumby = obj.thumby;
camera.position.set(0, 0, 3)
this.createPoint(row)
},
toAddCamera(event) {
// 初次添加
var mouse = new THREE.Vector2()
const element = document.getElementById('container')
const width = element.offsetWidth // 窗口宽度
const height = element.offsetHeight // 窗口高度
mouse.x = (event.offsetX / width) * 2 - 1
mouse.y = -(event.offsetY / height) * 2 + 1
// 定义一个射线,位于相机位置
var raycaster = new THREE.Raycaster()
raycaster.setFromCamera(mouse, camera)
// 计算射线与场景中所有物体的交点
var intersects = raycaster.intersectObjects(scene.children, true)
// 如果有交点,则高亮选中的物体
if (intersects.length == 0) return
// 取得第一个交点对应的物体
var selectedObject = intersects[0].object
const texLoader = new THREE.TextureLoader()
const texture = texLoader.load(this.camera)
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true, //开启透明(纹理图片png有透明信息),
})
let sprite = new THREE.Sprite(spriteMaterial)
sprite.scale.set(1, 1, 1)
// sprite.userData = this.recordRow; // 这里recordRow存储监控点信息
// sprite.position.y = mouse.y; //标签底部箭头和空对象标注点重合
let point = intersects[0].point
if (point.x > 0) point.x = point.x - 0.3
else point.x = point.x + 0.3
if (point.y > 0) point.y = point.y - 0.3
else point.y = point.y + 0.3
if (point.z > 0) point.z = point.z - 0.3
else point.z = point.z + 0.3
sprite.position.set(point.x, point.y, point.z)
cameraGroup.add(sprite)
},
// 右键监听
rightClickEvent(event) {
let that = this
var mouse = new THREE.Vector2()
const element = document.getElementById('container')
const width = element.offsetWidth // 窗口宽度
const height = element.offsetHeight // 窗口高度
mouse.x = (event.offsetX / width) * 2 - 1
mouse.y = -(event.offsetY / height) * 2 + 1
// 定义一个射线,位于相机位置
var raycaster = new THREE.Raycaster()
raycaster.setFromCamera(mouse, camera)
// 计算射线与场景中所有物体的交点
var intersects = raycaster.intersectObjects(cameraGroup.children, true)
// 如果有交点,则高亮选中的物体
if (intersects.length == 0) return
// 取得第一个交点对应的物体
var selectedObject = intersects[0].object
that.spriteObj = selectedObject
that.menuLeft = event.offsetX
that.menuTop = event.offsetY
that.showMenu = true
that.$forceUpdate()
},
handleOpen(row) {
this.previewList = [row.url]
this.dialogVisible = true;
},
handleClose() {
this.previewList = []
this.dialogVisible = false;
},
getCamerPosition() {
// 获取相机的旋转四元数
let rotation = camera.quaternion.clone()
// 转换为欧拉角(用rotation取特定轴上的旋转角度)
let euler = new THREE.Euler().setFromQuaternion(rotation, 'YXZ')
// // 获取绕z轴的旋转角度(转换为度)
let currentRotZ = THREE.MathUtils.radToDeg(-euler.y)
// console.log('currentRotZ', currentRotZ)
const bgRoate = document.querySelector('.thumb-point')
bgRoate.style.transform = `rotate(${currentRotZ
? Number(currentRotZ)
: currentRotZ
}deg)`
},
},
};
</script>
<style lang="scss" scoped>
.three-box {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
.three-model {
width: 100%;
height: 100%;
}
.thumb-box {
position: absolute;
top: 50px;
right: 50px;
height: 220px;
display: flex;
justify-content: center;
// height: 250px;
padding: 10px;
z-index: 9999;
background-color: rgba($color: #000000, $alpha: 0.6);
img {
width: auto;
height: 220px;
}
.thumb-point {
width: 20px;
height: 20px;
position: absolute;
}
}
.scene-box {
width: 50px;
height: 50px;
position: absolute;
right: 30px;
bottom: 100px;
background-color: rgba($color: #000000, $alpha: 0.6);
border-radius: 50%;
cursor: pointer;
font-size: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.scene-list {
padding: 0 50px 0 20px;
height: 50px;
position: absolute;
right: 30px;
bottom: 100px;
background-color: rgba($color: #000000, $alpha: 0.6);
border-radius: 25px;
cursor: pointer;
font-size: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: left;
.scene-item {
padding: 0 20px;
}
}
.menu-box {
position: absolute;
width: 60px;
height: 60px;
height: 30px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #fff;
cursor: pointer;
z-index: 9999;
.menu-item {
width: 100%;
height: 30px;
text-align: center;
font-size: 12px;
line-height: 30px;
z-index: 999;
&:hover {
background-color: green;
color: #fff;
}
}
}
}
</style>