C 2018 года карта на 2gis.ru рендерится при помощи WebGL API для рисования трехмерной графики. Сначала мы в команде веб-карт использовали эту технологию просто как очень быструю рисовалку двухмерных данных с небольшими исключениями в виде 3D-домов и моделей.
Приход в карту иммерсивных возможностей начал менять сложившееся положение вещей — моделей стало больше, они стали красивее и детальнее, их больше хочется рассматривать.
Наши картографические движки, заточенные на работу на масштабе города и отдельного района, теперь должны научиться отображать ближние вьюпорты с более высокой детализацией. И тут возникла проблема:
Это дезориентирует и создаёт визуальный мусор: глядя на башни Сити, меньше всего хочешь видеть иконки, находящиеся за 300−700 метров позади. Нужно было научить наши POI корректно рисоваться вместе с 3D-объектами, скрываясь за ними при необходимости.
И мы начали экспериментировать.
Решение при помощи буфера глубины
WebGL, как и другие трёхмерные API, предоставляет стандартную возможность для скрытия одних 3D-объектов другими — буфер глубины. Если просто включить глубину POI, картинка будет следующая:
Проблема в том, что POI не совсем «честный» 3D-объект. Она сохраняет свою позицию на трёхмерной сцене, но в то же время не подчиняется перспективе — не уменьшается при удалении от камеры, не поворачивается вместе со сценой, оставаясь всегда лицом к пользователю. Это очевидное с точки зрение UX-решение делает её маргиналом в трёхмерном пространстве. Использование буфера глубины напрямую нам не подходит.
Поэтому мы стали продумывать альтернативные способы решения.
Аналитический подход
Аналитический подход предполагает расчёт скрытия на CPU с помощью алгоритмов бросания луча.
На карте запросто может быть несколько тысяч POI и несколько тысяч 3D-объектов на экране, что в итоге даёт миллионы итераций. А проверку пересечения нужно проводить на каждый кадр, то есть максимум за 10−15 мс. Не проходит по производительности.
Отдельная сцена скрытия POI
В этом подходе мы сгружаем вычисления на GPU. Для этого во вспомогательном фреймбуфере отрисовываем POI вместе с 3D-объектами с использованием буфера глубины и пользуемся данными из этого фреймбуфера в шейдерах при отрисовке основной сцены. Условно говоря, если под центром POI в фреймбуфере красный цвет, то считаем его видимым, если красного цвета нет, то скрытым.
Вот так примерно это выглядит — тут фреймбуфер скрытия POI полупрозрачно наложен на основную сцену.
Отрисовка отдельного фреймбуфера влечёт за собой накладные расходы по производительности, впрочем, не столь большие, как при аналитическом подходе. К тому же их можно уменьшить, снизив разрешение этого фреймбуфера. На картинке этого фреймбуфера в два раза меньшее разрешение, это видно в виде большой пикселизованности красных областей.
В результате мы практически достигли нужно результата, но остался один момент:
Причина — при пересечении POI и 3D-объекта возникают пиксельные решётки, при проходе по которым центра POI (по которому мы судим о видимости) слишком часто меняются состояния.
Преимущество этого подхода — скорость работы — обернулась для нас недостатком.
Борьба с пиксельной решёткой впоследствии оказалась нетривиальным делом. Мы перепробовали множество вариантов: от различных способов антиалиасинга, блюра, до варианта с двумя фреймбуферами и моушен-блюром
На экране иконки в сцене скрытия при движении оставляли за собой красивые хвосты, а мы пытались разобраться, где же проблема — в недостаточном количестве блюра, в точности буфера глубины или где-то ещё. На тот момент все эти эксперименты успеха не имели. Однако, забегая наперед, надо сказать, что именно этот шейдерный подход пошёл на прод. Правда, в слегка модифицированном виде.
Асинхронная сцена скрытия
Другой наш подход заключался в том, чтобы рассчитывать вспомогательный фреймбуфер не каждый кадр, а гораздо реже — где-то раз в 100−150 мс, и использовать его результаты в JS.
Для этого нам нужно отрисовать вспомогательный фреймбуфер и скачать его обратно из GPU в память CPU. После этого можно заглядывать в пикселы так же, как и в шейдерах (благо спроецировать центр POI в координаты экрана — дело достаточно простое).
В этом подходе мы победили мерцание POI. Однако редкая отрисовка и последующее скачивание вспомогательного буфера приводили к отложенным изменениям в расположении POI на экране: они долго изменяли свою конфигурацию после отзума или поворота карты.
Вторая итерация: текстура глубины и интерполяция прозрачности
Времени на фичу потратили уже достаточно. Полноценного решения, которое нас устроит, нет, зато есть множество неидеальных. Надо выбирать, что делать дальше.
Решили, больше не будем качать вариант с асинхронной сценой, а сосредоточимся на решении со скрытием в шейдерах. И ещё обратили внимание, что больше всего мерцают POI зданий, кроме того, удобно было бы не скрывать POI собственным зданием.
Чтобы понимать при отрисовке POI, насколько её глубина больше или меньше глубины здания или модели, к проверке цвета подключили ещё и проверку глубины. Для этого использовали расширение для webgl WEBGL_depth_texture, позволяющее получить буфер глубины в виде текстуры из фреймбуфера. Эта текстура следующий вид:
Чем объект ближе к камере, тем темнее, т.е. значение глубины в текстуре меньше. И наоборот: чем светлее объект, тем глубина больше и он располагается дальше от камеры.
Теперь при отрисовке POI в главной сцене можно сравнивать значения глубины зданий из текстуры и глубины POI: если глубина POI меньше глубины здания, то POI показывается, и наоборот — не показывается.
Но проблема мерцания POI никуда не уходит. Чтобы визуально сгладить процесс скрытия, мы ввели некоторое значение дельты глубины, в границах которого мы интерполируем прозрачность POI. Другими словами, у POI появилось переходное полупрозрачное состояние, т.е.:
— если глубина POI меньше глубины здания, POI показывается;
— если больше, то мы вычисляем разность глубин POI и здания;
— если разность меньше значения дельты, то интерполируем прозрачность и получаем полупрозрачную POI;
— если разность больше, то POI полностью скрыта.
Оставалась ещё одна нерешённая проблема: если организация расположена в здании, то её POI может быть расположена внутри геометрии здания, т.е. она всегда будет скрываться.
Здесь нам пригодился буфер цвета из фреймбуфера. В данные каждой POI добавили id здания и буфере цвета присвоили ей цвет, совпадающий с цветом для id здания. И так как они одинаковые — POI и само здание рисуются одним цветом — а раз так, то у нас и здание не скрывает собственные POI, и пропадает большая часть пиксельных решеток.
С этого момента в логике скрытия POI появилась ещё одна проверка: на цвет. Теперь, как бы мы ни повернули камеру, из-за совпадения цвета здание, в котором расположена эта POI, перестало влиять на скрытие POI.
В итоге скрытие построено на двух проверках. Сначала проверяем совпадение цвета POI с цветом из буфера цвета. Если цвета совпали — POI не скрывается, то есть ей ничего не мешает отображаться: ни собственное, ни какое-либо другое здание. Если цвета не совпали, то проверяем глубину POI и интерполируем прозрачность.
Итого
Не хочется использовать избитые формулировки, но к данной фиче как нельзя лучше подходит «Было трудно, но мы справились».
Мерцание POI до конца не победили — оно изредка происходит в особо сложных геометрических ситуациях. С этим мы ещё будем разбираться дальше.
Зато теперь стало значительно проще понять, где именно находится POI, и можно рассматривать наши красивые 3D-модели без помех.
Благодарности
Статья написана в соавторстве с Александром Артемьевым. Спасибо Саша!