Генерация 3D-мешей из текста
- пятница, 16 января 2026 г. в 00:00:12
Привет, Хаброжители!
Недавно мне захотелось научиться преобразовывать текст в 3D-меши для последующего рендеринга, так, чтобы такими объектами можно было манипулировать в рамках моего проекта Geotoy и на языке Geoscript. Я занялся исследованием инструментов и библиотек, которые могли бы решать разные аспекты этой задачи, и потом собрал конвейер, реализующий всё вместе. Получились красивые 2-многообразные 3D-меши, поддерживающие произвольные шрифты, текстовые стили и многое другое.
В этом посте мы подробно разберём всю получившуюся конструкцию. Надеюсь, материал пригодится всем, кому интересно реализовать что-то подобное и самостоятельно пустить такой проект в работу.
Библиотекаsvg-text-to-path
Первым делом нам понадобится библиотека на JavaScript под названием svg-text-to-path. Она отвечает за приём произвольного текстового ввода и параметров шрифта. В результате генерируются SVG-файлы, содержащие пути, наиболее близко соответствующие тексту.
На внутреннем уровне эта библиотека обрабатывает как выборку, так и загрузку шрифта, задаваемого пользователем, а также выполняет само преобразование текста в пути. Для каждого из этих этапов поддерживаются свои бэкенды.
Для интересующего меня случая я воспользовался провайдером Google Fonts. Его легко настроить, для этого требуется только ключ Google Fonts API, который можно сгенерировать бесплатно. Таким образом, для создания моих мешей я могу воспользоваться практически любым шрифтом из Google Fonts. Некоторые не загружаются, но таких совсем не много, причём, они выглядят непонятными. Поэтому я решил не разбираться подробно, почему с ними не получается работать.
Для преобразования текста в путь svg-text-to-path по умолчанию использует бэкенд fontkit. Fontkit — это ещё одна библиотека на чистом JavaScript, реализующая движок для работы с шрифтами. Опять же, я подробно её не изучал, но складывается впечатление, что она богата возможностями, и некоторые функции для работы со шрифтами довольно продвинутые.
В рамках моего проекта приложение работает в браузере. Я мог бы использовать svg-text-to-path непосредственно в приложении для генерации этих путей. Но возможность преобразования текста в меш для меня не является ключевой, и я не хотел раздувать ею приложение. Также я хотел бы максимально упростить для пользователей настройку этой библиотеки, а также позволить им безопасно пользоваться моим ключом к Google Fonts API.
Таким образом, я решил создать маленький бэкендовый сервис, который принимал бы на вход текст и параметры, а на вывод давал сгенерированный путь в виде строки. Это очень минимальный веб-сервер Bun, использующий встроенную функцию Bun.serve. Она предоставляет единственную конечную точку для HTTP/JSON.
svg-text-to-path также содержит минимальный встроенный веб-сервер, но я решил создать свой собственный, чтобы можно было самостоятельно настроить собственную процедуру кэширования и постобработку сгенерированных SVG — чтобы просто извлечь путь. Скелет этого приложения я решил написать в основном при помощи LLM. Получилось достаточно удачно. Я считаю, что такие нетребовательные одноразовые обособленные приложения лучше всего писать именно при помощи LLM.
Вот исходный код, если вам интересно, но в нём нет ничего особенного: https://github.com/Ameobea/sketches-3d/tree/main/geoscript_backend/text-to-path
В любом случае, выводом этого кода будет SVG-путь, кодирующий последовательность команд отрисовки, на основе которых генерируется подобный текст:
Теперь, когда у нас настроена генерация путей, нужно придумать, как преобразовать их в треугольники, из которых будет складываться меш. К счастью, эта задача отлично решается при помощи отличных библиотек lyon, написанных на Rust (которыми я ранее пользовался в некоторых проектах).
В пакете lyon_extra содержится парсер SVG-путей, выполняющий синтаксический разбор этих путей и преобразующий их в базовые команды отрисовки.
Далее пакет lyon_tessellation принимает эти команды отрисовки и преобразует их в треугольники. Он обрабатывает все сложные детали и пограничные случаи, реализуя их в виде выпуклых фигур, полых внутренних областей, делая дискретные кривые Безье и всё прочее.
Я реализовал тоненькую обёртку на WebAssembly, которая принимает входной путь и возвращает буферы, наполненные вершинами и индексами: https://github.com/Ameobea/sketches-3d/blob/main/src/viz/wasm/path_tessellate/src/lib.rs
Здесь содержится ещё некоторая дополнительная логика для обработки специальных случаев масштабирования, но в остальном это действительно очень тонкая обёртка вокруг функционала lyon.
Здесь должен отметить, что мне пришлось изменить заданные по умолчанию опции FillTessellator, чтобы сделать значение fill-rule ненулевым — по-моему, для SVG именно этот вариант действует по умолчанию. Так решается проблема с некоторыми шрифтами, в начертаниях которых есть самопересекающиеся пути. Из этого:
Приходим к этому:
Итак, на данном этапе у меня есть два буфера, содержащих вершины и индексы. Эти вершины и индексы определяют 2D-меш, сопоставляющий путь с текстом. Единственное, что остаётся сделать — вылепить его в 3D методом экструзии. Это довольно простая и обычная операция, которая часто выполняется при работе с треугольными мешами.
Для начала преобразуем все вершины из 2D в 3D, заполнив новую ось нулями (по принципу (5, 10) -> (5, 0, 10)).
Затем меняем порядок следования вершин всех треугольников в вашем меше на противоположный. В WebGL и почти всех других рендеринговых системах вершины выстраиваются против часовой стрелки, и от этого зависит, под каким углом видится треугольник. Чтобы обратить этот порядок, можно просто поменять местами первый и третий индекс каждого треугольника в индексном буфере, примерно так:
Далее дублируем каждую из вершин со смещением в n единиц и откладываем их по новой оси (по принципу (5, 0, 10) -> (5, 2, 10)).
Далее объединяем эти новые вершины с треугольниками, но в исходном (необращённом) порядке следования. В таком случае верхние и нижние грани будут смотреть в противоположных направлениях: верхняя вверх, а нижняя вниз.
Наконец, генерируем полоски из треугольников, чтобы сшить ими краевые рёбра верхних и нижних граней. Краевым является такое ребро, которое входит в состав ровно одной грани. Обычно при работе с мешами граф представляется как двусвязный список рёбер, и в данном случае эта структура данных нам очень поможет.
В результате должно получиться что-то подобное:

