Compositor | Usage differences between GDScript and C#?

Godot Version

v4.6.stable.mono.official [89cea1439]

Question

For context, I’m trying to create a simple full-screen blur effect using Godot’s compositor feature. I’ve been following the documentation (see here) and everything is now set up as instructed.

However, I’m getting an error saying that the TEXTURE_USAGE_STORAGE_BIT is not set on the color image (RenderSceneBuffersRD.GetColorLayer()) – a texture that is initialized by the engine, not me.


The errors logged when I run the game.


Stack trace of the logged error concerning the TEXTURE_USAGE_STORAGE_BIT.

Given that I’m using C#, I tried to make the same basic CompositorEffect in GDScript which actually worked without issue. This leaves me wondering whether the C# build of the engine is not setting the correct usage flags on the textures inside the RenderSceneBuffersRD retrieved from the RenderData passed to the CompositorEffect’s _RenderCallback method.

It’s worth nothing that the error does not occur when simply viewing the effect in the preview image located in the Camera3D’s inspector. It only happens when I run the game. In other words, it works in the editor but not in the game.


I haven’t been able to find a post anywhere else describing this specific problem with the color texture – only the depth texture:

I feel like I’ve triple-checked any differences there may exist between the C# script and the GDScript script. Am I missing something here?

C# CompositorEffect
using Godot;
using System;

[Tool, GlobalClass]
public partial class CompositorEffectSplitScreen : CompositorEffect
{
    const EffectCallbackTypeEnum EFFECT_TYPE = EffectCallbackTypeEnum.PostTransparent;
    const int MAX_SPLIT_SCREENS = 4;
    const string SHADER_FILE_PATH = "res://shaders/glsl_shaders/zone_blur.glsl";

    RDShaderFile shaderFile = ResourceLoader.Load<RDShaderFile>(SHADER_FILE_PATH);

    const string contextName = "blur";
    const string blurTextureName = "blurTex";

    RenderingDevice rd;
    Rid shader;
    Rid pipelineCompute;
    Rid pipelineRender;

    // Mutex mutex = new Mutex();

    [Export] private bool useHalfRes = false;

    public CompositorEffectSplitScreen()
    {
        EffectCallbackType = EFFECT_TYPE;

        // Get the global rendering device
        rd = RenderingServer.GetRenderingDevice();

        // Initialize compute shader on the rendering thread
        RenderingServer.CallOnRenderThread(Callable.From(InitComputeShader));
    }

    private string DebugTime()
    {
        float ms = Time.GetTicksMsec();
        TimeSpan ts = TimeSpan.FromMilliseconds(ms);
        return ts.ToString(@"hh\:mm\:ss\.fffffff");
    }

    private bool InitComputeShader()
    {
        if (!rd.IsValid())
            return false;

        GD.Print($"{DebugTime()} - Shader file loaded: {(shaderFile.IsValid() ? "true" : "false")}");

        // Compile shader from shader file
        var shaderSpirv = shaderFile.GetSpirV();
        shader = rd.ShaderCreateFromSpirV(shaderSpirv);
        GD.Print($"{DebugTime()} - Compiled shader is valid: {(shader.IsValid ? "true" : "false")}");
        if (shader.IsValid)
            pipelineCompute = rd.ComputePipelineCreate(shader);

        GD.Print($"{DebugTime()} - Compute pipeline is valid: {(pipelineCompute.IsValid ? "true" : "false")}");

        return true;
    }

    // private void InitRenderShader()
    // {

    //     var rasterState = new RDPipelineRasterizationState();
    //     var sampleState = new RDPipelineMultisampleState();
    //     var stencilState = new RDPipelineDepthStencilState();
    //     var colorState = new RDPipelineColorBlendState();

    //     pipelineRender = rd.RenderPipelineCreate(shader, default, default, RenderingDevice.RenderPrimitive.Triangles,
    //         rasterState, sampleState, stencilState, colorState, RenderingDevice.PipelineDynamicStateFlags.BlendConstants);
    // }

    public override void _RenderCallback(int effectCallbackType, RenderData renderData)
    {
        if (rd.IsValid() && effectCallbackType == (int)EFFECT_TYPE)
        {
            // Retrieve the scene buffer data from the render data
            var buffers = renderData.GetRenderSceneBuffers() as RenderSceneBuffersRD;
            var data = renderData.GetRenderSceneData() as RenderSceneDataRD;

            var renderSize = buffers.GetInternalSize();
            var effectSize = renderSize;

            if (useHalfRes)
                effectSize = new Vector2I(Mathf.RoundToInt(effectSize.X * 0.5f), Mathf.RoundToInt(effectSize.Y * 0.5f));

            // Don't render NOTHING
            if (effectSize.X == 0 && effectSize.Y == 0)
                return;

            // ===================== CREATE / REFRESH TEXTURES =====================
            if (buffers.HasTexture(contextName, blurTextureName))
            {
                // If the size for the texture no longer matches the target size, clear all textures within the context
                // NOTE: What is a context?
                var tf = buffers.GetTextureFormat(contextName, blurTextureName);
                if (tf.Width != effectSize.X || tf.Height != effectSize.Y)
                {
                    buffers.ClearContext(contextName);
                }
            }

            if (!buffers.HasTexture(contextName, blurTextureName))
            {
                uint usageBits = (uint)RenderingDevice.TextureUsageBits.SamplingBit | (uint)RenderingDevice.TextureUsageBits.StorageBit;
                buffers.CreateTexture(contextName, blurTextureName, RenderingDevice.DataFormat.R16Unorm, usageBits, RenderingDevice.TextureSamples.Samples1, effectSize, 1, 1, true, false);
            }
            // =====================================================================

            // Compute shader settings (NOTE: u-literal implies uint type)
            uint xGroups = (uint)(effectSize.X - 1) / 8u + 1u;
            uint yGroups = (uint)(effectSize.Y - 1) / 8u + 1u;
            uint zGroups = 1u;

            // Push constant
            float[] pushConstant = new float[4];
            pushConstant[0] = renderSize.X;
            pushConstant[1] = renderSize.Y;
            pushConstant[2] = effectSize.X;
            pushConstant[3] = effectSize.Y;
            // Docs: [...] we (may) have to pad this array with a 16 byte alignment.
            // the length of our array needs to be a multiple of 4.
            int pushByteSize = pushConstant.Length * sizeof(float);
            byte[] pushBytes = new byte[pushByteSize];
            Buffer.BlockCopy(pushConstant, 0, pushBytes, 0, pushBytes.Length);
            // NOTE: A float array is created to store the floating-point numbers.
            //      However, because the compute list (further down) expects a byte array,
            //      a byte array is produced from the float array via Buffer.BlockCopy.

            // Loop through views (for cases when stereo rendering is enabled)
            // NOTE: This will never be the case for this game. It's just a formality
            var viewCount = buffers.GetViewCount();
            for (uint view = 0; view < viewCount; view++)
            {
                // Prime buffers for uniform set
                Rid colorBuffer = buffers.GetColorLayer(view);
                Rid blurBuffer = buffers.GetTextureSlice(contextName, blurTextureName, view, 0, 1, 1);

                // var samplerState = new RDSamplerState();
                // samplerState.MinFilter = RenderingDevice.SamplerFilter.Linear;
                // samplerState.MagFilter = RenderingDevice.SamplerFilter.Linear;
                // var sampler = rd.SamplerCreate(samplerState);

                // DOCS: Note the use of our UniformSetCacheRD cache which ensures we can check for our 
                // uniform set each frame. As our color buffer can change from frame to frame and our uniform
                // cache will automatically clean up uniform sets when buffers are freed, this is the safe way
                // to ensure we do not leak memory or use an outdated set.
                RDUniform uniformColor = new RDUniform();
                uniformColor.UniformType = RenderingDevice.UniformType.Image;
                uniformColor.Binding = 0;
                uniformColor.AddId(colorBuffer);
                var uniformColorSet = UniformSetCacheRD.GetCache(shader, 0, [uniformColor]);

                // RDUniform uniformBlur = new RDUniform();
                // uniformBlur.UniformType = RenderingDevice.UniformType.Image;
                // uniformBlur.Binding = 1;
                // uniformBlur.AddId(blurBuffer);
                // var uniformBlurSet = UniformSetCacheRD.GetCache(shader, 1, [uniformBlur]);

                var computeList = rd.ComputeListBegin();
                rd.ComputeListBindComputePipeline(computeList, pipelineCompute);
                rd.ComputeListBindUniformSet(computeList, uniformColorSet, 0);
                // rd.ComputeListBindUniformSet(computeList, uniformBlurSet, 1);
                rd.ComputeListSetPushConstant(computeList, pushBytes, (uint)pushByteSize);
                rd.ComputeListDispatch(computeList, xGroups, yGroups, zGroups);
                rd.ComputeListEnd();
            }
        }
    }

    public override void _Notification(int what)
    {
        if (what == (int)NotificationPredelete)
        {
            if (shader.IsValid)
            {
                rd.FreeRid(shader);
            }
        }
    }
}
GDScript CompositorEffect
@tool
class_name CompositorEffectTesting
extends CompositorEffect

var rd: RenderingDevice
var shader: RID
var pipeline: RID


func _init() -> void:
	effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
	rd = RenderingServer.get_rendering_device()
	RenderingServer.call_on_render_thread(_initialize_compute)


# System notifications, we want to react on the notification that
# alerts us we are about to be destroyed.
func _notification(what: int) -> void:
	if what == NOTIFICATION_PREDELETE:
		if shader.is_valid():
			# Freeing our shader will also free any dependents such as the pipeline!
			rd.free_rid(shader)


#region Code in this region runs on the rendering thread.
# Compile our shader at initialization.
func _initialize_compute() -> void:
	rd = RenderingServer.get_rendering_device()
	if not rd:
		return

	# Compile our shader.
	var shader_file := load("res://shaders/glsl_shaders/zone_blur.glsl")
	var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()

	shader = rd.shader_create_from_spirv(shader_spirv)
	if shader.is_valid():
		pipeline = rd.compute_pipeline_create(shader)


# Called by the rendering thread every frame.
func _render_callback(p_effect_callback_type: EffectCallbackType, p_render_data: RenderData) -> void:
	if rd and p_effect_callback_type == EFFECT_CALLBACK_TYPE_POST_TRANSPARENT and pipeline.is_valid():
		# Get our render scene buffers object, this gives us access to our render buffers.
		# Note that implementation differs per renderer hence the need for the cast.
		var render_scene_buffers := p_render_data.get_render_scene_buffers()
		if render_scene_buffers:
			# Get our render size, this is the 3D render resolution!
			var size: Vector2i = render_scene_buffers.get_internal_size()
			if size.x == 0 and size.y == 0:
				return

			# We can use a compute shader here.
			@warning_ignore("integer_division")
			var x_groups := (size.x - 1) / 8 + 1
			@warning_ignore("integer_division")
			var y_groups := (size.y - 1) / 8 + 1
			var z_groups := 1

			# Create push constant.
			# Must be aligned to 16 bytes and be in the same order as defined in the shader.
			var push_constant := PackedFloat32Array([
					size.x,
					size.y,
					0.0,
					0.0,
				])

			# Loop through views just in case we're doing stereo rendering. No extra cost if this is mono.
			var view_count: int = render_scene_buffers.get_view_count()
			for view in view_count:
				# Get the RID for our color image, we will be reading from and writing to it.
				var input_image: RID = render_scene_buffers.get_color_layer(view)

				# Create a uniform set, this will be cached, the cache will be cleared if our viewports configuration is changed.
				var uniform := RDUniform.new()
				uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
				uniform.binding = 0
				uniform.add_id(input_image)
				var uniform_set := UniformSetCacheRD.get_cache(shader, 0, [uniform])

				# Run our compute shader.
				var compute_list := rd.compute_list_begin()
				rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
				rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
				rd.compute_list_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
				rd.compute_list_dispatch(compute_list, x_groups, y_groups, z_groups)
				rd.compute_list_end()
#endregion

GLSL Shader
#[compute]
#version 460

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
    
layout(rgba8, set = 0, binding = 0) uniform image2D colorImage;

// layout(rgba16f, set = 1, binding = 1) uniform image2D blurBuffer;
    
layout(push_constant, std430) uniform Params
{
    vec2 raster_size;
    vec2 effect_size;
} params;

void main()
{
    ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
    vec2 uv_norm = vec2(uv) / params.raster_size;
	ivec2 size = ivec2(params.raster_size);

	// Prevent reading/writing out of bounds.
	if (uv.x >= size.x || uv.y >= size.y) {
		return;
	}
    
    vec4 color = imageLoad(colorImage, uv);
    
    // Apply our changes.
	float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
	color.rgb = vec3(gray);

	// Write back to our color buffer.
	imageStore(colorImage, uv, color);
}

EDIT: The C# script in question makes use of an extension method to test for null instances.

Extension method
public static bool IsValid(this GodotObject go)
{
	return (go != null) && Godot.GodotObject.IsInstanceValid(go);
}

Your C# code has errors. Here’s the fixed one:

C# code
using Godot;
using System;

[Tool, GlobalClass]
public partial class CompositorEffectSplitScreen : CompositorEffect
{
	const EffectCallbackTypeEnum EFFECT_TYPE = EffectCallbackTypeEnum.PostTransparent;
	const int MAX_SPLIT_SCREENS = 4;
	const string SHADER_FILE_PATH = "res://shaders/glsl_shaders/zone_blur.glsl";

	RDShaderFile shaderFile = ResourceLoader.Load<RDShaderFile>(SHADER_FILE_PATH);

	const string contextName = "blur";
	const string blurTextureName = "blurTex";

	RenderingDevice rd;
	Rid shader;
	Rid pipelineCompute;
	Rid pipelineRender;

	// Mutex mutex = new Mutex();

	[Export] private bool useHalfRes = false;

	public CompositorEffectSplitScreen()
	{
		EffectCallbackType = EFFECT_TYPE;

		// Get the global rendering device
		rd = RenderingServer.GetRenderingDevice();

		// Initialize compute shader on the rendering thread
		RenderingServer.CallOnRenderThread(Callable.From(InitComputeShader));
	}

	private string DebugTime()
	{
		float ms = Time.GetTicksMsec();
		TimeSpan ts = TimeSpan.FromMilliseconds(ms);
		return ts.ToString(@"hh\:mm\:ss\.fffffff");
	}

	private bool InitComputeShader()
	{
		if (rd == null)
			return false;

		GD.Print($"{DebugTime()} - Shader file loaded: {((shaderFile != null) ? "true" : "false")}");

		// Compile shader from shader file
		var shaderSpirv = shaderFile.GetSpirV();
		shader = rd.ShaderCreateFromSpirV(shaderSpirv);
		GD.Print($"{DebugTime()} - Compiled shader is valid: {(shader.IsValid ? "true" : "false")}");
		if (shader.IsValid)
			pipelineCompute = rd.ComputePipelineCreate(shader);

		GD.Print($"{DebugTime()} - Compute pipeline is valid: {(pipelineCompute.IsValid ? "true" : "false")}");

		return true;
	}

	// private void InitRenderShader()
	// {

	//     var rasterState = new RDPipelineRasterizationState();
	//     var sampleState = new RDPipelineMultisampleState();
	//     var stencilState = new RDPipelineDepthStencilState();
	//     var colorState = new RDPipelineColorBlendState();

	//     pipelineRender = rd.RenderPipelineCreate(shader, default, default, RenderingDevice.RenderPrimitive.Triangles,
	//         rasterState, sampleState, stencilState, colorState, RenderingDevice.PipelineDynamicStateFlags.BlendConstants);
	// }

	public override void _RenderCallback(int effectCallbackType, RenderData renderData)
	{
		if (rd != null && effectCallbackType == (int)EFFECT_TYPE)
		{
			// Retrieve the scene buffer data from the render data
			var buffers = renderData.GetRenderSceneBuffers() as RenderSceneBuffersRD;
			var data = renderData.GetRenderSceneData() as RenderSceneDataRD;

			var renderSize = buffers.GetInternalSize();
			var effectSize = renderSize;

			if (useHalfRes)
				effectSize = new Vector2I(Mathf.RoundToInt(effectSize.X * 0.5f), Mathf.RoundToInt(effectSize.Y * 0.5f));

			// Don't render NOTHING
			if (effectSize.X == 0 && effectSize.Y == 0)
				return;

			// ===================== CREATE / REFRESH TEXTURES =====================
			if (buffers.HasTexture(contextName, blurTextureName))
			{
				// If the size for the texture no longer matches the target size, clear all textures within the context
				// NOTE: What is a context?
				var tf = buffers.GetTextureFormat(contextName, blurTextureName);
				if (tf.Width != effectSize.X || tf.Height != effectSize.Y)
				{
					buffers.ClearContext(contextName);
				}
			}

			if (!buffers.HasTexture(contextName, blurTextureName))
			{
				uint usageBits = (uint)RenderingDevice.TextureUsageBits.SamplingBit | (uint)RenderingDevice.TextureUsageBits.StorageBit;
				buffers.CreateTexture(contextName, blurTextureName, RenderingDevice.DataFormat.R16Unorm, usageBits, RenderingDevice.TextureSamples.Samples1, effectSize, 1, 1, true, false);
			}
			// =====================================================================

			// Compute shader settings (NOTE: u-literal implies uint type)
			uint xGroups = (uint)(effectSize.X - 1) / 8u + 1u;
			uint yGroups = (uint)(effectSize.Y - 1) / 8u + 1u;
			uint zGroups = 1u;

			// Push constant
			float[] pushConstant = new float[4];
			pushConstant[0] = renderSize.X;
			pushConstant[1] = renderSize.Y;
			pushConstant[2] = effectSize.X;
			pushConstant[3] = effectSize.Y;
			// Docs: [...] we (may) have to pad this array with a 16 byte alignment.
			// the length of our array needs to be a multiple of 4.
			int pushByteSize = pushConstant.Length * sizeof(float);
			byte[] pushBytes = new byte[pushByteSize];
			Buffer.BlockCopy(pushConstant, 0, pushBytes, 0, pushBytes.Length);
			// NOTE: A float array is created to store the floating-point numbers.
			//      However, because the compute list (further down) expects a byte array,
			//      a byte array is produced from the float array via Buffer.BlockCopy.

			// Loop through views (for cases when stereo rendering is enabled)
			// NOTE: This will never be the case for this game. It's just a formality
			var viewCount = buffers.GetViewCount();
			for (uint view = 0; view < viewCount; view++)
			{
				// Prime buffers for uniform set
				Rid colorBuffer = buffers.GetColorLayer(view);
				Rid blurBuffer = buffers.GetTextureSlice(contextName, blurTextureName, view, 0, 1, 1);

				// var samplerState = new RDSamplerState();
				// samplerState.MinFilter = RenderingDevice.SamplerFilter.Linear;
				// samplerState.MagFilter = RenderingDevice.SamplerFilter.Linear;
				// var sampler = rd.SamplerCreate(samplerState);

				// DOCS: Note the use of our UniformSetCacheRD cache which ensures we can check for our
				// uniform set each frame. As our color buffer can change from frame to frame and our uniform
				// cache will automatically clean up uniform sets when buffers are freed, this is the safe way
				// to ensure we do not leak memory or use an outdated set.
				RDUniform uniformColor = new RDUniform();
				uniformColor.UniformType = RenderingDevice.UniformType.Image;
				uniformColor.Binding = 0;
				uniformColor.AddId(colorBuffer);
				var uniformColorSet = UniformSetCacheRD.GetCache(shader, 0, [uniformColor]);

				// RDUniform uniformBlur = new RDUniform();
				// uniformBlur.UniformType = RenderingDevice.UniformType.Image;
				// uniformBlur.Binding = 1;
				// uniformBlur.AddId(blurBuffer);
				// var uniformBlurSet = UniformSetCacheRD.GetCache(shader, 1, [uniformBlur]);

				var computeList = rd.ComputeListBegin();
				rd.ComputeListBindComputePipeline(computeList, pipelineCompute);
				rd.ComputeListBindUniformSet(computeList, uniformColorSet, 0);
				// rd.ComputeListBindUniformSet(computeList, uniformBlurSet, 1);
				rd.ComputeListSetPushConstant(computeList, pushBytes, (uint)pushByteSize);
				rd.ComputeListDispatch(computeList, xGroups, yGroups, zGroups);
				rd.ComputeListEnd();
			}
		}
	}

	public override void _Notification(int what)
	{
		if (what == (int)NotificationPredelete)
		{
			if (shader.IsValid)
			{
				rd.FreeRid(shader);
			}
		}
	}
}

Sorry. Perhaps I should have disclosed that I make use of an extension method to test for null instances. The script runs and doesn’t present any errors beyond the ones initially described.

Extension Method
public static bool IsValid(this GodotObject go)
{
	return (go != null) && Godot.GodotObject.IsInstanceValid(go);
}