使用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>
到这里,我们已经可以进行模型的加载及控制了,如果只是想看的话,那就已经够了
接下来的功能是与我们的业务相关
向场景中添加热点
热点就是在模型中出现一个标志,始终面向用户,但是会随着模型切换角度而换位置
在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
},
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()
向场景里添加热力图
这里主要是两点,一个是先生成热力图,然后再作为材质添加到场景中
生成热力图可以使用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
- 在线演示:
本人较懒,以后再补