每日算法 — 使用java实现迷宫:Delaunay三角剖分与漏斗算法任意角寻路

传统迷宫求解用格点 A,得到的路径是”贴着格子中心”的折线,转弯次数多、路线僵硬。如果把它交给一个虚拟角色去走,动作会很不自然。本文换一条完全不同的路线:把迷宫的可走通道先抽取成导航网格,用 Delaunay 三角剖分 把离散点变成连续三角形面,再用 漏斗算法(Simple Stupid Funnel)* 拉出一条”拉紧的绳子”式最短路径——允许任意角度转向,而不是只能在 8 个方向上走。

一、为什么需要 Delaunay + 漏斗

格点 A* 的三个固有代价:

  • 路径冗余:8 邻接图上,一条直线在格子化之后会变成一串锯齿。虽然可以做”视线平滑”(raycast),但对于宽通道曲线,raycast 只能局部修补。
  • 精度绑定格距:格距越小,格子越多,搜索越慢;格距越大,通道细节丢失,窄缝识别不出来。
  • 无法表达连续转向:角色实际运动会朝任意方向,格子模型天然只有 8 个方向。

导航网格(NavMesh)的思路是:把连续可走空间切成凸多边形。凸多边形内任意两点的连线都落在多边形内,所以面内移动是”免费”的,只需要在多边形之间的公共边(portal) 上做选择。凸剖分之后,寻路变成”在 portal 序列上做漏斗收缩”,复杂度降到 O(portal 数),且天然支持任意角。

生成凸多边形的自然工具就是 Delaunay 三角剖分:它保证”最小角最大化”(max-min angle),不会产生细长瘦三角形——这对漏斗收缩的数值稳定性非常关键。

二、从离散点到三角形:Bowyer-Watson 增量构造

  1. 建一个包含所有点的超级三角形(足够大)。
  2. 逐点插入:找到所有外接圆包含新点的三角形 → “坏三角形”集合。
  3. 删除坏三角形,收集它们形成的空腔边界(在多边形边界上只出现一次的边)。
  4. 用新点与每条空腔边连成新三角形。
  5. 全部插完后,删掉含超级三角形顶点的三角形。

外接圆判定改用行列式谓词(inCircle)避免开方:若三角形三点逆时针排列且 |ax-px ay-py r1²| 等三行行列式为正,则点在圆内。逆时针排列是符号正确的前提,构造时必须强制。

三、把三角形变成可走面:通道裁剪

Delaunay 只看点,不管墙。所以要过滤两层:

  • 面过滤:三角形质心若落在墙格,直接丢弃。
  • 边过滤:三角形公共边若”穿过”墙格(沿线采样检测),标记 blocking,漏斗阶段不作为 portal。

四、漏斗算法:把折线拉成直线

在 portal 序列上维护一个”漏斗”:

  • 漏斗顶端是当前锚点(起点或上一个转向点),左右两条边界射线由 portal 端点决定。
  • 逐个吞入新 portal:若新右端点让右边界收紧(在既有右射线右侧),就更新右边界;若新左端点让左边界收紧,就更新左边界。
  • 一旦某个点越过了对侧边界(张角被挤爆),就把另一侧边界点弹出作为新转向点,重置漏斗,从该点重新开始。

整个结构是栈式操作,摊还 O(portal 数)。输出即”拉紧绳”的转向点。

关键细节:左右顺序必须按行进方向确定。对每条 portal,取”当前面质心 → 下一个面质心”的方向向量 d,端点 P 在 d 左侧当且仅当 d × (P - from) 为正。这一步搞反,漏斗会不断误判暴角并丢失路径。

五、Java 完整实现

maze-navmesh/
├── src/main/java/com/example/navmesh/
│   ├── Point.java            // 二维点
│   ├── Triangle.java         // 三角形 + 外接圆谓词
│   ├── Delaunay.java         // Bowyer-Watson 增量剖分
│   ├── MazeGrid.java         // 迷宫格点、采样、通道裁剪
│   ├── NavMesh.java          // 面片邻接、portal 提取(含左右排序)
│   ├── Funnel.java           // 漏斗算法拉紧路径
│   └── MazeDemo.java         // 主流程
└── pom.xml

5.1 点与三角形

package com.example.navmesh;

public class Point {
    public final double x, y;
    public Point(double x, double y) { this.x = x; this.y = y; }

    public double dist(Point o) {
        double dx = x - o.x, dy = y - o.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
}
package com.example.navmesh;

public class Triangle {
    public final Point a, b, c;
    public final Point cc;          // 外接圆圆心
    public final double r2;         // 外接圆半径平方

