3
$\begingroup$

I am using the book Ray Tracing from the Ground Up by Kevin Suffern to build my first ray tracers in Java. Based on the theory, I expected that direct illumination and simple path tracers render the same scene equally bright (that is, when the resulting images are more of less converged and the path tracer only allows paths of maximal length 2). However, my implementation for a simple path tracer renders much darker images. Here is an example:

Direct illumination Path tracer

The images on the left and right are respectively the result of direct illumination and path tracing with 225 rays per pixel. My path tracer does not use next event estimation; it returns black whenever the path becomes longer than a certain length without reaching the area light. For every pixel, the received radiances are averaged afterwards.

When I manually change the sensitivity, though (i.e. a simple post rendering scaling of the colours in the image), the path traced image becomes this:

Lighter version of path tracer

Of course, the brightened image is not near convergence, but it should at least be clear that the path tracer actually renders the same scene similarly.

Is this normal behaviour? If so, could I just pick a larger power for the light source when using a path tracer? If not, please find some relevant code of my path tracer underneath.


This is my code for shading an intersection point of a ray and a matte object. It is supposed to shoot an arbitrary ray from the intersection point along a cosine distribution (I have used a branch factor 1 for my images).

public RGBSpectrum shadePath(Shading shading, BranchPathTracer tracer, int length) {
    int factor = tracer.getBranchFactor();
    cosinusSampler sampler = new cosinusSampler(factor);
    List<Point> samples = sampler.computeSamples();
    RGBSpectrum L = RGBSpectrum.BLACK;

    for (Point sample : samples) {
        Vector w = shading.getIntersectionPoint().getNormal();
        Random PRNG = new Random();
        Vector r = new Vector(PRNG.nextDouble(), PRNG.nextDouble(), PRNG.nextDouble());
        Vector v = r.cross(w).normalize();
        Vector u = v.cross(w);
        Vector wi = u.scale(sample.x).add(v.scale(sample.y)).add(w.scale(sample.z)).normalize();
        Ray ray = new Ray(shading.getIntersectionPoint().toPoint(shading.getRay()), wi);
        L = L.add(tracer.trace(ray, length + 1));
    }

    L = L.scale(1.0 / factor);
    return shading.getColour().scale(getDiffuseReflection()).multiply(L);
}

Here is how the cosine distributed samples are computed:

public List<Point> computeSamples() {
    List<Point> samples = new ArrayList<>();
    for (int i = 0; i < getSamples(); i++) {
        double phi = 2 * Math.PI * Math.random();
        double cosPhi = Math.cos(phi);
        double sinPhi = Math.sin(phi);
        double cosTheta = Math.sqrt(1 - Math.random());
        double sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
        double pu = sinTheta * cosPhi;
        double pv = sinTheta * sinPhi;
        double pw = cosTheta;
        Point sample = new Point(pu, pv, pw);
        samples.add(sample);
    }
    return samples;
}

This is the shading for emissive materials. showPath returns true by default.

public RGBSpectrum shadePath(Shading shading, BranchPathTracer tracer, int length) {
    Ray ray = shading.getRay();
    Vector n = shading.getIntersectionPoint().getNormal();

    boolean showPath = tracer.getShowOnlyLength() == 0 || length == tracer.getShowOnlyLength();
    if (n.dot(ray.direction) < 0 && showPath) {
        return getTexture().getColour().scale(getPower());
    } else {
        return RGBSpectrum.BLACK;
    }
}

For reference, here is also my shading function for matte objects under direct illumination.

public RGBSpectrum shade(Shading shading) {
    RGBSpectrum colour = shading.getColour();
    double L = 0;
    final Point intersection = shading.getIntersectionPoint().toPoint(shading.getRay());

    for (Light light : shading.getScene().getLights()) {
        double l = 0;

        for (Point sample : light.computeSamples()) {
            Vector wi = sample.subtract(intersection);
            double cos = shading.getIntersectionPoint().getNormal().dot(wi);

            if (cos > 0) {
                Ray shadowRay = new Ray(intersection, wi);

                if (!light.inShadow(shadowRay, shading.getScene())) {
                    if (light instanceof PointLight) {
                        double denom = 4 * Math.PI * wi.lengthSquared();
                        l += light.getPower() * cos / denom;
                    } else if (light instanceof RectangularLight) {
                        Rectangle rectangle = ((RectangularLight) light).getRectangle();
                        double cosp = -wi.dot(rectangle.getNormal());
                        l += light.getPower() * cos * cosp / (wi.lengthSquared() * rectangle.getArea());
                    }
                }
            }
        }

        L += l / light.getSamplesNumber();
    }

    return colour.scale(getAmbientReflection() + L * getDiffuseReflection());
}

Note: I started to randomly change things in my code as a last desparate attempt to find the cause for my dark images. Funnily enough I found out that averaging over the square root of the number of rays per pixel (i.e. change n * n into n in the code underneath) seems to result in the correct brightness. Maybe this is a coincidence, because I don't use the number of rays per pixel anywhere else. Or maybe I'm still overlooking something.

// iterate over the contents of the tile
for (int y = tile.yStart; y < tile.yEnd; y++) {
    for (int x = tile.xStart; x < tile.xEnd; x++) {
        RGBSpectrum colour = scene.getBackground();
        Random PRNG = new Random(x * width + y);

        // iterate over the contents of the subtile
        for (int p = 0; p < n; p++) {
            for (int q = 0; q < n; q++) {
                // create a ray through the center of the subtile.
                Double jitter1 = PRNG.nextDouble();
                Double jitter2 = PRNG.nextDouble();
                double rayX = x + (p + jitter1) / n;
                double rayY = y + (q + jitter2) / n;
                Ray ray = scene.getCamera().generateRay(new Sample(rayX, rayY));

                // test the scene on intersections
                colour = colour.add(scene.getTracer().trace(ray));
            }
        }

        // average the colours
        colour = colour.scale(1.0 / (n * n));

        // add a color contribution to the pixel
        buffer.getPixel(x, y).add(colour);
    }
}
$\endgroup$
  • $\begingroup$ If your path tracer has a limited path length then what happens when you reach that limit, do you discard that path or accumulate it as black ? I would imagine a lot of paths in an unbiased tracer would miss that square light source. $\endgroup$ – PaulHK Aug 13 '18 at 12:21
  • $\begingroup$ @PaulHK The paths that miss the square light are accumulated as black. I think this causes the lack of bright colours: they are averaged out. Is that approach wrong? $\endgroup$ – Jeroen Aug 13 '18 at 16:54
  • $\begingroup$ The images should be of similar brightness yes. If the path tracer isn't using direct light sampling (aka next-event estimation) then it will have a lot more variance but it should not affect the average. At a guess, maybe something is wrong/missing in a probability normalization somewhere. $\endgroup$ – Nathan Reed Aug 13 '18 at 18:15
  • $\begingroup$ @NathanReed I have added some code to my question in order to illustrate how I shade intersection points. I have also provided some more details (in words) on how the path tracer works. $\endgroup$ – Jeroen Aug 13 '18 at 22:09
  • $\begingroup$ @Jeroen what do you mean by changing the sensitivity? $\endgroup$ – Hubble Aug 14 '18 at 4:40
5
+50
$\begingroup$

The results that you are getting, are not correct. More specifically, your direct illumination ray tracer is not correct.

When limiting a path tracer's bounces to only allow for any direct illumination (thus only the camera ray and one bounce ray for a total of two rays), its brightness should and will match any ray tracer doing only direct illumination. Once the path tracer's bounces are extended or unlimited, it will produce brighter images since it then recovers partially or all energy that would come in through the bouncing of light.

If you compare your path tracer to your direct illumination ray tracer, you will notice that in the path tracer on the blue wall, the brightest spot is closer to the light. Compare of his direct illumination ray tracer and path tracer

Your path tracer here is correct, and it is the direct illumination ray tracer that does not give a correct image. To find out why, let us look at the math behind a path tracer.

To find out the radiance of a point for a given viewing angle ($w_{o}$), you integrate over the point's hemisphere. We currently ignore the term $L_{e}$ which is the light that the object emits at that point. $$ L_{o}(x,w_{o})=\int_{\Omega }^{ }f_{r}(x, w_{i}, w_{o})L_{i}(x,w_{i})(w_{i}\cdot n)dw_{i} $$

  • $L_{o}$ is the radiance of a point for a given viewing angle. a.k.a what a ray would return when traced.
  • $x$ is the point.
  • $w_{o}$ is the direction of outgoing light. a.k.a. viewing angle.
  • $\int_{\Omega }^{ }$ is the integral over the hemisphere. a.k.a take a bunch of samples.
  • $f_{r}$ is the result of the BRDF. a.k.a. the colour of the object.
  • $L_{i}$ is the incoming radiance for that angle. a.k.a. what the rays, that you spawn in the shadePath() method, return.
  • $w_{i}\cdot n$ is also written as $cos\theta _{i}$ and is used to weaken the incoming radiance due to how light actually works. You already did this by using cosine distributed samples.
  • $dw_{i}$ is a part of the integral, and basically averages all the samples.

If we look at an example in 2D, we have a point with the hemisphere (here just half a circle) and a rectangular light (here just a line). A light covering 30° of a semicircle. We see that the light takes up 30° of the 180° of the hemisphere (in 3D we would talk in area instead of degrees). If we move the light further back. A light that is covering 12° of a semicircle. We see that the light now only takes up 12° of the 180° of the hemisphere. The further back a light is, the less it contributes to the final radiance of the point. In 2D this falloff is $1/d$ where $d$ is the distance. In 3D this falloff is $1/d^{2}$.

