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.
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:
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.
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.
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.
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.
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.
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
.
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?
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.
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:
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?
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.
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:
_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.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:
_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._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.
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.
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#.
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.
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:
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:
target
/!target
are equivalent to target != null
/target == null
;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:
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!