Skip to content

Feature Walkthrough

MarkelZ edited this page Dec 14, 2023 · 4 revisions

This section covers the main features of pygame-render. To learn how to install the package, take a look at Installation.

If you prefer to look at working examples, check out the examples/ directory.

Drawing a sprite

We will start by importing pygame, as well as the RenderEngine class from pygame_render:

import pygame
from pygame_render import RenderEngine

Next, initialize pygame:

pygame.init()

Now we can create the render engine by specifying the screen resolution, we will do 900x600:

engine = RenderEngine(900, 600)

Let's store an image called sprite.png in the directory where this script is located. We can load this image with the load_texture function:

sprite = engine.load_texture('sprite.png')

We will now make a game-loop:

clock = pygame.time.Clock()
running = True
while running:
    # Tick the clock at 60 frames per second
    clock.tick(60)

    # Game logic here!
    # ...

    # Process events
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

We will be adding some code where we have # Game logic here!.

Let's first clear the screen with magenta color (255, 0, 255):

engine.clear(255, 0, 255)

Now, draw the sprite at position (100, 100) in screen-pixel coordinates:

engine.render(sprite, engine.screen, position=(100, 100))

and lastly, we need to call flip to show it on the screen:

pygame.display.flip()

Done! If you run the script, you should see the sprite on the screen at position (100, 100). The resulting code should be very similar to examples/example1.py.

Applying transformations to the sprite

The function RenderEngine.render takes these parameters:

  • tex (Texture): The texture to render.
  • layer (Layer): The layer to render onto.
  • position (tuple[float, float]): The position (x, y) where the texture will be rendered. Default is (0, 0).
  • scale (tuple[float, float] | float): The scaling factor for the texture. Can be a tuple (x, y) or a scalar. Default is (1.0, 1.0).
  • angle (float): The rotation angle in degrees. Default is 0.0.
  • flip (tuple[bool, bool] | bool): Whether to flip the texture. Can be a tuple (flip x axis, flip y axis) or a boolean (flip x axis). Default is (False, False).
  • section (pygame.Rect | None): The section of the texture to render. If None, the entire texture is rendered. Default is None.
  • shader (Shader): The shader program to use for rendering. If None, a default shader is used. Default is None.

We can use position, scale, and angle to transform the sprite. For example, we can draw the sprite at position (200, 200), scaled up by a factor of 2, and with an angle of 45 degrees:

engine.render(sprite, engine.screen, position=(200, 200), scale=2, angle=45)

You can run the script again to see the difference.

The flip parameter can be used to specify whether we want to flip the texture. We can set flip=(True, False) to flip it horizontally, flip=(False, True) to flip it vertically, and flip=(True, True) to flip both axes.

The section parameter can be used to specify a subsection of the sprite that you want to render. For example, if you set section = pygame.Rect(0, 0, 10, 10), then you will only render a 10x10 subsection of the sprite. This is particularly useful for rendering individual frames of an animation using a sprite sheet, like in examples/example3.py. You can also pass a rectangle that is larger than the original texture, in which case the engine will repeat the texture, which is useful for filling a background with a tiling texture for example.

Layers

In the previous examples we passed engine.screen as the layer argument of render, which tells the engine to draw the texture directly onto the screen. Instead of that, it is also possible to render textures to other targets, called layers.

It's very easy to create a layer. For example, let's create a layer of size 320x180:

layer = engine.make_layer(size=(320, 180))

We can now render textures to this layer with render by passing the layer in the layer argument.

If we do that, the sprites that we rendered will not appear on the screen since we instead rendered them on layer. We can obtain the texture of this layer with layer.texture. Since layer.texture is a texture, we can render it to other layers or directly to the screen. Let's do the latter:

engine.render(layer.texture, engine.screen)

Layers are incredibly useful for a variety of applications:

  • Organizing the rendering of different game objects (foreground, background, GUI, special effects, etc.).
  • Pixel art games can be rendered to a layer with the native resolution, and then upscaled to fit the screen. Look at the note below for details.
  • Apply transformations to the layers, such as a shake-effect.
  • Apply shaders to particular layers. For example blur the game-scene when paused but do not blur the pause menu buttons.

Note for pixel art: If your game uses pixel art and you are upscaling a layer to achieve the effect, it will look blurry unless you add this line:

layer.texture.filter = (NEAREST, NEAREST)

Take a look at examples/example5.py to see how to use layers for pixel art.

Shaders

