X Coordinate over 60,000 seems to create bug?

Godot Version

4.3

Question

`I’ve got a player with an in-code state machine that’s worked across every scene I’ve plugged it into, which have all been test scenes, so reasonably small.

On boxing out a larger level scene, I’ve run into an issue where the player controller works fine up until they run far enough for their global_position.x to hit 60,000, give or take a few, at which point the state machine breaks down and the character can no longer stay in its Wallgrabbing state. This gif shows the player crossing the threshold–every surface to the left allows the player to wallgrab normally, and every surface to the right flicks back and forth between falling and wallgrabbing states about once per frame. This holds true even if the player runs all the way back to the start of the level, so it doesn’t seem like a time or memory issue but my knowledge there is very limited.

xpos

I’ve tested the issue at different Y heights, and though it changes the exact breakpoint very slightly, it always lands between 59,000 and 61,000 on the X-axis with the same behavior change.

Is there some soft cap to sizing on scenes in Godot?

I’m pasting the character’s state machine below, which is all handled within a single code. Apologies for the length. The relevant portion is in the first few sections, but including the rest since there are a few other areas that interact with the Wallgrabbing state.

Any ideas are much appreciated. Thanks!

func _physics_process(delta):
	knockback = lerp(knockback, Vector2.DOWN, 0.5)
	if state == States.CROUCHING or state == States.CRAWLING:
		is_hidden = true
	else: is_hidden = false
	# Set Gravity
	velocity.y += find_gravity() * delta + (knockback.y)
	
	if lifestate == LifeStates.ALIVE:
		if raycast_bottom_left.is_colliding() and raycast_top_left.is_colliding():
			on_wall_left = true
		else: on_wall_left = false
		
		if raycast_bottom_right.is_colliding() and raycast_top_right.is_colliding():
			on_wall_right = true
		else: on_wall_right = false

		#State machine decision tree (no sprite management)
		if is_on_floor():
			if get_input_velocity() == 0:
				if Input.is_action_pressed("input_down") or (crouch_check_left.is_colliding() or crouch_check_right.is_colliding()):
					state = States.CROUCHING
				else:
					state = States.IDLE
			if (get_input_velocity() == 1) or (get_input_velocity() == -1):
				if Input.is_action_pressed("input_down") or (crouch_check_left.is_colliding() or crouch_check_right.is_colliding()):
					state = States.CRAWLING
				else:
					state = States.RUNNING
		if is_on_wall_only() and (can_wall_grab == true):
			if on_wall_left == true and (get_input_velocity() == -1):
				state = States.WALLGRABBING
				is_wall_grabbing_left = true
				#gun.wallgrab_left = true
			elif on_wall_right == true and (get_input_velocity() == 1):
				state = States.WALLGRABBING
				is_wall_grabbing_right = true
				#gun.wallgrab_right = true
		elif (!is_on_floor() and velocity.y < 0) and state != States.WALLJUMPING:
			state = States.JUMPING
		elif !is_on_floor() and velocity.y > 0.0 and state != States.WALLJUMPING:
			state = States.FALLING
		
		
		#Horizontal Control
		if state in [States.IDLE, States.CROUCHING, States.RUNNING, States.CRAWLING, States.JUMPING, States.FALLING]:	
			if has_control == true:	
				if get_input_velocity() == 1:
					if state == States.CROUCHING or state == States.CRAWLING:
						velocity.x = min(velocity.x + acceleration, crawl_speed) + knockback.x
					else:
						velocity.x =  min(velocity.x + acceleration, speed) + knockback.x
				elif get_input_velocity() == -1:
					if state == States.CROUCHING or state == States.CRAWLING:
						velocity.x =  max(velocity.x - acceleration, -crawl_speed) + knockback.x
					else:
						velocity.x =  max(velocity.x - acceleration, -speed) + knockback.x
				else:
					velocity.x = lerp(velocity.x, 0.0, deceleration) + knockback.x
				can_wall_grab = false
			
		#Edge Sticking
		if state == States.IDLE and get_input_velocity() == 0:
			if floorcheck_left.is_colliding():
				if !floorcheck_right.is_colliding():
					velocity.x = -300
				else: pass
			elif floorcheck_right.is_colliding():
				if !floorcheck_left.is_colliding():
					velocity.x = 300
				else: pass
		else: pass
		

		#Single Jump Mechanics
		if state in [States.IDLE, States.CROUCHING, States.RUNNING, States.CRAWLING]:
			has_double_jumped = false
			can_jump = true
			if Input.is_action_just_pressed("input_jump"):
				jump()
				
		#Double Jump Mechanics
		if state in [States.JUMPING, States.FALLING]:
			if Input.is_action_just_pressed("input_jump") and has_double_jumped == false:
				doublejump()
				

		#Wallgrabbing Mechanics
		if state == States.WALLGRABBING:
			velocity.y = 0
			#var walljump_direction = (mouse_position - position).normalized()
			if Input.is_action_just_released("input_left") or Input.is_action_just_released("input_right"):
				state = States.FALLING
				is_wall_grabbing_left = false
				gun.wallgrab_left = false
				is_wall_grabbing_right = false
				gun.wallgrab_right = false
				has_double_jumped = false
			if Input.is_action_just_pressed("input_jump"):
				state = States.WALLJUMPING
				walljump()
				
		#Crouching Mechanics
		if state in [States.CROUCHING, States.CRAWLING]:
			on_crouch()
		else:
			on_stand()	

		#Particle Controller
		if state == States.FALLING:
			land_particle_emitted = false
			jump_particle_emitted = false
			
		if state == States.WALLGRABBING:
			jump_particle_emitted = false
		
		if state == States.RUNNING:
			run_particles.emitting = true
		else:
			run_particles.emitting = false
			
		if (state == States.JUMPING or States.FALLING) and is_on_floor():
			if land_particle_emitted == false:
				land_particles_left.emitting = true
				land_particles_right.emitting = true
				land_particle_emitted = true
			else:
				pass
		
		#Sprite Controller
		if state == States.IDLE:
			body_sprite.play("idle")
		if state == States.RUNNING:
			body_sprite.play("run")
		if state == States.CROUCHING:
			body_sprite.play("crouch")
		if state == States.CRAWLING:
			body_sprite.play("crawl")
		if state == States.JUMPING:
			body_sprite.play("jump")
		if state == States.FALLING:
			body_sprite.play("fall")
		if state == States.WALLGRABBING:
			body_sprite.play("wallgrab")
		#flip body sprite for x velocity
		if velocity.x < 0:
			body_sprite.flip_h = true
		if velocity.x > 0:
			body_sprite.flip_h = false
		#flip head sprite for x velocity
		var mouse_position = get_global_mouse_position()
		if mouse_position.x < global_position.x:
			head_sprite.flip_h = true
		else:
			head_sprite.flip_h = false
		#tilt and squash
		if velocity.x < 300 and velocity.x > -300:
				sprite_controller.rotation_degrees = 0
		if state in [States.CRAWLING, States.CROUCHING, States.WALLGRABBING, States.IDLE]:	
			sprite_controller.rotation_degrees = 0
		else:
			if velocity.x < 0:
				sprite_controller.rotation_degrees = max((-velocity.x / -speed) * 100, -10)		
			if velocity.x > 0:
				sprite_controller.rotation_degrees = min((velocity.x / speed) * 100, 10)

		head_sprite.rotation_degrees = head_tilt_modifier
		
		if state == States.WALLGRABBING:
			animation_player.play("RESET")
			if is_wall_grabbing_left == true:
				head_tilt_modifier = 30
				gun.wallgrab_left = true
			elif is_wall_grabbing_right:
				head_tilt_modifier = -30
				gun.wallgrab_right = true
		if state != States.WALLGRABBING:
			head_tilt_modifier = 0
			if animation_player.is_playing() != true:
				animation_player.play("default")
			else:
				pass
			
		if state == States.RUNNING:
			animation_player.speed_scale = 5
		else:
			animation_player.speed_scale = 2
			
		if state in [States.IDLE, States.CROUCHING, States.RUNNING, States.CRAWLING, States.WALLGRABBING]:
			sprite_controller.scale.y = 1
			sprite_controller.scale.x = 1
			
		else:
			if velocity.y == 0:
				sprite_controller.scale.x = 1
			if velocity.y > 0:
				sprite_controller.scale.x = 1 - (velocity.y / fall_gravity)
				sprite_controller.scale.y = .8 + (velocity.y / fall_gravity)
			
			if velocity.y < 0:
				sprite_controller.scale.x = 1 - (velocity.y / -fall_gravity)
				sprite_controller.scale.y = .8 + (velocity.y / -fall_gravity)

		#hide gun while crawling and crouching
		if state in [States.CROUCHING, States.CRAWLING]:
			head_sprite.visible = false
			gun.visible = false
			gun.gun_available = false
		else:
			head_sprite.visible = true
			gun.gun_available = true
		
		if state != States.WALLGRABBING:
			gun.wallgrab_left = false
			gun.wallgrab_right = false
		
		look_rotation = gun.gun_rotation
		if gun.wallgrab_left == true:
			head_sprite.flip_h = false
		if gun.wallgrab_right ==true:
			head_sprite.flip_h = true
		
		#head sprite rotation
		if ((look_rotation > -15 + head_tilt_modifier) and (look_rotation < 15 + head_tilt_modifier)) or ((look_rotation > 165 + head_tilt_modifier) or (look_rotation < -165 + head_tilt_modifier)):
			head_sprite.play("straight")
		if ((look_rotation > 15 + head_tilt_modifier) and (look_rotation < 45 + head_tilt_modifier)) or ((look_rotation > 135 + head_tilt_modifier) and (look_rotation < 165 + head_tilt_modifier)):
			head_sprite.play("down1")
		if ((look_rotation > 45 + head_tilt_modifier) and (look_rotation < 135 + head_tilt_modifier)):
			head_sprite.play("down2")	
		if ((look_rotation > -45 + head_tilt_modifier) and (look_rotation < -15 + head_tilt_modifier)) or ((look_rotation > -165 + head_tilt_modifier) and (look_rotation < -135 + head_tilt_modifier)):
			head_sprite.play("up1")
		if ((look_rotation > -135 + head_tilt_modifier) and (look_rotation < -45 + head_tilt_modifier)):
			head_sprite.play("up2")
			
		#gun sprite flip on rotation
		if look_rotation > -90 and look_rotation < 90:
			gun.gun_sprite.flip_v = false
		elif look_rotation > 90 or look_rotation < -90:
			gun.gun_sprite.flip_v = true
	elif lifestate == LifeStates.DEAD:
		state = States.IDLE
		deceleration = 0.05
		velocity.x = lerp(velocity.x, 0.0, deceleration)
		sprite_controller.scale.x = 1
		sprite_controller.scale.y = 1
		head_sprite.visible = false
		gun.visible = false
		gun.gun_available = false
		body_sprite.play("dead")
		sprite_controller.rotation_degrees = 0

`

