PKM纹理压缩踩坑记录:ETC格式与资源加密

声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。

更新说明:技术栈版本信息基于 Python 3.10+ / OpenGL ES 3.0 / ETC2。

PKM纹理压缩踩坑记录

在做游戏资源优化时,研究了ETC纹理压缩和PKM文件加密,记录一下实现过程。

纹理压缩技术基础

为什么需要纹理压缩

在移动设备上,未经压缩的纹理资源会带来严重问题:

问题 影响 解决方案
内存占用高 容易导致OOM 使用压缩格式
显存压力大 帧率下降 GPU原生支持的压缩格式
加载时间长 用户体验差 减少数据量
包体过大 下载转化率低 压缩存储

主流纹理压缩格式对比

格式 压缩比 支持平台 透明度 GPU解压
ETC1 6:1 OpenGL ES 2.0+ 不支持 硬件支持
ETC2 6:1~8:1 OpenGL ES 3.0+ 支持 硬件支持
PVRTC 8:1 PowerVR GPU 支持 硬件支持
ASTC 4:1~12:1 OpenGL ES 3.2+ 支持 硬件支持

ETC格式的优势:

  • 跨平台支持好(Android主流)
  • 解压速度快(GPU硬件解压)
  • 压缩率高(ETC2支持透明度)
  • 免费使用(无专利费)

ETC压缩原理

ETC(Ericsson Texture Compression)采用分块压缩策略:

1
2
3
4
5
6
7
8
基本原理:
1. 将纹理划分为4x4像素块
2. 每个块使用基础颜色+修正值
3. 通过颜色查找表还原像素

压缩方式:
- ETC1: 64位存储16个像素,6:1压缩
- ETC2: 增加Alpha通道支持,支持4-8:1压缩

PKM文件格式详解

PKM文件结构

PKM是ETC压缩纹理的容器格式,文件头包含元数据信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PKM文件结构:
+----------------+------------------+------------------+
| Header | Metadata | ETC Data |
| (16 bytes) | (variable) | (compressed) |
+----------------+------------------+------------------+

Header格式:
- Magic: "PKM " (4 bytes)
- Version: "10" (2 bytes)
- Texture type: 2 bytes
- Extent width: 2 bytes
- Extent height: 2 bytes
- Padded width: 2 bytes
- Padded height: 2 bytes

PNG与PKM互转

etcpack工具使用

etcpack是Ericsson官方提供的ETC压缩工具,支持PNG与PKM的相互转换。

PNG转PKM(ETC1):

1
2
3
4
5
6
7
8
# 基本转换命令
etcpack.exe input.png output_dir -c etc1 -s slow -as -ext PNG

# 参数说明:
# -c etc1 : 使用ETC1压缩格式
# -s slow : 使用慢速高质量压缩
# -as : 生成Alpha通道(透明度)
# -ext PNG : 保留PNG扩展名(可选)

PNG转PKM(ETC2):

1
2
# ETC2支持透明度
etcpack.exe input.png output_dir -c etc2 -s slow

PKM转PNG:

1
2
# 解压回PNG
etcpack input.pkm output_dir -ext PNG

参数详解:

参数 可选值 说明
-c etc1, etc2 压缩格式
-s fast, slow 压缩速度
-as - 生成Alpha通道
-ext PNG, PVR, etc 输出文件扩展名

批量转换脚本

Python批量处理脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import os
import subprocess
import argparse
from pathlib import Path

def convert_png_to_pkm(input_dir, output_dir, format='etc1', speed='slow'):
"""
批量将PNG转换为PKM

Args:
input_dir: 输入PNG目录
output_dir: 输出PKM目录
format: 压缩格式 etc1/et c2
speed: 压缩速度 fast/slow
"""
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)

# etcpack可执行文件路径
etcpack_path = "path/to/etcpack.exe"

# 遍历所有PNG文件
for png_file in Path(input_dir).glob("**/*.png"):
relative_path = png_file.relative_to(input_dir)
output_subdir = Path(output_dir) / relative_path.parent
os.makedirs(output_subdir, exist_ok=True)

# 构建命令
cmd = [
etcpack_path,
str(png_file),
str(output_subdir),
"-c", format,
"-s", speed,
"-as",
"-ext", "PNG"
]

try:
subprocess.run(cmd, check=True)
print(f"成功转换: {png_file.name}")
except subprocess.CalledProcessError as e:
print(f"转换失败: {png_file.name}, 错误: {e}")

