encompass-cs-docs/content/pong/draw_paddle/canvas_renderer.md

8.9 KiB

title date weight
Canvas Renderer 2019-05-23T11:29:24-07:00 10

Now that we have a Texture2DComponent and a PositionComponent, we can tell Encompass how to draw things that have them.

Create a file: PongFE/Renderers/Texture2DRenderer.cs

This is gonna be a bit more complex than our Components, so let's take this slowly.

using Encompass;
using PongFE.Components;

namespace PongFE.Renderers
{
    public class Texture2DRenderer : OrderedRenderer<Texture2DComponent>
    {
        public override void Render(Entity entity, in Texture2DComponent drawComponent)
        {

        }
    }
}

Before we go any further, let's talk about some of the new concepts introduced here.

Notice that this is a class instead of a struct, like we have done before. There are many reasons why you might want to use classes vs structs in C# in general, but for our purposes, you can use the following rule of thumb: if the object contains data, it should be a struct. If it contains logic, it should be a class. Since Renderers draw things (logic), they are classes.

An OrderedRenderer is defined by the Component type it tracks and a Render method. It uses the Layer property of the specified Component to draw things in the correct order. Each time World.Draw is called, the OrderedRenderer will run its Render method for each Entity that contains a Component of its specified tracking type.

The most efficient way for us to draw Texture2Ds is to use FNA's SpriteBatch system. If you actually care about how SpriteBatch works and why we need to structure our draws using it, read the Note section below. Otherwise feel free to just skip ahead.

{{% notice note %}} Why SpriteBatch? Modern computers have dedicated chips for processing graphics-related logic. These are called GPUs, or Graphics Processing Units. In order to draw using the GPU, the CPU (central processing unit) must send data to the GPU.

Imagine that you want to bake cookies. One way you could do this is by putting a single blob of cookie dough on a sheet, stick it in the oven, wait 15 or so minutes for it to bake, take it out, and then repeat until all your cookies are done. Or... you could stick a dozen cookies on a single baking sheet and bake them all at the same time. It should be pretty obvious which one of these methods is faster.

If you understand the analogy you will see why sending Texture2Ds to the GPU to be drawn one at a time will bog down the CPU enormously. So what we want to do is batch the data. This is what SpriteBatch does! {{% /notice %}}

Now let's fill out the Renderer some more.

using Encompass;
using Microsoft.Xna.Framework.Graphics;
using PongFE.Components;

namespace PongFE.Renderers
{
    public class Texture2DRenderer : OrderedRenderer<Texture2DComponent>
    {
        private readonly SpriteBatch _spriteBatch;

        public Texture2DRenderer(SpriteBatch spriteBatch)
        {
            _spriteBatch = spriteBatch;
        }

        public override void Render(Entity entity, in Texture2DComponent textureComponent)
        {
            ref readonly var positionComponent = ref GetComponent<PositionComponent>(entity);

            _spriteBatch.Draw(
                textureComponent.Texture,
                positionComponent.Position,
                null,
                Color.White,
                0,
                Vector2.Zero,
                Vector2.One,
                SpriteEffects.None,
                0
            );
        }
    }
}

First of all, when we construct the Texture2DRenderer, we will pass in a SpriteBatch instance. Simple enough.

Our Render method will run exactly once for each Texture2DComponent that lives in our world. It also gives us a reference to the Entity that each specific Texture2DComponent is attached to. The in keyword means that the textureComponent is accessed by reference, but cannot be modified. We shouldn't ever be changing the data of a component inside a Renderer, because that is not the Renderer's job. So the method requires us to use the in keyword here.

Next, we want to retrieve our position data. We can do this with the Renderer's GetComponent method.

GetComponent<PositionComponent>(entity) means that we retrieve the PositionComponent that is attached to the given entity. Simple! The brackets mean that the method is what is called a generic method. That means we have to tell the method which type it needs to return, which in this case is PositionComponent.

ref means that we are referencing the component struct rather than copying it by value. This is optional, but it is much, much faster than not using ref so I recommend doing this pretty much always.

readonly is similar to in because it means that the struct's values cannot be modified. We shouldn't ever be changing the data of a component inside a Renderer, and in fact, GetComponent does not even allow you to use non-readonly references. We will talk more about strategies for updating components later on.

Now that we can access our data, we have a problem though. We are using a custom struct for handling our Position data, but the SpriteBatch doesn't know how to use that data. It only knows how to use a Microsoft.Xna.Framework.Vector2 for position. So what do we do?

We have two options in this case: we could create a helper function that takes in a MoonTools.Structs.Position2D position struct and converts it to an appropriate Microsoft.Xna.Framework.Vector2 struct. But in cases like these, I think it is cleaner to define what we call an extension method.

Let's create a folder in our PongFE directory called Extensions. Inside of Extensions, create a file: PongFE/Extensions/Position2DExtensions.cs

using MoonTools.Structs;

namespace PongFE.Extensions
{
    public static class Position2DExtensions
    {
        public static Microsoft.Xna.Framework.Vector2 ToXNAVector(this Position2D position)
        {
            return new Microsoft.Xna.Framework.Vector2(position.X, position.Y);
        }
    }
}

When we define a method in this way, it means that we can add a new method to an existing class even if we don't have control over the class's implementation. This can be a very nice and clean way to add functionality that we need for our project without having to modify the original code. If you find yourself using tons and tons of extension methods, however, it could be time to consider creating your own class instead.

Now we can rewrite our SpriteBatch draw call:

    _spriteBatch.Draw(
        textureComponent.Texture,
        positionComponent.Position.ToXNAVector(),
        null,
        Color.White,
        0,
        Vector2.Zero,
        Vector2.One,
        SpriteEffects.None,
        0
    );

Let's take this opportunity to break down the SpriteBatch.Draw method. There are actually a few different configurations of Draw method arguments, but this is the most commonly used one.

The first argument is the texture we are going to draw.

The second argument is the position where we will draw the texture.

The third argument is the "source rectangle" of the texture. This can be useful when we use spritesheets, which let us pack lots of sprites into the same texture, increasing efficiency. We don't need it right now, so we can just pass null and the sprite batch assumes we are drawing the entire texture.

The fourth argument is a color blend value. We don't want to change the color of the sprite, so Color.White will draw the sprite normally.

The fifth argument is a rotation value. We aren't rotating anything right now so we can just use 0.

The sixth argument is an "origin" value. All transformations of a sprite, like rotation and scaling, take place relative to the origin. For example, if you wanted the sprite to rotate around its center, we could pass in the center point of the sprite as the origin. We can ignore this for now, so we just use Vector2.Zero.

The seventh argument is a scaling value, which multiples the sprite's dimensions by the given value. For example, if we wanted to make the sprite draw twice as large, we could pass new Vector(2, 2) here. We just want to draw the sprite at its original dimensions, so let's just use Vector2.One here.

The eighth argument is a SpriteEffects argument, which can be used to flip the sprite horizontally or vertically. This argument is basically useless and you will pretty much never need to pass anything except SpriteEffects.None here, because passing in negative scaling values can handle sprite flipping. This is one of those examples of the XNA design being a bit weird and crusty in certain places.

The ninth and final argument is a layerDepth integer. This is only used when the SpriteBatch uses an internal sorting technique. This is much less efficient than letting Encompass do the sorting, so we will ignore this value and just pass 0 for everything.

That's it! Now we need to set up our World with its starting configuration so our Encompass elements can work in concert.