2D Visibility

Visibility triangles from 2D occluding segment geometry in GDScript.

– Created: June 12, 2023 UTC

– Edited: July 28, 2024 UTC

– Tags: Programming, Godot, GDScript


Based on Redblobgames' visibility article and Haxe reference implementation

Full usable code is here.

Explanation

First step is determining angles for each segment point as well as denoting which one gets encountered first.

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:

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:

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:

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.

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)