add FPS limit option

This commit is contained in:
Brian Beck 2026-03-16 19:04:48 -07:00
parent ceb9fea9f4
commit 4565f88210
36 changed files with 135 additions and 58 deletions

View file

@ -70,6 +70,8 @@ export const InspectorControls = memo(function InspectorControls({
setAudioVolume,
animationEnabled,
setAnimationEnabled,
fpsLimit,
setFpsLimit,
} = useSettings();
const {
speedMultiplier,
@ -377,6 +379,28 @@ export const InspectorControls = memo(function InspectorControls({
Enable animations
</label>
</div>
<div className={styles.Field}>
<label htmlFor="fpsLimitInput">FPS limit</label>
<div className={styles.Control}>
<select
id="fpsLimitInput"
value={fpsLimit ?? ""}
onChange={(e) => {
const val = e.target.value;
setFpsLimit(val === "" ? null : parseInt(val));
}}
>
<option value="30">30</option>
<option value="60">60</option>
<option value="120">120</option>
<option value="144">144</option>
<option value="">No limit</option>
</select>
</div>
<p className={styles.Description}>
Give your device a break by capping the framerate.
</p>
</div>
</Accordion>
<Accordion value="debug" label="Debug">
<div className={styles.CheckboxField}>

View file

@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { useSettings } from "./SettingsProvider";
function useFPSLimit() {
const { fpsLimit } = useSettings();
const invalidate = useThree((state) => state.invalidate);
useEffect(() => {
if (fpsLimit == null) return;
const interval = 1000 / fpsLimit;
let lastTime = 0;
let rafId: number;
function tick(time: number) {
rafId = requestAnimationFrame(tick);
if (time - lastTime >= interval) {
// Snap lastTime forward to avoid drift accumulation
lastTime = time - ((time - lastTime) % interval);
invalidate();
}
}
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [fpsLimit, invalidate]);
return fpsLimit;
}
export function LimitFPS() {
useFPSLimit();
return null;
}

View file

@ -42,6 +42,8 @@ type SettingsContext = {
setAudioVolume: StateSetter<number>;
sidebarOpen: boolean;
setSidebarOpen: StateSetter<boolean>;
fpsLimit: number | null;
setFpsLimit: StateSetter<number | null>;
};
type DebugContext = {
@ -86,6 +88,7 @@ type PersistedSettings = {
invertDrag?: boolean;
invertJoystick?: boolean;
sidebarOpen?: boolean;
fpsLimit?: number | null;
};
export function useSettings() {
@ -136,6 +139,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [invertDrag, setInvertDrag] = useState(false);
const [invertJoystick, setInvertJoystick] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fpsLimit, setFpsLimit] = useState<number | null>(null);
const [renderOnDemand, setRenderOnDemand] = useState(false);
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
@ -170,6 +174,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setAudioVolume,
sidebarOpen,
setSidebarOpen,
fpsLimit,
setFpsLimit,
}),
[
fogEnabled,
@ -183,6 +189,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
warriorName,
audioVolume,
sidebarOpen,
fpsLimit,
],
);
@ -288,6 +295,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.invertJoystick != null) {
setInvertJoystick(savedSettings.invertJoystick);
}
if (savedSettings.fpsLimit === null || Number.isInteger(savedSettings.fpsLimit)) {
setFpsLimit(savedSettings.fpsLimit!);
}
if (savedSettings.sidebarOpen != null) {
// Don't restore on touch devices!
if (!isTouch) {
@ -323,6 +333,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
invertDrag,
invertJoystick,
sidebarOpen,
fpsLimit,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
@ -352,6 +363,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
invertDrag,
invertJoystick,
sidebarOpen,
fpsLimit,
]);
return (

View file

@ -1,7 +1,8 @@
import { ReactNode, Suspense } from "react";
import { Canvas, GLProps, RootState } from "@react-three/fiber";
import { NoToneMapping, PCFShadowMap, SRGBColorSpace } from "three";
import { useDebug } from "./SettingsProvider";
import { useDebug, useSettings } from "./SettingsProvider";
import { LimitFPS } from "./LimitFPS";
export type InvalidateFunction = RootState["invalidate"];
@ -26,16 +27,19 @@ export function ThreeCanvas({
}) {
const { renderOnDemand: renderOnDemandFromSettings } = useDebug();
const renderOnDemand = renderOnDemandFromProps || renderOnDemandFromSettings;
const { fpsLimit } = useSettings();
const fpsLimitActive = fpsLimit != null;
return (
<Canvas
frameloop={renderOnDemand ? "demand" : "always"}
frameloop={renderOnDemand || fpsLimitActive ? "demand" : "always"}
dpr={dprFromProps}
gl={glSettings}
shadows={{ type: PCFShadowMap }}
onCreated={onCreated}
>
<Suspense>{children}</Suspense>
{fpsLimitActive ? <LimitFPS /> : null}
</Canvas>
);
}