每日算法 — 使用java实现粘粘世界:质点弹簧软体物理与结构稳定性判定

粘粘世界(World of Goo)的核心玩法看似搭建,实质是在做一场连续介质力学计算:一堆有质量的粘性小球通过”粘性连杆”连接,玩家不断加高结构,让它跨越沟壑、顶住风阻、从高处垂下而不塌。判定标准很简单——只要有一根连杆承受的拉力超过粘性极限,整座塔就开始连锁崩溃。

这篇文章用纯 Java 把这套机制从零实现,重点不在画图,而在两件事:一是用 Verlet 积分 + 距离约束求解器(Position Based Dynamics)计算每根连杆的受力,二是用图论的连通性与最小生成结构判定”结构是否还撑得住”。

一、问题抽象:从游戏画面到力学模型

把每个粘豆包(Goo Ball)抽象成一个质点 p = (x, y, m),把一根粘性连杆抽象成一根理想无质量弹簧,刚度 k、自然长度 L。整座结构就是一个带结构的无向图 G=(V, E),其中同一条连杆两端的引力相互抵消,因此可以用迭代求解器快速收敛。

游戏里的关键约束有三条:

约束 物理含义 算法后果
最大拉伸比 连杆不能被拉长超过自身长度的 2 倍 硬约束,超限即断裂
最大压力比 连杆被压缩到 0.5 倍以下时就失去刚性 软约束,仅提示
锚点固定 顶部或底部的结构附着在静态物上 位移为 0,作为边界条件

真正会打断结构的只有第一条:拉伸比超过阈值。因此”断裂”是离散事件,”变形”是连续过程,两者必须分开处理。

二、核心算法一:Position Based Dynamics 求解约束

传统弹簧系统要用显式欧拉 + 阻尼,稍有不稳就爆炸。PBD 的做法是把动力学拆成两步:先积分预测,再投影约束。

积分部分采用 Verlet 形式,不需要显式保存速度:

x' = 2x - x_prev + a * dt^2

约束投影部分对每根连杆反复迭代:如果当前长度偏离自然长度,就把两端质点沿连线方向各移动一半(按质量反比分配)。

public final class Vec2 {
    public double x, y;
    public Vec2(double x, double y) { this.x = x; this.y = y; }
    public Vec2 add(Vec2 o) { return new Vec2(x + o.x, y + o.y); }
    public Vec2 sub(Vec2 o) { return new Vec2(x - o.x, y - o.y); }
    public Vec2 mul(double s) { return new Vec2(x * s, y * s); }
    public double len() { return Math.sqrt(x * x + y * y); }
    public Vec2 norm() {
        double l = len();
        return l < 1e-12 ? new Vec2(0, 0) : new Vec2(x / l, y / l);
    }
    public double dot(Vec2 o) { return x * o.x + y * o.y; }
}

public final class GooBall {
    public Vec2 pos;        // 当前位置
    public Vec2 prev;       // 上一帧位置(Verlet)
    public Vec2 force;      // 累积外力
    public double mass;     // 质量
    public double invMass;  // 质量倒数,静止锚点为 0
    public boolean anchored;

    public GooBall(Vec2 p, double m, boolean anchored) {
        this.pos = p;
        this.prev = new Vec2(p.x, p.y);
        this.force = new Vec2(0, 0);
        this.mass = m;
        this.anchored = anchored;
        this.invMass = anchored ? 0.0 : 1.0 / m;
    }
}

连杆负责记录自然长度与当前拉伸比,断裂判定就放在这里:

public final class ViscousLink {
    public final GooBall a, b;
    public final double restLength;
    public double stretchRatio = 1.0;   // 当前长度 / 自然长度
    public boolean broken = false;

    public static final double MAX_STRETCH = 2.0;   // 拉伸 2 倍即断裂
    public static final double TEAR_STIFFNESS = 0.9; // 约束收敛强度

    public ViscousLink(GooBall a, GooBall b) {
        this.a = a;
        this.b = b;
        this.restLength = a.pos.sub(b.pos).len();
    }

    /** 约束投影:把两端拉回自然长度附近,并按逆质量分配位移 */
    public void solve() {
        if (broken) return;
        Vec2 d = b.pos.sub(a.pos);
        double cur = d.len();
        if (cur < 1e-9) return;

        stretchRatio = cur / restLength;
        if (stretchRatio > MAX_STRETCH) {   // 拉力超限,直接断开
            broken = true;
            return;
        }

        double w = a.invMass + b.invMass;
        if (w < 1e-12) return;              // 两端都固定,无法调整

        // 目标长度 = 自然长度,误差按逆质量比例分配
        double correction = (cur - restLength) / cur * TEAR_STIFFNESS;
        Vec2 n = d.mul(correction);

        a.pos = a.pos.add(n.mul(a.invMass / w));
        b.pos = b.pos.sub(n.mul(b.invMass / w));
    }
}

迭代求解器把”重力积分”与”约束投影”分开,迭代次数是精度与性能的旋钮:

