Tech

Realtime 2D Lighting in GameMaker Studio 2 - Part 1

Lighting_blog

Posted by Mike Dailly on 27 April 2017

One of the more advanced things developers want to do is create their own 2D lighting engine, but the reality of it sometimes gets a little overwhelming. In this post I'd like to cover the basics of a light, what "blockers" are, and how to cast some shadows from them in a reasonably efficient manner.

The fastest method of doing blockers (and the simplest to edit) is to use the new tilemaps. This allows us to only test cells close to the light, rather than using instances at which point you'll have to loop through them all to find those closest. So, we'll stick to tilemaps.

So, lets create a simple tileset and tilemap for testing. (tileset first)

tileset

The darker tiles are in one layer (called "ground"), and the lighter ones in another layer (called "walls").

tilemap

Now let's create an object and use it as our light, and stick it on the map, and then let's see what we should be processing. The red circle is the light size, while the yellow rectangle is the tilemap area we need to process.

light on tilemap

To process these all we need to do is loop over these tiles, and find out if there is a wall there or not.

var lx = mouse_x;       // the light position, based around the mouse location
var ly = mouse_y;
var rad = 96            // the radius of the light
var tile_size = 32;     // size of a tile
var tilemap = layer_tilemap_get_id("walls");


var startx = floor((lx-rad)/tile_size);
var endx = floor((lx+rad)/tile_size);
var starty = floor((ly-rad)/tile_size);
var endy = floor((ly+rad)/tile_size);

draw_set_color(c_yellow);
draw_rectangle(startx*tile_size,starty*tile_size, endx*tile_size,endy*tile_size,true);  

By placing this in the draw event of an object (placed at the light location), it will select the tile range we're going to process, and draws a box around it, clamped to tile boundaries. Next, we'll loop through this tile box and look for tiles that aren't empty - like this

for(var yy=starty;yy<=endy;yy++)
{
    for(var xx=startx;xx<=endx;xx++)
    {
        var tile = tilemap_get(tilemap,xx,yy);
        if( tile!=0 ){

        }
    }
}

So, now we're ready to actually do something! First, let's add some code to the create event of this object to handle the vertex buffer and the vertex format we'll be using.

/// @description init shadow casting
vertex_format_begin();
vertex_format_add_position();
vertex_format_add_color();
VertexFormat = vertex_format_end();

VBuffer = vertex_create_buffer();

With this done, now we're ready to build some shadow volumes, but before we do, how do we cast a shadow anyway? Let's go back to our light radius image, and this time project from the centre light to the one of the blocks - each corner to be more precise. The rays that project from the back of the block is the shadow volume we're after. Now, the sharper pencils among you will notice that the front 2 edges, project the same shape as the rear - they just start at a different point closer to the light. This is handy, as it means we only need to project edges FACING the light.

Light projection

Let's try this again only now project the 2 leading edges - those facing the light. We use the 2 leading edges, as the front 2 edges of the QUAD (quadrilateral, made up of 2 triangles) we're going to project. The other 2 more distant edges, we will want to just make them really far away - the hardware will clip it to the viewport anyway, so we can just make the edges really long. We first workout the room coordinates of each corner of the block, and then create small lines (vectors) of each edge (as shown below).

Light edge projection

For now, we'll just project all 4 edges and see what happens. So, let's change our processing loop to actually build our buffers.

vertex_begin(VBuffer, VertexFormat);
for(var yy=starty;yy<=endy;yy++)
{
    for(var xx=startx;xx<=endx;xx++)
    {
        var tile = tilemap_get(tilemap,xx,yy);
        if( tile!=0 )
        {
            // get corners of the 
            var px1 = xx*tile_size;     // top left
            var py1 = yy*tile_size;
            var px2 = px1+tile_size;    // bottom right
            var py2 = py1+tile_size;


            ProjectShadow(VBuffer,  px1,py1, px2,py1, lx,ly );
            ProjectShadow(VBuffer,  px2,py1, px2,py2, lx,ly );
            ProjectShadow(VBuffer,  px2,py2, px1,py2, lx,ly );
            ProjectShadow(VBuffer,  px1,py2, px1,py1, lx,ly );                      
        }
    }
}
vertex_end(VBuffer);    
vertex_submit(VBuffer,pr_trianglelist,-1);

The trick in ProjectShadow is to get the vector from the light to each point in the line supplied. We do this by doing working out Point1X-LightX and Point1Y-LightY, and Point2X-LightX and Point2Y-LightY. This gives us 2 the vectors we need. Next we want to make these "unit" vectors, that is a vector of length 1.0. This is handy as we can then scale this unit vector by an even amount, otherwise if your especially close to the block, one edge might be really close (and on screen), while the other is off into the distance. This keeps them both the same size, and the projection even. Here's how you work out a unit vector.