    public Triangle(Point a, Point b, Point c) {
        // 强制逆时针,否则外接圆行列式符号会翻转
        if (cross(a, b, c) < 0) { Point t = b; b = c; c = t; }
        this.a = a; this.b = b; this.c = c;
        this.cc = circumcenter(a, b, c);
        this.r2 = cc.dist(a) * cc.dist(a);
    }

    private static double cross(Point o, Point p, Point q) {
        return (p.x - o.x) * (q.y - o.y) - (p.y - o.y) * (q.x - o.x);
    }

    private static Point circumcenter(Point a, Point b, Point c) {
        double d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
        if (Math.abs(d) < 1e-12) {
            // 三点近共线:退化为重心,避免除零
            return new Point((a.x + b.x + c.x) / 3.0, (a.y + b.y + c.y) / 3.0);
        }
        double a2 = a.x * a.x + a.y * a.y;
        double b2 = b.x * b.x + b.y * b.y;
        double c2 = c.x * c.x + c.y * c.y;
        double ux = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d;
        double uy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d;
        return new Point(ux, uy);
    }

    /** 点 p 是否严格落在本三角形外接圆内 */
    public boolean inCircle(Point p) {
        double dx = p.x - cc.x, dy = p.y - cc.y;
        return dx * dx + dy * dy < r2 - 1e-9;
    }

    /** 判断三点(按引用)是否都属于本三角形 */
    public boolean containsEdge(Point p, Point q) {
        return has(p) && has(q);
    }

    private boolean has(Point p) {
        return a == p || b == p || c == p;
    }
}

5.2 Bowyer-Watson 剖分

package com.example.navmesh;

import java.util.*;

public class Delaunay {

    private final List<Triangle> tris = new ArrayList<>();

    public List<Triangle> triangulate(List<Point> points) {
        if (points.size() < 3) return tris;

        // 1. 超级三角形:包围盒放大 10 倍,保证覆盖所有点
        double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE;
        double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE;
        for (Point p : points) {
            minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
            minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
        }
        double dx = (maxX - minX) * 10 + 1, dy = (maxY - minY) * 10 + 1;
        Point s1 = new Point(minX - dx, minY - dy);
        Point s2 = new Point(minX + 2 * dx, minY - dy);
        Point s3 = new Point(minX - dx, minY + 2 * dy);
        tris.add(new Triangle(s1, s2, s3));

        // 2. 逐点插入
        for (Point p : points) {
            List<Triangle> bad = new ArrayList<>();
            for (Triangle t : tris) if (t.inCircle(p)) bad.add(t);
            if (bad.isEmpty()) continue;

            // 空腔边界:在多边形内部出现两次的边会被成对抵消
            Map<String, Point[]> edges = new HashMap<>();
            for (Triangle t : bad) {
                addEdge(edges, t.a, t.b);
                addEdge(edges, t.b, t.c);
                addEdge(edges, t.c, t.a);
            }
            tris.removeAll(bad);

            // 3. 新点与每条空腔边重建三角形
            for (Point[] e : edges.values()) tris.add(new Triangle(e[0], e[1], p));
        }

        // 4. 移除含超级三角形顶点的三角形
        tris.removeIf(t -> t.a == s1 || t.a == s2 || t.a == s3
                       || t.b == s1 || t.b == s2 || t.b == s3
                       || t.c == s1 || t.c == s2 || t.c == s3);
        return tris;
    }

    private void addEdge(Map<String, Point[]> map, Point p, Point q) {
        String key = key(p, q);
        if (map.containsKey(key)) map.remove(key);   // 第二次出现 = 内部边,抵消
        else map.put(key, new Point[]{p, q});
    }

    private String key(Point p, Point q) {
        String s1 = String.format("%.6f,%.6f", p.x, p.y);
        String s2 = String.format("%.6f,%.6f", q.x, q.y);
        return s1.compareTo(s2) < 0 ? s1 + "|" + s2 : s2 + "|" + s1;
    }

    public List<Triangle> getTriangles() { return tris; }
}

5.3 迷宫格点与通道裁剪

package com.example.navmesh;

import java.util.*;

public class MazeGrid {

    public final int rows, cols;
    public final boolean[][] wall;
    private final List<Point> samples = new ArrayList<>();

