Introduction: Why Is Your Game Stuttering?
One wall many developers face when developing with Godot Engine is frame rate (FPS) instability. The "stuttering" and "jitter" that occur especially in action games or scenes with many moving objects severely damages player immersion and fundamentally undermines game quality.
"V-Sync is enabled but it's not smooth," "I don't understand the difference between _process and _physics_process," "I'm unsure whether to implement Object Pooling for optimization"... These concerns can be resolved by deeply understanding how Godot's rendering and physics systems work.
This article goes beyond simple feature introductions. It unravels the root causes of FPS instability and thoroughly explains specific solutions with practical code and tabular comparisons. After reading this article, you'll gain the following knowledge and be able to confidently tackle performance optimization.
- Problem solving: Learn concrete implementation methods for "physics interpolation" to eliminate FPS instability (jitter).
- Correct understanding: Completely understand Godot's physics and rendering mechanisms, and the proper use of
delta. - Accurate judgment: Be able to determine when and how to apply optimization techniques like Object Pooling based on profiling.
- Practical knowledge: Master advanced frame rate management techniques like the differences between V-Sync modes and how to use
Engine.max_fps.
Root Cause of FPS Instability: Physics Ticks vs Rendering Frames
The key to understanding performance issues in Godot is recognizing the difference between two independent cycles: Physics Ticks and Rendering Frames. This asynchronicity is the direct cause of jitter.
| Concept | Physics Tick | Rendering Frame |
|---|---|---|
| Role | Physics calculations, collision detection (_physics_process) | Screen rendering, input handling (_process) |
| Execution frequency | Fixed (default 60Hz) | Variable (depends on hardware performance, V-Sync settings) |
| Purpose | Guarantee reproducibility and consistency of physics simulation | Provide players with the smoothest possible visuals |
delta | Fixed value (1 / physics tick rate) | Variable value (elapsed time since previous frame) |
Why does jitter occur?
Consider the case where physics runs at 60Hz and the monitor at 144Hz. The physics engine updates object positions every 1/60th of a second, but the screen tries to render every 1/144th of a second. As a result, objects stay in the same position across multiple render frames, then suddenly jump to a new position at the next physics tick—appearing to the player as "staircase-like movement." This is what jitter is.
Solution 1: Frame Rate Control with V-Sync and max_fps
The most basic countermeasure is controlling the rendering frame rate. Godot provides two main methods: V-Sync (vertical synchronization) and Engine.max_fps.
Using V-Sync (Vertical Synchronization)
V-Sync is a technology that synchronizes the rendering frame rate with the monitor's refresh rate. This prevents "tearing" caused by the screen being updated mid-refresh.
Configure it in Project Settings under Display > Window > Vsync > Vsync Mode.
| V-Sync Mode | Overview | Benefits | Drawbacks |
|---|---|---|---|
| Disabled | Disables V-Sync. | No FPS cap, input latency minimized. | Severe tearing occurs. |
| Enabled | Always enables V-Sync. | Completely prevents tearing. | When FPS drops below refresh rate, rendering waits for next sync timing, increasing input latency. |
| Adaptive | Syncs only when FPS exceeds refresh rate. | Prevents tearing while avoiding input latency during FPS drops. | Requires compatible hardware. |
| Mailbox | Always keeps the latest rendered frame and displays at sync timing. | Minimizes both tearing and input latency. | Increases VRAM usage and may cause slight additional latency. |
Note: External Setting Overrides If V-Sync settings in Godot don't take effect, graphics card settings like NVIDIA Control Panel or AMD Radeon Software may be overriding Godot's settings. Select "V-Sync: Application Controlled" in driver settings.
Manual Limiting with Engine.max_fps
Effective when you want to lock to a specific FPS without relying on V-Sync. Used for purposes like intentionally creating retro-style low FPS or reducing battery consumption on mobile devices.
# GameController.gd
func _ready():
# Set game's maximum FPS to 60
# Only effective when V-Sync is Disabled
Engine.max_fps = 60
max_fps becomes effective game-wide once set in a function like _ready(). However, note that when V-Sync is Enabled, max_fps is ignored.
Solution 2: Eliminating Jitter with Physics Interpolation
Frame rate control alone doesn't solve the fundamental problem of physics tick and rendering frame asynchronicity. This is where physics interpolation comes in. It's a technique that smoothly interpolates object positions between fixed physics ticks during rendering—the most effective solution for jitter.
Enable in Project Settings (Recommended)
Since Godot 4.3, physics interpolation has been natively supported for both 2D and 3D. In most cases, simply enabling the following setting solves the problem.
- Open Project > Project Settings
- Set Physics > Common > Physics Interpolation to
On
This alone automatically interpolates the movement of most nodes affected by physics—like CharacterBody2D/3D and RigidBody2D/3D—making them very smooth.
Manual Interpolation Implementation (Camera Following, etc.)
When making non-physics objects (like cameras or UI elements) follow physics nodes, manual interpolation is needed. Using Engine.get_physics_interpolation_fraction(), you can get where the current rendering frame is between physics ticks (0.0 to 1.0).
Here's practical code for a camera smoothly following a player character with physics interpolation enabled.
# SmoothCamera2D.gd
# Attach to Camera2D node
extends Camera2D
# Target node to follow (like player) - set from inspector
@export var target: Node2D
# Smoothness of following. Lower values are smoother
@export var smoothing: float = 0.1
var _previous_target_position: Vector2
func _ready():
if target:
# Initialize position to target
global_position = target.global_position
_previous_target_position = target.global_position
else:
push_warning("SmoothCamera2D: Target node is not set.")
func _process(delta):
if not target:
return
# When physics interpolation is enabled, target's render position is always interpolated
# However, non-physics nodes like cameras need their own interpolation
var interpolation_fraction = Engine.get_physics_interpolation_fraction()
# Interpolate between previous and current target position to calculate
# the "true" current position the camera should aim for
var interpolated_target_position = _previous_target_position.lerp(target.global_position, interpolation_fraction)
# Move camera's own position smoothly toward the calculated target position
global_position = global_position.lerp(interpolated_target_position, smoothing)
func _physics_process(delta):
if not target:
return
# Save target position at physics tick time for next frame's interpolation
_previous_target_position = target.global_position
This code performs two stages of lerp (linear interpolation):
- Interpolate target movement between physics ticks
- Further smooth the camera's own movement
This allows the camera to follow cinematically smoothly, even when player movement is sudden.
Common Mistakes and Best Practices
Here's a summary of traps developers commonly fall into when tackling optimization, and best practices to avoid them.
| Topic | Common Mistake (Anti-pattern) | Best Practice |
|---|---|---|
| Where to write processing | Writing all movement and logic in _process. | Write physical movement in _physics_process, rendering and input processing in _process. Understand delta's role correctly. |
| Optimization order | Introducing Object Pooling because something "seems heavy." | "Don't guess, measure." Use Godot's profiler to identify bottlenecks, then select appropriate optimization techniques. |
| Object Pooling | Applying to infrequently spawned objects (bosses, UI), complicating code. | Apply only to short-lived, high-frequency objects (bullets, effects) confirmed as bottlenecks by the profiler. |
| Deleting physics objects | Directly calling queue_free() or remove_child() in _physics_process, causing errors. | Use call_deferred("queue_free") to safely schedule deletion after the current physics step completes. |
| Physics tick rate | Making physics tick rate variable or extremely high to solve jitter. | Keep physics tick rate fixed (default 60Hz) and leave jitter countermeasures to physics interpolation. |
Advanced Technique: Implementing Object Pooling
If profiling reveals that node creation/destruction (instantiate() / queue_free()) is a performance bottleneck, Object Pooling is an effective solution.
Here's a generic Object Pooling manager implementation that can be used as an AutoLoad (singleton).
# ObjectPoolManager.gd (Register as AutoLoad)
extends Node
var _pool: Dictionary = {}
# Pre-populate the pool
func pre_populate_pool(scene: PackedScene, count: int):
if not _pool.has(scene):
_pool[scene] = []
for i in range(count):
var instance = scene.instantiate()
instance.name = "%s_pooled_%d" % [scene.resource_path.get_file().get_basename(), i]
# Initially inactive in pool
instance.set_process(false)
instance.set_physics_process(false)
instance.visible = false
add_child(instance)
_pool[scene].append(instance)
# Get object from pool
func get_object(scene: PackedScene) -> Node:
if _pool.has(scene) and not _pool[scene].is_empty():
var instance = _pool[scene].pop_front()
# Activate for reuse
instance.set_process(true)
instance.set_physics_process(true)
instance.visible = true
return instance
else:
# Create new if pool is empty (fallback)
return scene.instantiate()
# Return object to pool
func return_object(instance: Node):
if not is_instance_valid(instance):
return
var scene_key = null
for key in _pool.keys():
if key.can_instantiate() and instance.scene_file_path == key.resource_path:
scene_key = key
break
if scene_key:
# Deactivate and return to pool
instance.set_process(false)
instance.set_physics_process(false)
instance.visible = false
# Move off-screen to avoid physics collisions
if instance is Node2D:
instance.global_position = Vector2(1e8, 1e8)
# Return parent to ObjectPoolManager
if instance.get_parent() != self:
instance.get_parent().remove_child(instance)
add_child(instance)
_pool[scene_key].append(instance)
else:
# Destroy objects not managed by pool normally
instance.queue_free()
# --- Usage Example ---
# Player.gd
const BULLET_SCENE = preload("res://bullet.tscn")
func _ready():
# Pre-pool 50 bullets at game start
ObjectPoolManager.pre_populate_pool(BULLET_SCENE, 50)
func _fire():
# Get bullet from pool
var bullet = ObjectPoolManager.get_object(BULLET_SCENE)
# Place retrieved bullet at appropriate place in game world (e.g., Y-Sort node)
var game_world = get_tree().get_root().get_node("GameWorld")
bullet.reparent(game_world)
# Initialize bullet settings
bullet.global_position = $Muzzle.global_position
bullet.rotation = global_rotation
bullet.fire()
# Bullet.gd
func on_hit_or_timeout():
# Return to pool instead of destroying
ObjectPoolManager.return_object(self)
Important Point:
reparent()Pooled objects must usereparent()after retrieval to move to the appropriate parent node in the game world (like a Y-Sort enabled node). This ensures correct draw order and parent-child relationships.
Summary and Next Steps
Stable FPS is the foundation of a high-quality game experience. By systematically applying the knowledge explained here, your Godot project will dramatically improve.
| Problem | Root Cause | Solution |
|---|---|---|
| Jitter (stuttering) | Physics and rendering asynchronicity | Enabling physics interpolation is best. |
| Tearing (screen tearing) | Rendering and monitor update asynchronicity | Enable V-Sync (Adaptive or Mailbox recommended). |
| Node creation overhead | Frequent creation/destruction of short-lived objects | After confirming with profiler, if bottleneck, introduce Object Pooling. |
| Overall performance degradation | Unknown heavy processing | Identify bottleneck with Godot Profiler, improve algorithms or shaders. |
Next Steps
- Current state analysis: Enable debugger Monitors in your project and check FPS fluctuations.
- Jitter countermeasures: Enable physics interpolation and experience how movement becomes smoother. Implement manual interpolation code for following non-physics nodes like cameras.
- Bottleneck identification: Run the Profiler on scenes that feel slow and identify the functions consuming the most time.
- Targeted optimization: Based on identified causes, apply solutions learned in this article (algorithm improvements, Object Pooling, etc.), then profile again to measure effectiveness.
Armed with this knowledge, create smooth and comfortable game experiences that captivate your players.