美术:你要什么格式的动画?我?我也不知道我要什么啊

315 阅读13分钟

前言

来新年了,去年年底接了一个业务需求,要给网页做一个入场动画。整这一出,正常的第一反应也就是来一个视频放背景得了,反正只是动画,也不需要播放声音,能给用户看见炫酷的效果就行了。

但是依照之前的经验,直接用视频在pc还好,但是在移动端的各种浏览器上,有时候总会出现一些你不希望看见的表现,比如我在小米浏览器上总会出现视频控件,找了一堆方式我最后没能成功去掉。如果有朋友知道方法希望可以赐教一下。

然后我就开始回顾我浅薄的职业生涯,发现自己做动画仅仅用了以下几种方式。

视频

  • 优点

    • 视觉表现力强:视频能够承载丰富的动态信息,支持高分辨率、高帧率、高质量音频,无论是细腻的色彩过渡、复杂的光影变化,还是震撼的音效搭配,都能营造出沉浸式的体验。例如用于大型活动的宣传网页,播放精美的开场视频,瞬间抓住用户眼球,传递活动的精彩氛围。
    • 文件压缩率高:相较于同样时长的 GIF 动画,视频格式(如 MP4、WebM )经过专业编码压缩,文件体积更小。像 H.264、H.265 等编码标准,在保证画质的同时,大幅削减数据量,利于页面快速加载,尤其适合移动端网络场景。
    • 丰富的交互控制:借助 HTML5 的 <video> 标签,能轻松实现播放、暂停、音量调节、进度条拖动等交互功能,部分浏览器还支持画中画模式,满足用户多样化的观看需求。例如在线课程网页,学生可以自主控制视频播放进程,随时暂停做笔记。
    • 广泛的格式支持与兼容性:主流的视频格式 MP4、WebM 等,被绝大多数现代浏览器支持,保障了大部分用户无需额外插件就能观看视频,覆盖人群范围广。
  • 缺点

    • 兼容性仍存挑战:尽管主流浏览器支持常见视频格式,但仍有部分老旧浏览器版本,或是小众浏览器存在兼容性问题,可能需要额外处理来适配,增加开发成本,这里特别说一下,在我使用小米浏览器访问的时候视频的进度条没办法解决,经常遇到全屏的动画如果用视频的话会有进度条的问题。
    • 性能消耗大:视频解码播放对设备的 CPU、GPU 运算能力要求较高,尤其是高清、高帧率视频,在性能较差的设备上容易出现卡顿、掉帧,甚至无法播放的情况,移动端设备受此影响更为明显。
    • 加载与缓冲延迟:即使经过压缩,较大的视频文件初次加载时,仍可能需要较长的缓冲时间,要是网络不稳定,频繁卡顿、缓冲会严重影响用户体验。

GIF

gif的优点真的是显而易见的,包括:

  • 实现简单:使用方式极为简便,只需用 <img> 标签引入,如 <img src="example.gif" alt="动画">,无需编写复杂的 CSS 动画代码或者 JavaScript 脚本,就能快速为页面添加动态效果,开发成本低、效率高吗,或者直接设置为background属性值都可以。
  • 兼容性强:几乎被所有主流浏览器支持,包括老旧版本,所以不用担心用户因浏览器版本问题无法查看动画效果,能覆盖非常广泛的用户群体。
  • 动画效果直观:很适合展示一些短小、循环的简单动画场景,像 loading 提示、简单的图标动态效果,能够直观地传达信息,让用户一眼就明白当前页面的状态或者元素的动态特性。

一搬简单的内容为了图省事,美术给我这种格式的动画我也是很欢喜的嘛。

但是gif的缺点同样是明显的包括:

  • 文件体积大:GIF 采用无损压缩,对比其他动画格式,相同时长和画质下,文件往往更大。这会拖慢页面加载速度,尤其在移动网络环境下,用户可能要等待较长时间才能看到动画,影响体验。
  • 画质有限:GIF 最多支持 256 种颜色,色彩表现力差,对于色彩丰富、过渡细腻的场景,容易出现色块、色阶丢失,导致画面失真、模糊,无法呈现高质量视觉效果。
  • 缺乏交互性:它仅仅是单纯播放预设好的动画,没办法像 JavaScript 动画那样根据用户交互(如点击、滚动)实时改变动画状态、暂停、回放,限制了动画在交互场景下的应用。
  • 性能消耗:由于 GIF 的解码方式,浏览器渲染时会占用较多 CPU 资源,长时间播放大规模的 GIF 动画,电脑或移动设备容易出现发热、卡顿等性能问题,特别是在性能较弱的设备上。