Если вам интересно — ниже выложен мой исходный код. Но обращу ваше внимание на то, что в коде используется моё собственное внутреннее представление мешей: https://github.com/Ameobea/sketches-3d/blob/main/src/viz/wasm/geoscript/src/mesh_ops/extrude.rs
Если вы всё сделали правильно и позаботились об аккуратном отслеживании индексов вершин, чтобы у вас не дублировались вершины в одной и той же позиции, то у вас получится правильно оформленный меш. Он будет «водонепроницаемым» 2-многообразием. Это очень важное топологическое свойство, которое является обязательным для многих других алгоритмов обработки мешей, в том числе, для КБГ (конструктивной блочной геометрии).
Благодаря тому, что получаемые на выходе меши являются многообразиями, их можно комбинировать с другими мешами при помощи булевых операций и могут быть направлены на дополнительную обработку, например, на сглаживание. Я не берусь утверждать на 100%, что все пути, сгенерированные из любых глифов с применением всех шрифтов будут давать на выходе многообразия, но все варианты, которые я пробовал, давали нужный результат.
Вот и всё! Проделав всю описанную работу, получаем набор вершин и индексов, которые определяют 3D-меш. Этот 3D-меш соответствует полученному на вход тексту.
Я интегрировал эту возможность в мой язык Geoscript в виде встроенной функции:
Здесь нужно управиться с несколькими этапами, но под капотом можно разместить мощные библиотеки (svg-text-to-path, fontkit и lyon), берущие на себя всю сложную и тяжёлую работу.
Даже притом, что некоторые критически важные библиотеки написаны на JavaScript, и притом, что сама генерация происходит на удалённом веб-сервере, я выяснил, что на материале (относительно короткого) текстового фрагмента преобразование идёт достаточно быстро. Эти операции вполне можно выполнять по требованию, без ожидания.
Также пока мне не попадалось шрифтов, которые дают неисправный вывод или меши с багами. Метод работает даже со сложными неанглийскими надписями:

Это был интересный сайд-проект, и я его результатами очень доволен.
Приобретайте любимые книги на нашей ежегодной распродаже, приуроченной к старому Новому году! Торопитесь сделать выгодные покупки, ведь акция завершится уже 18 января.