Pull to refresh

Шейдеры для маглов

Reading time 6 min
Views 74K

Предыстория


Вышла книга Ламмерса на русском, астрологи предсказывают…

На конференции DevGAMM я купил задорого книгу Кенни Ламмерса в которой впоследствии расписались: Симонов, Галёнкик и Придюк. Вальяжно за два вечера я-таки добил её до середины и решил: собрать всё то что там написано в начале, переварить, нарисовать картинок и написать статью.


Статья предназначена для совсем новичков которые с трудом код на C# из уроков копируют, по этому я не буду углубляться в теорию которая и так уже описана. За место этого мы будем решать практические задачи и узнаем что шейдеры нужны не только что бы: «Всё сверкало и блестело».



Введение


Что такое шейдер? Это такая крутая программа которая исполняется на видеокарте. Круто да? А вот нет, в неумелых руках один кривой шейдер способен просадить вам FPS в нули, а может даже и отмотает время назад. Шейдер тоже не целостный, он ещё и исполняется на пикселях (Пиксельный шейдер) и на вертексах (Вертексный шейдер). То есть действительно для каждого пикселя вашего растра вызовется небольшая программа которая что-то там посчитает, если мы там ещё будем циклами крутить и if'ов наставим — будет совсем больно. Для этого Дамблдор придумал запретить ученикам ходить в запретный лес использовать множество синтаксического сахара и в нашем распоряжение есть весьма укороченный язык Си. Нет, мы конечно же можем использовать циклы и if'ы но это не не очень хорошо и зачастую дешевле всё будет посчитать с помощью формул или просто умножить на ноль.

Тот чьё имя нельзя называть


В Unity3D сделали эдакую обёртку над HLSL и CG(шейдерные языки) — Shader Lab. Она позволяет не писать новые шейдеры под каждое API и предоставляет множество всего вкусного. Но Shader Lab всё-таки обёртка и внутри мы всё равно пишем на одном из этих языков, исторически так сложилось что почти все пишут на CG. Также Unity3D сама закидывает большую часть информации в шейдер так-что нам нужно просто описать что мы хотим а там движок разберётся.

Surface Shader — это такой функционал который абстрагирует нас от пиксельных и вертексных шейдеров и мы работаем с поверхностью. То-есть просто говорим цвет, силу отражения и нормаль. Это всё дело с компилируется в вертексные и пиксельные функции. Очень мощный инструмент который позволяет писать шейдеры легко и просто, вот даже я смог.

Пишем первый шейдер


В итоге у нас получится это:
image

Да это терраин, да он какой-то грязный. Суть в чём:
  • Мы берём меш
  • Берём карту высот
  • Текстурируем меш по этой карте


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

Shader "Custom/HeightMapTexture" { //Имя шейдеры при выборе его на материале
	Properties { //Блок с параметрами для Unity3D
		_HeightTex ("HeightMap Texture", 2D) = "white" {} //Имя параметр, описание для редактора и тип. Далее идёт значение по умолчанию 
		_GrassTex ("Grass Texture", 2D) = "white" {} //Тип 2D указывает что тут обычная текстура
		_RockTex ("Rock Texture", 2D) = "white" {}
	}
	SubShader {
		Tags { "RenderType"="Opaque" } //Теги для рендеринга
		LOD 200
		
		CGPROGRAM //Говорим что мы шпрехаем на CG и начинаем писать на нём
		#pragma surface surf Lambert //Директива для доп. данных, таких как: Функция Surface шейдера и модель освещения.
		sampler2D _GrassTex; //Указываем переменные в которые будут переданные данные из блока Properties имена и типы желательно указывать такие же
		sampler2D _RockTex;
		sampler2D _HeightTex;
		struct Input { //Структура для входных данных для вызова шейдера, заполняется автоматически нам нужно только указать что хотим
			float2 uv_GrassTex; //uv для текстур например, позволит настраивать тайлинг и оффсет из редактора
			float2 uv_RockTex;
			float2 uv_HeightTex;
		};

		void surf (Input IN, inout SurfaceOutput o) { //Собственно функция сурфейс шейдера
               //Получаем из текстур цвета пикселей по UV
			float4 grass = tex2D(_GrassTex, IN.uv_GrassTex); //Трава
			float4 rock = tex2D(_RockTex, IN.uv_RockTex); //Камень\Грязь
			float4 height = tex2D(_HeightTex, IN.uv_HeightTex); //Карта высот

			o.Albedo = lerp(grass, rock, height); //Интерполируем по карте высот цвета травы и камня и получаем итоговый цвет  
                //И помещаем его в структуру SurfaceOutput которая нужна для передачи результата работы :)
		}
		ENDCG
	} 
	FallBack "Diffuse" //Если что-то не так взять обычный дифуз
}



Вроде выглядит сложно, но ничего Гарри же смог патронусом дементров отгонять, а мы что не сможем пиксели интерполировать? Сейчас расскажу зачем мы это всё делаем и код станет чуточку понятней.
  • Это карта высот. Мы берём из неё цвет пикселя, она чёрно белая так-что мы можем интерпретировать его как высоту. Допустим 1 это абсолютно высоко а 0 это абсолютно низко (даже ниже чем конверсия в твоей игре). Исходя из этого мы можем делать такие вещи как: Если 1 то берём полностью текстурку камня а если 0 травушки. Если будет серый то 50\50 и т.д Как видите по карте, высот у нас много так-что всё ложится мягко как в объятья Хагрида.

  • lerp() — это такая функция для интерполяции, в первых двух аргументах мы указываем два значения между которыми будет что либо выбираться а во втором на каких основаниях. Туда мы могли бы передать одну из компонент пикселя, допустим только r но в данном случае у нас передаётся всё сразу. Потом расскажу почему так делать ненужно.
  • tex2D() — Берём цвет пикселя с текстуры по UV. Очень скучно. UV мы получаем из структуры Input, юнити за нас позаботилась и туда всё положила. Для анимации текстуры мы можем допустим смещать UV по синусоидальному времени.


Понятней стало? Если нет, то вам срочно нужно покушать яблочного пирога а потом вернуться. В 70% это работает. Ах да, создаём шейдер там же где и скрипты. Потом вешаем его на материал, и материал суём в настройках терраина.


UV


Расскажу немного про UV, а то UV то UV сё — а что за зверь — непонятно.

В простом случае UV координата просто соответствует координате на объёкте. UV координаты лежат в пределе от 0 до 1. При таком раскладе текстура просто наложится на объект 1 к 1, местами растянется — но нам не страшно. Мы можем указать UV для каждой вершины и у нас получится развёртка, когда текстура ложится частями. Я конечно не эксперт но вроде Unity3D сама берёт информацию о развёртке из меша и передаёт его в шейдер так-что нам об этом думать пока-что не нужно.

Как сделать красиво и не подать виду. Или используем Normal map


Normal map — это такая текстура в rgb которой закодированы векторы. То есть в нашем трёхмерном пространстве обычный вектор состоит из трёх компонент x, y и z. Который как раз умещаются в компоненты текстуры. Поэтому она выглядит так странно когда мы смотрим на неё как на обычную текстуру, там на самом деле мозгошмыги сидят.

Используя эту текстуру при рисование наших пикселей мы можем брать вектор из пикселя и преломлять по нему свет получая эффект объёмности. Звучит сложновато но используя колдунство поверхностных шейдеров и библиотеку Unity3D реализация этой задачи весьма тривиальна.

До


После


Шейдер:
Shader "Custom/BumpMapExample" {
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_BumpMap ("Bump map", 2D) = "bump" {} //Указываем что это обычная текстура
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Lambert

		sampler2D _MainTex;
		sampler2D _BumpMap;
		
		struct Input {
			float2 uv_MainTex;
			float2 uv_BumpMap;
		};

		void surf (Input IN, inout SurfaceOutput o) {
			half4 c = tex2D (_MainTex, IN.uv_MainTex);
			o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)); //Магия
			o.Albedo = c.rgb;
			o.Alpha = c.a;
		}
		ENDCG
	} 
	FallBack "Diffuse"
}



Тут всё также как и в прошлом только теперь мы берём пиксель и передаём его в функцию UnpackNormal, она сама достанет нормаль и мы просто заносим её в структуру SurfaceOutput. Далее нормаль в функции освещения займёт своё место и там уже решат что с ней делать. К слову без источника света Normal Map'инг работать не будет.

Три непростительных заклятия


Немного про то как делать правильно:
  • Собирать текстуры в одну. Допустим у нас есть карта высот, туда в пиксель мы можем упаковать аж 4 значения, для высоты нам достаточно только одного. То есть мы можем собирать несколько текстур в одну, раскидывая их по каналам. Полезная практика настоящих волшебников
  • Часть логики можно и нужно переносить на скрипты. многое достаточно рассчитывать только каждый кадр а не каждый пиксель. Шейдеры помогают но не стоит всё делать только ими.
  • Не использовать условные операторы, это очень нагружает систему и пытайтесь искать обходные пути.
  • Всё что можно запечь заранее в текстуру, пеките. Освобождайте шейдер от лишней работы, взять данные из текстуры намного быстрее чем просчитать их заново.


Заключение


Это конец первой части. Дальше я хочу ещё рассказать про спекуляр, отображения с помощью CubeMap и о том как держать 300 анимированых юнитов в кадре на айпаде :)

Я очень надеюсь что все замечания по статье будут выражены максимально конструктивно для того что бы я и другие новички смогли обучаться. Я не считаю себя профессионалом в этом деле но имею некий опыт, и знаю сколько тонкостей в этом есть. Я очень надеюсь на помощь Хабрасообщества. Я намеренно упустил рассказ об шейдерных моделях и тонкостях в настройках тегов и прочего — для этого есть ещё не законченный материал в котором будем это разбирать.
Tags:
Hubs:
+25
Comments 9
Comments Comments 9

Articles