Подход шейдера обрезки Outbound: Точное удаление листвы для реального времени

Как остановить траву от того, чтобы она не проходила сквозь пол в вашей игре о жизни в фургоне в открытом мире? В этом гостевом посте программисты Square Glade Games Тони Фиал и Михиел Проце предлагают подробный взгляд на то, как они подошли к решению этой проблемы в Outbound с помощью решения для обрезки шейдеров.
Мы Тони Фиал и Михиел Проце, часть команды Square Glade Games, и в настоящее время мы работаем над последним проектом студии, Outbound, который является игрой для исследования открытого мира, установленной в утопическом ближайшем будущем. Игрок начинает с пустого фургона и может превратить его в мобильный дом своей мечты, строя его именно так, как он хочет.
Транспортное средство является крупной центральной точкой игры, как и вождение его по природе. Мир в Outbound создан вручную и включает в себя много листвы и травы, которая пышная, высокая и обильная. Хотя мы могли создать красивый мир с этими активами, сочетание их с транспортным средством, которое движется по таким средам, вызвало некоторые визуальные проблемы.
Проблема
Игрок может управлять своим фургоном практически по любой открытой территории. Кусты и трава не являются препятствием для этого. Поскольку фургон находится довольно близко к земле, это часто приводило к тому, что трава с местности проходила сквозь дно или стороны транспортного средства.
Есть также места, где фургон может достичь более высокой листвы, такой как цветы и кусты. Чтобы показать проблему, приведенный ниже скриншот показывает случай, когда и трава, и кусты сильно проходят сквозь транспортное средство. Это не только визуально непривлекательно, но также вызывает различные проблемы с игровым процессом, такие как визуальная блокировка взаимодействий или важной информации.

Чтобы подвести итог нашей основной проблеме, есть разные типы листвы и травы, которые проходят сквозь фургон, что нежелательно с визуальной и игровой точки зрения.
Теперь перейдем к решению, не так ли?
Мозговой штурм возможных решений
В Square Glade Games, прежде чем мы активно начнем работать над решением, мы лично считаем полезным составить список оптимальных требований.
В этом конкретном случае нам нужно было, чтобы наше решение:
• Будьте производительными. В Outbound много травы, поэтому неоптимизированное решение может быть очень дорогим в районах с большим количеством травы и растений.
• Сохраните оригинальный стиль. В настоящее время мы находимся на стадии разработки, где не можем изменить внешний вид основных элементов в Outbound, поэтому идеальное решение должно использовать как можно больше оригинальной листвы.
• Будьте совместимыми с несколькими платформами.Поскольку планируется выпуск заголовка на нескольких платформах, решение должно работать на Windows, Nintendo Switch™, Xbox и PlayStation®.
• Будьте интуитивно понятными в использовании.Решение должно быть интуитивно понятным как для дизайнеров, так и для программистов в команде.
• Применяйтесь к нескольким формам. Идеально, если мы обрежем листву в точной форме автомобиля, возможно, используя несколько форм.
Теперь нужно подумать о решениях, которые могут удовлетворить этот список требований. Наши первые мысли пришли к элементу, которым делятся все травинки... шейдеру.
Практически вся флора в Outbound размещена на территории Unity с использованием инструментов местности. Значительная часть этого - трава, которая использует стандартный шейдер травы. Этот шейдер использует GPU для размещения и билбординга травяных плоскостей очень производительным образом. Другие элементы, такие как большие кусты, показанные на скриншоте выше, размещены как детализированные сетки, используя свои собственные назначенные материалы и шейдеры.
Это представило еще одну важную деталь, а именно то, что предложенное решение должно работать на нескольких совершенно разных шейдерах одновременно.
Предложенные решения
Все предложенные ниже решения имеют одно главное 'входное' общее также: Положение кемпера, или, точнее, область, где листву следует обрезать.
Смотря на заявленные требования, мы хотели, чтобы наше решение было интуитивно понятным для остальной команды Square Glade. По нашему опыту, инструменты редактора будут использоваться членами команды только тогда, когда они интуитивно понятны и легки в освоении. С учетом этого, мы решили создать визуальный 3D-куб, который можно масштабировать, вращать и манипулировать, чтобы обрезать именно ту часть кузова автомобиля и подправить ее так, чтобы она была в самый раз. Любая растительность внутри куба будет обрезана, в то время как все, что снаружи, останется прежним.
Шейдер трафарета
Первое, что мы попробовали, это использование элемента шейдера, называемого 'трафаретный буфер'.
Эта часть программирования шейдеров очень увлекательна, но также немного сложна для понимания. Суть для нашей цели заключается в том, что мы говорим 'элементу обрезки', в данном случае нашему кубу, записать некоторую информацию в трафаретный буфер отрендеренного кадра. Это означает, что в любом месте на экране, где находится куб, он запишет значение 1. 'Обрезанный' объект (в нашем случае, трава) может считывать из этого буфера и отбрасывать любые пиксели, у которых значение установлено ровно 1.
В коде шейдера это будет выглядеть примерно так:
Clipping object 'Cube'
Stencil
{
Ref 1
Comp always
Pass replace
}
Clipped object 'Grass'
Stencil
{
Ref 1
Comp equal
}Объект обрезки записывает значение 1 в буфер, как указано в строке Ref 1, и будет делать это Всегда. Если позже отрендеренное значение трафарета совпадает или Проходит сравнение трафарета, оно заменит его информацией этого шейдера.У травы аналогичная реализация: Она также будет искать значение Ref 1 и пройдет проверку только если Сравнение будет Равно этому эталонному значению.
Эта реализация действительно работала для обрезки травы, и она была очень эффективной, так как работает с пикселями отрендеренного кадра и не зависит от количества травы в данной сцене. Однако в этом решении была фатальная ошибка. Поскольку эта реализация не имеет представления о глубине, она также будет обрезать все, что находится за кубом. Практически это означало, что когда игрок сидел внутри автомобиля, глядя с точки зрения первого лица, весь экран будет отмечен как 'обрезанный', так что игрок не увидит траву нигде. Из-за этого нам пришлось попробовать некоторые другие методы, которые также работали бы, когда камера игрока находилась внутри объекта 'клиппер'.
Ручное обрезание
Решение, которое мы кратко обсудили, заключалось в том, чтобы вручную удалить траву в позиции нашего автомобиля, убирая её с самой местности. Мы уже сделали это для других частей игры, используя функцию 'TerrainData.SetDetailLayer', которую Unity предоставляет для местности. Это установит градацию серого цвета слоя деталей на 0 на пикселях прямо под фургоном, инструктируя местность удалить любые детали или траву в этом наборе местоположений.
Поскольку карты Outbound довольно большие, это означает, что разрешение слоя деталей находится на более низком уровне, что делает его немного 'зубчатым'. Это вполне нормально для обычного размещения деталей травы и других мешей, но при ручном обрезании частей, низкое разрешение приведет к форме, которая не будет достаточно близка к размеру фургона, либо будет слишком маленькой, либо слишком большой.
Это решение также приведет к мерцанию деталей, когда транспортное средство находилось на границе двух пикселей деталей местности. По этим причинам мы не стали продолжать реализацию этого решения. Наше путешествие продолжается!
Шейдер обрезки
С шейдером буфера трафарета мы думали, что почти достигли цели, так как мы сделали пиксели невидимыми там, где это необходимо, с точностью к внешнему корпусу фургона. Если бы только был другой способ сделать это, используя глубину куба, зная, что решение должно в основном просто обрезать пиксели внутри его ограничивающего объема.
Как оказалось, есть метод, который делает именно это! Шейдеры HLSL предоставляют скромную функцию clip(), которая просто отбрасывает пиксель, если указанное значение меньше 0. Вы, возможно, видели это раньше в каком-то случайном шейдере, где это часто используется для альфа-обрезки.
Чтобы привести пример, трава Outbound выглядит как настоящие пучки травы, а не как квадратные квадраты с изображением травы на них, потому что мы 'обрезаем' там, где альфа-канал нашей текстуры травы черный.
Когда мы сделали быстрый первый прототип/проверку для этого решения, у нас были большие надежды, что эта реализация сможет работать, так как мы смогли сделать пиксели невидимыми выше определенной мировой позиции. В псевдокоде функция выглядела следующим образом:
// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );Решение: Шейдер обрезки
На этом этапе у нас был простой пример, который показывал многообещающее решение, а именно использование шейдера обрезки. Следующим шагом было создание функции, чтобы предоставить шейдеру информацию, необходимую для обрезки именно там, где мы хотели. Это включало две части:
• Часть, где мы в основном рассчитываем 'форму', включая ее размеры и трансформации, и предоставляем эти данные шейдеру.
• Часть, где шейдер использует эти данные, проверяет, находится ли данная точка внутри формы, и отбрасывает ее пиксели, где это необходимо.
Для первого шага нашего решения мы создали скрипт 'GrassClipperShape', MonoBehaviour, который мы могли прикрепить к объекту в сцене, который определял бы, где будет область обрезки. Пример этого показан ниже, где область формы с использованием OnDrawGizmos в представлении редактора отображается.

