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

Современные игры не были бы так хороши без системы частиц.

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

Хорошая система частиц потребляет мало памяти, быстро работает и имеет многочисленные настройки.

В OpenGL обычно создаётся вершинный буфер, который содержит все частицы одного типа и отрисовывается такой буфер за один проход.

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

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

Сначала демонстрация в цветовой гамме MS-DOS (поводите мышкой по черной области), затем пояснения и исходники на JavaScript:



Частица представлена у нас в виде javascript объекта:

var Particle = function() {
    // Позиция частицы
    this.x = 0;
    this.y = 0;

    // Скорость движения частицы по x, y
    this.vx = 0;
    this.vy = 0;

    // Ускорение частицы по x, y
    this.ax = 0;
    this.ay = 0;

    // Прозрачность частицы
    this.alpha = 0;
    // Изменение прозрачности в секунду
    this.vAlpha = 0;

    // Размер частицы
    this.size = 0;

    // Флаг, что частица активна
    this.active = false;
};

Инициализация массива частиц:

    // Инициализируем массив частиц
    particles = new Array(numParticles);

    for (var i = 0; i < numParticles; ++i) {
        // Каждая частица изначально выключена
        particles[i] = new Particle();
    }

Чтобы частицы создавались непрерывно, введем переменную pps, она будет означать количество новых частиц в секунду. Для вычисления необходимого количества частиц, введем переменную realTime типа float. Она будет накапливать количество секунд для создания хотя бы одной частицы.

Формула для вычисления количества частиц:

newParticleCount = Math.floor(pps * realTime)

После вычисления количества, необходимо уменьшить таймер:

realTime -= newParticleCount / pps

Общий код создания частиц:

var newParticleCount = Math.floor(pps * realTime)

if (newParticleCount > 0) {
    realTime -= newParticleCount / pps;

    for (var i = 0; i < particles.length; ++i) {
        if (newParticleCount <= 0) {
            break;
        }

        var particle = particles[i];

        if (!particle.active) {
            add(particle);
            newParticleCount--;
        }
    }
}

Код добавления частицы просто инициализирует частицу и делает ее активной

function add(particle) {
    particle.x = someX;
    particle.y = someY;

    particle.vx = someVX;
    particle.vy = someVY;

    particle.ax = someAX;
    particle.ay = someAY;

    particle.alpha = someAlpha;
    particle.vAlpha = someVAlpha;

    particle.active = true;
}

Для придания реалистичности движения частицы используется ускорение и гравитация:

particle.vx += particle.ax * dt;
particle.vy += particle.ay * dt;

particle.vy -= gravity * dt;

Если частица полностью прозрачна или выходит за пределы экрана (слева, справа или снизу), то частица отключается и выпадает из обработки:

if (particle.alpha < 0 || 
    particle.x < -1 || particle.x > 1 || 
    particle.y < -1) {

    particle.active = false;
}

После обновления позиции и прозрачности частицы, заполняем вершинный буфер, в котором на каждую частицу приходится два треугольника, шесть вершин и по три значения на вершину (x, y, alpha):

// Слева снизу
vertices[index++] = left;
vertices[index++] = bottom;
vertices[index++] = alpha;

// Справа снизу
vertices[index++] = right;
vertices[index++] = bottom;
vertices[index++] = alpha;

// Слева сверху
vertices[index++] = left;
vertices[index++] = top;
vertices[index++] = alpha;

// Справа снизу
vertices[index++] = right;
vertices[index++] = bottom;
vertices[index++] = alpha;

// Справа сверху
vertices[index++] = right;
vertices[index++] = top;
vertices[index++] = alpha;

// Слева сверху
vertices[index++] = left;
vertices[index++] = top;
vertices[index++] = alpha

Дальше дело техники: отрисовать вершинный буфер с правильный шейдером и режимом смешивания.

Полные исходники на javascript
Файл ps.js

"use strict";

var vsSource = [
    "attribute vec2 a_position;",
    "attribute float a_alpha;",
    "",
    "varying float v_alpha;",
    "",
    "void main() {",
    "    gl_Position = vec4(a_position, 1.0, 1.0);",
    "    v_alpha = a_alpha;",
    "}"
].join("\n");


var fsSource = [
    "precision highp float;",
    "",
    "varying float v_alpha;",
    "",
    "void main() {",
    "    gl_FragColor = vec4(0.0, 0.905, 0.0, v_alpha);",
    "}"
].join("\n");


var Particle = function() {
    // Позиция частицы
    this.x_ = 0;
    this.y_ = 0;

    // Скорость движения частицы по x, y
    this.vx_ = 0;
    this.vy_ = 0;

    // Ускорение частицы по x, y
    this.ax_ = 0;
    this.ay_ = 0;

    // Прозрачность частицы
    this.alpha_ = 0;
    // Изменение прозрачности в секунду
    this.vAlpha_ = 0;

    // Размер частицы
    this.size_ = 0;

    // Флаг, что частица активна
    this.active_ = false;
};


