three.js+vue污水处理厂数字孪生平台智慧城市web3d
案例效果截图如下:
具体三维场景和功能,详见b站视频:
https://www.bilibili.com/video/BV1sZ421g7DZ/?vd_source=7d4ec9c9275b9c7d16afe9b4625f636c
主场景三维逻辑代码如下:
<template> <div class="whole"> <!-- threejs画布 --> <div id="threejs" ref="threejs"></div> <!-- 污水厂模型加载进度条 --> <a-progress :stroke-color="{ from: '#00F5FF', to: '#4169E1', }" :percent="0.0" trailColor="#E8E8E8" status="active" class="progress" /> <!-- 标签组件 --> <Label></Label> <!-- 巡检数据展示面板--> <div class="inspectPanel a-fadein" v-show="inspectPanelShow"> <div class="panelTitle" id="panelTitle">曝气池</div> <div class="panelData"> <div class="left"> <div class="leftTitle">介绍</div> <div class="segment"></div> <div class="describe" id="describe"></div> </div> <div class="right"> <div class="rightTitle">数据记录</div> <div class="segment"></div> <div class="record"> <div class="main" id="panelData"></div> </div> </div> </div> </div> <!-- 巡检中 返回和状态按钮 --> <div class="inspect" v-show="props['selectedMenu'] === 'inspect'"> <div class="common" @click="endInspect"> <div class="return_icon" style=""></div> 返回 </div> <div class="common" @click="inspectStateChange"> <div :class="inspectState ? 'stop_icon' : 'continue_icon'"></div> {{ inspectState ? '暂停' : '继续' }} </div> </div> <!-- 巡检进度条 --> <progressBar v-show="props['selectedMenu'] === 'inspect'" :schedule="schedule" :inspectState="inspectState" @progressBarChange="progressBarChange"></progressBar> <!-- 巡检速度控制条 --> <speedControlBar v-show="props['selectedMenu'] === 'inspect'" :speed="speed" :inspectState="inspectState" @controlBarChange="controlBarChange"></speedControlBar> </div> </template> <script setup> import { ref, onMounted, watch } from 'vue'; // 引入threejs import * as THREE from 'three'; // 基础配置文件——场景、灯光、相机等 import { scene, renderer, css2DRender, camera, controls } from './base/index.js'; // 添加污水厂模型函数 import { addSewageModel } from './addSewageModel/index.js'; // 添加人物模型函数 import { addPeopleModel, WalkAction } from './addPeopleModel/index.js'; // 引入tween.js,用来创建动画 import TWEEN from '@tweenjs/tween.js'; // 引入标签组件 import Label from './label/index.vue'; // 引入人物2D标签、CSS2D渲染器、标签初始化函数和建筑标签组对象 import { css2DPeopleLabel, initLabel, buildLabelGroup } from './label/index.js'; // 引入创建水面函数 import { createWaterPlane, waterPlaneGroup } from './waterPlane/index.js'; import { inspectPathArr, inspectIndex, inspectPathIndex, inspectState, inspectPanelShow, inspectLinePointGroup, openInspection, inspectionParams } from './inspection/index.js'; import progressBar from './progressBar/index.vue'; import speedControlBar from './speedControlBar/index.vue'; // 引入RGB加载器 import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js'; import { setPoolMaterial } from './poolMaterial/index'; import Stats from 'three/examples/jsm/libs/stats.module.js'; // 首页传值 const props = defineProps([ 'craftAnimationStatus', // 工艺动画状态,为true时开启播放相应的工艺动画 'craftAnimationType', // 工艺动画类型,根据此值决定动画的类型 'selectedMenu', // 首页底部菜单按钮选中项 ]); // 传递事件 const emit = defineEmits(['closeInspect', 'craftAnimationEnd']); // threejs容器 const threejs = ref(); // 污水厂模型 let sewageModel = null; // 人物模型 let people = null; // 人物动画播放器 let animationMixer = null; // 当前巡检进度百分比值 let schedule = ref(0); // 巡检的速度 let speed = ref(0); let stats; onMounted(async () => { //创建stats对象 stats = new Stats(); threejs.value.appendChild(stats.domElement); threejs.value.appendChild(renderer.domElement); threejs.value.appendChild(css2DRender.domElement); const rgbeLoader = new RGBELoader(); // 环境贴图 let envMap = await rgbeLoader.loadAsync('./envMap.hdr'); createEnvironment(envMap); // 异步加载污水厂模型 sewageModel = await addSewageModel(envMap); // 添加人物模型、人物动画播放器 const { peopleGroup, mixer } = await addPeopleModel(); people = peopleGroup; // 相机添加到人物模型中 people.add(camera); animationMixer = mixer; // 允许人物模型产生阴影 people.castShadow = true; scene.add(sewageModel, people, inspectLinePointGroup); // 创建水面 createWaterPlane(sewageModel, envMap); // 设置水池材质 setPoolMaterial(sewageModel); // 开始循环渲染 render(); // 播放首次进入动画 eventAnimation(); }); watch( () => props['craftAnimationStatus'], (e) => { if (e) { // 重置水面透明度 waterPlaneGroup.children.map((obj) => { obj.material.uniforms.alpha.value = 1.0; }); craftAnimation(props['craftAnimationType']); } } ); watch( () => props['selectedMenu'], (e) => { // 巡检开启 if (e === 'inspect') { // 相机角度重置 camera.rotation.x = -0.9662198328141542; camera.rotation.y = 0.0004725006116027576; camera.rotation.z = 0.0006839146449353786; // 相机位置重置 camera.position.set(0.103, 179.349, 123.908); // 相机观察点重置 camera.lookAt(0, 1.7, 0); // 设置相机位置在人物模型后方 camera.position.set(0, -5, -1); // camera.position.set(0, 1.4, -1); // 禁止相机控件旋转平移和缩放 controls.enableRotate = false; controls.enablePan = false; controls.enableZoom = false; controls.target.set(0, 1.7, 0); controls.update(); // 每次开启巡检时,将巡检项目索引和项目索引都重置,从第一个项目开始巡检 inspectIndex.value = 0; inspectPathIndex.value = 0; // 人物步行动画开始播放 WalkAction.play(); // 人物标签开启显示 css2DPeopleLabel.visible = true; // 巡检标线开启显示 inspectLinePointGroup.children[inspectIndex.value].visible = true; // 建筑物标签关闭显示 buildLabelGroup.children.map((item) => { item.visible = false; }); } else if (e !== 'craft') { // 相机角度重置 camera.rotation.x = -0.9662198328141542; camera.rotation.y = 0.0004725006116027576; camera.rotation.z = 0.0006839146449353786; // 相机位置重置 camera.position.set(0.103, 179.349, 123.908); // 相机观察点重置 camera.lookAt(0, 0, 0); controls.target.set(0, 0, 0); controls.update(); // 重置水面透明度水面颜色 waterPlaneGroup.children.map((obj) => { obj.material.uniforms.alpha.value = 1.0; obj.material.uniforms.waterColor.value = obj.color; }); } } ); const clock = new THREE.Clock(); // 设置渲染帧率30FPS,默认情况下requestAnimationFrame在60帧左右,控制帧率优化性能 const FPS = 30; // 间隔多长时间渲染一次 const renderT = 1 / FPS; // 执行一次renderer.render,timeS重新置0 let timeS = 0; // 渲染循环 function render() { stats.update(); // 循环渲染 renderer.render(scene, camera); // 获取两帧渲染间隔时间 const T = clock.getDelta(); timeS = timeS + T; animationMixer.update(T); if (timeS > renderT) { TWEEN.update(); // renderer.render每执行一次,timeS置0 timeS = 0; // css2D标签渲染 css2DRender.render(scene, camera); // 水面波纹动画渲染 waterPlaneGroup.children.map((item) => { item.material.uniforms['time'].value += T / 6; }); // 巡检时标线和拐点动画 if (inspectLinePointGroup.children[inspectIndex.value] && props['selectedMenu'] === 'inspect') { inspectLinePointGroup.children[inspectIndex.value].children.map((item) => { if (item.name === '标线') { item.material.map.offset.x -= 0.03; } else if (item.name === '拐点') { item.rotation.y += 0.02; } }); } // 巡检动画 if (props['selectedMenu'] === 'inspect' && inspectState.value) { openInspection(people, controls); schedule.value = inspectPathIndex.value; // 巡检速度不断更新 if (inspectPathArr[inspectIndex.value]) { speed.value = inspectPathArr[inspectIndex.value].speed; } console.log('巡检动画'); } } requestAnimationFrame(render); } // 巡检状态变化事件 function inspectStateChange() { // 巡检的状态切换 inspectState.value = !inspectState.value; // 关闭巡检数据面板的显示 inspectPanelShow.value = false; if (inspectState.value) { // 人物动画开始播放 WalkAction.play(); if (inspectPathIndex.value >= 100) { // 巡检项目索引加1 inspectIndex.value += 1; // 巡检标记线组对象开启显示 inspectLinePointGroup.children.map((item, index) => { if (index === inspectIndex.value) { item.visible = true; } else { item.visible = false; } }); } } else { // 人物动画停止播放 WalkAction.stop(); } if (inspectPathIndex.value >= 100) { // 巡检项目路径索引重新置零 inspectPathIndex.value = 0; } // 巡检项目索引值超过巡检路径数组时,表示已经巡检完最后一项,调用endInspect()结束巡检 if (inspectIndex.value > inspectPathArr.length - 1) { endInspect(); } } // 结束巡检 function endInspect() { // 人物位置重置 people.position.set(0, 0, 0); // 人物角度重置 people.rotation.y = 0; people.rotation.x = 0; people.rotation.z = 0; // 相机位置重置 camera.position.set(0.103, 179.349, 123.908); // 开启相机控件旋转平移和缩放 controls.enableRotate = true; controls.enablePan = true; controls.enableZoom = true; // 相机控件观察点重置 controls.target.set(0, 1.7, 0); // 相机控件更新 controls.update(); // 巡检状态重置为true inspectState.value = true; // 关闭巡检数据面板显示 inspectPanelShow.value = false; // 人物标签隐藏显示 css2DPeopleLabel.visible = false; // 巡检标记线组对象隐藏显示 inspectLinePointGroup.children.map((item) => { item.visible = false; }); // 建筑物标签开启显示 buildLabelGroup.children.map((item) => { item.visible = true; }); // 巡检速度重置 inspectPathArr.map((item) => { item.speed = inspectionParams[item.name].speed; }); // 关闭巡检 emit('closeInspect'); } // 巡检进度条变化事件 function progressBarChange(e) { inspectPathIndex.value = e; } // 巡检速度条变化事件 function controlBarChange(speed) { inspectPathArr[inspectIndex.value].speed = 0.4 * (speed * 0.01); } // 工艺动画 function craftAnimation(type) { // 重置水面透明度水面颜色 waterPlaneGroup.children.map((obj) => { obj.material.uniforms.alpha.value = 1.0; obj.material.uniforms.waterColor.value = obj.color; }); // 禁止相机控件旋转平移和缩放 // controls.enableRotate = false; // controls.enablePan = false; // controls.enableZoom = false; // 精确曝气动画 if (type === 'aeration') { const name = '南北生物池水面'; // 水面世界坐标位置 const position = sewageModel.getObjectByName(name).getWorldPosition(new THREE.Vector3()); // 开启动画,视角切换到水面处 new TWEEN.Tween(camera.position) .to({ x: -113.85, y: 7.67, z: 43.59 }, 1500) .easing(TWEEN.Easing.Sinusoidal.InOut) .onUpdate(() => { controls.target.copy(new THREE.Vector3(-113, 2, 30)); controls.update(); }) // 动画执行完成后 .onComplete(() => { // 获取水面模型 const waterPlane = waterPlaneGroup.getObjectByName(name); // 加载气泡纹理 const texture = new THREE.TextureLoader().load('./bubbles.png'); // 球体(气泡)材质,map气泡贴图模仿气泡效果 const material = new THREE.MeshPhysicalMaterial({ map: texture, color: '#fff', transparent: true, opacity: 0.6, }); // 球体(气泡)组对象 const sphereGroup = new THREE.Group(); // 创建box3包围盒计算水面模型尺寸 const box3 = new THREE.Box3(); box3.expandByObject(waterPlane); // 根据水面尺寸计算出球体(气泡)出现的范围 const x = ((box3.max.x - box3.min.x) / 2).toFixed(3) - ''; const z = ((box3.max.z - box3.min.z) / 2).toFixed(3) - '' - 0.1; // 循环创建多个球体(气泡) for (let i = 0; i <= 2000; i++) { // 指定随机大小创建球形几何体 const sphere = new THREE.SphereGeometry(Math.random() * 0.03 + 0.05); const mesh = new THREE.Mesh(sphere, material); // 随机旋转一定角度 mesh.rotateX(Math.random() * Math.PI); // 设置位置 mesh.position.copy(position); // y值置空 mesh.position.y = 0; // 随机在增加一定值,使气泡在不同的位置出现 mesh.position.x += Math.random() * (x - -x) + -x; mesh.position.y += Math.random() * 2; mesh.position.z += Math.random() * (z - -z) + -z; // 随机气泡上升的速度值 mesh.speed = Math.random() * 0.04 + 0.04; sphereGroup.add(mesh); } scene.add(sphereGroup); // 此变量用作循环动画和销毁动画 let bubbleRiseAnimationId; // 气泡上升动画 function bubbleRise() { bubbleRiseAnimationId = requestAnimationFrame(bubbleRise); sphereGroup.children.map((item) => { item.position.y += item.speed; if (item.position.y >= position.y) item.position.y = 0; }); } bubbleRise(); // 水面默认的透明度 let alpha = waterPlane.material.uniforms.alpha.value; const color1 = new THREE.Color('#87CEFA'); const color2 = waterPlane.material.uniforms.waterColor.value; // 此变量用作循环动画和销毁动画 let waterPlaneAnimationId; // 水面逐渐透明动画 function waterPlaneTransparent() { waterPlaneAnimationId = requestAnimationFrame(waterPlaneTransparent); // 透明度大于0.3则不断降低透明度 if (alpha >= 0.3) { alpha -= 0.01; waterPlane.material.uniforms.alpha.value = alpha; const newColor = color1.clone().lerp(color2.clone(), alpha); waterPlane.material.uniforms.waterColor.value = newColor; } // 透明度小于0.3 else { // 延迟一定秒数后移除气泡组对象 setTimeout(() => { // scene.remove(sphereGroup); }, 3000); // 传递事件告知动画执行完毕 emit('craftAnimationEnd'); // 销毁水面透明动画和气泡上升动画 cancelAnimationFrame(waterPlaneAnimationId); // cancelAnimationFrame(bubbleRiseAnimationId); } } waterPlaneTransparent(); }) .start(); } // 精确加药 if (type === 'dosing') { const name = '东加药管2-2'; // 加药管位置 const position = sewageModel.getObjectByName(name).getWorldPosition(new THREE.Vector3()); // 将位置偏移一下到出水口 position.y -= 0.138; position.z += 0.22; // 开启Tweenjs动画,将视角切换到加药管处 new TWEEN.Tween(camera.position) .to({ x: 57.16, y: 2.09, z: 6.53 }, 1500) .easing(TWEEN.Easing.Sinusoidal.InOut) .onUpdate(() => { controls.target.copy(position); controls.update(); }) // 视角切换完成后 .onComplete(() => { // 创建一个位置数组,因为这个加药管有多个出水口,每个位置对应一个出水口 const posArr = []; // 当前出水口位置先push到数组里去 posArr.push(position); // 获取左侧出水口位置 for (let i = 1; i <= 4; i++) { const pos = position.clone(); pos.x += i * 0.765; posArr.push(pos); } // 获取右侧出水口位置 for (let i = 1; i <= 4; i++) { const pos = position.clone(); pos.x -= i * 0.765; posArr.push(pos); } // 创建球形几何体,模仿水滴 const sphereGeometry = new THREE.SphereGeometry(0.005, 16, 16); const sphereMaterial = new THREE.MeshPhongMaterial({ color: '#afeeee', }); // 创建球体数组,存储所有的球体 const sphereArr = []; // 每个出水管球体数量 const numSpheres = 300; // 遍历posArr位置数组,给每个出水管创建球体 posArr.map((pos) => { for (let i = 0; i < numSpheres; i++) { const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial); // 默认将其隐藏起来,因为随机高度球体会高于出水管 sphere.visible = false; // 赋值出水管位置 sphere.position.copy(pos); // 球体高度在加上一个随机值 sphere.position.y += Math.random() * 2; // 不同的初始高度 // 设置球体下落速度 sphere.velocity = Math.random() * 0.02 + 0.01; // 随机下落速度 sphereArr.push(sphere); scene.add(sphere); } }); // 此变量用作循环动画和销毁动画 let animationFrameId1; function animation1() { animationFrameId1 = requestAnimationFrame(animation1); // 遍历球体数组 sphereArr.forEach((sphere) => { if (sphere.position.y <= position.y) { sphere.visible = true; } sphere.position.y -= sphere.velocity; if (sphere.position.y <= 0.3) { // 当球体下落到一定位置时 sphere.position.y = position.y; // 重新置于顶端 } }); } animation1(); const waterPlane = waterPlaneGroup.getObjectByName('东西生物池-东水面1'); let alpha = waterPlane.material.uniforms.alpha.value; const color1 = new THREE.Color('#87CEFA'); const color2 = waterPlane.material.uniforms.waterColor.value; // 此变量用作循环动画和销毁动画 let animationFrameId2; function animation2() { animationFrameId2 = requestAnimationFrame(animation2); if (alpha >= 0.5) { alpha -= 0.006; waterPlane.material.uniforms.alpha.value = alpha; const newColor = color1.clone().lerp(color2.clone(), alpha); waterPlane.material.uniforms.waterColor.value = newColor; } else { emit('craftAnimationEnd'); cancelAnimationFrame(animationFrameId2); } } animation2(); }) .start(); } // 污泥回流 if (type === 'sludge') { // 二沉池模型名称数组 const sinkPoolNameArr = [ '二沉池3水面', '二沉池3水面001', '二沉池4水面', '二沉池4水面001', // "初沉池水面1", // "初沉池水面1001", ]; // 生物池模型名称数组 const organismPoolNameArr = ['南北生物池水面', '东西生物池-东水面1', '东西生物池-东水面2', '东西生物池-西水面1', '东西生物池-西水面2']; // 获取二沉池模型 const sinkPoolArr = []; sinkPoolNameArr.map((name) => { sinkPoolArr.push(waterPlaneGroup.getObjectByName(name)); }); // 获取生物池模型 const organismPoolArr = []; organismPoolNameArr.map((name) => { const organismPool = waterPlaneGroup.getObjectByName(name); organismPool.material.uniforms.alpha.value = 0.3; organismPool.visible = false; organismPool.userData.y = organismPool.clone().position.y; organismPool.position.y = 0; organismPoolArr.push(organismPool); }); // x: -10.84, y: 289.89, z: 276.17 // 开启动画,视角切换到整个污水厂 new TWEEN.Tween(camera.position) .to({ x: 100, y: 100, z: 180 }, 1500) .easing(TWEEN.Easing.Sinusoidal.InOut) .onUpdate(() => { controls.target.set(100, 0, -30); controls.update(); }) .onComplete(() => { // 此变量用作循环动画和销毁动画 let waterPlaneAnimationId; // 水面逐渐透明动画 function waterPlaneTransparent() { waterPlaneAnimationId = requestAnimationFrame(waterPlaneTransparent); sinkPoolArr.map((item) => { let alpha = item.material.uniforms.alpha.value; // 透明度大于0.3则不断降低透明度 if (alpha >= 0.3) { alpha -= 0.01; item.material.uniforms.alpha.value = alpha; } }); if (sinkPoolArr[sinkPoolArr.length - 1].material.uniforms.alpha.value < 0.3) { // 传递事件告知动画执行完毕 // emit("craftAnimationEnd"); cancelAnimationFrame(waterPlaneAnimationId); waterLevelRise(); } } waterPlaneTransparent(); // 此变量用作循环动画和销毁动画 let waterLevelRiseAnimationId; function waterLevelRise() { waterLevelRiseAnimationId = requestAnimationFrame(waterLevelRise); organismPoolArr.map((item) => { item.visible = true; const yPos = item.userData.y; let alpha = item.material.uniforms.alpha.value; if (item.position.y < yPos) { item.position.y += 0.01; } if (alpha < 1) { alpha += 0.02; item.material.uniforms.alpha.value = alpha; } if (item.position.y >= yPos && alpha >= 1) { // 传递事件告知动画执行完毕 emit('craftAnimationEnd'); cancelAnimationFrame(waterLevelRiseAnimationId); } }); } }) .start(); } } // 首次进入动画 function eventAnimation() { new TWEEN.Tween(camera.position) .to({ x: 0.103, y: 179.349, z: 123.908 }, 2000) .easing(TWEEN.Easing.Sinusoidal.InOut) .onUpdate(() => { controls.target.set(0, 0, 0); controls.update(); }) .onComplete(() => { // 初始化标签 initLabel(sewageModel); // 将人物标签添加到人物模型中 people.children[0].add(css2DPeopleLabel); // 设置位置在人物模型头顶 css2DPeopleLabel.position.set(0, 2.2, 0); // 设置合适大小 css2DPeopleLabel.scale.set(0.1, 0.1, 0.1); // 人物标签默认隐藏显示 css2DPeopleLabel.visible = false; }) .start(); } function createEnvironment(texture) { // scene.environment = texture; // hdr作为环境贴图生效,设置.mapping为EquirectangularReflectionMapping texture.mapping = THREE.EquirectangularReflectionMapping; // 创建一个巨大球体作为整个天空环境 const sphere = new THREE.SphereGeometry(1000, 512, 512); const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(sphere, material); mesh.position.y -= 100; scene.add(mesh); } </script> <style lang='less'> @import './index.less'; </style>