by the Codermind team. |
|
Now that we've seen how to treat some simple surface properties, we will try to add a few more details to our scenes. One of the simplest way to add details is via the use of textures.
I won't cover the texturing in its full exhaustive details, but I will detail here two concepts, one is the procedural texture and the other one is the environment texture or cubemap.
This is the third part of our ray tracing in C++ article series. This one follows the one titled "Specularity, supersampling, gamma correction and photo exposure".
Perlin noise
Ken Perlin is one of the pionneers of the image synthesis field. He contributed to movies such as Tron, and his famous "noise" is one of the most used formula in the world of graphics. You can go and visit his webpage to learn more about his current project : http://mrl.nyu.edu/~perlin/. It's also on that website that you can find the source code for his noise algorithm that is remarkably simple. Here is the C++ version that I used and that is directly translated from the original version in java :
struct perlin
{
int p[512];
perlin(void);
static perlin & getInstance(){static perlin instance; return
instance;}
};
static int
permutation[] = { 151,160,137,91,90,15,
131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,
190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,
88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,
77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,
102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208,89,18,169,200,196,
135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,
5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,
23,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167,43,172,9,
129,22,39,253,19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,
251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,
49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127,4,150,254,
138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180
};
static double fade(double t)
{
return t * t * t * (t * (t * 6 - 15) + 10);
}
static double lerp(double t, double a, double b) {
return a + t * (b - a);
}
static double grad(int hash, double x, double y, double z) {
int h = hash & 15;
// CONVERT LO 4 BITS OF HASH CODE
double u = h<8||h==12||h==13 ? x : y, // INTO 12 GRADIENT DIRECTIONS.
v = h < 4||h == 12||h == 13 ? y : z;
return ((h & 1) == 0 ? u : -u) + ((h&2) == 0 ? v : -v);
}
double noise(double x, double y, double z) {
perlin & myPerlin = perlin::getInstance();
int X = (int)floor(x) & 255, // FIND UNIT CUBE THAT
Y = (int)floor(y) & 255, // CONTAINS POINT.
Z = (int)floor(z) & 255;
x -= floor(x); // FIND RELATIVE X,Y,Z
y -= floor(y); // OF POINT IN CUBE.
z -= floor(z);
double u = fade(x), // COMPUTE FADE CURVES
v = fade(y), // FOR EACH OF X,Y,Z.
w = fade(z);
int A = myPerlin.p[X]+Y, // HASH COORDINATES OF
AA = myPerlin.p[A]+Z, // THE 8 CUBE CORNERS,
AB = myPerlin.p[A+1]+Z,
B = myPerlin.p[X+1]+Y,
BA = myPerlin.p[B]+Z,
BB = myPerlin.p[B+1]+Z;
return
lerp(w, lerp(v, lerp(u, grad(myPerlin.p[AA], x, y, z), // AND ADD
grad(myPerlin.p[BA], x-1, y, z)), // BLENDED
lerp(u, grad(myPerlin.p[AB], x, y-1, z), // RESULTS
grad(myPerlin.p[BB], x-1, y-1, z))), // FROM 8
lerp(v, lerp(u, grad(myPerlin.p[AA+1], x, y, z-1), // CORNERS
grad(myPerlin.p[BA+1], x-1, y, z-1)),// OF CUBE
lerp(u, grad(myPerlin.p[AB+1], x, y-1, z-1 ),
grad(myPerlin.p[BB+1], x-1, y-1, z-1 ))));
}
perlin::perlin (void)
{
for (int i=0; i < 256 ; i++) {
p[256+i] = p[i] = permutation[i];
}
}
What this code does is to create a kind of pseudo-random noise, that can be visualized in one, two three dimensions. Noise that looks random enough is inevitable to break visible "patterns" if we were instead to rely on hand made content only. Ken Perlin also contributed some nice procedural models as we'll se with the following examples.
Procedural textures
If we were to populate our 3D world with objects, we would have to create geometry but also textures. Textures contain spatial data that we will use to modulate the existing appearance of our geometry. To have the sphere represent the Earth, there is no need to create geometric detail with the right color properties on the sphere. We can keep our simple sphere and instead map a diffuse texture on the surface that would modulate the lighting to the local property of the Earth (blue in the oceans, green or brown on the firm ground.). This is good enough in first approximation (as long as we don't zoom too much on it). The Earth textures would have to be authored. Most likely by a texture artist. This is generally true for all objects in a scene, detail needed to make it lifelike is too important to rely only on geometric detail (and the use of textures can also be used to compress geometric details, see displacement mapping).
Procedural textures are subset of textures. One that hasn't to be authored by a texture artist. Actually somebody still has to design them and place them on objects, but he wouldn't have to decide of the color of each of the texture component, the texels. That's the case because the procedural texture is generated with an implicit formula rather than fully described. Why would we want to do that ? Well there's a few advantages. Because the image is not fully described we can represent a lot more details without using a lot of storage. Actually some formulas (such as fractals) allow for infinite level of details, something that an hand drawing artist even full-time cannot achieve. There is also no limitations on the dimensions that the texture can have. For our Perlin noise, it's as efficient to have three dimensional textures as it is to have two dimensional textures. For a fully described textures, storage requirements would augment as N^3, and the authoring would be made much more difficult. Procedural textures can achieve cheap, infinite detail, that do not suffer from mapping problems (because we can texture three dimensional objects with a three dimensional texture instead of having to figure a UV mapping on the surface, for example have a twod image).
We can't use procedural textures everywhere. We are limited by the formulas that we have implemented, which can try to mimic an existing material, but as we've seen if we wanted to represent the Earth we would be better off with somebody working directly from a known image of the Earth. Also an advantage and inconvenient of procedural is that everything is computed in place. You don't compute what is not needed, but accelerating stuff by computing them offline can prove more difficult (you can do so but you lose the quality over storage advantage). Procedural content has still a lot of things to say in the near/far future because labor intensive methods can only get you as far as you have access to that cheap labor. Also you shouldn't base your impression of what procedural can do for you only on those examples, as the field has led to many applications that are better looking and more true to nature as what I'm presenting in this limited tutorial. But let's stop the digression now.
Turbulent texture
Our code now contains a basic implementation of an effect that Ken Perlin presented as an application of his noise, the turbulent texture. The basic idea of this texture and several other similar ones is that you can easily add details to the basic noise, by combining several versions of it at different resolutions. Those are called "harmonics", like the multiple frequencies that compose a sound. Each version is attenuated so that the result is not pure noise but rather adds some additional shape to the previous low resolution texture.
case material::turbulence:
{
for (int level = 1; level < 10; level ++)
{
noiseCoef += (1.0f / level )
* fabsf(float(noise(level * 0.05 * ptHitPoint.x,
level * 0.05 * ptHitPoint.y,
level * 0.05 * ptHitPoint.z)));
};
output = output + coef * (lambert * currentLight.intensity)
* (noiseCoef * currentMat.diffuse + (1.0f - noiseCoef) * currentMat.diffuse2);
}
break;
The noise itself is used to compute the interpolation coeficient between two diffuse colors. But it doesn't have to be limited to that, as we'll see later.
Marble textures
By taking the previous formula and just inputting it back to another formula involving a sinusoidal function you can obtain a differently looking effect, a pseudo-marble texture.
case material::marble:
{
for (int level = 1; level < 10; level ++)
{
noiseCoef += (1.0f / level)
* fabsf(float(noise(level * 0.05 * ptHitPoint.x,
level * 0.05 * ptHitPoint.y,
level * 0.05 * ptHitPoint.z)));
};
noiseCoef =
0.5f * sinf( (ptHitPoint.x + ptHitPoint.y) * 0.05f + noiseCoef) + 0.5f;
output = output
+ coef * (lambert * currentLight.intensity)
* (noiseCoef * currentMat.diffuse
+ (1.0f - noiseCoef) * currentMat.diffuse2);
}
break;
Those are two simple examples that you could take and complexify as you will. You don't necessarily have to use a Perlin noise to generate procedural textures, other models exist (look for fractal, random particle deposition, checkerboards etc.).
Bump mapping
The textures above play on the diffusion term in the Lambert formula to make the surface more interesting, but we can play on more terms than that. The idea behind this bump mapping is that you can often guess the shape of a surface via its lighting rather than its silhouette. for example a very flat surface would have almost uniform lighting, whereas a bumpy surface, would see its lighting vary between dark and lighter areas. The thing is that our eye has become very good at decoding light variations, especially from diffuse lighting, so that a simple gradient in an image can make us see a shape that is not there. Of course in image synthesis it doesn't really matter which way the lighting has been computed, be it by real geometry or by other mean.

The lighting reacts to the normal directions, but if the bumps are not there we can still alter the normals themselves. Our code that follows will read the scale of the bumps in our scene file, then will displace the normal of the affected surface material by a vector that is computed from the Perlin noise. Before doing actual lighting computation we make sure our normal is "normalized", that meant that his length is one, so that we don't get distortion in the lighting (we don't want amplification, just perturbation). If the length isn't one, we correct that by taking its current length and dividing the current normal (all three normal coordinates) by it. By definition the result will have a length of one (and it keeps the same direction as the original).
if (currentMat.bump)
{
float noiseCoefx =
float(noise(0.1 * double(ptHitPoint.x),
0.1 * double(ptHitPoint.y),
0.1 * double(ptHitPoint.z)));
float noiseCoefy =
float(noise(0.1 * double(ptHitPoint.y),
0.1 * double(ptHitPoint.z),
0.1 * double(ptHitPoint.x)));
float noiseCoefz =
float(noise(0.1 * double(ptHitPoint.z),
0.1 * double(ptHitPoint.x),
0.1 * double(ptHitPoint.y)));
vNormal.x = (1.0f - currentMat.bump )
* vNormal.x + currentMat.bump * noiseCoefx;
vNormal.y = (1.0f - currentMat.bump )
* vNormal.y + currentMat.bump * noiseCoefy;
vNormal.z = (1.0f - currentMat.bump )
* vNormal.z + currentMat.bump * noiseCoefz;
temp = vNormal * vNormal;
if (temp == 0.0f)
break;
temp = invsqrtf(temp);
vNormal = temp * vNormal;
}
The sphere on top of the image below uses a Lambertian material without any texture. The second half on bottom has its normal perturbated by a perlin noise. If the light was moving in this image, the light on the bump would be affected as naturally as with natural bumps. No additional geometric detail was needed below. The illusion is kind of broken on the silhouette of the sphere as the silhouette of the bottom half remains as flat as the non bumpy version.

