Пускаем волны многопоточности[Job System] Часть 4.

Научился сам? Помоги начинающему.

Пускаем волны многопоточности[Job System] Часть 4.

Сообщение lawsonilka 01 июн 2018, 16:57

Пускаем волны многопоточности. Часть 4.

...предыдущая часть

Продолжаем работать с созданием сетки. Теперь необходимо указать какие данные будут передаваться в задачу для вычисления.

Возвращаемся в структуру задачи BuildingJob и создадим несколько переменных.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 public float radius;
 public Vector2 origin;

 public void Execute(int index) {

 }

}

В задачу мы будем передавать переменные radius и origin для указания размера и позиции где будем строить точки.

Следующим действием нужно передать в задачу координаты coords сетки, эта переменная как и origin будет показывать начало координат ряда.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 public float radius;
 public Vector2 origin;

 public Vector2Int coords;

 public void Execute(int index) {

 }

}


Далее нужно также завести в задаче список точек куда они будут складываться после создания, для этого используем массив-контейнер NativeArray<Node>.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 public float radius;
 public Vector2 origin;

 public Vector2Int coords;

 [WriteOnly]
 public NativeArray<Node> nodes;

 public void Execute(int index) {

 }

}

Пометим переменную nodes атрибутом WriteOnly чтобы запретить другим потокам считывать данные из нее.
...чего!?
Скрытый текст:
Когда данные синхронизируются между потоками, необходимо определять что именно происходит с этими данными в потоке - они записываются или только считываются, в данном случаем мы предоставляем другим потокам возможность только записывать данные в массив, что именно там находится в массиве они знать не могут. Атрибут ReadOnly имеет ровно противоположное значение.


В примере сетка будет строиться по горизонтали, то есть каждая задача будет строить определенное кол-во точек в длину, высоту же мы будем задавать заранее в задаче, для этого введем переменную yIndex для вычисления на какой сейчас высоте строиться ряд точек. Это касается только координат, а не позиции, для этого мы уже имеем переменную origin.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 public float radius;
 public Vector2 origin;

 public Vector2Int coords;

 [WriteOnly]
 public NativeArray<Node> nodes;

 public int yIndex;

 public void Execute(int index) {

 }

}


Готово. Возвращаемся в метод BuildMap скрипта Grid, и начнем создание задач в цикле.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void BuildMap() {
  /*...остальной код...*/

  for(int i = 0; i < yCount; i++) {
   float posX = origin.x;
   float posY = origin.y + (i * this.radius) + this.radius / 2f;
  }

  OnBuildingStart();
 }

}


Цикл у нас будет считать сколько рядов ячеек нужно построить, а для этого используем переменную yCount в которой мы вычислили кол-во рядов. Далее в переменные posX и posY вносим позиции первой точки из ряда.

Теперь можно создать экземпляры задач и массива точек NativeArray<Node>.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void BuildMap() {
  /*...остальной код...*/

  for(int i = 0; i < yCount; i++) {
   float posX = origin.x;
   float posY = origin.y + (i * this.radius) + this.radius / 2f;

   NativeArray<Node> array = new NativeArray<Node>(xCount, Allocator.Temp);
   BuildingJob buildJob = new BuildingJob();
  }

  OnBuildingStart();
 }

}

В переменную array мы передали экземпляр массива-контейнера для точек, который содержит кол-во точек для построения и тип доступа к данным Temp.

Вносим остальные данные в задачу.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void BuildMap() {
  /*...остальной код...*/

  for(int i = 0; i < yCount; i++) {
   float posX = origin.x;
   float posY = origin.y + (i * this.radius) + this.radius / 2f;

   NativeArray<Node> array = new NativeArray<Node>(xCount, Allocator.Temp);
   BuildingJob buildJob = new BuildingJob();

   buildJob.nodes = array;
   buildJob.yIndex = i * xCount;
   buildJob.coords = new Vector2Int(xCount, i);
   buildJob.radius = this.radius;
   buildJob.origin = new Vector2(posX, posY);
  }

  OnBuildingStart();
 }

}

В массив nodes передаем созданный экземпляр массива ячеек array, в yIndex передаем текущую высоту ряда, в координаты coords передаем координаты ряда, в radius и origin передаем размер и начальные позиции точек.

