>
>
>
PVS-Studio in development of Unity proj…

Andrey Moskalev
Articles: 12

PVS-Studio in development of Unity projects: new specialized diagnostic rules

To this day, Unity remains popular among thousands of developers. Many well-known games such as V Rising, Beat Saber, Hearthstone, Genshin Impact, and others have been created using this engine. How useful would the PVS-Studio analyzer be for developers of such projects? What errors and optimization opportunities could it help uncover? Let's find out.

Intro

First, I'd like to point out a perhaps obvious thing: writing code for a game is a difficult task. Maintaining its quality is just as challenging. Many bugs slip into the release and are later discovered by players, which significantly worsens their first impression. This can be a key factor in deciding to leave a negative review, post a critical analysis, or even uninstall the game altogether. In turn, this can negatively impact potential sales.

Beyond bugs, game projects face another equally—if not more—critical obstacle: poor optimization. Mainly because game code doesn't usually leverage parallelism. Developers often overlook the optimization of things like:

  • inefficient ordering of mathematical operations between complex and simple structures;
  • frequent memory allocation/deallocation that could have been avoided;
  • complex repetitive calculations, the result of which could be cached;
  • and so on.

Yet they forget that their code can run dozens of times per second. If such carelessness is high, game performance may decrease.

Just like bugs, poor optimization can negatively impact players' experience. It also acts as a limiting factor that may hinder further development of a project.

The conclusion is clear: to maximize the game's chances of success, developers should minimize the number of bugs and ensure high level of optimization at the very beginning of the development process. To achieve this, the most ambitious projects require special tools such as PVS-Studio. This is especially relevant now that the analyzer can detect both specific errors and optimization opportunities in Unity project code! In this article, we'll introduce you to these new capabilities.

What is PVS-Studio?

If you're hearing this name for the first time, PVS-Studio is a static code analyzer—a tool that automatically checks your project code without executing it, detecting potential errors, security vulnerabilities, and optimization opportunities.

If you aren't familiar with static code analyzers, you might think they are difficult to use and require extensive learning, but that's not the case. Installing the analyzer involves just a few simple steps, and using it is often as easy as clicking the analysis button and reviewing the warnings in a dedicated, user-friendly interface within your IDE or code editor.

What Unity-specific warnings can PVS-Studio issue now?

Our initial diagnostic rules for Unity are largely based on recommendations and specifics we discovered in the engine documentation. Currently, there are 20 such diagnostics in total. In this section, we'll take a look at just a few of them. You can find the full list at the end of the article.

Diagnostic rules for specific errors

V3216. Unity Engine. Checking a field with a specific Unity Engine type for null may not work correctly due to implicit field initialization by the engine.

A link to the diagnostic rule documentation.

So, the first rule in our overview warns about errors related to unusual behavior when the engine implicitly initializes fields. This happens if the field type is UnityEngine.Object or derived from it (except for the MonoBehaviour and ScriptableObject classes), and the fields can be displayed in the Unity inspector.

Let's say we have a field in our code that doesn't have a value assigned to it neither in its declaration nor in the Unity inspector. In this case, we initialize it with some default value at runtime, which can look like this:

[SerializeField] GameObject DefaultTarget;
public GameObject Target;
....

void Update()
{
  var target = Target ?? DefaultTarget;
  ....
}

If a value hasn't been explicitly assigned to Target, we expect the field to be null in Update. This isn't really the case, because Target will be implicitly initialized with an object equivalent to null from Unity's point of view:

However, this object is valid for the ?? operator, so no value of DefaultTarget will be assigned to the target variable.

To avoid such surprises when checking for null, use the == and != operators or the shorthand field and !field checks, which have special overloads that consider Unity-specific behavior.

V3209. Unity Engine. Using await on 'Awaitable' object more than once can lead to exception or deadlock, as such objects are returned to the pool after being awaited.

A link to the diagnostic rule documentation.

The Awaitable class is a relatively new mechanism for asynchronous and parallel programming that appeared in the 2023.1 version of Unity. It's essentially the Task analog. The main difference is that in some cases, Awaitable significantly improves performance. See the Unity documentation for more details about the class.

Awaitable has limitations—one of them is that the await operator can be applied only once to each instance of Awaitable. This is because they are pooled, and when await is applied to them, they return to that pool. Re-applying await to the same reference can lead to unpredictable negative consequences, such as a race condition or an exception being thrown.

V3214. Unity Engine. Using Unity API in the background thread may result in an error.

A link to the diagnostic rule documentation.

In addition to asynchronous execution, the Awaitable class enables you to switch the execution to a background thread using Awaitable.BackgroundThreeadAsync. To do this, call this method using await, then any subsequent code in the current method will run in the background thread, but when the next frame is processed. To switch the execution back to the main thread (with a frame delay as well), use the Awaitable.MainThreadAsync method.

There's also a limitation here: it's impossible to call the Unity API from the background thread to change any state in the game (to load a new scene, change object positions, start an animation, etc.). This leads to unpredictable consequences like the game freezing or throwing an exception. However, you can safely use functions from Mathf as well as all the basic data structures like Vector3 and Quaternion.

