- PVSM.RU - https://www.pvsm.ru -
Привет! Я хочу рассказать как сделать шейдер для отрисовки щита космического корабля в Unity3D.
Статья рассчитана на новичков, но я буду рад если и опытные шейдерописатели прочтут и покритикуют статью.
Заинтересовавшихся прошу под кат. (Осторожно! Внутри тяжелые картинки и гифки).
Статья написана как набор инструкций с пояснениями, даже полный новичок сможет выполнить их и получить готовый шейдер, но для понимания того что происходит желательно ориентироваться в базовых терминах:
Эффект состоит из 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
}
}
}
Подготовим его для наших целей
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)
Мы получили базовый неосвещенный полупрозрачный шейдер. Теперь из него нужно сделать шейдер, который использует текстуру как маску полупрозрачности и цвет заданный пользователем как цвет пикселя:
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)
Подробнее про передачу параметров в шейдер можно почитать в официальной документации [1].
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
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 — нормаль поверхности в точке падения
Bias,
Scale, Power
. Я рассчитываю, что читатель уже научился добавлять параметры в шейдер и не буду приводить детальные инструкции как это сделать. При затруднениях всегда можно посмотреть в полный код в конце разделаv2f vert (appdata v)
возвращаемое значение — это описанная ранее структура v2f
, а 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));
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));
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
}
}
}
Теперь можно переходить к самому интересному — отображению попаданий по щиту.
Я опишу лишь один из возможных вариантов реакции на попадание, он достаточно прост и дешев по производительности при этом выглядит достаточно симпатично и, в отличие от совсем простейших, даёт красивую картинку при близко ложащихся попаданиях.
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);
}
}
}
}
}
}
Я выбрал следующую формулу:
$$display$$intensity = (1 - time) * (1/distance - 1)$$display$$
где:
distance
— доля расстояния до точки попадания от максимального, [0, 1]
time
— доля времени жизни от максимального, [0, 1]
Таким образом, интенсивность обратно пропорциональна расстоянию до точки столкновения,
пропорциональна времени оставшемуся до конца действия попадания, а также равна 0 при дистанции равной или больше максимальной и при оставшемся времени равному 0.
Я бы хотел найти функцию, которая бы удовлетворяла этим условиям без необходимости ограничивать область значений времени и расстояния, но эта — всё что у меня есть.
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
}
}
}
То что нужно.
Вот так несложно и не слишком дорого можно получить достаточно красивый эффект щита космического корабля.
Обозначу основные направления возможной оптимизации:
Автор: Goldseeker
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/unity3d/276387
Ссылки в тексте:
[1] официальной документации: https://docs.unity3d.com/ru/current/Manual/SL-Properties.html
[2] Источник: https://habrahabr.ru/post/352228/?utm_source=habrahabr&utm_medium=rss&utm_campaign=352228
Нажмите здесь для печати.