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

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

Переключение между функциями.

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

Метод функции.

Чтобы наш график поддерживал несколько функций одновременно, мы должны запрограммировать в нем все функции. Однако коду, рассчитывающий каждую точку графика, не важно какая функция используется. Нам не нужно повторять этот код для каждой отдельной функции. Вместо этого мы выберем часть математической функции и поместим ее в свой собственный метод.
Добавьте новый метод в Graph, который будет содержать код для нашей синусоиды. Это работает так же, как создание методов Awake и Update, за исключением того, что мы назовем эту SineFunction.
void SineFunction () {}
Эта функция будет представлять нашу математическую функцию f (x, t) = sin (π (x + t)). Для этого он должен вернуть число с плавающей запятой. Поэтому вместо void тип функции должен быть float.
float SineFunction () {}
Функция также нуждается в параметрах. В настоящее время она содержит пустой список параметров. Чтобы добавить параметр x, поместите его в круглые скобки после имени метода. Точно так же, как и для самого метода, его параметры имеют свой тип, написанный перед ними. Поскольку мы работаем с числами с плавающей запятой, мы снова должны использовать float.
float SineFunction (float x) {}
Добавьте параметр t, а также его тип. Объявления параметров должны разделяться запятой.
float SineFunction (float x, float t) {}
Теперь мы можем поместить код, который вычисляет функцию внутри метода, используя его параметры x и t.
float SineFunction (float x, float t) {
  Mathf.Sin(Mathf.PI * (x + t));
 }
Последний шаг - явно указать, каков результат метода. Поскольку это метод float, он должен вернуть float. Мы указываем это, записывая return, затем следует результат, который является нашим математическим вычислением.
float SineFunction (float x, float t) {
  return Mathf.Sin(Mathf.PI * (x + t));
 }
Теперь можно вызвать этот метод внутри Update, используя position.x и Time.time в качестве аргументов для его параметров. Его результат может быть использован для установки координаты Y точки, вместо использования явной математики.
void Update () {
  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.time));
   position.y = SineFunction(position.x, Time.time);
   point.localPosition = position;
  }
 }
Обратите внимание, что Time.time одинаково каждый раз, когда мы вызываем это свойство внутри цикла. Мы можем справиться с извлечением его значения только один раз, перед циклом, сохраняя его в переменной.
void Update () {
  float t = Time.time;
  for (int i = 0; i < points.Length; i++) {
   Transform point = points[i];
   Vector3 position = point.localPosition;
   position.y = SineFunction(position.x, t);
   point.localPosition = position;
  }
 }

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

Теперь, когда у нас есть метод функции, давайте сделаем еще один. На этот раз мы сделаем несколько более сложную функцию, используя более одного синуса. Начните с дублирования метода SineFunction и переименуйте новый в MultiSineFunction.
float SineFunction (float x, float t) {
  return Mathf.Sin(Mathf.PI * (x + t));
 }
 
 float MultiSineFunction (float x, float t) {
  return Mathf.Sin(Mathf.PI * (x + t));
 }
Мы сохраним функцию синуса, которая у нас уже есть, но добавим к ней что-то дополнительное. Чтобы сделать это проще, назначьте текущий результат переменной y перед ее возвратом.
float MultiSineFunction (float x, float t) {
  float y = Mathf.Sin(Mathf.PI * (x + t));
  return y;
 }
