2D Pixel Art Game with Smooth and Pixel Perfect Camera

Hello everybody,

since the beginning of my 2D pixel art game development I researched from time to time about how to make the camera movement as smooth as possible without any stutters or ripple/warping effects on the sprites and still being pixel perfect.

But up to now I’am not 100% satisfied with my camera movement. Since I found some very good sources regarding this topic over time I thought its maybe good to share this information. On one site I hope it can help somebody with similar problems and on the other side maybe I have a misconception or overlooked something.

1. Basics - Orthographic Size
When you draw your sprites or your canvas for a pixel art game you draw (unsurprisingly) single pixels ^^. But you need to choose a resolution (native resolution) for your drawings in Photoshop, Pyxel Edit, aseprite, Pixaki, Paint or whatever program you like to use. To have a pixel perfect image in your game at the end you need to scale your native resolution by an integer N (1, 2, 3, …) because there are no half pixels. Images look blurry or the drawing pixels aren’t square if they aren’t pixel perfect. That means

N * drawing pixel = screen pixel

must always be fulfield to a have a pixel perfect image on the screen.

The following image:


is a close up pixel perfect „screenshot“ (with a smartphone camera) from the game FEZ on a screen with a resolution of 2560x1440. You basically see that 1 drawing pixel (e.g. one of the yellow cord pixel of the red hat) consists of 4 screen pixel. So the native resolution is 2560x1440 divided by 4 = 640x360.

These sources have some good visual explanetaions:

But which native resolution shell we use? This obviously deppends on the screen (platform) you designing for (Smartphones, Tablets, PC, Consoles, Consoles hooked on TV-Screens, …).I like to make a game for PCs. The (probably) most common resouloution is 1920x1080 (16:9 ratio). So I chose 640x360 as native resolution. Its a good trade off between the level of detail for drawing and having a pixel perfect and black bazle free images on common resolutions:

  • 640x360 *2 = 1280x720
  • … *3 = 1920x1080 (HD)
  • … *4 = 2560x1440 (WQHD)
  • … *6 = 3840x2160 (4k)
  • … *8 = 5120x2880 (5k)

Here is an interesting blog post from D-Pad Studio the makers of Owlboy about the Hi-Bit Era:

As side note: This websites shows you the wolrdwide most common screen resolutions:

So my Unity settings are as follows:

  • PPU (pixels per unit) = 16px/unit (my tiles are also 16x16 that means → 1 tile = 1 unit)
  • orthographic size = 360px / (2* 16px/unit) = 11.25 unit

For my Sprites I have the following settings:

  • PPU = 16
  • Filter Mode = Point (no filter)
  • Compression = None

2. Smooth (floating point) Camera/Character Movements on a integer Screen
This is the tricky part I think. Because whenever you like to move something smooth in Unity you have floating point operations (lerping, physics calculations, …) so thats a probleme on an integer screen (no half pixels) and I think that my stuttery camera and rippling/warping sprite have to do with this relation.

To analyse this I created a Unity project as test bench where I tried 3 different movements techniques for the player:

  • Rigidbody2D.MovePosition(…)
  • Rigidbody2D.velocity(…)
  • Rigidbody2D.AddForce(…)

