Построение графика. Математическая визуализация

  • Создадим префаб.
  • Создадим линию из кубов.
  • Отобразим математическую функцию.
  • Создадим свой собственный шейдер.
  • Анимируем наш график.

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

Этот урок предполагает, что вы ознакомились с уроком Игровые объекты и скрипты. Этот урок, как и прошлый был сделан в Unity версии 2017.1.0.

Синусоида из кубов

1.Создадим линию из кубов

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

Например, у нас есть функция . Мы можем заменить , скажем на 3. Тогда получится . Мы передали 3 как входное значение параметра , а на выходе получили значение 4 как результат выполнения некоторой математической функции. Так же мы можем сказать, что это функция map 3 к 4. В более коротком виде это можно записать так: . Мы можем создать множества пар формы . Например, (5,6), (8,9), (1,2), (6,7). Но легче понять функцию, когда мы упорядочиваем пары по входным-выходным значениям. (1,2), (2,3), (3,4) и т.д.

Функция проста для понимания. сложна. Мы бы могли записать несколько пар ввода-вывода, но это, вероятно, не даст нам понимания о том, как выглядит график этой функции. Нам нужно будет записать множество точек, которые находятся близко друг к другу. Как результат мы получим море чисел, которые очень сложно разобрать. Вместо этого мы могли бы интерпретировать пары как двумерные координаты вида . Это 2D вектор где вверху представлены координаты по оси Х, а внизу координаты для оси Y. По другому еще это можно записать так: . Если мы будем использовать достаточное количество точек, то результатом соединения точек друг за другом станет график.

График построенный на интервале от -2 до 2

Визуализация может быстро дать нам представление о том, как ведет себя функция. Это очень удобный инструмент, потому давайте сделаем подобное в Unity. Для начала создайте пустую сцену File / New Scene или используйте сцену по умолчанию, которая создается при создании нового проекта.

1.1 Префабы

Графики создаются путем размещения точек по соответствующим координатам. Для этого нам понадобится 3D визуализация точки. Мы не будем изобретать велосипед, потому будем использовать объект, который доступен в Unity по умолчанию – куб. Добавим один на нашу сцену и удалим у него коллайдер, потому что нам не нужно чтобы куб взаимодействовал с другими объектами.

Являются ли кубы лучшим решением для визуализации графиков?

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

Мы будем использовать скрипт, чтобы создать много экземпляров этого куба и расположить их правильно. Для этого мы будем использовать куб в качестве шаблона. Перетащите куб из окна иерархии в окно проекта. Это позволит создать новый объект со значком синего куба, этого объект более известен как префаб (Prefab). Это готовый игровой объект, который существует в проекте, но не на сцене.

Префаб куба

Префабы – удобный способ настройки игровых объектов. При изменении главного префаба, который находится в проекте, все его экземпляры в любой сцене изменяются одинаково. Например, изменение масштаба префаба так же изменит масштаб куба, который все еще находится на сцене. Однако каждый экземпляр использует свое собственное положение и поворот. Кроме того, свойства у каждого игрового объекта можно изменить, никак не повлияв на префаб, но это отходит от сути ради которой был создан префаб. При больших изменениях, таких как добавление или удаление компонента, связь между префабом и экземпляром будет нарушена.

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

1.2 Компонент Graph

Нам понадобится скрипт на C# для того чтобы создать наш график. Давайте создадим один, и назовем его Graph. Мы начнем с простого класса, который будет расширять класс MonoBehavior , так его можно использовать как компонент для игровых объектов. Создадим публичное поле, которое будет ссылаться на префаб для создания точек, дадим ему имя pointPrefab. Так как нам понадобится доступ к компоненту Transform для позиционирования точек, то установим этот тип у нашего поля.

using UnityEngine;
public class Graph : MonoBehaviour 
{
	public Transform pointPrefab;
}

Добавим пустой игровой объект на сцену, с помощью GameObject /Create Empty, установим его в начале, и назовем его Graph. Добавим наш скрипт Graph к этому объекту, с помощью переноса или кнопки Add Component. Затем перетащим наш префаб из окна проекта в созданное нами поле Point Prefab. Теперь он ссылается на компонент префаба – Transform.

Объект Graph с компонентом Graph, который содержит ссылку на префаб Cube

1.3 Создание экземпляров префаба

