PixiJS 修炼指南 - 03. 资源加载(上)

不知道有没有同学注意到,第一篇中我们创建精灵时使用的是 Sprite.from(textureUrl) 方法,但是第二篇重构后却改用了 Assets.load(textureUrl) 加载纹理,然后再设置到 this.texture 属性内来完成精灵纹理素材加载的。

这里的 Assets 是 PixiJS 提供的资源管理器,由它负责处理下载、缓存、转换等工作,将项目资源变成你需要的形式。

和其他 PixiJS 模块一样,虽然它的功能很强大,但使用起来还是有些骨感。对于日常开发还需要将其做一些封装和完善,改造为更方便的开发模式来使用。

模块介绍

Pixi.Assets 模块的前身是 PixiJS 6.x 之前的版本中的 Pixi.Loader,经过改进完善后,它提供了更现代化的 Promise 风格 API。

Assets 模块工作时,在后台自动进行并发加载的控制调度,缩短加载时间,加快启动速度;缓存机制避免重复加载相同的资源,提高效率;可扩展的转换器系统,允许我们轻松扩展和定制更多需要的资源格式。

在没有添加第三方转换器的情况下,PixiJS.Assets 内部默认提供了以下几类资源的支持:

  • 纹理 (Textures): avif, webp, png, jpg, gif
  • 精灵表 (Sprite sheets): json
  • 位图字体 (Bitmap fonts): xml, fnt, txt
  • Web 字体 (Web fonts): ttf, woff, woff2
  • JSON 文件 (Json files): json
  • 文本文件 (Text files): txt

这些已经基本覆盖了我们日常开发的常见资源类型,更多资源类型的支持可以再针对需求找寻、实现相关的加载转换器,再加入项目中即可。

工作流程

1. 项目内路径关系

之前的例子中,为了更快看到 demo 的效果,通过直接访问一张我放在服务器上的图片,来作为精灵纹理的素材。

日常开发工作中,自然需要把用到的资源加入项目内,再进行打包整理和部署等处理。

这里我们直接将素材放到 Vite 默认的项目静态资源目录 public/ 内就好,先在其中创建大致的分类目录:

1
2
3
4
./public
├── audio # 音频资源目录
├── images # 图片资源目录
└── sheets # 精灵表资源目录

放在其中的资源,不需要经过打包处理,可以直接通过相对页面位置的路径来进行访问,方便我们到时候做配置统一化管理。

2. 资源总包的类型定义

当我们向项目添加上面的三类资源后,希望可以实现快速地找到配置文件、方便地创建加载配置、开发时自动提示可用资源的效果。

为了实现这个效果,我们首先需要定义一下对应这些资源的 TypeScript 类型。

比如我们先定义一个资源总包 AssetsPacks,然后把项目中用到的资源粗略分为 GAME_AUDIO 游戏音频和 SPRITE_TEXTURE 精灵纹理两个子包:

1
2
3
4
5
6
7
8
9
10
import { Texture } from 'pixi.js';
import { Sound } from '@pixi/sound';

/** 资源总包 */
export class AssetsPacks {
/** 子包:游戏音频 */
GAME_AUDIO = {} as Record<string, Sound>;
/** 子包:精灵纹理 */
SPRITE_TEXTURE = {} as Record<string, Texture>;
}

它们都是以 string 为键类型的 Record 对象,其值类型分别为 SoundTexture

其中 Sound 目前并不包含在 PixiJS 的默认包内,记得手动额外安装一下 @pixi/sound 模块:

1
2
npm i -S @pixi/sound
# npm i -S @pixi/assets @pixi/core # 安装 @pixi/soud 需要的 peer 依赖版本,确保它们版本兼容

3. 子包类型定义

这个情况下,我们使用 AssetsPacks 类的实例时,能得到第一级子包名字的智能提示,然而无法获得子包内部资源名的智能提示,使用起来还是有些不便。

不过没关系,因为我们定义子包的加载参数时,正好有个键参数可以用作提示。我们先用 enum 的形式准备好 GAME_AUDIOSPRITE_TEXTURE 包的加载参数:

1
2
3
4
5
6
7
8
9
10
11
12
/** 包参数:游戏音频 */
export enum PackGameAudio {
BGM_THEME = './sounds/bgm/theme.mp3',
BGM_BATTLE = './sounds/bgm/battle.mp3',
SFX_HIT = './sounds/sfx/hit.mp3',
}

/** 包参数:精灵纹理 */
export enum PackSpriteTexture {
SPRITE_MINION = './images/sprites/minion.png',
BG_MAIN = './images/bg/main.jpg',
}

请参考上面的格式,在自己项目中准备几个对应的资源文件。

然后借助它们的键来完善子包 Record 键类型的约束:

1
2
3
4
5
6
7
/** 资源总包 */
export class AssetsPacks {
/** 子包:游戏音频 */
GAME_AUDIO = {} as Record<keyof typeof PackGameAudio, Sound>;
/** 子包:精灵纹理 */
SPRITE_TEXTURE = {} as Record<keyof typeof PackSpriteTexture, Texture>;
}

