Table of Contents
Building upon the Nori renderer from ETH Zurich's Computer Graphics course (FS2025), this here represents my exploration into advanced rendering techniques. Here I document the features I implemented beyond the course requirements, along with my notes and validations.
Emitters ↑
Spot Light ↑
Spotlights emit light in a cone of directions from their position. This is discussed in chapter 12.2.1 of the PBR book.
Description ↑
Files created or modified:
src/spotlight.cpp
Validation scenes:
scenes/nori-beyond/spotlight/cbox.xmlscenes/nori-beyond/spotlight/cbox_mitsuba.xml
A new emitter type SpotLight is implemented in the file spotlight.cpp.
The origin of the light is at , and points towards the axis. A spot light is defined by two angles -- falloffStart, where everything inside this angle is fully illuminated (intensity multiplier = 1), and totalWidth, where illumination fades to black at this angle, and everything outside is dark (intensity multiplier = 0). To avoid expensive operations, I store the the cosines of the angles cosFalloffStart and cosTotalWidth, and since decreases as increases, . To create a soft transition zone (penumbra) in the outer cone, I use the SmoothStep function, which is a cubic function (Hermite interpolation), that takes in , where is the cosine angle between and the vector with direction pointing from the light origin to the point in scene:
- Compute and if or .
- Return (Hermite interpolation)
The angular intensity is the radiant intensity in the direction (power emitted per solid angle in direction ), and is given by
A surface patch at point subtends a small solid angle around direction , with the relation , where is the angle between the surface normal at and direction from to the light. The power arriving at from that infinitesimal solid angle is given by
The irradiance on the patch is . The radiance is defined so that
Derivation of flux (not used explicitly in this implementation because of the uniform sampling strategy)
A spherical cap (cone of half-angle ) has solid angle . Within the region of falloffStart, intensity is constant, so flux in that region is
Between falloffStart and totalWidth, the intensity decreases smoothly to 0. The PBR book approximates the integral by assuming that intensity in the region averages to half the peak value. The solid angle of the penumbra annulus is . If the average intensity is , then
Total flux is thus
SpotLight::eval(...)
Delta emitters have zero area so there is measure-zero probability of hitting them via BSDF sampling.
SpotLight::sample(...)
First assign the fields of the emitter query record lRec given the position of the light and the reference point . Then compute the angle attenuation term using given the cosine of the angle between the m_direction (direction of spot light) and the vector pointing from the light to (lRec.ref). Finally compute the intensity and return the radiance.
SpotLight::pdf(...)
Delta lights have zero measure, thus PDF = 0. This is required for MIS.
Spotlight::samplePhoton(...)
First sample a direction from the light using squareToUniformSphereCap and assign the sampled direction to ray. Then compute the angle attenuation term using given the cosine of the angle between the m_direction (direction of spot light) and the sampled direction. Finally return the photon power, which is the intensity divided by the sampling PDF.
Validation ↑
I compared my implementation with Mitsuba with the Cornell box scene with two cubes, and a spot light positioned behind the right one, pointing towards the left one.
I also updated path_mis.cpp to support delta lights (spotlights and point lights) by modifying the MIS weight calculation. For delta lights (detected by checking if ems_lRec.n.isZero()), I set the MIS weight to 1.0 since delta lights cannot be sampled by BSDF sampling (the probability of randomly hitting a point light via BSDF is zero). This ensures that only emitter sampling contributes when rendering scenes with delta lights, preventing the image from being incorrectly darkened by MIS weighting.
Comparison: Cornell Box with Spot Light





