Soft-follow camera: weird jitter/flicker/teleportation of the player

Hello!

I am trying to make a simple third-person camera, but everytime I want to add smooting/interpolation to the camera postion, the player starts jumping back on certain frames, I don’t really know how to describe it and I can’t figure out why it happens.

Here are gifs to showcase the issue:

Hard-follow example
Hard-follow camera. No issues

Soft-follow example
Soft-follow (or “lag”) camera. Jitters/flickers

Here is the code that handle player movement and camera movement:

        dt = globalClock.getDt()

        forward = Vec3(0, 1, 0)
        right = Vec3(1, 0, 0)
        speed = 10

        movementVec = Vec3(0, 0, 0)

        if self.keyMap['forward']:
            movementVec += forward
        if self.keyMap['backward']:
            movementVec -= forward
        if self.keyMap['right']:
            movementVec += right 
        if self.keyMap['left']:
            movementVec -= right

        movementVec.normalize()

        newPlayerPos = self.player.getPos() + movementVec * speed * dt
        self.player.setPos(newPlayerPos)

        camMovSpeed = 10

        camTarget = self.player.getPos() + self.cameraOffset
        camPos = self.camPivot.getPos()

        newCamPos = camPos + (camTarget - camPos) * dt * camMovSpeed

        self.camera.lookAt(self.camera.getPos() - self.cameraOffset)
        # self.camPivot.setPos(self.player.getPos() + self.cameraOffset) # Hard follow (works fine)
        self.camPivot.setPos(newCamPos)

I can share my entire file if necessary.

Any form of help is welcome, I’ve been dealing with this issue for 3+ days now and it is genuinely starting to drive me insane.

Is self.keyMap updated every frame with the current key state?

Hello Baribal!

I am not a hundred percent sure, but I don’t think the keyMap updates every frame.

It works with the event functionality base.accept. I wrote two events per key, one to detect when the key is pressed, and the other to detect when it stops being pressed. The keyMap is updated in the function called by base.accept.

Hmm… It’s tricky to say just from what’s been presented, I feel. For one thing, it’s hard to say whether it’s the player’s position or the camera’s position that’s jittering… (Although my guess is that it’s the latter.)

One thought does occur to me, however: your code currently doesn’t seem to account for the possibility of dt being greater than 1/“camMovSpeed”.

If the value of dt is ever greater than that value, then dt * camMovSpeed will be greater than 1. This would result in (camTarget - camPos) being multiplied by a value greater than 1, thus causing the camera to jump beyond the target position.

Furthermore, depending on the specific values of that frame and the next, it might start the next frame still ahead of its target, and thus be moved backwards towards that target…

Of course, I don’t know what sort of values you’re getting for “dt”, so it’s hard to say whether this is in fact the issue that you’re seeing…

Hello Thaumaturge!

The fact is, the Panda3D window runs at a constant 60 FPS with plenty of headroom (4000+ FPS uncaped). At 60 FPS, I get consistent delta time between ~0.016 and ~0.017.

I tried:

newCamPos = camPos + (camTarget - camPos) * min(dt * camMovSpeed, 1)
in case dt * camMovSpeed ever goes above 1, but it did not change anything.

newCamPos = camPos + (camTarget - camPos) * camMovSpeed
with camMoveSpeed < 1, it did not help.

newPlayerPos = self.player.getPos() + movementVec * speed * 0.016
again, the issue is still there.

But when I tried removing delta time from both the newPlayerPos and newCamPos formula, I was suprised to see the issue fixed. I guess the issue is cause by dt? The annoying thing is that without delta time, movements become FPS dependant which I would like otherwise.

A thing I have started to notice too: the jitter seems to happen every X frame, the time interval between each micro-teleportation seems to stay constant.

Here is my full file for you to run the app:

from direct.showbase.ShowBase import ShowBase
from panda3d.core import WindowProperties
from panda3d.core import load_prc_file_data
from panda3d.core import Vec3
from panda3d.core import CardMaker, PNMImage, Texture
import random

from math import exp

# load_prc_file_data('', 'sync-video f\nshow-frame-rate-meter t')
# load_prc_file_data('','win-size 1280 720')

