Quick answer: Each engine call returning a collection allocates a marshalled wrapper. Cache results, avoid per-frame calls, and prefer typed packed arrays where possible.
A C# game calls GetChildren() and GetOverlappingBodies() every frame for many nodes. GC spikes cause periodic stutters. Marshalling allocations pile up.
Cache Static Results
// BAD: allocates every frame
void _Process(double delta) {
foreach (Node child in GetChildren()) { ... }
}
// GOOD: cache once
private Node[] _children;
public override void _Ready() {
_children = GetChildren().ToArray();
}
If the child list is stable, cache it. Re-cache only when it changes (signal-driven).
Reuse Buffers for Physics Queries
GetOverlappingBodies returns a new array each call. For per-frame physics queries, use the lower-level PhysicsServer with a pre-allocated result buffer, or cache and diff.
Packed Arrays Are Cheaper
Godot.Collections.Array marshals as a wrapper object. PackedVector3Array etc. marshal to contiguous memory — less GC pressure when you control the type.
Profile with the GC View
Editor → Debugger → Monitors. Watch “Memory/Static” and the GC collection count. Spikes correlate with marshalling-heavy frames. Use the C# profiler to find the call sites.
Batch Boundary Crossings
Each C#↔engine call has overhead. One call returning 100 items beats 100 calls returning 1. Restructure APIs to batch.
Verifying
Profiler GC count flat during gameplay. No periodic stutter. Frame time consistent.
“Every marshalled collection is an allocation. Cache, batch, and prefer packed types.”
For hot loops touching many nodes, consider keeping a parallel C#-side data structure updated via signals — never cross the boundary in _Process.