Sunday, August 9, 2015

OpenTK Tutorial 8 Part 1: Normals, Materials, and Loading More From Files

In the previous tutorial, we loaded a simple object in from a file. Right now, it's hard to see the detail in the models because they're all one color (or have a texture applied wrong). By the end of this tutorial (that is, after the second part), we'll have diffuse lighting with specular highlights on our models, making them look much nicer.

This part will cover loading texture coordinates and normals from a file, calculating normals for geometry that doesn't come with them (such as shapes we're generating through code) and loading basic materials from a file.

(UPDATED 2015-11-24 to fix some issues with loading normals from a file)

(UPDATED 2015-12-16 to make the MTL file have materials that look nicer under lighting)

(UPDATED 2018-11-06 to make compatible with input handling changes in Tutorial 5)

First, we need to lay some groundwork for the rest of the tutorial. We will be changing the Volume class to store normals (and their associated information).

The normals will be stored as an array defined in the Volume class. Why isn't it like the vertex, color, or texture data? To make things convenient, a function will be added to the Volume class to calculate the normals, but if each subclass stored normals differently, the function would need to be redefined in each one. Add the array to the class:

        Vector3[] Normals = new Vector3[0];


Now add a new property to the Volume class to store the number of normals the volume has. This might not always be equal to the number of vertices or indices, so it's best to make it simply linked to the array we stored the normals in:

        public virtual int NormalCount { get { return Normals.Length; } }

