Статья рассчитана на новичков, но я буду рад если и опытные шейдерописатели прочтут и покритикуют статью.
Заинтересовавшихся прошу под кат. (Осторожно! Внутри тяжелые картинки и гифки).
Статья написана как набор инструкций с пояснениями, даже полный новичок сможет выполнить их и получить готовый шейдер, но для понимания того что происходит желательно ориентироваться в базовых терминах:
- Шейдер
- Вершинный шейдер
- Фрагментный/пиксельный шейдер
- 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
}
}
}
Подготовим его для наших целей
- Переименуем его в Shields/Transparent для этого заменим строку
Shader "Unlit/NewUnlitShader"
наShader "Shields/Transparent"
- Полупрозрачные элементы в юнити отрисовываются в отдельной очереди и особым способом, для этого необходимо сообщить юнити что шейдер полупрозрачный заменив
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" }
- Эффект щита не будет использоваться вместе со встроенным в юнити эффектом тумана, поэтому удалим все упоминания о нем из шейдера — удаляем строки
UNITY_FOG_COORDS(1)
UNITY_TRANSFER_FOG(o,o.vertex)
UNITY_APPLY_FOG(i.fogCoord, col)
Мы получили базовый неосвещенный полупрозрачный шейдер. Теперь из него нужно сделать шейдер, который использует текстуру как маску полупрозрачности и цвет заданный пользователем как цвет пикселя:
- Сейчас шейдер имеет лишь один входной параметр — текстуру, добавим цвет как входной параметр, а параметр текстуры переименую в 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)
Подробнее про передачу параметров в шейдер можно почитать в официальной документации. - Цвет отдельного пикселя определяется возвращаемым значением фрагментного шейдера,
сейчас он выглядит так:
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
с альфа каналом, взятым из красного цвета текстуры.
- Добавим ещё один параметр — множитель интенсивности щита — для того чтобы можно было регулировать полупрозрачность щита, не меняя текстуру.
Предлагаю читателю это сделать самому или заглянуть под спойлер.
Скрытый текст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 — нормаль поверхности в точке падения
- Первым делом скопируем шейдер в новый файл и переименуем его в Shields/Fresnel, чтобы иметь историю изменений
- Как видно из формулы, нам понадобятся 3 новых параметра для шейдера
Bias,
. Я рассчитываю, что читатель уже научился добавлять параметры в шейдер и не буду приводить детальные инструкции как это сделать. При затруднениях всегда можно посмотреть в полный код в конце раздела
Scale, Power - Рассчитаем I и N в вершинном шейдере. Вершинный шейдер в нашем шейдере это функция
v2f vert (appdata v)
возвращаемое значение — это описанная ранее структураv2f
, аappdata
это параметры вершины взятые из меша.
Что такое appdatastruct 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));
- Теперь можно посчитать непрозрачность щита по формуле эффекта Френеля:
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));
- Осталось только передать это значение в пиксельный шейдер. Для этого добавляем в структуру 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
можно ещё в вершинном шейдере, так и сделаем.
Готово! Поиграв параметрами уравнения Френеля, можно получить такую картинку
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
}
}
}
Теперь можно переходить к самому интересному — отображению попаданий по щиту.
Отрисовка попадания
Я опишу лишь один из возможных вариантов реакции на попадание, он достаточно прост и дешев по производительности при этом выглядит достаточно симпатично и, в отличие от совсем простейших, даёт красивую картинку при близко ложащихся попаданиях.
- Для реализации эффекта шейдеру нужно как-то узнать в какой точке произошло попадание и в какое время. Передачей этих аргументов будет заниматься скрипт на 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); } } } } } }
- Как и в прошлый раз сохраним шейдер под новым именем Shields/FresnelWithHits
- Идея заключается в том, чтобы в каждой точке щита рассчитать возмущение щита от попаданий рядом, при этом, чем раньше попадание произошло, тем меньше его влияние на возмущение щита.
Я выбрал следующую формулу:
где:
distance
— доля расстояния до точки попадания от максимального, [0, 1]
time
— доля времени жизни от максимального, [0, 1]
Таким образом, интенсивность обратно пропорциональна расстоянию до точки столкновения,
пропорциональна времени оставшемуся до конца действия попадания, а также равна 0 при дистанции равной или больше максимальной и при оставшемся времени равному 0.
Я бы хотел найти функцию, которая бы удовлетворяла этим условиям без необходимости ограничивать область значений времени и расстояния, но эта — всё что у меня есть.
- Отрисовка эффектов попадания в шейдерах неизбежно накладывает ограничения на количество одновременно обрабатываемых попаданий, для примера я выбрал 3 одновременно отображающихся попадания. Добавим в шейдер входные параметры WorldHitPoint0, WorldHitPoint1, WorldHitPoint2, HitTime0, HitTime1, HitTime2 — по паре для каждого одновременно обрабатываемого попадания. Также нам понадобятся параметры MaxDistance — на какое максимальное расстояние распространяется возмущение щита от попадания, и HitDuration — длительность возмущения щита от попадания.
- Для каждого попадания рассчитаем в вершинном шейдере 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;
- Настраиваем материал, выставляем правильные значения расстояния и вуаля:
Уже достаточно хорошо, так? Но есть одна проблема. Попадания на обратной стороне щита не видны. Причина этого в том, что, по умолчанию, полигоны, нормаль которых направлена от камеры, не рисуются. Чтобы заставить графический движок их рисовать нужно добавить послеZWrite Off
строкуCull off
. Но и тут нас поджидает проблема:
эффект Френеля, реализованный в прошлом разделе, подсвечивает все полигоны, смотрящие от камеры — придется менять формулу на
float dt = dot(I, normWorld); fresnel = saturate(_Bias + _Scale * pow(1.0 - dt * dt, _Power));
Так как исходная формула — уже аппроксимация, использование квадрата не оказывает значимого эффекта на результат (его можно подправить другими параметрами) и позволяет не добавлять дорогой оператор ветвления и не использовать дорогой sqrt.
Запускаем, проверяем и:
Теперь всё очень неплохо.
- Остался последний штрих: для придания эффекту «живости» можно прибавить к текстурным координатам шума текущее время для создания эффекта движения щита по сфере.
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)
Tutanhomon
28.03.2018 18:59+1Добавлю только
t0, t1, t2 можно считать на CPU раз за кадр для каждого щита в скрипте. Тем самым можно убрать 3 saturate и кучу вычислений.
Спорный момент. Переменные вычисляются в вершинном шейдере (а значит не так чтобы часто), и на GPU, который зачастую ждет CPU, так что не факт что перенос вычислений в скрипт даст прирост. Вычисления-то относительно несложные.Goldseeker Автор
28.03.2018 19:17Согласен, такие оптимизации нужно производить только после профилирования, есть GPU, которые не очень хорошо переживают saturate и есть сценарии, когда CPU и так не сильно занят — поставить несколько uniform'ов в кадр может. А бывает и наоборот. С шейдерами, к сожалению, очень редко можно дать однозначный совет по оптимизации.
Leopotam
29.03.2018 10:33Единственное что могу добавить — _HitDuration и _MaxDistance лучше слать в виде 1/_HitDuration и 1/_MaxDistance, посчитав один раз на CPU — тогда можно будет умножать вместо деления, что быстрее на всех GPU.
kirillrcom
28.03.2018 19:18Интересный эффект. Было бы здорово повторить такой при помощи нового ShaderGraph от Unity.
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 } } }
Goldseeker Автор
29.03.2018 12:06Это та же самая формула, которую использую я, только в локальных координатах объекта, значения в мировых координатах мне всё равно нужны в расчетах попадания, поэтому лишних вычислений не производится, за исключением вычисления нормали поверхности в мировых координатах.
Я так же заменил abs на возведение скалярного произведения в квадрат.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
В любом случае статья хорошая, Спасибо!
Кстати, примени вы блендинг один к одному, получите боле сочный энергетичный еффект)))Goldseeker Автор
29.03.2018 14:39Действительно, в изначальном варианте я убрал pow, так как всё равно всегда использовал _Power=1.0f, в варианте для статьи я решил не отходить от формулы из cg tutorial, а в возможные оптимизации вписать забыл.
FadeToBlack
29.03.2018 14:34А где вы берете информацию о том, какая функция быстрее/медленнее? Стоит ли вообще обращать внимание на такие вещи в шейдере? Бранчинг в шейдере стоит дорого, но насколько я знаю, для abs есть отдельные инструкции, так что можно расслабиться. А то мне кажется, так с ума можно сойти, оптимизируя то, что совсем не тормозит.
Goldseeker Автор
29.03.2018 14:43Либо профилирую сам, либо гуглю результаты других. Полагаться на то, что какая-то функция реализовано железно не всегда правильно, особенно, если рассматривать мобильные устройства. В общем случае если я могу обойтись без функций, которые наивно реализуются через бранчинг, я стараюсь без них обходится.
NeitronC
29.03.2018 14:49В функциях есть смысл из-за разработки под разные платформы, + не все шарят математику достаточно хорошо, что бы писать более оптимальные решения. К сожалению не все понимают, что тот же abs, например, реализован на бинарных операциях, а не с помощью if )))
NeitronC
29.03.2018 14:42Здесь v2f — возвращаемое значение вершинных шейдеров интерполированное для данного пикселя на экране
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
uv — текстурная координата пикселя
vertext — координата пикселя в экранных координатах
«Пиксель пиксельный». Такие формулировки приводят в заблуждение начинающих шейдер-мейкеров, они потом разобраться не могут, что же все таки пиксель, и кто с ним работает. uv это координаты текселя, по которым берется семпл. А то что у вас названо vertex'ом (вершина) — имя не подходящее, так как это не вершина, а координаты «фрагмента» в пространстве клипера. Фрагмент — не пиксель, а претиндент на место пикселя (собственно поетому шейдер и «фрагментный»), из которого ему дорога в NDC, и воевать за место на экране в операциях блендинга с его соседями.Goldseeker Автор
29.03.2018 14:52+1Названо не у меня, а у Unity, переименовывать я не стал.
uv это координаты текселя
Для данного пикселя/фрагмента, вычисляемая из uv-координат присвоенных вершинам, по которым может взяться, а может и не взяться, семпл из текстуры.
Уж, простите, но различие между фрагментом и пикселем я в этом контексте считаю буквоедством.
NeitronC
29.03.2018 15:16Не, не, не. Не хотел придираться или обидеть, не поймите не правильно, просто вы говорите в том месте о разных понятиях, а обозвали по одинаковому, еще и тем, чем они не являются. Сам, когда изучал когда-то эти дебри, голову сломал на терминалогии. Сейчас преподаю детям в школе, и у них от этого тоже крыша едет. Так ребенок послушает, и покивает, даже если не поймет, а взролый, подростки, докапываються «как так-то, и то пиксель, и это пиксель?!», оно и понятно, если в документации даже не придерживаются этого, хотя опять же, кто назвал документацию эталоном. Прекрасно понимаю, что это не столь фажно в кругу специалистов, где все шарят, но статья ведь образовательная, для тех кто не силен в этих вопросах. Еще рас прошу прощение, за занудство. Даже наоборот, вы вдохновили меня все таки попробовать написать статью самому, а не копать под чужими)))
asaq
29.03.2018 12:07Добавьте ссылку на Cg Tutorial (7.4) с формулой.
I — направление на камеру в мировых координатах — можно посчитать, как разницу мировых координат камеры и мировых координат вершины.
Такая формулировка противоречит формуле, ведь I это вектор от камеры к вершине.
float3 I = normalize(worldVertex - _WorldSpaceCameraPos.xyz);
И вопрос. В следующем контексте normalize() выполняется над float4 или float3?
float4 a; float3 b; float3 c = normalize(a - b);
Goldseeker Автор
29.03.2018 12:11По первому пункту — спасибо, исправил, вектор считался верно, но словесное описание неверное.
По второму вопросу, я не знаю, но я думаю что для float4, а потом приведется к float3
DanielJ
29.03.2018 12:11Вы молодец, но сам спецэффект выглядит не очень. Возможно в другом масштабе хорошо смотрится. Игроки нынче избалованны эффектами в ааа играх 8)
Arugin
29.03.2018 12:53unity3d.com/ru/public-relations/brand
> Ссылаясь на нашу компанию, используйте “Unity Technologies.” Ссылаясь на движок Unity, пишите “Unity®” или “Unity®Pro” (не Unity3d)FadeToBlack
29.03.2018 14:39Человек хорошую статью написал. Чтобы он правильно все делал, надо ему платить денег. Заплатите ему, тогда и требуйте соблюдения таких правил.
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);
Goldseeker Автор
29.03.2018 14:30Результат деления на ноль при операциях с плавующей точкой зависит от конкретного целевого API(directx, opengl, еtc.). В большинстве случаев поведение не определено, но есть гарантия отсутствия креша. Учитывая, что получить дистанцию ровно 0.0f не очень просто и эффект этого будет мало заметен я не стал заморачиваться.
Tutanhomon
Спасибо! Очень редко можно встретить статьи подобного качества. Обычно накопипастят из разных мест, соберут в кучу, порадуются что работает и бегом на хабр. Здесь же все по делу и в мелочах.