def convert_pkm_to_png(input_dir, output_dir):
"""
批量将PKM转换回PNG
"""
os.makedirs(output_dir, exist_ok=True)
etcpack_path = "path/to/etcpack.exe"

for pkm_file in Path(input_dir).glob("**/*.pkm"):
relative_path = pkm_file.relative_to(input_dir)
output_subdir = Path(output_dir) / relative_path.parent
os.makedirs(output_subdir, exist_ok=True)

cmd = [
etcpack_path,
str(pkm_file),
str(output_subdir),
"-ext", "PNG"
]

try:
subprocess.run(cmd, check=True)
print(f"成功解压: {pkm_file.name}")
except subprocess.CalledProcessError as e:
print(f"解压失败: {pkm_file.name}, 错误: {e}")

if __name__ == "__main__":
parser = argparse.ArgumentParser(description='PKM转换工具')
parser.add_argument('--mode', choices=['to_pkm', 'to_png'], required=True)
parser.add_argument('--input', required=True, help='输入目录')
parser.add_argument('--output', required=True, help='输出目录')
parser.add_argument('--format', default='etc1', choices=['etc1', 'etc2'])

args = parser.parse_args()

if args.mode == 'to_pkm':
convert_png_to_pkm(args.input, args.output, args.format)
else:
convert_pkm_to_png(args.input, args.output)

Cocos Creator ETC批量配置

项目纹理配置

在Cocos Creator中,可以通过meta文件配置纹理压缩格式。

ETC配置结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"ver": "1.0.0",
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"textureUuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"assetType": "cc.Texture2D",
"platformSettings": {
"android": {
"formats": [
{
"name": "etc1",
"quality": "fast"
}
]
},
"ios": {
"formats": [
{
"name": "etc2",
"quality": "slow"
}
]
}
}
}

配置参数说明:

参数 说明 可选值
name 压缩格式 etc1, etc2, webp, jpg
quality 压缩质量 fast, slow

批量修改脚本

Node.js批量配置脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
const fs = require('fs');
const path = require('path');

// ETC压缩配置
const etcSettings = {
"android": {
"formats": [
{
"name": "etc1",
"quality": "fast"
}
]
},
"ios": {
"formats": [
{
"name": "etc2",
"quality": "slow"
}
]
}
};

/**
* 递归遍历目录修改meta文件
* @param {string} url - 目标目录
* @param {boolean} isCompress - 是否启用压缩
*/
function lookupDir(url, isCompress) {
if (!fs.existsSync(url)) {
console.error(`目录不存在: ${url}`);
return;
}

fs.readdir(url, (err, files) => {
if (err) {
console.error(`读取目录失败: ${err}`);
return;
}

files.forEach((file) => {
const curPath = path.join(url, file);
const stat = fs.statSync(curPath);

if (stat.isDirectory()) {
// 递归遍历子目录
lookupDir(curPath, isCompress);
} else {
// 处理meta文件
if (file.endsWith('.meta')) {
processMetaFile(curPath, isCompress);
}
}
});
});
}

/**
* 处理meta文件
* @param {string} filePath - meta文件路径
* @param {boolean} isCompress - 是否启用压缩
*/
function processMetaFile(filePath, isCompress) {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error(`读取文件失败: ${filePath}, 错误: ${err}`);
return;
}

try {
let obj = JSON.parse(data);

// 检查是否有platformSettings
if (obj && obj.platformSettings !== undefined) {
// 设置或清空压缩配置
obj.platformSettings = isCompress ? etcSettings : {};

// 写回文件
const writeData = JSON.stringify(obj, null, 2);
fs.writeFile(filePath, writeData, 'utf8', (err) => {
if (err) {
console.error(`写入文件失败: ${filePath}, 错误: ${err}`);
} else {
console.log(`成功更新: ${filePath}`);
}
});
}
} catch (parseErr) {
console.error(`解析JSON失败: ${filePath}, 错误: ${parseErr}`);
}
});
}

// 主程序
function main() {
let sourcePath = process.argv[2];
const isCompress = parseInt(process.argv[3]) === 1;

// 处理相对路径
if (!path.isAbsolute(sourcePath)) {
sourcePath = path.join(__dirname, sourcePath);
}

console.log(`处理目录: ${sourcePath}`);
console.log(`压缩状态: ${isCompress ? '启用' : '禁用'}`);

lookupDir(sourcePath, isCompress);
}

