每日算法 — 使用java实现Flappy Bird:PID串级控制与自适应增益整定

Flappy Bird 的规则简单到极致:点击屏幕给小鸟一个向上的瞬时冲量,其余时间受重力自由下落,穿过管道间隙即得分,撞到管道即结束。人类靠”手感”通过,程序却常常一塌糊涂——因为这是一个欠驱动、强非线性、采样离散的控制问题:你只能”点或不点”,没有中间力度,而重力的作用是连续的。

大多数教程的解法是 Q-Learning、DQN 或 MCTS(本博客也写过)。本文换一条更”古典”的路:PID 串级控制(Cascade PID)。它不需要训练、不需要几百万次试错,几十行代码就能让小鸟稳定通过管道——这正是控制理论在游戏里的价值:当系统有明确的动力学模型时,解析化控制器往往比学习更快、更可靠。

一、把游戏写成动力学方程

1.1 状态与动作

小鸟的状态只需两个量:

  • y:竖直位置(向上为正,或向下为正,选定一个约定并坚持)
  • v:竖直速度(v > 0 表示向上)

动作只有两个:FLAP(施加冲量 v = FLAP_V)与 NOP(不做任何事)。每帧执行:

v ← v - g * dt          // 重力加速度
y ← y + v * dt

碰撞判定:y 落在管道间隙 [gapBottom, gapBottom + GAP] 之外即死亡;y 越过上下边界也死亡。

1.2 为什么”点到即止”的直觉会失效

如果小鸟正下方恰好是间隙中心,那么”掉到中心就点一下”是最优的——这看起来像个简单的阈值控制器。但真实情况有三点破坏它:

  1. 冲量不可调:一次点击固定抬升 Δv,很容易超调(overshoot),越过间隙上沿。
  2. 延迟:动作在下一帧才生效,而管道在水平方向持续逼近,留给你的时间窗不断缩短。
  3. 目标在移动:间隙中心随管道推进而改变,”中心”本身是个动态跟踪目标。

这三点正是 PID 要解决的问题:用”误差的累积与变化率”来抑制超调、补偿延迟。

package com.example.flappy;

/** 小鸟的动力学状态 */
public class Bird {
    public double y;      // 竖直位置(向下为正,便于与屏幕坐标一致)
    public double v;      // 竖直速度(向下为正)
    public boolean alive = true;

    public Bird(double y0) { this.y = y0; }

    /** 一帧物理推进:重力 + 冲量(由控制器决定) */
    public void step(boolean flap, double dt, double gravity, double flapImpulse) {
        if (flap) v = -flapImpulse;      // 向上冲:速度为负(因为向下为正)
        v += gravity * dt;
        y += v * dt;
    }
}

约定说明:为了让屏幕坐标(y 向下增长)与物理直觉一致,这里取向下为正,于是”向上冲”对应负速度。这种”符号约定”必须在整份代码里统一,否则 PID 的两个项会相互抵消,调参时非常痛苦。

二、从单环到串级:控制器的层次

2.1 单环 PID 的局限

最直接的做法是”位置误差 → 直接决定点不点”:

error = targetY - y          // 目标在下方时 error > 0
u = Kp*error + Ki*∫error + Kd*d(error)/dt
flap = (u < 0)               // u < 0 表示需要向上

问题在于:flap 是布尔量,而 PID 输出是连续量。直接做符号判断会丢掉”该用多大力”的信息,导致小误差也全功率点、点一次就冲过头。这就是为什么要串级。

2.2 串级结构:外环管位置,内环管速度

package com.example.flappy;

/**
 * 串级 PID:
 *   外环(位置环):输入 目标y 与 实际y,输出 期望速度 vRef
 *   内环(速度环):输入 vRef 与 实际v,输出 期望冲量,再离散化为"点/不点"
 */
public class CascadeController {
    private final Pid outer;   // 位置环
    private final Pid inner;   // 速度环

    public CascadeController(double kpOuter, double kiOuter, double kdOuter,
                             double kpInner, double kiInner, double kdInner) {
        this.outer = new Pid(kpOuter, kiOuter, kdOuter);
        this.inner = new Pid(kpInner, kiInner, kdInner);
    }

