Three.js VR全景图(标注偏移、显示不全的问题记录)
最近在项目中利用three.js实现了vr全景的功能,过程中遇到了标注偏移、标注图标显示不全的问题,在此做个记录
1.VR全景实现原理
- 构建three.js基本要素(场景、相机、渲染器)
- 在场景中创建一个球体
- 将相机放置在球体内部,添加轨道控制器(OrbitControls)
- 将全景图贴在球体内部
- 点击获取标注位置,添加标注
- 切换场景
2. 实现过程
- 构建three.js基本要素
// 初始化场景
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x101010);
this.container = document.getElementById(containId)
const clientWidth = this.container.clientWidth
const clientHeight = this.container.clientHeight
// 初始化相机
this.camera = new THREE.PerspectiveCamera(45, clientWidth/clientHeight, 0.1, 1000)
this.camera.position.set(10, 10, 10); //设置相机位置
this.camera.lookAt(this.scene); // 相机看向
// 初始化渲染器
this.renderer = new THREE.WebGLRenderer({antialias: true})
this.renderer.setSize(clientWidth, clientHeight)
// this.renderer.outputEncoding = THREE.sRGBEncoding
this.container.appendChild(this.renderer.domElement)
- 创建球体, 贴图
如何将全景图贴在球体内部 ?
将球体轴负比例缩放或者设置贴图双面可见(建议使用负缩放)
// 创建球体
const sphereGeometry = new THREE.SphereGeometry(16, 50, 50);
sphereGeometry.scale(16, 16, -16);
// 加载全景图
new THREE.TextureLoader().load(this.vrList[0].url, texture => {
this.textureData[vrItem.id] = texture
const sphereMaterial = new THREE.MeshBasicMaterial({
map: texture,
})
// 合并模型
this.sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
this.scene.add(this.sphere);
});
- 获取标注位置
根据点击事件获取屏幕二维坐标,将二维坐标转换成世界坐标,通过Raycaster获取到选中的对象(intersects),intersects[0]对象下的point即为标注的位置,注意全景图不是占满全屏的话需要计算鼠标在全景图DOM中的位置(鼠标位置 - dom与屏幕的距离)
// 监听点击事件
this.container.addEventListener('click', this.onMouseClick.bind(this))
onMouseClick(e) {
e.preventDefault()
const { clientX, clientY } = e
const dom = this.renderer.domElement
// 拿到canvas画布到屏幕的距离
const domRect = dom.getBoundingClientRect()
// 计算标准设备坐标
const x = ((clientX - domRect.left) / dom.clientWidth) * 2 - 1
const y = -((clientY - domRect.top) / dom.clientHeight) * 2 + 1
const vector = new THREE.Vector3(x, y, 0)
// 转世界坐标
const worldVector = vector.unproject(this.camera)
// 射线
const ray = worldVector.sub(this.camera.position).normalize()
// 射线投射对象
const raycaster = new THREE.Raycaster(this.camera.position, ray)
raycaster.camera = this.camera
//返回射线选中的对象 //第一个参数是检测的目标对象 第二个参数是目标对象的子元素
const intersects = raycaster.intersectObjects(this.scene.children)
if (intersects.length > 0) {
console.log('捕获到对象', intersects)
const intersect = intersects[0]
} else {
console.log('没捕获到对象')
}
}
- 添加标注
(tipsList || []).forEach(item => {
const texLoader = new THREE.TextureLoader()
// 加载标注图标
const spriteMap = texLoader.load(item.url)
// 精灵材质
const spriteMaterial = new THREE.SpriteMaterial({
map: spriteMap,
depthTest: false,
})
let sprite = new THREE.Sprite(spriteMaterial)
sprite.scale.set(10, 10, 1)
sprite.position.set(item.x, item.y, item.z)
sprite.content = item
this.tipsGroup.add(sprite)
})
this.scene.add(this.tipsGroup)
- 切换场景
当前场景加载过则不再加载,直接贴图;gsap.to用来实现切换过渡,视觉效果会更好一些
const vrItem = this.vrList[index]
let texture
if(this.textureData[vrItem.id]) {
texture = this.textureData[vrItem.id]
}else {
this.loading.value = true
texture = new THREE.TextureLoader().load(vrItem.url, (map) => {
this.textureData[vrItem.id] = map
this.loading.value = false
});
}
const sphereMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0,
});
this.sphere.material =sphereMaterial
gsap.to(sphereMaterial, { transparent: true, opacity: 1, duration: 2 });
this.camera.updateProjectionMatrix();
if(this.hasTips) {
this.tipsGroup.remove(...this.tipsGroup.children)
this.scene.remove(this.tipsGroup)
this.addTipsSprite(vrItem.tips)
}
3. 问题记录
- 点位偏移问题
造成偏移的问题是标注点位的位置设置有误,一开始直接用了转换后的世界坐标,发现标注位置自己选取的不一样,后面通过学习知道了射线交点的坐标才是拾取的位置
- 标注显示不全
按照个人理解标注显示不全是因为被全景图遮挡了,在网上搜索了好久都没有看到相关的信息,后面通过官网的demo发现了个材质属性depthTest(是否在渲染此材质时启用深度测试),在渲染时候会监测当前像素是否存在叠加,如果存在叠加的情况只绘制第一层像素,该属性默认是开启的,我们把它关闭调即可
- 使用gsap做场景切换时候,标注会消失
由于全景材质设置了transparent:true会影响标注的显示,标注需要每次重新添加到场景之中才能正常显示(再贴图之后添加)
4. 相关代码及使用
vr.js
import * as THREE from 'three';
import gsap from 'gsap';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { ref, nextTick } from 'vue';
export class VR {
containId = null
renderer = null //渲染器
scene = null //场景
light = null //光源
camera = null //相机
controls = null //控制器
container = null
sphere = null
vrList = []
textureData = {}
loading = null
hasTips = true
tipsGroup = null
constructor(containId, loading, hasTips = true) {
this.containId = containId
this.hasTips = hasTips
this.loading = loading
this.loading.value = true
nextTick(() => {
// 初始化场景
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x101010);
this.container = document.getElementById(containId)
const clientWidth = this.container.clientWidth
const clientHeight = this.container.clientHeight
// 初始化相机
this.camera = new THREE.PerspectiveCamera(45, clientWidth/clientHeight, 0.1, 1000)
this.camera.position.set(10, 10, 10); //设置相机位置
this.camera.lookAt(this.scene); // 相机看向
// 初始化渲染器
this.renderer = new THREE.WebGLRenderer({antialias: true})
this.renderer.setSize(clientWidth, clientHeight)
// this.renderer.outputEncoding = THREE.sRGBEncoding
this.container.appendChild(this.renderer.domElement)
// const axisHelper = new THREE.AxesHelper(600); //添加辅助坐标系
// this.scene.add(axisHelper);
// this.initLight()
// 轨道控制器初始化
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.zoomSpeed = 8;
this.controls.minDistance = 1;
this.controls.maxDistance = 100;
if(hasTips) {
this.tipsGroup = new THREE.Group()
this.scene.add(this.tipsGroup)
}
window.addEventListener('resize', this.onResize.bind(this), false);
// 监听点击事件
this.container.addEventListener('click', this.onMouseClick.bind(this))
})
}
/**
* @description: 初始化灯光
* @return {*}
*/
initLight() {
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff)
this.scene.add(ambientLight)
//聚光灯
const pointLight = new THREE.PointLight(0xffffff, 1)
pointLight.position.copy(this.camera.position)
this.scene.add(pointLight)
}
/**
* @description: 初始化Vr列表
* @return {*}
*/
initVrList(vrList) {
this.vrList = vrList
if(this.vrList.length > 0) {
this.initModel()
}else if(this.sphere){
this.scene.remove(this.sphere)
}
}
/**
* @description: 初始化模型
* @return {*}
*/
initModel() {
const vrItem = this.vrList[0]
// 创建球体
const sphereGeometry = new THREE.SphereGeometry(16, 50, 50);
sphereGeometry.scale(16, 16, -16);
// 加载全景图
new THREE.TextureLoader().load(this.vrList[0].url, texture => {
this.textureData[vrItem.id] = texture
const sphereMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0,
})
// 合并模型
this.sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
gsap.to(sphereMaterial, { transparent: true, opacity: 1, duration: 2 });
this.scene.add(this.sphere);
this.loading.value = false
this.addTipsSprite(vrItem.tips)
this.animate()
});
}
/**
* @description: 更换场景
* @return {*}
*/
changeMaterial(index) {
const vrItem = this.vrList[index]
let texture
if(this.textureData[vrItem.id]) {
texture = this.textureData[vrItem.id]
}else {
this.loading.value = true
texture = new THREE.TextureLoader().load(vrItem.url, (map) => {
this.textureData[vrItem.id] = map
this.loading.value = false
});
}
const sphereMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0,
});
this.sphere.material =sphereMaterial
gsap.to(sphereMaterial, { transparent: true, opacity: 1, duration: 2 });
this.camera.updateProjectionMatrix();
if(this.hasTips) {
this.tipsGroup.remove(...this.tipsGroup.children)
this.scene.remove(this.tipsGroup)
this.addTipsSprite(vrItem.tips)
}
}
/**
* @description: 添加标签
* @return {*}
*/
addTipsSprite(tipsList) {
(tipsList || []).forEach(item => {
const texLoader = new THREE.TextureLoader()
// 加载标注图标
const spriteMap = texLoader.load(item.url)
// 精灵材质
const spriteMaterial = new THREE.SpriteMaterial({
map: spriteMap,
depthTest: false,
})
let sprite = new THREE.Sprite(spriteMaterial)
sprite.scale.set(10, 10, 1)
sprite.position.set(item.x, item.y, item.z)
sprite.content = item
this.tipsGroup.add(sprite)
})
this.scene.add(this.tipsGroup)
}
// 点击事件
onMouseClick(e) {
e.preventDefault()
let screenWidth = document.body.clientWidth || document.documentElement.clientWidth
let screenHeight = document.body.clientHeight || document.documentElement.clientHeight
const { clientX, clientY } = e
// 看板做了缩放,需要计算实际的鼠标位置
let finalX = (clientX * 1920) / screenWidth
let finalY = (clientY * 1080) / screenHeight
const dom = this.renderer.domElement
// 拿到canvas画布到屏幕的距离
const domRect = dom.getBoundingClientRect()
// 计算标准设备坐标
const x = ((finalX - domRect.left) / dom.clientWidth) * 2 - 1
const y = -((finalY - domRect.top) / dom.clientHeight) * 2 + 1
const vector = new THREE.Vector3(x, y, 0)
// 转世界坐标
const worldVector = vector.unproject(this.camera)
console.log('点击坐标系', worldVector)
// 射线
const ray = worldVector.sub(this.camera.position).normalize()
// 射线投射对象
const raycaster = new THREE.Raycaster(this.camera.position, ray)
raycaster.camera = this.camera
//返回射线选中的对象 //第一个参数是检测的目标对象 第二个参数是目标对象的子元素
const intersects = raycaster.intersectObjects(this.scene.children)
if (intersects.length > 0) {
console.log('捕获到对象', intersects)
} else {
console.log('没捕获到对象')
}
}
fullScreen() {
let el = document.getElementById(this.containId)
let screen =
el.requestFullScreen ||
el.webkitRequestFullScreen ||
el.mozRequestFullScreen ||
el.msRequestFullScreen
let wscript = null
if (typeof screen != 'undefined' && screen) {
screen.call(el)
}else if (typeof window.ActiveXObject != 'undefined') {
wscript = new ActiveXObject('WScript.Shell')
if (wscript) {
wscript.SendKeys('{F11}')
}
}
setTimeout(() => {
this.onResize()
}, 10)
}
onResize() {
const element = this.container
this.camera.aspect = element.clientWidth / element.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(element.clientWidth, element.clientHeight);
}
animate() {
this.controls.update();
this.renderer.sortObjects = false;
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.animate.bind(this));
}
}
使用
<script setup>
const vrList = ref([
{
name: '高空全景',
id: 1,
thumbnail: '/rcas/town/static/vr-img/th_gk.jpg',
url: '/rcas/town/static/vr-img/gk.jpg',
texture: null,
tips: [
{
name: '音频',
type: 'audio',
url: require('@/assets/images/board/vr-point/point_yp.png'),
x: -93.02943556852131, y: -237.9335735064603, z: 15.35924134181327
},
],
},
{
name: '西正门',
id: 2,
thumbnail: '/rcas/town/static/vr-img/th_xzm.jpg',
url: '/rcas/town/static/vr-img/xzm.jpg',
texture: null,
},
{
name: '北大门',
id: 3,
thumbnail: '/rcas/town/static/vr-img/th_bdm.jpg',
url: '/rcas/town/static/vr-img/bdm.jpg',
texture: null,
},
{
name: '村委楼',
id: 4,
thumbnail: '/rcas/town/static/vr-img/th_cwl.jpg',
url: '/rcas/town/static/vr-img/cwl.jpg',
texture: null,
},
{
name: '高空全景',
id: 5,
thumbnail: '/rcas/town/static/vr-img/th_gk.jpg',
url: '/rcas/town/static/vr-img/gk.jpg',
texture: null,
},
{
name: '西正门',
id: 6,
thumbnail: '/rcas/town/static/vr-img/th_xzm.jpg',
url: '/rcas/town/static/vr-img/xzm.jpg',
texture: null,
},
{
name: '北大门',
id: 7,
thumbnail: '/rcas/town/static/vr-img/th_bdm.jpg',
url: '/rcas/town/static/vr-img/bdm.jpg',
texture: null,
},
{
name: '村委楼',
id: 8,
thumbnail: '/rcas/town/static/vr-img/th_cwl.jpg',
url: '/rcas/town/static/vr-img/cwl.jpg',
texture: null,
},
]);
let vr = null;
const loading = ref(false);
const fullScreen = () => {
if(vr) {
vr.fullScreen()
}
}
const selectTab = (tab, index) => {
vr.changeMaterial(index);
};
onMounted(() => {
vr = new VR('vr', loading);
vr.initVrList(props.vrList);
})
<?script>
结束
大概就这多了吧,有不对的地方欢迎大佬们指正~