Оптимизируем вычисления в Unity

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Постановка задачи

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

Приготовления

Чтобы сэкономить время мы не будем профайлить всю игру. Лучше создать отдельный проект, в рамках которого мы будем изолированно изучать производительность конкретных участков кода. Для упрощения снятия замеров производительности будем использовать Unity Performance Testing package.

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

В исходном варианте префаб имеет 3 "корня", в каждом из которых 790 вершин. Добавим к нему второй вариант префаба, где будет по 45835 вершин. И будем проводить по 2 теста: 50 объектов с малым количеством вершин и один объект с большим.

И создаем сцену с простеньким спавнером.

Далее пишем тест:

public class PerformanceTest {
  [UnityTest, Performance]
  public IEnumerator Test() {
    SceneManager.LoadScene("Performance Test Scene", LoadSceneMode.Additive);
    yield return Measure.Frames()
      .WarmupCount(30)
      .MeasurementCount(30)
      .Run();
  }
}

Грузить сцену в данном тесте не совсем идеологически правильно. Лучше было бы создавать наши объекты где-то внутри него. Это позволило бы создавать параметрические тесты на разное количество объектов и т.д. Но в простом сценарии нам для этого потребовалось бы засунуть наш префаб в Resources, что тоже неправильно. Потому что наш тестовый префаб потом будет попадать в билд в проектах, использующих нашу библиотеку. Более продвинутые варианты вроде Addressables подразумевают лишние зависимости. Так что постараемся не усложнять жизнь конечным пользователям нашего творчества.

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

 Test Name

Min

Max

Median

Average

IL2CPP - 1 Big mesh

65.98

67.66

66.52

66.51

IL2CPP - 50 simple meshes

52.19

60.24

58.41

58.03

Mono - 1 Big mesh

113.49

119.72

117.17

116.80

Mono - 50 simple meshes

107.6

117.72

110.5

111.91

Теперь посмотрим на профайлер и будем разбираться с проблемами.

 Борьба с аллокациями

Первое, что бросается в глаза - большое количество аллокаций.

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

С этого и начнем. Вряд ли это сильно увеличит наш фпс, но лучше не напрягать GC лишний раз и не тратить время на создание лишних объектов.

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

bentVertices.Add(sample.GetBent(vert));
...
MeshUtility.Update(result,
	source.Mesh,
	source.Triangles,
	bentVertices.Select(b => b.position),
	bentVertices.Select(b => b.normal));
...
mesh.vertices = vertices == null ? source.vertices : vertices.ToArray();
mesh.normals = normals == null ? source.normals : normals.ToArray();

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

Ссылка на коммит

Уже лучше, но еще не идеал. Давайте посмотрим, что происходит внутри CurveSample.GetBent():

public MeshVertex GetBent(MeshVertex vert) {
  var res = new MeshVertex(vert.position, vert.normal, vert.uv);

  // application of scale
  res.position = Vector3.Scale(res.position, new Vector3(0, scale.y, scale.x));

  // application of roll
  res.position = Quaternion.AngleAxis(roll, Vector3.right) * res.position;
  res.normal = Quaternion.AngleAxis(roll, Vector3.right) * res.normal;

  // reset X value
  res.position.x = 0;

  // application of the rotation + location
  Quaternion q = Rotation * Quaternion.Euler(0, -90, 0);
  res.position = q * res.position + location;
  res.normal = q * res.normal;
  return res;
}

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

public class MeshVertex {
  public Vector3 position;
  public Vector3 normal;
  public Vector2 uv;

  public MeshVertex(Vector3 position, Vector3 normal, Vector2 uv) {
    this.position = position;
    this.normal = normal;
    this.uv = uv;
  }

  public MeshVertex(Vector3 position, Vector3 normal): this(position, normal, Vector2.zero){}
}

Для достаточно простого объекта, который к тому же создается в огромных количествах, автор выбрал class. Давайте попробуем изменить его на struct:

Тема "class vs struct" сложна и обширна. Но лучше всегда стараться разобраться в причинах своего выбора того или иного типа. В данном случае мы избавились от аллокаций. Но в дальнейшем эта конвертация нам также пригодится.

Следующее подозрительное место по памяти - сравнение объектов CurveSample. Посмотрим код:

public struct CurveSample {
	...
	public override bool Equals(object obj) {
      if (obj == null || GetType() != obj.GetType()) {
        return false;
      }
      CurveSample other = (CurveSample)obj;
      return location == other.location &&
        tangent == other.tangent &&
        up == other.up &&
        scale == other.scale &&
        roll == other.roll &&
        distanceInCurve == other.distanceInCurve &&
        timeInCurve == other.timeInCurve;
    }
	...
}

На этот раз автор выбрал struct, но лишь переопределил базовый метод сравнения, который неизбежно приводит к boxing/unboxing. Для того чтобы этого избежать страктам нужно определять метод сравнения с явным указанием типа, как-то так:

public bool Equals(CurveSample other) {
  return location == other.location &&
    tangent == other.tangent &&
    up == other.up &&
    scale == other.scale &&
    Math.Abs(roll - other.roll) < float.Epsilon &&
    Math.Abs(distanceInCurve - other.distanceInCurve) < float.Epsilon &&
    Math.Abs(timeInCurve - other.timeInCurve) < float.Epsilon;
}

public override bool Equals(object obj) {
  return obj is CurveSample other && Equals(other);
}

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

//Было
public UnityEvent Changed = new UnityEvent();
//Стало
public event Action Changed;

Все работает точно так же, но при этом в профайлере:

Теперь проведем замеры:

 Test Name

 Min

 Max

 Median

 Average

IL2CPP - 1 Big mesh

49.85

58.62

50.19

53.05

IL2CPP - 50 simple meshes

41.90

51.00

49.90

48.05

Mono - 1 Big mesh

102.12

110.73

108.03

107.18

Mono - 50 simple meshes

104.09

112.34

108.44

108.59

Мы многого и не ждали, но что-то да получили.

Оптимизируем вычисления

Теперь попробуем поэкспериментировать с Unity Job System с применением Burst Compiler. Начнем с CurveSample.GetBent(), т.к. по последним данным профайлера это самый "долгий" вызов.

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

for (var i = 0; i < _sourceVertices.Count; i++) {
  var vert = _sourceVertices[i];
  var distanceRate = source.Length == 0 ? 0 : Math.Abs(vert.position.x - source.MinX) / source.Length;
  if (!sampleCache.TryGetValue(distanceRate, out var sample)) {
    if (!useSpline) {
    	sample = curve.GetSampleAtDistance(curve.Length * distanceRate);
    } else {
    	var intervalLength =
    	intervalEnd == 0 ? spline.Length - intervalStart : intervalEnd - intervalStart;
    	var distOnSpline = intervalStart + intervalLength * distanceRate;
    	if (distOnSpline > spline.Length) {
    		distOnSpline = spline.Length;
  		}
  		sample = spline.GetSampleAtDistance(distOnSpline);
  	}

  	sampleCache[distanceRate] = sample;
  }

  var bent = sample.GetBent(vert);
  _vertices[i] = bent.position;
  _normals[i] = bent.normal;
}

Теперь же мы не будем делать вызовы sample.GetBent(vert) в цикле, мы будем только готовить данные для запуска нашей Job, в которой будем производить вычисления:

[BurstCompile]
public struct CurveSampleBentJob : IJobParallelFor {
	[ReadOnly]
	public NativeArray<CurveSample> Curves;
    [ReadOnly]
    public NativeArray<MeshVertex> VerticesIn;
    [WriteOnly]
    public NativeArray<float3> VerticesOut;
    [WriteOnly]
    public NativeArray<float3> NormalsOut;

	public void Execute(int i) {
    	var curve = Curves[i];
        var vertexIn = VerticesIn[i];
		var bent = new MeshVertex(vertexIn.position, vertexIn.normal, vertexIn.uv);
        // application of scale
        bent.position = new float3(0.0f, bent.position.y * curve.scale.y, bent.position.z * curve.scale.x);
        // application of roll
        bent.position = math.mul(quaternion.AxisAngle(new float3(1.0f, 0.0f ,0.0f), math.radians(curve.roll)), bent.position);
        bent.normal = math.mul(quaternion.AxisAngle(new float3(1.0f, 0.0f ,0.0f), math.radians(curve.roll)), bent.normal);
        bent.position.x = 0;
        // application of the rotation + location
        var q =  math.mul(curve.Rotation, quaternion.Euler(0.0f, math.radians(-90.0f), 0.0f));
        bent.position = math.mul(q, bent.position) + curve.location;
        bent.normal = math.mul(q, bent.normal);
        VerticesOut[i] = bent.position;
        NormalsOut[i] = bent.normal;    
	}
}