Самый простой способ добавить большую сложность к синусоидальной волне - добавить еще одну, с удвоенной частотой. Это означает, что он изменяется в два раза быстрее, что делается путем умножения аргумента синусоиды на 2. В то же время мы вдвое уменьшим результат этой функции. Это сохраняет форму синусоиды одинаковой, но уменьшает амплитуду вдвое.
float y = Mathf.Sin(Mathf.PI * (x + t));
  y += Mathf.Sin(2f * Mathf.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, что равнозначно умножению на 2/3.
float y = Mathf.Sin(Mathf.PI * (x + t));
  y += Mathf.Sin(2f * Mathf.PI * (x + t)) / 2f;
  y *= 2f / 3f;
  return y;
Давайте используем эту функцию вместо SineFunction in Update и посмотрим, как она выглядит.
position.y = MultiSineFunction(position.x, t);
Можно сказать, что меньшая синусоидальная волна теперь следует за большей синусоидальной волной. Мы могли бы даже сделать меньшую смещающуюся вдоль большей волны, например, удвоив ее коэффициент времени. Результатом будет функция, которая не просто скользит с течением времени, она меняет свою форму. Поскольку синусоидальные волны повторяются, они будут возвращаться к одной и той же форме каждые две секунды.
float MultiSineFunction (float x, float t) {
  float y = Mathf.Sin(Mathf.PI * (x + t));
  y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
  y *= 2f / 3f;
  return y;
 }

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

Следующее, что мы можем сделать, это добавить код, который позволяет контролировать, какой метод используется графиком. Мы могли бы сделать это с помощью слайдера, как и для разрешения графика. Поскольку у нас есть две функции на выбор, нам понадобится общедоступное целочисленное поле с диапазоном 0-1. Назовите его function, чтобы было понятно что он контролирует.
[Range(0, 1)]
 public int function;

Мы можем использовать пару блоков if-else внутри Update для управления той или иной функцией. Если ползунок установлен в 0, мы будем использовать SineFunction. В противном случае мы будем использовать MultiSineFunction.
void Update () {
  float t = Time.time;
  for (int i = 0; i < points.Length; i++) {
   Transform point = points[i];
   Vector3 position = point.localPosition;
   if (function == 0) {
    position.y = SineFunction(position.x, t);
   }
   else {
    position.y = MultiSineFunction(position.x, t);
   }
   point.localPosition = position;
  }
 }
Это позволяет контролировать функцию через инспектор, пока мы находимся в режиме воспроизведения.

Статические методы.

Хотя SineFunction и MultiSineFunction являются частью Graph, они автономны. Они полагаются только на свои параметры и математику, чтобы выполнять свою работу. Они полагаются на Mathf, но мы можем видеть это просто как математику. Кроме того, им не нужно обращаться к другим методам или полям Graph. Это говорит о том, что мы можем поместить их в другой класс или структуру, и они все равно будут работать. Таким образом, мы могли бы создать отдельный класс для методов функций и разместить их там. Однако, поскольку Graph является единственным, использующим эти методы, для этого нет большой причины.
По умолчанию методы и поля связаны с конкретными экземплярами объекта или типа класса или структуры. Но это не обязательно. Мы можем указать, что этой ассоциации не существует. Это делается путем размещения ключевого слова static перед методом или определением поля. Давайте сделаем это для наших двух методов. Тогда метод или поле будут связаны не с конкретным экземпляром, а с типом.
static float SineFunction (float x, float t) {
  return Mathf.Sin(Mathf.PI * (x + t));
 }

 static float MultiSineFunction (float x, float t) {
  float y = Mathf.Sin(Mathf.PI * (x + t));
  y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
  y *= 2f / 3f;
  return y;
 }
Эти методы все еще являются частью Graph, но теперь они напрямую связаны с типом класса и больше не привязаны к экземплярам объектов. Если бы мы сделали их общедоступными, мы могли бы вызывать их откуда угодно, например Graph.SineFunction (0f, 0f), как и Mathf.Sin (0f). Внутри самого класса Graph нам не нужно явно добавлять префикс типа, поэтому наш существующий код все еще работает.
Какой смысл делать наши методы статическими?read
Поскольку статические методы не связаны с экземплярами объектов, скомпилированный код не должен отслеживать, к какому объекту вы вызываете метод. Это означает, что вызовы статического метода немного быстрее, но это обычно недостаточно важно для беспокойства.

Делегаты.

Простой блок if-else работает для двух функций, но он быстро становится громоздким, пытаясь поддерживать больше функций. Было бы гораздо удобнее использовать переменную для хранения ссылки на метод, который мы хотим вызвать. Это возможно, используя тип делегата. Делегат - это особый тип, который определяет, ссылку на какой метод может содержать переменная. Для наших методов математических функций не существует стандартного типа, но мы можем сами определить его. Для этого создайте новый скрипт C# и назовите его GraphFunction.
Нужно ли нам использовать новый скрипт? read
Избавьтесь от кода по умолчанию в этом скрипте. Вместо этого мы будем использовать пространство имен UnityEngine, а затем определим открытый тип делегата с именем GraphFunction. Это не то же самое, что определение класса или структуры, за которым должна следовать точка с запятой.
using UnityEngine;

public delegate GraphFunction;
Тип делегата определяет форму методов, для которых он может использоваться. Эта форма называется сигнатурой метода, которая определяется его возвращаемым типом и списком параметров. В нашем случае возвращаемым типом методов является float, и есть два параметра: оба float. Примените эту подпись к типу делегата GraphFunction. Фактические имена, используемые для параметров, не имеют значения, но их типы должны быть правильными.
public delegate float GraphFunction (float x, float t);
Теперь мы можем объявить переменную типа GraphFunction внутри Graph.Update, перед циклом. После этого можно вызвать эту переменную, как если бы это был метод. Это позволяет нам избавиться от кода if-else внутри цикла.
void Update () {
  float t = Time.time;
  GraphFunction f;
  for (int i = 0; i < points.Length; i++) {
   Transform point = points[i];
   Vector3 position = point.localPosition;
//   if (function == 0) {
//    position.y = SineFunction(position.x, t);
//   }
//   else {
//    position.y = MultiSineFunction(position.x, t);
//   }
   position.y = f(position.x, t);
   point.localPosition = position;
  }
 }
Вместо этого нам нужно поставить блок if-else перед циклом, назначив ссылку на соответствующий метод нашей переменной.
GraphFunction f;
  if (function == 0) {
   f = SineFunction;
  }
  else {
   f = MultiSineFunction;
  }
  for (int i = 0; i < points.Length; i++) {
   …
  }

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

Хотя мы переместили блок if-else из цикла, мы по-прежнему не устранили его. Мы можем полностью избавиться от него, заменив его индексированием массива. Теперь, когда у нас есть тип GraphFunction, мы можем добавить поле массива функций этого типа в Graph.
GraphFunction[] functions;

Мы всегда будем помещать одни и те же элементы в этот массив, так что мы можем явно определить его содержимое как часть своего объявления. Это делается путем назначения последовательности элементов массива между фигурными скобками. Простейшей является пустая последовательность.
GraphFunction[] functions = {};

Это означает, что мы сразу получаем экземпляр массива, но он пуст. Измените это так, чтобы он содержал ссылки на методы обеих функций,- на функцию SineFunction, за которой следует MultiSineFunction.
GraphFunction[] functions = {
  SineFunction, MultiSineFunction
 };
Поскольку этот массив всегда один и тот же, нет смысла создавать один на каждый экземпляр графа. Вместо этого давайте определим его один раз для самого типа Graph, сделав его статичным, как и наши методы функции.
static GraphFunction[] functions = {
  SineFunction, MultiSineFunction
 };
Затем используйте массив в Update, используя поле экземпляра function для его индексации. После этого мы можем, наконец, удалить код if-else.
GraphFunction f = functions[function];
//  if (function == 0) {
//   f = SineFunction;
//  }
//  else{
//   f = MultiSineFunction;
//  }

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

Слайдер работает, но не очевидно, что 0 представляет собой синусоидальную функцию, а 1 представляет собой функцию с несколькими синусами. Было бы яснее, если бы у нас был раскрывающийся список, содержащий значимые имена. Для этого мы можем использовать перечисление.
Перечисления могут быть созданы путем определения типа перечисления. Создайте новый скрипт C#, который будет содержать этот тип с именем GraphFunctionName.
Минимальное определение перечисления работает так же, как и класс, за исключением того, что вместо класса используется перечисление.
public enum GraphFunctionName {}
Блок после имени перечисления содержит список меток, разделенных запятыми. Это строки, которые следуют тем же правилам и соглашениям, что и имена типов. В качестве имен для наших функций используйте Sine и MultiSine.
public enum GraphFunctionName {
 Sine,
 MultiSine
}
Затем замените поле целочисленного значения функции Graph на другое поле функции, которое имеет новый тип GraphFunctionName.
// [Range(0, 1)]
// public int function;
 public GraphFunctionName function;
Перечисления можно рассматривать как синтаксический сахар. По умолчанию каждая метка перечисления представляет собой целое число. Первая метка соответствует 0, вторая метка - 1 и т. д. Поэтому мы можем продолжать использовать поле перечисления для индексации нашего массива. Однако компилятор будет жаловаться на то, что перечисление не может быть неявно преобразовано в целое число. Мы должны явно выполнять этот приём, когда используем его как индекс в Update.
GraphFunction f = functions[(int)function];

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

Добавление другого измерения.

До сих пор мы работали с традиционными линейными графиками. Они сопоставляют одномерные значения с другими значениями 1D, хотя, если учесть время, это отображение 2D-значений в значения 1D. Таким образом, мы уже сопоставляем многомерный вход с 1D значением. Как мы добавили время, мы можем добавить дополнительные пространственные измерения.
В настоящее время мы используем измерение X как вход для наших функций. Y используется для отображения вывода. Это оставляет Z в качестве второго пространственного измерения для входа. Чтобы визуализировать его, обновите шейдер, чтобы он использовал координату Z для установки синего цветного канала. Это можно сделать, заменив использование rg и xy rgb и xyz при вычислении альбедо.
o.Albedo.rgb = IN.worldPos.xyz * 0.5 + 0.5;

Настройка функций.

Для поддержки второго входа для нашей функции добавьте параметр z после параметра x для типа делегата GraphFunction.
public delegate float GraphFunction (float x, float z, float t);
Это требует от нас также добавить параметр к нашим двум методам функций в Graph, хотя они фактически пока не используют дополнительное измерение.
static float SineFunction (float x, float z, float t) {
  return Mathf.Sin(Mathf.PI * (x + t));
 }

 static float MultiSineFunction (float x, float z, float t) {
  float y = Mathf.Sin(Mathf.PI * (x + t));
  y += Mathf.Sin(2f * Mathf.PI * (x + 2f * t)) / 2f;
  y *= 2f / 3f;
  return y;
 }
Чтобы сделать эту работу, мы должны указать координату позиции Z как второй аргумент при вызове метода функции в Update.
position.y = f(position.x, position.z, t);

Создаем сетку точек.

Чтобы показать размерность Z, мы должны превратить нашу линию точек в сетку точек. Мы можем сделать это, создав несколько строк, каждое со смещением на один шаг вдоль Z. Мы будем использовать тот же диапазон для Z, что и для X, поэтому мы создадим столько строк, сколько в настоящее время имеем точек. Это означает, что мы должны получить квадрат точек. Отрегулируйте создание массива точек в Awake, чтобы он был достаточно большим, чтобы содержать все точки.
points = new Transform[resolution * resolution];

По мере того, как мы увеличиваем координату X на каждой итерации, основанной на разрешении, просто создавая больше точек, вы получите одну длинную линию. Мы должны настроить цикл инициализации, чтобы принять во внимание второе измерение.
Во-первых, давайте проследим за координатой X явно. Сделайте это, объявив и увеличивая переменную x внутри цикла for, точно так же, как и i переменная. Для этого секции объявления и увеличения в определении цикла могут быть превращены в списки, разделенные запятыми.
for (int i = 0, x = 0; i < points.Length; i++, x++) {
   Transform point = Instantiate(pointPrefab);
   position.x = (i + 0.5f) * step - 1f;
   point.localPosition = position;
   point.localScale = scale;
   point.SetParent(transform, false);
   points[i] = point;
  }
Каждый раз, когда мы заканчиваем строку, мы должны вернуть x обратно к нулю. Строка завершается, когда x становится равным разрешению, поэтому мы можем использовать блок if в верхней части цикла, чтобы определить это. Затем используйте x вместо i, чтобы вычислить координату X.
for (int i = 0, x = 0; i < points.Length; i++, x++) {
   if (x == resolution) {
    x = 0;
   }
   Transform point = Instantiate(pointPrefab);
   position.x = (x + 0.5f) * step - 1f;
   point.localPosition = position;
   point.localScale = scale;
   point.SetParent(transform, false);
   points[i] = point;
  }
Затем каждая строка должна быть смещена по измерению 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 = 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);
   points[i] = point;
  }
