Pixellated 3D & Smooth Camera — Prevent Jitter

Godot Version

4.4

Question

I have pixellated 3D via a SubViewport, with a smooth camera (game is rendering at a high resolution). The camera follows the player with lerp. The game is top down 3rd person.

The issue is, that when the camera is furthest from the player, the player’s movement (snapped to the pixellation) appears jittery.

How can I prevent that? I’m not even sure it’s physically possible so my compromise solution is to try to temporarily reparent the camera rig to the player to keep them in sync, when the player is furthest from the camera. That way the jitteriness is “transferred” to the rest of the scene, and is hidden somewhat by the movement.

But, I can’t figure how to achieve this with 360 movement and have the camera continue to move freely around the player while still being its child in this instance. Any thoughts / advice would be hugely appreciated!

Can you show an example of that jittery movement?

Sure, here’s a video:

Hopefully that’s clear enough to see (it’s actually more apparent in the game than the video), you can see the smooth camera and the placeholder flower that snaps to the pixellation

Soo I think thats the problem on the movement not camera…
Have you checked it with a still camera?
There are any possible problems like jittery
Movement, pixelated problem or just a jittery camera

Yeah, exactly. I want to keep the pixel-perfect pixellation (no visible subpixel movement). So, the snapping that’s causing the jitter is unavoidable (unless someone can correct me).

I believe the solution I described in my original post might be the answer — but I’m not sure how to achieve that.

I think then you should try changing your viewport size,instead of using a subviewport fancy things : )

Okay sooo,if your flower movement is right camera is smooth and the pixelation is the problem then you can try doing this:

Thanks, but I don’t think that answers any questions re movement.

What do you mean?

Well, please tell me — what part of the video is relevant to my question?

1 Like

First part step ONE
Just clarify that which one is your problem is this the problem of the movement or camera or just the pixelation thing.If the problem is on the pixelation thing and the subviewport is giving you the problem then lower your viewport resolution according to that video that may not able to output any hd ui but your jittery would be fixed or just see that does this video help : )

This isn’t my issue — the jitteriness is due to the pixellation, and I don’t want to change the viewport resolution as that will mean I lose the pixellated style / and or introduce visible “subpixel” movement.

I’m okay with the jitteriness, the point is that I want to lock the camera to the same pixellated grid (so the camera itself becomes jittery) when the player is moving and the camera is following at it’s furthest distance. That way, the player appears non-jittery, and jitteriness of the environment is hidden with illusion of movement. Then, when the player stops moving, I’d like to detach the camera and allow it to recenter with smoothness (not locked to pixel grid)

The issue is, with 360degree movement, how can we parent the camera to the player and allow for changes in direction while keeping it at a consistent distance?

Okay then…first make your node structure like this
[/├── CameraPivot
│ └── CameraTilt
│ └── SpringArm3D
│ └── Camera3D
]

CameraTilt is going under CameraPivot and SpringArm is going under CameraTilt and camera is going under springarm

Then add this code to the CameraPivot

extends Node3D

@onready var tilt = $CameraTilt

# Sensitivity (adjust to your liking)
var sensitivity := 0.2

# Clamp angles to prevent flipping
var min_pitch := -60.0
var max_pitch := 60.0

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion:
		# Yaw - horizontal rotation
		rotate_y(deg_to_rad(-event.relative.x * sensitivity))
		
		# Pitch - vertical rotation
		tilt.rotate_x(deg_to_rad(-event.relative.y * sensitivity))

		# Clamp the pitch to avoid flipping the camera
		tilt.rotation_degrees.x = clamp(tilt.rotation_degrees.x, min_pitch, max_pitch)

Could you please explain how you believe this solves the problem? I’ve tried and it doesn’t solve it for me

Wait…after reading your issue for a 1000 year I figured it out !!!
You are taking about that,you wanna change the Flowers direction according to the mouse rotation like any other fps/tps game right?
Then if I am correct then I will give you the code it should be easy : )

No, that’s not it at all, but I appreciate your help

1 Like

Okeyyyy..

I’ve read this entire topic and I’m amazed at how there isn’t a single piece of code from OP.
The video clearly depicts the issue but without any access to the related code, you’re gambling on having someone come along who has already experienced the issue and knows a fix.

