直播动画方案

首先梳理web场景下,动画播放的几种方式。

方案对比

目前较常见的动画实现方案有原生动画、帧动画、gif/webp、lottie/SVGA,对于复杂动画特效的实现做个简单对比

方案优势劣势
css动画使用方便还原程度 取决工程师和 动画复杂程度; 复杂动画实现成本高
序列帧动画实现成本低还原程度中,比较固定,部分复杂特效不支持,且资源消耗大
gif实现成本低还原度低,只支持8位(256)颜色,且资源消耗大,质量较大。最重要的 不能交互
webp实现成本低还原程度中,资源消耗大;考虑IOS兼容问题,最重要的 不能交互
Lottie/SVGA实现成本低部分复杂特效不支持
透明mp4视频性能好,一次接入永久使用还原度高

css动画

在实现简单页面交互上是比较常用,当设计师提供较高要求的 动画效果,就比较挠头了。

gif

实现简单,质量较大。最重要的 不能交互

Webp

Webp相比PNG和JPEG格式体积可以减少25%,在移动端的平台支持上也很全面,支持24位RGB色;缺点是资源体积比较大,而且使用的软解效率低下,低端机上有明显卡顿问题。且在比较低版本的ios 机型存在 比较大的兼容问题。 最重要的 不能交互

Lottie​/​SVGA

在不涉及mask和mattes等特性时性能非常优秀,主要耗时基本集中在Canvas#draw()上而已。然而在设计实现复杂遮罩、光影渐变上效果一般且异常消耗性能。对于直播场景的复杂特效动画而言,他们就不是一个很合适的实现方案了。

透明mp4视频 方案

透明视频 由于出色的还原度和良好的性能,非常适合 复杂直播场景。 又详细分为以下几类:

方案优势劣势
video + css 滤色(cssmix-blend-mode: screen;​)工作量小、体积小只适用于黑色背景视频
webm透明视频原生支持兼容性差(透明只在Chrome和Opera中得到支持)
canvas 绘制透明视频动画效果好CPU占用高
webgl绘制透明视频动画效果好,CPU占用小学习成本

video + css 滤色 实现透明

实现过程:

 	一个黑色背景的 **特效图片** 或者 **视频** 覆盖在目标 层上,加上 `mix-blend-mode: screen;`​ css 样式,即可实现 透明 图片或视频 播放动画的效果。

其实现逻辑基于 混色计算方式:

C = 255 - (255-A) ✖️ (255-B) / 255

如: A 是 红色 RGB(255,0,0);  B 是 蓝色 RGB(0,0,255)

那么:

	R = 255 - (255 - 255) * (255 - 0) / 255 = 255

	G = 255 - (255 - 0) * (255 - 0) / 255 = 0

	B = 255 - (255 - 0) * (255 - 255) / 255 = 255

直观特性:

  1. 任何颜色和黑色执行滤色,还是呈现原来的颜色;
  2. 任何颜色和白色执行滤色得到的是白色;
  3. 任何颜色和其他颜色执行滤色模式混合后的颜色会更浅,有点类似漂白的效果。

详细讲解 戳 深入理解CSS mix-blend-mode滤色screen混合模式

其实这种 过于简单粗暴了,如果我们的动画 背景是 黑色透明度的呢? 上面的计算可是 没有 Alpha 通道的。这时候就要说到 绘制透明视频

webgl绘制透明视频

原理

首先看段 视频, 这是我们设计师 实际导出 的动画视频(别误会,右侧 不是出了问题)。​​​

我们会发现视频播放 右侧 类似高度曝光的视频,其实 记录的 Alpha 通道 的值,通常被放在 R通道。

所以 我们取出 右侧视频区域的R通道的值,与左侧原视频RGB值 进行重新组合,绘制 RGBA 的 图片即可。然后 一帧一帧的播放 绘制即可。即可实现 上面的 第二张图的 透明视频的动画效果。

