• SirSilentPhil

Simulation of three-dimensional buildings in a 2D game

Norland is 2D game, developed with Game Maker Studio 2 and during my work I faced a lot of tasks a la "it must be pretty". Somewhere I had to reinvent the wheel, somewhere I was lucky enough to come across descriptions of solutions to similar problems. But, in general, there's quite little information on the web about implementing all sorts of 2D effects, so my article won't be out of place.

Final result in game


Shadows

In order to make "three-dimensional" shadows for a 2D building, you first need to mark up this building with some primitives - cubes, cylinders, prisms, etc. But these primitives will not "cast shadows" in the usual sense of the word - they themselves will be shadows.

Consider, for example, a cube (so it is a parallelepiped, but the word cube is shorter). It consists of eight vertices. The x-component of the normal of each vertex is written as 0.0 if it is the base and 1.0 if it is the top of the cube. In the texture coordinates in the x-component, the height of the figure, which will cast a shadow, is written.

1 - the vertex will move, 0 - the vertex stays in place


All other work is done in the vertex shader. It takes parameters of the sun (angle above horizon and length of shadows). Then it is elementary - the vertices that have normal.x equal to 1 are shifted by the specified distance to the desired angle, and the other vertices stay in place.

All transformations will be considered affine, because the parallel faces of the cube will remain parallel after the transformations.

A simplified view of a vertex shader (GLSL):

void main() {
  float move_amount   = u_vSun.x;
  float shadow_length = u_vSun.y * 1.3;
  
  float height = in_TextureCoord0.x;
  
  vec4 object_space_pos = vec4(0.0);
  if (in_Normal.x > 0.5) {
    object_space_pos = vec4(
      in_Position.x + sin(move_amount) * (shadow_length * height), 
      in_Position.y + cos(move_amount) * (shadow_length * height), 
      in_Position.z, 
      1.0
    );      
  } else {
    object_space_pos = vec4( 
      in_Position.x, 
      in_Position.y, 
      in_Position.z, 
      1.0
    );
  }
  gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] *    object_space_pos;
}

The cube actually looks like picture 2. That is, both vertices "1" and vertices "0" are in the same coordinates in pairs. Figure 3 shows how the vertices of "1" are shifted relative to the light source. Figure 4 shows the final view of the shadow


Other primitives are created in a similar way - a cylinder and prisms for two kinds of roofs (horizontal and vertical). Result:

Primitives from which the shadows for all the buildings are assembled


As a result the cube will consist of 8 vertices and 10 triangles (2 triangles from the base of the cube can be discarded - they have no effect).

For the shadows to be semi-transparent, but at the same time where shadows touch, there should be no layering; you should draw all the shadows with alpha one on the surface. And the surface should be drawn with the desired alpha. Also you can blur the surface with a gaussian blur shader to reduce the imperfection of the polygonal shadows.

Z-sorting and elevation map

Our game is two dimensional (except for the windmill blades - using sprite animation in that case would be extremely difficult due to the large size of each frame, so we had to use a 3d model), but the characters should be able to appear both in front of and behind the buildings. In simple cases this is solved somehow like this:

depth = -y; 
/* 
A standard way in Game Maker for automatic sorting by depth has been considered obsolete for several years due to the introduction of a layer system in the engine. But it still works…
*/

However, we need to correctly display the character inside the building as well, which can contain a lot of objects.

The automatic sorting on the GPU with the help of Z-Buffer helps us with this.

For those who don't know – Z-Buffer is a data structure where the depth of each pixel is described by a number from 0.0 to 1.0. If you use color instead of numbers, you get a black and white picture.

A picture from wikipedia


In GMS 2, the depth buffer can be handled with some limitations - it can not be received as a texture, it can not be read and modified in the shaders (by default it is, but there are ways around it). But you can still just use Z-sorting! And that's exactly what we need.

Z-sorting allows you to discard pixels that have more depth (this is the default behavior, but you can change it) than those that are already drawn on the screen (and in the Z-Buffer, respectively).

I.e. if you draw the building in chunks, giving each chunk its depth, and then draw the man (with depth equal to -y), then with Z-sorting, those character pixels that are behind the conditional wall will simply not be drawn and it will turn out that the wall will overlap that man correctly.

But to do this, you need to mark up the building. On top of the sprite, I place quads, where you can set whether this quad will be a vertical wall (in which case the top tops of the quad will have the same depth as the bottom tops) or a horizontal surface (the top and bottom tops of the quad will have different depth, depending on their position in the y axis).

