Shaders in Unity3d

Автор: | 21.12.2018

Введение
CPU и GPU
Типы шейдеров
Конвейер программного рендеринга
Простые примеры написания шейдеров
Полезные ссылки

Введение

Движок (жаргонизм от англ. engine – мотор, двигатель) – выделенная часть программного кода для реализации конкретной прикладной задачи. Использование движка сокращает время разработки программы.

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

Unity3d – один из наиболее известных игровых движков.

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

Вторым существенным отличием, вытекающим из первого, является то, что игровые графические движки производят визуализацию с помощью высокоскоростных графических процессоров GPU (англ. — graphics processing unit), еще их называют видеокартами. Неигровые графические движки используют в основном центральные процессоры — CPU (англ. — central processing unit).

Хорошая, интересная и близкая к фото-реализму графика, требовала от разработчиков видеокарт реализовывать многие алгоритмы на аппаратном уровне, в какой-то момент зашитых аппаратных алгоритмов им стало слишком мало. Наступило время видеокартам стать более интеллектуальными. Было принято решение позволить разработчикам программировать блоки графического процессора в произвольные конвейеры, реализующие разные алгоритмы. То есть разработчики игр, графические программисты отныне смогли писать программы для видеокарточек.

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

Изначально шейдеры можно было писать на assembler-подобном языке, но позже появились шейдерные языки высокого уровня, похожие на язык С, такие как: Cg, GLSL и HLSL. Такие языки намного проще чем C, ведь задачи, решаемые с их помощью, гораздо проще. Система типов в таких языках отражает нужды программистов графики. Поэтому они предоставляют программисту специальные типы данных: матрицы, семплеры, векторы и тп.

CPU и GPU

Как CPU, так и GPU  являются процессорами, и между ними есть много общего, однако сконструированы они были для выполнения различных задач. Разработчикам программного обеспечения требуется учитывать особенности архитектуры как GPU, так и CPU.

 Control, ALU, CacheDRAM

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

Основная функция GPU — рендеринг 3D графики и визуальных эффектов, следовательно, в нем все немного проще: ему необходимо получить на входе полигоны, а после проведения над ними необходимых математических и логических операций, на выходе выдать координаты пикселей. По сути, работа GPU сводится к оперированию над огромным количеством независимых между собой задач, следовательно, он содержит большой объем памяти, но не такой быстрой, как в CPU, и огромное количество исполнительных блоков: в современных GPU их 2048 и более, в то время как у CPU их количество может достигать 48, но чаще всего их количество лежит в диапазоне 2-8.

CPU отличается от GPU в первую очередь способами доступа к памяти. В GPU он связанный и легко предсказуемый — если из памяти читается тексел текстуры, то через некоторое время настанет очередь и соседних текселов. С записью похожая ситуация — пиксель записывается во фреймбуфер, и через несколько тактов будет записываться расположенный рядом с ним.  Также графическому процессору, в отличие от универсальных процессоров, просто не нужна кэш-память большого размера, а для текстур требуются лишь 128–256 килобайт. Кроме того, на видеокартах применяется более быстрая память, и в результате GPU доступна в разы большая пропускная способность, что также весьма важно для параллельных расчетов, оперирующих с огромными потоками данных.

Есть множество различий и в поддержке многопоточности: CPU исполняет 12 потока вычислений на одно процессорное ядро, а GPU может поддерживать несколько тысяч потоков на каждый мультипроцессор, которых в чипе несколько штук! И если переключение с одного потока на другой для CPU стоит сотни тактов, то GPU переключает несколько потоков за один такт.

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

Типы шейдеров

В зависимости от стадии конвейера шейдеры делятся на несколько типов: вершинный, фрагментный (пиксельный) и геометрический. А в новейших типах конвейеров есть еще шейдеры тесселяции.

Вершинными шейдерами делают анимации персонажей, травы, деревьев, создают волны на воде и многие другие штуки. В вершинном шейдере программисту доступны данные, связанные с вершинами, например: координаты вершины в пространстве, её текстурные координатами, её цвет и вектор нормали.

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

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

Конвейер программного рендеринга

Для понимания работы шейдеров, нужно хорошо ориентироваться в том, как видеокарта строит изображение. Общая структура визуализации 3D объекта на экране изображена на рисунке ниже:

Для начала разберемся в таком понятии, как Graphics Rendering Pipeline. Это конвейер, этапы которого проходит видеокарта для построения финального изображения. Первые компьютеры использовали программный рендеринг. Всеми просчетами занимался центральный процессор. И конвейер выглядел следующим образом:

