simDrift issues in 3d

I’m trying to generalize simulated drift into 3 dimensions. The way this is implemented is that the direction of motion lags behind the direction the vehicle is facing and must catch up. I’ve got it working for heading or roll so long as none of the other directions are changed. Pitch freaks out (seems to roll 180 degrees and then jiggle wildly) after I pitch past vertical.

The other methods also only work as long as I haven’t changed direction. For example simDriftHeading works so long as I haven’t rolled.

It seems to be a problem with changing the coordinates that the motion is with respect to, but I don’t understand what is going on. Please help if you can.

My code is adapted from working code in the Panda3D Beginner’s Guide by Packt Publishing. You don’t need to have read the book to help with this.

I can provide additional code and comments as needed. Thank you for any time and insights you can provide.

See simDriftHeading, simDriftPitch, or simDriftRoll in the following code.

Here is the cycle class:

''' Cycle Class
Each instance of this class will be one of the cycles
racing on the track. This class handles all of the
variables and components necessary for a cycle controlled
by the player.

In addition, there is an option to have this class create
an instance of the CycleAI class to control it, instead of
using player input.'''
from direct.showbase.DirectObject import DirectObject
from pandac.PandaModules import *
from CycleAIClass import CycleAI

class Cycle(DirectObject):
  def __init__(self, inputManager, track, startPos, name, ai = None):
    # Stores a reference to the InputManager to access user input.
    self.inputManager = inputManager
    # Sets up initial variables, NodePaths, and collision objects.
    self.setupVarsNPs(startPos, name)
    # if the ai input is passed anything, it won't default to None, and the cycle will create an AI to control itself.
    if(ai == True):
      self.ai = CycleAI(self)
    # If the cycle isn't using an AI, activate player control.
    # With this set up, if AI == False, the cycle will be completely uncontrolled.
    elif(ai == None):
      taskMgr.add(self.cycleControl, "Cycle Control")


  def setupVarsNPs(self, startPos, name):
    '''setupVarsNPs: Initializes most of the non-collision variables and NodePaths needed by the cycle.'''
    # Stores a unique name for this cycle.
    self.name = name
    # Creates and stores the NodePath that will be used as the root of the cycle
    # for the purpose of movement.
    self.root = render.attachNewNode("Root")
    # Sets the root to a new position according to what place it is given.
    self.root.setPos(5,0,0)
    self.cycle = loader.loadModel("../Models/RedCycle.bam")
    # Sets basic variables for the cycle's racing attributes.
    self.speed = 0
    self.maxSpeed = 200
    self.handling = 20
    # Loads the visual model for the cycle and reparents it to the root.
    self.cycle.reparentTo(self.root)
    # Creates a NodePaths to use as a reference during various calculations
    # the cycle performs.
    self.refNP = self.root.attachNewNode("RefNP")
    #NodePath for the direction the cycle is facing.
    self.dirNP = self.root.attachNewNode("DirNP")
    # Connects the camera to dirNP so the camera will follow and 
    # rotate with that node. Also moves it backward 5 meters.
    base.camera.reparentTo(self.dirNP)
    base.camera.setY(base.camera, -10)
    #Direction vector used for calculating move
    # Creates and stores 3 vector objects to be used in simulating drift when the
    # cycle turns.
    self.dirVec = Vec3(0,0,0)
    self.cycleVec = Vec3(0,0,0)
    self.refVec = Vec3(0,0,0)


  def cycleControl(self, task):
    '''cycleControl: Manages the cycle's behavior when under player control.'''
    # Gets the amount of time that has passed since the last frame
    #from the global clock. If the value is too large, there has 
    #been a hiccup and the frame will be skipped.
    dt = globalClock.getDt()
    if( dt > .20): return task.cont
    # Checks the InputManager for turning initiated by the user and performs it.
    if(self.inputManager.keyMap["d"] == True):
      self.turn("r", dt)
    elif(self.inputManager.keyMap["a"] == True):
      self.turn("l", dt)
    #Changing pitch
    if(self.inputManager.keyMap["arrow_up"]):
      self.changePitch('up', dt)
    elif(self.inputManager.keyMap["arrow_down"]):
      self.changePitch('down', dt)
    #Rolling
    if(self.inputManager.keyMap["arrow_left"]):
      self.roll('counter', dt)
    elif(self.inputManager.keyMap["arrow_right"]):
      self.roll('clockwise', dt)
    # Calls the methods that control cycle behavior frame-by-frame.
    self.simDriftPitch(dt)
    #self.simDriftHeading(dt)
    #self.simDriftRoll(dt)
    return task.cont


  def turn(self, dir, dt):
    # turn: Rotates the cycle based on its speed to execute turns.
    # Determines the current turn rate of the cycle according to its speed.
    turnRate = self.handling * (2 - (self.speed / self.maxSpeed))
    # If this is a right turn, then turnRate should be negative.
    if(dir == "r"):
      turnRate = -turnRate
    # Rotates the cycle according to the turnRate and time.
    self.cycle.setH(self.cycle, turnRate * dt)
  

  def changePitch(self, dir, dt):
    '''dir = up/down'''
    turnRate = self.handling * (2 - (self.speed / self.maxSpeed))
    if(dir == "down"):
      turnRate = -turnRate
    self.cycle.setP(self.cycle, turnRate * dt)


  def roll(self, dir, dt):
    '''dir = counter/clockwise'''
    turnRate = self.handling * (2 - (self.speed / self.maxSpeed))
    if(dir == "counter"):
      turnRate = -turnRate
    self.cycle.setR(self.cycle, turnRate * dt)


  def simDriftPitch(self, dt):
    '''simDrift: This function simulates the cycle drifting when the cycle pitches by causing the dirNP, which faces the direction the cycle is moving in, to slowly catch up to the actual facing of the cycle over time.'''
    # Uses refNP to get a vector that describes the facing of dirNP.
    self.refNP.setPos(self.dirNP, 0, 1, 0)
    self.dirVec.set(self.refNP.getX(), self.refNP.getY(), self.refNP.getZ())
    # Uses refNP to get a vector that describes the facing of the cycle.
    #The height value is discarded as it is unnecessary.
    self.refNP.setPos(self.cycle, 0, 1, 0)
    self.cycleVec.set(self.refNP.getX(), self.refNP.getY(), self.refNP.getZ())
    # Sets refVec to point straight up. This vector will be the axis
    #used to determine the difference in the angle between dirNP and the cycle.
    self.refVec.set(1,0,0)
    # Gets a signed angle that describes the difference between the
    #facing of dirNP and the cycle.
    vecDiff = self.dirVec.signedAngleDeg(self.cycleVec, self.refVec)
    # if the difference between the two facings is insignificant, set
    #dirNP to face the same direction as the cycle.
    #print str(vecDiff)+' - '+str(self.dirVec)+' - '+str(self.cycleVec)
    if(vecDiff < .1 and vecDiff > -.1):
      self.dirNP.setP(self.cycle.getP())
    # If the difference is significant, tell dirNP to slowly rotate to
    #try and catch up to the cycle's facing.    
    else:
      self.dirNP.setHpr(self.dirNP, 0, vecDiff * dt * 2.5, 0)
    # Constrains dirNP heading and roll to the cycle.
    self.dirNP.setH(self.cycle.getH())
    self.dirNP.setR(self.cycle.getR())


  def simDriftHeading(self, dt):
    '''simDrift: This function simulates the cycle drifting when it turns by causing the dirNP, which faces the direction the cycle is moving in, to slowly catch up to the actual facing of the cycle over time.'''
    # Uses refNP to get a vector that describes the facing of dirNP.
    self.refNP.setPos(self.dirNP, 0, 1, 0)
    self.dirVec.set(self.refNP.getX(), self.refNP.getY(), self.refNP.getZ())
    # Uses refNP to get a vector that describes the facing of the cycle.
    self.refNP.setPos(self.cycle, 0, 1, 0)
    self.cycleVec.set(self.refNP.getX(), self.refNP.getY(), self.refNP.getZ())
    # Sets refVec to point straight up. This vector will be the axis
    #used to determine the difference in the angle between dirNP and the cycle.
    self.refVec.set(0,0,1)
    # Gets a signed angle that describes the difference between the
    #facing of dirNP and the cycle.
    vecDiff = self.dirVec.signedAngleDeg(self.cycleVec, self.refVec)
    # if the difference between the two facings is insignificant, set
    #dirNP to face the same direction as the cycle.
    #print vecDiff
    if(vecDiff < .1 and vecDiff > -.1):
      self.dirNP.setH(self.cycle.getH())
    # If the difference is significant, tell dirNP to slowly rotate to
    #try and catch up to the cycle's facing.    
    else:
      self.dirNP.setHpr(self.dirNP, vecDiff * dt * 2.5, 0, 0)
    # Constrains dirNP pitch and roll to the cycle.
    self.dirNP.setP(self.cycle.getP())
    self.dirNP.setR(self.cycle.getR())


  def simDriftRoll(self, dt):
    '''simDrift: This function simulates the cycle drifting when it rolls by causing the dirNP, which faces the direction the cycle is moving in, to slowly catch up to the actual facing of the cycle over time.'''
    # Uses refNP to get a vector that describes the facing of dirNP.
    self.refNP.setPos(self.dirNP, 0, 0, 1)
    self.dirVec.set(self.refNP.getX(), self.refNP.getY(), self.refNP.getZ())
    # Uses refNP to get a vector that describes the facing of the cycle.
    self.refNP.setPos(self.cycle, 0, 0, 1)
    self.cycleVec.set(self.refNP.getX(), self.refNP.getY(), self.refNP.getZ())
    # Sets refVec to point straight up. This vector will be the axis
    #used to determine the difference in the angle between dirNP and the cycle.
    self.refVec.set(0,1,0)
    # Gets a signed angle that describes the difference between the
    #facing of dirNP and the cycle.
    vecDiff = self.dirVec.signedAngleDeg(self.cycleVec, self.refVec)
    #print str(vecDiff)+' - '+str(self.dirVec)+' - '+str(self.cycleVec)
    # if the difference between the two facings is insignificant, set
    #dirNP to face the same direction as the cycle.
    if(vecDiff < .1 and vecDiff > -.1):
      self.dirNP.setR(self.cycle.getR())
    # If the difference is significant, tell dirNP to slowly rotate to
    #try and catch up to the cycle's facing.    
    else:
      self.dirNP.setHpr(self.dirNP, 0, 0, vecDiff * dt * 2.5)
    # Constrains dirNP heading and pitch to the cycle.
    self.dirNP.setH(self.cycle.getH())
    self.dirNP.setP(self.cycle.getP())

