-
Canvas как способ оптимизации графики
Мы постепенно начинаем обновлять дизайн сайта Аймобилко и уже выкатили пару новых макетов. Самое заметное изменение — это главная страница сайта:
Дизайнер очень хорошо постарался: страница выглядит очень красиво и современно. Осталось только перенести всё эту красоту из фотошопа в веб.
Центральный элемент страницы — сцена, на которой показываются новинки нашего каталога. На фоне сцены находится очень большая картинка с кулисами. Если присмотреться, то эти кулисы покрыты лёгким шумом, что придаёт им особый колорит:
Однако вся эта красота на проверку оказалась очень тяжёлой: в одной картинке объединилось всё худшее, что плохо влияет на сжатие. Это и красный цвет (даёт очень сильные артефакты сжатия в 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);
Получим вот такой результат:
Вполне неплохо для начала. Однако нам не достаточно просто сгенерировать слой с шумной текстурой и наложить его на кулисы с полупрозрачностью. Если ещё внимательней присмотреться к картинке, то можно заменить, что там нет светлых пикселей, есть только тёмные. То есть монохромный шум должен быть наложен на картинку в режиме Multiply.
Режимы наложения
Каждый, кто работал с фотошопом и другими продвинутыми графическими редакторами, знает, что такое режимы наложения слоёв:
Кому-то они могут показаться запредельно сложными с точки зрения программной реализации, однако на самом деле практически все режимы наложения основаны на очень простых алгоритмах. Например, алгоритм режима наложения 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); }
Получим что-то типа этого:
Уже похоже на правду, но шум получился грубый: нужно наложить его с меньшей прозрачностью.
Альфа-композиция
Процесс смешивание двух цветов с учётом прозрачности называется «альфа-композиция». В простейшем варианте алгоритм смешивания выглядит так:
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%:
Оптимизация
У меня картинка генерируется примерно за 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 МБ, так что я не стал сохранять её — доступное пространство можно и нужно использовать с большей пользой.
В целом, считаю эксперимент удавшимся, посмотрим, будут ли жалобы со стороны пользователей. Кто хочет попробовать что-то подобное на своих проектах — добро пожаловать в онлайн-демо, в котором я собрал реализации популярных режимов наложения, чтобы оценить их скорость и качество.
Добавлено: некоторые читатели и Денис в частности справедливо заметил в комментариях, что практически идентичного результата можно добиться наложением слоя с шумом поверх картинки. Тут скорее речь идёт о не совсем удачном примере, выбранном для статьи, нежели о неправильности подхода в целом.
59 комментариев
Решение, конечно, интересное, но миллион итераций, при каждом переходе по страницам, да на каком-нибудь дохлом девайсе, наверно, будет тормозить. Не было мысли, например, сгенерировать несколько прозрачных небольших канвасов с шумом, и накладывать их на картинку? Не уверен, что drawImage() поддерживает multiply, но может, тут и простого наложения хватит?
Все же, по моему, решение тяжеловатое.
В теории, шум можно было сделать по принципу «цикады»: http://habrahabr.ru/blogs/css/117160/
Не рассматривали такой вариант?
Очень понравилось онлайн-демо.
Про него можно было бы отдельную статью написать.
Пробовали переместить шум в отдельную повторяющуюся картинку и наложить ее поверх картинки с кулисами?
var pixelsLength = pixels.length;
По поводу красного цвета и артефактов сжатия jpeg — можно передавать цвета лучше. Обычно компрессоры на яркость отводят «вдвое больше информации, чем на цвет». Можно это соотношение и поменять. Хороший пример приведен у Давида Мзареуляна http://david-m.livejournal.com/1010761.html
Не надо боятся таких цифр. Для современных браузеров (а только у них есть canvas) это не проблема. Тем более, я привёл конкретный цифры: 80 мс на обработку картинки (а сейчас это уже 50 мс с учётом дополнительных оптимизаций).
Теория цикад немного для других вещей служит: с помощью неё можно разнообразить повторяющуюся текстуру. Здесь же наложение шума на картинку.
Про неё особо нечего писать: все идеи и принципы изложены в этой статье, в демо просто есть выбор способа наложения картинок.
Это не поможет: у картинки с кулисами разная освещённость в разных областях.
Да, спасибо, я буду иметь это в виду. Но в моём случае это не поможет, потому что основной проблемой является шум, который не превращается в кашу только при очень больших значениях качества сжатия.
У тебя там, кажется, к int приводится в коде на сайте: b >> 0, есть способ короче: b|0. Ну и вообще, код можно ещё ужать 🙂
И, кстати, спасибо за статью! Как всегда креативно и интересно.
Как оказалось, отдавать int в image data совсем необязательно: браузер сам его приведёт к нужному типу 🙂 Я уже внёс некоторые изменения и ускорил отрисовку, сегодня код на продакшн попадёт.
Круто! Всегда интересно читать про такие оптимизации, как с точки зрения идеи, так и конкретных примеров в яваскрипте.
У меня небольшой оффтопик, но про главную страницу: .news-list-title>a { padding:.15em 0; } — и ховер у этих многострочных ссылок не будет «рябить» 🙂
А почему нельзя шум вынести в отдельную картинку и с помощью css её наложить на хороший сжатый фон?
В Опере очень медленно выполняется, за 350 мс. Не очень она переваривает огромное кол-во рандомов. Поэтому генерируем строку рандомов заранее, потом только смещение в ней меняем.
Этот отрисовывает за 140 мс: http://pastie.org/2356062
В остальных брауерах прирост есть, но не столь значительный. Ну и плюс, рестрограды будут рады (420 мс против 963 мс в третьем ФФ и 780 мс против двух секунд в Опере 10.10).
При дальнейшей оптимизации (работа только с целыми числами, коих в js как бы нет) — http://pastie.org/2356271 — Опера ускоряется еще в два раза, а остальные начинают замедляться. Если вдруг кому-то придет в голову накладывать шум в реальном времени, есть смысл сделать разные версии для разных браузеров.
Попробуйте так сделать и посмотрите на результат.
Да, хороший подход. Судя по всему, Опера тормозит на преобразованиях float → int в imageData. Я сделал вот так, вроде везде быстро пашет:
Сергей спасибо за статью, я не занимаюсь html а делаю все на flash, но очень полезно было почитать. Спасибо вам, что вы такой смелый и успеваете такие полезные штуки делать.
Слона-то я не приметил. Действительно, в Опере тормозит именно присваивание в pixels[i] дробных чисел. Только вариант http://pastie.org/2356062 с измененной строкой:
pixels[i] = pixels[i] * randoms[x + offset] | 0;
работает у меня быстрее во всех браузерах (я уже тестирую на картинке в 4 раза больше исходной, чтобы заметить результат) чем:
pixels[i] = (pixels[i] * randoms[i % rl]) | 0;
Сергей извините за возможный вопрос не в тему, но планируется ли сделать для Аймобилки RSS с новыми товарами?
Да, сделаем обязательно
Благодарю за ответ! Сергей а не рассматривали ли вы возможность комбинированной подписки(допустим человек покупает подписку на месяц и получает возможность скачать 10 книг и 10 аудио книг за месяц)?
Получилось красиво 🙂
>Пробовали переместить шум в отдельную повторяющуюся картинку и наложить ее поверх картинки с >кулисами?
>>Это не поможет: у картинки с кулисами разная освещённость в разных областях.
Как я вижу, освещённость симметрична относительно вертикальной оси, проходящей по центру картинки.
Возможно получится случайно генерить только левую половину шума, а правую — копировать, симметрично отражая. Может ускорить чуть ли на в два раза.
Ещё я бы поигрался с разворачиванием цикла. Генерить не один пиксель в теле цикла, а сразу штук восемь. Практически, просто накопипастить.
А чем и как меряли время исполнения скрипта? У меня в FF 4.0 и хроме шум появляется в последнюю очередь, после загрузки всех фоновых и контентных картинок. А файербаговский профайлер утверждает, что время исполнения noiseGenerator на разном железе (к слову, не самом медленном) от 1000 до 1500 мс. Пробовал на нет-буке — там вообще все печально: 3500 мс.
Насчет фона — грузится прекрасно, а вот ссылочка «Вход и регистрация» у вас при ховере меняет цвет, а ее иконка — нет. Не очень-то красиво…
Сергей, ваша инженерная креативность не перестает радовать.
Хотелось бы узнать, тем не менее, ваше отношение к полученному результату.
С одной стороны, вы уменьшили объем подгружаемых данных аж на 200 кБ! Хотя в наши времена высокоскоростного интернета это достижение вряд ли кому-то удалось бы оценить.
С другой стороны, вы обеспечили свое решение дополнительными костылями, в виде отдельно выполняемого кода, нагрузки процессора, выделяемой памяти и потенциальных проблем с кроссбраузерностью.
Сергей, не понимаю почему у вас возникли сложности с шумом отдельной картинкой с повторением?
По-моему задача решается небольшой подготовкой «шума» в фотошопе:
полученный с помощью фильтра слой инвертируем и кладем маской для слоя залитого черным цветом, играем с прозрачностью, сохраняем, и вуаля — http://float-left.ru/im/
Просто замерял чистое время выполнения скрипта. Я посмотрю, почему могут быть такие проблемы со скоростью, спасибо за репорт.
Если смотреть с этой стороны, то всякие новомодные border-radius и box-shadow грузят процессор не меньше. В целом — да, есть такая проблема и я о ней написал в конце. Она решаемая, но меня, в первую очередь, интересует это с точки зрения эксперимента. Потому что на подходе ещё несколько страниц с более тяжёлой графикой, но меньшими площадями «воздействия», там я тоже хочу попробовать применить такой же подход.
У вас получились грязные брызги вместо лёгкого шума. Сравните:
Я бы попробовал генерить шум на небольшом участке (скажем, 64×64), а потом циклически его повторять. Это если хочется именно генерить. Хотя задача, в самом деле, решается наложением png с чёрным полупрозрачным шумом.
Давид, попробуйте размножить кусок шума в обычном режиме наложения и посмотрите на результат. Вышу в комментариях уже есть пример.
Ну уменьшите прозрачность немного. Разница между Normal и Multiply, конечно, есть, но она больше вкусовая в данном случае.
Но даже если хочется именно Multiply, то всё равно можно здорово сэкономить, используя циклическую сгенерённую текстуру.
P. S. У меня в хроме тоже шум накладывается в самую последнюю очередь, после загрузки обложек.
Я специально вынес добавление шума на
onload
картинки с кулисами, чтобы не блокировали отрисовку страницы. Всё-таки шум — это вспомогательный элемент, а не основной. В любом случае буду следить за тем, как развивается этот эксперимент.Cергей, в предыдущем сообщении я просто показал принцип.
Потратив 2 минуты на подбор нужных параметров фильтра, продублировав слой шума со смещением, подобрав прозрачность, можно добиться ускользающее малой разницы:
Картинку не пропустило:
http://float-left.ru/im/sample.png
Денис, а вы покажите пример целиком
Лежит по тому же адресу — http://float-left.ru/im/
http://float-left.ru/im/ — в целом, готов признать, что разница не так сильно заметна по сравнению с оригиналом. Но лично у меня немного рябит в глазах не пересвеченых участках 🙂
Денис, я немного обновил статью, поставил ссылку на ваш пример.
Ну так нижняя картинка ваша 🙂 Возможно при использовании на сайте, когда над фоном выводится содержимое, его «пересвеченные участки» не так оттягивают на себя внимание.
А вообще это конечно же proof of concept, потратив еще немного времени можно избавиться от всех шероховатостей.
Хотя экперимент с реализацией режимов смешивания на js очень интересен, исходная задача решается куда более тривиально.
Нет, я имел в виду пример в целом. Скорее всего, рябит из-за повторяющейся текстуры, хотя конкретно в моём случае этого не было бы заметно.
Вот ты задрачиваешься. Крутота нечеловеческая, молодец 🙂
Хммм, странно видеть свой дизайн от чужих людей…
Год назад рисовал для одного театра http://s46.radikal.ru/i113/1108/59/601f0cd2b958.jpg (вариант принят не был).
Крепитесь! Здесь ваш дизайн уже воплощен в живой сайт: http://www.best-d.ru/ Можно подать в суд.
А здесь http://rylik.ru/tags/%F2%E5%E0%F2%F0/ ваш дизайн можно скачать в формате psd. Они, сволочи, у вас исходники потырили. Готовьте иск — дело верное.
Хм… а вот эти, похоже, сами вас могут засудить http://inetio.ru/entries/create/268?page=1 Их-то работа датирована 2008 годом. Старше вашей. Что скажете в свое оправдание?
А вообще, сдается мне, что красные занавески — не ваше изобретение 🙂
http://www.freelancerbay.com/files/users/Haizy/portfolio/8979_%D1%82%D0%B5%D0%B0%D1%82%D1%80%20860-874.jpg
http://www.free-lancers.net/posted_files/N9C901070C607.jpg
st.free-lance.ru/users/ivadesign/upload/f_496c92374ee43.png
st.free-lance.ru/users/CheRya/upload/f_4bc58b6fe8d75.jpg
http://www.site4u.kz/upload/screen/44143_410127_16.jpg
http://www.d-p74.ru/media/primers/dizajn-sajta-teatra-opery-i-baleta-im-miglinki/chelnepr.jpg
kvakazyambra, inetio.ru и правда первее.
остальные — либо другие, либо позже. Про суд, конечно глупость, все равно мой вариант заказчиком не был принят.
Офигенно сделано всё! Сейчас зашел на Аймобилко и охренел просто! таак быстро все загрузило..с моим то модемным интернетом. Круто в общем )
Очередной раз убил подходом к делу. Респект, Сергей.
Сергей, расскажите, пожалуйста, каким образом вы проверяете скорость выполнения скрипта?
Михаил, это проверяется замером времени выполнения куска кода через
(new Date).getTime()
, либо через профайлер в Firebug и Web InspectorПросто я пробовал получать время, используя getTime — уж слишком разброс в результатах большой получается. А профайлер из Firebug почему-то показывает только скорость работы участков jquery.
А как вы решили проблемы с IE, который вовсе не поддерживает canvas?
romanov, да плевать на этот недобраузер. На крайний случай можно написать процедуру определения юзер-агента и выдавать юзеру с IE толстую картинку. Автору хвала и слава, аж захотелось постичь канвас HTML5.
Давненько ничего нового не появлялось! Сергей, Вы забросили сайт?
вопрос плевать или не плевать на ие, выходит за рамки данной дискуссии.
в данном же топике, насколько я понял, идёт речь о реально работающем проекте с canvas. посему очень интересно, как это реализовано на практике.
сам сталкивался с подобным, приходилось лепить скрытый флеш и ещё кучу костылей, что давало очень низкую производительность и на без того тормознутом ие.
в демо когда листаешь список режимов клавиатурой картинки не пересчитываются. наверное, вместо onclick надо какой-нибудь там onchange
В видео формате урок есть? Хотя и так написано не плохо. Просо хочу сахранить если забуду.
А почему не сделать PNG/GIF с шумом и прозрачностью но скажем 100х100 и не положить ее на картинку с занавесками? Сорри если уже было в комментах.
проблем в PNG с альфаканалом не бывает ???
Не нашел вашего емаила. Есть вопрос-предложение.
суперский подход.вы молодец
Прекрасное изложение материала
Полностью соглашусь с Дамиром, материал суперский))