32 Raycaster 射线检测与设备交互

Raycaster 射线检测与设备交互

关联:索引

要解决的问题

本讲定位(与前置衔接,避免重复)

章节内容(本讲核心)

环境与先修(默认沿用 Three.js 工程)

先修要求:

如你需要补装依赖(仅在未安装时执行):

npm i three
npm i -D @types/three

解释:


工业场景的拾取(Picking)不是“炫技交互”,而是数据可视化的入口。最低标准可以总结为三句话:

Raycaster 解决的问题可以拆成四步:

  1. 获取鼠标在画布上的位置(屏幕坐标,单位是像素)
  2. 把屏幕坐标转换为 NDC(Normalized Device Coordinates,范围是 [-1, 1])
  3. 从相机发射射线(raycaster.setFromCamera(ndc, camera)
  4. 与场景中“可选对象集合”求交(intersectObjects),取最近命中作为选中目标

关键结论(背下来就能排错):

目标:

1) 可复制运行:Raycaster 点击选中组件(Vue3 + TS)

下面示例用几何体搭建“机械臂 + 传送带”两个可选对象,并把设备元信息放进 userData,用于后续信息面板展示。

建议落地文件路径(班级统一口径,便于排错):

src/
└─ components/
   └─ RaycasterDevicePickLab.vue

解释:

<template>
  <div class="page">
    <!-- Three.js 渲染容器:renderer.domElement 会 append 到这里 -->
    <div ref="containerRef" class="three"></div>

    <section class="panel">
      <h2>设备信息</h2>
      <div v-if="selectedDevice">
        <div class="row">
          <span class="label">名称</span>
          <span class="value">{{ selectedDevice.name }}</span>
        </div>
        <div class="row">
          <span class="label">状态</span>
          <span class="value">{{ selectedDevice.status }}</span>
        </div>
      </div>
      <div v-else class="hint">点击 3D 设备以查看信息</div>
    </section>
  </div>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

type DeviceStatus = 'RUNNING' | 'IDLE' | 'ALARM';
type DeviceMeta = { deviceId: string; deviceName: string; deviceStatus: DeviceStatus };

// 容器 DOM:用于计算鼠标坐标(getBoundingClientRect)与挂载 canvas
const containerRef = ref<HTMLDivElement | null>(null);

// Three.js 核心对象(在 onMounted 创建,在 onBeforeUnmount 释放)
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let controls: OrbitControls | null = null;

let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let onPointerDown: ((e: PointerEvent) => void) | null = null;
let onPointerUp: ((e: PointerEvent) => void) | null = null;

// Raycaster:负责从相机发射射线并与可选对象求交
const raycaster = new THREE.Raycaster();
// 鼠标 NDC(Normalized Device Coordinates):范围 [-1, 1]
const mouseNdc = new THREE.Vector2();

// 可选对象集合:只对它们做求交(避免遍历整个 scene)
const selectableMeshes: THREE.Mesh[] = [];
// 需要释放资源的对象集合:卸载时统一 dispose(几何体/材质/GPU 资源)
const disposables: THREE.Object3D[] = [];

// 选中对象:用于高亮与信息面板展示
const selectedObject = ref<THREE.Object3D | null>(null);
// 记录选中前的原材质:用于取消选中时恢复
let selectedOriginalMaterial: THREE.Material | THREE.Material[] | null = null;
// 选中高亮材质:复用一份,避免频繁 new Material 造成 GC/性能波动
const selectedHighlightMaterial = new THREE.MeshStandardMaterial({
  color: 0x22c55e,
  emissive: 0x14532d,
  emissiveIntensity: 0.9,
  transparent: true,
  opacity: 0.92,
});

// 从 selectedObject.userData 读出业务信息(面板展示用)
const selectedDevice = computed(() => {
  const obj = selectedObject.value;
  if (!obj) return null;
  const meta = obj.userData as Partial<DeviceMeta>;
  if (!meta.deviceName || !meta.deviceStatus) return null;
  return { name: meta.deviceName, status: meta.deviceStatus };
});

