From 57da7114927e62ebacc25c78ea71a48b575f107e Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 25 Nov 2025 12:55:39 -0500 Subject: [PATCH] caching perf improvements - major improvements for immediate mode --- FlexLove.lua | 41 ++++++++++---------- modules/Blur.lua | 83 ++++++++++++++++++++++++++++++---------- modules/LayoutEngine.lua | 54 ++++++++++++++++++++++++++ modules/StateManager.lua | 37 ++++++++++++++++-- modules/utils.lua | 13 ++++++- 5 files changed, 182 insertions(+), 46 deletions(-) diff --git a/FlexLove.lua b/FlexLove.lua index cc00068..1a1b5b8 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -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 + + 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 - StateManager.setState(element.id, state) + -- Use optimized update that only changes modified values + StateManager.updateStateIfChanged(element.id, stateUpdate) end end diff --git a/modules/Blur.lua b/modules/Blur.lua index fd669f2..80fed26 100644 --- a/modules/Blur.lua +++ b/modules/Blur.lua @@ -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) diff --git a/modules/LayoutEngine.lua b/modules/LayoutEngine.lua index 87443fc..36b931f 100644 --- a/modules/LayoutEngine.lua +++ b/modules/LayoutEngine.lua @@ -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 diff --git a/modules/StateManager.lua b/modules/StateManager.lua index 7b01c0c..00d33e9 100644 --- a/modules/StateManager.lua +++ b/modules/StateManager.lua @@ -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 - state[key] = value + if state[key] ~= value then + state[key] = value + changed = true + end end - -- Update metadata - stateMetadata[id].lastFrame = frameNumber + -- 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 diff --git a/modules/utils.lua b/modules/utils.lua index bf5836e..33624ae 100644 --- a/modules/utils.lua +++ b/modules/utils.lua @@ -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))