Наконец, состоялся релиз Unity 6! Разработчики называют эту версию самой стабильной версией за всё время существования движка. Почему бы нам не убедиться в этом с помощью статического анализатора кода? А заодно проведём краткий обзор основных фич и улучшений, привнесённых обновлением.

Введение

Unity 6 — это глобальное обновление, нацеленное на улучшение производительности и стабильности движка, но далеко не ограничивающееся этим. Улучшения затронули самые разные аспекты: инструментарий мультиплеера, систему освещения, рендеринг, постобработку, XR-инструментарий, некоторые визуальные эффекты и пр. Отдельно хочу отметить, что была добавлена система для интеграции нейросетей.

Хотя preview-версия Unity 6 уже была доступна с 15 мая 2024 года, возможно вы, как и я, лишь недавно заинтересовались что же особенного в этом обновлении. Поэтому в первой части статьи я хочу провести краткий обзор наиболее значительных, на мой взгляд, улучшений, но не буду сильно углубляться в детали. Вместо этого я поделюсь ссылками, по которым вы сможете получить больше информации.

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

Приятного чтения!

Что нового в Unity 6?

Итак, давайте узнаем, что же команда Unity приготовила для нас на этот раз.

Интеграция ИИ на основе искусственных нейронных сетей

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

Расширение инструментария для создания многопользовательских проектов

  • Новый пакет Multiplayer Center. Представляет отправную точку для создания многопользовательской игры. Он рекомендует пакеты для мультиплеера в зависимости от заданных параметров игры и предоставляет доступ к соответствующим примерам и руководствам.

  • Добавлен Multiplayer Play Mode. Позволяет моделировать до 4-х игроков прямо из редактора Unity.

  • В пакет Netcode for GameObjects добавлена поддержка Distributed authority topologies. А в пакет Multiplayer Tools добавлен новый инструмент Network scene visualization, который, помимо прочего, должен помочь в отладке проектов, использующих новую топологию.

  • Добавлен пакет для поддержки разработки игр и приложений на платформе выделенного сервера.

Рисунок N1 — Multiplayer Play Mode на практике

Оптимизация нагрузки на CPU

  • Новая система рендеринга GPU Resident Drawer. Эффективность этой системы напрямую зависит от среднего количества экземпляров объектов в сцене. Чем больше в сцене экземпляров одних и тех же объектов (например, растительности, созданной с помощью SpeedTree) — тем больше польза. Данная система имеет ряд технических ограничений. Например, она оптимизирует рендеринг только экземпляров объектов со статическими сетками. Следовательно, она не будет работать в случае экземпляров систем частиц и прочих эффектов.

  • Новая функция GPU Occlusion Culling. Данная функция выполняет исключение из рендеринга полностью затенённых объектов на GPU вместо CPU.

Оптимизация нагрузки на GPU

  • Новая функция Foveated Rendering. Уменьшает нагрузку графического процессора в XR-проектах за счёт снижения детализации в периферийном зрении пользователя. Имеет два режима:

    • Fixed foveated — рендеринг центральной области дисплея для каждого глаза с максимальным разрешением и снижение разрешения на периферии;

    • Gaze-based foveated — в этом режиме используется отслеживание глаз для определения области экрана с максимальным разрешением.

  • Уменьшение нагрузки на GPU за счёт внедрения автоматического объединения этапов рендеринга в Render Graph. Также GPU можно оптимизировать с помощью создания собственных этапов и проходов между ними. Стоит также отметить, что теперь Render Graph доступен не только в HDRP, но и в URP.

Улучшение качества рендеринга среды

  • Улучшена визуализация неба для заката и восхода солнца в HDRP. Добавлены эффекты озонового слоя и рассеивания в атмосфере в дополнение к туману на больших расстояниях;

  • Улучшена визуализация воды (в HDRP) посредством добавления эффекта подводного объёмного тумана.

В левой части изображения эффект озонового слоя не используется в отличии от правой.

Эффект подводного тумана.

Прочие улучшения

  • Были внесены важные улучшения в XR-инструментарий;

  • Редактор Unity 6, в отличии от предыдущих версий, теперь работает на устройствах Windows на базе Arm;

  • Улучшено окно сборки, были добавлены новые профили сборки;

  • Были внесены улучшения, связанные с освещением. В частности, был добавлен новый способ создания глобального освещения;

  • Исправлены основные известные проблемы, а также внесены некоторые улучшения в Shader Graph.

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

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

Разбор новых ошибок в коде движка

