Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 33efde78 authored by Jay Aliomer's avatar Jay Aliomer Committed by Lucas Dupin
Browse files

Update ripple spec

Added new noise function, and general improvements on rendering.

Test: atest RippleDrawableTest
Test: manual
Fixes: 180556929
Fixes: 182172454
Fixes: 182172793
Fixes: 182174746
Change-Id: Ia2701e1438ac17010d5112ed01e296eef3ff4293
parent 057fc812
Loading
Loading
Loading
Loading
+85 −48
Original line number Diff line number Diff line
@@ -27,8 +27,9 @@ import android.graphics.Paint;
import android.graphics.RecordingCanvas;
import android.graphics.animation.RenderNodeAnimator;
import android.util.ArraySet;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.AnimationUtils;
import android.view.animation.LinearInterpolator;
import android.view.animation.PathInterpolator;

import java.util.function.Consumer;

@@ -36,32 +37,41 @@ import java.util.function.Consumer;
 * @hide
 */
public final class RippleAnimationSession {
    private static final int ENTER_ANIM_DURATION = 350;
    private static final int EXIT_ANIM_OFFSET = ENTER_ANIM_DURATION;
    private static final int EXIT_ANIM_DURATION = 350;
    private static final String TAG = "RippleAnimationSession";
    private static final int ENTER_ANIM_DURATION = 300;
    private static final int SLIDE_ANIM_DURATION = 450;
    private static final int EXIT_ANIM_DURATION = 300;
    private static final TimeInterpolator LINEAR_INTERPOLATOR = new LinearInterpolator();
    // Matches R.interpolator.fast_out_slow_in but as we have no context we can't just import that
    private static final TimeInterpolator DECELERATE_INTERPOLATOR = new DecelerateInterpolator();

    private static final TimeInterpolator PATH_INTERPOLATOR =
            new PathInterpolator(.2f, 0, 0, 1f);
    private Consumer<RippleAnimationSession> mOnSessionEnd;
    private AnimationProperties<Float, Paint> mProperties;
    private final AnimationProperties<Float, Paint> mProperties;
    private AnimationProperties<CanvasProperty<Float>, CanvasProperty<Paint>> mCanvasProperties;
    private Runnable mOnUpdate;
    private long mStartTime;
    private boolean mForceSoftware;
    private ArraySet<Animator> mActiveAnimations = new ArraySet(3);
    private final float mWidth, mHeight;
    private final ValueAnimator mSparkle = ValueAnimator.ofFloat(0, 1);
    private final ArraySet<Animator> mActiveAnimations = new ArraySet<>(3);

    RippleAnimationSession(@NonNull AnimationProperties<Float, Paint> properties,
            boolean forceSoftware) {
            boolean forceSoftware, float width, float height) {
        mProperties = properties;
        mForceSoftware = forceSoftware;
    }

    void end() {
        for (Animator anim: mActiveAnimations) {
            if (anim != null) anim.end();
        }
        mActiveAnimations.clear();
        mWidth = width;
        mHeight = height;

        mSparkle.addUpdateListener(anim -> {
            final long now = AnimationUtils.currentAnimationTimeMillis();
            final long elapsed = now - mStartTime - ENTER_ANIM_DURATION;
            final float phase = (float) elapsed / 1000f;
            mProperties.getShader().setSecondsOffset(phase);
            notifyUpdate();
        });
        mSparkle.setDuration(ENTER_ANIM_DURATION);
        mSparkle.setStartDelay(ENTER_ANIM_DURATION);
        mSparkle.setInterpolator(LINEAR_INTERPOLATOR);
        mSparkle.setRepeatCount(ValueAnimator.INFINITE);
    }

    @NonNull RippleAnimationSession enter(Canvas canvas) {
@@ -70,17 +80,19 @@ public final class RippleAnimationSession {
        } else {
            enterSoftware();
        }
        mStartTime = System.nanoTime();
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        return this;
    }

