cat
Shioho

# 前言

啊,之前不是写了个聊天用的循环列表嘛,当时稍微提到了要加入动态表情包,本来想法是让美术提供多图片图集或者直接用 Spine 做的,因为 Cocos 自身是不支持 gif 的嘛,为了避免自己去生成图集(偷懒 x),想了想还是自己去支持一下 gif 渲染吧 hhh
在调研了亿点点时间后在 github 发现了一个好用的 gif 解析库 (gifuct-js) 哈,本着白嫖解决一切的思路,就决定是你啦,这里先感谢作者 hhh~

# 使用说明

# 拷贝文件

  • 安装 js-binary-schema-parser npm 包
    在 cocos 项目根目录下安装包
npm install js-binary-schema-parser --save
  • 将 lib 下的 3 个 js 文件导入 cocos 项目中
    deinterlace.jsindex.jslzw.js

注意,不是 src 下的文件哦,src 下的脚本由于 Cocos 的 ESM 与 CJS 交互规则,编辑器会产生 Unexpected import statement in CJS moduleUnexpected export statement in CJS module 的错误。因此如果我们非要使用几个文件的话则需要做一点小小的修改~

1. 将 export const xxx = ... 全部改为 exports.xxx = ... , 如:

export const lzw = (minCodeSize, data, pixelCount) => {
    ...
}
改为
exports.lzw = (minCodeSize, data, pixelCount) => {
    ...
}

2. 将 index.js 的 import 写法改为 require

// import GIF from 'js-binary-schema-parser/lib/schemas/gif'
// import { parse } from 'js-binary-schema-parser'
// import { buildStream } from 'js-binary-schema-parser/lib/parsers/uint8'
// import { deinterlace } from './deinterlace'
// import lzw from './lzw'
const lzw = require('./lzw');
const deinterlace = require('./deinterlace');
const GIF = require('js-binary-schema-parser/lib/schemas/gif').default;
const parse = require('js-binary-schema-parser').parse;
const buildStream = require('js-binary-schema-parser/lib/parsers/uint8').buildStream;

挺麻烦的,还是老老实实用提供的 lib 文件吧

好啦,这样就行啦,那么接下来就来写渲染组件吧~

# GifRender

本着能简单就简单的原则,资源先放在 resource 下加载啦,具体 component 代码如下:

