Blog

Animated voxel water with MagicaVoxel and VoxBox

Introduction

This post describes my recent attempt to generate animated voxel water from Python code (using the VoxBox library), render it with MagicaVoxel, and stitch together the resulting frames with FFmpeg. It serves as an introduction to VoxBox and can be used to add voxel water to your own scenes. The result can be seen below:

Background

I began work on VoxBox a few months ago because I was too busy to make proper progress with Cubiquity 2, and I needed a simpler side project which I could dip in and out of (Cubiquity 2 will return but I have a busy six months coming up). At the moment VoxBox provides the ability to read and write Python NumPy arrays as MagicaVoxel volumes, which means it can be used to procedurally generate voxel scenes.

The complete code for generating the scene above can be found on GitHub. The code does some additional tasks such as creating the walls of the container but in this post I will focus on generating and applying the animated water heightmap.

Creating the water heightmaps

Each water heightmap is a 2D greyscale image built from a number of layers. Each layer contains value noise filtered (blurred) by different amounts (see the figure below). The layer which is given the most filtering (layer 3) describes the overall shape of the water surface, while the layer which is given the least filtering (layer 0) describe the high-frequency surface detail.

Anyone familiar with procedural generation will know that value noise has some limitations compared to gradient-based approaches such as Perlin noise or Simplex noise. My reasons for using it here are firstly that it is simple, but more importantly it is easy to make the function wrap-around to become tilable, and this is desirable (at least in the time domain) to make the resulting animation loop seamlessly. Creating tilable Perlin nose is significantly more complex.

The code builds each layer in turn, but for each layer all frames are generated simultaneously. This is done by stacking the 2D heightmaps for each layer into a 3D array, which can then be filtered using the SciPy ‘gaussian_filter()‘ function with various values for ‘sigma‘ to control the amount of filtering. By setting the ‘mode‘ parameter to ‘wrap‘ we ensure that the resulting 3D array is tilable in all dimensions, including time (which is the one we care about).

Each layer is then normalised to contain values between zero and one, before being adjusted (weighted) so that each layer contributes twice as much as the preceeding layer to the final image. That is, the low-frequency layers end up dominating the overall shape of the heightmap with the high-frequency layers just adding detail. The final heightmaps are also normalised and the resulting 3D array then contains a set of 2D water heightmaps (one for each frame). In terms of code this all looks as follows:

# A 3D array, which can also be considered as a 1D array of 2D heightmap data.
# Each 2D slice will be the heightmap corresponding to a single frame.
# It starts off with zeros and the octaves get added in one at a time.
heightmaps = np.zeros((frame_count, row_count, col_count))

noise_layer_count = 4 # The number of layers of noise we will combine
for layer_index in range(0, noise_layer_count):
    
    # Each layer starts off as random noise.
    layers = np.random.rand(frame_count, row_count, col_count)
    
    # These sigma values control the amount of bluring in each axis. The 
    # pow(2.0, ...) part means that each layer has twice the sigma of the
    # previous one and so is twice as blurred. Note that we have less blurring 
    # in the inter-frame dimension - empirically this was found to look nicer.
    inter_pixel_sigma = math.pow(2.0, layer_index)
    inter_frame_sigma = math.pow(2.0, max(0, layer_index-1))
    layers = scipy.ndimage.filters.gaussian_filter(layers,
                                                  (inter_frame_sigma,
                                                   inter_pixel_sigma,
                                                   inter_pixel_sigma),
                                                  mode='wrap')    

    # Normalise values to lie between zero and one.
    layers = layers - layers.min()
    layers = layers / layers.max()
    
    # Scale the values so that layers which have been blurred less
    # (high-frequency details) contribute less to the overall heightmap.
    layers *= math.pow(2.0, layer_index)
    
    # Combine the layer
    heightmaps += layers
    
# Normalise the heightmaps to between zero and one.
heightmaps = heightmaps - heightmaps.min()
heightmaps = heightmaps / heightmaps.max()

# Bring the range of heights to 0.4 to 0.6. This means the troughs of the waves
# will end up at 40% of the volume height with peaks at 60% of the height.
heightmaps -= 0.5
heightmaps *= 0.2
heightmaps += 0.5

Building the scene

