GTCS Game Engine:
Tutorial 5: Illumination
Tutorial 4 <-- Tutorial 5 --> Tutorial 6
Tutorial Homepage
Introduction
In this tutorial, we are going to look at how to enhance the look of our game scene using lighting effects. We are going to see how to create different types of light sources and create renderables that react to the lights. We will also look at a renderable that can react to lighting in a way that gives more depth to 2D graphics.
Covered Topics: Illumination • Light Renderable Object • Normal Maps
Demonstrations: Light Renderables • Using Normal Maps
Complete source code for all tutorials can be downloaded from here.
Illumination
In Tutorial 2, we have seen the use of ambient lighting to illuminate our renderables. The ambient lighting sources is controlled with the setGlobalAmbientIntensity() function. This source is always active and it adjusts the lighting on all renderables in our scene. If we want to disable the light, we set the intensity to 0. The lighting affect is analogous to increasing the backlight intensity on your cell phone or laptop. Everything gets "brightened" by the same amount.
The game engine supports 3 other types of light sources.
- Point - An omnidirectional light source in space. Similar to a hovering firefly with a "sphere of illumination" wherever it goes.
- Spot - A cone-shaped light source directed at a location. Similar to a flashlight pointing at a wall.
- Directional - A planar light source where light is equally intense at all incident points. Similar to the screen backlight of electronics devices.
These different lighting types are declared using a number of parameters. Proper use of lighting requires...
- Declaring a light source with the type and parameters defining it's characteristics.
- Notifying each applicable renderable that it should account for the light source when drawing itself.
[Note: This second requirement may seem odd but lighting calculations are essentially a manipulation of how pixels are drawn to the screen (how the object looks). Since we are familiar with the fact that the "look" of game elements is under the purview of the renderable, we treat lighting as a parameter we add to the renderable object.]
To create a light, we allocate a new Light object and set parameters using accessor methods. There are many settings of which certain ones will be used or ignored based on the type of light. Here is an overview of some of the settings we can set...
setLightType()- Identifies what type of light to render as. Valid choices are eLightType.ePointLight, eLightType.eSpotLight and eLightType.eDirectionalLight.setColor()- Color of the light using a 4-value vector identifying red, green, blue and alpha values from 0.0 to 1.0.setIntensity()- A value from 0.0 to 1.0 giving the strength of the light.setXPos(), setYPos(), setZPos()- The functions allow you to set the X, Y and Z world coordinates of where the light is positioned in 3D space. Though we are working with a 2D game scene, our light needs to exist in 3D space to allow for the calculations to illuminate the scene (used only for point and spot lights).setNear(), setFar()- The functions allow you to set the radii for the area of affect for light sources. From the center of the light to the near radius, the intensity will be constant. From the near radius to the far radius, the intensity will gradually drop off effectively softening the edges of the area of affect. If you set both values to be the same, the edge of the area of illumination will be very sharp (used only for point and spot lights).setDropOff()- Identifies how quickly the light intensity will fall as we reach the edge of the area of the lighting affect (used only for point and spot lights).setDirection()- Provide a vector of 3 components to identify the direction the light should point (used only for directional and spot lights).
Types of Light
Point Lights
A point light can be compared to a lightbulb hovering in mid-air illuminating a spherical volume around it. It has intensity, color, size and position. The amount of the game scene that will be illuminated with be determined by the size of the illumination volume (determined by the radius of affect from the center point) and the location of the point. Figure 5-1 provides a visual representation of the affect. To create a point light, we allocate a new Light object and set parameters using accessor methods.
Spot Lights
Spot lights are more complex than point lights in that they utilize more parameters to give more of a directional affect than the point light. Using angles as illustrated in Figure 5-2, we can see how a spot light illuminates a surface.
The cone angles in Figure 5-2 affect the intensity of the light across the area of affect.
The attenuation of light over a distance will cause a diffusion on the surface particularly near the edges
and these angles are meant to simplify applying this affect. To set these angles, we use the setInner()
and setOuter() functions. These functions accept an angle in radians.
Directional Lights
A directional light is similar to ambient light in that every pixel illuminated by it receives the light with equal intensity. However, while the ambient light source allows us to set just the intensity, we can also define direction for directional light sources. Since we are dealing with two dimensional renderables, the direction of the light is not going to provide a sense of depth like it would in three dimensional space. This will change when we look at normal mapping and shadows where the directional light can be used to enhance the scene.
LightRenderable
To demonstrate the light, in the next example, we will create a point light. We will set the arrow keyboard controls to allow moving the light around the screen. We will set the ambient light intensity to a low level for contrast. You can see the demonstration here. Note, all of the particle and RigidShape code has been removed for this tutorial demonstration.
We will introduce a Light object and a background renderable to show how to use a simple point light.
[Note: The engine is setup to allow a LightRenderable to calculate up to eight light sources. To keep managing lights simple due to GameObject overlap, you should limit your game to no more than eight light sources in your project.]
constructor() {
super();
this.mCamera = null;
this.mMinionObj = null;
this.mBackground = null;
this.mPointLight = null;
this.kTexture = "assets/minion_spritesheet.png";
this.kBackground = "assets/bg.png"
}
load() {
engine.texture.load(this.kTexture);
engine.texture.load(this.kBackground);
}
unload() {
engine.texture.unload(this.kTexture);
engine.texture.unload(this.kBackground);
}
To illuminate our Scene, we need to use a renderable that knows how to work with lights. The LightRenderable has all of the same functionality as SpriteAnimateRenderable plus the ability to process lighting affects.
init() {
this.mCamera = new engine.Camera(
vec2.fromValues(50, 50), // position of the camera
100, // width of camera
[0, 0, 600, 600], // viewport (orgX, orgY, width, height)
2
);
this.mCamera.setBackgroundColor([0.8,0.8,0.8,1]);
this.mBackground = new LightRenderable(this.kBackground);
this.mBackground.getXform().setSize(100,100);
this.mBackground.getXform().setPosition(50,50);
this.mMinionObj = new GameObject(new LightRenderable(this.kTexture));
this.mMinionObj.getRenderable().setElementPixelPositions(130,310,0,180);
this.mMinionObj.getXform().setSize(20,20);
this.mMinionObj.getXform().setPosition(50,50);
// create the light and set properties
this.mPointLight = new Light();
this.mPointLight.setLightType(eLightType.ePointLight);
this.mPointLight.setColor([1,1,1,1]);
this.mPointLight.setXPos(50);
this.mPointLight.setYPos(50);
this.mPointLight.setZPos(0);
this.mPointLight.setNear(10);
this.mPointLight.setFar(14);
this.mPointLight.setIntensity(1);
// associate the light with the renderables
this.mMinionObj.getRenderable().addLight(this.mPointLight);
this.mBackground.addLight(this.mPointLight);
engine.defaultResources.setGlobalAmbientIntensity(0.5);
}
In Code Snippet 5-2, we first create a new LightRenderable for the background and set it fill the camera, we don't pass this LightRenderable to a GameObject because the background does not need to interact with other objects. The next new segment is instantiating the Light and setting the properties relevant to a point light. Then we add our Light to the two LightRenderables. Notice in the last line, we set the ambient light to 0.5 intensity. Ambient light affects all game objects regardless of what type of renderable. We set this low value to dim our scene and to make the point light stand out.
Next, we draw.
draw() {
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]);
this.mCamera.setViewAndCameraMatrix();
this.mBackground.draw(this.mCamera);
this.mMinionObj.draw(this.mCamera);
}
For our Update() function, we set it up so that the arrow keys on the keyboard will control the location of the point light.
update() {
// Move left
if(engine.input.isKeyPressed(engine.input.keys.A)){
this.mMinionObj.getXform().incXPosBy(-0.5);
this.mMinionObj.getRenderable().setElementPixelPositions(130,310,0,180)
}
// Move right
if(engine.input.isKeyPressed(engine.input.keys.D)){
this.mMinionObj.getXform().incXPosBy(0.5);
this.mMinionObj.getRenderable().setElementPixelPositions(720,900,0,180)
}
// Move down
if(engine.input.isKeyPressed(engine.input.keys.S)){
this.mMinionObj.getXform().incYPosBy(-0.5);
}
// Move Up
if(engine.input.isKeyPressed(engine.input.keys.W)){
this.mMinionObj.getXform().incYPosBy(0.5);
}
// Move light left
if(engine.input.isKeyPressed(engine.input.keys.Left)){
this.mPointLight.setXPos(this.mPointLight.getPosition()[0]-0.5);
}
// Move light right
if(engine.input.isKeyPressed(engine.input.keys.Right)){
this.mPointLight.setXPos(this.mPointLight.getPosition()[0]+0.5);
}
// Move light down
if(engine.input.isKeyPressed(engine.input.keys.Down)){
this.mPointLight.setYPos(this.mPointLight.getPosition()[1]-0.5);
}
// Move light Up
if(engine.input.isKeyPressed(engine.input.keys.Up)){
this.mPointLight.setYPos(this.mPointLight.getPosition()[1]+0.5);
}
// quit
if(engine.input.isKeyClicked(engine.input.keys.Q)){
this.next();
}
}
Using Normal Maps
Directional and spot lights include a directional vector component to their definition. While, for spot lights, this setting will provide for a difference in diffusion between the closer and farther edges, it will not affect directional lights at all. This is because our renderables are in 2D.
A normal map can be used to tell a renderable how to react to lighting for a given texture image. Below, we see a texture with it's normal map. We need to make sure that we have pixel-for-pixel coordination between the two images. Many 3D modeling software packages can generate and export normal images (often called bump maps). Using these two images together, the engine can simulate specularity and diffusion of different materials and enhance the lighting affect with regards to light direction.
To use normal maps, we need another renderable object. The IllumRenderable knows how to use normal maps and react to light accordingly. In our next example, we will demonstrate this using all three types of lights. The angle of the spotlight will accentuate the affect we are looking for in our rock texture and the entire scene will be lit by a directional light. We continue to use the WASD and arrow key control schemes as before. How the rock is illuminated is going to change with its animation frame and the position of the point light. We still have the minion texture and you will notice that by comparison, the lighting affect on the minion is flat and consistent. You can view the end result here.
First, we setup constants, declare variables and load our resources.
constructor() {
super();
this.mCamera = null;
this.mMinionObj = null;
this.mBackground = null;
this.mPointLight = null;
this.mRockObj = null;
this.mSpotLight = null;
this.mDirectionLight = null;
this.kTexture = "assets/minion_spritesheet.png";
this.kBackground = "assets/bg.png";
this.kBackgroundNormal = "assets/bg_normal.png";
this.kRockTexture = "assets/asteroids.png";
this.kRockNormal = "assets/asteroidsNormal.png";
}
load() {
engine.texture.load(this.kTexture);
engine.texture.load(this.kBackground);
engine.texture.load(this.kBackgroundNormal);
engine.texture.load(this.kRockTexture);
engine.texture.load(this.kRockNormal);
}
unload() {
engine.texture.unload(this.kTexture);
engine.texture.unload(this.kBackground);
engine.texture.unload(this.kBackgroundNormal);
engine.texture.unload(this.kRockTexture);
engine.texture.unload(this.kRockNormal);
}
With our assets loaded, we continue with initialization. We instantiate a new GameObject with an IllumRenderable using the our rock texture and normal map. Since LightRenderable and IllumRenderable inherit from SpriteAnimateRenderable we can support sprite animation and illumination, we are going to take advantage of this with mRockObj.
- The images we are using have 59 frames of animation.
- Each frame is size at 61x64 pixels with a 2 pixel buffer between frames.
- Set the animation speed to 2.
We create a spotlight
- We set the direction to point towards the rock.
- We set the position to be close to the lower left corner and above the game plane.
- We set the outer and inner angles to soften the edges where light meets darkness.
- We set the near and far radii to reach the rock.
We create a directional light
- We set the intensity low to serve as a replacement ambient light
init() {
this.mCamera = new engine.Camera(
vec2.fromValues(50, 50), // position of the camera
100, // width of camera
[0, 0, 600, 600], // viewport (orgX, orgY, width, height)
2
);
this.mCamera.setBackgroundColor([0.8,0.8,0.8,1]);
this.mBackground = new IllumRenderable(this.kBackground,this.kBackgroundNormal);
this.mBackground.getXform().setSize(100,100);
this.mBackground.getXform().setPosition(50,50);
this.mMinionObj = new GameObject(new LightRenderable(this.kTexture));
this.mMinionObj.getRenderable().setElementPixelPositions(130,310,0,180);
this.mMinionObj.getXform().setSize(20,20);
this.mMinionObj.getXform().setPosition(50,50);
this.mRockObj = new GameObject(new IllumRenderable(this.kRockTexture,this.kRockNormal));
this.mRockObj.getRenderable().setSpriteSequence(64,0,61,64,59,2);
this.mRockObj.getRenderable().setAnimationSpeed(2);
this.mRockObj.getRenderable().setAnimationType(eAnimationType.eRight);
this.mRockObj.getXform().setSize(10,10);
this.mRockObj.getXform().setPosition(30,70);
// create the light and set properties
this.mPointLight = new Light();
this.mPointLight.setLightType(eLightType.ePointLight);
this.mPointLight.setColor([1,1,1,1]);
this.mPointLight.setXPos(50);
this.mPointLight.setYPos(50);
this.mPointLight.setZPos(1);
this.mPointLight.setNear(10);
this.mPointLight.setFar(14);
this.mPointLight.setIntensity(1);
this.mSpotLight = new Light();
this.mSpotLight.setLightType(eLightType.eSpotLight);
this.mSpotLight.setColor([1,1,1,1]);
this.mSpotLight.setXPos(10);
this.mSpotLight.setYPos(20);
this.mSpotLight.setZPos(1);
this.mSpotLight.setDirection([30,70,-1]);
this.mSpotLight.setInner(0.5);
this.mSpotLight.setOuter(1);
this.mSpotLight.setNear(70);
this.mSpotLight.setFar(80);
this.mSpotLight.setDropOff(1);
this.mSpotLight.setIntensity(2);
this.mDirectionLight = new Light();
this.mDirectionLight.setLightType(eLightType.eDirectionalLight);
this.mDirectionLight.setIntensity(0.1);
// associate the light with the renderables
this.mMinionObj.getRenderable().addLight(this.mPointLight);
this.mMinionObj.getRenderable().addLight(this.mSpotLight);
this.mMinionObj.getRenderable().addLight(this.mDirectionLight);
this.mBackground.addLight(this.mPointLight);
this.mBackground.addLight(this.mSpotLight);
this.mBackground.addLight(this.mDirectionLight);
this.mRockObj.getRenderable().addLight(this.mPointLight);
this.mRockObj.getRenderable().addLight(this.mSpotLight);
this.mRockObj.getRenderable().addLight(this.mDirectionLight);
engine.defaultResources.setGlobalAmbientIntensity(0.5);
}
We also changed the background's renderable type to IllumRenderable, which makes it seem like part of the game world. By default the GlobalAmbientIntensity does not affect IllumRenderables, this is why we add the directional light with a low intensity.
We add the second GameObject to our Draw() function.
draw() {
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]);
this.mCamera.setViewAndCameraMatrix();
this.mBackground.draw(this.mCamera);
this.mMinionObj.draw(this.mCamera);
this.mRockObj.draw(this.mCamera);
}
Lastly, we add code to the Update() function to update the animation on our rock renderable.
update() {
// ... same keyboard input code as before, found in Code Snippet 5-4 ...
this.mRockObj.getRenderable().updateAnimation();
}
Conclusion
As we have seen, adding different types of light sources and using normal maps provides depth to our 2D game. This will immerse our player in the game by providing a more interesting environment and a sense of realism.
In Tutorial 6, we continue to enhance the look of our scene by implementing shadow affects on our renderables to go along with lighting. We will also see how parallax works to add more depth in the scene.
Tutorial 4 <-- Tutorial 5 --> Tutorial 6
Tutorial Homepage