Stuttering Background Scrolling (Pixel Art)

Hello! New Defold user here. :waving_hand:

I am trying to create a low-res (288x208px) flappy bird clone to get a feel for Defold. You can view the project here: GitHub - BLTspirit/rocket-cherry: Flappy bird clone created to learn Defold · GitHub

The Issue

I am scrolling two seamless background sprites that are 288x208 each but there is a seemingly random stutter that is happening with each background layer.

Current Setup Overview

  • Using fixed timestep and fixed update frequency of 60
  • High DPI off
  • Display is set to 288x208
  • Texture filters set to nearest
  • Fixed camera with 4.0 ortho zoom
  • I have a GO with two sprite components that represent two copies of the 288x208 seamless background sprite. They are horizontally adjacent to one another and scroll to the left infinitely via the following code:
-- Define instance (script) properties
go.property("anim", hash("")) --Assigns the proper background layer
go.property("scroll_mult", 1) -- Closest layer scrolls at full speed x1 and furthest layer doesnt scroll (x0)
go.property("depth", -1)


local SPEED = 120

function init(self)
	local pos = go.get_position()
	pos.z = self.depth
	go.set_position(pos)
	msg.post("#sprite", "play_animation", {id = self.anim})
	msg.post("#sprite1", "play_animation", {id = self.anim})
end

function fixed_update(self, dt)
	local pos = go.get_position()
	local effective_speed = SPEED * self.scroll_mult * dt
	pos.x = pos.x - effective_speed
	if pos.x <= -288 then -- If left bg sprite goes completely offscreen, move it to the original position
		pos.x = 0
	end
    pos.x = math.floor(pos.x + 0.5)
	go.set_position(pos)
end
  • I upscale the window 4x via a game_manage.script in the bootstrap collection:
local level_data = {
	"#level_proxy"
}

local scroll_speed = 0
local scroll_acceleration = {1, 2, 3} -- update based on level
local current_level = 1
local state = "menu"


function init(self)
	msg.post("#level_proxy", "load")
	local width, height = window.get_size()
	local scale_factor = 4
	local window_w = width * scale_factor 
	local window_h = height * scale_factor
	
	
	window.set_size(window_w, window_h)
	local window_x = 1920/2 - window_w /2
	local window_y = 1080/2 - window_h/2
	window.set_position(window_x, window_y)

end

function on_message(self, message_id, message, sender)
	if message_id == hash("proxy_loaded") then
		msg.post(sender, "init")
		msg.post(sender, "enable")
		msg.post(sender, "acquire_input_focus")
	end
end

Please clarify further, or check out the project. Thanks!!

Move the background in update() instead. You will most likely get more calls to update() than fixed_update().

I unfortunately tried that with no success previously. Any other ideas?

I suspect this is part of it, as you’re throwing away a fractional part of the position, leaving the items unaligned. (a.k.a “truncating”)
You should move it by a multiple of the size of the sprite, to try to minimize the floating point drift.
Understanding floating point precision is an important part in games.

I think if you move this part before the IF, it should work:

pos.x = math.floor(pos.x + 0.5)
If pos.x <=288 then …

Maybe with a strict comparison :thinking:

Commenting out the floor() line and then entire scroll reset line does not fix it. It’s something else.

I made an object with simple left and right movement and it also seems to stutter. Hmm…

Even without this part, the movement still stutters. I definitely think I’m missing something with floating point position values but I’m not sure what. I come from GameMaker where consistent pixel level movement is just a given so just trying to wrap my head around achieving similar functionality.

You didn’t post what you replaced it with?
It’s important that the edge of the moved piece matches the other edge of the other piece.

I tried your project couple of days ago and it was running quite smooth on my machine.

Can you please try to make your fixed update like below?

function fixed_update(self, dt)
    local pos = go.get_position()
    local effective_speed = 1

    pos.x = pos.x - effective_speed
    if pos.x <= -288 then
	    pos.x = 0
    end
    go.set_position(pos)
end

Here each layer should move by 1 pixel (of your raw image) each update. It make the movment perfectly smooth on my machine. Please try it and say if the game is running smooth with such setting - if not, I’m worried that there may be issue with your machine (maybe too many processes in the background or outdated drivers?).

If however, it will look smooth with such settings, I will investigate further.

Edit: Also this one works smooth on my PC and have different speeds for each layer:

function fixed_update(self, dt)
    local pos = go.get_position()
    local effective_speed = math.floor(1 * self.scroll_mult * 2)
    pos.x = pos.x - effective_speed

    if pos.x <= -288 then 
    	pos.x = 0
    end
    go.set_position(pos)
end

But with this one, hills stutter like crazy:

function fixed_update(self, dt)
    local pos = go.get_position()
    local effective_speed = 1 * self.scroll_mult
    pos.x = pos.x - effective_speed

    if pos.x <= -288 then 
	     pos.x = 0
    end
    go.set_position(pos)
end

I think the underlying issue is not only floating-point precision, but also the fact that in the last example the hills move by 4 screen pixels (which corresponds to 1 pixel in the source image) every 4 updates. With a fixed update rate of 60 Hz, that means the hills effectively move at only 15 Hz, which is very noticeable.

One possible solution is to set the camera zoom to 1 and scale the image up 4x instead. However, in that case you lose the strictly pixel-perfect look, because a stylized pixel can move by a fraction of a “big” pixel on the screen. The result is smoother motion, though.

This is a common trade-off in pixel-art games: you have to decide whether pixel-perfect rendering or smoother movement is more important for you.

I can confirm the first two examples you gave result in smooth movement, but only on my laptop screen.

It seems that my laptop display was running at 120.11 Hz and my other monitor was at 60Hz. Switching my other monitor to 120 Hz fixes the stuttering.

I understand what you’re saying about choppy movement due to pixel-perfect rendering and I’m familiar with what that looks like so rest assured that’s not the issue I’m seeing.

Thank you for your help.

Because I didn’t replace it with anything. I just removed it for testing to confirm that it wasn’t the source of the issue. Removing that part obviously broke the infinite scroll effect but the fact that I could still see stuttering as the background layers left the screen told me that the part the shifts the sprite back was not the what was causing my issue.

That’s weird. I tested it on two screens, 120 and 60 Hz, and I cannot see any difference between them - I don’t see why there would be difference, since movment is updated in the fixed_update, which is not affected by screen refresh rate anyway. I even confirmed, that game works with 120 FPS on one screen and 60 on the other - still for me, judging by eye, I cannot spot any difference in the stutter.

I think this is more about compatiblity/drivers issue than this movement logic then. I have no idea what could help, but I don’t think many people will have this exact problem to be honest.

1 Like

I think the problem is you update frequency, it was set to 0.
If I set it to 60

and use this code:

function update(self, dt)
	local pos = go.get_position()
	local effective_speed = SPEED * self.scroll_mult * dt
	pos.x = math.floor(pos.x - effective_speed)
	if pos.x <= -288 then -- If left bg sprite goes completely offscreen, move it to the original position
		pos.x = 0
	end
	go.set_position(pos)
end

It is smooth.
With same code with update frequency = 0, every thing start to stutter.

Or you can also use fixed_update() if you set the fixed_update frequency accordingly.
But update() should be fine.

It must be something with my displays or something because your solution is fairly jittery for me. The smoothest I’ve managed to get it is by setting my displays to 120Hz and using @LesniakM 's

function fixed_update(self, dt)
    local pos = go.get_position()
    local effective_speed = math.floor(1 * self.scroll_mult * 2)
    pos.x = pos.x - effective_speed

    if pos.x <= -288 then 
    	pos.x = 0
    end
    go.set_position(pos)
end

Thanks for your input tho!

ok… this is weird :thinking: we should not get so much different behaviors on such simple things…
Did you save game.project before building? I ask because I always forgot to do it :laughing:

But Vsync was enabled, so it was anyway 60 or 120 updates per second, even with update frequency set to 0. I have stutter free movement for both Vsync enabled or update frequency set to 60, no matter if I use “update” or “fixed_update” function…

@noah_an_egg Which version of Defold you are using by the way? I don’t see possible solution at the moment, but I willing to try the project with same Defold version as yours.

…

Aaaaand just before sending, what I wrote above, I tried to change the refresh rate of my 120 Hz monitor to 60 Hz. And using Mathias’s update function:

function update(self, dt)
	local pos = go.get_position()
	local effective_speed = SPEED * self.scroll_mult * dt
	pos.x = math.floor(pos.x - effective_speed)
	if pos.x <= -288 then -- If left bg sprite goes completely offscreen, move it to the original position
		pos.x = 0
	end
	go.set_position(pos)
end

I have some stutter now! But after setting fixed_update to 60, its gone.

With this code its still perfectly smooth for both 60 and 120 Hz refresh rate on the 120Hz monitor, with fixed_update in Display section set to 0.

function fixed_update(self, dt)
    local pos = go.get_position()
    local effective_speed = math.floor(1 * self.scroll_mult * 2)
    pos.x = pos.x - effective_speed

    if pos.x <= -288 then 
    	pos.x = 0
    end
    go.set_position(pos)
end

@noah_an_egg Have you tried setting update frequency in the Display tab of project settings?

Have you done the Endless runner tutorial? It includes a scrolling background that should work without stuttering and is therefore a good test as well as learning opportunity, and starting point.