Shader — это не магия. Написание шейдеров в Unity. Введение
Всем привет! Меня зовут Дядиченко Григорий, и я основатель и CTO студии Foxsys. Сегодня хочется поговорить про шейдеры. Умение писать шейдеры (и в целом работать с рендером) очень важно при разработке под мобильные платформы или AR/VR, если хочется добиться крутой графики. Многие разработчики считают, что шейдеры — это магия. Что по ним мало хорошей информации, и что чтобы их писать нужно иметь, как мимимум, звание кандидата наук. Да, разработка шейдеров по своим принципам сильно отличается от клиентской разработки. Но основное понимать базовые принципы работы шейдеров, а так же знать их суть, чтобы в этом не было ничего магического и поиск информации по этой теме был простой задачей. Данная серия статей рассчитана на новичков, так что если вы разбираетесь в программировании шейдеров, данная серия вам не будет интересна. Всем же кто хочет разобраться в этой теме — добро пожаловать под кат!
Это вводная статья в которой я расскажу общие принципы написания шейдеров. Если тема будет интересна, то мы разберём уже подробнее в отдельных статьях: вершинные шейдеры, геометрические шейдеры, фрагментные/пиксельные шейдеры, трипланарные шейдеры, скринспейс эффекты и компьют шейдеры (OpenCL, СUDA и т.п.). И в целом всю ту магию, которую можно делать на GPU. Разбираться это будет в контексте стандартного рендер пайплайна Unity. Так LWRP и HDRP мне пока кажутся немного сыроватыми.
Что такое шейдер?
По сути это программа выполняемая на гпу, выходными данными которых является разная информация. В вершинных шейдерах — это параметры вершин меша. Пиксельные шейдеры выполняются попиксельно.
Для понимания того, как работают шейдеры нужно рассказать, что такое графический конвейер (graphic pipeline). Очень часто про эту тему говорят довольно сложными словами, но мы это немного упростим для понимания. Возьмём на примере OpenGL. В этом плане мне очень нравится эта картинка.
Если опустить детали связанные с освещением и т.п. То в целом с точки написания тех же Unlit шейдеров на hlsl суть такова. У нас есть в шейдере
где мы определяем, что вертексная часть шейдера будет писаться в функции vert, а фрагментная — в функции frag.
Структуры которые мы описываем в шейдере определяют какие данные мы будем забирать из меша и после обработки вертексным шейдером, которые висят на нашем MeshRenderer и MeshFilter объекте.
Дальше вертексный шейдер вычисляет получив на вход данные appdata и отдаёт результат в виде структуры v2f, которая дальнейшем пойдёт в фрагментный шейдер. Который в свою очередь уже рассчитает цвет пикселя. Так как информация v2f пишется только в вершины (которых меньше, чем пикселей), данные в фрагментной части интерполируются. Всё это можно представить как то, что vert считается в каждом вертексе независимо. Потом результат передаётся в фрагментную часть, где frag для каждого пикселя считается так же независимо. Так как вычисления производятся параллельно, в данных частях нет никакой информации о соседях (если не передавать её как-то хитро).
Более детально все нюансы, а так же множество примеров описаны в документации Unity docs.unity3d.com/Manual/SL-Reference.html
Языки программирования шейдеров
Дальше с точки зрения изучения шейдеров, когда эти языки уже не вызывают вопросов можно посмотреть какие возможности предоставляет сам по себе«UnityCG.cginc» и другие библиотеки написанные юнити, чтобы упростить себе работу.
Почему if в шейдерах — это плохо?
Тут важно понимать, как шейдеры исполняются на уровне железа и почему они такие быстрые, что могут выполнять миллионы операций не напрягаясь.
Основная идея графических процессоров — это максимальная параллельность вычислений. Тут нужно ввести такое понятие, как “волновой фронт”. По сути оно довольно простое, волновой фронт — это группа шейдеров выполняющая одну и туже последовательность операции. То есть с точки зрения гпу самый лучший вариант, когда в одно и тоже время выполняются одни и те же инструкции. Единственно различие в выполнении — это входные данные. Проблема ветвления в том, что может случиться ситуация, когда в одной группе шейдеров, шейдеры должны вызывать разные операции. Что в свою очередь приводит к созданию нового волнового фронта, копированию в него данных и т.п. А это очень дорого.
Там есть нюансы и исключения, но для того чтобы спокойно писать if, вы должны понимать, как он себя поведёт на целевой версии графического апи. Так как тот же самый OpenGL ES 2 или DX11 в этом плане сильно отличаются.
Зачем мне это знать, ведь есть нодовые редакторы?
Важно понимать, что нодовые редакторы — это в первую очередь инструмент для техникал артистов. Это специалисты, которые имеют экспертизу в математике, но в большей степени являются дизайнерами. Шейдеры типа wireframe (где требуется понимание барицентрических координат) или же преобразование к картезианским координатам, которое используется для хитрых проекций, в разы проще делать кодом, так же как и многие математические модели физических материалов. При этом с точки зрения шейдерного программиста вы по сути делаете кастомные ноды и инструменты для техникал артистов, чтобы творить реальную магию. Нодовые редакторы имеют ограниченный функционал с этой точки зрения. Поэтому важно уметь писать шейдеры на языках типа hlsl. Понимать то, как работает рендер и т.п.
Полезные ресурсы для изучения
С точки зрения изучения шейдерного программирования хорошим упражнением является переписывание шейдеров с www.shadertoy.com или glslsandbox.com. Кроме того существует крутой профиль специалиста из Unity, где можно посмотреть много интересного github.com/keijiro
Всё остальное — это математика и понимание физики эффектов. Это в чём-то похоже на смешивание ингредиентов, если не решается конкретная задача физического моделирования. Много любопытного можно сделать смешивая между собой шум, преломление, подповерхностное рассеивание света, каустику, эффект Френеля, реакцию диффузии и прочие физические свойства объектов. В целом шейдерное программирование это безусловно не элементарно, и там есть куда копать в глубину.
Если тема шейдеров будет интересно, то постараюсь выпустить серию на статей на эту тему, уже с конкретными примерами и туториалами на тему создания разных эффектов. Предлагайте в комментариях про что вам было бы интересно прочитать и какие темы изучить. Спасибо за внимание!
Все эффекты в статье — это запись эффектов шейдеров с shadertoy.
Шейдеры интерактивных карт в Unity
Этот туториал посвящён интерактивным картам и их созданию в Unity при помощи шейдеров.
Этот эффект может служить основой более сложных техник, например голографических проекций или даже песочного стола из фильма «Чёрная пантера».
Источником вдохновения для этого туториала стал опубликованный Baran Kahyaoglu твит, демонстрирующий пример того, что он создаёт для Mapbox.
Nothing really special this time, no shader/vfx magic, it’s just an interactive map (with pan&zoom) on the same table/environment.
It uses new #unity HDRP though so it looks very cool compared to regular boring topdown maps.#gamedev #madewithunity #builtwithmapbox #map pic.twitter.com/hUgZqfloUK
and even though there’s nothing special about it, it’s so much fun to move around because it just looks good.
It was hard to record in HD because of the mouse movement but here’s a low quality one. pic.twitter.com/ileBzYwHO9
Сцена (за исключением карты) взята из демо Unity Visual Effect Graph Spaceship (см. ниже), которое можно скачать здесь.
Часть 1. Смещение вершин
Анатомия эффекта
Первое, что можно сразу заметить — географические карты плоски: если их использовать в качестве текстур, то им не хватает трёхмерности, которую бы имела настоящая 3D-модель соответствующей области карты.
Можно применить такое решение: создать 3D-модель той области, которая нужна в игре, а затем наложить на неё текстуру из географической карты. Это поможет решить задачу, но требует много времени и не позволит реализовать эффект «прокрутки» из видео Baran Kahyaoglu.
Очевидно, что лучше всего применить более технический подход. К счастью, для изменения геометрии 3D-модели можно использовать шейдеры. С их помощью можно превратить любую плоскость в долины и горы нужной нам области.
В этом туториале мы используем карту коммуны Кильота в Чили, знаменитой своими характерными холмами. На изображении ниже показана текстура области, нанесённая на круглый меш.
Хоть мы и видим холмы и горы, они всё-таки совершенно плоские. Это разрушает иллюзию реализма.
Экструдирование нормалей
Первым шагом к использованию шейдеров для изменения геометрии является техника под названием «экструдирование нормалей» (normal extrusion). Ей требуется модификатор вершин: функция, способная манипулировать отдельными вершинами 3D-модели.
Способ применения модификатора вершин зависит от типа используемого шейдера. В этом туториале мы будем изменять Surface Standard Shader — один из типов шейдеров, которые можно создавать в Unity.
Существует множество способов манипуляции вершинами 3D-модели. Один из самых первых способов, описываемых в большинстве туториалов по вершинным шейдерам — это экструдирование нормалей. Он заключается в выталкивании каждой вершины «наружу» (экструдировании), что придаёт 3D-модели более раздутый вид. «Наружу» обозначает, что каждая вершина движется вдоль направления нормали.
Для гладких поверхностей это срабатывает очень хорошо, но в моделях с плохим соединением вершин такой способ может создавать странные артефакты. Этот эффект хорошо объяснён в одном из моих первых туториалов: A Gentle Introduction to Shaders, где я показал, как экструдировать и интрудировать 3D-модель.
Отредактированный шейдер выглядит следующим образом:
Экструдирование нормалей с текстурами
Использованный нами выше код работает правильно, но он далёк от того эффекта, которого мы хотим достичь. Причина заключается в том, что мы не хотим экструдировать все вершины на одинаковую величину. Мы хотим, чтобы поверхность 3D-модели соответствовала долинам и горам соответствующего географического региона. Сначала нам каким-то образом нужно хранить и извлекать информацию о том, насколько поднята каждая точка карты. Мы хотим, чтобы на экструдирование влияла текстура, в которой закодированы высоты ландшафта. Такие текстуры часто называют картами вершин (heightmaps), однако нередко они также называются картами глубин (depthmaps), в зависимости от контекста. Получив информацию о высотах, мы сможем модифицировать экструдирование плоскости на основании карты высот. Как показано на схеме, это позволит нам контролировать поднятием и опусканием областей.
Довольно просто найти спутниковое изображение интересующей вас географической области и связанную с ней карту высот. Ниже показана спутниковая карта Марса (сверху) и карта высот (снизу), которые использовались в этом туториале:
Я подробно рассказывал о концепции карты глубин в ещё одной серии туториалов под названием «3D-фотографии Facebook изнутри: шейдеры параллакса» [перевод на Хабре].
В показанном ниже фрагменте кода текстура под названием _HeightMap используется для модификации величины экструдирования, выполняемого для каждой вершины:
И в самом деле, tex2D используется для сэмплирования пикселей из текстуры, вне зависимости от того, что в ней хранится, цвета или высоты. Однако можно заметить, что tex2D невозможно использовать в вершинной функции.
Причина в том, что tex2D не только считывает пиксели из текстуры. Она также решает, какую версию текстур использовать, в зависимости от расстояния до камеры. Эта техника называется MIP-текстурированием (mipmapping): она позволяет иметь уменьшенные версии одной текстуры, которые можно автоматически использовать на различных расстояниях.
В поверхностной функции шейдер уже знает, какую MIP-текстуру использовать. Эта информация может быть ещё не доступна в вершинной функции, и поэтому tex2D нельзя использовать с полной уверенностью. В отличие от неё, функции tex2Dlod можно передать два дополнительных параметра, которые в этом туториале могут иметь нулевое значение.
Результат чётко заметен на изображениях ниже
В данном случае можно выполнить одно небольшое упрощение. Код, который мы рассматривали ранее, может работать с любой геометрией. Однако мы можем допустить, что поверхность абсолютно плоская. На самом деле мы действительно хотим применить этот эффект к плоскости.
Следовательно, можно удалить v.normal и заменить её на float3(0, 1, 0) :
Мы могли это сделать, потому что все координаты в appdata_base хранятся в пространстве модели, то есть они задаются относительно центра и ориентации 3D-модели. Перенос, поворот и масштабирование при помощи transform в Unity меняют позицию, поворот и масштаб объекта, но не влияют на исходную 3D-модель.
Часть 2. Эффект прокрутки
Всё, что мы сделали выше, довольно неплохо работает. Прежде чем продолжить, вынесем код, необходимый для вычисления новой высоты вершины, в отдельную функцию getVertex :
Тогда вся функция vert будет иметь вид:
Мы сделали так потому, что в ниже нам понадобится вычислять высоту нескольких точек. Благодаря тому, что эта функциональность будет в собственной отдельной функции, код станет намного проще.
Вычисление UV-координат
Однако это приводит нас к другой проблеме. Функция getVertex зависит не только от позиции текущей вершины (v.vertex), но и от её UV-координат ( v.texcoord ).
Это означает, что имеющаяся система способна вычислять смещение высоты только для текущей вершины. Такое ограничение не позволит нам двигаться дальше, поэтому нужно найти решение.
Проще всего будет найти способ вычисления UV-координат 3D-объекта, зная позицию его вершины. Это очень сложная задача, и существует несколько техник её решения (одна из самых популярных — это трипланарная проекция). Но в данном конкретном случае нам не нужно сопоставлять UV с геометрией. Если мы допустим, что шейдер всегда будет применяться к плоскому мешу, то задача становится тривиальной.
Мы можем вычислять UV-координаты (нижнее изображение) из позиций вершин (верхнее изображение) благодаря тому, что на плоском меше и те, и другие накладываются линейно.
Это значит, что для решения нашей задачи нам нужно преобразовать компоненты XZ позиции вершины в соответствующие UV-координаты.
Такая процедура называется линейной интерполяцией. Она подробно рассмотрена на моём веб-сайте (например: The Secrets Of Colour Interpolation).
В большинстве случаев значения UV находятся в интервале от до ; координаты каждой вершины, напротив, потенциально ничем не ограничены. С точки зрения математики, для преобразования из XZ в UV нам нужны только их предельные значения:
Эти значения изменяются в зависимости от используемого меша. На плоскости Unity UV-координаты находятся в интервале от до , а координаты вершин находятся в интервале от до .
Уравнения преобразования XZ в UV имеют вид:
Если вам незнакомо понятие линейной интерполяции, то эти уравнения могут показаться довольно пугающими.
Однако выводятся они достаточно просто. Давайте рассмотрим только пример . У нас есть два интервала: один имеет значения от до , другой — от до . Входящими данными для координаты является координата текущей обрабатываемой вершины, а выходными данными будет координата , используемая для сэмплирования текстуры.
Нам необходимо сохранить свойства пропорциональности между и его интервалом, и и его интервалом. Например, если имеет значение 25% от его интервала, то тоже будет иметь значение 25% от его интервала.
Всё это показано на следующей схеме:
Из этого мы можем вывести, что пропорция, составляемая красным отрезком по отношению к розовому, должна быть такой же, что и пропорция между синим отрезком и голубым:
Теперь мы можем преобразовать показанное выше уравнение, чтобы получить :
Эти уравнения можно реализовать в коде следующим образом:
Теперь мы можем вызывать функцию getVertex без необходимости передачи ей v.texcoord :
Тогда вся функция vert принимает вид:
Эффект прокрутки
Благодаря написанному нами коду на меше отображается вся карта. Если мы хотим усовершенствовать отображение, то нужно внести изменения.
Давайте ещё немного формализуем код. Во-первых, нам может понадобиться приблизить отдельную часть карты, а не смотреть на неё целиком.
Эту область можно определить двумя значениями: её размерами ( _CropSize ) и расположением на карте ( _CropOffset ), измеряемыми в пространстве вершин (от _VertexMin до _VertexMax ).
Получив эти два значения, мы можем ещё раз использовать линейную интерполяцию, чтобы getVertex вызывалась не для настоящей позиции вершины 3D-модели, а для отмасштабированной и перенесённой точки.
Если мы хотим, чтобы выполнялась прокрутка, то достаточно будет обновлять _CropOffset через скрипт. Благодаря этому область усечения будет двигаться, фактически выполняя прокрутку по ландшафту.
Чтобы это сработало, очень важно указать для режима Wrap Mode всех текстур значение Repeat. Если этого не сделать, то мы не сможем зацикливать текстуру.
Часть 3. Затенение рельефа
Плоское затенение
Весь написанный нами код работает, но имеет серьёзную проблему. Затенение модели выполняется как-то странно. Поверхность правильно искривляется, но реагирует на свет так, как будто является плоской.
Это очень чётко видно на показанных ниже изображениях. На верхнем изображении показан имеющийся шейдер; на нижнем показано, как он работает на самом деле.
Устранение этой проблемы может быть большой сложностью. Но сначала нам нужно разобраться, в чём же ошибка.
Операция экструдирования нормалей изменила общую геометрию плоскости, которую мы использовали изначально. Однако Unity изменила только позицию вершин, но не их направления нормалей. Направление нормали вершины, как понятно из названия, — это вектор единичной длины (направление), указывающий перпендикулярно поверхности. Нормали необходимы, потому что они играют важную роль в затенении 3D-модели. Они используются всеми поверхностными шейдерами для вычисления того, как свет должен отражаться от каждого треугольника 3D-модели. Обычно это нужно для улучшения трёхмерности модели, например, это заставляет свет отражаться от плоской поверхности так же, как он отражался бы от изогнутой. Этот трюк часто используется, чтобы низкополигональные поверхности выглядели более плавными, чем есть на самом деле (см. ниже).
Однако в нашем случае происходит обратное. Геометрия искривлённая и плавная, но так как все нормали направлены вверх, свет отражается от модели так, как будто она плоская (см. ниже):
Подробнее о роли нормалей в затенении объекта можно прочитать в статье о Normal Mapping (Bump Mapping), где одинаковые цилиндры выглядят очень разными, несмотря на одну 3D-модель, из-за разных способов вычислений нормалей вершин (см. ниже).
К сожалению, ни в Unity, ни в языке создания шейдеров нет встроенного решения для автоматического пересчёта нормалей. Это значит, что придётся изменять их вручную в зависимости от локальной геометрии 3D модели.
Вычисление нормалей
Единственный способ устранения проблемы с затенением — это вычисление нормалей вручную на основании геометрии поверхности. Подобная задача рассматривалась в посте Vertex Displacement – Melting Shader Part 1, где она использовалась для симуляции таяния 3D-моделей в игре Cone Wars.
Хотя готовый код должен будет работать в 3D-координатах, давайте пока ограничим задачу только двумя измерениями. Представим, что на нужно вычислить направление нормали, соответствующей точке на 2D-кривой (большая синяя стрелка на схеме ниже).
С геометрической точки зрения, направление нормали (большая синяя стрелка) — это вектор, перпендикулярный касательной, проходящей через интересующую нас точку (тонкую синюю линию). Касательную можно представить как линию, расположенную на кривизне модели. Касательный вектор — это единичный вектор, который лежит на касательной.
Это значит, что для вычисления нормали нужно сделать два шага: сначала найти прямую, касательную к нужной точке; затем вычислить вектор, перпендикулярный ей (который и будет необходимым направлением нормали).
Вычисление касательных
Для получения нормали нам сначала нужно вычислить касательную. Её можно аппроксимировать, сэмплировав точку поблизости и использовав его для построения отрезка рядом с вершиной. Чем меньше отрезок, тем точнее будет значение.
Необходимы три этапа:
Векторное произведение
Получив подходящие векторы касательной и касательной к двум точкам, мы можем вычислить нормаль при помощи операции под названием векторное произведение. Существует множество определений и объяснений векторного произведения и того, что оно делает.
Векторное произведение получает два вектора и возвращает один новый. Если два исходных вектора были единичными (их длина равна единице), и они расположены под углом 90, то получившийся вектор будет расположен под 90 градусов относительно обоих.
Поначалу это может сбивать с толку, но графически это можно представить так: векторное произведение двух осей создаёт третью. То есть , но ещё и , и так далее.
Если мы сделаем достаточно малый шаг (в коде это offset ), то векторы касательной и касательной к двум точкам будут находиться под углом 90 градусов. Вместе с вектором нормали они образуют три перпендикулярные оси, ориентированные вдоль поверхности модели.
Зная это, мы можем написать весь необходимый код для вычисления и обновления вектора нормали.
Соединяем всё вместе
Теперь, когда всё работает, мы можем вернуть и эффект прокрутки.
И на этом наш эффект наконец-то завершён.
Куда двигаться дальше
Этот туториал может стать основой более сложных эффектов, например, голографических проекций или даже копии песочного стола из фильма «Чёрная пантера».