Архив за Август 2011

  • 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 МБ, так что я не стал сохранять её — доступное пространство можно и нужно использовать с большей пользой.

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

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

    Метки: , ,