V3206. Unity Engine. A direct call to the coroutine-like method will not start it. Use the 'StartCoroutine' method instead.

A link to the diagnostic rule documentation.

This diagnostic rule warns about an attempt to start coroutine as a regular method, without calling StartCoroutine. At first glance, this may seem like a beginner's mistake, but it can just as easily happen due to tiredness or carelessness—leading to wasted time trying to determine why some code doesn't work even though there are no error messages. It's much better to run the analyzer after writing a script, get a warning in a few seconds, and immediately fix it, isn't it?

V3215. Unity Engine. Passing a method name as a string literal into the 'StartCoroutine' is unreliable.

A link to the diagnostic rule documentation.

Let's continue catching possible issues with coroutines. This diagnostic rule warns that a coroutine is being executed by passing a string literal to StartCoroutine. This alone isn't an error, at least for now... At some point, the coroutine name may change, and if this change is also ignored in such calls, they will simply stop working.

The best way to start the coroutine is to pass its IEnumerator to StartCoroutine, but if starting by name is important for some reason, consider using the nameof operator.

Optimization diagnostic rules

As mentioned earlier, some Unity script code runs very often, especially in methods like Update, FixedUpdate, etc. This means that the following things should be avoided in it:

  • repeated execution of compute-intensive operations whose results can be cached;
  • regular memory allocation/deallocation (since frequent memory allocation leads to frequent garbage collector calls, which negatively affects performance);
  • inefficient mathematical operations with complex structures like Vector3 and Quaternion.

However, in the midst of development, it can be hard to keep track of these things, so it's good to have a tool that reminds you about them, right?

V4005. Unity Engine. The expensive operation is performed inside method or property. Using such member in performance-sensitive context can lead to decreased performance.

A link to the diagnostic rule documentation.

The diagnostic rule warns about calls to common and relatively heavy functions such as FindObjectOfType, FindGameObjectWithTag, GetComponent, etc. within frequently executed code.

In most cases, calling these functions, for example, when processing each frame, is redundant, since at each call they recalculate the result, which can often be cached and then reused.

V4006. Unity Engine. Multiple operations between complex and numeric values. Prioritizing operations between numeric values can optimize execution time.

A link to the diagnostic rule documentation.

We're used to thinking that the order of multipliers in a mathematical expression doesn't matter. However, this isn't the case when the expression involves not only familiar numbers, but also complex structures such as vectors. In this case, the order in which the multiplication operations are performed may determine the actual number of calculations. For optimization purposes, it's better to minimize the latter.

The diagnostic rule points out such optimization opportunities. For clarity, let's count the number of calculations performed in the two versions of the same expression:

  • _requestedMoveDirection * _moveSpeed * Time.deltaTime;
  • _requestedMoveDirection * (_moveSpeed * Time.deltaTime).

Here, _requestedMoveDirection has the Vector3 type, whereas _moveSpeed and Time.deltaTime are real numbers of the float type.

In the first case:

  • Multiplying _requestedMoveDirection by _moveSpeed actually performs three multiplication operations, because when Vector3 is multiplied by a number, each of the three vector components is multiplied by that number. This will result in a new vector.
  • The result of the previous operation is multiplied by Time.deltaTime, which also requires three multiplication operations.

This makes a total of six operations.

Now, let's break down the second option in the same way:

  • The _moveSpeed variable is multiplied by Time.deltaTime. Since this is multiplying a number by a number, only one multiplication operation is performed, and the result is also a number.
  • The _requestedMoveDirection variable is multiplied by the result of the previous operation. As we already know, this requires three multiplication operations.

This makes a total of four operations.

The difference may not seem significant, but keep in mind that this operation may be executed several dozen times per second, and the code may contain many such operations.

Moreover, the above expression is quite simple, but the more multipliers it contains, the more noticeable the effect of optimization will be. If we compare the following expressions...

  • Vector3 * float * float * float * float;
  • Vector3 * (float * float * float * float).

...then we'll get 15 and 6 operations, respectively.

V4007. Unity Engine. Avoid creating and destroying UnityEngine objects in performance-sensitive context. Consider activating and deactivating them instead.

A link to the diagnostic rule documentation.

Did you know that regularly creating and destroying game objects in methods like Update is a bad practice? This causes large amounts of memory to be allocated and released frequently, making the garbage collector work harder and slowing down your game.

In most cases, developers can replace object creation and destruction operations with enable, disable, and reset state operations.

Since the disabled object still exists, the memory it occupies won't be freed (so, it won't be re-allocated). Also, in terms of computational resources, enabling an object is much "cheaper" for the engine than creating a new object instance.

If there's only one such object (let's say, a modal window), you can create an instance of it—for example, in the Start method—cache it in a field, and then use the field in Update, enabling/disabling it in the scene as needed.

If there are a lot of regularly created/destroyed objects (e.g., projectiles), then object pools should do the trick. By the way, Unity already has universal pool implementations, so all you need to do is define a few handlers to use them.