I won’t be able to help unless I see some relevant code from this project. Therefore, please post that if you wish to receive feedback on your implementation.

As a sidenote, I don’t view your current rendering artefact to be a result of the pixelation – that doesn’t make sense. I believe it is more likely that your movement code is causing the issue.

1 Like

Makes sense, hopefully this code helps:

CharacterBody3D:

extends CharacterBody3D

@export var move_speed := 6.8
@export var rotation_speed := 8.6
@export var gravity := 0.0
@export var deceleration := 36.0  # higher = shorter slide

const Z_COMP := sqrt(2.0)

# our “north star” yaw for the box
var desired_yaw: float

func _ready():
	# start off facing however the box is rotated in the editor
	desired_yaw = $Box4.rotation.y

func _input(event):
	# only catch key-presses for our movement actions
	if event is InputEventKey and event.pressed:
		for action in ["move_left","move_right","move_down","move_up"]:
			if event.is_action(action):
				_update_desired_yaw()
				break

func _update_desired_yaw():
	# calculate the current input vector & camera-flattened dir
	var iv = Input.get_vector("move_left","move_right","move_down","move_up")
	if iv == Vector2.ZERO:
		return  # no movement
	var cr = $"../../SubViewport/CameraRig/Camera3D".global_transform.basis.x
	var cf = -$"../../SubViewport/CameraRig/Camera3D".global_transform.basis.z
	cr.y = 0; cr = cr.normalized()
	cf.y = 0; cf = cf.normalized()

	# stretch forward before normalizing for your √2 world-scale
	var raw = cr * iv.x + cf * (iv.y * Z_COMP)
	var d   = raw.normalized()
	desired_yaw = atan2(d.x, d.z)

func _physics_process(delta):
	# — velocity XZ —
	var iv = Input.get_vector("move_left","move_right","move_down","move_up")
	if iv != Vector2.ZERO:
		# build dir & apply full-speed immediately
		var cr = $"../../SubViewport/CameraRig/Camera3D".global_transform.basis.x
		var cf = -$"../../SubViewport/CameraRig/Camera3D".global_transform.basis.z
		cr.y = 0; cr = cr.normalized()
		cf.y = 0; cf = cf.normalized()

		var raw = cr * iv.x + cf * (iv.y * Z_COMP)
		var d   = raw.normalized()
		velocity.x = d.x * move_speed
		velocity.z = d.z * move_speed * Z_COMP
	else:
		# brake evenly
		velocity.x = move_toward(velocity.x, 0, deceleration * delta)
		velocity.z = move_toward(velocity.z, 0, deceleration * delta)

	# — always lerp the box toward desired_yaw —
	var box = $Rogue
	var current = box.rotation.y
	var diff = wrapf(desired_yaw - current, -PI, PI)
	var step = rotation_speed * delta
	if abs(diff) <= step:
		box.rotation.y = desired_yaw
	else:
		box.rotation.y = lerp_angle(current, desired_yaw, step)

	# — gravity + slide —
	velocity.y -= gravity * delta
	move_and_slide()

	# — animation state —
	_update_animation_state()

func _update_animation_state():
	var sprite = $AnimatedSprite3D
	if not sprite:
		return

	var moving = abs(velocity.x) > 0.1 or abs(velocity.z) > 0.1
	# use GDScript’s ?: ternary, not Python’s if-else
	var want_anim = moving if "walk" else "idle"

	if sprite.animation != want_anim:
		sprite.animation = want_anim
		sprite.play()

CameraRig Node3D:

extends Node3D

@export var move_speed: float = 60.0    # How fast the camera rig position lerps towards player
@export_range(0, 50) var orbit_speed: float = 4.0
var _target_orbit := rotation.y

@export var circular_radius: float = 0.0
@export var circular_speed: float = 0.2
var _circ_angle: float = 0

@export var player: Node3D        # Assign your player node in the inspector

@onready var cam: Camera3D = $Camera3D