Создание экземпляра объекта игры осуществляется через метод Instantiate. Это общедоступный метод, который мы получили из класса object, от которого неявно наследуется MonoBehaviour. Метод Instantiate клонирует любой объект и добавляет его на сцену. В качестве аргумента он принимает любой объект, который есть в Unity. Если мы укажем в качестве аргумента наш префаб, то в результате мы увидим, как наш куб появится на сцене. Давайте вызовем этот метод в момент, когда наш Graph «пробуждается».

public class Graph : MonoBehaviour 
{
	public Transform pointPrefab;
	void Awake () 
	{
		Instantiate(pointPrefab);
	}
}

Создание экземпляра префаба

На данном этапе при переходе в режим воспроизведения будет создан один куб в начале координат, при условии, что позиция префаба будет равна нулю. Чтобы переместить его в другое место, нам нужно изменить положение нашего только что созданного экземпляра. Метод Instantiate возвращается ссылку на все, что он создал. Поскольку мы передали в метод ссылку на компонент Transform, то давайте попросим его вернуть нам ссылку на этот же компонент, но уже у экземпляра префаба. Создадим переменную типа Transform и присвоим ей возвращаемое значение метода Instantiate.

void Awake ()
{
	Transform point = Instantiate(pointPrefab);
}

Теперь мы можем задать положение точки, назначив ей нужное нам значение 3D вектора. Вспомните как мы задавали положение стрелкам часов в предыдущем уроке Игровые объекты и скрипты. Точно так же мы будем делать и для точек графика. Мы будем изменять локальное положение через свойство localPosition, а не через position.

3D вектора создаются при помощи структуры Vector3. Поскольку это структура, то используется она в контексте значения, примерно так же как число, она не является объектом. Например, можно вручную установть координату X равной 1, оставив координаты Y и Z равные нулю, но можно использовать свойство Vector3.right которое хранит в себе значения для координат X,Y,Z равные (+1,0,0).

Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;

Разве свойства не должны писаться с большой буквы?

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

При переходе в режим игры мы все равно получаем один куб, только в немного другой позиции. Давайте создадим второй экземпляр, сместив его немного вправо. Это можно сделать, умножив наш вектор на 2. Продублируйте строку с созданием экземпляра объекта и строку с изменением позиции, но умножив правый вектор на 2 Vector3.right *2.

Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * 2f;

Можем ли мы перемножать структуры и числа?

“Из под коробки” такого функционала нет, но можно добавить его. Это делается путем создания метода со специальным синтаксисом, который перегружает символ умножения *. В этом случае, то, что кажется простым умножением, но на самом деле является вызовом метода, что-то вроде Vector3.Multiply(Vector3.right, 2f).

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

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

Код, который мы только что написали приведет к ошибке компиляции, потому что мы пытаемся определить переменную point дважды. Если мы хотим использовать другую переменную, мы должны дать ей другое имя. Нам уже не нужна ссылка на первую точку (куб), поэтому просто создайте новый экземпляр точки использовав ту же переменную.

	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right;
//	Transform point = Instantiate(pointPrefab);
	point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * 2f;

Два экземпляра с координатой по оси X равной 1 и 2.

1.4 Циклы

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

Ключевое слово while будет повторять блок кода, которые написан внутри него. Напишите while после него поставьте фигурные скобки {} и внутри этих скобок впишите первые 2 строки, остальное удалите.

void Awake () 
{
	while 
	{
		Transform point = Instantiate(pointPrefab);			
		point.localPosition = Vector3.right;
	}
//		point = Instantiate(pointPrefab);
//		point.localPosition = Vector3.right * 2f;
}

Так же, как и оператор if оператор while должен иметь условие, которое указывается в круглых скобках. Как и в случае с if, следующий за условием блок кода будет выполняться только в том случае, если выражение имеет значение true. Если указанное условие истинно, то выполнится код, который описан внутри фигурных скобок, после этого программа вернется проверить выполнилось ли условие. Если нет, то цепочка действий будет повторяться до тех пор, пока условие не станет false.

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

while (false) 
{
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right;
}

Можем ли мы проинициализировать переменную внутри цикла?

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

Но важно знать одну вещь: проинициализированная переменная внутри цикла будет доступна только в нем т.е. мы не сможем использовать эту переменную вне блока while.

Ограничить количество итераций можно путем отслеживания того, сколько раз мы повторяли код. Мы можем использовать целочисленную переменную, чтобы отслеживать это. Пременная будет содержать в себе номер итерации цикла. Объявим новую переменную типа int и назовем её i от слова iteration. Нам нужно чтобы эта переменная хранила в себе номер итерации. Если мы ее объявим внутри цикла, то она будет постоянно объявляться заново, поэтому сделаем это вне цикла.

void Awake ()
{
	int i;
	while (false) 
	{
		Transform point = Instantiate(pointPrefab);
		point.localPosition = Vector3.right;
	}
}

На каждой итерации увеличиваем значение счетчика на единицу.

void Awake ()
{
	int i;
	while (false) 
	{
		i = i + 1;
		Transform point = Instantiate(pointPrefab);
		point.localPosition = Vector3.right;
	}
}

Если мы сейчас запустим наш код, то это приведет в ошибке компиляции. Это произошло потому что наша переменная не проинициализирована. Мы как бы пытаемся прибавить единицу к неопределённому значению, чего сделать конечно же невозможно*. Нужно явно указать значение, к которому будет прибавляться единица.

  • Примечание от переводчика.

Вы могли задаться вопросом: «Если мы не проинициализируем переменную и попытаемся узнать, чему она равна, то окажется что она равно нулю. Для чего тогда нам явно указывать то, что уже было сделано до нас?».

В других языках, например, C++, при объявлении переменной её значение вообще заполнено «мусором». Оно может быть равно любому числу, которое попадает в диапазон, покрываемый значением типа int. При разработке на C# мы можем не задумываться об этом, мы знаем, что значение переменной по умолчанию равно нулю. Конструкцию i = i + 1; можно расшифровать как: «Возьми значение переменной i прибавь к нему единицу и получившееся значение присвой переменной i.» Но какое именно значение переменной нужно взять? 0? 1? 100000? Откуда именно начинать отсчет, никому не понятно. Разработчики языка учли этот момент и сделали обязательным инициализацию переменной, которой потом происходят какие-либо манипуляции. Чтобы не возникало путаницы, чтобы любому человеку, который будет работать с этим кодом было понятно откуда именно мы ведем отсчет. Так же это сделано для безопасности кода, мы ведь не знаем действительно ли значение по умолчанию равно нулю. Понимаю, звучит абсурдно, но мы можем обходными путями добиться того чтобы наши переменные имели какое угодно значение по умолчанию. Но мы пишем код на высокоуровневом языке программирования, зачем нам об это вообще думать. Так вот и не думайте, за вас уже этот момент продумали. Просто явно укажите откуда именно вы хотите вести счет.

int i = 0;

Проговорим что нам нужно. Нам нужно чтобы цикл выполнился определённое количество раз. Для этого мы создали счетчик. Когда цикл начинает выполняться, то i увеличивается на единицу. При первом проходе она равна 1, потом 2, потом 3 на 10 проходе она равна 9. Допустим я хочу, чтобы цикл закончил выполняться после 10 итераций. Значит нам нужно чтобы цикл выполнялся пока значение счетчика меньше 10. Математически это условие можно записать как .

После того как мы проговорили это, нужно прописать это условие в коде:

int i = 0;
while (i < 10) 
{
	i = i + 1;
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right;
}

Теперь после перехода в режим воспроизведения мы получим 10 кубов. Но все они окажутся в одной позиции. Чтобы переместить их в ряд вдоль оси X, умножьте правый вектор на i.

point.localPosition = Vector3.right * i;

Десять кубов в ряд.

Обратите внимание, что сейчас координаты первого куда по оси X начинаются с 1, а заканчиваются 10. Это не много не то что мы хотели. Поскольку счет мы начинаем с 0, то хотим чтобы наш первый куб был с координатой 0. Мы можем сдвинуть все точки на одну единицу влево, умножив наш вектор на i-1 вместо i. Но подобные вещи в программировании принято называть костылями. Но у нас есть более элегантное решение.

while (i < 10) 
{
//	i = i + 1;
Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * i;
	i = i + 1;
}

1.5 Лаконичный синтаксис

Использование циклов очень распространено в программировании. В любой ситуации где наши действия нужно выполнить несколько раз используется цикл. Чем сложнее код внутри цикла, тем сложнее понять, что он делает. Для упрощения чтения и понимания кода существует такая вещь как «синтаксический сахар».

Например, давайте рассмотрим увеличение операцию инкремента. Увеличение счетчика на единицу мы можем написать так: i = i + 1;, но можем и так i += 1;. Подобные сокращения и называются синтаксическим сахаром.

//	i = i + 1;
	i += 1;

Но и в данной ситуации можно «добавить сахара» заменив i += 1; на ++i;.

//	i += 1;
	++i;

Стоит так же сказать об интересной особенности использования данного сахара. Подобные конструкции могут использоваться в качестве выражений. Это означает, что вы можете написать что-то вроде y = (x += 3). Это можно расшифровать как: «Присвоить Y значение равное X + 3». Из этого можно сделать вывод что инкремент (увеличение) i мы могли бы сделать внутри условия цикла while, тем самым сократив блок кода.

