Любимый 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, но всё же прирост производительности огромный
Всю дальнейшую разработку я вёл с помощью библиотеки PixiJS и @pixi/react , которые позволяют максимально приближенный экспириенс к DOM дереву
Часть 2. Анимируй это
Окей, элементы вроде бы создаются, но хочется добавить экшна, живости интерфейса
https://www.goodboydigital.com/pixijs/bunnymark/ интересный опыт потыкать данный benchmark, потому что 50 тысяч элементов без тормозов и с очень резвой анимацией - просто магия. Такого с использованием только DOM добиться невозможно
Анимации в WebGL и Canvas — штука занятная. Даже при использовании библиотек, которые призваны дать максимально высокую абстракцию при граф. разработке, мы всё еще не можем задавать анимации как в CSS. Чтобы анимировать элемент, в каждом кадре нужно обновлять свойства нашего элемента (например менять его позицию по оси X или Y, цвет, длину и ширину)
В 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, который поможет полюбить математику, в отличие от того как нас учили в школе
Часть 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 (когда один элемент тянется от силы другого), добавлением в друзья и т.д.
Но могу сказать одно: графика - это интересно, и погружаясь в эту тему вы становитесь настоящим гигачедом программирования, просто лордом качалки. До скорого 👋