Torque Bar Optimization + Gradient

This commit is contained in:
firestarsdog
2026-10-26 21:55:36 -04:00
parent eebd74e745
commit ed2abcc4b9
6 changed files with 328 additions and 209 deletions
+3
View File
@@ -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()
+12 -169
View File
@@ -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()
@@ -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()
@@ -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):
@@ -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 [])
@@ -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)