Найти в Дзене
Joseph & Adolf

Unity Basics. Математические поверхности

Оглавление

Моделирование с помощью чисел

В этой статье:

  • Создание библиотеки функций.
  • Использование делегата и перечисляемого типа.
  • Отображение двумерной функции с помощью сетки.
  • Определение поверхности в трёхмерном пространстве.

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

Руководство создано с использованием Unity 2020.3.6f1.

Комбинация из нескольких волн создаст сложную поверхность.
Комбинация из нескольких волн создаст сложную поверхность.

1. Библиотека функций

После завершения предыдущего урока у нас появился точечный график, на котором в режиме воспроизведения отображается анимированная синусоида. Также можно отображать другие математические функции. Вы можете изменить код, и функция изменится вместе с ним. Вы можете сделать это, даже когда редактор Unity находится в режиме воспроизведения. Выполнение будет приостановлено, текущее состояние сохранено, затем скрипты будут скомпилированы заново, и, наконец, состояние будет перезагружено, и всё возобновится. Это называется горячей перезагрузкой (hot reload). Не всё выдерживает горячую перезагрузку, но наш график выдержит. Он переключится на анимацию новой функции, даже не заметив, что что-то изменилось.

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

1.1 Библиотечный класс

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

Создайте новый C# скрипт FunctionLibrary и поместите его в папку Scripts рядом с Graph. Вы можете либо использовать пункт меню для создания нового ассета, либо дублировать и переименовать Graph. В любом случае очистите содержимое файла и начните с использования UnityEngine и объявления пустого класса FunctionLibrary, который ничего не расширяет.

using UnityEngine;
public class FunctionLibrary {}

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

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

public static class FunctionLibrary {}

1.2 Метод для функции

Нашей первой функцией будет та же синусоида, которую сейчас показывает Graph. Нам нужно создать для нее метод. Это работает так же, как создание метода Awake или Update, за исключением того, что мы назовем его Wave.

public static class FunctionLibrary {

void Wave () {}
}

По умолчанию методы являются методами экземпляра, что означает, что они должны вызываться в экземпляре объекта. Чтобы заставить их работать непосредственно на уровне класса, мы должны пометить их как статические, как и саму FunctionLibrary.

static void Wave () {}

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

public static void Wave () {}

Этот метод будет представлять нашу математическую функцию f(x,t)=sin(π(x+t)). Это означает, что он должен выдавать результат, который должен быть числом с плавающей точкой. Поэтому вместо void тип возвращаемого значения функции должен быть float.

public static float Wave () {}

Затем мы должны добавить два параметра в список параметров метода, точно так же, как и для математической функции. Единственное отличие заключается в том, что перед каждым параметром мы должны указать тип, который является float.

public static float Wave (float x, float t) {}

Теперь мы можем поместить код, который вычисляет синусоиду, внутрь метода, используя его параметры x и t.

public static float Wave (float x, float t) {
Mathf.Sin(Mathf.PI * (x + t));
}

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

public static float Wave (float x, float t) {
return
Mathf.Sin(Mathf.PI * (x + t));
}

Теперь этот метод можно вызвать внутри Graph.Update, используя position.x и time в качестве аргументов для его параметров. Его результат можно использовать для задания координат точки по оси Y вместо явного математического уравнения.

void Update () {
float time =
Time.time;
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = FunctionLibrary.Wave(position.x, time);
point.localPosition = position;
}
}

1.3 Неявное использование типа

Мы будем использовать Mathf.PI, Mathf.Sin и другие методы из Mathf, которые часто используются в FunctionLibrary. Было бы неплохо, если бы мы могли писать их без необходимости постоянно явно указывать тип. Мы можем сделать это возможным, добавив еще одну инструкцию using в начало файла FunctionLibrary с дополнительным ключевым словом static, за которым следует явный тип UnityEngine.Mathf. Это позволит нам использовать все постоянные и статические члены типа без явного указания самого типа.

