Система частиц

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


Для демонстрации работы системы частиц я выбрал бенгальский огонь. Мой выбор обусловлен тем, что мы покроем бОльшую часть свойств, присущую другим системам частиц. У нас будет источник появления частиц (так называемый эмиттер), а каждая частица будет имеет свою скорость, направление и время жизни. Дополнительно каждая частица будет оставлять за собой след, как и все приличные искры.

Если говорить о системах частиц в целом, каждую частицу представляет отдельный объект. Это может быть полноценный 3D-объект, прямоугольник с заливкой или текстурой, или точка (спрайт). Для искр нам идеально подходит последний вариант — спрайт, так как для каждой частицы достаточно всего одной вершины, к тому же спрайт по определению всё время повёрнут в сторону наблюдателя, независимо от поворота сцены, и искра всегда остаётся искрой.



Одна искра

Начнём с самого простого — с отображения одной искры. Предполагается, что вам уже знакомы базовые принципы WebGL и основы текстур. Если нет — не беда, обо всём этом можно узнать в уроках (раз, два).

Искра Для искры нам понадобится изображение с прозрачным фоном, которое мы будем использовать в качестве текстуры. Наверное, звучит странно, что у нас будет текстура для точки, но в этом-то и вся соль. Мы можем задать размер точки с помощью gl_PointSize в вершинном шейдере, и тогда точка превращается в квадрат указанного размера с центром в этой точке. Поведение такой фигуры будет отличаться от квадрата, заданного двумя треугольниками: квадрат из двух треугольников будет подвержен трансформациям матриц, то есть он будет вращаться при повороте и уменьшаться при удалении от наблюдателя, в то время как заданный одной точкой квадрат будет всегда направлен к наблюдателю и иметь размер gl_PointSize.

Вершинный шейдер будет самым обычным, добавится лишь задание размера квадрата через gl_PointSize. Желательно, чтобы размер точки совпадал с размером изображения искры.


    attribute vec3 a_position;

    uniform mat4 u_mvMatrix;
    uniform mat4 u_pMatrix;

    void main() {
        gl_Position = u_pMatrix * u_mvMatrix * vec4(a_position, 1.0);

        // размер искры
        gl_PointSize = 32.0;
    }


Фрагментный шейдер будет типовым для использования текстур:


    precision mediump float;

    uniform sampler2D u_texture;

    void main() {
        gl_FragColor = texture2D(u_texture, gl_PointCoord);
    }


Теперь пройдёмся по коду JavaScript. Первым делом нам необходимо загрузить изображение и инициализировать текстуру искры, только после этого мы начинаем отрисовывать сцену.


    var texture = gl.createTexture();
    var image = new Image();
    image.src = "spark.png";
    image.addEventListener('load', function() {
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        gl.generateMipmap(gl.TEXTURE_2D);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        gl.bindTexture(gl.TEXTURE_2D, null);

        // отрисовку сцены начинаем только после загрузки изображения
        requestAnimationFrame(drawScene);
    });


Сравнение фильтров текстур Обратите внимание на использование значения gl.NEAREST для фильтрации текстуры вместо значения по умолчанию gl.LINEAR. Дело в том, что при использовании gl.LINEAR при определённых значениях размера canvas (например, 512х513) текстура незначительно меняется в размере и пиксель начинает формироваться из нескольких окружающих его пикселей, из-за чего искра получается немного размытой (правая часть изображения). При значении фильтра gl.NEAREST берётся один наиболее подходящий пиксель и искра выглядит чёткой (левая часть изображения).

Для того, чтобы WebGL понимал прозрачность и избавлялся от фона искры, нам необходимо включить смешивание цветов.


    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE);