To make our normals data available in the rest of the program, add a function to the class as well (the Normals array could've just been made public, but this makes it match up nicely with the existing code):

        public virtual Vector3[] GetNormals()
        {
            return Normals;
        }



A "normal" is a vector perpendicular to a surface. In our case, the surface will be the triangles forming the shape of a model.

Normals on a curved surface (from Wikimedia Commons)

Fortunately, the normal vector is very easy to calculate. The cross product of two vectors is a third vector perpendicular to both of the other two vectors. This means that we can calculate the normal vector by finding the cross product of two vectors making up the edges of our triangles.

Keep in mind that this may result in either "flat" looking shading, or objects being shaded like they don't have edges when the normals are generated this way. Normals output by a 3D modelling program will allow for better control, including things like smoothing groups.

Add this function to the Volume class:

        public void CalculateNormals()
        {
            Vector3[] normals = new Vector3[VertCount];
            Vector3[] verts = GetVerts();
            int[] inds = GetIndices();

            // Compute normals for each face
            for (int i = 0; i < IndiceCount; i += 3)
            {
                Vector3 v1 = verts[inds[i]];
                Vector3 v2 = verts[inds[i + 1]];
                Vector3 v3 = verts[inds[i + 2]];

                // The normal is the cross product of two sides of the triangle
                normals[inds[i]] += Vector3.Cross(v2 - v1, v3 - v1);
                normals[inds[i + 1]] += Vector3.Cross(v2 - v1, v3 - v1);
                normals[inds[i + 2]] += Vector3.Cross(v2 - v1, v3 - v1);
            }

            for (int i = 0; i < NormalCount; i++)
            {
                normals[i] = normals[i].Normalized();
            }

            Normals = normals;
        }


Now that we have normals included, we have one more change to make in the Volume class. Replace the existing definition for TextureCoordsCount with the following:

        public virtual int TextureCoordsCount { get; set; }

This adds some additional flexibility required for the improved OBJ loader, which is the next step.

Improving the OBJ loader will also require some work before the main course. Add the following class to the end (but still inside the namespace!) of the ObjVolume.cs file:

    class FaceVertex
    {
        public Vector3 Position;
        public Vector3 Normal;
        public Vector2 TextureCoord;

        public FaceVertex(Vector3 pos, Vector3 norm, Vector2 texcoord)
        {
            Position = pos;
            Normal = norm;
            TextureCoord = texcoord;
        }
    }

In the new OBJ loader, each face uses three vertices with a position, normal vector and a texture coordinate. As these might not line up nicely, we'll need to store information about each vertex of each face.

To begin updating the ObjVolume class to use this class, we need to replace the definition for the faces variable. Replace it with the following:

        private List<Tuple<FaceVertex, FaceVertex, FaceVertex>> faces = new List<Tuple<FaceVertex, FaceVertex, FaceVertex>>();


The GetVerts, GetIndices, GetColorData, and GetTextureCoords functions need to be adapted too:

        /// <summary>
        /// Get vertice data for this object
        /// </summary>
        /// <returns></returns>
        public override Vector3[] GetVerts()
        {
            List<Vector3> verts = new List<Vector3>();

            foreach (var face in faces)
            {
                verts.Add(face.Item1.Position);
                verts.Add(face.Item2.Position);
                verts.Add(face.Item3.Position);
            }

            return verts.ToArray();
        }

        /// <summary>
        /// Get indices
        /// </summary>
        /// <param name="offset"></param>
        /// <returns></returns>
        public override int[] GetIndices(int offset = 0)
        {
            return Enumerable.Range(offset, IndiceCount).ToArray();
        }

        /// <summary>
        /// Get color data.
        /// </summary>
        /// <returns></returns>
        public override Vector3[] GetColorData()
        {
            return new Vector3[ColorDataCount];
        }

        /// <summary>
        /// Get texture coordinates.
        /// </summary>
        /// <returns></returns>
        public override Vector2[] GetTextureCoords()
        {
            List<Vector2> coords = new List<Vector2>();

            foreach (var face in faces)
            {
                coords.Add(face.Item1.TextureCoord);
                coords.Add(face.Item2.TextureCoord);
                coords.Add(face.Item3.TextureCoord);
            }

            return coords.ToArray();
        }



Now, replace the existing LoadFromString function in the ObjVolume class with the following (warning, long code ahead!):

  public static ObjVolume LoadFromString(string obj)
        {
            // Seperate lines from the file
            List<String> lines = new List<string>(obj.Split('\n'));

            // Lists to hold model data
            List<Vector3> verts = new List<Vector3>();
            List<Vector3> normals = new List<Vector3>();
            List<Vector2> texs = new List<Vector2>();
            List<Tuple<TempVertex, TempVertex, TempVertex>> faces = new List<Tuple<TempVertex, TempVertex, TempVertex>>();

            // Base values
            verts.Add(new Vector3());
            texs.Add(new Vector2());
            normals.Add(new Vector3());

            int currentindice = 0;

            // Read file line by line
            foreach (String line in lines)
            {
                if (line.StartsWith("v ")) // Vertex definition
                {
                    // Cut off beginning of line
                    String temp = line.Substring(2);

                    Vector3 vec = new Vector3();

                    if (temp.Trim().Count((char c) => c == ' ') == 2) // Check if there's enough elements for a vertex
                    {
                        String[] vertparts = temp.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

                        // Attempt to parse each part of the vertice
                        bool success = float.TryParse(vertparts[0], out vec.X);
                        success |= float.TryParse(vertparts[1], out vec.Y);
                        success |= float.TryParse(vertparts[2], out vec.Z);

                        // If any of the parses failed, report the error
                        if (!success)
                        {
                            Console.WriteLine("Error parsing vertex: {0}", line);
                        }
                    }
                    else
                    {
                        Console.WriteLine("Error parsing vertex: {0}", line);
                    }

                    verts.Add(vec);
                }
                else if (line.StartsWith("vt ")) // Texture coordinate
                {
                    // Cut off beginning of line
                    String temp = line.Substring(2);

                    Vector2 vec = new Vector2();

                    if (temp.Trim().Count((char c) => c == ' ') > 0) // Check if there's enough elements for a vertex
                    {
                        String[] texcoordparts = temp.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

                        // Attempt to parse each part of the vertice
                        bool success = float.TryParse(texcoordparts[0], out vec.X);
                        success |= float.TryParse(texcoordparts[1], out vec.Y);

                        // If any of the parses failed, report the error
                        if (!success)
                        {
                            Console.WriteLine("Error parsing texture coordinate: {0}", line);
                        }
                    }
                    else
                    {
                        Console.WriteLine("Error parsing texture coordinate: {0}", line);
                    }

                    texs.Add(vec);
                }
                else if (line.StartsWith("vn ")) // Normal vector
                {
                    // Cut off beginning of line
                    String temp = line.Substring(2);

                    Vector3 vec = new Vector3();

                    if (temp.Trim().Count((char c) => c == ' ') == 2) // Check if there's enough elements for a normal
                    {
                        String[] vertparts = temp.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

                        // Attempt to parse each part of the vertice
                        bool success = float.TryParse(vertparts[0], out vec.X);
                        success |= float.TryParse(vertparts[1], out vec.Y);
                        success |= float.TryParse(vertparts[2], out vec.Z);

                        // If any of the parses failed, report the error
                        if (!success)
                        {
                            Console.WriteLine("Error parsing normal: {0}", line);
                        }
                    }
                    else
                    {
                        Console.WriteLine("Error parsing normal: {0}", line);
                    }

                    normals.Add(vec);
                }
                else if (line.StartsWith("f ")) // Face definition
                {
                    // Cut off beginning of line
                    String temp = line.Substring(2);

                    Tuple<TempVertex, TempVertex, TempVertex> face = new Tuple<TempVertex, TempVertex, TempVertex>(new TempVertex(), new TempVertex(), new TempVertex());

                    if (temp.Trim().Count((char c) => c == ' ') == 2) // Check if there's enough elements for a face
                    {
                        String[] faceparts = temp.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

                        int v1, v2, v3;
                        int t1, t2, t3;
                        int n1, n2, n3;

                        // Attempt to parse each part of the face
                        bool success = int.TryParse(faceparts[0].Split('/')[0], out v1);
                        success |= int.TryParse(faceparts[1].Split('/')[0], out v2);
                        success |= int.TryParse(faceparts[2].Split('/')[0], out v3);

                        if (faceparts[0].Count((char c) => c == '/') >= 2)
                        {
                            success |= int.TryParse(faceparts[0].Split('/')[1], out t1);
                            success |= int.TryParse(faceparts[1].Split('/')[1], out t2);
                            success |= int.TryParse(faceparts[2].Split('/')[1], out t3);
                            success |= int.TryParse(faceparts[0].Split('/')[2], out n1);
                            success |= int.TryParse(faceparts[1].Split('/')[2], out n2);
                            success |= int.TryParse(faceparts[2].Split('/')[2], out n3);
                        }
                        else
                        {
                            if (texs.Count > v1 && texs.Count > v2 && texs.Count > v3)
                            {
                                t1 = v1;
                                t2 = v2;
                                t3 = v3;
                            }
                            else
                            {
                                t1 = 0;
                                t2 = 0;
                                t3 = 0;
                            }


                            if (normals.Count > v1 && normals.Count > v2 && normals.Count > v3)
                            {
                                n1 = v1;
                                n2 = v2;
                                n3 = v3;
                            }
                            else
                            {
                                n1 = 0;
                                n2 = 0;
                                n3 = 0;
                            }
                        }


                        // If any of the parses failed, report the error
                        if (!success)
                        {
                            Console.WriteLine("Error parsing face: {0}", line);
                        }
                        else
                        {
                            TempVertex tv1 = new TempVertex(v1, n1, t1);
                            TempVertex tv2 = new TempVertex(v2, n2, t2);
                            TempVertex tv3 = new TempVertex(v3, n3, t3);
                            face = new Tuple<TempVertex, TempVertex, TempVertex>(tv1, tv2, tv3);
                            faces.Add(face);
                        }
                    }
                    else
                    {
                        Console.WriteLine("Error parsing face: {0}", line);
                    }
                }
            }

            // Create the ObjVolume
            ObjVolume vol = new ObjVolume();

            foreach (var face in faces)
            {
                FaceVertex v1 = new FaceVertex(verts[face.Item1.Vertex], normals[face.Item1.Normal], texs[face.Item1.Texcoord]);
                FaceVertex v2 = new FaceVertex(verts[face.Item2.Vertex], normals[face.Item2.Normal], texs[face.Item2.Texcoord]);
                FaceVertex v3 = new FaceVertex(verts[face.Item3.Vertex], normals[face.Item3.Normal], texs[face.Item3.Texcoord]);

                vol.faces.Add(new Tuple<FaceVertex, FaceVertex, FaceVertex>(v1, v2, v3));
            }

            return vol;
        }

        private class TempVertex
        {
            public int Vertex;
            public int Normal;
            public int Texcoord;

            public TempVertex(int vert = 0, int norm = 0, int tex = 0)
            {
                Vertex = vert;
                Normal = norm;
                Texcoord = tex;
            }
        }

        public override Vector3[] GetNormals()
        {
            if (base.GetNormals().Length > 0)
            {
                return base.GetNormals();
            }

            List<Vector3> normals = new List<Vector3>();

            foreach (var face in faces)
            {
                normals.Add(face.Item1.Normal);
                normals.Add(face.Item2.Normal);
                normals.Add(face.Item3.Normal);
            }

            return normals.ToArray();
        }

        public override int NormalCount
        {
            get
            {
                return faces.Count * 3;
            }
        }

This code loads the normal and texture data found in most OBJ files. The TempVertex class is used to make it easier to pass around the data for a vertex. There's also code included to make the ObjVolume class use the loaded normals, but revert to the computed ones if they exist (it assumes that if you've set a new list of normals, you want that one instead of the ones from the file).


