Skip to main content

Metaballs Revisited

The new visual identity of my work place (and the re-ignition of an old lava lamp) made me think of the good old metaballs from the Amiga demo-scene of yonder and how it has been a while since I have implemented them from scratch. This time I wanted to play around with Python and numpy to see what that could bring.

But first, what are metaballs? 

It's, for example, this:


Wikipedia defines them as:

In computer graphics, metaballs are organic-looking n-dimensional isosurfaces, characterised by their ability to meld together when in close proximity to create single, contiguous objects. https://en.wikipedia.org/wiki/Metaballs

As cool as multidimensional metaballs are, we'll stick to 2D ones in this post.

The algorithm

The algorithm behind them is quite straightforward -- in their simplest form. Basically for each pixel in each frame, or buffer, you add up the influence of each metaball in the simulation and then cut off below a certain threshold and normalize what’s left. The influence function can be a simple “ball-power” over euclidean distance calculation.

So, for each pixel you get:

def generate_buffer_raw(buff, balls):
    for x in range(0, buff.shape[0]):
        for y in range(0, buff.shape[1]):
            for b in balls:
                d = dist([x,y], b.pos)
                if d == 0: 
                    d = 1 # not correct, per se, but, alas, it works... 
                effect = b.rad / d
                buff[x,y] += effect
    return buff

And then you can apply the threshold, like so:

def apply_threshold(buff,threshold, margin=0):
    s1 = buff < (threshold - margin)
    s2 = buff > (threshold + margin)
    buff[:] = 255
    buff[s1] = 0
    if margin>0:
        buff[s2] = 0
    return buff

(With this threshold function we can also generate membrane style metaballs, but more on that later.)

First go: Pythagoras

My first go at this was just a brute force by using Pythagoras to calculate the euclidean distance.

def dist_sqrt(p1,p2):
    return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)

The main loop is then just:

images = []
for _ in range(0, FRAMES):
    buff = generate_buffer(np.zeros((WIDTH, HEIGHT)), balls)
    buff = apply_threshold(buff, THRESHOLD, MARGIN)
    im = img.fromarray(buff).convert("RGB")
    images.append(im)
    update_balls(balls)

Where update_balls is simply a updating the position of the metaballs given their velocity — bouncing on the “walls” of our simulation, but, importantly not other balls.

And lo and behold there were metaballs.


However, as expected, this was rather slow. 100 frames of 128 by 128 clocking in at 46.5s on my macbook air from 2013.

46.5 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

(For fun I ran the same code using Juno on my phone, and runtime went down to about 17s!)

As you might have guessed the costly computation is the distance function, so I went about experimenting.

Take two: numpy.linalg.norm

By naively replacing the distance function from with the equivalent linear algebra norm, we get:

def dist_linalg(p1,p2):
    return np.linalg.norm(p1 - p2)

This led to the same metaballs, but, alas, a higher runtime of 2min 47s!

Take three: precalculations

Next attempt to investigate precalculation of the computationally costly square root function needed in my first approach.

sqrt_precalc = {}

for i in np.arange(0, WIDTH ** 2 + HEIGHT ** 2):    
    sqrt_precalc[int(i)] = math.sqrt(i)

