题图来源: https://commons.wikimedia.org本文做者:杨彩芳javascript
在云音乐的直播开发中会常遇到动画播放的需求,每一个需求的应用场景不一样,体积较小的动画大都采用 APNG 格式。html
若是动画仅单独展现可使用 <img>
直接展现 APNG 动画,可是会存在兼容性 Bug,例如:部分浏览器不支持 APNG 播放,Android 部分机型重复播放失效。前端
若是须要将 APNG 动画 和 其余 DOM 元素 结合 CSS3 Animation 展现动画,APNG 就须要预加载和受控,预加载可以防止 APNG 解析花费时间,从而出现两者不一样步的问题,受控可以有利于用户在 APNG 解析成功或播放结束等时间节点进行一些操做。java
这些问题 apng-canvas 均可以帮咱们解决。apng-canvas 采用 canvas 绘制 APNG 动画,能够兼容更多的浏览器,抹平不一样浏览器的差别,且便于控制 APNG 播放。下面将具体介绍 APNG 、apng-canvas 库实现原理以及在 apng-canvas 基础上增长的 WebGL 渲染实现方式。git
APNG(Animated Portable Network Graphics,Animated PNG)是基于 PNG 格式扩展的一种位图动画格式,增长了对动画图像的支持,同时加入了 24 位真彩色图像和 8 位 Alpha 透明度的支持,动画拥有更好的质量。APNG 对传统 PNG 保留向下兼容,当解码器不支持 APNG 播放时会展现默认图像。github
除 APNG 外,常见的动画格式还有 GIF 和 WebP。从浏览器兼容性、尺寸大小和图片质量三方面比较,结果以下所示(其中尺寸大小以一张图为例,其余纯色或多彩图片尺寸大小比较可查看 GIF vs APNG vs WebP ,大部分状况下 APNG 体积更小)。综合比较 APNG 更优,这也是咱们选用 APNG 的缘由。web
APNG 是基于 PNG 格式扩展的,咱们首先了解下 PNG 的组成结构。canvas
PNG 主要包括 PNG Signature
、IHDR
、IDAT
、IEND
和 一些辅助块。其中,PNG Signature
是文件标示,用于校验文件格式是否为 PNG ;IHDR
是文件头数据块,包含图像基本信息,例如图像的宽高等信息;IDAT
是图像数据块,存储具体的图像数据,一个 PNG 文件可能有一个或多个 IDAT
块;IEND
是结束数据块,标示图像结束;辅助块位于 IHDR
以后 IEND
以前,PNG 规范未对其施加排序限制。数组
PNG Signature
块的大小为 8 字节,内容以下:promise
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
其余每一个块的组成结构基本以下所示:
4 个字节标识数据的长度,4 个字节标识块类型,length 个字节为数据(若是数据的长度 length 为 0,则无该部分),最后4个字节是CRC校验。
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 指定了下一帧绘制以前对缓冲区的操做
blend_op 指定了绘制当前帧以前对缓冲区的操做
了解 APNG 的组成结构以后,咱们就能够分析 apng-canvas 的实现原理啦,主要分为两部分:解码和绘制。
APNG 解码的流程以下图所示:
首先将 APNG 以arraybuffer
的格式下载资源,经过视图
操做二进制数据;而后依次校验文件格式是否为 PNG 及 APNG;接着依次拆分 APNG 每一块处理并存储;最后将拆分得到的 PNG 标示块、头块、其余辅助块、一帧的帧图像数据块和结束块从新组成 PNG 图片并经过加载图像资源。在这个过程当中须要浏览器支持 Typed Arrays
和 Blob URLs
。
APNG 的文件资源是经过 XMLHttpRequest
下载,实现简单,这里不作赘述。
校验 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 格式就是判断文件是否存在类型为 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 了,遍历 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 解码完成后就能够根据动画控制块和帧控制块绘制播放啦。具体是使用 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为帧间隔时间 };
渲染方式除 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 渲染实现。但愿阅读本文后,可以对您有所帮助,欢迎探讨。
本文发布自 网易云音乐大前端团队,文章未经受权禁止任何形式的转载。咱们常年招收前端、iOS、Android,若是你准备换工做,又刚好喜欢云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!