diff --git a/articles/2d-visibility/page.mmd b/articles/2d-visibility/page.mmd new file mode 100644 index 0000000..ace6222 --- /dev/null +++ b/articles/2d-visibility/page.mmd @@ -0,0 +1,133 @@ +Title: 2D Visibility +Brief: Visibility triangles from 2D occluding segment geometry in GDScript. +Date: 1686547796 +Tags: Programming, Godot, GDScript +CSS: /style.css + +![](/articles/2d-visibility/example.gif) + +Based on [Redblobgames' visibility article and Haxe reference implementation](https://www.redblobgames.com/articles/visibility) + +Full usable code is [here](/articles/2d-visibility/Visiblity2D.gd.txt). + +### Explanation ### + +First step is determining angles for each segment point as well as denoting +which one gets encountered first. + +```gdscript +for segment in range(0, _endpoints.size(), 2): + var p1 := _endpoints[segment] as EndPoint + var p2 := _endpoints[segment + 1] as EndPoint + p1.angle = (p1.point - center).angle() + p2.angle = (p2.point - center).angle() + var da := p2.angle - p1.angle + if da <= PI: da += TAU + if da > PI: da -= TAU + p1.begin = da > 0.0 + p2.begin = not p1.begin +``` + +Then points are sorted by angle and beginning: + +```gdscript +static func sort(p_a: EndPoint, p_b: EndPoint) -> bool: + if p_a.angle > p_b.angle: return true + elif p_a.angle < p_b.angle: return false + elif not p_a.begin and p_b.begin: return true + else: return false +``` + +Then in two passes: +- Walk over sorted points. +- When nearest segment end or another more nearest encountered, - +remember the starting angle and only emit two points representing the visible portion of segment on second pass. + +```gdscript +var start_angle := 0.0 + +for n_pass in range(2): + for p_idx in range(_sorted_endpoints.size() - 1, -1, -1): + var p := _sorted_endpoints[p_idx] as EndPoint + var old := -1 if _open.empty() else _open[0] + + if p.begin: + var idx := 0 + while idx < _open.size() and _is_segment_in_front(p.segment, _open[idx]): + idx += 1 + _open.insert(idx, p.segment) + else: + var idx := _open.rfind(p.segment) + if idx != -1: _open.remove(idx) + + if old != (-1 if _open.empty() else _open[0]): + if n_pass == 1: + var p3 := _endpoints[old].point as Vector2 if old != -1 else \ + center + Vector2(cos(start_angle), sin(start_angle)) * 500.0 + var t2 := Vector2(cos(p.angle), sin(p.angle)) + var p4 := p3.direction_to(_endpoints[old + 1].point) if old != -1 else t2 + + # note: Checks are in case of parallel lines. + var l = Geometry.line_intersects_line_2d(p3, p4, center, + Vector2(cos(start_angle), sin(start_angle))) + if l != null: output.append(l) + l = Geometry.line_intersects_line_2d(p3, p4, center, t2) + if l != null: output.append(l) + + start_angle = p.angle +``` + +Where segment front deciding algorithm is as follows, using cross products: + +```gdscript +func _is_segment_in_front(p_segment1: int, p_segment2: int) -> bool: + var s1p1 := _endpoints[p_segment1].point as Vector2 + var s1p2 := _endpoints[p_segment1 + 1].point as Vector2 + var s2p1 := _endpoints[p_segment2].point as Vector2 + var s2p2 := _endpoints[p_segment2 + 1].point as Vector2 + + var d := s1p2 - s1p1 + var p := s2p1.linear_interpolate(s2p2, 0.01) + var a1 := (d.x * (p.y - s1p1.y) \ + - d.y * (p.x - s1p1.x)) < 0.0 + p = s2p2.linear_interpolate(s2p1, 0.01) + var a2 := (d.x * (p.y - s1p1.y) \ + - d.y * (p.x - s1p1.x)) < 0.0 + var a3 := (d.x * (center.y - s1p1.y) \ + - d.y * (center.x - s1p1.x)) < 0.0 + + if a1 == a2 and a2 == a3: return true + + d = s2p2 - s2p1 + p = s1p1.linear_interpolate(s1p2, 0.01) + var b1 := (d.x * (p.y - s2p1.y) \ + - d.y * (p.x - s2p1.x)) < 0.0 + p = s1p2.linear_interpolate(s1p1, 0.01) + var b2 := (d.x * (p.y - s2p1.y) \ + - d.y * (p.x - s2p1.x)) < 0.0 + var b3 := (d.x * (center.y - s2p1.y) \ + - d.y * (center.x - s2p1.x)) < 0.0 + + return b1 == b2 and b2 != b3 +``` + +### Usage example ### +Visibility2D.gd class implements builder interface to make it slightly easier to work with. + +```gdscript +func _process(_delta): + $Visibility2D.init_builder() \ + .view_point(get_global_mouse_position()) \ + .bounds(get_viewport_rect()) \ + .occluder($Line2D) \ + .finalize() + + for child in $Cones.get_children(): + child.queue_free() + + var edges = $Visibility2D.sweep() + for i in range(0, edges.size() - 1, 2): + var polygon := Polygon2D.new() + polygon.polygon = PoolVector2Array([$Visibility2D.center, edges[i], edges[i + 1]]) + $Cones.add_child(polygon) +```