Particle.prototype = {
    constructor : Particle
}


var ParticleManager = function(numParticles, pps) {
    // Всего float на частицу
    this.FLOATS_PER_PARTICLE = 18;

    // Количество новых частиц в секунду
    this.pps_ = pps;

    // Инициализируем массив частиц
    this.particles_ = new Array(numParticles);

    for (var i = 0; i < numParticles; ++i) {
        this.particles_[i] = new Particle();
    }

    // Позиция эмиттера 
    this.emitterX_ = 0;
    this.emitterY_ = 0;

    // Начальная скорость частицы
    this.velInit_ = 0.5;
    // Разброс (+-) начальной скорости частицы
    this.velDisp_ = 0.5;

    // Начальное ускорение частицы
    this.accInit_ = 0.0;
    // Разброс (+-) начального ускорения частицы
    this.accDisp_ = 0.025;

    // Начальный размер частицы
    this.sizeInit_ = 0.005;
    // Разброс (+-) начального размера частицы
    this.sizeDisp_ = 0.005;

    // Сила гравитации, направлена вниз
    this.gravity_ = 2.0;

    // Массив вершин
    this.vertices_ = new Float32Array(numParticles * this.FLOATS_PER_PARTICLE);
    // Количество активных частиц
    this.numActiveParticles_ = 0;

    // Вершинный буфер
    this.vbo_ = null;

    // Шейдер, позиция в шейдере и прозрачность
    this.shader_ = null;
    this.positionId_ = -1;
    this.alphaId_ = -1;

    // Время для вычисления количества новых частиц
    this.realTime_ = 0;
}


ParticleManager.prototype = {
    constructor : ParticleManager,


    // Активирование частицы
    add : function(particle) {
        if (particle.active_) {
            return;
        }

        // Начальная позиция частицы совпадает с позицием эмиттера
        particle.x_ = this.emitterX_;
        particle.y_ = this.emitterY_;
        
        // Вычисляем начальное ускорение частицы
        particle.ax_ = this.accInit_ + (Math.random() - 0.5) * this.accDisp_;
        particle.ay_ = this.accInit_ + (Math.random() - 0.5) * this.accDisp_;

        // Направление движения частицы
        var angle = Math.random() * Math.PI * 2.0;
        var cosA = Math.cos(angle);
        var sinA = Math.sin(angle)

        // Скорость движения
        var vel = (Math.random() - 0.5) * this.velDisp_;

        // Скорость и направление движения частицы
        particle.vx_ = (this.velInit_ + vel) * cosA;
        particle.vy_ = (this.velInit_ + vel) * sinA;

        // Размер частицы
        particle.size_ = this.sizeInit_ + (Math.random() - 0.5) * this.sizeDisp_;

        // Начальная прозрачность
        particle.alpha_ = 1.0;
        // Уменьшение прозрачности в секунду
        particle.vAlpha_ = 0.25 + Math.random();

        // Активируем частицу
        particle.active_ = true;
    },


    update : function(dt) {
        this.realTime_ += dt;

        // Вычисляем количество новых частиц
        var newParticleCount = Math.floor(this.pps_ * this.realTime_);

        if (newParticleCount > 0) {
            this.realTime_ -= newParticleCount / this.pps_;

            for (var i = 0, count = this.particles_.length; i < count; ++i) {
                if (newParticleCount <= 0) {
                    break;
                }

                var particle = this.particles_[i];

                if (!particle.active_) {
                    this.add(particle);
                    newParticleCount--;
                }
            }
        }

        var numActiveParticles = 0;
        var vertices = this.vertices_;

        for (var i = 0, count = this.particles_.length; i < count; ++i) {
            var particle = this.particles_[i];

            if (!particle.active_) {
                continue;
            }

            // Обновление скорости частицы
            particle.vx_ += particle.ax_ * dt;
            particle.vy_ += particle.ay_ * dt;

            // Обновление позиции частицы
            particle.x_ += particle.vx_ * dt;
            particle.y_ += particle.vy_ * dt;

            // Применение гравитации
            particle.vy_ -= this.gravity_ * dt;

            // Изменение прозрачности
            particle.alpha_ -= particle.vAlpha_ * dt;

            // Деактивация невидимой частицы
            if (particle.alpha_ < 0) {
                particle.active_ = false;
                continue;
            }

            // Выключаем частицы за пределами экрана
            if (particle.x_ < -1.0 || particle.x_ > 1.0) {
                particle.active_ = false;
                continue;
            }

            // Выключаем частицы за пределами экрана
            if (particle.y_ < -1.0) {
                particle.active_ = false;
                continue;
            }

            var l = particle.x_ - particle.size_;
            var t = particle.y_ + particle.size_;
            var r = particle.x_ + particle.size_;
            var b = particle.y_ - particle.size_;
            var a = particle.alpha_;

            var index = numActiveParticles * this.FLOATS_PER_PARTICLE;

            vertices[index++] = l;
            vertices[index++] = b;
            vertices[index++] = a;

            vertices[index++] = r;
            vertices[index++] = b;
            vertices[index++] = a;

            vertices[index++] = l;
            vertices[index++] = t;
            vertices[index++] = a;

            vertices[index++] = r;
            vertices[index++] = b;
            vertices[index++] = a;

            vertices[index++] = r;
            vertices[index++] = t;
            vertices[index++] = a;

            vertices[index++] = l;
            vertices[index++] = t;
            vertices[index++] = a;

            numActiveParticles++;
        }

        this.numActiveParticles_ = numActiveParticles;
    },


    render : function(gl) {
        if (0 == this.numActiveParticles_) {
            return;
        }

        if (!this.shader_) {
            this.initShader(gl);
        }

        if (!this.vbo_) {
            this.vbo_ = gl.createBuffer();
        }

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

        gl.useProgram(this.shader_);
        gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo_);
        gl.bufferData(gl.ARRAY_BUFFER, this.vertices_, gl.DYNAMIC_DRAW);

        gl.vertexAttribPointer(
            this.positionId_, 
            2, 
            gl.FLOAT, 
            false, 
            3 * Float32Array.BYTES_PER_ELEMENT, 
            0);
            
        gl.enableVertexAttribArray(this.positionId_);

        gl.vertexAttribPointer(
            this.alphaId_, 
            1, 
            gl.FLOAT, 
            false, 
            3 * Float32Array.BYTES_PER_ELEMENT, 
            2 * Float32Array.BYTES_PER_ELEMENT);

        gl.enableVertexAttribArray(this.alphaId_);

        gl.drawArrays(gl.TRIANGLES, 0, this.numActiveParticles_ * 6);

        gl.disableVertexAttribArray(this.alphaId_);
        gl.disableVertexAttribArray(this.positionId_);
        gl.bindBuffer(gl.ARRAY_BUFFER, null);
        gl.useProgram(null);
    },


    initShader : function(gl) {  
        var vs = gl.createShader(gl.VERTEX_SHADER);
        gl.shaderSource(vs, vsSource);
        gl.compileShader(vs);

        var fs = gl.createShader(gl.FRAGMENT_SHADER);
        gl.shaderSource(fs, fsSource);
        gl.compileShader(fs);

        var shader = gl.createProgram();
        gl.attachShader(shader, vs);
        gl.attachShader(shader, fs);
        gl.linkProgram(shader);

        this.positionId_ = gl.getAttribLocation(shader, "a_position");
        this.alphaId_ = gl.getAttribLocation(shader, "a_alpha");
        this.shader_ = shader;
    },

    setPosition : function(x, y) {
        this.emitterX_ = x;
        this.emitterY_ = y;
    }
}

