Coffee-Break Tutorial: Simple Lighting (GML)


Coffee-Break Tutorial: Simple Lighting (GML)

Today's coffee-break tutorial is going to be about adding some very simple lighting effects to your games using surfaces. By simple, we mean that these lights won't be able to cast shadows, but they will provide the cover of darkness along with areas of brightness, and can give a nice effect and are easy to modify so that they flicker (for example). Also for the sake of simplicity, we'll be basing this tutorial on the YoYo Platformer Demo, which you can get from the following link:

YoYo Platformer Simple Lighting GML Demo

NOTE: This tutorial is for people that use GML. If you prefer to use DnD™ to make your games, we have a companion article for you here.

Once you have it imported the project into GMS2, run it and make sure it works and that you have an idea of how it is all put together.


SETTING UP

To start with, you need to open up the room editor for the room rGrass, and then add a new instance layer. This layer will be for our lighting controller object, so go ahead and name the layer "Lighting" now (note that there is no need to repeat this for the room rSand as it will inherit the layer automatically):

Add Layer For Lighting

The next thing we need to do here is create a parent object for everything we want to be considered as a light source. For that, create a new object and call it "oLightParent". Now, click the Parent button and in the Parent Editor, click the + sign to add children to it. We want to add the Player, Ghost and Star objects as children:

Add Children To Parent

We'll also need a sprite to use for drawing the light. You can make your own (it should only use white and be on a transparent background about 256x256 pixels in size), or you can download and use the one shown below:

Light Sprite

Create a new sprite resource and call it "sLight" and then add (or create) your light image. You will need to make sure that the origin of the sprite is set to Middle Center:

Light Resource

With that done, we are now ready to start adding in our code to draw the lighting!


DRAWING THE SURFACE

We need to make another object now to act as the controller for all the games lighting. So, make a new object and call it "oLighting", then give it a Create Event. In this event we'll add this simple piece of code:

surf = -1;

Our lighting is going to use a surface with a subtractive blend mode - essentially we'll be drawing a black rectangle over the screen and then "subtracting" from it using a white sprite - and so we need a variable to hold the unique ID value of the surface we'll be using. We don't create it in this event however, as surfaces are volatile which means that they may be overwritten and removed from memory due to changes in the rendering (things like going fullscreen, or minimising the game will cause the surface to disappear, for example). To catch this we'll be doing all our surface manipulation and drawing in the Draw Event.

Add a Draw Event now to our oLightingcontroller and in it add the following:

if !surface_exists(surf)
    {
    var _cw = camera_get_view_width(view_camera[0]);
    var _ch = camera_get_view_height(view_camera[0]);
    surf = surface_create(_cw, _ch);
    surface_set_target(surf);
    draw_set_colour(c_black);
    draw_set_alpha(0);
    draw_rectangle(0, 0, _cw, _cw, false);
    surface_reset_target();
    }

All we're doing here is checking to see if the surface exists and if it doesn't we create it and clear it. Why do we clear it? Well, a surface is really a memory location that has been "reserved" for drawing, and so if we don't clear the surface on creation, it may contain "garbage" from what was previously in the memory location and when we draw the surface we'll have unwanted artefacts.

We've got our if to check if the surface exists, so now we need to add an else for when it does exist, like this:

else
{
if (surface_exists(surf)) {
var _cw = camera_get_view_width(view_camera[0]);
var _ch = camera_get_view_height(view_camera[0]);
var _cx = camera_get_view_x(view_camera[0]);
var _cy = camera_get_view_y(view_camera[0]);
surface_set_target(surf);
draw_set_color(c_black);
draw_set_alpha(0.8);
draw_rectangle(0, 0, _cw, _ch, 0);
gpu_set_blendmode(bm_subtract);
with (oLightParent)
    {

    }
gpu_set_blendmode(bm_normal);
draw_set_alpha(1);
surface_reset_target();
draw_surface(surf, _cx, _cy);
}

What this does is get the view camera position and size and stores these values in variables. It then draws a rectangle to the surface with an 80% alpha (this is the "darkness" that we are going to illuminate), before changing to a subtractive blend mode. At this point we would draw the lights using the with statement, but we'll add that in a moment. Finally the code is drawing the surface to the screen at the camera position.

We could make the surface the size of the room (and if you are not using cameras then that is what you would have to do), but in the demo the camera follows the player and so there is no need to draw anything that isn't going to be seen by the player. This helps keep memory use down and makes it less likely that your game will have issues running on low end hardware.

You should go into the room editor now, and on the "Lighting" layer that you created previously, add the controller object we've just made (it will be automatically inherited by the other room, so no need to add it there). If you run the project now, you should see the game as before, but now shrouded in darkness... Time to add some lights!


ADDING THE LIGHTS

As mentioned above, the code/actions shown expect us to do something with the oLightParent object in the Draw Event code. The simplest thing to add here would be a draw_sprite() function with the light sprite, but in the demo, each of the child objects has a different origin offset, and it may be that you'd like to add in extra effects on a per-object basis. To make this possible we'll use a switch function to check which child object index is actually running the code and change what is drawn accordingly.

So, in the Draw Event, within the with or "Applies to..." you'd have:

var _sw = sprite_width / 2;
var _sh = sprite_height / 2;
switch(object_index)
{
case oStar:
    draw_sprite_ext(sLight, 0, x - _cx, y - _cy, 0.5 + random(0.05), 0.5 + random(0.05), 0, c_white, 1);
    break;
case oGhost:
    draw_sprite_ext(sLight, 0, x + _sw - _cx, y + _sh - _cy, 0.75, 0.75, 0, c_white, 1);
    break;
case oPlayer:
    draw_sprite_ext(sLight, 0, x - _cx, y - _sh - _cy, 1, 1, 0, c_white, 1);            
    break;
}

Here we're drawing the light sprite at the center of each instance, and we're also using the image x/y scale to change the size of the lit area, with a small random value added to it in the case of the star object to make it twinkle. One thing that's very important is that we're also offsetting the x/y position for drawing by the camera x/y position. This is because when you draw to a surface all drawing will be done from the (0, 0) position, which is the surface origin. By subtracting the camera position from the instance position, we bring it into the correct range to be drawn on the surface.

Run the project again now and you should see how the stars, the ghost and the player all have a "light" around them.

Screenshot

FINAL NOTES

Okay, this isn't really lighting... technically we're really punching holes in a surface and using those holes to reveal what's underneath, but this technique can be very useful in a great number of situations, and making simple (fake) lighting is just one of them. However, before you go off and start playing with this technique, we need to add one final thing into the project, and that's a Clean Up Event. As we mentioned earlier, surfaces take up memory and if we don't free up that memory when not in use we get a memory leak, as we may be creating a new surface every time we (re)start the room and so the old surface gets de-referenced meaning it can no longer be accessed and just sits there in memory taking up space. Memory leaks will eventually slow down and maybe even crash your game when it's being played, and you want to always be vigilant to avoid them.

So, add a Clean Up event now and in it place this code:

if surface_exists(surf)
    {
    surface_free(surf);
    }

Now we really are finished and you have leaned how to make fast and simple lighting! Now that you have it all set up, take a moment to test the system using different types of sprites with different shapes, alphas, colours... or maybe try different blend modes and see what happens? Whatever you do, have fun and...

Happy GameMaking!