Thank you both for your input.
I’ve tried both ideas. Here is my test code in full. It can be copy/pasted, there are no assets or external dependancies other than a Monogame UWP project.
Program.cs:
using Windows.Foundation;
using Windows.UI.ViewManagement;
namespace UWPRefreshRate
{
/// <summary>
/// The main class.
/// </summary>
public static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
static void Main()
{
ApplicationView.PreferredLaunchViewSize = new Size(TestGame.Width, TestGame.Height);
ApplicationView.PreferredLaunchWindowingMode = ApplicationViewWindowingMode.FullScreen;
var factory = new MonoGame.Framework.GameFrameworkViewSource<TestGame>();
Windows.ApplicationModel.Core.CoreApplication.Run(factory);
}
}
}
TestGame.cs:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;
namespace UWPRefreshRate
{
public class TestGame : Game
{
// Screen dimensions...
public static int Width = 1920;
public static int Height = 1080;
GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;
/// <summary>
/// Represents a Pixel Art Game Screen.
/// </summary>
RenderTarget2D _renderTargetPixelArt;
/// <summary>
/// Represents the player sprite texture
/// </summary>
RenderTarget2D _playerTexture;
/// <summary>
/// Represents the Player Size
/// </summary>
Rectangle _playerBounds;
/// <summary>
/// Represents the player position
/// </summary>
Vector2 _playerPosition = new Vector2(20, 20);
/// <summary>
/// Represents the bounds of the Pixel Art Screen
/// </summary>
Rectangle _screenBounds;
/// <summary>
/// Give uniform scaling to pixel art screen.
/// </summary>
const int SCALE_FACTOR = 4;
/// <summary>
/// 1x1 Pixel Texture to avoid Loading Assets in this example.
/// </summary>
Texture2D _pixelTexture;
/// <summary>
/// Set to true to ignore elapsedTime in movement code.
/// </summary>
bool _arcadeModeIgnoreElapsedTime = false;
public TestGame()
{
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
graphics.IsFullScreen = true;
graphics.HardwareModeSwitch = true; // Makes no difference on UWP.
// -----------------------------------------------------------------------------------------------------------------------------
// Test 1: Use a Variable TimeStep.
// Outcome: Horrible Stutter on 120Hz screen, at least "now and again".
// Give it a minute and try some diagonal movement too, you'll see it skip.
// Once the jitter starts it pretty much continues, as if the elapsed time is going "out of phase"
//...the trouble is the elapsedTime value is changing which ruins consistent frame timing and smooth movement.
IsFixedTimeStep = false;
graphics.SynchronizeWithVerticalRetrace = true;
_arcadeModeIgnoreElapsedTime = false;
//// -----------------------------------------------------------------------------------------------------------------------------
//// Test 2: Uncomment to use Variable Timestep but with the Arcade approach of ignoring ElapsedTime.
//// Outcome: Fixes the stutter but movement speed is different on faster screens (not what we want).
//IsFixedTimeStep = false;
//graphics.SynchronizeWithVerticalRetrace = true;
//_arcadeModeIgnoreElapsedTime = true;
//// -----------------------------------------------------------------------------------------------------------------------------
//// Test 3: Uncomment for 60Hz FixedTimeStep
//// Outcome: "Better" frametiming at least although now "Ghosting" as moving at 2 pixel jumps on a 120Hz screen.
//// This is represented by the Player rectangle looking more like an isomentric 3D "Box" as it moves diagonally.
//IsFixedTimeStep = true;
//TargetElapsedTime = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60);
//graphics.SynchronizeWithVerticalRetrace = true;
//_arcadeModeIgnoreElapsedTime = false;
//// -----------------------------------------------------------------------------------------------------------------------------
//// Test 4: Uncomment for 60Hz FixedTimeStep but with Arcade style Ignore ElapedTime.
//// Outcome: No Ghosting but moving at "half speed" for everyone.
//IsFixedTimeStep = true;
//TargetElapsedTime = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60);
//graphics.SynchronizeWithVerticalRetrace = true;
//_arcadeModeIgnoreElapsedTime = true;
//// -----------------------------------------------------------------------------------------------------------------------------
//// Test 5: Uncomment 120Hz FixedTimeStep
//// Outcome: "Pretty much perfect" as moving approx 1 pixel every update on a 120Hz screen.
//// The trouble is, we cannot "Know" about the 120Hz screen from the UWP API (or MonoGame) so cannot set this without user input.
//IsFixedTimeStep = true;
//TargetElapsedTime = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 120);
//graphics.SynchronizeWithVerticalRetrace = true;
//_arcadeModeIgnoreElapsedTime = false;
//// -----------------------------------------------------------------------------------------------------------------------------
//// Test 6: Uncomment 120Hz FixedTimeStep updates that ignore GameTime.
//// Outcome: Basically the same as test 5 excepy moving 1 pixel every update (guaranteed).
//// This is "Absolute Perfection" on 120Hz screen.
//// The trouble is, it's totally wrong for anyone NOT using a 120Hz screen!
//IsFixedTimeStep = true;
//TargetElapsedTime = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 120);
//graphics.SynchronizeWithVerticalRetrace = true;
//_arcadeModeIgnoreElapsedTime = true;
//// -----------------------------------------------------------------------------------------------------------------------------
//// Test 7: Uncomment for "Smaller timestep for all".
//// Outcome: Not divisable by 120Hz or even 60, beyond horrible jerkiness pretty much ALL the time.
//// Actually gives me a headache just looking at it.
//IsFixedTimeStep = true;
//TargetElapsedTime = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 144);
//graphics.SynchronizeWithVerticalRetrace = true;
//_arcadeModeIgnoreElapsedTime = false;
/// -----------------------------------------------------------------------------------------------------------------------------
//// Test Bonus: Just for "fun", Uncomment to cause the game to crash on startup (At least Debug mode on Monogame 3.7.x)?
//graphics.SynchronizeWithVerticalRetrace = false;
}
protected override void LoadContent()
{
spriteBatch = new SpriteBatch(GraphicsDevice);
_renderTargetPixelArt = new RenderTarget2D(GraphicsDevice, 224, 248, false, SurfaceFormat.Color, DepthFormat.None);
// Centered
_screenBounds = new Rectangle(0, 0, _renderTargetPixelArt.Width * SCALE_FACTOR, _renderTargetPixelArt.Height * SCALE_FACTOR);
_screenBounds.X += (GraphicsDevice.Viewport.Width - _screenBounds.Width) / 2;
_screenBounds.Y += (GraphicsDevice.Viewport.Height - _screenBounds.Height) / 2;
_playerTexture = new RenderTarget2D(GraphicsDevice, 15, 5, false, SurfaceFormat.Color, DepthFormat.None);
_playerBounds = new Rectangle(0, 0, _playerTexture.Width, _playerTexture.Height);
_pixelTexture = new Texture2D(GraphicsDevice, 1, 1, false, SurfaceFormat.Color);
_pixelTexture.SetData(new Color[] { Color.White });
base.LoadContent();
}
protected override void Update(GameTime gameTime)
{
KeyboardState keyState = Keyboard.GetState();
if (keyState.IsKeyDown(Keys.Escape))
Exit();
Vector2 moveVector = Vector2.Zero;
if (keyState.IsKeyDown(Keys.Right) || keyState.IsKeyDown(Keys.D))
moveVector.X = 1;
if (keyState.IsKeyDown(Keys.Left) || keyState.IsKeyDown(Keys.A))
moveVector.X = -1;
if (keyState.IsKeyDown(Keys.Down) || keyState.IsKeyDown(Keys.S))
moveVector.Y = 1;
if (keyState.IsKeyDown(Keys.Up) || keyState.IsKeyDown(Keys.W))
moveVector.Y = -1;
// PC mode, try to move with elapsed time for best compatibility with a range of devices...
if (!_arcadeModeIgnoreElapsedTime)
{
const float speed = 60 * 2; // 2 pixels per frame @60 fps BUT a perfect 1 pixel per frame at 120Hz
float elapsedSeconds = (float)gameTime.ElapsedGameTime.TotalSeconds;
_playerPosition += moveVector * speed * elapsedSeconds;
}
// Arcade mode, we run at a fixed speed ALL the time, no need for time integration.
// But can ONLY really work with a Fixed Time step that works for everyone (ie known hardware, hence Arcade/Console approach)
if (_arcadeModeIgnoreElapsedTime)
{
_playerPosition += moveVector; // 1 pixel per frame always but will move twice as fast @ 120Hz than @ 60Hz.
}
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
// Create player "Sprite"
GraphicsDevice.SetRenderTarget(_playerTexture);
spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp);
spriteBatch.Draw(_pixelTexture, _playerBounds, Color.White);
spriteBatch.End();
// Draw Player Sprite into Pixel Art "screen".
GraphicsDevice.SetRenderTarget(_renderTargetPixelArt);
GraphicsDevice.Clear(Color.Black);
spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp);
spriteBatch.Draw(_playerTexture, _playerPosition, Color.White);
spriteBatch.End();
// Draw Pixel Art to screen.
GraphicsDevice.SetRenderTarget(null);
GraphicsDevice.Clear(Color.CornflowerBlue);
spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp);
spriteBatch.Draw(_renderTargetPixelArt, _screenBounds, Color.White);
spriteBatch.End();
base.Draw(gameTime);
}
}
}
In order to see what I see and get the outcomes I get of course you’ll need to be running on a 120Hz screen but I think my notes on this are sound?
Test 5 would be about the best you could possibly do if you wanted to support all screen Hz and would be virtually perfect for everyone…if only you knew what the Screen Hz actually was…but you don’t.
Unless you get user input to tell you that it’s 120 or X, then Test 3 is about the best you can do and although fine for the vast majority running a 60Hz screen, the high Hz people have to put up with ghosting and this is not ideal for a 2D arcade style game.
One final thought for now…perhaps Test 1 should indeed have worked the best as according to Shawn Hargreaves: His blog
If you disable fixed timesteps, XNA does nothing clever for you. Our algorithm in this mode is extremely simple:
Update
Draw
Rinse, lather, repeat
(that is actually a slight simplification, but the details are unimportant)
The trouble perhaps comes about because now ElapsedTime is varying quite alot and this causes the “phasing” observed.
This leads me to believe that a FixedTimeStep is actually the only real way, in UWP (no Hardware Exclusive Screen, no PresentationInterval.Two option) that smoothness can be achieved. The trouble is, as I say, there’s no way to know what the FixedTimeStep should actually be to be optimal.