Pixel perfect 2d in 4.3?

Just wondering if there are any guidelines for making pixel perfect retro 2d games in the new unity 4.3?
E.g, scaling/cropping and aligning a small ‘screen’ to fit a designated resolution without any blurring or artifacts?

Thanks,
Scott

I have the same question: How to configure the main camera to have 1 pixel = 1 Unite?
It will be very helpfull to have “clean” X/Y positions like 118 pixels or unite instead of 5.9885631…

Thank you very much! :slight_smile:

Bump on this! I am also wondering how to set up pixel perfection across all mobile devices. Should one import Retina iPad sized sprites and then have Unity somehow scale them down? Or is there / will there be an atlas-switching setup like in NGUI/2d toolkit?

Yeah I wonder this to, if there are any nice tools now in Unity to handle this.

It’s all math. Which I suck at, but I can try for ya.

Sprites have a resolution in their import settings - it defaults to 100 pixels per unit.

You know how high your screen is (Screen.Height), and an ortho camera’s “size” indicates how tall of an area it displays.

So in the game’s startup somewhere, you need to do something similar to this:

float UnitsPerPixel = 1f / 100f;
float PixelsPerUnit = 100f / 1f; // yeah, yeah, 100
Camera.main.orthographicSize =
Screen.height / 2f // ortho-size is half the screen height…

  • UnitsPerPixel;

So if you have a 640x480 resolution, you’ll end up with an ortho size of 2.4 (that’s half of 480, divided by 100)

Yeah, it looks good for me: 640x960, main camera ortho size 4.8
Thank you :slight_smile:

Not working like expected. I get one Pixel offset (tripple checked everything).

However if I move the Camera Y around 0.0001 the offset fits again. The Texture then offsets again at 0.5f, 0.25f, 0.125f… you see the Pattern =)

Has someone a solution without Offsetting my Camera on certain numbers?

Edit: On the Default Sprite Material there is a Option called Pixel Snap that solved my issue. But it still appears in uneven hight resolutions like 481. In case someone want to take a look at the built-in shader you can Download them here: http://unity3d.com/unity/download/archive/

Thanks for the feedback!

I’m working on a retro title and wanted to scale and center pixel perfect art for multiple devices.

The following code assumes you have a max vertical art size you can set and a background that can bleed off the edges,
art with filter mode ‘point’ and units to pixels = 1.

It scales the art at some integer multiple to maintain pixel fidelity

using UnityEngine;
using System.Collections;

public class ConfigCamera : MonoBehaviour {
	
	public float maxPixelHeight = 214f;

	void Awake () {
		float scale = Mathf.Ceil(Screen.height / maxPixelHeight);
		Camera.main.orthographicSize = Screen.height / 2f / scale;
	}

}

I don’t know if it needs any slight offsets or how to modify the pixel snap in the default shader…
and if anyone has done any work on writing ‘anchor’ scripts to place ui elements as in some of the other 2d packages that would be pretty slick.

Thanks,
Scott

1 Like

I’m really struggling with this also.

In Photoshop i’ve designed my level in a document size based on iPad Retina (2048x1536). I’ve imported some of my sprites into Unity and they take up a much bigger proportion of the camera’s viewable area, than they do within photoshop.

For example, one of my level props takes up approximately 17% of the document window in photoshop. With my game view set to the same dimensions as photoshop, the imported sprite takes up approximately 38% of the screen space. I’ve no idea how to get around this, and the code listed above does not seem to be working for me.

EDIT: I have attached a crude example image, which hopefully demonstrates my issue. It shows the space one of my props takes up in photoshop, and then in Unity.

EDIT2: I tried doubling the orthographic size, so my InitCamera class does the following:
amera.main.orthographicSize = (Screen.height / 2) / _pixelsToUnits * _mod; // mod is set to 2.

The above now resembles photoshop. However, i’m now unclear as to what happens when someone is playing on a non-retina iPad. If I change _mod to equal 1, the camera’s viewable area is halved, and my prop bottom-right is not on screen anymore. This must be a common problem. Can anyone recommend a solution?

Thanks for your help

Mat

1429459--75800--$example.jpg

Bump! I am also facing the same issue. Are there any good solutions?

Really wish I could find a good tutorial on this, this is driving me nuts coming from other SDK. Everyone I talk to just gives me a generic answer “unity doesn’t have default resolution and it will handle it automatically” but it does.

