1. 从一次“点不准”的翻车现场说起
如果你做过三维交互项目,大概率遇到过这种尴尬:场景里摆了一堆模型,用户鼠标点上去,要么毫无反应,要么点A却选中了背后的B,要么在模型边缘反复横跳、时灵时不灵。我最早接触三维拾取的时候,就是靠“给每个模型挂一个碰撞体、再监听点击事件”这种土办法硬扛,模型少的时候还能凑合,一旦场景里塞进几十上百个物体,事件管理立刻变成一团乱麻,性能也开始肉眼可见地掉。
后来我把方案换成了光线投射器(Raycaster),整个拾取逻辑一下子清爽了。它的核心思路其实特别朴素:从摄像机(或者鼠标所在的屏幕位置)发出一条看不见的射线,射向三维空间,然后计算这条射线和场景里哪些物体相交、交点在哪、离摄像机多远。谁离得近、谁在射线上,谁就是被“点中”的对象。这套机制不依赖物理引擎,不需要给每个模型挂事件监听,一次计算就能拿到完整的命中列表,按距离排序后想取谁取谁。
这篇文章我想聊的就是如何用光线投射器实现点击拾取模型这件事。它属于三维交互里最基础、也最容易被低估的一环。适合谁看?如果你正在做三维可视化、模型编辑器、三维小游戏、数字孪生场景,或者任何需要“鼠标点一下、选中某个物体”的项目,这篇内容基本可以直接抄作业。哪怕你之前完全没碰过射线拾取,我也会从坐标转换、射线构造、相交检测一路讲到性能优化和踩坑排查,保证你能落地。
需要先说明一点:不同三维库对光线投射器的封装程度不一样,有的库(比如Three.js)直接给你一个Raycaster类,几行代码就能用;有的库需要你自己组装射线和求交。但底层原理是共通的——屏幕坐标转标准化设备坐标、构造射线、遍历求交、按距离排序。我会以最常见的实现方式为主线,把每一步的“为什么”讲透,你换成任何库都能对号入座。
2. 光线投射拾取的整体设计与思路拆解
2.1 为什么不用碰撞体事件,而选射线求交
先说清楚方案选型的逻辑,这决定了你后面会不会返工。三维场景里实现“点击选中”,主流有两条路:一条是给物体挂碰撞体、靠物理引擎或事件系统派发点击;另一条就是射线求交,主动从屏幕往场景里“打一条线”。
碰撞体事件的路子,优点是直观,物体自带“可点击”属性,写起来像二维网页里的onclick。但它的代价藏在后面:每个可交互物体都要维护一个碰撞体,碰撞体要和模型同步更新(模型动了、缩放了、换骨骼动画了,碰撞体得跟着变),物体一多,物理世界的开销和事件派发的开销都会涨。更麻烦的是,碰撞体事件往往只能告诉你“点到了谁”,很难一次性拿到“这条视线穿过了哪些物体、各自距离多远”,做遮挡判断、穿透选择就很别扭。
射线求交正好补上这些短板。它是按需计算的:用户点一下,我才发一条射线算一次,不点就不算,天然省性能。它返回的是命中列表,包含每个被射线穿过的物体、交点坐标、交点到射线起点的距离,按距离排好序。这意味着遮挡关系是白送的——列表里第一个就是最前面那个,想做“点穿选中背后物体”也只是取列表第二个而已。而且它不依赖物理引擎,纯几何计算,模型怎么变都不影响,只要包围盒或几何体数据是对的就行。
所以我的结论很明确:只要你的需求是“从屏幕位置反查三维物体”,优先用光线投射器。碰撞体事件更适合“物体之间互相碰撞、需要持续物理模拟”的场景,两者定位不同,别混用。
2.2 一条射线是怎么从鼠标位置“长”出来的
很多人卡在第一步:鼠标明明在屏幕左上角,射线怎么就能精准打到场景里的模型?这里的关键是坐标系的层层转换,我把它拆成三步,理解了这三步,后面写代码就是照抄。
第一步,拿到鼠标在画布上的像素坐标。比如画布宽800、高600,鼠标点在(400, 300),这是相对画布左上角的坐标,原点在左上角,y轴向下。
第二步,把像素坐标转成标准化设备坐标(NDC)。NDC是一个范围在[-1, 1]的坐标系,原点在画布正中心,x向右为正,y向上为正。转换公式是:ndcX = (像素X / 画布宽) * 2 - 1,ndcY = -(像素Y / 画布高) * 2 + 1。注意y轴那个负号,因为屏幕y向下、NDC的y向上,方向反了。这一步是整个拾取最容易出错的地方,符号搞反,射线就会上下颠倒,点上面选中下面。
第三步,用NDC坐标和摄像机,构造出射线的起点和方向。常见做法是:把NDC坐标配合一个深度值(近裁剪面用-1,远裁剪面用1),通过摄像机的投影逆矩阵和视图逆矩阵,反算出近裁剪面和远裁剪面上的两个三维点,射线起点就是近裁剪面那个点,方向就是“远点减近点”再归一化。也有库直接提供setFromCamera这类方法,把NDC坐标和摄像机丢进去,它帮你算好射线,省去手动矩阵运算。
提示:如果你用的是正交相机(OrthographicCamera),射线方向是平行的,起点计算方式和透视相机不同,但库一般都会帮你处理,你只需要保证传进去的是正确的相机对象。
2.3 求交阶段:射线到底和什么在算
射线构造好之后,就要和场景里的物体做相交检测。这里有个性能上的关键决策:射线和谁算。最朴素的做法是遍历场景里所有物体,逐个求交。物体少无所谓,物体一多,每点一次遍历几百上千个网格,卡顿就来了。
更聪明的做法是只和候选集求交。比如你只关心某几个可交互的模型,那就维护一个数组,射线只和这个数组里的物体算,其他装饰性物体(地面、天空盒、背景)直接排除。再进一步,可以用包围盒(BoundingBox)或包围球(BoundingSphere)做粗筛:先判断射线有没有碰到物体的大包围盒,没碰到直接跳过,碰到了再做精确的三角形级求交。这一层粗筛能把大量明显不相交的物体快速排除,性能提升非常明显。
求交的精度也分档。有的库默认做包围盒级别的相交,快但不够准,模型形状不规则时边缘会误判;有的支持三角形级别的精确求交,准但慢。我的经验是:交互拾取用包围盒级别通常够用,除非你的模型特别细长或者有大量镂空,才需要上精确求交。如果既要准又要快,可以先用包围盒粗筛,再对粗筛通过的少数物体做精确求交,兼顾两者。
3. 核心细节解析与实操要点
3.1 坐标转换里的三个致命细节
坐标转换看着简单,实际项目里翻车最多的就是这里。我总结了三个必须盯死的细节。
第一个是画布尺寸的取值。转换NDC时用的宽高,必须是渲染画布的实际像素尺寸,不是CSS样式尺寸,也不是窗口尺寸。如果你的画布用了响应式布局,CSS宽高和实际渲染分辨率不一致,用错了就会导致点击位置整体偏移。正确做法是从渲染器或画布对象上取clientWidth/clientHeight或者渲染器记录的尺寸,别自己拍脑袋写死。
第二个是鼠标坐标的参照系。event.clientX/clientY是相对浏览器视口的,如果你的画布不在页面左上角(比如有侧边栏、有顶部导航),直接拿clientX算就会偏。稳妥做法是用getBoundingClientRect()拿到画布相对视口的位置,再用clientX - rect.left、clientY - rect.top得到相对画布的坐标。这一步不做,画布一挪位置,拾取就全乱。
第三个是y轴翻转。前面提过,屏幕y向下、NDC的y向上,转换时那个负号不能丢。我见过太多人复制公式时把负号漏了,结果射线上下颠倒,点顶部选中底部,排查半天以为是求交出了问题,其实是坐标转换的锅。
3.2 射线起点与方向的构造方式对比
构造射线有两种常见写法,各有适用场景,我列个表对比一下。
| 构造方式 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 库封装方法 | 调用setFromCamera(ndc, camera) | 一行搞定,自动处理透视/正交 | 依赖库实现,黑盒 | 绝大多数常规拾取 |
| 手动矩阵反算 | 用投影逆矩阵和视图逆矩阵算近远点 | 完全可控,便于调试 | 矩阵运算易错 | 需要自定义射线逻辑时 |
| 从摄像机位置出发 | 起点取摄像机位置,方向指向目标点 | 直观,适合“从眼睛看出去” | 正交相机不适用 | 第一人称视角拾取 |
我个人的习惯是:能用库封装就用库封装,省事且不容易错;只有在需要做特殊处理(比如射线不从摄像机出发,而是从某个自定义点出发)时,才手动构造。手动构造时,一定要把近裁剪面和远裁剪面的点都算出来,别只算一个点就当成方向,那样射线起点会错。
3.3 命中结果的排序与筛选策略
射线求交返回的是一个数组,里面每个元素包含被命中的物体、交点、距离等信息。这个数组默认按距离从近到远排序,但不同库的排序保证不一样,有的需要你手动排一次。我建议拿到结果后自己按距离排一遍,别赌库的默认行为,尤其是跨版本升级时。
排序之后就是筛选。最常见的需求是“只选最前面那个”,那就取intersects[0]。但实际项目里还有几种变体:
- 穿透选择:想选被遮挡的物体,就取列表里第二个、第三个,或者提供一个“循环切换”的交互,每次点击往下取一个。
- 按类型筛选:列表里可能混着辅助物体(网格线、坐标轴),需要过滤掉,只保留真正的模型。
- 按层级筛选:如果模型有父子层级,命中的可能是子网格,你需要往上找到对应的逻辑对象(比如找到挂载了业务数据的父节点)。
注意:筛选逻辑最好封装成一个独立函数,别散落在点击回调里。场景一复杂,筛选规则会越来越多,集中管理才好维护。
3.4 性能优化的几个实操手段
射线拾取的性能,主要花在“遍历求交”上。物体越多、几何越复杂,单次点击的开销越大。我常用的优化手段有这么几个,按性价比排序。
第一,缩小求交范围。维护一个pickableObjects数组,只把需要交互的物体放进去,射线只和这个数组算。这一招最简单,效果也最直接,往往能砍掉一大半无用计算。
第二,用包围盒粗筛。在精确求交前,先判断射线和物体的包围盒是否相交。包围盒求交是纯数学计算,比三角形求交快得多。粗筛能过滤掉绝大多数明显不相交的物体,剩下的才做精确计算。
第三,降低求交精度。如果包围盒级别够用,就别开三角形级别。很多库的Raycaster有个精度参数,调低它能显著提速,代价是边缘判断稍微糙一点,交互场景通常感知不到。
第四,节流点击事件。用户可能疯狂连点,每次点击都算一遍射线很浪费。可以加一个简单的节流,比如100毫秒内只处理一次,或者用requestAnimationFrame把拾取放到下一帧统一处理。
第五,静态物体做空间划分。如果场景里有大量静态物体,可以预先构建八叉树或BVH(层次包围盒),把求交复杂度从O(n)降到O(log n)。这个属于进阶优化,物体量级到几千以上再考虑,普通项目用前四招就够了。
4. 实操过程与核心环节实现
4.1 完整拾取流程的分步实现
下面我把整个拾取流程拆成可复现的步骤,用伪代码加说明的方式呈现,你对照自己的库替换API即可。
第一步:准备拾取所需的三个对象。你需要一个射线投射器实例、一个存放鼠标NDC坐标的二维向量、一个存放命中结果的数组。这三个对象建议在初始化时创建一次,复用即可,别每次点击都new,减少垃圾回收压力。
// 初始化阶段创建,全局复用 const raycaster = new Raycaster(); const pointer = new Vector2(); const intersects = [];第二步:监听画布上的指针事件。用pointerdown或click都行,前者响应更快。回调里先拿到相对画布的坐标,再转成NDC。
canvas.addEventListener('pointerdown', (event) => { const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; pointer.x = (x / rect.width) * 2 - 1; pointer.y = -(y / rect.height) * 2 + 1; // 后续在这里触发拾取 });第三步:用NDC和相机设置射线。调用库的封装方法,把射线起点和方向算好。
raycaster.setFromCamera(pointer, camera);第四步:执行求交并处理结果。把候选物体数组传进去,拿到命中列表,排序后取第一个。
intersects.length = 0; // 清空复用数组 raycaster.intersectObjects(pickableObjects, true, intersects); if (intersects.length > 0) { const hit = intersects[0]; // hit.object 是被点中的物体 // hit.point 是交点世界坐标 // hit.distance 是交点到射线起点的距离 handlePick(hit); }第五步:在业务层响应选中。拿到命中物体后,做高亮、弹信息面板、切换选中状态等操作。这一步和拾取本身解耦,拾取只负责“告诉你点到了谁”,怎么响应是业务的事。
4.2 参数选择与计算过程说明
有几个参数在实际项目里需要你根据情况调,我把选择依据说清楚。
候选物体数组怎么定。如果场景是静态的、可交互物体固定,初始化时收集一次就行。如果物体动态增删,需要在增删时同步维护这个数组。我一般会给可交互物体打一个标记(比如userData.pickable = true),遍历场景时按标记收集,避免手动维护数组漏掉。
包围盒粗筛的阈值。包围盒本身有个膨胀系数,稍微放大一点能避免边缘物体被误排除。膨胀多少?我通常按物体尺寸的5%到10%来放,太小容易漏,太大粗筛效果打折。这个值没有标准答案,得根据你的场景实测。
求交精度参数。如果库提供类似raycaster.params.Points.threshold或raycaster.params.Line.threshold的参数,它们控制的是点、线这类特殊物体的求交容差。模型拾取一般用不到,但如果你要拾取点云或线条,这个阈值就得调,太小点不中,太大误触。
节流时间窗口。我一般设80到120毫秒。太短起不到节流效果,太长会让快速连续点击变得迟钝。交互密集的场景可以设短一点,普通场景100毫秒足够。
4.3 一次真实的调试记录
说个我实际踩过的坑。有次做模型编辑器,拾取死活不准,点模型左边选中右边,偏移量还随着模型在屏幕上的位置变化。我一开始怀疑是求交精度问题,调了半天参数没用。后来打印NDC坐标才发现,画布外面套了一层带padding的容器,我用getBoundingClientRect()拿到的rect包含了padding,导致坐标基准错了。改成用画布自身的clientWidth/clientHeight配合offsetX/offsetY,问题立刻消失。
还有一次更隐蔽:模型用了骨骼动画,包围盒没有随动画更新,导致射线打到的位置和视觉上模型的位置对不上。解决办法是在每帧渲染前调用一次computeBoundingBox()刷新包围盒,或者在拾取前临时刷新。这个坑的教训是:只要模型会变形,包围盒就必须跟着更新,否则拾取一定偏。
5. 常见问题与排查技巧实录
5.1 拾取偏移问题速查表
拾取偏移是最常见的问题,原因五花八门,我整理成一张速查表,按可能性从高到低排查。
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 整体偏移固定量 | 画布有padding/border,rect取值错 | 打印rect和画布实际尺寸对比 | 用画布自身尺寸,别用外层容器 |
| 上下颠倒 | NDC的y轴符号写反 | 点顶部看是否选中底部 | 检查y转换公式的负号 |
| 左右颠倒 | NDC的x轴公式写反 | 点左侧看是否选中右侧 | 检查x转换公式 |
| 偏移随位置变化 | 相机宽高比和画布宽高比不一致 | 对比相机aspect和画布比例 | 同步更新相机aspect |
| 模型动了拾取没动 | 包围盒未随模型更新 | 打印包围盒和模型位置 | 变形后刷新包围盒 |
| 边缘点不中 | 求交精度太低 | 换精确求交测试 | 提高精度或膨胀包围盒 |
5.2 点不中、点穿、点重复的排查思路
点不中,先确认物体在不在候选数组里,再确认射线方向对不对(可以画一条调试线把射线可视化出来,肉眼一看就知道打哪去了)。如果射线方向对、物体也在候选里,那就是求交精度或包围盒的问题。
点穿(点前面的选中了后面的),检查命中列表的排序。如果列表没排序,取[0]可能不是最近的。手动按distance升序排一遍再取。
点重复(一次点击触发多次选中),多半是事件监听绑了多次,或者用了click又用了pointerdown导致双触发。检查监听器注册逻辑,确保只绑一次;如果用了事件委托,注意别让事件冒泡重复触发。
提示:调试拾取时,强烈建议把射线可视化出来——从起点沿方向画一条足够长的线段,渲染到场景里。射线打向哪里一目了然,比对着数字猜快十倍。
5.3 几个容易被忽略的边界情况
相机切换后拾取失效。如果你在透视相机和正交相机之间切换,射线构造方式变了,但NDC转换没变,通常没问题;但如果切换时没更新相机引用,射线就会用旧相机算,结果全错。切换相机时记得同步更新拾取逻辑里用的相机对象。
画布缩放后拾取偏移。页面缩放、设备像素比变化时,画布的渲染尺寸和CSS尺寸可能不一致。用devicePixelRatio参与计算,或者统一用渲染器记录的尺寸,别混用。
多画布场景。一个页面有多个三维画布时,每个画布的拾取要独立处理,别共用一个射线投射器实例,否则坐标会串。每个画布配一套拾取对象,互不干扰。
触摸屏上的拾取。移动端用touchstart或pointerdown,注意触摸点的坐标取值和鼠标略有不同,touches[0].clientX这种写法要适配。另外触摸目标小,包围盒可以适当膨胀,提升点中率。
6. 拾取之后的交互延展
拾取本身只是“知道点到了谁”,真正让项目好用的是拾取之后的交互设计。我分享几个实际项目里验证过的做法。
悬停高亮。不用等点击,鼠标移动时就做一次射线检测,命中物体就高亮。注意悬停检测频率高,一定要做节流,否则每移动一像素算一次射线,性能扛不住。我一般用requestAnimationFrame把悬停检测限制到每帧一次。
拖拽移动。拾取到物体后,按住拖动,需要把鼠标的屏幕位移转换成物体在世界空间的位移。常见做法是记录按下时的交点和物体位置,拖动时用新的射线和某个参考平面求交,算出位移量再应用到物体上。参考平面的选择很关键,一般选垂直于相机视线的平面,拖拽手感最自然。
框选多物体。除了单点射线,还可以用矩形框选。做法是把矩形四个角转成NDC,构造四条射线,判断物体是否落在四条射线围成的视锥内。这个比单射线复杂,但批量操作场景很实用。
拾取优先级。场景里物体重叠时,除了按距离排序,还可以给物体设优先级。比如标注点、控制手柄这类小物件,希望它们优先被选中,可以在排序时给它们加权,让它们排到前面。
这些延展交互的共同点是:拾取负责“选中”,交互负责“操作”,两者解耦。拾取逻辑保持纯粹,只回答“点到了谁”,后续怎么响应、怎么拖拽、怎么框选,都是独立模块。这样代码好维护,也方便复用。
我在实际项目里最大的体会是,光线投射拾取看着简单,但真正做稳做顺,功夫全在细节上——坐标转换的符号、画布尺寸的取值、包围盒的更新、命中列表的排序,任何一处马虎都会导致“点不准”。把这几处盯死,再配上合理的候选集和粗筛,拾取就能又准又快。至于拾取之后的交互,那是另一个值得单独展开的话题,先把拾取这步做扎实,后面的花活才有地基。