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 manually

37.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).