Working With Shaders#

Shaders are graphics programs that run on GPU and can be used for many varied purposes.

Here we look at some very simple shader programs and learn how to pass data to and from shaders

Basic Arcade Program#

Starting template#
 1import arcade
 2
 3SCREEN_WIDTH = 800
 4SCREEN_HEIGHT = 600
 5SCREEN_TITLE = "Basic Arcade Template"
 6
 7
 8class MyWindow(arcade.Window):
 9    def __init__(self):
10        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
11        self.center_window()
12        self.background_color = arcade.color.ALMOND
13
14    def on_draw(self):
15        # Draw a simple circle to the screen
16        self.clear()
17        arcade.draw_circle_filled(
18            SCREEN_WIDTH / 2, 
19            SCREEN_HEIGHT / 2,
20            100,
21            arcade.color.AFRICAN_VIOLET
22        )
23
24
25app = MyWindow()
26arcade.run()

Basic Shader Program#

From here we add a very basic shader and draw it to the screen. This shader simply sets color and alpha based on the horizontal coordinate of the pixel.

We have to define vertex shader and fragment shader programs.

  • Vertex shaders run on each passed coorninate and can modify it. Here we use it only to pass on the coordinate on to the fragment shader

  • Fragment shaders set color for each passed pixel. Here we set a fixed color for every pixel and vary alpha based on horizontal position

We need to pass the shader the pixel coordinates so create an object quad_fs to facilitate it.

Simple shader#
 1import arcade
 2
 3SCREEN_WIDTH = 800
 4SCREEN_HEIGHT = 600
 5SCREEN_TITLE = "Basic Vertex and Fragment Shader"
 6
 7
 8class MyWindow(arcade.Window):
 9    def __init__(self):
10        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
11        self.center_window()
12        self.background_color = arcade.color.ALMOND
13        
14        # GL geometry that will be used to pass pixel coordinates to the shader
15        # It has the same dimensions as the screen
16        self.quad_fs = arcade.gl.geometry.quad_2d_fs()
17
18        # Create a simple shader program
19        self.prog = self.ctx.program(
20            vertex_shader="""
21            #version 330
22            in vec2 in_vert;
23            void main()
24            {
25                gl_Position = vec4(in_vert, 0., 1.);
26            }
27            """,
28            fragment_shader="""
29            #version 330
30            out vec4 fragColor;
31            void main()
32            {
33                // Set the pixel colour and alpha based on x position
34                fragColor = vec4(0.9, 0.5, 0.5, sin(gl_FragCoord.x / 50));
35            }
36            """
37        )
38
39    def on_draw(self):
40        # Draw a simple circle
41        self.clear()
42        arcade.draw_circle_filled(
43            SCREEN_WIDTH / 2, 
44            SCREEN_HEIGHT / 2,
45            100,
46            arcade.color.AFRICAN_VIOLET
47        )
48
49        # Run the shader and render to screen
50        # The shader code is run once for each pixel coordinate in quad_fs
51        # and the fragColor output added to the screen
52        self.quad_fs.render(self.prog)
53
54
55app = MyWindow()
56arcade.run()

Passing Data To The Shader#

To pass data to the shader program we can define uniforms. Uniforms are global shader variables that act as parameters passed from outside the shader program.

We have to define uniform within the shader and then register the python variable with the shader program before rendering.

It is important to make sure that the uniform type is appropriate for the data being passed.

Uniforms#
 1import arcade
 2
 3SCREEN_WIDTH = 800
 4SCREEN_HEIGHT = 600
 5SCREEN_TITLE = "Shader With Uniform"
 6
 7
 8class MyWindow(arcade.Window):
 9    def __init__(self):
10        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
11        self.center_window()
12        self.background_color = arcade.color.ALMOND
13        
14        # GL geometry that will be used to pass pixel coordinates to the shader
15        # It has the same dimensions as the screen
16        self.quad_fs = arcade.gl.geometry.quad_2d_fs()
17
18        # Create a simple shader program
19        self.prog = self.ctx.program(
20            vertex_shader="""
21            #version 330
22            in vec2 in_vert;
23            void main()
24            {
25                gl_Position = vec4(in_vert, 0., 1.);
26            }
27            """,
28            fragment_shader="""
29            #version 330
30            // Define an input to receive total_time from python
31            uniform float time;
32            out vec4 fragColor;
33            void main()
34            {
35                // Set the pixel colour and alpha based on x position and time
36                fragColor = vec4(0.9, 0.5, 0.5, sin(gl_FragCoord.x / 50 + time));
37            }
38            """
39        )
40
41        # Create a variable to track program run time
42        self.total_time = 0
43
44    def on_update(self, delta_time):
45        # Keep tract o total time
46        self.total_time += delta_time
47
48    def on_draw(self):
49        # Draw a simple circle
50        self.clear()
51        arcade.draw_circle_filled(
52            SCREEN_WIDTH / 2, 
53            SCREEN_HEIGHT / 2,
54            100,
55            arcade.color.AFRICAN_VIOLET
56        )
57
58        # Register the uniform in the shader program
59        self.prog['time'] = self.total_time
60
61        # Run the shader and render to screen
62        # The shader code is run once for each pixel coordinate in quad_fs
63        # and the fragColor output added to the screen
64        self.quad_fs.render(self.prog)
65
66
67app = MyWindow()
68arcade.run()

Accessing Textures From The Shader#

To make the shader more useful we may wish to pass textures to it.

Here we create to textures (and associated framebuffers) and pass them to the shader as uniform sampler objects. Unlike other uniforms we need to assign a reference to an integer texture channel (rather than directly to the python object) and .use() the texture to bind it to that channel.

Textures#
  1import arcade
  2
  3SCREEN_WIDTH = 800
  4SCREEN_HEIGHT = 600
  5SCREEN_TITLE = "Shader with Textures"
  6
  7
  8class MyWindow(arcade.Window):
  9    def __init__(self):
 10        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
 11        self.center_window()
 12        self.background_color = arcade.color.ALMOND
 13        
 14        # GL geometry that will be used to pass pixel coordinates to the shader
 15        # It has the same dimensions as the screen
 16        self.quad_fs = arcade.gl.geometry.quad_2d_fs()
 17
 18        # Create textures and FBOs
 19        self.tex_0 = self.ctx.texture((self.width, self.height))
 20        self.fbo_0 = self.ctx.framebuffer(color_attachments=[self.tex_0])
 21
 22        self.tex_1 = self.ctx.texture((self.width, self.height))
 23        self.fbo_1 = self.ctx.framebuffer(color_attachments=[self.tex_1])
 24
 25        # Fill the textures with solid colours
 26        self.fbo_0.clear(color_normalized=(0.0, 0.0, 1.0, 1.0))
 27        self.fbo_1.clear(color_normalized=(1.0, 0.0, 0.0, 1.0))
 28
 29        # Create a simple shader program
 30        self.prog = self.ctx.program(
 31            vertex_shader="""
 32            #version 330
 33            in vec2 in_vert;
 34            // Get normalized coordinates
 35            in vec2 in_uv;
 36            out vec2 uv;
 37            void main()
 38            {
 39                gl_Position = vec4(in_vert, 0., 1.);
 40                uv = in_uv;
 41            }
 42            """,
 43            fragment_shader="""
 44            #version 330
 45            // Define an input to receive total_time from python
 46            uniform float time;
 47            // Define inputs to access textures
 48            uniform sampler2D t0;
 49            uniform sampler2D t1;
 50            in vec2 uv;
 51            out vec4 fragColor;
 52            void main()
 53            {
 54                // Set pixel color as a combination of the two textures
 55                fragColor = mix(
 56                    texture(t0, uv), 
 57                    texture(t1, uv), 
 58                    smoothstep(0.0, 1.0, uv.x));
 59                // Set the alpha based on time
 60                fragColor.w = sin(time);
 61            }
 62            """
 63        )
 64
 65        # Register the texture uniforms in the shader program
 66        self.prog['t0'] = 0
 67        self.prog['t1'] = 1
 68
 69        # Create a variable to track program run time
 70        self.total_time = 0
 71
 72    def on_update(self, delta_time):
 73        # Keep tract o total time
 74        self.total_time += delta_time
 75
 76    def on_draw(self):
 77        # Draw a simple circle
 78        self.clear()
 79        arcade.draw_circle_filled(
 80            SCREEN_WIDTH / 2, 
 81            SCREEN_HEIGHT / 2,
 82            100,
 83            arcade.color.AFRICAN_VIOLET
 84        )
 85
 86        # Register the uniform in the shader program
 87        self.prog['time'] = self.total_time
 88
 89        # Bind our textures to channels
 90        self.tex_0.use(0)
 91        self.tex_1.use(1)
 92
 93        # Run the shader and render to screen
 94        # The shader code is run once for each pixel coordinate in quad_fs
 95        # and the fragColor output added to the screen
 96        self.quad_fs.render(self.prog)
 97
 98
 99app = MyWindow()
