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

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

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

  • סגירת אפליקציות בגלל מחסור בזיכרון (LMK): מערכת ההפעלה סוגרת בכפייה אפליקציות שפועלות ברקע או אפילו בחזית כדי לפתור מחסור בזיכרון בכל המערכת.
  • השמטה של פריימים וגמגום (jank): שיאים תכופים של איסוף אשפה (GC) בערימה המנוהלת או החלפת זיכרון ברמת מערכת ההפעלה יוצרים צווארי בקבוק בעיבוד.
  • הגבלת מהירות שעון (Thermal 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 (מוזיקת רקע) סטרימינג העברת אודיו מהדיסק במאגרים קטנים כדי למנוע עליות חדות בשימוש בזיכרון.
אפקט קולי ארוך דחוס בזיכרון שומר על תקורה נמוכה של זיכרון ה-RAM ומבצע דקומפרסיה של האודיו תוך כדי הפעלה.
אפקטים קוליים קצרים ותכופים ביטול דחיסה בזמן הטעינה פורס את האודיו ל-RAM בזמן הטעינה כדי למנוע עומס על המעבד בזמן ההפעלה.

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

  • הפעלת האפשרות 'המרת אודיו למונו' לאודיו לא כיווני: הרבה נכסי אודיו – כמו אפקטים קוליים בממשק המשתמש, קריינות ואפקטים קוליים תלת-ממדיים – לא דורשים הפרדה בין ערוצי סטריאו. מפעילים את האפשרות Force To Mono בהגדרות הייבוא של AudioClip לנכסים האלה כדי לצמצם את הזיכרון שבשימוש ואת שטח האחסון הזמני שלהם בחצי (הפחתה של 50%).
  • הגבלת ערוצי אודיו פעילים לפי רמת המכשיר: כל קול אודיו פעיל מקצה מאגרים מקוריים של מיקסר DSP ופריסת נתונים. במכשירים עם מעט זיכרון, כדאי להפחית את המספר המקסימלי של ערוצים פעילים בו-זמנית. לדוגמה, אפשר להפחית את הערך של Max Real Voices (למשל מ-32 ל-16) באמצעות AudioSettings.Reset ב-Unity, או להגביל את ערוצי ההפעלה של התוכנה במנועי אודיו של צד שלישי.

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

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

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

קטגוריה ב': ביטול שימוש מיותר בזיכרון

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

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

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

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

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

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

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

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

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

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

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

ד. איחוד של שיידרים מיותרים: שיידרים מסוג Uber, כמו Lit ו-Simple Lit, מכילים הרבה הוראות #multi_compile וצורכים הרבה זיכרון כשהם יוצרים וריאציות רבות:

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

קטגוריה ג': ביטול הקצאות 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) למערכים זמניים לטווח קצר, משתמשים בזיכרון מחסנית.

// 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 allocation) של 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[] חדש בערימה בכל קריאה. במקום זאת, צריך לשמור במטמון מערך מצלמות ולהעביר אותו אל Camera.GetAllCameras(_allCameras).
  • ‫Avoid foreach on IReadOnlyList<T>: Iterating over an interface causes struct enumerator boxing, generating GC allocations. במקום זאת, צריך להשתמש בלולאה רגילה של for.
  • שמירת אובייקטים של שגרות המשך במטמון: שמירת מופעים של WaitForSeconds במטמון במקום ליצור מופעים של yield return new WaitForSeconds(time); שוב ושוב.
  • מפתחות struct בהתאמה אישית במילונים: שימוש ב-structs בהתאמה אישית כמפתחות Dictionary מפעיל את ברירת המחדל Equals, וגורם ל-boxing של אובייקטים. מטמיעים את IEqualityComparer<T> ומעבירים אותו ל-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. צמצום השימוש ב-generic וב-reflection

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

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

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

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

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

7. שימוש בקטלוגים בינאריים ל-Addressables

ב-Unity 2022 ובגרסאות קודמות, ברירת המחדל של Addressables היא קטלוגים בפורמט JSON ולא קטלוגים בינאריים (שהם ברירת המחדל ב-Unity 2023 ובגרסאות מאוחרות יותר). אם הפרויקט שלכם משתמש בקטלוגים בפורמט JSON, טעינה של קטלוג גדול בזמן ההפעלה קוראת את כל הקובץ למחרוזת C# אחת גדולה, ומקצה אלפי מחרוזות מנוהלות ומערכי byte[] זמניים במהלך ניתוח ה-JSON ופענוח Base64. הפעולה הזו גורמת לעלייה חדה בהקצאת ה-GC שמרחיבה באופן קבוע את ה-Managed Heap, לכן כדאי לעבור לקטלוג בינארי כדי להעביר נתונים קומפקטיים בסטרימינג ישירות.