В этом туториале мы воссоздадим эффект 3D-принтера, используемый в таких играх, как Astroneer и Planetary Annihilation. Это интересный эффект, показывающий процесс создания объекта. Несмотря на внешнюю простоту, в нём есть множество далеко не тривиальных сложностей.
Введение: первая попытка
Для воссоздания этого эффекта давайте начнём с чего-нибудь попроще. Например, с шейдера, по-разному раскрашивающего объект в зависимости от его положения. Для этого необходимо получить доступ к положению отрисовываемых пикселей в мире. Это можно выполнить, добавив поле worldPos
к структуре Input
поверхностного шейдера Unity 5.
struct Input {
float2 uv_MainTex;
float3 worldPos;
};
Затем можно использовать в функции поверхности координату Y положения в мире для изменения цвета объекта. Этого можно добиться изменением свойства Albedo
в структуре SurfaceOutputStandard
.
float _ConstructY;
fixed4 _ConstructColor;
void surf (Input IN, inout SurfaceOutputStandard o) {
if (IN.worldPos.y < _ConstructY)
{
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
o.Alpha = c.a;
}
else
{
o.Albedo = _ConstructColor.rgb;
o.Alpha = _ConstructColor.a;
}
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
}
Результатом становится первое приближение к эффекту из Astroneer. Основная проблема заключается в том, что для цветной части всё ещё выполняется затенённое отображение.
Неосвещённый поверхностный шейдер
В предыдущем туториале PBR and Lighting Models мы изучали способ создания собственных моделей освещения для поверхностных шейдеров. Неосвещённый шейдер всегда создаёт один и тот же цвет, вне зависимости от внешнего освещения и угла обзора. Можно реализовать его следующим образом:
#pragma surface surf Unlit fullforwardshadows
inline half4 LightingUnlit (SurfaceOutput s, half3 lightDir, half atten)
{
return _ConstructColor;
}
Его единственная задача — возвращать единственный сплошной цвет. Как мы видим, он обращается к SurfaceOutput
, который использовался в Unity 4. Если мы хотим создать собственную модель освещения, работающую с PBR и глобальным освещением, то нужно реализовать функцию, получающую в качестве входных данных SurfaceOutputStandard
. В Unity 5 для этого используется следующая функция:
inline half4 LightingUnlit (SurfaceOutputStandard s, half3 lightDir, UnityGI gi)
{
return _ConstructColor;
}
Параметр gi
здесь относится к глобальному освещению (global illumination), но в нашем неосвещённом шейдере он не выполняет никаких задач. Такой подход работает, но у него есть большая проблема. Unity не позволяет поверхностному шейдеру выборочно изменять функцию освещения. Мы не можем применить стандартное освещение по Ламберту к нижней части объекта и одновременно сделать верхнюю часть неосвещённой. Можно назначить единственную функцию освещения для всего объекта. Мы должны сами менять способ рендеринга объекта в зависимости от его положения.
Передаём параметры функции освещения
К сожалению, функция освещения не имеет доступа к положению объекта. Простейший способ предоставить эту информацию — использовать булеву переменную (building
), которую мы зададим в функции поверхности. Эту переменную может проверять наша новая функция освещения.
int building;
void surf (Input IN, inout SurfaceOutputStandard o) {
if (IN.worldPos.y < _ConstructY)
{
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
o.Alpha = c.a;
building = 0;
}
else
{
o.Albedo = _ConstructColor.rgb;
o.Alpha = _ConstructColor.a;
building = 1;
}
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
}
Расширяем стандартную функцию освещения
Последняя проблема, с которой нам предстоит столкнуться, довольно сложна. Как я объяснил в предыдущем разделе, мы можем использовать building
для изменения способа вычисления освещения. Часть объекта, которая в текущий момент строится, будет неосвещённой, а на оставшейся части будет правильно рассчитанное освещение. Если мы хотим, чтобы наш материал использовал PBR, мы не можем переписывать весь код для фотореалистичного освещения. Единственное разумное решение — вызывать стандартную функцию освещения, которая уже реализована в Unity.
В традиционном стандартном поверхностном шейдере директива #pragma
, определяющая использование функции освещения PBR, имеет следующий вид:
#pragma surface surf Standard fullforwardshadows
По стандартам наименования Unity легко заметить, что используемая функция должна называться LightingStandard
. Эта функция находится в файле UnityPBSLighting.cginc
, который можно при необходимости подключить.
Мы хотим создать собственную функцию освещения под названием LightingCustom
. В обычных условиях она просто вызывает стандартную функцию PBR из Unity под названием LightingStandard
. Однако при необходимости она использует определённую ранее LightingUnlit
.
inline half4 LightingCustom(SurfaceOutputStandard s, half3 lightDir, UnityGI gi)
{
if (!building)
return LightingStandard(s, lightDir, gi); // Unity5 PBR
return _ConstructColor; // Unlit
}
Чтобы скомпилировать этот код, Unity 5 нужно определить ещё одну функцию:
inline void LightingCustom_GI(SurfaceOutputStandard s, UnityGIInput data, inout UnityGI gi)
{
LightingStandard_GI(s, data, gi);
}
Она используется для вычисления степени воздействия освещения на глобальное освещение, но для целей нашего туториала она необязательна.
Результат выйдет точно таким, какой нам нужен:
В этой первой части мы научились использовать две разные модели освещения в одном шейдере. Это позволило нам отрендерить одну половину модели с использованием PBR, а другую оставить неосвещённой. Во второй части мы завершим этот туториал и покажем, как анимировать и улучшить эффект.
Отрезаем геометрию
Проще всего добавить к нашему шейдеру эффект прекращения отрисовки верхней части геометрии. Для отмены отрисовки произвольного пикселя в шейдере можно использовать ключевое слово discard
. С его помощью можно отрисовывать только границу вокруг верхней части модели:
void surf (Input IN, inout SurfaceOutputStandard o)
{
if (IN.worldPos.y > _ConstructY + _ConstructGap)
discard;
...
}
Важно помнить, что это может оставлять «дыры» в нашей геометрии. Нужно отключить отсечение граней, чтобы полностью отрисовывалась обратная сторона объекта.
Cull Off
Теперь нас больше всего не устраивает то, что объект выглядит полым. Это не просто ощущение: в сущности, все 3D-модели являются полыми. Однако нам нужно создать иллюзию, что объект на самом деле сплошной. Этого с лёгкостью можно добиться, раскрашивая объект изнутри тем же неосвещённым шейдером. Объект по-прежнему полый, но воспринимается заполненным.
Чтобы достичь этого, мы просто раскрашиваем треугольники, направленные к камере обратной стороной. Если вы незнакомы с векторной алгеброй, то это может показаться достаточно сложным. На самом деле, этого можно довольно просто добиться с помощью скалярного произведения. Скалярное произведение двух векторов показывает, насколько они «сонаправлены». А это непосредственно связано с углом между ними. Когда скалярное произведение двух векторов отрицательно, то угол между ними больше 90 градусов. Мы можем проверить наше исходное условие, взяв скалярное произведение между направлением взгляда камеры (viewDir
в поверхностном шейдере) и нормалью треугольника. Если оно отрицательное, то треугольник повёрнут от камеры. То есть мы видим его «изнанку» и можем отрендерить её сплошным цветом.
struct Input {
float2 uv_MainTex;
float3 worldPos;
float3 viewDir;
};
void surf (Input IN, inout SurfaceOutputStandard o)
{
viewDir = IN.viewDir;
...
}
inline half4 LightingCustom(SurfaceOutputStandard s, half3 lightDir, UnityGI gi)
{
if (building)
return _ConstructColor;
if (dot(s.Normal, viewDir) < 0)
return _ConstructColor;
return LightingStandard(s, lightDir, gi);
}
Результат показан на изображениях ниже. Слева «изнаночная геометрия» отрендерена красным. Если использовать цвет верхней части объекта, то объект больше не выглядит полым.
Эффект «волнистости»
Если вы играли в Planetary Annihilation, то знаете, что в шейдере 3D-принтера используется эффект небольшой волнистости. Мы тоже можем его реализовать, добавив немного шума к положению отрисовываемых пикселей в мире. Этого можно добиться или текстурой шума, или с помощью непрерывной периодической функции. В коде ниже я использую синусоиду с произвольными параметрами.
void surf (Input IN, inout SurfaceOutputStandard o)
{
float s = +sin((IN.worldPos.x * IN.worldPos.z) * 60 + _Time[3] + o.Normal) / 120;
if (IN.worldPos.y > _ConstructY + s + _ConstructGap)
discard;
...
}
Эти параметры можно подправить вручную для получения красивого эффекта волнистости.
Анимация
Последняя часть эффекта — это анимация. Её можно получить, просто добавив к материалу параметр _ConstructY
. Об остальном позаботится шейдер. Можно управлять скоростью эффекта или через код, или с помощью кривой анимации. При первом варианте вы можете полностью контролировать его скорость.
public class BuildingTimer : MonoBehaviour
{
public Material material;
public float minY = 0;
public float maxY = 2;
public float duration = 5;
// Update is called once per frame
void Update () {
float y = Mathf.Lerp(minY, maxY, Time.time / duration);
material.SetFloat("_ConstructY", y);
}
}
Замечу в конце, что использованная в этом изображении модель несколько секунд выглядит полой, потому что нижняя часть ускорителей незамкнута. То есть объект на самом деле полый.
[Можно скачать пакет Unity (код, шейдер и 3D-модели), поддержав автора оригинала статьи десятью долларами на Patreon.]
Автор: PatientZero