使用Three.js加载模型并且添加热力图及生成gif

15,592 阅读7分钟

使用Three.js加载模型并且添加热力图及生成gif

前言

我们在自己的公司要加载模型通常使用封装好的平台,但是遇到简单的项目没必要使用那些平台,可以自己简单做一个

什么是Threejs

如何使用Threejs

安装

npm: npm install three --save-dev yarn: yarn add three --save-dev

初始化

考虑到一个项目里多个页面可能使用多个模型,因此进行简单的封装

首先创建文件three.js

import * as THREE from 'three/build/three.module'
// 该模型是gltf格式的,所以使用GLTFLoader,,其他可使用loader可以看官网
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

export class MyThree {
  constructor(container) {
    this.container = container
    this.scene
    this.camera
    this.renderer
    this.controls
    this.init()
  }
  /**
   * 初始化模型
   * @param {Object} container HTMLElemnt
   */
  init = () => {
    this.scene = new THREE.Scene()
    var width = this.container.offsetWidth // 窗口宽度
    var height = this.container.offsetHeight // 窗口高度
    var k = width / height // 窗口宽高比
    /**
     * PerspectiveCamera(fov, aspect, near, far)
     * Fov – 相机的视锥体的垂直视野角
     * Aspect – 相机视锥体的长宽比
     * Near – 相机视锥体的近平面
     * Far – 相机视锥体的远平面
     */
    this.camera = new THREE.PerspectiveCamera(45, k, 0.1, 1000)
    this.camera.position.set(14, 12, 0.3)
    this.camera.rotation.set(-2.1, 1.1, 2.5)
    this.renderer = new THREE.WebGLRenderer({
      preserveDrawingBuffer: true,
      // 抗锯齿,产品非说模型效果不好,让我加上
      antialias: true,
      alpha: true
    })
    this.renderer.setSize(width, height)
    this.renderer.setClearColor(0xe8e8e8, 0)
    this.container.appendChild(this.renderer.domElement)
    /** 轨道控制器(OrbitControls)用于鼠标的拖拽旋转等操作 */
    this.controls = new OrbitControls(this.camera, this.renderer.domElement)
    this.animate()
    // 使动画循环使用时阻尼或自转 意思是否有惯性
    this.controls.enableDamping = true
    // 动态阻尼系数 就是鼠标拖拽旋转灵敏度
    this.controls.dampingFactor = 0.1
    this.controls.enableZoom = true
    this.controls.minDistance = 1 // 限制缩放
    // controls.maxDistance = 30
    this.controls.target.set(0, 0, 0) // 旋转中心点

    window.onresize = () => {
      // 重置渲染器输出画布canvas尺寸
      this.renderer.setSize(this.container.offsetWidth, this.container.offsetHeight)
      // 全屏情况下:设置观察范围长宽比aspect为窗口宽高比
      this.camera.aspect = this.container.offsetWidth / this.container.offsetHeight
      // 渲染器执行render方法的时候会读取相机对象的投影矩阵属性projectionMatrix
      // 但是不会每渲染一帧,就通过相机的属性计算投影矩阵(节约计算资源)
      // 如果相机的一些属性发生了变化,需要执行updateProjectionMatrix ()方法更新相机的投影矩阵
      this.camera.updateProjectionMatrix()
    }
  }
  // 渲染函数
  render() {
    this.renderer.render(this.scene, this.camera) // 执行渲染操作
  }
  /**
   * 加载模型
   * @param {*} path 路径
   */
  loadModel = (path) => {
    var loader = new GLTFLoader()
    loader.load(
      path,
      (gltf) => {
        gltf.scene.traverse(function (child) {
          if (child.isMesh) {
            // child.geometry.center() // center here

            // 如果加载模型后发现模型非常暗,可以开启,会将丢失的材质加上
            // child.material.emissive = child.material.color
            // child.material.emissiveMap = child.material.map
          }
        })
        gltf.scene.scale.set(0.5, 0.5, 0.5) // scale here
        this.setModelPosition(gltf.scene) // 自动居中,项目需要的话可以使用
        this.scene.add(gltf.scene)
      },
      function (xhr) {
        // 侦听模型加载进度
        console.log((xhr.loaded / xhr.total) * 100 + '% loaded')
      },
      function (error) {
        // 加载出错时的回调
        console.log(error)
        console.log('An error happened')
      }
    )
  }
    // 自动居中
  setModelPosition = (object) => {
    object.updateMatrixWorld()
    // 获得包围盒得min和max
    const box = new THREE.Box3().setFromObject(object)
    // 返回包围盒的宽度,高度,和深度
    // const boxSize = box.getSize()
    // console.log(box)
    // 返回包围盒的中心点
    const center = box.getCenter(new THREE.Vector3())
    object.position.x += object.position.x - center.x
    object.position.y += object.position.y - center.y
    object.position.z += object.position.z - center.z
  }
  // 增加光源,光源种类有很多,可以自己尝试一下各种光源及参数
  getLight() {
    const ambient = new THREE.AmbientLight(0xffffff)
    // const ambient = new THREE.AmbientLight(0xcccccc, 3.5)
    // const ambient = new THREE.HemisphereLight(0xffffff, 0x000000, 1.5)
    // ambient.position.set(30, 30, 0)
    this.scene.add(ambient)
  }
    /**
   * 动画
   */
  animate = () => {
    // 更新控制器
    this.controls.update()
    this.render()
    requestAnimationFrame(this.animate)
  }
}

