Game World 1: Making a custom animated globe

Welcome to the first entry in the Game World series. In this series I will guide you step by step in making your own gaming world from the top down, using Python + a bunch of creativity.

Now I will cover a lot of creative world-building aspects as part of this series, but before I get into that I wanted to start with a very simple technical mission:

As in my last Small Sims post, I will use repl.it and Pillow for now to get the graphics working. I also base my solution off an image projection from ActiveState, which shows how to create a static globe projection with working rotation in two directions (the yz rotation in the example is actually broken).

In this post I will explain you a way how to make this, and you can follow it in two ways:

1. If you want to follow it as an actual tutorial, then create a new repl.it (or use a different Python environment), and add code to your main.py file whenever you see blocks like:

#This is an example comment.

2. Or, you can simply read/skim/browse the post, and download a working repl.it example at the end, which you can then test and modify as you see fit :).

Libraries needed

Quite simply, we need a basic maths library, and the Image function from Pillow:

import math

from PIL import Image

To actually use PIL, you also need to set up Pillow, which you can do as follows:

1. Click on the Package Icon on the left side of your screen when you have you repl.it open:

2. And then select the Pillow 7.0.0 package, and press + (once you've pressed it, it will change to a -

You may have to wait a little while before the package is set up, but you only have to do this once :).

Rendering a single world frame

To render a single world frame, we define a new function called render_world(). As arguments it will take:

- The rotation in the xy-plane (0.0-1.0). This will turn the world, like the drum in a washing machine.

- The rotation in the xz-plane (0.0-1.0). This will turn the world, like a ball rolling away or towards you.

- The rotation in the yz-plane (0.0-1.0). This one we will use to 'spin' the world in an animation, like the world normally turns.

- The width of the animation in pixels.

- The height of the animation in pixels.

Then, as a start we define the function and load a default image:

def render_world(xyr, xzr, yzr, imgxOutput, imgyOutput):

# Load image and make output buffer

imageInput = Image.open("Equirectangular_projection_SW.gif")

(imgxInput, imgyInput) = imageInput.size

pixelsInput = imageInput.load()

imageOutput = Image.new("RGB", (imgxOutput, imgyOutput))

pixelsOutput = imageOutput.load()

Note that we use an image from Wikipedia called Equirectangular_projection_SW.gif. This image looks as follows:

Source: WikipediaUserStrebe, CC BY-SA 3.0,

Next up, we define some essential values for each of the three rotations,

pi2 = math.pi * 2

# 3D Sphere Rotation Angles (arbitrary)

xy = -pi2 * xyr

xz = -pi2 * xzr

yz = -pi2 * yzr

sxy = math.sin(xy); cxy = math.cos(xy)

sxz = math.sin(xz); cxz = math.cos(xz)

a range of center points and the radius,

# define a sphere behind the screen

xc = (imgxOutput - 1.0) / 2

yc = (imgyOutput - 1.0) / 2

zc = min((imgxOutput - 1.0), (imgyOutput - 1.0)) / 2

r = min((imgxOutput - 1.0), (imgyOutput - 1.0)) / 2

the camera view point:

# define camera view point

xo = (imgxOutput - 1.0) / 2

yo = (imgyOutput - 1.0) / 2

zo = -min((imgxOutput - 1.0), (imgyOutput - 1.0))

and the distances between the camera viewpoint and the center point:

xoc = xo - xc

yoc = yo - yc

zoc = zo - zc

doc2 = xoc * xoc + yoc * yoc + zoc * zoc

The next part of render_world() is the trickiest part of the function. Here we use the Equirectangular projection to map the flat image above to a sphere. Note that we are just creating one image of the sphere at a particular rotation, as we'll do the animation later on.

To start off, we make two loops, over x and y to generate each pixel in our output image:

for yi in range(imgyOutput):

for xi in range(imgxOutput):

First in the loop, we calculate distances between the camera viewpoint, and the area that corresponds to that specific pixel:

xio = xi - xo

yio = yi - yo

zio = 0.0 - zo

dio = math.sqrt(xio * xio + yio * yio + zio * zio)

, as well as ratios of the distance of each coordinate in relation to the total distance.

xl = xio / dio

yl = yio / dio

zl = zio / dio

Next, we will need a few more complicated functions to ensure we map the half-sphere facing us correctly to the image (and not trace pixels that are on the opposite side, for instance):

dot = xl * xoc + yl * yoc + zl * zoc

val = dot * dot - doc2 + r * r

if val >= 0: # if there is line-sphere intersection

if val == 0: # 1 intersection point

d = -dot

else: # 2 intersection points => choose the closest

d = min(-dot + math.sqrt(val), -dot - math.sqrt(val))

Once we have identified the area closest to us, we will render it using the equations for the equirectangular projection...

xd = xo + xl * d

yd = yo + yl * d

zd = zo + zl * d

x = (xd - xc) / r

y = (yd - yc) / r

z = (zd - zc) / r

...but as part of that, we need to incorporate the rotations in the xy plane and in the xz plane, as we indicated with xyr and xzr parameters at the start...

x0=x*cxy-y*sxy;y=x*sxy+y*cxy;x=x0 # xy-plane rotation

x0=x*cxz-z*sxz;z=x*sxz+z*cxz;x=x0 # xz-plane rotation

Now, there is a third rotation, namely in the yz plan (yzr), and that rotation was actually not working properly in the original script from ActiveState. To fix this, I replaced the old equation with a simpler approach, where I simply add the yz rotation as an offset to the longitude of the area that we are visualizing:

lng = (math.atan2(y, x) + pi2 + yz) % pi2

To do the final rendering, we also, need the lattitude of course,

lat = math.acos(z)