while (++i < 10) 
{
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * i;
//	++i;
}

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

//while (++i < 10) {
while (i++ < 10) 
{
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * i;
}

Хотя цикл while работает для всех типов циклов, существует альтернативный синтаксис, особенно подходящий для итерации по диапазонам. Это цикл for. Он работает так же, как и while, за исключением того, что объявление переменной итератора и ее сравнение содержатся в круглых скобках и разделяются точкой с запятой.

//int i = 0;
//while (i++ < 10) {
for (int i = 0; i++ < 10) 
{
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * i;
}

Фрагмент выше приведет к ошибке компиляции, потому что на самом деле есть три части. Третья часть отвечает за увеличение итератора, как бы сохраняя его отдельно от сравнения.

//for (int i = 0; i++ < 10) {
for (int i = 0; i < 10; i++) 
{
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * i;
}

Почему в цикле for мы используем i++ а не ++i?

Поскольку выражение инкремента не используется ни для чего другого, не имеет значения, какую из двух этих версий мы используем. Мы могли бы также использовать i += 1 или i = i + 1.

Классический цикл for имеет форму for (int i = 0; i < someLimit; i++). Вы будете сталкиваться с подобной записью во многих других скриптах и программах.

1.6 Изменение области определения функции

На данный момент нашим кубам даны координаты по оси X от 0 до 9. Это не удобный диапазон для работы с функциями. Чаще всего используется диапазон от 0 до 1. Или если мы работаем с функцией, которая симметрична относительно нуля то ее диапазон обычно равен . Давайте расположим наши кубы в соответствующем диапазоне.

Расположение десятка кубов на отрезке длиной в две единицы приведет к их перекрытию. Чтобы предотвратить это надо уменьшить масштаб кубов. Каждый куб имеет размер 1 по всем осям по умолчанию. Для того чтобы сделать их подходящего размера нам нужно уменьшить их масштаб до . Мы можем сделать это установив масштаб каждого куба равный Vector3.one деленный на 5 т.е. принять значение масштаба для всех осей равный

for (int i = 0; i < 10; i++) 
{
	Transform point = Instantiate(pointPrefab);
	point.localPosition = Vector3.right * i;
	point.localScale = Vector3.one / 5f;
}

Маленькие кубики.

Чтобы снова собрать кубики вместе разделите их позицию на 5.

point.localPosition = Vector3.right * i / 5f;

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

point.localPosition = Vector3.right * (i / 5f  1);

Сейчас у первого куба координата по оси X равна -1, в то время как последний куб имеет координату 0.8. Однако размер куба равен 0.2. Поскольку размер куб считается его центра, то левая сторона первого куба имеет координату X равной -1.1, в то время как правая сторона этого же куба имеет координату -0.9. Чтобы правильно заполнить диапазон нашими кубиками, мы должны сдвинуть их на половину куба вправо. Это можно сделать просто, добавив значение 0.5 к i перед операцией деления. Более подробнее узнать о том, как приводить какие-либо значения к нужному диапазону можно узнать из статьи на Википедии: Feature scaling.

point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);

1.7 Выносим вектора за пределы цикла

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

void Awake () 
{
	Vector3 scale = Vector3.one / 5f;
	for (int i = 0; i < 10; i++) 
	{
		Transform point = Instantiate(pointPrefab);
		point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);
		point.localScale = scale;
	}
}

Мы также можем определить переменную для позиции перед циклом. Поскольку мы создаем линию вдоль оси X, нам нужно изменять только координату X внутри цикла. Таким образом нам больше не нужно умножать нашу позицию на Vector3.right.

Vector3 scale = Vector3.one / 5f;
Vector3 position;
for (int i = 0; i < 10; i++) 
{
	Transform point = Instantiate(pointPrefab);
//	point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);
	position.x = (i + 0.5f) / 5f - 1f;
	point.localPosition = position;
	point.localScale = scale;
}

Можем ли мы изменить составляющие вектора так как мне нужно?

Vector3 это структура, которая содержит в себе три поля x, y и z типа float. Эти поля публичные, значит мы можем изменить их.

