Skip to content

Commit fa6f762

Browse files
authored
TexturePacker in Gradle plugin can detect and merge duplicate tiles (#2346)
- Refactor packImages and add packTilesets function - Implement removing of duplicate tiles in TexturePacker - Write unit tests for packTilesets in NewTexturePacker
1 parent 861636e commit fa6f762

File tree

5 files changed

+188
-34
lines changed

5 files changed

+188
-34
lines changed

korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewTexturePacker.kt

Lines changed: 134 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,62 @@ object NewTexturePacker {
4040
} }
4141
}
4242

43+
/**
44+
* Packs tilesets from the given folders into texture atlases.
45+
*
46+
* Each tileset image is split into individual tiles of the specified size.
47+
* These tiles are checked for duplicates and then packed into atlases.
48+
*
49+
* @param folders The folders containing tilesets to be packed.
50+
* @param padding The padding to apply around each tile in the atlas.
51+
* @param tileSize The size of each tile in the tilesets.
52+
* @return A list of AtlasInfo objects representing the packed atlases.
53+
*/
54+
fun packTilesets(
55+
vararg folders: File,
56+
padding: Int = 1,
57+
tileSize: Int = 16
58+
): List<AtlasInfo> {
59+
// Load all tilesets and create SimpleBitmap instances
60+
val tilesets: List<Pair<File, SimpleBitmap>> = getAllFiles(*folders).mapNotNull {
61+
try {
62+
it.relative to SimpleBitmap(it.file)
63+
} catch (e: Throwable) {
64+
e.printStackTrace()
65+
null
66+
}
67+
}
68+
// Split each tileset into tiles
69+
val images = arrayListOf<Pair<File, SimpleBitmap>>()
70+
tilesets.forEach { tileset ->
71+
val (file, image) = tileset
72+
if (image.width % tileSize != 0 || image.height % tileSize != 0) {
73+
throw IllegalArgumentException("Tileset image size must be multiple of tileSize ($tileSize): $file with size ${image.width}x${image.height}")
74+
}
75+
images += image.splitInListOfTiles(file.nameWithoutExtension, tileSize)
76+
}
77+
78+
return packImages(images, enableRotation = false, enableTrimming = false, padding = padding, trimFileName = true, removeDuplicates = true)
79+
}
80+
81+
/**
82+
* Packs images from the given folders into texture atlases.
83+
*
84+
* @param folders The folders containing images to be packed.
85+
* @param enableRotation Whether to allow rotation of images for better packing.
86+
* @param enableTrimming Whether to trim transparent pixels from images.
87+
* @param padding The padding to apply around each image in the atlas.
88+
* @param trimFileName Whether to trim the file name in the output info.
89+
* @param removeDuplicates Whether to remove duplicate images and map duplicates in atlas info.
90+
* @return A list of AtlasInfo objects representing the packed atlases.
91+
*/
4392
fun packImages(
4493
vararg folders: File,
4594
enableRotation: Boolean = true,
4695
enableTrimming: Boolean = true,
4796
padding: Int = 2,
97+
trimFileName: Boolean = false,
98+
removeDuplicates: Boolean = false
4899
): List<AtlasInfo> {
49100
val images: List<Pair<File, SimpleBitmap>> = getAllFiles(*folders).mapNotNull {
50101
try {
@@ -54,68 +105,100 @@ object NewTexturePacker {
54105
null
55106
}
56107
}
108+
return packImages(images, enableRotation, enableTrimming, padding, trimFileName, removeDuplicates)
109+
}
57110

111+
private fun packImages(
112+
images: List<Pair<File, SimpleBitmap>>,
113+
enableRotation: Boolean,
114+
enableTrimming: Boolean,
115+
padding: Int,
116+
trimFileName: Boolean,
117+
removeDuplicates: Boolean
118+
): List<AtlasInfo> {
58119
val packer = NewBinPacker.MaxRectsPacker(4096, 4096, padding * 2, NewBinPacker.IOption(
59120
smart = true,
60121
pot = true,
61122
square = false,
62123
allowRotation = enableRotation,
63-
//allowRotation = false,
64124
tag = false,
65125
border = padding
66126
))
67127

68-
packer.addArray(images.map { (file, image) ->
128+
// Handle duplicate images if requested
129+
val tileMapping = linkedMapOf<File, File>() // In case of duplicates, map duplicate file to original file
130+
val mappedImages = if (removeDuplicates) { // mappedImages will contain only unique images if removeDuplicates is true
131+
// Remove duplicate images
132+
val uniqueMap = linkedMapOf<Int, Pair<File, SimpleBitmap>>()
133+
for ((fileName, image) in images) {
134+
val file = if (trimFileName) File(fileName.nameWithoutExtension) else fileName
135+
val hash = image.hashCode()
136+
val existing = uniqueMap[hash]
137+
if (existing == null) {
138+
uniqueMap[hash] = file to image
139+
tileMapping[file] = file
140+
} else tileMapping[file] = existing.first
141+
}
142+
uniqueMap.values.toList()
143+
} else {
144+
// No duplicate removal, use all images
145+
for ((fileName, _) in images) {
146+
val file = if (trimFileName) File(fileName.nameWithoutExtension) else fileName
147+
tileMapping[file] = file
148+
}
149+
images
150+
}
151+
152+
// Add images to the packer (possibly without duplicates)
153+
packer.addArray(mappedImages.map { (file, image) ->
69154
val fullArea = Rectangle(0, 0, image.width, image.height)
70155
val trimArea = if (enableTrimming) image.trim() else fullArea
71156
val trimmedImage = image.slice(trimArea)
72157
//println(trimArea == fullArea)
158+
val fileName = if (trimFileName) File(file.nameWithoutExtension) else file
73159
NewBinPacker.Rectangle(width = trimmedImage.width, height = trimmedImage.height, raw = Info(
74-
file, fullArea, trimArea, trimmedImage
160+
fileName, fullArea, trimArea, trimmedImage
75161
))
76162
})
77163

164+
// Building atlas info which includes mapping duplicates
78165
val outAtlases = arrayListOf<AtlasInfo>()
79166
for (bin in packer.bins) {
80-
//val rwidth = bin.rects.maxOf { it.right }
81-
//val rheight = bin.rects.maxOf { it.bottom }
82-
//val maxWidth = bin.maxWidth
83-
//val maxHeight = bin.maxHeight
84-
//val out = SimpleBitmap(rwidth, rheight)
85167
val out = SimpleBitmap(bin.width, bin.height)
86-
//println("${bin.width}x${bin.height}")
87-
88168
val frames = linkedMapOf<String, Any?>()
89169

90170
for (rect in bin.rects) {
91171
val info = rect.raw as Info
92-
val fileName = info.file.name
93-
//println("$rect :: info=$info")
94172

95-
val chunk = if (rect.rot) info.trimmedImage.flipY().rotate90() else info.trimmedImage
96-
out.put(rect.x - padding, rect.y - padding, chunk.extrude(padding))
97-
//out.put(rect.x, rect.y, chunk)
173+
// Check if this rect (image) is used by any duplicate files - if so, map them all to the same rect area in the atlas
174+
val files = tileMapping.filterValues { it == info.file }.keys
175+
for (file in files) {
176+
val fileName = file.name
98177

99-
val obj = LinkedHashMap<String, Any?>()
178+
val chunk = if (rect.rot) info.trimmedImage.flipY().rotate90() else info.trimmedImage
179+
out.put(rect.x - padding, rect.y - padding, chunk.extrude(padding))
100180

101-
fun Dimension.toObj(rot: Boolean): Map<String, Any?> {
102-
val w = if (!rot) width else height
103-
val h = if (!rot) height else width
104-
return mapOf("w" to w, "h" to h)
105-
}
106-
fun Rectangle.toObj(rot: Boolean): Map<String, Any?> {
107-
return mapOf("x" to x, "y" to y) + this.size.toObj(rot)
108-
}
181+
val obj = LinkedHashMap<String, Any?>()
109182

110-
obj["frame"] = Rectangle(rect.x, rect.y, rect.width, rect.height).toObj(rect.rot)
111-
obj["rotated"] = rect.rot
112-
obj["trimmed"] = info.trimArea != info.fullArea
113-
obj["spriteSourceSize"] = info.trimArea.toObj(false)
114-
obj["sourceSize"] = info.fullArea.size.toObj(false)
183+
fun Dimension.toObj(rot: Boolean): Map<String, Any?> {
184+
val w = if (!rot) width else height
185+
val h = if (!rot) height else width
186+
return mapOf("w" to w, "h" to h)
187+
}
115188

116-
frames[fileName] = obj
117-
}
189+
fun Rectangle.toObj(rot: Boolean): Map<String, Any?> {
190+
return mapOf("x" to x, "y" to y) + this.size.toObj(rot)
191+
}
118192

193+
obj["frame"] = Rectangle(rect.x, rect.y, rect.width, rect.height).toObj(rect.rot)
194+
obj["rotated"] = rect.rot
195+
obj["trimmed"] = info.trimArea != info.fullArea
196+
obj["spriteSourceSize"] = info.trimArea.toObj(false)
197+
obj["sourceSize"] = info.fullArea.size.toObj(false)
198+
199+
frames[fileName] = obj
200+
}
201+
}
119202

120203
val atlasOut = linkedMapOf<String, Any?>(
121204
"frames" to frames,
@@ -138,3 +221,23 @@ object NewTexturePacker {
138221
return outAtlases
139222
}
140223
}
224+
225+
/**
226+
* Split the bitmap into tiles of given size and return them as a list of pairs (File, SimpleBitmap).
227+
* The File is named as "${name}_${index} without extension."
228+
*/
229+
fun SimpleBitmap.splitInListOfTiles(name: String, tileSize: Int): List<Pair<File, SimpleBitmap>> {
230+
val tiles = arrayListOf<Pair<File, SimpleBitmap>>()
231+
val tilesX = this.width / tileSize
232+
val tilesY = this.height / tileSize
233+
var index = 0
234+
for (ty in 0 until tilesY) {
235+
for (tx in 0 until tilesX) {
236+
val tileBitmap = this.slice(Rectangle(tx * tileSize, ty * tileSize, tileSize, tileSize))
237+
val tileFile = File("${name}_${index}")
238+
tiles.add(tileFile to tileBitmap)
239+
index++
240+
}
241+
}
242+
return tiles
243+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package korlibs.korge.gradle.texpacker
2+
3+
import korlibs.korge.gradle.typedresources.getResourceURL
4+
import org.junit.Assert
5+
import org.junit.Test
6+
import java.io.File
7+
8+
9+
class NewTexturePackerTest {
10+
@Test
11+
fun testPackTilesets() {
12+
val atlasInfos = NewTexturePacker.packTilesets(File(getResourceURL("tilesets").file))
13+
14+
// Check if duplicate tiles were detected and merged into one tile rect area in the tileset atlas
15+
val atlasInfo = atlasInfos.first()
16+
val frames = atlasInfo.info["frames"] as Map<String, Any>
17+
18+
val tile_1 = frames["test_tileset_1"] as Map<String, Any>
19+
val tile_2 = frames["test_tileset_4"] as Map<String, Any>
20+
val tile_3 = frames["test_tileset_2_0"] as Map<String, Any> // This tile is in the second tileset but is a duplicate of test_tileset_1
21+
22+
// Check that all three tiles point to the same frame data (i.e., they are duplicates)
23+
val tileFrame_1 = tile_1["frame"] as Map<String, Int>
24+
val tileFrame_2 = tile_2["frame"] as Map<String, Int>
25+
val tileFrame_3 = tile_3["frame"] as Map<String, Int>
26+
Assert.assertEquals(tileFrame_1["x"], tileFrame_2["x"])
27+
Assert.assertEquals(tileFrame_1["y"], tileFrame_2["y"])
28+
Assert.assertEquals(tileFrame_1["w"], tileFrame_2["w"])
29+
Assert.assertEquals(tileFrame_1["h"], tileFrame_2["h"])
30+
Assert.assertEquals(tileFrame_1["x"], tileFrame_3["x"])
31+
Assert.assertEquals(tileFrame_1["y"], tileFrame_3["y"])
32+
Assert.assertEquals(tileFrame_1["w"], tileFrame_3["w"])
33+
Assert.assertEquals(tileFrame_1["h"], tileFrame_3["h"])
34+
35+
// Check that tiles are not rotated or trimmed
36+
val rotated_1 = tile_1["rotated"] as Boolean
37+
val rotated_2 = tile_2["rotated"] as Boolean
38+
val rotated_3 = tile_3["rotated"] as Boolean
39+
Assert.assertFalse(rotated_1)
40+
Assert.assertFalse(rotated_2)
41+
Assert.assertFalse(rotated_3)
42+
val trimmed_1 = tile_1["trimmed"] as Boolean
43+
val trimmed_2 = tile_2["trimmed"] as Boolean
44+
val trimmed_3 = tile_3["trimmed"] as Boolean
45+
Assert.assertFalse(trimmed_1)
46+
Assert.assertFalse(trimmed_2)
47+
Assert.assertFalse(trimmed_3)
48+
49+
//println("Atlas Info: $atlasInfos")
50+
}
51+
}

korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/AseSpriteTest.kt renamed to korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/util/AseInfoTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
package korlibs.korge.gradle.typedresources
1+
package korlibs.korge.gradle.util
22

3-
import korlibs.korge.gradle.util.*
3+
import korlibs.korge.gradle.typedresources.getResourceBytes
44
import org.junit.Assert
55
import org.junit.Test
66

7-
class AseSpriteTest {
7+
class AseInfoTest {
88
@Test
99
fun test() {
1010
val info = ASEInfo.Companion.getAseInfo(getResourceBytes("sprites.ase"))
222 Bytes
Loading
224 Bytes
Loading

0 commit comments

Comments
 (0)