说实话,有时候有很多项目的生命周期很短,而且所需要的动画内容也不大的情况下,这种文件体积和性能消耗可以忽略不计,该用我还是要用,但是画质有限就非常致命了。而且gif的透明支持度很差,基本上你想做出半透明的效果是不可能的,所以且不论用户在不在意你得色彩表现,单是产品那关你就做不了。

而这次我的需求恰恰是不能被其所接纳的。

序列帧动画

序列帧动画算是应对 GIF 不足的一个不错选择,它有自身不少亮眼的特性:

  • 画质表现:序列帧动画由一系列的静态图片组成,常见格式有 PNG、JPEG 等。像 PNG 支持高达 32 位的色彩深度,还能呈现高精度的透明度,这意味着无论是色彩绚丽的场景,还是需要细腻半透明过渡效果的元素,都能完美展现,画质远超 GIF。例如制作一个精美的光影流动特效,用 PNG 序列帧,光影的层次与通透感可以栩栩如生。
  • 灵活控制:基于 JavaScript 与 CSS 来驱动序列帧动画,这赋予了它极强的交互性。可以轻松响应诸如点击、滚动、悬停这类用户交互行为,实现暂停、播放、倒放,或是切换动画速度等复杂操作。要是做一个可交互的动画故事,用户点击画面就能决定剧情走向,序列帧动画就能很好驾驭。
  • 压缩优势:尽管是多张图片,但利用现代的图片压缩技术,如 WebP 格式(既能无损也能有损压缩 ),整体文件体积常常能比 GIF 更小。尤其对于较长的动画,这种体积优化在提升页面加载速度上效果显著,减少用户等待时长。
  • 性能优化:得益于浏览器的渲染机制优化,在合适的帧率设置下,序列帧动画的渲染对 CPU 资源占用更合理。相较于 GIF,长时间播放也不太容易引发设备过热、卡顿的状况,能保障流畅的观看体验。

当然,序列帧动画也并非十全十美:

  • 实现复杂:需要编写 JavaScript 代码或是 CSS 动画规则来管理图片序列的切换,从加载图片资源、控制播放顺序到设置帧率,整体开发流程要比直接用 GIF 复杂得多,开发成本更高,耗时也会久一些。
  • 资源管理:作为一组图片集合,序列帧动画意味着要处理多张图片资源,这增加了资源管理的难度。

序列帧具体实现方式

使用lottie-web 播放JSON动画

这种动画方式去年在面临无法使用JSON的时候找到的,但是最终没有应用到线上,其实这种方式的本质依旧是序列帧的播放。只不过由lottie-web这个库代替我们做了加载序列帧的动作,最后渲染的方式是渲染为svg

  • 矢量图形的无限缩放:因为最终渲染为 SVG,作为矢量图形,无论如何放大或缩小动画,都不会出现像素化、模糊的情况。这对于需要适配不同屏幕尺寸与分辨率的项目至关重要。
<template>
  <div ref='animation1' class="size"></div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import lottie from "lottie-web";
import json001 from "@/assets/json/data.json";
 json001.assets.forEach((item,index) => {
   if (item) {
     item.u = '';
     if (item.w && item.h) {
      let imgUrl = `../../assets/json/images/${item.p}`;
      // 这里注意要使用 new URL(imgUrl, import.meta.url).href 动态引入图片路径
     item.p = new URL(imgUrl, import.meta.url).href;
    }
   }
 });
 onMounted(() => {
  const animation = lottie.loadAnimation({
     container: animation1.value,//选择渲染dom
     renderer: "svg",//渲染格式
    loop: true,//循环播放
    autoplay: true,//是否i自动播放,
     animationData: json001,//渲染动效json
   });
 });
</script>

动态图片内容加载

这里使用vue实现,该方式的原理就是通过动态修改img 标签的src属性来实现动画,为了保持动画的连贯性我们必须确保全部图片都已经被加载了再进行动画播放。不然必然会造成卡帧的现象。

