libgdxbox2d

Imprecise rayCasting prediction and reflection


I use Android Studio with Java, LibGDX and Box2D.

I need to visualize the predictions of the movement of the balls, at least until the next two collisions.

Now the code works but the projections are not precise, as you can see in the image where they move while the ball is reaching the first collision point.

So, how do you make them as accurate as possible?

Thanks

enter image description here

public static void debugBallNextCollisions(MyGame game, World world, Viewport viewport, Body ball, float maxPredictionDistance, int qtyRecursions) {
    Array<Vector2[]> raysVector2 = getNextCollisionsRay(world, ball, maxPredictionDistance, qtyRecursions);

    game.shapeRenderer.end();
    game.shapeRenderer.setProjectionMatrix(viewport.getCamera().combined);
    Gdx.gl.glEnable(GL20.GL_BLEND); 
    game.shapeRenderer.begin(ShapeRenderer.ShapeType.Line);

    for(Vector2[] vector2s : new Array.ArrayIterator<>(raysVector2)) {
        game.shapeRenderer.line(vector2s[0].x, vector2s[0].y, vector2s[1].x, vector2s[1].y);
    }

    game.shapeRenderer.end();
}

public static Array<Vector2[]> getNextCollisionsRay(World world, Body ball, float maxPredictionDistance, int qtyRecursions) {
    Array<Vector2[]> collisionRays = new Array<>();

    Vector2 velocity = ball.getLinearVelocity();

    if (!velocity.isZero()) {
        Vector2 vector2start = new Vector2(ball.getPosition());
        Vector2 vector2End = new Vector2(vector2start).add(velocity.nor().scl(maxPredictionDistance));

        getNextCollisionsRayRecursive(world, vector2start, vector2End, velocity, maxPredictionDistance, String.valueOf(ball.getUserData()), collisionRays, qtyRecursions);
    }

    return collisionRays;
}

private static void getNextCollisionsRayRecursive(World world, Vector2 startPos, Vector2 endPos, Vector2 velocity, float maxPredictionDistance, String bodyId, Array<Vector2[]> collisionRays, int qtyRecursions) {
    DetailedRayCastCallback callback2 = new DetailedRayCastCallback(startPos, endPos, bodyId);

    Vector2 vector2start2 = new Vector2(startPos);
    Vector2 vector2End2 = new Vector2(vector2start2).add(velocity.nor().scl(maxPredictionDistance));

    world.rayCast(callback2, vector2start2, vector2End2);

    if (callback2.closestHit != null) {
        CollisionInfo hit2 = callback2.closestHit;

        collisionRays.add(new Vector2[]{new Vector2(vector2start2.x, vector2start2.y), new Vector2(hit2.collisionPoint.x, hit2.collisionPoint.y)});

        if(--qtyRecursions > 0) {
            Vector2 endPos2 = new Vector2(hit2.reflectedVector).scl(maxPredictionDistance).add(hit2.collisionPoint);
            getNextCollisionsRayRecursive(world, hit2.collisionPoint, endPos2, hit2.reflectedVector, maxPredictionDistance, bodyId, collisionRays, qtyRecursions);
        }
    }
    else {
        Vector2 velocityEnd2 = new Vector2(vector2start2).add(velocity);
        collisionRays.add(new Vector2[]{new Vector2(vector2start2.x, vector2start2.y), new Vector2(velocityEnd2.x, velocityEnd2.y)});
    }
}

private static class DetailedRayCastCallback implements RayCastCallback {
    public CollisionInfo closestHit;
    private Vector2 position;
    private Vector2 velocity;
    private String bodyId;

    public DetailedRayCastCallback(Vector2 position, Vector2 velocity, String bodyId) {
        this.position = new Vector2(position);
        this.velocity = new Vector2(velocity);
        this.bodyId = bodyId;
    }

    @Override
    public float reportRayFixture(Fixture fixture, Vector2 point, Vector2 normal, float fraction) {
        if (fixture.getBody().getUserData() == bodyId) return -1;
        Vector2 reflectedVelocity = calculateReflection(velocity, normal);

        closestHit = new CollisionInfo(position, point, normal, reflectedVelocity, fixture);

        return fraction;
    }
}

private static class CollisionInfo {
    public Vector2 initialPoint;
    public Vector2 collisionPoint;
    public Vector2 surfaceNormal;
    public Vector2 reflectedVector;

