This project is a solo project that is a part of Engine and Tool Specialization. I decide to use DirectX 12 to work although other options such as OpenGL/GLFW and Vulkan is available.
D3D12 is quite challenging for someone who never systematically learnt computer graphics. However, I enjoy the idea of D3D12 comparing with D3D11 or OGL. D3D12 leaves a lot of options to developers, which means I have to be very aware of resource management, synchronization and performance optimization.
I aim to work on this project for a rather longer time, to understand more of a modern game engine.
Roadmap will be updated on GitHub.
In D3D12, CPU must generate the command lists before GPU can work on them. This means the CPU will almost always be ahead of GPU for modern graphics-intensive games. When GPU is rendering the buffer, CPU is mostly idle; and when buffer swaps, GPU will be idle again. This creates a lot of meaningless waiting on both sides.
Instead of two back buffers, I introduced three back buffers in this project, and they are used in a loop. When GPU is working on rendering of back buffer index 0, CPU will start to process the data of back buffer index 1. However, if GPU has not yet finished back buffer #0 when CPU arrives at back buffer #2, CPU will wait until back buffer 0's finish. Under this mechanism, CPU will hopefully be ahead of GPU by one frame.
A game engine has a number of subsystems. The ideal mechanism is that each subsystem only works with its own datas for avoiding synchoronization problems. Messages and message queues can be used here as a mechanism to exchange information by a small portion of heap memories. Messaging mechanisms is widely used in systems with high hardware concurrency.Â
In this engine, mesh and UI are controlled by different subsystems, and they have individual message queues. They also have individual, ever-lasting thread to process the messages. To save CPU resource, if the message queue is empty upon checking, the thread will put itself to hibernation for a certain period of time.Â
void StartListeningThread()
{
    std::thread m_listenerThread(&MeshManager::Listen, this);
    m_listenerThread.detach();
}
void MeshManager::Listen()
{
while (true)
{
Message msg;
if (m_messageQueue.PopMessage(msg))
{
// Process message
_processMessage(msg);
continue;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // Avoid busy waiting
}
}
void PushMessage(Message* message)
{
LockMutex();
// critical region
m_messageQueue.push(message);
UnlockMutex();
}
bool PopMessage(Message& message)
{
if (!m_messageQueue.empty())
{
// critical region
LockMutex();
message = *(m_messageQueue.front());
delete m_messageQueue.front();
m_messageQueue.pop();
UnlockMutex();
return true;
}
return false;
}
Upon loading meshes, the programwill read from .obj file and save vertices, normals and texture coordination, break the faces into list of triangles, and upload all necessary information to GPU memory. When initial reading is done, the program will write binary datas into another file alongside the original obj file. Next time the file is being read, program checks whether a binary optimization exists. If it does, the program will copy binary datas from file instead of reading.
void Mesh::WriteToBinaryFile(const wchar_t* p_binFilePath)
{
// need to write index list and combined buffer to binary file
// binary filename should be sha1(objfilepath) + ".bin"
std::ofstream binFile(p_binFilePath, std::ios::binary | std::ios::out);
if (!binFile.is_open())
{
// open failed
exit(1);
}
binFile << "vertices: " << combinedBuffer.size() << "\n";
binFile.write(reinterpret_cast<const char*>(combinedBuffer.data()), combinedBuffer.size() * sizeof(VertexPosColor));
binFile << "\nindices: " << m_triangles.size() << "\n";
binFile.write(reinterpret_cast<const char*>(m_triangles.data()), m_triangles.size() * sizeof(uint32_t));
binFile.close();
}
void Mesh::ReadFromBinaryFile(const wchar_t* p_binFilePath)
{
// need to read index list and combined buffer from binary file
// binary filename should be sha1(objfilepath) + ".bin"
std::ifstream binFile(p_binFilePath, std::ios::binary | std::ios::in);
if (!binFile.is_open())
{
// open failed
exit(1);
}
std::string line;
std::getline(binFile, line);
size_t vertexCount = 0;
sscanf_s(line.c_str(), "vertices: %zu", &vertexCount);
combinedBuffer.resize(vertexCount);
binFile.read(reinterpret_cast<char*>(combinedBuffer.data()), vertexCount * sizeof(VertexPosColor));
std::getline(binFile, line); // read the newline
std::getline(binFile, line);
size_t indexCount = 0;
sscanf_s(line.c_str(), "indices: %zu", &indexCount);
m_triangles.resize(indexCount);
binFile.read(reinterpret_cast<char*>(m_triangles.data()), indexCount * sizeof(uint32_t));
binFile.close();
}
Could add a mechanism of checking SHA-256 of obj file, in case the binary file is out-of-date since obj file may be updated.