using UnityEngine;

using static UnityEngine.
Mathf;
public static class FunctionLibrary { … }

Теперь мы можем сократить код в Wave, опустив Mathf.

public static float Wave (float x, float z, float t) {
return Sin(PI * (x + t));
}

1.4 Вторая функция

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

public static float Wave (float x, float t) {
return Sin(PI * (x + t));
}

public static float MultiWave (float x, float t) {
return Sin(PI * (x + t));
}

Мы сохраним функцию синуса, которая у нас уже есть, но добавим к ней кое-что ещё. Чтобы упростить задачу, присвойте текущий результат переменной y, перед return.

public static float MultiWave (float x, float t) {
float y = Sin(PI * (x + t));
return y;
}

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

float y = Sin(PI * (x + t));
y += Sin(2f * PI * (x + t)) / 2f;
return y;

Это дает нам математическую функцию f(x,t)=sin(π(x+t))+sin(2π(x+t))/2. Поскольку положительные и отрицательные экстремумы функции синуса равны 1 и -1, максимальное и минимальное значения этой новой функции могут быть 1,5 и -1,5. Чтобы гарантировать, что мы останемся в диапазоне от -1 до 1, мы должны разделить сумму на 1,5.

return y / 1.5f;

Для деления требуется немного больше усилий, чем для умножения, поэтому, как правило, предпочтение отдается умножению, а не делению. Однако, такие постоянные выражения, как 1f / 2f, а также 2f * Mathf.PI уже сокращены компилятором до одного числа. Таким образом, мы могли бы переписать наш код, чтобы использовать умножение только во время выполнения. Мы должны убедиться, что сначала уменьшаются постоянные части, используя порядок операций и скобки.

y += Sin(2f * PI * (x + t)) * (1f / 2f);
return y * (2f / 3f);

Мы также можем напрямую записать 0.5f вместо 1f / 2f, но значение, обратное 1.5, не может быть точно записано в десятичной системе счисления, поэтому мы продолжим использовать 2f / 3f, которое компилятор с максимальной точностью преобразует в число с плавающей точкой.

y += 0.5f * Sin(2f * PI * (x + t));

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

position.y = FunctionLibrary.MultiWave(position.x, time);

Сумма двух синусоид.
Сумма двух синусоид.

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

float y = Sin(PI * (x + 0.5f * t));
y += 0.5f * Sin(2f * PI * (x + t));
Трансформирующаяся волна.
Трансформирующаяся волна.

1.5 Выбор функций в редакторе

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

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

[
SerializeField, Range(0, 1)]
int function;
Ползунок Function
Ползунок Function

Теперь мы можем проверить function внутри цикла Update. Если она равна нулю, то на графике должна отображаться Wave. Чтобы сделать этот выбор, мы будем использовать оператор if, за которым следует выражение и блок кода. Это работает аналогично while, за исключением того, что не выполняется обратный цикл, поэтому блок либо выполняется, либо пропускается. В этом случае проверяется, равна ли функция нулю, что можно сделать с помощью оператора равенства ==.

void Update () {
float time =
Time.time;
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
if (function == 0) {
position.y = FunctionLibrary.Wave(position.x, time);
}
point.localPosition = position;
}
}

За блоком if мы можем добавить else и еще один блок, который будет выполнен, если тест не пройден. В этом случае на графике вместо этого должна отображаться MultiWave.

if (function == 0) {
position.y = FunctionLibrary.Wave(position.x, time);
}
else {
position.y = FunctionLibrary.MultiWave(position.x, time);
}

Это позволяет управлять функцией с помощью инспектора Graph's, в том числе и в режиме воспроизведения.

1.6 Функция Ripple

Давайте добавим в нашу библиотеку третью функцию, которая создает эффект, подобный колебаниям. Создав её, мы заставим синусоиду отклоняться от начала координат, вместо того чтобы всегда двигаться в одном направлении. Мы можем сделать это, основываясь на расстоянии от центра, которое является абсолютной величиной X. Начните с вычисления только этого значения с помощью Mathf.Abs, в новом методе FunctionLibrary.Ripple. Сохраните значение расстояния в переменной d и затем верните его.