    public CollisionInfo(Vector2 initial, Vector2 collision, Vector2 normal, Vector2 reflected, Fixture hitFixture) {
        this.initialPoint = new Vector2(initial);
        this.collisionPoint = new Vector2(collision);
        this.surfaceNormal = new Vector2(normal);
        this.reflectedVector = new Vector2(reflected);
    }
}

private static Vector2 calculateReflection(Vector2 incomingVector, Vector2 surfaceNormal) {
    float dotProduct = incomingVector.dot(surfaceNormal);
    Vector2 reflection = new Vector2(incomingVector).sub(surfaceNormal.scl(2 * dotProduct));
    return reflection;
}

debugBallNextCollisions(game, world, viewport, ball.body, WORLD_HEIGHT, 5);


Solution

  • Using ray-casts is problematic as you can end up in scenarios where, even if you use multiple rays, the prediction is wrong.

    Consider this diagram,

    collision diagram

    The tan-circle is the ball, it is moving from left to right. The blue box is a n obstacle, and the three black arrows are the rays cast to find the next collision. In this scenario the wrong result is aquired as two of the rays completely miss the obstacle, and the one that does intersect shows a collision with the left face, rather than the corner. This means the reflected vector is not reflected around the normal of the face (as would normally be the case), but rather along the vector going from the corner through the circle center.

    Also, the collisions reported by Box2D ray-casts will be at the intersection between the ray and the obstacle, but since you are trying to predict the path of a ball you need to also calculate how far back along the path the circle actually collides with the wall, a distance that is at least the radius of the circle.

    And this is just one edge-case to handle.

    Another approach that might work for you (depending on how complex your scene is) could be to brute force it by simply simulating stepping forward the ball in small increments and checking for circle-line intersections,

    collision prediction animation

    In the simulation above I step forward along the direction of the ball in 0.1f steps, and if one of the steps is a collision, I reflect out of it and iterate again, for a maximum depth of 4 collisions.

    This approach is obviously somewhat computational heavy, but it can be combined with ray-casting for optimization if required.

    The full, completely not optimized, code for the animation above is:

    package com.bornander.sb3d;
    
    import com.badlogic.gdx.ApplicationAdapter;
    import com.badlogic.gdx.Gdx;
    import com.badlogic.gdx.graphics.*;
    import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
    import com.badlogic.gdx.math.*;
    import com.badlogic.gdx.physics.box2d.*;
    import com.badlogic.gdx.utils.Array;
    import com.badlogic.gdx.utils.ScreenUtils;
    
    public class SandboxGame extends ApplicationAdapter {
    
    
        private static class Segment
        {
            public Vector2 a = new Vector2();
            public Vector2 b = new Vector2();
    
            public Vector2 normal = new Vector2();
    
            public Fixture fixture;
    
            public Segment(Vector2 a, Vector2 b, Fixture fixture) {
                this.a.set(a);
                this.b.set(b);
                this.fixture = fixture;
    
                normal.set(b).sub(a).nor();
                normal.set(-normal.y, normal.x);
    
            }
        }
    
        private static class Prediction
        {
            private Array<Segment> segments = new Array<>();
            public Circle collisionPoint = new Circle();
    
            private Array<Circle> tests = new Array<>();
    
            public Array<Vector2> collisions = new Array<>();
    
    
            public void clear() {
                segments.clear();
                tests.clear();
                collisions.clear();
            }
    
    
            public void add(Circle test) {
                tests.add(new Circle(test));
            }
            public void render(ShapeRenderer shapeRenderer) {
                shapeRenderer.setColor(Color.GREEN);
                for(Segment segment : segments)
                    shapeRenderer.line(segment.a, segment.b);
    
                shapeRenderer.setColor(Color.DARK_GRAY);
    
                //for(Circle test : tests)
                //    shapeRenderer.circle(test.x, test.y, test.radius);
    
                shapeRenderer.setColor(Color.MAGENTA);
                for(int i = 0; i < collisions.size - 1; ++i) {
                    shapeRenderer.line(collisions.get(i), collisions.get(i+1));
                }
    
                shapeRenderer.setColor(Color.PURPLE);
                shapeRenderer.circle(collisionPoint.x, collisionPoint.y, collisionPoint.radius);
            }
        }
    
    
        private OrthographicCamera camera;
        private ShapeRenderer shapeRenderer;
        private World world;
        private Box2DDebugRenderer debugRenderer;
    
        private Body block;
        private float blockSpeed = 20;
    
        private float ballRadius = 2.0f;
        private Body ball;
    
        private Prediction prediction = new Prediction();
    
    
        @Override
        public void create() {
            float aspectRatio = Gdx.graphics.getHeight() / (float)Gdx.graphics.getWidth();
            float w = 100.0f;
    
            camera = new OrthographicCamera(w, w * aspectRatio);
            camera.position.set(Vector3.Zero);
    
            shapeRenderer = new ShapeRenderer();
    
            world = new World(Vector2.Zero, false);
            debugRenderer = new Box2DDebugRenderer();
    
            makeWall(-40, 0, 0);
            makeWall(40, 0, 0);
            makeWall(0, -30, 90 * MathUtils.degreesToRadians);
            makeWall(0, 30, 90 * MathUtils.degreesToRadians);
    
            block = makeBlock(0, -24, 90 * MathUtils.degreesToRadians);
            ball = makeBall(0, 0, 10, 5);
        }
    
        private void makeWall(float x, float y, float angle) {
            float w = 1.0f;
            float h = 35.0f;
            PolygonShape wallShape = new PolygonShape();
            wallShape.setAsBox(1, 35);
            FixtureDef fd = new FixtureDef();
            fd.shape = wallShape;
            fd.friction = 0.5f;
            fd.restitution = 1.0f;
    
            // Associate some Polygons with the fixtures, as they are easy to use
            Polygon polygon = new Polygon(new float[8]);
            polygon.setVertex(0, -w, -h);
            polygon.setVertex(1,  w, -h);
            polygon.setVertex(2,  w,  h);
            polygon.setVertex(3, -w,  h);
    
    
    
            BodyDef bd = new BodyDef();
            bd.type = BodyDef.BodyType.StaticBody;
    
            Body b = world.createBody(bd);
            Fixture f = b.createFixture(fd);
            f.setUserData(polygon);
            b.setTransform(x, y, angle);
    
            wallShape.dispose();
        }
    
        private  Body makeBlock(float x, float y, float angle) {
            float w = 1.0f;
            float h = 5.0f;
            PolygonShape blockShape = new PolygonShape();
            blockShape.setAsBox(w, h);
            FixtureDef fd = new FixtureDef();
            fd.shape = blockShape;
            fd.friction = 0.5f;
            fd.restitution = 1.0f;
    
            // Associate some Polygons with the fixtures, as they are easy to use
            Polygon polygon = new Polygon(new float[8]);
            polygon.setVertex(0, -w, -h);
            polygon.setVertex(1,  w, -h);
            polygon.setVertex(2,  w,  h);
            polygon.setVertex(3, -w,  h);
    
            BodyDef bd = new BodyDef();
            bd.type = BodyDef.BodyType.KinematicBody;
    
            Body b = world.createBody(bd);
            Fixture f = b.createFixture(fd);
            f.setUserData(polygon);
            b.setTransform(x, y, angle);
    
            b.setLinearVelocity(blockSpeed, 0);
    
            blockShape.dispose();
    
            return b;
        }
    
        private Body makeBall(float x, float y, float vx, float vy) {
            CircleShape circleShape = new CircleShape();
            circleShape.setRadius(ballRadius);
            FixtureDef fd = new FixtureDef();
            fd.shape = circleShape;
            fd.friction = 0.5f;
            fd.restitution = 1.0f;
            fd.density = 2.0f;
            BodyDef bd = new BodyDef();
            bd.type = BodyDef.BodyType.DynamicBody;
    
            Body b = world.createBody(bd);
            b.createFixture(fd);
            b.setTransform(x, y, 0);
    
            b.setLinearVelocity(vx, vy);
    
            circleShape.dispose();
    
            return b;
        }
    
        @Override
        public void render() {
            world.step(Gdx.graphics.getDeltaTime(), 8, 8);
            Array<Polygon> polygons = new Array<>();
            Array<Fixture> fixtures = new Array<>();
            world.getFixtures(fixtures);
            for(Fixture fixture : fixtures) {
                if (fixture.getUserData() instanceof Polygon) {
                    Polygon polygon = (Polygon)fixture.getUserData();
                    polygon.setPosition(fixture.getBody().getPosition().x, fixture.getBody().getPosition().y);
                    polygon.setRotation(fixture.getBody().getAngle() * MathUtils.radiansToDegrees);
                    polygons.add(polygon);
                }
            }
    
            prediction.clear();
            predict(ball.getWorldCenter(), ball.getLinearVelocity());
            if (block != null) {
                if (block.getLinearVelocity().x > 0 && block.getWorldCenter().x > 40)
                    block.setLinearVelocity(-blockSpeed, 0);
    
                if (block.getLinearVelocity().x < 0 && block.getWorldCenter().x < -40)
                    block.setLinearVelocity(blockSpeed, 0);
            }
            camera.update();
            ScreenUtils.clear(Color.BLACK);
            shapeRenderer.setProjectionMatrix(camera.combined);
            shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
            renderGrid();
            debugRenderer.render(world, camera.combined);
    
            renderBallPredictedPath();
    
            shapeRenderer.setColor(Color.YELLOW);
            for(Polygon polygon : polygons) {
                shapeRenderer.polygon(polygon.getTransformedVertices());
            }
            prediction.render(shapeRenderer);
            shapeRenderer.end();
    
    
        }
    
    
        private void renderGrid() {
            shapeRenderer.setColor(Color.DARK_GRAY);
            for(int x = -50; x <= 50; x += 5) {
                shapeRenderer.setColor(x == 0 ? Color.LIGHT_GRAY : Color.DARK_GRAY);
                shapeRenderer.line(x, -50, x, 50);
            }
    
            for(int y = -50; y <= 50; y += 5) {
                shapeRenderer.setColor(y == 0 ? Color.LIGHT_GRAY : Color.DARK_GRAY);
                shapeRenderer.line(-50, y, 50, y);
            }
        }
    
        private void predict(Vector2 position, Vector2 direction) {
    
    
            float stepSize = 0.1f;
            Vector2 a = new Vector2(position);
            Vector2 b = new Vector2(direction);
            b.nor().scl(stepSize);
    
            prediction.collisions.add(new Vector2(a.x, a.y));
    
            Array<Fixture> fixtures = new Array<>();
            Array<Segment> segments = new Array<>();
            world.getFixtures(fixtures);
            for(Fixture fixture : fixtures) {
                if (fixture.getUserData() instanceof Polygon) {
                    Polygon polygon = (Polygon)fixture.getUserData();
                    polygon.setPosition(fixture.getBody().getPosition().x, fixture.getBody().getPosition().y);
                    polygon.setRotation(fixture.getBody().getAngle() * MathUtils.radiansToDegrees);
    
                    float[] vertices = polygon.getTransformedVertices();
                    int vc = vertices.length;
                    for (int j = 0; j < vertices.length; j += 2) {
                        int i0 = (j + 0) % vc;
                        int i1 = (j + 1) % vc;
                        int i2 = (j + 2) % vc;
                        int i3 = (j + 3) % vc;
    
    
                        Vector2 sa = new Vector2(vertices[i0], vertices[i1]);
                        Vector2 sb = new Vector2(vertices[i2], vertices[i3]);
                        segments.add(new Segment(sa, sb, fixture));
                    }
                }
            }
    
    
    
            Intersector.MinimumTranslationVector mtv = new Intersector.MinimumTranslationVector();
    
    
            Fixture previousFixture = null;
            for(int iteration = 0; iteration < 5; ++iteration) {
                Circle circle = new Circle(0, 0, ballRadius);
                boolean found = false;
    
    
                for (float i = 0; i < 1000.0f; i += stepSize) {
                    circle.setPosition(a.x + b.x * i, a.y + b.y * i);
                    prediction.add(circle);
                    for (Segment segment : segments) {
                        if (b.dot(segment.normal) < MathUtils.PI)
                        {
                            if (segment.fixture != previousFixture && Intersector.intersectSegmentCircle(segment.a, segment.b, circle, mtv)) {
                                prediction.collisionPoint.set(circle);
                                prediction.collisions.add(new Vector2(circle.x, circle.y));
                                found = true;
                                a.set(circle.x, circle.y);
    
    
                                Vector2 reflection = new Vector2();
                                Vector2 n = new Vector2(segment.normal);
                                float dn2 = b.dot(segment.normal) * 2.0f;
                                reflection.set(b).sub(n.scl(dn2)).nor();
    
                                b.set(reflection);
                                previousFixture = segment.fixture;
                                break;
                            }
                        }
                    }
                    if (found)
                        break;
                }
            }
        }
    
        private void renderBallPredictedPath() {
            Vector2 position = new Vector2();
            Vector2 velocity = new Vector2();
            Vector2 a = new Vector2();
            Vector2 b = new Vector2();
    
            position.set(ball.getWorldCenter());
            velocity.set(ball.getLinearVelocity());
    
            a.set(position);
            b.set(a).add(velocity);
    
            shapeRenderer.setColor(Color.RED);
            shapeRenderer.line(a, b);
        }
    }