Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

в 11:35, , рубрики: game engine, Gamedev, gamedevelopment, script, shader, tutorial, unity3d, урок, метки: , , , , , , , ,

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера
Доброго времени суток. В статье я расскажу, как сделать многослойное двухмерное звездное небо в Unity3d с помощью шейдеров.

Предполагается, что читатель хотя бы немного знаком с Unity3d. В статье будут описаны первые шаги в написании скриптов и шейдеров.

Всем заинтересовавшимся — добро пожаловать под кат!

Итак, задача – сделать звездное небо, например, для космического скроллера. Мы хотим, чтобы оно не было статическим, а как то двигалось, звезды мерцали и т.д. Самый простой и очевидный вариант реализации — сделать несколько слоев: фон и несколько слоев со звездами. В этом случае у нас получиться что-то вроде этого:

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

Минусом данного способа является то, что для отрисовки такого неба потребуется 4 перерисовки (Draw call). Я же хочу показать другой способ — посредством шейдеров.

Начнем с того, что создадим новый проект. В окне Hierarchy в нём уже будет Main Camera – камера, которая создается по-умолчанию. Давайте настроим её – кликнем по ней, и посмотрим на окно Inspector, в котором отобразятся свойства камеры.

Для начала, давайте поставим камеру на позицию x=0, y=0, z=100. А поворот x=0, y=180, z=0. Далее, поскольку наша игра – двухмерная, необходимо поставить в поле Projection режим Orthographic, это переключит камеру в ортографический режим, который подходит для изометрических и 2D игр. Также, нам необходимо задать значение в поле Size. Оно должно быть равно половине высоты экрана (подробнее).

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

Для того чтобы вручную его каждый раз не ставить, давайте для этого напишем простенький скрипт. Нажимаем на кнопку Create в окне Project, выбираем C# Script и даем скрипту название CameraSettings.

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

Приведу сначала код, а потом расскажу, что он делает:

using UnityEngine;
using System.Collections;

public class CameraSettings : MonoBehaviour 
{
	public Camera camera;	
	private float lastHeight = 0;	
	
	void OnEnable()
	{
		if (!camera)
		{
			Debug.Log ("Camera is not set");
			enabled = false;			
		}
	}
	
	void Update () 
	{
		if (lastHeight != Screen.height)
		{
			lastHeight = Screen.height;
			camera.orthographicSize = lastHeight / 2;
		}
	}
}

В классе добавляем открытое свойство camera, в котором мы будем указывать камеру, для которой необходимо установить Orthographic Size. В закрытом свойстве lastScreenHeight мы будем хранить последнее значение высоты экрана для того, чтобы не изменять Orthographic Size каждый кадр, а делать это только при изменении размера окна.

Функция OnEnable вызывается каждый раз, когда скрипт становится активным. В ней мы проверяем, что свойство camera не пустое. Если камера не задана – то сообщаем об этом в консоль, и выключаем скрипт, поскольку без камеры он работать не будет, и каждый кадр станет выдавать ошибку о том, что камеры нет.

Функция Update вызывается каждый кадр, в случае если скрипт активен. В ней мы проверяем, изменился ли размер окна, и если да – то сохраняем новое значение высоты экрана, и изменяем ортографический размер, равный половине высоты экрана.

Теперь необходимо этот скрипт добавить к нашей камере. В окне Hierarchy снова выделяем Main Camera. В окне Inspector нажимаем на кнопочку Add Component, в выпадающем списке жмем на Script и выбираем наш скрипт. В списке компонентов окна Inspector появится наш скрипт с пустым полем Camera. Для того чтобы указать там нашу камеру, необходимо кликнуть на кружочек в конце поля и выбрать в списке Main Camera (тоже самое можно сделать, если перетащить Main Camera прямо из окна Hierarchy на поле camera).

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

Теперь давайте создадим плоскость, на которой, собственно, и будут отображены наши звезды. Для этого выбираем в главном меню GameObject -> Create Other -> Plane. В результате в окне Hierarchy у нас появится еще один объект – Plane (давайте переименуем его в StarfieldPlane), который также можно увидеть на сцене. Выберем его и поместим в нулевые координаты. Кроме того, нам необходимо его повернуть к камере, поэтому поворачиваем по оси X на 90 градусов.

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