I read PC you may need to offset by .5px but osx you don’t. I assume mobile you don’t either but not 100% sure. Still trying to figure this out.

Unity developers could you please clarify this and maybe make a short tutorial as it appears quite a few of your users are struggling with this same topic…

Thanks,

Vanz

The only problem with that though, is I think you will loose the built-in draw-call batching of the current system by using multiple Sprite Materials for each texture. This greatly reduces performance, since the game needs to do a Draw Call for each material. The way the new integrated Sprite material works, is that it uses that single material for every texture you’ve set to Sprite. Meaning only 1 draw call is used for all your textures. The PixelSnap is a PRO feature, sadly =/

And it may disable some of the other performance enhancements as well, like the automatic atlas creation.

They go through some of the performance enhancements here:

https://www.youtube.com/watch?v=B1F6fi04qw8

When using the monkey Language there is an import I use called Autofit. It was programmed for the monkey language by DruggedBunny a.k.a James L Boyd. He made it available to everyone and released it to the Public Domain.

This code has never failed me when using Monkey for a 2d game. It is capable of zooming, autoscaling to fit device res with or without borders and has virtual controls that also work flawlessly on any res.

The code is pasted below but I have also attached the monkey file.

What brave soul is going to convert it to work with unity? :slight_smile:

Strict


' -----------------------------------------------------------------------------
' AutoFit - Virtual Display System... [Public domain code]
' -----------------------------------------------------------------------------


' Couldn't think of a better name!


Import mojo.app
Import mojo.graphics
Import mojo.input


' Changes - made Strict and added VTouchX/Y.


' Changes - moved LOADS of locals in UpdateVirtualDisplay into fields
' and now perform very few checks/calculations on each call (most are
' only done when the device size or zoom level is changed).








' -----------------------------------------------------------------------------
' Usage. For details see function definitions...
' -----------------------------------------------------------------------------










' -----------------------------------------------------------------------------
' SetVirtualDisplay
' -----------------------------------------------------------------------------


' Call during OnCreate, passing intended width and height of game area. Design
' your game for this fixed display size and it will be scaled correctly on any
' device. You can pass no parameters for default 640 x 480 virtual device size.


' Optional zoom parameter default to 1.0.


' -----------------------------------------------------------------------------
' UpdateVirtualDisplay
' -----------------------------------------------------------------------------


' Call at start of OnRender, BEFORE ANYTHING ELSE, including Cls!


' -----------------------------------------------------------------------------
' VMouseX ()/VMouseY ()
' -----------------------------------------------------------------------------


' Call during OnUpdate (or OnRender) to get correctly translated MouseX ()/MouseY ()
' positions. By default, the results are bound to the display area within the
' borders. You can override this by passing False as an optional parameter,
' and the functions will then return values outside of the borders.


' -----------------------------------------------------------------------------
' VTouchX ()/VTouchY ()
' -----------------------------------------------------------------------------


' Call during OnUpdate (or OnRender) to get correctly translated TouchX ()/TouchY ()
' positions. By default, the results are bound to the display area within the
' borders. You can override this by passing False as an optional parameter,
' and the functions will then return values outside of the borders.


' -----------------------------------------------------------------------------
' VDeviceWidth ()/VDeviceHeight ()
' -----------------------------------------------------------------------------


' Call during OnUpdate (or OnRender) for the virtual device width/height. These
' are just the values you passed to SetVirtualDisplay.


' -----------------------------------------------------------------------------
' SetVirtualZoom
' -----------------------------------------------------------------------------


' Call in OnUpdate to set zoom level.


' -----------------------------------------------------------------------------
' AdjustVirtualZoom
' -----------------------------------------------------------------------------


' Call in OnUpdate to zoom in/out by given amount.


' -----------------------------------------------------------------------------
' GetVirtualZoom
' -----------------------------------------------------------------------------


' Call in OnUpdate or OnRender to retrieve current zoom level.












' -----------------------------------------------------------------------------
' Function definitions and parameters...
' -----------------------------------------------------------------------------










' -----------------------------------------------------------------------------
' SetVirtualDisplay: Call in OnCreate...
' -----------------------------------------------------------------------------


' Parameters: width and height of virtual game area, optional zoom...


Function SetVirtualDisplay:Int (width:Int = 640, height:Int = 480, zoom:Float = 1.0)
	New VirtualDisplay (width, height, zoom)
	Return 0
End


' -----------------------------------------------------------------------------
' SetVirtualZoom: Call in OnUpdate...
' -----------------------------------------------------------------------------


' Parameters: zoom level (1.0 being normal)...


Function SetVirtualZoom:Int (zoom:Float)
	VirtualDisplay.Display.SetZoom zoom
	Return 0
End


' -----------------------------------------------------------------------------
' AdjustVirtualZoom: Call in OnUpdate...
' -----------------------------------------------------------------------------


' Parameters: amount by which to change current zoom level. Positive values
' zoom in, negative values zoom out...


Function AdjustVirtualZoom:Int (amount:Float)
	VirtualDisplay.Display.AdjustZoom amount
	Return 0
End


' -----------------------------------------------------------------------------
' GetVirtualZoom: Call in OnUpdate or OnRender...
' -----------------------------------------------------------------------------


' Parameters: none...


Function GetVirtualZoom:Float ()
	Return VirtualDisplay.Display.GetZoom ()
End


' -----------------------------------------------------------------------------
' UpdateVirtualDisplay: Call at start of OnRender...
' -----------------------------------------------------------------------------


' Parameters:


' Gah! Struggling to explain this! Just experiment!


' The 'zoomborders' parameter can be set to False to allow you to retain FIXED
' width/height borders for the current device size/ratio. Effectively, this
' means that as you zoom out, you can see more of the 'playfield' outside the
' virtual display, instead of having borders drawn to fill the outside area.
' See VMouseX ()/Y information for more details on how this can be used...


' The 'keepborders' parameter, if set to True, means the outer borders are
' kept no matter how ZOOMED IN the game is. Setting this to False means you
' can zoom into the game, the borders appearing to go 'outside' the screen
' as you zoom further in. You'll have to try it to get it, but it only
' affects zooming inwards.


' NB. *** TURNING 'keepborders' OFF ONLY TAKES EFFECT IF zoomborders IS
' SET TO TRUE! Borders will remain otherwise... ***


Function UpdateVirtualDisplay:Int (zoomborders:Bool = True, keepborders:Bool = True)
	VirtualDisplay.Display.UpdateVirtualDisplay zoomborders, keepborders
	Return 0
End


' -----------------------------------------------------------------------------
' Misc functions: Call in OnUpdate (optionally)...
' -----------------------------------------------------------------------------


' Mouse position within virtual display; the limit parameter allows you to only
' return values within the virtual display.


' Set the 'limit' parameter to False to allow returning of values outside
' the virtual display area. Combine this with ScaleVirtualDisplay's zoomborders
' parameter set to False if you want to be able to zoom way out and allow
' gameplay in the full zoomed-out area... 


Function VMouseX:Float (limit:Bool = True)
	Return VirtualDisplay.Display.VMouseX (limit)
End


Function VMouseY:Float (limit:Bool = True)
	Return VirtualDisplay.Display.VMouseY (limit)
End


Function VTouchX:Float (index:Int = 0, limit:Bool = True)
	Return VirtualDisplay.Display.VTouchX (index, limit)
End


Function VTouchY:Float (index:Int = 0, limit:Bool = True)
	Return VirtualDisplay.Display.VTouchY (index, limit)
End


' Virtual display size...


Function VDeviceWidth:Float ()
	Return VirtualDisplay.Display.vwidth
End


Function VDeviceHeight:Float ()
	Return VirtualDisplay.Display.vheight
End








Class VirtualDisplay


	Global Display:VirtualDisplay
	
	Private
	
	Field vwidth:Float					' Virtual width
	Field vheight:Float					' Virtual height


	Field device_changed:Int				' Device size changed
	Field lastdevicewidth:Int				' For device change detection
	Field lastdeviceheight:Int				' For device change detection
	
	Field vratio:Float					' Virtual ratio
	Field dratio:Float					' Device ratio


	Field scaledw:Float					' Width of *scaled* virtual display in real pixels
	Field scaledh:Float					' Width of *scaled* virtual display in real pixels


	Field widthborder:Float				' Size of border at sides
	Field heightborder:Float				' Size of border at top/bottom


	Field sx:Float						' Scissor area
	Field sy:Float						' Scissor area
	Field sw:Float						' Scissor area
	Field sh:Float						' Scissor area


	Field realx:Float						' Width of SCALED virtual display (real pixels)
	Field realy:Float						' Height of SCALED virtual display (real pixels)


	Field offx:Float						' Pixels between real borders and virtual borders
	Field offy:Float						' Pixels between real borders and virtual borders


	Field vxoff:Float						' Offsets by which view needs to be shifted
	Field vyoff:Float						' Offsets by which view needs to be shifted


	Field multi:Float						' Ratio scale factor
	Field vzoom:Float						' Zoom scale factor
	Field zoom_changed:Int					' Zoom changed
	Field lastvzoom:Float					' Zoom change detection
	
	Field fdw:Float						' DeviceWidth () gets pre-cast to Float in UpdateVirtualDisplay
	Field fdh:Float						' DeviceHeight () gets pre-cast to Float in UpdateVirtualDisplay
	
	Public
	
	Method New (width:Int, height:Int, zoom:Float)


		' Set virtual width and height...
			
		vwidth = width
		vheight = height


		vzoom = zoom
		lastvzoom = vzoom + 1 ' Force zoom change detection! Best hack ever. (vzoom can be zero.)


		' Store ratio...
		
		vratio = vheight / vwidth


		' Create global VirtualDisplay object...
		
		Display = Self
	
	End


	Method GetZoom:Float ()
		Return vzoom
	End
	
	Method SetZoom:Int (zoomlevel:Float)
		If zoomlevel < 0.0 Then zoomlevel = 0.0
		vzoom = zoomlevel
		Return 0
	End
	
	Method AdjustZoom:Int (amount:Float)
		vzoom = vzoom + amount
		If vzoom < 0.0 Then vzoom = 0.0
		Return 0
	End
	
	Method VMouseX:Float (limit:Bool)
		
		' Position of mouse, in real pixels, from centre of screen (centre being 0)...
		
		Local mouseoffset:Float = MouseX () - Float (DeviceWidth ()) * 0.5
		
		' This calculates the scaled position on the virtual display. Somehow...
		
		Local x:Float = (mouseoffset / multi) / vzoom + (VDeviceWidth () * 0.5)


		' Check if mouse is to be limited to virtual display area...
		
		If limit
	
			Local widthlimit:Float = vwidth - 1
	
			If x > 0
				If x < widthlimit
					Return x
				Else
					Return widthlimit
				Endif
			Else
				Return 0
			Endif
	
		Else
			Return x
		Endif
	
		Return 0
		
	End


	Method VMouseY:Float (limit:Bool)
	
		' Position of mouse, in real pixels, from centre of screen (centre being 0)...


		Local mouseoffset:Float = MouseY () - Float (DeviceHeight ()) * 0.5
		
		' This calculates the scaled position on the virtual display. Somehow...


		Local y:Float = (mouseoffset / multi) / vzoom + (VDeviceHeight () * 0.5)
		
		' Check if mouse is to be limited to virtual display area...


		If limit
		
			Local heightlimit:Float = vheight - 1
		
			If y > 0
				If y < heightlimit
					Return y
				Else
					Return heightlimit
				Endif
			Else
				Return 0
			Endif


		Else
			Return y
		Endif
		
		Return 0


	End


	Method VTouchX:Float (index:Int, limit:Bool)
		
		' Position of touch, in real pixels, from centre of screen (centre being 0)...
		
		Local touchoffset:Float = TouchX (index) - Float (DeviceWidth ()) * 0.5
		
		' This calculates the scaled position on the virtual display. Somehow...
		
		Local x:Float = (touchoffset / multi) / vzoom + (VDeviceWidth () * 0.5)


		' Check if touches are to be limited to virtual display area...
		
		If limit
	
			Local widthlimit:Float = vwidth - 1
	
			If x > 0
				If x < widthlimit
					Return x
				Else
					Return widthlimit
				Endif
			Else
				Return 0
			Endif
	
		Else
			Return x
		Endif
	
		Return 0
		
	End


	Method VTouchY:Float (index:Int, limit:Bool)
	
		' Position of touch, in real pixels, from centre of screen (centre being 0)...


		Local touchoffset:Float = TouchY (index) - Float (DeviceHeight ()) * 0.5
		
		' This calculates the scaled position on the virtual display. Somehow...


		Local y:Float = (touchoffset / multi) / vzoom + (VDeviceHeight () * 0.5)
		
		' Check if touches are to be limited to virtual display area...


		If limit
		
			Local heightlimit:Float = vheight - 1
		
			If y > 0
				If y < heightlimit
					Return y
				Else
					Return heightlimit
				Endif
			Else
				Return 0
			Endif


		Else
			Return y
		Endif
		
		Return 0


	End


	Method UpdateVirtualDisplay:Int (zoomborders:Bool, keepborders:Bool)


		' ---------------------------------------------------------------------
		' Calculate/draw borders, if any, scale, etc...
		' ---------------------------------------------------------------------


		' ---------------------------------------------------------------------
		' Check for 'real' device resolution change...
		' ---------------------------------------------------------------------


		If (DeviceWidth () <> lastdevicewidth) Or (DeviceHeight () <> lastdeviceheight)
			lastdevicewidth = DeviceWidth ()
			lastdeviceheight = DeviceHeight ()
			device_changed = True
		Endif
		
		' ---------------------------------------------------------------------
		' Force re-calc if so (same for first call)...
		' ---------------------------------------------------------------------


		If device_changed


			' Store device resolution as float values to avoid loads of casts. Doing it here as
			' device resolution may potentially be changed on the fly on some platforms...
			
			fdw = Float (DeviceWidth ())
			fdh = Float (DeviceHeight ())
			
			' Device ratio is calculated on the fly since it can change (eg. resizeable
			' browser window)...
			
			dratio = fdh / fdw


			' Compare to pre-calculated virtual device ratio...
	
			If dratio > vratio
	
				' -----------------------------------------------------------------
				' Device aspect narrower than (or same as) game aspect ratio:
				' will use full width, borders above and below...
				' -----------------------------------------------------------------
	
				' Multiplier required to scale game width to device width (to be applied to height)...
				
				multi = fdw / vwidth
				
				' "vheight * multi" below applies width multiplier to height...
				
				heightborder = (fdh - vheight * multi) * 0.5
				widthborder = 0
				
			Else
	
				' -----------------------------------------------------------------
				' Device aspect wider than game aspect ratio:
				' will use full height, borders at sides...
				' -----------------------------------------------------------------
				
				' Multiplier required to scale game height to device height (to be applied to width)...
				
				multi = fdh / vheight
				
				' "vwidth * multi" below applies height multiplier to width...
	
				widthborder = (fdw - vwidth * multi) * 0.5
				heightborder = 0
	
			Endif


		Endif


		' ---------------------------------------------------------------------
		' Check for zoom level change...
		' ---------------------------------------------------------------------


		If vzoom <> lastvzoom
			lastvzoom = vzoom
			zoom_changed = True
		Endif
		
		' ---------------------------------------------------------------------
		' Re-calc if so (and on first call), or if device size changed...
		' ---------------------------------------------------------------------


		If zoom_changed Or device_changed


			If zoomborders
	
				' Width/height of SCALED virtual display in real pixels...
				
				realx = vwidth * vzoom * multi
				realy = vheight * vzoom * multi
		
				' Space in pixels between real device borders and virtual device borders...
				
				offx = (fdw - realx) * 0.5
				offy = (fdh - realy) * 0.5
	
				If keepborders
	
					' -----------------------------------------------------
					' Calculate inner area...
					' -----------------------------------------------------


					If offx < widthborder
						sx = widthborder
						sw = fdw - widthborder * 2.0
					Else
						sx = offx
						sw = fdw - (offx * 2.0)
					Endif
	
					If offy < heightborder
						sy = heightborder
						sh = fdh - heightborder * 2.0
					Else
						sy = offy
						sh = fdh - (offy * 2.0)
					Endif
	
				Else
	
					sx = offx
					sw = fdw - (offx * 2.0)
	
					sy = offy
					sh = fdh - (offy * 2.0)
	
				Endif


				' Apply limits...
				
				sx = Max (0.0, sx)
				sy = Max (0.0, sy)
				sw = Min (sw, fdw)
				sh = Min (sh, fdh)


			Else


				' Apply limits...


				sx = Max (0.0, widthborder)
				sy = Max (0.0, heightborder)
				sw = Min (fdw - widthborder * 2.0, fdw)
				sh = Min (fdh - heightborder * 2.0, fdh)
			
			Endif


			' Width and height of *scaled* virtual display in pixels...


			scaledw = (vwidth * multi * vzoom)
			scaledh = (vheight * multi * vzoom)


			' Find offsets by which view needs to be shifted...
			
			vxoff = (fdw - scaledw) * 0.5
			vyoff = (fdh - scaledh) * 0.5


			' Ahh, good old trial and error -- I have no idea how this works!
			
			vxoff = (vxoff / multi) / vzoom
			vyoff = (vyoff / multi) / vzoom
		
			' Reset these...
			
			device_changed = False
			zoom_changed = False
			
		Endif
		
		' ---------------------------------------------------------------------
		' Draw borders at full device size...
		' ---------------------------------------------------------------------


		SetScissor 0, 0, DeviceWidth (), DeviceHeight ()
		Cls 0, 0, 0


		' ---------------------------------------------------------------------
		' Draw inner area...
		' ---------------------------------------------------------------------


		SetScissor sx, sy, sw, sh
			
		' ---------------------------------------------------------------------
		' Scale everything...
		' ---------------------------------------------------------------------
		
		Scale multi * vzoom, multi * vzoom


		' ---------------------------------------------------------------------
		' Shift display to account for borders/zoom level...
		' ---------------------------------------------------------------------


		If vzoom Then Translate vxoff, vyoff
		
		Return 0
		
	End


End

1453488–78728–$autofit.monkey.txt (18.4 KB)

Nachtmahr solution works for me and from all the test that I’ve done, I was able to keep my batching and also able to use PixelSnap with Unity Free version. . :slight_smile:

So here’s what have done.

1 - Add a script to your camera to automatically set the othographic size to get 1:1 pixel.

public void Awake()
{
	//
	camera.orthographicSize = (Screen.height / 100f / 2.0f); // 100f is the PixelPerUnit that you have set on your sprite. Default is 100.
}

2 - Create a new material, choose from the shader list the Sprites/Default shader and check the Pixel Snap option on your material.

1453868--78755--$MaterialSetup.png

3 - From now you need to add this material on the SpriteRenderer component of each sprites in your game. (Adding this material on all your sprites will keep batching.)

1453868--78759--$SpriteRenderer.png

And that’s it.

P.S. As Nachtmahr said it will not work with uneven screen height like 485. But I’ve found that sometimes, if you uncheck PixelSnap when the screen height is uneven it will work. I’ve tried to uncheck PixelSnap at runtime when the screen height is uneven, but unfortunately, it looks like PixelSnap does not update the shader at runtime. :frowning:

Hope it will helps ! :smile:
Cheers.

4 Likes

Awesome, that fixed my problem with my random tile map generator. I noticed that depending on the camera’s position the UV coordinates would sometimes be one pixel off. I think perhaps there should be a special camera option for 2D games that would automatically enable pixel snap for all sprites. If done at the camera level it could also be designed in such a way that it would work for both even and odd screen resolutions.

