Monday, November 18, 2013 Eric Richards

A Terrain Minimap with SlimDX and DirectX 11

Minimaps are a common feature of many different types of games, especially those in which the game world is larger than the area the player can see on screen at once.  Generally, a minimap allows the player to keep track of where they are in the larger game world, and in many games, particularly strategy and simulation games where the view camera is not tied to any specific player character, allow the player to move their viewing location more quickly than by using the direct camera controls.  Often, a minimap will also provide a high-level view of unit movement, building locations, fog-of-war and other game specific information.

Today, we will look at implementing a minimap that will show us a birds-eye view of the our Terrain class.  We’ll also superimpose the frustum for our main rendering camera over the terrain, so that we can easily see how much of the terrain is in view.  We’ll also support moving our viewpoint by clicking on the minimap.  All of this functionality will be wrapped up into a class, so that we can render multiple minimaps, and place them wherever we like within our application window.

As always, the full code for this example can be downloaded from GitHub, at https://github.com/ericrrichards/dx11.git.  The relevant project is the Minimap project.  The implementation of this minimap code was largely inspired by Chapter 11 of Carl Granberg’s Programming an RTS Game with Direct3D, particularly the camera frustum drawing code.  If you can find a copy (it appears to be out of print, and copies are going for outrageous prices on Amazon…), I would highly recommend grabbing it.

image

OrthoCamera

To render our minimap to a texture, we will need to create a camera which uses an orthographic projection.  Our previous two camera types, the FpsCamera and the LookAtCamera, have used perspective projections.  In a perspective projection, all of the viewing rays originate from a point behind the near-plane of the frustum, which results in the truncated pyramid viewing frustum we are familiar with.  In an orthographic projection, the view rays do not have a single focus, and instead are all parallel to each other, which results in a viewing frustum which is shaped like a rectangular prism.  One of the results of using an orthographic projection is that perceived sizes of objects do not depend on the distance from the camera, as they do with a perspective projection.  If you’ve ever tried to assemble a piece of furniture using the instruction schematics, you are probably familiar with one of the flavors of orthographic projections.

image

Because we already have a method in our Terrain class to render the terrain from the viewpoint of a CameraBase camera, we will create a new orthographic projection camera, so that we can simply plug it into our Terrain.Draw() function.  This new OrthoCamera class is going to be very basic, at the moment, since we will only be using it to draw the minimap.  Therefore, although we need to provide implementations for our update functions (Strafe, Walk, Pitch, Yaw, Zoom, LookAt), these implementations won’t do anything. 

public class OrthoCamera : CameraBase {
    public Vector3 Target { get; set; }

    public OrthoCamera() {
        Target = new Vector3();
        Up = new Vector3(0, 0, 1);
        Look = new Vector3(0, -1, 0);            
    }
    public override void UpdateViewMatrix() {
        View = Matrix.LookAtLH(Position, Target, new Vector3(0, 0, 1));

        _frustum = Frustum.FromViewProj(ViewProj);
    }

    public override void SetLens(float width, float height, float znear, float zfar) {
        Proj = Matrix.OrthoLH(width, height, 0.1f, 2000);
        UpdateViewMatrix();
    }
    public override void LookAt(Vector3 pos, Vector3 target, Vector3 up) { }
    public override void Strafe(float d) { }
    public override void Walk(float d) { }
    public override void Pitch(float angle) { }
    public override void Yaw(float angle) { }
    public override void Zoom(float dr) { }
}

We will give our OrthoCamera a target position vector, so that we can use the LookAtLH() matrix function to easily create a view matrix in our UpdateViewMatrix() function. Creating the orthographic projection matrix in our SetLens() function will be performed a little differently than the base function in CameraBase. Because we are replacing the base class function with a new implementation in OrthoCamera, we need to add the override modifier to SetLens(). While the PerspectiveFovLH() method requires a field-of-view angle and the screen aspect ratio, OrthoLH() requires the screen width and height instead. The final two parameters, for the near and far z-distances, remain the same for both functions.

Minimap Class

public class Minimap :DisposableClass {
    // DeviceContext to render with
    private readonly DeviceContext _dc;

    // RenderTarget and ShaderResource views of the minimap texture
    private RenderTargetView _minimapRTV;
    public ShaderResourceView MinimapSRV { get; private set; }

