返回博客

Canvas 动画剧烈闪烁?你把 RAF 时间戳当相位用了

给网站做了一个流动波浪背景,预期是柔和的曲线缓缓移动。跑起来一看——画面在疯狂抽搐,像一张被压缩到极限的噪点图每帧都在重新随机采样。同一帧里,用「累加变量」驱动的正弦波完全正常,只有「噪声调制」部分在发疯。

这个对比很有意思:两段代码同时运行,一段好、一段坏,说明问题不在渲染管线,而在相位数学本身。

现象

  • requestAnimationFrame 循环正常运行,无报错。
  • 基于 Math.sin(... + phase) 的波形平滑流动。
  • 基于 valueNoise2D(x, t * waveSpeed * 80) 的噪声调制部分每帧跳变,视觉上是高频闪烁/抽搐。
  • 降低 waveSpeed 能稍微缓解但无法根治。

根因

requestAnimationFrame 回调收到的参数 tperformance.now() 的返回值——从页面加载到当前帧的绝对毫秒数,典型值是 123456.78

每一帧的 Δt ≈ 16ms(60fps),那么:

每帧噪声坐标增量 = Δt × waveSpeed × 80 ≈ 16 × 0.003 × 80 ≈ 3.84 噪声单位/帧

噪声场通常在 0–1 的范围内有意义,跳 3.84 单位意味着每帧都在采样完全不相关的区域——这正是闪烁的物理根因。

坑:t 是绝对时间戳,不是相位增量
// 错误写法 — t 是 performance.now() 的绝对值(如 123456ms)
const animate = (t) => {
  const noiseVal = valueNoise2D(nx * 2 + i * 3.7, t * waveSpeed * 80 + i);
  //                                               ^^^^^^^^^^^^^^^^^^^^^^^^
  //  t 每帧增加 ~16,乘以系数后跳量巨大 → 每帧采样不同噪声区域 → 闪烁
  requestAnimationFrame(animate);
};

同一函数里若有 phase += waveSpeed * 6(累加),那段反而正常——这就是判断根因的关键线索。

正解

彻底抛弃对 t 的乘法运算,改为维护一个每帧累加小增量phase 变量:

let phase = 0;
 
const draw = () => {
  // ...画布清空 & 路径初始化...
 
  for (let i = 0; i < waveCount; i++) {
    const noiseVal = valueNoise2D(nx * 2 + i * 3.7, phase + i); // 用 phase,不用 t
    const y = H * 0.5 + Math.sin(nx * Math.PI * 3 + phase + offset) * A + noiseVal * B;
    // ...
  }
 
  phase += waveSpeed * 6; // 每帧只加一小步,稳定、可预测
};
 
const animate = () => {
  draw(); // draw 不需要 t 参数
  rafId = requestAnimationFrame(animate);
};

phase 从 0 开始,每帧只移动 waveSpeed * 6(通常 0.01~0.1 量级),噪声采样坐标的帧间跳变量也是同一数量级——波形自然流动。

正解:累加相位,而非时间戳乘法
  • let phase = 0 + phase += speed,不依赖 RAF 回调的 t 参数。
  • 增量大小决定动画速度;0.01–0.2/帧 是合理区间。
  • 若需帧率无关:phase += speed * (dt / 16.67),但对背景动画通常不必要。
  • draw() 不再需要接收 t,签名更干净。
同类陷阱:GLSL uTime

WebGL shader 里若用 uTime = t * K(K 较大)驱动 FBM/噪声,会有完全相同的问题。正确做法是传入 uTime = t * 0.001(秒级),保持频率参数在合理范围。

一句话外卖

RAF 的 t 是绝对时间戳,只适合做帧间 Δt 计算;噪声/相位坐标必须用累加变量驱动,永远不要把大数乘以系数当采样坐标用。