Идея заключается в том, чтобы структуры должны быть неизменяемыми поскольку они как правило хранят в себе простые значения. Конечные структуры не должны изменяться. Если вы хотите использовать другое значение, создайте новую структуру поле или переменную как это мы делаем с числами. Если мы скажем что , а потом , то мы присвоим переменной X другое значение. Мы не использовали 3 чтобы получить 5, мы просто заменили одно значение другим. Хоть и структуры, содержащие в себе значения векторов и можно изменить в Unity, лучше избегать этого. Создайте новую переменную типа Vector3 и ей присвойте значение, взятое из структуры, а после изменяйте его как хотите. Манипуляции будут происходить только с объявленной вами переменной.

Чтобы получить представление о том, как работать с изменяемыми векторами нужно рассмотреть использование Vector3 в качестве удобной замены для использования трех отдельных значений float. Вы можете получить к ним доступ независимо друг от друга, скопировать любое значение или объединить их в группу.

Код выше приведет к ошибке компиляции, жалуясь на использование неназначенной переменной. Это происходит потому что мы назначаем позицию чему-то, не установив его начальные координаты по осям Y и Z. Явно установите их, присвоив им значение нуля.

Vector3 position;
position.y = 0f;
position.z = 0f;
for (int i = 0; i < 10; i++) 
{
	
}

1.8 Выражаем Y через X

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

Vector3 position;
//position.y = 0f;
position.z = 0f;
for (int i = 0; i < 10; i++) 
{
	Transform point = Instantiate(pointPrefab);
	position.x = (i + 0.5f) / 5f - 1f;
	position.y = position.x;
	point.localPosition = position;
	point.localScale = scale;
}

Y равен X.

Не много менее очевидная функция , которая определяет параболу с вершиной в нуле.

position.y = position.x * position.x;

Y равен X в квадрате.
1. unitypackage

2. Создаем больше кубов.

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

2.1. Различные разрешения.

Вместо того, чтобы использовать фиксированное количество кубов, мы можем сделать его настраиваемым. Чтобы сделать это возможным, добавьте публичное целое поле, которое будет хранить в себе разрешение Graph (разрешение в данном контексте подразумевает количество кубов, которое будет использоваться для отображения графика. Как монитор компьютера, чем больше разрешение, тем точнее картинка. Так же и тут чем выше разрешение, тем точнее график). Дайте ему значение по умолчанию 10, которое мы используем сейчас.

public int resolution = 10;

Поле Resolution

Идея заключается в том, что мы можем изменить разрешение графа, изменив это значение с помощью созданного нами поля в инспекторе. Однако не все целые числа являются допустимыми. Как минимум, числа должны быть положительными. Мы можем поручить инспектору обеспечить соблюдение попадания в определенный диапазон для нашего поля. Это делается путем записи Range в квадратные скобки перед определением поля.

[Range] public int resolution = 10;

Range -Тип атрибута, определяемый Unity. Атрибут-это способ присоединения метаданных к структурам кода, в данном случае к полю. Инспектор Unity проверяет, есть ли у поля добавленный атрибут Range. Если это так, он будет использовать ползунок вместо поля ввода по умолчанию для чисел. Однако для этого необходимо знать допустимый диапазон. Так Range имеет два параметра, для минимального и максимального значения. Давайте используем 10 и 100. Кроме того, атрибуты обычно записываются выше объявляемого поля или метода, а не перед ними.

[Range(10, 100)]
public int resolution = 10;

Поле Resolution со слайдером

Гарантирует ли это, что разрешение будет ограничено 10-100?

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

2.2 Создание различных экземпляров.

Для того чтобы использовать разрешение, мы должны изменить количество кубов, которое мы создаем. Вместо использования циклов фиксированное количество раз в awake, количество итераций теперь ограничено разрешением. Теперь если разрешение установлено 50, мы создадим 50 кубов после входа в игровой режим.

for (int i = 0; i < resolution; i++) 
{
	
}

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

void Awake () 
{
	float step = 2f / resolution;
	Vector3 scale = Vector3.one * step;
	Vector3 position;
	position.z = 0f;
	for (int i = 0; i < resolution; i++) 
	{
		Transform point = Instantiate(pointPrefab);
		position.x = (i + 0.5f) * step - 1f;
		position.y = position.x * position.x;
		point.localPosition = position;
		point.localScale = scale;
	}
}

График со значением Resolution равным 50

2.3. Устанавливаем родителя

После открытия режима с разрешением в 50, множество созданных кубов окажутся на сцене.

Все точки графика являются корневыми.

Эти Кубы в настоящее время являются корневыми объектами, хотя по сути они должны являться потомками объекта Graph. Мы можем установить эту связь после создания экземпляра куба, вызвав метод SetParent из компонента куба Transform. В качестве аргумента мы должны предоставить компонент Transform объекта, который будет для наших кубов родителем. Мы можем напрямую получить доступ к компоненту Transform объекта Graph через его свойство transform.