// 运行
if (require.main === module) {
if (process.argv.length < 4) {
console.log('用法: node setETC.js <目录路径> <0|1>');
console.log(' 0 - 禁用压缩');
console.log(' 1 - 启用压缩');
process.exit(1);
}
main();
}

module.exports = { lookupDir, processMetaFile };

Windows批处理脚本

1
2
3
4
5
6
7
8
9
10
@echo off
chcp 65001

:: 启用压缩
node setETC.js ./assets 1

:: 禁用压缩
:: node setETC.js ./assets 0

pause

使用说明

启用ETC压缩:

1
node setETC.js ./assets 1

禁用ETC压缩:

1
node setETC.js ./assets 0

配置说明:

  • 脚本会递归遍历指定目录下的所有.meta文件
  • 自动添加或移除platformSettings配置
  • 根据平台设置不同的压缩格式

PKM文件加密方案

为什么需要加密

虽然PKM已经是压缩格式,但仍可能被提取和还原:

  • 保护游戏美术资源
  • 防止竞争对手获取资源
  • 增加资源破解难度

XOR加密方案

Python加密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import os
import sys
import time
import glob

# 加密密钥(必须英文)
ENCRYPT_KEY = '!12345678!'
# 加密签名(用于验证)
ENCRYPT_SIG = '1234567'

def is_pkm_file(abs_path):
"""
检查文件是否为PKM文件

Args:
abs_path: 文件绝对路径

Returns:
bool: 是否为PKM文件
"""
is_file = os.path.isfile(abs_path)
file_ext = os.path.splitext(abs_path)[1].lower()
is_pkm_ext = (file_ext == ".pkm")
return is_file and is_pkm_ext

def xor_encrypt(file_data, key):
"""
XOR加密

Args:
file_data: 原始文件数据(bytes)
key: 加密密钥字符串

Returns:
bytes: 加密后的数据
"""
assert isinstance(key, str), "密钥必须是字符串"

key_bytes = key.encode('utf-8')
key_len = len(key_bytes)

# 转换为bytearray进行修改
file_data = bytearray(file_data)

# XOR加密
for i in range(len(file_data)):
key_index = i % key_len
file_data[i] = file_data[i] ^ key_bytes[key_index]

# 添加签名前缀
sig_bytes = ENCRYPT_SIG.encode('utf-8')
return sig_bytes + bytes(file_data)

def xor_decrypt(file_data, key):
"""
XOR解密

Args:
file_data: 加密后的文件数据(bytes)
key: 解密密钥字符串

Returns:
bytes: 解密后的数据
"""
assert isinstance(key, str), "密钥必须是字符串"

# 移除签名前缀
sig_len = len(ENCRYPT_SIG.encode('utf-8'))
if file_data[:sig_len] == ENCRYPT_SIG.encode('utf-8'):
file_data = file_data[sig_len:]

key_bytes = key.encode('utf-8')
key_len = len(key_bytes)

# 转换为bytearray
file_data = bytearray(file_data)

# XOR解密(XOR的逆操作是同样的XOR)
for i in range(len(file_data)):
key_index = i % key_len
file_data[i] = file_data[i] ^ key_bytes[key_index]

return bytes(file_data)

def process_pkm_file(file_path, mode='encrypt'):
"""
处理单个PKM文件

Args:
file_path: PKM文件路径
mode: 'encrypt' 或 'decrypt'
"""
try:
# 读取文件
with open(file_path, 'rb') as f:
file_data = f.read()

if mode == 'encrypt':
# 加密
encrypted_data = xor_encrypt(file_data, ENCRYPT_KEY)
# 删除原文件
os.remove(file_path)
# 写入加密文件
with open(file_path, 'wb') as f:
f.write(encrypted_data)
return True
else:
# 解密
decrypted_data = xor_decrypt(file_data, ENCRYPT_KEY)
# 写入解密文件
with open(file_path, 'wb') as f:
f.write(decrypted_data)
return True

except Exception as e:
print(f"处理文件失败 {file_path}: {e}")
return False

def batch_process(directory, mode='encrypt'):
"""
批量处理目录下的PKM文件

Args:
directory: 目标目录
mode: 'encrypt' 或 'decrypt'
"""
processed_count = 0
failed_count = 0

# 遍历目录
for root, dirs, files in os.walk(directory):
for file in files:
if file.lower().endswith('.pkm'):
file_path = os.path.join(root, file)
if process_pkm_file(file_path, mode):
processed_count += 1
print(f"已{mode}: {file_path}")
else:
failed_count += 1