Теперь мы создали квадратную сетку точек вместо одной строки. Поскольку наши функции по-прежнему зависят только от X, это будет выглядеть так, как исходные точки были выдавлены в линии.
Поскольку в небольшом пространстве теперь много точек, вероятно, точки будут отбрасывать тени друг на друга. Поворот Y по умолчанию направленного светильника устанавливается на -30 °, что приводит к появлению большого количества видимых теней при просмотре графика в положительном направлении. Чтобы лучше видеть цвета, вы можете вращать свет, чтобы получить более приятные тени, например, использовать положительное вращение Y на 30 ° или просто отключить тени.
Почему частота кадров значительно снизилась?read

Двойной цикл.

Хотя наш текущий подход к созданию макета сетки работает, использование блока if неудобно. Более читаемым способом петли над двумя измерениями является использование отдельного цикла для каждого измерения. Для этого удалите объявление старый цикл for и блок if, заменив их циклом for, который изменяет Z. Внутри этого цикла создайте еще один цикл, который делает то же самое для X. Точки создаются внутри второго вложенного цикла. Эффект этого заключается в том, что мы циклически перебираем X несколько раз, увеличивая Z после каждой строки, как и раньше.
Переменная i больше не нужна для завершения цикла, но все же необходимо индексировать массив точек. Определите его во внешнем цикле, но увеличивайте его во внутреннем цикле. Таким образом, он известен на протяжении всего процесса и получает прирост для каждой точки.
//  for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
//   if (x == resolution) {
//    x = 0;
//    z += 1;
//   }
  for (int i = 0, z = 0; z < resolution; z++) {
   for (int x = 0; x < resolution; x++, i++) {
    Transform point = 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);
    points[i] = point;
   }
  }
