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.