    // viewport to match the minimap texture size
    private readonly Viewport _minimapViewport;
    // reference to the terrain that we render in the minimap
    private readonly Terrain _terrain;

    // ortho camera for rendering the minimap
    private readonly OrthoCamera _orthoCamera = new OrthoCamera();
    // reference to the normal view camera, so we can render the view frustum on the minimap
    private readonly CameraBase _viewCam;

    // vertex buffer to hold the view camera frustum points
    private Buffer _frustumVB;
    // array of planes defining the "box" that surrounds the terrain
    private readonly Plane[] _edgePlanes;
    private bool _disposed;

    // position and size of the minimap when rendered to our backbuffer
    // these are defined in percentages of the full backbuffer dimensions, to scale when the screen size changes
    public Vector2 ScreenPosition { get; set; }
    public Vector2 Size { get; set; }
}

Creating the Minimap

Our Minimap class consists of a texture, with render target and shader resource views, a DirectX viewport that matches the dimensions of that texture render target, and an orthographic camera, which we will use to render the terrain to texture.  We also need a reference to the DirectX graphics DeviceContext, and a reference to the Terrain object that we will be rendering in the minimap.  To render the viewing frustum that shows where the main camera is located within the terrain, we need a reference to the main view camera, a vertex buffer to store the vertices of the rectangle that we will draw to show the frustum extents, and a set of planes that define the edges of the terrain in the XZ plane, which we will use to clip the view frustum if it extends outside the terrain bounds.  We also have two 2D vectors which determine the on-screen size and position of the minimap, in percentages of the screen size, which allows us to render the minimap as part of our application UI.

To create a minimap object, we need to pass in references to the DirectX Device, DeviceContext, the Terrain, and our main viewing camera.  We will also pass in the dimensions of the minimap texture that we wish to create.  Our first step is to create a DirectX viewport which matches the minimap texture dimensions.  Next, we create the minimap texture, along with the RenderTargetView that we will draw to, and the ShaderResourceView that we will use as input when we render the minimap to the backbuffer.  Next, we setup our orthographic camera, so that it is centered above our terrain, looking straight down, and encompassing the full extents of the terrain, but no more.  Then, we create the vertex buffer that will contain the points that define the main camera view frustum, and create the array of planes that define the edges of the terrain bounds.  Finally, we set a default screen position and size for rendering the minimap to the backbuffer, which in this case places the minimap on the bottom of our screen, next to the normal/depth buffer debug texture, at 1/16th of the full screen size.

public Minimap(Device device, DeviceContext dc, int minimapWidth, int minimapHeight, Terrain terrain, CameraBase viewCam) {
    _dc = dc;

    _minimapViewport = new Viewport(0, 0, minimapWidth, minimapHeight);

    CreateMinimapTextureViews(device, minimapWidth, minimapHeight);

    _terrain = terrain;

    SetupOrthoCamera();
    _viewCam = viewCam;

    // frustum vb will contain four corners of view frustum, with first vertex repeated as the last
    var vbd = new BufferDescription(
        VertexPC.Stride * 5, 
        ResourceUsage.Dynamic, 
        BindFlags.VertexBuffer, 
        CpuAccessFlags.Write, 
        ResourceOptionFlags.None, 
        0
    );
    _frustumVB = new Buffer(device, vbd);

    _edgePlanes = new[] {
        new Plane(1, 0, 0, -_terrain.Width / 2),
        new Plane(-1, 0, 0, _terrain.Width / 2),
        new Plane(0, 1, 0, -_terrain.Depth / 2),
        new Plane(0, -1, 0, _terrain.Depth / 2)
    };

    ScreenPosition = new Vector2( 0.25f, 0.75f);
    Size = new Vector2(0.25f, 0.25f);
}
private void SetupOrthoCamera() {
    _orthoCamera.Target = new Vector3(0, 0, 0);
    _orthoCamera.Position = new Vector3(0, _terrain.Depth, 0);
    _orthoCamera.SetLens(_terrain.Width, _terrain.Depth, 1, _terrain.Depth*2);
    _orthoCamera.UpdateViewMatrix();
}

