ChatGPT cooked up a fun little demo of something called “metaballs”. It’s not much but it’s kind of neat. Code below.
-- Metaballs Demo using a Shader
-- This demo simulates several moving metaballs using a fragment shader on a full-screen mesh.
-- The metaball field is computed per pixel in the shader rather than via a CPU grid.
local metaballs = {}
local numBalls = 5
local threshold = 1.0
local metaballsMesh
-- Metaballs shader definition
MetaballsShader = {
vertexShader = [[
// Vertex shader for Metaballs
attribute vec4 position;
attribute vec2 texCoord;
varying vec2 vTexCoord;
uniform mat4 modelViewProjection;
void main() {
vTexCoord = texCoord;
gl_Position = modelViewProjection * position;
}
]],
fragmentShader = [[
// Fragment shader for Metaballs
precision mediump float;
varying vec2 vTexCoord;
uniform vec2 resolution;
uniform int numBalls;
uniform float threshold;
// We allow for up to 10 balls.
const int MAX_BALLS = 10;
// Each ball is encoded as (x, y, r) in ballData.
uniform vec3 ballData[MAX_BALLS];
// Each ball’s color is stored as a vec4.
uniform vec4 ballColor[MAX_BALLS];
void main() {
vec2 pos = vTexCoord * resolution;
float field = 0.0;
float bestContribution = 0.0;
int bestIndex = -1;
// Loop over each metaball (only the first numBalls are valid)
for (int i = 0; i < MAX_BALLS; i++) {
if (i < numBalls) {
vec3 ball = ballData[i];
float dx = pos.x - ball.x;
float dy = pos.y - ball.y;
float d2 = dx * dx + dy * dy + 1.0;
float contrib = (ball.z * ball.z) / d2;
field += contrib;
if (contrib > bestContribution) {
bestContribution = contrib;
bestIndex = i;
}
}
}
// If the summed field exceeds the threshold, use the best ball’s color.
if (field > threshold && bestIndex >= 0) {
gl_FragColor = ballColor[bestIndex];
} else {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}
}
]]
}
function setup()
math.randomseed(os.time())
-- Create metaballs with random positions, radii, velocities, and colors.
for i = 1, numBalls do
local ball = {
x = math.random(WIDTH),
y = math.random(HEIGHT),
r = math.random(30,70),
dx = math.random(-2,2),
dy = math.random(-2,2),
col = color(math.random(100,255), math.random(100,255), math.random(100,255))
}
table.insert(metaballs, ball)
end
-- Create a full-screen mesh (a simple quad covering the screen)
metaballsMesh = mesh()
metaballsMesh.vertices = {
vec2(0, 0),
vec2(WIDTH, 0),
vec2(WIDTH, HEIGHT),
vec2(0, HEIGHT),
vec2(WIDTH, HEIGHT),
vec2(0, 0)
}
metaballsMesh.texCoords = {
vec2(0,0),
vec2(1,0),
vec2(1,1),
vec2(0,1),
vec2(1,1),
vec2(0,0)
}
-- Create and assign the shader to the mesh.
metaballsMesh.shader = shader(MetaballsShader.vertexShader, MetaballsShader.fragmentShader)
noSmooth()
end
-- Update metaball positions and bounce them off screen edges.
function updateMetaballs()
for i, ball in ipairs(metaballs) do
ball.x = ball.x + ball.dx
ball.y = ball.y + ball.dy
if ball.x < 0 or ball.x > WIDTH then
ball.dx = -ball.dx
ball.x = math.max(0, math.min(WIDTH, ball.x))
end
if ball.y < 0 or ball.y > HEIGHT then
ball.dy = -ball.dy
ball.y = math.max(0, math.min(HEIGHT, ball.y))
end
end
end
function draw()
background(40)
updateMetaballs()
-- Prepare uniform arrays for the shader.
local ballData = {}
local ballColor = {}
for i = 1, numBalls do
local ball = metaballs[i]
table.insert(ballData, vec3(ball.x, ball.y, ball.r))
-- Convert Codea’s color (0-255) to normalized RGBA.
local r, g, b, a = ball.col.r, ball.col.g, ball.col.b, ball.col.a
table.insert(ballColor, vec4(r/255, g/255, b/255, a/255))
end
-- Update the shader uniforms.
local s = metaballsMesh.shader
s.numBalls = numBalls
s.threshold = threshold
s.resolution = vec2(WIDTH, HEIGHT)
s.ballData = ballData
s.ballColor = ballColor
-- Draw the full-screen mesh using the metaballs shader.
metaballsMesh:draw()
fill(255)
fontSize(16)
text("Metaballs Demo", WIDTH/2, 20)
end