Пятигорск (фотография и рендер)
Пятигорск (фотография и рендер)

Я долгое время занимаюсь построением 3D копий городов в проприетарном игровом движке на основе картографических данных. Суммарно это сложная задача, успех выполнения которой заключется в решении небольшого набора больших проблем. Одной из таких проблем является отрисовка точного ландшафта на основе реальных данных. Далее я постараюсь расказать обо всех R&D этапах и технических особенностях, с которыми пришлось столкнуться, а вконце будет несколько сравнений сгенерированного ландшафта с фотографиями реальных мест (перейти).

Карты

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

Пример карты
Москва, Open Street Map
Москва, Open Street Map

А теперь взглянем на те же самые данные, отрисованные "на коленке":

Разбитие на тайлы
Москва из полигонов и линий
Москва из полигонов и линий

Как видно, пространство разбивается на тайлы, которые хранят информацию о геометрии, а точнее содержат полигоны, линии и точки. Каждый объект геометрии может дополнительно содержать разные теги, по которым можно определить, что именно отображает геометрия, будь то дороги, реки, дома, парки и т.д. Таким образом, если из тайла достать все полигоны и линии (и точки) и отрисовать их, уже получится подобие карты.

Количество разнообразных объектов внутри и размер тайлов соответствует некоторому уровню детализации - лоду (LOD, level of detail), который увеличивается с приближением камеры к поверхности. Базовый лод нулевой, он содержит единственный тайл, включающий в себя все материки и океаны. Первый лод содержит 4 тайла - сетка 2х2, второй лод содержит 4х4 тайлов, третий лод содержит 8х8 тайлов и т.д. В моем случае максимальный лод 14й, потому что это значение я чаще всего встречал в коммерческих проектах. По этой же причине в качестве проекции выбран веб меркатор. Для двухмерной карты, логика обычно подгоняется под то что если тайл больше экрана, происходит переход к следующему лоду, если на экране вмещается 2 тайла по каждой оси, то происходит переход к предыдущему лоду. В трехмерном случае логика похожая, только теперь отталкиваемся от расстояния до поверхности: если расстояние до тайла меньше некоторого порога, то он разбивается на 4 тайла меньшего размера следующего лода, которые также могу быть разбиты на более мелкие тайлы, если растояние меньше порога следующего лода. Формула ожидаемого лода в точке может быть такой:

Lod =  clamp(Lod_{max} - (\lceil log_2(\frac {D}{T_{Lod_{max}}}+1) \rceil -1), 0, Lod_{max})\\Где \space D - расстояние \space до \space точки \\ T_{Lod_{max}} - размер  \space тайла \space с \space максимальным \space лодом \\ Lod_{max} - максимальный \space лод

Суммарно для отрисовки ландшафта будет использовано три основных базы данных. Каждая представляет собой sql файл, к которому можно обращаться локально или захостить его на сервере. Особенностью является то что данные подготовлены специальным образом, чтобы программе было удобно работать с ними. Каждой базе данных соответствует источник данных - это сырые исходные неподготовленные данные. Процесс получения базы данных из исходных данных я буду называть построением базы данных. Этот процесс является достаточно трудоемким как с точки зрения алгоритмов и кода так и с точки зрения производительности и может занимать часы или дни. Саму sql базу данных следует рассматривать как контейнер, где по запросу (x,y,lod) можно получить нужный тайл. Он может содержать геометрию домов и дорог или высоту ландшафта, или цвета ландшафта, и в каждом случае формат данных будет уникальным.

В коммерческих проектах, с которыми мне довелось работать, формат тайлов карт чаще всего был mbtiles (mapbox tiles) https://wiki.openstreetmap.org/wiki/MBTiles , поэтому я выбрал его. Здесь тайл представляет собой зазипованный файл, в котором особым образом закодированы линии и полигоны. Тайлы в таком формате я буду называть векторными тайлами. Источники данных для векторных тайлов могут быть разные, основное бесплатное решение это проект Open Street Map https://www.openstreetmap.org/ , из которого можно скачать информацию обо всей планете, где дома, дороги, материки, водоемы и другие структуры хранятся в файле на 50 Гб.

Существуют разные конверторы, которые из исходных данных строят базу данных. Я остановился на tilemaker’е https://github.com/systemed/tilemaker. Поле построения занимаемый размер становится порядка 100 Гб.

Построение базы данных

