399 lines
13 KiB
GDScript
399 lines
13 KiB
GDScript
|
|
# 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 or brush behavior, only rendering 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 HT_Logger = preload("../../util/logger.gd")
|
|
const HT_Util = preload("../../util/util.gd")
|
|
const HT_NoBlendShader = preload("./no_blend.gdshader")
|
|
const HT_NoBlendRFShader = preload("./no_blend_rf.gdshader")
|
|
|
|
const UNDO_CHUNK_SIZE = 64
|
|
|
|
# All painting shaders can use these common parameters
|
|
const SHADER_PARAM_SRC_TEXTURE = "u_src_texture"
|
|
const SHADER_PARAM_SRC_RECT = "u_src_rect"
|
|
const SHADER_PARAM_OPACITY = "u_opacity"
|
|
|
|
const _API_SHADER_PARAMS = [
|
|
SHADER_PARAM_SRC_TEXTURE,
|
|
SHADER_PARAM_SRC_RECT,
|
|
SHADER_PARAM_OPACITY
|
|
]
|
|
|
|
# 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
|
|
# No longer supported since Godot 4 removed support for it in 2D viewports...
|
|
# Image.FORMAT_RH,
|
|
# Image.FORMAT_RGH,
|
|
# Image.FORMAT_RGBH,
|
|
# Image.FORMAT_RGBAH
|
|
]
|
|
|
|
# - SubViewport (size of edited region + margin to allow quad rotation)
|
|
# |- Background
|
|
# | Fills pixels with unmodified source image.
|
|
# |- Brush sprite
|
|
# Size of actual brush, scaled/rotated, modifies source image.
|
|
# Assigned texture is the brush texture, src image is a shader param
|
|
|
|
var _viewport : SubViewport
|
|
var _viewport_bg_sprite : Sprite2D
|
|
var _viewport_brush_sprite : Sprite2D
|
|
var _brush_size := 32
|
|
var _brush_scale := 1.0
|
|
var _brush_position := Vector2()
|
|
var _brush_opacity := 1.0
|
|
var _brush_texture : Texture
|
|
var _last_brush_position := Vector2()
|
|
var _brush_material := ShaderMaterial.new()
|
|
var _no_blend_material : ShaderMaterial
|
|
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 = HT_Logger.get_for(self)
|
|
|
|
|
|
func _init():
|
|
_viewport = SubViewport.new()
|
|
_viewport.size = Vector2(_brush_size, _brush_size)
|
|
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
|
_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
|
|
#_viewport.hdr = false
|
|
# Require 4 components (RGBA)
|
|
_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
|
|
|
|
# There is no "blend_disabled" option on standard CanvasItemMaterial...
|
|
_no_blend_material = ShaderMaterial.new()
|
|
_no_blend_material.shader = HT_NoBlendShader
|
|
_viewport_bg_sprite = Sprite2D.new()
|
|
_viewport_bg_sprite.centered = false
|
|
_viewport_bg_sprite.material = _no_blend_material
|
|
_viewport.add_child(_viewport_bg_sprite)
|
|
|
|
_viewport_brush_sprite = Sprite2D.new()
|
|
_viewport_brush_sprite.centered = true
|
|
_viewport_brush_sprite.material = _brush_material
|
|
_viewport_brush_sprite.position = _viewport.size / 2.0
|
|
_viewport.add_child(_viewport_brush_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_bg_sprite.texture = _texture
|
|
_brush_material.set_shader_parameter(SHADER_PARAM_SRC_TEXTURE, _texture)
|
|
if image != null:
|
|
if image.get_format() == Image.FORMAT_RF:
|
|
# In case of RF all shaders must encode their fragment outputs in RGBA8,
|
|
# including the unmodified background, as Godot 4.0 does not support RF viewports
|
|
_no_blend_material.shader = HT_NoBlendRFShader
|
|
else:
|
|
_no_blend_material.shader = HT_NoBlendShader
|
|
# TODO HDR is required in order to paint heightmaps.
|
|
# Seems Godot 4.0 does not support it, so we have to wait for Godot 4.1...
|
|
#_viewport.hdr = image.get_format() in _hdr_formats
|
|
if (image.get_format() in _hdr_formats) and image.get_format() != Image.FORMAT_RF:
|
|
push_error("Godot 4.0 does not support HDR viewports for GPU-editing heightmaps! " +
|
|
"Only RF is supported using a bit packing hack.")
|
|
#print("PAINTER VIEWPORT HDR: ", _viewport.hdr)
|
|
|
|
|
|
# Sets the size of the brush in pixels.
|
|
# This will cause the internal viewport to resize, which is expensive.
|
|
# If you need to frequently change brush size during a paint stroke, prefer using scale instead.
|
|
func set_brush_size(new_size: int):
|
|
_brush_size = new_size
|
|
|
|
|
|
func get_brush_size() -> int:
|
|
return _brush_size
|
|
|
|
|
|
func set_brush_rotation(rotation: float):
|
|
_viewport_brush_sprite.rotation = rotation
|
|
|
|
|
|
func get_brush_rotation() -> float:
|
|
return _viewport_bg_sprite.rotation
|
|
|
|
|
|
# The difference between size and scale, is that size is in pixels, while scale is a multiplier.
|
|
# Scale is also a lot cheaper to change, so you may prefer changing it instead of size if that
|
|
# happens often during a painting stroke.
|
|
func set_brush_scale(s: float):
|
|
_brush_scale = clampf(s, 0.0, 1.0)
|
|
#_viewport_brush_sprite.scale = Vector2(s, s)
|
|
|
|
|
|
func get_brush_scale() -> float:
|
|
return _viewport_bg_sprite.scale.x
|
|
|
|
|
|
func set_brush_opacity(opacity: float):
|
|
_brush_opacity = clampf(opacity, 0.0, 1.0)
|
|
|
|
|
|
func get_brush_opacity() -> float:
|
|
return _brush_opacity
|
|
|
|
|
|
func set_brush_texture(texture: Texture):
|
|
_viewport_brush_sprite.texture = texture
|
|
|
|
|
|
func set_brush_shader(shader: Shader):
|
|
if _brush_material.shader != shader:
|
|
_brush_material.shader = shader
|
|
|
|
|
|
func set_brush_shader_param(p: String, v):
|
|
assert(not _API_SHADER_PARAMS.has(p))
|
|
_modified_shader_params[p] = true
|
|
_brush_material.set_shader_parameter(p, v)
|
|
|
|
|
|
func clear_brush_shader_params():
|
|
for key in _modified_shader_params:
|
|
_brush_material.set_shader_parameter(key, null)
|
|
_modified_shader_params.clear()
|
|
|
|
|
|
# If we want to be able to rotate the brush quad every frame,
|
|
# we must prepare a bigger viewport otherwise the quad will not fit inside
|
|
static func _get_size_fit_for_rotation(src_size: Vector2) -> Vector2i:
|
|
var d = int(ceilf(src_size.length()))
|
|
return Vector2i(d, d)
|
|
|
|
|
|
# You must call this from an `_input` function or similar.
|
|
func paint_input(center_pos: Vector2):
|
|
var vp_size := _get_size_fit_for_rotation(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
|
|
_viewport_brush_sprite.position = _viewport.size / 2.0
|
|
|
|
# Need to floor the position in case the brush has an odd size
|
|
var brush_pos := (center_pos - _viewport.size * 0.5).round()
|
|
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
|
_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
|
|
_viewport_bg_sprite.position = -brush_pos
|
|
_brush_position = brush_pos
|
|
_cmd_paint = true
|
|
|
|
# We want this quad to have a specific size, regardless of the texture assigned to it
|
|
_viewport_brush_sprite.scale = \
|
|
_brush_scale * Vector2(_brush_size, _brush_size) \
|
|
/ Vector2(_viewport_brush_sprite.texture.get_size())
|
|
|
|
# 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 = float(_viewport.size.x) / float(_texture.get_width())
|
|
rect.a = float(_viewport.size.y) / float(_texture.get_height())
|
|
# In order to make sure that u_brush_rect is never bigger than the brush:
|
|
# 1. we ceil() the result of lower-left corner
|
|
# 2. we floor() the result of upper-right corner
|
|
# and then rederive width and height from the result
|
|
# var half_brush:Vector2 = Vector2(_brush_size, _brush_size) / 2
|
|
# var brush_LL := (center_pos - half_brush).ceil()
|
|
# var brush_UR := (center_pos + half_brush).floor()
|
|
# rect.r = brush_LL.x / _texture.get_width()
|
|
# rect.g = brush_LL.y / _texture.get_height()
|
|
# rect.b = (brush_UR.x - brush_LL.x) / _texture.get_width()
|
|
# rect.a = (brush_UR.y - brush_LL.y) / _texture.get_height()
|
|
_brush_material.set_shader_parameter(SHADER_PARAM_SRC_RECT, rect)
|
|
_brush_material.set_shader_parameter(SHADER_PARAM_OPACITY, _brush_opacity)
|
|
|
|
|
|
# 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 viewport_image := _viewport.get_texture().get_image()
|
|
|
|
if _image.get_format() == Image.FORMAT_RF:
|
|
# Reinterpret RGBA8 as RF. This assumes painting shaders encode the output properly.
|
|
assert(viewport_image.get_format() == Image.FORMAT_RGBA8)
|
|
viewport_image = Image.create_from_data(
|
|
viewport_image.get_width(), viewport_image.get_height(), false, Image.FORMAT_RF,
|
|
viewport_image.get_data())
|
|
else:
|
|
viewport_image.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 = maxf(-brush_pos.x, 0)
|
|
var src_y : int = maxf(-brush_pos.y, 0)
|
|
var src_w : int = minf(maxf(_viewport.size.x - src_x, 0), _texture.get_width() - dst_x)
|
|
var src_h : int = minf(maxf(_viewport.size.y - 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)
|
|
HT_Util.update_texture_partial(_texture, viewport_image,
|
|
Rect2i(src_x, src_y, src_w, src_h), Vector2i(dst_x, dst_y))
|
|
texture_region_changed.emit(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):
|
|
#print("Marking chunk ", Vector2(cx, cy))
|
|
_modified_chunks[Vector2(cx, cy)] = true
|
|
|
|
|
|
func _commit_modified_chunks() -> Dictionary:
|
|
var time_before := Time.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_image()
|
|
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 = mini(cs, _image.get_width() - x)
|
|
var h : int = mini(cs, _image.get_height() - y)
|
|
|
|
var rect := Rect2i(x, y, w, h)
|
|
var initial_data := _image.get_region(rect)
|
|
var final_data := final_image.get_region(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 := Time.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
|