Skip to content

Latest commit

 

History

55 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

3D Model Viewer

A small real-time renderer written from scratch in modern C++ on top of OpenGL 3.3 Core Profile. It imports polygonal 3D models through Assimp, uploads them to the GPU as indexed vertex buffers, and shades them with a per-fragment Phong lighting model. Window creation, the OpenGL context, and input are handled by GLFW; OpenGL entry points are resolved at runtime by GLAD.

The project grew out of a graphics programming course and is deliberately kept readable: no engine framework, no abstraction layers you have to fight, every draw call traceable from main.cpp to the GLSL source.

Showcase

Showcase

Features

Model import

  • Loads meshes through Assimp, so every format Assimp supports is fair game. The bundled sample models (Backpack, Nanosuit, Planet, Rock) are OBJ.
  • Import post-processing: triangulation, smooth normal generation, UV flipping, and tangent/bitangent calculation.
  • Recursive scene graph traversal - a model with many nodes and sub-meshes is flattened into a list of drawable meshes.
  • Diffuse, specular, and normal maps are read from the material, with a per-model texture cache so a map shared by several meshes is uploaded to the GPU only once.
  • Meshes of the same model may use different materials, so each mesh tells the shader which maps it actually binds. Untextured meshes fall back to a flat material colour.

Rendering

  • Per-fragment Phong shading: ambient, Lambertian diffuse, and specular highlights with a shininess exponent of 32.
  • Normal mapping via the tangent basis imported from Assimp, with Gram-Schmidt re-orthogonalisation of the interpolated tangent.
  • Specular maps modulate the highlight per texel, so leather and metal on the same mesh reflect differently.
  • Normals are transformed by the inverse-transpose of the model matrix, so non-uniform scaling does not break lighting.
  • The projection aspect ratio is read back from the framebuffer, so resizing the window does not distort the image.
  • A single point light orbits the scene on a sine path, which is what gives the showcase its moving highlight.
  • Depth testing, mipmapped textures with trilinear minification, and a wireframe mode toggled at runtime via glPolygonMode.

Camera and interaction

  • Free-flying Euler-angle camera (yaw/pitch) with pitch clamped to +/-89 degrees to avoid gimbal flip.
  • Frame-rate independent movement through delta-time scaling.
  • Scroll-wheel zoom implemented as a field-of-view change, clamped to 1-45 degrees.
  • The model can be rotated around its own pivot independently of the camera.

Engineering

  • Ownership is expressed through the type system rather than by convention. std::unique_ptr with a custom deleter for the GLFW window, move-only RAII wrappers (GlResource.h) for every OpenGL handle, and std::shared_ptr where a texture genuinely is shared between meshes.
  • Teardown order is explicit in Engine::Finalize, because GPU handles have to be deleted while the context that owns them is still alive.
  • Every class has one job and holds only the state that job needs. Material is the shader and the surface colour, the object transform lives in Transform, the light in Light, and everything about turning them into pixels in Renderer.
  • A shared IObject interface gives the subsystems with a real lifecycle (Engine, Viewport) the same Initialize / Draw / Finalize contract. Plain value types such as Transform and Light stay out of it rather than implementing methods they have no use for.
  • Centralised ErrorHandler with a strongly typed MessageType enum, an error-code-to-message lookup table, ANSI-coloured console output, and automatic __FILE__/__LINE__ capture at the call site.
  • DataHandler resolves resource paths relative to the running executable, so the viewer behaves the same when launched from an IDE or from a shell.

Architecture

main.cpp
└── Application            owns the engine, runs Initialize -> Run -> Finalize
    └── Engine             owns all subsystems, drives the frame loop
        ├── Viewport       GLFW window, OpenGL context, GLAD loading, input, buffer swap
        ├── Camera         view and projection matrices, Euler angles, movement and zoom
        ├── Transform      the model's pivot, yaw and pitch, and the model matrix
        ├── Light          position and colour of the point light, and its animated path
        ├── Renderer       depth test, frame and per-object uniforms, issues the draw
        ├── Material       shader program and surface colour
        │   └── Shader     loading from file, compilation, linking, uniform setters
        └── Model          Assimp import, texture loading
            └── Mesh       VAO/VBO/EBO, vertex attribute layout, draw call

One frame, in order:

Viewport::Update()      delta time, poll input state, apply camera and model transforms
Light::Animate()        move the point light along its sine path
Viewport::Draw()        clear colour and depth buffers
Renderer::BeginFrame()  collect what holds for this frame: view, projection, light
Renderer::Draw()        bind the material, upload its uniforms, then Model::Draw()
Model::Draw()           bind textures and VAOs, one glDrawElements per mesh
Viewport::LateDraw()    swap buffers, poll events