public static float Ripple (float x, float t) {
float d = Abs(x);
return d;
}

Чтобы показать это, увеличьте диапазон Graph.function до 2 и добавьте еще один блок для метода Wave в Update. Мы можем связать несколько условных блоков, записав другой if непосредственно после else, чтобы он стал блоком else-if, который должен выполняться, когда функция равна 1. Затем добавьте новый блок else для колебаний.

[SerializeField, Range(0, 2)]


void
Update () {
float time =
Time.time;
for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
if (function == 0) {
position.y = FunctionLibrary.Wave(position.x, time);
}
else if (function == 1) {
position.y = FunctionLibrary.MultiWave(position.x, time);
}
else {
position.y = FunctionLibrary.Ripple(position.x, time);
}
point.localPosition = position;
}
}
Абсолютный X.
Абсолютный X.

Возвращаясь к FunctionLibrary.Ripple, мы используем расстояние в качестве входных данных для функции синуса и получаем из этого результат. В частности, мы будем использовать y=sin(4πd) при d=|x|, чтобы колебания многократно увеличивались и уменьшались в области графика.

public static float Ripple (float x, float t) {
float d = Abs(x);
float y = Sin(4f * PI * d);
return y;
}
Синус на расстоянии.
Синус на расстоянии.

Визуально результат трудно интерпретировать, потому что Y слишком сильно меняется. Мы можем уменьшить это значение, уменьшив амплитуду волны. Но колебания не имеют фиксированной амплитуды, они уменьшаются с расстоянием. Итак, давайте преобразуем нашу функцию в y=sin(4πd)/(1+10d).

float y = Sin(4f * PI * d);
return y / (1f + 10f * d);

Последний штрих - анимация колебаний. Чтобы заставить двигаться её наружу, мы должны вычесть время из значения, которое мы передаем в функцию синуса. Давайте используем πt, чтобы конечная функция стала y=sin(π(4d−t))/(1+10d).

float y = Sin(PI * (4f * d - t));
return y / (1f + 10f * d);
Анимированные колебания.
Анимированные колебания.

2. Управление методами

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

2.1 Делегаты

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

Чтобы создать тип делегата, продублируйте функцию
Wave, переименуйте её в Function и замените блок кода точкой с запятой. Это определяет сигнатуру метода без реализации. Затем мы преобразуем его в тип delegate, заменив ключевое слово static на delegate.

public static class FunctionLibrary {

public delegate float Function (float x, float t);


}

Теперь мы можем добавить метод GetFunction, который возвращает функцию Function с заданным параметром index, используя ту же логику if-else, которую мы использовали в цикле, за исключением того, что в каждом блоке мы возвращаем соответствующий метод вместо его вызова.

public delegate float Function (float x, float t);

public static Function GetFunction (int index) {
if (index == 0) {
return Wave;
}
else if (index == 1) {
return MultiWave;
}
else {
return Ripple;
}
}

Далее мы используем этот метод, чтобы получить делегат функции в начале Graph.Update на основе function и сохраняем его в переменной. Поскольку этот код не находится внутри FunctionLibrary, мы должны ссылаться на вложенный тип делегата как FunctionLibrary.Function.

void Update () {
FunctionLibrary.Function f = FunctionLibrary.GetFunction(function);

}

Затем вызовите переменную делегата вместо явного метода в цикле.

for (int i = 0; i < points.Length; i++) {
Transform point = points[i];
Vector3 position = point.localPosition;
//if (function == 0) { position.y = FunctionLibrary.Wave(position.x,time); }
//else if (function == 1) { position.y = FunctionLibrary.MultiWave(position.x, time); }
//else { position.y = FunctionLibrary.Ripple(position.x, time); }
position.y = f(position.x, time);
point.localPosition = position;
}

