Pull to refresh

JPG, прозрачность, Canvas, анимация

Reading time8 min
Views33K
Здравствуйте, друзья!

Предлагаю вам небольшой урок на тему анимации спрайтов с альфаканалом на канве HTML5.

Преамбула.


Для начала нарисуем нечто
image

Почему круглое и желтое? Потому что у Дугласа Адамса в «Автостопом по галактике» есть такой Слартибартфаст — очень трогательный дядя, имеет приз за береговые линии при строительстве Земли. Поэтому на всякий случай будем анимировать желтую звезду.

Далее анимируем звезду в последовательность из 256 кадров с размером кадра 256х256 пикселей.
Те 256, эти 256 и вон те 256 взяты только ради примера. Анимация может быть и короче и мельче, впрочем как и длиннее и крупнее — все зависит от ваших целей. Я исхожу из того что 256х256 пикселей нынче это такой вполне себе нормальный размер для спрайта: не слишком большой и не слишком маленький. Конечно раньше, когда трава была зеленее, и спрайты были скромнее 16х16 или 32х32, но мы будем как будто бы на вырост экспериментировать.
256 кадров это тоже не так чтобы много но и не так чтобы мало: в зависимости от частоты кадров можно получить 10-20-30 секундную последовательность.

До сих пор все без неожиданностей, пока не перемножить размеры сторон кадра, длительность и глубину:

256 х 256 х 256 х 4 = 64 МБ

Однако! 64 мегабайта без сжатия это немного жирновато для спрайта. Если выполнить просчет всех 256 кадров в матрицу 16х16 кадров и сохранить эту рыбу в PNG с альфа-каналом (все происходит в Adobe After Effects) то на выходе получим 18.6 МБ что хоть и не 64 но все-же немало. Для тех, кому интересно взглянуть на этот PNG вот ссылочка.

Сжать!


Неужели After Effect так небрежно относится к сжатию PNG? Чтобы привести полученную сетку к вменяемому размеру, я попытался оптимизировать отрендеренный PNG с помощью утилит OptiPNG и PNGOut. Обе утилиты запускались в с различными опциями вплоть до экстремальных, но в результате в самом лучшем случае сжатие оптимизировалось на феерические 1.5 процента, что явно не соответсвует ожиданиям. Поэтому эта анимация была пересчитана в пару файлов без потери качества (тот же самый PNG) но раздельно RGB и Alpha, и далее с помощью Image Optimizer они были конвертированы в пару JPEG-ов. Чтобы облегчить работу компрессору JPG (это правильное слово?), фон на той последовательности что содержит RGB-слои перед рендерингом принудительно заливаем космически-желтым цветом вместо космически-черного.

В зависимости от степени сжатия получаем разные по весу пары JPEG-ов:
Сжатие 10% 15% 25% 50% 75% 85% 90%
RGB 139KB 198KB 250KB 425KB 691KB 992KB 1337KB
Alpha 363KB 459KB 524KB 689KB 856KB 1067KB 1273KB

Вот эти размеры уже ближе к желаемому. Для меня оказалось полной неожиданностью что файл с прозрачностью оказывается во всех случаях тяжелее чем файл с цветами, вероятно все дело в контрасте. Визуально-же, все что сжато сильнее 50% похоже на кирпичи а не на косомс. Друзья, если среди вас есть кто-то кто сможет сделать хорошо сжатый и одновременно хорошо различимый JPEG — поделитесь пожалуйста своим опытом, я с удовольствием обновлю статью. А пока дальнейшие эксперименты будем проводить с парой файлов сжатых до 75% которые тянут в сумме на 1547KB что ощутимо лучше (легче) чем 18.6MB для PNG и уж тем более 64MB для несжатого формата.

Переслать!


Теперь эти раздельные JPEG-и нам будет отдавать сервер а на клиенте мы их будем принимать и склеивать в одно общее изображение с прозрачностью.

Разметка-же будет самая что ни наесть пустая:
Разметка
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Canvas Example: Using canvas</title>
    <link rel="stylesheet" href="css/main.css" type="text/css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js" type="text/javascript"></script>
    <script src="js/main.js" type="text/javascript"></script>
    <script type="text/javascript">
      $(document).ready(function(){
        loadRGBA("jpeg_16x16/render_16_16_rgb_75.jpg", "jpeg_16x16/render_16_16_aplha_75.jpg");
      });
    </script>
  </head>
  <body>
    <div id="star1" class="base64"></div>
    <div id="star2" class="base64"></div>
  </body>
</html>


Как впрочем и стили:
Стили
* {
  margin: 0;
  padding: 0;
}

html {
  background: #888 url(../backs/back64dark.png);
  font-size: 14px;
  font-family: 'Helvetica', Helvetica, sans-serif;
}

h1 {
  font-size: 1.5em;
  text-align: center;
  margin: 1em 0;
  color: #ddd;
}

#base64{
  background: transparent;
  color: #bbb;
  width: 256px;
  height: 265px;
  margin: 0 auto;
}


