Thursday, August 29, 2013

A Look-At Camera in SlimDX and Direct3D 11



Today, we are going to reprise our Camera class from the Camera Demo.  In addition to the FPS-style camera that we have already implemented, we will create a Look-At camera, a camera that remains focused on a point and pans around its target.  This camera will be similar to the very basic camera we implemented for our initial examples (see the Colored Cube Demo).  While our FPS camera is ideal for first-person type views, the Look-At camera can be used for third-person views, or the “birds-eye” view common in city-builder and strategy games.  As part of this process, we will abstract out the common functionality that all cameras will share from our FPS camera into an abstract base camera class.

The inspiration for this example come both from Mr. Luna’s Camera Demo (Chapter 14 of Frank Luna’s Introduction to 3D Game Programming with Direct3D 11.0), and the camera implemented in Chapter 5 of Carl Granberg’s Programming an RTS Game with Direct3D.  You can download the full source for this example from my GitHub repository at https://github.com/ericrrichards/dx11.git under the CameraDemo project.  To switch between the FPS and Look-At camera, use the F(ps) and L(ook-at) keys on your keyboard.

lookat



Extracting a Base Camera Class

When we examine our FPS camera, we can identify a number of methods and properties that will need to be shared by any camera classes that we create.  To allow us to interact with any type of camera, without regard to its internal implementation, we’ll extract these commonalities out into a base class that we can inherit for both our FPS and Look-At cameras.

public abstract class CameraBase {
    protected Frustum _frustum;
    public Vector3 Position { get; set; }
    public Vector3 Right { get; protected set; }
    public Vector3 Up { get; protected set; }
    public Vector3 Look { get; protected set; }
    public float NearZ { get; protected set; }
    public float FarZ { get; protected set; }
    public float Aspect { get; protected set; }
    public float FovY { get; protected set; }
    public float FovX {
        get {
            var halfWidth = 0.5f * NearWindowWidth;
            return 2.0f * MathF.Atan(halfWidth / NearZ);
        }
    }
    public float NearWindowWidth { get { return Aspect * NearWindowHeight; } }
    public float NearWindowHeight { get; protected set; }
    public float FarWindowWidth { get { return Aspect * FarWindowHeight; } }
    public float FarWindowHeight { get; protected set; }
    public Matrix View { get; protected set; }
    public Matrix Proj { get; protected set; }
    public Matrix ViewProj { get { return View * Proj; } }

    protected CameraBase() {
        Position = new Vector3();
        Right = new Vector3(1, 0, 0);
        Up = new Vector3(0, 1, 0);
        Look = new Vector3(0, 0, 1);

        View = Matrix.Identity;
        Proj = Matrix.Identity;
        SetLens(0.25f * MathF.PI, 1.0f, 1.0f, 1000.0f);
    }

    public abstract void LookAt(Vector3 pos, Vector3 target, Vector3 up);
    public abstract void Strafe(float d);
    public abstract void Walk(float d);
    public abstract void Pitch(float angle);
    public abstract void Yaw(float angle);
    public abstract void Zoom(float dr);
    public abstract void UpdateViewMatrix();

    public bool Visible(BoundingBox box) {
        return _frustum.Intersect(box) > 0;
    }

    public void SetLens(float fovY, float aspect, float zn, float zf) {
        FovY = fovY;
        Aspect = aspect;
        NearZ = zn;
        FarZ = zf;

        NearWindowHeight = 2.0f * NearZ * MathF.Tan(0.5f * FovY);
        FarWindowHeight = 2.0f * FarZ * MathF.Tan(0.5f * FovY);

        Proj = Matrix.PerspectiveFovLH(FovY, Aspect, NearZ, FarZ);
    }  
}

Since our camera movement operations will be different for the FPS and Look-at cameras, we make the LookAt, Strafe, Walk, Pitch and Yaw methods abstract, so that our derived classes will need to implement them.  In addition, we also add a Zoom method.  We also make the UpdateViewMatrix method abstract, as the two camera types will update their view matrices and view frustums in different ways.

Updated FPS Camera

With our base camera class defined, we need to update our FPS camera.  We will need to override the abstract methods from the CameraBase class using the appropriate FPS implementations.  Mostly, these methods are the same as in our previous incarnation.  Of note, however, is the new Zoom override; we implement a zoom feature for the FPS camera by narrowing or widening the field-of-view of the camera.  A smaller field of view results in magnification, while a larger fov effectively zooms out.  We need to be sure to clamp the fov values, as a FOV of 180 degrees or more will result in some very strange behavior.

public class FpsCamera : CameraBase {
    public override void LookAt(Vector3 pos, Vector3 target, Vector3 up) {
        Position = pos;
        Look = Vector3.Normalize(target - pos);
        Right = Vector3.Normalize(Vector3.Cross(up, Look));
        Up = Vector3.Cross(Look, Right);
    }

    public override void Strafe(float d) {
        Position += Right * d;
    }

    public override void Walk(float d) {
        Position += Look * d;
    }

    public override void Pitch(float angle) {
        var r = Matrix.RotationAxis(Right, angle);
        Up = Vector3.TransformNormal(Up, r);
        Look = Vector3.TransformNormal(Look, r);
    }

    public override void Yaw(float angle) {
        var r = Matrix.RotationY(angle);
        Right = Vector3.TransformNormal(Right, r);
        Up = Vector3.TransformNormal(Up, r);
        Look = Vector3.TransformNormal(Look, r);
    }
    public override void Zoom(float dr) {
        var newFov = MathF.Clamp(FovY + dr, 0.1f, MathF.PI / 2);
        SetLens(newFov, Aspect, NearZ, FarZ);
    }

