Performance Optimization
Use the Profiler and Monitors (Chapter 36) to find whether you're script-bound, physics-bound, GPU-bound, or draw-call-bound before optimizing.
37.1 Measure first
Use the Profiler and Monitors (Chapter 36) to find whether you're script-bound, physics-bound, GPU-bound, or draw-call-bound before optimizing.
37.2 GDScript-level tips
Use static types everywhere Enables typed opcodes; avoids Variant checks
Cache node references in @onready vars $Path/get_node() lookups cost time Avoid work in _process when idle
set_process(false); use signals/timers instead of polling Use distance_squared_to for comparisons
Use StringName (&"name") in hot paths Pointer comparison instead of string hashing
Prefer Packed arrays for large numeric data Contiguous memory, less overhead Avoid creating objects every frame Reuse arrays, pool nodes
Use built-in methods (Array.sort, find) Implemented in C++
Reduce get_tree().get_nodes_in_group() in loops Cache results when possible Move hot algorithms to GDExtension When GDScript is truly the bottleneck
37.3 Object pooling
Pooling helps when instancing many short-lived complex scenes; for simple nodes Godot's allocation is fast — profile first.
# bullet_pool.gd
class_name BulletPool
extends Node
@export var bullet_scene: PackedScene
@export var size := 100
var _free: Array[Node2D] = []
func _ready() -> void:
for i in size:
var b: Node2D = bullet_scene.instantiate()
b.process_mode = Node.PROCESS_MODE_DISABLED
b.hide()
add_child(b)
_free.append(b)
func acquire(pos: Vector2, dir: Vector2) -> Node2D:
if _free.is_empty():
return null # or grow the pool
var b: Node2D = _free.pop_back()
b.global_position = pos
b.set("direction", dir)
b.process_mode = Node.PROCESS_MODE_INHERIT
b.show()
return b
func release(b: Node2D) -> void:
b.process_mode = Node.PROCESS_MODE_DISABLED
b.hide()
_free.append(b)37.4 Rendering
Use MultiMeshInstance2D/3D for thousands of identical objects (grass, bullets, crowds):
Use VisibleOnScreenEnabler2D/3D to disable off-screen nodes; occlusion culling (OccluderInstance3D); LOD (automatic mesh LOD on import, visibility_range_*).
Reduce shadow-casting lights, use baked lighting (LightmapGI) for static scenes, keep texture sizes reasonable, use texture atlases in 2D for batching.
Choose the Compatibility or Mobile renderer for low-end targets.
func scatter_grass(count: int, mesh: Mesh) -> void:
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.mesh = mesh
mm.instance_count = count
for i in count:
var t := Transform3D(Basis(Vector3.UP, randf() * TAU), Vector3(randf_range(-50, 50), 0, randf_range(-50, 50)))
mm.set_instance_transform(i, t)
var mmi := MultiMeshInstance3D.new()
mmi.multimesh = mm
add_child(mmi)37.5 Using servers directly
For massive numbers of simple objects, skip nodes and talk to RenderingServer / PhysicsServer2D directly:
extends Node2D
@export var texture: Texture2D
var items: Array[RID] = []
func _ready() -> void:
for i in 5000:
var ci := RenderingServer.canvas_item_create()
RenderingServer.canvas_item_set_parent(ci, get_canvas_item())
RenderingServer.canvas_item_add_texture_rect(ci, Rect2(-texture.get_size() / 2, texture.get_size()), texture)
RenderingServer.canvas_item_set_transform(ci, Transform2D(0.0, Vector2(randf() * 1920, randf() * 1080)))
items.append(ci)
func _exit_tree() -> void:
for ci in items:
RenderingServer.free_rid(ci) # server resources must be freed manually37.6 Physics
Use simple shapes (circles/capsules/boxes) instead of concave polygons/trimeshes for moving bodies.
Limit collision masks so bodies don't test unnecessary pairs.
Lower physics tick rate for slow games; enable physics interpolation for smoothness.
Consider Jolt Physics (4.4+) for many 3D rigid bodies.
Let bodies sleep (can_sleep).