Привет Хабр! Я хочу рассказать как сделать шейдер для отрисовки щита космического корабля в Unity3D.

Вот такой


Статья рассчитана на новичков, но я буду рад если и опытные шейдерописатели прочтут и покритикуют статью.

Заинтересовавшихся прошу под кат. (Осторожно! Внутри тяжелые картинки и гифки).

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

  • Шейдер
  • Вершинный шейдер
  • Фрагментный/пиксельный шейдер
  • UV-координаты

Эффект состоит из 3-х основных компонентов:

  • Базовый полупрозрачный шейдер, использующий текстуру как карту прозрачности и цвет как цвет щита
  • Эффект Френеля
  • Реакция на попадание по щиту
  • Анимация

Будем по порядку добавлять эти компоненты в шейдер и к концу статьи получим эффект как на КДПВ.

Базовый шейдер


Начнем со стандартного шейдера Unity3D:

Исходный код стандартного неосвещенного шейдера
Shader "Unlit/NewUnlitShader"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
	}
	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 100

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			// make fog work
			#pragma multi_compile_fog
			
			#include "UnityCG.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				UNITY_FOG_COORDS(1)
				float4 vertex : SV_POSITION;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				UNITY_TRANSFER_FOG(o,o.vertex);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				fixed4 col = tex2D(_MainTex, i.uv);
				// apply fog
				UNITY_APPLY_FOG(i.fogCoord, col);
				return col;
			}
			ENDCG
		}
	}
}


Подготовим его для наших целей

  1. Переименуем его в Shields/Transparent для этого заменим строку Shader "Unlit/NewUnlitShader" на Shader "Shields/Transparent"
  2. Полупрозрачные элементы в юнити отрисовываются в отдельной очереди и особым способом, для этого необходимо сообщить юнити что шейдер полупрозрачный заменив Tags { "RenderType"="Opaque" } на Tags { "Queue"="Transparent" "RenderType"="Transparent" }
    Для отрисовки полупрозрачных элементов требуется задать особый режим смешивания, для этого после Tags { "Queue"="Transparent" "RenderType"="Transparent" } добавим строку Blend SrcAlpha OneMinusSrcAlpha.

    Так же необходимо отключить запись в Z-Buffer — он используется для сортировки непрозрачных объектов, а для отрисовки полупрозрачных объектов будет только мешать. Для этого добавим строку

    ZWrite Off

    после

    Tags { "Queue"="Transparent" "RenderType"="Transparent" }

  3. Эффект щита не будет использоваться вместе со встроенным в юнити эффектом тумана, поэтому удалим все упоминания о нем из шейдера — удаляем строки

    UNITY_FOG_COORDS(1)

    UNITY_TRANSFER_FOG(o,o.vertex)

    UNITY_APPLY_FOG(i.fogCoord, col)

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

  1. Сейчас шейдер имеет лишь один входной параметр — текстуру, добавим цвет как входной параметр, а параметр текстуры переименую в Transparency Mask. В юнити входные параметры для шейдера задаются внутри блока Properties, сейчас он выглядит так:

    Properties
    {
    	_MainTex ("Texture", 2D) = "white" {}
    }
    

    Добавим входной параметр цвет и переименуем текстуру:

    Properties
    {
    	_ShieldColor("Shield Color", Color) = (1, 0, 0, 1)
    	_MainTex ("Transparency Mask", 2D) = "white" {}
    }
    

    Чтобы входные параметры, заданные в блоке Properties, стали доступны в вершинном и фрагментном шейдерах, их нужно объявить как переменные внутри прохода шейдера — вставим строку

     float4 _ShieldColor; 

    перед строкой

    v2f vert (appdata v)

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

    fixed4 frag (v2f i) : SV_Target
    {
    	// sample the texture
    	fixed4 col = tex2D(_MainTex, i.uv);
    	return col;
    }

    Что такое v2f
    Здесь v2f — возвращаемое значение вершинных шейдеров интерполированное для данного пикселя на экране

    struct v2f
    {
    	float2 uv : TEXCOORD0;
    	float4 vertex : SV_POSITION;
    };
    

    uv — текстурная координата пикселя
    vertext — координата пикселя в экранных координатах

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

    Сделаем следующее:

    fixed4 frag (v2f i) : SV_Target
    {
    	// sample the texture
    	fixed4 transparencyMask = tex2D(_MainTex, i.uv);
    	return fixed4(_ShieldColor.r, _ShieldColor.g, _ShieldColor.b, transparencyMask.r);
    }
    

    То есть семплируем текстуру как раньше, но вместо возврата её цвета напрямую, возвращаем цвет как _ShieldColor с альфа каналом, взятым из красного цвета текстуры.
  3. Добавим ещё один параметр — множитель интенсивности щита — для того чтобы можно было регулировать полупрозрачность щита, не меняя текстуру.

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

    Скрытый текст
    Properties
    {
     	_ShieldIntensity("Shield Intensity", Range(0,1)) = 1.0
    	_ShieldColor("Shield Color", Color) = (1, 0, 0, 1)
    	_MainTex ("Transparency Mask", 2D) = "white" {}
    }
    

    float _ShieldIntensity;
    fixed4 frag (v2f i) : SV_Target
    {
    	// sample the texture
    	fixed4 transparencyMask = tex2D(_MainTex, i.uv);
    	return fixed4(_ShieldColor.r, _ShieldColor.g, _ShieldColor.b, _ShieldIntensity * transparencyMask.r);
    }
    



