Day 125 · 交互与钻取 — 拾取、tooltip 与联动:让数据看板"能查"
前四天让图表"能看",今天让它"能查"——点击一根柱子弹出它的详情、hover 曲面某处显示该点温度、选中某个设备联动 2D 图表。这是数据可视化从"展示"到"分析"的分水岭。核心三件事:
Raycaster拾取(重点攻克 InstancedMesh 的"点到哪根柱子")、选中态的视觉反馈、以及"选中 → 桥 → Vue 联动"的完整链路。做完,你的 3D 数据看板就有了"钻取"能力——这是面试官最想听的故事。
目录
一、拾取的本质:把"点击屏幕"翻译成"点中哪个对象"
1.1 一条光线的旅程
鼠标点击 (px, py)
→ 屏幕坐标 → 归一化设备坐标 (NDC)
→ 从相机发出"视线"(Raycaster.ray)
→ 与场景对象求交(射线 vs 包围盒/三角形)
→ 返回命中的对象 + 命中点
拾取 = 3D 版的"命中检测"。Web 2D 里是 elementFromPoint,3D 里是 raycaster.intersectObjects。
1.2 前提:一个可以取坐标的事件源
// 渲染器已经绑定过鼠标事件?—— 拾取前要有"点击屏幕位置"
renderer.domElement.addEventListener("click", (e) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; // → NDC [-1,1]
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
handlePick(x, y);
});
二、Raycaster 基础:从屏幕坐标到命中对象
2.1 最小实现
// src/core/three/interaction/picker.ts(引擎内,纯逻辑)
import * as THREE from "three";
/**
* 通用拾取器:给定 NDC 坐标,返回命中的对象列表
* 由具体场景决定"命中后干什么"(显示详情/高亮/联动)
*/
export function pickAt(
raycaster: THREE.Raycaster,
camera: THREE.Camera,
targets: THREE.Object3D[],
ndcX: number,
ndcY: number
): THREE.Intersection[] {
// 1. 从相机发射穿过该屏幕点的射线
raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera);
// 2. 与目标对象求交(recursive 只查一层,命中可交互对象)
return raycaster.intersectObjects(targets, true);
}
2.2 命中后的信息
Intersection 里关键字段:
object → 命中的对象(可能是 InstancedMesh / Mesh)
point → 命中点的世界坐标
instanceId → ★ 实例化对象专属:命中第几个实例(第 3 节重点)
uv / face → 命中点在几何上的位置(曲面拾取用,第 6 节)
三、拾取 InstancedMesh:这是"点到哪根柱子"
3.1 为什么 InstancedMesh 拾取特殊
普通 Mesh:hit.object 就是"那一个"对象
InstancedMesh:500 根柱子是 1 个 object
→ 光知道"点中了柱状图"没用,必须知道"第几根"
→ 答案:Intersection.instanceId(命中实例的索引)
3.2 从 instanceId 反查数据
/**
* 把命中实例索引映射回"它代表哪条数据"
* BarChart3D 的实例 i 对应数据数组的第 i 项(Day 121 构造约定)
*/
export function resolveBarData(instanceId: number | undefined, sourceValues: number[]): { index: number; value: number } | null {
if (instanceId === undefined || instanceId >= sourceValues.length) return null;
return { index: instanceId, value: sourceValues[instanceId] };
}
3.3 完整拾取流程(柱状图)
// 引擎内:组装"点击柱状图 → 解析到哪根 → 高亮 + 上报"
const raycaster = new THREE.Raycaster();
renderer.domElement.addEventListener("click", (e) => {
const rect = renderer.domElement.getBoundingClientRect();
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const hits = pickAt(raycaster, camera, [barChart.mesh], ndcX, ndcY);
if (hits.length === 0) { onSelect(null); return; }
const hit = hits[0];
const info = resolveBarData(hit.instanceId, currentValues);
if (info) onSelect(info.index); // → 高亮 + 桥上报(第 4/5 节)
});
对照表第 12 行:“手写 WebGL 拾取要自己做射线-三角形求交或 GPU 拾取;Three.js raycaster.intersectObjects + instanceId 一步到位”。记进笔记。
四、选中态:视觉反馈的三种手法
命中后要给用户"我选中了它"的反馈:
4.1 手法 1:整体高亮(最简单)
// 选中时给整个图表加发光/描边(Day 114 OutlinePass 回流)
outlinePass.selectedObjects = [barChart.mesh];
4.2 手法 2:实例颜色替换(推荐,局部精确)
/**
* 高亮某一根柱子:把它的实例颜色换成高亮色,其余恢复
* 利用 InstancedMesh 的 setColorAt(Day 121 能力)——只改一根,一次 draw call
*/
export function highlightBar(mesh: THREE.InstancedMesh, index: number | null, normalColors: THREE.Color[]) {
const hl = new THREE.Color(0xffffff);
for (let i = 0; i < mesh.count; i++) {
mesh.setColorAt(i, index === i ? hl : normalColors[i]);
}
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
}
4.3 手法 3:浮起/缩放(空间反馈)
// 选中柱子在 Y 上"浮起"一点(用 position 偏移,需单独处理矩阵)
// 适合少量柱子;海量实例下颜色替换更省(手法 2)
推荐组合:海量柱 → 手法 2(颜色);少量特殊对象 → 手法 1(描边)。反馈要"秒级即时",别做慢动画。
五、联动链路:选中 → 桥 → Vue → 2D
5.1 完整链路
引擎拾取 → 解析数据 → 高亮
│
▼ 桥出向(Day 124 的 SceneEvents)
onSelect(index)
│
▼ Vue 层
更新详情面板 + 联动 2D 图表(高亮对应 ECharts 柱)
5.2 桥与 Vue 侧
// 引擎侧:把选中 id 上报(不传对象,窄口原则)
sceneEvents.onSelect(index); // index 即"数据行号",Vue 自己查详情
// Vue 侧:监听并联动
bridge.on("select", (index: number | null) => {
selectedIdx.value = index;
if (index !== null) {
detail.value = devices[index]; // 详情面板
chart2d.highlight(index); // 联动 2D ECharts
} else {
detail.value = null;
chart2d.clearHighlight();
}
});
5.3 联动价值
3D 拾取 + 2D 联动 = 工业大屏的标准交互范式:3D 看"空间关系",点中后 2D 看"精确数值/趋势"。这也是 Day 126 看板最出彩的功能,面试时一定要演示。
六、曲面与粒子拾取(进阶)
6.1 曲面拾取(命中点 → 最近网格点)
// HeatSurface 命中后:用 Intersection.point 找到最近顶点,反查数值
export function sampleSurfaceAt(hit: THREE.Intersection, surface: HeatSurface): { row: number; col: number; value: number } | null {
// 把命中点投影回网格行列(利用面元 uv 或包围盒反算)
// 简化:用最近顶点索引
const geo = surface.mesh.geometry as THREE.PlaneGeometry;
const pos = geo.attributes.position as THREE.BufferAttribute;
let best = -1, bestDist = Infinity;
for (let i = 0; i < pos.count; i++) {
const d = new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i)).distanceTo(hit.point);
if (d < bestDist) { bestDist = d; best = i; }
}
// best 对应第几行几列 → 查数据
const segX = surface.segX;
const row = Math.floor(best / (segX + 1));
const col = best % (segX + 1);
return { row, col, value: surface.getData(row, col) };
}
6.2 粒子拾取(Points)
Points 拾取的代价:逐粒子做射线-点距离测试,海量时开销大
策略: ✓ 少量点(<2000)直接 raycaster 测试
✗ 海量粒子 → 换"拾取辅助体"(在对应位置放一个不可见的小 Mesh 让射线命中它)
// 策略 2:为每个"可交互数据点"放一个不可见拾取盒(只对少量重点点做)
const pickHelper = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.3, 0.3),
new THREE.MeshBasicMaterial({ visible: false })
); // 按数据点位置摆多个,射线命中 → instanceId 或对象映射回数据
七、常见坑点
坑 1:拾取不到对象(点击没反应)
原因:targets 没传对 / 射线没算准 / 对象 visible: false 或 raycast 关闭。修法:确认 targets 包含对象、NDC 换算正确、可交互对象开着 raycast。
坑 2:InstancedMesh 不知道点了哪根
原因:只看 hit.object。修法:读 hit.instanceId 反查数据(第三节)。
坑 3:NDC 的 y 方向反了
原因:屏幕 y 向下、NDC y 向上。修法:y = -((clientY-top)/height)*2 + 1(第一节)。
坑 4:高亮后颜色"恢复不了"
原因:恢复时用了旧颜色数组但被实例缓冲污染。修法:每次全量 setColorAt(高亮色或原色)(第四节)。
坑 5:曲面命中点想读数值读不到
原因:直接拿 point 当数据。修法:point → 最近顶点 → 行列 → 查数据(第六节)。
坑 6:海量粒子拾取卡顿
原因:逐粒子射线测试。修法:换"不可见拾取辅助体"策略(第六节)。
八、自测挑战
T1 · 柱状图拾取(60 分钟)
给 Day 121 的柱状图加点击拾取:点到哪根 → 该根高亮 + 控制台输出"第 i 根 = 值 v"。验证:点空白处清除高亮。
T2 · 选中态反馈(40 分钟)
实现"颜色替换高亮"(手法 2),确认高亮只改一根、一次 draw call、恢复正确。
T3 · 联动链路(60 分钟)
接入桥:引擎 onSelect(index) → Vue 详情面板 + 联动一个 2D ECharts 柱状图。录屏证明"点 3D 柱 → 2D 图同步高亮"——这是 Day 126 演示的高光。
T4 · 曲面拾取(选做,50 分钟)
给 HeatSurface 加 hover:显示"第 (row, col) 点温度 = v°C"。
九、总结
看板从"能看"变成"能查":点柱子看详情、点曲面读数值、选中联动 2D——这是工业大屏最标准的交互范式。明天把本周全部能力(柱/面/点 + 数据管线 + 交互)组装成 3D 数据看板 并做最终验收。
明日预告:Day 126 BOSS 战——3D 数据看板交付日:柱 + 热力 + 流场 + 交互四线合流,录演示视频、写 README、出简历条目,阶段 3"建模-氛围-数据"三段能力全部收官。