Writing / Articles
ArticleCode

Basic lighting models

A practical introduction to the three foundational shading models — Lambert, Phong, and Blinn-Phong — implemented as Unity shaders, covering diffuse and specular lighting from first principles.

Introduction

This article opens the "Computer graphics" section of the site. Today we'll look at the basic principles of shading polygonal models in modern computer graphics, using three fundamental shading models as examples.

In the Russian-speaking part of the internet these models are usually called "lighting models," which isn't quite accurate. First, in English they're called "shading models" — that's the literal translation. Second, "lighting model" is more reasonably used to cover all the components that make up the final image: indirect lighting, ambient occlusion, shadows, reflections, and so on.

Shading models can be roughly split into two types: physically based and empirical.

Physically based models aim to produce a result as close as possible to what a person would actually see in the same situation in the real world. To achieve that, they account not only for the basic laws of light propagation, but also for effects that don't often get modeled in, say, video games — things like a thin film on a metal surface, light transport through wax or skin, or modeling composite materials (like a "metallic" car paint surface).

This approach is often quite complex to implement and overkill for games. On top of that, an accurate lighting calculation is usually computationally expensive even for a simple 3D scene. So instead of physically based lighting, games typically use a different approach: empirical. Some physical laws and effects are ignored outright, and the rest is approximated by a simplified model whose parameters are tuned to give a result that looks convincing enough.

In this article we'll look at three basic empirical shading models: the Lambert model, the Phong model, and the Blinn-Phong model. These are the models game developers build on, extending them as far as they need. Working through them will also give us a sense of the principles developers rely on when computing lighting for polygonal models.

Infrastructure

I don't think it makes sense to cover things like window creation, setting up a rendering context, or loading textures, models, shaders, and so on in computer graphics articles: these problems have already been solved many times over in various libraries and game engines, and reinventing that wheel for the tenth time would just waste my time and yours.

Instead, we'll use Unity Engine and focus only on the technologies, effects, and so on that we actually care about here.

Shading components

In standard shading models, the final illumination at a point is usually made up of three components:

  1. Ambient
  2. Diffuse
  3. Specular

Ambient: essentially a constant that's added to the illumination at every point on the model. Importantly, the ambient component doesn't depend on where the point sits in space.

Diffuse: this component is computed using Lambert's law, based on the assumption that light hitting a surface scatters evenly in all directions.

Specular: the component responsible for modeling a material's reflective properties. Adding it lets us render highlights on the surface of the model.

The resulting illumination formula at a point looks roughly like this (I — intensity):

I=Ia+Id+IsI = I_a + I_d + I_s

Visually, the contribution of each component to the final result looks roughly like this:

First steps: visualizing normals

Let's start with a simple warm-up shader that just outputs our object's normals:

glsl
Shader "karonator/JustNormals"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float3 normal: NORMAL0;
            };

            fragment_data vert(vertex_data v)
            {
                fragment_data o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.normal = v.normal;
                return o;
            }

            fixed4 frag(fragment_data i): COLOR
            {
                return float4(i.normal, 1.0);
            }

            ENDCG
        }
    }
}

In the example above, we get the position and normal in the vertex shader and pass that data to the fragment shader, where we output the normals as the result of our shader. The result should look roughly like this:

An important thing to notice here: computer graphics uses polygonal models — models made up of small triangles, or polygons. So why don't we see any facets in the picture above, with the normals looking "smooth" even though there's logically a crease between adjacent polygons on the model that should be visually noticeable? The answer is simple: when data is passed from the vertex shader to the fragment shader, it gets interpolated, which is what produces that clean, smoothed-out result.

After this interpolation, the normal may no longer be normalized (its length won't equal 1).

This matters for the calculations, so after passing it into the fragment shader you need to renormalize the data.

The Lambert model

The Lambert model is a shading model that describes ideal diffuse lighting. It assumes that light hitting a surface scatters evenly in every direction.

To compute the illumination at a point in the scene, we need the normal at that point and the direction toward the light source:

Lambertian lighting is computed from the cosine of the angle between the normal N and the direction toward the light source L: the smaller the angle between these two vectors, the closer the cosine value gets to one (the light hits the surface head-on and the point is well lit).

If the angle between the vectors is large, the cosine value will be close to zero (which makes sense: the light hits at a shallow angle and poorly illuminates the point).

The final Lambertian illumination formula:

I=max(0,dot(N,L))I = max(0, dot(N, L))

Note that we're taking the dot product of the vectors: it will equal the cosine between them as long as the vectors are normalized (we'll make sure of that).

Time to write a shader that implements this formula:

glsl
Shader "karonator/Shading/Lambert"
{
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;
                float3 normal: NORMAL0;
            };

            fragment_data vert (vertex_data v)
            {
                fragment_data o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                
                // Transform the normal and the point's position into world space
                // and pass them to the fragment shader
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);
                o.normal = mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz;

                return o;
            }

            fixed4 frag (fragment_data i):COLOR
            {
                // Compute and normalize the direction toward the light source
                float3 L = normalize(_WorldSpaceLightPos0.xyz - i.pos_world.xyz);

                // After being passed to the fragment shader, the normal needs to be normalized too
                float3 N = normalize(i.normal);

                float result = max(dot(L, N), 0.0);
                return result;
            }

            ENDCG
        }
    }

    Fallback "VertexLit" 
}

In the shader, we use the _WorldSpaceLightPos0 constant to get the light source's position.

The light source's coordinates are in world space (the global coordinate system). All our calculations need to happen in a single coordinate system, so we also transform the normal and position into world space using Unity's built-in unity_ObjectToWorld matrix.

The cosine's range is from -1 to 1, while we compute illumination in the range from 0 to 1. To keep our shader from returning a negative value, we clamp the cosine against zero using the max function.

The result of the shader:

The Phong model

The Phong model builds on the Lambert model, adding a specular component for the material being rendered. Let's look at the following diagram:

As you can see, unlike the diagram for the Lambert model, the Phong model adds two new vectors: the direction from the point on the scene to the viewer (camera), V (view), and the light ray reflected from that point, R (reflection).

The Phong model works like this: the smaller the angle between the R and V vectors, the brighter the rendered point should be lit. Informally, the logic goes: a small angle between these vectors means the ray reflected off the scene point lands in the camera, "blinding" it — which visually reads as a highlight.

In terms of implementation, we take the cosine between these vectors and raise it to some power n, which is tuned by hand and controls the size of the highlights. The smaller the angle, the larger the cosine, and the brighter the specular component.

Below is the final formula, part of which you already know (that's the diffuse component from the Lambert model), while the second, right-hand part of the sum is our specular component:

I=max(0,dot(N,L))+(dot(R,V))nI = max(0, dot(N, L)) + (dot(R, V))^n

Now let's implement this as a shader:

glsl
Shader "karonator/Shading/Phong"
{
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
                float2 uv: TEXCOORD0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;

                float3 normal: NORMAL0;
                float2 uv : TEXCOORD0;
            };

            fragment_data vert (vertex_data v)
            {
                fragment_data o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);
                o.normal = mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz;
                return o;
            }

            fixed4 frag (fragment_data i):COLOR
            {
                float3 L = normalize(_WorldSpaceLightPos0.xyz - i.pos_world.xyz);
                float3 N = normalize(i.normal);

                float3 V = normalize(_WorldSpaceCameraPos.xyz - i.pos_world.xyz); // direction toward the camera
                float3 R = reflect(-L, N); // the light direction vector reflected about the normal

                float diff = max(dot(L, N), 0.0); // the diffuse component is computed using the Lambert model
                float spec = pow(saturate(dot(R, V)), 8); // saturate clamps the value to the 0..1 range

                // an ambient component could be added here if desired
                return diff + spec;
            }

            ENDCG
        }
    }
}

Let's visualize the specular component for n equal to 8, 16, and 64:

And here's the final result (diffuse + specular component):

The Blinn-Phong model

As described above, the specular component in the Phong model is based on the cosine between the reflected vector (R) and the view vector (V). This approach runs into a problem: if the angle between R and V is greater than 90 degrees, the cosine becomes negative, which leads to a complete absence of the specular component. It's easy to reproduce this: just bring the light source close to the surface plane:

The image shows a sharp cutoff right where the angle crosses 90 degrees. To fix this, in 1977 James Blinn proposed an improvement to the Phong model (which eventually became the Blinn-Phong model). The idea is to introduce what's called a halfway vector. Like the Phong model, Blinn-Phong uses the Lambert model for the diffuse component, but computes the specular component differently.

Instead of the reflected vector (R), it uses a vector that sits exactly halfway between the view vector (V) and the light vector (L). The smaller the angle between this vector and the normal, the brighter the specular component at that point. Crucially, this angle can never exceed 90 degrees, which automatically fixes the problem present in the original Phong model.

Take a look at the images below:

In general, it's clear that no matter how the camera and light source are positioned, the angle between the normal and the halfway vector will never exceed 90 degrees. And it's very simple to compute: just add the view vector V and the light vector L, then normalize the result. Below is a commented shader implementing the Blinn-Phong model:

glsl
Shader "karonator/Shading/BlinnPhong"
{
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
                float2 uv: TEXCOORD0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;

                float3 normal: NORMAL0;
                float2 uv : TEXCOORD0;
            };

            fragment_data vert (vertex_data v)
            {
                fragment_data o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);

                o.uv = v.uv;
                o.normal = normalize(mul(float4(v.normal, 0.0), unity_WorldToObject).xyz);
                return o;
            }

            fixed4 frag (fragment_data i):COLOR
            {
                float3 L = normalize(_WorldSpaceLightPos0.xyz - i.pos_world.xyz); // direction toward the light source
                float3 N = normalize(i.normal);
                float3 V = normalize(_WorldSpaceCameraPos.xyz - i.pos_world.xyz); // direction toward the camera

                float3 M = normalize(L + V); // the halfway vector (between the light direction and the view direction)

                float diff = max(dot(L, N), 0.0);
                float spec = pow(saturate(dot(M, N)), 16); // saturate clamps the value to the 0..1 range

                // an ambient component could be added here if desired
                return diff + spec;
            }

            ENDCG
        }
    }

    Fallback "VertexLit" 
}

Let's compare edge cases between the Phong model and the Blinn-Phong model:

The sharp cutoffs are gone, and the plane is now lit evenly. The Blinn-Phong model is often used in computer graphics as a baseline that people build on for further refinement.

That wraps up this introduction to basic lighting models. We'll continue the conversation about lighting models in future articles — thanks for reading.

To sum up how the three models relate to each other:

  • Lambert — diffuse only: I = max(0, dot(N, L))
  • Phong — Lambert plus a specular term built from the reflected vector: I = max(0, dot(N, L)) + dot(R, V)^n
  • Blinn-Phong — same idea, but the specular term uses the halfway vector instead of the reflected one: I = max(0, dot(N, L)) + dot(N, M)^n, where M = normalize(L + V)

Each model is a small, direct extension of the one before it — which is exactly how you'd approach this in practice: start from Lambert, layer in specular highlights with Phong, then swap in the halfway vector to get rid of the hard cutoff, arriving at Blinn-Phong.

Links and files

Computer graphicsshadingphonglambertblinn-phongunity
Karen Grigorian
Karen Grigorian