Должно получиться примерно следующее:



Здесь и далее я использую вот эту бесшовную текстуру шума


Полный листинг получившегося шейдера
Shader "Shields/Transparent"
{
	Properties
	{
		_ShieldIntensity("Shield Intensity", Range(0,1)) = 1.0
		_ShieldColor("Shield Color", Color) = (1, 0, 0, 1)
		_MainTex ("Transparency Mask", 2D) = "white" {}
	}
	SubShader
	{
		Tags { "Queue"="Transparent" "RenderType"="Transparent" }
		ZWrite Off
		Blend SrcAlpha OneMinusSrcAlpha

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
                                float3 normal: NORMAL;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				
				float4 vertex : SV_POSITION;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;

			float4 _ShieldColor;
			
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				return o;
			}
			
			float _ShieldIntensity;
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				fixed4 transparencyMask = tex2D(_MainTex, i.uv);
				return fixed4(_ShieldColor.r, _ShieldColor.g, _ShieldColor.b, _ShieldIntensity * transparencyMask.r);
			}
			ENDCG
		}
	}
}


Пока выглядит не очень, но это база, на которой будет строиться весь эффект.

Эффект Френеля


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

Приступим к реализации, используя приближенную формулу из cg tutorial on nvidia

где I — направление от камеры до вершины, N — нормаль поверхности в точке падения

  1. Первым делом скопируем шейдер в новый файл и переименуем его в Shields/Fresnel, чтобы иметь историю изменений
  2. Как видно из формулы, нам понадобятся 3 новых параметра для шейдера Bias,
    Scale, Power
    . Я рассчитываю, что читатель уже научился добавлять параметры в шейдер и не буду приводить детальные инструкции как это сделать. При затруднениях всегда можно посмотреть в полный код в конце раздела
  3. Рассчитаем I и N в вершинном шейдере. Вершинный шейдер в нашем шейдере это функция v2f vert (appdata v) возвращаемое значение — это описанная ранее структура v2f, а appdata это параметры вершины взятые из меша.

    Что такое appdata
    struct appdata
    {
    	float4 vertex : POSITION;
    	float3 normal: NORMAL;
    	float2 uv : TEXCOORD0;
    };
    

    vertex — координаты вершины в локальных координатах
    normal — нормаль к поверхности заданная для этой вершины
    uv — текстурные координаты вершины

    I — направление от камеры до вершины в мировых координатах — можно посчитать, как разницу мировых координат вершины и мировых координат камеры. В шейдерах Unity матрица перехода из локальных координат в мировые доступна в переменной unity_ObjectToWorld, а мировые координаты камеры в переменной _WorldSpaceCameraPos. Зная это, можно вычислить I следующими строками в коде вершинного шейдера:

    float4 worldVertex = mul(unity_ObjectToWorld, v.vertex);
    float3 I = normalize(worldVertex - _WorldSpaceCameraPos.xyz);
    

    N — нормаль поверхности в мировых координатах — вычислить ещё проще:

     float3 normWorld = normalize(mul(unity_ObjectToWorld, v.normal));

  4. Теперь можно посчитать непрозрачность щита по формуле эффекта Френеля:

    float fresnel = _Bias + _Scale * pow(1.0 + dot(I, normWorld), _Power);

    Можно заметить, что значение fresnel при определенных значениях переменных может быть меньше 0, это даст цветовые артефакты при отрисовке. Ограничим значение переменной интервалом [0;1] с помощью функции saturate:

    float fresnel = saturate(_Bias + _Scale * pow(1.0 + dot(I, normWorld), _Power));

  5. Осталось только передать это значение в пиксельный шейдер. Для этого добавляем в структуру v2f поле intensity:

    struct v2f
    {
    	float2 uv : TEXCOORD0;
    	float intensity : COLOR0;
    	float4 vertex : SV_POSITION;
    };
    

    ( COLOR0 — это семантика, объяснение что это такое выходит за рамки этой статьи, заинтересовавшиеся могут почитать про semantics в hlsl).

    Теперь мы можем заполнить это поле в вершинном шейдере и использовать во фрагментном:

    v2f vert (appdata v)
    {
    	v2f o;
    	o.vertex = UnityObjectToClipPos(v.vertex);
    	o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    	float4 worldVertex = mul(unity_ObjectToWorld, v.vertex);
    	float3 normWorld = normalize(mul(unity_ObjectToWorld, v.normal));
    	float3 I = normalize(worldVertex - _WorldSpaceCameraPos.xyz);
    	float fresnel = saturate(_Bias + _Scale * pow(1.0 + dot(I, normWorld), _Power));
    	o.intensity = fresnel;
    	return o;
    }
    			
    float _ShieldIntensity;
    fixed4 frag (v2f i) : SV_Target
    {
    	// sample the texture
    	fixed4 transparencyMask = tex2D(_MainTex, i.uv);
    	return fixed4(_ShieldColor.r, _ShieldColor.g, _ShieldColor.b, (_ShieldIntensity + i.intensity) * transparencyMask.r);
    }
    

    Можно заметить, что теперь сложить _ShieldIntensity и i.intensity можно ещё в вершинном шейдере, так и сделаем.

