2011-06-17

Minecraft mapping - Applying Minecraft textures

Last time we loaded a Minecraft file and rendered a big grid of numbers indicating the block ID of cells in the map. Those block IDs don't directly tell us which item in the texture atlas to use. Instead, we create a great big mapping table that says for each block ID which texture from the texture atlas to use. That's still not going to be perfect - for wool blocks we also need to consider the data value to pick the colour. For minecart tracks the data value will tell us the orientation of the tracks, but then we only have two textures - one straight and one corner! In the future we'll see how to write a pixel shader that flips and rotates them appropriately. Other blocks like signs and torches and doors don't have any appropriate texture for rendering a top-down view, so we'll need to create them. Most evil of all is redstone wire - for one thing, the orientation of the wire isn't encoded in the saved game at all – we need to calculate it by looking at surrounding tiles. In addition, Minecraft renders redstone wire using a small number of textures and some rather complicated operations to clip and colour them. I haven't yet tackled that problem, and I might just avoid it.

Today all we do is use the mapping table to look up the position in the texture atlas (which I'm calling the "texture code") for blocks from the same slice of the map as last time. I'm not going to discuss that in detail because it's not terribly exciting. Pull down the code from github, and copy your terrain.png file into the same directory before you run texture_demo.py. (I don't think I can legally include terrain.png in the repo unless I draw it all myself.) You'll see something like this:

Here you can see pink squares in areas of empty space, tree trunks, leaves in dark grey, grass in light grey, dirt in brown and stone in medium grey. The reason that the leaves and grass are grey is because the texture in terrain.png is greyscale and Minecraft colours it at run-time based on the biome. We might do that eventually, but it's probably easier just to use a custom terrain.png with green leaves and grass.

One thing that I would like to discuss is some of the stuff we're doing in numpy that's not really important just now, but will be much more important when we render more than just a flat horizontal slice of the world.

def get_cells_using_heightmap(source, heightmap):
    '''
    Given a 3D array, and a 2D heightmap, select cells from the
    3D array using indices from the 2D heightmap.
    '''
    idx = [numpy.arange(dimension) for dimension in source.shape]
    idx = list(numpy.ix_(*idx))
    idx[2] = numpy.expand_dims(heightmap, 2)
    return numpy.squeeze(source[idx])

We use this function to flatten the 3D array of blocks into a 2D array. Note that I will be referring to the dimensions of the 3D array as X, Z and Y, in that order, to match how Minecraft lays them out. The funcion is pretty hideous, but I couldn't find anywhere describing an easier way to do this in numpy. I think I found it here. Looking at it again I really don't like that it reuses the name idx to mean rather different things. Basically, it works like this:

  • Supposing the input array "source" is 512×512×128, we first construct three arrays, [0, 1, 2 ... 510, 511], [0, 1, 2 ... 510, 511] and [0, 1, 2 ... 126, 127].
  • We then feed them into numpy.ix_, which converts them into 3D arrays. The first has dimensions 512×1×1, the second 1×512×1 and the last 1×1×128, and each has the sequence of values that we previously fed in.
  • We throw away the third one and replace it with our height-field. If our height-field has less than two dimensions, we bump it up to two dimensions. (This lets us pass in a single value if we want a uniform height-field.)
  • The horrifying magic occurs when we use this bizarre collection of three arrays as the indices into source. First of all, numpy applies its broadcasting rules to expand all of index arrays to have matching dimension. The result of this is that all three of our indexing arrays end up with dimensions 512×512×1.
  • The dimensions of the indexing arrays determine the dimensions of the result of the indexing operation. Each element of the output array is determined by looking up the source array with the coordinates from the indexing arrays at the corresponding position. The X and Z indexing arrays basically just pass through the X and Z coordinates unmodified. The Y indexing array is our height field.
  • Finally, we use numpy.squeeze to discard the last dimension.

Now that I've spent the time to think about it and explain it, I think it could be made clearer. I think the use of ix_ followed by broadcasting is quite hard to follow. I think we could avoid the extra dimension and the squeeze too. I don't think there's any getting around the fancy indexing.

That's quite enough for today. I'm now undecided what I'll discuss next week. I'm really not sure who's reading and what sort of interests they have. Are you beginners or experts? Do you want tutorials and stuff that you can easily try at home, or would you prefer a faster paced tour of the fancy bits? Is it important to build things up a step at a time or would you prefer I skip over things to get to the good stuff? How painful is this numpy stuff? I could spend some time drawing up diagrams to better explain it, but that would probably mean it will be quite a while before we get to the interesting shader stuff. Let me know what you think in the comments.

No comments:

Post a Comment