Every time I add a new butterfly to the mod, I need to create multiple textures for them. Recently, I developed a method generating spawn egg textures so I didn’t have to do them manually. This week I set about integrating texture generation into the pipeline, and generating even more textures.
These are the textures for each new species of butterfly or moth that I need to add at the time of writing:
Entities
- Egg (same as item)
- Caterpillar
- Chrysalis (same as item)
- Butterfly
Items
- Egg (same as entity)
- Caterpillar
- Chrysalis (same as entity)
- Bottled Caterpillar
- Bottled Butterfly
- Butterfly Scroll
- Egg Spawn Egg
- Caterpillar Spawn Egg
- Chrysalis Spawn Egg
- Butterfly Spawn Egg
GUI
- Butterfly Scroll
With the egg and chrysalis sharing the same texture, this is a total of 13 textures. That’s a lot of work for someone who isn’t an artist!
Most of these textures are derived from the four entity textures. When I create the item and GUI textures, I’m essentially copying and pasting from the entities’ textures. I’m almost doing the same thing automatically each time. Repetitive tasks like this are ripe for automation, so this week that’s exactly what I did.
Image Generator
When I developed scripts for generating spawn egg textures, I knew they would have to be added to the pipeline eventually. I found that images would need cropping, scaling, moving, rotating, and other transformations before combining, so I created a method that would support all of these transformations.
To integrate it into the pipeline I started by creating an ImageGenerator
class that contained all of the needed transformations as separate methods. This would allow me to write scripts that could apply them to both the base image and the overlay image – something I would need later on.
It would also allow me to combine multiple images with different transformations on each, which would enable the creation of butterfly scroll textures. The methods I implemented are as follows:
class ImageGenerator: def _load_image(self, image_path: Path) -> Image.Image: """ Loads an image from the given path. """ try: with Image.open(image_path) as img: img.load() return img.copy() except OSError as e: self.logger.error(f"Could not open image {image_path}: {e}") raise @staticmethod def _crop_image( image: Image.Image, crop_start: tuple[int, int], crop_size: tuple[int, int] ) -> Image.Image: """ Crop the given image and return the cropped image. """ return image.crop(( crop_start[0], crop_start[1], crop_start[0] + crop_size[0], crop_start[1] + crop_size[1] )) @staticmethod def _rotate_image(image: Image.Image, rotate: int) -> Image.Image: """ Rotate the given image and return the rotated image. """ return image.rotate(rotate, expand=True) @staticmethod def _resize_image( image: Image.Image, new_width: int, new_height: int, resampling: Image.Resampling = Image.Resampling.LANCZOS ) -> Image.Image: """ Resizes the given image. """ return image.resize((new_width, new_height), resampling) @staticmethod def _combine_images( base_image: Image.Image, overlay_image: Image.Image, offset: tuple[int, int] = (0, 0) ) -> Image.Image: """ Combine the given images and return the combined image. """ # Ensure we are using RGBA in our images. if base_image.mode != 'RGBA': base_image = base_image.convert('RGBA') if overlay_image.mode != 'RGBA': overlay_image = overlay_image.convert('RGBA') # Ensure the overlay fits if overlay_image.size > base_image.size: overlay_resized = overlay_image.resize(base_image.size, Image.Resampling.LANCZOS) else: overlay_resized = overlay_image position =\ (((base_image.width - overlay_resized.width) // 2) + offset[0], ((base_image.height - overlay_resized.height) // 2) + offset[1]) # Offset the image overlay_final = Image.new('RGBA', base_image.size, (0, 0, 0, 0)) overlay_final.paste(overlay_resized, position, overlay_resized) return Image.alpha_composite(base_image, overlay_final) def _save_image(self, image_path: Path, image: Image.Image) -> None: """ Saves the image to the path, optimizing PNG files. """ try: image.save(image_path, optimize=True) except Exception as e: self.logger.error(f"Failed to save image {image_path}: {e}") raise
These base functions would form a simple library that I could use to generate many different textures. I started with re-implementing what I had already done: spawn egg texture generation.
Spawn Eggs (Again)
I modified the many texture generation scripts I had left lying around the textures folders to be functions in the ImageGenerator
class, and to use these methods instead. They were relatively simple to adapt, as you can see in the example for chrysalis spawn eggs. The original version called a single function like this:
for x in os.listdir(): if x.endswith(".png") and x.startswith("chrysalis_"): overlay_images('spawn_egg.png', x, '../../item/spawn_egg/chrysalis/' + x[10:], offset=(4, 1), scale=0.8)
Now we have a function that only performs the required steps, but also makes it explicit in what order these steps are performed:
def _generate_chrysalis_spawn_eggs(self, egg_image: Image.Image) -> None: """ Generate chrysalis spawn egg textures. """ self.logger.info(f"Generating chrysalis spawn egg textures") butterfly_entity_textures = [ f for f in self.config.CHRYSALIS_ENTITY_TEXTURE_PATH.iterdir() if f.suffix == ".png" and os.path.basename(f).startswith("chrysalis_") ] for texture in butterfly_entity_textures: overlay_image = self._load_image(texture) overlay_image = self._crop_image(overlay_image, (0, 0), (16, 16)) overlay_image = self._resize_image(overlay_image, int(egg_image.width * 0.8), int(egg_image.height * 0.8)) overlay_image = self._combine_images(egg_image, overlay_image, (3, 1)) self._save_image(self.config.CHRYSALIS_SPAWN_EGG_TEXTURE_PATH / os.path.basename(texture)[10:], overlay_image)
I won’t list the code for all the functions here, but you can see the full script in my GitHub repository if you’re interested in seeing how they all work.
With this change, the total number of textures I need to add for each species is reduced from 13 to 9.
Bottles and Scrolls
Bottles and scrolls were easy to generate with these new methods. Butterfly scrolls were generated the same as spawn eggs, only with a different base texture, and a 45 degree rotation rather than 90 degrees.
Bottles were very similar as well, however I would first combine the textures with butterfly/caterpillar overlay. This would ensure the image remains the right size, and allow an offset to be applied properly.
I would then combine the new image with the bottle texture as an overlay, so that the bottle texture would appear on top:
def _generate_bottled_caterpillar(self, bottle_image, caterpillar_item_textures) -> None: """ Generate bottled butterfly textures. """ self.logger.info(f"Generating bottled caterpillar textures") for texture_info in caterpillar_item_textures: overlay_image = self._resize_image(texture_info[1], int(bottle_image.width * 0.58), int(bottle_image.height * 0.58)) overlay_image = self._combine_images(bottle_image, overlay_image, (1, 3)) overlay_image = self._combine_images(overlay_image, bottle_image) self._save_image(self.config.BOTTLED_CATERPILLAR_TEXTURE_PATH / ('bottled_caterpillar_' + os.path.basename(texture_info[0])[12:]), overlay_image)
This changes the textures for bottle and scroll items, but I think they’ve come out looking really well. You can see a lot more detail in them, and have more of an idea of which entity they relate to:


This reduces the number of textures for each species by 3, so now there are only 6 textures left.
Caterpillar Item
The caterpillar item texture is derived from the caterpillar entity texture. When I create them, I’m literally copying and pasting pixels from one image to the next. This is easy to do in Python’s Pillow library using the getpixel()
and putpixel()
methods.
So I created a pixel map to tell the script where to get a pixel from, and where to paste it, and used a simple loop to do the copy and paste:
def _generate_caterpillars(self) -> None: """ Generate caterpillar spawn egg textures. """ self.logger.info(f"Generating caterpillar spawn egg textures") caterpillar_entity_textures = [ f for f in self.config.CATERPILLAR_ENTITY_TEXTURE_PATH.iterdir() if f.suffix == ".png" and os.path.basename(f).startswith("caterpillar_") ] for texture in caterpillar_entity_textures: entity_image = self._load_image(texture) item_image = Image.new('RGBA', (16, 16), (0, 0, 0, 0)) pixel_map = [ # Head [(2, 8), (22, 23)], [(1, 9), (20, 24)], [(2, 9), (22, 24)], [(1, 10), (20, 25)], [(1, 11), (20, 26)], [(3, 9), (23, 24)], [(2, 10), (21, 26)], [(3, 10), (22, 26)], [(2, 11), (21, 27)], [(3, 11), (22, 27)], # Trimmed the rest of the pixel map for brevity ] for pixel in pixel_map: item_image.putpixel(pixel[0], entity_image.getpixel(pixel[1])) self._save_image(self.config.CATERPILLAR_ITEM_TEXTURE_PATH / os.path.basename(texture), item_image)
I generate this before I generate spawn eggs and bottled caterpillars, so that those textures will be based on these new ones, and they will look the same.
That leaves me with 5 textures to add, and I can still automate one more.
Scroll GUI
The scroll GUIs are created by copying and pasting parts of the butterfly base texture onto the new image. This is really just multiple image combines that can be done one after the other. To generate a scroll GUI image, I start with the scroll image as a base image, then I paste each part of the butterfly: the antennae, the body, then the wings. Finally, I paste the nail on top of the image.
I use Pillow’s transpose()
method here to flip the wings and the antenna so I can paste their opposites to complete the image. The full method takes full advantage of the “library” I talked about at the start of this article:
def _generate_butterfly_scroll_gui(self): """ Generate butterfly scroll gui textures. """ self.logger.info(f"Generating butterfly scroll GUI textures") scroll_image = self._load_image(self.config.SCROLL_TEXTURE_PATH) nail_image = self._load_image(self.config.NAIL_TEXTURE_PATH) butterfly_entity_textures = [ f for f in self.config.BUTTERFLY_ENTITY_TEXTURE_PATH.iterdir() if f.suffix == ".png" and os.path.basename(f).startswith("butterfly_") ] for texture in butterfly_entity_textures: # Base butterfly texture butterfly_image = self._load_image(texture) # Antennae antenna_image = self._crop_image(butterfly_image, (0, 0), (3, 2)) antenna_image = self._rotate_image(antenna_image, 90) antenna_image = self._resize_image( antenna_image, int(antenna_image.width * 5), int(antenna_image.height * 5), Image.Resampling.NEAREST ) overlay_image = self._combine_images(scroll_image, antenna_image, (-32, -80)) antenna_image = antenna_image.transpose(Image.FLIP_LEFT_RIGHT) overlay_image = self._combine_images(overlay_image, antenna_image, (-48, -80)) # Body body_image = self._crop_image(butterfly_image, (9, 22), (24, 24)) body_image = self._rotate_image(body_image, 90) body_image = self._resize_image( body_image, int(body_image.width * 5), int(body_image.height * 5), Image.Resampling.NEAREST ) overlay_image = self._combine_images(overlay_image, body_image, (15, -59)) # Wings wing_image = self._crop_image(butterfly_image, (10, 0), (17, 10)) wing_image = self._rotate_image(wing_image, 90) wing_image = self._resize_image( wing_image, int(wing_image.width * 5), int(wing_image.height * 5), Image.Resampling.NEAREST ) overlay_image = self._combine_images(overlay_image, wing_image, (-68, -35)) wing_image = wing_image.transpose(Image.FLIP_LEFT_RIGHT) overlay_image = self._combine_images(overlay_image, wing_image, (-11, -35)) # Nail overlay_image = self._combine_images(overlay_image, nail_image) self._save_image(self.config.BUTTERFLY_SCROLL_GUI_TEXTURE_PATH / os.path.basename(texture)[10:], overlay_image)
When resizing these images, I had to change the resampling method. When scaling the images down I used LANCZOS
which, to keep a long story short, is basically a “blending” algorithm that blurs details together. This works when scaling down pixel art, because it allows the images to keep detail that would otherwise be lost.
This doesn’t work when scaling up pixel art, as it results in a blurry image and loses the “pixels” in the pixel art. To keep it pixelated, I use the NEAREST
sampling method, which just tries to get the color from the nearest pixel instead.
This whole process results in new textures that are barely distinguishable from the originals:


That leaves only four images that need to be added for each species.
Epilogue
There is no way I can automatically generate the four remaining images unless I want to use AI, but you know that’s something I would never do. These images are the base from which all others are derived.
Overall I’m really happy with the results from the ImageGenerator
. Other than just saving time, it actually has a couple of other benefits:
- The images, especially the item images, look a lot nicer. There’s more detail in the smaller images especially.
- Automatically generating images like this creates more uniformity. The script prevents any human error, and this uniformity means that the differences between each texture stand out more.
I haven’t made a release yet, because there is still one problem I need to solve. You may have noticed that I didn’t show any hummingbird moths. That’s because these textures don’t work with these new scripts, as the layout for their entity textures is different to others. So, they come out looking a bit of a mess:
Before I make a new release, I want to fix these textures and provide something that actually looks good to people who play the mod. So keep an eye out for the next release, which will have much better textures for all the items you can collect!