This section includes the experiences of implementing the advanced lighting and HDR imaging support.
In order to achieve these mapping, we need to be able to read and write HDR images in EXR format. I added the TinyEXR library in the GitHub repo. In order to create a HDR image, we just need to write the float pixel values without clamping in EXR format.
Tone mapping features are given in the new field . Our ray tracer will support Photographic Tone Mapping which is specified in the field of . The options of tonemap algorithm are given in the . For the Photographic Tone Mapping, the first value is the image key value explained in the paper by Reinhard et. al. The second value is the percent of pixels to be burned. L_white specified in the paper is computed from this by sorting the pixels with respect to their luminance, then computing the given percentile. The luminance in this percentile will be used as L_white. The field of indicates how colorful we want the final image to be. Finally, is the parameter of the Gamma Correction algorithm.
<Scene>
<Cameras>
<Camera id="1" type="lookAt">
<Tonemap>
<TMO>Photographic</TMO>
<TMOOptions>0.18 1</TMOOptions>
<Saturation>1.0</Saturation>
<Gamma>2.2</Gamma>
</Tonemap>
</Camera>
</Cameras>
</Scene>
The Camera class will get new methods as given below.
Class Camera
...
bool tonemap
vec2 tmoOptions
float saturation
float gamma
Tone mapping is applied just before writing the float pixel values as a HDR image. I follow the Reinhard Photographic Tone Mapping algorithm as given below.
Class Scene
function toneMapping (camera):
1. lum_in <- convert the pixel_color to luminance
2. // Equation 1
3. lum_w_hat <- the summation of the log luminance with small epsilon
4. lum_w_hat <- exp(lum_w_hat / n)
5. // Equation 2
6. lum_scaled <- camera.key_value * lum_in / lum_w_hat
7. sorted_lum_scaled <- sort(lum_scaled)
8. lum_white <- sort(lum_scaled)[(n-1) * (100-camera.burnPercent) / 100]
9. // Equation 4
10. lum_d <- lum_scaled * (1 + lum_scaled / (lum_white*lum_white)) / (1+lum_scaled)
11. rgb = lum_d * pow((pixel_color / lum_in), camera.saturation)
12. color = pow(rgb, 1 / camera.gamma) * 255
13. return color
Finally, we can write the tone mapped color in EXR format.
We got the scene on the left without tone mapping. In my first tries, the ray tracer produced the scene in the right. It includes the pale cube and the burning did not look correct.
Then, I realized that I computed the white luminance (lum_white) by using the initial luminance (lum_in) instead of scaled ones. After fixing it, I got the scene on the left. The environment looked correct but the cube was still pale. Finally, I applied the degamma method to the material of the cube so that the cube looks more colorful as given in the right.
This section includes the implementation of point light, area light, directional light, spherical directional (environment) light and finally spot light.
Lights' features are defined in the XML file as below.
<Scene>
<Lights>
<AmbientLight>7.5 7.5 7.5</AmbientLight>
<PointLight id="1">
<Position> -4.4391 1.50656 -4.44377</Position>
<Intensity>1000 1000 1000</Intensity>
</PointLight>
<AreaLight id="1">
<Position>0 9.8 2</Position>
<Normal>0 -1 0</Normal>
<Size>3</Size>
<Radiance>150000 150000 150000</Radiance>
</AreaLight>
<DirectionalLight id="1">
<Direction>1 -0.8 -1</Direction>
<Radiance>200 200 200</Radiance>
</DirectionalLight>
<SphericalDirectionalLight id="1">
<ImageId>2</ImageId>
</SphericalDirectionalLight>
<SpotLight id="1">
<Position>-0.93 1 0.9</Position>
<Direction>1 -1 -1</Direction>
<Intensity>600 600 600</Intensity>
<CoverageAngle>10</CoverageAngle>
<FalloffAngle>8</FalloffAngle>
</SpotLight>
</Lights>
</Scene>
Light class is extended with new features and methods.
Class TextureMap
vec3 position
vec3 intensity
vec3 normal
vec3 radiance
vec3 direction
vec3 spotDirection
vec3 u, v
float coverageAngle
float falloffAngle
float size
int imageID
int type //0:PointLight,
//1:AreaLight,
//2:DirectionalLight,
//3:SphericalDirectionalLight
//4:SpotLight
TextureMap* texture
function getDirection(pHit, obj_normal)
function illuminance(ray, obj_normal, obj_material)
All lighting features are combined in the light class and will be explained in the coming parts. Before that, let's examine the lighting methods called from our basic shading function as below. Here the difference from the previous sections, shadow can be checked for the lights coming from the infinity such as directional and Spherical directional lights. In these kinds of lights, our light direction will be a normalized vector so that we cannot check the shadow distance in the range of [0-1]. Instead, we can check the shadow by looking at the positive intersection distance. In order to switch this feature, I send a new boolean flag, inf, to the isShadow function.
Class Scene
function shading (object, ray, pHit, normal):
1. ... // previously
2. for each light in lights:
3. direction <- light.getDirection(pHit, normal)
4. origin <- pHit + normal*shadowRayEpsilon
5. inf <- lights.isDirectional() ? true : false
6. shadowRay <- Ray(direction, origin)
7. shadow <- isShadow(object, shadowRay, inf)
8. if (!shadow):
9. color <- color + lights.illuminance(ray, normal, material)
10. return color
Radiance of the point light can be computed by dividing the intensity to the distance between hit point and the light source. After finding the radiance all shading functions can be applied.
Class Light
function getDirection (pHit, normal):
1. ... // previously
2. if type is PointLight:
3. light.direction <- light.position - pHit
4. return light.direction
Class Light
function illuminance (ray, normal, material):
1. ... // previously
2. if type is PointLight:
3. radiance <- light.intensity / dot(direction, direction)
4. ... // shading operations
5. return color
In the area light, we generate a random point in the plane light. This point will represent the whole light. Thus, we can compute the direction from this point to the hit_point of the object. Note that this direction should be not normalized to compute the shadow correctly.
Class Light
function getDirection (pHit, normal):
1. ... // previously
2. if type is AreaLight:
3. x <- generate a number in [-0.5, 0.5]
4. y <- generate a number in [-0.5, 0.5]
5. point <- x*light.size*light.u + y*light.size*light.v + light.position
6. light.direction <- point - pHit
7. return light.direction
Once getting the direction of the light, we can compute the declination by looking at the angle between the normal of the light source and the light direction to the object. In order to compute the radiance, we need to multiply the light intensity with the integral of the area. Note that, we choose a point to represent the whole light source.
Class Light
function illuminance (ray, normal, material):
1. ... // previously
2. if type is AreaLight:
3. declination <- dot(normal, normalize(-light.direction))
4. // flip the normal if it is in the opposite direction
5. if declination < 0:
6. declination <- max(dot(-normal, normalize(-direction)), 0)
7. area <- light.size * light.size
8. distance <- dot(light.direction, light.direction)
9. radiance <- light.intensity * area * declination / distance
10. ... // shading operations
11. return color
Directional lights have a direction with a radiance and they come from infinity. Thus, we just send its direction in the getDirection method.
Class Light
function getDirection (pHit, normal):
1. ... // previously
2. if type is DirectionalLight:
3. return light.direction
Similarly, we don't need to compute the radiance. Instead, we just use the given radiance of the light in the shading.
Class Light
function illuminance (ray, normal, material):
1. ... // previously
2. if type is DirectionalLight:
3. radiance <- light.radiance
4. ... // shading operations
5. return color
In spherical directional light, we generate a vector in the upper hemisphere. This vector will represent the light direction.
Class Light
function getDirection (pHit, normal):
1. ... // previously
2. while true:
3. if type is SphericalDirectionalLight:
4. x <- generate a number in [-1, 1]
5. y <- generate a number in [-1, 1]
6. z <- generate a number in [-1, 1]
7. direction <- Direction(x, y, z)
8. if dot(direction, direction) <= 1 and dot(direction, normal) > 0:
9. light.direction <- normalize(direction)
10. break
11. return light.direction
We have used the light direction to get the radiance value from the lighting texture. Note that this radiance is just from the one sample and should be generalized (i.e. getting the expected value of the radiance) by multiplying the probability as below.
Class Light
function illuminance (ray, normal, material):
1. ... // previously
2. if type is SphericalDirectionalLight:
3. texCoord.s <- 0.5 - atan2(direction.z, direction.x) * (1 / 2 PI)
4. texCoord.t <- acosf(direction.y) * (1 / PI)
5. radiance <- light.texture->getColor(texCoord) * 2 * PI
6. ... // shading operations
7. return color
Spot light has its own direction as given below so that we can just send its direction in the getDirection method.
Class Light
function getDirection (pHit, normal):
1. ... // previously
2. if type is SpotLight:
3. return light.direction
Spot lights have different radiance in three conditions. The radiance will be the same as the radiance of point light when the angle between the direction from the object and the original direction of the spotlight is less than half of the falloff angle. It decreases its radiance outside of this angle until half of the coverage angle. Finally, outside of the coverage angle, the radiance will be zero.
Class Light
function illuminance (ray, normal, material):
1. ... // previously
2. if type is SpotLight:
3. dir1 <- normalize(light.spotDirection)
4. dir2 <- normalize(-light.direction)
5. declination <- acos(dot(dir1, dir2));
6. if declination < falloffAngle / 2:
7. radiance <- intensity / dot(light.direction, light.direction)
8. else if declination < coverageAngle / 2:
9. radiance <- intensity / dot(light.direction, light.direction)
10. radiance <- radiance * pow((cos(declination) - cos(coverageAngle/2))
11. radiance <- radiance / (cos(falloffAngle/2.) - cos(coverageAngle/2)), 4)
12. else:
13. radiance <- 0
14. ... // shading operations
15. return color
I faced some problems by implementing the area light. In my first implementation, I miscalculated the direction of the lights as given in the left. After fixing the light direction, I used the object normal instead of the normal of the light source in the computation of declination by accident (in the right).
However, fixing this problem led to losing the light source in the scene. I realized that using the light normal in one direction causes to not illumination on the object placed in the other direction (e.g. the upper plane of the box). This can be seen in the left image. In order to fix it, I flipped the normal of the light for this kind of object. Finally, the correct scene is shared in the right.
Let's look at the final results of my implementation after all improving.
```markdown XML file is parsed in 0 sec Maximum BVH depth is 1 Preprocessing is finished in 0 sec Scene is created in 62 sec ``` ```markdown XML file is parsed in 1 sec Maximum BVH depth is 1 Preprocessing is finished in 0 sec Scene is created in 0 sec ``` ```markdown XML file is parsed in 0 sec Maximum BVH depth is 1 Preprocessing is finished in 0 sec Scene is created in 1 sec ``` ```markdown XML file is parsed in 0 sec Maximum BVH depth is 1 Preprocessing is finished in 0 sec Scene is created in 1 sec ``` ```markdown XML file is parsed in 4 sec Maximum BVH depth is 19 Preprocessing is finished in 31 sec Scene is created in 141 sec ``` ```markdown XML file is parsed in 1 sec Maximum BVH depth is 12 Preprocessing is finished in 0 sec Scene is created in 1395 sec ``` ```markdown XML file is parsed in 0 sec Maximum BVH depth is 1 Preprocessing is finished in 0 sec Scene is created in 1 sec ``` ```markdown XML file is parsed in 0 sec Maximum BVH depth is 19 Preprocessing is finished in 1 sec Scene is created in 234 sec ```