← Back to Articles

Ghost Textures and the Dependencies You Didn't Ask For

Ghost Textures and the Dependencies You Didn't Ask For

You load a scene. It's a small level, a few props, simple materials, nothing heavy. But the memory profiler tells a different story. There are large textures loaded that have nothing to do with this scene. You didn't reference them. Nothing visible is using them. They're just there, consuming memory silently.

In a project with asset bundles, this compounds further. Bundles you never explicitly loaded show up as resident in memory. Bundles containing assets for completely different parts of the game. No errors, no warnings, no stack trace pointing at anything.

How Unity Loads More Than You Asked For

Unity's dependency resolution starts at the scene level.

When you load a scene, Unity resolves every asset that scene references: meshes, materials, textures, audio clips. If a material references a texture, that texture gets loaded too. The problem is that Unity resolves dependencies based on what's serialized, not what's actually used by the current shader.

A material can hold texture references that are invisible in the Inspector, properties that belonged to a previous shader, never cleaned up, still sitting in the .mat file. Unity doesn't know they're irrelevant. It sees a reference, and it loads the asset.

In a project with asset bundles, this compounds further. When you load a bundle, Unity inspects its assets and resolves their dependencies. If a material in Bundle A has a stale texture reference pointing to an asset in Bundle B, Unity loads Bundle B automatically, silently, without any warning. And if Bundle B contains materials with their own stale references, more bundles get pulled in. The chain grows.

You end up with bundles in memory you never loaded, for content that's never on screen, tracing back to a .mat file with a texture slot that hasn't been relevant since an artist switched shaders six months ago.

Seeing the Ghost

Here's how easy it is to create this problem.

Take any material. Assign a shader that has a texture slot, say, a custom shader with a _DetailAlbedoMap property. Drag a texture into that slot. Now switch the material to a different shader, one that has no _DetailAlbedoMap property. The material looks correct. The new shader renders fine.

Open the .mat file in a text editor. You'll see something like this:

m_SavedProperties:
  m_TexEnvs:
  - _BumpMap:
      m_Texture: {fileID: 0}
  - _DetailAlbedoMap:
      m_Texture: {fileID: 2800000, guid: a1b2c3d4e5f6..., type: 3}
  - _MainTex:
      m_Texture: {fileID: 0}

That _DetailAlbedoMap entry, with a real guid pointing at a real texture asset, is still there. The current shader doesn't declare _DetailAlbedoMap. Unity won't render it. But the reference is serialized, and Unity's dependency resolver doesn't care whether the shader uses it. It sees a reference, it follows it.

That texture will be included in your asset bundle. And at runtime, the bundle containing that texture will be loaded whenever this material's bundle is loaded.

This is the ghost.

Why It Compounds

One ghost texture on its own is barely worth caring about. In a real project, it compounds fast.

Materials get duplicated, artists swap shaders, projects migrate from Built in to URP, asset store imports land with materials configured for pipelines you don't use, any of it can leave stale references behind, and none of it produces a visible error.

In a project with hundreds of asset bundles, bundles start pulling in other bundles for no obvious reason. Your memory profiler shows allocations you can't explain. You spend hours auditing your loading code looking for a bug that isn't there.

The actual cause is sitting in a .mat file, four lines of YAML that nobody thought to clean up.

The Fix

We can build a small editor tool that resolves this. It lives under Tools > Clear Unused Textures From Materials.

public class ClearUnusedTexturesFromMaterials
{
    public const string KMenuPath = "Tools/Clear Unused Textures From Materials";

    [MenuItem(KMenuPath)]
    public static void ClearUnusedTextures()
    {
        var materials = EditorUtils.GetAssets<Material>();
        foreach (var material in materials)
        {
            material.ClearUnusedTextures();
        }
        AssetDatabase.SaveAssets();
    }
}

The real logic lives in the extension method:

public static void ClearUnusedTextures(this Material material)
{
    var shader = material.shader;
    var propertiesCount = ShaderUtil.GetPropertyCount(shader);
    var usedPropertyNames = new List<string>();

    for (var propertyIndex = 0; propertyIndex < propertiesCount; propertyIndex++)
    {
        var propertyName = ShaderUtil.GetPropertyName(shader, propertyIndex);
        if (string.IsNullOrEmpty(propertyName))
        {
            continue;
        }
        usedPropertyNames.Add(propertyName);
    }

    foreach (var texturePropertyName in material.GetTexturePropertyNames())
    {
        var isUsedProperty = usedPropertyNames.Contains(texturePropertyName);
        if (isUsedProperty)
        {
            continue;
        }
        material.SetTexture(texturePropertyName, null);
        EditorUtility.SetDirty(material);
    }
}

It asks the shader what properties it actually declares via ShaderUtil.GetPropertyCount and ShaderUtil.GetPropertyName, that's the ground truth. Then it checks what the material has serialized using Material.GetTexturePropertyNames(), which returns everything: properties the current shader uses, and ones it doesn't. Any property in the second list but not the first is a ghost. Null it out, mark the material dirty, let Unity serialize it clean.

After running this and rebuilding bundles, those unexplained entries in the memory profiler were gone.

Make It Automatic

The manual tool is a good first pass, but it won't stay solved. Ghost textures aren't a problem you clean up once. They're a workflow problem.

Any developer can accidentally introduce one. An artist swaps a shader, a material gets duplicated from an older prefab, a new asset store package lands with materials configured for a different render pipeline. None of these leave a warning. None of them show up in the Inspector. The stale reference appears silently, and by the time you notice unexpected bundles in the profiler, it's already in your build.

Running the tool manually before every build asks the team to remember something that has no visible feedback loop. Someone will forget. Someone new won't know to do it. The ghost will come back.

The real fix is to call ClearUnusedTexturesFromMaterials.ClearUnusedTextures() as the first step in your build pipeline, before any bundles are created. That way it doesn't matter what happened during development. Every build starts clean. Ghost textures never make it to a bundle, regardless of how they got there.

The Pattern Is Broader

Materials aren't the only place this happens. ScriptableObjects and Prefabs have the same issue, and [Flags] enum conditionals are a common trigger, a field that only appears when a certain flag is set:

[Flags]
public enum GameMode { None = 0, Singleplayer = 1, Multiplayer = 2, Coop = 4 }

public GameMode supportedModes;

[ShowIf(nameof(supportedModes), GameMode.Multiplayer)]
public GameObject multiplayerLobbyPrefab;

You assign a prefab while Multiplayer is active. Later the flag gets removed. The mode got cut, the feature deprecated. The field disappears from the Inspector. It looks clean. But the reference is still serialized. Unity hid the field visually, it didn't remove the data.

The Takeaway

Those unexpected entries in the memory profiler are a symptom, not the problem. The problem is that Unity's dependency resolution is honest: it follows every reference it finds, whether or not that reference still reflects intent.

A stale texture slot in a .mat file is a real dependency as far as the runtime is concerned. It loads assets into scenes that don't need them. In a project with asset bundles, it pulls in entire bundles you never asked for, which pull in more bundles, compounding silently across the project. The same is true for any reference that outlived the context it was created in.

I put the StaleReferenceScanner on GitHub: bartouv/unity-stale-reference-scanner. Drop it in, open Tools > Stale Asset Scanner, and scan your project. You'll probably find more than you expect.