Godot引擎下体素游戏(类Minecraft)动态生成最小2的幂次尺寸纹理图集:不同尺寸纹理密集打包及UV坐标获取方案
Great question—this is a common pain point for moddable voxel games where pre-built atlases can't keep up with custom content. Let's walk through how to implement a tightly packed, power-of-two square texture atlas with UV mapping in Godot, step by step.
1. Pick a Dense Packing Algorithm
To squeeze variable-sized textures into the smallest possible space, the Skyline Algorithm is ideal. It prioritizes placing each texture in the tightest available spot and works well for dynamic, unsorted texture sets. Here's the core idea:
- Maintain a "skyline" array that tracks the height of every column in the atlas so far.
- Sort textures from largest to smallest (by area) first—this minimizes wasted space by placing big textures before smaller ones fill gaps.
- For each texture, scan the skyline to find the leftmost position where it fits without overlapping. Update the skyline to reflect the new height after placement.
2. Implement Packing Logic in Godot
First, define a helper class to track each texture's packed position and UV data:
class_name PackedTextureData var texture: Texture2D var x: int = 0 var y: int = 0 var uv_rect: Rect2 = Rect2.ZERO # Precomputed UV coordinates
Then, write the skyline packing function:
func pack_textures(textures: Array[Texture2D]) -> Dictionary: # Sort textures from largest to smallest to optimize packing density var sorted_textures = textures.duplicate() sorted_textures.sort_custom(func(a, b): return a.get_width() * a.get_height() > b.get_width() * b.get_height() ) var skyline: Array[int] = [] var max_width: int = 0 var max_height: int = 0 var packed_data: Array[PackedTextureData] = [] for tex in sorted_textures: var tex_w = tex.get_width() var tex_h = tex.get_height() var best_x = -1 var best_y = -1 var best_height_increase = INF # Find the optimal position for the current texture for x in range(0, skyline.size() - tex_w + 1): var current_max_y = 0 for i in range(x, x + tex_w): current_max_y = max(current_max_y, skyline[i]) var height_increase = current_max_y + tex_h - max_height if height_increase < best_height_increase: best_x = x best_y = current_max_y best_height_increase = height_increase # Expand the skyline if no existing spot fits if best_x == -1: best_x = skyline.size() best_y = max_height # Extend skyline to accommodate the new texture's width for i in range(skyline.size(), skyline.size() + tex_w): skyline.append(max_height) # Update skyline and track maximum atlas dimensions for i in range(best_x, best_x + tex_w): skyline[i] = best_y + tex_h max_width = max(max_width, best_x + tex_w) max_height = max(max_height, best_y + tex_h) # Store packed position data var data = PackedTextureData.new() data.texture = tex data.x = best_x data.y = best_y packed_data.append(data) return { "packed_data": packed_data, "required_width": max_width, "required_height": max_height }
3. Calculate the Smallest Power-of-Two Square Size
Once you have the minimum required width and height for all textures, compute the smallest power-of-two value that's larger than or equal to the maximum of these two dimensions (since the atlas must be square):
func get_min_power_of_two(value: int) -> int: if value <= 0: return 1 var pot = 1 while pot < value: pot *= 2 return pot # Usage example after packing: var packing_result = pack_textures(your_custom_textures_list) var atlas_size = get_min_power_of_two(max(packing_result.required_width, packing_result.required_height))
Matching your examples:
- 4x16x16 textures: Required dimensions are 32x32 → atlas size stays 32.
- 5x16x16 textures: Required dimensions are 32x48 → max is 48 → smallest power-of-two is 64.
4. Build the Atlas Texture
Now create the final atlas image and draw all packed textures onto it:
func build_atlas(packed_data: Array[PackedTextureData], atlas_size: int) -> Texture2D: # Create a blank RGBA8 image (adjust format if your textures use a different type) var atlas_image = Image.create(atlas_size, atlas_size, false, Image.FORMAT_RGBA8) atlas_image.fill(Color.TRANSPARENT) for data in packed_data: var tex_image = data.texture.get_image() # Draw the texture onto the atlas at its packed position atlas_image.blit_rect(tex_image, Rect2(0, 0, tex_image.get_width(), tex_image.get_height()), Rect2(data.x, data.y, tex_image.get_width(), tex_image.get_height())) # Precompute UV coordinates (Godot uses top-left (0,0) to bottom-right (1,1) for UVs) var uv_x = data.x / float(atlas_size) var uv_y = data.y / float(atlas_size) var uv_w = data.texture.get_width() / float(atlas_size) var uv_h = data.texture.get_height() / float(atlas_size) data.uv_rect = Rect2(uv_x, uv_y, uv_w, uv_h) # Convert the image to a usable texture var atlas_texture = ImageTexture.new() atlas_texture.set_image(atlas_image) return atlas_texture
5. Use UVs for Block Faces
When rendering voxel faces, you can directly use the uv_rect from each PackedTextureData to map the atlas texture. For example, in a mesh builder:
# For a quad face, define UVs using the precomputed rect var uv_coords = [ data.uv_rect.position, data.uv_rect.position + Vector2(data.uv_rect.size.x, 0), data.uv_rect.position + data.uv_rect.size, data.uv_rect.position + Vector2(0, data.uv_rect.size.y) ] # Assign these UVs to your mesh's vertex array
Key Optimizations & Notes
- Prevent Texture Bleeding: If you see edge artifacts from adjacent textures, add a 1-pixel padding around each texture when packing. Modify the packing logic to include padding, and copy edge pixels into the padding area to avoid transparency gaps.
- Asynchronous Processing: For large texture sets, run the packing and atlas building in a
Threadto avoid freezing the main game loop. - Format Consistency: Ensure all input textures use the same image format (e.g., RGBA8). Convert mismatched textures with
Image.convert()before packing.
内容的提问来源于stack exchange,提问作者RedSmarty

