PixiJS 修炼指南 - 04. 资源加载(下)

上一篇中,我们实现的项目资源管理模块 AssetsManager 功能基本还只是雏形,这次我们来对它进行一些改进和加强,完善诸如对精灵表的支持、总进度回调这样的能力。

补充改进

其实相比普通的 Sprite 精灵对象,PixiJS 官方表示更推荐使用 Spritesheet “精灵表”。

官方文档: https://pixijs.io/guides/basics/sprite-sheets.html

它使用起来就像 Web 开发中的 CSS “雪碧图”,将许多的小图合并到一张大图内,再根据需求来控制展示大图的部分区域。

(顺带一提,其实“雪碧图”的原文 Sprites 精灵图,应该就是指早期电视游戏开发中就已有之的精灵图概念。只是 Web 开发的同学可能很多都是先接触到 CSS Sprites,再看到游戏开发的精灵图时反而有前者像后者的感觉。这波可以说“这爸爸长得真像儿子”了属于是。)

按照 PixiJS 文档中所说,SpriteSheets 至少有以下两个明显优点:

  • 首先,可以加快加载速度,在浏览器的下载并发限制下,减少请求数可以减少整个加载过程的等待时间;
  • 其次,它可以提升批量渲染,WebGL 的渲染时间打制和绘制调用次数成正比,同一个 Spritesheet 内的素材更有可能在同义词调用内批量渲染,提高渲染效率。

所以我们来改进一下之前的 AssetsManager,让它也支持快速配置 Spritesheet 资源的加载吧。

1. 加载精灵表

1-1. 使用精灵表

我们先用 TexturePacker 创建一个包含多个小图的精灵表素材,再将导出的 Json 和图片文件加入项目的 public/ 目录,随后就可以通过 Assets.load() 读取 Json 文件获得 Spritesheet 对象了:

1
2
3
import { Spritesheet } from 'pixi.js';

const sheet = await Assets.load('./public/pack/test.json') as Spritesheet;

其中,精灵表对象内的纹理素材都在 sheet.textures 字段下,以文件名和对应素材 Texture 的键值对形式存在:

1
2
// 假如 test.json 的 frames 内有名为 minion.png 素材
const minion = new Sprite(sheet.textures['minion.png']);

1-2. 精灵表与序列帧动画

并且 TexturePacker 还支持自动排列帧动画素材。只需要在制作时,将加入表内的动画帧文件名按照动画帧的顺序命名,工具即可自动识别。

比如,我使用 TexturePacker 制作了一个精灵表 Json 文件: https://hk.krimeshu.com/public/sheets/cat.json

打开可以看到其中的 frames 下有 cat-01.png~cat-14.png 这些图片素材,同时后面的 animations 内出现了一个名为 cat 的成员,正是这些图片按顺序排列后的动画序列帧。

通过上面的方式读取它后,在 sheet.animations 里就会出现上面定义的动画帧序列 cat,我们再通过它创建一个 AnimatedSprite 动画精灵:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { AnimatedSprite, Spritesheet } from 'pixi.js';

const sheet = await Assets.load('https://hk.krimeshu.com/public/sheets/cat.json') as Spritesheet;

// 使用上面的动画帧素材创建动画精灵
const cat = new AnimatedSprite(sheet.animations.cat);
cat.anchor.set(0.5);
cat.position.set(app.screen.width / 2, app.screen.height / 2);

// 设置动画速度
cat.animationSpeed = 0.4;
// 开始动画
cat.play();

将其加入到场景内后,就可以轻松地看到动画效果了:

AnimatedSprite 01

我们还可以做点小改动,为它加上加速和减速的效果:

1
2
3
4
5
6
7
8
9
10
11
let acceleration = 0.05;
setInterval(() => {
cat.animationSpeed += acceleration;
if (cat.animationSpeed >= 1.2) {
acceleration = -0.1;
} else if (cat.animationSpeed <= 0.4) {
acceleration = 0.05;
} else if (acceleration > 0 && cat.animationSpeed >= 0.8) {
acceleration = 0.1;
}
}, 400);

就得到了一只会加速奔跑,并且间歇减速休息的小猫:

AnimatedSprite 02

Gif 录屏的帧数不是很稳定,建议大家自行搭建项目后进行测试体验,或者访问我的 Demo 页面 https://hk.krimeshu.com/public/demos/running-cat/ 来查看效果。

2. 接入 AssetsManager