public final class PbdSolver {
    private final java.util.List<GooBall> balls;
    private final java.util.List<ViscousLink> links;
    private final Vec2 gravity = new Vec2(0, 980);   // 像素/秒^2
    private static final double DAMPING = 0.98;

    public PbdSolver(java.util.List<GooBall> b, java.util.List<ViscousLink> l) {
        this.balls = b;
        this.links = l;
    }

    public void step(double dt, int iterations) {
        // 1) Verlet 积分:位置由历史位置与外力共同决定
        for (GooBall g : balls) {
            if (g.anchored) continue;
            Vec2 vel = g.pos.sub(g.prev).mul(DAMPING);
            Vec2 acc = g.force.mul(g.invMass).add(gravity);
            Vec2 next = g.pos.add(vel).add(acc.mul(dt * dt));
            g.prev = g.pos;
            g.pos = next;
            g.force = new Vec2(0, 0);
        }
        // 2) 多轮约束投影:迭代越多次,连杆越"硬"
        for (int it = 0; it < iterations; it++) {
            for (ViscousLink lk : links) lk.solve();
        }
        // 3) 碰撞兜底:不允许穿透地面
        for (GooBall g : balls) {
            if (g.pos.y > GameWorld.GROUND_Y) g.pos.y = GameWorld.GROUND_Y;
        }
    }
}

这里有一个容易踩的坑:TEAR_STIFFNESS 取 1.0 会导致约束过冲抖动,取太小则结构像橡皮筋一样软塌。工程实践中 0.8~0.95 是比较稳的区间;同时迭代次数 8~16 次即可在 60fps 下保持稳定。

三、核心算法二:并查集判定结构连通性

断裂发生后,”整座塔是否还连着锚点”才是玩家最关心的问题。这正是一个动态连通性问题,用并查集(Union-Find)在近 O(1) 时间内回答。

public final class DisjointSet {
    private final int[] parent;
    private final int[] rank;

    public DisjointSet(int n) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;
    }

    public int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];  // 路径压缩(隔代压缩)
            x = parent[x];
        }
        return x;
    }

    public boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;
        if (rank[ra] == rank[rb]) rank[ra]++;
        return true;
    }
}

每帧重建一次(V+E 规模,代价可接受),然后检查锚点所在集合是否覆盖了关键支点:

public final class StructureAnalyzer {
    /** 返回仍然与锚点连通的小球数量;同时标记出悬空的残骸 */
    public static int countConnected(java.util.List<GooBall> balls,
                                     java.util.List<ViscousLink> links) {
        int n = balls.size();
        DisjointSet ds = new DisjointSet(n);
        for (ViscousLink lk : links) {
            if (lk.broken) continue;
            ds.union(balls.indexOf(lk.a), balls.indexOf(lk.b));
        }
        // 找到任意锚点作为连通性基准
        int root = -1;
        for (int i = 0; i < n; i++) {
            if (balls.get(i).anchored) { root = ds.find(i); break; }
        }
        if (root == -1) return n;   // 无锚点的自由结构

        int cnt = 0;
        for (int i = 0; i < n; i++) {
            if (ds.find(i) == root) cnt++;
        }
        return cnt;
    }
}

balls.indexOf(lk.a) 是 O(V),整体退化为 O(E·V)。生产代码应事先在 GooBall 里存索引(或直接用哈希表映射对象到下标),这样每帧重建才真正是 O(E α(V))。

四、核心算法三:基于受力的最小支撑结构分析

仅有连通性还不够——粘粘世界的难点是”结构连着,但中间那根连杆已经被拉到 1.9 倍,随时会断”。因此需要提前预测哪根连杆最危险。

对每根连杆计算相对拉伸裕度 slack = (MAX_STRETCH - stretchRatio) / MAX_STRETCH,再做一次最小生成树式的关键连杆识别:

public final class CriticalLinkFinder {
    /** 用 Kruskal 的思路:按裕度升序把连杆加入,第一条使图成环的即冗余,反之是割边候选 */
    public static java.util.List<ViscousLink> findBridges(
            java.util.List<GooBall> balls, java.util.List<ViscousLink> links) {

        int n = balls.size();
        java.util.Map<GooBall, Integer> idx = new java.util.HashMap<>();
        for (int i = 0; i < n; i++) idx.put(balls.get(i), i);

        // 按危险程度从高到低排序:裕度最小的先处理
        java.util.List<ViscousLink> sorted = new java.util.ArrayList<>(links);
        sorted.sort((x, y) -> Double.compare(slack(x), slack(y)));

        DisjointSet ds = new DisjointSet(n);
        java.util.List<ViscousLink> critical = new java.util.ArrayList<>();
        java.util.Set<ViscousLink> removed = new java.util.HashSet<>();

        // Tarjan 桥检测的等价简化:逐条移除试探连通性
        for (ViscousLink target : sorted) {
            if (target.broken || removed.contains(target)) continue;
            DisjointSet probe = new DisjointSet(n);
            for (ViscousLink lk : sorted) {
                if (lk == target || lk.broken || removed.contains(lk)) continue;
                probe.union(idx.get(lk.a), idx.get(lk.b));
            }
            if (!probe.union(idx.get(target.a), idx.get(target.b))) {
                // 移除后仍连通 => 冗余连杆,可以安全忽略
                removed.add(target);
            } else {
                critical.add(target);   // 割边:断了就分裂
            }
        }
        return critical;
    }

