Давайте напишем ... MMO! Часть 16: Веселые шейдеры

Опубликовано NowhereMan -

10 апреля 2011

В 15-й части у меня были куски пейзажей Minecraft, плавно загружаемые и выгружаемые по мере того, как вы перемещаетесь по миру. Я предполагал, что с изменением шейдеров я смогу значительно сократить память дисплея, используемую каждым фрагментом. На этой неделе, я попытался сделать это.

Первоначальный формат вершин был 3 числа с плавающей точкой для позиции (x, y, z), 3 числа с плавающей точкой для вектора нормали и 3 числа с плавающей точкой для координат текстуры (u, v, и индекс текстурного массива). Всего это 36 байт на вершину.

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

Я не вижу способа передать на вход шейдеру byte или даже short int, поэтому мне пришлось упаковать эти поля в целые числа и сделать логику сдвига и маскировки для их извлечения. Вершина теперь представляет собой два целых числа, 8 байт вместо 36. Используя 200-мегабайтный лимит памяти видеокарты, это означает, что в память легко помещается 1000 чанков.

Я боялся, что эта дополнительная работа в шейдере замедлит процесс, но никакого видимого эффекта нет. Я отрисовал большую сцену (расстояние обзора до 350, вместо 150, которые я использовал), используя обе версии кода. На моей NVidia GeForce GTS 250 сцена занимает 13.83 мс с вершинами с плавающей точкой и еще быстрее (12.39 мс) со сжатыми целочисленными вершинами.

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

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

Рис. 1: Полностью отрендеренный мир

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

Проблема в том, что при этом будут прозрачные данные. Я должен рендерить это в отсортированном порядке, и не хочу переключаться между шейдерами в сцене. Становится всё более заманчивым просто использовать альфа-тестирование на прозрачность, как это делает Minecraft, а затем иметь одну прозрачную текстуру для воды. Если прозрачная текстура только одна, то неважно, в каком порядке я буду её рендерить, пока вся вода рисуется последней.

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

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

Точки вместо кубиков

Я также проверил свою идею из 11-ой части, об использовании точек для подведения итогов на расстоянии. На рисунке 2 виден ландшафт, отображенный в точках. На рисунке 3 крупным планом показаны отдельные точки.

Рис. 2: Рендер точками
Рис. 3: Крупный план

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

Рис. 4: Мир, отрендеренный тремя способами

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

В 8 части я решил, что мне нужны астероиды с радиусом около 630 единиц, или 1260 в поперечнике. Этот блок пространства 1024 на 1024, по диагонали 1448 единиц. Так что речь идет о расстоянии, которое поместилось бы в один из моих астероидов. Я бы очень хотел, чтобы расстояние до этого было таким, чтобы пейзажи никогда не мерцали. Удаленные объекты просто переключат методы рендеринга, как только вы подойдете ближе.

Я все еще экспериментирую с шейдерами и рендерингом, и у меня нет готового демо. Мне придется заставить работать версию OpenGL 3.3, заставить шейдеры работать на ATI-дисплеях, а затем написать версию OpenGL 2.1. Это даст мне порты Linux и Mac. Если я хочу DirectX 9 версию для Windows, мне нужно будет выучить шейдеры DX9, чего я еще не трогал. Вероятно, я выпущу демонстрационную версию для Windows без DX9 для начала.

Тупики

Я также провел пару дней на этой неделе в погоне за проблемой, которая была совсем не такой, как я думал.

Читатель Клеменс Фридль отметил, что демо-версия 15-й части просто пожирает память на его машине. Несмотря на код, ограничивающий использование памяти до 200 мегабайт, он видел, что использование памяти постоянно растет, до более чем 500 мегабайт.

У меня есть собственные отладочные версии функций new и delete (см. Util/debugMemory.cpp в исходных текстах). Они просто вызывают malloc и free, но они позволяют мне отслеживать использование памяти, проверять на наличие дубликатов вызовов delete и перечислять все утечки памяти в конце выполнения.

Согласно этому коду, в части 15 нет утечек памяти. Плюс он отслеживает максимальный объем выделенной памяти, а это около 200 мега (лимит распространяется только на чанковое хранилище, поэтому есть и другие объекты, которые не учитываются).

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

Это достаточно просто. Я могу хранить связанный список блоков памяти (выделенных с помощью new), и просто ставить удаленные блоки обратно в список. Тогда мое использование памяти никогда не должно выходить за рамки того, что мне нужно. Так как старые блоки удаляются, а новые загружаются, то блоки памяти просто помещаются в свободный список, а затем снова выдаются.

Я внедрил это для чанков в Octree. Это было достаточно просто, так как у меня уже был код для управления блоками нод Octree (на самом деле вы не хотите вызывать new тысячи раз на каждый чанк). Это ничего не изменило.

Другим большим пользователем памяти являются вершинные и индексные объекты, созданные для отрисовки чанков. Они существуют недолго - код создает буфер, заполняет его, затем передает его на отображение с помощью OpenGL glBufferData. Затем копия буфера в системной памяти освобождается.

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

Это действительно изменило ситуацию - ухудшило! Под OpenGL я использовал glBufferData для перемещения всего буфера за один вызов. Так как теперь он был в блоках, мне пришлось использовать другой интерфейс. Я вызвал glMapBuffer, чтобы получить указатель на эту память, скопировал каждый из блоков вершинных данных, затем вызвал glUnmapBuffer, чтобы освободить память OpenGL. Похоже, это изменило способ обращения OpenGL с буфером. В Windows Resource Monitor показал дополнительные 200 мегабайт памяти, все помеченные как "shareable".

В тот момент я вдруг понял, что совершил глупую ошибку. Я отлаживал версию OpenGL, и даже не видел огромного роста памяти, который видел Клеменс. Я понял, что он работает с версией DirectX9. И, конечно же, это все меняет. Там, где моя часть 15 кода OpenGL ограничивала "рабочий набор" 276 мегабайтами, моя версия DirectX достигала 577 мегабайтов. Она работает на том же тестовом примере, рендерит тех же самые куски и т.д.

Я немного покопался в версии DirectX, но не смог найти проблему. Я освобождаю все буферы, как только они перестают использоваться, и Release возвращает нулевое количество ссылок. Поэтому я не понимаю, зачем DirectX должен удерживать какую-либо память.

Если у кого-нибудь из специалистов по DirectX есть идеи, дайте мне знать. Вы можете увидеть код в Framework/Graphics3D/DX9/DX9Indexes.cpp. Другой большой буфер находится в DX9VertexTA.cpp в том же каталоге. Это очень простой кусок кода.

Обновления блога

За последние три части я вошёл в привычку размещать описание того, что я делаю, а затем обновлять по мере того, как работает демо. По лог-файлам сервера трудно сказать, стоит ли это мне читателей. Я предполагаю, что вы все следите за этим через RSS и получаете уведомления об обновлениях. Если нет, дайте мне знать в комментариях. Я могу добавлять сообщение "update" вверху страницы каждый раз, когда я изменяю его, или на первую страницу блога, если это поможет.

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

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

Дайте мне знать, что вы думаете.