for (int i = 0; i < resolution; i++) 
{
	Transform point = Instantiate(pointPrefab);
	position.x = (i + 0.5f) * step - 1f;
	position.y = position.x * position.x;
	point.localPosition = position;
	point.localScale = scale;
	point.SetParent(transform);
}

Все точки графика стали дочерними к объекту Graph.

Когда новый родитель установлен, Unity попытается сохранить объект в исходном положении, вращении и масштабе. В нашем случае, нам это не нужно. Мы можем указать это, подставляя false в качестве второго аргумента SetParent.

2. unitypackage

3. Раскрасим график

Белый график выглядит не очень презентабельно. МЫ могли бы использовать другой сплошной цвет, но это тоже не очень интересно. Что мы можем сделать, так это использовать позицию точки для определения ее цвета.

Простой способ настройки цвета каждого куба можно сделать через свойство цвета материала куба. Мы можем сделать это в цикле. Поскольку каждый куб будет иметь свой цвет, это означает, что мы будем иметь один уникальный материал для каждого объекта. Хоть данная идея и реализуема, она не очень эффективна. Было бы на много проще, если бы могли использовать один материал, который изменял бы свой цвет в зависимости от своего положения в пространстве. К сожалению, в Unity нет такого материала. Так что давайте сами его создадим.

3.1 Создание собственного шейдера.

GPU (graphic processing unit) или графический процессор отвечает за рендер (отображение) 3D-объектов. Материалы в Unity определяются при помощи шейдеров, который позволяют настраивать его свойства. Нам нужно создать пользовательский шейдер для получения нужной функциональности. Создайте таковой через : Assets / Create / Shader / Standard Surface Shader и дайте ему имя ColoredPoint.

Пользовательский шейдер

Теперь у нас есть файл c расширением shader, который мы можем открыть как обычный скрипт с синтаксисом отличным от языка C#. Наш файл шейдера содержит код для определения качеств, которые присущи нашему материалу. Ниже представлено содержимое файла, которое генерируется Unity. Все строки с комментариями удалены для краткости.

Shader "Custom/ColoredPoint" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows

		#pragma target 3.0

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
		};

		half _Glossiness;
		half _Metallic;
		fixed4 _Color;

		UNITY_INSTANCING_CBUFFER_START(Props)
		UNITY_INSTANCING_CBUFFER_END

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Как работает поверхностный шейдер?

Написание шейдеров, взаимодействующих с освещением, это сложная задача. Есть различные типы источников света, различные варианты теней, различные пути рендеринга - прямой (forward) и отложенный (deferred), и шейдер должен как-то управлять всей этой сложностью. Шейдеры поверхности в Unity – это подход к созданию кода, который упрощает написание. Если вы хотите узнать больше о шейдерах, вы можете ознакомиться с серией туторов по Рендерингу (пока не переведено)

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

