Рисование линий может и не звучит как что-то очень сложное, но довольно сложно сделать в OpenGL и особенно в WebGL. Ниже я рассмотрю несколько различных техник рисования 2D и 3D линий и дополню каждый из них маленьким демо.
Все исходники вы можете найти здесь: https://github.com/mattdesl/webgl-lines
Простые линии
WebGL есть поддержка линий с помощью gl.LINES
, gl.LINE_STRIP
и gl.LINE_LOOP
. Звучит прекрасно, неправда ли? На самом все деле не все так хорошо, и вот несколько причин:
- Драйверы могут по разному рисовать/сглаживать линии и вы не можете получить одинаково выглядющую картинку на всех девайсах или браузерах.
- Ещё максимальная ширина линии зависит от реализации. Например люди, использующие ANGLE получат максимальную ширину линии равной 1.0, что довольно бесполезно. На моём новом ноутбуке с Yosemite линии могут быть шириной до ~10.
- Не возможность поменять стиль поворота линии или её конца.
- MSAA не поддерживается большинством устройств и большинство браузеров не поддерживаю это для закадрового буфера (off-screen buffers). Из-за этого у вас могут быть зубчатые линии.
- Для штрихованных/пунктирных линий
glLineStipple
или устарел или не поддерживается в WebGL.
В некоторых проектах приведённые выше ограничения не помеха и gl.LINES
оказывается приемлемым, но в большинстве случаев это не подходит для качественного продукта.
Триангуляция линии
Что такое триангуляция: читать в Wikipedia ->
Обычно это решают с помощью разбиения линии на треугольники или треугольные полосы и рендерить полосы как обычную геометрию. Этот способ даёт больше контроля надо отображением линии, позволяет добавить линии "колпачок" на конце, повороты линии, их соединение и т.д. Это также позволяет рисовать линии более творчески и интересно, как например в демонстрации выше.
Для создания такого эффекта обычно получают нормаль для каждой точки вдоль пути и расширение наружу на половину толщины с обеих сторон. На пример реализации можете посмотреть в polyline-normals. Отдельную часть линии называют митром, в демке выше они чередуются по цветам (серый/оранжевый). Как соединение митров [miter] объясняется с помощью математики можете посмотреть в этой дискуссии.
Вам понадобятся более продвинутые сетки для создания "колпачков" на торце линии, скошенных соединений и т.д. Обработка этих случаев может быть довольно сложной, как можно увидеть в исходниках Vaser C/C++
Для сглаживания у вас есть несколько вариантов:
- Надеяться что MSAA поддерживается и вам не когда не понадобятся рендерить линии в закадровый буфер
- Добавить больше треугольников по краям линии
- Использовать для линии текстуру с градиентом. Это довольно просто, но плохо масштабируется
- В фрагментном шейдере, вычислять сглаживание основанное на прогнозируемом размере линии на экране.
- Рендерить предварительно фильтрованную gl.LINES на втором проходе, по краю вашей линии
Примечание: Недостатком этого способа можно считать острые края. Когда угол соединения между двумя сегментами очень острый, длинна митра экспоненциально растёт и стремится к бесконечности, что вызывает артефакты при рендеринге. В некоторых приложениях это не проблема, в других же вы можете просто ограничить длину митры или соединить с другой митрой (т.е. скосить), когда угол слишком острый.
В Triangles демо выше используется extrude-polyline. Маленький модуль, который находится в разработке, для построения триангулированного меша из 2D ломаной. В итоге в него планируется добавить поддержку скруглённых соединений/окончаний и правильного ограничения митров.
Использование вершинного шейдера
Триангуляция может значительно усложнить ваш код и меш нужно будет перестраивать, когда изменяется тип соединения. Если вам надо простую линию в WebGL, это может быть немного перебор.
Демка выше просто растягивает линию [stroke] в вертексном шейдере, где толщина задаётся передачей значения в uniform. Мы создаем две вершины для каждой точки нашего пути и передаем нормали линии и длину митра как атрибуты вершины. Каждая пара имеет одну перевёрнутую нормаль (или митра), так что две точки расталкиваются от центра, образуя толстую линию.
attribute vec2 position;
attribute vec2 normal;
attribute float miter;
uniform mat4 projection;
void main() {
// Передвинуть точку вдоль нормали на половину толщины
vec2 p = position.xy + vec2(normal * thickness/2.0 * miter);
gl_Position = projection * vec4(p, 0.0, 1.0);
}
Эффект внутренней линии слева (нажмите, чтобы запустить анимацию) создан в фрагментном шейдере, используя заданное расстояние от центра. Мы можем также добавить линии штрихи, градиенты, свечение и другие эффекты. Для этого нам нужно ещё раз пройти по вершинам, используя distanceAlongPath
(расстояние от начала пути), как парамерт при вычисления.
Код реализации этого подхода может быть абстрагирован в свой собственный модуль. Для ThreeJS этот уже сделано в [three-line-2d] (https://github.com/mattdesl/three-line-2d), включая штрихованные линии.
Screen-Space Projected Lines
Предыдущее демо работало хорошо для 2D (ортогональных) линий, но может не работать так как вы хотите в 3D пространстве. Чтобы линия была с постоянной толщиной, независимо от положения в трёхмерном пространстве, нам нужно растянуть линию после проецирования в пространство экрана.
Как и в прошлой демке, нам нужно представить каждую точку дважды (зеркально центру), так что они направлены в разные стороны. Однако, вместо того, чтобы вычислять нормаль и длину митра на стороне CPU, мы будем делать это в вертексном шейдере. Для этого на нужно отправить атрибуты в вершинный шейдер: next
и previous
позиции на всем пути.
В вертексном шейдере, мы вычисляем наше соединение и насколько надо растянуть линию [extrusion] на экране, для получения постоянной толщины. Чтобы работать в экранном пространстве, нам нужно использовать постоянную однородности [illusive homogeneous component], обозначаемому как W
. Также известной как "перспектива деления". Узнать больше на английском кратко и на русском с выводом. Это даёт нам нормализованные координаты на экране [Normalized Device Coordinates], которые лежат в диапазоне [-1, 1]
. Затем мы корректируем соотношение сторон, прежде чем что-то делать с линиями. Эту операцию мы проделываем и с previous
и next
позиций на протяжении всего пути.
mat4 projViewModel = projection * view * model;
//into clip space
vec4 currentProjected = projViewModel * vec4(position, 1.0);
//into NDC space [-1 .. 1]
vec2 currentScreen = currentProjected.xy / currentProjected.w;
//correct for aspect ratio (screenWidth / screenHeight)
currentScreen.x *= aspect;
Так же нужно обработать крайние случаи для первой и последней точки линии, но тем не менее, обработка простого сегмента выглядит так.
//normal of line (B - A)
vec2 dir = normalize(nextScreen - currentScreen);
vec2 normal = vec2(-dir.y, dir.x);
// раздвинуть от центра & откорректировать на соотношение сторон
normal *= thickness/2.0;
normal.x /= aspect;
//offset by the direction of this point in the pair (-1 or 1)
vec4 offset = vec4(normal * direction, 0.0, 1.0);
gl_Position = currentProjected + offset;
Обратите внимание, что тут нет соединения двух сегментов. Этот подход иногда предпочтительнее митра, поскольку здесь нет проблем с острыми краями. Крутящийся кружок в демке выше не использует никаких митр соединений.
С другой стороны, форма песочных часов в демо выглядела бы скомканной и деформированной без митр соединений. Для этого, в вершинном шейдере реализовано базовое объединение митр без каких либо ограничений.
Мы могли бы внести некоторые небольшие изменения в формулу вычисления ширины линии для создания другого стиля линий. Например, используя компоненту Z
NDC для масштабирования ширины линии, когда они углубляются в сцену. Это поможет создать ощущение глубины.
Другие подходы
Как и для многих вещей в WebGL, есть десятки способов нарисовать линии. Все демки были сделаны с достаточно маленькими абстракциями, поэтому вы можете все их рассмотреть и выбрать что вам подходит для вашего конкретного случая. Некоторые другие подходы, которые так-же имеют право на жизнь:
-
Трафаретная геометрия
Крутой трюк работающий на трафаретном буфере для создания полигонов без использования триангуляции. Однако, этот подход вообще не работает с любым MSAA. [1][2][3] -
Loop-Blinn Curve Rendering
Независимые от разрешения кубические сплайны рендеринга, идеально для глифов шрифта. -
Растеризация штрихов
Можно использовать для создания кистей как в Photoshop. -
Single Pass Wireframe Rendering
Аналогично процедурно генерируем линиям в демо, но лучше подходят для создания 3D линий в режиме wireframes. [1] -
Геометрический шейдер
Это позволило бы создать линиям множество различных заглушек и соединений. Правда геометрические шейдеры не поддерживаются в WebGL. -
Analytic Distance Fields
Позволяет рендерить толстые сглаженные линии как в 2D так и в 3D. Но есть свои особенности из-за использования одного поля для quad и distance. Это не очень практично, но и даёт прикольные эффекты (например размытие в движении)
Используемые модули
При создании демок использовалось с десяток свободных модулей из npmjs.com. Вот список этих модулей:
Дополнительная литература
- CesiumJS - Robust Polyline Rendering
- MapboxGL - Drawing Antialiased Lines in WebGL
- NVIDIA - GPU Path Rendering
- Prefiltered Lines
Написание этого поста очень затянулось, так как я не особо разбирался в теме OpenGL и не знал как перевести часть терминов с сохранением их первоначального смысла. По этому я пока не буду переводить статьи по темам где не силен в терминах. Ждите статей по типу «Создание эффекта туннеля».
Так же я хочу составлять список слов, которые я часто смотрел в переводчике, в конце поста. Ведь моя главная цель это разобраться в теме и подучить английский. А лучший способ это узнать - это попробовать это объяснить другому.
https://github.com/grishy/blog/blob/hugo/content/post/drawing-lines-is-hard.md