8 мин.

Рисовать линии ‐ это сложно

Рисовать линии ‐ это сложно

Рисование линий может и не звучит как что-то очень сложное, но довольно сложно сделать в OpenGL и особенно в WebGL. Ниже я рассмотрю несколько различных техник рисования 2D и 3D линий и дополню каждый из них маленьким демо.

Все исходники вы можете найти здесь: https://github.com/mattdesl/webgl-lines

{{% toc %}}

{{% /toc %}}

Простые линии

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 поддерживается и вам не когда не понадобятся рендерить линии в закадровый буфер
  • Добавить больше треугольников по краям линии

Примечание: Недостатком этого способа можно считать острые края. Когда угол соединения между двумя сегментами очень острый, длинна митра экспоненциально растёт и стремится к бесконечности, что вызывает артефакты при рендеринге. В некоторых приложениях это не проблема, в других же вы можете просто ограничить длину митры или соединить с другой митрой (т.е. скосить), когда угол слишком острый.

В 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. Вот список этих модулей:

Дополнительная литература


Написание этого поста очень затянулось, так как я не особо разбирался в теме OpenGL и не знал как перевести часть терминов с сохранением их первоначального смысла. По этому я пока не буду переводить статьи по темам где не силен в терминах. Ждите статей по типу «Создание эффекта туннеля».

Так же я хочу составлять список слов, которые я часто смотрел в переводчике, в конце поста. Ведь моя главная цель это разобраться в теме и подучить английский. А лучший способ это узнать - это попробовать это объяснить другому.

gl hf

https://github.com/grishy/blog/blob/hugo/content/post/drawing-lines-is-hard.md