基本原理

如果还有不明白 可以看下 这里的解释

主要有两个部分,一个是视频播放器,负责视频解码;另一个是绘制器,负责将解析出来的每一帧画面进行透明度 混合,再绘制到 Canvas 画布上。

  • 视频播放器 : 在素材是 MP4 的情况下,其实使用 浏览器的 video 就完全可以胜任了【 毕竟使用其他播放器解码又要增加代码体积了 】
  • 绘制器: 至于 到底使用canvs 还是 webGL 其实就是看性能了。毕竟 webGL直接使用 GPU硬件,性能上要高上不少。
实现
视频播放器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

const src = `https://s3.amazonaws.com/liveme.storage.test/liveglb/202004171204/gifts/resource_manage/f8d6b_md5_f71ec37a5350a219500fef89335c3be9_.mp4`
let playing = false;
const video = document.createElement("video");
video.autoplay = false;
video.mute = true;
video.volume = 0;
video.muted = true;
video.loop = loop;
video.setAttribute("x-webkit-airplay", "true");
video.setAttribute("webkit-playsinline", "true");
video.setAttribute("playsinline", "true");
video.style.display = "none";
video.src = src;
video.crossOrigin = "anonymous";
video.addEventListener("play", () => {
// 使用 requestAnimationFrame 开始绘制
window.requestAnimationFrame(() => {
drawFrame();
});
});
document.body.appendChild(video);

// 绘制 函数
function drawFrame() {
if (playing) {
// webGL 绘制
drawWebglFrame();
}
// 下一帧继续绘制
window.requestAnimationFrame(() => {
drawFrame();
});
}

绘制器

在使用 webGL 绘制前 ,我们需要了解一些概念。

  • WebGL 主要是 低级光栅化 API, 不是 3D API。要使用 WebGL 绘制图像,您必须传递表示 图像的向量。然后,它使用 OpenGL SL 将给定的 矢量 转换为 像素格式,并在屏幕上显示图像

  • 着色器

    • 着色器 是使用 OpenGL ES 着色语言(GLSL) 编写的程序,它携带着 记录着像素点的位置颜色 的信息。GLSL编写 着色器程序,并将代码文本传递给 WebGL,在 GPU 执行时编译
      着色器 分为: 顶点着色器和片段着色器

      • 顶点着色器 : 主要记录着像素点的位置, 决定了 3D 物体在屏幕上的位置和形状

        • 每次渲染一个形状时,顶点着色器 会在形状中的 每个顶点运行。
          它的工作是将 输入顶点原始坐标系转换到 WebGL 使用的 裁剪空间 坐标系,其中每个轴的坐标范围从 -1.0 到 1.0,并且不考虑纵横比,实际尺寸或任何其他因素;
          顶点着色器根据需要,也可以完成其他工作。例如,决定哪个包含 texel 面部纹理的坐标,可以应用于顶点;通过 法线 来确定 应用到顶点的光照因子等。 然后将这些信息存储在 变量(varyings)或属性 (attributes)属性中,以便与片段着色器共享。
      • 片段着色器: 主要记录着像素点的颜色

        • 片段着色器在 顶点着色器 处理完图形 的顶点后会被要绘制的每个图形的每个像素点调用一次
          它的职责是 确定像素的颜色,通过指定应用到像素的纹理元素(也就是图形纹理中的像素),获取纹理元素的颜色,然后将 适当的光照应用于颜色。之后颜色存储在特殊变量 gl_FragColor 中,返回到 WebGL 层。该颜色将最终绘制到屏幕上图形对应像素的对应位置。

基本过程

1
2
3
4
5
创建着色器(顶点、片段)程序      ====>        绘制
创建缓冲区并绑定 ||
||
读取黄缓冲区变量 ====> 赋值给顶点着色器的变量(片段着色器 从顶点着色器读取 ) ====> 绘制

那么我们的思路就是 在着色器上 使用 左侧的视频帧 为底 + 右侧视频帧 的R通道最为 A通道, 那么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

// 位置缓冲区(Position Buffer)初始化:
// [具体看 裁剪空间] 中心是0,0,0 左侧 xyz 分别在 -1单位。 右侧 xyz 分别在 1 单位。都是向量单位
const positionVertice = new Float32Array([-1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 1.0, -1.0 ]);
const positionBuffer = gl.createBuffer(); // 创建buffer
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); // 把缓冲区对象绑定到目标
gl.bufferData(gl.ARRAY_BUFFER, positionVertice, gl.STATIC_DRAW); // 向缓冲区对象写入刚定义的顶点数据
// 纹理缓冲区(Texture Buffer)初始化:
const textureBuffer = gl.createBuffer();
/*** 定义纹理区域 ***/
// 这里将纹理 右侧 部分映射到整个画布上 给着色器 读取使用
const textureVertice = new Float32Array([
0.0,1.0, // 左上角
0.5,1.0, // 右上角
0.0,0.0, // 左下角
0.5,0.0, // 右下角
]);
// 这部分根据 direction 选项(left 或 right)设置纹理坐标,用于将视频帧映射到矩形上。
gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer);


// 片元着色器, gl_FragColor 即为每像素的颜色
const vShader = `
precision lowp float; // 设置浮点数精度
varying vec2 v_texCoord; // 从顶点着色器接收的纹理坐标
uniform sampler2D u_sampler; // 纹理采样器

void main(void) {
// 设置像素颜色:
// RGB 从当前纹理坐标采样
gl_FragColor = vec4(texture2D(u_sampler, v_texCoord).rgb,
// Alpha 从偏移后的纹理坐标采样 R 通道
texture2D(u_sampler, v_texcoord+vec(0.5, 0)).r);
}
`;

如果 ,我们的资源 反过来呢? 右侧的视频帧 为底 + 左侧视频帧 的R通道最为 A通道。 如何处理?对坐标转换下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
....
// 这里将纹理 右侧 部分映射到整个画布上 给着色器 读取使用
const textureVertice = new Float32Array([
0.5, 1.0,
1.0, 1.0,
0.5, 0.0,
1.0, 0.0
]);
....
// 片元着色器, gl_FragColor 即为每像素的颜色
const vShader = `
precision lowp float; // 设置浮点数精度
varying vec2 v_texCoord; // 从顶点着色器接收的纹理坐标
uniform sampler2D u_sampler; // 纹理采样器

void main(void) {
// 设置像素颜色:
// RGB 从当前纹理坐标采样
gl_FragColor = vec4(texture2D(u_sampler, v_texCoord).rgb,
// Alpha 从偏移后的纹理坐标采样 R 通道
texture2D(u_sampler, v_texcoord+vec(-0.5, 0)).r);
}
`;

当然别忘了, canvas 在 高清屏幕 绘制模糊的问题,canvas-更正分辨率​ 当然要修正下。

好上完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273

