1. 射线检测与3D拾取

在 Three.js 中,射线检测(Raycasting)是实现3D拾取(3D Picking)的核心技术,允许你通过鼠标点击或触摸来与3D场景中的物体进行交互。

1.1. 基本原理

射线检测的工作原理是从摄像机通过鼠标在屏幕上的位置发射一条射线,检测这条射线与哪些3D物体相交。

1.2. 基本实现

1.2.1. 创建射线检测器

javascript

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

1.2.2. 设置鼠标位置并转换坐标

javascript

function onMouseClick(event) {
  // 将鼠标位置归一化为设备坐标(-1到+1)
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

1.2.3. 执行射线检测

javascript

function checkIntersection() {
  // 通过摄像机和鼠标位置更新射线
  raycaster.setFromCamera(mouse, camera);

  // 计算射线与哪些物体相交
  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    console.log('选中了物体:', intersects[0].object);
    console.log('交点位置:', intersects[0].point);
    console.log('距离:', intersects[0].distance);
    console.log('面的索引:', intersects[0].faceIndex);
    console.log('UV坐标:', intersects[0].uv);

    // 处理选中的物体
    handleObjectSelect(intersects[0].object);
  }
}

1.3. 完整示例

javascript

class ObjectPicker {
  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.renderer.domElement.addEventListener('click', this.onClick.bind(this));
    this.renderer.domElement.addEventListener('mousemove', this.onMouseMove.bind(this));
  }

  onClick(event) {
    this.updateMousePosition(event);
    this.checkIntersection();

    if (this.selectedObject) {
      // 高亮选中的物体
      this.highlightObject(this.selectedObject);
    }
  }

  onMouseMove(event) {
    this.updateMousePosition(event);

    // 实时检测鼠标悬停
    this.raycaster.setFromCamera(this.mouse, this.camera);
    const intersects = this.raycaster.intersectObjects(
      this.scene.children, 
      true
    );

    if (intersects.length > 0) {
      // 可以在这里实现鼠标悬停效果
      this.renderer.domElement.style.cursor = 'pointer';
    } else {
      this.renderer.domElement.style.cursor = 'default';
    }
  }

  updateMousePosition(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;
  }

  checkIntersection() {
    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;
      return true;
    }

    this.selectedObject = null;
    return false;
  }

  highlightObject(object) {
    // 重置之前的高亮
    this.scene.traverse((child) => {
      if (child.isMesh && child.material.emissive) {
        child.material.emissive.setHex(0x000000);
      }
    });

    // 高亮当前选中的物体
    if (object.material.emissive) {
      object.material.emissive.setHex(0x333333);
    }
  }
}

1.4. 高级技巧

1.4.1. 分层检测

javascript

// 只检测特定类型的物体
const interactiveObjects = [];
scene.traverse((object) => {
  if (object.userData.isInteractive) {
    interactiveObjects.push(object);
  }
});

const intersects = raycaster.intersectObjects(interactiveObjects, true);

1.4.2. 忽略背面检测

javascript

// 只检测正面
raycaster.setFromCamera(mouse, camera);
raycaster.params.Points.threshold = 0.1; // 点云检测阈值
raycaster.params.Line.threshold = 0.1;   // 线检测阈值

1.4.3. 性能优化

javascript

class OptimizedRaycaster {
  constructor() {
    this.raycaster = new THREE.Raster();
    this.lastCheckTime = 0;
    this.checkInterval = 100; // 每100ms检测一次
  }

  update(currentTime) {
    if (currentTime - this.lastCheckTime > this.checkInterval) {
      this.performRaycasting();
      this.lastCheckTime = currentTime;
    }
  }

  performRaycasting() {
    // 执行射线检测...
  }
}

1.4.4. GPU拾取(高性能方案)

javascript

class GPUPicker {
  constructor(renderer, scene, camera) {
    this.renderer = renderer;
    this.scene = scene;
    this.camera = camera;

    // 创建拾取纹理(通常使用1x1像素,但可以更大以获得更好精度)
    this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
    this.pixelBuffer = new Uint8Array(4);

    // 存储物体ID映射
    this.idToObject = new Map();
    this.objectToId = new WeakMap();

    // 创建拾取材质
    this.pickingMaterial = new THREE.ShaderMaterial({
      uniforms: {
        objectId: { value: new THREE.Vector3(0, 0, 0) }
      },
      vertexShader: `
        varying vec3 vColor;
        void main() {
          vColor = vec3(objectId.r / 255.0, objectId.g / 255.0, objectId.b / 255.0);
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        varying vec3 vColor;
        void main() {
          gl_FragColor = vec4(vColor, 1.0);
        }
      `
    });

    // 初始化ID分配
    this.nextId = 1;  // 0通常保留为背景/无选择
  }

