# 前言
啊,之前不是写了个聊天用的循环列表嘛,当时稍微提到了要加入动态表情包,本来想法是让美术提供多图片图集或者直接用 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.js
、index.js
、lzw.js
注意,不是 src 下的文件哦,src 下的脚本由于 Cocos 的 ESM 与 CJS 交互规则,编辑器会产生 Unexpected import statement in CJS module
和 Unexpected 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-255Graphics Control Extension
图形控制扩展,通常用于指定透明度设置和控制动画,详细介绍:Animation and TransparencyImage 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)); |
# 总结
好啦,大概就写到这啦,感觉又进步了一点点呢~