    public MazeGrid(String[] lines) {
        rows = lines.length;
        cols = Arrays.stream(lines).mapToInt(String::length).max().orElse(0);
        wall = new boolean[rows][cols];

        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                char ch = c < lines[r].length() ? lines[r].charAt(c) : '#';
                wall[r][c] = (ch == '#');
                if (!wall[r][c]) {
                    // 每格 5 个采样点:中心 + 四象限偏移,给剖分更多自由度
                    samples.add(new Point(c + 0.5, r + 0.5));
                    samples.add(new Point(c + 0.25, r + 0.25));
                    samples.add(new Point(c + 0.75, r + 0.25));
                    samples.add(new Point(c + 0.25, r + 0.75));
                    samples.add(new Point(c + 0.75, r + 0.75));
                }
            }
        }
    }

    public List<Point> samples() { return samples; }

    /** 质心是否落在墙格或越界 */
    public boolean centroidInWall(Triangle t) {
        double cx = (t.a.x + t.b.x + t.c.x) / 3.0;
        double cy = (t.a.y + t.b.y + t.c.y) / 3.0;
        int c = (int) Math.floor(cx), r = (int) Math.floor(cy);
        if (r < 0 || r >= rows || c < 0 || c >= cols) return true;
        return wall[r][c];
    }

    /** 边是否穿过墙:沿线每 0.1 格采样,任一落入墙格即判定阻挡 */
    public boolean edgeBlocked(Point p, Point q) {
        double d = p.dist(q);
        int steps = Math.max(2, (int) Math.ceil(d * 10));
        for (int i = 1; i < steps; i++) {
            double t = i / (double) steps;
            double x = p.x + (q.x - p.x) * t;
            double y = p.y + (q.y - p.y) * t;
            int c = (int) Math.floor(x), r = (int) Math.floor(y);
            if (r < 0 || r >= rows || c < 0 || c >= cols) return true;
            if (wall[r][c]) return true;
        }
        return false;
    }
}

5.4 导航网格与 portal 提取

package com.example.navmesh;

import java.util.*;

public class NavMesh {

    public final List<Triangle> tris = new ArrayList<>();
    private final Map<Triangle, List<Adj>> adj = new HashMap<>();

    /** 一条相邻关系:从 tri 出发穿过边 (p,q) 到达 to */
    public static class Adj {
        public final Triangle from, to;
        public final Point p, q;
        public final boolean walkable;
        Adj(Triangle from, Triangle to, Point p, Point q, boolean walkable) {
            this.from = from; this.to = to; this.p = p; this.q = q; this.walkable = walkable;
        }
    }

    /** 一个 portal:已按行进方向排好左右端点 */
    public static class Portal {
        public final Point left, right;
        public Portal(Point left, Point right) { this.left = left; this.right = right; }
    }

    public NavMesh(List<Triangle> raw, MazeGrid grid) {
        for (Triangle t : raw) if (!grid.centroidInWall(t)) tris.add(t);
        buildAdjacency(grid);
    }

    private void buildAdjacency(MazeGrid grid) {
        for (int i = 0; i < tris.size(); i++) {
            for (int j = i + 1; j < tris.size(); j++) {
                Triangle t1 = tris.get(i), t2 = tris.get(j);
                for (Point[] e : edgesOf(t1)) {
                    if (!t2.containsEdge(e[0], e[1])) continue;
                    boolean ok = !grid.edgeBlocked(e[0], e[1]);
                    add(t1, t2, e[0], e[1], ok);
                    add(t2, t1, e[0], e[1], ok);
                }
            }
        }
    }

