Хабрахабр

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

Среди множества инди-игр, выпущенных за последние 10 лет, одной из самых любимых для меня определённо является 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: в нём используются четыре разные текстуры, смешиваемые в зависимости от положения и покатости каждой дюны.

В 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);
}

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

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

Часть 2. Diffuse Color

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

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

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

Написанная нами в предыдущей части поста функция освещения под названием 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^$. Если вы знакомы с векторной алгеброй, то могли понять, что величина, представляющая отражение по Ламберту $I$, равна $N \cdot L$, где оператор $\cdot$ называется скалярным произведением.

(1)

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

Скалярное произведение является мерой «совпадения» двух векторов относительно друг друга, и изменяется в интервале от $+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)

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

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

3$" data-tex="inline"/> объяснить гораздо сложнее. Влияние умножения компонента y на направление нормали <img src="https://habrastorage.org/getpro/habr/formulas/34c/99c/5d3/34c99c5d3317cdd989bf0d0d03e51b22.svg" alt="$0. Снижая значение компонента y до всего $30\%$ от исходного значения, отражение diffuse contrast заставляет тени становиться более вертикальными. При изменении компонентов вектора меняется общее направление, в котором он указывает.

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

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

Все показанные выше анимации имеют оттенки серого, потому что они показывают значения своей модели отражения, изменяющиеся в интервале от $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-модели. Такая техника часто называется bump mapping (рельефным текстурированием). Карты нормалей (normal map) — это текстуры, позволяющие имитировать более сложную геометрию, изменяя локальную ориентацию нормалей к поверхности.

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

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

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-модели.

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

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 вектора нормали. Случайные значения можно сэмплировать с помощью текстуры, заполненной случайными цветами. Затем полученный вектор нормализуется, чтобы его длина была равна $1$. Компоненты цвета находятся в интервале $\left[0, 1\right]$, поэтому их нужно преобразовать в интервал $\left[-1,+1\right]$.

Создание случайных текстур

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

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

Это гарантирует, что преобладающее направление будет совпадать с нормалью к поверхности. Во время доклада на 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-модели песчинки могут получиться или очень большими, или очень маленькими. Лучше всего добавить параметры масштабирования текстуры, чтобы мы могли настраивать их через инспектор.

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

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

Чтобы эти изменения отразились в сэмплировании текстуры, нам нужно использовать макрос 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

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

Операция 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-моделей.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть