August 15, 2023

Любимый DevLog #15.08

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

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

НЕТ

P.S. в сносках я буду оставлять технические комментарии, которые можно пролистывать, если хочется прочитать самую вкусную часть. И всё же советую читать их, чтобы оставаться в контексте повествования

Часть 1. DOM, который построил Джек

Как только я закончил свой макет, мне сразу же захотелось освежить память фронтендера, и конечно же, выбор пал на React

Q: Почему React, а не Vue / Angular / d3 (библиотека для графиков и визуализации) / чистый JS и canvas?
A: По иронии судьбы, именно тот референс, который я взял за основу интерфейса моего приложения, был демонстрацией работы библиотеки для JS с построением очень крутых блок схем / конструкторов, что дало мне уверенность, что я смогу сделать фронтенд супер быстро. Да и потом, React был мне знаком и обладает максимальным объемом информации в интернете и гайдов.
Та самая либа

Молниеносно скачав данную библиотеку, я попробовал её на прочность, наспавнив порядка 1000 элементов, сразу получил микрофризы и падение до 30-40 FPS.

Оптимизация при визуализации — ключевой аспект хорошего User Experience (UX). В случае библиотек, основанных на HTML элементах при работе с графикой, т.е. с деревом элементов или DOM, мы получим очень слабые показатели производительности. Всё дело в том, что сама по себе Web страница использует довольно большой объем абстракций, чтобы разработка обычных сайтов была комфортной (работа со стилями CSS, eventListeners, множество разнообразных свойств, даже селекторы (getElementById / querySelector ...) - всё это нехило коптит ваш процессор, а вследствие и снижает FPS.

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

При этом, существует технология Canvas и WebGL, которая широко используется в браузерных играх и графических приложениях (например diep.io, agar.io, Photoshop Online, Miro, Figma и т.д.) Оптимизация происходит за счёт низкоуровневого API, которое позволяет управлять нашим "холстом" вплоть до каждого пикселя, при этом совершая минимальную нагрузку на процессор. Об этих технологиях поговорим ещё далее

Красота требует жертв, поэтому первым делом выбрасываем DOM на помойку. Конечно же не полностью, так как canvas сам по себе является DOM элементом, но всё что будем рисовать на нём — упрощённо говоря, растровые изображения, будь то стрелочки или фото.

В нативной имплементации он не содержит функциональности для управления событиями будь то клик по элементу, перетаскивание, анимации и т.д. Конечно, с использованием библиотеки это упрощается и становится во многом приближенном к DOM, но всё же прирост производительности огромный
10000 элементов в браузере, при 120 FPS (WebGL Canvas, no hittest). Мечта любого разработчика

Всю дальнейшую разработку я вёл с помощью библиотеки PixiJS и @pixi/react , которые позволяют максимально приближенный экспириенс к DOM дереву

Это DOM, это JSX? Нет, это PixiJS! Ладно, это есть и JSX, и магия React

Часть 2. Анимируй это

Окей, элементы вроде бы создаются, но хочется добавить экшна, живости интерфейса

https://www.goodboydigital.com/pixijs/bunnymark/ интересный опыт потыкать данный benchmark, потому что 50 тысяч элементов без тормозов и с очень резвой анимацией - просто магия. Такого с использованием только DOM добиться невозможно

Анимации в WebGL и Canvas — штука занятная. Даже при использовании библиотек, которые призваны дать максимально высокую абстракцию при граф. разработке, мы всё еще не можем задавать анимации как в CSS. Чтобы анимировать элемент, в каждом кадре нужно обновлять свойства нашего элемента (например менять его позицию по оси X или Y, цвет, длину и ширину)
Пример анимации. app.ticker.add в коде снизу каждый кадр меняет позицию контейнера, из за чего получаем такой эффект вращения

В PixiJS есть несколько возможностей для отрисовки графики. Изначально я хотел сделать все вещи на просто Graphics элементе, который оперирует набором команд по заливке. Например

let obj = new PIXI.Graphics();

obj.beginFill(0xff0000);
obj.drawRect(0, 0, 200, 100);

// Нарисует квадрат на координатах x=0, y=0
// с шириной и высотой 200 и 100 пикселей
app.stage.addChild(obj);

Но вскоре я понял, что это очень непроизводительно, потому что сами по себе операции Graphics нагружают процессор сильнее, чем GPU. Следовательно, нужно предварительно подготовить набор картинок (спрайтов) и отрисовывать их, как будто бы я его отрисовал налету. P.S. вскоре я понял, что спрайты оказывается ещё и менее пикселизованные

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

  • Background спрайт. Отвечает за контент ноды (например, фото)
  • Hover спрайт. Отвечает за анимацию при наведении указателя (яркий фиолетовый цвет)
  • Click спрайт. Отвечает за анимацию при клике, зеленый цвет
  • Interactive спрайт. Отвечает за ивенты и делегирует анимацию соответствующим спрайтам (например при ивенте onpointerover заставляет светиться Hover спрайт)
К слову, анимация происходит за счёт изменения показателя alpha у спрайта, то есть непрозрачности. При наведении мышью этот показатель плавно бежит от 0 до 1, например 0.1, 0.1245, 0.351, 0.45 и т.д. Это позволяет создать эффект плавности как в CSS. Для плавного увеличения показателя на видео я использовал react-spring. Позже я её заменю на более удобное решение

Часть 3. Королевство кривых

Для следующей вехи развития мне потребовалось углубиться в алгебру и геометрию, и даже чуть чуть увело в нейросети (но об этом расскажу как нибудь в другой раз), а именно в Кривые Безье

Кривые Безье — типы кривых, предложенные в 60-х годах XX века независимо друг от друга Пьером Безье из автомобилестроительной компании «Рено» и Полем де Кастельжо из компании «Ситроен», где применялись для проектирования кузовов автомобилей
Если интересно послушать про кривые Безье подробнее, советую данный материал и в целом у девушки много приятных видео
Еще немного кривых

Так вот, суть в том, что стрелки на моём макете, соединяющие ноды между собой, могут быть описаны в математике именно этими Кривыми. Они так же используются в графических программах, вроде Иллюстратора, и в целом инструмент "Перо" - это вот про это :)