There is a reasonable difference in rendered images. In my implementation, after computing , I apply a Hermite interpolation (SmoothStep) following the PBR book, which creates an "S-curve" falloff. My spotlight hence stays brighter for longer near the center (the "shoulder" of the S-curve) and fade out more gradually near the absolute edge (the "toe" of the S-curve) compared to Mitsuba's sharp linear drop, which applies applies a pure linear ram. Also, my interpolation domain is cosine where Mitsuba is the angle itself. I believe that the visual differences make sense based on these two implementation differences.
Directional Light ↑
Directional Light is also known as distant light, which desccribes an emitter that deposits illumination from the same direction at every point in space. This feature is discussed in chapter 12.3 of the PBR book.
Description ↑
Files created or modified:
src/directionallight.cpp
Validation scenes:
scenes/nori-beyond/directional_light/
A new emitter type DirectionalLight is implemented in the file directionallight.cpp.
The radiance at a point from direction due to directional light with direction is given by
The physical reason that directional lights make sense is that for point lights, where is the distance from the point light. For directional lights, . However, the emitting area also grows as . The two effects cancel.
DirectionalLight::eval(...)
Delta emitters have zero area so there is measure-zero probability of hitting them via BSDF sampling.
DirectionalLight::sample(...)
Given a shading point , we sample the incoming direction deterministically since there is only one valid direction. We set:
- (toward the light source)
- (a fictitious point far away)
- (delta distribution, handled specially)
- Shadow ray from toward with infinite extent
The returned radiance is simply . Unlike area lights, there is no falloff since the light is infinitely distant.
DirectionalLight::pdf(...)
Delta lights have zero measure, thus PDF = 0. This is required for MIS to correctly handle the delta distribution—the MIS weight computation uses this PDF, and returning 0 ensures that BSDF-sampled paths that happen to align with the light direction don't incorrectly contribute.
DirectionalLight::samplePhoton(...)
For photon mapping, we need to emit photons that cover the entire scene uniformly from the light direction. Following PBR book 12.5, we:
- Sample a point on a disk of radius perpendicular to the light direction
- Position this disk behind the scene (offset by along )
- Emit a ray from this point in direction
The disk sampling uses an orthonormal frame built from :
where is a uniform disk sample and form an orthonormal basis.
The photon power is (radiance times the disk area), which accounts for the uniform PDF of over the sampling disk.
Validation ↑