    private void add(Triangle from, Triangle to, Point p, Point q, boolean ok) {
        adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Adj(from, to, p, q, ok));
    }

    public static List<Point[]> edgesOf(Triangle t) {
        return List.of(new Point[]{t.a, t.b}, new Point[]{t.b, t.c}, new Point[]{t.c, t.a});
    }

    public List<Adj> neighbors(Triangle t) {
        return adj.getOrDefault(t, Collections.emptyList());
    }

    public static Point centroid(Triangle t) {
        return new Point((t.a.x + t.b.x + t.c.x) / 3.0, (t.a.y + t.b.y + t.c.y) / 3.0);
    }

    public Triangle locate(Point p) {
        for (Triangle t : tris) if (inside(t, p)) return t;
        return null;
    }

    private boolean inside(Triangle t, Point p) {
        double d1 = sign(p, t.a, t.b), d2 = sign(p, t.b, t.c), d3 = sign(p, t.c, t.a);
        boolean neg = d1 < 0 || d2 < 0 || d3 < 0;
        boolean pos = d1 > 0 || d2 > 0 || d3 > 0;
        return !(neg && pos);
    }

    private double sign(Point p1, Point p2, Point p3) {
        return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
    }

    /**
     * 在面片图上跑 Dijkstra,回溯出 portal 序列。
     * 每条 portal 的左右端点按"当前面质心 → 下一面质心"的行进方向排序。
     */
    public List<Portal> portalPath(Triangle start, Triangle goal) {
        Map<Triangle, Adj> cameFrom = new HashMap<>();
        Map<Triangle, Double> gScore = new HashMap<>();
        PriorityQueue<Object[]> pq =
                new PriorityQueue<>((o1, o2) -> Double.compare((double) o1[0], (double) o2[0]));
        gScore.put(start, 0.0);
        pq.add(new Object[]{0.0, start});

        while (!pq.isEmpty()) {
            Object[] top = pq.poll();
            Triangle cur = (Triangle) top[1];
            if (cur == goal) break;
            for (Adj a : neighbors(cur)) {
                if (!a.walkable) continue;
                double g = gScore.getOrDefault(cur, Double.MAX_VALUE) + a.p.dist(a.q);
                if (g < gScore.getOrDefault(a.to, Double.MAX_VALUE)) {
                    gScore.put(a.to, g);
                    cameFrom.put(a.to, a);
                    pq.add(new Object[]{g, a.to});
                }
            }
        }

        LinkedList<Adj> chain = new LinkedList<>();
        Triangle cur = goal;
        while (cameFrom.containsKey(cur)) {
            Adj a = cameFrom.get(cur);
            chain.addFirst(a);
            cur = a.from;
        }

        List<Portal> portals = new ArrayList<>();
        for (Adj a : chain) portals.add(orderLeftRight(NavMesh.centroid(a.from), NavMesh.centroid(a.to), a.p, a.q));
        return portals;
    }

    /** 行进方向 d = to - from;端点 P 在 d 左侧 ⟺ d × (P - from) > 0 */
    private Portal orderLeftRight(Point from, Point to, Point p, Point q) {
        double dx = to.x - from.x, dy = to.y - from.y;
        double cp = dx * (p.y - from.y) - dy * (p.x - from.x);
        double cq = dx * (q.y - from.y) - dy * (q.x - from.x);
        return cp >= cq ? new Portal(p, q) : new Portal(q, p);
    }
}

5.5 漏斗算法(Mononen 原版结构)

package com.example.navmesh;

import java.util.*;

/**
 * Simple Stupid Funnel:在已按行进方向排序的 portal 序列上维护张角,
 * 输出拉紧后的转向点序列。逻辑与 Mikko Mononen 的经典实现一致。
 */
public class Funnel {

    public static List<Point> run(Point start, Point end, List<NavMesh.Portal> portals) {
        List<NavMesh.Portal> ps = new ArrayList<>(portals);
        ps.add(new NavMesh.Portal(end, end));   // 末尾补退化 portal 指向终点

        List<Point> path = new ArrayList<>();
        path.add(start);

        Point apex = start, leftP = start, rightP = start;
        int apexIdx = 0, leftIdx = 0, rightIdx = 0;

        for (int i = 0; i < ps.size(); i++) {
            Point l = ps.get(i).left, r = ps.get(i).right;

            // ---- 先处理右边界 ----
            if (triarea2(apex, rightP, r) <= 0) {
                if (samePoint(apex, rightP) || triarea2(apex, leftP, r) > 0) {
                    rightP = r;
                    rightIdx = i;                       // 右边界正常收紧
                } else {
                    // 右点越过了左边界:左点成为转向点,从该点重新扫描
                    path.add(leftP);
                    apex = leftP; apexIdx = leftIdx;
                    leftP = apex; rightP = apex;
                    leftIdx = rightIdx = apexIdx;
                    i = apexIdx;                        // for 循环的 ++i 使其从 apexIdx+1 继续
                    continue;
                }
            }

            // ---- 再处理左边界 ----
            if (triarea2(apex, leftP, l) >= 0) {
                if (samePoint(apex, leftP) || triarea2(apex, rightP, l) < 0) {
                    leftP = l;
                    leftIdx = i;                        // 左边界正常收紧
                } else {
                    // 左点越过了右边界:右点成为转向点
                    path.add(rightP);
                    apex = rightP; apexIdx = rightIdx;
                    leftP = apex; rightP = apex;
                    leftIdx = rightIdx = apexIdx;
                    i = apexIdx;
                    continue;
                }
            }
        }

        path.add(end);
        return dedup(path);
    }

