# ORCA-Test **Repository Path**: wanfengqu/orca-test ## Basic Information - **Project Name**: ORCA-Test - **Description**: No description available - **Primary Language**: C# - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 10 - **Created**: 2024-03-06 - **Last Updated**: 2024-03-06 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # ORCA-Test #### 介绍 {**以下是 Gitee 平台说明,您可以替换此简介** Gitee 是 OSCHINA 推出的基于 Git 的代码托管平台(同时支持 SVN)。专为开发者提供稳定、高效、安全的云端软件开发协作平台 无论是个人、团队、或是企业,都能够用 Gitee 实现代码托管、项目管理、协作开发。企业项目请看 [https://gitee.com/enterprises](https://gitee.com/enterprises)} #### 软件架构 软件架构说明 https://mp.weixin.qq.com/s/ZxAyBlSgjiQKVjQHHgx1ew #### 安装教程 1. xxxx 2. xxxx 3. xxxx #### 使用说明 1. xxxx 2. xxxx 3. xxxx #### 参与贡献 1. Fork 本仓库 2. 新建 Feat_xxx 分支 3. 提交代码 4. 新建 Pull Request #### 特技 1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md 2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com) 3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目 4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目 5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) 6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) Unity 的 DOTS 被广泛应用于高性能的游戏项目开发中,比如需要大量处理实体、物理计算、角色 AI 等的游戏。现在使用 DOTS 开发的游戏,很多都会有控制大批量角色的需求。如何控制大批量角色移动和避障是角色 AI 的需求之一。 本文利用 DOTS 高性能这一特性,详细分析 ORCA(Optimal Reciprocal Collision Avoidance)算法的原理,并使用 DOTS 进行算法实现。 ORCA 避障算法是一个经典的算法,源自 2011 年的文章《Reciprocal n-body Collision Avoidance》[1],目前已被广泛应用于许多领域,有不同计算机语言的实现版本。 虽然网上已经有许多针对 ORCA 的算法原理讲解,但是要么仅仅单纯的翻译原文,要么讲解得比较粗略,很少有能细致讲解清楚的,下面详细介绍一下算法原理和代码实现。 1. 算法原理 1.1. VO(Velocity Obstacle) 假设有 A、B 两个运动中的圆形物体,如下图所示。 图片 为了判断下一时间点能不能相撞,可以简化一下上面的模型。将圆 A 简化成一个质点 p A ,那么 B 就要相应地变成半径为 r A +r B 的圆。运行的下一时刻,只要点 p A 不出现在下图中阴影区域,圆 A 和圆 B 就不会碰撞。 图片 为了后续计算简便,将上面的坐标系转换为以点 p A 为参照的坐标系,也就是将 p A 当作坐标系原点进行计算。 图片 计算避障的核心是控制物体的速度,因此坐标系转需要从上面的位置坐标系转换为速度坐标系。也就是说,X 轴表示 X 方向的速度,即 X 方向上单位时间移动的距离,Y 轴同理。因此只需要位置坐标系的所有坐标都除以单位时间τ,就可以得到速度坐标系坐标了。 图片 上图中,因为所有坐标都除以了 τ,因此阴影区域的形状也发生了改变,原来以 p A +p B 为圆心、 r A +r B 为半径的大圆,变成了以 (p A +p B )/τ 为圆心、 (r A +r B )/τ 为半径的小圆。根据三角形相似原理,可以很容易推导出: • 大圆和小圆仅有一个交点; • 大圆和小圆都内切于阴影区域。 在速度坐标系下,每一个坐标点都代表一个速度(Velocity)值,因为这个坐标系是以圆 A 为参考建立的相对速度坐标系,因此每一个坐标点实际代表的是圆 A 的相对速度,记作 v A −v B 。坐标图中,这片阴影区域叫做速度障碍(VO,Velocity Obstacle),记作 VO A∣B τ 。 VO A∣B τ 是以圆 A 为参照建立坐标系求得的,那么以圆 B 为参照建立坐标系求得的 VO,我们记作 VO B∣A τ 。 VO A∣B τ 和 VO B∣A τ 相对于速度坐标系原点对称。 1.2. 避障速度集合 因为 A 的相对速度坐标系下,每一个坐标点都是圆 A 的相对速度,也就是 v A −v B 。因此,如果 v B 取值范围不同,那么为了避免圆 A 和圆 B 发生碰撞,因此 v A 也需要有对应的速度取值范围。这个问题就演变成以下数学问题: 设圆 B 的速度取值范围为集合 V B ,如果 v B ∈V B ,为了避免圆 A 和圆 B 发生碰撞,求圆 A 的速度取值范围集合 V A 。 图片 如上图所示,设速度 v B 的取值范围为集合 V B ,也就是蓝色填充区域,那么 V B 的闵可夫斯基和表示为 VO A∣B τ ⊕V B 。这里的闵可夫斯基和可以形象地理解为以 VO A∣B τ 为笔刷,以平移 ∣v A −v B ∣ 个单位距离的 V B 为笔刷路径(如蓝色虚线框所示),涂抹出来的区域集合(灰色区域)。 因此我们可以得出结论,只要圆 A 的速度 v A 不在 VO A∣B τ ⊕V B 范围内,两个圆就不会碰撞。也就是说,如果 v A ∈ / VO A∣B τ ⊕V B ,则两个圆就不会碰撞。 在实际工程中,程序执行的每个时刻,我们总是希望赋予圆(也就是机器人)一个期望移动速度 v opt ,让圆尽可能按照这个速度移动。这个期望移动速度可能会导致圆之间互相碰撞,也就是说,有可能出现 v A opt −v B opt ∈VO A∣B τ 。 通常机器人的期望移动速度 v opt 指向它的目的地。 但是没有关系,我们可以根据期望移动速度,求出一个闵可夫斯基和。令实际的移动速度 v new 在这个闵可斯基和的范围之外,就可以保证圆在接近期望移动速度的情况下移动,且不会出现互相碰撞。 图片 如图所示,可以看出 v A opt −v B opt ∈VO A∣B τ ,如果圆 A 以期望移动速度 v A opt 移动,那么将会与圆 B 碰撞。为了避免碰撞,首先求出坐标点 v A opt −v B opt 到 VO A∣B τ 边缘最近的向量 u ,向量 u 的方向和切平面的法线 n 一致。然后,在离 v A opt 距离 ∣ u ∣∗w 的地方,沿着圆切线方向求闵可夫斯基和,就可以得到圆 A 的实际移动速度 v new 允许的范围,记作 ORCA A∣B τ 。 上面的求解里,还有一个 w 没有解释,这是一个权重值。ORCA 算法认为,避障是双方的责任,也就是当圆 A 和圆 B 相遇时,互相都要避让对方。这个权重值就是避让的权重,这里可以默认其为 0.5。 当多个 ORCA 区域重叠时,取其交集,也就是实际移动速度最终允许的范围,如下图所示为圆 A 的实际移动速度取值范围,也就是最终所求的避障速度集合。 图片 当然也会出现没有交集的情况,这种情况的处理办法会在后续讲解代码时具体说明。 1. 代码实现 如果需要完整源码,请参看代码仓库 https://gitee.com/zd304/orca-test。 2.1. DOTS 代码结构 本小节通过分析 ECS 的结构,概括性地了解整体的代码结构。 2.1.1. Component 为每个圆生成如下避障代理组件,因此后文中的圆和 Agent 是同一个意思。 public struct Agent : IComponentData { /// /// 代理ID,全局唯一 /// public int id; /// /// 当前位置 /// public float2 position; /// /// 期望速度,也就是v^opt /// public float2 prefVelocity; /// /// 当前速度 /// public float2 velocity; /// /// 圆的半径 /// public float radius; /// /// 避障责任权重 /// public float weight; /// /// 最大速率 /// public float maxSpeed; /// /// KDTree查找的最大相邻Agent数量 /// public int maxNeighbors; /// /// KDTree查找相邻Agent时候的最大检测距离 /// public float neighborDist; /// /// 提前避障的时间(可以大于帧间隔deltaTime,表示要提前避障) /// public float timeHorizon; /// /// 移动的目标位置,用来计算期望速度prefVelocity /// public float2 targetPos; } 每个字段注释已经写清楚用途,不再赘述。其中需要用户配置的数据主要是以下几个。 public class AgentAuthoringAuthoring : MonoBehaviour { [Header("避障半径")] public float radius; [Header("避障责任权重")] public float weight; [Header("KDTree查找的最大相邻Agent数量")] public int maxNeighbors; [Header("KDTree查找相邻Agent时候的最大检测距离")] public float neighborDist; [Header("提前避障的时间")] public float timeHorizon; [Header("预期移动速率")] public float speed; private class AgentAuthoringBaker : Baker { ...... } } 2.1.2. System Entities 里为每个圆组织好以上组件数据,就可以到 System 里去访问并使用这些数据进行位移计算了。 处理 Agent 组件移动的 System 命名为 SimulatorSystem,其主要通过以下步骤完成 Agent 对应实体的移动: 1. 构建 KDTree; 2. 计算预速度; 3. 从 KDTree 里查找附近的圆; 4. 计算新的移动速度; 5. 根据新的移动速度,更新位置。 public partial struct SimulatorSystem : ISystem { ...... [BurstCompile] public partial struct MoveJob : IJobEntity { public KDTree kdTree; public float deltaTime; /// /// 遍历每一个Agent,并更新位置 /// private void Execute(Entity entity, [EntityIndexInQuery] [SuppressMessage("ReSharper", "UnusedParameter.Local")] int entityInQueryIndex, ref Agent agent, ref LocalTransform transform) { // 2. 计算预期速度 agent.prefVelocity = math.normalizesafe(agent.targetPos - agent.position) \* agent.maxSpeed; float prefDistSq = math.lengthsq(agent.targetPos - agent.position); if (prefDistSq < RVOMath.sqr(agent.radius)) { agent.prefVelocity = float2.zero; } float2 newVelocity; NativeList neighbors = new NativeList(agent.maxNeighbors, Allocator.Temp); neighbors.Clear(); // 3. 从KDTree里查找附近的圆 ComputeNeighbors(ref agent, ref neighbors); // 4. 计算新的移动速度 ComputeNewVelocity(ref agent, in neighbors, out newVelocity); // 5. 根据新的移动速度,更新位置 Update(ref agent, newVelocity); transform.Position = new float3(agent.position.x, 0.0f, agent.position.y); neighbors.Dispose(); } } ...... [BurstCompile] public void OnUpdate(ref SystemState state) { NativeArray agents = agentQuery.ToComponentDataArray(Allocator.TempJob); KDTree kdTree = new KDTree(); // 1. 构建KDTree kdTree.BuildAgentTree(ref agents); state.Dependency = new MoveJob() { kdTree = kdTree, deltaTime = SystemAPI.Time.DeltaTime, }.ScheduleParallel(state.Dependency); state.Dependency = agents.Dispose(state.Dependency); } } 通过以上代码注释,可以看到实现 Agent 对应实体的移动的主要步骤,下面详细介绍每一步实现。 2.2. KDTree 如果熟悉 KDTree 算法的话,可以跳过本节内容。 根据前文分析 ORCA 算法原理的时候知道,为了计算出合适的避障速度集合,需要计算每一个圆和其他所有圆的 ORCA 半平面,然后求这些半平面的交集。这样的计算量是巨大的,每增加一个圆的实体,其计算量都会呈几何倍数增长,这样的算法不是高效的算法。 为了提高计算的效率,我们仅仅需要关注那些离得比较近的实体间是否有碰撞。对于那些离得比较远的实体,由于其距离已经远大于避障检测的步长,因此他们是肯定不会碰撞的,这种情况下再去计算他们的 ORCA 半平面,将会是很大的计算浪费。 为了找到距离较近的圆进行避障检测,我们就需要对所有圆所处的位置空间进行空间分割。常用的空间分割方法有 KDTree、四叉树(八叉树)等。通常对均匀分布在场景中的物体做空间分割,会用四叉树(八叉树)来实现,比如《Minecraft》里面的地形盒子,一个格子最多一个地形块,其分布就很均匀,就适合用八叉树来进行空间分割。但是 ORCA 算法面对的对象是不停移动的物体,他们在空间中是不均匀分布的,因此非常适合用 KDTree 来进行空间分割。 KDTree 的本质是一棵二叉树,KDTree 根据物体的位置关系,将物体所处的空间不断二分,如下图所示。 图片 最终将细分的空间中的物体组成一棵二叉树,如下图所示就是组成的 KDTree,通过这棵二叉树很容易经过最多 3 次查询,就查询到目标物体。 图片 2.2.1. KDTree 数据结构 KDTree 里面的主要数据结构如下所示。 public struct KDTree { /// /// KDTree的节点 /// private struct AgentTreeNode { /// /// Agent的开始索引 /// internal int begin; /// /// Agent的结束索引 /// internal int end; /// /// 左叶子节点的AgentTreeNode索引 /// internal int left; /// /// 右叶子节点的AgentTreeNode索引 /// internal int right; #region KDTree节点范围 internal float maxX; internal float maxY; internal float minX; internal float minY; #endregion } ...... /// /// 本帧的所有代理 /// [ReadOnly] public NativeArray agents; /// /// KDTree的所有节点 /// [ReadOnly] private NativeArray agentTree; ...... } 根据代码中的 AgentTreeNode 结构可以知道,KDTree 的每一个节点主要保存的是当前节点里所有 Agent 的索引范围,而不是保存的 Agent 的引用。真正的 Agent 引用其实是保存在 agents 这个数组里。 2.2.2. 构建 KDTree 构建 KDTree 的过程非常简单,主要分两个步骤: • 递归生成 KDTree 节点; • 找到每一个节点的中间值,执行快速排序算法。 递归生成 KDTree 节点的逻辑很简单,就是通过层次遍历的方式,遍历生成每一层子节点。 生成了当前节点后,需要判断当前节点的 AABB 包围盒的长和宽的关系。 • 如果包围盒的长大于宽,那么以竖直方向边上的中点 Y 值作为参考值: • 将小于中点值的 Agent 移动到数组左边,归为左子节点; • 将大于中点值的 Agent 移动到数组右边,归为右子节点。 • 如果包围盒的宽大于长,那么以水平方向边上的中点 X 值作为参考值: • 将小于中点值的 Agent 移动到数组左边,归为左子节点; • 将大于中点值的 Agent 移动到数组右边,归为右子节点。 以上“找中点 → 移动数组元素”的过程,其实就是快速排序算法的过程。 图片 2.2.3. 查找相邻 Agent 查找指定 Agent 的相邻 Agent,需要给查找函数传入查找范围的引用 ref float rangeSq。之所以要传入引用是因为查找函数会动态调整这个值。 查找函数有主要步骤为: • 递归遍历 KDTree 的节点; • 叶子节点的所有 Agent 插入返回列表。 KDTree 的递归遍历,受限于检测距离 rangeSq,并非遍历全部节点。也就是说,只有离当前 Agent 距离小于等于 rangeSq 的节点才会被遍历到。 图片 如上图所示,因为 distSqLeft < rangeSq < distSqRight,所以只遍历左子节点。 如果当前 Agent 的位置在节点包围盒内,则当前 Agent 到包围盒的距离视为 0。 当遍历到叶子节点的时候,会将在检测距离范围内的叶子节点的所有 Agent 插入返回列表。插入过程中会遇到以下情况,分别按照以下描述的方法处理: • 如果返回列表的元素数量小于当前 Agent 的最大相邻数量,则直接插入。 • 如果返回列表的元素数量大于等于当前 Agent 的最大相邻数量,说明不需要返回那么多相邻 Agent,则替换距离最远的一个 Agent,并且缩小检测范围 rangeSq。 最后,按照距离由近到远对返回列表进行排序。 完整的查找流程如下图所示。 图片 2.3. ORCA 避障代码 ORCA 算法的避障代码就是函数 SimulatorSystem.MoveJob.ComputeNewVelocity,下面是主要代码。 private void ComputeNewVelocity(ref Agent agent, in NativeList neighbors, out float2 newVelocity) { NativeList orcaLines = new NativeList(Allocator.Temp); // 提前避障时间的倒数 float invTimeHorizon = 1.0f / agent.timeHorizon; for (int i = 0; i < neighbors.Length; ++i) { Agent other = neighbors[i].agent; // 相对位置:pB - pA float2 relativePosition = other.position - agent.position; // 相对速度:vA - vB = (pA - pB) / τ float2 relativeVelocity = agent.velocity - other.velocity; // 两个Agent之间的距离的平方 float distSq = math.lengthsq(relativePosition); // 大圆的半径:rA + rB float combinedRadius = agent.radius + other.radius; // 两个Agent半径和的平方 float combinedRadiusSq = RVOMath.sqr(combinedRadius); Line line = new Line(); float2 u; // 两个Agent之间的距离大于两者半径的和,说明还没有碰撞 if (distSq > combinedRadiusSq) {...} // 已经碰撞 else {...} line.point = agent.velocity + GetAgentWeight(agent, other.weight) \* u; orcaLines.Add(line); } // 通过线性规划找到ORCA半平面的交集 int lineFail = LinearProgram2(orcaLines, agent.maxSpeed, agent.prefVelocity, false, out newVelocity); if (lineFail < orcaLines.Length) { // 找不交集的情况下,使用3D线性规划计算 LinearProgram3(in orcaLines, agent.weight, 0, lineFail, agent.maxSpeed, ref newVelocity); } orcaLines.Dispose(); } 代码第 6 行的 agent.timeHorizon 是提前避障的时间,一般大于等于帧间隔 deltaTime(τ)。之所以有这样一个值,是为了让 Agent 在一定距离外就可以避让其他 Agent,不至于快要撞上了才避让。提前避让的好处是能让整体避让拥堵程度降低;坏处是当前 Agent 容易受到其他 Agent 影响主动避让,导致位置稳定得比较慢。因此,提前避让的时间是一个经验值,设置得好的话,可以很好减轻拥堵程度。 代码第 8 行到第 33 行的 for 循环,计算了每个 Agent 和它的所有相邻 Agent 的 ORCA 半平面,并存放到 orcaLines 列表里,供后续线性规划使用。 代码第 12 行到第 21 行准备了一些后续计算需要用到的数据,如下图所示。 图片 代码第 27 行判断是否 distSq > combinedRadiusSq,也就是判断原点 O 是否在大圆内部,也就是圆 A 和圆 B 是否已经碰撞。这两个条件分支将在 2.3.1.小节和 2.3.2.小节分别分析。 2.3.1. 两个 Agent 已经碰撞 如果两个 Agent 已经碰撞的话,将会执行到下面代码里。 // 已经碰撞 else { float invTimestep = 1.0f / deltaTime; // 小圆圆心到当前速度的向量 float2 w = relativeVelocity - invTimestep \* relativePosition; float wLength = math.length(w); float2 unitW = w / wLength; line.direction = new float2(unitW.y, -unitW.x); // u指向小圆圆周 u = (combinedRadius \* invTimestep - wLength) \* unitW; } 这段代码可以用下图的几何模型来表示。 图片 从图上可以看出来,relativeVelocity 就是当前的相对速度,即 v A opt −v B opt 。combinedRadius * invTimestep 用数学表达式表达就是 (r A +r B )/τ ,也就是小圆的半径。向量 w 就是小圆圆心指向 relativeVelocity 的向量,向量 u 就是 relativeVelocity 指向小圆圆周的向量。 求出了 u ,那么 ORCA 半平面就可以确定了,也就是垂直于 u 。 两个已经碰撞的 Agent 的 ORCA 半平面也就求解出来了。 2.3.2. 两个 Agent 未碰撞 如果两个 Agent 未碰撞的话,将会执行到下面代码里。 // 两个Agent之间的距离大于两者半径的和,说明还没有碰撞 if (distSq > combinedRadiusSq) { // 小圆圆心invTimeHorizon \* relativePosition // u为小圆圆心指向当前速度位置的向量 float2 w = relativeVelocity - invTimeHorizon \* relativePosition; float wLengthSq = math.lengthsq(w); // w向量在relativePosition向量上的投影 float dotProduct1 = math.dot(w, relativePosition); // dotProduct1小于0,说明w向量和relativePosition向量的夹角为钝角 // dotProduct1 = wLength \* length(relativePosition) \* cos(夹角) // RVOMath.sqr(dotProduct1) > combinedRadiusSq \* wLengthSq可简化为: // length(relativePosition) \* cos(夹角) > combinedRadius // 以上条件都满足,也就是说在小圆弧上计算u if (dotProduct1 < 0.0f && RVOMath.sqr(dotProduct1) > combinedRadiusSq \* wLengthSq) {...} // 否则,在两条腿上计算u else {...} } 这段代码可以用下图的几何模型来表示。 图片 从图上可以看出来,relativeVelocity 就是当前的相对速度,即 v A opt −v B opt 。relativePosition 也就是大圆的圆心,这里要看作原点到大圆圆心的向量,记作 p 。向量 w 就是小圆圆心指向 relativeVelocity 的向量。根据上图可知,dotProduct1 其实就是向量 w 在向量 p 上的投影。 如上图所示,此时的 dotProduct1 为负数,那么后续计算出来 u 就会指向小圆圆弧的黄色和绿色部分。 但是, u 其实不应该指向黄色部分,也就是说,当 v A opt −v B opt 出现在黄色线框的区域里面时, u 应该指向 VO 的一条直线边。后续称这条直线边为 VO 的“腿(leg)”。 因此,除了判断 dotProduct1 是否小于 0,还需要判断是否 RVOMath.sqr(dotProduct1) > combinedRadiusSq * wLengthSq 才能决定 u 是否指向小圆的弧。 RVOMath.sqr(dotProduct1) > combinedRadiusSq * wLengthSq 这个不等式表达的含义比较隐晦,我们需要对不等式做一下变换。 不等式用数学表达式书写为 dotProduct1 2 >combinedRadius 2 ∗wLength 2 ; 设向量 w 和向量 p 的夹角为 θ, 根据点乘公式得 dotProduct1=wLength∗∣ p ∣∗cos(θ) ; 将上述等式带入不等式得 wLength 2 (wLength∗∣ p ∣∗cos(θ)) 2 >combinedRadius 2 ; 简化后得 (∣ p ∣∗cos(θ)) 2 >combinedRadius 2 ; 再简化得 ∣ p ∣∗cos(θ)>combinedRadius 。 图片 根据上图的几何模型,可以继续简化上面的不等式: cos(θ)> ∣ p ∣ combinedRadius 。根据三角函数公式,最终得到 cos(θ)>cos(φ) 。 因此,综上所述,如果 dotProduct1 < 0.0f 并且 cos(θ)>cos(φ) ,则 u 需要指向小圆的弧;否则 u 指向离得最近的 VO“腿”上。 2.3.2.1. 在小圆上计算 u 如果满足条件 dotProduct1 < 0.0f 并且 cos(θ)>cos(φ) ,则在小圆上计算 u ,代码如下。 // 以上条件都满足,也就是说在小圆弧上计算u if (dotProduct1 < 0.0f && RVOMath.sqr(dotProduct1) > combinedRadiusSq \* wLengthSq) { float wLength = math.sqrt(wLengthSq); float2 unitW = w / wLength; line.direction = new float2(unitW.y, -unitW.x); u = (combinedRadius \* invTimeHorizon - wLength) \* unitW; } 计算过程和 2.3.1.小节一样,不再赘述。 2.3.2.2. 在两条腿上计算 u 如果以上条件不满足,则在两条腿上计算 u ,代码如下。 // 否则,在两条腿上计算u else { // 根据勾股定理,求得腿长 float leg = math.sqrt(distSq - combinedRadiusSq); // det利用叉积(sin值)判断在哪条腿上 if (RVOMath.det(relativePosition, w) > 0.0f) { /\* 向量(pB - pA)到左腿的投影向量 \*/ // 计算leg和大圆的切点,这个切点到原点的方向即为ORCA line的方向 line.direction = new float2(relativePosition.x \* leg - relativePosition.y \* combinedRadius, relativePosition.x \* combinedRadius + relativePosition.y \* leg) / distSq; } else { /\* 向量(pB - pA)到右腿的投影向量 \*/ // 计算leg和大圆的切点,这个切点到原点的方向的逆方向即为ORCA line的方向 line.direction = -new float2(relativePosition.x \* leg + relativePosition.y \* combinedRadius, -relativePosition.x \* combinedRadius + relativePosition.y \* leg) / distSq; } // dotProduct2是相对速度点到腿上的投影长度 float dotProduct2 = math.dot(relativeVelocity, line.direction); // 相对速度点到腿上的投影向量,减去相对速度向量,就得到了u u = dotProduct2 \* line.direction - relativeVelocity; } 代码中的 distSq 是原点到大圆圆心的距离 ∣ p ∣ 的平方。代码中的 combinedRadiusSq 是 combinedRadius 的平方,也就是大圆半径的平方。根据勾股定理,leg 就等于腿的长度。 这段代码可以用下图的几何模型来表示。 图片 代码第 8 行的判断语句里面的 RVOMath.det(relativePosition, w),也就是上图中的 det( p , w ) ,代表的是向量 w 到向量 p 的投影的另一条直角边的长度。其长度大于 0,则更靠近左腿;其长度小于 0,则更靠近右腿。例如上图就是更加靠近左腿。 以左腿为例,代码第 12 行计算出了 ORCA 半平面直线的 direction。因为左腿也是一条直线,所以其实 ORCA 半平面的 direction 和左腿是平行的,因此如果能算出左腿上某一点的坐标 p 1 ,也就能知道 ORCA 半平面的 direction。 设点 relativePosition 为 p r ,设长度 combinedRadius 为 r 1 。 根据上图,假设已知向量 p 与左腿的夹角为 θ, p 1 是点 p r 旋转 θ 后与左腿的交点。那么我们可以得以下等式。 p 1 =p r ∗( cos(θ) sin(θ) −sin(θ) cos(θ) ) 所以: p 1 =(p r .x∗cos(θ)−p r .x∗sin(θ),p r .y∗sin(θ)+p r .y∗cos(θ)) 又因为 cos(θ)= ∣ p ∣ leg , sin(θ)= ∣ p ∣ r 1 。 所以: p 1 =(p r .x∗ ∣ p ∣ leg −p r .x∗ ∣ p ∣ r 1 ,p r .y∗ ∣ p ∣ r 1 +p r .y∗ ∣ p ∣ leg ) 提取公因式得到: p 1 = ∣ p ∣ (p r .x∗leg−p r .x∗r 1 ,p r .y∗r 1 +p r .y∗leg) 因为 p 1 的长度为 ∣ p ∣ ,因此 p 1 的单位向量 p 1n 为: p 1n = ∣ p ∣ 2 (p r .x∗leg−p r .x∗r 1 ,p r .y∗r 1 +p r .y∗leg) 因此,ORCA 半平面的方向就是 p 1 的单位向量 p 1n ,代码在上一段代码的第 12 行。 /\* 向量(pB - pA)到左腿的投影向量 \*/ // 计算leg和大圆的切点,这个切点到原点的方向即为ORCA line的方向 line.direction = new float2(relativePosition.x \* leg - relativePosition.y \* combinedRadius, relativePosition.x \* combinedRadius + relativePosition.y \* leg) / distSq; 右腿的计算同理,不再赘述。 有了 line.direction,就可以求 u 了,代码如下。 // dotProduct2是相对速度点到腿上的投影长度 float dotProduct2 = math.dot(relativeVelocity, line.direction); // 相对速度点到腿上的投影向量,减去相对速度向量,就得到了u u = dotProduct2 \* line.direction - relativeVelocity; 这段代码可以用下图的几何模型来表示。 图片 设 relativeVelocity 在左腿上的投影为 OU ,那么 OU 的单位向量也就是 line.direction,则 dotProduct2 就是投影长度。所以点 U 的坐标等于 line.direction*dotProduct2。因此, u =U−relativeVelocity 。 2.3.3. 计算 line.point line.point 就是当前 Agent 的速度沿着 u 方向,偏移 u ×w 单元距离的点。跟算法描述的一致,不再过多解释,具体看如下代码。 line.point = agent.velocity + GetAgentWeight(agent, other.weight) \* u; orcaLines.Add(line); 代码第 2 行,将计算完成的 line 加入到 orcaLines 列表,以供接下来的线性规划计算使用。 2.3.4. 线性规划 在 MoveJob 中,有三个线性规划的函数,分别叫做 LinearProgram1、LinearProgram2、LinearProgram3。 2.3.4.1. LinearProgram2 首先看一下 LinearProgram2 函数。这个函数比较简单,就是遍历每一条 line 去执行 LinearProgram1 函数,求出当前 Agent 的实际移动速度。如果求解失败,则返回失败的 line 的索引。 /// /// 通过调用LinearProgram1线性规划,计算出Agent实际的移动速度 /// /// 所有ORCA /// 当前Agent的半径 /// 当前Agent的期望移动速度 /// 这个值通常是false,只有被LinearProgram3调用时候才为true /// Agent实际的移动速度。如果某一条line在执行LinearProgram1的时候失败,则返回上次计算成功的速度点 /// 如果计算成功,返回lines.Length;如果每一条line在执行LinearProgram1的时候失败,则返回失败的索引 private int LinearProgram2(in NativeList lines, float radius, float2 optVelocity, bool directionOpt, out float2 result) { // 在LinearProgram3里面,如果要直接使用预期速度的话 if (directionOpt) { // 直接赋值预期速度 result = optVelocity \* radius; } // 如果预期速度超过最大速度 else if (math.lengthsq(optVelocity) > RVOMath.sqr(radius)) { // 限制预期速度大小不能超过最大速度 result = math.normalizesafe(optVelocity) \* radius; } else { result = optVelocity; } for (int i = 0; i < lines.Length; ++i) { Line line = lines[i]; // 当前Vopt在line的右边 if (RVOMath.det(line.direction, line.point - result) > 0.0f) { float2 tempResult = result; // 调用LinearProgram1计算实际移动速度 if (!LinearProgram1(in lines, i, radius, optVelocity, directionOpt, ref result)) { result = tempResult; return i; } } } return lines.Length; } 2.3.4.2. LinearProgram1 LinearProgram1 函数用来计算在指定 line 上当前 Agent 的实际移动速度,这个实际移动速度大小不能超过 Agent 的最大移动速率,同时要尽量贴合预期移动速度。 LinearProgram1 函数核心思想如下图所示。 图片 如上图所示,传入 LinearProgram1 函数的预期移动速度 v opt 有可能在 ORCA 半平面以外,为了避免和其他 Agent 碰撞,需要以 Agent.maxSpeed 为半径,围绕原点旋转速度方向,直到速度和 ORCA 半平面有交点为止。得到的新的速度,即为相对当前 line 的实际速度 v new 。 代码前三句判断了以 Agent.maxSpeed 为半径,围绕原点旋转速度方向,是否能与 ORCA 半平面相交,如果 Agent.maxSpeed 太短,则可能没有交点,此时 LinearProgram1 函数计算失败。 // 设速度空间原点O到line的垂足为B,则dot(point, direction)算出的投影长度就是point到B的长度。 float dotProduct = math.dot(lines[lineNo].point, lines[lineNo].direction); // 如果discriminant小于0,则说明radius小于向量OB的长度,则说明在lineNo这条线内找不到合适的Velocity // discriminant也就是垂足到radius交点的距离的平方 float discriminant = RVOMath.sqr(dotProduct) + RVOMath.sqr(radius) - math.lengthsq(lines[lineNo].point); if (discriminant < 0.0f) { /\* Max speed circle fully invalidates line lineNo. \*/ return false; } 这段代码可以用下图的几何模型来表示。 图片 设 lines[lineNo].point 为 P 1 ,那么 dotProduct 就等于 dot( OP 1 ,line.direction) ,也就等于 ∣ P 1 P 2 ∣ 。 设 ∣ T 1 P 2 ∣ 为 x,设 ∣ OP 2 ∣ 为 y。因为 ∣ OT 1 ∣ 等于半径 radius,也就是 Agent.maxSpeed,是已知数。 根据勾股定理联立方程组得: { ∣ P 1 P 2 ∣ 2 +y 2 =∣ OP 1 ∣ 2 x 2 +y 2 =∣ OT 1 ∣ 2 解方程组得到: x 2 =∣ P 1 P 2 ∣ 2 +∣ OT 1 ∣ 2 −∣ OP 1 ∣ 2 所以 x 2 等于 RVOMath.sqr(dotProduct) + RVOMath.sqr(radius) - math.lengthsq(lines[lineNo].point),因此 discriminant 就是 T 1 P 2 长度的平方。 因此,discriminant 小于 0,则说明半径太短,与 ORCA 没有交点,返回失败。 接下来三句代码就比较简单了。 float sqrtDiscriminant = math.sqrt(discriminant); // Vnew左交点到line.point的距离 float tLeft = -dotProduct - sqrtDiscriminant; // Vnew右交点到line.point的距离 float tRight = -dotProduct + sqrtDiscriminant; 其中,sqrtDiscriminant 就是 ∣ T 1 P 2 ∣ ,tLeft 等于 ∣ P 1 T 2 ∣ ,tRight 等于 ∣ P 1 T 1 ∣ 。 以上就是当前 line 需要的数据,接下来用当前 line 和之前所有已经计算过的 line 重新计算最优的 v new 。 for (int i = 0; i < lineNo; ++i) { // direction是单位向量,长度为1 float denominator = RVOMath.det(lines[lineNo].direction, lines[i].direction); // direction是单位向量,长度为1;lines[lineNo].point - lines[i].point长度不为1 float numerator = RVOMath.det(lines[i].direction, lines[lineNo].point - lines[i].point); if (math.length(denominator) <= math.EPSILON) { /\* lines[i]和lines[lineNo]几乎平行 \*/ if (numerator < 0.0f) { return false; } continue; } // 因为direction是单位向量长度为1,且根据相似三角形原理,t就是两条line的交点到lines[lineNo].point的距离 float t = numerator / denominator; if (denominator >= 0.0f) { /\* 如果lines[i]在lines[lineNo]的右边 \*/ tRight = math.min(tRight, t); } else { /\* 如果lines[i]在lines[lineNo]的左边 \*/ tLeft = math.max(tLeft, t); } if (tLeft > tRight) { return false; } } 为了理解以上计算过程,我们建立一下几何模型帮助理解。 图片 设 lines[lineNo].direction 为 O 1 D 2 ,lines[i].direction 为 O 1 D 1 ,lines[lineNo].point - lines[i].point 为 P 3 P 1 。 那么 denominator 就等于平行四边形 ◇O 1 D 1 D 3 D 2 的面积,numerator 就等于平行四边形 ◇P 3 D 4 D 5 P 1 的面积。 因为 O 1 D 1 和 P 3 D 4 均为 lines[i].direction,所以平移 O 1 D 2 到 P 3 D 6 ,可以构建出两个共享底边的三角形 △P 3 D 4 P 1 和 △P 3 D 4 D 6 。因此,numerator / denominator 等于 D 4 D 6 D 4 P 1 。 又因为 △D 4 P 3 D 6 和 △D 4 O 1 P 1 相似,所以 denominator numerator = D 4 D 6 D 4 P 1 = P 3 D 6 O 1 P 1 。因为 ∣ P 3 D 6 ∣=1 ,所以 denominator numerator =∣ O 1 P 1 ∣ 。也就是代码里的 t 等于 ∣ O 1 P 1 ∣ 。 以上图为例,我们的 denominator 是大于 0 的,又因为 ∣ O 1 P 1 ∣<∣ P 1 T 2 ∣ ,所以 tRight 需要更新为 math.min(tRight, t),也就是 ∣ O 1 P 1 ∣ 。 代码第 33 行,如果出现 tLeft > tRight 的情况,可能是出现以下情形了,这样的情形下所有的 line 是没有交集的,因此返回失败。 图片 计算完所有的 line 对应的 v new 后,如果依然成功,那么就可以得到最终实际速度 v new ,代码里用 result 参数来返回。 float t = math.dot(lines[lineNo].direction, (optVelocity - lines[lineNo].point)); if (t < tLeft) { result = lines[lineNo].point + tLeft \* lines[lineNo].direction; } else if (t > tRight) { result = lines[lineNo].point + tRight \* lines[lineNo].direction; } else { result = lines[lineNo].point + t \* lines[lineNo].direction; } 如果线性规划失败了,将会调用 LinearProgram3 函数来重新做线性规划。 2.3.4.3. LinearProgram3 LinearProgram3 函数是为了避免在密集情况下,无法寻路导致的群体移动死锁而设计的,其具体实现如下。 private void LinearProgram3(in NativeList lines, float agentWeight, int numObstLines, int beginLine, float radius, ref float2 result) { float distance = 0.0f; // 从上次失败的line开始往后遍历 for (int i = beginLine; i < lines.Length; ++i) { // 如果result在line[i]的左侧distance远的区域以外,说明line[i]对最终的交集有影响 if (RVOMath.det(lines[i].direction, lines[i].point - result) > distance) { /\* result并不能满足line[i]的约束 \*/ NativeList projLines = new NativeList(Allocator.Temp); for (int ii = 0; ii < numObstLines; ++ii) { projLines.Add(lines[ii]); } // 遍历line[i]之前的所有line for (int j = numObstLines; j < i; ++j) { Line line; float determinant = RVOMath.det(lines[i].direction, lines[j].direction); if (math.length(determinant) <= math.EPSILON) { /\* Lin[i]和line[j]平行 \*/ if (math.dot(lines[i].direction, lines[j].direction) > 0.0f) { /\* Lin[i]和line[j]指向同一个方向 \*/ continue; } else { /\* Lin[i]和line[j]指向相反的方向 \*/ line.point = agentWeight \* (lines[i].point + lines[j].point); } } else { // 两条line的交点 line.point = lines[i].point + (RVOMath.det(lines[j].direction, lines[i].point - lines[j].point) / determinant) \* lines[i].direction; } // 角平分线方向 line.direction = math.normalizesafe(lines[j].direction - lines[i].direction); projLines.Add(line); } float2 tempResult = result; if (LinearProgram2(in projLines, radius, new float2(-lines[i].direction.y, lines[i].direction.x), true, out result) < projLines.Length) { /\* \* This should in principle not happen. The result is by \* definition already in the feasible region of this \* linear program. If it fails, it is due to small \* floating point error, and the current result is kept. \*/ result = tempResult; } projLines.Dispose(); distance = RVOMath.det(lines[i].direction, lines[i].point - result); } } } 其中 distance 为第三维的变量,用来排除对最终所有 line 的交集影响不大的 line。 代码中平行线的处理很简单,非平行线的 line.point 和 2.3.4.2.小节的证明过程相同,就是求两条 line 的交点。 这里比较特殊的是代码第 46 行 line.direction 的更新。算法使用 math.normalizesafe(lines[j].direction - lines[i].direction) 来更新 line.direction,也就是说新的方向设置为 lines[j].direction 和 lines[i].direction 构成的夹角的角平分线方向。 下面通过以下几何模型来说明这么更新 line 的方向的原因。 前文说过,下图这种情况可能导致所有 line 没有公共交集。 图片 但是,上图这种情况,在实际的位置空间中,所有 Agent 的分布有可能是如下图所示。 图片 这种情况下,其实圆 A 是有移动空间的,它可以沿着 v new 的方向移动,以避开圆 B、圆 C、圆 D,因此在这种情况下,我们可以重新设定 ORCA A∣B τ 和 ORCA A∣D τ 的方向,如下图所示。 图片 可见这样调整了 ORCA A∣B τ 和 ORCA A∣D τ 的方向后,绿色阴影区域即为新的公共交集, v new 取值在这个区域范围里,可以避免群体移动死锁。 2.4. 扩展 本文仅针对 Agent 进行避障,原版 ORCA 还考虑了静物,不过原理都差不多,后续有时间再补充。 本文仅考虑了 2D 环境下的避障,该算法可以扩展到 3D 环境。 1. 运行效果 在骁龙 425(处理器型号:MSM8917;制造工艺:28nm LP;CPU 架构:四核 A53;核心频率:1.4GHz;GPU:Adreno 308)的手机上运行,跑 1064 个 Agent 互相避障,避障行为的每帧消耗在 2ms 到 6ms 左右。 图片 602 个 Agent 互相避障,避障行为的每帧消耗在 1ms 到 5ms 左右。 图片 避障行为的效果如下图所示。 图片 引用链接 [1] 《Reciprocal n-body Collision Avoidance》: https://gamma.cs.unc.edu/ORCA/publications/ORCA.pdf