В одной из прошлых статей с подозрительно похожим заголовком мы показали, как построить модель месторождения и посчитать свойства пласта глубоко под землёй, используя крохи информации о породе, полученной со скважин. В этой статье мы расскажем, как отобразить модель месторождения на экране так, чтобы опытные геологи и гидродинамики могли сразу видеть (не)соответствие модели своим знаниям об особенностях конкретного месторождения. Более того, вся реализация займёт у нас не больше часа – после чего мы сможем визуализировать практически любые реальные модели месторождений, как на картинке. А в следующей статье мы оптимизируем быстродействие и потребление видеопамяти настолько, что наш рендерер станет самым быстрым и эффективным в мире*. * среди известных авторам. Дисклеймер: эта статья не является пособием по 3D-рендерингу и графическому API. Всё, что требуется от читателя – это понимание основных принципов 3D-графики: что такое атрибуты вершин и т. п. К счастью, на Хабре есть множество хороших статей (раз, два), которые можно прочитать для освежения этих концепций в памяти. В этой статье мы использовали современный OpenGL 4.5, но всё описанное будет работать даже на древнем OpenGL (ES) 2.0. На картинке в начале статьи представлена типичная модель месторождения, которая совсем не похожа на регулярную воксельную сетку, как в Майнкрафте. Подобные сетки, называемые геометрией угловой точки (англ. Corner-Point Grid/Geometry), уже более 30 лет используются практически в любом софте для геологического и гидродинамического моделирования. В простонародье ГУТ часто называют угловой геометрией. Геометрия угловой точки даёт возможность довольно точно моделировать разломы в пласте – намного точнее регулярных сеток. Благодаря своим ограничениям, угловая геометрия требует гораздо меньше памяти для хранения сетки, чем полностью нерегулярная сетка. Как оказалось, эти же ограничения позволяют значительно оптимизировать производительность рендеринга – что и даёт нам право без зазрения совести утверждать, что такой визуализацией можно пользоваться на сравнительно маломощных интегрированных в процессор видеокартах. Но об этом мы расскажем во второй части статьи.  Формат сетки ГУТ достаточно прост – можно бегло ознакомиться с ним в нашей предыдущей статье или на Википедии. Вместо того, чтобы тратить на это много времени (мы же обещали, что справимся за час), перейдём сразу к реализации рендерера, а с форматом разберёмся по ходу. Рендерер угловой геометрии должен отвечать нескольким требованиям, в субъективном порядке убывания важности: поддержка пикинга: определение координат и индексов ячейки под курсором при нажатии/наведении мыши. Это ключевое требование для интерактивной работы с моделями; способность отображать сетки размером 1003 ~ 10003 ячеек на типовых конфигурациях компьютеров; приемлемое время начальной загрузки данных, в идеале не больше 10-30 сек; отображение любых входных ГУТ без долгих предрасчётов; закраска ячеек по палитре с поддержкой wireframe-границ ячеек, иначе на экране ячейки сливаются друг с другом (см. рис. 2); адекватная частота кадров: от 60 до 20 FPS. Из этих требований вытекают ограничения на возможность применения сложных алгоритмов отсечения в предрасчёте, а также сложных моделей освещения вроде PBR (Physically-Based Rendering). Вопросы красивости визуализации никого не волнуют; главное, чтобы пользователь быстро получал интересующую его информацию об отображаемой модели. Чтобы читателю было проще представить, насколько огромными могут быть сетки угловой геометрии, сравним возможные размеры сетки с тем же Майнкрафтом версии 1.16.4 (на момент написания статьи). На стандартных настройках графики в область видимости в этой игре входит чуть более 11 миллионов блоков или примерно 2223. На максимальных настройках это число составляет уже более 71 миллионов блоков или примерно 4143. В случае геологических моделей, размеры сетки ГУТ порядка 2503 ячеек считаются небольшими; размеры порядка 5003 – 10003 ячеек считаются большими, а размеры порядка 10003 – 20003 ячеек считаются огромными и непрактичными для повседневной работы даже на мощных рабочих станциях. Все входные данные угловой геометрии будем считать плотно упакованными трёхмерными массивами в порядке C (row-major). Для удобства заведём простой класс span3d упрощающий работу с трёхмерными массивами. Этот класс не владеет переданным ему массивом, а просто ссылается на него, как и std::span Вместо этого класса можно использовать любую подобную обёртку над многомерными массивами, например std::mdspan (если он будет добавлен в C++ в будущем стандарте), или xtensor. Приступим к реализации загрузчика угловой геометрии. Все данные будем хранить в классе CornerPointGrid. Конструктор принимает размеры сетки (ni, nj, nk)и все входные массивы: COORD, ZCORN, PROPERTY, MASK. Проверять размеры входных массивов мы, конечно же, не будем. Массив COORD имеет размер (ni+1, nj+1, 6). В этом массиве хранится набор из (ni+1, nj+1)условно вертикальных отрезков-пилларов, заданных двумя трёхмерными точками. Для каждой ячейки используются четыре соседние пиллара, поэтому общее число пилларов всегда на единицу больше числа ячеек в каждом направлении сетки. Каждая ячейка задается восемью глубинами своих угловых вершин (отсюда и название ГУТ), которые хранятся в массиве ZCORN. Поэтому размер этого массива в восемь раз больше размерности сетки: (ni*2, nj*2, nk*2). Мы могли бы хранить все восемь угловых глубин в последней размерности массива, как в случае COORD, но у такой схемы есть преимущество: если итерировать вниз по пиллару, глубины  вершин соседних ячеек лежат в памяти друг за другом подряд, что позволяет симуляторам более эффективно использовать кэш во многих расчётах. Массив PROPERTY содержит значение какого-то свойства в каждой ячейке, например, значение пористости или нефтенасыщенности. Согласно этому значению мы раскрашиваем каждую ячейку в тот или иной цвет палитры. Массив MASK содержит булево значение активности ячейки. Если ячейка неактивна, она не участвует в расчётах модели, и её мы не рисуем. Также в конструкторе создаётся массив из байт под маску состыкованности граней. Посмотрим ещё раз на картинку в начале статьи. Мы видим на ней внешние грани ячеек сетки, и не видим внутренние, коих в сетке подавляющее большинство. Очевидно, что если не рисовать внутренние грани, частота кадров значительно увеличится, поэтому нам нужен быстрый способ определить, можно ли отсечь невидимые грани всех ячеек. Формат ГУТ позволяет описывать сетки, ячейки которых не стыкуются с соседними, в отличие от регулярных сеток. Но это свойство угловой геометрии несколько усложняет проверку видимости граней – недостаточно только лишь знать, активна ли соседняя ячейка. Если соседние грани двух ячеек не состыкованы (другими словами, если хотя бы одна из соответствующих вершин граней не совпадает с вершиной соседней грани), то эти грани должны быть нарисованы, даже если обе ячейки активны. Мы могли бы выполнять эту проверку прямо при генерации вершин, но есть смысл сохранить результат в отдельный массив как битовую маску состыкованности граней. Наличие маски состыкованности позволяет быстрее переключать маску активности ячеек – не придётся повторно проверять состыкованность граней. Алгоритм генерации маски состыкованности довольно прост: для каждой пары соседних граней двух ячеек проверим, совпадают ли все четыре вершины одной грани с четырьмя вершинами другой грани. Если вершины граней совпадают (с каким-то небольшим epsilon, нам не нужна слишком большая точность), установим в 1 биты этих граней в маске состыкованности граней, которая изначально была заполнена нулями. Для проверки совпадения вершин граней достаточно сравнивать только глубины вершин из массива ZCORN, поскольку формат ГУТ гарантирует, что вершины соседних граней лежат на одних и тех же пилларах – для одинаковых глубин получатся и одинаковые координаты X и Y. Благодаря этому свойству, маска состыкованности граней генерируется очень быстро, и нет смысла сохранять её на диск – чтение займёт больше времени, чем расчёт маски с нуля. Этот шаг позволяет нам узнать заранее, сколько памяти выделять под атрибуты вершин. К тому же, не зная требуемый объем памяти, мы не сможем реализовать некоторые оптимизации в будущем. Алгоритм расчёта числа граней тривиален – интерес представляет лишь функция check_if_quad_culled(), которая использует маску состыкованности граней из предыдущего шага. В функции check_if_quad_culled()выполняется две проверки: состыкована ли эта грань с гранью соседней ячейки – для этого проверяем нужный бит маски операцией побитового И; активна ли соседняя ячейка – если нет, то грань придётся нарисовать. Для большинства ГУТ, применяемых в моделировании, эта функция отсекает подавляющее большинство внутренних граней. Это, в свою очередь, позволяет отображать сетки большего размера на одном и том же компьютере. После расчёта числа видимых ячеек приступим к главному, ради чего мы здесь собрались. Cначала создадим вершинные буферы, которые мы уже видели в начале статьи. Пока не будем сразу же выделять память в этих буферах – мы сделаем это в следующей статье в рамках оптимизации. Для простоты, здесь атрибуты будем генерировать в промежуточные массивы в оперативной памяти. Каждая грань состоит из двух треугольников, заданных на 4 вершинах. У вершин есть 5 атрибутов: координаты вершины; нормаль грани; индексы ячейки – нужны для пикинга; текстурные координаты – для рисования границ ячеек; значение свойства в этой ячейке. Как обычно, пройдёмся по всем ячейкам и проверим, активна ли текущая ячейка по маске. Для каждой активной ячейки запишем в буфер трёхмерные координаты всех её восьми угловых вершин с помощью функции get_cell_vertices() Расчёт трёхмерных координат вершин ячеек довольно прост: зная координаты двух точек пиллара, находим X и Y вершины ячейки с помощью линейной интерполяции по известной глубине ячейки Z, которую мы считываем из массива ZCORN. Вернёмся к коду генерации атрибутов вершин: После проверки, видима ли грань, посчитаем её нормаль и приступим к генерации атрибутов вершин: Получив очередную вершину, запишем в массивы атрибутов её координаты, нормаль, индексы ячейки, значение ячейки, и текстурные координаты, заданные следующим массивом: После того, как все атрибуты вершин заполнены, остаётся только загрузить массивы атрибутов в VBO (Vertex Buffer Object): И можно приступать к генерации индексов, формирующих по два треугольника для каждой грани: На этом с генерацией вершин и индексов мы закончили, так что дело за малым: назначить атрибуты в VAO (Vertex Array Object) и начать рендерить ГУТ с помощью шейдеров. Для задания формата атрибутов в OpenGL применяется объект Vertex Array Object. Сам по себе VAO ничего не хранит, а только лишь ссылается на вершинные и индексный буферы и задаёт отступы и размеры, согласно которым вершинный шейдер считывает очередной атрибут из видеопамяти. В функции render() мы передаём в шейдер uniform-переменные, задающие параметры рендеринга: матрицу MVP-преобразования (Model-View-Projection); матрицу поворота нормалей; диапазон значений в палитре; направление источника света; режим пикинга. Также назначаем текстуру палитры в юнит под номером 0. Остаётся только забиндить VAO и вызвать glDrawElements() с запомненным ранее числом индексов. Не будем рассматривать процесс компиляции и линковки шейдера, поскольку он часто специфичен для каждого движка и не представляет особого интереса в этой статье. Приведём сразу код вершинного и фрагментного шейдеров. Вершинный шейдер: В вершинном шейдере, как обычно, проводим MVP-преобразование вершины, после чего передаём нужные данные во фрагментный шейдер. Освещение также посчитаем в вершинном шейдере – так немного быстрее. Остановимся на пикинге индексов, о котором уже упоминалось несколько раз. Пользователям нужно не просто видеть ГУТ, но и совершать с ней какие-то манипуляции, например, посмотреть, какое значение в ячейке под курсором, отредактировать его. Для этого 3D-движок должен предоставить приложению доступ к индексам ячейки, на которую нажал или навёл мышь пользователь. Традиционно, пикинг выполняется трассировкой луча в точку, куда нажал пользователь, но нам такое решение не подходит: пришлось бы хранить в оперативной памяти все трёхмерные вершины, не говоря уж о том, что без применения BVН (Bounding Volume Hierarchy) или другой иерархической структуры скорость такого решения далека от оптимальной. Поэтому мы пошли более простым путём: в атрибуты вершин сохранили индексы ячейки и при запросе пикинга рендерим всю ГУТ в отдельный фреймбуфер размером 1x1 с установленным флагом u_primitive_picking. В этом случае вместо цвета во фреймбуфер попадают индексы ячейки, которые нетрудно выгрузить в оперативную память и передать в приложение. Этот способ пикинга работает довольно быстро, поскольку подавляющее большинство треугольников оказывается за границами вьюпорта и отсекается до растеризации.  Фрагментный шейдер: Во фрагментном шейдере мы принимаем цвет с уже рассчитанным освещением из вершинного шейдера и добавляем к нему wireframe-закраску – более тёмный цвет на границах ячеек. Этот способ наложения границ ячеек практически не влияет на частоту кадров. В этой статье мы рассмотрели простейший рендерер угловой геометрии, который, тем не менее, предоставляет пользователям все необходимые возможности. Пока что он не отличается особым быстродействием и требует много памяти – заявленный в заголовке миллиард ячеек не потянет. Во второй части статьи мы займёмся оптимизацией генерации ГУТ и уменьшим требуемый объём памяти, проведём сравнение с текущей версией нашего рендерера и другими движками в коммерческом софте. Спойлер: потребление видеопамяти сократится почти в четыре раза! На этом первая часть статьи о рендеринге угловой геометрии окончена. Надеемся, что она оказалась интересной и полезной. В комментариях ждём ваших вопросов, будем рады обсудить возможные улучшения рендерера! Программное обеспечение