实现效果:
- 加载 GLB 格式电路板模型。
- 可以用鼠标拖拽旋转、缩放、平移模型。
- 点击某个芯片或元器件时,该元件变成半透明蓝色高亮。
- 可以一键隐藏 / 显示 PCB 主板,只看内部元器件。
- 点击自动旋转按钮后,模型可以平滑进入环绕旋转状态。
演示动态图效果如下:
核心功能原理讲解:
1. Raycaster 射线拾取实现元器件点击高亮:
核心流程:
- 监听页面点击事件,获取鼠标屏幕坐标。
- 根据渲染画布位置修正坐标偏移,转换为 Three.js 标准化坐标
[-1,1]。 - 通过
Raycaster从相机位置发射射线,与场景内模型做碰撞检测。 - 过滤 PCB 基板,仅元器件触发高亮逻辑。
- 切换选中对象时,自动恢复上一个元件原始材质。
2. 材质缓存机制,解决高亮无法复原:
新手最容易踩的坑:直接修改 Mesh 原始材质。
Three.js 中多个 Mesh 可以共用同一份材质对象,直接修改原始材质,会导致所有共用材质的物体同步变色,并且退出高亮后无法恢复。标准解决方案:
- 模型加载完成后,遍历所有 Mesh,克隆并缓存每一个物体的原始材质。
- 选中元器件时,克隆材质副本进行颜色、透明度修改。
- 取消选中时,读取缓存恢复原始材质。
3. Tween 镜头缓动 + OrbitControls 自动旋转:
直接开关controls.autoRotate交互生硬,体验较差。
优化方案:
- 开启旋转:镜头平滑过渡到适合环绕观察的视角 → 延时启动自动旋转。
- 停止旋转:先关闭自动旋转 → 镜头缓动回归初始观测视角。
- 状态切换前,强制清理上一轮所有动画帧、Tween 实例、定时器,避免多动画并行造成镜头抖动。
完整代码
1. template 结构:
<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 代码:
<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样式代码:
* {
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 前端技术学习演示,并未对模型本身做任何修改。