1. Alpha Masking

Drag slider to compare: Without Alpha Masking (Left) vs With Alpha Masking (Right)

Description

Alpha masking is a powerful and efficient technique used in computer graphics to create complex, highly detailed organic surfaces without the computational cost of modeling every single geometric detail. Instead of constructing every individual leaf, twig, or intricate cutout as a separate 3D mesh, we utilize a 2D texture map where the alpha channel explicitly defines the transparency of the object. By mapping transparency to a texture, we can simulate intricate structures using simple proxy geometry.

Implementation

We implemented this feature within the Instance class by adding an optional m_alpha texture property. The core logic was added to the intersection routine (specifically in intersectSceneWithAlpha). Instead of a simple binary cutoff, we implemented stochastic alpha masking. When a ray intersects the geometry, the renderer samples the alpha texture at the specific UV coordinate. This opacity value is compared against a uniform random number (generated by the sampler); if the random number is greater than the alpha value, the intersection is probabilistically discarded, allowing the ray to pass through as if hitting empty space. This approach correctly approximates partial transparency when averaged over multiple samples.

See:
  • include/lightwave/instance.hpp
  • src/core/instance.cpp

Challenges

The primary challenge was integrating the masking logic cleanly with the existing transformation hierarchy. We had to ensure that the alpha test was applied consistently whether the instance was transformed (requiring ray transformation to local space) or untransformed. Additionally, correct state management was crucial: if an intersection is masked out, the Intersection data structure must be strictly reverted to its previous state (using its = previousIts) to ensure the ray tracer continues to search for valid hits behind the transparent surface.

2. Clearcoat Material

Drag slider to compare: Standard Material (Left) vs Clearcoat Material (Right)

Description

Standard materials often assume a single layer of interaction, but real-world surfaces like car paint, varnished wood, or glazed ceramic typically feature a secondary layer: a transparent, reflective coating over a base substrate. To simulate this, we added a Clearcoat lobe to our Principled BSDF. This feature adds a separate, glossy dielectric layer on top of the existing diffuse and metallic layers. This dual-layer approach allows us to render materials that have both a rich underlying color and a sharp, distinct specular highlight, significantly increasing the realism of manufactured and coated surfaces in our scenes.

Implementation

We implemented the clearcoat as a distinct struct, ClearcoatLobe, within the Principled BSDF class.

  • BRDF Model: Unlike the metallic lobe which uses GGX, the clearcoat follows the Disney Principled BRDF specification by using the GTR1 (Generalized Trowbridge-Reitz) distribution (also known as Berry) for the microfacet distribution. This creates the specific "tail" falloff characteristic of clearcoats.
  • Masking/Shadowing & Fresnel: We utilized a fixed alpha (roughness) of 0.25 for the Smith G1 geometry term and a standard dielectric Fresnel approximation (Schlick) with R0 = 0.04 (representing common polyurethane or acrylic coatings).
  • Sampling: The Principled::sample method was updated to probabilistically select between the diffuse, metallic, and clearcoat lobes based on their respective albedos. If the clearcoat is selected, we sample the GTR1 distribution to generate a reflection direction.
See:
  • src/bsdfs/principled.cpp

Challenges

The toughest challenge was correctly balancing the energy conservation and probability weights when combining three different lobes (Diffuse, Metallic, and Clearcoat). Initially, adding the clearcoat energy simply on top of the others led to overly bright materials. We had to ensure the combine function correctly calculated selection probabilities based on the luminance of each component, and that the Monte Carlo weights in sample were properly divided by these probabilities (e.g., sample.weight /= (1 - mettalicBoundary) for the clearcoat) to maintain an unbiased estimator.

3. Post Processing (Bloom)

Drag slider to compare: Raw Render (Left) vs With Bloom (Right)

Description

Bloom is a post-processing effect used to simulate the optical phenomenon where very bright light overwhelms the camera sensor (or the human eye), causing light to bleed into the surrounding darker areas. While our path tracer calculates physically accurate radiance, it cannot natively simulate this lens artifact. By implementing Bloom, we add a soft, glowing halo around bright light sources—such as the sun, lamps, or emissive geometry—significantly enhancing the perceived brightness and dynamic range of the final image.

Implementation

We implemented the feature in the Bloom class (subclassing Postprocess) and a helper GaussianFilter class. The process consists of three main stages:

  1. Thresholding: We first extract a "bright pass" image by iterating over the input. Only pixels with a luminance value exceeding a user-defined m_threshold are kept; others are set to black.
  2. Gaussian Blurring: To create the soft glow, we perform a Gaussian blur on the thresholded image. We implemented a separable Gaussian filter. Instead of performing an expensive 2D convolution O(r2), we perform two 1D passes (horizontal then vertical), reducing complexity to O(r). To achieve a wide, smooth falloff, we apply this blur iteratively, where each iteration blurs the result of the previous one.
  3. Compositing: Finally, we compute a weighted sum of these blurred layers and add them back onto the original input image, scaled by an intensity factor to control the strength of the glow.