Небольшое лирическое отступление. Если сейчас мы нажмем на кнопку Play, то увидим, что плоскость очень маленькая. Это связано с тем, что мы сделали Orthographic Size равным половине размера экрана по высоте, и теперь одна единица (unit) в пространстве unity равна одному пикселю на экране. Как можно заметить на рисунке выше, наша плоскость состоит из сотни полигонов (10х10), каждый из которых, по размеру, равен 1 единице в пространстве. Поэтому эта плоскость занимает на экране площадь 10х10 пикселей. По-хорошему, надо создать плоскость в любом пакете 3D моделирования, чтобы она состояла из двух треугольников (нам совершенно не требуется 200 треугольников для простой плоскости). Причем размер этой плоскости необходимо сделать равным точно одному полигону плоскости в unity3d. Данный момент в этой статье будет пропущен, и мы просто добавим константу, которая будет учитывать, что наша плоскость в 10 раз больше.

Теперь давайте создадим еще один скрипт, который бы масштабировал наше звездное поле под размер экрана. Снова создаем скрипт в окне Project: Create -> C# Script, и называем его Starfield. Снова покажу сначала код, а потом расскажу, что он делает:

using UnityEngine;
using System.Collections;

public class Starfield: MonoBehaviour 
{
	private Vector2 lastScreenSize = new Vector2();	
	
	void Update () 
	{
		if (Screen.width != lastScreenSize.x || Screen.height != lastScreenSize.y)
			updateSize();
	}
	
	private void updateSize()
	{
		lastScreenSize.x = Screen.width; 
		lastScreenSize.y = Screen.height;
							 
		float maxSize = lastScreenSize.x > lastScreenSize.y ? lastScreenSize.x : lastScreenSize.y;	
		maxSize /= 10;
		transform.localScale = new Vector3(maxSize, 1, maxSize);			
	}
}

В функции Update мы проверяем изменился ли размер экрана, и если да, то масштабируем плоскость: в функции updateSize мы запоминаем текущий размер экрана, после этого масштабируем плоскость по большей стороне экрана. В таком случае у нас при любом разрешении – весь экран будет заполнен плоскостью в соответствии с пропорциями. Строчка, в которой мы делим maxSize на 10 – это то, о чем я говорил чуть выше, из-за того, что плоскость занимает 10 единиц в пространстве, а не одну.

Вешаем скрипт на плоскость, аналогично тому, как мы это делали c предыдущим скриптом для Main Camera. Теперь, если нажать на Play – мы увидим, что плоскость занимает весь экран при любом разрешении.

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

using UnityEngine;
using System.Collections;

public class CameraMove: MonoBehaviour 
{
	public float speed = 1.0f;
	
	void Update () 
	{
		Vector3 position = transform.position;
			    position.x += speed;
		transform.position = position;
	}
}

Он будет двигать камеру каждый кадр по оси x со скоростью, которую мы укажем в свойстве speed через инспектор свойств. Этот скрипт просто для примера. Вы точно также можете двигать камеру любым другим способом, в любом направлении.

Итак, теперь нам надо немного поменять скрипт Starfield для того, чтобы звездное поле двигалось вместе с камерой. Добавим открытое свойство camera, и в функции OnEnable будем проверять её наличие, точно также как мы это делали в скрипте CameraSettings. Кроме того, добавим функцию LateUpdate, которая и будет двигать звезды вместе с камерой. LateUpdate вызывается каждый кадр, но после всех Update. Изменять позицию необходимо именно в LateUpdate, а не в Update, иначе может произойти ситуация, когда позиция звездного неба будет “опаздывать” на один кадр, поскольку Update у Starfield выполниться раньше, чем Update у CameraMove. Не забываем в Inspector’е для StarfieldPlane указать камеру в свойствах скрипта.

Итак, новая версия исходного кода:

using UnityEngine;
using System.Collections;

public class Starfield: MonoBehaviour 
{
	public Camera camera;
	private Vector2 lastScreenSize = new Vector2();	
		
	void OnEnable() 
	{
		if (!camera)
		{
			Debug.Log ("Camera is not set");
			enabled = false;			
		}
	}
	
	void Update () 
	{
		if (Screen.width != lastScreenSize.x || Screen.height != lastScreenSize.y)
			updateSize();
	}
	
	void LateUpdate()
	{
		Vector3 pos = transform.position;
		pos.x = camera.transform.position.x;
		pos.y = camera.transform.position.y;
		transform.position = pos;
	}
	
