Skip to content

Commit 0736998

Browse files
committed
feat: add time-based smooth FPS calculation with cached update
Resolve #441: Replace fixed-frame FPS averaging with time-based sampling - Computes "current" FPS based on frame times in the last 500ms - Cached every 500ms alongside avg, 1% low, and 0.1% low - Avoids per-frame computation while preserving responsiveness
1 parent 12f2630 commit 0736998

7 files changed

Lines changed: 125 additions & 45 deletions

File tree

common/src/main/java/me/flashyreese/mods/sodiumextra/client/ClientTickHandler.java

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package me.flashyreese.mods.sodiumextra.client;
2+
3+
import java.util.ArrayDeque;
4+
import java.util.Comparator;
5+
import java.util.Deque;
6+
import java.util.List;
7+
8+
public class FrameCounter {
9+
private static final FrameCounter INSTANCE = new FrameCounter();
10+
private final Deque<FrameSample> samples = new ArrayDeque<>();
11+
private final long smoothWindow = 500_000_000L; // 0.5 seconds in nanoseconds
12+
private final long windowNanos = 5_000_000_000L; // 5 seconds in nanoseconds
13+
private final long updateIntervalNanos = 500_000_000L; // 0.5 seconds in nanoseconds
14+
private long lastFrameTime = -1;
15+
private long lastUpdateTime = 0;
16+
private double cachedSmoothFps = 0;
17+
private double cachedAverageFps = 0;
18+
private double cachedOnePercentLowFps = 0;
19+
private double cachedPointOnePercentLowFps = 0;
20+
21+
public static FrameCounter getInstance() {
22+
return FrameCounter.INSTANCE;
23+
}
24+
25+
public synchronized void onFrame() {
26+
long now = System.nanoTime();
27+
28+
// Record new frame delta
29+
if (this.lastFrameTime != -1) {
30+
long delta = now - this.lastFrameTime;
31+
this.samples.addLast(new FrameSample(now, delta));
32+
}
33+
this.lastFrameTime = now;
34+
35+
// Trim old samples
36+
while (!this.samples.isEmpty() && now - this.samples.peekFirst().timestamp > this.windowNanos) {
37+
this.samples.removeFirst();
38+
}
39+
40+
// Throttle stat computation
41+
if (now - this.lastUpdateTime >= this.updateIntervalNanos) {
42+
this.lastUpdateTime = now;
43+
if (!this.samples.isEmpty()) {
44+
long totalNanos = this.samples.stream().mapToLong(s -> s.deltaNanos).sum();
45+
this.cachedAverageFps = (double) (this.samples.size() * 1_000_000_000L) / totalNanos;
46+
this.cachedOnePercentLowFps = this.computePercentileLow(1.0);
47+
this.cachedPointOnePercentLowFps = this.computePercentileLow(0.1);
48+
this.cachedSmoothFps = this.computeSmoothFpsFromRecentFrames(now);
49+
} else {
50+
this.cachedAverageFps = this.cachedOnePercentLowFps = this.cachedPointOnePercentLowFps = this.cachedSmoothFps = 0;
51+
}
52+
}
53+
}
54+
55+
private double computeSmoothFpsFromRecentFrames(long now) {
56+
List<Long> recent = this.samples.stream()
57+
.filter(s -> now - s.timestamp <= this.smoothWindow)
58+
.map(s -> s.deltaNanos)
59+
.toList();
60+
61+
if (recent.isEmpty()) return 0;
62+
63+
double avgDelta = recent.stream().mapToLong(Long::longValue).average().orElse(0);
64+
return 1_000_000_000.0 / avgDelta;
65+
}
66+
67+
public synchronized int getSmoothFps() {
68+
return (int) Math.round(this.cachedSmoothFps);
69+
}
70+
71+
public synchronized int getAverageFps() {
72+
return (int) Math.round(this.cachedAverageFps);
73+
}
74+
75+
public synchronized int getOnePercentLowFps() {
76+
return (int) Math.round(this.cachedOnePercentLowFps);
77+
}
78+
79+
public synchronized int getPointOnePercentLowFps() {
80+
return (int) Math.round(this.cachedPointOnePercentLowFps);
81+
}
82+
83+
private double computePercentileLow(double percent) {
84+
if (this.samples.isEmpty()) return 0;
85+
List<Long> deltas = this.samples.stream()
86+
.map(s -> s.deltaNanos)
87+
.sorted(Comparator.reverseOrder()) // slowest frames first
88+
.toList();
89+
90+
int count = Math.max(1, (int) Math.ceil(deltas.size() * (percent / 100.0)));
91+
long sum = 0;
92+
for (int i = 0; i < count; i++) sum += deltas.get(i);
93+
double avgDelta = sum / (double) count;
94+
return 1_000_000_000.0 / avgDelta;
95+
}
96+
97+
private record FrameSample(long timestamp, long deltaNanos) {
98+
}
99+
}

common/src/main/java/me/flashyreese/mods/sodiumextra/client/SodiumExtraClientMod.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
import org.slf4j.LoggerFactory;
1212

1313
public class SodiumExtraClientMod {
14-
15-
private static final ClientTickHandler clientTickHandler = new ClientTickHandler();
1614
private static SodiumExtraGameOptions CONFIG;
1715
private static CaffeineConfig MIXIN_CONFIG;
1816
private static Logger LOGGER;
@@ -44,6 +42,7 @@ public static CaffeineConfig mixinConfig() {
4442
.addMixinOption("cloud", true)
4543
.addMixinOption("compat", true, false)
4644
.addMixinOption("fog", true)
45+
.addMixinOption("fps", true)
4746
.addMixinOption("gui", true)
4847
.addMixinOption("instant_sneak", true)
4948
.addMixinOption("light_updates", true)
@@ -77,10 +76,6 @@ public static CaffeineConfig mixinConfig() {
7776
return MIXIN_CONFIG;
7877
}
7978

80-
public static ClientTickHandler getClientTickHandler() {
81-
return clientTickHandler;
82-
}
83-
8479
private static SodiumExtraGameOptions loadConfig() {
8580
return SodiumExtraGameOptions.load(PlatformRuntimeInformation.getInstance().getConfigDirectory().resolve("sodium-extra-options.json").toFile());
8681
}
@@ -89,7 +84,6 @@ public static void onTick(Minecraft client) {
8984
if (hud == null) {
9085
hud = new SodiumExtraHud();
9186
}
92-
clientTickHandler.onClientTick(client);
9387
hud.onStartTick(client);
9488
}
9589

common/src/main/java/me/flashyreese/mods/sodiumextra/client/gui/SodiumExtraHud.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package me.flashyreese.mods.sodiumextra.client.gui;
22

33
import it.unimi.dsi.fastutil.objects.ObjectArrayList;
4+
import me.flashyreese.mods.sodiumextra.client.FrameCounter;
45
import me.flashyreese.mods.sodiumextra.client.SodiumExtraClientMod;
5-
import me.flashyreese.mods.sodiumextra.mixin.gui.MinecraftClientAccessor;
66
import net.minecraft.client.DeltaTracker;
77
import net.minecraft.client.Minecraft;
88
import net.minecraft.client.gui.GuiGraphics;
@@ -17,17 +17,19 @@ public class SodiumExtraHud {
1717

1818
private final Minecraft client = Minecraft.getInstance();
1919

20+
private final FrameCounter stats = FrameCounter.getInstance();
21+
2022
public void onStartTick(Minecraft client) {
2123
// Clear the textList to start fresh (this might not be ideal but hey it's still better than whatever the fuck debug hud is doing)
2224
this.textList.clear();
2325
if (SodiumExtraClientMod.options().extraSettings.showFps) {
24-
int currentFPS = MinecraftClientAccessor.getFPS();
26+
int currentFPS = FrameCounter.getInstance().getSmoothFps();
2527

2628
Component text = Component.translatable("sodium-extra.overlay.fps", currentFPS);
2729

2830
if (SodiumExtraClientMod.options().extraSettings.showFPSExtended)
29-
text = Component.literal(String.format("%s %s", text.getString(), Component.translatable("sodium-extra.overlay.fps_extended", SodiumExtraClientMod.getClientTickHandler().getHighestFps(), SodiumExtraClientMod.getClientTickHandler().getAverageFps(),
30-
SodiumExtraClientMod.getClientTickHandler().getLowestFps()).getString()));
31+
text = Component.literal(String.format("%s %s", text.getString(), Component.translatable("sodium-extra.overlay.fps_extended", this.stats.getAverageFps(), this.stats.getOnePercentLowFps(),
32+
this.stats.getPointOnePercentLowFps()).getString()));
3133

3234
this.textList.add(text);
3335
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package me.flashyreese.mods.sodiumextra.mixin.fps;
2+
3+
import me.flashyreese.mods.sodiumextra.client.FrameCounter;
4+
import net.minecraft.client.DeltaTracker;
5+
import net.minecraft.client.renderer.GameRenderer;
6+
import org.spongepowered.asm.mixin.Mixin;
7+
import org.spongepowered.asm.mixin.injection.At;
8+
import org.spongepowered.asm.mixin.injection.Inject;
9+
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
10+
11+
@Mixin(GameRenderer.class)
12+
public class MixinGameRenderer {
13+
@Inject(method = "render", at = @At("HEAD"))
14+
private void onRender(DeltaTracker deltaTracker, boolean bl, CallbackInfo ci) {
15+
FrameCounter.getInstance().onFrame();
16+
}
17+
}

common/src/main/resources/assets/sodium-extra/lang/en_us.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@
338338
"sodium-extra.option.use_fast_random.tooltip": "If enabled, a fast random function will be used for block rendering. This can affect the rotation of randomly rotated textures when compared to vanilla.",
339339
"sodium-extra.overlay.coordinates": "X: %s, Y: %s, Z: %s",
340340
"sodium-extra.overlay.fps": "%s FPS",
341-
"sodium-extra.overlay.fps_extended": "(max. %s / avg. %s / min. %s)",
341+
"sodium-extra.overlay.fps_extended": "(avg. %s / 1%% low %s / 0.1%% low %s)",
342342
"sodium-extra.overlay.light_updates": "Light updates disabled",
343343
"sodium-extra.suggestRSO.header": "Suggestion: Install Reese's Sodium Options",
344344
"sodium-extra.suggestRSO.message": "It is highly recommended you install Reese's Sodium Options alongside Sodium Extra. Due to the growing amount of features, it no longer fits properly on Sodium's video options.",

common/src/main/resources/sodium-extra.mixins.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"core.MixinMinecraftClient",
1616
"fog.MixinFogEnvironment",
1717
"fog.MixinFogRenderer",
18+
"fps.MixinGameRenderer",
1819
"gui.MinecraftClientAccessor",
1920
"instant_sneak.MixinCamera",
2021
"light_updates.MixinLevelLightEngine",

0 commit comments

Comments
 (0)