[Solved] Sub-pixel drawing of arbitrarily-sized rectangle

This is a fun one.

My current project is all about rectangles. There’s only two things drawn in it that aren’t simple rectangles: the font and an arrow texture. The rectangles are drawn using a simple code-generated blank texture that is stretched to fit whatever size of rectangle is needed.

I use a lot of RectangleF instances, as well, where RectangleF is a home-grown floating point implementation of Rectangle. Being able to draw those rectangles with sub-pixel precision would be nice.

However, subpixel rendering doesn’t work unless the blank texture has empty edges. And even when there are empty edges, if the texture is drawn stretched (if the rectangle doesn’t exactly match the size of the texture) the effect doesn’t work right.

Here’s a code sample of what I’m talking about:

public class Game1 : Game
{
    GraphicsDeviceManager graphics;
    SpriteBatch spriteBatch;

    Texture2D blankTextureFull;
    Texture2D blankTextureEmptyEdges;
    List<RectangleF> boxes = new();
    Color boxColor = new(255, 255, 255);
    int emptyEdgeBoxCopyOffset = 250;

    public Game1()
    {
        graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsFixedTimeStep = false;
        graphics.SynchronizeWithVerticalRetrace = true;
    }

    protected override void LoadContent()
    {
        const int textureWidth = 50;
        const int textureHeight = 50;

        spriteBatch = new SpriteBatch(GraphicsDevice);
        blankTextureFull = new Texture2D(GraphicsDevice, textureWidth, textureHeight);
        blankTextureEmptyEdges = new Texture2D(GraphicsDevice, textureWidth, textureHeight);

        Color[] colors = new Color[textureWidth * textureHeight];
        for (int i = 0; i < colors.Length; i++)
            colors[i] = new Color(255, 255, 255);

        blankTextureFull.SetData(colors);

        for (int x = 0; x < textureWidth; x++)
        {
            // Set top and bottom rows to transparent
            int index = Convert2DIndexInto1DIndex(x, 0, textureWidth);
            colors[index] = new Color(0, 0, 0, 0);
            index = Convert2DIndexInto1DIndex(x, textureHeight - 1, textureWidth);
            colors[index] = new Color(0, 0, 0, 0);
        }
        for (int y = 0; y < textureHeight; y++)
        {
            // Set left and right columns to transparent
            int index = Convert2DIndexInto1DIndex(0, y, textureWidth);
            colors[index] = new Color(0, 0, 0, 0);
            index = Convert2DIndexInto1DIndex(textureWidth - 1, y, textureWidth);
            colors[index] = new Color(0, 0, 0, 0);
        }

        blankTextureEmptyEdges.SetData(colors);

        boxes.Add(new RectangleF(0, 0, 50, 50));
        boxes.Add(new RectangleF(100, 0, 50, 200));
        boxes.Add(new RectangleF(200, 0, 200, 200));
        boxes.Add(new RectangleF(450, 0, 25, 25));

        base.LoadContent();

        int Convert2DIndexInto1DIndex(int x, int y, int rowLength) => y * rowLength + x;
    }

    protected override void Update(GameTime gameTime)
    {
        KeyboardState keys = Keyboard.GetState();
        if (keys.IsKeyDown(Keys.Escape))
            Exit();

        float moveSpeed = 1f;
        float moveAmount = (float)(moveSpeed * gameTime.ElapsedGameTime.TotalSeconds);

        if (keys.IsKeyDown(Keys.Up))
            MoveBoxes(0, -moveAmount);
        if (keys.IsKeyDown(Keys.Down))
            MoveBoxes(0, moveAmount);
        if (keys.IsKeyDown(Keys.Left))
            MoveBoxes(-moveAmount, 0);
        if (keys.IsKeyDown(Keys.Right))
            MoveBoxes(moveAmount, 0);

        base.Update(gameTime);

        void MoveBoxes(float x, float y)
        {
            for (int i = 0; i < boxes.Count; i++)
            {
                RectangleF box = boxes[i];
                box.X += x;
                box.Y += y;
                boxes[i] = box;
            }
        }
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);

        spriteBatch.Begin();
        for (int i = 0; i < boxes.Count; i++)
        {
            RectangleF box = boxes[i];
            DrawRectangle(spriteBatch, blankTextureFull, box, boxColor);
            box.Y += emptyEdgeBoxCopyOffset;
            DrawRectangle(spriteBatch, blankTextureEmptyEdges, box, boxColor);
        }
        spriteBatch.End();

        base.Draw(gameTime);
    }

    public static void DrawRectangle(SpriteBatch batch, Texture2D texture, RectangleF rectangle, Color color)
    {
        if (rectangle.IsEmpty)
            return;
        Vector2 scale = rectangle.Size / texture.Bounds.Size.ToVector2();
        batch.Draw(texture, rectangle.Location, null, color, 0f, Vector2.Zero, scale, SpriteEffects.None, 1f);
    }
}

// RectangleF struct, with elements unnecessary to the example removed
public struct RectangleF
{
    public float X;
    public float Y;
    public float Width;
    public float Height;

    public Vector2 Location
    {
        get => new(X, Y);
        set
        {
            X = value.X;
            Y = value.Y;
        }
    }
    public Vector2 Size
    {
        get => new(Width, Height);
        set
        {
            Width = value.X;
            Height = value.Y;
        }
    }
    public bool IsEmpty => Width == 0 || Height == 0;

    public RectangleF(float x, float y, float width, float height)
    {
        X = x;
        Y = y;
        Width = width;
        Height = height;
    }
}

The sample can be run without any content files. Use the keyboard arrow keys to very slowly move the rectangles around.

In this sample, only the bottom left rectangle does what I want. The top row is drawn with a blank texture with no transparent margin, and it behaves just like drawing integer rectangles. The bottom row is drawn with a texture with a transparent margin, but any of the rectangles that are stretched or squashed don’t work right, either having blurry sides or, in the case of the tiny rectangle, even weirder behavior.

My project has a large number of arbitrarily-sized rectangles, so creating a unique blank texture for each one isn’t going to work. Is there a way to get any rectangle of arbitrary size to be drawn like the lower-left one in my sample, without having to create multiple blank textures?

So the best way way to do it is break the rectangle up into seperate ‘lines’ and draw each line scaled to the correct proportions. Because we want sub pixel precision we use the Vector2 overload for each line and calculate the scale size of it.

We use a 1 x 1 white pixel texture2d. This also allows us to change the colour of the rectangle. The 1 x1 pixel will be scaled width and height to the correct size of each line when drawing

We can adjust the thickness of the lines and still keep the outside of the rectangle within the correct bounds

Heres an example ive knocked up and tested. Its a self contained class. You probably dont need to create the texture 2d in the class but pass one in and use the same one for each rectangle you draw

 class Border
  {
      //Vectors for location of each line
      Vector2 topMiddle;
      Vector2 rightMiddle;
      Vector2 bottomMiddle;
      Vector2 leftMiddle;
      
      float thickness;
      float width;
      float height;

      Texture2D texture2D;
      Rectangle spriteLocationOnTextureSheet = new Rectangle(0, 0, 1, 1);
      Vector2 spriteOrigin = new Vector2(0.5f, 0.5f);

      public Border(GraphicsDevice graphicsDevice, float topLeftX, float topLeftY, float width, float height, float thickness)
      {
          topMiddle = new Vector2(topLeftX + (width / 2), topLeftY + (thickness / 2));
          rightMiddle = new Vector2(topLeftX + width - (thickness / 2), topLeftY + (height / 2));
          bottomMiddle = new Vector2(topLeftX + (width / 2), topLeftY + height - (thickness / 2));
          leftMiddle = new Vector2(topLeftX + (thickness / 2), topLeftY + (height / 2));
          this.thickness = thickness;
          this.width = width;
          this.height = height;
          CreateTexture(graphicsDevice);
      }

      //Create a white 1x1 pixel texture2d
      private void CreateTexture(GraphicsDevice graphicsDevice)
      {
          texture2D = new Texture2D(graphicsDevice, 1, 1);
          //the array holds the color for each pixel in the texture
          Color[] data = new Color[1];
          data[0] = Color.White;
          //set the color
          texture2D.SetData(data);
      }

      public void Draw(SpriteBatch spriteBatch, Color drawColor)     //Change the colour to draw a different colour
      {
          //Top
          spriteBatch.Draw(texture2D, topMiddle, spriteLocationOnTextureSheet, drawColor, 0, spriteOrigin, new Vector2(width, thickness), SpriteEffects.None, 0);
          //Right
          spriteBatch.Draw(texture2D, rightMiddle, spriteLocationOnTextureSheet, drawColor, 0, spriteOrigin, new Vector2(thickness, height), SpriteEffects.None, 0);
          //Bottom
          spriteBatch.Draw(texture2D, bottomMiddle, spriteLocationOnTextureSheet, drawColor, 0, spriteOrigin, new Vector2(width, thickness), SpriteEffects.None, 0);
          //Left
          spriteBatch.Draw(texture2D, leftMiddle, spriteLocationOnTextureSheet, drawColor, 0, spriteOrigin, new Vector2(thickness, height), SpriteEffects.None, 0);
      }
  }
