2011-06-03

Minecraft mapping – Rendering with PyOpenGL

Last time we made ourselves a simple texture using Pygame. Today we're going to render it as-is on the screen using PyOpenGL. Simple, right?
This will probably be a little steep if you don't know any OpenGL. However, fear not. Read An intro to modern OpenGL by Joe Groff. It's great, and it's how I learned to write GLSL shaders. Well, I say learned to write them – I've pretty much only written the one that you're going to see in these articles. The tutorial's in C, but I've translated the early parts into Python: Python OpenGL tutorial Lastly, don't worry if the 3D maths is scary – we won't be needing it since we're sticking solidly to 2D. (That said, I am quite scared of the maths in the ambient occlusion bit. But we'll worry about that when we come to it.)
In addition to Python and Pygame, you're going to need PyOpenGL and NumPy. I installed them on Ubuntu using:
sudo apt-get install python-opengl
sudo apt-get install python-numpy
(This should all work fine on Windows and Mac, but you'll need to download and install all these things separately.)
#!/usr/bin/env python

# Copyright 2011, Annette Wilson
# Licensed under the MIT license:
# http://www.opensource.org/licenses/MIT
#
# Minecraft mapping - Rendering something with OpenGL
#
# With great thanks to Joe Groff:
# http://duriansoftware.com/joe/An-intro-to-modern-OpenGL.-Chapter-1:-The-Graphics-Pipeline.html

from OpenGL.GL import *
import pygame, pygame.image, pygame.key
from pygame.locals import *
from opengl_tools import *

vertex_shader='''\
#version 130

uniform vec2 screen_dimensions;
uniform vec2 cam_position;
uniform float zoom;
uniform float texture_dimension;
uniform float map_dimension;

in vec4 position;
out vec2 texcoord;

void main()
{
    gl_Position.xy =
        (
            (position.xy / 2.0 + 0.5)
            * texture_dimension
            - cam_position
        )
        * 2.0
        * zoom
        / screen_dimensions;
    gl_Position.zw = vec2(0.0, 1.0);
    texcoord = position.xy * 0.5 + 0.5;
}
'''
Our square sits between (-1,-1,0) and (1,1,0) in world space. Screen space has (-1,-1) as the bottom-left of the screen and (1,1) as the top-right. If the texture is texture_dimension pixels across, and cam_position is a set of pixel coordinates in texture, this will position the square so that the given pixel is in the centre of the screen. We also use the original world space coordinates as the texture coordinates, but we multiply by 0.5 and add 0.5 because we want the texture coordinates to range from 0 to 1. So the bottom-left of our texture will have texture coordinates (0,0) and the top-right will have (1,1).
fragment_shader = '''\
#version 130

uniform sampler2D texture_atlas;
uniform usampler2D map_texture;

in vec2 texcoord;
out vec4 fragcolor;

void main()
{
    fragcolor = texture2D(texture_atlas, texcoord);
}
'''
The fragment shader is even more trivial. We just look up the texture atlas texture. Next time we will develop this to do something more interesting.
class Resources(object):
    pass

def make_resources():
    minecraft_map = pygame.Surface((512,512))
    atlas = pygame.image.load('numbered_texture_atlas.png')
    vertex_buffer_data = float_array(
        -1.0, -1.0, 0.0, 1.0,
         1.0, -1.0, 0.0, 1.0,
        -1.0,  1.0, 0.0, 1.0,
         1.0,  1.0, 0.0, 1.0)
    element_buffer_data = short_array(
        0,1,2,3)
    resources = Resources()
    resources.vertex_buffer = make_buffer(
        GL_ARRAY_BUFFER,
        vertex_buffer_data,
        vertex_buffer_data.nbytes)
    resources.element_buffer = make_buffer(
        GL_ELEMENT_ARRAY_BUFFER,
        element_buffer_data,
        element_buffer_data.nbytes)
    resources.map_texture = make_texture(
        image=minecraft_map, interpolate=False,
        alpha=True, integer=True)
    resources.texture_atlas = make_texture(
        image=atlas, interpolate=False, alpha=True)
    resources.program = assemble_shader_program(
        vertex_shader,
        fragment_shader,
        uniform_names=[
            'screen_dimensions',
            'cam_position',
            'zoom',
            'texture_dimension',
            'texture_atlas',
            'map_texture'],
        attribute_names=[
            'position'])
    return resources
This sets up all the resources we need:
  • map_texture is a placeholder for our map data. We'll make use of this next time.
  • texture_atlas is the texture atlas that we created last time.
  • vertex_buffer contains the four vertices of our square.
  • element_buffer is a list of indices into vertex_buffer_data, describing the order they should be connected up in a triangle strip.
  • program is our shader program, combining the vertex and fragment shaders.
This makes use of various helper functions we define in opengl_tools.py.
def render(resources, position, zoom, screen_dimensions):
    screen_w, screen_h = screen_dimensions
    glViewport(0,0,screen_w,screen_h)
    glClearColor(0.4, 0.4, 0.4, 1.0)
    glClear(GL_COLOR_BUFFER_BIT)

    glUseProgram(resources.program.program)
    uniforms = resources.program.uniforms
    glUniform2f(uniforms['screen_dimensions'], screen_w, screen_h)
    glUniform2f(uniforms['cam_position'], position[0], position[1])
    glUniform1f(uniforms['zoom'], zoom)
    glUniform1f(uniforms['texture_dimension'], 512.0) 

    glActiveTexture(GL_TEXTURE0)
    glBindTexture(GL_TEXTURE_2D, resources.map_texture)
    glUniform1i(resources.program.uniforms['map_texture'], 0)

    glActiveTexture(GL_TEXTURE1)
    glBindTexture(GL_TEXTURE_2D, resources.texture_atlas)
    glUniform1i(resources.program.uniforms['texture_atlas'], 1)

    glBindBuffer(GL_ARRAY_BUFFER, resources.vertex_buffer)
    glVertexAttribPointer(
        resources.program.attributes['position'],
        4, # size
        GL_FLOAT, # type
        GL_FALSE, # normalized?
        ctypes.sizeof(GLfloat)*4, # stride
        None # offset
        )
    position_attribute = resources.program.attributes['position']
    glEnableVertexAttribArray(position_attribute)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, resources.element_buffer)
    glDrawElements(
        GL_TRIANGLE_STRIP,
        4,
        GL_UNSIGNED_SHORT,
        None)
    glDisableVertexAttribArray(position_attribute)
    pygame.display.flip()
Given the simplicity of the shaders, rendering is a somewhat gruesome affair. We need to provide a value for each of the uniforms that we defined in the shaders, bind textures to slots, connect up and enable a vertex array on each attribute and finally draw our triangle strip.
def main(): 
    video_flags = OPENGL|DOUBLEBUF
    pygame.init()
    screen_dimensions = 800, 600
    surface = pygame.display.set_mode(
        screen_dimensions, video_flags)
    resources = make_resources()
    frames = 0
    done = 0
    zoom = 1.0
    position = [256.0, 256.0]
    dragging = False
    draglast = 0,0

    while not done:
        while 1:
            event = pygame.event.poll()
            if event.type == NOEVENT:
                break
            if event.type == KEYDOWN:
                pass
            if event.type == QUIT:
                done = 1
        render(resources, position, zoom, screen_dimensions)
        frames += 1

if __name__ == '__main__':
    main()
And this is the output. Not thrilling, I'll admit, but I wanted to get as much of the tedious stuff out of the way to avoid confusing things too much when we do more with the shaders. You can download the two files from github. Next time: tiled map rendering.







2011-05-31

Minecraft mapping – Diagnostic texture

(Part of a series on rendering a top-down Minecraft map.)

As we will soon see, terrain.png isn't great for diagnosing problems if something goes wrong. For one thing, it has lots of gaps, so if we accidentally get the wrong textures there's a good chance we'll just end up rendering blank space and not being able to figure out how our calculation is wrong. Also, many of the textures in it don't have an obvious orientation, so if we flip them or rotate them we might not notice. To avoid these problems, it's useful to make ourselves a diagnostic texture without these problems.

Rather than spend hours of tedious work in a paint package, we can do this programmatically with Pygame. On Ubuntu, Python 2.6 was already installed and I installed Pygame with:
sudo apt-get install python-pygame
We're making a 512×512 texture that's a 16×16 grid of 32×32 pixel squares. Each square has a number between 0 and 255 printed in the middle and a solid grey background with intensity equal to the value printed on it. In Pygame, you render text to a new surface and then blit that surface onto your target. Also note that we need to initialize Pygame before we do any font rendering. Other than that, there's not a lot to comment on here.
import pygame

def make_numbered_texture_atlas():
    surface = pygame.Surface((512,512))
    font = pygame.font.SysFont("arial",14,bold=True)
    for i in xrange(256):
        y = 15 - (i // 16)
        x = i % 16
        fg = (255,255,255) if i<=127 else (0,0,0)
        bg = (i,i,i)
        textimage = font.render(str(i), True, fg)
        w,h = textimage.get_size()
        surface.fill(bg, (32*x, 32*y, 32, 32))
        surface.blit(textimage, (32*x+16-w//2, 32*y+16-h//2))
    return surface

def main():
    pygame.init()
    pygame.image.save(
        make_numbered_texture_atlas(),
        "numbered_texture_atlas.png")

if __name__ == "__main__":
    main()
Next time we'll use PyOpenGL to render something on-screen.

2011-05-27

Minecraft mapping – 2D rendering strategy

This is the first in a series of articles about writing a Minecraft mapping tool in Python. I'm aiming for somewhere between a tutorial and a general discussion of the things that I think are cool or interesting. Today I'll describe how we're going to render the map.
To understand what we're doing, you'll probably want to be familiar with Minecraft. To recap briefly, playing Minecraft involves exploring and building in a world of axis-aligned 1m side cubic blocks. Each cube might be filled with one of various materials, such as earth, air, stone, water or sand. The world extends indefinitely in both horizontal directions and has a vertical extent of 128m, with mostly impenetrable rock blocking access to the lower boundary and merely empty space into which blocks may not be placed above the upper boundary.
In subsequent posts we'll talk more about exactly how we're going to take this 3D world and flatten it into a 2D map. For now, we're going to suppose that we have a 2D grid of square cells and in each cell we have the Minecraft block ID that describes the content of that cell and we want to render the corresponding Minecraft block texture in it. (We will also ignore the fact that some Minecraft blocks are partially transparent for now.)
There are various ways we could do this:
  1. Load the Minecraft textures into a Pygame Surface, loop through the map of block IDs and for each one blit the corresponding texture onto the corresponding position on-screen. Pretty easy, possibly quite slow, and not enough fun.
  2. Load the Minecraft textures into an OpenGL texture as a "texture atlas", loop through the map of block IDs and create a pair of triangles for each with appropriate texture coordinates to apply the right texture from the atlas. This is a bit more tricky, but once we've created the vertices and indices that make up the triangles, it should be pretty fast, because we can render everything frame after frame without needing to send any large amount of data to the graphics card. However, it's still not very fun.
  3. Load the Minecraft textures into one OpenGL texture as above, but load the block ID map into another texture. Render one big quad and write a pixel shader that first looks up the block ID map and then uses the value it finds there to look up the block texture atlas. This is still quite tricky, but it also lets us do some other interesting stuff, and it's fun. Since I have very little experience writing shaders, I thought this would be a great idea to try.

To get a bit more concrete, we're going to feed in two textures. One is a texture atlas containing a 16x16 grid of textures – so 256×256 pixels if each texture is 16x16 - which will be very similar to the terrain.png file from Minecraft. You can see mine on the right. (As I mentioned before, I'm using the awesome Painterly Pack. All the cool textures are from that. Only a few special top-down views in the bottom-right were drawn by me.) The other texture that we will feed in will be a specially encoded 32-bit RGBA format texture where the values in the red channel (between 0 and 255) specify one of the textures in the texture atlas. (Later on we'll use the other channels for various other things.) We'll number the texture atlas textures from 0 in the top left through to 15 in the top right and in rows down to 255 in the bottom right. The dimensions of this texture will depend on what size of map we want to render in one go. A Minecraft region is 512×512 and this is a convenient enough size for us, so we'll go with that for now. The next article will be some preparatory groundwork – creating a useful diagnostic texture which we can use in place of terrain.png to properly understand what's going on and more easily spot when we've made a mistake. After that, we'll move on to doing OpenGL using PyOpenGL.




2011-05-24

Minecraft mapping with Python, Pygame and OpenGL


When playing Minecraft, I love doing stuff underground: mining, spelunking and digging great big tunnels. I really wanted to map out the mines I have dug and the caverns I have explored. There are a lot of great Minecraft mapping tools, but not many of them that seem oriented to underground mapping. I also thought it would be fun to write my own, and I have a few ideas for distinct directions to develop it in when it gets far enough. This series of articles will cover all the code that I've written and will span a range of topics including Pygame, OpenGL, Numpy and the structure of Minecraft maps.
You can see some teaser images of the sort of things I have working right now over on the right. The textures are from the Painterly Pack, which I thoroughly recommend as a Minecraft texture pack.
Here's the rough plan for the series:
  1. 2D rendering strategy
  2. Pygame – Making a diagnostic texture
  3. PyOpenGL – Rendering with a pixel shader
  4. PyOpenGL – Tiled rendering with a pixel shader
  5. Pygame – Zooming and panning
  6. NBT – Reading Minecraft files
  7. Numpy – Flattening a Minecraft map
  8. Ambient occlusion part 1 and part 2
  9. Rotating and flipping to render mine-cart tracks
  10. Lighting (Never finished this one.)
The tentative schedule is going to be Tuesdays and Fridays. This Friday we'll start with an overview of the basic rendering strategy for our maps.