这样,我们在日常编码过程中使用 AssetsPacks 实例时,就能轻松地得到所有可用资源子包和子包内容的智能提示了:

Hint 03

Hint 04

实现加载器

类型定义得差不多了,我们来看看怎么加载上面的资源吧。

1. 可用的加载方法

Pixi.Assets 提供的加载方法,除了之前 demo 里出现过的 Assets.load() 之外,还有一个就是用于批量加载的 Assets.loadBundle(),以及两者对应的参数准备和后台预加载方法。它们的使用方式大致如下:

1-1. 普通加载

对于 Assets.load(),可以直接根据指定 url 加载图片资源作为纹理素材:

1
2
const url = 'https://hk.krimeshu.com/public/images/sprite-minion.png';
const texture = await Assets.load(url) as Texture;

也可以通过 Asset.add() 添加别名,然后再根据别名进行加载:

1
2
Assets.add('SPRITE_MINION', 'https://hk.krimeshu.com/public/images/sprite-minion.png';
const texture = await Assets.load('SPRITE_MINION') as Texture;

1-2. 后台预加载

还可以提前通过 Assets.backgroundLoad() 启动后台加载,再在需要素材的时候通过 Assets.load() 立刻获得预加载好的素材资源。

比如,我们首先在后台启动两种按键状态的纹理素材的预加载:

1
2
3
4
5
6
7
8
Assets.add('BTN_DEFAULT', './images/ui/btn-default{png,webp}');
Assets.add('BTN_ACTIVE', './images/ui/btn-active{png,webp}');

// 启动后台加载
Assets.backgroundLoad([
'BTN_DEFAULT',
'BTN_ACTIVE',
]);

然后,在加载的过程中,我们就可以继续创建场景的准备工作,比如创建使用到上面素材资源的按键对象:

1
2
3
4
5
6
// ...

// 创建按键对象
const btn = new Sprite();
// 获取纹理素材,交给按键对象
btn.texture = await Assets.load('BTN_DEFAULT');

如果在这个准备过程中,后台预加载已经完成,此时我们就可以立刻得到预加载好的素材,提高加载效率。

同样,按键交互后的动态改变状态(比如按下),也可以享受到与加载方法带来的效率提升:

1
2
3
4
// ...

// 按下按键时,切换素材
btn.texture = await Assets.load('BTN_ACTIVE');

1-3. 按捆绑包加载

不过相比之下,我们还是推荐使用 Assets.loadBundle() 以发挥出 Pixi.Assets 的优势。

类似 Assets.add()Assets.load() 之间的关系,Assets.loadBundle() 的基本使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Assets.addBundle('UI_SPRITES', {
BTN_DEFAULT: './images/ui/btn-default{png,webp}',
BTN_ACTIVE: './images/ui/btn-active{png,webp}',
});

const bundle = await Assets.loadBundle('UI_SPRITES');
// ...

// 默认素材
btn.texture = bundle.BTN_DEFAULT;
// ...

// 激活态素材
btn.texture = bundle.BTN_ACTIVE;
// ...

同样,Assets.loadBundle() 也有对应的后台预加载方法 Assets.backgroundLoadBundle(),使用思路与 Assets.backgroundLoad() 基本没什么区别。

通过这些方法组合,我们可以实现按需分批加载的效果,减少首屏加载的等待时长——通常减少首屏等待时长都可以减少首屏退出率,提高留存。

例如,在开发时将资源根据场景划分子包。这样就可以在启动应用时优先加载首屏需要的资源包,比加载整个应用的资源包体积更小、更快;等到用户进入首屏后,再在后台启动后续场景的资源预加载;最后,等用户通过交互跳去下一场景时,下一个场景的资源也就基本都加载完成了。

1-4. 意外情况

当然,上面这些都是理想效果,想真正使用在实际项目中,还需要对一些意外情况做好预测和兜底处理,比如:

  • 预加载未完成:跳去下一场景时,对应资源并未彻底在后台加载完,此时可能需要给出等待提示或者增加临时加载进度页;
  • 弱网环境:弱网环境下后续分包可能无法加载完,需要保留当前进度现场、并在下次加载完成后继续流程。

2.开始创建 AssetsManager

2-1. 静态类

这里我们在自己项目中来实现一个和 Pixi.Assets 一样的静态类对象:使用前不需要实例化,项目内共享同一个静态实例。

以往在 JavaScript 模式的开发中,可能会用一个全局的字面量对象来实现这样的效果。但这个传统的方式无法使用私有字段特性来封装自身内部使用的属性和方法,不方便做类型约束,且所有字段都公开暴露出来,使用时也容易受干扰。

所以我们通过 class 的语法来创建我们的资源管理模块AssetsManager),将其所有方法和属性都设为 static 静态成员,这样无需实例化就可以使用它们了;并且记得将构造函数设为 private 避免被误操作实例化出其它实例,导致内部基于静态类设计的流程出现意外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/services/assets-manager.ts
export class AssetsManager {
private constructor() {
// 避免被外部误操作实例化
}

// 静态方法,可以直接调用
static doSth() {
console.log('foobar');
}

// 封装内部字段,使其对外只读
private static valueInner = 0;
static get value() {
return this.valueInner;
}
}

