Шейдеры в Unity: введение в тему от Cyanilux

Шейдеры в Unity: введение в тему от Cyanilux

Привет! Это небольшое введение в тему шейдеров, что это такое и как они используются при рендере графики в Unity.

Что такое меш?

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

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

Даже такая, казалось бы, простая фигура, как куб, должна быть разбита на треугольники.
Пример кубического меша, состоящего из 12 треугольников. В Blender (ПО для 3D-моделирования) это 8 вершин, однако при импорте в Unity цифра может измениться в зависимости от того, являются ли данные для каждой вершины одинаковыми у общих вершин.

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

Модель обычно делается во внешних программах 3D моделирования, таких как Blender, и импортируется в Unity (обычно в формате .FBX). Но мы также можем генерировать меш во время исполнения, с использованием методов в классе C# Mesh.

Когда мы говорим «вершина», мы обычно имеем в виду ее положение в трехмерном пространстве, но каждая вершина в меше может содержать множество фрагментов данных. Сюда входят такие данные, как:

  • Положение в 3D пространстве (пространстве объектов, поэтому (0,0,0) стоит в начале меша).
  • UV, также известные как Текстурные Координаты, поскольку они чаще всего используются для наложения текстуры на модель. Обычно бывают координаты Vector2 (два значения с плавающей точкой, каждая ось помечена как xy или uv), но настоящие UV каналы могут быть Vector4 и содержать для передачи данных до 4 чисел с плавающей точкой. Смотрите Mesh.SetUVs.
  • Нормали (Направления, используемые для шейдинга. Некоторые программы моделирования (например, Blender) могут дополнительно использовать для определения порядка поворота вершин, который показывает в какую сторону смотрит грань. Во время рендеринга в шейдере можно отбросить переднюю или заднюю сторону грани).
  • Касательные (Направления перпендикулярные нормалям, которые огибают поверхность сетки вдоль горизонтальных координат текстуры (uv.x). Используются для построения Пространства Касательных, которое необходимо в таких разновидностях шейдинга как Normal/Bump Mapping)
  • Цвета вершин (Цвет, который задан каждой вершине).

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

Шейдеры в Unity обычно пишут так, чтобы было по возможности больше смежных вершин.
Пример кубических мешей. Оба имеют по 12 треугольников, но у левого 24 вершины, а у правого 8.

Достаточно распространено, когда модель имеет равномерное затенение (flat shading), а не плавное (smooth shading) как на изображении выше. Равномерное затенение увеличит количество вершин, потому что нормали вершин у разных граней должны указывать в разных направлениях. (Этого может не случиться в самой программе моделирования, но произойдет при экспорте в Unity). Для плавного затенения используется среднее значение направлений, поэтому вершины можно использовать как общие, при условии, что остальные данные также совпадают!

Что такое шейдер?

Шейдер — это код, который выполняется на GPU (графическом процессоре), аналогично тому, как сценарий C # выполняется на центральном процессоре. В Unity отдельный файл «.shader» содержит программу шейдера, в которой обычно есть несколько «проходов» для рендеринга меша. Каждый проход содержит вершинный шейдер и фрагментный шейдер (иногда также называемый пиксельным шейдером). (Возможно, это немного сбивает с толку, но термин «шейдер» используется как для обозначения конкретных этапов, так и для программы / файла шейдера в целом).

Вершинный шейдер запускается для каждой вершины в меше и отвечает за преобразование трехмерных положений вершин (в пространстве объектов) в положения пространства отсечения (Геометрия отсекает невидимые камерой части объектов. Есть несколько дополнительных шагов, чтобы превратить это в положение на 2D экране. Надеюсь, я восполню пробелы в одном из следующих постов). Также он должен передавать из меша данные, которые потребуются для вычислений на этапе фрагментного шейдера (UV, нормали и т.д.).

В таких инструментах, как Shader Graph, все это обычно делается за нас, но важно понимать, что здесь происходит два отдельных этапа. В новых версиях есть Master Stack, чтобы четче разделить эти этапы. Он предоставляет нам порт Vertex Position, чтобы переопределить положение в пространстве объектов, прежде чем оно будет преобразовано в пространство отсечения, например, для Vertex Displacement. Мы также можем переопределить нормали и касательные, которые передаются на этап фрагментного шейдера, но Shader Graph обработает порты автоматически, если оставить поля пустыми.

