Graphics Ultra-Fast 2D Dynamic Lighting System

GM Version: ALL (coded in 2.3. Some codes like the new functions needs to be tweaked for it to work for older versions)
Target Platform: Windows (Other platforms not tested. until HTML5 Z-buffer is fixed, this wont work in HTML5)
Download: Itch page
Links: Youtube

Summary:
2D Dynamic Lighting System, also called Realtime Lighting System or Shadow Casting, is a method of dynamically lighting and casting shadows. The same algorithm could be used for Line of Sight (like in Among Us). I will be showing my own implementation that could handle a thousand lights (5000 in my case) with little to no lag!

Video Intro:

Video Tutorial:

Required knowledge:
  • Shaders
  • Vertex Buffers
  • Blend modes
  • Z-Buffer Depth handling
Tutorial:
This implementation is similar to Mike Daily's Realtime 2D Lighting but with a couple of optimizations. I utilized the use of shaders to transfer the bulk of shadow construction from the CPU to the GPU. I then used the Z-buffer depth handling to draw all the lights and shadows to one single surface, solving the texture swap lag issues. I recommend watching the videos to get a better understanding of this tutorial.

We will need just three objects to make this work:
  • Setup object
  • Walls
  • Lights
Both Walls and Lights will have ZERO code. All the code will be written in the setup object.

Construct the room by simply dropping walls and stretching them to your liking (you can later add wall tiles over the object for aesthetics). Put as many lights as you want in the room. Lastly ,place one setup object into the room, preferably in an instance layer below the rest.
1604819313593.png

We will be drawing the lights with the use of shaders. This is how the light shader will look like:

Vertex Shader:
GML:
attribute vec3 in_Position;                  // (x,y,z)
varying vec2 pos;
uniform float u_z; //depth position

void main(){
    vec4 object_space_pos = vec4( in_Position.x, in_Position.y, u_z, 1.0);
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
    pos = in_Position.xy;

}
Fragment Shader:
GML:
varying vec2 pos; //current pixel position
uniform vec2 u_pos; //light source positon

const float zz = 32.; //larger zz, larger light

void main(){
    vec2 dis = pos - u_pos;
    float str = 1./(sqrt(dis.x*dis.x + dis.y*dis.y + zz*zz)-zz); //strength of light is the inverse distance
    gl_FragColor = vec4(vec3(str),1.);
}
This will draw a bright white light at the center of the light and it gets dimmer the further away we get from the center. The light strength is inverse to the distance from the light source. Uniform vec2 u_pos is the position of the light source. the constant float zz determines how large the light is (visual explanation in the tutorial video at 9:08). You can change this constant to be a uniform float so different lights can have different light sizes. We will talk about uniform float u_z later.

To draw our shadows, we will use vertex buffers. We setup the vertex buffer in the create event of the setup object setup like so:
GML:
//Vertex format and buffer setup
vertex_format_begin();
vertex_format_add_position_3d();
vf = vertex_format_end();
vb = vertex_create_buffer();
The vertex color and texcoord is not necessary for shadow construction so our vertex format will not include both. Note that we are using position 3D and not 2D. We will be using the Z coordinate as a flag to determine if we need to later reposition the vertex in the vertex shader (This is where the magic begins).

Then we create a function that can draw our quads as so:
GML:
//Creates Quad with two triangles. Used to make the shadows.
//Z coordinate is used as a flag to determine if the vertex will be repositioned in the shader
function Quad(_vb,_x1,_y1,_x2,_y2){
    //Upper triangle
    vertex_position_3d(_vb,_x1,_y1,0);
    vertex_position_3d(_vb,_x1,_y1,1); //repositioned vertex
    vertex_position_3d(_vb,_x2,_y2,0);

    //Lower Triangle
    vertex_position_3d(_vb,_x1,_y1,1); //repositioned vertex
    vertex_position_3d(_vb,_x2,_y2,0);
    vertex_position_3d(_vb,_x2,_y2,1); //repositioned vertex
}
_vb is the vertex buffer variable. x1 y1 represents one end of the wall and x2 y2 represents the other end of the wall. If you inspect the code carefully, you will notice that the vertices will either have both x1 and y1 or both x2 and y2, never a mixture of x1 and y2 or x2 and y1. If you forget the z coordinate, this will create a single line. Don't worry, this is intentional. Those with z position at 1 will be later repositioned in the shader so the quad will be drawn. This quad forms our shadows.

With each wall, we will draw the quads using the diagonals of the block. No, not the four edges, the diagonals. It's a simple optimization that works and reduces the amount of quads constructed by half. This is done in the object setup step function. We will do it like so:
GML:
//Construct the vertex buffer with every wall
//Instead of using the four edges as the walls, we use the diagonals instead (Optimization)
vertex_begin(vb,vf);
var _vb = vb;
with(obj_wall){
    Quad(_vb,x,y,x+sprite_width,y+sprite_height); //Negative Slope Diagonal Wall
    Quad(_vb,x+sprite_width,y,x,y+sprite_height); //Positive Slope Diagonal Wall
}
vertex_end(vb);
Let us now create the shadow shader like so:
Vertex Shader:
GML:
attribute vec3 in_Position;                  // (x,y,z)

uniform vec2 u_pos; //light source positon
uniform float u_z; //depth position