private void CreateMinimapTextureViews(Device device, int minimapWidth, int minimapHeight) {
    var texDesc = new Texture2DDescription {
        ArraySize = 1,
        BindFlags = BindFlags.RenderTarget | BindFlags.ShaderResource,
        CpuAccessFlags = CpuAccessFlags.None,
        Format = Format.R8G8B8A8_UNorm,
        Width = minimapWidth,
        Height = minimapHeight,
        MipLevels = 1,
        OptionFlags = ResourceOptionFlags.None,
        SampleDescription = new SampleDescription(1, 0),
        Usage = ResourceUsage.Default
    };

    var tex = new Texture2D(device, texDesc);

    _minimapRTV = new RenderTargetView(device, tex);
    MinimapSRV = new ShaderResourceView(device, tex);
    Util.ReleaseCom(ref tex);
}

Disposing of the Minimap

Since our Minimap class controls unmanaged DirectX resources, we need to derive it from our DisposableClass base class and provide a Dispose() method to release those resources.  In this case, we dispose of our texture views and the vertex buffer for the main camera view frustum.

protected override void Dispose(bool disposing) {
    if (!_disposed) {
        if (disposing) {
            Util.ReleaseCom(ref _frustumVB);
            Util.ReleaseCom(ref _minimapRTV);
            var shaderResourceView = MinimapSRV;
            Util.ReleaseCom(ref shaderResourceView);
        }
        _disposed = true;
    }
    base.Dispose(disposing);
}

Rendering the Terrain to the Minimap Texture

Rendering the terrain to the minimap texture is relatively straightforward.  First, we set the minimap texture as the DeviceContext’s render target, using a null Depth/Stencil buffer, as we will be using a Painter’s Algorithm approach and drawing any minimap overlays in front-to-back order.  Next, we need to set the minimap viewport, and clear the render target.  Finally, we can use the normal Terrain Draw() function to render the terrain, using our OrthoCamera instead of the main view camera.

public void RenderMinimap(DirectionalLight[] lights) {
    _dc.OutputMerger.SetTargets((DepthStencilView)null, _minimapRTV);
    _dc.Rasterizer.SetViewports(_minimapViewport);

    _dc.ClearRenderTargetView(_minimapRTV, Color.White);
    _terrain.Draw(_dc, _orthoCamera, lights);

    DrawCameraFrustum();
}

Drawing the Main View Camera Frustum

Drawing the view camera frustum on the minimap is a little complicated.  In the old days, most games only had a fixed viewing angle for their main camera, so it was easy to map the viewing volume to the minimap.  Where our camera is more independent, we need to cast rays from our camera position through the four corners of our frustum near plane, and intersect those rays with the terrain to determine what is in view.  Since intersecting the terrain is fairly expensive, and this will need to be performed every frame ( or at least whenever the camera orientation and position changes), we will take a little shortcut and just intersect the XZ ground plane.  At some camera angles, a ray shot through a near-plane corner will not intersect the ground plane in front of the near plane at all; in those cases, we need to intersect the boundary planes that we setup at the edges of our terrain.  Once we have determined the bounds of the view frustum, we need to update the frustum vertex buffer with these positions.  We will use a simple position-color shader to draw these points, and we inform the GPU that they are structured as a line strip, so that it will know to render the points as lines like (p0-p1, p1-p2, … p3-p0), without needing to create an index buffer. 

private void DrawCameraFrustum() {
    var view = _viewCam.View;

    var fovX = _viewCam.FovX;
    var fovY = _viewCam.FovY;
    // world-space camera position
    var org = _viewCam.Position;
    
    // vectors pointed towards the corners of the near plane of the view frustum
    var dirs = new[] {
        new Vector3(fovX, fovY, 1.0f),
        new Vector3(-fovX, fovY, 1.0f),
        new Vector3(-fovX, -fovY, 1.0f),
        new Vector3(fovX, -fovY, 1.0f)
    };
    var points = new Vector3[4];

    // view-to-world transform
    var invView = Matrix.Invert(view);

    // XZ plane
    var groundPlane = new Plane(new Vector3(), new Vector3(0, 1, 0));

    var ok = true;
    for (var i = 0; i < 4 && ok; i++) {
        // transform the view-space vector into world-space
        dirs[i] = Vector3.Normalize(Vector3.TransformNormal(dirs[i], invView));
        // extend the near-plane vectors into very far away points
        dirs[i] *= 100000.0f;

        Vector3 hit;
        // check if the ray between the camera origin and the far point intersects the ground plane
        if (!Plane.Intersects(groundPlane, org, dirs[i], out hit)) {
            ok = false;
        }
        // make sure that the intersection is on the positive side of the frustum near plane
        var n = _viewCam.FrustumPlanes[Frustum.Near];
        var d = n.Normal.X * hit.X + n.Normal.Y * hit.Y + n.Normal.Z * hit.Z + n.D;
        if (d < 0.0f) {
            ok = false;
            // if we're here, the ray was pointing away from the ground
            // so we will instead intersect the ray with the terrain boundary planes
            foreach (var edgePlane in _edgePlanes) {
                if (!Plane.Intersects(edgePlane, org, dirs[i], out hit)) {
                    continue;
                }
                d = n.Normal.X * hit.X + n.Normal.Y * hit.Y + n.Normal.Z * hit.Z + n.D;
                if (!(d >= 0.0f)) {
                    continue;
                }
                // bump out the intersection point, so that if we're looking into the corners, the
                // frustum doesn't show that we shouldn't be able to see terrain that we can see
                hit *= 2;
                ok = true;
                break;
            }
        }
        points[i] = new Vector3(Math.Min(Math.Max(hit.X, -float.MaxValue), float.MaxValue), 0, Math.Min(Math.Max(hit.Z, -float.MaxValue), float.MaxValue));
    }

    if (!ok) {
        return;
    }

    // update the frustum vertex buffer
    var buf = _dc.MapSubresource(_frustumVB, MapMode.WriteDiscard, MapFlags.None);

    buf.Data.Write(new VertexPC(points[0], Color.White));
    buf.Data.Write(new VertexPC(points[1], Color.White));
    buf.Data.Write(new VertexPC(points[2], Color.White));
    buf.Data.Write(new VertexPC(points[3], Color.White));
    // include the first point twice, to complete the quad when we render as a linestrip
    buf.Data.Write(new VertexPC(points[0], Color.White));

    _dc.UnmapSubresource(_frustumVB, 0);

    _dc.InputAssembler.InputLayout = InputLayouts.PosColor;
    _dc.InputAssembler.PrimitiveTopology = PrimitiveTopology.LineStrip;
    _dc.InputAssembler.SetVertexBuffers(0, new VertexBufferBinding(_frustumVB, VertexPC.Stride, 0));
    // draw the frustum with a basic position-color shader
    for (var i = 0; i < Effects.ColorFX.ColorTech.Description.PassCount; i++) {
        Effects.ColorFX.SetWorldViewProj(_orthoCamera.ViewProj);
        Effects.ColorFX.ColorTech.GetPassByIndex(i).Apply(_dc);
        _dc.Draw(5, 0);
    }
}

Drawing the Minimap to the Backbuffer

Drawing the minimap to the screen is very similar to the way that we drew the debug textures that show our SSAO normal/depth map, occlusion map, and the shadow mapping depth buffer.  In those cases, we created special functions in our application class to draw the respective textures at hard-coded locations on screen.  For our minimap, we will want a more reusable solution.

Our Minimap class contains ScreenPosition and Size vectors, which define its location and size on screen.  These use a scale from [0,1] [0,1], where (0,0) is the top-left corner of the screen, and (1,1) is the bottom right.  In NDC coordinates, which DirectX uses unless we specify otherwise, the top-left corner of the screen is (-1, 1) and the bottom right is (1, –1), so we need to apply a scaling and a translation to correctly display the minimap on the screen.  If you are unfamiliar with transformation matrices, the main axis (M11, M22, M33, M44) define the scaling in the X, Y, Z and W directions (M44 should always be 1.0).  In a row-major matrix system like DirectX, the bottom row of the matrix (M41, M42, M43) defines the translation in X, Y and Z.

Since it proved to be quite useful, I have moved the full-screen quad vertex and index buffers into the base D3DApp class, and provided public read access to them, so that we can draw a quad from anywhere in our code using the global static reference to the application.