上面这个例子我们使用 Assets.load() 加载了精灵表的 JSON 配置文件,再将加载结果通过 as Spritesheet 断言为精灵表类型。看起来这样已经为 sheet 对象明确了类型,实际还是过于笼统。

因为其中 animationstextures 成员的类型分别为 Dict<Texture[]>Dict<Texture>,IDE 只能知道它们是 Dict<> 却并不能推断出其中具体有哪些动画、纹理的 key 是真正可用的。

这样实际开发工作中将无法得到相关智能提示和代码检查,对于每个 JSON 配置提供了什么可用的动画和纹理都需要打开文件逐个确认,效率低下。而且还容易出现有人手滑写错键名的情况。随着项目规模越来越大,显然将会变得无比繁琐混乱

因此,我们将之前的 AssetsManager 继续改造,让它支持 Spritesheet 精灵表类型资源的加载和管理。

2-1. 定义精灵表成员的类型

首先,对于 animationstextures 两类可用成员,我们分别定义好可用的键枚举:

1
2
3
4
5
6
7
8
9
/** 素材表分包:奔跑的猫 */
export enum SheetRunningCatTextures {
CAT_STOP = 'cat-01.png',
}

/** 素材表分包:动画素材包 */
export enum SheetRunningCatAnimations {
CAT_RUNNING = 'cat',
}

定义对应的加载函数 SheetLoader 类型:

1
2
3
4
5
6
7
8
9
/** 精灵表加载器 */
type SheetLoader = (
sheetName: keyof AssetsPacks,
jsonList: string[],
keyRemap: {
textures?: Record<string, string>,
animations?: Record<string, string>,
},
) => Promise<void>;

然后在 AssetsPacks内添加精灵表类型的子包成员 SHEET_RUNNING_CAT,并在 loadAllPacks 内调用加载函数进行加载操作:

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
/** 资源总包 */
export class AssetsPacks {
/** 子包:游戏音频 */
GAME_AUDIO = {} as Record<keyof typeof BundleGameAudio, Sound>;
/** 子包:精灵纹理 */
SPRITE_TEXTURE = {} as Record<keyof typeof BundleSpriteTexture, Texture>;
/** 素材表:奔跑的猫 */
SHEET_RUNNING_CAT = {} as {
animations: Record<keyof typeof SheetRunningCatAnimations, Texture[]>;
textures: Record<keyof typeof SheetRunningCatAnimations, Texture>;
};

/** 加载函数 */
static async loadAllPacks({ loadBundle, loadSheet }: {
loadBundle: BundleLoader,
loadSheet: SheetLoader,
}) {
await loadBundle('GAME_AUDIO', BundleGameAudio);
await loadBundle('SPRITE_TEXTURE', BundleSpriteTexture);
await loadSheet('SHEET_RUNNING_CAT', [
'https://hk.krimeshu.com/public/sheets/cat.json',
], {
animations: SheetRunningCatAnimations,
textures: SheetRunningCatAnimations,
});
}
}

2-2. 实现精灵表成员的加载能力

完成上面这些 config/assets-config.ts 内的类型和总包加载流程的修改后,我们还需要打开之前的 assets-manager.ts,真正实现 loadSheet() 的加载操作。

大家有没有注意到,上面对于精灵表的加载函数 loadSheet() 的参数表中,我们将其第二个参数 jsonList 的类型设定为 string[] 而不是 string

这是因为打包后的总纹理图其实有大小限制,分配置较低的设备上可能无法正常渲染单张尺寸过大的纹理图,所以像 TexturePacker 就推荐打包合并后的总纹理图样大小不要超过 2048x2048。这样就导致有些逻辑上属于同一分包的成员,可能最终会被拆分打散在几个不同的 JSON 配置内。

所以,我们在这里通过传入一个地址的数组,再将它们逐一加载后,再进行汇总合并处理。

这个过程相比 loadBundle() 会稍为繁琐一些,我们将其提取到一个单独的静态方法内,再在 loadSheet() 内调用它:

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
// 管理器: src/service/assets-manager.ts

import { AssetsPacks } from '../configs/assets-config';

