Files
lamatrix/renderloop.py
Noah 3e4dd4c0bc Rewrite to reduce memory usage and add more features
In addition to the clock scene, both the animation scene and the weather
scene should now work under MicroPython on devices with 520kBytes of RAM
(e.g. LoPy 1, WiPy 2) after:

- combating heap fragmentation during initialization by temporarily allocating
  a large chunk of RAM in the beginning of main.py and freeing it after all
  modules have been imported and initialized
- stream parsing the JSON response from the weather API
- converting animations to binary and streaming them from the flash file system

(additionally, older ESP8266 modules with 4MB flash have been found working
 under some circumstances with MicroPython 1.9.4 and an 8x8 LED matrix)

- 3D parts: add diffuser grid and frame for square LED matrix displays
- Arduino projects needs to be in a folder with the same name as the .ino file
- config: allow multiple WiFi networks to be configured
- config: add support for debug flags
- config: add intensity configuration
- HAL: unify serial input processing for Arduino and Pycom devices
- HAL: handle UART write failures on Pycom devices
- HAL: drop garbage collection from .update_display() because it takes several
  hundred milliseconds on 4MB devices
- MCU: clear display when enabling/disabling MCU independence from host
- PixelFont: move data to class attributes to reduce memory usage
- PixelFont: add more characters
- PixelFont: move data generation to scripts/generate-pixelfont.py
- LedMatrix: support LED matrixes with strides other than 8 (e.g. as 16x16 matrices)
- LedMatrix: add method to render text
- LedMatrix: let consumers handle brightness themselves
- AnimationScene: MicroPython does not implement bytearray.find
- AnimationScene: ensure minimum on-screen time
- BootScene: wifi connection and RTC sync progress for Pycom devices
- ClockScene: delete unused code, switch to generic text rendering method
- FireScene: classical fire effect
- WeatherScene: bug fixes, switch to generic text rendering method
- WeatherScene: ensure minimum on-screen time
- WeatherScene: use custom JSON parsing to reduce memory usage
2018-12-26 20:26:05 +00:00

174 lines
5.1 KiB
Python
Executable File

# The game looop
import time
import gc
from math import ceil
if not hasattr(time, 'ticks_ms'):
# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
time.ticks_ms = lambda: int(time.time() * 1000)
time.sleep_ms = lambda x: time.sleep(x/1000.0)
class RenderLoop:
def __init__(self, display, config=None):
self.display = display
self.debug = False
self.fps = display.fps
self.t_next_frame = None
self.prev_frame = 0
self.frame = 1
self.t_init = time.ticks_ms()
self.scenes = []
self.scene_index = 0
self.scene_switch_effect = 0
self.scene_switch_countdown = self.fps * 40
self.display.clear()
if not config:
return
if 'debug' in config:
self.debug = config['debug']
if 'sceneTimeout' in config:
self.scene_switch_countdown = self.fps * config['sceneTimeout']
def add_scene(self, scene):
"""
Add new scene to the render loop.
Called by main.py.
"""
self.scenes.append(scene)
def next_frame(self, button_state=0):
"""
Display next frame, possibly after a delay to ensure we meet the FPS target
Called by main.py.
"""
scene = self.scenes[self.scene_index]
# Process input
if button_state:
# Let the scene handle input
handled_bit = scene.input(button_state)
button_state &= ~handled_bit
# Use long-pressed buttons to handle intensity changes
if button_state & 0x22:
clear = 0
for s in self.scenes:
if hasattr(s, 'set_intensity'):
i = s.set_intensity()
if button_state & 0x02:
i -= 2
clear = 0x02
elif button_state & 0x20:
i += 2
clear = 0x20
i = (i + 32) % 32
s.set_intensity(i)
button_state &= ~clear
if self.debug:
print('RenderLoop: updated intensity to {} on scenes, remaining state: {}'.format(i, button_state))
# Calculate how much we need to wait before rendering the next frame
t_now = time.ticks_ms() - self.t_init
if not self.t_next_frame:
self.t_next_frame = t_now
delay = self.t_next_frame - t_now
if delay >= 0:
# Wait until we can display next frame
time.sleep_ms(delay)
else:
# Resynchronize
num_dropped_frames = ceil(-delay*self.fps/1000)
if self.debug:
print('RenderLoop: FPS {} too high, should\'ve rendered frame {} at {}ms but was {}ms late and dropped {} frames'.format(self.fps, self.frame, self.t_next_frame, -delay, num_dropped_frames))
self.frame += num_dropped_frames
self.t_next_frame += ceil(1000*num_dropped_frames/self.fps)
if self.debug:
print('RenderLoop: Updated frame counters to frame {} with current next at {}'.format(self.frame, self.t_next_frame))
# Let the scene render its frame
t = time.ticks_ms()
loop_again = scene.render(self.frame, self.frame - self.prev_frame - 1, self.fps)
t = time.ticks_ms() - t
if t > 1000/self.fps and self.debug:
print('RenderLoop: WARN: Spent {}ms rendering'.format(t))
# Consider switching scenes and update frame counters
self.scene_switch_countdown -= 1
scene_increment = 1
if not loop_again:
self.scene_switch_countdown = 0
elif button_state:
self.scene_switch_countdown = 0
if button_state & 0x1:
scene_increment = -1
if not self.scene_switch_countdown:
self.reset_scene_switch_counter()
# Transition to next scene
self.next_scene(scene_increment, button_state)
# Account for time wasted above
t_new = time.ticks_ms() - self.t_init
t_diff = t_new - t_now
frames_wasted = ceil(t_diff*self.fps/1000.0)
if self.debug:
print('RenderLoop: setup: scene switch took {}ms, original t {}ms, new t {}ms, spent {} frames'.format(t_diff, t_now,t_new, self.fps*t_diff/1000.0))
self.frame += int(frames_wasted)
self.t_next_frame += int(1000.0 * frames_wasted / self.fps)
self.prev_frame = self.frame
self.frame += 1
self.t_next_frame += int(1000/self.fps)
def reset_scene_switch_counter(self):
"""
Reset counter used to automatically switch scenes.
The counter is decreased in .next_frame()
"""
self.scene_switch_countdown = 45 * self.fps
def next_scene(self, increment=1, button_state=0):
"""
Transition to a new scene and re-initialize the scene
"""
if len(self.scenes) < 2:
return
print('RenderLoop: next_scene: transitioning scene')
# Fade out current scene
t0 = time.ticks_ms()
if button_state & 0x01:
self.display.hscroll(-4)
button_state &= ~0x01
elif button_state & 0x10:
self.display.hscroll(4)
button_state &= ~0x10
else:
effect = self.scene_switch_effect
self.scene_switch_effect = (effect + 1) % 4
if effect == 0:
self.display.vscroll()
elif effect == 1:
self.display.hscroll()
elif effect == 2:
self.display.fade()
else:
self.display.dissolve()
t2 = time.ticks_ms()
t1 = t2 - t0
gc.collect()
t3 = time.ticks_ms()
t2 = t3 - t1
num_scenes = len(self.scenes)
i = self.scene_index = (num_scenes + self.scene_index + increment) % num_scenes
# (Re-)initialize scene
self.scenes[i].reset()
t4 = time.ticks_ms()
t3 = t4 - t3
if self.debug:
print('RenderLoop: next_scene: selected {}, effect {}ms, gc {}ms, scene reset {}ms, total {}ms'.format(self.scenes[i].__class__.__name__, t1, t2, t3, t4-t0))
return button_state