0

Summary

I am making a real-time physics simulation that needs a low delta_t. I have connected this simulation to a python-arcade game window to display the information in real-time.

I made a separate thread for the physics because there are some expensive matrix multiplications in the physics thread. Then, when an update is done I set the resulting states of the game window class that the game window can display whenever it draws a new frame.

Therefore, my thought process would be that the game window class only has to worry about drawing on the screen, while the physics thread takes care of all of the computations.

However, there is a bottleneck in communicating between the game window and the thread and I don't know have the under-the-hood insight.

Minimal Representation of what I want to do:

import threading
import time
import math
import arcade


class DisplayWindow(arcade.Window):
    def __init__(self):
        super().__init__(width=400, height=400)

        self.state = 0
        self.FPS = 0

    def set_state(self, state):
        self.state = state

    def on_update(self, delta_time: float):
        self.FPS = 1. / delta_time

    def on_draw(self):
        arcade.start_render()
        arcade.draw_text(f'FPS: {self.FPS:0.2f}', 20, 20, arcade.color.WHITE)
        arcade.draw_rectangle_filled(center_x=self.state * self.width,
                                     center_y=self.height/2,
                                     color=arcade.color.WHITE,
                                     tilt_angle=0,
                                     width=10,
                                     height=10)

# Thread to simulate physics.
def simulation(display):
    t_0 = time.time()
    while True:

        # Expensive calculation that needs high frequency:
        t = time.time() - t_0
        x = math.sin(t) / 2 + 0.5       # sinusoid for demonstration

        # Send it to the display window
        display.set_state(state=x)

        # time.sleep(0.1)               # runs smoother with this

def main():
    display_window = DisplayWindow()
    physics_thread = threading.Thread(target=simulation, args=(display_window,), daemon=True)
    physics_thread.start()

    arcade.run()

    return 0

if __name__ == '__main__':
    main()

Expected result: Smooth simulation with high frame-rates. The arcade window only has to run the on_draw at 30 or 60 fps. It only has to draw a few things.

Actual result: The physics loop runs super fast and calls the FPS drops.

When I add a time.sleep(0.1) to the physics thread, the whole thing becomes much smoother, I guess for some reason set_state( _ ) slows down the draw loop.

Alderven
  • 7,569
  • 5
  • 26
  • 38
Speterius
  • 147
  • 1
  • 11

2 Answers2

1

Python threads might not be the ideal tool for the job you are trying to do.

Although it may be tempting to think Python threads as running concurrently, they are not : the Global Interpreter Lock (GIL) only allows one thread to control the Python interpreter. More info

Because of that, the arcade.Window object does not get an early chance to control the Python interpreter and run all its update functions because the GIL stays "focused" on the infinite loop in the simulation function of your physics_thread.

The GIL will only release the focus on the physics_thread and look for something else to do on other threads after a certain number of instructions were run or the physics_thread is set to sleep using time.sleep() which performs on threads. Which is exactly what you empirically found to restore the expected behavior of the program.

This is an example of a typical problem called thread starvation, which can be solved by using the multiprocessing library. This comes with a bit more complexity, but will separate your CPU-intensive computation and your lightweight event-based interface in separate processes, thereby solving your problem.

pjmv
  • 455
  • 2
  • 9
  • Thanks for the info! I solved it using the multiprocessing.Pipe as a two-way communication. It runs smoothly and real-time works. – Speterius Sep 23 '19 at 19:47
0

Thanks to @pjmv 's answer, I looked into using multiprocessing instead of threading.

The multiprocessing.Pipe object ensures duplex communication and made the whole thing smoother. I can now also ensure real-time running of the simulation.

Every update loop on both sides, simply use the send() and recv() commands. Didn't test for edge cases yet but seems to be working smoothly.

I added the modification to the example posted above:

import time
import arcade
from multiprocessing import Process, Pipe
from math import sin, pi


class DisplayWindow(arcade.Window):
    def __init__(self, connection: Pipe):
        super().__init__(500, 500)

        self.connection: Pipe = connection    # multiprocessing.Pipe

        self.position: float = 0               # GUI Display state
        self.user_input: float = 1.0           # Input to simulation
        self.FPS: float = 0                    # Frames per second estimation

    def on_update(self, delta_time: float):
        self.FPS = 1. / delta_time

        # Communicate with simulation:
        self.connection.send(self.user_input)
        self.position = self.connection.recv()

    def on_draw(self):
        arcade.start_render()
        arcade.draw_text(f'FPS: {self.FPS:0.0f}', 20, 20, arcade.color.WHITE)
        arcade.draw_point(self.position, self.height/2, arcade.color.WHITE, 10)

    def on_key_release(self, symbol: int, modifiers: int):
        if symbol == arcade.key.W:
            self.user_input = 1.8
        elif symbol == arcade.key.S:
            self.user_input = 0.3


# Separate Process target to simulate physics:
def simulation(connection: Pipe):
    t_0 = time.time()
    while True:
        freq = connection.recv() * 2 * pi       # Receive GUI user input

        t = time.time() - t_0
        x = sin(freq * t) * 250 + 250

        connection.send(x)                      # Send state to GUI

def main():
    parent_con, child_con = Pipe()
    display_window = DisplayWindow(connection=parent_con)
    physics = Process(target=simulation, args=(child_con,), daemon=True)
    physics.start()
    arcade.run()
    physics.terminate()
    return 0

if __name__ == '__main__':
    main()
Speterius
  • 147
  • 1
  • 11