A 3D ray-traced rendering engine in C++, with a rigid-body physics simulation running on real gravity and air-resistance values. Built from scratch: no third-party rendering or physics library, just the math.
The renderer does Blinn-Phong shading with cast shadows and recursive reflections, anti-aliases with jittered supersampling, and accelerates intersection with a bounding volume hierarchy across a thread pool.
- Ray tracer (
src/rendering):RayTracerfires camera rays through each pixel, intersects theScene, and shades hits with ambient + Lambertian diffuse + Blinn-Phong specular per light, casting a shadow ray to each. Reflective materials recurse up tomaxDepthbounces.samplesPerPixel > 1enables jittered supersampling for anti-aliasing. - Dielectrics (
Material::dielectric): materials with atransparencyabove 0 refract by Snell's law, and the split between the reflected and transmitted ray comes from Schlick's approximation of the Fresnel term, so glass is a window head-on and a mirror at a glancing angle. Past the critical angle there is no transmitted ray at all and the surface reflects totally, which is the bright rim along the bottom edge of a glass sphere. - Acceleration (
src/rendering/BVH.h): a bounding volume hierarchy over the scene's bounded objects, stored as a flat node array.BVH::Heuristicselects how it splits:SAH(the default) bins primitives by centroid on each axis and sweeps for the split that minimizes expected traversal cost, falling back to a median split if no split beats leaving the range as a leaf;Medianalways splits at the middle of the longest axis of the centroid bounds. Objects of infinite extent (an unboundedPlane) have no finite box, soScenekeeps them on a short linear list and tests both. - Threading: rows are handed out through an atomic cursor rather than split statically, because a row through the middle of the scene casts far more shadow and reflection rays than one through empty sky.
- Physics (
src/physics):RigidBodyintegrates position and velocity under gravity and drag using semi-implicit Euler stepping.PhysicsEngineadvances every body and resolves collisions: a ground plane plus impulse-based sphere-sphere response with positional correction split by inverse mass.Playerlayers movement, jumping, and gravity on top of that. - Control loop (
src/physics/InputState.h,InputDriver.h):InputStateis a plain struct of movement axes, a jump flag, and look deltas, with no dependency on any windowing library.Player::applyInput()turns oneInputStateplus a timestep into a real per-frame control loop: jump, move, integrate gravity, land.ScriptedInputDriverfeeds it a recorded sequence of frames headlessly;RaylibInputDriver(guarded behind#ifdef PHOTON_USE_RAYLIB) shows the real-keyboard wiring but is not compiled by any target here, since no raylib library is installed. See Known limitations for exactly what that does and doesn't prove. - Objects (
src/objects):Sphere,Block,Plane,Slope, andParachute, each implementing ray intersection and a bounding box against the sharedObjectinterface. - Math (
src/math):Vector3D,Ray, andAABB. - Four demo scenes:
main.cpp(shading, shadows, reflections, 4x AA),main_blocks.cppandmain_parachutes.cpp(both animated, with collisions), andmain_control_demo.cpp(drivesPlayerthrough a scriptedInputStatesequence — walk, jump, land — rendering one frame per input frame, to prove the control loop is real and not dead code).
cmake -B build
cmake --build buildThis produces six targets: GameEngine, Blocks, Parachutes, Tests,
Benchmark, and ControlDemo. Without CMake, each compiles directly, since
none of them depend on anything outside this repo:
g++ -std=c++17 -O2 -pthread src/main.cpp -o build/GameEngine-pthread is required: the renderer's row-sharing loop uses std::thread.
SDL2 is referenced in CMakeLists.txt but commented out. An earlier attempt
to link it broke the build, so it is disabled rather than left broken.
Nothing in this engine depends on it.
tests/test_math_physics.cpp is an assert-based self-check, no framework: it
builds as the Tests target and exits non-zero if any assertion fails.
./build/TestsIt covers Vector3D arithmetic and edge cases (including zero-vector
normalization, which must not divide by zero), ray-sphere intersection (hit,
miss, and behind-the-camera cases), free-fall against the closed-form
semi-implicit-Euler formula, mass-independence of free-fall, drag against the
analytic terminal-velocity bound, and:
- BVH equals linear scan. 425 rays against a 61-object scene, asserting the accelerated hit record is identical to the exhaustive scan's, field by field, for both the median-split and SAH builds independently. The BVH is only an optimization, so the property that matters is that it changes nothing; a tree that is fast and subtly wrong is worse than the scan it replaced.
- Ground collision. A body dropped from 10m settles exactly on the ground plane with zero residual velocity, instead of sinking through it.
- Momentum conservation. Two elastic spheres collide head-on; total momentum along the collision axis is unchanged to 1e-9, and they end up separating and non-overlapping.
- Threaded render equals single-threaded render. The same scene rendered on 1 thread and on 8 must be bit-identical, which is checkable only because the supersampling jitter is deterministic. A data race in the row-sharing loop shows up here as a mismatched pixel.
- Shadow rays. A point behind an occluder is shadowed; a point beside it is not.
- Refraction against Snell's law. A ray entering glass at 45 degrees must
leave at
asin(sin(45)/1.5), checked to 1e-9 rather than by eyeballing a render. The critical angle is checked from both sides: 0.05 rad inside it still transmits, 0.05 rad past it must report total internal reflection instead of returning a NaN direction from a negative square root. A second test renders a white sphere against a red wall twice, opaque and as glass, and asserts the opaque pixel is gray while the glass pixel is red-dominant- the wall's color can only reach the camera by transmission.
- Control loop. A scripted
InputStatesequence drivesPlayerthroughapplyInput(): it falls and lands on spawn without sinking through the ground, walking input translates position, jumping leaves the ground, and gravity brings it back to rest aty=0afterward.
./build/Benchmark [spheres] [repeats] # defaults: 500 spheres, best of 3Measured on a 16-core machine, 500 spheres plus an unbounded plane, 400x300, 2 samples/pixel, 3 bounces, best of 5:
Intersection, single-threaded:
| Structure | Time | Throughput |
|---|---|---|
| Linear scan | 2.553 s | 0.22 Mrays/s |
| BVH, SAH (depth 11, 583 nodes) | 0.319 s | 1.77 Mrays/s |
| Speedup | 8.0x |
Median split vs binned SAH, single-threaded:
| Heuristic | Build time | Depth | Nodes | Render time | Throughput |
|---|---|---|---|---|---|
| Median split | 0.0004 s | 9 | 511 | 0.339 s | 1.67 Mrays/s |
| Binned SAH | 0.0008 s | 11 | 583 | 0.330 s | 1.71 Mrays/s |
On this scene SAH wins, but only barely: about 3% faster traversal for roughly 2x the build time, and both are sub-millisecond to build regardless. The scene is 500 spheres scattered fairly evenly around a ring, which is close to the case median split already handles well; SAH's advantage grows on scenes with uneven or clustered object density, where a fixed midpoint split leaves lopsided subtrees and SAH's cost search does not. The benchmark verifies the two heuristics render bit-identical images before reporting either number, so this is a real (if modest) result, not a rounding artifact.
Thread scaling, with the BVH enabled:
| Threads | Time | Throughput | Speedup |
|---|---|---|---|
| 1 | 0.320 s | 1.76 Mrays/s | 1.0x |
| 2 | 0.196 s | 2.89 Mrays/s | 1.6x |
| 4 | 0.120 s | 4.71 Mrays/s | 2.7x |
| 8 | 0.080 s | 7.03 Mrays/s | 4.0x |
| 16 | 0.057 s | 9.89 Mrays/s | 5.6x |
The benchmark verifies that the BVH and the linear scan produce identical images, that the median-split and SAH builds produce identical images to each other, and that every thread count produces the image the single-threaded run did, before reporting any timing. A speedup can therefore never come from one configuration quietly doing less work.
Scaling falls short of linear mainly because this scene renders in under a second, so thread startup and the tail of the last few rows are a real fraction of the total. An earlier version also incremented a shared atomic ray counter on every ray; that single contended cache line cost roughly half the achievable scaling, and the counter is now thread-local and folded in once per worker.
The control loop itself is real and tested, but nothing here reads a real
keyboard, because raylib is not available in this environment: only the
bundled raylib.h header is present, no library is installed, and
pkg-config --exists raylib fails (confirmed; apt-get install libraylib-dev
also fails here for lack of sudo). To keep Player::move()/jump() genuinely
reachable rather than dead code without that dependency, the control loop was
split from any input source:
InputState(src/physics/InputState.h) is a plain struct — movement axes, jump flag, look deltas — with no windowing dependency.Player::applyInput()(src/physics/Player.h) is the actual control loop: jump, move, integrate gravity, land. This is exercised end to end, headlessly:tests/test_math_physics.cpp'stestControlLoopasserts landing, walking, jumping, and re-landing against real trajectories, andControlDemo(src/main_control_demo.cpp) drives the same loop through 30 scripted frames and ray-traces each one, so the player's motion is visible in the rendered output, not just in printed numbers.ScriptedInputDriver(src/physics/InputDriver.h) is what feeds both of those a recorded sequence of frames.RaylibInputDriver, in the same file, is written to show the intended wiring to real keyboard/mouse input, guarded behind#ifdef PHOTON_USE_RAYLIBso it compiles into nothing by default. It has never been compiled or run in this environment — there is no way to verify it without a raylib library to link, and no interactive window can be confirmed here (no display/X server either). Getting from here to a playable build is linking raylib and passing-DPHOTON_USE_RAYLIB; no other code changes are expected to be needed.
Collision treats every body as a sphere, so blocks resolve against their bounding sphere rather than their faces and will not come to rest on a corner realistically. The broad phase is an O(n^2) pair loop, which is fine for the tens of bodies these demos use.
The BVH is rebuilt from scratch each frame in the animated demos rather than
refitted, regardless of which split heuristic is selected.
UI.cpp and GUI.cpp are SDL-based and are not compiled by any target while
SDL2 stays disabled.
Refraction is uniform: there is no wavelength-dependent index, so the renderer produces no chromatic dispersion, and no Beer-Lambert absorption, so thick glass tints exactly as much as thin glass.
MIT. See LICENSE.