Для каждого треугольника и вершины в этом треугольнике положения пространства отсечения, переданные из стадии вершин, используются для создания фрагментов — потенциальных пикселей на 2D-экране. Все данные для каждой вершины, переданные во фрагментный шейдер, также интерполируются по треугольнику. Вот почему у каждой вершины может быть указан один цвет, но треугольник получится градиентно окрашенным. (То же самое происходит с UV, что позволяет правильно наложить текстуру, а не просто брать цвет как один пиксель для каждой вершины)

Треугольник с интерполированной раскраской.

Далее для этих фрагментов (потенциальных пикселей) запускается фрагментный шейдер. Он определяет цвет, который будет нарисован на экране (и в некоторых случаях выводит значение глубины). В результате может выводиться сплошной цвет с использованием цветов вершин, сэмплирование текстур и/или более сложный шейдинг с вычислениями освещения (название «шейдер» пошло от слова shade – тень).

В некоторых случаях бывает нужно отменить/отбросить пиксель из рендера (например, по alpha).

Код шейдера

Шейдеры в Unity написаны на HLSL (High Level Shading Language), хотя обычно вы увидите, что его называют CG. Особенно, если имеете дело со встроенным конвейером (Built-in Render Pipeline).

Вы всегда увидите этот шейдерный код между тегами CGPROGRAM / HLSLPROGRAM и ENDCG / ENDHLSL. (А еще можете увидеть CGINCLUDE / HLSLINCLUDE, который включает код в каждый проход шейдера).

Шейдеры для URP / HDRP всегда должны использовать HLSL-версии этих тегов, так как CG-теги включают некоторые дополнительные файлы, которые не нужны этим конвейерам. В противном случае это приведет к ошибке из-за переопределения в библиотеках шейдеров.

Остальная часть файла .shader написана с использованием специфичного для Unity синтаксиса, известного как ShaderLab, и включает такие блоки, как «Shader», «Properties», «SubShader» и «Pass». Документацию по синтаксису Shaderlab вы можете найти здесь.

Технически Shaderlab имеет несколько устаревших способов создания шейдеров с фиксированными функциями, что означает отсутствие в надобности CG/HLSL PROGRAM, но я бы не стал беспокоиться об их изучении, поскольку программирование шейдеров намного полезнее.

В качестве альтернативы существуют редакторы шейдеров на основе нодов, такие как Shader Graph (официальный, доступен для URP или HDRP), Amplify Shader Editor (работает во всех конвейерах, но не бесплатный) и Shader Forge (больше не поддерживается, работает только в старых версиях Unity).

Проходы шейдера

Обычно шейдеры включают основной этап, который либо не имеет тега LightMode, либо использует один из таких тегов как UniversalForward (URP), ForwardBase (Built-in Pipeline) или Forward (HDRP), если шейдер предназначен для использования в прямом, а не в отложенном рендеринге (я немного объясню это в следующем разделе).

// например
SubShader {
	Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
	Pass {
		Name "Example"
		Tags { "LightMode"="UniversalForward" }
	 
		HLSLPROGRAM
			...
		ENDHLSL
	}
	Pass {
		Name "Example2"
		Tags { "LightMode"="ShadowCaster" }
	 
		HLSLPROGRAM
			...
		ENDHLSL
	}
	// и так далее
}

Тег LightMode важен для того, чтобы сообщить Unity, для чего используется проход. Тег RenderPipeline сообщает Unity, для какого конвейера предназначен SubShader, если шейдер должен поддерживать несколько конвейеров.

В URP и встроенном конвейере в программе шейдера обычно также есть проход ShadowCaster, который, как следует из названия, позволяет мешу отбрасывать тени. Каждый проход имеет свой вершинный и фрагментный шейдер. В данном случае вершинный шейдер использует настройки смещения тени, чтобы немного сместить вершины и предотвратить артефакты затенения. Фрагментный шейдер здесь обычно просто выводит 0 (черный), поскольку его цвет не так важен. Нужно только отменить/вырезать фрагмент, если для этого пикселя не нужно отбрасывать тень.

