Avalonia with Fragment Shaders

Jumar Macato
August 16, 2024
Introduction
Avalonia is a powerful cross-platform UI framework that, when combined with fragment shaders, opens up a world of possibilities for creating stunning visual effects in your applications. This comprehensive guide will explore how to use shaders in Avalonia, focusing on creating a custom control that can render and animate them. We'll start with the basics and build our way up to a fully functional ShaderAnimationControl
.

Basic Concepts and Prerequisites
Before we dive into the implementation, let's review some key concepts:
Shaders/Fragment Shaders: These are programs that compute the color of each pixel on the screen.
Shader Languages: GLSL (OpenGL Shading Language) is commonly used for fragment shaders. In this guide, we'll focus on converting GLSL to SkSL (Skia Shading Language), which is understood by Skia, the graphics engine used by Avalonia.
Uniforms: These are variables that remain constant for all pixels during a single draw call, often used for passing data from the application to the shader.
To follow this guide, ensure your project uses Avalonia 11.2.1 or newer. We'll assume you have basic knowledge of Avalonia and fragment shaders moving forward.
Converting Shadertoy Shaders to SkSL
Shadertoy is a popular platform for creating and sharing exciting and visually captivating fragment shaders. To use these shaders with Avalonia, we need to convert them to SkSL. The conversion process involves several key steps:
Uniform declarations:
SkSL supports most of the Shadertoy uniforms directly.
You may need to adjust the types of some uniforms (e.g.,
vec3
tofloat3
) depending on your SkiaSharp version.
Main function signature:
Change
void mainImage(out vec4 fragColor, in vec2 fragCoord)
tovec4 main(vec2 fragCoord)
Return the
fragColor
at the end of the function instead of using an out parameter.
Adjust uniform and variable types:
Change
vec2
,vec3
,vec4
tofloat2
,float3
,float4
respectively.Replace
int
uniforms withfloat
(e.g.,uniform int iFrame
becomesuniform float iFrame
). Cast to int in the shader when needed:int currentFrame = int(iFrame);
Other type adjustments may be necessary.
Update coordinate system:
Shadertoy uses a bottom-left origin, while Avalonia and SkiaSharp use a top-left origin.
Flip the y-coordinate at the beginning of the main function:
Normalize coordinates:
Update how you calculate normalized coordinates:
Here's an example of the default Shadertoy shader and its SkSL conversion:
Original Shadertoy shader:
Converted SkSL version:
Key differences and changes:
The main function signature changed from
void mainImage(out vec4 fragColor, in vec2 fragCoord)
tofloat4 main(float2 fragCoord)
.We flip the y-coordinate at the beginning of the main function to account for the different coordinate systems.
Uniform types are adjusted (e.g.,
vec3
tofloat3
,vec4
tofloat4
,int
tofloat
).Instead of setting the
fragColor
out parameter, we now return it at the end of the function.We use
float2
andfloat3
instead ofvec2
andvec3
in the shader code.We simplified the uniforms to the actual ones that are used by the shader code. We also adjusted
iResolution
fromfloat3
tofloat2
to account for the fact that the control is only going to pass width and height values.
When using these shaders with the ShaderAnimationControl
, you'll need to ensure that you're passing the correct uniforms from your C# code. The ShaderAnimationControl
we're building will handle passing iResolution
and iTime
. If your shader uses other uniforms like iMouse
, you'll need to extend the control to pass these values as well.
Important Notes
Remember that some more complex fragment shaders may require additional modifications and that this article is not exhaustive of all the modifications you need to make, especially if they use features not directly supported in SkSL.
Always test your converted shaders thoroughly and be prepared to make additional adjustments as needed.
Please be mindful of the licensing of any shader code while converting and using them from Shadertoy or other similar websites & sources to your project. Make sure to properly attribute the author and compensate them according to the licensing terms of the said shader code.
Understanding and Building the ShaderAnimationControl
The ShaderAnimationControl
is a custom Avalonia control that allows for the rendering and animation of fragment shaders. We'll be building it step by step to understand its structure and functionality.
1. Class Definition and Private Structures
Let's define the ShaderAnimationControl
class and its private structures as a starting point:
This structure sets up the foundation for the ShaderAnimationControl
. The ShaderDrawPayload
record struct and HandlerCommand
enum work together to facilitate communication between the main control and its rendering logic in _customVisual
.
The use of private nested types (the record struct and enum) encapsulates these implementation details within the ShaderAnimationControl
class.
2. Avalonia Properties
The ShaderAnimationControl
class defines several Avalonia properties to allow customization of the shader's appearance and behavior.
Let's break down each property and its purpose:
StretchProperty: Determines how the shader content is stretched to fill the control's bounds.
StretchDirectionProperty: Specifies in which directions the shader content can be stretched.
ShaderUriProperty: Holds the URI of the shader code file to be loaded and rendered. In this control's case, it should be in
avares://
format and the shader must be an Avalonia Resource. This is explained further in sectionUsing the ShaderAnimationControl in Your Avalonia Project
.ShaderWidthProperty and ShaderHeightProperty: Defines the width and height of the shader's rendering area. Defaults to
512 x 512
when not set.
The static constructor of the class uses AffectsRender
and AffectsMeasure
to indicate that changes to these properties should trigger re-rendering and re-measurement of the control. This ensures that the control updates appropriately when these properties change.
3. Layout and Measurement
The control overrides methods for measuring and arranging itself:
These methods ensure that the control sizes itself correctly based on the shader size and stretch settings.
4. Visual Tree Attachment and Detachment
The ShaderAnimationControl
overrides methods for attaching to and detaching from the visual tree. These methods are crucial for initializing the custom visual when the control is added to the UI and cleaning up resources when it's removed.
Let's break down some of the key parts of the OnAttachedToVisualTree
method:
This block of code is responsible for setting up the composition visual elements:
It retrieves the
ElementVisual
for the control and its associatedCompositor
.It creates a new
CustomVisual
usingShaderCompositionCustomVisualHandler
.The custom visual is then set as a child composition visual of the control.
This code is crucial for enabling high-performance rendering of the shader, as it allows the shader to be rendered independently of the main UI thread.
The control subscribes to the LayoutUpdated
event to handle layout changes. Then the custom visual's size is set based on the control's bounds, and an initial update message is sent to the handler. The Start()
method is then called to begin the shader animation.
The OnDetachedFromVisualTree
method performs cleanup operations:
It unsubscribes from the
LayoutUpdated
event.It stops the shader animation.
It disposes of any resources held by the control.
These methods ensure that the ShaderAnimationControl
properly initializes when added to the UI and cleans up when removed, preventing resource leaks and ensuring proper integration with Avalonia's visual tree management.
Lastly, the OnLayoutUpdated
method sends the latest layout bounds to the custom visual handler.
5. Shader Control Methods
The class includes methods for starting, stopping, and disposing of the shader:
These methods send appropriate messages to the custom visual handler to control the shader's lifecycle.
6. Custom Visual Handler
The ShaderCompositionCustomVisualHandler
is a class defined inside ShaderAnimationControl
that is responsible for managing the shader's lifecycle, handling animation frame updates, and performing the actual fragment shader rendering using SkiaSharp and Composition API's. Let's break it down further:
It is based around the CompositionCustomVisualHandler
class and Avalonia's Composition API's that provides mechanisms to process custom visual rendering that is independent of the app's UI Thread.
This allows for better performance and visual responsiveness and also allows for some kind of visual feedback to the user if the UI Thread is blocked for some reason.
6.1. Class Definition and Fields
Let's break down these fields:
_running
: A boolean flag indicating whether the shader animation is currently running._stretch
and_stretchDirection
: These fields store the current stretch settings for the shader._boundsSize
: Represents the current size of the control's bounds._shaderSize
: Stores the size of the shader itself._shaderCode
: Holds the actual GLSL code of the shader._sync
: An object used for thread synchronization._uniforms
: An instance ofSKRuntimeEffectUniforms
used to pass uniform values to the shader._effect
: An instance ofSKRuntimeEffect
representing the compiled shader._isDisposed
: A flag indicating whether the handler has been disposed.
These fields are crucial for managing the state of the shader and ensuring proper rendering and lifecycle management. The use of nullable types (e.g., Stretch?
) allows for cases where these values might not be set.
6.2. Message Handling
The OnMessage
method is responsible for handling messages sent from the main control:
This method handles different commands:
Start
: Loads and compiles the shader, sets up initial parameters, and starts the animation.Update
: Updates the shader's size and stretch parameters.Stop
: Stops the animation.Dispose
: Cleans up resources.
6.3. Animation Frame Update
The OnAnimationFrameUpdate
method is called for each animation frame:
This method invalidates the visual (triggering a redraw) and registers for the next frame update, creating a continuous animation loop.
6.4. Shader Drawing
The Draw
method is responsible for actually rendering the shader:
This method sets up the shader uniforms (including time for animation) and draws the shader onto the canvas. This is the place where you can add more uniforms to the shader as needed.
6.5. Rendering
The OnRender
method is called when the control needs to be redrawn:
This method handles:
Acquiring the SkiaSharp API lease and the underlying SkCanvas from Avalonia's ImmediateDrawingContext.
Calculating the correct scale and position for the shader within the control.
Applying clipping to ensure the shader only renders within its designated area.
Applying transformations to scale and position the shader correctly.
Calling the
Draw
method to render the shader on the canvas.
6.6. Disposal
This ensures that the SkiaSharp assets in the custom visual handler are properly disposed of, that the handler stops playback, and avoids memory leaks.
Using the ShaderAnimationControl in Your Avalonia Project
Now that we've built our ShaderAnimationControl
, let's see how you can use it in your Avalonia project.
Add the control to your XAML:
Ensure your shader file is included in your project:
Add your SkSL shader file to your project's assets folder. For example, if the folder is in
(Project Root Folder)/Assets
then make sure that the project file has the following entry:Alternatively, you can set the Build Action to "Avalonia Resource" in the shader file's properties in your IDE.
Customize the control:
Adjust the
Stretch
,StretchDirection
,ShaderWidth
, andShaderHeight
properties as needed.
Handle playback events:
If necessary, you can start and stop the shader animation in your code-behind:
Conclusion
With the ShaderAnimationControl
, you can now easily incorporate stunning shader effects into your Avalonia app. This control provides a flexible way to load, render, and animate fragment shaders, opening up a world of creative possibilities for your app's visuals.
Remember to optimize your shaders for performance, especially on mobile devices or when using complex effects. The ShaderAnimationControl
provides a solid foundation for shader integration, but you may need to extend it further for more complex use cases, such as passing additional uniforms or handling user interactions.
As you experiment with different shaders and effects, keep in mind the balance between visual appeal and performance. Complex shaders can be resource-intensive, so always test your application on various devices to ensure a smooth user experience.
Happy coding, and enjoy creating captivating visual effects with Avalonia and fragment shaders!
Special thanks to Wiesław Šoltés for giving us the permission to use his EffectsDemo project as an inspiration to the article's ShaderAnimationControl
code.