ThreeJS 3D看板项目
1、项目简介
1.1 项目概述
- 这是一个专注于Web3D可视化实战开发的教学项目,以工厂设备场景为核心案例,展示如何将现代前端框架与3D渲染技术结合,构建真实的商业级3D可视化应用。
- Vue3(采用组合式API风格)+ Vite构建工具 + Three.js。
- 项目面向智慧工厂、园区、水利水电、物流、农业等多种3D可视化应用场景。
1.2 效果预览
描述:
- 3d场景中展示设备信息,设备名字颜色代表设备运行状态
- 自定义动画,自动循环展示每一个设备的具体信息
- 点击设备展示设备具体信息、并暂停相关动画
效果如下:
2、初始化
简单看一下布局,equipmentKK 为父元素、container 为实际场景、btns 为自定义按钮,你也可以自定义其它内容,例如标题,整体统计图表等。
因为没有做拆分,代码太多了,只贴了关键参数,都有具体注释。
<template>
<div class="equipmentKK">
<div id="container" ref="container"></div>
<div class="btns">
<button @click="updateData">模拟更新数据</button>
</div>
</div>
</template>
<script lang="ts" setup>
const stats: any = new Stats() // 性能监控
const gltfloader = new GLTFLoader() // 模型加载器
let scene: any // 场景
let camera: any // 相机
let renderer: any // 渲染器
let css2DRenderer: any// 2D渲染器
let composer: any // 效果合成器
let controls: any // 控制器
let canvasWidth: any // 画布宽度
let canvasHeight: any // 画布高度
let container: any = ref(null) // 容器
let rect: any // rect 变量的作用是获取容器元素相对于视口的位置信息,主要用于射线投射(Raycasting)中的鼠标坐标转换
let animationFrameID: any // 动画帧ID
let tween: any // 动画1
let tween1: any // 动画2
const textureLoader = new THREE.TextureLoader() // 纹理加载器
let boardMesh: any = null // 新增:3D看板Mesh
const myTag = '3D_KanBan' // 看板标签
// 自动动画定时器 记录设备模型
const deviceMeshes: any = []
let boardStartIndex = 0 // 3D 看板元素起始序号
let kanban3DTimer: any = null // 3D看板定时器
const durationKanban = 5 // 3D看板动画时长
let animateTimer: any // 动画定时器
let isExecution = true // 是否执行动画
let isAnimating = false // 标记动画开始
// 设备数组
let deviceArr = [
{ name: 'JK1', position: [-10, 0.01, -40], total: 100, now: 50, status: 1 },
]
<script>
在 onMounted 中执行初始化函数,先获取到画布的宽度和高度,初始化时相机等会用到,然后就是获取到位置信息 rect 后面点击元素时会用到。
因为耦合度太高了,下面这几个函数除 importModel() 外,必须一起都执行才能创建一个完整场景。不建议没有Three.js基础的开发者直接上手项目,不过每个函数具体功能是啥都会具体介绍,结合ai看懂也没啥大问题。
// 初始化
const init = () => {
canvasWidth = container.value.clientWidth
canvasHeight = container.value.clientHeight
rect = container.value.getBoundingClientRect()
initScene()
initOther()
initLight()
initCamera()
initRenderer(container.value)
initOrbitControls()
animation()
importModel()
}
下面逐一介绍每一个函数的具体作用:
2.1 初始化场景
执行如下函数会得到一个黑色的场景,此时,场景中没有任何模型。
// 场景
const initScene = () => {
// 初始化一个黑色的场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000); // 设置背景为黑色
}
2.2 添加地面、墙壁等
地面和墙壁都是直接添加了一个长方形,然后给长方形贴上贴图模拟地面和墙壁。这里用的贴图是 1:1 的,为了保证贴图不变形,可以使用 texture.repeat.set(5, 10) 让贴图在横向重复5次,纵向重复10次,这个比例和长方形的比例保持一致即可,即 new THREE.PlaneGeometry(50, 100) 和 texture.repeat.set(5, 10) 的比例一致。 坐标轴辅助可以选择性开启,方便布局。
// 地面 坐标轴 辅助
const initOther = () => {
setTheGround()
setTheWall()
// 坐标轴
// const axesHelper = new THREE.AxesHelper(50)
// scene.add(axesHelper)
container.value.appendChild(stats.domElement) // 添加性能监控
}
// 设置地面
const setTheGround = () => {
// 创建纹理
const texture = textureLoader.load(floorTextureSrc)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(5, 10) // 横向重复5次,纵向重复10次
// 添加地面
const floorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(50, 100),
new THREE.MeshPhongMaterial({
side: THREE.DoubleSide,
map: texture,
}));
floorMesh.name = 'floor'
floorMesh.receiveShadow = true;
floorMesh.rotation.x = - Math.PI / 2.0;
scene.add(floorMesh);
}
// 设置墙面
const setTheWall = () => {
// 创建纹理
const texture = textureLoader.load(wallTextureSrc)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(10, 1) // 横向重复5次,纵向重复10次
// 添加墙壁1
const wallMesh = new THREE.Mesh(
new THREE.PlaneGeometry(100, 10),
new THREE.MeshPhongMaterial({
side: THREE.DoubleSide,
map: texture,
}));
wallMesh.name = 'wall'
wallMesh.receiveShadow = true;
wallMesh.rotation.y = - Math.PI / 2.0;
wallMesh.position.y = 5
wallMesh.position.x = 25
// 克隆wallMesh
const wallMesh1 = wallMesh.clone()
const wallMesh2 = wallMesh.clone()
wallMesh2.position.x = -25
scene.add(wallMesh1);
scene.add(wallMesh2);
}
2.3 添加灯光
同时添加了环境光和点光源,模拟更逼真的环境效果。
// 添加灯光
const initLight = () => {
const light = new THREE.AmbientLight(0x404040, 20); // 柔和的白光
scene.add(light);
// 点光源
const pointLight = new THREE.PointLight(0xffffff, 100, 100);
pointLight.position.set(0, 20, 0);
pointLight.castShadow = true
scene.add(pointLight);
}
2.4 添加相机
创建透视相机,并设置初始化位置。PerspectiveCamera 具体参数作用如下:
// 相机
const initCamera = () => {
camera = new THREE.PerspectiveCamera(75, canvasWidth / canvasHeight, 0.1, 1000)
camera.position.set(0, 20, 70);
}
2.5 渲染器
-
WebGLRenderer:使用WebGL技术进行硬件加速渲染,性能最佳
-
antialias: true:开启抗锯齿,让3D物体的边缘更加平滑,减少锯齿感
- 优点:画面质量更好,边缘更平滑
- 缺点:会消耗更多GPU性能
// 渲染器
const initRenderer = (el: any) => {
renderer = new THREE.WebGLRenderer({
antialias: true
});
// renderer.setPixelRatio(window.devicePixelRatio);
renderer.setPixelRatio(canvasWidth / canvasHeight) //设置像素比例
renderer.setSize(canvasWidth, canvasHeight); // 设置渲染尺寸
el.appendChild(renderer.domElement); // 添加到DOM
}
2.6 轨道控制器
new OrbitControls(camera, renderer.domElement)
- 第一个参数:绑定要控制的相机对象
- 第二个参数:绑定监听鼠标事件的DOM元素(canvas画布)
- 作用:让用户可以通过鼠标操作来控制相机的位置和角度
// 轨道控制器
const initOrbitControls = () => {
controls = new OrbitControls(camera, renderer.domElement)
controls.maxDistance = 150; // 设置距离限制
controls.addEventListener('change', render)
controls.enablePan = true; // 启用平移
controls.enableDamping = true // 开启阻尼效果
controls.dampingFactor = 0.02 // 阻尼系数
// 自动旋转
// controls.autoRotate = true
// controls.autoRotateSpeed = 1
}
2.7 渲染动画帧
这个函数的作用是创建渲染循环,让3D场景持续刷新和更新,是整个3D应用的"心脏"。
- requestAnimationFrame:浏览器提供的动画API,通常以60FPS的频率执行
- 递归调用:函数调用自身,创建无限循环
- animationFrameID:保存动画帧ID,用于后续取消动画循环
- 优势:与浏览器刷新率同步,页面不可见时自动暂停,节省性能
// 动画帧
const animation = () => {
animationFrameID = requestAnimationFrame(animation)
controls.update()
// 让3D看板始终朝向摄像头
if (boardMesh) {
// boardMesh.lookAt(camera.position)
}
render()
}
// 渲染
const render = () => {
stats.update()
renderer.render(scene, camera);
if (composer) {
composer.render();
}
}
3、模型导入
- 因为没有接api接口,现在是在初始化的时候就直接导入模型。导入模型并没有和其它函数耦合,在任何地方导入都可以,你可以等获取到位置信息等数据后在导入模型。测试案例用的是glb格式、其它格式的可自行查询如何解析并导入。
- 模型是放在 src/assets 下面的,并没有放到 public 静态目录下,(直接放public后能直接导入,但是打包后就会出现路径错误等问题,没找到有效解决办法,所以用 import 导入,打包后正常),没有做额外配置可能会出现如下报错:
- 在 vite.config.ts 中添加如下配置解析 glb 格式:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// @ts-ignore
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve('./src'), // 相对路径别名配置,使用 @ 代替 src
},
},
// 解决glb和gltf文件打包后无法加载的问题
assetsInclude: ['**/*.glb', '**/*.gltf'],
})
模型导入函数如下:
- 模型导入成功后立即执行了自定义动画,然后在用定时器循环执行自定义动画。
- 给每一个模型都添加了自定义属性 myTag,方便后期点击时做判断,当然,你也可以直接自定义模型的name,以name为判断条件也是可以的,不过,name 另有用处,还是建议使用自定义属性。
- 注意事项:Model1.clone() 模型克隆只会克隆原始属性,自定义属性不会被克隆,因此,添加自定义属性需要在克隆之后在添加。
const importModel = async () => {
gltfloader.load(wittgenstein, (loader: any) => {
let Model1 = loader.scene
for (let i = 0; i < deviceArr.length; i++) {
let device = deviceArr[i]
let Model2 = Model1.clone()
Model2.position.set(device.position[0], device.position[1], device.position[2])
Model2.name = device.name
Model2.myTag = myTag
Model2.status = device.status
addText(Model2)
deviceMeshes.push(Model2)
scene.add(Model2)
}
executeKanban()
// 设置定时器
kanban3DTimer = setInterval(executeKanban, durationKanban * 1000);
})
}
4、给模型添加名字
- 传入需要添加名字的模型
- 通过 canvas 创建文字贴图
- 创建平面几何体、把创建好的canvas贴图贴到平面几何体上面
- 把平面几何体放到合适的位置即可, textMesh.position.y = 4,这里是默认写死了,你可以修改根据模型的高度自动调整。
- 模型导入时如果有需要即可调用该函数为模型添加名字,默认名字为模型的 name 属性,你也可以自定义。
// 为指定物体添加文字标注
const addText = (obj: any) => {
// 1. 创建canvas
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 64
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.font = 'bold 32px Microsoft YaHei'
ctx.fillStyle = obj.status == 0 ? 'green' : 'red'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(obj.name, canvas.width / 2, canvas.height / 2)
// 2. 创建贴图
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
// 3. 创建平面几何体
const aspect = canvas.width / canvas.height
const geometry = new THREE.PlaneGeometry(2 * aspect, 2)
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide
})
const textMesh = new THREE.Mesh(geometry, material)
textMesh.position.copy(obj.position)
textMesh.position.y = 4
textMesh.myTag = 'text'
textMesh.fatherTag = obj.name
scene.add(textMesh)
}
5、创建3D看板
- 说是3D看板,实际上还是平面几何体,和 4、给模型添加文字 实现一模一样,不同点在于支持多行文字,你可以在这里实现自己的看板。
- 这是根据名字从设备数组(数据)中拿到模型的基本信息(位置信息等)
为什么这里我不传模型进来,直接通过模型获取位置信息呢?
- 主要是考虑到数据的动态变化,实时通信或者用定时器每隔一段时间拉取一次最新的数据。
- 一般情况下模型导入设置好位置后就不需要再次导入了,一些需要展示的数据如果你都挂在模型上通过模型来展示数据的话,每次更新数据你都要更新模型上面的所有数据。
- 通过名称去设备数组中查找,就完全没必要关注模型了,我们只需要关心数据即可。
// 新增:创建3D看板的函数
const create3DBoard = (text: string) => {
// 从设备数组中找到当前设备的名称
const device = deviceArr.find((el: any) => el.name === text);
// 创建canvas
const canvas = document.createElement('canvas')
canvas.width = 316
canvas.height = 158
const ctx = canvas.getContext('2d')!
ctx.fillStyle = device?.status == 0 ? 'rgba(32, 147, 246,0.5)' : 'rgba(255, 0, 0,0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = 'bold 20px Microsoft YaHei'
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 支持多行文本
const lines = [
text,
'设备状态:' + ((device?.status == 0) ? '运行中' : '关闭'),
'达成率:' + ((device!.now / device!.total) * 100).toFixed(2) + '%',
'当前报工数:' + device?.now,
'总报工数:' + device?.total,
]
const lineHeight = 30
const startY = 20
lines.forEach((line, i) => {
ctx.fillText(line, canvas.width / 2, startY + i * lineHeight)
})
// 贴图
const texture = new THREE.CanvasTexture(canvas)
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide // 关键:双面可见
})
// 需保持和canvas宽高比一致,否则会出现拉伸或压缩
const geometry = new THREE.PlaneGeometry(10, 5)
const mesh = new THREE.Mesh(geometry, material)
mesh.name = '3dBoard'
mesh.renderOrder = 999 // 保证在前面
// mesh.lookAt(camera.position) // 朝向相机
mesh.scale.set(0, 0, 0) // 初始缩放为0
return mesh
}
6、点击事件、动画
6.1 点击事件
感觉没有 ai 讲的的好,就直接给 ai 帮忙分析整个点击过程,只在关键的地方做额外描述。
6.1.1 事件绑定与触发
6.1.2 射线投射检测
射线投射原理:
- 将鼠标2D坐标转换为3D世界坐标
- 从相机位置发射一条射线穿过鼠标点击位置
- 检测射线与场景中哪些物体相交
射线投射函数:
// 射线拾取
const raycaster = (event: any, geometrys: any) => {
const x = ((event.clientX - rect.left) / canvasWidth) * 2 - 1;
const y = -((event.clientY - rect.top) / canvasHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
return raycaster.intersectObjects(geometrys);
}
6.1.3 清理旧状态
包括包围盒和旧的3D看板
// 移除旧的包围盒
const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
if (findBox3) scene.remove(findBox3)
// 移除旧的3D看板
if (boardMesh) {
scene.remove(boardMesh)
boardMesh.geometry.dispose()
boardMesh.material.map.dispose()
boardMesh.material.dispose()
boardMesh = null
}
6.1.4 查找目标设备模型
const model = findParentWithName(material[0].object)
查找逻辑:
- 由于模型可能是复合对象,需要向上查找父级元素
- 通过
myTag属性识别是否为设备模型 - 确保点击到的是完整的设备对象
查找函数:
// 查询点击元素的父元素
const findParentWithName = (object: any) => {
let parent = object.parent;
while (parent) {
// 确保myTag属性存在且匹配
if (typeof parent.myTag !== 'undefined' && parent.myTag === myTag) {
// 找到匹配的父元素
return parent;
}
parent = parent.parent;
}
// 没有找到匹配的父元素
return null;
}
6.1.5 点击到设备的处理逻辑
6.1.5.1 停止自动动画
isExecution = false // 停止自动播放
if (tween) { tween.kill(); tween = null; } // 停止相机动画
if (tween1) { tween1.kill(); tween1 = null; }
controls.autoRotate = false // 停止自动旋转
6.1.5.2 创建3D看板
boardMesh = create3DBoard(model.name) // 创建包含设备信息的3D看板
boardMesh.position.copy(location)
boardMesh.position.y += 8 // 放到模型上方
scene.add(boardMesh)
6.1.5.3 添加视觉效果
通过 gsap 动画库实现看板从 0 到 1 的放大效果。创建3d看板的时候缩放已经设置为 0 了,这里只需要把缩放设置为 1 即可实现 缩放视觉效果。
// 看板缩放动画
gsap.to(boardMesh.scale, {
x: 1, y: 1, z: 1,
duration: 0.5,
ease: "back.out"
})
// 添加包围盒边框
calculateBoundingBox(model)
包围盒就是一个根据模型大小和位置信息,生成一个只有边框的立方体,模拟选中效果,这样做性能会好很多,如果让选中的物体发光变亮的话页面卡的不行,如需体验发光效果,取消第 266 行的注释即可。
6.1.6 点击到空白区域的处理
6.2 自定义动画
自定义动画的逻辑是依次展示各设备的生产状态,通过3d看板展示(创建3D看板如下 4.2 ),当用户点击了设备查看时,暂停动画,用户离开后继续执行动画。isExecution 参数就是判断动画是否需要执行,当用户点击了物体就不需执行。
// 使用 async/await
const autoKanban = async (model: any) => {
const cameraLocation = model.position
// 先移除旧的内容
const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
if (findBox3) scene.remove(findBox3)
// 移除旧的3D看板
if (boardMesh) {
scene.remove(boardMesh)
boardMesh.geometry.dispose()
boardMesh.material.map.dispose()
boardMesh.material.dispose()
boardMesh = null
}
const location = model.position
// 等待相机动画完成
await translateCamera(
new THREE.Vector3(cameraLocation.x, cameraLocation.y + 5, cameraLocation.z + 20),
new THREE.Vector3(cameraLocation.x, cameraLocation.y, cameraLocation.z)
)
// 相机动画完成后创建3D看板
boardMesh = create3DBoard(model.name)
boardMesh.position.copy(location)
boardMesh.position.y += 8
scene.add(boardMesh)
// 添加看板的缩放动画
gsap.to(boardMesh.scale, {
x: 1,
y: 1,
z: 1,
duration: 0.5,
ease: "back.out"
})
// 选中物体添加边框
calculateBoundingBox(model)
}
// 修改定时器逻辑
const executeKanban = async () => {
if (!isExecution || isAnimating) return // 如果正在执行动画则跳过
isAnimating = true // 标记开始动画
await autoKanban(deviceMeshes[boardStartIndex])
if (boardStartIndex < deviceArr.length - 1) {
boardStartIndex++
} else {
boardStartIndex = 0
}
isAnimating = false // 标记动画完成
}
7、内存释放(模型清理)
// 内存释放
const cleanupThreeJS = () => {
// 停止渲染循环(如果有的话) animationFrameID是requestAnimationFrame的返回值
cancelAnimationFrame(animationFrameID)
// 遍历并清理场景中的所有物体
scene.traverse((child: any) => {
if (child instanceof THREE.Mesh) {
if (child.geometry?.dispose) child.geometry.dispose()
if (child.material?.dispose) child.material.dispose()
if (child.material?.texture?.dispose) child.material.texture.dispose()
}
if (child instanceof THREE.Group) child.clear()
if (child instanceof THREE.Object3D) child.clear()
})
renderer.dispose() // 如果你的renderer对象有dispose方法的话
}
8、完整代码
模型大家自己找一个免费替换即可,贴图任意图片都可以。没做拆分!!!参数在各函数数之间调用很频繁,有兴趣可自行模块化。
<template>
<div class="equipmentKK">
<div id="container" ref="container"></div>
<div class="btns">
<button @click="updateData">模拟更新数据</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import floorTextureSrc from '@/assets/images/floot.png'
import wallTextureSrc from '@/assets/images/wall.png'
import Stats from 'three/addons/libs/stats.module.js'
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
import gsap from "gsap"
import wittgenstein from "@/assets/model/wittgenstein.glb"
const stats: any = new Stats() // 性能监控
const gltfloader = new GLTFLoader() // 模型加载器
let scene: any // 场景
let camera: any // 相机
let renderer: any // 渲染器
let composer: any // 效果合成器
let controls: any // 控制器
let canvasWidth: any // 画布宽度
let canvasHeight: any // 画布高度
let container: any = ref(null) // 容器
let rect: any // rect 变量的作用是获取容器元素相对于视口的位置信息,主要用于射线投射(Raycasting)中的鼠标坐标转换
let animationFrameID: any // 动画帧ID
let tween: any // 动画1
let tween1: any // 动画2
const textureLoader = new THREE.TextureLoader() // 纹理加载器
let boardMesh: any = null // 新增:3D看板Mesh
const myTag = '3D_KanBan' // 看板标签
// 自动动画定时器 记录设备模型
const deviceMeshes: any = []
let boardStartIndex = 0 // 3D 看板元素起始序号
let kanban3DTimer: any = null // 3D看板定时器
const durationKanban = 5 // 3D看板动画时长
let animateTimer: any // 动画定时器
let isExecution = true // 是否执行动画
let isAnimating = false // 标记动画开始
// 设备数组
let deviceArr = [
{ name: 'JK1', position: [-10, 0.01, -40], total: 100, now: 50, status: 1 },
{ name: 'JK2', position: [-10, 0.01, -20], total: 250, now: 30, status: 0 },
{ name: 'JK3', position: [-10, 0.01, 0], total: 130, now: 90, status: 1 },
{ name: 'JK4', position: [-10, 0.01, 20], total: 1000, now: 50, status: 0 },
{ name: 'JK5', position: [-10, 0.01, 40], total: 500, now: 500, status: 1 },
{ name: 'TK5', position: [10, 0.01, 40], total: 200, now: 20, status: 0 },
{ name: 'TK4', position: [10, 0.01, 20], total: 75, now: 55, status: 1 },
{ name: 'TK3', position: [10, 0.01, 0], total: 80, now: 36, status: 1 },
{ name: 'TK2', position: [10, 0.01, -20], total: 60, now: 28, status: 0 },
{ name: 'TK1', position: [10, 0.01, -40], total: 100, now: 10, status: 1 },
]
onMounted(() => {
init()
container.value.addEventListener('click', clickFn)
// window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('resize', handleResize) // 监听窗口尺寸变化
container.value.addEventListener('mousedown', handleMouseDown)
})
onBeforeUnmount(() => {
// label.element.style.visibility = 'hidden';
cleanupThreeJS()
container.value.removeEventListener('click', clickFn)
// window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('resize', handleResize) // 移除监听
container.value.removeEventListener('mousedown', handleMouseDown)
// 滚轮滚动也打断 动画
window.removeEventListener('wheel', handleMouseDown)
if (animateTimer) clearInterval(animateTimer)
if (kanban3DTimer) clearInterval(kanban3DTimer)
})
// const handleBeforeUnload = (e: any) => {
// e.preventDefault()
// e.returnValue = ""
// cleanupThreeJS()
// }
// 内存释放
const cleanupThreeJS = () => {
// 停止渲染循环(如果有的话) animationFrameID是requestAnimationFrame的返回值
cancelAnimationFrame(animationFrameID)
// 遍历并清理场景中的所有物体
scene.traverse((child: any) => {
if (child instanceof THREE.Mesh) {
if (child.geometry?.dispose) child.geometry.dispose()
if (child.material?.dispose) child.material.dispose()
if (child.material?.texture?.dispose) child.material.texture.dispose()
}
if (child instanceof THREE.Group) child.clear()
if (child instanceof THREE.Object3D) child.clear()
})
renderer.dispose() // 如果你的renderer对象有dispose方法的话
}
// 初始化
const init = () => {
canvasWidth = container.value.clientWidth
canvasHeight = container.value.clientHeight
rect = container.value.getBoundingClientRect()
initScene()
initOther()
initLight()
initCamera()
initRenderer(container.value)
initOrbitControls()
animation()
importModel()
}
// 场景
const initScene = () => {
// 初始化一个黑色的场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000); // 设置背景为黑色
}
// 相机
const initCamera = () => {
camera = new THREE.PerspectiveCamera(75, canvasWidth / canvasHeight, 0.1, 1000)
camera.position.set(0, 20, 70);
}
// 渲染器
const initRenderer = (el: any) => {
renderer = new THREE.WebGLRenderer({
antialias: true
});
// renderer.setPixelRatio(window.devicePixelRatio);
renderer.setPixelRatio(canvasWidth / canvasHeight) //设置像素比例
renderer.setSize(canvasWidth, canvasHeight); // 设置渲染尺寸
el.appendChild(renderer.domElement); // 添加到DOM
}
// 导入模型
const importModel = async () => {
gltfloader.load(wittgenstein, (loader: any) => {
let Model1 = loader.scene
for (let i = 0; i < deviceArr.length; i++) {
let device = deviceArr[i]
let Model2 = Model1.clone()
Model2.position.set(device.position[0], device.position[1], device.position[2])
Model2.name = device.name
Model2.myTag = myTag
Model2.status = device.status
addText(Model2)
deviceMeshes.push(Model2)
scene.add(Model2)
}
executeKanban()
// 设置定时器
kanban3DTimer = setInterval(executeKanban, durationKanban * 1000);
})
}
// 添加灯光
const initLight = () => {
const light = new THREE.AmbientLight(0x404040, 20); // 柔和的白光
scene.add(light);
// 点光源
const pointLight = new THREE.PointLight(0xffffff, 100, 100);
pointLight.position.set(0, 20, 0);
pointLight.castShadow = true
scene.add(pointLight);
}
// 轨道控制器
const initOrbitControls = () => {
controls = new OrbitControls(camera, renderer.domElement)
controls.maxDistance = 150; // 设置距离限制
controls.addEventListener('change', render)
controls.enablePan = true; // 启用平移
controls.enableDamping = true // 开启阻尼效果
controls.dampingFactor = 0.02 // 阻尼系数
// 自动旋转
// controls.autoRotate = true
// controls.autoRotateSpeed = 1
}
// 渲染
const render = () => {
stats.update()
renderer.render(scene, camera);
if (composer) {
composer.render();
}
}
// 动画帧
const animation = () => {
animationFrameID = requestAnimationFrame(animation)
controls.update()
// 让3D看板始终朝向摄像头
if (boardMesh) {
// boardMesh.lookAt(camera.position)
}
render()
}
// 查询点击元素的父元素
const findParentWithName = (object: any) => {
let parent = object.parent;
while (parent) {
// 确保myTag属性存在且匹配
if (typeof parent.myTag !== 'undefined' && parent.myTag === myTag) {
// 找到匹配的父元素
return parent;
}
parent = parent.parent;
}
// 没有找到匹配的父元素
return null;
}
// 点击事件
const clickFn = (event: any) => {
event.preventDefault()
rect = container.value.getBoundingClientRect()
let material = raycaster(event, scene.children)
if (material.length <= 0) {
return;
}
const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
if (findBox3) scene.remove(findBox3)
// 移除旧的3D看板
if (boardMesh) {
scene.remove(boardMesh)
boardMesh.geometry.dispose()
boardMesh.material.map.dispose()
boardMesh.material.dispose()
boardMesh = null
}
const model = findParentWithName(material[0].object)
if (model) {
// 打断正在执行的gasp动画
isExecution = false
if (tween) {
tween.kill();
tween = null;
}
if (tween1) {
tween1.kill();
tween1 = null;
}
// 选中物体发光
// postRenderer(model)
controls.autoRotate = false
const location = model.position
// 新增:创建3D看板
boardMesh = create3DBoard(model.name)
boardMesh.position.copy(location)
boardMesh.position.y += 8 // 放到模型上方
scene.add(boardMesh)
// 添加看板的缩放动画
gsap.to(boardMesh.scale, {
x: 1,
y: 1,
z: 1,
duration: 0.5,
ease: "back.out"
})
// 选中物体添加边框
calculateBoundingBox(model)
translateCamera(
new THREE.Vector3(location.x, location.y + 10, location.z + 20),
new THREE.Vector3(location.x, location.y, location.z)
)
} else {
postRenderer(false)
// label.element.style.visibility = 'hidden'; // 注释掉2D看板
// 移除3D看板
if (boardMesh) {
scene.remove(boardMesh)
boardMesh.geometry.dispose()
boardMesh.material.map.dispose()
boardMesh.material.dispose()
boardMesh = null
}
isExecution = true
// controls.autoRotate = true
}
}
// 新增:创建3D看板的函数
const create3DBoard = (text: string) => {
// 从设备数组中找到当前设备的名称
const device = deviceArr.find((el: any) => el.name === text);
// 创建canvas
const canvas = document.createElement('canvas')
canvas.width = 316
canvas.height = 158
const ctx = canvas.getContext('2d')!
ctx.fillStyle = device?.status == 0 ? 'rgba(32, 147, 246,0.5)' : 'rgba(255, 0, 0,0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = 'bold 20px Microsoft YaHei'
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 支持多行文本
const lines = [
text,
'设备状态:' + ((device?.status == 0) ? '运行中' : '关闭'),
'达成率:' + ((device!.now / device!.total) * 100).toFixed(2) + '%',
'当前报工数:' + device?.now,
'总报工数:' + device?.total,
]
const lineHeight = 30
const startY = 20
lines.forEach((line, i) => {
ctx.fillText(line, canvas.width / 2, startY + i * lineHeight)
})
// 贴图
const texture = new THREE.CanvasTexture(canvas)
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide // 关键:双面可见
})
// 需保持和canvas宽高比一致,否则会出现拉伸或压缩
const geometry = new THREE.PlaneGeometry(10, 5)
const mesh = new THREE.Mesh(geometry, material)
mesh.name = '3dBoard'
mesh.renderOrder = 999 // 保证在前面
// mesh.lookAt(camera.position) // 朝向相机
mesh.scale.set(0, 0, 0) // 初始缩放为0
return mesh
}
// 计算包围盒
const calculateBoundingBox = (model: any) => {
const box3 = new THREE.Box3().setFromObject(model);
// 创建一个线条来显示包围盒(可选)
const boxHelper = new THREE.Box3Helper(box3, new THREE.Color(0xffffff));
boxHelper.name = 'surroundingBox'
boxHelper.scale.copy(box3.getSize(new THREE.Vector3()));
scene.add(boxHelper);
}
const translateCamera = (position: any, target: any, duration = 1): Promise<void> => {
return new Promise((resolve) => {
tween = gsap.to(camera.position, {
x: position.x,
y: position.y,
z: position.z,
duration: duration,
ease: "none",
});
tween1 = gsap.to(controls.target, {
x: target.x,
y: target.y,
z: target.z,
duration: duration,
ease: "none",
onComplete: () => resolve() // 动画完成时 resolve Promise
});
});
}
// 射线拾取
const raycaster = (event: any, geometrys: any) => {
const x = ((event.clientX - rect.left) / canvasWidth) * 2 - 1;
const y = -((event.clientY - rect.top) / canvasHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
return raycaster.intersectObjects(geometrys);
}
// 后期处理渲染器
const postRenderer = (mesh: any) => {
if (!mesh) {
composer = null
return
}
composer = new EffectComposer(renderer)
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const v2 = new THREE.Vector2(canvasWidth, canvasHeight)
const outlinePass = new OutlinePass(v2, scene, camera)
outlinePass.selectedObjects = [mesh]
// 将此通道结果渲染到屏幕
outlinePass.renderToScreen = true;
outlinePass.edgeGlow = 1; // 发光强度
outlinePass.usePatternTexture = false; // 是否使用纹理图案
outlinePass.edgeThickness = 1; // 边缘浓度
outlinePass.edgeStrength = 2; // 边缘的强度,值越高边框范围越大
outlinePass.pulsePeriod = 2; // 闪烁频率,值越大频率越低
outlinePass.visibleEdgeColor.set('#ff0000'); // 呼吸显示的颜色
outlinePass.hiddenEdgeColor.set('#ffff00'); // 不可见边缘的颜色
composer.addPass(outlinePass)
}
// 地面 坐标轴 辅助
const initOther = () => {
setTheGround()
setTheWall()
// 坐标轴
// const axesHelper = new THREE.AxesHelper(50)
// scene.add(axesHelper)
container.value.appendChild(stats.domElement)
}
// 设置地面
const setTheGround = () => {
// 创建纹理
const texture = textureLoader.load(floorTextureSrc)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(5, 10) // 横向重复5次,纵向重复10次
// 添加地面
const floorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(50, 100),
new THREE.MeshPhongMaterial({
side: THREE.DoubleSide,
map: texture,
}));
floorMesh.name = 'floor'
floorMesh.receiveShadow = true;
floorMesh.rotation.x = - Math.PI / 2.0;
scene.add(floorMesh);
}
// 设置墙面
const setTheWall = () => {
// 创建纹理
const texture = textureLoader.load(wallTextureSrc)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(10, 1) // 横向重复5次,纵向重复10次
// 添加墙壁1
const wallMesh = new THREE.Mesh(
new THREE.PlaneGeometry(100, 10),
new THREE.MeshPhongMaterial({
side: THREE.DoubleSide,
map: texture,
}));
wallMesh.name = 'wall'
wallMesh.receiveShadow = true;
wallMesh.rotation.y = - Math.PI / 2.0;
wallMesh.position.y = 5
wallMesh.position.x = 25
// 克隆wallMesh
const wallMesh1 = wallMesh.clone()
const wallMesh2 = wallMesh.clone()
wallMesh2.position.x = -25
scene.add(wallMesh1);
scene.add(wallMesh2);
}
// 为指定物体添加文字标注
const addText = (obj: any) => {
// 1. 创建canvas
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 64
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.font = 'bold 32px Microsoft YaHei'
ctx.fillStyle = obj.status == 0 ? 'green' : 'red'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(obj.name, canvas.width / 2, canvas.height / 2)
// 2. 创建贴图
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
// 3. 创建平面几何体
const aspect = canvas.width / canvas.height
const geometry = new THREE.PlaneGeometry(2 * aspect, 2)
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide
})
const textMesh = new THREE.Mesh(geometry, material)
textMesh.position.copy(obj.position)
textMesh.position.y = 4
textMesh.myTag = 'text'
textMesh.fatherTag = obj.name
scene.add(textMesh)
}
const updateData = () => {
scene.traverse((child: any) => {
if (typeof child.myTag !== 'undefined' && child.myTag === 'text') {
// 找到对应设备
const device = deviceArr.find((d: any) => d.name === child.fatherTag)
if (!device) return
// 重新绘制canvas
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 64
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.font = 'bold 32px Microsoft YaHei'
ctx.fillStyle = device.status == 0 ? 'blue' : 'red'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(device.name, canvas.width / 2, canvas.height / 2)
// 更新贴图
const newTexture = new THREE.CanvasTexture(canvas)
newTexture.needsUpdate = true
// 释放旧贴图资源
if (child.material.map) {
child.material.map.dispose()
}
child.material.map = newTexture
child.material.needsUpdate = true
}
})
}
// 新增:窗口尺寸变化时自适应渲染
const handleResize = () => {
if (!container.value || !camera || !renderer) return
canvasWidth = container.value.clientWidth
canvasHeight = container.value.clientHeight
camera.aspect = canvasWidth / canvasHeight
camera.updateProjectionMatrix()
renderer.setSize(canvasWidth, canvasHeight)
render()
}
// 新增:鼠标按下时打断gsap动画
const handleMouseDown = () => {
if (tween) {
tween.kill();
tween = null;
}
if (tween1) {
tween1.kill();
tween1 = null;
}
}
// 使用 async/await
const autoKanban = async (model: any) => {
const cameraLocation = model.position
// 先移除旧的内容
const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
if (findBox3) scene.remove(findBox3)
// 移除旧的3D看板
if (boardMesh) {
scene.remove(boardMesh)
boardMesh.geometry.dispose()
boardMesh.material.map.dispose()
boardMesh.material.dispose()
boardMesh = null
}
const location = model.position
// 等待相机动画完成
await translateCamera(
new THREE.Vector3(cameraLocation.x, cameraLocation.y + 5, cameraLocation.z + 20),
new THREE.Vector3(cameraLocation.x, cameraLocation.y, cameraLocation.z)
)
// 相机动画完成后创建3D看板
boardMesh = create3DBoard(model.name)
boardMesh.position.copy(location)
boardMesh.position.y += 8
scene.add(boardMesh)
// 添加看板的缩放动画
gsap.to(boardMesh.scale, {
x: 1,
y: 1,
z: 1,
duration: 0.5,
ease: "back.out"
})
// 选中物体添加边框
calculateBoundingBox(model)
}
// 修改定时器逻辑
const executeKanban = async () => {
if (!isExecution || isAnimating) return // 如果正在执行动画则跳过
isAnimating = true // 标记开始动画
await autoKanban(deviceMeshes[boardStartIndex])
if (boardStartIndex < deviceArr.length - 1) {
boardStartIndex++
} else {
boardStartIndex = 0
}
isAnimating = false // 标记动画完成
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
}
.equipmentKK {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#container {
width: 100%;
height: 100%;
overflow: hidden;
}
.btns {
position: absolute;
top: 20px;
right: 20px;
z-index: 999;
}
button {
border: none;
outline: none;
background-color: #fff;
padding: 5px 10px;
margin: 5px;
cursor: pointer;
}
</style>