From 5e103062e9dabf8e128ec42e5c7a8e3e31fe25a0 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Wed, 22 Oct 2025 16:23:33 +0300 Subject: [PATCH 01/72] Rework path to reduce vertex count --- osu.Framework/Graphics/Lines/Path.cs | 47 ++- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 345 ++++++++---------- osu.Framework/Graphics/Lines/SmoothPath.cs | 18 +- osu.Framework/Resources/Shaders/sh_Path.fs | 37 ++ .../Resources/Shaders/sh_PathPrepass.fs | 14 + .../Resources/Shaders/sh_PathPrepass.vs | 17 + 6 files changed, 255 insertions(+), 223 deletions(-) create mode 100644 osu.Framework/Resources/Shaders/sh_Path.fs create mode 100644 osu.Framework/Resources/Shaders/sh_PathPrepass.fs create mode 100644 osu.Framework/Resources/Shaders/sh_PathPrepass.vs diff --git a/osu.Framework/Graphics/Lines/Path.cs b/osu.Framework/Graphics/Lines/Path.cs index 85c69475d..73a02e943 100644 --- a/osu.Framework/Graphics/Lines/Path.cs +++ b/osu.Framework/Graphics/Lines/Path.cs @@ -10,9 +10,11 @@ using osuTK; using osu.Framework.Graphics.Shaders; using osu.Framework.Allocation; using System.Collections.Generic; +using System.Runtime.InteropServices; using osu.Framework.Caching; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shaders.Types; using osu.Framework.Layout; using osuTK.Graphics; @@ -34,8 +36,8 @@ namespace osu.Framework.Graphics.Lines [BackgroundDependencyLoader] private void load(ShaderManager shaders) { - TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); - pathShader = shaders.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE); + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "Path"); + pathShader = shaders.Load("PathPrepass", "PathPrepass"); } private readonly List vertices = new List(); @@ -291,20 +293,12 @@ namespace osu.Framework.Graphics.Lines // The path should not receive the true colour to avoid colour doubling when the frame-buffer is rendered to the back-buffer. public override DrawColourInfo DrawColourInfo => new DrawColourInfo(Color4.White, base.DrawColourInfo.Blending); - private Color4 backgroundColour = new Color4(0, 0, 0, 0); + private static readonly Color4 background_colour = new Color4(0, 0, 0, 0); /// /// The background colour to be used for the frame buffer this path is rendered to. /// - public virtual Color4 BackgroundColour - { - get => backgroundColour; - set - { - backgroundColour = value; - Invalidate(Invalidation.DrawNode); - } - } + public Color4 BackgroundColour => background_colour; public long PathInvalidationID { get; private set; } @@ -321,7 +315,7 @@ namespace osu.Framework.Graphics.Lines return result; } - private readonly BufferedDrawNodeSharedData sharedData = new BufferedDrawNodeSharedData(new[] { RenderBufferFormat.D16 }, clipToRootNode: true); + private readonly BufferedDrawNodeSharedData sharedData = new BufferedDrawNodeSharedData(clipToRootNode: true); protected override DrawNode CreateDrawNode() => new PathBufferedDrawNode(this, new PathDrawNode(this), sharedData); @@ -335,11 +329,38 @@ namespace osu.Framework.Graphics.Lines } private long pathInvalidationID = -1; + private Texture texture; + private Vector4 textureRect; + private IUniformBuffer parametersBuffer; public override void ApplyState() { base.ApplyState(); pathInvalidationID = Source.PathInvalidationID; + texture = Source.Texture; + + var rect = texture.GetTextureRect(); + textureRect = new Vector4(rect.Left, rect.Top, rect.Width, rect.Height); + } + + protected override void BindUniformResources(IShader shader, IRenderer renderer) + { + base.BindUniformResources(shader, renderer); + + parametersBuffer ??= renderer.CreateUniformBuffer(); + parametersBuffer.Data = new PathTextureParameters + { + TexRect1 = textureRect, + }; + shader.BindUniformBlock("m_PathTextureParameters", parametersBuffer); + + texture?.Bind(1); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct PathTextureParameters + { + public UniformVector4 TexRect1; } protected override long GetDrawVersion() => pathInvalidationID; diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 9c7ad8779..dac05e3c5 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -2,17 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.Textures; using osuTK; using System; using System.Collections.Generic; -using osuTK.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using System.Diagnostics; +using System.Runtime.InteropServices; using osu.Framework.Utils; +using osuTK.Graphics.ES30; namespace osu.Framework.Graphics.Lines { @@ -26,12 +25,10 @@ namespace osu.Framework.Graphics.Lines private readonly List segments = new List(); - private Texture? texture; - private Vector2 drawSize; private float radius; private IShader? pathShader; - private IVertexBatch? triangleBatch; + private IVertexBatch? triangleBatch; public PathDrawNode(Path source) : base(source) @@ -45,8 +42,6 @@ namespace osu.Framework.Graphics.Lines segments.Clear(); segments.AddRange(Source.segments); - texture = Source.Texture; - drawSize = Source.DrawSize; radius = Source.PathRadius; pathShader = Source.pathShader; } @@ -55,136 +50,63 @@ namespace osu.Framework.Graphics.Lines { base.Draw(renderer); - if (texture?.Available != true || segments.Count == 0 || pathShader == null) + if (segments.Count == 0 || pathShader == null) return; // We multiply the size args by 3 such that the amount of vertices is a multiple of the amount of vertices // per primitive (triangles in this case). Otherwise overflowing the batch will result in wrong // grouping of vertices into primitives. - triangleBatch ??= renderer.CreateLinearBatch(max_res * 200 * 3, 10, PrimitiveTopology.Triangles); + triangleBatch ??= renderer.CreateLinearBatch(max_res * 200 * 3, 10, PrimitiveTopology.Triangles); renderer.PushLocalMatrix(DrawInfo.Matrix); - renderer.PushDepthInfo(DepthInfo.Default); - // Blending is removed to allow for correct blending between the wedges of the path. - renderer.SetBlend(BlendingParameters.None); + renderer.SetBlend(new BlendingParameters + { + Source = BlendingType.One, + Destination = BlendingType.One, + SourceAlpha = BlendingType.One, + DestinationAlpha = BlendingType.One, + RGBEquation = BlendingEquation.Max, + AlphaEquation = BlendingEquation.Max, + }); pathShader.Bind(); - texture.Bind(); - updateVertexBuffer(); pathShader.Unbind(); - renderer.PopDepthInfo(); renderer.PopLocalMatrix(); } - private Vector2 pointOnCircle(float angle) => new Vector2(MathF.Cos(angle), MathF.Sin(angle)); - - private Vector2 relativePosition(Vector2 localPos) => Vector2.Divide(localPos, drawSize); - - private Color4 colourAt(Vector2 localPos) => DrawColourInfo.Colour.TryExtractSingleColour(out SRGBColour colour) - ? colour.SRGB - : DrawColourInfo.Colour.Interpolate(relativePosition(localPos)).SRGB; - - private void addSegmentQuads(SegmentWithThickness segment, RectangleF texRect) + private void addCap(Line cap) { - Debug.Assert(triangleBatch != null); + Vector2 ortho = cap.OrthogonalDirection; + if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) + ortho = Vector2.UnitY; - // Each segment of the path is actually rendered as 2 quads, being split in half along the approximating line. - // On this line the depth is 1 instead of 0, which is done in order to properly handle self-overlap using the depth buffer. - Vector3 firstMiddlePoint = new Vector3(segment.Guide.StartPoint.X, segment.Guide.StartPoint.Y, 1); - Vector3 secondMiddlePoint = new Vector3(segment.Guide.EndPoint.X, segment.Guide.EndPoint.Y, 1); - Color4 firstMiddleColour = colourAt(segment.Guide.StartPoint); - Color4 secondMiddleColour = colourAt(segment.Guide.EndPoint); + Vector2 v2 = cap.StartPoint + ortho * radius; + Vector2 v3 = cap.EndPoint + ortho * radius; - // Each of the quads (mentioned above) is rendered as 2 triangles: - // Outer quad, triangle 1 - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(segment.EdgeRight.EndPoint.X, segment.EdgeRight.EndPoint.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = colourAt(segment.EdgeRight.EndPoint) - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(segment.EdgeRight.StartPoint.X, segment.EdgeRight.StartPoint.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = colourAt(segment.EdgeRight.StartPoint) - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = firstMiddlePoint, - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = firstMiddleColour - }); - - // Outer quad, triangle 2 - triangleBatch.Add(new TexturedVertex3D - { - Position = firstMiddlePoint, - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = firstMiddleColour - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = secondMiddlePoint, - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = secondMiddleColour - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(segment.EdgeRight.EndPoint.X, segment.EdgeRight.EndPoint.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = colourAt(segment.EdgeRight.EndPoint) - }); - - // Inner quad, triangle 1 - triangleBatch.Add(new TexturedVertex3D - { - Position = firstMiddlePoint, - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = firstMiddleColour - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = secondMiddlePoint, - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = secondMiddleColour - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(segment.EdgeLeft.EndPoint.X, segment.EdgeLeft.EndPoint.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = colourAt(segment.EdgeLeft.EndPoint) - }); - - // Inner quad, triangle 2 - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(segment.EdgeLeft.EndPoint.X, segment.EdgeLeft.EndPoint.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = colourAt(segment.EdgeLeft.EndPoint) - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(segment.EdgeLeft.StartPoint.X, segment.EdgeLeft.StartPoint.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = colourAt(segment.EdgeLeft.StartPoint) - }); - triangleBatch.Add(new TexturedVertex3D - { - Position = firstMiddlePoint, - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = firstMiddleColour - }); + drawQuad + ( + new Quad(cap.StartPoint, v2, cap.EndPoint, v3), + new Quad(new Vector2(0, -1), new Vector2(1f, -1), new Vector2(0, 1), Vector2.One) + ); } - private void addSegmentCaps(float thetaDiff, Line segmentLeft, Line segmentRight, Line prevSegmentLeft, Line prevSegmentRight, RectangleF texRect) + private void addSegmentQuad(SegmentWithThickness segment) { - Debug.Assert(triangleBatch != null); + drawQuad + ( + new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint), + new Quad(new Vector2(0, -1), new Vector2(0, -1), new Vector2(0, 1), new Vector2(0, 1)) + ); + } + + private void addConnectionBetween(SegmentWithThickness segment, SegmentWithThickness prevSegment) + { + float thetaDiff = segment.Guide.Theta - prevSegment.Guide.Theta; if (Math.Abs(thetaDiff) > MathF.PI) thetaDiff = -Math.Sign(thetaDiff) * 2 * MathF.PI + thetaDiff; @@ -192,51 +114,97 @@ namespace osu.Framework.Graphics.Lines if (thetaDiff == 0f) return; - Vector2 origin = (segmentLeft.StartPoint + segmentRight.StartPoint) / 2; + Vector2 origin = segment.Guide.StartPoint; + Vector2 end = thetaDiff > 0f ? segment.EdgeRight.StartPoint : segment.EdgeLeft.StartPoint; + Line start = thetaDiff > 0f ? new Line(prevSegment.EdgeLeft.EndPoint, prevSegment.EdgeRight.EndPoint) : new Line(prevSegment.EdgeRight.EndPoint, prevSegment.EdgeLeft.EndPoint); - // Use segment end points instead of calculating start/end via theta to guarantee - // that the vertices have the exact same position as the quads, which prevents - // possible pixel gaps during rasterization. - Vector2 current = thetaDiff > 0f ? prevSegmentRight.EndPoint : prevSegmentLeft.EndPoint; - Vector2 end = thetaDiff > 0f ? segmentRight.StartPoint : segmentLeft.StartPoint; - - Line start = thetaDiff > 0f ? new Line(prevSegmentLeft.EndPoint, prevSegmentRight.EndPoint) : new Line(prevSegmentRight.EndPoint, prevSegmentLeft.EndPoint); - float theta0 = start.Theta; - float thetaStep = Math.Sign(thetaDiff) * MathF.PI / max_res; - int stepCount = (int)MathF.Ceiling(thetaDiff / thetaStep); - - Color4 originColour = colourAt(origin); - Color4 currentColour = colourAt(current); - - for (int i = 1; i <= stepCount; i++) + // less than 90 degrees, single quad + if (Math.Abs(thetaDiff) < Math.PI * 0.5) { - // Center point - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(origin.X, origin.Y, 1), - TexturePosition = new Vector2(texRect.Right, texRect.Centre.Y), - Colour = originColour - }); + Vector2 middle = Vector2.Lerp(start.EndPoint, end, 0.5f); + Vector2 v3 = Vector2.Lerp(origin, middle, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle)); - // First outer point - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(current.X, current.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = currentColour - }); - - current = i < stepCount ? origin + pointOnCircle(theta0 + i * thetaStep) * radius : end; - currentColour = colourAt(current); - - // Second outer point - triangleBatch.Add(new TexturedVertex3D - { - Position = new Vector3(current.X, current.Y, 0), - TexturePosition = new Vector2(texRect.Left, texRect.Centre.Y), - Colour = currentColour - }); + drawQuad(new Quad(start.EndPoint, v3, origin, end), origin); } + else // more than 90 degrees - 2 quads + { + Vector2 ortho = start.OrthogonalDirection; + if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) + ortho = Vector2.UnitY; + + Vector2 v1 = start.StartPoint + Math.Sign(thetaDiff) * ortho * radius; + Vector2 v2 = start.EndPoint + Math.Sign(thetaDiff) * ortho * radius; + Vector2 middle = Vector2.Lerp(v1, v2, 0.5f); + + drawQuad + ( + new Quad(start.EndPoint, v2, origin, middle), + new Quad + ( + new Vector2(0, -1), + new Vector2(1, -1), + Vector2.Zero, + new Vector2(1, 0) + ) + ); + + Vector2 middle2 = Vector2.Lerp(middle, end, 0.5f); + Vector2 v3 = Vector2.Lerp(origin, middle2, radius / (float)Math.Cos((Math.Abs(thetaDiff) - Math.PI * 0.5) * 0.5) / Vector2.Distance(origin, middle2)); + + drawQuad(new Quad(middle, v3, origin, end), origin); + } + } + + private void drawQuad(Quad quad, Vector2 origin) + { + drawQuad + ( + quad, + new Quad + ( + Vector2.Divide(quad.TopLeft - origin, radius), + Vector2.Divide(quad.TopRight - origin, radius), + Vector2.Divide(quad.BottomLeft - origin, radius), + Vector2.Divide(quad.BottomRight - origin, radius) + ) + ); + } + + private void drawQuad(Quad quad, Quad relativeQuad) + { + Debug.Assert(triangleBatch != null); + + triangleBatch.Add(new PathVertex + { + Position = quad.TopLeft, + RelativePos = relativeQuad.TopLeft + }); + triangleBatch.Add(new PathVertex + { + Position = quad.TopRight, + RelativePos = relativeQuad.TopRight + }); + triangleBatch.Add(new PathVertex + { + Position = quad.BottomLeft, + RelativePos = relativeQuad.BottomLeft + }); + + triangleBatch.Add(new PathVertex + { + Position = quad.BottomLeft, + RelativePos = relativeQuad.BottomLeft + }); + triangleBatch.Add(new PathVertex + { + Position = quad.TopRight, + RelativePos = relativeQuad.TopRight + }); + triangleBatch.Add(new PathVertex + { + Position = quad.BottomRight, + RelativePos = relativeQuad.BottomRight + }); } private void updateVertexBuffer() @@ -259,11 +227,8 @@ namespace osu.Framework.Graphics.Lines // that make sense in the realm of OpenGL, rather than terms which are technically // accurate in the actually used "flipped" system. - Debug.Assert(texture != null); Debug.Assert(segments.Count > 0); - RectangleF texRect = texture.GetTextureRect(new RectangleF(0.5f, 0.5f, texture.Width - 1, texture.Height - 1)); - Line? segmentToDraw = null; SegmentStartLocation location = SegmentStartLocation.Outside; SegmentStartLocation modifiedLocation = SegmentStartLocation.Outside; @@ -303,8 +268,8 @@ namespace osu.Framework.Graphics.Lines else // Otherwise draw the expanded segment { SegmentWithThickness s = new SegmentWithThickness(segmentToDraw.Value, radius, location, modifiedLocation); - addSegmentQuads(s, texRect); - connect(s, lastDrawnSegment, texRect); + addSegmentQuad(s); + connect(s, lastDrawnSegment); lastDrawnSegment = s; @@ -324,21 +289,21 @@ namespace osu.Framework.Graphics.Lines if (segmentToDraw.HasValue) { SegmentWithThickness s = new SegmentWithThickness(segmentToDraw.Value, radius, location, modifiedLocation); - addSegmentQuads(s, texRect); - connect(s, lastDrawnSegment, texRect); - addEndCap(s, texRect); + addSegmentQuad(s); + connect(s, lastDrawnSegment); + addEndCap(s); } } /// /// Connects the start of the segment to the end of a previous one. /// - private void connect(SegmentWithThickness segment, SegmentWithThickness? prevSegment, RectangleF texRect) + private void connect(SegmentWithThickness segment, SegmentWithThickness? prevSegment) { if (!prevSegment.HasValue) { // Nothing to connect to - add start cap - addStartCap(segment, texRect); + addStartCap(segment); return; } @@ -347,13 +312,13 @@ namespace osu.Framework.Graphics.Lines default: case SegmentStartLocation.End: // Segment starts at the end of the previous one - addConnectionBetween(segment, prevSegment.Value, texRect); + addConnectionBetween(segment, prevSegment.Value); break; case SegmentStartLocation.Start: case SegmentStartLocation.Middle: // Segment starts at the start or the middle of the previous one - add end cap to the previous segment - addEndCap(prevSegment.Value, texRect); + addEndCap(prevSegment.Value); break; case SegmentStartLocation.Outside: @@ -367,39 +332,19 @@ namespace osu.Framework.Graphics.Lines // line since horizontal one will pass through it. However, that wouldn't be the case if horizontal line was located at // the middle and so end cap would be required. if (segment.StartLocation != SegmentStartLocation.End) - addEndCap(prevSegment.Value, texRect); + addEndCap(prevSegment.Value); // add start cap to the current one - addStartCap(segment, texRect); + addStartCap(segment); break; } } - private void addConnectionBetween(SegmentWithThickness segment, SegmentWithThickness prevSegment, RectangleF texRect) - { - float thetaDiff = segment.Guide.Theta - prevSegment.Guide.Theta; - addSegmentCaps(thetaDiff, segment.EdgeLeft, segment.EdgeRight, prevSegment.EdgeLeft, prevSegment.EdgeRight, texRect); - } + private void addEndCap(SegmentWithThickness segment) => + addCap(new Line(segment.EdgeLeft.EndPoint, segment.EdgeRight.EndPoint)); - private void addEndCap(SegmentWithThickness segment, RectangleF texRect) - { - // Explanation of semi-circle caps: - // Semi-circles are essentially 180 degree caps. So to create these caps, we - // can simply "fake" a segment that's 180 degrees flipped. This works because - // we are taking advantage of the fact that a path which makes a 180 degree - // bend would have a semi-circle cap. - - Line flippedLeft = new Line(segment.EdgeRight.EndPoint, segment.EdgeRight.StartPoint); - Line flippedRight = new Line(segment.EdgeLeft.EndPoint, segment.EdgeLeft.StartPoint); - addSegmentCaps(MathF.PI, flippedLeft, flippedRight, segment.EdgeLeft, segment.EdgeRight, texRect); - } - - private void addStartCap(SegmentWithThickness segment, RectangleF texRect) - { - Line flippedLeft = new Line(segment.EdgeRight.EndPoint, segment.EdgeRight.StartPoint); - Line flippedRight = new Line(segment.EdgeLeft.EndPoint, segment.EdgeLeft.StartPoint); - addSegmentCaps(MathF.PI, segment.EdgeLeft, segment.EdgeRight, flippedLeft, flippedRight, texRect); - } + private void addStartCap(SegmentWithThickness segment) => + addCap(new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint)); private static float progressFor(Line line, float length, Vector2 point) { @@ -467,6 +412,20 @@ namespace osu.Framework.Graphics.Lines EdgeRight = new Line(Guide.StartPoint - ortho * distance, Guide.EndPoint - ortho * distance); } } + + [StructLayout(LayoutKind.Sequential)] + public struct PathVertex : IEquatable, IVertex + { + [VertexMember(2, VertexAttribPointerType.Float)] + public Vector2 Position; + + [VertexMember(2, VertexAttribPointerType.Float)] + public Vector2 RelativePos; + + public readonly bool Equals(PathVertex other) => + Position.Equals(other.Position) + && RelativePos.Equals(other.RelativePos); + } } } } diff --git a/osu.Framework/Graphics/Lines/SmoothPath.cs b/osu.Framework/Graphics/Lines/SmoothPath.cs index e01202032..6a683109f 100644 --- a/osu.Framework/Graphics/Lines/SmoothPath.cs +++ b/osu.Framework/Graphics/Lines/SmoothPath.cs @@ -4,7 +4,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Caching; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osuTK.Graphics; @@ -38,18 +37,6 @@ namespace osu.Framework.Graphics.Lines } } - private Color4? customBackgroundColour; - - /// - /// The background colour to be used for the frame buffer this path is rendered to. - /// For , this automatically defaults to the colour at 0 (the outermost colour of the path) to avoid aliasing issues. - /// - public override Color4 BackgroundColour - { - get => customBackgroundColour ?? base.BackgroundColour; - set => customBackgroundColour = base.BackgroundColour = value; - } - private readonly Cached textureCache = new Cached(); protected void InvalidateTexture() @@ -63,7 +50,7 @@ namespace osu.Framework.Graphics.Lines if (textureCache.IsValid) return; - int textureWidth = (int)PathRadius * 2; + int textureWidth = (int)Math.Max(PathRadius, 1) * 2; //initialise background var raw = new Image(textureWidth, 1); @@ -89,9 +76,6 @@ namespace osu.Framework.Graphics.Lines Texture = texture; } - if (customBackgroundColour == null) - base.BackgroundColour = ColourAt(0).Opacity(0); - textureCache.Validate(); } diff --git a/osu.Framework/Resources/Shaders/sh_Path.fs b/osu.Framework/Resources/Shaders/sh_Path.fs new file mode 100644 index 000000000..2eebd778c --- /dev/null +++ b/osu.Framework/Resources/Shaders/sh_Path.fs @@ -0,0 +1,37 @@ +#ifndef PATH_FS +#define PATH_FS + +#include "sh_Utils.h" +#include "sh_Masking.h" +#include "sh_TextureWrapping.h" + +layout(location = 2) in mediump vec2 v_TexCoord; + +// FrameBuffer texture +layout(set = 0, binding = 0) uniform lowp texture2D m_Texture; +layout(set = 0, binding = 1) uniform lowp sampler m_Sampler; + +// Path texture +layout(set = 1, binding = 0) uniform lowp texture2D m_Texture1; +layout(set = 1, binding = 1) uniform lowp sampler m_Sampler1; + +layout(std140, set = 2, binding = 0) uniform m_PathTextureParameters +{ + highp vec4 TexRect1; +}; + +layout(location = 0) out vec4 o_Colour; + +void main(void) +{ + lowp vec4 frameBuffer = texture(sampler2D(m_Texture, m_Sampler), v_TexCoord, -0.9); + if (frameBuffer.r == 0.0) + { + o_Colour = vec4(0.0); + return; + } + lowp vec4 pathCol = texture(sampler2D(m_Texture1, m_Sampler1), TexRect1.xy + vec2(frameBuffer.r, 0.0) * TexRect1.zw, -0.9); + o_Colour = getRoundedColor(pathCol, v_TexCoord); +} + +#endif diff --git a/osu.Framework/Resources/Shaders/sh_PathPrepass.fs b/osu.Framework/Resources/Shaders/sh_PathPrepass.fs new file mode 100644 index 000000000..982955dc7 --- /dev/null +++ b/osu.Framework/Resources/Shaders/sh_PathPrepass.fs @@ -0,0 +1,14 @@ +#ifndef PATH_PREPASS_FS +#define PATH_PREPASS_FS + +layout(location = 0) in highp vec2 v_RelativePos; + +layout(location = 0) out vec4 o_Colour; + +void main(void) +{ + highp float dst = clamp(distance(v_RelativePos, vec2(0.0)), 0.0, 1.0); + o_Colour = vec4(vec3(1.0 - dst), 1.0); +} + +#endif \ No newline at end of file diff --git a/osu.Framework/Resources/Shaders/sh_PathPrepass.vs b/osu.Framework/Resources/Shaders/sh_PathPrepass.vs new file mode 100644 index 000000000..68de4bd68 --- /dev/null +++ b/osu.Framework/Resources/Shaders/sh_PathPrepass.vs @@ -0,0 +1,17 @@ +#ifndef PATH_PREPASS_VS +#define PATH_PREPASS_VS + +#include "sh_Utils.h" + +layout(location = 0) in highp vec2 m_Position; +layout(location = 1) in highp vec2 m_RelativePos; + +layout(location = 0) out highp vec2 v_RelativePos; + +void main(void) +{ + v_RelativePos = m_RelativePos; + gl_Position = g_ProjMatrix * vec4(m_Position, 1.0, 1.0); +} + +#endif \ No newline at end of file From fec6321f72084a07eeb822ca3d3ba10ea3963d0e Mon Sep 17 00:00:00 2001 From: marvin Date: Fri, 24 Oct 2025 21:08:25 +0200 Subject: [PATCH 02/72] Extract TabbableContainer focus movement logic into extension function --- .../Containers/ContainerExtensions.cs | 67 +++++++++++++++++++ .../Graphics/Containers/TabbableContainer.cs | 51 +------------- 2 files changed, 68 insertions(+), 50 deletions(-) diff --git a/osu.Framework/Graphics/Containers/ContainerExtensions.cs b/osu.Framework/Graphics/Containers/ContainerExtensions.cs index 72730b190..04d22155a 100644 --- a/osu.Framework/Graphics/Containers/ContainerExtensions.cs +++ b/osu.Framework/Graphics/Containers/ContainerExtensions.cs @@ -4,7 +4,9 @@ using osuTK; using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Extensions.ObjectExtensions; namespace osu.Framework.Graphics.Containers { @@ -92,5 +94,70 @@ namespace osu.Framework.Graphics.Containers return container; } + + /// + /// Searches the subtree for s and moves focus to the before/after the one currently focused. + /// + /// Container to search for valid focus targets in. + /// Whether to traverse the container's children in reverse when looking for the next target. + /// + /// Determines the behaviour when the currently focused drawable isn't rooted at this container. + /// If true, then focus will not be moved. + /// If false, then focus will be moved to the first valid child. + /// + /// Whether focus was moved to a new . + public static bool MoveFocusToNextTabStop(this CompositeDrawable target, bool reverse = false, bool requireFocusedChild = true) + { + var currentlyFocused = target.GetContainingInputManager()?.FocusedDrawable; + + if (currentlyFocused == null && requireFocusedChild) + return false; + + var focusManager = target.GetContainingFocusManager().AsNonNull(); + + Stack stack = new Stack(); + stack.Push(target); // Extra push for circular tabbing + stack.Push(target); + + // If we don't have a currently focused child we pretend we've already encountered our target child to move focus to the first valid target. + bool started = currentlyFocused == null; + + while (stack.Count > 0) + { + var drawable = stack.Pop(); + + if (!started) + started = ReferenceEquals(drawable, currentlyFocused); + else if (drawable is ITabbableContainer tabbable && tabbable.CanBeTabbedTo && focusManager.ChangeFocus(drawable)) + return true; + + if (drawable is CompositeDrawable composite) + { + var newChildren = composite.InternalChildren.ToList(); + int bound = reverse ? newChildren.Count : 0; + + if (!started) + { + // Find currently focused element, to know starting point + int index = newChildren.IndexOf(currentlyFocused); + if (index != -1) + bound = reverse ? index + 1 : index; + } + + if (reverse) + { + for (int i = 0; i < bound; i++) + stack.Push(newChildren[i]); + } + else + { + for (int i = newChildren.Count - 1; i >= bound; i--) + stack.Push(newChildren[i]); + } + } + } + + return false; + } } } diff --git a/osu.Framework/Graphics/Containers/TabbableContainer.cs b/osu.Framework/Graphics/Containers/TabbableContainer.cs index 9a49804fb..af6781a97 100644 --- a/osu.Framework/Graphics/Containers/TabbableContainer.cs +++ b/osu.Framework/Graphics/Containers/TabbableContainer.cs @@ -3,9 +3,6 @@ #nullable disable -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Input.Events; using osuTK.Input; @@ -44,54 +41,8 @@ namespace osu.Framework.Graphics.Containers if (TabbableContentContainer == null || e.Key != Key.Tab) return false; - moveToNextTabStop(TabbableContentContainer, e.ShiftPressed); + TabbableContentContainer.MoveFocusToNextTabStop(e.ShiftPressed); return true; } - - private void moveToNextTabStop(CompositeDrawable target, bool reverse) - { - var focusManager = GetContainingFocusManager().AsNonNull(); - - Stack stack = new Stack(); - stack.Push(target); // Extra push for circular tabbing - stack.Push(target); - - bool started = false; - - while (stack.Count > 0) - { - var drawable = stack.Pop(); - - if (!started) - started = ReferenceEquals(drawable, this); - else if (drawable is ITabbableContainer tabbable && tabbable.CanBeTabbedTo && focusManager.ChangeFocus(drawable)) - return; - - if (drawable is CompositeDrawable composite) - { - var newChildren = composite.InternalChildren.ToList(); - int bound = reverse ? newChildren.Count : 0; - - if (!started) - { - // Find self, to know starting point - int index = newChildren.IndexOf(this); - if (index != -1) - bound = reverse ? index + 1 : index; - } - - if (reverse) - { - for (int i = 0; i < bound; i++) - stack.Push(newChildren[i]); - } - else - { - for (int i = newChildren.Count - 1; i >= bound; i--) - stack.Push(newChildren[i]); - } - } - } - } } } From 6bd11b3d516cc1857be2ec01c23da9e158e1cb9f Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Fri, 24 Oct 2025 23:57:24 +0300 Subject: [PATCH 03/72] Use single triangle for small angle connections --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index dac05e3c5..5573a728d 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -106,6 +106,8 @@ namespace osu.Framework.Graphics.Lines private void addConnectionBetween(SegmentWithThickness segment, SegmentWithThickness prevSegment) { + Debug.Assert(triangleBatch != null); + float thetaDiff = segment.Guide.Theta - prevSegment.Guide.Theta; if (Math.Abs(thetaDiff) > MathF.PI) @@ -118,8 +120,25 @@ namespace osu.Framework.Graphics.Lines Vector2 end = thetaDiff > 0f ? segment.EdgeRight.StartPoint : segment.EdgeLeft.StartPoint; Line start = thetaDiff > 0f ? new Line(prevSegment.EdgeLeft.EndPoint, prevSegment.EdgeRight.EndPoint) : new Line(prevSegment.EdgeRight.EndPoint, prevSegment.EdgeLeft.EndPoint); - // less than 90 degrees, single quad - if (Math.Abs(thetaDiff) < Math.PI * 0.5) + if (Math.Abs(thetaDiff) < Math.PI / max_res) // small angle, 1 triangle is enough + { + triangleBatch.Add(new PathVertex + { + Position = start.EndPoint, + RelativePos = new Vector2(1, 0) + }); + triangleBatch.Add(new PathVertex + { + Position = origin, + RelativePos = Vector2.Zero + }); + triangleBatch.Add(new PathVertex + { + Position = end, + RelativePos = new Vector2(1, 0) + }); + } + else if (Math.Abs(thetaDiff) < Math.PI * 0.5) // less than 90 degrees, single quad { Vector2 middle = Vector2.Lerp(start.EndPoint, end, 0.5f); Vector2 v3 = Vector2.Lerp(origin, middle, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle)); From de7904a671ff2acbdd6041025df3dac5a9dc031e Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 25 Oct 2025 00:07:02 +0300 Subject: [PATCH 04/72] Add xmldoc explaining relativepos --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 5573a728d..0b30b92f6 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -438,6 +438,11 @@ namespace osu.Framework.Graphics.Lines [VertexMember(2, VertexAttribPointerType.Float)] public Vector2 Position; + /// + /// A position of a vertex, where distance from that position to (0,0) defines it's colour. + /// Distance 0 means white and 1 means black. + /// This position is being interpolated between vertices and final colour is being applied in the fragment shader. + /// [VertexMember(2, VertexAttribPointerType.Float)] public Vector2 RelativePos; From ece0861857f6ec6a9d288ee356c38bfee9476385 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 25 Oct 2025 00:30:45 +0300 Subject: [PATCH 05/72] Add xmldoc explaining cap drawing --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 0b30b92f6..b305dc297 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -81,6 +81,8 @@ namespace osu.Framework.Graphics.Lines private void addCap(Line cap) { + // The provided line is perpendicular to the end/start of a segment. + // To get the remaining quad positions we are expanding said segment by the path radius. Vector2 ortho = cap.OrthogonalDirection; if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) ortho = Vector2.UnitY; From 15959793dd37e135a336686acafa7ad2c3c3376c Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Mon, 27 Oct 2025 03:07:41 +0300 Subject: [PATCH 06/72] Fix unsmooth path jitter Noticeable in TestScenePathCapAngles --- osu.Framework/Resources/Shaders/sh_Path.fs | 2 +- osu.Framework/Resources/Shaders/sh_PathPrepass.fs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Framework/Resources/Shaders/sh_Path.fs b/osu.Framework/Resources/Shaders/sh_Path.fs index 2eebd778c..92d5a9752 100644 --- a/osu.Framework/Resources/Shaders/sh_Path.fs +++ b/osu.Framework/Resources/Shaders/sh_Path.fs @@ -31,7 +31,7 @@ void main(void) return; } lowp vec4 pathCol = texture(sampler2D(m_Texture1, m_Sampler1), TexRect1.xy + vec2(frameBuffer.r, 0.0) * TexRect1.zw, -0.9); - o_Colour = getRoundedColor(pathCol, v_TexCoord); + o_Colour = getRoundedColor(vec4(pathCol.rgb, pathCol.a * frameBuffer.a), v_TexCoord); } #endif diff --git a/osu.Framework/Resources/Shaders/sh_PathPrepass.fs b/osu.Framework/Resources/Shaders/sh_PathPrepass.fs index 982955dc7..5172d6844 100644 --- a/osu.Framework/Resources/Shaders/sh_PathPrepass.fs +++ b/osu.Framework/Resources/Shaders/sh_PathPrepass.fs @@ -8,7 +8,7 @@ layout(location = 0) out vec4 o_Colour; void main(void) { highp float dst = clamp(distance(v_RelativePos, vec2(0.0)), 0.0, 1.0); - o_Colour = vec4(vec3(1.0 - dst), 1.0); + o_Colour = vec4(vec3(1.0 - dst), float(dst < 1.0)); } #endif \ No newline at end of file From 9b9a0dd85d5abcc731ad54693a606b0877d6783c Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Fri, 31 Oct 2025 00:52:42 +0300 Subject: [PATCH 07/72] Draw caps and segments as 2 quads Due to potentially missing pixels during rasterization --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index b305dc297..1d556e0eb 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -79,7 +79,7 @@ namespace osu.Framework.Graphics.Lines renderer.PopLocalMatrix(); } - private void addCap(Line cap) + private void addCap(Line cap, Vector2 origin) { // The provided line is perpendicular to the end/start of a segment. // To get the remaining quad positions we are expanding said segment by the path radius. @@ -89,11 +89,17 @@ namespace osu.Framework.Graphics.Lines Vector2 v2 = cap.StartPoint + ortho * radius; Vector2 v3 = cap.EndPoint + ortho * radius; + Vector2 v4 = (v2 + v3) / 2; drawQuad ( - new Quad(cap.StartPoint, v2, cap.EndPoint, v3), - new Quad(new Vector2(0, -1), new Vector2(1f, -1), new Vector2(0, 1), Vector2.One) + new Quad(cap.StartPoint, v2, origin, v4), + new Quad(new Vector2(0, -1), new Vector2(1f, -1), new Vector2(0, 0), new Vector2(1, 0)) + ); + drawQuad + ( + new Quad(origin, v4, cap.EndPoint, v3), + new Quad(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 1), Vector2.One) ); } @@ -101,8 +107,13 @@ namespace osu.Framework.Graphics.Lines { drawQuad ( - new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint), - new Quad(new Vector2(0, -1), new Vector2(0, -1), new Vector2(0, 1), new Vector2(0, 1)) + new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.Guide.StartPoint, segment.Guide.EndPoint), + new Quad(new Vector2(0, -1), new Vector2(0, -1), new Vector2(0, 0), new Vector2(0, 0)) + ); + drawQuad + ( + new Quad(segment.Guide.StartPoint, segment.Guide.EndPoint, segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint), + new Quad(new Vector2(0, 0), new Vector2(0, 0), new Vector2(0, 1), new Vector2(0, 1)) ); } @@ -362,10 +373,10 @@ namespace osu.Framework.Graphics.Lines } private void addEndCap(SegmentWithThickness segment) => - addCap(new Line(segment.EdgeLeft.EndPoint, segment.EdgeRight.EndPoint)); + addCap(new Line(segment.EdgeLeft.EndPoint, segment.EdgeRight.EndPoint), segment.Guide.EndPoint); private void addStartCap(SegmentWithThickness segment) => - addCap(new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint)); + addCap(new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint), segment.Guide.StartPoint); private static float progressFor(Line line, float length, Vector2 point) { From 74e4f29e78e2099281056234bb078f732ffe36e8 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Wed, 29 Oct 2025 23:00:58 +0300 Subject: [PATCH 08/72] Increase path approximation resolution during draw process --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 9c7ad8779..9b498ea02 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -286,7 +286,7 @@ namespace osu.Framework.Graphics.Lines Vector2 closest = segmentToDraw.Value.At(progress); // Expand segment if next end point is located within a line passing through it - if (Precision.AlmostEquals(closest, segments[i].EndPoint, 0.1f)) + if (Precision.AlmostEquals(closest, segments[i].EndPoint, 0.01f)) { if (progress < 0) { From 8779f732c3bf6475d3eea7a9e869a9bd55e8f2d3 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Thu, 30 Oct 2025 23:47:14 +0300 Subject: [PATCH 09/72] Improve next segment start location calculation --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 9b498ea02..aae041d7e 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -267,6 +267,7 @@ namespace osu.Framework.Graphics.Lines Line? segmentToDraw = null; SegmentStartLocation location = SegmentStartLocation.Outside; SegmentStartLocation modifiedLocation = SegmentStartLocation.Outside; + SegmentStartLocation nextLocation = SegmentStartLocation.End; SegmentWithThickness? lastDrawnSegment = null; for (int i = 0; i < segments.Count; i++) @@ -293,11 +294,17 @@ namespace osu.Framework.Graphics.Lines // expand segment backwards segmentToDraw = new Line(segments[i].EndPoint, segmentToDraw.Value.EndPoint); modifiedLocation = SegmentStartLocation.Outside; + nextLocation = SegmentStartLocation.Start; } else if (progress > 1) { // or forward segmentToDraw = new Line(segmentToDraw.Value.StartPoint, segments[i].EndPoint); + nextLocation = SegmentStartLocation.End; + } + else + { + nextLocation = SegmentStartLocation.Middle; } } else // Otherwise draw the expanded segment @@ -307,11 +314,9 @@ namespace osu.Framework.Graphics.Lines connect(s, lastDrawnSegment, texRect); lastDrawnSegment = s; - - // Figure out at which point within currently drawn segment the new one starts - float p = progressFor(segmentToDraw.Value, segmentToDrawLength, segments[i].StartPoint); segmentToDraw = segments[i]; - location = modifiedLocation = Precision.AlmostEquals(p, 1f) ? SegmentStartLocation.End : Precision.AlmostEquals(p, 0f) ? SegmentStartLocation.Start : SegmentStartLocation.Middle; + location = modifiedLocation = nextLocation; + nextLocation = SegmentStartLocation.End; } } else From f58aa3fdc06165c37e677c3a84d4106791aaaa98 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 1 Nov 2025 01:32:52 +0300 Subject: [PATCH 10/72] Adjust vertex draw order Looks cleaner when inspecting draw calls --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 1d556e0eb..276d056e3 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -98,8 +98,8 @@ namespace osu.Framework.Graphics.Lines ); drawQuad ( - new Quad(origin, v4, cap.EndPoint, v3), - new Quad(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 1), Vector2.One) + new Quad(cap.EndPoint, v3, origin, v4), + new Quad(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, 0), new Vector2(1, 0)) ); } @@ -112,8 +112,8 @@ namespace osu.Framework.Graphics.Lines ); drawQuad ( - new Quad(segment.Guide.StartPoint, segment.Guide.EndPoint, segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint), - new Quad(new Vector2(0, 0), new Vector2(0, 0), new Vector2(0, 1), new Vector2(0, 1)) + new Quad(segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint, segment.Guide.StartPoint, segment.Guide.EndPoint), + new Quad(new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 0), new Vector2(0, 0)) ); } From 55f511311ba8ccf1c85cee9d339cc5023ad5d92c Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 1 Nov 2025 22:36:56 +0300 Subject: [PATCH 11/72] Use 3 triangles for caps --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 276d056e3..a7b91285b 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -81,6 +81,8 @@ namespace osu.Framework.Graphics.Lines private void addCap(Line cap, Vector2 origin) { + Debug.Assert(triangleBatch != null); + // The provided line is perpendicular to the end/start of a segment. // To get the remaining quad positions we are expanding said segment by the path radius. Vector2 ortho = cap.OrthogonalDirection; @@ -89,18 +91,28 @@ namespace osu.Framework.Graphics.Lines Vector2 v2 = cap.StartPoint + ortho * radius; Vector2 v3 = cap.EndPoint + ortho * radius; - Vector2 v4 = (v2 + v3) / 2; drawQuad ( - new Quad(cap.StartPoint, v2, origin, v4), - new Quad(new Vector2(0, -1), new Vector2(1f, -1), new Vector2(0, 0), new Vector2(1, 0)) - ); - drawQuad - ( - new Quad(cap.EndPoint, v3, origin, v4), - new Quad(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, 0), new Vector2(1, 0)) + new Quad(cap.StartPoint, v2, origin, v3), + new Quad(new Vector2(0, -1), new Vector2(1f, -1), new Vector2(0, 0), new Vector2(1, 1)) ); + + triangleBatch.Add(new PathVertex + { + Position = origin, + RelativePos = Vector2.Zero + }); + triangleBatch.Add(new PathVertex + { + Position = v3, + RelativePos = Vector2.One + }); + triangleBatch.Add(new PathVertex + { + Position = cap.EndPoint, + RelativePos = new Vector2(0, 1) + }); } private void addSegmentQuad(SegmentWithThickness segment) From 4a7498130d804e88effa9d87273124371fff59a8 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 1 Nov 2025 22:58:31 +0300 Subject: [PATCH 12/72] Use max 3 triangles for connections --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 120 +++++++++--------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index a7b91285b..b660503d2 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -81,8 +81,6 @@ namespace osu.Framework.Graphics.Lines private void addCap(Line cap, Vector2 origin) { - Debug.Assert(triangleBatch != null); - // The provided line is perpendicular to the end/start of a segment. // To get the remaining quad positions we are expanding said segment by the path radius. Vector2 ortho = cap.OrthogonalDirection; @@ -95,24 +93,14 @@ namespace osu.Framework.Graphics.Lines drawQuad ( new Quad(cap.StartPoint, v2, origin, v3), - new Quad(new Vector2(0, -1), new Vector2(1f, -1), new Vector2(0, 0), new Vector2(1, 1)) + new Quad(new Vector2(0, -1), new Vector2(1, -1), Vector2.Zero, Vector2.One) ); - triangleBatch.Add(new PathVertex - { - Position = origin, - RelativePos = Vector2.Zero - }); - triangleBatch.Add(new PathVertex - { - Position = v3, - RelativePos = Vector2.One - }); - triangleBatch.Add(new PathVertex - { - Position = cap.EndPoint, - RelativePos = new Vector2(0, 1) - }); + drawTriangle + ( + new Triangle(origin, v3, cap.EndPoint), + new Triangle(Vector2.Zero, Vector2.One, new Vector2(0, 1)) + ); } private void addSegmentQuad(SegmentWithThickness segment) @@ -120,19 +108,17 @@ namespace osu.Framework.Graphics.Lines drawQuad ( new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.Guide.StartPoint, segment.Guide.EndPoint), - new Quad(new Vector2(0, -1), new Vector2(0, -1), new Vector2(0, 0), new Vector2(0, 0)) + new Quad(new Vector2(0, -1), new Vector2(0, -1), Vector2.Zero, Vector2.Zero) ); drawQuad ( new Quad(segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint, segment.Guide.StartPoint, segment.Guide.EndPoint), - new Quad(new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 0), new Vector2(0, 0)) + new Quad(new Vector2(0, 1), new Vector2(0, 1), Vector2.Zero, Vector2.Zero) ); } private void addConnectionBetween(SegmentWithThickness segment, SegmentWithThickness prevSegment) { - Debug.Assert(triangleBatch != null); - float thetaDiff = segment.Guide.Theta - prevSegment.Guide.Theta; if (Math.Abs(thetaDiff) > MathF.PI) @@ -145,32 +131,22 @@ namespace osu.Framework.Graphics.Lines Vector2 end = thetaDiff > 0f ? segment.EdgeRight.StartPoint : segment.EdgeLeft.StartPoint; Line start = thetaDiff > 0f ? new Line(prevSegment.EdgeLeft.EndPoint, prevSegment.EdgeRight.EndPoint) : new Line(prevSegment.EdgeRight.EndPoint, prevSegment.EdgeLeft.EndPoint); - if (Math.Abs(thetaDiff) < Math.PI / max_res) // small angle, 1 triangle is enough + if (Math.Abs(thetaDiff) < Math.PI / max_res) // small angle, 1 triangle { - triangleBatch.Add(new PathVertex - { - Position = start.EndPoint, - RelativePos = new Vector2(1, 0) - }); - triangleBatch.Add(new PathVertex - { - Position = origin, - RelativePos = Vector2.Zero - }); - triangleBatch.Add(new PathVertex - { - Position = end, - RelativePos = new Vector2(1, 0) - }); + drawTriangle + ( + new Triangle(start.EndPoint, origin, end), + new Triangle(new Vector2(1, 0), Vector2.Zero, new Vector2(1, 0)) + ); } - else if (Math.Abs(thetaDiff) < Math.PI * 0.5) // less than 90 degrees, single quad + else if (Math.Abs(thetaDiff) < Math.PI * 0.5) // less than 90 degrees, 2 triangles { Vector2 middle = Vector2.Lerp(start.EndPoint, end, 0.5f); Vector2 v3 = Vector2.Lerp(origin, middle, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle)); drawQuad(new Quad(start.EndPoint, v3, origin, end), origin); } - else // more than 90 degrees - 2 quads + else // more than 90 degrees - 3 triangles { Vector2 ortho = start.OrthogonalDirection; if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) @@ -180,25 +156,49 @@ namespace osu.Framework.Graphics.Lines Vector2 v2 = start.EndPoint + Math.Sign(thetaDiff) * ortho * radius; Vector2 middle = Vector2.Lerp(v1, v2, 0.5f); - drawQuad - ( - new Quad(start.EndPoint, v2, origin, middle), - new Quad - ( - new Vector2(0, -1), - new Vector2(1, -1), - Vector2.Zero, - new Vector2(1, 0) - ) - ); - Vector2 middle2 = Vector2.Lerp(middle, end, 0.5f); Vector2 v3 = Vector2.Lerp(origin, middle2, radius / (float)Math.Cos((Math.Abs(thetaDiff) - Math.PI * 0.5) * 0.5) / Vector2.Distance(origin, middle2)); - drawQuad(new Quad(middle, v3, origin, end), origin); + drawQuad(new Quad(start.EndPoint, v2, origin, v3), origin); + drawTriangle(new Triangle(origin, v3, end), origin); } } + private void drawTriangle(Triangle triangle, Vector2 origin) + { + drawTriangle + ( + triangle, + new Triangle + ( + Vector2.Divide(triangle.P0 - origin, radius), + Vector2.Divide(triangle.P1 - origin, radius), + Vector2.Divide(triangle.P2 - origin, radius) + ) + ); + } + + private void drawTriangle(Triangle triangle, Triangle relativePos) + { + Debug.Assert(triangleBatch != null); + + triangleBatch.Add(new PathVertex + { + Position = triangle.P0, + RelativePos = relativePos.P0 + }); + triangleBatch.Add(new PathVertex + { + Position = triangle.P1, + RelativePos = relativePos.P1 + }); + triangleBatch.Add(new PathVertex + { + Position = triangle.P2, + RelativePos = relativePos.P2 + }); + } + private void drawQuad(Quad quad, Vector2 origin) { drawQuad @@ -214,40 +214,40 @@ namespace osu.Framework.Graphics.Lines ); } - private void drawQuad(Quad quad, Quad relativeQuad) + private void drawQuad(Quad quad, Quad relativePos) { Debug.Assert(triangleBatch != null); triangleBatch.Add(new PathVertex { Position = quad.TopLeft, - RelativePos = relativeQuad.TopLeft + RelativePos = relativePos.TopLeft }); triangleBatch.Add(new PathVertex { Position = quad.TopRight, - RelativePos = relativeQuad.TopRight + RelativePos = relativePos.TopRight }); triangleBatch.Add(new PathVertex { Position = quad.BottomLeft, - RelativePos = relativeQuad.BottomLeft + RelativePos = relativePos.BottomLeft }); triangleBatch.Add(new PathVertex { Position = quad.BottomLeft, - RelativePos = relativeQuad.BottomLeft + RelativePos = relativePos.BottomLeft }); triangleBatch.Add(new PathVertex { Position = quad.TopRight, - RelativePos = relativeQuad.TopRight + RelativePos = relativePos.TopRight }); triangleBatch.Add(new PathVertex { Position = quad.BottomRight, - RelativePos = relativeQuad.BottomRight + RelativePos = relativePos.BottomRight }); } From b461871e78012fe59d98e121c5d1352e76048528 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 1 Nov 2025 23:35:43 +0300 Subject: [PATCH 13/72] Don't draw anything if path radius is set to 0 --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index b660503d2..f26d4dfb4 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -50,7 +50,7 @@ namespace osu.Framework.Graphics.Lines { base.Draw(renderer); - if (segments.Count == 0 || pathShader == null) + if (segments.Count == 0 || pathShader == null || radius == 0f) return; // We multiply the size args by 3 such that the amount of vertices is a multiple of the amount of vertices From 7a0d121ca68b98b8a3fb19557de5893670999cef Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Fri, 7 Nov 2025 02:22:20 +0300 Subject: [PATCH 14/72] Reduce vertex count again But without potential missing pixels --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 56 +++++-------------- 1 file changed, 15 insertions(+), 41 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index f3dbaf6c9..b7f4d4aa9 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -79,7 +79,7 @@ namespace osu.Framework.Graphics.Lines renderer.PopLocalMatrix(); } - private void addCap(Line cap, Vector2 origin) + private void addCap(Line cap) { // The provided line is perpendicular to the end/start of a segment. // To get the remaining quad positions we are expanding said segment by the path radius. @@ -92,14 +92,8 @@ namespace osu.Framework.Graphics.Lines drawQuad ( - new Quad(cap.StartPoint, v2, origin, v3), - new Quad(new Vector2(0, -1), new Vector2(1, -1), Vector2.Zero, Vector2.One) - ); - - drawTriangle - ( - new Triangle(origin, v3, cap.EndPoint), - new Triangle(Vector2.Zero, Vector2.One, new Vector2(0, 1)) + new Quad(cap.StartPoint, v2, cap.EndPoint, v3), + new Quad(new Vector2(0, -1), new Vector2(1, -1), new Vector2(0, 1), Vector2.One) ); } @@ -107,13 +101,8 @@ namespace osu.Framework.Graphics.Lines { drawQuad ( - new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.Guide.StartPoint, segment.Guide.EndPoint), - new Quad(new Vector2(0, -1), new Vector2(0, -1), Vector2.Zero, Vector2.Zero) - ); - drawQuad - ( - new Quad(segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint, segment.Guide.StartPoint, segment.Guide.EndPoint), - new Quad(new Vector2(0, 1), new Vector2(0, 1), Vector2.Zero, Vector2.Zero) + new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint), + new Quad(new Vector2(0, -1), new Vector2(0, -1), new Vector2(0, 1), new Vector2(0, 1)) ); } @@ -128,39 +117,24 @@ namespace osu.Framework.Graphics.Lines return; Vector2 origin = segment.Guide.StartPoint; - Vector2 end = thetaDiff > 0f ? segment.EdgeRight.StartPoint : segment.EdgeLeft.StartPoint; + Line end = thetaDiff > 0f ? new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint) : new Line(segment.EdgeLeft.StartPoint, segment.EdgeRight.StartPoint); Line start = thetaDiff > 0f ? new Line(prevSegment.EdgeLeft.EndPoint, prevSegment.EdgeRight.EndPoint) : new Line(prevSegment.EdgeRight.EndPoint, prevSegment.EdgeLeft.EndPoint); if (Math.Abs(thetaDiff) < Math.PI / max_res) // small angle, 1 triangle { - drawTriangle - ( - new Triangle(start.EndPoint, origin, end), - new Triangle(new Vector2(1, 0), Vector2.Zero, new Vector2(1, 0)) - ); + drawTriangle(new Triangle(start.EndPoint, Vector2.Lerp(start.StartPoint, end.EndPoint, 0.5f), end.StartPoint), origin); } else if (Math.Abs(thetaDiff) < Math.PI * 0.5) // less than 90 degrees, 2 triangles { - Vector2 middle = Vector2.Lerp(start.EndPoint, end, 0.5f); - Vector2 v3 = Vector2.Lerp(origin, middle, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle)); + Vector2 middle1 = Vector2.Lerp(start.EndPoint, end.StartPoint, 0.5f); + Vector2 v3 = Vector2.Lerp(origin, middle1, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle1)); - drawQuad(new Quad(start.EndPoint, v3, origin, end), origin); + Vector2 middle2 = Vector2.Lerp(start.StartPoint, end.EndPoint, 0.5f); + drawQuad(new Quad(start.EndPoint, v3, middle2, end.StartPoint), origin); } - else // more than 90 degrees - 3 triangles + else // more than 90 degrees - cap { - Vector2 ortho = start.OrthogonalDirection; - if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) - ortho = Vector2.UnitY; - - Vector2 v1 = start.StartPoint + Math.Sign(thetaDiff) * ortho * radius; - Vector2 v2 = start.EndPoint + Math.Sign(thetaDiff) * ortho * radius; - Vector2 middle = Vector2.Lerp(v1, v2, 0.5f); - - Vector2 middle2 = Vector2.Lerp(middle, end, 0.5f); - Vector2 v3 = Vector2.Lerp(origin, middle2, radius / (float)Math.Cos((Math.Abs(thetaDiff) - Math.PI * 0.5) * 0.5) / Vector2.Distance(origin, middle2)); - - drawQuad(new Quad(start.EndPoint, v2, origin, v3), origin); - drawTriangle(new Triangle(origin, v3, end), origin); + addEndCap(prevSegment); } } @@ -390,10 +364,10 @@ namespace osu.Framework.Graphics.Lines } private void addEndCap(SegmentWithThickness segment) => - addCap(new Line(segment.EdgeLeft.EndPoint, segment.EdgeRight.EndPoint), segment.Guide.EndPoint); + addCap(new Line(segment.EdgeLeft.EndPoint, segment.EdgeRight.EndPoint)); private void addStartCap(SegmentWithThickness segment) => - addCap(new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint), segment.Guide.StartPoint); + addCap(new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint)); private static float progressFor(Line line, float length, Vector2 point) { From b7ad7e85687b83b8d384c70ce31eef8cd70aefab Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 8 Nov 2025 23:04:47 +0300 Subject: [PATCH 15/72] Rename SegmentWithThickness to DrawableSegment --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 76 ++++++++++++------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index b7f4d4aa9..910628ef7 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -97,16 +97,16 @@ namespace osu.Framework.Graphics.Lines ); } - private void addSegmentQuad(SegmentWithThickness segment) + private void addSegmentQuad(DrawableSegment segment) { drawQuad ( - new Quad(segment.EdgeLeft.StartPoint, segment.EdgeLeft.EndPoint, segment.EdgeRight.StartPoint, segment.EdgeRight.EndPoint), + segment.DrawQuad, new Quad(new Vector2(0, -1), new Vector2(0, -1), new Vector2(0, 1), new Vector2(0, 1)) ); } - private void addConnectionBetween(SegmentWithThickness segment, SegmentWithThickness prevSegment) + private void addConnectionBetween(DrawableSegment segment, DrawableSegment prevSegment) { float thetaDiff = segment.Guide.Theta - prevSegment.Guide.Theta; @@ -117,8 +117,8 @@ namespace osu.Framework.Graphics.Lines return; Vector2 origin = segment.Guide.StartPoint; - Line end = thetaDiff > 0f ? new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint) : new Line(segment.EdgeLeft.StartPoint, segment.EdgeRight.StartPoint); - Line start = thetaDiff > 0f ? new Line(prevSegment.EdgeLeft.EndPoint, prevSegment.EdgeRight.EndPoint) : new Line(prevSegment.EdgeRight.EndPoint, prevSegment.EdgeLeft.EndPoint); + Line end = thetaDiff > 0f ? new Line(segment.BottomLeft, segment.TopLeft) : new Line(segment.TopLeft, segment.BottomLeft); + Line start = thetaDiff > 0f ? new Line(prevSegment.TopRight, prevSegment.BottomRight) : new Line(prevSegment.BottomRight, prevSegment.TopRight); if (Math.Abs(thetaDiff) < Math.PI / max_res) // small angle, 1 triangle { @@ -251,7 +251,7 @@ namespace osu.Framework.Graphics.Lines SegmentStartLocation location = SegmentStartLocation.Outside; SegmentStartLocation modifiedLocation = SegmentStartLocation.Outside; SegmentStartLocation nextLocation = SegmentStartLocation.End; - SegmentWithThickness? lastDrawnSegment = null; + DrawableSegment? lastDrawnSegment = null; for (int i = 0; i < segments.Count; i++) { @@ -292,7 +292,7 @@ namespace osu.Framework.Graphics.Lines } else // Otherwise draw the expanded segment { - SegmentWithThickness s = new SegmentWithThickness(segmentToDraw.Value, radius, location, modifiedLocation); + DrawableSegment s = new DrawableSegment(segmentToDraw.Value, radius, location, modifiedLocation); addSegmentQuad(s); connect(s, lastDrawnSegment); @@ -311,7 +311,7 @@ namespace osu.Framework.Graphics.Lines // Finish drawing last segment (if exists) if (segmentToDraw.HasValue) { - SegmentWithThickness s = new SegmentWithThickness(segmentToDraw.Value, radius, location, modifiedLocation); + DrawableSegment s = new DrawableSegment(segmentToDraw.Value, radius, location, modifiedLocation); addSegmentQuad(s); connect(s, lastDrawnSegment); addEndCap(s); @@ -321,7 +321,7 @@ namespace osu.Framework.Graphics.Lines /// /// Connects the start of the segment to the end of a previous one. /// - private void connect(SegmentWithThickness segment, SegmentWithThickness? prevSegment) + private void connect(DrawableSegment segment, DrawableSegment? prevSegment) { if (!prevSegment.HasValue) { @@ -363,11 +363,11 @@ namespace osu.Framework.Graphics.Lines } } - private void addEndCap(SegmentWithThickness segment) => - addCap(new Line(segment.EdgeLeft.EndPoint, segment.EdgeRight.EndPoint)); + private void addEndCap(DrawableSegment segment) => + addCap(new Line(segment.TopRight, segment.BottomRight)); - private void addStartCap(SegmentWithThickness segment) => - addCap(new Line(segment.EdgeRight.StartPoint, segment.EdgeLeft.StartPoint)); + private void addStartCap(DrawableSegment segment) => + addCap(new Line(segment.BottomLeft, segment.TopLeft)); private static float progressFor(Line line, float length, Vector2 point) { @@ -390,38 +390,53 @@ namespace osu.Framework.Graphics.Lines Outside } - private readonly struct SegmentWithThickness + private readonly struct DrawableSegment { /// - /// The line defining this . + /// The line defining this . /// public Line Guide { get; } /// - /// The line parallel to and located on the left side of it. + /// The draw quad of this . /// - public Line EdgeLeft { get; } + public Quad DrawQuad { get; } /// - /// The line parallel to and located on the right side of it. + /// The top-left position of the of this . /// - public Line EdgeRight { get; } + public Vector2 TopLeft => DrawQuad.TopLeft; /// - /// Position of this relative to the previous one. + /// The top-right position of the of this . + /// + public Vector2 TopRight => DrawQuad.TopRight; + + /// + /// The bottom-left position of the of this . + /// + public Vector2 BottomLeft => DrawQuad.BottomLeft; + + /// + /// The bottom-right position of the of this . + /// + public Vector2 BottomRight => DrawQuad.BottomRight; + + /// + /// Position of this relative to the previous one. /// public SegmentStartLocation StartLocation { get; } /// - /// Position of this modified relative to the previous one. + /// Position of this modified relative to the previous one. /// public SegmentStartLocation ModifiedStartLocation { get; } - /// The line defining this . - /// The distance at which and will be located from the . - /// Position of this relative to the previous one. - /// Position of this modified relative to the previous one. - public SegmentWithThickness(Line guide, float distance, SegmentStartLocation startLocation, SegmentStartLocation modifiedStartLocation) + /// The line defining this . + /// The path radius. + /// Position of this relative to the previous one. + /// Position of this modified relative to the previous one. + public DrawableSegment(Line guide, float radius, SegmentStartLocation startLocation, SegmentStartLocation modifiedStartLocation) { Guide = guide; StartLocation = startLocation; @@ -431,8 +446,13 @@ namespace osu.Framework.Graphics.Lines if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) ortho = Vector2.UnitY; - EdgeLeft = new Line(Guide.StartPoint + ortho * distance, Guide.EndPoint + ortho * distance); - EdgeRight = new Line(Guide.StartPoint - ortho * distance, Guide.EndPoint - ortho * distance); + DrawQuad = new Quad + ( + Guide.StartPoint + ortho * radius, + Guide.EndPoint + ortho * radius, + Guide.StartPoint - ortho * radius, + Guide.EndPoint - ortho * radius + ); } } From ae1ba34f47a080b5d0869555d7dc9ba8c1e8fa49 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 8 Nov 2025 23:20:58 +0300 Subject: [PATCH 16/72] Cleanup connection construction --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 49 +++++++------------ 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 910628ef7..428714213 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -116,26 +116,31 @@ namespace osu.Framework.Graphics.Lines if (thetaDiff == 0f) return; + // more than 90 degrees - add end cap to the previous segment + if (Math.Abs(thetaDiff) > Math.PI * 0.5) + { + addEndCap(prevSegment); + return; + } + Vector2 origin = segment.Guide.StartPoint; Line end = thetaDiff > 0f ? new Line(segment.BottomLeft, segment.TopLeft) : new Line(segment.TopLeft, segment.BottomLeft); Line start = thetaDiff > 0f ? new Line(prevSegment.TopRight, prevSegment.BottomRight) : new Line(prevSegment.BottomRight, prevSegment.TopRight); - if (Math.Abs(thetaDiff) < Math.PI / max_res) // small angle, 1 triangle - { - drawTriangle(new Triangle(start.EndPoint, Vector2.Lerp(start.StartPoint, end.EndPoint, 0.5f), end.StartPoint), origin); - } - else if (Math.Abs(thetaDiff) < Math.PI * 0.5) // less than 90 degrees, 2 triangles - { - Vector2 middle1 = Vector2.Lerp(start.EndPoint, end.StartPoint, 0.5f); - Vector2 v3 = Vector2.Lerp(origin, middle1, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle1)); + // position of a vertex which is located slightly below segments intersection + Vector2 innerVertex = Vector2.Lerp(start.StartPoint, end.EndPoint, 0.5f); - Vector2 middle2 = Vector2.Lerp(start.StartPoint, end.EndPoint, 0.5f); - drawQuad(new Quad(start.EndPoint, v3, middle2, end.StartPoint), origin); - } - else // more than 90 degrees - cap + // at this small angle curvature of the connection isn't noticeable, we can get away with a single triangle + if (Math.Abs(thetaDiff) < Math.PI / max_res) { - addEndCap(prevSegment); + drawTriangle(new Triangle(start.EndPoint, innerVertex, end.StartPoint), origin); + return; } + + // 2 triangles for the remaining cases + Vector2 middle1 = Vector2.Lerp(start.EndPoint, end.StartPoint, 0.5f); + Vector2 outerVertex = Vector2.Lerp(origin, middle1, radius / (float)Math.Cos(Math.Abs(thetaDiff) * 0.5) / Vector2.Distance(origin, middle1)); + drawQuad(new Quad(start.EndPoint, outerVertex, innerVertex, end.StartPoint), origin); } private void drawTriangle(Triangle triangle, Vector2 origin) @@ -227,24 +232,6 @@ namespace osu.Framework.Graphics.Lines private void updateVertexBuffer() { - // Explanation of the terms "left" and "right": - // "Left" and "right" are used here in terms of a typical (Cartesian) coordinate system. - // So "left" corresponds to positive angles (anti-clockwise), and "right" corresponds - // to negative angles (clockwise). - // - // Note that this is not the same as the actually used coordinate system, in which the - // y-axis is flipped. In this system, "left" corresponds to negative angles (clockwise) - // and "right" corresponds to positive angles (anti-clockwise). - // - // Using a Cartesian system makes the calculations more consistent with typical math, - // such as in angle<->coordinate conversions and ortho vectors. For example, the x-unit - // vector (1, 0) has the orthogonal y-unit vector (0, 1). This would be "left" in the - // Cartesian system. But in the actual system, it's "right" and clockwise. Where - // this becomes confusing is during debugging, because OpenGL uses a Cartesian system. - // So to make debugging a bit easier (i.e. w/ RenderDoc or Nsight), this code uses terms - // that make sense in the realm of OpenGL, rather than terms which are technically - // accurate in the actually used "flipped" system. - Debug.Assert(segments.Count > 0); Line? segmentToDraw = null; From 18cba0d1f963d4930ef2916e0cb18400491d9781 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Fri, 14 Nov 2025 19:40:24 +0100 Subject: [PATCH 17/72] Expose usable bounds of displays --- .../TestSceneDisplayBoundsWindowBorder.cs | 20 +++++++++++ .../Visual/Platform/WindowDisplaysPreview.cs | 36 ++++++++++++++++--- osu.Framework/Platform/Display.cs | 11 +++++- .../Platform/SDL2/SDL2Window_Windowing.cs | 12 ++++++- .../Platform/SDL3/SDL3Window_Windowing.cs | 14 +++++++- 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs diff --git a/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs b/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs new file mode 100644 index 000000000..c3799e608 --- /dev/null +++ b/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; + +namespace osu.Framework.Tests.Visual.Platform +{ + public partial class TestSceneDisplayBoundsWindowBorder : FrameworkTestScene + { + [Test] + public void TestDisplayUsableBounds() + { + AddStep("Set up scene", () => Child = new WindowDisplaysPreview(true) + { + RelativeSizeAxes = Axes.Both, + }); + } + } +} diff --git a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs index 78fec929c..4ef7575d7 100644 --- a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs +++ b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -40,8 +41,11 @@ namespace osu.Framework.Tests.Visual.Platform private readonly Bindable windowMode = new Bindable(); private readonly Bindable currentDisplay = new Bindable(); - public WindowDisplaysPreview() + private readonly bool showDisplayBounds; + + public WindowDisplaysPreview(bool showDisplayBounds = false) { + this.showDisplayBounds = showDisplayBounds; Child = new Container { RelativeSizeAxes = Axes.Both, @@ -116,7 +120,7 @@ namespace osu.Framework.Tests.Visual.Platform foreach (var display in displays) { - screenContainer.Add(createScreen(display, window.AsNonNull().CurrentDisplayBindable.Value.Index)); + screenContainer.AddRange(createScreen(display, window.AsNonNull().CurrentDisplayBindable.Value.Index)); bounds = RectangleI.Union(bounds, new RectangleI(display.Bounds.X, display.Bounds.Y, display.Bounds.Width, display.Bounds.Height)); } @@ -130,11 +134,11 @@ namespace osu.Framework.Tests.Visual.Platform screenContainer.Size = bounds.Size; } - private Container createScreen(Display display, int activeDisplayIndex) + private IEnumerable createScreen(Display display, int activeDisplayIndex) { bool isActive = display.Index == activeDisplayIndex; - return new Container + yield return new Container { X = display.Bounds.X, Y = display.Bounds.Y, @@ -166,6 +170,30 @@ namespace osu.Framework.Tests.Visual.Platform } } }; + + if (showDisplayBounds) + { + yield return new Container + { + X = display.UsableBounds.X, + Y = display.UsableBounds.Y, + Width = display.UsableBounds.Width, + Height = display.UsableBounds.Height, + + BorderColour = isActive ? Color4.MediumPurple : Color4.Green, + BorderThickness = 20, + Masking = true, + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = (isActive ? Color4.MediumPurple : Color4.Green).Opacity(0.2f) + }, + } + }; + } } private string modeName(DisplayMode mode) => $"{mode.Size.Width}x{mode.Size.Height}@{mode.RefreshRate}"; diff --git a/osu.Framework/Platform/Display.cs b/osu.Framework/Platform/Display.cs index 171a4af7d..da1b65385 100644 --- a/osu.Framework/Platform/Display.cs +++ b/osu.Framework/Platform/Display.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Drawing; using System.Linq; @@ -24,6 +25,12 @@ namespace osu.Framework.Platform /// public Rectangle Bounds { get; } + /// + /// The current usable bounds of the display in screen space. + /// This is smaller and contained within . + /// + public Rectangle UsableBounds { get; } + /// /// The available s on this display, or empty if the display mode cannot be configured (e.g. mobile displays). /// @@ -34,11 +41,13 @@ namespace osu.Framework.Platform /// public int Index { get; } - public Display(int index, string? name, Rectangle bounds, DisplayMode[] displayModes) + public Display(int index, string? name, Rectangle bounds, Rectangle usableBounds, DisplayMode[] displayModes) { + Debug.Assert(bounds.Contains(usableBounds)); Index = index; Name = name; Bounds = bounds; + UsableBounds = usableBounds; DisplayModes = displayModes; } diff --git a/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs b/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs index 2584309b5..0535a850f 100644 --- a/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs +++ b/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs @@ -354,6 +354,12 @@ namespace osu.Framework.Platform.SDL2 return false; } + if (SDL_GetDisplayUsableBounds(displayIndex, out var usableBounds) < 0) + { + Logger.Log($"Failed to get usable display bounds for display at index ({displayIndex}). Assuming whole display is usable. SDL Error: {SDL_GetError()}"); + usableBounds = rect; + } + DisplayMode[] displayModes = Array.Empty(); if (RuntimeInfo.IsDesktop) @@ -379,7 +385,11 @@ namespace osu.Framework.Platform.SDL2 .ToArray(); } - display = new Display(displayIndex, SDL_GetDisplayName(displayIndex), new Rectangle(rect.x, rect.y, rect.w, rect.h), displayModes); + display = new Display(displayIndex, + SDL_GetDisplayName(displayIndex), + new Rectangle(rect.x, rect.y, rect.w, rect.h), + new Rectangle(usableBounds.x, usableBounds.y, usableBounds.w, usableBounds.h), + displayModes); return true; } diff --git a/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs b/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs index 42b79980d..d778550df 100644 --- a/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs @@ -360,6 +360,14 @@ namespace osu.Framework.Platform.SDL3 return false; } + SDL_Rect usableBounds; + + if (!SDL_GetDisplayUsableBounds(displayID, &usableBounds)) + { + Logger.Log($"Failed to get usable display bounds for display at index ({displayIndex}). Assuming whole display is usable. SDL Error: {SDL_GetError()}"); + usableBounds = rect; + } + DisplayMode[] displayModes = Array.Empty(); if (RuntimeInfo.IsDesktop) @@ -382,7 +390,11 @@ namespace osu.Framework.Platform.SDL3 displayModes[i] = modes[i].ToDisplayMode(displayIndex); } - display = new Display(displayIndex, SDL_GetDisplayName(displayID), new Rectangle(rect.x, rect.y, rect.w, rect.h), displayModes); + display = new Display(displayIndex, + SDL_GetDisplayName(displayID), + new Rectangle(rect.x, rect.y, rect.w, rect.h), + new Rectangle(usableBounds.x, usableBounds.y, usableBounds.w, usableBounds.h), + displayModes); return true; } From 97e09606187ac583a1c600716d2ef79c1d41cd89 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Fri, 14 Nov 2025 19:44:59 +0100 Subject: [PATCH 18/72] Expose window border size --- .../TestSceneDisplayBoundsWindowBorder.cs | 18 ++++++++++ .../Visual/Platform/WindowDisplaysPreview.cs | 34 +++++++++++++++++-- osu.Framework/Platform/IWindow.cs | 10 ++++++ osu.Framework/Platform/SDL2/SDL2Window.cs | 5 +++ .../Platform/SDL2/SDL2Window_Windowing.cs | 22 ++++++++++++ osu.Framework/Platform/SDL3/SDL3Window.cs | 5 +++ .../Platform/SDL3/SDL3Window_Windowing.cs | 24 +++++++++++++ 7 files changed, 116 insertions(+), 2 deletions(-) diff --git a/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs b/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs index c3799e608..d96b71c27 100644 --- a/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs +++ b/osu.Framework.Tests/Visual/Platform/TestSceneDisplayBoundsWindowBorder.cs @@ -16,5 +16,23 @@ namespace osu.Framework.Tests.Visual.Platform RelativeSizeAxes = Axes.Both, }); } + + [Test] + public void TestWindowBorder() + { + AddStep("Set up scene", () => Child = new WindowDisplaysPreview(false, true) + { + RelativeSizeAxes = Axes.Both, + }); + } + + [Test] + public void TestBoth() + { + AddStep("Set up scene", () => Child = new WindowDisplaysPreview(true, true) + { + RelativeSizeAxes = Axes.Both, + }); + } } } diff --git a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs index 4ef7575d7..345690d2e 100644 --- a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs +++ b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs @@ -28,6 +28,7 @@ namespace osu.Framework.Tests.Visual.Platform private readonly Container paddedContainer; private readonly Container screenContainer; private readonly Container windowContainer; + private readonly Container? borderContainer; private Vector2 screenContainerOffset; private static readonly Color4 active_fill = new Color4(255, 138, 104, 255); @@ -36,6 +37,8 @@ namespace osu.Framework.Tests.Visual.Platform private static readonly Color4 screen_stroke = new Color4(244, 137, 25, 255); private static readonly Color4 window_fill = new Color4(95, 113, 197, 255); private static readonly Color4 window_stroke = new Color4(36, 59, 166, 255); + private static readonly Color4 window_border_fill = new Color4(85, 207, 89, 200); + private static readonly Color4 window_border_stroke = new Color4(50, 122, 53, 255); private IWindow? window; private readonly Bindable windowMode = new Bindable(); @@ -43,7 +46,7 @@ namespace osu.Framework.Tests.Visual.Platform private readonly bool showDisplayBounds; - public WindowDisplaysPreview(bool showDisplayBounds = false) + public WindowDisplaysPreview(bool showDisplayBounds = false, bool showWindowBorders = false) { this.showDisplayBounds = showDisplayBounds; Child = new Container @@ -89,6 +92,22 @@ namespace osu.Framework.Tests.Visual.Platform } } }; + + if (showWindowBorders) + { + screenContainer.Add(borderContainer = new Container + { + BorderColour = window_border_stroke, + BorderThickness = 20, + Masking = true, + Depth = -5, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = window_border_fill + } + }); + } } [BackgroundDependencyLoader] @@ -114,7 +133,7 @@ namespace osu.Framework.Tests.Visual.Platform private void refreshScreens(IEnumerable displays) { - screenContainer.RemoveAll(d => d != windowContainer, false); + screenContainer.RemoveAll(d => d != windowContainer && d != borderContainer, false); var bounds = new RectangleI(); @@ -211,6 +230,17 @@ namespace osu.Framework.Tests.Visual.Platform windowContainer.Height = fullscreen ? currentBounds.Height : window.Size.Height; windowContainer.Position -= screenContainerOffset; windowCaption.Text = $"{windowMode}\nSize: {window.Size.Width}x{window.Size.Height}\nClient: {window.ClientSize.Width}x{window.ClientSize.Height}"; + + if (borderContainer != null) + { + var borderSize = window.BorderSize.Value; + + borderContainer.X = window.Position.X - borderSize.Left; + borderContainer.Y = window.Position.Y - borderSize.Top; + borderContainer.Width = windowContainer.Width + borderSize.TotalHorizontal; + borderContainer.Height = windowContainer.Height + borderSize.TotalVertical; + borderContainer.Position -= screenContainerOffset; + } } protected override void Update() diff --git a/osu.Framework/Platform/IWindow.cs b/osu.Framework/Platform/IWindow.cs index 1ac2fe764..78d800ba5 100644 --- a/osu.Framework/Platform/IWindow.cs +++ b/osu.Framework/Platform/IWindow.cs @@ -8,6 +8,7 @@ using System.Drawing; using System.IO; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Graphics; using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; namespace osu.Framework.Platform @@ -146,6 +147,15 @@ namespace osu.Framework.Platform /// BindableSafeArea SafeAreaPadding { get; } + /// + /// The size of the window decoration and border, relative to . + /// + /// + /// This may include the invisible resize border, even when maximised. + /// Usually 0 when in borderless or fullscreen. + /// + IBindable BorderSize { get; } + /// /// The s supported by this implementation. /// diff --git a/osu.Framework/Platform/SDL2/SDL2Window.cs b/osu.Framework/Platform/SDL2/SDL2Window.cs index 4621367f4..2324d0891 100644 --- a/osu.Framework/Platform/SDL2/SDL2Window.cs +++ b/osu.Framework/Platform/SDL2/SDL2Window.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.ImageExtensions; +using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Threading; using SixLabors.ImageSharp; @@ -38,6 +39,10 @@ namespace osu.Framework.Platform.SDL2 public BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); + protected readonly Bindable BorderSize = new Bindable(); + + IBindable IWindow.BorderSize => BorderSize; + public virtual Point PointToClient(Point point) => point; public virtual Point PointToScreen(Point point) => point; diff --git a/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs b/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs index 0535a850f..0522f8b33 100644 --- a/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs +++ b/osu.Framework/Platform/SDL2/SDL2Window_Windowing.cs @@ -10,6 +10,7 @@ using System.Drawing; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Graphics; using osu.Framework.Logging; using osuTK; using static SDL2.SDL; @@ -600,6 +601,27 @@ namespace osu.Framework.Platform.SDL2 currentDisplay = Displays.ElementAtOrDefault(displayIndex) ?? PrimaryDisplay; CurrentDisplayBindable.Value = currentDisplay; } + + if (tryGetBorderSize(out var borderSize)) + BorderSize.Value = borderSize; + } + + private bool tryGetBorderSize(out MarginPadding borderSize) + { + if (SDL_GetWindowBordersSize(SDLWindowHandle, out int top, out int left, out int bottom, out int right) < 0) + { + borderSize = default; + return false; + } + + borderSize = new MarginPadding + { + Top = top, + Left = left, + Bottom = bottom, + Right = right + }; + return true; } /// diff --git a/osu.Framework/Platform/SDL3/SDL3Window.cs b/osu.Framework/Platform/SDL3/SDL3Window.cs index 19111e92d..79d627500 100644 --- a/osu.Framework/Platform/SDL3/SDL3Window.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window.cs @@ -11,6 +11,7 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.ImageExtensions; +using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Threading; using SDL; @@ -40,6 +41,10 @@ namespace osu.Framework.Platform.SDL3 public BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); + protected readonly Bindable BorderSize = new Bindable(); + + IBindable IWindow.BorderSize => BorderSize; + public virtual Point PointToClient(Point point) => point; public virtual Point PointToScreen(Point point) => point; diff --git a/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs b/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs index d778550df..d203a8719 100644 --- a/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window_Windowing.cs @@ -10,6 +10,7 @@ using System.Drawing; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Graphics; using osu.Framework.Logging; using osuTK; using SDL; @@ -617,6 +618,29 @@ namespace osu.Framework.Platform.SDL3 CurrentDisplayBindable.Value = currentDisplay; } + + if (tryGetBorderSize(out var borderSize)) + BorderSize.Value = borderSize; + } + + private unsafe bool tryGetBorderSize(out MarginPadding borderSize) + { + int top, left, bottom, right; + + if (!SDL_GetWindowBordersSize(SDLWindowHandle, &top, &left, &bottom, &right)) + { + borderSize = default; + return false; + } + + borderSize = new MarginPadding + { + Top = top, + Left = left, + Bottom = bottom, + Right = right + }; + return true; } private static bool tryGetDisplayIndex(SDL_DisplayID id, out int index) From e4c53e09abc1aed1d22d9210d5b106b59d851258 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Fri, 14 Nov 2025 19:46:08 +0100 Subject: [PATCH 19/72] Fix names in test --- .../Visual/Platform/WindowDisplaysPreview.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs index 345690d2e..bba4f05f3 100644 --- a/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs +++ b/osu.Framework.Tests/Visual/Platform/WindowDisplaysPreview.cs @@ -44,11 +44,11 @@ namespace osu.Framework.Tests.Visual.Platform private readonly Bindable windowMode = new Bindable(); private readonly Bindable currentDisplay = new Bindable(); - private readonly bool showDisplayBounds; + private readonly bool showDisplayUsableBounds; - public WindowDisplaysPreview(bool showDisplayBounds = false, bool showWindowBorders = false) + public WindowDisplaysPreview(bool showDisplayUsableBounds = false, bool showWindowBorder = false) { - this.showDisplayBounds = showDisplayBounds; + this.showDisplayUsableBounds = showDisplayUsableBounds; Child = new Container { RelativeSizeAxes = Axes.Both, @@ -93,7 +93,7 @@ namespace osu.Framework.Tests.Visual.Platform } }; - if (showWindowBorders) + if (showWindowBorder) { screenContainer.Add(borderContainer = new Container { @@ -190,7 +190,7 @@ namespace osu.Framework.Tests.Visual.Platform } }; - if (showDisplayBounds) + if (showDisplayUsableBounds) { yield return new Container { From 5f4585f6fbaae277965ba3e7fdc297d0bffb013d Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sat, 15 Nov 2025 01:45:08 +0100 Subject: [PATCH 20/72] Propagate tablet pen device type from SDL The device type is currently: - direct on iOS - indirect or direct on Android - unknown on desktop --- osu.Framework/Input/Handlers/Pen/PenHandler.cs | 16 ++++++---------- osu.Framework/Platform/SDL3/SDL3Extensions.cs | 16 ++++++++++++++++ osu.Framework/Platform/SDL3/SDL3Window_Input.cs | 11 +++++++---- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/osu.Framework/Input/Handlers/Pen/PenHandler.cs b/osu.Framework/Input/Handlers/Pen/PenHandler.cs index eeee2665b..04d0e6dfd 100644 --- a/osu.Framework/Input/Handlers/Pen/PenHandler.cs +++ b/osu.Framework/Input/Handlers/Pen/PenHandler.cs @@ -45,24 +45,20 @@ namespace osu.Framework.Input.Handlers.Pen return true; } - // Pen input is not necessarily direct on mobile platforms (specifically Android, where external tablets are supported), - // but until users experience issues with this, consider it "direct" for now. - private static readonly TabletPenDeviceType device_type = RuntimeInfo.IsMobile ? TabletPenDeviceType.Direct : TabletPenDeviceType.Unknown; - - private void handlePenMove(Vector2 position, bool pressed) + private void handlePenMove(TabletPenDeviceType deviceType, Vector2 position, bool pressed) { - if (pressed && device_type == TabletPenDeviceType.Direct) + if (pressed && deviceType == TabletPenDeviceType.Direct) enqueueInput(new TouchInput(new Input.Touch(TouchSource.PenTouch, position), true)); else - enqueueInput(new MousePositionAbsoluteInputFromPen { DeviceType = device_type, Position = position }); + enqueueInput(new MousePositionAbsoluteInputFromPen { DeviceType = deviceType, Position = position }); } - private void handlePenTouch(bool pressed, Vector2 position) + private void handlePenTouch(TabletPenDeviceType deviceType, bool pressed, Vector2 position) { - if (device_type == TabletPenDeviceType.Direct) + if (deviceType == TabletPenDeviceType.Direct) enqueueInput(new TouchInput(new Input.Touch(TouchSource.PenTouch, position), pressed)); else - enqueueInput(new MouseButtonInputFromPen(pressed) { DeviceType = device_type }); + enqueueInput(new MouseButtonInputFromPen(pressed) { DeviceType = deviceType }); } private void handlePenButton(TabletPenButton button, bool pressed) diff --git a/osu.Framework/Platform/SDL3/SDL3Extensions.cs b/osu.Framework/Platform/SDL3/SDL3Extensions.cs index 3467f9f21..158a71e82 100644 --- a/osu.Framework/Platform/SDL3/SDL3Extensions.cs +++ b/osu.Framework/Platform/SDL3/SDL3Extensions.cs @@ -6,6 +6,7 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.StateChanges; using osuTK.Input; using SDL; using static SDL.SDL3; @@ -1048,6 +1049,21 @@ namespace osu.Framework.Platform.SDL3 return new DisplayMode(SDL_GetPixelFormatName(mode.format), new Size(mode.w, mode.h), bpp, mode.refresh_rate, displayIndex); } + public static TabletPenDeviceType ToTabletPenDeviceType(this SDL_PenDeviceType type) + { + switch (type) + { + case SDL_PenDeviceType.SDL_PEN_DEVICE_TYPE_DIRECT: + return TabletPenDeviceType.Direct; + + case SDL_PenDeviceType.SDL_PEN_DEVICE_TYPE_INDIRECT: + return TabletPenDeviceType.Indirect; + + default: + return TabletPenDeviceType.Unknown; + } + } + public static string ReadableName(this SDL_LogCategory category) { switch (category) diff --git a/osu.Framework/Platform/SDL3/SDL3Window_Input.cs b/osu.Framework/Platform/SDL3/SDL3Window_Input.cs index be0283f2f..ff947e408 100644 --- a/osu.Framework/Platform/SDL3/SDL3Window_Input.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window_Input.cs @@ -11,6 +11,7 @@ using osu.Framework.Configuration; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Primitives; using osu.Framework.Input; +using osu.Framework.Input.StateChanges; using osu.Framework.Input.States; using osu.Framework.Logging; using osuTK; @@ -530,14 +531,16 @@ namespace osu.Framework.Platform.SDL3 private void handleKeymapChangedEvent() => KeymapChanged?.Invoke(); + private static TabletPenDeviceType getPenType(SDL_PenID instanceID) => SDL_GetPenDeviceType(instanceID).ToTabletPenDeviceType(); + private void handlePenMotionEvent(SDL_PenMotionEvent evtPenMotion) { - PenMove?.Invoke(new Vector2(evtPenMotion.x, evtPenMotion.y) * Scale, evtPenMotion.pen_state.HasFlagFast(SDL_PenInputFlags.SDL_PEN_INPUT_DOWN)); + PenMove?.Invoke(getPenType(evtPenMotion.which), new Vector2(evtPenMotion.x, evtPenMotion.y) * Scale, evtPenMotion.pen_state.HasFlagFast(SDL_PenInputFlags.SDL_PEN_INPUT_DOWN)); } private void handlePenTouchEvent(SDL_PenTouchEvent evtPenTouch) { - PenTouch?.Invoke(evtPenTouch.down, new Vector2(evtPenTouch.x, evtPenTouch.y) * Scale); + PenTouch?.Invoke(getPenType(evtPenTouch.which), evtPenTouch.down, new Vector2(evtPenTouch.x, evtPenTouch.y) * Scale); } /// @@ -745,13 +748,13 @@ namespace osu.Framework.Platform.SDL3 /// /// Invoked when a pen moves. Passes pen position and whether the pen is touching the tablet surface. /// - public event Action? PenMove; + public event Action? PenMove; /// /// Invoked when a pen touches (true) or lifts (false) from the tablet surface. /// Also passes the current position of the pen. /// - public event Action? PenTouch; + public event Action? PenTouch; /// /// Invoked when a pen button is pressed (true) or released (false). From 352dff602b61d35e1deee14aa996f73644fce78e Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sat, 15 Nov 2025 14:23:55 +0100 Subject: [PATCH 21/72] Bump SDL3-CS --- osu.Framework/osu.Framework.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Framework/osu.Framework.csproj b/osu.Framework/osu.Framework.csproj index e5fe4bc60..3a96f0583 100644 --- a/osu.Framework/osu.Framework.csproj +++ b/osu.Framework/osu.Framework.csproj @@ -38,7 +38,7 @@ - + From 7e2f91dbd614b02b794089c6b2c09adf7d6150da Mon Sep 17 00:00:00 2001 From: marvin Date: Tue, 25 Nov 2025 00:11:46 +0100 Subject: [PATCH 29/72] Add missing `nameof` --- osu.Framework/Graphics/Containers/TextFlowContainer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Framework/Graphics/Containers/TextFlowContainer.cs b/osu.Framework/Graphics/Containers/TextFlowContainer.cs index 8fc1dfeed..cecbb1c84 100644 --- a/osu.Framework/Graphics/Containers/TextFlowContainer.cs +++ b/osu.Framework/Graphics/Containers/TextFlowContainer.cs @@ -535,9 +535,9 @@ namespace osu.Framework.Graphics.Containers // Things will be accounted for accurately later. // All calls to `spacingFactor()` in the original code thus reduce to returning (0,0). if (c.RelativeAnchorPosition != Vector2.Zero) - throw new InvalidOperationException($"All drawables in a {nameof(TextFlowContainer)} must not specify custom {RelativeAnchorPosition}s. Only (0,0) is supported."); + throw new InvalidOperationException($"All drawables in a {nameof(TextFlowContainer)} must not specify custom {nameof(RelativeAnchorPosition)}s. Only (0,0) is supported."); if (c.RelativeOriginPosition != Vector2.Zero) - throw new InvalidOperationException($"All drawables in a {nameof(TextFlowContainer)} must not specify custom {RelativeOriginPosition}s. Only (0,0) is supported."); + throw new InvalidOperationException($"All drawables in a {nameof(TextFlowContainer)} must not specify custom {nameof(RelativeOriginPosition)}s. Only (0,0) is supported."); // Populate running variables with sane initial values. if (i == 0) From 9878fb7b4beafa8bee205f35bec74569986baea6 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 10:58:42 +0300 Subject: [PATCH 30/72] Simplify segment reduction algorithm --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 116 ++++++++---------- 1 file changed, 54 insertions(+), 62 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 428714213..12f47e811 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -234,101 +234,93 @@ namespace osu.Framework.Graphics.Lines { Debug.Assert(segments.Count > 0); - Line? segmentToDraw = null; - SegmentStartLocation location = SegmentStartLocation.Outside; + Line segmentToDraw = segments[0]; + SegmentStartLocation location = SegmentStartLocation.End; SegmentStartLocation modifiedLocation = SegmentStartLocation.Outside; SegmentStartLocation nextLocation = SegmentStartLocation.End; DrawableSegment? lastDrawnSegment = null; - for (int i = 0; i < segments.Count; i++) + for (int i = 1; i < segments.Count; i++) { - if (segmentToDraw.HasValue) + float segmentToDrawLength = segmentToDraw.Rho; + + // If segment is too short, make its end point equal start point of a new segment + if (segmentToDrawLength < 1f) { - float segmentToDrawLength = segmentToDraw.Value.Rho; + segmentToDraw = new Line(segmentToDraw.StartPoint, segments[i].EndPoint); + continue; + } - // If segment is too short, make its end point equal start point of a new segment - if (segmentToDrawLength < 1f) + float progress = progressFor(segmentToDraw, segmentToDrawLength, segments[i].EndPoint); + Vector2 closest = segmentToDraw.At(progress); + + // Expand segment if next end point is located within a line passing through it + if (Precision.AlmostEquals(closest, segments[i].EndPoint, 0.01f)) + { + if (progress < 0) { - segmentToDraw = new Line(segmentToDraw.Value.StartPoint, segments[i].EndPoint); - continue; + // expand segment backwards + segmentToDraw = new Line(segments[i].EndPoint, segmentToDraw.EndPoint); + modifiedLocation = SegmentStartLocation.Outside; + nextLocation = SegmentStartLocation.Start; } - - float progress = progressFor(segmentToDraw.Value, segmentToDrawLength, segments[i].EndPoint); - Vector2 closest = segmentToDraw.Value.At(progress); - - // Expand segment if next end point is located within a line passing through it - if (Precision.AlmostEquals(closest, segments[i].EndPoint, 0.01f)) + else if (progress > 1) { - if (progress < 0) - { - // expand segment backwards - segmentToDraw = new Line(segments[i].EndPoint, segmentToDraw.Value.EndPoint); - modifiedLocation = SegmentStartLocation.Outside; - nextLocation = SegmentStartLocation.Start; - } - else if (progress > 1) - { - // or forward - segmentToDraw = new Line(segmentToDraw.Value.StartPoint, segments[i].EndPoint); - nextLocation = SegmentStartLocation.End; - } - else - { - nextLocation = SegmentStartLocation.Middle; - } - } - else // Otherwise draw the expanded segment - { - DrawableSegment s = new DrawableSegment(segmentToDraw.Value, radius, location, modifiedLocation); - addSegmentQuad(s); - connect(s, lastDrawnSegment); - - lastDrawnSegment = s; - segmentToDraw = segments[i]; - location = modifiedLocation = nextLocation; + // or forward + segmentToDraw = new Line(segmentToDraw.StartPoint, segments[i].EndPoint); nextLocation = SegmentStartLocation.End; } + else + { + nextLocation = SegmentStartLocation.Middle; + } } - else + else // Otherwise draw the expanded segment { + DrawableSegment s = new DrawableSegment(segmentToDraw, radius, location, modifiedLocation); + addSegmentQuad(s); + + if (lastDrawnSegment.HasValue) + connect(s, lastDrawnSegment.Value); + else + addStartCap(s); + + lastDrawnSegment = s; segmentToDraw = segments[i]; + location = modifiedLocation = nextLocation; + nextLocation = SegmentStartLocation.End; } } - // Finish drawing last segment (if exists) - if (segmentToDraw.HasValue) - { - DrawableSegment s = new DrawableSegment(segmentToDraw.Value, radius, location, modifiedLocation); - addSegmentQuad(s); - connect(s, lastDrawnSegment); - addEndCap(s); - } + // Finish drawing last segment + DrawableSegment last = new DrawableSegment(segmentToDraw, radius, location, modifiedLocation); + + if (lastDrawnSegment.HasValue) + connect(last, lastDrawnSegment.Value); + else + addStartCap(last); + + addSegmentQuad(last); + addEndCap(last); } /// /// Connects the start of the segment to the end of a previous one. /// - private void connect(DrawableSegment segment, DrawableSegment? prevSegment) + private void connect(DrawableSegment segment, DrawableSegment prevSegment) { - if (!prevSegment.HasValue) - { - // Nothing to connect to - add start cap - addStartCap(segment); - return; - } - switch (segment.ModifiedStartLocation) { default: case SegmentStartLocation.End: // Segment starts at the end of the previous one - addConnectionBetween(segment, prevSegment.Value); + addConnectionBetween(segment, prevSegment); break; case SegmentStartLocation.Start: case SegmentStartLocation.Middle: // Segment starts at the start or the middle of the previous one - add end cap to the previous segment - addEndCap(prevSegment.Value); + addEndCap(prevSegment); break; case SegmentStartLocation.Outside: @@ -342,7 +334,7 @@ namespace osu.Framework.Graphics.Lines // line since horizontal one will pass through it. However, that wouldn't be the case if horizontal line was located at // the middle and so end cap would be required. if (segment.StartLocation != SegmentStartLocation.End) - addEndCap(prevSegment.Value); + addEndCap(prevSegment); // add start cap to the current one addStartCap(segment); From b98702ebba8892eaf93a8e42cdaa05a4ce516a31 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 11:03:03 +0300 Subject: [PATCH 31/72] Skip rho calculation when possible --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 12f47e811..0f5e5e59f 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -235,6 +235,8 @@ namespace osu.Framework.Graphics.Lines Debug.Assert(segments.Count > 0); Line segmentToDraw = segments[0]; + float segmentToDrawLength = segmentToDraw.Rho; + SegmentStartLocation location = SegmentStartLocation.End; SegmentStartLocation modifiedLocation = SegmentStartLocation.Outside; SegmentStartLocation nextLocation = SegmentStartLocation.End; @@ -242,12 +244,11 @@ namespace osu.Framework.Graphics.Lines for (int i = 1; i < segments.Count; i++) { - float segmentToDrawLength = segmentToDraw.Rho; - // If segment is too short, make its end point equal start point of a new segment if (segmentToDrawLength < 1f) { segmentToDraw = new Line(segmentToDraw.StartPoint, segments[i].EndPoint); + segmentToDrawLength = segmentToDraw.Rho; continue; } @@ -261,6 +262,7 @@ namespace osu.Framework.Graphics.Lines { // expand segment backwards segmentToDraw = new Line(segments[i].EndPoint, segmentToDraw.EndPoint); + segmentToDrawLength *= 1f - progress; modifiedLocation = SegmentStartLocation.Outside; nextLocation = SegmentStartLocation.Start; } @@ -268,6 +270,7 @@ namespace osu.Framework.Graphics.Lines { // or forward segmentToDraw = new Line(segmentToDraw.StartPoint, segments[i].EndPoint); + segmentToDrawLength *= progress; nextLocation = SegmentStartLocation.End; } else @@ -287,6 +290,7 @@ namespace osu.Framework.Graphics.Lines lastDrawnSegment = s; segmentToDraw = segments[i]; + segmentToDrawLength = segmentToDraw.Rho; location = modifiedLocation = nextLocation; nextLocation = SegmentStartLocation.End; } From 0717703f2ddbe02668391b53001759b9745f35d2 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 11:07:43 +0300 Subject: [PATCH 32/72] Merge start and middle SegmentStartLocation values --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 0f5e5e59f..7ac16fb09 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -258,13 +258,14 @@ namespace osu.Framework.Graphics.Lines // Expand segment if next end point is located within a line passing through it if (Precision.AlmostEquals(closest, segments[i].EndPoint, 0.01f)) { + nextLocation = SegmentStartLocation.StartOrMiddle; + if (progress < 0) { // expand segment backwards segmentToDraw = new Line(segments[i].EndPoint, segmentToDraw.EndPoint); segmentToDrawLength *= 1f - progress; modifiedLocation = SegmentStartLocation.Outside; - nextLocation = SegmentStartLocation.Start; } else if (progress > 1) { @@ -273,10 +274,6 @@ namespace osu.Framework.Graphics.Lines segmentToDrawLength *= progress; nextLocation = SegmentStartLocation.End; } - else - { - nextLocation = SegmentStartLocation.Middle; - } } else // Otherwise draw the expanded segment { @@ -321,8 +318,7 @@ namespace osu.Framework.Graphics.Lines addConnectionBetween(segment, prevSegment); break; - case SegmentStartLocation.Start: - case SegmentStartLocation.Middle: + case SegmentStartLocation.StartOrMiddle: // Segment starts at the start or the middle of the previous one - add end cap to the previous segment addEndCap(prevSegment); break; @@ -367,8 +363,7 @@ namespace osu.Framework.Graphics.Lines private enum SegmentStartLocation { - Start, - Middle, + StartOrMiddle, End, Outside } From 94da62b296d83df76f3f33fea80ac246e7a19450 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 11:15:35 +0300 Subject: [PATCH 33/72] Simplify caps drawing --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 7ac16fb09..d757d4230 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -79,24 +79,6 @@ namespace osu.Framework.Graphics.Lines renderer.PopLocalMatrix(); } - private void addCap(Line cap) - { - // The provided line is perpendicular to the end/start of a segment. - // To get the remaining quad positions we are expanding said segment by the path radius. - Vector2 ortho = cap.OrthogonalDirection; - if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) - ortho = Vector2.UnitY; - - Vector2 v2 = cap.StartPoint + ortho * radius; - Vector2 v3 = cap.EndPoint + ortho * radius; - - drawQuad - ( - new Quad(cap.StartPoint, v2, cap.EndPoint, v3), - new Quad(new Vector2(0, -1), new Vector2(1, -1), new Vector2(0, 1), Vector2.One) - ); - } - private void addSegmentQuad(DrawableSegment segment) { drawQuad @@ -342,11 +324,29 @@ namespace osu.Framework.Graphics.Lines } } - private void addEndCap(DrawableSegment segment) => - addCap(new Line(segment.TopRight, segment.BottomRight)); + private void addEndCap(DrawableSegment segment) + { + Vector2 topRight = segment.TopRight + segment.Direction * radius; + Vector2 bottomRight = segment.BottomRight + segment.Direction * radius; - private void addStartCap(DrawableSegment segment) => - addCap(new Line(segment.BottomLeft, segment.TopLeft)); + drawQuad + ( + new Quad(segment.TopRight, topRight, segment.BottomRight, bottomRight), + new Quad(new Vector2(0, -1), new Vector2(1, -1), new Vector2(0, 1), new Vector2(1, 1)) + ); + } + + private void addStartCap(DrawableSegment segment) + { + Vector2 topLeft = segment.TopLeft - segment.Direction * radius; + Vector2 bottomLeft = segment.BottomLeft - segment.Direction * radius; + + drawQuad + ( + new Quad(topLeft, segment.TopLeft, bottomLeft, segment.BottomLeft), + new Quad(new Vector2(-1, -1), new Vector2(0, -1), new Vector2(-1, 1), new Vector2(0, 1)) + ); + } private static float progressFor(Line line, float length, Vector2 point) { @@ -375,6 +375,11 @@ namespace osu.Framework.Graphics.Lines /// public Line Guide { get; } + /// + /// The direction of the of this . + /// + public Vector2 Direction { get; } + /// /// The draw quad of this . /// @@ -420,9 +425,14 @@ namespace osu.Framework.Graphics.Lines StartLocation = startLocation; ModifiedStartLocation = modifiedStartLocation; - Vector2 ortho = Guide.OrthogonalDirection; - if (float.IsNaN(ortho.X) || float.IsNaN(ortho.Y)) - ortho = Vector2.UnitY; + Vector2 dir = guide.DirectionNormalized; + + if (float.IsNaN(dir.X) || float.IsNaN(dir.Y)) + dir = Vector2.UnitX; + + Direction = dir; + + Vector2 ortho = new Vector2(-dir.Y, dir.X); DrawQuad = new Quad ( From 4ab0140485c43915744fcc75476b8439e867c83b Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 11:18:01 +0300 Subject: [PATCH 34/72] Rename add methods to draw for clarity --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index d757d4230..0d81c93ce 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -79,7 +79,7 @@ namespace osu.Framework.Graphics.Lines renderer.PopLocalMatrix(); } - private void addSegmentQuad(DrawableSegment segment) + private void drawSegment(DrawableSegment segment) { drawQuad ( @@ -88,7 +88,7 @@ namespace osu.Framework.Graphics.Lines ); } - private void addConnectionBetween(DrawableSegment segment, DrawableSegment prevSegment) + private void drawConnectionBetween(DrawableSegment segment, DrawableSegment prevSegment) { float thetaDiff = segment.Guide.Theta - prevSegment.Guide.Theta; @@ -98,10 +98,10 @@ namespace osu.Framework.Graphics.Lines if (thetaDiff == 0f) return; - // more than 90 degrees - add end cap to the previous segment + // more than 90 degrees - draw previous segment end cap if (Math.Abs(thetaDiff) > Math.PI * 0.5) { - addEndCap(prevSegment); + drawEndCap(prevSegment); return; } @@ -260,12 +260,12 @@ namespace osu.Framework.Graphics.Lines else // Otherwise draw the expanded segment { DrawableSegment s = new DrawableSegment(segmentToDraw, radius, location, modifiedLocation); - addSegmentQuad(s); + drawSegment(s); if (lastDrawnSegment.HasValue) connect(s, lastDrawnSegment.Value); else - addStartCap(s); + drawStartCap(s); lastDrawnSegment = s; segmentToDraw = segments[i]; @@ -281,10 +281,10 @@ namespace osu.Framework.Graphics.Lines if (lastDrawnSegment.HasValue) connect(last, lastDrawnSegment.Value); else - addStartCap(last); + drawStartCap(last); - addSegmentQuad(last); - addEndCap(last); + drawSegment(last); + drawEndCap(last); } /// @@ -297,34 +297,34 @@ namespace osu.Framework.Graphics.Lines default: case SegmentStartLocation.End: // Segment starts at the end of the previous one - addConnectionBetween(segment, prevSegment); + drawConnectionBetween(segment, prevSegment); break; case SegmentStartLocation.StartOrMiddle: - // Segment starts at the start or the middle of the previous one - add end cap to the previous segment - addEndCap(prevSegment); + // Segment starts at the start or the middle of the previous one - draw previous segment end cap + drawEndCap(prevSegment); break; case SegmentStartLocation.Outside: // Segment starts outside the previous one. - // There's no need to add end cap in case when initial start location was at the end of the previous segment + // There's no need to draw end cap in case when initial start location was at the end of the previous segment // since created overlap will make this cap invisible anyway. // Example: imagine letter "T" where vertical line is prev segment and horizontal is a segment started at the end // of it, went to the right and then to the left (expanded backwards). In this case start location will be "End" and - // modified location will be "Outside". With that in mind we do not need to add the end cap at the top of the vertical + // modified location will be "Outside". With that in mind we do not need to draw the end cap at the top of the vertical // line since horizontal one will pass through it. However, that wouldn't be the case if horizontal line was located at // the middle and so end cap would be required. if (segment.StartLocation != SegmentStartLocation.End) - addEndCap(prevSegment); + drawEndCap(prevSegment); - // add start cap to the current one - addStartCap(segment); + // draw current segment draw cap + drawStartCap(segment); break; } } - private void addEndCap(DrawableSegment segment) + private void drawEndCap(DrawableSegment segment) { Vector2 topRight = segment.TopRight + segment.Direction * radius; Vector2 bottomRight = segment.BottomRight + segment.Direction * radius; @@ -336,7 +336,7 @@ namespace osu.Framework.Graphics.Lines ); } - private void addStartCap(DrawableSegment segment) + private void drawStartCap(DrawableSegment segment) { Vector2 topLeft = segment.TopLeft - segment.Direction * radius; Vector2 bottomLeft = segment.BottomLeft - segment.Direction * radius; From fa84f3218804d1e9fdb06ed63b54d5741db0859e Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 11:21:05 +0300 Subject: [PATCH 35/72] Move segment location modifiers outside the struct --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 0d81c93ce..d539cdf37 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -259,11 +259,11 @@ namespace osu.Framework.Graphics.Lines } else // Otherwise draw the expanded segment { - DrawableSegment s = new DrawableSegment(segmentToDraw, radius, location, modifiedLocation); + DrawableSegment s = new DrawableSegment(segmentToDraw, radius); drawSegment(s); if (lastDrawnSegment.HasValue) - connect(s, lastDrawnSegment.Value); + connect(s, lastDrawnSegment.Value, location, modifiedLocation); else drawStartCap(s); @@ -276,10 +276,10 @@ namespace osu.Framework.Graphics.Lines } // Finish drawing last segment - DrawableSegment last = new DrawableSegment(segmentToDraw, radius, location, modifiedLocation); + DrawableSegment last = new DrawableSegment(segmentToDraw, radius); if (lastDrawnSegment.HasValue) - connect(last, lastDrawnSegment.Value); + connect(last, lastDrawnSegment.Value, location, modifiedLocation); else drawStartCap(last); @@ -290,9 +290,9 @@ namespace osu.Framework.Graphics.Lines /// /// Connects the start of the segment to the end of a previous one. /// - private void connect(DrawableSegment segment, DrawableSegment prevSegment) + private void connect(DrawableSegment segment, DrawableSegment prevSegment, SegmentStartLocation initialLocation, SegmentStartLocation modifiedLocation) { - switch (segment.ModifiedStartLocation) + switch (modifiedLocation) { default: case SegmentStartLocation.End: @@ -315,7 +315,7 @@ namespace osu.Framework.Graphics.Lines // modified location will be "Outside". With that in mind we do not need to draw the end cap at the top of the vertical // line since horizontal one will pass through it. However, that wouldn't be the case if horizontal line was located at // the middle and so end cap would be required. - if (segment.StartLocation != SegmentStartLocation.End) + if (initialLocation != SegmentStartLocation.End) drawEndCap(prevSegment); // draw current segment draw cap @@ -405,25 +405,11 @@ namespace osu.Framework.Graphics.Lines /// public Vector2 BottomRight => DrawQuad.BottomRight; - /// - /// Position of this relative to the previous one. - /// - public SegmentStartLocation StartLocation { get; } - - /// - /// Position of this modified relative to the previous one. - /// - public SegmentStartLocation ModifiedStartLocation { get; } - /// The line defining this . /// The path radius. - /// Position of this relative to the previous one. - /// Position of this modified relative to the previous one. - public DrawableSegment(Line guide, float radius, SegmentStartLocation startLocation, SegmentStartLocation modifiedStartLocation) + public DrawableSegment(Line guide, float radius) { Guide = guide; - StartLocation = startLocation; - ModifiedStartLocation = modifiedStartLocation; Vector2 dir = guide.DirectionNormalized; From d3d97aaf1b203d962e40da9ea77fb6acd8c23aa2 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 11:36:13 +0300 Subject: [PATCH 36/72] Further reduce branching in the reduction loop --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index d539cdf37..5ff215421 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -222,7 +222,10 @@ namespace osu.Framework.Graphics.Lines SegmentStartLocation location = SegmentStartLocation.End; SegmentStartLocation modifiedLocation = SegmentStartLocation.Outside; SegmentStartLocation nextLocation = SegmentStartLocation.End; - DrawableSegment? lastDrawnSegment = null; + + // We initialize "fake" initial segment before the 0'th one + // so that on first connect() call with current SegmentStartLocation parameters path start cap will be drawn. + DrawableSegment lastDrawnSegment = new DrawableSegment(segments[0], radius); for (int i = 1; i < segments.Count; i++) { @@ -261,11 +264,7 @@ namespace osu.Framework.Graphics.Lines { DrawableSegment s = new DrawableSegment(segmentToDraw, radius); drawSegment(s); - - if (lastDrawnSegment.HasValue) - connect(s, lastDrawnSegment.Value, location, modifiedLocation); - else - drawStartCap(s); + connect(s, lastDrawnSegment, location, modifiedLocation); lastDrawnSegment = s; segmentToDraw = segments[i]; @@ -277,11 +276,7 @@ namespace osu.Framework.Graphics.Lines // Finish drawing last segment DrawableSegment last = new DrawableSegment(segmentToDraw, radius); - - if (lastDrawnSegment.HasValue) - connect(last, lastDrawnSegment.Value, location, modifiedLocation); - else - drawStartCap(last); + connect(last, lastDrawnSegment, location, modifiedLocation); drawSegment(last); drawEndCap(last); From c23f135904cccf48677d477a99a1e3f01e1f8473 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Sat, 29 Nov 2025 22:28:59 +0300 Subject: [PATCH 37/72] Reorder conditions in the connect function and improve xmldoc --- osu.Framework/Graphics/Lines/Path_DrawNode.cs | 43 ++++++------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/osu.Framework/Graphics/Lines/Path_DrawNode.cs b/osu.Framework/Graphics/Lines/Path_DrawNode.cs index 5ff215421..b68bdd408 100644 --- a/osu.Framework/Graphics/Lines/Path_DrawNode.cs +++ b/osu.Framework/Graphics/Lines/Path_DrawNode.cs @@ -277,7 +277,6 @@ namespace osu.Framework.Graphics.Lines // Finish drawing last segment DrawableSegment last = new DrawableSegment(segmentToDraw, radius); connect(last, lastDrawnSegment, location, modifiedLocation); - drawSegment(last); drawEndCap(last); } @@ -287,36 +286,22 @@ namespace osu.Framework.Graphics.Lines /// private void connect(DrawableSegment segment, DrawableSegment prevSegment, SegmentStartLocation initialLocation, SegmentStartLocation modifiedLocation) { - switch (modifiedLocation) + // Segment starts at the end of the previous one + if (modifiedLocation == SegmentStartLocation.End) { - default: - case SegmentStartLocation.End: - // Segment starts at the end of the previous one - drawConnectionBetween(segment, prevSegment); - break; - - case SegmentStartLocation.StartOrMiddle: - // Segment starts at the start or the middle of the previous one - draw previous segment end cap - drawEndCap(prevSegment); - break; - - case SegmentStartLocation.Outside: - // Segment starts outside the previous one. - - // There's no need to draw end cap in case when initial start location was at the end of the previous segment - // since created overlap will make this cap invisible anyway. - // Example: imagine letter "T" where vertical line is prev segment and horizontal is a segment started at the end - // of it, went to the right and then to the left (expanded backwards). In this case start location will be "End" and - // modified location will be "Outside". With that in mind we do not need to draw the end cap at the top of the vertical - // line since horizontal one will pass through it. However, that wouldn't be the case if horizontal line was located at - // the middle and so end cap would be required. - if (initialLocation != SegmentStartLocation.End) - drawEndCap(prevSegment); - - // draw current segment draw cap - drawStartCap(segment); - break; + drawConnectionBetween(segment, prevSegment); + return; // when drawing connection, we are in the situation when caps aren't needed } + + // End cap of the previous segment required in 2 cases. + // 1. Current segment starts at the start or the middle of the previous one, making the tail of the previous segment disconnected from anything. + // 2. Current segment starts outside but not passing through the end of the previous one (in which case current segment will be drawn on top of the cap anyway). + if (modifiedLocation == SegmentStartLocation.StartOrMiddle || (modifiedLocation == SegmentStartLocation.Outside && initialLocation != SegmentStartLocation.End)) + drawEndCap(prevSegment); + + // Segment starts outside the previous one, nothing is being connected to the start of the segment - start cap is required. + if (modifiedLocation == SegmentStartLocation.Outside) + drawStartCap(segment); } private void drawEndCap(DrawableSegment segment) From dd50d8db02794d8534b75a0d4c40500fdac0fd99 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Sun, 30 Nov 2025 06:54:29 -0500 Subject: [PATCH 38/72] Remove useless menus from iPadOS new app menu bar --- osu.Framework.iOS/GameApplicationDelegate.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/osu.Framework.iOS/GameApplicationDelegate.cs b/osu.Framework.iOS/GameApplicationDelegate.cs index c6bfddf11..757d96b07 100644 --- a/osu.Framework.iOS/GameApplicationDelegate.cs +++ b/osu.Framework.iOS/GameApplicationDelegate.cs @@ -17,7 +17,7 @@ namespace osu.Framework.iOS /// /// Base implementation for osu!framework applications. /// - public abstract class GameApplicationDelegate : UIApplicationDelegate + public abstract class GameApplicationDelegate : UIResponder, IUIApplicationDelegate { internal event Action? DragDrop; @@ -27,7 +27,7 @@ namespace osu.Framework.iOS public IOSGameHost Host { get; private set; } = null!; - public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) + public virtual bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) { mapLibraryNames(); @@ -46,7 +46,7 @@ namespace osu.Framework.iOS return true; } - public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) + public virtual bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) { // copied verbatim from SDL: https://github.com/libsdl-org/SDL/blob/d252a8fe126b998bd1b0f4e4cf52312cd11de378/src/video/uikit/SDL_uikitappdelegate.m#L508-L535 // the hope is that the SDL app delegate class does not have such handling exist there, but Apple does not provide a corresponding notification to make that possible. @@ -55,6 +55,17 @@ namespace osu.Framework.iOS return true; } + public override void BuildMenu(IUIMenuBuilder builder) + { + base.BuildMenu(builder); + + // Remove useless menus on iPadOS. This makes it almost match macOS, displaying only "Window" and "Help". + builder.RemoveMenu(UIMenuIdentifier.File.GetConstant()); + builder.RemoveMenu(UIMenuIdentifier.Edit.GetConstant()); + builder.RemoveMenu(UIMenuIdentifier.Format.GetConstant()); + builder.RemoveMenu(UIMenuIdentifier.View.GetConstant()); + } + /// /// Creates the class to launch. /// From 0a3359bebe7351819d41dde5aa0b4a85605f1d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 4 Dec 2025 11:50:25 +0100 Subject: [PATCH 39/72] Expose public helper to get a virtual key for a physical modifier key To be used to fix https://github.com/ppy/osu/discussions/35851. --- .../Extensions/InputKeyExtensions.cs | 38 +++++++++++++++++++ .../Input/Bindings/KeyCombination.cs | 28 +------------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/osu.Framework/Extensions/InputKeyExtensions.cs b/osu.Framework/Extensions/InputKeyExtensions.cs index 50b83acd0..0fc997247 100644 --- a/osu.Framework/Extensions/InputKeyExtensions.cs +++ b/osu.Framework/Extensions/InputKeyExtensions.cs @@ -38,5 +38,43 @@ namespace osu.Framework.Extensions return false; } } + + /// + /// If is a physical key which is covered by another virtual key, returns that virtual key. + /// Otherwise, returns . + /// + /// + /// + /// > InputKey.LShift.GetVirtualKey() + /// Shift + /// > InputKey.RSuper.GetVirtualKey() + /// Super + /// > InputKey.A.GetVirtualKey() + /// null + /// + /// + public static InputKey? GetVirtualKey(this InputKey key) + { + switch (key) + { + case InputKey.LShift: + case InputKey.RShift: + return InputKey.Shift; + + case InputKey.LControl: + case InputKey.RControl: + return InputKey.Control; + + case InputKey.LAlt: + case InputKey.RAlt: + return InputKey.Alt; + + case InputKey.LSuper: + case InputKey.RSuper: + return InputKey.Super; + } + + return null; + } } } diff --git a/osu.Framework/Input/Bindings/KeyCombination.cs b/osu.Framework/Input/Bindings/KeyCombination.cs index 4f30fc504..adef2a8eb 100644 --- a/osu.Framework/Input/Bindings/KeyCombination.cs +++ b/osu.Framework/Input/Bindings/KeyCombination.cs @@ -108,30 +108,6 @@ namespace osu.Framework.Input.Bindings return ContainsAll(Keys, pressedKeys.Keys, matchingMode); } - private static InputKey? getVirtualKey(InputKey key) - { - switch (key) - { - case InputKey.LShift: - case InputKey.RShift: - return InputKey.Shift; - - case InputKey.LControl: - case InputKey.RControl: - return InputKey.Control; - - case InputKey.LAlt: - case InputKey.RAlt: - return InputKey.Alt; - - case InputKey.LSuper: - case InputKey.RSuper: - return InputKey.Super; - } - - return null; - } - /// /// Check whether the provided set of pressed keys matches the candidate binding. /// @@ -192,7 +168,7 @@ namespace osu.Framework.Input.Bindings internal static bool KeyBindingContains(ImmutableArray candidateKeyBinding, InputKey physicalKey) { return candidateKeyBinding.Contains(physicalKey) || - (getVirtualKey(physicalKey) is InputKey vKey && candidateKeyBinding.Contains(vKey)); + (physicalKey.GetVirtualKey() is InputKey vKey && candidateKeyBinding.Contains(vKey)); } /// @@ -210,7 +186,7 @@ namespace osu.Framework.Input.Bindings foreach (var pk in pressedPhysicalKeys) { - if (getVirtualKey(pk) == candidateKey) + if (pk.GetVirtualKey() == candidateKey) return true; } From 4c3004e77484700502c5f57ee43ef1c6da3396e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Fri, 5 Dec 2025 13:30:37 +0100 Subject: [PATCH 40/72] Add spring physics --- .../Visual/Drawables/TestSceneSpring.cs | 327 ++++++++++++++++++ osu.Framework/Graphics/Transforms/Spring.cs | 151 ++++++++ 2 files changed, 478 insertions(+) create mode 100644 osu.Framework.Tests/Visual/Drawables/TestSceneSpring.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/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; + } + } +} From 5ab7dae2a83908a8d6fcf20c5afa078cf1e48d9e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 5 Dec 2025 22:15:05 +0900 Subject: [PATCH 41/72] Update SDL3-CS package --- osu.Framework/osu.Framework.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Framework/osu.Framework.csproj b/osu.Framework/osu.Framework.csproj index ef5e9efb4..bf3213f77 100644 --- a/osu.Framework/osu.Framework.csproj +++ b/osu.Framework/osu.Framework.csproj @@ -38,7 +38,7 @@ - +