	private void updateSize()
	{
		lastScreenSize.x = Screen.width; 
		lastScreenSize.y = Screen.height;
							 
		float maxSize = lastScreenSize.x > lastScreenSize.y ? lastScreenSize.x : lastScreenSize.y;	
		maxSize /= 10;
		transform.localScale = new Vector3(maxSize, 1, maxSize);			
	}
}

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

Ну что ж, пришло время приступить к написанию шейдера. Для начала, давайте решим, что мы хотим получить. Пусть у нас будет задний фон – нам нем будет какая-нибудь красивая туманность, и не очень много звезд. А также несколько слоев со звездами, примерно разделив их на маленькие, средние и большие. Таким образом, давайте остановимся на четырех слоях (так, как это было изображено на картинке в самом начале статьи). Кроме того, хотим сделать так, чтобы звезды немного сверкали.

Итак, создаем новый шейдер. В окне Project жмем на Create -> Shader, и называем его Starfield. Кроме того, сразу же давайте создадим материал. Create -> Material и назовем его StarfieldMaterial.
Применим материал к нашему StarsPlane. Для этого выберите его в окне Hierarchy, после чего, в окне Inspector, в компоненте Mesh Renderer найдите свойство Materials, и поместите в Element 0 материал StarfieldMaterial (Все это можно также сделать путем простого перетаскивания материала из окна Project в окно Scene прямо на объект StarfieldPlane).

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

В первой строке мы видим строчку