function resize() {
  const container = containerRef.value;
  if (!container || !renderer || !camera) return;

  const width = container.clientWidth;
  const height = container.clientHeight;
  if (width <= 0 || height <= 0) return;

  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(width, height, false);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

function animate() {
  if (!renderer || !scene || !camera) return;
  // 开启 enableDamping 后必须每帧 update,否则阻尼无效
  controls?.update();
  renderer.render(scene, camera);
  rafId = requestAnimationFrame(animate);
}

function setHighlight(mesh: THREE.Mesh, enabled: boolean) {
  if (enabled) {
    mesh.material = selectedHighlightMaterial;
  } else if (selectedOriginalMaterial) {
    mesh.material = selectedOriginalMaterial;
  }
}

function clearSelection() {
  const current = selectedObject.value;
  if (current && current instanceof THREE.Mesh) {
    setHighlight(current, false);
  }
  selectedObject.value = null;
  selectedOriginalMaterial = null;
}

// 拾取入口:将 pointer 坐标换算为 NDC,发射射线并求交
function pickObjectAt(clientX: number, clientY: number) {
  const container = containerRef.value;
  if (!container || !camera) return;

  // 用容器 rect 做换算(避免 canvas 非全屏时坐标计算错误)
  const rect = container.getBoundingClientRect();
  const inside =
    clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
  if (!inside) return;

  // 像素坐标 -> NDC:x 映射到 [-1, 1];y 需要翻转(屏幕 y 向下,NDC y 向上)
  mouseNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
  mouseNdc.y = -(((clientY - rect.top) / rect.height) * 2 - 1);

  // 从相机穿过 mouseNdc 发射射线
  raycaster.setFromCamera(mouseNdc, camera);

  // 只检测 selectableMeshes(性能与误触发控制的关键)
  const intersects = raycaster.intersectObjects(selectableMeshes, false);
  const hit = intersects[0]?.object ?? null;

  // 点击空白:取消选中
  if (!hit) {
    clearSelection();
    return;
  }

  // 点击同一对象:不重复处理(避免材质反复替换)
  if (selectedObject.value === hit) return;

  clearSelection();
  selectedObject.value = hit;

  if (hit instanceof THREE.Mesh) {
    // 保存原材质:用于取消选中恢复
    selectedOriginalMaterial = hit.material;
    setHighlight(hit, true);
  }
}

onMounted(() => {
  const container = containerRef.value;
  if (!container) throw new Error('Three container not found');

  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0b1220);

  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  container.appendChild(renderer.domElement);

  const width = container.clientWidth || 1;
  const height = container.clientHeight || 1;
  camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
  camera.position.set(8, 5, 10);
  camera.lookAt(0, 0, 0);

  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.08;
  controls.target.set(0, 0.8, 0);
  controls.update();

  const gridHelper = new THREE.GridHelper(30, 30);
  const axesHelper = new THREE.AxesHelper(2);
  scene.add(gridHelper);
  scene.add(axesHelper);
  scene.add(new THREE.AmbientLight(0xffffff, 0.35));
  disposables.push(gridHelper, axesHelper);
  const light = new THREE.DirectionalLight(0xffffff, 1.2);
  light.position.set(6, 10, 4);
  scene.add(light);
  disposables.push(light);

  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(30, 30),
    new THREE.MeshStandardMaterial({ color: 0x0f172a, roughness: 1 })
  );
  floor.rotation.x = -Math.PI / 2;
  scene.add(floor);
  disposables.push(floor);

  const arm = new THREE.Mesh(
    new THREE.CylinderGeometry(0.35, 0.35, 2.4, 24),
    new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.6, roughness: 0.35 })
  );
  arm.position.set(-3, 1.2, 0);
  const armMeta: DeviceMeta = { deviceId: 'arm-01', deviceName: '机械臂-01', deviceStatus: 'RUNNING' };
  arm.userData = armMeta;
  scene.add(arm);
  selectableMeshes.push(arm);
  disposables.push(arm);

  const belt = new THREE.Mesh(
    new THREE.BoxGeometry(5, 0.35, 1.2),
    new THREE.MeshStandardMaterial({ color: 0x334155, metalness: 0.2, roughness: 0.7 })
  );
  belt.position.set(3, 0.2, 0);
  const beltMeta: DeviceMeta = { deviceId: 'belt-01', deviceName: '传送带-01', deviceStatus: 'IDLE' };
  belt.userData = beltMeta;
  scene.add(belt);
  selectableMeshes.push(belt);
  disposables.push(belt);

  resize();
  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  const pointerDown = { x: 0, y: 0 };
  onPointerDown = (e: PointerEvent) => {
    pointerDown.x = e.clientX;
    pointerDown.y = e.clientY;
  };

  // pointerup 时判断位移:位移过大视为 OrbitControls 拖拽导航,不触发点击拾取
  onPointerUp = (e: PointerEvent) => {
    const dx = e.clientX - pointerDown.x;
    const dy = e.clientY - pointerDown.y;
    const moved = Math.hypot(dx, dy);
    if (moved > 3) return;
    pickObjectAt(e.clientX, e.clientY);
  };

  renderer.domElement.addEventListener('pointerdown', onPointerDown);
  renderer.domElement.addEventListener('pointerup', onPointerUp);
  animate();
});

