@tool @icon("../icons/VisionCone3D.svg") class_name VisionCone3D extends Area3D ## Provides a "Vision Cone", a cone-shaped area where objects are then ## probed for visibility via ray casts ## Emitted when a body is newly visible signal body_sighted(body: Node3D) ## Emitted when a body is newly not visible signal body_hidden(body: Node3D) ## Emitted when the cone shape changes signal shape_changed #region VisionCone3D #region members ## Determines how visibility is probed for bodies within the cone area ## enum VisionTestMode{ ## Samples the center of each CollisionShape. Maximum performance, least reliability SAMPLE_CENTER, ## Samples random vertices of each CollisionShape, up to `vision_test_shape_max_probe_count` ## for hidden objects ## If shape was visible at last frame, tests last successful probe position first SAMPLE_RANDOM_VERTICES, ## Gets a list of points where shapes collide from the physics engine # SAMPLE_COLLIDE_SHAPE, } const VisionConeDebugVisualizer3D := preload("./debug/VisionConeDebugVisualizer3D.gd") ## Distance that can be seen (the height of the vision cone) @warning_ignore("shadowed_global_identifier") @export var range := 20.0: set(v): range = v; _update_shape() ## Angle of the vision cone @export_range(0, 150) var angle := 45.0: set(v): angle = v; _update_shape() ## Whether or not to draw debug information @export var debug_draw := false: set(v): debug_draw = v if debug_draw and !_debug_visualizer: _debug_visualizer = VisionConeDebugVisualizer3D.new() add_child(_debug_visualizer) elif !debug_draw and _debug_visualizer: _debug_visualizer.queue_free() @export_group("Vision Test", "vision_test_") ## Which VisionTestMode to use to determine if a shape is visible @export var vision_test_mode : VisionTestMode = VisionTestMode.SAMPLE_RANDOM_VERTICES ## List of bodies to ignore in vision probing ## ## Useful for eg the VisionCone3D's parent body @export var vision_test_ignore_bodies : Array[PhysicsBody3D] @export_subgroup("Per-frame probe settings") ## Maximum amount of shape probes (per shape, per frame) @export var vision_test_shape_max_probe_count : int = 5 ## Maximum number of bodies to check, per-frame ## ## All bodies will still be evaluated as it will cycle through them ## frame by frame, but `body_sighted` or `body_hidden` may lag behind ## by some frames. @export var vision_test_max_body_count : int = 10 @export_group("Collision", "collision_") ## Collision layer "hoisted" up from Area3D for convenience @export_flags_3d_physics var collision_layer_ : int = 1: get: return collision_layer set(value): collision_layer = value ## Collision mask "hoisted" up from Area3D for convenience ## ## This represents what can be "seen" and notified against. Generally useful for characters @export_flags_3d_physics var collision_mask_ : int = 1: get: return collision_mask set(value): collision_mask = value ## Collision mask of what objects can obscure visible objects (but don't need to ## be tracked and probed to determine visibility) ## ## Generally useful for the environment but any node where you don't care ## if its seen or not can be on this layer. ## This layer only affects raycasts, which can collide with any layer ## in either `collision_mask` or `collision_environment_mask` @export_flags_3d_physics var collision_environment_mask : int = 1 ## Radius at the wide end of the vision cone var end_radius: float: get: return _get_end_radius() # { Node3D "body" : Node3D "shape" } var _body_probe_data : Dictionary = { # Node3D "body" : [ # "prober": VisionTestProber # ] } var _last_probed_index : int = -1 var _debug_visualizer : VisionConeDebugVisualizer3D var _collision_shape := CollisionShape3D.new() var _cone_shape := ConeShape3D.new() #endregion members ## Returns a list of intersecting PhysicsBody3Ds. The overlapping body's ## CollisionObject3D.collision_layer must be part of this area's CollisionObject3D.collision_mask ## in order to be detected. func get_visible_bodies() -> Array[PhysicsBody3D]: var bodies : Array[PhysicsBody3D] = [] for prober: VisionTestProber in _body_probe_data.values(): bodies.push_back(prober.body) return bodies ## Whether or not a given point in global space is within the cone's ## angle. func point_within_angle(global_point: Vector3) -> bool: var body_pos := -global_basis.z var pos := global_point - global_position var angle_to := pos.angle_to(body_pos) var angle_deg := rad_to_deg(angle_to) return angle_deg <= (angle / 2) ## Whether or not a given point in global space is within the cone func point_within_cone(global_point: Vector3) -> bool: var local_point := to_local(global_point) var z_distance : float = abs(local_point.z) if z_distance < 0 or z_distance > range: return false return point_within_angle(global_point) func _init() -> void: add_child(_collision_shape) _collision_shape.shape = _cone_shape _collision_shape.rotation_degrees.x = 90 _update_shape() # only true when copied if _debug_visualizer: _debug_visualizer.vision_cone = self var err := body_shape_entered.connect(_on_body_shape_entered) if err != OK: push_warning("VisionCone3D body_shape_entered: ", error_string(err)) err = body_shape_exited.connect(_on_body_shape_exited) if err != OK: push_warning("VisionCone3D body_shape_exited: ", error_string(err)) @warning_ignore("return_value_discarded") func _physics_process(_delta: float) -> void: if Engine.is_editor_hint(): return if !monitoring: return var bodies_to_probe := _get_bodies_to_probe_this_frame() for body: CollisionObject3D in bodies_to_probe: if !is_instance_valid(body): push_warning("erasing invalid body") _body_probe_data.erase(body) continue _probe_body(body) func _get_bodies_to_probe_this_frame() -> Array: # Array[CollisionObject3D]: var all_bodies := _body_probe_data.keys() if all_bodies.is_empty(): return [] if all_bodies.size() < vision_test_max_body_count: _last_probed_index = -1 return all_bodies var start_index := _last_probed_index + 1 var to_end := all_bodies.slice(start_index, start_index + vision_test_max_body_count) var counted := to_end.size() var end_index : int = min(vision_test_max_body_count - counted, start_index) var from_start := all_bodies.slice(0, end_index) _last_probed_index = from_start.size() - 1 if from_start.size() > 0 else start_index + counted - 1 return (to_end + from_start) func _probe_body(body: CollisionObject3D) -> void: var body_was_visible_last_frame := false var body_is_visible := false var body_probes : Array[VisionTestProber] body_probes.assign(_body_probe_data[body]) for prober in body_probes: if prober.visible: body_was_visible_last_frame = true prober.probe() if prober.visible: body_is_visible = true var body_visibility_changed := body_is_visible != body_was_visible_last_frame if body_visibility_changed: if body_is_visible: body_sighted.emit(body) else: body_hidden.emit(body) func _update_shape() -> void: _cone_shape.height = range _cone_shape.radius = end_radius _collision_shape.position.z = -range / 2 update_gizmos() shape_changed.emit() func _get_collision_shape_node_in_body(body: PhysicsBody3D, body_shape_index: int) -> Node3D: if !body: return null var body_shape_owner : int = body.shape_find_owner(body_shape_index) return body.shape_owner_get_owner(body_shape_owner) func _get_end_radius() -> float: var angle_rad := deg_to_rad(angle / 2) return range * tan(angle_rad) func _get_prober_for_shape(shape: CollisionShape3D, body: CollisionObject3D) -> VisionTestProber: for prober: VisionTestProber in _body_probe_data[body]: if prober.collision_shape == shape: return prober return null @warning_ignore("return_value_discarded") func _on_body_shape_entered( _body_rid: RID, body: Node3D, body_shape_index: int, _local_shape_index: int, ) -> void: # # weird! if !is_instance_valid(body): if _body_probe_data.has(body): _body_probe_data.erase(body) return var shape := _get_collision_shape_node_in_body(body, body_shape_index) var body_probes : Array[VisionTestProber] = _body_probe_data.get_or_add( body, [] as Array[VisionTestProber] ) var has_prober := _get_prober_for_shape(shape, body) if !has_prober: body_probes.push_back(VisionTestProber.new(self, shape, body)) else: push_warning("Already has prober") @warning_ignore("return_value_discarded") func _on_body_shape_exited( _body_rid: RID, body: Node3D, body_shape_index: int, _local_shape_index: int, ) -> void: if !body: return var shape := _get_collision_shape_node_in_body(body, body_shape_index) var prober := _get_prober_for_shape(shape, body) var body_probers : Array[VisionTestProber]= _body_probe_data[body] body_probers.erase(prober) if body_probers.is_empty(): _body_probe_data.erase(body) #endregion VisionCone3D class VisionTestProber: ## Useful for debugging probes const CONTINUE_PROBING_ON_SUCCESS := false ## Vision cone to probe for var vision_cone : VisionCone3D ## Collision shape to probe var collision_shape: CollisionShape3D ## Collision shape mesh representation var shape_probe_mesh: ArrayMesh ## Collision shape's owning body var body : PhysicsBody3D ## Whether the probe found the shape to be visible var visible: bool = false ## All probe results, for debugging var probe_results: Array[ProbeResult] static var _rng := RandomNumberGenerator.new() func _init( p_vision_cone: VisionCone3D, p_collision_shape: CollisionShape3D, p_body: PhysicsBody3D ) -> void: vision_cone = p_vision_cone collision_shape = p_collision_shape body = p_body func _probe_position(to: Vector3, shape_local_target: Vector3) -> ProbeResult: # Collide with bodies OR the environment var raycast_collision_mask := vision_cone.collision_mask | vision_cone.collision_environment_mask # can store reference to this? var space_state := vision_cone.get_world_3d().direct_space_state var from := vision_cone.global_position var exclude_bodies := vision_cone.vision_test_ignore_bodies\ .filter(func(x: PhysicsBody3D) -> bool: return is_instance_valid(x))\ .map(func(x: PhysicsBody3D) -> RID: return x.get_rid()) var query := PhysicsRayQueryParameters3D.create( from, to, raycast_collision_mask, exclude_bodies ) var result := space_state.intersect_ray(query) return ProbeResult.new( from, to, shape_local_target, result.collider if result.has("collider") else null ) func _get_last_visible_point_on_shape() -> Vector3: if probe_results.is_empty(): push_warning("Shape was not determined to be visible during last _probe_position") return Vector3.ZERO return probe_results[-1].shape_local_target func _random_points_on_probe_mesh(count: int) -> PackedVector3Array: if !shape_probe_mesh: shape_probe_mesh = collision_shape.shape.get_debug_mesh() var vertices : PackedVector3Array = shape_probe_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] var points : PackedVector3Array = [] for point in count: points.push_back(vertices[_rng.randi_range(0, vertices.size() - 1)]) return points func _get_scatter_points(count: int) -> PackedVector3Array: var sample_points : PackedVector3Array = [] # var random_point_count := vision_cone.vision_test_shape_max_probe_count sample_points.append_array(_random_points_on_probe_mesh(count)) return sample_points func _get_collide_points(count: int) -> PackedVector3Array: var sample_points : PackedVector3Array = [] # var cone_shape := vision_cone._collision_shape.shape var observable_shape := collision_shape.shape var query := PhysicsShapeQueryParameters3D.new() query.collide_with_areas = true query.collide_with_bodies = false query.collision_mask = vision_cone.collision_layer query.shape_rid = observable_shape.get_rid() var world_space := vision_cone.get_world_3d().direct_space_state var result := world_space.collide_shape(query) for i in result.size(): if i % 2 == 1: continue sample_points.push_back(result[i]) if sample_points.size() >= count: break return sample_points func probe() -> void: var sample_points : PackedVector3Array = [] var max_count := vision_cone.vision_test_shape_max_probe_count if visible: sample_points.append(_get_last_visible_point_on_shape()) max_count -= 1 match vision_cone.vision_test_mode: VisionTestMode.SAMPLE_CENTER: if !visible: sample_points.push_back(Vector3.ZERO) VisionTestMode.SAMPLE_RANDOM_VERTICES: sample_points.append_array(_get_scatter_points(max_count)) probe_results = [] visible = false for shape_local_point in sample_points: var global_point := collision_shape.global_position +\ (collision_shape.global_basis * shape_local_point) if !vision_cone.point_within_cone(global_point): continue var probe_result := _probe_position(global_point, shape_local_point) probe_results.push_back(probe_result) # found body we were looking for if probe_result.collider == body: probe_result.visible = true visible = true if CONTINUE_PROBING_ON_SUCCESS: print_debug("visible - continuing to _probe_position") continue return class ProbeResult: var start : Vector3 var end : Vector3 var shape_local_target : Vector3 var collider : Node3D var visible : bool = false func _init( p_start: Vector3, p_end: Vector3, p_shape_local_target: Vector3, p_collider: Node3D ) -> void: start = p_start end = p_end shape_local_target = p_shape_local_target collider = p_collider