// alpha-video-webgl.js
class AlphaVideo {
constructor(option) {
const defaultOption = {
src: "",
autoplay: true,
loop: true,
canvas: null,
// 默认透明视频展示大小
width: 375,
height: 300,
// 动画视频 所在位置,left 左,right 右
direction: "left",
onError: function () {},
onPlay: function () {},
};
this.options = {
...defaultOption,
...option,
};
this.radio = window.devicePixelRatio;


this.directionOption = {
// 纹理坐标
left: {
// 纹理坐标
textureVertice: [
0.0,1.0, // 左上角
0.5,1.0, // 右上角
0.0,0.0, // 左下角
0.5,0.0, // 右下角
],
// 纹理 偏移
fsSourceRadio: '0.5'
},

right: {
// 纹理坐标
textureVertice: [
0.5, 1.0,
1.0, 1.0,
0.5, 0.0,
1.0, 0.0
],
// 纹理 偏移
fsSourceRadio: '-0.5'
}
};

this.initVideo();
this.initWebgl();

if (this.options.autoplay) {
this.video.play();
}
}

initVideo() {
const { onPlay, onError, loop, src } = this.options;

const video = document.createElement("video");
video.autoplay = false;
video.mute = true;
video.volume = 0;
video.muted = true;
video.loop = loop;
video.setAttribute("x-webkit-airplay", "true");
video.setAttribute("webkit-playsinline", "true");
video.setAttribute("playsinline", "true");
video.style.display = "none";
video.src = src;
video.crossOrigin = "anonymous";
video.addEventListener("canplay", () => {
this.playing = true;
onPlay && onPlay();
});
video.addEventListener("error", () => {
onError && onError();
});
video.addEventListener("play", () => {
window.requestAnimationFrame(() => {
this.drawFrame();
});
});
document.body.appendChild(video);
this.video = video;
}

drawFrame() {
if (this.playing) {
this.drawWebglFrame();
}
window.requestAnimationFrame(() => {
this.drawFrame();
});
}

drawWebglFrame() {
const gl = this.gl;
// 配置纹理图像
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGB,
gl.RGB,
gl.UNSIGNED_BYTE,
this.video
);
// 绘制
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

play() {
this.playing = true;
this.video.play();
}

pause() {
this.playing = false;
this.video.pause();
}

initWebgl() {
// 设置 canvas 尺寸和点击事件
this.canvas = this.options.canvas;
this.canvas.width = this.options.width * this.radio;
this.canvas.height = this.options.height * this.radio;
this.canvas.addEventListener("click", () => {
this.play();
});
if (!this.canvas) {
this.canvas = document.createElement("canvas");
document.body.appendChild(this.canvas);
}

const gl = this.canvas.getContext("webgl");
// 设置视口大小
gl.viewport(
0,0,
this.options.width * this.radio,
this.options.height * this.radio
);
// 着色器程序设置
const program = this._initShaderProgram(gl);
gl.linkProgram(program);
gl.useProgram(program);

const buffer = this._initBuffer(gl);

// 绑定缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, buffer.position);
// 顶点位置 a_position 读取 绑定缓冲区
const aPosition = gl.getAttribLocation(program, "a_position");
// 允许属性读取,将缓冲区的值分配给特定的属性
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);


gl.bindBuffer(gl.ARRAY_BUFFER, buffer.texture);
const aTexCoord = gl.getAttribLocation(program, "a_texCoord");
gl.enableVertexAttribArray(aTexCoord);
gl.vertexAttribPointer(aTexCoord, 2, gl.FLOAT, false, 0, 0);

// 绑定纹理
const texture = this._initTexture(gl);
gl.bindTexture(gl.TEXTURE_2D, texture);

const scaleLocation = gl.getUniformLocation(program, "u_scale");
gl.uniform2fv(scaleLocation, [this.radio, this.radio]);

this.gl = gl;
}
// 根据类型创建着色器
_createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
}

return shader;
}

