Detects performance bottlenecks in Godot projects including expensive _process functions, get_node() calls in loops, instantiations in _process, and provides optimization suggestions with Godot profiler integration
Scanned 2/12/2026
Install via CLI
openskills install majiayu000/claude-skill-registry---
name: godot-profile-performance
version: 1.0.0
displayName: Godot Performance Profiler
description: Detects performance bottlenecks in Godot projects including expensive _process functions, get_node() calls in loops, instantiations in _process, and provides optimization suggestions with Godot profiler integration
author: Asreonn
license: MIT
category: game-development
type: tool
difficulty: intermediate
audience:
- developers
keywords:
- godot
- performance
- profiler
- optimization
- _process
- frame-time
- bottleneck
- memory
- gdscript
platforms:
- macos
- linux
- windows
repository: https://github.com/asreonn/godot-superpowers
homepage: https://github.com/asreonn/godot-superpowers#readme
permissions:
filesystem:
- read: "**/*.gd"
- read: "**/project.godot"
- write: "**/*.gd"
- write: "**/optimization_report.md"
---
# Godot Performance Profiler
## Overview
Analyzes Godot projects to detect performance bottlenecks in GDScript code. Identifies expensive operations in frame-critical functions like `_process`, `_physics_process`, and `_draw`. Provides actionable optimization suggestions with before/after code examples and integrates with Godot's built-in profiler for validation.
**Core principle:** Frame time is precious—expensive operations belong in `_ready` or signal handlers, not per-frame callbacks.
## When to Use
**Use when:**
- Frame rate drops or inconsistent performance
- CPU usage is unexpectedly high
- Game stutters during gameplay
- `_process` functions contain complex logic
- Memory usage grows over time
- Before releasing a game or major update
**Don't use for:**
- Network/server performance issues (use server profiling tools)
- GPU/shader optimization (use Godot's GPU profiler)
- Physics simulation tuning (use Godot's physics debug tools)
## Detection Patterns
### Heavy _process Functions
**Trigger:** Functions with >15 lines of code in `_process` or `_physics_process`
**Detection:**
```bash
# Count lines in process functions
rg "^func _process" -A 50 --glob "*.gd" | wc -l
```
**Thresholds:**
- Warning: >15 lines
- Critical: >30 lines
### get_node() in Loops
**Pattern:**
```gdscript
# ❌ BAD: get_node() in _process
func _process(delta):
get_node("UI/HealthBar").value = health # Called every frame!
get_node("Player").position = position
```
**Detection regex:**
```regex
func _process.*\n(?:.*\n)*?\s+get_node\(
```
### Instantiation in _process
**Pattern:**
```gdscript
# ❌ BAD: Creating objects every frame
func _process(delta):
var bullet = Bullet.new() # Memory churn!
add_child(bullet)
```
**Detection keywords:**
- `.new()` in `_process` or `_physics_process`
- `.instantiate()` in frame callbacks
- `add_child()` or `remove_child()` in loops
### Complex Operations in _physics_process
**Anti-patterns:**
- Heavy pathfinding calculations
- Complex AI state machines
- Large array operations
- File I/O or network calls
## Analysis Procedures
### Frame Time Analysis
**Steps:**
1. Enable Godot profiler (Debug > Profiler)
2. Run game for 60 seconds of typical gameplay
3. Identify functions with high "Time (ms)" values
4. Sort by "Time %" to find top offenders
5. Look for functions >0.1ms per frame
**Interpretation:**
- <0.01ms: Excellent
- 0.01-0.05ms: Good
- 0.05-0.1ms: Acceptable
- >0.1ms: Needs optimization
### Memory Usage Detection
**Indicators:**
- Continuous growth in Memory monitor
- Frequent garbage collection spikes
- Node count increasing over time
**Detection:**
```bash
# Check for object creation in process functions
rg "\.new\(\)|instantiate\(\)" -B 5 -A 2 --glob "*.gd" | \
rg -A 10 "func _process|func _physics_process"
```
### Draw Call Optimization
**Check:**
- Godot's "Monitors" tab > "Draw Calls"
- Each unique material = additional draw call
- GPU skinning vs CPU skinning
**Optimization targets:**
- <100 draw calls for 2D games
- <500 draw calls for simple 3D
- Use texture atlases to reduce material switches
### Physics Performance
**Red flags:**
- High collision shape complexity
- Too many rigid bodies (>100)
- Complex polygon collisions
- `_physics_process` doing non-physics work
## Optimization Suggestions
### Cache Node References
**Before:**
```gdscript
extends CharacterBody2D
func _process(delta):
get_node("UI/HealthBar").value = health
get_node("UI/ManaBar").value = mana
get_node("UI/LevelLabel").text = str(level)
```
**After:**
```gdscript
extends CharacterBody2D
@onready var health_bar = $UI/HealthBar
@onready var mana_bar = $UI/ManaBar
@onready var level_label = $UI/LevelLabel
func _process(delta):
health_bar.value = health
mana_bar.value = mana
level_label.text = str(level)
```
**Impact:** Eliminates 3 node lookups per frame (~0.01ms each)
### Move Initialization to _ready
**Before:**
```gdscript
func _process(delta):
var gravity = ProjectSettings.get("physics/2d/default_gravity")
velocity.y += gravity * delta
```
**After:**
```gdscript
var gravity
func _ready():
gravity = ProjectSettings.get("physics/2d/default_gravity")
func _process(delta):
velocity.y += gravity * delta
```
### Object Pooling for Bullets/Particles
**Before:**
```gdscript
func shoot():
var bullet = BulletScene.instantiate()
bullet.position = global_position
get_parent().add_child(bullet)
```
**After:**
```gdscript
var bullet_pool: Array[Bullet] = []
func _ready():
# Pre-instantiate bullets
for i in range(50):
var bullet = BulletScene.instantiate()
bullet.hide()
bullet_pool.append(bullet)
get_parent().add_child(bullet)
func shoot():
for bullet in bullet_pool:
if not bullet.visible:
bullet.position = global_position
bullet.show()
bullet.activate()
return
```
**Impact:** Eliminates instantiation overhead during gameplay
### Signal-Based Updates
**Before:**
```gdscript
func _process(delta):
# Checking every frame if health changed
if health != previous_health:
update_health_bar()
previous_health = health
```
**After:**
```gdscript
signal health_changed(new_health)
var health = 100:
set(value):
if health != value:
health = value
health_changed.emit(health)
func _ready():
health_changed.connect(update_health_bar)
```
### Batch Array Operations
**Before:**
```gdscript
func _process(delta):
for enemy in enemies:
if enemy.position.distance_to(player.position) < 100:
enemy.target_player()
```
**After:**
```gdscript
var check_timer = 0.0
const CHECK_INTERVAL = 0.1 # Check 10x per second, not 60x
func _process(delta):
check_timer += delta
if check_timer >= CHECK_INTERVAL:
check_timer = 0
update_enemy_targets()
func update_enemy_targets():
for enemy in enemies:
if enemy.position.distance_to(player.position) < 100:
enemy.target_player()
```
## Godot Profiler Integration
### Enabling the Profiler
1. Run game with Debug > Start with Profiler
2. Or click the "Profiler" tab in the bottom panel while game runs
3. Enable specific monitors:
- CPU Time
- Function Time
- Node Count
- Memory
- Draw Calls
### Key Metrics to Monitor
**Frame Time (ms):**
- Shows total time per frame
- Target: <16.67ms for 60 FPS, <33.33ms for 30 FPS
- Spikes indicate hitches
**Function Breakdown:**
- Lists all functions sorted by time
- Look for `_process`, `_physics_process`, `_draw`
- Click function name to see callers
**Memory Monitor:**
- Watch for continuous growth
- Sudden spikes indicate allocations
- Plateaus followed by drops = garbage collection
### Profiling Workflow
```
1. Establish baseline (unoptimized)
└─ Record profiler data for 60 seconds
2. Identify top 3 time consumers
└─ Sort by "Time %" in profiler
3. Apply optimization
└─ Use patterns above
4. Validate improvement
└─ Profile again, compare metrics
└─ Verify frame time reduced
5. Repeat for next bottleneck
```
## Examples
### Example 1: UI Controller Optimization
**Problem:**
```gdscript
# ui_controller.gd
extends Control
func _process(delta):
# Called every frame - 5 node lookups!
get_node("HealthBar").value = player.health
get_node("ManaBar").value = player.mana
get_node("LevelLabel").text = "Level: " + str(player.level)
get_node("XpBar").value = player.xp
get_node("GoldLabel").text = "Gold: " + str(player.gold)
```
**Profiler Output:**
```
Function | Time (ms) | Time %
----------------------------------------
_process | 0.18 | 12.3%
get_node | 0.15 | 10.2% (x5)
```
**Optimized:**
```gdscript
# ui_controller.gd
extends Control
@onready var health_bar = $HealthBar
@onready var mana_bar = $ManaBar
@onready var level_label = $LevelLabel
@onready var xp_bar = $XpBar
@onready var gold_label = $GoldLabel
func _ready():
# Connect to player signals instead of polling
player.health_changed.connect(_on_health_changed)
player.mana_changed.connect(_on_mana_changed)
player.leveled_up.connect(_on_leveled_up)
player.xp_changed.connect(_on_xp_changed)
player.gold_changed.connect(_on_gold_changed)
func _on_health_changed(value): health_bar.value = value
func _on_mana_changed(value): mana_bar.value = value
func _on_leveled_up(level): level_label.text = "Level: " + str(level)
func _on_xp_changed(value): xp_bar.value = value
func _on_gold_changed(value): gold_label.text = "Gold: " + str(value)
```
**Result:**
```
Function | Time (ms) | Time %
----------------------------------------
_process | 0.00 | 0.0% (removed!)
_on_health_changed| 0.01 | 0.7% (event-driven)
```
### Example 2: Enemy Spawner Fix
**Problem:**
```gdscript
# spawner.gd
extends Node2D
func _process(delta):
if enemies.size() < max_enemies:
var enemy = EnemyScene.instantiate() # Memory churn!
enemy.position = random_position()
add_child(enemy)
enemies.append(enemy)
```
**Memory leak:** Continuous instantiation without pooling
**Optimized:**
```gdscript
# spawner.gd
extends Node2D
var spawn_timer = 0.0
const SPAWN_RATE = 2.0 # Check spawn every 2 seconds
func _process(delta):
spawn_timer += delta
if spawn_timer >= SPAWN_RATE:
spawn_timer = 0
try_spawn()
func try_spawn():
if enemies.size() < max_enemies:
spawn_enemy()
func spawn_enemy():
# Consider object pool for frequent spawns
var enemy = EnemyScene.instantiate()
enemy.position = random_position()
add_child(enemy)
enemies.append(enemy)
```
**Additional improvement:** Use object pooling for frequently spawned enemies
### Example 3: Physics Optimization
**Problem:**
```gdscript
# player.gd
extends CharacterBody2D
func _physics_process(delta):
# Heavy calculation every physics frame
var nearby = get_tree().get_nodes_in_group("enemies")
for enemy in nearby:
if global_position.distance_to(enemy.global_position) < detection_radius:
enemy.set_target(self)
# Do physics
velocity.y += gravity * delta
move_and_slide()
```
**Profiler Output:**
```
Function | Time (ms) | Time %
--------------------------------------------
_physics_process | 0.45 | 28.5%
get_nodes_in_group | 0.25 | 15.8%
distance_to | 0.12 | 7.6%
```
**Optimized:**
```gdscript
# player.gd
extends CharacterBody2D
var detection_timer = 0.0
const DETECTION_RATE = 0.2 # 5x per second
func _physics_process(delta):
# Separate physics from AI
velocity.y += gravity * delta
move_and_slide()
# Run detection less frequently
detection_timer += delta
if detection_timer >= DETECTION_RATE:
detection_timer = 0
update_enemy_detection()
func update_enemy_detection():
var nearby = get_tree().get_nodes_in_group("enemies")
for enemy in nearby:
if global_position.distance_to(enemy.global_position) < detection_radius:
enemy.set_target(self)
```
**Result:** Physics frame time reduced by ~60%
## Success Criteria
A performance optimization is successful when:
### Quantitative Metrics
- [ ] Target function frame time reduced by >50%
- [ ] `_process` function line count <15 lines
- [ ] Zero `get_node()` calls in `_process` or `_physics_process`
- [ ] Zero `.new()` or `.instantiate()` calls in frame callbacks
- [ ] Profiler "Time %" for top 3 functions <30% combined
- [ ] Frame time stays <16.67ms for 60 FPS target
- [ ] Memory usage stable (no continuous growth)
### Qualitative Checks
- [ ] Node references cached in `_ready()` or `@onready`
- [ ] Complex logic moved to signals or timers
- [ ] Object pooling used for frequent instantiations
- [ ] `_physics_process` contains only physics-related code
- [ ] Profiler data shows improvement vs baseline
### Validation Steps
1. **Profile before:** Record baseline metrics
2. **Apply optimization:** Make targeted changes
3. **Profile after:** Compare metrics
4. **Verify in-game:** Test actual gameplay feels smoother
5. **Check edge cases:** Ensure optimization works in all scenarios
## Quick Reference
| Pattern | Detection | Fix | Impact |
|---------|-----------|-----|--------|
| get_node() in _process | Search: `get_node` after `_process` | Cache in `@onready` | ~0.01ms per call |
| .new() in _process | Search: `.new()` in frame functions | Use object pooling | Eliminates GC pressure |
| Heavy _process | Count lines >15 | Move to signals/timers | Reduces per-frame load |
| Physics + AI | AI in `_physics_process` | Separate with timer | 5-10x reduction |
| Uncached settings | `ProjectSettings.get()` in loop | Cache in `_ready` | One-time cost |
## Common Mistakes
### Mistake: "I'll optimize later"
**Problem:** Technical debt accumulates, harder to fix later
**Fix:** Profile early and often, fix bottlenecks as they appear
### Mistake: Premature optimization
**Problem:** Optimizing code that isn't a bottleneck
**Fix:** Always profile first, focus on top time consumers
### Mistake: Micro-optimizations
**Problem:** Spending hours to save 0.001ms
**Fix:** Target functions >0.1ms, ignore the rest
### Mistake: Not validating improvements
**Problem:** Assuming optimization worked without measuring
**Fix:** Always run profiler before and after
### Mistake: Optimizing release builds only
**Problem:** Debug builds have different performance characteristics
**Fix:** Profile release builds for accurate data
No comments yet. Be the first to comment!