Wednesday, December 16, 2015

OpenTK Tutorial 8 Part 2: Adding Textures and Specular Lighting

In the first part of this tutorial, we added normals. In this tutorial, we'll use them to do something interesting, adding specular lighting and diffuse maps.



(UPDATED 2-10-16: Missed a line in the Volume class. Thanks, NNYC, for pointing it out!)

We can see the normals now, but that's not really what our end goal was. We want lighting. In this tutorial, we'll be adding a light to the scene.

The lighting method we'll be using has three steps. The first is ambient lighting, which represents the general light in the environment that isn't coming from a specific source. The second is diffuse shading, which is the lighting based on the angle the light hits the surface, making sides facing the light brighter than sides facing away. The final step is specular highlighting. Specular highlights make an object look shiny, giving it an implied texture.

The end result here is essentially the Blinn-Phong shading model.
Steps in the lighting process (from Wikimedia)
Now that we've talked a little bit about what we're going to do, let's write some code!

First, add a "Light" class. This will store information about a light in the scene. Fill in the class with the following code:

    class Light
    {
        public Light(Vector3 position, Vector3 color, float diffuseintensity = 1.0f, float ambientintensity = 1.0f)
        {
            Position = position;
            Color = color;

            DiffuseIntensity = diffuseintensity;
            AmbientIntensity = ambientintensity;
        }

        public Vector3 Position;
        public Vector3 Color = new Vector3();
        public float DiffuseIntensity = 1.0f;
        public float AmbientIntensity = 0.1f;
    }

Our lights right now are "point lights", which only have a position and a color. The two intensities will allow us to tweak the lighting easier when the scene gets more interactive.

Next, add an instance of this class to the Game class:

        Light activeLight = new Light(new Vector3(), new Vector3(0.9f, 0.80f, 0.8f));

We'll have a lot more information to send to the shader now. We need to send all the light's properties and the material properties from the objects. Replace the existing OnRenderFrame method in the Game class with the following:

        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);

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

            int indiceat = 0;

            foreach (Volume v in objects)
            {
                GL.BindTexture(TextureTarget.Texture2D, v.TextureID);

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

                if (shaders[activeShader].GetAttribute("maintexture") != -1)
                {
                    GL.Uniform1(shaders[activeShader].GetAttribute("maintexture"), v.TextureID);
                }

                if (shaders[activeShader].GetUniform("view") != -1)
                {
                    GL.UniformMatrix4(shaders[activeShader].GetUniform("view"), false, ref view);
                }

                if (shaders[activeShader].GetUniform("model") != -1)
                {
                    GL.UniformMatrix4(shaders[activeShader].GetUniform("model"), false, ref v.ModelMatrix);
                }

                if (shaders[activeShader].GetUniform("material_ambient") != -1)
                {
                    GL.Uniform3(shaders[activeShader].GetUniform("material_ambient"), ref v.Material.AmbientColor);
                }

                if (shaders[activeShader].GetUniform("material_diffuse") != -1)
                {
                    GL.Uniform3(shaders[activeShader].GetUniform("material_diffuse"), ref v.Material.DiffuseColor);
                }

                if (shaders[activeShader].GetUniform("material_specular") != -1)
                {
                    GL.Uniform3(shaders[activeShader].GetUniform("material_specular"), ref v.Material.SpecularColor);
                }

                if (shaders[activeShader].GetUniform("material_specExponent") != -1)
                {
                    GL.Uniform1(shaders[activeShader].GetUniform("material_specExponent"), v.Material.SpecularExponent);
                }

                if (shaders[activeShader].GetUniform("light_position") != -1)
                {
                    GL.Uniform3(shaders[activeShader].GetUniform("light_position"), ref activeLight.Position);
                }

                if (shaders[activeShader].GetUniform("light_color") != -1)
                {
                    GL.Uniform3(shaders[activeShader].GetUniform("light_color"), ref activeLight.Color);
                }

                if (shaders[activeShader].GetUniform("light_diffuseIntensity") != -1)
                {
                    GL.Uniform1(shaders[activeShader].GetUniform("light_diffuseIntensity"), activeLight.DiffuseIntensity);
                }

                if (shaders[activeShader].GetUniform("light_ambientIntensity") != -1)
                {
                    GL.Uniform1(shaders[activeShader].GetUniform("light_ambientIntensity"), activeLight.AmbientIntensity);
                }

                GL.DrawElements(BeginMode.Triangles, v.IndiceCount, DrawElementsType.UnsignedInt, indiceat * sizeof(uint));
                indiceat += v.IndiceCount;
            }

            shaders[activeShader].DisableVertexAttribArrays();

            GL.Flush();
            SwapBuffers();
        }