Now let's add a parser for MTL files. Create a new class called Material. This class will store information about a material, and handle loading materials from MTL files.

First we'll have the basics of the Material class (don't forget to add a using directive for the OpenTK classes):

    /// <summary>
    /// Stores information about a material applied to a <c>Volume</c>
    /// </summary>
    public class Material
    {
        public Vector3 AmbientColor = new Vector3();
        public Vector3 DiffuseColor = new Vector3();
        public Vector3 SpecularColor = new Vector3();
        public float SpecularExponent = 1;
        public float Opacity = 1.0f;

        public String AmbientMap = "";
        public String DiffuseMap = "";
        public String SpecularMap = "";
        public String OpacityMap = "";
        public String NormalMap = "";

        public Material()
        {
        }

        public Material(Vector3 ambient, Vector3 diffuse, Vector3 specular, float specexponent = 1.0f, float opacity = 1.0f)
        {
            AmbientColor = ambient;
            DiffuseColor = diffuse;
            SpecularColor = specular;
            SpecularExponent = specexponent;
            Opacity = opacity;
        }
    }


The "maps" here are effectively textures. The most interesting of these is the normal map, which can be used to create an illusion of greater detail in a model. Most of these maps won't be used (we'll only be using diffuse for now) in this tutorial, but are included in case they need to be used later.

