强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Pixi.js 游戏开发教程 / 容器与场景图

容器与场景图

概述

场景图(Scene Graph)是 Pixi.js 组织显示对象的核心数据结构。它是一棵app.stage 是根节点,所有显示对象(Sprite、Graphics、Text 等)都是树中的节点。Container(容器)是场景图中的分支节点,可以包含子节点。

理解场景图对于构建复杂的游戏 UI、多层场景、以及优化渲染性能至关重要。


Container 基础

Container 是所有可显示对象的基类,本身不渲染任何内容,仅作为子节点的容器。

import { Application, Container, Sprite, Graphics, Assets } from 'pixi.js';

const app = new Application();
await app.init({ width: 800, height: 600, background: '#1a1a2e' });
document.body.appendChild(app.canvas);

// 创建容器
const gameWorld = new Container();
const uiLayer = new Container();

app.stage.addChild(gameWorld);
app.stage.addChild(uiLayer);

// 向容器中添加子节点
const heroTexture = await Assets.load('/image/hero.png');
const hero = new Sprite(heroTexture);
hero.position.set(400, 300);
gameWorld.addChild(hero);

// UI 元素添加到 UI 层
const hpBar = new Graphics();
hpBar.rect(0, 0, 200, 20);
hpBar.fill(0xe74c3c);
hpBar.position.set(20, 20);
uiLayer.addChild(hpBar);

addChild 与 removeChild

添加子节点

const parent = new Container();
const child = new Sprite(texture);

// 添加到末尾
parent.addChild(child);

// 添加到指定索引
parent.addChildAt(child, 0);

// 添加多个
parent.addChild(child1, child2, child3);

移除子节点

// 移除指定子节点
parent.removeChild(child);

// 移除指定索引的子节点
parent.removeChildAt(0);

// 移除指定范围的子节点
parent.removeChildren(0, 5); // 移除索引 0-4

// 移除所有子节点
parent.removeChildren();

检查与查找

// 检查是否包含某个子节点
if (parent.contains(child)) {
  console.log('已包含');
}

// 获取子节点数量
console.log(parent.children.length);

// 通过索引获取子节点
const first = parent.children[0];

场景树结构

典型的游戏场景树:

app.stage (根容器)
├── background (Container) — 背景层
│   ├── sky (Sprite)
│   ├── mountains (Sprite)
│   └── ground (Sprite)
├── gameWorld (Container) — 游戏世界层
│   ├── enemies (Container) — 敌人容器
│   │   ├── enemy1 (Sprite)
│   │   └── enemy2 (Sprite)
│   ├── player (Sprite)
│   └── bullets (Container) — 子弹容器
│       ├── bullet1 (Sprite)
│       └── bullet2 (Sprite)
└── uiLayer (Container) — UI 层
    ├── scoreText (Text)
    ├── hpBar (Graphics)
    └── minimap (Container)

💡 提示: 分层管理场景是游戏开发的最佳实践。UI 层不受游戏世界相机变换影响,便于独立管理。

// 分层管理示例
class GameScene {
  stage: Container;
  bgLayer: Container;
  worldLayer: Container;
  uiLayer: Container;

  constructor() {
    this.stage = new Container();
    this.bgLayer = new Container();
    this.worldLayer = new Container();
    this.uiLayer = new Container();

    // 渲染顺序:背景 → 世界 → UI
    this.stage.addChild(this.bgLayer);
    this.stage.addChild(this.worldLayer);
    this.stage.addChild(this.uiLayer);
  }
}

容器变换属性

容器继承自 Container,具有与 Sprite 相同的变换属性,且变换会传递给所有子节点

position

const group = new Container();
group.position.set(200, 150);

const child = new Sprite(texture);
child.position.set(50, 50); // 相对于父容器

// 子节点在世界坐标中的实际位置:(250, 200)
console.log(child.getGlobalPosition()); // { x: 250, y: 200 }

scale

const group = new Container();
group.scale.set(2, 2);

