blog

Personal blog
git clone git://git.laack.co/blog.git
Log | Files | Refs

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.