Paint in 3D in Godot 4.1

Working in my passion project temporarily named VCG I had to implement painting and it was surprisingly hard to figure out how to get working because most information out there is for Godot 3 and some functions and variables related to this have changed so all that information is deprecated now. Anyway, finally got it working after banging my head on it for a few days, here you have it.

To do this we need to get the UV position that matches the 3D world position we’re clicking, blend the brush texture onto the mesh’s material texture, and then update the texture and material.

Starting with getting these coordinates I used Alfred Baudisch’s example, which helped a lot! Had to update a few lines from the changes that happened between Godot 3 and 4, so the updated version of his script would be: pastebin.

Now that we can get the coordinates using get_uv_coords(point, normal, transform = true) we can paint with our brush using blend_rect().

extends Node

var img : Image
var tex : ImageTexture
var mesh : GeometryInstance3D = null

var rect : Rect2i
var brush : Image = Image.new()
var brushSize
var pixels = 256

func _create_image():
	# your image and your brush need to have the same format, in my case it's RGBA8
	img = Image.create(pixels, pixels, false, Image.FORMAT_RGBA8)
	img.fill(Color(0.82, 0.6, 0.451)) # this is the color I want my background to be
	
	brushSize = brush.get_height() / 2
	rect = Rect2i(Vector2i(brushSize,brushSize), Vector2i(brushSize,brushSize))
	
	tex = ImageTexture.create_from_image(img)
	mesh.material_override.set("shader_parameter/Mask_texture", tex)

func _get_mesh():
	for child in get_children():
		if child is GeometryInstance3D:
			mesh = child

func _ready():
	brush = brush.load("res://Art/Textures/T_TomatoSauce_brushes.png")
	_get_mesh()
	_create_image()

func random_brush_splat(): # choose a random sprite from my 2x2 atlas texture
	var index : Vector2i = Vector2i(0,0)
	if randi() % 2:
		index = Vector2i(brushSize, 0)
	if randi() % 2:
		index = Vector2i(index.x, brushSize)
	rect = Rect2i(Vector2i(index.x, index.y), Vector2i(brushSize,brushSize))

func draw_on_sprite(coords):
	coords = (coords * pixels) - Vector2(brushSize, brushSize)/2
	random_brush_splat()
	img.blend_rect (brush, rect, Vector2i(coords.x,coords.y))
	tex.update(img)
	mesh.material_override.set("shader_parameter/Mask_texture", tex)


For some reason you have to set the material texture every time you update it, both the texture file and the material itself. This was driving me insane because I knew the texture was updating if I checked the pixel but it wasn’t changing at all in my viewport.

And in this case I’ll do you one better: flipbook brush using the function random_brush_splat(). So there’s variation on the texture and it doesn’t look like you’re dragging your Windows 98 error window.

So now we call that draw_on_sprite(coords) function from my mouse event function that comes from an Area3D node that I have on my mesh I want to paint.

func _on_area_3d_input_event(camera, event, position, normal, _shape_idx):
	if TouchInput.checkClick(event).y and coords != position:  # dragging
		# drawing tomato sauce
		if coords.distance_squared_to(position) > 0.04: # give some space between splats
			coords = position
			if (PlayerVariables.ingredientHeld != null):
				if PlayerVariables.ingredientHeld is Tomato:
					$pizza_dough_flat/Area3D.set_mesh ($pizza_dough_flat/Pizza_dough)
					var uv_coords = $pizza_dough_flat/Area3D.get_uv_coords(position, normal)
					if (uv_coords != null):
						$pizza_dough_flat.draw_on_sprite(uv_coords )

For those curious, this is my TouchInput.checkClick(event) function:

class_name Touch_input extends Node

var is_dragging : bool = false
var is_click : bool = false

func checkClick(event):
	is_click = false
	is_dragging = false
	if (InputEventScreenTouch and !event.is_pressed()) and (Input.is_action_pressed("click")):  # dragging
		is_dragging = true
	elif (event is InputEventMouseButton): # tap - avoids auto double-tap 
		is_click = true
	return Vector2(is_click, is_dragging)

It’s in an autoloaded script, they work in a similar way that static classes do in Unity.

And there you have it folks.

Leave a Reply