Эффектная анимация разрушения (Pixel Dust)

в 14:46, , рубрики: animation, canvas, game development, html5, javascript, метки: , , ,

Эффектная анимация разрушения (Pixel Dust) В процессе развития нашей игры на HTML5, мы столкнулись с дилеммой: рисовать для каждого элемента эффект разрушения или попробовать сделать это программно на JavaScript (canvas). Если с первым способом всё понятно (проверенно работает, но много работы художнику), то со вторым у нас были сомнения относительно скорости рендера, ведь это 60FPS x 64 x 4 байта ~ 1 МБ/сек. на один элемент, а если их 40 на одном экране?

Итак, поставлена задача: создать эффект для игры на основе HTML5 (canvas), эффект должен брать на ввод изображение размера от 32х32 до 64x64 пикселей и генерировать последовательность кадров, которая будет проигрываться с частотой 60FPS. Казалось бы можно это закэшировать, чтобы не нагружать процессор, но 60FPS x 64 ширина x 64 высота x 4 байта на пиксель это уже почти мегабайт, и это только на одну секунду на одно входное изображение. Представим, что эффект надо применять к сотне изображений, а длится он побольше секунды — памяти не напасёшься. Остаётся realtime-расчёт, вот о нём и поговорим.

Идея в том чтобы разбить входной растр на мелкие куски размером 2х2 или 4х4 пикселя, и при каждой прорисовке расчитывать их новое положение, располагая в результирующем растре. Компоненты цвета и альфа-канал при этом должны тоже зависеть от времени по какому-нибудь квадратичному закону. В моём случае цвет «кучи» стремится к 0x323232, альфа к 1.0, направление равзвеивания пикселей (лево/право) к прозрачности (альфа 0).

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

Как оказалось, V8 достаточно быстр чтобы одновременно поддерживать 40 таких эффектов для изображений с средним размером 48х48 при полных 60FPS. (посмотреть на это)

Эффектная анимация разрушения (Pixel Dust)

Код эффекта с подробными комментариями

// буферы для изменения цвета и прозрачки
var test = [],
    test2 = []
for (var i = 0; i < 256; i++) {
    test.push(0);
    test2.push(0);
}

// подготовка объекта-функции
Effect = function () {
    this.buffer = document.createElement('canvas');
};

// **важно** все функции должны находиться в прототипе объекта-функции, иначе они будут
// копироваться каждый раз при создании объекта (new Effect())
Effect.prototype.ready = function () {
    return this.progress >= 0.99;
}

// инициализация параметров эффекта 
// w, h - размеры растра к которому его применяют
// part - размер одной квадратной частицы
// dir - куда развевать пепел, -1 налево, 0 по центру, 1 направо
// при этом для лева и права надо иметь канву в два раза большую по горизонтали,
// старый размер сохраняется в this.w2
// возвращает буфер в который надо поместить изображение
  
Effect.prototype.init0 = function (w, h, part, dir) {
    this.buffer.width = dir == 0 ? w : 2 * w;
    this.buffer.height = h;
    this.dir = 0;
    this.sx = 0;
    this.w = w;
    this.w2 = w;
    this.h = h;
    this.dir = dir;
    if (dir == -1) {
        this.sx = w;
        this.w *= 2;
    }
    if (dir == 1) {
        this.w *= 2;
    }
    this.wp = w / part;
    this.hp = h / part;
    this.part = part;
    return this.buffer;
}
// инициализировать партикли по изображению в буфере, поставить это все в координты (x, y)
Effect.prototype.init1 = function (x, y) {
    var context = this.buffer.getContext("2d");
  
    // результат работы эффекта
    var data = context.createImageData(this.w, this.h);
  
    // оригинальное изображение  
    var orig = context.getImageData(0, 0, this.w2, this.h);
  
    // типы частиц: 0 - не обрабатывается, 1 - уже остановилась, 2 - падает в кучу, 3 - летит в сторону
    var parts = [];
  
    // координаты частиц в результирующем изображении, используется для типа 1
    var px = [],
        py = [];
  
    // скорости частиц, инициализируются один раз случайными значениями
    var vx = [],
        speed = [];
  
    // шум нужен чтобы частицы отлетали не совсем рандомно а группами
    var noise = GenerateRandom(this.wp, this.hp);
    var k = 0;
    var part = this.part;
    for (var j = this.hp - 1; j >= 0; j--) {
        for (var i = 0; i < this.wp; i++) {
            var x0 = i * part;
            var y0 = j * part;
            var c = 0;
            for (var dx = 0; dx < part; dx++)
            for (var dy = 0; dy < part; dy++) {
                var t = (x0 + dx) + (y0 + dy) * this.w2;
                if (orig.data[t * 4 + 3] != 0) {
                    c++;
                }
            }
            var r = noise[k++]
            px.push(x0 + this.sx);
            py.push(y0);
          
            // только непрозрачные куски будут обрабатываться
            if (c * 2 >= part * part) {
                speed.push(1.2 * r + 0.75);
                if (r > 0.5) {
                    parts.push(3);
                } else {
                    parts.push(2);
                }
            } else {
                speed.push(0);
                parts.push(0);
            }
        }
    }
  
    // уровень пепла на дне
    this.level = [];
    for (var i = 0; i < this.w; i++)
    this.level.push(this.h);
    this.parts = parts;
    this.vx = vx;
    this.speed = speed;
    this.px = px;
    this.py = py;
    this.context = context;
    this.data = data;
    this.orig = orig;
    this.progress = 0.0;
    this.x = x;
    this.y = y;
    return this;
}