1 Like

Yeah, I also have code in my project to draw outlined rectangles instead of filled rectangles.

Thanks to your code, I was able to figure out how to get it to work! For some reason, the texture size absolutely has to be just one pixel, and also graphics.PreferMultiSampling needs to be set to true.

Here’s my code sample, now working right:

public class Game1 : Game
{
    GraphicsDeviceManager graphics;
    SpriteBatch spriteBatch;

    Texture2D blankTexture;
    List<RectangleF> boxes = new();
    Color boxColor = new(255, 255, 255);

    public Game1()
    {
        graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsFixedTimeStep = false;
        graphics.SynchronizeWithVerticalRetrace = true;
        graphics.PreferMultiSampling = true;
    }

    protected override void LoadContent()
    {
        spriteBatch = new SpriteBatch(GraphicsDevice);
        blankTexture = new Texture2D(GraphicsDevice, 1, 1);

        Color[] colors = new Color[1];
        colors[0] = new Color(255, 255, 255);

        blankTexture.SetData(colors);

        boxes.Add(new RectangleF(0, 0, 50, 50));
        boxes.Add(new RectangleF(100, 0, 50, 200));
        boxes.Add(new RectangleF(200, 0, 200, 200));
        boxes.Add(new RectangleF(450, 0, 10, 10));

        base.LoadContent();
    }

    protected override void Update(GameTime gameTime)
    {
        KeyboardState keys = Keyboard.GetState();
        if (keys.IsKeyDown(Keys.Escape))
            Exit();

        float moveSpeed = 1f;
        float moveAmount = (float)(moveSpeed * gameTime.ElapsedGameTime.TotalSeconds);

        if (keys.IsKeyDown(Keys.Up))
            MoveBoxes(0, -moveAmount);
        if (keys.IsKeyDown(Keys.Down))
            MoveBoxes(0, moveAmount);
        if (keys.IsKeyDown(Keys.Left))
            MoveBoxes(-moveAmount, 0);
        if (keys.IsKeyDown(Keys.Right))
            MoveBoxes(moveAmount, 0);

        base.Update(gameTime);

        void MoveBoxes(float x, float y)
        {
            for (int i = 0; i < boxes.Count; i++)
            {
                RectangleF box = boxes[i];
                box.X += x;
                box.Y += y;
                boxes[i] = box;
            }
        }
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);

        spriteBatch.Begin();
        for (int i = 0; i < boxes.Count; i++)
        {
            RectangleF box = boxes[i];
            DrawRectangle(spriteBatch, blankTexture, box, boxColor);
        }
        spriteBatch.End();

        base.Draw(gameTime);
    }

    public void DrawRectangle(SpriteBatch batch, Texture2D texture, RectangleF rectangle, Color color)
    {
        if (rectangle.IsEmpty)
            return;

        Vector2 scale = rectangle.Size / texture.Bounds.Size.ToVector2();
        batch.Draw(texture, rectangle.Location, null, color, 0f, Vector2.Zero, scale, SpriteEffects.None, 1f);
    }
}

// RectangleF struct, with elements unnecessary to the example removed
public struct RectangleF
{
    public float X;
    public float Y;
    public float Width;
    public float Height;

    public Vector2 Location
    {
        get => new(X, Y);
        set
        {
            X = value.X;
            Y = value.Y;
        }
    }
    public Vector2 Size
    {
        get => new(Width, Height);
        set
        {
            Width = value.X;
            Height = value.Y;
        }
    }
    public bool IsEmpty => Width == 0 || Height == 0;

    public RectangleF(float x, float y, float width, float height)
    {
        X = x;
        Y = y;
        Width = width;
        Height = height;
    }
}

Edit: I encountered another roadblock when attempting to work it into my current project. It turns out that if you’re using a RenderTarget2D, you have to specify a higher-than-one PreferredMultiSampleCount in its constructor for this to work right.