Самые первые 3D-ускорители использовали так называемый Fixed-Function Pipeline. Из названия следует, что он был фиксированным и строго последовательным. Вмешаться в построение картинки было невозможно.

  1. Входные данные. В качестве входных параметров видеокарта получает объект в виде отдельных вершин с множеством атрибутов. Например положение вершины в пространстве, её цвет, нормаль, текстурные координаты и другие.
  2. Трансформации и освещение. На этом этапе над объектом производятся геометрические операции (перемещение, вращение, масштаб). Здесь же производится расчет освещенности сцены. Для каждой вершины вычисляются значения освещенности исходя из расположения и типа источников света, а также параметров, характеризующих поверхность объекта (отражения, поглощения).
  3. Триангуляция. На этом этапе вертексы объединяются в треугольники.
  4. Растеризация. Цель этого этапа рассчитать цвет пикселей на основе тех данных, которые были уже подготовлены. Так как мы имеем информацию только о цвете вершин, то для получения цвета пикселя мы линейно интерполируем значение между значениями цветов соответствующих вершин.
  5. Попиксельная обработка. На этом этапе происходит раскраска пикселей. Входными данными являются данные предыдущего уровня. Также здесь мы можем применять дополнительные эффекты к пикселам. Например текстурирование.
  6. Формирование готового изображения. Теперь мы должны построить финальный кадр. На этом этапе учитываются данные Z-буфера для того, чтобы определить какой объект находится ближе к камере. Также здесь производится альфа-тест. Слой за слоем объекты «наносятся» на финальное изображение. Так же здесь же могут быть применены пост-эффекты. После чего готовый кадр помещается в Frame Buffer.

Как мы видим повлиять на финальное изображение мы можем лишь выбрав определенные опции определенных этапов, но написать, например, свою модель освещения мы не можем. В те времена приходилось довольствоваться тем, что поддерживал конкретный видеоадаптер. Так было до появления первых видеокарт с аппаратной поддержкой DirectX 8.0 — 8.1. Начиная с этого момента можно было писать программы для обработки вертексов и пикселей. Конвейер для такой модели показан на рисунке ниже.

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

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

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

Благодаря шейдерам, взаимодействие с GPU сводится не к простому вызову его специфических функций, а к загрузке в него целых программ. Это дает как минимум два серьезных преимущества.

  1. Значительно сокращается расходование ресурсов CPU на подготовку данных для загрузки в GPU.
  2. Набор функций GPU стал на порядок разнообразнее благодаря тому, что разработчик фактически способен им полностью управлять.

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

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

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

Простые примеры написания шейдеров

Все шейдеры вUnity3D можно разделить на две группы: вершинные и фрагментарные (пиксельные). В Unity3D упрощенный подход к написанию шейдеров — Surface Shader. Это просто более высокий уровень абстракции. При компиляции Surface шейдера компилятор создаст шейдер, состоящий из вершинного и пиксельного. Язык в Unity3D свой. Называется ShaderLab. Он поддерживает вставки на CG и HLSL.

В качестве примера рассмотрим написание шейдера, который накладывает на объект diffuse текстуру, карту нормалей, карту отражений (опираясь на Cubemap), и производит отсечение пикселей по альфа-каналу в diffuse текстуре.
Для начала рассмотрим общий синтаксис ShaderLab. Даже, если мы пишем шейдер на CG или HLSL, нам все равно нужно знать синтаксис ShaderLab, для того, чтобы мы могли устанавливать параметры нашего шейдера в инспекторе.

Shader "Group/SomeShader"
{
    // properties that will be seen in the inspector
    Properties
     {
        _Color ("Main Color", Color) = (1,0.5,0.5,1)
     }
    // define one subshader
    SubShader
     {
        Pass
       {
       }
     }
    }
   Fallback "Diffuse"
}

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

После этого идет описание параметров Properties { }, которые будут видны в инспекторе и с которыми cможет взаимодействовать пользователь.

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

Ключевое слово Pass { } определяет блок инструкций одного прохода. Шейдер может содержать от одного до нескольких проходов. Использование нескольких проходов может быть полезно например в случае оптимизации шейдеров для старого железа или для достижения особых эффектов (outline, toon shading и др).

