Reduce onroad UI work and move parameter refresh off render thread

This commit is contained in:
firestar5683
2026-09-22 08:49:27 -05:00
parent 7f0c5673b4
commit cdc6b3bd68
15 changed files with 1010 additions and 159 deletions
+32 -4
View File
@@ -65,7 +65,7 @@ OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offli
def _raylib_target_fps(fps: int) -> int:
return 0 if OFFSCREEN else fps
return 0 if OFFSCREEN or (DEVICE_TYPE == "mici" and not PC) else fps
GL_VERSION = """
#version 300 es
@@ -195,6 +195,14 @@ class MouseEvent(NamedTuple):
t: float
class FrameTiming(NamedTuple):
frame_ms: float = 0.0
cpu_ms: float = 0.0
draw_ms: float = 0.0
update_ms: float = 0.0
present_ms: float = 0.0
class DesktopMouseSample(NamedTuple):
pos: MousePos
left_pressed: bool
@@ -433,7 +441,7 @@ class MouseState:
self._append_mouse_event(event)
return
left_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) # noqa: TID251
left_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
left_pressed = (
rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) or # noqa: TID251
(left_down and not self._desktop_left_down)
@@ -525,6 +533,7 @@ class GuiApplication:
self._last_fps_log_time: float = time.monotonic()
self._burn_in_start_time = time.monotonic()
self._frame = 0
self.frame_timing = FrameTiming()
self._window_close_requested = False
self._nav_stack: list[object] = []
self._nav_stack_ticks: list[Callable[[], None]] = []
@@ -1031,6 +1040,8 @@ class GuiApplication:
self._render_profiler.enable()
while not (self._window_close_requested or rl.window_should_close()):
frame_start = time.monotonic()
cpu_start = time.thread_time()
self._mark_progress("gui_app.loop_start")
self._apply_render_mode()
if PC:
@@ -1087,12 +1098,18 @@ class GuiApplication:
# Only render top widgets
self._mark_progress("gui_app.before_widget_render")
for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]:
viewport = rl.Rectangle(0, 0, self.width, self.height)
widgets = self._nav_stack[-self._nav_stack_widgets_to_render:]
if len(widgets) > 1 and widgets[-1].covers_background(viewport):
widgets = widgets[-1:]
for widget in widgets:
widget.render(rl.Rectangle(0, 0, self.width, self.height))
self._mark_progress("gui_app.after_widget_render")
self._mark_progress("gui_app.frame_ready")
draw_end = time.monotonic()
yield True
update_end = time.monotonic()
if needs_render_transform:
rl.rl_pop_matrix()
@@ -1136,6 +1153,7 @@ class GuiApplication:
self._mark_progress("gui_app.before_end_drawing")
rl.end_drawing()
self._mark_progress("gui_app.after_end_drawing")
present_end = time.monotonic()
self._populate_render_texture_cache()
if RECORD:
@@ -1145,6 +1163,13 @@ class GuiApplication:
self._ffmpeg_queue.put(data) # Async write via background thread
rl.unload_image(image)
self.frame_timing = FrameTiming(
(time.monotonic() - frame_start) * 1000,
(time.thread_time() - cpu_start) * 1000,
(draw_end - frame_start) * 1000,
(update_end - draw_end) * 1000,
(present_end - update_end) * 1000,
)
self._monitor_fps()
self._frame += 1
self._mark_progress("gui_app.loop_idle")
@@ -1282,7 +1307,10 @@ class GuiApplication:
if fps < self._target_fps * FPS_DROP_THRESHOLD:
current_time = time.monotonic()
if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL:
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}")
timing = self.frame_timing
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps} " +
f"(frame={timing.frame_ms:.1f}ms cpu={timing.cpu_ms:.1f}ms " +
f"draw={timing.draw_ms:.1f}ms update={timing.update_ms:.1f}ms present={timing.present_ms:.1f}ms)")
self._last_fps_log_time = current_time
# Strict mode: terminate UI if FPS drops too much
+7 -7
View File
@@ -186,7 +186,7 @@ def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
def triangulate(pts: np.ndarray) -> np.ndarray:
"""Only supports simple polygons with two chains (ribbon)."""
# interleave points to produce a triangle strip
@@ -194,10 +194,10 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
pts = pts[:-1]
half = len(pts) // 2
tri_strip = np.empty_like(pts)
tri_strip = np.empty(pts.shape, dtype=np.float32)
tri_strip[0::2] = pts[:half]
tri_strip[1::2] = pts[half:][::-1]
return cast(list, tri_strip.tolist())
return tri_strip
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
@@ -210,15 +210,15 @@ def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
if len(points) < 3:
return
# Ensure (N,2) float32 contiguous array
pts = np.ascontiguousarray(points, dtype=np.float32)
pts = np.asarray(points)
assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)"
# Triangulate via interleaving
tri_strip = triangulate(pts)
vertices = rl.ffi.from_buffer("Vector2 *", tri_strip)
if gradient is None:
rl.draw_triangle_strip(tri_strip, len(tri_strip), color or rl.WHITE)
rl.draw_triangle_strip(vertices, len(tri_strip), color or rl.WHITE)
return
state = ShaderState.get_instance()
@@ -228,7 +228,7 @@ def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.draw_triangle_strip(vertices, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
+68 -1
View File
@@ -1,12 +1,23 @@
from importlib.resources import as_file
from types import SimpleNamespace
import pytest
from openpilot.system.ui.lib import application
def test_raylib_target_fps_limits_mici(monkeypatch):
def test_raylib_target_fps_uses_mici_display_refresh(monkeypatch):
monkeypatch.setattr(application, "OFFSCREEN", False)
monkeypatch.setattr(application, "DEVICE_TYPE", "mici")
monkeypatch.setattr(application, "PC", False)
assert application._raylib_target_fps(60) == 0
def test_raylib_target_fps_limits_mici_desktop_preview(monkeypatch):
monkeypatch.setattr(application, "OFFSCREEN", False)
monkeypatch.setattr(application, "DEVICE_TYPE", "mici")
monkeypatch.setattr(application, "PC", True)
assert application._raylib_target_fps(60) == 60
@@ -25,6 +36,62 @@ def test_raylib_target_fps_disables_limit_for_offscreen(monkeypatch):
assert application._raylib_target_fps(60) == 0
@pytest.mark.parametrize("covered", [False, True])
def test_render_preserves_visible_layers_and_measures_complete_frame(monkeypatch, covered):
monkeypatch.setattr(application, "PC", False)
monkeypatch.setattr(application, "RECORD", False)
monkeypatch.setattr(application.GuiApplication, "_set_log_callback", lambda _: None)
app = application.GuiApplication(536, 240)
app._scale = 1.0
app._nav_stack_widgets_to_render = 2
app._mouse = SimpleNamespace(get_events=list)
app._burn_in_shift = lambda: (0, 0)
app._monitor_fps = lambda: None
app._show_fps = app._show_touches = False
app._grid_size = app._profile_render_frames = 0
clock = SimpleNamespace(wall=10.0, cpu=1.0)
monkeypatch.setattr(application.time, "monotonic", lambda: clock.wall)
monkeypatch.setattr(application.time, "thread_time", lambda: clock.cpu)
monkeypatch.setattr(application.rl, "window_should_close", lambda: False)
monkeypatch.setattr(application.rl, "begin_drawing", lambda: None)
monkeypatch.setattr(application.rl, "clear_background", lambda _: None)
rendered = []
def draw(name):
rendered.append(name)
clock.wall += 0.004
clock.cpu += 0.003
def present():
clock.wall += 0.011
clock.cpu += 0.001
def populate_cache():
clock.wall += 0.003
clock.cpu += 0.002
monkeypatch.setattr(application.rl, "end_drawing", present)
app._populate_render_texture_cache = populate_cache
app._nav_stack = [
SimpleNamespace(render=lambda _: draw("road")),
SimpleNamespace(covers_background=lambda _: covered, render=lambda _: draw("panel")),
]
frames = app.render()
assert next(frames)
clock.wall += 0.002
clock.cpu += 0.001
app.request_close()
with pytest.raises(StopIteration):
next(frames)
assert rendered == (["panel"] if covered else ["road", "panel"])
draws = len(rendered)
assert app.frame_timing == pytest.approx(application.FrameTiming(
draws * 4 + 16, draws * 3 + 4, draws * 4, 2, 11,
))
def test_burn_in_shift_transitions_between_positions(monkeypatch):
app = object.__new__(application.GuiApplication)
app._burn_in_start_time = 100.0
@@ -1,5 +1,6 @@
import pyray as rl
import pytest
from types import SimpleNamespace
from openpilot.system.ui import widgets
from openpilot.system.ui.lib import scroll_panel2
@@ -32,7 +33,7 @@ def make_scroller(monkeypatch):
monkeypatch.setattr(gui_app, "_show_touches", False)
monkeypatch.setattr(gui_app, "_mouse_events", [])
monkeypatch.setattr(widgets, "PC", False)
monkeypatch.setattr(widgets.device, "awake", True)
monkeypatch.setattr(widgets, "device", SimpleNamespace(awake=True))
monkeypatch.setattr(scroll_panel2, "TICI", True)
def make(items=None, **kwargs):
+56 -5
View File
@@ -1,6 +1,10 @@
import numpy as np
import pyray as rl
import pytest
from types import SimpleNamespace
from openpilot.system.ui.lib.shader_polygon import triangulate
from openpilot.system.ui.lib import shader_polygon
from openpilot.system.ui.lib.shader_polygon import Gradient, draw_polygon, triangulate
def test_triangulate_interleaves_polygon_chains():
@@ -13,11 +17,11 @@ def test_triangulate_interleaves_polygon_chains():
[10.0, 100.0],
], dtype=np.float32)
assert triangulate(points) == [
np.testing.assert_array_equal(triangulate(points), [
[1.0, 10.0], [10.0, 100.0],
[2.0, 20.0], [20.0, 200.0],
[3.0, 30.0], [30.0, 300.0],
]
])
def test_triangulate_drops_unpaired_last_point():
@@ -29,7 +33,54 @@ def test_triangulate_drops_unpaired_last_point():
[99.0, 99.0],
], dtype=np.float32)
assert triangulate(points) == [
np.testing.assert_array_equal(triangulate(points), [
[1.0, 10.0], [10.0, 100.0],
[2.0, 20.0], [20.0, 200.0],
]
])
@pytest.mark.parametrize("points", [
np.arange(132, dtype=np.float32).reshape(-1, 2),
np.arange(132, dtype=np.float64).reshape(-1, 2),
np.arange(264, dtype=np.float64).reshape(-1, 2)[::2, ::-1],
np.asfortranarray(np.arange(132, dtype=np.float32).reshape(-1, 2)),
])
def test_triangulate_preserves_coordinates_in_contiguous_float_buffer(points):
original = points.copy()
points.flags.writeable = False
strip = triangulate(points)
expected = [point for pair in zip(points[:len(points) // 2], points[len(points) // 2:][::-1], strict=True) for point in pair]
assert strip.dtype == np.float32
assert strip.flags.c_contiguous
np.testing.assert_array_equal(strip, np.asarray(expected, dtype=np.float32))
np.testing.assert_array_equal(points, original)
@pytest.mark.parametrize("use_gradient", [False, True])
@pytest.mark.parametrize("point_count", [4, 5, 66])
def test_draw_polygon_passes_native_vertices(monkeypatch, use_gradient, point_count):
points = np.arange(point_count * 4, dtype=np.float64).reshape(-1, 2)[::2]
rect = rl.Rectangle(0, 0, 100, 100)
color = rl.Color(10, 20, 30, 40)
gradient = Gradient((0, 0), (1, 1), [color, rl.Color(*rl.WHITE)], [0, 1]) if use_gradient else None
calls = []
state = SimpleNamespace(initialize=lambda: calls.append("initialize"), shader="shader")
def draw_strip(vertices, count, fill):
assert rl.ffi.typeof(vertices) == rl.ffi.typeof("Vector2 *")
coordinates = [[vertices[i].x, vertices[i].y] for i in range(count)]
np.testing.assert_array_equal(coordinates, triangulate(points))
assert fill == (rl.WHITE if use_gradient else color)
calls.append("draw")
monkeypatch.setattr(shader_polygon.ShaderState, "get_instance", lambda: state)
monkeypatch.setattr(shader_polygon, "_configure_shader_color", lambda *args: calls.append(("configure", args)))
monkeypatch.setattr(rl, "begin_shader_mode", lambda shader: calls.append(("begin", shader)))
monkeypatch.setattr(rl, "end_shader_mode", lambda: calls.append("end"))
monkeypatch.setattr(rl, "draw_triangle_strip", draw_strip)
draw_polygon(rect, points, gradient=gradient, color=None if use_gradient else color)
assert calls == (["initialize", ("configure", (state, None, gradient, rect)), ("begin", "shader"), "draw", "end"]
if use_gradient else ["draw"])