Итак, вооружившись анализатором кода PVS-Studio, я исследовал C#-часть наиболее актуальной на момент написания статьи версии движка (6000.0.21f1) и нашёл несколько интересных как потенциальных, так и явных ошибок. Однако вместо того, чтобы просто описать вам их я бы хотел предложить вам испытать свои силы в роли статического анализатора кода. Попробуйте отыскать по одной ошибке в приведённых фрагментах кода и придумать способы их исправления. Под каждым из фрагментов есть полный ответ, с помощью которого вы можете проверить своё предположение.

Да начнутся голодные игры испытания и пусть программистская смекалка всегда будет с вами! Кстати, будет круто, если вы поделитесь результатами в комментариях :)

Первое испытание

public bool propagationHasStopped { get; }
public bool immediatePropagationHasStopped { get; }
public bool defaultHasBeenPrevented { get; }

public EventDebuggerCallTrace(IPanel panel, EventBase evt, 
                              int cbHashCode, string cbName,
                              bool propagationHasStopped,
                              bool immediatePropagationHasStopped,
                              long duration,
                              IEventHandler mouseCapture): base(....)
{
  this.callbackHashCode = cbHashCode;
  this.callbackName = cbName;
  this.propagationHasStopped = propagationHasStopped;
  this.immediatePropagationHasStopped = immediatePropagationHasStopped;
  this.defaultHasBeenPrevented = defaultHasBeenPrevented;
}
Ответ спрятан здесь

Сообщение анализатора:

V3005. The 'this.defaultHasBeenPrevented' variable is assigned to itself. EventDebuggerTrace.cs 42.

По какой-то причине при инициализации свойство defaultHasBeenPrevented присваивается само себе. Так как у свойства отсутствует сеттер, его значение больше никогда не изменится и всегда будет равно значению по умолчанию — false.

Возможное исправление: добавить в конструктор новый параметр для инициализации свойства.

Слишком просто? Будем считать это разминкой, посмотрим, как вы справитесь дальше!

Второе испытание

public void ParsingPhase(....)
{
  ....
  SpriteCharacter sprite = 
    (SpriteCharacter)textInfo.textElementInfo[m_CharacterCount]
                             .textElement;

  m_CurrentSpriteAsset = sprite.textAsset as SpriteAsset;
  m_SpriteIndex = (int)sprite.glyphIndex;

  if (sprite == null)
    continue;

  if (charCode == '<')
    charCode = 57344 + (uint)m_SpriteIndex;
  else
    m_SpriteColor = Color.white;
}
Ответ спрятан здесь

Сообщение анализатора:

V3095. The 'sprite' object was used before it was verified against null. Check lines: 310, 312. TextGeneratorParsing.cs 310.

Переменная sprite разыменовывается прямо перед проверкой на null:

m_CurrentSpriteAsset = sprite.textAsset as SpriteAsset;
m_SpriteIndex = (int)sprite.glyphIndex;

if (sprite == null)
  continue;

В случае если sprite действительно окажется равным null, это неизбежно приведёт к исключению. Вероятно, при добавлении строк c разыменованием в метод не обратили внимание на важность порядка выполнения операций. Таким образом, избежать проблемы можно, если перенести разыменования под проверку на null:

if (sprite == null)
  continue;

m_CurrentSpriteAsset = sprite.textAsset as SpriteAsset;
m_SpriteIndex = (int)sprite.glyphIndex;

Третье испытание