The only thing left for this class is the actual code to load the material itself! Add the following functions to the Material class.

       public static Dictionary<String, Material> LoadFromFile(string filename)
        {
            Dictionary<String, Material> mats = new Dictionary<String, Material>();

            try
            {
                String currentmat = "";
                using (StreamReader reader = new StreamReader(new FileStream(filename, FileMode.Open, FileAccess.Read)))
                {
                    String currentLine;

                    while (!reader.EndOfStream)
                    {
                        currentLine = reader.ReadLine();

                        if (!currentLine.StartsWith("newmtl"))
                        {
                            if (currentmat.StartsWith("newmtl"))
                            {
                                currentmat += currentLine + "\n";
                            }
                        }
                        else
                        {
                            if (currentmat.Length > 0)
                            {
                                Material newMat = new Material();
                                String newMatName = "";

                                newMat = LoadFromString(currentmat, out newMatName);

                                mats.Add(newMatName, newMat);
                            }

                            currentmat = currentLine + "\n";
                        }
                    }
                }

                // Add final material
                if (currentmat.Count((char c) => c == '\n') > 0)
                {
                    Material newMat = new Material();
                    String newMatName = "";

                    newMat = LoadFromString(currentmat, out newMatName);

                    mats.Add(newMatName, newMat);
                }
            }
            catch (FileNotFoundException)
            {
                Console.WriteLine("File not found: {0}", filename);
            }
            catch (Exception)
            {
                Console.WriteLine("Error loading file: {0}", filename);
            }

            return mats;
        }

        public static Material LoadFromString(string mat, out string name)
        {
            Material output = new Material();
            name = "";

            List<String> lines = mat.Split('\n').ToList();

            // Skip until the material definition starts
            lines = lines.SkipWhile(s => !s.StartsWith("newmtl ")).ToList();

            // Make sure an actual material was included
            if (lines.Count != 0)
            {
                // Get name from first line
                name = lines[0].Substring("newmtl ".Length);
            }

            // Remove leading whitespace
            lines = lines.Select((string s) => s.Trim()).ToList();

            // Read material properties
            foreach (String line in lines)
            {
                // Skip comments and blank lines
                if (line.Length < 3 || line.StartsWith("//") || line.StartsWith("#"))
                {
                    continue;
                }

                // Parse ambient color
                if (line.StartsWith("Ka"))
                {
                    String[] colorparts = line.Substring(3).Split(' ');

                    // Check that all vector fields are present
                    if (colorparts.Length < 3)
                    {
                        throw new ArgumentException("Invalid color data");
                    }

                    Vector3 vec = new Vector3();

                    // Attempt to parse each part of the color
                    bool success = float.TryParse(colorparts[0], out vec.X);
                    success |= float.TryParse(colorparts[1], out vec.Y);
                    success |= float.TryParse(colorparts[2], out vec.Z);

                    output.AmbientColor = new Vector3(float.Parse(colorparts[0]), float.Parse(colorparts[1]), float.Parse(colorparts[2]));

                    // If any of the parses failed, report the error
                    if (!success)
                    {
                        Console.WriteLine("Error parsing color: {0}", line);
                    }
                }

                // Parse diffuse color
                if (line.StartsWith("Kd"))
                {
                    String[] colorparts = line.Substring(3).Split(' ');

                    // Check that all vector fields are present
                    if (colorparts.Length < 3)
                    {
                        throw new ArgumentException("Invalid color data");
                    }

                    Vector3 vec = new Vector3();

                    // Attempt to parse each part of the color
                    bool success = float.TryParse(colorparts[0], out vec.X);
                    success |= float.TryParse(colorparts[1], out vec.Y);
                    success |= float.TryParse(colorparts[2], out vec.Z);

                    output.DiffuseColor = new Vector3(float.Parse(colorparts[0]), float.Parse(colorparts[1]), float.Parse(colorparts[2]));

                    // If any of the parses failed, report the error
                    if (!success)
                    {
                        Console.WriteLine("Error parsing color: {0}", line);
                    }
                }

                // Parse specular color
                if (line.StartsWith("Ks"))
                {
                    String[] colorparts = line.Substring(3).Split(' ');

                    // Check that all vector fields are present
                    if (colorparts.Length < 3)
                    {
                        throw new ArgumentException("Invalid color data");
                    }

                    Vector3 vec = new Vector3();

                    // Attempt to parse each part of the color
                    bool success = float.TryParse(colorparts[0], out vec.X);
                    success |= float.TryParse(colorparts[1], out vec.Y);
                    success |= float.TryParse(colorparts[2], out vec.Z);

                    output.SpecularColor = new Vector3(float.Parse(colorparts[0]), float.Parse(colorparts[1]), float.Parse(colorparts[2]));

                    // If any of the parses failed, report the error
                    if (!success)
                    {
                        Console.WriteLine("Error parsing color: {0}", line);
                    }
                }

                // Parse specular exponent
                if (line.StartsWith("Ns"))
                {
                    // Attempt to parse each part of the color
                    float exponent = 0.0f;
                    bool success = float.TryParse(line.Substring(3), out exponent);

                    output.SpecularExponent = exponent;

                    // If any of the parses failed, report the error
                    if (!success)
                    {
                        Console.WriteLine("Error parsing specular exponent: {0}", line);
                    }
                }

                // Parse ambient map
                if (line.StartsWith("map_Ka"))
                {
                    // Check that file name is present
                    if (line.Length > "map_Ka".Length + 6)
                    {
                        output.AmbientMap = line.Substring("map_Ka".Length + 1);
                    }
                }

                // Parse diffuse map
                if (line.StartsWith("map_Kd"))
                {
                    // Check that file name is present
                    if (line.Length > "map_Kd".Length + 6)
                    {
                        output.DiffuseMap = line.Substring("map_Kd".Length + 1);
                    }
                }

                // Parse specular map
                if (line.StartsWith("map_Ks"))
                {
                    // Check that file name is present
                    if (line.Length > "map_Ks".Length + 6)
                    {
                        output.SpecularMap = line.Substring("map_Ks".Length + 1);
                    }
                }

                // Parse normal map
                if (line.StartsWith("map_normal"))
                {
                    // Check that file name is present
                    if (line.Length > "map_normal".Length + 6)
                    {
                        output.NormalMap = line.Substring("map_normal".Length + 1);
                    }
                }

                // Parse opacity map
                if (line.StartsWith("map_opacity"))
                {
                    // Check that file name is present
                    if (line.Length > "map_opacity".Length + 6)
                    {
                        output.OpacityMap = line.Substring("map_opacity".Length + 1);
                    }
                }

            }

            return output;
        }

The Dictionary<String, Material> LoadFromFile returns is the set of materials defined in the file, with the names of the materials as the keys.

At this point we've added normals, we've added better OBJ file loading, and we've added MTL file loading. Now let's actually do something with these features.

First, we'll use the new MTL loader to dynamically load textures instead of having everything hardcoded.

In the Game class, add the following new member:

        Dictionary<String, Material> materials = new Dictionary<string, Material>();

This dictionary will store information about materials loaded from files. Next, we'll add a method to populate it. To do this, add the following method to the Game class:

        private void loadMaterials(String filename)
        {
            foreach (var mat in Material.LoadFromFile(filename))
            {
                if (!materials.ContainsKey(mat.Key))
                {
                    materials.Add(mat.Key, mat.Value);
                }
            }

            // Load textures
            foreach (Material mat in materials.Values)
            {
                if (File.Exists(mat.AmbientMap) && !textures.ContainsKey(mat.AmbientMap))
                {
                    textures.Add(mat.AmbientMap, loadImage(mat.AmbientMap));
                }

                if (File.Exists(mat.DiffuseMap) && !textures.ContainsKey(mat.DiffuseMap))
                {
                    textures.Add(mat.DiffuseMap, loadImage(mat.DiffuseMap));
                }

                if (File.Exists(mat.SpecularMap) && !textures.ContainsKey(mat.SpecularMap))
                {
                    textures.Add(mat.SpecularMap, loadImage(mat.SpecularMap));
                }

                if (File.Exists(mat.NormalMap) && !textures.ContainsKey(mat.NormalMap))
                {
                    textures.Add(mat.NormalMap, loadImage(mat.NormalMap));
                }

                if (File.Exists(mat.OpacityMap) && !textures.ContainsKey(mat.OpacityMap))
                {
                    textures.Add(mat.OpacityMap, loadImage(mat.OpacityMap));
                }
            }
        }

This will load the materials from a file, add them, and load the maps in the file, if they're not already loaded. Now we need one more thing to use this: an MTL file. Create opentk.mtl, set "Copy to Output Directory" to "Copy Always", and put the following inside it:

newmtl opentk1
 Ns 10.0000
 Ni 1.5000
 d 1.0000
 Tr 0.0000
 Tf 1.0000 1.0000 1.0000 
 illum 2
 Ka 0.1880 0.1880 0.1880
 Kd 1.0000 1.0000 1.0000
 Ks 0.1000 0.1000 0.1000
 map_Ka opentksquare.png
 map_Kd opentksquare.png

newmtl opentk2
 Ns 10.0000
 Ni 1.5000
 d 1.0000
 Tr 0.0000
 Tf 1.0000 1.0000 1.0000 
 illum 2
 Ka 0.1880 0.1880 0.1880
 Kd 1.0000 1.0000 1.0000
 Ks 0.1000 0.1000 0.1000
 map_Ka opentksquare2.png
 map_Kd opentksquare2.png



This file includes the materials of the two textured cubes we had already used in previous tutorials. To use this, replace the initProgram method in the Game class with the following;

        void initProgram()
        {
            lastMousePos = new Vector2(Mouse.X, Mouse.Y);

            GL.GenBuffers(1, out ibo_elements);

            // Load shaders from file
            shaders.Add("default", new ShaderProgram("vs.glsl", "fs.glsl", true));
            shaders.Add("textured", new ShaderProgram("vs_tex.glsl", "fs_tex.glsl", true));

            activeShader = "textured";
            
            loadMaterials("opentk.mtl");

            // Create our objects
            TexturedCube tc = new TexturedCube();
            tc.TextureID = textures[materials["opentk1"].DiffuseMap];
            tc.CalculateNormals();
            objects.Add(tc);

            TexturedCube tc2 = new TexturedCube();
            tc2.Position += new Vector3(1f, 1f, 1f);
            tc2.TextureID = textures[materials["opentk2"].DiffuseMap];
            tc2.CalculateNormals();
            objects.Add(tc2);

            // Move camera away from origin
            cam.Position += new Vector3(0f, 0f, 3f);
        }



Now we're back where we were two tutorials ago, but with everything somewhat more flexible.

One last thing before the end of this part. We've calculated normals, but we're not doing anything with them. Let's visualize them with a new shader!

Create vs_norm.glsl (set it to be copied to the output directory, as usual), and put the following in it:

#version 330

in  vec3 vPosition;
in  vec3 vNormal;
out vec3 v_norm;

uniform mat4 modelview;

void
main()
{
    gl_Position = modelview * vec4(vPosition, 1.0);
    v_norm = normalize(mat3(modelview) * vNormal);
    v_norm = vNormal;
}

Also create fs_norm.glsl, for the fragment shader:

#version 330

in vec3 v_norm;
out vec4 outputColor;

void
main()
{
 vec3 n = normalize(v_norm);

 outputColor = vec4( 0.5 + 0.5 * n, 1.0);
}

This shader will color each fragment based on the normal at that point. Why doesn't it directly draw the normal, instead of doing that additional math? Some normals will have negative components, and that wouldn't be drawn properly on the screen.

The OnUpdateFrame method in the Game class needs to be updated to send the normal information to this shader. Replace it with the following:

        protected override void OnUpdateFrame(FrameEventArgs e)
        {
            base.OnUpdateFrame(e);
            
            ProcessInput();

            List<Vector3> verts = new List<Vector3>();
            List<int> inds = new List<int>();
            List<Vector3> colors = new List<Vector3>();
            List<Vector2> texcoords = new List<Vector2>();
            List<Vector3> normals = new List<Vector3>();

            // Assemble vertex and indice data for all volumes
            int vertcount = 0;
            foreach (Volume v in objects)
            {
                verts.AddRange(v.GetVerts().ToList());
                inds.AddRange(v.GetIndices(vertcount).ToList());
                colors.AddRange(v.GetColorData().ToList());
                texcoords.AddRange(v.GetTextureCoords());
                normals.AddRange(v.GetNormals().ToList());
                vertcount += v.VertCount;
            }

            vertdata = verts.ToArray();
            indicedata = inds.ToArray();
            coldata = colors.ToArray();
            texcoorddata = texcoords.ToArray();
            normdata = normals.ToArray();

            GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("vPosition"));

            GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(vertdata.Length * Vector3.SizeInBytes), vertdata, BufferUsageHint.StaticDraw);
            GL.VertexAttribPointer(shaders[activeShader].GetAttribute("vPosition"), 3, VertexAttribPointerType.Float, false, 0, 0);

            // Buffer vertex color if shader supports it
            if (shaders[activeShader].GetAttribute("vColor") != -1)
            {
                GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("vColor"));
                GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(coldata.Length * Vector3.SizeInBytes), coldata, BufferUsageHint.StaticDraw);
                GL.VertexAttribPointer(shaders[activeShader].GetAttribute("vColor"), 3, VertexAttribPointerType.Float, true, 0, 0);
            }


            // Buffer texture coordinates if shader supports it
            if (shaders[activeShader].GetAttribute("texcoord") != -1)
            {
                GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("texcoord"));
                GL.BufferData<Vector2>(BufferTarget.ArrayBuffer, (IntPtr)(texcoorddata.Length * Vector2.SizeInBytes), texcoorddata, BufferUsageHint.StaticDraw);
                GL.VertexAttribPointer(shaders[activeShader].GetAttribute("texcoord"), 2, VertexAttribPointerType.Float, true, 0, 0);
            }

            if (shaders[activeShader].GetAttribute("vNormal") != -1)
            {
                GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("vNormal"));
                GL.BufferData<Vector3>(BufferTarget.ArrayBuffer, (IntPtr)(normdata.Length * Vector3.SizeInBytes), normdata, BufferUsageHint.StaticDraw);
                GL.VertexAttribPointer(shaders[activeShader].GetAttribute("vNormal"), 3, VertexAttribPointerType.Float, true, 0, 0);
            }

            // Update object positions
            time += (float)e.Time;

            objects[0].Position = new Vector3(0.3f, -0.5f + (float)Math.Sin(time), -3.0f);
            objects[0].Rotation = new Vector3(0.55f * time, 0.25f * time, 0);
            objects[0].Scale = new Vector3(0.5f, 0.5f, 0.5f);

            objects[1].Position = new Vector3(-1f, 0.5f + (float)Math.Cos(time), -2.0f);
            objects[1].Rotation = new Vector3(-0.25f * time, -0.35f * time, 0);
            objects[1].Scale = new Vector3(0.7f, 0.7f, 0.7f);

            // Update model view matrices
            foreach (Volume v in objects)
            {
                v.CalculateModelMatrix();
                v.ViewProjectionMatrix = cam.GetViewMatrix() * Matrix4.CreatePerspectiveFieldOfView(1.3f, ClientSize.Width / (float)ClientSize.Height, 1.0f, 40.0f); 
                v.ModelViewProjectionMatrix = v.ModelMatrix * v.ViewProjectionMatrix;
            }

            GL.UseProgram(shaders[activeShader].ProgramID);

            GL.BindBuffer(BufferTarget.ArrayBuffer, 0);

            // Buffer index data
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, ibo_elements);
            GL.BufferData(BufferTarget.ElementArrayBuffer, (IntPtr)(indicedata.Length * sizeof(int)), indicedata, BufferUsageHint.StaticDraw);
        }

We also need to add a new variable to the Game class itself to store normal information:

        Vector3[] normdata;

Now that the right data is being calculated and buffered, and the shaders are written, we need to load and use the shaders.

In the initProgram method, add the following (put it next to the other shaders being loaded):

            shaders.Add("normal", new ShaderProgram("vs_norm.glsl", "fs_norm.glsl", true));

And the last thing we need to do is to switch the active shader (also in initProgram):

            activeShader = "normal";


Now you can run the code and see the normals on our two cubes. Each face, being flat, will have one normal and therefore one color when this shader is used.


Stay tuned for Part 2, where we use these normals to shade an object loaded from an OBJ file.