void main(){
    vec2 pos = in_Position.xy;

    if (in_Position.z > 0.){ //check if vertex requires repositioning
        vec2 dis = pos - u_pos;
        pos += dis/sqrt(dis.x*dis.x + dis.y*dis.y) * 100000.; //repositioning the vertex with respect to the light position
    }
    vec4 object_space_pos = vec4( pos.x, pos.y, u_z-0.5, 1.0); //shadow is drawn at a z-value closer to the screen than its corresponding light.
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
}
Fragment Shader:
GML:
void main(){
    gl_FragColor = vec4(0.); //draws an invisible shadow that can block the light when Z-buffer is on
}
What this shader does is, if the vertex that was passed through the shader contains a z coordinate that is greater than zero, it will reposition the vertex x and y position based on the light position, u_pos. This is very similar to how Mike Daily positioned his vertices. We get the vector distance from the light source to the end of the wall then divide it by the the scalar distance to get the unit vector. Multiply it with a large number and you get a vector with the same slope as the distance but with a very long length. We add this length to the wall end points and we get the end points of the shadow.

The big difference with my method compared to Mike Daily's is mine is calculated in the GPU. I will need to only create one common shadow vertex buffer for all lights instead of creating unique vertex buffers for every light. this common vertex buffer will be reconfigured differently through the shaders by changing the light position passed through the shader.

Note that we are drawing an invisible shadow (fragment shader code) but don't worry, this shadow will be used to block its corresponding light without affecting the previous lights, that's how we can draw all the lights directly without needing to draw on a separate surface. But in order to do this, we will need to utilize the Z-buffer to handle the depth.

Before we go to the draw function, let us first setup the shader uniform variables in the setup obejct create:
GML:
//Shader uniform variable setup
u_pos = shader_get_uniform(shd_light,"u_pos");
u_pos2 = shader_get_uniform(shd_shadow,"u_pos");
u_z = shader_get_uniform(shd_light,"u_z");
u_z2 = shader_get_uniform(shd_shadow,"u_z");
Here is the draw function of the setup object which draws the shadows first, then the light.
GML:
//Local variables setup
var _u_pos = u_pos;
var _u_pos2 = u_pos2;
var _u_z = u_z;
var _u_z2 = u_z2;
var _vb = vb;

//Turn on the Zbuffer (3D)
gpu_set_ztestenable(1);
gpu_set_zwriteenable(1);
var _z = 0;
with(obj_light){

    //Draw the shadows (AKA light blockers)
    shader_set(shd_shadow);
    shader_set_uniform_f(_u_pos2,x,y);
    shader_set_uniform_f(_u_z2,_z);
    vertex_submit(_vb,pr_trianglelist,-1);

    //Draw the Light
    gpu_set_blendmode(bm_add);
    shader_set(shd_light);
    shader_set_uniform_f(_u_pos,x,y);
    shader_set_uniform_f(_u_z,_z);
    draw_rectangle(0,0,320,180,0); //canvas for drawing the light
    gpu_set_blendmode(bm_normal);

    _z--; //Next set of shadows and lights is set closer to the screen
}
shader_reset();
gpu_set_ztestenable(0);
gpu_set_zwriteenable(0);
we turn on ztestenable and zwriteenable to allow the use of Z-buffer depth handling. Remember the u_z variable in the light shader? it is also present in the shadow shader. the only difference is that the shadow sets its z coordinate to u_z -0.5, while the light sets its shadow to just u_z. This lets the shadow be drawn over the light, even if the shadow is drawn first. The cool thing is, even if the shadow is transparent, the light will not be able to draw over the shadow. The lights previously drawn that are under the shadow will not be affected since the shadow is transparent. after drawing each light and shadow pair, we bring the _z value closer to the screen so the next light will be over the previous shadows.

Lastly, remember that the light should be drawn with additive blend since that's how lights work. We don't want the lights to replace other lights but rather merge with other lights. The rectangle is set to be the view width and height. Right now, it is simply set at (0,0,320,180) but this should be changed depending on the view dimensions and position.

And that's it! Although we used several advanced concepts like shaders and vertex buffers, the code is relatively simple, yet effective. We can further improve this implementation with post-processing. I was able to implement wall edge lighting which I plan on sharing some other time :) Understand that this system works best with 90 degree edges. It is possible to do this with other polygons or rotated blocks but there will need to be a few tweaks to the code. This system unfortunately cannot handle irregular shaped walls.
 
Last edited:

Zhanghua

Member
@
GrizzliusMaximus
In the shd_light.fsh, why the "varying vec2 pos;" can get the current pixel position?
Isn't it the Coner Position of the View Rectangle which size is 320*180?
 
Last edited:

Zhanghua

Member
I kown that..
The varying variable both existed in the VSH and FSH will be interpolated during the raster, then the new value will pip to the FSH.
While the VSH is only excuted for the submitted vetrics.
 
I kown that..
The varying variable both existed in the VSH and FSH will be interpolated during the raster, then the new value will pip to the FSH.
While the VSH is only excuted for the submitted vetrics.
Sorry for the late reply. Didn't you answer your own question? in the vertex shader, the varying vector is set as the corner pixel, which is interpolated in the fragment shader. the varying vector in the fragment shader should be pointing to the pixel position in game.
 

Zhanghua

Member
Sorry for the late reply. Didn't you answer your own question? in the vertex shader, the varying vector is set as the corner pixel, which is interpolated in the fragment shader. the varying vector in the fragment shader should be pointing to the pixel position in game.
Thank you for your detail. I had know the vertex shader manipulate the corners, but unknown the interpolated part between the VS and FS before.

TKS again.
 
Top