【Three.js】day125-interaction-drilldown

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

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: falseraycast 关闭。修法:确认 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"。


九、总结

环节

要点

拾取本质

屏幕点 → 射线 → 与对象求交

Raycaster

setFromCamera + intersectObjects

InstancedMesh

关键在 instanceId → 反查数据

选中态

颜色替换(海量)/ 描边(少量)

联动

引擎上报 index → 桥 → Vue → 2D

进阶

曲面用"命中点→最近顶点";粒子用"拾取辅助体"

看板从"能看"变成"能查":点柱子看详情、点曲面读数值、选中联动 2D——这是工业大屏最标准的交互范式。明天把本周全部能力(柱/面/点 + 数据管线 + 交互)组装成 3D 数据看板 并做最终验收。


明日预告:Day 126 BOSS 战——3D 数据看板交付日:柱 + 热力 + 流场 + 交互四线合流,录演示视频、写 README、出简历条目,阶段 3"建模-氛围-数据"三段能力全部收官。

评论