From 05f34b16656b29f1531ea3047b56fc27d41b65ad Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:56:27 -0700 Subject: [PATCH 1/2] Fix Canvas Images going invisible We were rendering to canvases and then expecting them to stick around, but love documents canvas being cleared when the window size changes. Instead we should render them to an image and just use the image instead. Added helper methods that make sure to reset the graphics state when rendering Also used the right DPI and filter. Tested stage and character bundles --- client/src/graphics/graphics_util.lua | 36 +++++++++++ client/src/mods/Character.lua | 79 +++++++++++++------------ client/src/mods/Panels.lua | 66 +++++++++++++-------- client/src/mods/Stage.lua | 43 ++++++++------ client/src/scenes/CharacterSelect.lua | 23 ++++--- client/src/scenes/PuzzleEditorScene.lua | 72 +++++++++++----------- client/tests/PlayerSettingsTests.lua | 2 - 7 files changed, 193 insertions(+), 128 deletions(-) diff --git a/client/src/graphics/graphics_util.lua b/client/src/graphics/graphics_util.lua index d66aa93b..2a3ec7e1 100644 --- a/client/src/graphics/graphics_util.lua +++ b/client/src/graphics/graphics_util.lua @@ -379,6 +379,42 @@ function GraphicsUtil.resetAlignment() love.graphics.pop() end +---@param width number Canvas width +---@param height number Canvas height +---@param drawFunc function Function to call inside renderTo +---@param dpiscale number DPI scale for the canvas +---@param filterMin string filter mode +---@param filterMag string filter mode +---@return love.graphics.Texture +function GraphicsUtil.renderToImage(width, height, drawFunc, dpiscale, filterMin, filterMag) + love.graphics.push("all") + love.graphics.reset() + + local canvas = love.graphics.newCanvas(width, height, {dpiscale = dpiscale}) + + canvas:setFilter(filterMin, filterMag) + + canvas:renderTo(drawFunc) + + -- Restore graphics state + love.graphics.pop() + + local imageData + if love.getVersion() >= 12 then + imageData = love.graphics.readbackTexture(canvas) + else + imageData = canvas:newImageData() + end + local image = love.graphics.newImage(imageData, {dpiscale = dpiscale}) + + -- Preserve filter settings on the image + if filterMin and filterMag then + image:setFilter(filterMin, filterMag) + end + + return image +end + local loveMajor = love.getVersion() if loveMajor >= 12 then diff --git a/client/src/mods/Character.lua b/client/src/mods/Character.lua index e452e02a..ac2ffc6e 100644 --- a/client/src/mods/Character.lua +++ b/client/src/mods/Character.lua @@ -392,26 +392,35 @@ end -- bundles without stage icon display up to 4 icons of their substages function Character:createBundleIcon() - local canvas = love.graphics.newCanvas(2 * 168, 2 * 168) - canvas:renderTo(function() - for i, subCharacterId in ipairs(self.subIds) do - -- only draw up to 4 and only draw sub mods that are actually there unless there are none - if i <= 4 and (characters[subCharacterId] or (allCharacters[subCharacterId] and #self:getSubMods() == 0)) then - local character = allCharacters[subCharacterId] - local x = 0 - local y = 0 - if i % 2 == 0 then - x = 168 + local firstCharacter = allCharacters[self.subIds[1]] + assert(firstCharacter ~= nil, "Expected a valid character in sub IDs") + local filterMin, filterMag = firstCharacter.images.icon:getFilter() + local image = GraphicsUtil.renderToImage( + 2 * 168, + 2 * 168, + function() + for i, subCharacterId in ipairs(self.subIds) do + -- only draw up to 4 and only draw sub mods that are actually there unless there are none + if i <= 4 and (characters[subCharacterId] or (allCharacters[subCharacterId] and #self:getSubMods() == 0)) then + local character = allCharacters[subCharacterId] + local x = 0 + local y = 0 + if i % 2 == 0 then + x = 168 + end + if i > 2 then + y = 168 + end + local width, height = character.images.icon:getDimensions() + love.graphics.draw(character.images.icon, x, y, 0, 168 / width, 168 / height) end - if i > 2 then - y = 168 - end - local width, height = character.images.icon:getDimensions() - love.graphics.draw(character.images.icon, x, y, 0, 168 / width, 168 / height) end - end - end) - return canvas + end, + GAME:newCanvasSnappedScale(), + filterMin, + filterMag + ) + return image end function Character.graphics_uninit(self) @@ -581,17 +590,23 @@ function Character:createGarbageTexture(width, height) local relativeScale = self.images.pop:getWidth() / 16 -- create all canvases as if we were working with the 360x240 resolution but use the canvas dpi scale to use the real resolution -- that makes it easy to scale later as everything can be treated the same while love handles the dpi scale resolution for us - local canvas = love.graphics.newCanvas(width * 16, height * 16, {dpiscale = self.images.pop:getDPIScale() * relativeScale}) + local dpiscale = self.images.pop:getDPIScale() * relativeScale -- Use the same filter as the garbage images so that upscaling looks right for pixel art - local min, mag = self.images.pop:getFilter() - canvas:setFilter(min, mag) + local filterMin, filterMag = self.images.pop:getFilter() - canvas:renderTo(function() - self:__drawGarbage(width, height) - end) + local image = GraphicsUtil.renderToImage( + width * 16, + height * 16, + function() + self:__drawGarbage(width, height) + end, + dpiscale, + filterMin, + filterMag + ) - return canvas + return image end --- returns an existing prerender or if there is none, creates one and caches it for reuse @@ -604,21 +619,7 @@ function Character:getGarbageTexture(width, height) end if not self.garbagePrerenders[width][height] then - -- canvases are affected by scissors and transformations so we need to make sure to suspend them - local sx, sy, w, h = love.graphics.getScissor() - if sx then - love.graphics.setScissor() - end - love.graphics.push("transform") - love.graphics.origin() - self.garbagePrerenders[width][height] = self:createGarbageTexture(width, height) - - -- and then reapply them - love.graphics.pop() - if sx then - love.graphics.setScissor(sx, sy, w, h) - end end return self.garbagePrerenders[width][height] diff --git a/client/src/mods/Panels.lua b/client/src/mods/Panels.lua index 33d8c67b..0033726d 100644 --- a/client/src/mods/Panels.lua +++ b/client/src/mods/Panels.lua @@ -190,31 +190,36 @@ function Panels:loadSheets() self.size = self.sheets[1]:getHeight() / maxRowUsed end --- +-- function Panels:convertSinglesToSheetTexture(images, animationStates) - local canvas = love.graphics.newCanvas(self.size * 10, self.size * #animationStates, {dpiscale = images[1]:getDPIScale()}) - if self.size <= 24 then - -- none of the panels is bigger than 24x24 so we can assume pixel art style panels - canvas:setFilter("nearest", "nearest") - end - canvas:renderTo(function() - local row = 1 - -- ipairs over a static table so the ordering is definitely consistent - for _, animationState in ipairs(animationStates) do - local animationConfig = self.animationConfig[animationState] - for frameNumber, imageIndex in ipairs(animationConfig.frames) do - local widthScale = self.size / images[imageIndex]:getWidth() - local heightScale = self.size / images[imageIndex]:getHeight() - if heightScale > 1 or widthScale > 1 then - images[imageIndex]:setFilter("nearest", "nearest") + local dpiscale = images[1]:getDPIScale() + local filterMin, filterMag = images[1]:getFilter() + + local image = GraphicsUtil.renderToImage( + self.size * 10, + self.size * #animationStates, + function() + local row = 1 + -- ipairs over a static table so the ordering is definitely consistent + for _, animationState in ipairs(animationStates) do + local animationConfig = self.animationConfig[animationState] + for frameNumber, imageIndex in ipairs(animationConfig.frames) do + local widthScale = self.size / images[imageIndex]:getWidth() + local heightScale = self.size / images[imageIndex]:getHeight() + if heightScale > 1 or widthScale > 1 then + images[imageIndex]:setFilter("nearest", "nearest") + end + love.graphics.draw(images[imageIndex], self.size * (frameNumber - 1), self.size * (row - 1), nil, widthScale, heightScale) end - love.graphics.draw(images[imageIndex], self.size * (frameNumber - 1), self.size * (row - 1),nil, widthScale, heightScale) + row = row + 1 end - row = row + 1 - end - end) + end, + dpiscale, + filterMin, + filterMag + ) - return canvas + return image end local function validateSingleFilesAgainstConfig(imagesByColorAndIndex, animationConfig) @@ -344,12 +349,21 @@ function Panels:load() self.quad = love.graphics.newQuad(0, 0, self.size, self.size, self.sheets[1]:getDimensions()) self.displayIcons = {} + + local dpiscale = self.sheets[1]:getDPIScale() + local filterMin, filterMag = self.sheets[1]:getFilter() + for color = 1, 8 do - local canvas = love.graphics.newCanvas(self.size, self.size) - canvas:renderTo(function() - self:drawPanelFrame(color, "normal", 0, 0) - end) - self.displayIcons[color] = canvas + self.displayIcons[color] = GraphicsUtil.renderToImage( + self.size, + self.size, + function() + self:drawPanelFrame(color, "normal", 0, 0) + end, + dpiscale, + filterMin, + filterMag + ) --fileUtils.saveTextureToFile(self.sheets[color], self.path .. "/panel-" .. color, "png") self.batches[color] = love.graphics.newSpriteBatch(self.sheets[color], 100, "stream") end diff --git a/client/src/mods/Stage.lua b/client/src/mods/Stage.lua index 601ddf4e..b2276ea4 100644 --- a/client/src/mods/Stage.lua +++ b/client/src/mods/Stage.lua @@ -183,25 +183,34 @@ end -- bundles without stage thumbnail display up to 4 thumbnails of their substages function Stage:createBundleThumbnail() - local canvas = love.graphics.newCanvas(2 * 80, 2 * 45) - canvas:renderTo(function() - for i, substageId in ipairs(self.subIds) do - if i <= 4 and (stages[substageId] or (allStages[substageId] and #self:getSubMods() == 0)) then - local stage = allStages[substageId] - local x = 0 - local y = 0 - if i % 2 == 0 then - x = 80 + local firstStage = allStages[self.subIds[1]] + assert(firstStage ~= nil, "Expected a valid character in sub IDs") + local filterMin, filterMag = firstStage.images.thumbnail:getFilter() + local image = GraphicsUtil.renderToImage( + 2 * 80, + 2 * 45, + function() + for i, substageId in ipairs(self.subIds) do + if i <= 4 and (stages[substageId] or (allStages[substageId] and #self:getSubMods() == 0)) then + local stage = allStages[substageId] + local x = 0 + local y = 0 + if i % 2 == 0 then + x = 80 + end + if i > 2 then + y = 45 + end + local width, height = stage.images.thumbnail:getDimensions() + love.graphics.draw(stage.images.thumbnail, x, y, 0, 80 / width, 45 / height) end - if i > 2 then - y = 45 - end - local width, height = stage.images.thumbnail:getDimensions() - love.graphics.draw(stage.images.thumbnail, x, y, 0, 80 / width, 45 / height) end - end - end) - return canvas + end, + GAME:newCanvasSnappedScale(), + filterMin, + filterMag + ) + return image end -- uninits stage graphics diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 8ceac080..5232982b 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -338,15 +338,22 @@ function CharacterSelect:getCharacterButtons() end if character.panels and panels[character.panels] then - -- draw the color 1 normal panel in the left corner - -- it's only available on the sheet so we got to render it to its own canvas first local panels = panels[character.panels] - local canvas = love.graphics.newCanvas(panels.size, panels.size) - canvas:renderTo(function() - panels:drawPanelFrame(1, "normal", 0, 0, panels.size) - end) - - characterButton.panelIcon = ui.ImageContainer({image = canvas, vAlign = "bottom", hAlign = "left", x = 2, y = -2, width = 16, height = 16}) + local dpiscale = panels.sheets[1]:getDPIScale() + local filterMin, filterMag = panels.sheets[1]:getFilter() + + local panelImage = GraphicsUtil.renderToImage( + panels.size, + panels.size, + function() + panels:drawPanelFrame(1, "normal", 0, 0, panels.size) + end, + dpiscale, + filterMin, + filterMag + ) + + characterButton.panelIcon = ui.ImageContainer({image = panelImage, vAlign = "bottom", hAlign = "left", x = 2, y = -2, width = 16, height = 16}) characterButton:addChild(characterButton.panelIcon) end diff --git a/client/src/scenes/PuzzleEditorScene.lua b/client/src/scenes/PuzzleEditorScene.lua index 34fa939c..50cc2938 100644 --- a/client/src/scenes/PuzzleEditorScene.lua +++ b/client/src/scenes/PuzzleEditorScene.lua @@ -1,15 +1,15 @@ -local GameBase = require("client.src.scenes.GameBase") -local TouchInputDetector = require("client.src.TouchInputDetector") local Panel = require("common.engine.Panel") local Puzzle = require("common.engine.Puzzle") -local PuzzleEditorStackOverlay = require("client.src.ui.PuzzleEditorStackOverlay") local class = require("common.lib.class") local logger = require("common.lib.logger") local consts = require("common.engine.consts") local ui = require("client.src.ui") +local TouchInputDetector = require("client.src.TouchInputDetector") +local GameBase = require("client.src.scenes.GameBase") +local GraphicsUtil = require("client.src.graphics.graphics_util") local focusable = require("client.src.ui.Focusable") local directsFocus = require("client.src.ui.FocusDirector") - +local PuzzleEditorStackOverlay = require("client.src.ui.PuzzleEditorStackOverlay") ---@class PuzzleEditorScene : GameBase ---@field puzzleSet PuzzleSet @@ -393,40 +393,40 @@ end function PuzzleEditorScene:createShockGarbageButton(size) local stack = self.match.stacks[1] local panelsData = panels[stack.panels_dir] - - -- Create a canvas to draw the shock garbage with end caps like it appears in game - local canvas = love.graphics.newCanvas(size, size) - local prevCanvas = love.graphics.getCanvas() - - canvas:renderTo(function() - local shockImages = panelsData.images.metals - local leftImage = shockImages.left - local midImage = shockImages.mid - local rightImage = shockImages.right - - -- Calculate scaling to fit the button size - local targetWidth = size * 0.8 -- Leave some padding - local targetHeight = size * 0.6 - - -- Draw left cap - local leftWidth = targetWidth * 0.25 - love.graphics.draw(leftImage, size * 0.1, size * 0.2, 0, leftWidth / leftImage:getWidth(), targetHeight / leftImage:getHeight()) - - -- Draw middle section - local midWidth = targetWidth * 0.5 - local midX = size * 0.1 + leftWidth - love.graphics.draw(midImage, midX, size * 0.2, 0, midWidth / midImage:getWidth(), targetHeight / midImage:getHeight()) - - -- Draw right cap - local rightWidth = targetWidth * 0.25 - local rightX = midX + midWidth - love.graphics.draw(rightImage, rightX, size * 0.2, 0, rightWidth / rightImage:getWidth(), targetHeight / rightImage:getHeight()) - end) - - love.graphics.setCanvas(prevCanvas) + local shockImages = panelsData.images.metals + + local dpiscale = shockImages.mid:getDPIScale() + local filterMin, filterMag = shockImages.mid:getFilter() + + local garbageImage = GraphicsUtil.renderToImage( + size, + size, + function() + local leftImage = shockImages.left + local midImage = shockImages.mid + local rightImage = shockImages.right + + local targetWidth = size * 0.8 + local targetHeight = size * 0.6 + + local leftWidth = targetWidth * 0.25 + love.graphics.draw(leftImage, size * 0.1, size * 0.2, 0, leftWidth / leftImage:getWidth(), targetHeight / leftImage:getHeight()) + + local midWidth = targetWidth * 0.5 + local midX = size * 0.1 + leftWidth + love.graphics.draw(midImage, midX, size * 0.2, 0, midWidth / midImage:getWidth(), targetHeight / midImage:getHeight()) + + local rightWidth = targetWidth * 0.25 + local rightX = midX + midWidth + love.graphics.draw(rightImage, rightX, size * 0.2, 0, rightWidth / rightImage:getWidth(), targetHeight / rightImage:getHeight()) + end, + dpiscale, + filterMin, + filterMag + ) return ui.ImageButton({ - image = canvas, + image = garbageImage, width = size, height = size, onClick = function() diff --git a/client/tests/PlayerSettingsTests.lua b/client/tests/PlayerSettingsTests.lua index 680b0dfa..fc249d3d 100644 --- a/client/tests/PlayerSettingsTests.lua +++ b/client/tests/PlayerSettingsTests.lua @@ -71,8 +71,6 @@ local function testVsSelfChangesEndlessClassicSettingsToModern() endlessPlayer:setLevelData(LevelPresets.getClassicEndless(1)) -- Verify endless settings - assert(endlessPlayer.settings.style == GameModes.Styles.CLASSIC, - "Player should have classic style before vs self") assert(endlessPlayer.settings.levelData.colors == 5, "Player should have 5 colors before vs self") assert(endlessPlayer.settings.levelData.adjacentDenialFrequency == 0, From bb7ba156573c57e47bfcbfdc81b71429cc67c420 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 1 Nov 2025 00:46:30 +0100 Subject: [PATCH 2/2] rename to renderToTexture and don't do ImageData conversion for love 12 --- client/src/graphics/graphics_util.lua | 36 ++++++++++++++----------- client/src/mods/Character.lua | 4 +-- client/src/mods/Panels.lua | 4 +-- client/src/mods/Stage.lua | 2 +- client/src/scenes/CharacterSelect.lua | 2 +- client/src/scenes/PuzzleEditorScene.lua | 2 +- 6 files changed, 28 insertions(+), 22 deletions(-) diff --git a/client/src/graphics/graphics_util.lua b/client/src/graphics/graphics_util.lua index 2a3ec7e1..6bdcb4a7 100644 --- a/client/src/graphics/graphics_util.lua +++ b/client/src/graphics/graphics_util.lua @@ -1,6 +1,7 @@ local consts = require("common.engine.consts") local logger = require("common.lib.logger") local FileUtils = require("client.src.FileUtils") +local system = require("client.src.system") -- Utility methods for drawing local GraphicsUtil = { @@ -379,6 +380,7 @@ function GraphicsUtil.resetAlignment() love.graphics.pop() end +--- A wrapper function to create a love.Texture that persists in memory for drawing ---@param width number Canvas width ---@param height number Canvas height ---@param drawFunc function Function to call inside renderTo @@ -386,7 +388,7 @@ end ---@param filterMin string filter mode ---@param filterMag string filter mode ---@return love.graphics.Texture -function GraphicsUtil.renderToImage(width, height, drawFunc, dpiscale, filterMin, filterMag) +function GraphicsUtil.renderToTexture(width, height, drawFunc, dpiscale, filterMin, filterMag) love.graphics.push("all") love.graphics.reset() @@ -399,25 +401,29 @@ function GraphicsUtil.renderToImage(width, height, drawFunc, dpiscale, filterMin -- Restore graphics state love.graphics.pop() - local imageData - if love.getVersion() >= 12 then - imageData = love.graphics.readbackTexture(canvas) - else + if not system.meetsLoveVersionRequirement(12, 0) then + -- in love 11.5 Image and Canvas are distinct types inheriting from the Texture type that behave differently in a number of things + -- a main difference is that love.window.updateMode and love.window.setMode cause all Canvas objects to be cleared so that they need to be redrawn + -- to avoid this, immediately use the canvas's ImageData to create an Image type object that does not get cleared by window updates + local imageData imageData = canvas:newImageData() - end - local image = love.graphics.newImage(imageData, {dpiscale = dpiscale}) - -- Preserve filter settings on the image - if filterMin and filterMag then - image:setFilter(filterMin, filterMag) - end + local image = love.graphics.newImage(imageData, {dpiscale = dpiscale}) - return image -end + -- Preserve filter settings on the image + if filterMin and filterMag then + image:setFilter(filterMin, filterMag) + end -local loveMajor = love.getVersion() + return image + else + -- in love 12.0 the Canvas type does no longer exist, everything declared with newCanvas is a Texture and window updates don't clear these + -- that means we can use the canvas directly + return canvas + end +end -if loveMajor >= 12 then +if system.meetsLoveVersionRequirement(12, 0) then GraphicsUtil.newText = love.graphics.newTextBatch else GraphicsUtil.newText = love.graphics.newText diff --git a/client/src/mods/Character.lua b/client/src/mods/Character.lua index ac2ffc6e..5d89e51d 100644 --- a/client/src/mods/Character.lua +++ b/client/src/mods/Character.lua @@ -395,7 +395,7 @@ function Character:createBundleIcon() local firstCharacter = allCharacters[self.subIds[1]] assert(firstCharacter ~= nil, "Expected a valid character in sub IDs") local filterMin, filterMag = firstCharacter.images.icon:getFilter() - local image = GraphicsUtil.renderToImage( + local image = GraphicsUtil.renderToTexture( 2 * 168, 2 * 168, function() @@ -595,7 +595,7 @@ function Character:createGarbageTexture(width, height) -- Use the same filter as the garbage images so that upscaling looks right for pixel art local filterMin, filterMag = self.images.pop:getFilter() - local image = GraphicsUtil.renderToImage( + local image = GraphicsUtil.renderToTexture( width * 16, height * 16, function() diff --git a/client/src/mods/Panels.lua b/client/src/mods/Panels.lua index 0033726d..f318394a 100644 --- a/client/src/mods/Panels.lua +++ b/client/src/mods/Panels.lua @@ -195,7 +195,7 @@ function Panels:convertSinglesToSheetTexture(images, animationStates) local dpiscale = images[1]:getDPIScale() local filterMin, filterMag = images[1]:getFilter() - local image = GraphicsUtil.renderToImage( + local image = GraphicsUtil.renderToTexture( self.size * 10, self.size * #animationStates, function() @@ -354,7 +354,7 @@ function Panels:load() local filterMin, filterMag = self.sheets[1]:getFilter() for color = 1, 8 do - self.displayIcons[color] = GraphicsUtil.renderToImage( + self.displayIcons[color] = GraphicsUtil.renderToTexture( self.size, self.size, function() diff --git a/client/src/mods/Stage.lua b/client/src/mods/Stage.lua index b2276ea4..ccc090fd 100644 --- a/client/src/mods/Stage.lua +++ b/client/src/mods/Stage.lua @@ -186,7 +186,7 @@ function Stage:createBundleThumbnail() local firstStage = allStages[self.subIds[1]] assert(firstStage ~= nil, "Expected a valid character in sub IDs") local filterMin, filterMag = firstStage.images.thumbnail:getFilter() - local image = GraphicsUtil.renderToImage( + local image = GraphicsUtil.renderToTexture( 2 * 80, 2 * 45, function() diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 5232982b..7152f10e 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -342,7 +342,7 @@ function CharacterSelect:getCharacterButtons() local dpiscale = panels.sheets[1]:getDPIScale() local filterMin, filterMag = panels.sheets[1]:getFilter() - local panelImage = GraphicsUtil.renderToImage( + local panelImage = GraphicsUtil.renderToTexture( panels.size, panels.size, function() diff --git a/client/src/scenes/PuzzleEditorScene.lua b/client/src/scenes/PuzzleEditorScene.lua index 50cc2938..cb777f24 100644 --- a/client/src/scenes/PuzzleEditorScene.lua +++ b/client/src/scenes/PuzzleEditorScene.lua @@ -398,7 +398,7 @@ function PuzzleEditorScene:createShockGarbageButton(size) local dpiscale = shockImages.mid:getDPIScale() local filterMin, filterMag = shockImages.mid:getFilter() - local garbageImage = GraphicsUtil.renderToImage( + local garbageImage = GraphicsUtil.renderToTexture( size, size, function()