2.2 Массив делегатов

Мы значительно упростили Graph.Update, но мы только перенесли код if-else в FunctionLibrary.GetFunction. Можно полностью избавиться от этого кода, заменив его индексацией массива. Начнём с добавления статического поля для массива functions в FunctionLibrary. Этот массив предназначен только для внутреннего использования, поэтому не делайте его общедоступным.

public delegate float Function (float x, float t);
static Function[] functions;
public static Function GetFunction (int index) { … }

Мы всегда будем помещать в этот массив одни и те же элементы, поэтому можно явно определить его содержимое как часть его объявления. Это делается путем указания последовательности элементов массива, разделенных запятыми, в фигурных скобках. Самый простой способ - это пустой список.

static Function[] functions = {};

Это означает, что мы сразу получаем экземпляр массива, но он пуст. Измените его так, чтобы он содержал делегаты для нашего метода в том же порядке, что и раньше.

static Function[] functions = { Wave, MultiWave, Ripple };

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

public static Function GetFunction (int index) {
return functions[index];
}

2.3 Перечисления

Ползунок с целыми числами работает, но неочевидно, что 0 представляет функцию Wave и т.д. Было бы понятнее, если бы у нас был выпадающий список, содержащий названия функций. Для достижения этой цели мы можем использовать перечисление.

Перечисления могут быть созданы путем определения типа enum. Мы снова сделаем это внутри
FunctionLibrary, на этот раз назвав его FunctionName. В этом случае за именем типа следует список меток в фигурных скобках. Мы можем использовать копию списка элементов массива, но без точки с запятой. Обратите внимание, что это простые метки, они ни на что не ссылаются, хотя и подчиняются тем же правилам, что и имена типов. Мы несем ответственность за то, чтобы эти два списка были идентичными.

public delegate float Function (float x, float t);
public enum FunctionName { Wave, MultiWave, Ripple }
static Function[] functions = { Wave, MultiWave, Ripple };

Теперь замените параметр index в GetFunction параметром name типа functionName. Это означает, что в качестве аргумента должно быть допустимое имя функции.

public static Function GetFunction (FunctionName name) {
return functions[name];
}

Перечисления можно считать синтаксическим сахаром. По умолчанию каждая метка перечисления представляет собой целое число. Первая метка соответствует 0, вторая - 1 и так далее. Таким образом, мы можем использовать name для индексации массива. Однако компилятор будет жаловаться, что перечисление не может быть неявно преобразовано в целое число. Мы должны явно выполнить это преобразование.

return functions[(int)name];

Последний шаг - изменить тип поля Graph.function на FunctionLibrary.FunctionName и удалить его атрибут Range.

//[SerializeField, Range(0, 2)]
[
SerializeField]
FunctionLibrary.FunctionName function;

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

Выпадающий список функций.
Выпадающий список функций.

3. Добавляем еще одну размерность пространства

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

Сейчас мы используем размерность пространства X в качестве входного параметра для наших функций. Размерность пространства Y используется для отображения выходных данных. Остаётся Z как вторая размерность пространства для ввода. Добавление Z в качестве входного параметра преобразует нашу линию в квадратную сетку.

3.1 Трёхмерные цвета

Поскольку Z больше не является постоянной величиной, изменим наш шейдер Point Surface, чтобы он также изменял компонент синего альбедо, удалив код .rg и .xy из присвоения.

surface.Albedo = saturate(input.worldPos * 0.5 + 0.5);

И настроим наш Point URP shader graph так, чтобы Z использовался так же, как X и Y.

Настроенные входные параметры узлов Multiply и Add.
Настроенные входные параметры узлов Multiply и Add.

3.2 Модернизация функций

Чтобы поддерживать второй вход, не зависящий от времени, для наших функций, добавьте параметр z после параметра x типа delegate в FunctionLibrary.Function.

public delegate float Function (float x, float z, float t);

