做地图交互和图形编辑的时候,我几乎每周都要处理“判断点在多边形内”这类需求。用户随便点一下屏幕,你就得知道他落在哪个地块、哪个选区、哪个障碍物里,多边形可能是几十个顶点的不规则地块,也可能是随手画出来的凹多边形区域。射线法是这类问题里最经典也最稳的思路,网上资料不少,但绝大多数只给你核心那几行循环,顶点穿越、边界归属、浮点误差这些真正要命的地方全靠自己踩。这篇我把自己从原理到生产级实现完整过一遍,特别是边界规则为什么必须钉死,以及大数据量下怎么提速,看完你基本可以拿着代码直接干活。
1. 为什么“画一条水平线数交点”能判断内外:原理和数学化过程
1.1 奇偶性是“穿墙次数”的累计
射线法的几何直觉来自拓扑学里的一个朴素结论:从目标点向无穷远处发出一条射线,这条射线与多边形边界的交点数量如果是奇数,点就在多边形内部;如果是偶数,点就在外部。
为什么成立?想象你站在多边形内部往外走,走出边界的那一瞬间会穿过一次边界;如果你站在外部,想要进入内部必然也要穿过一次边界。把整个路径看作从目标点出发到无穷远的旅行,进入和离开是一个成对事件,只有起点在内部时,旅行才会以“多出来的一次穿越”结束。对应到代码里,就是用一个布尔值inside在每次遇到边界交点时取反,最后看它是 true 还是 false。
这个直觉简单,但有个关键问题:怎么把“引一条射线”落到代码里?如果真去构造一条射线,再和每条边求真正交点,计算量会大不少,而且射线方向选不好还会跟顶点纠缠不清。工程上普遍采用的方案是固定一条水平射线,也就是从目标点向右水平延伸,然后逐边判断。
1.2 为什么实现里都选水平射线
选择水平射线不是为了数学上更漂亮,而是为了让“判断边与射线是否相交”这件事变得极其廉价。
对每条边 (xi, yi) 到 (xj, yj),水平射线 y = py 会穿过这条边,当且仅当边线的两个端点分别位于水平线的上下两侧。也就是说,yi和yj中一个大于py,另一个小于py。这个条件用代码写出来就是(yi > py) !== (yj > py),一次比较搞定,根本不需要真的把射线构造出来。
真正确定交点横坐标时,再用相似三角形做线性插值。从 i 到 j 的这段线段上,当 y 从 yi 变化到 yj 时,x 也线性变化。已知目标点的 py,那么交点横坐标就是:
x_cross = (xj - xi) * (py - yi) / (yj - yi) + xi注意分母(yj - yi)在上面的端点两侧条件保证下不可能为 0,所以不会出现除零错误。算出来之后只需要判断px < x_cross即可。这里固定向右射线,是因为我们把比较方向统一成“交点在目标点右侧”,翻转标记的逻辑就非常简单直接。
整个过程完全不用动态规划、不用向量场、不用包围体积,就是逐条边扫一遍,每次只做常数次浮点运算,时间复杂度稳定在 O(n)。这个特性在数据量一般的交互场景里非常友好,甚至在几千个顶点的多边形上也能保持实时性能。
2. 射线穿过顶点时最容易翻车:半开半闭规则怎么钉死边界
2.1 顶点被射线精准命中的尴尬
射线法实现里最容易出问题的不是凹多边形,也不是孔洞,而是“射线恰好经过顶点”。
假设目标点发出的一条水平射线正好穿过某个顶点 V,那么从数学上看,这条射线在 V 点同时接触了相邻的两条边。如果代码里两条边都被统计进交点数量,这个顶点就被算了两次;如果相邻两条边因为浮点误差出现一侧漏判、另一侧补判,那结果完全取决于比较时的偶然偏向。
我最早写第一版实现时就吃过这个亏:同样的多边形、同样的点,在 A 坐标精度下返回 true,换到 B 坐标精度下返回 false,查了两天才定位到是顶点穿越时统计规则不统一。
这个问题的根源在于:交点数量的奇偶性判断,依赖的是“射线对边界是否发生了穿越”,而不是“射线与边界是否接触”。而顶点接触属于边界情形,必须人为约定一种统一的计数规则,才能让结果稳定下来。
2.2 半开半闭规则的准确表述与验证
主流做法是定一条“半开半闭”规则,直白讲就是:统计交点时,边的一个端点严格大于 py,另一个端点小于等于 py,两者不同时,才认为这条边与射线相交。
对应代码是:
(yi > py) !== (yj > py)注意我这里特意写了“一个严格大于,另一个小于等于”,而不是两边都用大于等于。这样做的效果是:如果 py 恰好等于某个顶点的 y 坐标,那么共享这个顶点的两条边中,只有那条以该顶点为“上端点”的边会被统计,另一条不会被统计。顶点无论落在哪条相邻边上,最终都只会被计数器看到一次。
我用一个简单的矩形验证给你看。矩形顶点按序为 (0,0)、(4,0)、(4,4)、(0,4),目标点取 (2,0),这个点落在底边上。如果不做任何边界特殊处理,按半开半闭规则扫描:
- 边 (0,0)->(4,0):两个端点都不大于 py=0,不统计;
- 边 (4,0)->(4,4):一端等于 0,另一端为 4 大于 0,统计,交点在 x=4,px=2 < 4,翻转;
- 边 (4,4)->(0,4):两端都大于 0,不统计;
- 边 (0,4)->(0,0):一端大于 0,另一端等于 0,统计,交点在 x=0,px=2 < 0 不成立,不翻转。
最终内部标记为 true,符合“边界点位应视为内部”的直觉。更关键的是,如果目标点移到 (5,0) 这种真正的外部点,数值上只会经过 x=4 那一次统计,翻转一次后变成 outside,也正确。
2.3 点在边上必须单独判断
射线法统计本身解决的只是“点在多边形内部还是外部”的二值问题,它不负责“点在边界上”的判断。但实际业务里,用户点击恰好落在描边上实在太常见了,这时候你得有一个独立检测。
判断点在边上的标准做法有两个条件同时成立:
- 点到线段所在直线的距离为 0,也就是叉积为 0;
- 点在线段的包围盒内,即坐标落在两端点之间。
cross = (px - xi) * (yj - yi) - (py - yi) * (xj - xi)当cross的绝对值小于某个 epsilon,且px、py都落在线段的范围里,就可以判定点在边界上。因为射线统计对边界点的结果不稳定,所以这个检测一定要放在射线统计之前,命中就直接返回“边界”或“内部”,不往下走了。
3. 可直接抄走的实现:JavaScript 与 C++ 双版本走读
3.1 JavaScript 实现与逐行解释
下面这个版本我一直在用,前端地图、可视化项目里都跑过,包含边界检测、半开半闭射线统计、epsilon 容差,可以直接复制使用:
function pointInPolygon(px, py, polygon, eps = 1e-9) { let inside = false; const n = polygon.length; let j = n - 1; for (let i = 0; i < n; i++) { const xi = polygon[i][0]; const yi = polygon[i][1]; const xj = polygon[j][0]; const yj = polygon[j][1]; // 边界检测:叉积 + 包围盒 const cross = (px - xi) * (yj - yi) - (py - yi) * (xj - xi); if ( Math.abs(cross) < eps && px >= Math.min(xi, xj) - eps && px <= Math.max(xi, xj) + eps && py >= Math.min(yi, yj) - eps && py <= Math.max(yi, yj) + eps ) { return true; } // 半开半闭规则:一个端点严格大于 py,另一个小于等于 py if ((yi > py) !== (yj > py)) { const xCross = ((xj - xi) * (py - yi)) / (yj - yi) + xi; if (px < xCross) { inside = !inside; } } j = i; } return inside; }几个值得说明的细节:
let j = n - 1配合循环末尾的j = i,让每轮处理的边都是“从 j 到 i”,也就是上一条边到当前顶点,形成闭合环;- 边界检测放在循环内部最前面,而不是单独对每条边预检完再进入主循环,是因为这样写最自然,一条边兼容了“边界返回”和“射线统计”两个职责;
Math.abs(cross) < eps里的 eps 是容差,后续会专门讲怎么选;- 返回
true时包含了“点在边上”这一情况,如果你希望边界点返回 false,把return true改成一个return false或者加一个模式参数即可。
3.2 C++ 实现与循环衔接细节
C++ 版本逻辑完全一致,只是把数组换成std::vector,更适合偏底层一点的项目:
#include <vector> #include <cmath> #include <algorithm> struct Point { double x; double y; }; bool pointInPolygonRayCasting(const Point& p, const std::vector<Point>& poly, double eps = 1e-9) { bool inside = false; int n = static_cast<int>(poly.size()); for (int i = 0, j = n - 1; i < n; j = i++) { const Point& a = poly[i]; const Point& b = poly[j]; double cross = (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x); if (std::abs(cross) < eps && p.x >= std::min(a.x, b.x) - eps && p.x <= std::max(a.x, b.x) + eps && p.y >= std::min(a.y, b.y) - eps && p.y <= std::max(a.y, b.y) + eps) { return true; } if ((a.y > p.y) != (b.y > p.y)) { double xCross = (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x; if (p.x < xCross) { inside = !inside; } } } return inside; }这里for (int i = 0, j = n - 1; i < n; j = i++)是 C++ 里常见的闭合环遍历写法。每一轮,i是当前点,j是上一个点,等循环结束执行j = i++,j就自然变成下一轮的前驱点,不需要额外维护状态。
3.3 关于 epsilon 和浮点误差的实测经验
epsilon 的选取是整个实现里最需要根据场景调的地方,我给不出一个万能值,但可以给你一个参考范围。
如果坐标来自整数像素点或者整数网格,epsilon 设成1e-9甚至直接0都没问题,因为整数运算在双精度表示下是精确的。如果坐标来自经纬度、测量数据或连续动画坐标,浮点误差就会累积,我自己习惯在1e-9到1e-6之间取一个值。太小则边界检测形同虚设,太大则会把真正在边界附近的点错误吸入。
有个很实用的技巧:把 epsilon 和坐标量级绑在一起。比如坐标是十万级别,就可以用1e-9 * 100000这样一个相对容差。不要直接对经纬度数据用绝对容差,因为纬度一度对应的实际距离在不同地区差异非常大。
4. 凹多边形、带孔多边形和脏数据:真实项目里的X因素
4.1 凹多边形完全不需要额外处理
射线法最让我省心的地方,就是它完全不在意多边形是凸是凹。
凸多边形的点在内部判断可以用叉积法,沿着某个方向把所有边的叉积检查一遍,效率很高,但前提是顶点顺序严格顺时针或逆时针。凹多边形里叉积法就废了,因为凹进去的区域会让叉积符号出现翻转。射线法没有这个问题,它统计的是边界穿越次数,凹形区域无非是让射线多穿越几个边界段,奇偶性照样成立。
我另一个项目里处理过一块非常刁钻的“回”字形选区,中间凹进去很深,射线一次穿过就会跨越三个边界段。用射线法跑出来的结果和用户视觉判断完全吻合,不需要额外写任何分支。
4.2 带孔多边形:分而治之优于拼接
带孔多边形在 GIS 数据里很常见,比如一个地块中间有块水域,水域就是多边形里的孔。处理方式有两种,我推荐第一种。
第一种思路最简单:判断点在“外环多边形”内,并且不在任何一个“孔多边形”内,两者同时成立才返回 true。实现上就是调用两次函数:
function pointInPolygonWithHoles(px, py, outerRing, holes) { if (!pointInPolygon(px, py, outerRing)) return false; for (const hole of holes) { if (pointInPolygon(px, py, hole)) return false; } return true; }第二种思路是把孔反向绕行后拼进外环的顶点数组里,再用一次射线法判断。这种写法可以让调用方只看到一个多边形对象,但有一个非常大的前提:外环和孔洞数据必须严格有序且方向正确,任何一条边的方向反转都会让结果完全错乱。在真实工程数据里,这种前提往往不成立,所以我几乎只推荐第一种分而治之的方式。
4.3 自交多边形和重复顶点
自交多边形,也就是边与边自己交叉的图形,射线法仍然会给出一个确定答案,但那个答案不一定符合你的业务直觉。
射线法对自交多边形统计的是“射线穿过的边界段数”,因此它给出的内部区域是交替出现的多块地方。如果你希望自交部分所在的整个区域都算内部,需要改用环绕数法。环绕数法的计算量更大,但能区分绕行方向不同的区域。工程里真正遇到自交多边形的时候,我通常先和业务方确认一次“内部到底是什么定义”,不要在算法层面替业务做决定。
重复顶点是另一个容易忽视的坑。数据里偶尔会出现连续两个相同的坐标点,比如(2,3)后面又跟了一个(2,3)。这种点会让叉积检测得到零向量,虽然绝大多数实现不会因此崩溃,但可能让你在调试时懵很久。我习惯在预处理阶段直接把相邻重复点过滤掉。
5. 点很多多边形很大的时候:预筛与索引的提速组合
5.1 包围盒预筛是第一道闸
射线法的 O(n) 在单次调用时很香,但在“一次对几万个点做空间查询”的场景里,比如批量校验多个设备的位置,每个点都扫一遍几千条边就太浪费了。
最实用的第一道预筛是包围盒:先算一次多边形的最小外接矩形,如果一个点在矩形之外,那必然在多边形之外,直接返回 false,根本不需要走进主循环。判断一次矩形包含只需要四次比较,成本极低,跑在射线主循环之前可以过滤掉大量明显不相关的点。
function pointInPolygonFast(px, py, polygon) { // bbox 在首次调用时算好并缓存 if (px < bbox.minX || px > bbox.maxX || py < bbox.minY || py > bbox.maxY) { return false; } return pointInPolygon(px, py, polygon); }5.2 网格索引适合高频查询
如果多边形顶点特别多,同时查询次数也特别多,包围盒就不够用了。可以建立一个网格索引:把多边形的每条边根据其包围盒插入到覆盖它的网格格子里,查询时先根据目标点定位到所在格子,再只对格子里的边做射线统计。
这个方案的本质是空间换时间。格子开得越细,每个格子覆盖的边越少,但索引构建和维护的成本越高。我做个简单测试:一个 5000 条边的多边形,对 10 万个点做归属查询,纯射线法是 5000 万次边循环,网格索引把每个点需要检查的边数降到几十条,整体提速非常可观。
网格索引最需要注意的点是格子边界。一条边可能跨越多个格子,如果插入时只放进起点所在的格子,查询点落在这条边位于其他格子的部分时就会漏判。所以我插入边的时候会把这条边经过的所有格子都登记上,宁可多存几条重复的边引用,也不能漏。
5.3 预处理简化边界
还有一种思路不太起眼但很有效:在精度允许的前提下,先对多边形做一次简化,把边数降下来。比如 Douglas-Peucker 算法,在保持整体形状的前提下减少顶点数量,然后对简化后的多边形执行射线法。
这个优化尤其适合那种顶点特别密、但形状其实很简单的多边形,比如一段弯弯曲曲的等高线,三千个顶点简化成一百个,判断精度损失不大,但性能提升是几十倍的。唯一需要注意的是不要简化掉小尺度的关键特征,比如需要精确判断某个点是否在某个小凹槽里时,简化容差会导致这些小特征被抹掉。
6. 实际项目里最容易出问题的三种场景和我的处理习惯
6.1 三态返回值比布尔值更省心
我最早实现射线法时只返回布尔值,后来在一个地图选区的需求里被折腾了一回:选区边界是用描边样式画的,用户点击边界时,业务方要求不仅算“选中”,还要求高亮样式跟内部选中不一样。
布尔值根本表达不了三种状态。后来我把函数改成返回 -1、0、1,分别对应外部、边界、内部。改造其实很简单:边界检测命中时返回 0,其余按照布尔统计返回 -1 或 1。这个三态版本在我后续好几个项目里都直接复用了。如果你的业务里也存在“边界单独处理”的需求,强烈建议一开始就设计成三态,不要等需求过来再改。
6.2 坐标方向不一致容易看反结果
屏幕坐标系的 y 轴是向下的,数学坐标系的 y 轴是向上的。射线法本身对这个不敏感,因为算法只关心相对大小,但如果你把结果可视化出来调试,或者把多边形数据和点击坐标混用了坐标系,很容易在看结果时产生错觉。
我的经验是:调试任何空间算法时,第一步永远是画图。把多边形、目标点、水平射线和交点位置都画出来,一眼就能看出是算法问题还是坐标系问题。这东西放在脑海里想象,很容易把自己绕进去。
6.3 一个小工具函数封装
最后给一个我认为很实用的封装思路。把射线法做成一个接受配置对象的工厂函数,这样你可以在一个文件里集中定义 epsilon、边界模式、是否启用包围盒预筛等参数:
function createPointInPolygon({ eps = 1e-9, boundary = "inside" } = {}) { return function isInside(px, py, polygon) { return pointInPolygon(px, py, polygon, eps) ? (boundary === "inside" ? true : true) : false; }; }这段代码里的 ternary 写法有点取巧,更多是示意。真正要表达的是:eps 和 boundary 这些策略不应该散落在业务代码里,集中到工厂函数里,业务方只需要关心语义化的调用。
我在实际项目中还会把射线法封装到一个独立的几何工具模块里,和向量计算、包围盒计算放一起。后续不管做前端还是后端,只要需要这类判断,直接拉这个模块就行。判断点在多边形内这个功能看着小,一旦把边界规则、容差策略、性能优化都理顺,在你后续所有空间计算需求里都能用得上。