Have you tried doing this with quaternions? You might find that more stable.

And by the way, you can, I believe, get forward, up and right vectors–which you seem to be using in your simulation–from a NodePath’s quaternion, like so:

# Given a NodePath named "myNP":

quat = myNP.getQuat() # Gets the quaternion representing this NodePath's orientation

# Forward, right and up vectors:

forward = quat.getForward()
right = quat.getRight()
up = quat.getUp()

Finally–and this may not be useful to you, I’m a little dozy at the moment, I fear–but could you perhaps do what you want by keeping a list of your changes to your object’s H, P and R, removing entries based on the time since they were added, then average the entries in the lst to create a smoothed, “lagging” camera?

Hmm, I’m still having a bit of trouble, but I can clarify my issues.

This is the main culprit screwing up my calculations: If my hpr is
LVecBase3f(0, 89.7977, 0)
and I pitch up a little bit more, I get this:
LVecBase3f(-180, 89.6000, 180)
when what I expect to get is this:
LVecBase3f(0, 9.6023, 0)
Why is pitch affecting roll and yaw when pitch crosses the threshold of 90 degrees? Roll and yaw seem to range from -180 to 180, but pitch has two distinct -90 to 90 ranges. I hope there is an explanation for this madness somewhere.

Regarding your suggestion of a list of changes and averaging:
Tracking changes to h, p, and r then averaging would work great except that changes to these don’t commute. For example, roll clockwise 90 degrees then pitch up 90 degrees is not the same as pitch up 90 degrees then roll clockwise 90 degrees.

