🎉 Celebrating 25 Years of GameDev.net! 🎉

Not many can claim 25 years on the Internet! Join us in celebrating this milestone. Learn more about our history, and thank you for being a part of our community!

Fixed Timestep Rotation Stutter at Specific Timesteps (with interpolation)

Started by
9 comments, last by fleabay 5 years, 1 month ago

I am currently in the process of implementing a fixed timestep game loop following the legendary "Fix Your Timesteparticle. I have managed to get everything setup and working...or so I thought, until I started to change my timestep value. I seem to be running into an issue where a stutter is produced during rotation at specific timesteps (linear movement (forwards, backwards, left, right) is always smooth when rotation is not changed). Below are the different timesteps and encountered results when running with vsync enabled on a 144hz panel:

  • 144 updates per second - smooth rotation, minor stutter every once in awhile but barely noticeable (possibly in my head)
  • 128 updates per second - rotation stutter noticeable (may not be completely obvious at first but as you rotate around the crate you will see it)
  • 30 updates per second - smooth rotation, no stutter noticed

When the above timesteps are used with vsync disabled they all appear to have smooth rotation. The takeaway (at least my takeaway) is that the more render cycles that happen between update steps, the smoother the rotation.

I have been going back and forth as to why this could be (this is the first time I have attempted to implement something like this so it's possible I have missed something someone else might instantly notice) and the thing that throws me is the fact that the interpolation appears to be working as expected for lower update rates (as seen from the 30 updates per second test (if I attempt the 30 updates per second test with interpolation disabled everything stutters horribly as you would expect until interpolation is enabled again)), but then appears to not be working when the render cycles more closely match the number of update cycles.

I have included my code below which I believe to be relative to the question (complete source available here). Is there anything that points out as being horribly wrong??? I have been obsessing over trying to figure this out for days now. Any advice greatly appreciated!


int main()
{
	glm::vec2			screen_size = glm::vec2(1280, 720);
	InputState			input;

	Window				window(&screen_size, &input);
	if (window.getInitFailed()) {
		std::cout << window.getInitMessage() << std::endl;
		return -1;
	}

	auto				scene = std::make_unique<Scene>(&screen_size);
	auto				game = std::make_unique<GameLogic>(&input);

	double				delta_time = 0.0078125; // 128 tps
	//double			delta_time = 0.0069444444444444; // 144 tps
	//double			delta_time = 0.0166666666666667; // 60 tps
	//double			delta_time = 0.03333333333; // 30 tps

	double				current_time = window.time();
	double				accumulator = 0.0;

	
	glEnable(GL_DEPTH_TEST);

	scene->addModel("data/models/bin/props/crate1/crate1.obj");


	// Render loop
	while (!window.shouldClose())
	{	

		// per-frame time logic
		double new_time = window.time();
		double frame_time = new_time - current_time;
		if (frame_time > 0.25) {
			frame_time = 0.25;
		}
		current_time = new_time;
		accumulator += frame_time;

		// exit if key_esc pressed
		if (input.key_esc) {
			window.setToClose();
			continue;
		}

		// capture input state
		window.update();
		
		// process game logic
		while (accumulator >= delta_time) {

			game->update(delta_time);

			accumulator -= delta_time;

		}
		
		scene->render(game->getCurrentGameState(), game->getPreviousGameState(), (accumulator / delta_time));
		
		window.vsync();
		window.swapBuffers();

	}

	return 0;
}


// From GameLogic.cpp
//-------------------------------
GameLogic::GameLogic(InputState* is) : player(is) {}
GameLogic::~GameLogic() {}

void GameLogic::update(double delta) {

	player.update(delta);

	updateState();

}

void GameLogic::updateState() {

	previous_state = current_state;

	current_state.player_position = player.getPlayerPosition();
	current_state.player_front = player.getPlayerFront();
	
	current_state.view_position = player.getViewPosition();
	current_state.view_front = player.getViewFront();

}

GameState GameLogic::getCurrentGameState() {
	return current_state;
}

GameState GameLogic::getPreviousGameState() {
	return previous_state;
}

// From Player.cpp
//-------------------------------
Player::Player(InputState* is) {
	this->input_state = is;
};
Player::~Player() {};

void Player::update(double delta) {

	this->delta = delta;

	updateRotation();
	onScroll(input_state->scroll_x, input_state->scroll_y);

	move();
	rotate();
	look();

}

void Player::move() {

	float speed = player_move_speed * delta;
	if (input_state->key_w)
		player_position += speed * player_front;
	if (input_state->key_s)
		player_position -= speed * player_front;
	if (input_state->key_a)
		player_position -= glm::normalize(glm::cross(player_front, player_up)) * speed;
	if (input_state->key_d)
		player_position += glm::normalize(glm::cross(player_front, player_up)) * speed;

	view_position = player_position + view_offset;

}

void Player::rotate() {

	glm::vec3 front;
	front.x = cos(glm::radians(yaw));
	front.y = 0.0f;
	front.z = sin(glm::radians(yaw));
	player_front = glm::normalize(front);

}

void Player::look() {

	glm::vec3 front;
	front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
	front.y = sin(glm::radians(pitch));
	front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
	view_front = glm::normalize(front);

}

void Player::updateRotation() {

	if (first_mouse)
	{
		last_x = input_state->mouse_xpos;
		last_y = input_state->mouse_ypos;
		first_mouse = false;
	}

	float xoffset = input_state->mouse_xpos - last_x;
	float yoffset = last_y - input_state->mouse_ypos;
	last_x = input_state->mouse_xpos;
	last_y = input_state->mouse_ypos;

	float sensitivity = 0.1f;
	xoffset *= sensitivity;
	yoffset *= sensitivity;

	yaw += xoffset;
	pitch += yoffset;

	if (pitch > 89.0f)
		pitch = 89.0f;
	if (pitch < -89.0f)
		pitch = -89.0f;

}

glm::vec3 Player::getPlayerPosition() {
	return player_position;
}

glm::vec3 Player::getPlayerFront() {
	return player_front;
}

glm::vec3 Player::getViewPosition() {
	return view_position;
}

glm::vec3 Player::getViewFront() {
	return view_front;
}

// From Scene.cpp
//-------------------------------
Scene::Scene(glm::vec2* screen_size) : camera(screen_size), 
shader(GlobalConstants::VERTEX_SHADER, GlobalConstants::FRAGMENT_SHADER) {}
Scene::~Scene(){}

void Scene::render(GameState current_state, GameState previous_state, float alpha) {

	GameState lerp_render_state = lerpRenderState(current_state, previous_state, alpha);

	camera.setPosition(lerp_render_state.view_position);
	camera.setDirection(lerp_render_state.view_front);
	camera.update();

	glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

	shader.use();

	shader.setMat4("view", camera.getViewMatrix());
	shader.setMat4("projection", camera.getProjectionMatrix());

	glm::mat4 model = glm::mat4(1.0f);
	model = glm::translate(model, glm::vec3(0.f, 2.f, -3.f));
	shader.setMat4("model", model);

	for (auto &m : models) {
		m.draw(shader);
	}

}

void Scene::addModel(std::string path) {
	models.push_back(Model(path));
}

GameState Scene::lerpRenderState(GameState current_state, GameState previous_state, float alpha) {

	GameState lerp_render_state;

	lerp_render_state.player_position = glm::lerp(previous_state.player_position, current_state.player_position, alpha);
	if (current_state.player_front != previous_state.player_front) {
		lerp_render_state.player_front = glm::slerp(previous_state.player_front, current_state.player_front, alpha);
	}
	else {
		lerp_render_state.player_front = glm::lerp(previous_state.player_front, current_state.player_front, alpha);
	}

	lerp_render_state.view_position = glm::lerp(previous_state.view_position, current_state.view_position, alpha);
	if (current_state.view_front != previous_state.view_front) {
		lerp_render_state.view_front = glm::slerp(previous_state.view_front, current_state.view_front, alpha);
	}
	else {
		lerp_render_state.view_front = glm::lerp(previous_state.view_front, current_state.view_front, alpha);
	}

	return lerp_render_state;

}

// From Camera.cpp
//-------------------------------
Camera::Camera(glm::vec2* screen_size) {
	this->screen_size = screen_size;
}
Camera::~Camera() {}

void Camera::update() {
	updateViewMatrix();
	updateProjectionMatrix();
}

void Camera::updateViewMatrix() {
	view_matrix = glm::lookAt(camera_position, camera_position + camera_front, camera_up);
}

void Camera::updateProjectionMatrix() {
	projection_matrix = glm::perspective(glm::radians(fov), 
		(float)screen_size->x / (float)screen_size->y, 0.1f, 100.0f);
}

void Camera::setPosition(glm::vec3 position) {
	camera_position = position;
}

void Camera::setDirection(glm::vec3 direction) {
	camera_front = direction;
}

glm::mat4 Camera::getViewMatrix() {
	return view_matrix;
}

glm::mat4 Camera::getProjectionMatrix() {
	return projection_matrix;
}
Advertisement

These things are always hard to nail down, in particular from reading code. You may want to debug this in more detail instead. One of the simplest ways to create a log of values through time, ie in each iteration write out key variables to a log, add a precise timestamp as well. Afterwards you can analyse the log output, and try to find the stutter in the log. In that way you get a more precise idea of what happens every cycle. Likely you'll end up repeating the experiment a few times, changing the set of variables being logged to get a better idea of the program internals at runtime.

If you're worried that logging will affect performance, you can write the log in memory first (eg as a struct in an array), and when you have "sufficient", write it out to file.

On 6/2/2019 at 11:00 AM, Alberth said:

You may want to debug this in more detail

I have spent some time generating several log files (and staring at them intently ?) for the various combinations I have described. The only pattern I have made out so far is that the stutter is happening when more logic ticks are performed, except when the logic tick happens at the exact rate as the render rate. The interpolation appears to be functioning as expected, however I am currently wondering if there is an accuracy issue with the interpolation that is only noticeable at specific logic/render ratios?

If anyone would like to take a look at the generated log files I have uploaded them to my server:

Refresh rate is 144 when vsync enabled, without vsync fps is over 1000. Each row of the log is a snapshot of a render loop. If a logic update has occurred, the word LOGIC is included in the row.

I will be investigating this until a resolution is found. Any/All feedback greatly appreciated!

I am a noob wrt render loops, but looking again at the loop, you're computing a frame, and render it, then you wait for vsync, and then you display it. Doesn't that mean you're always late by the delay introduced by vsync? (ie shouldn't you compute and render the state at the moment immediately after vsync?)

8 hours ago, Alberth said:

I am a noob wrt render loops, but looking again at the loop, you're computing a frame, and render it, then you wait for vsync, and then you display it. Doesn't that mean you're always late by the delay introduced by vsync? (ie shouldn't you compute and render the state at the moment immediately after vsync?)


window.vsync()

Is really just calling


glfwSwapInterval(1)

I realized this could be done once outside of my loop during my investigation of this issue and have since moved the call outside of the render loop so that it is not needlessly begin called every frame. Thinking that this issue might be related to vsync timing, I added code to limit my framerate without vsync, and the issue persists when I limit the framerate to my refresh rate.

I'm starting to wonder if there is an issue with how I'm calculating my position and direction in relation to how I am interpolating the two? Or if there is a precision issue when I calculate the `alpha` as a double, but pass as  a float (however if that were the case I would think that the linear side-to-side movement would also be affected).

On 6/3/2019 at 11:30 PM, whitwhoa said:

 

Refresh rate is 144 when vsync enabled, without vsync fps is over 1000. Each row of the log is a snapshot of a render loop. If a logic update has occurred, the word LOGIC is included in the row.

Didn't you just answer your own question there?

For 64 and 128 tps your game logic is running out of phase with vsync. Which is to say you sometimes finish 1, sometimes 2 screen updates per tick (2.25 and 1.125 frames per tick on average). Assuming that you need to lock your render thread to copy over this data, you're effectively pushing your framerate out of sync with vsync, which doesn't care about your updates but rather your actual framerate. You have three options:

