Metaballs
Once again I found out about this cool graphics thing from The Coding Train channel. The is one of the earlier videos in the series - "Coding Challenge #28: Metaballs". This one's about Metaballs. And I've used LÖVE 2D again to build it.
I watched the video, liked it a lot and given I'm studying graphics and making all these simulation things these days I thought I'm going to implement this in love2d. Without further ado, here's what the final simulation looks like:-
I've listed the entire source for the simulation in the Appendix. And the latest code is available at Explorations/metaballs.
What is a Metaball?
Here's the definition from wikipedia:
In computer graphics, metaballs, also known as blobby objects,are organic-looking n-dimensional isosurfaces, characterised by their ability to meld together when in close proximity to create single, contiguous objects.
So as per my understanding a Metaball visualization helps us create a image of a set of circular shaped objects melding into one with a single unified surface when they are close, and slowly separating out into individual shapes as they move farther from each other.
Concepts in this Demo
Isosurface
Simple definition of an isosurface is a surface where every pixel on the surface, or a canvas in our case, is a function of its x and y coordinates. The function can be any function of the position.
pixelColor = f(x, y)
Metaball Isosurface
Where the pixel is not just a function of the position, but also a function of its distance from one or more balls in the canvas. Since distance is already defined in terms of position of two objects. So effectively the pixel is a function of its distance from a ball.
pixelColor = f(d)
where,
d = distance(point, centerOfBall)
Inverse Square Law
One of the functions possible for the Metaball Isosurface is the
Inverse Square Law. The value of an inverse square law function is
inversely proportional to the square of the distance. So in our case lets say
d
is the distance between a pixel and the center of a ball on the canvas.
Then our inverse square law isosurface function could be:
pixelColor = 1/(d^2)
NOTE: In physics for e.g., the gravitational force is inversely proportional to the square of the distance between two bodies.
Typical Metaball Function
A typical function chosen for the the Metaball is actually just an inverse
of the distance i.e. 1/d
mutliplied with the radius of the metaball say r
.
This would be:
pixelColor = r/d
Future Plans
If possible I want to do it entirely in shaders someday. I know that my "shader-foo" is quite weak at the moment, but I would love to be able to improve the performance of this simulation, as it looks quite parallelizable.
Code
conf.lua
--- conf.lua: Config for the love2d game.
--
-- date: 1/3/2024
-- author: Abhishek Mishra
-- canvas size
local canvasWidth = 1024
local canvasHeight = 768
function love.conf(t)
-- set the window title
t.window.title = "Metaballs Simulation"
-- set the window size
t.window.width = canvasWidth
t.window.height = canvasHeight
-- disable unused modules for performance
t.modules.joystick = false
t.modules.physics = false
t.modules.touch = false
-- enable console
-- TODO: turning on console crashes Love2D on Windows,
-- so it's disabled for now
-- t.console = true
end
main.lua
--- main.lua: A Metaballs Simulation in LÖVE
-- TODO: Add a description of the program here
-- date: 1/3/2024
-- author: Abhishek Mishra
-- require middleclass
local class = require("middleclass")
--- Metaball: A class to represent a metaball
local Metaball = class("Metaball")
--- Metaball:initialize: Constructor for the Metaball class
function Metaball:initialize(x, y, r, v)
self.x = x
self.y = y
self.r = r
-- velocity
self.v = v or { x = 200, y = 50 }
end
--- Metaball:draw: Draw the metaball
function Metaball:draw()
-- draw the metaball
love.graphics.setColor(1, 0, 0, 1)
love.graphics.circle("line", self.x, self.y, self.r)
end
--- Metaball:update: Update the metaball
function Metaball:update(dt)
-- get the canvas size
local cw = love.graphics.getWidth()
local ch = love.graphics.getHeight()
-- bounce off the walls
if (self.x - self.r) < 0 or (self.x + self.r) > cw then
self.v.x = -(self.v.x)
end
if (self.y - self.r) < 0 or (self.y + self.r) > ch then
self.v.y = -(self.v.y)
end
-- check if position is beyond the canvas then clamp it
if self.x < self.r then
self.x = self.r
elseif self.x > cw - self.r then
self.x = cw - self.r
end
if self.y < self.r then
self.y = self.r
elseif self.y > ch - self.r then
self.y = ch - self.r
end
-- update the position
self.x = self.x + self.v.x * dt
self.y = self.y + self.v.y * dt
end
local balls
local numBalls = 5
--- love.load: Called once at the start of the simulation
function love.load()
-- get the canvas size
local cw = love.graphics.getWidth()
local ch = love.graphics.getHeight()
balls = {}
-- create the metaballs
for i = 1, numBalls do
local r = love.math.random(ch / 16, ch / 4)
local x = love.math.random(r, cw - r)
local y = love.math.random(r, ch - r)
local v = {
x = love.math.random(-cw / 3, cw / 3),
y = love.math.random(-ch / 3, ch / 3)
}
local b = Metaball:new(x, y, r, v)
table.insert(balls, b)
end
end
--- love.update: Called every frame, updates the simulation
function love.update(dt)
for _, b in ipairs(balls) do
b:update(dt)
end
end
--- love.draw: Called every frame, draws the simulation
function love.draw()
-- fill the background with black
love.graphics.setBackgroundColor(0, 0, 0)
-- get the canvas size
local cw = love.graphics.getWidth()
local ch = love.graphics.getHeight()
-- love.graphics.setColor(1, 1, 1, 1)
-- lets create an isosurface
local data = love.image.newImageData(cw, ch)
for i = 0, cw - 1 do -- remember: start at 0
for j = 0, ch - 1 do
local fSum = 0
-- calculate distance from the center of the metaball
for _, b in ipairs(balls) do
local d = math.sqrt((i - b.x) ^ 2 + (j - b.y) ^ 2)
fSum = fSum + (b.r / d)
end
-- normalize fSum
fSum = fSum / #balls
-- monochrome mode
-- local r = fSum; local g = fSum; local b = fSum
-- color mode
local r, g, b = HSV(fSum, 1, 1)
data:setPixel(i, j, r, g, b, 1)
end
end
-- create an image from the data
local img = love.graphics.newImage(data)
-- draw the image
love.graphics.draw(img, 0, 0)
-- -- draw the metaballs
-- for _, b in ipairs(balls) do
-- b:draw()
-- end
end
-- escape to exit
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
end
--- Converts HSV to RGB. (input and output range: 0 - 1)
-- see https://love2d.org/wiki/HSV_color
function HSV(h, s, v)
if s <= 0 then return v, v, v end
h = h * 6
local c = v * s
local x = (1 - math.abs((h % 2) - 1)) * c
local m, r, g, b = (v - c), 0, 0, 0
if h < 1 then
r, g, b = c, x, 0
elseif h < 2 then
r, g, b = x, c, 0
elseif h < 3 then
r, g, b = 0, c, x
elseif h < 4 then
r, g, b = 0, x, c
elseif h < 5 then
r, g, b = x, 0, c
else
r, g, b = c, 0, x
end
return r + m, g + m, b + m
end