【Three.js】day104-raycaster-interaction

作者:mario 发布时间: 2026-09-07 阅读量:6 评论数:0

Day 104 · 交互 — Raycaster 射线拾取与事件桥接

前六天场景是"能看的";今天给它"手"——点一下设备,它高亮,旁边弹出信息面板。2D 里这叫命中检测(第 7 周拓扑图做过),3D 里叫 Raycaster(射线拾取)。今天落地三件事:屏幕坐标→世界射线的换算、Raycaster 拾取与 OrbitControls 相机控制、以及 Day 103 定下的出向桥——引擎只报"我点了哪个设备(id)",Vue 收到 id 打开面板。边界契约在昨天画好,今天让数据真的流起来。


目录


一、从 2D 命中检测到 3D 射线拾取

1.1 同一个问题的两种解法

维度

2D 拓扑图(Day 46/49)

3D 场景(今天)

原理

鼠标坐标 vs 图元包围盒/几何判断

从相机发射射线 vs 三角面求交

复杂度

平面几何判断(便宜)

三维求交(含深度排序)

API

自己写 or canvas 判断

Raycaster 一行搞定

记忆钩子:2D 问"鼠标点在哪个框里",3D 问"从眼睛出发的视线穿过了什么"——本质上都是"命中检测",只是维度不同。第 7 周的经验直接迁移。

1.2 为什么 3D 必须"从相机发射"

屏幕上每个像素对应一条从相机位置出发、穿过该像素的射线。鼠标点在哪,就用哪条射线去和场景里的物体求交,取最近的交点。这就是 Raycaster 的全部原理——射线 = 视线


二、Raycaster 原理:从屏幕到世界的换算

2.1 屏幕坐标 → 归一化设备坐标(NDC)

鼠标的 clientX/Y 是像素坐标,Raycaster 需要的是 NDC(-1 ~ 1)

import * as THREE from "three";

/**
 * 屏幕像素坐标 → 归一化设备坐标(NDC)
 * NDC:x 右为 +1、y 上为 +1;与 Three 坐标系一致(y 向上)
 */
function toNdc(clientX: number, clientY: number, canvas: HTMLCanvasElement): THREE.Vector2 {
  return new THREE.Vector2(
    (clientX / canvas.clientWidth) * 2 - 1,      // x: 0~width → -1~1
    -(clientY / canvas.clientHeight) * 2 + 1     // y: 0~height → 1~-1(注意取反)
  );
}

最容易错的一行clientY 要取反——屏幕坐标 y 向下,NDC y 向上(Day 99 坐标系纪律的又一次现身)。

2.2 发射射线并求交

// 1. 建立射线:起点 = 相机位置,方向 = 穿过该 NDC 点
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndc, camera);

// 2. 与场景里的"可拾取对象"求交,得到按距离排序的命中数组
const hits = raycaster.intersectObjects(pickables, true);
//    - pickables:参与拾取的对象数组(通常是设备 Mesh/Group 列表)
//    - 第二个参数 true:递归子物体(Group 里的零件也算)

// 3. 取最近的命中
const hit = hits[0];

2.3 关键配置:pickables 清单

不是把整个 scene 丢进去,而是维护一个"可拾取设备"清单(Day 103 边界:引擎内部持有):

// 引擎内部:可拾取对象列表(初始化时收集,不含地面/辅助线)
const pickables: THREE.Object3D[] = [];
// 每个设备 Group 挂一个自定义标记:设备 id(传数据不传对象的源头)
deviceGroup.userData.deviceId = "pump-01";
pickables.push(deviceGroup);

userData 是 Three 提供的"自定义数据挂载点"——把设备 id 挂在对象上,命中后 hit.object.userData.deviceId 就能拿到 id。这才是出向桥要传的东西


三、OrbitControls:给相机装上手

3.1 装与不装

状态

表现

不装

相机定死,只能看一个角度(Week 15 之前的体验)

装 OrbitControls

