A星寻路算法(七):路径平滑

Number of views 39

我们已经对寻路算法做了不少工作,并增加了平滑权重的逻辑以便角色能够在道路中间而不是道路边缘行走。但是我们发现角色行走的路径在转弯处很生硬,如图所示:

image1748500391864.png添加平滑处理后路径会变得更加合理:

image1748500522898.png

假设我们有如下的点数组:

image1748500697391.png

我们要让角色从start起点到end终点位置,中间途径4个路径点,并在转弯时产生平滑效果,实际上没有太复杂。我们只需要在各个途径点之前定义一个点,这个点到途径点的距离称为“转弯半径”。

image1748501018090.png

接着我们对转弯半径的起点作垂线,作为转弯边界。例外的是路径的终点,我们在终点位置作转弯边界线。

image1748501219304.png

当路径经过转弯边界线的时候,路径开始平滑过渡,直至终点边界线后终止:

image1748501471438.png

显然我们可以通过调整转弯速度以便调整其平滑程度。

我们新建Line类,用于实现上面所述逻辑,Line的整体逻辑代码如下所示:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public struct Line {

    // 垂直线的梯度常量,用于避免除以零的情况
    const float verticalLineGradient = 1e5f;

    float gradient;         // 直线斜率
    float y_intercept;      // 直线在y轴上的截距
    Vector2 pointOnLine_1;  // 直线上的第一个点
    Vector2 pointOnLine_2;  // 直线上的第二个点,用于确定直线方向

    bool approachSide;      // 基准点所在的一侧,用于判断穿越方向

    // 构造函数:通过线上一点和垂直方向的点创建直线
    public Line(Vector2 pointOnLine, Vector2 pointPerpendicularToLine) {
        // 计算x和y的差值,用于确定垂线的方向
        float dx = pointOnLine.x - pointPerpendicularToLine.x;
        float dy = pointOnLine.y - pointPerpendicularToLine.y;

        // 处理垂直线的特殊情况(避免除以零)
        if (dy == 0) {
            gradient = verticalLineGradient;
        } else {
            // 计算垂直线的斜率(垂直于两点连线的直线)
            gradient = -dx / dy;
        }

        // 计算直线在y轴上的截距
        y_intercept = pointOnLine.y - gradient * pointOnLine.x;
        pointOnLine_1 = pointOnLine;
        // 计算直线上的另一个点,用于确定直线方向
        pointOnLine_2 = pointOnLine + new Vector2(1, gradient);

        // 确定基准点所在的一侧
        approachSide = false;
        approachSide = GetSide(pointPerpendicularToLine);
    }

    // 判断点p是否在直线的某一侧
    bool GetSide(Vector2 p) {
        // 使用叉积的符号判断点p位于直线的哪一侧
        // 如果结果为正,点在直线一侧;为负则在另一侧
        return (p.x - pointOnLine_1.x) * (pointOnLine_2.y - pointOnLine_1.y) > 
               (p.y - pointOnLine_1.y) * (pointOnLine_2.x - pointOnLine_1.x);
    }

    // 判断点p是否越过了直线
    public bool HasCrossedLine(Vector2 p) {
        // 比较点p当前所在侧与基准侧是否不同
        return GetSide(p) != approachSide;
    }

    // 使用Gizmos在场景中绘制直线(仅在编辑器中可见)
    public void DrawWithGizmos(float length) {
        // 创建3D方向向量(忽略y轴,用于在3D空间中绘制2D直线)
        Vector3 lineDir = new Vector3(1, 0, gradient).normalized;
        // 设置直线中心位置(在y=1平面上,便于在3D场景中查看)
        Vector3 lineCentre = new Vector3(pointOnLine_1.x, 0, pointOnLine_1.y) + Vector3.up;
        // 绘制指定长度的直线
        Gizmos.DrawLine(lineCentre - lineDir * length / 2f, lineCentre + lineDir * length / 2f);
    }
}

接着新建Path类,用于实现路径转向边界的计算与可视化。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Path {

    public readonly Vector3[] lookPoints;       // 路径上的关键点(转折点)
    public readonly Line[] turnBoundaries;     // 每个转折点的边界线数组
    public readonly int finishLineIndex;        // 终点线的索引

    // 构造函数:通过路径点、起点和转向距离创建路径
    public Path(Vector3[] waypoints, Vector3 startPos, float turnDst) {
        lookPoints = waypoints;
        turnBoundaries = new Line[lookPoints.Length];
        finishLineIndex = turnBoundaries.Length - 1;

        Vector2 previousPoint = V3ToV2(startPos);
        // 遍历所有路径点,计算每个转折点的边界线
        for (int i = 0; i < lookPoints.Length; i++) {
            Vector2 currentPoint = V3ToV2(lookPoints[i]);
            // 计算指向下一个点的方向向量
            Vector2 dirToCurrentPoint = (currentPoint - previousPoint).normalized;
            // 计算转折点的边界点(终点特殊处理,不后退)
            Vector2 turnBoundaryPoint = (i == finishLineIndex) ? 
                currentPoint : currentPoint - dirToCurrentPoint * turnDst;
            // 创建边界线(垂直于路径方向,用于判断是否应该转向)
            turnBoundaries[i] = new Line(turnBoundaryPoint, 
                previousPoint - dirToCurrentPoint * turnDst);
            previousPoint = turnBoundaryPoint;
        }
    }

    // 将三维向量转换为二维向量(忽略y轴,常用于俯视视角的路径规划)
    Vector2 V3ToV2(Vector3 v3) {
        return new Vector2(v3.x, v3.z);
    }

    // 使用Gizmos在场景中绘制路径和边界线(仅在编辑器中可见)
    public void DrawWithGizmos() {
        // 绘制路径关键点
        Gizmos.color = Color.black;
        foreach (Vector3 p in lookPoints) {
            Gizmos.DrawCube(p + Vector3.up, Vector3.one * 0.5f);
        }

        // 绘制转向边界线
        Gizmos.color = Color.white;
        foreach (Line l in turnBoundaries) {
            l.DrawWithGizmos(10);
        }
    }
}

