Pull to refresh

Исследование шейдера песка игры Journey

Reading time 19 min
Views 13K
Original author: Alan Zucconi
Среди множества инди-игр, выпущенных за последние 10 лет, одной из самых любимых для меня определённо является Journey. Благодаря своей потрясающей эстетике и красивому саундтреку Journey стала примером превосходства практически в каждом аспекте разработки.

Я разработчик игр и технический художник, поэтому сильнее всего меня заинтриговал способ рендеринга песка. Он не просто красив, но и непосредственно связан с базовым геймплеем и игровым процессом в целом. Journey в буквальном смысле построена из песка, и без такого удивительного эффекта сама игра просто не могла бы существовать.


В этой статье, разделённой на два поста, я отдам должное наследию Journey, научив вас тому, как воссоздать точно такой же рендеринг песка при помощи шейдеров. Вне зависимости от того, нужны ли в вашей игре песчаные дюны, эта серия туториалов позволит вам научиться воссоздавать конкретную эстетику в вашей собственной игре. Если вы хотите воссоздать красивый шейдер песка, использованный в Journey, то сначала нужно понять, как он был построен. И хотя он выглядит чрезвычайно сложным, на самом деле он состоит из нескольких относительно простых эффектов. Такой подход к написанию шейдеров необходим для того, чтобы стать успешным техническим художником. Поэтому я надеюсь, что вы совершите со мной это путешествие, в котором мы не только исследуем создание шейдеров, но и научимся сочетать эстетику и геймплей.

Анализ песка в Journey


Эта статья, как и многие другие попытки воссоздания рендеринга песка Journey, основываются на докладе с GDC ведущего инженера thatgamecompany Джона Эдвардса под названием "Sand Rendering in Journey". В этом докладе Джон Эдвардс рассказывает обо всех слоях эффектов, добавленных к песчаным дюнам Journey для достижения нужного внешнего вида.


Доклад очень полезен, но в контексте этого туториала многие ограничения и решения, изложенные Джоном Эдвардсом, не важны. Мы попытаемся воссоздать шейдеры песка, напоминающие шейдер Journey, в основном по визуальным референсам.

Давайте начнём с простого 3D-меша идеально гладкой дюны. Правдоподобность рендеринга песка зависит от двух аспектов: освещения и зернистости. Любопытный способ отражения света от песка обеспечивается изменённой моделью освещения. В контексте кодинга шейдеров модель освещения определяет тени и засветы на основании свойств модели и условий освещения сцены.

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

На схеме ниже показаны все эффекты, которые мы изучим в этом туториале. С технической точки зрения вычисления нормалей выполняются до обработки освещения. Для удобства изучения эффекты будут описаны в другом порядке.


Диффузный цвет

Самый простой эффект шейдера песка — это его диффузный цвет, который приблизительно описывает компонент матовости общего внешнего вида. Диффузный цвет вычисляется на основании настоящего цвета объекта и условий освещённости. Сфера, покрашенная в белый цвет, не будет повсюду идеально белой, потому что диффузный цвет зависит от падающего на неё освещения. Диффузные цвета вычисляются при помощи математической модели, аппроксимирующей отражение света от поверхности. Благодаря докладу Джона Эдвардса с GDC мы в точности знаем использованное уравнение, которое он называет diffuse contrast reflectance; оно основано на хорошо известной модели отражений по Ламберту.



До и после применения уравнения

Нормаль песка

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



Освещение краёв

В каждом уровне Journey используется ограниченная палитра цветов. Из-за этого довольно сложно понять, где заканчивается одна дюна и начинается другая. Для повышения читаемости используется техника небольшого подсвечивания того, что видно только по краю дюны. Она называется rim lighting, и для её реализации существует множество способов. Для этого туториала я выбрал способ на основании отражений по Френелю, который моделирует отражения на полированных поверхностях под так называемыми углами падения.



Зеркальное отражение океана

Одним из самых приятных аспектов игрового процесса Journey является способность «сёрфинга» по песчаным дюнам. Вероятно, поэтому thatgamecompany хотела, чтобы песок больше ощущался не как твёрдое тело, а как жидкость. Для этого использовали сильное отражение, которое часто можно встретить в шейдерах воды. Джон Эдвардс называет этот эффект ocean specular, а в туториале мы реализуем его при помощи отражения по Блинну-Фонгу.



