-
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 МБ, так что я не стал сохранять её — доступное пространство можно и нужно использовать с большей пользой.
В целом, считаю эксперимент удавшимся, посмотрим, будут ли жалобы со стороны пользователей. Кто хочет попробовать что-то подобное на своих проектах — добро пожаловать в онлайн-демо, в котором я собрал реализации популярных режимов наложения, чтобы оценить их скорость и качество.
Добавлено: некоторые читатели и Денис в частности справедливо заметил в комментариях, что практически идентичного результата можно добиться наложением слоя с шумом поверх картинки. Тут скорее речь идёт о не совсем удачном примере, выбранном для статьи, нежели о неправильности подхода в целом.

Решение, конечно, интересное, но миллион итераций, при каждом переходе по страницам, да на каком-нибудь дохлом девайсе, наверно, будет тормозить. Не было мысли, например, сгенерировать несколько прозрачных небольших канвасов с шумом, и накладывать их на картинку? Не уверен, что drawImage() поддерживает multiply, но может, тут и простого наложения хватит?
Все же, по моему, решение тяжеловатое.
В теории, шум можно было сделать по принципу “цикады”:
Не рассматривали такой вариант?
Очень понравилось онлайн-демо.
Про него можно было бы отдельную статью написать.
Пробовали переместить шум в отдельную повторяющуюся картинку и наложить ее поверх картинки с кулисами?
var pixelsLength = pixels.length;
По поводу красного цвета и артефактов сжатия jpeg – можно передавать цвета лучше. Обычно компрессоры на яркость отводят “вдвое больше информации, чем на цвет”. Можно это соотношение и поменять. Хороший пример приведен у Давида Мзареуляна
Не надо боятся таких цифр. Для современных браузеров (а только у них есть canvas) это не проблема. Тем более, я привёл конкретный цифры: 80 мс на обработку картинки (а сейчас это уже 50 мс с учётом дополнительных оптимизаций).
Теория цикад немного для других вещей служит: с помощью неё можно разнообразить повторяющуюся текстуру. Здесь же наложение шума на картинку.
Про неё особо нечего писать: все идеи и принципы изложены в этой статье, в демо просто есть выбор способа наложения картинок.
Это не поможет: у картинки с кулисами разная освещённость в разных областях.
Да, спасибо, я буду иметь это в виду. Но в моём случае это не поможет, потому что основной проблемой является шум, который не превращается в кашу только при очень больших значениях качества сжатия.
У тебя там, кажется, к int приводится в коде на сайте: b >> 0, есть способ короче: b|0. Ну и вообще, код можно ещё ужать
И, кстати, спасибо за статью! Как всегда креативно и интересно.
Как оказалось, отдавать int в image data совсем необязательно: браузер сам его приведёт к нужному типу
Я уже внёс некоторые изменения и ускорил отрисовку, сегодня код на продакшн попадёт.
Круто! Всегда интересно читать про такие оптимизации, как с точки зрения идеи, так и конкретных примеров в яваскрипте.
У меня небольшой оффтопик, но про главную страницу: .news-list-title>a { padding:.15em 0; } — и ховер у этих многострочных ссылок не будет «рябить»
А почему нельзя шум вынести в отдельную картинку и с помощью css её наложить на хороший сжатый фон?
В Опере очень медленно выполняется, за 350 мс. Не очень она переваривает огромное кол-во рандомов. Поэтому генерируем строку рандомов заранее, потом только смещение в ней меняем.
Этот отрисовывает за 140 мс:
В остальных брауерах прирост есть, но не столь значительный. Ну и плюс, рестрограды будут рады (420 мс против 963 мс в третьем ФФ и 780 мс против двух секунд в Опере 10.10).
При дальнейшей оптимизации (работа только с целыми числами, коих в js как бы нет) — — Опера ускоряется еще в два раза, а остальные начинают замедляться. Если вдруг кому-то придет в голову накладывать шум в реальном времени, есть смысл сделать разные версии для разных браузеров.
Попробуйте так сделать и посмотрите на результат.
Да, хороший подход. Судя по всему, Опера тормозит на преобразованиях float → int в imageData. Я сделал вот так, вроде везде быстро пашет:
var rl = (ctx.canvas.width * 3.73) | 0; var randoms = new Array(rl); for (var i = 0; i < rl; i++) { randoms[i] = Math.random() * alpha + alpha1; } for (var i = 0, il = pixels.length; i < il; i += 4) { pixels[i] = (pixels[i] * randoms[i % rl]) | 0; }Сергей спасибо за статью, я не занимаюсь html а делаю все на flash, но очень полезно было почитать. Спасибо вам, что вы такой смелый и успеваете такие полезные штуки делать.
Слона-то я не приметил. Действительно, в Опере тормозит именно присваивание в pixels[i] дробных чисел. Только вариант с измененной строкой:
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 кБ! Хотя в наши времена высокоскоростного интернета это достижение вряд ли кому-то удалось бы оценить.
С другой стороны, вы обеспечили свое решение дополнительными костылями, в виде отдельно выполняемого кода, нагрузки процессора, выделяемой памяти и потенциальных проблем с кроссбраузерностью.
Сергей, не понимаю почему у вас возникли сложности с шумом отдельной картинкой с повторением?
По-моему задача решается небольшой подготовкой «шума» в фотошопе:
полученный с помощью фильтра слой инвертируем и кладем маской для слоя залитого черным цветом, играем с прозрачностью, сохраняем, и вуаля —
Просто замерял чистое время выполнения скрипта. Я посмотрю, почему могут быть такие проблемы со скоростью, спасибо за репорт.
Если смотреть с этой стороны, то всякие новомодные border-radius и box-shadow грузят процессор не меньше. В целом — да, есть такая проблема и я о ней написал в конце. Она решаемая, но меня, в первую очередь, интересует это с точки зрения эксперимента. Потому что на подходе ещё несколько страниц с более тяжёлой графикой, но меньшими площадями «воздействия», там я тоже хочу попробовать применить такой же подход.
У вас получились грязные брызги вместо лёгкого шума. Сравните:

