Skip to content

Instantly share code, notes, and snippets.

@DedeHai
Created January 5, 2026 05:10
Show Gist options
  • Select an option

  • Save DedeHai/2bf680f9321c379daa54f84aed5097a4 to your computer and use it in GitHub Desktop.

Select an option

Save DedeHai/2bf680f9321c379daa54f84aed5097a4 to your computer and use it in GitHub Desktop.
Proof of concept of a lava-lamp blob pixel FX
# disclaimer: fully AI generated code, it works just to demonstrate the visual idea
import pygame
import random
import math
import numpy as np
from scipy.ndimage import gaussian_filter
# Initialize Pygame
pygame.init()
# Constants
GRID_SIZE = 64
CELL_SIZE = 8
WINDOW_SIZE = GRID_SIZE * CELL_SIZE
FPS = 30
# Colors
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
class Particle:
def __init__(self, x, y, color):
self.x = x
self.y = y
self.color = color
self.blob_id = 0
self.vx = random.uniform(-0.3, 0.3)
self.vy = random.uniform(-0.3, 0.3)
def update(self):
self.x += self.vx
self.y += self.vy
# Bounce off edges
if self.x < 2 or self.x >= GRID_SIZE - 2:
self.vx *= -1
self.x = max(2, min(GRID_SIZE - 3, self.x))
if self.y < 2 or self.y >= GRID_SIZE - 2:
self.vy *= -1
self.y = max(2, min(GRID_SIZE - 3, self.y))
def assign_blob_ids(particles, max_distance):
"""Assign blob IDs to particles based on proximity"""
next_blob_id = 1
# Initialize all to unblobbed
for p in particles:
p.blob_id = 0
# For each unassigned particle, start a new blob
for i, particle in enumerate(particles):
if particle.blob_id != 0:
continue
particle.blob_id = next_blob_id
# Flood fill through nearby particles
changed = True
while changed:
changed = False
for j, p_unassigned in enumerate(particles):
if p_unassigned.blob_id != 0:
continue
# Check if close to any particle in current blob
for k, p_in_blob in enumerate(particles):
if p_in_blob.blob_id != next_blob_id:
continue
dx = p_unassigned.x - p_in_blob.x
dy = p_unassigned.y - p_in_blob.y
dist = math.sqrt(dx*dx + dy*dy)
if dist <= max_distance:
p_unassigned.blob_id = next_blob_id
changed = True
break
next_blob_id += 1
return next_blob_id - 1
def metaball_influence(dist, radius):
"""Calculate metaball influence at given distance"""
if dist >= radius:
return 0.0
# Classic metaball function: 1 - (d/r)^2
normalized = dist / radius
return max(0, 1.0 - normalized * normalized)
def render_metaballs(particles, grid_size, blob_radius=6.0, threshold=0.5, smoothing=1.0):
"""Render particles as metaballs with smooth color blending"""
# Step 1: Create per-particle intensity and color fields
particle_fields = []
for p in particles:
field = np.zeros((grid_size, grid_size), dtype=np.float32)
cx, cy = int(p.x), int(p.y)
search_radius = int(blob_radius * 2)
for dy in range(-search_radius, search_radius + 1):
for dx in range(-search_radius, search_radius + 1):
x, y = cx + dx, cy + dy
if 0 <= x < grid_size and 0 <= y < grid_size:
dist = math.sqrt(dx*dx + dy*dy)
influence = metaball_influence(dist, blob_radius)
field[y, x] = influence
particle_fields.append((p, field))
# Step 2: Combine fields per blob
blob_data = {}
for blob_id in set(p.blob_id for p in particles if p.blob_id > 0):
blob_particles = [(p, field) for p, field in particle_fields if p.blob_id == blob_id]
# Sum intensity field
combined_field = np.zeros((grid_size, grid_size), dtype=np.float32)
for p, field in blob_particles:
combined_field += field
blob_data[blob_id] = {
'particles': [p for p, _ in blob_particles],
'field': combined_field
}
# Step 3: Create color field with smooth transitions
color_field = np.zeros((grid_size, grid_size, 3), dtype=np.float32)
mask = np.zeros((grid_size, grid_size), dtype=np.float32)
for blob_id, data in blob_data.items():
field = data['field']
blob_particles = data['particles']
for y in range(grid_size):
for x in range(grid_size):
intensity = field[y, x]
if intensity > threshold:
# Calculate color based on weighted average of nearby particles
total_weight = 0.0
weighted_color = np.zeros(3, dtype=np.float32)
for p in blob_particles:
dx = x - p.x
dy = y - p.y
dist = math.sqrt(dx*dx + dy*dy)
# Weight by inverse distance and particle influence
if dist < blob_radius * 1.5:
weight = metaball_influence(dist, blob_radius * 1.5)
weighted_color += np.array(p.color, dtype=np.float32) * weight
total_weight += weight
if total_weight > 0:
final_color = weighted_color / total_weight
# Edge smoothing: fade based on how far above threshold
edge_factor = min(1.0, (intensity - threshold) / (0.3))
color_field[y, x] = final_color * edge_factor
mask[y, x] = edge_factor
# Step 4: Apply gaussian blur for ultra-smooth edges
if smoothing > 0:
for channel in range(3):
color_field[:, :, channel] = gaussian_filter(color_field[:, :, channel], sigma=smoothing)
mask = gaussian_filter(mask, sigma=smoothing)
# Step 5: Apply mask and finalize
grid = np.zeros((grid_size, grid_size, 3), dtype=np.float32)
for y in range(grid_size):
for x in range(grid_size):
if mask[y, x] > 0.1:
grid[y, x] = color_field[y, x] * mask[y, x]
# Clamp values to 0-255
grid = np.clip(grid, 0, 255).astype(np.uint8)
return grid
def main():
screen = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE))
pygame.display.set_caption("Metaballs Lava Lamp Effect")
clock = pygame.time.Clock()
# Create particles with lava lamp colors
particles = []
# Orange/red blobs
for _ in range(12):
x = random.uniform(10, 30)
y = random.uniform(10, 30)
particles.append(Particle(x, y, (255, 100, 0)))
# Pink/magenta blobs
for _ in range(10):
x = random.uniform(34, 54)
y = random.uniform(10, 30)
particles.append(Particle(x, y, (255, 50, 150)))
# Purple blobs
for _ in range(11):
x = random.uniform(20, 40)
y = random.uniform(34, 54)
particles.append(Particle(x, y, (150, 50, 255)))
# Yellow blobs
for _ in range(8):
x = random.uniform(40, 54)
y = random.uniform(40, 54)
particles.append(Particle(x, y, (255, 200, 0)))
max_distance = 10.0
blob_radius = 7.0
threshold = 0.6
smoothing = 0.8
running = True
show_debug = False
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_d:
show_debug = not show_debug
elif event.key == pygame.K_UP:
blob_radius = min(12.0, blob_radius + 0.5)
elif event.key == pygame.K_DOWN:
blob_radius = max(2.0, blob_radius - 0.5)
elif event.key == pygame.K_RIGHT:
max_distance = min(20.0, max_distance + 1.0)
elif event.key == pygame.K_LEFT:
max_distance = max(2.0, max_distance - 1.0)
elif event.key == pygame.K_t:
threshold = min(1.0, threshold + 0.05)
elif event.key == pygame.K_g:
threshold = max(0.1, threshold - 0.05)
elif event.key == pygame.K_s:
smoothing = min(3.0, smoothing + 0.2)
elif event.key == pygame.K_a:
smoothing = max(0.0, smoothing - 0.2)
# Update particles
for particle in particles:
particle.update()
# Assign blob IDs
num_blobs = assign_blob_ids(particles, max_distance)
# Render the metaballs effect
grid = render_metaballs(particles, GRID_SIZE, blob_radius, threshold, smoothing)
# Clear screen
screen.fill(BLACK)
# Draw pixelated grid
for y in range(GRID_SIZE):
for x in range(GRID_SIZE):
color = tuple(grid[y, x])
if sum(color) > 0: # Only draw non-black pixels
pygame.draw.rect(screen, color,
(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE))
# Draw debug overlay if enabled
if show_debug:
# Draw grid lines
for i in range(0, GRID_SIZE + 1, 8):
pygame.draw.line(screen, (50, 50, 50),
(i * CELL_SIZE, 0),
(i * CELL_SIZE, WINDOW_SIZE))
pygame.draw.line(screen, (50, 50, 50),
(0, i * CELL_SIZE),
(WINDOW_SIZE, i * CELL_SIZE))
# Draw particle positions
for particle in particles:
pygame.draw.circle(screen, WHITE,
(int(particle.x * CELL_SIZE), int(particle.y * CELL_SIZE)),
3)
# Draw info text
font = pygame.font.Font(None, 24)
text = font.render(f"Blobs: {num_blobs} | Particles: {len(particles)}", True, WHITE)
screen.blit(text, (10, 10))
info_text = font.render(f"Radius: {blob_radius:.1f} | Dist: {max_distance:.1f} | Thresh: {threshold:.2f} | Smooth: {smoothing:.1f}", True, WHITE)
screen.blit(info_text, (10, 35))
controls1 = font.render(f"Arrows: Radius/Dist | T/G: Threshold | S/A: Smoothing", True, WHITE)
screen.blit(controls1, (10, 60))
controls2 = font.render(f"D: Debug ({show_debug})", True, WHITE)
screen.blit(controls2, (10, 85))
pygame.display.flip()
clock.tick(FPS)
pygame.quit()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment