A C++ rendering engine with an interactive editor, supporting both real-time rasterization and path tracing.
Scene: Nvidia Amazon Lumberyard Bistro (CC-BY 4.0) rendered with VexEngine
Rasterizer
- Cook-Torrance GGX BRDF (microfacet PBR)
- Normal, roughness, metallic, and emissive texture maps
- Image-based lighting: ambient from average env colour + equirectangular specular reflection
- Directional sun light and point light
- Directional shadow mapping: 4096×4096 depth pass, PCF 3×3, normal-offset + RPDB bias
- Wireframe, depth, normal, UV, albedo, and material-ID debug views
- Mouse picking and selection outline
Path Tracer
- Unidirectional path tracing with iterative bounces and Russian roulette termination
- Next-event estimation (NEE) with MIS for all light types
- Emissive area lights with CDF-weighted triangle sampling
- Directional sun light with configurable angular radius (soft shadows)
- Environment map importance sampling (marginal + conditional CDF)
- Cook-Torrance GGX BRDF with full PBR material support (diffuse, mirror, dielectric)
- VNDF specular sampling (Heitz 2018)
- Volumetric participating media: AABB or infinite volumes, Beer-Lambert transmittance, Henyey-Greenstein phase function, scatter color and anisotropy
- Depth-of-field (thin-lens, aperture and focus distance)
- Anti-aliasing via per-sample jitter, firefly clamping
- Progressive accumulation with automatic reset on camera, scene, or settings change
Post-Processing (all render modes)
- HDR pipeline with exposure, ACES tonemapping, and gamma correction
- Bloom: threshold → separable Gaussian blur → HDR composite
- OIDN denoising (Intel Open Image Denoise, path tracer only)
Implemented four ways:
- CPU: multithreaded, SAH BVH acceleration
- GPU (OpenGL, Compute): compute shader, same BVH uploaded to GPU
- GPU (Vulkan, Compute): compute shader path tracer, software BVH on GPU
- GPU (Vulkan, HW RT): hardware ray tracing (
VK_KHR_ray_tracing_pipeline), BLAS/TLAS acceleration structures, alpha-clipped geometry via any-hit shader
Editor
- ImGui-based UI: scene hierarchy, material editor, light controls, environment maps, volume manager
- Viewport gizmos for translate, rotate, and scale (W / E / R)
- Live switching between all render modes
- Save rendered image to PNG
- Timestamped log output for performance tracking
Measured with the built-in benchmark mode (--bench <config.json>). Every
number below is reproducible with the exact command shown for that row.
Configs live in bench/. Each run writes frames.csv (one row per measured
frame), summary.csv (mean/min/max/p50/p95/p99/stddev per zone), final.png,
and a run.json to results/<name>/, or to the directory given by
--bench-out.
run.json records the run's provenance and health: device (the
driver-reported device string, which is also the GPU name), backend,
width, height, mode, scene, warmupFrames, measureFrames,
maxSamples, cameraKeys, the frame_gpu and frame_cpu statistics, and a
health block. It does not record a git commit. The health block counts
measured frames whose profiler results repeat the previous frame's verbatim
and marks the run stale when too many do, so a run that stopped producing
fresh timing data reports itself instead of being read as a measurement. A
rejected run is unmissable from any entry point: every row of frames.csv and
summary.csv carries status=REJECTED, run.json carries
"health": {"stale": true}, the console prints a rejection banner, and the
process exits with code 2 so CI can fail on it. An accepted run marks the same
column ok.
The configs in bench/ are backend-agnostic, so running the same config on
both backends would write both results to the same results/<name>/ and the
second run would overwrite the first. The commands below pass --bench-out
to keep each backend's output separate.
Scene: ChessSet (76,920 triangles, 33 submeshes), 1920x1080, VSync off.
GPU: NVIDIA GeForce RTX 4070 Ti. mean/p95/p99 are computed over the
measured frames only (warmup frames excluded), per bench/*.json's
warmupFrames/measureFrames.
gpu_raytrace is a different code path on each backend: hardware ray
tracing (VK_KHR_ray_tracing_pipeline) on Vulkan, a software-BVH compute
shader on OpenGL. They are labelled "HW RT" and "compute" below so they are
never read as the same algorithm. compute_raytrace (a second, separate
software path tracer) is Vulkan-only by design and has no bench config in
bench/; its row was measured with an ad hoc copy of
chessset-gpurt-1080p.json with "mode" changed to "compute_raytrace",
reproduced inline below since nothing under bench/ runs it.
| Mode | Backend | Metric | mean (ms) | p95 (ms) | p99 (ms) | Command |
|---|---|---|---|---|---|---|
| Rasterize | Vulkan | frame_gpu | 0.141 | 0.166 | 0.167 | vex_app --bench bench/chessset-raster-1080p.json --bench-out results/vk-raster |
| Rasterize | OpenGL | frame_gpu | 0.161 | 0.164 | 0.165 | vex_app --bench bench/chessset-raster-1080p.json --bench-out results/gl-raster |
| Path trace (HW RT) | Vulkan | frame_gpu | 0.252 | 0.262 | 0.280 | vex_app --bench bench/chessset-gpurt-1080p.json --bench-out results/vk-gpurt |
| Path trace (compute) | Vulkan | frame_gpu | 0.912 | 1.037 | 1.072 | copy of chessset-gpurt-1080p.json with "mode": "compute_raytrace", --bench-out results/vk-computert |
| Path trace (compute) | OpenGL | frame_gpu | 5.247 | 5.734 | 5.946 | vex_app --bench bench/chessset-gpurt-1080p.json --bench-out results/gl-gpurt |
| Path trace (CPU) | Vulkan | frame_cpu | 115.011 | 128.160 | 134.576 | vex_app --bench bench/chessset-cpurt-1080p.json --bench-out results/vk-cpurt |
| Path trace (CPU) | OpenGL | frame_cpu | 105.275 | 115.314 | 121.900 | vex_app --bench bench/chessset-cpurt-1080p.json --bench-out results/gl-cpurt |
Every row passes --bench-out because the four bench/*.json configs are each
shared by a Vulkan and an OpenGL row. Without it both rows of a pair write to
the same default results/<name>/ and the second run silently overwrites the
first, which is how one backend's figure can end up read as the other's.
Notes on the metric column: frame_gpu (GPU-side wall time for the whole
frame) is the meaningful cost for GPU-bound modes. The CPU path tracer is
CPU-bound (CPU PT: trace alone averages ~90 ms), so frame_cpu is quoted
for those two rows instead; their frame_gpu mostly reflects the cost of
uploading the traced image and compositing it (2.3 ms on Vulkan, 14.9 ms on
OpenGL, driven by CPU PT: upload), not ray tracing itself.
The two path tracers are not comparable line by line: the OpenGL row is the
gpu_raytrace compute path and the Vulkan row is the separate
compute_raytrace path, and they differ in shader, BVH layout, and
per-frame dispatch, so the gap between them is not a backend comparison.
chessset-gpurt-converge.json measures convergence (static camera,
maxSamples: 512) rather than throughput and is not part of the table
above; running it produced frame_gpu mean 0.378 ms, p95 0.493 ms, p99
0.555 ms on Vulkan HW RT, consistent with the throughput row once the
accumulator is allowed to build up: RT dispatch cost is unaffected by
maxSamples since each dispatch still traces one sample, but frame_cpu
drops to 0.024 ms mean because the camera never triggers an accumulator
reset.
Per-pass breakdown for the Vulkan rasterizer (chessset-raster-1080p.json
on Vulkan), derived from that run's summary.csv. This is the same data
the Profiler window's "Copy as Markdown" button would export, reformatted
by hand: the button requires clicking inside the live interactive editor,
which cannot be automated headlessly in the environment these numbers were
generated in, so the mean and max columns are the run's statistics rather
than the window's live EMA and running peak. GPU and CPU are kept in
separate columns here for the same reason the Profiler window keeps them
separate: a single column that falls back from one to the other changes
what it measures without saying so.
| Pass | GPU mean (ms) | GPU max (ms) | CPU mean (ms) | CPU max (ms) | % of frame GPU |
|---|---|---|---|---|---|
| Frame | 0.141 | 0.172 | 0.028 | 0.113 | 100 |
| Outline mask | 0.003 | 0.003 | 0.004 | 0.032 | 2 |
| Frame changes | - | - | 0.001 | 0.006 | - |
| Raster: meshes | 0.112 | 0.139 | 0.015 | 0.075 | 79 |
| Composite | 0.019 | 0.022 | 0.003 | 0.009 | 13 |
The child rows do not sum to the frame total (2% + 79% + 13% = 94%, not
100%): zone begin timestamps are recorded at TOP_OF_PIPE and end
timestamps at BOTTOM_OF_PIPE, so a zone's measured GPU time can absorb
or miss time from work still draining ahead of it in the pipeline. Treat
the per-pass numbers as directionally accurate, not as an exact partition
of the frame.
- CMake 3.16+
- Visual Studio 2022 (Windows)
- OpenGL 4.3+ capable GPU (primary backend)
- Vulkan 1.2+ GPU with ray tracing support (for the Vulkan backend)
All dependencies are included as Git submodules. After cloning:
git submodule update --init --recursiveThen configure and build using CMake presets:
# OpenGL (less overhead)
cmake --preset opengl-release
cmake --build build-gl --config Release
# Vulkan (compute path tracer + hardware ray tracing)
cmake --preset vulkan-release
cmake --build build-vk --config ReleaseOr open the root CMakeLists.txt directly in Visual Studio 2022, which will pick up the presets automatically.
The executable is placed in build-gl/bin/ (or build-vk/bin/). It must be run from the repository root so that the assets/ and shaders/ directories are on the working-path.
app/ Editor application and scene renderer
engine/ Backend-agnostic core (mesh, texture, raytracer, BVH, log)
backends/ OpenGL and Vulkan backend implementations
shaders/ GLSL shader source
assets/ Meshes, textures, and HDR environment maps
external/ Third-party dependencies (GLFW, GLM, ImGui, stb, GLAD, ...)
cmake/ CMake helper modules