100arcade.run()

Drawing To Texture From The Shader#

Finally we have an example of reading from and writing to the same texture with a shader.

We use the with fbo: syntax to tell arcade that we wish to render to the new frambuffer rather than default one.

Once the shader has updated the framebuffer we need to copy its contents to the screen to be displayed.

Textures#
 1import arcade
 2
 3SCREEN_WIDTH = 800
 4SCREEN_HEIGHT = 600
 5SCREEN_TITLE = "An Empty Program"
 6
 7
 8class MyWindow(arcade.Window):
 9    def __init__(self):
10        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
11        self.center_window()
12        self.background_color = arcade.color.ALMOND
13        
14        # GL geometry that will be used to pass pixel coordinates to the shader
15        # It has the same dimensions as the screen
16        self.quad_fs = arcade.gl.geometry.quad_2d_fs()
17
18        # Create texture and FBO
19        self.tex = self.ctx.texture((self.width, self.height))
20        self.fbo = self.ctx.framebuffer(color_attachments=[self.tex])
21
22        # Put something in the framebuffer to start
23        self.fbo.clear(color=arcade.color.ALMOND)
24        with self.fbo:
25            arcade.draw_circle_filled(
26                SCREEN_WIDTH / 2, 
27                SCREEN_HEIGHT / 2,
28                100,
29                arcade.color.AFRICAN_VIOLET
30            )
31
32        # Create a simple shader program
33        self.prog = self.ctx.program(
34            vertex_shader="""
35            #version 330
36            in vec2 in_vert;
37            void main()
38            {
39                gl_Position = vec4(in_vert, 0., 1.);
40            }
41            """,
42            fragment_shader="""
43            #version 330
44            // Define input to access texture
45            uniform sampler2D t0;
46            out vec4 fragColor;
47            void main()
48            {
49                // Overwrite this pixel with the colour from its neighbour
50                ivec2 pos = ivec2(gl_FragCoord.xy) + ivec2(-1, -1);
51                fragColor = texelFetch(t0, pos, 0);
52            }
53            """
54        )
55
56        # Register the texture uniform in the shader program
57        self.prog['t0'] = 0
58
59    def on_draw(self):
60        # Activate our new framebuffer to render to
61        with self.fbo:
62            # Bind our texture to the first channel
63            self.tex.use(0)
64
65            # Run the shader and render to the framebuffer
66            self.quad_fs.render(self.prog)
67        
68        # Copy the framebuffer to the screen to display
69        self.ctx.copy_framebuffer(self.fbo, self.ctx.screen)
70
71
72app = MyWindow()
73arcade.run()