Немного пояснений. Атрибут [BurstCompile] нужен для того, чтобы наша Job компилировалась Burst'ом, интерфейс IJobParallelFor говорит о том, что она должна выполняться параллельно на массиве данных, индекс элемента для вычислений передается в метод Execute(int i). Также все вычисления переписаны с использованием пакета Unity.Mathematics, это позволяет Burst компилятору по возможности использовать SIMD инструкции для оптимизации вычислений. Во время перевода вычислений на Unity.Mathematics следует обращать внимание на некоторые различия между ней и стандартной математикой. Например, стандартный Quaternion.AngleAxis принимает угол в градусах, а Unity.Mathematics.quaternion.AxisAngle в радианах. Я не сразу заметил эту ошибку и некоторое время не мог понять, почему результаты вычислений не совпадали.

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

И переписываем код в MeshBender, отвечающий за запуск вычислений.

var jobVerticesIn = new NativeArray<MeshVertex>(_sourceVertices.Length, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
var jobVerticesOut = new NativeArray<Vector3>(_sourceVertices.Length, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
var jobNormalsOut = new NativeArray<Vector3>(_sourceVertices.Length, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
var jobCurveSamples = new NativeArray<CurveSample>(_sourceVertices.Length, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);

for (var i = 0; i < _sourceVertices.Length; i++) {
  var vert = _sourceVertices[i];
  var distanceRate = source.Length == 0 ? 0 : Math.Abs(vert.position.x - source.MinX) / source.Length;
  if (!sampleCache.TryGetValue(distanceRate, out var sample)) {
    if (!useSpline) {
      sample = curve.GetSampleAtDistance(curve.Length * distanceRate);
    } else {
      var intervalLength =
        intervalEnd == 0 ? spline.Length - intervalStart : intervalEnd - intervalStart;
      var distOnSpline = intervalStart + intervalLength * distanceRate;
      if (distOnSpline > spline.Length) {
        distOnSpline = spline.Length;
      }

      sample = spline.GetSampleAtDistance(distOnSpline);
    }
    sampleCache[distanceRate] = sample;
  }

  _curveSamples[i] = sample;
}

jobVerticesIn.CopyFrom(_sourceVertices);
jobCurveSamples.CopyFrom(_curveSamples);

var job = new CurveSampleBentJob {
  Curves = jobCurveSamples,
  VerticesIn = jobVerticesIn,
  VerticesOut = jobVerticesOut,
  NormalsOut = jobNormalsOut
  };
job.ScheduleParallel(_sourceVertices.Length, 4, default).Complete();

jobVerticesOut.CopyTo(_vertices);
jobNormalsOut.CopyTo(_normals);

jobCurveSamples.Dispose();
jobVerticesIn.Dispose();
jobVerticesOut.Dispose();
jobNormalsOut.Dispose();

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

 Test Name

 Min

 Max

 Median

 Average

IL2CPP - 1 Big mesh

40.54

42.66

41.69

41.67

IL2CPP - 50 simple meshes

40.56

42.83

41.64

41.69

Mono - 1 Big mesh

49.01

51.09

49.96

49.96

Mono - 50 simple meshes

66.82

75.54

74.30

72.56

И посмотрим еще на профайлер:

И обратим внимание на две вещи.

Первое: MeshBender.ComputeIfNeeded() теперь выполняется намного быстрее, а именно этого мы и добивались.

Второе: теперь CubicBezierCurve.ComputeSample() стал "лидером" по времени выполнения и кое-что с ним оказывается не так. Минутка вычислений: На сцене у нас 50 объектов, в каждом 3 "корня", в двух из них по 4 CubicBezierCurve, а в третьем 5. Значит кривых у нас на сцене должно быть 50 * (2 * 4 + 5) = 650. А вызовов 1300. Значит каждая кривая высчитывает сэмплы по два раза. А все потому, что каждая кривая задается двумя точками. И при изменении любой из точек она ловит событие об изменении и выполняет вычисления. Если за кадр изменились обе точки кривой, то она посчитает сэмплы лишний раз. В целом это к вопросу о необходимости осторожного обращения с событиями и возможном Event Hell.

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

Но количество вызовов мы свели к расчетному

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

 Test Name

 Min

 Max

 Median

 Average

IL2CPP - 1 Big mesh

40.68

42.62

41.68

41.66

IL2CPP - 50 simple meshes

40.70

42.70

41.66

41.64

Mono - 1 Big mesh

49.44

50.33

49.98

49.94

Mono - 50 simple meshes

57.43

59.44

58.32

58.29

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

public struct SourceMesh {
  private Vector3 translation;
  private Quaternion rotation;
  private Vector3 scale;

  internal Mesh Mesh { get; }
  ...
}

Возможно это было сделано для возможности подобных манипуляций избегая аллокаций:

mb.Source = SourceMesh.Build(tm.mesh)
  .Translate(tm.translation)
  .Rotate(Quaternion.Euler(tm.rotation))
  .Scale(tm.scale);

На данная проблема легко решается созданием специализированного конструктора

public SourceMesh(Mesh mesh, Vector3 translation, Quaternion rotation, Vector3 scale) {
  _translation = translation;
  _rotation = rotation;
  _scale = scale;
  BuildData(mesh);
}

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

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

for (var i = 0; i < _sourceVertices.Length; i++) {
  var vert = _sourceVertices[i];
  var distanceRate = source.Length == 0 ? 0 : Math.Abs(vert.position.x - source.MinX) / source.Length;
  if (!sampleCache.TryGetValue(distanceRate, out var sample)) {
    ...
  }
}

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

private Dictionary<float, List<int>> _sampleGroups;
...

private void BuildData(Mesh mesh) {
  ...
  for (var i = 0; i <Vertices.Length; i++) {
    var distanceRate = Length == 0 ? 0 : Math.Abs(Vertices[i].position.x - MinX) / Length;
    if (!_sampleGroups.TryGetValue(distanceRate, out var group)) {
      group = new List<int>();
      _sampleGroups[distanceRate] = group;
    }
    group.Add(i);
  }
}

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

foreach (var distanceRate in source.SampleGroups.Keys) {
  CurveSample sample;
  if (!useSpline) {
    sample = curve.GetSampleAtDistance(curve.Length * distanceRate);
  } else {
    var intervalLength =
      intervalEnd == 0 ? spline.Length - intervalStart : intervalEnd - intervalStart;
    var distOnSpline = intervalStart + intervalLength * distanceRate;
    if (distOnSpline > spline.Length) {
      distOnSpline = spline.Length;
    }
    sample = spline.GetSampleAtDistance(distOnSpline);
  }

  var sampleGroup = source.SampleGroups[distanceRate];

  for (var i = 0; i < sampleGroup.Count; i++) {
    _curveSamples[sampleGroup[i]] = sample;
  }
}

Теперь можно подумать, что еще из вычислений мы можем вынести в Job. Неплохим кандидатом является CubicBezierCurve.CreateSample(). Переносим все вычисления в отдельную Job, там я сразу заинлайнил все вызовы, чтобы сэкономить на нескольких вычислениях. Поэтому вместо:

private CurveSample CreateSample(float distance, float time) {
  return new CurveSample(
    GetLocation(time),
    GetTangent(time),
    GetUp(time),
    GetScale(time),
    GetRoll(time),
    distance,
    time);
}

Мы получаем вот такое:

[BurstCompile]
public struct ComputeSamplesJob : IJobParallelFor {
  public SplineNode Node1;
  public SplineNode Node2;
  [WriteOnly]
  public NativeArray<CurveSample> Samples;
  public void Execute(int i) {
  var time = (float)i / CubicBezierCurve.STEP_COUNT;
  //Location
  var omt = 1f - time;
  var omt2 = omt * omt;
  var t2 = time * time;
  var inverseDirection = 2 * Node2.Position - Node2.Direction;
  var location = Node1.Position * (omt2 * omt) +
                 Node1.Direction * (3f * omt2 * time) +
                 inverseDirection * (3f * omt * t2) +
                 Node2.Position * (t2 * time);
  //Tangent
  var tangent = Node1.Position * -omt2 +
                Node1.Direction * (3 * omt2 - 2 * omt) +
                inverseDirection * (-3 * t2 + 2 * time) +
                Node2.Position * t2;
  tangent = math.normalize(tangent);
  //Up
  var up = math.lerp(Node1.Up, Node2.Up, time);
  //Scale
  var scale = math.lerp(Node1.Scale, Node2.Scale, time);
  //Roll
  var roll = math.lerp(Node1.Roll, Node2.Roll, time);

  Samples[i] = new CurveSample(
      location,
      tangent,
      up,
      scale,
      roll,
      0.0f,
      time);
	}
}

Вычисление длины кривой тоже можно вынести:

[BurstCompile]
public struct ComputeCurveLengthJob : IJob {
  public NativeArray<CurveSample> Samples;
  public NativeArray<float> Length;
  public void Execute() {
    var length = 0.0f;
    for (var i = 0; i <= CubicBezierCurve.STEP_COUNT; i++) {
    var sample = Samples[i];
    if (i > 0) length += math.distance(Samples[i - 1].Location, sample.Location);
    sample.DistanceInCurve = length;
    Samples[i] = sample;
    }

    Length[0] = length;
  }
}

И вот так теперь выглядит их последовательное выполнение:

public void ComputeSamples() {
  if (!_isDirty) return;
  samples ??= new CurveSample[STEP_COUNT + 1];

  var jobCurveSamples = new NativeArray<CurveSample>(STEP_COUNT + 1, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
  var job = new ComputeSamplesJob {
  Node1 = _node1,
  Node2 = _node2,
  Samples = jobCurveSamples,
  };
  var jobHandle = job.Schedule(STEP_COUNT + 1, 4, default);

  var jobLength = new NativeArray<float>(1, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);

  var computeCurveLengthJob = new ComputeCurveLengthJob {
  Samples = jobCurveSamples,
  Length = jobLength
  };

  computeCurveLengthJob.Schedule(jobHandle).Complete();

  _length = jobLength[0];
  jobCurveSamples.CopyTo(samples);
  jobCurveSamples.Dispose();
  jobLength.Dispose();

  _isDirty = false;

  Changed?.Invoke();
}

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

 Test Name

 Min

 Max

 Median

 Average

IL2CPP - 1 Big mesh

32.32

34.36

33.32

33.32

IL2CPP - 50 simple meshes

40.04

58.73

41.66

43.07

Mono - 1 Big mesh

32.39

34.33

33.32

33.29

Mono - 50 simple meshes

40.38

51.52

41.65

42.18

Не самое важное наблюдение, но все же показатели Mono фактически сравнялись с IL2CPP.

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

Автор библиотеки вызывал RecalculateTangents() каждый раз при обновлении меша, но на наших материалах они в целом не нужны. Поэтому можно вынести это как доп. опцию для желающих. А при желании можно опять же поизобретать что-нибудь с Job System. Но пока просто сделаем отдельный флаг, по которому этот вызов будет включаться только в случае необходимости. И проведем финальные замеры.

 Test Name

 Min

 Max

 Median

 Average

IL2CPP - 1 Big mesh

7.53

9.38

8.32

8.32

IL2CPP - 50 simple meshes

23.31

33.35

25.01

26.35

Mono - 1 Big mesh

7.50

16.77

8.33

9.44

Mono - 50 simple meshes

23.83

50.21

24.95

27.42

А вот это уже интересно и с танжентами действительно стоит поразбираться на досуге.

Выводы и заключение

Сначала хотелось бы порассуждать о такой большой разнице между большим мешем и множеством мелких. Само по себе использование Job System не является панацеей и имеет свои накладные расходы в виде постоянного перегона данных между managed Array и NativeArray, а так же созданием этих самых NativeArray. Чем больше данных мы будем запихивать в один NativeArray и обсчитывать одной Job, тем заметнее будет выигрыш. А в данный момент каждая кривая и каждый MeshBender на сцене выполняют расчеты независимо от остальных и накладные расходы суммируются.

Самым удачным по моему мнению решением данной проблемы было бы использование ECS, благо другие составные части от DOTS мы уже используем и работать лучше всего они должны все вместе. Тогда бы мы могли написать отдельные системы для пересчета кривых и для обработки вершин мешей. А так же контролировать порядок их выполнения без необходимости использования LateUpdate. Но переход на ECS подразумевает уже полное переписывание проекта. Поэтому оставим это на светлое будущее. Или домашнее задание для желающих.

Так же неплохим подспорьем было бы использование Advanced Mesh API, которое в общем-то и предназначено для обработки мешей в Job System. В нынешнем виде мы слишком много гоняем данные туда сюда.

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

Что-нибудь я мог обязательно забыть или где-то ошибиться. Поэтому буду признателен за обратную связь.

Спасибо за внимание!

Источник: https://habr.com/ru/post/660535/


Интересные статьи

Интересные статьи

Как и любой почтовый сервер корпоративного класса, Carbonio CE тщательно фиксирует в специальном журнале все происходящие на сервере действия, начиная от отправки электронных писем и входа пользовател...
Вступление Привет, Хабр! Предыдущая часть понравилась многим, поэтому я снова перелопатил половину документации boost и нашёл о чем написать. Очень странно что вокруг boost.comput...
Делимся с вами подборкой вебинаров на тему разработки игр. Вы узнаете, как сделать простую консольную игру на PHP, 3D-арканоид на движке Unreal Engine 4, космическую аркаду и AR-приложение на...
В обновлении «Сидней» Битрикс выпустил новый продукт в составе Битрикс24: магазины. Теперь в любом портале можно создать не только лендинг или многостраничный сайт, но даже интернет-магазин. С корзино...
На размышления меня натолкнула статья об использовании «странной» инструкции popcount в современных процессорах. Речь пойдет не о подсчете числа единичек, а об обнаружении признака окончания Си с...