Web 端 APNG 播放實現原理

題圖來源: 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 簡介

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 結構

APNG 是基於 PNG 格式擴展的,咱們首先了解下 PNG 的組成結構。canvas

PNG 結構組成

PNG 主要包括 PNG SignatureIHDRIDATIEND 和 一些輔助塊。其中,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 結構組成

APNG 在 PNG 的基礎上增長了 acTLfcTLfdAT 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 ArraysBlob 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 依次讀取每一塊,根據每種類型塊中包含的數據及其對應的偏移和字節大小分別進行處理存儲。其中在處理 fcTLfdAT 塊時跳過了幀序列號 (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 drawImageclearRectgetImageDataputImageData 實現。

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 沒有 getImageDataputImageData 等 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!
相關文章
相關標籤/搜索