Howdy, Stranger!

It looks like you're new here. If you want to get involved, click one of these buttons!

Port of Marco Monster's 2D car physics (now with video)

edited June 2015 in Code Sharing Posts: 2,020

Looking at top-down 2D car physics code and tutorials, a lot of them cite Marco Monster's article (archived here, a port of the code here). Here's a quick Codea port I made, just the car/wheel physics (not the engine, gears etc), using modelMatrix to handle the rotations. The basic principle is that you translate into the local space of the car, then you can separate the lateral and longitudinal forces acting on the body and the wheels.

I don't understand how all the variables in the simulation interact, but you can make the car drift quite easily.

You could also implement this in Box2D, by cancelling out most of the lateral forces on the wheels, eg top example here.

Tilt to steer, touch right side to accelerate, left side to brake/ reverse.

Issues:

  • Friction doesn't seem to bring car to a complete halt, there seems to be lingering velocity.

  • Steering doesn't work when vehicle is going backwards

--# Main
-- 2D Car Physics
supportedOrientations(LANDSCAPE_ANY)
displayMode(OVERLAY)

function setup()
    centre=vec2(WIDTH,HEIGHT)/2
    parameter.watch("you.pos")
    parameter.watch("you.angleVel")
    parameter.watch("you.vel")
    parameter.watch("you.steerAngle")
    you = Vehicle{pos=vec2(WIDTH,HEIGHT)/2, size=vec2(50,30), mass=700, inertia=700, steering = Vehicle.tiltSteering, gas = Vehicle.touchGas}
end

function draw()
    background(40, 40, 50)
    you:update()  
end

function touched(t)
    if t.state == ENDED then
        touching = false
    else
        if t.x<centre.x then
            touching = 1
        else
            touching = 2
        end
    end
end

--# Body
Body = class()

function Body:init(t)
    self.size=t.size
    local w, h = t.size.x/2, t.size.y/2
    self.radius = vec2(w,h)
    self.points={vec2(-w,-h), vec2(-w,h), vec2(w,h), vec2(w,-h)}
    -- linear properties
    self.pos = t.pos
    self.vel = t.vel or vec2(0,0) 
    self.forces = vec2(0,0) --accumulated forces. Zeroed every frame
    self.mass = t.mass or 1 --kg
    --angular properties
    self.angle = t.angle or 0
    self.angleVel = 0 --in rad/s
    self.torque = 0 --accumulated torque. zeroed every frame
    self.inertia = t.inertia or 150 --kg.m
    --total transform
    self.matrix=matrix()
end

function Body:constrain()
    self.vel = outOfBounds(self.vel, self.pos-self.radius, self.pos+self.radius)
end

function Body:integrate()
    local dt = DeltaTime
    --integrate physics
    --linear
    local accel = self.forces /self.mass 
    self.vel  = self.vel  + dt * accel 
    --angular 
    local angAcc = self.torque /self.inertia
    self.angleVel = self.angleVel + dt * angAcc
    --constrain
   -- self:constrain()
    --update
    self.pos = self.pos + dt *self.vel    
    self.angle = self.angle + dt * self.angleVel
    --reset accumulators
    self.forces = vec2(0,0)
    self.torque = 0
end

function Body:getWorldPoint(v)
    return self.matrix * v
end

function Body:getLocalPoint(v)
    local inv = self.matrix:inverse()
    return inv * v
end

function Body:applyForce(force, worldPoint)
    self.forces = self.forces + force
    --[[
    if worldPoint then
        self:applyTorque( math.rad( worldPoint-self.pos:cross(force)) ) --world offset (torque arm) crossed with world force. Why does this have to be converted to radians though?
    end
      ]]
end

function Body:applyTorque(ang)
    self.torque = self.torque + ang
end

--# Vehicle
Vehicle = class(Body) --based on Marco Monster's model 
--X Forwards. 
--added weight distribution formula
--issues: steering doesnt work in reverse
--friction doesnt quite bring car to complete stop

local drag = 20 --4.257 --air resistance, proportional to vel squared
local resistance = drag * 30 --rolling resistance, proportional to vel
local cornerStiffRear = -5.2 
local cornerStiffFront = -5 
local maxGrip= 4 --increase this for a tighter turning circle, less sliding?

local engineAccel = 180
local engineBreak = engineAccel * 10
local sensitivity = 1 --steering sensitivity

