Как сделать туман в юнити
Перейти к содержимому

Как сделать туман в юнити

  • автор:

Глобальный туман

Графический эффект Global Fog создаёт эскпотенциальный туман на основе камеры. Все расчеты производятся в мировом пространстве, благодаря чему возможно использование режимов тумана на основе высоты, которые могут быть использованы для сложных эффектов (см пример).

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

Также как и в случае с другими эффектами изображения, этот эффект доступен только в Unity Pro и перед тем как он станет доступен, вы должны установить стандартные Pro ассеты.

Свойства

Свойство: Функция:
Fog Mode Доступные типы тумана, основанные на дистанции, высоте и том и другом одновременно.
Start Distance Дистанция, при которой туман начинает рассеиваться в мировых единицах координат.
Global Density Угол, при котором Fog Color увеличивается вместе с расстоянием.
Height Scale Угол, при котором плотность тумана уменьшается вместе с высотой (если активен туман, основанный на высоте).
Height Координата Y в мировом пространстве, где туман начинает рассеиваться.
#Глобальный туман Цвет тумана.

Аппаратная поддержка

Для этого эффекта требуется видеокарта с поддержкой Shader Model 2 и Depth Textures. Для более подробного ознакомления с темой и списком совместимых аппаратных средств, посетите страницу документации графические возможности аппаратных средств и их эмуляция.

Как создать Туман Войны, есть идеи?

Здравствуйте, делаю RTS на Unity3D, дошло дело до Тумана Войны. Пошарив в интернете увидел много, как на мой взгляд, «быдлокодерских» вариантов реализации. Из них хотелось бы выделить боле-мение достойные:
1. Берем текстуру, размещаем выше земли и делаем отверстия(alpha) в нужных местах при помощи шейдера.
2. Заполнить всю карту триггерами и в зависимости от пересечения колайдерами наносить туман.
3. Берем две текстуры, тумана и карту высот, размещаем выше земли и делаем отверстия в нужных местах с учетом карты высот.

Первый и третий способ меня устраивает по своей реализации, но не хватает функционала. Например, если стоит «дерево», то туман должен исчезать только между юнитом и деревом, но не за ним(деревом). В третьем способе можно динамически наносить/удалять «деревья» с карты высот, но не будет ли это требовательно к ресурсам? Насколько я понимаю, нужно будет перебирать все точки текстуры между позицией юнита и текущем пикселем в поиске преграды, а юнитов может быть много и смотреть они тоже могут далеко.
Второй меня устраивает по функционалу, но не реализацией. Тот же пример с деревом, чтобы учесть эту ситуацию можно пустить луч к триггеру, если он дошел к нему, то открываем туман, если нет то ничего не меняем. Если юнитов будет много, то всё это превратится в ад из триггеров, колайдеров и лучей.

Сейчас Я больше склоняюсь к модернизации первого или третьего способа.
Есть ли еще какие нибуть приличные реализации?

0db00eeda7c747dbb202b08f8c7d63b6.png

UPD. Вот схемка для пояснения ситуации с деревом.

  • Вопрос задан более трёх лет назад
  • 6002 просмотра

Комментировать
Решения вопроса 1

Как вариант, что если к каждому юниту прикрепить дополнительный объект, нужного размера круг с тегом, который делает его невидимым для основной камеры, но видимым для дополнительной, которая рендерит эти объекты в текстуру через Render Texture? Потом можно использовать полученную текстуру для шейдера тумана. Эти «круги» могут уже иметь полупрозрачности и более сложную форму. По идее должно все быстро работать, но нужно Unity Pro. Я так понимаю, что это и есть практически ваш первый вариант, может пригодится.

Опишите пожалуйста подробнее ваш пример с деревом, не сильно понял.

Шейдер тумана

Шейдер тумана

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

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

настройки глубины в пространствах

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

Пример 1 (Простой)

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

Прежде чем мы включим это в альфа, было бы хорошо, если бы мы могли как-то контролировать плотность тумана. Мы можем сделать это с помощью узла Multiply (или Divide, если вы предпочитаете использовать значения плотности как 2 или 3 вместо 0,5 и 0,33). Мы также должны поместить результат в узел насыщения (Saturate), чтобы зафиксировать значения в диапазоне от 0 до 1, так как значения больше 1 могут привести к очень ярким артефактам, особенно при использовании пост-эффекта свечения.

Наконец, чтобы изменить цвет тумана, мы создаем свойство Color, чтобы использоватьего дляввода цвета на Главном узле. Я также беру альфа-компонент нашего свойства и умножаю его на насыщенный результат, чтобы учитывалось альфа-значение цвета. Убедитесь, что альфа-компонент цвета тумана (Fog Color) в инспекторе установлен на 1, иначе эффект будет невидимым.

шейдер тумана - граф из примера 1

Пример 2 (Точный)

Чтобы туман был виден в правильном направлении, мы можем вычислить мировое положение координаты глубины сцены и использовать его для создания тумана, используя нормальное направление плоскости/квада.

Создайте узел View Direction, установленный на мировое пространство. Это позволяет получить вектор от положения пикселя/фрагмента до камеры. Величина вектора — это расстояние (между камерой и фрагментом), но это не то же самое, что глубина. Глубина — это расстояние от положения фрагмента до плоскости, которая перпендикулярна камере. Получается треугольник, как показано на рисунке.

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

