How to "lighten/max blend mode" one image object onto another?

Godot Version

4.7.2

Context

Hello, I’m trying to add and remove data from an image like a digital brush would. The intention is to use the output image data as the base for my smoothly interpolated marching squares algorithm. I have already managed to hook them up, and I am very pleased with the result:

For the interested, this is the script (beware that it uses data from an image instead of a dictionary)
extends Node2D

# Value on grid
@onready var image_manipulation = $Image_Manipulation
var cell_state: int # Marching squares "state"
const threshold: float = 0.4 # Threshold for point activation

# Resolution of the grid
@onready var res = $Image_Manipulation.res
@onready var rows = $Image_Manipulation.rows
@onready var cols = $Image_Manipulation.cols
@onready var screen_size = $Image_Manipulation.screen_size

# Circle for visualization
var circle_color: Color = Color.WHITE
const circle_size: float = 0.1

# Lines for visualization
const line_width: float = -1
const line_color: Color = Color.WHITE

#---------Built-in functions-------#
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	pass


# Called every frame. 'delta' is the elapsed time since the previous frame.
@warning_ignore("unused_parameter")
func _process(delta: float) -> void:
	# Mouse location
	var mouse_location: Vector2 = Vector2(round(get_local_mouse_position() / res))
	if Input.is_action_pressed("mouse_left"): # Clicking left-mouse button
		image_manipulation.add_paint(mouse_location)
		queue_redraw()
	if Input.is_action_pressed("mouse_right"): # Clicking right-mouse button
		image_manipulation.remove_paint(mouse_location)
		queue_redraw()


func _draw() -> void:
	# draw_circles() # Draw circles to visualize value data
	draw_lines() # Draw stuff with the value data

#---------Custom functions-------#
func value_get(key):
	var key_int: Vector2i = Vector2i(key)
	key_int = key_int.clamp(Vector2(0,0), Vector2(rows, cols) - Vector2(1.0, 1.0))
	return image_manipulation.field.get_pixelv(
			Vector2i(key_int.x, key_int.y)
	).get_luminance() * threshold + 0.1


func draw_circles():
	for c_num in range(cols):
		for r_num in range(rows):
			var dict_key = Vector2(r_num, c_num) # Positional key.
			# Circle position, size and color
			circle_color.v = value_get(dict_key) * 5 # Block color
			draw_circle(
					dict_key * res,
					circle_size * res,
					circle_color,
					true,
					-1,
					false,
			)


func draw_lines(): # Lines master function
	for c_num in range(cols-1):
		for r_num in range(rows-1):
			# Defining corner positions for line draw.
			# r_num and c_num are x, and y, and they start in the top-left.
			var p1 := Vector2(r_num, c_num) # Top-left
			var p2 := Vector2(r_num + 1, c_num) # Top-right
			var p3 := Vector2(r_num + 1, c_num + 1) # Bottom-right
			var p4 := Vector2(r_num, c_num +1) # Bottom_left
			
			# Get an output ranging from 0 to 15 (marching squares states)
			cell_state = get_cell_state(p1, p2, p3, p4)
			# Links cell-states to certain lines
			if cell_state == 1:
				construct_line(p4, p3, p4, p1)
			elif cell_state == 2:
				construct_line(p3, p4, p3, p2)
			elif cell_state == 3:
				construct_line(p3, p2, p4, p1)
			elif cell_state == 4:
				construct_line(p2, p1, p2, p3)
			elif cell_state == 5:
				construct_line(p2, p1, p4, p1)
				construct_line(p4, p3, p2, p3)
			elif cell_state == 6:
				construct_line(p2, p1, p3, p4)
			elif cell_state == 7:
				construct_line(p2, p1, p4, p1)
			elif cell_state == 8:
				construct_line(p1, p2, p1, p4)
			elif cell_state == 9:
				construct_line(p1, p2, p4, p3)
			elif cell_state == 10:
				construct_line(p1, p2, p3, p2)
				construct_line(p3, p4, p1, p4)
			elif cell_state == 11:
				construct_line(p1, p2, p3, p2)
			elif cell_state == 12:
				construct_line(p1, p4, p2, p3)
			elif cell_state == 13:
				construct_line(p4, p3, p2, p3)
			elif cell_state == 14:
				construct_line(p3, p4, p1, p4)
			elif cell_state == 15:
				pass


# Returns number from binary, e.g. 0100 = 4 and 0010 = 2
func get_cell_state(p1, p2, p3, p4):
	return int(
			t_check(value_get(p1)) * 8
			+ t_check(value_get(p2)) * 4
			+ t_check(value_get(p3)) * 2
			+ t_check(value_get(p4)) * 1)


func t_check(field_value): # Activation threshold check
	if field_value > threshold:
		return 1
	else:
		return 0