Теперь можно вернуться к ландшафту. С точки зрения отрисовки ландшафт - это сетка / меш, где каждой вершине задана высота. Структура данных ландшафта будет повторять структуру векторных тайлов - базовой единицей для построения станет тайл, только теперь он будет содержать монохромное растровое изображение фиксированного размера, где цвет характеризует высоту над уровнем моря в метрах. Ландшафт содержит множество вершин, и его отрисовка требует оптимизации, к счастью использование лодов частично возьмет базовую оптимизацию на себя. Дальше стоит выбрать источник данных. Я нашел три подходящих бесплатных решения:

  • SRTM v4.1: 90 метров на пиксель, 30 Гб данных, нет данных за пределами 60 широты в Евразии и Северной Америке

  • SRTM GL1: 30 метров на пиксель, 100 Гб данных, нет данных за пределами 60 широты в Евразии и Северной Америке

  • GDEM V3: 30 метров на пиксель, 300 Гб данных.

Следует отметить, что земная поверхность в каждом источнике данных разбивается на области размером 5х5 или 1х1 градусов. Также каждой области соответствует растровое изображение в виде файла. Это растровое изображение я буду называть регионом. Его формат зависит не только от выбранного источника, но и от времени. Если добыть старую версию, то там будет простой экзотический формат или TIFF, современная версия может оказаться в формате HDF4 или HDF5. Чтобы выкачать файлы областей придется написать небольшую программу или скрипт, которые смогут авторизоваться по токену и скачать данные с учетом возможного прерывания соединения, для этого я использовал CURL.

Основным источником данных я выбрал SRTM GL1, потому что база данных строится быстрее чем для GDEM V3, значит ее легче перестраивать после внесения изменений в алгоритмы, да самих данных достаточно - есть все крупные горы в высоком разрешении, однако, опционально можно выбрать любой вариант. Сами данные выглядят так:

Пример данных тайла
Пример данных тайла

Как и в случае с векторными тайлами, информация о ландшафте будет также сохраняться в sql базу данных. Теперь следует определиться с размером растрового изображения в тайле. Его следует выбрать по формуле:

2^n+1\\Где \space n \in \mathbb N

Никто не запрещает использовать любой другой размер, но практика показывает, что с размером, полученным по этой формуле, удобнее всего строить лоды, требующие уменьшания в два раза. После перебора разных вариантов, я пришел к тому, что мне достаточно детализации, достигаемой при размере в 257х257 пикселей (n=8). В SRTM GL1 размер области 1х1 градусов, а размер региона 3601х3601 пикселей, но фактически значащих пикселей 3600х3600, как и в растровом изображении тайла значащих пикселей только 256х256 (почему так будет объяснено далее). Посчитаем итоговое количество пикселей исходных данных по ширине:

3600 \times 360 = 1296000

Все что требуется, это выбрать максимальное значение лода таким образом, чтобы суммарное количество пикселей в тайлах в ширину покрывало суммарное количество пикселей в регионах в ширину. И это значение 12, проверяем:

2^{12} \times 256 = 1048576

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

Схематичная отрисовка участка ландшафта
Схематичная отрисовка участка ландшафта

На правой половине условные тайлы немного разнесены в стороны для наглядности. Паттерн триангуляции специально выбран с четным и нечетным чередованием, я считаю его наиболее удачным. Как видно, каждый тайл имеет общие вершины с соседним тайлом вверху, внизу, слева и справа. При этом высоты вершин будут задаваться растровым изображением. Это значит что для соседних тайлов соседние стороны будут иметь одинаковые значения цвета, потому что вершины, которые будут брать данные о высоте из пикселей, будут иметь одинаковые горизонтальные координаты, т.е X и Y. Исходные регионы также имеют одинаковые значениях на границах с соседями. Видимо, поэтому их размер как бы вырос на единичку с 3600x3600.

На основании сказанного известно что на входе будет 360x180 регионов каждый размером 3601х3601 пикселей, которые требуется конвертировать в 4096x4096 тайлов, каждый размером 257х257 пикселей. Таким образом задача импорта: берем очередной тайл с координатами (x, y, 12), для каждого его пикселя среди растрового изображения размером 257х257 вычисляем координаты в нормализованном веб меркаторе, затем конвертируем их в цилиндрическую проекцию и семплируем соответсвующие значения из регионов. Формула вычисления координат пикселя в нормализованной проекции веб меркатора следующая:

x_{mercator}=\frac{\frac{x_{pixel}}{256}+x_{tile}}{2^{lod}}2-1\\Где \space x_{pixel}\in[0, 256] , x_{tile}\in[0,2^{lod}-1], lod=12, x_{mercator} \in [-1,1]