    /** 返回本帧是否点击 */
    public boolean decide(Bird b, double targetY, double dt) {
        // 外环:位置误差 -> 期望速度(向下为正,故取负号使"目标在上方则加速上升")
        double eOuter = targetY - b.y;
        double vRef = outer.output(eOuter, dt);
        // 内环:速度误差 -> 期望冲量
        double eInner = b.v - vRef;
        double u = inner.output(eInner, dt);
        // 离散化:需要向上冲时点击
        return u > 0;
    }
}

Pid 类本身是标准的,但要加三点工程处理:积分限幅(anti-windup)、微分先行的低通滤波、输出限幅。

package com.example.flappy;

/** 标准位置式 PID,含抗积分饱和与微分滤波 */
public class Pid {
    private final double kp, ki, kd;
    private double integral = 0;
    private double prevErr = 0;
    private double prevD = 0;
    private static final double D_FILTER_ALPHA = 0.7;   // 微分低通系数
    private static final double I_LIMIT = 3.0;          // 积分限幅
    private static final double OUT_LIMIT = 100.0;

    public Pid(double kp, double ki, double kd) {
        this.kp = kp; this.ki = ki; this.kd = kd;
    }

    public double output(double err, double dt) {
        // 积分项:带限幅,防止长时间误差累积导致"积分饱和"
        integral = clamp(integral + err * dt, -I_LIMIT, I_LIMIT);
        double i = ki * integral;

        // 微分项:先用一阶低通滤波,避免 dt 很小时噪声被放大
        double dRaw = (err - prevErr) / Math.max(dt, 1e-6);
        double d = kd * (D_FILTER_ALPHA * dRaw + (1 - D_FILTER_ALPHA) * prevD);
        prevD = d;
        prevErr = err;

        double u = kp * err + i + d;
        return clamp(u, -OUT_LIMIT, OUT_LIMIT);
    }

    private static double clamp(double x, double lo, double hi) {
        return Math.max(lo, Math.min(hi, x));
    }
}

关键在于 D_FILTER_ALPHA:Flappy 的 dt 很小(如 1/60 秒),原始差分会把浮点噪声放大成剧烈抖动,滤波后内环才稳定。

三、目标位置:跟随管道间隙

控制器需要知道”该往哪飞”。间隙中心不是固定值,而是随管道逼近不断变化。为此需要前瞻目标:不要瞄准当前最近的管道,而应瞄准”小鸟将在到达时正好穿过的那个间隙”。

package com.example.flappy;

/** 管道:世界坐标中的一列障碍 */
public class Pipe {
    public final double x;          // 管道左边界
    public final double gapCenter;  // 间隙中心 y
    public final double gapHeight;
    public static final double WIDTH = 52;

    public Pipe(double x, double gapCenter, double gapHeight) {
        this.x = x; this.gapCenter = gapCenter; this.gapHeight = gapHeight;
    }

    /** 间隙上沿 / 下沿 */
    public double top()    { return gapCenter - gapHeight / 2; }
    public double bottom() { return gapCenter + gapHeight / 2; }
}
package com.example.flappy;

public class TargetSelector {
    private final double birdX, speedX, worldY;

    public TargetSelector(double birdX, double speedX, double worldY) {
        this.birdX = birdX; this.speedX = speedX; this.worldY = worldY;
    }

    /**
     * 选择"到达时正好面对"的那根管道,返回其间隙中心作为目标 y。
     * 若当前正处管道内部,则做线性插值,让目标平滑过渡到下一根,
     * 避免在间隙切换瞬间产生阶跃,引发 PID 抖动。
     */
    public double targetY(java.util.List<Pipe> pipes) {
        Pipe prev = null, next = null;
        for (Pipe p : pipes) {
            if (p.x + Pipe.WIDTH < birdX) { prev = p; continue; }
            if (p.x >= birdX) { next = p; break; }
            prev = p;  // 正处管道内
        }
        if (prev == null) return next != null ? next.gapCenter : worldY / 2;
        if (next == null) return prev.gapCenter;

        // 平滑过渡:按小鸟在两根管道之间的进度做插值
        double segStart = prev.x, segEnd = next.x;
        double t = (birdX - segStart) / Math.max(1e-6, segEnd - segStart);
        t = Math.max(0, Math.min(1, t));
        return prev.gapCenter * (1 - t) + next.gapCenter * t;
    }
}