Теперь от нас потребуется добавить параметр к нашим трем методам.

public static float Wave (float x, float z, float t) { … }

public static float MultiWave (float x, float z, float t) { … }

public static float Ripple (float x, float z, float t) { … }

А также добавить position.z в качестве аргумента при вызове функции в Graph.Update.

position.y = f(position.x, position.z, time);

3.3 Создание сетки точек

Чтобы отобразить размерность пространства Z, мы должны превратить нашу линию точек в сетку точек. Можно сделать это, создав несколько линий, каждая из которых смещена на один шаг вдоль Z. Будем использовать тот же диапазон для Z, что и для X, поэтому создадим столько линий, сколько точек у нас есть на данный момент. Это означает, что нам нужно возвести количество точек в квадрат. Настройте создание массива points в Awake таким образом, чтобы он был достаточно большим, чтобы вместить все точки.

points = new Transform[resolution * resolution];

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

Длинная линия из 2500 точек.
Длинная линия из 2500 точек.

Во-первых, давайте отслеживать координату X в явном виде. Для этого объявим и увеличим переменную x внутри цикла for, а также переменную-итератор i. Для этого третью секцию оператора for можно превратить в список, разделенный запятыми.

points = new Transform[resolution * resolution];
for (int i = 0, x = 0; i < points.Length; i++, x++) {

}

Каждый раз, когда мы заканчиваем строку, мы должны возвращать значение x обратно в ноль. Строка завершается, когда значение x становится равным resolution, поэтому мы можем использовать блок if в верхней части цикла, чтобы позаботиться об этом. Затем используем x вместо i для вычисления координаты X.

for (int i = 0, x = 0; i < points.Length; i++, x++) {
if (x == resolution) {
x = 0;
}
Transform point = points[i] = Instantiate(pointPrefab);
position.x = (x + 0.5f) * step - 1f;

}

Затем каждая строка должна быть смещена по оси Z. Это можно сделать, добавив переменную z в цикл for. Эта переменная не должна увеличиваться на каждой итерации. Вместо этого она увеличивается только при переходе к следующей строке, для которой у нас уже есть блок if. Затем зададим координату Z позиции точно так же, как и ее координату X, используя z вместо x.

for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
if (x == resolution) {
x = 0;
z += 1;
}
Transform point = points[i] = Instantiate(pointPrefab);
position.x = (x + 0.5f) * step - 1f;
position.z = (z + 0.5f) * step - 1f;

}

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

График-сетка.
График-сетка.

3.4 Улучшение визуальных эффектов

Поскольку наш график теперь трехмерный, я буду рассматривать его с точки зрения перспективы, используя игровое окно. Чтобы быстро выбрать удобное положение камеры, вы можете найти удобную точку обзора в окне сцены в режиме воспроизведения, выйти из режима воспроизведения и затем настроить камеру игры в соответствии с точкой обзора. Это можно сделать с помощью GameObject / Align With View, выбрав Main Camera и открыв окно сцены. Я сделал так, чтобы он был примерно направлен вниз по диагонали XZ. Затем я изменил угол поворота направленного света по оси Y с -30 на 30, чтобы улучшить освещение для этого угла обзора.

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

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

Настройки качества
Настройки качества

Мы можем еще больше улучшить производительность и чёткость теней, перейдя в раздел Shadows, уменьшив Shadow Distance до 10 и установив для Shadow Cascades значение No Cascades. Настройки по умолчанию рендерят тени четыре раза, что для нас слишком.

URP не использует эти настройки, вместо этого его тени настраиваются с помощью инспектора нашего ассета URP. По умолчанию он уже рендерит направленные тени только один раз, но Shadows / Max Distance можно уменьшить до 10. Кроме того, чтобы обеспечить стандартное качество Ultra в пайплайн рендеринга по умолчанию, включите функцию Shadows / Soft Shadows и увеличьте Lighting / Main Light / Shadow Resolution в разделе Lighting до 4096.

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