private static void CompileBackgroundPosition(....)
{
  ....
  else if (valCount == 2)
  {
    if (((val1.handle.valueType == StyleValueType.Dimension) ||
         (val1.handle.valueType == StyleValueType.Float)) &&

        ((val1.handle.valueType == StyleValueType.Dimension) || 
         (val1.handle.valueType == StyleValueType.Float)))
    {
      .... = new BackgroundPosition(...., val1.sheet
                                              .ReadDimension(val1.handle)
                                              .ToLength());

      .... = new BackgroundPosition(...., val2.sheet
                                              .ReadDimension(val2.handle)
                                              .ToLength());
    }
    else if ((val1.handle.valueType == StyleValueType.Enum)) &&
             (val2.handle.valueType == StyleValueType.Enum)
    ....
  {
   
 }
Ответ спрятан здесь

Сообщение анализатора:

V3001. There are identical sub-expressions to the left and to the right of the '&&' operator. StyleSheetApplicator.cs 169.

Анализатор подсказывает, что в одном из условий через оператор && выполняются два одинаковых выражения:

if (((val1.handle.valueType == StyleValueType.Dimension) ||
     (val1.handle.valueType == StyleValueType.Float)) &&

    ((val1.handle.valueType == StyleValueType.Dimension) || 
     (val1.handle.valueType == StyleValueType.Float)))

Здесь явно допущена ошибка. Более того очевидно и её исправление. Обратите внимание на остальной код. В нём повсеместно выполняются аналогичные операции сначала с использованием val1, а потом с использованием val2. Например, следующее за рассматриваемым условие, состоит из двух аналогичных проверок:

else if ((val1.handle.valueType == StyleValueType.Enum)) &&
         (val2.handle.valueType == StyleValueType.Enum)

Разница между ними заключается лишь в том, что в первой используется значение val1, а во второй — val2.

Таким образом, исправление ошибки, вероятнее всего, будет заключаться в замене всех val1 на val2 в повторяющемся выражении некорректного условия:

if (((val1.handle.valueType == StyleValueType.Dimension) ||
     (val1.handle.valueType == StyleValueType.Float)) &&

    ((val2.handle.valueType == StyleValueType.Dimension) || 
     (val2.handle.valueType == StyleValueType.Float)))

Четвёртое испытание

public partial class BuildPlayerWindow : EditorWindow
{
  ....
  internal static event Action<BuildProfile> 
                        drawingMultiplayerBuildOptions;
  ....
}

internal static class EditorMultiplayerManager
{
  ....
  public static event Action<NamedBuildTarget> drawingMultiplayerBuildOptions
  {
    add => BuildPlayerWindow.drawingMultiplayerBuildOptions += 
                                             (profile) => ....;
    remove => BuildPlayerWindow.drawingMultiplayerBuildOptions -= 
                                                (profile) => ....;
  }
  ....
}
Ответ спрятан здесь

Сообщение анализатора:

V3084. Anonymous function is used to unsubscribe from 'drawingMultiplayerBuildOptions' event. No handlers will be unsubscribed, as a separate delegate instance is created for each anonymous function declaration. EditorMultiplayerManager.bindings.cs 48.

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

Один из вариантов решения — вместо анонимной функции реализовать полноценный метод и использовать его для подписки/отписки на/от события.

Пятое испытание

public void DoRenderPreview(Rect previewRect, GUIStyle background)
{
  ....
  Matrix4x4 shadowMatrix;
  RenderTexture shadowMap = RenderPreviewShadowmap(....);

  if (previewUtility.lights[0].intensity != kDefaultIntensity ||  
      previewUtility.lights[0].intensity != kDefaultIntensity)
  {
    SetupPreviewLightingAndFx(probe);
  }

  float tempZoomFactor = (is2D ? 1.0f : m_ZoomFactor);
  previewUtility.camera.orthographic = is2D;
  if (is2D)
    previewUtility.camera.orthographicSize = 2.0f * m_ZoomFactor;
  ....
}

private void SetupPreviewLightingAndFx(SphericalHarmonicsL2 probe)
{
  previewUtility.lights[0].intensity = kDefaultIntensity;
  previewUtility.lights[0].transform.rotation = ....;
  previewUtility.lights[1].intensity = kDefaultIntensity;
  ....
}
Ответ спрятан здесь

Сообщение анализатора:

V3001. There are identical sub-expressions 'previewUtility.lights[0].intensity != kDefaultIntensity' to the left and to the right of the '||' operator. AvatarPreview.cs 721.

Условие первого if-оператора в методе DoRenderPreview состоит из двух одинаковых подвыражений. Это может быть как ошибкой, так и просто избыточным кодом, оставленным по невнимательности.

На наличие именно ошибки здесь намекает реализация метода SetupPreviewLightingAndFx, в которой используется не только previewUtility.lights[0], но и previewUtility.lights[1].

Таким образом, исправление ошибки может выглядеть следующим образом:


if (previewUtility.lights[0].intensity != kDefaultIntensity ||  
    previewUtility.lights[1].intensity != kDefaultIntensity)
{
  ....
}

Шестое испытание

void UpdateInfo()
{
  ....

  var infoLine3_format = "<color=\"white\">CurrentElement:" +
                         " Visible:{0}" +
                         " Enable:{1}" +
                         " EnableInHierarchy:{2}" +
                         " YogaNodeDirty:{3}";

  m_InfoLine3.text = string.Format(infoLine3_format,
                                   m_LastDrawElement.visible,
                                   m_LastDrawElement.enable,
                                   m_LastDrawElement.enabledInHierarchy,
                                   m_LastDrawElement.isDirty);

  var infoLine4_format = "<color=\"white\">" +
                         "Count of ZeroSize Element:{0} {1}%" +
                         " Count of Out of Root Element:{0} {1}%";

  m_InfoLine4.text = string.Format(infoLine4_format,
                                   countOfZeroSizeElement,
                                   100.0f * countOfZeroSizeElement / count,
                                   outOfRootVE,
                                   100.0f * outOfRootVE / count);
  ....
}
Ответ спрятан здесь

Сообщение анализатора:

V3025. Incorrect format. A different number of format items is expected while calling 'Format' function. Arguments not used: 3rd, 4th. UILayoutDebugger.cs 179.

Обратите внимание на второй string.Format(....). В его строке формата (первый аргумент) имеется 4 слота для вставки. Значений для вставки (2-5 аргумент) также передаётся 4. Проблема в том, что слоты содержат только номера 0 и 1. В результате в них будут вставлены только первое и второе значение, тогда как остальные два не будут использованы вовсе.

Исправленная строка формата выглядит следующим образом:

var infoLine4_format = "<color=\"white\">" +
                       "Count of ZeroSize Element:{0} {1}%" +
                       " Count of Out of Root Element:{2} {3}%";

Седьмое испытание

protected static bool IsFinite(float f)
{
  if (   f == Mathf.Infinity 
      || f == Mathf.NegativeInfinity 
      || f == float.NaN)
  {
    return false;
  }

  return true;
}
Ответ спрятан здесь

Сообщение анализатора:

V3076. Comparison of 'f' with 'float.NaN' is meaningless. Use 'float.IsNaN()' method instead. PhysicsDebugWindowQueries.cs 87.

Ошибка здесь связана с не очень очевидным поведением. Сравнение двух значений равных NaN всегда ложно. Поэтому, как и советует анализатор, вместо выражения f == float.NaN здесь следует использовать float.IsNaN(f).

Восьмое испытание

public readonly struct SearchField : IEquatable<SearchField>
{
  ....
  public override bool Equals(object other)
  {
    return other is SearchIndexEntry l && Equals(l);
  }

  public bool Equals(SearchField other)
  {
    return string.Equals(name, other.name, StringComparison.Ordinal);
  }
}
Ответ спрятан здесь

Сообщение анализатора:

V3197. The compared value inside the 'Object.Equals' override is converted to the 'SearchIndexEntry' type instead of 'SearchField' that contains the override. SearchItem.cs 634.

Как сказано в сообщении анализатора, в первом методе Equals параметр other ошибочно приводится к типу SearchIndexEntry вместо SearchField. Из-за чего при последующем вызове Equals(l) будет вызвана та же самая перегрузка метода. Если же вдруг окажется, что other действительно имеет тип SearchIndexEntry, произойдёт зацикливание кода. Это приведёт к StackOverflowException.

Девятое испытание

private void DrawRenderTargetToolbar()
{
  float blackMinLevel = ....;
  float blackLevel = ....;
  float whiteLevel = ....;
  EditorGUILayout.MinMaxSlider(....);
  float whiteMaxLevel = ....;

  if (blackMinLevel < whiteMaxLevel && whiteMaxLevel > blackMinLevel)
  {
    m_RTBlackMinLevel = blackMinLevel;
    m_RTWhiteMaxLevel = whiteMaxLevel;

    m_RTBlackLevel = Mathf.Clamp(blackLevel, 
                                 m_RTBlackMinLevel, 
                                 whiteLevel);

    m_RTWhiteLevel = Mathf.Clamp(whiteLevel, 
                                 blackLevel,
                                 m_RTWhiteMaxLevel);
  }
}
Ответ спрятан здесь

Сообщение анализатора:

V3001. There are identical sub-expressions 'blackMinLevel < whiteMaxLevel' to the left and to the right of the '&&' operator. FrameDebuggerEventDetailsView.cs 364.

Снова перед нами if-оператор, условие которого состоит из двух, по сути, одинаковых выражений, которые отличаются друг от друга лишь порядком операндов. Вероятно, вместо blackMinLevel во втором выражении должно быть что-то другое. Рассмотрев окружающий код, наиболее логичным вариантом кажется whiteLevel. Таким образом, исправленное условие могло бы выглядеть следующим образом:

if (blackMinLevel < whiteMaxLevel && whiteMaxLevel > whiteLevel)
{
  ....
}

Десятое испытание

internal static IEnumerable<Sample> FindByPackage(PackageInfo package, ....)
{
  if (string.IsNullOrEmpty(package?.upmReserved) && 
      string.IsNullOrEmpty(package.resolvedPath))
  {
    return Enumerable.Empty<Sample>();
  }
  try
  {
    IEnumerable<IDictionary<string, object>> samples = null;
    var upmReserved = upmCache.ParseUpmReserved(package);
    if (upmReserved != null)
        samples = upmReserved.GetList<....>("samples");
    ....
  }
  ....
}
Ответ спрятан здесь

Сообщение анализатора:

V3042. Possible NullReferenceException. The '?.' and '.' operators are used for accessing members of the 'package' object. PackageSample.cs 102.

Проверкой в начале метода разработчик попытался обработать сразу несколько случаев:

  • когда package.upmReserved равен null или пустой строке;

  • когда package.resolvedPath равен null или пустой строке;

  • когда package равен null.

Однако в большинстве случаев она будет работать некорректно:

  • если package равен null, то NullReferenceException будет выброшен уже при разыменовании во втором подвыражении самой проверки;

  • если либо package.upmReserved, либо package.resolvedPath будет равен null или пустой строке (но не оба сразу) выхода из метода, вопреки ожиданиям, не произойдёт.

Возможно, разработчик по невнимательности использовал оператор && вместо ||. Таким образом, исправленная версия проверки может выглядеть так:

if (string.IsNullOrEmpty(package?.upmReserved) || 
    string.IsNullOrEmpty(package.resolvedPath))
{
  return Enumerable.Empty<Sample>();
}

Одиннадцатое испытание

[RequiredByNativeCode]
internal static void InvalidateAll()
{
  lock (s_Instances)
  {
    foreach (var kvp in s_Instances)
    {
      WeakReference wr = kvp.Value;
      if (wr.IsAlive)
        (wr.Target as TextGenerator).Invalidate();
     }
   }
}
Ответ спрятан здесь

Сообщение анализатора:

V3145. Unsafe dereference of a WeakReference target. The object could have been garbage collected between checking 'IsAlive' and accessing the 'Target' property. TextGenerator.cs 140.

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

  1. Не все знакомы с понятием слабой ссылки (WeakReference). Объект, на который ссылается такая ссылка, в любой момент может быть удален сборщиком мусора, несмотря на наличие слабой ссылки.

  2. Свойство WeakReference.IsAlive позволяет проверить существует ли ещё объект, на который ссылается слабая ссылка. Но в данном коде не учитывается, что этот самый объект может быть отчищен уже после прохождения проверки, перед разыменованием. В результате всё равно может быть выброшен NullReferenceException.

  3. Можно было бы предположить, что оператор lock может защитить объект от сборки мусора. Однако после воспроизведения такого кейса выяснилось, что это не так.

Так как же защититься в данном случае? Чтобы наверняка избежать исключения NullReferenceException, следует создать сильную ссылку на объект. В таком случае сборщик мусора уже не сможет его удалить, пока ссылка остается актуальной. Проще говоря, нужно создать простую локальную переменную, ссылающуюся на этот объект, и дальше работать уже с ней. Таким образом, безопасная версия метода может выглядеть так:


[RequiredByNativeCode]
internal static void InvalidateAll()
{
  lock (s_Instances)
  {
    foreach (var kvp in s_Instances)
    {
      WeakReference wr = kvp.Value;
      var target = wr.Target;

      If (target != null)
        (target as TextGenerator).Invalidate();     
     }
   }
}

Заключение

Ну как, справились? Не устали? Что же, пришло время отдохнуть, ведь статья подходит к концу. Надеюсь, она показалась вам не только полезной, но и увлекательной.

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

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

Кстати, с помощью анализатора кода PVS-Studio можно проанализировать не только сам Unity, но и игры, реализованные с помощью Unity. Больше информации об этом вы сможете найти в документации.

До встречи в следующих статьях!

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Moskalev. What's new in Unity 6? Overview of release updates and source code issues.

Комментарии (4)


  1. DrRen7
    17.10.2024 16:27

    Хмм интересно

    Built-in render pipeline теперь вроде как не стандартный

    New input system терь стоит со стандартной(both), в 6.1 стандартную хотят вроде добавить в legacy.

    А так пока ничего из нового не пощупал(


  1. CrazyElf
    17.10.2024 16:27

    А в момент выполнения кода var target = wr.Target; не может опять же оказаться, что wr уже собрана GC и там опять же null? Ну то есть может на всякий случай тогда брать значение с учётом этой возможности var target = wr?.Target; Или, если на WeekReference объект есть ссылка, то GC его уже не соберёт? Надо почитать про слабые ссылки внимательнее что ли...


    1. mukhinid
      17.10.2024 16:27

      Я так понимаю, GC может собрать то, что лежит в wr.Target, сам объект WeakReference останется и проверять его на null не нужно


      1. CrazyElf
        17.10.2024 16:27

        Да, спасибо, я почитал доку, тоже понял именно так.