const child = new Sprite(texture);
child.position.set(100, 100);

// 子节点的实际位置:(200, 200),实际大小:2倍

rotation

const pivot = new Container();
pivot.position.set(400, 300);
pivot.pivot.set(0, 0); // 旋转中心

const arm = new Sprite(texture);
arm.position.set(50, 0); // 距离旋转中心 50px
pivot.addChild(arm);

// 旋转容器 → 所有子节点围绕容器 pivot 旋转
app.ticker.add(() => {
  pivot.rotation += 0.01;
});

pivot

const box = new Container();
box.position.set(400, 300);

// 设置 pivot 为容器中心
box.pivot.set(50, 50); // 假设容器内容为 100x100

// 旋转和缩放都围绕 pivot 点
box.rotation = Math.PI / 4;

局部坐标 vs 世界坐标

世界坐标(World): stage 的坐标系,原点在 canvas 左上角
局部坐标(Local): 相对于父容器的坐标,受父容器 position/scale/rotation 影响
// 设置世界坐标
const child = new Sprite(texture);
child.position.set(100, 200); // 这是局部坐标

// 获取世界坐标
const globalPos = child.getGlobalPosition();
console.log(globalPos.x, globalPos.y);

// 将世界坐标转换为某容器的局部坐标
const localPos = container.toLocal(globalPos);
console.log(localPos.x, localPos.y);

// 将局部坐标转换为世界坐标
const worldPos = container.toWorld(localPos);

坐标转换示例

const gameWorld = new Container();
gameWorld.position.set(100, 50);
gameWorld.scale.set(0.5);
app.stage.addChild(gameWorld);

const enemy = new Sprite(texture);
enemy.position.set(200, 300); // 在 gameWorld 的局部坐标
gameWorld.addChild(enemy);

// 获取 enemy 在屏幕上的真实位置
const screenPos = enemy.getGlobalPosition();
console.log(screenPos); // { x: 200, y: 200 } → 100+200*0.5, 50+300*0.5

// 鼠标点击位置转换到 gameWorld 局部坐标
app.stage.on('pointerdown', (e) => {
  const worldLocal = gameWorld.toLocal(e.global);
  console.log('点击位置(世界局部坐标):', worldLocal);
});

getBounds / getLocalBounds

// 获取容器在世界坐标系中的包围盒
const bounds = container.getBounds();
console.log(bounds.x, bounds.y, bounds.width, bounds.height);

// 获取容器在自身坐标系中的包围盒
const localBounds = container.getLocalBounds();
console.log(localBounds.x, localBounds.y, localBounds.width, localBounds.height);

⚠️ 注意: getBounds() 会遍历所有子节点计算,对大场景可能有性能开销。缓存结果而非每帧调用。


zIndex 排序

默认情况下,子节点按添加顺序渲染(后添加的在上层)。可通过 zIndex 改变排序。

const container = new Container();
container.sortableChildren = true; // 启用排序

const bg = new Sprite(bgTexture);
bg.zIndex = 0;

const player = new Sprite(playerTexture);
player.zIndex = 10;

const foreground = new Sprite(fgTexture);
foreground.zIndex = 20;

container.addChild(bg, player, foreground);
// 渲染顺序:bg → player → foreground
属性类型默认值说明
sortableChildrenbooleanfalse是否启用子节点排序
zIndexnumber0排序值(越大越在上层)

容器销毁(destroy)

// 销毁容器及其所有子节点
container.destroy({
  children: true,    // 同时销毁子节点(默认 false)
  texture: false,    // 是否销毁纹理(默认 false)
  baseTexture: false, // 是否销毁 BaseTexture(默认 false)
});

// 仅从父节点移除(不销毁)
parent.removeChild(container);

⚠️ 注意: 如果多个 Sprite 共享同一纹理,销毁 Sprite 时不要销毁纹理:

// ❌ 可能导致其他使用相同纹理的精灵出错
sprite.destroy({ texture: true });

