Отделяем свет от тьмы 3

Неровные мазки

Alexey “Averrin” Nabrodov
16 min readOct 28, 2017

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

После переработки системы отрисовки

Получилось так, что генератор поделен на несколько больших частей: генератор (первая часть), симулятор (вторая), отрисовщик (эта часть) и приложение (эта же часть, там где про интерфейс). Это разделение призвано сделать независимыми генерирование данных и их отображение. Это оказалось очень полезным, когда я переписывал отрисовку на слои. Сегодня я буду говорить как о сложностях старой системы рисования, так и о новом варианте. Однако сразу раскрою карты: я решил перестать развивать плоскую версию и собираюсь переделать отрисовку на 3d.

ЖивопИсь?

Полагаю, надо немного рассказать о том, как вообще происходит рисование. Обычно функция отрисовки представляет из себя цикл в котором проверяется наличие событий (нажатие кнопок, операции с окнами) и происходит рисование графических примитивов. Следует понимать, что говоря “примитивы”, я имею в виду действительно очень простые вещи. Если вы не используете какую-либо библиотеку, то вам самим придется реализовывать, например, рисование кругов и многоугольников. Да что там говорить, даже просто нарисовать линию так, чтобы не текла кровь из глаз — это уже не очень простое дело. Но об этом попозже.

Есть еще вариант использовать “движки”, то есть основу для игр, в которую обычно уже встроено много дополнительного, вроде физики, своей иерархии объектов, интерфейса и так далее. Для данной задачи это казалось излишним. Однако в процессе работы я понял, что сделать что-то визуально привлекательное не используя движки — очень непросто. Поэтому для 3д я буду использовать один из их великого множества. Но об этом в следующей статье, если ей суждено быть.

Готовим краски

Все изображения, используемые в генераторе

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

  • Шрифты
  • Изображения
  • Шейдеры (возможно, вы не знаете что это, но не переживайте, я подробно на этом остановлюсь)

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

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

В последней версии генератора к иконкам присоединились текстуры, которые также являются изображениями, поэтому соответствие картинка->тип_локации сменилась на картинка->строка, то есть я просто храню их по именам. Хотя структура с соответствием типа локаций их иконкам очень удобна, поэтому я просто сделал ее надстройкой над этими же объектами.

Шейдеры. Звучит страшно, но на самом деле это гораздо более простая вещь, чем уже описанные. Это всего лишь текстовой файлик с микропрограммой, которая содержит инструкции для видеокарты. В определенный момент я буду говорить “видеокарта, отрисуй эту картинку, но используя эту инструкцию” и так будут получаться разнообразные эффекты.

Великое и бесконечное Ничто

Чтобы что-то нарисовать, помимо всякого технического нам надо чтобы было что рисовать. А преобразование абстрактной карты в конкретные фигуры занимает некоторое время. Поэтому мы отправляем генератор делать свою работу, а сами начинаем рисовать хотя бы что-то.

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

Как вы понимаете (или не понимаете), есть два варианта рисования: залить все окно каким-то цветом (очистить) и нарисовать поверх все что нужно, либо рисовать поверх уже существующей картинки. Второе значительно быстрее, но имеет понятные ограничения связанные сразными эффектами и расчетами. Здесь я просто скажу, что при возникновении окна загрузки мы один раз рисуем полупрозрачный прямоугольник поверх предыдущего кадра, а затем каждый кадр накладываем черные прямоугольники с текстом и прогрессбаром поверх прошлых. Так у нас перерисовываются очень небольшие области экрана. Однако это лишило нас возможности сделать и их фон прозрачным, так как в противном случае там был бы виден предыдущий текст. А хранить в памяти отдельно нужный нам кусочек фона — слишком жирно для такой ерунды.

Как можно более маленькое и конечное Что

Один из самых бросающихся в глаза багов

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

Окей, понятно, список фигур и их свойства не меняются без каких-либо действий с нашей стороны, то есть его можно пересобирать, только если мы поменяли какие-то параметры отрисовки или всю карту. Однако нарисовать десять тысяч многоугольников, сотню иконок и десяток сплайнов — это все равно не очень быстро. Во-первых, это для нашего кода “нарисовать” — это вызвать одну функцию, а для нижележащего кода это все еще множество расчетов очень значительно нагружающих процессор. А во-вторых, это явно различимая задержка перед отрисовкой следующего кадра, а значит когда мы соберемся взаимодействовать с картой, мы будем видеть “подтупливание”.