Обратите внимание, что координата Z изменяется только за итерацию внешнего цикла. Это означает, что нам не нужно вычислять его внутри внутреннего цикла. Мы можем поднять его на один уровень и устранить дублирующую работу.
for (int i = 0, z = 0; z < resolution; z++) {
   position.z = (z + 0.5f) * step - 1f;
   for (int x = 0; x < resolution; x++, i++) {
    Transform point = 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);
    points[i] = point;
   }
  }
Имеет ли значение, какое измерение используется для внешнего цикла?read

Включаем Z.

У нас есть двумерная сетка входных точек, поэтому давайте воспользуемся этим новым вторым измерением. Но сначала давайте определим локальную константу для π, чтобы постоянно не писать Mathf.PI. Это просто удобно, потому что мы будем использовать его чаще.
const float pi = Mathf.PI;

 static float SineFunction (float x, float z, float t) {
  return Mathf.Sin(pi * (x + t));
 }

 static float MultiSineFunction (float x, float z, float t) {
  float y = Mathf.Sin(pi * (x + t));
  y += Mathf.Sin(2f * pi * (x + 2f * t)) / 2f;
  y *= 2f / 3f;
  return y;
 }
Вместо того, чтобы изменять существующие две функции, мы собираемся создать новую функцию, которая использует X и Z в качестве входных данных. Создайте для нее метод с именем Sine2DFunction. Пусть она представляет функцию f (x, z, t) = sin (π (x + z + t)), которая является наиболее простым способом создания синусоидальной волны, основанной как на x, так и на z.
static float Sine2DFunction (float x, float z, float t) {
  return Mathf.Sin(pi * (x + z + t));
 }
Добавьте этот метод в массив функций, поместив его сразу после SineFunction.
static GraphFunction[] functions = {
  SineFunction, Sine2DFunction, MultiSineFunction
 };
