1. 为什么选择Three.js+Vue3开发BIM查看器
最近在做一个建筑行业的项目时,客户要求能在网页端直接查看BIM模型,并且要支持基本的测量功能。经过技术选型,最终选择了Three.js+Vue3的方案,这里分享一下我的实战经验。
Three.js作为最流行的WebGL库,就像是给浏览器装上了3D引擎。而Vue3的组合式API特别适合管理3D场景中的各种状态。两者配合起来,就像咖啡配奶泡——一个负责底层渲染,一个负责界面交互,简直是天生一对。
我对比过几种方案:
- 纯Three.js开发:代码组织比较混乱
- React+Three.js:状态管理稍显复杂
- Vue3+Three.js:开发体验最流畅
特别是在处理BIM模型这种复杂场景时,Vue3的响应式系统能让开发效率提升不少。比如当用户进行测量操作时,测量结果可以实时显示在侧边栏,这种联动用Vue3实现特别简单。
2. 环境搭建与基础配置
2.1 初始化Vue3项目
首先用Vite创建一个新项目:
npm create vite@latest bim-viewer --template vue cd bim-viewer npm install three @tweenjs/tween.js这里我推荐使用Vite而不是Webpack,因为3D应用需要加载的模型文件通常比较大,Vite的开发服务器启动速度更快,热更新也更灵敏。
2.2 Three.js基础场景搭建
在components文件夹下新建一个BIMViewer.vue组件:
<script setup> import * as THREE from 'three' import { onMounted, ref } from 'vue' const container = ref(null) onMounted(() => { // 初始化场景 const scene = new THREE.Scene() scene.background = new THREE.Color(0xf0f0f0) // 初始化相机 const camera = new THREE.PerspectiveCamera( 75, container.value.clientWidth / container.value.clientHeight, 0.1, 1000 ) camera.position.z = 5 // 初始化渲染器 const renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(container.value.clientWidth, container.value.clientHeight) container.value.appendChild(renderer.domElement) // 添加一个立方体测试 const geometry = new THREE.BoxGeometry() const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) const cube = new THREE.Mesh(geometry, material) scene.add(cube) // 动画循环 function animate() { requestAnimationFrame(animate) cube.rotation.x += 0.01 cube.rotation.y += 0.01 renderer.render(scene, camera) } animate() }) </script> <template> <div ref="container" class="viewer-container"></div> </template> <style> .viewer-container { width: 100%; height: 100vh; } </style>这个基础架子跑起来后,你会看到一个旋转的绿色立方体。虽然简单,但已经包含了Three.js最核心的三要素:场景、相机和渲染器。
3. BIM模型加载与优化
3.1 支持DWG/DXF文件加载
实际项目中,BIM模型通常是DWG或DXF格式。Three.js本身不支持直接加载这些格式,需要借助一些解析库。我推荐使用dxf-parser这个库:
npm install dxf-parser然后在组件中添加模型加载逻辑:
import { DxfParser } from 'dxf-parser' async function loadDxfModel(url) { const response = await fetch(url) const text = await response.text() const parser = new DxfParser() const dxf = parser.parseSync(text) // 将DXF实体转换为Three.js对象 const group = new THREE.Group() dxf.entities.forEach(entity => { if (entity.type === 'LINE') { const geometry = new THREE.BufferGeometry() geometry.setAttribute( 'position', new THREE.Float32BufferAttribute([ entity.start.x, entity.start.y, entity.start.z, entity.end.x, entity.end.y, entity.end.z ], 3) ) const material = new THREE.LineBasicMaterial({ color: 0x000000 }) const line = new THREE.Line(geometry, material) group.add(line) } // 处理其他实体类型... }) scene.add(group) }3.2 模型性能优化
BIM模型往往非常复杂,直接渲染可能导致浏览器卡死。这里分享几个优化技巧:
- 使用InstancedMesh:对于重复的构件(如门窗),使用实例化渲染
const geometry = new THREE.BoxGeometry(1, 1, 1) const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) const mesh = new THREE.InstancedMesh(geometry, material, 1000) // 设置每个实例的位置 const matrix = new THREE.Matrix4() for (let i = 0; i < 1000; i++) { matrix.setPosition(Math.random() * 100, Math.random() * 100, Math.random() * 100) mesh.setMatrixAt(i, matrix) } scene.add(mesh)- 实现LOD(Level of Detail):根据模型与相机的距离显示不同精度的几何体
const lod = new THREE.LOD() // 添加不同层级的细节 const highDetail = new THREE.SphereGeometry(1, 32, 32) const mediumDetail = new THREE.SphereGeometry(1, 16, 16) const lowDetail = new THREE.SphereGeometry(1, 8, 8) lod.addLevel(highDetail, 5) // 距离<5时使用高模 lod.addLevel(mediumDetail, 20) // 距离<20时使用中模 lod.addLevel(lowDetail, 40) // 距离>=20时使用低模 scene.add(lod)- 使用Web Worker解析模型:避免主线程阻塞
4. 实现测量工具
4.1 距离测量功能
测量功能是BIM查看器的核心需求。先实现最简单的距离测量:
let measurePoints = [] let measureLine = null let measureLabels = [] function setupMeasurement() { container.value.addEventListener('click', (event) => { const mouse = new THREE.Vector2( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ) const raycaster = new THREE.Raycaster() raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObjects(scene.children) if (intersects.length > 0) { measurePoints.push(intersects[0].point) if (measurePoints.length === 2) { // 计算两点间距离 const distance = measurePoints[0].distanceTo(measurePoints[1]) // 创建测量线 if (measureLine) scene.remove(measureLine) const lineGeometry = new THREE.BufferGeometry().setFromPoints(measurePoints) measureLine = new THREE.Line( lineGeometry, new THREE.LineBasicMaterial({ color: 0xff0000 }) ) scene.add(measureLine) // 添加距离标签 const midPoint = new THREE.Vector3() midPoint.addVectors(measurePoints[0], measurePoints[1]).multiplyScalar(0.5) const label = createLabel(`${distance.toFixed(2)}米`, midPoint) measureLabels.push(label) scene.add(label) measurePoints = [] } } }) } function createLabel(text, position) { const canvas = document.createElement('canvas') canvas.width = 256 canvas.height = 128 const context = canvas.getContext('2d') context.fillStyle = 'rgba(255,255,255,0.7)' context.fillRect(0, 0, canvas.width, canvas.height) context.font = '24px Arial' context.fillStyle = '#000000' context.textAlign = 'center' context.fillText(text, canvas.width/2, canvas.height/2) const texture = new THREE.CanvasTexture(canvas) const material = new THREE.SpriteMaterial({ map: texture }) const sprite = new THREE.Sprite(material) sprite.position.copy(position) sprite.scale.set(0.5, 0.25, 1) return sprite }4.2 角度测量实现
角度测量稍微复杂些,需要计算三个点形成的夹角:
let anglePoints = [] function setupAngleMeasurement() { container.value.addEventListener('click', (event) => { const mouse = new THREE.Vector2( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ) const raycaster = new THREE.Raycaster() raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObjects(scene.children) if (intersects.length > 0) { anglePoints.push(intersects[0].point) if (anglePoints.length === 3) { // 计算向量 const v1 = new THREE.Vector3().subVectors(anglePoints[0], anglePoints[1]) const v2 = new THREE.Vector3().subVectors(anglePoints[2], anglePoints[1]) // 计算角度(弧度转角度) const angle = v1.angleTo(v2) * (180 / Math.PI) // 创建三条边 const lineGeometry1 = new THREE.BufferGeometry().setFromPoints([anglePoints[0], anglePoints[1]]) const lineGeometry2 = new THREE.BufferGeometry().setFromPoints([anglePoints[1], anglePoints[2]]) const line1 = new THREE.Line( lineGeometry1, new THREE.LineBasicMaterial({ color: 0x0000ff }) ) const line2 = new THREE.Line( lineGeometry2, new THREE.LineBasicMaterial({ color: 0x0000ff }) ) scene.add(line1) scene.add(line2) // 添加角度标签 const labelPos = anglePoints[1].clone() labelPos.y += 0.5 const label = createLabel(`${angle.toFixed(2)}°`, labelPos) scene.add(label) anglePoints = [] } } }) }5. 高级功能与性能调优
5.1 实现模型剖切功能
BIM查看器经常需要查看模型内部结构,剖切功能非常实用:
let clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0) function setupClipping() { // 在渲染器中启用裁剪 renderer.localClippingEnabled = true // 为所有材质启用裁剪 scene.traverse(function(child) { if (child.material) { if (Array.isArray(child.material)) { child.material.forEach(material => { material.clippingPlanes = [clipPlane] }) } else { child.material.clippingPlanes = [clipPlane] } } }) // 添加剖切面控制 const controls = { planeX: 0, planeY: -1, planeZ: 0, constant: 0 } // 使用GUI控制剖切面 const gui = new dat.GUI() gui.add(controls, 'planeX', -1, 1).onChange(updateClipPlane) gui.add(controls, 'planeY', -1, 1).onChange(updateClipPlane) gui.add(controls, 'planeZ', -1, 1).onChange(updateClipPlane) gui.add(controls, 'constant', -10, 10).onChange(updateClipPlane) } function updateClipPlane() { clipPlane.normal.set(controls.planeX, controls.planeY, controls.planeZ).normalize() clipPlane.constant = controls.constant }5.2 性能监控与优化
对于复杂的BIM场景,性能监控至关重要:
function setupPerformanceMonitor() { const stats = new Stats() stats.showPanel(0) // 0: fps, 1: ms, 2: mb document.body.appendChild(stats.dom) function animate() { stats.begin() // 渲染逻辑... stats.end() requestAnimationFrame(animate) } animate() } // 内存监控 function logMemoryUsage() { setInterval(() => { const memory = window.performance.memory console.log( `Used JS Heap: ${(memory.usedJSHeapSize / 1048576).toFixed(2)} MB / Total JS Heap: ${(memory.totalJSHeapSize / 1048576).toFixed(2)} MB` ) }, 5000) }在实际项目中,我发现Three.js的内存管理有几个关键点:
- 及时dispose不再需要的几何体和材质
- 避免在动画循环中创建新对象
- 使用BufferGeometry代替Geometry
- 对大型模型进行分块加载