├── LICENSE ├── README.md ├── baseAtlas.lua ├── dynamicSize.lua ├── fixedSize.lua ├── init.lua ├── packing.lua └── util.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 EngineerSmith (Engineer Smith) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Runtime Texture Atlas (RTA) 4 | A Love2D runtime texture atlas designed to be easy to use and memory optimized. Creates texture atlas when loading than having to use an external tool to create an atlas. Tested and written on **love 11.3 Android** and **love 11.4 Windows**. 5 | 6 | There are two types of Texture atlas you have access to: 7 | 8 | **Fixed Size**: *All images must have the same width and height* 9 | 10 | Uses home-brew algorithm to pack images. Estimates size of the canvas with ceil(sqrt(numOfImages)) for the number of columns, then optimizes number of rows. (e.g. ceil(sqrt(50))=8, instead of an 8x8= grid for the atlas, it'll use 8x7 grid to avoid wasting the extra row). If you are baking an awful lot of images, and the window is freezing: see advice to solve this issues below. 11 | 12 | **Note, due to algorithm constraints if it cannot get the size it requires it will throw an error.** This is due to the algorithm having no wiggly room to rearrange itself. This will unlikely be an issue unless you're forcing it to fit within a rectangle. 13 | 14 | **Dynamic Size**: *All images can be whatever size they want* 15 | Uses a home-brew algorithm to pack images together based on cells. Unit tests show it takes 40% longer to bake than fixed size (This is given due to it being more complex than filling a grid). Check out `packing.lua` to see how they are being packed together. 16 | 17 | Originally RTA used binary tree packing which didn't produced good results. This new packing algorithm is not only (almost) twice as fast but much more better at packing images optimally than the binary tree solution. 18 | ### How to run RTA headless 19 | If you're looking to run RTA headless, it supports drawing to imageData that is returned from baking. You can find this implemented in the [ETA command line tool](https://github.com/EngineerSmith/Export-TextureAtlas). 20 | ### Advice: Window Freezing during baking 21 | 22 | If your game window is freezing, it is due to the baking process taking too long. Try to avoid baking until the end, and avoid baking over and over unless you need to keep changing the images within the atlas. (If you're doing this to say animate, add all frames and then select the different quads in the draw) 23 | 24 | Otherwise there is no way easy way around it. You can use another library such as [Lily](https://github.com/MikuAuahDark/lily) to multi-thread load image - this will increase performance a little for loading your images. 25 | 26 | TLDR; bake once at the start of your game 27 | ## Examples 28 | ### Fixed Size 29 | All images must be the same size 30 | ```lua 31 | local textureAtlas = require("libs.TA") 32 | local ta = textureAtlas.newFixedSize(16, 32) 33 | ta:setFilter("nearest") 34 | 35 | ta:add(love.graphics.newImage("duck.png"), "duck") -- throws error if images aren't 16x32 36 | ta:add(love.graphics.newImage("cat.png"), "cat") 37 | ta:add(love.graphics.newImage("dog.png"), "dog") 38 | ta:add(love.graphics.newImage("rabbit.png"), "rabbit") 39 | 40 | ta:bake() 41 | 42 | ta:remove("dog") 43 | ta:remove("rabbit", true) -- Remove rabbit and bake changes 44 | 45 | ta:hardBake() -- Cannot add or remove images after call, and deletes all references to given images so they can be cleaned from memory 46 | collectgarbage("collect") 47 | 48 | local catDraw = ta:getDrawFuncForID("cat") 49 | 50 | love.draw = function() 51 | ta:draw("duck", 50,50) 52 | catDraw(100,50, 0, 5,5) 53 | end 54 | ``` 55 | ### Dynamic Size 56 | Images don't have to be the same size 57 | ```lua 58 | local textureAtlas = require("libs.TA") 59 | local ta = textureAtlas.newDynamicSize() 60 | ta:setFilter("nearest") 61 | 62 | ta:add(love.graphics.newImage("521x753.png"), "duck") 63 | ta:add(love.graphics.newImage("25x1250.png"), "cat") 64 | ta:add(love.graphics.newImage("duck.png"), "duck") -- Replace previous image at id without having to call ta:remove 65 | ta:add(love.graphic.newImage("rabbit.png", "rabbit") 66 | 67 | ta:bake("height") -- Sorting algorithm optimizes space use 68 | 69 | ta:remove("rabbit") -- will need to bake again 70 | ta:remove("cat", true, "area") -- remove graphic, bake with this sort 71 | 72 | ta:hardBake() -- Cannot add or remove images, and deletes all references to given images so they can be cleaned from memory 73 | collectgarbage("collect") 74 | 75 | local duckDraw = ta:getDrawFuncForID("duck") 76 | 77 | love.draw = function() 78 | ta:draw("banner", 50,50) 79 | duckDraw(100,50, 0, 5,5) 80 | love.graphics.print(("x%d:y%d\nw%d:h%d"):format(ta:getViewport("duck"))) 81 | end 82 | ``` 83 | ## Docs 84 | Clone into your lib/include file for your love2d project. 85 | 86 | E.g. `git clone https://github.com/EngineerSmith/Runtime-TextureAtlas libs/TA` 87 | ### require 88 | Require the library using the init.lua 89 | ```lua 90 | local textureAtlas = require("libs.TA") -- the location where it has been cloned to 91 | ``` 92 | ### textureAtlas.new 93 | Create an atlas to add images too: 94 | 95 | - Fixed Size atlas require all added images to have the same width and height 96 | - Dynamic Size atlas allows for any size of image 97 | 98 | Variables explained: 99 | * Padding: allows you to add a border around each image 100 | * Extrude: allows you to extend the image using the clamp warp mode (or that which you've set for the image) See [here](https://love2d.org/wiki/WrapMode) for an example of clamp. 101 | * Spacing: allows you to add space between each image, different from padding as it doesn't add space between atlas edge and the images. 102 | Padding Vs Spacing: 1 pixel of spacing would leave a 1 pixel gap between images, whilst 1 pixel of padding would leave a 2 pixel gap between each image. Spacing would allow the texture to go up to the edges of the atlas, whilst padding will not. 103 | ```lua 104 | local fs = textureAtlas.newFixedSize(width, height = width, padding = 1, extrude = 0, spacing = 0) 105 | local ds = textureAtlas.newDynamicSize(padding = 1, extrude = 0, spacing = 0) 106 | 107 | textureAtlas.newFixedSize(16) -- 16x16 only, padding 1 pixel 108 | textureAtlas.newFixedSize(32,64, 5) -- 32x64 only, padding 5 pixel 109 | textureAtlas.newFixedSize(64,64, 5, 3) -- 64x64 only, padding 5 pixels, extrude 3 pixels 110 | textureAtlas.newFixedSize(64,64, 2, 2, 2) -- 64x64 only, padding 2 pixels, extrude 2 pixels, space 2 pixels 111 | 112 | textureAtlas.newDynamicSize(1) -- padding 1 pixel 113 | textureAtlas.newDynamicSize(5) -- padding 5 pixels 114 | textureAtlas.newDynamicSize(3, 3) -- padding 3 pixels, extrude 3 pixels 115 | textureAtlas.newDynamicSize(2, 2, 2) -- padding 2 pixels, extrude 2 pixels, space 2 pixels 116 | ``` 117 | ### textureAtlas:setFilter(min, mag = min) 118 | Similar to `image:setFilter`; however, will always override default filter even if not changed. E.g. if `love.graphics.setDefaultFilter("nearest", "nearest")` is called, textureAtlas will continue to bake in `"linear"` 119 | ```lua 120 | ta:setFilter(min, mag = min) 121 | ta:setFilter("nearest") 122 | ``` 123 | ### textureAtlas:useImageData(boolean = false) 124 | Change mode for the texture atlas to use `ImageData` instead of `love.graphics`. By default if `love.graphics` isn't loaded when `textureAtlas.new` is called, it will use `imageData` mode automatically. 125 | 126 | **You cannot change this setting after adding images to the texture atlas!** 127 | ### textureAtlas:setBakeAsPow2(boolean = false) 128 | Will round the atlas width and height to their nearest power of 2 value. Do note that the packing algorithms are not optimized for spacing towards the closest power of 2, and you may be left with a lot of empty space. This function doesn't re-bake the atlas, and can be called after hard bake function has been ran, but it will not do anything. This function is useful for those who want to export an atlas as an external resource as some old graphics cards and old smartphones requires textures to be power of 2 and can (in some cases) increase performance minimally. 129 | ```lua 130 | ta:setBakeAsPow2(true) 131 | ta:setBakeAsPow2(false) 132 | ``` 133 | ### textureAtlas:setPadding(padding) 134 | Sets padding around each image, this will push the image away from the edge of the atlas. 135 | ```lua 136 | ta:setPadding(1) 137 | ta:setPadding(4) 138 | ``` 139 | ### textureAtlas:setExtrude(extrude) 140 | Sets how much an image should be extruded by. It allows you to extend the image using the clamp warp mode (or that which you've set for the image) See [here](https://love2d.org/wiki/WrapMode) for an example of clamp. This can help with bleeding in some cases. This function will use the individual warp mode of each image, be default this is "clamp". You can change this by setting the warp mode of each image you give to the texture atlas. 141 | ```lua 142 | ta:setExtrude(1) 143 | ta:setExtrude(2) 144 | ``` 145 | ### textureAtlas:setSpacing(spacing) 146 | Sets spacing between each image, doesn't add spacing between images and the edge of the atlas. 147 | ```lua 148 | ta:setSpacing(1) 149 | ta:setSpacing(3) 150 | ``` 151 | ### textureAtlas:setMaxSize(width=systemMax, height=systemMax) 152 | Set the maximum size for the texture atlas. Default is system limits, unless `love.graphics` isn't available (i.e. headless mode) then the default limit is 16,384. 153 | 154 | Due to constraints of `fixedSize`'s packing algorithm, if it cannot have the size it wants, it will throw an error. It cannot rearrange itself to fit in a restricted area. 155 | ```lua 156 | ta:setMaxSize(1000, 1000) 157 | ta:setMaxSize(100) -- height will default to baseAtlas._maxCanvasSize 158 | ta:setMaxSize(nil, 100) -- width will default to baseAtlas._maxCanvasSize 159 | ``` 160 | ### textureAtlas:add(image, id, bake = false, ...) 161 | Add or replace an image to your atlas. Use the 3rd argument to bake the addition. Recommended to only bake once all changes have been made - useful for updating one image. 4th argument is passed to `textureAtlas.bake` 162 | 163 | **Note, id can be any normal table index variable type - not limited to strings** 164 | ```lua 165 | ta:add(image, id, bake = false, ...) 166 | ta:add(love.graphics.newImage("duck.png"), "duck") 167 | 168 | fixed:add(love.graphics.newImage("duck.png"), "duck", true) 169 | dynamic:add(love.graphics.newImage("duck.png"), "duck", true, "height") -- option to add in sorting algorithm 170 | ``` 171 | ### textureAtlas:remove(id, bake = false, ...) 172 | Remove an image added to the atlas. Use the 2nd argument to bake the removal. Recommended to only bake once all changes have been made or if you're only making a single change. 4th argument is passed to `textureAtlas.bake` 173 | ```lua 174 | ta:remove(id, bake = false, ...) 175 | ta:remove("duck") 176 | 177 | fixed:remove("duck", true) 178 | dynamic:remove("duck", true, "area") 179 | ``` 180 | ### textureAtlas:getViewport(id) 181 | Get viewport for given id. Returns position, width and height for it's location on the texture atlas. Will throw an error if a quad doesn't exist for given id. 182 | ```lua 183 | local x, y, w, h = ta:getViewport(id) 184 | local x, y, w, h = ta:getViewport("duck") 185 | ``` 186 | ### textureAtlas:bake 187 | Baking takes all added images and stitches them together onto a single image. Basic check in place to ensure it only bakes when changes have been made via `add` or `remove` to avoid needless baking 188 | 189 | **Note, it's recommended to use `textureAtlas:hardBake` once all changes have been made.** 190 | ```lua 191 | fixed:bake() 192 | dynamic:bake(sortby) 193 | dynamic:bake("area") 194 | -- _sortBy options: "area" (default), "height", "width", "none" 195 | -- "height" and "area" are best from unit testing - but do your own tests to see what works best for your images 196 | -- use dynamic.image to grab the baked image 197 | ``` 198 | ### textureAtlas:hardBake 199 | Hard baking bakes the image(if changes have been made since last bake) and removes all references to all added images. Once called, you cannot add(throws error), remove(throws error) or bake again(no error). This function is designed to free up unused memory. 200 | 201 | **Note, any references to images that still exist outside of textureAtlas will keep the image alive (`image:release` is not called)** 202 | 203 | Call `collectgarbage("collect")` after `textureAtlas:hardBake` if you want to see instant results of cleaning out unused memory, otherwise let lua handle when it wants to collect garbage. 204 | ```lua 205 | fixed:hardBake() 206 | dynamic:hardBake(sortBy) -- See textureAtlas:bake for sortBy options 207 | ``` 208 | ### textureAtlas:draw(id, ...) 209 | Draw the given id, with the given variables. Varargs passed to `love.graphics.draw` 210 | ```lua 211 | ta:draw("cat") 212 | ta:draw("bird", 50,50, 0, 5,5) -- draw id "bird" at 50,50 at scale 5 213 | ``` 214 | ### textureAtlas:getDrawFuncForID(id) 215 | Get a draw function to avoid passing given texture atlas and id around 216 | ```lua 217 | local draw = ta:getDrawFuncForID("duck") 218 | -- draw(...) -- values are sent as arguments to love.graphics.draw, similar to textureAtlas:draw(id, ...) 219 | draw(50,50, 0, 5,5) -- draws id "duck" at 50,50 at scale 5 220 | ``` 221 | -------------------------------------------------------------------------------- /baseAtlas.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2021 EngineerSmith 2 | -- Under the MIT license, see license suppiled with this file 3 | local path = select(1, ...):match("(.-)[^%.]+$") 4 | local util = require(path .. "util") 5 | 6 | local lg = love.graphics 7 | 8 | local baseAtlas = { 9 | _canvasSettings = { 10 | dpiscale = 1, 11 | }, 12 | _maxCanvasSize = lg and (lg.getSystemLimits().texturesize - 1) or 16384, 13 | } 14 | baseAtlas.__index = baseAtlas 15 | 16 | baseAtlas.new = function(padding, extrude, spacing) 17 | return setmetatable({ 18 | padding = padding or 1, 19 | extrude = extrude or 0, 20 | spacing = spacing or 0, 21 | images = {}, 22 | imagesSize = 0, 23 | ids = {}, 24 | quads = {}, 25 | filterMin = "linear", 26 | filterMag = "linear", 27 | bakeAsPow2 = false, 28 | _pureImageMode = not lg, -- If this is true, no dependency to love.graphics is needed 29 | _dirty = false, -- Marked dirty if image is added or removed, 30 | _hardBake = false, -- Marked true if hardBake has been called, cannot use add, remove or bake once true 31 | }, baseAtlas) 32 | end 33 | 34 | baseAtlas._ensureCorrectImage = function(self, image) 35 | if self._pureImageMode then 36 | if image:typeOf("Texture") then 37 | error("Cannot convert Texture to ImageData") 38 | end 39 | else 40 | if image:typeOf("ImageData") then 41 | if lg then 42 | return lg.newImage(image) 43 | else 44 | error("Cannot convert ImageData to Image") 45 | end 46 | end 47 | end 48 | return image 49 | end 50 | 51 | baseAtlas.useImageData = function(self, mode) 52 | if (not mode) and (not lg) then 53 | error("love.graphics is required for useImageData(false)") 54 | end 55 | if next(self.images) then 56 | error("Cannot change image data mode if there's image in atlas") 57 | end 58 | self._pureImageMode = not not mode 59 | self._dirty = true 60 | return self 61 | end 62 | 63 | -- TA:add(img, "foo") 64 | -- TA:add(img, 68513, true) 65 | baseAtlas.add = function(self, image, id, bake, ...) 66 | if self._hardBake then 67 | error("Cannot add images to a texture atlas that has been hard baked") 68 | end 69 | self.imagesSize = self.imagesSize + 1 70 | local index = self.imagesSize 71 | local actualImage = self:_ensureCorrectImage(image) 72 | assert(type(id) ~= "nil", "Must give an id") 73 | self:remove(id) 74 | self.images[index] = { 75 | image = actualImage, 76 | id = id, 77 | index = index, 78 | } 79 | self.ids[id] = index 80 | 81 | self._dirty = true 82 | if bake then 83 | self:bake(...) 84 | end 85 | 86 | return self 87 | end 88 | 89 | -- TA:remove("foo", true) 90 | -- TA:remove(68513) 91 | baseAtlas.remove = function(self, id, bake, ...) 92 | if self._hardBake then 93 | error("Cannot remove images from a texture atlas that has been hard baked") 94 | end 95 | local index = self.ids[id] 96 | if index then 97 | self.images[index] = nil 98 | self.quads[id] = nil 99 | self.ids[id] = nil 100 | self._dirty = true 101 | if bake == true then 102 | self:bake(...) 103 | end 104 | end 105 | 106 | return self 107 | end 108 | 109 | baseAtlas.bake = function(self) 110 | error("Warning! Created atlas hasn't overriden bake function!") 111 | return self 112 | end 113 | 114 | baseAtlas.hardBake = function(self, ...) 115 | local _, data = self:bake(...) 116 | self.images = nil 117 | self.ids = nil 118 | self._hardBake = true 119 | return self, data 120 | end 121 | 122 | -- returns position on texture atlas, x,y, w,h 123 | baseAtlas.getViewport = function(self, id) 124 | local quad = self.quads[id] 125 | if quad then 126 | if self._pureImageMode then 127 | return quad[1], quad[2], quad[3], quad[4] 128 | else 129 | return quad:getViewport() 130 | end 131 | end 132 | error("Warning! Quad hasn't been baked for id: " .. tostring(id)) 133 | end 134 | 135 | baseAtlas.setFilter = function(self, min, mag) 136 | if self._pureImageMode then 137 | error("Warning! This function is unsupported within current image data mode!") 138 | end 139 | self.filterMin = min or "linear" 140 | self.filterMag = mag or self.filterMin 141 | if self.image then 142 | self.image:setFilter(self.filterMin, self.filterMag) 143 | end 144 | self._dirty = true 145 | return self 146 | end 147 | 148 | baseAtlas.setBakeAsPow2 = function(self, bakeAsPow2) 149 | self.bakeAsPow2 = bakeAsPow2 or false 150 | self._dirty = true 151 | return self 152 | end 153 | 154 | baseAtlas.setPadding = function(self, padding) 155 | self.padding = padding or 1 156 | self._dirty = true 157 | return self 158 | end 159 | 160 | baseAtlas.setExtrude = function(self, extrude) 161 | self.extrude = extrude or 0 162 | self._dirty = true 163 | return self 164 | end 165 | 166 | baseAtlas.setSpacing = function(self, spacing) 167 | self.spacing = spacing or 0 168 | self._dirty = true 169 | return self 170 | end 171 | 172 | baseAtlas.setMaxSize = function(self, width, height) 173 | self.maxWidth = width or baseAtlas._maxCanvasSize 174 | self.maxHeight = height or baseAtlas._maxCanvasSize 175 | self._dirty = true 176 | return self 177 | end 178 | 179 | baseAtlas.draw = function(self, id, ...) 180 | if self._pureImageMode then 181 | error("Warning! This function is unsupported within current image data mode!") 182 | end 183 | lg.draw(self.image, self.quads[id], ...) 184 | end 185 | 186 | baseAtlas.getDrawFuncForID = function(self, id) 187 | if self._pureImageMode then 188 | error("Warning! This function is unsupported within current image data mode!") 189 | end 190 | return function(...) 191 | lg.draw(self.image, self.quads[id], ...) 192 | end 193 | end 194 | 195 | return baseAtlas 196 | -------------------------------------------------------------------------------- /dynamicSize.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2021 EngineerSmith 2 | -- Under the MIT license, see license suppiled with this file 3 | local path = select(1, ...):match("(.-)[^%.]+$") 4 | local util = require(path .. "util") 5 | local baseAtlas = require(path .. "baseAtlas") 6 | local dynamicSizeTA = setmetatable({}, baseAtlas) 7 | dynamicSizeTA.__index = dynamicSizeTA 8 | 9 | -- Custom algorithm, see the file for more information 10 | local grid = require(path .. "packing") 11 | 12 | local lg = love.graphics 13 | local newImageData = love.image.newImageData 14 | local sort = table.sort 15 | 16 | dynamicSizeTA.new = function(padding, extrude, spacing) 17 | return setmetatable(baseAtlas.new(padding, extrude, spacing), dynamicSizeTA) 18 | end 19 | 20 | local area = function(a, b) 21 | local aW, aH = util.getImageDimensions(a.image) 22 | local bW, bH = util.getImageDimensions(b.image) 23 | return aW * aH > bW * bH 24 | end 25 | 26 | local height = function(a, b) 27 | local aH = select(2, util.getImageDimensions(a.image)) 28 | local bH = select(2, util.getImageDimensions(b.image)) 29 | return aH > bH 30 | end 31 | 32 | local width = function(a, b) 33 | local aW = util.getImageDimensions(a.image) 34 | local bW = util.getImageDimensions(b.image) 35 | return aW > bW 36 | end 37 | 38 | -- sortBy options: "height"(default), "area", "width", "none" 39 | dynamicSizeTA.bake = function(self, sortBy) 40 | if self._dirty and not self._hardBake then 41 | local shallowCopy = {unpack(self.images)} 42 | if sortBy == "height" then 43 | sort(shallowCopy, height) 44 | elseif sortBy == "area" or sortBy == nil then 45 | sort(shallowCopy, area) 46 | elseif sortBy == "width" then 47 | sort(shallowCopy, width) 48 | end 49 | 50 | -- Calculate positions and size of canvas 51 | local grid = grid.new(self.maxWidth or self._maxCanvasSize, self.maxHeight or self._maxCanvasSize) 52 | 53 | for _, image in ipairs(shallowCopy) do 54 | local img = image.image 55 | local width, height = util.getImageDimensions(img) 56 | width = width + self.spacing + self.extrude * 2 + self.padding * 2 57 | height = height + self.spacing + self.extrude * 2 + self.padding * 2 58 | grid:insert(width, height, image) -- will always be successful or will error 59 | end 60 | local maxWidth, maxHeight = grid.currentWidth - self.spacing, grid.currentHeight - self.spacing 61 | 62 | if self.bakeAsPow2 then 63 | maxWidth = math.pow(2, math.ceil(math.log(maxWidth)/math.log(2))) 64 | maxHeight = math.pow(2, math.ceil(math.log(maxHeight)/math.log(2))) 65 | end 66 | 67 | local data 68 | if self._pureImageMode then 69 | data = newImageData(maxWidth, maxHeight, "rgba8") 70 | grid:draw(self.quads, maxWidth, maxHeight, self.extrude, self.padding, data) 71 | self.image = data 72 | else 73 | local canvas = lg.newCanvas(maxWidth, maxHeight, self._canvasSettings) 74 | lg.push("all") 75 | lg.setBlendMode("replace") 76 | lg.setCanvas(canvas) 77 | grid:draw(self.quads, maxWidth, maxHeight, self.extrude, self.padding) 78 | lg.pop() 79 | local major = love.getVersion() 80 | if major == 12 and lg.readbackTexture then 81 | data = lg.readbackTexture(canvas) 82 | else 83 | data = canvas:newImageData() 84 | end 85 | self.image = lg.newImage(data) 86 | self.image:setFilter(self.filterMin, self.filterMag) 87 | end 88 | 89 | self._dirty = false 90 | return self, data 91 | end 92 | 93 | return self 94 | end 95 | 96 | return dynamicSizeTA 97 | -------------------------------------------------------------------------------- /fixedSize.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2021 EngineerSmith 2 | -- Under the MIT license, see license suppiled with this file 3 | 4 | local path = select(1, ...):match("(.-)[^%.]+$") 5 | local baseAtlas = require(path .. "baseAtlas") 6 | local util = require(path .. "util") 7 | local fixedSizeTA = setmetatable({}, baseAtlas) 8 | fixedSizeTA.__index = fixedSizeTA 9 | 10 | local lg = love.graphics 11 | local newImageData = love.image.newImageData 12 | local ceil, floor, sqrt = math.ceil, math.sqrt, math.floor 13 | 14 | fixedSizeTA.new = function(width, height, padding, extrude, spacing) 15 | local self = setmetatable(baseAtlas.new(padding, extrude, spacing), fixedSizeTA) 16 | self.width = width or error("Width required") 17 | self.height = height or width 18 | return self 19 | end 20 | 21 | fixedSizeTA.add = function(self, image, id, bake) 22 | local width, height = util.getImageDimensions(image) 23 | if width ~= self.width or height ~= self.height then 24 | error("Given image cannot fit into a fixed sized texture atlas\n Gave: W:".. tostring(width) .. " H:" ..tostring(height) .. ", Expected: W:"..self.width.." H:"..self.height) 25 | end 26 | return baseAtlas.add(self, image, id, bake) 27 | end 28 | 29 | fixedSizeTA.bake = function(self) 30 | if self._dirty and not self._hardBake then 31 | local columns = ceil(sqrt(self.imagesSize)) 32 | local width, height = self.width, self.height 33 | local widthPadded = width + self.spacing + self.extrude * 2 + self.padding * 2 34 | local heightPadded = height + self.spacing + self.extrude * 2 + self.padding * 2 35 | local maxIndex = self.imagesSize 36 | local data 37 | 38 | local widthCanvas = columns * widthPadded 39 | if widthCanvas > (self.maxWidth or self._maxCanvasSize) then 40 | error("Required width for atlas cannot be created due to reaching limits. Required: "..widthCanvas..", width limit: "..(self.maxWidth or self._maxCanvasSize)) 41 | end 42 | 43 | local rows = ceil(self.imagesSize / columns) 44 | local heightCanvas = rows * heightPadded 45 | if heightPadded > (self.maxHeight or self._maxCanvasSize) then 46 | error("Required height for atlas cannot be created due to reaching limits. Required: "..heightCanvas..", height limit: "..(self.maxHeight or self._maxCanvasSize)) 47 | end 48 | 49 | widthCanvas, heightCanvas = widthCanvas - self.spacing, heightCanvas - self.spacing 50 | if self.bakeAsPow2 then 51 | widthCanvas = math.pow(2, math.ceil(math.log(widthCanvas)/math.log(2))) 52 | heightCanvas = math.pow(2, math.ceil(math.log(heightCanvas)/math.log(2))) 53 | end 54 | 55 | if self._pureImageMode then 56 | data = newImageData(widthCanvas, heightCanvas, "rgba8") 57 | for x=0, columns-1 do 58 | for y=0, rows-1 do 59 | local index = (x*rows+y)+1 60 | if index > maxIndex then 61 | break 62 | end 63 | local x, y = x * widthPadded + self.padding + self.extrude, y * heightPadded + self.padding + self.extrude 64 | local image = self.images[index] 65 | data:paste(image.image, x, y, 0, 0, image.image:getDimensions()) 66 | if self.extrude > 0 then 67 | util.extrudeWithFill(data, image.image, self.extrude, x, y) 68 | end 69 | self.quads[image.id] = {x+self.extrude, y+self.extrude, width, height} 70 | end 71 | end 72 | else 73 | local extrudeQuad = lg.newQuad(-self.extrude, -self.extrude, width+self.extrude*2, height+self.extrude*2, self.width, self.height) 74 | local canvas = lg.newCanvas(widthCanvas, heightCanvas, self._canvasSettings) 75 | lg.push("all") 76 | lg.setBlendMode("replace") 77 | lg.setCanvas(canvas) 78 | for x=0, columns-1 do 79 | for y=0, rows-1 do 80 | local index = (x*rows+y)+1 81 | if index > maxIndex then 82 | break 83 | end 84 | local x, y = x * widthPadded + self.padding, y * heightPadded + self.padding 85 | local image = self.images[index] 86 | lg.draw(image.image, extrudeQuad, x, y) 87 | self.quads[image.id] = lg.newQuad(x+self.extrude, y+self.extrude, width, height, widthCanvas, heightCanvas) 88 | end 89 | end 90 | lg.pop() 91 | local major = love.getVersion() 92 | if major == 12 and lg.readbackTexture then 93 | data = lg.readbackTexture(canvas) 94 | else 95 | data = canvas:newImageData() 96 | end 97 | self.image = lg.newImage(data) 98 | self.image:setFilter(self.filterMin, self.filterMag) 99 | end 100 | 101 | self._dirty = false 102 | return self, data 103 | end 104 | 105 | return self 106 | end 107 | 108 | return fixedSizeTA 109 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2021 EngineerSmith 2 | -- Under the MIT license, see license suppiled with this file 3 | 4 | local path = select(1, ...) 5 | local textureAtlas = { 6 | newFixedSize = require(... .. ".fixedSize").new, 7 | newDynamicSize = require(... .. ".dynamicSize").new 8 | } 9 | 10 | return textureAtlas -------------------------------------------------------------------------------- /packing.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2021 EngineerSmith 2 | -- Under the MIT license, see license suppiled with this file 3 | 4 | -- Custom algorithm to solve the rectangle packing problem 5 | -- As every other algorithm only solved the bin packing problem 6 | 7 | local path = select(1, ...):match("(.-)[^%.]+$") 8 | local util = require(path .. "util") 9 | local lg = love.graphics 10 | local insert, remove = table.insert, table.remove 11 | 12 | local cell = {} 13 | cell.__index = {} 14 | 15 | cell.new = function(x, y, w, h, data) 16 | return setmetatable({ 17 | x = x, y = y, w= w, h = h, 18 | data = data, 19 | }, cell) 20 | end 21 | 22 | local grid = {} 23 | grid.__index = grid 24 | 25 | grid.new = function(limitWidth, limitHeight) 26 | return setmetatable({ 27 | limitWidth = limitWidth, 28 | limitHeight = limitHeight, 29 | currentWidth = 0, 30 | currentHeight = 0, 31 | cells = {}, 32 | unoccupiedCells = {}, 33 | }, grid) 34 | end 35 | 36 | grid.insert = function(self, width, height, data) 37 | if width > self.limitWidth then 38 | error("Given image's width("..width..") is bigger than the limit("..self.limitWidth.."). Image ID: "..data.id) 39 | return false 40 | elseif height > self.limitHeight then 41 | error("Given image's height("..height..") is bigger than the limit("..self.limitHeight.."). Image ID: "..data.id) 42 | return false 43 | end 44 | 45 | -- find edge cells for later 46 | local edgeCells = {} 47 | 48 | -- check if there are any unoccupied cells available for placement 49 | for index, unoccupiedCell in ipairs(self.unoccupiedCells) do 50 | -- perfect fit 51 | if unoccupiedCell.w == width and unoccupiedCell.h == height then 52 | unoccupiedCell.data = data 53 | insert(self.cells, unoccupiedCell) 54 | remove(self.unoccupiedCells, index) 55 | return true 56 | -- width is perfect, so cut height 57 | elseif unoccupiedCell.w == width and unoccupiedCell.h > height then 58 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x, unoccupiedCell.y+height, unoccupiedCell.w, unoccupiedCell.h-height)) 59 | unoccupiedCell.h, unoccupiedCell.data = height, data 60 | insert(self.cells, unoccupiedCell) 61 | remove(self.unoccupiedCells, index) 62 | return true 63 | -- height is perfect, so cut width 64 | elseif unoccupiedCell.h == height and unoccupiedCell.w > width then 65 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x+width, unoccupiedCell.y, unoccupiedCell.w-width, unoccupiedCell.h)) 66 | unoccupiedCell.w, unoccupiedCell.data = width, data 67 | insert(self.cells, unoccupiedCell) 68 | remove(self.unoccupiedCells, index) 69 | return true 70 | -- cell is big enough to fit but needs cutting down to size 71 | elseif unoccupiedCell.w > width and unoccupiedCell.h > height then 72 | -- pick best direction to cut 73 | if unoccupiedCell.h > unoccupiedCell.w then 74 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x+width, unoccupiedCell.y, unoccupiedCell.w-width, height)) -- right 75 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x, unoccupiedCell.y+height, unoccupiedCell.w, unoccupiedCell.h-height)) -- bottom 76 | else 77 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x+width, unoccupiedCell.y, unoccupiedCell.w-width, unoccupiedCell.h)) -- right 78 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x, unoccupiedCell.y+height, width, unoccupiedCell.h-height)) -- bottom 79 | end 80 | unoccupiedCell.w, unoccupiedCell.h, unoccupiedCell.data = width, height, data 81 | insert(self.cells, unoccupiedCell) 82 | remove(self.unoccupiedCells, index) 83 | return true 84 | end 85 | local edgeRight = unoccupiedCell.x + unoccupiedCell.w == self.currentWidth 86 | local edgeBottom = unoccupiedCell.y + unoccupiedCell.h == self.currentHeight 87 | -- Add cells we can use to the list 88 | if (edgeRight and unoccupiedCell.h >= height) or (edgeBottom and unoccupiedCell.w >= width) or (edgeRight and edgeBottom) then 89 | insert(edgeCells, { unoccupiedCell, right=edgeRight, bottom=edgeBottom, index = index}) 90 | end 91 | end 92 | -- no unoccupied cells suitable, create new cell 93 | 94 | -- score edge placement to find cheapest grow cost 95 | -- score is equal to the number of pixels that have to be newly claimed 96 | local overhangBottom = width - self.currentWidth -- over hang cost 97 | if overhangBottom > 0 then 98 | overhangBottom = self.currentHeight * overhangBottom 99 | else 100 | overhangBottom = 0 101 | end 102 | local bottomScore = height * (width > self.currentWidth and width or self.currentWidth) + overhangBottom 103 | 104 | local overhangRight = height - self.currentHeight -- over hang cost 105 | if overhangRight > 0 then 106 | overhangRight = self.currentHeight * overhangRight 107 | else 108 | overhangRight = 0 109 | end 110 | local rightScore = (height > self.currentHeight and height or self.currentHeight) * width + overhangRight 111 | 112 | -- Score trying to be placed indented within an edge cell 113 | local edgeScore, celledgeTbl = math.huge, nil 114 | for _, edgeTable in ipairs(edgeCells) do 115 | local cell = edgeTable[1] 116 | if edgeTable.right and edgeTable.bottom and overhangRight > 0 and overhangBottom > 0 then -- corner 117 | -- limits 118 | if cell.x + width > self.limitWidth or 119 | cell.y + height > self.limitHeight then 120 | goto continue 121 | end 122 | local totalArea = (width * height) - (cell.w * cell.h) -- cut out cell 123 | local overhangBottom = (cell.h - height) * cell.x -- everything passed X/Y is within totalArea 124 | local overhangRight = (cell.w - width ) * cell.y 125 | local score = overhangBottom + overhangRight + totalArea 126 | if edgeScore > score then 127 | edgeScore = score 128 | celledgeTbl = edgeTable 129 | end 130 | 131 | elseif overhangBottom == 0 and edgeTable.bottom then 132 | -- limits 133 | if cell.y + height > self.limitHeight or 134 | (edgeTable.right and cell.x + width > self.limitWidth) then 135 | goto continue 136 | end 137 | local score = self.currentWidth * (height - cell.h) 138 | if edgeScore > score then 139 | edgeScore = score 140 | celledgeTbl = edgeTable 141 | end 142 | elseif overhangRight == 0 and edgeTable.right then -- if over hang, don't handle it here 143 | -- limits 144 | if cell.x + width > self.limitWidth or 145 | (edgeTable.bottom and cell.y + height > self.limitHeight) then 146 | goto continue -- cannot fit here 147 | end 148 | local score = self.currentHeight * (width - cell.w) 149 | if edgeScore > score then 150 | edgeScore = score 151 | celledgeTbl = edgeTable 152 | end 153 | end 154 | ::continue:: 155 | end 156 | 157 | -- limits 158 | local limitWidth = self.currentWidth + width > self.limitWidth and edgeScore > rightScore 159 | local limitHeight = self.currentHeight + height > self.limitHeight and edgeScore > bottomScore 160 | if limitHeight and limitWidth then 161 | error("Could not fit all images within texture atlas. Failed inserting image ID: "..data.id) 162 | elseif limitWidth then -- place bottom or edge 163 | rightScore = edgeScore < bottomScore and edgeScore+1 or bottomScore + 1 164 | elseif limitHeight then -- place right or edge 165 | bottomScore = edgeScore < rightScore and edgeScore+1 or rightScore +1 166 | end 167 | 168 | -- Check cheapest direction 169 | if edgeScore < rightScore and edgeScore < bottomScore then 170 | -- edge is cheapest 171 | local unoccupiedCell = celledgeTbl[1] 172 | local oldWidth = self.currentWidth 173 | -- split cell 174 | if celledgeTbl.right and width - unoccupiedCell.w > 0 then 175 | if unoccupiedCell.y ~= 0 then 176 | insert(self.unoccupiedCells, cell.new(self.currentWidth, 0, width-unoccupiedCell.w, unoccupiedCell.y)) 177 | end 178 | if unoccupiedCell.h > height then 179 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x, unoccupiedCell.y+height, width, unoccupiedCell.h-height)) 180 | end 181 | if unoccupiedCell.y + unoccupiedCell.h < self.currentHeight then 182 | insert(self.unoccupiedCells, cell.new(self.currentWidth, unoccupiedCell.y+unoccupiedCell.h, width-unoccupiedCell.w, self.currentHeight-(unoccupiedCell.y+unoccupiedCell.h))) 183 | end 184 | self.currentWidth = self.currentWidth + width - unoccupiedCell.w 185 | end 186 | if celledgeTbl.bottom and height - unoccupiedCell.h > 0 then 187 | if unoccupiedCell.x ~= 0 then 188 | insert(self.unoccupiedCells, cell.new(0, self.currentHeight, unoccupiedCell.x, height-unoccupiedCell.h)) 189 | end 190 | if unoccupiedCell.w > width then 191 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x+width, unoccupiedCell.y, unoccupiedCell.w-width, height)) 192 | end 193 | if unoccupiedCell.x + unoccupiedCell.w < oldWidth then 194 | insert(self.unoccupiedCells, cell.new(unoccupiedCell.x+unoccupiedCell.w, self.currentHeight, oldWidth-(unoccupiedCell.x+unoccupiedCell.w), height-unoccupiedCell.h)) 195 | end 196 | self.currentHeight = self.currentHeight + height - unoccupiedCell.h 197 | end 198 | -- add image to cell 199 | unoccupiedCell.w, unoccupiedCell.h, unoccupiedCell.data = width, height, data 200 | insert(self.cells, unoccupiedCell) 201 | remove(self.unoccupiedCells, celledgeTbl.index) 202 | return true 203 | end 204 | 205 | -- Pick a direction to lean if scores are equal 206 | if bottomScore == rightScore then 207 | if width > height then 208 | rightScore = bottomScore + 1 -- place bottom 209 | else 210 | bottomScore = rightScore + 1 -- place right 211 | end 212 | end 213 | 214 | -- add best new cells 215 | if bottomScore < rightScore then -- place bottom 216 | insert(self.cells, cell.new(0, self.currentHeight, width, height, data)) 217 | if self.currentWidth > width then 218 | -- create cell to fit gap between new cell height and current height 219 | insert(self.unoccupiedCells, cell.new(width, self.currentHeight, self.currentWidth-width, height)) 220 | elseif self.currentWidth < width then 221 | insert(self.unoccupiedCells, cell.new(self.currentWidth, 0, width-self.currentWidth, self.currentWidth)) 222 | self.currentWidth = width 223 | end 224 | self.currentHeight = self.currentHeight + height 225 | else -- place right 226 | insert(self.cells, cell.new(self.currentWidth, 0, width, height, data)) 227 | if self.currentHeight > height then 228 | -- create cell to fit gap between new cell width and current width 229 | insert(self.unoccupiedCells, cell.new(self.currentWidth, height, width, self.currentHeight-height)) 230 | elseif self.currentHeight < height then 231 | insert(self.unoccupiedCells, cell.new(0, self.currentHeight, self.currentWidth, height-self.currentHeight)) 232 | self.currentHeight = height 233 | end 234 | self.currentWidth = self.currentWidth + width 235 | end 236 | 237 | -- TODO improvement: merge unoccupied cells (seperate function to reach all cases in first unoccupied loop to avoid goto) 238 | -- By merging cells it gives more options for images to be inserted into and decreases 239 | -- number of cells that will have to be looped through before placing new 240 | 241 | -- to solve: AABB - check if axis lines up so that they can be merged 242 | -- save time: Only call merge if unoccupied cells are created 243 | return true 244 | end 245 | 246 | grid.mergeCells = function(self) 247 | 248 | end 249 | 250 | grid.draw = function(self, quads, width, height, extrude, padding, imageData) 251 | for _, cell in ipairs(self.cells) do 252 | local image = cell.data.image 253 | local iwidth, iheight = util.getImageDimensions(image) 254 | if imageData then 255 | local x, y = cell.x + padding + extrude, cell.y + padding + extrude 256 | imageData:paste(image, x, y, 0,0, iwidth, iheight) 257 | if extrude > 0 then 258 | util.extrudeWithFill(imageData, image, extrude, x, y) 259 | end 260 | quads[cell.data.id] = {x, y, iwidth, iheight} 261 | else 262 | local extrudeQuad = lg.newQuad(-extrude, -extrude, iwidth+extrude*2, iheight+extrude*2, iwidth, iheight) 263 | lg.draw(image, extrudeQuad, cell.x+padding, cell.y+padding) 264 | quads[cell.data.id] = lg.newQuad(cell.x+extrude+padding, cell.y+extrude+padding, iwidth, iheight, width, height) 265 | end 266 | end 267 | end 268 | 269 | return grid -------------------------------------------------------------------------------- /util.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2021 EngineerSmith 2 | -- Under the MIT license, see license suppiled with this file 3 | 4 | local util = {} 5 | 6 | util.getImageDimensions = function(image) 7 | if image:typeOf("Texture") then 8 | return image:getPixelDimensions() 9 | else 10 | return image:getDimensions() 11 | end 12 | end 13 | 14 | 15 | -- TODO: Remove closure usage 16 | local fillImageData = function(imageData, x, y, w, h, r, g, b, a) 17 | imageData:mapPixel(function() 18 | return r, g, b, a 19 | end, x, y, w, h) 20 | end 21 | 22 | local extrudeImageData = function(dest, src, n, x, y, dx, dy, sx, sy, sw, sh) 23 | for i = 1, n do 24 | dest:paste(src, x + i * dx, y + i * dy, sx, sy, sw, sh) 25 | end 26 | end 27 | 28 | util.extrudeWithFill = function(dest, src, n, x, y) 29 | local iw, ih = src:getDimensions() 30 | extrudeImageData(dest, src, n, x, y, 0, -1, 0, 0, iw, 1) -- top 31 | extrudeImageData(dest, src, n, x, y, -1, 0, 0, 0, 1, ih) -- left 32 | extrudeImageData(dest, src, n, x, y + ih - 1, 0, 1, 0, ih - 1, iw, 1) -- bottom 33 | extrudeImageData(dest, src, n, x + iw - 1, y, 1, 0, iw - 1, 0, 1, ih) -- right 34 | fillImageData(dest, x - n, y - n, n, n, src:getPixel(0, 0)) -- top-left 35 | fillImageData(dest, x + iw, y - n, n, n, src:getPixel(iw - 1, 0)) -- top-right 36 | fillImageData(dest, x + iw, y + ih, n, n, src:getPixel(iw - 1, ih - 1)) -- bottom-right 37 | fillImageData(dest, x - n, y + ih, n, n, src:getPixel(0, ih - 1)) -- bottom-left 38 | end 39 | 40 | return util 41 | --------------------------------------------------------------------------------