export class AssetsManager {
private static isInitialized = false;
private static isLoading = false;

private static innerAssetsPacks = new AssetsPacks();
public static get assetsPacks() {
return this.innerAssetsPacks;
}

private constructor() {
// 避免被外部误操作实例化
}

static async init() {
if (this.isInitialized || this.isLoading) return;

this.isLoading = true;
// 加载各个分包
const { assetsPacks } = this;
await AssetsPacks.loadAllPacks({
async loadBundle(bundleName, bundleContents) {
Assets.addBundle(bundleName, bundleContents);
Object.assign(assetsPacks[bundleName], await Assets.loadBundle(bundleName));
},
async loadSheet(sheetName, jsonList, keyRemap) {
totalProgress.packName = sheetName;
const mapKeyToResource = await this.loadSheet(jsonList, keyRemap);
Object.assign(assetsPacks[sheetName], mapKeyToResource);
},
});

this.isLoading = false;
this.isInitialized = true;
}

/** 加载 Spritesheet 型分包 */
private static async loadSheet(jsonList: string[], keyRemap: {
textures?: Record<string, string>,
animations?: Record<string, string>,
}) {
const total = jsonList.length;
const mapFileNameToResource = {
animations: {} as Record<string, Texture[]>,
textures: {} as Record<string, Texture>,
};
// 逐个加载 json,结果合并到同一个集合内
for (let i = 0; i < total; i += 1) {
const jsonUrl = jsonList[i];
const newAssets = await Assets.load(jsonUrl) as Spritesheet;
Object.assign(mapFileNameToResource.animations, newAssets.animations);
Object.assign(mapFileNameToResource.textures, newAssets.textures);
}
// 将 json 内的文件名和动画名,转换为定义的 key
const mapKeyToResource = {
animations: {} as Record<string, Texture[]>,
textures: {} as Record<string, Texture>,
};
const {
animations: animationKeys = {},
textures: textureKeys = {},
} = keyRemap;
Object.entries(animationKeys).forEach(([key, fileName]) => {
mapKeyToResource.animations[key] = mapFileNameToResource.animations[fileName];
});
Object.entries(textureKeys).forEach(([key, fileName]) => {
mapKeyToResource.textures[key] = mapFileNameToResource.textures[fileName];
});
return mapKeyToResource;
}
}

如此一来,资源加载时就会自动将我们需要的精灵表分包和之前的普通分包一起加载完毕。

日常开发中,我们只需要在 IDE 内敲出分包的名字,就可以得到可用精灵表成员字段的智能提示了:

Hint 05

3. 提示加载进度

通常在各种游戏启动时,我们都能看到一个加载进度的提示,它能给用户一个完成预期,缓解等待交流。还可以用于推测自己的网络状况,判断是否遇到加载失败等异常情况。

3-1. 汇总加载进度

PixiJS 基础的 Assets.load()Assets.loadBundle() 方法其实提供了这样的进度回调,但它们都是基于这一次分包的加载进度进行计算的,对于我们这些分包组合加载的情况并无从知晓。

所以我们在它的基础上封装一个总进度回调函数,除了当前加载的分包进度之外,对于所有分包的数量、已加载分包的个数、正在加载的分包名字等信息进行汇总,再提供给最外层的回调所知晓。

如何实现呢?我们先在 service/assets-manager.ts 内将刚才提到的总进度定义出来:

1
2
3
4
5
6
7
8
9
10
11
/** 总进度 */
interface TotalProgress {
isComplete: boolean,
packName: string,
packProgress: number,
packLoaded: number,
packTotalCount: number,
}

/** 总进度回调函数 */
type TotalProgressCallback = (progress: TotalProgress) => void;

然后,在 AssetsManager.init() 做个改动,创建上面的总进度对象,在每个分包的加载过程中更新这个总进度,通知其回调函数:

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
// 管理器: src/service/assets-manager.ts

export class AssetsManager {
// ...

static async init(options: { onProgress: TotalProgressCallback }) {
if (this.isInitialized || this.isLoading) return;

const { assetsPacks } = this;
const packNames = Object.keys(assetsPacks) as (keyof AssetsPacks)[];
// 创建总进度对象
const totalProgress: TotalProgress = {
isComplete: false,
packName: '',
packProgress: 0,
packLoaded: 0,
packTotalCount: packNames.length,
};
const onProgress = (progress: number) => {
totalProgress.packProgress = progress;
// 通知外层回调
options.onProgress(totalProgress);

totalProgress.packLoaded += progress < 1 ? 0 : 1;
if (totalProgress.packLoaded === totalProgress.packTotalCount) {
// 已加载完所有包
totalProgress.isComplete = true;
totalProgress.packName = '';
totalProgress.packProgress = 0;
// 通知外层回调
options.onProgress(totalProgress);
}
};
// 加载各个分包
this.isLoading = true;
await AssetsPacks.loadAllPacks({
loadBundle: async (bundleName, bundleAssets) => {
totalProgress.packName = bundleName;
Assets.addBundle(bundleName, bundleAssets);
const contents = await Assets.loadBundle(bundleName, onProgress);
Object.assign(assetsPacks[bundleName], contents);
},
loadSheet: async (sheetName, jsonList, keyRemap) => {
totalProgress.packName = sheetName;
const mapKeyToResource = await this.loadSheet(jsonList, keyRemap, onProgress);
Object.assign(assetsPacks[sheetName], mapKeyToResource);
},
});

this.isLoading = false;
this.isInitialized = true;
}

// ...
}

