做过游戏 UI 的人大概都碰过这种局面:美术给了一张不规则的贴图——一块岩石底座、一片树叶、一张撕裂的纸片、一块地图上的岛屿——你把它丢进 Button 里,视觉上挺漂亮,可鼠标只要挨到那圈透明的矩形边框,按钮就亮了。玩家点在一片空白上,游戏却跳进了下一关。这类问题在 Godot 里尤其常见,因为它的 Control 体系从骨子里就是矩形的,Button、TextureButton、CheckBox全部继承自Control,而Control的命中测试默认就是Rect2(Vector2.ZERO, size).has_point(point)——一个规规矩矩的矩形。
多边形按钮、任意形状按钮这个需求,说穿了就两件事:点击判定要跟着形状走,视觉绘制也要跟着形状走。前者是逻辑,后者是表现,两者脱节就会出现"点得到但看不见"或者"看得见却点不到"的诡异体验。我下面要聊的,就是怎么在 Godot 4 里用最少的代码把这两件事对齐,包括三种技术路线的取舍、射线法自己的坑、顶点数据怎么攒、凹多边形挖洞踩过的雷,以及在物理节点和 UI 节点之间怎么选。
适合的读者:用过 Godot 基本控件、知道signal和_ready怎么回事、想做一个不规则热区交互的人。不需要数学基础,点内判定我会把原理拆开讲。
1. Godot 的按钮为什么天生是矩形
1.1 Control 的命中测试到底发生在哪一步
想改形状,先得知道 Godot 是怎么判断"鼠标落在哪个控件上"的。整个流程的核心在Viewport的 GUI 拾取逻辑里:鼠标移动或按下时,Viewport 会从场景树里可见的 Control 中,按绘制顺序从后往前遍历(后绘制的认为在上层),对每个控件依次调用has_point()做判定,第一个返回true且mouse_filter为STOP的控件拿走这次事件。
而Control::has_point()的实现非常朴素,它先尝试调用脚本重写的虚函数_has_point(),如果脚本没重写,就退回默认实现——拿控件的局部矩形去测。这就是"按钮天生是方的"的根源。它不是什么硬编码限制,只是一个默认值,而 Godot 恰好把这个默认值开放成了一个可以重写的虚函数。
还有一个细节容易被忽略:_has_point()接收的point参数是控件局部坐标系下的点,原点是控件左上角,不考虑父节点的位置。也就是说,如果你把一个 200x100 的按钮放在 (300, 400),鼠标在屏幕 (350, 450) 处,传进_has_point的是(50, 50),不是(350, 450)。这个语义决定了我们的多边形顶点数据也应该用局部坐标存储,省掉一次坐标转换。
另外,mouse_entered和mouse_exited这两个信号同样依赖has_point。你重写了_has_point,悬停范围会自动跟着变形,不用额外处理。这是个便宜占,很值得。
1.2 三条路线摆在一起看,代价差得很远
在 Godot 里实现任意形状热区,能走的路其实就三条,我按"改动量 / 精确度 / 适用场景"列一下:
| 路线 | 核心做法 | 精确度 | 改动量 | 适合谁 |
|---|---|---|---|---|
重写_has_point | 脚本里判断点是否在多边形内 | 顶点级,可做到亚像素 | 极小,一个脚本 | 绝大多数 UI 按钮场景 |
TextureButton的texture_click_mask | 从贴图 alpha 生成BitMap当遮罩 | 像素级,但受贴图分辨率限制 | 很小 | 按钮本来就是一张带透明通道的图 |
Area2D+CollisionPolygon2D | 物理拾取,靠碰撞形状判定 | 顶点级 | 中等,要处理图层冲突 | 游戏世界里可点击的场景物件 |
texture_click_mask看着最省事,但它有硬伤:遮罩是按贴图原始像素格判定的,控件缩放、拉伸之后命中范围和视觉会错位。你如果用了stretch_mode = KEEP_ASPECT_CENTERED之类的设置,形状和热区之间就会漂移,越放大越明显。做原型够用,做正式 UI 我不推荐。
Area2D那条路适合"游戏世界里的可点击物体"——地图上的一座城、场景里的一只箱子。但它跟 Control 体系是两套事件流,混用会打架,后面第 6 节单独讲。
绝大多数"多边形按钮"需求,答案就是第一条:继承 Button,重写_has_point。它保留了 Button 的所有现成能力——pressed、button_down、button_up、toggled、disabled、toggle_mode、快捷键、主题系统、无障碍名称——你只需要把命中判定这一个函数换掉,其余全都白捡。
2. 用_has_point换掉命中判定
2.1 点内判定为什么选射线法而不是凸包拆分
要在多边形内判断一个点,常见算法有两种:凸包拆分和射线法。
凸包拆分的思路是:把任意多边形拆成若干个三角形或凸多边形,然后逐个测试点是否落在其中任何一个里。它的好处是单个测试极快(凸多边形用叉积符号判断,几次乘加就完事),坏处是拆分本身有代价,而且形状一变就得重新拆。物理引擎里的CollisionPolygon2D走的就是这条路——Godot 内部会把凹多边形分解成若干凸块来建碰撞形状,这也是它能高效处理凹形的原因。
射线法的思路更像小孩子数数:从待测点向任意方向(习惯上取 +x 方向)发一条射线,数它穿过多边形边界的次数。奇数次说明点在内部,偶数次说明在外部。这个规则叫奇偶规则(even-odd rule),它的美妙之处在于不需要任何预处理,顶点数组拿来就能用,而且天然支持凹多边形和自相交形状。
代价是每次判定都要遍历所有边。但这里有个量级概念:一个按钮撑死几十个顶点,一次判定几百次浮点运算,现代 CPU 上一个循环几十纳秒。鼠标移动时一帧最多调用几百到一两千次,总开销不到 0.1 毫秒。在 UI 场景里这个开销完全可以忽略,追求凸包拆分那种微优化是自找麻烦。
提示:Godot 其实内置了
Geometry2D.is_point_in_polygon(point, polygon),一行就能用,语义同样是 even-odd。原型阶段直接用没问题。但它在边界附近的判定行为不完全可控,如果你需要挖洞、需要自定义容差,自己写一份 float 版本的射线法会更好掌握。
2.2 一个能直接抄进项目的 PolygonButton
下面这份脚本我在实际项目里用了不止一次,去掉注释大概四十行。核心结构是:@export暴露顶点数组,_has_point里先做 AABB 快速剔除再跑射线法,_draw负责把形状画出来。
@tool class_name PolygonButton extends Button # 顶点用局部坐标,原点在控件左上角 @export var polygon: PackedVector2Array = PackedVector2Array([ Vector2(64, 0), Vector2(128, 46), Vector2(102, 118), Vector2(26, 118), Vector2(0, 46) ]): set(value): polygon = value _aabb_dirty = true queue_redraw() # 内环,用来在按钮上挖洞(比如环形按钮) @export var holes: Array[PackedVector2Array] = []: set(value): holes = value _aabb_dirty = true queue_redraw() var _aabb := Rect2() var _aabb_dirty := true func _ready() -> void: # 把默认的矩形背景全部清掉,否则会在多边形外面露出一圈方框 for state in ["normal", "hover", "pressed", "focus", "disabled"]: add_theme_stylebox_override(state, StyleBoxEmpty.new()) func _has_point(point: Vector2) -> bool: # 顶点不够,退回默认矩形行为,避免整个控件点不到 if polygon.size() < 3: return Rect2(Vector2.ZERO, size).has_point(point) # AABB 先剔除,绝大多数鼠标位置在这里就被挡掉了 if not _get_aabb().has_point(point): return false if not point_in_polygon(point, polygon): return false # 命中外环后再检查内环,落在洞里算没点中 for hole in holes: if hole.size() >= 3 and point_in_polygon(point, hole): return false return true func _get_aabb() -> Rect2: if _aabb_dirty: _aabb = _build_aabb() _aabb_dirty = false return _aabb func _build_aabb() -> Rect2: if polygon.is_empty(): return Rect2(Vector2.ZERO, size) var mn := polygon[0] var mx := polygon[0] for p in polygon: mn.x = minf(mn.x, p.x) mn.y = minf(mn.y, p.y) mx.x = maxf(mx.x, p.x) mx.y = maxf(mx.y, p.y) return Rect2(mn, mx - mn) # 奇偶规则射线法,静态方法方便别处复用 static func point_in_polygon(p: Vector2, poly: PackedVector2Array) -> bool: var inside := false var n := poly.size() var j := n - 1 for i in n: var a := poly[i] var b := poly[j] # 判断这条边的两端是否横跨待测点的 y if (a.y > p.y) != (b.y > p.y): var t := (p.y - a.y) / (b.y - a.y) var x_at_y := a.x + (b.x - a.x) * t if p.x < x_at_y: inside = not inside j = i return inside func _draw() -> void: if polygon.size() < 3: return var fill := _state_color() draw_colored_polygon(polygon, fill) # 编辑器里把顶点和边画出来,方便对着调 if Engine.is_editor_hint(): var outline := polygon.duplicate() outline.append(polygon[0]) draw_polyline(outline, Color(1.0, 0.55, 0.1), 2.0) for p in polygon: draw_circle(p, 3.0, Color.ORANGE) func _state_color() -> Color: if disabled: return Color(0.35, 0.35, 0.35, 0.6) if button_pressed and toggle_mode: return Color(0.2, 0.5, 0.9) if is_hovered(): return Color(0.28, 0.6, 0.95) return Color(0.2, 0.45, 0.75) func _notification(what: int) -> void: # 悬停、按下状态变化时重绘,把颜色反馈跟上 if what in [NOTIFICATION_MOUSE_ENTER, NOTIFICATION_MOUSE_EXIT, NOTIFICATION_FOCUS_ENTER, NOTIFICATION_FOCUS_EXIT]: queue_redraw()有几个地方我想重点说一下,因为它们都是踩过的:
第一,for state in [...]那段覆盖 StyleBox 是必须的。很多人只重写_has_point就以为完事了,结果按钮外面那圈矩形背景还在——因为 Button 自己会画normal/hover/pressed三个样式的背景框,那是个实打实的矩形。视觉和热区一脱节,玩家就会觉得"这按钮很怪"。
第二,polygon.size() < 3时退回矩形不是偷懒。编辑器里刚创建节点、顶点还没配好的时候,如果_has_point直接返回false,你连选中它、拖它都做不到,会以为节点坏了。这个兜底能省掉不少困惑。
第三,_state_color()里我用了is_hovered(),这是 Godot 4 里 Control 自带的,比自己去连mouse_entered信号更省事,悬停状态会自动跟着重写后的_has_point走。
第四,_aabb_dirty这个标记不能省。如果每帧都遍历顶点算包围盒,那和直接跑射线法没区别,白优化了。
3. 射线法自己写起来会遇到的边界问题
3.1 顶点、水平边和浮点误差
射线法看着简单,但边界情况一堆,我第一次写的时候就在顶点上翻过车。
问题出在(a.y > p.y) != (b.y > p.y)这个判断上。它本质上是在问"这条边的两个端点是不是分居待测点两侧"。因为用的是严格大于,当端点 y 恰好等于p.y时,这条边会被算作"不跨越",于是射线刚好擦过顶点的情况会被漏掉或重复计数。
不过神奇的是,只要所有边都用同一套规则(都用>而不是>=),这个漏掉是成对发生的:射线穿过一个顶点,等于同时穿过两条边,两条边都判定为"不跨越",计数不变;或者都判为跨越,计数加二。加二不影响奇偶性,不跨越也不影响。所以标准写法反而是稳的——别自作聪明把某个比较改成>=,那样才会真的错。
真正需要处理的是点在边上的情况。射线法对"点恰好落在多边形边上"的返回值是不确定的,取决于边相对射线的方向。对按钮来说这不是大事——鼠标正好压在边缘那一个像素上,判定成内还是外,玩家感觉不出来。但如果你在做"拖拽多边形顶点"这类需要精确判断的编辑器工具,就得额外加一步:先算点到每条线段的距离,小于阈值就直接返回true。
浮点误差方面,(p.y - a.y) / (b.y - a.y)这一步在边接近水平时没问题(因为b.y != a.y由前面的判断保证了),但如果你自己改过比较逻辑,可能出现除零。这也是我坚持用标准写法的原因之一。
3.2 局部坐标和变换叠加的坑
_has_point拿到的是局部坐标,所以顶点用局部坐标存,比较的时候直接比,这是最简单的模型。
但如果你给这个 Button 加了rotation或者scale,事情会变复杂。Godot 在处理带变换的 Control 时,不同版本的坐标转换细节不完全一致。我在 Godot 4 上实测下来,rotation旋过的按钮,_has_point收到的点是被反向旋转回控件本地空间的,也就是说按你本地坐标存的多边形依然能对上。但这属于"依赖实现细节",我一般宁可绕开。
更稳的做法是不要给多边形按钮本体加缩放和旋转。需要旋转效果时,把旋转做在父节点上,或者直接在顶点数据里预先旋转好。需要缩放时,用一套固定的设计尺寸做判定,然后用Control的size与实际顶点做一个归一化映射。具体就是在_has_point开头加一步:
func _has_point(point: Vector2) -> bool: var design_size := Vector2(128, 128) var local_p := Vector2( point.x / size.x * design_size.x, point.y / size.y * design_size.y ) return point_in_polygon(local_p, polygon)这样你只需按 128x128 的设计尺寸画一次顶点,之后按钮拖多大、怎么拉伸,热区都会等比跟上,不用重算顶点。这个技巧在制作九宫格式的可变尺寸多边形按钮时特别有用。
3.3 挖洞:为什么直接拼接顶点数组是错的
做环形按钮、挖空按钮的时候,直觉上会觉得"把内环的顶点接到外环数组后面,射线法不是按奇偶算的吗,自然就挖空了"。我一开始就是这么想的,结果整个按钮的反了——环的实体部分判成了外部,中间的空洞判成了内部。
原因不难想:从环内实体部分的某点向 +x 发射线,它会先穿过内环(计数 1),再穿过外环(计数 2),总共两次,偶数,判定为"外部"。刚好反了。
正确做法是分别判定再组合:
if not point_in_polygon(point, polygon): return false for hole in holes: if point_in_polygon(point, hole): return false return true逻辑非常直白:先必须落在主体轮廓里,然后只要落在任何一个洞里,就否决。这个写法还有个额外好处——holes数组是独立的,你想加几个洞加几个,不用重新组织顶点顺序,也不会因为环与环之间的隐式连线把形状搞乱。
注意:内环的顶点绕向(顺时针还是逆时针)在这种"分别判定"的方案里完全不影响结果,因为每个环都是独立跑一遍 even-odd。这一点和某些图形库要求内外环绕向相反的规定不一样,可以省心。
4. 顶点数据从哪来
4.1@export数组加@tool预览
最省事的顶点来源就是手填。上面那段脚本里,@export var polygon: PackedVector2Array会让这个数组出现在检查器里,点开能一个一个改 x、y,也能增删元素。配合@tool让脚本在编辑器里也跑_draw,你改一个数字,视口里的形状和顶点标记点立刻跟着动,所见即所得。
这个组合对付十几个顶点的形状完全够用。但顶点一多,手填坐标的效率就崩了——你得先在纸上量,再一个个敲,改起来更痛苦。
4.2 拿 Polygon2D 当顶点编辑器
顶点多的时候我会用这个土招:在场景里临时放一个Polygon2D节点,用它的顶点编辑功能把形状摆好——这部分编辑器交互很成熟,鼠标直接拖点,加删点也顺手。摆完之后,把Polygon2D.polygon里的数据复制到按钮的polygon属性上,然后删掉那个临时节点。
在 Godot 4 的检查器里,PackedVector2Array类型的属性可以直接把Polygon2D的数据拖过去,或者右键复制粘贴。我一般更暴力一点,写个一次性脚本:
@tool extends EditorScript func _run() -> void: var src: Polygon2D = get_editor_interface().get_selection().get_selected_nodes()[0] var btn := ... # 目标节点引用 btn.polygon = src.polygon btn.queue_redraw()这个脚本丢进EditorScript里跑一次,顶点就搬过去了。比手动复制粘贴靠谱,尤其是形状复杂、需要反复调整的时候。
4.3 运行时采点这个土办法反而最好用
还有一种情况:形状不是设计好的,而是从游戏内容里长出来的。比如地图上一块不规则的可选区域,它的轮廓本来就是关卡数据里的一段多边形,这时候顶点根本不用你画,直接从数据里读就行。
但如果数据也没有,比如你就是想"照着这张不规则贴图做个按钮",那我推荐一个最土但最有效的做法:运行时采点。在按钮上挂一段临时脚本,鼠标点击时把局部坐标打印出来:
func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed \ and event.button_index == MOUSE_BUTTON_LEFT: print("Vector2(%f, %f), " % [event.position.x, event.position.y]) accept_event()对着贴图把轮廓的关键点挨个点一遍,控制台里吐出的就是一串可以直接粘进数组的Vector2。注意这里用的是event.position,_gui_input里的事件坐标已经是局部坐标了,不用再转换。我做过一个 30 多个顶点的形状,几分钟就采完了,比用外部工具量坐标快得多。
采完点记得把这段临时脚本删掉,否则正式版本里会一直吞鼠标事件。
5. 让手感不塌的几件事
5.1 边界抖动和悬停闪烁
形状越不规则,边缘越容易出现一种讨厌的现象:鼠标停在凹口的尖端附近,悬停状态噼里啪啦地闪。
根源是_has_point在相邻两帧给出了不同结果,mouse_entered和mouse_exited交替触发,视觉上就是背景色在抖。因为两个信号之间没