// ✅ 安全销毁精灵,纹理留给其他精灵使用
sprite.destroy();

场景图遍历

// 递归遍历场景树
function traverse(node: Container, depth: number = 0) {
  const indent = '  '.repeat(depth);
  const type = node.constructor.name;
  const pos = `(${Math.round(node.x)}, ${Math.round(node.y)})`;
  console.log(`${indent}${type} ${pos}`);

  for (const child of node.children) {
    if (child instanceof Container) {
      traverse(child, depth + 1);
    }
  }
}

traverse(app.stage);

输出示例:

Container (0, 0)
  Container (0, 0)
    Sprite (100, 200)
    Sprite (300, 200)
  Container (0, 0)
    Text (20, 20)

游戏开发场景

场景:相机跟随系统

import { Application, Container, Sprite, Assets } from 'pixi.js';

const app = new Application();
await app.init({ width: 800, height: 600, background: '#2c3e50' });
document.body.appendChild(app.canvas);

// 世界容器(相机控制此容器的 position)
const world = new Container();
app.stage.addChild(world);

// 创建大量游戏对象
const texture = await Assets.load('/image/tile.png');
for (let x = 0; x < 20; x++) {
  for (let y = 0; y < 20; y++) {
    const tile = new Sprite(texture);
    tile.x = x * 64;
    tile.y = y * 64;
    world.addChild(tile);
  }
}

// 玩家
const player = new Sprite(await Assets.load('/image/hero.png'));
player.anchor.set(0.5);
player.position.set(640, 640);
world.addChild(player);

// 相机跟随
app.ticker.add(() => {
  // 使玩家始终在屏幕中央
  world.x = app.screen.width / 2 - player.x;
  world.y = app.screen.height / 2 - player.y;
});

场景:HUD 叠加层

// HUD 不受世界变换影响
const hud = new Container();
app.stage.addChild(hud);

const scoreText = new Text({
  text: 'Score: 0',
  style: { fontSize: 24, fill: '#ffffff' },
});
scoreText.position.set(20, 20);
hud.addChild(scoreText);

// HUD 始终在最上层,不受世界相机移动影响

⚠️ 常见问题

问题原因解决方案
子节点位置异常父容器有变换检查父容器的 position/scale/rotation
移除后仍渲染未正确从父节点移除确认调用 removeChild()
内存泄漏容器未销毁或未从父节点移除使用 destroy() 彻底释放
zIndex 不生效未启用 sortableChildren设置 container.sortableChildren = true
getBounds 返回空容器没有可见子节点确保有子节点且 visible = true

💡 进阶提示

  1. 对象池复用 Container:

    class ContainerPool<T extends Container> {
      private pool: T[] = [];
      private factory: () => T;
    
      constructor(factory: () => T) {
        this.factory = factory;
      }
    
      get(): T {
        const obj = this.pool.pop() || this.factory();
        obj.visible = true;
        return obj;
      }
    
      release(obj: T) {
        obj.visible = false;
        obj.removeFromParent();
        this.pool.push(obj);
      }
    }
    
  2. 缓存容器为纹理(静态内容优化):

    // 将复杂静态容器缓存为单张纹理,减少 draw call
    const bounds = complexContainer.getBounds();
    const renderTexture = app.renderer.generateTexture(complexContainer);
    const cached = new Sprite(renderTexture);
    cached.position.set(bounds.x, bounds.y);
    complexContainer.visible = false;
    app.stage.addChild(cached);
    
  3. 层级调试: 开发时打印容器层级辅助调试:

    function printTree(node: Container, depth = 0) {
      console.log(`${'  '.repeat(depth)}[${node.constructor.name}] children=${node.children.length}`);
      node.children.forEach(c => {
        if (c instanceof Container) printTree(c, depth + 1);
      });
    }
    printTree(app.stage);
    

扩展阅读


上一章:03 - Sprite 与纹理 下一章:05 - 图形绘制(Graphics)