然后在vue文件中使用

<template>
  <div class="model"></div>
</template>
<script name="mymodel" setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { MyThree } from '../utils/three'

let three
onMounted(() => {
  let box = document.querySelector('.model')
  three = new MyThree(box)
  three.getLight()
  three.loadModel('/static/model.gltf')
})
onBeforeUnmount(() => {
  document.querySelector('.model').innerHTML = ''
})
</script>

到这里,我们已经可以进行模型的加载及控制了,如果只是想看的话,那就已经够了 model

接下来的功能是与我们的业务相关

向场景中添加热点

热点就是在模型中出现一个标志,始终面向用户,但是会随着模型切换角度而换位置 在three.js中使用的是精灵图THREE.Sprite constructor里新增spriteList和eventList,用来保存当前的精灵图列表以及点击事件

  /**
   * 添加精灵图
   * @param {string} name
   * @param {function} cb 回调函数
   */
  addSprite = (name, num, position = { x: 2, y: 0.5, z: 0 }, cb) => {
    // 使用图片做材质,我这里没图片,直接使用favicon做材质了
    var spriteMap = new THREE.TextureLoader().load('/favicon.ico')
    // 生成精灵图材质
    var spriteMaterial = new THREE.SpriteMaterial({
      map: spriteMap,
      color: 0xffffff,
      sizeAttenuation: false
    })
    var sprite = new THREE.Sprite(spriteMaterial)
    // 保存一下名字,用来记录,之后可以进行移除以及点击事件
    sprite.name = name
    sprite.scale.set(0.05, 0.05, 1)
    sprite.position.set(position.x, position.y, position.z)
    this.scene.add(sprite)
    this.spriteList.push(sprite)
    // 如果有回调函数,那么点击时就会执行
    cb && (this.eventList[name] = cb)
  }
  /**
   * 添加精灵图
   * @param {string} name 热点的名字
   */
  removeSprite = (name) => {
    this.spriteList.some((item) => {
      if (item.name === name) {
        this.scene.remove(item)
      }
    })
  }
  /** 移除全部热点 */
  removeAllSprite = () => {
    this.spriteList.forEach((item) => {
      this.scene.remove(item)
    })
  }

点击热点的事件保存在eventList里,这里的难点在于,如何判定点击到了所选内容,three.js里采用射线法,就是从相机发射一条射线,方向是鼠标位置,如果有相交的对象,那么就是选取的对象 在init函数里添加如下代码

  window.onclick = (event) => {
    // 将鼠标点击位置的屏幕左边转换成three.js中的标准坐标
    var mouse = { x: 0, y: 0 }
    mouse.x = (event.layerX / this.container.offsetWidth) * 2 - 1
    mouse.y = -(event.layerY / this.container.offsetHeight) * 2 + 1
    var vector = new THREE.Vector3(mouse.x, mouse.y, 0.5).unproject(this.camera)
    // 从相机发射一条射线,穿过这个标准坐标位置的,即为选到的对象
    var raycaster = new THREE.Raycaster(
      this.camera.position,
      vector.sub(this.camera.position).normalize()
    )
    raycaster.camera = this.camera
    var intersects = raycaster.intersectObjects(this.scene.children, true)
    intersects.forEach((item) => {
      this.eventList[item.object.name] && this.eventList[item.object.name]()
    })
  }

spirite

相机定位到指定位置,并且要平滑切换

这个功能是用于切换视角使用的,比如定位到某一个热点的位置、定位到所记录的位置

如果想要知道当前的位置,可以直接console.log(three.camera),可以获取相机目前的信息

如果想要改变位置,可以直接改变camera的参数,但是想要平滑过渡,就要使用tween.js 首先引入import { TWEEN } from 'three/examples/jsm/libs/tween.module.min'

  /**
   * 移动视角
   */
  moveCamera = (newT = { x: 0, y: 0, z: 0 }, newP = { x: 13, y: 0, z: 0.3 }) => {
    let oldP = this.camera.position
    // return console.log(this.controls)
    let oldT = this.controls.target

    let tween = new TWEEN.Tween({
      x1: oldP.x,
      y1: oldP.y,
      z1: oldP.z,
      x2: oldT.x,
      y2: oldT.y,
      z2: oldT.z
    })
    tween.to(
      {
        x1: newP.x,
        y1: newP.y,
        z1: newP.z,
        x2: newT.x,
        y2: newT.y,
        z2: newT.z
      },
      2000
    )
    let that = this
    tween.onUpdate((object) => {
      that.camera.position.set(object.x1, object.y1, object.z1)
      that.controls.target.x = object.x2
      that.controls.target.y = object.y2
      that.controls.target.z = object.z2
      that.controls.update()
    })
    tween.onComplete(() => {
      this.controls.enabled = true
    })
    tween.easing(TWEEN.Easing.Cubic.InOut)
    tween.start()
  }

同时animate还要添一句TWEEN.update()

move

向场景里添加热力图

这里主要是两点,一个是先生成热力图,然后再作为材质添加到场景中 生成热力图可以使用heatmap.js 安装 npm i @rengr/heatmap.js

import h337 from '@rengr/heatmap.js'
export function getHeatmapCanvas(points, x = 500, y = 160) {
  var canvasBox = document.createElement('div')
  document.body.appendChild(canvasBox)

  canvasBox.style.width = x + 'px'
  canvasBox.style.height = y + 'px'
  canvasBox.style.position = 'absolute'
  var heatmapInstance = h337.create({
    container: canvasBox,
    backgroundColor: 'rgba(255, 255, 255, 0)', // '#121212'    'rgba(0,102,256,0.2)'
    radius: 20, // [0,+∞)
    minOpacity: 0,
    maxOpacity: 0.6,
  })
  // 构建一些随机数据点,这里替换成你的业务数据
  var data
  if (points && points.length) {
    data = {
      max: 40,
      min: 0,
      data: points,
    }
  } else {
    let randomPoints = []
    var max = 0
    var cwidth = x
    var cheight = y
    var len = 300

    while (len--) {
      var val = Math.floor(Math.random() * 30 + 20)
      max = Math.max(max, val)
      var point = {
        x: Math.floor(Math.random() * cwidth),
        y: Math.floor(Math.random() * cheight),
        value: val,
      }
      randomPoints.push(point)
    }
    data = {
      max: 60,
      min: 15,
      data: randomPoints,
    }
  }

  // 因为data是一组数据,所以直接setData

  heatmapInstance.setData(data)
  let canvas = canvasBox.querySelector('canvas')
  document.body.removeChild(canvasBox)
  return canvas
}