V4008. Unity Engine. Avoid using memory allocation Physics APIs in performance-sensitive context.

A link to the diagnostic rule documentation.

Unity 5.3 introduced non-allocating versions of cast methods in Physics.

So, for example, a non-allocating analog of Physics.RaycastAll was introduced for Physics.RaycastNonAlloc. The difference between them is that Physics.RaycastAll creates a new collection for the result each time it's called. And as a parameter, Physics.RaycastNonAlloc takes an already created buffer to which the received RaycastHit will be added. In the first case, each call results in a new memory allocation, which we already know isn't good. In the second case, you can put the buffer in a field and use the Clear method to reset it before each new use.

Note that if the buffer is a regular list, calling Clear won't free the memory allocated for that buffer. If this is a new thing for you, you might be interested in our article on the peculiarities of list implementation in C#.

Full list of new diagnostic rules

We've looked at a few examples of new Unity-specific diagnostic rules, and as promised, here's the complete list.

V3188. Unity Engine. The value of an expression is a potentially destroyed Unity object or null. Member invocation on this value may lead to an exception.

V3205. Unity Engine. Improper creation of 'MonoBehaviour' or 'ScriptableObject' object using the 'new' operator. Use the special object creation method instead.

V3206. Unity Engine. A direct call to the coroutine-like method will not start it. Use the 'StartCoroutine' method instead.

V3208. Unity Engine. Using 'WeakReference' with 'UnityEngine.Object' is not supported. GC will not reclaim the object's memory because it is linked to a native object.

V3209. Unity Engine. Using await on 'Awaitable' object more than once can lead to exception or deadlock, as such objects are returned to the pool after being awaited.

V3210. Unity Engine. Unity does not allow removing the 'Transform' component using 'Destroy' or 'DestroyImmediate' methods. The method call will be ignored.

V3211. Unity Engine. The operators '?.', '??' and '??=' do not correctly handle destroyed objects derived from 'UnityEngine.Object'.

V3212. Unity Engine. Pattern matching does not correctly handle destroyed objects derived from 'UnityEngine.Object'.

V3213. Unity Engine. The 'GetComponent' method must be instantiated with a type that inherits from 'UnityEngine.Component'.

V3214. Unity Engine. Using Unity API in the background thread may result in an error.

V3215. Unity Engine. Passing a method name as a string literal into the 'StartCoroutine' is unreliable.

V3216. Unity Engine. Checking a field with a specific Unity Engine type for null may not work correctly due to implicit field initialization by the engine.

V4001. Unity Engine. Boxing inside a frequently called method may decrease performance.

V4002. Unity Engine. Avoid storing consecutive concatenations inside a single string in performance-sensitive context. Consider using StringBuilder to improve performance.

V4003. Unity Engine. Avoid capturing variable in performance-sensitive context. This can lead to decreased performance.

V4004. Unity Engine. New array object is returned from method or property. Using such member in performance-sensitive context can lead to decreased performance.

V4005. Unity Engine. The expensive operation is performed inside method or property. Using such member in performance-sensitive context can lead to decreased performance.

V4006. Unity Engine. Multiple operations between complex and numeric values. Prioritizing operations between numeric values can optimize execution time.

V4007. Unity Engine. Avoid creating and destroying UnityEngine objects in performance-sensitive context. Consider activating and deactivating them instead.

V4008. Unity Engine. Avoid using memory allocation Physics APIs in performance-sensitive context.

What else is special about PVS-Studio?

I'd like to note that Unity-specific diagnostic rules are only a small part of PVS-Studio's capabilities of analyzing C# code. The analyzer can detect many errors that are relevant to all C# projects. These include diagnostic rules that detect:

  • potential null dereferencing;
  • unreachable code;
  • always true/always false conditions;
  • errors caused by an incorrect understanding of arithmetic operation precedence;
  • integer overflow;
  • dozens of typo patterns in the code;
  • etc.

You can find the full list of C# diagnostic rules and more on our website.

In addition, the analyzer provides mechanisms that adjust and enhance the work of some general diagnostic rules, considering the specifics of Unity scripts. So, the analyzer:

  • recognizes that shorthand checks like target/!target are equivalent to target != null/target == null;
  • understands that these checks can indicate not only whether an object is null but also whether it has been destroyed in the game scene;
  • utilizes an annotation mechanism (we have a whole article about it) to consider the specifics of Unity's main APIs that can't be calculated automatically.

Conclusion

If you're a Unity developer and the PVS-Studio code analyzer has caught your attention, we'd like to recommend some of our articles:

There, you may find answers to questions you might have had while reading this article.

I'd also like to hear your thoughts on a few things:

  • How useful are tools like PVS-Studio for developing Unity-specific projects?
  • Are you familiar with DOTS, and if so, would you find it helpful to have a tool that gives you tips for fixing and optimizing your ECS code?

I'd be happy to read your responses in the comments :)

I'd also like to mention that you can always try PVS-Studio for free on your own projects by requesting a trial version on the official website. See you in the next articles!