接着修改下Unit类,以便使用刚刚的修改:

using UnityEngine;
using System.Collections;

public class Unit : MonoBehaviour {

    public Transform target;        // 移动目标的Transform组件
    public float speed = 20;        // 移动速度
    public float turnDst = 5;       // 转向提前量(在距离转折点多远时开始转向)

    Path path;                      // 当前正在跟随的路径

    void Start() {
        // 向路径请求管理器发送路径请求,路径找到后调用OnPathFound回调
        PathRequestManager.RequestPath(transform.position, target.position, OnPathFound);
    }

    // 路径找到后的回调函数
    public void OnPathFound(Vector3[] waypoints, bool pathSuccessful) {
        if (pathSuccessful) {
            // 创建路径对象
            path = new Path(waypoints, transform.position, turnDst);
  
            // 停止并重新启动路径跟随协程
            StopCoroutine("FollowPath");
            StartCoroutine("FollowPath");
        }
    }

    // 路径跟随协程
    IEnumerator FollowPath() {

        while (followingPath) {
            yield return null;  // 等待下一帧
        }
    }

    // 在编辑器中绘制调试信息
    public void OnDrawGizmos() {
        if (path != null) {
            path.DrawWithGizmos();
        }
    }
}

运行后可看到转弯边界线与路径点:

image1753342536623.png

其中白线就是转弯边界线,黑色点即是路径点,最后一个黑点对应从目标终点倒数第二条的转弯边界线。

继续完善代码,在Line类中新增DistanceFromPoint函数,用于计算点到直线的垂直距离:

// 计算点p到直线的垂直距离
public float DistanceFromPoint(Vector2 p) {
    // 计算过点p且垂直于当前直线的垂线方程
    float yInterceptPerpendicular = p.y - gradientPerpendicular * p.x;
    // 计算两直线的交点
    float intersectX = (yInterceptPerpendicular - y_intercept) / (gradient - gradientPerpendicular);
    float intersectY = gradient * intersectX + y_intercept;
    // 返回点p到交点的距离,即点到直线的垂直距离
    return Vector2.Distance(p, new Vector2(intersectX, intersectY));
}

改进Path类,增加减速区域的计算逻辑:

using UnityEngine;

public class Path {

    public readonly Vector3[] lookPoints;       // 路径上的关键点(转折点)
    public readonly Line[] turnBoundaries;     // 每个转折点的边界线数组
    public readonly int finishLineIndex;        // 终点线的索引
    public readonly int slowDownIndex;          // 减速区域的起始索引

    // 构造函数:通过路径点、起点、转向距离和停止距离创建路径
    public Path(Vector3[] waypoints, Vector3 startPos, float turnDst, float stoppingDst) {
        lookPoints = waypoints;
        turnBoundaries = new Line[lookPoints.Length];
        finishLineIndex = turnBoundaries.Length - 1;

        Vector2 previousPoint = V3ToV2(startPos);
        // 遍历所有路径点,计算每个转折点的边界线
        for (int i = 0; i < lookPoints.Length; i++) {
            Vector2 currentPoint = V3ToV2(lookPoints[i]);
            // 计算指向下一个点的方向向量
            Vector2 dirToCurrentPoint = (currentPoint - previousPoint).normalized;
            // 计算转折点的边界点(终点特殊处理,不后退)
            Vector2 turnBoundaryPoint = (i == finishLineIndex) ? 
                currentPoint : currentPoint - dirToCurrentPoint * turnDst;
            // 创建边界线(垂直于路径方向,用于判断是否应该转向)
            turnBoundaries[i] = new Line(turnBoundaryPoint, 
                previousPoint - dirToCurrentPoint * turnDst);
            previousPoint = turnBoundaryPoint;
        }

        // 从终点开始反向计算累积距离,确定减速区域的起始点
        float dstFromEndPoint = 0;
        for (int i = lookPoints.Length - 1; i > 0; i--) {
            dstFromEndPoint += Vector3.Distance(lookPoints[i], lookPoints[i - 1]);
            // 当累积距离超过停止距离时,标记该点为减速起始点
            if (dstFromEndPoint > stoppingDst) {
                slowDownIndex = i;
                break;
            }
        }
    }