расстояние до объекта в сцене

Чтобы восстановить положение в мировых координатах, нам нужно масштабировать этот вектор к положению сцены позади квада/плоскости. Глубину Scene Depth и положение сцены, которые нам нужны, создает еще один показанный на рисунке треугольник. Чтобы получить вектор до положения сцены, мы можем использовать отношение двух треугольников, которое получают путем деления Направления Просмотра на Необработанное Экранное Положение W/A Глубины и затем умножения на Глубину Сцены. Затем мы можем вычесть мировое положение камеры из узла Камера, чтобы получить мировое положение сцены.

Положение в мировом пространстве на основе глубины

Примечание: расчет положения сцены в мировых координатах на основе значений глубины полезен и для других эффектов (например, см. пост Water Shader Breakdown), но обратите внимание, что этот метод работает только для объектов в сцене, он не будет работать с экранными шейдерами для эффектов пост-обработки, например.
Также полезный совет: вы можете взять результат этого вычисления, поместить его в узел Fraction и поместить его в Color на главном узле, чтобы помочь визуализировать положения.

Получив мировое положение сцены, мы можем теперь справиться с туманом. Чтобы убедиться, что туман нарисован в правильном направлении, мы можем трансформировать его положение в объектное пространство и скалярно перемножить его с приведенным вектором нормалей в объектном пространстве. Так как вектор нормали направлен наружу от плоскости, нам также нужно перевернуть результат скалярного произведения (или перевернуть вектор нормали, входящий в скалярное произведение).

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

Затем мы насыщаем наш результат в пределах 1, так как значения больше 1 могут привести к очень ярким артефактам, особенно при использовании пост-эффекта свечения.

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

Туман просматривается по нормали

Если у вас есть какие-либо комментарии, вопросы или предложения, пожалуйста, напишите мне в Твиттере @Cyanilux, и если вам понравилось читать, поделитесь ссылкой с другими!

Понравилась статья? Поделиться с друзьями:

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

Добавить комментарий Отменить ответ

  • CoreMission в:
  • Telegram
  • YouTube
  • Календарь: даты выхода игр
  • Календарь: игровые ивенты
  • Крутые игры на ПК
  • Пойдет ли игра на ПК?
  • Игры для слабого ноутбука
  • Как стать киберспортсменом?
  • Коды на The Sims 4
  • Все дополнения Sims 4
  • Разработчики инди-игр
  • Всё про “читы” в играх
  • Читы для CS: GO
  • Как создать игру самому?
  • Как стать разработчиком игр?
  • Референсы для художников
  • Символы для ников
  • Время прохождения игр
  • Похожие игры
  • Карта сайта

Реализация тумана войны из Civilization VI в Unity

Эффект тумана войны из 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». В готовой игре этот список должен просто заполняться при помощи инспектора, однако для прототипирования довольно полезно иметь подобную функцию, потому что она без лишних действий позволяет увеличивать размер карты.

private void OnMouseDown() < ToggleVisibility(); >private void OnMouseEnter()

Мы хотим иметь возможность взаимодействия с демо и переключения видимости ячеек. Для этого у каждой ячейки есть коллайдер. При помощи 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 cells; public static void RegisterCell(GridCell 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 bufferElements; private ComputeBuffer buffer = null;

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

private void Awake() < cells = new List(); 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(); >

Мы выполняем настройку основных параметров шейдера и render texture. Обратите внимание: несмотря на то, что используемый здесь формат текстур является лишней тратой ресурсов и его можно заменить на другой (нам бы хватило формата с одним каналом, поскольку нам нужно только значение маски), необходимо, чтобы была включена произвольная запись. Мы задаём размер текстуры и саму текстуру для вычислительного шейдера как глобальные переменные.

Глобальные переменные шейдера

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

private void OnDestroy()

Как говорилось выше, есть низкоуровневые объекты 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. Это можно представить как то, что вычислительный шейдер одновременно рендерит 8×8 текселов текстуры.

Поэтому при диспетчеризации вычислительного шейдера важно вычислить, как часто мы должны запускать его в направлении 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 _CellBuffer;

Последняя необходимая нам переменная — это текстура маски. Так как мы должны выполнять запись в неё, для типа должны быть включены чтение/запись, поэтому это RWTexture2D. Каждый пиксель текстуры имеет тип float4, поэтому мы имеем по одному float на канал.

 RWTexture2D _Mask;

Теперь мы можем написать саму функцию вычислений. Давайте начнём с того, что зададим текущему текселу значение 0.

[numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID)

Мы должны обойти в цикле все ячейки и определить, находится ли ячейка в интервале тексела и видима ли она. Если да, то мы присваиваем маске значение 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 и нажать мышью на сетку, чтобы увидеть, как маска изменяет своё значение.

Your browser does not support HTML5 video.

Разобравшись с маской, мы можем приступить к шейдеру тайлов.

Шейдер шестиугольных тайлов

Мы начнём с добавления в шейдер нескольких свойств. Давайте разберём, что делает каждое из них.

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

Your browser does not support HTML5 video.

Так как мы хотим, чтобы граница имела более тёмный цвет, можно добавить к цвету тумана войны простую функцию 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.

Готово!

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *