Vue+Three.js实现PCB电路板3D交互:元器件点击高亮、部件显隐、模型自动旋转

实现效果:

  1. 加载 GLB 格式电路板模型。
  2. 可以用鼠标拖拽旋转、缩放、平移模型。
  3. 点击某个芯片或元器件时,该元件变成半透明蓝色高亮。
  4. 可以一键隐藏 / 显示 PCB 主板,只看内部元器件。
  5. 点击自动旋转按钮后,模型可以平滑进入环绕旋转状态。

演示动态图效果如下:

核心功能原理讲解:

1. Raycaster 射线拾取实现元器件点击高亮:

核心流程:

  • 监听页面点击事件,获取鼠标屏幕坐标。
  • 根据渲染画布位置修正坐标偏移,转换为 Three.js 标准化坐标[-1,1]
  • 通过Raycaster从相机位置发射射线,与场景内模型做碰撞检测。
  • 过滤 PCB 基板,仅元器件触发高亮逻辑。
  • 切换选中对象时,自动恢复上一个元件原始材质。

2. 材质缓存机制,解决高亮无法复原:

新手最容易踩的坑:直接修改 Mesh 原始材质。

Three.js 中多个 Mesh 可以共用同一份材质对象,直接修改原始材质,会导致所有共用材质的物体同步变色,并且退出高亮后无法恢复。标准解决方案:

  • 模型加载完成后,遍历所有 Mesh,克隆并缓存每一个物体的原始材质。
  • 选中元器件时,克隆材质副本进行颜色、透明度修改。
  • 取消选中时,读取缓存恢复原始材质。

3. Tween 镜头缓动 + OrbitControls 自动旋转:

直接开关controls.autoRotate交互生硬,体验较差。

优化方案:

  • 开启旋转:镜头平滑过渡到适合环绕观察的视角 → 延时启动自动旋转。
  • 停止旋转:先关闭自动旋转 → 镜头缓动回归初始观测视角。
  • 状态切换前,强制清理上一轮所有动画帧、Tween 实例、定时器,避免多动画并行造成镜头抖动。

完整代码

1. template 结构:

xml 复制代码
<template>
  <div class="container">
    <div class="btn-group">
      
      <!-- 显示主板控制按钮 -->
      <div class="btn" :class="{'btn-active': type=='0'}" @click="showVoid">显示主板</div>
			
      <!-- 隐藏主板控制按钮 -->
      <div class="btn" :class="{'btn-active': type=='1'}" @click="hideVoid">隐藏主板</div>

      <!-- 自动旋转控制按钮 -->
      <div class="btn" v-show="!isAutoRotate" @click="startRotate">自动旋转</div>

      <!-- 停止旋转控制按钮 -->
      <div class="btn" v-show="isAutoRotate" @click="stopRotate">停止旋转</div>
			
    </div>
    <!-- Three.js渲染画布容器 -->
    <div class="three" id="three" ref="content"></div>
  </div>
</template>

2. script 代码:

ini 复制代码
<script>
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import Stats from 'three/examples/jsm/libs/stats.module.js'
import TWEEN from '@tweenjs/tween.js'

