Если вы еще не в курсе, посетите сайт соревнования по JS1K демо. В конкурсе победит разработчик, создавший самое лучшее демо, вместив его в 1 килобайт JavaScript кода. Конечно же, я не смог устоять против соблазна попробовать сделать нечто подобное самому, так что я достал свой набор хитростей с проекта по визуализации музыки в Winamp, и начал писать код. И я остался очень доволен результатом. И нет, эта вещь не будет работать на Internet Explorer.
Исходная версия
ОстановитьИсходный код
Я только переписал код демо, добавил объемные лучи света, и по-прежнему вложился в 1К кода:
Новая улучшенная версия
ОстановитьИсходный код
(музыка не является частью демки, но так смотреть приятнее)
Итак, когда размер имеет значение, лучший способ сделать программу маленькой – генерировать необходимые данные с помощью процедур. Такой способ позволяет значительно экономить на объеме кода. С первого взгляда задача кажется сложной и почти невозможной, однако на деле все сводится к умному использованию математики (из высшей школы). И, как это часто бывает, самые лучшие фокусы – самые простые, поскольку описываются минимальным количеством кода.
Чтобы продемонстрировать все вышесказанное на деле, я разложу по полочкам мое демо и покажу все главные моменты и хитрости, которые я задействовал в нем. В отличие от исходного кода моего однокилобайтного демо, представленные здесь фрагменты будут отформатированы более свободно, имена переменных будут отражать их назначение.
Инициализация
Правила JS1K позволяют использовать тег Canvas, так что первый фрагмент кода инициализирует его и производит заливку окна.
Далее просто формируются фреймы демо. Этот процесс состоит из четырех основных частей:
Анимация проводков
Вращение проводков и проекция согласно положению камеры
Закраска проводков
Движение камеры
Все эти операции повторяются 30 раз в секунду, с помощью таймера
view plaincopy to clipboardprint?
setInterval:setInterval(function () { ... }, 33);
Рисуем проводки
Самый заметный фокус из используемых здесь состоит в том, что все демо рисуется одним единственным примитивом с использованием двух переменных: отрезок прямой переменного цвета и ширина штриха. Такой способ сводит весь процесс рисования к двум компактным, вложенным циклам. Каждая внутренняя итерация рисует новый отрезок проводка с того места, где кончился предыдущий, а внешняя итерация отвечает за последовательную дорисовку разных проводков.
Вдобавок линии переплетаются друг с другом с помощью встроенного режима наложения 'lighten', который позволяет рисовать линии проводков в любом направлении. Такой режим позволяет избежать ручной сортировки порядка изображения проводков.
Чтобы упростить преобразования связанные с перспективой, я использую систему координат, в которой точка (0, 0) помещена в центр картинки и колеблется от -1 до 1 в обоих направлениях. Это компактный и удобный способ решения проблемы разных размеров окна без лишнего кода:
view plaincopy to clipboardprint?
with (graphics) {
ratio = width / height;
globalCompositeOperation = 'lighter';
scale(width / 2 / ratio, height / 2);
translate(ratio, 1);
lineWidthFactor = 45 / height;
...
Я также добавляю корректировку пропорций (ratio) для неквадратных окон и вычисляю ширину оси координат с помощью lineWidthFactor на потом.Далее идет два вложенных цикла for: один проходится по всем проводам, второй – отвечает за построение каждого отдельного проводка. Псевдо-код этих циклов имеет вид:
view plaincopy to clipboardprint?
For (12 wires => wireIndex) {
Начинаем новый проводок
For (45 точек вдоль каждого проводка => pointIndex) {
Вычисляем путь точки в пространстве: (x,y,z)
Двигаем виток наружу: (x,y,z)
Двигаем/Вращаем согласно углу камеры: (x2,y2,z2) - (x3,y3,z3)
Проектируем в 2D: (x,y)
Определяем цвет, ширину и яркость этого сегмента: (r,g,b)
If (эта точка находится перед камерой) {
If (последняя точка была видна){
Нарисовать отрезок прямой, начиная с последней точки и до (x,y)
}
}
else {
Пометить эту точку как невидимую
}
Зафиксировать начало нового отрезка на (x,y)
}}
Математические извороты
Для генерации проводков, я начну с формулы, которая формирует извилистую линию на сфере, с помощью широты/долготы (latitude/longitude). Выглядит это так:
view plaincopy to clipboardprint?
offset = time - pointIndex * 0.03 - wireIndex * 3;
longitude = cos(offset + sin(offset * 0.31)) * 2
+ sin(offset * 0.83) * 3 + offset * 0.02;
latitude = sin(offset * 0.7) - cos(3 + offset * 0.23) * 3;
Это классика процедурного кодинга в своем лучшем виде: берем зависимую от времени переменную offset и произвольно изменяем ее с помощью таких общедоступных функций, как косинус и синус. Экспериментируйте с ней, пока не получите то, что надо. Это очень простой способ создания интересных, выглядящих естественно образов.
Но, так как это определенно больше искусство, чем наука, предлагаю пару способов украсить подобные конструкции, сделать их более привлекательными.
Во-первых, всегда включайте в расчеты какие-то нелинейные комбинации переменных, таких как sin() внутри cos(). Этот простой прием экспоненциально увеличивает сложность каждого компонента изображения. В нашем случае, с помощью него регулярные колебания превращаются в зависящие от времени частоты.
Во-вторых, всегда масштабируйте периоды колебаний, используя простые числа. Так как простые числа не имеют общих множителей, их использование предотвращает возникновение точного повторения всех отдельных периодов. Математически, наименьшее общее кратное выбранных периодов просто огромно (414253 пунктов ~ 4.8 часа). Начертив график долготы/широты для offset = 0..600, вы получите:
График выглядит как произвольно спутанная кривая, без видимой структуры, что создает видимость никогда не повторяющихся движений. Однако если вы уменьшите каждую константу до одной значащей цифры (к примеру, 0.31 -> 0.3, 0.83 -> 0.8), случайные повторения уже будут выглядеть не такими случайными:
Это потому, что наименьшее общее кратное уменьшилось до 84 пунктов ~ 3.5 секунды. Учтите, что обе формулы одинаковы по сложности кода, но дают совершенно разные результаты.
Экструзия
Имея заданные концы каждого из проводков, я могу сгенерировать оставшуюся часть проводка, просто размахивая его хвостом позади него, с задержкой во времени. Вот почему pointIndex стоит со знаком «-» в формуле, приведенной выше. В то же время я двигаю точки к наружному краю, чтобы сформировать длинные хвосты проводков.
Мне также надо перейти от широты/долготы к стандартному трехмерному пространству XYZ, что реализуется с помощью преобразований в сферических координатах:
view plaincopy to clipboardprint?
distance = f.sqrt(pointIndex+.2);
x = cos(longitude) * cos(latitude) * distance;
y = sin(longitude) * cos(latitude) * distance;
z = sin(latitude) * distance;
Вы наверняка заметили, что вместо того, чтобы сделать distance линейной функцией от pointIndex, я применил квадратный корень. Это еще один простой процедурный трюк, выполняющий важную визуальную функцию. Вот как выглядит квадратный корень (сплошная прямая):
Пунктирная прямая отображает производную от квадратного корня, то есть определяет наклон сплошной кривой. Поскольку наклон уменьшается с увеличением расстояния, этот прием уменьшает движение проводка наружу: чем дальше заходит его конец, тем медленнее движется сам проводок. На практике этот эффект работает так: проводки более подвижны в центре и более вялые снаружи. Так визуальный эффект становится более привлекательным.
Вращение и проекция
Когда у меня появились абсолютные трехмерные координаты для точки проводка, мне нужно интерпретировать ее в соответствии с положением камеры. Это реализуется путем перемещения начала в точку положения камеры (X,Y,Z), и применением двух ротаций: одна по вертикали (yaw) и вторая по горизонтали (pitch):
view plaincopy to clipboardprint?
x -= X; y -= Y; z -= Z;
x2 = x * cos(yaw) + z * sin(yaw);
y2 = y;
z2 = z * cos(yaw) - x * sin(yaw);
x3 = x2;
y3 = y2 * cos(pitch) + z2 * sin(pitch);
z3 = z2 * cos(pitch) - y2 * sin(pitch);
Координаты относительно камеры проектируются в перспективе путем деления на Z — чем дальше объект, тем он меньше. Отрезки линий с отрицательной координатой Z расположены за камерой и не должны прорисовываться. Ширина линий также масштабируется пропорционально расстоянию от камеры, и первый отрезок каждого проводка рисуются толще. Выглядит все это следующим образом:
view plaincopy to clipboardprint?
plug = !pointIndex;
lineWidth = lineWidthFactor * (2 + plug) / z3;
x = x3 / z3;
y = y3 / z3;
lineTo(x, y);
if (z3 > 0.1) {
if (lastPointVisible) {
stroke();
}
else {
lastPointVisible = true;
}
}
else {
lastPointVisible = false;
}
beginPath();
moveTo(x, y);
Окрашивание
Каждый отрезок проводков требует соответствующего окраса. Методом проб и ошибок я таки отыскал простую формулу, которая делает то, что нам нужно. Они использует синусоидальную волну для плавной смены яркости R G B каналов попеременно (то в большую, то в меньшую стороны), а также медленно меняет R-компоненту с течением времени. В результате получается приятная, не перенасыщенная палитра.
view plaincopy to clipboardprint?
pulse = max(0, sin(time * 6 - pointIndex / 8) - 0.95) * 70;
luminance = round(45 - pointIndex) * (1 + plug + pulse);
strokeStyle='rgb(' +
round(luminance * (sin(plug + wireIndex + time * 0.15) + 1)) + ',' +
round(luminance * (plug + sin(wireIndex - 1) + 1)) + ',' +
round(luminance * (plug + sin(wireIndex - 1.3) + 1)) +
')';
В данном случае pulse вызывает яркие пульсации вдоль проводков. Я начал со стандартной синусоидальной волны по всей длине проводка, но отсек все, кроме 5% каждой верхушки, чтобы добиться разреженной последовательности импульсов:
Движение камеры
Когда основная часть визуального образа уже сделана, мой запас кода практически иссяк, осталось только немного места для камеры. Мне нужен простой способ создать видимость движения координат камеры X, Y и Z. Я прибег к простейшему техническому приему, используя для этой цели повторную интерполяцию. Вот как она выглядит:
view plaincopy to clipboardprint?
sample += (target - sample) * fraction
target задается произвольно. Далее, с каждым новым фреймом, sample плавно приближается к значению target благодаря умножению на дробь (к примеру, на 0.1). Данная операция превращает sample в сглаженную версию target. Технически, это однополюсный фильтр нижних частот.
Будет лучше, если дважды повторить эту операцию, так чтобы и промежуточное значение (intermediate) тоже интерполировалось:
view plaincopy to clipboardprint?
intermediate += (target - intermediate) * fraction
sample += (intermediate - sample) * fraction
Вот как кривые выглядят на графике:
Вы видите, что с каждой повторной интерполяцией разрывность между кривыми уменьшается. Сначала, скачки преобразуются в загибы. Затем уже загибы превращаются в мягкие выпуклости.
В моем демо этот принцип применяется отдельно для трех координат камеры. Каждые ~2.5 секунды выбирается новое целевое положение (target):
view plaincopy to clipboardprint?
if (frames++ > 70) {
Xt = random() * 18 - 9;
Yt = random() * 18 - 9;
Zt = random() * 18 - 9;
frames = 0;
}
function interpolate(a,b) {
return a + (b-a) * 0.04;
}
Xi = interpolate(Xi, Xt);
Yi = interpolate(Yi, Yt);
Zi = interpolate(Zi, Zt);
X = interpolate(X, Xi);
Y = interpolate(Y, Yi);
Z = interpolate(Z, Zi);
Результирующая траектория становится очень плавной и выглядит довольно динамично.
Вращение камеры
Последний этап – правильное ориентирование камеры. Проще всего будет поместить камеру прямо в центр объекта и вычислить значения вертикальной (yaw) и горизонтальной (pitch) ротаций непосредственно из позиции камеры (X,Y,Z):
view plaincopy to clipboardprint?
yaw = atan2(Z, -X);
pitch = atan2(Y, sqrt(X * X + Z * Z));
В таком случае демо получается чересчур статичным, выглядит очень неестественно. Лучше всего оставить камере немного свободного пространства для движения вокруг заданной позиции.
К сожалению, лимит в 1 килобайт слишком мал, и у меня не хватило места для парочки «волшебных» формул или интерполяций. Поэтому, я просто заменил вышеуказанные формулы на вот эти:
view plaincopy to clipboardprint?
yaw = atan2(Z, -X * 2);
pitch = atan2(Y * 2, sqrt(X * X + Z * Z));
С умнощением переменных на 2, формула становится «неправильно», выскакивают ошибки при значении в 45 градусов. Однако это ограничение не портит общей картины, а даже наоборот. В итоге, я заставил камеру двигаться более плавно и медленно, достигнув отличной динамики всего за 4 лишних байта!
Заключение
Я уже давно являюсь поклонником визуальных демо, с теплотой вспоминаю мое знакомство с этим жанром: в 1993 году я посмотрел легендарный ролик Second Reality, с этого все и началось. Именно математику я считаю тем инструментом, который обязательно необходимо освоить и активно использовать в создании визуальных образов.
Этим постом я надеялся вдохновить вас на поиски простых математических решений, которые дают удивительные результаты.