6.3 拾取交互:射线检测与命中处理 本节摘要:Raycaster 把屏幕上的一个点击反投影成场景中的一条射线,命中列表按距离排序返回。本节讲清射线拾取的完整链路:坐标换算、命中过滤、悬停与点击两套节流,并把命中事件接到第 4 章的运镜与业务层,收束传动装置工段。 从一个像素到一条射线 用户点下的只是屏幕上的一个像素,场景里的却是三维空间——中间的翻译就是射线投射:把点击处的屏幕坐标换算成标准化设备坐标(x、y 都归一到 -1 到 1),再由相机把这个二维点"发射"成一条穿过场景的射线,射线沿途逐一询问物件"你挡我了吗"。所有命中的物件按距离从近到远排成列表,第一个就是用户点中的那件。 这条链上有三处高频翻车点。
本节摘要:Raycaster 把屏幕上的一个点击反投影成场景中的一条射线,命中列表按距离排序返回。本节讲清射线拾取的完整链路:坐标换算、命中过滤、悬停与点击两套节流,并把命中事件接到第 4 章的运镜与业务层,收束传动装置工段。
用户点下的只是屏幕上的一个像素,场景里的却是三维空间——中间的翻译就是射线投射:把点击处的屏幕坐标换算成标准化设备坐标(x、y 都归一到 -1 到 1),再由相机把这个二维点"发射"成一条穿过场景的射线,射线沿途逐一询问物件"你挡我了吗"。所有命中的物件按距离从近到远排成列表,第一个就是用户点中的那件。
这条链上有三处高频翻车点。坐标换算:标准化设备坐标的 y 轴向上,而屏幕坐标的 y 轴向下,忘了取反就永远点偏一格。命中范围:对整个场景树做射线检测,等于每帧点击都遍历所有三角面——大场景必须过滤。命中分层:射线只认网格,命中的可能是模型内部某个子件,业务上要的却是整个模型——需要沿 parent 链向上找挂了业务标记的祖先。
背景:沙盘的交互订单:鼠标悬停货箱时轮廓微亮,点击后弹出该货箱信息卡并触发机位特写(第 4 章的 focusOn)。要求:地面与辅助件不响应,悬停不闪不抖,低配机不因拾取掉帧。
操作:可交互物件统一挂 userData 标记,射线只检测这一层;悬停与点击分频处理。
const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let hovered = null; // 给每只货箱挂业务标记:射线命中的是网格,业务要的是箱子 group.children.forEach((box, i) => { box.userData.kind = 'cargo'; box.userData.cargoId = i; }); function pick(event) { const rect = stage.renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; // 归一 pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; // y 取反 raycaster.setFromCamera(pointer, stage.camera); // 只检测带标记的物件:先收集再投射,避免全场景三角面遍历 const pickables = group.children.filter(o => o.userData.kind === 'cargo'); const hits = raycaster.intersectObjects(pickables, false); return hits.length ? hits[0].object : null; } stage.renderer.domElement.addEventListener('pointermove', (e) => { const hit = pick(e); if (hit === hovered) return; // 悬停不抖:同一物件不重复处理 if (hovered) hovered.material.emissiveIntensity = 0; hovered = hit; if (hovered) hovered.material.emissive.set(0x334411); if (hovered) hovered.material.emissiveIntensity = 0.6; }); stage.renderer.domElement.addEventListener('click', (e) => { const hit = pick(e); if (!hit) { resetView(); return; } // 点空回全景 const box = hit; showCargoCard(box.userData.cargoId); // 业务层:信息卡 focusOn(box.position, 2.5); // 接第 4 章运镜:机位特写 });
结果:鼠标扫过货箱时对应箱体微微发亮,移开即灭;点击后信息卡弹出、机位滑向特写,点击空白处全景复位。拾取只扫十几只箱子,低配机上感知不到开销。
解读:四处细节各有讲究。其一,intersectObjects 的第二参传 false(不递归):货箱本身是网格,递归只会白遍历;对模型组拾取时才传 true 并配合"向上找祖先"。其二,悬停用 emissive 而不是换材质:换材质触发着色程序重建,悬停一闪一抖多半是它;发光强度只是 uniform,零成本。其三,pointermove 每秒触发上百次,"同一物件直接返回"的短路让实际射线检测只在跨物件时发生——节流不必上定时器,逻辑短路往往更干净。其四,点击空白回全景这条"负交互"是体验细节:用户点空的动机多半是"想退出当前状态",顺势复位比无响应体面。
变式:触屏设备没有悬停,但点击照常——同一套 pick 函数在 pointer 事件里天然兼容。进阶方向有两个:拖拽物件(pointerdown 记录命中、pointermove 里把射线与地面平面求交更新位置、pointerup 落箱),以及给拾取加缓冲(按空间划分网格桶,先粗筛桶再细测三角面)——后者在十万件级场景才需要,第 7 章实例化出场时会碰到它的进阶版。
传动装置工段收工:心跳、剪辑、拾取三根轴全部装好,沙盘"能看能动能点"。下一章进精装修工段——着色器、后期、粒子、物理,四样加戏的工序都费预算,怎么加得值,是那一章的主题。