Direct GPU texture to Blender Image data block without CPU readback?

I’m working on real-time image filtering in Blender 4.1 using custom shaders. Currently, I have to read pixels back from GPU to CPU to update the Image data block, which creates a performance bottleneck. Is there a way to directly connect a texture framebuffer to an Image data block so shader modifications appear instantly with minimal CPU involvement?

Worth pointing out that while the current shader is quite simple and could easily be done in the compositor, this is just for demonstration and testing puroses. The real shader code will be more complex and is not replicable in the Blender’s compositor thus I have to go full python.

This script creates a copy of the provided image and multiplies its G and B channels with the float value in the slider thus making it more red as the value approaches 0. The UI can be found in the image editor’s edit tab.

Thank you for your time in advance.

# ############################################################################
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ############################################################################
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
from bpy.types import Operator, Panel, PropertyGroup
from bpy.props import PointerProperty, FloatProperty, StringProperty

# Vertex shader for fullscreen quad
vertex_shader = '''
uniform mat4 ModelViewProjectionMatrix;

in vec2 pos;
in vec2 texCoord;

out vec2 texCoord_interp;

void main()
{
    gl_Position = ModelViewProjectionMatrix * vec4(pos, 0.0, 1.0);
    texCoord_interp = texCoord;
}
'''

# Fragment shader that applies G/B multiplication
fragment_shader = '''
uniform sampler2D image;
uniform float gb_factor;

in vec2 texCoord_interp;
out vec4 fragColor;

void main()
{
    vec4 color = texture(image, texCoord_interp);
    color.g *= gb_factor;
    color.b *= gb_factor;
    fragColor = color;
}
'''

class ImageGBProperties(PropertyGroup):
    target_image: PointerProperty(
        name="Image",
        type=bpy.types.Image,
        description="Select the image to modify",
        update=lambda self, context: self.on_image_change()
    )

    pp_image: PointerProperty(
        type=bpy.types.Image
    )

    gb_factor: FloatProperty(
        name="G/B Factor",
        description="Factor to multiply G and B channels by",
        default=0.5,
        min=0.0,
        max=1.0,
        subtype='FACTOR',
        update=lambda self, context: self.update_gpu_preview()
    )
    
    _shader = None
    _texture = None
    _batch = None

    def ensure_shader(self):
        """Compile the shader if not already done - always recreate to be safe"""
        try:
            print("Creating shader...")
            self._shader = gpu.types.GPUShader(vertex_shader, fragment_shader)
            print(f"Shader created: {self._shader}")
            return True
        except Exception as e:
            print(f"Shader compilation failed: {e}")
            self._shader = None
            return False

    def ensure_batch(self):
        """Create fullscreen quad batch if not already done"""
        if self._batch is None and self._shader is not None:
            # Fullscreen quad vertices and texture coordinates
            vertices = (
                Vector((-1, -1)),
                Vector((1, -1)),
                Vector((1, 1)),
                Vector((-1, 1))
            )
            
            indices = ((0, 1, 2), (0, 2, 3))
            
            texcoords = (
                Vector((0, 0)),
                Vector((1, 0)),
                Vector((1, 1)),
                Vector((0, 1))
            )
            
            self._batch = batch_for_shader(
                self._shader, 'TRIS',
                {
                    "pos": vertices,
                    "texCoord": texcoords,
                },
                indices=indices
            )
            print("Batch created")

    def on_image_change(self):
        """When image changes, create post-processing copy and set up"""
        if not self.target_image:
            return
        
        print(f"Image changed to: {self.target_image.name}")
        
        # Create post-processing copy
        if not self.pp_image or self.pp_image.name != f"{self.target_image.name}_pp":
            self.create_pp_copy()
        
        # Update texture to use the original image
        self.update_texture()
        self.update_gpu_preview()

    def create_pp_copy(self):
        """Create a post-processing copy of the image"""
        pp_name = f"{self.target_image.name}_pp"
        
        # Remove any existing pp copy
        if pp_name in bpy.data.images:
            bpy.data.images.remove(bpy.data.images[pp_name])
        
        # Create the pp copy
        self.pp_image = bpy.data.images.new(
            pp_name,
            width=self.target_image.size[0],
            height=self.target_image.size[1],
            alpha=True,
            float_buffer=self.target_image.is_float
        )
        
        # Copy pixel data from original
        if self.target_image.pixels:
            self.pp_image.pixels = self.target_image.pixels[:]
        
        print(f"Created PP copy: {self.pp_image.name}")

    def update_texture(self):
        """Update the GPU texture from the ORIGINAL image"""
        if not self.target_image or not self.target_image.pixels:
            print("No target image or pixels in update_texture")
            return
        
        print(f"Updating texture from: {self.target_image.name}")
        
        # Ensure the original image is packed or has GPU representation
        if self.target_image.packed_file is None and not self.target_image.has_data:
            self.target_image.reload()
        
        # Get or create texture from ORIGINAL image
        try:
            self._texture = gpu.texture.from_image(self.target_image)
            print(f"Texture created: {self._texture}")
        except Exception as e:
            print(f"Texture creation failed: {e}")
            self._texture = None

    def update_gpu_preview(self):
        """Update the preview using GPU rendering"""
        print("Starting update_gpu_preview")
        
        # Always recreate the shader to ensure it's valid
        if not self.ensure_shader():
            print("Failed to create shader")
            return
            
        # Ensure texture is available - ADD THIS LINE
        self.update_texture()
            
        if not self.target_image:
            print("No target image")
            return
            
        if not self._texture:
            print("No texture")
            return
            
        if not self.pp_image:
            print("No PP image")
            return
        
        print("All prerequisites met, proceeding with GPU preview")
        
        # Set up offscreen rendering
        width, height = self.target_image.size
        print(f"Creating offscreen buffer: {width}x{height}")
        offscreen = gpu.types.GPUOffScreen(width, height)
        
        with offscreen.bind():
            print("Offscreen buffer bound")
            # Clear and set up viewport
            gpu.state.depth_test_set('NONE')
            gpu.state.blend_set('NONE')
            
            # Bind shader and set uniforms
            print(f"Binding shader: {self._shader}")
            self._shader.bind()
            self._shader.uniform_sampler("image", self._texture)
            self._shader.uniform_float("gb_factor", self.gb_factor)
            
            # Set up model-view-projection matrix for fullscreen quad
            from gpu.matrix import load_projection_matrix, load_identity
            load_identity()
            
            # Use identity matrix for orthographic projection
            identity_matrix = Matrix.Identity(4)
            load_projection_matrix(identity_matrix)
            
            # Ensure batch exists
            self.ensure_batch()
            
            # Render
            if self._batch:
                self._batch.draw(self._shader)
                print("Batch drawn")
            else:
                print("No batch to draw")
            
            # Read back pixels and update the PP copy
            buffer = gpu.state.active_framebuffer_get()
            pixels = buffer.read_color(0, 0, width, height, 4, 0, 'FLOAT').to_list()
            flat_pixels = [component for row in pixels for pixel in row for component in pixel]
            
            self.pp_image.pixels = flat_pixels
            self.pp_image.update()
            print("PP image updated with new pixels")
            
            # Switch image editor to show the PP copy
            for area in bpy.context.screen.areas:
                if area.type == 'IMAGE_EDITOR' and area.spaces.active.image == self.target_image:
                    area.spaces.active.image = self.pp_image
                    print(f"Switched image editor to show {self.pp_image.name}")
        
        offscreen.free()
        print("Offscreen buffer freed")
        
        # Force UI refresh
        for area in bpy.context.screen.areas:
            if area.type == 'IMAGE_EDITOR':
                area.tag_redraw()

    def restore_original(self):
        """Restore the original image"""
        if not self.target_image or not self.pp_image:
            return
        
        # Copy original back to pp copy
        if self.pp_image.size[0] != self.target_image.size[0] or self.pp_image.size[1] != self.target_image.size[1]:
            self.pp_image.scale(self.target_image.size[0], self.target_image.size[1])
        
        self.pp_image.pixels = self.target_image.pixels[:]
        self.pp_image.update()
        self.gb_factor = 1.0
        
        # Switch image editor back to original if needed
        for area in bpy.context.screen.areas:
            if area.type == 'IMAGE_EDITOR' and area.spaces.active.image == self.pp_image:
                area.spaces.active.image = self.target_image

class IMAGE_OT_restore_original(Operator):
    bl_idname = "image.restore_original"
    bl_label = "Restore Original"
    bl_description = "Restore the original image and reset the factor"
    
    def execute(self, context):
        props = context.scene.image_gb_props
        props.restore_original()
        self.report({'INFO'}, "Original image restored")
        return {'FINISHED'}

class IMAGE_PT_gb_multiplier(Panel):
    bl_label = "GPU G/B Multiplier"
    bl_idname = "IMAGE_PT_gb_multiplier"
    bl_space_type = 'IMAGE_EDITOR'
    bl_region_type = 'UI'
    bl_category = "Edit"
    
    def draw(self, context):
        layout = self.layout
        props = context.scene.image_gb_props
        
        layout.prop(props, "target_image")
        
        if props.target_image:
            layout.prop(props, "gb_factor")
            row = layout.row()
            row.operator("image.restore_original")
            
            if props.pp_image:
                layout.label(text=f"Original: {props.target_image.name}", icon='IMAGE_DATA')
                layout.label(text=f"Processing: {props.pp_image.name}", icon='IMAGE_DATA')
            layout.label(text="GPU Accelerated - Real-time", icon='PLAY')
        else:
            layout.label(text="Select an image to begin", icon='INFO')

def register():
    bpy.utils.register_class(ImageGBProperties)
    bpy.utils.register_class(IMAGE_OT_restore_original)
    bpy.utils.register_class(IMAGE_PT_gb_multiplier)
    bpy.types.Scene.image_gb_props = PointerProperty(type=ImageGBProperties)

def unregister():
    bpy.utils.unregister_class(ImageGBProperties)
    bpy.utils.unregister_class(IMAGE_OT_restore_original)
    bpy.utils.unregister_class(IMAGE_PT_gb_multiplier)
    del bpy.types.Scene.image_gb_props

if __name__ == "__main__":
    register()

This part is not very efficient…

            # Read back pixels and update the PP copy
            buffer = gpu.state.active_framebuffer_get()
            pixels = buffer.read_color(0, 0, width, height, 4, 0, 'FLOAT').to_list()
            flat_pixels = [component for row in pixels for pixel in row for component in pixel]
            
            self.pp_image.pixels = flat_pixels

You should use this instead:

import numpy as np
#.......
            buffer = np.array( offscreen.texture_color.read( ), dtype = 'float32' ).flatten( order = 'F' )
            self.pp_image.pixels.foreach_set( buffer )

I actually learned a trick that can make this part a bit faster. np.array makes a full copy of the buffer but you can construct a view if you do it like this:

data = offscreen.texture_color.read( )
buffer = gpu.types.Buffer('FLOAT', np.prod(data.dimensions), data=data)

Weirdly enough, numpy conplains that the result of GPUTexture.read() is not C-contiguous and insists on creating a copy but gpu.types.Buffer() coudn’t care less, it just makes a flat view of the array.

@Secrop Thank you for your input! I tried it and while it does speed things up, it behaves really strangely. It essentially clamps all the values that are higher than 0 to 1, while 0s remain at 0.

@Jeacom I don’t know what I’m doing wrong, but for some reason when I use np.prod(data.dimensions) I’m getting an error saying “BufferError: array size does not match”. But when I replace np.prod(data.dimensions) with height * width * 4 it actually executes the buffer creation but fails at the following line which is “self.pp_image.pixels.foreach_set( buffer )”. And I find all that strange to say the least, since I’ve printed out height * width * 4 and np.prod(data.dimensions) and they literally show the same number… also, I had to replace data=data with just data since Buffers does not accept keyword arguments.

I’m honestly at a loss here what could be the problem here with both approaches. Perhaps it’s a local bug?

EDIT:
Turns out all that needs to be done is to explicitly state that the offscreen buffer is of float32 type by changing from gpu.types.GPUOffScreen(width, height) to gpu.types.GPUOffScreen(width=width , height=height , format=‘RGBA32F’ ), since it’s of ‘RGBA8’ type by default.

Now it’s running flawlessly and much faster. Thank you, @Secrop and @Jeacom once more, I couldn’t have done it without you.

Here’s the whole method with aforementioned changes:

    def update_gpu_preview(self):
        """Update the preview using GPU rendering"""
        print("Starting update_gpu_preview")
        
        # Always recreate the shader to ensure it's valid
        if not self.ensure_shader():
            print("Failed to create shader")
            return
            
        # Ensure texture is available - ADD THIS LINE
        self.update_texture()
            
        if not self.target_image:
            print("No target image")
            return
            
        if not self._texture:
            print("No texture")
            return
            
        if not self.pp_image:
            print("No PP image")
            return
        
        print("All prerequisites met, proceeding with GPU preview")
        
        # Set up offscreen rendering
        width, height = self.target_image.size
        print(f"Creating offscreen buffer: {width}x{height}")
        offscreen = gpu.types.GPUOffScreen(width=width, height=height, format='RGBA32F')
        
        with offscreen.bind():
            print("Offscreen buffer bound")
            # Clear and set up viewport
            gpu.state.depth_test_set('NONE')
            gpu.state.blend_set('NONE')
            
            # Bind shader and set uniforms
            print(f"Binding shader: {self._shader}")
            self._shader.bind()
            self._shader.uniform_sampler("image", self._texture)
            self._shader.uniform_float("gb_factor", self.gb_factor)
            
            # Set up model-view-projection matrix for fullscreen quad
            from gpu.matrix import load_projection_matrix, load_identity
            load_identity()
            
            # Use identity matrix for orthographic projection
            identity_matrix = Matrix.Identity(4)
            load_projection_matrix(identity_matrix)
            
            # Ensure batch exists
            self.ensure_batch()
            
            # Render
            if self._batch:
                self._batch.draw(self._shader)
                print("Batch drawn")
            else:
                print("No batch to draw")
            
            data = offscreen.texture_color.read()
            buffer_size = width * height * 4#np.prod(data.dimensions)
            buffer = gpu.types.Buffer('FLOAT', buffer_size, data)
            self.pp_image.pixels.foreach_set(buffer)
            self.pp_image.update()
            
            print("PP image updated with new pixels")
            
            # Switch image editor to show the PP copy
            for area in bpy.context.screen.areas:
                if area.type == 'IMAGE_EDITOR' and area.spaces.active.image == self.target_image:
                    area.spaces.active.image = self.pp_image
                    print(f"Switched image editor to show {self.pp_image.name}")
        
        offscreen.free()
        print("Offscreen buffer freed")
        
        # Force UI refresh
        for area in bpy.context.screen.areas:
            if area.type == 'IMAGE_EDITOR':
                area.tag_redraw()

Whoops, looks like I made a few mistakes. In my defense, I was typing on phone and didn’t remember all the details.

I forgot to add int(np.prod(…)), the int() part was necessary because np.prod returns a value in its own np.int64() type instead of a regular python int, and it seems blender’s code was not written to recognize it.

so the code would look like this

buffer = gpu.types.Buffer('FLOAT', int(np.prod(data.dimensions)), data)

No worries, I’m actually quite impressed by anyone’s ability to remember syntax at all especially when that person is being occupied by other stuff non-related to topic at hand. I personally gave up years ago on memorising syntax, there’s just so much to remember, it’s copy-paste life for me from now on!

Nevertheless, I’ve implemented the changes, and it works great, thanks.