// инициализировать по изображению из img, с определёнными параметрами
Effect.prototype.init01 = function (x, y, w, h, part, dir, img, imgX, imgY, imgW, imgH) {
    this.init0(w, h, part, dir);
    var context = this.buffer.getContext("2d");
    context.drawImage(img, imgX, imgY, imgW, imgH, 0, 0, w, h);
    this.init1(x, y);
    return this;
}

// при прорисовке нельзя забывать сдвигать или увеличивать изображение в случае развевания налево или направо
Effect.prototype.draw = function (context, x, y, w, h) {
    if (w === undefined) {
        w = this.w2;
        h = this.h;
    }
    if (this.dir == -1) {
        x -= w;
        w *= 2;
    }
    if (this.dir == 1) {
        w *= 2;
    }
    context.drawImage(this.buffer, x, y, w, h);
}

// вот тут вся магия, progress - это какая часть анимации только что прошла, this.progress от 0.0 до 1.0
Effect.prototype.update = function (progress) {
    this.progress += progress;
    var c = 100;
    var data = this.data.data;
    var orig = this.orig.data;
    var wp = this.wp;
    var hp = this.hp;
    var part = this.part;
    var h = this.h;
    var w = this.w;
    var k = 0;
    var w2 = this.w2;

    // test - как эволюционируют цвета, test2 - как эволюционирует прозрачка
    var p = this.progress;
    var p2 = Math.min(p * p, 1.0);
    for (var i = 0; i < 256; i++) {
        var j = i + (50 - i) * p2 | 0;
        if (j > 255) j = 255;
        if (j < 0) j = 0;
        test[i] = j;
        j = i + (255 - i) * p2 | 0;
        if (p2 > 0.7) j = 255 * (1.0 - p2) / 0.3 | 0
        test2[i] = j;
    }
  
    // делаем всё прозрачным
    for (var i = 3; i < w * h * 4; i += 4)
    data[i] = 0;
    for (var j = hp - 1; j >= 0; j--)
    for (var i = 0; i < wp; i++, k++) if (this.parts[k] != 0) {
      
        // обрабатываем частицу
        // личный прогресс частицы, зависит от её скорости и текущего момента
        var p = this.progress * this.speed[k];
      
        // координаты частицы в оригинальном изображении
        var x0 = i * part;
        var y0 = j * part;
        var x = this.px[k],
            y = this.py[k];
        var a = 1.0;
      
        // до момента 0.2 все стоят на своих местах
        if (p > 0.2) {
            p = (p - 0.2) / 0.8;
          
            // позиция по x, скорость берется как остаток случайной скорости при делении на 0.1,
            // здесь же учитывается в какую сторону всё это развеваем
            var px = p * this.dir + this.progress * (this.speed[k] * 10 % 0.1);
            if (this.parts[k] == 2) {
              
                // частица падает в кучу
                x = x0 + this.sx + px * w / 2 | 0;
                y = y0 + p * p * this.h | 0;
            } else if (this.parts[k] == 3) {
              
                // частица летит куда-то, прозрачка зависит от того как далеко она улетела
                x = x0 + this.sx + px * w | 0;
                y = y0 + p * w / 4 | 0;
                if (this.dir == -1) {
                    a = Math.min(1.0, x / w2);
                } else if (this.dir == 0) {
                    a = Math.min(1.0, 1.0 - y / h);
                    y = y + p * w / 2 | 0;
                } else if (this.dir == 1) {
                    a = Math.min(1.0, 2.0 - x / w2);
                }
              
                // улетела вниз - удаляем
                if (y + part > h) this.parts[k] = 0;
            }
          
            // улетела в сторону - удаляем
            if (x < 0 || x + part > w) this.parts[k] = 0;
        }
        if (this.parts[k] == 0) continue;
        var min = 0;
      
        // кидаем частицу в кучу, учитывая уровень и модифицируя его
        if (this.parts[k] == 2) {
            var max = this.level[x]
            var num = x;
          
            // вычисляем уровень на который она падает
            for (var x1 = x + 1; x1 < x + part; x1++)
            if (this.level[x1] > max) {
                num = x1;
                max = this.level[x1];
            }
          
            // проверяем что упала
            if (y + part > max) {
                y = max - part;
                x = num;
                this.level[num]--;
                this.parts[k] = 1;
            }
        }
        this.px[k] = x;
        this.py[k] = y;

        // если надо развевать в сторону и частица уже полетела, то альфу надо менять в зависимости от того насколько она улетела
        if (this.parts[k] == 3 && p > 0.2) {
            for (var dy = 0; dy < part; dy++)
            for (var dx = 0; dx < part; dx++) {
                var s = (x + dx) + (y + dy) * w;
                var t = (x0 + dx) + (y0 + dy) * w2;
                s *= 4;
                t *= 4;
                data[s] = test[orig[t]];
                data[s + 1] = test[orig[t + 1]];
                data[s + 2] = test[orig[t + 2]];
                data[s + 3] = a * orig[t + 3] | 0;
            }
        } else {
          
            // иначе меняем альфу как обычно
            for (var dy = 0; dy < part; dy++)
            for (var dx = 0; dx < part; dx++) {
                var s = (x + dx) + (y + dy) * w;
                var t = (x0 + dx) + (y0 + dy) * w2;
                s *= 4;
                t *= 4;
                data[s] = test[orig[t]];
                data[s + 1] = test[orig[t + 1]];
                data[s + 2] = test[orig[t + 2]];
                data[s + 3] = test2[orig[t + 3]];
            }
        }
    }
  
    // отправляем в буфер то что получилось
    this.context.putImageData(this.data, 0, 0);
}

Исходник с комментариями можно скачать здесь.

Автор: Jedi_Knight

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js