Recreating Celeste Lighting in GameMaker:Studio


Celeste is an absolute masterpiece, from the game design to the music to the graphics, all of which were made with the highest of standards. If you haven't heard of Celeste yet, do yourself a favor and check it out!

Inspired by Celeste, I've decided to recreate its lighting effect. Doing so required me to look up several online tutorials, one of which comes from the lead programmer of Celeste, Noel Berry himself! I would also like to mention Mike Dailly for the Realtime 2D lighting tutorial, and Gaming Reverend's Shaders Playlist. I won't be explaining in much detail parts that have been explained in these other tutorials. I will be linking their tutorials  throughout this tutorials and at the end.

Before we begin, you might want to learn more about surfaces, blend modes, vertex buffers and shaders as this tutorial will use them a lot. 

Rhythm Castle with the "Celeste" lighting

Here is a summarized step-by-step process of how to create the lighting effect:

  1. Locate the solid corners  
  2. Create edges from two corners
  3. Create a darkened surface 
  4.  Locate center of the light source
  5. Cast a shadow from those corners made in step 2 with reference to the light source
  6. "Block" the surface with the shadows
  7. Draw the light
  8. "Unblock" the surface
  9. Repeat steps 4-8 for all light sources
  10. Blur the surface
  11. Apply the surface to the main surface (application surface) as a color blend
  12. Add bloom filter.

It's quite the process but the results are stunning. Now let's get more in-depth!

LOCATING CORNERS AND MAKING EDGES

There are several ways of implementing this and it really depends on your game and how optimized you want it to be. In Mike Dailly's Tutorial, he made edges from tiles. My implementation uses objects and for better optimization, I only got the corners of the entire solid and not the corners of each individual solid objects. Reducing the amount of corners reduces the amount of vertex draws later on.


Corners marked as red

Getting these corners requires you to check if the adjacent blocks are empty. For example, if the top side and the right side are empty then you know there is a corner at top-right of the object. The tricky cases are the "elbow folds" for they are technically not corners.


Folds marked as green

A fold is detected when there is a block horizontally adjacent, another block vertically adjacent, and an empty space diagonally adjacent. For example, if there is a block at the bottom and another on the left and no block at the bottom left, then there  is a fold at the bottom left  corner of that object. 


Bottom-left corner of object (highlighted blue) detected as a fold

We then list down all these corners and folds in whatever data structure you desire. A double array, a ds_grid or even multiple ds_lists.  I used multiple ds_lists but I really don't see the problem with using the other two. In one list, I stored the x coordinate of the corners/folds and the y coordinate in the other list. I also made another list that stores the "direction" of the corner. What do I mean by "direction"? I'll explain that in the next paragraph.

The "direction" of the corner tells us where to look for the other corner pair (I'll be referring folds as corners too from now on). Each corner always has two other corners that it can connect to form an edge. However, for each corner, we only need to find one corner to make an edge.  It is  geometric property that all closed 2D shapes have the same number of corners as edges (we will not talk about the circle).  In other words, it is guaranteed that all edges will be found if each corner makes a pair with one corner. We just have to make sure that the corner doesnt find the corner thats finding it. That's where the "direction" property comes along. Since we are only dealing with 90 degree corners, we only have 4 directions:  up, down, left and right. Depending on the kind of  corner (upper-left corner, lower-right fold, etc.) a specific direction will be assigned to it.  Here is a picture so explain this better.


Direction of the corner

Based on the picture, the upper-left corner looks to its right, the upper-right corner looks below... and it goes on. Remember that all upper-left corners will always look to the right. There's is also a reason why we are going in a "clockwise" direction around the solid shape which we will cover in the later parts.

To get the corner pair, we would need to look at all corners that we listed down and see if it aligned and towards the direction of our current corner. There are times that we will get multiple corners that fit this description. If so, we only get the one closest to our current corner. Finally, we store the two corners and its coordinates into a list/grid/array that we will look up in the future. 

DRAWING THE SHADOW

Since Mike Dailly has already explained this in-depth in his tutorial, I won't be explaining too much about it. Read his tutorial to  understand how to draw a shadow using vertex buffers. I did the same thing but I used my edges as the parameters to be fed into the ProjectShadow() function.  Remember the "clockwise" direction of getting the corner pair? That is used to determine where the edge is facing. To reduce the amount of shadow projections, we only need to project shadows from the edges facing the light source. We can determine this using cross-product (code in  Mike Dailly's tutorial). 