左键旋转 / 右键平移 / 滚轮缩放——大屏 3D 的标准操作

3.2 安装与循环接入

npm install three
npm install -D @types/three
# OrbitControls 在 three/examples 里,无需额外包:
# import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

// 创建控制器:绑定相机与渲染器的 DOM 元素
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;      // 阻尼感(更跟手),开启后每帧必须 update()
controls.dampingFactor = 0.08;      // 阻尼系数
controls.minDistance = 1;           // 缩放下限(别穿进模型)
controls.maxDistance = 30;          // 缩放上限

// 循环里:更新段调 controls.update()(有阻尼时必须,否则惯性不生效)
const tick = () => {
  controls.update();                 // 让控制器把相机摆到新位置
  renderer.render(scene, camera);
  rafId = requestAnimationFrame(tick);
};

记忆钩子:OrbitControls 是"引擎内最高的每帧对象"(Day 103 裁决 #6:相机位置归 Three,不进 Vue)。它自己监听鼠标事件改相机——这就是为什么"相机控制"永远不该碰 Vue。

3.3 与 Raycaster 的冲突点

OrbitControls 会消费鼠标拖拽事件。"拖拽"和"点击"必须区分——用户拖动视角时不该触发设备选择。常用判断:按下与松开的位置差 < 5px 视为点击。

let downX = 0, downY = 0;   // 记录按下位置
canvas.addEventListener("pointerdown", (e) => { downX = e.clientX; downY = e.clientY; });
canvas.addEventListener("pointerup", (e) => {
  const dx = e.clientX - downX, dy = e.clientY - downY;
  if (dx * dx + dy * dy < 25) handleClick(e);   // 位移平方和 < 25 → 视为点击
});

四、拾取流程:hover 高亮 + 点击选中

4.1 三步状态机

pointermove ──► 拾取 ──► 有命中?──► hover 高亮(换材质/加描边)
                     └── 无 ──────► 取消高亮
pointerup(点击) ──► 拾取 ──► 有命中?──► 触发 select 事件(带 deviceId)
                     └── 无 ──────► 取消选中

4.2 hover 高亮的两个实现思路

方案

做法

适用

换材质

命中时替换/克隆材质(如发光色)

简单、本周够用

加描边

OutlinePass(后处理)

效果好、Week 16 再上

本周用换材质(避免引入后处理复杂度,红线第 1 条):

// hover 高亮:克隆材质 + 提亮(不污染原材质)
const HIGHLIGHT = 0x66e0ff;   // 高亮色(primary 的亮版)
const normalColor = new THREE.Color(0x16233a);

function setHovered(obj: THREE.Object3D | null): void {
  // 遍历设备所有 Mesh,统一换高亮色(下个版本可用材质克隆区分设备)
  if (obj) {
    obj.traverse((child) => {
      if (child instanceof THREE.Mesh) (child.material as THREE.MeshStandardMaterial).color.setHex(HIGHLIGHT);
    });
  } else {
    // 离开:还原(此处简化为固定色;多设备场景需记录各自原色)
  }
}

obj.traverse(cb):遍历对象树的所有子物体——"对设备所有零件统一操作"的标准姿势(Day 102 Group 的配套 API)。


五、事件桥接落地:出向桥的实装

5.1 在引擎里实现 Day 103 的 SceneEvents

// src/core/three/scene-engine.ts(Day 103 契约的实装片段)
export type SceneEvents =
  | { type: "hover"; deviceId: string | null }
  | { type: "select"; deviceId: string };

export interface SceneEngine {
  bridge: SceneBridge;
  events: { on(fn: (e: SceneEvents) => void): void };
  start(): () => void;
  dispose(): void;
}

// 引擎内部:事件发射器(最简实现,够用即可)
let emit: (e: SceneEvents) => void = () => {};
events.on = (fn) => { emit = fn; };

// 在 pointermove / pointerup 处理器里调用:
// 命中 → emit({ type: "hover", deviceId: hit.object.userData.deviceId })
//       emit({ type: "select", deviceId: ... })

5.2 Vue 侧消费:id → 面板

<!-- 接 Day 103 的 ThreeViewport.vue:出向桥落地 -->
<script setup lang="ts">
import { ref } from "vue";

const hoverId = ref<string | null>(null);   // Vue 侧状态:只存 id,不存对象(边界!)
const selectedId = ref<string | null>(null);

// 引擎初始化时注册事件:
engine.events.on((e) => {
  if (e.type === "hover") hoverId.value = e.deviceId;      // hover 面板
  if (e.type === "select") selectedId.value = e.deviceId;  // 详情面板
});
</script>

<template>
  <!-- 详情面板:id 驱动,内容是纯 Vue 组件 -->
  <div v-if="selectedId" class="device-panel">
    <h3>{{ selectedId }}</h3>
    <p>温度:{{ 86.4 }}℃</p>   <!-- 第 6 个月换成 store 数据 -->
  </div>
</template>

这就是 Day 103 契约的价值:Vue 只看到 string 类型的 id,永远不接触 Group/Mesh。交互数据流闭合:引擎报 id → Vue 存 id → Vue 渲染面板。边界干净,职责清晰。


六、完整示例:可点击的 3D 车间

把今天全部内容组装进引擎(Day 105 会把它扩展成 BOSS 战车间):

// src/core/three/interactive-engine.ts(Day 102 设备 + 今天交互 的整合)
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { SceneEvents } from "./scene-engine";

/**
 * 交互式 3D 引擎:场景 + 循环 + 相机控制 + 射线拾取 + 出向事件
 * 边界纪律:只向外部暴露 events.on(id),不暴露场景树
 */
export function createInteractiveEngine(canvas: HTMLCanvasElement) {
  // —— 场景三件套(Day 99/100 汇总)——
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0d1421);
  const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
  camera.position.set(6, 5, 8);
  camera.lookAt(0, 0, 0);
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(canvas.clientWidth, canvas.clientHeight);

  // —— 光照:三灯法则(Day 101)——
  scene.add(new THREE.AmbientLight(0xffffff, 0.4));
  const sun = new THREE.DirectionalLight(0xffffff, 1.2);
  sun.position.set(5, 8, 4);
  scene.add(sun);

  // —— 地面:帮助判断空间感(PlaneGeometry + 不参与拾取)——
  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({ color: 0x16233a, roughness: 0.9 })
  );
  floor.rotation.x = -Math.PI / 2;   // 躺平成地面
  floor.position.y = 0;
  scene.add(floor);

  // —— 可拾取设备清单(引擎内部持有,不含地面)——
  const pickables: THREE.Object3D[] = [];
  const devices = [/* Day 102 buildFanDevice 的多个实例,每个 .userData.deviceId = "pump-xx" */];
  devices.forEach((d) => { scene.add(d); pickables.push(d); });

  // —— 相机控制(引擎内最高频对象)——
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.minDistance = 2;
  controls.maxDistance = 30;

  // —— Raycaster 与事件发射器 ——
  const raycaster = new THREE.Raycaster();
  const ndc = new THREE.Vector2();
  let emit: (e: SceneEvents) => void = () => {};

  function pick(clientX: number, clientY: number) {
    ndc.set((clientX / canvas.clientWidth) * 2 - 1, -(clientY / canvas.clientHeight) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    const hit = raycaster.intersectObjects(pickables, true)[0];
    return hit ? (hit.object.userData.deviceId as string) : null;
  }

  // —— 交互事件:区分拖拽与点击(第三节)——
  let downX = 0, downY = 0;
  canvas.addEventListener("pointerdown", (e) => { downX = e.clientX; downY = e.clientY; });
  canvas.addEventListener("pointermove", (e) => {
    const id = pick(e.clientX, e.clientY);
    hoverHighlight(id);                 // 高亮实现见第四节
    emit({ type: "hover", deviceId: id });
  });
  canvas.addEventListener("pointerup", (e) => {
    const dx = e.clientX - downX, dy = e.clientY - downY;
    if (dx * dx + dy * dy < 25) {       // 位移小 → 视为点击
      const id = pick(e.clientX, e.clientY);
      if (id) emit({ type: "select", deviceId: id });
    }
  });

  // —— 渲染循环(Day 100 骨架)——
  const clock = new THREE.Clock();
  let rafId = 0;
  const tick = () => {
    const delta = clock.getDelta();
    devices.forEach((d) => { d.rotation.y += 0.3 * delta; });   // 设备缓慢自转
    controls.update();                    // 相机控制每帧更新
    renderer.render(scene, camera);
    rafId = requestAnimationFrame(tick);
  };

  return {
    events: { on(fn: (e: SceneEvents) => void) { emit = fn; } },
    start() { rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); },
    dispose() { renderer.dispose(); controls.dispose(); },
  };
}