Adx = PointX-LightX;        
Ady = PointY-LightY;        
len = sqrt( (Adx*Adx)+(Ady*Ady) );
Adx = Adx / len;    
Ady = Ady / len;    

Adx and Ady now comprise a vector of a length of 1, that is sqrt( (Adx*Adx)+(Ady*Ady) ) == 1.0. Unit length vectors are used all over the place in computing, from normals on 3D models for lighting, to motion directional vectors. For example, if you want to move at a steady speed even if you're going diagonally (since x++; y++; will make you go faster diagonally, than in a straight line), you would use a unit vector and multiply by the speed you wish to go. Once we have our unit vectors, we can scale these by a large amount and add on the positions. This will give us our distant points to help make up our "QUAD". Here's the ProjectShadow script...

/// @description cast a shadow of this line segment from the point light
/// @param VB Vertex buffer
/// @param Ax  x1
/// @param Ay  y1
/// @param Bx  x2
/// @param By  y2
/// @param Lx  Light x
/// @param Ly  Light Y

var _vb = argument0;
var _Ax = argument1;
var _Ay = argument2;
var _Bx = argument3;
var _By = argument4;
var _Lx = argument5;
var _Ly = argument6;

// shadows are infinite - almost, just enough to go off screen
var SHADOW_LENGTH = 20000;

var Adx,Ady,Bdx,Bdy,len

// get unit length to point 1
Adx = _Ax-_Lx;      
Ady = _Ay-_Ly;      
len = (1.0*SHADOW_LENGTH)/sqrt( (Adx*Adx)+(Ady*Ady) );      // unit length scaler * Shadow length
Adx = _Ax + Adx * len;
Ady = _Ay + Ady * len;

// get unit length to point 2
Bdx = _Bx-_Lx;      
Bdy = _By-_Ly;      
len = (1.0*SHADOW_LENGTH) / sqrt( (Bdx*Bdx)+(Bdy*Bdy) );    // unit length scaler * Shadow length
Bdx = _Bx + Bdx * len;
Bdy = _By + Bdy * len;


// now build a quad
vertex_position(_vb, _Ax,_Ay);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, _Bx,_By);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, Adx,Ady);
vertex_argb(_vb, $ff000000);

vertex_position(_vb, _Bx,_By);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, Adx,Ady);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, Bdx,Bdy);
vertex_argb(_vb, $ff000000);

So, if you putting all this together, increase the rad variable in the create event to 256 and you'll get something like the image below. Moving the mouse around you'll also get the shadows moving around and projecting the right direction.

demo projection

Lastly, let's fix the need to project all 4 edges. As mentioned above we can get the same result by only casting the leading 2 edges. We do this by testing which side of the vector (the edge of the block) the light is on.

    if( !SignTest( px1,py1, px2,py1, lx,ly) ){
        ProjectShadow(VBuffer,  px1,py1, px2,py1, lx,ly );
    }
    if( !SignTest( px2,py1, px2,py2, lx,ly) ){
        ProjectShadow(VBuffer,  px2,py1, px2,py2, lx,ly );
    }
    if( !SignTest( px2,py2, px1,py2, lx,ly) ){
        ProjectShadow(VBuffer,  px2,py2, px1,py2, lx,ly );
    }
    if( !SignTest( px1,py2, px1,py1, lx,ly) ){
        ProjectShadow(VBuffer,  px1,py2, px1,py1, lx,ly );                      
    }

As you can see, our inner loop calls change only slightly to now test the edge before projecting the shadow. The edge test is pretty quick (and you can even inline it directly) so it's certainly quicker to do the test than just project all edges for shadows. Here's the sign test....

/// @description which side of a line is the point on.
/// @param Ax 
/// @param Ay 
/// @param Bx
/// @param By
/// @param Lx
/// @param Ly

var _Ax = argument0;
var _Ay = argument1;
var _Bx = argument2;
var _By = argument3;
var _Lx = argument4;
var _Ly = argument5;

return ((_Bx - _Ax) * (_Ly - _Ay) - (_By - _Ay) * (_Lx - _Ax));

For those who are curious, this is doing a cross product, but you don't really have to know - or care, how it works - it just does! Now, adding this in will have no visible effect, in fact it should look exactly the same as it did before, only now we're drawing half the amount - which is always good. Fundamentally what you now have, is the basis of all 2D lighting engines. Take an edge, project a shadow from it and draw it. How you then create these edges, or detect the "blockers" is up to you, but once you decide an edge is in range and facing the light, you project it using something like the code above. Lastly... move the instance layer that your light object is on, to be UNDER the walls, and you'll get something like this....

wall projection

We'll leave it here for this lesson, but your next task would be to make everything outside the light radius black, and then you'd start to get the feeling of a true point light source. After that, you'll want to add some colour and a "drop off", so the light fades away as it reaches the edge of its radius, then perhaps add some more lights.

Back to Top