Aluno: André Leandro (a22509641) Disciplina: Computação Gráfica (Videojogos) - Universidade Lusófona, professor Diogo de Andrade.
- Introdução
- Organização do Projeto
- Tecnologias e Bibliotecas Utilizadas
- Implementação Técnica
- Compilação e Execução
- Melhorias Futuras
- Bibliografia
Este projeto é uma entrega final para a disciplina de Computação Gráfica que consiste numa engine render 3D ECS, usando OpenGL e que recebe inputs para movimentar no cenário.
Dei o nome de MiEngine(Vindo de MiGames, um nome que dei ao meu grupo solo de desenvolvimento de jogos) e que implementa funcionalidades básicas como:
- Render de modelos 3D no formato OBJ.
- Sistema de texturas nos modelos.
- Câmara em primeira pessoa usando teclado e rato.
- Iluminação básica através de shaders, com suporte para costumização do shaders e suporte para múltiplos shaders.
- Arquitetura Entity Component System (ECS).
O projeto segue uma arquitetura Entity Component System (ECS), que separa dados (Components) de comportamento (Systems). Desta forma facilita a manutenção e scability do projeto.
- C++20
| Biblioteca | Versão | Propósito | Link |
|---|---|---|---|
| OpenGL | 3.3 Core | API gráfica para renderização 3D | opengl.org |
| GLFW | 3.x | Gestão de janelas e input | glfw.org |
| GLAD | - | Carregador de funções OpenGL | glad.dav1d.de |
| GLM | - | Matemática para gráficos (vetores, matrizes) | glm.g-truc.net |
| stb_image | - | Carregamento de imagens | GitHub |
- CMake 3.12+ (Minimum version)
O sistema de renderização utiliza OpenGL 3.3 Core Profile com as seguintes técnicas:
Vertex Array Objects (VAO) e Vertex Buffer Objects (VBO):
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);Vértices (32 bytes por vértice):
- Posição: 3 floats (X, Y, Z) - offset 0 (12 bytes)
- Coordenadas de textura: 2 floats (U, V) - offset 12 bytes (8 bytes)
- Normais: 3 floats (nX, nY, nZ) - offset 20 bytes (12 bytes)
Foi adicionado um depth testing para garantir que desenha os objetos com as distânicas corretas(objetos mais perto são desenhados por cima dos mais longe) e face culling para remover faces não visíveis(traseiras) para melhorar performance retirando coisas que não são necessárias.
Implementado na app.cpp:
// Define a cor de fundo do ecrã
glClearColor(0.25f, 0.5f, 0.75f, 1.0f);
// Define a região de renderização para o tamanho real do ecrã
int framebufferWidth, framebufferHeight;
glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight);
glViewport(0, 0, framebufferWidth, framebufferHeight);
// Ativar depth testing para objetos 3D renderizarem corretamente
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
// Ativar face culling para melhorar performance
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);O motor suporta o formato Wavefront OBJ, um formato de ficheiro de modelo 3D amplamente utilizado.
Um ficheiro OBJ contém:
v: Posições dos vérticesvt: Coordenadas de texturavn: Vetores normaisf: Faces (triângulos ou polígonos)
Primeiro (factory.cpp):
Contar elementos para reservar memória.
// Primeira passagem: contar elementos
size_t vertexCount = 0;
size_t texcoordCount = 0;
size_t normalCount = 0;
size_t triangleCount = 0;
while (std::getline(file, line)) {
words = split(line, " ");
if (!words[0].compare("v")) ++vertexCount;
else if (!words[0].compare("vt")) ++texcoordCount;
else if (!words[0].compare("vn")) ++normalCount;
else if (!words[0].compare("f")) triangleCount += words.size() - 3;
}Segundo (factory.cpp):
Carregar dados nos vetores.
// Reservar memória para eficiência
positions.reserve(vertexCount);
textureCoordinates.reserve(texcoordCount);
normals.reserve(normalCount);
vertices.reserve(triangleCount * 3 * 8);
// Segunda passagem: carregar dados
while (std::getline(file, line)) {
words = split(line, " ");
if (!words[0].compare("v")) {
positions.push_back(read_vec3(words, preTransform, 1.0f));
}
else if (!words[0].compare("vt")) {
textureCoordinates.push_back(read_vec2(words));
}
else if (!words[0].compare("vn")) {
normals.push_back(read_vec3(words, preTransform, 0.0f));
}
else if (!words[0].compare("f")) {
read_face(words, positions, textureCoordinates, normals, vertices);
}
}[Explicar como o código lida com quads e polígonos, convertendo-os em triângulos - ficheiros OBJ podem ter faces com qualquer número de vértices, mas OpenGL só desenha triângulos. A solução é usar fan triangulation]
Implementado na função read_face() no ficheiro factory.cpp:
void Factory::read_face(std::vector<std::string> words,
std::vector<glm::vec3>& positions,
std::vector<glm::vec2>& textureCoordinates,
std::vector<glm::vec3>& normals,
std::vector<float>& vertices) {
size_t triangleCount = words.size() - 3;
// Fan triangulation: primeiro vértice é partilhado por todos os triângulos
for (size_t i = 0; i < triangleCount; ++i) {
read_corner(words[1], positions, textureCoordinates, normals, vertices);
read_corner(words[2 + i], positions, textureCoordinates, normals, vertices);
read_corner(words[3 + i], positions, textureCoordinates, normals, vertices);
}
}Cada vértice é processado pela função read_corner():
void Factory::read_corner(std::string description,
std::vector<glm::vec3>& positions,
std::vector<glm::vec2>& textureCoordinates,
std::vector<glm::vec3>& normals,
std::vector<float>& vertices) {
std::vector<std::string> indices = split(description, "/");
// Posição do vértice (x, y, z) - índices no ficheiro OBJ começam em 1
glm::vec3 position = positions[std::stol(indices[0]) - 1];
vertices.push_back(position[0]);
vertices.push_back(position[1]);
vertices.push_back(position[2]);
// Coordenadas de textura (u, v)
glm::vec2 textureCoordinate = textureCoordinates[std::stol(indices[1]) - 1];
vertices.push_back(textureCoordinate[0]);
vertices.push_back(textureCoordinate[1]);
// Vetor normal (nx, ny, nz) - usado para cálculo de iluminação
glm::vec3 normal = normals[std::stol(indices[2]) - 1];
vertices.push_back(normal[0]);
vertices.push_back(normal[1]);
vertices.push_back(normal[2]);
}Os modelos 3D por norma podem acabar por ser exportados com orientações diferentes, então fazemos esta transformação para alinha-los com o sistema de coordenadas da nossa engine.
Exemplo para o modelo "old_pc.obj":
glm::mat4 preTransform = glm::mat4(1.0f);
preTransform = glm::rotate(preTransform, glm::radians(90.0f), { 1.0f, 0.0f, 0.0f });
preTransform = glm::rotate(preTransform, glm::radians(90.0f), { 0.0f, 1.0f, 0.0f });Na nossa engine o eixo de altura é o Z em vez de Y e o old_pc foi criado com orientação de altura no Y então precisamos o rodar, bem como maior parte dos modelos feitos para a Unity engine que usa o Y como altura.
O projeto também inclui uma função genérica make_any_model() que permite carregar qualquer modelo OBJ com a sua textura correspondente(Mas por default coloquei para rodar o X e Y por 90 graus mas no futuro ter opção de usar ou não conforme a forma que o modelo foi exportado):
void Factory::make_any_model(std::string objPath, std::string imgPath,
glm::vec3 position, glm::vec3 eulers) {
// Aplica transformação padrão e carrega modelo + textura
glm::mat4 preTransform = glm::mat4(1.0f);
preTransform = glm::rotate(preTransform, glm::radians(90.0f), {1.0f, 0.0f, 0.0f});
preTransform = glm::rotate(preTransform, glm::radians(90.0f), {0.0f, 1.0f, 0.0f});
const std::string fileObjPath = "../models/" + objPath;
const std::string fileImgPath = "../img/" + imgPath;
RenderComponent render = make_obj_mesh(fileObjPath.c_str(), preTransform);
render.material = make_texture(fileImgPath.c_str());
}Neste projeto temos uma câmara em primeira pessoa com controlo WASD para movimento e rato para rotação. Por default no código coloquei 1000.0f de zFar(distância máxima de renderização), bem como o fov a 45.0f, mas que pode ser alterado no ficheiro app.cpp:
// Variáveis criadas aqui só para explicar o que cada coisa é
const float fov = 45.0f;
const float zNear = 0.1f;
const float zFar = 1000.0f;
// 640f e 480f é o aspecto da perspectiva.
glm::mat4 projection = glm::perspective(fov, 640.0f / 480.0f, zNear, zFar);A câmara utiliza três vetores ortogonais:
- forwards: direção para onde a câmara aponta
- right: direção para a direita da câmara
- up: direção para cima da câmara
float azimuthAngle = glm::radians(cameraEulers.z); // Yaw (rotação horizontal)
float elevationAngle = glm::radians(cameraEulers.y); // Pitch (rotação vertical)
forwards = {
glm::cos(azimuthAngle) * glm::cos(elevationAngle),
glm::sin(azimuthAngle) * glm::cos(elevationAngle),
glm::sin(elevationAngle)
};glm::vec3 positionDelta = {0.0f, 0.0f, 0.0f};
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
positionDelta.x += 1.0f; // Mover para a frente
}
// Normalizar o vetor de movimento, evitando assim velocidades maiores na diagonal
if (glm::length(positionDelta) > 0.1f) {
positionDelta = glm::normalize(positionDelta);
cameraPosition += 0.1f * positionDelta.x * forwards;
cameraPosition += 0.1f * positionDelta.y * right;
}Como a visualização do cenário é em primeira pessoa, temos de conseguir rodar a câmera com o rato, bem como manter o rato na mesma posição para não "sair da window".
Vamos pegar a posição atual nesse frame, e setar. depois vamos descontar na posição que detetámos os valores default e assim vamos ter a diferença que existiu no movimento do rato.
// Centrar o rato e calcular delta
double mouseX, mouseY;
glfwGetCursorPos(window, &mouseX, &mouseY);
glfwSetCursorPos(window, 320.0, 240.0);
// Aplicar diferença
glm::vec3 eulersDelta = {0.0f, 0.0f, 0.0f};
eulersDelta.z = -0.1f * static_cast<float>(mouseX - 320.0);
eulersDelta.y = -0.1f * static_cast<float>(mouseY - 240.0);O projeto utiliza GLSL 3.30 (OpenGL Shading Language) para programação de shaders.
Vertex Shader (vertex.txt)
Fragment Shader (fragment.txt)
Implementado em shader.cpp:
- Ler ficheiros de texto
- Compilar módulos individuais
- Criar programa e anexar shaders
- Linkar programa
Entity Component System é um padrão arquitetural usado em desenvolvimento de jogos.
Componentes:
- Transform (
transform_component.h); - Physics (
physics_component.h); - Render (
render_component.h); - Camera (
camera_component.h).
Sistemas:
- Motion (
motion_system.cpp); - Camera (
camera_system.cpp); - Render (
render_system.cpp).
stbi_set_flip_vertically_on_load(true);
unsigned char* data = stbi_load(filename, &width, &height, &channels, STBI_rgb_alpha);- Compilador C++20: MinGW, GCC 10+, Clang 11+, ou MSVC 2019+
- CMake 3.12 ou superior
- OpenGL 3.3 ou superior
- Sistema operativo: Windows
# Windows
.\build\Release\miengine.exe(O projeto começou por se chamar hello_window mas alterei no final para miengine, portanto pode haver nomes/textos ainda com hello_window)
- W/A/S/D: Movimento da câmara
- Rato: Rotação da câmara
- ESC: Sair da aplicação
Problema: Ficheiros OBJ podem ter faces com 3, 4 ou mais vértices, mas o OpenGL só desenha triângulos.
Solução: Implementei fan triangulation para converter todos os polígonos em triângulos.
- Resize Window dar update do width & height.
- Implementar objetos com movimento.
- Criar canvas/UI/HUD overlap on screen.
- Melhorar a luz criando shadows.
- Light sources.
-
OpenGL Documentation
-
GLFW Documentation
-
GLM Documentation
-
C++ Documentation
GetIntoGameDevY - OpenGL with C++ (Youtube)
-
stb Libraries (GitHub)
-
GLAD Generator
Nota: Este projeto foi desenvolvido para fins académicos na disciplina de Computação Gráfica do curso Videojogos na Universidade Lusófona de Lisboa sob orientação do Professor Diogo de Andrade.
