# Core logic to paint a texture using shaders, with undo/redo support. # Operations are delayed so results are only available the next frame. # This doesn't implement UI, only the painting logic. # # Note: due to the absence of channel separation function in Image, # you may need to use multiple painters at once if your application exploits multiple channels. # Example: when painting a heightmap, it would be doable to output height in R, normalmap in GB, and # then separate channels in two images at the end. tool extends Node const Logger = preload("../../util/logger.gd") const Util = preload("../../util/util.gd") const UNDO_CHUNK_SIZE = 64 const BRUSH_TEXTURE_SHADER_PARAM = "u_brush_texture" # Emitted when a region of the painted texture actually changed. # Note 1: the image might not have changed yet at this point. # Note 2: the user could still be in the middle of dragging the brush. signal texture_region_changed(rect) # Godot doesn't support 32-bit float rendering, so painting is limited to 16-bit depth. # We should get this in Godot 4.0, either as Compute or renderer improvement const _hdr_formats = [ Image.FORMAT_RH, Image.FORMAT_RGH, Image.FORMAT_RGBH, Image.FORMAT_RGBAH ] const _supported_formats = [ Image.FORMAT_R8, Image.FORMAT_RG8, Image.FORMAT_RGB8, Image.FORMAT_RGBA8, Image.FORMAT_RH, Image.FORMAT_RGH, Image.FORMAT_RGBH, Image.FORMAT_RGBAH ] var _viewport : Viewport var _viewport_sprite : Sprite var _brush_size := 32 var _brush_position := Vector2() var _brush_texture : Texture var _last_brush_position := Vector2() var _brush_material := ShaderMaterial.new() var _image : Image var _texture : ImageTexture var _cmd_paint := false var _pending_paint_render := false var _modified_chunks := {} var _modified_shader_params := {} var _debug_display : TextureRect var _logger = Logger.get_for(self) func _ready(): if Util.is_in_edited_scene(self): return _viewport = Viewport.new() _viewport.size = Vector2(_brush_size, _brush_size) _viewport.render_target_update_mode = Viewport.UPDATE_ONCE _viewport.render_target_v_flip = true _viewport.render_target_clear_mode = Viewport.CLEAR_MODE_ONLY_NEXT_FRAME _viewport.hdr = false _viewport.transparent_bg = true # Apparently HDR doesn't work if this is set to 2D... so let's waste a depth buffer :/ #_viewport.usage = Viewport.USAGE_2D #_viewport.keep_3d_linear _viewport_sprite = Sprite.new() _viewport_sprite.centered = false _viewport_sprite.material = _brush_material _viewport.add_child(_viewport_sprite) add_child(_viewport) func set_debug_display(dd: TextureRect): _debug_display = dd _debug_display.texture = _viewport.get_texture() func set_image(image: Image, texture: ImageTexture): assert((image == null and texture == null) or (image != null and texture != null)) _image = image _texture = texture _viewport_sprite.texture = _texture if image != null: _viewport.hdr = image.get_format() in _hdr_formats #print("PAINTER VIEWPORT HDR: ", _viewport.hdr) func set_brush_size(new_size: int): _brush_size = new_size func get_brush_size() -> int: return _brush_size func set_brush_texture(texture: Texture): _brush_material.set_shader_param(BRUSH_TEXTURE_SHADER_PARAM, texture) func set_brush_shader(shader: Shader): if _brush_material.shader != shader: _brush_material.shader = shader func set_brush_shader_param(p: String, v): _modified_shader_params[p] = true _brush_material.set_shader_param(p, v) func clear_brush_shader_params(): for key in _modified_shader_params: _brush_material.set_shader_param(key, null) _modified_shader_params.clear() # You must call this from an `_input` function or similar. func paint_input(center_pos: Vector2): var vp_size = Vector2(_brush_size, _brush_size) if _viewport.size != vp_size: # Do this lazily so the brush slider won't lag while adjusting it # TODO An "sliding_ended" handling might produce better user experience _viewport.size = vp_size # Need to floor the position in case the brush has an odd size var brush_pos := (center_pos - Vector2(_brush_size, _brush_size) * 0.5).round() _viewport.render_target_update_mode = Viewport.UPDATE_ONCE _viewport_sprite.position = -brush_pos _brush_position = brush_pos _cmd_paint = true # Using a Color because Godot doesn't understand vec4 var rect := Color() rect.r = brush_pos.x / _texture.get_width() rect.g = brush_pos.y / _texture.get_height() rect.b = _brush_size / _texture.get_width() rect.a = _brush_size / _texture.get_height() _brush_material.set_shader_param("u_texture_rect", rect) # Don't commit until this is false func is_operation_pending() -> bool: return _pending_paint_render or _cmd_paint # Applies changes to the Image, and returns modified chunks for UndoRedo. func commit() -> Dictionary: if is_operation_pending(): _logger.error("Painter commit() was called while an operation is still pending") return _commit_modified_chunks() func has_modified_chunks() -> bool: return len(_modified_chunks) > 0 func _process(delta: float): if _pending_paint_render: _pending_paint_render = false #print("Paint result at frame ", Engine.get_frames_drawn()) var data := _viewport.get_texture().get_data() data.convert(_image.get_format()) var brush_pos = _last_brush_position var dst_x : int = clamp(brush_pos.x, 0, _texture.get_width()) var dst_y : int = clamp(brush_pos.y, 0, _texture.get_height()) var src_x : int = max(-brush_pos.x, 0) var src_y : int = max(-brush_pos.y, 0) var src_w : int = min(max(_brush_size - src_x, 0), _texture.get_width() - dst_x) var src_h : int = min(max(_brush_size - src_y, 0), _texture.get_height() - dst_y) if src_w != 0 and src_h != 0: _mark_modified_chunks(dst_x, dst_y, src_w, src_h) VisualServer.texture_set_data_partial( _texture.get_rid(), data, src_x, src_y, src_w, src_h, dst_x, dst_y, 0, 0) emit_signal("texture_region_changed", Rect2(dst_x, dst_y, src_w, src_h)) # Input is handled just before process, so we still have to wait till next frame if _cmd_paint: _pending_paint_render = true _last_brush_position = _brush_position # Consume input _cmd_paint = false func _mark_modified_chunks(bx: int, by: int, bw: int, bh: int): var cs := UNDO_CHUNK_SIZE var cmin_x := bx / cs var cmin_y := by / cs var cmax_x := (bx + bw - 1) / cs + 1 var cmax_y := (by + bh - 1) / cs + 1 for cy in range(cmin_y, cmax_y): for cx in range(cmin_x, cmax_x): _modified_chunks[Vector2(cx, cy)] = true func _commit_modified_chunks() -> Dictionary: var time_before := OS.get_ticks_msec() var cs := UNDO_CHUNK_SIZE var chunks_positions := [] var chunks_initial_data := [] var chunks_final_data := [] #_logger.debug("About to commit ", len(_modified_chunks), " chunks") # TODO get_data_partial() would be nice... var final_image := _texture.get_data() for cpos in _modified_chunks: var cx : int = cpos.x var cy : int = cpos.y var x := cx * cs var y := cy * cs var w : int = min(cs, _image.get_width() - x) var h : int = min(cs, _image.get_height() - y) var rect := Rect2(x, y, w, h) var initial_data := _image.get_rect(rect) var final_data := final_image.get_rect(rect) chunks_positions.append(cpos) chunks_initial_data.append(initial_data) chunks_final_data.append(final_data) #_image_equals(initial_data, final_data) # TODO We could also just replace the image with `final_image`... # TODO Use `final_data` instead? _image.blit_rect(final_image, rect, rect.position) _modified_chunks.clear() var time_spent := OS.get_ticks_msec() - time_before _logger.debug("Spent {0} ms to commit paint operation".format([time_spent])) return { "chunk_positions": chunks_positions, "chunk_initial_datas": chunks_initial_data, "chunk_final_datas": chunks_final_data } # DEBUG #func _input(event): # if event is InputEventKey: # if event.pressed: # if event.control and event.scancode == KEY_SPACE: # print("Saving painter viewport ", name) # var im = _viewport.get_texture().get_data() # im.convert(Image.FORMAT_RGBA8) # im.save_png(str("test_painter_viewport_", name, ".png")) #static func _image_equals(im_a: Image, im_b: Image) -> bool: # if im_a.get_size() != im_b.get_size(): # print("Diff size: ", im_a.get_size, ", ", im_b.get_size()) # return false # if im_a.get_format() != im_b.get_format(): # print("Diff format: ", im_a.get_format(), ", ", im_b.get_format()) # return false # im_a.lock() # im_b.lock() # for y in im_a.get_height(): # for x in im_a.get_width(): # var ca = im_a.get_pixel(x, y) # var cb = im_b.get_pixel(x, y) # if ca != cb: # print("Diff pixel ", x, ", ", y) # return false # im_a.unlock() # im_b.unlock() # print("SAME") # return true