GLSL is Hard.
GLSL stands for OpenGL Shading Language and it is a language specifically for writing shaders to be calculated by your GPU. In the simplest terms, a shader changes the color of a pixel.
I'm no expert at all, but I'm going to try to talk through some of the how and why of shaders.
A shader is made up of two distinct halves: vertex shader and fragment shader. Each one of these has to run for every pixel the shader is being applied to.
- Vertex Shader -
consumes data about the pixel (position, color, UV) and passes it to the fragment shaderTakes vertices and gives us coordinate / color values. I don't understand it fully, but for my purposes I get coordinate and colors values. And that's all I need to know (for now).
- Fragment Shader - takes pixel data and returns a vec4 RGBA value for what color the pixel ought to be
Because this runs on every pixel, it is important to make your shader light weight. GPUs are fast, but not infinite. For this reason, anything that requires branching logic (if/else) can decrease the performance of a shader significantly.
Also: you can look at the pixels around you as they are passed in pre-shader, but cannot observe the effect of the shader during processing.
So writing a shader becomes a game of creating a logic puzzle to get the single pixel you are looking at right now in isolation to be the color you want it to be for some larger effect.
For the lighting in my game, I wanted to be able to draw colored light effects to some surface somewhere and then project and blend that color onto my sprites and surfaces while preserving the original pixel alpha value.
So we're trying to blend this:
Which is the surface representing the color of the lights. And we want to do this while preserving the alpha transparency values of the original sprite, not just draw a huge glowing blob on top of them.
There are two versions of this shader - one is for individual objects, which assumes that the sprite of that object is already being blended with the ambient "dark" color before the shader is applied.
When applying shaders to tileset layers in Game Maker, however, we cannot set the blend color prior to the shader, so the shader has to calculate that blend in the itself. That tileset version of the shader is the one I'm going to show.
The vertex shader for both of them is identical regardless.
This entire vertex shader is boilerplate up to line 19. (Excluding line 8 and 9). Basically we are getting the position of a pixel on the screen taking into account the UV size for it within the great Spritesheet In the Sky. (The texture page.)
Argument types: Attributes are default properties of a pixel. Uniforms are values passed into the shader. Varying values are calculated by the vertex shader and passed to the fragment shader.
These coordinates are for the texture we are applying it to. But in our fragment shader, we are going to pass in a second texture (the light surface) and we need a coordinate for pixels on that surface. The light surface is drawn to be the same size of the screen and is offset with the camera position to always be relative to the view. The gl position is relative to screen position, where (-1, -1) is the bottom left and (1, 1) is the top right. Lines 19-20 reinterpret this to be (0, 0) as top left and (1, 1) as bottom right.
Because in this implementation we are drawing the light texture relative to screen position, the surface_offset will always be passed in as (0, 0). If we had a different position (say, room width / height with an origin of (0, 0)) we would have to provide a pixel offset of the camera position.
BIG HEFTY NOTE HERE: I did not figure out the lum_coord calculations. I had help from the Gaming Reverend who has an excellent GLSL tutorial series. This vertex shader does not come from that series - he personally helped me out and custom wrote this for me, so big thank you to him.
Let's go through line by line!
vec4 baseCol = v_vColour * texture2D(gm_BaseTexture, v_vTexcoord);
We get the base color of the pixel, unaltered.
baseCol.rgb *= blendColor;
The color we want to blend with is passed in as a vec3 representing RGB in values 0-1. So [1, 0, 0] would be pure red. We multiply the base RGB value by this value to get the blend of the two. So if the base color was [.8, .5, .2] and we multiplied it by [1, 0, .5] we would end up with a value of [.8, 0, .1]. We replace the base color RGB value with this multiplied color. I could use the "mix" function, but that would kill all of the contrast and would be a muddied blend.
float brightness = (baseCol.r + baseCol.g + baseCol.b) / 3.0;
I decided that brighter colors should be affected more by light than darker colors. This is just true in life as well, and it really helps the effect looks more natural. There are better models than this for how to get brightness (for example, red should affect brightness much less than green), but just getting the average of the RGB values is good enough here. I might update that later to weigh each individual color value.
vec4 lumCol = texture2D(s_lightsurf, lum_coord);
We get the color of the pixel on the light surface at the given lum_coord passed in by the vertex shader.
float str = strength * (brightness * brightness) * 4.0;
We calculate how strong the light ought to affect this pixel. The "strength" uniform is a value I set per layer in the game, so that layers that are further back in the distance and should be affected by light less get a lower strength value. That strength is multiplied by brightness squared (to increase contrast) and then given a nice boost up by multiplying it by the magic number 4. (It would be too dim otherwise.)
Are there smarter ways to code this? For sure, but I never said I was smart.
baseCol.rgb = vec3(baseCol.rgb + (lumCol.rgb * str * ambience));
We reset the base color RGB values to a new vec3. We take the base color (already blended on line 17) and add the light color to it. That light color is multiplied by both the strength (which is per layer and has increased contrast) and the ambient light strength. This ambient light strength in game is relative to the time of day, so at night the value is 1 and midday it is 0. Basically lights should not affect anything during the day but should be as strong as they can be at night.
We then set the gl_FragColor (which is the final color of the pixel to be drawn) to be this newly calculated base RGBA value. Notice the baseCol was a vec4 to begin with, where the fourth vector is the alpha of the original image. We never touch that alpha so we only blend to the original image.
Shaders are hard. But I'm getting better. I hope this was interesting.