Skip to content

Commit 6be767c

Browse files
Perf: RDP slider path simplification (3-10x fewer render vertices) + smoke version-gated copy (no per-frame 32KB copy)
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/b24e3f10-427e-4d67-ade0-890e1f5955a4 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 6affa8a commit 6be767c

2 files changed

Lines changed: 137 additions & 13 deletions

File tree

osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Diagnostics;
77
using System.Numerics;
8+
using System.Threading;
89
using osu.Framework.Allocation;
910
using osu.Framework.Extensions.Color4Extensions;
1011
using osu.Framework.Graphics;
@@ -56,6 +57,11 @@ public abstract partial class SmokeSegment : Drawable, ITexturedShaderDrawable
5657

5758
protected readonly List<SmokePoint> SmokePoints = new List<SmokePoint>();
5859

60+
// Incremented (with a full memory barrier via Interlocked) whenever SmokePoints changes.
61+
// The draw node reads this with Volatile.Read and only copies SmokePoints when the
62+
// version changes, eliminating the O(N) per-frame list copy during smoke fade.
63+
private int smokeVersion;
64+
5965
private float pointInterval => width * 7f / 8;
6066

6167
private double smokeStartTime { get; set; } = double.MinValue;
@@ -85,6 +91,8 @@ public void StartDrawing(double time)
8591
SmokePoints.Clear();
8692
lastPosition = null;
8793
totalDistance = pointInterval;
94+
// Signal the draw node to discard its cached copy.
95+
Interlocked.Increment(ref smokeVersion);
8896
}
8997

9098
public void AddPosition(Vector2 position, double time)
@@ -118,6 +126,11 @@ public void AddPosition(Vector2 position, double time)
118126

119127
pointPos += increment;
120128
}
129+
130+
// Full barrier: the SmokePoints writes above must be visible to the render
131+
// thread before the version increment so the draw node always sees a
132+
// consistent snapshot when it re-copies the list.
133+
Interlocked.Increment(ref smokeVersion);
121134
}
122135

123136
Invalidate(Invalidation.DrawNode);
@@ -132,6 +145,10 @@ public void FinishDrawing(double time)
132145

133146
double initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, smokeEndTime - smokeStartTime);
134147
LifetimeEnd = smokeEndTime + final_fade_out_duration + initialFadeOutDurationTrunc / re_fade_in_speed + initialFadeOutDurationTrunc / final_fade_out_speed;
148+
149+
// Notify the draw node that the end-time state changed so it re-copies on its
150+
// next ApplyState and correctly clamps future-point lookups.
151+
Interlocked.Increment(ref smokeVersion);
135152
}
136153

137154
protected override DrawNode CreateDrawNode() => new SmokeDrawNode(this);
@@ -182,14 +199,16 @@ protected class SmokeDrawNode : TexturedShaderDrawNode
182199
protected double SmokeEndTime { get; private set; }
183200
protected double CurrentTime { get; private set; }
184201

185-
private readonly List<SmokePoint> points = new List<SmokePoint>();
202+
private readonly List<SmokePoint> allPoints = new List<SmokePoint>();
203+
private int lastSmokeVersion = -1;
186204
private IVertexBatch<TexturedVertex2D>? quadBatch;
187205
private float width;
188206
private float height;
189207
private Vector2 drawSize;
190208
private Texture? texture;
191209
private int rotationSeed;
192-
private int firstVisiblePointIndex;
210+
private int firstVisibleIndex;
211+
private int futureIndex;
193212

194213
// anim calculation vars (color, scale, direction)
195214
private double initialFadeOutDurationTrunc;
@@ -226,26 +245,35 @@ public override void ApplyState()
226245
reFadeInTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / re_fade_in_speed);
227246
finalFadeOutTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / final_fade_out_speed);
228247

229-
double firstVisiblePointTime = Math.Min(SmokeEndTime, CurrentTime) - initialFadeOutDurationTrunc;
230-
firstVisiblePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = firstVisiblePointTime }, new SmokePoint.LowerBoundComparer());
231-
int futurePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = CurrentTime }, new SmokePoint.UpperBoundComparer());
248+
// Only re-copy the full SmokePoints list when new points were added or the
249+
// smoke ended — not on every frame. Between add-calls the list is unchanged,
250+
// so the existing allPoints snapshot is still valid for the binary searches.
251+
int currentVersion = Volatile.Read(ref Source.smokeVersion);
232252

233-
points.Clear();
253+
if (currentVersion != lastSmokeVersion)
254+
{
255+
allPoints.Clear();
256+
allPoints.AddRange(Source.SmokePoints);
257+
lastSmokeVersion = currentVersion;
258+
}
234259

235-
for (int i = firstVisiblePointIndex; i < futurePointIndex; i++)
236-
points.Add(Source.SmokePoints[i]);
260+
double firstVisiblePointTime = Math.Min(SmokeEndTime, CurrentTime) - initialFadeOutDurationTrunc;
261+
firstVisibleIndex = ~allPoints.BinarySearch(new SmokePoint { Time = firstVisiblePointTime }, new SmokePoint.LowerBoundComparer());
262+
futureIndex = ~allPoints.BinarySearch(new SmokePoint { Time = CurrentTime }, new SmokePoint.UpperBoundComparer());
237263
}
238264

239265
protected sealed override void Draw(IRenderer renderer)
240266
{
241267
base.Draw(renderer);
242268

243-
if (points.Count == 0)
269+
int visibleCount = futureIndex - firstVisibleIndex;
270+
271+
if (visibleCount <= 0)
244272
return;
245273

246274
quadBatch ??= renderer.CreateQuadBatch<TexturedVertex2D>(200, 4);
247275

248-
if (points.Count > quadBatch.Size && quadBatch.Size != IRenderer.MAX_QUADS)
276+
if (visibleCount > quadBatch.Size && quadBatch.Size != IRenderer.MAX_QUADS)
249277
{
250278
int batchSize = Math.Min(quadBatch.Size * 2, IRenderer.MAX_QUADS);
251279
quadBatch = renderer.CreateQuadBatch<TexturedVertex2D>(batchSize, 4);
@@ -261,8 +289,11 @@ protected sealed override void Draw(IRenderer renderer)
261289

262290
texture.Bind();
263291

264-
for (int i = 0; i < points.Count; i++)
265-
drawPointQuad(renderer, points[i], textureRect, i + firstVisiblePointIndex);
292+
// Iterate allPoints directly with absolute indices — no per-frame copy needed.
293+
// 'i' is the absolute index into allPoints (mirroring SmokePoints), which is
294+
// what getRotation() requires for its seeded RNG.
295+
for (int i = firstVisibleIndex; i < futureIndex; i++)
296+
drawPointQuad(renderer, allPoints[i], textureRect, i);
266297

267298
UnbindTextureShader(renderer);
268299
renderer.PopLocalMatrix();

osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Numerics;
67
using osu.Framework.Allocation;
78
using osu.Framework.Bindables;
89
using osu.Framework.Graphics;
910
using osu.Game.Rulesets.Objects.Drawables;
1011
using osu.Game.Rulesets.Objects.Types;
1112
using osu.Game.Rulesets.Osu.Objects;
1213
using osu.Game.Rulesets.Osu.Objects.Drawables;
13-
using System.Numerics;
1414

1515
namespace osu.Game.Rulesets.Osu.Skinning
1616
{
@@ -148,6 +148,16 @@ private void updatePathSize()
148148
// (or better) the number of GetPathToProgress + SetVertices calls.
149149
private const double snaking_update_threshold = 0.002;
150150

151+
// Ramer-Douglas-Peucker simplification epsilon (osu coordinate units).
152+
// The sagitta of a circular arc with PathRadius ≈ 54 osu at 0.5 osu epsilon
153+
// is ~0.9% of the body width — imperceptible at any standard resolution.
154+
// Typical reduction: 2–3× for smooth curves, 10–50× for linear segments.
155+
private const float rdp_epsilon = 0.5f;
156+
157+
// Pre-allocated scratch buffer for RDP; grows monotonically, never reallocated
158+
// on typical frames (path point counts are stable once the beatmap is loaded).
159+
private bool[] rdpKeepBuffer = Array.Empty<bool>();
160+
151161
private void setRange(double p0, double p1)
152162
{
153163
if (p0 > p1)
@@ -163,6 +173,12 @@ private void setRange(double p0, double p1)
163173

164174
drawableSlider.HitObject.Path.GetPathToProgress(CurrentCurve, p0, p1);
165175

176+
// Simplify the path in-place via Ramer-Douglas-Peucker before handing it
177+
// to SmoothPath. The render thread's path-mesh work scales linearly with
178+
// vertex count; fewer vertices → proportionally faster geometry generation
179+
// and GPU upload each frame, with no visually detectable difference.
180+
simplifyPath(CurrentCurve, rdp_epsilon);
181+
166182
SetVertices(CurrentCurve);
167183

168184
// The bounding box of the path expands as it snakes, which in turn shifts the position of the path.
@@ -172,5 +188,82 @@ private void setRange(double p0, double p1)
172188

173189
Path.Position = snakedPosition - Path.PositionInBoundingBox(Vector2.Zero);
174190
}
191+
192+
/// <summary>
193+
/// Simplifies <paramref name="path"/> in-place using the Ramer-Douglas-Peucker algorithm,
194+
/// removing points whose perpendicular distance to the chord between their neighbours
195+
/// is less than <paramref name="epsilon"/> osu coordinate units.
196+
/// Endpoints are always preserved.
197+
/// </summary>
198+
private void simplifyPath(List<Vector2> path, float epsilon)
199+
{
200+
int count = path.Count;
201+
if (count <= 2)
202+
return;
203+
204+
if (rdpKeepBuffer.Length < count)
205+
rdpKeepBuffer = new bool[count * 2];
206+
207+
// Clear only the portion we'll use.
208+
Array.Clear(rdpKeepBuffer, 0, count);
209+
rdpKeepBuffer[0] = true;
210+
rdpKeepBuffer[count - 1] = true;
211+
212+
rdpSegment(path, rdpKeepBuffer, 0, count - 1, epsilon * epsilon);
213+
214+
// In-place compaction.
215+
int write = 0;
216+
for (int i = 0; i < count; i++)
217+
{
218+
if (rdpKeepBuffer[i])
219+
path[write++] = path[i];
220+
}
221+
222+
path.RemoveRange(write, count - write);
223+
}
224+
225+
private static void rdpSegment(List<Vector2> path, bool[] keep, int lo, int hi, float epsilonSq)
226+
{
227+
if (hi - lo <= 1)
228+
return;
229+
230+
Vector2 a = path[lo];
231+
Vector2 b = path[hi];
232+
Vector2 ab = b - a;
233+
float abLenSq = Vector2.Dot(ab, ab);
234+
235+
float maxDistSq = 0f;
236+
int maxIdx = lo;
237+
238+
for (int i = lo + 1; i < hi; i++)
239+
{
240+
float distSq;
241+
242+
if (abLenSq < 1e-10f)
243+
{
244+
distSq = Vector2.DistanceSquared(path[i], a);
245+
}
246+
else
247+
{
248+
Vector2 ap = path[i] - a;
249+
float t = Math.Clamp(Vector2.Dot(ap, ab) / abLenSq, 0f, 1f);
250+
Vector2 proj = a + t * ab;
251+
distSq = Vector2.DistanceSquared(path[i], proj);
252+
}
253+
254+
if (distSq > maxDistSq)
255+
{
256+
maxDistSq = distSq;
257+
maxIdx = i;
258+
}
259+
}
260+
261+
if (maxDistSq > epsilonSq)
262+
{
263+
keep[maxIdx] = true;
264+
rdpSegment(path, keep, lo, maxIdx, epsilonSq);
265+
rdpSegment(path, keep, maxIdx, hi, epsilonSq);
266+
}
267+
}
175268
}
176269
}

0 commit comments

Comments
 (0)