Эффект тумана войны из Civilization VI — отличный пример простой структуры вычислительного шейдера (compute shader). Если вы всегда хотели узнать об основах программирования таких шейдеров, то этот туториал для вас. Вы сможете понять его даже без знания шейдеров и программирования на C#; более опытные разработчики могут пропустить введение.
Анализ эффекта
Давайте начнём проект с изучения и анализа эффекта в игре. К счастью, Civilization — пошаговая игра, поэтому мы можем наблюдать эффект столько, сколько нам нужно. Я загрузил своё старое сохранение и сделал пару скриншотов разных областей мира.
Первое, на что нам нужно обратить внимание — граница между видимой и скрытой областями. «Скрытая» область — это напоминающая нарисованную от руки карту область, покрытая туманом войны. Мы чётко можем видеть, что граница не совпадает точно с полями шестиугольников и что присутствует небольшой шум, скорее всего шум Перлина.
Шум Перлина
Шум Перлина — это тип шума, используемый для моделирования органических форм. Обычно на его основе создаются карты высот рельефа и эффекты растворения.
Теперь давайте рассмотрим графическую составляющую скрытой области. Эффект на ней напоминает эффект изображения поверх статических мешей (зданий, растительности…) в сцене (контур границы и нечто, напоминающее тень). Очевидно, что динамические объекты под туманом войны невидимы, потому что это лишило бы его смысла.
Кроме этого присутствуют нарисованные от руки текстуры рельефа, например, травы, которые особенно заметны на пустых тайлах. В своём проекте мы будем использовать только эти текстуры, потому что создание подобного эффекта изображения в дополнение к шейдеру было бы слишком масштабной задачей для одного туториала.
Также есть ещё два аспекта, создающих эффект тумана войны. Первый — это градиент границы области, который чётко можно заметить на тайлах океана. Также можно увидеть, что он не имеет постоянной толщины, то есть он тоже основан на упомянутом выше шуме Перлина.
Во-вторых, поверх эффекта есть небольшой шум, разрушающий монотонность больших пустых тайлов. Его можно увидеть на тайлах океана к югу (на тех, у которых нет волн побережья). Для этого тоже можно использовать текстуру шума Перлина.
Я подготовил шаблон проекта для этого туториала, в котором уже создана предварительная структура. Если у вас его нет, то можете клонировать или скачать его с GitHub.
Шаблон проекта
В следующем разделе я вкратце расскажу о важных частях структуры проекта, особенно о скрипте управления вычислительным шейдером на C#.
Проект Unity
Начнём с открытия «Assets/SampleScene.unity». Как видите, в сцене почти ничего нет — только простая сетка шестиугольников, источник направленного освещения и камера.
Здесь я хочу обратить внимание на два аспекта: настройку постобработки камеры и структуру материалов.
Выбрав основную камеру, вы увидите объём и слой постобработки. Здесь мы используем пакет постобработки Unity. Применяемые эффекты достаточно просты. Присутствует нейтральная тональная коррекция, небольшое виньетирование и глубина резкости. Можете свободно экспериментировать с этими эффектами и добавлять новые, если хотите изменить внешний вид результата.
Если выбрать произвольное поле шестиугольника в сцене, то вы увидите. что ему уже назначен материал. Хотя все они используют одинаковый шейдер, в разных типах тайлов применяются разные спрайты. В случае представленного выше изображения тайл позже станет ветряной мельницей. В нашем проекте используется 9 разных типов тайлов, их материалы находятся в папке «Assets/Materials».
Это подводит нас к текстурам тайлов. В папке «Assets/Textures» находятся цветные текстуры каждого тайла, а также их нарисованные от руки версии с суффиксом "_Map". Цветные текстуры взяты из Hexagon Pack разработчика Kenney.
Kenney Assets
На сайте Kenney.nl есть множество бесплатных ассетов (2D, 3D и звуковых), которые можно использовать в своих проектах. Большинство из них даже имеет лицензию Creative Commons.
Нарисованные от руки версии этих текстур соответствуют цветным текстурам, что важно для эффекта, ведь на границе тумана войны он не должен иметь никаких смещений относительно исходного тайла.
Также в этой папке есть ещё две текстуры. «PerlinNoise» — это текстура шума Перлина.
Генератор текстур
Для создания текстуры шума Перлина я использовал этот онлайн-генератор текстур Кристиана Петри. Если вы хотите поэкспериментировать с другими значениями шума, то можете просто заменить текстуру из проекта новой.
Вторая текстура («MapBackground») используется для тех мест в тумане войны, которые нарушают монотонность больших пустых поверхностей.
Разобравшись с текстурами, перейдём к шейдерам и скриптам. В папке «Assets/Shaders» есть два шейдера: «MaskCompute» — это вычислительный шейдер, используемый для генерации маски видимых и скрытых областей; «Tile Shader» — это шейдер, применяемый к материалам тайлов. Он сэмплирует значение в текстуре маски, созданной вычислительным шейдером, и на основании неё рендерит текстуру тайла или туман войны. Подробнее мы рассмотрим шейдеры в следующих разделах.
Также в «Assets/Scripts» есть два скрипта. Чтобы понять, что происходит в шейдерах, важно понять логику C#. Давайте разберём по порядку каждый из них. Начнём с «GridCell».
Этот скрипт прикреплён к каждой шестиугольной ячейке в сцене. Он предназначен для управления видимостью ячейки и переключает её, когда происходит взаимодействие с мышью.
private void Start()
{
MaskRenderer.RegisterCell(this);
}
В начале каждая ячейка добавляет себя в список ячеек в скрипте «MaskRenderer» при помощи вызова функции «RegisterCell». В готовой игре этот список должен просто заполняться при помощи инспектора, однако для прототипирования довольно полезно иметь подобную функцию, потому что она без лишних действий позволяет увеличивать размер карты.
private void OnMouseDown()
{
ToggleVisibility();
}
private void OnMouseEnter()
{
ToggleVisibility();
}
Мы хотим иметь возможность взаимодействия с демо и переключения видимости ячеек. Для этого у каждой ячейки есть коллайдер. При помощи OnMouseDown() и OnMouseEnter() можно перетаскивать курср мыши по экрану и переключать по пути видимость всех ячеек.
private IEnumerator AnimateVisibility(float targetVal)
{
float startingTime = Time.time;
float startingVal = Visibility;
float lerpVal = 0.0f;
while(lerpVal < 1.0f)
{
lerpVal = (Time.time - startingTime) / 1.0f;
Visibility = Mathf.Lerp(startingVal, targetVal, lerpVal);
yield return null;
}
Visibility = targetVal;
}
Давайте рассмотрим эту корутину. Используемый в ней паттерн стандартен для анимации, управляемой через скрипт на C#. Преимущество самостоятельно выполнения вычислений вместо подготовки и воспроизведения анимации в Unity заключается в возможности приостановки анимации в любой момент времени.
Теперь откроем скрипт «MaskRenderer.cs». Важно, чтобы вы полностью его поняли, ведь он управляет логикой вычислительного шейдера.
private static List<GridCell> cells;
public static void RegisterCell(GridCell cell)
{
cells.Add(cell);
}
Как сказано ранее, каждая ячейка добавляет себя в список ячеек, используемый рендерером масок. Позже мы создадим вычислительный буфер (compute buffer) с удобной для шейдера struct переменных из списка.
[SerializeField, Range(64, 4096)]
private int TextureSize = 1024;
[SerializeField]
private float MapSize = 0;
[SerializeField]
private float Radius = 1.0f;
[SerializeField, Range(0.0f, 1.0f)]
private float BlendDistance = 0.8f;
Здесь перечислено несколько переменных, открытых для редактора; они задают основные параметры эффекта. «TextureSize» — это размер создаваемой текстуры маски, в идеале он должен быть степенью двойки. «MapSize» — это физический размер ячейки шестиугольников в единицах измерения Unity. Позже нам потребуется это число, чтобы наложить текстуру маски на сетку. «Radius» — это радиус одной ячейки, т.е. расстояние между центром и углом. Вместо того, чтобы определять, находится ли текущий вычисляемый тексел внутри поля шестиугольника, мы проверяем, находится ли он внутри описывающей поле окружности. Последний параметр — это «BlendDistance», определяющий ширину вокруг видимой области, которая используется для смешения с невидимой областью. Внутренний радиус области смешивания вокруг ячейки задаётся переменной «Radius», внешний — значением «Radius» + «BlendDistance».
private RenderTexture maskTexture;
Это текстура, которую мы записываем в вычислительный шейдер. При работе с render texture нужно учитывать множество аспектов, по сравнению с другими объектами Unity C# они довольно низкоуровневые. В отличие от других объектов, их не очищает сборщик мусора, поэтому чтобы освободить память для другой информации, нам придётся вручную вызывать Release() после того, как они больше не будут нам нужны.
Память Render Texture
Данные Render texture хранятся в памяти GPU, то есть хорошо они работают только тогда, когда все операции выполняются на стороне GPU, как и происходит в нашем случае (мы выполняем в них запись в вычислительном шейдере и считываем их в шейдере тайлов). Однако в момент копирования обратно в память ЦП вы заметите довольно большой пик времени вычисления кадра.
private static readonly int textureSizeId = Shader.PropertyToID("_TextureSize");
private static readonly int cellCountId = Shader.PropertyToID("_CellCount");
private static readonly int mapSizeId = Shader.PropertyToID("_MapSize");
private static readonly int radiusId = Shader.PropertyToID("_Radius");
private static readonly int blendId = Shader.PropertyToID("_Blend");
private static readonly int maskTextureId = Shader.PropertyToID("_Mask");
private static readonly int cellBufferId = Shader.PropertyToID("_CellBuffer");
В этом скрипте на C# мы задаём довольно много переменных, и большинство из них задаётся в каждом кадре. Чтобы избежать сравнения строк в каждом вызове, мы кэшируем ID свойств в виде integer. Так следует делать всегда при работе с шейдерами в скрипте на C#. Здесь имя каждой переменной должно оставаться таким же, как в шейдерах, чтобы движок Unity мог их задавать.
private struct CellBufferElement
{
public float PositionX;
public float PositionY;
public float Visibility;
}
private List<CellBufferElement> bufferElements;
private ComputeBuffer buffer = null;
Вычислительный буфер используется для парсинга информации ячейки для вычислительного шейдера. Благодаря использованию одинакового типа для каждого из значений struct мы в дальнейшем сможем просто использовать для задания данных каждой ячейки в вычислительном шейдере float3. Позже мы увидим, как работает этот буфер.
private void Awake()
{
cells = new List<GridCell>();
maskTexture = new RenderTexture(TextureSize, TextureSize, 0, RenderTextureFormat.ARGB32)
{
enableRandomWrite = true
};
maskTexture.Create();
computeShader.SetInt(textureSizeId, TextureSize);
computeShader.SetTexture(0, maskTextureId, maskTexture);
Shader.SetGlobalTexture(maskTextureId, maskTexture);
Shader.SetGlobalFloat(mapSizeId, MapSize);
bufferElements = new List<CellBufferElement>();
}
Мы выполняем настройку основных параметров шейдера и render texture. Обратите внимание: несмотря на то, что используемый здесь формат текстур является лишней тратой ресурсов и его можно заменить на другой (нам бы хватило формата с одним каналом, поскольку нам нужно только значение маски), необходимо, чтобы была включена произвольная запись. Мы задаём размер текстуры и саму текстуру для вычислительного шейдера как глобальные переменные.
Глобальные переменные шейдера
В масштабных проектах никогда не стоит использовать глобальные переменные. Однако они довольно полезны при прототипировании, так как эти переменные без дальнейшей настройки можно использовать в любом шейдере.
private void OnDestroy()
{
buffer?.Dispose();
maskTexture?.Release();
}
Как говорилось выше, есть низкоуровневые объекты Unity, для которых мы должны самостоятельно заниматься управлением памятью. В нашем случае это вычислительный буфер и render texture.
bufferElements.Clear();
foreach (GridCell cell in cells)
{
CellBufferElement element = new CellBufferElement
{
PositionX = cell.transform.position.x,
PositionY = cell.transform.position.z,
Visibility = cell.Visibility
};
bufferElements.Add(element);
}
Последняя часть нашего скрипта — это функция Update(). В ней мы начинаем с создания элементов вычислительного буфера для каждой ячейки и их добавления в список элементов. Мы должны обновлять их в каждом кадре, потому что значения видимости меняются в зависимости от того, видима ли ячейка.
if(buffer == null)
buffer = new ComputeBuffer(bufferElements.Count * 3, sizeof(float));
Если у нас нет вычислительного буфера (такое бывает только в первом кадре), то мы создаём новый. Первый параметр конструктора — это общее количество элементов; в нашем случае это количество ячеек, умноженное на 3 элемента каждой ячейки; второй параметр — это размер каждого элемента (т.е. количество байт, которое имеет каждый элемент). Проще всего определить размер при помощи оператора
sizeof
, возвращающего размер передаваемого ему типа.buffer.SetData(bufferElements);
computeShader.SetBuffer(0, cellBufferId, buffer);
computeShader.SetInt(cellCountId, bufferElements.Count);
computeShader.SetFloat(radiusId, Radius / MapSize);
computeShader.SetFloat(blendId, BlendDistance / MapSize);
Эта часть понятна сама по себе — мы просто задаём значения всех переменных, которые будут необходимы в вычислительном шейдере. Тут стоит упомянуть два аспекта. Первое: мы передаём функции SetBuffer() значение 0, обозначающее индекс вычислительного ядра, для которого мы задаём буфер. У нас оно только одно, поэтому и индекс 0. Второе: мы делим радиус и расстояние смешения на физический размер карты. Мы должны гарантировать, что все длины имеют одинаковый масштаб; при работе с текстурами простейший масштаб — это масштаб UV [0;1].
computeShader.Dispatch(0, Mathf.CeilToInt(TextureSize / 8.0f), Mathf.CeilToInt(TextureSize / 8.0f), 1);
Функция dispatch выполняет само вычислительное ядро (compute kernel). Первый параметр — это снова индекс ядра, который в нашем случае равен 0. Другие три параметра — это количество групп потоков в направлении x, y и z. Сейчас вы наверно сбиты с толку, так что давайте немного поговорим о том, как выполняются шейдеры в GPU.
GPU рассчитаны на параллельное выполнение одинаковых инструкций для различных данных. Например, если у нас в вершинном шейдере есть функция «x += 1» то мы параллельно прибавляем 1 к x для множества вершин. Данные не обязаны быть вершинами, они могут быть пикселями или, в случае вычислительного шейдера, практически чем угодно. Размер этих групп можно задавать в вычислительном шейдере; в нашем случае я задал значение 8x8x1. Это можно представить как то, что вычислительный шейдер одновременно рендерит 8x8 текселов текстуры.
Поэтому при диспетчеризации вычислительного шейдера важно вычислить, как часто мы должны запускать его в направлении x, y и z, чтобы покрыть всю render texture. Так как мы работаем в 2D, игнорируем z и присваиваем ей значение 1. Мы можем вычислить количество групп потоков в направлениях x и y, разделив разрешение текстуры на 8 — размер каждой группы.
Если текстура имеет размер 512 х 512, то мы должны запускать вычислительное ядро (512/8) x (512/8) = 64 x 64 = 4096 раз. В будущем я выпущу туториал, где это будет рассматриваться более подробно, но пока будет достаточно такого краткого введения в тему. Давайте начнём писать шейдеры.
Вычислительный шейдер масок
Открыв шейдер «Assets/Shaders/MaskCompute.compute», вы увидите созданную мной заготовку.
#pragma kernel CSMain
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
}
Первая строка сообщает Unity название нашего вычислительного ядра; у нас оно одно, так что тут всё просто. Вторая строка сообщает Unity размер группы потоков, в нашем случае это 8x8x1. Основы групп потоков я рассказывал в предыдущем разделе. Последний элемент здесь — это параметр id, который мы парсим в функцию. Эта переменная хранит id потока, над которым мы сейчас работаем, снова в трёх измерениях. В нашем случае id.xy — это пиксель, для которого вычисляет значение текущий поток.
Начнём с добавления в скрипт на C# основных переменных. Здесь не должно быть ничего неожиданного, помните, что радиус и расстояние смешения уже заданы в масштабе UV.
int _CellCount;
int _TextureSize;
float _MapSize;
float _Radius;
float _Blend;
Нам нужна ещё одна переменная для вычислительного буфера, содержащая данные всех ячеек.
Помните, что каждый элемент в буфере содержит три значения float? В шейдере мы можем скомбинировать их во float3, что упростит работу с буфером.
StructuredBuffer<float3> _CellBuffer;
Последняя необходимая нам переменная — это текстура маски. Так как мы должны выполнять запись в неё, для типа должны быть включены чтение/запись, поэтому это RWTexture2D. Каждый пиксель текстуры имеет тип float4, поэтому мы имеем по одному float на канал.
RWTexture2D<float4> _Mask;
Теперь мы можем написать саму функцию вычислений. Давайте начнём с того, что зададим текущему текселу значение 0.
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
}
Мы должны обойти в цикле все ячейки и определить, находится ли ячейка в интервале тексела и видима ли она. Если да, то мы присваиваем маске значение 1, если нет, то оставляем его равным 0. Для этого можно использовать простой цикл.
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
for (int i = 0; i < _CellCount; i++)
{
}
}
Чтобы вычислить расстояние между текущим текселом и каждой ячейкой, обе позиции должны находиться в одном пространстве. Выше мы начали преобразовывать значения в пространство UV, поэтому давайте повторим это и для позиции тексела с центром ячейки.
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
for (int i = 0; i < _CellCount; i++)
{
float2 UVPos = id.xy / (float)_TextureSize;
float2 centerUVPos = float2(_CellBuffer[i].x, _CellBuffer[i].y) / _MapSize;
}
}
Теперь мы можем вычислить расстояние между ними при помощи length().
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
for (int i = 0; i < _CellCount; i++)
{
float2 UVPos = id.xy / (float)_TextureSize;
float2 centerUVPos = float2(_CellBuffer[i].x, _CellBuffer[i].y) / _MapSize;
float UVDistance = length(UVPos - centerUVPos);
}
}
Теперь нам осталось только вычислить, меньше ли расстояние радиуса, и если да, то изменить значение маски на 1. Так как нам нужно на границе плавное смешение, зависящее от расстояния смешения, для этого можно использовать smoothstep. Нужно умножить это значение на видимость ячейки, чтобы рендерить в маску только видимые ячейки.
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
for (int i = 0; i < _CellCount; i++)
{
float2 UVPos = id.xy / (float)_TextureSize;
float2 centerUVPos = float2(_CellBuffer[i].x, _CellBuffer[i].y) / _MapSize;
float UVDistance = length(UVPos - centerUVPos);
float val = smoothstep(_Radius + _Blend, _Radius, UVDistance) * _CellBuffer[i].z;
}
}
Smoothstep работает следующим образом: если переменная «UVDistance» больше, чем "_Radius + _Blend", то она возвращает 0. Если переменная меньше, чем "_Radius", то она возвращает 1. Между ними значения плавно интерполируются от 0 до 1.
Нужно учесть ещё один аспект. Ячейки внутри буфера не идут в определённом порядке, поэтому тексел может находится в пределах радиуса видимой ячейки, а значение маски будет равно 1; но при этом значение маски может смениться позже в цикле на 0, потому что тексел не находится внутри радиуса следующей ячейки. Мы можем исправить это, сделав так, чтобы значение маски записывалось только тогда, когда оно больше имеющегося.
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
for (int i = 0; i < _CellCount; i++)
{
float2 UVPos = id.xy / (float)_TextureSize;
float2 centerUVPos = float2(_CellBuffer[i].x, _CellBuffer[i].y) / _MapSize;
float UVDistance = length(UVPos - centerUVPos);
float val = smoothstep(_Radius + _Blend, _Radius, UVDistance) * _CellBuffer[i].z;
val = max(_Mask[id.xy].r, val);
}
}
После того, как мы назначили значение маски, можно двигаться дальше. Всё просто, правда?
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
_Mask[id.xy] = float4(0, 0, 0, 1);
for (int i = 0; i < _CellCount; i++)
{
float2 UVPos = id.xy / (float)_TextureSize;
float2 centerUVPos = float2(_CellBuffer[i].x, _CellBuffer[i].y) / _MapSize;
float UVDistance = length(UVPos - centerUVPos);
float val = smoothstep(_Radius + _Blend, _Radius, UVDistance) * _CellBuffer[i].z;
val = max(_Mask[id.xy].r, val);
_Mask[id.xy] = float4(val, val, val, 1);
}
}
Давайте откроем «Assets/Shaders/TileShader.shader» и немного изменим его, чтобы он отображал маску. Этот шейдер представляет собой шаблонный поверхностный шейдер, в нём нет ничего особенного. Структуру простого поверхностного шейдера мы подробно рассматривали в предыдущем туториале о Gears Hammer of Dawn.
struct Input
{
float3 worldPos;
};
float _MapSize;
sampler2D _Mask;
void surf (Input IN, inout SurfaceOutputStandard o)
{
o.Albedo = tex2D(_Mask, IN.worldPos.xz / _MapSize).rgb;
}
Чтобы наложить текстуру маски на сетку, нам нужна позиция вершины в мировом пространстве.
В поверхностных шейдерах мы можем получить её, добавив к входящей struct переменную «worldPos». Также нам нужна переменная "_MapSize" и текстура "_Mask" для масштабирования позиции в мире и сэмплирования текстуры в вычисленных координатах. Помните, как мы использовали для них глобальную переменную шейдера? Их значение должно автоматически задаваться нашим скриптом на C#. Теперь мы можем сэмплировать текстуру и присвоить цвет маски цвету albedo выходной struct поверхности.
Изменив шейдер, мы можем запустить режим Play и нажать мышью на сетку, чтобы увидеть, как маска изменяет своё значение.
Разобравшись с маской, мы можем приступить к шейдеру тайлов.
Шейдер шестиугольных тайлов
Мы начнём с добавления в шейдер нескольких свойств. Давайте разберём, что делает каждое из них.
Properties
{
[NoScaleOffset] _MainTex("Color Texture", 2D) = "white" {}
[NoScaleOffset]_MapTex("Map Texture", 2D) = "white" {}
[NoScaleOffset]_Noise("Noise", 2D) = "black" {}
_Cutoff("Map Cutoff", float) = 0.4
_MapColor("Map Color", Color) = (1,1,1,1)
_MapEdgeColor("Map Edge Color", Color) = (1,1,1,1)
[NoScaleOffset]_MapBackground("Map Background Texture", 2D) = "white" {}
}
"_MainTex" — это текстура, содержащая цветное изображение тайла, "_MapTex" — это его нарисованная от руки версия. "_Noise" — это текстура шума Перлина, которую мы используем для границы тумана войны. Значение "_Cutoff" определяет, при каком значении маски мы переходим от цветного тайла к туману войны; мы хотим, чтобы этот переход был резким. "_MapColor" — это базовый цвет карты тумана войны, обычно он светло-коричневый. "_MapEdgeColor" — это цвет, который имеет эффект у границ. Наконец, "_MapBackground" — это прозрачная фоновая текстура, которую мы накладываем поверх эффекта тумана войны, чтобы повысить его разнообразие.
Для сэмплирования "_MainTex" и "_MapTex" нам нужны UV-координаты. Мы можем их получить, добавив во входящую struct переменные float2 с префиксом «uv».
struct Input
{
float3 worldPos;
float2 uv_MainTex;
float2 uv_MapTex;
};
Так как свойства — это конструкции Unity, используемые для отображения переменных в инспекторе, нам нужно добавить в часть файла с кодом шейдера CG переменные с теми же именами.
float _MapSize;
sampler2D _Mask;
sampler2D _MainTex;
sampler2D _MapTex;
sampler2D _Noise;
float _Cutoff;
float4 _MapColor;
float4 _MapEdgeColor;
sampler2D _MapBackground;
Теперь мы можем начать работу над самой функцией поверхности. Первое, что мы делаем — сэмплируем две текстуры тайлов. Пока мы этим занялись, давайте сохраним значение маски в отдельную переменную.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
o.Albedo = mask.rgb;
}
Также нам нужно сэмплировать текстуру фона карты и текстуру шума.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
float4 mapBackground = tex2D(_MapBackground, IN.worldPos.xz / _MapSize);
float noise = tex2D(_Noise, IN.worldPos.xz / _MapSize).r;
o.Albedo = mask.rgb;
}
Если мы будем использовать текущую маску в таком виде, то получим довольно монотонные границы в виде кругов вокруг видимой области, что будет выглядеть не очень красиво. Мы хотим, чтобы граница была более шумной, и чтобы сделать это, мы можем вычесть значение шума из значения маски. Чтобы избежать странного и неопределённого поведения в будущем, ограничим результат интервалом от 0 до 1.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
float4 mapBackground = tex2D(_MapBackground, IN.worldPos.xz / _MapSize);
float noise = tex2D(_Noise, IN.worldPos.xz / _MapSize).r;
float maskNoise = clamp(maskVal - noise, 0, 1);
o.Albedo = mask.rgb;
}
Однако у этой функции есть проблема. Значение шума может быть любым в интервале от 0 до 1, поэтому так мы можем вычесть 1 из маски в том месте, где она должна быть видимой, что приведёт к пятнам, рендерящимся в виде тумана войны. Мы можем исправить это, умножив шум на значение равное величине, обратное значению маски.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
float4 mapBackground = tex2D(_MapBackground, IN.worldPos.xz / _MapSize);
float noise = tex2D(_Noise, IN.worldPos.xz / _MapSize).r;
float maskNoise = clamp(maskVal - (1.0f - maskVal) * noise, 0, 1);
o.Albedo = mask.rgb;
}
Во время экспериментов я осознал, что эффект шума теперь не так видим, как должна быть вся область смешения, потому что сейчас он оказывает меньшее влияние при приближении к радиусу ячейки. Добавив функцию возведения в степень, мы можем увеличить влияние шума на область смешения.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
float4 mapBackground = tex2D(_MapBackground, IN.worldPos.xz / _MapSize);
float noise = tex2D(_Noise, IN.worldPos.xz / _MapSize).r;
float maskNoise = clamp(maskVal - pow(1.0f - maskVal, 0.01f) * noise, 0, 1);
o.Albedo = mask.rgb;
}
Теперь мы можем проверить, меньше ли адаптированное значение шума чем указанное значение "_Cutoff", и если да, то отрендерить туман войны. Внешний вид тумана войны является комбинацией "_MapColor", нарисованного от руки тайла и текстуры фона.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
float4 mapBackground = tex2D(_MapBackground, IN.worldPos.xz / _MapSize);
float noise = tex2D(_Noise, IN.worldPos.xz / _MapSize).r;
float maskNoise = clamp(maskVal - pow(1.0f - maskVal, 0.01f) * noise, 0, 1);
if(maskNoise < _Cutoff)
tile = _MapColor * tileMap * mapBackground;
o.Albedo = tile.rgb;
}
Так как мы хотим, чтобы граница имела более тёмный цвет, можно добавить к цвету тумана войны простую функцию
lerp
, зависящую от адаптированного значения маски.void surf (Input IN, inout SurfaceOutputStandard o)
{
float4 maskVal = tex2D(_Mask, IN.worldPos.xz / _MapSize);
float4 tile = tex2D(_MainTex, IN.uv_MainTex);
float4 tileMap = tex2D(_MapTex, IN.uv_MapTex);
float4 mapBackground = tex2D(_MapBackground, IN.worldPos.xz / _MapSize);
float noise = tex2D(_Noise, IN.worldPos.xz / _MapSize).r;
float maskNoise = clamp(maskVal - pow(1.0f - maskVal, 0.01f) * noise, 0, 1);
if(maskNoise < _Cutoff)
tile = lerp(_MapColor * tileMap * mapBackground, _MapEdgeColor, maskNoise / _Cutoff);
o.Albedo = tile.rgb;
}
И на этом код закончен! В следующем разделе я вкратце разберу параметры материала.
Завершающие штрихи
Все материалы имеют одинаковую структуру, отличаются только текстуры цвета и карты. В моих примерах я использую значение cutoff, равное 0.3, цвет карты в моём случае равен #BCA76E, цвет границы — #574A36.
Если вы не изменяли карту, то размер должен быть равен 26, я использую радиус 1.0 и расстояние смешения 0.8.
Готово!
Этот туториал получился короче предыдущего; тем не менее, вам должно понравиться с ним работать. Если в процессе работы у вас возникнут проблемы, то сверьтесь с готовой версией проекта.
Готовый проект
atri1
Если я правильно понял, компут шейдер обходит соседние ячайки вокруг своего шестиугольника чтоб затемнять куски идущие от состояния соседей.
Тогда получается что компут шейдер обновляет все состояние карты(основной маски видимых тайлов) каждый кадр, а карта видна не вся.
И нужна очевидная оптимизация с расчетом положения камеры для компут шейдера…
(и если я правильно понял со скриншота, обновляется 2к текстура каждый кадр? надеюсь я неправильно понял)
Но такой эффект можно сделать очень просто ведь...
По принципу "рисования", обновляемые ячейки это положение кисти, кисть рисует текстурой шума накладывая со временем все сильнее(как на видео), и радиус кисти равен размеру полторы ячеек.
Рисуем точно также в компут шейдере или в пиксель шейдере в отдельном буфере, разницы нет. Но без циклов и ненужных обходов соседних ячеек. (банальное наложение текстуры каждый кадр, суммирование старого цвета с текущим состоянием кисти)
Затемнение/осветление очевидно рисование в одну и другую сторону(черным/белым цветом той-же маской шума)
Также не нужно передавать состояние карты-ячеек на GPU в этом случае, достаточно контролировать шейдер рисующий на карте одну-несколько обновляемых в текущий момент ячеек...