Визуализация математики
В этой статье:
- Создание префаба
- Создание экземпляра нескольких кубов
- Демонстрация математической функции
- Создание surface shader и shader graph
- Анимирование графика.
Это второе руководство из серии, посвященной изучению основ работы с Unity. На этот раз мы будем использовать игровые объекты для построения графика, чтобы можно было отображать математические формулы. Мы также сделаем функцию зависимой от времени, создав анимированный график.
Это руководство создано с использованием Unity 2020.3.6f1.
1. Создание линии из кубов
При программировании важно хорошо разбираться в математике. На самом фундаментальном уровне математика - это манипуляции с символами, которые представляют числа. Решение уравнения сводится к замене одного набора символов на другой, обычно более короткий набор символов. Правила математики диктуют, как можно выполнить это переписывание.
Например, у нас есть функция f(x)=x+1. Мы можем подставить вместо параметра x число, скажем, 3. Это приведет к f(3)=3+1=4. Мы указали 3 в качестве входного аргумента, а на выходе получили 4. Можно сказать, что функция отображает 3 в 4. Проще всего было бы записать это в виде пары вход-выход, например (3, 4). Мы можем создать множество пар вида (x, f(x)), например (5, 6), (8, 9), (1, 2) и (6, 7). Но функцию легче понять, когда пары упорядочены по входному числу.
Функция f(x)=x+1 понимается легко. f(x)=(x−1)^4+5x^3−8x^2+3x сложнее. Мы могли бы записать несколько пар вход-выход, но это вряд ли даст нам хорошее представление о том, какое отображение она представляет. Нам понадобится много точек, расположенных близко друг к другу. В итоге получится море чисел, которые трудно разобрать. Вместо этого мы можем интерпретировать пары как двумерные координаты. Двумерный вектор, где верхнее число представляет горизонтальную координату по оси X, а нижнее число представляет вертикальную координату по оси Y. Другими словами, y=f(x). Мы можем нанести эти точки на плоскость. Если мы используем достаточное количество точек, расположенных очень близко друг к другу, у нас получится линия. В результате получится график.
Взглянув на график, можно быстро получить представление о том, как ведет себя та или иная функция. Это удобный инструмент, поэтому давайте создадим его в Unity. Начнем с нового проекта, как описано в первом разделе предыдущего урока.
1.1 Префабы
Графики создаются путем размещения точек в соответствующих координатах. Для этого нам нужна 3D-визуализация точки. И для этого мы воспользуемся стандартным игровым объектом Unity - кубом. Добавьте его в сцену и назовите Point. Удалите его компонент BoxCollider, так как мы не будем использовать физику.
Мы будем использовать индивидуальный компонент, чтобы создать множество экземпляров этого куба и правильно их расположить. Для этого мы превратим куб в шаблон игрового объекта. Перетащите куб из окна иерархии в окно проекта. В результате будет создан новый ассет, известный как префаб. Это готовый игровой объект, который существует в проекте, а не в сцене.
Игровой объект, который мы использовали для создания префаба, все еще существует в сцене, но теперь это экземпляр префаба. В окне иерархии у него есть синий значок и стрелка справа от него. В заголовке его инспектора также указано, что это префаб. Положение теперь выделено жирным шрифтом, что указывает на то, что значения экземпляра переопределяют значения префаба. Любые изменения, которые вы внесете в экземпляр, будут отображаться также.
При выборе ассета префаба его инспектор покажет корневой игровой объект и большую кнопку для открытия префаба.
При нажатии на кнопку Open Prefab в окне сцены отобразится сцена, которая не содержит ничего, кроме иерархии объектов префаба.
Вы можете выйти из сцены префаба с помощью стрелки слева от его названия в окне иерархии.
Префабы - это удобный способ настройки игровых объектов. Если вы измените ассет префаба, все его экземпляры в любой сцене будут изменены таким же образом. Например, изменение масштаба префаба также изменит масштаб куба, который все еще находится в сцене. Однако каждый экземпляр использует свою собственную позицию и вращение. Кроме того, экземпляры игровых объектов могут быть изменены, что переопределяет значения префаба. Обратите внимание, что в режиме воспроизведения связь между префабом и экземпляром нарушается.
Мы собираемся использовать скрипт для создания экземпляров префаба, что означает, что нам больше не нужен экземпляр префаба, который в данный момент находится в сцене. Поэтому удалите его либо с помощью Edit / Delete, либо с помощью контекстного меню в окне иерархии.
1.2 Компонент Graph
Нам нужен скрипт на C# для создания графика с помощью нашего префаба point. Создайте такой скрипт и назовите Graph.
Начнем с простого класса, который расширяет MonoBehaviour, чтобы его можно было использовать в качестве компонента для игровых объектов. Дадим ему сериализуемое поле для хранения ссылки на префаб для создания точек с именем pointPrefab. Нам понадобится доступ к компоненту Transform, чтобы расположить точки, поэтому укажите тип этого поля.
using UnityEngine;
public class Graph : MonoBehaviour {
[SerializeField]
Transform pointPrefab;
}
Добавьте в сцену пустой игровой объект и назовите его Graph. Убедитесь, что его положение и повороты равны нулю, а масштаб равен 1. Добавьте к этому объекту наш компонент Graph. Затем перетащите наш префаб в поле Point Prefab в Graph. Теперь он содержит ссылку на компонент Transform префаба.
1.3 Создание экземпляров префабов
Создание игрового объекта осуществляется с помощью метода Object.Instantiate. Это общедоступный (public) метод Object, который Graph косвенно унаследовал, расширив MonoBehaviour. Метод Instantiate клонирует любой объект Unity, переданный ему в качестве аргумента. В случае с префабом это приведет к добавлению экземпляра в текущую сцену. Сделаем это, когда наш компонент Graph будет запускаться.
public class Graph : MonoBehaviour {
[SerializeField]
Transform pointPrefab;
void Awake () {
Instantiate(pointPrefab);
}
}
Если мы сейчас перейдем в режим воспроизведения, то в начале мира будет создан единственный экземпляр префаба Point. Его имя будет таким же, как у префаба, с добавлением (Clone).
Чтобы поместить точку в другое место, нам нужно изменить положение экземпляра. Метод Instantiate дает нам ссылку на то, что он создал. Поскольку мы дали ему ссылку на компонент Transform, именно его мы и получим в ответ. Будем отслеживать это с помощью переменной.
void Awake () {
Transform point = Instantiate(pointPrefab);
}
В предыдущем руководстве мы вращали стрелки часов, присваивая кватернион свойству localRotation в оси вращения (pivot) компонента Transform. Изменение положения работает аналогично, за исключением того, что вместо этого мы должны присвоить свойству localPosition трехмерный вектор.
Трехмерные векторы создаются с использованием структурного типа Vector3. Например, зададим координату X нашей точки равной 1, оставив координаты Y и Z равными нулю. У Vector3 есть свойство right, которое дает нам такой вектор. Используем его, чтобы задать положение точки.
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
Теперь, при входе в режим воспроизведения, мы по-прежнему получаем один куб, только в несколько ином положении. Давайте создадим второй и поместим его на шаг правее. Это можно сделать, умножив Vector3.right на 2. Повторим создание и позиционирование экземпляра, затем добавим умножение к новому коду.
void Awake () {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * 2f;
}
Этот код приведет к ошибке компилятора, поскольку мы попытаемся дважды определить переменную point. Если мы хотим использовать другую переменную, мы должны присвоить ей другое имя. В качестве альтернативы мы можем повторно использовать уже имеющуюся переменную. Нам не нужно сохранять ссылку на первую точку, как только мы закончим с ней, поэтому присвойте новую точку той же переменной.
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
//Transform point = Instantiate(pointPrefab);
point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * 2f;
1.4 Циклы кода
Создадим ещё больше точек, пока их не будет десять. Мы могли бы повторить один и тот же код еще восемь раз, но это очень неэффективное программирование. В идеале мы пишем код только для одной точки и даем команду программе выполнить его несколько раз с небольшими изменениями.
Чтобы заставить блок кода повторяться, можно использовать оператор while. Применим его к первым двум операторам нашего метода и удалим остальные операторы.
void Awake () {
while {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}
//point = Instantiate(pointPrefab);
//point.localPosition = Vector3.right * 2f;
}
За ключевым словом while должно следовать выражение в круглых скобках. Блок кода, следующий за while, будет выполнен только в том случае, если значение выражения равно true. После этого программа вернется к оператору while. Если в этот момент выражение снова получит значение true, блок кода будет выполнен снова. Так будет повторяться до тех пор, пока выражение не получит значение false. Тогда программа пропускает блок кода, следующий за оператором while, и продолжает выполнение других методов.
Поэтому нужно добавить выражение после while. Мы должны быть осторожны, чтобы цикл не повторялся вечно. Бесконечные циклы приводят к зависанию программ, что требует ручного завершения пользователем. Самое безопасное из возможных компилируемых выражений - это просто false.
while (false) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}
Ограничить цикл можно, отслеживая, сколько раз мы повторяли код. Для этого мы можем использовать целочисленную переменную. Ее тип - int. Она будет содержать номер итерации цикла, поэтому давайте назовем ее i. Его начальное значение равно нулю. Чтобы его можно было использовать в выражении while, оно должно быть определено перед ним.
int i = 0;
while (false) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}
Теперь i становится равным 1 в начале первой итерации, 2 в начале второй итерации и так далее. Но выражение while вычисляется перед каждой итерацией. Таким образом, непосредственно перед первой итерацией i равно нулю, перед второй - 1 и так далее. Итак, после десятой итерации значение i равно десяти. На этом этапе мы хотим остановить цикл, поэтому его выражение должно быть оценено как false. Другими словами, мы должны продолжать до тех пор, пока значение i меньше десяти. Математически это выражается как i<10. В коде записано то же самое, только с оператором < меньше, чем.
int i = 0;
while (i < 10) {
i = i + 1;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}
Теперь, войдя в режим воспроизведения, мы получим десять кубиков. Но все они окажутся в одном и том же положении. Чтобы расположить их в ряд вдоль оси X, умножим Vector3.right на i.
point.localPosition = Vector3.right * i;
Обратите внимание, что сейчас первый куб имеет координату X, равную 1, а последний - 10. Изменим это так, чтобы отсчёт шёл с нуля, расположив первый куб в начале координат. Мы можем сдвинуть все точки на единицу влево, умножив их Vector3.right на (i - 1) вместо i. Однако мы могли бы пропустить это дополнительное вычитание, увеличив i в конце блока, после умножения, а не в начале.
while (i < 10) {
//i = i + 1;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
i = i + 1;
}
1.5 Краткий синтаксис
Поскольку циклическое выполнение определенного количества раз является обычным делом, удобно сохранять код цикла кратким. В этом нам может помочь некоторый синтаксический сахар.
Во-первых, давайте рассмотрим увеличение числа итераций. Когда выполняется операция вида x = x * y, ее можно сократить до x *= y. Это работает для всех операторов, которые работают с двумя операндами.
//i = i + 1;
i += 1;
Если пойти ещё дальше, при увеличении или уменьшении числа на 1 это можно сократить до ++x или --x.
//i += 1;
++i;
Одно из свойств операторов присваивания состоит в том, что они также могут использоваться в качестве выражений. Это означает, что вы могли бы написать что-то вроде y = (x += 3). Это увеличило бы x на три и также присвоило бы результат y. Это говорит о том, что мы могли бы увеличить значение 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;
}
1.6 Изменение домена
Сейчас наши точки имеют координаты по X от 0 до 9. Это неудобный диапазон при работе с функциями. Часто для X используется диапазон от 0 до 1. Или при работе с функциями, которые сосредоточены вокруг нуля, используется диапазон от −1 до 1. Давайте соответствующим образом расположим наши точки.
Если расположить десять кубиков вдоль отрезка длиной в две единицы, они будут перекрываться. Чтобы предотвратить это, мы уменьшим их масштаб. По умолчанию каждый куб имеет размер 1 в каждом измерении, поэтому, чтобы они соответствовали друг другу, мы должны уменьшить их масштаб до 2/10=1/5. Мы можем сделать это, установив для локального масштаба каждой точки значение свойство Vector3.one делённого на 5. Деление выполняется с помощью оператора '/' косой черты.
for (int i = 0; i < 10; i++) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
point.localScale = Vector3.one / 5f;
}
Вы можете лучше рассмотреть общее расположение кубов, переключив окно сцены в режим ортографической проекции, который игнорирует перспективу. Щелчок по надписи под виджетом оси в правом верхнем углу окна сцены позволяет переключаться между ортографическим и перспективным режимами. Белые кубы также легче увидеть, если отключить скайбокс на панели инструментов окна сцены.
Чтобы снова собрать кубики вместе, также разделите их положение на пять.
point.localPosition = Vector3.right * i / 5f;
В результате они будут находиться в диапазоне 0-2. Чтобы преобразовать это значение в диапазон −1–1, вычтите 1 перед масштабированием вектора. Используйте круглые скобки, чтобы указать порядок операций в математическом выражении.
point.localPosition = Vector3.right * (i / 5f - 1f);
1.7 Извлечение векторов из цикла
Хотя все кубы имеют одинаковый масштаб, мы вычисляем его заново на каждой итерации цикла. Нам не нужно этого делать, масштаб инвариантен. Вместо этого мы могли бы вычислить его один раз перед циклом, сохранить в переменной scale и использовать в цикле.
void Awake () {
var 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 position;
var scale = Vector3.one / 5f;
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;
}
Это приведет к ошибке компилятора, связанной с использованием неназначенной переменной. Это происходит потому, что мы присваиваем position чему-либо, хотя еще не задали ее координаты Y и Z. Мы можем исправить это, изначально установив position равным нулевому вектору, присвоив ему значение Vector3.zero.
//Vector3 position;
var position = Vector3.zero;
var scale = Vector3.one / 5f;
1.8 Использование X для определения Y
Идея заключается в том, что позиции наших кубов мы можем использовать для отображения функции. В этот момент координаты Y всегда равны нулю, что соответствует тривиальной функции f(x)=0. Чтобы показать другую функцию, мы должны определить координату Y внутри цикла, а не перед ним. Давайте начнем с того, что сделаем Y равным X, представляя функцию f(x)=x.
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;
}
Чуть менее очевидной функцией была бы f(x)=x^2, которая определяет параболу с минимумом, равным нулю.
position.y = position.x * position.x;
2. Создаём больше кубов
Хотя на данный момент у нас есть функциональный график, он выглядит некрасиво. Поскольку мы используем только десять кубов, предложенная линия выглядит очень объемной и дискретной. Она будет выглядеть лучше, если мы будем использовать больше кубов меньшего размера.
2.1 Переменная Resolution
Вместо использования фиксированного количества кубов мы можем сделать его настраиваемым. Чтобы сделать это возможным, добавьте в Graph сериализуемое целочисленное поле для resolution. По умолчанию задайте значение 10, которое мы используем сейчас.
[SerializeField]
Transform pointPrefab;
[SerializeField]
int resolution = 10;
Теперь мы можем настроить разрешение графика, изменив его с помощью инспектора. Однако не все целые числа имеют допустимое разрешение. Как минимум, они должны быть положительными. Мы можем поручить инспектору задать диапазон для нашего разрешения. Это делается путем добавления к нему атрибута Range. Мы могли бы либо заключить оба атрибута разрешения в их собственные квадратные скобки, либо объединить их в один список атрибутов, разделенных запятыми. Давайте сделаем последнее.
[SerializeField, Range]
int resolution = 10;
Инспектор проверяет, привязан ли к полю атрибут Range. Если да, то он ограничит значение, а также покажет ползунок. Однако для этого ему необходимо знать допустимый диапазон. Таким образом, Range требует два аргумента, как метод, для минимального и максимального значения. Давайте используем 10 и 100.
[SerializeField, Range(10, 100)]
int resolution = 10;
2.2 Создание экземпляра переменной
Чтобы использовать настроенное разрешение, мы должны изменить количество создаваемых экземпляров кубов. Вместо циклического выполнения фиксированного количества раз в Awake, количество итераций теперь ограничено разрешением, а не всегда равно 10. Таким образом, если разрешение установлено на 50, то после входа в режим воспроизведения мы получим 50 кубиков.
for (int i = 0; i < resolution; i++) {
…
}
Нам также нужно скорректировать масштаб и расположение кубов, чтобы они оставались в пределах от −1 до 1. Размер каждого шага, который мы должны выполнить за итерацию, теперь равен двум, разделенным на разрешение. Сохраните это значение в переменной и используйте его для вычисления масштаба кубов и их координат по оси X.
float step = 2f / resolution;
var position = Vector3.zero;
var scale = Vector3.one * step;
for (int i = 0; i < resolution; i++) {
Transform point = Instantiate(pointPrefab);
position.x = (i + 0.5f) * step - 1f;
…
}
2.3 Установка родительского элемента
После перехода в режим воспроизведения с разрешением 50 в сцене, а следовательно, и в окне проекта, появляется множество созданных экземпляров кубов.
Эти точки в настоящее время являются корневыми объектами, но для них имеет смысл быть потомками объекта Graph. Мы можем настроить эту связь после создания экземпляра точки, вызвав метод SetParent его компонента Transform, передав ему желаемый родительский Transform. Мы можем получить компонент Transform объекта Graph с помощью свойства transform объекта Graph, которое он унаследовал от Component. Сделайте это в конце блока цикла.
for (int i = 0; i < resolution; i++) {
…
point.SetParent(transform);
}
При установке нового родителя Unity попытается сохранить объект в его первоначальном положении, повороте и масштабе. В нашем случае это не нужно. Мы можем сообщить об этом, передав false в качестве второго аргумента SetParent.
point.SetParent(transform, false);
3. Раскрашиваем график
На белый график не очень приятно смотреть. Мы могли бы использовать другой сплошной цвет, но это тоже не очень интересно. Гораздо интереснее использовать положение точки для определения ее цвета.
Простым способом настроить цвет каждого куба было бы задать свойство color для его материала. Мы можем сделать это в цикле. Поскольку каждый куб будет иметь свой цвет, это означает, что в итоге мы получим один уникальный экземпляр материала для каждого объекта. И когда будем анимировать график, нам также придется постоянно корректировать эти материалы. Хотя это и работает, но не очень эффективно. Было бы намного лучше, если бы мы могли использовать один материал, который напрямую использует позицию в качестве цвета. К сожалению, в Unity нет такого материала. Так что давайте сделаем свой собственный.
3.1 Создание surface shader
Графический процессор запускает шейдерные программы для рендеринга 3D-объектов. Материальные ассеты Unity определяют, какой шейдер используется, и позволяют настраивать его свойства. Чтобы получить желаемую функциональность, нам нужно создать собственный шейдер. Создайте его с помощью Assets / Create / Shader / Standard Surface Shader и назовите его Point Surface.
Теперь у нас есть ассет шейдера, который можно открыть как скрипт. Наш файл шейдера содержит код для определения surface shader, который использует синтаксис, отличный от C#. Он содержит шаблон surface shader, но мы удалим все и начнем с нуля, чтобы создать минимальный шейдер.
Unity имеет свой собственный синтаксис для шейдерных ассетов, который в целом примерно похож на C#, но представляет собой смесь разных языков. Он начинается с ключевого слова Shader, за которым следует строка, определяющая пункт меню для шейдера. Строки заключаются в двойные кавычки. Мы будем использовать Graph/Point Surface. После этого появится блок кода для содержимого шейдеров.
Shader "Graph/Point Surface" {}
Шейдеры могут иметь несколько подшейдеров, каждый из которых определяется ключевым словом SubShader, за которым следует блок кода. Нам нужен только один.
Shader "Graph/Point Surface" {
SubShader {}
}
Ниже подшейдера мы также хотим добавить резервный вариант к стандартному диффузному шейдеру, написав FallBack "Diffuse".
Shader "Graph/Point Surface" {
SubShader {}
FallBack "Diffuse"
}
Подшейдеру шейдера требуется участок кода, написанный на гибриде CG и HLSL, двух шейдерных языков. Этот код должен быть заключен в ключевые слова CGPROGRAM и ENDCG.
SubShader {
CGPROGRAM
ENDCG
}
Первая необходимая инструкция - это директива компилятора, известная как прагма. Она записывается как #pragma, за которой следует директива. В этом случае нам нужно #pragma surface ConfigureSurface Standard fullforwardshadows который предписывает компилятору шейдеров сгенерировать surface shader со стандартным освещением и полной поддержкой теней. ConfigureSurface ссылается на метод, используемый для настройки шейдера, который нам предстоит создать.
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
ENDCG
После этого мы используем директиву #pragma target 3.0, которая устанавливает минимальный целевой уровень и качество шейдера.
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0
ENDCG
Мы собираемся раскрасить наши точки в зависимости от их положения. Чтобы это работало в surface shader, мы должны определить структуру ввода для нашей функции настройки. Она должна быть записана как struct Input, за которой следует блок кода и точка с запятой. Внутри блока мы объявляем одно структурное поле float3 worldPos. Оно будет содержать позицию того, что будет отображаться. Тип float3 является шейдерным эквивалентом структуры Vector3.
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0
struct Input {
float3 worldPos;
};
ENDCG
Ниже мы определяем наш метод ConfigureSurface, хотя в случае с шейдерами он всегда называется функцией, а не методом. Это функция void с двумя параметрами. Первый - входной параметр, имеющий тип Input, который мы только что определили. Второй параметр - это данные конфигурации surface с типом SurfaceOutputStandard.
struct Input {
float3 worldPos;
};
void ConfigureSurface (Input input, SurfaceOutputStandard surface) {}
Второй параметр должен содержать ключевое слово inout, написанное перед его типом, что указывает на то, что оно передается функции и используется для получения результата работы функции.
void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {}
Теперь, когда у нас есть работающий шейдер, создайте для него материал с именем Point Surface. Настройте его на использование нашего шейдера, выбрав Graph / Point Surface из выпадающего списка Shader в заголовке его инспектора.
Сейчас материал имеет сплошной матовый черный цвет. Мы можем сделать его более похожим на материал по умолчанию, установив параметр surface.Smoothness на 0.5 в нашей функции конфигурации. При написании кода шейдера нам не нужно добавлять суффикс f к значениям с плавающей точкой.
void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
surface.Smoothness = 0.5;
}
Теперь материал больше не является идеально матовым. Вы можете увидеть это в небольшом окне предварительного просмотра материала в заголовке инспектора или в окне предварительного просмотра.
Мы также можем настроить сглаживание, как если бы добавили поле для этого и использовали его в функции. По умолчанию перед параметрами конфигурации шейдера ставится знак подчеркивания, а следующая буква пишется заглавной, поэтому мы будем использовать _Smoothness.
float _Smoothness;
void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
surface.Smoothness = _Smoothness;
}
Чтобы этот параметр конфигурации появился в редакторе, мы должны добавить блок Properties в верхней части шейдера, над подшейдером. Напишите там _Smoothness, за которым следует ("Smoothness", Range(0,1)) = 0.5. Это даст ему метку Smoothness, и отобразит в виде ползунка с диапазоном от 0 до 1 и установит значение 0.5 по умолчанию.
Shader "Graph/Point Surface" {
Properties {
_Smoothness ("Smoothness", Range(0,1)) = 0.5
}
SubShader {
…
}
}
Сделайте так, чтобы в нашем ассете префаба Cube использовался этот материал вместо стандартного. В результате точки станут черными.
3.2 Цвет в зависимости от положения
Чтобы настроить цвет наших точек, мы должны изменить surface.Albedo. Поскольку и альбедо, и положение состоят из трех компонентов, мы можем напрямую использовать положение для определения альбедо.
void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
surface.Albedo = input.worldPos;
surface.Smoothness = _Smoothness;
}
Теперь позиция X управляет красной цветовой составляющей точки, позиция Y - зеленой цветовой компонентой, а Z - синей. Но область X на нашем графике равна −1–1, и отрицательные цветовые компоненты не имеют смысла. Поэтому нам нужно уменьшить расположение вдвое, а затем добавить ½, чтобы цвета соответствовали домену. Мы можем сделать это для всех трех измерений сразу.
surface.Albedo = input.worldPos * 0.5 + 0.5;
Чтобы лучше понять, правильно ли подобраны цвета, давайте изменим Graph.Awake так, чтобы отобразить функцию f(x)=x^3, которая также изменяет значение Y с -1 на 1.
position.y = position.x * position.x * position.x;
Результат получается голубоватым, потому что все грани куба имеют координаты Z, близкие к нулю, что устанавливает их синюю составляющую цвета, близкой к 0,5. Мы можем исключить синий цвет, включив только красный и зеленый каналы при настройке альбедо. Это можно сделать в шейдерах, присваивая только surface.Albedo.rg и только с использованием input.worldPos.xy. Таким образом, синяя составляющая остается нулевой.
surface.Albedo.rg = input.worldPos.xy * 0.5 + 0.5;
Так как красный и зеленый дают желтый цвет, то точки начинаются почти с черного в левом нижнем углу и становятся зелеными, когда Y увеличивается быстрее, чем X и желтеют, когда X догоняет. Становятся слегка оранжевыми, когда X увеличивается быстрее, и, наконец, заканчиваются около ярко-желтого в правом верхнем углу.
3.3 Universal Render Pipeline
Помимо стандарного пайплайна рендеринга, в Unity также есть универсальный пайплайн рендеринга и пайплайн рендеринга высокой четкости, сокращенно URP и HDRP. Оба пайплайна рендеринга имеют разные функции и ограничения. Текущий пайплайн рендеринга по умолчанию все еще функционирует, но его набор функций заморожен. Через несколько лет URP, скорее всего, станет стандартом по умолчанию. Итак, давайте сделаем так, чтобы наш график также работал с URP.
Если вы еще не используете URP, перейдите в менеджер пакетов и установите последний пакет Universal RP для вашей версии Unity. В моем случае это 10.4.0.
Это не означает, что Unity автоматически использует URP. Сначала мы должны создать для него ассет с помощью Assets / Create / Rendering / Universal Render Pipeline / Pipeline Asset (Forward Renderer). Я назвал его URP. Это также автоматически создаст еще один ассет для средства визуализации, в моем случае с именем URP_Renderer.
Затем перейдите в раздел Graphics настроек проекта и назначьте ассет URP в поле Scriptable Renderer Pipeline Settings.
Чтобы позже вернуться к пайплайну рендеринга по умолчанию, просто установите для Scriptable Renderer Pipeline Settings значение None. Это можно сделать только в редакторе, пайплайн рендеринга нельзя изменить во встроенном автономном приложении.
3.4 Создаем shader graph
Наш текущий материал работает только с пайплайном рендеринга по умолчанию, а не с URP. Поэтому при использовании URP, он заменяется материалом с ошибкой Unity, который имеет сплошной пурпурный цвет.
Нам нужно создать отдельный шейдер для URP. Мы могли бы написать его сами, но сейчас это очень сложно и, скорее всего, всё сломается при обновлении до более новой версии URP. Лучший подход - использовать графический пакет шейдеров Unity для визуального проектирования шейдера. URP зависит от этого пакета, поэтому он был автоматически установлен вместе с пакетом URP.
Создайте новый shader graph с помощью Assets / Create / Shader / Universal Render Pipeline / Lit Shader Graph и назовите его Point URP.
Шейдер можно открыть, дважды щелкнув по его элементу в окне проекта или нажав кнопку Open Shader Editor в инспекторе. При этом откроется окно shader graph, которое может быть перегружено множеством узлов и панелей. Это доска, инспектор графа и основная панель предварительного просмотра, размер которой можно изменять, а также скрывать с помощью кнопок на панели инструментов. размер которых можно изменять, а также которые можно скрыть с помощью кнопок на панели инструментов. Также есть два связанных узла: узел Vertex и узел Fragment. Эти два узла используются для настройки вывода shader graph.
Shader graph состоит из узлов, представляющих данные или операции. Сейчас значение Smoothness узла Fragment установлено на 0,5. Чтобы сделать его настраиваемым свойством шейдера, нажмите кнопку "плюс" на панели Point URP, выберите Float и назовите новую запись Smoothness. Появится закругленная кнопка, которая представляет это свойство. Выберите её и переключите Graph Inspector на вкладку Node Settings, чтобы увидеть конфигурацию этого свойства.
Reference - это имя, под которым свойство известно внутренне. Это соответствует тому, как мы назвали поле свойства _Smoothness в нашем коде шейдера, поэтому давайте и здесь будем использовать то же внутреннее имя. Затем установите значение по умолчанию под ним равным 0,5. Убедитесь, что включена опция Exposed, поскольку она определяет, будут ли материалы получать свойство шейдера. Наконец, чтобы он отображался как ползунок, измените его Mode с Default на Slider.
Затем перетащите круглую кнопку Smoothness с доски на свободное место на графике. Это добавит узел сглаживания на график. Соедините его со входом Smoothness узла PRB Master, перетащив одну из их точек на другую. Это создаст связь между ними.
Теперь вы можете сохранить график с помощью кнопки на панели инструментов Save Asset и создать материал с именем Point URP, который его использует. В меню шейдера выберите Shader Graphs / Point URP. Затем создайте префаб Point, используя этот материал вместо Point Surface.
3.5 Программирование с помощью узлов
Чтобы раскрасить точки, мы должны начать с узла положения. Создайте его, открыв контекстное меню в пустой части графика и выбрав в нем New Node. Выберите Input / Geometry / Position.
Теперь у нас есть узел позиционирования, который по умолчанию настроен на пространство World. Вы можете свернуть его визуализацию для предварительного просмотра, нажав стрелку вверх, которая появляется при наведении на него курсора.
Используйте тот же подход для создания узлов Multiply и Add. Используйте их, чтобы масштабировать компоненты по X и Y на 0,5, также установив Z равным нулю. Эти узлы адаптируют свои типы входных данных в зависимости от того, к чему они подключены. Поэтому сначала подключите узлы, а затем заполните их постоянные входные данные. Затем соедините полученный результат с Base Color в Fragment.
Вы можете уменьшить визуальный размер узлов Multiply и Add, нажав на стрелку, которая появляется в правом верхнем углу при наведении на них курсора мыши. Так скроются все их входы и выходы, которые не подключены к другому узлу. Это избавлит нас от большого количества беспорядка. Вы также можете удалить компоненты узлов Vertex и Fragment через их контекстное меню. Таким образом, можно скрыть всё, что имеет значения по умолчанию.
После сохранения ассета шейдера мы теперь получаем те же цветные точки в режиме воспроизведения, что и при использовании пайплайна рендеринга по умолчанию. Кроме того, в режиме воспроизведения в отдельной сцене DontDestroyOnLoad появляется средство обновления отладки. Он предназначен для отладки URP и может быть проигнорирован.
С этого момента вы можете использовать либо пайплайн рендеринга по умолчанию, либо URP. После переключения с одного режима на другой вам также придется изменить материал префаба Point, иначе он будет пурпурным. Если вам интересен код шейдера, который генерируется на основе графика, вы можете получить доступ к нему с помощью кнопки View Generated Shader в инспекторе графиков.
4. Анимация графика
Отображение статичного графика полезно, но на движущийся график смотреть интереснее. Итак, давайте добавим поддержку функций анимации. Для этого нужно включить время в качестве дополнительного параметра функции, используя функции вида f(x, t) вместо просто f(x), где t - время.
4.1 Отслеживание точек
Чтобы анимировать график, нам придется корректировать его точки с течением времени. Мы могли бы сделать это, удаляя все точки и создавая новые при каждом обновлении, но это неэффективный способ. Гораздо лучше продолжать использовать одни и те же точки, корректируя их положение при каждом обновлении. Чтобы сделать это возможным, мы собираемся использовать поле для привязки к нашим точкам. Добавьте поле points в Graph типа Transform.
[SerializeField, Range(10, 100)]
int resolution = 10;
Transform points;
Это поле позволяет нам ссылаться на одну точку, но нам нужен доступ ко всем. Мы можем превратить наше поле в массив, поставив пустые квадратные скобки после его типа.
Transform[] points;
Поле points теперь является ссылкой на массив, элементы которого имеют тип Transform. Массивы - это объекты, а не простые значения. Мы должны явно создать такой объект и заставить наше поле ссылаться на него. Это делается путем ввода new, за которым следует тип массива, то есть в нашем случае new Transform[]. Создайте массив в Awake перед нашим циклом и присвойте его points.
points = new Transform[];
for (int i = 0; i < resolution; i++) {
…
}
При создании массива необходимо указать его длину. Она определяет количество элементов, которое нельзя изменить после его создания. Длина указывается в квадратных скобках при создании массива. Пусть она будет равна разрешению графика.
points = new Transform[resolution];
Теперь мы можем заполнить массив ссылками на наши точки. Доступ к элементу массива осуществляется путем записи его индекса в квадратных скобках за ссылкой на массив. Индексы массива начинаются с нуля для первого элемента, точно так же, как счетчик итераций нашего цикла. Таким образом, мы можем использовать его для присвоения соответствующему элементу массива.
points = new Transform[resolution];
for (int i = 0; i < resolution; i++) {
Transform point = Instantiate(pointPrefab);
points[i] = point;
…
}
Если мы присваиваем одно и то же значение несколько раз подряд, мы можем объединить эти присваивания в цепочку, потому что результатом выражения присваивания является то, что было присвоено, как объяснялось в предыдущем руководстве.
Transform point = points[i] = Instantiate(pointPrefab);
//points[i] = point;
Теперь мы перебираем наш массив точек. Поскольку длина массива совпадает с разрешением, мы могли бы также использовать это для ограничения нашего цикла. Для этой цели у каждого массива есть свойство Length, давайте воспользуемся им.
points = new Transform[resolution];
for (int i = 0; i < points.Length; i++) {
…
}
4.2 Обновление точек
Чтобы скорректировать график для каждого кадра, нам нужно задать координаты точек по Y в методе Update. Таким образом, нам больше не нужно вычислять их в Awake. Мы по-прежнему можем задать координаты по X здесь, потому что мы не будем их изменять.
for (int i = 0; i < points.Length; i++) {
Transform point = points[i] = Instantiate(pointPrefab);
position.x = (i + 0.5f) * step - 1f;
//position.y = position.x * position.x * position.x;
…
}
Добавьте метод Update с циклом for, как у Awake, но пока без какого-либо кода в его блоке.
void Awake () {
…
}
void Update () {
for (int i = 0; i < points.Length; i++) {}
}
Мы будем начинать каждую итерацию цикла с получения ссылки на текущий элемент массива и сохранения ее в переменной.
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
}
После этого мы извлекаем локальное положение точки и также сохраняем его в переменной.
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
}
Теперь мы можем задать координату позиции Y на основе X, как мы это делали ранее.
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = position.x * position.x * position.x;
}
Поскольку позиция является структурой, мы всего лишь скорректировали значение локальной переменной. Чтобы применить ее к точке, мы должны снова задать ее положение.
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;
}
4.3 Отображение синусоиды
С этого момента, в режиме воспроизведения, точки на нашем графике меняются местами каждый кадр. Мы пока этого не замечаем, потому что они всегда оказываются в одних и тех же позициях. Нам нужно включить время в функцию, чтобы оно изменилось. Однако простое добавление времени приведет к тому, что функция начнет расти и быстро исчезнет из поля зрения. Чтобы этого не произошло, мы должны использовать функцию, которая изменяется, но остается в пределах фиксированного диапазона. Для этого идеально подходит функция синуса, поэтому мы будем использовать f(x)=sin(x). Для ее вычисления мы можем использовать метод Mathf.Sin.
position.y = Mathf.Sin(position.x);
Синусоида колеблется в диапазоне от -1 до 1. Она повторяется каждые 2π, что означает, что ее период составляет примерно 6,28. Поскольку координаты X на нашем графике находятся в диапазоне от -1 до 1, сейчас мы видим менее трети повторяющегося паттерна. Чтобы увидеть его целиком, увеличьте X на π, чтобы в итоге получить f(x) =sin(πx). Мы можем использовать константу Mathf.PI в качестве аппроксимации π.
position.y = Mathf.Sin(Mathf.PI * position.x);
Чтобы анимировать эту функцию, добавьте текущее игровое время к X перед вычислением функции синуса. Его можно найти с помощью Time.time. Если мы также увеличим время на π, функция будет повторяться каждые две секунды. Поэтому используйте f(x,t)=sin(π(x+t)), где t - затраченное игровое время. Это приведет к увеличению синусоиды с течением времени, смещая ее в отрицательном направлении по оси X.
Поскольку значение Time.time одинаково для каждой итерации цикла, мы можем вынести вызов функции за его пределы.
float time = Time.time;
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = Mathf.Sin(Mathf.PI * (position.x + time));
point.localPosition = position;
}
4.4 Фиксирование цветов
Амплитуда синусоиды равна 1, а это значит, что минимальное и максимальное положения, которых достигают наши точки, равны -1 и 1. Однако, поскольку точки представляют собой кубы, они немного выходят за пределы этого диапазона. Таким образом, мы можем получить цвета с отрицательными или превышающими 1 компонентами зеленого цвета. Хоть это и не заметно, давайте будем корректны и ограничим цвета, чтобы они оставались в диапазоне 0-1.
Мы можем сделать это для нашего шейдера, передав сгенерированный цвет через функцию saturate. Это специальная функция, которая привязывает все компоненты к 0-1. Это обычная операция в шейдерах, известная как saturation, отсюда и ее название.
surface.Albedo.rg = saturate(input.worldPos.xy * 0.5 + 0.5);
То же самое можно сделать в shader graph с помощью узла Saturate.