[For those who do want to understand how it works because Mike didn't explain much and just said it "just works", cross product can also be written as |A||B|sin(θ) where theta is the angle between the light source and  the edge. If the angle is between 0-180 degrees, the result will be positive. 0-180 degrees also means the light source is in front of the edge. Therefore, when the cross product gives a positive number, the light is facing the edge.]

If you were able to get all the edges properly and implement the Realtime 2D Lighting Shadows, you should come up with something that looks like this: (make sure to draw the shadows before foreground)

Single light projection casting shadows

DRAWING THE LIGHT

The next thing we need to do is draw the light. There are many ways of doing this, though some more effective than others. In Mike Dailly's  Part 2 Tutorial, he drew the shadows onto a new surface then used a shader on the surface to draw the light source that slowly fades out within a circular region and everything else outside this region is drawn black.  Noel Berry on the other hand, as seen in his blog,  used the cut-out implementation (his mesh implementation is entirely different from what we are doing and is far more complicated) wherein he drew the light on a new texture then drew the shadows to "cutout" the light. 

What we will do is make a new surface initially covered black and draw the shadows onto the surface then the light.  Now if you are smart, you will be asking, "Shouldn't we draw the light first then the shadows to cutout the light? If we draw the shadows first, the light will just go over it." This is true IF we draw things normally, but we will be using blend modes  and channel toggling to make some magic.  

We will use the script:  draw_set_color_write_enable(red,green,blue,alpha) for GMS1 or gpu_set_colourwriteenable(red,green,blue,alpha) for GMS2 to toggle the color channel off but leave the alpha channel on. We then use subtractive blend to draw the shadows onto the surface. What this does is it sets the alpha of the areas where the shadows are drawn to zero while preserving the colors.  

We  then draw the light  by first toggling the colors back on, setting the blend mode to  bm_dest for source and bm_one for destination. This essentially makes it an additive blend mode but we are restricted to only draw on areas where the surface is opaque (the areas where there are no shadows). 

If at this point you are having a hard time understanding these blend modes or have no idea what blend modes are, please look it up. Blend modes are a very powerful tool in making cool effects.

Going back to lights, make sure that the light being drawn is fading to black and not fading to transparency as the blend mode we are currently using disregards the transparency of the source. The light could simply be a sprite drawn at the light source or you can make spotlight shader as Mike Dailly did that will draw the light. The sprite method is faster and easier for most but it will take a bit of space on your texture page.

This is how it will look like if you do all the steps correctly:


Single light projection with light drawn

Now we are getting somewhere! However, notice how the bear  and all the background disappeared. We do not want that. This is where drawing it onto a new surface comes in handy. We can change the transparency/alpha of the entire surface so anything under the shadow could be partially seen. Another approach, which I did, is to draw the surface with blend mode set at bm_zero for source  and bm_src_color  for destination. This makes the light/shadow surface act like a typical color blend but each pixel has its own unique color blend. If the light/shadow surface is white, the application surface is kept as is (like setting image_blend to c_white) but if it's black, the application surface will be rendered completely black. I made use of shader that increases the brightness of the surface by adding a bit of blue light so the shaded areas are still partially seen (blue shadows look nice). So that part is solved  but here comes the next problem, how do we make more light sources? 

In Mike Dailly's approach and Noel Berry's first cutout implantation, in order to make more lights, they made a new surface/texture and repeated the whole process for each light. They could not use the same surface/texture since this may potentially remove the previous lights drawn.  

The problem with making new surfaces/textures, as Noel Berry pointed out,  is it will slow down the game by a lot due to all the texture swaps. Every time you set a new surface target, you swap textures. every time you draw a different surface, you swap textures. The reason why your sprites are bunched up in texture pages is to reduce the amount of texture swaps being made. We want to minimize texture swaps as much as possible to improve performance.

Noel Berry comes up with a smart solution to make use of the RED GREEN BLUE ALPHA channels so he can draw not just one but four lights in one texture. He then further improves this by making use of a 2048x2048 texture which he divides into a grid and draws four 256x256different lights at each grid cell, giving him a total of 256 lights with only one texture!

SPEED SPRAY PAINTING IMPLEMENTATION

The problem with Noel Berry's implementation is it's not that simple to implement and  it cannot have multicolored lights.  That is where my implementation comes in handy. Remember how we drew the shadow first then the light? There is a reason why I went through all that nonsense. 

Using the SAME surface, we can reset the alpha channel back to 1 by toggling the colors off then draw a rectangle that covers the entire surface. We then draw the shadow and light like we did last time and guess what? The previous light is not affected! I like to call this the SPEED SPRAY PAINTING IMPLEMENTATION as it in a way works the same as speed spray painting  as seen in this video. The spray painter uses objects to block parts of the canvas before spray painting, removes those objects, puts another set of objects to block the canvas, spray paints over it and repeats this process over and over and over. He only uses one canvas and does not paste anything on top of the canvas. In our light implementation, we could see the shadows as the"objects", the lights as the "spray paint" and our light/shadow surface as that "one canvas". The previous light that is underneath the current shadow projections DO NOT get painted over as they are being "blocked" by the shadow. 

Here is the result of what we did so far:


Multiple lights drawn using the Speed Spray Paint Implementation

Now there are still many things to be done to make this look better and even improve performance while we are at it.

BLURRING THE LIGHTS

As you can notice from the last image, the shadow projections are very jagged. This could be fixed with the power of blur shaders! Gaming Reverends made a very nice blur tutorial on youtube. We can use horizontal and vertical blur pass shader on the light/shadow surface to get the desired blurred effect.


Blurred Shadow Projection

We can take a step further by using the GPU linear texture filter or pixel interpolation (all this explained in Gaming Reverends video). Since this is a pixelated game where pixel interpolation is turned off by default, make sure to turn on pxiel interpolation when using the filter then turn it off when done.  We can take another step further and blur by scaling. This is my favorite of all the blurs as this reduces lag significantly.  Instead of using a surface that is at the same dimension as the room (or view), we can reduce the dimensions by half, effectively making our drawings 4 times more efficient! When we draw the light/shadow surface onto the application surface, we scale it up to fit match the size. Again, make sure pixel interpolation is on when scaling so the blur by scaling will work then don't forget to turn it off. Take note that down scaling may reduce the quality of the shadows and lights so don't scale to small!

FINALLY, THE BLOOM SHADER

This part is optional but it gives the extra oomph to the lighting.  Bloom basically makes anything bright "glow". You can learn how to do this with this Gaming Reverends video. This shader is applied post draw when everything has been drawn to the application surface.  Bloom uses blurring as well and the same blur filters used in the shadows and lights can be used here.

Here is a comparison with bloom and no bloom.


Left: no bloom. Right: with bloom

And you're all set! You may need to tweak the light brightness, color, blur and other settings to come up with the effects you desire. Hope this Tutorial helps you to come up with you own amazing 2D lighting effects! As you can see, many of these implementations came from others before me and all I really did was mash them up together.  The one thing that I'm proud of is the Speed Paint Implementation which I came up with myself and in a way "improves" on Noel Berry's  Cutout implementation .


Hope You have a great Day! I'll be releasing a Post Jam of Rhythm Castle some day with all the dumb problems that everybody has pointed out fixed XD


Links:

Celeste Main Website

Noel Berry's Remaking Celeste Lighting Blog

Mike Dailly's Realtime 2D Lighting Tutorial Part 1

Mike Dailly's Realtime 2D Lighting Tutorial Part 2

Gaming Reverends Shaders Playlist

Get Rhythm Castle

Download NowName your own price

Comments

Log in with itch.io to leave a comment.

(2 edits)

Funciona na versão 2.2.5? / Work in 2.2.5 version?

Most likely. Works with GMS 1.4 so I believe it will work with GMS 2.2.5 

(+1)

This is great thanks! Once I get some time I'll be sure to give this a try!