    public override void UpdateViewMatrix() {
        var r = Right;
        var u = Up;
        var l = Look;
        var p = Position;

        l = Vector3.Normalize(l);
        u = Vector3.Normalize(Vector3.Cross(l, r));

        r = Vector3.Cross(u, l);

        var x = -Vector3.Dot(p, r);
        var y = -Vector3.Dot(p, u);
        var z = -Vector3.Dot(p, l);

        Right = r;
        Up = u;
        Look = l;

        var v = new Matrix();
        v[0, 0] = Right.X;
        v[1, 0] = Right.Y;
        v[2, 0] = Right.Z;
        v[3, 0] = x;

        v[0, 1] = Up.X;
        v[1, 1] = Up.Y;
        v[2, 1] = Up.Z;
        v[3, 1] = y;

        v[0, 2] = Look.X;
        v[1, 2] = Look.Y;
        v[2, 2] = Look.Z;
        v[3, 2] = z;

        v[0, 3] = v[1, 3] = v[2, 3] = 0;
        v[3, 3] = 1;

        View = v;

        _frustum = Frustum.FromViewProj(ViewProj);
    }
}

Look-At Camera

Our look-at camera is defined by a target position and a radius from that position, and a pair of angles that describe the position of the camera in spherical coordinates.  These angles are commonly referred to as theta and phi, altitude and azimuth, latitude and longitude, or beta and alpha.  We’ll use the alpha/beta terms, where the alpha angle is the angle from the XY plane, and beta is the angle from the XZ plane.  The diagram below illustrates how this works in 2D:

lookat-diagram

public class LookAtCamera  : CameraBase {
    private Vector3 _target;
    private float _radius;
    private float _alpha;
    private float _beta;

    public LookAtCamera() {
        _alpha = _beta = 0.5f;
        _radius = 10.0f;
        _target = new Vector3();

    }
}

From the target, radius and angles, we can determine the position of the camera using a little bit of trigonometry, as seen in our UpdateViewMatrix override.  Once we have calculated the position of the camera, we can calculate the view matrix using the SlimDX Matrix.LookAtLH function.  After we have calculated the view matrix, we can extract the camera’s right and look vectors from the matrix.

public override void UpdateViewMatrix() {
    var sideRadius = _radius*MathF.Cos(_beta);
    var height = _radius*MathF.Sin(_beta);

    Position = new Vector3(
        _target.X + sideRadius * MathF.Cos(_alpha), 
        _target.Y + height, 
        _target.Z + sideRadius * MathF.Sin(_alpha) 
    );

    View = Matrix.LookAtLH(Position, _target, Vector3.UnitY);

    Right = new Vector3(View.M11, View.M21, View.M31);
    Right.Normalize();

    Look = new Vector3(View.M13, View.M23, View.M33);
    Look.Normalize();
}

Movement with our Look-at camera is a little more complicated than our FPS camera, since we need to move the target position relative to the XZ components of the look and right vectors.  To get good-looking results, we need to make sure that we normalize the XZ vector, as otherwise we will get varying movement speed depending on the alpha and beta angles of the camera.  As currently implemented, the camera target is bound to the XZ plane; if we wanted to make our camera follow the terrain, as in most strategy games, or the character, as in a third-person camera, we would need to manually update the Y coordinate of the target after moving.

public override void Strafe(float d) {
    var dt = Vector3.Normalize(new Vector3(Right.X, 0, Right.Z)) * d;
    _target += dt;
}

public override void Walk(float d) {
    _target += Vector3.Normalize(new Vector3(Look.X, 0, Look.Z)) *d;
}

To pan our camera, we need to adjust the alpha and beta angles of the camera.  For best results, we will need to constrain the angles to a reasonable domain for spherical coordinates; for the alpha angle, this means that the angle should stay in the range [0, 2*Pi], and the beta angle should remain between [0, Pi/2].  Because the trigonometry gets a little wonky at the limits of that range for the beta angle, we further restrict the beta angle by a further epsilon value, so that the effective range is [0.05, Pi/2 – 0.01].

public override void Pitch(float angle) {
    _beta += angle;
    _beta = MathF.Clamp(_beta, 0.05f, MathF.PI/2.0f - 0.01f);
}

public override void Yaw(float angle) {
    _alpha = (_alpha + angle) % (MathF.PI*2.0f);
}

For zooming the look-at camera, we have two options.  We could use the same method as the FPS camera, and vary the FOV of the projection, or we can vary the radius while keeping the FOV constant, thus physically moving the camera closer.  I have chosen the second option; from my experiments, it seems to be six of one versus a half-dozen of the other, as far as the two methods relative effectiveness.  The radius method does allow somewhat finer control over the game-specific distance of the camera, so that, for instance, in a third-person perspective game, you could move the camera closer to the player character if the player would be occluded by some scene geometry more easily.

public override void Zoom(float dr) {
    _radius += dr;
    _radius = MathF.Clamp(_radius, 2.0f, 70.0f);
}

Using the Look-At Camera

Moving on to the implementation of our CameraDemo application, integrating the Look-at camera is very simple.  Because we are using the common CameraBase implementation to interact with our cameras, the code for the FPS and Look-At cameras is the same.  The only changes we need to make are to track which camera is active, and provide some hotkeys to switch between the camera implementations.  From our UpdateScene() function:

if (Util.IsKeyDown(Keys.L)) {
    _useFpsCamera = false;
}
if (Util.IsKeyDown(Keys.F)) {
    _useFpsCamera = true;
}

Voila!

Next Time…

Next time, we’ll get back into Mr. Luna’s examples, with Chapter 16, covering picking, where we will cover how to determine which object in a 3D scene corresponds with a pixel on screen.

No comments :

Post a Comment