Tilesets and Makefiles Part 3: Shading

This post is part of the Tilesets and Makefiles series.

Dec 04, 2025

In our last post, we added 2 elevation levels to our map. To enhance the elevation effect, we want to shade certain sides of our terrain to invoke the idea of lighting.

Cornering

Before we do that, let’s clean up our meta template to shave off the sharp corners and get us some finer control.

In the end I a added 4 new zones:

  • east-corner
  • west-corner
  • east-shared-transition
  • west-shared-transition

I recolored the tilemap so that the colors are further appart

Pastedimage20251204133300.png

Added the new colorings to our yaml Pastedimage20251205170628.png

colors:
  # base colors
  # ../
  mustard: &mustard "#888800"
  olive: &olive "#666600"
  khaki: &khaki "#444400"
  ochre: &ochre "#222200"
  lime: &lime "#008800"
  forest: &forest "#006600"
  emerald: &emerald "#004400"
  pine: &pine "#002200"
  navy: &navy "#000088"
  ocean: &ocean "#000066"
  midnight: &midnight "#000044"
  sapphire: &sapphire "#000022"
  violet: &violet "#440044"
  imperial: &imperial "#880088"
  
zones:
  floor: *olive
  border: *ochre
  shared-east-transition: *mustard
  shared-west-transition: *khaki
  west_corner: *lime
  sunken_west_wall: *forest
  shared_west_wall: *emerald
  raised_west_wall: *pine
  sunken_east_wall: *ocean
  east_corner: *navy
  shared_east_wall: *midnight
  raised_east_wall: *sapphire
  sunken_north_wall: *violet
  raised_south_wall: *imperial

And we updated our make files to recolor the new zones accordingly

%-flat-template: $(template_file)
    convert $(template_file) \
        -fill "$(call conf,terrains.$*.border.color)"       -opaque "$(call conf,zones.border)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.floor)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_north_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_south_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.west_corner)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.east_corner)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared-east-transition)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared-west-transition)" \
        $*-template.png
  
# For RAISED terrain we merge the sunken zones to the floor color
%-raised-template: $(template_file)
    convert $(template_file) \
        -fill "$(call conf,terrains.$*.border.color)"       -opaque "$(call conf,zones.border)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.floor)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.sunken_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_west_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.sunken_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_north_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.raised_south_wall)" \
        -fill "$(call conf,terrains.$*.border.color)"   -opaque "$(call conf,zones.west_corner)" \
        -fill "$(call conf,terrains.$*.border.color)"   -opaque "$(call conf,zones.east_corner)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.shared-east-transition)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.shared-west-transition)" \
        $*-template.png
  
# For sunken terrain we merge the raised zones to the floor color
%-sunken-template: $(template_file)
    convert $(template_file) \
        -fill "$(call conf,terrains.$*.border.color)"       -opaque "$(call conf,zones.border)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.floor)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_west_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.raised_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_east_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.raised_east_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.sunken_north_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_south_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.west_corner)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.east_corner)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.shared-east-transition)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.shared-west-transition)" \
        $*-template.png

These extra zones give us sharp coners for the elevations, bur soft rounded corners in the flat templates and they avoid annoying artefacts: Pastedimage20251204133719.png

Shading

Now that we have a new and improved meta_template, we want to extract certain sections and darken them.

Let’s start with adding some information to our config.


# summer.yaml
# ...

shading:
  west: 0.5
  east: 0.0
  south: 0.1
  north: 0.1

This config shades the west walls, and a bit of the north and south walls giving what i hope to be a bit of a sunrise effect.

Lets start with extracting the south wall. We will call it the ‘raised’ shading section, because it will only apply to raised terrains

raised-shared-shade-section: $(template_file)
    convert $(template_file) \
    -alpha set +transparent  "$(call conf,zones.raised_south_wall)" -fill black -colorize 100 \
    -channel A -evaluate multiply "$(call conf,shading.south)" +channel \
    raised-shared-shade-section.png

We will extract that section, color it black and set the opacity to our config value. This gives us a nice overlay: Pastedimage20251204143223.png

Let’s do the same for the north (sunken) section:

sunken-shared-shade-section: $(template_file)
    convert $(template_file) \
    -alpha set +transparent  "$(call conf,zones.sunken_north_wall)" -fill black -colorize 100 \
    -channel A -evaluate multiply "$(call conf,shading.north)" +channel \
    sunken-shared-shade-section.png

Now we need to do the east and west sections. These are a bit tricky, because we will get different results depending on whether the terrain is sunken or raised. That’s why we will pass the terrain type as a wildcard to the target:

%-west-shade-section: $(template_file)
   convert $(template_file) \
       -alpha set \
       -fill "$(call conf,zones.$*_west_wall)" \
       -opaque "$(call conf,zones.shared_west_wall)" \
       +transparent  "$(call conf,zones.$*_west_wall)" \
       -fill black -colorize 100 \
       -channel A -evaluate multiply $(call conf,shading.west) +channel \
       $*-west-shade-section.png
       
# and another one for the east side...

Here we have to extract two sections. So first we recolor the shared saction to the non-shared, then we can extract only that color code. So when we go:

make raised-west-shade-section

This nets us a neat map:

Pastedimage20251204143750.png

All we need to do now is combine 3 of the 4 together based on the terrain.

sunken-shadow-mask: raised-shared-shade-section sunken-shared-shade-section sunken-east-shade-section sunken-west-shade-section
    convert sunken-shared-shade-section.png \
        sunken-west-shade-section.png -compose over -composite \
        sunken-east-shade-section.png -compose over -composite \
        sunken-shadow-mask.png
        
# and another variant for the raised-shadow-mask with a different section

For flat terrains, instead of dealing with conditional, we will just create an empty transparent png that will be our “flat-shadow-mask”

flat-shadow-mask:
    convert -size 512x384 xc:none flat-shadow-mask.png

Now we can make them conditional, based on the target terrain

%-shadow-mask: sunken-shadow-mask raised-shadow-mask
    # a little guard to drop any invalid terrains
    [ $(call conf,terrains.$*.type) != "null" ] || { exit 1; }; \
    # rename the one we want
    cp $$(echo $(call conf,terrains.$*.type)-shadow-mask.png) $*-shadow-mask.png

And we update our existing target, to take the shadow map as a dependency AND to apply it as a last layer on top of the other sections

%-tileset.png: %-border-cutout %-floor-cutout %-transition-cutout %-shadow-mask
  convert $*-border-cutout.png \
      $*-floor-cutout.png -compose over -composite\
      $*-transition-cutout.png -compose over -composite \
      $*-shadow-mask.png -compose multiply -composite \
      $*-tileset.png

and now when we generate our tilesets again

$ make tilesets
Pastedimage20251204173053.png

We get nicely shaded walls.

Notice how the east and west walls are misaligned. The sunken sea is lit from the west, while the raised mountains are lit from the east. The is because the shade directions between the sunken and raised terrains need to be swapped.

Let’s determine the values dynamically based on terrain type. For this we will use the [$(if …) ](Conditional Functions (GNU make) )function and the $(filter …) function. Make a has a ton of useful functions , that I should be using more to reduce duplication

#Functions determine the shade value for the walls based on terrain type

west-shade-value= $(if $(filter sunken,$1),$(call conf,shading.west),$(call conf,shading.east))

east-shade-value= $(if $(filter sunken,$1),$(call conf,shading.east),$(call conf,shading.west))

the config declares, where the light is coming from, so if the light is coming from the east, then the sunken west walls need to be lit and the raised east walls need to be lit.

We can use the functions like this:

%-east-shade-section: $(template_file)
  convert $(template_file) \
      -alpha set \
      -fill "$(call conf,zones.$*_east_wall)" \
      -opaque "$(call conf,zones.shared_east_wall)" \
      +transparent  "$(call conf,zones.$*_east_wall)" \
      -fill black -colorize 100 \
      -channel A -evaluate multiply $(call east-shade-value,$*) +channel \
      $*-east-shade-section.png
      
# Don't forget to update the corner shadow as well
%-corner-shade-section:
  convert $(template_file) \
      -alpha set \
      +transparent  "$(call conf,zones.$*_corner)" \
      -fill black -colorize 100 \
      -channel A -evaluate multiply $(call $*-shade-value,sunken) +channel \
      $*-corner-shade-section.png

And if we run our tileset again:

Pastedimage20251205165635.png Nice!

If you think this last part was confusing, you’re right! It’s because west/east in the config refers to the direction the light is coming from while west/east in the makefile refers to the position of the walls. I’m bad at languages and don’t have the brain power to fix it so adding a bunch of functions to duct-tape the issue should be enough. Just don’t look at it too much

Finally let’s add a global shading that we can customize for color and opacity. let’s add it to our config:

shading:
  global:
      color: *black # Anchors! Yay!
      opacity: .2
  west: .0
  east: .8
  south: 0.4
  north: 0.4

Now make a single overlay images based on the parameters

global-shadow-mask:
    convert -size 512x384 "xc: $(call conf,shading.color)" \
    -alpha on \
    -channel A -evaluate multiply $(call conf,shading.global) +channel \
    global-shadow-mask.png

And add it to our terrain stackstack:

%-tileset.png: %-border-cutout %-floor-cutout %-transition-cutout %-shadow-mask global-shadow-mask
	convert $*-border-cutout.png \
		$*-floor-cutout.png -compose over -composite\
		$*-transition-cutout.png -compose over -composite \
		$*-shadow-mask.png -compose multiply -composite \
        #Here it is
		global-shadow-mask.png -compose multiply -composite \
		$*-tileset.png
Pastedimage20251205150931.png

I kept the wall shadows black with opacity, but you can adjust it to use the global color instead if that looks better.

Conclusion

The combinatorics are getting a bit out of hand we are doing a ton of duplication even though most of the stuff is configured through yaml.

I’m starting to run into Make’s quirks where instructions are processed in different passes which often prohibits me from using certain features dynamically.

I have to think about which phase a certain condition is being executed. This is the reason I wanted to use something declarative and functional like make a.o.t. imperative build tools.

Next steps

I still need to scale the textures down to 32x32 for the target Tiled tileset.

I still need to generate the tileset dynamically

I might start exploring Make’s templating. But i think I will instead rewrite the file in Just . That seems like a more appropriate tool for the job.

I want to add some drop shadows using the new color keys

I still need to make sure dimensions are calculated dynamically by reading the inputs

The reason I wanted to use the bigger tile set template is to be able to add variation, but i am not looking forward to having to edit tile on the template individually if i want to create more or less pronounced zones for certain tilesets. I think ill have to generate the template itself from a reduced prime-template.

gamedev
Creative

Yasen Dinkov

Tilesets and Makefiles Part 2: Elevation