I think the problem must be in is_on_wall_only().

Can we see the code used for that function?

I wouldn’t be surprised if it’s a float precision problem. I notice that various places in the code you have things like:

if velocity.y == 0

That’s not always going to work as you expect. With floating point values, there’s an excellent chance that in some cases you do get 0.0, but in others you get 0.0000000000001 or something that’s really close to zero but not zero. Floating point is particularly prone to problems with this kind of thing when you make two floating point values with greatly differing magnitudes interact. For example:

var val: float = 0.0

while true:
    val += 1.0
    print(val)

If you run that, eventually val will reach a large enough magnitude that adding 1.0 to it doesn’t change it any more, and it will stall at that number and spin forever. This happens because internally (hand-waving simplification follows…), floating point has two fields; a value and an exponent. The value and the exponent take a fixed number of bits. So, to simplify it, if we had (say) 8bit float values, we might do 4 bits value, 4 bits exponent. So, the value can be -8 to 7, the exponent can be -8 to 7, and we have a range of -8^-8 to 7^7, meaning we can represent values between 0.00000005960464477539 and 823543.

But consider what happens if we add (say) 2^6 (that is, 64) and 1^1 (that is, 1). The next increment in 2^6 is 3^6, which is 729, vastly more than a difference of 1. There are only 4 bits for the exponent, and the difference between them would need 5 bits to represent, so the addition winds up being truncated.