export default {
  beforeDestroy() {
    // 组件销毁,释放所有3D资源、取消帧动画,防止内存泄漏
    this.leaveDestory()
  },
  mounted() {
    this.$nextTick(() => {
      this.init()
      // 监听浏览器窗口大小变化,自适应画布
      window.onresize = () => {
        this.resize()
      }
    })
  },
  data () {
    return {
      // 主板显示状态标记 0=显示 1=隐藏
      type: '0',
      // 模型自动旋转开关标记
      isAutoRotate: false
    }
  },
  methods: {
    /**
     * 停止模型自动旋转
     * 先终止现有动画、Tween缓动,再镜头缓动回归默认视角,最后关闭控制器自动旋转
     */
    stopRotate () {
      // 清理旋转相关requestAnimationFrame
      if (window.myMar2) {
        cancelAnimationFrame(window.myMar2)
        window.myMar2 = null
      }
      // 停止镜头缓动动画
      if (window.myMarTween2) {
        window.myMarTween2.stop()
        window.myMarTween2 = null
      }
      // 清除延时定时器
      if (window.myMar22) {
        clearTimeout(window.myMar22);
        window.myMar22 = null;
      }
      this.isAutoRotate = false;

      // 镜头缓动回归初始观测视角
      const animate = function () {
        if (TWEEN.update()) {
          window.myMar3 = requestAnimationFrame(animate)
        }
      }
      window.myMarTween3 = new TWEEN.Tween({ 
        x: window.camera.position.x, 
        y: window.camera.position.y, 
        z: window.camera.position.z, 
        yControls: window.controls.target.y 
      })
        .to({ 
          x: -0.11506604766249938, 
          y: -4.556768355147646, 
          z: -1.635384307897266, 
          yControls: -1 
        }, 1500)
        .onUpdate((val) => {
          if (window.myMarTween3) {
            window.camera.position.x = val.x
            window.camera.position.y = val.y
            window.camera.position.z = val.z
            window.controls.target.y = val.yControls
          }
        }).start()
      animate()
      // 2秒后关闭轨道控制器自动旋转
      window.myMar33 = setTimeout(() => {
        window.controls.autoRotate = false;
        window.myMar33 = null;
      }, 2000);
    },
    /**
     * 开启模型自动旋转
     * 先清理已有动画,镜头缓动过渡到旋转观测视角,延时开启OrbitControls自动旋转
     */
    startRotate () {
      if (window.myMar3) {
        cancelAnimationFrame(window.myMar3)
        window.myMar3 = null
      }
      if (window.myMarTween3) {
        window.myMarTween3.stop()
        window.myMarTween3 = null
      }
      if (window.myMar33) {
        clearTimeout(window.myMar33);
        window.myMar33 = null;
      }
      this.isAutoRotate = true;

      const animate = function () {
        if (TWEEN.update()) {
          window.myMar2 = requestAnimationFrame(animate)
        }
      }
      // 镜头平滑过渡到适合旋转观察的视角
      window.myMarTween2 = new TWEEN.Tween({ 
        x: window.camera.position.x, 
        y: window.camera.position.y, 
        z: window.camera.position.z, 
        yControls: window.controls.target.y 
      })
        .to({ 
          x: -0.17344362976419006, 
          y: -6.361258392715835, 
          z: -2.465079805757, 
          yControls: -1 
        }, 1500)
        .onUpdate((val) => {
          if (window.myMarTween2) {
            window.camera.position.x = val.x
            window.camera.position.y = val.y
            window.camera.position.z = val.z
            window.controls.target.y = val.yControls
          }
        }).start()
      animate()
      // 等待镜头过渡完成,开启控制器自动旋转
      window.myMar22 = setTimeout(() => {
        window.controls.autoRotate = true;
        window.controls.autoRotateSpeed = 2.0; // 旋转速度,默认1.0
        window.myMar22 = null;
      }, 2000);
      
    },
    /**
     * 显示主板PCB基板(Mesh分组)
     */
    showVoid () {
      const group = window.model.getObjectByName('Mesh');
      if (group) {
        group.visible = true;
        this.type = '0';
      }
    },
    /**
     * 隐藏主板PCB基板(Mesh分组)
     */
    hideVoid () {
      const group = window.model.getObjectByName('Mesh');
      if (group) {
        group.visible = false;
        this.type = '1';
      }
    },
    /**
     * 鼠标点击拾取芯片Mesh,实现选中高亮(半透蓝色)
     * event 原生点击事件
     */
    choose(event) {
      // 标准化鼠标坐标范围 [-1, 1],用于射线投射
      const mouse = new THREE.Vector2();
      // 获取画布相对视口位置,修正鼠标偏移
      const rect = window.renderer.domElement.getBoundingClientRect();
      
      mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
      mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

      // 创建射线投射器
      var raycaster = new THREE.Raycaster();
      // 根据鼠标坐标 + 相机生成射线
      raycaster.setFromCamera(mouse, window.camera);
      // 递归遍历场景所有物体进行碰撞检测
      const intersects = raycaster.intersectObjects(window.scene.children, true);

      // 存在相交物体,代表点击到模型
      if (intersects.length > 0) {
        // 过滤主板基板,点击主板不触发高亮
        if (intersects[0].object.name == 'Mesh') {
          return;
        }
        // 如果存在上次选中物体,恢复原始材质
        if (window.selectObj) {
          let selectObjName = window.selectObj.object.name;
          window.selectObj.object.material = window.materialObj[selectObjName];
          window.selectObj = null
        }
        // 保存当前选中对象
        window.selectObj = intersects[0];

        // 克隆材质,避免修改原始材质影响全局缓存
        let tempMaterial = window.selectObj.object.material.clone();
        window.selectObj.object.material = tempMaterial;
        // 开启透明、设置透明度、修改颜色
        window.selectObj.object.material.transparent = true;
        window.selectObj.object.material.opacity = 0.7;
        window.selectObj.object.material.color = new THREE.Color('rgb(78, 110, 242)');
								
      }
    },
    /**
     * 组件销毁资源回收
     * 清除动画帧、定时器、释放几何体/材质、销毁WebGL上下文,防止内存泄漏
     */
    leaveDestory() {
      window.airlinerLoad = null
      // 清理所有requestAnimationFrame
      if (window.myMar) {
        cancelAnimationFrame(window.myMar)
        window.myMar = null
      }
      if (window.myMar2) {
        cancelAnimationFrame(window.myMar2)
        window.myMar2 = null
      }
      if (window.myMarTween2) {
        window.myMarTween2.stop()
        window.myMarTween2 = null
      }
      if (window.myMar22) {
        clearTimeout(window.myMar22);
        window.myMar22 = null;
      }
      if (window.myMar3) {
        cancelAnimationFrame(window.myMar3)
        window.myMar3 = null
      }
      if (window.myMarTween3) {
        window.myMarTween3.stop()
        window.myMarTween3 = null
      }
      if (window.myMar33) {
        clearTimeout(window.myMar33);
        window.myMar33 = null
      }

      if (window.scene) {
        // 遍历释放所有Mesh几何体、材质显存
        window.scene.traverse(function (v) {
            if (v.type === 'Mesh') {
                if (v.geometry && v.geometry.dispose) {
                    v.geometry.dispose()
                }
                if (v.material && v.material.dispose) {
                    v.material.dispose()
                }
            }
        })
        if (window.scene.children.length > 0) {
          window.scene.remove(window.scene.children[0])
        }
        window.stats = null
        // 销毁渲染器与WebGL上下文
        window.renderer.dispose()
        window.renderer.forceContextLoss()
        window.renderer.domElement = null
        window.renderer = null
        window.camera = null
        // 销毁轨道控制器
        window.controls.dispose()
        window.controls = null
        window.onresize = null
        window.scene.clear()
        window.scene = null
        window.model = null
        window.selectObj = null
        window.newMaterial = null
      }
    },
    /**
     * 窗口自适应,更新相机宽高比与渲染画布尺寸
     */
    resize() {
      let width = this.$refs.content.offsetWidth
      let height = this.$refs.content.offsetHeight
      window.camera.aspect = width / height
      window.camera.updateProjectionMatrix()
      window.renderer.setSize(width, height)
    },
    /**
     * 初始化场景、相机、渲染器、灯光、加载GLB电路板模型
     */
    init() {
      let width = this.$refs.content.offsetWidth
      let height = this.$refs.content.offsetHeight
      // 创建场景容器
      window.scene = new THREE.Scene()
      // 透视相机
      window.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
      // WebGL渲染器,开启抗锯齿
      window.renderer = new THREE.WebGLRenderer({ antialias: true })
      window.renderer.setPixelRatio(window.devicePixelRatio)
      window.renderer.setClearColor(0xdddddd, 1.0) // 画布背景浅灰色
      window.renderer.setSize(width, height)
      window.renderer.shadowMapEnabled = true
      // 初始化相机位置
      window.camera.position.x = -0.11506604766249938
      window.camera.position.y = -4.556768355147646
      window.camera.position.z = -1.635384307897266
      window.camera.lookAt(window.scene.position)
      window.stats = new Stats()
      // 轨道控制器:拖拽旋转、缩放、平移模型
      window.controls = new OrbitControls(window.camera, window.renderer.domElement);
      window.controls.target.set(0, -1, 0);
      window.controls.enablePan = true;
      window.controls.enableDamping = true; // 开启阻尼平滑拖拽
      window.controls.dampingFactor = 0.5;
      window.controls.update();

      // 环境半球光,柔和基础照明
      let hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.3)
      hemiLight.position.set(0, 500, 0)
      window.scene.add(hemiLight)
      // 聚光灯,增加模型明暗层次
      let spotLight = new THREE.SpotLight(0xffffff)
      spotLight.position.set(-40, 60, -10)
      spotLight.castShadow = true
      window.scene.add(spotLight)

      // GLTF模型加载器
      let loader = new GLTFLoader()
      window.airlinerLoad = true
      loader.load('static/models/chip.glb', (gltf) => {
        if (window.airlinerLoad) {
          // 取出电路板模型根节点
          window.model = gltf.scene.children[1];
          // 预克隆保存所有Mesh原始材质,用于选中后恢复
          let materialObj = {};
          window.model.traverse((child) => {
            if (child.isMesh) {
              materialObj[child.name] = child.material.clone();
            }
          });
          window.materialObj = materialObj;
          // 将模型加入场景
          window.scene.add(window.model);
          // 启动渲染循环
          this.animate()
          // 全局点击监听,触发射线拾取
          document.onclick = (e) => {
            this.choose(e)
          }
        }
      })
      // 将渲染画布DOM挂载到页面容器
      document.getElementById('three').appendChild(window.renderer.domElement)
    },
    /**
     * 主渲染循环
     */
    animate() {
      window.myMar = requestAnimationFrame(this.animate)
      window.controls.update() // 更新轨道控制器阻尼
      window.stats.update()
      window.renderer.render(window.scene, window.camera)
    }
  }
}
</script>

