I am trying to implement for research purposes a path tracer, but so far but results are not so good and I will explain you why. The general idea before getting to the code:
I am working on paths that are generated before sampling them. I mean that the first step of my algorithm consists in calculating a path for a certain pixel (x,y). This path will perform some bounces within the scene and, if terminates on the light, will be considered valid, which means that I can calculate its contribution for the pixel (x,y) which otherwise will be black.
But first, my aim is to reach an "acceptable" image with a few number of SPP (around 16-32 spp). This is because I am following the code of a target framework, easy implemented, that manages to reach such good results in few steps (explained later). The following is the target image that I want to reach, produced by the target framework:
It's rendered with 16SPP. The core code behind it is quite straightforward. It just implements the rendering equation and it is shown and commented below:
Vec Sample(vec3 O, vec3 D, int depth)//oriding, direction, depth of the recursive step
{
vec3 color(0, 0, 0);
float t;
if (checkIfIntersectSomething(D, t))
{
vec3 I = O + t * D; //get to the intersection point on the object
vec3 diffuse = getMaterial(I);
vec3 L = vec3(-1 + Rand(2.0f), 20, 9 + Rand(2.0f)) - I; //(-1,20,9) is Hard-code of the light position, and I add Rand(2.0f) on X and Z axis
//so that I have an area light instead of a point light
L = normalize(L);
float ndotl = dot(I.getNormal(), L); //the closer the dotProdutc is to 1.0, the more the light and surface face each other
if (ndotl > 0)
{
if (!checkRayLightIntersection(L)) {
float dist = distFromLight(I);
color += diffuse * ndotl * vec3(1000.0f, 1000.0f, 850.0f) * (1.0f / (dist * dist));
}
}
// russian roulette
float Psurvival = CLAMP((diffuse.r + diffuse.g + diffuse.b) * 0.33333f, 0.2f, 0.8f);
// continue random walk
float rand = Rand(1.0f);
if (depth < 10 && rand < Psurvival)
{
//Besides russian roulette, I also do another weight, because rays that go towards the horizon will bring back very little energy
//so I make a random distribution that favours those rays who are close to the normal of the hit point, this is DiffuseReflectionCosineWeighted(). It creates a Random bounce but proportional to N dot R
vec3 R = DiffuseReflectionCosineWeighted(I.getNormal());//there is a weight
float prob = 1.0;
float cosTheta = fabs(dot(I.getNormal(), R));
if (cosTheta > 1e-6) prob = cosTheta / M_PI;
color += diffuse * Sample(I + R * EPSILON, R, depth + 1) * (1.0f / Psurvival); //the cosTheta() of the attenuation of the rendering equation gets simplified with the cosTheta of the "prob"
//the PI of the prob gets simplified with the BRDF where we are using the ideal BRDF = diffuse/PI
}
}
return color;
}
The DiffuseReflectionCosineWeighted implies that we have to apply a PDF to our recursive step. This PDF is cosTheta/PI and we have to divide for it in:
color += diffuse * Sample(I + R * EPSILON, R, depth + 1) * (1.0f / Psurvival);
You can't see it in the code because it gets simplified witht the cosTheta attenuation term of the rendering equation and PI of the diffuse BRDF where my BRDF is indeed diffuse/PI because we want to consider only DIFFUSE objects. (For any question about the code do not hesitate to ask.)
I tried to emulate this code with the difference that I don't calculate the path at the moment I want to calculate the color of a pixel (like the target framework does above), but I calculate a path beforehand: My generatePath() method indeed tracks the path into the scene and checks all the vertices it hits. The checkIfRayIntersectSomething(t) method you will see used, it's just a pseudo method implemented in my framework and that I omit posting cause of its length. I use it to check if my ray hits something in the scene, if it does, it update the "t" with the distance to that object. NOTE: the light is not considered an object itself. Hence, I also have a checkRayLightIntersection(hitLightPoint) which checks the intersection with the light, if there is any, the hitLightPoint is updated with the point on the light I have been hitting. The light is a 2D surface of area 2x2 placed at the same position (-1,20,9), as the target framework does.
First the definition of a couple of struct to store some info:
struct PathVert {
vec3 p; vec3 n; //hit point and normal of the surface hit
};
struct Path {
PathVert verts[MAX_DEPTH]; //maxDepth is 15 for now
int vertCount;
int x, y; //which pixel this path is referring to
};
And now the main code:
bool GeneratePath(int x, int y, Path &path){
path.x = x;
path.y = y;
path.vertCount = 0;
vec3 P = renderData.p1 + renderData.dx * ((float)(x) + Rand(1)) + renderData.dy * ((float)(y) + Rand(1));
vec3 O = renderData.E + vec3(Rand(0.4f) - 0.2f, Rand(0.4f) - 0.2f, Rand(0.4f) - 0.2f);
vec3 D = normalize(P - O); //direction of the first ray, the one from the camera towards the pixel we are considering
for (int depth = 1; depth <= MAXDEPTH; depth++){
float t;
Vec hitLightPoint;
PathVert vert;
if (!checkIfRayIntersectSomething(t)){
//we didn't find any object.. but we still may have found the light which is an object non represented in the scene
//the depth check avoids me rendering the light as a white plane
if (depth > 1 && checkRayLightIntersection(O, D, hitLightPoint)){
//update the vertex since we realized it's the light
vert.p = hitLightPoint;
vert.n = Vec(0, -1, 0);//cause the light is pointing down
path.verts[depth - 1] = vert;
path.vertCount++;
return true; //light hit, path completed
}
return false; //nothing hit, path non valid
}
//otherwise I got a hit into the scene
vert.p = O + D * t; //reach the hitPoint
vert.n = methodToFindTheNormal();
vert.color = CalculateColor(vert.p); //according to the material properties (only diffuse objects so far)
path.verts[depth - 1] = vert;
path.vertCount++;
//since I have the light, and a path terminates when it hits the light, I have to check out also if my ray hits this light,
//and if does, I have to check whether it first hits the light or the object just calculated above
//moreover with the "depth > 1" check, I avoid again rendering the light which otherwise would be visible as a white plane
if (depth > 1 && checkRayLightIntersection(O, D, hitLightPoint)){
float distFromObj = length(vert.p - O);
float distFromLight = length(hitLightPoint - O);
if (distFromLight < distFromObj){
//update the vertex since we realized it's the light
vert.p = hitLightPoint;
vert.n = Vec(0, -1, 0);
vert.color = Vec(1, 1, 1);// TODO light color? or light emission?
path.verts[depth - 1] = vert;
return true; //light hit, path completed
}
}
if (depth == MAXDEPTH) return false;
Vec newDir = BSDFDiffuseReflectionCosineWeighted(vert.n, D);//explained later
D = newDir;
O = vert.p;
}
return false;
}
The BSDFDiffuseReflectionCosineWeighted() just calculates the new direction like in the target framework, tested and working. What remains last is the Sample method which calculates the final color of the pixel using the path calculated right above:
Vec Sampling(Path &path){
Vec color(1, 1, 1);
for (int vert = 0; vert < path.vertCount - 1; vert++) { //considers the last vertex as the light
const PathVert &currVert = path.verts[vert];
const PathVert &nextVert = path.verts[vert + 1];
Vec wo = (nextVert.p - currVert.p).norm();
double cosTheta = fabs(wo.dot(currVert.n));
float PDF = cosTheta/PI;
if (cosTheta <= 1e-6) return Vec();
//considering only DIFFUSE objects
color = color.mult(currVert.color * (cosTheta / M_PI) / PDF);
}
return color.mult(Vec(10.0f, 10.0f, 10.0f)); //multiplication for the light emission?
}
The problem is that the image is really dark, you can see how small the light is, but somehow in the target framework it works correctly while in mine:
This is because not many rays reach the light, so many paths are not considered valid. If instead I make my light broad (20x20) results are better but I lose the shadows:
I am sure that less rays reach the light cause I have been calculating them in both cases: small area light and big area light. I also tried to compensate the dark image by increasing the light contribution when reached by the path, but it brings to white pixels (for paths that reach the light) and the black ones (cause of invalid path) keep remaining black, so not a good approach. It seems that (as far as my understanding arrives) the target framework applies direct lighting, that's why such a good image BUT according to theory direct lighting only decrease variance, reducing the noise.. which means that using or not direct lighting shouldn't change the resulting image so much. Is there a way according to you to fix this problem? Thanks in advance.
Sample
function, there is the statementfloat ndotl = dot(I.getNormal(), L);
. Not sure, butI.getNormal()
looks a bit like it denotes the normalizedI
vector, instead of the surface normal at positionI
. $\endgroup$