Textures from ShaderMaterial not freeing from VRAM

Godot Version

4.7.1

Question

Hello everyone. I have a problem with my .gd script.
Generally, this script connects to a mesh and accesses the material. After that, it clears the material and texture references so I can free the textures from VRAM.
With StandardMaterial, this works perfectly—the textures are successfully removed from VRAM. However, this doesn’t work with a ShaderMaterial (where a custom shader with textures is assigned); the textures are not being removed from memory.

How did you determine that?

By cheking Debugger → Video RAM tab.
If the mesh uses a StandardMaterial, the textures are successfully removed from the VRAM list.
But with a ShaderMaterial, this doesn’t work, so I am trying to find a solution.

Something else might still be holding a reference to the texture or to any other resource that hold a reference to it. It also may be cached. You’ll need to provide a bit more context. Or post a minimal reproduction example.

In the scene, I have 2 meshes — the first one with a StandardMaterial, and the second one with a ShaderMaterial.
For the ShaderMaterial, I assign the shader with this code:

shader_type spatial;

uniform sampler2D base_color_tex : source_color;
uniform sampler2D paint_mask_tex : hint_default_black;
uniform sampler2D grunge_mask_tex : hint_default_black;
uniform sampler2D rmao_tex : hint_default_white;
uniform sampler2D normal_tex : hint_normal;

uniform vec4 paint_color : source_color = vec4(1.0, 0.0, 0.0, 1.0);
uniform float grunge_intensity : hint_range(0.0, 1.0) = 0.5;

void fragment() {
    vec4 base_color = texture(base_color_tex, UV);
    float paint_mask = texture(paint_mask_tex, UV).r;
    float grunge_mask = texture(grunge_mask_tex, UV).r;
    float base_roughness = rmao.g;
    vec4 rmao = texture(rmao_tex, UV);
    vec3 painted_base = mix(base_color.rgb, paint_color.rgb * paint_mask, paint_mask);
    float grunge_effect = grunge_mask * grunge_intensity;
    vec3 final_albedo = mix(painted_base, painted_base * 0.4, grunge_effect);

    ALBEDO = final_albedo;
    NORMAL_MAP = texture(normal_tex, UV).rgb;
    ROUGHNESS = mix(base_roughness, base_roughness * grunge_mask, grunge_intensity);
    METALLIC = rmao.b;
    AO = rmao.r;
}

In my script, I use _material_cache.erase(path) to clear the reference to the texture, and as I understand it, when the reference count drops to 0, the engine automatically clears the textures from VRAM. But this only works with a StandardMaterial. The ShaderMaterial is used by only one mesh (where my script is attached), with no other references.

Shader code doesn’t really matter. Post the relevant script code.

Yes but are you certain that reference count indeed went to 0?

Sorry, I forgot to include some information in my previous post.

The object script calls MaterialStreamingManager.register_object(self, material_path)
The manager creates an entry with obj: reference to the MeshInstance3D and material_path: the resource path to the material file.

func register_object(obj: MeshInstance3D, material_path: String = "") -> void:
    for entry in _all_objects:
        if entry.obj == obj:
            return

    var entry = {
        "obj": obj,
        "material_path": material_path,
        "static_material": null, пуст
        "is_active": true,
        "original_visible": obj.visible
    }

    if material_path == "":
        entry.static_material = obj.material_override
        if entry.static_material == null and obj.mesh:
            entry.static_material = obj.mesh.surface_get_material(0)

    _all_objects.append(entry)

    var cell = _world_to_cell(obj.global_transform.origin)
    if not _grid.has(cell):
        _grid[cell] = []
    _grid[cell].append(entry)

    if _camera:
        var dist_sq = obj.global_transform.origin.distance_squared_to(_camera.global_transform.origin)
        if dist_sq > unload_distance * unload_distance:
            entry.is_active = false
            obj.visible = false
            if material_path == "" and entry.static_material:
                obj.material_override = null

If the distance between the player and the object is greater than 20, I call _deactivate_entry

func _deactivate_entry(entry: Dictionary) -> void:
	var obj: MeshInstance3D = entry.obj
	if not is_instance_valid(obj):
		return

	entry.is_active = false
	obj.visible = false

	if entry.material_path != "":
		obj.material_override = null
		_release_material(entry.material_path)
	else:
		obj.material_override = null

in _deactivate_entry I call _release_material for clean material

func _release_material(path: String) -> void:
    if path == "":
        return
    if _material_cache.has(path):
        var info = _material_cache[path]
        info.ref_count -= 1
        if info.ref_count <= 0:
            if info.loading:
                info.should_unload = true
                info.pending_entries.clear()
            else:
                _material_cache.erase(path)

At this point, no references to the material remain (object’s material_override is null, cache entry removed).

What’s the purpose of _release_material()? Are you implementing some sort of custom reference counting? You may have bugs in there. Why not just let Godot’s reference counting do the job.

Can you make a minimal reproduction example that doesn’t include your management system?

The idea is that when the player moves away, this manager should hide the object, clear its material, and consequently the textures as well (to avoid cluttering memory). Godot doesn’t automatically release textures that consume VRAM.

So can you replicate the behavior without the manager?

In general I think you’ve engaged in premature optimization. You shouldn’t really worry about this unless you’re hitting the vram limits on your target hardware. Just unreference the resource and let the engine handle the cleanup.

This is awesome… When I started preparing the MRP, I copied and pasted my mesh from the scene and put together a small test script. That’s when I realized the material was assigned to surface_material_override instead of material_override… You should have seen my face, I spent 2 days trying to rewrite my manager…

After switching it back to material_override and testing the scene, oh god, the ShaderMaterial textures were successfully removed from VRAM!

Anyway, thanks for the help!

Of course, you’re right, this optimization wasn’t strictly necessary, but—considering I’m using 2K–4K textures on an object that isn’t currently being rendered and is simply hidden—I’m glad I found the cause.