Вот, как он выглядит:
Майк Вазовски!
3D-редактор
Я ненавижу тормознутость компилятора Typescript (поверьте, это относится к теме статьи). Джем показался мне подходящей возможностью реализовать более быстрое подмножество Typescript, обгоняющее по скорости tsc
. Мне показалось, что проект можно реализовать, если начать с парсера Typescript esbuild or Bun. Но потом ко мне пришло понимание, что успешный результат будет выглядеть как команда терминала, выполняющая работу быстрее другой. Не особо впечатляюще в качестве демо. Мне хотелось создать крутое демо, поэтому я выбрал 3D.
Единственная причина того, что реализация 3D-проекта с нуля за неделю показалась мне осуществимой, заключается в технике под названием ray marched signed distance fields (SDF). Сцену ray marched SDF с цветами, плавными тенями и рассеянным затенением (ambient occlusion) можно реализовать гораздо быстрее, чем эквивалентный рендерер на основе треугольников. Удивительный Иниго Килез использует SDF для быстрого создания персонажей в стиле Pixar. Раньше я писал SDF-шейдеры, но они были рудиментарными. Моделирование при помощи редактирования кода казалось мне неестественным, хотелось редактировать модели мышью. Я посчитал, что джем — подходящая возможность для превращения этой задумки в реальность.
Визуализированные знаковые поля расстояний (Signed Distance Field) редактора ShapeUp
На C
Я написал ShapeUp на C и воспользовался raylib для создания окна OpenGL. Raylib оказалась одной из тех библиотек, которые позволяют быстро начать, но в длительной перспективе замедляют работу. Подробнее об этом ниже.
Некоторые считают C крайне простым и сырым языком, всё время разработки на котором приходится тратить на борьбу с отсутствием в нём встроенных структур данных и на устранение багов указателей. Истина в том, что в простоте C заключается его сила. Он быстро компилируются. Его синтаксис не скрывает сложные операции. Он достаточно прост, чтобы не приходилось искать по нему информацию. Его можно легко компилировать и нативно, и при помощи web assembly. Хотя C имеет свои особенности, за 22 года работы с ним я научился их избегать.
Мой «повседневный» проект представляет собой 177 тысяч строк на C и Objective-C. По сравнению с ним, ShapeUp — это очень простой в работе маленький единый файл C. Но даже несмотря на это, мне кажется, что стоит рассказать о том, как он использует данные. Модели состоят из фигур (Shape):
typedef struct {
Vector3 pos;
Vector3 size;
Vector3 angle;
float corner_radius;
float blob_amount;
struct {
uint8_t r,g,b;
} color;
struct {
bool x,y,z;
} mirror;
bool subtract;
} Shape;
Shape хранятся в статически выделенном массиве:
#define MAX_SHAPE_COUNT 100
Shape shapes[MAX_SHAPE_COUNT];
int shape_count;
int selected_shape = -1;
Благодаря этому отсутствует возможность ошибок распределения и утечек памяти, нет ничего лишнего. Прелестно. Ограничение в 100 фигур на практике никак нас не ограничивает. У меня было мало времени на оптимизацию рендерера, поэтому частота кадров упадёт ещё до того, как вы доберётесь до 100 фигур. Если бы у меня было время, я бы разбил модель на маленькие кирпичики, а затем выполнял бы raymarching в пределах каждого кирпича.
Что касается динамической памяти, то ShapeUp вызывает malloc только в трёх местах:
- Сохранение (выделение буфера, достаточного для хранения всего документа).
- Экспорт .OBJ (тоже выделение буфера, достаточного для хранения всех вершин).
- Генерация шейдера GLSL (буфер для исходников шейдера).
Во всех трёх случаях в конце функции есть единственная простая free
. Повторюсь, всё это тривиально, я упоминаю это только как доказательство того, что работа с памятью в C может быть тривиальной. Разумеется, можно усложнить себе жизнь, выполняя malloc каждого Shape
по отдельности и храня указатели в динамическом массиве — работа с языками наподобие Java, Javascript и Python вынуждает использовать такую структуру распределения. Я ценю то, что C обеспечивает мне контроль за структурой памяти.
UI реализован как immediate mode user interface (IMGUI). Мне нравится такой подход к UI. Его очень легко отлаживать, и можно использовать для позиционирования элементов настоящий язык программирования (в отличие от CSS, constraints или SwiftUI). Как и в большинстве IMGUI, я использовал enum
для отслеживания того, какой элемент имеет фокус, или того, какое действие выполняет мышь:
typedef enum {
CONTROL_NONE,
CONTROL_POS_X,
CONTROL_POS_Y,
CONTROL_POS_Z,
CONTROL_SCALE_X,
CONTROL_SCALE_Y,
CONTROL_SCALE_Z,
CONTROL_ANGLE_X,
CONTROL_ANGLE_Y,
CONTROL_ANGLE_Z,
CONTROL_COLOR_R,
CONTROL_COLOR_G,
CONTROL_COLOR_B,
CONTROL_TRANSLATE,
CONTROL_ROTATE,
CONTROL_SCALE,
CONTROL_CORNER_RADIUS,
CONTROL_ROTATE_CAMERA,
CONTROL_BLOB_AMOUNT,
} Control;
Control focused_control;
Control mouse_action;
Этому проекту не нужны динамические массивы или hashmap, но если бы были нужны, я бы использовал что-то типа stb_ds.h.
▍ Отступление: борьба с Raylib
Я не пожалел о выборе C, но проблемой оказалась raylib. Во-первых, в ней приняты странные архитектурные решения, вредящие удобству работы:
- Raylib использует
int
везде, где следовало бы использовать типenum
. Это не позволяет компилятору выполнять проверку типов и мешает самодокументированию функций. Возьмём для примера эту строку в заголовке raylib:// Проверяем, обнаружен ли жест RLAPI bool IsGestureDetected(unsigned int gesture);
Похоже, что
gesture
может быть ID жеста. Однако изучив исходники raylib, можно понять, что параметрgesture
на самом деле является enumGesture
! И такое встречается повсеместно. Единственная документация raylib — это файл заголовка, так что приходится заходить в реализацию, чтобы проверить, является ли параметрint
типом enum, и если да, то каким именно enum. - Raylib не выполняет обычной валидации параметров, и это сделано преднамеренно. Эта функция приводит к segfault, когда dataSize имеет значение null:
unsigned char *LoadFileData(const char *fileName, int *dataSize);
Заголовок raylib не даёт понять, что dataSize — это выходной параметр или что он не должен быть равен null. Это решение об отсутствии валидации влияет на множество функций и усложняет выявление тривиальных проблем. Если повезёт, это приводит к segfault где-то в полезном месте (но не выводит ошибку в лог). Если не повезёт, то библиотека просто незаметно делает что-нибудь странное.
- Raylib не берёт на себя ответственности за свои зависимости. В GLFW есть проблемы, которые raylib не пытается обойти или пропатчить. Для конечного пользователя raylib способ, выбранный для создания окна, становится невидимой подробностью реализации. Мне важно, чтобы функции raylib работали вне зависимости от их внутреннего устройства.
UI-библиотека raygui очень несовершенна:
- Она не может отображать числа с плавающей запятой. Мне пришлось создать текстовое поле для float.
- Она не обрабатывает маршрутизацию событий мыши для пересекающихся или усечённых элементов.
- Не может создавать скруглённые углы, которые очень часто применяются в UI.
- Её невозможно стилизовать так, чтобы она хорошо выглядела.
Есть и просто баги:
- В инструментарии raygui есть баг, не позволяющий менять шрифт с гиперстилизованного, используемого по умолчанию (выберите какой-нибудь подходящий стандартный шрифт!).
- Функции отрисовки наподобие
DrawCircle(...)
не используют общие вершины у треугольников. Из-за этого в связи с погрешностями округления чисел с плавающей запятой возникают пиксельные дыры, когда текущая матрица имеет масштабирование или поворот.
Какое-то время я сообщал о найденных проблемах, но почти все они были закрыты с формулировкой «исправляться не будет». Это раздражало и демотивировало, а на написание баг-репортов тратилось время, так что я просто перестал.
Итак, raylib создала мне окно OpenGL, но я дорого заплатил за это удобство. К счастью, я обычно всегда находил обходной путь: или использовал непосредственно функции OpenGL, или реализовывал фичу с нуля. В будущем я попробую работать с sokol.
За неделю
Если рассматривать ShapeUp на высоком уровне, он сводится к четырём основным частям, которые мне нужно было написать за шесть дней:
- Интерфейс пользователя (3D-гизмо, горячие клавиши, боковая панель, игровой контроллер).
- Генератор шейдеров GLSL + рендерер ray marching (объяснено в видео).
- Выбор мышью на основе GPU (объяснено в видео).
- Marching cubes для экспорта (объяснено в видео).
Каждый пункт по отдельности реализовать не так трудно. Трудно было правильно расставить приоритеты и не отклониться в сторону. В решении сложных или времязатратных задач мне помогли обходные способы или использование тупого решения, работавшего в 90% случаев. Иногда помогало оставить фичу на день, чтобы подсознание подсказало мне решение.
Я старался работать так, чтобы у меня всегда был работающий 3D-редактор, и постепенно совершенствовал его настолько, насколько позволяло время. Я подходил к этому как к строительству пирамиды. Если строить её слой за слоем, то к самому концу у тебя не будет пирамиды. Или же можно строить её так, чтобы на каждом из этапов у тебя была готовая пирамида.
В заключение
К концу недели у меня была 3D-программа, способная создавать осмысленные 3D-модели и экспортировать их в файл .obj. Кроме того, она работает на множестве платформ, и в ней можно открывать/сохранять файлы.
Гаечный ключ, смоделированный в ShapeUp
Проект состоит из 2024 строк C и 250 строк GLSL. Удивительно, что достаточно функциональный 3D-редактор можно написать в примерно 2300 строках.
Других участников джема впечатлил ShapeUp, но я не ощущаю, что достиг чего-то серьёзного, это относительно простой проект. Если уж в нём и есть что-то особенное, так это мой выбор того, что нужно сделать, мои знания, необходимые для этого, и дисциплинированность, чтобы уложиться в неделю.
Можете запустить ShapeUp в браузере, только помните, что это было сделано всего за неделю.
Исходный код выложен на Github.
Автор: ru_vds