This engine allows using custom shaders. Briefly explained, a shader program tells the GPU how to color the pixels of the screen by using two programs: a vertex shader and a fragment shader. The vertex shader tells the GPU how to process each vertex of the object that we are drawing. In our case, since we are drawing rectangular sprites, the vertices correspond to the corners of the rectangle. The fragment shader on the other hand processes every pixel individually.

Let's create a file named vertex.glsl with the following code:

#version 330 core

layout(location=0)in vec3 vertexPos;
layout(location=1)in vec2 vertexTexCoord;

out vec3 fragmentColor;
out vec2 fragmentTexCoord;

void main()
{
    gl_Position=vec4(vertexPos,1.);
    fragmentTexCoord=vertexTexCoord;
}

Also, we will create fragment.glsl with this content:

#version 330 core

in vec2 fragmentTexCoord;
uniform sampler2D imageTexture;

out vec4 color;

void main()
{
    color=texture(imageTexture,fragmentTexCoord);
}

We just created a "do nothing" shader. The vertex program receives the coordinate of the vertex and returns that same coordinate. The fragment shader takes the coordinate of the pixel (obtained by interpolating the vertex coordinates), and it returns the color corresponding to that pixel using texture(imageTexture,fragmentTexCoord). Therefore, this shader will draw the sprite as is, with no special effects.

Now we can tell the engine to create a shader using the vertex program and the fragment program that we just created:

shader = engine.load_shader_from_path('vertex.glsl', 'fragment.glsl')

and we can draw a texture using the shader:

engine.render(texture, engine.screen, shader=shader)

To see that we are using our shader, we can modify fragment.glsl as follows:

void main()
{
    color=texture(imageTexture,fragmentTexCoord);
    color.R *= 1.5f;
}

This will increase the redness of the texture by 50%. If we now run the script again we will see that the sprite looks more red.

Uniforms (sending data to the shader)

In the previous section we saw how to make a simple shader. To achieve more sphisticated effects we often need to send variables to the shader, and this is done with "uniforms".

We will be creating a "glow" shader, which adds a blinking yellowish glow to the sprite. This effect can be used to highlight a game object, for example to indicate that it has been selected. The brightness at which the sprite will glow depends on time, since it needs to be blinking, but we cannot access the game-time directly through the GPU. Therefore, we need to use uniforms!

In our python script, we will create a variable to keep track of time:

total_time = 0

In each frame, we will increase it by the number of milliseconds elapsed:

total_time += clock.get_time()

Now, we can implement the glow effect shader in fragment.glsl as follows:

#version 330 core

in vec2 fragmentTexCoord;
uniform sampler2D imageTexture;

uniform float time;

out vec4 color;

const float SCALE_GLOW=.2f;
const float SCALE_TIME=.005f;

void main()
{
    color=texture(imageTexture,fragmentTexCoord);
    float intensity=(sin(time*SCALE_TIME)+1)*SCALE_GLOW;
    color+=intensity*vec4(1,1,0,0);
}

Notice the uniform for time that we defined with uniform float time;. The keyword uniform indicates that time is a uniform, so we will be sending the value from our python script. Instead of float, you can have other types such as int, vec2, vec4, etc. It is also possible to send more complex data, such as arrays and textures, but we cover those in the next section as it is more complicated.

In main(), we simply sample the color corresponding to the pixel, calculate the intensity of the glow effect at that particular moment in time, and add yellow color with that intensity to our original color.

We are not done yet though! We still haven't sent the value of time from our python script to the shader. To do so, we just need this line in the python code:

shader_glow['time'] = total_time

This line of code should be executed in every frame, because the value of total_time changes every frame.

We are finally ready to run the code. If everything went smoothly, you should see that the sprite now blinks with a yellowish glow.

If you are having issues implementing this effect, take a look at examples/example2.py.

Uniform blocks (send an array of uniforms)

Look at examples/example4.py.

sampler2D uniforms (send textures as uniforms)

Look at examples/example6.py.

Accessing OpenGL objects

For some advanced applications, it may be necessary to access the OpenGL objects that the engine abstracts away.

  • RenderEngine.ctx is a moderngl Context.
  • Layer.texture is a moderngl Texture.
  • Layer.framebuffer is a moderngl Framebuffer.
  • Shader.program is a moderngl Program.
  • Shader._ubo_dict is a dictionary of moderngl Buffer.
  • Shader._sampler2D_locations is a dictionary of pairs of moderngl Texture objects and their bound locations (int).