Отражение отблесков

Добавление к шейдеру песка компонента ocean specular придаёт ему более «жидкий» внешний вид. Однако он всё равно не позволяет передать один из самых важных визуальных аспектов песка: возникающие случайным образом отблески. В настоящих дюнах этот эффект возникает, потому что каждая песчинка отражает свет в своём направлении и очень часто один из этих отражённых лучей попадает в наш глаз. Такое glitter reflection (отражение отблесков) возникает даже в тех местах, на которые не падает прямой солнечный свет; оно дополняет ocean specular и повышает ощущение правдоподобности.



Волны песка

Изменение нормалей позволило имитировать эффект небольших песчинок, покрывающих поверхность дюны. На дюнах в реальном мире часто возникают волны, вызываемые ветром. Их форма меняется в зависимости от покатости и положения каждой дюны относительно направления ветра. Потенциально такие паттерны можно создать через bump-текстуру, но в таком случае изменять форму дюн в реальном времени будет невозможно. Предложенное Джоном Эдвардсом решение похоже на технику под названием triplanar shading: в нём используются четыре разные текстуры, смешиваемые в зависимости от положения и покатости каждой дюны.



Анатомия шейдера песка Journey


В Unity есть множество шаблонов шейдеров, с которых можно начать работу. Так как нам интересны материалы, которые могут получать освещение и отбрасывать тени, то нужно начать с surface shader (поверхностного шейдера).

Все поверхностные шейдеры выполняются в два этапа. Сначала вызывается функция поверхности, собирающая свойства поверхности, которую нужно отрендерить, например, её альбедо, шероховатость, металлические свойства, прозрачность и направление нормали. Затем все эти свойства передаются в функцию освещения, которая учитывает влияние внешних источников освещения и вычисляет затенения и засветы.

Функция поверхности


Давайте начнём с того, что станет ядром нашей функции поверхности, называемой в показанном ниже коде surf. Единственные свойства, которые нам нужно задать — это цвет песка и нормаль к поверхности. Нормаль 3D-модели — это вектор, обозначающий положение поверхности. Векторы нормалей используются функцией освещения для вычисления того, как будет отражаться свет. Обычно они вычисляются во время импортирования меша. Однако их можно изменять для имитации более сложной геометрии. Именно здесь нам пригодятся эффекты нормали песка и волн песка, искажающие нормали песка для имитации его шероховатости.

void surf (Input IN, inout SurfaceOutput o)
{
    o.Albedo = _SandColor;
    o.Alpha = 1;

    float3 N = float3(0, 0, 1);
    N = RipplesNormal(N);
    N = SandNormal   (N);

    o.Normal = N;
}

При записи нормалей в o.Normal они должны быть выражены в касательном пространстве. Это значит, что вектор выбирается относительно поверхности 3D-модели. То есть float3(0, 0, 1) на самом деле означает, что в нормаль 3D-модели на самом деле не вносится никаких изменений.

Обе функции, RipplesNormal, и SandNormal получают вектор нормали и изменяют его. Позже мы увидим, как это можно сделать.

Функция освещения


Именно в функции освещения реализуются все остальные эффекты. В коде ниже показано, как в отдельных функциях вычисляется каждый отдельный компонент (diffuse colour, rim lighting, ocean specular и glitter reflection). Затем все они комбинируются.

#pragma surface surf Journey fullforwardshadows

float4 LightingJourney (SurfaceOutput s, fixed3 viewDir, UnityGI gi)
{
    float3 diffuseColor = DiffuseColor    ();
    float3 rimColor     = RimLighting     ();
    float3 oceanColor   = OceanSpecular   ();
    float3 glitterColor = GlitterSpecular ();

    float3 specularColor = saturate(max(rimColor, oceanColor));
    float3 color = diffuseColor + specularColor + glitterColor;
	
    return float4(color * s.Albedo, 1);
}

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

Обычно зеркальные отражения суммируются поверх diffuse colour. Так как здесь у нас есть не одно, а три specular reflection (rim light, ocean specular и glitter specular), то нам нужно быть более аккуратными, чтобы не сделать песок слишком мерцающим. Так как rim light и ocean specular являются частью одного эффекта, мы можем выбрать из них только максимальное значение. Glitter specular добавляется отдельно, потому что этот компонент создаёт мерцание песка.

Часть 2. Diffuse Color


Во второй части поста мы сосредоточимся на используемой в игре модели освещения и на том. как воссоздать её в Unity.

В предыдущей части мы заложили фундамент того, что постепенно превратится в нашу версию шейдера песка Journey. Как сказано ранее, функция освещения используется в поверхностных шейдерах для вычисления влияния освещения, благодаря чему на поверхности появляются затенения и засветы. Мы выяснили, что в Journey есть несколько эффектов, относящихся к этой категории. Мы начнём с самого базового (и простого) эффекта, находящегося в самом ядре этого шейдера: его diffuse lighting (диффузного/рассеянного освещения).


Пока мы опустим все другие эффекты и компоненты, сосредоточившись на освещении песка.

Написанная нами в предыдущей части поста функция освещения под названием LightingJourney просто делегирует вычисление диффузного цвета песка функции под названием DiffuseColor.

float4 LightingJourney (SurfaceOutput s, fixed3 viewDir, UnityGI gi)
{
    // Lighting properties
    float3 L = gi.light.dir;
    float3 N = s.Normal;

    // Lighting calculation
    float3 diffuseColor	= DiffuseColor(N, L);

    // Final color
    return float4(diffuseColor, 1);
}

Благодаря тому, что каждый эффект самодостаточен и хранится в собственной функции, наш код будет более модульным и чистым.

Отражение по Ламберту


Прежде чем создавать рассеянное освещение «как в Journey», неплохо будет посмотреть, как выглядит «базовая» функция рассеянного освещения. Простейшая техника создания затенения для матовых материалов называется Lambertian reflectance (отражением по Ламберту). Эта модель хорошо аппроксимирует внешний вид большинства неблестящих и неметаллических поверхностей. Она названа в честь швейцарского учёного-энциклопедиста Иоганна Гейнриха Ламберта, предложившего её концепцию в 1760 году.

В основе концепции отражения по Ламберту лежит простая идея: яркость поверхности зависит от количества падающего на неё света. Геометрически это можно показать на схеме ниже, где сфера освещается удалённым источником освещения. Хотя красная и зелёная области сферы получают одинаковое количество освещения, площади их поверхностей значительно отличаются. Если свет в красной области распределён по большей площади, это означает, что каждая единица красной площади получает меньше света по сравнению с зелёной.


Теоретически, отражение по Ламберту зависит от относительного угла между поверхностью и падающим светом. С математической точки зрения мы говорим, что это функция от нормали к поверхности и направлением освещения. Эти величины выражаются при помощи двух векторов единичной длины (называемыми единичными векторами) $N$ и $L$. Единичные векторы — это стандартный способ задания направлений в контексте кодинга шейдеров.

Значение N и L
Принято, что нормаль к поверхности $N$ — это единичный вектор, направленный от самой поверхности.

По аналогии можно предположить, что направление освещения $L$ указывает от источника освещения и следует в направлении, по которому движется свет. Но это не так: направление освещения — это единичный вектор, указывающий в сторону направления, из которого поступил свет.

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

Отражение по Ламберту в Unity
До появления в Unity 5 Standard Shader отражение по Ламберту было стандартной моделью для затенения освещённых поверхностей.

К ней по-прежнему можно получить доступ в инспекторе материалов: в Legacy shader оно называется Diffuse.

Если же вы пишете собственный поверхностный шейдер, то отражение по Ламберту доступно как функция освещения под названием Lambert:

#pragma surface surf Lambert fullforwardshadows

Её реализацию можно найти в функции LightingLambert, определённой в файле CGIncludes\Lighting.cginc.

Отражение по Ламберту и климат
Отражение по Ламберту — довольно старая модель, но она обеспечивает понимание таких сложных концепций, как затенение поверхностей. Также её можно использовать для объяснения множества других явлений. Например, та же самая схема объясняет, почему на полюсах планеты холоднее, чем на экваторе.