注意边界细节:dispose 里除了 renderer.dispose() 还加了 controls.dispose()(OrbitControls 内部有事件监听,也要释放)——Day 69 成对纪律的 3D 完整版。


七、常见坑点

坑 1:拾取不到物体(永远 null)

排查顺序:① NDC 的 y 是否取反(最易错)② canvas 尺寸是否为 0 ③ pickables 里有没有包含目标(Group 要 true 递归)④ 相机 near/far 是否覆盖物体。

坑 2:拖拽时误触发点击

症状:转动视角时弹出详情面板。原因:拖拽结束的 pointerup 被当成点击。修法:位移阈值判断(第三节)。

坑 3:OrbitControls 没 update()

症状:开了 damping 但拖不动/卡顿。原因:damping 需要每帧 controls.update()修法:循环更新段加上

坑 4:高亮后还原不了颜色

症状:hover 变亮后恢复成"全体亮"。原因:只记了高亮色没记原色。修法:用 Map 缓存 deviceId → 原材质/颜色,或直接 clone 材质换。

坑 5:把设备对象传给了 Vue

症状:Vue 侧出现 Mesh 引用,边界破裂。原因:事件桥传了对象而非 id。修法:只传 userData.deviceId(第五节纪律)。


八、自测挑战

T1 · 坐标换算实验(30 分钟)

在 canvas 四角与中心各放一个点,用 toNdc 换算并打印,验证:左下→(-1,-1)、右上→(1,1)、中心→(0,0)。亲手踩一遍 y 取反的坑

T2 · 可点击车间(70 分钟)

完成第六节:多台设备 + OrbitControls + hover 高亮 + 点击 select。验证:拖拽视角不误触发、hover 有反馈、点击弹面板(面板显示 deviceId)。

T3 · 高亮还原升级(40 分钟)

Map<deviceId, 原色> 实现多设备各自还原(坑 4 的正式修法)。再给选中态加一种颜色(如橙),实现"hover 蓝 + 选中橙"两态区分。

T4 · 边界自检(20 分钟)

回顾整个交互流程,检查是否有任何 Mesh/Group 引用泄漏到 Vue 组件——用 Day 103 的边界文档逐条对照


九、总结

环节

要点

原理

射线 = 视线:从相机穿过像素,与物体求交取最近

换算

屏幕像素 → NDC(y 取反),setFromCamera 发射

拾取

intersectObjects(pickables, true)userData.deviceId 拿 id

相机控制

OrbitControls:旋转/平移/缩放,damping 每帧 update

事件桥

引擎报 id → Vue 存 id → Vue 渲染(传数据不传对象)

成对纪律

dispose 里 renderer + controls 都要释放

3D 世界能点、能转、能反馈了。明天 BOSS 战:把所有零件拧成第一个 3D 车间,验收这一周的全部学习成果。


明日预告:Day 105 BOSS 战——第一个 3D 车间组装:多设备布局、Ground 网格地面、边界文档定稿、性能验收(FPS/显存),以及"第 15 周毕业总结"。

评论