AssetsManager.doSth(); // -> 'foobar'

2-2. 加载资源

我们从最基本的流程开始,先不考虑分包优化,为 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
/** 包参数:游戏音频 */
export enum PackGameAudio {
BGM_THEME = './sounds/bgm/theme.mp3',
BGM_BATTLE = './sounds/bgm/battle.mp3',
SFX_HIT = './sounds/sfx/hit.mp3',
}

/** 包参数:精灵纹理 */
export enum PackSpriteTexture {
SPRITE_MINION = './images/sprites/minion.png',
BG_MAIN = './images/bg/main.jpg',
}

/** 资源总包 */
export class AssetsPacks {
/** 子包:游戏音频 */
GAME_AUDIO = {} as Record<keyof typeof PackGameAudio, Sound>;
/** 子包:精灵纹理 */
SPRITE_TEXTURE = {} as Record<keyof typeof PackSpriteTexture, Texture>;
}

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;

// 添加捆绑包
Assets.addBundle('GAME_AUDIO', PackGameAudio);
Assets.addBundle('SPRITE_TEXTURE', PackSpriteTexture);

this.isLoading = true;
// 加载各个分包
this.innerAssetsPacks.GAME_AUDIO = await Assets.loadBundle('GAME_AUDIO');
this.innerAssetsPacks.SPRITE_TEXTURE = await Assets.loadBundle('SPRITE_TEXTURE');

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

2-3. 拆分逻辑和配置列表

很显然,今后我们向总包增加分包的时候,并不想上下翻找 AssetsPacksAssetsManager,在两个地方同时修改对应代码,这样太反直觉了,无法确保之后加入项目的同学也能正确掌握这里的修改方式。

所以我们做个小调整,将总包内部结构的定义和加载流程收拢在 AssetsPacks 类的静态方法中,但是通过借助 AssetsManager 提供的加载器来完成各个子包的加载。

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
// 配置表: src/configs/assets-config.ts

/** 包参数:游戏音频 */
export enum PackGameAudio {
BGM_THEME = './sounds/bgm/theme.mp3',
BGM_BATTLE = './sounds/bgm/battle.mp3',
SFX_HIT = './sounds/sfx/hit.mp3',
}

/** 包参数:精灵纹理 */
export enum PackSpriteTexture {
SPRITE_MINION = './images/sprites/minion.png',
BG_MAIN = './images/bg/main.jpg',
}

/** 资源总包 */
export class AssetsPacks {
/** 子包:游戏音频 */
GAME_AUDIO = {} as Record<keyof typeof PackGameAudio, Sound>;
/** 子包:精灵纹理 */
SPRITE_TEXTURE = {} as Record<keyof typeof PackSpriteTexture, Texture>;

/** 加载函数 */
static async loadAllPacks({ loadBundle }: {
loadBundle: BundleLoader,
}) {
await loadBundle('GAME_AUDIO', PackGameAudio);
await loadBundle('SPRITE_TEXTURE', PackSpriteTexture);
}
}

/** 分包加载器 */
type BundleLoader = (
bundleName: keyof AssetsPacks,
bundleContents: Record<string, string>,
) => Promise<void>;

上面是资源包的配置,下面是使用这些配置来加载的具体流程逻辑:

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
// 管理器: 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));
},
});

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

这样拆分后,两者分工更明了。放在项目的不同位置后,大部分时候我们只需要调整其中之一即可:

  • 增减资源时,只需要更新资源配置列表 assets-config.ts,无需关注详细的加载过程;
  • 定位资源加载问题、改进加载过程时,修改 assets-manager.ts 内的加载器,无需关心分包的具体资源类型和加载参数。

2-4. 游戏入口改动

虽然从之前的代码提示看来,我们已经能拿到 AssetsPacks 内的成员字段,但那只是我们通过 as 关键字断言设定的成员类型。

相当于我们对 TypeScript 的编译器和 VSCode 的代码提示插件“打包票”:AssetsPacks 类里的这些成员一定是这样的类型。

实际上,如果我们不执行 AssetsManager.init() 做初始化的话,这些成员并没有真正加载完毕,只能拿到我们在 as 左侧设置的空对象。

所以我们还需要稍微调整一下之前 App.startGame(),在里面调用 AssetsManager 的初始化,等待资源加载完毕后再进入后续场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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();

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

小结

项目资源管理模块 AssetsManager 的基本雏形就出来了。

篇幅所限,下一篇我们再继续完善它,为它添加 Spritesheet 精灵表资源的支持,并且实现加载进度的回调,敬请期待~