28 comments:

  1. This comment has been removed by the author.

    ReplyDelete
  2. Any chance of a 110 version of the shaders?

    v_norm = normalize(mat3(modelview) * vNormal);

    spits this: "GLSL 110 does not allow sub- or super-matrix constructors".

    110 rewrites are required for people following this series on MacOS. It's not that MacOS can't run 110+, but that your C# code requires modifications to run the 330 shaders. Which I can't figure out, hence the 110. Thanks again for the hard work man, these are so helpful

    ReplyDelete
  3. Nm, got it:

    #version 110

    attribute vec3 vPosition;
    attribute vec3 vNormal;
    varying vec3 v_norm;

    uniform mat4 modelview;

    void
    main()
    {
    gl_Position = modelview * vec4(vPosition, 1.0);
    mat3 NicolBolas = mat3(modelview[0].xyz, modelview[1].xyz, modelview[2].xyz);
    v_norm = normalize(NicolBolas * vNormal);
    v_norm = vNormal;
    }

    ReplyDelete
  4. Congrate for the artivcle (very helpfull) ... I'm using the tutorial number 3 as base for develop a application, and Im using, inside onUpdateFrame:
    GL.BindBuffer(BufferTarget.ArrayBuffer, vbo_position);
    GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(vertdata.Length * Vector3.SizeInBytes), vertdata, BufferUsageHint.StaticDraw);
    GL.VertexAttribPointer(attribute_vpos, 3, VertexAttribPointerType.Float, false, 0, 0);

    GL.BindBuffer(BufferTarget.ArrayBuffer, vbo_color);
    GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(coldata.Length * Vector3.SizeInBytes), coldata, BufferUsageHint.StaticDraw);
    GL.VertexAttribPointer(attribute_vcol, 3, VertexAttribPointerType.Float, true, 0, 0);

    GL.BindBuffer(BufferTarget.ElementArrayBuffer, ibo_elements);
    GL.BufferData(BufferTarget.ElementArrayBuffer, (IntPtr)(indicedata.Length * sizeof(int)), indicedata, BufferUsageHint.StaticDraw);

    it's work fine, but when I add...:

    GL.BindBuffer(BufferTarget.ArrayBuffer, vbo_normal);
    GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(Normals.Length * Vector3.SizeInBytes), Normals, BufferUsageHint.StaticDraw);
    GL.VertexAttribPointer(attribute_normal, 3, VertexAttribPointerType.Float, false, 0, 0);

    ...to try draw the normal its doesnt work...

    thanks for the help....

    ReplyDelete
    Replies
    1. Detail: I have the normals correctly calculated in the Normals array ;)

      Delete
    2. Hi Vinicius,

      Can you please put your whole Game.cs file up on Pastebin? It would help me figure out what the issue is.

      Delete
  5. Hi Kabuto, Thanks soo much for your answer....
    Before my Game.cs, let me exply: I have a class called "triangulation", with my vertex, index, color and normal information. I want create a terrain with, at the same time, color and normal, but I only can put one or another...
    Only color: https://onedrive.live.com/redir?resid=64A6A5CC5630D388!639&authkey=!AIw0foZZjQ25vbQ&v=3&ithint=photo%2cpng
    Only Normal: https://onedrive.live.com/redir?resid=64A6A5CC5630D388!640&authkey=!AIJeqMOtWwFS27Q&v=3&ithint=photo%2cpng
    I need this (this image generated with code in XNA embedded in a windows form): https://onedrive.live.com/redir?resid=64A6A5CC5630D388!638&authkey=!AMOx08owsMgeZx8&v=3&ithint=photo%2cpng

    My Game.cs:

    ReplyDelete
    Replies
    1. public Game()
      : base(512, 512, new GraphicsMode(32, 24, 0, 4))
      {
      }

      Matrix4 modelview;
      triangulation triangulation = new triangulation();

      Vector3[] vertdata;
      Vector3[] coldata;
      Vector3[] normals;

      int[] indicedata;

      int ibo_elements;

      Camera cam = new Camera();


      Dictionary textures = new Dictionary();
      Dictionary shaders = new Dictionary();

      string activeShader = "default";


      void initProgram()
      {
      GL.GenBuffers(1, out ibo_elements);

      // Load shaders from file
      shaders.Add("default", new ShaderProgram("vs.glsl", "fs.glsl", true));
      shaders.Add("normal", new ShaderProgram("vs_norm.glsl", "fs_norm.glsl", true));

      activeShader = "default";

      }

      protected override void OnLoad(EventArgs e)
      {
      base.OnLoad(e);

      initProgram();

      Title = "Hello OpenTK!";
      GL.ClearColor(Color.CornflowerBlue);
      GL.PointSize(5f);
      }

      protected override void OnRenderFrame(FrameEventArgs e)
      {
      base.OnRenderFrame(e);
      GL.Viewport(0, 0, Width, Height);
      GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
      GL.Enable(EnableCap.DepthTest);

      shaders[activeShader].EnableVertexAttribArrays();

      GL.UniformMatrix4(shaders[activeShader].GetUniform("modelview"), false, ref modelview);

      GL.DrawElements(BeginMode.Triangles, triangulation.getIndex().Count, DrawElementsType.UnsignedInt, 0);

      shaders[activeShader].DisableVertexAttribArrays();

      GL.Flush();
      SwapBuffers();
      }

      Delete
    2. protected override void OnUpdateFrame(FrameEventArgs e)
      {
      base.OnUpdateFrame(e);


      // Assemble vertex and indice data for all volumes


      vertdata = triangulation.getVertex().ToArray();
      indicedata = triangulation.getIndex().ToArray();
      coldata = triangulation.getColor().ToArray();
      normals = triangulation.CalculateNormals().ToArray();

      GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("vPosition"));

      GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(vertdata.Length * Vector3.SizeInBytes), vertdata, BufferUsageHint.StaticDraw);
      GL.VertexAttribPointer(shaders[activeShader].GetAttribute("vPosition"), 3, VertexAttribPointerType.Float, false, 0, 0);

      // Buffer vertex color if shader supports it
      if (shaders[activeShader].GetAttribute("vColor") != -1)
      {
      GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("vColor"));
      GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(coldata.Length * Vector3.SizeInBytes), coldata, BufferUsageHint.StaticDraw);
      GL.VertexAttribPointer(shaders[activeShader].GetAttribute("vColor"), 3, VertexAttribPointerType.Float, true, 0, 0);
      }

      if (shaders[activeShader].GetAttribute("vNormal") != -1)
      {
      GL.BindBuffer(BufferTarget.ArrayBuffer, shaders[activeShader].GetBuffer("vNormal"));
      GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(normals.Length * Vector3.SizeInBytes), normals, BufferUsageHint.StaticDraw);
      GL.VertexAttribPointer(shaders[activeShader].GetAttribute("vNormal"), 3, VertexAttribPointerType.Float, true, 0, 0);
      }

      modelview = Matrix4.LookAt(cam.getCamaraPosition(), cam.target, cam.getUp()) *
      Matrix4.CreatePerspectiveFieldOfView((float)Math.PI / 4, ClientSize.Width / (float)ClientSize.Height, 0.1f, 10000f);

      GL.UseProgram(shaders[activeShader].ProgramID);

      GL.BindBuffer(BufferTarget.ArrayBuffer, 0);

      // Buffer index data
      GL.BindBuffer(BufferTarget.ElementArrayBuffer, ibo_elements);
      GL.BufferData(BufferTarget.ElementArrayBuffer, (IntPtr)(indicedata.Length * sizeof(int)), indicedata, BufferUsageHint.StaticDraw);

      }

      Delete
    3. Thanks for the help... if you give me permission, letme ask two more asks:
      1) Is posible draw strings in the screen?
      2) OpenTK has a Viewport.Project, like XNA?

      thanks again!!

      Delete
    4. Hi Vinicius,

      It's not possible to draw strings directly with OpenTK. But you can write your own function to do it. Some people use the C# Graphics object to make a bitmap image with the text you want and then load that as a texture to draw. You could also create a pre-rendered image of all the characters in a font and use it as a texture atlas to get the characters you want (but then each character is drawn separately).

      Viewport.Project seems to do the same thing we do in the vertex shader with the mvp matrix. You can do this with OpenTK by using Vector4.Transform.

      Delete
    5. I was able to get the code you posted working on my end (I had to fill in a little bit because Blogger comments ate the parts with angle brackets; that's why I recommend http://pastebin.com/ for posting code). The problem may be in your shader or the Triangulation class. Can you please post those as well?

      Delete
    6. Hi Kabuto.... the triangularion class is only a class with the vectore's and index... and the shader are the sames from your previous post... the vs.glsl, fs.glsl, vs_norm.glsl and fs_norm.glsl.
      thanks

      Delete
    7. Hi Vinicius,

      That would explain the issue. One of those shaders shows colors, the other shows normals. You would need to create a shader that displays both.

      Delete
  6. Hi, Kabuto, I didn't know whether to post it here or part 2 but I'm having issues with my material.

    Error CS1061 'TexturedCube' does not contain a definition for 'Material' and no extension method 'Material' accepting a first argument of type 'TexturedCube' could be found (are you missing a using directive or an assembly reference?)

    when trying to do tc.Material = materials["opentk1"];

    Likewise
    Error CS1061 'Volume' does not contain a definition for 'Material' and no extension method 'Material' accepting a first argument of type 'Volume' could be found (are you missing a using directive or an assembly reference?)

    GL.Uniform3(shaders[activeShader].GetUniform("material_ambient"), ref v.Material.AmbientColor);

    this is true for the rest of material shaders (diffuse, specularColor, specularExponent, etc..)

    http://pastebin.com/2Tuj0j7c //Game.cs
    http://pastebin.com/UUJRzhEN //Volume.cs
    http://pastebin.com/grCxyT6T //Material.cs

    ReplyDelete
    Replies
    1. Hi NNYC,

      Thanks for pointing this out. Looks like I missed a line. I'll go add it to the tutorial now.

      In the Volume class, add:

      public Material Material = new Material();

      Delete
    2. Thank you for the help.
      I am following this guide concurrently while learning C# so some stuff are a bit foreign to me. Especially (at the time) the idea of separating classes into different files when until recently I just had it in one just very large file.

      Again thank you for making this tutorial it has been very informative about shaders and graphics in general so far.

      Delete
  7. Hi Kabuto!

    Thank you for this great tutorial. I've been following your tutorial and I'm a bit confused, how did we get the colors in the

    public static ObjVolume LoadFromString(string obj) ?

    we previously have this line:

    // Dummy color/texture coordinates for now
    colors.Add(new Vector3((float) Math.Sin(vec.Z), (float) Math.Sin(vec.Z), (float) Math.Sin(vec.Z)));
    texs.Add(new Vector2((float) Math.Sin(vec.Z), (float) Math.Sin(vec.Z)));

    but in this page I can't seem to find anywhere that we add colors.

    This line is still in our OnUpdateFrame

    colors.AddRange(v.GetColorData().ToList());

    but our GetColorData returns an array uninitialized values (new Vector3)

    public override Vector3[] GetColorData()
    {
    return new Vector3[ColorDataCount];
    }


    Am I missing something?

    ReplyDelete
  8. This comment has been removed by the author.

    ReplyDelete
  9. Hello Kabuto
    Just wondering if your material file works with any object? Keep up the tutorials man :) looking forward to #9!

    ReplyDelete
    Replies
    1. Should work with any object, as long as it's loaded properly.

      Delete
  10. Hello Kabuto. Please tell me How to calculate indices, and texture coordinates if known only to the coordinates of the vertices?

    ReplyDelete
    Replies
    1. Hello,

      Unfortunately, there's no easy way to do that. It would be different for each model. Unless you have more information about the model (e.g. you know that it is convex, or you know the vertices are each used once and are in order), you can't know enough to determine indices.

      Texture coordinates are similar, but they may be possible if you don't need the specific wrapping for the model and have the indices (e.g. spherical wrapping can make a usable set of texture coordinates: https://www.mvps.org/directx/articles/spheremap.htm - sadly I could only find a DirectX example, but the math is the same)

      Delete
    2. Im found cool way, i use your method Sin(z) and this worked)

      Delete
  11. Hello.

    I tried running this code, but program crashes hard ("vshost.exe stopped working") when I use normal shader. I triple checked step by step in debugger, and all C# objects seem to be created properly, UpdateFrame loop works correctly - debugger doesn't show anything wrong or sinister. Yet when I step out of debugger, it instantly crashes.

    Any ideas on that behavior?

    ReplyDelete
    Replies
    1. Update: I ran debugger through OnRenderFrame, and it appears that the program crashes on GL.DrawElements

      Delete
    2. Update: Nevermind, I was actually a moron and just forgot to copy OnUpdateFrame. That mistake was a result of me backing up from initial pasting of that code to change something that was fishy to find out what was going on, then after correction I forgot to copy it back again...

      Delete