This commit is contained in:
2025-11-21 13:30:33 +08:00
parent eb611f1c8f
commit 4e3455a5b6
5 changed files with 647 additions and 218 deletions

93
README_2.md Normal file
View File

@@ -0,0 +1,93 @@
# Three.js 3D 可视化开发
本文档整理了化工/工业仿真平台开发中 8 大核心功能的实现原理,以及 PBR 材质系统的基础概念。
---
## 第一部分8 大核心功能模块实现原理
### 1. 设置地面 (Ground Setup)
* **原理**:使用 `PlaneGeometry` 创建平面网格,并旋转 90 度使其水平平铺。
* **材质风格**
* **实景风**:使用 PBR 材质加载沥青、水泥或草地贴图。
* **科技风**:使用 `GridHelper` 或自定义 Shader 制作深色背景下的发光网格(工业软件常用)。
* **关键点**:必须开启 `receiveShadow = true`,否则模型会有“悬浮感”。
### 2. 地面光效 (Ground Lighting Effects)
* **原理**:利用 **Shader着色器****贴图映射** 模拟光效,而非消耗性能的实时光源。
* **常见效果**
* **镜面反射**:使用 `Reflector``SSR` 模拟潮湿/打蜡地面。
* **雷达/扩散波**:在 Shader 中利用距离和正弦波 (`sin(time)`) 计算透明度,形成向外扩散的光环。
* **聚焦光**:配合 `SpotLight` 和体积光技术模拟丁达尔效应。
### 3. 模型漫游 (Roaming / Inspection)
* **本质**摄像机Camera位置和朝向的连续变化。
* **实现方式**
* **自动巡检**:使用 `CatmullRomCurve3` 定义路径,通过 `progress (0-1)` 获取坐标赋值给摄像机。使用 `getTangentAt` 保持视线朝前。
* **点击移动**:利用 **Raycaster** 获取地面点击坐标使用动画库GSAP/TWEEN平滑过渡摄像机位置。
### 4. 模型动画 (Model Animation)
* **原理**播放模型文件GLTF/FBX自带的骨骼或关键帧动画。
* **核心系统**`AnimationMixer`
* **流程**:获取 `clips` -> 创建 `mixer` -> 生成 `action` -> 调用 `.play()`
* **驱动**:必须在渲染循环中每一帧调用 `mixer.update(deltaTime)`
### 5. 修改材质与告警 (Material Changes / Alerts)
* **原理**:遍历模型树,查找目标 Mesh 并修改 Material 属性。
* **告警实现**
* **查找**`scene.getObjectByName('设备ID')`
* **高亮**:修改 `material.emissive`(自发光颜色)及强度,确保在暗处可见。
* **呼吸闪烁**:在渲染循环中利用 `Math.sin(Date.now())` 动态改变发光强度或透明度。
### 6. 粒子效果 (Particles - 蒸汽/火焰)
* **原理**大量始终面向摄像机的微小平面Billboards/Sprites
* **数据结构**`THREE.Points` + `BufferGeometry`
* **运动逻辑**:每一帧更新粒子 Y 轴(上升),增加 X/Z 轴随机扰动(扩散),生命周期结束时重置位置。
### 7. 模型与 Web 交互 (Interaction)
* **鼠标 -> 3D (点击)**
* 利用 **Raycaster** 将屏幕坐标转为射线,检测与 Mesh 的相交,获取点击对象 ID 以触发 UI 弹窗。
* **3D -> 2D (标签跟随)**
* 利用 **`CSS2DRenderer`** 或坐标投影 (`vector.project(camera)`)。
* 将 3D 坐标转为屏幕 XY 坐标,通过 CSS `transform` 移动 HTML 标签,使其“粘”在模型上。
### 8. 后期处理 (Post-processing)
* **原理**`EffectComposer` 接管渲染流程,对离屏缓冲区图像进行像素级处理。
* **常用流水线**
1. `RenderPass` (基础画面)
2. `OutlinePass` (鼠标悬停高亮/描边)
3. `UnrealBloomPass` (辉光/泛光,增加科技感)
4. `FXAAPass` / `SMAAPass` (抗锯齿)
---
## 第二部分PBR 材质贴图详解
在物理渲染PBR流程中这三张贴图决定了模型的质感与真实度。
### 1. 纹理贴图 (Albedo / Base Color Map)
* **通俗解释****“包装纸”** 或 **“颜值”**。
* **作用**:决定物体表面的基础颜色和图案。
* **代码属性**`material.map`
### 2. 法线贴图 (Normal Map)
* **通俗解释****“伪造的凹凸”** 或 **“光影魔术”**。
* **特征**:通常为紫蓝色图片。
* **作用**:欺骗光线,让平坦的模型表面呈现出凹凸细节(如砖缝、刻痕),无需增加几何体面数。
* **代码属性**`material.normalMap`
### 3. 粗糙度贴图 (Roughness Map)
* **通俗解释****“磨砂纸的目数”** 或 **“质感”**。
* **特征**:黑白灰图片。
* **作用**:决定表面反光的清晰度。
* **黑色 (0.0)**:光滑、镜面反射(如湿瓷砖)。
* **白色 (1.0)**:粗糙、漫反射(如水泥、灰尘)。
* **灰色**:半哑光(如拉丝金属)。
* **代码属性**`material.roughnessMap`
### 综合应用示例:旧化工反应罐
| 贴图类型 | 视觉贡献 |
| :--- | :--- |
| **Color Map** | 决定罐子是绿色的,局部有棕色锈迹。 |
| **Normal Map** | 让锈迹看起来凹凸不平,漆面有剥落感。 |
| **Roughness Map** | 让绿色漆面有微弱反光,而锈迹部分完全粗糙哑光。 |

View File

