一、构建 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(...);
优化方式:合并绘制批次!我们将 Alive
和 Dead
的细胞分别绘制,只设置两次 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 bench
和 perf 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 说话,不要拍脑袋猜测性能瓶颈!