На початку цього року ми в Spatie провели цілий день за розробкою цікавих сайд-проєктів у межах нашого внутрішнього хакатону. Мета була простою: створити щось фанкове та функціональне до кінця дня.
Ми з Ніком (Nick) та Дрісом (Dries) вирішили оцифрувати нашу стіну з листівками — ту саму, де ми збираємо та показуємо всі postcards, які ви нам надсилаєте!
Оскільки сторінка з листівками виглядала дещо нудною і давно не оновлювалася, ми створили Kaartje (/ˈkaːr.tʲə/) — діджитал-версію нашої стіни. Концепція елементарна: це 3D-глобус, що обертається, де кожна листівка розміщена в місті відправника. (Не хвилюйтеся, ми не розкриваємо точні адреси, це лише приблизна геолокація).
Сама ідея була крутою, але ми пішли далі. А що, якби ми могли оцифрувати й сам процес надсилання? Тож ми розробили внутрішній додаток на Expo, який дозволяє сфотографувати листівку та миттєво додати її в систему. Демонстрацію можна переглянути нижче.
Стек технологій
Оскільки ми переважно frontend-розробники, ми обрали знайомі нам інструменти:
- API на базі Bun-сервера без жодних фреймворків — максимально просто та легко;
- SQLite як база даних;
- Astro з React для фронтенду;
- Expo для нативного мобільного додатка;
- Спільний пакет (shared package) для фронтенду та Expo з єдиною логікою та UI-елементами.
Усе це ми об'єднали в monorepo та використали npm workspaces для керування структурою. Вийшов дуже лаконічний setup.
Спільний код
Використання React та React Native дозволило нам створити універсальний компонент глобуса. Завдяки react-three-fiber це було легко: ми лише додали кілька перевірок для web-специфічного коду, і глобус «просто запрацював» в Expo-додатку.
Роль ШІ
Проєкт був амбітним для одного дня, тому ми залучили Claude, щоб пришвидшити процес. Ми діяли методично, встановлюючи чіткі межі (guard rails), щоб ШІ не почав «галюцинувати» на середині завдання. Це дозволило зберегти повний контроль над процесом, отримавши максимум продуктивності.
Ми почали з детального опису: вимоги, критерії прийняття, обмеження. Використовуючи специфічні навички на кшталт web-design-guidelines, vercel-react-native-skills та r3f-skills, ми швидко взялися до роботи.
Кожен етап проєкту був ізольованим, щоб ми могли протестувати та перевірити результат. Ми зупиняли допомогу ШІ для валідації коду та аналізу взаємодії між частинами системи.
Оскільки це був внутрішній хакатон, ми не витрачали час на зайву семантику — якщо код працював і виглядав добре, ми рухалися далі. За 4 години ми мали стабільний прототип. Решту часу присвятили ручному поліруванню та оптимізації продуктивності, де Нік продемонстрував справжню магію фронтенду.
Оптимізація рендерингу 3D-глобуса
Рендеринг глобуса з сотнями анімованих карток зазвичай не проблема для сучасного MacBook на M4, але ми помітили мікрофризи. Нашою метою була плавна робота навіть на пристроях середньої потужності.
Перша імплементація була наївною: один THREE.Mesh на кожну листівку. У кожної — власна геометрія, матеріал, текстура та окремий draw call. Це працювало для 10 карток, але на 100 кадри почали «просідати».
Ми застосували GPU instancing. Ця техніка дозволяє сказати відеокарті: «Ось одна фігура, намалюй її 512 разів у різних позиціях, з різними розмірами та текстурами». Браузер не перераховує форму кожного разу, а тримає її в пам'яті.
Тепер усі листівки рендериться як єдиний THREE.InstancedMesh. Одна геометрія, один матеріал, один draw call. Дані про розташування, розмір та текстуру кожної картки передаються як атрибути.
Це вирішило проблему з геометрією, але залишилося питання текстур: кожна листівка має бути унікальною. Ми використали DataArrayTexture — текстуру, що складається з шарів, де кожен шар — окрема листівка. Це як колода карт: форма однакова, але малюнки різні. Shader зчитує індекс шару для кожного екземпляра та відображає потрібне зображення.
Наш клас StampTextureAtlas керує пулом шарів RGBA розміром 128x86 і призначає індекси під час завантаження:
// Кожна марка отримує індекс шару; шейдер обирає потрібний
const layerIndex = atlas.allocateLayer(card.frontImageUrl);
Дрібні, але важливі рішення
Кілька нюансів, які разом зробили проєкт цілісним:
Покрокове завантаження зображень. Сторінка завантажує листівки батчами по 10 штук із паузою у 300 мс. Зображення прелоадяться перед додаванням на глобус. Це запобігає «лавині» запитів, яка могла б «покласти» сервер або спричинити таймаути в браузері.
Refs замість state у мобільному додатку. Процес сканування проходить крізь п'ять екранів. Зазвичай використовують state, але це спричиняє зайві ре-рендери при кожній зміні поля. Ми використали PostcardContext із useRef, що дозволило ділитися даними з нульовою кількістю ре-рендерів. Фінальний екран просто зчитує дані з ref перед відправкою на сервер.
Каскад координат. API спершу намагається отримати GPS-координати з телефону. Якщо доступу немає, використовується центр країни відправника. Якщо і це неможливо — ставиться дефолтна точка нашого офісу в Антверпені. Так кожна листівка знаходить своє місце.
Захист бюджету кадрів. Анімаційний цикл на фронтенді обмежує дельту часу (time delta) максимум 0.1 сек. Якщо користувач перемкне вкладку на пів хвилини і повернеться, глобус не буде шалено крутитися, наздоганяючи час, а продовжить плавний рух.
Чого ми навчилися
Зазвичай ми пишемо CSS та React для створення UI, тож занурення у світ 3D стало ковтком свіжого повітря. Ми обоє цікавимося шейдерами, і робота з Claude як із віртуальним колегою допомогла втілити ідеї в реальність неймовірно швидко.
До цього ми мало знали про GPU instancing, але тепер розуміємо, як він рятує продуктивність. Ми також поглибили знання у WebGL-шейдерах, створюючи ефекти на кшталт «хвилі» при появі картки. Це був крутий досвід виходу за межі звичного робочого середовища.
Для тих, хто хоче подивитися на результат: ми запустили демо-версію на kaartje.earth. Там наразі використовуються тестові листівки, адже ми ще не встигли відзняти всі ваші чудові послання.