@@ -40,6 +40,8 @@ const flameEmitters: any[] = [] // 所有火焰发射器数组
const burningMeshes = new Set<THREE.Mesh>() // 已着火的网格集合
let spreadTimer = 0 // 蔓延计时器
const spreadInterval = 2 // 每2秒蔓延一次
let centerFireEmitter: any = null // 中心大火发射器
let totalMeshCount = 0 // 模型中网格总数
// 地板流动效果
let flowingGround: any
@@ -204,8 +206,11 @@ function loadModel(particleUtils: any) {
loader.load('/mzjc_bansw.glb', (gltf) => {
model = gltf.scene
// 统计网格总数
totalMeshCount = 0
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
totalMeshCount++
child.castShadow = true
child.receiveShadow = true
child.userData.objectType = '建筑构件'
@@ -292,6 +297,54 @@ function spreadFire() {
}
})
})
// 检查是否已经蔓延到整个模型80%以上的网格着火)
if (burningMeshes.size >= totalMeshCount * 0.8 && !centerFireEmitter) {
createCenterFire()
}
}
// 创建中心大火效果
function createCenterFire() {
if (!model)
return
const particleUtils = useParticleSystem()
// 移除所有小火焰发射器
flameEmitters.forEach(({ emitter }) => {
emitter.stop()
// 保留发光效果
})
flameEmitters.length = 0
// 计算模型中心和范围
const modelBox = new THREE.Box3().setFromObject(model)
const modelCenter = modelBox.getCenter(new THREE.Vector3())
const modelSize = modelBox.getSize(new THREE.Vector3())
// 在中心创建大型火焰发射器
centerFireEmitter = particleUtils.createFlameEmitter(
scene,
new THREE.Vector3(modelCenter.x, modelBox.min.y + modelSize.y * 0.3, modelCenter.z),
30, // 大量粒子
)
// 添加多个位置的火焰,模拟整体燃烧
const positions = [
new THREE.Vector3(modelBox.min.x, modelBox.min.y + 1, modelCenter.z),
new THREE.Vector3(modelBox.max.x, modelBox.min.y + 1, modelCenter.z),
new THREE.Vector3(modelCenter.x, modelBox.min.y + 1, modelBox.min.z),
new THREE.Vector3(modelCenter.x, modelBox.min.y + 1, modelBox.max.z),
]
positions.forEach((pos) => {
const emitter = particleUtils.createFlameEmitter(scene, pos, 20)
flameEmitters.push({ emitter, mesh: null })
})
// 添加主中心发射器
flameEmitters.push({ emitter: centerFireEmitter, mesh: null })
}
function animate() {
@@ -495,6 +548,20 @@ function toggleFlame() {
emitter.stop()
// 移除发光效果
if (mesh && mesh.material) {
const material = mesh.material as THREE.MeshStandardMaterial
material.emissive = new THREE.Color(0x000000)
material.emissiveIntensity = 0
}
})
// 停止中心大火
if (centerFireEmitter) {
centerFireEmitter = null
}
// 移除所有物体的发光效果
burningMeshes.forEach((mesh) => {
if (mesh.material) {
const material = mesh.material as THREE.MeshStandardMaterial
material.emissive = new THREE.Color(0x000000)

View File

@@ -1,3 +1,16 @@
<!--
* @Description: Demo 5 - 地面光效动画
* @功能说明: 实现地面流动的彩色光效线条模拟科幻风格的地面扫描效果
* @核心技术:
* 1. Three.js 裁剪平面Clipping Planes- 限制线条在地面范围内显示
* 2. 动态位置更新 - 实现线条在地面上来回移动的动画效果
* 3. 延迟启动机制 - 不同颜色线条依次出现形成波浪式动画
* 4. 双轴运动 - 线条同时在 X 轴和 Z 轴方向移动形成交叉效果
* @Autor: 相卿
* @Date: 2025-11-17 16:07:33
* @LastEditors: 相卿
* @LastEditTime: 2025-11-21 10:04:12
-->
<script lang="ts" setup>
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
@@ -5,32 +18,41 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
// ========== 常量定义 ==========
const GROUND_SIZE = 30
const GROUND_THICKNESS = 0.2
const LINE_COLORS = [0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF]
const LINE_SPEED = 0.1
const LINE_DELAY = 300
const GROUND_SIZE = 30 // 地面尺寸30x30
const GROUND_THICKNESS = 0.2 // 地面厚度
const LINE_COLORS = [0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF] // 6种光效颜色红绿蓝黄紫青
const LINE_SPEED = 0.1 // 线条移动速度
const LINE_DELAY = 300 // 每条线启动延迟(毫秒)
// ========== 类型定义 ==========
/**
* 线条状态接口
* 用于追踪每条光效线的运动状态
*/
interface LineState {
mesh: THREE.Mesh
direction: number
startDelay: number
elapsed: number
axis: 'x' | 'z'
mesh: THREE.Mesh // 线条网格对象
direction: number // 移动方向1 或 -1
startDelay: number // 启动延迟时间(毫秒)
elapsed: number // 已经过时间(毫秒)
axis: 'x' | 'z' // 移动轴向x 轴或 z 轴
}
// ========== 状态变量 ==========
const threeRef = shallowRef<HTMLCanvasElement>()
const threeRef = shallowRef<HTMLCanvasElement>() // Canvas 容器引用
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let renderer: THREE.WebGLRenderer
let controls: OrbitControls
let animationId: number
// Three.js 核心对象
let scene: THREE.Scene // 场景对象
let camera: THREE.PerspectiveCamera // 透视相机
let renderer: THREE.WebGLRenderer // WebGL 渲染器
let controls: OrbitControls // 轨道控制器
let animationId: number // 动画帧 ID
const lineStates: LineState[] = []
const lineStates: LineState[] = [] // 所有光效线条的状态数组
/**
* 获取容器尺寸
* @returns {width, height} 容器的宽度和高度
*/
function getElementSize() {
const width = threeRef.value!.clientWidth
const height = threeRef.value!.clientHeight
@@ -39,43 +61,48 @@ function getElementSize() {
}
// ========== 初始化场景 ==========
/**
* 初始化 Three.js 场景
* 创建场景、相机、渲染器、控制器等核心对象
*/
function initModel() {
const { width, height } = getElementSize()
// 创建场景
// 创建场景 - 所有 3D 对象的容器
scene = new THREE.Scene()
scene.background = new THREE.Color(0x000000)
scene.background = new THREE.Color(0x000000) // 黑色背景
// 创建相机
// 创建透视相机 - 模拟人眼视角
// 参数:视场角 75°, 宽高比, 近裁剪面 0.1, 远裁剪面 1000
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
camera.position.set(3, 8, 12)
camera.position.set(3, 8, 12) // 设置相机位置(斜上方观察)
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.localClippingEnabled = true
threeRef.value!.appendChild(renderer.domElement)
// 创建 WebGL 渲染器
renderer = new THREE.WebGLRenderer({ antialias: true }) // 开启抗锯齿
renderer.setSize(width, height) // 设置渲染尺寸
renderer.setPixelRatio(window.devicePixelRatio) // 设置设备像素比(高清屏适配)
renderer.shadowMap.enabled = true // 启用阴影
renderer.shadowMap.type = THREE.PCFSoftShadowMap // 使用 PCF 软阴影
renderer.localClippingEnabled = true // 启用局部裁剪(用于光效线条边界裁剪)
threeRef.value!.appendChild(renderer.domElement) // 将渲染器的 canvas 添加到 DOM
// 加载模型
// 加载 GLTF 模型
const loader = new GLTFLoader()
loader.load('/mzjc_bansw.glb', (gltf) => {
scene.add(gltf.scene)
scene.add(gltf.scene) // 将模型添加到场景
})
// 创建控制器
// 创建轨道控制器 - 允许用户通过鼠标控制相机
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.enableDamping = true // 启用阻尼(惯性)
controls.dampingFactor = 0.05 // 阻尼系数
// 添加辅助工具
// 添加坐标轴辅助工具(红 X, 绿 Y, 蓝 Z
const axesHelper = new THREE.AxesHelper(5)
scene.add(axesHelper)
// 添加环境光
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 1.2)
// 添加环境光 - 提供基础照明
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 1.2) // 白色环境光,强度 1.2
scene.add(ambientLight)
// 添加地面
@@ -86,135 +113,217 @@ function initModel() {
}
// ========== 添加地面 ==========
/**
* 创建地面平台
* 使用带厚度的立方体作为地面,用于承载光效动画
*/
function addGround() {
// 创建立方体几何体(宽 30, 高 0.2, 深 30
const geometry = new THREE.BoxGeometry(GROUND_SIZE, GROUND_THICKNESS, GROUND_SIZE)
// 创建标准材质(深灰色)
const material = new THREE.MeshStandardMaterial({ color: 0x222222 })
// 创建网格对象
const ground = new THREE.Mesh(geometry, material)
// 设置地面位置Y 轴向下偏移一半厚度,使顶面在 Y=0
ground.position.y = -GROUND_THICKNESS / 2
ground.receiveShadow = true
ground.receiveShadow = true // 接收阴影
scene.add(ground)
}
// ========== 添加地面光效 ==========
/**
* 添加地面流动光效
* 创建多条彩色光带在地面上来回移动,形成科幻扫描效果
*
* 技术要点:
* 1. 使用裁剪平面确保光带只在地面范围内显示
* 2. 为每条光带设置不同的启动延迟,形成波浪效果
* 3. 光带同时在 X 轴和 Z 轴方向运动,形成交叉图案
* 4. 使用半透明材质和不同透明度创造层次感
*/
function addGroundLightEffect() {
// 创建裁剪平面
// 创建四个裁剪平面,限制光效在地面范围内
// 裁剪平面的法向量指向内部,距离原点 GROUND_SIZE/2
const clipPlanes = [
new THREE.Plane(new THREE.Vector3(1, 0, 0), GROUND_SIZE / 2),
new THREE.Plane(new THREE.Vector3(-1, 0, 0), GROUND_SIZE / 2),
new THREE.Plane(new THREE.Vector3(0, 0, 1), GROUND_SIZE / 2),
new THREE.Plane(new THREE.Vector3(0, 0, -1), GROUND_SIZE / 2),
new THREE.Plane(new THREE.Vector3(1, 0, 0), GROUND_SIZE / 2), // 左边界(法向量向右)
new THREE.Plane(new THREE.Vector3(-1, 0, 0), GROUND_SIZE / 2), // 右边界(法向量向左)
new THREE.Plane(new THREE.Vector3(0, 0, 1), GROUND_SIZE / 2), // 前边界(法向量向后)
new THREE.Plane(new THREE.Vector3(0, 0, -1), GROUND_SIZE / 2), // 后边界(法向量向前)
]
// 创建光带的几何体(长 30, 高 0.1, 宽 0.5 的扁平立方体)
const geometry = new THREE.BoxGeometry(GROUND_SIZE, 0.1, 0.5)
// 为每种颜色创建一对光带X 轴和 Z 轴各一条)
LINE_COLORS.forEach((color, index) => {
// 创建基础材质
const material = new THREE.MeshBasicMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: 0.15 * (index + 1) * Math.random(),
clippingPlanes: clipPlanes,
clipShadows: true,
depthWrite: false,
color: new THREE.Color(color), // 光带颜色
transparent: true, // 启用透明
opacity: 0.15 * (index + 1) * Math.random(), // 随机透明度(越后面越不透明)
clippingPlanes: clipPlanes, // 应用裁剪平面
clipShadows: true, // 裁剪阴影
depthWrite: false, // 禁用深度写入(避免透明物体遮挡问题)
})
// 计算光带在垂直方向上的偏移位置
// 将地面平均分成 6 段,每条光带占一段的中心位置
const offset = -GROUND_SIZE / 2 + (GROUND_SIZE / LINE_COLORS.length) * (index + 0.5)
const isEven = index % 2 === 0
const isEven = index % 2 === 0 // 判断是否为偶数索引
// 创建X轴线条
// ===== 创建 X 轴方向的光带 =====
const xMesh = new THREE.Mesh(geometry, material.clone())
// 设置初始位置:偶数索引从右边开始,奇数从左边开始
xMesh.position.set(isEven ? GROUND_SIZE : -GROUND_SIZE, 0, offset)
xMesh.renderOrder = 1
xMesh.renderOrder = 1 // 设置渲染顺序(确保正确的透明渲染)
scene.add(xMesh)
// 记录 X 轴光带的状态
lineStates.push({
mesh: xMesh,
direction: isEven ? -1 : 1,
startDelay: index * LINE_DELAY,
elapsed: 0,
axis: 'x',
direction: isEven ? -1 : 1, // 移动方向:偶数向左,奇数向右
startDelay: index * LINE_DELAY, // 启动延迟:依次增加
elapsed: 0, // 已过时间初始化为 0
axis: 'x', // 运动轴向为 X
})
// 创建Z轴线条
// ===== 创建 Z 轴方向的光带 =====
const zMesh = new THREE.Mesh(geometry, material.clone())
// 设置初始位置:偶数索引从后边开始,奇数从前边开始
zMesh.position.set(offset, 0.01, isEven ? GROUND_SIZE : -GROUND_SIZE)
zMesh.rotation.y = Math.PI / 2
zMesh.renderOrder = 2
zMesh.rotation.y = Math.PI / 2 // 旋转 90° 使光带沿 Z 轴方向
zMesh.renderOrder = 2 // 渲染顺序略高于 X 轴光带
scene.add(zMesh)
// 记录 Z 轴光带的状态
lineStates.push({
mesh: zMesh,
direction: isEven ? -1 : 1,
startDelay: index * LINE_DELAY,
elapsed: 0,
axis: 'z',
direction: isEven ? -1 : 1, // 移动方向:偶数向前,奇数向后
startDelay: index * LINE_DELAY, // 启动延迟:依次增加
elapsed: 0, // 已过时间初始化为 0
axis: 'z', // 运动轴向为 Z
})
})
}
// ========== 更新线条动画 ==========
/**
* 更新所有光效线条的位置
* 实现来回移动的动画效果
*
* @param deltaTime - 距离上一帧的时间差(毫秒)
*
* 动画逻辑:
* 1. 检查延迟时间是否已到(未到则跳过)
* 2. 检测是否到达边界,到达则反转方向
* 3. 根据方向和速度更新位置
*/
function updateLines(deltaTime: number) {
lineStates.forEach((state) => {
// 累计已过时间
state.elapsed += deltaTime
// 如果还未达到启动延迟时间,跳过此光带
if (state.elapsed < state.startDelay)
return
// 获取当前位置X 轴或 Z 轴)
const pos = state.axis === 'x' ? state.mesh.position.x : state.mesh.position.z
// 边界检测反转方向
// 边界检测:到达边界时反转方向
// 向右/向后移动超过边界,改为向左/向前
if (pos >= GROUND_SIZE)
state.direction = -1
// 向左/向前移动超过边界,改为向右/向后
else if (pos <= -GROUND_SIZE)
state.direction = 1
// 更新位置
// 计算位置变化量(速度 × 方向)
const delta = LINE_SPEED * state.direction
// 根据轴向更新对应的位置
if (state.axis === 'x') {
state.mesh.position.x += delta
state.mesh.position.x += delta // 更新 X 坐标
}
else {
state.mesh.position.z += delta
state.mesh.position.z += delta // 更新 Z 坐标
}
})
}
// ========== 主动画循环 ==========
let lastTime = 0
let lastTime = 0 // 上一帧的时间戳
/**
* 主动画循环函数
* 每帧调用一次,更新场景并渲染
*
* @param time - 当前时间戳(毫秒)
*
* 执行流程:
* 1. 请求下一帧动画
* 2. 计算时间差deltaTime
* 3. 更新控制器状态
* 4. 更新光效动画
* 5. 响应式调整渲染器尺寸
* 6. 渲染场景
*/
function animate(time = 0) {
// 请求下一帧动画(递归调用)
animationId = requestAnimationFrame(animate)
// 计算距离上一帧的时间差
const deltaTime = time - lastTime
lastTime = time
// 更新控制器
// 更新轨道控制器(处理阻尼效果)
controls.update()
// 更新线条动画
// 更新所有光效线条的位置
updateLines(deltaTime)
// 响应式调整渲染器大小
// 响应式调整渲染器大小(窗口尺寸变化时)
const { width, height } = getElementSize()
if (camera.aspect !== width / height) {
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
camera.aspect = width / height // 更新相机宽高比
camera.updateProjectionMatrix() // 更新投影矩阵
renderer.setSize(width, height) // 更新渲染器尺寸
}
// 渲染场景
renderer.render(scene, camera)
}
// ========== 清理资源 ==========
/**
* 清理所有 Three.js 资源
* 防止内存泄漏,组件卸载时调用
*
* 清理内容:
* 1. 取消动画帧
* 2. 销毁控制器
* 3. 销毁渲染器
* 4. 释放几何体和材质
* 5. 清空状态数组
*/
function cleanup() {
// 取消动画帧
if (animationId)
cancelAnimationFrame(animationId)
// 销毁轨道控制器
if (controls)
controls.dispose()
// 销毁渲染器并移除 DOM 元素
if (renderer) {
renderer.dispose()
threeRef.value?.removeChild(renderer.domElement)
}
// 遍历场景,释放所有网格的资源
scene?.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry?.dispose()
object.geometry?.dispose() // 释放几何体
// 处理材质(可能是单个或数组)
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose())
}
@@ -223,32 +332,49 @@ function cleanup() {
}
}
})
// 清空光效状态数组
lineStates.length = 0
}
/**
* 暂停地面光效动画
* 停止动画循环,但保留所有光带对象
*/
function pauseGroundLightEffect() {
cancelAnimationFrame(animationId)
}
/**
* 恢复地面光效动画
* 重新启动动画循环
*/
function resumeGroundLightEffect() {
animate()
}
/**
* 销毁地面光效
* 从场景中移除所有光带对象
*/
function destoryGroundLightEffect() {
lineStates.forEach((state) => {
state.mesh.parent?.remove(state.mesh)
state.mesh.parent?.remove(state.mesh) // 从父对象(场景)中移除
})
}
onMounted(initModel)
onBeforeUnmount(cleanup)
// 生命周期钩子
onMounted(initModel) // 组件挂载时初始化场景
onBeforeUnmount(cleanup) // 组件卸载前清理资源
</script>
<template>
<div ref="threeRef" class="model" />
<!-- 导航链接跳转到下一个 Demo -->
<router-link to="/three/6" class="position-fixed left-20px top-20px">
模型漫游巡检业务
</router-link>
<!-- 控制按钮组 -->
<div class="position-fixed right-20px top-20px flex gap-12px">
<button @click="addGroundLightEffect">
添加地面光效

