Hi all,
I thought I'd share my code for 2D dynamic shadows in case anyone wanted to use it.
Here's a very short video of the effect in action:
Ignore the frame rate - i get well over 140 FPS on my PC running a Radeon 6290, the lag shown here is from the screen capturing software.
The effect is produced by running 2 shaders applied to the screen Quad each frame. It is based on the technique described
here.
The code looks a little complicated, but it is basically doing the following steps:
1] Create a render image of the screen as it is - this is then used later to apply the light and shadows to.
2] Create an occlusion map - basically the same image as above, but the only images visible are those that create shadows. In this example, I hide all the floor tiles and keep all the boxes visible.
3] Run the shadow caster shader to create a 1D image. This 1D image represents minimum distance from the centre of the screen to the first occlusion pixel. I used and image of 360x1 in the video, but I have recently changed this to 512x1 to improve the movement of the shadows.
4] Finally run the shadow draw shader to apply the shadows and light to the original image.
Here is the AppGameKit code:
// Project: 2D Dynamic Shaddows
// Created: 2014-10-20
// set window properties
SetWindowTitle( "2D Dynamic Shaddows" )
SetScreenResolution( 640, 360, 0 )
SetSyncRate( 0, 0 )
// set display properties
SetVirtualResolution( 1280, 720 )
SetScissor( 0, 0, 0, 0 )
SetPrintSize( 32 )
//load the shaddow shaders.
global CastShaderID as integer
global DrawShaderID as integer
CastShaderID = LoadShader( "Quad.vs", "shadow cast.ps" )
DrawShaderID = LoadShader( "Quad.vs", "shadow draw.ps" )
//create image for main texture
global TextureImg as integer
TextureImg = 1
CreateRenderImage( TextureImg, GetVirtualWidth(), GetVirtualHeight(), 0, 0 )
//create image to hold the shadow casters
global OcclusionImg as integer
OcclusionImg = 2
CreateRenderImage( OcclusionImg, GetVirtualWidth(), GetVirtualHeight(), 0, 0 )
//create image to hold the shadow map
global ShadowsImg as integer
ShadowsImg = 3
//define how many light rays to render
LightRays = 512
//the shadow map is a 1D images with its width equal to the number of light rays
CreateRenderImage( ShadowsImg, LightRays, 1.0, 0, 0 )
//create Quad object to apply full screen shaders too
global ScreenObj as integer
ScreenObj = CreateObjectQuad()
//MEDIA LOADING
//load floor tiles
TileImg = LoadImage( "floor.png" )
//we will create an array of floor tiles to fill the screen
global MaxTileHoriz as integer
global MaxTileVert as integer
//calculate the maximum number of tiles to fit the width and height of the screen
MaxTileHoriz = Trunc(GetVirtualWidth()/GetImageWidth(TileImg))
MaxTileVert = Trunc(GetVirtualHeight()/GetImageHeight(TileImg))
//create an array to store all the floor tile sprite IDs
dim TileSpt[MaxTileHoriz,MaxTileVert] as integer
//create and position all the floor tiles
for h=0 to MaxTileHoriz
for v=0 to MaxTileVert
//create the sprite from the floor tile image
TileSpt[h,v] = CreateSprite( TileImg )
//set its position on the screen
SetSpritePosition( TileSpt[h,v], GetImageWidth(TileImg)*h, GetImageHeight(TileImg)*v )
//make the sprite visible
SetSpriteVisible( TileSpt[h,v], 1 )
//set the sprite depth to 1000
SetSpriteDepth( TileSpt[h,v], 1000 )
//switch off transparency to boost performance
SetSpriteTransparency( TileSpt[h,v], 0)
next v
next h
//load boxes - these will be our shadow casters
BoxImg = LoadImage( "container.png" )
//create 12 boxes in total
global MaxBoxes as integer
MaxBoxes = 12
//create an array to store all the sprite IDs for the boxes
dim BoxSpt[MaxBoxes] as integer
//create and randomly position all the boxes
for b=0 to MaxBoxes
//create the sprite from the box image
BoxSpt[b] = CreateSprite( BoxImg )
//set the offset in the middle of the sprite
SetSpriteOffset( BoxSpt[b], 0.5*GetSpriteWidth(BoxSpt[b]), 0.5*GetSpriteHeight(BoxSpt[b]) )
//pick random screen coordinates between 10% and 90% of the screen width and height
posx# = Random(10,90) * 0.01 * GetVirtualWidth()
posy# = Random(10,90) * 0.01 * GetVirtualHeight()
//position the sprite
SetSpritePositionByOffset( BoxSpt[b], posx#, posy# )
//rotate the box to a random angle
SetSpriteAngle( BoxSpt[b], Random(0,360) )
//make the box visible
SetSpriteVisible( BoxSpt[b], 1 )
//turn off transparency for the box to boost performance
SetSpriteTransparency( BoxSpt[b], 0 )
//set the boxes at a depth of one so they are the top most sprites
SetSpriteDepth( BoxSpt[b], 1 )
next b
//MAIN LOOP
//reset the "holding box" variable to -1 - as we are not holding a box when the app starts
holdingbox = -1
//resets the view offset
ViewX# = 0.0
ViewY# = 0.0
repeat
//display the FPS and instruction text
Print( "FPS:"+str( ScreenFPS() ) )
Print( "click to drag boxes." )
Print( "press ESC to quit." )
//check for box grabbing
if GetPointerPressed()=1
//the pointer has just been pressed
//get pointer position in screen coordinates - screen coordinates are needed to test for spite collision
tapx# = ScreenToWorldX( GetPointerX() )
tapy# = ScreenToWorldY( GetPointerY() )
//loop through all the boxes
for b=0 to MaxBoxes
//check if the box as been hit by the pointer
if GetSpriteHitTest( BoxSpt[b], tapx#, tapy# )=1
//If tru we have found the target - remember which box is being held
holdingbox = b
//remember offset between pointer and box centre so that when we move the sprite the sprite moves correctly
boxoffsetx# = GetSpriteXByOffset( BoxSpt[b] ) - tapx#
boxoffsety# = GetSpriteYByOffset( BoxSpt[b] ) - tapy#
//skip to end of the loop - no need to check any more boxes
b = MaxBoxes
endif
next b
endif
//check if we are holding a box
if holdingbox>-1 and GetPointerState()=1
//player is holding a box
//get the new pointer position
tapx# = ScreenToWorldX( GetPointerX() )
tapy# = ScreenToWorldY( GetPointerY() )
//find new position for box
posx# = tapx# + boxoffsetx#
posy# = tapy# + boxoffsety#
//reposition box based on the point's new position
SetSpritePositionByOffset( BoxSpt[holdingbox], posx#, posy# )
endif
//check if we have let go of the box
if GetPointerReleased()=1
//player has let go so forget about previsouly held box
holdingbox = -1
//there is nothing else to do as the box has been moved
endif
//move camera
speed# = 100.0
if GetRawKeyState( 37 )=1
//left
ViewX# = ViewX# - (speed# * GetFrameTime())
endif
if GetRawKeyState( 39 )=1
//right
ViewX# = ViewX# + (speed# * GetFrameTime())
endif
if GetRawKeyState( 38 )=1
//up
ViewY# = ViewY# - (speed# * GetFrameTime())
endif
if GetRawKeyState( 40 )=1
//down
ViewY# = ViewY# + (speed# * GetFrameTime())
endif
SetViewOffset( ViewX#, ViewY# )
//create the dynamic shadows - since this is a bit complicated the code is held in a separate function
CreateShadows()
//check for ESC being pressed
if GetRawKeyReleased( 27 ) = 1
//output images for debugging
SaveImage(TextureImg, "texture.png" )
SaveImage(OcclusionImg, "occlusion.png" )
SaveImage(ShadowsImg, "shadows.png" )
//on Windows these images are saved here:
//C:\Users\[YOUR NAME]\AppData\Local\AGKApps\DynamicShaddows
endif
//quit the app if ESC has been pressed.
until GetRawKeyReleased( 27 )
//SUPPORT FUNCTION
function CreateShadows()
//This function performs all the required shader calling and rendering for dynamic shadows
Update(0) : //update sprite positions
//CREATE SHADOW CASTER MAP
//The occlusion map tells the app which objects are generating shadows
//hide all the floor tiles
for h=0 to MaxTileHoriz
for v=0 to MaxTileVert
//set all floor tiles to invisible
SetSpriteVisible( TileSpt[h,v], 0 )
next v
next h
//show all the boxes that will generate shaddows
for b=0 to MaxBoxes
SetSpriteVisible( BoxSpt[b], 1)
next b
//set the app to render to the Occlusion Image
SetRenderToImage( OcclusionImg, 0)
//clear the screen of old data
ClearScreen()
//rend the world to the Occlusion Image
Render()
//CREATE THE 1D SHADOW MAP
//The shadow map is used by the final texture to determine whicj pixels are in shadow
//apply shadow cast shader to this image to create a 1D shadow map
//The shadow map will be the base image
SetObjectImage( ScreenObj, ShadowsImg, 0 )
//The Occlusion Image will also be used by the shader
SetObjectImage( ScreenObj, OcclusionImg, 1 )
//Set the Shadow Cast shader to the Screen Quad
SetObjectShader( ScreenObj, CastShaderID )
//set the world to render to the Shadow Map Image
SetRenderToImage( ShadowsImg, 0 )
//clear the buffer of old data
ClearScreen()
//Render only the Quad object - this is the only object we need to render at this moment.
DrawObject( ScreenObj )
//CREATE MAIN DISPLAY TEXTURE
//show all the floor tiles
for h=0 to MaxTileHoriz
for v=0 to MaxTileVert
//set the sprite to visible
SetSpriteVisible( TileSpt[h,v], 1 )
next v
next h
//set the world to render to the Texture Image
SetRenderToImage( TextureImg, 0)
//clear the buffer of old data
ClearScreen()
//render the world to the Texture Image
Render()
//APPLY THE SHADOWS TO THE MAIN TEXTURE
//hide all tiles - since they will be visible in the main texture anyway
//for h=0 to MaxTileHoriz
//for v=0 to MaxTileVert
//SetSpriteVisible( TileSpt[h,v], 0 )
//next v
//next h
//set the shader variables
//tell the shader the resolution of the image
SetShaderConstantByName( DrawShaderID, "tex_resolution", GetVirtualWidth(), GetVirtualHeight(), 0.0, 0.0 )
//Colour of the light generating the shadows in RGB (in the range 0.0 to 1.0)
SetShaderConstantByName( DrawShaderID, "agk_torch_color", 1.0, 1.05, 1.0, 0.0 )
//Define the distance of the torch light
SetShaderConstantByName( DrawShaderID, "agk_torch_dist", 400.0, 0.0, 0.0, 0.0 )
//Define the ambient light level 0.0 (no light) to 1.0 (full light)
SetShaderConstantByName( DrawShaderID, "agk_ambient", 0.1, 0.0, 0.0 ,0.0 )
//Set the main texture as the base texture for the QUAD
SetObjectImage( ScreenObj, TextureImg, 0)
//Set the shadow map image as the 2nd texture
SetObjectImage( ScreenObj, ShadowsImg, 1)
//set the occlusion image as the 3rd texture
SetObjectImage( ScreenObj, OcclusionImg, 2 )
//set the Shadow Draw shader to the Quad
SetObjectShader( ScreenObj, DrawShaderID )
//set the world to render to the screen
SetRenderToScreen()
//clear the buffer of old data
ClearScreen()
//render only the Quad - this improves speed and the main texture holds all the visible objects anyway.
DrawObject( ScreenObj )
//displays the buffer to the screen
Swap()
endfunction
Here is the Vertes shader "Quad.vs" for the Quad:
attribute vec3 position;
attribute vec4 color;
attribute vec2 uv;
varying vec2 uvVarying;
varying vec2 posVarying;
varying vec4 colorVarying;
uniform vec4 uvBounds0; // to adjust for atlas sub images
uniform float agk_invert; // FBOs render upside down so flip the quad
void main()
{
gl_Position = vec4(position.xy*vec2(1,agk_invert),0.5,1.0);
uvVarying = (position.xy*vec2(0.5,-0.5) + 0.5) * uvBounds0.xy + uvBounds0.zw;
colorVarying = color;
}
Here is the "shadow cast.ps" shader:
// This shader creates the 1D shadow map.
// These shaders are based on the technique described here:
// https://github.com/mattdesl/lwjgl-basics/wiki/2D-Pixel-Perfect-Shadows
//shadow map texture - this will be overwritten
uniform sampler2D texture0;
//the occluder texture - we use this to generate the shadows
uniform sampler2D texture1;
//resolution of the 1D map
uniform vec2 agk_resolution;
//variable passed from vertex shader
varying vec2 uvVarying;
//variable for occluder RGBA values
vec4 occluder;
//variables for occlusion map coordinates
vec2 occPos;
vec2 occUV;
//Alpha threshold for shadow map
float threshold = 0.75;
void main(void)
{
//convert shadow map UV to angle where the UV of 1.0 is equal to 360.0 degerees
float angle = 360.0 * uvVarying.x;
//determine direction vector for angle from centre outwards
vec2 dir = vec2( 0.5*cos( radians(angle) ), 0.5*sin( radians(angle) ) );
//step from centre to outside until occuder hit in 1% steps
float mindist = 1.0;
float dist = 0.0;
//step from the middle outwards until an occluder is hit
while ( dist <= 1.0 )
{
//fint the new UV in the Occluder Image
occUV = vec2( 0.5 + (dir.x * dist), 0.5 + (dir.y * dist) );
//get the RGBA data from the Occluder Image
occluder = texture2D( texture1, occUV );
//check if the pixel is an occluder
if (occluder.a > threshold)
{
//if it is - store the distance and skip to the end of the loop
mindist = dist;
dist = 1.0;
}
//step by 1%
dist += 0.01;
}
//find the UV position of the closest occluder
occUV = vec2( 0.5 + (dir.x * mindist), 0.5 + (dir.y * mindist) );
//store info about the distance of the occluder - only the B value is used in the shadow draw shader
gl_FragColor = vec4(occUV.x, occUV.y, mindist, 1.0);
}
Here is the "shadow draw.ps" shader:
// This shader draws the dynamic shadows
// These shaders are based on the technique described here:
// https://github.com/mattdesl/lwjgl-basics/wiki/2D-Pixel-Perfect-Shadows
//texture to apply shaddow to
uniform sampler2D texture0;
//shaddow map
uniform sampler2D texture1;
//occulder map - to re-draw boxes back to the screen
uniform sampler2D texture2;
uniform vec2 tex_resolution;
varying vec2 uvVarying;
//the next values are set within the game code using SetShaderConstantByName
uniform vec3 agk_torch_color;
uniform float agk_torch_dist;
uniform float agk_ambient;
vec4 finalColor;
vec4 occColour;
vec2 middle = vec2( tex_resolution.x*0.5, tex_resolution.y*0.5 );
vec2 realPos;
vec2 UVmiddle = vec2( 0.5, 0.5);
float fade;
vec2 delta;
void main( void )
{
//find angle of pixel from centre
delta.x = 0.5 - uvVarying.x;
delta.y = 0.5 - uvVarying.y;
//shift the angle by 180 degrees as atan outputs -180 to +180
float angle = degrees( atan( delta.y, delta.x ) ) + 180.0;
//find uV distance from centre to pixel
float UVdist = distance(UVmiddle.xy, uvVarying.xy ) * 2.0;
//get max UV distance from shadow map
vec2 shadowUV = vec2( (angle / 360.0), 0.5 );
//read the B value from the Shadow Map to determine the maximum UV distance for this angle
float maxUVdist = texture2D( texture1, shadowUV ).b;
//check if this pixel is beyond this distance
if (UVdist>maxUVdist)
{
//in shadow
//check the occuder texture
occColour = texture2D(texture2, uvVarying);
if (occColour.a>0.5)
{
//if the pixel is an occluder - draw this at full colour
gl_FragColor = occColour;
}
else
{
//the pixel is not an occluder so draw it in ambient light
gl_FragColor = texture2D(texture0, uvVarying) * agk_ambient;
}
}
else
{
//in light
//check the occuder texture
occColour = texture2D(texture2, uvVarying);
if (occColour.a>0.5)
{
//if the pixel is an occluder - draw this at full colour
gl_FragColor = occColour;
}
else
{
//get the position in pixels
realPos = uvVarying * tex_resolution;
float realdist = distance(tex_resolution.xy * 0.5, realPos.xy);
if (realdist>agk_torch_dist)
{
fade = 0.0;
}
else
{
fade = 1.0 - (realdist / agk_torch_dist);
}
if (fade<agk_ambient)
{
//limit the fade to the ambient light value
fade = agk_ambient;
}
//apply light colour
finalColor = vec4(agk_torch_color.r * fade, agk_torch_color.g * fade, agk_torch_color.b * fade, 1.0) + (texture2D(texture0, uvVarying) * agk_ambient);
gl_FragColor = texture2D(texture0, uvVarying) * finalColor;
}
}
}
I'm sure there are other optimisations and improvements I can add, but these will probably come as I implement it into a bigger demo.