В блоке скрипта по окончании формирования документа вызываем функцию загрузки изображений.
Она очень простая (лежит в файле js/main.js):
function loadRGBA()
function loadRGBA(url_rgb, url_alpha){

  var img_rgb = new Image();
  var img_alpha = new Image();
  var img_count = 0;
  var img_rgba = '';

  img_rgb.src = url_rgb;
  img_alpha.src = url_alpha;

  img_rgb.onload = function(){
    ++img_count;
    if(2 == img_count){
        img_rgba = compileRGBA(img_rgb, img_alpha);
    }
  }

  img_alpha.onload = function(){
    ++img_count;
    if(2 == img_count){
        img_rgba = compileRGBA(img_rgb, img_alpha);
    }
  }

}


В двух словах: объявим два объекта Image и дождавшись их загрузки (т.е. когда число img_count будет 2). В моем скрипте этот момент сделан несколько через одно место — после загрузки любого из файлов проверяется а не загружен ли УЖЕ другой и если да, то вызывается функция сборки двух изображений в одно. Я не так чтобы хорошо разбираюсь в JavaScript просто чуствую что что-то не так, но несмотря на мои чувства оно работает. И поскольку ожидается (в примере) всегда именно 2 изображения, то функцию загрузки пока оставим в покое и напишем функции сборки.

Собрать!


compileRGBA()
function compileRGBA(raw_rgb, raw_alpha){

  if (!raw_rgb.width || !raw_rgb.height || !raw_alpha.width || !raw_alpha.height){
    return;
  }

  if (raw_rgb.width !== raw_alpha.width || raw_rgb.height !== raw_alpha.height){
    alert('Размеры RGB и прозрачности не сходятся')
    return;
  }

  var canvas_rgb = document.createElement("canvas");
  var canvas_alpha = document.createElement("canvas");
  var canvas_frame = document.createElement("canvas");
  
  if (!canvas_rgb || !canvas_rgb.getContext('2d') 
   || !canvas_alpha || !canvas_alpha.getContext('2d')
   || !canvas_frame || !canvas_frame.getContext('2d')){
    alert('Та-а-а-а-а, насяльника... та-а-а-а, канва, насяльника');
    return;
  }

  canvas_rgb.width = raw_rgb.width;
  canvas_rgb.height = raw_rgb.height;
  canvas_alpha.width = raw_alpha.width;
  canvas_alpha.height = raw_alpha.height;
  canvas_frame.width = 256;
  canvas_frame.height = 256;
  
  var context_rgb = canvas_rgb.getContext('2d');
  var context_alpha = canvas_alpha.getContext('2d');
  var context_frame = canvas_frame.getContext('2d');
  
  context_rgb.drawImage(raw_rgb, 0, 0);
  context_alpha.drawImage(raw_alpha, 0, 0);
  
  var pix_rgb = context_rgb.getImageData(0, 0, raw_rgb.width, raw_rgb.height);
  var pix_alpha = context_alpha.getImageData(0, 0, raw_alpha.width, raw_alpha.height);
  
  for (var i = 0, n = pix_rgb.width * pix_rgb.height * 4; i < n; i += 4){
    pix_rgb.data[i+3] = pix_alpha.data[i];
  }

  context_rgb.putImageData(pix_rgb, 0, 0);

  var img_arr = [];
  var frames = [];
  for(var i=0; i<=15; i++){
    for(var j=0; j<=15; j++){
      frames[j*16 + i] = context_rgb.getImageData(i*256, j*256, 256, 256);
    }
  }

  var frame = 0;
  $("#base64").append(canvas_frame);
  
  var intFPS = setInterval(function(){ 
    ++frame;
    if (frame > 255){
        frame = 0;
    }
    context_frame.putImageData(frames[frame], 0, 0)
  }, 1000 / 16);

}


Тут тоже нет ничего сложного — в первом условии проверется наличие размеров а во втором их соответсвие. потом объявляем три канвы: для цвета, для прозрачности и для анимации. Как положено проверяем Наличие поддержки механизма Canvas в браузере (я тестировал в Mozilla Firefox и Google Chrome, тут все в порядке а у IE8 случилась истерика и я оставил его в покое. Как подсказывает dasm32: «На Opera работает», а так-же у sashagil «в IE 10 (это который пока только в Win8), нормально показывает», но у Krovosos «На IPad Safari не сработало, даже фон не показался» ) и генерируем для них контексты. А дальше начинается магия:
  var pix_rgb = context_rgb.getImageData(0, 0, raw_rgb.width, raw_rgb.height);
  var pix_alpha = context_alpha.getImageData(0, 0, raw_alpha.width, raw_alpha.height);

эти двумя строчками вытягиваем RGBA-слои от контекстов файла цвета и файла прозрачности, каждый из которых разворачивается в 64-мегабайтный массив, несмотря на то что в случае с файлом цвета отсутствует составляющая прозрачности а в случае Gray-Mode файла прозрачности достаточно вообще одного 8-битного слоя, но такова селяви — канва всегда 4 байта в глубину. По байту на каждый из трех цветов и еще один для прозрачности. Попав на канву монохромный файл прозрачности «разворачивается в глубину» на 32 бита таким образом что R=G=B а прозрачность=255.

обходим в цикле все пиксели этих массивов и
pix_rgb.data[i+3] = pix_alpha.data[i]

копируем значение байта красного цвета из канвы прозрачности в слой прозрачности (data[i] — красный, data[i+1] — зеленый, data[i+2] — синий, data[i+3] — альфа)

С этого момента у нас есть огромная (4096х4096) портянка состоящая из 256 отдельных кадров каждый из которых 256х256 пикселей размером. Кадры выложены в сетку 16х16 сверху-вниз и слева-направо. Теперь самое логичное экспортировать эту сетку из канвы в документ как Base64 инстанс и скормить его свойству Background-Image в DIV соответсвующего размера и сдвигать начальную точку по таймеру на размер кадра. Эксперимент показал что это ужасный вариант в плане загрузки процессора, мой DualCore 2.2GHz не смог выдать выше 5 FPS. Поэтому разрежем эти 16х16 солнышек на кадры и сложим их в массив.
  var frames = [];
  for(var i=0; i<=15; i++){
    for(var j=0; j<=15; j++){
      frames[j*16 + i] = context_rgb.getImageData(i*256, j*256, 256, 256);
    }
  }

далее запускаем таймер 16 раз в секунду (можно и скорее и помедленнее) и видим анимацию, или скачиваем и смотрим локально.

Внимание! Хром не станет локально «закачивать» файлы по соображениям безобразности, поэтому остается ФФ.
UPD SHVV: "… чтобы Хром смог открыть локальные файлы, его надо запустить с ключом --disable-web-security".

Если же вы смотрите онлайн, то должен сказать что загрузка (1.5МБ) происходит незаметно, а вот склеивание замерзает примерно секунд на 5, но потом отмерзает и анимация работает. Причем процессор загружен на 1% что дает повод провести дальнейший эксперимент с парой таких солнышек на предмет «посмотреть как они пересекутся».

Космос 2.0


для этого модифицируем CSS
Новые стили
.base64{
  background: transparent;
  position: absolute;
  width: 256px;
  height: 265px;
  margin: 0;
}


Сделаем абсолютное позиционирование чтобы блок мог летать.

А также JavaScript. Во-первых сделаем две звезды, соответственно два DIV-а, вызовем две функции склейки —
модификация loadRGBA()
  img_rgb.onload = function(){
    ++img_count;
    if(2 == img_count){
      compileRGBA(img_rgb, img_alpha, "star1");
      compileGGAA(img_rgb, img_alpha, "star2");
    }
  }

  img_alpha.onload = function(){
    ++img_count;
    if(2 == img_count){
      compileRGBA(img_rgb, img_alpha, "star1");
      compileGGAA(img_rgb, img_alpha, "star2");
    }
  }


одна такая-же как и ранее (compileRGBA) а вторая модифиуированная (compileGGAA) она отличается от первой лишь немного более вольным обращением с каналами цвета-прозрачности:
    pix_rgb.data[i] = pix_rgb.data[i+1];
    pix_rgb.data[i+2] = pix_rgb.data[i+3] = pix_alpha.data[i];

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

Во-вторых зададим таймеры для перемещения этих звезд по эллипсу с дельтой фаз в 180 градусов:
таймеры
  var intRotate = setInterval(function(){ 
    phase += .01;
    
    if (phase >= 6.28319) {
      phase = .0;
    }
    
    $("#star1.base64").css('top', (doc_h + 50*Math.sin(phase)) + 'px' );
    $("#star2.base64").css('top', (doc_h + 50*Math.sin(phase + 3.14159)) + 'px' );
    $("#star1.base64").css('left', (doc_w + 100*Math.cos(phase)) + 'px' );
    $("#star2.base64").css('left', (doc_w + 100*Math.cos(phase + 3.14159)) + 'px' );

    $("#star1.base64").css('z-index', (phase < 3.14159) ? '1001':'1000' );
    $("#star2.base64").css('z-index', (phase < 3.14159) ? '1000':'1001' );
  }, 1000 / 24);


в-третьих удалим промежуточные структуры:
удаляем мусор
  delete pix_rgb;
  delete pix_alpha;
  delete context_rgb;
  delete canvas_rgb;
  delete context_alpha;
  delete canvas_alpha;


Тут сложно сказать есть-ли толк от этих удалений, по-крайней мере Task Manager никакой разницы не заметил, что с удалением что без удаления.

Таким образом получается две по-переменно затменные звезды.

— А можно всех посмотреть?
— Да, конечно. Скачивать будете?

Резюме


На этом эксперимент/урок закончен. Ощущение двоякое: с одной стороны принципиально все работает. С другой — этот дикий тормоз на момент склейки портит радость победы. И что с этим делать пока не понятно. Если у вас есть предложения — охотно выслушаю и внесу коррективы.

Спасибо за внимание, друзья!

P.S. я смнеил для вас фон на менее беспощадный =)
Tags:
Hubs:
+46
Comments41

Articles