Lighting OpenGL (OpenTK/C#)

One of the most important part of graphics programming is lighting. The lighting feature that makes really pretty and gives more realistic effect to 3D environment. This post won't contain theoretical information about lighting. I'm going to try adding phong reflection model. You can get more information about phong reflection from this page Phong reflection model - Wikipedia.

We need to know what are normals, vectors, normalize, dot product, etc. Because we are going to use for lighting.

Normals: these are unit vectors and also they must be perpendicular to the surface.
Normalize: It makes vector a unit vector, basically. We will use this method before dot product operation.
Dot product: The brightness is calculated with this formula.

for example the use of the dot product:

a: vec3(2, -1, 4)
b: vec3(3, 2, -1)

a.b = ?

a.b = 2.3 + (-1.2) + (4.-1)
= 6 - 2 - 4
= 0

Let's get into the programming part. Let's add new ObjectType called Light:
    public enum ObjectType
    {
        Cube,
        Plane,
        Triangle2D,
        Quad2D,
        Light
    }
Also, a new method called AddObject to ObjectManager as follows, (we did method overloading, this is just for easy usage, but don't remember to declare objectType field as public, also it can be changed as property):
        public void AddObject(GameObject obj)
        {
            this.VAOManager.CreateVAO(obj.objectType);
            this.ShaderManager.CreateShader(obj.objectType);
            // GameObject gameObject = new GameObject(obj.objectType);
            obj.VAO = this.VAOManager.VAOs[obj.objectType];
            obj.EBO = this.VAOManager.EBOs[obj.objectType];
            obj.IndexCount = this.VAOManager.IndexCounts[obj.objectType];
            obj.Shader = this.ShaderManager.Shaders[obj.objectType];
            this.gameObjects.Add(obj);
        }
I will create a new mesh method for light. This method create a 3d cube for light object:
        private void GenerateLight()
        {
            float len = 0.3f;

            var (r,g,b,a) = (1.0f, 1.0f, 1.0f, 1.0f);

            float[] vertices = 
            {
                -len, -len, -len, r, g, b, a,  // 0
                 len, -len, -len, r, g, b, a,  // 1 
                 len, -len,  len, r, g, b, a,  // 2 
                -len, -len,  len, r, g, b, a,  // 3 

                -len,  len, -len, r, g, b, a,  // 4
                 len,  len, -len, r, g, b, a,  // 5
                 len,  len,  len, r, g, b, a,  // 6
                -len,  len,  len, r, g, b, a  // 7
            };

            int[] indices = 
            {
                7, 6, 2,  7, 2, 3,      // front face
                4, 5, 1,  4, 1, 0,      // back face
                4, 5, 6,  4, 6, 7,      // top face
                0, 1, 2,  0, 2, 3,      // bottom face
                6, 5, 1,  6, 1, 2,      // right face
                7, 4, 0,  7, 0, 3       // left face
            };
    
            indexCount = 36;

            GenerateVAOforLight(len, vertices, indices);
        }
The GenerateVAOforLight as follows:
        private void GenerateVAOforLight(float len, float[] vertices, int[] indices)
        {
            GL.GenVertexArrays(1, out vao);
            GL.BindVertexArray(vao);
            // create vbo to store the data in opengl and copy data to vbo
            GL.GenBuffers(1, out vbo);
            GL.BindBuffer(BufferTarget.ArrayBuffer, vbo);
            GL.BufferData(BufferTarget.ArrayBuffer, sizeof(float) * vertices.Length, vertices, BufferUsageHint.StaticDraw);
            // create ebo
            GL.GenBuffers(1, out ebo);
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, ebo);
            GL.BufferData(BufferTarget.ElementArrayBuffer, sizeof(int) * indices.Length, indices, BufferUsageHint.StaticDraw);
            // tell opengl how to use the data via attributes
            // position attribute
            // int attrPosition_Position = GL.GetAttribLocation(shader.ShaderProgram, "aPos");
            int attrPosition_Position = 0;
            GL.EnableVertexAttribArray(attrPosition_Position);
            GL.VertexAttribPointer(attrPosition_Position, 3, VertexAttribPointerType.Float, false, sizeof(float) * 7, 0);
            int attrPosition_Color = 1;
            GL.EnableVertexAttribArray(attrPosition_Color);
            GL.VertexAttribPointer(attrPosition_Color, 4, VertexAttribPointerType.Float, false, sizeof(float) * 7, sizeof(float) * 3);
            
            GL.BindBuffer(BufferTarget.ArrayBuffer, 0);
            GL.BindVertexArray(0);
            Console.WriteLine("light vao: " + vao  + " vbo: " + vbo + " ebo: " + ebo);
        }
    }
In the Mesh class constructor:
        public Mesh(ObjectType objType)
        {
            if(objType is ObjectType.Quad2D) GenerateQuad();
            if(objType is ObjectType.Cube) GenerateCube();
            if(objType is ObjectType.Light) GenerateLight();
        }
Also, we need to create a shader for light. I created a folder named light in shaders folder. I added the vertexShader and fragmentShader file like below:
#version 330 core

layout(location = 0) in vec3 aPos;
layout(location = 1) in vec4 aColor;

uniform mat4 uTransform;
uniform mat4 projection;
uniform mat4 view;

out vec4 passColor;

void main()
{
    gl_Position = vec4(aPos, 1.0) * uTransform * view * projection;
    passColor = aColor;
}
the fragment shader:
#version 330 core

in vec4 passColor;

out vec4 FragColor;

uniform vec4 lightColor;

void main()
{
    FragColor = passColor * lightColor;
}
We will create the shader in ShaderManager class(We need to refactor this method but that's good for now. But if you want to refactor the code then go ahead.):
        public void CreateShader(ObjectType objectType)
        {
            if(!Shaders.ContainsKey(objectType))
            {
                if(objectType == ObjectType.Quad2D) 
                {
                    Shader shader = new Shader("shaders/triangle/vertexShader.glsl","shaders/triangle/fragmentShader.glsl");
                    shaders[objectType] = shader;
                }
                if(objectType == ObjectType.Cube)
                {
                    Shader shader = new Shader("shaders/cube/vertexShader.glsl","shaders/cube/fragmentShader.glsl");
                    shaders[objectType] = shader;
                }
                if(objectType == ObjectType.Light)
                {
                    Shader shader = new Shader("shaders/light/vertexShader.glsl","shaders/light/fragmentShader.glsl");
                    shaders[objectType] = shader;
                }    
            }
        }
I'm going to create a Light class in objects folder. This class is going to inherit from the GameObject class. But we need to declare some methods of the GameObject class as virtual to override. The Light class is responsible for shaders of all objects that need to be illuminated. 
namespace OpenTKTutorial
{
    public class Light: GameObject
    {
        ShaderManager shaderManager;
        public Vector4 LightColor {get; set;}
        public Vector3 LightPosition {get; set;}
        public Light(ShaderManager shaderManager, ObjectType objectType = ObjectType.Light) : base(objectType)
        {
            this.shaderManager = shaderManager;
            LightColor = new Vector4(1f, 0, 0, 1f);
        }

        public override void Update()
        {
            Matrix4 Translation = OpenTK.Mathematics.Matrix4.CreateTranslation(this.Transform.Position);
            Matrix4 Rotation = OpenTK.Mathematics.Matrix4.CreateRotationZ(MathHelper.DegreesToRadians(this.Transform.Rotation.Z));
            Matrix4 Scale = OpenTK.Mathematics.Matrix4.CreateScale(this.Transform.Scale);
            // rule : Translate x Rotation x Scale
            Matrix4 Transform = Scale * Rotation * Translation;
            Transformation = Transform;

            foreach (var shader in shaderManager.Shaders)
            {
                if(shader.Value != this.Shader)
                {
                    GL.UseProgram(shader.Value.ShaderProgram);
                    int uniformLocation_lightColor = GL.GetUniformLocation(shader.Value.ShaderProgram, "lightColor");
                    GL.Uniform4(uniformLocation_lightColor, LightColor);
                    int uniformLocation_lightPosition = GL.GetUniformLocation(shader.Value.ShaderProgram, "lightPosition");
                    GL.Uniform3(uniformLocation_lightPosition, this.Transform.Position);
                }
            }
        }

        public override void Draw()
        {
            GL.UseProgram(Shader!.ShaderProgram);
            int uniformLocation_Transformation = GL.GetUniformLocation(Shader.ShaderProgram, "uTransform");
            GL.UniformMatrix4(uniformLocation_Transformation, true, ref Transformation);
            GL.BindVertexArray(VAO);
            GL.DrawElements(PrimitiveType.Triangles, IndexCount, DrawElementsType.UnsignedInt, 0);
            GL.BindVertexArray(0);
        }
    }
}
Let's visualize lighting as follows:
Lighting
We need to normals from mesh. Normally, we don't need to calculate normals because when we will use Blender for models, this model will be contain normals already. Right now, we have no option like that. We need to define normals of the cube:
Normals of cube
        private void GenerateCube()
        {
            float len = 0.3f;

            float[] vertices = 
            {
                -len, -len, -len,   // 0
                 len, -len, -len,   // 1 
                 len, -len,  len,   // 2 
                -len, -len,  len,   // 3 

                -len,  len, -len,   // 4
                 len,  len, -len,   // 5
                 len,  len,  len,   // 6
                -len,  len,  len,   // 7
            };

            // 36 * 8 = 288
            float[] cubeVertices = new float[288];

            int[] indices = 
            {
                7, 6, 2,  7, 2, 3,      // front face
                4, 5, 1,  4, 1, 0,      // back face
                4, 5, 6,  4, 6, 7,      // top face
                0, 1, 2,  0, 2, 3,      // bottom face
                6, 5, 1,  6, 1, 2,      // right face
                7, 4, 0,  7, 0, 3       // left face
            };

            float[] texCoords = { 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f }; 
            
            float[] normals = 
            {
                 0f,  0f,  1f,      // front
                 0f,  0f, -1f,      // back
                 0f,  1f,  0f,      // top
                 0f, -1f,  0f,      // bottom
                 1f,  0f,  0f,      // right
                -1f,  0f,  0f       // right
            };

            int nLine = 0;
            for (int i = 0; i < indices.Length; i++)
            {
                if(i!=0 && i%6==0)
                {
                    nLine++;
                }
                int index = indices[i];
                cubeVertices[i * 8 + 0] = vertices[(index * 3) + 0];
                cubeVertices[i * 8 + 1] = vertices[(index * 3) + 1];
                cubeVertices[i * 8 + 2] = vertices[(index * 3) + 2];
                cubeVertices[i * 8 + 3] = texCoords[(i % 6) * 2];
                cubeVertices[i * 8 + 4] = texCoords[(i % 6) * 2 + 1];
                cubeVertices[i * 8 + 5] = normals[(nLine % 6) * 3];
                cubeVertices[i * 8 + 6] = normals[(nLine % 6) * 3 + 1];
                cubeVertices[i * 8 + 7] = normals[(nLine % 6) * 3 + 2];
            }


            GenerateVAOforCube(len, cubeVertices, indices);
        }
I added new attribute pointer for normals in GenerateVAOforCube:
        private void GenerateVAOforCube(float len, float[] vertices, int[] indices)
        {
            ...
            
            int attrPosition_Position = 0;
            GL.EnableVertexAttribArray(attrPosition_Position);
            GL.VertexAttribPointer(attrPosition_Position, 3, VertexAttribPointerType.Float, false, sizeof(float) * 8, 0);
            
            int attrPosition_TextureCoords = 1;
            GL.EnableVertexAttribArray(attrPosition_TextureCoords);
            GL.VertexAttribPointer(attrPosition_TextureCoords, 2, VertexAttribPointerType.Float, false, sizeof(float) * 8, sizeof(float) * 3);
            
            int attrPosition_Normal = 2;
            GL.EnableVertexAttribArray(attrPosition_Normal);
            GL.VertexAttribPointer(attrPosition_Normal, 3, VertexAttribPointerType.Float, false, sizeof(float) * 8, sizeof(float) * 5);

            ...
        }
Let's update the vertex shader of cube:
#version 330 core

layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTexCoord;
layout(location = 2) in vec3 aNormal;

uniform mat4 uTransform;
uniform mat4 projection;
uniform mat4 view;

uniform vec3 lightPosition;

out vec2 passTexCoord;

void main()
{
    gl_Position = vec4(aPos, 1.0) * uTransform * view * projection;
    passTexCoord = aTexCoord;
}
Now, we can start to calculating of lighting effect on shader. Let's move on the vertex shader:
#version 330 core

layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTexCoord;
layout(location = 2) in vec3 aNormal;

uniform mat4 uTransform;
uniform mat4 projection;
uniform mat4 view;

uniform vec3 lightPosition;

out vec2 passTexCoord;
out vec3 passNormal;
out vec3 lightVector;

void main()
{
    gl_Position = vec4(aPos, 1.0) * uTransform * view * projection;
    passTexCoord = aTexCoord;

    passNormal = (vec4(aNormal, 0.0) * uTransform).xyz;
    lightVector = lightPosition - (vec4(aPos, 1.0) * uTransform).xyz;
}
After calculation of normal and lightVector, we will pass these vectors to the fragment shader:
#version 330 core

in vec2 passTexCoord;
in vec3 passNormal;
in vec3 lightVector;

out vec4 FragColor;

uniform sampler2D textureSampler;
uniform vec4 lightColor;

void main()
{
    vec3 normalizedNormal = normalize(passNormal);
    vec3 normalizedLightVector = normalize(lightVector);
    float calcDot = dot(normalizedNormal, normalizedLightVector);
    float brightness = max(calcDot, 0.0);
    vec4 diffuse = brightness * lightColor;
    
    FragColor = texture(textureSampler, passTexCoord) * diffuse;
}
The vectors are normalized and used dot production after normalizing. The brightness value is obtained with the smallest value being at least 0. The brightness and the color of the light are multiplied and diffuse is obtained. Finally we multiplied the diffuse with texture pixel, and we get the illuminated pixel value. One of the most important issues to be considered is the multiplication order. If the multiplication order is not as above, you will not get the desired result:
lighting
That's cool but it's too dark. Especially there faces that don't see light. Let's define an ambient variable in the fragment shader:
void main()
{
    float ambient = 0.1;

    vec3 normalizedNormal = normalize(passNormal);
    vec3 normalizedLightVector = normalize(lightVector);
    float calcDot = dot(normalizedNormal, normalizedLightVector);
    float brightness = max(calcDot, 0.0);
    vec4 diffuse = brightness * lightColor;

    FragColor = texture(textureSampler, passTexCoord) * (diffuse + ambient);
}
The result with ambient value:
opengl lighting
I think that's enough for this post. But we're not done with lighting yet. 

No comments:

Post a Comment