godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.15k stars 97 forks source link

Add support for RigidBody2D with holes or self-intersection #8480

Open artlessgames opened 11 months ago

artlessgames commented 11 months ago

Describe the project you are working on

I'm working on a project similar to crayon physics, where any shape of colliders might be drawn by my player.

Also, ring collider is widely used in abilities like Fire Nova, Ring of Frost, and Summon Donut. A rope or chain may also self-intersect at some point.

Describe the problem or limitation you are having in your project

My alternative solution to generating a collider for a mouse-drawn line with width is:

Generate the outer ring and inner ring with Geometry2D.offset_polyline Sort the inner rings with their leftmost x For each inner ring, from the left to the right: draw a line connecting its leftmost point towards the left, to the first thing it touches. (The combined shape becomes: outer ring - intersection point - leftmost point of inner ring - circle around the inner ring - leftmost point of inner ring - intersection point - the rest of outer ring) After each connection, the inner ring becomes part of the new outer ring, and future inner rings may directly connect to it. Repeat until everything is connected to the outer ring. Attach the outer ring to a CollisionPolygon2D.

It usually works. But it has numerous edge cases, and I still cannot fix some of them.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Just support RigidBody2D with holes.

A possible way to describe a holed polygon collision shape is an array for outer ring plus several arrays for inner rings. Or allow self-intersecting polygons, that is also enough to a ring shape.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

I think my generation strategy might work.

If this enhancement will not be used often, can it be worked around with a few lines of script?

The bugs I come up with include: Geometry2D.offset_polyline's outcome is float, while CollisionPolygon by default turns everything into integer. A clockwise ring might be turned to anticlockwise after being rounded, or self-intersecting. Sometimes, separate rings touch each other. Sometimes, two "to-the-left" lines are too close and even overlap. Sometimes, I have no idea what is happening but ERROR: Convex Decomposing failed is still shown.

