Realtime 2D Lighting in GameMaker Studio 2


Realtime 2D Lighting in GameMaker Studio 2

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).

undefined

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

undefined

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.

undefined

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.

undefined

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).

undefined

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.

undefined

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....

undefined

Our 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.

Before we really get going on the mechanics of this, I'm going to move the rendering of these shadow volumes to a surface, as this can then be processed by a shader more easily. Be warned, many of these changes will be dotted around in different scripts, so we'll have to jump around a bit - so keep up!

In the create event of your light object add this variable in:

surf = -1;

Then at the TOP of the draw event code add this, and it'll create the surface we need the first time the draw code runs - and recreate it if it ever gets lost.

if( !surface_exists(surf) ){
    surf = surface_create(room_width,room_height);
}

Now just before the tile loop, and prior to the vertex_begin() we'll set this surface to be the current one so that the shadow volume rendering will go to that instead of the screen. We'll change our loop so that it will now look something like this...

surface_set_target(surf);
draw_clear_alpha(0,0);

vertex_begin(VBuffer, VertexFormat);
for(var yy=starty;yy<=endy;yy++)
{
    for(var xx=startx;xx<=endx;xx++)
    {
            // Shadow volume creation. 
    }
}
vertex_end(VBuffer);    
vertex_submit(VBuffer,pr_trianglelist,-1);
surface_reset_target();

draw_surface(surf,0,0);

Now this should again not look any different than before, except for the fact we're now using a surface. You'll notice we clear the surface to black and 0 alpha. This is important as it allows us to blend the surface onto the display, and it will allow us to show the ground where ever there aren't any shadows.

Cool, now it gets really interesting! We'll now create our shader, and get it to process this new surface. Again, it shouldn't look any different than it currently does - for now. Right click on the shaders resource item and select create. This should add the shader and open it up ready to edit.

undefined

This will give us a simple, default shader and all we need to do is set this shader before drawing the surface, and then reset it after. This goes around the draw_surface() call (and assumes you've just called your shader shader0).

shader_set(shader0);
draw_surface(surf,0,0);
shader_reset();

Again, this will look exactly as before, only now we can add some cool bits to it! First we need to pass in a user defined value, one we can pass in from our lights draw event. Inside the Fragment shader code add in this line just above the void main()...

uniform vec4 u_fLightPositionRadius;        // x=lightx, y=lighty, z=light radius, w=unused

Next, we need to pass in the room coordinate into the Fragment shader from the Vertex shader. This is pretty easy as the coordinate we generate for shadow volumes is actually in room coordinates already , so we just need to pass this through. To do this we add another varying value to the Vertex shader, just under the others - like this:

varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec2 v_vScreenPos;

Then at the bottom of the Vertex shader script, just before the } we copy the location over.

v_vScreenPos =  vec2( in_Position.x, in_Position.y );

You also need to put the "varying vec2 v_vScreenPos;" line into the Fragment shader. Now that the Fragment shader has everything it needs, we can pass in the light position inside the light draw event. To do this we need to get the handle to the u_fLightPositionRadius variable inside the create event of the light object. We do this at the start as it's not a terribly quick thing to do, so if we do it once at the start, it'll be nice and quick later on. At the bottom of the light's create event, add this line in:

LightPosRadius = shader_get_uniform(shader0,"u_fLightPositionRadius");

We can now use this in the light draw event, just after we've set the shader and before we draw the surface.

shader_set(shader0);
shader_set_uniform_f( LightPosRadius, lx,ly,rad,0.0 );
draw_surface(surf,0,0);
shader_reset();

Now if we run this - yup, it looks exactly the same - but it should run fine!
However, we are now finally in a position to use this extra information in the fragment shader. By taking the room position (v_vScreenPos) and working out the distance to the Light position (u_fLightPositionRadius.xy) and then checking to see if it's larger than the radius, we can tell if it's in range of the light, and if not we output a black pixel. You should also increase the light radius (the rad variable in the draw event to about 256). Let's take a look at how we use that in the Fragment shader, here is the whole shader...

varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec2 v_vScreenPos;


uniform vec4 u_fLightPositionRadius;        // x=lightx, y=lighty, z=light radius, w=unused

void main()
{
    // Work out vector from room location to the light
    vec2 vect = vec2( v_vScreenPos.x-u_fLightPositionRadius.x, v_vScreenPos.y-u_fLightPositionRadius.y );

    // work out the length of this vector
    float dist = sqrt(vect.x*vect.x + vect.y*vect.y);

    // if in range use the shadow texture, if not it's black.
    if( dist< u_fLightPositionRadius.z ){
        gl_FragColor = v_vColour * texture2D( gm_BaseTexture, v_vTexcoord );
    }else{
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    }
}

As you can see, it's a pretty simple calculation, but this will give us the pretty cool effect of a point light and everything outside that being black.

undefined

So how cool is that! Moving the mouse around we now have a cool point light casting shadows on the world. We can improve even this simple effect by scaling the resulting ALPHA value down a bit so that we can see through it. Just before the final } in the Fragment shader, add this line....

gl_FragColor.a *= 0.5;

What this does is to take whatever the final alpha is (which in our case is either 1.0 for being in a shadow, or 0.0 for not in shadow), and scales it down making it more transparent. This results in the image below...

undefined

okay so let me do a small aside here. Above you'll notice that when I work out the vect variable I do vec2(x-x,y-y). I just do this here to make it clearer, but I actually don't have to do this. Shaders work in vectors, and this means we can do multiple things at once. That line should look like this...

vec2 vect = v_vScreenPos.xy-u_fLightPositionRadius.xy;

What this does is takes the XY of the screen position and subtracts the XY of the light position and stores it as a vec2 all in one operation. If you understand this, then leave the new line in, if not just leave it as before, it'll not make much difference to this particular shader but it is something you should try and get your head around at some point, as it's incredibly powerful and makes your code smaller to boot.

The next thing I'd like to add is a little colour, but before doing this I want to stop that hard edge of the light and add in what we'll call light fall off. Unless you're very close to a light you'll never really get the hard kind of edge we have, it will in fact it'll fade away slowly, so it'd be nice to add in this to our light as well. We'll make it a linear fall off, but you could use a curve or something if you felt smart. The fall off calculation is a really simple one, in fact we already have the values we need. The distance from the light and the light radius is all we need. Since we already have a bit of code that works when inside the radius we simply have to work out the 0.0 to 1.0 scale (of being inside the radius to the edge of it), and then LERP (Linear Interpolate) the shadow volume to complete shadow. Here is a replacement for the inner section of the Fragment shader:

// if in range use the shadow texture, if not it's black.
if( dist< u_fLightPositionRadius.z ){
    // work out the 0 to 1 value from the centre to the edge of the radius
    float falloff = dist/u_fLightPositionRadius.z;          
    // get the shadow texture
    gl_FragColor = v_vColour * texture2D( gm_BaseTexture, v_vTexcoord );        
    // now LERP from the shadow volume shape to total shadow
    gl_FragColor = mix( gl_FragColor, vec4(0.0,0.0,0.0,0.7), falloff);          
}else{
    // outside the radius - totally in shadow
    gl_FragColor = vec4(0.0, 0.0, 0.0, 0.7);
}

LERP inside a shader is called mix (for some bizarre reason), but it's the same thing. depending on the falloff value it'll slowly mix (I guess) from gl_FragColor to 0,0,0,0.7 (total shadow). After you remove the gl_FragColor.a *= 0.5; from the end of the Fragment shader script, this will give us a lovely falloff and a real feeling of dark and light.

undefined

The next logical thing to do, is add a bit of colour. So first double click the instance in the room, and set the colour of it to green - like this:

undefined

After you've done this, change the line in the lights draw event where we draw the surface, so we can pass this colour in, like this:

draw_surface_ext(surf,0,0,1,1,0,image_blend,0.5);

This will send in the green colour (and a 0.5 alpha) to the shader so we can use it to set the light colour. Next we're going to change the inner core of the Fragment shader again to deal with the colour. So where we had the line gl_FragColor = v_vColour * texture2D() we will now change to this...

    vec4 col =  texture2D( gm_BaseTexture, v_vTexcoord );
    if( col.a<=0.01 ){
        gl_FragColor = v_vColour;           
    }else{      
        gl_FragColor = col;
    }

We check the alpha (the col.a) value as it's 1.0 for full shadow and 0,0 for not in shadow, so it's a nice simple number to look at. We also don't compare to exactly 0.0 as with floating point you can never really be sure what the value will be, so we should always put in a little margin for error. Now if we run this, you should get a lovely green coloured light!

undefined

To do many lights, you now basically do this over and over again, and while there are some blend mode complications (i.e. how to keep blacks black, and colours coloured) this is the core of things. If you struggle to find the right blend mode, then pass everything into a shader and have it do whatever you need it to, remember the application surface is also just a surface and can also be an input to a shader. Just remember you can't read and write to the same surface, so you may need a temporary one.

So... there you go, you're now well on your way to doing your own lighting engine! Below you can see my own efforts as I went beyond this one light into multiple dynamic lights. Clicking the image below will take you to a set of videos showing my progress using this exact same system, and while some of the things I do are more advanced (like putting multiple shadows on a single surface), you certainly don't have to try and copy them to get your own lighting system working, it'll work just fine without any fancy additions.

undefined