commit 2eca1a5fb5fa7eeb5494abb350cd535f67acfb8b Author: Andrew Brown Date: Sun Aug 22 10:16:10 2010 -0400 initial comit diff --git a/chunk.py b/chunk.py new file mode 100644 index 0000000..c398ada --- /dev/null +++ b/chunk.py @@ -0,0 +1,142 @@ +import numpy +from PIL import Image +from itertools import izip, count + +import nbt +import textures +from textures import texturemap as txtarray + +# General note about pasting transparent image objects onto an image with an +# alpha channel: +# If you use the image as its own mask, it will work fine only if the alpha +# channel is binary. If there's any translucent parts, then the alpha channel +# of the dest image will have its alpha channel modified. To prevent this: +# first use im.split() and take the third item which is the alpha channel and +# use that as the mask. Then take the image and use im.convert("RGB") to strip +# the image from its alpha channel, and use that as the source to paste() + +def get_lvldata(filename): + """Takes a filename and returns the Level struct, which contains all the + level info""" + return nbt.load(filename)[1]['Level'] + +def get_blockarray(level): + """Takes the level struct as returned from get_lvldata, and returns the + Block array, which just contains all the block ids""" + return numpy.frombuffer(level['Blocks'], dtype=numpy.uint8).reshape((16,16,128)) + +def get_blockarray_fromfile(filename): + """Same as get_blockarray except takes a filename and uses get_lvldata to + open it. This is a shortcut""" + level = get_lvldata(filename) + return get_blockarray(level) + +def get_skylight_array(level): + """Returns the skylight array. Remember this is 4 bits per block, so divide + the z component by 2 when accessing the array. and mask off the top or + bottom 4 bits if it's odd or even respectively + """ + return numpy.frombuffer(level['SkyLight'], dtype=numpy.uint8).reshape((16,16,64)) + +# This set holds blocks ids that can be seen through +transparent_blocks = set([0, 8, 9, 18, 20, 37, 38, 39, 40, 50, 51, 52, 53, 59, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 74, 75, 76, 77, 79, 83, 85]) + +def chunk_render(chunkfile, img=None, xoff=0, yoff=0, cave=False): + level = get_lvldata(chunkfile) + blocks = get_blockarray(level) + if cave: + skylight = get_skylight_array(level) + # Cave mode. Actually go through and 0 out all blocks that are not in a + # cave, so that it only renders caves. + + # 1st task: this array is 2 blocks per byte, expand it so we can just + # do a bitwise and on the arrays + skylight_expanded = numpy.empty((16,16,128), dtype=numpy.uint8) + # Even elements get the lower 4 bits + skylight_expanded[:,:,::2] = skylight & 0x0F + # Odd elements get the upper 4 bits + skylight_expanded[:,:,1::2] = skylight >> 4 + + # Places where the skylight is not 0 (there's some amount of skylight + # touching it) change it to something that won't get rendered, AND + # won't get counted as "transparent". + blocks = blocks.copy() + blocks[skylight_expanded != 0] = 21 + + # Don't render + + + # Each block is 24x24 + # The next block on the X axis adds 12px to x and subtracts 6px from y in the image + # The next block on the Y axis adds 12px to x and adds 6px to y in the image + # The next block up on the Z axis subtracts 12 from y axis in the image + + # Since there are 16x16x128 blocks in a chunk, the image will be 384x1728 + # (height is 128*24 high, plus the size of the horizontal plane: 16*12) + if not img: + img = Image.new("RGBA", (384, 1728)) + + for x in xrange(15,-1,-1): + for y in xrange(16): + imgx = xoff + x*12 + y*12 + imgy = yoff - x*6 + y*6 + 128*12 + 16*12//2 + for z in xrange(128): + try: + + blockid = blocks[x,y,z] + t = textures.blockmap[blockid] + if not t: + continue + + # Check if this block is occluded + if cave and ( + x == 0 and y != 15 and z != 127 + ): + # If it's on the x face, only render if there's a + # transparent block in the y+1 direction OR the z-1 + # direction + if ( + blocks[x,y+1,z] not in transparent_blocks and + blocks[x,y,z+1] not in transparent_blocks + ): + continue + elif cave and ( + y == 15 and x != 0 and z != 127 + ): + # If it's on the facing y face, only render if there's + # a transparent block in the x-1 direction OR the z-1 + # direction + if ( + blocks[x-1,y,z] not in transparent_blocks and + blocks[x,y,z+1] not in transparent_blocks + ): + continue + elif cave and ( + y == 15 and x == 0 + ): + # If it's on the facing edge, only render if what's + # above it is transparent + if ( + blocks[x,y,z+1] not in transparent_blocks + ): + continue + elif ( + # Normal block or not cave mode, check sides for + # transparentcy or render unconditionally if it's + # on a shown face + x != 0 and y != 15 and z != 127 and + blocks[x-1,y,z] not in transparent_blocks and + blocks[x,y+1,z] not in transparent_blocks and + blocks[x,y,z+1] not in transparent_blocks + ): + # Don't render if all sides aren't transparent and + # we're not on the edge + continue + + img.paste(t[0], (imgx, imgy), t[1]) + + finally: + # Do this no mater how the above block exits + imgy -= 12 + + return img diff --git a/nbt.py b/nbt.py new file mode 100644 index 0000000..d5ee6ed --- /dev/null +++ b/nbt.py @@ -0,0 +1,130 @@ +import gzip +import struct + +def load(fileobj): + if isinstance(fileobj, basestring): + # Is actually a filename + fileobj = open(fileobj, 'r') + return NBTFileReader(fileobj).read_all() + +class NBTFileReader(object): + def __init__(self, fileobj): + self._file = gzip.GzipFile(fileobj=fileobj, mode='r') + + # These private methods read the payload only of the following types + def _read_tag_end(self): + # Nothing to read + return 0 + + def _read_tag_byte(self): + byte = self._file.read(1) + return struct.unpack("b", byte)[0] + + def _read_tag_short(self): + bytes = self._file.read(2) + return struct.unpack(">h", bytes)[0] + + def _read_tag_int(self): + bytes = self._file.read(4) + return struct.unpack(">i", bytes)[0] + + def _read_tag_long(self): + bytes = self._file.read(8) + return struct.unpack(">q", bytes)[0] + + def _read_tag_float(self): + bytes = self._file.read(4) + return struct.unpack(">f", bytes)[0] + + def _read_tag_double(self): + bytes = self._file.read(8) + return struct.unpack(">d", bytes)[0] + + def _read_tag_byte_array(self): + length = self._read_tag_int() + bytes = self._file.read(length) + return bytes + + def _read_tag_string(self): + length = self._read_tag_short() + + # Read the string + string = self._file.read(length) + + # decode it and return + return string.decode("UTF-8") + + def _read_tag_list(self): + tagid = self._read_tag_byte() + length = self._read_tag_int() + + read_tagmap = { + 0: self._read_tag_end, + 1: self._read_tag_byte, + 2: self._read_tag_short, + 3: self._read_tag_int, + 4: self._read_tag_long, + 5: self._read_tag_float, + 6: self._read_tag_double, + 7: self._read_tag_byte_array, + 8: self._read_tag_string, + 9: self._read_tag_list, + 10:self._read_tag_compound, + } + + read_method = read_tagmap[tagid] + l = [] + for _ in xrange(length): + l.append(read_method()) + return l + + def _read_tag_compound(self): + # Build a dictionary of all the tag names mapping to their payloads + tags = {} + while True: + # Read a tag + tagtype = ord(self._file.read(1)) + + if tagtype == 0: + break + + name = self._read_tag_string() + read_tagmap = { + 0: self._read_tag_end, + 1: self._read_tag_byte, + 2: self._read_tag_short, + 3: self._read_tag_int, + 4: self._read_tag_long, + 5: self._read_tag_float, + 6: self._read_tag_double, + 7: self._read_tag_byte_array, + 8: self._read_tag_string, + 9: self._read_tag_list, + 10:self._read_tag_compound, + } + payload = read_tagmap[tagtype]() + + tags[name] = payload + + return tags + + + + def read_all(self): + """Reads the entire file and returns (name, payload) + name is the name of the root tag, and payload is a dictionary mapping + names to their payloads + + """ + # Read tag type + tagtype = ord(self._file.read(1)) + if tagtype != 10: + raise Exception("Expected a tag compound") + + # Read the tag name + name = self._read_tag_string() + + payload = self._read_tag_compound() + + return name, payload + diff --git a/textures.py b/textures.py new file mode 100644 index 0000000..8fc65c9 --- /dev/null +++ b/textures.py @@ -0,0 +1,174 @@ +import os +import os.path +import zipfile +from cStringIO import StringIO +import math + +import numpy +from PIL import Image, ImageEnhance + +def _get_terrain_image(): + minecraftjar = zipfile.ZipFile(os.path.join(os.environ['HOME'], ".minecraft", "bin", "minecraft.jar")) + textures = minecraftjar.open("terrain.png") + buffer = StringIO(textures.read()) + return Image.open(buffer) + +def _split_terrain(terrain): + """Builds and returns a length 256 array of each 16x16 chunk of texture""" + textures = [] + for y in xrange(16): + for x in xrange(16): + left = x*16 + upper = y*16 + right = left+16 + lower = upper+16 + region = terrain.crop((left,upper,right,lower)) + textures.append(region) + + return textures + +# This maps terainids to 16x16 images +terrain_images = _split_terrain(_get_terrain_image()) + +def _transform_image(img): + """Takes a PIL image and rotates it left 45 degrees and shrinks the y axis + by a factor of 2. Returns the resulting image, which will be 24x12 pixels + + """ + + # Resize to 17x17, since the diagonal is approximately 24 pixels, a nice + # even number that can be split in half twice + img = img.resize((17, 17), Image.BILINEAR) + + # Build the Affine transformation matrix for this perspective + transform = numpy.matrix(numpy.identity(3)) + # Translate up and left, since rotations are about the origin + transform *= numpy.matrix([[1,0,8.5],[0,1,8.5],[0,0,1]]) + # Rotate 45 degrees + ratio = math.cos(math.pi/4) + #transform *= numpy.matrix("[0.707,-0.707,0;0.707,0.707,0;0,0,1]") + transform *= numpy.matrix([[ratio,-ratio,0],[ratio,ratio,0],[0,0,1]]) + # Translate back down and right + transform *= numpy.matrix([[1,0,-12],[0,1,-12],[0,0,1]]) + # scale the image down by a factor of 2 + transform *= numpy.matrix("[1,0,0;0,2,0;0,0,1]") + + transform = numpy.array(transform)[:2,:].ravel().tolist() + + newimg = img.transform((24,12), Image.AFFINE, transform) + return newimg + +def _transform_image_side(img): + """Takes an image and shears it for the left side of the cube (reflect for + the right side)""" + + # Size of the cube side before shear + img = img.resize((12,12)) + + # Apply shear + transform = numpy.matrix(numpy.identity(3)) + transform *= numpy.matrix("[1,0,0;-0.5,1,0;0,0,1]") + + transform = numpy.array(transform)[:2,:].ravel().tolist() + + newimg = img.transform((12,18), Image.AFFINE, transform) + return newimg + + +def _build_texturemap(): + """""" + t = terrain_images + + # Notes are for things I've left out or will probably have to make special + # exception for + top = [-1,1,0,2,16,4,15,17,205,205,237,237,18,19,32,33, + 34,21,52,48,49,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, # Cloths are left out + -1,-1,-1,64,64,13,12,29,28,23,22,6,6,7,8,35, # Gold/iron blocks? Doublestep? TNT from above? + 36,37,80,-1,65,4,25,101,98,24,43,-1,86,1,1,-1, # Torch from above? leaving out fire. Redstone wire? Crops left out. sign post + -1,-1,128,16,-1,-1,-1,-1,-1,51,51,-1,-1,1,66,67, # door,ladder left out. Minecart rail orientation + 66,69,72,-1,74 # clay? + ] + side = [-1,1,3,2,16,4,15,17,205,205,237,237,18,19,32,33, + 34,21,52,48,49,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,64,64,13,12,29,28,23,22,6,6,7,8,35, + 36,37,80,-1,65,4,25,101,98,24,43,-1,86,1,1,-1, + -1,-1,128,16,-1,-1,-1,-1,-1,51,51,-1,-1,1,66,67, + 66,69,72,-1,74 + ] + side[2] = 2 + + return ( + [(t[x] if x != -1 else None) for x in top], + [(_transform_image(t[x]) if x != -1 else None) for x in top], + [(_transform_image_side(t[x]) if x != -1 else None) for x in side], + ) +# texturemap maps block ids to a 16x16 image that goes on the top face +# perspective_texturemap does the same, except the texture is rotated and shrunk +# shear_texturemap maps block ids to the image that goes on the side of the +# block, sheared appropriately +texturemap, perspective_texturemap, shear_texturemap = _build_texturemap() + +def _render_sprite(img): + """Takes a 16x16 sprite image, and returns a 22x22 image to go in the + blockmap + This is for rendering things that are sticking out of the ground, like + flowers and such + torches are drawn the same way, but torches that attach to walls are + handled differently + """ + pass + +def _render_ground_image(img): + """Takes a 16x16 sprite image and skews it to look like it's on the ground. + This is for things like mine track and such + + """ + pass + +def _build_blockimages(): + """Returns a mapping from blockid to an image of that block in perspective + The values of the mapping are actually (image in RGB mode, alpha channel)""" + # This maps block id to the texture that goes on the side of the block + allimages = [] + for top, side in zip(perspective_texturemap, shear_texturemap): + if not top or not side: + allimages.append(None) + continue + img = Image.new("RGBA", (24,24)) + + otherside = side.transpose(Image.FLIP_LEFT_RIGHT) + + # Darken the sides slightly. These methods also affect the alpha layer, + # so save them first (we don't want to "darken" the alpha layer making + # the block transparent) + if 1: + sidealpha = side.split()[3] + side = ImageEnhance.Brightness(side).enhance(0.9) + side.putalpha(sidealpha) + othersidealpha = otherside.split()[3] + otherside = ImageEnhance.Brightness(otherside).enhance(0.8) + otherside.putalpha(othersidealpha) + + # Copy on the left side + img.paste(side, (0,6), side) + # Copy on the other side + img.paste(otherside, (12,6), otherside) + # Copy on the top piece (last so it's on top) + img.paste(top, (0,0), top) + + # Manually touch up 6 pixels that leave a gap because of how the + # shearing works out. This makes the blocks perfectly tessellate-able + for x,y in [(13,23), (17,21), (21,19)]: + # Copy a pixel to x,y from x-1,y + img.putpixel((x,y), img.getpixel((x-1,y))) + for x,y in [(3,4), (7,2), (11,0)]: + # Copy a pixel to x,y from x+1,y + img.putpixel((x,y), img.getpixel((x+1,y))) + + allimages.append((img.convert("RGB"), img.split()[3])) + return allimages + +# Maps block images to the appropriate texture on each side. This map is not +# appropriate for all block types +blockmap = _build_blockimages() + diff --git a/world.py b/world.py new file mode 100644 index 0000000..6934326 --- /dev/null +++ b/world.py @@ -0,0 +1,140 @@ +import functools +import string +import os +import os.path +import time + +from PIL import Image + +import chunk + +base36decode = functools.partial(int, base=36) + +def base36encode(number): + """String repr of a number in base 32""" + if number==0: return '0' + alphabet = string.digits + string.lowercase + + if number < 0: + number = -number + neg = True + else: + neg = False + base36 = '' + while number != 0: + number, i = divmod(number, 36) + base36 = alphabet[i] + base36 + + if neg: + return "-"+base36 + else: + return base36 + +def find_chunkfiles(worlddir): + """Returns a list of all the chunk file locations, and the file they + correspond to""" + all_chunks = [] + for dirpath, dirnames, filenames in os.walk(worlddir): + if not dirnames and filenames: + for f in filenames: + p = f.split(".") + all_chunks.append((base36decode(p[1]), base36decode(p[2]), + os.path.join(dirpath, f))) + return all_chunks + +def render_world(worlddir): + print "Scanning chunks..." + all_chunks = find_chunkfiles(worlddir) + + total = len(all_chunks) + print "Done! {0} chunks found".format(total) + if not total: + return + + # Create an image big enough for all chunks + # Each chunk is 352 pixels across. Each chunk is vertically 1584 pixels, + # but are spaced only 16*11=176 pixels apart. + + # Imagine a diagonal coordinate system to address the chunks where + # increasing x goes up-right and increasing z goes down-right. This needs + # to be embedded in a square. How big is this square? + + # Each column of chunks has a constant x+z sum of their coordinates, since + # going from a chunk to the one below it involves adding 1 to z and + # subtracting 1 from x. Therefore, the leftmost column is the one that + # minimizes x+z. The rightmost column maximizes x+z + + # This means the total width of the image is max sum - the min sum, times + # the horizontal spacing between each neighboring chunk. Since the rows are + # staggered, each row takes up half its actual width: 352/2 + + # Similarly, each row of chunks has a constant difference between their x + # and z coordinate, since going from from a chunk to the one to its right + # involves an addition of 1 to both x and z. + + # So the total height of the image must be the max diff - the min diff, + # times the vertical chunk spacing which is half of 16*11. Additionally, + # 1584-8*11 must be added to the height for the rest of the bottom layer of + # chunks. + + # Furthermore, the chunks with the minimum z-x are placed on the image at + # y=0 (in image coordinates, not chunk coordinates). The chunks with the + # minimum x+z are placed on the image at x=0. + + # Find the max and min sum and difference. Start out by finding the sum and + # diff of the first chunk + item = all_chunks[0] + minsum = maxsum = item[0] + item[1] + mindiff = maxdiff = item[1] - item[0] + + for c in all_chunks: + s = c[0] + c[1] + minsum = min(minsum, s) + maxsum = max(maxsum, s) + d = c[1] - c[0] + mindiff = min(mindiff, d) + maxdiff = max(maxdiff, d) + + width = (maxsum - minsum) * 384//2 + height = (maxdiff-mindiff) * 8*12 + (12*128-8*12) + + print "Final image will be {0}x{1}. (That's {2} bytes!)".format( + width, height, width*height*4) + + # Oh god create a giant ass image + worldimg = Image.new("RGBA", (width, height)) + + # Sort the chunks by their row, so when we loop through them it goes top to + # bottom + print "Sorting chunks..." + all_chunks.sort(key=lambda x: x[1]-x[0]) + + print "Processing chunks!" + processed = 0 + starttime = time.time() + for chunkx, chunky, chunkfile in all_chunks: + # Read in and render the chunk at world coordinates chunkx,chunky + # Where should this chunk go on the image? + column = chunkx + chunky - minsum + row = chunky - chunkx - mindiff + # col0 is at x=0. row0 is at y=0. + # Each col adds 384/2. Each row adds 16*12/2 + imgx = 192 * column + imgy = 96 * row + + print "Drawing chunk {0},{1} at pos {2},{3}".format( + chunkx, chunky, + imgx, imgy) + print "It's in column {0} row {1}".format(column, row) + + # Read it and render + chunk.chunk_render(chunkfile, worldimg, imgx, imgy, cave=True) + # chunk chunk chunk chunk + + processed += 1 + + print "{0}/{1} chunks rendered. Avg {2}s per chunk".format(processed, total, + (time.time()-starttime)/processed) + + print "All done!" + return worldimg