Day 104 · 交互 — Raycaster 射线拾取与事件桥接
前六天场景是"能看的";今天给它"手"——点一下设备,它高亮,旁边弹出信息面板。2D 里这叫命中检测(第 7 周拓扑图做过),3D 里叫 Raycaster(射线拾取)。今天落地三件事:屏幕坐标→世界射线的换算、Raycaster 拾取与 OrbitControls 相机控制、以及 Day 103 定下的出向桥——引擎只报"我点了哪个设备(id)",Vue 收到 id 打开面板。边界契约在昨天画好,今天让数据真的流起来。
目录
一、从 2D 命中检测到 3D 射线拾取
1.1 同一个问题的两种解法
记忆钩子: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 装与不装
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 高亮的两个实现思路
本周用换材质(避免引入后处理复杂度,红线第 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 的边界文档逐条对照。
九、总结
3D 世界能点、能转、能反馈了。明天 BOSS 战:把所有零件拧成第一个 3D 车间,验收这一周的全部学习成果。
明日预告:Day 105 BOSS 战——第一个 3D 车间组装:多设备布局、Ground 网格地面、边界文档定稿、性能验收(FPS/显存),以及"第 15 周毕业总结"。