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

Commit d243f966 authored by Yein Jo's avatar Yein Jo
Browse files

Add GlowPieEffect (first pass)

Add GlowPieEffect that rotates two pie slices with glow + base glow.

The effect will be drawn behind the target view, so it's not masked to
be shown as a border.

Bug: 335315940
Flag: NA. currently not used anywhere.
Test: GlowPieEffectTest
Change-Id: Ic0f9c5887695a6697fa05f5e3092fb97802886d0
parent 24caa927
Loading
Loading
Loading
Loading
+107 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.surfaceeffects.gloweffect

import android.animation.ValueAnimator
import android.animation.ValueAnimator.INFINITE
import android.graphics.RenderEffect
import androidx.annotation.VisibleForTesting
import com.android.systemui.surfaceeffects.RenderEffectDrawCallback
import com.android.systemui.surfaceeffects.utils.MathUtils

/** Renders rotating pie with glow on top, masked with a rounded box. */
class GlowPieEffect(
    config: GlowPieEffectConfig,
    private val renderEffectDrawCallback: RenderEffectDrawCallback
) {

    private val glowPieShader = GlowPieShader().apply { applyConfig(config) }

    @VisibleForTesting
    val mainAnimator: ValueAnimator =
        ValueAnimator.ofFloat(0f, 1f).apply {
            // We want to loop the full cycle.
            duration = DURATION_MS
            repeatMode = ValueAnimator.RESTART
            repeatCount = INFINITE
        }

    /** Plays glow pie until [finish] is called. */
    fun play() {
        if (mainAnimator.isRunning) return

        mainAnimator.addUpdateListener { updateListener ->
            val time = updateListener.currentPlayTime.toFloat() % mainAnimator.duration

            // TODO(b/335315940): Extract the timestamps to config.
            val progress1 = MathUtils.constrainedMap(0f, 1f, 250f, 2500f, time)
            val progress2 = MathUtils.constrainedMap(0f, 1f, 350f, 2600f, time)

            // TODO(b/335315940): Consider passing in 2D Matrix.
            val angle0 = 0f // No rotation for the base.
            // Negate the angle since we want clock-wise rotation.
            val angle1 =
                -(MathUtils.constrainedMap(-PI / 2f, 4f * PI, 0f, 1f, progress1) + progress1 * PI)
            val angle2 =
                -(MathUtils.constrainedMap(-PI / 2f, 3f * PI, 0f, 1f, progress2) + progress2 * PI)
            glowPieShader.setAngle(angle0, angle1, angle2)
            val bottomThreshold0 = 0f
            val topThreshold0 = 0f

            val bottomThreshold1 = MathUtils.lerp(1f, -FEATHER, progress1)
            val topThreshold1 = MathUtils.lerp(1f + FEATHER, 0f, progress1)

            val bottomThreshold2 = MathUtils.lerp(1f, -FEATHER, progress2)
            val topThreshold2 = MathUtils.lerp(1f + FEATHER, 0f, progress2)

            glowPieShader.setBottomAngleThresholds(
                bottomThreshold0,
                bottomThreshold1,
                bottomThreshold2
            )
            glowPieShader.setTopAngleThresholds(topThreshold0, topThreshold1, topThreshold2)

            // Remap timestamps (in MS) to alpha [0, 1].
            val alpha0 = MathUtils.constrainedMap(0f, 1f, 2250f, 2950f, time)
            val alpha1 = MathUtils.constrainedMap(1f, 0f, 2500f, 2750f, time)
            val alpha2 = MathUtils.constrainedMap(1f, 0f, 2600f, 2850f, time)
            glowPieShader.setAlphas(alpha0, alpha1, alpha2)

            // Finally trigger the draw callback.
            renderEffectDrawCallback.onDraw(
                RenderEffect.createRuntimeShaderEffect(
                    glowPieShader,
                    GlowPieShader.BACKGROUND_UNIFORM
                )
            )
        }

        mainAnimator.start()
    }

    fun finish() {
        // TODO(b/335315940) Add alpha fade.
        mainAnimator.cancel()
    }

    private companion object {
        private const val PI = Math.PI.toFloat()
        private const val FEATHER = 0.3f
        // This indicates a single loop of the animation.
        private const val DURATION_MS = 3000L
    }
}
+36 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.surfaceeffects.gloweffect

/** Parameter values needed to draw [GlowPieEffect]. */
data class GlowPieEffectConfig(
    /** Center x position of the effect. */
    val centerX: Float,
    /** Center y position of the effect. */
    val centerY: Float,
    /** Width of the rounded box mask. */
    val width: Float,
    /** Height of the rounded box mask. */
    val height: Float,
    /** Corner radius of the rounded box mask. */
    val cornerRadius: Float,
    /**
     * Colors of the effect. The number must match 3, which is defined in [GlowPieShader.NUM_PIE].
     * Each color corresponds to baseColor (bottom), firstLayerColor, and secondLayerColor (top).
     */
    val colors: IntArray
)
+233 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.surfaceeffects.gloweffect