Добавьте имя для него также в GraphFunctionName, используя Sine2D
public enum GraphFunctionName {
 Sine, Sine2D, MultiSine
}
При использовании этой функции в режиме воспроизведения вы увидите знакомую синусоидальную волну, за исключением того, что она ориентирована вдоль диагонали XZ вместо прямой вдоль X. Это потому, что мы используем x + z вместо просто x в качестве входа для функции синуса.
Альтернативный и более интересный способ использования обоих измерений состоит в объединении двух независимых синусоидальных волн, по одной для каждого измерения. Просто сложите их вместе, а затем разделите результат на два, чтобы выходное значение оставалось в диапазоне -1-1. Это дает нам функцию f (x, z, t) = (sin (π (x + t)) + sin (π (z + t))) / 2. Чтобы облегчить чтение кода, мы будем использовать переменную y и разобем функцию на три строки.
static float Sine2DFunction (float x, float z, float t) {
//  return Mathf.Sin(pi * (x + z + t));
  float y = Mathf.Sin(pi * (x + t));
  y += Mathf.Sin(pi * (z + t));
  y *= 0.5f;
  return y;
 }

Зачем использовать * = 0,5f вместо / = 2f?read
Давайте также создадим 2D-вариант для функции мультисинуса. В этом случае мы снова будем использовать одну основную волну, но с двумя вторичными волнами, по одной на измерение, поэтому мы получим функцию вида f (x, z, t) = M + Sx + Sz, где M обозначает основная волна, Sx представляет вторичную волну, основанную на x, и Sz - вторичную волну, основанную на z.
Мы будем использовать M = sin (π (x + z + t/2)) таким образом, основная волна - это медленная диагональная волна. Первая вторичная волна - Sx = sin (π (x + t)), так что это нормальная волна вдоль X. А третья волна - Sz = sin (2π (z + 2t)), которая является двухчастотным и быстро движется вдоль Z.
Мы сделаем основную волну М большой, в четыре раза больше амплитуды Sx. Поскольку у Sz есть двойная частота и скорость другой вторичной волны, мы дадим ей половину амплитуды. Это приводит к функции f (x, z, t) = 4M + Sx + Sz/2, которую нужно разделить на 5,5, чтобы нормализовать ее до диапазона -1–1. Для этого создайте метод MultiSine2DFunction.
static float MultiSine2DFunction (float x, float z, float t) {
  float y = 4f * Mathf.Sin(pi * (x + z + t * 0.5f));
  y += Mathf.Sin(pi * (x + t));
  y += Mathf.Sin(2f * pi * (z + 2f * t)) * 0.5f;
  y *= 1f / 5.5f;
  return y;
 }
Добавьте его в массив функций.
static GraphFunction[] functions = {
  SineFunction, Sine2DFunction, MultiSineFunction, MultiSine2DFunction
 };
И дайте ему имя MultiSine2D.
public enum GraphFunctionName {
 Sine, Sine2D, MultiSine, MultiSine2D
}

Создание пульсации.

Мы собираемся создать еще одну 2D-функцию, на этот раз она представляет собой анимированную рябь на поверхности. Мы позволим ряби распространяться во всех направлениях, чтобы мы получили круговой узор. Для этого нам нужно создать синусоидальную волну, основанную на расстоянии от начала координат. Это расстояние можно найти с помощью теоремы Пифагора, которая утверждает, что a2 + b2 = c2, где c - длина гипотенузы прямоугольного треугольника, а a и b являются длинами его двух других сторон.
В случае двумерных точек в плоскости XZ гипотенуза такого треугольника соответствует линии между началом координат и этой точкой, а ее координаты X и Z равны длинам двух других сторон. Следовательно, расстояние между каждой из наших точек и началом координат составляет √(x2 + z2).
Добавьте метод Ripple и попросите его вычислить расстояние, используя Mathf.Sqrt для вычисления квадратного корня. Пока просто используйте это как вывод.
static float Ripple (float x, float z, float t) {
  float d = Mathf.Sqrt(x * x + z * z);
  float y = d;
  return y;
 }
Добавьте этот метод в массив функций.
static GraphFunction[] functions = {
  SineFunction, Sine2DFunction, MultiSineFunction, MultiSine2DFunction,
  Ripple
 };
А также добавьте свое имя в перечисление.
public enum GraphFunctionName {
 Sine, Sine2D, MultiSine, MultiSine2D,
 Ripple
}
То, что мы получаем, это форма конуса, которая находится в нуле в середине и линейно увеличивается с расстоянием от начала координат. Это заканчивается самым высоким около углов сетки, потому что эти точки находятся дальше всего от начала координат. Именно по углам расстояние будет равно √2, что примерно равно 1,4142.
Чтобы создать нашу пульсацию, мы должны будем использовать f (x, z, t) = sin (πD) где D – расстояние
float d = Mathf.Sqrt(x * x + z * z);
  float y = Mathf.Sin(pi * d);
  return y;
>
Это не показывает нам большую часть волнового паттерна, поэтому давайте увеличим его частоту в четыре раза.
float y = Mathf.Sin(4f * pi * d);
Мы приближаемся к волнистой форме, но волнистость слишком велика. Мы можем позаботиться об этом, уменьшив амплитуду волны. Вместо того, чтобы делать это равномерно, мы можем сделать так, чтобы это зависело и от расстояния. Например, мы могли бы использовать 1 / 10D в качестве амплитуды. Это приводит к тому, что пульсация ослабевает дальше от ее источника, что имитирует ее поведение, хотя мы не будем беспокоиться о физически правильных пропорциях. Однако простое деление на расстояние приведет к делению на ноль в начале координат и вызовет экстремальную амплитуду вблизи начала координат. Чтобы предотвратить это, мы будем использовать 1 / (1 + 10D) вместо этого.
float y = Mathf.Sin(4f * pi * d);
  y /= 1f + 10f * d;
  return y;
Наконец, добавьте время к синусоиде, чтобы оживить его. Поскольку пульсация должна двигаться наружу, вычтите t вместо того, чтобы сложить ее.
float y = Mathf.Sin(pi * (4f * d - t));

Покидая решетку.

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

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

Если бы наши функции выводили трехмерные позиции вместо одномерных значений, мы могли бы использовать их для создания произвольных поверхностей. Например, функция f(x, z) = [x, 0, y] описывает плоскость XZ, а функция f(x, z) = [x, z, 0] описывает плоскость XY.
Поскольку входные параметры для этих функций больше не соответствуют конечным координатам X и Z, их больше нельзя называть x и z. Вместо этого их часто называют u и v. Поэтому мы получим такие функции, как f(u, v) = [(u+v), u*v, u/v]
Настройте наш делегат GraphFunction для поддержки этого нового подхода. Единственное необходимое изменение - это замена его возвращаемого типа с плавающей точкой на Vector3, но давайте также переименуем его параметры.
public delegate Vector3 GraphFunction (float u, float v, float t);
Наша первоначальная функция синуса теперь должна быть определена как f(u, v, t) = [u, sin(π (u+t)), v]. Но поскольку мы не настраиваем X и Z, мы оставим имена параметров SineFunction без изменений. Теперь он должен возвращать вектор, напрямую используя x и z для своих координат X и Z, при расчете координаты Y.
static Vector3 SineFunction (float x, float z, float t) {
//  return Mathf.Sin(pi * (x + t));
  
  Vector3 p;
  p.x = x;
  p.y = Mathf.Sin(pi * (x + t));
  p.z = z;
  return p;
 }
Сделайте те же изменения в Sine2DFunction.
static Vector3 Sine2DFunction (float x, float z, float t) {
//  float y = Mathf.Sin(pi * (x + t));
//  y += Mathf.Sin(pi * (z + t));
//  y *= 0.5f;
//  return y;
  
  Vector3 p;
  p.x = x;
  p.y = Mathf.Sin(pi * (x + t));
  p.y += Mathf.Sin(pi * (z + t));
  p.y *= 0.5f;
  p.z = z;
  return p;
 }
Настройте другие три метода функции тоже.
static Vector3 MultiSineFunction (float x, float z, float t) {
  Vector3 p;
  p.x = x;
  p.y = Mathf.Sin(pi * (x + t));
  p.y += Mathf.Sin(2f * pi * (x + 2f * t)) / 2f;
  p.y *= 2f / 3f;
  p.z = z;
  return p;
 }
 static Vector3 MultiSine2DFunction (float x, float z, float t) {
  Vector3 p;
  p.x = x;
  p.y = 4f * Mathf.Sin(pi * (x + z + t / 2f));
  p.y += Mathf.Sin(pi * (x + t));
  p.y += Mathf.Sin(2f * pi * (z + 2f * t)) * 0.5f;
  p.y *= 1f / 5.5f;
  p.z = z;
  return p;
 }
 static Vector3 Ripple (float x, float z, float t) {
  Vector3 p;
  float d = Mathf.Sqrt(x * x + z * z);
  p.x = x;
  p.y = Mathf.Sin(pi * (4f * d - t));
  p.y /= 1f + 10f * d;
  p.z = z;
  return p;
 }
Поскольку координаты X и Z точек больше не являются постоянными, мы больше не можем полагаться на их начальные значения в Update. Вместо этого мы должны предоставить новые U и V входы, что мы можем сделать двойным циклом. Затем мы можем напрямую присвоить результат метода функции позиции точки, поэтому нам больше не нужно ее извлекать.
void Update () {
  float t = Time.time;
  GraphFunction f = functions[(int)function];
//  for (int i = 0; i < points.Length; i++) {
//   Transform point = points[i];
//   Vector3 position = point.localPosition;
//   position.y = f(position.x, position.z, t);
//   point.localPosition = position;
//  }
  float step = 2f / resolution;
  for (int i = 0, z = 0; z < resolution; z++) {
   float v = (z + 0.5f) * step - 1f;
   for (int x = 0; x < resolution; x++, i++) {
    float u = (x + 0.5f) * step - 1f;
    points[i].localPosition = f(u, v, t);
   }
  }
 }