And to 2 different „camera follow player“ functions, where each in its core consits out of one of the following functions:

  • Camera.transform.position = Vector3.MoveTowards(lastCameraPosition, aimPosition, speed*time
  • Camera.transform.position = new Vector3(Player.xposition, Player.yposition

I switched in my test bench project between all of the above mentioned techniques and tried all of them in a built project (anti aliasing and anistropic textures turned off) but no success. Still a stuttery camera and ore warping effects on the sprites.

Regarding this issue I also found another post that addresses this problem:

Compared to games like Stardew Valley, Kingdom New Lands, Eitr or FEZ my camera is really worse. I know not all of these games are made with Unity but how can I achieve such a nice camera with Unity? What did these guys do different? How do you guys achieve a smooth camera? Do you have recommendations? I feel like I don’t make any progress on this topic :(.

I’m thankful for every answer and hint :).

Best regards,
Bredjo

2 Likes

I do the following:
Vector3 intendedPosition (where the camera is supposed to be)
Vector3 theoreticalPosition (where it would be without pixel Perfect)
Vector3 activePosition (where I really place my camera)

All you need to do is to lerp between intended position and theoretical position. This makes the camera go smooth.
Instead of setting the transform position of the camera to the theoretical, I calculate the closest whole pixel position of the camera into activePosition.
If each pixel is e.g. 0.1 Unity Units a theoretical Position of 0.432 is set to 0.4

The activePosition is then put into the Transform. Position.

Theoretical position values must be kept the whole time and intended position is public Tobe set by other scripts.

If you are interested into the script. Please write me an pm.

Best regards

1 Like

Thank you very much for your answer :).
It took me a while to understand but I think I got it now. Correct me if I’m wrong:

The key is to move the camera in steps of whole pixel means discrete (integer) pixel by pixel steps. Since the camera moves in unity units we need to know how much units represent 1 pixel. This depends on the PPU settings. In my case PPU = 16 that means the inverse number UPP = 1/16 (Units Per Pixel) gives us the answer. That means 0.0625 units = 1 pixel.

So the script for the camera movements needs to do the following steps:

  • lerp camera from current position to the intendedPosition BUT instead of assigning the lerp result direct as new camera position we save it in a variable theoreticalPosition (Vector2, not pixel perfect)
  • now we transfrom with a custom function the x and y value of the theoreticalPosition to their closest pixel perfect pandents
  • we assign the new closest pixel perfect x (xPixelPerfectValue) and y (yPixelPerfectValue) value to the camera.transform.position.

I implemented this with the script down below. The script is direct attached to the Camera GameObject.
The good thing is now all the sprites in the scene are pixel perfect and I have no warping effects but the camera movement is really juddery (because of the discrete steps).
What have I done wrong? Did I forgot something?

Best regards,
Bredjo

// CameraMovementSpeed = 1000
public float CameraMovementSpeed
// PPU = 16
public int PPU

 void Start ()
    {
        _invPPU = 1 / (float) PPU;
        _zStartPositionCamera = transform.position.z;
    }


void LateUpdate()
   {
       transform.position = WholePixelPositionMovement(new Vector2(Player.transform.position.x, transform.position.y));
   }

private Vector3 WholePixelPositionMovement(Vector2 intendPosition)
   {
       Vector2 theoreticalPosition;
       float xPixelPerfectValue;
       float yPixelPerfectValue;
 
       // lerping setup
       journeyLength = Math.Abs(transform.position.x - intendPosition.x);
       float distCovered = Time.deltaTime * CameraMovementSpeed;
       float fracJourney = distCovered / journeyLength;
       // therotical position <-- NOT Pixel Perfect or in Whole Pixel Position
       theoreticalPosition = Vector2.Lerp(AsVector2(transform.position), intendPosition, fracJourney);
       // exchange x and y values with thier closest whole pixel values
       xPixelPerfectValue = CalculateWholePixelPerfectPosition(theoreticalPosition.x);
       yPixelPerfectValue = CalculateWholePixelPerfectPosition(theoreticalPosition.y);
       // return Vector with x and y closest pixel values
       // z values stays untouchted
       return new Vector3(xPixelPerfectValue, yPixelPerfectValue, _zStartPositionCamera);
   }


private float CalculateWholePixelPerfectPosition(float valueWithoutPixelPerfection)
   {
       // divide value without pixel perfection by the inversed pixel per unit value (unit per pixel)
       // _invPPU = 1/PPU
       float resDivision = valueWithoutPixelPerfection / _invPPU;
       // cut off decimals
       int resDivisionInt = (int)resDivision;
       // get only decimals and multiply with factor 10 to make rounding decision
       float resDivisionDezim = (resDivision - resDivisionInt) * 10;
       // rounding up if decimals > 0.5 (0.5 = 5/10)
       if (resDivisionDezim >= 5)
       {
           resDivisionInt++;
       }
       // resDivisionInt = integer amount number of _invPPU for closestWholePixelValue
       float closestWholePixelValue = resDivisionInt * _invPPU;
       // return closestWholePixelValeu
       return closestWholePixelValue;
   }


private Vector2 AsVector2(Vector3 vector3)
    {
        return new Vector2(vector3.x, vector3.y);
    }
2 Likes

Maybe it’s fine, but I notice that all 3 of your character movement approaches are involving 2D physics.

Unless you’re making some pixel-perfect version of Angry Birds, you probably don’t need (and should not be using) physics to move your character around. Just move the Transform directly.

Moreover, if you set your scale so that 1 pixel = 1 unit, you can even use integer math for all your unit positions, just like the video games of yore. Or you can continue to do floating-point calculations, and just round to the nearest whole pixel, just like you’re now doing for the camera above.

2 Likes

Thank you also JoeStrout very much for your answer :).

