Low-res pixel-perfect movement (without camera)

Godot Version

v4.3.stable.official [77dcf97d8]

Question

I’m trying to move characters in a low-res pixel-art scene (sub-pixel movement, scaled up). But every way I tried to do that has jittering or stuttering.

I know how to do smooth movement with a camera and a shader, but a camera will complicate my life because I just want to move some things in the world, and have those collide with other things.

I tried a similar approach with a shader on a sprite, with and without rounding. I tried no shader. I tried various combinations of “Snap 2D” for all of that. But none of these produced smooth movement.

character movement test

(The gif may make “smooth camera” look less smooth, but in game itself, it’s perfectly smooth, unlike any of the others.)

Yes, I did see the article on jitter and stutter and that wasn’t really applicable to my issue.

How do I get it to move smoothly?

If there isn’t a direct solution, I guess my options are:

  1. Scale up the sprites, not the viewport container, and then pixel-perfect movement would be much smoother, given the much greater number of pixels. Then I guess I’d take that end result and scale it again (or let the game engine do that for me) to handle different resolutions.
  2. Use a camera and do manual collision detection
  3. I briefly tried using different viewports with a shared world, but that didn’t seem like it would work.

Would #1 be the best option?

Scene tree and code

Code:

extends Control

var position_unrounded = null
var new_pos = null
const MOVE_DELAY = 0.04
var move_timer = MOVE_DELAY
var MOVE_AMOUNT = 0.4

func update_pos(delta):
	if position_unrounded == null:
		position_unrounded = new_pos
	else:
		position_unrounded = lerp(position_unrounded, new_pos, 5*delta)
	var cam_subpixel_pos = round(position_unrounded) - position_unrounded
	%SpriteSR.material.set_shader_parameter("cam_offset", cam_subpixel_pos)
	%SpriteSU.material.set_shader_parameter("cam_offset", cam_subpixel_pos)
	%SpriteSnap.material.set_shader_parameter("cam_offset", cam_subpixel_pos)
	%SpriteSR.position.x = round(position_unrounded)
	%SpriteSU.position.x = position_unrounded
	%SpriteSnap.position.x = position_unrounded

func _ready():
	new_pos = %SpriteSR.position.x
	update_pos(0)

	_global.viewport_container = $SubViewportContainerCamera
	new_camera_pos = %SubViewport.get_visible_rect().position + %SubViewport.get_visible_rect().size / 2
	update_camera(0)

func _process(delta):
	move_timer -= delta
	if move_timer < 0:
		new_pos += MOVE_AMOUNT
		move_timer += MOVE_DELAY
		%SpriteRaw.position.x += MOVE_AMOUNT
		%SpriteSnapRaw.position.x += MOVE_AMOUNT
		new_camera_pos.x -= MOVE_AMOUNT
	update_pos(delta)
	update_camera(delta)

# Camera
var new_camera_pos
var actual_cam_pos = null

func update_camera(delta):
	if actual_cam_pos == null:
		actual_cam_pos = new_camera_pos
	else:
		actual_cam_pos = lerp(actual_cam_pos, new_camera_pos, 5*delta)
	var cam_subpixel_pos = actual_cam_pos.round() - actual_cam_pos
	$SubViewportContainerCamera.material.set_shader_parameter("cam_offset", cam_subpixel_pos)
	%Camera2D.global_position = actual_cam_pos.round()

Following recommendations elsewhere, I got it working as intended by scaling up the viewport, then scaling down the viewport container.

My final setup:

  • A SubViewportContainer with scale = 0.25.
  • This has a SubViewport child, which has a “root” Node2D child with scale = 16 (to scale the game up 4x, after the downscaling of the SVC).
  • Put a sprite (or some other Node2D) underneath this root and move it like shown in the post above.
    • You don’t really need a move timer. You can update the position directly by adding MOVE_AMOUNT * delta / MOVE_DELAY (and MOVE_AMOUNT / MOVE_DELAY can be simplified to 1 variable).
      • A move timer may have some benefits (probably not including performance, since it doesn’t reduce the number of updates called). But it makes movement less smooth, because it has occasional big changes instead of constant small ones (lerp only partially mitigates that issue).
    • If you have something which moves constantly at the same rate (e.g. the screen movement in a runner), you could set the sprite position directly (like SpriteRaw). The update_pos method (with lerp) is useful for smoothing less consistent movement (e.g. moving a character with the keyboard), and for when you want to combine that with constant movement.
  • You don’t need a shader.
  • Set the texture filter to “nearest” (for pixel-art) on the sprite and on the viewport container.
  • Most other things just have default values.
  • If you want to move many sprites at once, you could create a Node2D child under the “root” Node2D node and put them all under that node, and then move that node (don’t move the root node).

Note: This still isn’t perfectly smooth for really small or slow movements. I imagine this is just due to the limited resolution, and this can be fixed by further scaling it up (and then back down again). But having a viewport resolution in the 10s of thousands of pixels wide doesn’t seem like the best idea. Maybe shaders can fix this, but nothing I tried worked.

Updated code showing one sprite updated with update_pos and one updated directly:

extends Control  
  
var position_unrounded = null  
var new_pos = null  
const MOVE_AMOUNT = 10  
  
func update_pos(delta):  
	if position_unrounded == null:  
		position_unrounded = new_pos  
	else:  
		position_unrounded = lerp(position_unrounded, new_pos, 5*delta)  
	%SpriteSU2.position.x = position_unrounded  
  
func _ready():  
	new_pos = 100.0  
	update_pos(0)  
  
func _process(delta):  
	%SpriteRaw2.position.x += MOVE_AMOUNT * delta  
	new_pos += MOVE_AMOUNT * delta  
	update_pos(delta)

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.