> VAUGHN_CAMPOS_
All Projects
Active2026

VGL

A from-scratch C++/OpenGL 2D+3D rendering engine, a faster, more customizable alternative to pygame, with batched primitive rendering, GPU scalar-field visualization, and a configurable lighting model.

C++OpenGLGLSLComputer GraphicsGLFWCMakeReal-Time Rendering

Overview

VGL is a rendering engine built directly on OpenGL and GLSL, an attempt to recreate what pygame gives you (a simple, immediate-mode draw API for circles, rects, lines, sprites, an event loop) but backed by a compiled language and the GPU instead of pygame's CPU-bound software blitting. It splits into two faces on a shared GUI window/input/context layer: a 3D immediate-mode API (spheres, boxes, cylinders, OBJ meshes, an orbital camera) and a 2D API purpose-built for CFD-style scientific visualization (batched lines/points/polygons plus GPU colormap rendering of scalar fields). It's the rendering layer under a separate rigid-body physics project's six example scenarios, and standalone for 2D flow-field visualization.

Renderer architecturemedia pending — diagram
Shared GUI (window/input/GL context) with a 3D immediate-mode API and a 2D batched/GPU-colormap API on top.

Problem

pygame is a reasonable default for quick 2D visualization, but it's software-rendered and CPU-bound. Every primitive is blitted on the CPU, so frame time scales directly with how much you draw, with no path to the GPU for shaders, instancing, or hardware colormap rendering of a scalar field. For CFD-style visualization work, thousands of particles, dense line fields, per-cell scalar data, that ceiling shows up fast. Building on raw OpenGL instead meant solving the problems pygame solves for you (windowing, input, an immediate-mode-feeling draw API) while keeping every draw call GPU-driven:

  • A 2D primitive API that reads like pygame's (drawLine, drawCircle, addPoint) but batches everything into as few draw calls as possible
  • GPU-resident scalar-field rendering (velocity, pressure, vorticity fields as a texture, colormapped in a fragment shader) instead of reading data back to the CPU to draw it
  • A 3D side (meshes, materials, lighting, OBJ import) for physics-sim visualization, sharing the same window/input/camera plumbing as the 2D API
  • Keeping the whole thing dependency-light (GLFW, GLM, GLEW, stb_image) and buildable as a plain CMake static library

Technical Architecture

2D batching pipelinemedia pending — diagram
LineBatch2D / PointBatch2D accumulate primitives per frame into a growable GPU buffer, drawn in one glDrawArrays/glDrawElementsInstanced call each.
  • GUI. Owns the GLFW window, GL context, keyboard/mouse state, and the 3D immediate-mode draw API (drawSphere, drawBox, drawCylinder, drawOBJMesh, an infinite ray-cast ground plane, a logarithmic depth buffer for scenes spanning huge distances, since orbital mechanics is what it was built for).
  • Renderer2D. A facade over GUI adding an orthographic Camera2D and the 2D API. LineBatch2D and PointBatch2D accumulate every drawLine/addPoint call for a frame into a CPU-side vector, then upload once and issue a single draw call for all points, a shared unit-circle mesh instanced per point via glVertexAttribDivisor, so thousands of primitives cost one or two draw calls.
  • DataTexture + colormap shader. Scalar field data (a CFD pressure or vorticity grid, say) uploads straight into an R32F/RG32F texture; a fragment shader maps it through a colormap (Viridis, Jet, CoolWarm, Grayscale, Fire) entirely on the GPU, with optional contour lines and a solid-mask overlay for obstacles. No per-pixel CPU work.
  • Mesh / OBJMesh. Procedural primitive generators (circle, quad, cube, sphere, cylinder) plus a .obj/.mtl loader that triangulates n-gon faces and groups submeshes by material.
  • Framebuffer. Render-to-texture for post-processing and offscreen passes, generalized to a single reusable class across float and 8-bit-per-channel targets.

Implementation Details

Two performance issues came up while extending the engine, worth noting because neither shows up in a quick correctness check. Both only cost anything once a scene draws a lot per frame.

Uniform lookups on every set* call. Shader::setMat4/setFloat/etc. were calling glGetUniformLocation(id, name), a driver-side string lookup, on every single call, every frame, for every draw. Fixed with a name to GLint cache on Shader, invalidated on program relink, so every uniform name now resolves once per program's lifetime instead of once per frame.

GPU object churn in immediate-mode line drawing. GUI::drawLine/ drawArrow routed through a Mesh::uploadLines() call that destroyed and recreated the VAO/VBO from scratch on every single line drawn. Fine for one debug line, expensive for a frame full of vector-field arrows. Rewritten to match the pattern already used by LineBatch2D/PointBatch2D: allocate once, grow the buffer with headroom only when a frame needs more capacity, and glBufferSubData into the existing allocation otherwise.

Challenges

The most instructive bug wasn't a crash. It was "why does every 3D scene look almost black except directly facing the light." The fragment shader had a single hardcoded ambient = 0.15 constant and one directional light. Any face angled away from that light dropped to 15% brightness with nothing to fill it in, and there was no way to configure it beyond rotating the light direction. Worse, OBJMesh was already parsing Ka/Ks/Ns (ambient/specular/shininess) out of .mtl files into a Material struct. The data was there, the shader just never read it, so every object got the same fixed shininess and specular regardless of what its material actually specified.

The fix had two parts: replace the flat ambient constant with a hemisphere ambient (a sky color and a ground color, blended by how much a surface normal faces up) so backfaces pick up a believable fill instead of going flat dark, and thread the already-parsed Material into the shader as real per-object uniforms, plus a lit flag so any object can opt out of lighting entirely for a flat-shaded look. Getting from "the light direction must be wrong" to "the ambient model itself has no fill term" was the part that actually took investigation. Once diagnosed, the shader and API changes were mechanical.

Results

Lighting before/aftermedia pending — image
Flat single-directional-light ambient (left) versus hemisphere ambient with per-object material reflectivity (right).

The engine now renders both the 3D physics-sim scenes and the 2D CFD-style visualizations through the same window/input layer, with every hot path doing work proportional to changed data per frame rather than to driver calls or redundant lookups. Lighting is configurable per-scene and per-object, with .mtl-authored materials actually affecting the render instead of being parsed and discarded.

Lessons Learned

Batching and buffer-reuse patterns are easy to get right once, in one place, and easy to forget to apply everywhere else that looks similar. LineBatch2D had the right growable-buffer pattern from the start, but a separate, older immediate-mode line path in GUI didn't, because it predated the batch renderers and nobody went back to reconcile them. Configurability also rots quietly: OBJMesh gained Material parsing before the shader had any uniforms to receive it, so the data sat unused for a while with no error or warning, the kind of gap that only surfaces when someone reads both sides of an interface at once instead of just the side they're currently changing.

Future Work

  • Text rendering (FreeType or stb_truetype) for on-screen debug/UI overlays
  • Frustum culling, since every object currently renders regardless of visibility
  • Multiple/shadow-casting lights beyond the current single directional light
  • Migrate GLEW to glad for a more modern, minimal extension loader
  • Split GUI's window/input/render responsibilities into separate classes