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

首先创建文件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>

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

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

向场景中添加热点

热点就是在模型中出现一个标志,始终面向用户,但是会随着模型切换角度而换位置 在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]()

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

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

如果想要知道当前的位置,可以直接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 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()

向场景里添加热力图

这里主要是两点,一个是先生成热力图,然后再作为材质添加到场景中 生成热力图可以使用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 = []

导出图片以及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的效果图

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