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:
- Show how to make a spinning globe, using a world map.
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:
- If you have a cool world map and are able to make a rotating sphere like the one above, please send it to me (djgroennl@gmail.com). I'll happily highlight the best ones in a future blog post (if you permit me to do so), and if I get entries from 8 or more different people I'll award 5 XRP to the best entry as a tiny gesture of appreciation.
- As usual, I'll provide the source code and a repl.it.
- And for subscribers, I've got a slightly edited version of the Wikipedia world image without the white border, and a version of the script that uses numpy, and should be a tiny tiny bit faster.
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.