Yahya Gedik Yahya Gedik - 3 months ago 8
C# Question

Raytracer not producing expected output

I have a problem: I've read an article on scratchapixel with C++ code for raytracing. C++ is ok. I tried to convert it into Python, it worked (17x slower result and 4x reduced resolution). I tried to convert it to C#, but my code is not working. Only thing I can see is a blank white 800x600 image. Please see the previously linked article for the C++ code.

This is my interpretation of it as C# code:

using System;
using System.Collections.Generic;

namespace raytracer
{
class Program
{
const int MAX_RAY_DEPTH = 8;
const float FAR = 100000000;

public static void Main(string[] args)
{
Sphere[] spheres = new Sphere[7];
spheres[0] = new Sphere(new Vec3f( 0.0f, -10004, -20), 10000, new Vec3f(0.20f, 0.20f, 0.20f), 0, 0.0f);
spheres[1] = new Sphere(new Vec3f( 0.0f, 0, -20), 4, new Vec3f(1.00f, 0.32f, 0.36f), 1, 0.5f);
spheres[2] = new Sphere(new Vec3f( 5.0f, -1, -15), 2, new Vec3f(0.90f, 0.76f, 0.46f), 1, 0.0f);
spheres[3] = new Sphere(new Vec3f( 5.0f, 0, -25), 3, new Vec3f(0.65f, 0.77f, 0.97f), 1, 0.0f);
spheres[4] = new Sphere(new Vec3f(-5.5f, 0, -15), 3, new Vec3f(0.90f, 0.90f, 0.90f), 1, 0.0f);
spheres[5] = new Sphere(new Vec3f( 2f, 2, -30), 4, new Vec3f(0.53f, 0.38f, 0.91f), 1, 0.7f);
spheres[6] = new Sphere(new Vec3f( 0, 20, -25), 3, new Vec3f(0.00f, 0.00f, 0.00f), 0, 0.0f, new Vec3f(3));
Render(spheres);
}

public class Collision
{
public float t0, t1;
public bool collide;
public Collision(bool col, float tt0 = 0, float tt1 = 0)
{
t0 = tt0;
t1 = tt1;
collide = col;
}
}

public class Vec3f
{
public float x, y, z;
public Vec3f(){ x = y = z = 0; }
public Vec3f(float v){ x = y = z = v; }
public Vec3f(float xx, float yy, float zz){ x = xx; y = yy; z = zz; }

public Vec3f normalize()
{
float nor2 = length2();
if (nor2 > 0)
{
float invNor = 1 / (float)Math.Sqrt(nor2);
x *= invNor; y *= invNor; z *= invNor;
}
return this;
}
public static Vec3f operator *(Vec3f l, Vec3f r)
{
return new Vec3f(l.x * r.x, l.y * r.y, l.z * r.z);
}
public static Vec3f operator *(Vec3f l, float r)
{
return new Vec3f(l.x * r, l.y * r, l.z * r);
}
public float dot(Vec3f v)
{
return x * v.x + y * v.y + z * v.z;
}
public static Vec3f operator -(Vec3f l, Vec3f r)
{
return new Vec3f(l.x - r.x, l.y - r.y, l.z - r.z);
}
public static Vec3f operator +(Vec3f l, Vec3f r)
{
return new Vec3f(l.x + r.x, l.y + r.y, l.z + r.z);
}
public static Vec3f operator -(Vec3f v)
{
return new Vec3f(-v.x, -v.y, -v.z);
}
public float length2()
{
return x * x + y * y + z * z;
}
public float length()
{
return (float)Math.Sqrt(length2());
}
}

public class Sphere
{
public Vec3f center, surfaceColor, emissionColor;
public float radius, radius2;
public float transparency, reflection;
public Sphere(Vec3f c, float r, Vec3f sc, float refl = 0, float transp = 0, Vec3f ec = null)
{
center = c; radius = r; radius2 = r * r;
surfaceColor = sc; emissionColor = (ec == null) ? new Vec3f(0) : ec;
transparency = transp; reflection = refl;
}

public Collision intersect(Vec3f rayorig, Vec3f raydir)
{
Vec3f l = center - rayorig;
float tca = l.dot(raydir);
if (tca < 0){ return new Collision(false); }
float d2 = l.dot(l) - tca * tca;
if (d2 > radius2){ return new Collision(false); }
Collision coll = new Collision(true);
float thc = (float)Math.Sqrt(radius2 - d2);
coll.t0 = tca - thc;
coll.t1 = tca + thc;
return coll;
}
}

public static float mix(float a, float b, float mix)
{
return b * mix + a * (1 - mix);
}

public static Vec3f trace(Vec3f rayorig, Vec3f raydir, Sphere[] spheres, int depth)
{
float tnear = FAR;
Sphere sphere = null;
foreach(Sphere i in spheres)
{
float t0 = FAR, t1 = FAR;
Collision coll = i.intersect(rayorig, raydir);
if (coll.collide)
{
if (coll.t0 < 0) { coll.t0 = coll.t1; }
if (coll.t0 < tnear) { tnear = coll.t0; sphere = i; }
}
}
if (sphere == null){ return new Vec3f(2); }
Vec3f surfaceColor = new Vec3f(0);
Vec3f phit = rayorig + raydir * tnear;
Vec3f nhit = phit - sphere.center;
nhit.normalize();
float bias = 1e-4f;
bool inside = false;
if (raydir.dot(nhit) > 0){ nhit = -nhit; inside = true; }
if ((sphere.transparency > 0 || sphere.reflection > 0) && depth < MAX_RAY_DEPTH)
{
float facingratio = -raydir.dot(nhit);
float fresneleffect = mix((float)Math.Pow(1 - facingratio, 3), 1, 0.1f);
Vec3f refldir = raydir - nhit * 2 * raydir.dot(nhit);
refldir.normalize();
Vec3f reflection = trace(phit + nhit * bias, refldir, spheres, depth + 1);
Vec3f refraction = new Vec3f(0);
if (sphere.transparency > 0)
{
float ior = 1.1f; float eta = 0;
if (inside){ eta = ior; } else { eta = 1 / ior; }
float cosi = -nhit.dot(raydir);
float k = 1 - eta * eta * (1 - cosi * cosi);
Vec3f refrdir = raydir * eta + nhit * (eta * cosi - (float)Math.Sqrt(k));
refrdir.normalize();
refraction = trace(phit - nhit * bias, refrdir, spheres, depth + 1);
}
surfaceColor =
(
reflection * fresneleffect + refraction *
(1 - fresneleffect) * sphere.transparency) * sphere.surfaceColor;
}
else
{
foreach(Sphere i in spheres)
{
if (i.emissionColor.x > 0)
{
Vec3f transmission = new Vec3f(1);
Vec3f lightDirection = i.center - phit;
lightDirection.normalize();
foreach(Sphere j in spheres)
{
if (i != j)
{
Collision jcoll = j.intersect(phit + nhit * bias, lightDirection);
if (jcoll.collide)
{
transmission = new Vec3f(0);
break;
}
}
}
surfaceColor += sphere.surfaceColor * transmission * Math.Max(0, nhit.dot(lightDirection)) * i.emissionColor;

}
}
}
return surfaceColor;
}

public static void Render(Sphere[] spheres)
{
int width = 800, height = 600;
List<Vec3f> image = new List<Vec3f>();
float invWidth = 1 / width, invHeight = 1 / height;
float fov = 30, aspectratio = width / height;
float angle = (float)Math.Tan(Math.PI * 0.5 * fov / 180);
for (int y = 0; y < height; y++)
{
for(int x = 0; x < width; x++)
{
float xx = (2 * ((x + 0.5f) * invWidth) - 1) * angle * aspectratio;
float yy = (1 - 2 * ((y + 0.5f) * invHeight)) * angle;
Vec3f raydir = new Vec3f(xx, yy, -1);
raydir.normalize();
image.Add(trace(new Vec3f(0), raydir, spheres, 0));
}
}
Console.Write("P3 800 600 255\r\n");
int line = 150;
for(int i = 0; i < width * height; ++i)
{
if(line <= 0) {line = 150; Console.Write("\r\n");}
line--;
Vec3f pixel = GetColor(image[i]);
Console.Write(pixel.x + " " + pixel.y + " " + pixel.z);
}
}

public static Vec3f GetColor(Vec3f col)
{
return new Vec3f(Math.Min(1, col.x)* 255, Math.Min(1, col.y)* 255, Math.Min(1, col.z)* 255);
}
}
}


Anyone see what is wrong?

EDIT
Program is writing traced colours to console screen. Then I am able to use windows batch files to write into a ppm file.
I am creating executable using csc.exe
"csc.exe raytracer.cs"
And run program with
"raytracer.exe > out.ppm"

Answer

The basic problem your C# code has is using int values where you want a floating point result. Just as in the C++ code, the original int values are converted to float before using them in division, you need to do that in your C# code as well. In particular, your invHeight, invWidth, and aspectratio calculations all need to be performed using floating point math instead of integer math:

    float invWidth = 1f / width, invHeight = 1f / height;
    float fov = 30, aspectratio = (float)width / height;

Also, your text output is actually missing spaces between the pixels. In your version of the code, you can fix this by inserting a space before each pixel value, except for the first in a line:

    for(int i = 0; i < width * height; ++i)
    {
        if(line <= 0) {line = 150; Console.Write("\r\n");}
        else if (line < 150) Console.Write(" ");
        line--;
        Vec3f pixel = GetColor(image[i]);
        Console.Write(pixel.x + " " + pixel.y + " " + pixel.z);
    }

Or you can, of course, just always write the space:

        Console.Write(pixel.x + " " + pixel.y + " " + pixel.z + " ");

You also had a minor error in the conversion, in that you failed to add sphere.emissionColor at the end of the trace() method:

        return surfaceColor + sphere.emissionColor;

