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

  • Tutorial
В процессе развития нашей игры на 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. (посмотреть на это)



Код эффекта с подробными комментариями
// буферы для изменения цвета и прозрачки
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);
}


Исходник с комментариями можно скачать здесь.
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 55
  • –10
    С точки зрения конечного пользователя я бы категорически не захотел бы, чтобы все объекты в игре разрушались таким образом. Лучше бы напрячь художника, чем такое на выходе. Может где-то и подойдет, но уж точно не везде, выглядит очень искусственно.
    • +22
      Если графика пиксельная (как майнкрафт), то по-моему как раз это самое то.
      • –11
        Майнкрафт-то 3D, там это может быть оправдано. А в 2D смотрится отвратно.

        P.S.
        Сама игра ничего.
    • 0
      Так можно добавить ещё пачку таких эффектов, с разными параметрами, и вешать разные эффекты на разные объекты/скины.
      Просто дело в том что шкурок подходящих для игры в интернете много, но анимации смерти у них отсутствуют.
      Мы ориентируемся на большое количество user-generated контента.
      • +3
        А, ну тогда да. Но можно обязать любителей генерить контент дорисовывать несколько кадров смерти, раз уж они рисуют себе кастомного персонажа)
        • +3
          Они там такое г. дорисовывают :) Но я думаю что можно и анимацию и какой-нибудь эффект использовать одновременно, например сейчас Кенни и распадается на пиксели и улетает в небо.
          • +2
            Да это же гениальная идея!
      • 0
        Уточните совместимость, под МЕ 9 не завелось.
        V8 это Хром, работает только под ним?
        • 0
          МЕ9 должно было быть ИЕ9
          • 0
            Под оперой, FF, safari и хромом должно работать.
            В ie9 похоже stats.js не пашет, сейчас попробую это исправить.

            Ну и сама игра в ie9 не пашет из-за вебсокетов.
            • 0
              в IE10 работает на 60 fps, видимо из-за поддержки вебсокетов
              • 0
                Игра да, а демке анимации вебсокеты не нужны.
                • +1
                  понял в чем проблема, у вас нет объявления doctype в формате html5 на странице, IE сразу переходит в режим совместимости, добавьте стандартный doctype и все заработает и там
                  • +1
                    Поправил.
                    • 0
                      Ухты, "!doctype html5" вместо "!doctype html".
                      Спасибо что заметил :)
                      • 0
                        не понял, по стандарту нужно именно:

                        <!doctype html>
                        • 0
                          А у нас было html5 :)
                          • +1
                            а, я неправильно понял комментарий. Впрочем это, наверное было в игре. Я же говорил про тестовую страницу с анимацией, в ней было просто:

                            <html>
              • 0
                В ие9 под вп7 (dell venue pro) работает, но тормозить перестает только с 10 юнитами на экране.
              • –2
                Не вижу смысла грузить клиентский CPU (тратить батарейку моб. устройств) расчетами на взрывы. Это совсем неоправданно в данном случае.
                • 0
                  Дело в том что этот эффект не является узким местом этой игры.
                  Про мобильные — согласен, под них будут обязательно будут нативные клиенты.
                  • +3
                    Игра — затягивает. Разработка — классная. Код, расчеты, все дела…
                    НО!
                    Не надо стараться «засунуть» максимум из последних технологий в проект и тем самым «отмести» сразу бОльшую часть пользователей: слабый CPU на устройстве, браузер не обновили до последней версии, нет каких-то плагинов и пр.
                    Не грузите устройство пользователя неоправданными расчетами, производимыми «на лету».
                    Одно дело — однократное преобразование и кэширование. Но, когда это постоянно?..
                    Я привык так: задействуй меньше — останется больше. (ресурсов)
                    • 0
                      Рассыпание на пиксели — последняя технология? Лолшто?
                • +1
                  Было бы намного эффектнее, если бы точки разлетались равномерно в разные стороны (вероятность вектора начальной скорости равномерна по верхней полуокружности). Можно, впрочем, добавить некоторую часть от текущей скорости юнита, чтобы был эффект продолжающегося движения.
                  • +1
                    А можно было совместить и сделать прегенерацию анимаций с шагов в 45 градусов направлений помирания.
                    • 0
                      Вот я боюсь не потянет оно по памяти.
                      А градусы мы сможем итак любые сделать, если эффект переделаем.
                    • +1
                      А исходник GenerateRandom где можно посмотреть?
                      • 0
                        drawer.js, perlin.js, effect.js, но там без комментов.
                        • 0
                          Там самый обычный фильтр Перлина, возвращает двумерный массив с числами в диапазоне от 0.0 до 1.0
                        • +1
                          Так понимаю у вас в демке стоит ограничение сверху на количество юнитов, т.к. не обнаружил разницу между 100 и 100000 units.
                          • 0
                            Да, там есть следующее ограничение: за один кадр может создаться только один юнит.
                            Поправил, теперь там можно создать 5. То есть она сейчас не может создавать юнитов больше чем 5*FPS, надо бы вывести счётчик текущего числа юнитов…
                          • +4
                            А не проще было этим скриптом один раз «скомпилировать» картинки и оставить на сервере, и не сжигать драгоценные герцы?
                            • +1
                              Можно было бы задизайнить в адобе несколько разных типов разрушений (чтоб еще юзеру дать выбирать) в массив кадров для канваса или аним. gif.
                              • 0
                                Во-первых при изменении любого параметра скина придётся всё это пересобирать, во-вторых скин в 16 кадров, а анимация смерти основанная на одном из кадров будет минимум в 60, а их ещё надо минимум 4 штуки (по числу направлений ходьбы), это всё серьёзное увеличение размера ресурсов.
                                Конечно данный подход тоже имеет право на жизнь, но совсем при других требованиях, не при 60 кадрах в секунду.
                                • +1
                                  Можно одну анимацию сделать: быстро разрывающийся в стороны (по-окружности) и медленно сходящийся в центр (в кучку).
                                  В момент смерти убираем персонаж и проигрываем анимацию взрыва, одновременно смещая в направлении взрыва.
                                  Создастся эффект направленного взрыва и сноса частей персонажа в направлении взрыва (противоположном от бомбы).
                                  • 0
                                    Да, вот так можно :) Правда с количеством накладываемых изображений там тоже нельзя перебарщивать, drawImage тоже дорогое удовольствие.
                                    • +1
                                      Сделайте так. Экономия CPU будет существенная.
                                      • 0
                                        Ок, сделаем что-то такое для эффектов которые будут более часто использоваться, например для разноса блоков.
                                        Эффект смерти проигрывается редко, и по сравнению с остальной прорисовкой его стоимость очень мала, это не bottleneck :)
                                  • +1
                                    А почему бы не сделать анимацию взрыва не из 60 кадров, а из 10 или 5? У вас ведь анимация ходьбы всего из 2-х кадров.
                                    • 0
                                      Кадры ходьбы зациклены и меняются часто, а 60FPS там заметно потому что игрок при этом движется.
                                      Да, если будет тормозить то наверное поставлю 5-10 кадров. Но я думаю что не будет :)
                                      Ну и понятно, рисовать это вручную для каждого блока когда этих блоков будет много мне не хочется.
                                • +9
                                  Визуальный аспект.

                                  Эффект рассеивания в увеличенном виде смотрится нормально (хоть и не реалистично). Но, вот, в пылу сражения, особенно когда задевает краем взрыва, он очень незаметен. Даже не сразу доходит, что ты умер, или что кого-то убил. Практически с таким же успехом можно было бы убирать персонажей с экрана без лишних слов. С таким эффектом лишь чуть-чуть лучше, чем без эффекта вообще.
                                  Так, здесь только что был чувак! Куда он делся? Ух ты, у меня, оказывается +1 фраг, клево. А, точно, это я его убил (по логам).

                                  В классической dyna blasters для каждого скина был свой уникальный анимированный эффект, который начинался с изображения удивления (как?! меня убили??!) и далее персонаж/моб постепенно уходил в небытие.

                                  Вся мультипликация эффекта разрушения чисто для красоты. А вот первый кадр довольно важен в геймплее тем, что заметен. Боковым зрением видно, кого убил и сколько всего жертв, или что тебя внезапно убили. Ведь у каждого игрока «бомбермена» в течение всей игры тикают внутренние часы. И если несколько событий происходят одновременно, то они железно связываются причинно-следственной связью. Но для этого, — события должны выделяться, быть заметными, чтобы десятки событий на экране можно было охватить боковым зрением.

                                  По-моему, если так уж важен именно программный эффект, то лучше подошла бы последняя поза персонажа в другом цвете (в красном, например) с постепенным фейдом исчезновения. Таким образом, тихо и незаметно скончаться будет сложно. Сразу после смерти персонаж как бы будет кричать «я здесь! я только что умер! позор мне!». А так как цвет смерти для всех будет одинаково яркий и броский, то уже получается стандарт, выучив который, глаз будет легко воспринимать интуитивно. Ведь самое важное — это фраги, не так ли?

                                  Хотя, может я и не прав.
                                  • 0
                                    Конечно ты прав, надо будет сделать заметнее.
                                    Эффект исчезновения я тоже делал, но вот хотелось именно развевать пепел. Будут ресурсы — сделаем ещё эффектов и будет позаметней.
                                    • 0
                                      Достаточно сделать, чтобы персонаж развевался не моментально, а как бы, завис на время, а потом уже развеялся.
                                      Застрявший персонаж уже привлекает внимание. А если на время зависания добавить какой-нибудь эффект, как выше предложили покраснение, или мигание какое-нибудь — то будет и заметно и красиво.
                                  • 0
                                    А если точки группировать по цвету (плюс-минус яркость), то получатся цветовые области, с которыми и делать эффекты по отдельности?
                                    • +1
                                      Игруха супер. Но, на нетбуках (atom 450) тормозит заметно + как-то странно себя чувствует в разрешении 1024x600. На полный экран не удалось развернуть. Но это так, к слову. Вообще молодцы, на десктопе с удовольствием поиграл.
                                      • +1
                                        Спасибо, думаем сделать ещё PC и мобильные версии когда всё устаканится. HTML5 легко поддерживать, на начальном этапе даёт преимущество.
                                        • +1
                                          Ну, нетбук это не совсем мобильник. Я просто что хотел сказать — хрен с ним, с тормозами, возможно это опера так к меня, а если хром — то нормально. Беда в разрешении. Позвольте пользователю где-то прямо в игре развернуть на полный экран. Вот и все.
                                          • 0
                                            Так давно же есть фулскрин-режим — кнопкой P включается.
                                            • +1
                                              А, ну значит я просто косоглазый. Тогда все в ажуре, еще раз респект авторам, попробую завтра на работе. А про мобильные версии — если не забудете Windows mobile и symbian — вообще будет классно. Но это уже чисто из области фантастики конечно )) Но в любом случае, классная игра!

                                              PS: А в случае Win mobile мог бы и поспособствовать, just for fun, ради саморазвития…
                                      • 0
                                        Автору следовало сделать взрыв анимацией, которую не нарисует художник, а сгенерит скрипт — сколь угодно сложный, тормозной, зато красивый. Возможно даже несколько вариантов взрывов. И проблема тормозов решится и художник не нужен. Все счастливы.
                                        • 0
                                          ВК заблочен, а я хочу играть!
                                          scrobly.com/HblFJmIAOgPTfxk4H71p
                                          • 0
                                            поняшек-то за что?
                                            • 0
                                              150 юнитов = 40-50 фпс
                                              Chrome 24
                                              • 0
                                                Сейчас ещё есть эффект для блоков, там сейчас 80 штук, при этом работает graceful degradation: она пересчитывает эффекты пока есть время. В IE10 можно наблюдать 60фпс, при том что сама анимация подтормаживает.

                                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.