I had a hard time fixing them and it is still buggy. Now I can get about nine out of ten complex shapes to work fine, and simply delete the tenth one. (Players can live with this because it doesn't happen very often, but there should be a better solution)

Is there a reason why this should be core and not an add-on in the asset library?

Fire Nova, Ring of Frost, and Summon Donut.

Calinou commented 11 months ago
artlessgames commented 11 months ago

This one was caused by Geometry2D.offset_polyline's outcome is float, while CollisionPolygon by default turns everything into integer.

artlessgames commented 11 months ago

My functional but not always functional code in gdscript:

func GenerateLineCollider(points):
    if(points.size()<=1):
        current_node.queue_free()
        return false
    var ans = Geometry2D.offset_polyline(points, current_node.width / 2,Geometry2D.JOIN_ROUND,Geometry2D.END_ROUND)
    var outer_ring=[]
    var inner_ring=[]

    for item in ans:
        var clock=Geometry2D.is_polygon_clockwise(item)
        for i in item.size():
            item[i]=Vector2(round(item[i].x),round(item[i].y))
        for i in range(item.size()-1,-1,-1):
            if item[i]==item[(i+1)%item.size()]:
                item.remove_at(i)
        for i in range(item.size()-1,-1,-1):
            var p1=item[i]
            var p2=item[(i+1)%item.size()]
            var p3=item[i-1]
            if (p2-p1).cross(p3-p1)==0:
                item.remove_at(i)
        if clock!=Geometry2D.is_polygon_clockwise(item):
            print("reverse!")
            print(item)
            item.reverse()
    for i in range(ans.size()-1,-1,-1):
        if ans[i].size()<=2:
            ans.remove_at(i)
            continue
        if !checkselfintersect(ans[i]):
#           ans.remove_at(i)
            continue
        var v=ans[i].duplicate()
        v.reverse()
        if Geometry2D.is_polygon_clockwise(ans[i])==Geometry2D.is_polygon_clockwise(v):
            print("broken")
            print(v)
#       var minx=INF
#       var miny=INF
#       var maxx=-INF
#       var maxy=-INF
#       for i1 in ans[i]:
#           minx=min(i1.x,minx)
#           miny=min(i1.y,miny)
#           maxx=max(i1.x,maxx)
#           maxy=max(i1.y,maxy)
#       if(maxx-minx<mindis || maxx-miny<mindis):
#           print(maxx)
#           print(minx)
#           print(maxy)
#           print(miny)
#           ans.remove_at(i)
    var out1=[]
    for item in ans:
        if(Geometry2D.is_polygon_clockwise(item)):
            inner_ring.append(item)
        else:
            outer_ring = item
            out1.append(item)
#   print("out")
    if out1.size()>1:
        print("out>1")
        print(out1)
#   print("in")
#   for i in inner_ring:
#       print(i)
#       print("")
    for i1 in inner_ring.size():
        var item=inner_ring[i1]
        var leftmost = INF
        var ii=-1
        for i in item.size():
            if(item[i].x<leftmost):
                leftmost=item[i].x
                ii=i
        var v1=item.slice(ii)
        v1.append_array(item.slice(0,ii))
        inner_ring[i1]=v1
#   inner_ring.sort_custom(func(a, b): return a[0].y < b[0].y)
    inner_ring.sort_custom(func(a, b): return a[0].x < b[0].x)
    queue_redraw()
#       outer_ring=outer_ring.slice(0,30)
    #inner_ring=inner_ring.slice(0,2)
    for item in inner_ring:
        var jj=0
        var maxIntersection=Vector2(-INF,-INF)
#       var dir=Vector2(-10000,0)
#       var tryit=true
#       while tryit:
#           tryit=false
#           for j in range(0,outer_ring.size()):
#               if (outer_ring[j]-item[0]).normalized() == dir.normalized():
#                   tryit=true
#                   dir+=Vector2(0,1000)
#                   continue
        var crosspoint:bool=false
        for j in range(0,outer_ring.size()):
            var pt
            if outer_ring[j].y==item[0].y && outer_ring[j].x<item[0].x && outer_ring[j].x>=maxIntersection.x:
                if outer_ring[j].x==maxIntersection.x:
                    var pre=outer_ring[j-1]
                    var nex=outer_ring[(j+1)%outer_ring.size()]
                    if pre.y>item[0].y:
                        if nex.y!=item[0].y:
                            print("????")
                            print(pre)
                            print(nex)
                            print(nex+1)
                        pt=outer_ring[j]
                        jj=j
                        crosspoint=true
                    if nex.y<item[0].y:
                        if pre.y!=item[0].y:
                            print("????")
                            print(pre)
                            print(nex)
                            print(nex+1)
                        pt=outer_ring[j]
                        jj=j
                        crosspoint=true
                else:
                    pt=outer_ring[j]
                    jj=j
                    crosspoint=true
            elif outer_ring[j-1].y!=item[0].y:
                pt =Geometry2D.segment_intersects_segment(item[0],item[0]+Vector2(-10000,0),outer_ring[j],outer_ring[j-1])
            if pt!=null && pt.x>maxIntersection.x:
                maxIntersection=pt
                jj=j                
                crosspoint=false
        maxIntersection=Vector2(ceil(maxIntersection.x),maxIntersection.y)
        var b=false
        if jj==0:
            if !crosspoint:
                outer_ring.append(maxIntersection)
            outer_ring.append_array(item)
            outer_ring.append(item[0])
            outer_ring.append(maxIntersection)
        else:
            var tmp=outer_ring.slice(0,jj)
            var tmp2=outer_ring.slice(jj)
            if !crosspoint:
                tmp.append(maxIntersection)
            else:
                todraw.append(maxIntersection)
            tmp.append_array(item)
            tmp.append(item[0])
            tmp.append(maxIntersection)
            tmp.append_array(tmp2)
            outer_ring=tmp
        if crosspoint:
#           todraw.append(maxIntersection)
            if(maxIntersection.x<1000 && maxIntersection.x>900 && maxIntersection.y<320 && maxIntersection.y>290):
                print(maxIntersection)
                print(item[0])
    for i in range(outer_ring.size()):
        outer_ring[i]=Vector2(int(round(outer_ring[i].x)),int(round(outer_ring[i].y)))
    for i in range(outer_ring.size()-1,0,-1):
        if(outer_ring[i]==outer_ring[i-1]):
            outer_ring.remove_at(i)
    return outer_ring
AThousandShips commented 11 months ago

This one was caused by Geometry2D.offset_polyline's outcome is float, while CollisionPolygon by default turns everything into integer.

What are you basing this on? There's no such code that does this?

artlessgames commented 11 months ago

This one was caused by Geometry2D.offset_polyline's outcome is float, while CollisionPolygon by default turns everything into integer.

What are you basing this on? There's no such code that does this?

Yes, that is not a bug. I was just trying to use Geometry2D.offset_polyline to generate a CollisionPolygon. But they don't work well together. The problems I listed above was to describe why my code fail.

AThousandShips commented 11 months ago

But you are saying there's conversion to integer and that just isn't true? What are you basing that claim on?

artlessgames commented 11 months ago

But you are saying there's conversion to integer and that just isn't true? What are you basing that claim on?

I guess that means I was completely working on the wrong direction. (All my "bug fixes" are based on that assumption) Then another possibility is that lines that are too close together are considered as overlapping. (And rounding causes them to separate, leading me to think that rounding is the key.)

AThousandShips commented 11 months ago

The points in your bug report did have duplicates, that wasn't done by the collision polygon

Removing duplicate points is a trivial fix and part of cleaning up input IMO, and potential other issues with the decomposition should also be treated as bugs or things for the user to ensure, you're in control over the input data here

artlessgames commented 11 months ago

无标题 Here is a failed collider I generated with my strategy. (I do not guarantee that my strategy is correct. It works most of the time, but there's always edge cases) I'm guessing that the circled area caused the failure as they look suspicious but I have no evidence of that I am sure there are no adjacent duplicate points this time I am sure there are non-adjacent duplicate points, as my strategy relies on visiting the same position twice to connect inner and outer loops I removed the round to int thing in my code when I drew the shape above.

Should I provide a project of this? (But my strategy may be completely wrong and not helpful for this problem I'm hoping to solve)

AThousandShips commented 11 months ago

I don't think it makes sense to have self intersections or holes in the 2D collision polygon, it adds a lot (a lot) of additional complexity and points of failure to the code, and I don't see where it would make sense to use it in itself, i.e. where it would help to allow this with input you control

I don't even know how we would achieve the collision testing with this and it would potentially make the collision detection code slower or more error prone

Other than that it seems there are a few aspects to look into:

Some times when things don't support exactly what you need you might need to rethink things, for example do you necessarily need your process to accept user input that is self intersecting or has holes? Or can you navigate that with other aspects, for example just grabbing the outer perimeter of the shape, and in any case you would need to safeguard against various possible invalid user inputs, so it's a natural part of the process.

The issue is partially that adding this complexity to the core would add overhead to everyone, and many if not most would not benefit from the added features, only be negatively affected by the slowdown and possible bugs, in these cases it's more reasonable to expect the user to adapt to these limitations and design choices and make sure their input is valid

In essence I'm asking: do you need this directly for actual uses of it, or just to make handling of invalid input easier

I hope that makes sense