I did a lot of testing and changes in my test bench project and figured out the core of the problem.
The stutters a caused by the movement of the player. Since in previous states the camera followed the player without any delay the stutters transfered from the player straight to the camera movement.

In the current status the camera is fixed and I only the player is moving so the stutters are visible at the player sprite. I tested player movements with and without physics but I have stutters in both cases. To show you what I mean I recorded some gameplay of the built project. But I had to record it external with a tablet since the stutters are even worse if I did some screen recording with OBS. I uploaded the video to youtube.

Before you watch the video here some explanations:

  • pink text: movement typ of the player (active state is labelled with true or false):

  • physics based (in the video I only use the Rigidbody2D.velocity function as comparison)

  • none physics based movement (pixel perfect movement, see script below)

  • red text: player x and y position, screen resolution (of the recorded screen)

  • blue text on the top center: refresh rate (or FPS), to set the fps for the game I do:

 void Awake()
    {
        Application.targetFrameRate = Screen.currentResolution.refreshRate;
    }

Time slots:

  • 00:00 - 00:37: using pixel perfect movement (no physics involved) but still some stutters if you follow careful the player movement (see also the player x position is always in 0.0625 steps (1/16))

  • 00:00 - 00:12. doing some stop and go to show the pixel perfect x position of the player

  • 00:37 - End: using Rigidbody2D.velocity movement and still regular stutters.

I must admit you need to look close to see the stutters and you would definitively see it better if you run the project on your own pc. To see the stutters follow the player (yellow rectangle with 2 black stripes) during the longer movement phases. There are time periods where the 2 black stripes on the player jiggle more heavily (e.g. the whole first time slot 00:00 to 00:37 and from 00:59 to 1:06).

Here the link to the video:

Following the code for the pixel perfect movement function without physics:
(source: unity - How to do pixel perfect movement with Unity3D? - Game Development Stack Exchange)

void Update()
    {
        // get input keys for transform based movment
        InputForTransformBasedMovement();

        // pixel perfect movement
        PixelPerfectMovementController();
    }

// Input for Transform Based Movement 
private void InputForTransformBasedMovement()
    {
        // Move Left
        if (Input.GetKey(KeyCode.A))
        {
            // move left
            _movement.x -= MovementSpeed * Time.deltaTime;
            _movingLeft = true;
        }
        // Move Right
        if (Input.GetKey(KeyCode.D) && !_movingLeft)
        {
            // move right
            _movement.x += MovementSpeed * Time.deltaTime;
        }

        // reset value for next Update() call
        _movingLeft = false;
    }

// Pixel Perfect Movement Controller    
private void PixelPerfectMovementController()
    {
        // Clamp the current movement
        Vector2 clamped_movement = new Vector2(PPV(_movement.x), 0);

        // Check if a movement is needed (more than 1px move)
        if (clamped_movement.magnitude >= _invPPU)
        {
            // Update velocity, removing the actual movement
            _movement = _movement - clamped_movement;
            //Debug.Log("asdfasdf");
            if (clamped_movement != Vector2.zero)
            {
                //Debug.Log("Set new position");
                // Move to the new position
                transform.position = new Vector2(PPV(transform.position.x), PPV(transform.position.y)) + clamped_movement;
            }
        }
    }

// Find Nearest Pixel Perfect Position   
// PPV = Pixel Perfect Value
private float PPV(float valueWithoutPixelPerfection)
    {
        // divide value without pixel perfection by the inversed pixel per unit value (unit per pixel)
        // _invPPU = 1/PPU
        _divisionResult = valueWithoutPixelPerfection / _invPPU;

        // resDivisionInt = integer amount number of _invPPU for closestWholePixelValue
        float closestWholePixelValue = Mathf.Round(_divisionResult) * _invPPU;

        return closestWholePixelValue;
    }

And here are my quality settings (I run the project in Very Low state):

Best regards,
Bredjo

If I don’t set a value Application.targetFrameRate and therefor don’t limit the FPS my game/test bench project runs with about 1600 FPS smoother of course but I even then have occasionally stutters round about every 3 to 6 seconds in the player movement. Very strange :frowning:

Well, your display can’t actually show 1600 FPS. So that’s a bit silly. If you really want to pixel-perfect, old-school movement, you should set your project to sync to the monitor referesh (in Player settings IIRC).

Your’re right that’s silly ^^.
I activated the V Sync Count (Edit > Project Settings > Quality) but still no improvements.

I don’t know what to say at this point. Perhaps print out (via Debug.Log or writing to a file) the position of your object on each frame (i.e. Update), along with the Time.deltaTime value to make sure your frame rate isn’t stuttering. Make sure it’s exactly what you think it should be, and if not, then work backwards to figure out why.

I don’t mean to take this off-topic but, @JoeStrout , you seem to imply physics are inappropriate in a 2D game and, while I’m inclined to disagree, I wanted to better understand your meaning and rationale. I am probably misunderstanding.

Not inappropriate, but in most cases not necessary, and the harder way to do things. There was no physics engine in Mario, Metroid, or Castlevania. Nor in Lemmings, or Warcraft, or <insert great 2D game of yore here>. They didn’t need them, and if you’re making a game similar to any of these, then neither do you.

Using the physics engine means at least partially giving up control of your character (and the enemies, projectiles, etc.). The whole point of it is to calculate how things should move according to physics forces and collisions, which means your code doesn’t determine how things move. So, getting the really tight, natural-feeling, perfect motion characteristic of (say) Mario or Smash Bros is really difficult in this case. You’re constantly fighting the physics engine to try and get the motion you want.

On the other hand, if you’re making the next Angry Birds, where collision response and tumbling objects is a major part of the game, then by all means, let the physics engine do the work for you. My only complaint is that because the first tutorial most Unity newbies do is Roll-a-Ball, they think the physics engine is the only way to move things around in Unity, when that’s far from true.

This article of mine is mostly about 2D animation, but the demo project also shows a character able to run, skid to a stop, jump, and double-jump, with very tight motion controls, and all without Unity physics.

2 Likes

Excellent answer and totally agreed. Thanks.

1 Like

For my 2D game I honestly don’t really need physics but for starters it made things easier for me. But I will definitively study your article @JoeStrout . Thank you for sharing :).

And I’m happy to tell you that I found my mistake :):):).

Following some steps for a smooth pixel perfect 2D camera:

1. Scale PPU
The completely stutter free camera I aimed for is not possible in pixel art. To maintain a pixel perfect image (no sprite jitter) you can minimum move the camera in 1 pixel steps (correct me if I’m wrong). Dependent on your monitor resolution (also the refresh rate) and your screen pixel to drawing pixel ratio (called “N” in the first thread post) you have more or less noticeable stutters.
The core aspect I noticed is:
The bigger N the higher is the amount of screen pixel displaying 1 drawing pixel and the more steps you have to move 1 drawing pixel on your screen, which results in a more smooth movement. (correct me if I’am wrong)

The reason why I had relative strong noticeable stutters is because I forgot to consider the scale factor of the screen resolution to the native resolution.
Earlier I wrote, thanks to the answer of @Fab4 ,:

This is right if you only play your game in your native resolution (in my case: 640x360, PPU = 16). But if you play in 1920x1080 you need to add a factor of 4 (1920/640 = 4, or 1080/360 = 4) which gives us ScreenPPU = 4*PPU = 64 (4 times more steps).
That means:
Screen PPU = PPU * (Screen Resolution Width / Native Resolution Width)

(heights can also be used)

A lot of these things are already mentioned here:

But I guess needed to go the hard way to really understand whats going on ^^. Again thank you all for your support :smile:.

2. Built Project!
Always test the build of your game, because I noticed in the editor game mode you always get some stutters (I guess because of the edit possibility and other things Unity provides in that mode).

3. Turn Vsync On
After applying the methods described in 1., I noticed that Vsync does improve the smoothness (thanks to @JoeStrout for the hint). In Unity (Version 2017.3.1f1) go to: Edit > Project Settings > Quality > Other > V Sync Count

4. Smooth/Sluggish Camera
I noticed that a smaller camera speed which lets the camera act more sluggish/lagging behind helps retouching the stuttering mentioned in section 1. as well.

Conclusion:
When I play my built game with a native resolution of 640x360 (PPU = 16, camera orthographic size = 11.25) on a Full HD screen @60Hz (N = 3) I do notice some stutters but its much better as before and more important: I know what causes these stutters (see section 1.).
I also made a best case test with a native resolution of 320x180 (PPU = 16, camera orthographic size = 5.625) on a 2560x1440 (@144Hz) screen which gives me N = 8, which results in very smooth movement without any noticeable stutters.

!!! NOTE: If anyone has other ideas, techniques, tricks or experience how to make 2D pixel art games appeal stutter free or reduce stutters I’m always interested :slight_smile: !!!.

I also added some functions to automatically calculate the right Screen PPU value dependent on the orthographic size of the camera and your screen resolution. Here a minimal example script with all the core functionalities I’m currently using:

using UnityEngine;

public class SmoothPixelPerfectCamera : MonoBehaviour {

    // ---------------------------------------- public ----------------------------------------
    public GameObject Player;
    public GameObject MainCamera;
    // values between 1 - 3 make camera sluggish, 10 seems instant
    public float SmoothSpeed = 3;

    public static float PPUScale;
    public static float ScreenPPU;

    public int NativePPU;

 

    // ---------------------------------------- private ----------------------------------------
    private Camera _mainCamera;

    private float _zStartPositionCamera;
    private float _yOffsetCameraToPlayer;
    private float _orthographicCameraSize;

    private int _screenResolutionWidth;
    private int _nativeResolutionWidth;
 


    // Use this for initialization
    void Start ()
    {
        _mainCamera = MainCamera.GetComponent<Camera>();
        _yOffsetCameraToPlayer = MainCamera.transform.position.y - Player.transform.position.y;
        _zStartPositionCamera = MainCamera.transform.position.z;
        _screenResolutionWidth = 0;
    }


    // Update is called once per frame
    void Update()
    {

        // if screen resolution changes or orthographic size of the camera --> adapt values to maintain pixel perfection
        if (_screenResolutionWidth != Screen.currentResolution.width || !Mathf.Approximately(_orthographicCameraSize, _mainCamera.orthographicSize))
        {
            // update values for pixel perfection
            UpdatePixelPerfectScaleValues();
        }

    }