// 用于存储当前显示帧图片的路径
const currentFrameSrc = ref<string>(''); 
// 用来存储整个序列帧图片路径的数组 
const frameImagePaths = ref<string[]>([]); 
// 记录当前显示的帧索引 
let frameIndex = 0; 
// 序列帧的总帧数 
const totalFrames = 43;
// 图片缓存相关逻辑
// 用于缓存已经加载过的图片,减少重复加载
const imageCache: { image: HTMLImageElement; timestamp: number }[] = [];
// 设定缓存的最大容量,防止缓存无限制增长
const MAX_CACHE_SIZE = 50;

// 将图片添加到缓存数组的函数
// 参数 img 是要缓存的 HTMLImageElement 图片对象
const addToCache = (img: HTMLImageElement) => {
const currentTime = Date.now();
// 创建一个包含图片和当前时间戳的对象,加入缓存数组
imageCache.push({ image: img, timestamp: currentTime });
// 如果缓存数组超出最大容量,移除最早加入的图片(简单模拟LRU策略)
if (imageCache.length > MAX_CACHE_SIZE) {
    imageCache.shift();
}

图片缓存相关逻辑

const imageCache: { image: HTMLImageElement; timestamp: number }[] = [];
const MAX_CACHE_SIZE = 50;

const addToCache = (img: HTMLImageElement) => {
  const currentTime = Date.now();
  imageCache.push({ image: img, timestamp: currentTime });
  if (imageCache.length > MAX_CACHE_SIZE) {
    imageCache.shift();
  }
}

const getFromCache = (imgPath: string): HTMLImageElement | undefined => {
  const index = imageCache.findIndex((cached) => cached.image.src === imgPath);
  if (index!== -1) {
    if (index!== -1) {
      const cachedImage = imageCache.splice(index, 1)[0];
      cachedImage.timestamp = Date.now();
      imageCache.push(cachedImage);
      return cachedImage.image;
    }
    return undefined;
  }
}
  • imageCache是一个数组,用于缓存已经加载过的图片对象及其加载时间戳。
  • MAX_CACHE_SIZE定义了缓存的最大容量为 50。
  • addToCache函数用于将图片添加到缓存数组中,同时记录当前时间戳,如果缓存数组超出最大容量,会移除最早加入的图片,简单模拟了 LRU(最近最少使用)策略。
  • getFromCache函数用于从缓存中获取图片,如果找到对应路径的图片,会将其从原位置移除并更新时间戳后返回,否则返回undefined
  1. 预加载图片的函数
const loadImagePaths = (): Promise<void> => {
  const imagePromises: Promise<void>[] = [];
  for (let i = 0; i < totalFrames; i++) {
    const imgPath = `/${props.imgUrl}/${props.imgUrl}${i}.webp`;

    const cachedImage = getFromCache(imgPath);
    if (cachedImage) {
      frameImagePaths.push(cachedImage);
      continue;
    }

    const img = new Image();
    img.src = imgPath;
    const imageLoadPromise = (): Promise<void> => {
      return new Promise<void>((resolve, reject) => {
        img.onload = () => {
          addToCache(img);
          resolve();
        };
        img.onerror = (error) => {
          console.error(`图片加载失败: ${imgPath}`, error);
          reject(error);
        };
      });
    };
    imagePromises.push(imageLoadPromise);
  }
  return Promise.all(imagePromises).then(() => {
    frameImagePaths.value = frameImagePaths.value.concat(imagePromises.map((_, i) => {
      return `/${props.imgUrl}/${props.imgUrl}${i}.webp`;
    }));
  });
}
  • 遍历所有帧,对于每个帧的图片路径,先尝试从缓存中获取图片,如果缓存中存在则直接将其添加到frameImagePaths数组中,否则创建一个新的Image对象并设置其src属性为图片路径,同时创建一个Promise用于处理图片的加载和错误情况,将所有帧的加载Promise添加到imagePromises数组中。
  • 最后使用Promise.all等待所有图片加载完成后,将加载成功的图片路径添加到frameImagePaths数组中。

最终代码

<template>
  <div class="animation-container">
    <slot name="audio"></slot>
    <img ref="animationImg" :src="currentFrameSrc" alt="动画帧" class="animation-img">
  </div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';

// 定义组件接收的属性
const props = defineProps({
  imgUrl: {
    type: String,
    required: true
  },
  start: {
    type: Boolean,
    default: true
  },
  animationSpeed: {
    type: Number,
    default: 1
  }
})

// 用于存储当前显示帧图片的路径,是响应式数据
// 这样当它的值改变时,相关依赖的 DOM 会自动更新
const currentFrameSrc = ref<string>('');
// 用来存储整个序列帧图片路径的数组
const frameImagePaths = ref<string[]>([]);
// 记录当前显示的帧索引
let frameIndex = 0;
// 序列帧的总帧数
const totalFrames = 43;

// 图片缓存相关逻辑
// 用于缓存已经加载过的图片,减少重复加载
const imageCache: { image: HTMLImageElement; timestamp: number }[] = [];
// 设定缓存的最大容量,防止缓存无限制增长
const MAX_CACHE_SIZE = 50;

// 将图片添加到缓存数组的函数
// 参数 img 是要缓存的 HTMLImageElement 图片对象
const addToCache = (img: HTMLImageElement) => {
  const currentTime = Date.now();
  // 创建一个包含图片和当前时间戳的对象,加入缓存数组
  imageCache.push({ image: img, timestamp: currentTime });
  // 如果缓存数组超出最大容量,移除最早加入的图片(简单模拟LRU策略)
  if (imageCache.length > MAX_CACHE_SIZE) {
    imageCache.shift();
  }
}

// 从缓存中获取图片的函数
// 参数 imgPath 是要查找的图片路径
// 返回值是缓存中的 HTMLImageElement 图片对象,如果没找到则返回 undefined
const getFromCache = (imgPath: string): HTMLImageElement | undefined => {
  // 查找缓存数组中是否有对应路径的图片
  const index = imageCache.findIndex((cached) => cached.image.src === imgPath);
  if (index !== -1) {
    // 如果找到,将该图片对象从原位置移除
    if (index !== -1) {
      const cachedImage = imageCache.splice(index, 1)[0];
      cachedImage.timestamp = Date.now();
      imageCache.push(cachedImage);
      return cachedImage.image;
    }
    return undefined;
  }

  // 预加载图片的函数,返回一个 Promise,全部加载完成后 resolve
  const loadImagePaths = (): Promise<void> => {
    const imagePromises: Promise<void>[] = [];
    // 遍历所有帧,创建加载每个帧图片的 Promise
    for (let i = 0; i < totalFrames; i++) {
      const imgPath = `/${props.imgUrl}/${props.imgUrl}${i}.webp`;

      const cachedImage = getFromCache(imgPath);
      if (cachedImage) {
        frameImagePaths.push(cachedImage);
        continue;
      }

      const img = new Image();
      img.src = imgPath;
      const imageLoadPromise = (): Promise<void> => {
        return new Promise<void>((resolve, reject) => {
          img.onload = () => {
            addToCache(img);
            resolve();
          };
          img.onerror = (error) => {
            console.error(`图片加载失败: ${imgPath}`, error);
            reject(error);
          };
        });
      };
      imagePromises.push(imageLoadPromise);
    }
    return Promise.all(imagePromises).then(() => {
      frameImagePaths.value = frameImagePaths.value.concat(imagePromises.map((_, i) => {
        return `/${props.imgUrl}/${props.imgUrl}${i}.webp`;
      }));
    });
  }

  // 切换动画帧的函数
  // 用于更新当前显示帧的图片路径,并移动到下一帧
  const switchFrame = (): void => {
    currentFrameSrc.value = frameImagePaths.value[frameIndex];
    frameIndex = (frameIndex + props.animationSpeed) % totalFrames;
    if (frameIndex < 0) {
      frameIndex += totalFrames;
    }
  }

  // 使用 requestAnimationFrame 播放动画
  // requestAnimationFrame 能优化动画性能,让动画更流畅
  let animationFrameId = 0;
  const playAnimation = () => {
    switchFrame();
    animationFrameId = requestAnimationFrame(playAnimation);
  }

  // 监听 props 变化,当 imgUrl 或 start 属性改变时
  // 重新加载图片,保证动画显示正确
  watch(() => [props.imgUrl, props.start], (newValues, oldValues) => {
    if (newValues[0] !== oldValues[0] || newValues[1] !== oldValues[1]) {
      loadImagePaths().then(() => {
        switchFrame();
        playAnimation();
      });
    }
  })

  onMounted(() => {
    // 组件挂载后,开始预加载图片
    loadImagePaths().then(() => {
      // 预加载完成后,先切换到初始帧
      switchFrame();
      // 开始播放动画
      playAnimation();
    });

    onBeforeUnmount(() => {
      // 组件销毁前,取消正在执行的动画帧请求
      // 防止内存泄漏和不必要的性能消耗
      if (animationFrameId) {
        cancelAnimationFrame(animationFrameId);
      }
    });
  })
</script>

这样已经是一个可以实现需求的组件的,具体的传入值等内容大家可以根据自己需求调整props 和 样式内容。

但是因为我的需求是还需要去监听用户手指滑动事件去播放第二段动画,按照我最初的想法就是两端动画直接做到一个canvas上,然后等用户滑动屏幕直接做两段动画之间的切换。于是我又放弃上面的方法,进入到序列帧实现的第三张方式canvas

Canvas实现序列帧动画

这个方式和上面动态修改图片src的方式思路是一致的,在代码实现方面动态加载图片和动画使用requestAnimationFrame 这部分基本上不需要做什么调整,所以我这里也不再贴更多的代码内容。主要给大家写一下播放和绘制动画的方法

因为我做的是全屏动画,所以要在进入页面的时候先重置canvas的宽高,当然如果不是全屏动画也要根据设备的分辨率处理一下,大家根据动画要求的范围自己调整就好了。

  <div class="animation-container">
    <canvas ref="canvas" id="canvas"
    style="position:absolute; top:0; left:0; width:100%; height:100%;"></canvas>
  </div>
    // 重新调整 canvas 的大小
    const resizeCanvas = () => {
      const dpr = window.devicePixelRatio || 1;
      if (canvas.value) {
        canvas.value.width = window.innerWidth * dpr;
        canvas.value.height = window.innerHeight * dpr;
      }
    }

实现的主要方法分为三个,包括渲染动画帧,实际上就是用canvas把需要的图片画出来买这个大家都会的

// 渲染动画帧
const drawFrame = (ctx: CanvasRenderingContext2D, images: HTMLImageElement[], index: number) => {
  if (!ctx) return index;
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  const img = images[index];
  const scaleX = ctx.canvas.width / img.width;
  const scaleY = ctx.canvas.height / img.height;
  const scale = Math.max(scaleX, scaleY);
  const drawWidth = img.width * scale;
  const drawHeight = img.height * scale;
  const offsetX = (ctx.canvas.width - drawWidth) / 2;
  const offsetY = (ctx.canvas.height - drawHeight) / 2;
  ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
  return (index + 1) % images.length;
}

然后是播放动画方法

// 播放动画
const playAnimation = (canvasElement: HTMLCanvasElement | null, images: HTMLImageElement[], speed: number) => {
  // 获取canvas的2D渲染上下文
  const ctx = canvasElement?.getContext('2d');
  // 上下文不存在时提前返回
  if (!ctx) return;

  // 基础帧率设置(帧/秒)
  const baseFps = 30;
  // 根据速度计算帧间隔时间(毫秒)
  const frameInterval = Math.round(1000 / (baseFps * speed));
  // 记录上次绘制时间戳
  let lastTime = 0;
  // 当前播放的帧索引
  let currentIndex = 0;

  // 定义动画绘制函数
  const draw = () => {
    // 获取当前时间戳
    const now = performance.now();
    // 判断是否达到帧间隔时间
    if (now - lastTime >= frameInterval) {
      // 绘制当前帧并更新索引
      currentIndex = drawFrame(ctx, images, currentIndex);
      // 记录本次绘制时间
      lastTime = now;
    }
    // 请求下一帧动画
    requestAnimationFrame(draw);
  };
  // 启动动画循环
  draw();
}

最后加一个控制播放动画的方法就好了,frameImages是loadImagePaths方法的返回值

// 控制播放动画
const playSingleAnimation = async () => {
  if (frameImages.value.length > 0 && canvas.value) {
    await resizeCanvas();
    playAnimation(canvas.value, frameImages.value, animationSpeed.value);
  }
}

序列帧动画的实现方式说到这里基本可以满足大家需要了,当然还有其它的方法,比如通过background-position 的动画变化实现。

总结

在网页动画实现方案的探讨中,我们对比了视频、GIF 和序列帧动画这三种常见方式。

在实际项目中,应根据具体需求和场景权衡选择合适的动画实现方式。若追求视觉效果和交互性,且对兼容性和性能要求较高,序列帧动画是不错的选择;若项目生命周期短、动画内容简单,GIF 可能更便捷;而视频在满足特定条件下,也能发挥其独特优势。

然后大家可以根据自己的业务需求和需要去跟美术或者动画师沟通,拿到符合业务场景的动画格式。

这篇文章的篇幅太长了,但是关于实现方案的办法其实不仅仅如此,后续我会再分享一些方案解决对应的业务问题