You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何让Canvas滚动驱动的序列帧图像动画更流畅?

优化滚动驱动序列帧动画,实现类似ClearMotion网站的流畅效果

Hey there! I’ve worked on similar scroll-driven sequence animations before, so let’s walk through how to tweak your code to get that buttery-smooth feel like the ClearMotion site. Your existing code has the right core idea, but we can fix a few performance bottlenecks and add some robustness.


1. 修复图片预加载逻辑(避免部分加载导致卡顿)

你当前的循环只是创建Image对象,但没有跟踪图片是否完全加载完成。如果动画在所有帧加载完毕前启动,很容易出现卡顿。我们来添加一个带完成追踪的预加载流程:

const totalImages = 200;
const images = [];
let loadedCount = 0;

function preloadImages() {
  for (let i = 1; i <= totalImages; i++) {
    const img = new Image();
    // 更简洁的3位序号格式化方式
    const slug = String(i).padStart(3, '0');
    img.src = `https://s3.amazonaws.com/clearmotion/hero/high-min/frame${slug}-low.jpg`;
    
    img.onload = () => {
      loadedCount++;
      // 只有当所有帧都加载完成后,才初始化动画
      if (loadedCount === totalImages) {
        initScrollAnimation();
      }
    };
    
    // 添加错误处理,捕获加载失败的帧
    img.onerror = (e) => console.error(`加载第${i}帧失败:`, e);
    
    images.push(img);
  }
}

2. 节流滚动事件(避免主线程过载)

滚动事件的触发频率极高(每秒可达数百次),如果每一次事件都执行绘制逻辑,会阻塞主线程导致动画卡顿。用节流函数限制更新频率(目标是60fps,所以设置16ms间隔):

function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  }
}

3. 用requestAnimationFrame同步绘制

把节流和requestAnimationFrame结合,确保Canvas绘制和浏览器的刷新周期对齐,消除因更新时机错位导致的视觉卡顿:

function initScrollAnimation() {
  const c = document.getElementById("background");
  const ctx = c.getContext("2d");
  
  // 一次性设置Canvas尺寸匹配帧的大小
  c.width = images[0].width;
  c.height = images[0].height;

  const updateFrame = () => {
    // 计算滚动进度
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    const maxScroll = document.body.scrollHeight - window.innerHeight;
    
    // 计算帧索引并做边界检查(避免数组越界错误)
    const frameIndex = maxScroll === 0 
      ? 0 
      : Math.max(0, Math.min(totalImages - 1, Math.floor((scrollTop / maxScroll) * (totalImages - 1))));
    
    // 绘制当前帧
    const currentImg = images[frameIndex];
    ctx.clearRect(0, 0, c.width, c.height);
    ctx.drawImage(currentImg, 0, 0);
  };

  // 结合节流与requestAnimationFrame,兼顾性能与流畅度
  const throttledScrollHandler = throttle(() => {
    requestAnimationFrame(updateFrame);
  }, 16);

  // 绑定滚动事件处理器
  window.addEventListener('scroll', throttledScrollHandler);
  // 立即绘制第一帧
  updateFrame();
}

4. 额外优化:针对大数量帧的内存优化

如果200帧的初始加载或内存占用过高,可以实现帧懒加载:只加载当前帧前后的若干帧。不过如果追求全程无卡顿,预加载所有帧仍是最优选择——记得确保图片已经过压缩(比如用WebP格式),减少文件大小。


完整优化代码

结合DOM就绪检查的最终版本:

// 等待DOM完全加载后再启动
document.addEventListener('DOMContentLoaded', () => {
  const totalImages = 200;
  const images = [];
  let loadedCount = 0;

  function preloadImages() {
    for (let i = 1; i <= totalImages; i++) {
      const img = new Image();
      const slug = String(i).padStart(3, '0');
      img.src = `https://s3.amazonaws.com/clearmotion/hero/high-min/frame${slug}-low.jpg`;
      
      img.onload = () => {
        loadedCount++;
        if (loadedCount === totalImages) {
          initScrollAnimation();
        }
      };
      
      img.onerror = (e) => console.error(`加载第${i}帧失败:`, e);
      images.push(img);
    }
  }

  function throttle(func, limit) {
    let inThrottle;
    return function(...args) {
      if (!inThrottle) {
        func.apply(this, args);
        inThrottle = true;
        setTimeout(() => inThrottle = false, limit);
      }
    }
  }

  function initScrollAnimation() {
    const c = document.getElementById("background");
    const ctx = c.getContext("2d");
    
    c.width = images[0].width;
    c.height = images[0].height;

    const updateFrame = () => {
      const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
      const maxScroll = document.body.scrollHeight - window.innerHeight;
      
      const frameIndex = maxScroll === 0 
        ? 0 
        : Math.max(0, Math.min(totalImages - 1, Math.floor((scrollTop / maxScroll) * (totalImages - 1))));
      
      const currentImg = images[frameIndex];
      ctx.clearRect(0, 0, c.width, c.height);
      ctx.drawImage(currentImg, 0, 0);
    };

    const throttledScrollHandler = throttle(() => {
      requestAnimationFrame(updateFrame);
    }, 16);

    window.addEventListener('scroll', throttledScrollHandler);
    updateFrame();
  }

  preloadImages();
});

内容的提问来源于stack exchange,提问作者Deryckxie

火山引擎 最新活动