Изучаем PixiJS. Часть 2 — продвинутая анимация и GLSL-шейдеры

JavaScript
148 просмотров

В первой части мы разобрали основы PixiJS 8: создание приложения, рисование фигур через Graphics , игровой цикл, спрайты, контейнеры и фильтры. Всё это — фундамент. Теперь строим на нём что-то по-настоящему впечатляющее.

PixiJS: частицы, шейдеры и визуальные эффекты — продвинутые техники

В этой части — инструменты, которые превращают «просто анимацию» в сцены, от которых трудно оторваться: десятки тысяч частиц, рисование прямо в текстуру, режимы смешивания для огня и свечения, покадровая анимация и написание собственных шейдеров на 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 — собственные шейдеры для любых визуальных эффектов, которых нет в стандартной поставке.

В третьей части соберём всё воедино: построим полноценную интерактивную сцену — с управлением, столкновениями, звуком и переходами между состояниями. Другими словами, маленькую, но настоящую игру.

Вам может быть интересно:
Swiper.js — слайдер для сайта: настройки и примеры
Swiper.js — как подключить и настроить слайдер на сайте
Что такое функция в программировании — типы и примеры на JavaScript и Python
Что такое функция в программировании?
Promise в JavaScript — промисы JS простыми словами для начинающих
Promise в JavaScript простыми словами — полное руководство
Слайдер для сайта на Owl Carousel 2 — настройка и примеры
Слайдер для сайта Owl Carousel 2 — настройка просто и быстро
Комментарии 0 Разные мнения приветствуются. Здесь можно спорить и обсуждать, но уважительно и без токсичности.
Отмена