Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pixel-perfect scaling mode #6506

Closed
Zylann opened this issue Sep 15, 2016 · 23 comments
Closed

Pixel-perfect scaling mode #6506

Zylann opened this issue Sep 15, 2016 · 23 comments

Comments

@Zylann
Copy link
Contributor

Zylann commented Sep 15, 2016

There could be a new scaling mode in godot that preserves pixel matching on the screen.
For example, when the window resizes, instead of trying to fit the viewport on it in various ways, only do it with integer steps (x1, x2, x3...).

An example of this would be the GUI of Minecraft when set to Auto. If you resize the screen, it will change its scale but only by an integer factor, thus keeping game pixels and screen pixels in sync. So you can actually tell that the viewport scale is equal to the number of pixels a "game pixel" takes on screen.

I could investigate of a way to do this as a plugin, but I wonder if this can actually make it in the engine.

WDYT?

@CowThing
Copy link
Contributor

CowThing commented Sep 15, 2016

I actually made a script for this not too long ago, and all you have to do is set the script as an AutoLoad. It's simple enough, I don't really know if it needs to be built in.

extends Node

onready var root = get_tree().get_root()
onready var base_size = root.get_rect().size

func _ready():
    get_tree().connect("screen_resized", self, "_on_screen_resized")

    root.set_as_render_target(true)
    root.set_render_target_update_mode(root.RENDER_TARGET_UPDATE_ALWAYS)
    root.set_render_target_to_screen_rect(root.get_rect())

func _on_screen_resized():
    var new_window_size = OS.get_window_size()

    var scale_w = max(int(new_window_size.x / base_size.x), 1)
    var scale_h = max(int(new_window_size.y / base_size.y), 1)
    var scale = min(scale_w, scale_h)

    var diff = new_window_size - (base_size * scale)
    var diffhalf = (diff * 0.5).floor()

    root.set_rect(Rect2(diffhalf, base_size))
    root.set_render_target_to_screen_rect(Rect2(diffhalf, base_size * scale))

    # Black bars aren't needed, it already renders black outside of the viewport.
#   var odd_offset = Vector2(int(new_window_size.x) % 2, int(new_window_size.y) % 2)
#   VisualServer.black_bars_set_margins(diffhalf.x, diffhalf.y, diffhalf.x + odd_offset.x, diffhalf.y + odd_offset.y)

@Zylann
Copy link
Contributor Author

Zylann commented Sep 16, 2016

Oh, looks great :) but is there a way to get rid of the black borders when the window is resized? Like, make the game scale according to the given resolution but expand beyond the borders so we get the chance to fill it with some nice texture for example, or simply have a little bigger field of view?

@tiltfox
Copy link

tiltfox commented Sep 16, 2016

Hey @Zylann,

I came across this post (fortunately) while making my first dive into Godot Engine, coming from Unity. I'm just a hobbyist, with only a couple years in Unity, but I've been pretty impressed with Godot so far.

Anyway, I'm posting because your post, and @CowThing's post made me think about possible other solutions.

I tried out his script, and it does work nicely, but I desperately wanted to avoid black space if possible.

Now, what I ended up doing, I haven't fully worked out yet, but I think its possible that it could be a solution, and I wanted your input before I just settle on it.

What I did was set (in Display settings) my stretch mode to disabled. So by doing this, my game starts out at a pixel perfect scale, but resizing the window causes the viewable area to expand if the window is made larger, and the inverse if smaller, as you would expect.

Then, instead of resizing the viewport and getting blackspace, I decided I would instead use the Camera2D node's zoom to handle the amount of space I show on either side.

So in func _on_screen_resized() , I use the new window size to test the width in pixels against some const I made to hold my main target resolutions, which I got from a list reported by steam. Basically, if the window size is less than 1600, I zoom by 2x (0.5,0.5). If its between 1600 and 2200, I scale by 3x, if between 2200 and 3k, 4x. etc.

These zoom amounts work nicely for my game which is 32 x 32 for a tile, or base size of an object. Basically, everyone wont see things EXACTLY the same, unless they are 1080, 1440, or 4k, but I plan to solve this by allowing a mouse pan to accomodate the remaining unseen portions.

Anyway, I just wanted to toss that idea out for you if you dont mind showing more/less by a margin while always remaining pixel perfect. My game is top down so its not a deal breaker to show a little more or less between AR's or window sizes, especially if the player will be able to pan more/less depending on whats shown.

Thanks for posting, big help to me!

@SirPigeonz
Copy link
Contributor

I think it would be nice for pixel art games so it probably should be part of engine.

@txzeenath
Copy link

txzeenath commented Sep 16, 2016