В URP и HDRP также используется проход DepthOnly, который очень похож на ShadowCaster, но без смещения. Он позволяет выполнить предварительный проход глубины, который иногда используется для создания текстуры глубины. Подробнее я пишу в посте Depth. Я считаю, что во встроенном конвейере текстура глубины создается с помощью нового прохода ShadowCaster.

В зависимости от конвейера шейдер может включать больше проходов. Здесь перечислены некоторые для встроенного конвейера. Просмотр встроенных шейдеров также хороший способ узнать о каждом проходе. Вы можете загрузить исходный код шейдеров встроенного конвейера на странице загрузок Unity, а также найти исходники для URP и HDRP через Graphics github.

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

Forward и Deferred рендеринг

Forward рендеринг — это «обычный» способ рендеринга, в нем шейдер вычисляет цвет пикселя, который непосредственно отображается на экране. В URP существует ограничение на количество источников света, которые могут воздействовать на каждый GameObject (максимально восемь), в зависимости от того системы освещения (и вероятно, из соображений производительности). Встроенный конвейер имеет аналогичный предел, но его можно увеличить (вместе со снижением производительности).

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

Deferred рендеринг (или отложенный шейдинг) имеет то отличительное преимущество, что у него нет ограничения на количество источников света для GameObject. Освещение обрабатывается в конвейере позже, для каждого экранного пикселя внутри объема света. Наличие большого количества источников света по-прежнему будет влиять на производительность, но только если на экране они очень большие.

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

Прочесть больше про отложенный рендеринг можно в следующих тьюториалах:

https://catlikecoding.com/unity/tutorials/rendering/part-13/

https://www.patreon.com/posts/shaders-for-who-34008552

https://gamedevelopment.tutsplus.com/articles/forward-rendering-vs-deferred-rendering–gamedev-12342

Я больше придерживаюсь прямого рендеринга, поскольку использую UDP, который пока не поддерживает отложенный (его реализация запланирована).

Другие типы шейдеров