and this a very good tutorial about this issue

Does it make sense to use

    public void Awake()
    {
        //
        camera.orthographicSize = (Screen.height / 100f / 2.0f); // 100f is the PixelPerUnit that you have set on your sprite. Default is 100.
    }

?

The reason I ask is that the Screen.height seems to pick up whatever your final resolution is when you build the game. So in the event that you’re using something like 640x480 for all your graphics, and then the user sets their resolution to 1280x960, it ends up setting your camera to display more than you would have wanted once they run the game.

I’m only a couple weeks into learning Unity, so I’m not sure what the ideal way would be to do this…

This doesn’t work. None of it works.

It gets the correct size, such as turning a 40x40 square into a 40x40 pixels displayed. However, the pixels displayed are incorrect.

This is what I call “Pixel Imperfect”

Here is an image as to what I’m talking about:

edit: this seems to be an issue with the image being 40x40 and not a Power of Two.

edit: fixed this by using TexturePacker to pack all animations of a character into a POT image, while still keeping their 40x40 size. Alternatively, one could batch resize “Canvas Size” in Photoshop from NPOT (40x40) to POT (64x64) which would also resolve Unity’s butchering of the image when it is NPOT.

1487216--82777--$pixel imperfect.png
1487216--82778--$pixel imperfect.png