func _process(delta: float) -> void:
	if player:
		# Lerp the CameraRig's position towards the player
		global_position = global_position.lerp(player.global_position, 1.0 - exp(-move_speed * delta))
	
	rotation.y = lerpf(rotation.y, _target_orbit, 1.0 - exp(-orbit_speed * delta))
	if absf(rotation.y - _target_orbit) < 0.02:
		rotation.y = _target_orbit
	
	# Optional: move camera in a small circle around rig
	#_circ_angle -= TAU * circular_speed * delta
	#cam.position.x = cos(_circ_angle) * circular_radius
	#cam.position.y = sin(_circ_angle) * circular_radius

Camera3D:

class_name Camera3DTexelSnapped3
extends Camera3D

@export var snap := true
@export var snap_objects := true

var texel_error := Vector2.ZERO

@onready var _prev_rotation := global_rotation
@onready var _snap_space := global_transform
var _texel_size: float = 0.0

var _snap_nodes: Array[Node]
var _pre_snapped_positions: Array[Vector3]


func _ready() -> void:
	RenderingServer.frame_post_draw.connect(_snap_objects_revert)


func _process(_delta: float) -> void:
	# rotation changes the snap space
	if global_rotation != _prev_rotation:
		_prev_rotation = global_rotation
		_snap_space = global_transform
	_texel_size = size / float((get_viewport() as SubViewport).size.y)
	# camera position in snap space
	var snap_space_position := global_position * _snap_space
	# snap!
	var snapped_snap_space_position := snap_space_position.snapped(Vector3.ONE * _texel_size)
	# how much we snapped (in snap space)
	var snap_error := snapped_snap_space_position - snap_space_position
	if snap:
		# apply camera offset as to not affect the actual transform
		h_offset = snap_error.x
		v_offset = snap_error.y
		# error in screen texels (will be used later)
		texel_error = Vector2(snap_error.x, -snap_error.y) / _texel_size
		if snap_objects:
			_snap_objects.call_deferred()
	else:
		texel_error = Vector2.ZERO


func _snap_objects() -> void:
	_snap_nodes = get_tree().get_nodes_in_group("snap")
	_pre_snapped_positions.resize(_snap_nodes.size())
	for i in _snap_nodes.size():
		var node := _snap_nodes[i] as Node3D
		var pos := node.global_position
		_pre_snapped_positions[i] = pos
		var snap_space_pos := pos * _snap_space
		var snapped_snap_space_pos := snap_space_pos.snapped(Vector3(_texel_size, _texel_size, 0.0))
		node.global_position = _snap_space * snapped_snap_space_pos


func _snap_objects_revert() -> void:
	for i in _snap_nodes.size():
		(_snap_nodes[i] as Node3D).global_position = _pre_snapped_positions[i]
	_snap_nodes.clear()

Display Control:

extends Control

@export var viewport: SubViewport
@export var pixel_movement := true
@export var sub_pixel_movement_at_integer_scale := true

@onready var _sprite: Sprite2D = $Sprite2D


func _process(_delta: float) -> void:
	var screen_size := Vector2(get_window().size)
	# viewport size minus padding
	var game_size := Vector2(viewport.size - Vector2i(2, 2))
	var display_scale := screen_size / game_size
	# maintain aspect ratio
	var display_scale_min: float = minf(display_scale.x, display_scale.y)
	_sprite.scale = Vector2(display_scale_min, display_scale_min)
	# scale and center control node
	size = (_sprite.scale * game_size).round()
	position = ((screen_size - size) / 2).round()
	# smooth!
	if pixel_movement:
		var cam := viewport.get_camera_3d() as Camera3DTexelSnapped3
		var pixel_error: Vector2 = cam.texel_error * _sprite.scale
		_sprite.position = -_sprite.scale + pixel_error
		var is_integer_scale := display_scale == display_scale.floor()
		if is_integer_scale and not sub_pixel_movement_at_integer_scale:
			_sprite.position = _sprite.position.round()

Structure:

Node3D
|- Level
   |- CharacterBody3D
|- SubViewport
   |- CameraRig
       |- Camera3D
|- Display
   |- Sprite2D

Solved it!

Following guidance from here:
https://www.reddit.com/r/godot/comments/1hp7rhy/how_to_fix_this_annoying_camera3d_rotation_jitter/

I set the functions in both the player movement and camera setup to use _process() rather than one using _physics_process(), and this has smoothed things out nicely.

I should have posted code initially as Sweatix requested — will remember that for next time.

2 Likes