1. 拖放交互实现

下面详细介绍 Three.js 中实现拖放交互的几种方法。

1.1. 使用 THREE.DragControls(最简单的方法)

javascript

import * as THREE from 'three';
import { DragControls } from 'three/examples/jsm/controls/DragControls';

// 创建场景、相机、渲染器...
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();

// 创建可拖拽的物体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 初始化拖拽控制
const objects = [cube]; // 需要拖拽的对象数组
const dragControls = new DragControls(objects, camera, renderer.domElement);

// 添加事件监听
dragControls.addEventListener('dragstart', function(event) {
    event.object.material.color.set(0xff0000); // 拖拽开始变红色
    controls.enabled = false; // 禁用相机控制
});

dragControls.addEventListener('drag', function(event) {
    console.log('拖拽中:', event.object.position);
});

dragControls.addEventListener('dragend', function(event) {
    event.object.material.color.set(0x00ff00); // 拖拽结束恢复颜色
    controls.enabled = true; // 启用相机控制
});

// 限制拖拽平面
dragControls.transformGroup = true; // 以组的形式拖拽

1.2. 自定义拖拽实现(Raycaster 方法)

javascript

class DragAndDrop {
    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.offset = new THREE.Vector3();
        this.plane = new THREE.Plane();
        this.intersection = new THREE.Vector3();

        this.init();
    }

    init() {
        const domElement = this.renderer.domElement;

        domElement.addEventListener('mousedown', this.onMouseDown.bind(this));
        domElement.addEventListener('mousemove', this.onMouseMove.bind(this));
        domElement.addEventListener('mouseup', this.onMouseUp.bind(this));

        // 支持触摸屏
        domElement.addEventListener('touchstart', this.onTouchStart.bind(this));
        domElement.addEventListener('touchmove', this.onTouchMove.bind(this));
        domElement.addEventListener('touchend', this.onTouchEnd.bind(this));
    }

    onMouseDown(event) {
        event.preventDefault();

        // 计算鼠标位置归一化坐标
        this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        this.handlePointerDown();
    }

    onTouchStart(event) {
        event.preventDefault();

        if (event.touches.length === 1) {
            this.mouse.x = (event.touches[0].pageX / window.innerWidth) * 2 - 1;
            this.mouse.y = -(event.touches[0].pageY / window.innerHeight) * 2 + 1;

            this.handlePointerDown();
        }
    }

    handlePointerDown() {
        // 更新射线
        this.raycaster.setFromCamera(this.mouse, this.camera);

        // 检测与哪个物体相交
        const intersects = this.raycaster.intersectObjects(this.scene.children, true);

        if (intersects.length > 0) {
            this.selectedObject = intersects[0].object;

            // 计算拖拽平面(垂直于相机视线)
            const cameraDirection = new THREE.Vector3();
            this.camera.getWorldDirection(cameraDirection);
            this.plane.setFromNormalAndCoplanarPoint(
                cameraDirection, 
                this.selectedObject.position
            );

            // 计算偏移量
            if (this.raycaster.ray.intersectPlane(this.plane, this.intersection)) {
                this.offset.copy(this.intersection).sub(this.selectedObject.position);
            }
        }
    }

    onMouseMove(event) {
        event.preventDefault();

        this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        this.handlePointerMove();
    }

    onTouchMove(event) {
        event.preventDefault();

        if (event.touches.length === 1) {
            this.mouse.x = (event.touches[0].pageX / window.innerWidth) * 2 - 1;
            this.mouse.y = -(event.touches[0].pageY / window.innerHeight) * 2 + 1;

            this.handlePointerMove();
        }
    }

    handlePointerMove() {
        if (this.selectedObject) {
            this.raycaster.setFromCamera(this.mouse, this.camera);

            if (this.raycaster.ray.intersectPlane(this.plane, this.intersection)) {
                this.selectedObject.position.copy(
                    this.intersection.sub(this.offset)
                );
            }
        }
    }

    onMouseUp(event) {
        event.preventDefault();
        this.selectedObject = null;
    }

    onTouchEnd(event) {
        event.preventDefault();
        this.selectedObject = null;
    }
}

1.3. 基于平面的拖拽(限制在特定平面)

javascript

