-
Notifications
You must be signed in to change notification settings - Fork 1
Implementation details
Initialisation before the main program loop takes care of allocating resources used throughout the application. Here resources are allocated to manage the GUI widgets, the user’s input, OpenGL window, models, and shaders, and the dynamics world used for the core simulation.
The car is defined as a combination of rigid bodies and constraints and physical parameters:
// Car components
btRigidBody *car, *t1, *t2, *t3, *t4;
btGeneric6DofSpringConstraint *c1, *c2, *c3, *c4;
// Car properties
float car_mass = 1250.0f;
float tyre_mass_1 = 15.0f; // front wheels
float tyre_mass_2 = 20.0f; // rear wheels
float tyre_friction = 2.25f;
float tyre_stiffness = 120000.0f; // suspensions
float tyre_damping = 0.0000200f; // suspensions
float tyre_steering_angle = 0.5f;
float maxAcceleration = 800.0f; // torque
const float cLinDamp = 0.02f; // car linear damping
const float cAngDamp = 0.4f; // car angular damping
const float tLinDamp = 0.01f; // tyre linear damping
const float tAngDamp = 0.2f; // tyre angular damping
All car rigid bodies are then instantiated and initialised:
Physics simulation;
car = simulation.createRigidBody(BOX, car_pos, car_size, car_rot, car_mass, 1.75f, 0.2f, COLL_CHASSIS, COLL_EVERYTHING^COLL_CAR);
car->setSleepingThresholds(0.0, 0.0); // never stop simulating
car->setDamping(cLinDamp*assist, cAngDamp*assist);
t1 = simulation.createRigidBody(CYLINDER, t1_pos, t1_size, t1_rot, tyre_mass_1, tyre_friction, 0.0f, COLL_TYRE, COLL_EVERYTHING^COLL_CAR);
t1->setSleepingThresholds(0.0, 0.0); // never stop simulating
t1->setDamping(tLinDamp*assist, tAngDamp*assist);
// ... same for other tyres
Finally, constraints are set up to assemble the car:
btTransform frameA = btTransform::getIdentity();
btTransform frameB = btTransform::getIdentity();
frameA.getBasis().setEulerZYX(0, 0, 0);
frameB.getBasis().setEulerZYX(0, 0, glm::radians(90.0f));
frameA.setOrigin(btVector3(-1.0, -0.5, -2.1));
frameB.setOrigin(btVector3(0.0, 0.0, 0.0));
c1 = new btGeneric6DofSpringConstraint(*car, *t1, frameA, frameB, TRUE);
c1->setLinearLowerLimit(btVector3(0, 0, 0));
c1->setLinearUpperLimit(btVector3(0, -0.1, 0));
c1->setAngularLowerLimit(btVector3(1, -tyre_steering_angle, 0));
c1->setAngularUpperLimit(btVector3(-1, tyre_steering_angle, 0));
c1->enableSpring(1, TRUE);
c1->setStiffness(1, tyre_stiffness);
c1->setDamping(1, tyre_damping);
c1->setEquilibriumPoint();
// ... same for other constraints
Of course, the dynamics world needs to be aware of such constraints:
simulation.dynamicsWorld->addConstraint(c1);
simulation.dynamicsWorld->addConstraint(c2);
simulation.dynamicsWorld->addConstraint(c3);
simulation.dynamicsWorld->addConstraint(c4);
The terrain is modelled as a 2D array, whose values are treated as “grass” for zeroes, and “asphalt” for ones, and are used when instantiating their collision shapes and rendering their meshes. There are also vectors for holding references to rigid bodies and their position in space:
const unsigned int grid_width = 5;
const unsigned int grid_height = 8;
const unsigned int tiles = grid_width * grid_height;
const unsigned int track[grid_height][grid_width] = {
{ 0, 0, 0, 0, 0 },
{ 0, 1, 1, 1, 0 },
{ 0, 1, 0, 1, 0 },
{ 0, 1, 0, 1, 0 },
{ 0, 1, 0, 1, 0 },
{ 0, 1, 0, 1, 0 },
{ 0, 1, 1, 1, 0 },
{ 0, 0, 0, 0, 0 }
};
btRigidBody *plane[tiles];
glm::vec3 plane_pos[tiles];
The skybox is modelled as a raw collection of vertex information stored in an array, rather than an external model, and vertex buffer objects and arrays are created on purpose to manage its transformations and rendering.
This task is assigned to a function named loadCubeMap
:
unsigned int loadCubeMap() {
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
int width, height, nrChannels;
unsigned char *data;
std::vector<std::string> txtr_faces;
txtr_faces.push_back("textures/clouds1/clouds1_east.bmp");
txtr_faces.push_back("textures/clouds1/clouds1_west.bmp");
txtr_faces.push_back("textures/clouds1/clouds1_up.bmp");
txtr_faces.push_back("textures/clouds1/clouds1_down.bmp");
txtr_faces.push_back("textures/clouds1/clouds1_north.bmp");
txtr_faces.push_back("textures/clouds1/clouds1_south.bmp");
for (unsigned int i = 0; i < 6; i++) {
data = stbi_load(txtr_faces[i].c_str(), &width, &height, &channels, 0);
glTexImage2D(
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB,
width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
);
stbi_image_free(data);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
return textureID;
}
This function creates a cube map texture, generates its face textures and binds them; then, it loads the actual texture images and assigns each one of them to one face of the cube map; finally, texture parameters are set and a reference to the cube map is returned for later use.
Once set up, the program starts the main loop where I/O, simulation steps, and rendering occur.
The main application loop consists of a sequence of functions and routine calls, invoked in a specific order, and it mainly resembles the common “game loop” of a video game application. Its main operations are as follows:
while (!exit) {
process_input();
update_physics();
render();
}
Each operation deals with different aspects. At the beginning of each iteration, delta time is computed (time passed between each screen update) and the current render buffer is cleared; then, mouse and keyboard input is processed, updating the car’s control variables, while the GUI updates the car’s parameters via callback functions.
Then, input effects are applied to the dynamics world. There are three sub-parts that deal with acceleration, steering, and handbraking respectively.
Acceleration:
float linearVelocity = car->getLinearVelocity().length();
if (acceleration < 0 && linearVelocity > maxVelocity/10) {
// High speed, the user is stopping the car
braking = 0;
} else {
// The user is moving forward, or reverse
if (linearVelocity < maxVelocity/(1 + 9*(acceleration < 0))) {
float torque = -maxAcceleration * acceleration * (1-(abs(steering)*(linearVelocity>10))/2);
t1->applyTorque(rot * btVector3(torque, 0, 0));
t2->applyTorque(rot * btVector3(torque, 0, 0));
if (!handbrake) {
t3->applyTorque(rot * btVector3(torque, 0, 0));
t4->applyTorque(rot * btVector3(torque, 0, 0));
}
}
}
Car’s velocity magnitude is computed in order to determine whether the user input must be interpreted as an actual acceleration or a braking action. If so, torque is applied; otherwise, a flag value is raised, and its effects are delegated to the next piece of code.
Braking/steering (front wheels):
c1->setAngularLowerLimit(btVector3(braking, tyre_steering_angle*steering, 0));
c1->setAngularUpperLimit(btVector3(-braking, tyre_steering_angle*steering, 0));
c2->setAngularLowerLimit(btVector3(braking, tyre_steering_angle*steering, 0));
c2->setAngularUpperLimit(btVector3(-braking, tyre_steering_angle*steering, 0));
This code constraint the rotational degrees of freedom, setting the local X rotation to a value that either allows movement or firmly blocks the wheels, and giving the Y component a value in the interval [-tyre_steering_angle, tyre_steering_angle]: the steering angle is an always-positive value, while steering goes from -1 to 1, and equals 0 when the user is not turning the car at all. The third parameter is always 0 to prevent the wheels to fall down when at rest.
Handbrake (rear wheels):
if (handbrake) {
c3->setAngularLowerLimit(btVector3(0, 0, 0));
c3->setAngularUpperLimit(btVector3(0, 0, 0));
c4->setAngularLowerLimit(btVector3(0, 0, 0));
c4->setAngularUpperLimit(btVector3(0, 0, 0));
} else {
c3->setAngularLowerLimit(btVector3(braking, 0, 0));
c3->setAngularUpperLimit(btVector3(-braking, 0, 0));
c4->setAngularLowerLimit(btVector3(braking, 0, 0));
c4->setAngularUpperLimit(btVector3(-braking, 0, 0));
}
Braking occurs since the previous code snippet and is achieved by constraining wheels’ degrees of freedom around their local X-axis as long as the key is pressed. This is the same mechanism used to apply steering.
After applying torques and constraints, a single function call pushes the simulation one step forward, depending on the value of delta time.
Camera movement:
btTransform temp;
btVector3 newPos;
car->getMotionState()->getWorldTransform(temp);
float aVelocity = -car->getAngularVelocity().y();
newPos = temp.getBasis() * btVector3(glm::cos(glm::radians(-10*glm::sqrt(glm::abs(steering))*aVelocity+90 + baseYaw/4))*cameraRadius, 0, glm::sin(glm::radians(-10*glm::sqrt(glm::abs(steering))*aVelocity + 90 + baseYaw/4))*cameraRadius);
cameraFollowPos.x = temp.getOrigin().getX() + newPos.x();
cameraFollowPos.y = temp.getOrigin().getY() - glm::sin(glm::radians(camera.Pitch))*cameraRadius +1.5;
cameraFollowPos.z = temp.getOrigin().getZ() + newPos.z();
camera.Position = cameraFollowPos;// - glm::vec3(glm::cos(glm::radians(Y))*8, glm::sin(glm::radians(P))*8-1.5, glm::sin(glm::radians(Y))*8);
camera.LookAt(-newPos.x(), newPos.y(), -newPos.z());
When the virtual camera is in follow mode, it sticks around the car at a given distance (cameraRadius
) and rotates according to its internal pitch and yaw values, as well as the car’s relative orientation around the global Y-axis (temp.getBasis()
); the effect is a third-person camera above the car, which rotates when turning but does not firmly follow every slight movement in order to guarantee the stability of the point of view.
Terrain rendering:
tShader.Use();
tShader.setMat4("projection", projection);
tShader.setMat4("view", view);
tShader.setVec3("viewPos", camera.Position);
tShader.setVec3("light.direction", 1.0f, -0.5f, -0.5f);
tShader.setVec3("light.ambient", 0.473f, 0.428f, 0.322f);
glm::mat4 model = glm::mat4(1.0f);
glm::mat4 planeModelMatrix = glm::mat4(1.0f);
for (unsigned int i = 0; i < grid_width; i++) {
for (unsigned int j = 0; j < grid_height; j++) {
planeModelMatrix = glm::translate(planeModelMatrix, plane_pos[i*(grid_height)+j]);
glUniformMatrix4fv(glGetUniformLocation(tShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(planeModelMatrix));
if (track[j][i] == 0) {
// Grass
tShader.setFloat("material.shininess", 4.0f);
tShader.setVec3("light.diffuse", 1.195f, 1.105f, 0.893f);
tShader.setVec3("light.specular", 1.0f, 1.0f, 1.0f);
tModel0.Draw(tShader);
} else if (track[j][i] == 1) {
// Asphalt
tShader.setFloat("material.shininess", 16.0f);
tShader.setVec3("light.diffuse", 0.945f, 0.855f, 0.643f);
tShader.setVec3("light.specular", 2.75f, 2.75f, 2.75f);
tModel1.Draw(tShader);
}
planeModelMatrix = glm::mat4(1.0f);
}
}
After defining the projection and view transform matrices, the model matrix is computed for every tile in the grid and passed as a uniform variable to the current shader along with the camera position in world space, the light direction, and the ambient diffuse. Then, different values are set as shader uniforms depending on whether grass or asphalt are going to be drawn (in order to achieve different visual effects).
The same process applies when rendering the car, with a few additions. The choice of what mesh render is performed by a switch statement, for there are five different objects instead of two; then, car motion and orientation must be taken into account: ground tiles are static, so their physical state is always the same. On the other hand, both car’s vertices and normals must be updated in order to match physics and rendering.
The program browses through the list of active objects, and finds out their transform:
btCollisionObject* obj = simulation.dynamicsWorld->getCollisionObjectArray()[i];
btRigidBody* body = btRigidBody::upcast(obj);
body->getMotionState()->getWorldTransform(transform);
transform.getOpenGLMatrix(matrix);
objModelMatrix = glm::make_mat4(matrix) * glm::scale(objModelMatrix, obj_size);
objNormalMatrix = glm::transpose(glm::inverse(glm::mat3(objModelMatrix)));
While the model transform is immediately available, normals must be updated for the current frame; their transform is the transposed inverse of the model transform, and this is then set as a uniform variable in the shader. In addition, the cube map is bound as model textures, so that the fragment shader can read it when computing colour values:
glActiveTexture(GL_TEXTURE3);
mShader.setInt("skybox", 3);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
Skybox rendering:
view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
glDepthFunc(GL_LEQUAL);
sShader.Use();
sShader.setMat4("projection", projection);
sShader.setMat4("view", view);
glBindVertexArray(skyboxVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthFunc(GL_LESS);
Drawing the skybox requires changing the depth test function using the parameter GL_LEQUAL
: this ensures only fragments unaffected by any previous draw function are updated. This is because drawing other meshes first is likely to cover many pixels in the current frame buffer, and it can be exploited to reduce the overhead when drawing a skybox background. In addition, the view transform is recomputed without taking camera translation into account, so that the skybox will only be affected by rotation while moving integrally with the observer.
A total of six shaders were written for this project, three vertex shaders and three fragment shaders respectively. Each couple is used by the three main objects in the demo application: the terrain tiles, the car meshes, and the skybox.
skybox.vert:
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform mat4 projection;
uniform mat4 view;
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}
The vertex shader is very simple, it uses projection and view transform set in the main loop and returns UVR coordinates (because we are sampling a cube map rather than a texture) and the fragment position. Since the skybox is rendered last, the fragment position is altered so that it is at the furthest distance from the camera.
skybox.frag:
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
The fragment shader simply maps a direction from the origin of the cube map to a point on one of its faces, and assign a colour value to the corresponding fragment.
terrain.vert:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
out vec2 TexCoords;
out vec3 Normal;
out vec3 FragPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
TexCoords = aTexCoords;
Normal = aNormal;
FragPos = vec3(model * vec4(aPos, 1.0));
}
The vertex shader for the terrain computes UV texture coordinates as always, along with fragment normals and their position in world space (by multiplying the model transform and the fragment projected position); these values are then passed to the fragment shader for rendering.
terrain.frag:
#version 330 core
struct Material {
float shininess;
};
struct Light {
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
out vec4 FragColor;
in vec2 TexCoords;
in vec3 Normal;
in vec3 FragPos;
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;
uniform vec3 viewPos;
uniform Material material;
uniform Light light;
void main()
{
// Ambient
vec3 ambient = light.ambient * texture(texture_diffuse1, TexCoords).rgb;
// Diffuse
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(-light.direction);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(texture_diffuse1, TexCoords).rgb;
// Specular
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(texture_specular1, TexCoords).rgb;
// Shading
vec3 result = ambient + diffuse + specular;
result = clamp(result, 0.0, 1.0);
FragColor = vec4(result, 1.0);
}
This fragment shader implements Phong shading for a single directional light source (the “sun”). Texture samplers are set by the Model::Draw()
function, while shininess and light properties are set in the main loop as already seen. Ambient, diffuse, and specular lighting are computed combined with the corresponding texel; then, they are summed and clamped, and finally assigned as fragment colour.
Note that specular value depends on the current model’s specular map, allowing to define shiny and matte surface effects.
car.vert:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
out vec2 TexCoords;
out vec3 Normal;
out vec3 FragPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform mat3 normal;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
TexCoords = aTexCoords;
Normal = normalize(normal * aNormal);
FragPos = vec3(model * vec4(aPos, 1.0));
}
The car vertex shader differs from the terrain shader for an extra uniform variable, mat3 normal
, used to compute updated normals for the car vertices reflecting the model transformations in world space.
car.frag:
#version 330 core
struct Material {
float shininess;
};
struct Light {
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
out vec4 FragColor;
in vec2 TexCoords;
in vec3 Normal;
in vec3 FragPos;
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;
uniform samplerCube skybox;
uniform vec3 viewPos;
uniform Material material;
uniform Light light;
const float envBias = 1.0f;
const float envShininess = 32.0f;
void main()
{
// Ambient
vec3 ambient = light.ambient * texture(texture_diffuse1, TexCoords).rgb;
// Diffuse
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(-light.direction);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(texture_diffuse1, TexCoords).rgb;
// Specular
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(texture_specular1, TexCoords).rgb;
// Shading
vec3 result = ambient + diffuse + specular;
result = clamp(result, 0.0, 1.0);
// Environment mapping
reflectDir = reflect(-viewDir, norm);
vec3 reflected = texture(skybox, reflectDir).rgb * texture(texture_specular1, TexCoords).rgb;
FragColor = vec4(result + envBias*reflected + pow(reflected, vec3(envShininess)), 1.0);
}
While this shader is identical to the one used for the terrain, it uses an additional samplerCube
uniform variable to apply an environment map onto the model, resulting in a metal-like effect of the car body. Such value is computed taking the normals passed in by the vertex shader, and mapping them to the cube map (similarly to the skybox shader); this color value is then added to the fragment colour, taking two appropriate bias values into account: envBias
defines the weight of the envmap in respect to the model texture map, and envShininess
indicates how shiny the envmap looks when reflected by the car body.