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: IlluminationLight Renderable ObjectNormal Maps

Demonstrations: Light RenderablesUsing 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.

These different lighting types are declared using a number of parameters. Proper use of lighting requires...

  1. Declaring a light source with the type and parameters defining it's characteristics.
  2. 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...

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.

Figure 5-1: Point Light

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.

Figure 5-2: Spot Light

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.

Figure 5-3: Point Light Sample

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);
    }
Code Snippet 5-1: Loading Assets

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);
    }
Code Snippet 5-2: Initiating Light Source

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);
    }
		
Code Snippet 5-3: Draw Function

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();
	}
    }
Code Snippet 5-4: Controlling a Light Object

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.

Figure 5-4: Texture / Normal Map

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.

Figure 5-5: Normal Illumination Sample

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);
    }
	  
Code Snippet 5-5: Loading Assets for IllumRenderable

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.

We create a spotlight

We create a directional 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);
    }
Code Snippet 5-6: Initializing Lights and IllumRenderables

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);
    }
Code Snippet 5-7: Draw Function

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();
    }
Code Snippet 5-8: Updating the Animation

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

5/26/2022 - By Myles Dalton