View File

@@ -1,113 +1,191 @@
<!--
* @Description: Demo 6 - 模型动画播放巡检业务
* @功能说明: 加载带有动画的 GLTF 模型并播放内置动画
* @核心技术:
* 1. GLTFLoader - 加载 GLTF/GLB 格式的 3D 模型
* 2. AnimationMixer - Three.js 动画混合器用于管理和播放动画
* 3. AnimationAction - 动画动作控制器控制动画的播放暂停停止等
* 4. Clock - 时钟对象用于计算动画帧之间的时间差
* @业务场景: 巡检机器人/设备的运动轨迹动画播放
* @Autor: 相卿
* @Date: 2025-11-17 16:07:33
-->
<script lang="ts" setup>
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { onMounted, shallowRef } from 'vue'
const threeRef = shallowRef<HTMLCanvasElement>()
// ========== 响应式状态 ==========
const threeRef = shallowRef<HTMLCanvasElement>() // Canvas 容器引用
// 模型中包含的所有动画片段
const animations = shallowRef<THREE.AnimationClip[]>([])
// 加载的 3D 模型对象
const model = shallowRef<THREE.Group>()
// ========== Three.js 核心对象 ==========
let scene: THREE.Scene | undefined
/**
* 初始化 Three.js 场景
* 创建场景、相机、渲染器、灯光等基础设施
*/
function initModel() {
const width = threeRef.value!.clientWidth
const height = threeRef.value!.clientHeight
// 创建场景
scene = new THREE.Scene()
scene.background = new THREE.Color(0x000000)
scene.background = new THREE.Color(0x000000) // 黑色背景
// 创建透视相机
// 参数:视场角 75°, 宽高比, 近裁剪面 0.1, 远裁剪面 1000
const camera = new THREE.PerspectiveCamera(
75,
width / height,
0.1,
1000,
)
camera.position.set(0.1, 0.1, 1)
camera.position.set(0.1, 0.1, 1) // 相机位置(靠近原点观察)
const renderer = new THREE.WebGLRenderer({ antialias: true })
// 创建 WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true }) // 开启抗锯齿
renderer.setSize(width, height)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.shadowMap.enabled = true // 启用阴影渲染
renderer.shadowMap.type = THREE.PCFSoftShadowMap // PCF 软阴影
threeRef.value!.appendChild(renderer.domElement)
// 创建轨道控制器 - 允许用户旋转、缩放视角
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.enableDamping = true // 启用阻尼(平滑过渡)
controls.dampingFactor = 0.05 // 阻尼系数
// 添加坐标轴辅助工具(红 X, 绿 Y, 蓝 Z长度 5
const axesHelper = new THREE.AxesHelper(5)
scene.add(axesHelper)
// 环境光
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 0.5)
// ===== 灯光系统 =====
// 环境光 - 提供无方向的均匀照明
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 0.5) // 白色,强度 0.5
scene.add(ambientLight)
// 方向光(主光
const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 0.8)
directionalLight.position.set(20, 30, 20)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.set(2048, 2048)
directionalLight.shadow.camera.near = 0.5
directionalLight.shadow.camera.far = 50
// 方向光(主光源)- 模拟太阳光
const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 0.8) // 白色,强度 0.8
directionalLight.position.set(20, 30, 20) // 光源位置(右上方)
directionalLight.castShadow = true // 投射阴影
// 配置阴影参数
directionalLight.shadow.mapSize.set(2048, 2048) // 阴影贴图分辨率(越高越清晰)
directionalLight.shadow.camera.near = 0.5 // 阴影相机近裁剪面
directionalLight.shadow.camera.far = 50 // 阴影相机远裁剪面
// 阴影相机的可视范围(正交投影)
directionalLight.shadow.camera.left = -20
directionalLight.shadow.camera.right = 20
directionalLight.shadow.camera.top = 20
directionalLight.shadow.camera.bottom = -20
scene.add(directionalLight)
// 点光源
const pointLight = new THREE.PointLight(0x409EFF, 0.5, 50)
pointLight.position.set(0, 10, 0)
// 点光源 - 从一点向四周发光(蓝色调)
const pointLight = new THREE.PointLight(0x409EFF, 0.5, 50) // 颜色, 强度, 距离
pointLight.position.set(0, 10, 0) // 位置(头顶上方)
scene.add(pointLight)
// ===== 加载 GLTF 模型 =====
const loader = new GLTFLoader()
loader.load('/lxb_grp.glb', (gltf) => {
scene!.add(gltf.scene)
model.value = gltf.scene
animations.value = gltf.animations ?? []
// 模型加载成功回调
scene!.add(gltf.scene) // 将模型添加到场景
model.value = gltf.scene // 保存模型引用(供动画使用)
animations.value = gltf.animations ?? [] // 提取模型内置的动画片段
})
/**
* 动画循环函数
* 持续渲染场景,并处理窗口尺寸变化
*/
function animate() {
requestAnimationFrame(animate)
renderer.render(scene!, camera)
requestAnimationFrame(animate) // 递归调用,保持动画循环
renderer.render(scene!, camera) // 渲染场景
// 响应式调整渲染器尺寸(窗口大小变化时)
renderer.setSize(width, height)
camera.aspect = width / height
camera.updateProjectionMatrix()
}
animate()
animate() // 启动动画循环
}
/**
* 播放指定索引的动画
*
* @param index - 动画片段的索引(对应 animations 数组)
*
* 工作流程:
* 1. 获取指定的动画片段AnimationClip
* 2. 创建动画混合器AnimationMixer- 绑定到模型
* 3. 创建动画动作AnimationAction- 从混合器和片段创建
* 4. 播放动画
* 5. 创建更新循环,持续更新混合器状态
*
* 技术要点:
* - AnimationMixer: 管理一个或多个动画的播放
* - AnimationAction: 控制单个动画片段的播放状态
* - Clock: 用于计算每帧的时间差delta time
* - mixer.update(delta): 根据时间差推进动画状态
*/
function handlePlay(index: number) {
// 获取指定索引的动画片段
const clip = animations.value[index]
if (!clip)
return
return // 如果动画不存在,直接返回
// 创建动画混合器,绑定到模型对象
// AnimationMixer 负责管理和更新动画状态
const mixer = new THREE.AnimationMixer(model.value!)
// 从混合器创建动画动作
// clipAction() 返回一个可控制的动画动作对象
const action = mixer.clipAction(clip)
// 播放动画
action.play()
// 可选:定时停止动画(已注释)
// setTimeout(() => {
// action.stop()
// }, 3000)
// 创建时钟对象,用于计算帧之间的时间差
const clock = new THREE.Clock()
// 在动画循环中更新混合器
/**
* 动画更新循环
* 持续更新混合器状态,使动画平滑播放
*/
function updateMixer() {
requestAnimationFrame(updateMixer)
const delta = clock.getDelta()
mixer.update(delta)
requestAnimationFrame(updateMixer) // 递归调用,持续更新
const delta = clock.getDelta() // 获取距离上一帧的时间差(秒)
mixer.update(delta) // 更新混合器,推进动画状态
}
updateMixer()
updateMixer() // 启动动画更新循环
}
// 组件挂载时初始化场景
onMounted(initModel)
</script>
<template>
<div ref="threeRef" class="model" />
<!-- 导航链接跳转到下一个 Demo -->
<router-link to="/three/7" class="position-fixed left-20px top-20px">
模型漫游巡检业务
</router-link>
<!-- 动画控制按钮组 -->
<!-- 根据模型中的动画数量动态生成按钮 -->
<div class="position-fixed right-20px top-20px flex gap-12px">
<button v-for="(ani, index) in animations" :key="index" @click="handlePlay(index)">
播放动画{{ index + 1 }} - {{ ani.name }}