3. css样式代码:

css 复制代码
* {
  margin: 0;
  padding: 0;
}

.container {
  width: 100%;
  height: 100%;
  position: relative;
}

.three {
  width: 100%;
  height: 100%;
  position: relative;
  z-index: 1;
}

.btn-group {
  position: absolute;
  right: 0;
  top: 15px;
  z-index: 2;
  display: flex;
  justify-content: start;
  align-items: center;
}

.btn {
  width: 80px;
  height: 32px;
  line-height: 32px;
  text-align: center;
  border: 1px solid #dcdfe6;
  border-radius: 3px;
  margin-right: 15px;
  margin-left: 0;
  cursor: pointer;
  font-size: 12px;
  color: #606266;
}

.btn-active {
  background-color: #409eff;
  color: #FFF;
}

模型说明: 本文演示所用的 3D 模型来源于网络,版权归原作者所有。本文仅做 Three.js 前端技术学习演示,并未对模型本身做任何修改。

相关推荐
GitLqr3 小时前
提升 Web 开发效率:我常用的 10 个 TypeScript 实战技巧
vue.js·react.js·typescript
AI_paid_community7 小时前
机械研发人员如何使用 Claude?
java·vue.js
cdcdhj7 小时前
vue3中的watchEffect()监听,什么时候监听,什么时候清理,什么时候停止
前端·javascript·vue.js
无人生还7 小时前
从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer
前端·vue.js·react.js
答案answer8 小时前
做了几年 Three.js,我为什么决定自己开发一款3D低代码编辑器
前端·three.js
林间码客8 小时前
全栈视角下的 Mock 指南:从后端单元测试到前端 Vue 3 联调
前端·vue.js·单元测试
Cobyte8 小时前
手写 Rollup 插件实现解析 Vue 文件
前端·javascript·vue.js
meilindehuzi_a10 小时前
Vue3组件样式隔离与组件通信实战:从 scoped、props 校验到记事本应用
前端·javascript·vue.js
腻害兔1 天前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:IM 即时通讯模块,一个被低估的「全功能聊天系统」
java·前端·vue.js·产品经理·ai编程