技术博客

射线检测的调用路径:intersect_ray 一路到 broad phase

第 67 / 149 篇 · 2026-01-11 · godot · 物理引擎射线检测源码分析

游戏里最常被调用的物理 API 大概就是 intersect_ray:开枪判定、相机遮挡、拾取高亮全靠它。用多了难免会问一句:一条射线丢进去,引擎到底干了多少活?我在一个场景里放了两千根射线测帧时间,结果发现开销的大头不在窄相求交,而在 broad phase 之前的一个很容易忽略的动作。这篇从 GDScript 一路跟到哈希网格。

入口:命令缓冲模式下的"立即查询"

第 1 篇讲过 PhysicsServer3D 是服务器模式:普通的 body_set_state 这类命令会被写进命令缓冲,等物理步进前统一 flush,保证物理世界在一帧内看到一致的状态。但空间查询(space query)走的是另一条路——PhysicsDirectSpaceState3D 这个类名里的 Direct 就是这个意思:立即执行,不排队。

var query = PhysicsRayQueryParameters3D.create(from, to)
query.collision_mask = 1 << 2
query.exclude = [self]
var hit = get_world_3d().direct_space_state.intersect_ray(query)

get_world_3d().direct_space_state 拿到的对象在引擎里是 GodotPhysicsDirectSpaceState3D,它的 intersect_ray 直接操作 space(servers/physics_3d/godot_physics_direct_space_state_3d.cpp)。有两个前提条件源码里会替你检查:

  1. 必须在物理步进之外或同步上下文中调用。如果你从 _integrate_forces 之外的物理回调里查 space,或者查的不是当前活动 space,老版本会直接报"space is locked / flushed"错误。原理很直白:步进过程中 island、pair 缓存都在被 solver 改写,此时插入查询读到的可能是一半新一半旧的世界。
  2. 查询参数里的 collision_mask 和 exclude 会被翻译成 broad phase 的过滤条件,越早过滤越便宜。

第一层:broad phase 的线段剔除

拿到射线后第一步不是和形状求交,而是问 broad phase:"哪些物体的 AABB 可能和这条线段相交?"Godot Physics 3D 的 broad phase 是 GodotHashGrid(broadphase_godot_hash_grid.cpp):空间被切成边长 $h$ 的格子,每个物体的 AABB 以整数格坐标为键插进一张哈希表,每个格子挂一个 object id 列表。有 pair 缓存维护阶段成本,但查询侧的逻辑是:

  • 把线段 $(p_0, p_1)$ 的包围盒按格坐标展开成格子区间;
  • 线段穿过哪些格子,就取出那些格子的 object id(配合去重);
  • 返回候选集,AABB 逐个与线段包围盒再做一次粗筛。

剔除的数学只需要一个 AABB-线段相交测试:把线段参数化 $p(t) = p_0 + t(p_1 - p_0)$,对每一轴用 slab 法求 $t$ 区间,三轴区间有交即可能相交。这步是纯整数格坐标运算,没有开方,没有三角函数,一万个物体里筛出十几个候选是微秒级的事。

真正要注意的是射线长度直接影响候选数量:格哈希按线段包围盒展开格子,射线越长扫过的格子越多。长度翻十倍,候选数往往不止翻十倍(物体的空间分布近似泊松点过程时,命中的期望随长度近似线性增长,但去重开销随密度平方恶化)。所以"先算好射线终点"比任何优化都值。

第二层:逐候选的窄相求交

候选集里的每个物体被还原成 body,再对每个 shape 做真正的射线-形状求交。Godot Physics 3D 的射线实现集中在 servers/physics_3d/gjk_epa.cpp 和 servers/physics_3d/collision_solver_3d.cpp 一带:

  • 球:解析解,判别式 $b^2 - 4ac \ge 0$ 取较小根,一个分支解决;
  • 盒/凸包:通用路径是把射线在形状局部空间表示出来,用支持函数(support function)做射线凸包求交,本质是把 GJK 的思路用在射线-凸集距离上;
  • 胶囊/圆柱:解析或半解析,圆柱侧面是二次方程,端盖是平面交。

每个命中按参数 $t$ 排序,取最小 $t$ 那个,hit_from_inside、hit_back_faces 两个布尔决定了"起点在形状内部"和"背面命中"是否算数——3D 里默认从内部发射不算命中,这个默认值坑过不少人。

两层之间的开销比例实测大致是:一万个物体里一条短射线,broad phase 候选个位数时,总耗时里窄相和结果排序能占到七八成;候选上百时 broad phase 取候选的开支开始抬头。所以不存在"射线开销恒定"这种好事,它和你场景里物体的分布强相关。

什么时候必须走 direct space state

一个常见误用是在 _physics_process 里用 $RayCast3D.get_collider()——这没问题,RayCast3D 节点的 force_raycast_update() 内部同样调 direct space state,但它帮你缓存了上一帧结果,适合每帧查询一次的模式。反过来,一次物理帧里要发几十上百条射线(霰弹枪弹道、激光网),就别给每条射线配一个节点了,直接组 PhysicsRayQueryParameters3D 批量调,省掉节点树的属性同步开销。

还有个线程问题:direct space state 的查询不是线程安全的(4.x 文档明确写"Accessing this during physics callbacks... not allowed"),想在 WorkerThreadPool 里并行发射线,要等 4.4 之后 Jolt 的 space query 支持或者自己加锁,默认配置下并行查同一个 space 数据竞争是必然。

intersect_ray 调用链:从脚本到哈希网格(servers/physics_3d) GDScript intersect_ray(query) DirectSpaceState3D 立即查询,不进命令队列 GodotHashGrid 线段包围盒展开格子 窄相求交 球解析 / 凸包支持函数 按 t 排序取最早命中 query 参数:from / to / mask / exclude 物理步进中调用会被拒(space locked) 纯整数格坐标运算,无开方 候选数决定本层耗时 默认忽略起点在内部与背面

本文基于 Godot 4.x servers/physics_3d/godot_physics_direct_space_state_3d.cpp、broadphase_godot_hash_grid.h、gjk_epa.cpp。