Shaders For Beginners (Me) - Part 1
<< Home

Shaders For Beginners (Me) - Part 1

Shaders are good stuffs. You and I both know that. So today, I think “why don’t I go over those shaders and learn something new in the process?”, and then began reviewing those old shader knowledges. Well, so this time we are going to talk about all sorts of shaders, most of which are from here. Go check it out!

Preparations

Well, for gaming shaders to work, a scene is needed. That is why I built one using blender. You can get it here if you want. It looks just like the featured image: a few balls, and a suzanne overlooking them from the left. First thing first: we are going to create a program and render it! Let’s use normals as the output color first:

normals

Well, it’s ugly, but you know what’s going on. By the way, if you don’t know how to get the scene to the screen, check out LearnOpenGL. It’s been the most helpful resource if you want to learn. And the first thing we are gonna do is apply the Phong Reflection Model.

Phong Reflection Model

Phong components

Ripped straight from Wikipedia, we can see it consists three parts. Ambient, diffuse & specular. But first thing first, we are gonna set the light color! Well, why not just vec3(1.0, 1.0, 1.0)? I mean white lights are pretty common, right?

After setting the light color, we can calculate the ambient color first, which is the light color at its very, very dim.

vec3 ambient = lightColor * 0.01;

This simulates all kinds of photons which bounces around in a random way and reaching the destination. It also means nothing is truly black in the scene. Then, we can begin calculating the diffuse color:

vec3 norm = normalize(normal);
vec3 lightDir = normalize(lightPos - pos);
float diff = max(0.0, dot(lightDir, normal));
vec3 diffuse = lightColor * diff;

By calculating the dot product of the surface normal and the light direction, here’s what we get:

Cousine product

The cousine product of the ! Which means when gets bigger, the light would be dimmer. Exactly what we want! We want its minimum to be 0.0 though, because if the diff value’s negative, it would negate the ambient color, which is not good.

Specular light is quite similar to the diffuse light:

vec3 refl = reflect(lightDir, norm);
vec3 eyeDir = normalize(pos - eyePos);
float shinyness = pow(max(0.0, dot(refl, eyeDir)), 16.0);
vec3 specular = lightColor * shinyness;

As we can see, it also requires the eye position, that’s because specular is for the eyes; it’s the sun’s reflection on the surface. Diffuse however, is objective and does not really gets affected by the eye. After we get the dot product, we usually power it up by a terrible amount, so the light would really concentrate at one place.

Specular

And finally, the object color would be

color = vec3((ambient + diffuse + specular) * objectColor)

And there you have it:

Phong

Lighting distance

Well, it looks a little bit too bright. This however, could be swiftly solved by multiplying a distance to the output color:

float dist = pow(1.0 - (min(5.0, distance(lightPos, pos)) / 5.0), 0.5);
color *= dist;

In this way, the further the object is from the light, the dimmer it becomes. The closer, the brighter. And by squaring it, the light dims slower, so it would get a more uniform look:

Distance

Cel shading

Cel shading makes things look cartoonish by stepping the light color:

Cartoonish

What it does is pretty simple. It is just a slight modification of the Phong shading model, by adding a step procedure after the intensity calculation is done:

vec3 lightColor = vec3(1.0, 1.0, 1.0) * 1.0;
vec3 lightPos = vec3(sin(time), 4.0, cos(time)); // Coming from the other way
float dist = pow(1.0 - (min(5.0, distance(lightPos, pos)) / 5.0), 0.5);

// ambient
vec3 ambient = lightColor * 0.01;

// diffuse
vec3 norm = normalize(normal);
vec3 lightDir = normalize(lightPos - pos);
float brightness = max(0.0, dot(lightDir, normal));
brightness = step(0.1, brightness); // Cel shading
vec3 diffuse = lightColor * brightness;

// specular
vec3 refl = reflect(lightDir, norm);
vec3 eyeDir = normalize(pos - eyePos);
float shinyness = pow(max(0.0, dot(refl, eyeDir)), 16.0);
shinyness = step(0.98, shinyness); // Cel
vec3 specular = lightColor * shinyness;

In this way, the light transition changes from smooth in Phong shading to abrupt. This adds a cartoonish filter to the scene.

Graph

Box blur

Box blur blurs the scene. It is easy, and it gets the job done. I am not using the one from 3DSFB here, but instead I will use the classic box blur from image processing. First, we need to render the scene to a framebuffer, Then render the framebuffer as follow:

vec3 boxBlur() {
    // The kernel
    mat3 box = mat3(vec3(1.0, 1.0, 1.0),
                    vec3(1.0, 1.0, 1.0),
                    vec3(1.0, 1.0, 1.0));
    vec3 col = vec3(0.0, 0.0, 0.0);
    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            // tex is the framebuffer
            vec3 sample = texture(tex, uv + vec2(x * 0.003, y * 0.003)).xyz;
            col += sample * box[y + 1][x + 1];
        }
    }
    return col / 9.0;
}

void main() {
    vec3 sampled = boxBlur();
    color = vec4(sampled, 1.0);
}

Blur

Fog of war (or just fog)

Fog is an important thing if you don’t want the user to see far away stuffs. Its concept is simple: the further the thing is, the higher the intensity the fog is. Then we mix the fog texture with the object color.

vec3 fogColor = vec3(0.4, 0.4, 0.4); // I don't have a fog texture, so...
float near = 0.01;
float far = 3.0;
float intensity = clamp((position.y - near) / (far - near), 0.0, 0.95);
vec3 fogged = mix(fogColor, objectColor, min(intensity, 1.0));

Fog

Looking down, if the black background doesn’t really exist, it feels like the ground is fogged. In game, you oculd just calculate the intensity with the object’s distance to the eye. Oooh, why don’t we use Perlin Noise to fake noise texture? Here’s a hastily written Perlin Noise:

float perlin(vec3 p) {
    vec3 u = floor(p);
    vec3 f = fract(p);
    vec3 s = smoothstep(0.0, 1.0, f);
    
    vec3 rands[8];
    rands[0] = rand(u);
    rands[1] = rand(u + vec3(1.0, 0.0, 0.0));
    rands[2] = rand(u + vec3(0.0, 1.0, 0.0));
    rands[3] = rand(u + vec3(1.0, 1.0, 0.0));
    rands[4] = rand(u + vec3(0.0, 0.0, 1.0));
    rands[5] = rand(u + vec3(1.0, 0.0, 1.0));
    rands[6] = rand(u + vec3(0.0, 1.0, 1.0));
    rands[7] = rand(u + vec3(1.0, 1.0, 1.0));

    float res = mix(
        mix(
            mix(dot(rands[0], f), dot(rands[1], f - vec3(1.0, 0.0, 0.0)), s.x),
            mix(dot(rands[2], f - vec3(0.0, 1.0, 0.0)), dot(rands[3], f - vec3(1.0, 1.0, 0.0)), s.x),
            s.y
        ),
        mix(
            mix(dot(rands[4], f - vec3(0.0, 0.0, 1.0)), dot(rands[5], f - vec3(1.0, 0.0, 1.0)), s.x),
            mix(dot(rands[6], f - vec3(0.0, 1.0, 1.0)), dot(rands[7], f - vec3(1.0, 1.0, 1.0)), s.x),
            s.y
        ),
        s.z
    );
    return res;
}

Then we can do this with:

float r = perlin(pos / 2.0) * 0.5 + 0.5;
vec3 fogged = mix(vec3(r, r, r), o, min(intensity, 1.0));

Perlin

Well, it looks terrible. A little bit like fog, yes, but still terrible. I guess I am just having the wrong parameter. Tweak it yourself!

Pixelization

There are literally loads of ways to perform pixelization. But one simple & brutal way is magnifies the uv by a huge amount, then floor it (losing all the precisions in the process), then divide the position by the scalar again.

Pixel

The bigger the scalar is, the higher resolution the final image is:

vec3 pixelization() {
    vec2 p = uv;
    float scalar = 100.0;
    p = floor(p * scalar);
    p /= scalar;
    return texture(tex, p).xyz;
}

Gamma Correction

The explaination of the famous gamma correction could be found here and there. Just go search it! It is a brainless one-liner :

vec3 gamma(vec3 i) {
    float gamma = 2.2;
    return pow(i, vec3(1.0 / gamma));
}

After applying gamma correction, the whole scene looks considerably better:

Gamma Correction applied

However, you might notice one thing: the light edges becomes sharper.

Shadow Mapping

Shadow Mapping adds a great deal of realism to the scene. Don’t believe it? Check it out!

Rise

Monkey Watching Sunrise. Alrighty, we should get started. Shadow mapping is easy to understand (but a little bit hard to implement):

  1. Render the scene from the perspective of the light (shadow map)
  2. Render the scene again from the perspective of the camera
  3. Transform the position to light-space position
  4. Compare the depth value with the shadow map (perform depth testing manually)
  5. If the test fails, then this place was occluded by some sorta object. Just color it black
  6. Otherwise color it the original color

You can read a better tutorial here. Here’s how a shadow map should look like:

Shadow map

After rendering it (literally just another MVP transform - except this transform uses a orthographic matrix instead of a perspective one, because light beams are parallel), we could do the light depth-testing in the actual scene rendering.

float shadow = getShadowInSomeWay();
color = vec4((1.0 - shadow) * phongDir(), 1.0);

And here’s how we are gonna get shadow:

float getShadowInSomeWay() {
    vec3 projection = lightSpacePos.xyz / lightSpacePos.w;
    projection = projection * 0.5 + 0.5; // Normalize to texture coordinate
    float closestDepth = texture(depth, projection.xy).r; // Sample the depth
    float currentDepth = projection.z;
    float bias = 0.006; // Add bias to prevent shadow acne, which is really ugly
    float shadow = (currentDepth - bias) < closestDepth ? 0.0 : 1.0; // Compare with the current depth
    return shadow;
}

And yeah, I know the code could just be float shadow = currentDepth < closestDepth ? 0.0 : 1.0. But that would result in shadow acne, because of the floating point loss, and the low texture resolution, that kind of stuffs. Here, I will show you:

Acne

Shadow Acne is a very very bad stuff. Adding bias means adding generosity to the comparison. If it’s almose equal, then well, let’s pretend you pass the shadow depth testing. That’s why you shouldn’t be too generous, as there would be very little shadow remaining, which in turn results in Peter Panning:

Peter Panning

It looks like the monkey is now flying, and all other stuffs has lost their shadow. However the monkey is not; a great deal of would-be shadows were filtered out and this little bit is what remains. That is sad! Well, Peter Panning would be partially solved by doing glCullFace - not gonna cover it here. Check out this, please. It’s excellent!

End of Part 1!

Ahhh, that’s a lot! But we are gonna cut it here. This passage is getting way too long, so I decided I will split it into a two part thingy. Let’s hope we don’t ditch on that, shall we?

References

  1. LearnOpenGL, LearnOpenGL - Strongly recommended
  2. Phong Reflection Model, Wikipedia
  3. Cel Shading, Wikipedia - Zelda uses it!
  4. Cel Shading, 3DGSFB
  5. Lighting, 3DGSFB
  6. Fog, 3DGSFB
  7. Gamma Correction, LearnOpenGL
  8. Shadow Mapping, LearnOpenGL
  9. Box Blur, Wikipedia - Learn more about kernels & stuffs at Wikipedia too! It is Image Processing oriented.