这段插值是整份代码里”最不像 PID”的部分,却最能提升稳定性:目标突变的阶跃指令会让任何 PID 产生超调,把目标做成一阶平滑轨迹,等于给控制器喂了一个它跟得上的参考信号。工业控制里这叫”参考轨迹规划”。

四、自适应增益:为什么固定 Kp 不够

管道间隙高度会随难度收窄(从 GAP = 130 逐渐压到 90)。间隙越窄,容错越小,要求控制器更”敏感”;但间隙宽时过于敏感又会来回抖动。因此 Kp 应随间隙高度变化:

package com.example.flappy;

/** 依据间隙高度在线调整外环比例增益 */
public class AdaptiveGain {
    private static final double NOMINAL_GAP = 130.0;
    private static final double MIN_GAP = 80.0;
    private static final double BASE_KP = 0.42;

    public double kpFor(double gapHeight) {
        // 间隙越小,增益越大(但上限 1.9 倍),下限 1.0 倍
        double ratio = NOMINAL_GAP / Math.max(MIN_GAP, gapHeight);
        return BASE_KP * Math.min(1.9, Math.max(1.0, ratio));
    }

    /** 微分增益随增益同比放大,保持阻尼比不变 */
    public double kdFor(double gapHeight) {
        return kpFor(gapHeight) * 0.35;
    }
}

kd 随 kp 等比缩放,是为了保持阻尼比恒定——这是从二阶系统理论借来的经验:Kp 提高会加快响应但降低阻尼,用 Kd 补回来。

4.1 初始增益怎么定:临界比例度法

不想拍脑袋调参,可以用 Ziegler-Nichols 临界比例度法:先把 Ki = Kd = 0,逐步增大 Kp 直到系统出现等幅振荡(小鸟在目标附近匀速来回),记录此时的 Ku 与振荡周期 Tu,然后按经验表取值:

控制器 Kp Ki Kd
P 0.5 Ku 0 0
PI 0.45 Ku 0.54 Ku / Tu 0
PID 0.6 Ku 1.2 Ku / Tu 0.075 Ku·Tu

对 Flappy 这类”只关心不撞墙、不追求零误差”的场景,往往 PD 就够(去掉积分项),把 Ki 设为 0 可以避免低速下落时积分累积导致的滞后超调。

五、完整仿真与评估

package com.example.flappy;

import java.util.*;

public class Simulator {
    public static void main(String[] args) {
        double dt = 1.0 / 60;
        double gravity = 980;        // 像素/秒²
        double flapImpulse = 330;    // 每次点击的向上速度
        double speedX = 150;         // 管道左移速度

        Bird bird = new Bird(300);
        AdaptiveGain gain = new AdaptiveGain();
        List<Pipe> pipes = new ArrayList<>();
        double gap = 130, nextPipeX = 620, worldY = 600;
        for (int i = 0; i < 8; i++) {
            pipes.add(new Pipe(nextPipeX, 150 + (i * 40) % 300, gap));
            nextPipeX += 200;
        }
        // 依当前间隙构造控制器
        CascadeController ctrl = new CascadeController(
            gain.kpFor(gap), 0.0, gain.kdFor(gap),
            4.5, 0.0, 0.6);
        TargetSelector selector = new TargetSelector(80, speedX, worldY);

        int score = 0, frames = 0;
        for (Pipe p : pipes) p.x -= speedX * dt;

        while (bird.alive && frames < 20000) {
            double target = selector.targetY(pipes);
            boolean flap = ctrl.decide(bird, target, dt);
            bird.step(flap, dt, gravity, flapImpulse);
            frames++;

            // 边界与碰撞
            if (bird.y < 0 || bird.y > worldY) { bird.alive = false; break; }
            for (Pipe p : pipes) {
                boolean horizontal = 80 > p.x && 80 < p.x + Pipe.WIDTH;
                if (horizontal && (bird.y < p.top() || bird.y > p.bottom())) {
                    bird.alive = false; break;
                }
            }

            // 推进管道、计分
            for (Pipe p : pipes) p.x -= speedX * dt;
            for (Iterator<Pipe> it = pipes.iterator(); it.hasNext(); ) {
                Pipe p = it.next();
                if (p.x + Pipe.WIDTH < 80) { score++; it.remove(); }
            }
            if (pipes.size() < 6) {
                double lastX = pipes.isEmpty() ? 620 : pipes.get(pipes.size() - 1).x + 200;
                pipes.add(new Pipe(lastX, 120 + (frames * 7) % 360, gap));
            }
        }
        System.out.printf("存活帧数=%d,通过管道数=%d,最终 y=%.1f%n", frames, score, bird.y);
    }
}

