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