Three.js复杂3D模型部件精准选取:从Raycaster原理到实战应用
在实际前端开发中使用 Three.js 这类 3D 图形库时经常会遇到一个看似简单但实际棘手的问题如何精确地选中并操作复杂 3D 模型中的单个子物体而不是整个模型整体。这个问题在游戏开发、工业设计、医疗可视化等需要精细交互的场景中尤为关键。很多开发者第一次尝试时会直接使用Raycaster进行射线检测结果发现无论点击模型的哪个部分返回的都是整个模型对象。这是因为 Three.js 默认将导入的 GLTF 或 OBJ 模型视为一个整体 Mesh除非在建模阶段就做好了明确的分组和命名否则在代码层面很难直接区分模型内部的各个部件。本文将带你从零构建一个可交互的 3D 模型查看器重点解决模型部件选取问题。你将学会如何通过编程方式识别和操作复杂模型的子部件并掌握在实际项目中处理此类需求的完整流程。1. 理解 Three.js 中的场景图结构和对象选取机制1.1 Three.js 的场景图父子层级关系Three.js 使用场景图Scene Graph来管理 3D 对象这是一种树形结构。一个典型的场景包含Scene根容器所有 3D 对象的父级Group分组对象用于逻辑上组织相关物体Mesh具体的几何体材质的可渲染对象Light光源Camera相机当导入外部模型时如 GLTF建模软件中的分组结构会映射到 Three.js 的 Group 和 Mesh 层级中。如果建模时没有分组整个模型就可能只是一个单一的 Mesh。1.2 Raycaster 射线检测的工作原理Raycaster 是 Three.js 中用于检测鼠标/触摸与 3D 对象交互的核心工具。其工作流程从相机位置向鼠标点击的屏幕坐标发射一条射线检测这条射线与场景中所有可交互对象的交点返回按距离排序的交点数组关键点在于Raycaster 默认检测的是具有几何体的 Mesh 对象。如果整个模型是一个 Mesh那么只能检测到这个整体。1.3 模型导入后的结构分析不同的建模习惯会导致导入 Three.js 后产生不同的结构情况一未分组的单一模型// 整个模型就是一个 Mesh scene └── Mesh (整个汽车模型)情况二有分组的模型// 模型被分为多个部分 scene └── Group (汽车组) ├── Mesh (车身) ├── Mesh (车轮1) ├── Mesh (车轮2) ├── Mesh (车轮3) └── Mesh (车轮4)情况三复杂嵌套分组// 多级分组常见于复杂装配体 scene └── Group (汽车装配体) ├── Group (车身总成) │ ├── Mesh (主体) │ └── Mesh (车门) └── Group (底盘总成) ├── Group (前轮组) │ ├── Mesh (左前轮) │ └── Mesh (右前轮) └── Group (后轮组) ├── Mesh (左后轮) └── Mesh (右后轮)理解导入后的实际结构是解决选取问题的第一步。2. 环境准备与项目结构搭建2.1 技术栈选择与版本确认本项目需要以下依赖{ dependencies: { three: ^0.158.0, vite: ^5.0.0 }, devDependencies: { types/three: ^0.158.0 } }关键版本说明Three.js 0.158.x稳定的最新版本包含完整的 GLTF 加载器Vite快速的构建工具支持热重载2.2 项目目录结构设计创建以下项目结构3d-model-viewer/ ├── public/ │ └── models/ │ └── car.glb # 3D模型文件 ├── src/ │ ├── js/ │ │ ├── core/ │ │ │ ├── SceneManager.js # 场景管理 │ │ │ ├── ModelLoader.js # 模型加载 │ │ │ └── InteractionManager.js # 交互管理 │ │ └── utils/ │ │ ├── helpers.js # 辅助工具 │ │ └── debug.js # 调试工具 │ ├── css/ │ │ └── style.css # 样式文件 │ └── index.html # 主页面 ├── package.json └── vite.config.js2.3 基础 HTML 和 CSS 搭建index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title3D模型部件选取演示/title link relstylesheet href./css/style.css /head body div idapp div idcanvas-container/div div idinfo-panel h3选中部件信息/h3 div idselected-info未选择任何部件/div /div div idcontrols button idreset-view重置视图/button button idtoggle-wireframe线框模式/button /div /div script typemodule src./js/core/SceneManager.js/script /body /htmlstyle.css* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Segoe UI, Tahoma, Geneva, Verdana, sans-serif; overflow: hidden; background: #1a1a1a; } #app { position: relative; width: 100vw; height: 100vh; } #canvas-container { width: 100%; height: 100%; } #info-panel { position: absolute; top: 20px; right: 20px; background: rgba(0, 0, 0, 0.8); color: white; padding: 15px; border-radius: 8px; min-width: 250px; backdrop-filter: blur(10px); } #controls { position: absolute; bottom: 20px; left: 20px; display: flex; gap: 10px; } button { padding: 10px 15px; background: #007acc; color: white; border: none; border-radius: 4px; cursor: pointer; transition: background 0.3s; } button:hover { background: #005a9e; } .highlight { outline: 2px solid #ff4444; outline-offset: 2px; }3. 核心场景管理与模型加载实现3.1 场景管理器基础框架SceneManager.jsimport * as THREE from three; import { ModelLoader } from ./ModelLoader.js; import { InteractionManager } from ./InteractionManager.js; import { createHelpers } from ../utils/helpers.js; export class SceneManager { constructor(containerId) { this.container document.getElementById(containerId); this.scene null; this.camera null; this.renderer null; this.modelLoader null; this.interactionManager null; this.init(); } init() { this.setupScene(); this.setupCamera(); this.setupRenderer(); this.setupLighting(); this.setupHelpers(); this.modelLoader new ModelLoader(this.scene); this.interactionManager new InteractionManager( this.scene, this.camera, this.renderer ); this.loadModel(); this.animate(); } setupScene() { this.scene new THREE.Scene(); this.scene.background new THREE.Color(0x1a1a1a); this.scene.fog new THREE.Fog(0x1a1a1a, 10, 50); } setupCamera() { const aspect this.container.clientWidth / this.container.clientHeight; this.camera new THREE.PerspectiveCamera(60, aspect, 0.1, 1000); this.camera.position.set(5, 5, 5); this.camera.lookAt(0, 0, 0); } setupRenderer() { this.renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setSize( this.container.clientWidth, this.container.clientHeight ); this.renderer.shadowMap.enabled true; this.renderer.shadowMap.type THREE.PCFSoftShadowMap; this.renderer.outputColorSpace THREE.SRGBColorSpace; this.container.appendChild(this.renderer.domElement); // 响应窗口大小变化 window.addEventListener(resize, () this.onWindowResize()); } setupLighting() { // 环境光 const ambientLight new THREE.AmbientLight(0x404040, 0.6); this.scene.add(ambientLight); // 方向光 const directionalLight new THREE.DirectionalLight(0xffffff, 1); directionalLight.position.set(10, 10, 5); directionalLight.castShadow true; directionalLight.shadow.mapSize.width 2048; directionalLight.shadow.mapSize.height 2048; this.scene.add(directionalLight); } setupHelpers() { const helpers createHelpers(); this.scene.add(helpers.axes); this.scene.add(helpers.grid); } onWindowResize() { this.camera.aspect this.container.clientWidth / this.container.clientHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize( this.container.clientWidth, this.container.clientHeight ); } async loadModel() { try { const model await this.modelLoader.loadGLTF(/models/car.glb); console.log(模型加载成功:, model); // 分析模型结构 this.analyzeModelStructure(model); } catch (error) { console.error(模型加载失败:, error); } } analyzeModelStructure(model) { // 递归遍历模型中的所有对象 model.scene.traverse((object) { if (object.isMesh) { console.log(找到网格对象:, object.name, object); // 为每个网格对象添加交互支持 object.userData.selectable true; object.castShadow true; object.receiveShadow true; } }); } animate() { requestAnimationFrame(() this.animate()); this.renderer.render(this.scene, this.camera); } } // 初始化场景管理器 new SceneManager(canvas-container);3.2 模型加载器实现ModelLoader.jsimport * as THREE from three; import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; import { DRACOLoader } from three/examples/jsm/loaders/DRACOLoader.js; export class ModelLoader { constructor(scene) { this.scene scene; this.loader new GLTFLoader(); // 设置DRACO解码器用于压缩模型 const dracoLoader new DRACOLoader(); dracoLoader.setDecoderPath(https://www.gstatic.com/draco/v1/decoders/); this.loader.setDRACOLoader(dracoLoader); } loadGLTF(url) { return new Promise((resolve, reject) { this.loader.load( url, (gltf) { this.scene.add(gltf.scene); resolve(gltf); }, (progress) { console.log(加载进度: ${(progress.loaded / progress.total * 100).toFixed(2)}%); }, (error) { reject(error); } ); }); } // 辅助方法创建测试模型当没有实际模型文件时使用 createTestModel() { const group new THREE.Group(); group.name 测试汽车模型; // 创建车身 const bodyGeometry new THREE.BoxGeometry(3, 1, 1.5); const bodyMaterial new THREE.MeshPhongMaterial({ color: 0xff4444 }); const body new THREE.Mesh(bodyGeometry, bodyMaterial); body.name 车身; body.userData.selectable true; group.add(body); // 创建四个车轮 const wheelGeometry new THREE.CylinderGeometry(0.4, 0.4, 0.3, 16); const wheelMaterial new THREE.MeshPhongMaterial({ color: 0x333333 }); for (let i 0; i 4; i) { const wheel new THREE.Mesh(wheelGeometry, wheelMaterial); wheel.name 车轮${i 1}; wheel.userData.selectable true; // 设置车轮位置 const x i 2 ? -1 : 1; const z i % 2 0 ? -0.8 : 0.8; wheel.position.set(x, -0.5, z); wheel.rotation.z Math.PI / 2; group.add(wheel); } this.scene.add(group); return group; } }3.3 工具函数实现utils/helpers.jsimport * as THREE from three; export function createHelpers() { // 坐标轴辅助 const axesHelper new THREE.AxesHelper(2); axesHelper.position.y 0.01; // 稍微抬高避免与网格重叠 // 网格辅助 const gridHelper new THREE.GridHelper(10, 10, 0x444444, 0x222222); return { axes: axesHelper, grid: gridHelper }; } export function setupDefaultCameraControls(camera, renderer) { // 这里可以集成OrbitControls等相机控制器 // 简化版手动控制示例 let isDragging false; let previousMousePosition { x: 0, y: 0 }; renderer.domElement.addEventListener(mousedown, (e) { isDragging true; previousMousePosition { x: e.clientX, y: e.clientY }; }); renderer.domElement.addEventListener(mousemove, (e) { if (!isDragging) return; const deltaMove { x: e.clientX - previousMousePosition.x, y: e.clientY - previousMousePosition.y }; // 简单的旋转控制 camera.position.x Math.sin(deltaMove.x * 0.01) * 5; camera.position.z Math.cos(deltaMove.x * 0.01) * 5; camera.lookAt(0, 0, 0); previousMousePosition { x: e.clientX, y: e.clientY }; }); renderer.domElement.addEventListener(mouseup, () { isDragging false; }); return camera; }4. 核心交互管理与部件选取实现4.1 交互管理器基础架构InteractionManager.jsimport * as THREE from three; export class InteractionManager { constructor(scene, camera, renderer) { this.scene scene; this.camera camera; this.renderer renderer; this.raycaster new THREE.Raycaster(); this.mouse new THREE.Vector2(); this.selectedObject null; this.initEventListeners(); } initEventListeners() { this.renderer.domElement.addEventListener(click, (event) { this.onMouseClick(event); }); this.renderer.domElement.addEventListener(mousemove, (event) { this.onMouseMove(event); }); } getMousePosition(event) { const rect this.renderer.domElement.getBoundingClientRect(); this.mouse.x ((event.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y -((event.clientY - rect.top) / rect.height) * 2 1; } findSelectableObjects() { const selectableObjects []; this.scene.traverse((object) { if (object.isMesh object.userData.selectable) { selectableObjects.push(object); } }); return selectableObjects; } onMouseClick(event) { this.getMousePosition(event); this.raycaster.setFromCamera(this.mouse, this.camera); const selectableObjects this.findSelectableObjects(); const intersects this.raycaster.intersectObjects(selectableObjects); if (intersects.length 0) { this.selectObject(intersects[0].object); } else { this.clearSelection(); } } onMouseMove(event) { // 可以实现鼠标悬停效果 this.getMousePosition(event); this.raycaster.setFromCamera(this.mouse, this.camera); const selectableObjects this.findSelectableObjects(); const intersects this.raycaster.intersectObjects(selectableObjects); // 移除之前的悬停效果 this.scene.traverse((object) { if (object.userData.wasHovered) { object.material.emissive.setHex(object.userData.originalEmissive); object.userData.wasHovered false; } }); // 添加新的悬停效果 if (intersects.length 0 intersects[0].object ! this.selectedObject) { const object intersects[0].object; object.userData.originalEmissive object.material.emissive.getHex(); object.material.emissive.setHex(0x333333); object.userData.wasHovered true; } } selectObject(object) { // 清除之前的选择 this.clearSelection(); // 设置新选择 this.selectedObject object; // 添加选择效果 if (object.material instanceof THREE.Material) { object.userData.originalMaterial object.material.clone(); object.material.emissive.setHex(0x666666); } // 更新UI信息 this.updateSelectionInfo(object); console.log(选中对象:, object.name, object); } clearSelection() { if (this.selectedObject) { // 恢复原始材质 if (this.selectedObject.userData.originalMaterial) { this.selectedObject.material.emissive.setHex( this.selectedObject.userData.originalMaterial.emissive.getHex() ); } this.selectedObject null; } this.updateSelectionInfo(null); } updateSelectionInfo(object) { const infoElement document.getElementById(selected-info); if (!object) { infoElement.innerHTML 未选择任何部件; return; } const position object.position; const rotation object.rotation; infoElement.innerHTML pstrong名称:/strong ${object.name || 未命名}/p pstrong类型:/strong ${object.type}/p pstrong位置:/strong X:${position.x.toFixed(2)} Y:${position.y.toFixed(2)} Z:${position.z.toFixed(2)}/p pstrong旋转:/strong X:${rotation.x.toFixed(2)} Y:${rotation.y.toFixed(2)} Z:${rotation.z.toFixed(2)}/p pstrong顶点数:/strong ${object.geometry.attributes.position.count}/p ; } }4.2 高级选取策略处理复杂模型结构对于复杂的模型基础的射线检测可能不够用。我们需要实现更高级的选取策略export class AdvancedInteractionManager extends InteractionManager { constructor(scene, camera, renderer) { super(scene, camera, renderer); this.selectionStrategies { hierarchical: this.hierarchicalSelection.bind(this), spatial: this.spatialSelection.bind(this), material-based: this.materialBasedSelection.bind(this) }; this.currentStrategy hierarchical; } // 策略1层级选择考虑父子关系 hierarchicalSelection(intersects) { if (intersects.length 0) return null; // 优先选择最具体的子对象 let bestMatch intersects[0].object; for (const intersect of intersects) { const object intersect.object; // 如果这个对象有更具体的子对象也被选中优先选择子对象 if (this.hasSelectableChildren(object) this.anyChildrenIntersected(object, intersects)) { continue; } // 选择命名最具体的对象 if (object.name !bestMatch.name) { bestMatch object; } else if (object.name bestMatch.name object.name.length bestMatch.name.length) { bestMatch object; } } return bestMatch; } hasSelectableChildren(object) { let hasChildren false; object.traverse((child) { if (child ! object child.isMesh child.userData.selectable) { hasChildren true; } }); return hasChildren; } anyChildrenIntersected(parent, intersects) { for (const intersect of intersects) { if (intersect.object ! parent this.isDescendant(parent, intersect.object)) { return true; } } return false; } isDescendant(parent, child) { let current child; while (current.parent) { if (current.parent parent) return true; current current.parent; } return false; } // 策略2空间选择基于边界框 spatialSelection(intersects) { if (intersects.length 0) return null; // 选择体积最小的对象通常是最具体的部件 return intersects.reduce((smallest, current) { const currentSize this.getObjectSize(current.object); const smallestSize this.getObjectSize(smallest.object); return currentSize smallestSize ? current : smallest; }).object; } getObjectSize(object) { const box new THREE.Box3().setFromObject(object); const size new THREE.Vector3(); box.getSize(size); return size.length(); } // 策略3基于材质的选择 materialBasedSelection(intersects) { if (intersects.length 0) return null; // 优先选择有独特材质的对象 const scoredIntersects intersects.map(intersect ({ object: intersect.object, score: this.calculateMaterialScore(intersect.object) })); scoredIntersects.sort((a, b) b.score - a.score); return scoredIntersects[0].object; } calculateMaterialScore(object) { let score 0; if (object.material) { // 基于材质特性评分 if (object.material.name) score 10; if (object.material.color) score 5; if (object.material.map) score 15; // 有纹理加分 } return score; } setSelectionStrategy(strategy) { if (this.selectionStrategies[strategy]) { this.currentStrategy strategy; } } onMouseClick(event) { this.getMousePosition(event); this.raycaster.setFromCamera(this.mouse, this.camera); const selectableObjects this.findSelectableObjects(); const intersects this.raycaster.intersectObjects(selectableObjects); if (intersects.length 0) { const selectedObject this.selectionStrategies[this.currentStrategy](intersects); this.selectObject(selectedObject); } else { this.clearSelection(); } } }5. 模型预处理与结构优化技巧5.1 建模阶段的准备工作在建模软件如Blender、3ds Max中做好以下准备可以大幅简化代码中的选取逻辑合理命名为每个需要独立选取的部件赋予有意义的名称层级分组使用空物体Empty或组Group组织相关部件材质区分为不同部件使用不同的材质便于代码识别UV展开确保每个部件有正确的UV映射便于后续贴图5.2 代码层面的模型预处理在加载模型后可以自动进行结构优化export class ModelPreprocessor { static optimizeModelStructure(model) { this.ensureSelectableMeshes(model); this.addBoundingBoxHelpers(model); this.setupLODs(model); return model; } static ensureSelectableMeshes(model) { model.traverse((object) { if (object.isMesh) { // 确保每个网格都有名称 if (!object.name) { object.name mesh_${Math.random().toString(36).substr(2, 9)}; } // 设置可选取标志 object.userData.selectable true; object.userData.originalName object.name; // 确保几何体已索引提高性能 if (!object.geometry.index) { object.geometry object.geometry.toNonIndexed(); } } }); } static addBoundingBoxHelpers(model) { model.traverse((object) { if (object.isMesh object.userData.selectable) { // 为每个可选取对象添加边界框辅助 const boxHelper new THREE.BoxHelper(object, 0xffff00); boxHelper.visible false; object.userData.boxHelper boxHelper; model.add(boxHelper); } }); } static setupLODs(model) { // 为复杂模型设置LODLevel of Detail model.traverse((object) { if (object.isMesh object.geometry.attributes.position.count 1000) { const lod new THREE.LOD(); // 高细节版本近距离 lod.addLevel(object.clone(), 0); // 中细节版本中距离 const mediumGeometry this.simplifyGeometry(object.geometry, 0.5); const mediumMesh new THREE.Mesh(mediumGeometry, object.material); lod.addLevel(mediumMesh, 10); // 低细节版本远距离 const lowGeometry this.simplifyGeometry(object.geometry, 0.2); const lowMesh new THREE.Mesh(lowGeometry, object.material); lod.addLevel(lowMesh, 30); object.parent.add(lod); object.parent.remove(object); } }); } static simplifyGeometry(geometry, ratio) { // 简化的几何体创建实际项目可使用专业简化算法 return geometry; } }6. 性能优化与生产环境考量6.1 射线检测性能优化当场景中有大量可选取对象时射线检测可能成为性能瓶颈export class OptimizedInteractionManager extends InteractionManager { constructor(scene, camera, renderer) { super(scene, camera, renderer); this.selectableObjects []; this.spatialIndex null; this.lastUpdateTime 0; this.updateThreshold 1000; // 1秒更新一次索引 this.initializeSpatialIndex(); } initializeSpatialIndex() { // 使用空间索引加速射线检测 this.spatialIndex new THREE.Octree(); this.updateSpatialIndex(); } updateSpatialIndex() { const currentTime Date.now(); if (currentTime - this.lastUpdateTime this.updateThreshold) { return; } this.selectableObjects this.findSelectableObjects(); this.spatialIndex.clear(); this.selectableObjects.forEach(object { this.spatialIndex.addObject(object); }); this.lastUpdateTime currentTime; } onMouseClick(event) { this.updateSpatialIndex(); this.getMousePosition(event); // 使用空间索引进行更高效的检测 const intersects this.spatialIndex.intersectRay( this.raycaster.ray, this.selectableObjects ); if (intersects.length 0) { this.selectObject(intersects[0].object); } else { this.clearSelection(); } } // 批量操作优化 batchSelectObjects(objects) { objects.forEach(object { this.highlightObject(object); }); } highlightObject(object) { if (object.material instanceof THREE.Material) { object.userData.originalMaterial object.material.clone(); object.material.emissive.setHex(0x00ff00); } } }6.2 内存管理与垃圾回收在长时间运行的3D应用中内存管理至关重要export class MemoryManager { static setupCleanupProtocol(interactionManager) { // 定期清理不再需要的资源 setInterval(() { this.cleanupUnusedMaterials(); this.cleanupOrphanedGeometries(); }, 30000); // 每30秒清理一次 } static cleanupUnusedMaterials() { // 清理未被使用的材质 THREE.Cache.clear(); } static cleanupOrphanedGeometries() { // 可以添加自定义的几何体清理逻辑 } static disposeObject(object) { if (object.geometry) { object.geometry.dispose(); } if (object.material) { if (Array.isArray(object.material)) { object.material.forEach(material material.dispose()); } else { object.material.dispose(); } } if (object.texture) { object.texture.dispose(); } } }7. 常见问题排查与解决方案7.1 选取相关的问题诊断问题现象可能原因检查方法解决方案点击模型无反应1. 模型未标记为selectable2. 相机位置问题3. 射线检测范围设置不当1. 检查console日志2. 验证相机视锥体3. 调试射线路径1. 确保userData.selectabletrue2. 调整相机near/far参数3. 添加调试可视化选中整个模型而非部件1. 模型是单一Mesh2. 子对象未正确设置1. 检查模型结构2. 验证遍历逻辑1. 建模时分组2. 使用高级选取策略选取精度差1. 模型面数过低2. 射线检测参数问题1. 检查几何体细节2. 调试相交计算1. 提高模型精度2. 调整射线参数性能随对象增多下降1. 未使用空间索引2. 检测范围过大1. 性能分析2. 检查对象数量1. 实现空间索引2. 分区域检测7.2 调试工具集成创建专门的调试工具类来辅助问题排查export class DebugTools { static enableRaycastDebug(interactionManager) { const debug { rayLine: null, intersectionPoints: [], isEnabled: false }; // 创建射线可视化 const rayGeometry new THREE.BufferGeometry(); const rayMaterial new THREE.LineBasicMaterial({ color: 0xff0000 }); debug.rayLine new THREE.Line(rayGeometry, rayMaterial); debug.rayLine.visible false; interactionManager.scene.add(debug.rayLine); // 重写射线检测方法以添加调试信息 const originalRaycast interactionManager.raycaster.intersectObjects; interactionManager.raycaster.intersectObjects function(objects, ...args) { const intersects originalRaycast.call(this, objects, ...args); if (debug.isEnabled) { // 更新射线可视化 const points [ interactionManager.raycaster.ray.origin, interactionManager.raycaster.ray.origin.clone() .add(interactionManager.raycaster.ray.direction.clone().multiplyScalar(100)) ]; debug.rayLine.geometry.setFromPoints(points); debug.rayLine.visible true; // 显示交点 debug.intersectionPoints.forEach(point point.removeFromParent()); debug.intersectionPoints []; intersects.forEach(intersect { const pointGeometry new THREE.SphereGeometry(0.05, 8, 8); const pointMaterial new THREE.MeshBasicMaterial({ color: 0x00ff00 }); const pointMesh new THREE.Mesh(pointGeometry, pointMaterial); pointMesh.position.copy(intersect.point); interactionManager.scene.add(pointMesh); debug.intersectionPoints.push(pointMesh); }); } return intersects; }; return debug; } }8. 扩展功能与最佳实践8.1 多选与框选功能实现在实际项目中经常需要支持多选或框选操作export class MultiSelectManager { constructor(interactionManager) { this.interactionManager interactionManager; this.selectedObjects new Set(); this.selectionRectangle null; this.isSelecting false; this.startPoint new THREE.Vector2(); this.setupMultiSelect(); } setupMultiSelect() { const domElement this.interactionManager.renderer.domElement; domElement.addEventListener(mousedown, (event) { if (event.shiftKey) { // Shift键多选 this.startMultiSelect(event); } }); domElement.addEventListener(mousemove, (event) { if (this.isSelecting) { this.updateSelectionRectangle(event); } }); domElement.addEventListener(mouseup, (event) { if (this.isSelecting) { this.finalizeMultiSelect(event); } }); } start