这里的方法是使用数据生成热力图,如果没有数据就生成随机数据,其中的参数可以自己调试 有了这个canvas,就可以添加到场景里了

  // 增加一个物体,且以canvas为材质
  createPlaneByCanvas(name, canvas, position = {}, size = { x: 9, y: 2.6 }, rotation = {}) {
    var geometry = new THREE.PlaneGeometry(size.x, size.y) // 生成一个平面
    var texture = new THREE.CanvasTexture(canvas) // 引入材质
    var material = new THREE.MeshBasicMaterial({
      map: texture,
      side: THREE.DoubleSide,
      transparent: true
      // color: '#fff'
    })
    texture.needsUpdate = true
    const plane = new THREE.Mesh(geometry, material)
    plane.material.side = 2 // 双面材质
    plane.position.x = position.x || 0
    plane.position.y = position.y || 0
    plane.position.z = position.z || 0
    plane.rotation.x = rotation.x || 1.5707963267948966
    plane.rotation.y = rotation.y || 0
    plane.rotation.z = rotation.z || 0
    this.planes[name] = plane
    this.scene.add(this.planes[name])
  }
  /**
   * 根据名称移除热力图
   * @param {string} name
   */
  removeHeatmap(name) {
    this.scene.remove(this.planes[name])
    delete this.planes[name]
  }

在热力图处剖切

因为是建筑模型,直接加入热力图,没法看到里面的内容,所以采用剖切

  /**
   * 增加剖切
   */
  addClippingPlanes() {
    this.clipHelpers = new THREE.Group()
    this.clipHelpers.add(new THREE.AxesHelper(20))
    this.globalPlanes = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0) // 与热力图一样,其实都是生成一个平面
    this.clipHelpers.add(new THREE.PlaneHelper(this.globalPlanes, 20, 0xff0000))
    this.clipHelpers.visible = false
    this.scene.add(this.clipHelpers)
    // //创建一个剖切面
    // console.log(renderer, globalPlanes)
    this.renderer.clippingPlanes = [this.globalPlanes] // 显示剖面
    this.renderer.localClippingEnabled = true
    this.globalPlanes.constant = 0.01 // 设置位置稍微在热力图上方一点点,不然看不到热力图了
  }
  /**
   * 设置剖切位置
   * @param {number} v
   */
  setClippingConstant(v) {
    this.globalPlanes.constant = v
  }
  /**
   * 移除剖切
   */
  removeClippingPlanes() {
    this.scene.remove(this.clipHelpers)
    this.scene.remove(this.globalPlanes)
    this.renderer.clippingPlanes = []
  }

heatmap

导出图片以及gif图

因为three就是渲染在canvas上的,所以直接导出图片很简单

const exportCurrentCanvas = () => {
  var a = document.createElement('a')
  a.href = three.renderer.domElement.toDataURL('image/png')
  a.download = 'image.png'
  a.click()
}

生成gif图使用gif.js,这里与我的实际业务不一样,实际是可以很快就生成n多张图片拼成gif,这里是相当于录像的生成gif,不过原理是一样的

const generateGif = async () => {
  var gif = new window.GIF({
    workers: 2,
    quality: 10
  })
  // for (let i = 0; i < 60; i++) {
  // setCamera()
  for (let i = 0; i < 10; i++) {
    await new Promise((resolve) => {
      setTimeout(() => {
        console.log(i)
        gif.addFrame(three.renderer.domElement, { delay: 200 })
        resolve()
      }, 200)
    })
  }
  gif.on('finished', function (blob) {
    window.open(URL.createObjectURL(blob))
  })
  gif.render()

}

最后来张生成的gif的效果图 gif

结尾

  • 最后是项目地址: gitee
  • 在线演示: 本人较懒,以后再补