The actual floating point standard is a little more complex than that (and in Godot’s case has 64 bits to play with), but the principle is the same. Too much distance between two values can cause unexpected results.

All of which is to say, if you value your sanity don’t check float values against 0.0, instead use the is_zero_approx() function.

2 Likes

I just use the built-in function… do you mean any pieces of code that are accessing it?

To compliment @hexgrid very informative answer, there is more in the official docs about this Large world coordinates — Godot Engine (stable) documentation in English

1 Like

Woah, this one’s going to take a couple read throughs to get my head around haha. But I get the basic issue with it, and had been running into that problem earlier with x velocity checks. Had never heard of the is_zero_approx() function, but will definitely be putting that into use.

That said, I swapped the function in for that line of code you mentioned. Would that also cause problems with anything involving
if val < 0
as well? My assumption is that using it non-equal checks would cause problems too, but I don’t completely understand how that function works. I’m assuming it just rounds below a certain decimal.

1 Like

Thank you! And yes, huge thanks to @hexgrid for the lesson. I’ll read through that documentation later today.

That was what I was thinking it sounded like too. Your explanation is excellent though.

@lepho
I did not realise this was a CharacterBody2D method, sorry. (I don’t have any experience with this node type at all). I was actually thinking that if is_on_wall was returning false because of either precision problems in collisions or perhaps your velocity being virtually 0, your collision might not be firing.