public void Draw(DeviceContext dc) {
    var stride = Basic32.Stride;
    const int Offset = 0;

    dc.InputAssembler.InputLayout = InputLayouts.Basic32;
    dc.InputAssembler.PrimitiveTopology = PrimitiveTopology.TriangleList;
    dc.InputAssembler.SetVertexBuffers(0, new VertexBufferBinding(D3DApp.GD3DApp.ScreenQuadVB, stride, Offset));
    dc.InputAssembler.SetIndexBuffer(D3DApp.GD3DApp.ScreenQuadIB, Format.R32_UInt, 0);

    // transform from our [0,1] screen cordinates to NDC
    var world = new Matrix {
        M11 = Size.X,
        M22 = Size.Y,
        M33 = 1.0f,
        M41 = -1.0f + 2 * ScreenPosition.X + (Size.X),
        M42 = 1.0f - 2 * ScreenPosition.Y - (Size.Y),
        M44 = 1.0f
    };

    var tech = Effects.DebugTexFX.ViewArgbTech;
    for (int p = 0; p < tech.Description.PassCount; p++) {
        Effects.DebugTexFX.SetWorldViewProj(world);
        Effects.DebugTexFX.SetTexture(MinimapSRV);
        tech.GetPassByIndex(p).Apply(dc);
        dc.DrawIndexed(6, 0, 0);
    }
}

Moving the View Camera via the Minimap

In addition to showing an overview of the terrain, we can use the minimap to move around the terrain.  In our application OnMouseDown() event handler, we will add a check to determine if the user clicks within the screen bounds of the minimap.  As part of this check, we will convert the input position, which is defined in [0,1] screen-space, into minimap-space, which we can then easily use to set the camera target position in world space.  We can also easily implement camera moving by clicking and dragging on the minimap, by adding some similar code to our OnMouseMove event handler.  This lets us move around the terrain very quickly and easily, as you can see in the YouTube clip below.

// MinimapDemo
protected override void OnMouseDown(object sender, MouseEventArgs mouseEventArgs) {
    var x = (float)mouseEventArgs.X / Window.ClientSize.Width;
    var y = (float)mouseEventArgs.Y / Window.ClientSize.Height;
    var p = new Vector2(x, y);
    if (_minimap.Contains(ref p)) {
        var terrainX = _terrain.Width * p.X - _terrain.Width/2;
        var terrainZ = -_terrain.Depth * p.Y + _terrain.Depth/2;
        _camera.Target = new Vector3(terrainX, _terrain.Height(terrainX, terrainZ), terrainZ );
        return;
    }

    _lastMousePos = mouseEventArgs.Location;
    Window.Capture = true;
}
protected override void OnMouseMove(object sender, MouseEventArgs e) {
    var x = (float)e.X / Window.ClientSize.Width;
    var y = (float)e.Y / Window.ClientSize.Height;
    var p = new Vector2(x, y);
    if (e.Button == MouseButtons.Left && _minimap.Contains(ref p)) {
        var terrainX = _terrain.Width * p.X - _terrain.Width / 2;
        var terrainZ = -_terrain.Depth * p.Y + _terrain.Depth / 2;
        _camera.Target = new Vector3(terrainX, _terrain.Height(terrainX, terrainZ), terrainZ);
        return;
    }
    if (e.Button == MouseButtons.Left) {
        var dx = MathF.ToRadians(0.25f * (e.X - _lastMousePos.X));
        var dy = MathF.ToRadians(0.25f * (e.Y - _lastMousePos.Y));

        _camera.Pitch(dy);
        _camera.Yaw(dx);

    }
    _lastMousePos = e.Location;
}

// Minimap
public bool Contains(ref Vector2 p) {
    // check if position is within minimap screen bounds
    if (p.X >= ScreenPosition.X && p.X <= ScreenPosition.X + Size.X &&
        p.Y >= ScreenPosition.Y && p.Y <= ScreenPosition.Y + Size.Y) {
        // convert screen-space to minimap-space
        p.X = (p.X - ScreenPosition.X) / Size.X;
        p.Y = (p.Y - ScreenPosition.Y) / Size.Y;

        return true;
    }
    return false;
}
Minimap implementation

Next Time…

Next time, we will look at how to perform mouse picking on our terrain.  To do this, we will implement a quad-tree of bounding boxes.  Once we have picking in place, we will be able to select areas on our terrain, which will be very important going forward, when we move on to moving units around and implementing pathfinding.  At that point, we’ll start to have something that really starts to look like a game.


Bookshelf

Hi, I'm Eric, and I'm a biblioholic. Here is a selection of my favorites. All proceeds go to feed my addiction...