Теперь добавим экземпляры задачи и массив точек в глобальный список-словарь jobs.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void BuildMap() {
  /*...остальной код...*/

  for(int i = 0; i < yCount; i++) {
   float posX = origin.x;
   float posY = origin.y + (i * this.radius) + this.radius / 2f;

   NativeArray<Node> array = new NativeArray<Node>(xCount, Allocator.Temp);
   BuildingJob buildJob = new BuildingJob();

   buildJob.nodes = array;
   buildJob.yIndex = i * xCount;
   buildJob.coords = new Vector2Int(xCount, i);
   buildJob.radius = this.radius;
   buildJob.origin = new Vector2(posX, posY);

   this.jobs.Add(buildJob, array);
  }

  OnBuildingStart();
 }

}


Теперь у нас есть массив задач которые можно поместить в планировщик и выполнить.

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

Вкладывать одну задачу в другую можно в методе Schedule, выполним это в цикле.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void BuildMap() {
  /*...остальной код...*/

  for(int i = 0; i < yCount; i++) {
   float posX = origin.x;
   float posY = origin.y + (i * this.radius) + this.radius / 2f;

   NativeArray<Node> array = new NativeArray<Node>(xCount, Allocator.Temp);
   BuildingJob buildJob = new BuildingJob();

   buildJob.nodes = array;
   buildJob.yIndex = i * xCount;
   buildJob.coords = new Vector2Int(xCount, i);
   buildJob.radius = this.radius;
   buildJob.origin = new Vector2(posX, posY);

   this.jobs.Add(buildJob, array);

   int blocksSize = Mathf.Clamp(xCount, 1, 10);
   this.handle = buildJob.Schedule(xCount, blocksSize, this.handle);   
  }

  OnBuildingStart();
 }

}

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

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

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

В данном примере очищение происходит в методе OnDestroy скрипта Grid, выглядит это так.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnDestroy() {
  foreach(NativeArray<Node> array in this.jobs.Values) {
   array.Dispose();
  }
  this.jobs.Clear();
 }

}
 

Здесь очищается каждый массив-контейнер из списка jobs, а также сам список.
Вызовим также этот метод OnDestroy в методе OnBuildingFinish, чтобы очищать память после выполнения всех вычислений, а не только когда уничтожается объект.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnBuildingFinish() {
  this.building = false;
  OnDestroy();
 }

 private void OnDestroy() {
  foreach(NativeArray<Node> array in this.jobs.Values) {
   array.Dispose();
  }
  this.jobs.Clear();
 }

}
 

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

Наконец возвращаемся к задаче и выполним расчеты точек на сетке в методе Execute, для начала вычислим позицию точки на плоскости.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 /*...переменные...*/

 public void Execute(int index) {
  float x = this.origin.x + (index * this.radius) + this.radius / 2f;
  float y = this.origin.y;
 }

}

Так как мы строим точки только по горизонтали, позиция Y(по вертикали) остается одной и той же.

Дальше укажем номер id новой точки.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 /*...переменные...*/

 public void Execute(int index) {
  float x = this.origin.x + (index * this.radius) + this.radius / 2f;
  float y = this.origin.y;

  int id = index + this.yIndex + 1;
 }

}

Переменная id будет равна сумме индекса по высоте и индекса по длине. Также чтобы номера точек не начинались с 0 я добавиляю 1 в конце.

Установим новые позицию и координаты точки.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 /*...переменные...*/

 public void Execute(int index) {
  float x = this.origin.x + (index * this.radius) + this.radius / 2f;
  float y = this.origin.y;

  int id = index + this.yIndex + 1;

  Vector2 position = new Vector2(x, y);
  Vector2Int coords = new Vector2Int(index, this.coords.y);
 }

}

Для позиции используются переменные X и Y, а для координат текущий index ряда и индекс по высоте.

Создадим новую ячейку и заполним ею массив.
Синтаксис:
Используется csharp
public struct BuildingJob : IJobParallelFor {

 /*...переменные...*/

 public void Execute(int index) {
  float x = this.origin.x + (index * this.radius) + this.radius / 2f;
  float y = this.origin.y;

  int id = index + this.yIndex + 1;

  Vector2 position = new Vector2(x, y);
  Vector2Int coords = new Vector2Int(index, this.coords.y);
 
  Node node = new Node(id, position, coords);
  this.nodes[index] = node;
 }

}


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

