diff --git a/system/ui/lib/shader_shimmer.py b/system/ui/lib/shader_shimmer.py new file mode 100644 index 00000000000000..dd2b950732b279 --- /dev/null +++ b/system/ui/lib/shader_shimmer.py @@ -0,0 +1,130 @@ +import platform +import pyray as rl +from openpilot.system.ui.lib.application import gui_app + +VERSION = """ +#version 300 es +precision mediump float; +""" +if platform.system() == "Darwin": + VERSION = """ + #version 330 core + """ + +VERTEX_SHADER = VERSION + """ +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in vec3 vertexNormal; +in vec4 vertexColor; +uniform mat4 mvp; +out vec2 fragTexCoord; +out vec4 fragColor; +void main() { + fragTexCoord = vertexTexCoord; + fragColor = vertexColor; + gl_Position = mvp * vec4(vertexPosition, 1.0); +} +""" + +SHIMMER_FRAGMENT_SHADER = VERSION + """ +in vec2 fragTexCoord; +in vec4 fragColor; +uniform sampler2D texture0; +uniform float time; +uniform float shimmerWidth; +uniform float shimmerSpeed; +uniform float sliderPercentage; +uniform float opacity; +out vec4 finalColor; + +void main() { + vec4 texColor = texture(texture0, fragTexCoord); + float xPos = fragTexCoord.x; + float shimmerPos = mod(-time * shimmerSpeed, 1.0 + shimmerWidth); + float distFromShimmer = abs(xPos - shimmerPos); + float mask = 1.0 - smoothstep(0.0, shimmerWidth, distFromShimmer); + vec3 shimmerColor = vec3(1.0, 1.0, 1.0); + vec3 finalRGB = mix(texColor.rgb, shimmerColor, mask); + float alphaFade = (1.0 - sliderPercentage) * opacity; + float finalAlpha = texColor.a * alphaFade; + finalColor = vec4(finalRGB, finalAlpha) * fragColor; +} +""" + +UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT + + +class ShimmerShader: + _instance: 'ShimmerShader' | None = None + + @classmethod + def get_instance(cls) -> 'ShimmerShader': + if cls._instance is None: + cls._instance = cls() + cls._instance.initialize() + return cls._instance + + def __init__(self): + if ShimmerShader._instance is not None: + raise Exception("This class is a singleton. Use get_instance() instead.") + + self.initialized = False + self.shader = None + + self.locations = { + 'time': None, + 'shimmerWidth': None, + 'shimmerSpeed': None, + 'sliderPercentage': None, + 'opacity': None, + 'mvp': None, + } + + self.time_ptr = rl.ffi.new("float[]", [0.0]) + self.shimmer_width_ptr = rl.ffi.new("float[]", [0.15]) + self.shimmer_speed_ptr = rl.ffi.new("float[]", [0.6]) + self.slider_percentage_ptr = rl.ffi.new("float[]", [0.0]) + self.opacity_ptr = rl.ffi.new("float[]", [1.0]) + + def initialize(self): + if self.initialized: + return + + self.shader = rl.load_shader_from_memory(VERTEX_SHADER, SHIMMER_FRAGMENT_SHADER) + + for uniform in self.locations.keys(): + self.locations[uniform] = rl.get_shader_location(self.shader, uniform) + + proj = rl.matrix_ortho(0, gui_app.width, gui_app.height, 0, -1, 1) + rl.set_shader_value_matrix(self.shader, self.locations['mvp'], proj) + rl.set_shader_value(self.shader, self.locations['shimmerWidth'], self.shimmer_width_ptr, UNIFORM_FLOAT) + rl.set_shader_value(self.shader, self.locations['shimmerSpeed'], self.shimmer_speed_ptr, UNIFORM_FLOAT) + + self.initialized = True + + def cleanup(self): + if not self.initialized: + return + if self.shader: + rl.unload_shader(self.shader) + self.shader = None + + self.initialized = False + + def set_uniforms(self, time: float, slider_percentage: float, opacity: float): + if not self.initialized: + self.initialize() + + self.time_ptr[0] = time + self.slider_percentage_ptr[0] = slider_percentage + self.opacity_ptr[0] = opacity + + rl.set_shader_value(self.shader, self.locations['time'], self.time_ptr, UNIFORM_FLOAT) + rl.set_shader_value(self.shader, self.locations['sliderPercentage'], self.slider_percentage_ptr, UNIFORM_FLOAT) + rl.set_shader_value(self.shader, self.locations['opacity'], self.opacity_ptr, UNIFORM_FLOAT) + + +def cleanup_shimmer_shader_resources(): + state = ShimmerShader.get_instance() + state.cleanup() + diff --git a/system/ui/widgets/slider.py b/system/ui/widgets/slider.py index b17d8f3b7cd506..0982d0555d4066 100644 --- a/system/ui/widgets/slider.py +++ b/system/ui/widgets/slider.py @@ -3,6 +3,7 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.shader_shimmer import ShimmerShader from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.common.filter_simple import FirstOrderFilter @@ -37,6 +38,13 @@ def __init__(self, title: str, confirm_callback: Callable | None = None): alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9) + self._shader_state = ShimmerShader.get_instance() + + self._text_render_texture: rl.RenderTexture | None = None + self._text_render_texture_width = 0 + self._text_render_texture_height = 0 + self._last_text_color: rl.Color | None = None + def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100)) @@ -57,6 +65,46 @@ def reset(self): def set_opacity(self, opacity: float): self._opacity = opacity + def _ensure_render_texture(self, width: int, height: int) -> rl.RenderTexture: + if (self._text_render_texture is None or + self._text_render_texture_width != width or + self._text_render_texture_height != height): + if self._text_render_texture is not None: + rl.unload_render_texture(self._text_render_texture) + + self._text_render_texture = rl.load_render_texture(width, height) + rl.set_texture_filter(self._text_render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + self._text_render_texture_width = width + self._text_render_texture_height = height + + return self._text_render_texture + + def _render_text_to_texture(self, label_rect: rl.Rectangle, text_color: rl.Color): + # Only re-render if color changed + if (self._last_text_color is not None and + self._last_text_color.r == text_color.r and + self._last_text_color.g == text_color.g and + self._last_text_color.b == text_color.b and + self._last_text_color.a == text_color.a): + return + + self._last_text_color = text_color + width = int(label_rect.width) + height = int(label_rect.height) + + rl.begin_texture_mode(self._ensure_render_texture(width, height)) + rl.clear_background(rl.Color(0, 0, 0, 0)) # Transparent background + self._label.render(rl.Rectangle(0, 0, width, height)) + rl.end_texture_mode() + + def close(self): + if self._text_render_texture is not None: + rl.unload_render_texture(self._text_render_texture) + self._text_render_texture = None + + def __del__(self): + self.close() + @property def slider_percentage(self): activated_pos = -self._bg_txt.width + self._circle_bg_txt.width @@ -115,8 +163,6 @@ def _update_state(self): self._scroll_x_circle_filter.x = self._scroll_x_circle def _render(self, _): - # TODO: iOS text shimmering animation - white = rl.Color(255, 255, 255, int(255 * self._opacity)) bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2 @@ -126,15 +172,23 @@ def _render(self, _): btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2 - if self._confirmed_time == 0.0 or self._scroll_x_circle > 0: - self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity))) + if self._confirmed_time == 0 or self._scroll_x_circle > 0: + text_color = rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity)) + self._label.set_text_color(text_color) label_rect = rl.Rectangle( self._rect.x + 20, self._rect.y, self._rect.width - self._circle_bg_txt.width - 20 * 3, self._rect.height, ) - self._label.render(label_rect) + + self._render_text_to_texture(label_rect, text_color) + self._shader_state.set_uniforms(rl.get_time(), self.slider_percentage, self._opacity) + + rl.begin_shader_mode(self._shader_state.shader) + src_rect = rl.Rectangle(0, 0, float(self._text_render_texture_width), -float(self._text_render_texture_height)) + rl.draw_texture_pro(self._text_render_texture.texture, src_rect, label_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + rl.end_shader_mode() # circle and arrow rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white)