Смешивание определяет, как будут взаимодействовать между собой пиксели, которые уже были отрисованы, с пикселями, которые рисуются в данный момент. Когда наша система частиц заработает на полную катушку, искры будут многократно накладываться друг на друга и без правильного задания смешивания нельзя ожидать приемлемого результата. Вызов gl.enable(gl.BLEND) включает смешивание, а функция gl.blendFunc определяет взаимодействие рисуемых и уже отрисованных пикселей. Первый параметр функции — множитель для рисуемого пикселя. Значение gl.SRC_ALPHA означает, что каждый канал рисуемого пикселя нужно умножить на прозрачность рисуемого пикселя. Так мы уберём прозрачный фон изображения и получим только изображение искры. Второй параметр — множитель для уже отрисованного пикселя. Значение gl.ONE означает, что уже отрисованные пиксели никак не меняются (умножение на 1 не меняет значение).

Остальной код довольно шаблонный — инициализация буферов, uniform-переменных, атрибутов… И в результате получится следующая картина (можно открыть в отдельном окне и посмотреть исходный код):



Добавляем следы искр

Искры бенгальского огня оставляют за собой след, идущий от источника появления частиц до текущего положения искры. Этот след можно представить линией из двух координат, одна из которых будет текущей координатой частицы, а вторая — координатой эмиттера (в нашем случае эмиттер располагается в координатах 0, 0, 0). Причём след будет более яркий возле эмиттера, а по мере отдаления будет становиться менее ярким, приближаясь к оранжевому — так мы сымитируем остывание искры.

Но сейчас наши шейдеры не приспособлены для отображения линии: они отображают точку вместо линии и текстуру вместо цвета. Поэтому нам понадобится ещё один набор шейдеров и, соответственно, ещё одна программа.

Вершинный шейдер линии через varying-переменную будет передавать значение цвета во фрагментный шейдер, а также определять текущую координату частицы:


    attribute vec3 a_position;
    attribute vec3 a_color;

    varying vec3 v_color;

    uniform mat4 u_mvMatrix;
    uniform mat4 u_pMatrix;

    void main() {
        v_color = a_color;
        gl_Position = u_pMatrix * u_mvMatrix * vec4(a_position, 1.0);
    }


Фрагментный шейдер будет просто устанавливать итоговый цвет пикселя из значения varying-переменной:


    precision mediump float;

    varying vec3 v_color;

    void main() {
        gl_FragColor = vec4(v_color, 1.0);
    }


Нам понадобится две программы и два набора ссылок на атрибуты и uniform-переменные:


    // инициализация программы следов искр
    var programTrack = webglUtils.createProgramFromScripts(gl, ["vertex-shader-track", "fragment-shader-track"]);

    var positionAttributeLocationTrack = gl.getAttribLocation(programTrack, "a_position");
    var colorAttributeLocationTrack = gl.getAttribLocation(programTrack, "a_color");
    var pMatrixUniformLocationTrack = gl.getUniformLocation(programTrack, "u_pMatrix");
    var mvMatrixUniformLocationTrack = gl.getUniformLocation(programTrack, "u_mvMatrix");

    // инициализация программы искр
    var programSpark = webglUtils.createProgramFromScripts(gl, ["vertex-shader-spark", "fragment-shader-spark"]);

    var positionAttributeLocationSpark = gl.getAttribLocation(programSpark, "a_position");
    var textureLocationSpark = gl.getUniformLocation(programSpark, "u_texture");
    var pMatrixUniformLocationSpark = gl.getUniformLocation(programSpark, "u_pMatrix");
    var mvMatrixUniformLocationSpark = gl.getUniformLocation(programSpark, "u_mvMatrix");


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


    var positions = [
         1, 0,   0,
        -1, 0.5, 0,
        -0.5, -1, 0
    ];
    drawTracks(positions);
    drawSparks(positions);


В начале каждой функции отрисовки drawTracks и drawSparks будет вызываться gl.useProgram для установки текущей программы. Таким образом при отрисовке искр функцией drawSparks будет активироваться программа programSpark, и шейдеры этой программы отобразят точку с текстурой. А при отрисовке следов искр функцией drawTracks будет активироваться программа programTrack, а соответствующие шейдеры отобразят линию с плавным переходом цвета от белого к оранжевому.

