Шейдеры растворения и исследования мира +47


Часть 1: шейдер растворения


Шейдер растворения возвращает красивый эффект, к тому же его легко создать и понять; сегодня мы сделаем его в Unity Shader Graph, а также напишем на HLSL.

Вот пример того, что мы будем создавать:



Как это работает


Чтобы создать шейдер растворения (dissolve shader), нам придётся работать со значением AlphaClipThreshold в шейдере «Shader Graph» или воспользоваться функцией HLSL под названием clip.

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

Использовать мы будем такую текстуру:


Можете создать и собственную — прямые, треугольники, да всё, что угодно! Просто помните, что белые части растворяются быстрее.

Эту текстуру я создал в Photoshop, воспользовавшись фильтром «Clouds».

Даже если вам интересен только Shader Graph и вы ничего не знаете о HLSL, то я всё равно рекомендую прочитать эту часть, потому что полезно понимать, как Unity Shader Graph работает внутри.



HLSL


В HLSL мы используем функцию clip(x). Функция clip(x) отбрасывает все пиксели со значением меньше нуля. Поэтому если мы вызовем clip(-1), то будем уверены, что шейдер никогда не будет рендерить этот пиксель. Подробнее о clip можно прочитать в Microsoft Docs.

Свойства


Шейдеру нужны два свойства, Dissolve Texture и Amount (которая будет обозначать общий процесс выполнения). Как и в случае с другими свойствами и переменными, можно назвать их как угодно.

Properties {
    //Your other properties
    //[...]
 
    //Dissolve shader properties
    _DissolveTexture("Dissolve Texture", 2D) = "white" {}
    _Amount("Amount", Range(0,1)) = 0
 }

Не забудьте добавить после CGPROGRAM SubShader следующее (иными словами, объявить переменные):

sampler2D _DissolveTexture;
half _Amount;

Кроме того, не забудьте. что их имена должны соответствовать именам в разделе Properties.

Функция


Мы начнём функцию Surface или Fragment с сэмплирования текстуры растворения и получения значения красного. P.S. Наша текстура сохранена в градациях серого, то есть её значения R, G и B равны, и можно выбрать любое из них. Например, белый равен (1,1,1), чёрный равен (0,0,0).

В своём примере я используют поверхностный шейдер:

void surf (Input IN, inout SurfaceOutputStandard o) {
 
    half dissolve_value = tex2D(_DissolveTexture, IN.uv_MainTex).r; //Get how much we have to dissolve based on our dissolve texture
    clip(dissolve_value - _Amount); //Dissolve!
 
    //Your shader body, you can set the Albedo etc.
    //[...]
}

И всё! Мы можем применить этот процесс к любому имеющемуся шейдеру и превратить его в шейдер растворения!

Вот стандартный Surface Shader движка Unity, превращённый в двусторонний шейдер растворения:

Shader "Custom/DissolveSurface" {
 Properties {
 _Color ("Color", Color) = (1,1,1,1)
 _MainTex ("Albedo (RGB)", 2D) = "white" {}
 _Glossiness ("Smoothness", Range(0,1)) = 0.5
 _Metallic ("Metallic", Range(0,1)) = 0.0
 
 //Dissolve properties
 _DissolveTexture("Dissolve Texutre", 2D) = "white" {} 
 _Amount("Amount", Range(0,1)) = 0
 }
 
 SubShader {
 Tags { "RenderType"="Opaque" }
 LOD 200
 Cull Off //Fast way to turn your material double-sided
 
 CGPROGRAM
 #pragma surface surf Standard fullforwardshadows
 
 #pragma target 3.0
 
 sampler2D _MainTex;
 
 struct Input {
 float2 uv_MainTex;
 };
 
 half _Glossiness;
 half _Metallic;
 fixed4 _Color;
 
 //Dissolve properties
 sampler2D _DissolveTexture;
 half _Amount;
 
 void surf (Input IN, inout SurfaceOutputStandard o) {
 
 //Dissolve function
 half dissolve_value = tex2D(_DissolveTexture, IN.uv_MainTex).r;
 clip(dissolve_value - _Amount);
 
 //Basic shader function
 fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; 
 
 o.Albedo = c.rgb;
 o.Metallic = _Metallic;
 o.Smoothness = _Glossiness;
 o.Alpha = c.a;
 }
 ENDCG
 }
 FallBack "Diffuse"
}



