MarkelZ / pygame-render

Python package for rendering textures with shaders using pygame.
MIT License
10 stars 0 forks source link

questions/suggestion #2

Open OlaKenji opened 9 months ago

OlaKenji commented 9 months ago

Hi again

I have used your engine more and I am still amazed :D I have the following questions/suggestions which hopefully are also useful for you:

1, Is it possible to "permanently" update the texture? For example, if I want to change the colour of a texture, it is possible to write a shader accordingly and update it every frame. However, if this texture is supposed to be a specific colour, it feels unnecessary to do this operation every frame. One idea would be to "permanently" change the colour of the texture in the initialisation and render it so that there is no need to change the colour every frame. Perhaps this kind of structure saves performance (especially if it involves something more complicated than just changing color)?

2, Is it possible to apply 2 shaders in series onto the same texture? For example, let's say I want to blur and change the colour of a texture. I can of course write a shader that does both. However, perhaps the code could be more modular if it is possible to have one colour changing shader, and one blurring shader that are applied in series. One could perhaps pass a list of shaders [shader_colour, shader_blur] in engine.render() (if this makes sense)?

3, What is the "proper/efficient" way of making a texture? For example, if one would make a circle "normally" in pygame, one would make an empty pygame.Surface() and pygame.draw.circle() on it. From here, we could do engine.surface_to_texture in this engine but as you kindly explained, this operation cost performance. Is "shader equivalent" to make a circle properly/efficiently to first make a layer by engine.make_layer() and use shaders to draw a circle onto this layer?

I hope these inquires make sense.

MarkelZ commented 9 months ago

Hey @OlaKenji ! Yes, your suggestions are very useful for me :) I'll add them to FAQ. Thank you!

  1. Is it possible to "permanently" update the texture?

Yes, you can use layers for this. You can render the texture to a layer that has the same dimensions and use a shader with the effect you want (e.g. change the color). The texture of the layer is layer.texture, so you can just render this layer once during initialization and use its texture for future renders. Something like this:

def load_asstets(self):
    # Create a colored texture
    texture = engine.load_texture('texture.png')
    layer = engine.make_layer(size=texture.size)
    self.shader_color = engine.load_shader_from_path('vertex.glsl', 'fragment_color.glsl')
    self.engine.render(texture, layer, shader=shader_color)
    self.colored_texture = layer.texture

    # Load other assets
    # ...

Now when drawing your game you can just use this texture:

def draw(self):
    self.engine.render(self.colored_texture, ...)
    # Render other stuff
    # ...
  1. Is it possible to apply 2 shaders in series onto the same texture?

Yes, again the solution is using layers! You can create a new layer with the texture's dimensions and render the texture to the layer with the first shader. Then, you can use the layer's texture and render it to the screen with the second shader. Done!

There's one thing you should be careful about though. Let's say that you need to render three shaders in series. You could do the same, create a new intermediate layer, and render the texture to it with the first shader. Now, to apply the second shader we can't just render it to the screen because we want to apply a third shader too, so one could try to render the texture of the intermediate layer onto itself. This is bad! You cannot take the texture of a layer and render it to this same layer. Instead, you need to use a double buffer method. So, you create two layers, A and B, and now you can chain as many shaders as you want by rendering A to B and then B to A. Hope this makes sense. My lighting engine needed precisely this, double_buff.py.

  1. What is the "proper/efficient" way of making a texture?

This depends on what exactly you want to achieve.

Textures are almost always created during initialization or when loading a level (as opposed to in every frame). All the methods that you propose should differ only in milliseconds, so it shouldn't really matter during initialization, but it would have an impact if you do it every frame.

In general, creating a new texture in every frame should be avoided. If you really need to do this, I would need to know why exactly? You can probably optimize it, but it depends on your specific needs.

OlaKenji commented 9 months ago

Alright, thank you for the explanations! I think I got it :D

For 3, I was thinking if you for instance would like to spawn X number of objects during the game. To avoid making a texture during game, the solution could be to store an empty layer/texture in memory in initialization and read/use this when making a new object, if there is a way to copy (".copy()") a texture? If you already know how many X number of objects you need, one could store all X objects in memory but I guess you don't always know X. Or perhaps store texture/layer as a class variable in the initialization?

MarkelZ commented 9 months ago

@OlaKenji Oh now I understand. I think that each texture should be loaded only once, when starting the game or loading the level. Same for other assets like sound effects. Loading/copying the same texture over and over for every instance of the same class seems very inefficient. I would do exactly what you propose, store the texture as a class variable and load it just once during initialization.

OlaKenji commented 9 months ago

@MarkelZ Storing an already initialised texture in a class variable kind of worked. The only issue is if you, for example, make one object and change it to one colour (class Circle) and another object (class Circle) and change it to another colour. To clarify, they are the same class but 2 different objects. In this case, both objects are coloured with the same color, determined by the latest operation. I think this is because both objects are based on a texture that was stored and are pointing to same location in memory. So changing one texture automatically changes the other. (perhaps it is possible to get a new texture in the shader?)

In "normal" pygame, if you have stored an empty_surface = pygame.Surface() in memory, you could make a unique copy of it via new_surface = empty_surface.copy(). empty_surface and new_surface doesn't share memory so the above problem is avoided.

