Overview
When developing games with Godot Engine, especially action-heavy games or games handling many objects, frame rate (FPS) instability becomes a major challenge. Unstable FPS directly leads to degraded player experience (stuttering, jitter) and compromises game quality.
This article explains the fundamental differences between physics ticks and rendering frames in Godot Engine, and covers Physics Interpolation, the state-of-the-art technique for synchronizing them. Additionally, we introduce Object Pooling application criteria for resolving performance bottlenecks, common misconceptions developers fall into, and practical optimization techniques with concrete GDScript code examples.
By reading this article, you'll be able to solve the following challenges:
- Correctly understand how physics calculations and rendering work in Godot's framework.
- Learn specific implementation methods for eliminating FPS instability (jitter).
- Apply effective optimization techniques (Object Pooling, etc.) based on profiling results.
Physics Ticks and Rendering Frames
The most important concept for understanding Godot Engine's framework is distinguishing between physics ticks and rendering frames. These operate independently, and their asynchronous nature can be the root cause of FPS instability.
Physics Tick
A physics tick is when the game's physics calculations and game logic within _physics_process(delta) are executed.
| Feature | Details |
|---|---|
| Execution frequency | Fixed. Default is 60 TPS (Ticks Per Second). |
| Setting | Can be changed in Project Settings under Physics > Common > Physics Tick per Second. |
| Role | Physics calculations, collision detection, fixed timestep logic execution. |
Physics ticks are fixed to guarantee consistency and reproducibility of physics simulation. If tick rate varies, the same inputs could produce different physics results.
Rendering Frame
A rendering frame is when screen drawing occurs and logic within _process(delta) is executed.
| Feature | Details |
|---|---|
| Execution frequency | Variable. Depends on hardware performance, monitor refresh rate, and V-Sync settings. |
| Role | Rendering, input processing, variable timestep logic execution. |
Asynchronicity Issues and Jitter
When physics tick and rendering frame execution timing don't match, jitter (stuttering) occurs.
Example: If physics ticks run at 10 TPS (every 0.1 seconds) and rendering frames at 60 FPS (about every 0.016 seconds), the new object positions calculated by physics remain the same across multiple render frames until the next physics tick. This makes object movement appear not smooth but "jumping."
| Issue | Description |
|---|---|
| Jitter | Unnatural movement or stuttering due to timing gaps between physics and rendering updates. |
| Stair-stepping | Objects appear to move in steps rather than smoothly, with position updating at fixed intervals. |
Common Pitfalls
When working on performance optimization, it's important to understand common misconceptions and Godot-specific pitfalls.
Misconception 1: Making Tick Rate Variable Will Solve It
Making physics tick rate match rendering frame rate might seem to solve synchronization issues. However, this is not recommended.
Physics calculations are most consistent at fixed tick rates. Making tick rate variable loses physics simulation reproducibility and causes game behavior to differ by hardware, making quality assurance extremely difficult.
Note: Proper Use of delta
The delta value in _process(delta) increases during frame drops. While essential for movement distance calculations (position += velocity * delta), don't use it for physics behavior or collision detection logic. Keep physics processing in _physics_process(delta) and leverage the fixed tick rate.
Misconception 2: Object Pooling Always Optimizes Performance
Object Pooling is a powerful pattern for improving performance of frequently created/destroyed objects (bullets, effects, etc.). However, in Godot Engine, its effectiveness is becoming limited.
- Godot 4+ optimization: In Godot 4, node creation (instantiation) costs have been significantly optimized.
- Application criteria: Pooling truly excels only for short-lived objects created/destroyed tens of times or more per second.
- Unnecessary cases: Introducing pooling for low-frequency objects like players, bosses, or UI elements only increases management costs with almost no performance benefit.
Conclusion: Pooling should only be applied when profiling confirms that node creation/destruction is the bottleneck.
Pitfall: V-Sync Setting External Interference
Even when V-Sync is enabled in Godot's project settings, FPS might not be limited to the monitor's refresh rate. This is because graphics card control panel settings (NVIDIA Control Panel, AMD Radeon Settings, etc.) may be overriding Godot's settings.
Solution: Check graphics card settings and either delete the profile for Godot's executable or set V-Sync back to application control.
Best Practices and Practical Code Examples
Here we introduce specific best practices and GDScript code examples for stabilizing FPS and optimizing performance.
Using Physics Interpolation
The most recommended approach to eliminate jitter is to maintain a fixed physics tick rate while using interpolation during rendering. Godot linearly interpolates (Lerp) movement between physics ticks to smooth out rendering.
Enabling in Project Settings
In Godot 4, physics interpolation can be easily enabled from project settings.
- Open Project > Project Settings
- Enable Physics > Common > Physics Interpolation (check the box)
With this setting enabled, Godot automatically interpolates rendering between physics ticks, achieving smooth movement. In most cases, this setting alone is sufficient to eliminate jitter.
Note: 2D physics interpolation is available from Godot 4.3 onwards. Versions 4.0-4.2 only supported 3D, and 2D required external addons.
When Manual Interpolation is Needed
For cases where project settings physics interpolation doesn't work (custom drawing, following physics nodes with non-physics nodes, etc.), use Engine.get_physics_interpolation_fraction() for manual interpolation.
Getting and Applying Interpolation Fraction
In the _process function, you can get the interpolation fraction indicating how far the current render frame is between the previous and next physics ticks.
# Player.gd (CharacterBody2D, etc.)
# Do physics in _physics_process
func _physics_process(delta):
# Physical movement calculations
velocity = calculate_movement()
move_and_slide()
# Save current position for interpolation (Godot's built-in may do this automatically, but for nodes needing manual interpolation)
# self.set_meta("prev_position", global_position) # Conceptual example
# Do rendering in _process
func _process(delta):
# Get physics interpolation fraction
var interpolation_fraction = Engine.get_physics_interpolation_fraction()
# Here, use interpolation fraction to adjust render position (for custom drawing or camera following)
# Example: Make camera following smoother
# camera.global_position = camera.global_position.lerp(target_position, interpolation_fraction)
# Node rendering positions are usually automatically interpolated by Godot's internal systems,
# so users rarely need to manually manipulate node positions.
# However, this value is useful for custom drawing or making non-physics nodes follow physics nodes.
# FPS display
var fps = Engine.get_frames_per_second()
# print("FPS: %d, Interpolation: %.2f" % [fps, interpolation_fraction])
Object Pooling Implementation Example
If node creation/destruction is identified as a bottleneck, implement the following Object Pooling manager.
# ObjectPoolManager.gd (Use as AutoLoad or Singleton)
extends Node
# Dictionary storing pooled objects
var pool = {}
# Initialize pool (pre-allocation)
func initialize_pool(scene_path: String, count: int):
if not pool.has(scene_path):
pool[scene_path] = []
var scene = load(scene_path)
for i in range(count):
var instance = scene.instantiate()
# Don't add to scene tree, hide and store in pool
instance.visible = false
instance.set_process(false)
instance.set_physics_process(false)
instance.set_meta("pool_scene_path", scene_path) # Save scene path in metadata
add_child(instance) # Add as manager's child
pool[scene_path].append(instance)
# Get object from pool
func get_object(scene_path: String) -> Node:
if pool.has(scene_path) and not pool[scene_path].is_empty():
var instance = pool[scene_path].pop_back()
# Initialize for reuse
instance.visible = true
instance.set_process(true)
instance.set_physics_process(true)
# Call custom spawn logic (e.g., instance.on_spawn(position, direction))
return instance
else:
# If pool is empty, create new (consider increasing pool size)
var scene = load(scene_path)
var instance = scene.instantiate()
add_child(instance)
return instance
# Return object to pool
func return_object(instance: Node):
# Cleanup before returning to pool
instance.visible = false
instance.set_process(false)
instance.set_physics_process(false)
# For physics objects, move off-screen to exclude from collision detection
if instance is RigidBody2D or instance is CharacterBody2D:
instance.global_position = Vector2(-10000, -10000)
# Add to pool (get scene path from metadata)
var scene_path = instance.get_meta("pool_scene_path", "")
if scene_path != "" and pool.has(scene_path):
pool[scene_path].append(instance)
else:
# Destroy non-pooled objects
instance.queue_free()
# Usage example:
# const BULLET_SCENE_PATH = "res://scenes/bullet.tscn"
# ObjectPoolManager.initialize_pool(BULLET_SCENE_PATH, 50)
# var bullet = ObjectPoolManager.get_object(BULLET_SCENE_PATH)
# bullet.global_position = $Player.global_position
# bullet.on_fire()
#
# Important: If draw order like Y-Sort is needed, move to appropriate parent after retrieval
# bullet.reparent(game_world) # Move to game world node
Note on draw order: Pooled objects are managed as ObjectPoolManager children by default. For 2D games using Y-Sort or where overlap order with UI is important, use
reparent()afterget_object()to move to the appropriate game world node.
Safe Deletion of Physics Objects
When removing physics objects (like RigidBody2D) from the scene tree, calling remove_child() directly during physics engine callbacks can cause errors.
Workaround: Use call_deferred() to defer deletion until after the current frame's processing completes. Additionally, when pooling, moving objects off-screen before deletion (or returning to pool) safely avoids physics engine conflicts.
# Bullet.gd (RigidBody2D, etc.)
# When needing to delete (or return to pool) on collision, etc.
func on_hit():
# Deferred execution to avoid physics engine conflicts
call_deferred("safe_remove")
func safe_remove():
# Move off-screen before deletion (or returning to pool)
global_position = Vector2(-5000, -5000)
# If using Object Pooling
# ObjectPoolManager.return_object(self)
# If not using Object Pooling
# queue_free() automatically removes from parent
queue_free()
Profiling and Debugging
The most important principle of optimization is "Don't guess, measure". Optimizing without identifying bottlenecks not only wastes time and effort but also reduces code readability.
Optimization Process
- Profile: Use Godot's built-in profiler to measure CPU and GPU processing time breakdown.
- Identify bottlenecks: Identify functions, nodes, or resources consuming the most time.
- Optimize: Apply countermeasures like Object Pooling, algorithm improvements, or shader optimization to identified bottlenecks.
- Re-profile: Re-measure to verify optimization effectiveness and check for new bottlenecks.
Using the Godot Profiler
The Debugger panel in the Godot editor contains powerful tools for analyzing performance.
| Profiler Feature | Role |
|---|---|
| Monitors | Display real-time statistics like FPS, memory usage, CPU usage. |
| Profiler | Display detailed processing time of functions and signals executed in each frame. Can identify which script line is heaviest. |
| VRAM | Check GPU memory consumed by textures and meshes. |
Usage steps:
- Run the game using the Run button (or F5) at the top right of the editor.
- Open the Debugger panel at the bottom of the editor and select the Profiler tab.
- Press the Start button to begin profiling and play through scenes where performance issues occur.
- Press the Stop button and analyze items with long processing times (especially functions called within
_processor_physics_process) from the collected data.
Summary
Achieving stable FPS in Godot Engine requires understanding framework fundamentals and strategic optimization based on profiling.
| Challenge | Root Cause | Recommended Solution |
|---|---|---|
| Jitter (stuttering) | Asynchronicity between physics ticks and rendering frames. | Use Physics Interpolation. Maintain fixed tick rate and smooth rendering. |
| Node creation spikes | Frequent instantiate() and queue_free() of short-lived objects. | Object Pooling. Only when profiling confirms it's the bottleneck. |
| Performance degradation | Presence of unoptimized heavy processing. | Profiling. Identify bottleneck with Godot debugger before optimizing. |
| V-Sync issues | Overriding by external graphics card settings. | Check graphics card settings and explicitly limit FPS with Engine.target_fps. |
Next Steps
- Use
Engine.get_frames_per_second()in your project to display real-time FPS and check for instability. - Open Godot's debugger and identify the heaviest processing in the Profiler tab.
- If the identified bottleneck is node creation/destruction, consider introducing Object Pooling. Otherwise, consider replacing with more efficient algorithms or shaders.
Stable frame rate is the foundation for providing the best experience to players. Use this knowledge to take your Godot project's performance to the next level.