Those three changes will fix your code and produce the results you want.


Now, that said, IMHO it is worth considering some other changes. The most notable would be to use struct types for Vec3f and Collision instead of class. Unlike in C++, where the only real difference between struct and class is the default accessibility for members, in C# these two kinds of types are very different in their basic behavior. In a program like this, using struct instead of class for frequently-used values like these can improve performance significantly, by minimizing the amount of heap-allocated data, especially data which exists only temporarily and will need to be collected by the garbage collector while your program is trying to do other work.

You might also want to consider changing the data type from float to double. I tested the code both ways; it makes no difference in the visual output, but I was seeing the render take 2.1 seconds on average with double and 2.8 seconds on average with float. The 25% improvement in speed is probably something you'd want to have. :)

As far as the struct vs class question goes, in my tests, using the faster double type for the arithmetic, I saw a 36% improvement in speed using struct instead of class (using class for these types runs in 3.3 seconds, while using struct runs in 2.1 seconds).

At the same time, struct types where the values can be modified can lead to hard-to-find bugs. A struct really ought to be immutable, so as part of the change, I adjusted the types so that they are. This was relatively simple for the Collision type, but in the case of Vec3f, your code has a number of places where these values were modified (by calling normalize()) in place. To make the change to immutable struct values work, these all had to change so that the return value of the normalize() method was used in place of the original value.

Other changes I made include:

  • Removing the Vec3f() constructor. This isn't allowed for struct types anyway, and there's no need for it because the default constructor will do the right thing.
  • Moving the collision's check for t0 < 0 into the Collision type, to support the immutability of that type.
  • Changing your Sphere iteration loops back to using integer indexes, as in the original C++. The foreach statement involves allocating an enumerator for each loop; by indexing the array directly, you can avoid these unnecessary allocations, and it means that the variable names make more sense too (i and j are conventionally reserved for indexes, so it's odd reading code where they represent something else).
  • I also returned the code back to being more similar to the C++ code in other places, such as the initialization of eta and lining up the code more similar to the C++ code.
  • I changed the code from using List<Vec3f> to using an array instead. This is more efficient and avoids having to reallocate the backing storage for the list periodically.

Finally, I made a significant change in the output of the program. I wasn't interested in waiting for the console window to print all the output, nor was I interested in trying to track down and install a program that would read and display the text-based image output.

So instead, I changed the text output so that it only wrote to an in-memory string, and I added code so that the program would generate an actual PNG file that I could open directly, without going through some third-party program.

All said and done, this is what I got:

ray-traced balls

Here is my final version of the code:

class Program
{
    const int MAX_RAY_DEPTH = 8;
    const float FAR = 100000000;

    public static void Main(string[] args)
    {
        Sphere[] spheres = new Sphere[7];
        spheres[0] = new Sphere(new Vec3f( 0.0f, -10004, -20), 10000, new Vec3f(0.20f, 0.20f, 0.20f), 0, 0.0f);
        spheres[1] = new Sphere(new Vec3f( 0.0f,      0, -20),     4, new Vec3f(1.00f, 0.32f, 0.36f), 1, 0.5f);
        spheres[2] = new Sphere(new Vec3f( 5.0f,     -1, -15),     2, new Vec3f(0.90f, 0.76f, 0.46f), 1, 0.0f);
        spheres[3] = new Sphere(new Vec3f( 5.0f,      0, -25),     3, new Vec3f(0.65f, 0.77f, 0.97f), 1, 0.0f);
        spheres[4] = new Sphere(new Vec3f(-5.5f,      0, -15),     3, new Vec3f(0.90f, 0.90f, 0.90f), 1, 0.0f);
        spheres[5] = new Sphere(new Vec3f(   2f,      2, -30),     4, new Vec3f(0.53f, 0.38f, 0.91f), 1, 0.7f);
        spheres[6] = new Sphere(new Vec3f(    0,     20, -30),     3, new Vec3f(0.00f, 0.00f, 0.00f), 0, 0.0f, new Vec3f(3));
        Render(spheres);
    }

    public struct Collision
    {
        public readonly float t0, t1;
        public readonly bool collide;

        public Collision(bool col, float tt0, float tt1)
        {
            t0 = tt0 < 0 ? tt1 : tt0;
            t1 = tt1;
            collide = col;
        }
    }

    public struct Vec3f
    {
        public readonly float x, y, z;
        public Vec3f(float v) { x = y = z = v; }
        public Vec3f(float xx, float yy, float zz) { x = xx; y = yy; z = zz; }

        public Vec3f normalize()
        {
            float nor2 = length2();
            if (nor2 > 0)
            {
                float invNor = 1 / (float)Math.Sqrt(nor2);

                return new Vec3f(x * invNor, y * invNor, z * invNor);
            }

            return this;
        }
        public static Vec3f operator *(Vec3f l, Vec3f r)
        {
            return new Vec3f(l.x * r.x, l.y * r.y, l.z * r.z);
        }
        public static Vec3f operator *(Vec3f l, float r)
        {
            return new Vec3f(l.x * r, l.y * r, l.z * r);
        }
        public float dot(Vec3f v)
        {
            return x * v.x + y * v.y + z * v.z;
        }
        public static Vec3f operator -(Vec3f l, Vec3f r)
        {
            return new Vec3f(l.x - r.x, l.y - r.y, l.z - r.z);
        }
        public static Vec3f operator +(Vec3f l, Vec3f r)
        {
            return new Vec3f(l.x + r.x, l.y + r.y, l.z + r.z);
        }
        public static Vec3f operator -(Vec3f v)
        {
            return new Vec3f(-v.x, -v.y, -v.z);
        }
        public float length2()
        {
            return x * x + y * y + z * z;
        }
        public float length()
        {
            return (float)Math.Sqrt(length2());
        }
    }

    public class Sphere
    {
        public readonly Vec3f center, surfaceColor, emissionColor;
        public readonly float radius, radius2;
        public readonly float transparency, reflection;
        public Sphere(Vec3f c, float r, Vec3f sc, float refl = 0, float transp = 0, Vec3f? ec = null)
        {
            center = c; radius = r; radius2 = r * r;
            surfaceColor = sc; emissionColor = (ec == null) ? new Vec3f() : ec.Value;
            transparency = transp; reflection = refl;
        }

        public Collision intersect(Vec3f rayorig, Vec3f raydir)
        {
            Vec3f l = center - rayorig;
            float tca = l.dot(raydir);
            if (tca < 0) { return new Collision(); }
            float d2 = l.dot(l) - tca * tca;
            if (d2 > radius2) { return new Collision(); }
            float thc = (float)Math.Sqrt(radius2 - d2);
            return new Collision(true, tca - thc, tca + thc);
        }
    }

    public static float mix(float a, float b, float mix)
    {
        return b * mix + a * (1 - mix);
    }

    public static Vec3f trace(Vec3f rayorig, Vec3f raydir, Sphere[] spheres, int depth)
    {
        float tnear = FAR;
        Sphere sphere = null;
        for (int i = 0; i < spheres.Length; i++)
        {
            Collision coll = spheres[i].intersect(rayorig, raydir);
            if (coll.collide && coll.t0 < tnear)
            {
                tnear = coll.t0;
                sphere = spheres[i];
            }
        }
        if (sphere == null) { return new Vec3f(2); }
        Vec3f surfaceColor = new Vec3f();
        Vec3f phit = rayorig + raydir * tnear;
        Vec3f nhit = (phit - sphere.center).normalize();
        float bias = 1e-4f;
        bool inside = false;
        if (raydir.dot(nhit) > 0) { nhit = -nhit; inside = true; }
        if ((sphere.transparency > 0 || sphere.reflection > 0) && depth < MAX_RAY_DEPTH)
        {
            float facingratio = -raydir.dot(nhit);
            float fresneleffect = mix((float)Math.Pow(1 - facingratio, 3), 1, 0.1f);
            Vec3f refldir = (raydir - nhit * 2 * raydir.dot(nhit)).normalize();
            Vec3f reflection = trace(phit + nhit * bias, refldir, spheres, depth + 1);
            Vec3f refraction = new Vec3f();
            if (sphere.transparency > 0)
            {
                float ior = 1.1f; float eta = inside ? ior : 1 / ior;
                float cosi = -nhit.dot(raydir);
                float k = 1 - eta * eta * (1 - cosi * cosi);
                Vec3f refrdir = (raydir * eta + nhit * (eta * cosi - (float)Math.Sqrt(k))).normalize();
                refraction = trace(phit - nhit * bias, refrdir, spheres, depth + 1);
            }
            surfaceColor = (
                reflection * fresneleffect + 
                refraction * (1 - fresneleffect) * sphere.transparency) * sphere.surfaceColor;
        }
        else
        {
            for (int i = 0; i < spheres.Length; i++)
            {
                if (spheres[i].emissionColor.x > 0)
                {
                    Vec3f transmission = new Vec3f(1);
                    Vec3f lightDirection = (spheres[i].center - phit).normalize();
                    for (int j = 0; j < spheres.Length; j++)
                    {
                        if (i != j)
                        {
                            Collision jcoll = spheres[j].intersect(phit + nhit * bias, lightDirection);
                            if (jcoll.collide)
                            {
                                transmission = new Vec3f();
                                break;
                            }
                        }
                    }
                    surfaceColor += sphere.surfaceColor * transmission *
                        Math.Max(0, nhit.dot(lightDirection)) * spheres[i].emissionColor;

                }
            }
        }

        return surfaceColor + sphere.emissionColor;
    }

    public static void Render(Sphere[] spheres)
    {
        int width = 800, height = 600;
        Vec3f[] image = new Vec3f[width * height];
        int pixelIndex = 0;
        float invWidth = 1f / width, invHeight = 1f / height;
        float fov = 30, aspectratio = (float)width / height;
        float angle = (float)Math.Tan(Math.PI * 0.5 * fov / 180);
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++, pixelIndex++)
            {
                float xx = (2 * ((x + 0.5f) * invWidth) - 1) * angle * aspectratio;
                float yy = (1 - 2 * ((y + 0.5f) * invHeight)) * angle;
                Vec3f raydir = new Vec3f(xx, yy, -1).normalize();

                image[pixelIndex] = trace(new Vec3f(), raydir, spheres, 0);
            }
        }

        StringWriter writer = new StringWriter();
        WriteableBitmap bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Rgb24, null);

        bitmap.Lock();

        unsafe
        {
            byte* buffer = (byte*)bitmap.BackBuffer;

            {
                writer.Write("P3 800 600 255\r\n");
                for (int y = 0; y < height; y++)
                {
                    for (int x = 0; x < width; ++x)
                    {
                        if (x > 0) { writer.Write(" "); }
                        Vec3f pixel = GetColor(image[y * width + x]);
                        writer.Write(pixel.x + " " + pixel.y + " " + pixel.z);

                        int bufferOffset = y * bitmap.BackBufferStride + x * 3;
                        buffer[bufferOffset] = (byte)pixel.x;
                        buffer[bufferOffset + 1] = (byte)pixel.y;
                        buffer[bufferOffset + 2] = (byte)pixel.z;
                    }

                    writer.WriteLine();
                }
            }
        }

        bitmap.Unlock();


        var encoder = new PngBitmapEncoder();

        using (Stream stream = File.OpenWrite("temp.png"))
        {
            encoder.Frames.Add(BitmapFrame.Create(bitmap));
            encoder.Save(stream);
        }

        string result = writer.ToString();
    }

    public static Vec3f GetColor(Vec3f col)
    {
        return new Vec3f(Math.Min(1, col.x) * 255, Math.Min(1, col.y) * 255, Math.Min(1, col.z) * 255);
    }
}

Note that for the above to compile, you'll need to add references in your project to the PresentationCore, WindowsBase, and System.Xaml assemblies. You'll also need to check the "Allow unsafe code" option in the project settings.