Файл main.js

"use strict";

window.requestAnimFrame = (function() {
    return window.requestAnimationFrame ||
        window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame ||
        window.oRequestAnimationFrame ||
        window.msRequestAnimationFrame ||
        function(callback, element) {
            window.setTimeout(callback, 1000.0/30.0);
        };
})();


var gl = null;
var lastTime = Date.now();
var particleManager = null;


function update(dt) {
    particleManager.update(dt);
}


function drawFrame() {
    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    particleManager.render(gl);
}


function render() {
    window.requestAnimFrame(render);

    var time = Date.now();
    var dt = (time - lastTime) * 0.001;
    lastTime = time;

    update(dt);

    drawFrame();
}


function startRender() {
    var canvas = document.getElementById("webgl-canvas");
    gl = canvas.getContext("experimental-webgl");
    gl.viewport(0, 0, canvas.width, canvas.height);
     
    // Всего тысяча частиц и их рождается 100 штук в секунду
    particleManager = new ParticleManager(1000, 100);

    canvas.onmousemove = function(event) {
        var bbox = canvas.getBoundingClientRect();

        var x = (event.clientX - bbox.left) * (canvas.width / bbox.width);
        var y = (event.clientY - bbox.top) * (canvas.height / bbox.height);

        var u = x / (canvas.width) * 2 - 1.0;
        var v = 1.0 - 2 * y / canvas.height;

        particleManager.setPosition(u, v);
    }

    render();
}

Вы молодец, если дочитали до конца!

Поэтому я открою еще один способ создания частиц. Этот способ самый быстрый, но имеет ряд ограничений.
Основная идея заключается в том, что все данные частицы живут в статическом вершинном буфере и каждый аттрибут вершины зависит от переменной t, которая принимает значения от нуля до единицы. Ваша задача подобрать формулу зависимости аттрибута от t и при рисовании обновлять только t, все остальные вычисления производятся в шейдере.

Комментариев нет. Будьте первым!
Добавить комментарий

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

Блог Евгения Жирнова