A physically-based Monte Carlo path tracer written in pure D with zero external dependencies. This project implements a complete ray tracing pipeline from scratch, including all mathematical primitives, native file I/O (Linux syscalls, Windows API), and advanced rendering techniques.
- Zero External Dependencies: Every component is implemented from first principles
- Custom math library (vectors, rays, trigonometry, RNG)
- Native OS file I/O (Linux syscalls on Linux, kernel32.dll on Windows)
- Custom mutable string implementation for PPM image output
- Monte Carlo Path Tracing with unbiased global illumination
- Next Event Estimation (NEE) for efficient direct lighting
- Multiple Importance Sampling (MIS) combining BSDF and light sampling
- BVH Acceleration for fast ray-scene intersection
- Physically-based Materials: Lambertian, Metal, Dielectric, Emissive
The project uses DUB with the LDC2 compiler for optimized builds.
- LDC2 (LLVM-based D compiler for faster binary)
- DUB (D package manager)
- Linux x86-64 or Windows x86-64
There are a set of bash scripts for use on Linux:
# Release build
./build/build.sh
# Build and run
./build/run.sh
# Run unit tests
./build/test.sh
# Clean artifacts
./build/clean.shOr manually with DUB, which works on both Linux and Windows:
dub build --compiler=ldc2 --build=release && ./ntraycer # Linux
dub build --compiler=ldc2 --build=release && ntraycer.exe # WindowsThe rendered image is written to image.ppm in the current directory.
ntraycer/
├── dub.sdl # D build configuration
├── build/
│ ├── build.sh # Release build script
│ ├── run.sh # Build and execute
│ ├── test.sh # Run unit tests
│ └── clean.sh # Clean build artifacts
├── demos/
│ ├── cornell_box.png # Cornell Box scene
│ └── spheres.png # Ray Tracing in One Weekend scene
└── source/
├── app.d # Main entry point & scene setup
└── lib/
├── core/
│ ├── math.d # Vec2, Vec3, Ray, RNG, math functions
│ ├── file.d # Linux syscall wrappers (open/write/close)
│ └── mutstring.d # Mutable string buffer
├── accel/
│ ├── aabb.d # Axis-Aligned Bounding Box
│ └── bvh.d # Bounding Volume Hierarchy
├── renderer/
│ ├── renderer.d # Path tracing renderer with NEE/MIS
│ └── film.d # Image buffer and output
└── scene/
├── scene.d # Scene container with optional BVH
├── background.d # Environment backgrounds
├── light.d # Light sampling interface
├── camera/
│ ├── camera.d # Camera interface
│ └── pinhole.d # Perspective pinhole camera
├── hittable/
│ ├── hittable.d # Ray intersection interface
│ ├── sphere.d # Sphere primitive
│ └── mesh.d # Triangle and Quad primitives
└── material/
├── material.d # Material interface with BSDF
├── lambertian.d # Diffuse material
├── metal.d # Reflective material
├── dielectric.d # Refractive material
└── emissive.d # Light source material
This project deliberately avoids all external dependencies, including D's standard library (std). Everything is implemented from scratch as a learning exercise.
The renderer uses unbiased Monte Carlo integration to solve the rendering equation. For each pixel, multiple random samples are traced through the scene, bouncing off surfaces according to their material properties. The results are averaged to produce the final color.
// Core path tracing loop (simplified)
for (int bounce = 0; bounce < depth; bounce++)
{
if (!scene.hit(currentRay, hitInfo))
{
radiance += throughput * background;
break;
}
// Sample BSDF for next direction
material.scatter(ray, hitInfo, rng, scatterResult);
throughput *= scatterResult.weight;
currentRay = scatterResult.scattered;
}Russian Roulette termination is applied after 5 bounces to prevent infinite paths while maintaining an unbiased estimator.
Instead of waiting for paths to randomly hit light sources, NEE explicitly samples lights at each diffuse bounce to reduce noise when the only light sources are small area lights (like in the Cornell Box):
- Randomly select a light source from the scene
- Sample a point on that light's surface
- Cast a shadow ray to check visibility
- Add the direct lighting contribution if unoccluded
This dramatically reduces variance for scenes with small light sources.
if (scene.hasLights() && !mat.isSpecular())
{
LightSample lightSample;
scene.sampleLight(hitPoint, rng, lightSample);
// Shadow ray test
if (!inShadow)
{
Vec3 directLight = lightSample.emission * brdf * cosTheta / pdf;
radiance += throughput * directLight;
}
}MIS combines BSDF sampling and light sampling using the power heuristic to minimize variance. When a path could have been generated by either strategy, the contribution is weighted by the relative probability:
This prevents bright fireflies when the BSDF PDF is near zero but the light PDF is high (or vice versa).
// Convert area PDF to solid angle PDF
float pLightOmega = lightSample.pdfArea * dist * dist / cosLight;
// Get BSDF pdf for this direction
float pBsdf = mat.pdf(wo, wi, hitInfo);
// MIS weight using power heuristic
float misWeight = powerHeuristic(pLightOmega, pBsdf);Materials implement the Material interface with three key methods for physically-based rendering:
interface Material
{
/// Sample a scattered direction from the BSDF
bool scatter(Ray ray, HitInfo hitInfo, ref RNG rng, out ScatterResult result);
/// Evaluate the BSDF: f(wo, wi)
Vec3 eval(Vec3 wo, Vec3 wi, HitInfo hitInfo);
/// Return the PDF for sampling direction wi given wo
float pdf(Vec3 wo, Vec3 wi, HitInfo hitInfo);
/// True if this is a delta (specular) BSDF
bool isSpecular();
}| Material | Description | BSDF |
|---|---|---|
| Lambertian | Diffuse scattering |
|
| Metal | Specular reflection | Delta BSDF at mirror direction, optional fuzz |
| Dielectric | Glass/water | Fresnel reflection + Snell's law refraction |
| Emissive | Area lights | Returns emission, no scattering |
All geometric primitives implement the Hittable interface:
interface Hittable
{
/// Test ray intersection in [timeMin, timeMax]
bool hit(Ray r, float timeMin, float timeMax, out HitInfo hitInfo);
}Primitives that support BVH acceleration also implement Boundable:
interface Boundable
{
AABB boundingBox();
}Available Primitives:
Sphere— Analytic ray-sphere intersectionTriangle— Möller–Trumbore intersection algorithmQuad— Two triangles with unified light sampling
Objects can implement the Light interface to be sampled for NEE:
interface Light
{
LightSample sampleLight(Vec3 hitPoint, ref RNG rng);
Vec3 getEmission();
float getArea();
float getPdfArea();
}Both Sphere and Quad implement this interface when assigned an Emissive material.
MIT License — Copyright (c) 2026 Nathan Abebe

