题图起源:https://commons.wikimedia.org
本文作者:杨彩芳
写在后面
在云音乐的直播开发中会常遇到动画播放的需要,每个需要的利用场景不同,体积较小的动画大都采纳 APNG 格局。
如果动画仅独自展现能够应用 <img>
间接展现 APNG 动画,然而会存在兼容性 Bug,例如:局部浏览器不反对 APNG 播放,Android 局部机型反复播放生效。
如果须要将 APNG 动画 和 其余 DOM 元素 联合 CSS3 Animation 展现动画,APNG 就须要预加载和受控,预加载可能避免 APNG 解析破费工夫,从而呈现二者不同步的问题,受控可能有利于用户在 APNG 解析胜利或播放完结等工夫节点进行一些操作。
这些问题 apng-canvas 都能够帮咱们解决。apng-canvas 采纳 canvas 绘制 APNG 动画,能够兼容更多的浏览器,抹平不同浏览器的差别,且便于管制 APNG 播放。上面将具体介绍 APNG、apng-canvas 库实现原理以及在 apng-canvas 根底上减少的 WebGL 渲染实现形式。
APNG 简介
APNG(Animated Portable Network Graphics,Animated PNG)是基于 PNG 格局扩大的一种位图动画格局,减少了对动画图像的反对,同时退出了 24 位真彩色图像和 8 位 Alpha 透明度的反对,动画领有更好的品质。APNG 对传统 PNG 保留向下兼容,当解码器不反对 APNG 播放时会展现默认图像。
除 APNG 外,常见的动画格局还有 GIF 和 WebP。从浏览器兼容性、尺寸大小和图片品质三方面比拟,后果如下所示(其中尺寸大小以一张图为例,其余纯色或多彩图片尺寸大小比拟可查看 GIF vs APNG vs WebP,大部分状况下 APNG 体积更小)。综合比拟 APNG 更优,这也是咱们选用 APNG 的起因。
APNG 构造
APNG 是基于 PNG 格局扩大的,咱们首先理解下 PNG 的组成构造。
PNG 构造组成
PNG 次要包含 PNG Signature
、IHDR
、IDAT
、IEND
和 一些辅助块。其中,PNG Signature
是文件标示,用于校验文件格局是否为 PNG;IHDR
是文件头数据块,蕴含图像根本信息,例如图像的宽低等信息;IDAT
是图像数据块,存储具体的图像数据,一个 PNG 文件可能有一个或多个 IDAT
块;IEND
是完结数据块,标示图像完结;辅助块位于 IHDR
之后 IEND
之前,PNG 标准未对其施加排序限度。
PNG Signature
块的大小为 8 字节,内容如下:
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
其余每个块的组成构造根本如下所示:
4 个字节标识数据的长度,4 个字节标识块类型,length 个字节为数据(如果数据的长度 length 为 0,则无该局部),最初 4 个字节是 CRC 校验。
APNG 构造组成
APNG 在 PNG 的根底上减少了 acTL
、fcTL
和 fdAT
3 种块,其组成构造如下图所示:
acTL
:动画管制块,蕴含了图片的帧数和循环次数(0 示意有限循环)fcTL
:帧管制块,属于 PNG 标准中的辅助块,蕴含了以后帧的序列号、图像的宽高及程度垂直偏移量,帧播放时长和绘制形式(dispose_op 和 blend_op)等,每一帧只有一个fcTL
块fdAT
:帧数据块,蕴含了帧的序列号和图像数据,仅比IDAT
多了帧的序列号,每一帧能够有一个或多个fcTL
块。fdAT
的序列号与fcTL
共享,用于检测 APNG 的序列谬误,可选择性的纠正
IDAT
块是 APNG 向下兼容展现时的默认图片。如果 IDAT
之前有 fcTL
,那么 IDAT
的数据则当做第一帧图片(如上图构造),如果 IDAT
之前没有 fcTL
,则第一帧图片是第一个 fdAT
,如下图所示:
APNG 动画播放次要是通过 fcTL
来管制渲染每一帧的图像,即通过 dispose_op 和 blend_op 管制绘制形式。
-
dispose_op 指定了下一帧绘制之前对缓冲区的操作
- 0:不清空画布,间接把新的图像数据渲染到画布指定的区域
- 1:在渲染下一帧前将以后帧的区域内的画布清空为默认背景色
- 2:在渲染下一帧前将画布的以后帧区域内复原为上一帧绘制后的后果
-
blend_op 指定了绘制以后帧之前对缓冲区的操作
- 0:示意革除以后区域再绘制
- 1:示意不革除间接绘制以后区域,图像叠加
apng-canvas 实现原理
理解 APNG 的组成构造之后,咱们就能够剖析 apng-canvas 的实现原理啦,次要分为两局部:解码和绘制。
APNG 解码
APNG 解码的流程如下图所示:
首先将 APNG 以 arraybuffer
的格局下载资源,通过 视图
操作二进制数据;而后顺次校验文件格局是否为 PNG 及 APNG;接着顺次拆分 APNG 每一块解决并存储;最初将拆分取得的 PNG 标示块、头块、其余辅助块、一帧的帧图像数据块和完结块从新组成 PNG 图片并通过加载图像资源。在这个过程中须要浏览器反对 Typed Arrays
和 Blob URLs
。
APNG 的文件资源是通过 XMLHttpRequest
下载,实现简略,这里不做赘述。
校验 PNG 格局
校验 PNG 格局就是校验 PNG Signature
块,将文件资源从第 1 个字节开始顺次比对前 8 个字节的内容,要害实现如下:
const bufferBytes = new Uint8Array(buffer); // buffer 为下载的 arraybuffer 资源
const PNG_SIGNATURE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
for (let i = 0; i < PNG_SIGNATURE_BYTES.length; i++) {if (PNG_SIGNATURE_BYTES[i] !== bufferBytes[i]) {reject('Not a PNG file (invalid file signature)');
return;
}
}
校验 APNG 格局
校验 APNG 格局就是判断文件是否存在类型为 acTL
的块。因而须要依序读取文件中的每一块,获取块类型等数据。块的读取是依据上文所述的 PNG 块的根本组成构造进行解决,流程实现如下图所示:
off 初始值为 8,即 PNG Signature
的字节大小,而后依序读取每一块。首先读取 4 个字节获取数据块长度 length,持续读取 4 个字节获取数据块类型,而后执行回调函数解决本块的数据,依据回调函数返回值 res、块类型和 off 值判断是否须要持续读取下一块(res 值示意是否要持续读取下一块数据,默认为 undefined
持续读取)。如果持续则 off 值累加 4 + 4 + length + 4
,偏移到下一块的开始循环执行,否则间接完结。要害代码如下:
const parseChunks = (bytes, callback) => {
let off = 8;
let res, length, type;
do {length = readDWord(bytes, off);
type = readString(bytes, off + 4, 4);
res = callback(type, bytes, off, length);
off += 12 + length;
} while (res !== false && type !== 'IEND' && off < bytes.length);
};
调用 parseChunks
从头开始查找,一旦存在 type === 'acTL'
的块就返回 false
进行读取,要害实现如下:
let isAnimated = false;
parseChunks(bufferBytes, (type) => {if (type === 'acTL') {
isAnimated = true;
return false;
}
return true;
});
if (!isAnimated) {reject('Not an animated PNG');
return;
}
依照类型解决每一块
APNG 构造中的外围类型块的具体构造如下图所示:
调用 parseChunks
顺次读取每一块,依据每种类型块中蕴含的数据及其对应的偏移和字节大小别离进行解决存储。其中在解决 fcTL
和 fdAT
块时跳过了帧序列号 (sequence_number)的读取,仿佛没有思考序列号出错的问题。要害实现如下:
let preDataParts = [], // 存储 其余辅助块
postDataParts = [], // 存储 IEND 块
headerDataBytes = null; // 存储 IHDR 块
const anim = anim = new Animation();
let frame = null; // 存储 每一帧
parseChunks(bufferBytes, (type, bytes, off, length) => {
let delayN,
delayD;
switch (type) {
case 'IHDR':
headerDataBytes = bytes.subarray(off + 8, off + 8 + length);
anim.width = readDWord(bytes, off + 8);
anim.height = readDWord(bytes, off + 12);
break;
case 'acTL':
anim.numPlays = readDWord(bytes, off + 8 + 4); // 循环次数
break;
case 'fcTL':
if (frame) anim.frames.push(frame); // 上一帧数据
frame = {}; // 新的一帧
frame.width = readDWord(bytes, off + 8 + 4);
frame.height = readDWord(bytes, off + 8 + 8);
frame.left = readDWord(bytes, off + 8 + 12);
frame.top = readDWord(bytes, off + 8 + 16);
delayN = readWord(bytes, off + 8 + 20);
delayD = readWord(bytes, off + 8 + 22);
if (delayD === 0) delayD = 100;
frame.delay = 1000 * delayN / delayD;
anim.playTime += frame.delay; // 累加播放总时长
frame.disposeOp = readByte(bytes, off + 8 + 24);
frame.blendOp = readByte(bytes, off + 8 + 25);
frame.dataParts = [];
break;
case 'fdAT':
if (frame) frame.dataParts.push(bytes.subarray(off + 8 + 4, off + 8 + length));
break;
case 'IDAT':
if (frame) frame.dataParts.push(bytes.subarray(off + 8, off + 8 + length));
break;
case 'IEND':
postDataParts.push(subBuffer(bytes, off, 12 + length));
break;
default:
preDataParts.push(subBuffer(bytes, off, 12 + length));
}
});
if (frame) anim.frames.push(frame); // 顺次存储每一帧帧数据
组装 PNG
拆分完数据块之后就能够组装 PNG 了,遍历 anim.frames
将 PNG 的通用数据块 PNG_SIGNATURE_BYTES、headerDataBytes、preDataParts、一帧的帧数据 dataParts 和 postDataParts 按序组成一份 PNG 图像资源(bb),通过 createObjectURL
创立图片的 URL 存储到 frame 中,用于后续绘制。
const url = URL.createObjectURL(new Blob(bb, { type: 'image/png'}));
frame.img = document.createElement('img');
frame.img.src = url;
frame.img.onload = function () {URL.revokeObjectURL(this.src);
createdImages++;
if (createdImages === anim.frames.length) { // 全副解码实现
resolve(anim);
}
};
到这里咱们曾经实现了解码工作,调用 APNG.parseUrl
就能够实现动画资源预加载性能:页面初始化之后首次调用加载资源,渲染时再次调用间接返回解析后果进行绘制操作。
const url2promise = {};
APNG.parseURL = function (url) {if (!(url in url2promise)) {url2promise[url] = loadUrl(url).then(parseBuffer);
}
return url2promise[url];
};
APNG 绘制
APNG 解码实现后就能够依据动画管制块和帧管制块绘制播放啦。具体是应用 requestAnimationFrame 在 canvas 画布上顺次绘制每一帧图片实现播放。apng-canvas 采纳 Canvas 2D 渲染。
const tick = function (now) {while (played && nextRenderTime <= now) renderFrame(now);
if (played) requestAnimationFrame(tick);
};
Canvas 2D 绘制次要是应用 Canvas 2D 的 API drawImage
、clearRect
、getImageData
、putImageData
实现。
const renderFrame = function (now) {
// fNum 记录循环播放时的总帧数
const f = fNum++ % ani.frames.length;
const frame = ani.frames[f];
// 动画播放完结
if (!(ani.numPlays === 0 || fNum / ani.frames.length <= ani.numPlays)) {
played = false;
finished = true;
if (ani.onFinish) ani.onFinish(); // 这行是作者加的便于在动画播放完结后执行一些操作
return;
}
if (f === 0) {
// 绘制第一帧前将动画整体区域画布清空
ctx.clearRect(0, 0, ani.width, ani.height);
prevF = null; // 上一帧
if (frame.disposeOp === 2) frame.disposeOp = 1;
}
if (prevF && prevF.disposeOp === 1) { // 清空上一帧区域的底图
ctx.clearRect(prevF.left, prevF.top, prevF.width, prevF.height);
} else if (prevF && prevF.disposeOp === 2) { // 复原为上一帧绘制之前的底图
ctx.putImageData(prevF.iData, prevF.left, prevF.top);
} // 0 则间接绘制
const {
left, top, width, height,
img, disposeOp, blendOp
} = frame;
prevF = frame;
prevF.iData = null;
if (disposeOp === 2) { // 存储以后的绘制底图,用于下一帧绘制前复原该数据
prevF.iData = ctx.getImageData(left, top, width, height);
}
if (blendOp === 0) { // 清空以后帧区域的底图
ctx.clearRect(left, top, width, height);
}
ctx.drawImage(img, left, top); // 绘制以后帧图片
// 下一帧的绘制工夫
if (nextRenderTime === 0) nextRenderTime = now;
nextRenderTime += frame.delay; // delay 为帧间隔时间
};
WebGL 绘制
渲染形式除 Canvas 2D 外还能够应用 WebGL。WebGL 渲染性能优于 Canvas 2D,然而 WebGL 没有能够间接绘制图像的 API,绘制实现代码较为简单,本文就不展现绘制图像的具体代码,相似 drawImage
API 的 WebGL 实现可参考 WebGL-drawimage,二维矩阵等。上面将介绍作者选用的绘制实现计划的关键点。
因为 WebGL 没有 getImageData
、putImageData
等 API 能够获取或复制以后画布的图像数据,所以在 WebGL 初始化时就初始化多个纹理,应用变量 glRenderInfo 记录历史渲染的纹理数据。
// 纹理数量
const textureLens = ani.frames.filter(item => item.disposeOp === 0).length;
// 历史渲染的纹理数据
const glRenderInfo = {
index: 0,
frames: {},};
渲染每一帧时依据 glRenderInfo.frames
应用多个纹理顺次渲染,同时更新 glRenderInfo
数据。
const renderFrame = function (now) {
...
let prevClearInfo;
if (f === 0) {
glRenderInfo.index = 0;
glRenderInfo.frames = {};
prevF = null;
prevClearInfo = null;
if (frame.disposeOp === 2) frame.disposeOp = 1;
}
if (prevF && prevF.disposeOp === 1) { // 须要清空上一帧区域底图
const prevPrevClear = glRenderInfo.infos[glRenderInfo.index].prevF;
prevClearInfo = [...(prevPrevClear || []),
prevF,
];
}
if (prevF && prevF.disposeOp === 0) { // 递增纹理下标序号,否则间接替换上一帧图片
glRenderInfo.index += 1;
}
// disposeOp === 2 间接替换上一帧图片
glRenderInfo.frames[glRenderInfo.index] = { // 更新 glRenderInfo
frame,
prevF: prevClearInfo, // 用于革除上一帧区域底图
};
prevF = frame;
prevClearInfo = null;
// 绘制图片,底图革除在 glDrawImage 接口外部实现
Object.entries(glRenderInfo.frames).forEach(([key, val]) => {glDrawImage(gl, val.frame, key, val.prevF);
});
...
}
小结
本文介绍了 APNG 的构造组成、图片解码、应用 Canvas 2D / WebGL 渲染实现。心愿浏览本文后,可能对您有所帮忙,欢送探讨。
参考
- Animated PNG graphics
- apng-canvas
- APNG 那些事
- 二进制数组
本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!