With a path tracer, this falloff is already done for you. If the light is further away, less samples would hit it and thus it contributes less. 5 samples hitting a light 3 samples hitting a light

However, with a direct illumination ray tracer or next-event-estimation, every sample will always hit the light (unless there is something blocking it). Therefore, we need to add that falloff back in. We do this by multiplying the incoming light from the area light by the falloff $1/d^{2}$.

If we rotate the area light, we would notice that it would then also take up less area on the hemisphere. This should also be accounted for, and an easy way to approximate it is using the cosine between the normal of the area light and the outgoing direction. Which you already have.

It is the not having this falloff, that makes your direct illumination ray tracer not give correct results. You would need to make the area light brighter than, because of the falloff.

If I have not missed anything else, that is all you need.

EDIT

Jeroen added the code for his direct illumination ray tracer later on, and I just noticed why it is giving incorrect results. The above is still correct, yet he already had my suggested fix implemented.

When looking at his code, there is one thing that he forgot to do a few times, and that was to normalize $w_{i}$ before using it in the dot product. The dot operator only gives the cosine of the angle between the two vectors, when both vectors have a length of 1 (a.k.a. they are normalized). Jeroen forgot to do this and $w_{i}$ had a longer length than 1. Below is the code of his direct illumination ray tracer's shade function where I added some comments to make it easier to understand.

public RGBSpectrum shade(Shading shading) {
    RGBSpectrum colour = shading.getColour();
    double L = 0;
    final Point intersection = shading.getIntersectionPoint().toPoint(shading.getRay());

    for (Light light : shading.getScene().getLights()) {
        double l = 0;

        for (Point sample : light.computeSamples()) {
            Vector wi = sample.subtract(intersection);
            // In the line below, you calculate the cosine, 
            // yet wi is not normalized. Later on you use wi again 
            // to calculate a cosine and still forgot to normalize it. 
            // Since you use the lengthSquared() of wi also, 
            // I recommend to save that to a variable here, 
            // and then you normalize wi. This way you only need to add 
            // the following two lines (that I commented out)
            //
            // double lengthSquared = wi.lengthSquared();
            // wi = wi.normalize();
            double cos = shading.getIntersectionPoint().getNormal().dot(wi);

            if (cos > 0) {
                Ray shadowRay = new Ray(intersection, wi);

                if (!light.inShadow(shadowRay, shading.getScene())) {
                    if (light instanceof PointLight) {
                        double denom = 4 * Math.PI * wi.lengthSquared();
                        l += light.getPower() * cos / denom;
                    } else if (light instanceof RectangularLight) {
                        Rectangle rectangle = ((RectangularLight) light).getRectangle();
                        double cosp = -wi.dot(rectangle.getNormal());
                        l += light.getPower() * cos * cosp / (wi.lengthSquared() * rectangle.getArea());
                    }
                }
            }
        }

        L += l / light.getSamplesNumber();
    }

    return colour.scale(getAmbientReflection() + L * getDiffuseReflection());
}
$\endgroup$
  • $\begingroup$ Thank you for your answer, Bram. You are right about the lower bright spots on the walls for the direct illumination. Odd, I hadn't noticed that before. However, I think I have already taken the distance attenuation into account. See the added shading function in my question. Did I implement something wrong? $\endgroup$ – Jeroen Aug 15 '18 at 17:16
  • 1
    $\begingroup$ l += light.getPower() * cos * cosp / (wi.lengthSquared() * rectangle.getArea()); The larger the area of the light, the brighter it becomes since it takes up more area on the hemisphere. So you multiply it with the area instead of dividing it. Also (probably the main thing) L += l / light.getSamplesNumber(); There is a typo, you added instead of multiplied. Also a small optimization, when doing this on a single double, just use /= because else you are doing an unnecessary extra multiply. For vectors (three doubles) doing it that way would be faster (probably). $\endgroup$ – bram0101 Aug 15 '18 at 18:12
  • $\begingroup$ You are right about the area. Thank you for pointing that out! By chance, the light source in my scene has area 1, so your correction does not influence the result. I don't really understand your comment on the L += ... part. Surely, we need to add up the radiances from all light sources in the scene? Otherwise, we always get L = 0, since that is the default. $\endgroup$ – Jeroen Aug 15 '18 at 18:33
  • $\begingroup$ The L += .... part was indeed a misread from me. That was correct, I just read the lower case L as a 1. I just edited my answer to include what should actually be the error. $\endgroup$ – bram0101 Aug 15 '18 at 19:14
  • $\begingroup$ It is amazing how a forgotten normalization can cause so much trouble. I am really grateful for your correction, Bram! I was getting desparate after all those days of code staring and re-checking... It just didn't cross my mind that the path tracer was okay. Thanks again for your effort, I'm gladly giving you the bounty etc. :) $\endgroup$ – Jeroen Aug 15 '18 at 23:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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