Potok graficzny HAR

Na tej stronie opisujemy pełny potok graficzny renderera o wysokiej dostępności (HAR), śledząc przepływ danych od dokumentu projektu w Figma do końcowych pikseli wyświetlanych na ekranie.

Przegląd

Potok przekształca definicje interfejsu użytkownika wysokiego poziomu na polecenia graficzne niskiego poziomu i efektywnie prezentuje je na wyświetlaczach sprzętowych. Potok jest przeznaczony do aplikacji o krytycznym znaczeniu dla bezpieczeństwa w motoryzacji. Kładzie nacisk na deterministyczne renderowanie, efektywne zarządzanie stanem i solidną interakcję z podsystemami graficznymi platformy, takimi jak Direct Rendering Manager (DRM) i Generic Buffer Management (GBM).

Potok można podzielić na 4 główne fazy:

  1. Wstępne renderowanie: przetwarzanie grafu sceny, stosowanie dostosowań i rozwiązywanie układu.
  2. Generowanie poleceń: przekształcanie rozwiązanego grafu sceny w listę wyświetlania niezależną od backendu.
  3. Renderowanie: wykonywanie poleceń rysowania za pomocą silnika graficznego Impeller.
  4. Prezentacja: zarządzanie buforami ramki i synchronizacja ze sprzętem wyświetlającym.

Przepływ grafiki HAR

Rysunek 1. Przepływ grafiki HAR.

Faza 1. Wstępne renderowanie

W tej fazie statyczny projekt w Figma i dynamiczny stan aplikacji są przekształcane w w pełni rozwiązane drzewo interfejsu użytkownika w pamięci, gotowe do renderowania. Ta faza jest wykonywana w osobnym wątku ograniczenia, niezależnym od głównej pętli wyświetlania.

1.1 Podstawa DesignCompose

Potok HAR jest oparty na ekosystemie DesignCompose.

  • Źródło: interfejs użytkownika jest projektowany w Figma i eksportowany za pomocą wtyczki DesignCompose.
  • Definicja: dane wyjściowe to instancja DesignComposeDefinition, czyli serializowana reprezentacja projektu (węzły, style, warianty).
  • Wiązanie danych: model interfejsu użytkownika aplikacji używa makr proceduralnych (np. #[Design(node = "#speed")]) do jawnego wiązania pól struktury Rust z konkretnymi nazwanymi węzłami w dokumencie Figma. Dzięki temu stan aplikacji może automatycznie sterować właściwościami elementów wizualnych.

Kluczowe komponenty tej podstawy to:

  • Ograniczenie: działa jako centralna pętla zdarzeń, przetwarzając działania i aktualizując bieżący stan. Framework udostępnia DefaultReducer, ale w razie potrzeby można podać niestandardową implementację ograniczenia.
  • Prezenter: łączy bieżący stan z modelem interfejsu użytkownika. Cechę Presenter określa pakiet harry framework, a implementacja referencyjna (UIModelPresenter) jest dostępna w pakiecie harry-app-core.
  • Model interfejsu użytkownika: generuje dostosowania na podstawie bieżącego stanu. Kod modelu interfejsu użytkownika jest generowany za pomocą makra DesignDocument udostępnianego przez pakiet derive_customizations. Przykładem jest struktura UIModel w pakiecie harry-app-core.
  • Squoosh: udostępnia strukturę danych SquooshView i repozytorium wariantów, które służą do renderowania interfejsu użytkownika zgodnie z projektem. Serializowany dokument projektu jest wczytywany przez pakiet dc_bundle z biblioteki DesignCompose i przekształcany w drzewo struktur SquooshView, co zapewnia wydajne działanie w czasie wykonywania.

1.2 Pętla ograniczenia

Potok jest sterowany przez działania. Framework określa wyliczeniowy typ Actions, który definiuje wewnętrzne działania używane przez sam framework, ale zawiera też wariant CustomAction, który umożliwia użytkownikom definiowanie dodatkowych działań specyficznych dla aplikacji (np. UpdateVehicleSpeed lub ButtonPress).

Framework udostępnia też cechę StateAction, która upraszcza implementację działań wpływających na stan aplikacji i opcjonalnie generuje efekty uboczne, które są następnie przekazywane z ograniczenia do aplikacji w celu przetworzenia. Szczegółowy przykład znajdziesz w wyliczeniu CustomActions w pakiecie harry-app-core.

Oto podstawowy zarys pętli ograniczenia:

  • Przetwarzanie działania: Reducer odbiera działanie i aktualizuje bieżący stan. Są to dane pierwotne, takie jak bieżąca prędkość lub aktywne kontrolki (lampki ostrzegawcze). Może to też generować efekty uboczne (np. sygnał odtwarzający dzwonek, gdy miga lampka pasów bezpieczeństwa).
  • Prezentacja: Presenter mapuje nowy stan na UIModel. UIModel to model widoku, który zawiera dane sformatowane specjalnie na potrzeby interfejsu użytkownika (np. formatowanie prędkości „120” na ciąg znaków „65 mph”).
  • Generowanie dostosowań: wywoływana jest metoda apply modelu interfejsu użytkownika, aby wygenerować zestaw instancji RenderCustomization. Są to wyraźne instrukcje modyfikowania projektu w Figma (np. „Ustaw tekst węzła #speed na „65 mph””).
  • UpdatePolicy na potrzeby optymalizacji: po każdej operacji wstępnego renderowania zwracana jest wartość UpdatePolicy, która wskazuje, kiedy wymagana jest następna aktualizacja renderowania. Jeśli nie ma oczekujących zmian stanu ani uruchomionych animacji, UpdatePolicy sygnalizuje, że nie są potrzebne żadne dalsze aktualizacje. W takich przypadkach ograniczenie przestaje generować nowe listy wyświetlania, co zapobiega niepotrzebnym cyklom renderowania i oszczędza zasoby, dopóki nowe działanie lub zdarzenie nie spowoduje zmiany.

1.3 Wczytywanie widoku i inicjowanie repozytorium

Potok zaczyna się od instancji DesignComposeDefinition. Jest to dokument projektu w Figma serializowany przez DesignCompose do struktury bufora protokołu.

  • Wczytywanie początkowe: podczas uruchamiania główny projekt (określony przez węzeł główny) jest przekształcany z DesignComposeDefinition w początkowe drzewo SquooshView. Jest to jednorazowa operacja.

  • Repozytorium: SquooshVariantRepository zarządza wariantami komponentów wielokrotnego użytku i początkowo wczytanymi widokami.

  • Wczytywanie leniwe: aby zminimalizować czas uruchamiania i zużycie pamięci, dodatkowe widoki (które nie są częścią początkowego drzewa węzła głównego) są wczytywane z dokumentu leniwie, tylko wtedy, gdy są jawnie przywoływane i potrzebne przez logikę renderowania (np. podczas dostosowywania listy).

1.4 Operacja dostosowywania

Drzewo SquooshView jest przechodzone w celu zastosowania dynamicznego stanu aplikacji:

  • Zamiana wariantów: instancje komponentów są zamieniane na konkretne warianty (np. zmiana ikony reprezentującej bieżący tryb jazdy z trybu sportowego na tryb ekonomiczny) na podstawie logiki czasu wykonywania.

  • Rozwijanie listy: pojedynczy element szablonu w Figma jest zastępowany dynamiczną listą elementów podrzędnych. Dla tych elementów podrzędnych generowane są nowe unikalne identyfikatory, aby zapewnić stabilną tożsamość animacji.

  • Zastępowanie tekstu i stylu: zawartość tekstowa (np. wartość prędkości) i style (np. nieprzezroczystość, kolor) są aktualizowane na podstawie bieżącego stanu.

1.5 Rozdzielczość zmienna

Rozwiązywane są tokeny projektowe i zmienne zdefiniowane w Figma lub lokalnie w aplikacji.

  • Wiązanie: właściwości SquooshView odwołujące się do zmiennych (takich jak kolory lub wymiary) są zastępowane ich konkretnymi wartościami dla bieżącej ramki.

1.6 Obliczanie układu

  • Układ dynamiczny: DynamicLayout oblicza ostateczne położenie i rozmiar (granice) każdego węzła w drzewie SquooshView.

  • Układ tekstu: TextHelper używa implementacji cechy LayoutHelper do obliczania wymiarów tekstu, zawijania i kształtowania. Pomaga to sprawdzić, czy tekst prawidłowo przepływa w ramach ograniczeń przed renderowaniem.

1.7 Wskaźniki i mierniki

Jest to specjalny krok w przypadku interfejsów użytkownika w motoryzacji.

  • MeterData: jeśli węzeł ma dane miernika (zdefiniowane w Figma), jego geometria jest dynamicznie zmieniana na podstawie meter_value (np. prędkości pojazdu).
    • Łuki: kąt zamiecenia jest dostosowywany.
    • Rotacje: transformacja rotacji jest obliczana na podstawie kątów początkowego i końcowego.
    • Paski postępu: szerokość lub wysokość prostokąta jest skalowana.
    • Wektory postępu: długość ścieżki wektorowej jest dostosowywana.

1.8 Animacja

  • Porównywanie: bieżący SquooshView jest porównywany z previous_squoosh_view z PreRenderCache.

  • Interpolacja: jeśli właściwości uległy zmianie, Squoosh tworzy interpolatory, aby płynnie przechodzić między wartościami (np. nieprzezroczystością lub transformacją) w czasie.

Faza 2. Generowanie poleceń

Gdy drzewo SquooshView jest w pełni rozwiązane i animowane, jest przekształcane w liniową sekwencję poleceń rysowania.

Kluczowym komponentem tej fazy jest pakiet DisplayList:

  • generate_dl: ta funkcja rekurencyjnie przechodzi przez drzewo SquooshView.

  • Tłumaczenie:

    • Kształty i ścieżki: przekształcane w DisplayListEntry z odpowiednim wariantem DisplayListAppearance (np. Rect lub Path).
    • Tekst: przekształcany za pomocą TextHelper w elementy rysowania tekstu.
    • Transformacje i wycinki: przekształcane w pary PushTransform3D i PopTransform3D lub PushClipRegion i PopClipRegion, aby zarządzać stosem stanu rysowania.
    • Maskowanie: przekształcane w pary PushMaskLayer i PopMaskLayer, aby prawidłowo tworzyć i łączyć warstwy.

Wynikiem końcowym jest instancja Vec<DisplayListEntry>, która opisuje co należy narysować, niezależnie od sposobu rysowania.

2.1 Przekazywanie do pętli

Po wygenerowaniu DisplayList ograniczenie opakowuje go w instancję ViewDescriptor i wysyła przez kanał Rust MPSC (LooperMessage) do wątku pętli. Looper odpowiada za fazy renderowania i wyświetlania, co zapobiega blokowaniu potoku graficznego przez wątek ograniczenia.

Faza 3. Renderowanie

Niezależna od platformy DisplayList jest przekazywana do backendu renderowania, gdzie abstrakcyjne polecenia są przekształcane w instrukcje GPU.

HAR używa Impeller, silnika renderowania pierwotnie stworzonego na potrzeby Fluttera. Impeller został zaprojektowany, aby rozwiązać problem z zacinaniem się klatek spowodowanym kompilacją shaderów. W tym celu podczas kompilacji wstępnie kompiluje mały, wydajny zestaw shaderów. To podejście w połączeniu z efektywnym grupowaniem i wysoce zoptymalizowanym backendem zapewnia:

  • Deterministyczną wydajność: praktycznie eliminuje zacinanie się spowodowane kompilacją shaderów w czasie wykonywania.
  • Szybkie uruchamianie: zmniejsza obciążenie związane z inicjowaniem.
  • Mały rozmiar: tworzy kompaktowy plik binarny.

Szczegółowe wprowadzenie do architektury Impeller znajdziesz w filmie Introducing Impeller – Flutter's new rendering engine. Chociaż film dotyczy Fluttera, te podstawowe zalety bezpośrednio wzmacniają stos HAR w motoryzacji.

Kluczowe komponenty fazy renderowania to:

  • ImpellerRenderer: przekształca listę wyświetlania z fazy wstępnego renderowania w polecenia renderowania Impeller.

  • Impeller Rust API: opakowuje bibliotekę Impeller do użytku w Rust (pakiety impeller i impeller-rs-bindgen).

  • TypographyContext: zarządza rejestracją czcionek i kształtowaniem tekstu.

3.1 Inicjowanie i zarządzanie powierzchnią

  • Tworzenie kontekstu: renderer inicjuje instancję impeller::Context z backendem OpenGL ES, przekazując wywołanie zwrotne, aby rozwiązać wskaźniki funkcji OpenGL ES z kontekstu GL platformy.

  • Powierzchnia FBO opakowana: zamiast tworzyć własne okno, Impeller renderuje do istniejącego obiektu bufora ramki OpenGL (FBO) udostępnianego przez fazę 4. Odbywa się to przez wywołanie Surface::create_wrapped_fbo.

3.2 Zarządzanie zasobami

  • Obrazy: obsługuje standardowe formaty i skompresowane tekstury KTX2. Są one przesyłane do tekstur GPU i zarządzane przez wewnętrzną strukturę Resources.

  • Czcionki: czcionki TrueType i OpenType są wczytywane i rejestrowane w TypographyContext na potrzeby renderowania tekstu.

  • Obrazy zewnętrzne: specjalna obsługa tekstur zewnętrznych (np. strumieni z kamery i zewnętrznych rendererów 3D) polega na wiązaniu instancji EGLImage lub zewnętrznych tekstur OpenGL z obiektami Texture Impeller w celu renderowania bez kopiowania.

3.3 Operacja renderowania

Pętla render tworzy instancję DisplayList Impeller (nie mylić z Vec<DisplayListEntry> wygenerowaną w fazie wstępnego renderowania) za pomocą DisplayListBuilder:

  1. Czyści bufor i stosuje globalne transformacje na potrzeby skalowania DPI i rotacji wyświetlacza.

  2. Iteruje po elementach wejściowych DisplayListEntry:

    • Stan: save() i restore() służą do wypychania i wysuwania transformacji oraz regionów wycinków.
    • Elementy pierwotne: Rect i RoundedRect są rysowane za pomocą standardowych operacji malowania.
    • Ścieżki: złożone ścieżki wektorowe (w tym dynamiczne instancje Arc) są tworzone i rysowane.
    • Tekst: Text i StyledText są renderowane za pomocą TypographyContext.
    • Obrazy: standardowe i zewnętrzne obrazy są rysowane za pomocą draw_texture_rect.
  3. Przesyła utworzoną listę wyświetlania Impeller do powierzchni za pomocą surface.draw_display_list(), generując podstawowe polecenia GL.

  4. Wywołuje swap_buffers() w podstawowym kontekście, aby uruchomić fazę 4.

Faza 4. Prezentacja

Ta ostatnia faza obsługuje interakcję ze sprzętem wyświetlającym w celu wyświetlenia renderowanej ramki. HAR używa solidnej ścieżki renderowania bezpośredniego w systemie operacyjnym Android Automotive (AAOS) Software-Defined Vehicle (SDV).

Kluczowym komponentem tej fazy jest HarDirectRenderingContext (w pakiecie har-gl-context).

4.1 Architektura

Warstwa prezentacji używa podejścia z podwójnym buforowaniem z celem rysowania poza ekranem:

  1. Bufor rysowania: FBO poza ekranem, w którym Impeller renderuje scenę.

  2. Bufor rozwiązywania (opcjonalny): opcjonalny bufor pomocniczy do obsługi wygładzania wielopróbkowego (MSAA).

    • Można go włączyć w razie potrzeby przez podstawową implementację lub konfigurację OpenGL ES. W takich przypadkach służy jako cel pośredni do rozwiązywania bufora rysowania wielopróbkowego przed blitowaniem (przenoszeniem bloków bitów) do bufora renderowania.
  3. Bufor renderowania: bufor ogólny obsługiwany przez obiekt GBM, który odpowiada buforowi tylnemu w typowym łańcuchu wymiany grafiki.

  4. Bufor przedni: bufor GBM, który jest skanowany na wyświetlaczu.

4.2 Łańcuch wymiany

Gdy wywoływana jest funkcja swap_buffers, HAR wykonuje te czynności:

  1. Blituje zawartość bufora rysowania do bufora renderowania (z pośrednim blitowaniem do bufora rozwiązywania, jeśli jest to wymagane przez implementację).

  2. Wywołuje glFlush() w kontekście GL i tworzy instancję EGL_SYNC_NATIVE_FENCE_ANDROID, aby śledzić zakończenie pracy GPU.

  3. Tworzy atomowe żądanie DRM, aby zamienić bufor renderowania na ekran. To żądanie zawiera deskryptor pliku ogrodzenia GPU (nazywany ogrodzeniem wejściowym), aby zapobiec wyświetlaniu bufora renderowania przez kontroler wyświetlania, zanim GPU zakończy rysowanie.

  4. Jednocześnie żąda nowego ogrodzenia od DRM (nazywanego ogrodzeniem wyjściowym), aby zasygnalizować, kiedy poprzedni bufor (bufor przedni poprzedniej ramki) nie jest już wyświetlany.

  5. Zatwierdza żądanie atomowe za pomocą flagi nieblokującej, aby umożliwić wątkowi głównemu kontynuowanie pracy, podczas gdy podsystemy graficzne pozostają zsynchronizowane.

  6. Przechowuje nowe ogrodzenie wyjściowe w kontekście, aby HAR mógł poczekać na jego zasygnalizowanie na początku procesu swap_buffers w następnej ramce. Zapobiega to rysowaniu przez GPU w buforze, który jest nadal wyświetlany.

4.3 Ustawienie trybu bezpośredniego

HAR wchodzi w interakcję bezpośrednio z jądrem za pomocą podsystemów DRM i Kernel Mode Setting (KMS), aby skonfigurować rozdzielczość wyświetlacza AAOS SDV, pomijając interakcje z menedżerami okien, takimi jak SurfaceFlinger (w określonych konfiguracjach), co umożliwia wyłączną i priorytetową kontrolę nad sprzętem wyświetlającym.

4.4 Renderowanie zewnętrzne

HAR obsługuje delegowanie renderowania określonych elementów interfejsu użytkownika (identyfikowanych za pomocą tagów w Figma) do procesów lub wątków zewnętrznych. Jest to przydatne do integrowania złożonych scen 3D (np. wizualizacji samochodu ego z silników takich jak Kanzi czy Unity) lub innych treści, które wymagają dedykowanego kontekstu OpenGL.

4.4.1 Kluczowe komponenty

  • HarExternalRenderContext: dedykowany kontekst EGL poza ekranem dla usługi zewnętrznej.
  • SurfacePool: zarządza zestawem buforów LocalSurface (Texture plus EGLImage) na potrzeby podwójnego lub potrójnego buforowania.
  • SharedSurfaceExternalImage: opakowanie bezpieczne dla wątków do przekazywania uchwytów EGLImage między usługą zewnętrzną a głównym rendererem.

4.4.2 Przepływ pracy

Przepływ pracy jest następujący:

  1. Usługa zewnętrzna uruchamia się i rejestruje w głównej pętli, określając, które tagi Figma (np. #cluster/3d-car) renderuje.

  2. Usługa czeka na sygnały RenderStart z pętli, aby zsynchronizować renderowanie z sygnałem VSYNC wyświetlacza.

  3. Poza ekranem usługa renderuje zawartość do bufora ramki udostępnianego przez SurfacePool.

  4. Usługa wywołuje swap_buffers w swoim kontekście, co powoduje rotację puli i udostępnia ukończoną ramkę jako instancję SharedSurface.

  5. SharedSurface jest opakowywany w ExternalImage i wysyłany przez kanał Rust MPSC do pętli.

  6. Główny renderer Impeller (faza 3) odbiera obraz zewnętrzny. Zamiast kopiować dane pikseli, wiąże podstawowy EGLImage bezpośrednio z teksturą i rysuje go jako część głównej sceny, uzyskując kompozycję bez kopiowania.

4.5 Platformy programistyczne i testowe (har-platform-linux)

Na potrzeby programowania i testowania aplikacje HAR mogą być kierowane na standardowe środowiska komputerowe z systemem Linux i konfiguracje bez ekranu. Te platformy są zaimplementowane w pakiecie crates/reference/platforms/har-platform-linux.

W przeciwieństwie do produkcyjnego celu AAOS SDV te platformy nie używają podsystemu direct-rendering pakietu har-gl-context do wyświetlania. Zamiast tego korzystają ze standardowych pakietów Rust OpenGL:

  • Tryb okienkowy: używa winit do zarządzania oknami i pętlami zdarzeń oraz glutin do tworzenia kontekstów OpenGL ES i integracji z systemem okien.

  • Tryb bez ekranu: używa pakietu har-gl-context do tworzenia kontekstu pbuffer poza ekranem z domyślnym wyświetlaczem EGL. Umożliwia to renderowanie do bufora poza ekranem bez konieczności używania widocznego okna lub bezpośredniego dostępu do sprzętu wyświetlającego. Jest to używane głównie do testów automatycznych lub przetwarzania backendu.