В первой части мы разобрали основы PixiJS 8: создание приложения, рисование фигур через Graphics , игровой цикл, спрайты, контейнеры и фильтры. Всё это — фундамент. Теперь строим на нём что-то по-настоящему впечатляющее.
В этой части — инструменты, которые превращают «просто анимацию» в сцены, от которых трудно оторваться: десятки тысяч частиц, рисование прямо в текстуру, режимы смешивания для огня и свечения, покадровая анимация и написание собственных шейдеров на GLSL. Пристёгивайтесь.
ParticleContainer — десятки тысяч объектов без лагов
Обычный Container удобен, но каждый его дочерний объект может иметь произвольные трансформации, фильтры и события — и браузер проверяет всё это на каждом кадре. Когда объектов тысячи, это становится проблемой.
ParticleContainer — специальный контейнер, оптимизированный именно для таких случаев. Он работает по-другому: все спрайты внутри него должны использовать одну текстуру, а набор допустимых трансформаций ограничен. Взамен вы получаете скорость: там, где обычный контейнер начинает задыхаться на двух-трёх тысячах объектов, ParticleContainer спокойно тянет 50 000 и больше.
// Второй аргумент — какие свойства разрешены для изменения.
// Отключайте всё, что не нужно — это ускоряет рендеринг.
const container = new PIXI.ParticleContainer(50_000, {
position: true, // x, y
rotation: true, // угол
scale: true, // масштаб
alpha: true, // прозрачность
uvs: false, // координаты текстуры (не нужны, если текстура одна)
tint: false, // цветовой оттенок
});
app.stage.addChild(container);
// Рисуем кружок и запекаем в текстуру — одна текстура на все частицы
const shape = new PIXI.Graphics();
shape.circle(0, 0, 4).fill({ color: 0xffffff });
const texture = app.renderer.generateTexture(shape);
// Создаём 10 000 частиц
const particles = [];
for (let i = 0; i < 10_000; i++) {
const p = new PIXI.Sprite(texture);
p.anchor.set(0.5);
p.x = Math.random() * app.screen.width;
p.y = Math.random() * app.screen.height;
p.alpha = Math.random() * 0.8 + 0.2;
p.scale.set(Math.random() * 1.5 + 0.5);
p.tint = Math.random() * 0xffffff;
// Скорость движения для каждой частицы
p.vx = (Math.random() - 0.5) * 2;
p.vy = (Math.random() - 0.5) * 2;
particles.push(p);
container.addChild(p);
}
app.ticker.add((time) => {
for (const p of particles) {
p.x += p.vx * time.deltaTime;
p.y += p.vy * time.deltaTime;
// Оборачиваем за края экрана
if (p.x < 0) p.x = app.screen.width;
if (p.x > app.screen.width) p.x = 0;
if (p.y < 0) p.y = app.screen.height;
if (p.y > app.screen.height) p.y = 0;
}
}); Свойство tint у ParticleContainer по умолчанию выключено (передаётся как false) — и это не баг, а осознанная оптимизация. Если вам нужны разноцветные частицы, включите tint: true, но имейте в виду: это немного снижает производительность. Хотите ещё быстрее — используйте одну текстуру на один цвет и несколько контейнеров.
Ниже — живой пример: 10 000 частиц в реальном времени. Если ваш вентилятор ноутбука не заводится — значит, ParticleContainer работает как надо:
RenderTexture — рисуем в текстуру
Как правило, результат рендера PixiJS уходит прямо на страницу. Но иногда нужно что-то нарисовать «за кулисами» — сохранить результат в текстуру и потом использовать её как изображение. Это и есть RenderTexture : невидимый холст, в который можно рендерить объекты, а затем применять как обычную текстуру к спрайту.
Это открывает целый класс эффектов, которые иначе не реализовать: следы движения, эффект рисования кистью, накапливающиеся следы частиц, постепенное «проявление» текстуры. Принцип прост — создаём RenderTexture , рендерим в неё нужные объекты, получаем результат как обычную текстуру:
// Создаём render texture размером с экран
const renderTexture = PIXI.RenderTexture.create({
width: app.screen.width,
height: app.screen.height,
});
// Спрайт, который отображает содержимое render texture
const canvas = new PIXI.Sprite(renderTexture);
app.stage.addChild(canvas);
// «Кисть» — круг, который будем рендерить в текстуру
const brush = new PIXI.Graphics();
brush.circle(0, 0, 20).fill({ color: 0xff6b6b, alpha: 0.6 });
// Следим за мышью
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
app.stage.on('pointermove', (e) => {
brush.x = e.global.x;
brush.y = e.global.y;
// Рендерим кисть в текстуру — не очищая её (clear: false)
// Каждый раз кисть «допечатывается» поверх предыдущего следа
app.renderer.render({
container: brush,
target: renderTexture,
clear: false,
});
}); Ключевой момент — параметр clear: false . Именно он создаёт «накапливающийся» эффект: каждый новый рендер добавляется поверх предыдущего, не стирая его. Если передать clear: true — текстура будет очищаться каждый раз, и вы увидите только последнее положение кисти.
Попробуйте порисовать в примере ниже. Кисть оставляет светящийся след — это и есть RenderTexture в действии:
Затухающий след
Статичный след — уже интересно. Но что, если хочется, чтобы след медленно исчезал? Хитрость в том, чтобы каждый кадр накладывать на текстуру полупрозрачный тёмный прямоугольник — он постепенно «затирает» старый след, создавая эффект затухания. Правда, для этого надо включить premultipliedAlpha: false при создании текстуры — иначе альфа будет работать неправильно:
const renderTexture = PIXI.RenderTexture.create({
width: app.screen.width,
height: app.screen.height,
// Позволяет корректно накладывать полупрозрачные слои
alphaMode: PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA,
});
// Полупрозрачная «стирающая» плашка на весь экран
const fade = new PIXI.Graphics();
fade.rect(0, 0, app.screen.width, app.screen.height)
.fill({ color: 0x000000, alpha: 0.05 }); // 5% непрозрачности
app.ticker.add(() => {
// Сначала накладываем затухающий слой...
app.renderer.render({ container: fade, target: renderTexture, clear: false });
// ...затем рендерим кисть в точке под курсором
if (mouseActive) {
app.renderer.render({ container: brush, target: renderTexture, clear: false });
}
}); Blend Modes — режимы смешивания
Blend mode — это правило, по которому новый пиксель смешивается с тем, что уже нарисовано под ним. В CSS вы наверняка встречали mix-blend-mode . В PixiJS это работает так же, но с аппаратным ускорением и применимо к любому объекту на сцене.
Самые полезные режимы для визуальных эффектов:
- add — складывает значения пикселей. Там где два объекта перекрываются, становится ярче. Идеально для огня, взрывов и свечения — частицы «суммируются» и создают горячие белые центры.
- multiply — перемножает. Результат всегда темнее. Хорошо для теней, тонирования, эффекта «чернил».
- screen — инверсия multiply: результат всегда светлее. Подходит для дымки, засветки, призрачных эффектов.
- normal — режим по умолчанию, без смешивания.
// Создаём частицу с режимом ADD — она будет «прибавляться» к фону
const particle = new PIXI.Graphics();
particle.circle(0, 0, 30).fill({ color: 0xff4400 });
particle.blendMode = 'add';
// Два перекрывающихся круга: там, где они пересекаются, будет белое пятно
const a = new PIXI.Graphics();
const b = new PIXI.Graphics();
a.circle(0, 0, 50).fill({ color: 0xff0000 });
b.circle(0, 0, 50).fill({ color: 0x0000ff });
a.blendMode = 'add';
b.blendMode = 'add';
a.x = 100; a.y = 150;
b.x = 140; b.y = 150; // частичное перекрытие → фиолетовый центр светлеет
// Для спрайта из файла — то же самое
const sprite = new PIXI.Sprite(texture);
sprite.blendMode = 'screen'; Режим add — секретный ингредиент любого эффекта огня или магии. Возьмите тёмный фон, нарисуйте тысячу оранжево-красных частиц в режиме add — и получите языки пламени. Никакой магии, чистая математика:
Blend modes работают только на объектах с тёмным или прозрачным фоном — иначе смешивание не будет заметно. Если вы включили add, а эффект не виден — проверьте, что фон под объектом не белый. Белый + add = всё равно белый.
AnimatedSprite — покадровая анимация
Пока мы двигали и вращали объекты через Ticker — это процедурная анимация. Но персонажи в играх часто анимируются иначе: художник рисует последовательность кадров, а движок листает их с нужной скоростью. В PixiJS для этого есть AnimatedSprite .
Кадры передаются как массив текстур. Обычно их загружают из спрайт-листа (sprite sheet) — одного большого изображения, где все кадры упакованы рядом. Загружать дюжину отдельных файлов долго и расточительно — спрайт-лист решает это: один HTTP-запрос, одна текстура в GPU, минимум накладных расходов:
// Загружаем спрайт-лист — JSON с координатами кадров + само изображение
await PIXI.Assets.load('path/to/spritesheet.json');
// После загрузки PixiJS знает о кадрах и может достать их по имени
const frames = [];
for (let i = 0; i < 8; i++) {
// Имена кадров обычно задаются в JSON, например: 'run_00.png', 'run_01.png'...
frames.push(PIXI.Texture.from(`run_0${i}.png`));
}
const character = new PIXI.AnimatedSprite(frames);
character.anchor.set(0.5);
character.x = app.screen.width / 2;
character.y = app.screen.height / 2;
// Скорость: количество кадров за одну «тику» игрового цикла
// 0.1 → примерно 6 кадров в секунду при 60 fps
character.animationSpeed = 0.15;
character.loop = true; // зациклить
character.play(); // запустить
app.stage.addChild(character); Если спрайт-листа нет — можно нарезать кадры прямо из одной большой текстуры, указав frame — прямоугольник нужного фрагмента:
// Загружаем одно изображение со всеми кадрами в ряд
const baseTexture = await PIXI.Assets.load('sheet.png');
const FRAME_W = 64; // ширина одного кадра
const FRAME_H = 64; // высота одного кадра
const COLS = 8; // количество кадров в ряду
const frames = Array.from({ length: COLS }, (_, i) =>
new PIXI.Texture({
source: baseTexture.source,
frame: new PIXI.Rectangle(i * FRAME_W, 0, FRAME_W, FRAME_H),
})
);
const sprite = new PIXI.AnimatedSprite(frames);
sprite.animationSpeed = 0.15;
sprite.loop = true;
sprite.play(); У AnimatedSprite есть полезные callback'и: onComplete срабатывает, когда анимация дошла до конца (актуально при loop: false ), а onFrameChange — при смене каждого кадра. Удобно, чтобы синхронизировать звук с ударом ноги персонажа.
Кастомные шейдеры на GLSL
Встроенные фильтры PixiJS — это удобно. Но рано или поздно возникает задача, для которой готового фильтра нет. Тут на помощь приходит Filter — класс, позволяющий написать собственный шейдер на GLSL (Graphics Library Shading Language).
Шейдер — это программа, которая выполняется прямо на GPU для каждого пикселя. Звучит страшно, на деле — вполне освоимо. В PixiJS шейдер фильтра состоит из двух частей: вершинного (vertex) и фрагментного (fragment). Нас интересует фрагментный — он отвечает за цвет каждого пикселя.
Волновой эффект
Начнём с волнового деформирующего фильтра. Идея простая: для каждого пикселя немного сдвигаем координату выборки текстуры по синусоидальной кривой — и картинка «волнится». Параметр uTime передаётся из JavaScript и меняется каждый кадр, заставляя волну двигаться:
// GLSL-код фрагментного шейдера
const fragmentShader = `
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform float uTime;
uniform float uAmplitude;
uniform float uFrequency;
void main() {
vec2 uv = vTextureCoord;
// Смещаем X-координату по синусоиде, зависящей от Y и времени
uv.x += sin(uv.y * uFrequency + uTime) * uAmplitude;
gl_FragColor = texture2D(uSampler, uv);
}
`;
// Создаём фильтр — передаём фрагментный шейдер
// (вершинный берётся стандартный)
const waveFilter = new PIXI.Filter({
glProgram: PIXI.GlProgram.from({
fragment: fragmentShader,
vertex: PIXI.defaultFilterVert, // стандартный вершинный шейдер
}),
resources: {
// Uniform-переменные — доступны в шейдере
waveUniforms: {
uTime: { value: 0, type: 'f32' },
uAmplitude: { value: 0.01, type: 'f32' },
uFrequency: { value: 20, type: 'f32' },
},
},
});
// Применяем к любому объекту или ко всей сцене
sprite.filters = [waveFilter];
// Каждый кадр обновляем время — волна движется
app.ticker.add((time) => {
waveFilter.resources.waveUniforms.uniforms.uTime += 0.05 * time.deltaTime;
}); Хроматическая аберрация
Хроматическая аберрация — эффект, при котором цветовые каналы изображения слегка разъезжаются в стороны. Выглядит как «глитч» или эффект старой видеозаписи. Реализуется просто: сэмплируем красный канал чуть левее, синий — чуть правее:
const chromaFragment = `
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform float uOffset;
void main() {
vec2 uv = vTextureCoord;
// Красный канал — сдвинут влево
float r = texture2D(uSampler, vec2(uv.x - uOffset, uv.y)).r;
// Зелёный — без сдвига
float g = texture2D(uSampler, uv).g;
// Синий — сдвинут вправо
float b = texture2D(uSampler, vec2(uv.x + uOffset, uv.y)).b;
// Альфа — стандартная
float a = texture2D(uSampler, uv).a;
gl_FragColor = vec4(r, g, b, a);
}
`;
const chromaFilter = new PIXI.Filter({
glProgram: PIXI.GlProgram.from({
fragment: chromaFragment,
vertex: PIXI.defaultFilterVert,
}),
resources: {
chromaUniforms: {
uOffset: { value: 0.003, type: 'f32' },
},
},
});
// Можно комбинировать несколько фильтров — они применяются по порядку
sprite.filters = [waveFilter, chromaFilter]; В PixiJS 8 API фильтров переработали: вместо new PIXI.Filter(vert, frag, uniforms) теперь используется объектная нотация с GlProgram.from() и resources. Если вы переносите шейдеры со старых версий — обратите на это внимание, иначе получите ошибку и озадаченное лицо.
Пример ниже показывает волновой фильтр и хроматическую аберрацию в действии. Шейдер запускается целиком на GPU — JavaScript только передаёт значение времени раз в кадр:
Assets — правильная загрузка ресурсов
В первой части мы грузили файлы через PIXI.Assets.load() по одному. Для небольших сцен — нормально. Для реального проекта лучше описать все ресурсы заранее и загрузить их пакетом, пока пользователь видит экран загрузки:
// Описываем все ресурсы заранее — с псевдонимами
PIXI.Assets.add({ alias: 'hero', src: 'img/hero.png' });
PIXI.Assets.add({ alias: 'background', src: 'img/bg.webp' });
PIXI.Assets.add({ alias: 'enemies', src: 'img/enemies-sheet.json' });
PIXI.Assets.add({ alias: 'ui', src: 'img/ui-sheet.json' });
// Загружаем всё пакетом с отслеживанием прогресса
await PIXI.Assets.load(
['hero', 'background', 'enemies', 'ui'],
(progress) => {
// progress — от 0 до 1
loadingBar.width = app.screen.width * progress;
console.log(`Загрузка: ${Math.round(progress * 100)}%`);
}
);
// Теперь ресурсы доступны мгновенно по псевдониму
const hero = new PIXI.Sprite(PIXI.Assets.get('hero'));
const background = new PIXI.Sprite(PIXI.Assets.get('background'));
// Кадры спрайт-листа тоже по имени из JSON
const enemyTexture = PIXI.Texture.from('enemy_walk_01.png'); Для больших игр или приложений с несколькими сценами удобны бандлы — группы ресурсов, которые загружаются только при переходе на нужный экран:
// Описываем бандлы
PIXI.Assets.addBundle('level-1', [
{ alias: 'tileset', src: 'levels/1/tiles.json' },
{ alias: 'bg-layer1', src: 'levels/1/bg1.webp' },
{ alias: 'bg-layer2', src: 'levels/1/bg2.webp' },
]);
PIXI.Assets.addBundle('level-2', [
{ alias: 'tileset2', src: 'levels/2/tiles.json' },
{ alias: 'bg2-1', src: 'levels/2/bg1.webp' },
]);
// Грузим только нужный уровень — остальные ждут
await PIXI.Assets.loadBundle('level-1', (progress) => {
console.log(`Уровень 1: ${Math.round(progress * 100)}%`);
});
// При переходе на второй уровень — грузим второй бандл
await PIXI.Assets.loadBundle('level-2'); Итого: что изучили в этой части
Пять мощных инструментов, которые меняют уровень того, что можно сделать с PixiJS:
- ParticleContainer — когда нужно 50 000 объектов без единого лага. Одна текстура, максимальная скорость.
- RenderTexture — рендеринг в офлайн-текстуру. Следы, рисование кистью, накопленные эффекты.
- blendMode = "add" — сложение пикселей для огня, свечения и магических эффектов. Тёмный фон обязателен.
- AnimatedSprite — покадровая анимация из спрайт-листа. Один файл, один запрос, вся анимация персонажа.
- Filter + GLSL — собственные шейдеры для любых визуальных эффектов, которых нет в стандартной поставке.
В третьей части соберём всё воедино: построим полноценную интерактивную сцену — с управлением, столкновениями, звуком и переходами между состояниями. Другими словами, маленькую, но настоящую игру.