Shader Graph


Если нам нужно будет создать этот эффект с помощью Unity Shader Graph, то мы должны использовать значение AlphaClipThreshold (которое работает иначе, чем clip(x) из HLSL). В этом примере я создал PBR-шейдер.

Функция AlphaClipThreshold приказывает шейдеру отбрасывать все пиксели, значение которых меньше её значения Alpha. Например, если оно равно 0.3f, а наше значение альфы равно 0.2f, то шейдер не будет рендерить этот пиксель. О функции AlphaClipThreshold можно прочитать в документации Unity: PBR Master Node и Unlit Master Node.

Вот наш готовый шейдер:


Мы сэмплируем текстуру растворения и получаем значение красного, а затем прибавляем его к значению Amount (которое является свойством, которое я добавил для обозначения общего процесса выполнения, значение 1 означает полное растворение) и соединяем его к AlphaClipThreshold. Готово!

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



Шейдер растворения с контурами


А если попробовать добавить к нему контуры? Давайте это сделаем!


Мы не можем работать с уже растворившимися пикселями, потому что после их отбрасывания они пропадают навсегда. Вместо этого мы можем работать с «почти растворившимися» значениями!

В HLSL это сделать очень просто, достаточно добавить несколько строк кода после вычислений clip:

void surf (Input IN, inout SurfaceOutputStandard o) {
 //[...]
 //After our clip calculations
 if (dissolve_value - _Amount < .05f) //outline width = .05f
 o.Emission = fixed3(1, 1, 1); //emits white color
 
 //Your shader body, you can set the Albedo etc. 
 //[...]
 }

Готово!

При работе с Shader Graph логика немного отличается. Вот готовый шейдер:




Мы можем создать очень крутые эффекты с помощью простого шейдера растворения; можно экспериментировать с различными текстурами и значениями, а также придумать что-то ещё!

Часть 2: шейдер исследования мира


