Sunday, April 6, 2014

SharpDX Toolkit Tutorial 2: Game structure

In previous tutorial we have created a base game project which contains the bare minimum for a SharpDX Toolkit game to run. We will use this as a basis for next tutorials. In this post we will draw something more than an empty screen and will discuss the general structure of a game.

To make easier to follow these tutorial series I have created a repository which will contain all related code. Feel free to examine, download, play with it and contribute.

While the Game class is the central point of interaction of all components, keeping all functionality here makes it difficult to maintain, extend and reuse. To improve maintainability and allow easier integration of S.O.L.I.D. design principles the Toolkit library introduced the concept of GameSystem and Service.

A game system can encapsulate some reusable functionality like a particle system, camera management or AI logic. The important thing is that a game system should not be created for every entity in the game - as this would be a waste of resources and it will be difficult to maintain all relations between components.

A service represents, well, just a service in IoC terms. It allows easier separation of concerns and improved testability.

Let's try to apply this in practice. For this post we will draw a simple scene and we need to have independent camera control. To keep things simple, we will just rotate the camera around the scene. At this point we identified 2 main components - camera provider and scene renderer.

For improved maintainability, the game class will be responsible just for creating instances of the components and clearing the render target:

internal sealed class MyGame : Game
{
    private readonly GraphicsDeviceManager _graphicsDeviceManager;
    private readonly SceneRenderer _sceneRenderer;
    private readonly CameraProvider _cameraProvider;

    public MyGame()
    {
        _graphicsDeviceManager = new GraphicsDeviceManager(this);
        _sceneRenderer = new SceneRenderer(this);
        _cameraProvider = new CameraProvider(this);

        Content.RootDirectory = "Content";
    }

In the code above, we are creating instances of our components and setting the root directory of the content folder from where all assets should be loaded. The GraphicsDeviceManager compoent is responsible for instantiating, resetting and changing settings of the GraphicsDevice and is mandatory for every Toolkit game. The other two components are custom created for our game. The Game.Content property provides a reference to the content manager, responsible for loading and unloading assets (textures, shaders, models and the recently added sound effects).

Next, let's have a look at our CameraProvider class:

internal sealed class CameraProvider : GameSystem, ICameraService
{
    private Matrix _view;
    private Matrix _projection;

    public CameraProvider(Game game)
        : base(game)
    {
        Enabled = true;
        game.GameSystems.Add(this);
        game.Services.AddService(typeof(ICameraService), this);
    }

    public Matrix View { get { return _view; } }
    public Matrix Projection { get { return _projection; } }

    public override void Update(GameTime gameTime)
    {
        base.Update(gameTime);

        var viewRotationAngle = (float)(gameTime.TotalGameTime.TotalSeconds * 0.2f);
        var eyePosition = Vector3.Transform(new Vector3(0, 2, 5), Quaternion.RotationAxis(Vector3.UnitY, viewRotationAngle));

        _view = Matrix.LookAtRH(eyePosition, new Vector3(0, 0, 0), Vector3.UnitY);
        _projection = Matrix.PerspectiveFovRH(MathUtil.PiOverFour, (float)GraphicsDevice.BackBuffer.Width / GraphicsDevice.BackBuffer.Height, 0.1f, 200.0f);
    }
}

In constructor it enables calls to Update method (they are disabled by default), registers itself as a game system and adds itself to the game services registry as a provider for ICameraService interface. In the Update method the camera provider computes the view matrix (animated rotation around center) and the projection matrix. For performance reasons it is recommended to cache the computed matrix values and update them only when needed.

The ICameraService interface provides access to camera-related matrix values:

internal interface ICameraService
{
    Matrix View { get; }
    Matrix Projection { get; }
}

The next class, SceneRenderer as you can understand from its name - is reposnsible for scene rendering. It makes use of the camera service and enables itself both Update and Draw calls:

internal sealed class SceneRenderer : GameSystem
{
    private ICameraService _cameraService;

    private GeometricPrimitive _cube;
    private Texture2D _cubeTexture;
    private Matrix _cubeTransform;

    private GeometricPrimitive _plane;
    private Texture2D _planeTexture;
    private Matrix _planeTransform;

    private BasicEffect _basicEffect;

    public SceneRenderer(Game game)
        : base(game)
    {
        Visible = true;
        Enabled = true;

        game.GameSystems.Add(this);
    }

    public override void Initialize()
    {
        base.Initialize();

        _cameraService = Services.GetService();
    }

The obtaining of the camera service reference is done in the Initialize method to make sure all constructors have been called and all services are added to the registry, therefore the recommended pattern is to register everything in the Game's constructor, then bind services and components to each other in the Initialize method. Of course this is not a silver-bullet method, so make sure you carefully examine your situation and fully understand why you implement a certain pattern.

Next, the content is loaded in the following methods:

protected override void LoadContent()
{
    base.LoadContent();

    _basicEffect = ToDisposeContent(new BasicEffect(GraphicsDevice));
    _basicEffect.EnableDefaultLighting();
    _basicEffect.TextureEnabled = true;

    LoadCube();
    LoadPlane();
}

private void LoadCube()
{
    _cube = ToDisposeContent(GeometricPrimitive.Cube.New(GraphicsDevice));
    _cubeTexture = Content.Load("logo_large");
    _cubeTransform = Matrix.Identity;
}

private void LoadPlane()
{
    _plane = ToDisposeContent(GeometricPrimitive.Plane.New(GraphicsDevice, 50f, 50f));
    _planeTexture = Content.Load("GeneticaMortarlessBlocks");
    _planeTransform = Matrix.RotationX(-MathUtil.PiOverTwo) * Matrix.Translation(0f, -5f, 0f);
}

As the assets loaded via ContentManager are disposed automatically like in XNA, we can add any other disposable object to be disposed at the same time if his lifetime is tightly coupled to an asset, to do this - we are calling the ToDisposeContent method. When loading assets, the extension is not required (but can be provided if necessary) as ContentManager will try to append the default asset extension (".tkb", from ToolKitBinary) if one is not supplied.

The drawing of both objects uses the same basic effect:

public override void Draw(GameTime gameTime)
{
    base.Draw(gameTime);

    _basicEffect.Texture = _cubeTexture;
    _basicEffect.World = _cubeTransform;
    _cube.Draw(_basicEffect);

    _basicEffect.Texture = _planeTexture;
    _basicEffect.World = _planeTransform;
    _plane.Draw(_basicEffect);
}

Before drawing an object, we are setting the needed effect parameters (texture and world transform), then we are calling the GraphicsPrimitive.Draw method which applied the provided effect and performs the draw calls on GraphicsDevice.

The update method reads the data provided by the camera service and updates the animation of the cube:

public override void Update(GameTime gameTime)
{
    base.Update(gameTime);

    var time = (float)gameTime.TotalGameTime.TotalSeconds;
    _cubeTransform = Matrix.RotationX(time) * Matrix.RotationY(time * 2f) * Matrix.RotationZ(time * .7f);

    _basicEffect.View = _cameraService.View;
    _basicEffect.Projection = _cameraService.Projection;
}

In the final you should get something like this:

Full code with detailed comments is uploaded here. Feel free to do anything you want with it. Keep in mind that there doesn't exist an architecture that will fit all scenarios, so don't follow blindly all described here - make sure you understand what and when to apply.

Let me know in comments about which Toolkit functionality you would like to read next.

Happy coding!