Yesterday, I downloaded a few small, amazing, beautiful, first person, art games / experiences from itch.io, and WOW!!!
And the problem is not really that the controller is bad (..except it is), it's that A LOT of developers are just not aware of a few things that need to be setup for it to work as intended (except I'm not sure if it was seriously intended to be used), and not just drag'n'drop'n'forget. I've realized these issues myself right away, long time ago, and instead had since written my own FPS controller. Hence, I wouldn't really care that much about writing this, had it not been the sheer number of consecutive games I played that had EXACTLY THE SAME issues! (the games which would not be nice to name)
Disclaimer: This is a rant about the standard Unity controller, not Unity-itself. Unity is obviously a great software, that literally changed my life. This is more like, 'Hey newcomers, be careful!'
Note: This post contains solutions to 3 most jarring problems, there are 'no-code' involved solutions and 'recommended' solutions involving slightly more work. If you don't care about reading about the tech behind the issues, you can skip 'The Techsplanation' parts and just jump straight to 'The Solution' titles.
Also note: I am talking here only about the 'FPSController', not 'RigidbodyFPSController', simply because it is simpler and rigidbody based controllers are a totally different issue altogether.
Also also note: big gifs down the road
Well, here it is in action, I am pressing W and D to move left and right, and as you can see, it neither stops, nor slows down when I release the buttons. The player just keeps on walking at a constant speed for a while. Someone once asked me 'Why is my character so sticky??'
Now, there are a few ways of getting keyboard input in Unity, and one common solution for 'polar' inputs, like in our very example moving left and right, is to create a virtual axis, similar to a gamepad stick analogue input for example, but instead actuated by buttons. Unity creates a few axes by default, including 'Horizontal' and 'Vertical', which are typically used for player movement. The way to get these inputs in code is by calling Input.GetAxis(axisName) or Input.GetAxisRaw(axisName).
In the gif above, you can notice that the axis input (white line) is rising and falling smoothly over time, that is because of the less-known Unity input manager features called 'gravity' and 'sensitivity'. Since the input of button press is either 0 or 1, these two parameters are used to smooth out 0 to 1 transition to simulate an analogue axis in a better way. 'Sensitivity' is how much the number will rise on key press, while 'gravity' is how much it will fall off after release.
Take a look into Edit > Project Settings > Input and open a Horizontal axis
By default, gravity and sensitivity are both set to 3, which means the value will be smoothed over 1/3 seconds. That is exactly what we see in the plot above.
We can infer that this is a part of the issue, because the player's velocity drops only when input reaches 0. But, smoothing inputs still doesn't explain the reason why the FPS controller behaves like that. If the value is smoothed, then the movement should be smoothed too, right?
Well, looking into FirstPersonController.cs, we can find this line:
The point of interest is at the very end: '.normalized'. This means that vector of any length is converted to it's unit length (length of 1). For example, if the vector was initially (0.2, 0, 0), it will become (1, 0, 0). And hey, there's the source of our problem!! Since the input manager smooths the value of x, it will jump to 1 immediately as soon as the button is pressed, and not be lower than 1 until the 'gravity' drops the value to 0, which happens after 1/gravity seconds.
Now, normalizing the input is not an error, there is a reason why you would want to normalize input vector, and it has to do with vector addition. If you press both right and forward keys for example, these two values will add up in 2D plane and the resulting vector length will be larger than 1. It will be the length of a diagonal of a square (square root of 2 to be exact). And that exactly is the cause of the infamous diagonal strafe boost. This problem was very common back in the day of early FPS, most notorious maybe in GoldenEye 007 for N64 where you could storm through levels a lot faster than enemies could touch you.
BUT! A very very bad consequence of normalizing the input axes, is that, any analogue input will also be normalized. So, if you are using a gamepad stick for movement, the player will always move at a constant velocity, even if you touch the stick lightly.
The problem here is that whoever made the character scripts might not have been aware of input smoothing, or they set up high values for gravity and sensitivity in the input manager without thinking that A LOT of people might not be aware of it, aaaand.. they did not test with a gamepad.
This will make the controller snap to 1 and 0 right away and will not have any 'stickiness', but the controller will also have no velocity smoothing, it will stand or move at constant velocity. It also doesn't solve the gamepad issue where it does not have any velocity control.
In FirstPersonController.cs, find:
desiredMove = Vector3.ProjectOnPlane(desiredMove, hitInfo.normal).normalized;
and replace it with:
desiredMove = Vector3.ProjectOnPlane(desiredMove, hitInfo.normal);
desiredMove = Vector3.ClampMagnitude(desiredMove, 1);
Now however, this doesn't solve the issues perfectly, there is still tiny 'stickiness' when holding both forward and a strafe key, but it's minimal, and a very good compromise. If you are not intending to make a fast action or competitive game where input precision is extremely important.
Additionally, it would nice to now increase gravity and sensitivity a bit, for example to 6, to increase responsiveness.
This also solves the gamepad issue, you can now precisely change movement speed with analogue stick.
A different, perhaps even better solution would be though to not be connected to input gravity and sensitivity at all, instead, post-smoothing the normalized input value, for example with Vector3.SmoothDamp(). This would also introduce a little bit of inertia, which is nice in my opinion.
I have exaggerated the jerking in this gif, but notice how looking around while standing is smooth, while while strafing is not
By default, fixed timestep is set to 0.02 seconds, which means it is updated at 50hz (times per second), which is less than a typical screen refresh rate of 60hz. As a result the refresh rate is actually faster than refresh rate. This means that at certain render frames, physics is not calculated, but is the same as in the last physics step.
As you can see, some frames lie completely within physics updates, and therefore, there is no change between the 2 consecutive frames. In those frames, the controller doesn't change position, it simply freezes, and that is where the 'jerk' happens.
But be very careful! Because modifying the fixed timestep will actually increase the rate of all physics calculations in the scene. If you have a lot of rigidbodies, or a lot of FixedUpdate() calls, this may have a big performance hit. For most small, art games / experiences tho, I think this is not an issue.
But note that this still doesn't solve the issue because of a number of reasons, user devices with different refresh rates, fps may drop (or raise if VSync is off). Also, when fixedupdate runs twice or more in some update frame (the inverse of the graph above), you will now experience speedup-jerks basically. But that is much less noticeable than previously encountered freeze-stutters.
The easiest way to do this would be, in FirstPersonController.cs to:
Now, the character movement and rotation will be always in sync with refresh rate and there won't be any stutters.
Obviously, it's clear how it uses the 'Bobcurve' to interpolate movement over distance. The problem is that the Bobcurve just gets sampled with time. And additionally, it starts and ends abruptly, snapping from curve (when moving) and original position (when standing still), jerking the camera.
Now, when jumping, there is some sort of jump bob, that just feels awful, it's just a linear down, up... And if that is not enough, at some moments, it jerks up in a single frame:
A mysterious spike at the beginning of the landing. The jerk is not visible in gif due to gif's framerate, but I can guarantee you it's very noticeable when playing.
I would say 'make your own controller', but speaking from experience, making a first person controller and solving all edge cases is not a trivial task at all. I spent a muuch longer time on my own than I initially thought. And I'm not even talking about rigidbody based solutions. I think these few quick fixes are better than 'make your own'.. Unless you are really making some competitive, precise game.
But what I'm really disappointed about is that Unity hasn't fixed any of these issues looong time ago. I remember this came with Unity 5 as the 'new and improved' character controller, (actually, as far as I remember, I'd say it was a downgrade) and it still has these ridiculous issues. People that never touched code now make amazing games thanks to Unity, and inadvertently infect their games with this controller.
..I know I know, the sky too... but cmon, it's not as bad as this.
The ingame value plotter source: https://gist.github.com/nothke/094157037a61671865b0bb61bb51c5e3
My head bob solution: https://gist.github.com/nothke/66597fe11bc2b5db3b685319355fb9ff
Those sprawling landscapes ....
.... The infinite skylines ....
.... The light shining upon me ....
... The mind-bending illusions ....
... I could almost touch the grass ....
........ Except .......
........ With every jerk and jitter ......
......... I felt ........
..... UNITY!
Of course... I mean.. 'oh no, that Unity [engine] issue'...
...
..and umm.. No, it's not the sky..
...
There is one thing that connects them all ... (in unity.. ha-ha) and that's
..They all use Unity FPS character controller!
Yes, THAT one!
And the problem is not really that the controller is bad (..except it is), it's that A LOT of developers are just not aware of a few things that need to be setup for it to work as intended (except I'm not sure if it was seriously intended to be used), and not just drag'n'drop'n'forget. I've realized these issues myself right away, long time ago, and instead had since written my own FPS controller. Hence, I wouldn't really care that much about writing this, had it not been the sheer number of consecutive games I played that had EXACTLY THE SAME issues! (the games which would not be nice to name)
Disclaimer: This is a rant about the standard Unity controller, not Unity-itself. Unity is obviously a great software, that literally changed my life. This is more like, 'Hey newcomers, be careful!'
Note: This post contains solutions to 3 most jarring problems, there are 'no-code' involved solutions and 'recommended' solutions involving slightly more work. If you don't care about reading about the tech behind the issues, you can skip 'The Techsplanation' parts and just jump straight to 'The Solution' titles.
Also note: I am talking here only about the 'FPSController', not 'RigidbodyFPSController', simply because it is simpler and rigidbody based controllers are a totally different issue altogether.
Also also note: big gifs down the road
So, lets begin
Now, it's not enough to say the controller doesn't 'feel' right, I wouldn't be able to prove anything. Therefore, I made an runtime plotter to show you my points using maths! (The source of which is available at the bottom of the post)1. The 'sticky' button release
You may have noticed that the player walks on a few steps more when the button is released.
A and D flash while I hold those buttons pressed
Well, here it is in action, I am pressing W and D to move left and right, and as you can see, it neither stops, nor slows down when I release the buttons. The player just keeps on walking at a constant speed for a while. Someone once asked me 'Why is my character so sticky??'
The Techsplanation
Lets plot a few values, to analyse it better
Blue line represents player's velocity,
Yellow raw axis input, while
White line represents axis input
Now, there are a few ways of getting keyboard input in Unity, and one common solution for 'polar' inputs, like in our very example moving left and right, is to create a virtual axis, similar to a gamepad stick analogue input for example, but instead actuated by buttons. Unity creates a few axes by default, including 'Horizontal' and 'Vertical', which are typically used for player movement. The way to get these inputs in code is by calling Input.GetAxis(axisName) or Input.GetAxisRaw(axisName).
In the gif above, you can notice that the axis input (white line) is rising and falling smoothly over time, that is because of the less-known Unity input manager features called 'gravity' and 'sensitivity'. Since the input of button press is either 0 or 1, these two parameters are used to smooth out 0 to 1 transition to simulate an analogue axis in a better way. 'Sensitivity' is how much the number will rise on key press, while 'gravity' is how much it will fall off after release.
Take a look into Edit > Project Settings > Input and open a Horizontal axis
By default, gravity and sensitivity are both set to 3, which means the value will be smoothed over 1/3 seconds. That is exactly what we see in the plot above.
We can infer that this is a part of the issue, because the player's velocity drops only when input reaches 0. But, smoothing inputs still doesn't explain the reason why the FPS controller behaves like that. If the value is smoothed, then the movement should be smoothed too, right?
Well, looking into FirstPersonController.cs, we can find this line:
desiredMove = Vector3.ProjectOnPlane(desiredMove, hitInfo.normal).normalized;
The point of interest is at the very end: '.normalized'. This means that vector of any length is converted to it's unit length (length of 1). For example, if the vector was initially (0.2, 0, 0), it will become (1, 0, 0). And hey, there's the source of our problem!! Since the input manager smooths the value of x, it will jump to 1 immediately as soon as the button is pressed, and not be lower than 1 until the 'gravity' drops the value to 0, which happens after 1/gravity seconds.
Now, normalizing the input is not an error, there is a reason why you would want to normalize input vector, and it has to do with vector addition. If you press both right and forward keys for example, these two values will add up in 2D plane and the resulting vector length will be larger than 1. It will be the length of a diagonal of a square (square root of 2 to be exact). And that exactly is the cause of the infamous diagonal strafe boost. This problem was very common back in the day of early FPS, most notorious maybe in GoldenEye 007 for N64 where you could storm through levels a lot faster than enemies could touch you.
BUT! A very very bad consequence of normalizing the input axes, is that, any analogue input will also be normalized. So, if you are using a gamepad stick for movement, the player will always move at a constant velocity, even if you touch the stick lightly.
The problem here is that whoever made the character scripts might not have been aware of input smoothing, or they set up high values for gravity and sensitivity in the input manager without thinking that A LOT of people might not be aware of it, aaaand.. they did not test with a gamepad.
The Solution
No-code
A simple solution for keyboard users would be to make sensitivity and gravity in Edit > Project Settings > Input Manager for both Horizontal and Vertical axes be very high, like 1000.This will make the controller snap to 1 and 0 right away and will not have any 'stickiness', but the controller will also have no velocity smoothing, it will stand or move at constant velocity. It also doesn't solve the gamepad issue where it does not have any velocity control.
Recommended
A much better solution with a little coding, is to remove normalization, and instead clamp the vector to 1 to prevent vector addition exceeding 1.In FirstPersonController.cs, find:
desiredMove = Vector3.ProjectOnPlane(desiredMove, hitInfo.normal).normalized;
and replace it with:
desiredMove = Vector3.ProjectOnPlane(desiredMove, hitInfo.normal);
desiredMove = Vector3.ClampMagnitude(desiredMove, 1);
Now however, this doesn't solve the issues perfectly, there is still tiny 'stickiness' when holding both forward and a strafe key, but it's minimal, and a very good compromise. If you are not intending to make a fast action or competitive game where input precision is extremely important.
Additionally, it would nice to now increase gravity and sensitivity a bit, for example to 6, to increase responsiveness.
This also solves the gamepad issue, you can now precisely change movement speed with analogue stick.
A different, perhaps even better solution would be though to not be connected to input gravity and sensitivity at all, instead, post-smoothing the normalized input value, for example with Vector3.SmoothDamp(). This would also introduce a little bit of inertia, which is nice in my opinion.
2. Jerky movement and rotation
There is another very annoying and distinguishable issue, especially noticeable as stutter when strafing and rotating the view at the same time. This comes down to the temporal discrepancy between the calculations of controller's movement that is calculated in fixed update, and camera rotation which is calculated in rendering update.I have exaggerated the jerking in this gif, but notice how looking around while standing is smooth, while while strafing is not
The Techsplanation
Now, you see, Unity's physics and rendering are calculated separately in 2, lets say, 'time-spaces' (I avoid to use 'threads' in fear it may be programmatically incorrect), and they run at different rates (aka timesteps) in parallel. If the VSync is enabled, the rendering rate depends on your display device's refresh rate (for most screens that is 60hz), while the physics update rate is defined in the Time Manager.By default, fixed timestep is set to 0.02 seconds, which means it is updated at 50hz (times per second), which is less than a typical screen refresh rate of 60hz. As a result the refresh rate is actually faster than refresh rate. This means that at certain render frames, physics is not calculated, but is the same as in the last physics step.
The Solution
No-code
A quick fix would be to go into Edit > Project Settings > Time Manager and decrease the 'Fixed Timestep' value to at or below the refresh rate. I like to set this value to 0.01, which means physics will run at 100hz.But be very careful! Because modifying the fixed timestep will actually increase the rate of all physics calculations in the scene. If you have a lot of rigidbodies, or a lot of FixedUpdate() calls, this may have a big performance hit. For most small, art games / experiences tho, I think this is not an issue.
But note that this still doesn't solve the issue because of a number of reasons, user devices with different refresh rates, fps may drop (or raise if VSync is off). Also, when fixedupdate runs twice or more in some update frame (the inverse of the graph above), you will now experience speedup-jerks basically. But that is much less noticeable than previously encountered freeze-stutters.
Recommended (in most cases)
Another solution, recommended for games that do not have any other physics interactions, would be to move the entire character movement calculation to Update().The easiest way to do this would be, in FirstPersonController.cs to:
- rename the 'private void FixedUpdate()' to e.g. 'private void CharacterUpdate()'
- Put 'CharacterUpdate();' line at the end of 'Update()' function.
- Find and replace 'Time.fixedDeltaTime' with 'Time.deltaTime' in the CharacterUpdate() function.
Now, the character movement and rotation will be always in sync with refresh rate and there won't be any stutters.
3. Wonky headbob
Now the headbob is another story, take a look at this plot of a the head height relative to the character when moving:Obviously, it's clear how it uses the 'Bobcurve' to interpolate movement over distance. The problem is that the Bobcurve just gets sampled with time. And additionally, it starts and ends abruptly, snapping from curve (when moving) and original position (when standing still), jerking the camera.
Isolated bob issue
Now, when jumping, there is some sort of jump bob, that just feels awful, it's just a linear down, up... And if that is not enough, at some moments, it jerks up in a single frame:
A mysterious spike at the beginning of the landing. The jerk is not visible in gif due to gif's framerate, but I can guarantee you it's very noticeable when playing.
The Solution
No-code
The simple solution is to turn the head bob off :DRecommended
Another 'solution' is to use my, currently temporary, simpler head bob solution, that I wrote today, and which is SmoothDamp based. It never jitters but it's a bit harder to tweak at the moment. It reacts to landing after a jump as well as to steps, naturally, as a response to character's velocity. It only has vertical bob (just since I personally don't prefer horizontal bob). But I am still working on improving it.Conclusion
These three are just the most annoying issues, there is a bunch more that I may talk in another post, like stairs, slopes, bashing your head into a ceiling, CharacterController weird sleeping issues, etc.. Also, pressing space when in air will make you jump once you land. Whyyy? Also that your project gets infected with CrossPlatformInput, which is useless for non-mobile games. Thinking of these, you could even write a book.. There's enough material.I would say 'make your own controller', but speaking from experience, making a first person controller and solving all edge cases is not a trivial task at all. I spent a muuch longer time on my own than I initially thought. And I'm not even talking about rigidbody based solutions. I think these few quick fixes are better than 'make your own'.. Unless you are really making some competitive, precise game.
But what I'm really disappointed about is that Unity hasn't fixed any of these issues looong time ago. I remember this came with Unity 5 as the 'new and improved' character controller, (actually, as far as I remember, I'd say it was a downgrade) and it still has these ridiculous issues. People that never touched code now make amazing games thanks to Unity, and inadvertently infect their games with this controller.
..I know I know, the sky too... but cmon, it's not as bad as this.
The ingame value plotter source: https://gist.github.com/nothke/094157037a61671865b0bb61bb51c5e3
My head bob solution: https://gist.github.com/nothke/66597fe11bc2b5db3b685319355fb9ff
Thank you for taking the time to write this up and the visualisation of the various values really helped. Looking forward to the next blog post.
ReplyDeletethanks so much! I enjoy those art, aesthetic, atmospheric, games on itch too! And while trying to make my own I ran into this post and its EXACTLY what I was concerned about!
ReplyDeleteabsolutely epic
ReplyDeleteFixed two of my issues, THANK YOU!
ReplyDeleteholy cow awsome bro thanks!
ReplyDeleteThanks a lot! You're the man! I had problems with the stuttering issue for years!
ReplyDeleteThis was quite useful even for the RigidBody version, btw, so thank you for this. :) The RigidBody version of the FPS controller has the exact same sticky movement problem. One interesting detail I found, and it reminds me of an issue I had in another project with a custom movement controller that also relied on Vector3.ProjectOnPlane, is that tilting the camera too far down makes it so that forward movement no longer works; this isn't an issue before the fix is applied to the FPS controller. To get around it, I simply limit the camera tilt... problem solved. :) It's a cheap hack of a fix but hey, results are results, amirite? :) But anyway:
ReplyDeletedesiredMove = Vector3.ProjectOnPlane(desiredMove, m_GroundContactNormal).normalized;
in the RigidBody version can be likewise updated to:
desiredMove = Vector3.ProjectOnPlane(desiredMove, m_GroundContactNormal);
desiredMove = Vector3.ClampMagnitude(desiredMove, 1);
as per your suggestion, and along with setting gravity and sensitivity to 6, gives a pretty dang close to perfect controller for a basic FPP game such as the one I'm currently working on. :) (and I hope this posts properly...)
Amazing. Been using this on everything I make.
ReplyDelete