Quantcast
Channel: Сергей Чикуёнок »Работа с графикой
Viewing all articles
Browse latest Browse all 10

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

$
0
0

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

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

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

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


Viewing all articles
Browse latest Browse all 10

Latest Images

Trending Articles





Latest Images