Texture Variety & Connections

by Taylor Hadden | 23:51

Pixels Please

I’m quite enamored with pixel art on voxels. The odd marriage of the 2D and 3D concepts appeals to me – a side effect of a prior Minecraft addiction, perhaps.  The addiction evolves, and variation is one of the first fruits off of the vine.

Texture Variation

The previous texturing system supported different textures for the different faces of the texture, though this wasn’t taken advantage of in the textures used in Alpha 6. This functionality has been expanded, and a material can now have any number of variants for any of its sides.

unity_personal_64bit_-_game-unity_-_unity_projec_2016-11-16_18-19-11

These two tiles have the same number of variations. Variations are weighted, so their frequency can be freely adjusted.

All textures are packed into a collection of atlasses, so each texture is actually just a UV coordinate. These UV coordinate lists are grouped into a lookup table of materials and grouped further by a lookup table of block faces.

class BlockSet {
    Dictionary<ushort, MaterialDefinition> materialLookup;
}

class MaterialDefinition {
    ushort materialID;
    Dictionary<Sides, TextureLookupCluster> textureLookup;
}

struct TextureLookupCluster {
    readonly float[] weights;
    readonly Vector2[] uvs;
}

The texture lookup cluster is built from a list of the texture maps that apply to that particular face:

class TextureMap {
    readonly Sides flags;
    readonly Vector2 uv;
    readonly float weight;
}

struct TextureLookupCluster {
    public TextureLookupCluster(List<TextureMap> textures) {
        uvs = new Vector2[textures.Count];

        for (int i = 0; i < uvs.Length; i++) {
            uvs[i] = textures[i].uv;
        }
        if (uvs.Length > 1) {
            weights = new float[uvs.Length];

            float maxWeight = 0;
            for (int i = 0; i < uvs.Length; i++) {
                maxWeight += textures[i].weight;
            }

            // This makes the weights ascend while keeping relative value
            // vs a random input of 0..1
            weights[0] = textures[0].weight / maxWeight;
            for (int i = 1; i < uvs.Length; i++) {
                weights[i] = weights[i - 1] + textures[i].weight / maxWeight;
            }
        }
        else {
            weights = null;
        }
    }
}

During meshing, this bank of uv coordinates is queried like this:

class BlockSet {
    // The primary entry point
    public Vector2 MaterialUV(Voxel voxel, Sides face, float random) {
        MaterialDefinition map;
        if (materialMap.TryGetValue(voxel.MaterialID, out map)) {
            return map.MapForSide(face).UV(random);
        }
        return Vector2.zero;
    }
}

class MaterialDefinition {
    public TextureLookupCluster MapForSide(Sides face) {
        return textureLookup[face];
    }
}

struct TextureLookupCluster {
    public Vector2 UV(float random) {
         if (uvs.Length == 1) {
             return uvs[0];
         }
         else {
              for (int i = 0; i < uvs.Length; i++) {
                  if (weights[i] >= random) {
                      return uvs[i];
                  }
              }
              return uvs[0];
         }
    }
}

Variations are selected procedurally and will be consistent between individual meshing operations. This is achieved by creating a Random object seeded by a chunk’s id and always querying that Random instance in the same order every time.

// Metaphors are fun
class ChunkViewLoaf {
    public void BakeMesh() {
        /*
         Setup Happens
         */
        System.Random rng = new SystemRandom(chunk.ID.GetHashCode());
    
        for (int i = 0; i < chunk.Length; i++) {
             random = (float)rng.NextDouble();
         
             /*
              The rest of the meshing algorithm
              */
         }
         // Since spacial organization of a chunk's voxel array is consistent,
         // and the Random instance will always start with the same seed, this
         // setup will always return the same "random" number for a voxel in a
         // specific position.
    }
}

Texture Connections

Now we cook with butter. Connecting textures to one another allows for the illusion of voxels being parts of larger objects. The simplest form of this is blocks of stone — or jade in this case.

unity_personal_64bit_-_game-unity_-_unity_projec_2016-11-16_18-36-24

The illusion is quite effective. Materials designed to use connections use 6 of the 16 data bits in the voxel to store whether or not a voxel is connected on each of its 6 faces. When meshing a face of the voxel, the data from the perpendicular faces are reworked to 4 bits for the faces 4 sides. Finally, there are 16 possible combinations of those four bits, and at least one texture is needed in the lookup table for each of those possibilities.

unity_personal_64bit_-_game-unity_-_unity_projec_2016-11-16_18-41-24

The implementation of this feature is actually quite simple: just another layer in the material lookup tree. The complex part was updating all of the various tools in order to author textures with the connection flags and edit the flags of relevant voxels as they are edited. A number that should jump out at you is that I need to make and maintain 16 textures for every connected material. The thought of having to manage 16 separate textures for this feature – let alone any variations – led me to build a node-based texture editor that I will show off in a future post.

I’m looking forward to digging into the potential for expression these two features provide.