Blog

Creating a voxel rain effect with MagicaVoxel and VoxBox

MagicaVoxel is a powerful voxel editing and rendering program but creating large animations by hand is a lot of work. In my previous post I explained how Python and VoxBox could be used to generate water and add it into an existing MagicaVoxel scene. In this post I provide another example by using a similar process to create rain. The result can be seen below:

Implementation

The implementation of this effect is relatively straight-forward. Given a static MagicaVoxel scene we perform the following steps:

  1. Create a boolean ‘rain volume’ with most of the elements set to False, but a few set to True to represent the initial position of the rain drops.
  2. Optionally stretch the raindrops to give them an elongated appearance.
  3. For each output frame:
    1. Copy the input scene into the current output frame.
    2. For each column of the current output frame:
      1. Copy the corresponding column of the rain volume, starting at the top.
      2. Stop copying if the current output frame voxel is not empty (so the rain is blocked by objects in the scene).
    3. Roll the contents of the rain volume along the vertical axis to move the rain ready for the next frame.

Rain drop generation

Surprisingly, the most challenging aspect of this process proved to be the initial generation of the rain volume. The obvious approach was to start with an empty volume and then use the Python rand() function to set a certain number of the voxels. However, it was possible to see some structure in the resulting rain which made it too easy to notice that the animation was repeating after only a short number of frames.

To fix this I needed to constrain the initial random points to be spread evenly throughout the rain volume. This page contains some useful information on how to do this with Poission-Disc sampling, but although the ‘best-candidate algorithm‘ does not seem particular complicated it was none-the-less a distraction from the task at hand, and I didn’t find a built-in implementation in NumPy/SciPy.

Eventually I settled on performing thresholding on a 3D Blue Noise texture to generate the initial points. I’m not exactly clear on the mathematical link between Poisson-Disc sampling and Blue Noise, but I can see that the previously-mentioned ‘best-candidate algorithm’ has some conceptual similarities to the ‘void-and-cluster method‘, so I suspect a link does exist. At any rate, using a precomputed texture was much faster than trying to generate the values on-the-fly.

Result

Final touches included adding splashes where the rain hit the ground, and then the final render was set up with the same settings as in my previous post.

As before, the source code is available on GitHub. Feel free to play with it and let me know if you come up with anything cool!

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!