Skip to content

Commit 2ea17ff

Browse files
authored
Adding a stub Clock class and providing a fixed update event in Window and View (#2169)
* Delete the old unused clock code * Create new streamlines clock class * more clock stuff * Application updated with Fixed Update, and clocks with aliases to time values * Clock linting * Remove the unnecessary time values from examples * fixing sign issue with accumulated fraction * an example using fixed update * linting pass * Changed to ticks and fixed integration test * linting pass * formating * decreasing number of properties for now * unit test fix * fixed light demo's
1 parent 55ee12a commit 2ea17ff

File tree

17 files changed

+319
-58
lines changed

17 files changed

+319
-58
lines changed

arcade/application.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import arcade
2020
from arcade import SectionManager, get_display_size, set_window
21+
from arcade.clock import Clock, FixedClock
2122
from arcade.color import TRANSPARENT_BLACK
2223
from arcade.context import ArcadeContext
2324
from arcade.types import Color, RGBANormalized, RGBOrA255
@@ -79,6 +80,10 @@ class Window(pyglet.window.Window):
7980
:param resizable: Can the user resize the window?
8081
:param update_rate: How frequently to run the on_update event.
8182
:param draw_rate: How frequently to run the on_draw event. (this is the FPS limit)
83+
:param fixed_rate: How frequently should the fixed_updates run, fixed updates will always run at this rate.
84+
:param fixed_frame_cap: The maximum number of fixed updates that can occur in one update loop.
85+
defaults to infinite. If large lag spikes cause your game to freeze, try setting
86+
this to a smaller number. This may cause your physics to lag behind temporarily
8287
:param antialiasing: Should OpenGL's anti-aliasing be enabled?
8388
:param gl_version: What OpenGL version to request. This is ``(3, 3)`` by default \
8489
and can be overridden when using more advanced OpenGL features.
@@ -119,6 +124,8 @@ def __init__(
119124
enable_polling: bool = True,
120125
gl_api: str = "gl",
121126
draw_rate: float = 1 / 60,
127+
fixed_rate: float = 1.0 / 60.0,
128+
fixed_frame_cap: Optional[int] = None,
122129
) -> None:
123130
# In certain environments we can't have antialiasing/MSAA enabled.
124131
# Detect replit environment
@@ -186,6 +193,7 @@ def __init__(
186193
)
187194
self.register_event_type("on_update")
188195
self.register_event_type("on_action")
196+
self.register_event_type("on_fixed_update")
189197
except pyglet.window.NoSuchConfigException:
190198
raise NoOpenGLException(
191199
"Unable to create an OpenGL 3.3+ context. "
@@ -197,11 +205,19 @@ def __init__(
197205
except pyglet.gl.GLException:
198206
LOG.warning("Warning: Anti-aliasing not supported on this computer.")
199207

208+
self._global_clock: Clock = Clock()
209+
self._fixed_clock: FixedClock = FixedClock(self._global_clock, fixed_rate)
210+
200211
# We don't call the set_draw_rate function here because unlike the updates, the draw scheduling
201212
# is initially set in the call to pyglet.app.run() that is done by the run() function.
202213
# run() will pull this draw rate from the Window and use it. Calls to set_draw_rate only need
203214
# to be done if changing it after the application has been started.
204215
self._draw_rate = draw_rate
216+
217+
# Fixed rate cannot be changed post initialisation as this throws off physics sims.
218+
# If more time resolution is needed in fixed updates, devs can do 'sub-stepping'.
219+
self._fixed_rate = fixed_rate
220+
self._fixed_frame_cap = fixed_frame_cap
205221
self.set_update_rate(update_rate)
206222

207223
self.set_vsync(vsync)
@@ -381,11 +397,27 @@ def on_update(self, delta_time: float) -> Optional[bool]:
381397
"""
382398
pass
383399

400+
def on_fixed_update(self, delta_time: float):
401+
"""
402+
Move everything. Perform collision checks. Always
403+
404+
"""
405+
pass
406+
384407
def _dispatch_updates(self, delta_time: float) -> None:
385408
"""
386409
Internal function that is scheduled with Pyglet's clock, this function gets run by the clock, and
387410
dispatches the on_update events.
388-
"""
411+
It also accumulates time and runs fixed updates until the Fixed Clock catches up to the global clock
412+
"""
413+
self._global_clock.tick(delta_time)
414+
fixed_count = 0
415+
while self._fixed_clock.accumulated >= self._fixed_rate and (
416+
self._fixed_frame_cap is None or fixed_count <= self._fixed_frame_cap
417+
):
418+
self._fixed_clock.tick(self._fixed_rate)
419+
self.dispatch_event("on_fixed_update", self._fixed_rate)
420+
fixed_count += 1
389421
self.dispatch_event("on_update", delta_time)
390422

391423
def set_update_rate(self, rate: float) -> None:
@@ -976,6 +1008,45 @@ def center_y(self) -> float:
9761008
"""Returns the Y-coordinate of the center of the window."""
9771009
return self.height / 2
9781010

1011+
# --- CLOCK ALIASES ---
1012+
@property
1013+
def time(self) -> float:
1014+
return self._global_clock.time
1015+
1016+
@property
1017+
def fixed_time(self) -> float:
1018+
return self._fixed_clock.time
1019+
1020+
@property
1021+
def delta_time(self) -> float:
1022+
return self._global_clock.delta_time
1023+
1024+
@property
1025+
def fixed_delta_time(self) -> float:
1026+
return self._fixed_rate
1027+
1028+
@property
1029+
def global_clock(self) -> Clock:
1030+
return self._global_clock
1031+
1032+
@property
1033+
def global_fixed_clock(self) -> FixedClock:
1034+
return self._fixed_clock
1035+
1036+
# Possibly useful clock properties
1037+
1038+
# @property
1039+
# def current_fixed_tick(self) -> int:
1040+
# return self._fixed_clock.ticks
1041+
1042+
# @property
1043+
# def accumulated_time(self) -> float:
1044+
# return self._fixed_clock.accumulated
1045+
1046+
# @property
1047+
# def accumulated_fraction(self) -> float:
1048+
# return self._fixed_clock.fraction
1049+
9791050

9801051
def open_window(
9811052
width: int,
@@ -1072,6 +1143,10 @@ def on_update(self, delta_time: float) -> Optional[bool]:
10721143
"""To be overridden"""
10731144
pass
10741145

1146+
def on_fixed_update(self, delta_time: float):
1147+
"""To be overridden"""
1148+
pass
1149+
10751150
def on_draw(self) -> Optional[bool]:
10761151
"""Called when this view should draw"""
10771152
pass

arcade/clock.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
class Clock:
2+
"""
3+
A time keeping class.
4+
5+
:Coming post 3.0:
6+
you can add 'sub-clocks' to arcade's top level clock which will tick at the same time, and
7+
have cumulative tick_speeds. This allows you to slow down only certain elements rather than everything.
8+
"""
9+
10+
def __init__(
11+
self, initial_elapsed: float = 0.0, initial_frame: int = 0, tick_speed: float = 1.0
12+
):
13+
self._elapsed_time: float = initial_elapsed
14+
self._frame: int = initial_frame
15+
self._tick_delta_time: float = 0.0
16+
self._tick_speed: float = tick_speed
17+
18+
def tick(self, delta_time: float):
19+
self._tick_delta_time = delta_time * self._tick_speed
20+
self._elapsed_time += self._tick_delta_time
21+
self._frame += 1
22+
23+
def time_since(self, time: float):
24+
return self._elapsed_time - time
25+
26+
def frames_since(self, frame: int):
27+
return self._frame - frame
28+
29+
@property
30+
def time(self):
31+
"""
32+
The total number of seconds that have elapsed for this clock
33+
"""
34+
return self._elapsed_time
35+
36+
@property
37+
def t(self):
38+
"""
39+
Alias to Clock.time
40+
"""
41+
return self._elapsed_time
42+
43+
@property
44+
def delta_time(self):
45+
"""
46+
The amount of time that elapsed during the last tick
47+
"""
48+
return self._tick_delta_time
49+
50+
@property
51+
def dt(self):
52+
"""
53+
Alias to Clock.delta_time
54+
"""
55+
return self.delta_time
56+
57+
@property
58+
def speed(self):
59+
"""
60+
A modifier on the delta time that elapsed each tick.
61+
62+
Decreasing the speed will 'slow down' time for this clock
63+
Immutable in 3.0
64+
"""
65+
return self._tick_speed
66+
67+
@property
68+
def ticks(self):
69+
"""
70+
The number of ticks that have occurred for this clock
71+
"""
72+
return self._frame
73+
74+
@property
75+
def tick_count(self):
76+
"""
77+
Alias to Clock.frame
78+
"""
79+
return self._frame
80+
81+
82+
class FixedClock(Clock):
83+
"""
84+
A fixed clock which expects its delta_time to stay constant. If it doesn't it will throw an error.
85+
"""
86+
87+
def __init__(self, sibling: Clock, fixed_tick_rate: float = 1.0 / 60.0):
88+
self._sibling_clock: Clock = sibling
89+
self._fixed_rate: float = fixed_tick_rate
90+
super().__init__()
91+
92+
def tick(self, delta_time: float):
93+
if delta_time != self._fixed_rate:
94+
raise ValueError(
95+
f"the delta_time {delta_time}, "
96+
f"does not match the fixed clock's required delta_time {self._fixed_rate}"
97+
)
98+
super().tick(self._fixed_rate)
99+
100+
@property
101+
def rate(self):
102+
return self._fixed_rate
103+
104+
@property
105+
def accumulated(self):
106+
return self._sibling_clock.time - self._elapsed_time
107+
108+
@property
109+
def fraction(self):
110+
return self.accumulated / self._fixed_rate
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Interpolate a sprite's motion which is calculated in fixed update.
3+
4+
The bouncing done in this example is very bare-bones, and unstable.
5+
The fixed update has been slowed down to highlight the value of interpolation,
6+
the fixed update should be kept close to the nominal update rate, or even faster.
7+
8+
If Python and Arcade are installed, this example can be run from the command line with:
9+
python -m arcade.examples.fixed_update_interpolation.py
10+
"""
11+
import arcade
12+
13+
# --- Constants ---
14+
GRAVITY = 98.1 # 98.1 px per second
15+
CIRCLE_RADIUS = 30
16+
17+
SCREEN_WIDTH = 1289
18+
SCREEN_HEIGHT = 720
19+
SCREEN_TITLE = "Sprite Follow Path Simple Example"
20+
21+
22+
class Game(arcade.Window):
23+
24+
def __init__(self):
25+
super().__init__(fixed_rate=1/120.0)
26+
self.unfixed_sprite = arcade.SpriteCircle(CIRCLE_RADIUS, arcade.color.RADICAL_RED)
27+
self.interpolated_sprite = arcade.SpriteCircle(CIRCLE_RADIUS, arcade.color.ORANGE)
28+
self.fixed_sprite = arcade.SpriteCircle(CIRCLE_RADIUS, arcade.color.GOLD)
29+
30+
# We store the last position of the fixed sprite to find the interpolated sprite's position
31+
self.last_position = 0.0
32+
33+
self.sprites = arcade.SpriteList()
34+
self.sprites.extend((self.unfixed_sprite, self.fixed_sprite, self.interpolated_sprite))
35+
36+
def setup(self):
37+
self.unfixed_sprite.change_y = self.fixed_sprite.change_y = self.interpolated_sprite.change_y = 0.0
38+
39+
self.unfixed_sprite.position = SCREEN_WIDTH / 4.0, SCREEN_HEIGHT / 2.0
40+
self.interpolated_sprite.position = 2.0 * SCREEN_WIDTH / 4.0, SCREEN_HEIGHT / 2.0
41+
self.fixed_sprite.position = 3.0 * SCREEN_WIDTH / 4.0, SCREEN_HEIGHT / 2.0
42+
43+
self.last_position = self.fixed_sprite.center_y
44+
45+
def on_key_press(self, symbol: int, modifiers: int):
46+
if symbol == arcade.key.R:
47+
self.setup()
48+
49+
def on_fixed_update(self, delta_time: float):
50+
# Accelerate the sprite downward due to gravity
51+
self.fixed_sprite.change_y -= GRAVITY * delta_time
52+
53+
# If the sprite is colliding with the ground then make it 'bounce' by flipping it's velocity
54+
if self.fixed_sprite.center_y <= CIRCLE_RADIUS and self.fixed_sprite.change_y <= 0.0:
55+
self.fixed_sprite.change_y *= -1
56+
57+
# Move the sprite based on its velocity
58+
self.last_position = self.fixed_sprite.center_y
59+
self.fixed_sprite.center_y += self.fixed_sprite.change_y * delta_time
60+
61+
def on_update(self, delta_time: float):
62+
# Accelerate the sprite downward due to gravity
63+
self.unfixed_sprite.change_y -= GRAVITY * delta_time
64+
65+
# If the sprite is colliding with the ground then make it 'bounce' by flipping it's velocity
66+
if self.unfixed_sprite.center_y <= CIRCLE_RADIUS and self.unfixed_sprite.change_y <= 0.0:
67+
self.unfixed_sprite.change_y *= -1
68+
69+
# Move the sprite based on its velocity
70+
self.unfixed_sprite.center_y += self.unfixed_sprite.change_y * delta_time
71+
72+
self.interpolated_sprite.center_y = arcade.math.lerp(
73+
self.last_position, self.fixed_sprite.center_y, self.global_fixed_clock.fraction
74+
)
75+
76+
def on_draw(self):
77+
self.clear()
78+
self.sprites.draw()
79+
80+
81+
def main():
82+
win = Game()
83+
win.setup()
84+
win.run()
85+
86+
87+
if __name__ == '__main__':
88+
main()

arcade/examples/gl/compute_ssbo.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ def __init__(self, *args, **kwargs):
131131
# We need to specify OpenGL 4.3 when using Compute Shaders
132132
super().__init__(1280, 720, "Compute Shader", gl_version=(4, 3), resizable=True, vsync=True)
133133
# Keep track of time
134-
self.time = 0
135134
self.frame_time = 0
136135
# The work group size we have configured the compute shader to.
137136
# This is hardcoded the compute shader (careful with changing)
@@ -317,7 +316,6 @@ def on_draw(self):
317316

318317
def on_update(self, delta_time: float):
319318
# Keep our time variables up to date
320-
self.time += delta_time
321319
self.frame_time = delta_time
322320

323321
def gen_initial_data(self, num_balls):

arcade/examples/gl/compute_texture.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ class App(arcade.Window):
4949
def __init__(self, *args, **kwargs):
5050
# We need to specify OpenGL 4.3 when using Compute Shaders
5151
super().__init__(*SIZE, "Compute Shader", gl_version=(4, 3))
52-
self.time = 0
5352
self.cs = self.ctx.compute_shader(source=COMPUTE_SHADER)
5453
# In gles the texture needs to be immutable (immutable storage, not contents)
5554
self.texture = self.ctx.texture(SIZE, components=4, immutable=True)
@@ -107,7 +106,6 @@ def on_draw(self):
107106
self.quad.render(self.program)
108107

109108
def on_update(self, delta_time: float):
110-
self.time += delta_time
111109
self.cs["time"] = self.time * 10
112110

113111

arcade/examples/gl/multisample_fbo.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ class MultisampleFramebuffer(arcade.Window):
1919

2020
def __init__(self, width, height):
2121
super().__init__(width, height, "Multisampled Framebuffer", samples=SAMPLES)
22-
self.time = 0
23-
2422
# Create a MSAA texture and framebuffer
2523
self.texture = self.ctx.texture(self.get_framebuffer_size(), samples=SAMPLES)
2624
self.fbo = self.ctx.framebuffer(color_attachments=[self.texture])
@@ -51,8 +49,5 @@ def on_draw(self):
5149
self.ctx.screen.use()
5250
self.ctx.copy_framebuffer(self.fbo, self.ctx.screen)
5351

54-
def on_update(self, delta_time: float):
55-
self.time += delta_time
56-
5752

5853
MultisampleFramebuffer(800, 600).run()

0 commit comments

Comments
 (0)