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
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.
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.
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.
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.
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()