Cubic environment mapping
The image synthesis world is all about illusion and shortcuts. Instead of having the idea of rendering the whole world that surrounds our scene, here is a much better idea. We can imagine that instead of being in a world full of geometries, light sources and so on, we are inside a big sphere (or a surrounding object), big enough to contain our whole scene. This sphere has some image projected on it and this image is the view of the world in all existing directions. From our rendering point of view, as long as the following properties are true, it doesn't matter where we are : the environment is far enough that small translation movements in the scene won't alter the view in a significant way (no parallax effect) and the outside environement is static and doesn't move by itself. That certainly wouldn't fit every situations but there are still some cases where this applies.
Well, in my case, I'm just interested to introduce the concept to you and to get cheap backgrounds without having to trace rays into that. The question is how do you map the environment to the big sphere ? There are several answers to that question but a possible answer is through cube mapping. So instead of texturing a sphere we'll texture a cube.
Why a cube ? There are multiple reasons. The first is that it doesn't really matter what shape that environment has. It could be a cube, a sphere, a dual paraboloid, in the end all that matters is that if you send a ray towards that structure you will get the right color/light intensity as if you were in the environment simulated by that structure. Second is that rendering to a cube is very simple, each face is similar to our planar projection, the cube being constituted of six different planar projections. Also storage is easy, faces of the cube are two dimensional arrays that can be stored easily and with a limited distortion. Here are the six faces of the cubes put end to end :
Not only that but also pointing a ray to that cube environment and finding the corresponding color is dead easy. We have to find first which face we're pointing at but that's pretty simple with basic math as seen in the code below :
color readCubemap(const cubemap & cm, ray myRay)
{
color * currentColor ;
color outputColor = {0.0f,0.0f,0.0f};
if(!cm.texture)
{
return outputColor;
}
if ((fabsf(myRay.dir.x) >= fabsf(myRay.dir.y))
&& (fabsf(myRay.dir.x) >= fabsf(myRay.dir.z)))
{
if (myRay.dir.x > 0.0f)
{
currentColor = cm.texture + cubemap::right * cm.sizeX * cm.sizeY;
outputColor = readTexture(currentColor,
1.0f - (myRay.dir.z / myRay.dir.x+ 1.0f) * 0.5f,
(myRay.dir.y / myRay.dir.x+ 1.0f) * 0.5f, cm.sizeX, cm.sizeY);
}
else if (myRay.dir.x < 0.0f)
{
currentColor = cm.texture + cubemap::left * cm.sizeX * cm.sizeY;
outputColor = readTexture(currentColor,
1.0f - (myRay.dir.z / myRay.dir.x+ 1.0f) * 0.5f,
1.0f - ( myRay.dir.y / myRay.dir.x + 1.0f) * 0.5f,
cm.sizeX, cm.sizeY);
}
}
else if ((fabsf(myRay.dir.y) >= fabsf(myRay.dir.x)) && (fabsf(myRay.dir.y) >= fabsf(myRay.dir.z)))
{
if (myRay.dir.y > 0.0f)
{
currentColor = cm.texture + cubemap::up * cm.sizeX * cm.sizeY;
outputColor = readTexture(currentColor,
(myRay.dir.x / myRay.dir.y + 1.0f) * 0.5f,
1.0f - (myRay.dir.z/ myRay.dir.y + 1.0f) * 0.5f, cm.sizeX, cm.sizeY);
}
else if (myRay.dir.y < 0.0f)
{
currentColor = cm.texture + cubemap::down * cm.sizeX * cm.sizeY;
outputColor = readTexture(currentColor,
1.0f - (myRay.dir.x / myRay.dir.y + 1.0f) * 0.5f,
(myRay.dir.z/myRay.dir.y + 1.0f) * 0.5f, cm.sizeX, cm.sizeY);
}
}
else if ((fabsf(myRay.dir.z) >= fabsf(myRay.dir.x))
&& (fabsf(myRay.dir.z) >= fabsf(myRay.dir.y)))
{
if (myRay.dir.z > 0.0f)
{
currentColor = cm.texture + cubemap::forward * cm.sizeX * cm.sizeY;
outputColor = readTexture(currentColor,
(myRay.dir.x / myRay.dir.z + 1.0f) * 0.5f,
(myRay.dir.y/myRay.dir.z + 1.0f) * 0.5f, cm.sizeX, cm.sizeY);
}
else if (myRay.dir.z < 0.0f)
{
currentColor = cm.texture + cubemap::backward * cm.sizeX * cm.sizeY;
outputColor = readTexture(currentColor,
(myRay.dir.x / myRay.dir.z + 1.0f) * 0.5f,
1.0f - (myRay.dir.y /myRay.dir.z+1) * 0.5f, cm.sizeX, cm.sizeY);
}
}
return outputColor;
}
cubemap is a structure defined by us that contains all the necessary informations related to the six textures that make the cube.
Once in the face, finding the texel and reading the color is done in a classic way. Each point of the texture is identified by a set of coordinate between 0 and 1. Each time it is called, the readTexture function will find the four texels closer to our direction in its array of data and do a bilinear interpolation between the color of those four points.
As a reminder, for four points P0, P1, P2, P3, the bilinear interpolation on a point situated on the square is given by the following formula :
This is then translated in our code by the function called readTexture described below :
color readTexture(const color* tab, float u, float v, int sizeU, int sizeV)
{
u = fabsf(u);
v = fabsf(v);
int umin = int(sizeU * u);
int vmin = int(sizeV * v);
int umax = int(sizeU * u) + 1;
int vmax = int(sizeV * v) + 1;
float ucoef = fabsf(sizeU * u - umin);
float vcoef = fabsf(sizeV * v - vmin);
// The texture is being addressed on [0,1]
// There should be an addressing type in order to
// determine how we should access texels when
// the coordinates are beyond those boundaries.
// Clamping is our current default and the only
// implemented addressing type for now.
// Clamping is done by bringing anything below zero
// to the coordinate zero
// and everything beyond one, to one.
umin = min(max(umin, 0), sizeU - 1);
umax = min(max(umax, 0), sizeU - 1);
vmin = min(max(vmin, 0), sizeV - 1);
vmax = min(max(vmax, 0), sizeV - 1);
// What follows is a bilinear interpolation
// along two coordinates u and v.
color output =
(1.0f - vcoef) *
((1.0f - ucoef) * tab[umin + sizeU * vmin]
+ ucoef * tab[umax + sizeU * vmin])
+ vcoef *
((1.0f - ucoef) * tab[umin + sizeU * vmax]
+ ucoef * tab[umax + sizeU * vmax]);
return output;
}
Once we've got that code we simply need to hook it up to the default case of our raytracer when we don't hit any of the scene geometry :
if (currentSphere == -1)
{
// No geometry hit, instead we simulate a virtual environment
// by looking the color in a environment cube map.
output += coef * readCubemap(myScene.cm, viewRay);
break;
}
Annex I : Auto exposure
One of the drawbacks of the previous exposure function that we were using was that the exposure ratio was constant. But one of the property of high dynamic range image is that the actual useful range of an image cannot be predicted easily, leading to an image underexposed or overexposed. This isn't flexible enough, so we'll introduce a function that does an automatic exposure determination. The result is less than perfect and auto exposure could be the topic of an article series of its own so I won't digress too much. The below code works "well enough" and can be easily tweaked.
float AutoExposure(scene & myScene)
{
#define ACCUMULATION_SIZE 16
float exposure = -1.0f;
float accumulationFactor = float(max(myScene.sizex, myScene.sizey));
accumulationFactor = accumulationFactor / ACCUMULATION_SIZE;
color mediumPoint = 0.0f;
const float mediumPointWeight = 1.0f / (ACCUMULATION_SIZE*ACCUMULATION_SIZE);
for (int y = 0; y < ACCUMULATION_SIZE; ++y) {
for (int x = 0 ; x < ACCUMULATION_SIZE; ++x) {
ray viewRay = { {float(x) * accufacteur,
float(y) * accufacteur, -1000.0f},
{ 0.0f, 0.0f, 1.0f}};
color currentColor = addRay (viewRay, myScene);
float luminance = 0.2126f * currentColor.red
+ 0.715160f * currentColor.green
+ 0.072169f * currentColor.blue
mediumPoint += mediumPointWeight * (luminance * luminance);
}
}
float mediumLuminance = sqrtf(mediumPoint);
if (mediumLuminance > 0.001f)
{
// put the medium luminance to an intermediate gray value
exposure = logf(0.6f) / mediumLuminance;
}
return exposure;
}
Annex II : Corrections on texture read
A texture like our cubic environment map is a light source like another one and should be controled finely as well. First the storage of our texture usually happen after the original data has been exposed (if it is a 8 bits per component image) and is encoded as an sRGB image (because it is adapted to the viewing conditions on your PC). But internally all our lighting computations happen in a linear space and before exposure took place. If we don't take those elements into account, reading those images and integrating them in our rendering will be slightly off.
So in order to take that into account, each sample read from our cube map is corrected as this :
if (cm.bsRGB)
{
// We make sure the data that was in sRGB storage mode is brought back to a
// linear format. We don't need the full accuracy of the sRGBEncode function
// so a powf should be sufficient enough.
outputColor.blue = powf(outputColor.blue, 2.2f);
outputColor.red = powf(outputColor.red, 2.2f);
outputColor.green = powf(outputColor.green, 2.2f);
}
if (cm.bExposed)
{
// The LDR (low dynamic range) images were supposedly already
// exposed, but we need to make the inverse transformation
// so that we can expose them a second time.
outputColor.blue = -logf(1.001f - outputColor.blue);
outputColor.red = -logf(1.001f - outputColor.red);
outputColor.green = -logf(1.001f - outputColor.green);
}
outputColor.blue /= cm.exposure;
outputColor.red /= cm.exposure;
outputColor.green /= cm.exposure;
Here is the output of our program with the notions that we explained in this page :

In order to compile the source code for this third page, you don't need any particular additional library. You just need a C++ compiler coming with the standard C++ library. Tested with the GCC 3.4.4 and Visual Studio .net/2005/2008. Decompress the .rar file with Winrar.
Get the source code of the raytracer in C++. Source code for the third page explanations only.
For this page and the following ones you will also need the extra files in the archive called textures.rar (3 MB). It contains the cube map files used to render those images.
To page 4 : "Depth of field, Fresnel and blobs".




