// Shader Ripples — the Blazor sandbox sample (EffectsShader.razor), as a plain snippet.
// Press the image: every press starts its own ripple, ten can run at once, with a reflection pass.
// 8.jpg and maui1.png sit in the Resources panel, so they are bundled assets:
// addressed by bare name here, and written to Resources/Raw on MAUI export.
public class MultiRippleWithTouchEffect : ShaderDoubleTexturesEffect, IStateEffect, ISkiaGestureProcessor
{
private const string RippleShaderCode = """
uniform float4 iMouse;
uniform float iTime;
uniform float2 iResolution;
uniform float2 iImageResolution;
uniform shader iImage1;
uniform shader iImage2;
uniform float2 iOffset;
uniform float2 iOrigin;
uniform float2 origins[10];
uniform float progresses[10];
const float duration = 5.0;
const float amplitude = 0.015;
const float frequency = 15.0;
const float decay = 2.0;
const float speed = 0.8;
const float rippleIntensity = 0.05;
const float reflectionIntensity = 0.15;
const float minReflectionIntensity = 1.5;
const vec3 waterTint = vec3(0.1, 0.2, 0.5);
const float reflectionAngleX = 5.0;
const float reflectionAngleY = 5.0;
const float reflectionAngleZ = 1.0;
half4 main(float2 fragCoord)
{
float2 renderingScale = iImageResolution.xy / iResolution.xy;
float2 inputCoord = (fragCoord - iOffset) * renderingScale;
vec2 uv = (fragCoord - iOffset) / iResolution.xy;
half4 baseColor = iImage1.eval(inputCoord);
vec2 combinedDisplacement = vec2(0.0, 0.0);
for (int i = 0; i < 10; i++)
{
float progress = progresses[i];
vec2 mouse = origins[i];
if (progress >= 0.0 && progress <= 1.0)
{
vec2 origin = mouse / iResolution.xy;
vec2 direction = uv - origin;
float distance = max(length(direction), 0.0001);
float delay = distance / speed;
float time = max(0.0, progress * duration - delay);
float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);
vec2 n = direction / distance;
combinedDisplacement += rippleAmount * n;
}
}
vec2 finalPosition = uv + combinedDisplacement;
vec3 finalColor = iImage1.eval(finalPosition * iResolution.xy * renderingScale).rgb;
vec2 viewingDirection = normalize(vec2(reflectionAngleX, reflectionAngleY));
float fresnelEffect = pow(1.0 - dot(normalize(combinedDisplacement), viewingDirection), 3.0);
float reflectionFactor = fresnelEffect * clamp(length(combinedDisplacement) / amplitude, 0.0, 1.0);
reflectionFactor = max(reflectionFactor, minReflectionIntensity);
float dynamicPerturbationFactor = length(combinedDisplacement) * 10000.0;
vec2 perturbation = vec2(sin(finalPosition.x * dynamicPerturbationFactor), cos(finalPosition.y * dynamicPerturbationFactor)) * 0.05;
vec2 angleOffsetX = vec2(reflectionAngleX * combinedDisplacement.y, reflectionAngleX * combinedDisplacement.x);
vec2 angleOffsetY = vec2(reflectionAngleY * combinedDisplacement.y, reflectionAngleY * combinedDisplacement.x);
vec2 angleOffsetZ = vec2(reflectionAngleZ * combinedDisplacement.y, reflectionAngleZ * combinedDisplacement.x);
vec2 angleOffset = angleOffsetX + angleOffsetY + angleOffsetZ;
vec2 distortedCoord = finalPosition + angleOffset + perturbation;
vec3 reflectionColor = iImage2.eval(distortedCoord * iResolution.xy * renderingScale).rgb;
vec3 tintedReflectionColor = mix(reflectionColor, reflectionColor * waterTint, 0.5);
finalColor += rippleIntensity * (length(combinedDisplacement) / amplitude);
finalColor = mix(finalColor, tintedReflectionColor, reflectionFactor * reflectionIntensity);
return vec4(finalColor, baseColor.a);
}
""";
public MultiRippleWithTouchEffect()
{
ShaderCode = RippleShaderCode;
}
protected bool Initialized { get; set; }
public virtual void UpdateState()
{
if (Parent != null && !Initialized && Parent.IsLayoutReady)
{
Initialized = true;
}
}
public override void Attach(SkiaControl parent)
{
base.Attach(parent);
UpdateState();
}
// ten slots of (origin, progress): the shader reads them every frame
protected override SKRuntimeEffectUniforms CreateUniforms(SKRect destination)
{
var uniforms = base.CreateUniforms(destination);
var active = GetActiveRipples();
var mouseArray = new float[10 * 2];
var progressArray = new float[10];
for (int index = 0; index < 10; index++)
{
if (index < active.Count)
{
var ripple = active[index];
mouseArray[index * 2] = ripple.Origin.X;
mouseArray[index * 2 + 1] = ripple.Origin.Y;
progressArray[index] = (float)ripple.Progress;
}
else
{
progressArray[index] = -1f;
}
}
uniforms["origins"] = mouseArray;
uniforms["progresses"] = progressArray;
return uniforms;
}
public class Ripple
{
public Guid Uid { get; set; }
public System.Drawing.PointF Origin { get; set; }
public long Time { get; set; }
public double Progress { get; set; }
}
private System.Collections.Concurrent.ConcurrentDictionary<Guid, Ripple> Ripples { get; } = new();
public Ripple CreateRipple(System.Drawing.PointF origin)
{
var ripple = new Ripple
{
Uid = Guid.NewGuid(),
Origin = origin,
Time = Super.GetCurrentTimeNanos()
};
Ripples[ripple.Uid] = ripple;
return ripple;
}
public void RemoveRipple(Guid key) => Ripples.TryRemove(key, out _);
public List<Ripple> GetActiveRipples() => Ripples.Values.OrderByDescending(x => x.Time).Take(10).ToList();
// a press starts one ripple and animates its progress 0..1 over 4.5s, then drops it
public virtual ISkiaGestureListener ProcessGestures(SkiaGesturesParameters args, GestureEventProcessingInfo apply)
{
if (args.Type == TouchActionResult.Down && Initialized)
{
// The shader works in control-local pixels ((fragCoord - iOffset) / iResolution), while the
// gesture arrives in canvas pixels. In the sandbox the canvas IS the control, so raw
// Event.Location matched; here the layer is centred in a bigger canvas, so take off its origin.
var point = args.Event.Location;
var local = new System.Drawing.PointF(
point.X - (float)Parent.DrawingRect.Left,
point.Y - (float)Parent.DrawingRect.Top);
Console.WriteLine($"raw={point.X:0},{point.Y:0} rect=({Parent.DrawingRect.Left:0},{Parent.DrawingRect.Top:0} {Parent.DrawingRect.Width:0}x{Parent.DrawingRect.Height:0}) local={local.X:0},{local.Y:0} scale={Parent.RenderingScale:0.00}");
var ripple = CreateRipple(local);
System.Threading.Tasks.Task.Run(async () =>
{
await Parent.AnimateRangeAsync(value =>
{
ripple.Progress = value;
Update();
}, 0, 1, 4500);
RemoveRipple(ripple.Uid);
}).ConfigureAwait(false);
}
return null;
}
}
return new SkiaLayer
{
WidthRequest = 375,
HeightRequest = 400,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
UseCache = SkiaCacheType.GPU,
Children = new List<SkiaControl>
{
new SkiaImage
{
Source = "8.jpg",
Aspect = TransformAspect.AspectCover,
LoadSourceOnFirstDraw = false,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
UseCache = SkiaCacheType.Image,
},
new SkiaLabel("Infinite ripples with reflection")
{
Margin = new Thickness(16),
FontSize = 14,
TextColor = Colors.White,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.End,
UseCache = SkiaCacheType.Image,
},
},
VisualEffects = new List<SkiaEffect>
{
new MultiRippleWithTouchEffect
{
SecondarySource = "maui1.png", // the reflection texture, iImage2
},
},
};