Готово! Поиграв параметрами уравнения Френеля, можно получить такую картинку



Мои параметры
Bias=-0.5, Scale=1, Power=1

Полный листинг щита с эффектом Френеля
Shader "Shields/Fresnel"
{
	Properties
	{
		_ShieldIntensity("Shield Intensity", Range(0,1)) = 1.0
		_ShieldColor("Shield Color", Color) = (1, 0, 0, 1)
		_MainTex ("Transparency Mask", 2D) = "white" {}

		_Bias("Bias", float) = 1.0
		_Scale("Scale", float) = 1.0
		_Power("Power", float) = 1.0
	}
	SubShader
	{
		Tags { "Queue"="Transparent" "RenderType"="Transparent" }
		ZWrite Off
		Blend SrcAlpha OneMinusSrcAlpha

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
				float3 normal: NORMAL;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float intensity : COLOR0;
				float4 vertex : SV_POSITION;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;

			float4 _ShieldColor;
			float _ShieldIntensity;
			
			float _Bias;
			float _Scale;
			float _Power;

			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				float4 worldVertex = mul(unity_ObjectToWorld, v.vertex);

				float3 normWorld = normalize(mul(unity_ObjectToWorld, v.normal));
				float3 I = normalize(worldVertex - _WorldSpaceCameraPos.xyz);
				float fresnel = saturate(_Bias + _Scale * pow(1.0 + dot(I, normWorld), _Power));
				o.intensity = fresnel + _ShieldIntensity;
				return o;
			}
			
			
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				fixed4 transparencyMask = tex2D(_MainTex, i.uv);
				return fixed4(_ShieldColor.r, _ShieldColor.g, _ShieldColor.b, i.intensity * transparencyMask.r);
			}
			ENDCG
		}
	}
}



Теперь можно переходить к самому интересному — отображению попаданий по щиту.