  // 分配唯一ID给物体
  assignObjectIds() {
    this.scene.traverse((object) => {
      if (object.isMesh && object.visible) {
        const id = this.nextId++;
        object.userData.pickingId = id;

        // 将ID编码为RGB颜色
        const r = ((id >> 0) & 0xFF) / 255;
        const g = ((id >> 8) & 0xFF) / 255;
        const b = ((id >> 16) & 0xFF) / 255;

        this.idToObject.set(id, object);
        this.objectToId.set(object, {
          id: id,
          color: new THREE.Color(r, g, b)
        });
      }
    });
  }

  // 核心函数:使用ID颜色渲染场景
  renderObjectsWithIdColors() {
    // 保存原始场景状态
    const originalMaterials = new Map();

    // 遍历场景中的所有网格
    this.scene.traverse((object) => {
      if (object.isMesh && object.visible) {
        const idData = this.objectToId.get(object);
        if (idData) {
          // 保存原始材质
          originalMaterials.set(object, object.material);

          // 创建或复用拾取材质
          let pickingMaterial = object.userData.pickingMaterial;
          if (!pickingMaterial) {
            pickingMaterial = this.createPickingMaterial(idData.id);
            object.userData.pickingMaterial = pickingMaterial;
          }

          // 应用拾取材质
          object.material = pickingMaterial;
        }
      }
    });

    // 使用拾取材质渲染场景
    this.renderer.render(this.scene, this.camera);

    // 恢复原始材质
    originalMaterials.forEach((material, object) => {
      if (object.isMesh) {
        object.material = material;
      }
    });
  }

  // 创建基于ID的拾取材质
  createPickingMaterial(id) {
    // 将ID转换为RGB颜色
    const r = ((id >> 0) & 0xFF) / 255;
    const g = ((id >> 8) & 0xFF) / 255;
    const b = ((id >> 16) & 0xFF) / 255;

    return new THREE.MeshBasicMaterial({
      color: new THREE.Color(r, g, b),
      depthTest: true,
      depthWrite: true,
      transparent: false,
      side: THREE.FrontSide
    });
  }

  // 简化的ShaderMaterial版本(性能更好)
  renderObjectsWithIdColorsShader() {
    // 临时替换材质为拾取材质
    const originalMaterials = new Map();

    this.scene.traverse((object) => {
      if (object.isMesh && object.visible) {
        const idData = this.objectToId.get(object);
        if (idData) {
          // 保存原始材质
          originalMaterials.set(object, object.material);

          // 应用统一的拾取Shader材质
          const pickingMaterial = object.userData.pickingShaderMaterial || 
                                 this.createPickingShaderMaterial(idData.id);

          object.material = pickingMaterial;
          object.userData.pickingShaderMaterial = pickingMaterial;
        }
      }
    });

    // 渲染
    this.renderer.render(this.scene, this.camera);

    // 恢复材质
    originalMaterials.forEach((material, object) => {
      object.material = material;
    });
  }

  createPickingShaderMaterial(id) {
    // 将ID编码为归一化的RGB值
    const r = ((id >> 0) & 0xFF) / 255;
    const g = ((id >> 8) & 0xFF) / 255;
    const b = ((id >> 16) & 0xFF) / 255;

    return new THREE.ShaderMaterial({
      uniforms: {
        idColor: { value: new THREE.Vector3(r, g, b) }
      },
      vertexShader: `
        void main() {
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        uniform vec3 idColor;
        void main() {
          gl_FragColor = vec4(idColor, 1.0);
        }
      `,
      depthTest: true,
      depthWrite: true
    });
  }

  // 执行拾取操作
  pick(x, y) {
    // 计算拾取区域(通常使用小区域提高精度)
    const pickX = Math.floor(x * this.renderer.domElement.width);
    const pickY = Math.floor((1 - y) * this.renderer.domElement.height);

    // 设置拾取渲染目标
    const originalRenderTarget = this.renderer.getRenderTarget();
    const originalClearColor = this.renderer.getClearColor();

    // 配置拾取渲染
    this.renderer.setClearColor(0x000000); // 黑色背景(ID = 0)
    this.renderer.setRenderTarget(this.pickingTexture);
    this.renderer.clear();

    // 使用ID颜色渲染场景
    this.renderObjectsWithIdColorsShader();

    // 读取像素数据
    this.renderer.readRenderTargetPixels(
      this.pickingTexture,
      0, 0,  // 对于1x1纹理,总是读取(0,0)
      1, 1,
      this.pixelBuffer
    );

    // 恢复原始渲染状态
    this.renderer.setRenderTarget(originalRenderTarget);
    this.renderer.setClearColor(originalClearColor);

    // 将RGB颜色转换回ID
    const id = this.decodeIdFromColor(
      this.pixelBuffer[0],  // R
      this.pixelBuffer[1],  // G
      this.pixelBuffer[2]   // B
    );

    // 返回对应的物体
    return id > 0 ? this.idToObject.get(id) : null;
  }