_initShaderProgram(gl) {
// 顶点 着色器glsl代码
const vsSource = `
attribute vec2 a_position; // 接收顶点位置
attribute vec2 a_texCoord; // 接收纹理坐标
varying vec2 v_texCoord; // 传递给片段着色器的纹理坐标
uniform vec2 u_scale; // 缩放因子

void main(void) {
gl_Position = vec4(a_position, 0.0, 1.0); // 设置顶点位置
v_texCoord = a_texCoord; // 传递纹理坐标
}
`;


// 片段 着色器 glsl 代码
const fsSource = `
precision lowp float; // 设置浮点数精度, 必须指明float的精度,因为计算过程中片段着色器的精度没有默认
varying vec2 v_texCoord; // 从顶点着色器接收的纹理坐标
uniform sampler2D u_sampler; // 纹理采样器

void main(void) {
// 设置像素颜色:
// RGB 从当前纹理坐标采样
gl_FragColor = vec4(texture2D(u_sampler, v_texCoord).rgb,
// Alpha 从偏移后的纹理坐标采样 R 通道
texture2D(u_sampler, v_texCoord+vec2(${this.directionOption[ this.options.direction].fsSourceRadio}, 0)).r);
}
`;
// 创建 顶点着色器
const vsShader = this._createShader(gl, gl.VERTEX_SHADER, vsSource);
// 创建 片段着色器
const fsShader = this._createShader(gl, gl.FRAGMENT_SHADER, fsSource);
// 创建 着色器程序
const program = gl.createProgram();
// 将 顶点着色器程序附着到webgl
gl.attachShader(program, vsShader);
// 将 片段 着色器程序附着到webgl
gl.attachShader(program, fsShader);
// 关联着色器程序到整个绘制对象中
gl.linkProgram(program);

return program;
}

_initBuffer(gl) {
// 位置缓冲区(Position Buffer)初始化:
// 这部分定义了一个矩形的四个顶点坐标,范围在 -1 到 1 之间,这是 WebGL 的标准化设备坐标系(NDC)。
const positionVertice = new Float32Array([
-1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 1.0, -1.0,
]);
const positionBuffer = gl.createBuffer(); // 创建buffer
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); // 把缓冲区对象绑定到目标
gl.bufferData(gl.ARRAY_BUFFER, positionVertice, gl.STATIC_DRAW); // 向缓冲区对象写入刚定义的顶点数据
// 纹理缓冲区(Texture Buffer)初始化:
const textureBuffer = gl.createBuffer();

// 这里将纹理 部分映射到整个画布上
const textureVertice = new Float32Array(this.directionOption[ this.options.direction].textureVertice);
// 这部分根据 direction 选项(left 或 right)设置纹理坐标,用于将视频帧映射到矩形上。
gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer);
// gl.STATIC_DRAW 表示数据不会频繁更改,适合静态几何形状
gl.bufferData(gl.ARRAY_BUFFER, textureVertice, gl.STATIC_DRAW);
// 位置和纹理坐标都使用 Float32Array 是因为 WebGL 需要类型化数组
// 返回的两个 buffer 会在后续的渲染过程中被使用
// 这段代码是实现透明视频效果的基础,通过合理设置纹理坐标来实现 alpha 通道的映射。
return {
position: positionBuffer,
texture: textureBuffer,
};
}

_initTexture(gl) {
const texture = gl.createTexture();

gl.bindTexture(gl.TEXTURE_2D, texture);
// 对纹理图像进行y轴反转,因为WebGL纹理坐标系统的t轴(分为t轴和s轴)的方向和图片的坐标系统Y轴方向相反。因此将Y轴进行反转。
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
// 配置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

return texture;
}
}

调用测试下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>透明视频</title>
<style>
.canvas {
width: 414px;
height: 896px;
}
</style>
</head>

<body style="background-color: black;">
<canvas class="canvas" id="AlphaVideoCanvas" width="414" height="896"></canvas>
<script src="./alpha-video-webgl.js" inline></script>
<script>
// 动画视频 在左侧
const url_left = `https://s3.amazonaws.com/liveme.storage.test/liveglb/202004171204/gifts/resource_manage/f8d6b_md5_f71ec37a5350a219500fef89335c3be9_.mp4`
// 动画视频 在右侧
const url_right = `https://dlied5sdk.myapp.com/music/release/upload/t_mm_file_publish/2868412.mp4`;
this.headMv = new AlphaVideo({
src: url_left,
width: 414,
height: 896 * 375/400,
loop: true, // 是否循环播放
canvas: document.getElementById('AlphaVideoCanvas'),
// direction: "right", // 动画视频的播放方向,left:原来视频在左侧 ;right: 原视频在右侧
});
this.headMv.play();
</script>
</body>

</html>