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 为什么”点到即止”的直觉会失效
如果小鸟正下方恰好是间隙中心,那么”掉到中心就点一下”是最优的——这看起来像个简单的阈值控制器。但真实情况有三点破坏它:
- 冲量不可调:一次点击固定抬升
Δv,很容易超调(overshoot),越过间隙上沿。 - 延迟:动作在下一帧才生效,而管道在水平方向持续逼近,留给你的时间窗不断缩短。
- 目标在移动:间隙中心随管道推进而改变,”中心”本身是个动态跟踪目标。
这三点正是 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 行 | 低 | 中 | 不稳定 |
三点判断:
- 模型已知时,解析控制器优先。Flappy 的动力学就是
y'' = g,一个二阶系统,PID 天然适配。用强化学习去学一个”已经知道答案”的问题,属于拿大炮打蚊子。 - PID 的上限在”无模型”处。若加入随机风、管道移动、小鸟惯性等扰动,PID 的固定增益会吃力,此时 MPC(模型预测控制)或学习类方法才有优势。
- 自适应是低成本的鲁棒性来源。Flappy 的难度体现在间隙收窄,把这条”难度信号”反馈给增益,等于用一行公式获得了参数整定的效果。
七、复杂度与工程细节
| 环节 | 复杂度 | 说明 |
|---|---|---|
| PID 计算 | O(1) |
每帧常数,无状态爆炸 |
| 目标选择 | O(P) |
P 为屏幕上管道数,通常 ≤ 6 |
| 碰撞检测 | O(P) |
可优化为只看最近的两根 |
| 内存 | O(P) |
无经验回放、无模型参数 |
三个容易踩的坑:
dt必须固定。PID 的微分项对dt敏感,若帧率波动而dt用实际间隔,Kd会时大时小。工程上固定dt = 1/60,物理用固定步长累加,渲染再插值——这也是所有物理引擎的标准做法。- 符号约定统一。前面提过:向下为正时”向上冲”是负速度。一旦混用,
Kp和Kd会打对台,表现是”越调越抖”。 - 积分项要不要。Flappy 不存在持续偏差(重力已被内环抵消),积分项往往只会带来滞后。从 PD 开始,只在确认存在稳态误差时才加 I,这条经验适用于绝大多数游戏控制场景。
八、总结
Flappy Bird 这个”一键游戏”,恰好是控制工程的最小试验场:
- 建模先行:先写出
y'' = g与冲量模型,才知道该用几阶控制器; - 串级解耦:位置环给出”想要的下降速度”,速度环负责”怎么点”,两者分工明确,比单环容易整定;
- 参考轨迹平滑:目标位置做插值,避免阶跃指令引起的超调;
- 参数随工况自适应:把”间隙高度”这一难度信号接入增益计算,用几行代码换取鲁棒性。
如果说强化学习是”用数据逼近一个未知函数”,那么 PID 是”用一个已知结构去驯服一个已知系统”。在游戏 AI 的谱系里,这两条路没有高下,只有适用边界——而判断边界的能力,比会写哪个算法更重要。