Tech

Realtime 2D Lighting in GameMaker Studio 2 - Part 2

Lighting_blog2

Posted by Mike Dailly on 12 May 2017

In the last post, I showed you how to create the fundamentals of a 2D lighting system, so in this one I want to carry on the current example and make it more like an actual point light. We'll also introduce a little shader use to help make things a little easier - yes shaders can be easy! - and then make the light coloured. Lets remind ourselves where we go to last time...

wall projection

At the end of the last post, I said our next task would be to make everything outside the light radius black, as this would give us a nice "point light" feel, so let's do that first. 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.

wall projection

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.

wall projection

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

wall projection

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.

shadow falloff

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:

shadow falloff

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!

shadow falloff

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.

Lighting demo

Back to Top