Архив категории «Работа с графикой»

  • Canvas как способ оптимизации графики

    Мы постепенно начинаем обновлять дизайн сайта Аймобилко и уже выкатили пару новых макетов. Самое заметное изменение — это главная страница сайта:

    ss01

    Дизайнер очень хорошо постарался: страница выглядит очень красиво и современно. Осталось только перенести всё эту красоту из фотошопа в веб.

    Центральный элемент страницы — сцена, на которой показываются новинки нашего каталога. На фоне сцены находится очень большая картинка с кулисами. Если присмотреться, то эти кулисы покрыты лёгким шумом, что придаёт им особый колорит:

    example

    Однако вся эта красота на проверку оказалась очень тяжёлой: в одной картинке объединилось всё худшее, что плохо влияет на сжатие. Это и красный цвет (даёт очень сильные артефакты сжатия в JPEG), и мелкий шум (сильные артефакты в JPEG; плохо упаковывается в PNG). Приемлемое качество картинки было достигнуто при размере в 330 КБ, что, на мой взгляд, довольно много для одной картинки. Очень хочется, чтобы главная страница загружалась как можно быстрее. Поэтому я решился на один эксперимент.

    Изучаем картинку

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

    Простой алгоритм монохромного шума выглядит так:

    var canvas = document.createElement('canvas');
    canvas.width = canvas.height = 200;
    var ctx = canvas.getContext('2d');
    
    // получаем все пиксели изображения
    var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    var pixels = imageData.data;
    
    for (var i = 0, il = pixels.length; i < il; i += 4) {
    	var color = Math.round(Math.random() * 255);
    	// так как шум монохромный, в каналы R, G и B кладём одно и то же значение
    	pixels[i] = pixels[i + 1] = pixels[i + 2] = color;
    
    	// делаем пиксель непрозрачным
    	pixels[i + 3] = 255;
    }
    
    // записываем пиксели обратно на холст
    ctx.putImageData(imageData, 0, 0);
    
    document.body.appendChild(canvas);
    

    Получим вот такой результат:

    noise

    Вполне неплохо для начала. Однако нам не достаточно просто сгенерировать слой с шумной текстурой и наложить его на кулисы с полупрозрачностью. Если ещё внимательней присмотреться к картинке, то можно заменить, что там нет светлых пикселей, есть только тёмные. То есть монохромный шум должен быть наложен на картинку в режиме Multiply.

    Режимы наложения

    Каждый, кто работал с фотошопом и другими продвинутыми графическими редакторами, знает, что такое режимы наложения слоёв:

    blending-modes

    Кому-то они могут показаться запредельно сложными с точки зрения программной реализации, однако на самом деле практически все режимы наложения основаны на очень простых алгоритмах. Например, алгоритм режима наложения Multiply выглядит так:

    (colorA * colorB) / 255

    То есть просто умножаем два цвета и делим результат на 255 (отсюда и название Multiply: «умножение»).

    Доработаем нашу функцию: загрузим картинку, сгенерируем шум и наложим его в режиме Multiply:

    // Загружаем картинку. Обязательно ждём, пока она полностью загрузится
    var img = new Image;
    img.onload = function() {
    	addNoise(img);
    };
    img.src = "stage-bg.jpg";
    
    
    function addNoise(img) {
    	var canvas = document.createElement('canvas');
    	canvas.width = img.width;
    	canvas.height = img.height;
    
    	var ctx = canvas.getContext('2d');
    
    	// нарисуем картинку на холсте, чтобы получить её пиксели
    	ctx.drawImage(img, 0, 0);
    
    	// получаем все пиксели изображения
    	var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    	var pixels = imageData.data;
    
    	for (var i = 0, il = pixels.length; i < il; i += 4) {
    		// генерируем пиксель «шума»
    		var color = Math.random() * 255;
    
    		// накладываем пиксель шума в режиме multiply на каждый канал
    		pixels[i] = pixels[i] * color / 255;
    		pixels[i + 1] = pixels[i + 1] * color / 255;
    		pixels[i + 2] = pixels[i + 2] * color / 255;
    	}
    
    	ctx.putImageData(imageData, 0, 0);
    	document.body.appendChild(canvas);
    }
    

    Получим что-то типа этого:

    stage-noise

    Уже похоже на правду, но шум получился грубый: нужно наложить его с меньшей прозрачностью.

    Альфа-композиция

    Процесс смешивание двух цветов с учётом прозрачности называется «альфа-композиция». В простейшем варианте алгоритм смешивания выглядит так:

    colorA * alpha + colorB * (1 - alpha)

    где alpha — это коэффициент смешивания (прозрачность) от 0 до 1. В данном случае важно правильно выбрать, что будет фоновым изображением (colorB), а что будет накладываемым (colorA). В нашем случае фоновой будет сцена, а шум — накладываемым.

    Добавим в функцию addColor() дополнительный параметр alpha и модифицируем сам алгоритм с учётом альфа-композиции:

    var img = new Image;
    img.onload = function() {
    	addNoise(img, 0.4);
    };
    img.src = "stage-bg.jpg";
    
    
    function addNoise(img, alpha) {
    	var canvas = document.createElement('canvas');
    	canvas.width = img.width;
    	canvas.height = img.height;
    
    	var ctx = canvas.getContext('2d');
    	ctx.drawImage(img, 0, 0);
    
    	var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    	var pixels = imageData.data, r, g, b;
    
    	for (var i = 0, il = pixels.length; i < il; i += 4) {
    		// генерируем пиксель «шума»
    		var color = Math.random() * 255;
    
    		// высчитываем итоговый цвет в режиме multiply без альфа-композиции
    		r = pixels[i] * color / 255;
    		g = pixels[i + 1] * color / 255;
    		b = pixels[i + 2] * color / 255;
    
    		// альфа-композиция
    		pixels[i] =     r * alpha + pixels[i] * (1 - alpha);
    		pixels[i + 1] = g * alpha + pixels[i + 1] * (1 - alpha);
    		pixels[i + 2] = b * alpha + pixels[i + 2] * (1 - alpha);
    	}
    
    	ctx.putImageData(imageData, 0, 0);
    	document.body.appendChild(canvas);
    }
    

    Получаем именно то, что нам нужно: слой шума, наложенный на картинку в режиме Multiply и прозрачностью 20%:

    stage-noise-alpha

    Оптимизация

    У меня картинка генерируется примерно за 400 мс, что довольно заметно. Поэтому мы оптимизируем код, чтобы он работал быстрее.

    Размер моей картинки 1293×897 пикселей, что в итоге даёт 1 159 821 итераций цикла. Это довольно много, поэтому в первую очередь нужно оптимизировать операции вычисления, а именно убрать ненужные и повторяющиеся операции.

    Например, в цикле три раза высчитывается значение 1 - alpha, хотя это постоянное значение для всей функции, поэтому делаем новую переменную за пределами цикла:

    var alpha1 = 1 - alpha;

    Далее, при генерации пикселя шума используется формула Math.random() * 255, однако дальше мы делим этот цвет на 255: r = pixels[i] * color / 255. Соответственно, умножение и деление на 255 можно смело убирать.

    Эти простые операции снизили время выполнения скрипта с 400 мс до 300 мс (-25%).

    Помним, что у нас больше миллиона итераций по массиву, поэтому каждая мелочь на счету. В цикле мы два раза обращаемся к массиву pixels, сначала чтобы получить значения для вычисления multiply-пикселя, а затем чтобы сделать альфа-композицию. Доступ к массиву довольно дорогая операция, поэтому сохраняем оригинальное значение пикселя в переменную (то есть оставляем только одно обращение к массиву):

    var origR = pixels[i],
    	origG = pixels[i + 1],
    	origB = pixels[i + 2];
    

    Это экономит ещё около 40 мс.

    С учётом всех оптимизаций функция addNoise() выглядит вот так:

    function addNoise(img, alpha) {
    	var canvas = document.createElement('canvas');
    	canvas.width = img.width;
    	canvas.height = img.height;
    
    	var ctx = canvas.getContext('2d');
    	ctx.drawImage(img, 0, 0);
    
    	var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    	var pixels = imageData.data, r, g, b, origR, origG, origB;
    	var alpha1 = 1 - alpha
    
    	for (var i = 0, il = pixels.length; i < il; i += 4) {
    		// генерируем пиксель «шума»
    		var color = Math.random();
    
    		origR = pixels[i];
    		origG = pixels[i + 1];
    		origB = pixels[i + 2];
    
    		// высчитываем итоговый цвет в режиме multiply без альфа-композиции
    		r = origR * color;
    		g = origG * color;
    		b = origB * color;
    
    		// альфа-композиция
    		pixels[i] =     r * alpha + origR * alpha1;
    		pixels[i + 1] = g * alpha + origG * alpha1;
    		pixels[i + 2] = b * alpha + origB * alpha1;
    	}
    
    	ctx.putImageData(imageData, 0, 0);
    	document.body.appendChild(canvas);
    }
    

    Скорость выполнения скрипта — около 170 мс (было 400 мс), что довольно неплохо.

    Ещё больше оптимизаций

    Внимательный читатель мог заметить, что картинка с кулисами — красная. То есть информация об изображении присутствует только в красном канале, в синем и зелёном её нет. Если её нет, зачем делать вычисления для этих каналов? Поэтому оставляем расчёты только для красного канала: конкретно в моём случае это даст аналогичный результат, а время выполнения снизит до 80 мс:

    for (var i = 0, il = pixels.length; i < il; i += 4) {
    	origR = pixels[i];
    	pixels[i] = origR * Math.random() * alpha + origR * alpha1;
    }
    

    Добавлено: читатель @Sergeyev указал, что можно ещё сократить время выполнения скрипта (-20%), убрав ненужные операции:

    for (var i = 0, il = pixels.length; i < il; i += 4) {
    	pixels[i] = pixels[i] * (Math.random() * alpha + alpha1);
    }
    

    Результат

    Результат получился довольно неплохим:

    • Вес изображения снизился с 330 КБ до 70 КБ + 1 КБ пожатого JS-кода. На самом деле, картинку можно было бы ещё больше ужать, потому что слой с шумом скроет большинство артефактов JPEG-сжатия.
    • Такая оптимизация соответствует практикам progressive enhancement: пользователи с браузерами, в которых нет canvas (например, IE6) или отключён JS всё равно получат картинку, но менее детализированную.

    Единственный минус, который я вижу — это выполнение наложения каждый раз при загрузке страницы, в то время как обычная картинка может быть просто закэширована браузером. Но, во-первых, время выполнения наложения довольно низкое (80 мс), а во-вторых, как вариант, результат можно хранить в localStorage в виде data:url и при следующей загрузке страницы доставать из кэша. Но моя картинка занимает более 1 МБ, так что я не стал сохранять её — доступное пространство можно и нужно использовать с большей пользой.

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

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

    Метки: , ,
  • punypng

    Хоть и зарекался я не рекомендовать инструменты для пакетной оптимизации графики, но тут все-таки придется.

    Встречайте нового игрока — punypng. Новый сервис, очень похожий (но пока не такой удобный) на известный smush.it.

    После публикации на SM первой части статьи про оптимизацию PNG со мной связались ребята из проекта Ask.com — довольно известного на Западе поисковика. Они как раз начали разрабатывать очередной оптимизатор картинок для веба. Немного проконсультировавшись, они добавили чистку прозрачных пикселей, что в большинстве случаев дает довольно ощутимое сжатие. Так что я теперь являюсь консультантом проекта punypng, а этот пост — наглый пиар :)

    А теперь немного конкретики. Я прогнал несколько тестовых изображений через этот сервис и могу сказать, что результат был действительно впечатляющим. Практически на всех изображениях удавалось достичь дополнительного сжатия. Даже на изображениях, которые я оптимизировал вручную и прогнал через OptiPNG.

    Чтобы сравнить результаты, я дополнительно прогнал несколько изображений (наиболее показательным оказалось вот это) через smush.it и ImageOptim.

    smush.it использует утилиту pngcrush, которая по всем меркам является устаревшей. Она пытается перепаковать изображения, не меняя их тип (например, черно-белая RGB-картинка не будет преобразована в Grayscale). Утилита OptiPNG, которая является ответвлением от pngcrush, делает такие преобразования автоматически, но при этом работает гораздо медленнее (поэтому на smush.it используется pngcrush). Так что от этого сервиса ждать каких-то выдающихся результатов пока не приходится. Однако на выходе мы получаем «безопасное» преобразование, которое гарантирует, например, что полноцветный полупрозрачный PNG не будет преобразован в полупрозрачный PNG с палитрой (разница в отображении в IE6).

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

    Так что от всей души советую попробовать этот сервис. Я, в свою очередь, постараюсь описать больше алгоритмов оптимизации изображений, чтобы сделать сервис еще лучше.

    UPD: Окей, значит, расклад такой.

    PNGOUT сам не умеет перебирать фильтры. Как правильно заметил Павел у себя в блоге, с этим успешно справляется OptiPNG, выдавая в консоль статистику обработки файла. Соответственно, правильный способ максимального сжатия файла такой: сначала пропускаем через OptiPNG, получаем от него номер N примененного фильтра и отдаем его в качестве параметра -fN для PNGOUT.

    По поводу ImageOptim. Судя по исходнику, там PNGOUT используется без указания правильного фильтра, и, более того, применяет библиотеки не друг за другом на одном и том же файле, а независимо, выбирая наименьший результат (поправьте меня если не прав, так как Objective-C не знаю). Поэтому и результат сжатия у меня получился не самый лучший.

    PNGSquash, напротив, применяет библиотеки в цепочке, поэтому получается так сильно сжать файлы. Так что эта программка работает лучше, чем ImageOptim.

    Сейчас узнаю, как работает punypng.

    UPD2: punypng не использует PNGOUT из-за лицензионных ограничений. Есть вероятность того, что ребята сделают офлайновую версию punypng: для этого нужно всей толпой проголосовать за это предложение.

  • Руководство по оптимизации PNG

    Специально для Smashing Magazine написал руководство по оптимизации PNG (вернее, наоборот: руководство по оптимизации изображений для PNG). По сути, это собранные вместе статьи из Техногрета и моего блога, которые были немного переосмыслены, дополнены, упрощены и переведены на английский язык.

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

  • Пакетная оптимизация PNG

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

    optipng -o5 *.png

    Создают даже целые сервисы вроде smush.it.

    Так вот, лично я никогда не пользовался такими советами. И пока не рекомендую делать этого ребятам, которые действительно хотят качественно оптимизировать графику. По одной простой причине: «меньше» не всегда значит «лучше».

    Что бы не говорили злые языки, но доля IE6 на рынке все еще слишком велика, чтобы сбрасывать его со счетов. Именно у этого браузера больше всего проблем с отображением разных вариаций PNG. В частности, полноцветный полупрозрачный PNG (PNG24 в терминологии фотошопа) он просто так не покажет, нужно использовать фильтр AlphaImageLoader или VML. А у полупрозрачных индексированных PNG покажет только непрозрачные пиксели. Из этого следует вывод, что нужно контролировать оптимизацию каждого файла.

    Рассмотрим несколько примеров. Для начала возьмем полупрозрачную плашку:

    …и вызовем на ней OptiPNG:

    optipng -o5 mate.png

    Он нам скажет, что самым оптимальным способом хранения этого изображения будет 8-битная палитра. Но так как на этой картинке нет ни одного непрозрачного пикселя, то в IE6 не отобразится вообще ничего. Поэтому для данного изображения нужно отключить оптимизацию по цвету:

    optipng -o5 -nc mate.png

    Рассмотрим второй пример:

    При попытке оптимизировать этот файл OptiPNG преобразует изображение в 8-битную палитру, однако это будет не оптимальный способ хранения изображения и вы увидите сообщение, что файл уже оптимизирован. Но я готов пожертвовать «гладкими» краями в IE6, лишь бы не извращаться с фильтрами и VML, потому что этот файл будет использоваться 50 раз на странице. Кто не в курсе: AlphaImageLoader жрет очень много памяти и процессорного времени, VML менее требователен, но все равно больше, чем обычная картинка или фон. Поэтому в данном случае нужно форсировать преобразование палитры:

    optipng -o5 -force corner.png

    Еще одним плюсом ручной оптимизации для меня является соответствие моих ожиданий действительности. Очень часто при сохранении черно-белых PNG я забывают выставить им режим цветовой Grayscale, а лог OptiPNG постоянно мне об этом напоминает, когда процент сжатия меньше 20. Кстати, smush.it не умеет преобразовывать цветовую палитру в черно-белую.

  • Спрайты в вебе. Часть 1

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

    Что такое спрайты?

    Думаю, вы и без меня знаете, что это такое. Спрайты — это несколько небольших картинок, объединенных в один графический файл.


    Спрайт с сайта «Житлобуд»

    Такой подход дает сразу несколько преимуществ. Во-первых, объем одного файла как правило меньше, чем сумма объемов отдельных файлов. Это можно объяснить снижением издержек на сохранение одного изображения: всякие там заголовки, указывающие на формат изображения, или общая палитра цветов, если речь идет об индексированном PNG или GIF. Бородатые кодеры могут вспомнить рекомендации 90-х годов по разбивке одного большого файла на несколько мелких, чтобы создать иллюзию у пользователя, что сайт грузится быстрее :) Такой подход создавал именно иллюзию, так как суммарный объем нескольких файлов был больше, чем объем одного файла, а сама картинка грузилась дольше.

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

    Итак, мы уже знаем, что спрайты — это круто и модно. Ограничено ли применение спрайтов только группировкой разных изображений в одно?

    Назад в прошлое

    Будучи неопытным юнцом, я очень любил играть в игры. Больше всего меня интересовало то, как же эти игры делаются. Тогда еще не было повсеместного использования 3D-графики, все игры, в которые я играл, в основном были 2D-аркадами (Another World, Commander Keen, Moles, Prince of Persia, эх…), активно использующими спрайты. Вообще, слово «спрайт» получило широкое распространение именно благодаря видеоиграм, и именно видеоигры больше всего повлияли на меня как на веб-разработчика.

    Помимо обычной группировки изображений можно выделить еще несколько типов спрайтов.

    Раскадровка


    Слон с сайта «Паритет98»

    Раскадровка, по сути, представляет собой набор кадров для анимации, сохраненных в одном файле. Для такого спрайта пишется контроллер на JavaScript, который управляет скоростью и направлением смещения спрайта. По сравнению с обычной GIF-анимацией это дает нам следующие преимущества:

    1. Можно использовать JPEG или полупрозрачный PNG.
    2. Более широкие возможности по оптимизации изображения.
    3. Можно самостоятельно управлять скоростью и направлением анимации, имея один и тот же набор кадров (то есть используя всего одно изображение).

    При создании такого спрайта нужно помнить следующее. Для начала нужно выбрать размер кадра; такой, чтобы в нем без проблем уместилась любое изображение из раскадровки +10-20% свободного пространства по бокам для надежности. Это позволит в дальнейшем без особых хлопот модифицировать изображение.

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

    Циклический спрайт

    Этот тип спрайтов в основном применяется для создания механизмов наподобие прелоудера: некая повторяющаяся текстура на блоке неопределенной ширины.

    Тут, конечно, руки так и чешутся использовать GIF-анимацию, однако перфекционистов вроде меня может не устроить плавность движения. Сравните:

    В первом случае я сделал GIF-анимацию с задержкой 0 ms (то есть вообще без задержки) между кадрами. В предпросмотре фотошопа все работает довольно плавно, а в браузере отчетливо заметны задержки между кадрами. В JS-анимации, используя контроллер из предыдущего примера, задержек никаких нет.

    К минусам данного подхода можно отнести более высокие требования системным ресурсам, поэтому если у вас много прелоудеров на странице нужно десять раз подумать, что лучше использовать, GIF или JS.

    Слои

    Иногда случается так, что для изображения не получается выбрать самый оптимальный формат. Наиболее яркий пример — фотография с полупрозрачными декорациями:

    В этом случае намного эффективнее будет разделить изображение на два слоя: первый — сердцевина — сохранить в JPEG, а второй — окантовка — в полупрозрачный PNG.

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

    Естественно, этот пример довольно простой и примитивный: уверен, что многие так и поступили бы с этой картинкой. Наибольший эффект от такого типа спрайтов достигается в том случае, когда сердцевина имеет не прямоугольную форму. Хороший пример: раздел «Отделение Imperia» сайта Imperia Private Banking:

    imperia-branch

    Огромный узор на двери был разделен на 2 слоя: сердцевину и окантовку, каждый слой был по-своему оптимизирован, что в итоге позволило мне сэкономить около 200 КБ. Как быстро разделить изображение на два таких слоя я писал в статье «Про PNG. Часть 2».

    Векторные маски

    К сожалению, не всегда подобные картинки можно эффективно разделить по слоям. Например, когда сердцевина не прямоугольной формы содержит фотографическое изображение. В этом случае на помощь приходят современные браузерные технологии, а именно SVG, Canvas и VML. С помощью них можно наложить векторные маски на прямоугольную JPEG-картинку, добившись необходимого внешнего вида. Для этих целей я написал специальную библиотеку под названием ictinus, которая занимается решением таких задач. Хороший пример использования использования спрайтовых масок (и моей библиотеки :)) — главная страница сайта «Аймобилко»:

    imobilco

    В середине страницы находится красивый тизер продукта, у которого изображение должно принимать «неправильную» форму (в данном случае — коробка компакт-диска с загнутым углом). Так как фон не однородный (и тоже, кстати, меняется), единственным очевидным способом решения этой задачи было создание отдельного PNG-изображения весом более 100 КБ для каждого такого продукта. Меня такой расклад не устраивал, поэтому я решил в качестве эксперимента применить ictinus (на тот момент был в состоянии очень сырой альфа-версии). На прямоугольное JPEG-изображение накладывается векторная маска (в зависимости от типа продукта), затем под эту картинку ставится PNG-тень. Результат оказался более чем убедительным. Помимо сильной экономии на траффике пользователя я сэкономил нервы заказчику: теперь ему достаточно любому продукту в админке поставить галочку «Показывать на главной», а не готовить в фотошопе новые картинки.

    ***

    С типами спрайтов, пожалуй, всё (ничего не забыл?), в следующей части рассмотрим способы хранения данных в спрайтах и некоторые рекомендации по использованию.

    Скачать JS-котроллер для спрайтовой анимации



    Метки: , , , ,

← cтарое