Phaser Compact Texture Atlas(PCT)格式规范详解:从行格式到加载器实现
【免费下载链接】phaserPhaser is a fun, free and fast 2D game framework for making HTML5 games for desktop and mobile web browsers, supporting Canvas and WebGL rendering.项目地址: https://gitcode.com/gh_mirrors/ph/phaser
导读
Phaser Compact Texture Atlas(简称 PCT)是 Phaser 4 引入的一种紧凑型纹理图集描述格式。它以纯文本、逐行记录的方式描述纹理图集,相比传统的 JSON 图集描述文件体积通常缩小 90%~95%,同时保持运行时可极低成本解析。本文以仓库中的官方规范文档(Phaser Compact Texture Atlas Format Specification)为骨架,结合源码实现与测试用例,完整讲解 PCT 的 8 种记录类型、名称编码规则、解码算法与设计取舍。读完本文,你将具备独立实现 PCT 解析器/加载器的能力,并理解 Phaser 内部如何用atlasPCT加载器把.pct文件与图集图片组装成可用的多源纹理。
一、PCT 格式总览
一个.pct文件是普通的 UTF-8 纯文本文件,每一行是一条记录(record),解析器自上而下单趟扫描。文件中共有 8 种记录类型,通过前缀字符或模式区分:
| 前缀 | 记录类型 | 用途 |
|---|---|---|
PCT: | 版本头 | 文件标识与格式版本声明(必须是第一行) |
P: | 页头 | 声明一张图集图片及其尺寸 |
F: | 文件夹条目 | 在文件夹字典中声明一个文件夹名 |
# | 页选择器 | 将后续帧切换到不同的图集页 |
B: | 块头 | 声明一个同尺寸精灵组成的网格块 |
A: | 别名 | 将重复精灵映射到已存在的帧 |
name\| | 单帧 | 一个带显式位置的精灵 |
names,... | 块名称行 | 前一块的名称列表(逗号分隔) |
记录必须按如下顺序出现:第 1 行是PCT:版本头,之后是全部P:页头,再之后是全部F:文件夹条目,然后是帧数据(可以穿插#、B:、块名称行与单帧行),最后是A:别名记录。别名放在最后,是因为它们按名称引用帧,加载器通过复制已存在的条目来解析别名。
实现事实:仓库中的解码器 PCTDecode.js 严格实现了上述顺序约定,且对未知前缀行采取"静默跳过"策略(见 PCTDecode.js 中对不足 3 个
|分段的行直接continue的处理)。
二、八种记录类型详解
2.1 版本头(PCT:)
每个 PCT 文件的第一行必须是版本头。它同时充当文件标识(magic string)与格式版本声明:
PCT:1.0版本号采用major.minor的 semver 风格编号:
| 组件 | 含义 |
|---|---|
| Major | 破坏性变更。v2 文件无法被 v1 解析器加载。 |
| Minor | 增量特性。v1.2 文件可以在 v1.0 解析器中加载——未知特性会被安全忽略。 |
加载器校验逻辑:
const firstLine = lines[0]; if (!firstLine.startsWith('PCT:')) { throw new Error('Not a PCT file'); } const [major, minor] = firstLine.slice(4).split('.').map(Number); if (major > 1) { throw new Error('Unsupported PCT version ' + major); } // Minor features can be checked as needed: // const hasRotation = minor >= 1;加载器应当拒绝无法识别的主版本号,但接受无法识别的次版本号。未来次版本中引入的未知行前缀应被静默跳过——前缀驱动的解析器天然支持这一点。
源码实现中,PCTDecode.js 对版本校验做了额外的健壮性处理:先剥离首行末尾可能的\r(兼容 CRLF 换行),再校验PCT:前缀,并解析主版本号;当主版本缺失或大于 1 时,通过console.warn输出警告并返回null。这些边界行为都有对应的测试覆盖,见 PCTDecode.test.js:非字符串输入、空字符串、缺失PCT:头、PCT:2.0主版本超限都应返回null,而PCT:1.5这类次版本增量必须能正常加载,CRLF 换行也能被正确处理。
版本历史:
| 版本 | 变更 |
|---|---|
| 1.0 | 初始发布。支持块、文件夹、范围、扩展名、别名、多页。 |
2.2 页头(P:)
声明一张图集页。多图集文件有多个P:行,单图集文件有一个。
P:atlas_0.png,RGBA8888,2048,512,2字段(逗号分隔):
| 位置 | 字段 | 类型 | 描述 |
|---|---|---|---|
| 0 | filename | string | 纹理图片文件名 |
| 1 | format | string | 像素格式(当前始终为RGBA8888) |
| 2 | width | int | 图集宽度(像素) |
| 3 | height | int | 图集高度(像素) |
| 4 | padding | int | 打包时使用的形状填充(shape padding)值 |
padding 值仅作信息用途——它告诉加载器精灵之间预留的间隙。帧坐标已经包含了 padding,因此加载器不需要再应用它。
所有P:行出现在文件顶部、任何其他记录类型之前。页按出现顺序从 0 开始编号。
在解码器中,每个P:行被解析为pages数组中的一个对象(filename / format / width / height / padding),见 PCTDecode.js。测试 PCTDecode.test.js 验证了单页与多页两种情况下页数组的解析结果。
2.3 文件夹条目(F:)
在文件夹字典中声明一个文件夹名。文件夹条目出现在所有P:页头之后、任何帧数据之前:
F:warrior F:knight/idle F:effects每个F:行向文件夹字典添加一个条目,按出现顺序从 0 开始编号。帧名称通过该数字索引引用文件夹,而不是重复完整的文件夹字符串。如果没有文件夹(所有精灵都在根层级),则没有F:行。
源码中folders数组按声明顺序压入,见 PCTDecode.js;测试验证了无文件夹时返回空数组、文件夹按顺序解析、以及文件夹名可以包含斜杠(如knight/idle)三种情况(PCTDecode.test.js)。
2.4 页选择器(#)
将后续帧数据切换到另一张图集页。仅在存在多个页时出现:
#0 #1#后的数字是零基页索引,对应P:页头的顺序。跟在#N行之后的所有帧、块和单帧都属于页 N,直到下一个#行或文件结束。
如果只有一个页,则没有#行,所有帧数据隐式属于页 0。
解码器中currentPage变量记录当前页,#行直接更新该变量(PCTDecode.js)。测试验证了块帧和单帧都能被正确路由到目标页,且缺省时默认页为 0(PCTDecode.test.js)。
2.5 块头(B:)
声明一个网格块——由同尺寸精灵按网格排列形成的矩形区域。B:头的下一行总是该块的名称行。
未裁剪(untrimmed)块:
B:2,2,8,64,64已裁剪(trimmed)块:
B:2,2,6,120,108|134,120,4,6|之前的字段(始终存在):
| 位置 | 字段 | 类型 | 描述 |
|---|---|---|---|
| 0 | x | int | 块在图集中的原点 X(第一个单元格左上角) |
| 1 | y | int | 块在图集中的原点 Y |
| 2 | cols | int | 网格列数 |
| 3 | frameW | int | 每个帧的宽度(裁剪/打包后的尺寸) |
| 4 | frameH | int | 每个帧的高度 |
|之后的字段(仅裁剪时存在):
| 位置 | 字段 | 类型 | 描述 |
|---|---|---|---|
| 0 | sourceW | int | 裁剪前的原始源图像宽度 |
| 1 | sourceH | int | 裁剪前的原始源图像高度 |
| 2 | trimX | int | 裁剪区域在源图像中的 X 偏移 |
| 3 | trimY | int | 裁剪区域在源图像中的 Y 偏移 |
对于裁剪块,spriteSourceSize.w和spriteSourceSize.h分别等于frameW和frameH——它们不会被单独存储。
行数通过推导得出:rows = ceil(spriteCount / cols)。
2.6 块名称行
紧跟在B:头之后,包含块内所有精灵的名称,可以使用范围压缩、文件夹索引和扩展名索引:
0/idle#01-24该行被解析为有序精灵名称列表。每个名称映射到一个网格单元:索引为i的精灵占据列i % cols、行floor(i / cols)的单元。
每个精灵的位置按如下推导:
cellW = frameW + padding * 2 cellH = frameH + padding * 2 sprite[i].x = blockX + (i % cols) * cellW + padding sprite[i].y = blockY + floor(i / cols) * cellH + padding其中padding是页头中的形状填充值。
实现细节:源码中有一个非常关键的约定——待处理的块(pendingBlock)总是把下一个非空行当作名称行消费,无论该行以什么前缀字符开头(PCTDecode.js)。这意味着即使名称行恰好以
#开头也不会被误判为页选择器。测试 PCTDecode.test.js 专门构造了B:0,0,3,10,10后跟#1-3的边界用例,验证展开出的帧名为1、2、3而不是被当作页选择器。
2.7 单帧
一个带显式位置和尺寸的独立精灵,用于不属于任何块组的精灵。
未裁剪:
sword|0|726,2,86,42已裁剪:
shield|2|726,48,72,68|80,80,4,6字段(以|分隔):
| 分段 | 字段 | 描述 |
|---|---|---|
| 0 | name | 精灵名称(可含文件夹索引与扩展名索引) |
| 1 | flags | 位标志:bit 0 = 旋转,bit 1 = 已裁剪 |
| 2 | x,y,w,h | 图集中的帧矩形 |
| 3 | sw,sh,sx,sy | 源尺寸与源内裁剪偏移(仅裁剪时存在) |
分段 3 存在时,把 4 个裁剪值打包进一个逗号分隔的字段,与B:块头中|分隔的裁剪字段布局保持一致。因此已裁剪的单帧恰好有 4 个|分段;未裁剪的单帧有 3 个。
对于未裁剪帧(flags bit 1 = 0):
sourceSize={ w: w, h: h }spriteSourceSize={ x: 0, y: 0, w: w, h: h }
对于已裁剪帧(flags bit 1 = 1):
sourceSize={ w: sw, h: sh }spriteSourceSize={ x: sx, y: sy, w: w, h: h }
旋转标志(bit 0)保留给未来使用。目前所有帧都按无旋转方式打包。
源码中 flags 的位运算解析见 PCTDecode.js:isTrimmed = (flags & 2) !== 0、rotated = (flags & 1) !== 0。测试覆盖了 flags=0(未裁剪)、flags=1(仅旋转)、flags=2(仅裁剪)、flags=3(裁剪+旋转)四种组合,见 PCTDecode.test.js。值得注意的是,虽然规范说明旋转目前保留未用,但解析器已经实现了旋转位——当src.rotated为真时,PCT.js 会把帧标记为rotated并调用updateUVsInverted()更新 UV 坐标,说明实现层面已经为旋转帧预留了完整能力。
2.8 别名(A:)
将重复精灵(打包时检测到的像素完全相同的帧)映射到图集中已有的原始帧。别名行出现在所有帧数据之后:
A:0/idle_01=0/idle_12,0/idle_18格式:A:originalName=duplicateName1,duplicateName2,...
重复名称列表可以使用范围压缩。
加载器应为每个重复名称创建帧条目,复制原帧的所有属性(位置、尺寸、裁剪数据)。重复名称不出现在纹理图片中——它们共享原帧的图集区域。
源码中别名的处理逻辑见 PCTDecode.js:先解析=两侧的名称(两侧都支持文件夹/扩展名索引),若原帧存在则对每个重复名执行属性复制并覆盖key字段;若原帧不存在或=缺失,则静默跳过。测试 PCTDecode.test.js 验证了:别名帧共享原帧位置、副本 key 为自己的名称、深拷贝(修改副本不影响原帧)、缺失原帧时静默跳过、缺失=时静默跳过、以及=两侧都解析文件夹/扩展名索引等行为。
三、名称编码规则
PCT 格式中的精灵名称包含三层编码:文件夹索引、扩展名索引和顺序范围压缩。解码时按以下顺序应用。
3.1 文件夹索引
名称可以以数字文件夹索引加/作为前缀:
0/idle_01 → folder[0] + "/" + "idle_01" 2/spark_05 → folder[2] + "/" + "spark_05" sword → no folder (root level)如果名称以数字加/开头,斜杠前的数字是文件夹字典索引。从F:条目中查出文件夹名,并加/分隔符拼成完整 key。没有/前缀的名称是根级精灵。
源码中对"数字"的判定使用了isAllDigits辅助函数(PCTDecode.js),逐字符检查charCodeAt是否落在48~57(即 ASCII0~9)区间。测试专门验证了abc/def这种非数字前缀不会被误判为文件夹索引(PCTDecode.test.js)。
3.2 扩展名索引
名称可以以~N结尾,其中 N 是硬编码的扩展名字典索引:
| 索引 | 扩展名 |
|---|---|
| 1 | .png |
| 2 | .webp |
| 3 | .jpg |
| 4 | .jpeg |
| 5 | .gif |
sword~1 → "sword.png" frame1~3 → "frame1.jpg" 0/idle_01~2 → folder[0] + "/idle_01.webp" heightmap.tga → "heightmap.tga" (unknown ext stored raw)如果名称以~加单个数字 1-5 结尾,去掉后缀并追加对应扩展名。如果名称包含.但没有~,说明它带的是字典之外的原始扩展名——原样使用。如果既没有~也没有.,说明名称没有扩展名(打包时被剥离)。
源码中的扩展名字典定义在 PCTDecode.js:EXT = { 1: '.png', 2: '.webp', 3: '.jpg', 4: '.jpeg', 5: '.gif' }。测试验证了 5 个扩展名索引都能正确映射(PCTDecode.test.js),以及heightmap.tga这种原始扩展名原样保留(PCTDecode.test.js)。
3.3 范围压缩
在块名称行中,连续名称可以压缩为范围记号:
frame#1-23 → frame1, frame2, frame3, ..., frame23 walk_#01-08 → walk_01, walk_02, ..., walk_08 idle#000-059 → idle000, idle001, ..., idle059格式:prefix#start-end
解码规则:
- 在逗号处拆分名称行得到分段
- 检查每个分段是否包含
# - 若包含:在
#处拆分为prefix和range - 在
-处拆分range得到startStr和endStr - 将两者解析为整数
start和end - 如果
startStr有前导零(长度 > 1 且首字符为0),生成的所有数字按startStr.length补零 - 生成名称:对
start到end(含)的每个i,输出prefix + pad(i) - 如果没有
#,该分段是字面名称
范围、字面名称和文件夹/扩展名索引可以组合:
0/frame#1-23~1展开为文件夹0、帧frame1.png到frame23.png。
组合形式的解码顺序:
- 在
,处拆分得到分段 - 展开任何
#范围为单个名称字符串 - 对每个名称:解析文件夹索引(去掉
N/前缀) - 对每个名称:解析扩展名索引(去掉
~N后缀)
补零逻辑在源码中体现为zeroPad辅助函数(PCTDecode.js)以及padLen的判断(startStr.length > 1 && startStr.charAt(0) === '0')。测试验证了idle_#001-012只生成补零的idle_001~idle_012而不会生成idle_12(PCTDecode.test.js),以及frame#1-5不补零(PCTDecode.test.js)。同一行混用字面名称与范围(如intro,frame#1-3,outro)也有测试覆盖(PCTDecode.test.js)。
3.4 带扩展名后缀的范围压缩
当块名称行带扩展名后缀时,它只出现在整行末尾一次,在所有名称和范围之后:
0/frame#1-23~1~1应用于行内所有名称。解码器应当:
- 检查行是否以
~N(N 为 1-5)结尾 - 若是,剥离并记录该扩展名
- 解析剩余字符串中的范围和名称
- 将扩展名应用到每个生成的名称
注意优先级:源码实现中,逐名称的
~N会覆盖行级后缀。resolveFullName先检查名称自身的~N匹配(PCTDecode.js),命中则直接替换扩展名;否则才使用行级extSuffix。测试a,b,c~1验证了行级后缀应用到所有名称(PCTDecode.test.js)。
四、完整解码算法
以下伪代码完整呈现 PCT 文件的解码流程(对应仓库实现 PCTDecode.js):
function decodePCT(text): lines = text.split('\n') pages = [] folders = [] currentPage = 0 frames = {} // key → frame data // Extension dictionary EXT = { 1:'.png', 2:'.webp', 3:'.jpg', 4:'.jpeg', 5:'.gif' } pendingBlock = null for line in lines: if line is empty: continue // A pending block ALWAYS consumes the next non-empty line as its names // line, regardless of what prefix character that line happens to start // with. This must be checked before any prefix-based dispatch below. if pendingBlock is not null: // This line is the names for the pending block block = pendingBlock pendingBlock = null padding = pages[block.page].padding cellW = block.frameW + padding * 2 cellH = block.frameH + padding * 2 names = expandNames(line, folders, EXT) for i, name in enumerate(names): col = i % block.cols row = floor(i / block.cols) frame = { key: name, page: block.page, x: block.x + col * cellW + padding, y: block.y + row * cellH + padding, w: block.frameW, h: block.frameH, trimmed: block.trimmed, rotated: false } if block.trimmed: frame.sourceW = block.sourceW frame.sourceH = block.sourceH frame.trimX = block.trimX frame.trimY = block.trimY else: frame.sourceW = block.frameW frame.sourceH = block.frameH frame.trimX = 0 frame.trimY = 0 frames[name] = frame continue if line starts with 'P:': parts = line[2:].split(',') pages.push({ filename: parts[0], format: parts[1], width: int(parts[2]), height: int(parts[3]), padding: int(parts[4]) }) else if line starts with 'F:': folders.push(line[2:]) else if line starts with '#': currentPage = int(line[1:]) else if line starts with 'B:': // Parse block header trimParts = line[2:].split('|') main = trimParts[0].split(',') block = { page: currentPage, x: int(main[0]), y: int(main[1]), cols: int(main[2]), frameW: int(main[3]), frameH: int(main[4]), trimmed: trimParts.length > 1 } if block.trimmed: trim = trimParts[1].split(',') block.sourceW = int(trim[0]) block.sourceH = int(trim[1]) block.trimX = int(trim[2]) block.trimY = int(trim[3]) pendingBlock = block else if line starts with 'A:': // Parse alias eqIdx = line.indexOf('=', 2) originalName = resolveFullName(line[2:eqIdx], folders, EXT, '') dupNames = expandNames(line[eqIdx+1:], folders, EXT) for name in dupNames: frames[name] = copy(frames[originalName]) frames[name].key = name else: // Individual frame line parts = line.split('|') name = resolveFullName(parts[0], folders, EXT, '') flags = int(parts[1]) trimmed = (flags & 2) != 0 fv = parts[2].split(',') frame = { key: name, page: currentPage, x: int(fv[0]), y: int(fv[1]), w: int(fv[2]), h: int(fv[3]), trimmed: trimmed, rotated: (flags & 1) != 0 } if trimmed: tv = parts[3].split(',') frame.sourceW = int(tv[0]) frame.sourceH = int(tv[1]) frame.trimX = int(tv[2]) frame.trimY = int(tv[3]) else: frame.sourceW = frame.w frame.sourceH = frame.h frame.trimX = 0 frame.trimY = 0 frames[name] = frame return { pages, folders, frames }辅助函数:expandNames(line, folders, EXT)
function expandNames(line, folders, EXT): // Check for trailing extension suffix: ~N at very end extSuffix = '' match = line.match(/~([1-5])$/) if match: extSuffix = EXT[int(match[1])] line = line[0:-2] // strip ~N results = [] segments = line.split(',') for segment in segments: if '#' in segment: hashIdx = segment.indexOf('#') prefix = segment[0:hashIdx] range = segment[hashIdx+1:] dashIdx = range.indexOf('-') startStr = range[0:dashIdx] endStr = range[dashIdx+1:] start = int(startStr) end = int(endStr) padLen = startStr.length if (startStr.length > 1 and startStr[0] == '0') else 0 for i from start to end inclusive: numStr = padLen > 0 ? zeroPad(i, padLen) : str(i) rawName = prefix + numStr results.push(resolveFullName(rawName, folders, EXT, extSuffix)) else: results.push(resolveFullName(segment, folders, EXT, extSuffix)) return results辅助函数:resolveFullName(raw, folders, EXT, extSuffix)
function resolveFullName(raw, folders, EXT, extSuffix): name = raw folder = '' // Folder index: "N/rest" slashIdx = name.indexOf('/') if slashIdx > 0 and name[0:slashIdx] is all digits: folderIdx = int(name[0:slashIdx]) folder = folders[folderIdx] name = name[slashIdx+1:] // Extension index: "name~N" (per-name, overrides line-level) match = name.match(/~([1-5])$/) if match: ext = EXT[int(match[1])] name = name[0:-2] + ext else if extSuffix: name = name + extSuffix // else: name has no extension or contains a raw one (e.g. ".tga") if folder: return folder + '/' + name return name与规范的差异说明:仓库实现与上述伪代码在健壮性上略有增强——解码前会先校验输入是否为非空字符串,并剥离每行末尾的
\r以兼容 CRLF 换行文件(PCTDecode.js);校验失败时通过console.warn输出警告而非抛异常,并返回null交由上层处理。测试 PCTDecode.test.js 覆盖了 CRLF 与空行跳过两种边界情况。
五、完整示例
5.1 示例 1:简单块
8 个 64×64 未裁剪精灵打包进单行。
PCT:1.0 P:atlas_0.png,RGBA8888,1024,256,2 B:2,2,8,64,64 frame#1-8产生 8 个名为frame1到frame8的帧,每个 64×64 像素,位置由块网格推导,单元间 2px padding。
解码后的帧位置:
| 名称 | X | Y |
|---|---|---|
| frame1 | 4 | 4 |
| frame2 | 72 | 4 |
| frame3 | 140 | 4 |
| frame4 | 208 | 4 |
| frame5 | 276 | 4 |
| frame6 | 344 | 4 |
| frame7 | 412 | 4 |
| frame8 | 480 | 4 |
单元宽度 = 64 + 2×2 = 68。位置 = blockX + col × 68 + 2。
这个示例与源码测试 PCTDecode.test.js 中的 "spec Example 1" 用例完全一致,测试断言了这 8 个帧的精确坐标(4、72、140、208、276、344、412、480),可直接对照验证。
5.2 示例 2:多页 + 全部特性
两张图集页、三个文件夹、裁剪块、单帧、扩展名索引、范围压缩和别名。
PCT:1.0 P:atlas_0.png,RGBA8888,2048,512,2 P:atlas_1.png,RGBA8888,2048,256,2 F:warrior F:knight F:effects #0 B:2,2,6,120,108|134,120,4,6 0/idle_#01-24~1 B:2,222,6,120,108|134,120,4,6 1/idle_#01-18~1 sword~1|0|726,2,86,42 shield~1|2|726,48,72,68|80,80,4,6 #1 B:2,2,10,48,48 2/spark_#01-30~1 A:0/idle_01~1=0/idle_12,0/idle_18~1 A:1/idle_01~1=1/idle_09~1解码结果:
页头:
- 页 0:
atlas_0.png,2048×512,padding 2 - 页 1:
atlas_1.png,2048×256,padding 2
文件夹:0=warrior,1=knight,2=effects
页 0 帧:
- (2,2) 处的块:6 列 120×108 帧,从 134×120 源裁剪,偏移 (4,6)。名称:
warrior/idle_01.png~warrior/idle_24.png(24 帧,4 行 × 6 列) - (2,222) 处的块:尺寸相同,名称:
knight/idle_01.png~knight/idle_18.png(18 帧,3 行 × 6 列) sword.png:未裁剪单帧,位于 (726,2),尺寸 86×42,页 0shield.png:裁剪单帧,位于 (726,48),帧 72×68,源 80×80,裁剪偏移 (4,6)
页 1 帧:
- (2,2) 处的块:10 列 48×48 未裁剪帧。名称:
effects/spark_01.png~effects/spark_30.png
别名:
warrior/idle_12.png和warrior/idle_18.png与warrior/idle_01.png像素相同——共享其图集位置knight/idle_09.png与knight/idle_01.png像素相同
这个完整示例被端到端测试原样采用(PCTDecode.test.js),测试断言了:2 页、3 文件夹、74 个帧(24+18+30+2)、
warrior/idle_01.png位于文档所述 (4,4)、warrior/idle_24.png位于 4 行块的最后一个单元 (624,340)(对应 col=5、row=3、cellW=124、cellH=112)、effects/spark_01.png路由到页 1、别名帧共享原帧坐标等细节。这些断言既是测试,也是规范可执行化的最佳佐证。
5.3 示例 3:最小化
单个未裁剪精灵,无文件夹、无块。
PCT:1.0 P:atlas_0.png,RGBA8888,256,256,1 logo|0|1,1,200,180一个名为logo的帧,位于 (1,1),尺寸 200×180,未裁剪,256×256 图集,1px padding。
六、帧数据结构
解码完成后,每个帧应为渲染引擎提供以下字段:
| 字段 | 类型 | 描述 |
|---|---|---|
| key | string | 唯一帧名称(含文件夹路径与扩展名,若存在) |
| page | int | 该帧所在的图集页(pages 数组索引) |
| x | int | 帧在图集中的 X 位置 |
| y | int | 帧在图集中的 Y 位置 |
| w | int | 帧在图集中的宽度 |
| h | int | 帧在图集中的高度 |
| sourceW | int | 原始源图像宽度 |
| sourceH | int | 原始源图像高度 |
| trimX | int | 帧在原始源中的 X 偏移 |
| trimY | int | 帧在原始源中的 Y 偏移 |
| trimmed | bool | 该帧是否从源中裁剪过 |
| rotated | bool | 该帧是否在图集中顺时针旋转 90°(保留,当前恒为 false) |
未裁剪帧:sourceW = w、sourceH = h、trimX = 0、trimY = 0。
按原始源尺寸渲染时,在(sourceW, sourceH)的画布中以偏移(trimX, trimY)绘制图集区域(x, y, w, h)。
在 Phaser 中,这个帧结构通过 PCT.js 解析器落到实际的Frame对象上:texture.add(src.key || key, sourceIndex, src.x, src.y, src.w, src.h)创建帧,setTrim(sourceW, sourceH, trimX, trimY, w, h)设置裁剪信息(PCT.js)。该解析器还额外做了一件事:为页 0 源添加__BASE帧(与 JSONArray/JSONHash 解析器行为一致),并把pages与folders元数据挂到纹理的customData['pct']上,方便运行时检视。
七、在 Phaser 中加载 PCT 图集
7.1 加载器用法
PCT 图集通过LoaderPlugin#atlasPCT方法加载(定义在 PCTAtlasFile.js):
function preload () { this.load.atlasPCT('level1', 'images/Level1.pct'); }也支持配置对象形式:
this.load.atlasPCT({ key: 'level1', atlasURL: 'images/Level1.pct' });加载完成后即可按 key 使用帧:
this.add.image(x, y, 'level1', 'background');解码后的 PCT 数据(含页、文件夹与帧元数据)还可以从 Atlas 缓存中取回:
var data = this.cache.atlas.get('level1');如果未指定 URL,加载器会取 key 并生成<key>.pct作为文件名(例如 key 为alien时 URL 为alien.pct,扩展名固定为.pct)。
7.2 内部工作流程
从 PCTAtlasFile.js 的实现可以看到完整的内部链路:
- 加载数据文件:内部类
PCTDataFile以responseType: 'text'请求.pct文件,onProcess阶段调用PCTDecode(this.xhrLoader.responseText)把文本解码为结构化对象(含pages、folders、frames),解码失败则进入onProcessError(PCTAtlasFile.js)。 - 动态排队图片:
onFileComplete中检查解码出的pages数组,为每一页动态创建并排队一个ImageFile(图片 key 形如PCT{multiKeyIndex}_{filename}),期间会临时覆盖加载器的 baseURL / path / prefix 以支持页图片位于不同路径的场景,处理完再恢复(PCTAtlasFile.js)。 - 组装纹理:
addToCache阶段按文件名把各页图片按页顺序组装为images数组,将解码数据写入 Atlas 缓存,并调用textureManager.addAtlasPCT(key, images, decoded)生成单一的多源纹理(PCTAtlasFile.js)。
TextureManager.addAtlasPCT是最终入口(TextureManager.js),它接收按页顺序排列的图片源与解码数据,交给 PCT.js 解析器逐帧创建Frame。
加载器测试 PCTAtlasFile.test.js 验证了这些行为:构造器只创建一个pct类型的子文件并指向 atlas 缓存、无 URL 时默认扩展名为.pct、onFileComplete为每页创建ImageFile、处理后恢复加载器原有 baseURL / path / prefix 等。
八、设计说明
8.1 为什么用文本而不是二进制?
在典型图集规模(50-500 帧)下,文本 PCT 文件只有 200-2000 字节。HTTP 标准使用的 Gzip 压缩会让文本与二进制格式的体积差距缩小到几个百分点以内。而文本格式带来的是:人类可读、版本控制中易于 diff、用split()即可平凡解析、没有字节序(endianness)问题。
8.2 块分组标准
当 4 个或更多精灵共享相同的签名(相同的frameW、frameH,若裁剪则还需相同的sourceW、sourceH、trimX、trimY)时,它们被分组成块。这个阈值在数据节省与打包效率之间取得平衡——更小的分组会过度约束 MaxRects 打包器,却无法获得足够的数据体积收益。
8.3 组感知裁剪
当多个精灵共享相同的源尺寸时,裁剪计算的是所有帧的并集包围盒,而不是各自独立裁剪。这确保了动画序列中的所有帧具有一致的裁剪边界,防止播放期间出现视觉抖动,同时使块分组成为可能。
8.4 列策略
块列数不固定为最大宽度。打包器测试三种策略(近方形、半宽、全宽),选择总图集最紧凑的方案。全宽条带在最后一行不完整时会浪费空间;更方正的块则为其他精灵在旁留出空间。
8.5 行拆分
如果块最后一行未填满,它会被拆分成一个满行的块和一个较小的余数块。这可以防止浪费的单元格占用本可由其他精灵填充的图集空间。
九、PCT 在 Phaser 源码中的完整脉络
如果你希望深入研读 PCT 的实现,仓库中的关键文件如下:
- 规范文档:Phaser Compact Texture Atlas Format Specification.md(本文依据的原始规范)
- 核心解码器:PCTDecode.js(
PCTDecode(text)返回{ pages, folders, frames },解码失败返回null) - 纹理解析器:PCT.js(把解码结构转换为 Texture 上的 Frame)
- 解析器注册表:parsers/index.js(
PCT与PCTDecode均挂在Phaser.Textures.Parsers命名空间下,从代码注释看两者自 Phaser 4.0.0 起提供) - 加载器:PCTAtlasFile.js(
atlasPCT加载器,负责数据文件 + 多页图片的组合加载) - 纹理管理器入口:TextureManager.js(
addAtlasPCT(key, source, data, dataSource),自 4.0.0 起提供) - 单元测试:PCTDecode.test.js(解码器全量行为验证,含规范三个示例的端到端断言)、PCTAtlasFile.test.js(加载器组装行为验证)
结语
PCT 是 Phaser 对纹理图集描述格式的一次务实优化:在保持"人类可读、极易解析"的前提下,通过页头 + 文件夹字典 + 网格块 + 范围压缩 + 扩展名索引 + 别名的组合把数据体积压缩 90%~95%。规范文档为每种记录类型、每种编码组合都给出了精确定义,而仓库中的 PCTDecode.js、PCT.js 与两套测试文件则让这份规范有了可执行、可验证的实现参照。无论是想为 PCT 编写第三方打包工具,还是要在自己的引擎中实现兼容解析器,本文所整理的内容都足以作为完整的实现依据。
【免费下载链接】phaserPhaser is a fun, free and fast 2D game framework for making HTML5 games for desktop and mobile web browsers, supporting Canvas and WebGL rendering.项目地址: https://gitcode.com/gh_mirrors/ph/phaser
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考