import React from 'react' const PR = Math.round(window.devicePixelRatio || 1) const FRAME_BAR_WIDTH = 2 export type FPSMeterProps = { width?: number height?: number systemFps?: number className?: string onClick?: () => void } const FRAME_HIT = 1 const FRAME_MISS = 0 const FRAME_UNINITIALIZED = -1 // TODO handle frames differently if browser went to background export const FPSMeter: React.FC<FPSMeterProps> = ({ width = 120, height = 30, systemFps = 60, className, onClick }) => { const adjustedWidth = Math.round(width * PR) const adjustedHeight = Math.round(height * PR) const numberOfVisibleFrames = Math.floor(adjustedWidth / FRAME_BAR_WIDTH) const resolutionInMs = 1000 / systemFps! // NOTE larger values can result in more items taken from array than it has and makes stuff go boom const numberOfSecondsForAverageFps = 2 // Depending on bar size and screen refresh rate, it can happen that the count of visible frames // is smaller than the count of frames used for calculating the average FPS. // To avoid this case, we force the number of frames used to calculate average FPS to always be less // than the number of visible frames. const numberOfFramesForAverageFps = Math.min(numberOfSecondsForAverageFps * systemFps, numberOfVisibleFrames) const animationFrameRef = React.useRef<number | undefined>(undefined) const canvasRef = React.useCallback( (canvas: HTMLCanvasElement | null) => { if (animationFrameRef.current !== undefined) { window.cancelAnimationFrame(animationFrameRef.current) } if (canvas === null) return if (numberOfFramesForAverageFps > numberOfVisibleFrames) { throw new Error( `numberOfFramesForAverageFps (${numberOfFramesForAverageFps}) must be smaller than numberOfVisibleFrames (${numberOfVisibleFrames}). Either increase the width or increase the resolutionInMs.`, ) } // eslint-disable-next-line unicorn/no-new-array const frames: number[] = new Array(numberOfVisibleFrames).fill(FRAME_UNINITIALIZED) const ctx = canvas.getContext('2d')! const draw = () => { ctx.clearRect(0, 0, adjustedWidth, adjustedHeight) for (let i = 0; i < numberOfVisibleFrames; i++) { const frameHit = frames[i]! if (frameHit === FRAME_UNINITIALIZED) continue const x = i * FRAME_BAR_WIDTH ctx.fillStyle = frameHit > 0 ? 'rgba(255, 255, 255, 0.3)' : 'rgba(255, 0, 0, 1)' ctx.fillRect(x, adjustedHeight, FRAME_BAR_WIDTH, -adjustedHeight) } let frameCount = 0 let numberOfInitializedFrames = 0 for (let i = 0; i < numberOfFramesForAverageFps; i++) { const frameHit = frames.at(-i - 1)! if (frameHit !== FRAME_UNINITIALIZED) { frameCount += frameHit numberOfInitializedFrames++ } } if (numberOfInitializedFrames >= numberOfFramesForAverageFps) { ctx.fillStyle = 'white' const fontSize = PR * 10 ctx.font = `${fontSize}px monospace` const averageFps = Math.round((systemFps * frameCount) / numberOfInitializedFrames) ctx.fillText(`${averageFps} FPS`, 2 * PR, 12 * PR) } } let previousFrameCounter = 0 const loop = () => { animationFrameRef.current = window.requestAnimationFrame((now) => { loop() const frameCounter = Math.floor(now / resolutionInMs) const numberOfSkippedFrames = frameCounter - previousFrameCounter - 1 // Checking for skipped frames for (let i = 0; i < numberOfSkippedFrames; i++) { frames.shift()! frames.push(FRAME_MISS) } frames.shift()! frames.push(FRAME_HIT) previousFrameCounter = frameCounter draw() }) } loop() }, [adjustedHeight, adjustedWidth, numberOfVisibleFrames, numberOfFramesForAverageFps, resolutionInMs, systemFps], ) return ( <canvas width={adjustedWidth} height={adjustedHeight} className={className} onClick={onClick} ref={canvasRef} style={{ width, height }} /> ) }