From bf434b2652f1eb36d5511e7130e4af6d8cd35177 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:30:41 -0500 Subject: [PATCH] fix(hud): steady frame graph on the bottom screen dashboard (#2345) --- .../com/droiddeck/launcher/hud/GameFrames.kt | 6 ++++- .../launcher/ui/BottomTelemetryPage.kt | 4 ++- .../com/droiddeck/launcher/ui/StatsHud.kt | 27 ++++++++++++------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt index 98dc35074..2aba209de 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt @@ -6,6 +6,7 @@ import kotlin.math.roundToInt internal class FrameStats { private val graph = FloatArray(GRAPH_FRAMES) private var graphHead = 0 + private var graphFrames = 0L private var lastPresentNs = 0L private var lastFpsUpdateNs = 0L private var framesSinceUpdate = 0 @@ -38,6 +39,7 @@ internal class FrameStats { if (lastPresentNs != 0L) { graph[graphHead] = frametimeNs / 1e6f graphHead = (graphHead + 1) % GRAPH_FRAMES + graphFrames++ } val elapsed = timeNs - lastFpsUpdateNs if (elapsed >= FPS_PERIOD_NS) { @@ -68,6 +70,7 @@ internal class FrameStats { fun reset() { graph.fill(0f) graphHead = 0 + graphFrames = 0L lastPresentNs = 0L lastFpsUpdateNs = 0L framesSinceUpdate = 0 @@ -86,8 +89,9 @@ internal class FrameStats { fun frametimeMs(): Float = 1000f / maxOf(fps, 1f) @Synchronized - fun graph(out: FloatArray) { + fun graph(out: FloatArray): Long { for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES] + return graphFrames } @Synchronized diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt index 4aee7fc22..08b4ae8ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt @@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } val now = System.nanoTime() val live = frames.live val stats = frames.stats - val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) } + val graph = FloatArray(FrameStats.GRAPH_FRAMES) + val graphEnd = if (live) stats.graph(graph) else 0L val hz = (frames.refreshMilliHz + 499) / 1000 val data = HudData( fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY, @@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock, fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(), historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()), + graphEnd = graphEnd, coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit, tempColor = { sensor, c -> thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt index b14dd39dc..0ed81319c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt @@ -98,6 +98,7 @@ internal class HudData( val tempColor: (HudMetrics.TempSensor, Int) -> Color, val redC: (HudMetrics.TempSensor) -> Int?, val historySeconds: Int = 0, + val graphEnd: Long = 0L, ) { val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it } val hz: Int get() = if (refreshHz > 0) refreshHz else 60 @@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean = private val GRAPH_MIN_GAP = 3.dp @Composable -private fun Graph(values: List, lo: Float, hi: Float, color: Color, modifier: Modifier) { +private fun Graph(values: List, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) { Canvas(modifier) { if (values.size < 2 || hi <= lo) return@Canvas val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1) - val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() } - if (points.size < 2) return@Canvas + // Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps + // its bucket as the window scrolls; counted from the window's start, every new frame re-paired them. + val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt() val path = Path() - points.forEachIndexed { i, v -> - val x = i / (points.size - 1f) * size.width - val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height - if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + var drawn = 0 + var start = skip + while (start < values.size) { + val stop = minOf(start + stride, values.size) + var sum = 0f + for (i in start until stop) sum += values[i] + val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width + val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height + if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y) + start = stop } + if (drawn < 2) return@Canvas drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) } @@ -287,7 +296,7 @@ private fun Graph(values: List, lo: Float, hi: Float, color: Color, modif @Composable private fun FrameGraph(d: HudData, accent: Color, height: Dp) { - Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height)) + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd) } @Composable @@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) { drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash) } } - Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize()) + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd) for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) { Small("%.1f ms ยท %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color, Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))