Each MagicaVoxel frame of animation is a full 3D volume which we need to generate from the corresponding 2D heightmap. We do this by iterating over each voxel, checking if it is below the height specified by the heightmap, and if so we set it to our ‘water’ material. Because we have a series of 2D heightmaps we end up generating a series of 3D volumes which are kept in a Python list.  This looks as follows:

# Materials in MagicaVoxel default palette.
empty_material_index = 0
light_blue_material_index = 151

# For each voxel in the volume
for frame in range(0, frame_count):
    
    print("Generating frame {} of {}...".format(frame + 1, frame_count))
    
    # Each frame is a 3D volume which starts off empty
    volume = np.zeros((plane_count, row_count, col_count), dtype=np.uint8)
    
    # Draw the walls and tower in the middle. This part isn't importand
    # for understanding how the water works. You could instead load 
    # in an existing MagicaVoxel scene and add water to that.
    voxbox.geometry.draw_box(volume, (0, 0, 0), (0, row_count-1, col_count-1), 246, 252)    
    voxbox.geometry.draw_box(volume, (0, 0, 0), (plane_count-25, 0, col_count-1), 246, 252)
    voxbox.geometry.draw_box(volume, (0, row_count-1, 0), (plane_count-25, row_count-1, col_count-1), 246, 252)    
    voxbox.geometry.draw_box(volume, (0, 0, 0), (plane_count-25, row_count - 1, 0), 246, 252)
    voxbox.geometry.draw_box(volume, (0, 0, col_count - 1), (plane_count-25, row_count - 1, col_count - 1), 246, 252)    
    voxbox.geometry.draw_box(volume, (0, 50, 50), (plane_count - 1, 76, 76), 246, 217)

    # Iterate over each voxel and check if it is below the level
    # defined by the heightmap. If so it represents water.
    for plane in range(0, plane_count):
        for row in range(0, row_count):
            for col in range(0, col_count):
                
                # Get the height from the heightmap, and
                # scale to the height of the volume
                height = heightmaps[frame][row][col]
                height *= plane_count
                
                # If the current voxel is below the
                # heightmap then set it to be solid.
                if plane <= height:
                    
                    # But only if it is currently empty
                    if volume[plane][row][col] == empty_material_index:
                        volume[plane][row][col] = light_blue_material_index

    # Add the new frame (volume) to the list
    frame_list.append(volume)

Note that we simply set the water voxels to light blue, rather than setting any kind of advanced reflective/refractive properties. This is because I haven’t yet added support to VoxBox for exporting such properties, though I believe the MagicaVoxel file format would allow me to do so.

Lastly, we save the result in MagicaVoxel format and use a VoxBox utility function to open the resulting ‘.vox’ file. As long as you have your file associations set up properly this should automatically launch MagicaVoxel on your platform. You can then scroll through the animation frames after clicking on the upside-down triangle under the ‘model’ tab.

Note: This currently only works with MagicaVoxel 0.98.2, as the latest version (0.99a) has temporarily removed support for animation.

Before rendering we need to set up the reflective and refractive properties for the water as we could not write these into the file earlier. Click the ‘Render’ tab at the top of the main window, then pick material 151 from the palette (that’s our water, and you can find the right material by clicking and dragging around the palette, and seeing the current index printed in the console at the bottom of the screen). Set the ‘Matter’ to ‘Glass’ and then increase the ‘Glass’ slider to get some transparency. You can also play around with the other sliders to get the look you like.

Creating the animation

As far as I am aware, MagicaVoxel does not provide an easy way to render out multiple frames. Therefore I did it manually by clicking on each frame in the timeline at the top of the application, waiting a minute for the image to render fully, and then pressing ‘F6’ to save the image to disk. This process was tedious but not unbearable with only 30 frames to render. It might be interesting to see if this process could be automated with AutoHotKey or PyAutoGUI, but I didn’t look into that yet.

Lastly we need to combine our sequence of PNG images into a video. There are various tools which can do this (some with a nice GUI) but my preferred option is FFmpeg because I often use it at work. Given our 30 frames named ’00.png’ up to ’29.png’ we can convert it to ‘water.mp4’  as follows:

ffmpeg -r 24 -f image2 -i %02d.png -vcodec libx264 -crf 25 -pix_fmt yuv420p water.mp4

You can find more information on the above command here.

That’s it! I hope this has given you a useful overview of VoxBox and what it can do. In principle it should be possible to adapt the code above to add animated water to your own voxel scenes, though I haven’t tried it myself. Let me know if you have any success or what else you come up with!