Отрисовка попадания


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

  1. Для реализации эффекта шейдеру нужно как-то узнать в какой точке произошло попадание и в какое время. Передачей этих аргументов будет заниматься скрипт на GameObject'е щита, а так как с# скриптинг не является предметом этой статьи, я просто приведу исходные коды скриптов:

    Листинг скрипта для объекта с щитом
    public class ShieldHitter : MonoBehaviour
    {
    	private static int[] hitInfoId = new[] { Shader.PropertyToID("_WorldHitPoint0"), Shader.PropertyToID("_WorldHitPoint1"), Shader.PropertyToID("_WorldHitPoint2") };
    	private static int[] hitTimeId = new[] { Shader.PropertyToID("_HitTime0"), Shader.PropertyToID("_HitTime1"), Shader.PropertyToID("_HitTime2") };
    
    	private Material material;
    	void Start()
    	{
    		if (material == null)
    		{
    			material = this.gameObject.GetComponent<MeshRenderer>().material;
    		}
    	}
    
    	int lastHit = 0;
    	public void OnHit(Vector3 point, Vector3 direction)
    	{
    		material.SetVector(hitInfoId[lastHit], point);
    		material.SetFloat(hitTimeId[lastHit], Time.timeSinceLevelLoad);
    		lastHit++;
    		if (lastHit >= hitInfoId.Length)
    			lastHit = 0;
    	}
    
    
    
    	void OnCollisionEnter(Collision collision)
    	{
    		OnHit(collision.contacts[0].point, Vector3.one);
    	}
    }
    


    Листинг скрипта для камеры
    using UnityEngine;
    
    [ExecuteInEditMode]
    public class CameraControls : MonoBehaviour
    {
    
    	private const int minDistance = 25;
    	private const int maxDistance = 25;
    	private const float minTheta = 0.01f;
    	private const float maxTheta = Mathf.PI - 0.01f;
    	private const float minPhi = 0;
    	private const float maxPhi = 2 * Mathf.PI ;
    	[SerializeField]
    	private Transform _target;
    
    	[SerializeField]
    	private Camera _camera;
    
    
    	[SerializeField]
    	[Range(minDistance, maxDistance)]
    	private float _distance = 25;
    
    
    	[SerializeField]
    	[Range(minTheta, maxTheta)]
    	private float _theta = 1;
    
    
    	[SerializeField]
    	[Range(minPhi, maxPhi)]
    	private float _phi = 2.5f;
    
    
    	[SerializeField] private float _angleSpeed = 2.0f;
    	[SerializeField] private float _distanceSpeed = 2.0f;
    
    	// Update is called once per frame
    	void Update ()
    	{
    		if (_target == null || _camera == null)
    		{
    			return;
    		}
    
    		if (Application.isPlaying)
    		{
    			if (Input.GetKey(KeyCode.Q))
    			{
    				_distance += _distanceSpeed * Time.deltaTime;
    			}
    			if (Input.GetKey(KeyCode.E))
    			{
    				_distance -= _distanceSpeed * Time.deltaTime;
    			}
    			Mathf.Clamp(_distance, minDistance, maxDistance);
    
    			if (Input.GetKey(KeyCode.A))
    			{
    				_phi += _angleSpeed * Time.deltaTime;
    			}
    			if (Input.GetKey(KeyCode.D))
    			{
    				_phi -= _angleSpeed * Time.deltaTime;
    			}
    			_phi = _phi % (maxPhi);
    
    			if (Input.GetKey(KeyCode.S))
    			{
    				_theta += _angleSpeed * Time.deltaTime;
    			}
    			if (Input.GetKey(KeyCode.W))
    			{
    				_theta -= _angleSpeed * Time.deltaTime;
    			}
    			_theta = Mathf.Clamp(_theta, minTheta, maxTheta);
    
    
    			Vector3 newCoords = new Vector3
    			{
    				x = _distance * Mathf.Sin(_theta) * Mathf.Cos(_phi),
    				z = _distance * Mathf.Sin(_theta) * Mathf.Sin(_phi),
    				y = _distance * Mathf.Cos(_theta)
    			};
    
    			this.transform.position = newCoords + _target.position;
    			this.transform.LookAt(_target);
    
    			if (Input.GetMouseButtonDown(0))
    			{
    				Ray ray = _camera.ScreenPointToRay(Input.mousePosition);
    				RaycastHit hit;
    				var isHit = Physics.Raycast(ray, out hit);
    
    				if (isHit)
    				{
    					ShieldHitter handler = hit.collider.gameObject.GetComponent<ShieldHitter>();
    					Debug.Log(hit.point);
    					if (handler != null)
    					{
    						handler.OnHit(hit.point, ray.direction);
    					}
    				}
    			}
    		}
    	}
    }
    


  2. Как и в прошлый раз сохраним шейдер под новым именем Shields/FresnelWithHits
  3. Идея заключается в том, чтобы в каждой точке щита рассчитать возмущение щита от попаданий рядом, при этом, чем раньше попадание произошло, тем меньше его влияние на возмущение щита.

    Я выбрал следующую формулу:

    $intensity = (1 - time) * (1/distance - 1)$


    где:
    distance — доля расстояния до точки попадания от максимального, [0, 1]
    time — доля времени жизни от максимального, [0, 1]
    Таким образом, интенсивность обратно пропорциональна расстоянию до точки столкновения,
    пропорциональна времени оставшемуся до конца действия попадания, а также равна 0 при дистанции равной или больше максимальной и при оставшемся времени равному 0.

    Я бы хотел найти функцию, которая бы удовлетворяла этим условиям без необходимости ограничивать область значений времени и расстояния, но эта — всё что у меня есть.
  4. Отрисовка эффектов попадания в шейдерах неизбежно накладывает ограничения на количество одновременно обрабатываемых попаданий, для примера я выбрал 3 одновременно отображающихся попадания. Добавим в шейдер входные параметры WorldHitPoint0, WorldHitPoint1, WorldHitPoint2, HitTime0, HitTime1, HitTime2 — по паре для каждого одновременно обрабатываемого попадания. Также нам понадобятся параметры MaxDistance — на какое максимальное расстояние распространяется возмущение щита от попадания, и HitDuration — длительность возмущения щита от попадания.
  5. Для каждого попадания рассчитаем в вершинном шейдере time и distance

    float t0 = saturate((_Time.y - _HitTime0) / _HitDuration);
    float d0 = saturate(distance(worldVertex.xyz, _WorldHitPoint0.xyz) / (_MaxDistance));
    				
    float t1 = saturate((_Time.y - _HitTime1) / _HitDuration);
    float d1 = saturate(distance(worldVertex.xyz, _WorldHitPoint1.xyz) / (_MaxDistance));
    				
    
    float t2 = saturate((_Time.y - _HitTime2) / _HitDuration);
    float d2 = saturate(distance(worldVertex.xyz, _WorldHitPoint2.xyz) / (_MaxDistance));
    

    и посчитаем суммарную интенсивность попаданий по формуле:

    float hitIntensity = (1 - t0) * ((1 / (d0)) - 1) +
    			(1 - t1) * ((1 / (d1)) - 1) +
    			(1 - t2) * ((1 / (d2)) - 1);
    

    Осталось лишь сложить интенсивность щита от попаданий с интенсивностью от других эффектов:

    o.intensity = fresnel + _ShieldIntensity + hitIntensity;
    

  6. Настраиваем материал, выставляем правильные значения расстояния и вуаля:



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

    float dt = dot(I, normWorld);
    fresnel = saturate(_Bias + _Scale * pow(1.0 - dt * dt, _Power));
    

    Так как исходная формула — уже аппроксимация, использование квадрата не оказывает значимого эффекта на результат (его можно подправить другими параметрами) и позволяет не добавлять дорогой оператор ветвления и не использовать дорогой sqrt.

    Запускаем, проверяем и:



    Теперь всё очень неплохо.
  7. Остался последний штрих: для придания эффекту «живости» можно прибавить к текстурным координатам шума текущее время для создания эффекта движения щита по сфере.

    o.uv = TRANSFORM_TEX(v.uv, _MainTex) + _Time.x / 6;
    