Я бы попробовал генерить шум на небольшом участке (скажем, 64×64), а потом циклически его повторять. Это если хочется именно генерить. Хотя задача, в самом деле, решается наложением png с чёрным полупрозрачным шумом.
Давид, попробуйте размножить кусок шума в обычном режиме наложения и посмотрите на результат. Вышу в комментариях уже есть пример.
Ну уменьшите прозрачность немного. Разница между Normal и Multiply, конечно, есть, но она больше вкусовая в данном случае.
Но даже если хочется именно Multiply, то всё равно можно здорово сэкономить, используя циклическую сгенерённую текстуру.
P. S. У меня в хроме тоже шум накладывается в самую последнюю очередь, после загрузки обложек.
Я специально вынес добавление шума на
onloadкартинки с кулисами, чтобы не блокировали отрисовку страницы. Всё-таки шум — это вспомогательный элемент, а не основной. В любом случае буду следить за тем, как развивается этот эксперимент.Cергей, в предыдущем сообщении я просто показал принцип.
Потратив 2 минуты на подбор нужных параметров фильтра, продублировав слой шума со смещением, подобрав прозрачность, можно добиться ускользающее малой разницы:
Картинку не пропустило:
Денис, а вы покажите пример целиком
Лежит по тому же адресу —
— в целом, готов признать, что разница не так сильно заметна по сравнению с оригиналом. Но лично у меня немного рябит в глазах не пересвеченых участках
Денис, я немного обновил статью, поставил ссылку на ваш пример.
Ну так нижняя картинка ваша
Возможно при использовании на сайте, когда над фоном выводится содержимое, его «пересвеченные участки» не так оттягивают на себя внимание.
А вообще это конечно же proof of concept, потратив еще немного времени можно избавиться от всех шероховатостей.
Хотя экперимент с реализацией режимов смешивания на js очень интересен, исходная задача решается куда более тривиально.
Нет, я имел в виду пример в целом. Скорее всего, рябит из-за повторяющейся текстуры, хотя конкретно в моём случае этого не было бы заметно.
Вот ты задрачиваешься. Крутота нечеловеческая, молодец
Хммм, странно видеть свой дизайн от чужих людей…
Год назад рисовал для одного театра (вариант принят не был).
Крепитесь! Здесь ваш дизайн уже воплощен в живой сайт: Можно подать в суд.
А здесь ваш дизайн можно скачать в формате psd. Они, сволочи, у вас исходники потырили. Готовьте иск – дело верное.
Хм… а вот эти, похоже, сами вас могут засудить Их-то работа датирована 2008 годом. Старше вашей. Что скажете в свое оправдание?
А вообще, сдается мне, что красные занавески – не ваше изобретение
st.free-lance.ru/users/ivadesign/upload/f_496c92374ee43.png
st.free-lance.ru/users/CheRya/upload/f_4bc58b6fe8d75.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 и не положить ее на картинку с занавесками? Сорри если уже было в комментах.