See:
  • src/postprocesses/bloom.cpp

Challenges

The main challenge was achieving a high-quality, wide-radius blur without killing performance. A naive implementation of a large Gaussian kernel is computationally prohibitive (checking neighbors in a massive N x N grid for every pixel). Implementing the separable filter optimization was essential to make the render time acceptable. Additionally, handling image boundaries during convolution required careful logic (using std::clamp) to ensure the blur didn't introduce artifacts or crash at the edges of the image.

4. Normal Mapping

Drag slider to compare: Without Normal Mapping (Left) vs With Normal Mapping (Right)

Description

Normal mapping is a texturing technique used to simulate high-frequency geometric detail—such as bumps, scratches, or grooves—without increasing the polygon count of the mesh. Instead of relying solely on the interpolated vertex normals of the geometry, we sample an additional 2D texture that encodes surface normal vectors. These detailed normals are used for lighting calculations (shading), creating the illusion of depth and complexity on otherwise flat surfaces.

Implementation

We integrated normal mapping directly into the Instance class, allowing any shape to be enhanced.

  • Texture Property: We added an optional m_normal texture member to the class.
  • Transform Logic: The core logic resides in Instance::transformFrame. Before transforming the intersection frame from object space to world space, we check if a normal map exists.
  • Mapping: If present, we evaluate the texture at the intersection's UV coordinates. Since texture colors are stored in the range [0, 1], we remap them to the vector range [-1, 1] by an affine transformation.
  • Application: We replace the intersection's object-space shadingNormal with this sampled vector and recompute the tangent using Frame(normal).tangent to ensure the shading frame remains orthogonal. Finally, the standard instance transformation matrix is applied to convert this new frame to world space.
See:
  • include/lightwave/instance.hpp
  • src/core/instance.cpp

Challenges

The main challenge was determining the correct order of operations within the transformation pipeline. It was crucial to apply the normal map modification in object space (before applying m_transform) rather than in world space. If applied after the world transform, the normals from the texture would not rotate or scale correctly with the object. Additionally, ensuring that the tangent vector was re-orthogonalized (Frame(normal).tangent) after modifying the normal was necessary to prevent degenerate shading frames.

5. Low Discrepancy Sampling

Drag slider to compare: Random Sampling (Left) vs Halton Sampling (Right)

Description

Standard pseudo-random number generators (independent samplers) often produce "clumping" and large gaps in the sample space, leading to noise that converges slowly. To improve this, we implemented a Halton Sampler. This is a low-discrepancy (quasi-random) sequence generator that deterministically distributes sample points to cover the N-dimensional domain more evenly than random chance. By ensuring a well-stratified distribution of samples, we significantly reduce visual noise and aliasing artifacts for the same number of samples per pixel, particularly in high-dimensional integration tasks like path tracing.

Implementation

We implemented the Halton class inheriting from Sampler. The core mechanism generates samples using the radical inverse function Φb(i), where b is a prime number base.

  • Dimension Mapping: We map each dimension of the rendering equation (pixel X, pixel Y, lens U, lens V, etc.) to a specific prime base (2, 3, 5, 7, ...). The first two dimensions (Base-2 and Base-3) are reserved for image space positions to ensure good spatial stratification.
  • Index Computation: A crucial part of the implementation was the seed(pixel, sampleIndex) function. Unlike a simple global counter, we compute a specific global Halton index for every pixel to ensure that samples land exactly within that pixel's boundaries. This required solving the Chinese Remainder Theorem to find an index i such that Φ2(i) ≈ x and Φ3(i) ≈ y.
  • Randomization: To prevent visual artifacts (structured patterns) common with raw Halton sequences, we added support for randomization strategies: Digit Permutation (scrambling the digits of the radical inverse expansion) and Owen Scrambling.
See:
  • src/samplers/halton.hpp
  • src/samplers/halton.cpp
  • src/samplers/utilities/lowdiscrepancy.hpp
  • src/samplers/utilities/lowdiscrepancy.cpp
  • src/samplers/utilities/permutation.hpp
  • src/samplers/utilities/permutation.cpp
  • src/samplers/utilities/primes.hpp

Challenges

The most complex aspect was implementing the global index mapping for the seed function. We had to calculate multiplicative inverses and extended GCDs to solve the system of congruences required to "jump" the generator to the correct sequence index for a specific pixel (x, y). Without this math, the sampler would generate valid Halton points globally across the unit square, but they wouldn't necessarily align with the specific pixel being rendered, breaking the stratification benefit.

6. Image Denoising