and we actually write the pixels of our destination image, and return our result at the very end of the function:

ix = int((imgxInput - 1) * lng / pi2 + 0.5)

iy = int((imgyInput - 1) * lat / math.pi + 0.5)

try:

pixelsOutput[xi, yi] = pixelsInput[ix, iy]

except:

pass

return imageOutput

If you put all this together, and you'd test it out, e.g. using imageOutput = render_world(0.25,0.25,0.0,384,384)

imageOutput.save(“World.png”, “PNG”))

you would get an image like this:

From single image to spinning animation

To make the animation of the world spinning, we generate all the images in a loop, and append them to an array. This is similar to what we did before in the last Small Sims post. We start off by defining the width and height in pixels (384 for now), and the number of frames (10). I didn't choose these values because they look cool, but rather because repl.it is quite slow, and can time out for much higher values. If you use your own Python installation, you can easily crank up the resolution:

if __name__ == "__main__":

xsize = 384

ysize = 384

steps = 10

Next up, we generate our first frame (which appears twice in the animation to indicate a clear starting point),

b = [render_world(0.25,0.25,0.0,xsize,ysize)]

, and then we generate all the other frames, increasing the yz rotation at every step:

for i in range(0, steps):

b.append(render_world(0.25, 0.25, (0.0+float(i)/float(steps))%1.0,xsize,ysize))

With all the frames generated, all that we need to do is to write the whole thing to an animated gif.

b[0].save("test.gif", save_all=True, append_images=b[1:], duration=100, loop=0)

And after you've coded all that, you can run the code and generate this:

Wrap-up

And that's it! Now as a first step of creating your own game world, you can now make your own map, and use that image to generate a spinning world. It will still work, even if the dimensions aren't quite right.

Now there are still a few things I want to share before I end this post:

Credits

The header image is a projected version of the aforementioned image by WikipediaUserStrebe, CC BY-SA 3.0.

Appendix: full source code and Replit

As always, you can find the full source code here, as well as the repl.it.

# Animated World Projection

# Image from: https://en.wikipedia.org/wiki/Equirectangular_projection

#(By Strebe - Own work, CC BY-SA 3.0, https://commons.wikimedia.org/w/index.php?curid=16115228)

# This is a modified version of the recipe found at:

# http://code.activestate.com/recipes/580695-image-projection-onto-sphere/

import math

from PIL import Image

def render_world(xyr, xzr, yzr, imgxOutput, imgyOutput):

pi2 = math.pi * 2

# 3D Sphere Rotation Angles (arbitrary)

xy = -pi2 * xyr

xz = -pi2 * xzr

yz = -pi2 * yzr

sxy = math.sin(xy); cxy = math.cos(xy)

sxz = math.sin(xz); cxz = math.cos(xz)

#syz = math.sin(yz); cyz = math.cos(yz)

imageInput = Image.open("Equirectangular_projection_SW.png")

(imgxInput, imgyInput) = imageInput.size

pixelsInput = imageInput.load()

imageOutput = Image.new("RGB", (imgxOutput, imgyOutput))

pixelsOutput = imageOutput.load()

# define a sphere behind the screen

xc = (imgxOutput - 1.0) / 2

yc = (imgyOutput - 1.0) / 2

zc = min((imgxOutput - 1.0), (imgyOutput - 1.0)) / 2

r = min((imgxOutput - 1.0), (imgyOutput - 1.0)) / 2

# define camera view point

xo = (imgxOutput - 1.0) / 2

yo = (imgyOutput - 1.0) / 2

zo = -min((imgxOutput - 1.0), (imgyOutput - 1.0))

xoc = xo - xc

yoc = yo - yc

zoc = zo - zc

doc2 = xoc * xoc + yoc * yoc + zoc * zoc

for yi in range(imgyOutput):

for xi in range(imgxOutput):

xio = xi - xo

yio = yi - yo

zio = 0.0 - zo

dio = math.sqrt(xio * xio + yio * yio + zio * zio)

xl = xio / dio

yl = yio / dio

zl = zio / dio

dot = xl * xoc + yl * yoc + zl * zoc

val = dot * dot - doc2 + r * r

if val >= 0: # if there is line-sphere intersection

if val == 0: # 1 intersection point

d = -dot

else: # 2 intersection points => choose the closest

d = min(-dot + math.sqrt(val), -dot - math.sqrt(val))

xd = xo + xl * d

yd = yo + yl * d

zd = zo + zl * d

x = (xd - xc) / r

y = (yd - yc) / r

z = (zd - zc) / r

x0=x*cxy-y*sxy;y=x*sxy+y*cxy;x=x0 # xy-plane rotation

x0=x*cxz-z*sxz;z=x*sxz+z*cxz;x=x0 # xz-plane rotation

lng = (math.atan2(y, x) + pi2 + yz) % pi2

lat = math.acos(z)

ix = int((imgxInput - 1) * lng / pi2 + 0.5)

iy = int((imgyInput - 1) * lat / math.pi + 0.5)

try:

pixelsOutput[xi, yi] = pixelsInput[ix, iy]

except:

pass

return imageOutput

if __name__ == "__main__":

imageOutput = render_world(0.25,0.25,0.0,384,384)

imageOutput.save("World.png", "PNG")

xsize = 384

ysize = 384

steps = 10

b = [render_world(0.25,0.25,0.0,xsize,ysize)]

for i in range(0, steps):

b.append(render_world(0.25, 0.25, (0.0+float(i)/float(steps))%1.0,xsize,ysize))

b[0].save("test.gif", save_all=True, append_images=b[1:], duration=100, loop=0)

You can find a Repl here.

Continue reading with a Coil membership.