Сбор данных будет выполняться в методе OnBuildingFinish.

Для начала очистим устаревший список точек points.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnBuildingFinish() {
  this.building = false;

  this.points.Clear();

  OnDestroy();
 }

}


Теперь обратимся к выполненным задачам в списке jobs.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnBuildingFinish() {
  this.building = false;

  this.points.Clear();

  foreach(KeyValuePair<BuildingJob, NativeArray<Node>> pair in this.jobs) {
   BuildingJob job = pair.Key;
   NativeArray<Node> array = pair.Value;
  }

  OnDestroy();
 }

}


Переберем все ячейки контейнера array и создадим по ним точки Point , потом добавим эти точки в список.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnBuildingFinish() {
  this.building = false;

  this.points.Clear();

  foreach(KeyValuePair<BuildingJob, NativeArray<Node>> pair in this.jobs) {
   BuildingJob job = pair.Key;
   NativeArray<Node> array = pair.Value;

   foreach(Node node in job.nodes) {
    Point point = new Point(node.id, node.position, node.coords);
    this.points.Add(point.coords, point);
   }

  }

  OnDestroy();
 }

}

Создаем экземпляры класса Point через конструктор в который передаем номер ячейки id, позицию на плоскости position и координаты точки на сетке coords.
Затем добавляем в словарь, где ключом будет выступать координаты точки coords, а значением будет сама точка Point.

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

Для этого переберем все точки из списка points.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnDrawGizmos() {
  foreach(Point point in this.points.Values) {

  }
 }

}


Далее через позицию и размер отрисуем саму точку.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnDrawGizmos() {
  foreach(Point point in this.points.Values) {
   Gizmos.DrawWireSphere(point.position, this.radius / 10f);
  }
 }

}


Теперь можно полноценно протестировать систему, чтобы каждый раз не ждать изменения параметров плоскости для перестройки сетки, вызовим метод BuildMap прямо в методе Start.

Это начнет построение сетки сразу после запуска игры.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void Start() {
  BuildMap();
 }

}


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


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

Вернемся в класс Point и создадим этот список соседей.
Синтаксис:
Используется csharp
public sealed class Point : System.IEquatable {

 public readonly int id;
 public readonly Vector2 position;
 public readonly Vector2Int coords;

 private HashSet<Point> pointConnections = new HashSet<Point>();

 /*...остальной код...*/

}

Пусть это будет HashSet список.

...почему не List?
Скрытый текст:
В список типа List можно добавлять любое кол-во объектов и их копии, HashSet же добавляет объекты по специальному уникальному ключу и у вас не получиться добавить два похожим друг на друга объекта или любые другие копии одного объекта. Этим он обеспечивает небольшой припрост скорости работы в отличие от списка List.


Теперь добавим в классе Point метод для добавления соседей.
Синтаксис:
Используется csharp
public sealed class Point : System.IEquatable {

 public readonly int id;
 public readonly Vector2 position;
 public readonly Vector2Int coords;

 private HashSet<Point> pointConnections = new HashSet<Point>();

 public bool AddConnection(Point connection) {
  if (this.id == connection.id) return false;
  else return this.pointConnections.Add(connection);
 }

 /*...остальной код...*/

}

Этот метод AddConnection будет принимать любую другую точку Point и сравнивать их id номера.

Так как переменная pointConnections приватная, чтобы избежать изменений вне экземпляра, нужно добавить метод чтобы вернуть список соседних точек.
Синтаксис:
Используется csharp
public sealed class Point : System.IEquatable {

 public readonly int id;
 public readonly Vector2 position;
 public readonly Vector2Int coords;

 private HashSet<Point> pointConnections = new HashSet<Point>();

 public bool AddConnection(Point connection) {
  if (this.id == connection.id) return false;
  else return this.pointConnections.Add(connection);
 }
 
 public IEnumerable<Point> connections {
  get { return this.pointConnections;}
 }

 /*...остальной код...*/

}

Свойство connections вернет простой не нумерованные список соседних точек.

