creator2.4.11项目资源进行批量纹理压缩实践
浅谈纹理压缩
在游戏开发中对资源的处理是非常重要的,而资源最多的一般是图片,所以针对图片的处理会有很多方案,总体的原则就是保证图片质量的前提下,尽量减少其占用磁盘的大小和内存大小。前者可以使我们的包体变小(如果是web端则可以减少网络传输数据量,从而节省流量),后者可以减少游戏运行时对内存的占用,以及提高cpu向gpu传输数据的效率。
我们对于图片在磁盘中存储优化,一般会采用图片压缩的方式,比如进行PNG、JPEG等等。但是这种处理不能对内存进行优化,等到图片加载到内存的时候会转换为原始大小,这个大小往往比在磁盘上大很多,一张图在内存中占用的大小的计算公式大概如下:
内存中图片大小 = 图片像素分辨率宽 * 图片像素分辨率高 * 一个像素所占字节大小
其中一个像素所占字节大小又根据不同的纹理格式会有出入,一般带透明通道的png使用ARGB8888(32bit(位),argb四个通道各占8bit(位),总共占4byte(字节)),不带透明通道的jpg使用RGB888(24位,占3字节),所以对于png和jpg而言有一个简化的公式,如
png内存大小 = 像素分辨率宽 * 高 * 4;
jpg内存大小 = 像素分辨率宽 * 高 * 3;
例如:一张分辨率为1024*1024的png压缩图片,其占用内存的大小为
size = 1024 * 1024 * 4 (byte) 如果转换为MB的话size/1024/1024,就是4MB,如果是存储在磁盘,这个大小根据不同图片有差异,但是会比在内存中小很多,可以自行测验一下。大概只会占用1. 多MB的空间。
使用png或者jpg压缩的图片虽然在存储空间上更有优势,但在内存中确没法优化,而且在内存中CPU会先对图片进行转换,处理成GPU能识别的纹理,然后传输到显存,GPU在进行渲染,这一步转换还会占用CPU的时间。所以后面出来专门针对纹理的压缩技术,俗称纹理压缩。纹理压缩过的图片不进减少了其在内存中占用的大小,而且因为它能够直接被GPU识别,所以也大大的提高了整个渲染流程的效率,因为不需要在做CPU那一步转换。
关于纹理压缩的文章网上有很多,这里不多说了,目前主流的有astc、etc等等。在creator2.4中支持纹理压缩,但是引擎没有提供批量处理的操作,资源一多的话处理起来很不方便,所以自己用node写了一个进行批量纹理压缩的工具,其中有些注意事项在此记录分享一下。
使用工具node
node的使用安装这些不说了,这里以astc纹理压缩为例子,astc目前总体压缩性价比较好,支持Android、ios、微信小游戏等多个平台,机型也适配较广,如果不是需要适配特别低的机型,一般够用了,其它方式的纹理压缩过程大体相同,只是具体压缩那一步各个工具使用不同。
准备阶段
1、准备需要压缩的项目资源,一般在自己项目下面
2、准备好压缩的工具,比如我这边是用astc,在引擎的安装目录下面有提供
3、用nodejs写一个批量处理文件的流程,将每一个文件进行纹理压缩
注意事项
1、如果是需要预乘的图片资源我们不能进行压缩,不然会有问题
2、设置纹理压缩的参数,在压缩和解析的时候一定要配套起来
2、如果是和打包一起使用,通常会将打包后的资源进行压缩并且进行资源替换,然后还会缓存压缩过的纹理,可以提高后续打包的效率,不用重复压缩。
使用过程
astcenc应用程序提供了可用命令行参数的完整列表,最基本的命令是:
astcenc.exe -cl ${filePath} ${path.dirname(filePath)}/${path.basename(filePath, '.png')}.astc 6x6 -fast
; // 压缩
astcenc.exe -dl ${filePath} ${path.dirname(filePath)}/${path.basename(filePath, '.astc')}.png
; // 解压缩
更多压缩指令可以直接在cmd下运行对应的应用查看
To compress an image use:
astcenc {-cl|-cs|-ch|-cH} [options]
e.g. using LDR profile, 8x6 blocks, and the thorough quality preset:
astcenc -cl kodim01.png kodim01.astc 8x6 -thorough
To decompress an image use:
astcenc {-dl|-ds|-dh|-dH}
Supported 2D block sizes are:
4x4: 8.00 bpp 10x5: 2.56 bpp
5x4: 6.40 bpp 10x6: 2.13 bpp
5x5: 5.12 bpp 8x8: 2.00 bpp
6x5: 4.27 bpp 10x8: 1.60 bpp
6x6: 3.56 bpp 10x10: 1.28 bpp
8x5: 3.20 bpp 12x10: 1.07 bpp
8x6: 2.67 bpp 12x12: 0.89 bpp
Supported 3D block sizes are:
3x3x3: 4.74 bpp 5x5x4: 1.28 bpp
4x3x3: 3.56 bpp 5x5x5: 1.02 bpp
4x4x3: 2.67 bpp 6x5x5: 0.85 bpp
4x4x4: 2.00 bpp 6x6x5: 0.71 bpp
5x4x4: 1.60 bpp 6x6x6: 0.59 bpp
The quality level configures the quality-performance tradeoff for
the compressor; more complete searches of the search space improve
image quality at the expense of compression time. The quality level
can be set to any value between 0 (fastest) and 100 (thorough), or
to a fixed quality preset:
-fastest (equivalent to quality = 0)
-fast (equivalent to quality = 10)
-medium (equivalent to quality = 60)
-thorough (equivalent to quality = 98)
-exhaustive (equivalent to quality = 100)
话不多说,关键代码如下:
外面调用这个方法就行 startCompress()
const path = require('path');
const fs = require('fs');
// RGBA_ASTC_4x4: 30
// RGBA_ASTC_5x4: 31
// RGBA_ASTC_5x5: 32
// RGBA_ASTC_6x5: 33
// RGBA_ASTC_6x6: 34
// RGBA_ASTC_8x5: 35
// RGBA_ASTC_8x6: 36
// RGBA_ASTC_8x8: 37
// RGBA_ASTC_10x5: 38
// RGBA_ASTC_10x6: 39
// RGBA_ASTC_10x8: 40
// RGBA_ASTC_10x10: 41
// RGBA_ASTC_12x10: 42
// RGBA_ASTC_12x12: 43
const CompressQuality = { // 这里可以根据项目情况,做不同的压缩质量等级设置
'none': ['0', '0', 0], // 不压缩
'high': ['4x4', '7@30', 30],
'middle': ['6x6', '7@34', 34],
'low': ['8x8', '7@37', 37],
}
// 默认压缩质量
const DefaultQuality = 'middle';
var AstcTools = {
_cacheDir: '',
_cacheMap: {},
_compressDir: '',
_comprExt: '.astc', // 压缩文件后缀名
project: '', // 项目路径
defaultAstcFormat: 34,
compressionQualityConfig: {}, // 质量配置
textureCompressCfg: '', // 配置了资源压缩质量等级的文件路径,针对不同资源做不同等级的压缩
/**
示例:
{
"high":
[
"assets/test0.png", // 项目下面的路径
"assets/resources/test" // 文件夹形式
],
"middle":["assets/test1.png"],
"low": ["assets/test2.png"]
}
*/
/**
* 遍历文件夹
* @param {*} dir
* @param {*} callback
*/
walkSync(dir, callback) {
if (fs.existsSync(dir)) {
fs.readdirSync(dir).forEach((file) => {
var filePath = path.join(dir, file);
var stat = fs.statSync(filePath);
if (stat.isFile()) {
callback(filePath);
} else if (stat.isDirectory()) {
this.walkSync(filePath, callback);
}
});
} else {
console.log(`walkSync error ${dir}`);
}
},
/**
* 递归创造目录
* @param {*} dirname
* @returns
*/
mkdirsSync(dirname) {
if (fs.existsSync(dirname)) {
return true;
} else {
if (this.mkdirsSync(path.dirname(dirname))) {
fs.mkdirSync(dirname);
return true;
}
}
return false;
},
/**
* 读取文件
* @param {*} file
* @returns
*/
readFile(file) {
if (fs.existsSync(file)) {
return JSON.parse(fs.readFileSync(file));
} else {
console.error(`readFile error ${file}`)
}
return null;
},
getMD5(file) {
return crypto.createHash('md5').update(fs.readFileSync(file)).digest('hex');
},
/**
* 同步创建cmd命令
* @param {*} cmd
* @param {*} callfunc
*/
createCmdSync(cmd, doWork, callfunc) {
child_process.execSync(cmd, { cwd: doWork, stdio: 'inherit' });
if (callfunc)
callfunc();
},
/**
* 处理引擎编辑器下的资源文件,生成对应的md5路径
* @param {string} from
* @param {object} qualityCfg 压缩map对象
* @param {string} quality 压缩质量
*/
haldleCreatorImgMd5Path(from, qualityCfg, quality) {
if (fs.statSync(from).isDirectory()) {
this.walkSync(from, (file) => {
let extname = path.extname(file);
if (extname == '.png' || extname == '.jpg') {
// 生成的md5名字就是对应.meta文件里面的uuid
let metaFile = file + '.meta'
let metaData = this.readFile(metaFile)
let md5 = metaData.uuid
qualityCfg[md5] = quality
}
});
} else {
let extname = path.extname(from);
if (extname == '.png' || extname == '.jpg') {
// 生成的md5名字就是对应.meta文件里面的uuid
let metaFile = from + '.meta'
let metaData = this.readFile(metaFile)
let md5 = metaData.uuid
qualityCfg[md5] = quality
}
}
},
// 初始化压缩配置
initCompress() {
this._cacheDir = path.join(this.project, 'temp', '.cache-texture', this._comprExt.slice(1));
let cacheCfg = path.join(this._cacheDir, 'cacheMap.json');
if (fs.existsSync(cacheCfg)) {
this._cacheMap = JSON.parse(fs.readFileSync(cacheCfg));
} else {
this._cacheMap = {};
}
if (!fs.existsSync(this._cacheDir)) {
this.mkdirsSync(this._cacheDir);
}
this.handleCompressLv()
},
handleCompressLv() {
this.defaultAstcFormat = CompressQuality[DefaultQuality][2]
this.compressionQualityConfig = {}
this.extFormatConfig = {}
let qualityPath = this.textureCompressCfg
if (qualityPath && fs.existsSync(qualityPath) && fs.statSync(qualityPath).isFile()) {
try {
let qualityConfig = JSON.parse(fs.readFileSync(qualityPath));
if (qualityConfig) {
let highs = qualityConfig.high
let middles = qualityConfig.middle
let lows = qualityConfig.low
let nones = qualityConfig.none
if (lows && lows.length > 0) {
for (let i = 0; i < lows.length; i++) {
this.handleQualityCfg(lows[i], this.compressionQualityConfig, 'low')
}
}
if (middles && middles.length > 0) {
for (let i = 0; i < middles.length; i++) {
this.handleQualityCfg(middles[i], this.compressionQualityConfig, 'middle')
}
}
if (highs && highs.length > 0) {
for (let i = 0; i < highs.length; i++) {
this.handleQualityCfg(highs[i], this.compressionQualityConfig, 'high')
}
}
if (nones && nones.length > 0) {
for (let i = 0; i < nones.length; i++) {
this.handleQualityCfg(nones[i], this.compressionQualityConfig, 'none')
}
}
}
console.log('纹理压缩配置生成完成, 配置路径正常!')
} catch (err) {
console.error('astc 纹理压缩配置读取失败', err)
}
}
},
handleQualityCfg(filePath, qualityCfg, quality) {
let src = path.join(this.project, filePath)
this.haldleCreatorImgMd5Path(src, qualityCfg, quality)
},
/**
* 异步开始压缩指定目录下的图片。
* @param {string} dir - 需要压缩的图片所在的目录。
* @param {Function} callfunc - 压缩完成后的回调函数。
*/
async startCompress(dir, callfunc) {
this.initCompress()
this._compressDir = dir;
let texture = [];
this.walkSync(dir, (file) => {
let extname = path.extname(file);
if (extname == '.png' || extname == '.jpg') {
if (this.whiteList(file, extname)) {
let md5 = this.getMD5(file);
let size = fs.statSync(file).size;
let quality = this.getQuality(file) // 这里获取一个质量
let params = {
'md5': md5,
'filePath': file,
'size': size,
'quality': quality
}
texture.push(params);
} else {
console.log(`图片通过白名单检测 ${file}`);
}
}
});
for (let i in texture) {
let params = texture[i];
let md5 = params.md5;
let filePath = params.filePath;
let quality = params.quality;
//检查纹理压缩缓存
if (this.queryCache(params)) {
await this.replaceImageToTxture(filePath, quality);
} else {
await this.transform(filePath, md5, quality);
}
console.log(`正在压缩,进度: ${Number(i) + 1}/${texture.length}\n`);
}
if (callfunc) {
callfunc();
}
},
/**
* 获取当前图片需要压缩的质量
* @param {string} filePath
* @returns
*/
getQuality(filePath) {
let key = path.basename(filePath, path.extname(filePath))
if (this.compressionQualityConfig[key]) {
return this.compressionQualityConfig[key]
} else {
return DefaultQuality
}
},
// 白名单检查
whiteList(filePath, extname) {
// 开了纹理预乘的不压缩
let importJson = filePath.replace(extname, '.json');
importJson = importJson.replace('\\native', '\\import');
if (fs.existsSync(importJson)) {
let json = this.readFile(importJson);
let config = json[5][0].split(',');
if (config[5] != 0) {
// 图片开启预乘不进行压缩
return false;
}
} else {
// 未找到图片关联json文件
return false;
}
return true;
},
// 纹理压缩缓存查询
queryCache(params) {
let md5 = params.md5;
let size = params.size;
let filePath = params.filePath;
let quality = params.quality;
let assetsIndex = filePath.lastIndexOf('assets\\');
let key = filePath.substring(assetsIndex);
if (this._cacheMap[key]) {
let textureUrl = this._cacheMap[key].textureUrl;
let textureCompressType = this._cacheMap[key].textureCompressType;
let cacheSize = this._cacheMap[key].size;
let cacheMd5 = this._cacheMap[key].md5;
let cacheQuality = this._cacheMap[key].quality;
let hasTextureUrl = textureUrl && fs.existsSync(textureUrl);
if (hasTextureUrl) {
return textureCompressType == 'astc' && cacheSize == size && cacheMd5 == md5
&& CompressQuality[cacheQuality][0] == CompressQuality[quality][0];
} else {
return false;
}
} else {
return false;
}
},
/**
* 将普通图片替换成压缩后的纹理
* @param {string} filePath
* @param {string} quality high | middle | low
* @returns
*/
replaceImageToTxture(filePath, quality) {
return new Promise(resolve => {
let assetsIndex = filePath.lastIndexOf('assets\\');
let key = filePath.substring(assetsIndex);
let extname = path.extname(filePath);
let textureUrl = filePath.replace(extname, this._comprExt);
fs.writeFileSync(textureUrl, fs.readFileSync(this._cacheMap[key].textureUrl));
fs.unlinkSync(filePath);
let importJson = filePath.replace(extname, '.json');
importJson = importJson.replace('\\native', '\\import');
let json = JSON.parse(fs.readFileSync(importJson));
let config = json[5][0].split(',');
config[0] = CompressQuality[quality][1];
//关闭预乘
config[5] = 0;
config = config.join(',');
json[5][0] = config;
fs.writeFileSync(importJson, JSON.stringify(json));
// console.log(`build textureUrl ---> ${textureUrl}`);
resolve();
})
},
/**
* 压缩纹理
* @param filePath
* @param md5
* @returns {Promise<void>}
*/
transform(filePath, md5, quality) {
return new Promise(resolve => {
let astc = `astcenc.exe -cl ${filePath} ${path.dirname(filePath)}/${path.basename(filePath, path.extname(filePath))}.astc ${CompressQuality[quality][0]} -medium`;
// 在安装的引擎目录下面
let textureCompressTool = "D:\\Creator\\2.4.11\\resources\\static\\tools\\texturecompress\\mali\\Windows_64"
this.createCmdSync(astc, textureCompressTool, () => {
// console.log(`---------压缩纹理---------\n${stdout}`)
let extname = path.extname(filePath);
let textureUrl = filePath.replace(extname, this._comprExt);
if (fs.existsSync(textureUrl)) {
this.textureCache(textureUrl, md5, filePath, quality);
this.replaceImageToTxture(filePath, quality);
resolve();
}
});
})
},
// 缓存纹理
textureCache(url, md5, filePath, quality) {
let cacheDir = this._cacheDir;
if (!fs.existsSync(cacheDir)) {
this.mkdirsSync(cacheDir);
}
let fileCacheDir = path.join(cacheDir, 'assets');
if (fs.existsSync(fileCacheDir)) {
let toDir = path.dirname(url).replace(this._compressDir, fileCacheDir);
let toPath = path.join(toDir, path.basename(url));
if (fs.existsSync(toDir)) {
this.cacheTexture(url, toPath, md5, filePath, quality);
} else {
this.mkdirsSync(toDir);
this.cacheTexture(url, toPath, md5, filePath, quality);
}
} else {
fs.mkdirSync(fileCacheDir);
this.textureCache(url, md5, filePath, quality);
}
},
cacheTexture(fromPath, toPath, md5, filePath, quality) {
fs.writeFileSync(toPath, fs.readFileSync(fromPath));
let size = fs.statSync(filePath).size;
let params = {
textureUrl: toPath,
size: size,
md5: md5,
textureCompressType: 'astc',
quality: quality
}
let assetsIndex = filePath.lastIndexOf('assets\\');
let key = filePath.substring(assetsIndex);
this._cacheMap[`${key}`] = params;
this.writeCacheInfo();
},
writeCacheInfo() {
let cacheCfg = path.join(this._cacheDir, 'cacheMap.json');
fs.writeFileSync(cacheCfg, JSON.stringify(this._cacheMap, null, 4));
}
}
module.exports = AstcTools;
如果是etc,在引擎目录下也有工具,使用原理同上,只需要替换掉具体执行压缩的地方就行,其它压缩方式也是同理
顺带复制一下相关指令
etcpack <input_filename> <output_directory> [Options]
Options:
-s {fast|slow} Compression speed. Slow = exhaustive
search for optimal quality
(default: fast).
-e {perceptual|nonperceptual} Error metric: Perceptual (nicest) or
nonperceptual (highest PSNR)
(default: perceptual).
-c {etc1|etc2} Codec: etc1 (most compatible) or
etc2 (highest quality)
(default: etc2).
-f {R|R_signed|RG|RG_signed| Compressed format: one, two, three
RGB|RGBA1|RGBA8 or RGBA} or four channels, and 1 or 8 bits
for alpha (1 equals punchthrough)
(default: RGB).
-mipmaps Generate mipmaps.
-ext {PPM|PGM|JPG|JPEG|PNG|GIF| Uncompressed formats
BMP|TIF|TIFF|PSD|TGA|RAW| (default PPM).
PCT|SGI|XPM}
-ktx Output ktx files, not pkm files.
-v Verbose mode. Prints additional
information during execution.
-progress Prints compression progress.
-version Prints version number.
Options to be used only in conjunction with etc codec (-c etc):
-aa Use alpha channel and create a
texture atlas.
-as Use alpha channel and create a
separate image.
-ar Use alpha channel and create a
raw image.
Examples:
etcpack img.ppm myImages Compresses img.ppm to myImages\img.pkm
in ETC2 RGB format.
etcpack img.ppm myImages -ktx Compresses img.ppm to myImages\img.ktx
in ETC2 RGB format.
etcpack img.pkm myImages Decompresses img.pkm to
myImages\img.ppm.
etcpack img.ppm myImages -s slow Compress img.ppm to myImages\img.pkm
using the slow mode.
etcpack img.tga MyImages -f RGBA Compresses img.tga to MyImages\img.pkm
using etc2 + alpha.
etcpack img.ppm MyImages -f RG Compresses red and green channels of
img.ppm to MyImages\img.pkm.
etcpack img.pkm MyImages -ext JPG Decompresses img.pkm to
MyImages\img.jpg.
etcpack orig.ppm images\copy.ppm -p Calculate PSNR between orig.ppm and
images\copy.ppm.
Instead of output directory a full
file path is given as a second
parameter.