I am going to read what @gentlemanhal linked to now. And ducking out of the convo…

Less than or greater than checks aren’t as prone to difficulties, it’s equality checks that cause most problems with float.

Let’s say, for example, that you want to see if something is 1/3. Floating point (at least, the kind used on most machines) can’t perfectly represent 1/3, so it’s rounded/truncated. If you have two values that should be 1/3 and you’re comparing them for equality, if they happen to have rounded/truncated differently you’ll get an unequal result even though they would be equal if you were using infinite precision math.

Which computers can do, but it’s slow and expensive, so for games we typically live with the inaccuracies of float.

>, >=, <= and < divide things into two ranges and just ask which side of the line something is on, rather than asking if something is precisely a value. They’re much safer.

The is_zero_approx() function kind of does the same thing; it has a small value “epsilon”, and effectively does:

func is_zero_approx(val: float) -> bool:
    if val >  epsilon: return false
    if val < -epsilon: return false
    return true

That gets around needing an exact match, since it matches against a band of values that’s 2*epsilon wide.

1 Like

All good, thanks for your help all the same!

OK, I’m tracking thus far. Again, thanks for the great explanation, very thorough. Haven’t had time to read through the docs @gentlemanhal mentioned but I’m hoping there’s a clue in there.

On the subject though, given that the zero approx function is less necessary for inequalities, is it usable at all with lerp functions? I’ve noticed that usually when I use it, there’s a several-frame tail where the values will be so small they’re effectively zero and create no visible feedback within the game, but still don’t register as an actual zero because they’re cycling downward through 0.0000562, etc., which can make the method feel too slow for responsive control with uses like decelerating movement.

My gut says the the zero approx function creates too small a band to be useful there, but I’ve been keeping an eye out for a way to clip lerp functions.

I recently discovered that the epsilon used is this:

define CMP_EPSILON 0.00001

(Is there a way to change default EPSILON tolerance?)

I try to make do with that as much as possible and usually it is fine. On the odd occasion it isn’t, I just define my own EPSILON const and compare the lerped value with that.

I thought nobody else would have a problem with this and I was just being too fussy, but perhaps having an optional second value in that function might be worth a PR after all?

bool is_zero_approx(x: float, epsilon: float = 0.00001)

In the docs:

For example:

if is_zero_approx(some_value, 0.001):
   # some code here

if is_equal_approx(some_value, some_other_value, 0.001):
   # some code here

The float 0.001 value being optional and uses the default EPSILON if not included.

A lot of the examples and uses of lerp() in this forum seem to use it kind of incorrectly, or at least, in a way that’s not “linear interpolation”, really. The classic thing you see is something like:

func _process(delta):
    val = lerp(val, target_val, fraction)

That’s using a linear interpolation function, but it’s resulting in exponential interpolation because the call is being made on the updated range, and on a fixed time value. Let’s say val starts at 1000, target_val is 0 and fraction is 0.5. You’ll get:

500, 250, 125, 62, 31, 15, 7, 3, 1, 0.5, 0.25, 0.125...

You’re basically iterating a limit. It’ll reach target_val (if it ever does) when the quantization from the floating point encoding coincides with it, but if you were using infinite precision we’d be in Zeno’s Paradox territory.

This is also really frame rate dependent; nothing in this method controls for delta, so if one machine is running 30fps and another 60fps, the second machine will run through the sequence at twice the speed. If the frame rate is inconsistent, your interpolation will be as well.

If you want to actually linearly interpolate, you need to do things slightly differently:

const LERP_TIME_SECONDS = 5 # How long does the lerp take?
const LERP_TIME_FACTOR = 1.0 / LERP_TIME_SECONDS # Simplify the math.
var src_val    # Where we're lerping from, "constant" over successive calls.
var target_val # Where we're lerping to, also "constant".
var fraction   # Where we are between src_ and target_, increases over time.

func _process(delta)
    fraction = clampf(fraction + (delta * LERP_TIME_FACTOR), 0.0, 1.0)
    val      = lerp(src_val, target_val, fraction)
    if fraction >= 1.0:
        print("lerp complete.")

That, for src == 1000, target == 0, TIME == 1/6 (assuming a clean 60fps…), should get you something like:

900, 800, 700, 600, 500, 400, 300, 200, 100, 0

Which is actually linear.

The important thing here is the third argument, often referred to as the “time” value; it should vary between 0.0 (100% source) and 1.0 (100% target).

1 Like