function Vehicle:init(t)
    Body.init(self, t)

    self.wheelRadius = 10 
    self.length = t.size.x - (self.wheelRadius*2) --wheelbase
    self.halfLength = self.length/2
    --weight distribution variables
    local height=2 --vertical distance from centre of wheels to centre of geometry (keep low, otherwise vehicle really slides on breaking)
    self.heightRatio = 2/ self.length -- height as a proportion if wheelbase, used to calculate shifting weight distribution 
    self.weightDiff = 0
    self.accelRate = 0 -- this is stored to calculate distribution of weight between axles
     --inputs
    self.steering = t.steering
    self.steerTarget=0

    self.gas = t.gas
    self.revs = 0
    self.maxRevs = 64000 --completely made-up number
end

function Vehicle:update()
    -- inputs
    self:gas()
    self:steering()
    --physics
    self:doPhysics()
    self:constrain()
    self:integrate()

    self:draw()
end

function Vehicle:doPhysics()
    --transform world velocity to local 2D space
   local vel = vecRotMat(self.vel, self.matrix:inverse()) --
   -- local vel = vec2(cos * self.vel.y + sin * self.vel.x, -sin * self.vel.y + cos * self.vel.x)
  --  local vel=returnVec2(self:getLocalPoint(self.vel))

    --lateral force on wheels. r * vel in radians/second
    local yawSpeed = self.halfLength * self.angleVel   
    rotAngle = math.atan(yawSpeed,vel.x)
    --sideslip angle of car (beta). ie angle between orientation and velocity
    sideSlip = math.atan(vel.y, vel.x) 
    --calculate slip angles for front and rear wheels (alpha)
    local slipAngleFront = sideSlip + rotAngle - self.steerAngle
    local slipAngleRear = sideSlip - rotAngle

    --weight per axle = half car mass times 1gee (9.8 m/s^2)
    local weight = self.mass * 4.9
    weightDiff = self.heightRatio * self.mass * self.accelRate --weight distribution between axles (stored to animate body)
    local weightFront = weight - weightDiff
    local weightRear = weight + weightDiff
    --vary this according to acceleration

    --lateral force on front wheels (corner stiffness * slip angle, clamped to friction circle * load), front slips
    local latForceFront = vec2(0,clamp(cornerStiffFront * slipAngleFront, -maxGrip, maxGrip)*weightFront) 
    --lateral force on rear wheels
    local latForceRear = vec2(0, clamp(cornerStiffRear * slipAngleRear, -maxGrip, maxGrip)* weightRear)

    --longitudinal force on rear wheels = traction. 100 * (self.revs - self.brake * sign(vel.x))
    local traction = vec2(100 * self.revs, 0 )

    --drag and rolling resistance
    local friction = -vel * (resistance + drag * vel:len())

    --sum forces
    local cornering = (latForceFront):rotate(self.steerAngle) + latForceRear
    local force = traction + cornering + friction

    --APPLY FORCE AND TORQUE
    --sum torque from lat forces   
    self:applyTorque(latForceFront.y - latForceRear.y)

    --convert force back to 3D world space
    acceleration = vecRotMat(force, self.matrix) -- self:getWorldPoint(accel)
    -- acceleration = vecAddZ(vec2(cos * accel.y + sin * accel.x, -sin * accel.y + cos * accel.x), 0)    
    self:applyForce(acceleration)

    self.accelRate = DeltaTime * force.x / self.mass --store acceleration rate to calculate weight distribution next frame 
end

function Vehicle:draw()
    pushMatrix()
    translate(self.pos.x, self.pos.y)
    rotate(math.deg(self.angle)) 

    self.matrix = modelMatrix()

    sprite("Platformer Art:Block Grass", 0,0,self.size.x, self.size.y)
    self:drawWheel(1, self.wheelRadius)
    self:drawWheel(2, self.wheelRadius)
    self:drawWheel(3, -self.wheelRadius, self.steerAngle)
    self:drawWheel(4, -self.wheelRadius, self.steerAngle)
    popMatrix()
end

function Vehicle:drawWheel(n, offset, turn)
    local turn = turn or 0
    pushMatrix()
    translate(self.points[n].x+offset, self.points[n].y)
    rotate(math.deg(turn))
    sprite("Platformer Art:Block Special", 0,0,20,5)
    popMatrix()
end

function Vehicle:tiltSteering()
    self.steerAngle = clamp(-Gravity.x * sensitivity, -0.40, 0.40) 
end

function Vehicle:touchGas()
    if touching==2 then
        self.revs = math.min(self.revs + engineAccel, self.maxRevs)
    elseif touching==1 then
        self.revs= math.max(self.revs - engineAccel, -self.maxRevs * 0.1)
    else
        self.revs= math.max(self.revs - engineBreak, 0)
    end
end

