web直播礼物动画

January 02, 2023

直播重要的功能之一就是送礼,礼物动画能增加直播间互动的趣味性,刺激互动与消费。此文探究一下直播网站中送礼动画的常用实现。

根据动画资源的不同,可以分为video、svga、apng、gif,也可以使用一些业界最新的动画资源例如pag等,不同的资源就有不同的实现方案。

VIDEO动画

动画可以看作是一种视频,用户送礼,播放一段动画视频,在网页对应的就能到<video>标签,但是如果直接使用video标签,就一定会有进度条、是否静音等的控制条出现,直接影响送礼的效果。因此web端是通过canvas, canvas的drawImage,可以绘制一个video对象,同时让video隐藏播放,播放的过程中逐帧drawImage到画布中。 逐帧绘制可以通过requestAnimationFrame来实现

  const video = document.getElementById('video');
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d')
  let videoWidth, videoHeight;

  function cb() {
    ctx.clearRect(0,0,  videoWidth / 2, videoHeight / 2) // 清空上一次的画布
    ctx.drawImage(video, 0, 0, videoWidth, videoHeight, 0, 0, videoWidth / 2, videoHeight / 2); // 绘制当前的video元素
    window.requestAnimationFrame(cb); // 调用下一次
  }

  video.addEventListener('play', function(e) {
    videoWidth = e.target.videoWidth;
    videoHeight = e.target.videoHeight;
    canvas.width = videoWidth / 2; // 控制画布大小
    canvas.height = videoHeight / 2; // 控制画布大小
    window.requestAnimationFrame(cb)
  });

提前下载video资源

video播放是边播放边下载的,如果播放视频卡顿,此时绘制到画布上的video也是卡顿的表现,因此需要提前下载好资源,可以通过blob的下载方式,将动画资源请求完整再播放。

let videoBlob = ''
fetch(url, {
  responseType
}).then(res => {
  if(responseType === 'blob') {
    videoBlob = res.blob();
    return res.blob(); // 将资源blob对象化
  } else if(responseType === 'arraybuffer') {
    return res.arrayBuffer();
  }
  return res;
});

// 设置video对象资源
this.videoElement.src = URL.createObjectURL(videoBlob);
this.videoElement.crossOrigin = "Anonymous";

透明视频动画

video是不带alpha通道的,具体表现就是一个视频如果有需要表现是透明的地方,渲染出来却是黑色的,而对于动画来说通常边缘都是透明的,或者是带有一层渐变,逐渐过渡到透明,因此还需要一个视频透明化的处理。

具体做法就是UI在产出动画资源的时候,会带有Alpha帧,对应说明哪些地方是有像素,哪些地方没有像素。

礼物资源示意图

右边的图层,对应左边示意是否有像素的地方,有像素的地方是非黑色,无像素的地方就是黑色。 通过canvas的getImageData获取左边和右边两个图层的像素数据,将左边图层的数据塞入右边alpha值,再通过putImageData重新设置左边图层的数据,就可以实现透明背景。

// 左边像素数据
const image = canvas.getImageData(
  0,
  0,
  width
  height
);
const imageData = image.data;

const alphaImage = canvas.getImageData(
  width,
  0,
  width
  height
);
for (let i = 3, len = imageData.length; i < len; i = i + 4) {
  imageData[i] = alphaData[i - 1];
}
canvas.clearRect(0, 0, width, height);
canvas.putImageData(image, 0, 0);

使用webgl

使用canvas会导致CPU占用高,webgl可以开启GPU渲染,CPU占用低,因此可以通过webgl来绘制动画视频。

在这里贴上一个大佬的实现,关键代码在于设置rgb值和alpha值混合 webgl 实现透明视频 动画

gl_FragColor = vec4(texture2D(u_sampler, v_texCoord).rgb, texture2D(u_sampler, v_texCoord+vec2(-0.5, 0)).r);

const textureVertice = new Float32Array([
    0.5, 1.0,
    1.0, 1.0,
    0.5, 0.0,
    1.0, 0.0
]); // 这里将纹理左半部分映射到整个画布上
gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer);

SVGA动画

video动画是一种比较静态的动画,mp4是什么样子动画结果就是什么样子,而SVGA动画可以动态设置某些元素,比方用户头像之类的,可以做一些定制化处理。因此如果动画有需要动态设置的部分,就可以考虑使用SVGA格式。

SVGA动画可以通过svga.lite库来解析

import { Parser, Player } from 'svga.lite';

const canvas = document.createElement('canvas');
const parser = new Parser({ disableWorker }); // 创建解析器
const player = new Player(canvas); // 创建播放器

// 加载动画资源
const fileData = await this.loadData(url, {
  responseType: 'arraybuffer',
});
// 解析器解析动画资源
const svgaData = await parser.do(fileData);
// 播放器装载动画资源
await player.mount(svgaData);

// 设置动态图像 一定要在开始播放前进行
player.setImage('http://yourserver.com/xxx.png', 'ImageKey');
// 设置动态文本 一定要在开始播放前进行
player.setText('Hello, World!', 'ImageKey');

// 开始播放动画
player.start();

PAG动画