I’ll read up on quaternions now.

Thanks.

In that case I really do think that quaternions are likely your best bet here, since I think that they should lack that issue.

As to why pitch is handled so, I’m honestly not certain; perhaps someone who better knows the history of the engine will know the reasoning behind that decision.

[edit]Thinking about it, I wonder–andd note that this is a guess–whether the odd handling of pitch might not be a result of the engine perhaps using quaternions internally and manufacturing H, P and R values as called for, meaning that there isn’t necessarily continuity between the HPR representation of one orientation and another, even if there is continuity between the two orientations.[/edit]

Ah, you’re right–my apologies.

Thanks again for your response. I did some browsing through the forums regarding quaternions and found two helpful posts, both of which you will be familiar with:

passing quaternions to objects over time [url]passing quaternions to objects over time]

Quat.angleDeg and Quat.setHpr with roll [url]Quat.angleDeg and Quat.setHpr with roll]

The trouble is that I’m still getting a “twitch” when pitch passes across -90 degrees even without any change to roll or yaw. I believe I have a nice simple method using quaternions correctly (see below). Am I missing something?

The “twitch” occurs here. The hpr for the cycle is on the left. The hpr for the chase cam is on the right:
LVecBase3f(0, -88.0852, 0) — LVecBase3f(0, -85.3164, 0)
LVecBase3f(0, -89.692, 0) — LVecBase3f(0, -85.7539, 0)
LVecBase3f(-180, -89.9268, -180) — LVecBase3f(0, -86.1859, 0)
LVecBase3f(180, -89.3085, -180) — LVecBase3f(0, -85.6227, 0)
LVecBase3f(-180, -87.6016, 180) — LVecBase3f(0, -84.5305, 0)
LVecBase3f(180, -86.6258, -180) — LVecBase3f(0, -82.6031, 0)
LVecBase3f(-180, -84.9346, 180) — LVecBase3f(0, -78.6328, 0)
LVecBase3f(-180, -84.9346, 180) — LVecBase3f(0, -69.7036, 0)
LVecBase3f(-180, -84.9346, 180) — LVecBase3f(0, -33.7563, 0)

Here is my simulated drift code. dirNP is the node path that “chases” the cycle. The cycle is the node path of a hoverbike model. The camera is parented to dirNP:

  def simDrift(self, dt):
    #Gets the quaternions representing this NodePaths' orientations.
    quat_cycle = self.cycle.getQuat()
    quat_dirNP = self.dirNP.getQuat()
    #Difference between actual and desired orientations in degrees
    diff_deg = quat_dirNP.angleDeg(quat_cycle)
    #If the difference is small enough, then just snap to the Cycle's orientation.
    if diff_deg < 0.1:
        new_quat = quat_cycle
    else:
        #Otherwise perform a linear interpolation
        t = 0.1 #Close 10% of the difference in orientation
        new_quat = quat_dirNP + (quat_cycle - quat_dirNP) * t
    self.dirNP.setQuat(new_quat)

The only other relevant code I can think of is the code that changes pitch:

  def changePitch(self, dir, dt):
    '''dir = up/down'''
    turnRate = self.handling * (2 - (self.speed / self.maxSpeed))
    if(dir == "down"):
      turnRate = -turnRate
    self.cycle.setP(self.cycle, turnRate * dt)

Hmm… Unless you’re using a fixed time-step, shouldn’t dt be taken into account somewhere in there? Something like this, perhaps:

    else:
        #Otherwise perform a linear interpolation
        t = 10.0*dt # This is now essentially a rate per second, hence the constant value being larger than 0.1

Good call. I added in the dt, but that didn’t change the main problem.

Thanks for all your help. I’m not sure what to do besides give up at this point. I’ll post again if I figure anything out.

Here’s some more data if you or anyone else is interested. I’ve printed out the pertinent quaternions below.

print new_quat

0.286379 + -0.137266i + 0j + 0k
0.191699 + -0.0484503i + 0j + 0k
0.108229 + 0.0329811i + 0j + 0k
0.0339998 + 0.107011i + 0j + 0k <—Problem occurs after this as pitch crosses -90 degrees
-0.0320474 + 0.174254i + 0j + 0k
-0.0904425 + 0.235604i + 0j + 0k
-0.141199 + 0.292192i + 0j + 0k

print str(quat_cycle)+' --- '+str(quat_dirNP)

0.75706 + -0.653345i + 0j + 0k — 0.840702 + -0.519485i + 0j + 0k
0.753233 + -0.657754i + 0j + 0k — 0.832338 + -0.532871i + 0j + 0k
0.738067 + -0.674727i + 0j + 0k — 0.824427 + -0.545359i + 0j + 0k
0.722554 + -0.691315i + 0j + 0k — 0.815791 + -0.558296i + 0j + 0k
0.715626 + -0.698483i + 0j + 0k — 0.806468 + -0.571598i + 0j + 0k <—Problem occurs after this as pitch crosses -90 degrees
-0.700329 + 0.71382i + 0j + 0k — 0.797383 + -0.584286i + 0j + 0k
-0.688795 + 0.724956i + 0j + 0k — 0.647612 + -0.454476i + 0j + 0k
-0.685709 + 0.727876i + 0j + 0k — 0.513971 + -0.336533i + 0j + 0k
-0.669097 + 0.743175i + 0j + 0k — 0.394003 + -0.230092i + 0j + 0k
-0.664191 + 0.747563i + 0j + 0k — 0.287693 + -0.132765i + 0j + 0k
-0.653522 + 0.756908i + 0j + 0k — 0.192505 + -0.0447323i + 0j + 0k

I found a solution in another post:
bullet : Mad Quaternion [url]bullet : Mad Quaternion[Solved]]

I think my code improves on that solution. Code follows.

In English, the solution is a hack that checks for a too-large difference between subsequent quaternions and reverses the quaternion to an equivalent, but not-so-different quaternion. I hope that makes sense.

  def simDrift(self, dt):
    #Gets the quaternions representing this NodePaths' orientations.
    quat_cycle = self.cycle.getQuat()
    quat_dirNP = self.dirNP.getQuat()
    #Get the difference between quat_cycle and the previous value of quat_cycle
    diff_quat=Quat(quat_cycle - self.previous_quat)
    #If the difference is abnormally large, reverse the quat to an equivalent quat.
    if abs(diff_quat.getR())>1.0 or abs(diff_quat.getI())>1.0 or \
      abs(diff_quat.getJ())>1.0 or abs(diff_quat.getK())>1.0:
      quat_cycle.setR(-quat_cycle.getR())
      quat_cycle.setI(-quat_cycle.getI())
      quat_cycle.setJ(-quat_cycle.getJ())
      quat_cycle.setK(-quat_cycle.getK())
    #Update previous quat
    self.previous_quat = quat_cycle
    #Difference between actual and desired orientations in degrees
    diff_deg = quat_dirNP.angleDeg(quat_cycle)
    #If the difference is small enough, then just snap to the Cycle's orientation.
    if diff_deg < 0.1:
        self.dirNP.setQuat(quat_cycle)
    else:
        #Otherwise perform a linear interpolation
        t = 10.0 * dt #Close 10% of the difference in orientation per second.
        self.dirNP.setQuat(quat_dirNP + (quat_cycle - quat_dirNP) * t)

That’s rather clever, I think (and may be of use to me too when I return to a particular part of my current project). :slight_smile:

Funnily enough, I was about to post a potential alternative technique for detecting the flip (comparing the axes and angles of the quaternions) when, in checking the documentation on the Quat class, I discovered that it already has the methods isSameDirection and almostSameDirection, which appear to do the job. (The latter seems to essentially be the former with the addition of a parameterised threshold, and so may be the more useful of the two for your purposes.)

Thinking about it further, however, I don’t think that those methods like add much, since you’d presumably want to check the signs on one or more elements anyway.