其中 Assets.loadBundle(bundleName, onProgress) 已经支持分包进度的回调,但是对于 AssetsManager.loadSheet() 的进度回调,显然就需要我们自己来实现了。

不过其实也不复杂,因为精灵表分包的每个 JSON 文件的加载速度都很快,通过 Assets.load() 获得的进度回调基本只会有 01 两个状态。我们不如将精灵表分包的已加载 JSON 数量作为基准来计算精灵表分包的整体加载进度:

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
// 管理器: src/service/assets-manager.ts

export class AssetsManager {
// ...

private static async loadSheet(jsonList: string[], keyRemap: {
textures?: Record<string, string>,
animations?: Record<string, string>,
// 【增加进度回调参数】
}, onProgress: (progress: number) => void) {
// ...
// 逐个加载 json,结果合并到同一个集合内
for (let i = 0; i < total; i += 1) {
const jsonUrl = jsonList[i];
const newAssets = await Assets.load(jsonUrl) as Spritesheet;

onProgress((i + 1) / total); // 【计算进度并回调】

Object.assign(mapFileNameToResource.animations, newAssets.animations);
Object.assign(mapFileNameToResource.textures, newAssets.textures);
}
// ...
}

// ...
}

这样,在之前的应用入口位置,我们就能获取到总加载进度了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/app.ts
import { Application } from 'pixi.js';

import { AssetsManager } from './services/assets-manager';
import FirstScene from './scenes/first-scene';

export default class MyApp extends Application {
// 省略之前例子中的重复代码...

async startGame() {
// 【修改】初始化资源管理器
await AssetsManager.init({
onProgress: (progress) => {
console.log('加载进度:', progress);
},
});

// 创建起始场景
const firstScene = new FirstScene({
app: this,
});
this.stage.addChild(firstScene);
}
}

在 DEMO 项目内的运行效果:

Loading Progress

3-2. 创建启动加载场景

目前,我们是在入口 startGame() 后静默等待直到加载完成才进入场景,这个加载过程如果较长的话,用户将会看到很久的白屏,显然体验相当糟糕。

既然已经能拿到所有资源的总加载进度了,那我们就可以开始动手创建一个启动加载场景,把资源加载进度输出展示给用户了。

这个启动加载场景只要注意以下几点就好:

  • 样式从简,尽快展示出来为第一目标;
  • 信息从简,用户不需要知道过于详细的信息,大部分时候根据总进度绘制进度条即可;
  • 如果启动场景用到了图片素材,尽量用以下方法减少它的等待时间:
    • 通过内联 Base64 的形式加入代码包;
    • 使用 渐进式 JPEG 格式储存,然后在透明 Canvas 底部通过 <img/> 渲染出来。

小结

这两篇文章里,我们创建了自己的 AssetsManager 进行项目资源的加载和管理,除了方便我们日常开发时代码提示、运行时把控总加载进度之外,还有个意义就是提供了一个稳定的项目资源引入模式。

刚刚开始尝试的同学,或许会觉得每次引入资源文件时都要手动修改 configs/assets-config.ts 内的配置代码,未免有些太麻烦了,哪怕有上面提到的代码提示和加载进度把控的效果,好像也有些得不偿失。

但仔细观察 assets-config.ts 内的代码,就会发现其规律十分明显,基本与资源文件名和类型一一对应。因此——除了手动书写之外,你完全可以考虑通过项目构建流程自动生成资源配置表,避免所有机械的工作,又能兼顾代码检查和进度控制。

不过篇幅有限,而且不同项目的资源文件规范可能各有不同,这里就不细说了,大家按照自己的想法去实现就好。

期待下一篇文章再见~