class_name GunkBody extends StaticBody3D ## StaticBody3D with an associated "gunkable" mesh. ## Emitted from the main thread after the clear total is asynchronously updated. signal clear_total_updated(clear_total: float) const CONTINUITY_LIMIT := 32 const BUFFER_LIMIT := 3 const FACE_EPSILON := 0.01 const CLEAR_COLOR := Color.BLACK const MASK_COLOR := Color.RED const CLEAR_TOTAL_EPSILON := 1 @export var mask_dim := 256 @export var mesh_instance: MeshInstance3D @export var initial_mask: Texture var meshtool := MeshDataTool.new() var _polyline_buffer: Array[Vector2] = [] var _continued_paint_this_frame := false var _multiline_buffer := PackedVector2Array() var _multiline_width := 1.0 var _clear_total := 0.0 var _prev_clear_total := 0.0 # _clear_total is async computed in separate thread var _mask_tx: Texture2D var _thread: Thread var _mutex: Mutex = Mutex.new() var _semaphore: Semaphore = Semaphore.new() var _exit_thread := false @onready var mask_viewport: SubViewport = %MaskViewport @onready var mask_control: DrawController = %MaskControl @onready var mask_texture: TextureRect = %MaskTexture func _ready() -> void: # If no mesh_instance was provided, use the first direct child. if not mesh_instance: for c: Node in get_children(): if c is MeshInstance3D: mesh_instance = c as MeshInstance3D assert(mesh_instance, "GunkBody %s has no associated mesh!" % str(self)) mask_viewport.size = Vector2(mask_dim, mask_dim) var gunk_mat: ShaderMaterial = mesh_instance.mesh.surface_get_material(0).next_pass gunk_mat.set_shader_parameter("gunk_mask", mask_viewport.get_texture()) meshtool.create_from_surface(mesh_instance.mesh as ArrayMesh, 0) if initial_mask: mask_texture.texture = initial_mask _thread = Thread.new() _thread.start(_async_compute_clear_total) func _trigger_recompute_deferred() -> void: _mutex.lock() _mask_tx = mask_viewport.get_texture() #_mask_img = mask_viewport.get_texture().get_image() _mutex.unlock() _semaphore.post() func _async_compute_clear_total() -> void: while true: _semaphore.wait() # check exit flag _mutex.lock() var exiting := _exit_thread _mutex.unlock() if exiting: break # Get mask from GPU # TODO: does this need mutex protection? _mutex.lock() var mask_img := _mask_tx.get_image() _mutex.unlock() mask_img.convert(Image.FORMAT_R8) var px_data := mask_img.get_data() var px_sum := 0 for px in px_data: px_sum += px var new_total := px_sum / 255.0 # Write total _mutex.lock() _clear_total = new_total _mutex.unlock() func _exit_tree() -> void: # Flag processing flag to cleanly exit _mutex.lock() _exit_thread = true _mutex.unlock() _semaphore.post() _thread.wait_to_finish() func clear_all() -> void: mask_control.clear(MASK_COLOR) ## Get the precise number of gunk pixels cleared from this image. ## ## This will use a cached result unless the mask has been painted since the last calculation. ## Be aware that cache misses are potentially quite expensive. func get_clear_total() -> float: # Protect with mutex _mutex.lock() var total := _clear_total _mutex.unlock() return total ## Get the index of the mesh face on which the given point+normal lies. ## ## Returns -1 if the given point+normal does not lie on a mesh face within tolerance. func _get_face(point: Vector3, normal: Vector3) -> int: var min_i := -1 var min_sdist := INF for i in range(meshtool.get_face_count()): var sdist := meshtool.get_face_normal(i).distance_squared_to(normal) if sdist > FACE_EPSILON or sdist > min_sdist: continue # Normals match, so check if the point is on this face var v1 := meshtool.get_vertex(meshtool.get_face_vertex(i, 0)) var v2 := meshtool.get_vertex(meshtool.get_face_vertex(i, 1)) var v3 := meshtool.get_vertex(meshtool.get_face_vertex(i, 2)) if GeometryTools.is_in_triangle(point, v1, v2, v3): min_i = i min_sdist = sdist return min_i ## UV-unwrap a point+normal in 3D space onto the UV space of this mesh. ## ## Returns Vector2.INF if the given point+normal does not lie on this mesh within tolerance. func _get_uv(point: Vector3, normal: Vector3) -> Vector2: var face := _get_face(point, normal) if face < 0: return Vector2.INF var fv0 := meshtool.get_face_vertex(face, 0) var fv1 := meshtool.get_face_vertex(face, 1) var fv2 := meshtool.get_face_vertex(face, 2) var v1 := meshtool.get_vertex(fv0) var v2 := meshtool.get_vertex(fv1) var v3 := meshtool.get_vertex(fv2) var bc := GeometryTools.barycentric(point, v1, v2, v3) # TODO memoize var uv1 := meshtool.get_vertex_uv(fv0) var uv2 := meshtool.get_vertex_uv(fv1) var uv3 := meshtool.get_vertex_uv(fv2) return (uv1 * bc.x) + (uv2 * bc.y) + (uv3 * bc.z) ## UV-unwrap a point+normal in 3D space onto the pixel space of this mesh's texture. ## ## Returns Vector2.INF if the given point+normal does not lie on this mesh within tolerance. func _get_px(point: Vector3, normal: Vector3) -> Vector2: return _get_uv(point * global_transform, normal * global_basis) * mask_control.size ## Paint a dot on the gunk mask. func paint_dot(point: Vector3, normal: Vector3, radius: float) -> void: var px := _get_px(point, normal) if px == Vector2.INF: return mask_control.queue_draw( func() -> void: mask_control.draw_circle(px, radius, MASK_COLOR, true, -1, true) ) ## Paint a continuous line on the gunk mask if called on successive frames. func paint_continuous(point: Vector3, normal: Vector3, width: float) -> void: var px := _get_px(point, normal) if _polyline_buffer and px.distance_to(_polyline_buffer[0]) <= CONTINUITY_LIMIT: _polyline_buffer.push_front(px) if len(_polyline_buffer) > BUFFER_LIMIT: _polyline_buffer.pop_back() var polyline := PackedVector2Array(_polyline_buffer) mask_control.queue_draw( func() -> void: mask_control.draw_polyline(polyline, MASK_COLOR, width * 2, true) ) else: _polyline_buffer = [px] # Always paint a circle, to round out corners & cap ends mask_control.queue_draw( func() -> void: mask_control.draw_circle(px, width, MASK_COLOR, true, -1, true) ) _continued_paint_this_frame = true ## Add a segment to the multiline to paint this frame. func add_to_multiline( point_a: Vector3, normal_a: Vector3, point_b: Vector3, normal_b: Vector3, width: float ) -> void: var px_a := _get_px(point_a, normal_a) var px_b := _get_px(point_b, normal_b) if px_a == Vector2.INF or px_b == Vector2.INF or px_a.distance_to(px_b) > CONTINUITY_LIMIT: return _multiline_buffer.append(px_a) _multiline_buffer.append(px_b) _multiline_width = width func _process(_delta: float) -> void: # Check if processing thread has updated the clear total _mutex.lock() var new_total := _clear_total _mutex.unlock() var delta := new_total - _prev_clear_total if abs(delta) > CLEAR_TOTAL_EPSILON: clear_total_updated.emit(new_total) # XXX due to fp error, this will drift from the "true count" over time # but it probably won't matter :shrug: Game.manager.collect_grunk(delta) _prev_clear_total = new_total # If paint_continuous wasn't called last frame, stop the current polyline. if not _continued_paint_this_frame: _polyline_buffer = [] _continued_paint_this_frame = false # If we've buffered a multiline in the last frame, draw it & clear the buffer. # The width of the multiline will just be the width of the last call. if _multiline_buffer: var points := _multiline_buffer.duplicate() var width := _multiline_width mask_control.queue_draw( func() -> void: mask_control.draw_multiline(points, MASK_COLOR, width, true) ) _multiline_buffer = PackedVector2Array() func _on_mask_painted() -> void: # XXX any problem with posting each frame? call_deferred("_trigger_recompute_deferred")