Environment Map Emitter ↑
Environment Map Emitter simulates global illumination by using an environment map as a light source.
Description ↑
Files created or modified:
Validation scenes:
Validation ↑
Textures ↑
Images as Texture ↑
Description ↑
Files created or modified:
include/nori/lodepng.h: downloaded from lodepngsrc/lodepng.cpp: downloaded from lodepngsrc/imagetexture.cpp
Validation scenes:
scenes/nori-beyond/image_texture/mesh-image-texture.xmlscenes/nori-beyond/image_texture/mesh-image-texture-mitsuba.xml
This feature enables the use of PNG images as textures to replace constant albedo colors on objects in the scene. A new texture type ImageTexture is implemented in imagetexture.cpp. The texture supports tiling and scaling.
The standard texture mapping pipeline includes scene definition -> texture loading at startup -> ray intersection -> UV coordinate interpolation -> texture lookup -> colour / material evaluation. To breakdown the implementation, the following details the pipeline:
- Image texture loading at startup: An image loader would be needed to parse the image file in order to handle decompressing data, convert to RGB format, etc.
- Ray intersection: When a ray hits a mesh triangle, I would need to get the barycentric coordinates of the hit point within the triangle
bary_coordinates. - UV coordinate interpolation: The 3D mesh has UV coordinates stored per vertex, normalized to . For example,
Point2f uv0 = m_UV.col(v0)is the UV coordinates at vertex 0. Usingbary_coordinates, I can interpolate the UV coordinates:its.uv = bary_coordinates.x() * uv0 + bary_coordinates.y() * uv1 + bary_coordinates.y() * uv2. This part and the previous two parts are already handled in nori. - Texture lookup: I need to loopk up the colour in the loaded texture data. This consists of first converting the UV coordinates () to pixel coordinates ([width, height]), then looking up the image loader data.
- Colour/Material evaluation: This is implemented in the BSDF
evalmethods (e.g.Diffuse'sevalmethod callsm_albedo->eval(bRec.uv)). Finally, the BSDF value is evaluated withbsdf->eval(bRec).
For the image loader, I searched for commonly used ones and were deciding between lodepng and stb_image. I decided to use lodepng since I only need PNG support and that it has separate compilation and has automatic memory management.
For the PNG image loading, I first construct the flattened 1D array m_data in which each entry is a Color3f, for memory efficiency.
ImageTexture::eval(...)
Given the UV coordinates on the 3D surface, this method should find the corresponding pixel in the image texture, and return its RGB colour.
First, apply the tiling and scaling by computing the fractional part of the scaled UV coordinates
float img_u = std::fmod(uv.x() * m_scale.x(), 1.0f);
float img_v = std::fmod(uv.y() * m_scale.y(), 1.0f);
For example, if uv = (0.75, 0.75) and m_scale = (2.0, 2.0), then after scaling, I get (1.5, 1.5) which is beyond and I are in the second repetition, so I subtract by to get , which means that at surface point I sample texture from , because the texture is repeated twice.
Next, convert the UV coordinates to pixel coordinates by scaling img_u by m_width and img_v by m_height and trancating to integer pixel index. For the V coordinate, I need to flip the axis because in the image storage, row 0 is at the top of the image but V = 0 is at the bottom of the texture. Then I clamp the bounds to prevent the pixel coordinates being negative or going beyond the last column or row.
Finally, convert from 2D to 1D array index to look up the RGB value from m_data which I constructed.
Validation ↑
For the validation scenes, I reuse the scene mesh-texture from assignment 1





Normal mapping ↑
Normal mapping replaces the shading normal with a normal sampled from a texture (RGB image). The texture stores perturbations of the normal direction. This feature is discussed in chapter 10.5.3 of the PBR book.
Description ↑
Files created or modified:
src/normalmap.cppinclude/nori/bsdf.hsrc/diffuse.cppsrc/mesh.cpp
Validation scenes:
scenes/nori-beyond/normal_mapping/mesh-normal-map.xmlscenes/nori-beyond/normal_mapping/mesh-no-normal-map.xmlscenes/nori-beyond/normal_mapping/mesh-normal-map-mitsuba.xmlscenes/nori-beyond/normal_mapping/mesh-no-normal-map-mitsuba.xml
The current rendering pipeline follows ray generation -> ray-scene interaction -> mesh.cpp::setHitInformation which fills in its -> integrators to compute radiance. The NormalMap texture would return the Normal3f at a given UV coordinate in the 3D surface. We need to modify mesh.cpp::setHitInformation so that its.shFrame is the perturbed frame according to the normal map.
src/normalmap.cpp
A new texture NormalMap is implemented in normalmap.cpp. Normal maps are stored in the tangent space (aligned with per-vertex tangent, bitangent, normal) using (R, G, B) = (nx, ny, nz) with each component in . Since real normals are in , the PBR book decodes them by x = 2 * r - 1, and correspondingly for y and z. After that, we store the Normal3f(x, y, z) into m_data. In include/nori/frame.h, the Frame class defines s and t as tangent vectors and n as the normal and the key transformation method is:
Vector3f toWorld(const Vector3f &v) const {
return s * v.x() + t * v.y() + n * v.z();
}
which is a standard right-handed coordinate frame with no special tangent convention defined. So for normal maps with OpenGL format, we pass through (x, y, z) which will be mapped to (s, t, n) and for normal maps with DirectX format, we pass (x, -y, z) since DirectX conventions have Y pointing down. Other than this detail and that the final returned Normal3f should be normalized, the implementation of eval is the same as as that in ImageTexture.
include/nori/bsdf.h
We added the method getNormalMap() which returns true if the BSDF has a normal map. We need to add this method because image textures are stored in m_albedo within the BSDF (diffuse.cpp::addChild()), and then the sample method uses it. However, since we need to modify its.shFrame inside mesh.cpp::setHitInformation, it must access m_normalMap.
src/diffuse.cpp
To use the normal map, we need at least one BSDF that stores it. We use the diffuse BSDF. For each instance where m_albedo appears, we also add m_normalMap. This includes the constructor, destructor, addChild() and toString(). In addition, we also implement the getNormalMap() override to return the normal map so the mesh can retrieve it.
Mesh::setHitInformation()
After interpolating base normal from mesh, we need to query BSDF for the normal map using getNormalMap(). If normal map exists, we evaluate the normal map at its.uv to get the tangent-space normal. Then we compute the tangent frame from UV gradients and transform the normal to world space. Then we update its.shFrame with the perturbed normal. In detail, we first read the normal map using Normal3f mappedNormal = normalMap->eval(its.uv), where eval was implemented in normalmap.cpp.
Let the triangle vertex positions be and the triangle UVs be . We define , , , . We want solving the linear system coming from
Equivalently,
Solving for by inverting the UV matrix, the determinant is
If , the inverse yields the closed form
If , the UV mapping is degenerate (no well-defined mapping), so we fall back. Next we Gram-Schmidt the tangent. We set Vector3f n = its.shFrame.n;, then to compute , we project on to normal by . Then remove the normal component
and normalize it. Then compute bitangent as cross product: . Then we do a linear change-of-basis from tangent-space coordinates to world coordinates using the TBN matrix :
and normalize it. Then, we replace the shading normal used for shading with the perturbed normal . The new shading frame is constructed from .
For the fallback branch (degenerate UV) or if there is no UV, we cannot reliably compute from UV, so we use the preexisting tangent frame.
Validation ↑
The materials used for validation are downloaded from ambientCG:
Bark Color Map
Bark Normal Map
Ground Color Map
Ground Normal Map
Comparison: Bark and Ground Material With and Without Normal Maps





Light Transport Algorithms ↑
Final Gather for Photon Mapping ↑
Final Gather for Photon Mapping refines global illumination by gathering photon data to compute indirect light.
In standard photon mapping, we estimate irradiance by averaging photons within a radius . This is inherently biased because we are averaging over an area, not evaluating at a point. Sharp features get smeared. The larger the radius, the more blur, and the smaller the radius, the more noise. Final gather hides the bias behind an integration.
Description ↑
Files created or modified:
src/photonmapper.cpp
Validation scenes:
scenes/nori-beyond/final_gather/cbox_0.xmlscenes/nori-beyond/final_gather/cbox_64_10M.xml
The basic photon mapping logic has two phases: preprocessing and rendering. The preprocessing phase follows the steps:
- Emit photons from light sources until the desired number of photons stored in map
num_photonsreaches the target number of photonsm_photonCount. Randomly select an emitter, sample a photon ray and power usingsamplePhoton(). - Trace photons through the scene :
- When a photon hits a diffuse surface, store it in the photon map (position, incident direction, power)
- Use Russian roulette for path termination
- Sample BSDF to bounce the photon, attenuating its power by
- Account for radiance scaling when passing through dielectric surfaces with scaling
- Build KD-tree for efficient photon lookups
The rendering phase follows the steps:
- Trace camera rays through the scene
- If the ray hits an emitter, add emitted radiance .
- At the first diffuse surface hit,
- Search photon map within
m_photonRadius - For each found photon , evaluate BSDF (with the current ray direction and the photon direction ) and accumulate with .
- Get density estimation where is the number of emitted photons.
- Aggregate by multiplied by the current throughput
- Search photon map within
- Use Russian roulette for path termination
- At specular surfaces, continue tracing via BSDF sampling.
PhotonMapper::Li()
Instead of looking up photons at the point of query , we do one extra bounce. We sample m_numGatherRays gather rays from with its BSDF. We trace each gather ray through the scene with max maxSpecularBounces specular bounces. At the first diffuse hit, we perform the photon lookup and density estimation. The radiance , from final gather is then given by
A caveat about final gather is that we need to explicitly compute direct lighting with a shadow ray test. Consider direct photon density estimation: Without final gather, photons from the light hits surfaces and get stored. A sphere blocks photons from reaching the floor behind it. Fewer photons are then stored in that shadowed region, causing it to be darker and creating shadows. With final gather, gather rays are shot from the shading point in all directions, where we look up photons. Even if the shading point is in shadow, the gather ray can reach lit surfaces, causing the shadow to disappear. Hence the radiance at , is given by where is the throughput.
The current code now takes in an argument numGatherRays as follows:
<integrator type="photonmapper">
<float name="photonRadius" value="0.05"/>
<integer name="photonCount" value="10000000"/>
<float name="numGatherRays" value="64"/>
</integrator>
If numGatherRays is unspecified, it is automatically set to 0, and the behaviour is the same as original version of photon mapping.
Validation ↑
It can be observed that the caustic effect gets diluted or lost. This is because caustic photons (light paths like ) are stored in the global photon map. Without final gather, we query photons directly at the shading point, and caustics are visible. With final gather, the gather rays sample the BSDF and do photon lookups at the endpoints of those rays. However, caustics are highly localized and the probability that a random gather ray lands exactly in a caustic region is very low, and thus most gather rays missthe caustic entirely. So the caustic effect gets diluted.




Dedicated Caustics Photon Map ↑
A dedicated caustics map separates photons into two categories: Global photon map and caustics photon map. The caustics photon map stores photons that passed through at least one specular surface before hitting a diffuse surface.
Description ↑
Files created or modified:
src/photonmapper.cpp
Validation scenes:
scenes/nori-beyond/caustics_map/cbox_64_10M.xml
New member variables m_causticPhotonCount, m_causticRadius, m_emittedCausticPhotons have been added. In general, typical photon radius in the caustics map is smaller than that in the global map, and fewer photons are needed.
PhotonMapper::preprocess()
The preprocessing phase now needs to build two photon maps. photonCount photons are to be stored in the global photon map and stored at diffuse surfaces. If caustics photon map is enabled, the photons are stored only if they did not just hit a specular surface one bounce ago. Otherwise if caustics photon map is disabled, they are stored at every diffuse hit. causticPhotonCount photons are to be stored in the caustics photon map. We track a flag hasHitSpecular, initialized as false, as the photon bounces in the scene. Once the photon has hit a specular surface, the flag is set to true. In the next diffuse hit, the photon is stored in the caustics map and we stop tracing this photon.
PhotonMapper::Li()
At a diffuse surface, we estimate the photon density from the caustics map without final gather and obtain . We also estimate the photon density with the global photon map with or without final gather (depending on user specification) and obtain . We then aggregate the two radiances.
The current code now takes in an argument causticPhotonCount and causticRadius as follows:
<integrator type="photonmapper">
<integer name="photonCount" value="10000000"/>
<float name="photonRadius" value="0.05"/>
<integer name="numGatherRays" value="64"/>
<integer name="causticPhotonCount" value="2000000"/>
<float name="causticRadius" value="0.02"/>
</integrator>
If causticPhotonCount or causticRadius are unspecified, they are automatically set to 0, and the behaviour is the same as original version of photon mapping.
Validation ↑





Resampled Importance Sampling ↑
Resampled Importance Sampling (RIS) focuses computation on areas of higher light contribution.
In the current path tracing with multiple importance sampling (MIS), we sample one light uniformly from the light set, and one BSDF direction, and combine the two with MIS weights. This works well, but uniform light selection is bad when many lights exist, and one light sample per bounce is often noisy. In RIS, we generate candidate lights, assign each a weight, and resample one candidate. When , RIS reduces to standard emitter sampling.
Description ↑
Files created or modified:
src/path_ris.cpp
Validation scenes:
scenes/nori-beyond/path_ris/sponza.xml
The general RIS framework from Talbot et al. estimates an integral using two PDFs:
- Source PDF : easy to sample from (the proposal distribution)
- Target PDF : closer to , possibly unnormalized or difficult to sample directly
The RIS procedure is:
- Draw candidate samples
- Compute resampling weights
- Resample one candidate from with probability
- The unbiased RIS estimator is:
When , RIS reduces to standard importance sampling with PDF .
Our implementation uses a simplified parameterization where we set (the target equals the integrand). This is a valid special case of RIS.
For direct lighting at shading point , the integrand is:
where is a point on a light source, , is the geometry term, and is visibility.
Our source (proposal) PDF is uniform light selection combined with area sampling on each light:
with .
PathRISIntegrator::Li()
At shading point :
-
Sample candidates. For , sample light uniformly such that , and sample point on light :
-
For each candidate, compute the contribution (ignoring visibility for now):
Note that is the integrand divided by the solid angle PDF only (not the full proposal PDF). The factor is handled separately. Also compute the scalar resampling weight .
-
Select candidate with probability
-
If the selected candidate passes the visibility test, compute the final estimate. When (our case), , so the estimator simplifies to . However, computing the full sum requires evaluating all candidates. In practice, we select candidate with probability , then output . By importance sampling, this has the same expectation as the full sum but only requires evaluating one candidate (e.g., one visibility test instead of ). Using the this single-sample RIS variant with , the estimator is:
But this assumes with the full proposal PDF . In our implementation, we instead use where (missing the factor). To correct for this, we multiply by :
The term gives us the correct importance sampling ratio for the full proposal PDF.
When , , recovering standard emitter sampling: .
For the emitter sampling branch (RIS), we apply a MIS weight:
where is the full emitter sampling PDF (solid angle PDF divided by number of lights) and is the BSDF sampling PDF. The final RIS contribution becomes:
For the BSDF sampling branch, when a BSDF-sampled direction hits an emitter, we apply the complementary MIS weight:
and add:
For delta BSDFs (mirrors, glass), is a Dirac delta and emitter sampling cannot find specular paths, so we use for BSDF-sampled paths through specular surfaces.
Validation ↑
This Sponza scene features multiple area lights distributed throughout the space, creating complex lighting conditions where uniform light selection becomes inefficient. Many shading points receive significant contribution from only a subset of the lights (e.g., nearby lights or those with unobstructed paths), while distant or occluded lights contribute little.



RIS excels in this scenario by resampling candidates based on their actual contribution (BSDF emission geometry), effectively concentrating samples on the lights that matter most for each shading point. This leads to lower variance compared to MIS with uniform light selection at the same sample count.









Participating Media ↑
Phase Functions ↑
The phase function describes the angular distribution of light as it is scattered at a point within a medium. It is the volumetric analog of the BRDF for surfaces. Unlike a BRDF, which is normalized by a cosine factor and can integrate to values less than 1 (due to absorption), a phase function represents a pure probability density function (PDF) over the sphere of directions. Therefore, it must integrate to unity:
Description ↑
The phase functions implemented are the isotropic phase function and the henyey-greenstein phase function.
In isotropic scattering, light is scattered equally in all directions. This is the volumetric equivalent of a perfectly diffuse Lambertian surface. Since there are steradians in a unit sphere,
In many real-world media (like clouds or smoke), scattering is highly anisotropic, meaning it favors certain directions. Henyey-Greenstein is a popular empirical model used to simulate this behavior:
where is the angle between the incoming and outgoing directions. The parameter (the asymmetry parameter or mean cosine) defines the "shape" of the scattering:
- : Forward scattering (light continues mostly in the same direction).
- : Backscattering (light is reflected back toward the source).
- : Isotropic scattering (mathematically equivalent to the isotropic model).
The value of is defined as the average cosine of the scattering angle:
where . For example, if , the "average" photon is deflected only () from its original path.
Files created or modified:
include/nori/phasefunction.hsrc/isotropic.cppsrc/henyey_greenstein.cpp
Validation scenes:
include/nori/phasefunction.h
This file mirrors the BSDF interface for consistency. It includes a PhaseFunctionQueryRecord, similar to the BSDFQueryRecord, and supports two constructors of a PhaseFunctionQueryRecord -- one that takes in the incident direction for sampling the outgoing direction , and one that takes in both and for evaluation.
The PhaseFunction object is the superclass of all phase functions and has the methods sample and eval. Unlike BSDF which also has the pdf method, phase functions are PDFs themselves, hence eval will return the phase function value (i.e. the PDF). The sample method in PhaseFunction also returns the phase function value for convenience (instead of 1.0, which would be case in the BSDF implementation that returns eval() / pdf()).
Isotropic::sample(...)
Given a sample , we sample a direction uniformly on a sphere with Warp::squareToUniformSphere and assign this direction to pRec.wo. The method returns the phase function value is .
Isotropic::eval(...)
The phase function value is simply for any given pairs of and .
HenyeyGreensteinPhaseFunction::sample(...)
The azimuthal angle is simply since Henyey-Greenstein does not depend on it. For the polar angle, we need to find by inverting the CDF. The marginal PDF for is obtained by integrating over :
The CDF is obtained by integrating from to . Let , so :
Setting and solving for :
Squaring both sides and solving for :
For the special case (isotropic), we use uniform sphere sampling: .
Once and are computed, we build a coordinate frame around the forward scattering direction and convert from spherical to Cartesian coordinates to obtain .
HenyeyGreensteinPhaseFunction::eval(...)
The method simply computes for the given pair of and , and returns
Validation ↑
Transmittance in Homogeneous Media ↑
This feature enables coloured dielectric materials or liquid using Beer's law transmittance for homogeneous participating media.
Description ↑
By Beer's law, in a homogeneous participating media (where the absorption coefficient is constant), the transmittance of light is given by
where is the distance travelled.
Files created or modified:
include/nori/bsdf.hsrc/null.cppsrc/dielectric.cppsrc/path_mis.cppsrc/path_ris.cppsrc/photonmapper.cpp
Validation scenes:
include/nori/bsdf.h
The BSDF interface is extended to include absorption with default no absorption behaviour. New methods getAbsorptionCoefficient and hasAbsorption are added.
src/null.cpp
This is a null/transparent BSDF that lets rays pass through without any scattering or reflection. This is defined to bound fog volumes without any refractive distortion. It overrides the hasAbsorption and getAbsorptionCoefficient methods to return true. The null BSDF has discrete measure and returns zero for the eval and pdf methods. In the sample method, it sets to (since there is no change in ray direction) and to 1 (since there is no change in medium). It can be used in the scene like this:
<mesh type="obj">
<string name="filename" value="fog_box.obj"/>
<bsdf type="null">
<color name="sigmaA" value="0.5, 0.5, 0.5"/>
</bsdf>
</mesh>
src/dielectric.cpp
Homogeneous participating media is implemented for the dielectric BSDF. The dielectric material takes in a new Color3f member variable m_sigmaA, which stores the absorption coefficient per RGB channel. The methods getAbsorptionCoefficient and hasAbsorption are overridden.
PathMISIntegrator::Li(...)
In the path tracer with MIS, we use the flag insideMedium to track the medium state and mediumBSDF, which can be a null pointer or a BSDF, to track the current medium.
We first check if the camera is inside a medium by checking ray.d.dot(its.geoFrame.n). If the dot product is greater than zero, it means that the camera is inside a medium. We then get the absorption coefficient of the BSDF, compute the transmittance and multiply the throughput by the transmittance.
In both the emitter sampling and BSDF sampling branch (when the measure of the BSDF of the current intersection's mesh is discrete and the sampled direction is a refraction), we toggle the medium state and update mediumBSDF. If we are now inside a medium and the medium BSDF has non-zero absorption coefficients, then multiply the throughput by the transmittance.
Validation ↑