import { _decorator, Component, Node, Sprite, CCString, resources, ImageAsset, SpriteFrame } from 'cc';
const { ccclass, property, disallowMultiple, requireComponent } = _decorator;
import * as gif from "./js/index.js";
const gifParse = gif["default"];
@ccclass('GifRender')
@disallowMultiple
@requireComponent(Sprite)
export class GifRender extends Component {
    @property(CCString)
    url: string;
    private _sprite: Sprite;
    // 画布,可以考虑设置为全局唯一
    private _mainCanvas = null;
    private _tempCanvas = null;
    // 绘制方法对象
    private _mainContext = null;
    private _tempContext = null;
    private _frames = [];
    private _frameIdx = 0;
    private _frameWidth = 0;
    private _frameHeight = 0;
    private _needsDisposal = false;
    private _frameData: ImageData = null;
    onLoad() {
        this._sprite = this.node.getComponent(Sprite);
        this._mainCanvas = this.createCanvas();
        this._tempCanvas = this.createCanvas();
        this._mainContext = this._mainCanvas.getContext('2d');
        this._tempContext = this._tempCanvas.getContext('2d');
    }
    onEnable() {
        this.showGif();
    }
    onDisable() {
        // 清理计时器
        this.unscheduleAllCallbacks();
    }
    private createCanvas() {
        return document.createElement("canvas");
    }
    private async showGif() {
        this.unscheduleAllCallbacks();
        // 准备 gif 渲染数据,这里可以设置缓存避免重复解析加载
        var buffer = await this.getGifData();
        // 解析 gif
        var parse = gifParse.parseGIF(buffer);
        // 解压缩,获取图片列表
        try {
            this._frames = gifParse.decompressFrames(parse, true);
        } catch (e) {
            console.error(e);
            return
        }
        // 开始绘制
        this._needsDisposal = true;
        this._frameIdx = 0;
        this._frameWidth = 9999;
        this._frameHeight = 9999;
        this._frameData = null;
        this._mainCanvas.width = this._frames[0].dims.width;
        this._mainCanvas.height = this._frames[0].dims.height;
        console.log(this._mainCanvas.width,this._mainCanvas.height)
        this.drawGif();
    }
    private drawGif() {
        let frame = this._frames[this._frameIdx];
        let now = Date.now();
        // 清理画布
        if (this._needsDisposal) {
            this._mainContext.clearRect(0, 0, this._frameWidth, this._frameHeight);
            this._needsDisposal = false;
        }
        // 当前帧大小尺寸是否发生变化,按理不会
        let dims = frame.dims;
        if (!this._frameData || dims.width !== this._frameData.width || dims.height !== this._frameData.height) {
            this._tempCanvas.width = dims.width;
            this._tempCanvas.height = dims.height;
            this._frameData = this._mainContext.createImageData(dims.width, dims.height);
        }
        this._frameData.data.set(frame.patch);
        this._tempContext.putImageData(this._frameData, 0, 0);
        this._mainContext.drawImage(this._tempCanvas, dims.left, dims.top);
        this._frameIdx++;
        if (this._frameIdx >= this._frames.length) {
            this._frameIdx = 0;
        }
        if (frame.disposalType === 2) {
            this._needsDisposal = true;
        }
        this._sprite.spriteFrame = SpriteFrame.createWithImage(new ImageAsset(this._mainCanvas));
        // 循环绘制
        let diff = Date.now() - now;
        let time = Math.max(0, Math.floor(frame.delay - diff)) / 1000;
        this.scheduleOnce(this.drawGif.bind(this), time);
    }
    private getGifData() {
        return new Promise((res, rej) => {
            // 资源地址不能包含后缀,需要删除文件后缀
            resources.load(this.url.replace(/\.gif$/i, ""), (error, asset) => {
                if (error) {
                    console.error(error);
                    rej(error)
                }
                this.loadImgArrayBuffer(asset.nativeUrl).then(res);
            })
        })
    }
    private loadImgArrayBuffer(url: string): Promise<ArrayBuffer | null> {
        return new Promise(function (resolve) {
            const xhr = new XMLHttpRequest();
            xhr.open("GET", url, true);
            xhr.responseType = 'arraybuffer';
            xhr.onreadystatechange = function () {
                if (xhr.readyState === 4) {
                    resolve(xhr.response);
                }
            };
            xhr.onerror = function (err) {
                console.error("xhr error", err);
                resolve(null)
            };
            xhr.send(null);
            setTimeout(function () {
                resolve(null)
            }, 10 * 10000);
        })
    }
}

将 url 设置为相对于 resources 下的路径就能正常显示啦,效果大概像这样:

# 原理分析

# 什么是 Gif

在怎么做之前当然需要知道是什么啦,那么什么是 Gif 呢。
GIF 是一种公用的图像文件格式标准,是一种位图,允许一个文件存储多个图像信息,可实现动画功能,允许某些像素透明,采用 LZW 压缩算法,最高支持 256 种颜色。

# Gif 格式剖析

