Feature Walkthrough
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.
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
.
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.
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.
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.
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
.
Look at examples/example4.py
.
Look at examples/example6.py
.
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).