Shader "Custom/Starfield" {

Здесь указывается путь, по которому у нас будет лежать шейдер, а также его имя. В данном случае шейдер с именем Starfield появится в категории Custom. Давайте выберем материал StarfieldMaterial в окне Project. В окне Inspector появится поле Shader для материала. Выбираем в нем Custom->Shader. Теперь у материала – созданный нами шейдер. Чуть ниже в инспекторе показаны свойства шейдера. Сейчас там есть только одна текстура Base. А нам то нужно четыре! Возвращаемся к коду шейдера.

После названия шейдера у нас идет секция Properties. Именно в ней то и задаются свойства, которые мы будем видеть в окне Inspector (подробнее).

Сейчас в ней описано всего одно свойство. Общий синтаксис описания свойств:
<Название переменной> (“<Имя переменной, которое будет отображено в Inspector’е>”, <тип переменной>) = <значение по умолчанию>
Здесь нам необходимо определить четыре свойства для четырех наших текстур:

Properties 
{
	_Background ("Background (RGB)"   , 2D) = "black" {}
	_SmallStars ("Small Stars (RGBA)" , 2D) = "black" {}
	_MediumStars("Medium Stars (RGBA)", 2D) = "black" {}
	_BigStars   ("Big Stars (RGBA)"   , 2D) = "black" {}
}

После секции Properties идет секция SubShader. Каждый шейдер в юнити может иметь несколько таких секций. Когда необходимо отобразить объект с данным шейдером – юнити пытается найти первый шейдер, который поддерживается видеокартой компьютера. У нас будет только одна секция SubShader (подробнее).

SubShader {

Далее нам нужно прописать тэги для SubShader’а:

Tags { "RenderType"="Opaque" }

Теги подсказывают юнити когда надо отрисовать тот или иной объект. В данном случае мы говорим, что у нас не прозрачный объект (подробнее).

Далее надо прописать Level Of Detail:

LOD 200

Число 200 говорит – насколько затратным (относительно других SubShader’ов) является данный SubShader. Это нужно для того, чтобы на слабых видеокартах, которые не в состоянии быстро отрисовать всю сцену целиком (но при этом они могут это сделать) – юнити может решить, что лучше использовать другой SubShader, чтобы войти в рамки максимального LOD’a (подробнее).

Далее начинается сам шейдер. Он обрамляется следующим образом.

CGPROGRAM
…
ENDCG

Первым делом нам надо указать директиву:

#pragma surface surf Lambert

Это означает, что мы пишем surface шейдер, который задан функцией surf. Также мы используем освещение по Lambert’у (подробнее).

Первым делом нам надо указать, какие у нас есть переменные, передающиеся извне (наши четыре текстуры):

sampler2D _Background;
sampler2D _SmallStars;
sampler2D _MediumStars;
sampler2D _BigStars;

Далее, надо указать, какие нам нужны данные для шейдера. Делается это следующим образом:

struct Input 
{
	float2 uv_Background;
	float2 uv_SmallStars;
	float2 uv_MediumStars;
	float2 uv_BigStars;
};

В данном случае, добавляя префикс uv к названиям переменных, мы запрашиваем uv координаты для каждой из текстур. Здесь можно также было запросить такие данные, как нормали, позицию на экране, вектор направления, позицию в пространстве и др. для более сложных шейдеров (подробнее).

Далее мы приступаем к функции surf, в которой и будет описан шейдер:

void surf(Input IN, inout SurfaceOutput o) {

В параметре IN хранятся все запрошенные нами в секции Input данные.
Параметр o – это структура, в которую мы будем сохранять результаты вычислений в шейдере.

Для начала нам нужно получить цвет в данной точке для каждой из текстур:

half4 background  = tex2D (_Background , IN.uv_Background );
half4 smallStars  = tex2D (_SmallStars , IN.uv_SmallStars );
half4 mediumStars = tex2D (_MediumStars, IN.uv_MediumStars);
half4 bigStars    = tex2D (_BigStars   , IN.uv_BigStars   );	

Функция tex2D возвращает пиксель текстуры для каждой конкретной uv координаты. half4 – это четырехкомпонентная переменная, которая хранит цвет в “r”,“g” и “b” компонентах, и альфу в “a” компоненте.
Сумма цветов звезд без фона:

half3 starAlbedo = smallStars.rgb * smallStars.a + 
                   mediumStars.rgb * mediumStars.a + 
                   bigStars.rgb * bigStars.a;

Сохраняем это в результат вывода цвета, учитывая еще и фон:

o.Albedo = background.rgb + starAlbedo;

Albedo — цвет пикселя в каждой конкретной точке.

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

half starAlpha = 
        smallStars .a * (2 + sin(IN.uv_SmallStars .x * IN.uv_SmallStars .y * 12 + _Time.w * 3)) + 
        mediumStars.a * (2 + sin(IN.uv_MediumStars.x * IN.uv_MediumStars.y * 24 + _Time.z * 2) / 2) + 
        bigStars.a;

И сохраняем это, с учетом фона, в вывод свечения:

o.Emission = background.rgb + starAlbedo * starAlpha;
}

Полный исходный код шейдера

Shader "Custom/Starfield" 
{
	Properties 
	{
		_Background ("Background (RGB)"   , 2D) = "black" {}
		_SmallStars ("Small Stars (RGBA)" , 2D) = "black" {}
		_MediumStars("Medium Stars (RGBA)", 2D) = "black" {}
		_BigStars   ("Big Stars (RGBA)"   , 2D) = "black" {}
	}
	
	SubShader 
	{	
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Lambert 

		sampler2D _Background;
		sampler2D _SmallStars;
		sampler2D _MediumStars;
		sampler2D _BigStars;

		struct Input 
		{
			float2 uv_Background;
			float2 uv_SmallStars;
			float2 uv_MediumStars;
			float2 uv_BigStars;
		};

		void surf(Input IN, inout SurfaceOutput o) 
		{
			half4 background  = tex2D (_Background , IN.uv_Background );
			half4 smallStars  = tex2D (_SmallStars , IN.uv_SmallStars );
			half4 mediumStars = tex2D (_MediumStars, IN.uv_MediumStars);
			half4 bigStars    = tex2D (_BigStars   , IN.uv_BigStars   );	
								
			half3 starAlbedo = smallStars.rgb * smallStars.a + mediumStars.rgb * mediumStars.a + bigStars.rgb * bigStars.a;
			 
			o.Albedo = background.rgb + starAlbedo;
			
			half starAlpha = smallStars .a * (2 + sin(IN.uv_SmallStars .x * IN.uv_SmallStars .y * 12 + _Time.w * 3)) + 
							 mediumStars.a * (2 + sin(IN.uv_MediumStars.x * IN.uv_MediumStars.y * 24 + _Time.z * 2) / 2) + 
							 bigStars.a;		
			
			o.Emission = background.rgb + starAlbedo * starAlpha;
		}
		ENDCG
	} 
}

Итак, сохранив шейдер, мы увидим в Inspector’е, что у материала StarsMaterial теперь можно задать четыре текстуры. Я приведу здесь текстуры, которыми воспользовался я (картинки кликабельны):

Unity3d. Многослойное двухмерное звездное небо с помощью шейдера Unity3d. Многослойное двухмерное звездное небо с помощью шейдера Unity3d. Многослойное двухмерное звездное небо с помощью шейдера Unity3d. Многослойное двухмерное звездное небо с помощью шейдера

Положив каждую текстуру в соответствующий слот шейдера, и запустив наше творение – мы увидим, что текстуры наложились друг на друга, а некоторые звезды немного пульсируют. Остался вопрос – а как же их теперь двигать относительно камеры? Посмотрев еще раз в Inspector, мы заметим, что там есть еще такие свойства как Tiling и Offset. Вот с помощью Offset и будем это делать. Этот параметр сдвигает нашу текстуру относительно начальной точки. Давайте немного изменим скрипт Starfield следующим образом. Добавим открытое свойство:

public Material starsMaterial;

В него мы передадим наш материал с нашим шейдером. Не забываем поправить проверку в OnEnable на то, что мы указали материал:

if (!camera || !starsMaterial)

Также добавим свойства:

public float backgroundDistance  = 10000;
public float smallStarsDistance  = 5000;
public float mediumStarsDistance = 2500;
public float bigStarsDistance    = 1000;

Как не сложно догадаться по названию переменных, это условные числа расстояния каждого из слоев до камеры. В функции LateUpdate мы добавляем следующие строки:

starsMaterial.SetTextureOffset("_Background" , new Vector2(camera.transform.position.x / backgroundDistance, camera.transform.position.y / backgroundDistance));
starsMaterial.SetTextureOffset("_SmallStars" , new Vector2(camera.transform.position.x / smallStarsDistance, camera.transform.position.y / smallStarsDistance));
starsMaterial.SetTextureOffset("_MediumStars", new Vector2(camera.transform.position.x / mediumStarsDistance, camera.transform.position.y / mediumStarsDistance));
starsMaterial.SetTextureOffset("_BigStars"   , new Vector2(camera.transform.position.x / bigStarsDistance, camera.transform.position.y / bigStarsDistance));

Тут мы как раз и сдвигаем каждый конкретный слой в зависимости от расстояния.

Окончательная версия скрипта

using UnityEngine;
using System.Collections;

public class Starfield1: MonoBehaviour 
{
	public Camera camera;
public Material starsMaterial;
	public float backgroundDistance  = 10000;
	public float smallStarsDistance  = 5000;
	public float mediumStarsDistance = 2500;
	public float bigStarsDistance    = 1000;
	private Vector2 lastScreenSize = new Vector2();	
		
	void OnEnable() 
	{
		if (!camera || !starsMaterial)
		{
			Debug.Log ("Camera or material is not set");
			enabled = false;			
		}
	}
	
	void Update () 
	{
		if (Screen.width != lastScreenSize.x || Screen.height != lastScreenSize.y)
			updateSize();
	}
	
	void LateUpdate()
	{
		Vector3 pos = transform.position;
		pos.x = camera.transform.position.x;
		pos.y = camera.transform.position.y;
		transform.position = pos;
		
		starsMaterial.SetTextureOffset("_Background" , new Vector2(camera.transform.position.x / backgroundDistance, camera.transform.position.y / backgroundDistance));
		starsMaterial.SetTextureOffset("_SmallStars" , new Vector2(camera.transform.position.x / smallStarsDistance, camera.transform.position.y / smallStarsDistance));
		starsMaterial.SetTextureOffset("_MediumStars", new Vector2(camera.transform.position.x / mediumStarsDistance, camera.transform.position.y / mediumStarsDistance));
		starsMaterial.SetTextureOffset("_BigStars"   , new Vector2(camera.transform.position.x / bigStarsDistance, camera.transform.position.y / bigStarsDistance));
	}
	
	private void updateSize()
	{
		lastScreenSize.x = Screen.width; 
		lastScreenSize.y = Screen.height;
							 
		float maxSize = lastScreenSize.x > lastScreenSize.y ? lastScreenSize.x : lastScreenSize.y;	
		maxSize /= 10;
		transform.localScale = new Vector3(maxSize, 1, maxSize);	
	}
}

Не забудьте передать в свойство StarsMaterial – материал StarfieldMaterial.
Еще один момент. Для того, чтобы тайлинг фона не бросался в глаза я поставил для него значения Tiling x=0.5, y=0.5.

В итоге у нас получилось звездное небо, которое отрисовывается всего за один DrawCall!

Полный проект можно скачать здесь
Итоговый результат можно посмотреть на видео.

Всем спасибо за потраченное время на прочтение.

PS: Не судите строго, это моя первая статья. Замечания приветствуются.

Автор: Qwin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js