Для их описания на канвасе я написал довольно большой код, в котором мы вычисляем набор точек (на каждую стрелку их всего около 30 штук), после чего строим простую геометрию с помощью lineTo (к этому моменту мы ещё вернёмся)

Вычисление точек

И по итогу получаем такую прекрасную картину

Если бы эти ноды были людьми - вероятно мы бы наблюдали набор кружков по интересам: связей между нодами очень мало
А здесь мы видим, что каждая нода соединена с ближайшей к ней. Напоминает какую то биологическую структуру клеток
Связей стало чуть меньше

Все эти линии можно контролировать через ограничения близости двух нод и шансом этим связям заспавниться (в нашем случае только в одном из 10)

Ошибки - последствия жесткого рефакторинга
К слову, все эти вещи у меня получилось изучить на KhanAcademy. При том, что сервис бесплатный, у него афигенный UX, который поможет полюбить математику, в отличие от того как нас учили в школе
Пример задачи на Khanacademy. Нужно передвинуть точки так, чтобы график соответствовал уравнению. Не нужны тетради и книги

Часть 4. Поймай меня, если сможешь

С момента худо-бедной реализации стрелочек, я решил перейти к перемещению элементов, то есть drag&drop.

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

Суть анимации примерно та же: Ticker PixiJS принимает в метод add колбэк, который смотрит текущее положение курсора и выставляет его в позицию спрайтов.

Здесь пришлось пошаманить сначала с z-индексом активных (dragging) нод, чтобы при пересечении мышью вышестоящей ноды наш ивент не сбрасывался (появлялся довольно неприятный баг, который рывками тянул ноду и приходилось возвращать курсор в последнее положение ноды

Так же сначала использовал useState, что очень негативно сказывалось на производительности, от чего потом отказался. Спойлер: стало даже удобнее

Часть 5. Начало оптимизации

Фундаментальная мантра: работает — не трожь. Не нужно оптимизировать то, что работает хорошо.

Что у нас работает плохо:

  • На каждую ноду приходится по 4 спрайта. Следовательно, отрисовывая, казалось бы, 1000 элементов, получаем минимум 4000
  • Анимации используют useState, а не нативный API PixiJS, что заставляет каждый раз ре-рендерить наш компонент
  • Ужасная компоновка кода и нету консистентности данных
  • Стрелки используют графику, хотя разумнее использовать спрайты. НО как?

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

Со второй проблемой пришлось чуть повозиться, потому что с обилием либ для анимации, не до конца понимаешь как этим всем пользоваться.

По итогу остановился на либе gsap, которая удачно легла в мой флоу, потому что поддерживает аддон для pixijs и позволяет модифицировать поля напрямую без стейтов и ререндеринга

Третью проблему решал комплексно.

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

Абстрактно получилось вот так

Здесь видим, что Idle - начальное состояние ноды. Затем она может перейти в состояние наведения курсором (Hover), от него в состояние Dragging и только потом в Clicked. При этом, мы не можем перейти в Clicked из Idle или Hovered, в этом заключается красота машин состояний (в нашем случае конечный автомат), что один и тот же ивент может влиять на стейт по разному

Реализация
Ивенты отвечают только за команды, а не за стейт
Сама машина состояний (и их переходов)

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

Часть 6. Boss of this gym

Промежуточный результат, и это, естесственно, не конец. Предстоит еще много работы с фото, viewport (отдалением и приближением камеры), force (когда один элемент тянется от силы другого), добавлением в друзья и т.д.

Но могу сказать одно: графика - это интересно, и погружаясь в эту тему вы становитесь настоящим гигачедом программирования, просто лордом качалки. До скорого 👋