pygame-vs-raylib.md (10647B)
1 # Pygame vs Raylib 2 3 I implemented [Prim's algorithm](https://cp-algorithms.com/graph/mst_prim.html) for the creation of a MST of a graph in Python. It's an algorithm worth understanding, but as it relates to the task of rendering, the following is true about the visualization I want: 4 5 1. The first rendered frame must draw every edge and vertex of the graph. 6 2. For each subsequent timestep we have to re-draw one edge, the edge traversed at the timestep, and one vertex, the vertex we are visiting. 7 8 Given this, the first frame being rendered will take substantially longer than subsequent frames for most graphs because rendering time is often proportional to the number of components we are rendering. With any sensible rendering program, there's a way to do incremental changes, making use of prior renders. This is true for both Raylib and PyGame. 9 10 ## Raylib 11 12 ### Implementation 13 14 Raylib has 2D rendered textures which can be persisted across frames, to be rendered as part of the frame. Here's a little Python code that does this: 15 16 ```python 17 18 # ... (imports and such) 19 20 pr.init_window(screen_width,screen_height, "prim") 21 22 # ... (other initialization) 23 24 texture = pr.load_render_texture(screen_width,screen_height) 25 26 while !done: 27 if first: 28 start = time.monotonic_ns() 29 pr.begin_texture_mode(texture) 30 pr.clear_background(pr.BLACK) 31 32 for edge in edge_list: 33 edge.draw_edge(pr.DARKGRAY) 34 for vertex in graph: 35 vertex.draw_vertex() 36 first = False 37 pr.end_texture_mode() 38 source_rec = pr.Rectangle(0, 0, 5120, 1440) 39 dest_rec = pr.Rectangle(0, 0, 5120,1440) 40 pr.begin_drawing() 41 pr.draw_texture_pro(texture.texture, source_rec, dest_rec, pr.Vector2(0, 0), 0, pr.WHITE) 42 pr.end_drawing() 43 end = time.monotonic_ns() 44 first_time.append(end - start) 45 continue 46 47 # ... (perform one step of Prim's algorithm) 48 49 if len(to_draw_edge) != 0 or len(to_draw_vert) != 0: 50 start = time.monotonic_ns() 51 pr.begin_texture_mode(texture) 52 for edge in to_draw_edge: 53 edge.draw_edge(pr.WHITE) 54 for vert in to_draw_vert: 55 vert.draw_vertex() 56 pr.end_texture_mode() 57 source_rec = pr.Rectangle(0, 0, 5120, 1440) 58 dest_rec = pr.Rectangle(0, 0, 5120,1440) 59 60 pr.begin_drawing() 61 pr.draw_texture_pro(texture.texture, source_rec, dest_rec, pr.Vector2(0, 0), 0, pr.WHITE) 62 pr.end_drawing() 63 end = time.monotonic_ns() 64 fts.append(end - start) 65 to_draw_vert = [] 66 to_draw_edge = [] 67 68 ``` 69 70 (TODO: This isn't correct anymore with the code above) 71 72 Outside of our main iteration loop for Prim's algorithm, we initialize our texture to be the size of our screen, matching the window size we specified, because fullscreen animations are cooler, and we want the texture to fill the entire window. We then call the `draw_init_graph()` function with our texture as input to draw our graph to the texture, and then output the first rendered frame. 73 74 Within our main iteration loop, we call the `begin_texture_mode()` function, which updates the Raylib global state, and makes any Raylib calls to render things to be rendered on that texture. Examples of such calls are shown below: 75 76 ```python 77 pr.draw_line(int(self.v1.x), int(self.v1.y), int(self.v2.x), int(self.v2.y), c) 78 pr.draw_circle(int(self.x),int(self.y), RADIUS, pr.WHITE) 79 ``` 80 81 Notice how we don't have to specify we are rendering to the texture, Raylib stores the global state for what canvas is being rendered to. 82 83 Continuing on, we then call `end_texture_mode()` to finalize the render of the texture. This invocation is generally quite slow because it realizes the queue of writes we've sent to Raylib from our `draw_line` and `draw_circle` invocations. In this case though, since each iteration only draws one line and one circle, the overhead cost isn't great, it is merely interesting that this is how Raylib supports batch rendering. 84 85 We then define two rectangles which will be used to map the texture onto the frame. We also call `begin_drawing()` which tells Raylib we are going to start rendering our frame now. Since Raylib makes extensive use of global state, it knows we mean we are rendering to the window we created from our `init_window()` invocation at the beginning. 86 87 The rest is fairly obvious. 88 89 ## PyGame 90 91 ### Implementation 92 93 PyGame frames are persisted from frame-to-frame, so we don't have to mess with rendered textures like we did with Raylib! Here's a little code that does the PyGame rendering: 94 95 ```python 96 97 # ... (imports and such) 98 99 pygame.init() 100 display = pygame.display.set_mode((screen_width,screen_height)) 101 102 # ... (other initialization) 103 104 while !done: 105 if first: 106 start = time.monotonic_ns() 107 display.fill(black) 108 for edge in edge_list: 109 edge.draw_edge(display, light_grey) 110 111 for vertex in graph: 112 vertex.draw_vertex(display) 113 pygame.display.update() 114 end = time.monotonic_ns() 115 first_time.append(end - start) 116 first = False 117 continue 118 119 # ... (perform one step of Prim's algorithm) 120 121 start = time.monotonic_ns() 122 for edge in to_draw_edge: 123 edge.draw_edge(display, white) 124 for vert in to_draw_vert: 125 vert.draw_vertex(display) 126 pygame.display.update() 127 end = time.monotonic_ns() 128 129 to_draw_vert = [] 130 to_draw_edge = [] 131 ``` 132 133 That's a bit nicer than the Raylib variant because we don't have to mess with a standalone texture, and I like the way writing to a display works in PyGame: 134 135 ```python 136 pygame.draw.circle(display, white, (self.x,self.y), 5) 137 pygame.draw.line(display, white, (self.v1.x,self.v1.y), (self.v2.x,self.v2.y), 1) 138 ``` 139 140 This feels much more explicit to write directly to the display instead of writing to the Raylib global state. Despite this, the final: 141 142 ```python 143 pygame.display.update() 144 ``` 145 146 Is still over-using global state in my opinion. All that being said, PyGame does feel more pythonic. 147 148 ## Benchmarking 149 150 These benchmarks are performed with the following parameters: 151 152 - Vertex count: 2,500 153 - Edge count: 25,000 154 - Screen dimensions: 5120x1440 155 156 Averaging across 10 full graph traversals. For each of the times we are tracking, these are as close to just render time as possible, removing all of the graph traversal logic time from the measured times. 157 158 ### Raylib 159 160 Average frame time (ns): 2882704.4661864745 161 Std. deviation frame time (ns): 1114606.9047266191 162 Average first frame time (ns): 29873724.3 163 Std. deviation first frame time (ns): 737806.3908848008 164 165 ### PyGame 166 167 Average frame time (ns): 5593530.846578632 168 Std. deviation frame time (ns): 654036.6681470409 169 Average first frame time (ns): 323388385.3 170 Std. deviation first frame time (ns): 44603039.88315142 171 172 ### Deltas 173 174 - Raylib's average frame render time was 1.94x faster than PyGame's. 175 - Raylib's average first frame render time was 10.83x faster than PyGame's. 176 177 ### Why So Slow? 178 179 Well, let's add some more time track to our PyGame code to find out why: 180 181 ```python 182 if first: 183 start = time.monotonic_ns() 184 display.fill(black) 185 186 edge_times = [] 187 vertex_times = [] 188 189 for edge in edge_list: 190 se = time.monotonic_ns() 191 edge.draw_edge(display, light_grey) 192 ee = time.monotonic_ns() 193 edge_times.append(ee - se) 194 195 for vertex in graph: 196 sv = time.monotonic_ns() 197 vertex.draw_vertex(display) 198 ev = time.monotonic_ns() 199 vertex_times.append(ev - sv) 200 201 pygame.display.update() 202 end = time.monotonic_ns() 203 first_time.append(end - start) 204 first = False 205 print("Edge time avg: " + str(sum(edge_times) / len(edge_times))) 206 print("Vertex time avg: " + str(sum(vertex_times) / len(vertex_times))) 207 continue 208 ``` 209 210 Here are some sample outputs of this: 211 212 ``` 213 Edge time avg: 9504.15048 214 Vertex time avg: 686.6508 215 Edge time avg: 9479.576 216 Vertex time avg: 684.0856 217 Edge time avg: 10398.7096 218 Vertex time avg: 689.1952 219 ``` 220 221 That's interesting. Let's compare this to our Raylib code: 222 223 ```python 224 if first: 225 start = time.monotonic_ns() 226 pr.begin_texture_mode(texture) 227 pr.clear_background(pr.BLACK) 228 229 edge_times = [] 230 vertex_times = [] 231 232 for edge in edge_list: 233 es = time.monotonic_ns() 234 edge.draw_edge(pr.DARKGRAY) 235 ee = time.monotonic_ns() 236 edge_times.append(ee - es) 237 for vertex in graph: 238 vs = time.monotonic_ns() 239 vertex.draw_vertex() 240 ve = time.monotonic_ns() 241 vertex_times.append(ve - vs) 242 first = False 243 pr.end_texture_mode() 244 source_rec = pr.Rectangle(0, 0, 5120, 1440) 245 dest_rec = pr.Rectangle(0, 0, 5120,1440) 246 pr.begin_drawing() 247 pr.draw_texture_pro(texture.texture, source_rec, dest_rec, pr.Vector2(0, 0), 0, pr.WHITE) 248 pr.end_drawing() 249 end = time.monotonic_ns() 250 first_time.append(end - start) 251 252 print("Edge time avg: " + str(sum(edge_times) / len(edge_times))) 253 print("Vertex time avg: " + str(sum(vertex_times) / len(vertex_times))) 254 continue 255 ``` 256 257 ``` 258 Edge time avg: 944.15884 259 Vertex time avg: 3016.1852 260 Edge time avg: 961.17092 261 Vertex time avg: 3076.4524 262 ``` 263 264 Huh. We see PyGame is faster at rendering vertices (circles), but slower drawing edges (lines). If we consider the PyGame source code, this actually makes some amount of sense. PyGame uses SDL where we have to walk the lines pixel by pixel on CPU, costing a lot of cycles, while Raylib writes the edge ends to a geometry batch to be asynchronously rendered on GPU lessening the amount of time spent in this loop. 265 266 If we consider the vertices, the Python Raylib bindings call out to `DrawCircleSector()`, and this in turn appends geometries to the GPU geometries batch, but for every circle there are a bunch of discrete geometries which have to be appended to the batch, costing cycles. Contrasting this with the PyGame approach where PyGame simply has a draw some pixels onto the screen. 267 268 There are ways to make PyGame utilize the GPU more, but these approaches are much more complex, and not something an average person who just wants to render some graphs would do. 269 270 ## Takeaways 271 272 For most use cases, you'll likely have better rendering performance if you use Raylib. Raylib is slightly more complex to work with, requiring explicit texture manipulation to achieve comparable performance on incremental renders, and making use of more global state, how the underlying C library is designed. Despite this, PyGame also makes use of some global state that can be a bit annoying, but slightly less so. Additionally, if you want to utilize GPU rendering more fully, Raylib will be better.