Во встроенном конвейере также есть surface shaders (которые обычно можно определить по функциям «#pragma surface surf» и «surf»). За кулисами они генерируют вершинный и фрагментный шейдер, а также обрабатывают вычисления освещения и тени. Однако эти типы шейдеров не работают в URP и HDRP (в будущем, возможно, будет версия SRP). В Shader Graph PBR (или освещенного HDRP) ноды Main уже очень похожи на поверхностные шейдеры, если вы не против работать с нодами.

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

  • Тесселяция (domain and hull shaders), которые преобразуют треугольники в более мелкие, обычно в зависимости от расстояния просмотра, чтобы добавить больше деталей к мешу.
  • Геометрические шейдеры, которые могут добавлять новую геометрию (т.е. треугольники) для каждой вершины/треугольника, которые уже есть в меше. Например, для создания травинок. Однако производительность геометрических шейдеров невелика, и для достижения похожих эффектов могут быть лучшие методы.
Простенькая схема конвейера графики.
Показано, куда в конвейере помещены эти дополнительные этапы. (Очень упрощенно и исключая отбрасывание, отсечение поверхности, ранний и поздний тесты глубины, блендинг и т.д.)

Дополнительные этапы шейдера нужны не в каждой программе шейдера, и поддерживаются не всеми платформами/GPU. Обе из вышеупомянутых не включены/доступны в Shader Graph (пока), поэтому я не буду говорить о них в данном посте. Но в интернете можно найти кое-какие примеры кода:

https://catlikecoding.com/unity/tutorials/advanced-rendering/tessellation/

https://halisavakis.com/my-take-on-shaders-geometry-shaders/ [перевод этой статьи про геометрические шейдеры на русский от Coremission]

https://roystan.net/articles/grass-shader.html [и это мы тоже переводили]

https://github.com/Cyanilux/URP_GrassGeometryShader

Также есть вычислительные шейдеры, которые выполняются отдельно от всех вышеупомянутых. Они обычно используются для генерации текстур (например, вывода с использованием RWTexture2D) или геометрии/других произвольных данных (например, RWStructuredBuffer, или с int, или пользовательской структурой). Однако в данной статье речь не идет об этом. Если вы хотите узнать больше о вычислительных шейдерах, ознакомьтесь с такими руководствами, как:

https://www.ronja-tutorials.com/2020/07/26/compute-shader.html

http://kylehalladay.com/blog/tutorial/2014/06/27/Compute-Shaders-Are-Nifty.html

https://lexdev.net/tutorials/case_studies/frostpunk_heatmap.html

Материалы и свойства

Из шейдера мы можем создавать материалы Materials, которые действуют как контейнеры для определенных данных (таких как числа с плавающей точкой, векторы, текстуры и т.д.). Данные отображаются шейдером с помощью раздела Properties синтаксиса Shaderlab (или Blackboard в случае Shader Graph). Эти свойства затем можно редактировать для каждого материала в инспекторе.

Shader "Example" {
    Properties {
		// Название свойства/Референс ( "Display Name", Type) = значение по умолчанию
		_ExampleTex("Example Texture", 2D) = "white" {}
		_ExampleColor("Example Colour", Color) = (1,1,1,1)
		_ExampleRange("Example Float Range", Range(0.0, 1.0)) = 0.5
		_ExampleFloat("Example Float", Float) = 1.0
		_ExampleVector("Example Vector", Vector) = (0,0,0,0)
		...
		// Подробнее на странице документации Properties в ShaderLab
	}
	...
	// Продолжение шейдера (сабшейдер, проходы и т.д.)
}

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

Например, стандартный шейдер, предоставляемый Unity (или URP / Lit, HDRP / Lit), может достаточно хорошо воспроизводить многие базовые материалы (пластик, металл, дерево, камень и т.д.) с правильными картами текстур и другими значениями (альбедо, нормали, окклюзия, отражение и т.д.).

Установка свойств с помощью C#

Чтобы установить одно этих свойств из C #, нам нужна ссылка на объект Material и имя свойства (поле Reference в Shader Graph). Получить материал можно несколькими способами. Один из них — использовать поле public:

public Material material;

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

В качестве альтернативы можно оставить материал private и устанавливать его по значению в Renderer. Например:

private Material material;

void Start(){
	Renderer renderer = GetComponent<Renderer>();
	material = renderer.sharedMaterial;
	// или
	// material = renderer.material;
}

Предполагается, что скрипт находится в том же GameObject, что и Renderer (например, MeshRenderer).

Затем, мы можем установить свойство, используя одну из функций Set. Например, на странице документации по скриптам для Materials перечислены SetFloat, SetVector, SetTexture, SetColor. Это может быть Start или Update (хотя лучше вызывать их только тогда, когда значение действительно нужно изменить).

// например
material.SetColor("_ExampleColor", Color.red);

Здесь «_ExampleColor» будет именем / ссылкой на свойство, как определено в шейдере. Обычно, если вы выбираете файл .shader (или .shadergraph) в окне Project, он перечисляет в окне Inspector свойства, которые имеет.

Вы также можете найти их приведенными в разделе Properties кода Shaderlab. Для Shader Graph это поле Reference, которое есть у каждого свойства, не надо путать его с отображаемым именем.

Обычно референс начинается с символа «_» в качестве соглашения об именах. Я считаю, что это помогает избежать ошибок, поскольку без него может получиться что-то уже используемое синтаксисом Shaderlab, но не цитируйте меня по этому поводу. («Offset» может быть ошибкой из-за использования его в Shaderlab, а «_Offset» — нет? То же самое с Cull, ZTest, Blend и т. д.)

Глобальные свойства шейдера

Существуют также глобальные (Global) свойства шейдера, которые можно установить с помощью класса Shader и статических функций SetGlobal, например, SetGlobalFloat, SetGlobalVector, SetGlobalTexture, SetGlobalColor и т.д.

// например
Shader.SetGlobalColor("_GlobalColor", Color.red);

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

В Shader Graph у каждого свойства на Blackboard вместо этого есть флажок Exposed. Неотмеченные свойства не будут отображаться в инспекторе материалов, но их все равно можно настроить с помощью функций SetGlobal.

Экземпляры материалов и пакетная обработка

Имея дело с получением материала для изменения его свойст необходимо понимать разницу между renderer.sharedMaterial и renderer.material.

Использование .sharedMaterial, как следует из названия, дает вам материал, общий для всех объектов. Это означает, что изменение свойства в нем повлияет на все объекты (аналогично установке свойств в публичном Material).

Использование .material вместо этого создает новый экземпляр (клон) материала при первом вызове и автоматически назначает его рендереру. Установка свойства с использованием данного параметра повлияет только на этот объект. Однако учтите, что вы также обязаны уничтожить этот экземпляр когда он больше не нужен. Например, с OnDestroy:

private Material material;

void Start(){
	Renderer renderer = GetComponent<Renderer>();
	material = renderer.material;
}
	
void OnDestroy(){
	// при использовании renderer.material нужно уничтожить экземпляр, если он дальше не используется:
	Destroy(material);
	// или Destroy(GetComponent<Renderer>().material); если не кешировать его в приватной переменной
}

Обычно для встроенного конвейера, использующего много экземпляров материалов, это плохо. Это нарушает так называемое пакетирование (Batching), которое объединяет меши, использующие вместе один и тот же материал, и их можно рисовать все сразу, а не по отдельности. Пакетирование помогает с производительностью, хотя стоит отметить, что поскольку оно объединяет меши, определенные данные (например, матрицы модели UNITY_MATRIX_M / Unity_ObjectToWorld и обратные) больше не привязаны к объектам. Это может привести к непредвиденным результатам, если шейдер полагается на пространство объектов.

То же самое происходит со спрайтами. По соображениям производительности они пакетируются и рисуются вместе, поэтому «пространство объектов» на самом деле не имеет значения.

Есть несколько типов пакетной обработки: статическая пакетная обработка, которая требует, чтобы GameObjects были помечены как статические, и динамическая пакетная обработка, которая выполняется автоматически, но о некоторых вещах следует помнить (см. страницу документации DrawCallBatching для получения дополнительной информации об этих типах).

Кроме пакетной обработки есть еще один способ объединить множество объектов, но иметь разные значения материалов, — это совместное использование GPU Instancing и Material Property Blocks. MPB можно использовать без создания экземпляров GPU, но это все равно приведет к отдельным вызовам отрисовки, поэтому без экземпляров не будет большого выигрыша в производительности. (Но нагрузка на память все-таки немного снижается). Дополнительную информацию можно найти на приведенных выше страницах документации.

Для URP и HDRP есть еще один тип пакетирования, который важно обсудить:

SRP Batcher

Программируемый конвейер визуализации (URP или HDRP) имеет еще один метод пакетирования SRP Batcher.

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

Это означает, что в URP и HDRP фактически безопасно использовать renderer.material для создания экземпляров материалов и изменения свойств, как описано в предыдущем разделе. Они вызовут накладные расходы памяти, но их все равно можно пакетировать! Но чтобы не делать лишних экземпляров, если разные значения свойств на самом деле не нужны, лучше использовать material.sharedMaterial,

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

Шейдеры, созданные с помощью Shader Graph, будут автоматически поддерживать SRP Batcher, но пользовательские шейдеры кода HLSL должны определять UnityPerMaterial CBUFFER. Он должен содержать все свойства материала, кроме текстур. Каждый проход в шейдере должен использовать одни и те же значения CBUFFER, поэтому я рекомендую помещать его внутри SubShader в тегах HLSLINCLUDE, так как это будет автоматически включать его в каждый проход. Например, для URP должно работать следующее:

HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    CBUFFER_START(UnityPerMaterial)
    float4 _ExampleTexture_ST; // Значения тайлинга и смещения для текстуры
    float4 _ExampleColor;
	float _ExampleRange;
	float _ExampleFloat;
    float4 _ExampleVector;
	// и так далее
    CBUFFER_END
ENDHLSL

Полные примеры вы можете найти в моем посте «Написание кода шейдера для URP» (в настоящее время только на моем старом сайте WordPress, извините за рекламу).

Обратите внимание, что блоки свойств материалов также нарушают пакетирование через SRP Batcher. Проверить, правильно ли все происходит в пакетном режиме, обычно можно используя окно Frame Debugger.

Больше сведений про SRP Batcher можно найти на странице его документации.

Оригинал статьи: https://www.cyanilux.com/tutorials/intro-to-shaders/

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

    Просто бессвязная каша

  2. Очень странно, мне так не показалось, когда я вычитывал.

  3. Лев :

    читаю уже сотый сайт потому что весь ютуб пересмотрел на эту тему и все равно не понимаю почему у меня в 2д шейдеры не работают

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

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