@tiltfox
For resizing, I make all of my backgrounds with a 1:1 aspect ratio at 1.5x times the max size of my intended viewing area (so at 1920x1080 my backgrounds would be 2880x2880). The 1.5x area is just a "bleed" zone if the user has a different aspect ratio. Zoom is capped so they can only see within the 2880x2880 area.

Prevents players from seeing regions they shouldn't while preventing black bars on any screen or orientation. Just a bit more work to get backgrounds set up that look nice.

    var screenSize = OS.get_window_size()
    var height = viewport.get_rect().size.y
    var width = viewport.get_rect().size.x
    var zoom_ratio = max(1920.0/width,1080.0/height)
    var visW = width*zoom_ratio
    var visH = height*zoom_ratio
    if visW > 1920.0*1.5 or visH > 1920.0*1.5:#Limit view area
        zoom_ratio = min(1920.0*1.5/screenSize.width,(1920.0*1.5)/screenSize.height)

Currently I don't need pixel perfection so I don't snap the zoom values.

@Zylann
Copy link
Contributor Author

Zylann commented Sep 16, 2016

@tiltfox Using Camera zoom is nice and easy to do, but it won't scale the pixel grid, but the pixels themselves, even if zoom scales are integer. So you could end up with two moving objects overlapping a half of a pixel. This is not an issue for most games, but if you really want all game pixels to fit the viewport grid as in old-school games, the only way is to change the viewport size, not the camera zoom. But with this solution you get black borders, and I wonder if it's possible to get rid of them while still being able to get real blocky pixels. Maybe a different calculation?

@CowThing
Copy link
Contributor

Ah I see, for the game I was making I wanted to keep it at the same resolution, just scale it up from there. I modified the code so that the resolution can change and it will still scale up based on the initial size. There are still small black borders on the sides, but they will never be more than one scaled up pixel large (so if the scale is 7, the black border would never be more than 6 pixels large, the code also centers the image, so it would be 3 pixels on each side). This is because as far as I can tell it's not possible to render only part of a pixel using this method, it needs to be whole pixels.

extends Node

onready var root = get_tree().get_root()
onready var base_size = root.get_rect().size

func _ready():
    get_tree().connect("screen_resized", self, "_on_screen_resized")

    root.set_as_render_target(true)
    root.set_render_target_update_mode(root.RENDER_TARGET_UPDATE_ALWAYS)
    root.set_render_target_to_screen_rect(root.get_rect())

func _on_screen_resized():
    var new_window_size = OS.get_window_size()

    var scale_w = max(int(new_window_size.x / base_size.x), 1)
    var scale_h = max(int(new_window_size.y / base_size.y), 1)
    var scale = min(scale_w, scale_h)

    #This offset is needed to keep pixels square
    var offset = ((new_window_size / scale) - (new_window_size / scale).floor()) * scale
    var offsethalf = (offset * 0.5).floor()

    root.set_rect(Rect2(offsethalf, new_window_size / scale))
    root.set_render_target_to_screen_rect(Rect2(offsethalf, new_window_size - offset))

@mharmcode
Copy link

Just chiming in to say that I would love to see this as a built-in feature. For now though I am glad I found this discussion- lots of great insight on how to handle this via scripting.

@HummusSamurai
Copy link
Contributor

I love this idea @CowThing !

@Calinou
Copy link
Member

Calinou commented Dec 11, 2017

@HummusSamurai Please do not bump issues without contributing significant new information, use the 👍 reaction button on the issue's first post instead.

@reduz
Copy link
Member

reduz commented Dec 11, 2017

the Viewport API in Godot has everything you need to make this yourself. As it's not a feature you might use commonly, I suggest just make a script and put it in the asset library for others to use.

@reduz reduz closed this as completed Dec 11, 2017
@AllEternalsDeck
Copy link

AllEternalsDeck commented Mar 12, 2018

@reduz "the Viewport API in Godot has everything you need to make this yourself. As it's not a feature you might use commonly, I suggest just make a script and put it in the asset library for others to use."

Any pointer as to how?

The code written in this discussion is obsolete and based on 2.1 functions, and I don't think Viewport can be manipulated in that way anymore, especially in fullscreen.

I've been trying to make something like that work for a week but nothing seems to really work, aside from zooming the camera (but that has the mentioned problem of not preserving the pixel grid)

@Type1J
Copy link

Type1J commented Mar 12, 2018

@Omiminpo Look in your Project Settings under "Display -> Window -> Stretch"

Try setting "Mode" to "2D" and "Aspect" to "keep".

You might want other settings, but you can play with them to get what you want.

Does that help?

@Calinou
Copy link
Member

Calinou commented Mar 12, 2018

Try setting "Mode" to "2D" and "Aspect" to "keep".

This can scale to fractional values, which will look really bad when done with nearest-neighbor-filtered textures such as pixel art.

This could be fixed by adding something like a "2D Integer" and "Viewport Integer" scaling modes, which would only resize or scale the 2D viewport by the largest integer factor possible (and fill the rest with black or another color).

@Zylann
Copy link
Contributor Author

Zylann commented Mar 12, 2018

@Calinou it should not necessarily fill spaces with black if the view can expand further

@Calinou
Copy link
Member

Calinou commented Mar 12, 2018

@Calinou it should not necessarily fill spaces with black if the view can expand further

True, however, this would be only possible with the "2D Integer" scaling mode I proposed (and in 3D games only, based on my understanding), not the "Viewport Integer" scaling mode – unless you plan on implementing fractional pixel scaling, which is possible but doesn't look ideal.

@Type1J
Copy link

Type1J commented Mar 13, 2018

@Calinou You can set Aspect to "expand" to try to remove the black bars.

@AlvarBer
Copy link

AlvarBer commented Mar 13, 2018

@Calinou @Omiminpo I'm writing an article on how integer-only scaling can be done using an inner viewport so you can freely set what you want to display on the borders, but the gist of it is:

  • Using Camera2D and an inner viewport you can pretty much do it, expand the viewport on multiples only, and set the zoom to the opposite of that multiple, so if you are scaling by two set zoom to 0.5, etc
  • If you want to get really pedantic and avoid using floats, instead manipulate the canvas directly, which is what Camera2D is doing anyway, please see the script below for that.
extends Node2D

const DESIRED_RESOLUTION = Vector2(320, 180)


func _ready():
	self.get_viewport().connect("size_changed", self, "on_root_vp_size_change")
	self.on_root_vp_size_change()

func on_root_vp_size_change():
	var scales = self.get_viewport().size / self.DESIRED_RESOLUTION
	var scaling_factor = floor(min(scales[0], scales[1]))
	var actual_resolution = DESIRED_RESOLUTION * scaling_factor
	$Control/ViewportContainer.margin_top = -actual_resolution[1] / 2
	$Control/ViewportContainer.margin_bottom = actual_resolution[1] / 2
	$Control/ViewportContainer.margin_left = -actual_resolution[0] / 2
	$Control/ViewportContainer.margin_right = actual_resolution[0] / 2
	var default_transform = Transform2D(Vector2(1, 0), Vector2(0, 1), Vector2())
	$Control/ViewportContainer/Viewport.canvas_transform = default_transform.scaled(Vector2(scaling_factor, scaling_factor))

The hierarchy for this scene is:
Main
-- Control
---- ViewportContainer
------ Viewport

On the script I've purposely kept the inner viewport in the middle by setting all anchors to 0.5 and setting margins around that, but there are other ways to do it that are probably better, such as allowing viewport to be larger on x-axis so borders are only on the sides without breaking integer scaling.

I´m not 100 % sure this is perfect, as I´m still playing with it, but seems to work well enough.

@chanon
Copy link
Contributor

chanon commented Mar 14, 2018

Another method that can be used to easily control the viewport pixel size is Viewport's set_size_override method. You can use it to control the root viewport size without needing a special scene hierarchy.

For example, to make sure that the viewport doesn't scale anything, set_size_override to the same as OS.get_window_size().

# maybe in an autoload/singleton but in a scene script should also work
# Use this with Stretch Mode 2d and Aspect ignore.

func _ready():
	get_viewport().connect("size_changed", self, "window_resize")

func window_resize():
	get_viewport().set_size_override(true, OS.get_window_size())

Use this with Stretch Mode 2d and Aspect ignore.

The size you send to set_size_override controls how many pixels the viewport takes from the source world_2d. In this case we set the number of pixels width/height that the viewport takes to be the same as the size of the window. Result is that no scaling/stretching occurs. (If window gets bigger, viewport takes more pixels, no stretching happens).

You will need to adjust the viewport's global_canvas_transform so it is centered correctly on your content.

To do x2, x3 scaling if the window size is large enough, then just check the window size from get_window_size(). If it is large enough modify the size sent to set_size_override. (Eg. if it is >= 2x the content size, then you can give set_size_override half of the window size.)

For your scenes, make sure your actual content is in the middle, and then surround it with whatever you want to show on the edges. (You can have the top left of your actual content be at 0,0 as usual, just make sure your global_canvas_transform is correct and surround your content with whatever you want to show on the edges.)

BTW I believe this is the Viewport API reduz had in mind when he commented. I just have to say that Godot is amazingly flexible and it seems the devs have thought of everything.

@chanon
Copy link
Contributor

chanon commented Mar 14, 2018

Here is a full example

extends Node

# cache the viewport reference
onready var viewport = get_viewport()

