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

Код станет намного длиннее. Больше, чем я могу здесь рассказать. Но по большей части это тоже скучно, поэтому я остановлюсь только на основных моментах. Полный код доступен здесь.

Сначала я переместил весь код в многоразовый класс под названием GPUParticleSystem. Он расширяет Object3D, поэтому он имеет позицию и может быть добавлен непосредственно в сцену. Он принимает объект параметров, содержащий значения по умолчанию для таких вещей, как положение, ускорение и цвет. Он обрабатывает геометрию так же, как и раньше, с большим количеством атрибутов буфера. Чтобы код было легче понять, я сначала создаю атрибуты с тремя значениями (на основе Vector3), а затем скаляры. Это выглядит так. Все это происходит в конструкторе.

// geometry
this.geometry = new THREE.BufferGeometry();

//vec3 attributes
this.geometry.addAttribute('position',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('positionStart', new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('velocity',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('acceleration',  new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('color',         new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('endColor',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));

//scalar attributes
this.geometry.addAttribute('startTime',     new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT), 1).setDynamic(true));
this.geometry.addAttribute('size',          new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT), 1).setDynamic(true));
this.geometry.addAttribute('lifeTime',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT), 1).setDynamic(true));


this.particleSystem = new THREE.Points(this.geometry, this.material);
this.particleSystem.frustumCulled = false;
this.add(this.particleSystem);

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

velocity = options.velocity !== undefined ? velocity.copy(options.velocity) : velocity.set(0, 0, 0);
const velocityAttribute = this.geometry.getAttribute('velocity')
velocityAttribute.array[i * 3 + 0] = velocity.x;
velocityAttribute.array[i * 3 + 1] = velocity.y;
velocityAttribute.array[i * 3 + 2] = velocity.z;

Волшебная часть - это то, что происходит дальше. Давайте посмотрим, как частица попадает в графический процессор.

Графический процессор рисует частицы как группу, отрисовывая сразу весь массив частиц. Этот массив определяется набором массивов, каждый из которых определяется атрибутом buffer. Когда мы добавляем одну частицу, нам не нужно обновлять весь массив целиком, а только другой срез. Чтобы справиться с этим, мы вводим новую переменную на стороне JS: Particle_cursor. Это отслеживает, где в массиве мы в последний раз добавляли частицу. Чтобы добавить новую частицу, мы обновляем переменную count. Если мы добавляем четыре частицы за один тик, то счет увеличивается в четыре раза.

Для обновления графического процессора в классе частиц есть метод обновления. Если добавлены какие-либо частицы, он обновит массивы для графического процессора, начиная с Particle_cursor, проходя через счетные пробелы в массиве. По сути, мы используем массивы как гигантский кольцевой буфер.

/*
  This updates the geometry on the shader if at least one particle has been spawned.
  It uses the offset and the count to determine which part of the data needs to actually
  be sent to the GPU. This ensures no more data than necessary is sent.
 */
geometryUpdate () {
    if (this.particleUpdate === true) {
        this.particleUpdate = false;
        UPDATEABLE_ATTRIBUTES.forEach(name => {
            const attr = this.geometry.getAttribute(name)
            if (this.offset + this.count < this.PARTICLE_COUNT) {
                attr.updateRange.offset = this.offset * attr.itemSize
                attr.updateRange.count = this.count * attr.itemSize
            } else {
                attr.updateRange.offset = 0
                attr.updateRange.count = -1
            }
            attr.needsUpdate = true
        })
        this.offset = 0;
        this.count = 0;
    }
}

Каждый раз, когда добавляется частица, курсор перемещается по массиву. Когда он достигает конца, он возвращается к началу. Таким образом, мы можем добавлять бесконечное количество частиц, повторно используя буферные пятна. Общее количество частиц в любой момент времени фиксировано. Наиболее важно то, что фактически обновляется только часть измененных буферов, а не весь буфер.

Итак, теперь мы можем перерабатывать частицы, но у нас все еще есть проблема. Все частицы срабатывают в нулевой момент времени. Чтобы исправить это, нам понадобится атрибут startTime. Это значение устанавливается всякий раз, когда порождается частица. Затем вершинный шейдер может решить, действительно ли данная частица жива. В противном случае он установит размер точки в 0. Если он активен, он придаст ему реальный размер и отправит его во фрагментный шейдер. Если срок его жизни истек, размер снова устанавливается на 0.

float timeElapsed = uTime - startTime;
lifeLeft = 1.0 - ( timeElapsed / lifeTime );
gl_PointSize = ( uScale * size ) * lifeLeft;
if (lifeLeft < 0.0) { 
    lifeLeft = 0.0; 
    gl_PointSize = 0.;
}
//while active use the new position
if( timeElapsed > 0.0 ) {
    gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
} else {
    //if dead use the initial position and set point size to 0
    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    lifeLeft = 0.0;
    gl_PointSize = 0.;
}

Вот и все. Теперь мы можем создать столько частиц, сколько захотим. С помощью этого метода я довольно легко получил более 100000 за один раз. Чтобы дать вам представление о том, что вы можете сделать, я создал демо-версию волшебной палочки.

Наслаждаться. В следующий раз мы рассмотрим интерполяцию цветов для создания эффектов огня.