Есть несколько способов нахождения соседних точек: можно было бы перебирать список всех точек и находить ближайшие по позиции, что займет больше времени сравнивая расстояния между ними, по сравнению с другим вариантом - в примере уже есть четкие координаты всех точек они находятся в списке points для которого мы использовали словарь Dictionary<Vector2Int, Point>, нужно только знать координаты искомой точки и по ним найти в разных направлениях соседней от нее, при это не перебирая каждый раз для каждой точки список всех остальных точек, что намного увеличит скорость их поиска.

Координаты в отличие от позиции отличаются друг от друга только на 1(единицу) в любом направлении, а чтобы искать направление будем использовать простой вектор Vector2Int который вместо нечетких float параметров имеет int с которыми проще работать.

Будем искать соседей в четырех направлениях. Для этого создадим статический массив векторов Vector2Int в скрипте Grid.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {

 private static readonly Vector2Int[] Directions = {
  Vector2Int.up,
  new Vector2Int(1, 1),
  Vector2Int.right,
  new Vector2Int(-1, 1)
 };
 
 /*...остальной код...*/

}


Сам процесс поиска будет описан в методе OnBuildingFinish, там где мы и создаем точки.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnBuildingFinish() {
  this.building = false;

  this.points.Clear();

  foreach(KeyValuePair<BuildingJob, NativeArray<Node>> pair in this.jobs) {
   BuildingJob job = pair.Key;
   NativeArray<Node> array = pair.Value;

   foreach(Node node in job.nodes) {
    Point point = new Point(node.id, node.position, node.coords);
    this.points.Add(point.coords, point);
   }

  }

  foreach(Point point in this.points.Values) {
   for(int i = 0; i < Directions.Length; i++) {

   }
  }

  OnDestroy();
 }

}

В цикле перебираем каждую точку и пытаемся с помощью небольшого цикла найти соседние точки по координатам.

Вычислияем координаты соседей.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnBuildingFinish() {

  /*...остальной код...*/

  foreach(Point point in this.points.Values) {
   for(int i = 0; i < Directions.Length; i++) {
    Vector2Int coords = point.coords + Directions[i];
    Point connection = null;
   }
  }

  OnDestroy();
 }

}
 


Чтобы не перебирать снова массив всех точек для каждой координаты, просто попробуем сразу подобрать ключ к словарю points в виде координаты Vector2Int и найти ее значение, но делать это нужно осторожно ведь если ключ будет не верным словарь вызовит ошибку, для этого используем блок обхода исключений try cartch.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {

 /*...остальной код...*/
 
 private void OnBuildingFinish() {
 
  /*...остальной код...*/

  foreach(Point point in this.points.Values) {
   for(int i = 0; i < Directions.Length; i++) {
    Vector2Int coords = point.coords + Directions[i];
    Point connection = null;

    try {
     connection = this.points[coords];
    } catch {
     continue;
    }
    if (connection != null) point.AddConnection(connection);
   }
  }

  OnDestroy();
 }

}

В блоке try попробуем подобрать ключ и вернуть значение точки, если ключ будет не правильным - получим ошибку которая попадет в блок catch который в свою очередь ее просто проигнорирует и продолжит искать соседей дальше. Если ключ был подобран верно, ошибки не возникнет и можно будет добавить соседнюю точку через метод AddConnection.

Теперь отрисуем связи между точками в методе OnDrawGizmos.
Синтаксис:
Используется csharp
public sealed class Grid : MonoBehaviour {
 
 /*...остальной код...*/

 private void OnDrawGizmos() {
  foreach(Point point in this.points.Values) {
   Color c = Gizmos.color;
   Gizmos.color = Color.green;
   foreach(Point connection in point.connections) {
    Gizmos.DrawLine(point.position, connection.position);
   }
   Gizmos.color = c;
   Gizmos.DrawWireSphere(point.position, this.radius / 10f);
  }
 }

}
 

Здесь не много изменен порядок отрисовки, сначала рисуются линии между соседями через DrawLine, а только потом сами точки через DrawWireSphere.

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

Вот и все. Система готова.

Заключение...
lawsonilka
UNIверсал
 
Сообщения: 390
Зарегистрирован: 21 окт 2014, 14:48

Вернуться в Уроки

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и гости: 3