Pixi.js 游戏开发教程 / Pixi.js 容器嵌套与变换
11. 容器嵌套与变换
概述
容器(Container)是 Pixi.js 场景图的核心。所有显示对象都是容器的子节点,容器的变换会向下传递给子节点。理解容器嵌套和变换系统是构建复杂游戏场景的基础。
Container 基础嵌套
容器可以无限嵌套,子节点继承父节点的变换(位置、旋转、缩放):
import { Container, Sprite, Texture } from 'pixi.js';
const app = new Application();
await app.init({ width: 800, height: 600 });
// 创建父容器
const world = new Container();
app.stage.addChild(world);
// 创建子容器
const playerGroup = new Container();
playerGroup.x = 200;
playerGroup.y = 150;
world.addChild(playerGroup);
// 添加精灵到子容器
const body = Sprite.from('/image/hero.png');
body.x = -16; // 相对于 playerGroup 的局部坐标
body.y = -16;
playerGroup.addChild(body);
const weapon = Sprite.from('/image/sword.png');
weapon.x = 16;
weapon.y = 0;
playerGroup.addChild(weapon);
// 移动父容器,所有子节点一起移动
playerGroup.x = 400; // body 和 weapon 都会移动
嵌套最佳实践
| 原则 | 说明 |
|---|---|
| 层级清晰 | 使用语义化命名:worldLayer、uiLayer、effectLayer |
| 避免过深 | 层级过深影响变换计算性能,建议不超过 8 层 |
| 批量操作 | 移动一组对象时用容器,而非逐个移动 |
| 按需渲染 | 不可见的容器设 visible = false 跳过渲染 |
// 推荐的层级结构
const stage = new Container();
const backgroundLayer = new Container(); // 背景层
const gameLayer = new Container(); // 游戏层(地图、角色、特效)
const uiLayer = new Container(); // UI 层(不受相机影响)
const overlayLayer = new Container(); // 覆盖层(过渡动画、对话框)
stage.addChild(backgroundLayer, gameLayer, uiLayer, overlayLayer);
局部变换 vs 世界变换
每个显示对象有两套变换:
- 局部变换(Local Transform):相对于父容器的位置/旋转/缩放
- 世界变换(World Transform):相对于场景根节点的最终变换
const parent = new Container();
parent.x = 100;
parent.y = 50;
parent.rotation = Math.PI / 6; // 30 度
app.stage.addChild(parent);
const child = new Container();
child.x = 50;
child.y = 0;
parent.addChild(child);
// 获取世界坐标
child.updateWorldTransform();
const worldPos = child.position; // 局部坐标 (50, 0)
const globalMatrix = child.worldTransform;
console.log('世界 X:', globalMatrix.tx, '世界 Y:', globalMatrix.ty);
💡 提示:Pixi.js v8 中可以使用
toGlobal()和toLocal()方法进行坐标转换,无需手动计算矩阵。
toLocal / toGlobal 坐标转换
这两个方法是游戏开发中最常用的坐标转换工具:
import { Container, Graphics, Application } from 'pixi.js';
const app = new Application();
await app.init({ width: 800, height: 600 });
const world = new Container();
world.x = 200;
world.y = 100;
world.scale.set(2);
app.stage.addChild(world);
const entity = new Container();
entity.x = 50;
entity.y = 80;
world.addChild(entity);
// toGlobal: 局部坐标 → 屏幕坐标
const screenPos = entity.toGlobal({ x: 0, y: 0 });
console.log('屏幕坐标:', screenPos.x, screenPos.y);
// 结果: (300, 260) → (200 + 50*2, 100 + 80*2)
// toGlobal: 也可以转换非原点的坐标
const screenPos2 = entity.toGlobal({ x: 10, y: 10 });
console.log('偏移屏幕坐标:', screenPos2.x, screenPos2.y);
// 结果: (320, 280) → (200 + (50+10)*2, 100 + (80+10)*2)
// toLocal: 屏幕坐标 → 局部坐标
const mouseGlobal = { x: 400, y: 300 };
const localPos = entity.toLocal(mouseGlobal);
console.log('局部坐标:', localPos.x, localPos.y);
游戏场景:鼠标点击世界坐标
// 将鼠标点击转换为世界坐标(用于 RTS 游戏选择单位)
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
app.stage.on('pointerdown', (event) => {
const worldPoint = world.toLocal(event.global);
console.log('世界坐标:', worldPoint.x, worldPoint.y);
// 在世界坐标处检测是否有单位
selectUnitAt(worldPoint.x, worldPoint.y);
});
Pivot vs Anchor
pivot 和 anchor 经常被混淆,它们的区别如下:
| 属性 | 适用对象 | 作用 | 值类型 |
|---|---|---|---|
pivot | Container | 变换的旋转/缩放中心点(像素坐标) | {x, y} 像素 |
anchor | Sprite | 精灵纹理的对齐锚点(比例值 0~1) | {x, y} 0-1 |
import { Sprite, Container, Graphics } from 'pixi.js';
// --- Pivot: 容器的旋转中心 ---
const container = new Container();
container.x = 400;
container.y = 300;
container.pivot.set(50, 50); // 旋转中心偏移 (50, 50)
container.rotation = Math.PI / 4;
// 容器会围绕 (400-50, 300-50) = (350, 250) 旋转
// --- Anchor: 精灵纹理的锚点 ---
const sprite = Sprite.from('/image/character.png');
sprite.x = 400;
sprite.y = 300;
sprite.anchor.set(0.5, 0.5); // 锚点居中
sprite.rotation = Math.PI / 4;
// 精灵会围绕自身中心旋转
⚠️ 注意:
anchor只影响纹理的绘制位置,不影响子节点。pivot影响整个容器及其所有子节点的变换中心。
Skew 倾斜变换
Skew 可以创建倾斜效果(如透视变形):
const skewedSprite = Sprite.from('/image/wall.png');
skewedSprite.x = 400;
skewedSprite.y = 300;
skewedSprite.anchor.set(0.5, 0.5);
// 水平倾斜(伪 3D 地板效果)
skewedSprite.skew.x = Math.PI / 6; // 30 度倾斜
// 垂直倾斜
skewedSprite.skew.y = Math.PI / 8;
app.stage.addChild(skewedSprite);
游戏场景:伪 3D 地面效果
// 创建伪 3D 地面瓦片
function createIsoTile(texture, isoX, isoY) {
const tile = Sprite.from(texture);
tile.anchor.set(0.5, 0.5);
tile.skew.x = Math.PI / 4;
tile.skew.y = -Math.PI / 4;
tile.x = (isoX - isoY) * 64; // 等轴测 X
tile.y = (isoX + isoY) * 32; // 等轴测 Y
return tile;
}
变换矩阵 Matrix 详解
Pixi.js 使用 3x2 仿射矩阵表示 2D 变换:
| a b tx |
| c d ty |
| 0 0 1 |
| 分量 | 对应变换 |
|---|---|
a | X 轴缩放 (scaleX × cos) |
b | X 轴倾斜 (scaleX × sin) |
c | Y 轴倾斜 (scaleY × -sin) |
d | Y 轴缩放 (scaleY × cos) |
tx | X 轴平移 |
ty | Y 轴平移 |
import { Matrix } from 'pixi.js';
// 手动创建变换矩阵
const matrix = new Matrix();
// 平移
matrix.translate(100, 50);
// 旋转 45 度
matrix.rotate(Math.PI / 4);
// 缩放
matrix.scale(2, 2);
// 追加变换(在右侧乘)
matrix.append(new Matrix().translate(50, 0));
// 前置变换(在左侧乘)
matrix.prepend(new Matrix().rotate(Math.PI / 6));
// 逆矩阵
const inverted = matrix.clone().invert();
// 点变换
const point = { x: 10, y: 20 };
const transformed = matrix.apply(point);
const reversed = inverted.apply(transformed);
// reversed ≈ point
变换脏标记与性能
Pixi.js 使用脏标记(dirty flag)系统避免不必要的矩阵重新计算:
// Pixi.js 内部机制:
// 1. 修改 position/rotation/scale → 标记 transform 为 dirty
// 2. 渲染时检测到 dirty → 重新计算 worldTransform
// 3. 计算完成后清除 dirty 标记
// 不要手动调用 updateTransform(),除非你需要立即获取世界坐标
const sprite = Sprite.from('/image/bullet.png');
sprite.x = 100;
sprite.y = 200;
// 无需手动更新,Pixi.js 渲染时自动处理
// 如果需要在渲染前获取准确的世界坐标:
sprite.updateWorldTransform();
const worldPos = sprite.toGlobal({ x: 0, y: 0 });
💡 提示:在
ticker.update回调中频繁修改大量对象时,批量修改后再让系统统一更新,避免频繁调用updateWorldTransform()。
容器裁剪 Mask(遮罩)
Mask 可以限制容器的可见区域:
import { Graphics, Container, Sprite } from 'pixi.js';
// 图形遮罩(最常用)
const maskGraphics = new Graphics();
maskGraphics.rect(100, 100, 300, 200);
maskGraphics.fill(0xffffff); // 颜色无意义,只看形状
const content = new Container();
content.mask = maskGraphics;
app.stage.addChild(content, maskGraphics);
// 添加内容到被遮罩的容器
for (let i = 0; i < 20; i++) {
const item = Sprite.from('/image/item.png');
item.x = Math.random() * 600;
item.y = Math.random() * 400;
content.addChild(item);
}
圆形遮罩(视野系统)
// Roguelike 游戏的视野遮罩
const fogOfWar = new Graphics();
fogOfWar.circle(0, 0, 120);
fogOfWar.fill(0xffffff);
const gameWorld = new Container();
gameWorld.mask = fogOfWar;
app.stage.addChild(gameWorld);
// 跟随玩家移动遮罩
app.ticker.add(() => {
fogOfWar.x = player.x;
fogOfWar.y = player.y;
});
Sprite 作为遮罩
// 使用图片作为遮罩(渐变边缘效果)
const maskSprite = Sprite.from('/image/mask-circle.png');
maskSprite.anchor.set(0.5);
const revealContent = new Container();
revealContent.mask = maskSprite;
⚠️ 注意:Sprite 遮罩比 Graphics 遮罩性能更好,但 Graphics 遮罩更灵活。WebGL 模式下两者都使用 stencil buffer,WebGPU 下使用 clip。
混合模式 blendMode
混合模式控制像素如何与下方像素合成:
import { BLEND_MODES } from 'pixi.js';
const glowEffect = Sprite.from('/image/glow.png');
glowEffect.blendMode = 'add'; // 叠加混合(发光效果)
gameLayer.addChild(glowEffect);
const shadowEffect = Sprite.from('/image/shadow.png');
shadowEffect.blendMode = 'multiply'; // 正片叠底(阴影效果)
const screenBlend = Sprite.from('/image/light.png');
screenBlend.blendMode = 'screen'; // 滤色(光效)
常用混合模式对照:
| 混合模式 | 效果 | 游戏用途 |
|---|---|---|
normal | 正常绘制 | 默认模式 |
add | 叠加变亮 | 火焰、激光、光晕 |
multiply | 正片叠底变暗 | 阴影、环境遮蔽 |
screen | 滤色变亮 | 光照、闪电 |
erase | 擦除 | 纹理擦除效果 |
容器缓存 cacheAsBitmap
将容器渲染为纹理缓存,避免每帧重绘复杂容器:
const complexUI = new Container();
// 添加大量子元素...
for (let i = 0; i < 100; i++) {
const icon = Sprite.from(`/image/icon-${i}.png`);
icon.x = (i % 10) * 40;
icon.y = Math.floor(i / 10) * 40;
complexUI.addChild(icon);
}
// 缓存为位图(不再每帧重绘 100 个子元素)
complexUI.cacheAsBitmap = true;
// 当内容变化时,需要更新缓存
function updateUI() {
complexUI.cacheAsBitmap = false; // 关闭缓存
// ... 修改子元素 ...
complexUI.cacheAsBitmap = true; // 重新开启缓存
}
⚠️ 注意:
cacheAsBitmap会创建一张额外的纹理,占用显存。对于频繁变化的容器不适用,仅适合静态或低频变化的复杂 UI。
综合实战:游戏 HUD 系统
import { Application, Container, Graphics, Text, Sprite } from 'pixi.js';
const app = new Application();
await app.init({ width: 800, height: 600 });
// 游戏世界容器(受相机影响)
const world = new Container();
app.stage.addChild(world);
// UI 层(不受相机影响)
const hud = new Container();
app.stage.addChild(hud);
// 血条组件
const healthBar = createHealthBar();
healthBar.x = 10;
healthBar.y = 10;
hud.addChild(healthBar);
function createHealthBar() {
const bar = new Container();
const bg = new Graphics();
bg.roundRect(0, 0, 200, 24, 4);
bg.fill(0x333333);
bar.addChild(bg);
const fill = new Graphics();
fill.roundRect(2, 2, 196, 20, 3);
fill.fill(0xff4444);
bar.addChild(fill);
bar.updateHealth = (ratio) => {
fill.clear();
fill.roundRect(2, 2, 196 * ratio, 20, 3);
fill.fill(0xff4444);
};
return bar;
}
// 使用遮罩显示小地图
const minimap = new Container();
minimap.x = 650;
minimap.y = 10;
hud.addChild(minimap);
const minimapContent = new Container();
const minimapMask = new Graphics();
minimapMask.rect(0, 0, 140, 140);
minimapMask.fill(0xffffff);
minimapContent.mask = minimapMask;
minimap.addChild(minimapContent, minimapMask);