class Test(ShowBase):
    def __init__(self, fStartDirect=True, windowType=None):
        super().__init__(fStartDirect, windowType)

        self.disable_mouse()

        self.player = self.render.attachNewNode("player")
        self.player.setPos(0, 0, 0)

        self.model = self.loader.loadModel('teapot')
        self.model.setScale(self.model.getScale()*0.1)
        self.model.reparentTo(self.player)

        props = WindowProperties()
        props.setCursorHidden(True)
        self.win.requestProperties(props)

        self.accept("escape", self.userExit)

        self.accept('raw-w', self.updateKeyMap, ['forward', True])
        self.accept('raw-w-up', self.updateKeyMap, ['forward', False])
        self.accept('raw-a', self.updateKeyMap, ['left', True])
        self.accept('raw-a-up', self.updateKeyMap, ['left', False])
        self.accept('raw-s', self.updateKeyMap, ['backward', True])
        self.accept('raw-s-up', self.updateKeyMap, ['backward', False])
        self.accept('raw-d', self.updateKeyMap, ['right', True])
        self.accept('raw-d-up', self.updateKeyMap, ['right', False])

        self.keyMap = {
            "forward": False,
            "backward": False,
            "left": False,
            "right": False,
        }

        self.camPivot = self.render.attach_new_node('camPivot')
        self.camera.reparent_to(self.camPivot)
        # self.camLens.setFov(10)
        self.zoomLevel = 0
        self.cameraOffset = Vec3(0, -5, 10)

        self.generateGround()

        self.taskMgr.add(self.update, 'update', sort=100)

    def update(self, task):
        dt = globalClock.getDt()

        forward = Vec3(0, 1, 0)
        right = Vec3(1, 0, 0)
        speed = 10

        movementVec = Vec3(0, 0, 0)
        maxZoomOut = 5

        if self.keyMap['forward']:
            movementVec += forward
        if self.keyMap['backward']:
            movementVec -= forward
        if self.keyMap['right']:
            movementVec += right 
        if self.keyMap['left']:
            movementVec -= right

        movementVec.normalize()

        newPlayerPos = self.player.getPos() + movementVec * speed * dt
        self.player.setPos(newPlayerPos)

        camMovSpeed = 10

        camTarget = self.player.getPos() + self.cameraOffset
        camPos = self.camPivot.getPos()
        print(dt)
        newCamPos = camPos + (camTarget - camPos) * camMovSpeed *dt

        self.camera.lookAt(self.camera.getPos() - self.cameraOffset)
        # self.camPivot.setPos(self.player.getPos() + self.cameraOffset) # Hard follow (works fine)
        self.camPivot.setPos(newCamPos)

        self.zoomLevel = maxZoomOut if movementVec.length() > 0 else 0
        # self.updateFov(dt)
        
        return task.cont
    
    def updateFov(self, dt):
        zoomInSpeed = 5
        zoomOutSpeed = 1
        fov = 10

        targetFov = fov + self.zoomLevel
        currentFov = self.camLens.getFov()[0]

        if targetFov > currentFov:
            smoothingSpeed = zoomOutSpeed
        else:
            smoothingSpeed = zoomInSpeed

        currentFov += (targetFov - currentFov) * (1 - exp(-smoothingSpeed * dt))

        # self.camLens.setFov(currentFov + (targetFov - currentFov) * min(zoomSpeed * dt, 1))
        self.camLens.setFov(currentFov)

    def generateGround(self):
        size = 256  # texture resolution
        img = PNMImage(size, size)

        for x in range(size):
            for y in range(size):

                r = 0.25 + random.uniform(-0.05, 0.05)
                g = 0.7  + random.uniform(-0.1, 0.1)
                b = 0.25 + random.uniform(-0.05, 0.05)

                r = min(max(r, 0), 1)
                g = min(max(g, 0), 1)
                b = min(max(b, 0), 1)

                img.setXel(x, y, r, g, b)

        texture = Texture("groundTexture")
        texture.load(img)
        texture.setWrapU(Texture.WM_repeat)
        texture.setWrapV(Texture.WM_repeat)

        cm = CardMaker("ground")
        cm.setFrame(-50, 50, -50, 50)
        cm.setUvRange((0, 0), (10, 10))

        ground = self.render.attachNewNode(cm.generate())
        ground.setP(-90)
        ground.setZ(0)
        ground.setTexture(texture)

    def updateKeyMap(self, key, value):
        self.keyMap[key] = value

app = Test()
app.run()

How could I make the programm run without jitters while conserving dt?

Edits:
typos
commented out prc file data loaders
update on dt

Hmm, interesting: When I run your code on my own machine, I see no such jitter.

Are you sure that you’re not getting occasional spikes in the value of dt for some reason? Perhaps some background task intermittently taking a bunch of CPU time, or something like that?

(Ironically, given the results that you’re getting, I seem to see jitter when I remove the multiplication by dt from the camera-movement code. (Replacing it with a hard value of 0.2.) Although that should perhaps not be unexpected, I feel.)

I don’t see any problems either. You may be using a laptop where the cooling system is inefficient due to dust. This can actually happen with a PC.

1 Like

Are you sure that you’re not getting occasional spikes in the value of dt for some reason?

I decided to track dt to check if I had spikes.

Unfortunatly, I have spikes.

Every 59/60 frames, one frame takes 3x the expected render time and then the following only 1/3.

Here is the code used:

def benchmarkFps(self, rawdt):
    self.frame += 1
    expectedDt = 1/globalClock.getAverageFrameRate() # Needs a few seconds to be accurate
    if rawdt + 0.009 < expectedDt or rawdt - 0.009 > expectedDt: # We check if expectedDt is in the range rawdt ± 0.009
        print(f"dt spike @ frame {self.frame} - {self.frame - self.lastSpike} frames since last spike - rawdt: {round(rawdt, 6)} expected dt: {round(expectedDt, 6)}")
        self.lastSpike = self.frame

You may be using a laptop where the cooling system is inefficient due to dust.

Here are the specs of the laptop (Lenovo LOQ 15IRH8) used:

  • 13th Gen Intel Core i7-13620H
  • 16 GB 5200 MHz DDR5 RAM
  • Windows 10 22H2

This laptop is only 6 month old, and gives more than decent performance everywhere else. It is actively cooled by an external cooler in addition to the built-in cooler. I don’t undertand why it’s struggling as it should be able to handle Panda3D without issues?

When your scene is empty, the CPU clock cycle occurs too often, which causes overheating. To take the load off the CPU, you can try this one from the post.

1 Like

I tried adding loadPrcFileData('', 'client-sleep 0.001'), but it did not fix the spikes.


With loadPrcFileData('', 'client-sleep 0.001')


Without loadPrcFileData('', 'client-sleep 0.001')

Looking at windows’ Task Manager, I can also say that CPU load percentage never goes above 5% so I’m not really sure it is a CPU load issue.

You need to find out the actual CPU temperature, this can be done using monitoring utilities. As you mentioned above, the problem may also be related to other processes in your operating system.

I think it should be clarified that overheating does not always mean 100% load, these are different concepts. A car with an overheated engine can actually move at a speed of 1 km per hour and not develop momentum.

1 Like

Seems like @Thaumaturge’s suggestion from this thread does the trick. With V-Sync off, the game stops sending dt spike alerts and the players moves smoothly.

2 Likes