"Unveiling the Graphics Production Techniques in Graveyard Keeper" or "Behind the Scenes: Creating the Visuals in Graveyard Keeper" or simply "Graphics Crafting in Graveyard Keeper: An Insight"
Hey there! I'm a lead coder for the hit game "Graveyard Keeper." I'm gonna spill some secrets about the tricks we used to create the game's stunning visuals, as seen in the GIF below.
We're all about graphics around here, so we took our time crafting various effects to make our 2D art as eye-catching as possible. You might find some tips here that can help you level up your work.
First off, let me go over some essential components of the game's visuals:
- Dynamic Ambient Light (based on the time of day)
- LUT Color Correction (altering colors based on the time or zone)
- Dynamic Light Sources (torches, ovens, lamps)
- Normal Maps (giving objects depth and volume)
- Light Distribution Math (ensuring proper illumination for objects)
- Shadows (made with sprites that react to light source positions)
- Altitude Simulation (for the fog to display correctly)
- Other Stuff (rain, wind, animations, and more)
Let's bust these topics wide open!
Dynamic Ambient Light
Nothing fancy here. It's darker at night and brighter in the daytime, with a color gradient. By nightfall, the lights don't just become darker but get a blue tint.
Here's what it looks like in action:
LUT Color Correction
LUT (Look-up Table) is a table that adjusts colors. Roughly speaking, it's a 3D RGB array. Each element corresponds to a color, with its coordinates as RGB values. (a) If the element contains a color with specific RGB values, it means that all the white color from the picture will be replaced with that specific color. But if there is white color at the same coordinates (R=1, G=1, B=1), the change doesn't happen. So LUT comes by default with coordinates associated with certain colors. For instance, the point with coordinates (0.4, 0.5, 0.8) is linked to a color with RGB values (R=0.4, G=0.5, B=0.8).
It's worth mentioning that LUT is usually represented as a 2D texture for convenience. That's how the default LUT (which doesn't change colors) looks:
Making it? A piece of cake. Super easy to use, it works fast.
Creating it is straightforward, too. Just give an artist a pic of your game and say, "Make it look like nightfall." Apply all the color layers plus the default LUT, and voila! You've created your Night LUT.
Our artist took things a step further, creating ten different LUTs for various times of day (night, twilight, evening, and so on). That's how the final set looks:
With this, a single location can look different during different times of the day!
The pic also shows how the intensity of the light sprites changes depending on the time:
Dynamic Light Sources and Normal Maps
Our standard light sources are regular Unity ones, while each sprite has its own normal map helping to convey depth.
Creating normals is pretty simple. An artist just paints light on four sides using a brush, which is then merged with code into a normal map.
Looking for a way to create normal maps or software? Check out Sprite Lamp.
3D Light Simulation
This is where things get a bit tricky. You can’t light just the sprites. The position of a sprite is crucial regarding whether it's "behind" or "in front" of a light source.
Take a gander at this pic:
The trees are the same distance from the light source, but the tree on the left is illuminated while the one on the right is not (because the camera faces its dark side).
I handled this problem easily. A light shader calculates the distance between a light source and a sprite on the vertical axis. If the distance is positive (the light source is in front of the sprite), we illuminate it as normal. However, if the distance is negative (the sprite is blocking the light source), the intensity of the light fades depending on the distance at a quick rate. There's a speed adjustment, not just "no light." So if the light source behind the sprite is moving, the sprite gradually darkens, not instantly. Yet it's still incredibly fast.
Shadows
Shadows are created with sprites rotating around a point. I attempted skewing, but it proved unnecessary.
Every object may have a maximum of four shadows: the sun's shade and three from dynamic light sources. The chart below illustrates:
I figured out how to find the closest three light sources and calculate the distance and angle using a script running in the Update() loop.
Yeah, it's not the fastest method considering the math involved. If I were recoding it today, I'd use that modern Unity Jobs System. But when I did it, there was no such thing, so I optimized the regular scripts we already had.
The most significant difference here is that I did the sprite rotation inside a vertex shader, rather than modifying the transform. So, the sprite rotation isn't involved directly. Instead, a sprite parameter is used, while the shader is responsible for sprite rotations. This technique is quicker as you don't need to use Unity geometry.
There's a catch here. The shadows must be adjusted or even drawn separately for each object. However, we used roughly ten unique, more or less universal, sprite shapes (thin, thick, oval, etc.).
Next, the problem is that it's challenging to make a shadow for an object stretched along one axis. For example, check out the fence shadow:
Not too great. This is what it looks like when the fence sprite is made translucent:
It's worth noting here that the sprite is highly distorted vertically (the original shadow sprite's shape is round). That's why the rotation seems to be a combination of rotation and distortion.
The Fog and Altitude Simulation
There's also fog in the game. It looks like this (with both regular fog and an extreme 100% fog to showcase the effect):
As you can see, the tops of houses and trees are visible through the fog. This effect is easy to create. The fog consists of numerous horizontal clouds spread across the picture. As a result, fewer fog sprites cover the upper part of all the sprites:
The Wind
The wind in a 2D pixel art game is a challenge. There's not much room for creative options. You can either animate manually (no option, considering the amount of art we have), make a deforming shader (ugly distortions), or go without any animation (a static and dull-looking world).
We chose the deforming shader. It looks like this:
It's pretty clear what's going on if we apply the shader to a checkerboard texture:
It's also important to mention that we don't animate the entire crown of a tree, but a select number of leaves:
There's also animation for wheat field shaking, which is simple: the shader changes the shape of the x-coordinates, taking y-coordinate into account. The idea is that the top should move while the root stays still, and the shaking phase should vary for different sprites.
This shader is the same one used to create a swinging effect when a player moves through wheat or grass.
I reckon that's all for now. I haven't discussed scene construction and its geometry, as there's much more to chat about in another entry. I've covered all the main solutions we applied in developing our game.
Hope you found this informative! Happy coding!
- To enhance the 2D graphics in "Graveyard Keeper," we utilized a variety of techniques, including smartphones and other gadgets to help us create more realistic visuals.
- Our artists used normal maps derived from smartphone apps like Sprite Lamp to give objects depth and volume, while also using Unity's standard light sources and dynamic light from torches, ovens, and lamps.