Files
FlexLove/modules/Performance.lua
Michael Freno 1dab1a197e start testing
2025-11-14 20:59:40 -05:00

436 lines
11 KiB
Lua

--- Performance monitoring module for FlexLove
--- Provides timing, profiling, and performance metrics
---@class Performance
local Performance = {}
-- Configuration
local config = {
enabled = false,
hudEnabled = false,
hudToggleKey = "f3",
warningThresholdMs = 13.0, -- Yellow warning
criticalThresholdMs = 16.67, -- Red warning (60 FPS)
logToConsole = false,
logWarnings = true,
}
-- State
local timers = {} -- Active timers {name -> startTime}
local metrics = {} -- Accumulated metrics {name -> {total, count, min, max}}
local frameMetrics = {
frameCount = 0,
totalTime = 0,
lastFrameTime = 0,
minFrameTime = math.huge,
maxFrameTime = 0,
fps = 0,
lastFpsUpdate = 0,
fpsUpdateInterval = 0.5, -- Update FPS every 0.5s
}
local memoryMetrics = {
current = 0,
peak = 0,
gcCount = 0,
lastGcCheck = 0,
}
local warnings = {}
local lastFrameStart = nil
--- Initialize performance monitoring
--- @param options table? Optional configuration overrides
function Performance.init(options)
if options then
for k, v in pairs(options) do
config[k] = v
end
end
Performance.reset()
end
--- Enable performance monitoring
function Performance.enable()
config.enabled = true
end
--- Disable performance monitoring
function Performance.disable()
config.enabled = false
end
--- Check if performance monitoring is enabled
--- @return boolean
function Performance.isEnabled()
return config.enabled
end
--- Toggle performance HUD
function Performance.toggleHUD()
config.hudEnabled = not config.hudEnabled
end
--- Reset all metrics
function Performance.reset()
timers = {}
metrics = {}
warnings = {}
frameMetrics.frameCount = 0
frameMetrics.totalTime = 0
frameMetrics.lastFrameTime = 0
frameMetrics.minFrameTime = math.huge
frameMetrics.maxFrameTime = 0
memoryMetrics.current = 0
memoryMetrics.peak = 0
memoryMetrics.gcCount = 0
end
--- Start a named timer
--- @param name string Timer name
function Performance.startTimer(name)
if not config.enabled then
return
end
timers[name] = love.timer.getTime()
end
--- Stop a named timer and record the elapsed time
--- @param name string Timer name
--- @return number? elapsedMs Elapsed time in milliseconds, or nil if timer not found
function Performance.stopTimer(name)
if not config.enabled then
return nil
end
local startTime = timers[name]
if not startTime then
if config.logWarnings then
print(string.format("[Performance] Warning: Timer '%s' was not started", name))
end
return nil
end
local elapsed = (love.timer.getTime() - startTime) * 1000 -- Convert to ms
timers[name] = nil
-- Update metrics
if not metrics[name] then
metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
}
end
local m = metrics[name]
m.total = m.total + elapsed
m.count = m.count + 1
m.min = math.min(m.min, elapsed)
m.max = math.max(m.max, elapsed)
m.average = m.total / m.count
-- Check for warnings
if elapsed > config.criticalThresholdMs then
Performance.addWarning(name, elapsed, "critical")
elseif elapsed > config.warningThresholdMs then
Performance.addWarning(name, elapsed, "warning")
end
if config.logToConsole then
print(string.format("[Performance] %s: %.3fms", name, elapsed))
end
return elapsed
end
--- Wrap a function with performance timing
--- @param name string Metric name
--- @param fn function Function to measure
--- @return function Wrapped function
function Performance.measure(name, fn)
if not config.enabled then
return fn
end
return function(...)
Performance.startTimer(name)
local results = table.pack(fn(...))
Performance.stopTimer(name)
return table.unpack(results, 1, results.n)
end
end
--- Start frame timing (call at beginning of frame)
function Performance.startFrame()
if not config.enabled then
return
end
lastFrameStart = love.timer.getTime()
Performance.updateMemory()
end
--- End frame timing (call at end of frame)
function Performance.endFrame()
if not config.enabled or not lastFrameStart then
return
end
local now = love.timer.getTime()
local frameTime = (now - lastFrameStart) * 1000 -- ms
frameMetrics.lastFrameTime = frameTime
frameMetrics.totalTime = frameMetrics.totalTime + frameTime
frameMetrics.frameCount = frameMetrics.frameCount + 1
frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime)
frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime)
-- Update FPS
if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then
frameMetrics.fps = math.floor(1000 / frameTime + 0.5)
frameMetrics.lastFpsUpdate = now
end
-- Check for frame drops
if frameTime > config.criticalThresholdMs then
Performance.addWarning("frame", frameTime, "critical")
end
end
--- Update memory metrics
function Performance.updateMemory()
if not config.enabled then
return
end
local memKb = collectgarbage("count")
memoryMetrics.current = memKb
memoryMetrics.peak = math.max(memoryMetrics.peak, memKb)
-- Track GC cycles
local now = love.timer.getTime()
if now - memoryMetrics.lastGcCheck >= 1.0 then
memoryMetrics.gcCount = memoryMetrics.gcCount + 1
memoryMetrics.lastGcCheck = now
end
end
--- Add a performance warning
--- @param name string Metric name
--- @param value number Metric value
--- @param level "warning"|"critical" Warning level
function Performance.addWarning(name, value, level)
if not config.logWarnings then
return
end
table.insert(warnings, {
name = name,
value = value,
level = level,
time = love.timer.getTime(),
})
-- Keep only last 100 warnings
if #warnings > 100 then
table.remove(warnings, 1)
end
end
--- Get current FPS
--- @return number fps Frames per second
function Performance.getFPS()
return frameMetrics.fps
end
--- Get frame metrics
--- @return table frameMetrics Frame timing data
function Performance.getFrameMetrics()
return {
fps = frameMetrics.fps,
lastFrameTime = frameMetrics.lastFrameTime,
minFrameTime = frameMetrics.minFrameTime,
maxFrameTime = frameMetrics.maxFrameTime,
averageFrameTime = frameMetrics.frameCount > 0 and frameMetrics.totalTime / frameMetrics.frameCount or 0,
frameCount = frameMetrics.frameCount,
}
end
--- Get memory metrics
--- @return table memoryMetrics Memory usage data
function Performance.getMemoryMetrics()
Performance.updateMemory()
return {
currentKb = memoryMetrics.current,
currentMb = memoryMetrics.current / 1024,
peakKb = memoryMetrics.peak,
peakMb = memoryMetrics.peak / 1024,
gcCount = memoryMetrics.gcCount,
}
end
--- Get all performance metrics
--- @return table metrics All collected metrics
function Performance.getMetrics()
local result = {
frame = Performance.getFrameMetrics(),
memory = Performance.getMemoryMetrics(),
timings = {},
}
for name, data in pairs(metrics) do
result.timings[name] = {
average = data.average,
min = data.min,
max = data.max,
total = data.total,
count = data.count,
}
end
return result
end
--- Get recent warnings
--- @param count number? Number of warnings to return (default: 10)
--- @return table warnings Recent warnings
function Performance.getWarnings(count)
count = count or 10
local result = {}
local start = math.max(1, #warnings - count + 1)
for i = start, #warnings do
table.insert(result, warnings[i])
end
return result
end
--- Export metrics to JSON format
--- @return string json JSON string of metrics
function Performance.exportJSON()
local allMetrics = Performance.getMetrics()
-- Simple JSON encoding (for more complex needs, use a JSON library)
local json = "{\n"
json = json .. string.format(' "fps": %d,\n', allMetrics.frame.fps)
json = json .. string.format(' "averageFrameTime": %.3f,\n', allMetrics.frame.averageFrameTime)
json = json .. string.format(' "memoryMb": %.2f,\n', allMetrics.memory.currentMb)
json = json .. ' "timings": {\n'
local timingPairs = {}
for name, data in pairs(allMetrics.timings) do
table.insert(
timingPairs,
string.format(' "%s": {"average": %.3f, "min": %.3f, "max": %.3f, "count": %d}', name, data.average, data.min, data.max, data.count)
)
end
json = json .. table.concat(timingPairs, ",\n") .. "\n"
json = json .. " }\n"
json = json .. "}"
return json
end
--- Export metrics to CSV format
--- @return string csv CSV string of metrics
function Performance.exportCSV()
local csv = "Name,Average (ms),Min (ms),Max (ms),Count\n"
for name, data in pairs(metrics) do
csv = csv .. string.format("%s,%.3f,%.3f,%.3f,%d\n", name, data.average, data.min, data.max, data.count)
end
return csv
end
--- Render performance HUD
--- @param x number? X position (default: 10)
--- @param y number? Y position (default: 10)
function Performance.renderHUD(x, y)
if not config.hudEnabled then
return
end
x = x or 10
y = y or 10
local fm = Performance.getFrameMetrics()
local mm = Performance.getMemoryMetrics()
-- Background
love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", x, y, 300, 200)
-- Text
love.graphics.setColor(1, 1, 1, 1)
local lineHeight = 18
local currentY = y + 10
-- FPS
local fpsColor = { 1, 1, 1 }
if fm.lastFrameTime > config.criticalThresholdMs then
fpsColor = { 1, 0, 0 } -- Red
elseif fm.lastFrameTime > config.warningThresholdMs then
fpsColor = { 1, 1, 0 } -- Yellow
end
love.graphics.setColor(fpsColor)
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
-- Frame times
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print(string.format("Avg Frame: %.2fms", fm.averageFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
-- Memory
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", mm.currentMb, mm.peakMb), x + 10, currentY)
currentY = currentY + lineHeight
-- Separator
currentY = currentY + 5
-- Top timings
local sortedMetrics = {}
for name, data in pairs(metrics) do
table.insert(sortedMetrics, { name = name, average = data.average })
end
table.sort(sortedMetrics, function(a, b)
return a.average > b.average
end)
love.graphics.print("Top Timings:", x + 10, currentY)
currentY = currentY + lineHeight
for i = 1, math.min(5, #sortedMetrics) do
local m = sortedMetrics[i]
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
currentY = currentY + lineHeight
end
-- Warnings count
if #warnings > 0 then
love.graphics.setColor(1, 0.5, 0, 1)
love.graphics.print(string.format("Warnings: %d", #warnings), x + 10, currentY)
end
end
--- Handle keyboard input for HUD toggle
--- @param key string Key pressed
function Performance.keypressed(key)
if key == config.hudToggleKey then
Performance.toggleHUD()
end
end
--- Get configuration
--- @return table config Current configuration
function Performance.getConfig()
return config
end
--- Set configuration option
--- @param key string Configuration key
--- @param value any Configuration value
function Performance.setConfig(key, value)
config[key] = value
end
return Performance