Если Unity3D в теле шейдера не нашла ни одного SubShader»a, который корректно может отобразить геометрию, используется откат к другому шейдеру, объявленному после Fallback инструкции.

В следующем примере  будет использован Diffuse шейдер, если видеокарта не сможет корректно отобразить текущий.

Shader "Example/Bumped Reflection Clip"
  {
    Properties
    {
     _MainTex ("Texture", 2D) = "white" {}
     _BumpMap ("Bumpmap", 2D) = "bump" {}
     _Cube ("Cubemap", CUBE) = "" {}
     _Value ("Reflection Power", Range(0,1)) = 0.5
    }
    SubShader
    {
     Tags { "RenderType" = "Opaque" }
     Cull Off
     CGPROGRAM    
     #pragma surface surf Lambert
     struct Input
      {
       float2 uv_MainTex;
       float2 uv_BumpMap;
       float3 worldRefl;
       INTERNAL_DATA
      };         
     sampler2D _MainTex;
     sampler2D _BumpMap;
     samplerCUBE _Cube;
     float _Value;
     void surf (Input IN, inout SurfaceOutput o)
      {
       float4 tex = tex2D (_MainTex, IN.uv_MainTex);
       clip (tex.a - 0.5);
       o.Albedo = tex.rgb;
       o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
       float4 refl = texCUBE (_Cube, WorldReflectionVector (IN, o.Normal));
       o.Emission = refl.rgb * _Value * refl.a;
      }
     ENDCG
    }
  Fallback "Diffuse"
}

Поле Properties cодержит  переменные, которые будут отображены в инспекторе Unity3D.

  • _MainTex — в скобках указано имя, отображаемое в инспекторе, тип и значение по умолчанию.
  •  _BumpMap — текстуры,
  •  _Cube — кубомапа для отражений,
  • _Value — ползунок степени эффекта отражений.

Тег { «RenderType» = «Opaque» } помечает шейдер как непрозрачный. Это влияет на очередь отрисовки.

Cull Off указывает, что в шейдере не будет производится отсечения по направлениям нормалей. Существуют три варианта отсечение полигонов: нормали которых направлены от камеры, в камеру, и без отсечения. Последний вариант означает то, что мы будем видеть полигон с двух сторон.

При написании кода мы будем использовать вставку на языке CG. Вставка обрамляется двумя ключевыми словами: CGPROGRAMENDCG.

#pragma surface surf Lambert — объявление функции surface-шейдера и дополнительные параметры. В данном случае функция называется surf, а в качестве дополнительных параметров указана модель освещения по Ламберту.

Теперь рассмотрим входную структуру. Все возможные переменные входной структуры можно посмотреть в справке. Мы же рассмотрим только те, что использовали:

  • uv_MainTex и uv_BumpMap — это UV координаты, необходимые шейдеру для правильного наложения текстур на объект. Эти переменные обязательно должны называться так же, как и названия переменных текстур с префиксом uv_ или uv2_ для первого и второго каналов соответственно.
  • worldRefl иINTERNAL_DATA используются для отражений.

Теперь рассмотрим функцию шейдера surf.
Первым шагом мы получаем четырехкомпонентный вектор, в котором хранится информация о цвете пикселя текстуры. Переменная tex будет хранить информацию о нем. После чего функцией clip мы указываем, какие пиксели мы пропускаем при рендеринге. Т.к. отсекаем мы по информации, которая хранится в альфа-канале, то в параметрах указываем tex.a.
После отсечения мы производим наложение нашей основной текстуры на объект следующей строчкой:

o.Albedo = tex.rgb;

Переменная o это наша выходная структура. Все её поля можно посмотреть в справке по SurfaceShaders.

Следующим шагом мы применяем карту нормалей:

o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));

После чего производим наложение отражения:

float4 refl = texCUBE (_Cube, WorldReflectionVector (IN, o.Normal));
o.Emission = refl.rgb * _Value * refl.a;

Тут стоит отметить, что полученную информацию об отражении мы умножаем на _Value, чтобы в инспекторе мы могли управлять степенью эффекта.

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

А вот то, что  получилось.

 

Полезные ссылки:

  1. Технологии с реализованным GPU-ускорением
  2. В чем разница между CPU и GPU?
  3. Что такое шейдеры?
  4. Краткая теория шейдеров
  5. Разбираемся с шейдерами в Unity3D на конкретном примере
  6. Написание шейдеров в Unity. GrabPass, PerRendererData

 

Автор: Николай Свирневский

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

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