前言:动效播放可选的技术方案
谈起web上的动效播放,大家可能会想起一系列的方案,Svga、Lottie、Spine(后续计划讲一讲)、序列帧动画、PAG...(gif/apng/webp格式就不说了,资源体积是硬伤,只适合简单动图预览)。
还有一种比较冷门的动效播放--视频动效播放,当然只是播一段不透明的视频未免场景也太过于小众,可能只是在公司官网这种页面里能见到,但是!但是!如果带上透明度的视频播放是不是就比较炫酷了,而且使用场景大大增加,可以与svga媲美,但如果只是跟svga一样,那为什么还需要带通道的视频动效播放?答案只有一个,那当然是同等条件下表现比SVGA要好。
视频素材如下,左边是动画通道,右边是动画画面

小试牛刀:用Canvas2D实现视频动效播放器
实现思路
先创建一个离线的视频播放,然后再创建一个离线的画布,将视频播放内容逐帧绘制到离线的画布上,然后分别取画布上左半边和右半边的像素数据,进行透明通道值替换(左边部分使用RGB通道存储了原透明视频的Alpha值,右边部分使用RGB通道存储了原透明视频的RGB,通过重新将每个像素点的Alpha值和RGB值进行组合,重新得到RGBA视频画面,实现透明视频的动画效果),最后就是将处理后的画布数据绘制到视觉画布上就OK了。
借个图来说明:图片来源
实现步骤
1、创建video播放标签
ts
// 创建video元素
this.videoScript = document.createElement("video");
this.videoScript.width = opts.width * 2;
this.videoScript.height = opts.height;
this.videoScript.controls = false;
this.videoScript.playsInline = true;
this.videoScript.loop = opts.loop || false;
this.videoScript.autoplay = false;
this.videoScript.muted = true;
this.videoScript.crossOrigin = "Anonymous";
// @ts-ignore
this.videoScript["x5-video-player-type"] = "h5";
this.videoScript.src = url;
this.videoScript.addEventListener('loadedmetadata', () => {
// const videoWidth = this.videoScript.videoWidth;
// const videoHeight = this.videoScript.videoHeight;
// console.log('视频宽度:', videoWidth);
// console.log('视频高度:', videoHeight);
this.isReady = true;
if (opts.loadedCallback) opts.loadedCallback();
if (opts.autoplay) this.play();
});
this.videoScript.addEventListener("ended", this.handleEnd);
2、创建一个离屏canvas,用于绘制video
ts
const offlineCanvas = document.createElement("canvas");
offlineCanvas.width = opts.width * dpr * 2;
offlineCanvas.height = opts.height * dpr;
this.offlineCanvas = offlineCanvas;
this.offlineContext = offlineCanvas.getContext("2d", {
willReadFrequently: true,
});
3、播放的时候取离线画布的数据,并进行通道值计算,将计算后的画布数据放到视觉中的画布进行渲染
ts
animation() {
// 视频未准备好,不做处理
if (!this.isReady) {
this.rafId = requestAnimationFrame(() => {
this.animation();
});
return;
}
const { context, offlineContext } = this;
if (!context || !offlineContext) {
console.error("canvas context is null");
return;
}
// 先获取离线画布的数据,然后绘制到主画布上
const oW = this.offlineCanvas!.width;
const oH = this.offlineCanvas!.height;
offlineContext.drawImage(this.videoScript, 0, 0, oW, oH);
const imgdata2 = offlineContext.getImageData(0, 0, oW / 2, oH);
const imgdata1 = offlineContext.getImageData(oW / 2, 0, oW / 2, oH);
// 将imgdata2的alpha通道数据赋值给imgdata1的alpha通道数据
// 补充一下,此处imgdata2的R就是imgdata1需要的A
const data2 = imgdata2.data;
for (let i = 3, n = data2.length; i < n; i += 4) {
imgdata1.data[i] = data2[i - 3];
}
const newImageData = imgdata1;
context.putImageData(newImageData, 0, 0, 0, 0, oW / 2, oH);
// 如果正在播放中,继续下一帧的绘制
if (this.isPlaying) {
this.rafId = requestAnimationFrame(() => {
this.animation();
});
}
}
方案总结
整个过程是不是很简单呢?但是你先别着急高兴,该方案采用CPU计算消耗极大,笔者曾经试过用wasm解决,但未果,原因在于性能瓶颈是在
context.getImageData
上,该步骤需要将画布图像数据复制到CPU内存,获取的是RGBA四通道数据(每个像素4字节),1920x1080画布约8MB数据,因此一不小心就会有Failed to execute 'getImageData' on 'CanvasRenderingContext2D': Out of memory at ImageData creation
的报错
终极杀器:用WebGL实现视频动效播放器
实现思路
底层原理与Canvas2D一致,只是视频像素数据的处理放到了webgl去实现,webgl对色彩的处理有着天然的优势(GPU加速、以及通过GLSL着色器,开发者可以自定义色彩处理逻辑等)。
所以仍需要创建一个离线的视频播放用来获取视频里的像素信息,然后需要获取WebGL上下文,并绑定顶点着色器和片元着色器,接下来就是绘制时更新纹理数据
实现步骤
1、创建video播放管理器,便于后续控制播放
ts
export interface VideoOptions {
useBlob?: boolean,
width: number,
height: number,
loop?: boolean
autoplay?: boolean,
endCallBack?: () => any,
updateCallBack?: () => void,
errorCallBack?: () => void
onError?: (err?: ErrorEvent) => void,
}
export default class VideoPlayer {
public videoScript: HTMLVideoElement;
public ready: boolean = false; // 视频是否就绪
private opts: VideoOptions;
private playing: boolean = false; // 视频正在播放中
private timeupdate: boolean = false;
constructor(src: string, opts: VideoOptions) {
this.opts = opts || <VideoOptions>{};
// 创建视频播放器
this.videoScript = document.createElement("video");
this.videoScript.width = opts.width * 2; // 宽度是画布的双倍
this.videoScript.height = opts.height;
this.videoScript.controls = false;
this.videoScript.playsInline = true;
this.videoScript.loop = opts.loop || false;
this.videoScript.autoplay = false;
this.videoScript.muted = true;
this.videoScript.crossOrigin = "Anonymous";
// @ts-ignore
this.videoScript["x5-video-player-type"] = "h5";
// 是否使用blob方式加载视频
if (opts.useBlob) {
this.loadVideoBlob(src).then((res: string) => this.videoScript.src = res)
.catch((err: any) => {
onsole.error('video error', err);
if (opts.onError) opts.onError(err);
})
} else {
this.videoScript.src = src;
}
// 监听视频事件
this.videoScript.addEventListener('canplay', this.onReadyPlay.bind(this));
this.videoScript.addEventListener("playing", this.playingEventHandler.bind(this));
this.videoScript.addEventListener("timeupdate", this.timeupdateEventHandler.bind(this));
this.videoScript.addEventListener("ended", this.onEndedPlay.bind(this));
this.videoScript.addEventListener('error', this.onErrorPlay.bind(this));
}
private onReadyPlay() {
//
}
private onErrorPlay(err: ErrorEvent) {
if (this.opts.onError) this.opts.onError(err);
}
private onEndedPlay() {
if (this.opts.endCallBack) this.opts.endCallBack();
}
private playingEventHandler() {
this.playing = true;
this.checkReady();
}
private timeupdateEventHandler() {
this.timeupdate = true;
this.checkReady();
}
/**
* 检查视频是否就绪
*/
private checkReady() {
if (this.playing && this.timeupdate) {
this.ready = true;
}
}
private loadVideoBlob(url: string) {
return fetch(url).then(res => res.blob()).then(res => {
return URL.createObjectURL(res);
});
}
/**
* 开始视频播放
*/
public play() {
this.videoScript.currentTime = 0;
this.videoScript.play();
}
/**
* 恢复视频播放
*/
public resume() {
this.videoScript.play();
}
/**
* 暂停视频播放
*/
public pause() {
this.videoScript.pause();
}
/**
* 销毁视频播放器
*/
public dispose() {
this.pause();
this.videoScript.removeEventListener('canplay', this.onReadyPlay.bind(this));
this.videoScript.removeEventListener("playing", this.playingEventHandler.bind(this));
this.videoScript.removeEventListener("timeupdate", this.timeupdateEventHandler.bind(this));
this.videoScript.removeEventListener("ended", this.onEndedPlay.bind(this));
this.videoScript.removeEventListener('error', this.onErrorPlay.bind(this));
this.videoScript.remove();
}
}
2、获取webgl context,并初始化Shader和顶点缓存区,这块是实现视频透明播放的核心逻辑,从片元着色器代码可以看出,我们取了纹理右边的rgb,和左边的r用作alpha,合成了rgba的vec4(片元着色器里的坐标是0-1之间的浮点数,0.5即是纹理的中间位置)
ts
// 伪代码,获取WebGL Context
this.gl = canvas.getContext("webgl")!;
const VSHADER_SOURCE = `
attribute vec4 a_Position;
attribute vec2 a_uv;
uniform mat4 u_Translation;
varying vec2 uv;
void main() {
gl_Position = a_Position;
uv= a_uv;
}
`;
const FSHADER_SOURCE = `
precision mediump float;
uniform sampler2D uSampler;
varying vec2 uv;
void main() {
vec3 color = texture2D( uSampler, vec2(0.5 + uv.x/2.0, uv.y) ).rgb;
float alpha = texture2D( uSampler, vec2(uv.x/2.0, uv.y) ).r;
gl_FragColor = vec4(color,1.0) * alpha;
}
`;
// 创建着色器程序
private _initShaders(vshader: string, fshader: string) {
const program = this._createProgram(vshader, fshader);
this.gl.useProgram(program);
this._program = program;
}
// 初始化顶点缓冲区
private _initVertexBuffers() {
// 定义顶点坐标和UV坐标
const vertextList = new Float32Array([-1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, -1.0]);
const uvList = new Float32Array([0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0]);
// 绑定到GPU缓冲区
this._initArrayBuffer("a_Position", vertextList, 2, gl.FLOAT);
this._initArrayBuffer("a_uv", uvList, 2, gl.FLOAT);
}
3、每帧渲染时,将video帧数据绑定到纹理
ts
public updateTexture(dom: HTMLVideoElement) {
const { gl } = this;
const level = 0;
const internalFormat = gl.RGBA;
const srcFormat = gl.RGBA;
const srcType = gl.UNSIGNED_BYTE;
gl.bindTexture(gl.TEXTURE_2D, this._texture);
// 将视频帧绑定到纹理
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, dom);
}
至此已基本实现了动画的绘制,播放控制部分就不一一贴代码了,这里稍微再科普一下GLSL里的顶点坐标系和纹理坐标系,帮助理解着色器代码
顶点坐标系:
纹理坐标系:
方案总结
经过实测,同动画同机型条件下该方案性能略优于SVGA
注意点📢
部分IOS系统版本不支持视频的自动播放,要求用户必须有点击操作才可以,这块需要大家使用的时候注意一下,可以使用
can-autoplay
包进行条件探测,也可以通过context state探测,类似startaudiocontext
包实现的那样