How to keep drawings on viewport from one frame to the next?

Godot Version

4.2.2

Question

Hello everybody.

I’m currently struggling with my understanding of Viewports and their interactions with other nodes, especially CanvasItems/Control ones.

I’m working on a simple “Paint”-like tool where you can draw in a given area of the app by clicking and dragging with the mouse button kept pressed. To achieve this I’ve started from the code from the godot demo projects “gd paint” and changed things to try to improve the functionality. For the record I’m using Godot 4.2.2 on Windows 10.

I’ve managed to get everything working however I’ve encountered performance problems when redrawing every single point on each _draw call, so I figured I could use the clear mode of Viewports to only draw new points and only periodically clear the area when undoing a stroke or erasing the whole drawing. I haven’t been able to get this to work however, and I believe this might be because of how my scene is setup, but I cannot figure out how to achieve this behaviour.

Here is a picture of my setup:

I have an UI based on simple Control nodes and I have created a SubViewportContainer with a SubViewport to host the Panel that I use to draw and determine the area where drawing is allowed. Even with the SubViewport clear mode set to Never my code only ever displays the last point drawn. After further testing, where I could verify that setting the Viewport clear mode did have an effect (background color staying black in clear mode NEVER when the viewport is larger than my panel), I’m guessing that this is because the Panel is drawing “on top” of itself, and therefore on top of the Viewport in a manner of speaking, and it doesn’t care about the clear mode of the Viewport.

But if that is so, I guess I’m going about this completely wrong? There doesn’t seem to be a similar property for controlling the clearing of Canvas Items drawing. I’ve tried using a simple Node2D child of my Panel to do the drawing but I get the same results. Should I not use the custom drawing methods for this kind of use case? If so, what should I do instead?

Do you have any clue about this?

Thank you for your help.

1 Like

Could you point out the code in GD paint for the draw function?

So in my mind a view port will have a setting to not clear it’s buffer. So if there is anything to render it will render it and never clear it.

So to simulate a brush you would have a node that has some sort of visual aspect (a CanvasItem) like a sprite in the shape of a circle. When the brush is not active it will be hidden, but when the brush is applied the shape will move to the mouse location, and become visible. As long as the mouse button is held, it will follow the mouse around making a mark as it’s rendering across the viewport that is not clearing its buffer.

There wouldn’t be a specific low level command to draw.

Then, in order to make an undo/redo you would need to also save the screen texture of the viewport before the brush is applied, and if the user undos you will apply the previous texture to the viewport. To have multiple undoes you just save a finite array of viewport textures. You would then not need to re-apply each changes to the viewport, as it seems in your description.

1 Like

Thank you for your answer @pennyloafers , I will try out what you propose and report back my results. I feel like this might make changing the color and brush size more difficult but I don’t know enough about Sprites and textures yet so I will see how it goes!

In the meantime I would also like to know whether my previous approach could never achieve what I’m trying to do. Here is what the GD Paint demo project is doing for the drawing (with brush_data_list being an array containing all of the strokes (positions, brush size, etc.) stored in _process when the left mouse button is pressed):

func _draw():
	# Go through all of the brushes in brush_data_list.
	for brush in brush_data_list:
		match brush.brush_type:
			BrushModes.PENCIL:
				# If the brush shape is a rectangle, then we need to make a Rect2 so we can use draw_rect.
				# Draw_rect draws a rectagle at the top left corner, using the scale for the size.
				# So we offset the position by half of the brush size so the rectangle's center is at mouse position.
				if brush.brush_shape == BrushShapes.RECTANGLE:
					var rect = Rect2(brush.brush_pos - Vector2(brush.brush_size / 2, brush.brush_size / 2), Vector2(brush.brush_size, brush.brush_size))
					draw_rect(rect, brush.brush_color)
				# If the brush shape is a circle, then we draw a circle at the mouse position,
				# making the radius half of brush size (so the circle is brush size pixels in diameter).
				elif brush.brush_shape == BrushShapes.CIRCLE:
					draw_circle(brush.brush_pos, brush.brush_size / 2, brush.brush_color)
			BrushModes.ERASER:
				# NOTE: this is a really cheap way of erasing that isn't really erasing!
				# However, this gives similar results in a fairy simple way!

				# Erasing works exactly the same was as pencil does for both the rectangle shape and the circle shape,
				# but instead of using brush.brush_color, we instead use bg_color instead.
				if brush.brush_shape == BrushShapes.RECTANGLE:
					var rect = Rect2(brush.brush_pos - Vector2(brush.brush_size / 2, brush.brush_size / 2), Vector2(brush.brush_size, brush.brush_size))
					draw_rect(rect, bg_color)
				elif brush.brush_shape == BrushShapes.CIRCLE:
					draw_circle(brush.brush_pos, brush.brush_size / 2, bg_color)
			BrushModes.RECTANGLE_SHAPE:
				# We make a Rect2 with the postion at the top left. To get the size we take the bottom right position
				# and subtract the top left corner's position.
				var rect = Rect2(brush.brush_pos, brush.brush_shape_rect_pos_BR - brush.brush_pos)
				draw_rect(rect, brush.brush_color)
			BrushModes.CIRCLE_SHAPE:
				# We simply draw a circle using stored in brush.
				draw_circle(brush.brush_pos, brush.brush_shape_circle_radius, brush.brush_color)