Если и дальше следовать начатой логической цепочке, то быстро родятся еще две простых оптимизации:

  • Можно же один раз все нарисовать и больше не очищать окно. Увы, покуда мы делаем нашу карту интерактивной, это далеко не всегда возможно. Все движущиеся объекты или выделение, которые мы рисуем поверх нельзя “счистить” с окна не трогая уже нарисованную карту. Хотя, конечно, стоит иметь в виду, что если действительно ничего не происходит, то перерисовка нам не к чему.
  • Хорошо, тогда может быть отрисуем сложные фигуры один раз, например на какую-нибудь картинку, и потом будем заполнять окно не цветом, а ею? Бинго! Именно эта оптимизация и даст нам заветные 60 кадров в секунду. Нам действительно будет очень удобно иметь “фотографию” нашей карты, чтобы использовать как фон. И для ее отрисовки вовсе не нужно делать сложные расчеты и смешивания полупрозрачных цветов, это всего лишь набор разноцветных точек, а именно их компьютер умеет рисовать быстрее всего.

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

Вавилонская башня

Да, при отрисовке используется 11 слоев

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

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

И снова простыми словами. Решение лежащее на поверхности — сделать некие “слои”, которые как и общая карта будут отрисовываться на картинки, которые потом будут собираться в одну общую. Так можно будет манипулировать их порядком, прятать некоторые и, что немаловажно, раздельно обновлять.

Получаем “примитивы” более высокого уровня, которые сами решают как рисоваться (и рисоваться ли вообще), рисовать фигуры или картинку, и какие применять эффекты к итоговому результату. В качестве “бонуса” мы получаем прозрачность (которую я переоценил поначалу) и отсутствие антиалиасинга, который делает нашу картинку несколько более уродливой. Об этом я расскажу еще ниже, а пока, я надеюсь, вы следите за мыслью и понимаете, что я объединил наши объекты в некие “группы”, которыми удобнее манипулировать чем одним большим массивом. Сейчас будем разбираться в чем же удобство.

Хлябь

Цвет биома, усреднение по соседям, блур-эффект

Сердцем отрисовщика является метод, который, получая регион, отдает нам многоугольник, готовый для отрисовки. Именно здесь решается какого он будет цвета и формы. Казалось бы, все очень просто, но если рисовать втупую, то получится не очень красиво. Например совсем плоские варианты вы видели в первой статье. На рисунке выше можно увидеть чуть более продвинутый вариант. Если приглядеться заметен небольшой “шум” когда соседние регионы чуть-чуть отличаются по цвету. Это делается сознательно, чтобы придать некого “объема”. Про это я скажу в конце статьи, так как результаты этой игры в реализм привели к несколько неожиданным результатам.

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

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

HSL и RGB

Как видно по иллюстрации, цвет может быть представлен или в виде отношения трех цветовых компонент (красного, зеленого и синего) или как набор других трех параметров: оттенок, насыщенность и яркость. Оба представления обычно имеют еще и показатель прозрачности. RGB удобен когда вам нужна возможность сделать “краснее” или “синее”. Этот формат прост в расчетах и удобен для усреднения различных цветов, так как мы можем просто посчитать среднее арифметическое для каждой из компонент и объединить в один цвет. Однако сделать “темнее”, “насыщеннее” или сделать близкий, но в то же время отличающийся, цвет будет очень сложно. Для этого удобнее HSL, когда мы регулируя всего один показатель получаем, например, более светлый оттенок того же цвета. Упомянутый выше “шум” достигается незначительным сдвигом показателя оттенка.

Второй эффект, который применяется к водным регионам — размытие. И говоря про него мы вернемся к понятию шейдера. Это и правда очень простая штука. Так как я сейчас говорю о, так называемом, фрагментном шейдере, это всего лишь код, который видеокарта применяет ко всем пикселям скормленной ей картинки. То есть для каждого вызова на вход подается текущая точка и полная картинка. И я, например, могу взять цвет текущей точки и подредактировать его относительно соседних. Да, именно так, как я это делал с усреднением цвета, но для каждой точки. Для размытия указывается еще и радиус, на который размывается цвет, что делает картинку “гладкой” против того, какая она была разбитой на многоугольники. Это очень красивый эффект и в подходящем месте вызывающий “вау-эффект”. Тем не менее, со временем мне начало казаться, что здесь он лишний.

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

Твердь

Это гора. Узнали? И я нет=(

Несколько иначе обрабатывается внешний вид регионов на суше. Для начала поговорим о высоте. Обычно объем демонстрируется с помощью света и тени, однако я в своей программе так и не дошел до расчета освещенности, поэтому я просто корректировал яркость. Однако тогда у меня еще не было преобразования представлений цвета и используя RGB это сложно было сделать хорошо. И я поступил хитро: я варьировал прозрачность, что вкупе с темным фоном меняло яркость цвета. Так в рамках одного биома у нас был градиент высот. Тем не менее, когда я начал баловаться эффектами, я понял, что прозрачность может сыграть и против меня. Например, когда я хотел рисовать границу между сушей и морем, самым простым решением было рисовать под сушей еще один такой же слой, но с толстыми границами многоугольников. Поверьте, это было куда проще чем строить новый многугольник-остров, который будет рисовать только нужные границы. То же самое происходило когда я делал “тень” под лесом. Естественно, просвечивающие сквозь верхний слой полигоны не только меняли внешний вид, но и делали это неравномерно, так как были или сдвинуты или имели цветные границы. Здесь мне тоже помогло представление HSL, которое позволяло менять яркость цвета. Правда, это все равно так себе решение, так как хорошо работает для одного цвета, а у меня несколько биомов и, по-хорошему, надо бы менять яркость в диапазоне допустимых высот конкретного биома, а не глобальных максимума и минимума. Ну да это уже не важно.

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

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

Отдельно выделены леса и озера, которые рисуются поверх рек, а у лесов еще и “тень”, которая реализуется так же как и “объем” всей суши.

Стоит также упомянуть и про “режимы”, в которых отображается влажность, высота и так далее. Это тот же набор полигонов, но с другим цветом, который зависит от выбранного режима. Я хотел сделать их отдельными слоями, но руки не дошли, а сейчас они уже вряд ли понадобятся. Слои, не руки=)

Прочее. И подобное и далее

Сглаживание добавляет полупрозрачные пиксели, убирая эффект “пилы”

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

Лично у меня по-настоящему течет кровь из глаз

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

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

Сравните скругление дорог и границ. Очевидно кто из них рисуется со сглаживанием

Почему же я не делаю это и с реками? Дело в том, что во многом начинание по переписыванию было задумано и чтобы “красиво” рисовать реки. Красиво не получилось, но есть о чем рассказать. Помните как отвратительно раньше реки висели над морем, куда впадали? А теперь еще раз посмотрите на картинку с реками. Видите, как они обрезаны по краю суши? За этим стоит целая история.

Если пропустить все нецензурные выражения и бездарно потраченный выходной, то все сводится к воплю “Ну как вы за семь лет не реализовали маски?!” и решению использовать шейдер. Да, по сути, маска — это инструкция где рисовать пиксели, а где не рисовать. Ненарисованные пиксели — это пиксели прозрачные, а значит можно посчитать это шейдером, используя слой суши как указание на то, что эти пиксели не надо делать прозрачными. Вот и все, я смог унылейшим образом рассказать то, где можно было целую увлекательную статью сваять. Не, ну правда, если вы с первого раза дочитали до сюда, то вы читаете уже для информации, а не удовольствия. Поэтому дальше будем кратко.

Оставшиеся слои включают в себя:

  • Границы государств, которые рисуются сплайнами. Тут, правда, тоже есть хитрость. Как понять, откуда и куда рисовать линию? Для того чтобы найти эти “угловые” точки мне приходится фильтровать регионы так, чтобы вычислить тот, который является граничным и имеет только одного соседа, который является “берегом”. То есть я ищу, где идущая по берегу граница “заворачивает” и начинает идти по суше. Затем я рекурсивно, похоже на алгоритм нахождения пути, ищу следующую сухопутную границу, куда можно продолжить линию. Из-за неровности полигонов иногда случаются сложности, когда, например, у угла два соседа, или поиск следующей границы заходит в тупик, делая петлю. Что-то из этого я исправил, а что-то оставил на неопределенное и все более туманное будущее.
  • Подписи. То есть те самые таблички с именами городов. Тут было бы что написать, будь у меня алгоритм их умного размещения, но это тоже не в этот раз. Хотя я знаю, что есть товарищи, которые убили на эту проблему уйму времени. Оцените.
  • Локации. Просто спрайты с картинками. Ничего интересного.
  • И самое главное: надпись об авторстве и версия. Да, конечно, заслуживает отдельного слоя=)

Ох, да, чуть не забыл! “Бродяги”. Те самые бегающие точки. Их не имеет смысла кэшировать, так как они каждый кадр имеют новую позицию. Если говорить просто, то это всего лишь счетчик, который увеличивается каждый кадр. Из этого счетчика выводится позиция “бродяги”. Делается это элементарным взятием очередной точки из сплайна выбранной дороги. Они выбираются случайно когда заканчивается предыдущая. Единственное, что стоит учитывать — то, что дорога может вести не из этого города, а в него, и поэтому “тик” счетчика будет его уменьшать, а не увеличивать, чтобы двигаться с правильного конца дороги.

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

Интерфейс

Флажки, деревья, таблицы и прочая

Ну он есть, да. И мне придется про него рассказать, так как это может быть интересно=( Если совсем честно, то вообще весь этот проект начался с того, что мне захотелось написать что-то с использованием этой библиотеки, а то что это будет именно генератор карт, стало понятно уже потом. Библиотека имеет название ImGui и предназначена не для написания привычных всем приложений, а именно для “вспомогательного” интерфейса для игр или другого софта с визуализацией.

Если не углубляться, то интересна она своим подходом, а именно “immediate mode” , то есть, в большинстве своем элементы интерфейса не являются отдельными объектами, которые живут вместе с приложением, а представляют из себя функции, которые выполняются каждый цикл отрисовки. Если вы не программировали интерфейсы ранее, а только читаете мою статью, для вас это должно казаться совершенно очевидным решением, так как остальная отрисовка примерно так и работает. Но на самом деле обычные библиотеки интерфейсов работают абсолютно не так.

Собственно, интерфейс состоит из следующих частей:

  • Главное окно, в котором находятся разнообразные настройки. Под ними находится всего лишь инвалидация кэша отрисовщика, а чтением текущего значения и установкой нового занимаются сами контролы.
  • Информационное окно. Оно появляется когда включен режим подробной информации. В нем каждый кадр вычисляется регион, находящийся под курсором мыши, а окно наполняется различной информацией об этом регионе. Более того здесь вычисляется набор дополнительных, “информационных” полигонов, которые будут отрисованы поверх основной карты. Так как они полупрозрачны, это будет выглядеть как выделение, которое подсветит как текущий регион, так и его родительские кластеры.
  • Похожим образом работает “Окно объектов”, которое содержит дерево всех кластеров, локаций и рек на карте. Оно помогает проверить корректность всех параметров сгенерированных объектов и найти странности на карте, не водя мышкой по всем.
  • Ну и окно симуляции, которое содержит настройки экономической модели и график результатов. Да, библиотека содержит и такие контролы, что делает ее отличным выбором для подобных прототипов.

Будущее

Честно говоря, на момент написания этой статьи, я уже около двух недель не написал ни строчки кода в генератор. Во-первых, я утомлен и разочарован теми возможностями, которые у меня есть в 2д при текущем выборе технологий. А так как я заранее подумал об архитектуре, по идее, будет не сложно сменить технологию отрисовки. Мне всегда импонировал низкополигональный стиль, поэтому я хочу постараться небольшими усилиями сделать привлекательную 3д картинку.

Что-то вроде такого

Вторая причина, которая привела к отказу от 2д, это достигнутый эффект “зловещей долины”. То есть все мои эффекты и улучшения картинки привели к тому, что потерялся “схематичный” стиль, но “реалистичный” так и не был достигнут. Поэтому я и решил сделать качественный шаг в направлении реализма, который расширит возможности оставаться в “мультяшном” стиле, который требует куда меньших трудозатрат. Да и ассеты стоят куда меньших денег=)

Да, это прыжок в совершенно другую и категорически неизведанную область, но в этом изначально и была цель проекта. Вообще я бы хотел отметить то, что я, благодаря этому небольшому генератору, поднял с нуля до приемлемого “джуна” знания одного из самых используемых и уважаемых языков программирования — С++, узнал множество отвлеченных, а часто и совершенно не технических вещей, пообщался с интересными людьми из разных стран, а также понял, насколько важно делиться своими результатами в процессе работы. Это не только мотивирует на продолжение разработки, но и учит делать некие “минимально полезные” версии, которые не стыдно показать, даже если они совсем еще ничего не умеют. Ну и нельзя умалять того, сколько новых идей мне дали люди, которые пробовали мои демо-версии или смотрели скриншоты.

В общем, спасибо, что были со мной все это время, надеюсь, мне еще найдется что вам рассказать.

Кошк одобряет

--

--

Responses (10)