I tried to see if modernGL has some copy function and I found Context.copy_buffer() and Context.copy_framebuffer(). Could these be used to do similar copy trick? Or do you have other suggestions?

MarkelZ commented 9 months ago

@OlaKenji You could use engine.ctx.copy_framebuffer(), but I think this would be easier:

  1. Load the base texture as a class variable.
  2. Whenever you create an object, initialize a Layer object as one of its attributes, and render the texture to it with a shader that changes the color.
  3. The texture of the new object would be layer.texture.
  4. Whenever you draw the new object, just draw layer.texture.

This way each object would have its own colored texture.

I think what I propose is the easiest thing to implement since your game already kind of does this, so it would be easy to adapt the existing code. However, creating a new texture for every circle particle that you spawn is not very clean, regardless of whether you use Pygame surfaces or OpenGL textures.

Instead, I recommend doing this:

  1. Load the base texture as a class variable.
  2. Create a shader that draws a texture with a given color (you send the color as a uniform). This shader would be a class variable too.
  3. When you create a new object, add an attribute self.color with the color of the object.
  4. Right before drawing the object, send self.color as a uniform to the shader.
  5. Draw the object with the shader.

This would be more work to implement, but it would be much cleaner and more efficient as you are no longer creating and deleting textures on the fly.

The fragment shader, fragment_color.glsl, would be something like this:

#version 330 core

in vec2 fragmentTexCoord;
uniform sampler2D imageTexture;

uniform vec4 particle_color; // This is the color uniform

out vec4 color;

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

The vertex shader would be the same as in examples/vertex.glsl.

You can create the shader with

# It's a class variable of the Circle class
shader_color = engine.load_shader_from_path('vertex.glsl', 'fragment_color.glsl')

Then, before drawing the circle particle, send its color to the shader

# self.color must be a tuple of 4 values between 0 and 1 (instead of 0 to 255)
Circle.shader_color['particle_color'] = self.color

and render it

engine.render(Circle.texture, engine.screen, position=self.position, 
              scale=self.scale, shader=Circle.shader_color)

This won't be slower than the other method, because shader_color is very fast. It would only matter if you were doing some expensive computation in the shader. Also, each object now only stores a tuple representing the color of the particle.

Again, this is just a suggestion. If the second method would take too much time to implement, the first one is also good.

OlaKenji commented 9 months ago

@MarkelZ Thank you for the suggestions. I tried them and it solved the texture memory location issue. So almost there. But I realised that there is something with the Shaders as well.

If I initialise self.shader = engine.load_shader_from_path() in the Circle() objects (so that each object calls engine.load_shader_from_path() in the init), then it works as expected. However, if I initialise self.shader = engine.load_shader_from_path() when the game starts in e.g. main game.py file or store them as a class variable in Circle() I get the same problem. In this case, each Circle object calls on this shader from memory and the result is that all Circle objects get the same color, even though you send in different color values in the uniform (i.e. the same problem as I described above).

I read somewhere that there is something called Binding Textures/Texture Units. Could it be that each of these Circle textures need to be somehow be bound to the shader, or that the shader calls a specific texture?

MarkelZ commented 9 months ago

@OlaKenji What about something like this:

from pygame_render import RenderEngine, Texture, Shader, LINEAR
from random import random, randint

class Circle():
    texture: Texture
    shader_color: Shader

    def load_assets(engine: RenderEngine):
        Circle.texture = engine.load_texture('circle.png')
        Circle.texture.filter = (LINEAR, LINEAR)  # For smooth scaling
        Circle.shader_color = engine.load_shader_from_path(
            'vertex.glsl', 'fragment_color.glsl')

    def __init__(self, position) -> None:
        self.position = position
        self.color = [random() for _ in range(3)] + [1]  # [R, G, B, 1]
        self.radius = randint(32, 128)  # Random size
        self.scale = (self.radius * 2) / Circle.texture.width

    def update(self, delta_time: int):
        # Move the particle, despawn, etc.
        pass

    def draw(self, engine: RenderEngine):
        Circle.shader_color['particle_color'] = self.color
        engine.render(Circle.texture, engine.screen, position=self.position,
                      scale=self.scale, shader=Circle.shader_color)

This is my circle.png file (if you have GitHub light theme maybe you cant see it, but the image is there):

circle

The shader program is the same as in my previous message, with the particle_color uniform.

The result looks like this:

screenshot

OlaKenji commented 9 months ago

@MarkelZ It is obviously working for you, that's great! Then I know the fault is on my side. I think I am doing the same as you but still something off. Could you send me your script file to reproduce your picture? I will try to compare the details and debug.

MarkelZ commented 9 months ago

@OlaKenji Of course, here: ola_kenji.zip

Let me know how the debugging goes :)

OlaKenji commented 9 months ago

@MarkelZ Ok, so I managed to solve it :D The problem was that the line Circle.shader_color['particle_color'] = self.color was in the update method, instead of in the draw method. Since it is a class variable, I think every circle overwrote each others values in the update method and there was only one value left in the draw method.

MarkelZ commented 9 months ago

@OlaKenji That makes a lot of sense, glad that you fixed it! I'll leave this issue open for now in case we get back to it or someone else takes interest. If you want to discuss something that is not related to this issue you can open a new one though.