Game Performance Profiling and Testing: FPS, Memory, and Load Times

Game Performance Profiling and Testing: FPS, Memory, and Load Times

Performance is a feature in games. A 60 FPS game feels completely different from the same game at 30 FPS. Players notice stutters, long load times, and frame drops — and they stop playing. Performance testing catches regressions before players experience them.

This guide covers tools and techniques for profiling game performance and building automated performance tests.

What to Measure

Game performance has four key dimensions:

  • Frame rate — FPS and frame time (ms/frame)
  • Memory — RAM usage, texture memory, audio memory, GC pressure
  • Load times — scene transitions, asset loading
  • CPU/GPU — which systems consume most time per frame

Each needs different profiling tools and test approaches.

Unity Performance Testing

The Unity Performance Testing Package

Unity's Performance Testing Package adds measurement tools to Unity Test Framework:

using NUnit.Framework;
using Unity.PerformanceTesting;
using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;

public class PerformanceTests
{
    [Test, Performance]
    public void Inventory_AddItems_Performance()
    {
        var inventory = new InventorySystem(maxSlots: 1000);
        
        Measure.Method(() =>
        {
            for (int i = 0; i < 100; i++)
            {
                inventory.AddItem(new Item($"Item {i}", ItemType.Misc, 1f));
            }
            inventory.Clear();
        })
        .WarmupCount(5)
        .MeasurementCount(20)
        .GC()  // Measure GC allocations too
        .Run();
    }

    [UnityTest, Performance]
    public IEnumerator Scene_LoadTime_BelowThreshold()
    {
        using (Measure.Scope("SceneLoad"))
        {
            AsyncOperation load = UnityEngine.SceneManagement.SceneManager
                .LoadSceneAsync("Level1");
            while (!load.isDone)
                yield return null;
        }
        
        // Assert load time after measurement
        // Results stored in PerformanceTestResults
    }

    [UnityTest, Performance]
    public IEnumerator Gameplay_FPS_AboveMinimum()
    {
        // Load gameplay scene
        yield return UnityEngine.SceneManagement.SceneManager
            .LoadSceneAsync("GameplayScene");
        
        var frameTimeMarker = new SampleGroup("FrameTime", SampleUnit.Millisecond);
        
        // Measure 100 frames
        for (int i = 0; i < 100; i++)
        {
            Measure.Custom(frameTimeMarker, Time.deltaTime * 1000f);
            yield return null;
        }
        
        // The test runner will report p50, p90, p99 frame times
    }
}

Frame Time Benchmarks

Target FPS Max Frame Time
30 FPS 33.3 ms
60 FPS 16.7 ms
90 FPS (VR) 11.1 ms
120 FPS 8.3 ms

Set thresholds in your tests:

[UnityTest, Performance]
public IEnumerator CombatScene_AverageFrameTime_Below16ms()
{
    yield return LoadScene("CombatArena");
    SpawnEnemies(50);  // Stress scenario
    
    float totalFrameTime = 0;
    int frames = 200;
    
    for (int i = 0; i < frames; i++)
    {
        totalFrameTime += Time.deltaTime * 1000f;
        yield return null;
    }
    
    float avgFrameTime = totalFrameTime / frames;
    
    Assert.Less(avgFrameTime, 16.7f, 
        $"Average frame time {avgFrameTime:F1}ms exceeds 60 FPS target");
}

Memory Profiling

Memory leaks in games cause crashes on long play sessions. Test memory growth:

[UnityTest, Performance]
public IEnumerator Level_Unload_ReleasesAllMemory()
{
    // Record memory before level load
    long beforeLoad = GC.GetTotalMemory(forceFullCollection: true);
    
    // Load and unload level
    yield return LoadScene("Level1");
    yield return new WaitForSeconds(2f);
    yield return UnloadScene("Level1");
    
    // Force GC
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    yield return null;
    
    long afterUnload = GC.GetTotalMemory(forceFullCollection: true);
    long leakedBytes = afterUnload - beforeLoad;
    
    // Allow small variance, fail on significant leaks
    Assert.Less(leakedBytes, 1 * 1024 * 1024,  // 1MB tolerance
        $"Memory increased by {leakedBytes / 1024}KB after unload — possible leak");
}

[UnityTest, Performance]
public IEnumerator SpawnDestroy_Cycle_NoMemoryLeak()
{
    long baseline = GC.GetTotalMemory(true);
    
    // Simulate gameplay spawn/destroy cycles
    for (int cycle = 0; cycle < 100; cycle++)
    {
        var enemies = new List<GameObject>();
        for (int i = 0; i < 20; i++)
        {
            enemies.Add(SpawnEnemy(EnemyType.Goblin));
        }
        
        yield return new WaitForFixedUpdate();
        
        foreach (var e in enemies)
            Object.Destroy(e);
        
        yield return null;
    }
    
    GC.Collect();
    long after = GC.GetTotalMemory(true);
    
    long growthMB = (after - baseline) / (1024 * 1024);
    Assert.Less(growthMB, 10, $"Memory grew by {growthMB}MB over 100 spawn/destroy cycles");
}

Load Time Testing

[UnityTest]
public IEnumerator MainMenu_LoadsIn_Under3Seconds()
{
    float startTime = Time.realtimeSinceStartup;
    
    var load = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("MainMenu");
    while (!load.isDone)
        yield return null;
    
    // Wait for any additional initialization
    yield return new WaitForEndOfFrame();
    
    float loadTime = Time.realtimeSinceStartup - startTime;
    
    Assert.Less(loadTime, 3.0f,
        $"Main menu took {loadTime:F1}s to load (target: < 3s)");
}

[UnityTest]
public IEnumerator LevelTransition_DoesNotShowBlackScreen_MoreThan2Seconds()
{
    float blackScreenStart = -1f;
    float maxBlackScreenDuration = 0f;
    
    // Monitor screen transitions
    // This requires a screen capture + analysis approach in practice
    yield return LoadScene("Level1");
    
    // Assert transition time
    Assert.Less(maxBlackScreenDuration, 2.0f,
        $"Black screen lasted {maxBlackScreenDuration:F1}s during transition");
}

Profiling CPU Hotspots

Use Unity Profiler markers to measure specific systems:

using UnityEngine.Profiling;

public class EnemyAIManager : MonoBehaviour
{
    private static readonly ProfilerMarker s_AIUpdateMarker = 
        new ProfilerMarker("EnemyAI.Update");
    
    void Update()
    {
        using (s_AIUpdateMarker.Auto())
        {
            // All AI update logic
            UpdateAllEnemies();
        }
    }
}

// Test: verify AI update stays under budget
[UnityTest, Performance]
public IEnumerator EnemyAI_100Enemies_UpdateUnder5ms()
{
    SpawnEnemies(100);
    yield return null;
    
    var marker = new SampleGroup("EnemyAI.Update", SampleUnit.Millisecond);
    
    for (int frame = 0; frame < 60; frame++)
    {
        // Capture profiler data for AI update
        Measure.Custom(marker, GetProfilerSampleTime("EnemyAI.Update"));
        yield return null;
    }
    
    // Assertion handled by PerformanceTesting package
}

Godot Performance Testing

For Godot 4:

# performance_test.gd
extends GutTest

func test_spawning_100_enemies_under_budget():
    var start_time = Time.get_ticks_usec()
    
    for i in range(100):
        var enemy = preload("res://enemies/goblin.tscn").instantiate()
        add_child(enemy)
    
    var elapsed_ms = (Time.get_ticks_usec() - start_time) / 1000.0
    
    assert_lt(elapsed_ms, 16.7, "Spawning 100 enemies should complete in one frame")

func test_no_memory_leak_in_gameplay_loop():
    var baseline = Performance.get_monitor(Performance.MEMORY_STATIC)
    
    # Simulate gameplay
    for i in range(1000):
        var bullet = preload("res://projectiles/bullet.tscn").instantiate()
        add_child(bullet)
        await get_tree().create_timer(0.016).timeout
        bullet.queue_free()
    
    await get_tree().create_timer(1.0).timeout
    
    var after = Performance.get_monitor(Performance.MEMORY_STATIC)
    var growth_kb = (after - baseline) / 1024
    
    assert_lt(growth_kb, 512, "Memory should not grow significantly in gameplay loop")

Automated Performance Regression Testing in CI

Track performance over time with baseline comparisons:

name: Performance Tests

on:
  push:
    branches: [main, develop]

jobs:
  performance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run Unity Performance Tests
        uses: game-ci/unity-test-runner@v4
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
        with:
          testMode: playmode
          testPathPattern: ".*Performance.*"
          coverageOptions: ""
      
      - name: Compare with baseline
        run: |
          python scripts/compare_performance.py \
            --current artifacts/performance_results.xml \
            --baseline performance_baselines/main.xml \
            --threshold 10  # Fail if >10% regression
      
      - name: Update baseline on main branch
        if: github.ref == 'refs/heads/main'
        run: |
          cp artifacts/performance_results.xml performance_baselines/main.xml
          git add performance_baselines/main.xml
          git commit -m "Update performance baseline [skip ci]"
          git push

Performance Testing Checklist

  • FPS benchmark for main gameplay scenarios
  • Frame time p50/p90/p99 measurements (not just average)
  • Memory usage at startup, peak gameplay, and after level unload
  • Load times for main menu, first level, and longest level
  • GC allocation rate (high GC = stutters)
  • Performance on minimum spec hardware (not just dev machines)
  • Performance regressions caught in CI

Summary

Game performance testing catches regressions before players notice. The Unity Performance Testing Package gives you the measurement infrastructure; the tests above give you the patterns. Focus on:

  1. Frame time percentiles (p99 matters more than average)
  2. Memory growth over time (leaks compound)
  3. Load times (players abandon long load screens)
  4. Automated regression detection in CI

Performance tests are only useful if they run continuously. Set up CI to catch regressions on every commit, and you'll never ship a performance regression that "worked in development."

Read more