Shaders
DrawnUI ships with a thin, reusable SKSL shader layer on top of SkiaSharp's
SKRuntimeEffect. It is designed for hot-path rendering — applying custom
fragment shaders every frame at 60+ FPS without generating garbage or churning
GPU handles.
There are two entry points:
| Type | Namespace | Purpose |
|---|---|---|
SkiaShader |
DrawnUi.Infrastructure |
Lightweight reusable engine. Use directly for manual rendering or as a building block. |
SkiaShaderEffect |
DrawnUi.Draw |
XAML-bindable IPostRendererEffect that wraps SkiaShader. Attach to any SkiaControl via .FX or the Effects collection. |
Quick start — effect on a control
<draw:SkiaImage Source="photo.jpg"
Aspect="AspectCover"
UseCache="ImageComposite">
<draw:SkiaImage.VisualEffects>
<draw:SkiaShaderEffect ShaderSource="Shaders/ripples.sksl" />
</draw:SkiaImage.VisualEffects>
</draw:SkiaImage>
The shader receives the control's rendered output as iImage1 and draws the
result back onto the canvas. No code-behind required.
Standard uniforms
SkiaShader provides the Shadertoy-style uniform set out of the box. Declare
the ones you need in your .sksl file:
uniform shader iImage1; // input texture (the control's output)
uniform float2 iResolution; // viewport size in pixels
uniform float2 iImageResolution; // texture size in pixels
uniform float iTime; // elapsed seconds
uniform float2 iOffset; // top-left of the draw rect
uniform float4 iMouse; // xy = current, zw = down position
You do not need to populate them manually — the base class handles it every frame.
Resource loading
Shader files go in Resources/Raw/Shaders/ (lowercase filenames — iOS is
case-sensitive). Load with ShaderSource="Shaders/myeffect.sksl". Compiled
effects are cached by filename, so the same .sksl file is only compiled once
per process even if used on many controls.
For inline code use ShaderCode="..." instead.
Code-behind rendering
SkiaShader can be used directly inside any control's Paint override:
private readonly SkiaShader _shader =
SkiaShader.FromResource("Shaders/noise.sksl");
protected override void Paint(DrawingContext ctx)
{
base.Paint(ctx);
_shader.Time = (float)(ctx.Context.FrameTimeNanos * 1e-9);
_shader.DrawRect(ctx.Context.Canvas, ctx.Destination);
}
protected override void OnDisposing()
{
_shader.Dispose();
base.OnDisposing();
}
Custom uniforms — subclass pattern
Subclass either SkiaShader (engine-level) or SkiaShaderEffect (XAML-level).
Override CreateUniforms and set extra keys on the returned instance:
public class RippleEffect : SkiaShaderEffect
{
public float Intensity { get; set; } = 1f;
// Pre-allocated buffer — see "Performance contract" below
private readonly float[] _bufCenter = new float[2];
public SKPoint Center { get; set; } = new(0.5f, 0.5f);
protected override SKRuntimeEffectUniforms CreateUniforms(SKRect destination)
{
var uniforms = base.CreateUniforms(destination);
uniforms["intensity"] = Intensity;
_bufCenter[0] = Center.X;
_bufCenter[1] = Center.Y;
uniforms["iCenter"] = _bufCenter;
return uniforms;
}
}
Additional input textures work the same way via CreateTexturesUniforms /
SkiaShader.CreateChildren:
protected override SKRuntimeEffectChildren CreateTexturesUniforms(
SkiaDrawingContext ctx, SKRect destination, SKShader primaryTexture)
{
var children = base.CreateTexturesUniforms(ctx, destination, primaryTexture);
children["iImage2"] = _secondaryTextureShader;
return children;
}
UseBackground modes
SkiaShaderEffect.UseBackground controls how the input texture (iImage1) is
sourced:
| Mode | Behavior | When to use |
|---|---|---|
Always |
Captures a fresh snapshot of the parent every frame (or reuses the parent's cached image if present). | Live effects over animated content. |
Once |
Snapshots on first render, freezes, and keeps feeding the same image. Reset with AquiredBackground = false. |
One-shot transitions, reveal animations. |
Never |
Passes no texture. Your shader must not declare iImage1. |
Generative shaders (noise, gradients, procedural patterns). |
Tip: For
Alwaysmode, set a cache type on the parent (ImageCompositeworks well) so the snapshot path reuses the already-rasterised parent image instead of re-snapshotting the canvas every frame.
Performance contract
Shaders run on the hot path — every frame, on the render thread. SkiaShader
is carefully allocation-free in steady state:
SKRuntimeEffectUniforms,SKRuntimeEffectChildren, and the textureSKShaderare cached on the instance and reused across frames. They are rebuilt automatically only when the compiled effect changes, the source image handle changes, or sampling options change.- The only unavoidable per-frame allocation is the final
SKShaderreturned bySKRuntimeEffect.ToShader(...)— SkiaSharp snapshots uniforms at that call, so it has to be recreated each frame. - All standard uniform float arrays (
iResolution,iMouse,iOffset, …) are stored in pre-allocatedfloat[]fields on the base class.
Rules when subclassing:
- Do not dispose the object returned from
base.CreateUniforms(...)orbase.CreateChildren(...). It is owned by the engine and reused. - Do not return a different instance — mutate the one returned by
baseand return it. - Do not allocate per frame inside
CreateUniforms. If you need afloat[]uniform, store it as a field and overwrite its slots each call (see_bufCenterabove). - Do not dispose the shader returned by
SkiaShader.CreateTextureShader(source)— it is cached per source handle. - Never cache a layer that hosts a shader effect in
SkiaScroll,SkiaDrawer,SkiaCarousel, or any layout that virtualizes — follow the standard DrawnUI caching rules for dynamic content. - PROHIBITED: Do NOT cache controls with GPU-surface shaders using
OperationsorGPUcache types.Operationsrecords draw commands into anSKPicturewhich cannot replay GPU-surface shader programs.GPUcache creates its own GPU surface that conflicts with the shader's surface requirements. UseImage,ImageDoubleBuffered, orImageCompositeinstead. - PROHIBITED: Do NOT nest children that use GPU-backed cache types (
GPU,ImageCompositeGPU) inside a parent cached withOperations—SKPicturerecording cannot capture GPU-surface output from children.
Breaking these rules turns a 60 FPS render loop into a GC-thrashing one — every disposed-then-rebuilt uniforms/children pair is a native handle round trip and a managed allocation.
Lifecycle and disposal
SkiaShader.DisposeCompiled()tears down the compiled effect and its cached uniforms/children/texture shader (they are all bound to the effect). Call this before recompiling.SkiaShader.Dispose()releases everything including the owned paint.SkiaShaderEffect.OnDisposing()disposes the engine automatically. If you attach an effect and later remove it, dispose it explicitly:_image.VisualEffects.Remove(_effect); _effect.Dispose();
Debugging
- Hook
SkiaShaderEffect.OnCompilationErrorto surface SKSL compile errors instead of letting them throw. - If the effect renders black, check
IsCompiledon the engine and verify your shader declaresiImage1whenUseBackground != Never. - Line endings in
.skslfiles are normalised automatically viaSkiaShader.NormalizeLineEndings— no need to worry about CRLF/LF mixing.
See also
- Drawing Pipeline — where effects fit in the render loop
- Fluent C# Extensions — attaching effects from code