Design Patterns and Video Games

OpenGL 2D Facade (23): Water animation

In this post, I show how to animate water tiles using shaders. The approach I propose also works for all animations that repeat continuously in the same layer!

This post is part of the OpenGL 2D Facade series

Objective

All water tiles we create on the map are animated:

Animation with shaders

Sub tilesets

We use the type 3 water tiles created by Pipoya: https://pipoya.itch.io/pipoya-rpg-tileset-32x32. It is an 8-step animation, and for each step, we have tiles for every corner:

Water animation tiles

For instance, the top-left tile (water surrounded by land) has the following animation tiles: (0,0) (8,0) (16,0) (24,0) (32,0) (40,0) (48,0) (56,0). More generally, we can compute the ith animtation of a tile at coordinate (x,y) using a shift:

tile(i) = (x + i*8, y)

Vertex shader

We can shift all tiles in the vertex shader to get the animation:

#version 330
layout (location=0) in vec4 vertex;
layout (location=1) in vec2 inputUV;
                   out vec2 outputUV;
uniform vec4 translation;
uniform vec2 uvShift;
void main() {
    gl_Position = vertex + translation;
    outputUV = inputUV + uvShift;
}

Line 6 declares a 2D uniform variable with the tile shift (converted to UV coordinates).

Line 9 computes the final UV coordinates: it is the tile coordinates as usual plus the shift.

This approach works fine as long as all tiles have the same number of animation steps. We also have to organize them similarly since we can set only one shift (not one for each animation case). For the water case, we create one row for each water type:

Water animation tiles

As a result, every time we shift by i*8 tiles, we display the ith animation step for all water types.

Compute UV coordinates

We need to convert the tile coordinates into OpenGL UV coordinates. These coordinates are between 0 and 1, and the Y-axis is bottom-up:

uvShiftX = -x * tileSize * texturePixelWidth
uvShiftY = y * tileSize * texturePixelHeight

We compute the size of a pixel in the texture as before:

texturePixelWidth = 1.0 / float(textureWidth)
texturePixelHeight = 1.0 / float(textureHeight)

Finally, we set the uniform using the OpenGL functions:

uvShiftShaderVar = glGetUniformLocation(shaderProgramId, "uvShift")
glUniform2f(uvShiftShaderVar, uvShiftX, uvShiftY)

Main program improvements

I improved the main program; I sum it up here.

State. There is a new water layer in regions. We represent it as another dimension in the Numpy array.

We update version 1 of the region serializer. It can create a new region (with two dimensions in the Numpy array) from a version 1 save. We also create version 2 of the region serializer. Then, the code can load version 1 and 2 saves and create version 2 saves.

Logic. We add new commands to add or remove water tiles in regions.

Layers. We add a new attribute that stores the tile shift. It means that each layer (ground, water, text, ...) can have its animation cycle. We use these values in the render() method of the Facade:

def render(self):
    glClear(GL_COLOR_BUFFER_BIT)

    glUseProgram(self.__shaderProgramId)

    translationShaderVar = glGetUniformLocation(self.__shaderProgramId, "translation")
    uvShiftShaderVar = glGetUniformLocation(self.__shaderProgramId, "uvShift")
    for layerGroup in self.__layerGroups:
        if layerGroup is None:
            continue
        glUniform4f(translationShaderVar,
                    layerGroup.translationX, layerGroup.translationY,
                    0.0, 0.0)
        for layer in layerGroup:
            glUniform2f(uvShiftShaderVar, layer.uvShiftX, layer.uvShiftY)
            layer.draw()

    glUseProgram(0)

    pygame.display.flip()
    self.__clock.tick(60)

Edit game mode. We set the tile shift for the water layer in the render() method. It is not the best design since this animation is not specific to edition mode, I'll improve that later:

def render(self):
    if self.__waterLayer is not None:
        self.__waterLayer.setTilesetShift(
            self.__tileSize * 8 * (self.__waterAnimation // 6), 0
        )
        self.__waterAnimation += 1
        if self.__waterAnimation >= 8 * 6:
            self.__waterAnimation = 0

    self.__gui.render()

Final program

Download code & assets

In the next post, I'll start to show the use of the OpenGL Z-buffer in a 2D game.