    private static double slack(ViscousLink lk) {
        return (ViscousLink.MAX_STRETCH - lk.stretchRatio) / ViscousLink.MAX_STRETCH;
    }
}

这段”逐条移除 + 并查集探连通”的写法对 E 较小的场景(粘粘世界单关通常几十根连杆)非常实用,最坏 O(E² α(V)),但工程上完全可以按优先级只检查前 K 根,把复杂度压到 O(K·E α(V))。

真正实现 Tarjan 桥检测可以做到 O(V+E),思路是维护 dfn/low,但因为我们还需要”按危险度排序”的语义,逐条试探反而更贴近游戏需求。

五、组装:一关的完整主循环

把三块拼起来,得到一个可以跑起来的最小关卡:底部一段固定锚点,玩家不断往上加球,结构过重就垮。

public final class GameWorld {
    public static final double GROUND_Y = 600;

    public static void main(String[] args) {
        java.util.List<GooBall> balls = new java.util.ArrayList<>();
        java.util.List<ViscousLink> links = new java.util.ArrayList<>();

        // 锚点:地面上的固定球
        GooBall a1 = new GooBall(new Vec2(200, GROUND_Y), 1.0, true);
        GooBall a2 = new GooBall(new Vec2(260, GROUND_Y), 1.0, true);
        balls.add(a1); balls.add(a2);
        links.add(new ViscousLink(a1, a2));

        // 逐层加高,每加一个球连两根杆(形成三角形,结构最稳)
        GooBall prevA = a1, prevB = a2;
        for (int level = 1; level <= 6; level++) {
            GooBall left  = new GooBall(new Vec2(200 - level * 10, GROUND_Y - level * 50), 1.5, false);
            GooBall right = new GooBall(new Vec2(260 + level * 10, GROUND_Y - level * 50), 1.5, false);
            balls.add(left); balls.add(right);
            links.add(new ViscousLink(prevA, left));
            links.add(new ViscousLink(prevB, right));
            links.add(new ViscousLink(left, right));   // 横撑:抗剪
            prevA = left; prevB = right;
        }

        PbdSolver solver = new PbdSolver(balls, links);
        double dt = 1.0 / 60;

        for (int frame = 0; frame < 600; frame++) {
            solver.step(dt, 12);

            int alive = StructureAnalyzer.countConnected(balls, links);
            if (frame % 60 == 0) {
                java.util.List<ViscousLink> critical =
                        CriticalLinkFinder.findBridges(balls, links);
                System.out.printf("帧 %d:连通球数 %d,关键连杆 %d,最长拉伸 %.2f%n",
                        frame, alive, critical.size(), maxStretch(links));
            }
            if (alive < balls.size() / 2) {
                System.out.println("结构崩解,关卡失败");
                break;
            }
        }
    }

    private static double maxStretch(java.util.List<ViscousLink> links) {
        double m = 0;
        for (ViscousLink lk : links) if (!lk.broken) m = Math.max(m, lk.stretchRatio);
        return m;
    }
}

三角形横撑不是装饰——它让结构从”链式结构”变成”刚性桁架”,重心偏移时由剪切力分摊,这是粘粘世界高手搭塔的共同直觉。算法上,它带来的效果是一根连杆断掉后仍能保持连通,即结构冗余度提升。

六、复杂度与调优

环节 复杂度 说明
Verlet 积分 O(V) 每帧一次
约束投影 O(it · E) it 为迭代次数(8~16)
并查集连通性 O(E α(V)) 路径压缩 + 按秩合并
关键连杆识别 O(E² α(V)) 或被 K 截断 单关规模足够

几个实操建议:

  1. dt 固定为 1/60,并与渲染帧分离,用累加器做定步长。变步长会让 Verlet 的稳定性彻底失效。
  2. 质量差不要超过 10 倍。逆质量差距过大时,约束投影会把位移几乎全部分配给轻的一端,导致视觉上的”甩动”。
  3. 断裂要做滞后处理:拉伸比超过 2.0 只标记 pendingBreak,连续 2~3 帧都超限才真正断,避免单帧数值抖动误判。
  4. 迭代次数自适应:结构规模大时降到 8 次,小关卡升到 16 次,帧率优先。

粘粘世界最迷人的地方在于它把”结构力学”包装成了拖拽游戏:玩家并不计算应力,但每次失败都在学习”三角形比四边形稳、长悬臂必垮、锚点要分散”。当这套直觉被翻译成 PBD 约束投影 + 并查集连通性 + 割边识别,一座塔的生死就从手感变成了可复现的计算。