1) disable vsync
2) step in sync with vsync (144 tps)
3) adjust the tick rate to some integral divisor of vsync. Eg 144/2 = 78, 144/3 = 48, 144/4 = 36, 144/6 = 24 tps (notice the absence of 5).

My guess is that for 30 tps the updates happen so infrequently (every 4.8 vsync frames) that you simply can't perceive the jitter.  It is therefore a good idea to support a healthy range of tick rates in your engine. Moreover, I'm not sure you actually need a tick rate higher than 30-60 Hz in most games; the exceptions being titles that demand very low latency).

23 minutes ago, irreversible said:

For 64 and 128 tps your game logic is running out of phase with vsync. Which is to say you sometimes finish 1, sometimes 2 screen updates per tick (2.25 and 1.125 frames per tick on average). Assuming that you need to lock your render thread to copy over this data, you're effectively pushing your framerate out of sync with vsync, which doesn't care about your updates but rather your actual framerate.

This would make sense to me if I did not include an interpolation phase. I thought the purpose of interpolation was to smooth out this issue, or have I misunderstood?

Just now, whitwhoa said:

This would make sense to me if I did not include an interpolation phase. I thought the purpose of interpolation was to smooth out this issue, or have I misunderstood?

I'm not 100% sure but it seems to me that interpolation alone (at least the way you've implemented it) isn't enough here. Please be critical of the following explanation :)

In the very least you should be performing precise time-based interpolation (eg by calculating your alpha based on timings from QueryPerformaceCounter()) to match frame-step non-sync points as precisely as possible, as opposed to the fixed step based interpolation which you seem to be using.

Consider the below arbitrary example I threw together with close to zero finesse. In the top row are vsynced frames, in the bottom steps of arbitrary length (from an arbitrarily chosen tps value).

As you see from my previous post, the ratio for 128 tps and 144 fps is 1.125 (I do not know the ratio for this example because I drew the rectangles arbitrarily). This implies the updates and actual drawn frames sync up the same way (but not necessarily precisely, if you're running them in different threads) every 8 frames (1/0.125). During these 8 frames your updates are jittering and interpolation actually fails. For predictive interpolation this is likely enough time for positions to become incorrect and be abruptly changed. For retroactive interpolation you should be running your renderer a whopping 8 frames behind for the state to be fully known at each frame.

For instance, you do not have correct information at the vsync point between the second and third frame frame since the start of the current step is somewhere in the middle of one of the previous frames and the end is somewhere in the middle of the current or future frames.

Once again, my logic might be at fault here but I have noticed a very similar jitter in my own renderer and my assumption has always been that I'm not taking care of the small details. Syncing game logic and the renderer to an integral ratio should fix the problem.


image.png.c5ed24f8fc11844f9e3ff2aa7c84c388.png

Can you in the very least try running you simulation at the tick rates I suggested in my previous post and see if they're smooth?

9 minutes ago, irreversible said:

Can you in the very least try running you simulation at the tick rates I suggested in my previous post and see if they're smooth?

The stutter appears to be gone for all of the values you had suggested:

  • 72tps (you had listed 78 but I believe you meant 72)
  • 48tps
  • 36tps
  • 24tps

I'm still processing your explanation, as I really am trying to understand what's going on here. I appreciate the level of detail you have provided, and the picture! I like pictures :)

This topic is closed to new replies.

Advertisement