Wednesday, March 7, 2018

OpenGL 4 with OpenTK in C# Part 15: Object picking by mouse


This time we will go through how to use the mouse to pick objects on the screen.

This is part 15 of my series on OpenGL4 with OpenTK.
For other posts in this series:
OpenGL 4 with OpenTK in C# Part 1: Initialize the GameWindow
OpenGL 4 with OpenTK in C# Part 2: Compiling shaders and linking them
OpenGL 4 with OpenTK in C# Part 3: Passing data to shaders
OpenGL 4 with OpenTK in C# Part 4: Refactoring and adding error handling
OpenGL 4 with OpenTK in C# Part 5: Buffers and Triangle
OpenGL 4 with OpenTK in C# Part 6: Rotations and Movement of objects
OpenGL 4 with OpenTK in C# Part 7: Vectors and Matrices
OpenGL 4 with OpenTK in C# Part 8: Drawing multiple objects
OpenGL 4 with OpenTK in C# Part 9: Texturing
OpenGL 4 with OpenTK in C# Part 10: Asteroid Invaders
Basic bullet movement patterns in Asteroid Invaders
OpenGL 4 with OpenTK in C# Part 11: Mipmap
OpenGL 4 with OpenTK in C# Part 12: Basic Moveable Camera
OpenGL 4 with OpenTK in C# Part 13: IcoSphere
OpenGL 4 with OpenTK in C# Part 14: Basic Text
OpenGL 4 with OpenTK in C# Part 15: Object picking by mouse

Introduction

As I've written in previous posts, I'm still not that good with all the math etc. writing these posts is a way for me to learn and hopefully someone else out there finds it useful as well. This time I found a great post that goes into the details of ray-casting to pick objects in 3D space over at Anton Gerdelan's blog: antongerdelan.net/opengl/raycasting.html

I've taken his base solution and made it work with the code-base that we use in this tutorial series. It did not work directly, so a lot of time was spent on what was going wrong.
So, now that credit is where it belongs, lets look at the code.

Code

First we want to calculate the ray from our camera location based on the mouse click on the screen. So first we need to subscribe to mouse events. So in our OnLoad method, we will add the following event handler.
MouseUp += OnMouseUp;

In our event handler we will check that it is the correct button and send the coordinates to the actual object picking method
private void OnMouseUp(object sender, MouseButtonEventArgs mouseButtonEventArgs)
{
    if (mouseButtonEventArgs.Button != MouseButton.Left)
        return;
    PickObjectOnScreen(mouseButtonEventArgs.X, mouseButtonEventArgs.Y);
}

This is the method that calculates the ray from the camera and into the screen. It takes the mouse coordinates and then backtracks the transformations back to world space. For more details, check antongerdelan.net/opengl/raycasting.html.
private void PickObjectOnScreen(int mouseX, int mouseY)
{
    // heavily influenced by: http://antongerdelan.net/opengl/raycasting.html
    // viewport coordinate system
    // normalized device coordinates
    var x = (2f * mouseX) / Width - 1f;
    var y = 1f - (2f * mouseY) / Height;
    var z = 1f;
    var rayNormalizedDeviceCoordinates = new Vector3(x, y, z);

    // 4D homogeneous clip coordinates
    var rayClip = new Vector4(rayNormalizedDeviceCoordinates.X, rayNormalizedDeviceCoordinates.Y, -1f, 1f);

    // 4D eye (camera) coordinates
    var rayEye = _projectionMatrix.Inverted() * rayClip;
    rayEye = new Vector4(rayEye.X, rayEye.Y, -1f, 0f);

    // 4D world coordinates
    var rayWorldCoordinates = (_camera.LookAtMatrix.Inverted() * rayEye).Xyz;
    rayWorldCoordinates.Normalize();
    FindClosestAsteroidHitByRay(rayWorldCoordinates);
}

After that we need to add a method that checks if the game object has been hit by the ray. I added this to the AGameObject class so that all game objects can be picked. Basically we begin by checking if the origin of the ray is inside the sphere that we are checking against. After that we check if the ray is pointing away from the object and lastly we perform Pythagorean method to determine if the ray is within the radius of the sphere that we are looking at. This ray method is something that I've had lying around for a while and used for various purposes.