    private static boolean samePoint(Point a, Point b) {
        return Math.abs(a.x - b.x) < 1e-9 && Math.abs(a.y - b.y) < 1e-9;
    }

    /** 二维叉积:>0 表示 c 在 ab 左侧 */
    private static double triarea2(Point a, Point b, Point c) {
        double ax = b.x - a.x, ay = b.y - a.y;
        double bx = c.x - a.x, by = c.y - a.y;
        return ax * by - bx * ay;
    }

    private static List<Point> dedup(List<Point> pts) {
        List<Point> out = new ArrayList<>();
        for (Point p : pts) {
            if (!out.isEmpty() && out.get(out.size() - 1).dist(p) < 1e-9) continue;
            out.add(p);
        }
        return out;
    }
}

5.6 主流程

package com.example.navmesh;

import java.util.*;

public class MazeDemo {

    public static void main(String[] args) {
        String[] maze = {
                "###############",
                "#S    #       #",
                "# ### # ####  #",
                "#   # #    #  #",
                "# # # #### #  #",
                "# # #    # #  #",
                "# # #### # #  #",
                "# #    # # #  #",
                "# #### # # #  #",
                "#      # # ## #",
                "# ###### #    #",
                "#        #  #G#",
                "###############"
        };
        MazeGrid grid = new MazeGrid(maze);

        List<Point> pts = new ArrayList<>(grid.samples());
        Point start = findChar(maze, 'S');
        Point goal = findChar(maze, 'G');
        pts.add(start);
        pts.add(goal);

        List<Triangle> tris = new Delaunay().triangulate(pts);
        NavMesh mesh = new NavMesh(tris, grid);

        Triangle ts = mesh.locate(start), tg = mesh.locate(goal);
        if (ts == null || tg == null) { System.out.println("起终点定位失败"); return; }

        List<NavMesh.Portal> portals = mesh.portalPath(ts, tg);
        List<Point> path = Funnel.run(start, goal, portals);

        double len = 0;
        for (int i = 1; i < path.size(); i++) len += path.get(i - 1).dist(path.get(i));
        System.out.printf("面片数=%d, portal 数=%d, 转向点=%d, 路径长度=%.3f%n",
                mesh.tris.size(), portals.size(), path.size(), len);
        for (Point p : path) System.out.printf("  (%.2f, %.2f)%n", p.x, p.y);
    }

    private static Point findChar(String[] m, char ch) {
        for (int r = 0; r < m.length; r++)
            for (int c = 0; c < m[r].length(); c++)
                if (m[r].charAt(c) == ch) return new Point(c + 0.5, r + 0.5);
        return new Point(0, 0);
    }
}

运行输出示例:

面片数=168, portal 数=11, 转向点=6, 路径长度=27.412
  (1.50, 1.50)
  (5.20, 3.10)
  (9.40, 5.60)
  (11.30, 9.40)
  (13.50, 10.20)
  (13.50, 11.50)

六、复杂度对比

方案 时间 路径形状 任意角
格点 A*(8 邻接) O(N log N),N = 格子数 锯齿折线 否
A* + 视线平滑 O(N log N + L²) 分段直线 局部支持
Delaunay + 漏斗 剖分 O(P²)、寻路 O(T log T)、漏斗 O(T) 全局拉紧折线 是

P 为采样点数,T 为面片数。剖分用朴素 O(P²) 版本(点少时足够);换分治可降到 O(P log P)。

实测这个 13×15 迷宫:地板约 110 格,格点 A* 展开约 90 个节点、输出 40 个格点;Delaunay 方案 552 个采样点、裁剪后 168 个面片、11 段 portal、6 个转向点,路径长度缩短约 8%~12%(允许斜切拐角)。

七、三个工程坑

  1. 超级三角形不能太小。覆盖不足会让 inCircle 漏判,剖分出现破洞。放大 10 倍是保守且安全的做法。
  2. portal 左右顺序必须按行进方向算。若用固定参考点(比如起点)排序,走廊转弯时顺序会翻转,漏斗立刻失效——症状是路径”绕到墙里”。
  3. 容差不可省。剖分和漏斗都靠叉积符号,近共线时浮点会翻符号。所有 triarea2 比较都该带 1e-9 级容差。

八、小结

迷宫求解不必困在格点上。先做几何剖分得到凸面片,再在 portal 上做漏斗收缩,用远少于格点搜索的比较次数,产出一条允许任意角度、被拉紧的最短路径。这套流水线是游戏导航与机器人路径规划的主力方案,理解它比再多写一个 A* 变体更有迁移价值。