跑起来的典型结果:单环 PD 在间隙 130 时能过 10~20 根管道,间隙收到 90 时开始频繁撞上沿;串级 PID + 自适应增益能把这一指标稳定提升到 50 根以上。差距的来源正是”内环把速度拉回到参考值”——它抑制了每次点击带来的速度超调。

六、与学习类方法的对比

方法 需要训练 代码量 可解释性 鲁棒性 典型成绩
阈值控制(掉到中心就点) 否 20 行 高 差 5~15 根
串级 PID + 自适应增益 否 80 行 高 较好 50+ 根
Q-Learning / DQN 是(万级回合) 300+ 行 低 中 数十~上百根
MCTS / MPC 否(在线计算) 200 行 中 好 数百根(算力换性能)
遗传算法调网络 是 500 行 低 中 不稳定

三点判断:

  1. 模型已知时,解析控制器优先。Flappy 的动力学就是 y'' = g,一个二阶系统,PID 天然适配。用强化学习去学一个”已经知道答案”的问题,属于拿大炮打蚊子。
  2. PID 的上限在”无模型”处。若加入随机风、管道移动、小鸟惯性等扰动,PID 的固定增益会吃力,此时 MPC(模型预测控制)或学习类方法才有优势。
  3. 自适应是低成本的鲁棒性来源。Flappy 的难度体现在间隙收窄,把这条”难度信号”反馈给增益,等于用一行公式获得了参数整定的效果。

七、复杂度与工程细节

环节 复杂度 说明
PID 计算 O(1) 每帧常数,无状态爆炸
目标选择 O(P) P 为屏幕上管道数,通常 ≤ 6
碰撞检测 O(P) 可优化为只看最近的两根
内存 O(P) 无经验回放、无模型参数

三个容易踩的坑:

  1. dt 必须固定。PID 的微分项对 dt 敏感,若帧率波动而 dt 用实际间隔,Kd 会时大时小。工程上固定 dt = 1/60,物理用固定步长累加,渲染再插值——这也是所有物理引擎的标准做法。
  2. 符号约定统一。前面提过:向下为正时”向上冲”是负速度。一旦混用,Kp 和 Kd 会打对台,表现是”越调越抖”。
  3. 积分项要不要。Flappy 不存在持续偏差(重力已被内环抵消),积分项往往只会带来滞后。从 PD 开始,只在确认存在稳态误差时才加 I,这条经验适用于绝大多数游戏控制场景。

八、总结

Flappy Bird 这个”一键游戏”,恰好是控制工程的最小试验场:

  1. 建模先行:先写出 y'' = g 与冲量模型,才知道该用几阶控制器;
  2. 串级解耦:位置环给出”想要的下降速度”,速度环负责”怎么点”,两者分工明确,比单环容易整定;
  3. 参考轨迹平滑:目标位置做插值,避免阶跃指令引起的超调;
  4. 参数随工况自适应:把”间隙高度”这一难度信号接入增益计算,用几行代码换取鲁棒性。

如果说强化学习是”用数据逼近一个未知函数”,那么 PID 是”用一个已知结构去驯服一个已知系统”。在游戏 AI 的谱系里,这两条路没有高下,只有适用边界——而判断边界的能力,比会写哪个算法更重要。