diff options
| author | 2024-04-24 22:31:35 +0000 | |
|---|---|---|
| committer | 2024-04-24 22:31:35 +0000 | |
| commit | 444433357fb193c802116a934310d89e9005f65c (patch) | |
| tree | 8098c4a85349186129349fdd6aa05425b575f2c7 | |
| parent | d0c84ab693d7e1e19824111f0d5575f891b1fc61 (diff) | |
| parent | c2d9a4ad6925be1e3dae7bf9cbcb299506ac284c (diff) | |
Merge changes I08d0d430,Ic0f9c588 into main
* changes:
Extract GlowPie to avoid code repetition.
Add GlowPieEffect (first pass)
4 files changed, 607 insertions, 0 deletions
diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffect.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffect.kt new file mode 100644 index 000000000000..c08afd3e1bda --- /dev/null +++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffect.kt @@ -0,0 +1,209 @@ +/* + * 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 + + baseGlow.resetProgress() + firstGlowPie.resetProgress() + secondGlowPie.resetProgress() + + mainAnimator.addUpdateListener { updateListener -> + val time = updateListener.currentPlayTime.toFloat() % mainAnimator.duration + + // Remap each glow pie progress. + baseGlow.updateProgress(time) + firstGlowPie.updateProgress(time) + secondGlowPie.updateProgress(time) + + // TODO(b/335315940): Consider passing in 2D Matrix. + glowPieShader.setAngles(baseGlow.angle(), firstGlowPie.angle(), secondGlowPie.angle()) + glowPieShader.setBottomAngleThresholds( + baseGlow.bottomThreshold(), + firstGlowPie.bottomThreshold(), + secondGlowPie.bottomThreshold() + ) + glowPieShader.setTopAngleThresholds( + baseGlow.topThreshold(), + firstGlowPie.topThreshold(), + secondGlowPie.topThreshold() + ) + glowPieShader.setAlphas(baseGlow.alpha(), firstGlowPie.alpha(), secondGlowPie.alpha()) + + // 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() + } + + companion object { + @VisibleForTesting const val PI = Math.PI.toFloat() + @VisibleForTesting const val FEATHER = 0.3f + @VisibleForTesting const val DURATION_MS = 3000L + + private val baseGlow = BaseGlow() + private val firstGlowPie = FirstGlowPie() + private val secondGlowPie = SecondGlowPie() + } + + /** Contains animation parameters for each layer of glow pie. */ + interface GlowPie { + /** + * The start & end timestamps of the animation. Must be smaller than or equal to the full + * [DURATION_MS]. + */ + val startMs: Float + val endMs: Float + /** + * Start & end angles in radian. This determines how many cycles you want to rotate. e.g. + * startAngle = 0f endAngle = 4f * PI, will give you the 2 cycles. + */ + val startAngle: Float + val endAngle: Float + /** + * Start & end timestamps of the fade out duration. You may want to override [alpha] if you + * want to make it fade in. See [BaseGlow]. + */ + val alphaFadeStartMs: Float + val alphaFadeEndMs: Float + + /** Below two values are expected to be updated through [updateProgress]. */ + /** Normalized progress. */ + var progress: Float + /** current time of the animation in ms. */ + var time: Float + + // Must be called before retrieving angle, bottom & top thresholds, and alpha. + // Otherwise the values would be stale. + fun updateProgress(time: Float) { + progress = MathUtils.constrainedMap(0f, 1f, startMs, endMs, time) + this.time = time + } + + fun resetProgress() { + progress = 0f + time = 0f + } + + fun angle(): Float { + // Negate the angle since we want clock-wise rotation. + val angle = + MathUtils.constrainedMap(startAngle, endAngle, 0f, 1f, progress) + progress * PI + return -angle + } + + fun bottomThreshold(): Float { + return MathUtils.lerp(1f, -FEATHER, progress) + } + + fun topThreshold(): Float { + return MathUtils.lerp(1f + FEATHER, 0f, progress) + } + + // By default, it fades "out". + fun alpha(): Float { + // Remap timestamps (in MS) to alpha [0, 1]. + return MathUtils.constrainedMap(1f, 0f, alphaFadeStartMs, alphaFadeEndMs, time) + } + } + + data class BaseGlow( + override val startMs: Float = 0f, + override val endMs: Float = 0f, + override val startAngle: Float = 0f, + override val endAngle: Float = 0f, + override val alphaFadeStartMs: Float = 2250f, + override val alphaFadeEndMs: Float = 2950f, + ) : GlowPie { + + override var progress: Float = 1f + override var time: Float = 0f + override fun updateProgress(time: Float) {} + + override fun resetProgress() {} + + override fun angle(): Float = 0f + + override fun bottomThreshold(): Float = 0f + + override fun topThreshold(): Float = 0f + + // Base glow fade "in" (i.e. reveals). + override fun alpha(): Float { + return MathUtils.constrainedMap(0f, 1f, alphaFadeStartMs, alphaFadeEndMs, time) + } + } + + data class FirstGlowPie( + override val startMs: Float = 250f, + override val endMs: Float = 2500f, + override val startAngle: Float = -PI / 2f, + override val endAngle: Float = 4f * PI, + override val alphaFadeStartMs: Float = 2500f, + override val alphaFadeEndMs: Float = 2750f, + override var progress: Float = 0f, + override var time: Float = 0f + ) : GlowPie + + data class SecondGlowPie( + override val startMs: Float = 350f, + override val endMs: Float = 2600f, + override val startAngle: Float = -PI / 2f, + override val endAngle: Float = 3f * PI, + override val alphaFadeStartMs: Float = 2600f, + override val alphaFadeEndMs: Float = 2850f, + override var progress: Float = 0f, + override var time: Float = 0f + ) : GlowPie +} diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffectConfig.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffectConfig.kt new file mode 100644 index 000000000000..6c728c1b5a53 --- /dev/null +++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffectConfig.kt @@ -0,0 +1,36 @@ +/* + * 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 +) diff --git a/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieShader.kt b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieShader.kt new file mode 100644 index 000000000000..2dbc0b5e24aa --- /dev/null +++ b/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieShader.kt @@ -0,0 +1,233 @@ +/* + * 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 setAngles(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) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffectTest.kt b/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffectTest.kt new file mode 100644 index 000000000000..8105cc58b58e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/surfaceeffects/gloweffect/GlowPieEffectTest.kt @@ -0,0 +1,129 @@ +/* + * 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() + } + + @Test + fun glowPie_progress_computesProgressCorrectly() { + val myGlowPieConfig = + object : GlowPieEffect.GlowPie { + override val startMs: Float = 0f + override val endMs: Float = GlowPieEffect.DURATION_MS.toFloat() + override val startAngle: Float = 0f + override val endAngle: Float = 6f * GlowPieEffect.PI + override val alphaFadeStartMs: Float = 0f + override val alphaFadeEndMs: Float = GlowPieEffect.DURATION_MS.toFloat() + override var progress: Float = 0f + override var time: Float = 0f + } + + val playTime = GlowPieEffect.DURATION_MS.toFloat() * 0.5f + val tolerance = 1e-4f + myGlowPieConfig.updateProgress(playTime) + + assertThat(myGlowPieConfig.time).isWithin(tolerance).of(playTime) + assertThat(myGlowPieConfig.progress).isWithin(tolerance).of(0.5f) + assertThat(myGlowPieConfig.angle()).isWithin(tolerance).of(-3.5f * GlowPieEffect.PI) + assertThat(myGlowPieConfig.bottomThreshold()) + .isWithin(tolerance) + .of((1f - GlowPieEffect.FEATHER) * 0.5f) + assertThat(myGlowPieConfig.topThreshold()) + .isWithin(tolerance) + .of((1f + GlowPieEffect.FEATHER) * 0.5f) + assertThat(myGlowPieConfig.alpha()).isWithin(tolerance).of(0.5f) + + myGlowPieConfig.resetProgress() + + assertThat(myGlowPieConfig.time).isEqualTo(0f) + assertThat(myGlowPieConfig.progress).isEqualTo(0f) + } +} |