The screenshot shows the walls as filled orange rectangles, and the surfaces as empty white rectangles. You can also specify the height of the quad above the ground, but this is a nuance.

Then these marked quads are converted to polygons and written to the vertex buffer of the assemblage. In addition to the Z-level, the vertices carry information about the UV coordinates of the building texture (as well as the UV texture map of the glow-in-the-night windows, normals and other auxiliary parameters for all sorts of effects).

The disadvantage of this approach is obvious to those who have already worked with Z-Buffer. After all, it does not support semi-transparency. That is, if you have first drawn translucent glass in the buffer, then if we try to draw something behind this glass, it will not work, because the glass is closer than the new pixels being drawn. To get around this point, we should first sort the assets to be drawn on the CPU from farthest to nearest and only then draw them on the screen (and in the buffer). But we approached this in a simpler way - our buildings don't have transparent elements : )

In addition to the Z-Quads (as I call these polygons to indicate the Z-level), an important part of the markup of the asset, are H-Quads (height quads). These quads are needed to add height differences to buildings (see the steps and the pulpit platform in the temple, for example). Here everything is simple - a rectangle with the height. Then in the rantime find the intersection of the character's lower point with the H-Quad under it and move the character up or down to the specified height.

Z-Quads and H-Quads in action


Normal and window maps.

There is a technology called Normal mapping. In simple terms, it's a texture where the x, y, z components of the normal vector for that pixel are written into the r, g, b-channels of each pixel, i.e. "where this pixel is directed" relative to the world coordinate system.

Due to the peculiarities of recording vectors in the rgb equivalent, the output is usually a purple-red-green map. And if you pass it into a rudimentary shader, by moving a virtual light bulb, you can depict a game of shadows on a 2D sprite. And that's what we need.

It remains to be understood where to get this map of normals. With the 3D model it is simple - modern 3D modeling packages allow you to generate a normal map in four clicks, because this model has all the necessary information.

With 2D sprites it's different - it's just a picture. And only a person looking at it (and probably modern neural networks) can determine that this is the slope of the roof, and this is a windowsill.

But there is a way out. For manual or semi-automatic (with the expected average to poor result) creation of normal maps from 2D images there is some software. For example SpriteLamp, SpriteIlluminator, Laigter, Plugins for Gimp, etc. You can even write your own little software with one tool-brush and a trackball to indicate the desired angle of the drawn normal.

To draw a normal map for a simple building in a program, it doesn't take much effort. Here is the vertical wall, here is the roof and here is the protruding part, which can be more intensely illuminated. Often just a few colors are enough. You may not be super accurate in this case, since we keep the normal map at 0.25x the size of the building sprite, so imperfections are covered by interpolation.


Simplified GLSL vertex shader:

 void main() {
  vec4 normal_raw = texture2D(gm_BaseTexture, v_vNormalUV);
  vec3 normal     = normalize(normal_raw.rgb * 2.0 - 1.0);
  vec3 light_dir  = normalize(u_vLightDir);
  
  // Base color
  vec4 color = v_vColour * texture2D(gm_BaseTexture, v_vTexcoord);
  
  vec3 light_color   = vec3(1.0, 1.0, 0.98);
  vec3 shadow_color  = vec3(0.76, 0.76, 1.0);
  float light_factor = smoothstep(0.2, 0.8, dot(normal, light_dir));
  
  // Light and shadow
  vec3 lighting_color = mix(shadow_color * 0.5, 1.1 * light_color, light_factor);
  
  // Base color with light and shadow
  vec4 result_color = mix(color, vec4(color.rgb * lighting_color, color.a), normal_raw.a * u_fLightStrength);

  if (result_color.a < u_fAlphaDiscardValue) discard;
  gl_FragColor = result_color;
}

Render pipeline


In addition to the normal map, a window map is also used. When dusk arrives, the pixels of the building being drawn, which correspond to the white pixels of the window map, should turn orange.

Of course, this is not all the effects that we use in our rendering. In the video from the beginning of the article there is also light around the windows, LUT-color correction for imitating changes of day and night (for making such a thing I recommend this article), smoke particles from the chimneys, dissolve effect, etc. But that is another story.

Attentive readers may have noticed that the shadow does not quite match (or rather does not match at all) with the building illumination from the normal map. Like, the shadow is from below, so the light falls on the back (not visible to the player) wall of the building. But the facade is still illuminated.
If the shadow of the building were on top (i.e. the sun was illuminating the front), this mishap could have been avoided, but I intentionally put the shadow at the bottom, because it's just more cute. Especially at noon.

That's it, folks! Add Norland to your wishlist on Steam.

423 views