В первой части статьи мы реализовали простой (и не очень эффективный) рендерер сетки ГУТ, и пообещали, что оптимизируем рендерер настолько, что он сможет отобразить заявленный в заголовке миллиард ячеек. Для этого нам придётся значительно сократить объём потребляемой видеопамяти — в текущем виде даже на игровых видеокартах (если бы их можно было купить в наше время!) памяти может не хватить, не говоря уж о видеокартах в офисных компьютерах. Начнём с того, что проанализируем, сколько памяти требует наш рендерер сейчас. Каждая грань ячейки задана на четырёх вершинах, каждая вершина содержит пять атрибутов общим объёмом в 48 байт. Предположим, что на вход подаётся полностью состыкованная сетка размером 10003. В этом случае будет сгенерировано 4*6*10002 вершин для внешних граней сетки, общим объёмом 1098,6 Мб. Не будем забывать и про индексы, коих будет сгенерировано 6*6*10002 шт. размером 137,3 Мб. В реальности, сетки ГУТ часто не полностью состыкованы и обычно имеют маску активности ячеек, из-за чего не отсечённых граней может быть гораздо больше.  Представим пару соседних ячеек, одна из которых активна, а другая – нет. Алгоритм отсечения невидимых граней (см. первую часть статьи) скажет, что для активной ячейки нужно рисовать грань со стороны неактивной ячейки. Таким образом, чем больше в сетке неактивных ячеек, тем больше граней будет создано.  Чтобы проиллюстрировать этот случай, сгенерируем тестовую сетку сравнительно небольшого размера — всего 2003 ячеек. Подвох в том, что ровно половину из ячеек случайным образом сделали неактивными — это худший случай для нашего алгоритма отсечения невидимых граней.  Может показаться, что этот пример является надуманным, но с точки зрения отсечения невидимых граней нет разницы, оставлена ли грань из-за нестыкованности или из-за неактивности соседней ячейки. Для этой сравнительно небольшой сетки объём занимаемой памяти под вершины и индексы составил 2484,3 Мб — в два раза больше, чем для миллиарда состыкованных ячеек! Для удобства читателя приведём исходный код рендерера из первой части статьи: Избавляемся от ненужных атрибутов Посмотрим ещё раз на список атрибутов вершин: 1.                  координаты вершины (3*4 байт); 2.                  нормаль грани (3*4 байт); 3.                  индексы ячейки (3*4 байт); 4.                  текстурные координаты (2*4 байт); 5.                  значение свойства в ячейке (4 байта). В первой части мы упоминали, что «красивость» стоит далеко не на первом месте в списке требований к рендереру. Поэтому обратим внимание на атрибут нормали граней. Нам совсем необязательно, чтобы нормаль была гладкой по всей грани — можно вычислять «плоскую» нормаль в шейдере, а от атрибута нормали избавиться, сэкономив 12 байт на каждую вершину. Сначала удалим все упоминания нормалей из функции генерации вершин: Затем избавимся от атрибута (придется сдвинуть номера последующих атрибутов): И, наконец, модифицируем шейдеры. В вершинном шейдере рассчитаем модельные координаты вершины и передадим их во фрагментный шейдер: Во фрагментном шейдере возьмём интерполированные при растеризации модельные координаты, получим их экранные производные и посчитаем нормаль как векторное произведение: Если мы сделали всё правильно, мы не увидим особой разницы на простой сетке. Но на сетках с непланарными гранями разница станет очевидной: На реальных сетках месторождений разница не настолько заметна, поскольку обычно грани ячеек более-менее планарны. Что же с потреблением памяти?   Память под атрибуты FPS в лучшем случае FPS в худшем случае До оптимизаций 2 484,29 Мб 54,3 17,7 Без нормалей 1 932,23 Мб 60,8 18,4 Таблица 1 Сравнение потребления памяти и производительности на тестовой сетке размером 2003 c 50% активных ячеек на видеокарте AMD RX580 8GB Как видим, объём потребляемой памяти предсказуемо уменьшился, а частота кадров немного увеличилась даже несмотря на то, что освещение теперь вычисляется во фрагментном шейдере. После удаления атрибута нормалей список атрибутов выглядит следующим образом: координаты вершины (3*4 байт); индексы ячейки (3*4 байт); текстурные координаты (2*4 байт); значение свойства в ячейке (4 байта). Обратим внимание на атрибут текстурных координат. Каждая компонента вектора текстурных координат принимает одно из двух значений: 0 или 1. Использовать целых восемь байт ради двух бит — расточительно, так что этот атрибут следующий в списке на расстрел оптимизацию. Куда же запаковать эти два бита? Наилучшим кандидатом является атрибут индексов ячейки: индексы всегда положительны — так что мы можем записать по одному биту текстурной координаты в их знаки без потери информации. Как и в случае с нормалями, сначала удалим из метода генерации вершин и шейдера все упоминания атрибута текстурных координат: Затем модифицируем массивы с описанием значений текстурных координат — там, где был 0, запишем -1: И умножим первые две компоненты индекса ячейки на текстурные координаты — так записывается знак: После этого в вершинном шейдере восстановим значения индексов, просто взяв модуль, а текстурные координаты восстановим с помощью функции sign(): Запускаем и замеряем потребление памяти и производительность:  Память под атрибуты FPS в лучшем случае FPS в худшем случае До оптимизаций 2 484,29 Мб 54,3 17,7 Без нормалей 1 932,23 Мб 60,8 18,4 Без текстурных координат  1 564,18 Мб 65,8 19,0 Таблица 2 Сравнение потребления памяти и производительности на видеокарте AMD RX580 8GB Как и в случае с нормалями, потребление памяти сократилось, а частота кадров немного увеличилась. Можно дополнительно уменьшить размер атрибута индексов ячейки, если хранить их не во float, а в int16 – сетки с числом ячеек более 32767 по одной из размерностей нам не встречались, не говоря уж о том, что такой огромный размер сетки не переварит ни одна рабочая станция. Меняем тип данных индекса на int16 (придется сдвинуть индексы на 1, чтобы не потерять знак в нуле): Запускаем, и… Видим, что рендерер невероятно лагает — более 200 мс на кадр. Как же так? Все дело в выравнивании атрибутов в памяти. Большинство видеокарт требуют, чтобы атрибут очередной вершины был выравнен по 32 битам. Очевидно, что наш атрибут с тремя компонентами из int16 выровнен всего лишь по 16 битам, что и приводит к неадекватной производительности. Решить проблему просто — добавим ещё одну компоненту для выравнивания: Этот фикс вернул производительность к ожидаемой:  Память под атрибуты FPS в лучшем случае FPS в худшем случае До оптимизаций 2 484,29 Мб 54,3 17,7 Без нормалей 1 932,23 Мб 60,8 18,4 Без текстурных координат  1 564,18 Мб 65,8 19,0 16-битные индексы 1 380,16 Мб 70,4 19,3 Таблица 3 Сравнение потребления памяти и производительности на видеокарте AMD RX580 8GB Объём потребляемой памяти ещё немного сократился, а производительность немного увеличилась. На этом этапе мы сделали все очевидные оптимизации атрибутов, и их итоговый список выглядит так: координаты вершины (3*4 байт); индексы ячейки * текстурные координаты (4*2 байт); значение свойства в ячейке (4 байта). Теперь каждая вершина занимает в памяти не 48 байт, а 24 байта – в два раза меньше! Общий объём потребляемой видеопамяти сократился в 1,8 раза. Оптимизируем производительность в худшем случае В таблицах сверху мы уже видели колонку «FPS в худшем случае». Что же это за случай, и почему всё так плохо? На самом деле, чтобы увидеть этот «худший случай», достаточно всего лишь развернуть камеру и посмотреть на сетку с обратной стороны. Частота кадров сразу падает в 3 раза, что очень сильно раздражает пользователя. Причина такого значительного падения производительности кроется в порядке растеризации. Видеокарты обязаны растеризовать треугольники последовательно (или так, что результат эквивалентен последовательному) — это важно для корректности блендинга. К тому же, число треугольников в сетке настолько велико, что параллельно их все растеризовать всё равно невозможно. Однако порядок растеризации влияет не только на блендинг, но и быстродействие при рендеринге с включённым тестом глубины. Представим следующую ситуацию: Рендерим несколько треугольников, которые с точки зрения камеры перекрывают друг друга. Если с точки зрения камеры треугольники рендерятся в порядке от дальнего к ближнему, то каждый треугольник пройдёт тест глубины, и все его пиксели запишутся в фреймбуфер. Если же треугольники рендерятся в обратном порядке — от ближнего к дальнему с точки зрения камеры, то первый треугольник перекроет остальные в буфере глубины, а остальные треугольники не пройдут тест глубины и не будут записывать свои пиксели в фреймбуфер. Кроме того, на всех современных видеокартах реализована оптимизация Early Z Rejection, благодаря которой фрагментный шейдер даже не запускается для фрагментов, не прошедших тест глубины. Вернёмся к нашему рендереру и вспомним, в каком порядке мы генерируем вершины: Видно, что мы просто генерируем вершины ячеек в порядке их хранения в трёхмерном массиве. Это объясняет, почему с одной стороны сетки частота кадров высокая, а с другой — низкая. Во втором случае мы просто смотрим на сетку с той стороны, в какую растут индексы ячеек — из-за этого каждая следующая грань находится ближе к камере, чем предыдущая, и всегда проходит тест глубины. Решение этой проблемы тривиально — нужно всего лишь генерировать ячейки так, чтобы с любой возможной точки зрения камеры грани ячеек располагались в порядке от ближней к дальней. А для этого просто представим нашу сетку как луковицу — сначала сгенерируем все грани самого внешнего слоя, затем сгенерируем все грани следующего слоя и так далее, до самого центра луковицы. Чтобы было проще закодить этот алгоритм, вынесем часть кода, отвечающую за генерацию граней одной ячейки, в лямбду: Затем напишем такой, страшный на вид, цикл: Наверняка есть много способов написать алгоритм, который обходит трёхмерный массив в луковичном порядке — здесь приведен самый простой, который придуман за 5 минут. Производительность этих циклов не имеет большого значения, т. к. каждая итерация (генерация граней) намного дороже, чем сами циклы. Запускаем рендерер и видим, что частота кадров всегда высокая и практически не меняется, независимо от того, с какой стороны мы смотрим на сетку. Худший случай теперь работает в три раза быстрее, чем до оптимизации!  Память под атрибуты FPS в лучшем случае FPS в худшем случае До оптимизаций 2 484,29 Мб 54,3 17,7 Без нормалей 1 932,23 Мб 60,8 18,4 Без текстурных координат  1 564,18 Мб 65,8 19,0 16-битные индексы 1 380,16 Мб 70,4 19,3 Луковичный порядок 1 380,16 Мб 67,4 64,9 Таблица 4. Сравнение потребления памяти и производительности на видеокарте AMD RX580 8GB Уменьшаем пиковое потребление оперативной памяти В первой части мы обратили внимание на то, что вершины генерируются в промежуточные буферы в оперативной памяти и лишь затем передаются в буферы OpenGL. Это значит, что в худшем случае выделяется двойной объём памяти — одна копия в наших массивах, а вторая копия может выделяться драйвером перед передачей в видеопамять.  Для решения этой проблемы ещё в OpenGL 1.5 была добавлена возможность сделать память буфера видимой в адресном пространстве процесса с помощью функции glMapBuffer(). Эта функция возвращает указатель, по которому можно писать или читать данные буфера «напрямую». В случае современных интегрированных видеокарт, это мало чем отличается от работы с обычной оперативной памятью (однако, для старших поколений интегрированных видеокарт это не всегда так). Для дискретных видеокарт механизм работы с такой памятью обычно зависит от специфики конкретной модели видеокарты и версии драйвера и не документирован. Избавляемся от промежуточных массивов и сохраняем атрибуты вершин по указателям, полученным через glMapBuffer(): То же самое делаем и с индексами: Убедившись, что всё работает как надо, проверим пиковое потребление оперативной памяти:  Память под атрибуты Память процесса Память процесса (пиковая) До оптимизаций 2 484,29 Мб 3 258,55 Мб 5 844,64 Мб Без нормалей 1 932,23 Мб 2 704,95 Мб 4 738,06 Мб Без текстурных координат  1 564,18 Мб 2 336,84 Мб 4 000,90 Мб 16-битные индексы 1 380,16 Мб 2 151,87 Мб 3 631,87 Мб glMapBuffer 1 380,16 Мб 2 152,14 Мб 2 618,31 Мб Таблица 5. Сравнение потребления памяти на видеокарте AMD RX580 8GB Видим, что пиковое потребление памяти процессом значительно уменьшилось, что позволяет быстро загружать большие сетки на одной и той же машине. Неожиданная проблема На этом мы планировали поставить точку, однако при тестировании на нескольких видеокартах Nvidia разных моделей и поколений (GTX 1050Ti, RTX 2070) обнаружилась весьма необычная проблема — производительность катастрофически упала до 400+ мс на кадр. С помощью git bisect мы определили, что проблема возникла именно после последней оптимизации с glMapBuffer(). Странным было и то, что на продакшн-версии движка эта проблема не воспроизводилась, как и на видеокартах AMD и Intel. В отладочном выводе (GL_KHR_debug) были сразу замечены такие строки: Судя по логу, драйвер решил, что раз буфер создан с флагом GL_DYNAMIC_DRAW, то не нужно выделять под него видеопамять, ведь он всё равно будет очень часто перезаписываться. Вместо этого под буфер выделена обычная оперативная память, что и объясняет настолько низкую производительность. Проблема в том, что мы нигде не указывали GL_DYNAMIC_DRAW – у нас даже нет такой возможности, ведь мы использовали glBufferStorage(), у которого такого флага нет. Для эксперимента мы переделали выделение памяти в буфере на устаревший glBufferData(…, GL_STATIC_DRAW), после чего проблема с производительностью исчезла, а в логе появились такие сообщения: Насколько мы можем судить, подсказки использования буферов драйвер Nvidia воспринимает слишком буквально – в нашем случае GL_DYNAMIC_DRAW оказался неудачным значением по умолчанию. Не стоит сильно их винить, поскольку это в большей степени недоработка спецификации GL_ARB_buffer_storage, не предвидевшей возможные проблемы из-за сохранения обратной совместимости со старым способом выделения памяти. Сравним производительность рендерера до и после оптимизации на нескольких моделях видеокарт: Видеокарта FPS в лучшем случае   (до – после, улучшение в %) FPS в худшем случае   (до – после, улучшение в %) AMD RX580    54,3 – 67,4   (+24%)     17,7   – 64,9 (+366%) Nvidia GTX   1050Ti 29.9 – 40,2 (+34%) 20.9 – 39,5 (+88%) Nvidia RTX 2070 116 – 150   (+29%) 36,3 – 149 (+410%) Intel UHD 630 6 – 12   (+100%) 5 – 12   (+140%) Таблица 6. Сравнение производительности до и после оптимизаций На протестированных видеокартах прирост производительности составил около 30% для хорошего случая, а для худшего случая – от 80% до 410%! Отдельно можно выделить ту самую встроенную видеокарту, речь о которой шла в заголовке. Всё равно не блеск, конечно, но дышать стало полегче. И никто не собирается останавливаться на достигнутом! В этой статье мы рассмотрели несколько оптимизаций рендерера угловой геометрии, которые снизили объём потребляемой видеопамяти почти в два раза и увеличили частоту кадров в три с лишним раза. Теперь наш рендерер сможет отобразить сетки большего размера на маломощных офисных машинах и сетки размером порядка миллиарда ячеек на рабочих станциях. Конечно, это не все возможные оптимизации — например, можно ещё уменьшить размер атрибутов, наложив некоторые ограничения на входные данные. Можно улучшить алгоритм отсечения невидимых граней, о котором упоминалось в первой части статьи; можно уменьшить число уникальных вершин при генерации граней одной ячейки; можно генерировать вершины не разом, а чанками... Для примера, продакшн-версия движка, где мы реализовали подобные оптимизации, потребляет ещё в полтора раза меньше памяти, а по сравнению с изначальной реализацией — почти в четыре раза меньше памяти! На этом мы заканчиваем цикл статей о рендеринге угловой геометрии. Надеемся, что обе части оказались интересными и полезными. В комментариях ждем ваших вопросов; будем рады обсудить описанные и новые способы оптимизации рендерера! Программное обеспечение