    void LateUpdate () {

        // only move camera if distance between player position and camera is bigger then 10* 1/ScreenPPU
        // reason: single latecomer stutters when player is standing still (single adjustings of the script)
        if (Mathf.Abs(Player.transform.position.x - GrafikAndGuiSettings.PPV(transform.position.x)) > 10 * 1 / GrafikAndGuiSettings.ScreenPPU)
        {
            SmoothPixelPerfectCamera();
        }

    }


    //Smooth pixel perfect camera
    private void SmoothPixelPerfectCamera()
    {
        Vector2 desiredPosition = new Vector2(Player.transform.position.x, Player.transform.position.y + _yOffsetCameraToPlayer);
        Vector2 pixelPerfectDesiredPosition = new Vector2(PPV(desiredPosition.x), PPV(desiredPosition.y));
        Vector2 smoothPosition = Vector2.Lerp(transform.position, pixelPerfectDesiredPosition, Time.deltaTime * SmoothSpeed);
        Vector2 smoothPixelPerfectPosition = new Vector2(PPV(smoothPosition.x), PPV(smoothPosition.y));

        // add z-start-position of camera
        MainCamera.transform.position = (Vector3)smoothPixelPerfectPosition + Vector3.forward * PPV(_zStartPositionCamera);
    }


    // PPV = Pixel Perfect Value
    public static float PPV(float valueWithoutPixelPerfection)
    {
        // divide value without pixel perfection by the inversed pixel per unit value (unit per pixel)
        // _screenPPU = 1/PPU
        float screenPixelPosition = valueWithoutPixelPerfection * ScreenPPU;

        // resDivisionInt = integer amount number of _screenPPU for closestWholePixelValue
        float pixelPerfectScreenUnitPosition = Mathf.Round(screenPixelPosition) / ScreenPPU;

        return pixelPerfectScreenUnitPosition;

    }


    private void UpdatePixelPerfectScaleValues()
    {
        float aspectRatio = (float)16 / 9;
        // calculate native resolution width, float auxiliary variable for a full float calculation
        float auxiliaryVar = aspectRatio * _mainCamera.orthographicSize * NativePPU * 2f;
        _nativeResolutionWidth = (int)auxiliaryVar;

        // calculate PPUScale
        PPUScale = (float)Screen.currentResolution.width / _nativeResolutionWidth;
        // translate Native Pixel Per Unit (PPU) to actually ScreenPPU
        ScreenPPU = PPUScale * NativePPU;

        // save new resolution
        _orthographicCameraSize = _mainCamera.orthographicSize;
        _screenResolutionWidth = Screen.currentResolution.width;
    }
}
1 Like

Update:
In the previous approach I set direct the camera.transform.position from one pixel to the next and that causes noticeable small stutters, particular in that cases where the screen pixel to drawing pixel ratio is small.
Instead of setting direct the transform position of the camera to the next pixel perfect position I use now a coroutine, that contains the Vector2.MoveTowards method to move the camera (fast) from one pixel to the next (see the code below). In between the pixel positions, the camera is strictly speaking not pixel perfect but since the movement speed is high, you do not notice this and there is no sprite wiggle.
Overall this makes things also bit smoother.

A friend of mine gave me also some other keywords: Lanczos Resampling and subpixel accurate movement to look into. We also so talked about my problem with the discrete pixel steps and it might also be solvable with making a FFT (Fast Fourier Transformation) (I actually have an electrical engineering background). But I assume Unity is already doing something like that internally since I can move objects not only on the pixel grid but also in between, am I right? Anyway I’ll deal with these topics next to see if i can improve the situation.

If anyone knows more about these things or has some experience in this areas, pleas get in touch. I would be very grateful :).

