אופטימיזציה של הזיכרון במשחקי Unity ב-Android

למרות שהקיבולת הפיזית של ה-RAM במכשירים ניידים מודרניים ממשיכה לגדול, צריכת הזיכרון של המשחקים עולה בקצב מהיר יותר בגלל נכסים ברזולוציה גבוהה וצינורות עיבוד מורכבים.

בעיות עיקריות שנגרמות כתוצאה משימוש מוגזם בזיכרון

  • סגירות של תהליכים בגלל מחסור בזיכרון (LMK): מערכת ההפעלה סוגרת בכוח אפליקציות ברקע או אפילו בחזית כדי לפתור בעיות של מחסור בזיכרון במערכת כולה.
  • פריימים חסרים וגמגום (jank): שיאים תכופים של איסוף אשפה (GC) בערימה המנוהלת או בהחלפת זיכרון ברמת מערכת ההפעלה יוצרים צווארי בקבוק בעיבוד.
  • הגבלת מהירות השעון (throttling) של המעבד והתרוקנות הסוללה: הקצאה, ביטול הקצאה והתחייבויות של דפי זיכרון מתמשכים גורמים לתקורה משמעותית של המעבד, מה שמוביל לעלייה בטמפרטורה ולהתרוקנות מהירה יותר של הסוללה.

עדכונים בניהול הזיכרון ב-Android

  • Android 17: השקת MemoryLimiter: ב-Android 17 הושק MemoryLimiter שעוקב באופן פעיל אחרי צריכת הזיכרון של האפליקציה בהשוואה לספים ספציפיים למכשיר. אפליקציות שחורגות ממגבלת הזיכרון שלהן מופסקות באופן מיידי ברמת המערכת. מכיוון שהמנגנון הזה מחמיר יותר מ-LMK רגיל, ניהול השימוש בזיכרון בשיא הפעילות חשוב יותר מאי פעם.

איך מצמצמים את השימוש בזיכרון ב-Unity

ב-Unity, אחרי שמנוע המשחק מרחיב את מאגרי הזיכרון הפנימיים שלו (Native Block Allocators ו-Managed Heap) כדי להתמודד עם עומס שימוש גבוה, הוא שומר את דפי הזיכרון האלה במקום להחזיר אותם מיד למערכת ההפעלה. כתוצאה מכך, גם אחרי שהנכסים הכבדים מוסרים מהזיכרון, ערך הבסיס של Resident Memory נשאר גבוה, והאפליקציה הופכת לפגיעה מאוד לסיום תהליכים במערכת ההפעלה Android (כמו LMK או MemoryLimiter).

כדי למנוע אכיפה ברמת מערכת ההפעלה, צריך להתייחס לאופטימיזציה של הזיכרון דרך שלושה עקרונות מרכזיים:

  • קטגוריה א': צמצום השימוש בזיכרון בשיא
  • קטגוריה ב': צמצום טביעות הרגל של נכסים ומערכות לא נחוצים
  • קטגוריה ג': ביטול הקצאות מיותרות של GC

קטגוריה א': צמצום השימוש בזיכרון בשיא

הקצאת הזיכרון של Unity שומרת דפי זיכרון שפנויים לשימוש חוזר במקום להחזיר אותם למערכת ההפעלה באופן מיידי, ולכן זיכרון הבסיס נוטה לשקף את השיא הגבוה ביותר שהושג ולא את השימוש הנוכחי. לכן, עדיף למנוע את העליות החדות מלכתחילה מאשר להסתמך על ניקוי אחרי שהן קורות.

1. הימנעות מ-AssetBundles גדולים מדי

בגלל מנגנון טעינת החבילות של Unity, חבילות AssetBundle גדולות מובילות לניפוח זיכרון חמור ולקריסות OOM. כדי להבטיח ניהול יעיל של משאבים, חשוב לשמור על מודולריות של חבילות.

בעיות מרכזיות

  • תקורה גבוהה של זיכרון: כשמבקשים נכס קטן יחיד, Unity נאלצת לטעון את כל קובץ החבילה (כולל כותרות, מטא-נתונים ומאגרי זמני של נתונים) לזיכרון ה-RAM.
  • המלכודת של ביטול הטעינה: אם נעשה שימוש פעיל בנכס כלשהו בתוך חבילה, אי אפשר לבטל את הטעינה של החבילה כולה, והנתונים שלא בשימוש נשארים בזיכרון ה-RAM.

שיטות מומלצות

  • שמירה על מודולריות של חבילות: קיבוץ נכסים באופן הגיוני לפי סצנה או מחזור חיים.
  • טיפ ל-Unity 6.6 ואילך: כדי למנוע תלות לא מכוונת בין חבילות, כדאי להשתמש בספריות תוכן.

2. אופטימיזציה של הפניות לנכסים בקמפיין ScriptableObjects

שדות ישירים מסוג UnityEngine.Object serialized ב-ScriptableObject יוצרים הפניות קשיחות ישירות, וכך מאלצים את כל הנכסים שהם מפנים אליהם להיטען ל-RAM ברגע ש-ScriptableObject עצמו נטען או מופעל.

// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public Object _worldAsset;
    public Object _spawnSettings;
}

// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public AssetReference _worldAsset;
    public AssetReference _spawnSettings;
}

3. הגדרת סוגי טעינה של קליפים של אודיו

טעינה של כל קטעי האודיו ישירות לזיכרון ללא דחיסה יוצרת שיאי זיכרון גדולים וקבועים. צריך להגדיר את הגדרות טעינת האודיו בהתאם לפרופיל השימוש:

קטגוריית אודיו סוג הטעינה סיבה
BGM (מוזיקת רקע) סטרימינג האודיו מוזרם מהדיסק במאגרי נתונים זמניים קטנים כדי למנוע עליות חדות בשימוש בזיכרון.
Long SFX דחיסה בזיכרון הוא שומר על תקורה נמוכה של זיכרון ה-RAM ומבצע דקומפרסיה של האודיו תוך כדי הפעלה.
אפקטים קצרים ובתדירות גבוהה חילוץ בזמן הטעינה האודיו עובר דקומפרסיה ל-RAM בזמן הטעינה כדי למנוע עומס יתר על המעבד בזמן ההפעלה.

4. יישום של אסטרטגיות לשחרור ולשיתוף של אובייקטים

מופעים שלא שוחררו ונשארו בתוך מאגרי אובייקטים במהלך מעברים בין סצנות, ממשיכים להשתמש בזיכרון שמור ללא הגבלת זמן, וכך מגדילים את הזיכרון שבשימוש שלא לצורך.

  • פעולה: מדי פעם, כדאי לנקות או לצמצם אובייקטים מאוגדים שלא נמצאים בשימוש במהלך מעברים בין סצנות או בתקופות של פעילות נמוכה, כדי לאפשר ל-Unity להחזיר או לעשות שימוש חוזר במקום השמור הזה להקצאות אחרות.

קטגוריה ב': צמצום השימוש בזיכרון

הסרת נכסים גרפיים מיותרים ועיבוד של מאגרי יעד ישירות מקטינים את טביעת הרגל הבסיסית של הזיכרון.

1. אופטימיזציה של טקסטורות רינדור ועומק המצלמה

  • הסרת מאגרי עומק/סטנסיל: מגדירים את הפורמט של סטנסיל העומק לללא עבור טקסטורות של עיבוד שנדרשים להן רק נתוני צבע.
  • השבתה של מרקם עומק המצלמה בממשק המשתמש: במצלמות בממשק המשתמש שבהן נתוני העומק לא נחוצים, אפשר להשבית את יצירת מרקם העומק בהגדרות המצלמה של URP כדי לבטל את המעבר CopyDepth ואת זיכרון המרקם המשויך ב-GPU.

2. אופטימיזציה של טקסטורות ורשתות

קטגוריה הנחיה לאופטימיזציה
דחיסת מרקם תמיד צריך להחיל פורמטים של דחיסה של פלטפורמת היעד (לדוגמה, ASTC ל-Android).
קריאה/כתיבה מופעלות עדיף להשאיר את ההגדרה מושבתת אלא אם יש צורך להפעיל אותה. הפעלת האפשרות הזו משכפלת את זיכרון הטקסטורה ב-RAM של המעבד וב-RAM של ה-GPU.
Mipmaps משביתים את Mipmaps לטקסטורות או לאובייקטים של ממשק המשתמש שקבועים במרחק קבוע מהמצלמה, וכך חוסכים כ-33% מזיכרון הטקסטורה.
מורכבות של רשת כדי לצמצם את השימוש ב-GPU ובזיכרון שבשימוש, צריך להקטין את מספר הפוליגונים ואת זרמי הקודקודים שלא נחוצים.

3. הסרת וריאציות של הצללות ואופטימיזציה של הזיכרון

‫Uber-shaders (לדוגמה, URP Lit Shader) מכילים תכונות רבות באמצעות מילות המפתח #multi_compile ו-shader_feature. בלי אופטימיזציה, וריאציות של shader יוצרות עשרות אלפי שילובים ייחודיים, מה שמוביל לגדלים מנופחים של קובצי build, לצריכה מסיבית של זיכרון מקומי ולבעיות בהידור של מנהלי התקנים של GPU במהלך המשחק.

א. מכניקה של תקורה בזיכרון של וריאציות של shader

  • פיצוץ קומבינטורי: מספר הגרסאות האפשריות גדל באופן אקספוננציאלי עם כל קבוצת מילות מפתח שמוסיפים.
  • ארכיטקטורת הקצאת גושים: קבוצות Unity מהדרות וריאציות בינאריות דחוסות של בלוקי זיכרון שנקראים גושים (ברירת מחדל: 4MB).
  • ניפוח זיכרון מקורי: כשקוד זמן הריצה מבקש אפילו וריאנט אחד בתוך נתח, כל הנתח בגודל 4MB עובר דקומפרסיה ל-RAM. אם לא מבצעים אופטימיזציה, אלפי וריאציות לא בשימוש שנארזות בחלקים האלה תופסות זיכרון מקומי באופן קבוע.

ב. ‫Unity built-in multi-stage stripping pipeline: ‏ Unity מסיר באופן אוטומטי וריאציות מיותרות משך זמן של תהליך build על סמך הגדרות הגרפיקה של פלטפורמת היעד ותכונות מנוע שלא נעשה בהן שימוש (לדוגמה, Fog,‏ Lightmaps והגדרות XR). בנוסף, וריאציות של shader_feature מסוננות אוטומטית אם מילות המפתח שלהן לא נמצאות בשימוש פעיל בחומרים כלשהם בפרויקט, בעוד שווריאציות של #multi_compile נכללות בכוח ללא קשר לשימוש.

ג. ארכיטקטורה מותאמת אישית של הסרת תגים אוטומטית (IPreprocessShaders): מכיוון שניתוח סטטי לא יכול לזהות מילות מפתח שמשתנות באופן דינמי באמצעות סקריפטים של C# בזמן ריצה (Material.EnableKeyword), הסרת תגים רגילה לרוב לא מספיקה. כדי לוודא שרק וריאציות שנמצאות בשימוש בפועל ייכללו, אפשר לאסוף וריאציות במהלך חבילות בדיקות QA באמצעות Player.log (הפעלת Log Shader Compilation בהגדרות Editor) או Profiler Traces (סמני Shader.CreateGPUProgram). לאחר מכן, מטמיעים את IPreprocessShaders.OnProcessShader בסקריפט של Editor כדי לסנן וריאציות שלא הופעלו אף פעם במהלך זמן הריצה, ולהשאיר רק את הווריאציות הנדרשות ב-build.

קטגוריה ג': ביטול הקצאות מיותרות של GC

הקצאות של איסוף אשפה (GC) ב-Managed Heap מובילות לפיצול זיכרון, להרחבות של ה-Heap שלא מצטמצמות אף פעם ולנפילות חמורות של פריימים במהלך הפסקות של GC.

1. מניעת הקצאות של סגירת lambda

כשביטוי למבדה לוכד משתנים מקומיים חיצוניים, C# ‎ יוצר מחלקה מוצגת מרומזת ב-Heap. הפעלת הקוד הזה בתוך Update מקצה מופעים של סגירה בכל פריים.

// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
    int targetId = 100;
    Monster target = monsterList.Find(m => m.Id == targetId);
}

// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
    int targetId = 100;
    Monster target = null;
    for (int i = 0; i < monsterList.Count; i++)
    {
        if (monsterList[i].Id == targetId)
        {
            target = monsterList[i];
            break;
        }
    }
}

// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);

2. שימוש ב-stackalloc וב-Span

כדי להימנע מהקצאות זיכרון בערימה (heap allocation) למערכים זמניים קצרי-חיים, משתמשים בזיכרון מחסנית (stack memory).

// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];

// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];

3. אופטימיזציה של איטרציה של אוסף

מומלץ להימנע מהקצאות של זיכרון בערימה (heap) ל-boxing ול-Enumerator שנגרמות על ידי תוספי LINQ או על ידי פונקציות גישה של ReadOnlyCollection.

// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;

// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);

private bool HasDataOptimized(TestComponent component)
{
    if (component == null) return false;

    var count = component.parameters.Count;
    for (var i = 0; i < count; ++i)
    {
        if (component.parameters[i].overrideState)
            return true;
    }
    return false;
}

4. כללים נוספים למניעת הקצאת זיכרון ב-GC

  • מומלץ להימנע משימוש ב-Camera.allCameras כי היא יוצרת מערך Camera[] חדש ב-heap בכל קריאה. במקום זאת, צריך לשמור במטמון מערך מצלמות ולהעביר אותו אל Camera.GetAllCameras(_allCameras).
  • הימנעות משימוש ב-foreach ב-IReadOnlyList<T>: איטרציה על פני ממשק גורמת לאריזת מונה של מבנה, וכך נוצרות הקצאות של GC. במקום זאת, צריך להשתמש בלולאה רגילה של for.
  • שמירת אובייקטים של שגרות המשך במטמון: שמירת מופעים של WaitForSeconds במטמון במקום ליצור מופעים של yield return new WaitForSeconds(time); שוב ושוב.
  • מפתחות מותאמים אישית של struct במילונים: שימוש ב-structs מותאמים אישית כמפתחות Dictionary מפעיל את ברירת המחדל Equals, וגורם לאריזת אובייקטים. מטמיעים את IEqualityComparer<T> ומעבירים אותו ל-Dictionary constructor.
public struct TypeKey
{
    public int v1;
    public int v2;

    public class TypeKeyComparer : IEqualityComparer<TypeKey>
    {
        public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
        public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
    }
}

// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());

5. צמצום השימוש בגנריות ובהשתקפות

שיטות גנריות מאפשרות שימוש חוזר בקוד ושמירה על תחזוקה טובה, אבל שימוש יתר בהן עלול להשפיע לרעה על הפרויקט בהקשר של Unity IL2CPP (שפת ביניים ל-C++) backend.

  • ניפוח קוד IL2CPP: לכל שילוב ייחודי של סוג כללי, IL2CPP יוצר גרסה מיוחדת של הקוד. שימוש מוגזם ב-generics מורכבים עלול להוביל ל"פיצוץ קומבינטורי" של קוד C++‎ שנוצר, ולהגדיל באופן משמעותי את גודל הקובץ הבינארי של האפליקציה ואת טביעת הרגל של הזיכרון המקורי.
  • תקורה של רפלקציה: שיטות שמשתמשות ברפלקציה, כמו ממשקי API של System.Reflection, הן איטיות מטבען ולרוב גורמות להקצאות של ערימה במהלך זמן הריצה.
  • שיטה מומלצת: כדאי להשתמש ב-generics בחוכמה – לתת להם עדיפות כדי להשיג בהירות ארכיטקטונית ולא להשתמש בהם באופן נרחב ולא מובחן. במקרים שבהם הביצועים הם קריטיים, עדיף להשתמש בסוגים קונקרטיים או בפולימורפיזם מבוסס-ממשק. כדי לשפר את הביצועים, כדאי לשמור במטמון תוצאות כמו MethodInfo או FieldInfo במהלך האתחול במקום לשלוח שאילתות לגביהן בלולאת העדכון.

6. איך להימנע מדליפות של מעטפות מנוהלות

לכל UnityEngine.Object, כמו MonoBehaviour, Texture או GameObject, יש עטיפה של C#‎ מסוג Managed Shell שמתקשרת עם מנוע C++‎ מקורי.

  • הבעיה: אם Managed Shell נשמר בזיכרון על ידי הפניה סטטית, מינוי לאירוע מתמשך או סגירה לא נקייה, ה-GC לא יכול לשחרר את הזיכרון. גם אם האובייקט המקורי מושמד, העטיפה המנוהלת נשארת, מה שמוביל לדליפות זיכרון 'רפאים' שמנפחות את ה-Managed Heap.
  • רזולוציה: תמיד כדאי להטמיע דפוסי ניקוי חזקים. כשמבטלים אובייקטים או מעברים בין סצנות, צריך לבטל את ההרשמה לאירועים באופן מפורש באמצעות האופרטור -= ולבטל הפניות סטטיות לסוגים UnityEngine.Object. הניקוי הזה מבטיח ש-GC יוכל לאסוף את העטיפה אחרי שהמנוע המקורי ישחרר את הטיפול שלו.