在了解什么是 Gif 后,现在来说说 Gif 的存储格式吧,总所周知,所有的文件格式都是以字节数组的形式存储在磁盘中的,Gif 作为文件格式的一种,也遵循了这一准则,具有 GIF87a 和 GIF89a 两个版本。现在的 Gif 格式基本都是遵循 W3C GIF89a 规范格式进行存储的,以 GIF89a 为例,文件基本分为这几个部分:Header Block(文件头)、Logical Screen Descriptor(逻辑屏幕描述区)、Global Color Table(全色表)、Graphics Control Extension(图形控制扩展)、Image Descriptor(图像描述)、Local Color Table(局部色表)、Image Data(图像数据)、Plain Text Extension(文本扩展)、Application Extension(应用扩展)、Comment Extension(评论扩展)、Trailer(尾部块)

  • Header Block
    文件头,存储了当前 Gif 的版本信息

  • Logical Screen Descriptor
    逻辑屏幕描述区,定义了与图像数据相关的画布宽高,打包字节(确认是否使用全色表、颜色分辨率描述、排序标志、 全局颜色表的大小),背景色索引和像素纵横比

  • Global Color Table
    记录了当前 Gif 所用到的所有颜色,色彩范围为 0-255

  • Graphics Control Extension
    图形控制扩展,通常用于指定透明度设置和控制动画,详细介绍:Animation and Transparency

  • Image Descriptor
    图像描述,定义了当前帧图片的左上初始点与图片宽高信息

  • Local Color Table
    局部色表,与全色表一样也用于记录颜色值,但只作用于之后跟随的图像数据

  • Image Data
    图像数据,经过 LZW 压缩后的数据块,定义了图像最终的各个像素色彩信息

  • Plain Text Extension
    文本扩展,规范允许你在指定的图像上呈现的文本

  • Application Extension
    应用扩展,该规范允许将特定于应用程序的信息嵌入到 GIF 文件本身中

  • Comment Extension
    评论扩展,为 gif 添加评论信息,虽然不显示在画面上,但也算是一种奇妙的加密通信方式?

  • Trailer
    尾部块,指示何时到达文件末尾,固定为 3B 的字节

好啦,关于 Gif 格式剖析就到这啦,基本都是从 Matthew Flickinger 这位大佬的博客翻译过来的,有什么错误欢迎指出,最后附上原文链接:Project: What's In A GIF - Bit by Byte

# LZW 压缩算法

LZW 算法又叫 “串表压缩算法” 就是通过建立一个字符串表,用较短的代码来表示较长的字符串来实现压缩,对于连续重复出现的字节和字串,具有很高的压缩比且压缩和解压缩速度较快。

算法啥的,爷并不擅长呢,还是这位大大,自己去看吧~Matthew Flickinger - LZW Image Data

# gifuct-js 分析

一个简单易用的 Gif 解码器,可以将 gif 文件解码为大佬自定义的数据格式,获取的每一帧图片的宽高与颜色数据,让我们能很方便的利用 canvas 将其绘制。
让我们看看数据格式吧~

{
    // 像素点数组,值对应为全色表数组索引
    pixels: [...],
    // 记录了当前帧图片的位置与宽高
    dims: {
        top: 0,
        left: 10,
        width: 100,
        height: 50
    },
    // 下一帧图片的延迟显示时间
    delay: 50,
    // 处理类型,1 为将图像留在原处并在其上绘制下一个图像,2 为将画布恢复为背景颜色,3 为将画布恢复到绘制当前图像之前的先前状态,4-7 暂未定义
    disposalType: 1,
    // 全色表数组,定义了 rgb
    colorTable: [...],
    // 透明度,pixels 中与该索引相同的索引透明度设置为 0
    transparentIndex: 33,
    // 图像最终的颜色数组,一个颜色 4 字节 RGBA
    patch: [...]
 }

很方便吧,通过这个库就能拿到我们需要的绘制数据了,最后再通过 Canvas 绘制,然后转化为 Cocos 需要的 spriteframe 就行啦~

var canvas = document.createElement("canvas");
    var ctx = canvas.getContext('2d');
    //frame 当前 gif 帧数据
    canvas.width = frame.dims.width;
    canvas.height = frame.dims.height;
    // 转化为 ts 的图片数据
    var frameData: ImageData = ctx.createImageData(canvas.width, canvas.height);
    // 设置颜色
    frameData.data.set(frame.patch);
    // 上下文设置图片数据
    ctx.putImageData(frameData, 0, 0);
    //canvas 绘制
    ctx.drawImage(canvas, frame.dims.left, frame.dims.top);
    // 等到最终的 spriteframe
    var sp = SpriteFrame.createWithImage(new ImageAsset(canvas));

# 总结

好啦,大概就写到这啦,感觉又进步了一点点呢~

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

汘帆 微信

微信

汘帆 支付宝

支付宝