PAG是腾讯推出的一套完整的动画工作流, 基于WebAssembly + WebGL。应用过程也非常简单,实例化PAG、加载素材为PAGFile对象,实例化PAGView对象,播放。

<canvas class="canvas" id="pag"></canvas>
<script src="https://cdn.jsdelivr.net/npm/libpag@latest/lib/libpag.min.js"></script>
<script>
  window.onload = async () => {
    // 实例化 PAG
    const PAG = await window.libpag.PAGInit();
    // 获取 PAG 素材数据
    const buffer = await fetch('https://pag.art/file/like.pag').then((response) => response.arrayBuffer());
    // 加载 PAG 素材为 PAGFile 对象
    const pagFile = await PAG.PAGFile.load(buffer);
    // 将画布尺寸设置为 PAGFile的尺寸
    const canvas = document.getElementById('pag');
    canvas.width = pagFile.width();
    canvas.height = pagFile.height();
    // 实例化 PAGView 对象
    const pagView = await PAG.PAGView.init(pagFile, canvas);
    // 播放 PAGView
    await pagView.play();
  };
</script>

APNG动画

APNG是基于PNG格式扩展的一种动画格式,与PNG相比它可以展示动态图像。与GIF相比它支持Alpha透明通道,而GIF不支持透明通道,边缘带有杂边。APNG在web中的使用,可以img直接设置apng格式的文件,此时不可以动态控制播放的时机。如果需要控制播放的时机,可以调用react-apng库。

import ApngComponent from 'react-apng';
const spinBoxRef = useRef();
function play() {
  spinBoxRef.current?.one();
}

<ApngComponent
  ref={spinBoxRef}
  src={APNG_SOURCE.SPIN}
/>

图形动画

直播礼物动画还有一种特殊的动画就是图形动画,比方说送100朵玫瑰就能组成一个心形之类的,简述一下实现过程。每种图形都有一个表示坐标的一个json数据,每个坐标组合起来就是一个心形。画心的过程就是canvas画布drawImage的过程。结合requestAnimationFrame就可以实现一个动态画心的过程。

离屏渲染

离屏渲染是canvas动画的一种优化方式,具体描述就是不直接在主canvas上渲染,而是有一个副的canvas,在副的canvas上绘制,然后再在主canvas上画整个副canvas。尽可能减少在主canvas调用渲染相关 API 的次数。 相当于在屏幕渲染的时候开辟一个缓冲区,将当前需要加载的动画事先在缓冲区渲染完成之后,再显示到屏幕上. 当渲染的数量非常大时,可以使用这种方法来优化,如果数量少但是又要额外开辟渲染区,这就得不偿失。

在下面的例子中,通过离屏渲染,避开每一帧频繁的调用渲染相关 API 的次数

class OffScreen {
  constructor() {
    this.canvas = document.createElement('canvas');
    this.r = 2.5;
    this.width = this.canvas.width = 5;
    this.height = this.canvas.height = 5;
    this.ctx = this.canvas.getContext('2d');
    this.x = this.width * Math.random();
    this.y = this.height * Math.random();

    this.create();
  }
  
  // 创建粒子
  create() {
    this.ctx.save();
    this.ctx.fillStyle = 'rgba(255,255,255)';
    this.ctx.beginPath();
    this.ctx.arc(this.x, this.y, 2.5, 0, 2 * Math.PI, false);
    this.ctx.closePath();
    this.ctx.fill();
    this.ctx.restore();
  }
  
  // 绘制粒子
  move(ctx, x, y) {
    ctx.drawImage(this.canvas, x, y);
  }
}

class DrawSnow {
  constructor(count,useOffCanvas) {
    ......

    this.useOffCanvas = useOffCanvas; // 是否使用离屏渲染
    this.init();
  }
    
  init() {
    let offScreen = '';

    if (this.useOffCanvas) {
      offScreen = new OffScreen();
    }

    for (let i = 0; i < this.count; i++) {
      let x = this.width * Math.random();
      let y = this.height * Math.random();

      if (this.useOffCanvas) {

        this.snowArray.push({
          instance: offScreen,
          x: x,
          y: y
        });
      } else {
        this.snowArray.push({
          x: x,
          y: y
        });
        this.draw(x, y);
      }
    }
    this.animate();
  }
  
  animate() {
    this.content.clearRect(0, 0, this.width, this.height);

    for (let i in this.snowArray) {
      let snow = this.snowArray[i];

      snow.y += 2;
      if (snow.y >= this.height + 10) {
        snow.y = Math.random() * 50;
      }

      if (this.useOffCanvas) {
        snow.instance.move(this.content, snow.x, snow.y);
      } else {
        this.draw(snow.x, snow.y);
      }
    }
    this.timer = requestAnimationFrame(() => {
      this.animate();
    });
  }

  draw() {
    this.content.save();
    this.content.fillStyle = 'rgba(255,255,255)';
    this.content.beginPath();
    this.content.arc(this.x, this.y, 2.5, 0, 2 * Math.PI, false);
    this.content.closePath();
    this.content.fill();
    this.content.restore();
  }
}

3D动画

未完待续……

相关阅读:

看你骨骼惊奇,这里有一套 Canvas 粒子动画方案了解一下?


Profile picture

百事可乐

Let it snow, let it snow, let it snow