  // 从颜色解码ID
  decodeIdFromColor(r, g, b) {
    return r + (g << 8) + (b << 16);
  }

  // 支持区域拾取(更精确)
  pickWithArea(x, y, width = 5, height = 5) {
    const canvas = this.renderer.domElement;
    const pickX = Math.floor(x * canvas.width - width / 2);
    const pickY = Math.floor((1 - y) * canvas.height - height / 2);

    // 创建更大的拾取纹理
    const areaTexture = new THREE.WebGLRenderTarget(width, height);

    // 设置渲染
    const originalTarget = this.renderer.getRenderTarget();
    this.renderer.setClearColor(0x000000);
    this.renderer.setRenderTarget(areaTexture);
    this.renderer.clear();

    // 渲染ID颜色
    this.renderObjectsWithIdColorsShader();

    // 读取整个区域
    const areaBuffer = new Uint8Array(width * height * 4);
    this.renderer.readRenderTargetPixels(
      areaTexture,
      0, 0,
      width, height,
      areaBuffer
    );

    // 恢复
    this.renderer.setRenderTarget(originalTarget);
    areaTexture.dispose();

    // 分析区域内的所有ID
    const ids = new Map();
    for (let i = 0; i < width * height; i++) {
      const r = areaBuffer[i * 4];
      const g = areaBuffer[i * 4 + 1];
      const b = areaBuffer[i * 4 + 2];
      const id = this.decodeIdFromColor(r, g, b);

      if (id > 0) {
        ids.set(id, (ids.get(id) || 0) + 1);
      }
    }

    // 返回出现次数最多的ID对应的物体
    let maxCount = 0;
    let selectedId = 0;
    ids.forEach((count, id) => {
      if (count > maxCount) {
        maxCount = count;
        selectedId = id;
      }
    });

    return selectedId > 0 ? this.idToObject.get(selectedId) : null;
  }
}

1.5. 实际应用场景

1.5.1. 3D标注系统

javascript

class AnnotationSystem {
  createAnnotation(intersect) {
    const annotation = document.createElement('div');
    annotation.className = 'annotation';
    annotation.textContent = '点击查看详情';

    // 将3D坐标转换为屏幕坐标
    const vector = intersect.point.clone();
    vector.project(camera);

    const x = (vector.x * 0.5 + 0.5) * window.innerWidth;
    const y = (-vector.y * 0.5 + 0.5) * window.innerHeight;

    annotation.style.left = `${x}px`;
    annotation.style.top = `${y}px`;

    document.body.appendChild(annotation);
  }
}

1.5.2. 拖拽3D物体

javascript

class DragController {
  startDrag(intersect) {
    this.draggedObject = intersect.object;
    this.offset = new THREE.Vector3().copy(intersect.point)
      .sub(this.draggedObject.position);
  }

  updateDrag(mouse) {
    if (!this.draggedObject) return;

    raycaster.setFromCamera(mouse, camera);
    const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0));
    const intersectionPoint = new THREE.Vector3();

    raycaster.ray.intersectPlane(plane, intersectionPoint);

    this.draggedObject.position.copy(intersectionPoint)
      .add(this.offset);
  }
}

1.6. 常见问题与解决方案

1.6.1. 问题1:射线检测不准确

解决

1.6.2. 问题2:性能问题

解决

1.6.3. 问题3:透明物体检测

javascript

// 透明物体需要特殊处理
raycaster.params.Mesh = {
  threshold: 0.1
};

// 或者单独处理透明材质
if (intersect.object.material.transparent) {
  // 特殊逻辑
}

1.7. 最佳实践

  1. 分层管理:将可交互物体单独分组管理

  2. 事件委托:使用事件委托减少事件监听器数量

  3. 节流检测:对频繁的检测操作进行节流

  4. 边界球优化:优先使用边界球进行粗略检测

  5. 缓存结果:缓存射线检测结果避免重复计算

通过合理使用射线检测技术,可以创建出交互性强的3D应用,从简单的点击选中到复杂的拖拽操作都能实现。