onBeforeUnmount(() => {
  if (renderer?.domElement && onPointerDown) renderer.domElement.removeEventListener('pointerdown', onPointerDown);
  if (renderer?.domElement && onPointerUp) renderer.domElement.removeEventListener('pointerup', onPointerUp);

  if (rafId !== null) cancelAnimationFrame(rafId);
  resizeObserver?.disconnect();

  clearSelection();
  controls?.dispose();

  if (scene) {
    for (const obj of disposables) {
      scene.remove(obj);
      const resourceOwner = obj as unknown as { geometry?: { dispose?: () => void }; material?: THREE.Material | THREE.Material[] };
      if (resourceOwner.geometry && typeof resourceOwner.geometry.dispose === 'function') {
        resourceOwner.geometry.dispose();
      }

      const m = resourceOwner.material;
      if (Array.isArray(m)) {
        for (const mi of m) {
          if (mi !== selectedHighlightMaterial) mi.dispose();
        }
      } else if (m && m !== selectedHighlightMaterial) {
        m.dispose();
      }
    }
  }
  selectableMeshes.length = 0;
  disposables.length = 0;

  selectedHighlightMaterial.dispose();

  renderer?.dispose();
  if (renderer?.domElement && renderer.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  selectedObject.value = null;
  selectedOriginalMaterial = null;

  controls = null;
  camera = null;
  scene = null;
  renderer = null;
  resizeObserver = null;
  rafId = null;
  onPointerDown = null;
  onPointerUp = null;
});
</script>

<style scoped>
.page {
  display: grid;
  grid-template-columns: 1fr 320px;
  gap: 12px;
  padding: 12px;
  background: #0b1220;
  min-height: 100vh;
  color: #e5e7eb;
}

.three {
  width: 100%;
  height: calc(100vh - 24px);
  border: 1px solid rgba(148, 163, 184, 0.16);
  border-radius: 10px;
  overflow: hidden;
}

.panel {
  border: 1px solid rgba(148, 163, 184, 0.16);
  border-radius: 10px;
  padding: 12px;
  background: rgba(15, 23, 42, 0.78);
  height: fit-content;
}

.panel h2 {
  margin: 0 0 10px;
  font-size: 16px;
}

.row {
  display: grid;
  grid-template-columns: 56px 1fr;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  border-top: 1px solid rgba(148, 163, 184, 0.12);
}

.label {
  color: rgba(226, 232, 240, 0.75);
}

.value {
  font-weight: 700;
}

.hint {
  color: rgba(226, 232, 240, 0.7);
  padding: 10px 0;
}
</style>

把组件挂载到页面(最小方式:App 直接渲染):

src/App.vue(核心片段):

<template>
  <RaycasterDevicePickLab />
</template>

<script setup lang="ts">
import RaycasterDevicePickLab from './components/RaycasterDevicePickLab.vue';
</script>

解释:

解释(把 Raycaster 接入“鼠标点击→选中状态”):

hover 的价值:

hover 的风险:

推荐状态口径(工程里更不容易乱):

高亮优先级建议:

本讲的信息面板只展示两项:名称、运行状态。但建议你从一开始就让数据结构可扩展,例如:

最小扩展示例(无需改 3D 逻辑,只改面板展示):

type DeviceMeta = {
  deviceId: string;
  deviceName: string;
  deviceStatus: 'RUNNING' | 'IDLE' | 'ALARM';
  temperatureC?: number;
};

解释:

性能优化优先级从高到低建议是:

  1. 限对象:只检测“可选对象集合”,不要对整个 scene 求交
  2. 限频率:hover 必须限频;click 可以每次点击求交一次
  3. 限范围:缩短射线检测范围(near/far),减少无意义命中
  4. 限递归:能不递归就不递归;复杂模型可用“设备根节点”统一拾取

1) 射线范围限制(推荐)

raycaster.near = 0.2;
raycaster.far = 80;

解释:

2) 检测频率控制(hover 必做)

hoverRaycast.minIntervalMs = 40;

解释:

3) “可选对象集合”的组织策略(工程化建议)

当你从几何体过渡到 GLTF 模型后,常见问题是:点击命中的是模型内部的某个子网格,但业务上你想选中“整台设备”。推荐两种策略:

策略 B 的最小实现:

function findDeviceRoot(obj: THREE.Object3D): THREE.Object3D | null {
  let current: THREE.Object3D | null = obj;
  while (current) {
    const meta = current.userData as Partial<DeviceMeta>;
    if (meta.deviceId) return current;
    current = current.parent;
  }
  return null;
}

解释:

  1. 多设备选中:机械臂/传送带/工位(至少 2 个)都能被点击选中,且不会串台。
  2. hover 预提示:鼠标移入设备有预提示(高亮或指针),移出能恢复。
  3. 信息准确性:信息面板名称/状态与设备绑定一致,连续切换不错误。
  4. 误触发控制:点击地面/网格不选中设备,或不会影响当前选中。
  5. 流畅性:快速移动鼠标与连续点击时无明显卡顿,交互响应无明显延迟。

项目工坊:工业分拣产线设备选中 + 信息面板 + 性能优化

实现目标:

作业(布置)

  1. 完成 3D 场景中至少 2 个设备的射线检测选中功能,实现选中高亮与信息面板展示。
  2. 优化射线检测性能,确保点击响应流畅(无延迟、无误触发)。

参考与延伸