OpenGL Boids Project
End Result:
You can download the application from itch.io(Password: 762): Link
In this article we will be going over how I created a Boids simulation using OpenGL and ImGUI, before starting make sure that you have GLFW, GLEW, GLM and ImGUI setup and working inside your project.
First we create a window and setup render loop the steps are below:
- GLFW initialized, Windows hints, Window creation, setup current context to this window.
- GLEW initialized, Get frame buffer size and set callback.
- Create viewport, optionally set GLEW experimental to true.
- Create render loop, poll events, clear color and buffer, swap buffer.
- After render loop terminate and shutdown.
Architecture:
This project was made in such a way that resembles a proper game engine with out Entity class as game object for boids. The Entity class will have a mesh component which will take care of all the rendering.
Entity Class:
class Entity
{
public:
Entity();
virtual ~Entity();
virtual void Tick();
inline MeshComponent* GetMeshComp();
// If we want to render and run tick or not.
bool isEnabled;
protected:
std::unique_ptr<MeshComponent> meshComp;
glm::vec2 velocity;
};
Mesh Component:
class MeshComponent
{
public:
MeshComponent();
~MeshComponet();
// This get's called in Entity's Tick function
void Draw();
void SetShader(std::shared_ptr<Shader> shader);
inline glm::vec2 GetPostion();
inline void SetPosition(const glm::vec2& pos);
inline void SetRotation(const float rot);
/** Set up the VAO, VBO and pointer attributes */
void SetupMesh();
protected:
GLuint VAO, VBO;
std::shared_ptr<Shader> shader;
glm::vec2 position;
float rotation;
float scale;
};
Let’s Go into the SetupMesh
function:
// VAO
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
// VBO
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(boidVerts), boidVerts, GL_STATIC_DRAW);
// Attribute pointer (Location)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);
// Reset
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
Now in the Draw
function:
const GLuint shaderPrgId = shaderPtr.get()->shaderPrgId;
glUseProgram(shaderPrgId);
// Pass location, rotation and scale to vertex shader
glUniform3f(shaderPtr.get()->uniformPosRotLoc, position.x, position.y, rotation);
glUniform1f(shaderPtr.get()->uniformScaleLoc, scale);
// create orthographic projection
glm::mat4 projMat(1.0f);
projMat = glm::ortho(-1.0f, 1.0f, -1.0f, 1.0f, -1.0f, 1.0f);
glUniformMatrix4fv(shaderPtr.get()->uniformProjLoc, 1, GL_FALSE, glm::value_ptr(projMat));
// Send color to shader
glUniform3f(shaderPtr.get()->uniformColorLoc, color.r, color.g, color.b);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glUseProgram(0);
Shader:
I decided to just make a header only shader file which contains our struct.
GLuint shaderPrgId;
GLuint uniformProjLoc;
GLuint uniformViewLoc;
GLuint uniformColorLoc;
GLuint uniformPosRotLoc;
GLuint uniformScaleLoc;
void SetupShader()
{
shaderPrgId = glCreateProgram();
const GLuint vertShaderId = CompileShader(vertShader, GL_VERTEX_SHADER);
const GLuint fragShaderId = CompileShader(fragShader, GL_FRAGMENT_SHADER);
// Link program
glLinkProgram(shaderPrgId);
int linkStatus;
glGetProgramiv(shaderPrgId, GL_LINK_STATUS, &linkStatus);
if (linkStatus != GL_TRUE)
{
std::cout << "SHADER::PROGRAM::LINK_FAILED" << std::endl;
int logLength;
glGetProgramiv(shaderPrgId, GL_INFO_LOG_LENGTH, &logLength);
if (logLength > 0)
{
char log[256];
glGetProgramInfoLog(shaderPrgId, 256, NULL, log);
std::cout << "SHADER::PROGRAM::LOG::" << log << std::endl;
}
}
// Validate program
glValidateProgram(shaderPrgId);
int validateStatus;
glGetProgramiv(shaderPrgId, GL_VALIDATE_STATUS, &validateStatus);
if (validateStatus != GL_TRUE)
{
std::cout << "SHADER::PROGRAM::VALIDATE_FAILED" << std::endl;
int logLength;
glGetProgramiv(shaderPrgId, GL_INFO_LOG_LENGTH, &logLength);
if (logLength > 0)
{
char log[256];
glGetProgramInfoLog(shaderPrgId, 256, NULL, log);
std::cout << "SHADER::PROGRAM::LOG::" << log << std::endl;
}
}
// Get uniform locations
//uniformModelLoc = glGetUniformLocation(shaderPrgId, "modelMat");
uniformProjLoc = glGetUniformLocation(shaderPrgId, "projMat");
uniformColorLoc = glGetUniformLocation(shaderPrgId, "baseColor");
uniformViewLoc = glGetUniformLocation(shaderPrgId, "viewMat");
uniformPosRotLoc = glGetUniformLocation(shaderPrgId, "uPosRot");
uniformScaleLoc = glGetUniformLocation(shaderPrgId, "uScale");
// Delete shaders
glDeleteShader(vertShaderId);
glDeleteShader(fragShaderId);
}
GLuint CompileShader(const char* shaderSource, GLenum shaderType)
{
GLuint shaderId = glCreateShader(shaderType);
glShaderSource(shaderId, 1, &shaderSource, NULL);
glCompileShader(shaderId);
int compileStatus;
glGetShaderiv(shaderId, GL_COMPILE_STATUS, &compileStatus);
if (compileStatus != GL_TRUE)
{
std::cout << "SHADER::COMPILE::COMPILE_FAILED" << std::endl;
int logLength;
glGetShaderiv(shaderId, GL_INFO_LOG_LENGTH, &logLength);
if (logLength > 0)
{
char log[256];
glGetShaderInfoLog(shaderId, 256, NULL, log);
std::cout << "SHADER::COMPILE::LOG" << log << std::endl;
}
}
glAttachShader(shaderPrgId, shaderId);
return shaderId;
}
Full code of Setup Shader function that gets called in constructor of shader struct.
Speaking of Shaders let’s go to our Data (BoidData.h
) file where we have stored our vertices array and shader strings:
Shader Source:
inline const float boidVerts[] = {
-0.7f, -1.0f, 0.0f,
0.7f, -1.0f, 0.0f,
0.0f, 1.0f, 0.0f
};
Shader Sources:
inline const char* vertShader = "#version 330 core\n\
layout (location = 0) in vec3 aPos;\n\
uniform vec3 uPosRot;\n\
uniform float uScale;\n\
uniform mat4 projMat;\n\
uniform mat4 viewMat;\n\
void main()\n\
{\n\
float cosTheta = cos(uPosRot.z);\n\
float sinTheta = sin(uPosRot.z);\n\
mat2 rotation = mat2(cosTheta, -sinTheta, sinTheta, cosTheta);\n\
vec2 scaledPosition = aPos.xy * uScale;\n\
vec2 rotatedPosition = rotation * scaledPosition;\n\
vec2 finalPosition = rotatedPosition + uPosRot.xy;\n\
gl_Position = projMat * viewMat * vec4(finalPosition, aPos.z, 1.0f);\n\
}\n";
inline const char* fragShader = "#version 330 core\n\
out vec4 fragColor;\
uniform vec3 baseColor;\
void main()\
{\
fragColor = vec4(baseColor, 1.0f);\
}\
";
Notice that instead of using Matrix 4x4 we are using a vector 3 for position and rotation than we calculate the angle ourselves and store it in matrix 2x2.
For scale we are using a single float.
Camera:
Camera is important we will be using to pan around the level and our camera will be set to orthographic mode.
Camera(glm::vec3 pos, GLFWwindow& window);
void Tick(GLfloat deltaTime);
void SetUniformViewMatrix(const Shader& shader);
static void KeyCallback(GLFWwindow* window, int key, int scancode, int action, int mods);
// Member variables
glm::vec3 cameraPos;
GLfloat moveSpeed;
glm::mat4 viewMat;
GLFWwindow& mainWindow;
Register your window with Camera Object inside the constructor:
// Store the pointer to camera class in window
glfwSetWindowUserPointer(&mainWindow, this);
glfwSetKeyCallback(&mainWindow, KeyCallback);
In the Tick function simply check for Move input and calculate the look at matrix:
if (glfwGetKey(&mainWindow, GLFW_KEY_W) == GLFW_PRESS)
{
cameraPos.y += moveSpeed * deltaTime;
}
if (glfwGetKey(&mainWindow, GLFW_KEY_S) == GLFW_PRESS)
{
cameraPos.y -= moveSpeed * deltaTime;
}
if (glfwGetKey(&mainWindow, GLFW_KEY_A) == GLFW_PRESS)
{
cameraPos.x -= moveSpeed * deltaTime;
}
if (glfwGetKey(&mainWindow, GLFW_KEY_D) == GLFW_PRESS)
{
cameraPos.x += moveSpeed * deltaTime;
}
viewMat = glm::lookAt(cameraPos, cameraPos + glm::vec3(0.0f, 0.0f, -1.0f), glm::vec3(0.0f, 1.0f, 0.0f));
View Matrix function:
void SetUniformViewMatrix(const Shader& shader)
{
glUseProgram(shader.shaderPrgId);
glUniformMatrix4fv(shader.uniformViewLoc, 1, GL_FALSE, glm::value_ptr(viewMat));
glUseProgram(0);
}
Key callback function:
static void KeyCallback(GLFWwindow* window, int key, int scancode, int action, int mods)
{
// Cast back to camera class from main window user pointer
Camera* camera = static_cast<Camera*>(glfwGetWindowUserPointer(window));
if (!camera) { std::cout << "CAMERA::KEY_CALLBACK::INVALID_WINDOW_POINTER" << std::endl; }
if (action == GLFW_PRESS || action == GLFW_RELEASE)
bool isPressed = (action == GLFW_PRESS);
switch (key)
{
case GLFW_KEY_ESCAPE:
glfwSetWindowShouldClose(window, GL_TRUE);
break;
case GLFW_KEY_F:
camera->cameraPos = glm::vec3(0.0f);
break;
default:
break;
}
}
Boid Manager:
In this class we take reference of our boids vector which was declared in main file and assign it on construction, after that we have member variables for adjustment of each rule.
class BoidManager
{
public:
BoidManager(std::vector<Entity>& boids);
void Tick(GLfloat deltaTime);
glm::vec2 CalSeparation(Entity* e1, Entity* e2, const float distance, int& neighborCount);
glm::vec2 CalAlignment(Entity* boid, const float distance, int& neighborCount);
glm::vec2 CalCohesion(Entity* boid, const float distance, int& neighborCount);
glm::vec2 CalReturn(Entity* boid);
GLfloat maxDistanceSeparation;
GLfloat maxCohesionDistance;
GLfloat maxAlignmentDistance;
GLfloat returnDistance;
GLfloat separateFactor;
GLfloat alignmentFactor;
GLfloat cohesionFactor;
GLfloat returnFactor;
protected:
std::vector<Entity>& boids;
GLfloat maxSpeed;
};
In the source file:
BoidManager::BoidManager(std::vector<Entity>& boids) : boids{ boids }
{ }
void BoidManager::Tick(GLfloat deltaTime)
{
for (std::vector<Entity>::iterator currItr = boids.begin(); currItr != boids.end(); ++currItr)
{
if (currItr->isEnabled == false) { continue; }
glm::vec2 separationDir(0.0f);
glm::vec2 alignmentDir(0.0f);
glm::vec2 cohesionDir(0.0f);
glm::vec2 returnDir(0.0f);
int neighborCountSepration = 0;
int neighborCountAlignment = 0;
int neighborCountCohesion = 0;
for (std::vector<Entity>::iterator itr = boids.begin(); itr != boids.end(); ++itr)
{
if (currItr == itr || itr->isEnabled == false) { continue; }
const float distance = glm::distance(currItr->GetPosition(), itr->GetPosition());
separationDir -= CalSeparation(&*currItr, &*itr, distance, neighborCountSepration);
alignmentDir += CalAlignment(&*itr, distance, neighborCountAlignment);
cohesionDir += CalCohesion(&*itr, distance, neighborCountCohesion);
}
// Calculate return direction
returnDir = CalReturn(&*currItr);
// Normalize separation
separationDir /= neighborCountSepration;
separationDir = GetSafeNormal(separationDir);
// Normalize alignment
alignmentDir /= neighborCountAlignment;
alignmentDir = GetSafeNormal(alignmentDir);
// Normalize cohesion
cohesionDir /= neighborCountCohesion;
cohesionDir = GetSafeNormal(cohesionDir);
// Get direction towards center of mass
cohesionDir = (cohesionDir - currItr->GetPosition());
cohesionDir = GetSafeNormal(cohesionDir);
glm::vec2 finalDirection =
(separationDir * separateFactor) +
(alignmentDir * alignmentFactor) +
(cohesionDir * cohesionFactor) +
(returnDir * returnFactor);
finalDirection = GetSafeNormal(finalDirection);
// Update the velocity by blending the old velocity with the new direction
glm::vec2 finalVelocity = currItr->GetVelocity() + finalDirection * deltaTime;
finalVelocity = GetClampedToMaxSize(finalVelocity, maxSpeed); // Ensure velocity does not exceed maxSpeed
currItr->SetVelocity(finalVelocity); // Update velocity
// Update position based on clamped velocity
currItr->SetPosition(currItr->GetPosition() + finalVelocity * deltaTime);
}
}
Let’s go over each rule:
Separation:
glm::vec2 BoidManager::CalSeparation(Entity* e1, Entity* e2, const float distance, int& neighborCount)
{
if (distance > maxDistanceSeparation) { return glm::vec2(0.0f); }
neighborCount++;
return (e2->GetPosition() - e1->GetPosition());
}
Cohesion:
glm::vec2 BoidManager::CalCohesion(Entity* boid, const float distance, int& neighborCount)
{
if (distance > maxCohesionDistance) { return glm::vec2(0.0f); }
neighborCount++;
return boid->GetPosition();
}
Alignment:
glm::vec2 BoidManager::CalAlignment(Entity* boid, const float distance, int& neighborCount)
{
if (distance > maxAlignmentDistance) { return glm::vec2(0.0f); }
neighborCount++;
return boid->GetVelocity();
}
Converge:
glm::vec2 BoidManager::CalReturn(Entity* boid)
{
const glm::vec2 center(0.0f);
if (GetDistanceSquared(boid->GetPosition(), center) > (returnDistance * 2.0f))
{
glm::vec2 direction = center - boid->GetPosition();
direction = GetSafeNormal(direction);
return direction;
}
return boid->GetVelocity();
}
That is all!
Helper Functions:
We have created some helper functions to help us in the calculations and optimization:
inline glm::vec2 GetClampedToMaxSize(const glm::vec2& vec, float maxSize)
{
return glm::normalize(vec) * maxSize;;
}
inline glm::vec2 GetSafeNormal(const glm::vec2& vec, float tolerance = 1e-6f)
{
float length = glm::length(vec);
if (length > tolerance)
{
return glm::normalize(vec);
}
return glm::vec2(0.0f);
}
inline float GetDistanceSquared(const glm::vec2& v1, const glm::vec2& v2)
{
const glm::vec2 diff = v1 - v2;
return glm::dot(diff, diff);
}
Dot Product and Distance: The dot product of a vector with itself gives the square of its magnitude. Therefore, by taking the dot product of the difference vector with itself, you obtain the squared distance:
User Interface:
Same as all of my other UI using ImGUI was written, a separate class with different functions, in it’s constructor the class takes references to several different objects like main window, boids manager.
UserInterface(GLFWwindow& window, std::vector<Entity>& boids, BoidManager& boidManager);
void NewFrame();
void Tick();
void Draw();
void Shutdown();
protected:
void AddFpsCounter();
void AddBoidsCount();
void AddBoidControls();
void AddCameraResetHint();
private:
GLFWwindow& mainWindow;
ImVec2 controlWidgetSize;
bool firstFrame;
int frameCounter;
double fps;
double perviousTime;
double currentTime;
std::vector<Entity>& boids;
BoidManager& boidManager;
Source File:
// In constructor
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
ImGui::StyleColorsDark();
ImGui_ImplGlfw_InitForOpenGL(&mainWindow, true);
ImGui_ImplOpenGL3_Init("#version 330");
New Frame:
void UserInterface::NewFrame()
{
ImGui_ImplGlfw_NewFrame();
ImGui_ImplOpenGL3_NewFrame();
ImGui::NewFrame();
if (firstFrame)
{
ImGui::SetNextWindowSize(controlWidgetSize);
ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f));
firstFrame = false;
}
}
Tick:
void UserInterface::Tick()
{
ImGui::Begin("Controls", NULL);
controlWidgetSize = ImGui::GetContentRegionAvail();
AddFpsCounter();
AddBoidsCount();
AddBoidControls();
AddCameraResetHint();
ImGui::End();
}
Draw:
void UserInterface::Draw()
{
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
}
Shutdown:
void UserInterface::Shutdown()
{
ImGui_ImplGlfw_Shutdown();
ImGui_ImplOpenGL3_Shutdown();
ImGui::DestroyContext();
}
FPS counter:
void UserInterface::AddFpsCounter()
{
currentTime = glfwGetTime();
frameCounter++;
// difference
double elapsedTime = currentTime - perviousTime;
if (elapsedTime >= 1.0)
{
fps = frameCounter / elapsedTime;
frameCounter = 0;
perviousTime = currentTime;
}
ImGui::Text("FPS: %.1f", fps);
}
Add Boids using slider:
void UserInterface::AddBoidsCount()
{
ImGui::AlignTextToFramePadding();
ImGui::Text("No Of Boids:");
ImGui::SameLine(170);
ImGui::SliderInt("##boids", &targetActiveBoids, 0, 200);
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
{
ImGui::SetTooltip("Add the number of boids by moving the slider.");
}
if (targetActiveBoids != currentActiveBoids)
{
if (targetActiveBoids > currentActiveBoids)
{
// loop over the boids and enable from last active boid to target index
for (int i = currentActiveBoids; i < targetActiveBoids; i++)
{
boids.at(i).isEnabled = true;
}
}
else
{
for (int i = (currentActiveBoids - 1); i >= targetActiveBoids; i--)
{
boids.at(i).isEnabled = false;
}
}
currentActiveBoids = targetActiveBoids;
}
}
Tips:
Use ImGui::Spacing();
to next line.
ImGui::AlignTextToFramePadding();
ImGui::Text("Max Cohesion Distance:");
ImGui::SameLine(labelWidth);
ImGui::SliderFloat("##cohesionDist", &boidManager.maxCohesionDistance, 0.1f, 1.0f);
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
{
ImGui::SetTooltip("Controls the radius in which the boid will try to find the center of mass of surrounding flock.");
}
To keep the next element in the same line use the function SameLine(width)
.
If you use ##
with text that will tell ImGUI to ignore the text, It is important that all of your elements have different text else the binding with member variable might go wrong.
You can use the IsItemHovered
function along with SetToolTip
to enable hints.
Always use inline specifier along with header only files to make them be able to be included into different files for this you need to have C++ version 17.