Created
January 5, 2026 05:10
-
-
Save DedeHai/2bf680f9321c379daa54f84aed5097a4 to your computer and use it in GitHub Desktop.
Proof of concept of a lava-lamp blob pixel FX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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