View File

@@ -1,5 +1,19 @@
<!--
* @Description: 模型漫游巡检业务
* @Description: Demo 7 - 模型漫游巡检业务
* @功能说明: 实现相机沿预设路径自动巡检模拟无人机/机器人巡检业务
* @核心技术:
* 1. 路径插值动画 - 使用 lerp线性插值在路径点之间平滑移动
* 2. SmoothStep 缓动 - 使用平滑步进函数实现加速减速效果
* 3. 相机目标联动 - 相机位置和观察目标同步插值
* 4. OrbitControls 状态切换 - 自动模式禁用控制器手动模式启用
* @业务场景:
* - 建筑/工厂自动巡检
* - 虚拟导览
* - 安防监控路线演示
* @技术要点:
* - 13个路径点构成完整巡检循环
* - 支持慢速0.003和快速0.008两种巡检速度
* - 使用 Fog 雾效增强空间深度感
* @Autor: 相卿
* @Date: 2025-11-17 16:07:33
* @LastEditors: 相卿
@@ -11,148 +25,174 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
const threeRef = shallowRef<HTMLDivElement>()
const camera = shallowRef<THREE.PerspectiveCamera>()
const scene = shallowRef<THREE.Scene>()
const controls = shallowRef<OrbitControls>()
const renderer = shallowRef<THREE.WebGLRenderer>()
// ========== 响应式状态 ==========
const threeRef = shallowRef<HTMLDivElement>() // Canvas 容器
const camera = shallowRef<THREE.PerspectiveCamera>() // 透视相机
const scene = shallowRef<THREE.Scene>() // 场景对象
const controls = shallowRef<OrbitControls>() // 轨道控制器
const renderer = shallowRef<THREE.WebGLRenderer>() // WebGL 渲染器
// 漫游状态
const tourMode = ref<'none' | 'slow' | 'fast'>('none')
let tourTime = 0
const initialCameraPosition = new THREE.Vector3(0, 5, 15)
const initialCameraTarget = new THREE.Vector3(0, 0, 0)
// ========== 漫游状态管理 ==========
const tourMode = ref<'none' | 'slow' | 'fast'>('none') // 巡检模式:无、慢速、快速
let tourTime = 0 // 巡检动画时间累加器
const initialCameraPosition = new THREE.Vector3(0, 5, 15) // 相机初始位置
const initialCameraTarget = new THREE.Vector3(0, 0, 0) // 相机初始观察目标
// 巡检路径点位 - 模拟进入建筑内部进行详细巡检
// 包含位置和对应的观察目标点
// ========== 巡检路径定义 ==========
/**
* 路径点接口
* 定义相机在巡检过程中的位置和观察目标
*/
interface PathPoint {
position: THREE.Vector3
lookAt: THREE.Vector3
position: THREE.Vector3 // 相机位置
lookAt: THREE.Vector3 // 相机观察目标(朝向)
}
/**
* 巡检路径点数组
*
* 巡检路线设计:
* 1. 外部俯视 → 2. 正面接近 → 3. 进入前厅 →
* 4-5. 右侧巡检 → 6. 右后方 → 7. 后方中央 →
* 8-9. 左侧巡检 → 10. 左前方 → 11-12. 中央回顾 →
* 13. 退出俯视
*
* 形成完整的顺时针巡检循环
*/
const inspectionPath: PathPoint[] = [
// 1. 外部俯视全景
// 1. 外部俯视全景 - 建立整体认知
{ position: new THREE.Vector3(0, 8, 15), lookAt: new THREE.Vector3(0, 0, 0) },
// 2. 从正面接近入口
// 2. 从正面接近入口 - 降低高度,准备进入
{ position: new THREE.Vector3(0, 3, 10), lookAt: new THREE.Vector3(0, 2, 0) },
// 3. 进入前厅区域
// 3. 进入前厅区域 - 穿过入口
{ position: new THREE.Vector3(0, 2, 5), lookAt: new THREE.Vector3(0, 2, -2) },
// 4. 右侧区域巡检
// 4. 右侧区域巡检 - 检查右侧设施
{ position: new THREE.Vector3(5, 2, 3), lookAt: new THREE.Vector3(8, 2, 0) },
// 5. 深入右侧内部
// 5. 深入右侧内部 - 详细检查右侧深处
{ position: new THREE.Vector3(8, 2.5, 0), lookAt: new THREE.Vector3(10, 2, -3) },
// 6. 右后方区域
// 6. 右后方区域 - 转向后方
{ position: new THREE.Vector3(6, 2, -5), lookAt: new THREE.Vector3(3, 2, -8) },
// 7. 后方中央区域
// 7. 后方中央区域 - 检查后方设施
{ position: new THREE.Vector3(0, 2.5, -8), lookAt: new THREE.Vector3(0, 2, -5) },
// 8. 左后方区域
// 8. 左后方区域 - 转向左侧
{ position: new THREE.Vector3(-6, 2, -5), lookAt: new THREE.Vector3(-3, 2, -8) },
// 9. 深入左侧内部
// 9. 深入左侧内部 - 详细检查左侧深处
{ position: new THREE.Vector3(-8, 2.5, 0), lookAt: new THREE.Vector3(-10, 2, -3) },
// 10. 左侧区域巡检
// 10. 左侧区域巡检 - 检查左侧设施
{ position: new THREE.Vector3(-5, 2, 3), lookAt: new THREE.Vector3(-8, 2, 0) },
// 11. 返回中央区域
// 11. 返回中央区域 - 回到中心
{ position: new THREE.Vector3(0, 2, 0), lookAt: new THREE.Vector3(0, 3, -5) },
// 12. 中央上升视角
// 12. 中央上升视角 - 俯瞰全局
{ position: new THREE.Vector3(0, 5, 2), lookAt: new THREE.Vector3(0, 0, 0) },
// 13. 退出并俯视
// 13. 退出并俯视 - 完成巡检,恢复初始视角
{ position: new THREE.Vector3(0, 8, 12), lookAt: new THREE.Vector3(0, 0, 0) },
]
/**
* 初始化 Three.js 场景
* 创建场景、相机、渲染器、灯光、地面等基础元素
*/
function initModel() {
const width = threeRef.value!.clientWidth
const height = threeRef.value!.clientHeight
// 创建场景
// ===== 创建场景 =====
scene.value = new THREE.Scene()
// 创建天空盒
// ===== 创建天空盒(立方体背景)=====
// 使用6个面的立方体模拟天空
const skyboxGeometry = new THREE.BoxGeometry(500, 500, 500)
const skyboxMaterials = [
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // right
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // left
new THREE.MeshBasicMaterial({ color: 0x0F0F1E, side: THREE.BackSide }), // top
new THREE.MeshBasicMaterial({ color: 0x000000, side: THREE.BackSide }), // bottom
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // front
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // back
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // 右侧(深蓝灰)
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // 左侧
new THREE.MeshBasicMaterial({ color: 0x0F0F1E, side: THREE.BackSide }), // 顶部(更深)
new THREE.MeshBasicMaterial({ color: 0x000000, side: THREE.BackSide }), // 底部(黑色)
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // 前侧
new THREE.MeshBasicMaterial({ color: 0x1A1A2E, side: THREE.BackSide }), // 后侧
]
const skybox = new THREE.Mesh(skyboxGeometry, skyboxMaterials)
scene.value.add(skybox)
scene.value.background = new THREE.Color(0x000000)
// 添加雾效增强深度感
scene.value.background = new THREE.Color(0x000000) // 场景背景色(黑色)
// 添加雾效增强深度感和空间层次
// Fog(颜色, 起始距离, 结束距离)
scene.value.fog = new THREE.Fog(0x000000, 10, 50)
// 创建透视相机 PerspectiveCamera(视野角度, 宽高比, 近截面, 远截面)
// ===== 创建透视相机 =====
// PerspectiveCamera(视野角度, 宽高比, 近截面, 远截面)
camera.value = new THREE.PerspectiveCamera(
60,
60, // 视野角度 60°较窄适合建筑巡检
width / height,
0.1,
1000,
0.1, // 近裁剪面
1000, // 远裁剪面
)
camera.value.position.copy(initialCameraPosition)
camera.value.position.copy(initialCameraPosition) // 设置初始位置
// 创建渲染器
renderer.value = new THREE.WebGLRenderer({ antialias: true })
// ===== 创建 WebGL 渲染器 =====
renderer.value = new THREE.WebGLRenderer({ antialias: true }) // 抗锯齿
renderer.value.setSize(width, height)
renderer.value.setPixelRatio(window.devicePixelRatio)
renderer.value.setPixelRatio(window.devicePixelRatio) // 高清屏适配
// 开启阴影贴图
renderer.value.shadowMap.enabled = true
renderer.value.shadowMap.type = THREE.PCFSoftShadowMap
renderer.value.shadowMap.type = THREE.PCFSoftShadowMap // PCF 软阴影
threeRef.value!.appendChild(renderer.value.domElement)
// 创建轨道控制器
// ===== 创建轨道控制器 =====
controls.value = new OrbitControls(camera.value, renderer.value.domElement)
controls.value.enableDamping = true // 启用阻尼(惯性)
controls.value.dampingFactor = 0.05
controls.value.minDistance = 2
controls.value.maxDistance = 50
controls.value.maxPolarAngle = Math.PI / 2 * 1.8 // 限制垂直旋转角度
controls.value.enableDamping = true // 启用阻尼(惯性效果
controls.value.dampingFactor = 0.05 // 阻尼系数
controls.value.minDistance = 2 // 最小缩放距离
controls.value.maxDistance = 50 // 最大缩放距离
controls.value.maxPolarAngle = Math.PI / 2 * 1.8 // 限制垂直旋转角度(防止翻转)
// 添加环境光
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 0.6)
// ===== 灯光系统 =====
// 环境光 - 提供基础照明
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 0.6) // 白色,强度 0.6
scene.value.add(ambientLight)
// 添加主平行光(模拟太阳光)
const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 1.2)
directionalLight.position.set(10, 15, 10)
directionalLight.castShadow = true
// 主平行光(模拟太阳光)
const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 1.2) // 强度 1.2
directionalLight.position.set(10, 15, 10) // 光源位置(右上方)
directionalLight.castShadow = true // 投射阴影
// 配置阴影相机范围(正交投影)
directionalLight.shadow.camera.left = -20
directionalLight.shadow.camera.right = 20
directionalLight.shadow.camera.top = 20
directionalLight.shadow.camera.bottom = -20
directionalLight.shadow.mapSize.width = 2048
directionalLight.shadow.mapSize.width = 2048 // 阴影贴图分辨率
directionalLight.shadow.mapSize.height = 2048
scene.value.add(directionalLight)
// 添加辅助光源(从另一个方向
// 辅助光源(填充光)- 从另一个方向照亮暗部
const fillLight = new THREE.DirectionalLight(0xFFFFFF, 0.5)
fillLight.position.set(-10, 10, -10)
fillLight.position.set(-10, 10, -10) // 左上后方
scene.value.add(fillLight)
// 添加地面
const groundGeometry = new THREE.PlaneGeometry(100, 100)
// ===== 创建地面 =====
const groundGeometry = new THREE.PlaneGeometry(100, 100) // 100x100 的平面
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x999999,
roughness: 0.8,
metalness: 0.2,
color: 0x999999, // 灰色
roughness: 0.8, // 粗糙度(不光滑)
metalness: 0.2, // 金属度(略有金属感)
})
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
ground.rotation.x = -Math.PI / 2
ground.position.y = 0
ground.receiveShadow = true
ground.rotation.x = -Math.PI / 2 // 旋转 90° 使其水平
ground.position.y = 0 // 位于 Y=0 平面
ground.receiveShadow = true // 接收阴影
scene.value.add(ground)
// 加载模型
// ===== 加载 GLTF 模型 =====
const loader = new GLTFLoader()
loader.load('/mzjc_bansw.glb', (gltf) => {
const model = gltf.scene
// 遍历模型,启用阴影
// 遍历模型所有子对象,启用阴影
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true
child.receiveShadow = true
child.castShadow = true // 投射阴影
child.receiveShadow = true // 接收阴影
}
})
@@ -161,46 +201,56 @@ function initModel() {
console.error('模型加载失败:', error)
})
/**
* 动画循环函数
*
* 职责:
* 1. 处理巡检动画逻辑(相机路径插值)
* 2. 更新轨道控制器
* 3. 渲染场景
*/
function animate() {
requestAnimationFrame(animate)
requestAnimationFrame(animate) // 递归调用
// 巡检漫游效果
// ===== 巡检漫游动画逻辑 =====
if (tourMode.value !== 'none' && camera.value && controls.value) {
// 根据模式确定移动速度
const speed = tourMode.value === 'slow' ? 0.003 : 0.008 // 慢速或快速
tourTime += speed
tourTime += speed // 累加时间
const totalPoints = inspectionPath.length
const totalProgress = tourTime % totalPoints
const currentIndex = Math.floor(totalProgress)
const nextIndex = (currentIndex + 1) % totalPoints
const t = totalProgress - currentIndex
const totalPoints = inspectionPath.length // 路径点总数
const totalProgress = tourTime % totalPoints // 当前进度0 到 totalPoints 循环)
const currentIndex = Math.floor(totalProgress) // 当前路径点索引
const nextIndex = (currentIndex + 1) % totalPoints // 下一个路径点索引(循环)
const t = totalProgress - currentIndex // 两点之间的插值参数0-1
// 使用平滑的缓动函数
const smoothT = t * t * (3 - 2 * t) // smoothstep
// 使用平滑的缓动函数SmoothStep
// smoothstep: 在 0 和 1 处导数为 0产生平滑的加速减速效果
const smoothT = t * t * (3 - 2 * t)
// 获取当前和下一个路径点
const currentPoint = inspectionPath[currentIndex]!
const nextPoint = inspectionPath[nextIndex]!
// 插值位置
// 插值计算相机位置
const newPosition = new THREE.Vector3()
newPosition.lerpVectors(currentPoint.position, nextPoint.position, smoothT)
camera.value.position.copy(newPosition)
// 插值观察目标
// 插值计算观察目标
const newTarget = new THREE.Vector3()
newTarget.lerpVectors(currentPoint.lookAt, nextPoint.lookAt, smoothT)
controls.value.target.copy(newTarget)
// 禁用控制器的用户交互
// 巡检模式下禁用用户控制
controls.value.enabled = false
}
else if (controls.value) {
// 非漫游模式时启用控制
// 非巡检模式时启用用户控制
controls.value.enabled = true
}
// 更新控制器
// 更新轨道控制器(处理阻尼等)
controls.value?.update()
// 执行渲染
@@ -208,13 +258,16 @@ function initModel() {
renderer.value.render(scene.value, camera.value)
}
}
animate()
animate() // 启动动画循环
// 窗口自适应
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
}
// 处理窗口大小变化
/**
* 处理窗口大小变化
* 更新相机宽高比和渲染器尺寸
*/
function handleResize() {
if (!threeRef.value || !camera.value || !renderer.value)
return
@@ -224,55 +277,66 @@ function handleResize() {
// 更新相机宽高比
camera.value.aspect = width / height
camera.value.updateProjectionMatrix()
camera.value.updateProjectionMatrix() // 必须调用以应用更改
// 更新渲染器尺寸
renderer.value.setSize(width, height)
renderer.value.setPixelRatio(window.devicePixelRatio)
}
// 慢速巡检漫游
/**
* 启动慢速巡检
* 速度0.003,适合详细观察
*/
function startSlowTour() {
tourMode.value = 'slow'
tourTime = 0
tourTime = 0 // 重置时间,从第一个路径点开始
}
// 快速巡检漫游
/**
* 启动快速巡检
* 速度0.008,适合快速浏览
*/
function startFastTour() {
tourMode.value = 'fast'
tourTime = 0
tourTime = 0 // 重置时间,从第一个路径点开始
}
// 停止漫游
/**
* 停止巡检
* 恢复相机到初始位置,重新启用用户控制
*/
function stopTour() {
tourMode.value = 'none'
tourTime = 0
if (camera.value && controls.value) {
camera.value.position.copy(initialCameraPosition)
controls.value.target.copy(initialCameraTarget)
controls.value.enabled = true
camera.value.position.copy(initialCameraPosition) // 恢复初始位置
controls.value.target.copy(initialCameraTarget) // 恢复初始目标
controls.value.enabled = true // 重新启用用户控制
controls.value.update()
}
}
onMounted(initModel)
// ===== 生命周期钩子 =====
onMounted(initModel) // 组件挂载时初始化
onUnmounted(() => {
// 清理事件监听
window.removeEventListener('resize', handleResize)
// 销毁控制器
controls.value?.dispose()
// 销毁渲染器
renderer.value?.dispose()
// 组件卸载时清理资源
window.removeEventListener('resize', handleResize) // 移除事件监听
controls.value?.dispose() // 销毁控制器
renderer.value?.dispose() // 销毁渲染器
})
</script>
<template>
<div ref="threeRef" class="w-full h-full" />
<!-- 导航链接 -->
<router-link to="/three/8" class="position-fixed left-20px top-20px">
漫游 修改材质设备告警业务
</router-link>
<!-- 控制面板 -->
<div class="position-fixed right-20px top-20px flex flex-col gap-12px">
<!-- 巡检控制按钮组 -->
<div class="flex gap-12px">
<button
:class="{ 'bg-blue-500 text-white': tourMode === 'slow' }"
@@ -296,6 +360,7 @@ onUnmounted(() => {
停止漫游
</button>
</div>
<!-- 状态提示 -->
<div v-if="tourMode !== 'none'" class="bg-white px-12px py-8px rounded shadow text-sm">
正在进行{{ tourMode === 'slow' ? '慢速' : '快速' }}巡检...
</div>