Финальный результат:



Листинг финальной версии шейдера
Shader "Shields/FresnelWithHits"
{
	Properties
	{
		_ShieldIntensity("Shield Intensity", Range(0,1)) = 1.0
		_ShieldColor("Shield Color", Color) = (1, 0, 0, 1)
		_MainTex ("Transparency Mask", 2D) = "white" {}

		_Bias("Bias", float) = 1.0
		_Scale("Scale", float) = 1.0
		_Power("Power", float) = 1.0

		_WorldHitPoint0("Hit Point 0", Vector) = (0, 1, 0, 0)
		_WorldHitTime0("Hit Time 0", float) = -1000

		_WorldHitPoint1("Hit Point 1", Vector) = (0, 1, 0, 0)
		_WorldHitTime1("Hit Time 1", float) = -1000

		_WorldHitPoint2("Hit Point 2", Vector) = (0, 1, 0, 0)
		_WorldHitTime2("Hit Time 2", float) = -1000

		_HitDuration("Hit Duration", float) = 10.0
		_MaxDistance("MaxDistance", float) = 0.5
	}
		SubShader
		{
			Tags { "Queue" = "Transparent" "RenderType" = "Transparent" }
			ZWrite Off
			Cull Off
			Blend SrcAlpha OneMinusSrcAlpha

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
				float3 normal: NORMAL;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float intensity : COLOR0;
				float4 vertex : SV_POSITION;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;

			float4 _ShieldColor;
			float _ShieldIntensity;
			
			float _Bias;
			float _Scale;
			float _Power;

			float _MaxDistance;
			float _HitDuration;
			float _HitTime0;
			float4 _WorldHitPoint0;
			float _HitTime1;
			float4 _WorldHitPoint1;
			float _HitTime2;
			float4 _WorldHitPoint2;

			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex) + _Time.x / 6;
				float4 worldVertex = mul(unity_ObjectToWorld, v.vertex);

				float3 normWorld = normalize(mul(unity_ObjectToWorld, v.normal));
				float3 I = normalize(worldVertex - _WorldSpaceCameraPos.xyz);
				float fresnel = 0;
				float dt = dot(I, normWorld);
				fresnel = saturate(_Bias + _Scale * pow(1.0 - dt * dt, _Power));


				

				float t0 = saturate((_Time.y - _HitTime0) / _HitDuration);
				float d0 = saturate(distance(worldVertex.xyz, _WorldHitPoint0.xyz) / (_MaxDistance));
				

				float t1 = saturate((_Time.y - _HitTime1) / _HitDuration);
				float d1 = saturate(distance(worldVertex.xyz, _WorldHitPoint1.xyz) / (_MaxDistance));
				

				float t2 = saturate((_Time.y - _HitTime2) / _HitDuration);
				float d2 = saturate(distance(worldVertex.xyz, _WorldHitPoint2.xyz) / (_MaxDistance));
				

				float hitIntensity = (1 - t0) * ((1 / (d0)) - 1) +
					(1 - t1) * ((1 / (d1)) - 1) +
					(1 - t2) * ((1 / (d2)) - 1);


				o.intensity = fresnel + _ShieldIntensity + hitIntensity;
				return o;
			}
			
			
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				fixed4 transparencyMask = tex2D(_MainTex, i.uv);
				return fixed4(_ShieldColor.r, _ShieldColor.g, _ShieldColor.b, saturate(i.intensity * transparencyMask.r));
			}
			ENDCG
		}
	}
}