--# Helper
function outOfBounds(vel, a, b, bb, aa) --if box ab on heading vel is outside of boundary aabb, returns a corrective vel, plus how much out of bounds the box is
    local b=b or a
    local aa=aa or vec2(0,0)
    local bb=bb or vec2(WIDTH,HEIGHT)
    local x,y=0,0
    if a.x<aa.x and vel.x<0 then x=a.x-aa.x vel.x=-vel.x
    elseif b.x>bb.x and vel.x>0 then x=b.x-bb.x vel.x=-vel.x
    end
    if a.y<aa.y and vel.y<0 then y=a.y-aa.y vel.y=-vel.y
    elseif b.y>bb.y and vel.y>0 then y=b.y-bb.y vel.y=-vel.y
    end
    return vel, x, y
end

function AABB(v,aa,bb)
    if v.x>aa.x and v.x<bb.x and v.y>aa.y and v.y<bb.y then return true end
end

function sign(x) --returns -1 if x<0, else 1
    return (x<0 and -1) or 1
end

function clamp(v,low,high)
    return math.min(math.max(v, low), high)
end

function vecRotMat(v, m) --apply rotation part (top 2x2) of a mat4 to a vec2
    return vec2(
    m[1]*v.x + m[5]*v.y,
    m[2]*v.x + m[6]*v.y)
end

Comments

  • edited June 2015 Posts: 2,020

    Here's a partial fix for the weird friction problem. Turning the wheel creates tiny amounts of lateral force, so that the car still twitches a little when it is at rest. This uses an edge algorithm to kill the lateral force when the velocity drops down too low. It's a bit of a hack though...

    Change these lines in Vehicle:doPhysics(), and then add the edge function somewhere:

        local motion = edge(vel:len(), 0.1, 5) --kill steering, motion when speed falls off
        --lateral force on front wheels (corner stiffness * slip angle, clamped to friction circle * load), front slips
        local latForceFront = vec2(0,clamp(cornerStiffFront * slipAngleFront * motion, -maxGrip, maxGrip)*weightFront) 
        --lateral force on rear wheels
        local latForceRear = vec2(0, clamp(cornerStiffRear * slipAngleRear * motion, -maxGrip, maxGrip)* weightRear)
    
        -- put this function somewhere:
        function edge(t,a,b)
            local a,b = a or 0,b or 1
            return math.min(1,math.max(0,(t-a)/(b-a)))
        end
    

    I have no idea why the steering doesn't work in reverse though. Perhaps someone who understands the trig better than me can work out what's going on there?

    These issues aside, I quite like the feel of this, it reminds me of top-down racers like the Reckless Racing games. I'm sure with some tweaking of the variables you could get various drift behaviours like "donuts" etc

  • IgnatzIgnatz Mod
    Posts: 5,396

    @Yojimbo2000 - I suspect the code writer didn't attempt reverse because that is more difficult

    It seems to me the whole thing could be done more simply without all this effort, because there is no need for such complexity on a flat even surface. Even if you drive on bumpy terrain, I'm sure it's easier to just fake it.

  • Posts: 2,020

    This seems to be your default response, "it's too complex, why bother!" :P

    It depends on what your aims are I suppose. I initially started with the assumption "the car can only move in the direction the wheels are pointing". But then the vehicle handles as if it is on rails. That assumption is probably true at slow speeds, but it's unrealistic, and just not very satisfying at high speeds.

    Then I tried simulating the car as an unrestrained physics body, with its own mass and inertia, being propelled in the direction the wheels are pointing. But then it handles like a shopping trolley (because the wheels move equally well in all directions).

    I didn't find either of the above approaches particularly satisfying.

    In order to get a somewhat satisfying feeling that we are steering a car at speed we need a combination of the above two approaches. The wheels want to move longitudinally (ie they roll in the direction they face), but particularly at high speeds they are also subject to lateral forces (slip). So we need some kind of system that converts a certain amount of the lateral force into longitudinal force.

    This is the case regardless of whether we are working in 2D or 3D.

    I think Marco's code probably can be simplified, but the fundamental principle — of translating into the local space of the car, so that longitudinal and lateral forces can be mapped onto the X and Y axis — is a good one. It also lends itself well to 3D (or 2.5D) simulations. The video below adapts the code above to a 3D simulation. All of the calculations of slip and drift occur in a local 2D plane, but that plane undulates as the vehicle moves over the bumpy terrain (I've read that this is how a lot of the more arcadey 3D racers work).

    I've also animated the body so that you can see the weight shifting forward when the vehicle brakes, which helps the vehicle drift.

  • IgnatzIgnatz Mod
    Posts: 5,396

    Don't get me wrong, I admire all the technical wizardry B-), and I think it's great to try it out.

    I'm simply repeating what I've read about in the history of video gaming, that faking stuff realistically is much easier (and faster) than trying to copy the real physics, which is horrendously complex.

    Watching the video, I think simple terrain following code would look effectively identical, except for the drift, which can be faked, and I'm sure the steering round corners at speed can be mimicked too. Perhaps the physics would show to greater advantage if the vehicle got more air.

    (Nice terrain, by the way!).

  • Posts: 2,020

    I would argue that Marco's code is "faking it" though. For one thing, it's only modelling two wheels (front and back), and using them to represent the four wheels of the car. Things like the maxGrip variable is a fairly arbitrary number that is representing far more complex things (I think it's the friction circle * load, whatever that means)

    And mapping the 2D torque and sliding onto whatever 3D plane the vehicle is in is definitely faking it.

  • Posts: 2,020

    Here's another tutorial on doing the same thing in Box2D, by cancelling out some of the lateral forces: http://www.iforce2d.net/b2dtut/top-down-car

  • IgnatzIgnatz Mod
    edited June 2015 Posts: 5,396

    Here is a simple example of using forces to steer the car round corners.

    The car will try to follow your finger on the screen, and the black line shows the wheel direction (I haven't put realistic limits on how far wheels can turn!). The cars momentum keeps it travelling in its original direction initially, so the car would appear to slide if I bothered drawing wheels.

    displayMode(STANDARD)
    
    function setup()
        pos=vec2(200,200)
        carDir=vec2(0,1)
        wheelDir=vec2(0,1)
        speed=0
        momentum=0
    end
    
    function draw()
        background(120)
        speed=math.max(0,speed-.2)
        --give momentum a minimum size to prevent dividing by zero
        momentum=math.max(0.000001,momentum)
        --calc weighted ave of carDir and wheelDir
        carDir=((carDir*momentum+wheelDir*speed)/(momentum+speed)):normalize()
        pos=pos+carDir*speed*DeltaTime
        DrawCar(pos)
        --reduce momentum, add part of speed (dot function means we add
        --more momentum if going straight than if turning sharply)
        momentum=momentum*0.99+carDir:dot(wheelDir)*speed
    end
    
    function touched(t)
        --wheel direction points at finger
        wheelDir=(vec2(t.x,t.y)-pos):normalize()
        speed=math.min(100,speed+3) --increase speed while touching
    end
    
    function DrawCar()
        pushMatrix()
        pushStyle()
        stroke(163, 76, 76, 255)
        lineCapMode(SQUARE)
        local s=25
        strokeWidth(s)
        local p1,p2=pos-carDir*s,pos+carDir*s
        line(p1.x,p1.y,p2.x,p2.y)
        stroke(0)
        strokeWidth(2)
        local d=wheelDir*s
        line(p2.x,p2.y,p2.x+d.x,p2.y+d.y)    
        popStyle()
        popMatrix()
    end
    
    
  • Posts: 2,020

    That's cool! It reminds me a little of the path-following vehicle in Nature Of Code. It's really interesting I think that you've done this without any explicit angles (just vectors), and almost no trig (just normalize). But the car can only point in the direction it's travelling, have I got that right (so no drifting or lateral movement)?

    Could I ask you a favour, would you be able to add some comments to your code above? e.g. the formula for calculating carDir and momentum are a little hard to follow (eg I don't understand why you divide carDir by momentum+speed if you're then normalizing it?). It would be really helpful for me (and I'm sure others) if you could unpack those parts a little.

  • IgnatzIgnatz Mod
    Posts: 5,396

    Lol, the Nature of Code is pretty much what I'm channeling.

    It should be possible to drift the car by delaying the change in direction until after the car is drawn, ie draw the car, then adjust direction and move the car.

    I added some comments in the code.

  • Posts: 2,020

    Awesome, thanks for commenting the code. It's an interesting approach. So rather than damping the lateral motion (like in the box2d tutorial I linked to previously), it adds extra longitudinal motion depending on the dot product of the wheel and car direction.

    You'd need to separate car direction (ie which way it is facing) from car velocity (which direction it is moving in) to get sideways drift though.

  • IgnatzIgnatz Mod
    Posts: 5,396

    Agreed, so it does seem to be possible to get similar effects with this approach.

    Vector-based steering seems to work very nicely, for tabletop racing.

  • Posts: 2,020

    Here's a version of your code that adds velocity and friction, so the car can have lateral motion (drift). With some tweaking, this could actually be pretty good. The momentum calculation is really interesting, it's absolutely key. The vehicle section of Nature of Code uses dot products for its steering, but it didn't use them quite like this, AFAIR. Did you come up with that yourself?

    displayMode(STANDARD)
    local friction = 3
    function setup()
        pos=vec2(200,200)
        carDir=vec2(0,1) --direction car is pointing
        carVel=vec2(0,1) --direction car is moving 
        wheelDir=vec2(0,1) --direction car wants to move
        speed=0
        momentum=0
    end
    
    function draw()
        background(120)
        if touching then speed=math.min(900,speed+21) end --increase speed while touching
        speed=math.max(0,speed-9)
        --give momentum a minimum size to prevent dividing by zero
        momentum=math.max(0.000001,momentum)
        --calc weighted ave of carDir and wheelDir
        carDir=(carDir*momentum+wheelDir*speed):normalize() --/(momentum+speed)
        local forces = carDir * speed - carVel * friction
        carVel = carVel + forces * DeltaTime
        pos=pos+carVel*DeltaTime
        DrawCar(pos)
        --reduce momentum, add part of speed (dot function means we add
        --more momentum if going straight than if turning sharply)
        --(or should it be the dot of the velocity and the wheel dir?)
        momentum=momentum*0.995+carDir:dot(wheelDir)*speed
        --momentum=momentum*0.995+carVel:normalize():dot(wheelDir)*speed
    end
    
    function touched(t)
        --wheel direction points at finger
        wheelDir=(vec2(t.x,t.y)-pos):normalize()
        if t.state == ENDED then touching = false else touching = true end
    end
    
    function DrawCar()
        pushMatrix()
        pushStyle()
        stroke(163, 76, 76, 255)
        lineCapMode(SQUARE)
        local s=25
        strokeWidth(s)
        local p1,p2=pos-carDir*s,pos+carDir*s
        line(p1.x,p1.y,p2.x,p2.y)
        stroke(0)
        strokeWidth(2)
        local d=wheelDir*s
        line(p2.x,p2.y,p2.x+d.x,p2.y+d.y)    
        popStyle()
        popMatrix()
    end
    
    
  • IgnatzIgnatz Mod
    Posts: 5,396

    I could say it was my idea, but it's more likely I got it from somewhere :P

  • dave1707dave1707 Mod
    Posts: 7,553

    Here's a way that might interest you. Just move your finger in a figure 8 or any other way you want. Also, after you turn a corner, lift your finger off the screen. I didn't put a whole lot into this because I don't know if this is usable or not. If you loose where the car is, just restart the program. You can try changing the friction, or the 1.05 in draw.

    displayMode(FULLSCREEN)
    
    function setup()
        physics.continuous=true
        lineCapMode(SQUARE)
        dx,dy=0,0
        p1=physics.body(CIRCLE,20)
        p1.x=300
        p1.y=600
        p1.gravityScale=0
        p2=physics.body(CIRCLE,5)
        p2.x=300
        p2.y=550
        p2.gravityScale=0
        p2.friction=20
        jnt1=physics.joint(DISTANCE,p1,p2,p1.position,p2.position)
    end
    
    function draw()
        background(40, 40, 50)
        stroke(255)
        strokeWidth(12)
        line(p1.x,p1.y,p2.x,p2.y)
        if math.abs(p2.linearVelocity.x)>0 or math.abs(p2.linearVelocity.y)>0 then
            p2.linearVelocity=p2.linearVelocity/1.05
        end
    end
    
    function touched(t)
        if t.state==MOVING then
            p1.linearVelocity=p1.linearVelocity+vec2(t.deltaX*5,t.deltaY*5)
        end
    end    
    
  • edited June 2015 Posts: 2,020

    @dave1707 I think it should be linearDamping in these kinds of top-down sims, not friction, as damping is in effect at all times (ie simulating an XY ground plane that box2D is otherwise unaware of), whereas friction only applies when two box2D bodies come into contact. Personally, I wouldn't use a joint for a steering action like this, it's too hard to control. I'd apply a force in the direction of the touch. EDIT: or rather, the object doesn't feel very car-like with this kind of offset control, you don't get a sense of steering towards something, more like it is directly translating your touch, but with a rubber-banding effect.

  • dave1707dave1707 Mod
    Posts: 7,553

    @yojimbo2000 I'm not sure why I didn't try the linearDamping. Maybe the linearVelocity calculation was working how I wanted and I didn't look for anything else. This was reminding me of a front wheel drive pickup truck driving around in a snow covered parking lot.

Sign In or Register to comment.