August 26, 2020

Реалистичное освещение персонажей в мобильных играх

Судя по отзывам, игрокам понравились миниатюры юнитов в мобильной версии игры Terminator Genisys: Future War. Мы исследовали возможность добавления таких же миниатюр в один из наших популярных проектов — Vikings: War of Clans.

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

Для этого требуется использовать:

  1. Стандартные карты Roughness и Metalness.
  2. Кубические текстуры окружения.
  3. Специальный математический аппарат для соблюдения закона сохранения энергии, согласно которому объект не может отражать больше света, чем на него попадает.
Пример физически корректного освещения для материалов с различными характеристиками

Посмотрим, как можно применить предложенные в каждом пункте варианты для реализации PBR на мобильных устройствах с поддержкой OpenGL ES 2.0 и OpenGL ES 3.0.

Карта Roughness. Шероховатость объекта

Текстура roughness

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

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

Для работы с такой текстурой используется шейдерная инструкция:

float4 texCUBElod(samplerCUBE samp, float4 s);

Samp — имя кубической текстуры, а s — координаты чтения (xyz) и уровень детализации (w).

Таким образом, если есть 8 уровней детализации текстуры, то можно выбрать нужную, исходя из значения roughness* 7 (потому что начинаем отсчет не с 1, а с 0):

float4 glossyReflection = texCUBElod (cubemap, float4 (coords, (1 - roughness) * 7));

Такой подход применим только на устройствах с поддержкой OpenGL ES 3.0. В OpenGL ES 2.0 инструкция texCUBElod не поддерживается.

Чтобы данный шейдер работал и на устройствах с OpenGL ES 2.0, необходимо вычислить приблизительное значение глянцевого отражения. Для этого из кубической текстуры отражения получаем кубическую текстуру рассеянного отражения (irradiance cubemap), используя инструменты движка или сторонние приложения, например cmftStudio.

Затем на основании значения roughness интерполируем значение отражения между зеркальным и рассеянным. При этом будут заметны незначительные визуальные различия между шейдером под OpenGL ES 2.0 и 3.0.

Влияние значения roughness на результат

Карта Metalness. Металличность объекта

Текстура metalness

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

Для упрощения будем считать, что:

metallicReflection = materialColor * reflection;nonmetallicReflection = materialColor + reflection;

В большинстве случаев объекты обладают свойствами металлов и неметаллов одновременно, поэтому можно вывести общую формулу:

metallReflection =metallicReflection + nonmetallicReflection;metallReflection = materialColor * reflection + materialColor + reflection;

Влияние значения metalness на результат

Запеченное освещение от источников света

Скриншот Spherical Harmonics в Unity

Во многих современных игровых движках есть запекаемые точечные замеры освещения Spherical Harmonics. Подробная информация есть в документации к движку, которым вы пользуетесь. В наших проектах мы используем Unity, где замеры называются Light Probes.

Чтобы прочитать уже запеченное освещение, необходимо знать направление нормали в данной точке.

half3 shl;

shl.r = dot(unity_SHAr, normalDirection);shl.g = dot(unity_SHAg, normalDirection);shl.b = dot(unity_SHAb, normalDirection);

Energy Conservation. Закон сохранения энергии

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

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

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

В результате получаем новые условия:

  • Объект не может быть 100%-ным металлом и 100%-ным неметаллом одновременно, поэтому glossyReflection * (1 -metalness*0.85). Коэффициент 0,85 позволяет оставить немного глянцевого отражения и на металлах.
  • Нельзя сложить metalReflection и glossyReflection, так как результат будет больше 1. Поэтому нормализуем отражение: (metalReflection * 0.75 + glossyReflection *0.25)*2.
  • Объект не может одновременно создавать зеркальное отражение и обладать видимой текстурой, поэтому albedoShl * (1 - metalness) + reflection.

Если объединим результаты, получим примерный код шейдера.

Для OpenGL ES 3.0:

half3 shl;

shl.r = dot(unity_SHAr, normalDirection);
shl.g = dot(unity_SHAg, normalDirection);
shl.b = dot(unity_SHAb, normalDirection);

half3 albedoShl = albedo * shl;
half3 oneMinusMetalness = (1 - metalness*0.85);

half3 cubeMap = texCUBElod(_Cubemap, half4(direction, ((1 - roughness) * 7)));

half3 metalReflection = saturate(cubeMap * albedoShl * metalness);
half3 glossReflection = saturate(cubeMap * shl * roughness * oneMinusMetalness);

half3 reflection = metalReflection * 0.75 + glossReflection * 0.25;
col.rgb = albedoShl * (1 - metalness) + reflection * 2;

Для OpenGL ES 2.0:

half3 shl;
shl.r = dot(unity_SHAr, normalDirection);
shl.g = dot(unity_SHAg, normalDirection);
shl.b = dot(unity_SHAb, normalDirection);

half3 albedoShl = albedo * shl;
half3 oneMinusMetalness = (1 - metalness*0.85);

fixed3 glossy = texCUBE(_CubemapGlossy, direction);
fixed3 matte = saturate(texCUBE(_CubemapIrradiance, direction));
half3 cubeMap = lerp(matte, glossy, roughness*roughness);

half3 metalReflection = saturate(cubeMap * albedoShl * metalness);
half3 glossReflection = saturate(cubeMap * shl * roughness * oneMinusMetalness);

half3 reflection = metalReflection * 0.75 + glossReflection * 0.25;
col.rgb = albedoShl * (1 - metalness) + reflection * 2;

Эти шейдеры отличаются всего на 1 кубическую текстуру, поэтому при невозможности запуска OpenGL ES 3.0 мы можем автоматически подключить шейдер OpenGL ES 2.0. Функция удобна в использовании и помогает получить предсказуемую картинку даже на слабых устройствах.

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

Результат наших усилий виден на примере юнита из Vikings: War of Clans. Вы можете адаптировать описанный выше способ работы с PBR-шейдером под свой проект и реализовать качественное освещение даже на девайсах, которые раньше его не тянули. Удачи!