class PlaneDragControls {
    constructor(object, planeNormal = new THREE.Vector3(0, 1, 0)) {
        this.object = object;
        this.planeNormal = planeNormal.normalize();
        this.plane = new THREE.Plane();
        this.isDragging = false;
        this.offset = new THREE.Vector3();

        this.intersection = new THREE.Vector3();
        this.worldPosition = new THREE.Vector3();
        this.inverseMatrix = new THREE.Matrix4();
    }

    startDrag(mouse, camera) {
        const raycaster = new THREE.Raycaster();
        raycaster.setFromCamera(mouse, camera);

        // 创建拖拽平面
        this.object.updateMatrixWorld();
        this.object.getWorldPosition(this.worldPosition);
        this.plane.setFromNormalAndCoplanarPoint(
            this.planeNormal, 
            this.worldPosition
        );

        if (raycaster.ray.intersectPlane(this.plane, this.intersection)) {
            this.offset.copy(this.intersection).sub(this.worldPosition);
            this.isDragging = true;
            return true;
        }
        return false;
    }

    drag(mouse, camera) {
        if (!this.isDragging) return;

        const raycaster = new THREE.Raycaster();
        raycaster.setFromCamera(mouse, camera);

        if (raycaster.ray.intersectPlane(this.plane, this.intersection)) {
            const newPosition = this.intersection.sub(this.offset);

            // 如果需要保持物体在局部坐标系
            this.object.position.copy(newPosition);

            // 或者转换到世界坐标
            // this.object.worldToLocal(newPosition);
            // this.object.position.copy(newPosition);
        }
    }

    endDrag() {
        this.isDragging = false;
    }
}

1.4. 完整示例:带网格吸附的拖拽

javascript

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

class AdvancedDragAndDrop {
    constructor() {
        this.scene = new THREE.Scene();
        this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 1000);
        this.renderer = new THREE.WebGLRenderer({ antialias: true });

        this.initScene();
        this.setupLights();
        this.createGrid();
        this.createDraggableObjects();
        this.setupControls();
        this.setupEventListeners();

        this.selectedObject = null;
        this.snapToGrid = true;
        this.gridSize = 1;