VSync включен для игрового окна.
VSync включен для игрового окна.

3.5 Включение Z

Самый простой способ использовать Z в функции Wave - это использовать сумму как X, так и Z. Это создаст диагональную волну.

public static float Wave (float x, float z, float t) {
return Sin(PI * (x + z + t));
}
Диагональная волна.
Диагональная волна.

И самое простое изменение для MultiWave - заставить каждую волну использовать отдельную размерность пространства. Давайте сделаем так, чтобы меньшая волна использовала Z.

public static float MultiWave (float x, float z, float t) {
float y = Sin(PI * (x + 0.5f * t));
y += 0.5f * Sin(2f * PI * (z + t));
return y * (2f / 3f);
}
Две волны с разными размерностями пространства.
Две волны с разными размерностями пространства.

Мы также можем добавить третью волну, которая проходит по диагонали XZ. Давайте используем ту же волну, что и Wave, только с замедлением времени до четверти. Затем масштабируем результат, разделив на 2,5, чтобы сохранить его в диапазоне от −1 до 1.

float y = Sin(PI * (x + 0.5f * t));
y += 0.5f * Sin(2f * PI * (z + t));
y += Sin(PI * (x + z + 0.25f * t));
return y * (1f / 2.5f);

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

Тройная волна.
Тройная волна.

Наконец, чтобы колебания распространились во всех направлениях на плоскости XZ, мы должны рассчитать расстояние в обеих размерностях пространства. Для этого мы можем использовать теорему Пифагора с помощью метода Mathf.Sqrt.

public static float Ripple (float x, float z, float t) {
float d = Sqrt(x * x + z * z);
float y = Sin(PI * (4f * d - t));
return y / (1f + 10f * d);
}
Колебания на плоскости XZ.
Колебания на плоскости XZ.

4. Выход из сетки

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

4.1 Трехмерные функции

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

Поскольку входные параметры для этих функций больше не должны соответствовать конечным координатам X и Z, называть их x и z больше нецелесообразно. Вместо этого они используются для создания параметрической поверхности и часто называются u и v.

Таким образом, мы получим функцию такого типа
Таким образом, мы получим функцию такого типа

Измените тип нашего делегата Function для нового подхода. Единственное необходимое изменение - это замена возвращаемого типа float на Vector3, но давайте также переименуем его параметры.

public delegate Vector3 Function (float u, float v, float t);

Нам также необходимо соответствующим образом настроить методы наших функций. Мы просто будем использовать U и V непосредственно для X и Z. Нет необходимости изменять имена параметров — только их типы должны соответствовать делегату, но мы сделаем это, чтобы оставаться последовательными.

Начнём с Wave. Пусть он сначала объявит переменную
Vector3, затем задаст ее компоненты, а затем вернет ее. Нам не нужно присваивать вектору начальное значение, потому что мы задаем все его поля перед возвратом.

public static Vector3 Wave (float u, float v, float t) {
Vector3 p;
p.x = u;
p.y = Sin(PI * (u + v + t));
p.z = v;
return p;
}

Затем сделаем то же самое с MultiWave и Ripple.

public static Vector3 MultiWave (float u, float v, float t) {
Vector3 p;
p.x = u;
p.y = Sin(PI * (u + 0.5f * t));
p.y += 0.5f * Sin(2f * PI * (v + t));
p.y += Sin(PI * (u + v + 0.25f * t));
p.y *= 1f / 2.5f;
p.z = v;
return p;
}

public static
Vector3 Ripple (float u, float v, float t) {
float d = Sqrt(u * u + v * v);
Vector3 p;
p.x = u;
p.y = Sin(PI * (4f * d - t));
p.y /= 1f + 10f * d;
p.z = v;
return p;
}

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

void Update () {
FunctionLibrary.Function f = FunctionLibrary.GetFunction(function);
float time =
Time.time;
float step = 2f / resolution;
for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
if (x == resolution) {
x = 0;
z += 1;
}
float u = (x + 0.5f) * step - 1f;
float v = (z + 0.5f) * step - 1f;
points[i].localPosition = f(u, v, time);
}
}