print(f"\n处理完成:")
print(f"成功: {processed_count}")
print(f"失败: {failed_count}")

if __name__ == '__main__':
if len(sys.argv) < 3:
print("用法: python pkm_crypto.py <目录> <encrypt|decrypt>")
sys.exit(1)

target_dir = sys.argv[1]
mode = sys.argv[2]

if mode not in ['encrypt', 'decrypt']:
print("模式必须是 'encrypt' 或 'decrypt'")
sys.exit(1)

batch_process(target_dir, mode)

Cocos Creator加载加密PKM

JavaScript/TypeScript加载代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
const {ccclass, property} = cc._decorator;

// 加密配置
const ENCRYPT_KEY = '!12345678!';
const ENCRYPT_SIG = '1234567';

@ccclass
export default class EncryptedTextureLoader extends cc.Component {

/**
* XOR解密
*/
private xorDecrypt(data: Uint8Array, key: string): Uint8Array {
const keyBytes = new TextEncoder().encode(key);
const keyLen = keyBytes.length;

// 检查并移除签名
const sigBytes = new TextEncoder().encode(ENCRYPT_SIG);
let startIdx = 0;

// 验证签名
let hasSig = true;
for (let i = 0; i < sigBytes.length; i++) {
if (data[i] !== sigBytes[i]) {
hasSig = false;
break;
}
}

if (hasSig) {
startIdx = sigBytes.length;
}

// XOR解密
const result = new Uint8Array(data.length - startIdx);
for (let i = startIdx; i < data.length; i++) {
result[i - startIdx] = data[i] ^ keyBytes[(i - startIdx) % keyLen];
}

return result;
}

/**
* 加载加密的PKM纹理
*/
async loadEncryptedPkm(path: string): Promise<cc.Texture2D> {
return new Promise((resolve, reject) => {
// 使用cc.assetManager加载二进制数据
cc.assetManager.loadRemote(path, { responseType: 'arraybuffer' },
(err, data: ArrayBuffer) => {
if (err) {
reject(err);
return;
}

try {
// 解密数据
const encryptedData = new Uint8Array(data);
const decryptedData = this.xorDecrypt(encryptedData, ENCRYPT_KEY);

// 创建纹理
const texture = new cc.Texture2D();

// 从PKM数据初始化纹理
// 注意:实际使用时需要根据引擎API调整
// texture.initWithData(decryptedData, ...);

resolve(texture);
} catch (e) {
reject(e);
}
}
);
});
}
}

资源优化最佳实践

纹理压缩策略

平台 推荐格式 压缩比 注意事项
Android ETC1/ETC2 6:1~8:1 ETC1不支持透明度
iOS ETC2/PVRTC 6:1~8:1 优先ETC2
Web WebP/JPG 2:1~4:1 兼容性考虑

工作流程建议

1
2
3
4
5
6
7
8
9
10
11
美术出图(PNG) ->

工具压缩(PKM/ETC) ->

加密处理(可选) ->

打包发布 ->

运行时解密加载 ->

GPU解压渲染

性能对比

格式 内存占用 GPU解压速度 加载速度 画质
PNG(RGBA8888) 100% 无需解压 最好
ETC1 16.7% 极快 较好
ETC2 12.5%~16.7% 极快
WebP 100% 无需解压 中等

常见问题处理

ETC格式不支持透明度

问题:ETC1不支持Alpha通道

解决方案:

  1. 使用ETC2格式(OpenGL ES 3.0+)
  2. 分离Alpha通道为单独纹理
  3. 使用其他支持透明度的格式(PVRTC、ASTC)

纹理尺寸要求

要求:

  • ETC1:宽高必须是4的倍数
  • ETC2:宽高必须是4的倍数
  • PVRTC:宽高必须是2的幂且相等

处理方法:

1
2
3
4
5
def pad_to_multiple_of_4(width, height):
"""将尺寸调整为4的倍数"""
padded_width = ((width + 3) // 4) * 4
padded_height = ((height + 3) // 4) * 4
return padded_width, padded_height

加密性能开销

优化建议:

  1. 异步解密避免阻塞主线程
  2. 预加载和缓存解密后的纹理
  3. 只在需要时解密

总结

ETC纹理压缩能有效降低游戏包体和内存占用,配合PKM加密可以保护美术资源。建议Android平台优先使用ETC2,同时建立自动化的资源处理流程。


参考资源: