A Simple 2D Lighting System

by Neil Edelman, May 2000

Essentially all major new commercial games use complex 3D engines to make impressive graphics. 3D has some drawbacks, however. Most obviously, it takes much more time to program something in this manner. Programs using 3D graphics in real time are generally larger and slower. For many game programmers, 2D is more appropriate for what they intend to create, even if it doesn't look as tangible. For anyone programming under the less daunting task of making a simple 2D game, however, some of the fascinating eye-candy effects associated with 3D games can be reproduced. Presented here is one simple method of creating the appearance of point light sources in such a situation.

Suppose, for a highly simplified example, that a game consists of a ship which can move around a star at the centre of the screen. This game would be greatly improved should it appear that the ship were actually being lit by the star. At the very simplest level, this can be done by making pixels on the side of the ship which is facing the star brighter, and those on the other side darker. To this end, two pieces of information must be known: the angle of the light striking the ship and the angle of each pixel composing the ship with relation to the centre of the ship's sprite. In order to measure angles, allow me to arbitrarily choose straight to the right as zero, increasing in the counter-clockwise direction up to 360 degrees after a complete turn. Note that in an application, you probably wouldn't want to use this scale, but I'm using it for purposes of example since it's probably familiar.

To find the angle between the ship and the light, one method is to use the arctangent trigonometric function. Note that trig functions take time and slow your program down. This sort of thing should probably be optimized using special vector operations, etc. As a start, however, this is a simple method of finding the angle. The tangent of the angle between the two points is their vertical difference, light.y - ship.y, divided by their horizontal difference, light.x - ship.x. Viz. Tan(angle of light striking ship) = (light.y - ship.y) / (light.x - ship.x). Solving this equation for the angle of light striking ship variable (which is what must be found) gives:

angle of light striking ship = atan((light.y - ship.y) / (light.x - ship.x))

Here are some important considerations:



The angle of each pixel is even easier to find. It is possible to calculate it, but that is neither necessary nor efficient. Simply make an array of the same size as the ship's sprite. In this array, store what the angle of every pixel is. For example, the pixel at the top-right corner would have an angle of 45 degrees. The pixel at the bottom-centre would have an angle of 270 degrees. After filling in this array, simply reference it's values to find the angle of each pixel. For example, if the sprite was 32 pixels by 32 pixels, stored from left to right, bottom to top with a base of zero, then the angle of the top-right pixel should be angle of pixel = angle array(31, 0) = 45 degrees or angle array[31][0] = 45 degrees or however one's programming language chooses to reference array elements. If you don't want to fill in the values by hand, you could calculate them in much the same manner as the angles above (Don't try doing it by hand if your sprite is really big; it's too much work).


This diagram illustrates a few of the various angles that must be known in order to calculate shading as a result of the direction in which a pixel faces in relation to the light source.

The angular difference between the two aforementioned angles will serve in calculating the brightness. This, very simply, is angle away from light = angle of pixel - angle of light striking ship. For example, suppose light is striking the ship from directly above, an angle of 90 degrees. A pixel at the top-center of the sprite also has an angle of 90 degrees. The difference, 90 - 90 = 0 is small so this pixel will be bright. A pixel at the top-left corner has an angle of 135 degrees, and thus the difference, 135 - 90 = 45 is a little larger. This pixel isn't being lit directly, so it is darker. A pixel on the opposite side of the sprite at the bottom-center has an angle of 270 degrees. Ergo, 270 - 90 = 180 which is a full half turn away. This pixel will therefore be the darkest. There are yet two problems that can be very easily fixed. First, try the angle of the top-right pixel. It should be 45 degrees, but it is rather 45 - 90 = -45. To fix this, the equation must be changed to find the magnitude, or absolute value of the difference as such: angle away from light = |angle of pixel - angle of light striking ship|. Many programming languages use the abs function to accomplish this, angle away from light = abs(angle of pixel - angle of light striking ship). In C, this is declared in the math.h header (It's likely a macro). The final problem manifests itself when using the bottom-right angle. It's angle is 315 degrees, giving |315 - 90| = 225 which is indeed the counter-clockwise angular difference between 90 degrees and 315 degrees. The problem is that this angle is smaller when moving clockwise. Any angular difference greater than 180 degrees means that it will be shorter to go the other way around. For example, what is the angular difference between 1 degree and 359 degrees? Using the current formula, it's |1 - 359| = 357. The actual difference, however, is only two degrees which is 360 - |1 - 359| = 2. Since we know that this is the case when the angular difference exceeds 180 degrees, a clause can be added to the calculation as such:

angle away from light = |angle of pixel - angle of light striking ship|

if angle away from light > 180 then angle away from light = 360 - angle away from light

The next element of shading involves causing the edges of the sprite to receive more light than the centre, causing the light to appear as if striking a spherical object rather than the current conical-object-from-above.

There is a simple method of accomplishing this. Create an array of the same size as the ship's sprite, as before with the angles. In this array, store a circular pattern of numbers with the smaller numbers in the center, moving outwards to larger numbers in the corners. For each pixel, combine the accompanying value in this array by the light value from the angle (by multiplication, most likely). This will cause the pixels along the edge of the sprite to be lit, while becoming darker as they move away.

The act of combining the values can be done in many ways. The following is a simplified and modified overview of the whole process of which I've used (in quasi-C code). It shows a few extra items which are important, such as making the light fall off in the distance.

First, this array stores the circular pattern. It is 16 by 16, since SPRSZE was previously defined to be 16, the size of the ship's sprite.


static const byte lght[SPRSZE][SPRSZE] = {
10,10,10,9,9,9,8,8,8,8,8,9,9,10,10,10,
10,9,9,9,8,8,7,7,7,7,8,8,9,9,9,10,
10,9,8,8,7,7,6,6,6,6,7,7,8,8,9,10,
9,9,8,7,6,6,5,5,5,5,6,6,7,8,9,9,
9,8,7,6,5,5,4,4,4,4,5,5,6,7,8,9,
9,8,7,6,5,4,4,3,3,4,4,5,6,7,8,9,
8,7,6,5,4,4,3,2,2,3,4,4,5,6,7,8,
8,7,6,5,4,3,2,1,1,2,3,4,5,6,7,8,
8,7,6,5,4,3,2,1,1,2,3,4,5,6,7,8,
8,7,6,5,4,4,3,2,2,3,4,4,5,6,7,8,
9,8,7,6,5,4,4,3,3,4,4,5,6,7,8,9,
9,8,7,6,5,5,4,4,4,4,5,5,6,7,8,9,
9,9,8,7,6,6,5,5,5,5,6,6,7,8,9,9,
10,9,8,8,7,7,6,6,6,6,7,7,8,8,9,10,
10,9,9,9,8,8,7,7,7,7,8,8,9,9,9,10,
10,10,10,9,9,9,8,8,8,8,9,9,9,10,10,10};

This is the radial pattern of the angles of each pixel. Because of the nature of this sort of array assignment in C, note that the numbers are not left to right top to bottom, which is why they seem to be in there sideways.


static const byte angs[SPRSZE][SPRSZE] = { 96,92,88,84,80,76,70,68,60,58,52,48,44,40,36,32,
100,96,92,84,80,76,70,68,60,58,52,48,44,36,32,28,
104,100,96,92,84,80,72,68,60,56,48,44,36,32,28,24,
108,108,100,96,88,80,72,68,60,56,48,40,32,28,20,20,
112,112,108,104,96,88,80,68,60,48,40,32,24,20,16,16,
116,116,112,112,104,96,80,68,60,48,32,24,16,16,12,12,
122,122,120,120,112,112,96,68,60,32,16,16,8,8,6,6,
124,124,124,124,124,124,124,96,32,4,4,4,4,4,4,4,
132,132,132,132,132,132,132,160,224,252,252,252,252,252,252,252,
134,134,136,136,144,144,160,188,196,224,240,240,248,248,250,250,
140,140,144,144,152,160,176,188,196,208,224,232,240,240,244,244,
144,144,148,152,160,168,176,188,196,208,216,224,232,236,240,240,
148,148,156,160,168,176,184,188,196,200,208,216,224,218,236,236,
152,156,160,164,172,176,184,188,196,200,208,212,220,224,218,232,
156,160,164,172,176,180,186,188,196,198,204,208,212,220,224,218,
160,164,168,172,176,180,186,188,196,198,204,208,212,216,220,224};

This is the ship's sprite.


static const byte sprt[2][SPRSZE][SPRSZE] = {
00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,
00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,
00,00,00,00,00,00,00,255,00,00,00,00,00,00,00,00,
00,00,00,00,00,00,00,70,70,00,00,00,00,00,00,00,
00,00,00,00,00,00,70,90,90,70,00,00,00,00,00,00,
00,00,00,30,90,90,90,90,90,90,90,30,60,90,60,00,
30,90,90,90,90,90,90,90,90,90,90,90,90,90,90,60,
90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,50,
90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,50,
30,90,90,90,90,90,90,90,90,90,90,90,90,90,90,60,
00,00,00,30,90,90,90,90,90,90,90,30,60,90,60,00,
00,00,00,00,00,00,70,90,90,70,00,00,00,00,00,00,
00,00,00,00,00,00,00,70,70,00,00,00,00,00,00,00,
00,00,00,00,00,00,00,255,00,00,00,00,00,00,00,00,
00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,
00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,}

Naturally, there would be some sort of code that actually assigned values to the light positions, ship positions, etc. It could (but probably wouldn't) go here.

Save the horizontal and vertical displacement from the light to the ship in variables.

dtolight.x = ship.x - light.x;

dtolight.y = ship.y - light.y;

Check to make sure the ship is within box of light.distance dimensions around the light. This is not necessary, but you probably don't want to go through the trouble of calculating the light values if the object is too far away from the light to make any difference.

if(dtolight.x < light.distance && dtolight.y < light.distance && dtolight.x > -light.distance && dtolight.y > -light.distance) {

Calculate the distance to the light source. The hypot function essentially accomplishes square root of(square of(argument 1) + square of(argument 2)). One is added to this value, not for precision, but rather to ensure that it will not be zero, since it is later used as a denominator.

disttolight = hypot(dtolight.x, dtolight.y) + 1;

This repeats the check that the ship is within the range of the light as specified by light.distance, only instead of a bounding box, it actually check the distance (a circle). This may allow us to skip calculating the light values for a few more areas. It is likewise not necessary.

if(disttolight <= light.dist) {

Calculate the angle at which the light arrives at the ship. Notice that RADIAN was defined as 128 / PI since I'm using a 256-degree circle to measure angles. If this were otherwise, it would be necessary to wrap the value using the modulo operator.

lightang = (byte)((atan2(light[j].p.y - ship[i].p.y, light[j].p.x - ship[i].p.x)) * RADIAN + 64);

This cycles sprpxl.x and sprpxl.r through every pixel of the sprite left to right, top to bottom. The order doesn't really matter, as long as it gets to every pixel. SPRSZE was previously defined as the size of the ship's sprite.

for(sprpxl.y = 0; sprpos.y < SPRSZE; sprpxl.y++) {
for(sprpxl.x = 0; sprpos.x < SPRSZE; sprpxl.x++) {

pxl.x and pxl.y are the points on the screen where the pixels are to be written. Notice that HLFSZE is 8, half the size of SPRSZE, the size of the sprite. Subtracting this value causes the sprite to to drawn centered at ship.x, ship.y rather than with its top-left corner at the same.

pxl.x = sprpxl.x + ship.x - HLFSZE;
pxl.y = sprpxl.y + ship.y - HLFSZE;

c will be used to keep track of the color. In this code, 0 is the darkest color and 255 is the brightest. In a real game, one would likely keep track of color and brightness separately. This starts c off with a certain ambient light value, AMBLIGHT so that the ship will never be totally black and therefore invisible.

c = AMBLIGHT;

As discussed, this finds the angle between the incoming light and that of the pixel.

angfromlight = abs(lightang - angs[sprpxl.x][sprpxl.y]);
if(angfromlight > 128) angfromlight = 256 - angfromlight;

Store the value of the pixel in the sprite in the variable clr.

clr = sprt[i][sprpxl.x][sprpxl.y];

Next, multiply this value by the brightness. This is probably the key line of the whole process. The rather long equation is subject to some change, but this is the one which I've eventually settled upon as producing a nice effect. The equation becomes simpler to understand if you disregard the capitalized variables, which are all parameters defined as numbers. They can be toyed with to see what each of them changes to. SHADOWANG is .5, and causes the angular width of the light to increase more or less as the ship approaches the light. SHADOWDIST is 5, and causes the angular width of the light to spread out a certain amount. SHADOWBLEND is also 5, and reduces the sharpness of the light from the edge to the centre. SHADOWRATE is 256, and scales the number down in size so that it's range is generally around 0 to 255 (or whatever color values you want); it may need to be changed to compensate for changes in other variables. An important new item to note is light.brightness / disttolight, which causes the brightness to diminish with distance (making it scientifically accurate didn't look as nice, but I suppose you could try it).

clr *= ((angfromlight - (disttolight * SHADOWANG) + SHADOWDIST) + (lght[sprpos.x][sprpos.y] + SHADOWBLEND)) * (light.brightness / disttolight) / SHADOWRATE;

This line ensures that the color does not end up below zero (usually light isn't sucked away from a surface). If it is zero, the next lines (c += clr) won't do anything anyway.

if(clr > 0) {

MAXCOLOR has been defined as the maximum color value, 255 in my case. What this line does is if adding clr to c will make c greater than MAXCOLOR, it just sets c to MAXCOLOR so it doesn't go over.

if(clr > MAXCOLOR - c) c = MAXCOLOR;

Otherwise, add the clr variable to c (which is the color of the pixel we want to write on the screen). You may have noticed that it's silly to use the two variables c and clr separately rather than as one variable, but this is because with multiple light sources you would want to add the clr value for each light source to c.

else c += tmpclr;

End of if(clr > 0).

}


An illustration of where sprite's co-ordinates are placed on the screen.

Now finally draw the pixel to the screen at pxl.x, pxl.y with color c. The pset function is one that I made up. You will probably have to find or make your own equivalent depending on the manner in which you intend to write to the screen.

pset(pxl.x, pxl.y, c);

End of for(sprpxl.y = 0; sprpos.y < SPRSZE; sprpxl.y++) and for(sprpxl.x = 0; sprpos.x < SPRSZE; sprpxl.x++).

}
}

End of if(disttolight <= light.dist).

}

End of if(dtolight.x < light.distance && dtolight.y < light.distance && dtolight.x > -light.distance && dtolight.y > -light.distance).

}

That sums up the code. Hopefully the process is understandable. It makes sense to put this inside of a function that is called upon to draw the graphics inside a game. Obviously this code is very minimal. I've attempted to leave out as many unnecessary details as possible. Depending on what you want to do with this idea, there are very many additions that are needed, but with an understanding of this method it shouldn't be that difficult. Naturally, there are also other ways of implementing similar effects.

Source code and executable for my sample program using this technique can currently be downloaded under the programs section at Neil's Quarters.

HOME