Для отрисовки следов искр нам необходимо дополнить массив координат нулевыми точками для координаты каждой искры (чтобы получить линию из двух координат), а также задать цвета — 3 канала для точки появления искры (1, 1, 1 — белый) и 3 канала для текущей позиции искры (0.47, 0.31, 0.24 — оранжевый). Шейдер сам сделает плавный переход цвета от белого к оранжевому на протяжении линии:


    var colors = [];
    var positionsFromCenter = [];
    for (var i = 0; i < positions.length; i += 3) {
        // для каждой координаты добавляем точку начала координат, чтобы получить след искры
        positionsFromCenter.push(0, 0, 0);
        positionsFromCenter.push(positions[i], positions[i + 1], positions[i + 2]);

        // цвет в начале координат будет белый (горячий), а дальше будет приближаться к оранжевому
        colors.push(1, 1, 1, 0.47, 0.31, 0.24);
    }


В результате мы получим искры со следами:



Полноценная система частиц

Пришло время значительно увеличить количество искр и заставить их двигаться. Алгоритм работы бенгальского огня будет следующий:

  • создаём искру с произвольным направлением, скоростью и длиной пути;
  • при каждой отрисовке увеличиваем положение искры на приращение;
  • когда искра пройдёт весь отрезок пути, запускаем её заново из начала координат.
Создадим класс для искры с двумя функциями - функция init вызывается для создания и инициализации искры, функция move вызывается при каждом цикле отрисовки для приращения координат искры.


function Spark() {
    this.init();
};

// количество искр
Spark.sparksCount = 200;

Spark.prototype.init = function() {
    // время создания искры
    this.timeFromCreation = performance.now();

    // задаём направление полёта искры в градусах, от 0 до 360
    var angle = Math.random() * 360;
    // радиус - это расстояние, которое пролетит искра
    var radius = Math.random();
    // отмеряем точки на окружности - максимальные координаты искры
    this.xMax = Math.cos(angle) * radius;
    this.yMax = Math.sin(angle) * radius;

    // dx и dy - приращение искры за вызов отрисовки, то есть её скорость,
    // у каждой искры своя скорость. multiplier подобран экспериментально
    var multiplier = 125 + Math.random() * 125;
    this.dx = this.xMax / multiplier;
    this.dy = this.yMax / multiplier;

    // Для того, чтобы не все искры начинали движение из начала координат,
    // делаем каждой искре свой отступ, но не более максимальных значений.
    this.x = (this.dx * 1000) % this.xMax;
    this.y = (this.dy * 1000) % this.yMax;
};

Spark.prototype.move = function(time) {
    // находим разницу между вызовами отрисовки, чтобы анимация работала
    // одинаково на компьютерах разной мощности
    var timeShift = time - this.timeFromCreation;
    this.timeFromCreation = time;

    // приращение зависит от времени между отрисовками
    var speed = timeShift;
    this.x += this.dx * speed;
    this.y += this.dy * speed;

    // если искра достигла конечной точки, запускаем её заново из начала координат
    if (Math.abs(this.x) > Math.abs(this.xMax) || Math.abs(this.y) > Math.abs(this.yMax)) {
        this.init();
        return;
    }
};


Вся основная логика уместилась в одном классе. Нам осталось лишь создать необходимое количество искр при инициализации программы


    var sparks = [];
    for (var i = 0; i < Spark.sparksCount; i++) {
        sparks.push(new Spark());
    }


... затем вызвать смещение искр при каждой отрисовке и получить координаты искр для передачи в функции drawTracks и drawSparks


    for (var i = 0; i < sparks.length; i++) {
        sparks[i].move(now);
    }

    var positions = [];
    sparks.forEach(function(item, i, arr) {
        positions.push(item.x);
        positions.push(item.y);
        // искры двигаются только в одной плоскости xy
        positions.push(0);
    });

    drawTracks(positions);
    drawSparks(positions);


В итоге у нас получится полноценная система частиц бенгальского огня:


Ссылки:

Система частиц

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *