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()