Best regards,
Bredjo

 IEnumerator MoveTowardsNextPixel(Vector2 endPosition, float zStartPosition, float speed)
    {
        float sqrRemainingDistance = ((Vector2)transform.position - endPosition).sqrMagnitude;

        while (sqrRemainingDistance > float.Epsilon)
        {
            Vector2 newPosition = Vector2.MoveTowards(transform.position, endPosition, speed * Time.fixedDeltaTime);

            //Vector3 newPixelPerfectPosition = GrafikAndGuiSettings.PPVVector3(newPosition) + Vector3.forward*_zStartPositionCamera;
            transform.position = (Vector3)newPosition + Vector3.forward * _zStartPositionCamera;

            sqrRemainingDistance = ((Vector2)transform.position - endPosition).sqrMagnitude;

            yield return null;
        }

        transform.position = (Vector3)endPosition + Vector3.forward * zStartPosition;
    }

My variable settings:
speed = 60
Smooth Speed = 8 (see code previous to this post)

1 Like

Don’t use a pixel perfect camera. Instead write a shader that samples the texture for each pixel drawn on screen. If the sample is 100% one colour then draw that colour. If it is somewhere between 2+ colours then draw the average. So you will still get fairly crisp pixel art without blurring (unlike bilinear filtering which burs based on sampled image resolution, not screen resolution) but you will also get an antialiased effect that is only 1 screen pixel wide at most regardless of zoom level.

example: Shader - Shadertoy BETA

4 Likes

@Cereal_Killa , that is the coolest thing I’ve heard all week. Including last week since this week’s just begun.

Unfortunately, I don’t think my shader skills are up to converting that Shadertoy sample into a Unity shader. Any guidance on how to do that?

Do what I do, and use Tilengine to render actual 2D sprites using a legacy pixel approach. That way you can get a texture object with your scene rendered to true pixel-perfect 2D, no matter what you are doing with camera movements or sprites. It’s a bit extreme, but I’ve been getting some good results, and I don’t have to worry about the camera in the slightest.

Also, also, the movement of the camera isn’t nearly as important as the elements within the camera, and their relation to the current pixel display. The size of the display likely doesn’t change. The orthographic size of your camera probably doesn’t change. If you store your game elements as a root and parented game object, you could perform your position and physics calculations on the root object, and have the child object contain your rendering elements. Then cook up a script that slightly adjusts the local position of the child elements to correct themselves for pixel-perfect rendering.

Also, also, also, why would you ever render at anything other than your target resolution? If you need to scale for different displays, just render at your lower target resolution, point it at a renderTexture, slap that crap on a quad, and scale it up to the desired display resolution. That’s the fastest way to keep things perfectly sized.

People with ultrawide screens, ultra HD and other configs constantly run into problems with games so complaining is their default response to any game/app that wants to display with black borders in order to enforce a standard resolution.

I can agree that black borders are annoying but I would personally take a different approach away from 2D pixel art and then not have to worry about viewport size.

(Shrug) It’s always an option. In fact, in an engine like Unity it is technically the default option. Unity’s core rendering is built around a resolution-independent 3D pipeline. Even it’s 2D tools are built around the same pipeline. If you just do things the standard “Unity Way”, you’ll get resolution-independent scaling graphics.

But you also won’t get low-resolution pixel art graphics. And while that style isn’t for everybody, it is for some people. And for those people who do want to go down that road, you have discussions like this one where you try to figure out efficient ways of creating that look within an engine like Unity. It is a testament to Unity’s flexibility that it can provide such a presentation, even when it isn’t built around such an approach.

Another possible option, and a potentially interesting stylistic experiment. Use relatively high-polycount models, cook up some relatively simple shaders, and then render a 3D scene specifically to look like some old-school low-resolution games. Unity’s render-target tools make it very easy to play around with alternative forms of rendering. Adjusting some basic shaders to display basic ramps as blocks of colors instead of gradients of shading would allow you to create a old-school PC game look from 3D models. Switch off all anti-aliasing and mip-mapping to get those nice, hard pixel edges. And adjust your render camera to a low resolution. You could produce fairly vibrant and complex scenes this way, while still maintaining a retro aesthetic. And because this rendering approach wouldn’t be very performance intensive, you could also afford to throw more polygons at the scene, allowing your models to have smoother edges and more complex shapes.