Доброго времени суток. В статье я расскажу, как сделать многослойное двухмерное звездное небо в 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. Оно должно быть равно половине высоты экрана (подробнее).
Для того чтобы вручную его каждый раз не ставить, давайте для этого напишем простенький скрипт. Нажимаем на кнопку Create в окне Project, выбираем C# Script и даем скрипту название CameraSettings.
Приведу сначала код, а потом расскажу, что он делает:
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).
Теперь давайте создадим плоскость, на которой, собственно, и будут отображены наши звезды. Для этого выбираем в главном меню GameObject -> Create Other -> Plane. В результате в окне Hierarchy у нас появится еще один объект – Plane (давайте переименуем его в StarfieldPlane), который также можно увидеть на сцене. Выберем его и поместим в нулевые координаты. Кроме того, нам необходимо его повернуть к камере, поэтому поворачиваем по оси X на 90 градусов.
Небольшое лирическое отступление. Если сейчас мы нажмем на кнопку 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 теперь можно задать четыре текстуры. Я приведу здесь текстуры, которыми воспользовался я (картинки кликабельны):
Положив каждую текстуру в соответствующий слот шейдера, и запустив наше творение – мы увидим, что текстуры наложились друг на друга, а некоторые звезды немного пульсируют. Остался вопрос – а как же их теперь двигать относительно камеры? Посмотрев еще раз в 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