Поскольку этот новый подход больше не опирается на исходные позиции, нам больше не нужно инициализировать их в Awake, что значительно упрощает этот метод. Мы можем обойтись одним циклом, который инициализирует все точки и оставляет их позиции без изменений.
void Awake () {
  float step = 2f / resolution;
  Vector3 scale = Vector3.one * step;
//  Vector3 position;
//  position.y = 0f;
//  position.z = 0f;
  points = new Transform[resolution * resolution];
//  for (int i = 0, z = 0; z < resolution; z++) {
//   position.z = (z + 0.5f) * step - 1f;
//   for (int x = 0; x < resolution; x++, i++) {
//    Transform point = Instantiate(pointPrefab);
//    position.x = (x + 0.5f) * step - 1f;
//    point.localPosition = position;
//    point.localScale = scale;
//    point.SetParent(transform, false);
//    points[i] = point;
//   }
//  }
  for (int i = 0; i < points.Length; i++) {
   Transform point = Instantiate(pointPrefab);
   point.localScale = scale;
   point.SetParent(transform, false);
   points[i] = point;
  }
 }

Создание Цилиндра.

Чтобы продемонстрировать, что мы больше не ограничены одной точкой на пару координат (X, Z), давайте создадим функцию, которая определяет цилиндр. Для этого добавьте метод функции Cylinder, начиная с того, что всегда возвращайте точку в начале координат.
Также добавьте этот метод в массив функций и добавьте имя для него в GraphFunctionName, как обычно. Я больше не буду явно упоминать этот шаг.
static Vector3 Cylinder (float u, float v, float t) {
  Vector3 p;
  p.x = 0f;
  p.y = 0f;
  p.z = 0f;
  return p;
 }
Цилиндр - это вытянутый круг, поэтому мы начнем с части круга. Как упомянуто в предыдущем уроке, все точки на двумерной окружности могут быть определены через [sin (θ), cos (θ)] с θ, идущим от 0 до . Вместо этого мы можем использовать u, который в нашем случае идет от −1 до 1. Чтобы создать окружность в плоскости XZ, нам нужна функция f (u) = [sin (πu), 0, cos (πu)].
static Vector3 Cylinder (float u, float v, float t) {
  Vector3 p;
  p.x = Mathf.Sin(pi * u);
  p.y = 0f;
  p.z = Mathf.Cos(pi * u);
  return p;
 }
Поскольку функция не использует v, все точки, которые используют одно и то же значение v, оказываются в одной и той же позиции. Таким образом, мы фактически сведены к одной линии. Чтобы увидеть, как эта линия обтекает круг, сделайте Y равным u.
p.x = Mathf.Sin(pi * u);
  p.y = u;
  p.z = Mathf.Cos(pi * u);
Это показывает нам, что линия начинается в [0, −1, −1] и изгибается вокруг начала координат по часовой стрелке, в соответствии с вводом функции. Чтобы превратить его в настоящий цилиндр, вместо этого сделайте Y равным v. Таким образом, мы получаем цилиндр, складывая плоские круги вдоль Y.
p.x = Mathf.Sin(pi * u);
  p.y = v;
  p.z = Mathf.Cos(pi * u);
В настоящее время мы используем круг с радиусом равным 1 для нашего цилиндра, но это не обязательно. Радиус круга можно отрегулировать, масштабируя амплитуду синуса и косинуса на одинаковую величину. В общем случае функция становится f (u, v) = [R*sin (πu), v, Rcos (πv)] где R - радиус круга. Настройте наш метод так, чтобы он использовал явный радиус 1.
float r = 1f;
  p.x = r * Mathf.Sin(pi * u);
  p.y = v;
  p.z = r * Mathf.Cos(pi * u);
Что бы произошло, если бы мы использовали разные амплитуды?read
Мы могли бы использовать любой другой радиус, он даже не должен быть постоянным. Например, мы могли бы изменять радиус вдоль u. Для этого воспользуемся еще одной синусоидальной волной, например, R = 1 + sin (6πu) / 5.
float r = 1f + Mathf.Sin(6f * pi * u) * 0.2f;
В результате цилиндр становится «вихляющимся». Круг приобрел округлую форму звезды. Поверхность следует волновой схеме вокруг круга, шесть раз перемещаясь внутрь и наружу.
Мы также можем сделать радиус зависящим от v, например R = 1 + sin (2πv) / 5. В этом случае каждое кольцо цилиндра имеет постоянный радиус, но радиус изменяется по длине цилиндра.
float r = 1f + Mathf.Sin(2f * pi * v) * 0.2f;
Еще более интересно было бы использовать и u, и v для создания диагональной волны, которая в итоге закручивается вокруг цилиндра. Давайте также добавим t, чтобы оживить его. Наконец, чтобы убедиться, что радиус не превышает 1, уменьшите его базовую линию до 4/5.
float r = 0.8f + Mathf.Sin(pi * (6f * u + 2f * v + t)) * 0.2f;

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

Теперь, когда у нас есть цилиндр, давайте перейдем к сфере. Для этого продублируйте метод Cylinder и переименуйте его в Sphere. Давайте посмотрим, как мы можем превратить цилиндр в сферу, уменьшив его радиус сверху и снизу до нуля другой волной, используя R = cos (πv/2).
static Vector3 Sphere (float u, float v, float t) {
  Vector3 p;
  float r = Mathf.Cos(pi * 0.5f * v);
  p.x = r * Mathf.Sin(pi * u);
  p.y = v;
  p.z = r * Mathf.Cos(pi * u);
  return p;
 }