public double? IntersectsRay(Vector3 rayDirection, Vector3 rayOrigin)
{
    var radius = _scale.X;
    var difference = Position.Xyz - rayDirection;
    var differenceLengthSquared = difference.LengthSquared;
    var sphereRadiusSquared = radius * radius;
    if (differenceLengthSquared < sphereRadiusSquared)
    {
        return 0d;
    }
    var distanceAlongRay = Vector3.Dot(rayDirection, difference);
    if (distanceAlongRay < 0)
    {
        return null;
    }
    var dist = sphereRadiusSquared + distanceAlongRay * distanceAlongRay - differenceLengthSquared;
    var result = (dist < 0) ? null : distanceAlongRay - (double?)Math.Sqrt(dist);
    return result;
}

The above method is called from the following that iterates all the objects currently active in the game and then tries to find the object that is closest (if there are many) to the origin of the ray.

private void FindClosestAsteroidHitByRay(Vector3 rayWorldCoordinates)
{
    AGameObject bestCandidate = null;
    double? bestDistance = null;
    foreach (var gameObject in _gameObjects)
    {
        if (!(gameObject is SelectableSphere) && !(gameObject is Asteroid))
            continue;
        var candidateDistance = gameObject.IntersectsRay(rayWorldCoordinates, _camera.Position);
        if (!candidateDistance.HasValue)
            continue;
        if (!bestDistance.HasValue)
        {
            bestDistance = candidateDistance;
            bestCandidate = gameObject;
            continue;
        }
        if (candidateDistance < bestDistance)
        {
            bestDistance = candidateDistance;
            bestCandidate = gameObject;
        }
    }
    if (bestCandidate != null)
    {
        switch (bestCandidate)
        {
            case Asteroid asteroid:
                _clicks += asteroid.Score;
                break;
            case SelectableSphere sphere:
                sphere.ToggleModel();
                break;
        }
    }
}

As you can see above, we have introduced a new GameObject for this part of the tutorial series, the SelectableSphere. It looks like the following:
public class SelectableSphere : AGameObject
{
    private ARenderable _original;
    private ARenderable _secondaryModel;
    public SelectableSphere(ARenderable model, ARenderable secondaryModel, Vector4 position, Vector4 direction, Vector4 rotation)
        : base(model, position, direction, rotation, 0)
    {
        _original = model;
        _secondaryModel = secondaryModel;
    }


    public override void Update(double time, double delta)
    {
        _rotation.Y = (float) ((time + GameObjectNumber) * 0.5);
        var d = new Vector4(_rotation.X, _rotation.Y, 0, 0);
        d.Normalize();
        _direction = d;
        base.Update(time, delta);
    }

    public void ToggleModel()
    {
        if (_model == _original)
            _model = _secondaryModel;
        else
            _model = _original;
    }
}
Basically an endlessly rotating sphere that will toggle its model when selected.

And lastly we generate these spheres in 2 layers in the OnLoad method in our MainWindow to have stuff on screen that can be selected.
var maxX = 2.5f;
var maxY = 1.5f;
for (float x = -maxX; x < maxX; x += 0.5f)
{
    for (float y = -maxY; y < maxY; y += 0.5f)
    {
        _gameObjects.Add(_gameObjectFactory.CreateSelectableSphere(new Vector4(x, y, -5f, 0)));
    }
}
for (float x = -maxX; x < maxX; x += 0.65f)
{
    for (float y = -maxY; y < maxY; y += 0.65f)
    {
        _gameObjects.Add(_gameObjectFactory.CreateSelectableSphereSecondary(new Vector4(x, y, -6f, 0)));
    }
}


End results


For the complete source code for the tutorial at the end of this part, go to: https://github.com/eowind/dreamstatecoding

So there, thank you for reading. Hope this helps someone out there : )

Until next time: Work to Live, Don’t Live to Work

No comments:

Post a Comment