import android.graphics.RuntimeShader
import android.util.Log
import com.android.systemui.surfaceeffects.shaderutil.SdfShaderLibrary
import com.android.systemui.surfaceeffects.shaderutil.ShaderUtilLibrary

/** Draws two glowing pies rotating around the center of a rounded box on a base. */
class GlowPieShader : RuntimeShader(GLOW_PIE_SHADER_COMP) {
    // language=AGSL
    companion object {
        const val BACKGROUND_UNIFORM = "in_dst"
        const val NUM_PIE = 3

        private const val UNIFORMS =
            """
            uniform shader ${BACKGROUND_UNIFORM};
            uniform vec2 in_center;
            uniform vec2 in_size;
            uniform half in_cornerRad;
            uniform float[${NUM_PIE}] in_angles;
            uniform float[${NUM_PIE}] in_alphas;
            uniform float[${NUM_PIE}] in_bottomThresholds;
            uniform float[${NUM_PIE}] in_topThresholds;
            layout(color) uniform vec4 in_colors0;
            layout(color) uniform vec4 in_colors1;
            layout(color) uniform vec4 in_colors2;
        """

        private const val GLOW_PIE_MAIN =
            """
        vec4 main(vec2 p) {
            vec4 pie = vec4(0.);
            vec4 glow = vec4(0.);

            vec2 c = p - in_center;
            half box = sdRoundedBox(c, in_size, in_cornerRad);

            // Base glow (drawn at the bottom)
            pieGlow(
                box,
                c,
                in_angles[0],
                in_colors0.rgb,
                /* pieAlpha= */ 1., // We always show the base color.
                /* glowAlpha= */ in_alphas[0],
                vec2(in_bottomThresholds[0], in_topThresholds[0]),
                pie,
                glow
            );

            // First pie
            pieGlow(
                box,
                c,
                in_angles[1],
                in_colors1.rgb,
                /* pieAlpha= */ in_alphas[1],
                /* glowAlpha= */ in_alphas[1],
                vec2(in_bottomThresholds[1], in_topThresholds[1]),
                pie,
                glow
            );

            // Second pie (drawn on top)
            pieGlow(
                box,
                c,
                in_angles[2],
                in_colors2.rgb,
                /* pieAlpha= */ in_alphas[2],
                /* glowAlpha= */ in_alphas[2],
                vec2(in_bottomThresholds[2], in_topThresholds[2]),
                pie,
                glow
            );

            return vec4(pie.rgb + glow.rgb * 0.3, pie.a);
        }
        """

        private const val REMAP =
            """
            float remap(float in_start, float in_end, float out_start, float out_end, float x) {
                x = (x - in_start) / (in_end - in_start);
                x = clamp(x, 0., 1.);
                return x * (out_end - out_start) + out_start;
            }
        """

        /**
         * This function draws a pie slice, an a glow on top. The glow also has the same pie shape
         * but with more blur and additive blending.
         */
        private const val GLOW_PIE =
            """
            void pieGlow(
                half box,
                vec2 c,
                half angle,
                vec3 color,
                half pieAlpha,
                half glowAlpha,
                vec2 angleThresholds,
                inout vec4 inout_pie,
                inout vec4 inout_glow) {

                // Apply angular rotation.
                half co = cos(angle), si = sin(angle);
                mat2 rotM = mat2(co, -si, si, co); // 2D rotation matrix
                c *= rotM;

                // We rotate based on the cosine value, since we want to avoid using inverse
                // trig function, which in this case is atan.

                // Dot product with vec2(1., 0.) and bring the range to [0,1].
                // Same as dot(normalize(c), vec2(1.,0) * 0.5 + 0.5
                half d = normalize(c).x * 0.5 + 0.5;

                // Those thresholds represents each end of the pie.
                float bottomThreshold = angleThresholds[0];
                float topThreshold = angleThresholds[1];
                float angleMask = remap(bottomThreshold, topThreshold, 0., 1., d);

                half boxMask = 1. - smoothstep(-0.02, 0.02, box);
                vec4 pie = vec4(color, 1.0) * angleMask * boxMask * pieAlpha;

                // We are drawing the same pie but with more blur.
                half glowMask = 1. - smoothstep(0., 0.6, box);
                // Glow outside only.
                glowMask = min(glowMask, smoothstep(-0.02, 0.02, box));
                // Apply some curve for the glow. (Can take out)
                glowMask *= glowMask * glowMask;
                // Glow mask should also be sliced with the angle mask.
                glowMask *= angleMask;
                vec4 glow = vec4(color, 1.0) * glowMask * glowAlpha;

                inout_pie = pie + inout_pie * (1. - pie.a);
                // Additive blending.
                inout_glow += glow;
            }
            """

        private const val GLOW_PIE_SHADER_COMP =
            ShaderUtilLibrary.SHADER_LIB +
                SdfShaderLibrary.SHADER_SDF_OPERATION_LIB +
                SdfShaderLibrary.ROUNDED_BOX_SDF +
                UNIFORMS +
                REMAP +
                GLOW_PIE +
                GLOW_PIE_MAIN

        private val TAG = GlowPieShader::class.java.simpleName
    }

    fun applyConfig(config: GlowPieEffectConfig) {
        setCenter(config.centerX, config.centerY)
        setSize(config.width, config.height)
        setCornerRadius(config.cornerRadius)
        setColor(config.colors)
    }

    fun setCenter(centerX: Float, centerY: Float) {
        setFloatUniform("in_center", centerX, centerY)
    }

    fun setSize(width: Float, height: Float) {
        setFloatUniform("in_size", width, height)
    }

    fun setCornerRadius(cornerRadius: Float) {
        setFloatUniform("in_cornerRad", cornerRadius)
    }

    /** Ignores alpha value, as fade in/out is handled within shader. */
    fun setColor(colors: IntArray) {
        if (colors.size != NUM_PIE) {
            Log.wtf(TAG, "The number of colors must be $NUM_PIE")
            return
        }
        setColorUniform("in_colors0", colors[0])
        setColorUniform("in_colors1", colors[1])
        setColorUniform("in_colors2", colors[2])
    }

    fun setAngle(vararg angles: Float) {
        if (angles.size != NUM_PIE) {
            Log.wtf(TAG, "The number of angles must be $NUM_PIE")
            return
        }
        setFloatUniform("in_angles", angles)
    }

    fun setAlphas(vararg alphas: Float) {
        if (alphas.size != NUM_PIE) {
            Log.wtf(TAG, "The number of angles must be $NUM_PIE")
            return
        }
        setFloatUniform("in_alphas", alphas)
    }

    fun setBottomAngleThresholds(vararg bottomThresholds: Float) {
        if (bottomThresholds.size != NUM_PIE) {
            Log.wtf(TAG, "The number of bottomThresholds must be $NUM_PIE")
            return
        }
        setFloatUniform("in_bottomThresholds", bottomThresholds)
    }

    fun setTopAngleThresholds(vararg topThresholds: Float) {
        if (topThresholds.size != NUM_PIE) {
            Log.wtf(TAG, "The number of topThresholds must be $NUM_PIE")
            return
        }
        setFloatUniform("in_topThresholds", topThresholds)
    }
}
+94 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.surfaceeffects.gloweffect

import android.graphics.Color
import android.graphics.RenderEffect
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import androidx.test.filters.SmallTest
import com.android.systemui.animation.AnimatorTestRule
import com.android.systemui.model.SysUiStateTest
import com.android.systemui.surfaceeffects.RenderEffectDrawCallback
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
class GlowPieEffectTest : SysUiStateTest() {

    @get:Rule val animatorTestRule = AnimatorTestRule(this)

    @Test
    fun play_triggersDrawCallback() {
        var effectFromCallback: RenderEffect? = null
        val glowPieEffectConfig =
            GlowPieEffectConfig(
                centerX = 0f,
                centerY = 0f,
                width = 1f,
                height = 1f,
                cornerRadius = 0.5f,
                colors = intArrayOf(Color.RED, Color.GREEN, Color.BLUE)
            )
        val drawCallback =
            object : RenderEffectDrawCallback {
                override fun onDraw(renderEffect: RenderEffect) {
                    effectFromCallback = renderEffect
                }
            }
        val glowPieEffect = GlowPieEffect(glowPieEffectConfig, drawCallback)

        assertThat(effectFromCallback).isNull()

        glowPieEffect.play()

        animatorTestRule.advanceTimeBy(100L)

        assertThat(effectFromCallback).isNotNull()
    }

    @Test
    fun finish_cancelsAnimator() {
        val glowPieEffectConfig =
            GlowPieEffectConfig(
                centerX = 0f,
                centerY = 0f,
                width = 1f,
                height = 1f,
                cornerRadius = 0.5f,
                colors = intArrayOf(Color.RED, Color.GREEN, Color.BLUE)
            )
        val drawCallback =
            object : RenderEffectDrawCallback {
                override fun onDraw(renderEffect: RenderEffect) {}
            }
        val glowPieEffect = GlowPieEffect(glowPieEffectConfig, drawCallback)

        glowPieEffect.play()
        animatorTestRule.advanceTimeBy(100L)

        assertThat(glowPieEffect.mainAnimator.isRunning).isTrue()

        glowPieEffect.finish()

        assertThat(glowPieEffect.mainAnimator.isRunning).isFalse()
    }
}