Это приближает нас к нужному результату, но уменьшение радиуса цилиндра еще не круговое. Это потому, что круг состоит как из синуса, так и из косинуса, и мы используем только косинус в этой точке. Другая часть уравнения - Y, которая в настоящее время все еще равна v. Чтобы завершить круг, Y должен стать равным sin (πv/2).
p.y = Mathf.Sin(pi * 0.5f * v);
В итоге мы получаем сферу, созданную по шаблону, который обычно называют UV-сферой. Хотя этот подход создает правильную сферу, обратите внимание, что распределение точек не является равномерным, потому что сфера создается с помощью кругов с разным радиусом. На полюсах сферы их радиус становится равным нулю.
Чтобы иметь возможность контролировать радиус сферы, мы должны несколько изменить нашу формулу. Мы будем использовать f (u, v) = [S*sin (πu), R* sin (πv/2), S* Rcos (πu)], где S = Rcos (πv/2) и R -это радиус.
Такой подход позволяет анимировать радиус сферы. Давайте использовать отдельные синусоиды для u и v: R = 4/5 + sin (π (6u + t)) /10 + sin (π (4v + t)) /10.
  float r = 0.8f + Mathf.Sin(pi * (6f * u + t)) * 0.1f;
  r += Mathf.Sin(pi * (4f * v + t)) * 0.1f;
  float s = r * Mathf.Cos(pi * 0.5f * v);
  p.x = s * Mathf.Sin(pi * u);
  p.y = r * Mathf.Sin(pi * 0.5f * v);
  p.z = s * Mathf.Cos(pi * u);

Создание Тора.

Мы собираемся закончить этот урок, превратив сферу в тор. Скопируйте Sphere и переименуйте его в Torus, затем удалите код радиуса сферы.
static Vector3 Torus (float u, float v, float t) {
  Vector3 p;
  float s = Mathf.Cos(pi * 0.5f * v);
  p.x = s * Mathf.Sin(pi * u);
  p.y = Mathf.Sin(pi * 0.5f * v);
  p.z = s * Mathf.Cos(pi * u);
  return p;
 }
Мы создадим тор, раздвинув сферу, подобно тому, как мы хватаем ее за ее полюсы и вытягиваем ее во всех направлениях в плоскости XZ. Мы можем сделать это, добавив к S постоянное значение, например ½.
float s = Mathf.Cos(pi * 0.5f * v) + 0.5f;
Это дает нам половину тора с учетом только внешней части его кольца. Чтобы завершить тор, мы должны использовать v для описания всего круга вместо полукруга. Это можно сделать с помощью πv вместо πv/2.
float s = Mathf.Cos(pi * v) + 0.5f;
  p.x = s * Mathf.Sin(pi * u);
  p.y = Mathf.Sin(pi * v);
  p.z = s * Mathf.Cos(pi * u);
Поскольку мы вытянули сферу на половину единицы, это создает самопересекающуюся форму, известную как тор шпинделя. Если бы вместо этого мы вытянули ее на одну единицу, мы получили бы тор, который не самопересекающийся, но также не имеет отверстия, которое известно как роговой тор. То, как далеко мы раздвигаем сферу, влияет на форму тора. В частности, он определяет основной радиус тора, который мы обозначим R1. Таким образом, наша функция становится f (u, v) = [S*sin (πu), sin (πv), S*cos (πu)], где S = cos (πv) + R1.
float r1 = 1f;
  float s = Mathf.Cos(pi * v) + r1;

Если R1 будет больше 1, откроется отверстие в середине тора, что сделает его кольцевым тором. Но это предполагает, что окружности, которые мы обертываем вокруг кольца, всегда имеют радиус 1. Это вторичный радиус тора R2, и мы также можем изменить его, если будем использовать функцию f (u, v) = [S*sin (πu), R2*sin (πv), S*cos (πu)] где S = R2*cos (πv) + R1
Давайте оставим R1 в 1 и уменьшим R2 до ½.
float r1 = 1f;
  float r2 = 0.5f;
  float s = r2 * Mathf.Cos(pi * v) + r1;
  p.x = s * Mathf.Sin(pi * u);
  p.y = r2 * Mathf.Sin(pi * v);
  p.z = s * Mathf.Cos(pi * u);

Теперь у нас есть два радиуса, чтобы поиграть в более интересный тор. Довольно простой, но все же интересный подход заключается в добавлении u-волны к R1 и v-волны к R2, оба анимированных, при этом убедитесь, что тор вписывается в диапазон -1-1.
float r1 = 0.65f + Mathf.Sin(pi * (6f * u + t)) * 0.1f;
  float r2 = 0.2f + Mathf.Sin(pi * (4f * v + t)) * 0.05f;

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


Переводчик Беляев В.А. ака seaman.

Комментариев нет:

Отправить комментарий