从 60 FPS 掉帧到 7.6 倍提速Rust + WebAssembly 优化《生命游戏》的实战指南

发布于:2025-04-12 ⋅ 阅读:(42) ⋅ 点赞:(0)

一、构建 FPS 统计器:用 performance.now() 实时观察性能变化

要优化,就要先 测量。我们在 JavaScript 端添加一个 fps 对象,结合 performance.now() 来监控每一帧的耗时,并统计最近 100 帧的平均 FPS、最小 FPS、最大 FPS:

const fps = new class {
  constructor() {
    this.fps = document.getElementById("fps");
    this.frames = [];
    this.lastFrameTimeStamp = performance.now();
  }

  render() {
    const now = performance.now();
    const delta = now - this.lastFrameTimeStamp;
    this.lastFrameTimeStamp = now;
    const fps = 1 / delta * 1000;

    this.frames.push(fps);
    if (this.frames.length > 100) this.frames.shift();

    const min = Math.min(...this.frames);
    const max = Math.max(...this.frames);
    const mean = this.frames.reduce((a, b) => a + b, 0) / this.frames.length;

    this.fps.textContent = `FPS\nlatest = ${Math.round(fps)}\navg = ${Math.round(mean)}\nmin = ${Math.round(min)}\nmax = ${Math.round(max)}`;
  }
};

将该 fps.render() 方法插入每一帧的 renderLoop 循环后,我们可以在页面上实时看到性能变化。

二、Rust 中用 web_sys::console::time 精准记录函数耗时

通过封装一个 RAII 的 Timer 工具结构,我们可以在 Rust 的 Universe::tick 方法中精确测量每一步耗时:

pub struct Timer<'a> {
    name: &'a str,
}
impl<'a> Timer<'a> {
    pub fn new(name: &'a str) -> Self {
        console::time_with_label(name);
        Self { name }
    }
}
impl<'a> Drop for Timer<'a> {
    fn drop(&mut self) {
        console::time_end_with_label(self.name);
    }
}

这样,在 Universe::tick() 函数中只需写一行:

let _timer = Timer::new("Universe::tick");

配合浏览器性能工具的 Waterfall / Flamegraph,我们可以看到每一步操作在哪花了时间。

三、定位真正的瓶颈:不是 tick,而是 Canvas fillStyle

当我们把宇宙网格从 64x64 扩大到 128x128 时,FPS 从 60 降到 40,查看性能分析发现,每帧竟然有 40% 的时间耗费在设置 ctx.fillStyle 上!

代码如下,每个细胞都设置一次 fillStyle:

ctx.fillStyle = cells[idx] === DEAD ? DEAD_COLOR : ALIVE_COLOR;
ctx.fillRect(...);

优化方式:合并绘制批次!我们将 AliveDead 的细胞分别绘制,只设置两次 fillStyle,FPS 瞬间恢复到 60:

ctx.fillStyle = ALIVE_COLOR;
绘制所有活细胞

ctx.fillStyle = DEAD_COLOR;
绘制所有死细胞

四、误判的优化假设:clone vector 实际并不慢

我们尝试将每一帧的 tick 次数从 1 提高到 9,结果 FPS 掉到 35。原以为是频繁分配新 vector 开销大,于是在 Universe::tick() 中加了三段 Timer

  • allocate next cells
  • new generation
  • free old cells

结果发现主要耗时根本不在 vector 分配,而是全部集中在“计算下一代细胞”上。

五、找出真正的热点:mod 运算占了 40% 时间!

通过 cargo benchperf record 工具,我们对原生代码进行 profiling,发现:

  • live_neighbor_count() 中的 % 模运算(用于边界环绕)非常慢,占了 30%+ 的时间!

于是我们手动展开环绕逻辑,用 if 替代 %,并将 8 个邻居全部展开,不再用循环:

let north = if row == 0 { self.height - 1 } else { row - 1 };
let south = if row == self.height - 1 { 0 } else { row + 1 };
// ...
let nw = self.get_index(north, west);
count += self.cells[nw] as u8;
// 重复 8 次

六、最终提速效果

优化前后对比:

$ cargo benchcmp before.txt after.txt

 name            before.txt ns/iter  after.txt ns/iter  diff ns/iter   diff %  speedup
 universe_ticks  664,421             87,258                 -577,163  -86.87%   x 7.61

性能提升高达 7.6 倍!浏览器中的 FPS 也恢复到 60,并且每帧耗时只有 10ms 左右。

七、下一步优化方向(建议实践)

✅ 实现双缓冲机制:避免频繁分配 vector
✅ 实现增量渲染(delta-based):仅绘制状态变化的 Cell
✅ 替换为 WebGL 渲染器:显卡绘图 + GPU 加速!

总结

从最开始的掉帧,到深入时间剖析、重构逻辑、使用系统级性能工具、替换关键路径操作,整个过程体现了 现代 WebAssembly 性能优化的实践路线。最重要的经验是:

永远让 profiler 说话,不要拍脑袋猜测性能瓶颈!


网站公告

今日签到

点亮在社区的每一天
去签到