Custom Controls mit Avalonia UI und OpenTK
Inhaltsverzeichnis zum Aufklappen
Avalonia UI ist ein modernes, plattformübergreifendes UI-Framework für .NET, das sich stark an WPF orientiert, aber für Windows, Linux, macOS, iOS, Android und das Web (via WebAssembly) ausgelegt ist. [1] Es bietet eine Vielzahl an Basiselementen, um typische UI-Szenarien abzubilden. Reichen diese nicht aus, ermöglicht Avalonia UI das Erstellen eigener Erweiterungen über Custom Controls.
Custom Controls sind wiederverwendbare, individuell gestaltete UI-Elemente. Mit ihnen lassen sich eigene Bedienelemente wie z. B. Slider, Diagramme, komplexe Eingabefelder, aber auch 3D Integrationen entwerfen, die sich nahtlos in das Styling- und Templating-System von Avalonia einfügen. Mit Custom Controls in Avalonia UI erhalten Entwickler maximale Gestaltungsfreiheit und können ein konsistentes, modernes Look-and-Feel für ihre Anwendungen schaffen – unabhängig vom Zielbetriebssystem.
Da Avalonia UI für das Rendering der Benutzeroberfläche auf Skia setzt (2D-Grafik), ist die native Unterstützung für 3D-Anwendungen – etwa für wissenschaftliche Visualisierungen, grafikintensive Programme oder Spiele – begrenzt. Hier kommt OpenTK ins Spiel. [2]
OpenTK (Open Toolkit) ist eine quelloffene .NET-Bibliothek, die den direkten Zugriff auf modernes OpenGL (plattformunabhängige Schnittstelle für die Entwicklung von 2D- und 3D-Grafik), OpenGL ES (simpleres OpenGL für Embedded Systems), OpenAL (Audio) sowie OpenCL (Parallel-Computing) ermöglicht. [3] Sie dient als Bindeglied zwischen .NET-Sprachen wie C# und den nativen Grafik- und Rechen-APIs. Dadurch können Entwickler plattformübergreifend 2D- und 3D-Grafiken rendern, Audio ausgeben und GPU-beschleunigte Berechnungen durchführen ohne eigene Wrapper schreiben zu müssen.
Wenn Sie wissen wollen, wie Sie OpenTK mit Hilfe von Custom Controls in Verbindung mit Avalonia UI einsetzen können, dann sind Sie hier richtig.
Beispiel mit einem einfachen Custom Control
Zunächst ein Beispiel, wie Sie einfach ein eigenes Custom Control in Avalonia erstellen können. Es handelt sich um ein erweitertes Panel, das beim Überfahren mit der Maus automatisch seine Hintergrundfarbe ändert. Dazu wird von der existierenden Klasse Panel geerbt, welche die Basisklasse Control enthält und die Grundlage eines Custom Controls bildet. Des Weiteren werden die Events PointerEntered und PointerExited genutzt, um die Hintergrundfarbe dynamisch anzupassen.
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace AvaloniaAppExtendedPanel;
public class ExtendedPanel : Panel
{
public static readonly StyledProperty<IBrush> HoverBackgroundProperty =
AvaloniaProperty.Register<ExtendedPanel, IBrush>(
nameof(HoverBackground),
Brushes.LightGray);
public IBrush HoverBackground
{
get => GetValue(HoverBackgroundProperty);
set => SetValue(HoverBackgroundProperty, value);
}
private IBrush? _originalBackground;
public ExtendedPanel()
{
// Subscribe to pointer events using AddHandler
AddHandler(InputElement.PointerEnteredEvent, OnPointerEnter, handledEventsToo: false);
AddHandler(InputElement.PointerExitedEvent, OnPointerLeave, handledEventsToo: false);
}
private void OnPointerEnter(object? sender, PointerEventArgs e)
{
_originalBackground = Background;
Background = HoverBackground;
}
private void OnPointerLeave(object? sender, PointerEventArgs e)
{
Background = _originalBackground;
}
}
Einbinden des Controls in die UI:
<Window xmlns="https://github.com/avaloniaui"
xmlns:AvaloniaAppExtendedPanel="clr-namespace:AvaloniaAppExtendedPanel"
Width="400" Height="200">
<AvaloniaAppExtendedPanel:ExtendedPanel
HoverBackground="DarkGray"
Background="LightGray">
<TextBlock Text="Hello Custom Control!"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
</AvaloniaAppExtendedPanel:ExtendedPanel>
</Window>
Das Resultat:
Abbildung 1: Hello Custom Control – das Resultat des einfachen Beispiels
Beispiel Custom Control mit OpenTK
Damit ein funktionierendes Beispiel entstehen kann, müssen verschiedene Aspekte berücksichtigt werden: Es reicht nicht, nur ein 3D-Objekt zu zeichnen – die gesamte Umgebung, Interaktion und Darstellung spielen eine Rolle. Dazu gehören eine Oberfläche in der Benutzeroberfläche, auf der die Szene angezeigt wird, Möglichkeiten zur Eingabe und Interaktion, eine Kamera zum Betrachten des Raumes, ein System zum Verwalten der anzuzeigenden Objekte sowie die Art und Weise, wie diese Objekte gerendert werden, beispielsweise über Shader.
Die OpenGlControlBase in Avalonia ist eine abstrakte Basisklasse für UI-Kontrollen, die OpenGL-Rendering ermöglichen. Sie baut auf der Control-Klasse auf, also einer klassischen UI-Komponente innerhalb des visuellen Baums von Avalonia. Da es sich um eine abstrakte Klasse handelt, kann sie nicht direkt verwendet werden, sondern muss durch Ableitung erweitert und mit eigener Logik gefüllt werden.
Im nächsten Schritt erstellen Sie das eigentliche Control, OpenTkControl. Dieses übernimmt das komplette interne Management der beteiligten Komponenten sowie die 3D-Logik und basiert auf der zuvor genannten OpenGlControlBase. Dabei empfiehlt es sich, typische Funktionalität wie Grafikkontext Bereitstellung, Benutzereingaben und Kamerasteuerung in eine zusätzliche Basisklasse OpenTkControlBase auszulagern. Dort können auch Methoden für Initialisierung, Rendering und Teardown zentral verwaltet werden, um den Code sauber zu halten.
OpenTkControlBase
Die Basisklasse OpenTkControlBase enthält die grundlegende Funktionalität für Initialisierung, Steuerung, Kameramanagement, Rendering und Aufräumarbeiten. Besonders wichtig ist dabei die Initialisierung des OpenGL-Kontexts, da OpenTK die von Avalonia bereitgestellten Bindings benötigt, um Inhalte korrekt darzustellen. Ohne das richtige Setzen des Kontexts kann OpenGL keine Grafiken rendern.
/// <summary>
/// Wrapper to expose GetProcAddress from Avalonia in a manner that OpenTK can consume.
/// </summary>
internal class AvaloniaTkContext(GlInterface glInterface) : IBindingsContext
{
public nint GetProcAddress(string procName) => glInterface.GetProcAddress(procName);
Dieser Wrapper ermöglicht es OpenTK, die OpenGL-Funktionen über Avalonia zu binden.
protected sealed override void OnOpenGlInit(GlInterface gl)
{
//bind Avalonia context to OpenGL
_avaloniaTkContext = new(gl);
GL.LoadBindings(_avaloniaTkContext);
Init(); //more initialization in sub class
}
Der InputManager [LINK] verwaltet sämtliche Benutzereingaben. In dieser Implementierung wird der OpenGL-Kontext von Avalonia genutzt, nicht die Standard-Ereignisschicht von OpenTK. Deshalb müssen Eingaben von Avalonia abgefangen und an das OpenTK Custom Control weitergeleitet werden. Der InputManager hält dabei je nach Update-Zyklus den aktuellen Zustand von Tastatur und Maus fest.
Wichtig: Damit das Custom Control die Eingaben erhält, muss es das Interface ICustomHitTest implementieren. Ohne dieses Interface werden keine Input-Events an das Control weitergeleitet.
3D Objekte Organisieren
Vor dem eigentlichen OpenTKControl sollte auch das Organisieren von 3D Objekten betrachtet werden.
Wenn Sie im modernen OpenGL Standard arbeiten, kommt schnell die Frage auf: Wie organisiere ich 3D-Objekte so, dass sie leicht zu verwalten und zu rendern sind?
Auch hier bietet sich eine eigene Klasse an, die alle relevanten Daten und Ressourcen für ein einzelnes 3D-Objekt enthält. Zentrale Bestandteile der Klasse sind hierfür Buffer, Shader und Transformationen. Hier finden Sie die zugehörige Datei.
Transformationen
Damit ein 3D-Objekt korrekt in der Szene dargestellt werden kann, benötigt es neben den reinen Grafikdaten auch Informationen darüber, wo es sich befindet und wie es dargestellt wird. Typischerweise verwaltet die zugehörige Klasse dafür drei grundlegende Eigenschaften:
- Position: die Koordinaten des Objekts im Raum
- Rotation: die Ausrichtung, meist als Winkel oder Quaternion gespeichert
- Skalierung: die Größe bzw. Streckung des Modells
Aus diesen Werten wird eine Modellmatrix berechnet. Diese Matrix kombiniert Verschiebung, Drehung und Skalierung zu einer einzigen Rechenoperation und wird beim Rendern an den Shader übergeben, damit das Objekt korrekt im 3D-Raum platziert und angezeigt wird.
Buffer
Damit OpenGL weiß, wie ein Objekt aussieht, müssen die Vertex-Daten in Buffern abgelegt werden. Die Klasse übernimmt die Erzeugung dieser Buffer (z. B. VBO, VAO, EBO) und speichert die zugehörigen Speicheradressen, sodass später beim Rendern schnell darauf zugegriffen werden kann. Aus diesem Grund werden VBO, VAO und EBO im Grafikspeicher (GPU-Speicher) und nicht im normalen Arbeitsspeicher der CPU abgelegt.
Die Bedeutung der üblichen Standardbufferobjekte:
- Das VBO (Vertex Buffer Object) enthält die Vertex-Daten eines Modells.
- Das EBO (Element Buffer Object, auch Index Buffer Object) speichert Indizes, die definieren, wie die Vertex-Daten zu Dreiecken zusammengesetzt werden.
- Das VAO (Vertex Array Object) speichert nur die Konfiguration, also welche VBOs für welche Attribute genutzt werden und wie diese interpretiert werden. Ein VAO ist wie eine „Landkarte“, die der GPU mitteilt, wie VBO und EBO beim Rendern verwenden werden sollen.
Info: Neben diesen Buffern sind noch weitere individuelle Buffer möglich, so beispielweise für die UV-Textur Koordinaten eines 3D Objekts, falls benötigt. Dies ist eine oft verwendete Struktur, kann aber auch nach belieben anders aufgebaut werden, so dass das VBO ebenfalls die Textur-Positionsinformationen enthalten kann (via VAO konfiguriert).
Shader
Jedes darzustellende Objekt benötigt ein eigenes Shader-Programm. Eine separate Shader-Klasse [LINK] übernimmt dabei die Aufgabe, die Shader-Sourcen (in GLSL – OpenGL Shading Language) einzulesen, zu kompilieren und anschließend zu einem ausführbaren Programm zu verlinken. Dieses Programm wird danach im Speicher gebunden und steht dem jeweiligen Objekt zur Verfügung.
Ein Shader-Programm besteht typischerweise aus zwei Komponenten: Vertex-Shader und Fragment-Shader, die beide in einer C++-ähnlichen Sprache verfasst sind.
Der Vertex-Shader arbeitet auf der Ebene einzelner Vertices. Er berechnet anhand der übergebenen Daten – wie Position, sowie Model-, View- und Projection-Matrix – die endgültige Position eines Vertex auf dem Bildschirm. Diese Matrizen sorgen dafür, dass das Objekt korrekt transformiert, aus der richtigen Perspektive betrachtet und an die Bildschirmkoordinaten angepasst wird. Zusätzlich kann der Vertex-Shader weitere Informationen, wie z. B. Farben oder Texturkoordinaten, an den Fragment-Shader weitergeben.
#vertex shader
#version 300 es
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aCol;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec3 vCol;
void main()
{
vCol = aCol; //export color and give to fragment shader
// transformation from right to left (OpenGL conform) in the vertex shader => Shader UniformMatrix4 must use transposed=false
gl_Position = projection * view * model * vec4(aPos, 1.0);
Der Fragment-Shader ist dafür zuständig, für jedes einzelne Pixel (Fragment) die endgültige Farbe zu bestimmen, indem er die interpolierten Werte des Vertex-Shaders mit den übergebenen Farb- und Texturdaten kombiniert.
#fragment shader
#version 300 es
precision mediump float; // required in fragment shaders
in vec3 vCol;
out vec4 FragColor;
void main()
{
FragColor = vec4(vCol, 1.0);
}
In den Shader-Dateien lässt sich zusätzliche Logik implementieren, um die Vertex- und Farbinformationen gezielt zu manipulieren und so unterschiedliche Effekte zu erzeugen. Welche kreativen Möglichkeiten Shader bieten, zeigt die Shader-Library von Geeks3D [LINK].
Neben Vertex- und Fragment-Shadern stellt OpenGL noch weitere Shader-Typen zur Verfügung. Dazu gehören zum Beispiel Geometry-Shader, die auf bereits transformierten Primitiven arbeiten und neue Geometrie erzeugen oder verändern können. Tessellation-Shader ermöglichen es, Geometrie dynamisch zu unterteilen, um feine Details wie Kurven oder komplexe Oberflächen darzustellen. Außerdem gibt es Compute-Shader, die unabhängig von der klassischen Render-Pipeline laufen und für allgemeine Berechnungen auf der GPU eingesetzt werden – etwa für Physik-Simulationen oder Bildverarbeitung.
OpenTkControl
In der Hauptklasse [LINK] laufen alle zuvor definierten Schritte zusammen. Die Basisklasse stellt dabei zentrale Methoden bereit, die den Lebenszyklus des Controls steuern:
- OnOpenGlInit – für die Initialisierung
- OnOpenGlRender – für das Rendern jedes Frames
- OnOpenGlDeinit – für das Aufräumen
Diese sorgen dafür, dass die entsprechenden Methoden der Hauptklasse ausgeführt werden.
Initialisierung
Während der Initialisierung werden alle benötigten Shader und 3D-Objekte geladen und im GPU-Speicher abgelegt. So stehen sie für das Rendering bereit und können effizient verwendet werden.
private readonly List<MyObject> _myObjects = [];
…
//Initialize all needed resources
protected override void Init()
{
…
//create and link shader
var shader = new Shader("Shaders/shader.vert", "Shaders/shader.frag");
//visual coordinate axis
var coordinateAxisModel = MyObject
.CreateTestObject(id: 0, ModelData.CoordinateAxisModel.Vertices, ModelData.CoordinateAxisModel.Colors, ModelData.CoordinateAxisModel.Indices, shader);
…
_myObjects.Add(coordinateAxisModel);
…
}
Render Loop
Der Render Loop ist in zwei Phasen unterteilt: Update und Render.
In der Update-Phase werden alle frameunabhängigen Werte aktualisiert und Eingaben ausgewertet. Dazu gehört beispielsweise die Anpassung der Kameraposition oder die Animation von 3D-Objekten basierend auf der seit dem letzten Frame vergangenen Delta-Zeit.
Bei der Render-Phase werden die Daten des vorherigen Frames bereinigt und die Objekte mit Hilfe ihrer Shader sowie den benötigten Matrizen auf dem Bildschirm dargestellt. Falls eine feste Bildwiederholrate eingestellt ist, wird das Rendering nur dann ausgeführt, wenn genügend Delta-Zeit seit dem letzten Frame vergangen ist.
protected override void Render()
{
…
var now = Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
var delta = now - _lastFrameTime;
DoUpdate(delta);
if (delta < _frameInterval) // only render if enough time has passed (to limit FPS)
{
return;
}
_lastFrameTime = now;
// set correct viewport size
var correctRenderSize = GetCorrectRenderSize();
GL.Viewport(0, 0, correctRenderSize.X, correctRenderSize.Y);
// clear previous data
GL.ClearColor(_clearColor);
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
DrawScene();
}
private void DoUpdate(double delta)
{
RotateModel(delta);
EvaluateInputs(delta);
}
private void DrawScene()
{
foreach (var myObject in _myObjects)
{
myObject.Shader.Use();
var model = myObject.GetModelMatrix();
var view = Camera.GetViewMatrix();
var projection = Camera.GetProjectionMatrix(Bounds.Width, Bounds.Height, 0.1f, 100.0f);
myObject.Shader.SetMatrix4("model", model);
myObject.Shader.SetMatrix4("view", view);
myObject.Shader.SetMatrix4("projection", projection);
myObject.Draw();
}
}
Beispielanwendung:
Abbildung 2: Beispielanwendung mit Avalonia UI und OpenTK
Cleanup
Im Cleanup werden alle genutzten Ressourcen wieder freigegeben. Dazu gehören Shader, Texturen, 3D-Objekte und andere GPU-Ressourcen, die während der Laufzeit angelegt wurden. So stellen Sie sicher, dass kein Speicher verschwendet wird und die Anwendung sauber beendet werden kann.
Alternative Stylingmöglichkeit
Wie am Beispiel des ExtendedPanel zu sehen, wäre es ebenfalls möglich, die Darstellung – etwa die Hintergrundfarbe des 3D-Views – direkt über das interne Avalonia-Stylingsystem zu steuern. Auf diese Weise können Standard-Avalonia-Stile genutzt oder individuell angepasst werden, ohne die Shader-Logik ändern zu müssen.
Fazit
Mit Custom Controls in Avalonia können Sie sowohl einfache als auch komplexe Funktionen elegant in die Benutzeroberfläche integrieren. Dadurch ist es möglich, die Standard-UI flexibel zu erweitern und auch spezialisierte Darstellungen, wie z. B. 3D-Views, umzusetzen.
Die Integration von OpenTK eröffnet die Möglichkeit, leistungsfähige 3D-Grafik in Avalonia-Anwendungen zu nutzen. Allerdings ist die Lernkurve für OpenTK ohne vorherige OpenGL-Erfahrung recht steil. Wer sich aber einmal eingearbeitet hat, erhält ein mächtiges Werkzeug, um plattformübergreifende 3D-Anwendungen in .NET zu realisieren.
Hinweise:
Das gesamte Repository für das beschriebene Beispiel finden Sie auf GitHub.
[1] Weitere Informationen zu Avalonia UI.
[2] Weitere Informationen zu OpenTK.
[3] Es gibt einige Alternativen zu OpenTK wie Silk.NET (modern, sehr performant und bietet neben OpenGL auch Vulkan und Direct3D Unterstützung), SharpGL (eher für einfachere OpenGL-Anwendungen gedacht) oder Veldrid (abstrahiert mehrere Grafik-APIs wie OpenGL, Vulkan, Direct3D oder Metal und erlaubt flexibleres Backend-Targeting).
Hier finden Sie einen Beitrag über Avalonia UI und die Entwicklung einer Cross-Plattform WPF-Anwendung mit .NET Core.
Suchen Sie nach einem Team für Ihre Softwareentwicklung oder -modernisierung? Dann laden Sie sich den t2informatik Steckbrief herunter.
Wollen Sie als Meinungsmacherin oder Kommunikator über das Thema diskutieren? Dann teilen Sie den Beitrag gerne in Ihrem Netzwerk. Und falls Sie sich für weitere Beiträge aus der Welt der Softwareentwicklung interessieren, dann testen Sie unseren beliebten Newsletter.
Marco Menzel hat zwei weitere Beitrag im t2informatik Blog veröffentlicht:

Marco Menzel
Marco Menzel ist Junior-Softwareentwickler bei t2informatik. Schon in seiner Kindheit entdeckte er seine Begeisterung für Computer und die Entwicklung von Software. Erste kleine Programme schrieb er noch in der Schulzeit, und schnell wurde klar, dass er sein Hobby später auch beruflich verfolgen möchte. Folgerichtig studierte er Informatik an der BTU Cottbus-Senftenberg, wo er seine Kenntnisse systematisch vertiefte und praktische Erfahrungen in verschiedenen Projekten sammelte. Heute setzt er dieses Wissen in seiner täglichen Arbeit ein und verbindet damit Leidenschaft und Beruf.
Im t2informatik Blog veröffentlichen wir Beträge für Menschen in Organisationen. Für diese Menschen entwickeln und modernisieren wir Software. Pragmatisch. ✔️ Persönlich. ✔️ Professionell. ✔️ Ein Klick hier und Sie erfahren mehr.



