From ed2abcc4b985a9433cff2ef32c872800ac440469 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:55:36 -0400 Subject: [PATCH] Torque Bar Optimization + Gradient --- openpilot/starpilot/ui/onroad.py | 3 + openpilot/starpilot/ui/onroad_torque.py | 181 ++---------------- openpilot/starpilot/ui/onroad_torque_draw.py | 143 ++++++++++++++ .../starpilot/ui/tests/test_onroad_axes.py | 12 +- .../ui/tests/test_onroad_torque_draw.py | 129 +++++++++++++ .../ui/tests/test_onroad_torque_layout.py | 69 +++---- 6 files changed, 328 insertions(+), 209 deletions(-) create mode 100644 openpilot/starpilot/ui/onroad_torque_draw.py create mode 100644 openpilot/starpilot/ui/tests/test_onroad_torque_draw.py diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py index 60d1c9e34c..96d56f463f 100644 --- a/openpilot/starpilot/ui/onroad.py +++ b/openpilot/starpilot/ui/onroad.py @@ -143,6 +143,7 @@ class OnroadView: from openpilot.starpilot.ui.onroad_now_playing import NowPlayingWidget self.now_playing = NowPlayingWidget(fonts) self.torque_bar = TorqueBarWidget() + gui_app.add_render_prepare(self.torque_bar.prepare, self.torque_bar.close) if fonts.profile == Profile.LARGE: self.unified_speed = UnifiedSpeedWidget(fonts) self.current_speed = CurrentSpeedHud(fonts) @@ -584,6 +585,8 @@ class OnroadView: def close(self) -> None: gui_app.remove_render_prepare(self._corner_cache.prepare) self._corner_cache.close() + gui_app.remove_render_prepare(self.torque_bar.prepare) + self.torque_bar.close() self.navigation.close() if self.fonts.profile == Profile.LARGE: self.unified_speed.close() diff --git a/openpilot/starpilot/ui/onroad_torque.py b/openpilot/starpilot/ui/onroad_torque.py index 1aa9d408be..0a892cf6fc 100644 --- a/openpilot/starpilot/ui/onroad_torque.py +++ b/openpilot/starpilot/ui/onroad_torque.py @@ -1,162 +1,23 @@ -"""Adapted StarPilot torque-bar polygon with supplied utilization. +"""Shared StarPilot torque bar with caller-qualified utilization.""" -The arc polygon generator is retained from the frozen renderer under LICENSE. -""" - -import math -import time -from collections import OrderedDict -from functools import lru_cache, wraps - -import numpy as np import pyray as rl from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.system.ui.lib.shader_polygon import draw_polygon from openpilot.starpilot.ui.onroad_state import OnroadState from openpilot.starpilot.ui.onroad_customization import offset as widget_offset, placement -from openpilot.starpilot.ui.onroad_torque_geometry import ( - CAP_RADIUS, LARGE_CAP_RADIUS, MAX_HEIGHT, MAX_OFFSET, MIN_HEIGHT, MIN_OFFSET, RADIUS, TORQUE_ANGLE_SPAN, -) +from openpilot.starpilot.ui.onroad_torque_draw import TorqueBarPainter +from openpilot.starpilot.ui.onroad_torque_geometry import CAP_RADIUS, LARGE_CAP_RADIUS -DEBUG = False - -def quantized_lru_cache(maxsize=128): - def decorator(func): - cache = OrderedDict() - @wraps(func) - def wrapper(r_mid, thickness, a0_deg, a1_deg, **kwargs): - # Quantize inputs: balanced for smoothness vs cache effectiveness - key = (round(r_mid), - round(thickness), # 1px precision for smoother height transitions - round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions - round(a1_deg * 10) / 10, - tuple(sorted(kwargs.items()))) - - if key in cache: - cache.move_to_end(key) - else: - if len(cache) >= maxsize: - cache.popitem(last=False) - - result = func(r_mid, thickness, a0_deg, a1_deg, **kwargs) - cache[key] = result - return cache[key] - return wrapper - return decorator - - -@lru_cache(maxsize=16) -def _cap_vectors(left: bool, segments: int): - # These quarter-circle samples depend only on tessellation, not live torque, - # radius or widget placement. Reuse them when the arc geometry cache misses. - start, end = (180, 90) if left else (90, 0) - alpha = np.deg2rad(np.linspace(start, end, segments + 2))[1:-1] - start, end = (-90, -180) if left else (0, -90) - alpha2 = np.deg2rad(np.linspace(start, end, segments + 1))[:-1] - vectors = (np.cos(alpha), np.sin(alpha), np.cos(alpha2), np.sin(alpha2)) - for vector in vectors: - vector.flags.writeable = False - return vectors - - -@quantized_lru_cache(maxsize=256) -def arc_bar_pts(r_mid: float, thickness: float, - a0_deg: float, a1_deg: float, - *, max_points: int = 100, cap_segs: int = 10, - cap_radius: float = CAP_RADIUS, px_per_seg: float = 2.0) -> np.ndarray: - """Return Nx2 np.float32 points for a single closed polygon (rounded thick arc), centered at origin.""" - - def get_cap(left: bool, a_deg: float): - # end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes) - # quarter arc (outer corner) at a1 with fixed pixel radius cap_radius - - nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal - tx, ty = -ny, nx # tangent (CCW) - - mx, my = nx * r_mid, ny * r_mid # mid-point at a1 - if DEBUG: - rl.draw_circle(int(mx), int(my), 4, rl.PURPLE) - - ex = mx + nx * (half - cap_radius) - ey = my + ny * (half - cap_radius) - - if DEBUG: - rl.draw_circle(int(ex), int(ey), 2, rl.WHITE) - - # sweep 90° in the local (t,n) frame: from outer edge toward inside - cos_a, sin_a, cos_b, sin_b = _cap_vectors(left, cap_segs) - cap_end = np.column_stack((ex + cos_a * cap_radius * tx + sin_a * cap_radius * nx, - ey + cos_a * cap_radius * ty + sin_a * cap_radius * ny)) - - # bottom quarter (inner corner) at a1 - ex2 = mx + nx * (-half + cap_radius) - ey2 = my + ny * (-half + cap_radius) - if DEBUG: - rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE) - - cap_end_bot = np.column_stack((ex2 + cos_b * cap_radius * tx + sin_b * cap_radius * nx, - ey2 + cos_b * cap_radius * ty + sin_b * cap_radius * ny)) - - # append to the top quarter - if not left: - cap_end = np.vstack((cap_end, cap_end_bot)) - else: - cap_end = np.vstack((cap_end_bot, cap_end)) - - return cap_end - - if a1_deg < a0_deg: - a0_deg, a1_deg = a1_deg, a0_deg - half = thickness * 0.5 - - cap_radius = min(cap_radius, half) - - span = max(1e-3, a1_deg - a0_deg) - - # pick arc segment count from arc length, clamp to shader points[] budget - arc_len = r_mid * math.radians(span) - arc_segs = max(6, int(arc_len / px_per_seg)) - max_arc = (max_points - (4 * cap_segs + 3)) // 2 - arc_segs = max(6, min(arc_segs, max_arc)) - - # outer arc a0→a1 - ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1)) - outer = np.column_stack((np.cos(ang_o) * (r_mid + half), - np.sin(ang_o) * (r_mid + half))) - - # end cap at a1 - cap_end = get_cap(False, a1_deg) - - # inner arc a1→a0 - ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1)) - inner = np.column_stack((np.cos(ang_i) * (r_mid - half), - np.sin(ang_i) * (r_mid - half))) - - # start cap at a0 - cap_start = get_cap(True, a0_deg) - - pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32) - - # Rotate to start from middle of cap for proper triangulation - pts = np.roll(pts, cap_segs, axis=0) - - if DEBUG: - n = len(pts) - idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec - for i, (x, y) in enumerate(pts): - j = (i - idx) % n # rotate the gradient - t = j / n - color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255) - rl.draw_circle(int(x), int(y), 2, color) - - return pts class TorqueBarWidget: def __init__(self): self._torque_filter = FirstOrderFilter(0.0, 0.1, 1 / 60) self._alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / 60) self._drive_frame = None + self._painter = TorqueBarPainter() + + def prepare(self) -> None: + self._painter.prepare() def render(self, rect: rl.Rectangle, state: OnroadState, screen_width: float) -> None: drive_frame = getattr(state, 'torque_drive_frame', None) @@ -172,28 +33,10 @@ class TorqueBarWidget: utilization = max(-1.0, min(1.0, state.torque_utilization)) torque = self._torque_filter.update(utilization) alpha = self._alpha_filter.update(1.0) - scale = rect.height / 240.0 * (rect.width / screen_width) - torque_offset = float(np.interp(abs(torque), [0.5, 1.0], [MIN_OFFSET * scale, MAX_OFFSET * scale])) - torque_height = float(np.interp(abs(torque), [0.5, 1.0], [MIN_HEIGHT * scale, MAX_HEIGHT * scale])) - radius = RADIUS * scale - span = alpha * TORQUE_ANGLE_SPAN - middle_radius = radius + torque_height / 2 - center_x = rect.x + rect.width / 2 + 8 - center_y = rect.y + rect.height + radius - torque_offset - offset = np.array([center_x, center_y], dtype=np.float32) - dx, dy = widget_offset(state.customization, profile, 'torque_bar') - translation = np.array([dx, dy], dtype=np.float32) - draw_rect = rl.Rectangle(rect.x + dx, rect.y + dy, rect.width, rect.height) - cap_radius = LARGE_CAP_RADIUS if profile == 'large' else CAP_RADIUS - points = arc_bar_pts(middle_radius, torque_height, -90 - span / 2, -90 + span / 2, cap_radius=cap_radius) + offset if not enabled: return - bg_alpha = float(np.interp(abs(torque), [0.5, 1.0], [0.25, 0.5])) - draw_polygon(draw_rect, points + translation, color=rl.Color(255, 255, 255, int(255 * bg_alpha * alpha))) - indicator_end = -90 + span / 2 * torque - indicator = arc_bar_pts(middle_radius, torque_height, -90, indicator_end, cap_radius=cap_radius) + offset - indicator_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha)) - draw_polygon(draw_rect, indicator + translation, color=indicator_color) - if abs(torque) < 0.5: - dot_y = rect.y + rect.height - torque_offset - torque_height / 2 - rl.draw_circle(int(center_x + dx), int(dot_y + dy), 5 * scale, rl.Color(182, 182, 182, int(255 * 0.9 * alpha))) + cap_radius = LARGE_CAP_RADIUS if profile == 'large' else CAP_RADIUS + self._painter.render(rect, torque, alpha, screen_width, cap_radius, widget_offset(state.customization, profile, 'torque_bar')) + + def close(self) -> None: + self._painter.close() diff --git a/openpilot/starpilot/ui/onroad_torque_draw.py b/openpilot/starpilot/ui/onroad_torque_draw.py new file mode 100644 index 0000000000..5a8a316bea --- /dev/null +++ b/openpilot/starpilot/ui/onroad_torque_draw.py @@ -0,0 +1,143 @@ +"""Antialiased torque arc, painted in one bounded pass without a polygon cache.""" + +import math + +import pyray as rl + +from openpilot.system.ui.lib.application import GL_VERSION +from openpilot.starpilot.ui.onroad_torque_geometry import MAX_HEIGHT, MAX_OFFSET, MIN_HEIGHT, MIN_OFFSET, RADIUS, TORQUE_ANGLE_SPAN + + +VERTEX_SHADER = GL_VERSION + """ +in vec3 vertexPosition; +in vec2 vertexTexCoord; +uniform mat4 mvp; +out vec2 position; +void main() { + position = vertexTexCoord; + gl_Position = mvp * vec4(vertexPosition, 1.0); +} +""" + +FRAGMENT_SHADER = GL_VERSION + """ +in vec2 position; +out vec4 finalColor; +uniform vec4 geometry; // center, middle radius, half thickness +uniform vec4 backgroundNormals; +uniform vec4 indicatorNormals; +uniform vec4 style; // cap radius, background alpha, indicator alpha, dot alpha +uniform vec4 dotGeometry; // center, radius, reserved +uniform vec4 gradient; // signed inverse reach, center green, green change, blue + +float capDistance(vec2 p, vec2 normal) { + vec2 tangent = vec2(-normal.y, normal.x); + float radial = abs(dot(p, normal) - geometry.z); + return length(vec2(dot(p, tangent), max(radial - (geometry.w - style.x), 0.0))) - style.x; +} +float barDistance(vec2 p, vec4 normals, float ringDistance) { + vec2 tangent0 = vec2(-normals.y, normals.x); + vec2 tangent1 = vec2(-normals.w, normals.z); + float sector = max(ringDistance, max(-dot(p, tangent0), dot(p, tangent1))); + return min(sector, min(capDistance(p, normals.xy), capDistance(p, normals.zw))); +} +float coverage(float distance) { + return clamp(0.5 - distance / max(fwidth(distance), 0.0001), 0.0, 1.0); +} +void main() { + vec2 p = position - geometry.xy; + float ringDistance = abs(length(p) - geometry.z) - geometry.w; + float b = coverage(barDistance(p, backgroundNormals, ringDistance)) * style.y; + float i = coverage(barDistance(p, indicatorNormals, ringDistance)) * style.z; + float d = coverage(length(position - dotGeometry.xy) - dotGeometry.z) * style.w; + float bi = b + i * (1.0 - b); + float alpha = d + bi * (1.0 - d); + float t = clamp(p.x * gradient.x, 0.0, 1.0); + vec3 indicatorColor = vec3(1.0, gradient.y + gradient.z * t, gradient.w); + vec3 premultiplied = (vec3(b * (1.0 - i)) + indicatorColor * i) * (1.0 - d) + vec3((182.0 / 255.0) * d); + finalColor = vec4(premultiplied / max(alpha, 0.00001), alpha); +} +""" + + +class TorqueBarPainter: + def __init__(self): + self._shader = None + self._uniforms = {} + self._previous = {} + + def prepare(self) -> None: + if self._shader is not None: + return + shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER) + names = ('geometry', 'backgroundNormals', 'indicatorNormals', 'style', 'dotGeometry', 'gradient') + locations = {name: rl.get_shader_location(shader, name) for name in names} + if not shader.id or any(location < 0 for location in locations.values()): + if shader.id and shader.id != rl.rl_get_shader_id_default(): + rl.unload_shader(shader) + raise RuntimeError('Torque bar shader unavailable') + self._uniforms = {name: (location, rl.ffi.new('float[4]')) for name, location in locations.items()} + self._previous.clear() + self._shader = shader + + def render(self, rect: rl.Rectangle, torque: float, alpha: float, screen_width: float, cap_radius: float, + offset: tuple[float, float] = (0, 0)) -> None: + if alpha <= 0: + return + self.prepare() + scale = rect.height / 240.0 * (rect.width / screen_width) + strength = max(0.0, min(1.0, (abs(torque) - 0.5) * 2)) + torque_offset = MIN_OFFSET * scale + (MAX_OFFSET * scale - MIN_OFFSET * scale) * strength + thickness = MIN_HEIGHT * scale + (MAX_HEIGHT * scale - MIN_HEIGHT * scale) * strength + radius = RADIUS * scale + middle_radius = radius + thickness / 2 + cap = min(cap_radius, thickness / 2) + cx = rect.x + rect.width / 2 + 8 + offset[0] + cy = rect.y + rect.height + radius - torque_offset + offset[1] + half_span = math.radians(alpha * TORQUE_ANGLE_SPAN / 2) + sine, cosine = math.sin(half_span), math.cos(half_span) + end_sine, end_cosine = math.sin(half_span * torque), math.cos(half_span * torque) + start, end = ((end_sine, -end_cosine), (0, -1)) if torque < 0 else ((0, -1), (end_sine, -end_cosine)) + dot_y = rect.y + rect.height - torque_offset - thickness / 2 + offset[1] + indicator_alpha = int(255 * 0.9 * alpha) / 255 + warmth = max(0.0, min(1.0, (abs(torque) - 0.75) * 4)) + extent = (middle_radius + thickness / 2 - cap) * sine + cap + reach = max(0.65 * extent, 0.0001) + values = { + 'geometry': (cx, cy, middle_radius, thickness / 2), + 'backgroundNormals': (-sine, -cosine, sine, -cosine), + 'indicatorNormals': (*start, *end), + 'style': (cap, int(255 * (0.25 + 0.25 * strength) * alpha) / 255, + indicator_alpha, indicator_alpha if abs(torque) < 0.5 else 0), + 'dotGeometry': (int(cx), int(dot_y), 5 * scale, 0), + 'gradient': (math.copysign(1 / reach, torque), 1 - warmth + warmth * (115 / 255), + warmth * (85 / 255), 1 - warmth), + } + # Cover subpixel edge samples without allocating an offscreen surface, including desktop downscaling. + extent += 4 + top = rect.y + rect.height - torque_offset - thickness + offset[1] - 4 + bottom = cy - (radius + cap) * cosine + cap + 4 + rl.begin_shader_mode(self._shader) + try: + for name, value in values.items(): + if self._previous.get(name) != value: + location, pointer = self._uniforms[name] + pointer[0:4] = value + rl.set_shader_value(self._shader, location, pointer, rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4) + self._previous[name] = value + # rlgl may transform positions on the CPU; UVs keep distance calculations in logical pixels. + left, right = cx - extent, cx + extent + rl.rl_set_texture(0) + rl.rl_begin(rl.RL_QUADS) + for x, y in ((left, top), (left, bottom), (right, bottom), (right, top)): + rl.rl_tex_coord2f(x, y) + rl.rl_vertex2f(x, y) + rl.rl_end() + finally: + rl.end_shader_mode() + + def close(self) -> None: + if self._shader is not None and rl.is_window_ready(): + rl.unload_shader(self._shader) + self._shader = None + self._uniforms.clear() + self._previous.clear() diff --git a/openpilot/starpilot/ui/tests/test_onroad_axes.py b/openpilot/starpilot/ui/tests/test_onroad_axes.py index 0ba5da7750..619a71912e 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_axes.py +++ b/openpilot/starpilot/ui/tests/test_onroad_axes.py @@ -21,6 +21,7 @@ from openpilot.starpilot.ui.preview_shell import reference_onroad from openpilot.starpilot.ui.runtime_snapshot import RuntimeSnapshotAdapter from openpilot.starpilot.ui.shell import ShellMode from openpilot.starpilot.ui.tests.test_runtime_snapshot import NOW, ui_fake +from openpilot.starpilot.ui.tests.test_onroad_torque_draw import record_gpu def rgba(color: rl.Color) -> tuple[int, int, int, int]: @@ -354,13 +355,12 @@ class TestOnroadAxes(unittest.TestCase): self.assertEqual(compact._set_speed_opacity(stale, compact._last_cruise_ns + 150_000_001), 0) self.assertEqual(compact._set_speed_alpha.x, 0) torque = TorqueBarWidget() - with patch("openpilot.starpilot.ui.onroad_torque.draw_polygon") as draw, \ - patch("openpilot.starpilot.ui.onroad_torque.rl.draw_circle"): + with record_gpu() as gpu: torque.render(rl.Rectangle(0, 0, 476, 240), active, 536) self.assertGreater(torque._alpha_filter.x, 0) - draw.reset_mock() + gpu.frames.clear() torque.render(rl.Rectangle(0, 0, 476, 240), stale, 536) - draw.assert_not_called() + self.assertFalse(gpu.frames) self.assertEqual(torque._alpha_filter.x, 0) def test_missing_actual_torque_output_stays_hidden_with_active_axes(self): @@ -369,9 +369,9 @@ class TestOnroadAxes(unittest.TestCase): self.assertTrue(state.lateral_active) self.assertFalse(state.torque_source_available) torque = TorqueBarWidget() - with patch("openpilot.starpilot.ui.onroad_torque.draw_polygon") as draw: + with record_gpu() as gpu: torque.render(rl.Rectangle(0, 0, 476, 240), state, 536) - draw.assert_not_called() + self.assertFalse(gpu.frames or gpu.loads) self.assertEqual(torque._alpha_filter.x, 0) def test_pressed_control_cancels_on_axis_transition_even_with_fresh_transport(self): diff --git a/openpilot/starpilot/ui/tests/test_onroad_torque_draw.py b/openpilot/starpilot/ui/tests/test_onroad_torque_draw.py new file mode 100644 index 0000000000..2eddad2e99 --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_onroad_torque_draw.py @@ -0,0 +1,129 @@ +from contextlib import contextmanager +from types import SimpleNamespace as NS +from unittest.mock import patch + +import pyray as rl +import pytest + +from openpilot.starpilot.ui.onroad_torque_draw import TorqueBarPainter + + +@contextmanager +def record_gpu(): + data = NS(uniforms={}, uploads=[], frames=[], loads=[], unloads=[]) + names, vertices, coordinates = {}, [], [] + + def upload(shader, location, values, kind): + name = next(name for name, value in names.items() if value == location) + data.uniforms[name] = tuple(values[0:4]) + data.uploads.append((name, int(rl.ffi.cast('uintptr_t', values)))) + + def finish(): + data.frames.append(NS(uniforms=dict(data.uniforms), vertices=tuple(vertices), coordinates=tuple(coordinates))) + vertices.clear() + coordinates.clear() + + with patch.object(rl, 'load_shader_from_memory', side_effect=lambda *_: data.loads.append(1) or NS(id=len(data.loads))), \ + patch.object(rl, 'get_shader_location', side_effect=lambda _, name: names.setdefault(name, len(names))), \ + patch.object(rl, 'set_shader_value', side_effect=upload), \ + patch.object(rl, 'begin_shader_mode'), patch.object(rl, 'end_shader_mode'), \ + patch.object(rl, 'rl_set_texture'), patch.object(rl, 'rl_begin'), \ + patch.object(rl, 'rl_tex_coord2f', side_effect=lambda x, y: coordinates.append((x, y))), \ + patch.object(rl, 'rl_vertex2f', side_effect=lambda x, y: vertices.append((x, y))), \ + patch.object(rl, 'rl_end', side_effect=finish), \ + patch.object(rl, 'unload_shader', side_effect=lambda shader: data.unloads.append(shader.id)), \ + patch.object(rl, 'is_window_ready', return_value=True), patch.object(rl, 'rl_get_shader_id_default', return_value=0): + yield data + + +def test_small_torque_changes_reach_the_screen_without_angle_steps(): + painter = TorqueBarPainter() + with record_gpu() as gpu: + rect = rl.Rectangle(0, 0, 476, 240) + painter.render(rect, .3, 1, 536, 7) + painter.render(rect, .3001, 1, 536, 7) + endpoints = [frame.uniforms['indicatorNormals'][2] for frame in gpu.frames] + assert 0 < endpoints[1] - endpoints[0] < .00002 + assert all(frame.vertices == frame.coordinates for frame in gpu.frames) + + +@pytest.mark.parametrize('rect,width,cap,torque,expected', [ + ((0, 0, 476, 240), 536, 7, .3, (246, 1286.134328, 1071.888060, 6.216418)), + ((0, 0, 476, 240), 536, 7, 1, (246, 1282.582090, 1090.537313, 24.865672)), + ((30, 30, 2100, 1020), 2160, 21, .3, (1088, 5917.430556, 4987.256944, 28.923611)), + ((30, 30, 2100, 1020), 2160, 21, 1, (1088, 5900.902778, 5074.027778, 115.694444)), + ((30, 30, 1800, 1020), 2160, 21, .3, (938, 5222.083333, 4274.791667, 24.791667)), + ((30, 30, 1800, 1020), 2160, 21, 1, (938, 5207.916667, 4349.166667, 99.166667)), +]) +def test_native_arc_dimensions_and_opacities_match_the_original_renderer(rect, width, cap, torque, expected): + with record_gpu() as gpu: + TorqueBarPainter().render(rl.Rectangle(*rect), torque, 1, width, cap) + frame, = gpu.frames + assert frame.uniforms['geometry'] == pytest.approx(expected, abs=.001) + assert tuple(round(value * 255) for value in frame.uniforms['style'][1:]) == ((63, 229, 229) if torque == .3 else (127, 229, 0)) + + +@pytest.mark.parametrize('rect,width,cap', [((0, 0, 476, 240), 536, 7), ((30, 30, 2100, 1020), 2160, 21)]) +@pytest.mark.parametrize('torque,warmth', [(.5, 0), (.75, 0), (.875, .5), (1, 1)]) +def test_gradient_colors_and_direction_for_both_ui_sizes(rect, width, cap, torque, warmth): + painter = TorqueBarPainter() + with record_gpu() as gpu: + painter.render(rl.Rectangle(*rect), torque, 1, width, cap) + painter.render(rl.Rectangle(*rect), -torque, 1, width, cap) + right, left = [frame.uniforms['gradient'] for frame in gpu.frames] + assert right[0] > 0 + assert left[0] == pytest.approx(-right[0]) + assert left[1:] == pytest.approx(right[1:]) + assert (1, right[1], right[3]) == pytest.approx((1, 1 - warmth * 140 / 255, 1 - warmth)) + assert (1, right[1] + right[2], right[3]) == pytest.approx((1, 1 - warmth * 55 / 255, 1 - warmth)) + + +def test_gradient_anchor_follows_the_bar_when_repositioned(): + painter = TorqueBarPainter() + with record_gpu() as gpu: + painter.render(rl.Rectangle(30, 30, 2100, 1020), 1, .6, 2160, 21) + painter.render(rl.Rectangle(130, 80, 2100, 1020), 1, .6, 2160, 21, (7, -4)) + first, second = gpu.frames + assert second.uniforms['gradient'] == pytest.approx(first.uniforms['gradient']) + assert second.uniforms['geometry'][0] - first.uniforms['geometry'][0] == pytest.approx(107) + assert second.uniforms['geometry'][1] - first.uniforms['geometry'][1] == pytest.approx(46) + assert second.uniforms['style'] == pytest.approx(first.uniforms['style']) + + +def test_shader_and_uniform_buffers_are_reused_and_released_before_reload(): + painter = TorqueBarPainter() + rect = rl.Rectangle(0, 0, 476, 240) + with record_gpu() as gpu: + painter.close() + painter.render(rect, .3, 1, 536, 7) + first_uploads = list(gpu.uploads) + painter.render(rect, .3, 1, 536, 7) + assert gpu.uploads == first_uploads + painter.render(rect, .3001, 1, 536, 7) + pointers = dict(first_uploads) + assert all(pointer == pointers[name] for name, pointer in gpu.uploads) + assert len(gpu.loads) == 1 + painter.close() + painter.close() + assert gpu.unloads == [1] + painter.render(rect, .3, 1, 536, 7) + assert len(gpu.loads) == 2 + assert len(gpu.uploads) == len(first_uploads) * 2 + 1 + painter.close() + assert gpu.unloads == [1, 2] + + +def test_invisible_bar_does_not_allocate_graphics_resources(): + with record_gpu() as gpu: + TorqueBarPainter().render(rl.Rectangle(0, 0, 476, 240), .3, 0, 536, 7) + assert not gpu.loads and not gpu.frames + + +@pytest.mark.parametrize('default_shader_id', [0, 1]) +def test_shader_failure_never_paints_the_bounding_rectangle(default_shader_id): + with record_gpu() as gpu, patch.object(rl, 'get_shader_location', return_value=-1), \ + patch.object(rl, 'rl_get_shader_id_default', return_value=default_shader_id): + with pytest.raises(RuntimeError, match='Torque bar shader unavailable'): + TorqueBarPainter().render(rl.Rectangle(0, 0, 476, 240), .3, 1, 536, 7) + assert not gpu.frames + assert gpu.unloads == ([1] if default_shader_id == 0 else []) diff --git a/openpilot/starpilot/ui/tests/test_onroad_torque_layout.py b/openpilot/starpilot/ui/tests/test_onroad_torque_layout.py index 18feadce79..a8808a31b6 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_torque_layout.py +++ b/openpilot/starpilot/ui/tests/test_onroad_torque_layout.py @@ -9,6 +9,7 @@ import pyray as rl from openpilot.starpilot.ui.onroad_customization import PROFILES, default_document, validate_document, widget_size from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation from openpilot.starpilot.ui.onroad_torque import TorqueBarWidget +from openpilot.starpilot.ui.tests.test_onroad_torque_draw import record_gpu def scene(document=None): @@ -21,17 +22,15 @@ def geometry(profile, state, torque=.3, alpha=1.): bounds = data['bounds'] rect = rl.Rectangle(*(bounds[key] for key in ('x', 'y', 'width', 'height'))) widget = TorqueBarWidget() - calls, dots = [], [] with patch.object(widget._torque_filter, 'update', return_value=torque), \ patch.object(widget._alpha_filter, 'update', return_value=alpha), \ - patch('openpilot.starpilot.ui.onroad_torque.draw_polygon', side_effect=lambda rect, points, **kwargs: calls.append((rect, points.copy(), kwargs))), \ - patch('openpilot.starpilot.ui.onroad_torque.rl.draw_circle', side_effect=lambda x, y, radius, color: dots.append((x, y, radius))): + record_gpu() as gpu: widget.render(rect, state, data['width']) - return calls, dots + return gpu.frames class TestTorqueLayout(unittest.TestCase): - def test_moved_polygon_dot_and_gradient_origin_translate_without_resizing(self): + def test_moved_arc_dot_and_sampling_quad_translate_without_resizing(self): for profile in PROFILES: with self.subTest(profile=profile): document = default_document() @@ -39,37 +38,39 @@ class TestTorqueLayout(unittest.TestCase): base['x'] += 10 base['y'] += 10 document = validate_document(document) - original, original_dots = geometry(profile, scene()) - moved, moved_dots = geometry(profile, scene(document)) - for (old_rect, old_points, old_style), (new_rect, new_points, new_style) in zip(original, moved, strict=True): - np.testing.assert_array_equal(new_points, old_points + np.array([10, 10], dtype=np.float32)) - self.assertEqual((new_rect.x, new_rect.y, new_rect.width, new_rect.height), - (old_rect.x + 10, old_rect.y + 10, old_rect.width, old_rect.height)) - if 'gradient' in old_style: - for field in ('start', 'end', 'stops'): - self.assertEqual(getattr(new_style['gradient'], field), getattr(old_style['gradient'], field)) - self.assertEqual([tuple(getattr(color, channel) for channel in 'rgba') for color in new_style['gradient'].colors], - [tuple(getattr(color, channel) for channel in 'rgba') for color in old_style['gradient'].colors]) - self.assertEqual(moved_dots, [(x + 10, y + 10, radius) for x, y, radius in original_dots]) + original, = geometry(profile, scene()) + moved, = geometry(profile, scene(document)) + np.testing.assert_allclose(moved.vertices, np.array(original.vertices) + [10, 10], atol=1e-10) + for field in ('geometry', 'dotGeometry'): + x, y, *rest = original.uniforms[field] + self.assertEqual(moved.uniforms[field], (x + 10, y + 10, *rest)) + for field in ('backgroundNormals', 'indicatorNormals', 'style'): + self.assertEqual(moved.uniforms[field], original.uniforms[field]) - def test_metadata_bounds_enclose_all_legal_polygon_and_dot_states(self): + def test_metadata_bounds_enclose_all_legal_arc_and_dot_states(self): for profile, data in PROFILES.items(): widget = data['widgets']['torque_bar'] x, y = widget['default']['x'], widget['default']['y'] right, bottom = x + widget['width'], y + widget['height'] for torque in np.linspace(-1, 1, 41): for alpha in (0, .01, .2, .5, 1): - polygons, dots = geometry(profile, scene(), float(torque), alpha) - for _, points, _ in polygons: - self.assertGreaterEqual(float(points[:, 0].min()), x) - self.assertGreaterEqual(float(points[:, 1].min()), y) - self.assertLessEqual(float(points[:, 0].max()), right) - self.assertLessEqual(float(points[:, 1].max()), bottom) - for cx, cy, radius in dots: - self.assertGreaterEqual(cx - radius, x) - self.assertGreaterEqual(cy - radius, y) - self.assertLessEqual(cx + radius, right) - self.assertLessEqual(cy + radius, bottom) + for frame in geometry(profile, scene(), float(torque), alpha): + cx, cy, radius, half = frame.uniforms['geometry'] + cap = frame.uniforms['style'][0] + normals = np.array(frame.uniforms['backgroundNormals']).reshape(2, 2) + # The arc and its rounded ends must fit the saved layout, independent of the padded sampling quad. + ends = np.array([cx, cy]) + radius * normals + extents = abs(normals) * (half - cap) + cap + self.assertGreaterEqual(float((ends - extents)[:, 0].min()), x) + self.assertGreaterEqual(cy - radius - half, y) + self.assertLessEqual(float((ends + extents)[:, 0].max()), right) + self.assertLessEqual(float((ends + extents)[:, 1].max()), bottom) + if frame.uniforms['style'][3] > 0: + dx, dy, dot_radius, _ = frame.uniforms['dotGeometry'] + self.assertGreaterEqual(dx - dot_radius, x) + self.assertGreaterEqual(dy - dot_radius, y) + self.assertLessEqual(dx + dot_radius, right) + self.assertLessEqual(dy + dot_radius, bottom) def test_only_torque_underlay_can_overlap_reserved_actions(self): document = default_document() @@ -124,9 +125,9 @@ class TestTorqueLayout(unittest.TestCase): rect = rl.Rectangle(*(data[key] for key in ('x', 'y', 'width', 'height'))) document = default_document() document['layouts']['compact']['torque_bar']['enabled'] = False - with patch('openpilot.starpilot.ui.onroad_torque.draw_polygon') as draw: + with record_gpu() as gpu: widget.render(rect, replace(scene(document), torque_utilization=.8), 536) - draw.assert_not_called() + self.assertFalse(gpu.frames or gpu.loads) self.assertGreater(widget._torque_filter.x, 0) self.assertGreater(widget._alpha_filter.x, 0) @@ -135,16 +136,16 @@ class TestTorqueLayout(unittest.TestCase): for lateral, source in ((True, False), (False, True)): widget._torque_filter.x = widget._alpha_filter.x = .8 state = replace(scene(), lateral_active=lateral, torque_source_available=source) - with patch('openpilot.starpilot.ui.onroad_torque.draw_polygon') as draw: + with record_gpu() as gpu: widget.render(rl.Rectangle(0, 0, 476, 240), state, 536) - draw.assert_not_called() + self.assertFalse(gpu.frames or gpu.loads) self.assertEqual((widget._torque_filter.x, widget._alpha_filter.x), (0, 0)) def test_new_drive_resets_existing_filters_before_first_fresh_render(self): widget = TorqueBarWidget() state = replace(scene(), lateral_active=True, torque_source_available=True, torque_drive_frame=10, torque_utilization=.8) rect = rl.Rectangle(0, 0, 476, 240) - with patch('openpilot.starpilot.ui.onroad_torque.draw_polygon'), patch('openpilot.starpilot.ui.onroad_torque.rl.draw_circle'): + with record_gpu(): for _ in range(30): widget.render(rect, state, 536) self.assertGreater(widget._torque_filter.x, .7)