Шейдер исследования мира (или "шейдер растворения мира", или глобальное растворение") позволяет нам одинаково скрывать все объекты сцены на основании их расстояния до позиции; сейчас мы создадим такой шейдер в Unity Shader Graph и напишем его на HLSL.

Вот пример того, что мы создадим:




Расстояние как параметр


Допустим, нам нужно растворять объект в сцене, если он слишком далеко от игрока. Мы уже объявили параметр _Amount, который управляет процессом исчезания/растворения объекта, поэтому нам нужно заменить его расстоянием между объектом и игроком.

Для этого нам нужно взять позиции Player и Object.

Позиция игрока


Процесс будет аналогичным и для Unity Shader Graph, и для HLSL: нам нужно передавать в коде позицию игрока.

private void Update()
{
    //Updates the _PlayerPos variable in all the shaders
    //Be aware that the parameter name has to match the one in your shaders or it wont' work
    Shader.SetGlobalVector("_PlayerPos", transform.position); //"transform" is the transform of the Player
}



Shader Graph


Позиция объекта и расстояние до него


С помощью Shader Graph мы сможем использовать ноды Position и Distance.



P.S. Чтобы эта система заработала со Sprite Renderers, необходимо добавить свойство _MainTex, сэмплировать его и соединить с albedo. Можете прочитать мой предыдущий туториал Sprites diffuse shader (в котором используется shader graph).



HLSL (поверхность)


Позиция объекта


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

struct Input 
{
 float2 uv_MainTex;
 float3 worldPos; //add this and Unity will set it automatically
};

На странице документации Unity можно узнать, какие другие встроенные параметры допустимо добавлять к структуре Input.

Применяем расстояние


Нам нужно использовать расстояние между объектами и игроком как величину растворения. Для этого можно применить встроенную функцию distance (документация Microsoft).

void surf (Input IN, inout SurfaceOutputStandard o) {
 half dissolve_value = tex2D(_DissolveTexture, IN.uv_MainTex).x;
 
 float dist = distance(_PlayerPos, IN.worldPos);
 
 clip(dissolve_value - dist/ 6f); //"6" is the maximum distance where your object will start showing
 
 //Set albedo, alpha, smoothness etc[...]
}

Результат (3D)



Результат (2D)



Как видите, объекты растворяются «локально», у нас не получился однородный эффект, потому что мы получаем «значение растворения» из текстуры, сэмплированной с помощью UV каждого объекта. (В 2D это менее заметно).



Трёхмерный LocalUV Dissolve Shader на HLSL


Shader "Custom/GlobalDissolveSurface" {
 Properties {
 _Color ("Color", Color) = (1,1,1,1)
 _MainTex ("Albedo (RGB)", 2D) = "white" {}
 _Glossiness("Smoothness", Range(0,1)) = 0.5
 _Metallic("Metallic", Range(0,1)) = 0.0
 
 _DissolveTexture("Dissolve texture", 2D) = "white" {}
 _Radius("Distance", Float) = 1 //distance where we start to reveal the objects
 }
 SubShader{
 Tags { "RenderType" = "Opaque" }
 LOD 200
 Cull off //material is two sided
 
 CGPROGRAM
 
 #pragma surface surf Standard fullforwardshadows
 
 #pragma target 3.0
 
 sampler2D _MainTex;
 sampler2D _DissolveTexture; //texture where we get the dissolve value
 
 struct Input
 {
 float2 uv_MainTex;
 float3 worldPos; //Built-in world position
 };
 
 half _Glossiness;
 half _Metallic;
 fixed4 _Color;
 
 float3 _PlayerPos; //"Global Shader Variable", contains the Player Position
 float _Radius; 
 
 void surf (Input IN, inout SurfaceOutputStandard o) {
 
 half dissolve_value = tex2D(_DissolveTexture, IN.uv_MainTex).x;
 
 float dist = distance(_PlayerPos, IN.worldPos);
 
 clip(dissolve_value - dist/ _Radius);
 
 fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
 o.Albedo = c.rgb;
 o.Metallic = _Metallic;
 o.Smoothness = _Glossiness;
 o.Alpha = c.a;
 }
 
 ENDCG
 }
 FallBack "Diffuse"
}

Sprites Diffuse – LocalUV Dissolve Shader на HLSL


Shader "Custom/GlobalDissolveSprites"
{
 Properties
 {
 [PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {}
 _Color("Tint", Color) = (1,1,1,1)
 [MaterialToggle] PixelSnap("Pixel snap", Float) = 0
 [HideInInspector] _RendererColor("RendererColor", Color) = (1,1,1,1)
 [HideInInspector] _Flip("Flip", Vector) = (1,1,1,1)
 [PerRendererData] _AlphaTex("External Alpha", 2D) = "white" {}
 [PerRendererData] _EnableExternalAlpha("Enable External Alpha", Float) = 0
 
 
 _DissolveTexture("Dissolve texture", 2D) = "white" {}
 _Radius("Distance", Float) = 1 //distance where we start to reveal the objects
 }
 
 SubShader
 {
 Tags
 {
 "Queue" = "Transparent"
 "IgnoreProjector" = "True"
 "RenderType" = "Transparent"
 "PreviewType" = "Plane"
 "CanUseSpriteAtlas" = "True"
 }
 
 Cull Off
 Lighting Off
 ZWrite Off
 Blend One OneMinusSrcAlpha
 
 CGPROGRAM
 #pragma surface surf Lambert vertex:vert nofog nolightmap nodynlightmap keepalpha noinstancing
 #pragma multi_compile _ PIXELSNAP_ON
 #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
 #include "UnitySprites.cginc"
 
 struct Input
 {
 float2 uv_MainTex;
 fixed4 color;
 float3 worldPos; //Built-in world position
 };
 
 sampler2D _DissolveTexture; //texture where we get the dissolve value
 float3 _PlayerPos; //"Global Shader Variable", contains the Player Position
 float _Radius;
 
 void vert(inout appdata_full v, out Input o)
 {
 v.vertex = UnityFlipSprite(v.vertex, _Flip);
 
 #if defined(PIXELSNAP_ON)
 v.vertex = UnityPixelSnap(v.vertex);
 #endif
 
 UNITY_INITIALIZE_OUTPUT(Input, o);
 o.color = v.color * _Color * _RendererColor;
 }
 
 void surf(Input IN, inout SurfaceOutput o)
 {
 half dissolve_value = tex2D(_DissolveTexture, IN.uv_MainTex).x;
 float dist = distance(_PlayerPos, IN.worldPos);
 clip(dissolve_value - dist / _Radius);
 
 fixed4 c = SampleSpriteTexture(IN.uv_MainTex) * IN.color;
 o.Albedo = c.rgb * c.a;
 o.Alpha = c.a;
 }
 
 ENDCG
 }
 
 Fallback "Transparent/VertexLit"
}

P.S. Для создания последнего шейдера я скопировал стандартный шейдер Unity Sprites-Diffuse и добавил часть с «растворением», описанный ранее в этой части статьи. Все стандартные шейдеры можно найти здесь.



Делаем эффект однородным


Чтобы сделать эффект однородным, мы можем использовать в качестве UV-координат текстуры растворения глобальные координаты (позицию в мире). Также важно задать в параметрах текстуры растворения Wrap = Repeat, чтобы мы могли повторять текстуру, не замечая этого (убедитесь, что текстура бесшовна и хорошо повторяется!)


HLSL (поверхность)


half dissolve_value = tex2D(_DissolveTexture, IN.worldPos / 4).x; //I modified the worldPos to reduce the texture size

Shader Graph



Результат (2D)



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

Этот шейдер уже идеально подходит для 2D-игр, но для 3D-объектов его нужно усовершенствовать.

Проблема с 3D-объектами



Как видите, шейдер не работает для «невертикальных» граней, и сильно искажает текстуру. Так получается потому. что UV-координатам нужно значение float2, а если мы передаём worldPos, то она получает только X и Y.

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

Решение сложно будет понять новичкам: необходимо избавиться от текстуры, сгенерировать в мире трёхмерный шум и получать «значение растворения» из него. В этом посте я не буду объяснять генерацию 3D-шума, но можно найти кучу готовых к использованию функций!

Вот пример шейдера шума: https://github.com/keijiro/NoiseShader. Также научиться генерировать шум можно здесь: https://thebookofshaders.com/11/ и здесь: https://catlikecoding.com/unity/tutorials/noise/

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

void surf (Input IN, inout SurfaceOutputStandard o) {
 
 float dist = distance(_PlayerPos, IN.worldPos);
 
 //"abs" because you have to make sure that the noise is between the range [0,1]
 //you can remove "abs" if your noise function returns a value between [0,1]
        //also, replace "NOISE_FUNCTION_HERE" with your 3D noise function.
 half dissolve_value = abs(NOISE_FUNCTION_HERE(IN.worldPos));
 
 if (dist > _Radius) {
 float clip_value = dissolve_value - ((dist - _Radius) / _Radius);
 clip(clip_value);
 
 if (clip_value < 0.05f)
 o.Emission = float3(1, 1, 1);
 }
 
 
 fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
 o.Albedo = c.rgb;
 o.Metallic = _Metallic;
 o.Smoothness = _Glossiness;
 o.Alpha = c.a;
}

Краткое напоминание о HLSL: прежде чем использовать/вызывать функцию, её необходимо написать/объявить.

P.S. Если вы хотите создать шейдер с помощью Unity Shader Graph, то нужно использовать Custom Nodes (и генерировать шум, написав в них код на HLSL). О Custom Nodes я расскажу в будущем туториале.

Результат (3D)





Добавление контуров


Чтобы добавить контуры, нужно повторить процесс из предыдущей части туториала.




Инвертированный эффект


А если мы захотим обратить этот эффект? (Объекты должны исчезать, если рядом находится игрок)

Нам достаточно изменить одну строку:

float dist = _Radius - distance(_PlayerPos, IN.worldPos);

(Тот же процесс относится к Shader Graph).

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (3):

  1. LaG1924
    /#19274193

    Статья интересная (переводчик, как обычно, молодец). Но если в мире сложная геометрия и пиксели перекрашиваются по несколько раз, то «рисование в лоб» губит производительность. Можно ли включить что-то вроде отложенного освещения (т.е. возможность писать исходные данные, вроде мировых координат пикселя, в G-Buffer)? Или оно сразу включено, т.к. юнити вроде умеет в deffered shading и должен понимать, что в шейдерах из статьи не используется ничего особенного, чего нельзя «отложить на потом»?

  2. Hzpriezz
    /#19274251

    Черт возьми, это самый полезный и доступный материал по шейдерам который можно найти в интернете, на английском есть объяснения но обычно они ограничиваются шейдер графом. Спасибо, в рамочку и на стену.

  3. MrMureno
    /#19274255

    не особо информативный комментарий, но отмечу что отличный туториал и жду уже про Custom Nodes, таким же простым и понятным языком описанное с наглядными примерами)