Посмотрев внимательнее, мы можем увидеть, что поверхность получает максимальное количество освещения, когда её нормаль параллельна направлению освещения. И наоборот: света нет, если два единичных вектора перпендикулярны друг другу.


Очевидно, что угол между $N$ и $L$ критически важен для отражения по Ламберту. Более того, яркость максимальна и равна $100\%$, когда угол равен $0$ и минимальна ($0\%$), когда угол стремится к $90^{\circ}$. Если вы знакомы с векторной алгеброй, то могли понять, что величина, представляющая отражение по Ламберту $I$, равна $N \cdot L$, где оператор $\cdot$ называется скалярным произведением.

(1)

$\begin{equation*}  I = N \cdot L \end{equation*}$


Скалярное произведение является мерой «совпадения» двух векторов относительно друг друга, и изменяется в интервале от $+1$ (у двух идентичных векторов) до $-1$ (у двух противоположных векторов). Скалярное произведение — это фундамент затенения, который я подробно рассматривал в туториале Physically Based Rendering and Lighting Models.

Реализация


И к $N$, и к $L$ можно легко получить доступ в функции освещения поверхностного шейдера через s.Normal и gi.light.dirin. Для упрощения мы переименуем их в коде шейдера в N и L.

float3 DiffuseColor(float3 N, float3 L)
{
    float NdotL = saturate( dot(N, L) );
    return NdotL;
}

Функция saturate ограничивает значение интервалом от $0$ до $1$. Однако поскольку скалярное произведение находится в интервале от $-1$ до $+1$, нам нужно будет работать только с его отрицательными значениями. Именно поэтому отражение по Ламберту часто реализуется следующим образом:

float NdotL = max(0, dot(N, L) );

Отражение контраста рассеянного освещения


Хотя отражение по Ламберту хорошо затеняет большинство материалов, оно не является ни физически точным, ни фотореалистичным. В старых играх активно использовались шейдеры по Ламберту. Игры, в которых применяется эта техника, часто кажутся старыми, потому что они могут ненамеренно воспроизводить эстетику старых игр. Если вы к этому не стремитесь, то отражения по Ламберту стоит избегать и использовать более современные технологии.

Одной из таких моделей является модель отражений по Орену-Найяру, которая изначально была изложена в статье Generalization of Lambert’s Reflectance Model, опубликованной в 1994 году Майклом Ореном и Шри К. Найяром. Модель Орена-Найяра является обобщением отражения по Ламберту и специально разработана для шероховатых поверхностей. Изначально разработчики Journey хотели использовать в качестве основы для своего шейдера песка отражение по Орену-Найяру. Однако от этой идеи отказались из-за высоких вычислительных затрат.

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

По его словам, полученная модель затенения соответствует этому уравнению:

(2)

$\begin{equation*}  I = 4 * \left( \left(N\odot \left[1, 0.3, 1\right]\right) \cdot L\right) \end{equation*}$


где $\odot$поэлементное произведение двух векторов.

float3 DiffuseColor(float3 N, float3 L)
{
    N.y *= 0.3;
    float NdotL = saturate(4 * dot(N, L));
    return NdotL;
}

Модель отражения (2) Джон Эдвардс называет diffuse contrast, поэтому на протяжении всего туториала мы будем использовать это название.

В показанной ниже анимации продемонстрирована разница затенения по Ламберту (слева) и diffuse contrast из Journey (справа).



В чём смысл 4 и 0.3?
Хотя отражение diffuse contrast не было разработано как физически точное, мы всё равно можем попробовать понять, что оно делает.

По своей сути оно по-прежнему использует отражение по Ламберту. Первое очевидное отличие заключается в то, что общий результат умножается на $4$. Это значит, что все пиксели, которые в обычном состоянии получали $25\%$ освещения, теперь будут сиять, как будто получают $100\%$ освещения. Благодаря умножению всего на $4$ слабое затенение по Ламберту становится намного сильнее, а область перехода между темнотой и светом — меньше. При этом тень становится резче.

Влияние умножения компонента y на направление нормали $0.3$ объяснить гораздо сложнее. При изменении компонентов вектора меняется общее направление, в котором он указывает. Снижая значение компонента y до всего $30\%$ от исходного значения, отражение diffuse contrast заставляет тени становиться более вертикальными.

Примечание: скалярное произведение напрямую измеряет угол между двумя векторами только в том случае, если они оба имеют длину $1$. Внесённое изменение уменьшает длину нормали $N$, которая больше не является единичным вектором.

От оттенков серого к цвету


Все показанные выше анимации имеют оттенки серого, потому что они показывают значения своей модели отражения, изменяющиеся в интервале от $0$ до $1$". Мы легко можем добавить цвета, воспользовавшись NdotL в качестве коэффициента интерполяции между двумя цветами: одного для полностью затенённого и другого для полностью освещённого песка.

float3 _TerrainColor;
float3 _ShadowColor;

float3 DiffuseColor(float3 N, float3 L)
{
    N.y *= 0.3;
    float NdotL = saturate(4 * dot(N, L));
	
    float3 color = lerp(_ShadowColor, _TerrainColor, NdotL);
    return color;
}

Часть 3. Нормали песка


В третьей части мы сосредоточимся на создании карт нормалей, превращающих гладкие 3D-модели в песчаные дюны.

В предыдущей части туториала мы реализовали диффузное освещение песка Journey. При использовании только этого эффекта пустынные дюны будут казаться довольно плоскими и скучными.


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


Этого эффекта можно добиться при помощи техники под названием bump mapping (рельефное текстурирование), позволяющей свету отражаться от плоской поверхности, как будто она сложнее. Посмотрите, как этот эффект меняет внешний вид рендеринга:



Мелкие отличия можно разглядеть при увеличении:



Разбираемся с картами нормалей


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

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


Реализовать это можно бесчисленным количеством способов. Самый простой — это создание текстуры, изменяющей направления исходных нормалей модели дюны.

Нормаль к поверхности $N$ в общем случае вычисляется по геометрии 3D-модели. Однако можно модифицировать её с помощью карты нормалей. Карты нормалей (normal map) — это текстуры, позволяющие имитировать более сложную геометрию, изменяя локальную ориентацию нормалей к поверхности. Такая техника часто называется bump mapping (рельефным текстурированием).

Изменение нормалей — это достаточно простая задача, которую можно выполнить в функции surf поверхностного шейдера. Эта функция получает два параметра, один из которых является struct под названием SurfaceOutput. Он содержит все свойства, необходимые для отрисовки части 3D-модели, от её цвета (o.Albedo) до прозрачности (o.Alpha). Ещё один параметр, который она содержит — это направление нормали (o.Normal), которое можно переписать, чтобы изменить способ отражения света на модели.

Согласно документации Unity по поверхностным шейдерам (Writing Surface Shaders), все нормали, записываемые в o.Normal структуры SurfaceOutput, должны быть выражены в касательном пространстве:

struct SurfaceOutput
{
    fixed3 Albedo;  // diffuse color
    fixed3 Normal;  // tangent space normal, if written
    fixed3 Emission;
    half Specular;  // specular power in 0..1 range
    fixed Gloss;    // specular intensity
    fixed Alpha;    // alpha for transparencies
};

Таким образом мы можем сообщить, что единичные векторы должны быть выражены в системе координат относительно нормали меша. Например, при записи в o.Normal значения float3(0, 0, 1) нормаль останется неизменной.

void surf (Input IN, inout SurfaceOutput o)
{
    o.Albedo = _SandColor;
    o.Alpha = 1;
    o.Normal = float3(0, 0, 1);
}

Так происходит, потому что вектор float3(0, 0, 1) на самом деле является вектором нормали, выраженным относительно геометрии 3D-модели.

Итак, чтобы изменить нормаль к поверхности в поверхностном шейдере, нам достаточно записать в o.Normal новый вектор в функции поверхности:

void surf (Input IN, inout SurfaceOutput o)
{
    o.Albedo = _SandColor;
    o.Alpha = 1;
    o.Normal = ... // change the normal here
}

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

