Understanding the Voxel UV Bug

Creating a voxel game in JMonkeyEngine (JME) is an exciting challenge. However, when you transition from simple single-block textures to a unified texture atlas spanning entire chunks, you often run into a frustrating visual bug: ugly seams, flickering lines, and distorted textures at the edges and corners of chunks farthest from the origin (-x, -z).

This issue is caused by two distinct problems working in tandem:

  1. Floating-Point Precision Loss: As chunks get farther from the world origin, absolute world coordinates grow larger, causing float-based operations like FastMath.floor() to lose precision and jitter.
  2. Texture Atlas Mipmap/Bilinear Bleeding: When you manually wrap UV coordinates (e.g., uv - floor(uv)), the GPU's texture sampler gets confused at the boundaries. The sudden jump from 0.99 to 0.0 causes a massive derivative spike, triggering the lowest mipmap level and bleeding adjacent atlas tiles into your mesh.

Let's look at the best ways to fix this bug, ranging from math corrections to modern engine features.

---

Solution 1: Use Chunk-Relative Coordinates to Avoid Precision Loss

Currently, you are calculating UVs using absolute world coordinates:

float worldX = vertex.x + ax;
float worldZ = vertex.z + az;

As ax and az grow into the thousands, FastMath.floor(uv.x) loses the precision needed to keep textures perfectly aligned. To fix this, compute your UV coordinates using local chunk coordinates (which always stay between 0 and CHUNK_SIZE), and map them relative to the chunk's boundaries.

Modify your vertex loop to use the local coordinates of the vertices within the chunk mesh:

float scale = 1f / WorldChunk.CHUNK_SIZE;

for (Vector3f vertex : face.vertices()) {
    // Use local chunk coordinates (vertex.x + x) instead of absolute world coordinates (worldX)
    float localX = vertex.x + x;
    float localZ = vertex.z + z;

    float u = localX * scale;
    float v = localZ * scale;

    texCoords.add(manageUV(new Vector2f(u, v), index));
}
---

Solution 2: Implement Half-Pixel Insets (Fixes Bilinear Bleeding)

When using a texture atlas, bilinear filtering samples neighboring pixels. At the very edge of an atlas tile (at 0.0 or 1.0), the GPU will sample pixels from the adjacent tile. To prevent this, you need to slightly shrink (inset) the UV boundaries by a fraction of a pixel.

Update your manageUV method to include a half-pixel offset correction:

public static Vector2f manageUV(Vector2f uv, int index) {
    int tileX = index % ATLAS_TILES_WIDE;
    int tileY = index / ATLAS_TILES_WIDE;
    
    float tileSizeX = 1f / ATLAS_TILES_WIDE;
    float tileSizeY = 1f / ATLAS_TILES_HIGH;
    
    // Keep local coordinates strictly within [0, 1]
    float localU = uv.x - FastMath.floor(uv.x);
    float localV = uv.y - FastMath.floor(uv.y);
    
    // Half-pixel correction to prevent border bleeding
    float texelSizeX = 1.0f / (ATLAS_TILES_WIDE * 256); // Assuming 256px wide tiles
    float texelSizeY = 1.0f / (ATLAS_TILES_HIGH * 256);
    
    localU = FastMath.clamp(localU, texelSizeX, 1.0f - texelSizeX);
    localV = FastMath.clamp(localV, texelSizeY, 1.0f - texelSizeY);
    
    float u = (tileX + localU) * tileSizeX;
    float v = (tileY + localV) * tileSizeY;
    
    return new Vector2f(u, 1.0f - v);
}
---

Solution 3: The Modern & Robust Way: Use Texture Arrays

If you want a flawless solution without complex math hacks or sacrificing mipmaps, you should replace your 2D Texture Atlas with a Texture Array (Texture2DArray).

Texture arrays treat each tile as a separate layer in a single texture object. This allows you to use the GPU's native WrapMode.Repeat on individual layers without any bleeding or mipmap derivative issues.

1. Load the Texture Array in JME

List<Image> images = new ArrayList<>();
// Load your individual block textures into the list
images.add(assetManager.loadTexture("Textures/grass.png").getImage());
images.add(assetManager.loadTexture("Textures/stone.png").getImage());

Texture2DArray texArray = new Texture2DArray(images);
texArray.setWrap(Texture.WrapMode.Repeat);
chunkMat.setTexture("DiffuseMap", texArray);

2. Update Your Shader and Mesh

To use a texture array, your vertices need a 3D texture coordinate (u, v, layer) instead of a 2D coordinate. JMonkeyEngine's default lighting materials can be extended with a custom shader to support sampler2DArray, allowing you to pass the texture index directly as the third coordinate (w) without manual atlas math!