Drag slider to compare: Noisy Output (Left) vs Denoised Output (Right)

Description

Monte Carlo path tracing is notorious for producing grainy, noisy images when the sample count is low. Increasing the sample count to resolve this noise can increase render times linearly or worse. To combat this, we integrated Intel® Open Image Denoise (OIDN), a high-performance, AI-based library capable of reconstructing clean images from noisy inputs. This allows us to achieve high-quality results with significantly fewer samples per pixel, drastically reducing overall render time.

Implementation

We created a new Denoise class inheriting from Postprocess. The implementation involves:

  • Device Abstraction: The implementation initializes an OIDN device (supporting CPU, CUDA, Metal, etc.) based on user configuration, ensuring hardware acceleration is used where available.
  • Auxiliary Buffers (AOVs): To improve the denoiser's ability to distinguish between geometry edges and noise, we feed it auxiliary feature buffers. Specifically, we support optional albedo and normal maps.
  • Data Transformation: Before passing data to OIDN, we preprocess the normal maps. Our renderer outputs normals in the [0, 1] color range, but OIDN expects vector data in [-1, 1]. We transformed the normals in an affine manner to remap these values correctly.
  • Execution: The execute method transfers the input image (and optional AOVs) to OIDN-managed buffers, runs the "RT" (ray tracing) filter with HDR mode enabled, and reads the cleaned result back into the output image.
See:
  • src/postprocesses/denoise.cpp

Challenges

The primary challenge was ensuring the correct format and range of the auxiliary data. OIDN is sensitive to the quality of input features; feeding it incorrect normal vectors (e.g., in the wrong coordinate space or range) leads to worse artifacts than not using them at all. We had to implement a specific check where normal maps are only used if albedo maps are also present, as OIDN's documentation suggests normals are less effective in isolation. Additionally, managing the memory transfer between the host application and OIDN device buffers required careful handling to support different backend types (like Metal or CUDA) robustly.

7. Rough Dielectric

Drag slider to compare: Perfect Dielectric (Left) vs Rough Dielectric (Right)

Description

Standard dielectrics (like clear glass or water) assume a perfectly smooth surface. However, many real-world transparent materials, such as frosted glass, ice, or etched crystal, possess microscopic surface irregularities that scatter light. To simulate this, we implemented a Rough Dielectric BSDF. This model combines the physics of refraction with microfacet theory. Instead of a single, sharp refraction direction, light rays are scattered according to a statistical distribution of micro-surface normals, creating blurry refractions and softened specular highlights.

Implementation

We implemented the RoughDielectric class, which unifies logic from both rough conductors and smooth dielectrics.

  • Microfacet Distribution: We use the GGX distribution to model the surface roughness. The roughness parameter (α) is derived from the square of the texture value: α = roughness2.
  • Sampling: In sample, we first sample a microfacet normal ωh using the Visible Normal Distribution Function (VNDF). We then calculate the Fresnel term F for this microfacet. Based on the ratio of reflected to transmitted luminance (weighted by F), we stochastically decide whether to reflect or refract the incoming ray relative to ωh.
  • Evaluation: The evaluate function handles both reflection (same hemisphere) and refraction (opposite hemisphere).
    • For reflection, it behaves similarly to a rough conductor but uses the dielectric Fresnel term.
    • For refraction, it uses the generalized microfacet equation for transmission. Crucially, the half-vector for refraction is defined as ωh = -η ωi + ωo.
See:
  • src/bsdfs/roughdielectric.cpp

Challenges

The most difficult part of this implementation was correctly handling the change of variables (Jacobian) in the refraction term. Unlike reflection, where the solid angle change is straightforward, refraction compresses or expands the solid angle based on the ratio of indices of refraction (-η). Failing to include the determinant of the Jacobian results in incorrect energy levels (dark glass). Additionally, ensuring that the half-vector ωh always points into the simpler medium (or consistently handling the sign of ωh) was essential to avoid NaN values during the Fresnel calculation.

8. Env Sampling

Drag slider to compare: Standard Sampling (Left) vs Importance Sampling (Right)

Description

Environment maps (HDRI) are excellent for providing realistic, complex lighting from "infinity." However, naive sampling strategies treat every pixel on the environment map as equally important. In scenes with high-contrast lighting—like a bright sun in a dark sky—uniformly sampling the sphere means we rarely hit the sun, resulting in excessive noise and slow convergence. To fix this, we attempted to implement Importance Sampling for the environment map. This technique constructs a probability distribution based on the brightness of the texture, ensuring that we cast significantly more shadow rays toward bright areas (light sources) than dark ones.

Implementation