То что нужно.

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

Вместо послесловия: оптимизация


Обозначу основные направления возможной оптимизации:

  • Удалить неиспользуемое: эффект Френеля, базовый полупрозрачный щит — всё это не бесплатно и если некоторые из компонентов не нужны, нужно их удалить.
  • t0, t1, t2 можно считать на CPU раз за кадр для каждого щита в скрипте. Тем самым можно убрать 3 saturate и кучу вычислений.
  • Использовать числа с плавающей точкой с меньшей точностью, во многих местах можно обойтись fixed или даже half вместо float.
  • Если на экране рисуется много щитов, есть смысл рассмотреть использование инстансинга.

Комментарии (27)


  1. Tutanhomon
    28.03.2018 18:55

    Спасибо! Очень редко можно встретить статьи подобного качества. Обычно накопипастят из разных мест, соберут в кучу, порадуются что работает и бегом на хабр. Здесь же все по делу и в мелочах.


  1. Tutanhomon
    28.03.2018 18:59
    +1

    Добавлю только

    t0, t1, t2 можно считать на CPU раз за кадр для каждого щита в скрипте. Тем самым можно убрать 3 saturate и кучу вычислений.

    Спорный момент. Переменные вычисляются в вершинном шейдере (а значит не так чтобы часто), и на GPU, который зачастую ждет CPU, так что не факт что перенос вычислений в скрипт даст прирост. Вычисления-то относительно несложные.


    1. Goldseeker Автор
      28.03.2018 19:17

      Согласен, такие оптимизации нужно производить только после профилирования, есть GPU, которые не очень хорошо переживают saturate и есть сценарии, когда CPU и так не сильно занят — поставить несколько uniform'ов в кадр может. А бывает и наоборот. С шейдерами, к сожалению, очень редко можно дать однозначный совет по оптимизации.


      1. Leopotam
        29.03.2018 10:33

        Единственное что могу добавить — _HitDuration и _MaxDistance лучше слать в виде 1/_HitDuration и 1/_MaxDistance, посчитав один раз на CPU — тогда можно будет умножать вместо деления, что быстрее на всех GPU.


  1. kirillrcom
    28.03.2018 19:18

    Интересный эффект. Было бы здорово повторить такой при помощи нового ShaderGraph от Unity.


  1. NeitronC
    29.03.2018 12:02

    Лучше ипользовать стандартный Rim эффект а не Френеля, тот же визуальный эфект, но проще вычесления. Также есть встроенный метод для получени направления в камеру от вершины из пространства объекта ObjSpaceViewDir. С учетом doubleSided, я использую нечто подобное:

    Shader "CS/Shield"
    {
    	Properties
    	{
    		_MainTex ("Texture", 2D) = "white" {}
    		_Tint ("Tint color", Color) = (1,1,1,1)
    		_Width ("Width", Range(0.0, 1.0)) = 0.7
    		_HdrPower ("HDR Power", Float) = 5.0
    		_DoubleSided ("Double Sided", Float) = 0.0
    	}
    	SubShader
    	{
    		Tags 
    		{ 
    			"RenderType"="Transparent" 
    			"Queue"="Transparent+100"
    		}
    
    		LOD 100
    		Blend One One
    		Cull [_DoubleSided]
    		ZWrite Off
    
    		Pass
    		{
    			CGPROGRAM
    			#pragma vertex vert
    			#pragma fragment frag
    			
    			#include "UnityCG.cginc"
    
    			struct appdata
    			{
    				float4 vertex : POSITION;
    				float2 uv : TEXCOORD0;
    				float3 normal : NORMAL;
    			};
    
    			struct v2f
    			{
    				float2 uv : TEXCOORD0;
    				float4 vertex : SV_POSITION;
    				fixed4 rim : TEXCOORD1;
    			};
    
    			sampler2D _MainTex;
    			fixed4 _MainTex_ST;
    			fixed4 _Tint;
    			fixed _Width;
    			fixed _HdrPower;
    
    
    			v2f vert (appdata v)
    			{
    				v2f o;
    				o.vertex = UnityObjectToClipPos(v.vertex);
    				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    
    				// rim
    				fixed3 viewDir = 
                                        normalize(ObjSpaceViewDir(v.vertex));
    				fixed dotRes = 
                                        1.0 - abs(dot(viewDir, v.normal));
    				o.rim =  _Tint * _HdrPower *
                                        smoothstep(1.0 - _Width, 1.0, dotRes);
    
    				return o;
    			}
    			
    
    			fixed4 frag (v2f i) : SV_Target
    			{
    				fixed4 col = tex2D(_MainTex, i.uv);
    				return col * i.rim;
    			}
    			ENDCG
    		}
    	}
    }
    


    1. Goldseeker Автор
      29.03.2018 12:06

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

      Я так же заменил abs на возведение скалярного произведения в квадрат.


      1. NeitronC
        29.03.2018 14:00

        Согласен с вами, но для меня дело в употреблении метода возведения в степень, тяжело шейдерам со степенями.
        Квадрат или abs — визуально, конечно, особой роли нет, хоть и заметно, но математически квадрат значения, меньшего единицы — уменьшает результат, что собой являет искажение, вроде не видно — но комочек в горле есть.
        1. abs(-0.5) => 0.5
        2. -0.5 * (-0.5) => 0.25
        3. -0.1 * (-0.1) => 0.01
        В любом случае статья хорошая, Спасибо!
        Кстати, примени вы блендинг один к одному, получите боле сочный энергетичный еффект)))


        1. FadeToBlack
          29.03.2018 14:37

          один-к-одному это что? ONE, ONE Имеете ввиду сложение цветов?


          1. NeitronC
            29.03.2018 14:43

            Да, именно, additive blending


        1. Goldseeker Автор
          29.03.2018 14:39

          Действительно, в изначальном варианте я убрал pow, так как всё равно всегда использовал _Power=1.0f, в варианте для статьи я решил не отходить от формулы из cg tutorial, а в возможные оптимизации вписать забыл.


      1. FadeToBlack
        29.03.2018 14:34

        А где вы берете информацию о том, какая функция быстрее/медленнее? Стоит ли вообще обращать внимание на такие вещи в шейдере? Бранчинг в шейдере стоит дорого, но насколько я знаю, для abs есть отдельные инструкции, так что можно расслабиться. А то мне кажется, так с ума можно сойти, оптимизируя то, что совсем не тормозит.


        1. Goldseeker Автор
          29.03.2018 14:43

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


          1. NeitronC
            29.03.2018 14:49

            В функциях есть смысл из-за разработки под разные платформы, + не все шарят математику достаточно хорошо, что бы писать более оптимальные решения. К сожалению не все понимают, что тот же abs, например, реализован на бинарных операциях, а не с помощью if )))


      1. NeitronC
        29.03.2018 14:42

        Здесь v2f — возвращаемое значение вершинных шейдеров интерполированное для данного пикселя на экране

        struct v2f
        {
        float2 uv : TEXCOORD0;
        float4 vertex : SV_POSITION;
        };


        uv — текстурная координата пикселя
        vertext — координата пикселя в экранных координатах

        «Пиксель пиксельный». Такие формулировки приводят в заблуждение начинающих шейдер-мейкеров, они потом разобраться не могут, что же все таки пиксель, и кто с ним работает. uv это координаты текселя, по которым берется семпл. А то что у вас названо vertex'ом (вершина) — имя не подходящее, так как это не вершина, а координаты «фрагмента» в пространстве клипера. Фрагмент — не пиксель, а претиндент на место пикселя (собственно поетому шейдер и «фрагментный»), из которого ему дорога в NDC, и воевать за место на экране в операциях блендинга с его соседями.


        1. Goldseeker Автор
          29.03.2018 14:52
          +1

          Названо не у меня, а у Unity, переименовывать я не стал.


          uv это координаты текселя

          Для данного пикселя/фрагмента, вычисляемая из uv-координат присвоенных вершинам, по которым может взяться, а может и не взяться, семпл из текстуры.


          Уж, простите, но различие между фрагментом и пикселем я в этом контексте считаю буквоедством.


          1. NeitronC
            29.03.2018 15:16

            Не, не, не. Не хотел придираться или обидеть, не поймите не правильно, просто вы говорите в том месте о разных понятиях, а обозвали по одинаковому, еще и тем, чем они не являются. Сам, когда изучал когда-то эти дебри, голову сломал на терминалогии. Сейчас преподаю детям в школе, и у них от этого тоже крыша едет. Так ребенок послушает, и покивает, даже если не поймет, а взролый, подростки, докапываються «как так-то, и то пиксель, и это пиксель?!», оно и понятно, если в документации даже не придерживаются этого, хотя опять же, кто назвал документацию эталоном. Прекрасно понимаю, что это не столь фажно в кругу специалистов, где все шарят, но статья ведь образовательная, для тех кто не силен в этих вопросах. Еще рас прошу прощение, за занудство. Даже наоборот, вы вдохновили меня все таки попробовать написать статью самому, а не копать под чужими)))


  1. asaq
    29.03.2018 12:07

    Добавьте ссылку на Cg Tutorial (7.4) с формулой.

    I — направление на камеру в мировых координатах — можно посчитать, как разницу мировых координат камеры и мировых координат вершины.
    float3 I = normalize(worldVertex - _WorldSpaceCameraPos.xyz);
    Такая формулировка противоречит формуле, ведь I это вектор от камеры к вершине.

    И вопрос. В следующем контексте normalize() выполняется над float4 или float3?
    float4 a;
    float3 b;
    float3 c = normalize(a - b);


    1. Goldseeker Автор
      29.03.2018 12:11

      По первому пункту — спасибо, исправил, вектор считался верно, но словесное описание неверное.

      По второму вопросу, я не знаю, но я думаю что для float4, а потом приведется к float3


    1. Tutanhomon
      29.03.2018 14:03

      и вот еще, моя самая любимая ссылочка )


  1. DanielJ
    29.03.2018 12:11

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


    1. Goldseeker Автор
      29.03.2018 12:11

      На вкус и цвет все фломастеры разные )


  1. Arugin
    29.03.2018 12:53

    unity3d.com/ru/public-relations/brand

    > Ссылаясь на нашу компанию, используйте “Unity Technologies.” Ссылаясь на движок Unity, пишите “Unity®” или “Unity®Pro” (не Unity3d)


    1. FadeToBlack
      29.03.2018 14:39

      Человек хорошую статью написал. Чтобы он правильно все делал, надо ему платить денег. Заплатите ему, тогда и требуйте соблюдения таких правил.


  1. Kos-Boss
    29.03.2018 14:13
    +1

    Спасибо! Очень полезная и занимательная статья.
    Кстати, что в шейдерах с возможным делением на ноль?
    Например, здесь:

    float hitIntensity = (1 - t0) * ((1 / (d0)) - 1) +
    					(1 - t1) * ((1 / (d1)) - 1) +
    					(1 - t2) * ((1 / (d2)) - 1);


    1. Goldseeker Автор
      29.03.2018 14:30

      Результат деления на ноль при операциях с плавующей точкой зависит от конкретного целевого API(directx, opengl, еtc.). В большинстве случаев поведение не определено, но есть гарантия отсутствия креша. Учитывая, что получить дистанцию ровно 0.0f не очень просто и эффект этого будет мало заметен я не стал заморачиваться.


      1. Kos-Boss
        29.03.2018 17:27

        Понял.

        есть гарантия отсутствия креша

        Это главное.