6
$\begingroup$

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:

Target image

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:

Dark render

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:

enter image description here

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.

$\endgroup$
3
  • 1
    $\begingroup$ In your Sample function, there is the statement float ndotl = dot(I.getNormal(), L);. Not sure, but I.getNormal() looks a bit like it denotes the normalized I vector, instead of the surface normal at position I. $\endgroup$
    – matz
    Apr 25, 2016 at 8:03
  • $\begingroup$ I know this is an old question but have you found a solution? $\endgroup$ Oct 26, 2020 at 22:08
  • $\begingroup$ @HenryLeBerre my apologies Henry but unfortunately I cannot help you. It's been years since I touched that project and I don't work in the field anymore so I don't even remember if eventually I solved it or not.. good luck! $\endgroup$
    – Tarta
    Oct 27, 2020 at 9:46

1 Answer 1

5
$\begingroup$

A note first

From the look of your screen capture, I suspect there might still be a bug in your code. Noise is to be expected with only 16 spp, but your picture still looks surprisingly dark to me. For comparison, here is what my implementation of SmallPT looks like with 16 spp, 15 bounces, and no next event prediction:

SmallPT, 16 spp, 15 bounces, no next event predicition (here it is on ShaderToy)

Noisy, but not nearly as dark as your picture. It is very different scene, but considering the that the shape is similar (a box with one side open and only one light source), I would expect to get a similar luminosity/noise appearance.

The rest of this answer assumes your code to be correct.


Path tracing and reducing noise

The balance between light source size, noise and SPP is a classic problem, to which there is no magical solution. From easiest to hardest, you could:

  • Use a scene that is more suitable to your algorithm:

    Rendering algorithms have limitations, may shine in certain cases and poorly handle other cases. Knowing the limitations of yours, you can choose to restrict its use to a case that works best for it: a large light source, a sky dome, many light sources, a scene with less occlusion, etc.

  • Implement next event prediction:

    When you know where your light sources are, at each bounce you can try and shoot rays directly toward them. If the ray doesn't hit other geometry first, you get a path that contributes. Since doing so biases the randomness, you will need to use proper factors to take it into account.

  • Implement Bi-Directional Path Tracing:

    When tracing rays from the camera, if the light source is small, a large number of paths will never reach it. This is the problem you are having. On the opposite, when tracing rays from the light, many of them will never reach the camera. BDPT consists in first shooting rays both from the camera and from the light source, then trying to connect them. Each pair that can be connected is a contributing path. BDPT will significantly reduce noise.

  • Implement Metropolis Light Transport:

    If there is a path between the camera and a light source, it is likely there will be a similar path between them. MLT consists in searching for those contributing paths in the neighborhood of paths that are already known to contribute by mutating them (adding, removing or changing one or more bounces). This is a lot less trivial already, as the math to keep it unbiased gets a bit hairy.

The problem of reducing noise is still under active research though, and there is no silver bullet. At this point you will need to read what are the latest developments.

$\endgroup$
1
  • $\begingroup$ Thanks for your answer Julien! I do agree with you, most likely there is some error in the code.. I am not sure whether I am doing some wrong calculation or there is some error at the level of the algorithm itself. That's why I posted my code.. unfortunately I tried to implement some variance reduction algorithm like next event prediction.. the results are not better. Indeed those algorithm should give back a "better" image, but something is wrong at the base $\endgroup$
    – Tarta
    Apr 21, 2016 at 7:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.