        this.animate();
    }

    initScene() {
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(this.renderer.domElement);

        this.camera.position.set(10, 10, 10);
        this.camera.lookAt(0, 0, 0);

        this.scene.background = new THREE.Color(0xf0f0f0);
    }

    setupLights() {
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
        this.scene.add(ambientLight);

        const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
        directionalLight.position.set(10, 20, 0);
        this.scene.add(directionalLight);
    }

    createGrid() {
        const gridHelper = new THREE.GridHelper(20, 20, 0x000000, 0x000000);
        gridHelper.material.opacity = 0.2;
        gridHelper.material.transparent = true;
        this.scene.add(gridHelper);
    }

    createDraggableObjects() {
        // 创建多个可拖拽物体
        const geometries = [
            new THREE.BoxGeometry(1, 1, 1),
            new THREE.SphereGeometry(0.5, 32, 32),
            new THREE.ConeGeometry(0.5, 1, 32)
        ];

        const colors = [0xff0000, 0x00ff00, 0x0000ff];

        geometries.forEach((geometry, index) => {
            const material = new THREE.MeshPhongMaterial({ 
                color: colors[index],
                transparent: true,
                opacity: 0.8
            });

            const mesh = new THREE.Mesh(geometry, material);
            mesh.position.set(
                (index - 1) * 2,
                0.5,
                0
            );

            // 添加边框高亮
            const edges = new THREE.EdgesGeometry(geometry);
            const line = new THREE.LineSegments(
                edges,
                new THREE.LineBasicMaterial({ color: 0x000000 })
            );
            mesh.add(line);

            mesh.userData.draggable = true;
            mesh.userData.originalY = mesh.position.y;

            this.scene.add(mesh);
        });
    }

    setupControls() {
        this.controls = new OrbitControls(this.camera, this.renderer.domElement);
        this.controls.enableDamping = true;
        this.controls.dampingFactor = 0.05;
    }

    setupEventListeners() {
        this.renderer.domElement.addEventListener('mousedown', this.onMouseDown.bind(this));
        this.renderer.domElement.addEventListener('mousemove', this.onMouseMove.bind(this));
        this.renderer.domElement.addEventListener('mouseup', this.onMouseUp.bind(this));

        window.addEventListener('resize', this.onWindowResize.bind(this));

        // 键盘控制
        window.addEventListener('keydown', (event) => {
            if (event.key === 'g') {
                this.snapToGrid = !this.snapToGrid;
                console.log('网格吸附:', this.snapToGrid ? '开启' : '关闭');
            }
        });
    }

    onMouseDown(event) {
        event.preventDefault();

        const mouse = new THREE.Vector2();
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        const raycaster = new THREE.Raycaster();
        raycaster.setFromCamera(mouse, this.camera);

        const intersects = raycaster.intersectObjects(
            this.scene.children.filter(obj => obj.userData.draggable)
        );

        if (intersects.length > 0) {
            this.selectedObject = intersects[0].object;
            this.controls.enabled = false;

            // 创建拖拽平面
            const planeNormal = new THREE.Vector3(0, 1, 0);
            const worldPosition = new THREE.Vector3();
            this.selectedObject.getWorldPosition(worldPosition);

            this.dragPlane = new THREE.Plane();
            this.dragPlane.setFromNormalAndCoplanarPoint(
                planeNormal,
                worldPosition
            );

            // 计算偏移
            this.offset = new THREE.Vector3();
            const intersection = new THREE.Vector3();
            if (raycaster.ray.intersectPlane(this.dragPlane, intersection)) {
                this.offset.copy(intersection).sub(worldPosition);
            }

            // 高亮选中物体
            this.highlightObject(this.selectedObject, true);
        }
    }

    onMouseMove(event) {
        if (!this.selectedObject) return;

        const mouse = new THREE.Vector2();
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        const raycaster = new THREE.Raycaster();
        raycaster.setFromCamera(mouse, this.camera);

        const intersection = new THREE.Vector3();
        if (raycaster.ray.intersectPlane(this.dragPlane, intersection)) {
            let newPosition = intersection.sub(this.offset);

            // 网格吸附
            if (this.snapToGrid) {
                newPosition.x = Math.round(newPosition.x / this.gridSize) * this.gridSize;
                newPosition.z = Math.round(newPosition.z / this.gridSize) * this.gridSize;
                newPosition.y = this.selectedObject.userData.originalY;
            }

            this.selectedObject.position.copy(newPosition);
        }
    }

    onMouseUp() {
        if (this.selectedObject) {
            this.highlightObject(this.selectedObject, false);
            this.selectedObject = null;
            this.controls.enabled = true;
        }
    }

    highlightObject(object, highlight) {
        if (object.children[0]) {
            object.children[0].material.color.set(highlight ? 0xffff00 : 0x000000);
        }
    }

    onWindowResize() {
        this.camera.aspect = window.innerWidth / window.innerHeight;
        this.camera.updateProjectionMatrix();
        this.renderer.setSize(window.innerWidth, window.innerHeight);
    }

    animate() {
        requestAnimationFrame(this.animate.bind(this));
        this.controls.update();
        this.renderer.render(this.scene, this.camera);
    }
}

// 初始化
new AdvancedDragAndDrop();

1.5. 最佳实践建议

  1. 性能优化

    javascript

   // 使用节流防止频繁更新
   let dragTimeout;
   function onDrag(event) {
       clearTimeout(dragTimeout);
       dragTimeout = setTimeout(() => {
           // 更新逻辑
       }, 16); // 约60fps
   }
  1. 多平台支持

    javascript

   // 统一处理鼠标和触摸事件
   function getNormalizedCoordinates(event) {
       let clientX, clientY;

       if (event.touches) {
           clientX = event.touches[0].clientX;
           clientY = event.touches[0].clientY;
       } else {
           clientX = event.clientX;
           clientY = event.clientY;
       }

       return {
           x: (clientX / window.innerWidth) * 2 - 1,
           y: -(clientY / window.innerHeight) * 2 + 1
       };
   }
  1. 拖拽约束

    javascript

   // 限制拖拽范围
   const bounds = {
       minX: -10, maxX: 10,
       minY: 0, maxY: 10,
       minZ: -10, maxZ: 10
   };

   function constrainPosition(position) {
       position.x = Math.max(bounds.minX, Math.min(bounds.maxX, position.x));
       position.y = Math.max(bounds.minY, Math.min(bounds.maxY, position.y));
       position.z = Math.max(bounds.minZ, Math.min(bounds.maxZ, position.z));
   }

这些方法覆盖了从简单到复杂的拖拽实现,你可以根据具体需求选择合适的方案。