Pixel Normals
Lighting for 2D pixel art games can be much more fascinating than you might think! This project includes a few different techniques to quickly build pixel art environments with realtime lighting. Normally, pixel art is hand drawn and has an extremely rich potential for art and drawing normals is an even more advanced layer of complexity, but I can make up for some of my lacking artistic ability through the use of 3D rendering. For this demo I've used Blender and Godot, however the specific tools shouldn't matter very much.
The first step in this workflow is to model the scene in 3D. This may seem like more work and depending on the skills of the reader, it easily could be! But this technique was devised because it leveredges the tools I know best. Additionally, there are many 3D assets available that may be very easy to drop into your scene if the license is compatible. A key thing to keep in mind is rearranging models to play angles is a few clicks in 3D, but could easily be hours of hand drawn art.
The shader used here is fairly simple flat colors, we'll be rendering lighting at runtime after all, but a switch has been added to replace that color with the surface normal relative to the camera's position. Some dubious math is done on the vector to scale and flip them in an ideal direction for the lighting engine of choice, since this is done on baked textures that take seconds or less to render, we don't need to be too precious with efficiency. It's also imporant to check that the render color management is set to Standard, otherwise the 'colors' will be processed and not give the correct values.
To get the pixelated look, the render needs to be forced down and then scaled back up with a nearest-neighbour method. In Blender this is done by going to the Output tab and selecting a desired resolution. Since my demo is designed to run at 1080p, I used 480 x 270 to get a clean 1:4 ratio. Each section of the scene that needs to be a different element will need to be rendered individually by masking the other items. At a minimum, the scene should have a few layers to represents different planes of depth so we can choose if the light responds or not. For example, a desk light shouldn't light up the wall behind it the same way it hits the desk.
And finally, what is surprisingly the easiest part: A simple light shader. Godot does not automatically calculate a direction for non-directional lights, they're simply a texture that is overlayed on the screen, so we calculate it ourselves. To push the light's position in front of the surface, a depth offset is added. Omitting this causes lights to be on the same plane as flat surfaces and this is usually not a desireable result. Directional lights also need to be respected, having defined directions as if they're infinitely far away, so a line is added to account for that case. Multiplication is used to select which calculation to use, avoiding an if/else statement that would slow the shader down. The rest is a quick Blinn-Phong shader, or at least an attempt at converting Wikipedia documentation into a Godot shader.
uniform float light_depth = -50; // Pull light towards camera
void light()
{
// Point light dir
vec3 new_light_dir = (LIGHT_POSITION-vec3(FRAGCOORD.x, FRAGCOORD.y, light_depth));
new_light_dir *= (1. - float(LIGHT_IS_DIRECTIONAL));
// Directional light dir
new_light_dir += LIGHT_DIRECTION * float(LIGHT_IS_DIRECTIONAL);
new_light_dir = normalize(new_light_dir);
float NdotL = dot(NORMAL, new_light_dir);
float diffuseIntensity = clamp(NdotL, 0.0, 1.0);
vec4 diffuse = diffuseIntensity * LIGHT_COLOR;
vec3 H = normalize(new_light_dir + vec3(0,0,1));
float NdotH = dot(NORMAL, H);
float specularIntensity = pow(clamp(NdotH, 0.0, 1.0), SPECULAR_SHININESS.a);
vec4 specular = specularIntensity * SPECULAR_SHININESS * LIGHT_COLOR;
LIGHT += mix(diffuse, specular, SPECULAR_SHININESS) * LIGHT_ENERGY;
}
After attaching the shader and setting up each texture and normal map, along with some lights and other design details, the scene comes to life! There are of course many limitations to shading in 2D, all of which come back to the major benefit: By removing or greatly simplifying depth in favor of simple sprites, the compute power needed to run the game is dramatically lower. Shadows are tricky to get right and will likely require more manual work to look nice, but that's a project for another day.