If you look over the code, there's two variables there that isn't defined yet: view, and the Material of each Volume.

To add the Material property for each volume, add it to the Volume class:

        public Material Material = new Material();


"view" will be the view matrix from the camera, which will be required for part of the shader code. First, add it as a variable to the Game class:

        Matrix4 view = Matrix4.Identity;


Now we need to assign it a real value. At the end of OnUpdateFrame, add:


            view = cam.GetViewMatrix();

Next we'll add the shaders. Add a "vs_lit.glsl" file to your project, and make sure Copy to Output Directory is set to "Copy if newer" or "Copy always". In it, put the following:

#version 330

in vec3 vPosition;
in vec3 vNormal;
in vec2 texcoord;

out vec3 v_norm;
out vec3 v_pos;
out vec2 f_texcoord;

uniform mat4 modelview;
uniform mat4 model;
uniform mat4 view;

void
main()
{
 gl_Position = modelview * vec4(vPosition, 1.0);
 f_texcoord = texcoord;

 mat3 normMatrix = transpose(inverse(mat3(model)));
 v_norm = normMatrix * vNormal;
 v_pos = (model * vec4(vPosition, 1.0)).xyz;
}

This shader is pretty simple. The first two lines of the main method are from the textured shader we've used before. The remainder is similar to the normal visualization shader, but with a change to move normals with the model. Now we change the normals to transform with the object.

Next comes the fragment shader. This time, add a new file as "fs_lit.glsl" and put in it:

#version 330

in vec3 v_norm;
in vec3 v_pos;
in vec2 f_texcoord;
out vec4 outputColor;

uniform sampler2D maintexture;
uniform mat4 view;

uniform vec3 material_ambient;
uniform vec3 material_diffuse;
uniform vec3 material_specular;
uniform float material_specExponent;

uniform vec3 light_position;
uniform vec3 light_color;
uniform float light_ambientIntensity;
uniform float light_diffuseIntensity;

void
main()
{
 vec2 flipped_texcoord = vec2(f_texcoord.x, 1.0 - f_texcoord.y);
 vec3 n = normalize(v_norm);

 // Colors
 vec4 texcolor = texture2D(maintexture, flipped_texcoord.xy);
 vec4 light_ambient = light_ambientIntensity * vec4(light_color, 0.0);
 vec4 light_diffuse = light_diffuseIntensity * vec4(light_color, 0.0);

 // Ambient lighting
 outputColor = texcolor * light_ambient * vec4(material_ambient, 0.0);

 // Diffuse lighting
 vec3 lightvec = normalize(light_position - v_pos);
 float lambertmaterial_diffuse = max(dot(n, lightvec), 0.0);
 outputColor = outputColor + (light_diffuse * texcolor * vec4(material_diffuse, 0.0)) * lambertmaterial_diffuse;

 // Specular lighting
 vec3 reflectionvec = normalize(reflect(-lightvec, v_norm));
 vec3 viewvec = normalize(vec3(inverse(view) * vec4(0,0,0,1)) - v_pos); 
 float material_specularreflection = max(dot(v_norm, lightvec), 0.0) * pow(max(dot(reflectionvec, viewvec), 0.0), material_specExponent);
 outputColor = outputColor + vec4(material_specular * light_color, 0.0) * material_specularreflection;
}


This is a much longer shader, but it's the three steps we talked about earlier. First it creates ambient light, then it calculates diffuse light, then it add specular highlights.
Vectors involved in our lighting shader (from Wikimedia)
There's a lot of vector math involved, but the simple version of it is that the diffuse and specular are both based on the angle of the light. The diffuse only cares about the angle between the light and the normal, the closer it is to zero the brighter the lighting. The specular highlights include the angle to the eye viewing the scene. The highlighting is based on the reflection of the light back into our eyes from a shiny surface. The "specular exponent" changes how shiny a material is: with a higher exponent, the highlights are smaller and sharper, making it look shinier.

We have our shader in now, but we need to load it if we want to render anything with it. In the Game class' initProgram method, after the other shaders are loaded, add:

            shaders.Add("lit", new ShaderProgram("vs_lit.glsl", "fs_lit.glsl", true));


Next, change the line in initProgram where activeShader is set to the normal shader to use the new one:

            activeShader = "lit";


If we run this...


Everything is black. But don't worry, everything is (probably) okay! This is because both of the cubes were set to use a texture, but they're using a material with everything set to pitch black. We've loaded materials in a previous tutorial, but we never assigned them properly to our objects!

In initProgram, where the first TexturedCube is created, add:

            tc.Material = materials["opentk1"];

Where the second TexturedCube is created, add:

            tc2.Material = materials["opentk2"];

If we run it now:
This is looking better. If you move around the scene, you should see that the lighting is consistent in direction.

To be able to see the light better, let's add a textured sphere to the scene.

Download the sphere model and texture from this link. Add the two files from that archive to the project, make sure to set "Copy to Output Directory", and then we can add it to the scene.

In initProgram:

            textures.Add("earth.png", loadImage("earth.png"));
            ObjVolume earth = ObjVolume.LoadFromFile("earth.obj");
            earth.TextureID = textures["earth.png"];
            earth.Position += new Vector3(1f, 1f, -2f);
            earth.Material = new Material(new Vector3(0.15f), new Vector3(1), new Vector3(0.2f), 5);
            objects.Add(earth);


Now, if you run the program one more time, you'll see:

Now we have a texture mapped, smooth sphere in our scene, which really helps to demonstrate the lighting.

14 comments:

  1. These are great man, was worried you'd stopped working on them! Would love to see multiple light sources next. Invaluable resource you're making here, keep it up! Have a great next cuppa weeks an all, peace.

    ReplyDelete
    Replies
    1. *cuppla weeks and all
      aka couple of weeks and all

      Delete
  2. This comment has been removed by the author.

    ReplyDelete
  3. I had given up on finding a good set of tutorials for opengl in any language, but you sir have created just that. Not only will this help people programming in C#, it can also be easily ported to other languages!

    ReplyDelete
  4. Didn't tell when to initial colors in the ObjVolume class and the vertices also.

    System.NullReferenceException was unhandled

    StackTrace:
    OpenTKTutorial7.ObjVolume.get_ColorDataCount()
    OpenTKTutorial7.ObjVolume.GetColorData()
    OpenTKTutorial7.Game.OnUpdateFrame(FrameEventArgs e)

    ReplyDelete
  5. Hi NeoKabuto,

    These tutorials are really useful and have helped me get a working engine up and running.
    What is necessary to use multiple shaders? You make a dictionary which stores all shaders, yet you never show how we could use more than one for an OnRenderFrame.

    I assume you need to call
    GL.UseProgram(shaders[activeShader].ProgramID);
    shaders[activeShader].EnableVertexAttribArrays();
    //Some draw code here
    shaders[activeShader].DisableVertexAttribArrays();

    //and then..
    GL.UseProgram(shaders["otherShader"].ProgramID);
    shaders["otherShader"].EnableVertexAttribArrays();
    //Some other draw code here
    shaders["otherShader"].DisableVertexAttribArrays();

    however this does not seem to work, am I missing some extra steps?
    Thanks,
    -j

    ReplyDelete
  6. I had to stop on this tutorial, because I got BadImageFormatException while trying to run this. (ShaderProgram, line GL.GetActiveUniform(ProgramID, i, 256, out length, out info.size, out info.type, name); )

    Erm. Help, please? :|

    ReplyDelete
    Replies
    1. I started experimenting with all the code, and what I changed:

      - Light to struct from class
      - Same for Material

      - After throwing entire shader code to trash and replacing it with simple setting white as an output color - it started working. -> thus I presume there was something wrong within the fragment shader itself.

      Delete
  7. I am trying to run the program using the lighting shaders. Half the time it crashes without throwing an error while reading the fragment shader. I have not a clue why any advice would be appreciated.

    Furthermore occasionally i get a "AccessViolationException: Attempted to read or write protected memory." error message on GL.CompileShader(address). I also have no clue why this happens either.

    Thanks for any information give.

    ReplyDelete
    Replies
    1. To anyone who had similar problems of it crashing using lighting shaders, the solution I achieved was to set the target framework to .NET 4, and use an older version of openTK (v1.1.2349.61993) instaed of v2.

      Delete
    2. even i have the same problem program is crushing and stop working

      Delete
  8. You never initialized the Vector3[] vertices and Vector3[] colors in the ObjVolume class, which causes problems here.

    ReplyDelete
  9. If anyone gets crashes while loading the lit shader and loading models, replace

    StringBuilder name = new StringBuilder();

    GL.GetActiveUniform(ProgramID, i, 256, out length, out info.size, out info.type, name);

    with

    StringBuilder name = new StringBuilder(256);

    GL.GetActiveUniform(ProgramID, i, name.Capacity, out length, out info.size, out info.type, name);

    took me a while to find the problem and solve it :D

    ReplyDelete