Speedy Upscaling for that Pixely Look


It’s certainly possible to have too much of a good thing. In this generation of HD TVs and game consoles, that too much of a good thing can be pixels. If you take some of the great pixel art of old and display it 1:1 on a modern screen, the art ends of being tiny. In order to fully appreciate the pixel art, you need to scale it up.

There are many ways to accomplish this sort of upscaling and, while they may have the same end result, they are not always the same in terms of performance. With that in mind, I decided to write some code and try out some of those methods for myself and see how their relative performance compares.

Using XNA, there are three major ways to upscale sprites to get that retro pixel look:

  1. Pass a scale to the Draw() call for each sprite
  2. Use a transformation matrix to scale an entire SpriteBatch
  3. Draw sprites without scaling to a render target smaller than the screen, then draw that render target scaled-up to the screen

To test out these three methods, I first put together a test application that draws 128 Richter Belmonts to the screen with an appropriate background. To measure performance, I added the good-old frametime measurement code.

Implementing the three methods above, I tried them out in turn on my Xbox 360 to see how they stack-up against each other.

First, passing a scale of 2.0 to each sprite’s draw call:

Second, creating a transformation matrix with a scale of 2.0 and passing that to SpriteBatch::Begin():

Finally, I created a 640×360 render target, drew the sprites to that, and then drew the render target to the screen scaled by 2.0:

Judging from this sample, scaling via a transformation matrix is slightly faster than scaling each sprite individually, but pales in comparison with the speed boost granted by using a smaller render target. While I expect the speed difference to be smaller the fewer individual draw calls are involved, the performance difference between the render target and non-render target methods should get larger the more sprites are drawn.

Running the test with varying numbers of sprites demonstrates just that:

Sprites Draw() Scaling Begin() Scaling Render Target Scaling
64 1.1204 ms 1.1071 ms 1.1215 ms
128 1.6667 ms 1.6359 ms 1.2471 ms
256 2.7606 ms 2.7263 ms 1.5242 ms
512 4.9174 ms 4.8351 ms 2.1099 ms

So, there you have it. For a small number of sprites, scaling by transformation just barely edges out upscaling via render target. For any large number of sprites, however, drawing to a small render target then drawing the render target scaled up is faster by a large margin.

For reference, I’ve included the code for my test below:

//#define SCALE_WITH_RENDERTARGET
#define SCALE_WITH_TRANSFORMATION

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Media;

namespace LotsOfPixels
{
    /// <summary>
    /// This is the main type for your game
    /// </summary>
    public class Game1 : Microsoft.Xna.Framework.Game
    {
        SpriteFont stopWatchFont;
        System.Diagnostics.Stopwatch stopWatch;

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Texture2D bgTexture;
        Texture2D richterTexture;
        List<Vector2> richterPositions;

        RenderTarget2D scaleupTarget;

        const int RICHTER_COUNT = 64;
#if SCALE_WITH_RENDERTARGET
        const float SPRITE_SCALE = 1.0f;
#elif SCALE_WITH_TRANSFORMATION
        const float SPRITE_SCALE = 1.0f;
#else
        const float SPRITE_SCALE = 2.0f;
#endif

        const int RENDER_TARGET_WIDTH = 640;
        const int RENDER_TARGET_HEIGHT = 360;
        float upScaleAmount;

        float scalingMatrixAmount = 2.0f;
        Matrix scalingMatrix;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            graphics.PreferredBackBufferWidth = 1280;
            graphics.PreferredBackBufferHeight = 720;

            this.IsFixedTimeStep = false;
            graphics.SynchronizeWithVerticalRetrace = false;
        }

        /// <summary>
        /// Allows the game to perform any initialization it needs to before starting to run.
        /// This is where it can query for any required services and load any non-graphic
        /// related content.  Calling base.Initialize will enumerate through any components
        /// and initialize them as well.
        /// </summary>
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here
            stopWatch = new System.Diagnostics.Stopwatch();
            stopWatch.Start();