Для оси Y формула аналогичная. В случае нормализации диапазон значений веб меркатора от -1 до 1 по каждой из осей и не зависит от уровня детализации, как независят и координаты любой точки. Т.к. после конвертации координаты попадают между пикселями регионов, следует использовать линейную интерполяцию.

После импорта начинается построение лодов тайлов. Эта операция напоминает генерацию мип-уровней в текстурах. Каждые 4ки соседних тайлов размера 257х257 дают один тайл размера 257х257 с усредненными значениями в пикселях. Таким образом в следующем уменьшенном лоде тайлов ровно четыре раза меньше.

Выше было сказано, что лоды зависят от расстояния до камеры, таким образом, может оказаться, что более детальные лоды соседствуют с менее детальными. Посмотрим пример.

Пример нестыковки вершин
Пример нестыковки вершин

Если лод соседнего тайла больше на один, то только каждая вторая вершина будет иметь свою пару в менее детализированном лоде (если лод больше на два, то только каждая четвертая будет иметь пару и т.д.). В результате при отрисовке появятся трещины.

Ландшафт с трещинами
Трещины в ландшафте
Трещины в ландшафте

В интернете много способов решения этой проблемы, и я использовал вариант, который использовали в Far Cry 5 (https://ubm-twvideo01.s3.amazonaws.com/o1/vault/gdc2018/presentations/TerrainRenderingFarCry5.pdf): беспарные вершины более детализированного лода объединяются с соседними вершинами, имеющими пару. Схематично это выглядит так:

Исправление нестыковки вершин
Исправление нестыковки вершин

И соответственно результат:

Ландшафт без трещин
Исправление трещин в ландшафте
Исправление трещин в ландшафте

Я делал смещение вершин в вершинном шейдере, и вершина в таком случае должна знать, что она принадлежит стороне, какой стороне она принадлежит, какой лод имеет тайл, и какой лод имеет тайл, примыкающей к стороне. Как оказалось, операция вычисления и передачи этой информации в шейдер достаточно тривиальная. Также следует отметить, что двигаться могут только вершины более детализированного лода, вершины менее детализированного соседнего лода должны оставаться на месте. Если взглянуть на пример разметки тайлами, то можно заметить, что четыре белых цифры в центре стороны каждого тайла показывают лод тайла, премыкающего к этой стороне.

Тайлы с лодами
Тайлы с лодами
Тайлы с лодами

Код функции шейдера, которая смещает вершины:

vec3 layerComputeVertexPosition(in sampler2D positionTex, ivec3 coords, ivec4 neighbors, int vertexIndex)
{
  ivec2 index = ivec2(vertexIndex % SURFACE_RESOLUTION, vertexIndex / SURFACE_RESOLUTION);
  ivec2 delta = ivec2(0);

  if(index.x + 1 >= SURFACE_RESOLUTION && neighbors.x >= 0)
    delta.y = coords.z - neighbors.x;
  else if(index.x <= 0 && neighbors.z >= 0)
    delta.y = coords.z - neighbors.z;

  if(index.y <= 0 && neighbors.y >= 0)
    delta.x = coords.z - neighbors.y;
  else if(index.y + 1 >= SURFACE_RESOLUTION && neighbors.w >= 0)
    delta.x = coords.z - neighbors.w;
      
  delta = max(delta, ivec2(0));
  index = (index >> delta) << delta;

  float height = texelFetch(positionTex, index, 0).x;

  vec3 position;
  position.x = float(index.x) / float(SURFACE_RESOLUTION - 1);
  position.z = float(index.y) / float(SURFACE_RESOLUTION - 1);
  position.y = height.x;
  
  return position;
}

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

Рендеринг и оптимизация

Предположим, что на данном этапе получилось построить sql базу данных с тайлами высот. Однако, чтобы корректно применять освещение также требуются нормали. Технически нормаль характеризует наклон или изменение высоты по осям Х и У, а значит это есть не что иное как производная, и ее несложно посчитать.

dx=\frac {H(x+1,y) - H(x-1,y)}{2} \\ dy = \frac {H(x,y+1) - H(x,y-1)}{2} \\ Normal = normalize(dx, dy, Pixel_{meters}) \\ Где \space H(x,y) - получение \space высоты \space в \space метрах \space для \space пикселя \space (x,y) \\ Pixel_{meters} - размер \space пикселя \space в \space метрах

В итоге получаем две текстуры на тайл, каждая разрешением 257х257, одна с высотами, вторая с нормалями.

Текстура высот и текстура нормалей
Текстура высот и текстура нормалей

Этого достаточно чтобы нарисовать ландшафт:

Нарисованный ландшафт
Ландшафт, разрешение 257х257 (filled, wireframe)
Ландшафт, разрешение 257х257 (filled, wireframe)

Получаем 50 млн треугольников при разрешении вершин 257х257. Вершины настолько плотно расположены, что режим wireframe отображает практически сплошную поверхность без просветов. Такая плотность подобрана специально. Однако, такое большое количество треугольников, является неприемлемым. Но ведь никто не запрещает уменьшить разрешение текстур и количество вершин. Уменьшим разрешение каждой стороны в 8 раз и посмотрим на результат.

Оптимизированный ландшафт
Ландшафт, разрешение 33х33, (filled, wireframe)
Ландшафт, разрешение 33х33, (filled, wireframe)

Теперь имеем 1 млн треугольников при разрешении вершин 33х33. Как несложно посчитать, уменьшение разрешения в 8 раз по каждой стороне дает общее уменьшение количества вершин примерно в 64 раза. По количеству треугольников определенно произошло попадание в приемлемое значение, но вот качество стало неприемлемым. Но можно предположить, что форму ландшафта передают не столько сами вершины, сколько затенения. Они в свою очередь берутся из нормалей. А что если сделать такой трюк: разрешение вершин оставить низким 33х33, но разрешение текстуры нормалей взять оригинальное - 257х257? Результат.

Оптимальный ландшафт
Ландшафт, разрешение вершин 33х33, разрешение текстуры нормалей 257х257 (filled, wireframe)
Ландшафт, разрешение вершин 33х33, разрешение текстуры нормалей 257х257 (filled, wireframe)

Ровно то что и требовалось!

Цвет на большом расстоянии

Отлетим камерой как можно дальше и посмотрим как выглядит планета издалека.

Ландшафт на огромном расстоянии
Наша планета при большом отдалении камеры
Наша планета при большом отдалении камеры

(Ну вот, плоская! а вы говорили). Нарисованный квадрат наглядно показывает проекцию веб меркатора в деле. Но это еще не все - на таком расстоянии конечно ни о каких нормалях и тенях не может идти и речи, потому что самые высокие горы становятся не более чем незначительными шероховатостями, и в итоге все залито сплошным белым цветом. Но как же материки, моря, леса и океаны?

У меня было две основных идеи раскраски поверхности на макроуровне. Первая - это проект Land Cover (GLCNMO) (https://globalmaps.github.io/glcnmo.html). Здесь в наше распоряжение предоставляется огромное растровое изображение разрешением 80000х40000 пикселей, где в кажом пикселе находится значение от 1 до 20, которое сотответствует определенной поверхности. Список типов поверхностей можно посмотреть в таблице.

Как видно данных тут предостаточно. Стратегия их использования делится на 3 части:

1. Строим мип уровни

  1. Индивидуально генерируем растровое изображение под каждый из 20 слоев. Если индекс пикселя равен индексу текущего слоя, то на выходе в пиксель пишем зачение 255, если не равен, пишем 0.

  2. Сохраняем слой в файл.

  3. К растровому изображению слоя применяем гаусовское размытие.

  4. Уменьшаем растровое изображение в два раза.

  5. Переходим в пункт 2, и так пока не построим все мип уровни каждого слоя.

2. Строим базу данных

  1. С помощью алгоритма марширующих квадратов из каждого слоя каждого мип уровня достаем контуры.

  2. Полученные контуры переводим в полигоны, дополнительно оптимизируем, удаляя лишние вершины, и сглаживаем.

  3. Сохраняем полигоны в векторный формат mbtiles и строим базу данных.

3. Раскрашиваем ландшафт

  1. Загружем полигоны из соответствующего тайлу ландшафта векторного тайла.

  2. Триангулируем полигоны, превращая их в двухмерные меши.

  3. Мешам назначаем материалы (о них далее) в соответствии со слоями.

  4. В ландшафт дополнительно добавляем текстуру материалов, в которую придварительно отрисовываем все меши, полученные на предыдущем шаге.

Возможно некоторые пункты стоит раписать подробнее, но в этом нет большой необходимости, потому что идея не сработала на 100%. Возникло две проблемы. Первая: как ни подбирай материалы, они хоть и добавят детализации, но никогда не будут соответствовать реальным цветам, и поверхность планеты не будет похожа на настоящую. Вторая: загрузка тайлов и дополнительные вычисления занимают время, и как оказалось, данных добавляется огромное количество и это существенно замедляет генерацию ландшафта в рантайме. Поэтому от идеи пришлось отказаться.

Вторая идея раскраски ландшафта основана на прокете NASA Blue Marble (https://visibleearth.nasa.gov/collection/1484/blue-marble). Здесь содержатся изображения всей планеты в высоком разрешении 80000х40000 пикселей на каждый месяц.

Растровое изображение планеты в цилиндрической проекции
Растровое изображение планеты в цилиндрической проекции

Видимо, из-за невозможности любой операционной системы выделить память под все данные в сыром виде одним куском, финальное изображение разбито на 8 тайлов, каждый разрешением 20000х20000.

Разбитие на тайлы
Разбитие на тайлы

Как оказалось, скачать необходимые данные непросто - браузер упорно продолжал разрывать соединение и отказывался докачивать большие (примерно 400 Мб) файлы, поэтому снова использовался загрузчик, написанный с использованием CURL.

Следующая задача, это построить базу данных по аналогии с высотами. Разрешение для высот уже подобратно - 257х257. Растровым изображениями в цветовых тайлах не надо дублировать соседние стороны, и сами изображения будут применяться ни к вершинам, а к поверхности, поэтому соответствующим разрешением в таком случае будет 256х256. Посчитаем необходимое количество лодов: нужно покрыть 80000 пикселей по ширине, возьмем максимальный лод равным 8 и посчитаем:

256 \times 2^8 = 65526

Нужное значение почти достигнуто, небольшими потерями можно пренебречь.

Импорт делается по аналогии с высотами - точно также выполняется нахождение координат в нормализованном веб меркаторе, их конвертирование в цилидрическую проекцию и интерполяция соседних пикселей для получания конечного значения. Далее построение лодов делается аналогично как и для высот. Потом над каждым тайлом выполняется коррекция. Как оказалось, для правильного бесшовного наложения цвета пикселей на границе каждого тайла надо смешать с цветами соответствующих пикселей соседнего тайла, потому что далее в шейдере текстуры накладываются с враппингом clamp_to_edge и фильтром linear, что немного искажает фильтрацию цветов на границах текстуры. В итоге получаем sql базу данных, тайлы которой содержат растровые изображения цветов поверхности ландшафта.

При отрисовке из растровых изображений генерируются текстуры, которые используются как источник цвета. Посмотрим что получилось после их добавления:

"До и после" на огромном расстоянии
Сравнение применения цвета на огромном расстоянии
Сравнение применения цвета на огромном расстоянии

Посмотрим поближе:

"До и после" на большом расстоянии
Сравнение применения цвета на большом расстоянии
Сравнение применения цвета на большом расстоянии

Посмотрим еще ближе:

"До и после" на среднем расстоянии
Сравнение применения цвета на среднем расстоянии
Сравнение применения цвета на среднем расстоянии

Цвет на среднем расстоянии

Если посмотреть на предыдущий скриншот результата, создается впечатление, что на средних расстояниях до повехности действие глобальной текстуры почти сходит на нет из-за недостаточно большого количества исходных данных (изображение 80000х40000 пикселей), и для дальнейшего раскрашивания ландшафта нужно использовать что-то еще.

На этом моменте происходит переход к полноценным PBR (physics based rendering) материалам. Модель физически корректных материалов до промышленного стандарта довел Дисней, можно ознакомиться со статьей https://disneyanimation.com/publications/physically-based-shading-at-disney/. Согласно ей полный набор параметров непрозрачного материала такой:

Параметры PBR материала
Параметры PBR материала

Однако игровые движки значительно сокращают их количество. Для примера посмотрим на базовый непрозрачный материал в Unreal Engine 5:

Параметры материала в UE5
Параметры материала в UE5

Выше я отметил нужные параметры, которые будут использоваться при отрисовке. Красным - цвет (albedo). Зеленым - группу параметров шероховатости (roughness), металличности (metallic) и коэффициента отражения (specular). Синим - нормаль и тангент для bump mapping'а. При этом для ландшафта достаточно иметь нормаль, а тангент можно посчитать на лету в шейдере. Таким образом, чтобы задать все нужные параметры материала, потребуется три текстуры: albedo текстура, normal map текстура, RMS (roughness + metallic + specular) текстура. Теперь главный вопрос - как лучше передать информацию о материале в ландшафт при его отрисовке? Я попробовал три варианта и ниже перечислю их вместе с плюсами и минусами.

1. Вершина ландшафта содержит индекс материала

Это можно считать базовым вариантом. Каждая вершина ландшафта содержит 4 индекса материала в структуре вида ivec4(1, 2, 3, 4), также вершина содержит и веса материалов в структуре вида vec4(0.1, 0.2, 0.3, 0.4). В пиксельном шейдере происходит чтение из текстурного массива по соответствующим индексам данных об альбедо, шероховатости, металличности, коэффициенте отражения и нормали, а затем с помощью весов происходит смешивание этих данных и они пишутся в GBuffer.

Плюсы:

  • Компактный и удобный способ, потребляющий наименьшее количество памяти

  • Поддержка плавного перехода между материалами

  • Низкая чувствительность к артефактам детализации

Минусы:

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

2. Текстура ландшафта содержит данные материала

В предыдущем случае мы по индексам читали нужные слои из текстурного массива, но никто не запрящает вместо индексов хранить сразу нужные цвета и не в вершинах, а в текстуре - ведь более высокое разрешение даст больше детализации независимо от количества вершин. Именно в этом и заключается подход - берем произвольный тайл, для него пишем данные в три уникальных текстуры: albedo, normal map и RMS, а когда рисуется тайл, просто читаем данные обратно и отрисовываем уже в GBuffer.

Плюсы:

  • Все данные материала берутся напрямую из текстур без всяких промежуточных вычислений

  • Поддержка плавного перехода между материалами

  • Сильная чувствительность к детализации

Минусы:

  • Текстуры, принадлежащие тайлам ландшафта становятся уникальными на каждый тайл, и соответственно начинает сильно расти потребление памяти. Т.е. если рисуется 1000 тайлов, потребуется 3000 текстур, каждая разрешением 512х512. Использование сжатых текстур , например, BC1, существенно улучшает ситуацию в плане потребления памяти, но сжатие приходится делать в рантайме, что существенно снижает время генерации ландшафта. Да и для тех же нормалей формат BC1 слишком сильно теряет данные, приходится использовать менее радикальное сжатие.

3. Текстура ландшафта содержит индекс материала

Это компромиссный вариант, который является чем-то средним между двумя предыдущими подходами. С одной стороны не требуется на каждый тайл хранить три большие уникальные текстуры, с другой стороны появляется возможность хранить в каждой точке уникальный индекс материала, что позволяет иметь большее разрешение, чем разрешение самих вершин тайла.

Плюсы:

  • Текстура все еще уникальна для каждого тайла, но она содержит лишь один однобайтный канал, что существенно снижает потребление памяти в сравнении с предыдущим способом, и для этого не требуется никаких сжатий

  • Умеренная чувствительность к детализации

Минусы:

  • Плавный переход между материалами скорее отсутствует, однако некоторой плавности можно добиться, если использовать шум

  • Умеренная чувствительность к детализации

Умеренная чувствительность к детализации является и плюсом, и минусом, она все еще присутствует, и это минус, но уже не так ярко выражена как во 2м варианте, и это плюс. Так что же за детализация такая? Скажем так, это недостаточное количество пикселей тайла более низкого лода, пока не загружен подходящий. Выглядит так:

Пример недостаточной детализации
Пример недостаточной детализации

В настоящий момент я остановился на 3м варианте. Далее надо заполнить текстуру индексов материала. Для этого используется три вида данных, которые последовательно применяются один за другим.

1. Векторные тайлы

Как я уже писал ранее, в векторном тайле хранятся полигоны и линии с тегами, на основании которых можно узнать к чему эта геометрия относится. Полигоны можно триангулировать и отрисовать их в текстуру, где, например, полигонам воды на отрисовку назначить индекс материала воды, полигонам песка на отрисовку назначить индекс материала песка и т.д. Сложнее обстоит дело с линиями, потому что те же дороги надо не просто отрисовать в виде асфальта, но еще и добавить разметку. Для этого я рисую их таким образом, что в шейдере они не только имеют толщину, но также в каждой точке можно узнать расстояние до начала дороги (расстояние вдоль дороги) и расстояние до центра дороги (расстояние поперек дороги), что дает нечто наподобии 2d signed distance function. Обладая этими данными уже легко определить будет ли пиксель принадлежать линии разметки и при необходимости вместо индекса материала асфальта записать индекс материала белого асфальта.

2. Уклон

Уклон характеризуется нормалью, если нормаль отклоняется от вертикальной оси больше чем на какое-то фиксированное значение, мы просто считаем поверхность скалистой, и пишем соответствующий индекс материала.

3. Высота

Существует более-менее строгая зависимость растительной массы и температуры от высоты. Зная высоту можно иметь представление о том, будет ли повехность покрыта снегом, травой или будет просто скалистой. Конечно это грубое приближение, но это лучше чем ничего. Соответственно на основании высоты также рассчитывается индекс подходящего материала и пишется в текстуру. Однако, если сделать это в лоб, то получатся некрасивые очертания, поэтому переход маскируется через шум.

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

"До и после" на среднем расстоянии
Сравнение применения материалов на среднем расстоянии
Сравнение применения материалов на среднем расстоянии

Посмотрим, как изменения сказались на высокогорье:

"До и после" на большом расстоянии
Сравнение применения материалов на большом расстоянии
Сравнение применения материалов на большом расстоянии

Посмотрим, какие изменения произошли совсем вблизи поверхности:

"До и после" на малом расстоянии
Сравнение применения материалов на малом расстоянии
Сравнение применения материалов на малом расстоянии

По краям дороги можно увидеть плавный переход от асфальта к траве. Выше я писал, что классический блендинг при задании материала через индекс в текстуре не поддерживается, поэтому переход сделан через шум - ближе к дороге больше “асфальных” пикселей, разбросанных в случайном порядке, а дальше от края дороги - больше “травяных” пикселей. Идея позаимоствована из Far Cry 5 (https://ubm-twvideo01.s3.amazonaws.com/o1/vault/gdc2018/presentations/TerrainRenderingFarCry5.pdf), а они ее позаимовствовали из статьи про отрисовку полупрозрачных объектов на примере волос и усов через шум с удачной хеш-функцией. Ниже в https://shadertoy.com я накидал простой шейдер, который показывает принцип:

const float RADIUS = 0.2;
const float CENTER_LINEAR = 0.25;
const float CENTER_NOISE = 0.75;

float hash(vec2 i) 
{
    return fract(1.0e4 * sin(17.0 * i.x + 0.1 * i.y) * (0.1 + abs(sin(13.0 * i.y + i.x))));
}

float computeWithLinear(float x)
{
    float alpha = 1.0 - clamp((abs(x - CENTER_LINEAR) - RADIUS * 0.5) / (RADIUS * 0.5), 0.0, 1.0);
    alpha *= alpha;
    return alpha;
}

float computeWithNoise(float x, vec2 coord)
{
    float alpha = 1.0 - clamp((abs(x - CENTER_NOISE) - RADIUS * 0.5) / (RADIUS * 0.5), 0.0, 1.0);
    alpha *= alpha;
    return alpha > hash(coord) ? 1.0 : 0.0;
}

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
	vec2 uv = fragCoord.xy / iResolution.xx;
    
    float final;
    if(uv.x < 0.5)
        final = computeWithLinear(uv.x);
    else
        final = computeWithNoise(uv.x, fragCoord.xy);
    
    fragColor = vec4(vec3(final), 1.0);
}

И вот результат: слева плавный переход от белого к черному, справа переход через шум..

Блендинг через альфа смешивание и  шум
Блендинг через альфа смешивание и шум

Коррекция высоты в рантайме

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

Для начала следует увеличить максимальный лод. Сейчас максимальный лод векторных тайлов 14, максимальный лод тайлов высоты 12, максимальный лод тайлов цвета 8. Увеличим общий максимальный лод до 23. При этом если текущий лод больше 14 для векторных тайлов просто рисуем геометрию из 14 лода. Если лод цветовых тайлов больше 8, просто используем цветовую текстуру из тайла 8 лода, пересчитав координаты.

Чусть сложнее обстоит дело с тайлом высоты, когда лод больше 12. В таком случае необходимо вручную пересчитать высоты из 12 лода на текущий применив интерполяцию Catmull-Rom сплайном или любую другую, содержащую степень больше 1. Это важно, линейной интерполяции в данном случае будет недостаточно, т.к. она приведет к недостаточно плавному переходу:

Линейная / Catmull–Rom интерполяция
Сравнение линейной и Catmull–Rom интерполяций
Сравнение линейной и Catmull–Rom интерполяций

Теперь появилось пространство для внесения информации о высоте сверх того что предоставляет база данных и можно попытаться выровнять дорогу. Каким мог бы быть алгоритм? Дорога должна стремиться иметь одинаковою высоту в рамках перпендикулярного среза и в качестве этого значения стоит взять высоту в центре дороги. Таким образом, если текущая точка пренадлежит дороге, ее необходимо спроецировать на центральную линию дороги и высоте текущей точки присвоить высоту в спроецированной на центральную линию точке. Результат есть, но он выглядит неприятно, потому что переход получается резким:

"По и после" грубого выравнивания
Сравнение с выравниванием и без
Сравнение с выравниванием и без

Однако, несложно сделать переход плавным. Мы уже можем находить центр дороги для произвольной точки, значит можем посчитать и расстояние до него. Теперь достаточно взять два порога, если расстояние меньше первого, то начинается плавный переход, если меньше второго, то резкий. Удобнее всего взять первый порог равным половине физической ширины дороги, а второй порог, например, физической ширине дороги. Результат:

"До и после" плавного перехода
Сравнение резкого и плавного переходов
Сравнение резкого и плавного переходов

Похожим образом происходит обработка тропинок, а на пересечении дороги и тропинки можно даже сгенерировать пешеходный переход, данных о расстоянии для этого дастаточно. Для наглядности далее будет пример sdf текстур, получившейся текстуры материалов и финального тайла ландшафта.

Пешеходный переход и тропинки
Генерации тайла ландшафта
Генерации тайла ландшафта

Теперь можно исравлять общую гладкость поверности из-за недостаточного количества данных. Я это сделал добавлением шума. Есть функция, оперерующая координатами в double типе, которая складывает несколько октав fractal brownian motion шума Перлина и подмешивает его к высоте. При этом учитывается склон и объекты поверхности, например, шум не оказывает влияния на высоту, если точка принадлежит реке или любому другому водоему. Про базовые понятия шума можно почитать тут https://thebookofshaders.com/ . Ниже приведен небольшой кусок кода упрощенной функции для понимания:

  float alpha =
    noiseMap.linearValuef(glm::fract(coord * 0.5)) * 8.0f +
    noiseMap.linearValuef(glm::fract(coord * 1.0)) * 4.0f +
    noiseMap.linearValuef(glm::fract(coord * 2.0)) * 2.0f +
    noiseMap.linearValuef(glm::fract(coord * 4.0)) * 1.0f +
    noiseMap.linearValuef(glm::fract(coord * 8.0)) * 0.5f;
  
  float slope = 1.0f - glm::abs(normalY);
  return alpha * alpha * 0.5f * (slope * 0.95f + 0.05f);

Чтобы каждый раз не тратить ресурсы на вычисление шума, я закешировал его в текстуру размером 1024х1024. Это даст повторяемость и паттерн может броситься в глаза, однако, шум относится к разряду высокочастотных, т.к. низкочастотной является сама поверхность ландшафта, что маскирует повторяющиеся значения. Посмотрим что получилось:

"До и после" добавления шума на малом расстоянии
Сравнение с шумом и без
Сравнение с шумом и без

Дополнение высот ландшафта удачно подобранным шумом дает наибольший вклад в визуальную составляющую на близком расстоянии! И в завершение сравним изменения, внесенные добавлением шума, на среднем расстоянии:

"До и после" добавления шума на среднем расстоянии
Сравнение с шумом и без
Сравнение с шумом и без

Финальный результат

Дополнительно к ландшафту была добавлена растительность. Она делится на несколько категорий, а именно: трава, цветы, кусты и деревья. Цветы и кусты рисуются тривиально, а вот для поддержки травы и деревьев был проделан большой R&D, который тянет на отдельную статью, поэтому сейчас я не буду останавливаться на этом подробнее. Далее будут скриншоты финальных результатов получившегося ландшафта, когда все слои визуализируются комплексно. Мне показалось интересным сравнить их с фотографиями реальных мест, поэтому большинство примеров парные.

Результат
Торгашинский хребет (Красноярск)
Торгашинский хребет (Красноярск)
Казанский съезд (Нижний Новгород)
Казанский съезд (Нижний Новгород)
Пятигорск
Пятигорск
село Эльбрус
село Эльбрус
Мартиньи (Швейцария)
Мартиньи (Швейцария)
Эверест
Эверест
тропинка в лесу
тропинка в лесу

Комментарии (1)


  1. UberSchlag
    19.05.2025 08:03

    Прекрасный и подробный материал, спасибо!