Обратите внимание, что нам нужно пересчитывать значение v только при изменении z. Для этого нам необходимо установить его начальное значение перед началом цикла.

float v = 0.5f * step - 1f;
for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
if (x == resolution) {
x = 0;
z += 1;
v = (z + 0.5f) * step - 1f;
}
float u = (x + 0.5f) * step - 1f;
//float v = (z + 0.5f) * step - 1f;
points[i].localPosition = f(u, v, time);
}

Также обратите внимание, что, поскольку Update теперь использует resolution, его изменение в режиме воспроизведения приведет к деформации графика, растягивая или сжимая сетку в прямоугольник.

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

void Awake () {
float step = 2f / resolution;
var scale =
Vector3.one * step;
//var position = Vector3.zero;
points = new
Transform[resolution * resolution];
for (int i = 0; i < points.Length; i++) {
//if (x == resolution) {
//x = 0;
//z += 1;
//}
Transform point = points[i] = Instantiate(pointPrefab);
//position.x = (x + 0.5f) * step - 1f;
//position.z = (z + 0.5f) * step - 1f;
//point.localPosition = position;
point.localScale = scale;
point.SetParent(transform, false);
}
}

4.2 Создание сферы

Чтобы продемонстрировать, что мы действительно больше не ограничены одной точкой на пару координат (X, Z), давайте создадим функцию определяющую сферу. Для этой цели добавьте метод Sphere в FunctionLibrary. Также добавьте запись для него в перечисление functionName и массив functions. Начнём с того, что мы всегда возвращаем точку в начало координат.

public enum FunctionName { Wave, MultiWave, Ripple, Sphere }

static Function[] functions = { Wave, MultiWave, Ripple, Sphere };



public static
Vector3 Sphere (float u, float v, float t) {
Vector3 p;
p.x = 0f;
p.y = 0f;
p.z = 0f;
return p;
}

Первым шагом к созданию сферы является описание окружности, лежащей плашмя в плоскости XZ.

p.x = Sin(PI * u);
p.y = 0f;
p.z = Cos(PI * u);
Окружность.
Окружность.

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

p.x = Sin(PI * u);
p.y = v;
p.z = Cos(PI * u);
Цилиндр.
Цилиндр.

Мы можем изменить радиус цилиндра, увеличив значения X и Z на некоторое значение r. Если мы используем r=cos(π*v/2), то верхняя и нижняя части цилиндра будут сведены в одну точку.

float r = Cos(0.5f * PI * v);
Vector3 p;
p.x = r * Sin(PI * u);
p.y = v;
p.z = r * Cos(PI * u);
Цилиндр с уменьшающимся радиусом.
Цилиндр с уменьшающимся радиусом.

Это приближает нас к сфере, но уменьшение радиуса цилиндра еще не делает его круглым. Это потому, что окружность образуется с помощью синуса и косинуса, сейчас мы используем только косинус для определения радиуса. Другой частью уравнения является Y, который по-прежнему равен v. Чтобы завершить окружность, мы должны использовать y=sin(π*v/2).

p.y = Sin(PI * 0.5f * v);
Сфера.
Сфера.

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

4.3 Изменение сферы

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

Используем эту функцию, где s=rcos(π*v/2), r - радиус.
Используем эту функцию, где s=rcos(π*v/2), r - радиус.

Это позволит нам изменять радиус. Например, можно анимировать его, используя r=(1+sin(πt))/2, чтобы масштабировать в зависимости от времени.

float r = 0.5f + 0.5f * Sin(PI * t);
float s = r * Cos(0.5f * PI * v);
Vector3 p;
p.x = s * Sin(PI * u);
p.y = r * Sin(0.5f * PI * v);
p.z = s * Cos(PI * u);
Масштабируемая сфера.
Масштабируемая сфера.

Нам не обязательно использовать одинаковый радиус. Мы могли бы варьировать его в зависимости от u, например, r=(9+sin(8πu))/10.

float r = 0.9f + 0.1f * Sin(8f * PI * u);
Сфера с вертикальными полосами; resolution 100.
Сфера с вертикальными полосами; resolution 100.

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

float r = 0.9f + 0.1f * Sin(8f * PI * v);
Сфера с горизонтальными полосами.
Сфера с горизонтальными полосами.

Используя оба варианта, мы получим крутящиеся полосы. Добавим время, чтобы они вращались, и получим r = (9+sin(π(6u+4v+t)))/10.

float r = 0.9f + 0.1f * Sin(PI * (6f * u + 4f * v + t));
Вращающаяся скрученная сфера.
Вращающаяся скрученная сфера.

4.4 Создаем тороид

В завершение добавим поверхность тороида в FunctionLibrary. Продублируем Sphere, переименуем ее в Torus и установим радиус равным 1. Также обновим имена и массив функций.

public enum FunctionName { Wave, MultiWave, Ripple, Sphere, Torus }

static Function[] functions = { Wave, MultiWave, Ripple, Sphere, Torus };



public static
Vector3 Torus (float u, float v, float t) {
float r = 1f;
float s = r * Cos(0.5f * PI * v);
Vector3 p;
p.x = s * Sin(PI * u);
p.y = r * Sin(0.5f * PI * v);
p.z = s * Cos(PI * u);
return p;
}

Мы можем превратить нашу сферу в тороид, оттянув вертикальные полуокружности друг от друга и превратив их в полные окружности. Начнем с того, что перейдем к s=1/2+rcos(π*v/2).

float s = 0.5f + r * Cos(0.5f * PI * v);
Сфера раздвинулась.
Сфера раздвинулась.

Это даст нам половину тороида, при этом учитывается только внешняя часть его кольца. Чтобы завершить построение, мы должны использовать v для описания всей окружности, а не половины. Это можно сделать, используя πv вместо πv/2 в s и y.

float s = 0.5f + r * Cos(PI * v);
Vector3 p;
p.x = s * Sin(PI * u);
p.y = r * Sin(PI * v);
p.z = s * Cos(PI * u);
Самопересекающийся веретенообразный тороид.
Самопересекающийся веретенообразный тороид.

Поскольку мы раздвинули сферу на половину, получилась самопересекающаяся форма, известная как веретенообразный тороид. Если бы вместо этого мы раздвинули сферу на единицу, то получили бы тороид, который не пересекается сам с собой, но и не имеет отверстий, который известен как конусообразный тороид. То, насколько мы раздвинем сферу, влияет на форму тороида. В частности, он определяет главный радиус тороида. Другой радиус является малым и определяет толщину кольца. Давайте определим главный радиус как r1, а другой - как r2, таким образом, s=r2cos(πv)+r1. Затем используем 0,75 для главного и 0,25 для малого радиуса, чтобы точки оставались в диапазоне от −1 до 1.

//float r = 1f;
float r1 = 0.75f;
float r2 = 0.25f;
float s = r1 + r2 * Cos(PI * v);
Vector3 p;
p.x = s * Sin(PI * u);
p.y = r2 * Sin(PI * v);
p.z = s * Cos(PI * u);
Кольцевой тороид.
Кольцевой тороид.

Теперь у нас есть два радиуса, с которыми можно поиграть. Например, мы можем превратить его во вращающуюся звезду, используя r1=(7+sin(π(6u+t/2)))/10, а также закрутить кольцо, используя r2=(3+sin(π(8u+4v+2t)))/20.

float r1 = 0.7f + 0.1f * Sin(PI * (6f * u + 0.5f * t));
float r2 = 0.15f + 0.05f * Sin(PI * (8f * u + 4f * v + 2f * t));
Скручивающийся тороид.
Скручивающийся тороид.

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