""" Top-down car dynamics simulation. Some ideas are taken from this great tutorial http://www.iforce2d.net/b2dtut/top-down-car by Chris Campbell. This simulation is a bit more detailed, with wheels rotation. Created by Oleg Klimov """ import math import Box2D import numpy as np from gym.error import DependencyNotInstalled try: from Box2D.b2 import fixtureDef, polygonShape, revoluteJointDef except ImportError: raise DependencyNotInstalled("box2D is not installed, run `pip install gym[box2d]`") SIZE = 0.02 ENGINE_POWER = 100000000 * SIZE * SIZE WHEEL_MOMENT_OF_INERTIA = 4000 * SIZE * SIZE FRICTION_LIMIT = ( 1000000 * SIZE * SIZE ) # friction ~= mass ~= size^2 (calculated implicitly using density) WHEEL_R = 27 WHEEL_W = 14 WHEELPOS = [(-55, +80), (+55, +80), (-55, -82), (+55, -82)] HULL_POLY1 = [(-60, +130), (+60, +130), (+60, +110), (-60, +110)] HULL_POLY2 = [(-15, +120), (+15, +120), (+20, +20), (-20, 20)] HULL_POLY3 = [ (+25, +20), (+50, -10), (+50, -40), (+20, -90), (-20, -90), (-50, -40), (-50, -10), (-25, +20), ] HULL_POLY4 = [(-50, -120), (+50, -120), (+50, -90), (-50, -90)] WHEEL_COLOR = (0, 0, 0) WHEEL_WHITE = (77, 77, 77) MUD_COLOR = (102, 102, 0) class Car: def __init__(self, world, init_angle, init_x, init_y): self.world: Box2D.b2World = world self.hull: Box2D.b2Body = self.world.CreateDynamicBody( position=(init_x, init_y), angle=init_angle, fixtures=[ fixtureDef( shape=polygonShape( vertices=[(x * SIZE, y * SIZE) for x, y in HULL_POLY1] ), density=1.0, ), fixtureDef( shape=polygonShape( vertices=[(x * SIZE, y * SIZE) for x, y in HULL_POLY2] ), density=1.0, ), fixtureDef( shape=polygonShape( vertices=[(x * SIZE, y * SIZE) for x, y in HULL_POLY3] ), density=1.0, ), fixtureDef( shape=polygonShape( vertices=[(x * SIZE, y * SIZE) for x, y in HULL_POLY4] ), density=1.0, ), ], ) self.hull.color = (0.8, 0.0, 0.0) self.wheels = [] self.fuel_spent = 0.0 WHEEL_POLY = [ (-WHEEL_W, +WHEEL_R), (+WHEEL_W, +WHEEL_R), (+WHEEL_W, -WHEEL_R), (-WHEEL_W, -WHEEL_R), ] for wx, wy in WHEELPOS: front_k = 1.0 if wy > 0 else 1.0 w = self.world.CreateDynamicBody( position=(init_x + wx * SIZE, init_y + wy * SIZE), angle=init_angle, fixtures=fixtureDef( shape=polygonShape( vertices=[ (x * front_k * SIZE, y * front_k * SIZE) for x, y in WHEEL_POLY ] ), density=0.1, categoryBits=0x0020, maskBits=0x001, restitution=0.0, ), ) w.wheel_rad = front_k * WHEEL_R * SIZE w.color = WHEEL_COLOR w.gas = 0.0 w.brake = 0.0 w.steer = 0.0 w.phase = 0.0 # wheel angle w.omega = 0.0 # angular velocity w.skid_start = None w.skid_particle = None rjd = revoluteJointDef( bodyA=self.hull, bodyB=w, localAnchorA=(wx * SIZE, wy * SIZE), localAnchorB=(0, 0), enableMotor=True, enableLimit=True, maxMotorTorque=180 * 900 * SIZE * SIZE, motorSpeed=0, lowerAngle=-0.4, upperAngle=+0.4, ) w.joint = self.world.CreateJoint(rjd) w.tiles = set() w.userData = w self.wheels.append(w) self.drawlist = self.wheels + [self.hull] self.particles = [] def gas(self, gas): """control: rear wheel drive Args: gas (float): How much gas gets applied. Gets clipped between 0 and 1. """ gas = np.clip(gas, 0, 1) for w in self.wheels[2:4]: diff = gas - w.gas if diff > 0.1: diff = 0.1 # gradually increase, but stop immediately w.gas += diff def brake(self, b): """control: brake Args: b (0..1): Degree to which the brakes are applied. More than 0.9 blocks the wheels to zero rotation""" for w in self.wheels: w.brake = b def steer(self, s): """control: steer Args: s (-1..1): target position, it takes time to rotate steering wheel from side-to-side""" self.wheels[0].steer = s self.wheels[1].steer = s def step(self, dt): for w in self.wheels: # Steer each wheel dir = np.sign(w.steer - w.joint.angle) val = abs(w.steer - w.joint.angle) w.joint.motorSpeed = dir * min(50.0 * val, 3.0) # Position => friction_limit grass = True friction_limit = FRICTION_LIMIT * 0.6 # Grass friction if no tile for tile in w.tiles: friction_limit = max( friction_limit, FRICTION_LIMIT * tile.road_friction ) grass = False # Force forw = w.GetWorldVector((0, 1)) side = w.GetWorldVector((1, 0)) v = w.linearVelocity vf = forw[0] * v[0] + forw[1] * v[1] # forward speed vs = side[0] * v[0] + side[1] * v[1] # side speed # WHEEL_MOMENT_OF_INERTIA*np.square(w.omega)/2 = E -- energy # WHEEL_MOMENT_OF_INERTIA*w.omega * domega/dt = dE/dt = W -- power # domega = dt*W/WHEEL_MOMENT_OF_INERTIA/w.omega # add small coef not to divide by zero w.omega += ( dt * ENGINE_POWER * w.gas / WHEEL_MOMENT_OF_INERTIA / (abs(w.omega) + 5.0) ) self.fuel_spent += dt * ENGINE_POWER * w.gas if w.brake >= 0.9: w.omega = 0 elif w.brake > 0: BRAKE_FORCE = 15 # radians per second dir = -np.sign(w.omega) val = BRAKE_FORCE * w.brake if abs(val) > abs(w.omega): val = abs(w.omega) # low speed => same as = 0 w.omega += dir * val w.phase += w.omega * dt vr = w.omega * w.wheel_rad # rotating wheel speed f_force = -vf + vr # force direction is direction of speed difference p_force = -vs # Physically correct is to always apply friction_limit until speed is equal. # But dt is finite, that will lead to oscillations if difference is already near zero. # Random coefficient to cut oscillations in few steps (have no effect on friction_limit) f_force *= 205000 * SIZE * SIZE p_force *= 205000 * SIZE * SIZE force = np.sqrt(np.square(f_force) + np.square(p_force)) # Skid trace if abs(force) > 2.0 * friction_limit: if ( w.skid_particle and w.skid_particle.grass == grass and len(w.skid_particle.poly) < 30 ): w.skid_particle.poly.append((w.position[0], w.position[1])) elif w.skid_start is None: w.skid_start = w.position else: w.skid_particle = self._create_particle( w.skid_start, w.position, grass ) w.skid_start = None else: w.skid_start = None w.skid_particle = None if abs(force) > friction_limit: f_force /= force p_force /= force force = friction_limit # Correct physics here f_force *= force p_force *= force w.omega -= dt * f_force * w.wheel_rad / WHEEL_MOMENT_OF_INERTIA w.ApplyForceToCenter( ( p_force * side[0] + f_force * forw[0], p_force * side[1] + f_force * forw[1], ), True, ) def draw(self, surface, zoom, translation, angle, draw_particles=True): import pygame.draw if draw_particles: for p in self.particles: poly = [pygame.math.Vector2(c).rotate_rad(angle) for c in p.poly] poly = [ ( coords[0] * zoom + translation[0], coords[1] * zoom + translation[1], ) for coords in poly ] pygame.draw.lines( surface, color=p.color, points=poly, width=2, closed=False ) for obj in self.drawlist: for f in obj.fixtures: trans = f.body.transform path = [trans * v for v in f.shape.vertices] path = [(coords[0], coords[1]) for coords in path] path = [pygame.math.Vector2(c).rotate_rad(angle) for c in path] path = [ ( coords[0] * zoom + translation[0], coords[1] * zoom + translation[1], ) for coords in path ] color = [int(c * 255) for c in obj.color] pygame.draw.polygon(surface, color=color, points=path) if "phase" not in obj.__dict__: continue a1 = obj.phase a2 = obj.phase + 1.2 # radians s1 = math.sin(a1) s2 = math.sin(a2) c1 = math.cos(a1) c2 = math.cos(a2) if s1 > 0 and s2 > 0: continue if s1 > 0: c1 = np.sign(c1) if s2 > 0: c2 = np.sign(c2) white_poly = [ (-WHEEL_W * SIZE, +WHEEL_R * c1 * SIZE), (+WHEEL_W * SIZE, +WHEEL_R * c1 * SIZE), (+WHEEL_W * SIZE, +WHEEL_R * c2 * SIZE), (-WHEEL_W * SIZE, +WHEEL_R * c2 * SIZE), ] white_poly = [trans * v for v in white_poly] white_poly = [(coords[0], coords[1]) for coords in white_poly] white_poly = [ pygame.math.Vector2(c).rotate_rad(angle) for c in white_poly ] white_poly = [ ( coords[0] * zoom + translation[0], coords[1] * zoom + translation[1], ) for coords in white_poly ] pygame.draw.polygon(surface, color=WHEEL_WHITE, points=white_poly) def _create_particle(self, point1, point2, grass): class Particle: pass p = Particle() p.color = WHEEL_COLOR if not grass else MUD_COLOR p.ttl = 1 p.poly = [(point1[0], point1[1]), (point2[0], point2[1])] p.grass = grass self.particles.append(p) while len(self.particles) > 30: self.particles.pop(0) return p def destroy(self): self.world.DestroyBody(self.hull) self.hull = None for w in self.wheels: self.world.DestroyBody(w) self.wheels = []