            base.Initialize();
        }

        /// <summary>
        /// LoadContent will be called once per game and is the place to load
        /// all of your content.
        /// </summary>
        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // TODO: use this.Content to load your game content here
            stopWatchFont = Content.Load<SpriteFont>("DefaultFont");

            bgTexture = Content.Load<Texture2D>("bgroom");
            richterTexture = Content.Load<Texture2D>("richterStand01");

            Random rng = new Random(1024);
            richterPositions = new List<Vector2>(RICHTER_COUNT);
            for (int i = 0; i < RICHTER_COUNT; ++i)
            {
                richterPositions.Add(new Vector2(rng.Next(0, (int)(bgTexture.Width * SPRITE_SCALE)), rng.Next(0, (int)(bgTexture.Height * SPRITE_SCALE))));
            }

            scaleupTarget = new RenderTarget2D(GraphicsDevice, RENDER_TARGET_WIDTH, RENDER_TARGET_HEIGHT);
            upScaleAmount = (float)GraphicsDevice.PresentationParameters.BackBufferWidth / RENDER_TARGET_WIDTH;

            scalingMatrix = Matrix.CreateScale(new Vector3(scalingMatrixAmount, scalingMatrixAmount, 1.0f));
        }

        /// <summary>
        /// UnloadContent will be called once per game and is the place to unload
        /// all content.
        /// </summary>
        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

        /// <summary>
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Update(GameTime gameTime)
        {
            // Allows the game to exit
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed ||
                Keyboard.GetState().IsKeyDown(Keys.Escape))
                this.Exit();

            // TODO: Add your update logic here

            base.Update(gameTime);
        }

        /// <summary>
        /// This is called when the game should draw itself.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Draw(GameTime gameTime)
        {
            long ticks = stopWatch.ElapsedTicks;
            TimeSpan drawTime = stopWatch.Elapsed;
            stopWatch.Reset();
            stopWatch.Start();

            Vector2 richterOrigin = new Vector2((float)richterTexture.Width * 0.5f, (float)richterTexture.Height * 0.5f);
            Vector2 baseRenderPos = Vector2.UnitX * 50.0f * SPRITE_SCALE + Vector2.UnitY * 100.0f * SPRITE_SCALE;

#if SCALE_WITH_RENDERTARGET
            GraphicsDevice.SetRenderTarget(scaleupTarget);
            GraphicsDevice.Clear(Color.SeaGreen);
            spriteBatch.Begin(
                SpriteSortMode.BackToFront,
                BlendState.AlphaBlend
                );
#elif SCALE_WITH_TRANSFORMATION
            GraphicsDevice.Clear(Color.LightBlue);
            spriteBatch.Begin(
                SpriteSortMode.BackToFront,
                BlendState.AlphaBlend,
                SamplerState.PointClamp,
                null,
                null,
                null,
                scalingMatrix);
#else
            GraphicsDevice.Clear(Color.CornflowerBlue);
            spriteBatch.Begin(
                SpriteSortMode.BackToFront,
                BlendState.AlphaBlend,
                SamplerState.PointClamp,
                null,
                null,
                null);
#endif

            spriteBatch.Draw(bgTexture, baseRenderPos, null, Color.White, 0.0f, Vector2.Zero, SPRITE_SCALE, SpriteEffects.None, 1.0f);

            for (int i = 0; i < RICHTER_COUNT; ++i)
            {
                spriteBatch.Draw(richterTexture, richterPositions[i] + baseRenderPos, null, Color.White, 0.0f, richterOrigin, SPRITE_SCALE, SpriteEffects.None, 0.0f);
            }

            spriteBatch.End();
#if SCALE_WITH_RENDERTARGET
            GraphicsDevice.SetRenderTarget(null);

            spriteBatch.Begin(
                SpriteSortMode.Texture,
                BlendState.AlphaBlend,
                SamplerState.PointClamp,
                null,
                null);
            spriteBatch.Draw(scaleupTarget, Vector2.Zero, null, Color.White, 0.0f, Vector2.Zero, upScaleAmount, SpriteEffects.None, 0.0f);
            spriteBatch.End();
#endif            
            spriteBatch.Begin();
            spriteBatch.DrawString(stopWatchFont, "Frame Time ms: " + drawTime.TotalMilliseconds, Vector2.UnitX * 500.0f + Vector2.UnitY * 10.0f, Color.DarkMagenta);
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}
Share this Article:
  • Digg
  • StumbleUpon
  • del.icio.us
  • Facebook
  • Yahoo! Buzz
  • Twitter
  • Google Bookmarks
  • Print

One thought on “Speedy Upscaling for that Pixely Look

  1. Pingback: Black and White Monitor Pixel Shader | Game Dev Without a Cause

Comments are closed.