Shader "Custom/ColoredPoint" {
	Properties {
//		_Color ("Color", Color) = (1,1,1,1)
//		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows

		#pragma target 3.0

//		sampler2D _MainTex;

		struct Input {
//			float2 uv_MainTex;
		};

		half _Glossiness;
		half _Metallic;
//		fixed4 _Color;

		UNITY_INSTANCING_CBUFFER_START(Props)
		UNITY_INSTANCING_CBUFFER_END

		void surf (Input IN, inout SurfaceOutputStandard o) {
//			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
//			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
//			o.Alpha = c.a;
			o.Alpha = 1;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Что такое альбедо и альфа-канал?

Цвет диффузного отражения материала называется альбедо. В нем описывается сколько каналов красного, зеленого и синего отражено диффузно. Остальное поглощается.

Альфа-канал используется как мера непрозрачности. При альфа-канале равным нулю поверхность полностью прозрачная, в то время как при альфа-канале равным единице она полностью непрозрачна.

На этом этапе шейдер не скомпилируется, поскольку поверхностные шейдеры не могут работать с пустой входной структурой. В ней мы определяем, какие пользовательские данные необходимы для окрашивания пикселей. В нашем случае, нам нужна позиция точки. Мы можем получить доступ к позиции объекта в глобальной системе координат, добавив float3 worldPos; в структуру Input.

struct Input {
	float3 worldPos;
};

Говорит ли это о том, что перемещение объекта Graph повлияет на его цвет?

Да. При таком подходе окраска будет правильно только до тех пор, пока Graph находится в начале координат.

Также обратите внимание, что эта позиция определяется для каждой вершины. В нашем случае для каждого угла куба. Цвет будет интерполирован по его граням. Чем больше кубики, тем более очевидным будет цветовой переход.

Теперь, когда у нас есть функционирующий шейдер, создайте для него материал с именем Colored Point. Перетяните шейдер на материал или выберите в выпадающем меню Custom / Colored Point.

Материал для раскраски точек.

Пусть префаб нашего куба использует этот материал вместо материала по умолчанию. Это можно сделать просто перетащим наш материала на префаб.

3.2 Изменение цвета, основанное на изменении глобальных координат

Если мы нажмем кнопку воспроизведения, то наш график будет создать экземпляры черных кубов. Чтобы изменить их цвет, мы должны изменить переменную o.Albedo. Вместо того чтобы присваивать ему ноль, присвойте свойству отвечающего за красный цвет значение координаты X.

//	o.Albedo = 0;
	o.Albedo.r = IN.worldPos.x;

График, цвет которого регулируется координатой X.

Разве мы не должны инициализировать o.Albedo?

Нам не нужно устанавливать значения для свойств отвечающие за зеленый и синий цвет, потому что они им уже было установлено значение нуля перед тем как был вызван метод surf

Кубы имеющие положительное значение координаты по оси X постепенно становятся красными. А вот кубы имеющие отрицательные значения по оси X остаются черными, потому что цвета не могут быть отрицательными. Чтобы получить более плавный переход в красный цвет, необходимо в двое сократить координаты X и прибавить 0.5.

o.Albedo.r = IN.worldPos.x * 0.5 + 0.5;

Плавный переход

Давайте также использовать координату по оси Y для регулирования свойства отвечающего за зеленый цвет. В файле шейдера это можно сделать одним простым движением, заменив o.Albedo.r на o.Albedo.rg и IN.worldPos.x на IN.worldPos.xy.

o.Albedo.rg = IN.worldPos.xy * 0.5 + 0.5;

Изменение цвета в зависимости от положения по осям X и Y

Красный плюс зеленый равен желтому, поэтому наш график плавно переходит от светло-зеленого к желтому цвету. Поскольку график по оси Y начинает отображаться с позиции равной -1, то в этом положении он имеет темно-зеленый цвет. Чтобы проверить мое утверждение измените нашу функцию с квадратичной параболы на кубическую. Формула кубической параболы выглядит так: .

position.y = position.x * position.x * position.x;

Кубическая парабола лежащая в диапазоне (-1,+1)

3. unitypackage

4. Анимируем график

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

4.1 Отслеживание точек

Чтобы заставить наш график двигаться, нам придется корректировать положения его точек в данный момент времени. Первое что приходит в голову это каждый раз удалять все точки и заново создавать, но уже в новой позиции. Отличная идея, но неэффективная. Гораздо лучше использовать уже созданные нами точки, просто корректируя их положение в зависимости от параметра времени. Сделать это проще всего можно путем добавления поля, которое будет содержать в себе ссылки на все наши точки. Создадим поле points в скрипте Graph типа Transform.

Transform points;

Созданное только что поле позволяет нам ссылаться на одну точку, но нам нужна не одна, а множество точек. В языке C# и во многих других языках уже существует готовая конструкция, позволяющая хранить в себе множество значений одного типа и называется она – массив. Для того чтобы превратить нашу обычную переменную в массив необходимо всего лишь добавить пару квадратных скобок после объявления типа переменной. Читаться такая конструкция будет как: «Создадим поле с именем points, которое будет содержать в себе массив значений типа Transform».

Transform[] points;

Массив – это ссылочный тип данных, а не тип значений, потому при объявлении переменной мы обязаны передать ссылку на экземпляр данного массива, либо же объявить новый используя ключевое слово new, далее указывается тип создаваемого экземпляра, который соответствует типу переменной, которой мы пытаемся присвоить значение Transform[]. Мы ведь не хотим создавать массив при каждой итерации цикла, потому объявление сделаем перед циклом for.

points = new Transform[];
for (int i = 0; i < resolution; i++) 
{
	
}

Необходимо помнить одну очень важную особенность всех массивов: они создаются фиксированной длины. Размерность массива определяет сколько элементов может в нем храниться. Если нам нужно будет увеличить размерность массива, то нужно будет создавать новый экземпляр с нужной нам длиной. Размерность массива указывается внутри квадратных скобок при объявлении. В нашем случае его длина равна разрешению (resolution).

points = new Transform[resolution];

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

points = new Transform[resolution];
for (int i = 0; i < resolution; i++) 
{
	
	points[i] = point;
}

4.2 Перенос логики в метод Update.

Нельзя заставить двигаться график, прописав его создание в методе, который вызывается всего 1 раз. Чтобы у нас получилась красивая анимация нам нужно перенести часть логики в метод Update. В результате нем не нужно будет вычислять положение точек в месте их создания. Однако инициализацию некоторых переменных, чье значение должно быть нулевым на момент их изменения мы сотавим в блоке Awake. Код ниже отображает изменения в методе Awake.

position.y = 0f;
position.z = 0f;
for (int i = 0; i < resolution; i++) 
{
	Transform point = Instantiate(pointPrefab);
	position.x = (i + 0.5f) * step - 1f;
//	position.y = position.x * position.x * position.x;
	point.localPosition = position;
	point.localScale = scale;
	point.SetParent(transform, false);
	points[i] = point;
}

Добавьте пустой цикл for в метод Update

void Update () 
{
	for (int i = 0; i < resolution; i++) {}
}

Теперь проговорим что нам нужно. Нам нужно пройтись по нашему массиву точек и установить каждой точке координаты по оси Y. Поскольку длина массива совпадает с resolution, мы можем использовать эту переменную для граничного значения счетчика массива. Но мало ли что может произойти с нашим массивом, вдруг приедет хипстер на голубом самокате и изменит размерность массива? В реальности, конечно, такое маловероятно, но давайте просто воспользуемся встроенным C# свойством Length, которое хранит в себе длину нашего массива.

for (int i = 0; i < points.Length; i++) {}

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

for (int i = 0; i < points.Length; i++) 
{
	Transform point = points[i];
	Vector3 position = point.localPosition;
}

Далее получим значение для текущей точки в соответствии с нашей формулой

for (int i = 0; i < points.Length; i++) 
{
	Transform point = points[i];
	Vector3 position = point.localPosition;
	position.y = position.x * position.x * position.x;
}

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

for (int i = 0; i < points.Length; i++) 
{
	Transform point = points[i];
	Vector3 position = point.localPosition;
	position.y = position.x * position.x * position.x;
	point.localPosition = position;
} 

А разве мы не можем сделать так: point.localPosotion.y?

Если бы localPosition было полем, то это было бы возможно. Мы могли бы напрямую задать координату Y. Однако localPosition является свойством. Оно может передать и принять только значение вектора. Таким образом, мы пытаем скорректировать позицию в локальной системе координат, которая вообще никак не влияет на положение точки. И поскольку мы явно не сохранили его в переменно, операция бессмысленна и вызовет ошибку компилятора.

4.3 Отобразим синусоиду

С этого момента, если мы перейдем в режим воспроизведения, то точки нашего графика будут менять свое положение каждый кадр. Одна проблема: мы не замечаем никаких изменений, потому что они всегда остаются на одних и тех же позициях. Для того чтобы изменить текущее положение дел надо наконец-то добавить зависимость нашей функции от времени. Однако стоит сразу сделать не большую оговорку: простое добавление времени приведет к тому что наша функция просто уползет куда-то в сторону бесконечности. Чтобы подобного не произошло мы должны использовать функцию, которая изменяется, но остается в фиксированном диапазоне. Функция синуса идеально подойдет для этого. В стандартной библиотеке Unity есть уже готовая структура для работы с математическими функциями - Mathf. В ней есть метод Sin, который возвращает значение синуса от переменной, которую мы передадим в качестве аргумента. В данном случае мы хотим отобразить функцию вида , потому мы должный координате в оси Y присвоить значение синуса от координаты X.

position.y = Mathf.Sin(position.x);

Что за Mathf?

Это структура, содержащая в себе набор математических функций и констант для работы с числами и векторами. В пространстве имен System уже есть подобная структура, которая называется Math. Отличие в том, что Mathf работает с числами типа float и векторами, а Math с числами типа double и в ней вообще такого типа как вектор.

Синусоида меняется в диапазоне от . Данная функция циклична и повторяется каждые юнита, что примерно равно 6.28. Если так же масштабировать параметр времени на , функция будет повторяться каждые две секунды. В итоге получим вот такую функцию: , где это прошедшее время с момента запуска сцены. Этот параметр будет двигать синусоиду (волну), смещая ее в отрицательном направлении по оси X.

Анимированная функция

Итоговый вариант от автора урока: 4. unitypackage

Итоговый вариант от переводчика: 5. unitypackage

Следующий урок: Математические поверхности