Посты с тэгом «canvas»

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

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

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

    Метки: , ,
  • Ambilight для тэга video

    В некоторых топовых моделях телевизоров Philips есть такая прикольная штука, как Ambilight. По сути, это светодиодная подсветка телевизора, которая меняет цвет в зависимости от цвета картинки. Смотреть кино на таком телевизоре — одно удовольствие.

    На флэше уже есть реализации такой подсветки, ну а чем мы — фронтовики — хуже? Дабы в очередной раз разобраться, на что способны современные браузеры, на свет появился очередной эксперимент:

    Ambilight для тэга <video> (Firefox 3.5, Opera 10.5, Safari 4, Google Chrome 4)

    Далее рассмотрим, как это было сделано.

    Алгоритм

    Прежде, чем начать что-то писать, нужно составить алгоритм, по которому будет работать наша подсветка.

    Настоящая подсветка в телевизоре работает примерно так. На задней панели располагается ряд ярких светодиодов, которые светятся разными цветами. Причём цвет диода примерно соответствует цвету области изображения, напротив которой он находится. Когда картинка меняется, светодиод плавно меняет свой цвет на другой.

    Исходя из этого описания, нам нужно проделать следующее: определить цвет каждого диода для текущего кадра и отрисовать его свечение. Что ж, приступим.

    Определяем цвет диода

    Для удобства предположим, что в нашем «телевизоре» всего по 5 светодиодов с каждой стороны. Соответственно, нужно взять фрагмент кадра, разделить его на области по количеству диодов и найти усреднённый цвет в каждой области — это и будут цвета подсветки:

    get-color

    Чтобы получить изображение текущего видео-кадра, достаточно отрисовать его в <canvas> через метод drawImage():

    var canvas = document.createElement('canvas'),
    	video = document.getElementsByTagName('video')[0],
    	ctx = canvas.getContext('2d');
    
    // обязательно выставляем размер холста
    canvas.width = video.width;
    canvas.height = video.height;
    
    // рисуем кадр
    ctx.drawImage(video, 0, 0, video.width, video.height);
    

    Текущий кадр получили, теперь нужно узнать, какого цвета пиксели сбоку изображения. Для этого воспользуемся методом getImageData():

    /** Ширина области, которую будем анализировать */
    var block_width = 50;
    
    var pixels = ctx.getImageData(0, 0, block_width, canvas.height);
    

    В объекте pixels есть свойство data, в котором содержатся цвета всех пикселей. Причём хранятся они в немного необычном формате: это массив RGBA-компонетнов всех пикселей. К примеру, чтобы узнать цвет и прозрачность первого пикселя, нужно взять первые 4 элемента массива data, второго пикселя — следующие 4 и так далее:

    var pixel1 = {
    	r: pixels.data[0],
    	g: pixels.data[1],
    	b: pixels.data[2],
    	a: pixels.data[3]
    };
    
    var pixel2 = {
    	r: pixels.data[4],
    	g: pixels.data[5],
    	b: pixels.data[6],
    	a: pixels.data[7]
    };
    

    Нам нужно разделить все полученные пиксели на 5 групп (по количеству светодиодов, которое мы выбрали ранее) и проанализировать каждую группу по очереди:

    function getMidColors() {
    	var width = canvas.width,
    		height = canvas.height,
    		lamps = 5, //количество светодиодов
    		block_width = 50, // ширина анализируемой области
    		block_height = Math.ceil(height / lamps), // высота анализируемого блока
    		pxl = block_width * block_height * 4, // сколько всего RGBA-компонентов в одной области
    		result = [],
    
    		img_data = ctx.getImageData(0, 0, block_width, h),
    		total = img_data.data.length;
    
    
    	for (var i = 0; i < lamps; i++) {
    		var from = i * width * block_width;
    		result.push( calcMidColor(img_data.data, i * pxl, Math.min((i + 1) * pxl, total_pixels - 1)) );
    	}
    
    	return result;
    }
    

    В этой функции мы просто пробегаемся по анализируемым блокам и считаем для них усреднённый цвет с помощью функции calcMidColor(). Нам не нужно применять всякие хитрые формулы, чтобы посчитать усреднённый цвет на области исходя из интенсивности цветов в ней, достаточно посчитать среднее арифметическое для каждого цветового компонента:

    function calcMidColor(data, from, to) {
    	var result = [0, 0, 0];
    	var total_pixels = (to - from) / 4;
    
    	for (var i = from; i <= to; i += 4) {
    		result[0] += data[i];
    		result[1] += data[i + 1];
    		result[2] += data[i + 2];
    	}
    
    	result[0] = Math.round(result[0] / total_pixels);
    	result[1] = Math.round(result[1] / total_pixels);
    	result[2] = Math.round(result[2] / total_pixels);
    
    	return result;
    }
    

    Итак, мы получили цвета для светодиодов, но они слишком тусклые: ведь диоды светят очень ярко чтобы добиться достаточного уровня свечения. Нужно увеличить яркость цветов, а также увеличить насыщенность, чтобы добавить глубины свечению. Для этих целей очень удобно пользоваться цветовой моделью HSV — hue, saturation, value, — достаточно домножить два последних компонента на некий коэффициент. Но цвета у нас хранятся в модели RGB, поэтому сначала конвертируем цвет в HSV, увеличиваем яркость и насыщенность, а затем обратно конвертируем в RGB (формулы конвертирования RGB→HSV и обратно легко находятся в интернетах):

    function adjustColor(color) {
    	color = rgb2hsv(color);
    	color[1] = Math.min(100, color[1] * 1.4); // насыщенность
    	color[2] = Math.min(100, color[2] * 2.7); // яркость
    	return hsv2rgb(color);
    }
    

    Рисуем свечение

    Светодиоды — это всенаправленные источники света. Для их отображения лучше всего подходят радиальные градиенты: для каждого диода свой градиент. Однако для достижения хорошего визуального результата придётся делать очень много сложных расчётов: нужно учитывать позицию диода, диаметр и затухание свечения, смешивание соседних цветов и так далее. Поэтому мы немного сжульничаем: нарисуем обычный — линейный — градиент, а сверху наложим специальную маску, которая создаст ощущение правдоподобного свечения.

    Градиент рисуется просто: сначала создаём его с помощью createLinearGradient(), а потом добавляем цвета через addColorStop() и отрисовываем его:

    // для свечения создаём новый холст
    var light_canvas = document.createElement('canvas'),
    	light_ctx = light_canvas.getContext('2d');
    
    light_canvas.width = 200;
    light_canvas.height = 200;
    
    var midcolors = getMidColors(), // полчаем усреднённые цвета
    
    	grd = ctx.createLinearGradient(0, 0, 0, canvas.height); // градиент
    
    for (var i = 0, il = midcolors.length; i < il; i++) {
    	grd.addColorStop(i / il, 'rgb(' + adjustColor(midcolors[i]).join(',') + ')');
    }
    
    // рисуем градиент
    light_ctx.fillStyle = grd;
    light_ctx.fillRect(0, 0, light_canvas.width, light_canvas.height);
    

    Получим что-то вроде этого:

    gradient

    Маска

    Маску мы нарисуем в фотошопе. Есть замечательный фильтр Lightning Effects (Filters→Render→ Lightning Effects...), который позволяет создавать источники света. Заливаем слой белым цветом и вызываем этот фильтр примерно с такими настройками:

    lightning

    Получим вот такое световое пятно:

    spot

    Меняем режим наложения на Lighten, дублируем, крутим, меняем масштаб, играемся с прозрачностью, правим уровни и получаем вот такой результат:
    spot-grid

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

    result

    Но самое главное — мы легко сможем менять внешний вид и интенсивность свечения, не прибегая к программированию.

    Свечение для левой стороны готово, осталось проделать то же самое для правой стороны, добавить плавную смену подсветок и написать контроллер, который с определённым интервалом будет эту подсветку обновлять. Расписывать это — долго и нудно, проще посмотреть исходник.

    UPD: как показал эксперимент, далеко не у всех нормально работает HD-видео (изначально размер ролика был 1280×544), снижение разрешения до 592×256 решило проблему.

    Метки: , , ,
  • Как создавался WebWeb

    В пятницу, 7 августа, состоялось открытие художественного проекта WebWeb, в котором я принимал участие. Не спрашивайте меня, что это все означает, я и сам не понимаю; это такое высокое искусство. Для меня самым интересным разделом был Death: если подвигать курсором влево-вправо, то можно увидеть, как по лошади ползет тень, описывая ее контуры.

    death

    В этом посте я расскажу, как сделать еще один плагин к jQuery как создавалась техническая часть этой страницы.

    Макет от дизайнера

    Дизайнер прислал мне макет, в котором он нарисовал 33 состояния тени.

    step1

    В принципе, его устраивало, чтобы тень менялась дискретно, то есть можно было просто показывать 33 картинки в зависимости от позиции курсора и не париться. Но меня этот вариант не нравится, так как в нем явно не хватало секса. Поэтому было решено сделать плавное перемещение тени.

    Смотрим внимательно на саму тень. По сути, это две пересеченные линии, одна из них — перекладина — простая, как пять копеек, а вторую можно представить как обычную кривую, отрисованную контуром (stroke). Решение этой задачи будет выглядеть следующим образом: создаем кривые, которые описывают каждое состояние тени…

    step2-1

    …а затем будем рассчитывать между координатами точек новую линию, в зависимости от позиции курсора.

    step2-2

    Важно помнить, что набор точек должен быть одним и тем же (то есть в одной линии не может быть больше или меньше точек, чем в другой). Я делал так: отрисовал первую тень, продублировал путь (path) и передвинул точки в другое положение. И так еще 31 раз.

    Хранение кривых внутри JavaScript

    Линии отрисовали, теперь нужно придумать, как будем их хранить и использовать в JS. У нас возможны два типа линий: обычная, прямая линия и кривая Безье. Сам контур тени представляет собой массив таких линий. Для удобства и краткости записи создаются две прокси-функции, которые просто будут возвращать объект с координатами:

    /**
     * Точка
     */
    function p(x, y) {
    	return {x: x, y: y};
    }
    
    /**
     * Кривая Безье
     */
    function bz(cp1x, cp1y, cp2x, cp2y, x, y) {
    	return {
    		cp1x: cp1x,
    		cp1y: cp1y,
    		cp2x: cp2x,
    		cp2y: cp2y,
    		x: x,
    		y: y
    	};
    }
    
    // пример использования
    var line = [p(381, 289), p(427.33, 254), bz(427, 254, 427, 244, 433, 237)];
    

    Отлично, с форматом разобрались. Но есть один нюанс: помимо координат контура нужно хранить и угол наклона линии, чтобы можно было рассчитывать новую, динамическую линию между двумя заданными. Поэтому создаем еще одну прокси-функцию, которая будет создавать объект линии по заданным координатам.

    var shadow = {
    	min: Number.POSITIVE_INFINITY,
    	max: Number.NEGATIVE_INFINITY,
    	lines: {},
    	degs: []
    };
    
    /**
     * Добавляет линию, описывающую тень
     * @param Массив точек
     */
    function addLine() {
    	// преобразуем объект аргументов в настоящий массив
    	var points = Array.prototype.slice.call(arguments, 0);
    
    	/** Первая точка линии */
    	var first = points[0],
    		/** Последняя точка линии */
    		last = points[points.length - 1];
    
    	// считаем угол наклона линии
    	var deg = -Math.atan2(last.y - first.y, last.x - first.x) / Math.PI * 180;
    
    	shadow.degs.push(deg);
    	shadow.lines[deg] = points;
    	if (deg < shadow.min)
    		shadow.min = deg;
    
    	if (deg > shadow.max)
    		shadow.max = deg;
    }
    

    Как видите, я использую массив углов линий shadow.degs и хэш самих линий shadow.lines, где ключом является угол наклона. Все это нужно для того, чтобы быстро находить две соседние линии. Дополнительно я считаю минимальный и максимальный угол чтобы знать, в каких пределах производить расчеты.

    Экспорт данных из фотошопа

    На первый взгяд, это самая рутинная и скучная часть работы: все 33 контура тени теперь нужно перенести из фотошопа в JS, причем в том формате, который мы выбрали. Решение «в лоб» — наводить курсор на каждую точку кривой, запомнить координаты и записать их в JS. Потратил час только на то, чтобы таким образом записать 3–4 линии (и те с ошибками). Так дело не пойдет, нужно как-то автоматизировать этот процесс.

    Я помню, что в адобовском иллюстраторе есть опция экспорта кривых в SVG, а у фотошопа есть функция экспорта кривых в иллюстратор (File → Export → Paths to Illustrator). Что ж, это уже что-то. Так как иллюстратора у меня нет, экспортированный файл высылаю дизайнеру и прошу его пересохранить в SVG.

    Но все равно SVG не самый удобный формат для моей задачи. Во-первых, у его Path Data есть два типа данных — абсолютные и относительные координаты. Например, запись l10,20 означает смещение на 10 пикселей по горизонтали и 20 пикселей по вертикали относительно предыдущей точки, а запись L10,20 уже означает смещение в координату x=10, y=20. Во-вторых, в SVG присутствует короткая запись однотипных инструкций: например, L10,20 L30,40 L0,60 можно записать как L10,20,30,40,0,60 и иллюстратор любит этим пользоваться. В общем, парсинг из SVG далеко не тривиальная задача.

    Оказывается, формат .ai вполне себе текстовый. Более того, координаты кривых там записываются в двольно простом формате и это именно то, что мне нужно.

    ...
    %Adobe_Photoshop_Path_Begin:<< /defaultFill false >>
    *u
    %AI3_Note:<< /operation /xor /defaultFill false >>
    1 XR
    551.5000 248.5000 m
    596.0000 283.5000 L
    601.0000 285.5000 595.5000 299.5000 607.0000 303.0000 C
    616.0000 309.0000 626.0000 317.5000 634.0000 323.5000 c
    642.0000 329.5000 662.5000 339.0000 670.0000 345.5000 c
    677.5000 352.0000 690.5000 376.5000 699.0000 384.5000 C
    711.0000 398.5000 719.0000 379.5000 y
    854.5000 487.0000 l
    N
    *U
    %Adobe_Photoshop_Path_End
    ...
    

    Пишем простенький парсер, которые преобразует набор координат в наш формат (кому интересно, вот он) и уже через полчаса у меня появился набор всех контуров.

    Вспомнить школу

    Настало время приступить к программированию самой логики. Алгоритм работы будет следующим: во время движения мышки рассчитываем угол наклона линии, соединяющей курсор и начальную точку (в нашем случае это столб), добавляем к этому углу 180˚ и получаем угол наклона тени. Затем находим два статических контура, между которыми находится полученный угол, строим новый контур и отрисовываем его. Все просто, не так ли? :)

    step3

    Прежде, чем начать, нам нужно вспомнить школьный курс математики и геометрии. У большинства моих преподавателей было типичное совковое мировоззрение: задавая вопрос «зачем мне все это нужно?» я получал ответ вроде «это нужно для поступления в ВУЗ», а о способе применения этих знаний в жизни тактично умалчивали. Если б я знал, что буду заниматься программированием анимации и графики, я бы совсем иначе относился к учебе. Так что если вы еще учитесь — наматывайте на ус и не повторяйте моих ошибок :)

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

    graph

    Как видите, одна и та же линия и ее угол наклона рассчитывается по-разному в декартовой и компьютерной системе координат.

    При движении мышки мы знаем ее X и Y координаты, соответственно, можем рассчитать и угол наклона линии, соединяющей курсор и начальную точку, через арктангенс. Затем просто переводим этот угол в другую систему координат с помощью поворота (не претендую на изящность решения, так как всю геометрию и алгебру забыл):

    // считаем угол наклона линии  от курсора до начальной точки
    var cursor_deg = Math.atan2(y, x) + Math.PI / 4 * 3;
    // считаем угол линии контура тени
    var line_deg = 315 - cursor_deg / Math.PI * 180;
    

    Следует помнить, что все вычисления с углами через Math.xxx функции должны вестись в радианах, а не углах.

    Итак, нужный угол мы получили, теперь осталось на основе него построить контур тени. Как указано выше, алгоритм следующий: находим два статических контура, между которыми располагается искомый контур, и считаем новый. Расчет ведется следующим образом: берется отрезок, соединяющий две одинаковые точки, и его длина умножается на некий коэффициент. Этот коэффициент рассчитывается исходя из близости искомого контура к одному из статических. Например, если искомый контур находится ровно посередине, то коэффициент будет 0.5, если ближе к первому, то, например 0.2 и так делее. На практике все довольно просто:

    /**
     * Высчитывает параметры контура для указанного угла
     * @param {Number} deg
     * @return {Array}
     */
    function calculateLine(deg) {
    	// угол должен быть не меньше минимального и не больше максимального
    	deg = Math.min(shadow.max, Math.max(shadow.min, deg));
    
    	var result = [], d1, d2, coeff;
    	for (var i = 1; i < shadow.degs.length; i++) {
    		d1 = shadow.degs[i - 1];
    		d2 = shadow.degs[i];
    
    		if (deg >= d1 && deg <= d2) {
    			// нашли нужный диапазон, считаем коэффициент
    			coeff = 1 - (deg - d1) / (d2 - d1);
    			break;
    		}
    	}
    
    	if (coeff !== null) {
    		var l1 = shadow.lines[d1],
    			l2 = shadow.lines[d2];
    
    		// считаем координаты нового контура
    		for (var j = 0; j < l1.length; j++) {
    			var p1 = l1[j],
    				p2 = l2[j],
    				point = {};
    			for (var p in p1) if (p1.hasOwnProperty(p)) {
    				point[p] = p2[p] + (p1[p] - p2[p]) * coeff;
    			}
    
    			result.push(point);
    		}
    	}
    
    	return result;
    }
    

    Начинаем рисовать

    Основные элементы логики описаны, осталось собрать их вместе. Для рисования я буду использовать Canvas, для IE, соответственно, VML с помощью прослойки excanvas. Пишем промежуточный класс CanvasAdapter, который будет рисовать графику по выбранному ранее формату записи контуров, пишем вспомогательные функции, отслеживающие перемещение курсора и изменения размера экрана (чтобы не тратить лишний раз ресурсы на расчет координат начальной точки контуров) и проверяем:

    step41

    Вполне неплохо. Обращаем внимание на то, что контур тени — так как это обычная кривая — неестественно смотрится на ягодицах лошади:

    step5

    Нужно отрезать выступающий кусок контура. Для решения этой задачи на потребуется создать конструкцию из трех слоев. Первый слой — рисуем прямую линию от начала и до конца тени, как будто лошади вообще нет и у нас голый пол. Второй слой — сама лошадь в PNG, она будет скрывать ту часть прямой тени, которая находится под лошадью. И, наконец, третий слой — полноценный контур тени, на который наложена маска. То есть получается два canvas-элемента, между которыми располагается картинка.

    Помним, что у нас есть замечательный конвертер, который может из ai-формата сделать наш внутренний формат, поэтому в фотошопе быстренько рисуем маску и экспортируем ее в JS. Особая точность маске не нужна, у меня она получилась вот такой:

    step6

    Собираем все вместе, смотрим:

    step7

    Осталось совсем немного: отрезать верхушку тени, чтобы она смотрелась естественно. Можно воспользоваться методом canvas.clearRect(), однако в IE, из-за ограниченной поддержки excanvas, это не сработает. Поэтому размещаем сверху <div> с белым фоном, который будет скрывать ненужную часть. Просто и эффективно.

    Рисуем перекладину

    Последняя часть марлезонского балета — нарисовать перекладину. Она представляет из себя обычный прямоугольник, который должен правильно искажаться в зависимости от рассчитанного угла наклона тени. Решаем задачу следующим образом: находим пересечение текущего контура тени (достаточно прямой линии, кривые Безье не нужны) и горизонтальной линии перекладины, искажаем текущее координатное пространство матричным преобразованием и рисуем прямоугольник:

    var intersection = lineIntersection(0, line_y, 1000, line_y, last_point.x, last_point.y, first_point.x, first_point.y);
    canvas.transform(1, 0, (line_deg - 90) / 50, 1, intersection.x - 125, intersection.y);
    canvas.fillRect(0, 0, 250, 10);
    

    Вот и все. Делаем окончательный тюнинг, чтобы все работало гладко и смотрим на окончательный результат. Осталось добавить, что excanvas не поддерживает и маски, поэтому тень в IE смотрится не очень красиво, но для проекта это не критично. Интересующиеся могут исследовать исходный код страницы, чтобы увидеть все детали и жуткий баг Оперы, связанный с использованием масок в canvas.

    Метки: , , ,
  • Для вдохновения

    Покопался тут в своих старых проектах, нашел некоторые демки, которые я делал для разных конференций и проектов. Может, кому пригодится. Сразу скажу: большая часть из них не работает в IE, некоторые глючат во всех остальных браузерах. Все представленные демки — не более чем разминка для решения более сложных задач.

    • Стиралка — водим курсором по картинке, стирая защитный слой (HTML, JS, CSS).
    • Экскаватор — Для конференции РИТ-2008. Полностью программная анимация, использовались принципы инверсной кинематики (Canvas, JS).
    • Инфоскроллер — Реализация инфоскроллера Артема Горбунова на JS: анализирует текущий документ и строит из него упрощенную схему для скроллера. Делался в качестве домашнего задания для технологов Студии Лебедева (я, кстати, был той сволочью, которая их раздавала). Руки не дошли довести его до нормального состояния.
    • Волшебные пузырьки — Демонстрация способа имитации масок не прямоугольной формы (HTML, CSS, JS). Эта демка использовалась разработчиками IE8 для тюнинга производительности.
    • Спрей — Для конференции РИТ-2008. Простенькая анимация цвета и движения объектов вдоль кривой Безье (JS, jTweener).
    • Переливающиеся слова — Для конференции РИТ-2008. Тоже простая анимация: в основном демонстрируется способ у обычного текста выхватить отдельные слова и анимировать их (HTML, CSS, JS). Кстати, повторить это на флэше гораздо сложнее :). Особенно с учетом того, что текст растягивается и можно менять размеры шрифта.
    Метки: , , , ,
  • rocon — подробности

    Рассмотрим, как работает библиотека rocon.

    rocon дожидается загрузки DOM-дерева документа и ищет все элементы с классом rcN, где N — радиус скругления, и добавляет им скругленные уголки.

    Safari

    Создается новая новая таблица стилей в документе, в которой классам rcN указываются CSS-свойства -webkit-border-radius и -khtml-border-radius.

    Firefox

    По аналогии с Safari указывается CSS-свойство -moz-border-radius.

    Opera

    Через <canvas> рисуется звездочка, которая затем преобразуется в data: URL картинку. Затем для каждого уголка создается по отдельному элементу и каждому указывается эта картинка в качестве фона. Все CSS-свойства указываются в таблице стилей, а не в самих элементах (это значит, что их можно легко перекрывать).

    Internet Explorer

    Такой же способ, как и у Opera, только используется VML. Соответственно, уголок вставляется как элемент, а не как фоновая картинка.

    С первыми двумя случаями все довольно просто: используем внутренние возможности браузера для рисования скругленных уголков. Для двух последних все намного сложнее. После инициализации основному блоку указывается класс rocon-init — это значит, что элемент был инициализирован. Также добавляется класс rocon__N, где N — некий порядковый номер, характеризующий набор уникальных свойств блока: радиус скругления, цвет фона, толщина бордюра и так далее. Соответственно, если попадаются элементы с одинаковым набором этих характеристик, новые уголки не рисуются, а достаются из кэша. Для каждого создаваемого элемента с уголком присваиваются классы rocon-tl, rocon-tr, rocon-bl, rocon-br, что означает, соответственно, левый верхний, правый верхний, левый нижний и правый нижний. Все стили записываются в новую таблицу стилей, а не в сам элемент. Тем самым вы можете менять некоторые свойства уголка через CSS, что будет особенно полезно в IE6. Дело в том, что в этом браузере неправильно работают CSS-свойства right и bottom. И если первое свойство я смог эмулировать с помощью margin-left: 100%, то со вторым проблема: я пока не знаю, как это можно исправить. Поэтому вам придется указывать вручную его позицию. Так как уголок добавляется в самый конец блока, вы можете воспользоваться тем, что его сместит контент блока в самый низ и подкорректировать его позицию с помощью свойства margin-top (на забудьте поставить bottom: auto).

    Можно в любой момент добавить любому блоку скругление или поменять его свойства (радиус, цвет фона и так далее). Чтобы изменения вступили в силу, нужно вызвать метод rocon.update(elem), где elem — указатель на элемент, который нужно обновить.

    Все пожелания и комментарии просьба указывать в багтрекере.

    Метки: , ,