# get game content (screen) width and height from project settings
# BTW don't forget to use stretch mode 2d and aspect ignore (since we're managing aspect ourselves)
onready var content_width = ProjectSettings.get("display/window/size/width")
onready var content_height = ProjectSettings.get("display/window/size/height")

func _ready():
	# listen to window resize changes
	viewport.connect("size_changed", self, "window_resize")
	# actually, should call window_resize() once here for initial setup, I forgot

func window_resize():
	# get the game window's size
	var window_size = OS.get_window_size()
	
	# see how big the window is compared to our content size
	# floor it so we only get round numbers (0, 1, 2, 3 ...)
	var x_multiple = floor( window_size.x / content_width )
	var y_multiple = floor( window_size.y / content_height )
	
	# use either x or y multiple .. the smaller one
	var multiple = min( x_multiple, y_multiple )
	
	# if resize window very small then multiple will come out as 0 so make it at least 1
	if (multiple < 1):
		multiple = 1
	
	# divide the window_size vector by our final multiple so we get the target size for the viewport
	# (we are dividing a vector by a scalar)
	var target_size = window_size / multiple

	# set the target_size to the viewport
	viewport.set_size_override(true, target_size)
	
	# center viewport on content
	viewport.global_canvas_transform = Transform2D( 0, Vector2( floor(target_size.x/2 - content_width/2), floor(target_size.y/2 - content_height/2) ) )

That's it. If it looks long its because of the comments.

Here is a full example project:
scaleExample2.zip

The example uses a 320x240 screen (content) size set in project settings with a 320x240 image with an alternating pixel background to check for artifacts. 2D pixel snapping is turned on and the image's filtering is off.

Short version without comments:

extends Node

onready var viewport = get_viewport()
onready var content_width = ProjectSettings.get("display/window/size/width")
onready var content_height = ProjectSettings.get("display/window/size/height")

func _ready():
	viewport.connect("size_changed", self, "window_resize")
	window_resize()

func window_resize():
	var window_size = OS.get_window_size()
	var multiple = min( floor( window_size.x / content_width ) , floor( window_size.y / content_height ) )
	if (multiple < 1):
		multiple = 1
	var target_size = window_size / multiple
	viewport.set_size_override(true, target_size)
	viewport.global_canvas_transform = Transform2D( 0, Vector2( floor(target_size.x/2 - content_width/2), floor(target_size.y/2 - content_height/2) ) )

Also, actually you can just use the 'Environment/Default Clear Color' setting in Project Settings if you just want a solid color that is not the default for the borders. In the example project I used a large ColorRect.

UPDATE in case anyone sees this: sysharm over at the QA site has a better version that uses viewport stretch mode which is better suited for pixel games:
https://godotengine.org/qa/25504/pixel-perfect-scaling?show=26997#a26997

@golddotasksquestions
Copy link

golddotasksquestions commented Mar 8, 2019

n-pigeon

I think it would be nice for pixel art games so it probably should be part of engine.

reduz

As it's not a feature you might use commonly, I suggest just make a script and put it in the asset library for others to use.

Why would this not be used commonly? There is definitely demand for it (in this thread, in the comments here and here and here) And I for one would want to see how my pixelart game looks without any halve-pixels scaled up pixels on screen. I agree with n-pigeon it probably should be part of the engine for everyone who created pixel art games. This is anything but a small portion of Godot users after all.
Right now, you have to wade through comments on reddit (I would not have found this thread here otherwise) for solutions you don't even know how well they are supported. If there are performance or other usability costs, or other downsides. Reading and especially understanding all the proposed scripts here is asking a lot from people who are just starting out and want their pixelgame just to be displayed as full pixels, nothing more nothing less. If it would be part of the preference settings, as I newbie, I would not have any of these worries.
I understand the subject is complicated, however if features are missing (like the ability to scale without black bars) they could be tracked and discussed here on github and added to in future releases.

@girng
Copy link

girng commented Dec 1, 2019

I had an issue with my fonts becoming distorted if the resized window's size contained an odd number.

I used this code in a screen_resized signal:

	var size = OS.get_window_size()
	var nW = size.x
	var nH = size.y
	
	if int(size.x) % 2 == 1:
		nW = size.x + 1
	if int(size.y) % 2 == 1:
		nH = size.y + 1
		
	get_viewport().set_size_override(true, Vector2(nW, nH))

Before and After

Hope that helps. This doesn't produce any black borders, bars, etc. I stumbled upon this issue from Google

@Calinou
Copy link
Member

Calinou commented Oct 14, 2020

Note: This is now being discussed in godotengine/godot-proposals#1666.

@Calinou Calinou reopened this Oct 14, 2020
@Calinou Calinou closed this as completed Oct 14, 2020
Leirda01 added a commit to Leirda01/spin-spong that referenced this issue Nov 20, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests