Three.js 全景图(标注偏移、显示不全的问题记录)

Three.js VR全景图(标注偏移、显示不全的问题记录)

最近在项目中利用three.js实现了vr全景的功能,过程中遇到了标注偏移、标注图标显示不全的问题,在此做个记录

1.VR全景实现原理

  1. 构建three.js基本要素(场景、相机、渲染器)
  2. 在场景中创建一个球体
  3. 将相机放置在球体内部,添加轨道控制器(OrbitControls)
  4. 将全景图贴在球体内部
  5. 点击获取标注位置,添加标注
  6. 切换场景

2. 实现过程

  1. 构建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)
  1. 创建球体, 贴图

如何将全景图贴在球体内部 ?
将球体轴负比例缩放或者设置贴图双面可见(建议使用负缩放)

// 创建球体
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);
});
  1. 获取标注位置

根据点击事件获取屏幕二维坐标,将二维坐标转换成世界坐标,通过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('没捕获到对象')
    }
  }
  1. 添加标注
(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)
  1. 切换场景

当前场景加载过则不再加载,直接贴图;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. 问题记录

  1. 点位偏移问题

造成偏移的问题是标注点位的位置设置有误,一开始直接用了转换后的世界坐标,发现标注位置自己选取的不一样,后面通过学习知道了射线交点的坐标才是拾取的位置

  1. 标注显示不全

按照个人理解标注显示不全是因为被全景图遮挡了,在网上搜索了好久都没有看到相关的信息,后面通过官网的demo发现了个材质属性depthTest(是否在渲染此材质时启用深度测试),在渲染时候会监测当前像素是否存在叠加,如果存在叠加的情况只绘制第一层像素,该属性默认是开启的,我们把它关闭调即可

  1. 使用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>

结束

大概就这多了吧,有不对的地方欢迎大佬们指正~

你可能感兴趣的:(THREE.JS,javascript,vr)