Поскольку мы в идеале хотели бы использовать несколько таких обрезчиков, нам нужен общий скрипт (т.е. "менеджер"), чтобы управлять всеми доступными обрезчиками. Каждый обрезчик должен предоставить следующие свойства этому общему скрипту, названному 'GrassClipperManager':
• Форма: тип формы. Мы хотели, чтобы эта версия работала как с кубами, так и с сферами, поэтому это простой перечисляемый тип, установленный либо на 'куб', либо на 'сфера'
• Vector3: размер объекта в сцене
• Matrix4x4: вычисленный вращенный объект в мировом пространстве
GrassClipperManager, которого в сцене всегда только один, будет получать эту информацию от обрезчиков каждый кадр и отправлять ее шейдеру следующим образом:
Shader.SetGlobalInteger("_ShapeCount", count);
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);
Shader.SetGlobalVectorArray("_ShapeParams", size);
Shader.SetGlobalFloatArray("_ShapeType", type);Строки выше установят глобальные значения шейдера. Чтобы объяснить вкратце, это означает, что вы можете использовать значения шейдеров с этими точными именами и типами и использовать их в любом шейдере.
Поскольку мы хотим, чтобы наше отсечение происходило на нескольких различных шейдерах, мы создали отдельный скрипт HLSL, который будет включен в любой шейдер, который необходимо затронуть нашим клиппером. Этот скрипт предоставляет пользовательскую функцию с именем 'ApplyClipVolumeSDF'. Он использует информацию из теперь заполненных глобальных значений шейдера и будет вычислять, находится ли пиксель в пределах каких-либо границ.
inline void ApplyClipVolumeSDF(float3 worldPos)
{
float clipVal = GetClipFade(worldPos);
if (clipVal <= 0.0)
clip(-1);
}Как вы можете видеть выше, если пиксель должен быть отброшен, он вызовет функцию 'clip(-1)', возвращая отброшенный пиксель. В противном случае он просто продолжит нормально проходить через остальную часть шейдера.
Реализация шейдера отсечения
Теперь, когда функция отсечения создана и снабжена необходимыми данными, пришло время внедрить ее в наши шейдеры.
Сначала давайте обсудим, как это сделать для детализированных мешей, где мы могли бы создать копию оригинала и отредактировать ее. В самом верху шейдера мы должны сослаться на пользовательский скрипт следующим образом:
#include "Assets/Shaders/ClipVolume.hlsl"А затем, когда мы хотим фактически использовать функцию, мы просто вызываем ее внутри фрагментной части шейдера следующим образом:
float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);В нашем случае только два шейдера должны были включить это, а именно стандартный шейдер, используемый травой Unity, и пользовательский шейдер, используемый для всей остальной листвы, отрисованной как детализированные меши. Теперь, когда у нас это есть, его можно легко внедрить в любой другой шейдер, если это необходимо.
Но наше путешествие еще не закончилось – перед нами встала последняя преграда. Как мы теперь можем редактировать и фактически сохранять изменения, внесенные в стандартный шейдер травы? Unity использует некоторые специфические встроенные шейдеры для рендеринга травы, в нашем случае это 'WavingGrassBillboard.shader'. Этот шейдер автоматически применяется ко всей траве, без возможности предоставить пользовательские варианты. Это было решающим для работы нашего решения, так как оно должно было подключаться к этому шейдеру, чтобы иметь возможность вызывать пользовательскую функцию 'ApplyClip' и отбрасывать нежелательные пиксели.
После того как несколько решений были опробованы, наш коллега Михиел Проце нашел способ редактировать и фактически сохранять изменения в стандартном шейдере травы надежно. Запуская следующий код во время сборок и в редакторе, наш пользовательский шейдер заменяет стандартный шейдер URP:
string replacementShaderName = "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass_Clipped";
if (GraphicsSettings.TryGetRenderPipelineSettings<UniversalRenderPipelineRuntimeShaders>(out var shadersResources))
{
if (shadersResources.terrainDetailGrassBillboardShader.name != replacementShaderName)
{
Shader replacementShader = Shader.Find(replacementShaderName);
shadersResources.terrainDetailGrassBillboardShader = replacementShader;
}
}Обратите внимание, что это только заменяет шейдер WavingGrassBillboard, но реализация этого для других шейдеров будет аналогичной.
Заключительные мысли
Наше конечное решение с использованием шейдера клипа хорошо работает для наших целей, и мы очень довольны результатами, которые оно предоставляет. Смотрите скриншот ниже для визуализации решения, где прямоугольный куб отсекает траву внутри. Обратите внимание, что коробка видна сверху и помещена через местность для оптимального просмотра того, что отсекается.

Оглядываясь на наш список требований для решения по отсечению травы, мы были рады видеть, что оно соответствует всем из них!
• решение производительно, так как функции, используемые для расчета отсечения, очень дешевы. И поскольку оно полностью отбрасывает пиксель, наша реализация не будет выполнять дальнейшую ненужную обработку.
• Оно сохраняет оригинальный стиль Outbound нетронутым, потому что оно построено на основе шейдеров, которые мы уже использовали.
• Реализация независима от платформы, потому что функция clip() сама по себе.
• Решение интуитивно понятно в использовании для остальной команды. Дизайнеры могут создавать и использовать несколько форм и даже заставлять их пересекаться друг с другом.
Мы считаем, что такие функции, как вышеупомянутое, крайне важны не только ради креативности, но и для предотвращения появления странных ошибок в будущем.
Пример проекта
Чтобы поделиться этим решением с сообществом, мы создали пример проекта, используя описанные выше техники, чтобы вы могли попробовать это сами – посмотрите это здесь на GitHub.
Спасибо за чтение нашего гостевого поста. Надеемся, это поможет многим другим разработчикам, которые сталкиваются с той же проблемой, что и мы!
Outbound в настоящее время находится на закрытом бета-тестировании; следите за игрой в Steam для обновлений. Исследуйте больше игр, созданных с помощью Unity, на нашей странице кураторов Steam и ознакомьтесь с другими историями разработчиков Unity на нашем центре ресурсов.
Nintendo Switch™ является товарным знаком Nintendo.