    @NonNull RippleAnimationSession exit(Canvas canvas) {
        mSparkle.end();
        if (isHwAccelerated(canvas)) exitHardware((RecordingCanvas) canvas);
        else exitSoftware();
        return this;
    }

    private void onAnimationEnd(Animator anim) {
        notifyUpdate();
        mActiveAnimations.remove(anim);
    }

@@ -92,7 +104,6 @@ public final class RippleAnimationSession {

    RippleAnimationSession setOnAnimationUpdated(@Nullable Runnable run) {
        mOnUpdate = run;
        mProperties.setOnChange(mOnUpdate);
        return this;
    }

@@ -122,14 +133,12 @@ public final class RippleAnimationSession {
    }

    private long computeDelay() {
        long currentTime = System.nanoTime();
        long timePassed =  (currentTime - mStartTime) / 1_000_000;
        long difference = EXIT_ANIM_OFFSET;
        return Math.max(difference - timePassed, 0);
        final long timePassed =  AnimationUtils.currentAnimationTimeMillis() - mStartTime;
        return Math.max((long) SLIDE_ANIM_DURATION - timePassed, 0);
    }

    private void notifyUpdate() {
        Runnable onUpdate = mOnUpdate;
        if (onUpdate != null) onUpdate.run();
        if (mOnUpdate != null) mOnUpdate.run();
    }

    RippleAnimationSession setForceSoftwareAnimation(boolean forceSw) {
@@ -153,7 +162,7 @@ public final class RippleAnimationSession {
            }
        });
        exit.setTarget(canvas);
        exit.setInterpolator(DECELERATE_INTERPOLATOR);
        exit.setInterpolator(LINEAR_INTERPOLATOR);

        long delay = computeDelay();
        exit.setStartDelay(delay);
@@ -161,36 +170,67 @@ public final class RippleAnimationSession {
        mActiveAnimations.add(exit);
    }

    private void enterHardware(RecordingCanvas can) {
    private void enterHardware(RecordingCanvas canvas) {
        AnimationProperties<CanvasProperty<Float>, CanvasProperty<Paint>>
                props = getCanvasProperties();
        RenderNodeAnimator expand =
                new RenderNodeAnimator(props.getProgress(), .5f);
        expand.setTarget(can);
        expand.setDuration(ENTER_ANIM_DURATION);
        expand.addListener(new AnimatorListener(this));
        RenderNodeAnimator slideX =
                new RenderNodeAnimator(props.getX(), mWidth / 2);
        RenderNodeAnimator slideY =
                new RenderNodeAnimator(props.getY(), mHeight / 2);
        expand.setTarget(canvas);
        slideX.setTarget(canvas);
        slideY.setTarget(canvas);
        startAnimation(expand, slideX, slideY);
    }

    private void startAnimation(Animator expand,
            Animator slideX, Animator slideY) {
        expand.setDuration(SLIDE_ANIM_DURATION);
        slideX.setDuration(SLIDE_ANIM_DURATION);
        slideY.setDuration(SLIDE_ANIM_DURATION);
        slideX.addListener(new AnimatorListener(this));
        expand.setInterpolator(LINEAR_INTERPOLATOR);
        slideX.setInterpolator(PATH_INTERPOLATOR);
        slideY.setInterpolator(PATH_INTERPOLATOR);
        expand.start();
        slideX.start();
        slideY.start();
        if (!mSparkle.isRunning()) {
            mSparkle.start();
            mActiveAnimations.add(mSparkle);
        }
        mActiveAnimations.add(expand);
        mActiveAnimations.add(slideX);
        mActiveAnimations.add(slideY);
    }

    private void enterSoftware() {
        ValueAnimator expand = ValueAnimator.ofFloat(0f, 0.5f);
        ValueAnimator slideX = ValueAnimator.ofFloat(
                mProperties.getX(), mWidth / 2);
        ValueAnimator slideY = ValueAnimator.ofFloat(
                mProperties.getY(), mHeight / 2);
        expand.addUpdateListener(updatedAnimation -> {
            notifyUpdate();
            mProperties.getShader().setProgress((Float) expand.getAnimatedValue());
        });
        expand.addListener(new AnimatorListener(this));
        expand.setInterpolator(LINEAR_INTERPOLATOR);
        expand.start();
        mActiveAnimations.add(expand);
        slideX.addUpdateListener(anim -> {
            float x = (float) slideX.getAnimatedValue();
            float y = (float) slideY.getAnimatedValue();
            mProperties.setOrigin(x, y);
            mProperties.getShader().setOrigin(x, y);
        });
        startAnimation(expand, slideX, slideY);
    }

    @NonNull AnimationProperties<Float, Paint> getProperties() {
        return mProperties;
    }

    @NonNull AnimationProperties getCanvasProperties() {
    @NonNull
    AnimationProperties<CanvasProperty<Float>, CanvasProperty<Paint>> getCanvasProperties() {
        if (mCanvasProperties == null) {
            mCanvasProperties = new AnimationProperties<>(
                    CanvasProperty.createFloat(mProperties.getX()),
@@ -209,6 +249,7 @@ public final class RippleAnimationSession {
        AnimatorListener(RippleAnimationSession session) {
            mSession = session;
        }

        @Override
        public void onAnimationStart(Animator animation) {

@@ -231,21 +272,12 @@ public final class RippleAnimationSession {
    }

    static class AnimationProperties<FloatType, PaintType> {
        private final FloatType mY;
        private FloatType mProgress;
        private FloatType mMaxRadius;
        private final FloatType mProgress;
        private final FloatType mMaxRadius;
        private final PaintType mPaint;
        private final FloatType mX;
        private final RippleShader mShader;
        private Runnable mOnChange;

        private void onChange() {
            if (mOnChange != null) mOnChange.run();
        }

        private void setOnChange(Runnable onChange) {
            mOnChange = onChange;
        }
        private FloatType mX;
        private FloatType mY;

        AnimationProperties(FloatType x, FloatType y, FloatType maxRadius,
                PaintType paint, FloatType progress, RippleShader shader) {
@@ -261,6 +293,11 @@ public final class RippleAnimationSession {
            return mProgress;
        }

        void setOrigin(FloatType x, FloatType y) {
            mX = x;
            mY = y;
        }

        FloatType getX() {
            return mX;
        }
+29 −29
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ import android.graphics.RecordingCanvas;
import android.graphics.Rect;
import android.graphics.Shader;
import android.os.Build;
import android.os.SystemProperties;
import android.util.AttributeSet;
import android.view.animation.LinearInterpolator;

@@ -159,6 +160,9 @@ public class RippleDrawable extends LayerDrawable {
    /** The maximum number of ripples supported. */
    private static final int MAX_RIPPLES = 10;
    private static final LinearInterpolator LINEAR_INTERPOLATOR = new LinearInterpolator();
    /** Temporary flag for teamfood. **/
    private static final boolean FORCE_PATTERNED_STYLE =
            SystemProperties.getBoolean("persist.material.patternedripple", false);

    private final Rect mTempRect = new Rect();

@@ -361,7 +365,9 @@ public class RippleDrawable extends LayerDrawable {
            }
        } else {
            if (focused || hovered) {
                if (!pressed) {
                    enterPatternedBackgroundAnimation(focused, hovered);
                }
            } else {
                exitPatternedBackgroundAnimation();
            }
@@ -571,7 +577,10 @@ public class RippleDrawable extends LayerDrawable {
        mState.mMaxRadius = a.getDimensionPixelSize(
                R.styleable.RippleDrawable_radius, mState.mMaxRadius);

        mState.mRippleStyle = a.getInteger(R.styleable.RippleDrawable_rippleStyle, STYLE_SOLID);
        if (!FORCE_PATTERNED_STYLE) {
            mState.mRippleStyle = a.getInteger(R.styleable.RippleDrawable_rippleStyle,
                    mState.mRippleStyle);
        }
    }

    private void verifyRequiredAttributes(@NonNull TypedArray a) throws XmlPullParserException {
@@ -812,21 +821,25 @@ public class RippleDrawable extends LayerDrawable {
    }

    private void drawPatterned(@NonNull Canvas canvas) {
        final Rect bounds = getBounds();
        final Rect bounds = getDirtyBounds();
        final int saveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
        boolean useCanvasProps = shouldUseCanvasProps(canvas);
        boolean changedHotspotBounds = !bounds.equals(mHotspotBounds);
        if (isBounded()) {
            canvas.clipRect(mHotspotBounds);
            canvas.clipRect(bounds);
        }
        float x, y;
        float x, y, w, h;
        if (changedHotspotBounds) {
            x = mHotspotBounds.exactCenterX();
            y = mHotspotBounds.exactCenterY();
            w = mHotspotBounds.width();
            h = mHotspotBounds.height();
            useCanvasProps = false;
        } else {
            x = mPendingX;
            y = mPendingY;
            w = bounds.width();
            h = bounds.height();
        }
        boolean shouldAnimate = mRippleActive;
        boolean shouldExit = mExitingAnimation;
@@ -837,9 +850,9 @@ public class RippleDrawable extends LayerDrawable {
        drawPatternedBackground(canvas);
        if (shouldAnimate && mRunningAnimations.size() <= MAX_RIPPLES) {
            RippleAnimationSession.AnimationProperties<Float, Paint> properties =
                    createAnimationProperties(x, y);
            mRunningAnimations.add(new RippleAnimationSession(properties, !useCanvasProps)
                    .setOnAnimationUpdated(useCanvasProps ? null : () -> invalidateSelf(false))
                    createAnimationProperties(x, y, w, h);
            mRunningAnimations.add(new RippleAnimationSession(properties, !useCanvasProps, w, h)
                    .setOnAnimationUpdated(() -> invalidateSelf(false))
                    .setOnSessionEnd(session -> {
                        mRunningAnimations.remove(session);
                    })
@@ -864,20 +877,8 @@ public class RippleDrawable extends LayerDrawable {
            } else {
                RippleAnimationSession.AnimationProperties<Float, Paint> p =
                        s.getProperties();
                float posX, posY;
                if (changedHotspotBounds) {
                    posX = x;
                    posY = y;
                    if (p.getPaint().getShader() instanceof RippleShader) {
                        RippleShader shader = (RippleShader) p.getPaint().getShader();
                        shader.setOrigin(posX, posY);
                    }
                } else {
                    posX = p.getX();
                    posY = p.getY();
                }
                float radius = p.getMaxRadius();
                canvas.drawCircle(posX, posY, radius, p.getPaint());
                canvas.drawCircle(p.getX(), p.getY(), radius, p.getPaint());
            }
        }
        canvas.restoreToCount(saveCount);
@@ -905,14 +906,13 @@ public class RippleDrawable extends LayerDrawable {

    private float computeRadius() {
        Rect b = getDirtyBounds();
        float gap = 0;
        float radius = (float) Math.sqrt(b.width() * b.width() + b.height() * b.height()) / 2 + gap;
        float radius = (float) Math.sqrt(b.width() * b.width() + b.height() * b.height()) / 2;
        return radius;
    }

    @NonNull
    private RippleAnimationSession.AnimationProperties<Float, Paint> createAnimationProperties(
            float x, float y) {
            float x, float y, float w, float h) {
        Paint p = new Paint(mRipplePaint);
        float radius = mState.mMaxRadius;
        RippleAnimationSession.AnimationProperties<Float, Paint> properties;
@@ -920,19 +920,19 @@ public class RippleDrawable extends LayerDrawable {
        int color = mMaskColorFilter == null
                ? mState.mColor.getColorForState(getState(), Color.BLACK)
                : mMaskColorFilter.getColor();
        color = color | 0xFF000000;
        shader.setColor(color);
        shader.setOrigin(x, y);
        shader.setResolution(w, h);
        shader.setSecondsOffset(0);
        shader.setRadius(radius);
        shader.setProgress(.0f);
        properties = new RippleAnimationSession.AnimationProperties<>(
                x, y, radius, p, 0f,
                shader);
        if (mMaskShader == null) {
            shader.setHasMask(false);
            shader.setShader(null);
        } else {
            shader.setShader(mMaskShader);
            shader.setHasMask(true);
        }
        p.setShader(shader);
        p.setColorFilter(null);
@@ -1160,7 +1160,7 @@ public class RippleDrawable extends LayerDrawable {
            // The ripple timing depends on the paint's alpha value, so we need
            // to push just the alpha channel into the paint and let the filter
            // handle the full-alpha color.
            int maskColor = color | 0xFF000000;
            int maskColor = mState.mRippleStyle == STYLE_PATTERNED ? color : color | 0xFF000000;
            if (mMaskColorFilter.getColor() != maskColor) {
                mMaskColorFilter = new PorterDuffColorFilter(maskColor, mMaskColorFilter.getMode());
            }
@@ -1276,7 +1276,7 @@ public class RippleDrawable extends LayerDrawable {
        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
        ColorStateList mColor = ColorStateList.valueOf(Color.MAGENTA);
        int mMaxRadius = RADIUS_AUTO;
        int mRippleStyle = STYLE_SOLID;
        int mRippleStyle = FORCE_PATTERNED_STYLE ? STYLE_PATTERNED : STYLE_SOLID;

        public RippleState(LayerState orig, RippleDrawable owner, Resources res) {
            super(orig, owner, res);
+135 −37
Original line number Diff line number Diff line
@@ -17,58 +17,153 @@
package android.graphics.drawable;

import android.annotation.ColorInt;
import android.annotation.NonNull;
import android.graphics.Color;
import android.graphics.RuntimeShader;
import android.graphics.Shader;

final class RippleShader extends RuntimeShader {
    private static final String SHADER = "uniform float2 in_origin;\n"
            + "uniform float in_maxRadius;\n"
    private static final String SHADER_UNIFORMS =  "uniform vec2 in_origin;\n"
            + "uniform float in_progress;\n"
            + "uniform float in_maxRadius;\n"
            + "uniform vec2 in_resolution;\n"
            + "uniform float in_hasMask;\n"
            + "uniform float4 in_color;\n"
            + "uniform shader in_shader;\n"
            + "float dist2(float2 p0, float2 pf) { return sqrt((pf.x - p0.x) * (pf.x - p0.x) + "
            + "(pf.y - p0.y) * (pf.y - p0.y)); }\n"
            + "float mod2(float a, float b) { return a - (b * floor(a / b)); }\n"
            + "float rand(float2 src) { return fract(sin(dot(src.xy, float2(12.9898, 78.233))) * "
            + "43758.5453123); }\n"
            + "float4 main(float2 p)\n"
            + "{\n"
            + "    float fraction = in_progress;\n"
            + "    float2 fragCoord = p;//sk_FragCoord.xy;\n"
            + "    float maxDist = in_maxRadius;\n"
            + "    float fragDist = dist2(in_origin, fragCoord.xy);\n"
            + "    float circleRadius = maxDist * fraction;\n"
            + "    float colorVal = (fragDist - circleRadius) / maxDist;\n"
            + "    float d = fragDist < circleRadius \n"
            + "        ? 1. - abs(colorVal * 3. * smoothstep(0., 1., fraction)) \n"
            + "        : 1. - abs(colorVal * 5.);\n"
            + "    d = smoothstep(0., 1., d);\n"
            + "    float divider = 2.;\n"
            + "    float x = floor(fragCoord.x / divider);\n"
            + "    float y = floor(fragCoord.y / divider);\n"
            + "    float density = .95;\n"
            + "    d = rand(float2(x, y)) > density ? d : d * .2;\n"
            + "    d = d * rand(float2(fraction, x * y));\n"
            + "    float alpha = 1. - pow(fraction, 2.);\n"
            + "    if (in_hasMask != 0.) {return sample(in_shader).a * in_color * d * alpha;}\n"
            + "    return in_color * d * alpha;\n"
            + "uniform float in_secondsOffset;\n"
            + "uniform vec4 in_color;\n"
            + "uniform shader in_shader;\n";
    private static final String SHADER_LIB =
            "float triangleNoise(vec2 n) {\n"
            + "    n  = fract(n * vec2(5.3987, 5.4421));\n"
            + "    n += dot(n.yx, n.xy + vec2(21.5351, 14.3137));\n"
            + "    float xy = n.x * n.y;\n"
            + "    return fract(xy * 95.4307) + fract(xy * 75.04961) - 1.0;\n"
            + "}"
            + "const float PI = 3.1415926535897932384626;\n"
            + "\n"
            + "float threshold(float v, float l, float h) {\n"
            + "  return step(l, v) * (1.0 - step(h, v));\n"
            + "}\n"
            + "\n"
            + "float sparkles(vec2 uv, float t) {\n"
            + "  float n = triangleNoise(uv);\n"
            + "  float s = 0.0;\n"
            + "  for (float i = 0; i < 4; i += 1) {\n"
            + "    float l = i * 0.25;\n"
            + "    float h = l + 0.025;\n"
            + "    float o = abs(sin(0.1 * PI * (t + i)));\n"
            + "    s += threshold(n + o, l, h);\n"
            + "  }\n"
            + "  return saturate(s);\n"
            + "}\n"
            + "\n"
            + "float softCircle(vec2 uv, vec2 xy, float radius, float blur) {\n"
            + "  float blurHalf = blur * 0.5;\n"
            + "  float d = distance(uv, xy);\n"
            + "  return 1. - smoothstep(1. - blurHalf, 1. + blurHalf, d / radius);\n"
            + "}\n"
            + "\n"
            + "float softRing(vec2 uv, vec2 xy, float radius, float blur) {\n"
            + "  float thickness = 0.4;\n"
            + "  float circle_outer = softCircle(uv, xy, radius + thickness * 0.5, blur);\n"
            + "  float circle_inner = softCircle(uv, xy, radius - thickness * 0.5, blur);\n"
            + "  return circle_outer - circle_inner;\n"
            + "}\n"
            + "\n"
            + "struct Viewport {\n"
            + "  float aspect;\n"
            + "  vec2 uv;\n"
            + "  vec2 resolution_pixels;\n"
            + "};\n"
            + "\n"
            + "Viewport getViewport(vec2 frag_coord, vec2 resolution_pixels) {\n"
            + "  Viewport v;\n"
            + "  v.aspect = resolution_pixels.y / resolution_pixels.x;\n"
            + "  v.uv = frag_coord / resolution_pixels;\n"
            + "  v.uv.y = (1.0 - v.uv.y) * v.aspect;\n"
            + "  v.resolution_pixels = resolution_pixels;\n"
            + "  return v;\n"
            + "}\n"
            + "\n"
            + "vec2 getTouch(vec2 touch_position_pixels, Viewport viewport) {\n"
            + "  vec2 touch = touch_position_pixels / viewport.resolution_pixels;\n"
            + "  touch.y *= viewport.aspect;\n"
            + "  return touch;\n"
            + "}\n"
            + "\n"
            + "struct Wave {\n"
            + "  float ring;\n"
            + "  float circle;\n"
            + "};\n"
            + "\n"
            + "Wave getWave(Viewport viewport, vec2 touch, float progress) {\n"
            + "  float fade = pow((clamp(progress, 0.8, 1.0)), 8.);\n"
            + "  Wave w;\n"
            + "  w.ring = max(softRing(viewport.uv, touch, progress, 0.45) - fade, 0.);\n"
            + "  w.circle = softCircle(viewport.uv, touch, 2.0 * progress, 0.2) - progress;\n"
            + "  return w;\n"
            + "}\n"
            + "\n"
            + "vec4 getRipple(vec4 color, float loudness, float sparkle, Wave wave) {\n"
            + "  float alpha = wave.ring * sparkle * loudness\n"
            + "        + wave.circle * color.a;\n"
            + "  return vec4(color.rgb, saturate(alpha));\n"
            + "}\n"
            + "\n"
            + "float getRingMask(vec2 frag, vec2 center, float r, float progress) {\n"
            + "      float dist = distance(frag, center);\n"
            + "      float expansion = r * .6;\n"
            + "      r = r * min(1.,progress);\n"
            + "      float minD = max(r - expansion, 0.);\n"
            + "      float maxD = r + expansion;\n"
            + "      if (dist > maxD || dist < minD) return .0;\n"
            + "      return min(maxD - dist, dist - minD) / expansion;    \n"
            + "}\n"
            + "\n"
            + "float subProgress(float start, float end, float progress) {\n"
            + "    float sub = clamp(progress, start, end);\n"
            + "    return (sub - start) / (end - start); \n"
            + "}\n";
    private static final String SHADER_MAIN = "vec4 main(vec2 p) {\n"
            + "    float fadeIn = subProgress(0., 0.175, in_progress);\n"
            + "    float fadeOutNoise = subProgress(0.375, 1., in_progress);\n"
            + "    float fadeOutRipple = subProgress(0.375, 0.75, in_progress);\n"
            + "    Viewport vp = getViewport(p, in_resolution);\n"
            + "    vec2 touch = getTouch(in_origin, vp);\n"
            + "    Wave w = getWave(vp, touch, in_progress * 0.25);\n"
            + "    float ring = getRingMask(p, in_origin, in_maxRadius, fadeIn);\n"
            + "    float alpha = min(fadeIn, 1. - fadeOutNoise);\n"
            + "    float sparkle = sparkles(p, in_progress * 0.25 + in_secondsOffset)\n"
            + "        * ring * alpha;\n"
            + "    vec4 r = getRipple(in_color, 1., sparkle, w);\n"
            + "    float fade = min(fadeIn, 1.-fadeOutRipple);\n"
            + "    vec4 circle = vec4(in_color.rgb, softCircle(p, in_origin, in_maxRadius "
            + "      * fadeIn, 0.2) * fade * in_color.a);\n"
            + "    float mask = in_hasMask == 1. ? sample(in_shader).a > 0. ? 1. : 0. : 1.;\n"
            + "    return mix(circle, vec4(1.), sparkle * mask);\n"
            + "}";
    private static final String SHADER = SHADER_UNIFORMS + SHADER_LIB + SHADER_MAIN;

    RippleShader() {
        super(SHADER, false);
    }

    public void setShader(@NonNull Shader s) {
        setInputShader("in_shader", s);
    public void setShader(Shader shader) {
        if (shader != null) {
            setInputShader("in_shader", shader);
        }
        setUniform("in_hasMask", shader == null ? 0 : 1);
    }

    public void setRadius(float radius) {
        setUniform("in_maxRadius", radius);
    }

    /**
     * Continuous offset used as noise phase.
     */
    public void setSecondsOffset(float t) {
        setUniform("in_secondsOffset", t);
    }

    public void setOrigin(float x, float y) {
        setUniform("in_origin", new float[] {x, y});
    }
@@ -77,13 +172,16 @@ final class RippleShader extends RuntimeShader {
        setUniform("in_progress", progress);
    }

    public void setHasMask(boolean hasMask) {
        setUniform("in_hasMask", hasMask ? 1 : 0);
    }

    /**
     * Color of the circle that's under the sparkles. Sparkles will always be white.
     */
    public void setColor(@ColorInt int colorIn) {
        Color color = Color.valueOf(colorIn);
        this.setUniform("in_color", new float[] {color.red(),
                color.green(), color.blue(), color.alpha()});
    }

    public void setResolution(float w, float h) {
        setUniform("in_resolution", w, h);
    }
}