by Monstrous Software

Step 20 - Frame rate independence

This step is in fact a bug fix, to allow the game to run at different frame rates. Up to now the game runs fine at 60Hz, but at a higher frame rate (perhaps because your monitor runs at 120 Hz) some things go too fast. Lesson learnt: always test this before shipping, other people have other computers than you do.

To try this out, you can edit Lwjgl3Launcher.java in the lwjgl3 folder. This is the launcher code used for the “Desktop” version. The pre-generated comments already give a hint what to do: change useVsync(true) to useVsync(false) and comment out the line with setForegroundFPS(). Now you will get a frame rate that is not tied to your monitor frequency but which depends on your computer and your graphics card. Maybe now you get 1000 frames per second, because we are rendering quite a simple scene. Although this is useful for performance testing, you should normally leave vsync on; uncapping the frame rate just stresses the hardware and runs down laptop batteries for no visible benefit.

Lwjgl3Launcher.java:

    private static Lwjgl3ApplicationConfiguration getDefaultConfiguration() {
        Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration();
        configuration.setTitle("Tut3D");
        configuration.useVsync(true);                                                                       <--- change to false
        //// Limits FPS to the refresh rate of the currently active monitor.
        configuration.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate);        <--- comment out
        //// If you remove the above line and set Vsync to false, you can get unlimited FPS, which can be
        //// useful for testing performance, but can also be very stressful to some hardware.
        //// You may also need to configure GPU drivers to fully disable Vsync; this can cause screen tearing.

        configuration.setWindowedMode(1280, 720);
        configuration.setBackBufferConfig(8, 8, 8, 8, 16, 0, 4);

        configuration.setWindowIcon("libgdx128.png", "libgdx64.png", "libgdx32.png", "libgdx16.png");
        return configuration;
    }

Although in many places, we already used deltaTime to make game behaviour independent of frame rate, there were in fact still a few places where it was not done properly.

In the PhysicsWorld class we update the physics. The physics library ODE does not work well with a variable time step. You can however use the delta time, to ensure you run an update cycle at the desired rate. (Also update World.update() to pass the new parameter deltaTime).

PhysicsWorld.java:

    static final float TIME_STEP = 0.025f;  // fixed physics time step

    // update the physics
    // time step of quickStep() needs to be fixed size
    //
    public void update(float deltaTime) {
            timeElapsed += deltaTime;
            while(timeElapsed > TIME_STEP) {
                space.collide(null, nearCallback);
                world.quickStep(TIME_STEP);
                contactGroup.empty();
    
                timeElapsed -= TIME_STEP;
            }
        }

Then we notice the enemies are moving much too fast. Where we are applying force to the enemy rigid bodies, we need to scale this with the time step. Change as follows (you could also change Settings.cookForce to get rid of this 60f constant) :

CookBehaviour.java:

  @Override
    public void update(World world, float deltaTime ) {
        ...

            if(distance > 5f)   // move unless quite close
                go.body.applyForce(targetDirection.scl(deltaTime * 60f * Settings.cookForce * climbFactor));
        }

Then some changes are needed to the PlayerController, because the jumps are too high and the view rotation with the mouse is too slow. The jump force also needs to be scaled with deltaTime. The mouse movement was scaled by deltaTime but in fact it shouldn’t be. The amount of rotation is directly determined by how much the mouse has moved.

PlayerController:

    public void update (GameObject player, float deltaTime ) {
        ...

        // mouse to move view direction
        rotateView(mouseDeltaX*Settings.turnSpeed/60f, mouseDeltaY*Settings.turnSpeed/60f ); <---- removed deltaTime
        mouseDeltaX = 0;
        mouseDeltaY = 0;

        // controller stick inputs
        moveForward(stickMove.y*deltaTime * moveSpeed);
        strafe(stickMove.x * deltaTime * Settings.walkSpeed);
        float delta = 0;
        float speedFactor;
        if(world.weaponState.scopeMode) {
            speedFactor = 0.2f;
            delta = (stickLook.y * 30f );
        }
        else {
            speedFactor = 1f;
            delta = (stickLook.y * 90f - stickViewAngle);
        }
        delta *= deltaTime*Settings.verticalReadjustSpeed*speedFactor;
        stickViewAngle += delta;
        rotateView(stickLook.x * deltaTime * Settings.turnSpeed*speedFactor,  delta );

        // note: most of the following is only valid when on ground, but we leave it to allow some fun cheating
        if (keys.containsKey(forwardKey))
            moveForward(deltaTime * moveSpeed);
        if (keys.containsKey(backwardKey))
            moveForward(-deltaTime * moveSpeed);
        if (keys.containsKey(strafeLeftKey))
            strafe(-deltaTime * Settings.walkSpeed);
        if (keys.containsKey(strafeRightKey))
            strafe(deltaTime * Settings.walkSpeed);
        if (keys.containsKey(turnLeftKey))
            rotateView(deltaTime * Settings.turnSpeed, 0);
        if (keys.containsKey(turnRightKey))
            rotateView(-deltaTime * Settings.turnSpeed, 0);

        if (isOnGround && keys.containsKey(jumpKey) )
            linearForce.y =  Settings.jumpForce * deltaTime * 60f;              <---- change

        linearForce.scl(120);
        player.body.applyForce(linearForce);
        // note: as the player body is a capsule it is not necessary to rotate it
        // (and in fact it causes problems due to errors building up)
        // so we don't rotate the rigid body, but we rotate the modelInstance in World.syncToPhysics()
    }

Then in GameView the render() method needs a small update. This is to make sure that when the game view is used for an overlay (i.e. the gun view), the camera is reset to the same Y position each frame before adding the bobbing effect. Otherwise, the gun will just drift away. It is an effect that only becomes noticeable with very small deltaTime values.

    public void render(float delta, float speed ) {
        if(!isOverlay)
            camController.update(world.getPlayer().getPosition(), world.getPlayerController().getViewingDirection());
        else                                                    <-----   add
            cam.position.y = Settings.eyeHeight;                <------- add
        addHeadBob(delta, speed);
        cam.update();
        refresh();
        sceneManager.update(delta);

        Gdx.gl.glClear(GL20.GL_DEPTH_BUFFER_BIT);   // clear depth buffer only
        sceneManager.render();
    }

    private void addHeadBob(float deltaTime, float speed ) {
        if( speed > 0.1f ) {
        bobAngle += speed * deltaTime * Math.PI / Settings.headBobDuration;

        // move the head up and down in a sine wave
        cam.position.y +=  bobScale * Settings.headBobHeight * (float)Math.sin(bobAngle);
        }
    }

These are the steps needed to have the game run the same, regardless of which frame rate you are using, so that different users get the same experience. If you disabled the vsync(), don’t forget to re-enable it after testing.

This concludes step 20 where we adapted the code for different frame rates.


<
Previous Post
3D Tutorial - Step 19 - Navigation Mesh
>
Next Post
Post Mortem “Oz Racer”