Uniforms are written by whoever knows the value. Material sets what describes the surface, Renderer sets what describes the frame (view, projection, camera position, light) and what describes the object (the model matrix), and Mesh binds its own texture units. glUniform* only ever reaches the currently bound shader program, which is why BeginFrame merely collects the frame values and Renderer::Draw uploads them after the material has bound its shader.

The vertex format (MeshData.h) carries position, texture coordinates, normal, tangent, and bitangent, plus four bone indices with weights. All seven attribute slots are bound; the bone data is groundwork for skeletal animation and is not consumed by the current shader.

Controls

Input Action
Mouse movement Look around
W A S D Move the camera forward, left, backward, right
Mouse wheel Zoom in and out
Arrow keys Rotate the model around its own axis
1 Toggle wireframe mode
R Reset the camera position
ESC Close the application

Building

Requirements

  • A C++20 compiler
  • CMake 3.20 or newer
  • GLFW 3 and Assimp development packages
  • A GPU and driver exposing OpenGL 3.3 Core Profile

GLAD, GLM, and stb_image are vendored under Libs/ and need no installation.

Linux

sudo apt install build-essential cmake libglfw3-dev libassimp-dev

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
./x64/Debug/ModelViewer

The executable deliberately lands in x64/<Config>/ to match the Visual Studio output layout, because resource paths are resolved relative to it.

Windows

Open ModelViewer.sln in Visual Studio 2022 and build the x64 configuration. The prebuilt GLFW and Assimp binaries in Libs/x64/ are already wired into the project. assimp-vc143-mtd.dll has to sit next to the executable.

The CMake build works on Windows as well if you would rather not use MSBuild.

IDE notes

Rider and CLion on Linux cannot open the .sln for a C++ project - open CMakeLists.txt as the project instead. The build exports compile_commands.json, so clangd-based editors work out of the box.

Configuration

A few things are still compile-time constants rather than runtime settings:

What Where
Model to display modelPathAndFileName in Engine.h
Shader pair vertexShaderFileName / fragmentShaderFileName in Engine.h
Window size, title, clear colour constants in Engine.h
Light colour, position, and animated path Light.h
Surface colour objectColor in Material.h
Model start rotation and pivot Transform.h
Near and far plane Camera.h

Two shader pairs ship with the project: LightModel* implements the full Phong model and is the default, ModelVertexShader/ModelFragmentShader is a minimal unlit pass that just samples the diffuse map.

Known Limitations

Worth knowing before you file a bug:

  • A single hardcoded point light, no shadowing, no post-processing.
  • Ambient occlusion and roughness maps shipped with some sample models are ignored, because the lighting model has no slot for them.
  • Exactly one model is loaded per run, chosen at compile time.
  • The inverse-transpose normal matrix is computed per vertex in the shader rather than uploaded as a uniform.

Roadmap

  • Immediate-mode UI (ImGui) for wireframe, camera speed, light, and material settings
  • Runtime model loading and multiple simultaneous models
  • More advanced lighting: multiple light types, Blinn-Phong or a PBR pass that can use the roughness and AO maps
  • Skeletal animation using the bone attributes the vertex format already reserves
  • Longer term: build a small puzzle game or simulation on top of the viewer

Project Structure

ModelViewer/              engine sources and GLSL shaders
  Resources/
    Shader/              GLSL vertex and fragment shaders
    Models/              sample models and their textures
Libs/                    vendored headers (GLAD, GLM, stb) and Windows binaries
Media/                   showcase recordings
CMakeLists.txt           cross-platform build
ModelViewer.sln           Visual Studio build

Dependencies

  • CMake - build system
  • GLFW - windowing, context creation, input
  • GLAD - OpenGL function loader
  • Assimp - model import
  • GLM - vector and matrix maths
  • stb_image - texture decoding

Goals

  • Learn OpenGL and real-time 3D rendering from the ground up, without an engine in the way
  • Grow the viewer into a simple (puzzle) game or simulation

Credits

  • Marcus Schaal (SAE Lecture: Graphics and Shader Programming)
  • Joey de Vries for his excellent OpenGL tutorials @ LearnOpenGL

License

MIT - see LICENSE.

About

A real-time OpenGL renderer written from scratch in modern C++: Assimp model import, Phong shading, normal mapping.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Contributors

Languages