Нормаль песка


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

В каждой точке модели из текстуры сэмплируется случайный единичный вектор. Затем нормаль к поверхности на определённую величину наклоняется по направлению к этому вектору. При правильном создании случайной текстуры и выборе подходящей величины смешения мы можем сдвинуть нормаль к поверхности именно так, чтобы создать ощущение зернистости, не теряя при этом общую кривизну дюн.

Случайные значения можно сэмплировать с помощью текстуры, заполненной случайными цветами. Компоненты R, G и B каждого пикселя используются в качестве компонентов X, Y и Z вектора нормали. Компоненты цвета находятся в интервале $\left[0, 1\right]$, поэтому их нужно преобразовать в интервал $\left[-1,+1\right]$. Затем полученный вектор нормализуется, чтобы его длина была равна $1$.


Создание случайных текстур
Существует множество способов генерации случайной текстуры. Для получения нужного эффекта важнее всего общее распределение случайных векторов, которые можно сэмплировать из текстуры.

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

Во время доклада на GDC Джон Эдвардс чётко указал, что случайная текстура, использованная для песка в Journey, была сгенерирована из гауссова распределения. Это гарантирует, что преобладающее направление будет совпадать с нормалью к поверхности.

Нужно ли нормализовать случайные векторы?
Использованное мной для сэмплирования случайных векторов изображение было сгенерировано при помощи полностью случайного процесса. По отдельности генерируется не только каждый пиксель: компоненты R, G и B одного пикселя тоже не зависят друг от друга. То есть в общем случае, сэмплируемые из этой текстуры векторы не будут гарантированно иметь длину, равную $1$.

Разумеется, вы можете сгенерировать текстуру, в которой каждый пиксель при преобразовании из $\left[0, 1\right]$ в $\left[-1,+1\right]$ и в самом деле должен будет иметь длину $1$. Однако здесь возникает две проблемы.

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

Чтобы избежать проблем, векторы всегда следует нормализовать.

Реализация


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

void surf (Input IN, inout SurfaceOutput o)
{
    o.Albedo = _SandColor;
    o.Alpha = 1;
    
    float3 N = float3(0, 0, 1);
    N = RipplesNormal(N); // Covered in Journey Sand Shader #6
    N = SandNormal   (N); // Covered in this article
    
    o.Normal = N;
}

В предыдущем разделе мы ввели понятие рельефного текстурирования (bump mapping), которое показало нам, что для части эффекта потребуется сэмплирование текстуры (в коде она называется uv_SandTex).

Проблема приведённого выше кода заключается в том, что для вычислений требуется знать истинную позицию точки, которую мы отрисовываем. Фактически, для сэмплирования текстуры нужна UV-координата, которая определяет, из какого пикселя выполняется чтение. Если используемая нами 3D-модель относительно плоская и имеет UV-преобразование, то мы можем использовать её UV для сэмплирования случайной текстуры.

N = WavesNormal(IN.uv_SandTex.xy, N);
N = SandNormal (IN.uv_SandTex.xy, N);

Или же можно также использовать позицию в мире (IN.worldPos) рендерящейся точки.

Теперь мы наконец-то можем сосредоточиться на SandNormal и её реализации. Как сказано выше в этой части, идея заключается в сэмплировании пикселя из случайной текстуры и использовании его (после преобразования в единичный вектор) в качестве новой нормали.

sampler2D_float _SandTex;

float3 SandNormal (float2 uv, float3 N)
{
    // Random vector
    float3 random = tex2D(_SandTex, uv).rgb;
    // Random direction
    // [0,1]->[-1,+1]
    float3 S = normalize(random * 2 - 1);
    return S;
}

Как изменить масштаб случайной текстуры?
В зависимости от UV-преобразования 3D-модели песчинки могут получиться или очень большими, или очень маленькими. Лучше всего добавить параметры масштабирования текстуры, чтобы мы могли настраивать их через инспектор.

Это стандартная возможность, предоставляемая Unity для всех текстур. Чтобы использовать её, нужно задать ещё одну переменную под названием _SandText_ST. Unity свяжет её с уже существующей переменной (и её свойством) _SandTex.

