Canvas 动画剧烈闪烁?你把 RAF 时间戳当相位用了
给网站做了一个流动波浪背景,预期是柔和的曲线缓缓移动。跑起来一看——画面在疯狂抽搐,像一张被压缩到极限的噪点图每帧都在重新随机采样。同一帧里,用「累加变量」驱动的正弦波完全正常,只有「噪声调制」部分在发疯。
这个对比很有意思:两段代码同时运行,一段好、一段坏,说明问题不在渲染管线,而在相位数学本身。
现象
requestAnimationFrame循环正常运行,无报错。- 基于
Math.sin(... + phase)的波形平滑流动。 - 基于
valueNoise2D(x, t * waveSpeed * 80)的噪声调制部分每帧跳变,视觉上是高频闪烁/抽搐。 - 降低
waveSpeed能稍微缓解但无法根治。
根因
requestAnimationFrame 回调收到的参数 t 是 performance.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计算;噪声/相位坐标必须用累加变量驱动,永远不要把大数乘以系数当采样坐标用。