    // 将三维向量转换为二维向量(忽略y轴,常用于俯视视角的路径规划)
    Vector2 V3ToV2(Vector3 v3) {
        return new Vector2(v3.x, v3.z);
    }

    // 使用Gizmos在场景中绘制路径和边界线(仅在编辑器中可见)
    public void DrawWithGizmos() {
        // 绘制路径关键点
        Gizmos.color = Color.black;
        foreach (Vector3 p in lookPoints) {
            Gizmos.DrawCube(p + Vector3.up, Vector3.one * 0.5f);
        }

        // 绘制转向边界线
        Gizmos.color = Color.white;
        foreach (Line l in turnBoundaries) {
            l.DrawWithGizmos(10);
        }
    }
}

在Unit类中我们继续完善路径跟随的功能:

using UnityEngine;
using System.Collections;

public class Unit : MonoBehaviour {

    // 路径更新相关常量
    const float minPathUpdateTime = .2f;         // 最小路径更新时间间隔
    const float pathUpdateMoveThreshold = .5f;   // 触发路径更新的最小移动阈值

    public Transform target;                     // 移动目标的Transform组件
    public float speed = 20;                     // 移动速度
    public float turnSpeed = 3;                  // 转向速度
    public float turnDst = 5;                    // 转向提前量(在距离转折点多远时开始转向)
    public float stoppingDst = 10;               // 减速停止距离

    Path path;                                   // 当前正在跟随的路径

    void Start() {
        // 启动路径更新协程
        StartCoroutine(UpdatePath());
    }

    // 路径找到后的回调函数
    public void OnPathFound(Vector3[] waypoints, bool pathSuccessful) {
        if (pathSuccessful) {
            // 创建路径对象,传入转向距离和停止距离
            path = new Path(waypoints, transform.position, turnDst, stoppingDst);
          
            // 停止并重新启动路径跟随协程
            StopCoroutine("FollowPath");
            StartCoroutine("FollowPath");
        }
    }

    // 路径更新协程
    IEnumerator UpdatePath() {
        // 等待一小段时间,确保场景初始化完成
        if (Time.timeSinceLevelLoad < .3f) {
            yield return new WaitForSeconds(.3f);
        }
      
        // 首次请求路径
        PathRequestManager.RequestPath(transform.position, target.position, OnPathFound);

        float sqrMoveThreshold = pathUpdateMoveThreshold * pathUpdateMoveThreshold;
        Vector3 targetPosOld = target.position;

        while (true) {
            // 等待最小更新时间
            yield return new WaitForSeconds(minPathUpdateTime);
          
            // 如果目标移动距离超过阈值,重新计算路径
            if ((target.position - targetPosOld).sqrMagnitude > sqrMoveThreshold) {
                PathRequestManager.RequestPath(transform.position, target.position, OnPathFound);
                targetPosOld = target.position;
            }
        }
    }

    // 路径跟随协程
    IEnumerator FollowPath() {
        bool followingPath = true;  // 是否正在跟随路径
        int pathIndex = 0;          // 当前路径点索引
      
        // 面向第一个路径点
        transform.LookAt(path.lookPoints[0]);

        float speedPercent = 1;     // 速度百分比,用于减速控制

        while (followingPath) {
            // 获取当前位置在路径上的2D投影
            Vector2 pos2D = new Vector2(transform.position.x, transform.position.z);
          
            // 检查是否越过了当前路径段的边界线
            while (path.turnBoundaries[pathIndex].HasCrossedLine(pos2D)) {
                // 如果是最后一个路径点,表示已到达终点附近
                if (pathIndex == path.finishLineIndex) {
                    followingPath = false;
                    break;
                } else {
                    // 否则移动到下一个路径点
                    pathIndex++;
                }
            }

            if (followingPath) {
                // 检查是否进入减速区域
                if (pathIndex >= path.slowDownIndex && stoppingDst > 0) {
                    // 根据到终点的距离计算速度百分比
                    speedPercent = Mathf.Clamp01(path.turnBoundaries[path.finishLineIndex].DistanceFromPoint(pos2D) / stoppingDst);
                    // 如果距离足够近,停止跟随路径
                    if (speedPercent < 0.01f) {
                        followingPath = false;
                    }
                }

                // 计算目标旋转并平滑转向
                Quaternion targetRotation = Quaternion.LookRotation(path.lookPoints[pathIndex] - transform.position);
                transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, Time.deltaTime * turnSpeed);
              
                // 根据速度百分比调整速度并向前移动
                transform.Translate(Vector3.forward * Time.deltaTime * speed * speedPercent, Space.Self);
            }
          
            yield return null;  // 等待下一帧
        }
    }

    // 在编辑器中绘制调试信息
    public void OnDrawGizmos() {
        if (path != null) {
            path.DrawWithGizmos();
        }
    }
}

运行后的效果如图:

image1753346529965.png

0 Answers