From 559b9cef8872323a1ff02f0a0fffbeae0e1b7705 Mon Sep 17 00:00:00 2001 From: LA <1245661240@qq.com> Date: Sat, 6 Dec 2025 17:23:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Visual/Drawables/TestSceneSpring.cs | 327 ++++++++++++++++++ .../Visual/Graphics/TestSceneFrostedGlass.cs | 102 ------ osu.Framework/Graphics/Transforms/Spring.cs | 151 ++++++++ 3 files changed, 478 insertions(+), 102 deletions(-) create mode 100644 osu.Framework.Tests/Visual/Drawables/TestSceneSpring.cs delete mode 100644 osu.Framework.Tests/Visual/Graphics/TestSceneFrostedGlass.cs create mode 100644 osu.Framework/Graphics/Transforms/Spring.cs diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneSpring.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneSpring.cs new file mode 100644 index 000000000..866607e64 --- /dev/null +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneSpring.cs @@ -0,0 +1,327 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Layout; +using osu.Framework.Testing; +using osuTK; +using osuTK.Graphics; + +namespace osu.Framework.Tests.Visual.Drawables +{ + public partial class TestSceneSpring : TestScene + { + private readonly BindableFloat naturalFrequency = new BindableFloat(2) + { + MinValue = 0.1f, + MaxValue = 8f, + Precision = 0.01f, + }; + + private readonly BindableFloat damping = new BindableFloat(1) + { + MinValue = 0f, + MaxValue = 6f, + Precision = 0.01f, + }; + + private readonly BindableFloat response = new BindableFloat(0) + { + MinValue = -5f, + MaxValue = 5f, + Precision = 0.01f, + }; + + private SpringTimeline timeline = null!; + private FollowingCircle followingCircle = null!; + private DraggableCircle targetCircle = null!; + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = [new Dimension(GridSizeMode.Absolute, 300), new Dimension()], + RowDimensions = [new Dimension(GridSizeMode.AutoSize)], + Padding = new MarginPadding { Vertical = 150 }, + Content = new Drawable[][] + { + [ + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new LabelledSliderBar("Frequency") + { + Size = new Vector2(300, 30), + Current = naturalFrequency, + }, + new LabelledSliderBar("Damping") + { + Size = new Vector2(300, 30), + Current = damping, + }, + new LabelledSliderBar("Response") + { + Size = new Vector2(300, 30), + Current = response, + }, + ] + }, + timeline = new SpringTimeline + { + RelativeSizeAxes = Axes.X, + Height = 150, + }, + ] + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 300, + Children = + [ + targetCircle = new DraggableCircle + { + Anchor = Anchor.Centre, + }, + followingCircle = new FollowingCircle(targetCircle) + { + Anchor = Anchor.Centre, + Depth = 1, + } + ] + } + ] + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + naturalFrequency.BindValueChanged(_ => Scheduler.AddOnce(updateSpring)); + damping.BindValueChanged(_ => Scheduler.AddOnce(updateSpring)); + response.BindValueChanged(_ => Scheduler.AddOnce(updateSpring)); + updateSpring(); + } + + private void updateSpring() + { + var springParameters = new SpringParameters + { + NaturalFrequency = naturalFrequency.Value, + Damping = damping.Value, + Response = response.Value, + }; + + followingCircle.SpringParameters = springParameters; + + timeline.SetSpringParameters(springParameters); + } + + private partial class LabelledSliderBar : CompositeDrawable + { + private readonly BasicSliderBar sliderBar; + private readonly SpriteText label; + private readonly string labelText; + + public Bindable Current + { + get => sliderBar.Current; + set => sliderBar.Current = value; + } + + public LabelledSliderBar(string labelText) + { + this.labelText = labelText; + + InternalChildren = + [ + sliderBar = new BasicSliderBar + { + RelativeSizeAxes = Axes.Both, + }, + label = new SpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 5 }, + Font = new FontUsage(size: 15f), + Colour = Color4.Black + }, + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(e => + { + label.Text = $"{labelText}: {e.NewValue:F2}"; + }, true); + } + } + + private partial class SpringTimeline : CompositeDrawable + { + private const double graph_duration = 3_000; + + private readonly SmoothPath graph; + + private readonly FloatSpring spring = new FloatSpring(); + + private readonly LayoutValue drawSizeBacking = new LayoutValue(Invalidation.DrawSize); + + public SpringTimeline() + { + AddLayout(drawSizeBacking); + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f + }, + graph = new SmoothPath + { + PathRadius = 1 + } + ]; + + for (int i = 0; i <= graph_duration; i += 1000) + { + AddInternal(new Box + { + RelativePositionAxes = Axes.X, + X = i / (float)graph_duration, + RelativeSizeAxes = Axes.Y, + Width = 1, + Origin = Anchor.TopCentre, + Alpha = 0.2f, + }); + } + } + + public void SetSpringParameters(SpringParameters parameters) + { + spring.Parameters = parameters; + updateGraph(); + } + + protected override void Update() + { + base.Update(); + + if (!drawSizeBacking.IsValid) + { + updateGraph(); + drawSizeBacking.Validate(); + } + } + + private void updateGraph() + { + spring.Current = 0; + spring.Velocity = 0; + spring.PreviousTarget = 0; + + int numSteps = (int)DrawWidth; + double timestep = graph_duration / numSteps; + + var vertices = new Vector2[numSteps]; + + for (int i = 0; i < numSteps; i++) + { + vertices[i] = new Vector2(i, (1 - spring.Current) * DrawHeight); + + spring.Update(timestep, 1); + } + + graph.Vertices = vertices; + + graph.OriginPosition = graph.PositionInBoundingBox(new Vector2()); + } + } + + private partial class DraggableCircle : Circle + { + public DraggableCircle() + { + Size = new Vector2(20); + Colour = FrameworkColour.Green; + Origin = Anchor.Centre; + } + + protected override bool OnHover(HoverEvent e) + { + Scale = new Vector2(1.2f); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Scale = new Vector2(1); + + base.OnHoverLost(e); + } + + protected override bool OnDragStart(DragStartEvent e) => true; + + protected override void OnDrag(DragEvent e) + { + Position += e.Delta; + } + } + + private partial class FollowingCircle : Circle + { + private readonly Drawable target; + + public FollowingCircle(Drawable target) + { + this.target = target; + Size = new Vector2(30); + Colour = FrameworkColour.Yellow; + Origin = Anchor.Centre; + } + + private readonly Vector2Spring position = new Vector2Spring(); + + public SpringParameters SpringParameters + { + set => position.Parameters = value; + } + + protected override void Update() + { + base.Update(); + + Position = position.Update(Time.Elapsed, target.Position); + } + } + } +} diff --git a/osu.Framework.Tests/Visual/Graphics/TestSceneFrostedGlass.cs b/osu.Framework.Tests/Visual/Graphics/TestSceneFrostedGlass.cs deleted file mode 100644 index ecf803ded..000000000 --- a/osu.Framework.Tests/Visual/Graphics/TestSceneFrostedGlass.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; - -namespace osu.Framework.Tests.Visual.Graphics -{ - public partial class TestSceneFrostedGlass : FrameworkTestScene - { - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Container backgroundCircles; - - Children = new Drawable[] - { - // Background with animated circles - backgroundCircles = new Container - { - RelativeSizeAxes = Axes.Both, - }, - // Frosted glass container overlaying part of the screen - // new FrostedGlassContainer - // { - // RelativeSizeAxes = Axes.Both, - // Width = 0.5f, - // BlurSigma = new Vector2(10), - // Children = new Drawable[] - // { - // new Box - // { - // RelativeSizeAxes = Axes.Both, - // Colour = new Color4(1, 1, 1, 0.5f), - // }, - // new SpriteText - // { - // Text = "Frosted Glass Effect", - // Anchor = Anchor.Centre, - // Origin = Anchor.Centre, - // Colour = Color4.Black, - // } - // } - // }, - new Label("Background"), - new Label("FrostedGlassContainer") - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight - } - }; - - const float circle_radius = 0.05f; - const float spacing = 0.01f; - - for (float xPos = 0; xPos < 1; xPos += circle_radius + spacing) - { - for (float yPos = 0; yPos < 1; yPos += circle_radius + spacing) - { - backgroundCircles.Add(new CircularProgress - { - RelativeSizeAxes = Axes.Both, - Size = new Vector2(circle_radius), - RelativePositionAxes = Axes.Both, - Position = new Vector2(xPos, yPos), - Progress = 1, - Colour = Color4.HotPink, - }); - } - } - } - - private partial class Label : Container - { - public Label(string text) - { - AutoSizeAxes = Axes.Both; - Margin = new MarginPadding(10); - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black - }, - new SpriteText - { - Text = text, - Margin = new MarginPadding(10) - } - }; - } - } - } -} diff --git a/osu.Framework/Graphics/Transforms/Spring.cs b/osu.Framework/Graphics/Transforms/Spring.cs new file mode 100644 index 000000000..8fe123a5f --- /dev/null +++ b/osu.Framework/Graphics/Transforms/Spring.cs @@ -0,0 +1,151 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osuTK; + +namespace osu.Framework.Graphics.Transforms +{ + public readonly record struct SpringParameters( + float NaturalFrequency = 1, + float Damping = 1, + float Response = 1 + ); + + /// + /// Simulates a value following a target value over time using spring physics. + /// See TestSceneSpring for a visualization of the spring parameters. + /// + public abstract class Spring + where T : struct + { + /// + /// The current value of the spring. + /// + public T Current; + + /// + /// The current velocity of the spring. + /// + public T Velocity; + + /// + /// The target value of the previous frame. + /// + public T PreviousTarget; + + private SpringParameters parameters; + + public SpringParameters Parameters + { + get => parameters; + set + { + parameters = value; + + k1 = Damping / (MathF.PI * NaturalFrequency); + k2 = 1 / ((2 * MathF.PI * NaturalFrequency) * (2 * MathF.PI * NaturalFrequency)); + k3 = Response * Damping / (2 * MathF.PI * NaturalFrequency); + } + } + + /// + /// Controls the overall movement speed of the spring and the frequency (in hertz) that the spring will tend to vibrate at. + /// + public float NaturalFrequency + { + get => Parameters.NaturalFrequency; + set => Parameters = Parameters with { NaturalFrequency = value }; + } + + /// + /// Rate at which the spring looses energy over time. + /// If the value is 0, the spring will vibrate indefinitely. + /// If the value is between 0 and 1, the vibration will settle over time. + /// If the value is greater than or equal to 1 the spring will not vibrate, and will approach the target value at decreasing speeds as damping is increased. + /// + public float Damping + { + get => Parameters.Damping; + set => Parameters = Parameters with { Damping = value }; + } + + /// + /// Controls the initial response to target value changes. + /// If the value is 0, the system will take time to begin moving towards the target value. + /// If the value is positive, the spring will react immediately to value changes. + /// If the value is negative, the spring will anticipate value changes by moving in the opposite direction at first. + /// If the value is greater than 1, the spring will overshoot the target value before it settles down. + /// + public float Response + { + get => Parameters.Response; + set => Parameters = Parameters with { Response = value }; + } + + private float k1, k2, k3; + + protected Spring(T initialValue = default, float naturalFrequency = 1, float damping = 1, float response = 0) + { + Current = initialValue; + PreviousTarget = initialValue; + + Parameters = new SpringParameters + { + NaturalFrequency = naturalFrequency, + Damping = damping, + Response = response, + }; + } + + protected abstract T GetTargetVelocity(T target, T previousTarget, float dt); + + public T Update(double elapsed, T target, T? targetVelocity = null) + { + float dt = (float)(elapsed / 1000); + + if (targetVelocity == null) + { + targetVelocity = GetTargetVelocity(target, PreviousTarget, dt); + PreviousTarget = target; + } + + return ComputeNextValue(dt, target, targetVelocity.Value); + } + + protected abstract T ComputeNextValue(float dt, T target, T targetVelocity); + + protected void ComputeSingleValue(float dt, ref float current, ref float velocity, float target, float targetVelocity) + { + float k2Stable = MathF.Max(MathF.Max(k2, dt * dt / 2 + dt * k1 / 2), dt * k1); + + current += dt * velocity; + velocity += (dt * (target + k3 * targetVelocity - current - k1 * velocity)) / k2Stable; + } + } + + public class FloatSpring : Spring + { + protected override float GetTargetVelocity(float target, float previousTarget, float dt) => (target - previousTarget) / dt; + + protected override float ComputeNextValue(float dt, float target, float targetVelocity) + { + ComputeSingleValue(dt, ref Current, ref Velocity, target, targetVelocity); + + return Current; + } + } + + public class Vector2Spring : Spring + { + protected override Vector2 GetTargetVelocity(Vector2 target, Vector2 previousTarget, float dt) => (target - previousTarget) / dt; + + protected override Vector2 ComputeNextValue(float dt, Vector2 target, Vector2 targetVelocity) + { + ComputeSingleValue(dt, ref Current.X, ref Velocity.X, target.X, targetVelocity.X); + ComputeSingleValue(dt, ref Current.Y, ref Velocity.Y, target.Y, targetVelocity.Y); + + return Current; + } + } +}