We modified the EnvironmentMap class to support Next Event Estimation (NEE) driven by texture intensity, attempting to follow the implementation details found in Physically Based Rendering (PBRT).

  • Precomputation: In computeDistribution, we compute the luminance of the texture and build a Distribution2D structure (utilizing a marginal and conditional CDF) to allow picking UV coordinates proportional to their brightness.
  • Sampling: In sampleDirect, we utilize this distribution to sample a UV coordinate (u, v) and a corresponding probability density p(u, v).
  • Mapping: We convert the sampled UVs to spherical coordinates (φ = 2πu, θ = π v) and finally to a local direction vector.
  • Weighting: We calculate the Monte Carlo weight using the inverse of the PDF. We included the Jacobian determinant for the equirectangular projection (2π2 sinθ) to convert the PDF from image space to solid angle space.
See:
  • src/lights/envmap.cpp
  • src/lights/utilities/distribution.hpp
  • src/lights/utilities/distribution.cpp

Challenges & Current Status

The current implementation is buggy. Despite correctly building the 2D distribution function and sampling coordinates according to the CDF following the PBRT reference, the resulting lighting is incorrect. The output image with and without the importance sampling seems to be the same.The main challenge lies in the complex chain of coordinate transformations and PDF conversions (Image Space to Spherical Coordinates to Solid Angle). Somewhere in the interplay between the Jacobian term (sinθ), the normalization factors, and the texture coordinate mapping, errors were introduced that we have not yet resolved.

9. Area Light

Drag slider to compare: Point Light (Left) vs Area Light (Right)

Description

Relying on random chance to hit small, bright light sources (like light bulbs or the sun) results in extremely noisy images, as most rays simply miss the light. To resolve this, we implemented Next Event Estimation (NEE) for emissive shapes. Instead of waiting for a path to accidentally hit a light source, we explicitly sample points on the surface of area lights at every shading point. This technique, known as direct light sampling, drastically reduces variance and noise by ensuring that the contribution of light sources is calculated consistently.

Implementation

We implemented a new AreaLight class that wraps an Instance (which contains the geometric shape and emission profile).

  • Sampling Geometry: The core logic relies on the Instance::sampleArea method (and the underlying Shape::sampleArea), which selects a point on the surface of the shape.
  • Conversion to Solid Angle: The sampling occurs in area measure (finding a point p on the surface with probability density pA). However, the rendering equation integrates over solid angle. In sampleDirect, we perform the necessary change of variables considering the angle between the light's normal and the incoming ray, and the distance between the shading point and the light.
  • Emission Evaluation: Once a point is sampled, we query the Emission component of the instance to get the radiance Le.

Challenges

The main challenge was ensuring the correct geometric relationships between the shading point and the sampled light point. Specifically, handling the geometry term G(x, y) correctly is vital. If the distance squared term (d2) or the cosine foreshortening at the light source (cosθ) is omitted or incorrect, the lighting intensity falls off incorrectly with distance or angle. Additionally, we had to ensure robust handling of edge cases, such as when the sampled point is extremely close to the origin (causing division by zero) or when the probability density (PDF) is zero.

10. ThinLens

Perspective Camera
Perspective Camera
Focus on First Bunny
Low Focal Distance
Focus on Second Bunny
Mid Focal Distance
High Blur Strength
High Lens Radius

Description

Standard computer graphics cameras usually follow the "pinhole" model, where the aperture is infinitesimally small, resulting in an image that is perfectly sharp at all distances. Real cameras, however, have a finite aperture size, which causes objects outside the focal plane to appear blurry—an effect known as depth of field. To simulate this optical phenomenon, we implemented a Thin Lens camera model. By defining a lens radius and a focal distance, we can control which parts of the scene are in focus and the intensity of the background/foreground blur (bokeh).

Implementation

We modified the Perspective camera class to support finite apertures.

  • Focal Plane Calculation: In the sample method, we first calculate where a ray from a standard pinhole camera would intersect the plane of focus. This is determined by the m_focalDistance.
  • Lens Sampling: Instead of starting every ray at the origin (0,0,0), we sample a random point on the surface of the lens. We use concentric disk sampling (squareToUniformDiskConcentric) to map uniform random numbers to a disk of radius m_lensRadius.
  • Ray Generation: The final camera ray is constructed by connecting the sampled point on the lens to the target point on the focal plane. This ensures that all rays originating from the lens converge at the focal distance (keeping that plane sharp) while diverging elsewhere (creating blur).

Challenges

The main challenge was correctly establishing the geometric relationship between the screen space sample, the lens sample, and the focal plane. We had to ensure that the initial direction vector was scaled correctly so that it strictly intersected the focal plane at the focal distance. If this intersection point is calculated incorrectly, the plane of focus shifts unpredictably, making it difficult to focus on specific objects in the scene. Additionally, ensuring that the sampling was uniform over the lens disk (using concentric mapping) was important to avoid "clumping" of rays that would bias the shape of the bokeh.