Переменная _SandText_ST будет содержать четыре значения: предпочтительный размер и смещение текстуры. Эти значения можно будет напрямую изменять в инспекторе, и они будут автоматически отображаться в слоте текстуры как Tiling и Offset:


Чтобы эти изменения отразились в сэмплировании текстуры, нам нужно использовать макрос TRANSFORM_TEX:

sampler2D_float _SandTex;
float4          _SandTex_ST;

float3 SandNormal (float2 uv, float3 N)
{
    // Random vector
    float3 random = tex2D(_SandTex, TRANSFORM_TEX(uv, _SandTex)).rgb;
    // Random direction
    // [0,1]->[-1,+1]
    float3 S = normalize(random * 2 - 1);
    return S;
}

Наклоняем нормали


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

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



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


Описанная на схеме операция называется slerp, что расшифровывается как spherical linear interpolation (сферическая линейная интерполяция). Slerp работает в точности так же, как и lerp, за одним исключением — её можно использовать для безопасной интерполяции между единичными векторами, и результатом операции будут другие единичные векторы.

К сожалению, правильная реализация slerp довольно затратна. И для эффекта, по большей мере основанного на случайности, её использовать нелогично.

Покажите мне уравнение slerp
Пусть имеются две точки, $p_0$ и $p_1$ на периметре окружности, центр которой находится в точке начала координат. Тогда $slerp$ можно задать следующим образом:

(1)

$\begin{equation*} slerp\left(p_0, p_1, t\right) = \frac{\sin\left[\left(1-t\right)\Omega\right]}{\sin\left(\Omega\right)}p_0 + \frac{\sin\left(t\Omega\right)}{\sin\left(\Omega\right)} p_1 \end{equation*}$


где $\Omega$ — угол между двумя точками $p_0$ и $p_1$, который можно вычислить при помощи скалярного произведения:

(2)

$\begin{equation*} \Omega=cos^{-1} \left(p_0 \cdot p_1 \right) \end{equation*}$



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


Операция Lerp между двумя отдельными единичными векторами не всегда создаёт другие единичные векторы. На самом деле, такого никогда не происходит, за исключением случаев, когда коэффициент равен $1$ или $0$.

Несмотря на это, при нормализации результата lerp на самом деле получается единичный вектор, который на удивление близок к результату, создаваемому slerp:

float3 nlerp(float3 n1, float3 n2, float t)
{
    return normalize(lerp(n1, n2, t));
}

Эта техника, называемая nlerp, обеспечивает близкую аппроксимацию slerp. Её использование популяризировал Кейси Муратори, один из разработчиков The Witness. Если вам интересно подробнее изучить эту тему, то рекомендую статьи Understanding Slerp. Then Not Using It Джонатана Блоу и Math Magician – Lerp, Slerp, and Nlerp.

Благодаря nlerp мы теперь может эффективно наклонять векторы нормалей в случайную сторону, сэмплированную из _SandTex:

sampler2D_float _SandTex;
float _SandStrength;

float3 SandNormal (float2 uv, float3 N)
{
    // Random vector
    float3 random = tex2D(_SandTex, uv).rgb;
    // Random direction
    // [0,1]->[-1,+1]
    float3 S = normalize(random * 2 - 1);
    
    // Rotates N towards Ns based on _SandStrength
    float3 Ns = nlerp(N, S, _SandStrength);
    return Ns;
}

Результат показан ниже:



Что дальше


В следующей части мы рассмотрим мерцающие отражения, благодаря которым дюны будут напоминать океан.

Благодарности


Видеоигра Journey разработана Thatgamecompany и издана Sony Computer Entertainment. Она доступна для PC (Epic Store) и PS4 (PS Store).

3D-модели дюн фоны и параметры освещения созданы Jiadi Deng.

3D-модель персонажа Journey была найдена на (ныне закрытом) форуме FacePunch.

Пакет Unity


Если вы хотите воссоздать этот эффект, то полный пакет Unity можно скачать на Patreon. В него включено всё необходимое, от шейдеров до 3D-моделей.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+26
Comments 2
Comments Comments 2

Articles