我不知道的 Pixi.js(02)— 纹理与 Sprites 的管理机制
很多人以为 Pixi.js 的纹理就是"把图片贴到 Sprite 上",但实际上纹理管理涉及三层抽象:TextureSource(GPU 上的图像数据)、Texture(纹理上的矩形区域)、Sprite(屏幕上的可渲染对象)。搞清楚这三层关系,大部分纹理相关的性能问题和内存泄漏…
很多人以为 Pixi.js 的纹理就是”把图片贴到 Sprite 上”,但实际上纹理管理涉及三层抽象:TextureSource(GPU 上的图像数据)、Texture(纹理上的矩形区域)、Sprite(屏幕上的可渲染对象)。搞清楚这三层关系,大部分纹理相关的性能问题和内存泄漏都能迎刃而解。
一、v8 的资源加载:从 Loader 到 Assets
Pixi.js v7.3 开始废弃 PIXI.Loader,v8 彻底移除。取而代之的是 Assets 系统——一个基于 Promise 的资源管理器。
先看 v7 的旧写法:
// v7 — 回调式加载,已废弃
const loader = PIXI.Loader.shared;
loader.add('hero', 'hero.png');
loader.load((loader, resources) => {
const sprite = new PIXI.Sprite(resources.hero.texture);
});
再看 v8 的新写法:
// v8 — Promise 式加载
import { Assets, Sprite } from 'pixi.js';
const texture = await Assets.load('hero.png');
const sprite = new Sprite(texture);
换句话说,Assets.load() 把”加载”和”使用”之间的回调地狱消除了。但更重要的变化在底层——Assets 系统内置了缓存、类型推断和解析链:
// 批量加载
await Assets.load(['hero.png', 'enemy.png', 'spritesheet.json']);
// 预加载(后台下载,不阻塞主线程)
Assets.backgroundLoad(['level2/tilemap.json']);
// 卸载释放内存
Assets.unload('hero.png');
这里有一个很多人会忽略的细节——Assets.load() 对同一路径会自动去重。多次调用 Assets.load('hero.png') 不会发起多次网络请求,它会返回同一个 Promise,指向缓存中的同一份纹理数据。
二、三层纹理架构:TextureSource → Texture → Sprite
说白了,Pixi.js 的纹理系统是一个”一对多”的层级结构:
(1)TextureSource(v7 中叫 BaseTexture):代表一块实际上传到 GPU 的图像数据。一张 PNG 文件加载后,在 GPU 上就是一个 TextureSource。它是内存和显存占用的真正来源。
(2)Texture:代表 TextureSource 上的一个矩形区域。一个 TextureSource 可以衍生出多个 Texture,比如 Spritesheet 里的每一帧。Texture 本身不占额外显存,它只是一个”裁切框”。
(3)Sprite:引用一个 Texture,决定在屏幕上的位置、大小、旋转等。多个 Sprite 可以共享同一个 Texture。
import { Assets, Sprite, Texture, Rectangle } from 'pixi.js';
const source = await Assets.load('spritesheet.png');
// source 是一个 Texture,其 source 属性指向 TextureSource
// 手动从 source 裁出子区域
const frame1 = new Texture({ source: source.source, frame: new Rectangle(0, 0, 64, 64) });
const frame2 = new Texture({ source: source.source, frame: new Rectangle(64, 0, 64, 64) });
// 两个 Sprite 共享同一个 TextureSource,只有一份 GPU 数据
const spriteA = new Sprite(frame1);
const spriteB = new Sprite(frame2);
这说明了什么?100 个 Sprite 使用同一张 Spritesheet 的不同帧,GPU 上只存了一份图像数据。这就是为什么 Spritesheet 比零散的小图片高效得多——减少了纹理上传次数和显存占用。
三、Spritesheet 的解析过程
实际开发中很少手动裁切 Texture,通常用 JSON 描述文件配合 Spritesheet 图集:
// spritesheet.json 描述了每帧在图集中的位置和尺寸
const sheet = await Assets.load('spritesheet.json');
// Assets 系统自动解析 JSON,生成 textures 映射
const idleTexture = Texture.from('hero-idle-01');
const walkTexture = Texture.from('hero-walk-01');
解析过程是这样的:Assets 加载 JSON 文件 → 读取其中的 meta.image 字段找到对应的图集图片 → 上传图片到 GPU 创建 TextureSource → 按 JSON 中每帧的 frame 坐标创建多个 Texture → 以帧名称为 key 注册到全局缓存。
问题的关键在于——Spritesheet 的帧名称就是全局缓存的 key。如果两个不同的 Spritesheet 有同名帧(比如都叫 frame_01),后加载的会覆盖先加载的,导致显示错误。命名时加前缀是个好习惯。
四、UV 坐标:GPU 怎么知道裁哪块
Texture 的 frame 属性定义了裁切区域,但 GPU 不认像素坐标——它认的是 UV 坐标,范围 0 到 1。
换句话说,U 和 V 分别对应纹理的水平和垂直方向:(0, 0) 是左上角,(1, 1) 是右下角。一个 256x256 的图集中,左上角 64x64 的区域对应的 UV 范围是 (0, 0) 到 (0.25, 0.25)。
Pixi.js 在创建 Texture 时自动计算 UV 坐标,开发者通常不需要手动处理。但在自定义 Mesh 或 Shader 时,理解 UV 映射就变得重要了——它决定了纹理的哪块区域被”贴”到几何体的哪个面上。
// Texture 的 uvs 属性存储了标准化后的 UV 坐标
// frame: (64, 0, 64, 64) 在 256x256 图集上
// 对应 UV: (0.25, 0, 0.5, 0.25)
五、纹理生命周期:创建、缓存与销毁
纹理的生命周期管理是内存泄漏的重灾区。Pixi.js v8 引入了自动垃圾回收(GC)机制,但开发者仍需理解其工作原理。
创建阶段:Assets.load() 加载资源后,TextureSource 上传到 GPU,Texture 注册到缓存。
使用阶段:Sprite 引用 Texture,渲染器每帧读取其 UV 和 TextureSource 进行绘制。v8 的 GC 系统会追踪 TextureSource 的最近使用时间。
销毁阶段:v8 提供了两种方式——
// 方式一:手动卸载(推荐用于场景切换)
await Assets.unload('hero.png');
// 方式二:自动 GC(v8.15+ 统一的 GCSystem)
await app.init({
gcActive: true,
gcMaxUnusedTime: 60000, // 60 秒未使用则释放 GPU 数据
gcFrequency: 30000, // 每 30 秒检查一次
});
这里有一个很多人会忽略的细节——v8.15 之前有 TextureGCSystem 和 RenderableGCSystem 两套 GC,v8.15 统一为 GCSystem。旧的配置参数(textureGCActive 等)虽然还能用但已标记为废弃。
如果你只记住一句话:TextureSource 不销毁,GPU 显存就不会释放。 Sprite 被移除出场景树不会触发纹理释放——纹理是独立于场景树的资源,需要显式卸载。
总结
Pixi.js 的纹理系统不是”加载图片 → 创建 Sprite”这么简单。它是一个三层架构:TextureSource 管 GPU 数据,Texture 管裁切区域,Sprite 管屏幕呈现。理解这个层级关系,就能理解为什么 Spritesheet 比散图高效、为什么纹理切换会打断批处理、为什么移除 Sprite 不等于释放内存。
本系列其他文章:
延伸阅读: