caching perf improvements - major improvements for immediate mode

This commit is contained in:
Michael Freno
2025-11-25 12:55:39 -05:00
parent d3014200da
commit 57da711492
5 changed files with 182 additions and 46 deletions

View File

@@ -377,36 +377,37 @@ function flexlove.endFrame()
end
end
-- Save state back for all elements created this frame
-- Save state back for all elements created this frame (with diffing optimization)
for _, element in ipairs(flexlove._currentFrameElements) do
if element.id and element.id ~= "" then
local state = StateManager.getState(element.id, {})
-- Build state update object
local stateUpdate = {}
-- Save stateful properties back to persistent state
-- Get event handler state
if element._eventHandler then
local eventState = element._eventHandler:getState()
for k, v in pairs(eventState) do
state[k] = v
stateUpdate[k] = v
end
end
state._focused = element._focused
state._focused = element._focused
state._cursorPosition = element._cursorPosition
state._selectionStart = element._selectionStart
state._selectionEnd = element._selectionEnd
state._textBuffer = element._textBuffer
state._scrollX = element._scrollX
state._scrollY = element._scrollY
state._scrollbarDragging = element._scrollbarDragging
state._hoveredScrollbar = element._hoveredScrollbar
state._scrollbarDragOffset = element._scrollbarDragOffset
state._cursorBlinkTimer = element._cursorBlinkTimer
state._cursorVisible = element._cursorVisible
state._cursorBlinkPaused = element._cursorBlinkPaused
state._cursorBlinkPauseTimer = element._cursorBlinkPauseTimer
StateManager.setState(element.id, state)
stateUpdate._focused = element._focused
stateUpdate._cursorPosition = element._cursorPosition
stateUpdate._selectionStart = element._selectionStart
stateUpdate._selectionEnd = element._selectionEnd
stateUpdate._textBuffer = element._textBuffer
stateUpdate._scrollX = element._scrollX
stateUpdate._scrollY = element._scrollY
stateUpdate._scrollbarDragging = element._scrollbarDragging
stateUpdate._hoveredScrollbar = element._hoveredScrollbar
stateUpdate._scrollbarDragOffset = element._scrollbarDragOffset
stateUpdate._cursorBlinkTimer = element._cursorBlinkTimer
stateUpdate._cursorVisible = element._cursorVisible
stateUpdate._cursorBlinkPaused = element._cursorBlinkPaused
stateUpdate._cursorBlinkPauseTimer = element._cursorBlinkPauseTimer
-- Use optimized update that only changes modified values
StateManager.updateStateIfChanged(element.id, stateUpdate)
end
end

View File

@@ -4,16 +4,36 @@ local unpack = table.unpack or unpack
local Cache = {
canvases = {},
quads = {},
blurInstances = {}, -- Cache blur instances by quality
MAX_CANVAS_SIZE = 20,
MAX_QUAD_SIZE = 20,
INTENSITY_THRESHOLD = 5, -- Skip blur below this intensity
}
--- Round canvas size to nearest bucket for better reuse
---@param size number Size to bucket
---@return number bucketSize Bucketed size
local function bucketSize(size)
if size <= 128 then
return math.ceil(size / 32) * 32
elseif size <= 512 then
return math.ceil(size / 64) * 64
elseif size <= 1024 then
return math.ceil(size / 128) * 128
else
return math.ceil(size / 256) * 256
end
end
--- Get or create a canvas from cache
---@param width number Canvas width
---@param height number Canvas height
---@return love.Canvas canvas The cached or new canvas
function Cache.getCanvas(width, height)
local key = string.format("%dx%d", width, height)
-- Use bucketed sizes for better cache reuse
local bucketedWidth = bucketSize(width)
local bucketedHeight = bucketSize(height)
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
if not Cache.canvases[key] then
Cache.canvases[key] = {}
@@ -28,11 +48,14 @@ function Cache.getCanvas(width, height)
end
end
local canvas = love.graphics.newCanvas(width, height)
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
table.insert(cache, { canvas = canvas, inUse = true })
if #cache > Cache.MAX_CANVAS_SIZE then
table.remove(cache, 1)
local removed = table.remove(cache, 1)
if removed and removed.canvas then
removed.canvas:release()
end
end
return canvas
@@ -102,6 +125,7 @@ end
function Cache.clear()
Cache.canvases = {}
Cache.quads = {}
Cache.blurInstances = {}
end
-- ============================================================================
@@ -169,6 +193,27 @@ function ShaderBuilder.build(taps, offset, offsetType, sigma)
return love.graphics.newShader(shaderCode)
end
--- Get or create a blur instance from cache
---@param quality number Quality level (1-10)
---@return table blurData Cached blur data {shader, taps}
function Cache.getBlurInstance(quality)
if not Cache.blurInstances[quality] then
local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps)
if taps % 2 == 0 then
taps = taps + 1
end
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
Cache.blurInstances[quality] = {
shader = shader,
taps = taps,
}
end
return Cache.blurInstances[quality]
end
---@class BlurProps
---@field quality number? Quality level (1-10, default: 5)
@@ -189,26 +234,13 @@ function Blur.new(props)
local quality = props.quality or 5
quality = math.max(1, math.min(10, quality))
-- Map quality to shader parameters
-- Quality 1: 3 taps (fastest, lowest quality)
-- Quality 5: 7 taps (balanced)
-- Quality 10: 15 taps (slowest, highest quality)
local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps)
if taps % 2 == 0 then
taps = taps + 1
end
local offset = 1.0
local offsetType = "weighted"
local sigma = -1
local shader = ShaderBuilder.build(taps, offset, offsetType, sigma)
-- Get cached blur instance for this quality level
local blurData = Cache.getBlurInstance(quality)
local self = setmetatable({}, Blur)
self.shader = shader
self.shader = blurData.shader
self.quality = quality
self.taps = taps
self.taps = blurData.taps
return self
end
@@ -233,6 +265,12 @@ function Blur:applyToRegion(intensity, x, y, width, height, drawFunc)
return
end
-- Early exit for very low intensity (optimization)
if intensity < Cache.INTENSITY_THRESHOLD then
drawFunc()
return
end
intensity = math.max(0, math.min(100, intensity))
-- Intensity 0-100 maps to 0-5 passes
@@ -302,6 +340,11 @@ function Blur:applyBackdrop(intensity, x, y, width, height, backdropCanvas)
return
end
-- Early exit for very low intensity (optimization)
if intensity < Cache.INTENSITY_THRESHOLD then
return
end
intensity = math.max(0, math.min(100, intensity))
local passes = math.ceil(intensity / 20)

View File

@@ -99,6 +99,14 @@ function LayoutEngine.new(props, deps)
self._layoutCount = 0
self._lastFrameCount = 0
-- Layout memoization cache
self._layoutCache = {
childrenCount = 0,
containerWidth = 0,
containerHeight = 0,
childrenHash = "",
}
return self
end
@@ -169,6 +177,14 @@ function LayoutEngine:layoutChildren()
return
end
-- Check if layout can be skipped (memoization optimization)
if self:_canSkipLayout() then
if timerName and LayoutEngine._Performance then
LayoutEngine._Performance:stopTimer(timerName)
end
return
end
-- Track layout recalculations for performance warnings
self:_trackLayoutRecalculation()
@@ -987,6 +1003,44 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
end
end
--- Check if layout can be skipped based on cached state (memoization)
---@return boolean canSkip True if layout hasn't changed and can be skipped
function LayoutEngine:_canSkipLayout()
if not self.element then
return false
end
local childrenCount = #self.element.children
local containerWidth = self.element.width
local containerHeight = self.element.height
-- Generate simple hash of children dimensions
local childrenHash = ""
for i, child in ipairs(self.element.children) do
if i <= 5 then -- Only hash first 5 children for performance
childrenHash = childrenHash .. child.width .. "x" .. child.height .. ","
end
end
local cache = self._layoutCache
-- Check if layout inputs have changed
if cache.childrenCount == childrenCount and
cache.containerWidth == containerWidth and
cache.containerHeight == containerHeight and
cache.childrenHash == childrenHash then
return true -- Layout hasn't changed, can skip
end
-- Update cache with current values
cache.childrenCount = childrenCount
cache.containerWidth = containerWidth
cache.containerHeight = containerHeight
cache.childrenHash = childrenHash
return false -- Layout has changed, must recalculate
end
--- Track layout recalculations and warn about excessive layouts
function LayoutEngine:_trackLayoutRecalculation()
if not LayoutEngine._Performance or not LayoutEngine._Performance.warningsEnabled then

View File

@@ -340,13 +340,42 @@ end
function StateManager.updateState(id, newState)
local state = StateManager.getState(id)
-- Merge new state into existing state
-- Merge new state into existing state (with diffing optimization)
local changed = false
for key, value in pairs(newState) do
if state[key] ~= value then
state[key] = value
changed = true
end
end
-- Update metadata
-- Only update metadata if something actually changed
if changed then
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state only if values have changed (optimized for immediate mode)
---@param id string Element ID
---@param newState table New state values to merge
---@return boolean changed True if any values changed
function StateManager.updateStateIfChanged(id, newState)
local state = StateManager.getState(id)
local changed = false
for key, value in pairs(newState) do
-- Skip if value hasn't changed (optimization)
if state[key] ~= value then
state[key] = value
changed = true
end
end
if changed then
stateMetadata[id].lastFrame = frameNumber
end
return changed
end
--- Clear state for a specific element ID

View File

@@ -193,8 +193,17 @@ end
---@param fontPath string?
---@return love.Font
function FONT_CACHE.get(size, fontPath)
-- Round size to reduce cache entries (e.g., 14.5 -> 15, 14.7 -> 15)
size = math.floor(size + 0.5)
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
-- Small sizes (< 20): round to nearest 2
-- Medium sizes (20-40): round to nearest 4
-- Large sizes (> 40): round to nearest 8
if size < 20 then
size = math.floor((size + 1) / 2) * 2
elseif size < 40 then
size = math.floor((size + 2) / 4) * 4
else
size = math.floor((size + 4) / 8) * 8
end
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))