The GD Paint demo is classified as a gui demo project so it makes sense that it would use _draw(). I followed that lead and my code is very similar although I also use draw_line and draw_polygon methods. Is that approach incorrect for a real project that wouldn’t want to redraw everything everytime? Are those custom drawing methods of CanvasItems never affected by the viewport clear mode? If so, is it because they do not actually draw to the ViewportTexture?

I created a multiplayer whiteboard in Godot 3.x (I have a few YouTube videos showing it in action in VR, such as https://www.youtube.com/watch?v=s9dnvd6VxMQ), so I have some experience with this. Here are a few tips I have:

  1. Do NOT redraw the contents of your display surface on every frame. It won’t take long before the program becomes slow and unresponsive. Setting your viewport to never redraw, as you did, is correct.

  2. Your code is erasing the display surface somewhere (I don’t know where, but maybe you are inserting an eraser type in your data list before every other drawing type?).

  3. I accumulate user movement since the last _process() call in an array, then draw ONLY the new data in the subsequent _process() call. This array usually accumulates one or two strokes per frame, so performance remains more or less constant regardless of how much drawing occurs over the life of the drawing surface.

You don’t have to change how it creates a rendering shape, I was just using sprite as an example.

I just don’t think clearing the layer to reapply brush strokes individually can be optimized.

Now you have me confused. Should a Panel using draw methods in a _draw() call be expected to accumulate drawings on its own surface when the Viewport it is a child of is set to clear mode: NEVER?

Because here is my complete code, which only ever results in the last point being drawn, everything else being cleared on each frame:

extends Panel

class Stroke:
	var brush_color : Color
	var brush_size : int
	var stroke_points := []
	
	func _init(point, color: Color, size: int):
		self.stroke_points = []
		self.stroke_points.append(point)
		self.brush_color = color
		self.brush_size = size

signal added_to_redo()
signal emptied_redo()

var viewport : SubViewport

var brush_color := Color.BLACK
var brush_size := 32

enum DRAW_ACTION {ADD, UNDO, CLEAR}
var draw_act : DRAW_ACTION = DRAW_ACTION.ADD
var strokes : Array[Stroke] = []
var redo_strokes : Array[Stroke] = []
var last_drawn_index : int = 0

var ongoing_stroke : bool = false
var left_mouse_pressed := false
var mouse_inside_rect := false
var mouse_started_inside := false

func _ready():
	viewport = get_viewport()
	viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_NEVER

func _process(_delta) -> void:
	if !left_mouse_pressed:
		return
	if !ongoing_stroke:
		return
	if !mouse_inside_rect:
		return
	if strokes.is_empty():
		return
	
	var mouse_pos = get_global_transform().affine_inverse() * viewport.get_mouse_position()
	if get_rect().has_point(mouse_pos):
		strokes[-1].stroke_points.append(mouse_pos)
		queue_redraw()

func _gui_input(event) -> void:
	if !(event is InputEventMouseButton) or (event is InputEventMouseMotion):
		return
	if !(event.button_index == MOUSE_BUTTON_LEFT):
		return
	if event.pressed:
		left_mouse_pressed = true
		var mouse_pos = get_global_transform().affine_inverse() * viewport.get_mouse_position()
		if get_rect().has_point(mouse_pos):
			mouse_started_inside = true
			ongoing_stroke = true
			strokes.append(Stroke.new(mouse_pos, brush_color, brush_size))
			queue_redraw()
	elif not event.pressed:
		left_mouse_pressed = false
		ongoing_stroke = false
		mouse_started_inside = false
		last_drawn_index = 0

func _on_redo_button_pressed() -> void:
	if redo_strokes.is_empty() or ongoing_stroke:
		return
	strokes.append(redo_strokes.pop_back())
	if redo_strokes.is_empty():
		emptied_redo.emit()
	queue_redraw()

func _on_undo_button_pressed() -> void:
	if strokes.is_empty() or ongoing_stroke:
		return
	redo_strokes.append(strokes.pop_back())
	added_to_redo.emit()
	draw_act = DRAW_ACTION.UNDO
    #Here I would set the clear mode to once but have removed it for now for the sake of simplicity (it did not work when it was there)
	queue_redraw()

func _on_clear_button_pressed() -> void:
	strokes.clear()
	redo_strokes.clear()
	emptied_redo.emit()
	last_drawn_index = 0
	draw_act = DRAW_ACTION.CLEAR
	queue_redraw()

func _draw() -> void:
	if strokes.is_empty():
		return
	
	match draw_act:
		DRAW_ACTION.ADD:
			var current_stroke := strokes[-1]
			var current_last_index := current_stroke.stroke_points.size() - 1
			for index in range(last_drawn_index, current_last_index):
				var point = current_stroke.stroke_points[index]
				draw_colored_polygon(generate_circle_polygon(current_stroke.brush_size / 2.0, 72, point), current_stroke.brush_color)
				if index > 0:
					if current_stroke.stroke_points[index - 1].distance_to(point) >= current_stroke.brush_size / 4.0:
						draw_line(current_stroke.stroke_points[index - 1], point, current_stroke.brush_color, current_stroke.brush_size, true)
			last_drawn_index = current_last_index
		DRAW_ACTION.UNDO:
			for stroke in strokes:
				if stroke.stroke_points.size() >= 1:
					draw_colored_polygon(generate_circle_polygon(stroke.brush_size / 2.0, 72, stroke.stroke_points[0]), stroke.brush_color)
					if stroke.stroke_points.size() == 1:
						continue
					var previous_pos := stroke.stroke_points[0] as Vector2
					for point in stroke.stroke_points:
						draw_colored_polygon(generate_circle_polygon((stroke.brush_size) / 2.0, 72, point), stroke.brush_color)
						if previous_pos.distance_to(point) >= stroke.brush_size / 4.0:
							draw_line(previous_pos, point, stroke.brush_color, stroke.brush_size, true)
						previous_pos = point
			draw_act = DRAW_ACTION.ADD
		DRAW_ACTION.CLEAR:
			#viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
			#For the sake of simplicity I have commented the above line for now, but it doesn't change anything
			draw_act = DRAW_ACTION.ADD

func generate_circle_polygon(radius: float, num_sides: int, pos: Vector2) -> PackedVector2Array:
	var angle_delta: float = (PI * 2) / num_sides
	var vector: Vector2 = Vector2(radius, 0)
	var polygon: PackedVector2Array = []

	for _i in num_sides:
		polygon.append(vector + pos)
		vector = vector.rotated(angle_delta)

	return polygon

func save_picture(path):
	# Wait until the frame has finished before getting the texture.
	await RenderingServer.frame_post_draw

	# Get the viewport image.
	var img = viewport.get_texture().get_image()
	# Crop the image so we only have canvas area.
	var cropped_image = img.get_region(get_global_rect())

	# Save the image with the passed in path we got from the save dialog.
	cropped_image.save_png(path)

func _on_color_picker_button_color_changed(color) -> void:
	brush_color = color


func _on_h_scroll_brush_size_value_changed(value) -> void:
	brush_size = value

func _on_mouse_entered():
	mouse_inside_rect = true
	if (left_mouse_pressed and mouse_started_inside):
		ongoing_stroke = true
		var mouse_pos = get_global_transform().affine_inverse() * viewport.get_mouse_position()
		strokes.append(Stroke.new(mouse_pos, brush_color, brush_size))
		queue_redraw()

func _on_mouse_exited():
	mouse_inside_rect = false
	ongoing_stroke = false
	last_drawn_index = 0

The clear mode does seem to work because if I make my viewport larger than my panel, it won’t get the background color. In fact, I also tried to change the clear mode of the main Viewport to never in the GD Paint project and it does not change anything either, it gets cleared.

So, if anyone is ever interested, I finally figured it out!

As it turns out, if you try to use a Control node that is intended to display stuff such as any kind of UI element or a simple Panel or ColorRect, you can use it to draw but when the drawing is on top of itself it will always clear itself and reapply itself, regardless of its Viewport clear mode. Here is an example of this behaviour in a minimal setup (drawing a circle from a call to redraw (with the mouse position as center of the circle) in _process):

No matter what you do, any child or other node that has a higher z-index as such a Control node and tries to draw on top of it will be subject to this behaviour. However, you can have a setup similar to this one:

newSetup

And then you can hide the drawing area bg after it has been drawn once and since the Viewport is never clearing itself it will still appear as a background on top of which you can draw. This is still not great though, as clearing your drawing or undoing it will require re-showing the background node and it will get very messy as you end up having to delay your next draw calls to after the node has drawn itself and re-hidden itself, etc.

So in the end, my best solution was to simply draw my background color myself, as a draw_rect for example, and then I can use the viewport clear mode properly. I give my code as a reference for future interested readers, although I’m sure it’s not particularly clean nor efficient:

Code
extends Control

#This is just an enum to know what to do next, it's all managed in another script on my subviewportContainer
var draw_act : PaintManager.DRAW_ACTION = PaintManager.DRAW_ACTION.CLEAR

var strokes : Array[PaintManager.Stroke] = []
var redo_strokes : Array[PaintManager.Stroke] = []
var last_drawn_index : int = 0

func _draw() -> void:
	match draw_act:
		PaintManager.DRAW_ACTION.ADD:
			if strokes.is_empty():
				return
			var current_stroke = strokes[-1]
			var current_last_index = current_stroke.stroke_points.size() - 1
			for index in range(last_drawn_index, current_last_index):
				var point = current_stroke.stroke_points[index]
				draw_colored_polygon(generate_circle_polygon(current_stroke.brush_size / 2.0, 72, point), current_stroke.brush_color)
				if index > 0:
					if current_stroke.stroke_points[index - 1].distance_to(point) >= current_stroke.brush_size / 4.0:
						draw_line(current_stroke.stroke_points[index - 1], point, current_stroke.brush_color, current_stroke.brush_size, true)
			last_drawn_index = current_last_index
		PaintManager.DRAW_ACTION.UNDO:
			draw_rect(get_viewport_rect(),Color("ecb283"))
			if strokes.is_empty():
				return
			for stroke in strokes:
				if stroke.stroke_points.size() >= 1:
					draw_colored_polygon(generate_circle_polygon(stroke.brush_size / 2.0, 72, stroke.stroke_points[0]), stroke.brush_color)
					if stroke.stroke_points.size() == 1:
						continue
					var previous_pos := stroke.stroke_points[0] as Vector2
					for point in stroke.stroke_points:
						draw_colored_polygon(generate_circle_polygon((stroke.brush_size) / 2.0, 72, point), stroke.brush_color)
						if previous_pos.distance_to(point) >= stroke.brush_size / 4.0:
							draw_line(previous_pos, point, stroke.brush_color, stroke.brush_size, true)
						previous_pos = point
			draw_act = PaintManager.DRAW_ACTION.ADD
		PaintManager.DRAW_ACTION.CLEAR:
			draw_rect(get_viewport_rect(),Color("ecb283"))
			draw_act = PaintManager.DRAW_ACTION.ADD

And the accompanying PaintManager on my SubViewportContainer:

extends SubViewportContainer
class_name PaintManager

class Stroke:
	var brush_color : Color
	var brush_size : int
	var stroke_points := []
	
	func _init(point, color: Color, size: int):
		self.stroke_points = []
		self.stroke_points.append(point)
		self.brush_color = color
		self.brush_size = size

signal added_to_redo()
signal emptied_redo()

@onready var sub_viewport = $SubViewport
@onready var painter = $SubViewport/Painter

var brush_color := Color.BLACK
var brush_size := 32

enum DRAW_ACTION {ADD, UNDO, CLEAR}

var ongoing_stroke : bool = false
var left_mouse_pressed := false
var mouse_inside_rect := false
var mouse_started_inside := false

func _ready():
	sub_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE

func _process(_delta) -> void:
	if !left_mouse_pressed:
		return
	if !ongoing_stroke:
		return
	if !mouse_inside_rect:
		return
	if painter.strokes.is_empty():
		return
	if painter.strokes[-1].stroke_points.is_empty():
		return
	
	var mouse_pos = sub_viewport.get_mouse_position()
	# Check if the mouse is currently inside the canvas/drawing-area.
	if sub_viewport.get_visible_rect().has_point(mouse_pos):
		painter.strokes[-1].stroke_points.append(mouse_pos)
		painter.queue_redraw()

func _gui_input(event) -> void:
	if !(event is InputEventMouseButton) or (event is InputEventMouseMotion):
		return
	if !(event.button_index == MOUSE_BUTTON_LEFT):
		return
	if event.pressed:
		left_mouse_pressed = true
		#var mouse_pos = get_global_transform().affine_inverse() * viewport.get_mouse_position()
		var mouse_pos = sub_viewport.get_mouse_position()
		# Check if the mouse is currently inside the canvas/drawing-area.
		if sub_viewport.get_visible_rect().has_point(mouse_pos):
			mouse_started_inside = true
			ongoing_stroke = true
			painter.redo_strokes.clear()
			emptied_redo.emit()
			painter.strokes.append(Stroke.new(mouse_pos, brush_color, brush_size))
			painter.queue_redraw()
	elif not event.pressed:
		left_mouse_pressed = false
		ongoing_stroke = false
		mouse_started_inside = false
		painter.last_drawn_index = 0

func _on_redo_button_pressed() -> void:
	if painter.redo_strokes.is_empty() or ongoing_stroke:
		return
	painter.strokes.append(painter.redo_strokes.pop_back())
	painter.last_drawn_index = 0
	if painter.redo_strokes.is_empty():
		emptied_redo.emit()
	painter.queue_redraw()

func _on_undo_button_pressed() -> void:
	if painter.strokes.is_empty() or ongoing_stroke:
		return
	painter.redo_strokes.append(painter.strokes.pop_back())
	added_to_redo.emit()
	painter.draw_act = DRAW_ACTION.UNDO
	#viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
	RenderingServer.viewport_set_clear_mode(sub_viewport.get_viewport_rid(), RenderingServer.VIEWPORT_CLEAR_ONLY_NEXT_FRAME)
	painter.queue_redraw()

func _on_clear_button_pressed() -> void:
	painter.strokes.clear()
	painter.redo_strokes.clear()
	emptied_redo.emit()
	painter.last_drawn_index = 0
	painter.draw_act = DRAW_ACTION.CLEAR
	RenderingServer.viewport_set_clear_mode(sub_viewport.get_viewport_rid(), RenderingServer.VIEWPORT_CLEAR_ONLY_NEXT_FRAME)
	painter.queue_redraw()

func save_picture(path):
	# Wait until the frame has finished before getting the texture.
	await RenderingServer.frame_post_draw

	# Get the viewport image.
	var img = sub_viewport.get_texture().get_image()
	# Crop the image so we only have canvas area.
	var cropped_image = img.get_region(sub_viewport.get_visible_rect())#Rect2(global_position, size))

	# Save the image with the passed in path we got from the save dialog.
	cropped_image.save_png(path)

func _on_color_picker_button_color_changed(color) -> void:
	brush_color = color

func _on_h_scroll_brush_size_value_changed(value) -> void:
	brush_size = value

func _on_mouse_entered():
	mouse_inside_rect = true
	if (left_mouse_pressed and mouse_started_inside):
		ongoing_stroke = true
		var mouse_pos = sub_viewport.get_mouse_position()
		painter.strokes.append(Stroke.new(mouse_pos, brush_color, brush_size))
		painter.queue_redraw()

func _on_mouse_exited():
	mouse_inside_rect = false
	ongoing_stroke = false
	painter.last_drawn_index = 0

cool, well I was talking about something different on a more theoretical concept as i don’t know anything about the base project. I also wouldn’t mix ui with the drawing subviewport.