def dist_precalc(p1,p2):
    i = (p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2
    return sqrt_precalc[int(i)]

This did shave off some seconds, and runtime went down to 42s.

Yet another approach: moar precalc!

I left it at that, thinking that the future of this would be to look into cheaper distance approximations to cut away parts of the buffer where no metaball could show up, but in my sleep another solution appeared… I was thinking that one could go further with the precalculations and precalculate “fields” of influence for each ball, or “radius”, and use proper linear algebra to get rid of some for loops.

def precalculate_field(ball, dim):    
    buff = np.zeros(dim*2)
    ball = Ball([WIDTH, HEIGHT],ball.rad,np.array([0,0]))
    return generate_buffer_raw(buff, [ball])

fields = {}
for b in balls:
    if int(b.rad) not in fields.keys():
        fields[int(b.rad)] = precalculate_field(b, np.array([WIDTH, HEIGHT]))

As you can see, to accommodate all possible positions of the balls I generate a field of influence that is 4 times larger than the output buffer.

Then I updated the generate buffer function to look like this:

def generate_buffer_precalc(buff, balls):
    for b in balls:
        x1 = WIDTH - int(b.pos[0])
        y1 = HEIGHT - int(b.pos[1])
        field = fields[int(b.rad)]
        window = field[x1:x1+WIDTH,y1:y1+HEIGHT]
        buff += window
    return buff

The main trick here is to move the field in place so that it covers the buffer based on where the ball in question is -- a window into it, if you want. The only for loop now is over the balls themselves, and a simple linear algebra addition operation suffices to generate the resulting buffer of accumulated influence.

And lo and behold it was fast! A thousand fold increase over the best approach so far!

40 ms ± 687 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Admittedly it consumes more memory, but hey, memory is cheap, time is money. 

Some caveats:

  1. It is slightly less precise since it doesn't do decimal places. (Yet!) (You still get good-enough-looking metaballs, methinks.)
  2. The precalulculation of the "fields" are not taken into account when reporting the run-time, but that is surprisingly fast -- and only done once per "radius" of metaball...

More importantly, like this I can implement a real time version of this at some point using, let’s say pygame.

Ah, yes, I promised membranes, here’s a gif:

Comments

Popular posts from this blog

Fix your rapid blinking Marantz SR-6004 using nothing but 3 fingers - and a thumb

A couple of years ago my (most of the time excellent) Maranz SR6004 acted up. It did't want to turn itself on. Properly. Just stood there and blinked rapidly. Its little red light that is. At me. The solution was so simple that I didn't bother to write it down as I was sure to remember it. Alas, no. Some weeks ago it did it again. (Can it be the heat?) Just stood there blinking rapidly at me. The manual just said - as it said last time around - that it was time to return the unit to it's maker. Or similar. Some googling led me to this page:  http://www.allquests.com/question/4056803/Marantz-XXX4-Series-Failure-Issues.html  The technical term for what I had experienced seems to be "The Pop of Death". Aïe. But!, humongous letters said: YOU CAN SOMETIMES RESET THE UNIT BY PRESSING SURR MODE, CLEAR AND EXIT SIMULTANEOUSLY And so I did. And so it was fixed. And all was well. (And now I have written it down for the next time.)

Fix upside down Skype video in Ubuntu 12.10 [UPDATED]

When launching Skype in 64-bit Ubuntu 12.10 on my Asus U35J the webcam image was all topsy-turvy. Since I don't live in Australia, or something (tsk-tsk), this was not really cutting it for me.  Some quick googling led me to this forum post:  http://forums.pcpitstop.com/index.php?/topic/198236-why-is-my-skype-video-showing-upside-down/   After making sure that the necessary packages was installed (notably  libv4l-0) I adapted the command from the forum post to: LD_PRELOAD=/usr/lib/i386-linux-gnu/libv4l/v4l1compat.so skype and voila, the image was OK. Next step is for this to be set to default, which seems to be outlined here (in steps 2 and 3):  http://pc-freak.net/blog/how-to-fix-upside-down-inverted-web-camera-laptop-asus-k51ac-issue-on-ubuntu-linux-and-debian-gnu-linux/  (Actually this post seems to cover most of what is useful from the forum post above...) UPDATE (19/04/2013): Since my laptop was working fine, I decided it was about time to fix it. Also I wanted to

Using a Raspberry Pi as a MIDI USB/5-pin bridge

In my constant... need... to get everything music instrument related to communicate with each other, I wanted to look into ways to get some of my keyboards/synths with only MIDI over USB to talk to devices with regular good old-fashioned 5-pin MIDI ports from the eighties. Cables! First I had a quick look at off the shelf solutions. The most interesting one being the Kenton MIDI USB Host – providing MIDI host functionality for USB devices as well as regular MIDI in and out in a small box. Unfortunately it is rather expensive (~125 €) and a reliable online source warned me that it was not entirely stable in collaboration with my OP-1, so I started thinking of more... home-grown solutions. I decided to try to use my old Raspberry Pi and see if that would serve as a USB host with a borrowed MIDI USB adapter. (Thanks Simon.) A cheaper, and, as an added boon, a nerdier solution. Step 1: Get the USB MIDI device up and running This was the easy part. The device I have been lent