# Interpolate end and start, then draw line between a and b.
func construct_line(start_a, end_a, start_b, end_b):
	var start: Vector2 = start_a
	start = start.lerp(
			end_a,
			lerpf(1 - threshold / value_get(start_a), 1.0, value_get(end_a) / threshold)
	)
	var end: Vector2 = start_b
	end = end.lerp(
			end_b,
			lerpf(1 - threshold / value_get(start_b), 1.0, value_get(end_b) / threshold)
	)
	draw_line(start * res, end * res, line_color, line_width)

Where the question comes in

The problem with my method blending a brush image onto the canvas seems that it is continuously making it brighter while I am holding the mouse. I am using Image.blend_rect(), which uses alpha-blending. All the blending-related methods of the Image class seem to be alpha-blending, and blit seems to be straight-up copying data without blending. So what should I do?

My idea to solve this, requires a lighten/max blend on top of the image.

Here is a small video of how I intend this to work:

Here is the current image manipulation script:

extends Node2D

# Screen
var screen_size: Vector2

# Grid-related
const res: int = 15
var cols: int
var rows: int 

# Data objects and properties
var field: Image
var white_brush: Image
var black_brush: Image
var brush_rectangle: Rect2i
var brush_size: Vector2

# A texture just here to help me debug
var debug_texture: ImageTexture

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	screen_size = get_viewport_rect().size # Screen size
	rows = int(floor(screen_size.x / res)) + 1 # Max rows amount.
	cols = int(floor(screen_size.y / res)) + 1 # Max columns amount
	
	# Create canvas
	# var field_path := "res://Assets/debug_image.png"
	# field = load(field_path)
	# field.convert(Image.FORMAT_LA8)
	
	field = Image.create_empty(rows, cols, false, Image.FORMAT_LA8)
	field.fill(Color.BLACK)
	create_brushes()


func create_brushes():
	# Create white white_brush
	var white_brush_path := "res://Assets/brush_white.png"
	# I want to edit the pixels so I assign this to an image
	white_brush = load(white_brush_path)
	# Convert to simple format
	white_brush.convert(Image.FORMAT_LA8)

	# Create black white_brush
	var black_brush_path := "res://Assets/brush_black.png"
	# I want to edit the pixels so I assign this to an image
	black_brush = load(black_brush_path)
	# Convert to simple format
	black_brush.convert(Image.FORMAT_LA8)
	
	# Defining white_brush boundary used in blending 
	brush_size = white_brush.get_size() # Used for offsetting the mouse
	brush_rectangle = Rect2i(Vector2i(0, 0), brush_size)
	# print(field.get_size())

# Called every frame. 'delta' is the elapsed time since the previous frame.
@warning_ignore("unused_parameter")
func _process(delta: float) -> void:
	pass


func add_paint(mouse_location): # Use mouse to paint values
	field.blend_rect(white_brush, brush_rectangle, mouse_location - brush_size / 2)
	# display_image()


func remove_paint(mouse_location): # Use mouse to remove values
	field.blend_rect(black_brush, brush_rectangle, mouse_location - brush_size / 2)
	# display_image()
	# print(field.get_pixelv(Vector2i(rows-1, cols-1)).get_luminance())


func display_image():
	debug_texture = ImageTexture.create_from_image(field)
	$Canvas.position = Vector2i(screen_size / 2) 
	$Canvas.scale = Vector2(1, 1) * res
	$Canvas.texture = debug_texture

Should I write a shader for this? For this question I’ve been researching shaders a bit and it seems that it is possible to save a shader output to an image (which in turn can be used as data values for my marching squares)?

By the way, this is a continuation of this question post I made before: What approach for "smooth" 2D terrain that can be dug into / subtracted from - #5 by Puzzling_Detective

If you have better ideas regarding the terrain removal method (as a drilling vehicle would), I am keen to hear them!

If images are relatively small, which seems to be the case, write your own blit/blend function.

That’s actually a neat idea, and I have looked into this, but had troubles defining it. Like how do you get an array of all the pixel coordinates of an image object in Vector2i format?

Not sure what you have problem with here. Just iterate over all pixels in the source image and copy them with a coordinate offset into the target image, skipping all pixels that would cross the boundaries of the target image. It’s just a nested loop.

Ah. I just realized I can use get_height() and get_width() to make for loops to do the iteration. My head was struggling with this one. It must be the evening energy. Thanks for the advice on making a custom function, it helped

Actually not fully correct, in order to do max/lighten I would have to compare brightness, and only copy if it is higher than the destination.

Sure, I assumed that’s understood.

If the performance becomes an issue, you can implement the same thing in a compute shader.
In any case, do the script version first, and see how it performs for your typical image sizes. It may be sufficient.

That is good to know! Seems like solid idea